@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/src/dialect.js
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SQL dialect seam for the Smithers persistence layer.
|
|
3
|
+
*
|
|
4
|
+
* Smithers' storage was authored against SQLite (bun:sqlite). This module is the
|
|
5
|
+
* single source of truth for the handful of places where SQLite and PostgreSQL
|
|
6
|
+
* SQL diverge, so the same hand-written SQL the adapter emits can run unchanged
|
|
7
|
+
* against either backend:
|
|
8
|
+
*
|
|
9
|
+
* - placeholder style: SQLite `?` vs PostgreSQL `$1, $2, …`
|
|
10
|
+
* - DDL types: SQLite's permissive `INTEGER`/`REAL`/`BLOB` vs PostgreSQL's
|
|
11
|
+
* stricter `BIGINT`/`DOUBLE PRECISION`/`BYTEA` (millisecond timestamps stored
|
|
12
|
+
* in `INTEGER` overflow PostgreSQL's 32-bit `integer`, hence `BIGINT`)
|
|
13
|
+
* - autoincrement: `INTEGER PRIMARY KEY AUTOINCREMENT` vs `BIGSERIAL PRIMARY KEY`
|
|
14
|
+
* - schema introspection: `PRAGMA table_info` vs `information_schema.columns`
|
|
15
|
+
* - transaction start: `BEGIN IMMEDIATE` (SQLite write-lock) vs `BEGIN`
|
|
16
|
+
*
|
|
17
|
+
* Identifier quoting (`"x"`), `ON CONFLICT … DO UPDATE SET … = excluded.…`, and
|
|
18
|
+
* `CREATE TABLE/INDEX IF NOT EXISTS` are already standard across both dialects,
|
|
19
|
+
* so they need no translation.
|
|
20
|
+
*
|
|
21
|
+
* @typedef {"sqlite" | "postgres"} Dialect
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
export const SQLITE = /** @type {const} */ ("sqlite");
|
|
25
|
+
export const POSTGRES = /** @type {const} */ ("postgres");
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* @param {unknown} value
|
|
29
|
+
* @returns {value is Dialect}
|
|
30
|
+
*/
|
|
31
|
+
export function isDialect(value) {
|
|
32
|
+
return value === SQLITE || value === POSTGRES;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* @param {string} identifier
|
|
37
|
+
* @returns {string}
|
|
38
|
+
*/
|
|
39
|
+
export function quoteIdentifier(identifier) {
|
|
40
|
+
return `"${String(identifier).replaceAll(`"`, `""`)}"`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Rewrite positional `?` placeholders to PostgreSQL's `$1, $2, …`. SQLite keeps
|
|
45
|
+
* `?`. A `?` inside a string literal, quoted identifier, or comment is left
|
|
46
|
+
* untouched so only real placeholders are renumbered.
|
|
47
|
+
*
|
|
48
|
+
* @param {Dialect} dialect
|
|
49
|
+
* @param {string} sql
|
|
50
|
+
* @returns {string}
|
|
51
|
+
*/
|
|
52
|
+
export function translatePlaceholders(dialect, sql) {
|
|
53
|
+
if (dialect !== POSTGRES) {
|
|
54
|
+
return sql;
|
|
55
|
+
}
|
|
56
|
+
let out = "";
|
|
57
|
+
let index = 1;
|
|
58
|
+
let inSingle = false;
|
|
59
|
+
let inDouble = false;
|
|
60
|
+
let inLineComment = false;
|
|
61
|
+
let inBlockComment = false;
|
|
62
|
+
for (let i = 0; i < sql.length; i++) {
|
|
63
|
+
const ch = sql[i];
|
|
64
|
+
const next = sql[i + 1];
|
|
65
|
+
if (inLineComment) {
|
|
66
|
+
out += ch;
|
|
67
|
+
if (ch === "\n") inLineComment = false;
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
if (inBlockComment) {
|
|
71
|
+
out += ch;
|
|
72
|
+
if (ch === "*" && next === "/") {
|
|
73
|
+
out += next;
|
|
74
|
+
i++;
|
|
75
|
+
inBlockComment = false;
|
|
76
|
+
}
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
if (inSingle) {
|
|
80
|
+
out += ch;
|
|
81
|
+
if (ch === "'") {
|
|
82
|
+
if (next === "'") {
|
|
83
|
+
out += next;
|
|
84
|
+
i++;
|
|
85
|
+
} else {
|
|
86
|
+
inSingle = false;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
if (inDouble) {
|
|
92
|
+
out += ch;
|
|
93
|
+
if (ch === `"`) {
|
|
94
|
+
if (next === `"`) {
|
|
95
|
+
out += next;
|
|
96
|
+
i++;
|
|
97
|
+
} else {
|
|
98
|
+
inDouble = false;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
if (ch === "'") {
|
|
104
|
+
inSingle = true;
|
|
105
|
+
out += ch;
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
if (ch === `"`) {
|
|
109
|
+
inDouble = true;
|
|
110
|
+
out += ch;
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
if (ch === "-" && next === "-") {
|
|
114
|
+
inLineComment = true;
|
|
115
|
+
out += ch;
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
if (ch === "/" && next === "*") {
|
|
119
|
+
inBlockComment = true;
|
|
120
|
+
out += ch;
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
if (ch === "?") {
|
|
124
|
+
out += `$${index++}`;
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
out += ch;
|
|
128
|
+
}
|
|
129
|
+
return out;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Map a single SQLite column type keyword (`INTEGER`, `REAL`, `BLOB`, `TEXT`) to
|
|
134
|
+
* the dialect equivalent. Used by the Zod→DDL generator, which only ever emits
|
|
135
|
+
* `INTEGER` or `TEXT`.
|
|
136
|
+
*
|
|
137
|
+
* @param {Dialect} dialect
|
|
138
|
+
* @param {string} sqliteType
|
|
139
|
+
* @returns {string}
|
|
140
|
+
*/
|
|
141
|
+
export function columnType(dialect, sqliteType) {
|
|
142
|
+
if (dialect !== POSTGRES) {
|
|
143
|
+
return sqliteType;
|
|
144
|
+
}
|
|
145
|
+
switch (sqliteType.toUpperCase()) {
|
|
146
|
+
case "INTEGER":
|
|
147
|
+
return "BIGINT";
|
|
148
|
+
case "REAL":
|
|
149
|
+
return "DOUBLE PRECISION";
|
|
150
|
+
case "BLOB":
|
|
151
|
+
return "BYTEA";
|
|
152
|
+
default:
|
|
153
|
+
return sqliteType;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Translate a complete `CREATE TABLE`/`CREATE INDEX` statement written in SQLite
|
|
159
|
+
* DDL to the target dialect. Only the controlled, internal Smithers DDL passes
|
|
160
|
+
* through here (no user-supplied text), so keyword-level regex substitution is
|
|
161
|
+
* safe. Order matters: the autoincrement primary key is rewritten before the
|
|
162
|
+
* blanket `INTEGER → BIGINT` so it is not double-translated.
|
|
163
|
+
*
|
|
164
|
+
* @param {Dialect} dialect
|
|
165
|
+
* @param {string} ddl
|
|
166
|
+
* @returns {string}
|
|
167
|
+
*/
|
|
168
|
+
export function translateDdl(dialect, ddl) {
|
|
169
|
+
if (dialect !== POSTGRES) {
|
|
170
|
+
return ddl;
|
|
171
|
+
}
|
|
172
|
+
return ddl
|
|
173
|
+
.replace(/\bINTEGER\s+PRIMARY\s+KEY\s+AUTOINCREMENT\b/gi, "BIGSERIAL PRIMARY KEY")
|
|
174
|
+
.replace(/\s+AUTOINCREMENT\b/gi, "")
|
|
175
|
+
.replace(/\bBLOB\b/gi, "BYTEA")
|
|
176
|
+
.replace(/\bREAL\b/gi, "DOUBLE PRECISION")
|
|
177
|
+
.replace(/\bINTEGER\b/gi, "BIGINT");
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* SQL that lists the column names of a table, normalized to rows shaped
|
|
182
|
+
* `{ name: string }`.
|
|
183
|
+
*
|
|
184
|
+
* @param {Dialect} dialect
|
|
185
|
+
* @param {string} table
|
|
186
|
+
* @returns {{ sql: string; params: ReadonlyArray<string> }}
|
|
187
|
+
*/
|
|
188
|
+
export function tableColumnsSql(dialect, table) {
|
|
189
|
+
if (dialect === POSTGRES) {
|
|
190
|
+
return {
|
|
191
|
+
sql: "SELECT column_name AS name FROM information_schema.columns WHERE table_name = ? AND table_schema = current_schema()",
|
|
192
|
+
params: [table],
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
return {
|
|
196
|
+
sql: `PRAGMA table_info(${quoteIdentifier(table)})`,
|
|
197
|
+
params: [],
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* The statement that begins a write transaction. SQLite takes the write lock
|
|
203
|
+
* eagerly with `BEGIN IMMEDIATE`; PostgreSQL uses a plain `BEGIN`.
|
|
204
|
+
*
|
|
205
|
+
* @param {Dialect} dialect
|
|
206
|
+
* @returns {string}
|
|
207
|
+
*/
|
|
208
|
+
export function beginTransactionSql(dialect) {
|
|
209
|
+
return dialect === POSTGRES ? "BEGIN" : "BEGIN IMMEDIATE";
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* A scalar SQL expression extracting a top-level string field from a JSON column
|
|
214
|
+
* stored as TEXT. SQLite uses `json_extract(col, '$.key')`; PostgreSQL casts to
|
|
215
|
+
* `json` and uses the `->>` operator. Only simple top-level paths (`$.key`) are
|
|
216
|
+
* used in Smithers' queries.
|
|
217
|
+
*
|
|
218
|
+
* @param {Dialect} dialect
|
|
219
|
+
* @param {string} columnSql Already-quoted/qualified column expression.
|
|
220
|
+
* @param {string} jsonPath SQLite-style path, e.g. `$.nodeId`.
|
|
221
|
+
* @returns {string}
|
|
222
|
+
*/
|
|
223
|
+
export function jsonExtractText(dialect, columnSql, jsonPath) {
|
|
224
|
+
if (dialect === POSTGRES) {
|
|
225
|
+
const key = jsonPath.replace(/^\$\./, "").replace(/'/g, "''");
|
|
226
|
+
return `(${columnSql}::json->>'${key}')`;
|
|
227
|
+
}
|
|
228
|
+
return `json_extract(${columnSql}, '${jsonPath.replace(/'/g, "''")}')`;
|
|
229
|
+
}
|
package/src/ensure.js
CHANGED
|
@@ -14,5 +14,12 @@ export function ensureSmithersTablesEffect(db) {
|
|
|
14
14
|
* @param {_BunSQLiteDatabase<Record<string, unknown>>} db
|
|
15
15
|
*/
|
|
16
16
|
export function ensureSmithersTables(db) {
|
|
17
|
+
// Postgres schema initialization is asynchronous and cannot be driven by
|
|
18
|
+
// runSync. The Postgres/PGlite entry points ensure the schema (awaited)
|
|
19
|
+
// before the engine starts, so this synchronous SQLite helper is a no-op
|
|
20
|
+
// for a Postgres connection descriptor.
|
|
21
|
+
if (db && typeof db === "object" && /** @type {any} */ (db).dialect === "postgres") {
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
17
24
|
Effect.runSync(ensureSmithersTablesEffect(db));
|
|
18
25
|
}
|
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>;
|
|
@@ -20,3 +20,5 @@ export { smithersMemoryMessages } from "./smithersMemoryMessages.js";
|
|
|
20
20
|
export { smithersVectors } from "./smithersVectors.js";
|
|
21
21
|
export { smithersCron } from "./smithersCron.js";
|
|
22
22
|
export { smithersSchemaMigrations } from "./smithersSchemaMigrations.js";
|
|
23
|
+
export { smithersWorkspaceStates } from "./smithersWorkspaceStates.js";
|
|
24
|
+
export { smithersWorkspaceCheckpoints } from "./smithersWorkspaceCheckpoints.js";
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { integer, primaryKey, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
|
2
|
+
export const smithersWorkspaceCheckpoints = sqliteTable("_smithers_workspace_checkpoints", {
|
|
3
|
+
runId: text("run_id").notNull(),
|
|
4
|
+
nodeId: text("node_id").notNull(),
|
|
5
|
+
iteration: integer("iteration").notNull().default(0),
|
|
6
|
+
attempt: integer("attempt").notNull(),
|
|
7
|
+
seq: integer("seq").notNull(),
|
|
8
|
+
jjCwd: text("jj_cwd").notNull(),
|
|
9
|
+
jjCommitId: text("jj_commit_id").notNull(),
|
|
10
|
+
source: text("source").notNull(),
|
|
11
|
+
tier: integer("tier").notNull(),
|
|
12
|
+
label: text("label"),
|
|
13
|
+
toolUseId: text("tool_use_id"),
|
|
14
|
+
createdAtMs: integer("created_at_ms").notNull(),
|
|
15
|
+
}, (t) => ({
|
|
16
|
+
pk: primaryKey({ columns: [t.runId, t.nodeId, t.iteration, t.attempt, t.seq] }),
|
|
17
|
+
}));
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { integer, primaryKey, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
|
2
|
+
export const smithersWorkspaceStates = sqliteTable("_smithers_workspace_states", {
|
|
3
|
+
runId: text("run_id").notNull(),
|
|
4
|
+
jjCwd: text("jj_cwd").notNull(),
|
|
5
|
+
jjCommitId: text("jj_commit_id").notNull(),
|
|
6
|
+
jjOperationId: text("jj_operation_id").notNull(),
|
|
7
|
+
jjChangeId: text("jj_change_id"),
|
|
8
|
+
createdAtMs: integer("created_at_ms").notNull(),
|
|
9
|
+
}, (t) => ({
|
|
10
|
+
pk: primaryKey({ columns: [t.runId, t.jjCwd, t.jjCommitId] }),
|
|
11
|
+
}));
|
package/src/internal-schema.js
CHANGED
|
@@ -268,3 +268,5 @@ export const smithersSchemaMigrations = sqliteTable("_smithers_schema_migrations
|
|
|
268
268
|
.default(false),
|
|
269
269
|
detailsJson: text("details_json"),
|
|
270
270
|
});
|
|
271
|
+
export { smithersWorkspaceStates } from "./internal-schema/smithersWorkspaceStates.js";
|
|
272
|
+
export { smithersWorkspaceCheckpoints } from "./internal-schema/smithersWorkspaceCheckpoints.js";
|
package/src/output.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import { and, eq } from "drizzle-orm";
|
|
1
|
+
import { and, eq, getTableName } from "drizzle-orm";
|
|
2
2
|
import { getTableColumns } from "drizzle-orm/utils";
|
|
3
3
|
import { createInsertSchema, createSelectSchema } from "drizzle-zod";
|
|
4
4
|
import { Effect } from "effect";
|
|
5
5
|
import { z } from "zod";
|
|
6
6
|
import { SmithersError } from "@smithers-orchestrator/errors/SmithersError";
|
|
7
7
|
import { toSmithersError } from "@smithers-orchestrator/errors/toSmithersError";
|
|
8
|
+
import { getJsonColumnKeys, isPostgresDb, pgRowToDrizzle } from "./snapshot.js";
|
|
8
9
|
import { withSqliteWriteRetryEffect } from "./write-retry.js";
|
|
9
10
|
/** @typedef {import("drizzle-orm").AnyColumn} AnyColumn */
|
|
10
11
|
/** @typedef {import("./output/OutputKey.ts").OutputKey} _OutputKey */
|
|
@@ -76,9 +77,25 @@ export function buildKeyWhere(table, key) {
|
|
|
76
77
|
* @returns {Effect.Effect<T | undefined, SmithersError>}
|
|
77
78
|
*/
|
|
78
79
|
export function selectOutputRowEffect(db, table, key) {
|
|
79
|
-
const
|
|
80
|
+
const cols = getKeyColumns(table);
|
|
81
|
+
const hasIteration = Boolean(cols.iteration);
|
|
82
|
+
const jsonKeys = getJsonColumnKeys(table);
|
|
80
83
|
return Effect.tryPromise({
|
|
81
|
-
try: () =>
|
|
84
|
+
try: () => {
|
|
85
|
+
if (isPostgresDb(db)) {
|
|
86
|
+
const tableName = getTableName(table).replaceAll(`"`, `""`);
|
|
87
|
+
const clauses = ["run_id = $1", "node_id = $2"];
|
|
88
|
+
const values = [key.runId, key.nodeId];
|
|
89
|
+
if (hasIteration) {
|
|
90
|
+
clauses.push("iteration = $3");
|
|
91
|
+
values.push(key.iteration ?? 0);
|
|
92
|
+
}
|
|
93
|
+
return db.connection
|
|
94
|
+
.query({ text: `SELECT * FROM "${tableName}" WHERE ${clauses.join(" AND ")} LIMIT 1`, values })
|
|
95
|
+
.then((result) => (result.rows[0] ? [pgRowToDrizzle(result.rows[0], jsonKeys)] : []));
|
|
96
|
+
}
|
|
97
|
+
return db.select().from(table).where(buildKeyWhere(table, key)).limit(1);
|
|
98
|
+
},
|
|
82
99
|
catch: (cause) => toSmithersError(cause, `select output ${table["_"]?.name ?? "output"}`, {
|
|
83
100
|
code: "DB_QUERY_FAILED",
|
|
84
101
|
details: { outputTable: table["_"]?.name ?? "output" },
|
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 { POSTGRES, translateDdl } from "./dialect.js";
|
|
3
4
|
import { smithersSchemaMigrations } from "./internal-schema/smithersSchemaMigrations.js";
|
|
4
5
|
|
|
5
6
|
const MIGRATION_TABLE_SQL = `CREATE TABLE IF NOT EXISTS _smithers_schema_migrations (
|
|
@@ -452,6 +453,26 @@ function buildMigrations(context) {
|
|
|
452
453
|
return { statementCount: context.createIndexStatements.length + EXTRA_INDEX_STATEMENTS.length };
|
|
453
454
|
},
|
|
454
455
|
},
|
|
456
|
+
{
|
|
457
|
+
id: "0015_add_workspace_states",
|
|
458
|
+
name: "Add workspace snapshot states table",
|
|
459
|
+
checksum: "packages/db/migrations/0015_add_workspace_states.sql",
|
|
460
|
+
isApplied: (sqlite) => tableExists(sqlite, "_smithers_workspace_states"),
|
|
461
|
+
up: (sqlite) => {
|
|
462
|
+
sqlite.run(createTableStatementFor("_smithers_workspace_states", context.createTableStatements));
|
|
463
|
+
return { table: "_smithers_workspace_states" };
|
|
464
|
+
},
|
|
465
|
+
},
|
|
466
|
+
{
|
|
467
|
+
id: "0016_add_workspace_checkpoints",
|
|
468
|
+
name: "Add workspace snapshot checkpoints table",
|
|
469
|
+
checksum: "packages/db/migrations/0016_add_workspace_checkpoints.sql",
|
|
470
|
+
isApplied: (sqlite) => tableExists(sqlite, "_smithers_workspace_checkpoints"),
|
|
471
|
+
up: (sqlite) => {
|
|
472
|
+
sqlite.run(createTableStatementFor("_smithers_workspace_checkpoints", context.createTableStatements));
|
|
473
|
+
return { table: "_smithers_workspace_checkpoints" };
|
|
474
|
+
},
|
|
475
|
+
},
|
|
455
476
|
];
|
|
456
477
|
}
|
|
457
478
|
|
|
@@ -479,3 +500,30 @@ export function runSmithersSchemaMigrations(sqlite, context) {
|
|
|
479
500
|
applied.add(migration.id);
|
|
480
501
|
}
|
|
481
502
|
}
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* PostgreSQL schema initialization. A fresh PostgreSQL database starts at the
|
|
506
|
+
* current schema, so it skips the SQLite-only legacy column/foreign-key
|
|
507
|
+
* migrations entirely and simply creates every current table and index
|
|
508
|
+
* idempotently, with DDL translated to PostgreSQL types. Tables are created in
|
|
509
|
+
* declaration order, which keeps foreign-key references valid (referenced
|
|
510
|
+
* tables precede their dependents).
|
|
511
|
+
*
|
|
512
|
+
* @param {{ query: (config: { text: string }) => Promise<unknown> }} pgConn
|
|
513
|
+
* @param {{
|
|
514
|
+
* createTableStatements: readonly string[];
|
|
515
|
+
* createIndexStatements: readonly string[];
|
|
516
|
+
* }} context
|
|
517
|
+
* @returns {Promise<void>}
|
|
518
|
+
*/
|
|
519
|
+
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
|
+
}
|
|
529
|
+
}
|
package/src/snapshot.js
CHANGED
|
@@ -27,6 +27,29 @@ function getBooleanColumnKeys(table) {
|
|
|
27
27
|
return [];
|
|
28
28
|
}
|
|
29
29
|
}
|
|
30
|
+
/**
|
|
31
|
+
* Keys of every json-mode column on the table. zodToTable maps any
|
|
32
|
+
* array/object/union/complex Zod field to a Drizzle `text(col,{mode:'json'})`
|
|
33
|
+
* column, which Drizzle's bun:sqlite reader auto-decodes on read. The Postgres
|
|
34
|
+
* path stores those as TEXT, so we must JSON.parse them to match.
|
|
35
|
+
* @param {_Table} table
|
|
36
|
+
* @returns {string[]}
|
|
37
|
+
*/
|
|
38
|
+
export function getJsonColumnKeys(table) {
|
|
39
|
+
try {
|
|
40
|
+
const cols = getTableColumns(table);
|
|
41
|
+
const keys = [];
|
|
42
|
+
for (const [key, col] of Object.entries(cols)) {
|
|
43
|
+
const c = /** @type {Record<string, unknown> & { config?: { mode?: string } }} */ (/** @type {unknown} */ (col));
|
|
44
|
+
if (c?.columnType === "SQLiteTextJson" || c?.config?.mode === "json" || c?.mode === "json" || c?.dataType === "json") {
|
|
45
|
+
keys.push(key);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return keys;
|
|
49
|
+
} catch {
|
|
50
|
+
return [];
|
|
51
|
+
}
|
|
52
|
+
}
|
|
30
53
|
/**
|
|
31
54
|
* @param {ReadonlyArray<Record<string, unknown>>} rows
|
|
32
55
|
* @param {readonly string[]} boolKeys
|
|
@@ -52,6 +75,45 @@ function coerceBooleanColumns(rows, boolKeys) {
|
|
|
52
75
|
* @param {string} runId
|
|
53
76
|
* @returns {Effect.Effect<Record<string, unknown> | undefined, SmithersError>}
|
|
54
77
|
*/
|
|
78
|
+
/**
|
|
79
|
+
* @param {unknown} db
|
|
80
|
+
* @returns {boolean}
|
|
81
|
+
*/
|
|
82
|
+
export function isPostgresDb(db) {
|
|
83
|
+
return Boolean(db && typeof db === "object" && /** @type {any} */ (db).dialect === "postgres" && /** @type {any} */ (db).connection);
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Map a raw node-postgres row (snake_case columns, JSON stored as TEXT) into the
|
|
87
|
+
* shape Drizzle's bun:sqlite reader returns (camelCase keys, json-mode columns
|
|
88
|
+
* decoded), so input/output consumers stay dialect-agnostic. `jsonKeys` is the
|
|
89
|
+
* set of camelCase json-mode column keys (from getJsonColumnKeys); every TEXT
|
|
90
|
+
* value for those columns is JSON.parsed to match Drizzle's mode:'json' read.
|
|
91
|
+
* The literal `payload` column is always decoded so callers that omit `jsonKeys`
|
|
92
|
+
* (single-value outputs) keep working.
|
|
93
|
+
* @param {Record<string, unknown>} row
|
|
94
|
+
* @param {readonly string[]} [jsonKeys]
|
|
95
|
+
* @returns {Record<string, unknown>}
|
|
96
|
+
*/
|
|
97
|
+
export function pgRowToDrizzle(row, jsonKeys) {
|
|
98
|
+
/** @type {Record<string, unknown>} */
|
|
99
|
+
const out = {};
|
|
100
|
+
const jsonSet = new Set(jsonKeys ?? []);
|
|
101
|
+
for (const [columnName, value] of Object.entries(row)) {
|
|
102
|
+
const camel = columnName.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase());
|
|
103
|
+
if ((camel === "payload" || jsonSet.has(camel)) && typeof value === "string") {
|
|
104
|
+
try {
|
|
105
|
+
out[camel] = JSON.parse(value);
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
out[camel] = value;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
else {
|
|
112
|
+
out[camel] = value;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return out;
|
|
116
|
+
}
|
|
55
117
|
export function loadInputEffect(db, inputTable, runId) {
|
|
56
118
|
return Effect.suspend(() => {
|
|
57
119
|
const cols = getTableColumns(inputTable);
|
|
@@ -59,6 +121,19 @@ export function loadInputEffect(db, inputTable, runId) {
|
|
|
59
121
|
if (!runIdCol) {
|
|
60
122
|
return Effect.fail(new SmithersError("DB_MISSING_COLUMNS", "schema.input must include runId column"));
|
|
61
123
|
}
|
|
124
|
+
if (isPostgresDb(db)) {
|
|
125
|
+
const tableName = getTableName(inputTable).replaceAll(`"`, `""`);
|
|
126
|
+
const jsonKeys = getJsonColumnKeys(inputTable);
|
|
127
|
+
return Effect.tryPromise({
|
|
128
|
+
try: () => db.connection
|
|
129
|
+
.query({ text: `SELECT * FROM "${tableName}" WHERE run_id = $1 LIMIT 1`, values: [runId] })
|
|
130
|
+
.then((result) => (result.rows[0] ? pgRowToDrizzle(result.rows[0], jsonKeys) : undefined)),
|
|
131
|
+
catch: (cause) => toSmithersError(cause, "load input", {
|
|
132
|
+
code: "DB_QUERY_FAILED",
|
|
133
|
+
details: { runId },
|
|
134
|
+
}),
|
|
135
|
+
});
|
|
136
|
+
}
|
|
62
137
|
return Effect.tryPromise({
|
|
63
138
|
try: () => db.select().from(inputTable).where(eq(runIdCol, runId)).limit(1),
|
|
64
139
|
catch: (cause) => toSmithersError(cause, "load input", {
|
|
@@ -104,8 +179,13 @@ export function loadOutputsEffect(db, schema, runId) {
|
|
|
104
179
|
}).pipe(Effect.option);
|
|
105
180
|
if (Option.isNone(tableNameOpt)) continue;
|
|
106
181
|
const tableName = tableNameOpt.value;
|
|
182
|
+
const jsonKeys = getJsonColumnKeys(/** @type {_Table} */ (table));
|
|
107
183
|
const rawRows = yield* Effect.tryPromise({
|
|
108
|
-
try: () => db
|
|
184
|
+
try: () => isPostgresDb(db)
|
|
185
|
+
? db.connection
|
|
186
|
+
.query({ text: `SELECT * FROM "${tableName.replaceAll(`"`, `""`)}" WHERE run_id = $1`, values: [runId] })
|
|
187
|
+
.then((result) => result.rows.map((r) => pgRowToDrizzle(r, jsonKeys)))
|
|
188
|
+
: db.select().from(/** @type {_Table} */ (table)).where(eq(runIdCol, runId)),
|
|
109
189
|
catch: (cause) => toSmithersError(cause, `load outputs ${tableName}`, { code: "DB_QUERY_FAILED", details: { runId, tableName } }),
|
|
110
190
|
});
|
|
111
191
|
const boolKeys = getBooleanColumnKeys(/** @type {_Table} */ (table));
|
|
@@ -125,3 +205,43 @@ export function loadOutputsEffect(db, schema, runId) {
|
|
|
125
205
|
export function loadOutputs(db, schema, runId) {
|
|
126
206
|
return Effect.runPromise(loadOutputsEffect(db, schema, runId));
|
|
127
207
|
}
|
|
208
|
+
/**
|
|
209
|
+
* Read every row of a single output table for a run, returning Drizzle-shaped
|
|
210
|
+
* rows (camelCase keys, boolean columns coerced to JS booleans). Dialect-aware:
|
|
211
|
+
* Drizzle for bun:sqlite, a raw `$n` query for the Postgres descriptor.
|
|
212
|
+
* @param {unknown} db
|
|
213
|
+
* @param {_Table} table
|
|
214
|
+
* @param {string} [runId]
|
|
215
|
+
* @returns {Effect.Effect<Array<Record<string, unknown>>, SmithersError>}
|
|
216
|
+
*/
|
|
217
|
+
export function loadRunOutputRowsEffect(db, table, runId) {
|
|
218
|
+
return Effect.gen(function* () {
|
|
219
|
+
const cols = getTableColumns(table);
|
|
220
|
+
const runIdCol = cols.runId;
|
|
221
|
+
const tableName = getTableName(table);
|
|
222
|
+
const boolKeys = getBooleanColumnKeys(table);
|
|
223
|
+
const jsonKeys = getJsonColumnKeys(table);
|
|
224
|
+
const rawRows = yield* Effect.tryPromise({
|
|
225
|
+
try: () => {
|
|
226
|
+
if (isPostgresDb(db)) {
|
|
227
|
+
const escaped = tableName.replaceAll(`"`, `""`);
|
|
228
|
+
const text = runId && runIdCol
|
|
229
|
+
? `SELECT * FROM "${escaped}" WHERE run_id = $1`
|
|
230
|
+
: `SELECT * FROM "${escaped}"`;
|
|
231
|
+
const values = runId && runIdCol ? [runId] : [];
|
|
232
|
+
return db.connection
|
|
233
|
+
.query({ text, values })
|
|
234
|
+
.then((result) => result.rows.map((r) => pgRowToDrizzle(r, jsonKeys)));
|
|
235
|
+
}
|
|
236
|
+
return runId && runIdCol
|
|
237
|
+
? db.select().from(table).where(eq(runIdCol, runId))
|
|
238
|
+
: db.select().from(table);
|
|
239
|
+
},
|
|
240
|
+
catch: (cause) => toSmithersError(cause, `load run output ${tableName}`, {
|
|
241
|
+
code: "DB_QUERY_FAILED",
|
|
242
|
+
details: { runId, tableName },
|
|
243
|
+
}),
|
|
244
|
+
});
|
|
245
|
+
return coerceBooleanColumns(rawRows, boolKeys);
|
|
246
|
+
}).pipe(Effect.annotateLogs({ runId: runId ?? "" }), Effect.withLogSpan("db:load-run-output"));
|
|
247
|
+
}
|