@smithers-orchestrator/db 0.23.0 → 0.24.2

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@smithers-orchestrator/db",
3
- "version": "0.23.0",
3
+ "version": "0.24.2",
4
4
  "description": "SQLite and Drizzle persistence adapter for Smithers workflows",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -10,6 +10,10 @@
10
10
  "import": "./src/index.js",
11
11
  "default": "./src/index.js"
12
12
  },
13
+ "./runState": {
14
+ "import": "./src/runState.js",
15
+ "default": "./src/runState.js"
16
+ },
13
17
  "./*": {
14
18
  "types": "./src/index.d.ts",
15
19
  "import": "./src/*.js",
@@ -26,10 +30,10 @@
26
30
  "drizzle-zod": "^0.8.3",
27
31
  "effect": "^3.21.1",
28
32
  "zod": "^4.3.6",
29
- "@smithers-orchestrator/errors": "0.23.0",
30
- "@smithers-orchestrator/observability": "0.23.0",
31
- "@smithers-orchestrator/graph": "0.23.0",
32
- "@smithers-orchestrator/scheduler": "0.23.0"
33
+ "@smithers-orchestrator/graph": "0.24.2",
34
+ "@smithers-orchestrator/errors": "0.24.2",
35
+ "@smithers-orchestrator/scheduler": "0.24.2",
36
+ "@smithers-orchestrator/observability": "0.24.2"
33
37
  },
34
38
  "devDependencies": {
35
39
  "@electric-sql/pglite": "0.5.1",
package/src/adapter.js CHANGED
@@ -11,6 +11,7 @@
11
11
  // @smithers-type-exports-end
12
12
 
13
13
  import { getTableName } from "drizzle-orm";
14
+ import { getTableColumns } from "drizzle-orm/utils";
14
15
  import { Effect, Exit, FiberId, Metric } from "effect";
15
16
  import { toSmithersError } from "@smithers-orchestrator/errors/toSmithersError";
16
17
  import { getSqlMessageStorage } from "./sql-message-storage.js";
@@ -25,6 +26,7 @@ import { camelToSnake } from "./utils/camelToSnake.js";
25
26
  /** @typedef {import("./adapter/AlertStatus.ts").AlertStatus} AlertStatus */
26
27
  /** @typedef {import("./adapter/AttemptRow.ts").AttemptRow} AttemptRow */
27
28
  /** @typedef {import("drizzle-orm/bun-sqlite").BunSQLiteDatabase} BunSQLiteDatabase */
29
+ /** @typedef {import("drizzle-orm").Table} Table */
28
30
  /** @typedef {import("./adapter/EventHistoryQuery.ts").EventHistoryQuery} EventHistoryQuery */
29
31
  /** @typedef {import("./adapter/HumanRequestRow.ts").HumanRequestRow} HumanRequestRow */
30
32
  /** @typedef {import("./output/OutputKey.ts").OutputKey} OutputKey */
@@ -427,6 +429,85 @@ function resolveOutputTableName(table) {
427
429
  return "output";
428
430
  }
429
431
  }
432
+ /**
433
+ * @param {unknown} db
434
+ * @param {string} tableName
435
+ * @returns {unknown | null}
436
+ */
437
+ function findDrizzleTableByName(db, tableName) {
438
+ const schema = /** @type {{ _?: { fullSchema?: Record<string, unknown> } }} */ (db)?._?.fullSchema;
439
+ if (!schema) return null;
440
+ for (const table of Object.values(schema)) {
441
+ try {
442
+ if (getTableName(/** @type {Table} */ (table)) === tableName) return table;
443
+ }
444
+ catch {
445
+ // Ignore non-table schema entries.
446
+ }
447
+ }
448
+ return null;
449
+ }
450
+ /**
451
+ * @param {unknown} table
452
+ * @returns {string[]}
453
+ */
454
+ function getPhysicalBooleanColumnNames(table) {
455
+ try {
456
+ const cols = getTableColumns(/** @type {Table} */ (table));
457
+ const names = [];
458
+ for (const col of Object.values(cols)) {
459
+ const c = /** @type {Record<string, unknown> & { config?: { mode?: string }; mapFromDriverValue?: unknown; name?: unknown }} */ (/** @type {unknown} */ (col));
460
+ const mapFn = /** @type {{ toString?: () => string } | undefined} */ (c?.mapFromDriverValue);
461
+ if (c?.columnType === "SQLiteBoolean" ||
462
+ c?.config?.mode === "boolean" ||
463
+ c?.mode === "boolean" ||
464
+ c?.dataType === "boolean" ||
465
+ mapFn?.toString?.().includes("Boolean")) {
466
+ const name = typeof c.name === "string" ? c.name : null;
467
+ if (name) names.push(name);
468
+ }
469
+ }
470
+ return names;
471
+ }
472
+ catch {
473
+ return [];
474
+ }
475
+ }
476
+ /**
477
+ * @param {Record<string, unknown> | null | undefined} row
478
+ * @param {readonly string[]} columnNames
479
+ * @returns {Record<string, unknown> | null | undefined}
480
+ */
481
+ function coerceRawBooleanColumns(row, columnNames) {
482
+ if (!row || columnNames.length === 0) return row;
483
+ const next = { ...row };
484
+ for (const columnName of columnNames) {
485
+ const value = next[columnName];
486
+ if (columnName in next && typeof value !== "boolean") {
487
+ if (value === 0 || value === 0n || value === "0") next[columnName] = false;
488
+ else if (value === 1 || value === 1n || value === "1") next[columnName] = true;
489
+ }
490
+ }
491
+ return next;
492
+ }
493
+ /**
494
+ * @param {unknown} client
495
+ * @param {string} tableName
496
+ * @returns {string[]}
497
+ */
498
+ function getPersistedBooleanColumnNames(client, tableName) {
499
+ try {
500
+ const rows = /** @type {{ query: (sql: string) => { all: (...args: unknown[]) => Array<Record<string, unknown>> } }} */ (client)
501
+ .query(`SELECT column_name FROM _smithers_output_schema_columns WHERE table_name = ? AND kind = 'boolean'`)
502
+ .all(tableName);
503
+ return rows
504
+ .map((row) => row.column_name)
505
+ .filter((name) => typeof name === "string");
506
+ }
507
+ catch {
508
+ return [];
509
+ }
510
+ }
430
511
  /**
431
512
  * @param {unknown} db
432
513
  * @returns {{ run: (sql: string) => unknown; query: (sql: string) => { run: (...args: unknown[]) => unknown; get: (...args: unknown[]) => Record<string, unknown> | null | undefined; all: () => Array<Record<string, unknown>> }; exec: (sql: string) => unknown; $client?: unknown }}
@@ -1191,14 +1272,19 @@ export class SmithersDb {
1191
1272
  return runnableEffect(this.read(`get raw node output ${tableName}`, () => {
1192
1273
  const escaped = tableName.replaceAll(`"`, `""`);
1193
1274
  if (this.internalStorage.dialect === POSTGRES) {
1275
+ const boolColumns = getPhysicalBooleanColumnNames(findDrizzleTableByName(this.db, tableName));
1194
1276
  return this.internalStorage
1195
1277
  .queryOneRaw(`SELECT * FROM "${escaped}" WHERE run_id = ? AND node_id = ? ORDER BY iteration DESC LIMIT 1`, [runId, nodeId])
1196
- .then((row) => row ?? null);
1278
+ .then((row) => coerceRawBooleanColumns(row ?? null, boolColumns) ?? null);
1197
1279
  }
1198
1280
  const client = this.db.session.client;
1281
+ const boolColumns = [
1282
+ ...getPersistedBooleanColumnNames(client, tableName),
1283
+ ...getPhysicalBooleanColumnNames(findDrizzleTableByName(this.db, tableName)),
1284
+ ];
1199
1285
  const stmt = client.query(`SELECT * FROM "${escaped}" WHERE run_id = ? AND node_id = ? ORDER BY iteration DESC LIMIT 1`);
1200
1286
  const row = stmt.get(runId, nodeId);
1201
- return Promise.resolve(row ?? null);
1287
+ return Promise.resolve(coerceRawBooleanColumns(row ?? null, boolColumns) ?? null);
1202
1288
  }).pipe(Effect.catchAll(() => Effect.succeed(null))));
1203
1289
  }
1204
1290
  /**
@@ -1208,18 +1294,40 @@ export class SmithersDb {
1208
1294
  * @param {number} iteration
1209
1295
  * @returns {RunnableEffect<Record<string, unknown> | null, SmithersError>}
1210
1296
  */
1297
+ /**
1298
+ * Whether a physical table with this exact name exists in the database.
1299
+ * @param {string} tableName
1300
+ * @returns {RunnableEffect<boolean, SmithersError>}
1301
+ */
1302
+ hasPhysicalTable(tableName) {
1303
+ return runnableEffect(this.read(`has physical table ${tableName}`, () => {
1304
+ if (this.internalStorage.dialect === POSTGRES) {
1305
+ return this.internalStorage
1306
+ .queryOneRaw(`SELECT 1 AS one FROM information_schema.tables WHERE table_name = ? LIMIT 1`, [tableName])
1307
+ .then((row) => row != null);
1308
+ }
1309
+ const client = this.db.session.client;
1310
+ const stmt = client.query(`SELECT 1 AS one FROM sqlite_master WHERE type = 'table' AND name = ? LIMIT 1`);
1311
+ return Promise.resolve(stmt.get(tableName) != null);
1312
+ }).pipe(Effect.catchAll(() => Effect.succeed(false))));
1313
+ }
1211
1314
  getRawNodeOutputForIteration(tableName, runId, nodeId, iteration) {
1212
1315
  return runnableEffect(this.read(`get raw node output ${tableName} iteration ${iteration}`, () => {
1213
1316
  const escaped = tableName.replaceAll(`"`, `""`);
1214
1317
  if (this.internalStorage.dialect === POSTGRES) {
1318
+ const boolColumns = getPhysicalBooleanColumnNames(findDrizzleTableByName(this.db, tableName));
1215
1319
  return this.internalStorage
1216
1320
  .queryOneRaw(`SELECT * FROM "${escaped}" WHERE run_id = ? AND node_id = ? AND iteration = ? LIMIT 1`, [runId, nodeId, iteration])
1217
- .then((row) => row ?? null);
1321
+ .then((row) => coerceRawBooleanColumns(row ?? null, boolColumns) ?? null);
1218
1322
  }
1219
1323
  const client = this.db.session.client;
1324
+ const boolColumns = [
1325
+ ...getPersistedBooleanColumnNames(client, tableName),
1326
+ ...getPhysicalBooleanColumnNames(findDrizzleTableByName(this.db, tableName)),
1327
+ ];
1220
1328
  const stmt = client.query(`SELECT * FROM "${escaped}" WHERE run_id = ? AND node_id = ? AND iteration = ? LIMIT 1`);
1221
1329
  const row = stmt.get(runId, nodeId, iteration);
1222
- return Promise.resolve(row ?? null);
1330
+ return Promise.resolve(coerceRawBooleanColumns(row ?? null, boolColumns) ?? null);
1223
1331
  }).pipe(Effect.catchAll(() => Effect.succeed(null))));
1224
1332
  }
1225
1333
  /**
@@ -2125,6 +2233,34 @@ export class SmithersDb {
2125
2233
  WHERE run_id = ? AND status = ?`, [runId, "requested"], { booleanColumns: ["autoApproved"] }));
2126
2234
  }
2127
2235
  /**
2236
+ * @param {string} runId
2237
+ * @returns {RunnableEffect<ApprovalRow[], SmithersError>}
2238
+ */
2239
+ listDecidedApprovals(runId) {
2240
+ return this.read(`list decided approvals ${runId}`, () => this.internalStorage.queryAll(`SELECT a.*
2241
+ FROM _smithers_approvals a
2242
+ JOIN _smithers_nodes n
2243
+ ON a.run_id = n.run_id
2244
+ AND a.node_id = n.node_id
2245
+ AND a.iteration = n.iteration
2246
+ WHERE a.run_id = ?
2247
+ AND a.status IN ('approved', 'denied')
2248
+ AND n.state = 'pending'`, [runId], { booleanColumns: ["autoApproved"] }));
2249
+ }
2250
+ /**
2251
+ * Returns all decided approvals for a run (approved or denied), regardless of
2252
+ * node state. Used by why-diagnosis so denied gates (node state = 'failed')
2253
+ * are included in the diagnosis output.
2254
+ * @param {string} runId
2255
+ * @returns {RunnableEffect<ApprovalRow[], SmithersError>}
2256
+ */
2257
+ listAllDecidedApprovals(runId) {
2258
+ return this.read(`list all decided approvals ${runId}`, () => this.internalStorage.queryAll(`SELECT *
2259
+ FROM _smithers_approvals
2260
+ WHERE run_id = ?
2261
+ AND status IN ('approved', 'denied')`, [runId], { booleanColumns: ["autoApproved"] }));
2262
+ }
2263
+ /**
2128
2264
  * @returns {RunnableEffect<Array<Record<string, unknown>>, SmithersError>}
2129
2265
  */
2130
2266
  listAllPendingApprovals() {
@@ -2134,8 +2270,12 @@ export class SmithersDb {
2134
2270
  a.iteration,
2135
2271
  a.status,
2136
2272
  a.requested_at_ms,
2273
+ a.decided_at_ms,
2137
2274
  a.note,
2138
2275
  a.decided_by,
2276
+ a.request_json,
2277
+ a.decision_json,
2278
+ a.auto_approved,
2139
2279
  r.workflow_name,
2140
2280
  r.status AS run_status,
2141
2281
  n.label AS node_label
@@ -2146,7 +2286,7 @@ export class SmithersDb {
2146
2286
  AND a.node_id = n.node_id
2147
2287
  AND a.iteration = n.iteration
2148
2288
  WHERE a.status = ?
2149
- ORDER BY COALESCE(a.requested_at_ms, 0) ASC, a.run_id, a.node_id, a.iteration`, ["requested"]));
2289
+ ORDER BY COALESCE(a.requested_at_ms, 0) ASC, a.run_id, a.node_id, a.iteration`, ["requested"], { booleanColumns: ["autoApproved"] }));
2150
2290
  }
2151
2291
  /**
2152
2292
  * @param {string} workflowName
@@ -2558,6 +2698,20 @@ export class SmithersDb {
2558
2698
  }
2559
2699
  /**
2560
2700
  * @param {string} runId
2701
+ * @returns {RunnableEffect<ApprovalRow[], SmithersError>}
2702
+ */
2703
+ listDecidedApprovalsEffect(runId) {
2704
+ return this.listDecidedApprovals(runId);
2705
+ }
2706
+ /**
2707
+ * @param {string} runId
2708
+ * @returns {RunnableEffect<ApprovalRow[], SmithersError>}
2709
+ */
2710
+ listAllDecidedApprovalsEffect(runId) {
2711
+ return this.listAllDecidedApprovals(runId);
2712
+ }
2713
+ /**
2714
+ * @param {string} runId
2561
2715
  * @returns {RunnableEffect<FrameRow | undefined, SmithersError>}
2562
2716
  */
2563
2717
  getLastFrameEffect(runId) {
package/src/index.d.ts CHANGED
@@ -958,6 +958,16 @@ declare class SmithersDb {
958
958
  */
959
959
  listPendingApprovals(runId: string): RunnableEffect<ApprovalRow[], SmithersError$1>;
960
960
  /**
961
+ * @param {string} runId
962
+ * @returns {RunnableEffect<ApprovalRow[], SmithersError>}
963
+ */
964
+ listDecidedApprovals(runId: string): RunnableEffect<ApprovalRow[], SmithersError$1>;
965
+ /**
966
+ * @param {string} runId
967
+ * @returns {RunnableEffect<ApprovalRow[], SmithersError>}
968
+ */
969
+ listAllDecidedApprovals(runId: string): RunnableEffect<ApprovalRow[], SmithersError$1>;
970
+ /**
961
971
  * @returns {RunnableEffect<Array<Record<string, unknown>>, SmithersError>}
962
972
  */
963
973
  listAllPendingApprovals(): RunnableEffect<Array<Record<string, unknown>>, SmithersError$1>;
@@ -1179,6 +1189,16 @@ declare class SmithersDb {
1179
1189
  listPendingApprovalsEffect(runId: string): RunnableEffect<ApprovalRow[], SmithersError$1>;
1180
1190
  /**
1181
1191
  * @param {string} runId
1192
+ * @returns {RunnableEffect<ApprovalRow[], SmithersError>}
1193
+ */
1194
+ listDecidedApprovalsEffect(runId: string): RunnableEffect<ApprovalRow[], SmithersError$1>;
1195
+ /**
1196
+ * @param {string} runId
1197
+ * @returns {RunnableEffect<ApprovalRow[], SmithersError>}
1198
+ */
1199
+ listAllDecidedApprovalsEffect(runId: string): RunnableEffect<ApprovalRow[], SmithersError$1>;
1200
+ /**
1201
+ * @param {string} runId
1182
1202
  * @returns {RunnableEffect<FrameRow | undefined, SmithersError>}
1183
1203
  */
1184
1204
  getLastFrameEffect(runId: string): RunnableEffect<FrameRow | undefined, SmithersError$1>;
@@ -18,6 +18,21 @@ function sqliteTypeFor(zodFieldSchema) {
18
18
  }
19
19
  return "TEXT";
20
20
  }
21
+ function sqliteKindFor(zodFieldSchema) {
22
+ const baseType = unwrapZodType(zodFieldSchema);
23
+ const baseTypeName = getZodBaseTypeName(baseType);
24
+ if (baseTypeName === "boolean")
25
+ return "boolean";
26
+ if (baseTypeName === "number" ||
27
+ baseTypeName === "int" ||
28
+ baseTypeName === "float")
29
+ return "number";
30
+ if (baseTypeName === "string" ||
31
+ baseTypeName === "enum" ||
32
+ baseTypeName === "literal")
33
+ return "string";
34
+ return "json";
35
+ }
21
36
  function quoteIdentifier(identifier) {
22
37
  return `"${String(identifier).replaceAll(`"`, `""`)}"`;
23
38
  }
@@ -29,7 +44,7 @@ export function zodSchemaColumns(schema) {
29
44
  const out = [];
30
45
  const shape = schema.shape;
31
46
  for (const [key] of Object.entries(shape)) {
32
- out.push({ name: camelToSnake(key), sqliteType: sqliteTypeFor(shape[key]) });
47
+ out.push({ name: camelToSnake(key), sqliteType: sqliteTypeFor(shape[key]), kind: sqliteKindFor(shape[key]) });
33
48
  }
34
49
  return out;
35
50
  }
@@ -68,6 +83,18 @@ export function zodToCreateTableSQL(tableName, schema, opts) {
68
83
  */
69
84
  export function syncZodTableSchema(sqlite, tableName, schema, opts) {
70
85
  sqlite.run(zodToCreateTableSQL(tableName, schema, opts));
86
+ if (!opts?.isInput) {
87
+ try {
88
+ sqlite.run(`CREATE TABLE IF NOT EXISTS _smithers_output_schema_columns (table_name TEXT NOT NULL, column_name TEXT NOT NULL, kind TEXT NOT NULL, PRIMARY KEY (table_name, column_name))`);
89
+ const stmt = sqlite.query(`INSERT INTO _smithers_output_schema_columns (table_name, column_name, kind) VALUES (?, ?, ?) ON CONFLICT(table_name, column_name) DO UPDATE SET kind = excluded.kind`);
90
+ for (const { name, kind } of zodSchemaColumns(schema)) {
91
+ stmt.run(tableName, name, kind);
92
+ }
93
+ }
94
+ catch {
95
+ // Schema metadata is best-effort; table creation remains the source of truth.
96
+ }
97
+ }
71
98
  let existing;
72
99
  const quotedTable = quoteIdentifier(tableName);
73
100
  try {
File without changes