Skip to content

Commit eb05488

Browse files
authored
feat(db): add schema repair and sentry cli fix command (#197)
## Summary Adds automatic schema repair for SQLite database migration issues. This fixes cases where the schema version gets out of sync with actual table structure during development. ### Changes **Auto-Repair on SQLiteError** - When a query fails due to missing columns/tables, the CLI automatically repairs the schema and retries - Works at both query preparation (`db.query()`) and execution (`stmt.get()`) stages - Logs repairs to stderr without breaking JSON output - Opt-out via `SENTRY_CLI_NO_AUTO_REPAIR=1` **`sentry cli fix` Command** ```bash sentry cli fix # Diagnose and repair schema issues sentry cli fix --dry-run # Show what would be fixed ``` **Schema Repair Utilities** - `EXPECTED_TABLES` / `EXPECTED_COLUMNS` - Canonical schema definitions - `getSchemaIssues()` - Diagnostics for missing tables/columns - `repairSchema()` - Non-destructive repair (only adds, never deletes) ### Testing - 14 new tests for schema repair functions - 5 new tests for fix command - All 997 tests pass
1 parent 12a1ea4 commit eb05488

8 files changed

Lines changed: 1235 additions & 165 deletions

File tree

plugins/sentry-cli/skills/sentry-cli/SKILL.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -390,6 +390,13 @@ CLI-related commands
390390

391391
Send feedback about the CLI
392392

393+
#### `sentry cli fix`
394+
395+
Diagnose and repair CLI database issues
396+
397+
**Flags:**
398+
- `--dry-run - Show what would be fixed without making changes`
399+
393400
#### `sentry cli upgrade <version>`
394401

395402
Update the Sentry CLI to the latest version

src/commands/cli/fix.ts

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
/**
2+
* sentry cli fix
3+
*
4+
* Diagnose and repair CLI database issues.
5+
*/
6+
7+
import { buildCommand } from "@stricli/core";
8+
import type { SentryContext } from "../../context.js";
9+
import { getDbPath, getRawDatabase } from "../../lib/db/index.js";
10+
import {
11+
CURRENT_SCHEMA_VERSION,
12+
getSchemaIssues,
13+
repairSchema,
14+
type SchemaIssue,
15+
} from "../../lib/db/schema.js";
16+
17+
type FixFlags = {
18+
readonly "dry-run": boolean;
19+
};
20+
21+
function formatIssue(issue: SchemaIssue): string {
22+
if (issue.type === "missing_table") {
23+
return `Missing table: ${issue.table}`;
24+
}
25+
return `Missing column: ${issue.table}.${issue.column}`;
26+
}
27+
28+
export const fixCommand = buildCommand({
29+
docs: {
30+
brief: "Diagnose and repair CLI database issues",
31+
fullDescription:
32+
"Check the CLI's local SQLite database for schema issues and repair them.\n\n" +
33+
"This is useful when upgrading from older CLI versions or if the database\n" +
34+
"becomes inconsistent due to interrupted operations.\n\n" +
35+
"The command performs non-destructive repairs only - it adds missing tables\n" +
36+
"and columns but never deletes data.\n\n" +
37+
"Examples:\n" +
38+
" sentry cli fix # Fix database issues\n" +
39+
" sentry cli fix --dry-run # Show what would be fixed without making changes",
40+
},
41+
parameters: {
42+
flags: {
43+
"dry-run": {
44+
kind: "boolean",
45+
brief: "Show what would be fixed without making changes",
46+
default: false,
47+
},
48+
},
49+
},
50+
func(this: SentryContext, flags: FixFlags): void {
51+
const { stdout, stderr, process: proc } = this;
52+
const dbPath = getDbPath();
53+
54+
stdout.write(`Database: ${dbPath}\n`);
55+
stdout.write(`Expected schema version: ${CURRENT_SCHEMA_VERSION}\n\n`);
56+
57+
const db = getRawDatabase();
58+
const issues = getSchemaIssues(db);
59+
60+
if (issues.length === 0) {
61+
stdout.write("No issues found. Database schema is up to date.\n");
62+
return;
63+
}
64+
65+
stdout.write(`Found ${issues.length} issue(s):\n`);
66+
for (const issue of issues) {
67+
stdout.write(` - ${formatIssue(issue)}\n`);
68+
}
69+
stdout.write("\n");
70+
71+
if (flags["dry-run"]) {
72+
stdout.write("Run 'sentry cli fix' to apply fixes.\n");
73+
return;
74+
}
75+
76+
stdout.write("Repairing...\n");
77+
const { fixed, failed } = repairSchema(db);
78+
79+
for (const fix of fixed) {
80+
stdout.write(` + ${fix}\n`);
81+
}
82+
83+
if (failed.length > 0) {
84+
stderr.write("\nSome repairs failed:\n");
85+
for (const fail of failed) {
86+
stderr.write(` ! ${fail}\n`);
87+
}
88+
stderr.write(
89+
`\nTry deleting the database and restarting: rm ${dbPath}\n`
90+
);
91+
proc.exitCode = 1;
92+
return;
93+
}
94+
95+
stdout.write("\nDatabase repaired successfully.\n");
96+
},
97+
});

src/commands/cli/index.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,18 @@
11
import { buildRouteMap } from "@stricli/core";
22
import { feedbackCommand } from "./feedback.js";
3+
import { fixCommand } from "./fix.js";
34
import { upgradeCommand } from "./upgrade.js";
45

56
export const cliRoute = buildRouteMap({
67
routes: {
78
feedback: feedbackCommand,
9+
fix: fixCommand,
810
upgrade: upgradeCommand,
911
},
1012
docs: {
1113
brief: "CLI-related commands",
1214
fullDescription:
13-
"Commands for managing the Sentry CLI itself, including sending feedback " +
14-
"and upgrading to newer versions.",
15+
"Commands for managing the Sentry CLI itself, including sending feedback, " +
16+
"upgrading to newer versions, and repairing the local database.",
1517
},
1618
});

src/lib/db/index.ts

Lines changed: 49 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,10 @@ const CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000;
2222
/** Probability of running cleanup on write operations */
2323
const CLEANUP_PROBABILITY = 0.1;
2424

25+
/** Traced database wrapper (returned by getDatabase) */
2526
let db: Database | null = null;
27+
/** Raw database without tracing (used for repair operations) */
28+
let rawDb: Database | null = null;
2629
let dbOpenedPath: string | null = null;
2730

2831
export function getConfigDir(): string {
@@ -69,6 +72,7 @@ export function getDatabase(): Database {
6972
if (db && dbOpenedPath !== dbPath) {
7073
db.close();
7174
db = null;
75+
rawDb = null;
7276
dbOpenedPath = null;
7377
}
7478

@@ -78,39 +82,64 @@ export function getDatabase(): Database {
7882

7983
ensureConfigDir();
8084

81-
const rawDb = new Database(dbPath);
85+
rawDb = new Database(dbPath);
8286

83-
// 5000ms busy_timeout prevents SQLITE_BUSY errors during concurrent CLI access.
84-
// When multiple CLI instances run simultaneously (e.g., parallel terminals, CI jobs),
85-
// SQLite needs time to acquire locks. WAL mode allows concurrent reads, but writers
86-
// must wait. Without sufficient timeout, concurrent processes fail immediately.
87-
// Set busy_timeout FIRST - before WAL mode - to handle lock contention during init.
88-
rawDb.exec("PRAGMA busy_timeout = 5000");
89-
rawDb.exec("PRAGMA journal_mode = WAL");
90-
rawDb.exec("PRAGMA foreign_keys = ON");
91-
rawDb.exec("PRAGMA synchronous = NORMAL");
92-
93-
setDbPermissions();
94-
initSchema(rawDb);
95-
runMigrations(rawDb);
96-
migrateFromJson(rawDb);
97-
98-
// Wrap with tracing proxy for automatic query instrumentation
99-
db = createTracedDatabase(rawDb);
100-
dbOpenedPath = dbPath;
87+
try {
88+
// 5000ms busy_timeout prevents SQLITE_BUSY errors during concurrent CLI access.
89+
// When multiple CLI instances run simultaneously (e.g., parallel terminals, CI jobs),
90+
// SQLite needs time to acquire locks. WAL mode allows concurrent reads, but writers
91+
// must wait. Without sufficient timeout, concurrent processes fail immediately.
92+
// Set busy_timeout FIRST - before WAL mode - to handle lock contention during init.
93+
rawDb.exec("PRAGMA busy_timeout = 5000");
94+
rawDb.exec("PRAGMA journal_mode = WAL");
95+
rawDb.exec("PRAGMA foreign_keys = ON");
96+
rawDb.exec("PRAGMA synchronous = NORMAL");
97+
98+
setDbPermissions();
99+
initSchema(rawDb);
100+
runMigrations(rawDb);
101+
migrateFromJson(rawDb);
102+
103+
// Wrap with tracing proxy for automatic query instrumentation
104+
db = createTracedDatabase(rawDb);
105+
dbOpenedPath = dbPath;
101106

102-
return db;
107+
return db;
108+
} catch (error) {
109+
// Clean up on initialization failure to prevent connection leak
110+
rawDb.close();
111+
rawDb = null;
112+
throw error;
113+
}
103114
}
104115

105116
/** Close the database connection (used for testing). */
106117
export function closeDatabase(): void {
107118
if (db) {
108119
db.close();
109120
db = null;
121+
rawDb = null;
110122
dbOpenedPath = null;
111123
}
112124
}
113125

126+
/**
127+
* Get the raw (unwrapped) database connection.
128+
* Used for repair operations to avoid triggering the traced wrapper's
129+
* auto-repair logic (which would cause infinite loops).
130+
*/
131+
export function getRawDatabase(): Database {
132+
if (!rawDb) {
133+
// Ensure database is initialized
134+
getDatabase();
135+
}
136+
// After getDatabase() call, rawDb is guaranteed to be set
137+
if (!rawDb) {
138+
throw new Error("Database initialization failed");
139+
}
140+
return rawDb;
141+
}
142+
114143
function shouldRunCleanup(): boolean {
115144
return Math.random() < CLEANUP_PROBABILITY;
116145
}

0 commit comments

Comments
 (0)