Skip to content

Commit 2bdeab4

Browse files
authored
refactor(config): migrate storage from JSON to SQLite (#89)
## Summary Migrates CLI configuration storage from `~/.sentry/config.json` to `~/.sentry/cli.db` (SQLite) for better concurrency safety. ## Changes - **New `src/lib/db/` module** with separate files for each domain: - `schema.ts` - DDL with schema versioning - `auth.ts` - Token management with refresh logic - `defaults.ts` - Default org/project settings - `project-cache.ts` - Project metadata cache (7-day TTL) - `dsn-cache.ts` - DSN resolution cache - `project-aliases.ts` - Short ID alias mappings - `migration.ts` - One-time JSON to SQLite migration - **SQLite configuration**: - WAL mode for better concurrency - 100ms busy timeout (fast fail for CLI responsiveness) - Secure file permissions (0o600) - **Node.js compatibility**: Added `bun:sqlite` → `node:sqlite` polyfill for npm bundle - **Backward compatible API**: Existing `src/lib/config.ts` re-exports from new modules ## Migration On first run after upgrade, the CLI automatically migrates data from `config.json` to `cli.db` and deletes the old file. Closes #76
1 parent e77a991 commit 2bdeab4

39 files changed

Lines changed: 1912 additions & 889 deletions

.github/workflows/ci.yml

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ jobs:
3737
id: cache
3838
with:
3939
path: node_modules
40-
key: node-modules-${{ hashFiles('bun.lock') }}
40+
key: node-modules-${{ hashFiles('bun.lock', 'patches/**') }}
4141
- if: steps.cache.outputs.cache-hit != 'true'
4242
run: bun install --frozen-lockfile
4343
- run: bun run lint
@@ -57,7 +57,7 @@ jobs:
5757
id: cache
5858
with:
5959
path: node_modules
60-
key: node-modules-${{ hashFiles('bun.lock') }}
60+
key: node-modules-${{ hashFiles('bun.lock', 'patches/**') }}
6161
- if: steps.cache.outputs.cache-hit != 'true'
6262
run: bun install --frozen-lockfile
6363
- name: Unit Tests
@@ -100,7 +100,7 @@ jobs:
100100
id: cache
101101
with:
102102
path: node_modules
103-
key: node-modules-${{ matrix.os }}-${{ hashFiles('bun.lock') }}
103+
key: node-modules-${{ matrix.os }}-${{ hashFiles('bun.lock', 'patches/**') }}
104104
- if: steps.cache.outputs.cache-hit != 'true'
105105
run: bun install --frozen-lockfile
106106
- name: Build
@@ -133,7 +133,7 @@ jobs:
133133
id: cache
134134
with:
135135
path: node_modules
136-
key: node-modules-${{ hashFiles('bun.lock') }}
136+
key: node-modules-${{ hashFiles('bun.lock', 'patches/**') }}
137137
- if: steps.cache.outputs.cache-hit != 'true'
138138
run: bun install --frozen-lockfile
139139
- name: Download Linux binary
@@ -167,7 +167,7 @@ jobs:
167167
id: cache
168168
with:
169169
path: node_modules
170-
key: node-modules-${{ hashFiles('bun.lock') }}
170+
key: node-modules-${{ hashFiles('bun.lock', 'patches/**') }}
171171
- if: steps.cache.outputs.cache-hit != 'true'
172172
run: bun install --frozen-lockfile
173173
- name: Bundle

biome.jsonc

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,28 @@
2525
}
2626
}
2727
}
28+
},
29+
{
30+
// SQLite operations are sync but we keep async signatures for API compatibility
31+
"includes": ["src/lib/db/**/*.ts"],
32+
"linter": {
33+
"rules": {
34+
"suspicious": {
35+
"useAwait": "off"
36+
}
37+
}
38+
}
39+
},
40+
{
41+
// db/index.ts exports db connection utilities - not a barrel file but triggers the rule
42+
"includes": ["src/lib/db/index.ts"],
43+
"linter": {
44+
"rules": {
45+
"performance": {
46+
"noBarrelFile": "off"
47+
}
48+
}
49+
}
2850
}
2951
]
3052
}

script/bundle.ts

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,38 @@ if (!SENTRY_CLIENT_ID) {
2929
process.exit(1);
3030
}
3131

32+
// Regex patterns for esbuild plugin (must be top-level for performance)
33+
const BUN_SQLITE_FILTER = /^bun:sqlite$/;
34+
const ANY_FILTER = /.*/;
35+
36+
// Plugin to replace bun:sqlite with our node:sqlite polyfill
37+
const bunSqlitePlugin: Plugin = {
38+
name: "bun-sqlite-polyfill",
39+
setup(pluginBuild) {
40+
// Intercept imports of "bun:sqlite" and redirect to our polyfill
41+
pluginBuild.onResolve({ filter: BUN_SQLITE_FILTER }, () => ({
42+
path: "bun:sqlite",
43+
namespace: "bun-sqlite-polyfill",
44+
}));
45+
46+
// Provide the polyfill content
47+
pluginBuild.onLoad(
48+
{ filter: ANY_FILTER, namespace: "bun-sqlite-polyfill" },
49+
() => ({
50+
contents: `
51+
// Use the polyfill injected by node-polyfills.ts
52+
const polyfill = globalThis.__bun_sqlite_polyfill;
53+
export const Database = polyfill.Database;
54+
export default polyfill;
55+
`,
56+
loader: "js",
57+
})
58+
);
59+
},
60+
};
61+
3262
// Configure Sentry plugin for source map uploads (production builds only)
33-
const plugins: Plugin[] = [];
63+
const plugins: Plugin[] = [bunSqlitePlugin];
3464

3565
if (process.env.SENTRY_AUTH_TOKEN) {
3666
console.log(" Sentry auth token found, source maps will be uploaded");

script/node-polyfills.ts

Lines changed: 52 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,66 @@
11
/**
2-
* Node.js polyfills for Bun APIs
3-
*
4-
* Injected at esbuild bundle time to provide Node.js-compatible
5-
* implementations of Bun globals. This allows the same source code
6-
* to run on both Bun (native) and Node.js (polyfilled).
2+
* Node.js polyfills for Bun APIs. Injected at bundle time via esbuild.
73
*/
84
import { execSync, spawn as nodeSpawn } from "node:child_process";
95
import { access, readFile, writeFile } from "node:fs/promises";
6+
import { DatabaseSync } from "node:sqlite";
107

118
import { glob } from "tinyglobby";
129

1310
declare global {
1411
var Bun: typeof BunPolyfill;
1512
}
1613

14+
type SqliteValue = string | number | bigint | null | Uint8Array;
15+
16+
/** Wraps node:sqlite StatementSync to match bun:sqlite query() API. */
17+
class NodeStatementPolyfill {
18+
private readonly stmt: ReturnType<DatabaseSync["prepare"]>;
19+
20+
constructor(stmt: ReturnType<DatabaseSync["prepare"]>) {
21+
this.stmt = stmt;
22+
}
23+
24+
get(...params: SqliteValue[]): Record<string, SqliteValue> | undefined {
25+
return this.stmt.get(...params) as Record<string, SqliteValue> | undefined;
26+
}
27+
28+
all(...params: SqliteValue[]): Record<string, SqliteValue>[] {
29+
return this.stmt.all(...params) as Record<string, SqliteValue>[];
30+
}
31+
32+
run(...params: SqliteValue[]): void {
33+
this.stmt.run(...params);
34+
}
35+
}
36+
37+
/** Wraps node:sqlite DatabaseSync to match bun:sqlite Database API. */
38+
class NodeDatabasePolyfill {
39+
private readonly db: DatabaseSync;
40+
41+
constructor(path: string) {
42+
// SQLite configuration (busy_timeout, foreign_keys, WAL mode) is applied
43+
// via PRAGMA statements in src/lib/db/index.ts after construction
44+
this.db = new DatabaseSync(path);
45+
}
46+
47+
exec(sql: string): void {
48+
this.db.exec(sql);
49+
}
50+
51+
query(sql: string): NodeStatementPolyfill {
52+
return new NodeStatementPolyfill(this.db.prepare(sql));
53+
}
54+
55+
close(): void {
56+
this.db.close();
57+
}
58+
}
59+
60+
const bunSqlitePolyfill = { Database: NodeDatabasePolyfill };
61+
(globalThis as Record<string, unknown>).__bun_sqlite_polyfill =
62+
bunSqlitePolyfill;
63+
1764
const BunPolyfill = {
1865
file(path: string) {
1966
return {
@@ -58,8 +105,6 @@ const BunPolyfill = {
58105
opts?: { stdout?: "pipe" | "ignore"; stderr?: "pipe" | "ignore" }
59106
) {
60107
const [command, ...args] = cmd;
61-
// Map Bun's stdout/stderr options to Node's stdio array format
62-
// Currently only supports "ignore" - "pipe" would require returning streams
63108
const stdio: ("pipe" | "ignore")[] = [
64109
"ignore", // stdin
65110
opts?.stdout ?? "ignore",

src/commands/auth/login.ts

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,8 @@ import type { SentryContext } from "../../context.js";
33
import { listOrganizations } from "../../lib/api-client.js";
44
import { openBrowser } from "../../lib/browser.js";
55
import { setupCopyKeyListener } from "../../lib/clipboard.js";
6-
import {
7-
clearAuth,
8-
getConfigPath,
9-
isAuthenticated,
10-
setAuthToken,
11-
} from "../../lib/config.js";
6+
import { clearAuth, isAuthenticated, setAuthToken } from "../../lib/db/auth.js";
7+
import { getDbPath } from "../../lib/db/index.js";
128
import { AuthError } from "../../lib/errors.js";
139
import { muted, success } from "../../lib/formatters/colors.js";
1410
import { formatDuration } from "../../lib/formatters/human.js";
@@ -74,7 +70,7 @@ export const loginCommand = buildCommand({
7470
}
7571

7672
stdout.write(`${success("✓")} Authenticated with API token\n`);
77-
stdout.write(` Config saved to: ${getConfigPath()}\n`);
73+
stdout.write(` Config saved to: ${getDbPath()}\n`);
7874
return;
7975
}
8076

@@ -138,7 +134,7 @@ export const loginCommand = buildCommand({
138134
await completeOAuthFlow(tokenResponse);
139135

140136
stdout.write(`${success("✓")} Authentication successful!\n`);
141-
stdout.write(` Config saved to: ${getConfigPath()}\n`);
137+
stdout.write(` Config saved to: ${getDbPath()}\n`);
142138

143139
if (tokenResponse.expires_in) {
144140
stdout.write(

src/commands/auth/logout.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@
66

77
import { buildCommand } from "@stricli/core";
88
import type { SentryContext } from "../../context.js";
9-
import { clearAuth, getConfigPath, isAuthenticated } from "../../lib/config.js";
9+
import { clearAuth, isAuthenticated } from "../../lib/db/auth.js";
10+
import { getDbPath } from "../../lib/db/index.js";
1011
import { success } from "../../lib/formatters/colors.js";
1112

1213
export const logoutCommand = buildCommand({
@@ -28,6 +29,6 @@ export const logoutCommand = buildCommand({
2829

2930
await clearAuth();
3031
stdout.write(`${success("✓")} Logged out successfully.\n`);
31-
stdout.write(` Credentials removed from: ${getConfigPath()}\n`);
32+
stdout.write(` Credentials removed from: ${getDbPath()}\n`);
3233
},
3334
});

src/commands/auth/refresh.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
import { buildCommand } from "@stricli/core";
88
import type { SentryContext } from "../../context.js";
9-
import { readConfig, refreshToken } from "../../lib/config.js";
9+
import { getAuthConfig, refreshToken } from "../../lib/db/auth.js";
1010
import { AuthError } from "../../lib/errors.js";
1111
import { success } from "../../lib/formatters/colors.js";
1212
import { formatDuration } from "../../lib/formatters/human.js";
@@ -63,8 +63,8 @@ Examples:
6363
const { stdout } = this;
6464

6565
// Pre-check for refresh token availability (better error message)
66-
const config = await readConfig();
67-
if (!config.auth?.refreshToken && config.auth?.token) {
66+
const auth = await getAuthConfig();
67+
if (!auth?.refreshToken && auth?.token) {
6868
throw new AuthError(
6969
"invalid",
7070
"No refresh token available. You may be using a manual API token.\n" +

src/commands/auth/status.ts

Lines changed: 17 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -8,16 +8,19 @@ import { buildCommand } from "@stricli/core";
88
import type { SentryContext } from "../../context.js";
99
import { listOrganizations } from "../../lib/api-client.js";
1010
import {
11-
getConfigPath,
11+
type AuthConfig,
12+
getAuthConfig,
13+
isAuthenticated,
14+
} from "../../lib/db/auth.js";
15+
import {
1216
getDefaultOrganization,
1317
getDefaultProject,
14-
isAuthenticated,
15-
readConfig,
16-
} from "../../lib/config.js";
18+
} from "../../lib/db/defaults.js";
19+
import { getDbPath } from "../../lib/db/index.js";
1720
import { AuthError } from "../../lib/errors.js";
1821
import { error, success } from "../../lib/formatters/colors.js";
1922
import { formatExpiration, maskToken } from "../../lib/formatters/human.js";
20-
import type { SentryConfig, Writer } from "../../types/index.js";
23+
import type { Writer } from "../../types/index.js";
2124

2225
type StatusFlags = {
2326
readonly showToken: boolean;
@@ -28,24 +31,22 @@ type StatusFlags = {
2831
*/
2932
function writeTokenInfo(
3033
stdout: Writer,
31-
config: SentryConfig,
34+
auth: AuthConfig | undefined,
3235
showToken: boolean
3336
): void {
34-
if (!config.auth?.token) {
37+
if (!auth?.token) {
3538
return;
3639
}
3740

38-
const tokenDisplay = showToken
39-
? config.auth.token
40-
: maskToken(config.auth.token);
41+
const tokenDisplay = showToken ? auth.token : maskToken(auth.token);
4142
stdout.write(`Token: ${tokenDisplay}\n`);
4243

43-
if (config.auth.expiresAt) {
44-
stdout.write(`Expires: ${formatExpiration(config.auth.expiresAt)}\n`);
44+
if (auth.expiresAt) {
45+
stdout.write(`Expires: ${formatExpiration(auth.expiresAt)}\n`);
4546
}
4647

4748
// Show refresh token status
48-
if (config.auth.refreshToken) {
49+
if (auth.refreshToken) {
4950
stdout.write(`Auto-refresh: ${success("enabled")}\n`);
5051
} else {
5152
stdout.write("Auto-refresh: disabled (no refresh token)\n");
@@ -119,18 +120,18 @@ export const statusCommand = buildCommand({
119120
async func(this: SentryContext, flags: StatusFlags): Promise<void> {
120121
const { stdout, stderr } = this;
121122

122-
const config = await readConfig();
123+
const auth = await getAuthConfig();
123124
const authenticated = await isAuthenticated();
124125

125-
stdout.write(`Config file: ${getConfigPath()}\n\n`);
126+
stdout.write(`Config: ${getDbPath()}\n\n`);
126127

127128
if (!authenticated) {
128129
throw new AuthError("not_authenticated");
129130
}
130131

131132
stdout.write(`Status: Authenticated ${success("✓")}\n\n`);
132133

133-
writeTokenInfo(stdout, config, flags.showToken);
134+
writeTokenInfo(stdout, auth, flags.showToken);
134135
await writeDefaults(stdout);
135136
await verifyCredentials(stdout, stderr);
136137
},

src/commands/issue/list.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,10 @@ import { buildCommand, numberParser } from "@stricli/core";
99
import type { SentryContext } from "../../context.js";
1010
import { buildOrgAwareAliases } from "../../lib/alias.js";
1111
import { listIssues } from "../../lib/api-client.js";
12-
import { clearProjectAliases, setProjectAliases } from "../../lib/config.js";
12+
import {
13+
clearProjectAliases,
14+
setProjectAliases,
15+
} from "../../lib/db/project-aliases.js";
1316
import { createDsnFingerprint } from "../../lib/dsn/index.js";
1417
import { AuthError, ContextError } from "../../lib/errors.js";
1518
import {

src/commands/issue/utils.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import {
1010
getIssue,
1111
getIssueByShortId,
1212
} from "../../lib/api-client.js";
13-
import { getProjectByAlias } from "../../lib/config.js";
13+
import { getProjectByAlias } from "../../lib/db/project-aliases.js";
1414
import { createDsnFingerprint, detectAllDsns } from "../../lib/dsn/index.js";
1515
import { ContextError } from "../../lib/errors.js";
1616
import { getProgressMessage } from "../../lib/formatters/seer.js";

0 commit comments

Comments
 (0)