@smithers-orchestrator/db 0.20.3 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/adapter.js CHANGED
@@ -2031,7 +2031,7 @@ export class SmithersDb {
2031
2031
  * @returns {RunnableEffect<void, SmithersError>}
2032
2032
  */
2033
2033
  insertCache(row) {
2034
- return this.write(`insert cache ${row.cacheKey}`, () => this.internalStorage.insertIgnore("_smithers_cache", row));
2034
+ return this.write(`insert cache ${row.cacheKey}`, () => this.internalStorage.upsert("_smithers_cache", row, ["cacheKey"]));
2035
2035
  }
2036
2036
  /**
2037
2037
  * @param {Record<string, unknown>} row
@@ -19,3 +19,4 @@ export { smithersMemoryThreads } from "./smithersMemoryThreads.js";
19
19
  export { smithersMemoryMessages } from "./smithersMemoryMessages.js";
20
20
  export { smithersVectors } from "./smithersVectors.js";
21
21
  export { smithersCron } from "./smithersCron.js";
22
+ export { smithersSchemaMigrations } from "./smithersSchemaMigrations.js";
@@ -1,4 +1,5 @@
1
- import { integer, sqliteTable, text, primaryKey, } from "drizzle-orm/sqlite-core";
1
+ import { foreignKey, integer, sqliteTable, text, primaryKey, } from "drizzle-orm/sqlite-core";
2
+ import { smithersRuns } from "./smithersRuns.js";
2
3
  export const smithersFrames = sqliteTable("_smithers_frames", {
3
4
  runId: text("run_id").notNull(),
4
5
  frameNo: integer("frame_no").notNull(),
@@ -11,4 +12,8 @@ export const smithersFrames = sqliteTable("_smithers_frames", {
11
12
  note: text("note"),
12
13
  }, (t) => ({
13
14
  pk: primaryKey({ columns: [t.runId, t.frameNo] }),
15
+ runFk: foreignKey({
16
+ columns: [t.runId],
17
+ foreignColumns: [smithersRuns.runId],
18
+ }).onDelete("cascade"),
14
19
  }));
@@ -1,4 +1,5 @@
1
- import { integer, primaryKey, sqliteTable, text } from "drizzle-orm/sqlite-core";
1
+ import { foreignKey, integer, primaryKey, sqliteTable, text } from "drizzle-orm/sqlite-core";
2
+ import { smithersRuns } from "./smithersRuns.js";
2
3
  export const smithersNodeDiffs = sqliteTable("_smithers_node_diffs", {
3
4
  runId: text("run_id").notNull(),
4
5
  nodeId: text("node_id").notNull(),
@@ -9,4 +10,8 @@ export const smithersNodeDiffs = sqliteTable("_smithers_node_diffs", {
9
10
  sizeBytes: integer("size_bytes").notNull(),
10
11
  }, (t) => ({
11
12
  pk: primaryKey({ columns: [t.runId, t.nodeId, t.iteration, t.baseRef] }),
13
+ runFk: foreignKey({
14
+ columns: [t.runId],
15
+ foreignColumns: [smithersRuns.runId],
16
+ }).onDelete("cascade"),
12
17
  }));
@@ -0,0 +1,12 @@
1
+ import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
2
+
3
+ export const smithersSchemaMigrations = sqliteTable("_smithers_schema_migrations", {
4
+ id: text("id").primaryKey(),
5
+ name: text("name").notNull(),
6
+ appliedAtMs: integer("applied_at_ms").notNull(),
7
+ checksum: text("checksum"),
8
+ destructive: integer("destructive", { mode: "boolean" })
9
+ .notNull()
10
+ .default(false),
11
+ detailsJson: text("details_json"),
12
+ });
@@ -1,4 +1,5 @@
1
- import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
1
+ import { foreignKey, integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
2
+ import { smithersRuns } from "./smithersRuns.js";
2
3
 
3
4
  export const smithersTimeTravelAudit = sqliteTable("_smithers_time_travel_audit", {
4
5
  id: integer("id").primaryKey({ autoIncrement: true }),
@@ -9,4 +10,9 @@ export const smithersTimeTravelAudit = sqliteTable("_smithers_time_travel_audit"
9
10
  timestampMs: integer("timestamp_ms").notNull(),
10
11
  result: text("result").notNull(),
11
12
  durationMs: integer("duration_ms"),
12
- });
13
+ }, (t) => ({
14
+ runFk: foreignKey({
15
+ columns: [t.runId],
16
+ foreignColumns: [smithersRuns.runId],
17
+ }).onDelete("cascade"),
18
+ }));
@@ -1,4 +1,4 @@
1
- import { blob, integer, sqliteTable, text, primaryKey, } from "drizzle-orm/sqlite-core";
1
+ import { blob, foreignKey, integer, sqliteTable, text, primaryKey, } from "drizzle-orm/sqlite-core";
2
2
  export const smithersRuns = sqliteTable("_smithers_runs", {
3
3
  runId: text("run_id").primaryKey(),
4
4
  parentRunId: text("parent_run_id"),
@@ -63,6 +63,10 @@ export const smithersFrames = sqliteTable("_smithers_frames", {
63
63
  note: text("note"),
64
64
  }, (t) => ({
65
65
  pk: primaryKey({ columns: [t.runId, t.frameNo] }),
66
+ runFk: foreignKey({
67
+ columns: [t.runId],
68
+ foreignColumns: [smithersRuns.runId],
69
+ }).onDelete("cascade"),
66
70
  }));
67
71
  export const smithersApprovals = sqliteTable("_smithers_approvals", {
68
72
  runId: text("run_id").notNull(),
@@ -158,6 +162,10 @@ export const smithersNodeDiffs = sqliteTable("_smithers_node_diffs", {
158
162
  pk: primaryKey({
159
163
  columns: [t.runId, t.nodeId, t.iteration, t.baseRef],
160
164
  }),
165
+ runFk: foreignKey({
166
+ columns: [t.runId],
167
+ foreignColumns: [smithersRuns.runId],
168
+ }).onDelete("cascade"),
161
169
  }));
162
170
  export const smithersTimeTravelAudit = sqliteTable("_smithers_time_travel_audit", {
163
171
  id: integer("id").primaryKey({ autoIncrement: true }),
@@ -168,7 +176,12 @@ export const smithersTimeTravelAudit = sqliteTable("_smithers_time_travel_audit"
168
176
  timestampMs: integer("timestamp_ms").notNull(),
169
177
  result: text("result").notNull(),
170
178
  durationMs: integer("duration_ms"),
171
- });
179
+ }, (t) => ({
180
+ runFk: foreignKey({
181
+ columns: [t.runId],
182
+ foreignColumns: [smithersRuns.runId],
183
+ }).onDelete("cascade"),
184
+ }));
172
185
  export const smithersSandboxes = sqliteTable("_smithers_sandboxes", {
173
186
  runId: text("run_id").notNull(),
174
187
  sandboxId: text("sandbox_id").notNull(),
@@ -245,3 +258,13 @@ export const smithersCron = sqliteTable("_smithers_cron", {
245
258
  nextRunAtMs: integer("next_run_at_ms"),
246
259
  errorJson: text("error_json"),
247
260
  });
261
+ export const smithersSchemaMigrations = sqliteTable("_smithers_schema_migrations", {
262
+ id: text("id").primaryKey(),
263
+ name: text("name").notNull(),
264
+ appliedAtMs: integer("applied_at_ms").notNull(),
265
+ checksum: text("checksum"),
266
+ destructive: integer("destructive", { mode: "boolean" })
267
+ .notNull()
268
+ .default(false),
269
+ detailsJson: text("details_json"),
270
+ });
@@ -0,0 +1,481 @@
1
+ import { eq } from "drizzle-orm";
2
+ import { drizzle } from "drizzle-orm/bun-sqlite";
3
+ import { smithersSchemaMigrations } from "./internal-schema/smithersSchemaMigrations.js";
4
+
5
+ const MIGRATION_TABLE_SQL = `CREATE TABLE IF NOT EXISTS _smithers_schema_migrations (
6
+ id TEXT PRIMARY KEY,
7
+ name TEXT NOT NULL,
8
+ applied_at_ms INTEGER NOT NULL,
9
+ checksum TEXT,
10
+ destructive INTEGER NOT NULL DEFAULT 0,
11
+ details_json TEXT
12
+ )`;
13
+
14
+ const RUN_OWNED_FOREIGN_KEY_TABLES = [
15
+ {
16
+ table: "_smithers_frames",
17
+ columns: [
18
+ "run_id",
19
+ "frame_no",
20
+ "created_at_ms",
21
+ "xml_json",
22
+ "xml_hash",
23
+ "encoding",
24
+ "mounted_task_ids_json",
25
+ "task_index_json",
26
+ "note",
27
+ ],
28
+ defaults: { encoding: "'full'" },
29
+ },
30
+ {
31
+ table: "_smithers_node_diffs",
32
+ columns: [
33
+ "run_id",
34
+ "node_id",
35
+ "iteration",
36
+ "base_ref",
37
+ "diff_json",
38
+ "computed_at_ms",
39
+ "size_bytes",
40
+ ],
41
+ },
42
+ {
43
+ table: "_smithers_time_travel_audit",
44
+ columns: [
45
+ "id",
46
+ "run_id",
47
+ "from_frame_no",
48
+ "to_frame_no",
49
+ "caller",
50
+ "timestamp_ms",
51
+ "result",
52
+ "duration_ms",
53
+ ],
54
+ },
55
+ ];
56
+
57
+ const LEGACY_COLUMN_MIGRATIONS = [
58
+ {
59
+ id: "0002_attempt_legacy_columns",
60
+ name: "Add legacy attempt operational columns",
61
+ table: "_smithers_attempts",
62
+ columns: [
63
+ ["response_text", "response_text TEXT"],
64
+ ["jj_cwd", "jj_cwd TEXT"],
65
+ ["heartbeat_at_ms", "heartbeat_at_ms INTEGER"],
66
+ ["heartbeat_data_json", "heartbeat_data_json TEXT"],
67
+ ["cached", "cached INTEGER DEFAULT 0"],
68
+ ["meta_json", "meta_json TEXT"],
69
+ ],
70
+ },
71
+ {
72
+ id: "0003_run_legacy_columns",
73
+ name: "Add legacy run operational columns",
74
+ table: "_smithers_runs",
75
+ columns: [
76
+ ["workflow_hash", "workflow_hash TEXT"],
77
+ ["heartbeat_at_ms", "heartbeat_at_ms INTEGER"],
78
+ ["runtime_owner_id", "runtime_owner_id TEXT"],
79
+ ["cancel_requested_at_ms", "cancel_requested_at_ms INTEGER"],
80
+ ["hijack_requested_at_ms", "hijack_requested_at_ms INTEGER"],
81
+ ["hijack_target", "hijack_target TEXT"],
82
+ ["vcs_type", "vcs_type TEXT"],
83
+ ["vcs_root", "vcs_root TEXT"],
84
+ ["vcs_revision", "vcs_revision TEXT"],
85
+ ["parent_run_id", "parent_run_id TEXT"],
86
+ ["error_json", "error_json TEXT"],
87
+ ["config_json", "config_json TEXT"],
88
+ ],
89
+ },
90
+ {
91
+ id: "0004_approval_payload_columns",
92
+ name: "Add approval payload columns",
93
+ table: "_smithers_approvals",
94
+ columns: [
95
+ ["request_json", "request_json TEXT"],
96
+ ["decision_json", "decision_json TEXT"],
97
+ ["auto_approved", "auto_approved INTEGER NOT NULL DEFAULT 0"],
98
+ ],
99
+ },
100
+ {
101
+ id: "0005_alert_model_extensions",
102
+ name: "Add alert model extension columns",
103
+ table: "_smithers_alerts",
104
+ columns: [
105
+ ["fingerprint", "fingerprint TEXT"],
106
+ ["node_id", "node_id TEXT"],
107
+ ["iteration", "iteration INTEGER"],
108
+ ["owner", "owner TEXT"],
109
+ ["runbook", "runbook TEXT"],
110
+ ["labels_json", "labels_json TEXT"],
111
+ ["reaction_json", "reaction_json TEXT"],
112
+ ["source_event_type", "source_event_type TEXT"],
113
+ ["first_fired_at_ms", "first_fired_at_ms INTEGER"],
114
+ ["last_fired_at_ms", "last_fired_at_ms INTEGER"],
115
+ ["occurrence_count", "occurrence_count INTEGER DEFAULT 1"],
116
+ ["silenced_until_ms", "silenced_until_ms INTEGER"],
117
+ ["acknowledged_by", "acknowledged_by TEXT"],
118
+ ["resolved_by", "resolved_by TEXT"],
119
+ ],
120
+ },
121
+ {
122
+ id: "0006_frame_encoding_column",
123
+ name: "Add frame encoding column",
124
+ table: "_smithers_frames",
125
+ columns: [["encoding", "encoding TEXT NOT NULL DEFAULT 'full'"]],
126
+ },
127
+ ];
128
+
129
+ const EXTRA_INDEX_STATEMENTS = [
130
+ `CREATE INDEX IF NOT EXISTS _smithers_runs_parent_idx ON _smithers_runs (parent_run_id)`,
131
+ `CREATE INDEX IF NOT EXISTS _smithers_alerts_fingerprint_idx ON _smithers_alerts (fingerprint)`,
132
+ `CREATE INDEX IF NOT EXISTS _smithers_alerts_run_status_idx ON _smithers_alerts (run_id, status)`,
133
+ ];
134
+
135
+ /**
136
+ * @param {string} identifier
137
+ * @returns {string}
138
+ */
139
+ function quoteIdentifier(identifier) {
140
+ return `"${identifier.replace(/"/g, "\"\"")}"`;
141
+ }
142
+
143
+ /**
144
+ * @param {import("bun:sqlite").Database} sqlite
145
+ * @param {string} table
146
+ */
147
+ function tableExists(sqlite, table) {
148
+ return Boolean(sqlite
149
+ .query(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?`)
150
+ .get(table));
151
+ }
152
+
153
+ /**
154
+ * @param {import("bun:sqlite").Database} sqlite
155
+ * @param {string} table
156
+ */
157
+ function tableColumnNames(sqlite, table) {
158
+ if (!tableExists(sqlite, table)) {
159
+ return new Set();
160
+ }
161
+ return new Set(sqlite
162
+ .query(`PRAGMA table_info(${quoteIdentifier(table)})`)
163
+ .all()
164
+ .map((row) => row.name)
165
+ .filter((name) => typeof name === "string"));
166
+ }
167
+
168
+ /**
169
+ * @param {import("bun:sqlite").Database} sqlite
170
+ * @param {string} table
171
+ */
172
+ function tableHasRunForeignKey(sqlite, table) {
173
+ if (!tableExists(sqlite, table)) {
174
+ return false;
175
+ }
176
+ return sqlite
177
+ .query(`PRAGMA foreign_key_list(${quoteIdentifier(table)})`)
178
+ .all()
179
+ .some((row) => row.from === "run_id" &&
180
+ row.table === "_smithers_runs" &&
181
+ row.to === "run_id" &&
182
+ String(row.on_delete ?? "").toUpperCase() === "CASCADE");
183
+ }
184
+
185
+ /**
186
+ * @param {import("bun:sqlite").Database} sqlite
187
+ * @param {string} table
188
+ * @param {string} [whereSql]
189
+ */
190
+ function countRows(sqlite, table, whereSql = "") {
191
+ const row = sqlite
192
+ .query(`SELECT COUNT(*) AS count FROM ${quoteIdentifier(table)} ${whereSql}`)
193
+ .get();
194
+ return Number(row?.count ?? 0);
195
+ }
196
+
197
+ /**
198
+ * @param {import("bun:sqlite").Database} sqlite
199
+ * @param {string} table
200
+ */
201
+ function nextLegacyTableName(sqlite, table) {
202
+ let suffix = 0;
203
+ while (true) {
204
+ const candidate = `${table}__legacy_fk_${suffix}`;
205
+ if (!tableExists(sqlite, candidate)) {
206
+ return candidate;
207
+ }
208
+ suffix += 1;
209
+ }
210
+ }
211
+
212
+ /**
213
+ * @param {string} table
214
+ * @param {readonly string[]} createTableStatements
215
+ */
216
+ function createTableStatementFor(table, createTableStatements) {
217
+ const statement = createTableStatements.find((candidate) => candidate.includes(`CREATE TABLE IF NOT EXISTS ${table} (`));
218
+ if (!statement) {
219
+ throw new Error(`Missing create statement for ${table}`);
220
+ }
221
+ return statement;
222
+ }
223
+
224
+ /**
225
+ * @param {import("bun:sqlite").Database} sqlite
226
+ * @param {string} table
227
+ * @param {string} column
228
+ * @param {string} definition
229
+ */
230
+ function addColumnIfMissing(sqlite, table, column, definition) {
231
+ const columns = tableColumnNames(sqlite, table);
232
+ if (columns.has(column)) {
233
+ return false;
234
+ }
235
+ sqlite.run(`ALTER TABLE ${quoteIdentifier(table)} ADD COLUMN ${definition}`);
236
+ return true;
237
+ }
238
+
239
+ /**
240
+ * @param {import("bun:sqlite").Database} sqlite
241
+ * @param {{ table: string; columns: readonly string[]; defaults?: Record<string, string>; }} config
242
+ * @param {readonly string[]} createTableStatements
243
+ */
244
+ function rebuildRunOwnedForeignKeyTable(sqlite, config, createTableStatements) {
245
+ if (!tableExists(sqlite, config.table)) {
246
+ return { table: config.table, beforeCount: 0, keptCount: 0, droppedCount: 0, skipped: "missing" };
247
+ }
248
+ if (tableHasRunForeignKey(sqlite, config.table)) {
249
+ const count = countRows(sqlite, config.table);
250
+ return { table: config.table, beforeCount: count, keptCount: count, droppedCount: 0, skipped: "has_fk" };
251
+ }
252
+
253
+ const beforeCount = countRows(sqlite, config.table);
254
+ const legacyTable = nextLegacyTableName(sqlite, config.table);
255
+ sqlite.run("PRAGMA foreign_keys = OFF");
256
+ sqlite.run("BEGIN IMMEDIATE");
257
+ try {
258
+ sqlite.run(`ALTER TABLE ${quoteIdentifier(config.table)} RENAME TO ${quoteIdentifier(legacyTable)}`);
259
+ sqlite.run(createTableStatementFor(config.table, createTableStatements));
260
+ const legacyColumns = tableColumnNames(sqlite, legacyTable);
261
+ const columnSql = config.columns.map(quoteIdentifier).join(", ");
262
+ const selectSql = config.columns
263
+ .map((column) => legacyColumns.has(column)
264
+ ? quoteIdentifier(column)
265
+ : (config.defaults?.[column] ?? "NULL"))
266
+ .join(", ");
267
+ const validRunWhere = legacyColumns.has("run_id")
268
+ ? `WHERE ${quoteIdentifier("run_id")} IN (SELECT run_id FROM ${quoteIdentifier("_smithers_runs")})`
269
+ : "WHERE 0";
270
+ const keptCount = countRows(sqlite, legacyTable, validRunWhere);
271
+ sqlite.run(`INSERT INTO ${quoteIdentifier(config.table)} (${columnSql})
272
+ SELECT ${selectSql} FROM ${quoteIdentifier(legacyTable)} ${validRunWhere}`);
273
+ sqlite.run(`DROP TABLE ${quoteIdentifier(legacyTable)}`);
274
+ sqlite.run("COMMIT");
275
+ return {
276
+ table: config.table,
277
+ beforeCount,
278
+ keptCount,
279
+ droppedCount: beforeCount - keptCount,
280
+ };
281
+ }
282
+ catch (error) {
283
+ try {
284
+ sqlite.run("ROLLBACK");
285
+ }
286
+ catch {
287
+ // Preserve the original migration error.
288
+ }
289
+ throw error;
290
+ }
291
+ finally {
292
+ sqlite.run("PRAGMA foreign_keys = ON");
293
+ }
294
+ }
295
+
296
+ /**
297
+ * @param {readonly string[]} statements
298
+ */
299
+ function checksumForStatements(statements) {
300
+ return statements.join("\n-- statement --\n");
301
+ }
302
+
303
+ /**
304
+ * @param {import("bun:sqlite").Database} sqlite
305
+ * @param {string} id
306
+ */
307
+ function hasMigrationRecord(sqlite, id) {
308
+ const db = drizzle(sqlite, { schema: { smithersSchemaMigrations } });
309
+ return Boolean(db
310
+ .select({ id: smithersSchemaMigrations.id })
311
+ .from(smithersSchemaMigrations)
312
+ .where(eq(smithersSchemaMigrations.id, id))
313
+ .limit(1)
314
+ .all()[0]);
315
+ }
316
+
317
+ /**
318
+ * @param {import("bun:sqlite").Database} sqlite
319
+ */
320
+ function loadAppliedMigrationIds(sqlite) {
321
+ const db = drizzle(sqlite, { schema: { smithersSchemaMigrations } });
322
+ return new Set(db
323
+ .select({ id: smithersSchemaMigrations.id })
324
+ .from(smithersSchemaMigrations)
325
+ .all()
326
+ .map((row) => row.id));
327
+ }
328
+
329
+ /**
330
+ * @param {import("bun:sqlite").Database} sqlite
331
+ * @param {{ id: string; name: string; checksum?: string; destructive?: boolean; }} migration
332
+ * @param {unknown} details
333
+ */
334
+ function recordMigration(sqlite, migration, details) {
335
+ const db = drizzle(sqlite, { schema: { smithersSchemaMigrations } });
336
+ db.insert(smithersSchemaMigrations)
337
+ .values({
338
+ id: migration.id,
339
+ name: migration.name,
340
+ appliedAtMs: Date.now(),
341
+ checksum: migration.checksum ?? null,
342
+ destructive: Boolean(migration.destructive),
343
+ detailsJson: details === undefined ? null : JSON.stringify(details),
344
+ })
345
+ .onConflictDoNothing({ target: smithersSchemaMigrations.id })
346
+ .run();
347
+ }
348
+
349
+ /**
350
+ * @param {{ id: string; destructive?: boolean; }} migration
351
+ * @param {any} details
352
+ */
353
+ function logDestructiveMigration(migration, details) {
354
+ if (!migration.destructive || !details?.tables) {
355
+ return;
356
+ }
357
+ const droppedCount = details.tables.reduce((sum, row) => sum + Number(row.droppedCount ?? 0), 0);
358
+ if (droppedCount <= 0) {
359
+ return;
360
+ }
361
+ console.warn(`[smithers-db] migration ${migration.id} dropped ${droppedCount} orphan run-owned rows`, details.tables);
362
+ }
363
+
364
+ /**
365
+ * @param {{
366
+ * createTableStatements: readonly string[];
367
+ * createIndexStatements: readonly string[];
368
+ * }} context
369
+ */
370
+ function buildMigrations(context) {
371
+ const columnMigrations = LEGACY_COLUMN_MIGRATIONS.map((config) => ({
372
+ id: config.id,
373
+ name: config.name,
374
+ checksum: checksumForStatements(config.columns.map(([, definition]) => `ALTER TABLE ${config.table} ADD COLUMN ${definition}`)),
375
+ isApplied: (sqlite) => {
376
+ const columns = tableColumnNames(sqlite, config.table);
377
+ return config.columns.every(([column]) => columns.has(column));
378
+ },
379
+ up: (sqlite) => {
380
+ const addedColumns = [];
381
+ for (const [column, definition] of config.columns) {
382
+ if (addColumnIfMissing(sqlite, config.table, column, definition)) {
383
+ addedColumns.push(column);
384
+ }
385
+ }
386
+ return { table: config.table, addedColumns };
387
+ },
388
+ }));
389
+ return [
390
+ {
391
+ id: "0001_current_tables",
392
+ name: "Create current Smithers tables",
393
+ checksum: checksumForStatements(context.createTableStatements),
394
+ isApplied: () => false,
395
+ up: (sqlite) => {
396
+ for (const statement of context.createTableStatements) {
397
+ sqlite.run(statement);
398
+ }
399
+ return { statementCount: context.createTableStatements.length };
400
+ },
401
+ },
402
+ ...columnMigrations,
403
+ {
404
+ id: "0011_add_node_diffs",
405
+ name: "Add node diff cache table",
406
+ checksum: "packages/db/migrations/0011_add_node_diffs.sql",
407
+ isApplied: (sqlite) => tableExists(sqlite, "_smithers_node_diffs"),
408
+ up: (sqlite) => {
409
+ sqlite.run(createTableStatementFor("_smithers_node_diffs", context.createTableStatements));
410
+ return { table: "_smithers_node_diffs" };
411
+ },
412
+ },
413
+ {
414
+ id: "0012_add_time_travel_audit",
415
+ name: "Add time-travel audit table",
416
+ checksum: "packages/db/migrations/0012_add_time_travel_audit.sql",
417
+ isApplied: (sqlite) => tableExists(sqlite, "_smithers_time_travel_audit") &&
418
+ tableColumnNames(sqlite, "_smithers_time_travel_audit").has("duration_ms"),
419
+ up: (sqlite) => {
420
+ if (!tableExists(sqlite, "_smithers_time_travel_audit")) {
421
+ sqlite.run(createTableStatementFor("_smithers_time_travel_audit", context.createTableStatements));
422
+ return { table: "_smithers_time_travel_audit", created: true };
423
+ }
424
+ addColumnIfMissing(sqlite, "_smithers_time_travel_audit", "duration_ms", "duration_ms INTEGER");
425
+ return { table: "_smithers_time_travel_audit", addedColumns: ["duration_ms"] };
426
+ },
427
+ },
428
+ {
429
+ id: "0013_run_owned_foreign_keys",
430
+ name: "Rebuild run-owned tables with cascading foreign keys",
431
+ checksum: checksumForStatements(RUN_OWNED_FOREIGN_KEY_TABLES.map((config) => config.table)),
432
+ destructive: true,
433
+ isApplied: (sqlite) => RUN_OWNED_FOREIGN_KEY_TABLES.every((config) => tableHasRunForeignKey(sqlite, config.table)),
434
+ up: (sqlite) => {
435
+ const tables = RUN_OWNED_FOREIGN_KEY_TABLES.map((config) => rebuildRunOwnedForeignKeyTable(sqlite, config, context.createTableStatements));
436
+ const violations = sqlite.query("PRAGMA foreign_key_check").all();
437
+ if (violations.length > 0) {
438
+ throw new Error(`SQLite foreign key check failed after Smithers schema migration: ${JSON.stringify(violations)}`);
439
+ }
440
+ return { tables };
441
+ },
442
+ },
443
+ {
444
+ id: "0014_current_indexes",
445
+ name: "Create current Smithers indexes",
446
+ checksum: checksumForStatements([...context.createIndexStatements, ...EXTRA_INDEX_STATEMENTS]),
447
+ isApplied: () => false,
448
+ up: (sqlite) => {
449
+ for (const statement of [...context.createIndexStatements, ...EXTRA_INDEX_STATEMENTS]) {
450
+ sqlite.run(statement);
451
+ }
452
+ return { statementCount: context.createIndexStatements.length + EXTRA_INDEX_STATEMENTS.length };
453
+ },
454
+ },
455
+ ];
456
+ }
457
+
458
+ /**
459
+ * @param {import("bun:sqlite").Database} sqlite
460
+ * @param {{
461
+ * createTableStatements: readonly string[];
462
+ * createIndexStatements: readonly string[];
463
+ * }} context
464
+ */
465
+ export function runSmithersSchemaMigrations(sqlite, context) {
466
+ sqlite.run("PRAGMA foreign_keys = ON");
467
+ sqlite.run(MIGRATION_TABLE_SQL);
468
+ const applied = loadAppliedMigrationIds(sqlite);
469
+ for (const migration of buildMigrations(context)) {
470
+ if (applied.has(migration.id) || hasMigrationRecord(sqlite, migration.id)) {
471
+ continue;
472
+ }
473
+ const alreadyApplied = migration.isApplied(sqlite);
474
+ const details = alreadyApplied
475
+ ? { skipped: "schema_already_matches" }
476
+ : migration.up(sqlite);
477
+ logDestructiveMigration(migration, details);
478
+ recordMigration(sqlite, migration, details);
479
+ applied.add(migration.id);
480
+ }
481
+ }