@smithers-orchestrator/db 0.24.2 → 0.25.1

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.
@@ -1,5 +1,6 @@
1
1
  import { eq } from "drizzle-orm";
2
2
  import { drizzle } from "drizzle-orm/bun-sqlite";
3
+ import { Effect } from "effect";
3
4
  import { POSTGRES, translateDdl } from "./dialect.js";
4
5
  import { smithersSchemaMigrations } from "./internal-schema/smithersSchemaMigrations.js";
5
6
 
@@ -151,6 +152,21 @@ function tableExists(sqlite, table) {
151
152
  .get(table));
152
153
  }
153
154
 
155
+ /**
156
+ * Extract the target table name from a `CREATE INDEX ... ON <table> (...)`
157
+ * statement so the "current indexes" migration can skip an index whose table a
158
+ * later migration has not created yet (e.g. a store upgrading from a ledger that
159
+ * predates `_smithers_docs`). Returns null when no table can be parsed, in which
160
+ * case the statement runs unguarded.
161
+ *
162
+ * @param {string} statement
163
+ * @returns {string | null}
164
+ */
165
+ function indexTargetTable(statement) {
166
+ const match = /\bON\s+"?([A-Za-z0-9_]+)"?/i.exec(statement);
167
+ return match ? match[1] : null;
168
+ }
169
+
154
170
  /**
155
171
  * @param {import("bun:sqlite").Database} sqlite
156
172
  * @param {string} table
@@ -166,6 +182,35 @@ function tableColumnNames(sqlite, table) {
166
182
  .filter((name) => typeof name === "string"));
167
183
  }
168
184
 
185
+ /**
186
+ * @param {{ query: (config: { text: string; values?: readonly unknown[] }) => Promise<{ rows?: readonly Record<string, unknown>[] }> }} pgConn
187
+ * @param {string} table
188
+ */
189
+ async function tableExistsPostgres(pgConn, table) {
190
+ const result = await pgConn.query({
191
+ text: "SELECT 1 AS ok FROM information_schema.tables WHERE table_schema = current_schema() AND table_name = $1 LIMIT 1",
192
+ values: [table],
193
+ });
194
+ return Boolean(result.rows?.[0]);
195
+ }
196
+
197
+ /**
198
+ * @param {{ query: (config: { text: string; values?: readonly unknown[] }) => Promise<{ rows?: readonly Record<string, unknown>[] }> }} pgConn
199
+ * @param {string} table
200
+ */
201
+ async function tableColumnNamesPostgres(pgConn, table) {
202
+ if (!(await tableExistsPostgres(pgConn, table))) {
203
+ return new Set();
204
+ }
205
+ const result = await pgConn.query({
206
+ text: "SELECT column_name AS name FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = $1",
207
+ values: [table],
208
+ });
209
+ return new Set((result.rows ?? [])
210
+ .map((row) => row.name)
211
+ .filter((name) => typeof name === "string"));
212
+ }
213
+
169
214
  /**
170
215
  * @param {import("bun:sqlite").Database} sqlite
171
216
  * @param {string} table
@@ -222,6 +267,15 @@ function createTableStatementFor(table, createTableStatements) {
222
267
  return statement;
223
268
  }
224
269
 
270
+ /**
271
+ * @param {readonly string[]} createTableStatements
272
+ */
273
+ function currentTableNames(createTableStatements) {
274
+ return createTableStatements
275
+ .map((statement) => statement.match(/CREATE TABLE IF NOT EXISTS\s+("?[_a-zA-Z0-9]+"?)\s*\(/i)?.[1]?.replace(/^"|"$/g, ""))
276
+ .filter((name) => typeof name === "string" && name.length > 0);
277
+ }
278
+
225
279
  /**
226
280
  * @param {import("bun:sqlite").Database} sqlite
227
281
  * @param {string} table
@@ -237,6 +291,21 @@ function addColumnIfMissing(sqlite, table, column, definition) {
237
291
  return true;
238
292
  }
239
293
 
294
+ /**
295
+ * @param {{ query: (config: { text: string; values?: readonly unknown[] }) => Promise<unknown> }} pgConn
296
+ * @param {string} table
297
+ * @param {string} column
298
+ * @param {string} definition
299
+ */
300
+ async function addColumnIfMissingPostgres(pgConn, table, column, definition) {
301
+ const columns = await tableColumnNamesPostgres(pgConn, table);
302
+ if (columns.has(column)) {
303
+ return false;
304
+ }
305
+ await pgConn.query({ text: `ALTER TABLE ${quoteIdentifier(table)} ADD COLUMN ${translateDdl(POSTGRES, definition)}` });
306
+ return true;
307
+ }
308
+
240
309
  /**
241
310
  * @param {import("bun:sqlite").Database} sqlite
242
311
  * @param {{ table: string; columns: readonly string[]; defaults?: Record<string, string>; }} config
@@ -327,6 +396,16 @@ function loadAppliedMigrationIds(sqlite) {
327
396
  .map((row) => row.id));
328
397
  }
329
398
 
399
+ /**
400
+ * @param {{ query: (config: { text: string; values?: readonly unknown[] }) => Promise<{ rows?: readonly Record<string, unknown>[] }> }} pgConn
401
+ */
402
+ async function loadAppliedMigrationIdsPostgres(pgConn) {
403
+ const result = await pgConn.query({ text: "SELECT id FROM _smithers_schema_migrations" });
404
+ return new Set((result.rows ?? [])
405
+ .map((row) => row.id)
406
+ .filter((id) => typeof id === "string"));
407
+ }
408
+
330
409
  /**
331
410
  * @param {import("bun:sqlite").Database} sqlite
332
411
  * @param {{ id: string; name: string; checksum?: string; destructive?: boolean; }} migration
@@ -347,6 +426,28 @@ function recordMigration(sqlite, migration, details) {
347
426
  .run();
348
427
  }
349
428
 
429
+ /**
430
+ * @param {{ query: (config: { text: string; values?: readonly unknown[] }) => Promise<unknown> }} pgConn
431
+ * @param {{ id: string; name: string; checksum?: string; destructive?: boolean; }} migration
432
+ * @param {unknown} details
433
+ */
434
+ async function recordMigrationPostgres(pgConn, migration, details) {
435
+ await pgConn.query({
436
+ text: `INSERT INTO _smithers_schema_migrations
437
+ (id, name, applied_at_ms, checksum, destructive, details_json)
438
+ VALUES ($1, $2, $3, $4, $5, $6)
439
+ ON CONFLICT (id) DO NOTHING`,
440
+ values: [
441
+ migration.id,
442
+ migration.name,
443
+ Date.now(),
444
+ migration.checksum ?? null,
445
+ migration.destructive ? 1 : 0,
446
+ details === undefined ? null : JSON.stringify(details),
447
+ ],
448
+ });
449
+ }
450
+
350
451
  /**
351
452
  * @param {{ id: string; destructive?: boolean; }} migration
352
453
  * @param {any} details
@@ -362,6 +463,39 @@ function logDestructiveMigration(migration, details) {
362
463
  console.warn(`[smithers-db] migration ${migration.id} dropped ${droppedCount} orphan run-owned rows`, details.tables);
363
464
  }
364
465
 
466
+ /**
467
+ * Run one SQLite schema migration inside an Effect span while preserving the
468
+ * existing synchronous migration API.
469
+ *
470
+ * @param {{
471
+ * id: string;
472
+ * name: string;
473
+ * checksum?: string;
474
+ * destructive?: boolean;
475
+ * isApplied: (sqlite: import("bun:sqlite").Database) => boolean;
476
+ * up: (sqlite: import("bun:sqlite").Database) => unknown;
477
+ * }} migration
478
+ * @param {() => unknown} run
479
+ */
480
+ function runSqliteMigrationSpan(migration, run) {
481
+ const start = Date.now();
482
+ return Effect.runSync(Effect.sync(() => {
483
+ const details = run();
484
+ const durationMs = Date.now() - start;
485
+ Effect.runSync(Effect.logDebug("db.schema_migration.applied").pipe(Effect.annotateLogs({
486
+ dbDialect: "sqlite",
487
+ migrationId: migration.id,
488
+ migrationName: migration.name,
489
+ durationMs,
490
+ })));
491
+ return details;
492
+ }).pipe(Effect.annotateLogs({
493
+ dbDialect: "sqlite",
494
+ migrationId: migration.id,
495
+ migrationName: migration.name,
496
+ }), Effect.withLogSpan("db:schema-migration")));
497
+ }
498
+
365
499
  /**
366
500
  * @param {{
367
501
  * createTableStatements: readonly string[];
@@ -377,6 +511,10 @@ function buildMigrations(context) {
377
511
  const columns = tableColumnNames(sqlite, config.table);
378
512
  return config.columns.every(([column]) => columns.has(column));
379
513
  },
514
+ isAppliedPostgres: async (pgConn) => {
515
+ const columns = await tableColumnNamesPostgres(pgConn, config.table);
516
+ return config.columns.every(([column]) => columns.has(column));
517
+ },
380
518
  up: (sqlite) => {
381
519
  const addedColumns = [];
382
520
  for (const [column, definition] of config.columns) {
@@ -386,6 +524,15 @@ function buildMigrations(context) {
386
524
  }
387
525
  return { table: config.table, addedColumns };
388
526
  },
527
+ upPostgres: async (pgConn) => {
528
+ const addedColumns = [];
529
+ for (const [column, definition] of config.columns) {
530
+ if (await addColumnIfMissingPostgres(pgConn, config.table, column, definition)) {
531
+ addedColumns.push(column);
532
+ }
533
+ }
534
+ return { table: config.table, addedColumns };
535
+ },
389
536
  }));
390
537
  return [
391
538
  {
@@ -393,12 +540,27 @@ function buildMigrations(context) {
393
540
  name: "Create current Smithers tables",
394
541
  checksum: checksumForStatements(context.createTableStatements),
395
542
  isApplied: () => false,
543
+ isAppliedPostgres: async (pgConn) => {
544
+ const names = currentTableNames(context.createTableStatements);
545
+ for (const name of names) {
546
+ if (!(await tableExistsPostgres(pgConn, name))) {
547
+ return false;
548
+ }
549
+ }
550
+ return true;
551
+ },
396
552
  up: (sqlite) => {
397
553
  for (const statement of context.createTableStatements) {
398
554
  sqlite.run(statement);
399
555
  }
400
556
  return { statementCount: context.createTableStatements.length };
401
557
  },
558
+ upPostgres: async (pgConn) => {
559
+ for (const statement of context.createTableStatements) {
560
+ await pgConn.query({ text: translateDdl(POSTGRES, statement) });
561
+ }
562
+ return { statementCount: context.createTableStatements.length };
563
+ },
402
564
  },
403
565
  ...columnMigrations,
404
566
  {
@@ -406,10 +568,15 @@ function buildMigrations(context) {
406
568
  name: "Add node diff cache table",
407
569
  checksum: "packages/db/migrations/0011_add_node_diffs.sql",
408
570
  isApplied: (sqlite) => tableExists(sqlite, "_smithers_node_diffs"),
571
+ isAppliedPostgres: (pgConn) => tableExistsPostgres(pgConn, "_smithers_node_diffs"),
409
572
  up: (sqlite) => {
410
573
  sqlite.run(createTableStatementFor("_smithers_node_diffs", context.createTableStatements));
411
574
  return { table: "_smithers_node_diffs" };
412
575
  },
576
+ upPostgres: async (pgConn) => {
577
+ await pgConn.query({ text: translateDdl(POSTGRES, createTableStatementFor("_smithers_node_diffs", context.createTableStatements)) });
578
+ return { table: "_smithers_node_diffs" };
579
+ },
413
580
  },
414
581
  {
415
582
  id: "0012_add_time_travel_audit",
@@ -417,6 +584,8 @@ function buildMigrations(context) {
417
584
  checksum: "packages/db/migrations/0012_add_time_travel_audit.sql",
418
585
  isApplied: (sqlite) => tableExists(sqlite, "_smithers_time_travel_audit") &&
419
586
  tableColumnNames(sqlite, "_smithers_time_travel_audit").has("duration_ms"),
587
+ isAppliedPostgres: async (pgConn) => (await tableExistsPostgres(pgConn, "_smithers_time_travel_audit")) &&
588
+ (await tableColumnNamesPostgres(pgConn, "_smithers_time_travel_audit")).has("duration_ms"),
420
589
  up: (sqlite) => {
421
590
  if (!tableExists(sqlite, "_smithers_time_travel_audit")) {
422
591
  sqlite.run(createTableStatementFor("_smithers_time_travel_audit", context.createTableStatements));
@@ -425,6 +594,14 @@ function buildMigrations(context) {
425
594
  addColumnIfMissing(sqlite, "_smithers_time_travel_audit", "duration_ms", "duration_ms INTEGER");
426
595
  return { table: "_smithers_time_travel_audit", addedColumns: ["duration_ms"] };
427
596
  },
597
+ upPostgres: async (pgConn) => {
598
+ if (!(await tableExistsPostgres(pgConn, "_smithers_time_travel_audit"))) {
599
+ await pgConn.query({ text: translateDdl(POSTGRES, createTableStatementFor("_smithers_time_travel_audit", context.createTableStatements)) });
600
+ return { table: "_smithers_time_travel_audit", created: true };
601
+ }
602
+ const added = await addColumnIfMissingPostgres(pgConn, "_smithers_time_travel_audit", "duration_ms", "duration_ms INTEGER");
603
+ return { table: "_smithers_time_travel_audit", addedColumns: added ? ["duration_ms"] : [] };
604
+ },
428
605
  },
429
606
  {
430
607
  id: "0013_run_owned_foreign_keys",
@@ -432,6 +609,7 @@ function buildMigrations(context) {
432
609
  checksum: checksumForStatements(RUN_OWNED_FOREIGN_KEY_TABLES.map((config) => config.table)),
433
610
  destructive: true,
434
611
  isApplied: (sqlite) => RUN_OWNED_FOREIGN_KEY_TABLES.every((config) => tableHasRunForeignKey(sqlite, config.table)),
612
+ isAppliedPostgres: () => true,
435
613
  up: (sqlite) => {
436
614
  const tables = RUN_OWNED_FOREIGN_KEY_TABLES.map((config) => rebuildRunOwnedForeignKeyTable(sqlite, config, context.createTableStatements));
437
615
  const violations = sqlite.query("PRAGMA foreign_key_check").all();
@@ -440,17 +618,44 @@ function buildMigrations(context) {
440
618
  }
441
619
  return { tables };
442
620
  },
621
+ upPostgres: async () => ({ skipped: "sqlite_only" }),
443
622
  },
444
623
  {
445
624
  id: "0014_current_indexes",
446
625
  name: "Create current Smithers indexes",
447
626
  checksum: checksumForStatements([...context.createIndexStatements, ...EXTRA_INDEX_STATEMENTS]),
448
627
  isApplied: () => false,
628
+ isAppliedPostgres: () => false,
449
629
  up: (sqlite) => {
630
+ let applied = 0;
631
+ let skipped = 0;
450
632
  for (const statement of [...context.createIndexStatements, ...EXTRA_INDEX_STATEMENTS]) {
633
+ const target = indexTargetTable(statement);
634
+ // Skip indexes whose table a later migration creates; running
635
+ // them here would throw "no such table" on a store whose
636
+ // ledger predates that table (e.g. `_smithers_docs`).
637
+ if (target && !tableExists(sqlite, target)) {
638
+ skipped += 1;
639
+ continue;
640
+ }
451
641
  sqlite.run(statement);
642
+ applied += 1;
643
+ }
644
+ return { statementCount: applied, skipped };
645
+ },
646
+ upPostgres: async (pgConn) => {
647
+ let applied = 0;
648
+ let skipped = 0;
649
+ for (const statement of [...context.createIndexStatements, ...EXTRA_INDEX_STATEMENTS]) {
650
+ const target = indexTargetTable(statement);
651
+ if (target && !(await tableExistsPostgres(pgConn, target))) {
652
+ skipped += 1;
653
+ continue;
654
+ }
655
+ await pgConn.query({ text: translateDdl(POSTGRES, statement) });
656
+ applied += 1;
452
657
  }
453
- return { statementCount: context.createIndexStatements.length + EXTRA_INDEX_STATEMENTS.length };
658
+ return { statementCount: applied, skipped };
454
659
  },
455
660
  },
456
661
  {
@@ -458,20 +663,68 @@ function buildMigrations(context) {
458
663
  name: "Add workspace snapshot states table",
459
664
  checksum: "packages/db/migrations/0015_add_workspace_states.sql",
460
665
  isApplied: (sqlite) => tableExists(sqlite, "_smithers_workspace_states"),
666
+ isAppliedPostgres: (pgConn) => tableExistsPostgres(pgConn, "_smithers_workspace_states"),
461
667
  up: (sqlite) => {
462
668
  sqlite.run(createTableStatementFor("_smithers_workspace_states", context.createTableStatements));
463
669
  return { table: "_smithers_workspace_states" };
464
670
  },
671
+ upPostgres: async (pgConn) => {
672
+ await pgConn.query({ text: translateDdl(POSTGRES, createTableStatementFor("_smithers_workspace_states", context.createTableStatements)) });
673
+ return { table: "_smithers_workspace_states" };
674
+ },
465
675
  },
466
676
  {
467
677
  id: "0016_add_workspace_checkpoints",
468
678
  name: "Add workspace snapshot checkpoints table",
469
679
  checksum: "packages/db/migrations/0016_add_workspace_checkpoints.sql",
470
680
  isApplied: (sqlite) => tableExists(sqlite, "_smithers_workspace_checkpoints"),
681
+ isAppliedPostgres: (pgConn) => tableExistsPostgres(pgConn, "_smithers_workspace_checkpoints"),
471
682
  up: (sqlite) => {
472
683
  sqlite.run(createTableStatementFor("_smithers_workspace_checkpoints", context.createTableStatements));
473
684
  return { table: "_smithers_workspace_checkpoints" };
474
685
  },
686
+ upPostgres: async (pgConn) => {
687
+ await pgConn.query({ text: translateDdl(POSTGRES, createTableStatementFor("_smithers_workspace_checkpoints", context.createTableStatements)) });
688
+ return { table: "_smithers_workspace_checkpoints" };
689
+ },
690
+ },
691
+ {
692
+ id: "0017_add_scorer_context_columns",
693
+ name: "Add scorer context correlation columns",
694
+ checksum: "packages/db/migrations/0017_add_scorer_context_columns.sql",
695
+ isApplied: (sqlite) => {
696
+ const columns = tableColumnNames(sqlite, "_smithers_scorers");
697
+ return columns.has("ground_truth_json") && columns.has("context_json");
698
+ },
699
+ up: (sqlite) => {
700
+ const addedColumns = [];
701
+ if (addColumnIfMissing(sqlite, "_smithers_scorers", "ground_truth_json", "ground_truth_json TEXT")) {
702
+ addedColumns.push("ground_truth_json");
703
+ }
704
+ if (addColumnIfMissing(sqlite, "_smithers_scorers", "context_json", "context_json TEXT")) {
705
+ addedColumns.push("context_json");
706
+ }
707
+ return { table: "_smithers_scorers", addedColumns };
708
+ },
709
+ },
710
+ {
711
+ id: "0018_add_docs",
712
+ name: "Add docs (tickets/plans/specs/proposals) table",
713
+ checksum: "packages/db/migrations/0018_add_docs.sql",
714
+ isApplied: (sqlite) => tableExists(sqlite, "_smithers_docs"),
715
+ isAppliedPostgres: (pgConn) => tableExistsPostgres(pgConn, "_smithers_docs"),
716
+ up: (sqlite) => {
717
+ sqlite.run(createTableStatementFor("_smithers_docs", context.createTableStatements));
718
+ sqlite.run(`CREATE INDEX IF NOT EXISTS _smithers_docs_kind_live_idx
719
+ ON _smithers_docs (kind, deleted_at_ms, updated_at_ms)`);
720
+ return { table: "_smithers_docs" };
721
+ },
722
+ upPostgres: async (pgConn) => {
723
+ await pgConn.query({ text: translateDdl(POSTGRES, createTableStatementFor("_smithers_docs", context.createTableStatements)) });
724
+ await pgConn.query({ text: translateDdl(POSTGRES, `CREATE INDEX IF NOT EXISTS _smithers_docs_kind_live_idx
725
+ ON _smithers_docs (kind, deleted_at_ms, updated_at_ms)`) });
726
+ return { table: "_smithers_docs" };
727
+ },
475
728
  },
476
729
  ];
477
730
  }
@@ -491,16 +744,103 @@ export function runSmithersSchemaMigrations(sqlite, context) {
491
744
  if (applied.has(migration.id) || hasMigrationRecord(sqlite, migration.id)) {
492
745
  continue;
493
746
  }
494
- const alreadyApplied = migration.isApplied(sqlite);
495
- const details = alreadyApplied
496
- ? { skipped: "schema_already_matches" }
497
- : migration.up(sqlite);
747
+ const details = runSqliteMigrationSpan(migration, () => {
748
+ const alreadyApplied = migration.isApplied(sqlite);
749
+ return alreadyApplied
750
+ ? { skipped: "schema_already_matches" }
751
+ : migration.up(sqlite);
752
+ });
498
753
  logDestructiveMigration(migration, details);
499
754
  recordMigration(sqlite, migration, details);
500
755
  applied.add(migration.id);
501
756
  }
502
757
  }
503
758
 
759
+ /**
760
+ * Run one Postgres schema migration inside an Effect span so OTLP-backed
761
+ * runtimes can attribute long DDL steps and log-only runtimes still get a
762
+ * structured event.
763
+ *
764
+ * @param {{
765
+ * id: string;
766
+ * name: string;
767
+ * checksum?: string;
768
+ * destructive?: boolean;
769
+ * isAppliedPostgres?: (pgConn: any) => Promise<boolean> | boolean;
770
+ * upPostgres?: (pgConn: any) => Promise<unknown>;
771
+ * }} migration
772
+ * @param {() => Promise<unknown>} run
773
+ */
774
+ async function runPostgresMigrationSpan(migration, run) {
775
+ const start = Date.now();
776
+ return Effect.runPromise(Effect.tryPromise({
777
+ try: async () => {
778
+ const details = await run();
779
+ const durationMs = Date.now() - start;
780
+ await Effect.runPromise(Effect.logDebug("db.schema_migration.applied").pipe(Effect.annotateLogs({
781
+ dbDialect: "postgres",
782
+ migrationId: migration.id,
783
+ migrationName: migration.name,
784
+ durationMs,
785
+ })));
786
+ return details;
787
+ },
788
+ catch: (cause) => cause,
789
+ }).pipe(Effect.annotateLogs({
790
+ dbDialect: "postgres",
791
+ migrationId: migration.id,
792
+ migrationName: migration.name,
793
+ }), Effect.withLogSpan("db:schema-migration")));
794
+ }
795
+
796
+ /**
797
+ * Versioned PostgreSQL schema migration runner. It shares the SQLite migration
798
+ * ledger ids so the current server schema head is available on both dialects.
799
+ * A fresh Postgres database applies the current DDL in 0001 and records the
800
+ * historical SQLite-only/additive steps as already satisfied.
801
+ *
802
+ * @param {{ query: (config: { text: string; values?: readonly unknown[] }) => Promise<{ rows?: readonly Record<string, unknown>[] } | unknown> }} pgConn
803
+ * @param {{
804
+ * createTableStatements: readonly string[];
805
+ * createIndexStatements: readonly string[];
806
+ * }} context
807
+ * @returns {Promise<void>}
808
+ */
809
+ export async function runSmithersSchemaMigrationsPostgres(pgConn, context) {
810
+ await pgConn.query({ text: translateDdl(POSTGRES, MIGRATION_TABLE_SQL) });
811
+ const applied = await loadAppliedMigrationIdsPostgres(pgConn);
812
+ for (const migration of buildMigrations(context)) {
813
+ if (applied.has(migration.id)) {
814
+ continue;
815
+ }
816
+ const details = await runPostgresMigrationSpan(migration, async () => {
817
+ await pgConn.query({ text: "BEGIN" });
818
+ try {
819
+ const alreadyApplied = migration.isAppliedPostgres
820
+ ? await migration.isAppliedPostgres(pgConn)
821
+ : false;
822
+ const nextDetails = alreadyApplied
823
+ ? { skipped: "schema_already_matches" }
824
+ : await migration.upPostgres?.(pgConn);
825
+ await recordMigrationPostgres(pgConn, migration, nextDetails);
826
+ await pgConn.query({ text: "COMMIT" });
827
+ return nextDetails;
828
+ }
829
+ catch (error) {
830
+ try {
831
+ await pgConn.query({ text: "ROLLBACK" });
832
+ }
833
+ catch {
834
+ // Preserve the original migration failure.
835
+ }
836
+ throw error;
837
+ }
838
+ });
839
+ logDestructiveMigration(migration, details);
840
+ applied.add(migration.id);
841
+ }
842
+ }
843
+
504
844
  /**
505
845
  * PostgreSQL schema initialization. A fresh PostgreSQL database starts at the
506
846
  * current schema, so it skips the SQLite-only legacy column/foreign-key
@@ -517,13 +857,5 @@ export function runSmithersSchemaMigrations(sqlite, context) {
517
857
  * @returns {Promise<void>}
518
858
  */
519
859
  export async function runSmithersSchemaInitPostgres(pgConn, context) {
520
- const statements = [
521
- MIGRATION_TABLE_SQL,
522
- ...context.createTableStatements,
523
- ...context.createIndexStatements,
524
- ...EXTRA_INDEX_STATEMENTS,
525
- ];
526
- for (const statement of statements) {
527
- await pgConn.query({ text: translateDdl(POSTGRES, statement) });
528
- }
860
+ await runSmithersSchemaMigrationsPostgres(pgConn, context);
529
861
  }
@@ -0,0 +1,14 @@
1
+ import { createHash } from "node:crypto";
2
+
3
+ /**
4
+ * Lowercase hex `sha256` of a UTF-8 string. The gateway's
5
+ * `createTicket`/`updateTicket` handlers and the file-watcher seam both hash doc
6
+ * `content` through this ONE helper so a `content_hash` written by an RPC and
7
+ * one computed from a `.md` file are byte-for-byte comparable (the watcher's
8
+ * last-write-wins compare key).
9
+ * @param {string} content
10
+ * @returns {string}
11
+ */
12
+ export function sha256Hex(content) {
13
+ return createHash("sha256").update(content, "utf8").digest("hex");
14
+ }
@@ -321,6 +321,8 @@ const CREATE_TABLE_STATEMENTS = [
321
321
  meta_json TEXT,
322
322
  input_json TEXT,
323
323
  output_json TEXT,
324
+ ground_truth_json TEXT,
325
+ context_json TEXT,
324
326
  latency_ms REAL,
325
327
  scored_at_ms INTEGER NOT NULL,
326
328
  duration_ms REAL
@@ -352,6 +354,15 @@ const CREATE_TABLE_STATEMENTS = [
352
354
  node_id TEXT,
353
355
  created_at_ms INTEGER NOT NULL
354
356
  )`,
357
+ `CREATE TABLE IF NOT EXISTS _smithers_docs (
358
+ path TEXT PRIMARY KEY,
359
+ kind TEXT NOT NULL DEFAULT 'ticket',
360
+ content TEXT NOT NULL,
361
+ content_hash TEXT NOT NULL,
362
+ status TEXT,
363
+ updated_at_ms INTEGER NOT NULL,
364
+ deleted_at_ms INTEGER
365
+ )`,
355
366
  ];
356
367
  const CREATE_INDEX_STATEMENTS = [
357
368
  `CREATE INDEX IF NOT EXISTS _smithers_runs_status_heartbeat_idx
@@ -360,6 +371,8 @@ const CREATE_INDEX_STATEMENTS = [
360
371
  ON _smithers_signals (run_id, signal_name, correlation_id, received_at_ms)`,
361
372
  `CREATE INDEX IF NOT EXISTS _smithers_time_travel_audit_lookup_idx
362
373
  ON _smithers_time_travel_audit (run_id, caller, timestamp_ms)`,
374
+ `CREATE INDEX IF NOT EXISTS _smithers_docs_kind_live_idx
375
+ ON _smithers_docs (kind, deleted_at_ms, updated_at_ms)`,
363
376
  ];
364
377
  /**
365
378
  * @param {string} identifier
@@ -1,21 +1,28 @@
1
1
  import { unwrapZodType } from "./unwrapZodType.js";
2
2
  import { columnType, SQLITE } from "./dialect.js";
3
3
  import { camelToSnake } from "./utils/camelToSnake.js";
4
+ import { assertNoReservedColumns } from "./assertNoReservedColumns.js";
4
5
  /**
5
6
  * Determines the Zod base type name from a (possibly unwrapped) Zod type.
6
7
  */
7
8
  function getZodBaseTypeName(zodType) {
8
9
  return zodType._zod?.def?.type ?? "unknown";
9
10
  }
11
+ function isIntegerNumberType(zodType, baseTypeName) {
12
+ if (baseTypeName === "int")
13
+ return true;
14
+ const def = zodType._zod?.def;
15
+ return def?.format === "safeint" ||
16
+ def?.checks?.some((check) => check?._zod?.def?.check === "number_format");
17
+ }
10
18
  function sqliteTypeFor(zodFieldSchema) {
11
19
  const baseType = unwrapZodType(zodFieldSchema);
12
20
  const baseTypeName = getZodBaseTypeName(baseType);
13
- if (baseTypeName === "number" ||
14
- baseTypeName === "int" ||
15
- baseTypeName === "float" ||
16
- baseTypeName === "boolean") {
21
+ if (baseTypeName === "boolean" || isIntegerNumberType(baseType, baseTypeName)) {
17
22
  return "INTEGER";
18
23
  }
24
+ if (baseTypeName === "number" || baseTypeName === "float")
25
+ return "REAL";
19
26
  return "TEXT";
20
27
  }
21
28
  function sqliteKindFor(zodFieldSchema) {
@@ -55,6 +62,7 @@ export function zodSchemaColumns(schema) {
55
62
  * (`sqlite`) is byte-identical to the historical output.
56
63
  */
57
64
  export function zodToCreateTableSQL(tableName, schema, opts) {
65
+ assertNoReservedColumns(schema, tableName, opts);
58
66
  const dialect = opts?.dialect ?? SQLITE;
59
67
  const integer = columnType(dialect, "INTEGER");
60
68
  const colDefs = opts?.isInput
package/src/zodToTable.js CHANGED
@@ -1,4 +1,6 @@
1
- import { sqliteTable, text, integer, primaryKey, } from "drizzle-orm/sqlite-core";
1
+ import { sqliteTable, text, integer, real, primaryKey, } from "drizzle-orm/sqlite-core";
2
+ import { assertZodV4 } from "@smithers-orchestrator/errors/assertZodV4";
3
+ import { assertNoReservedColumns } from "./assertNoReservedColumns.js";
2
4
  import { unwrapZodType } from "./unwrapZodType.js";
3
5
  import { camelToSnake } from "./utils/camelToSnake.js";
4
6
  /**
@@ -7,12 +9,19 @@ import { camelToSnake } from "./utils/camelToSnake.js";
7
9
  function getZodBaseTypeName(zodType) {
8
10
  return zodType._zod?.def?.type ?? "unknown";
9
11
  }
12
+ function isIntegerNumberType(zodType, baseTypeName) {
13
+ if (baseTypeName === "int")
14
+ return true;
15
+ const def = zodType._zod?.def;
16
+ return def?.format === "safeint" ||
17
+ def?.checks?.some((check) => check?._zod?.def?.check === "number_format");
18
+ }
10
19
  /**
11
20
  * Generates a Drizzle sqliteTable from a Zod object schema.
12
21
  *
13
22
  * Each Zod field is mapped to a SQLite column:
14
23
  * - z.string() / z.enum() -> text column
15
- * - z.number() -> integer column
24
+ * - z.number() -> real column
16
25
  * - z.boolean() -> integer column with boolean mode
17
26
  * - z.array() / z.object() / complex -> text column with json mode
18
27
  *
@@ -20,6 +29,11 @@ function getZodBaseTypeName(zodType) {
20
29
  * runId, nodeId, iteration with a composite primary key.
21
30
  */
22
31
  export function zodToTable(tableName, schema, opts) {
32
+ // A Zod v3 schema has no `_zod`, so getZodBaseTypeName below would silently
33
+ // resolve every field to "unknown" and degrade every column to JSON text.
34
+ // Reject it up front with an actionable error instead.
35
+ assertZodV4(schema, tableName);
36
+ assertNoReservedColumns(schema, tableName, opts);
23
37
  const columns = opts?.isInput
24
38
  ? { runId: text("run_id").primaryKey() }
25
39
  : {
@@ -32,11 +46,12 @@ export function zodToTable(tableName, schema, opts) {
32
46
  const colName = camelToSnake(key);
33
47
  const baseType = unwrapZodType(zodType);
34
48
  const baseTypeName = getZodBaseTypeName(baseType);
35
- if (baseTypeName === "number" ||
36
- baseTypeName === "int" ||
37
- baseTypeName === "float") {
49
+ if (isIntegerNumberType(baseType, baseTypeName)) {
38
50
  columns[key] = integer(colName);
39
51
  }
52
+ else if (baseTypeName === "number" || baseTypeName === "float") {
53
+ columns[key] = real(colName);
54
+ }
40
55
  else if (baseTypeName === "boolean") {
41
56
  columns[key] = integer(colName, { mode: "boolean" });
42
57
  }