@smithers-orchestrator/db 0.22.0 → 0.24.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 +12 -5
- package/src/adapter.js +341 -30
- package/src/dialect.js +229 -0
- package/src/ensure.js +7 -0
- package/src/index.d.ts +20 -0
- package/src/internal-schema/index.js +2 -0
- package/src/internal-schema/smithersWorkspaceCheckpoints.js +17 -0
- package/src/internal-schema/smithersWorkspaceStates.js +11 -0
- package/src/internal-schema.js +2 -0
- package/src/output.js +20 -3
- package/src/schema-migrations.js +48 -0
- package/src/snapshot.js +121 -1
- package/src/sql-message-storage.js +188 -10
- package/src/zodToCreateTableSQL.js +36 -4
- /package/src/{runState-types.ts → runState.d.ts} +0 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@smithers-orchestrator/db",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.24.0",
|
|
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,13 +30,16 @@
|
|
|
26
30
|
"drizzle-zod": "^0.8.3",
|
|
27
31
|
"effect": "^3.21.1",
|
|
28
32
|
"zod": "^4.3.6",
|
|
29
|
-
"@smithers-orchestrator/errors": "0.
|
|
30
|
-
"@smithers-orchestrator/
|
|
31
|
-
"@smithers-orchestrator/scheduler": "0.
|
|
32
|
-
"@smithers-orchestrator/
|
|
33
|
+
"@smithers-orchestrator/errors": "0.24.0",
|
|
34
|
+
"@smithers-orchestrator/graph": "0.24.0",
|
|
35
|
+
"@smithers-orchestrator/scheduler": "0.24.0",
|
|
36
|
+
"@smithers-orchestrator/observability": "0.24.0"
|
|
33
37
|
},
|
|
34
38
|
"devDependencies": {
|
|
39
|
+
"@electric-sql/pglite": "0.5.1",
|
|
40
|
+
"@electric-sql/pglite-socket": "0.2.1",
|
|
35
41
|
"@types/bun": "latest",
|
|
42
|
+
"pg": "^8.13.1",
|
|
36
43
|
"typescript": "~5.9.3"
|
|
37
44
|
},
|
|
38
45
|
"scripts": {
|
package/src/adapter.js
CHANGED
|
@@ -11,9 +11,11 @@
|
|
|
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";
|
|
18
|
+
import { POSTGRES, beginTransactionSql } from "./dialect.js";
|
|
17
19
|
import { alertsAcknowledgedTotal, alertsActive, alertsFiredTotal, dbQueryDuration, dbTransactionDuration, dbTransactionRollbacks, } from "@smithers-orchestrator/observability/metrics";
|
|
18
20
|
import { assertOptionalStringMaxLength, assertPositiveFiniteNumber, } from "./input-bounds.js";
|
|
19
21
|
import { FRAME_KEYFRAME_INTERVAL, applyFrameDeltaJson, encodeFrameDelta, normalizeFrameEncoding, serializeFrameDelta, } from "./frame-codec.js";
|
|
@@ -24,6 +26,7 @@ import { camelToSnake } from "./utils/camelToSnake.js";
|
|
|
24
26
|
/** @typedef {import("./adapter/AlertStatus.ts").AlertStatus} AlertStatus */
|
|
25
27
|
/** @typedef {import("./adapter/AttemptRow.ts").AttemptRow} AttemptRow */
|
|
26
28
|
/** @typedef {import("drizzle-orm/bun-sqlite").BunSQLiteDatabase} BunSQLiteDatabase */
|
|
29
|
+
/** @typedef {import("drizzle-orm").Table} Table */
|
|
27
30
|
/** @typedef {import("./adapter/EventHistoryQuery.ts").EventHistoryQuery} EventHistoryQuery */
|
|
28
31
|
/** @typedef {import("./adapter/HumanRequestRow.ts").HumanRequestRow} HumanRequestRow */
|
|
29
32
|
/** @typedef {import("./output/OutputKey.ts").OutputKey} OutputKey */
|
|
@@ -409,6 +412,102 @@ function getSqliteTransactionState(client) {
|
|
|
409
412
|
}
|
|
410
413
|
return state;
|
|
411
414
|
}
|
|
415
|
+
/**
|
|
416
|
+
* Resolve a Drizzle output table's on-disk name. The name lives on the
|
|
417
|
+
* `Symbol(drizzle:Name)` slot, which `getTableName` reads; `table["_"].name` is
|
|
418
|
+
* `undefined` for these tables, so the raw-SQL Postgres path must not depend on
|
|
419
|
+
* it. Mirrors how the Drizzle `db.insert(table)` path resolves the name on
|
|
420
|
+
* SQLite.
|
|
421
|
+
* @param {unknown} table
|
|
422
|
+
* @returns {string}
|
|
423
|
+
*/
|
|
424
|
+
function resolveOutputTableName(table) {
|
|
425
|
+
try {
|
|
426
|
+
return getTableName(/** @type {Table} */ (table));
|
|
427
|
+
}
|
|
428
|
+
catch {
|
|
429
|
+
return "output";
|
|
430
|
+
}
|
|
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
|
+
}
|
|
412
511
|
/**
|
|
413
512
|
* @param {unknown} db
|
|
414
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 }}
|
|
@@ -505,6 +604,9 @@ export class SmithersDb {
|
|
|
505
604
|
}),
|
|
506
605
|
});
|
|
507
606
|
return yield* self.read(`raw query ${validatedQuery.slice(0, 20)}`, () => {
|
|
607
|
+
if (self.internalStorage.dialect === POSTGRES) {
|
|
608
|
+
return self.internalStorage.queryAllRaw(validatedQuery);
|
|
609
|
+
}
|
|
508
610
|
const client = self.db.session.client;
|
|
509
611
|
const stmt = client.query(validatedQuery);
|
|
510
612
|
return Promise.resolve(stmt.all());
|
|
@@ -648,7 +750,18 @@ export class SmithersDb {
|
|
|
648
750
|
const transactionState = getSqliteTransactionState(resolveSqliteClientKey(self.db));
|
|
649
751
|
const start = performance.now();
|
|
650
752
|
return yield* Effect.gen(function* () {
|
|
651
|
-
const
|
|
753
|
+
const isPostgres = self.internalStorage.dialect === POSTGRES;
|
|
754
|
+
const client = isPostgres ? null : yield* self.getSqliteTransactionClient();
|
|
755
|
+
/**
|
|
756
|
+
* Run a transaction-control statement on the active connection,
|
|
757
|
+
* dialect-appropriately: synchronous bun:sqlite client.run for
|
|
758
|
+
* SQLite, async @effect/sql execute (same connection) for Postgres.
|
|
759
|
+
* @param {string} sql
|
|
760
|
+
* @returns {Promise<unknown>}
|
|
761
|
+
*/
|
|
762
|
+
const runControl = (sql) => isPostgres
|
|
763
|
+
? self.internalStorage.execute(sql)
|
|
764
|
+
: Promise.resolve(client.run(sql));
|
|
652
765
|
/**
|
|
653
766
|
* @param {"operation" | "commit"} phase
|
|
654
767
|
* @param {unknown} error
|
|
@@ -660,18 +773,11 @@ export class SmithersDb {
|
|
|
660
773
|
phase,
|
|
661
774
|
error: String(error),
|
|
662
775
|
}));
|
|
663
|
-
yield* Effect.
|
|
664
|
-
try {
|
|
665
|
-
client.run("ROLLBACK");
|
|
666
|
-
}
|
|
667
|
-
catch {
|
|
668
|
-
// ignore rollback failures
|
|
669
|
-
}
|
|
670
|
-
});
|
|
776
|
+
yield* Effect.promise(() => runControl("ROLLBACK").then(() => undefined, () => undefined));
|
|
671
777
|
});
|
|
672
|
-
yield* Effect.
|
|
673
|
-
try: () => {
|
|
674
|
-
|
|
778
|
+
yield* Effect.tryPromise({
|
|
779
|
+
try: async () => {
|
|
780
|
+
await runControl(beginTransactionSql(self.internalStorage.dialect));
|
|
675
781
|
transactionState.depth += 1;
|
|
676
782
|
transactionState.ownerThread = currentFiberThread;
|
|
677
783
|
self.transactionDepth = transactionState.depth;
|
|
@@ -688,10 +794,8 @@ export class SmithersDb {
|
|
|
688
794
|
yield* rollback("operation", operationExit.cause);
|
|
689
795
|
return yield* Effect.failCause(operationExit.cause);
|
|
690
796
|
}
|
|
691
|
-
const commitExit = yield* Effect.exit(Effect.
|
|
692
|
-
try: () =>
|
|
693
|
-
client.run("COMMIT");
|
|
694
|
-
},
|
|
797
|
+
const commitExit = yield* Effect.exit(Effect.tryPromise({
|
|
798
|
+
try: () => runControl("COMMIT"),
|
|
695
799
|
catch: (cause) => toSmithersError(cause, "commit sqlite transaction", {
|
|
696
800
|
code: "DB_WRITE_FAILED",
|
|
697
801
|
details: { writeGroup, phase: "commit" },
|
|
@@ -884,9 +988,34 @@ export class SmithersDb {
|
|
|
884
988
|
*/
|
|
885
989
|
claimRunForResume(params) {
|
|
886
990
|
return this.write(`claim stale run ${params.runId}`, () => {
|
|
887
|
-
const client = this.db.session.client;
|
|
888
991
|
const expectedStatus = params.expectedStatus ?? "running";
|
|
889
992
|
const requireStale = params.requireStale ?? expectedStatus === "running";
|
|
993
|
+
if (this.internalStorage.dialect === POSTGRES) {
|
|
994
|
+
// Null-safe heartbeat compare without wrapping the bigint param in a
|
|
995
|
+
// numeric COALESCE(?, -1): the int4 `-1` literal would force the
|
|
996
|
+
// ms-timestamp param to int4 and overflow. Compare against the
|
|
997
|
+
// bigint column directly so Postgres infers bigint.
|
|
998
|
+
return this.internalStorage
|
|
999
|
+
.queryAllRaw(`UPDATE _smithers_runs
|
|
1000
|
+
SET runtime_owner_id = ?, heartbeat_at_ms = ?
|
|
1001
|
+
WHERE run_id = ?
|
|
1002
|
+
AND status = ?
|
|
1003
|
+
AND COALESCE(runtime_owner_id, '') = COALESCE(?, '')
|
|
1004
|
+
AND (heartbeat_at_ms IS NOT DISTINCT FROM ?)
|
|
1005
|
+
AND (? = 0 OR heartbeat_at_ms IS NULL OR heartbeat_at_ms < ?)
|
|
1006
|
+
RETURNING run_id`, [
|
|
1007
|
+
params.claimOwnerId,
|
|
1008
|
+
params.claimHeartbeatAtMs,
|
|
1009
|
+
params.runId,
|
|
1010
|
+
expectedStatus,
|
|
1011
|
+
params.expectedRuntimeOwnerId,
|
|
1012
|
+
params.expectedHeartbeatAtMs,
|
|
1013
|
+
requireStale ? 1 : 0,
|
|
1014
|
+
params.staleBeforeMs,
|
|
1015
|
+
])
|
|
1016
|
+
.then((rows) => rows.length > 0);
|
|
1017
|
+
}
|
|
1018
|
+
const client = this.db.session.client;
|
|
890
1019
|
client
|
|
891
1020
|
.query(`UPDATE _smithers_runs
|
|
892
1021
|
SET runtime_owner_id = ?, heartbeat_at_ms = ?
|
|
@@ -924,19 +1053,33 @@ export class SmithersDb {
|
|
|
924
1053
|
updateClaimedRun(params) {
|
|
925
1054
|
validateRunPatch(params.patch);
|
|
926
1055
|
return this.write(`update claimed run ${params.runId}`, () => {
|
|
927
|
-
const client = this.db.session.client;
|
|
928
1056
|
const patchEntries = Object.entries(params.patch);
|
|
929
1057
|
if (patchEntries.length === 0) {
|
|
930
1058
|
return Promise.resolve(true);
|
|
931
1059
|
}
|
|
932
1060
|
const assignments = patchEntries.map(([key]) => `${camelToSnake(key)} = ?`);
|
|
1061
|
+
const setArgs = patchEntries.map(([, value]) => value);
|
|
1062
|
+
if (this.internalStorage.dialect === POSTGRES) {
|
|
1063
|
+
// Null-safe heartbeat compare (see claimRunForResume): IS NOT
|
|
1064
|
+
// DISTINCT FROM keeps the bigint param from being coerced to int4
|
|
1065
|
+
// by an int4 `-1` sentinel.
|
|
1066
|
+
return this.internalStorage
|
|
1067
|
+
.queryAllRaw(`UPDATE _smithers_runs
|
|
1068
|
+
SET ${assignments.join(", ")}
|
|
1069
|
+
WHERE run_id = ?
|
|
1070
|
+
AND runtime_owner_id = ?
|
|
1071
|
+
AND (heartbeat_at_ms IS NOT DISTINCT FROM ?)
|
|
1072
|
+
RETURNING run_id`, [...setArgs, params.runId, params.expectedRuntimeOwnerId, params.expectedHeartbeatAtMs])
|
|
1073
|
+
.then((rows) => rows.length > 0);
|
|
1074
|
+
}
|
|
1075
|
+
const client = this.db.session.client;
|
|
933
1076
|
client
|
|
934
1077
|
.query(`UPDATE _smithers_runs
|
|
935
1078
|
SET ${assignments.join(", ")}
|
|
936
1079
|
WHERE run_id = ?
|
|
937
1080
|
AND runtime_owner_id = ?
|
|
938
1081
|
AND COALESCE(heartbeat_at_ms, -1) = COALESCE(?, -1)`)
|
|
939
|
-
.run(...
|
|
1082
|
+
.run(...setArgs, params.runId, params.expectedRuntimeOwnerId, params.expectedHeartbeatAtMs);
|
|
940
1083
|
return this.internalStorage
|
|
941
1084
|
.queryOne("SELECT changes() AS count")
|
|
942
1085
|
.then((row) => Number(row?.count ?? 0) > 0);
|
|
@@ -1005,14 +1148,22 @@ export class SmithersDb {
|
|
|
1005
1148
|
const target = cols.iteration
|
|
1006
1149
|
? [cols.runId, cols.nodeId, cols.iteration]
|
|
1007
1150
|
: [cols.runId, cols.nodeId];
|
|
1008
|
-
const tableName = table
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1151
|
+
const tableName = resolveOutputTableName(table);
|
|
1152
|
+
const conflictColumns = cols.iteration
|
|
1153
|
+
? ["runId", "nodeId", "iteration"]
|
|
1154
|
+
: ["runId", "nodeId"];
|
|
1155
|
+
return this.write(`upsert output ${tableName}`, () => {
|
|
1156
|
+
if (this.internalStorage.dialect === POSTGRES) {
|
|
1157
|
+
return this.internalStorage.upsert(tableName, values, conflictColumns);
|
|
1158
|
+
}
|
|
1159
|
+
return this.db
|
|
1160
|
+
.insert(table)
|
|
1161
|
+
.values(values)
|
|
1162
|
+
.onConflictDoUpdate({
|
|
1163
|
+
target: target,
|
|
1164
|
+
set: values,
|
|
1165
|
+
});
|
|
1166
|
+
});
|
|
1016
1167
|
}
|
|
1017
1168
|
/**
|
|
1018
1169
|
* @param {Table} table
|
|
@@ -1030,6 +1181,14 @@ export class SmithersDb {
|
|
|
1030
1181
|
*/
|
|
1031
1182
|
deleteOutputRow(tableName, key) {
|
|
1032
1183
|
return this.write(`delete output ${tableName}`, () => {
|
|
1184
|
+
if (this.internalStorage.dialect === POSTGRES) {
|
|
1185
|
+
// PostgreSQL output tables are created from the Zod schema with
|
|
1186
|
+
// snake_case run_id/node_id/iteration columns, so no PRAGMA-based
|
|
1187
|
+
// column discovery is needed.
|
|
1188
|
+
const escapedPg = tableName.replaceAll(`"`, `""`);
|
|
1189
|
+
return this.internalStorage.execute(`DELETE FROM "${escapedPg}"
|
|
1190
|
+
WHERE run_id = ? AND node_id = ? AND iteration = ?`, [key.runId, key.nodeId, key.iteration ?? 0]);
|
|
1191
|
+
}
|
|
1033
1192
|
const client = this.db.session.client;
|
|
1034
1193
|
let resolvedTableName = tableName;
|
|
1035
1194
|
let escapedTableName = resolvedTableName.replaceAll(`"`, `""`);
|
|
@@ -1112,10 +1271,20 @@ export class SmithersDb {
|
|
|
1112
1271
|
getRawNodeOutput(tableName, runId, nodeId) {
|
|
1113
1272
|
return runnableEffect(this.read(`get raw node output ${tableName}`, () => {
|
|
1114
1273
|
const escaped = tableName.replaceAll(`"`, `""`);
|
|
1274
|
+
if (this.internalStorage.dialect === POSTGRES) {
|
|
1275
|
+
const boolColumns = getPhysicalBooleanColumnNames(findDrizzleTableByName(this.db, tableName));
|
|
1276
|
+
return this.internalStorage
|
|
1277
|
+
.queryOneRaw(`SELECT * FROM "${escaped}" WHERE run_id = ? AND node_id = ? ORDER BY iteration DESC LIMIT 1`, [runId, nodeId])
|
|
1278
|
+
.then((row) => coerceRawBooleanColumns(row ?? null, boolColumns) ?? null);
|
|
1279
|
+
}
|
|
1115
1280
|
const client = this.db.session.client;
|
|
1281
|
+
const boolColumns = [
|
|
1282
|
+
...getPersistedBooleanColumnNames(client, tableName),
|
|
1283
|
+
...getPhysicalBooleanColumnNames(findDrizzleTableByName(this.db, tableName)),
|
|
1284
|
+
];
|
|
1116
1285
|
const stmt = client.query(`SELECT * FROM "${escaped}" WHERE run_id = ? AND node_id = ? ORDER BY iteration DESC LIMIT 1`);
|
|
1117
1286
|
const row = stmt.get(runId, nodeId);
|
|
1118
|
-
return Promise.resolve(row ?? null);
|
|
1287
|
+
return Promise.resolve(coerceRawBooleanColumns(row ?? null, boolColumns) ?? null);
|
|
1119
1288
|
}).pipe(Effect.catchAll(() => Effect.succeed(null))));
|
|
1120
1289
|
}
|
|
1121
1290
|
/**
|
|
@@ -1125,13 +1294,40 @@ export class SmithersDb {
|
|
|
1125
1294
|
* @param {number} iteration
|
|
1126
1295
|
* @returns {RunnableEffect<Record<string, unknown> | null, SmithersError>}
|
|
1127
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
|
+
}
|
|
1128
1314
|
getRawNodeOutputForIteration(tableName, runId, nodeId, iteration) {
|
|
1129
1315
|
return runnableEffect(this.read(`get raw node output ${tableName} iteration ${iteration}`, () => {
|
|
1130
1316
|
const escaped = tableName.replaceAll(`"`, `""`);
|
|
1317
|
+
if (this.internalStorage.dialect === POSTGRES) {
|
|
1318
|
+
const boolColumns = getPhysicalBooleanColumnNames(findDrizzleTableByName(this.db, tableName));
|
|
1319
|
+
return this.internalStorage
|
|
1320
|
+
.queryOneRaw(`SELECT * FROM "${escaped}" WHERE run_id = ? AND node_id = ? AND iteration = ? LIMIT 1`, [runId, nodeId, iteration])
|
|
1321
|
+
.then((row) => coerceRawBooleanColumns(row ?? null, boolColumns) ?? null);
|
|
1322
|
+
}
|
|
1131
1323
|
const client = this.db.session.client;
|
|
1324
|
+
const boolColumns = [
|
|
1325
|
+
...getPersistedBooleanColumnNames(client, tableName),
|
|
1326
|
+
...getPhysicalBooleanColumnNames(findDrizzleTableByName(this.db, tableName)),
|
|
1327
|
+
];
|
|
1132
1328
|
const stmt = client.query(`SELECT * FROM "${escaped}" WHERE run_id = ? AND node_id = ? AND iteration = ? LIMIT 1`);
|
|
1133
1329
|
const row = stmt.get(runId, nodeId, iteration);
|
|
1134
|
-
return Promise.resolve(row ?? null);
|
|
1330
|
+
return Promise.resolve(coerceRawBooleanColumns(row ?? null, boolColumns) ?? null);
|
|
1135
1331
|
}).pipe(Effect.catchAll(() => Effect.succeed(null))));
|
|
1136
1332
|
}
|
|
1137
1333
|
/**
|
|
@@ -1810,6 +2006,75 @@ export class SmithersDb {
|
|
|
1810
2006
|
ORDER BY attempt ASC, seq ASC`, [runId, nodeId, iteration]));
|
|
1811
2007
|
}
|
|
1812
2008
|
/**
|
|
2009
|
+
* Record a distinct working-copy state (deduped by jj commit id). Upsert so a
|
|
2010
|
+
* re-snapshot of the same tree refreshes the operation handle.
|
|
2011
|
+
* @param {Record<string, unknown>} row
|
|
2012
|
+
* @returns {RunnableEffect<void, SmithersError>}
|
|
2013
|
+
*/
|
|
2014
|
+
upsertWorkspaceState(row) {
|
|
2015
|
+
return this.write(`upsert workspace state ${row.jjCommitId}`, () => this.internalStorage.upsert("_smithers_workspace_states", row, ["runId", "jjCwd", "jjCommitId"]));
|
|
2016
|
+
}
|
|
2017
|
+
/**
|
|
2018
|
+
* @param {string} runId
|
|
2019
|
+
* @returns {RunnableEffect<Array<Record<string, unknown>>, SmithersError>}
|
|
2020
|
+
*/
|
|
2021
|
+
listWorkspaceStates(runId) {
|
|
2022
|
+
return this.read(`list workspace states ${runId}`, () => this.internalStorage.queryAll(`SELECT *
|
|
2023
|
+
FROM _smithers_workspace_states
|
|
2024
|
+
WHERE run_id = ?
|
|
2025
|
+
ORDER BY created_at_ms ASC`, [runId]));
|
|
2026
|
+
}
|
|
2027
|
+
/**
|
|
2028
|
+
* Record a snapshot checkpoint event. One row per tool/watch boundary; never
|
|
2029
|
+
* deduped, so a Tier 1 boundary always has a seq to bind a resume to.
|
|
2030
|
+
* @param {Record<string, unknown>} row
|
|
2031
|
+
* @returns {RunnableEffect<void, SmithersError>}
|
|
2032
|
+
*/
|
|
2033
|
+
insertWorkspaceCheckpoint(row) {
|
|
2034
|
+
return this.write(`insert workspace checkpoint ${row.nodeId}#${row.seq}`, () => this.internalStorage.insertIgnore("_smithers_workspace_checkpoints", row));
|
|
2035
|
+
}
|
|
2036
|
+
/**
|
|
2037
|
+
* @param {string} runId
|
|
2038
|
+
* @returns {RunnableEffect<Array<Record<string, unknown>>, SmithersError>}
|
|
2039
|
+
*/
|
|
2040
|
+
listWorkspaceCheckpoints(runId) {
|
|
2041
|
+
return this.read(`list workspace checkpoints ${runId}`, () => this.internalStorage.queryAll(`SELECT *
|
|
2042
|
+
FROM _smithers_workspace_checkpoints
|
|
2043
|
+
WHERE run_id = ?
|
|
2044
|
+
ORDER BY seq ASC`, [runId]));
|
|
2045
|
+
}
|
|
2046
|
+
/**
|
|
2047
|
+
* Prune old workspace states for a run, keeping the most recent maxKeep by
|
|
2048
|
+
* created_at_ms. The latest rows are always kept. Row-value NOT IN avoids the
|
|
2049
|
+
* string-concat collision a naive key would have; portable to SQLite + Postgres.
|
|
2050
|
+
* @param {string} runId
|
|
2051
|
+
* @param {number} [maxKeep]
|
|
2052
|
+
* @returns {RunnableEffect<void, SmithersError>}
|
|
2053
|
+
*/
|
|
2054
|
+
pruneWorkspaceStates(runId, maxKeep = 50) {
|
|
2055
|
+
const keep = Math.max(1, maxKeep);
|
|
2056
|
+
return this.write(`prune workspace states ${runId}`, () => this.internalStorage.deleteWhere("_smithers_workspace_states", `run_id = ? AND (jj_cwd, jj_commit_id) NOT IN (
|
|
2057
|
+
SELECT jj_cwd, jj_commit_id FROM _smithers_workspace_states
|
|
2058
|
+
WHERE run_id = ? ORDER BY created_at_ms DESC LIMIT ?)`, [runId, runId, keep]));
|
|
2059
|
+
}
|
|
2060
|
+
/**
|
|
2061
|
+
* Prune old workspace checkpoints, keeping the most recent maxKeepPerScope per
|
|
2062
|
+
* (node_id, iteration, attempt) via a window function. The latest checkpoint per
|
|
2063
|
+
* scope is always kept, so resume-restore targets survive.
|
|
2064
|
+
* @param {string} runId
|
|
2065
|
+
* @param {number} [maxKeepPerScope]
|
|
2066
|
+
* @returns {RunnableEffect<void, SmithersError>}
|
|
2067
|
+
*/
|
|
2068
|
+
pruneWorkspaceCheckpoints(runId, maxKeepPerScope = 100) {
|
|
2069
|
+
const keep = Math.max(1, maxKeepPerScope);
|
|
2070
|
+
return this.write(`prune workspace checkpoints ${runId}`, () => this.internalStorage.deleteWhere("_smithers_workspace_checkpoints", `run_id = ? AND (node_id, iteration, attempt, seq) NOT IN (
|
|
2071
|
+
SELECT node_id, iteration, attempt, seq FROM (
|
|
2072
|
+
SELECT node_id, iteration, attempt, seq,
|
|
2073
|
+
ROW_NUMBER() OVER (PARTITION BY node_id, iteration, attempt ORDER BY seq DESC) AS rn
|
|
2074
|
+
FROM _smithers_workspace_checkpoints WHERE run_id = ?
|
|
2075
|
+
) sub WHERE rn <= ?)`, [runId, runId, keep]));
|
|
2076
|
+
}
|
|
2077
|
+
/**
|
|
1813
2078
|
* @param {Record<string, unknown>} row
|
|
1814
2079
|
* @returns {RunnableEffect<void, SmithersError>}
|
|
1815
2080
|
*/
|
|
@@ -1968,6 +2233,34 @@ export class SmithersDb {
|
|
|
1968
2233
|
WHERE run_id = ? AND status = ?`, [runId, "requested"], { booleanColumns: ["autoApproved"] }));
|
|
1969
2234
|
}
|
|
1970
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
|
+
/**
|
|
1971
2264
|
* @returns {RunnableEffect<Array<Record<string, unknown>>, SmithersError>}
|
|
1972
2265
|
*/
|
|
1973
2266
|
listAllPendingApprovals() {
|
|
@@ -1977,8 +2270,12 @@ export class SmithersDb {
|
|
|
1977
2270
|
a.iteration,
|
|
1978
2271
|
a.status,
|
|
1979
2272
|
a.requested_at_ms,
|
|
2273
|
+
a.decided_at_ms,
|
|
1980
2274
|
a.note,
|
|
1981
2275
|
a.decided_by,
|
|
2276
|
+
a.request_json,
|
|
2277
|
+
a.decision_json,
|
|
2278
|
+
a.auto_approved,
|
|
1982
2279
|
r.workflow_name,
|
|
1983
2280
|
r.status AS run_status,
|
|
1984
2281
|
n.label AS node_label
|
|
@@ -1989,7 +2286,7 @@ export class SmithersDb {
|
|
|
1989
2286
|
AND a.node_id = n.node_id
|
|
1990
2287
|
AND a.iteration = n.iteration
|
|
1991
2288
|
WHERE a.status = ?
|
|
1992
|
-
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"] }));
|
|
1993
2290
|
}
|
|
1994
2291
|
/**
|
|
1995
2292
|
* @param {string} workflowName
|
|
@@ -2401,6 +2698,20 @@ export class SmithersDb {
|
|
|
2401
2698
|
}
|
|
2402
2699
|
/**
|
|
2403
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
|
|
2404
2715
|
* @returns {RunnableEffect<FrameRow | undefined, SmithersError>}
|
|
2405
2716
|
*/
|
|
2406
2717
|
getLastFrameEffect(runId) {
|