@smithers-orchestrator/db 0.24.0 → 0.25.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/package.json +5 -5
- package/src/adapter/DB_RUN_ALLOWED_STATUSES.js +1 -0
- package/src/adapter.js +240 -67
- package/src/assertJsonPayloadWithinBounds.js +1 -1
- package/src/assertNoReservedColumns.js +38 -0
- package/src/dialect.js +2 -31
- package/src/docWatcher.js +162 -0
- package/src/frame-codec.js +5 -1
- package/src/getSmithersSchemaSignature.js +70 -0
- package/src/index.d.ts +60 -12
- package/src/index.js +3 -0
- package/src/internal-schema/index.js +1 -0
- package/src/internal-schema/smithersDocs.js +27 -0
- package/src/internal-schema/smithersScorers.js +2 -0
- package/src/internal-schema.js +1 -0
- package/src/react-output.js +3 -10
- package/src/runState/DeriveRunStateInput.ts +4 -0
- package/src/runState/ReasonBlocked.ts +4 -1
- package/src/runState/RunState.ts +1 -0
- package/src/runState/computeRunStateFromRow.js +21 -0
- package/src/runState/deriveRunState.js +39 -10
- package/src/schema-migrations.js +346 -14
- package/src/sha256Hex.js +14 -0
- package/src/sql-message-storage.js +13 -0
- package/src/zodToCreateTableSQL.js +12 -4
- package/src/zodToTable.js +20 -5
- package/src/frame-codec/index.js +0 -15
- package/src/output/index.js +0 -14
- package/src/storage/InMemoryStorage.js +0 -484
- package/src/storage/StorageService.js +0 -7
- package/src/storage/StorageServiceShape.ts +0 -122
- package/src/storage/StorageServiceTypes.ts +0 -150
package/src/schema-migrations.js
CHANGED
|
@@ -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:
|
|
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
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/sha256Hex.js
ADDED
|
@@ -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 === "
|
|
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() ->
|
|
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
|
|
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
|
}
|