@hyperfixation/db 0.1.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.
Files changed (65) hide show
  1. package/LICENSE +21 -0
  2. package/dist/app-state.d.ts +12 -0
  3. package/dist/app-state.js +18 -0
  4. package/dist/boot-checks.d.ts +56 -0
  5. package/dist/boot-checks.js +228 -0
  6. package/dist/classify.d.ts +6 -0
  7. package/dist/classify.js +75 -0
  8. package/dist/control-plane.d.ts +75 -0
  9. package/dist/control-plane.js +155 -0
  10. package/dist/delete-guard.d.ts +32 -0
  11. package/dist/delete-guard.js +62 -0
  12. package/dist/fenced-client.d.ts +35 -0
  13. package/dist/fenced-client.js +153 -0
  14. package/dist/grant-ro.d.ts +23 -0
  15. package/dist/grant-ro.js +49 -0
  16. package/dist/index.d.ts +12 -0
  17. package/dist/index.js +10 -0
  18. package/dist/internal/control-pool.d.ts +20 -0
  19. package/dist/internal/control-pool.js +17 -0
  20. package/dist/loader.d.ts +15 -0
  21. package/dist/loader.js +82 -0
  22. package/dist/migrate.d.ts +54 -0
  23. package/dist/migrate.js +143 -0
  24. package/dist/migration-policy.d.ts +14 -0
  25. package/dist/migration-policy.js +85 -0
  26. package/dist/migrator.d.ts +9 -0
  27. package/dist/migrator.js +9 -0
  28. package/dist/roles.d.ts +47 -0
  29. package/dist/roles.js +112 -0
  30. package/dist/schema/app.d.ts +235 -0
  31. package/dist/schema/app.js +23 -0
  32. package/dist/schema/approvals.d.ts +352 -0
  33. package/dist/schema/approvals.js +43 -0
  34. package/dist/schema/auth.d.ts +1272 -0
  35. package/dist/schema/auth.js +120 -0
  36. package/dist/schema/index.d.ts +7 -0
  37. package/dist/schema/index.js +7 -0
  38. package/dist/schema/ledger.d.ts +722 -0
  39. package/dist/schema/ledger.js +68 -0
  40. package/dist/schema/machinery.d.ts +1343 -0
  41. package/dist/schema/machinery.js +146 -0
  42. package/dist/schema/records.d.ts +15 -0
  43. package/dist/schema/records.js +16 -0
  44. package/dist/schema/runs.d.ts +213 -0
  45. package/dist/schema/runs.js +26 -0
  46. package/dist/step-pool.d.ts +26 -0
  47. package/dist/step-pool.js +57 -0
  48. package/migrations/0000_core_schema.sql +185 -0
  49. package/migrations/0001_llm_call_reservation_index.sql +4 -0
  50. package/migrations/0002_approvals.sql +25 -0
  51. package/migrations/0003_auth_invitation.sql +13 -0
  52. package/migrations/0004_machinery.sql +105 -0
  53. package/migrations/0005_nullable_activity_task_record.sql +4 -0
  54. package/migrations/0006_activity_score_key.sql +5 -0
  55. package/migrations/0007_score_spec_name.sql +3 -0
  56. package/migrations/meta/0000_snapshot.json +1238 -0
  57. package/migrations/meta/0001_snapshot.json +1238 -0
  58. package/migrations/meta/0002_snapshot.json +1426 -0
  59. package/migrations/meta/0003_snapshot.json +1516 -0
  60. package/migrations/meta/0004_snapshot.json +2353 -0
  61. package/migrations/meta/0005_snapshot.json +2353 -0
  62. package/migrations/meta/0006_snapshot.json +2415 -0
  63. package/migrations/meta/0007_snapshot.json +2427 -0
  64. package/migrations/meta/_journal.json +62 -0
  65. package/package.json +58 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Graham Lutz
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,12 @@
1
+ import type { ClientBase, Pool } from "pg";
2
+ /**
3
+ * The pause flag, read the way the step gate reads it: `COALESCE` over a missing singleton, so
4
+ * an app whose `hf_app_state` row has not been seeded is not paused rather than an error.
5
+ */
6
+ export declare const APP_PAUSED_STATEMENT = "SELECT COALESCE((SELECT paused FROM hf_app_state WHERE id = 1), false) AS paused";
7
+ /** Written by `pause`/`resume` only, inside a control-plane transaction. */
8
+ export declare const SET_APP_PAUSED_STATEMENT = "UPDATE hf_app_state SET paused = $1, paused_by = $2 WHERE id = 1";
9
+ export declare class AppStateMissing extends Error {
10
+ constructor(operation: string);
11
+ }
12
+ export declare function appPaused(queryable: Pool | ClientBase): Promise<boolean>;
@@ -0,0 +1,18 @@
1
+ /**
2
+ * The pause flag, read the way the step gate reads it: `COALESCE` over a missing singleton, so
3
+ * an app whose `hf_app_state` row has not been seeded is not paused rather than an error.
4
+ */
5
+ export const APP_PAUSED_STATEMENT = "SELECT COALESCE((SELECT paused FROM hf_app_state WHERE id = 1), false) AS paused";
6
+ /** Written by `pause`/`resume` only, inside a control-plane transaction. */
7
+ export const SET_APP_PAUSED_STATEMENT = "UPDATE hf_app_state SET paused = $1, paused_by = $2 WHERE id = 1";
8
+ export class AppStateMissing extends Error {
9
+ constructor(operation) {
10
+ super(`AppStateMissing: ${operation} matched no hf_app_state row; the singleton is seeded by ` +
11
+ "`hf bootstrap` and nothing else can pause an app that has never been bootstrapped");
12
+ this.name = "AppStateMissing";
13
+ }
14
+ }
15
+ export async function appPaused(queryable) {
16
+ const { rows } = await queryable.query(APP_PAUSED_STATEMENT);
17
+ return rows[0].paused;
18
+ }
@@ -0,0 +1,56 @@
1
+ import type { RecordTable } from "./delete-guard.js";
2
+ export type BootCheckCode = "E001" | "E002" | "E003" | "E004" | "E005" | "E006";
3
+ export declare const BOOT_CHECK_CODES: readonly BootCheckCode[];
4
+ export declare class BootCheckFailure extends Error {
5
+ readonly code: BootCheckCode;
6
+ readonly details: readonly string[];
7
+ constructor(code: BootCheckCode, summary: string, details?: readonly string[]);
8
+ }
9
+ export interface Queryable {
10
+ query(text: string, values?: unknown[]): Promise<{
11
+ rows: unknown[];
12
+ }>;
13
+ }
14
+ export interface BootCheckOptions {
15
+ /** Connection the process itself uses — for `web` and `worker`, the application role. */
16
+ databaseUrl: string;
17
+ /** Tables registered with `defineRecord`; empty until an app exists. */
18
+ recordTables?: readonly RecordTable[];
19
+ /** Directory of the app's own migrations, for E005. */
20
+ appMigrationsDir?: string;
21
+ }
22
+ /**
23
+ * E001–E006 in order, as the first statements of `startWorker()` and
24
+ * `getClient()`. Throws `BootCheckFailure` naming the first check that fails.
25
+ */
26
+ export declare function runBootChecks(options: BootCheckOptions): Promise<void>;
27
+ /** E001 — every registered record table has a `bigint` identity primary key named `id`. */
28
+ export declare function checkE001(db: Queryable, recordTables: readonly RecordTable[]): Promise<void>;
29
+ /** E002 — every `record_type` stored in a machinery table is registered. */
30
+ export declare function checkE002(db: Queryable, recordTables: readonly RecordTable[]): Promise<void>;
31
+ /** E003 — every registered record table has the trigram index on `normalized_name`. */
32
+ export declare function checkE003(db: Queryable, recordTables: readonly RecordTable[]): Promise<void>;
33
+ /** E004 — no app table references an `hf_*` table with a foreign key. */
34
+ export declare function checkE004(db: Queryable): Promise<void>;
35
+ /** E005 — no app migration creates, alters or drops an `hf_*` table. */
36
+ export declare function checkE005(appMigrationsDir?: string): Promise<void>;
37
+ /**
38
+ * E006 — the application role has `USAGE` on schema `dbos` and `INSERT` on
39
+ * `dbos.workflow_status`.
40
+ *
41
+ * Only the migrator's `dbos schema -s dbos -r hf_<app>` step grants these; the
42
+ * SDK's own system-database migrations contain no `GRANT` at all. Without it the
43
+ * worker dies at `DBOS.launch` with a bare `42501`, and `enqueueInTransaction`
44
+ * raises it from inside somebody's open transaction. So this runs before any
45
+ * statement that could hit `dbos.*`, and it reads privileges rather than
46
+ * exercising them.
47
+ *
48
+ * Two traps in reading them, both of which raise the very error the check exists
49
+ * to preempt: `has_*_privilege` on a missing schema raises `3F000`, and
50
+ * `to_regclass('dbos.workflow_status')` raises `42501` when the role has no
51
+ * `USAGE` on `dbos`, because resolving a qualified name needs the schema. Hence
52
+ * the nested `CASE` — arms are evaluated lazily, so the table lookup is never
53
+ * reached without `USAGE` — and the catch. The process must fail naming E006,
54
+ * never with a raw permission error.
55
+ */
56
+ export declare function checkE006(db: Queryable): Promise<void>;
@@ -0,0 +1,228 @@
1
+ import { readdir, readFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { Client } from "pg";
4
+ import { parse } from "pgsql-ast-parser";
5
+ export const BOOT_CHECK_CODES = [
6
+ "E001",
7
+ "E002",
8
+ "E003",
9
+ "E004",
10
+ "E005",
11
+ "E006",
12
+ ];
13
+ export class BootCheckFailure extends Error {
14
+ code;
15
+ details;
16
+ constructor(code, summary, details = []) {
17
+ super(`${code}: ${summary}${details.length > 0 ? `\n - ${details.join("\n - ")}` : ""}`);
18
+ this.name = "BootCheckFailure";
19
+ this.code = code;
20
+ this.details = details;
21
+ }
22
+ }
23
+ /**
24
+ * E001–E006 in order, as the first statements of `startWorker()` and
25
+ * `getClient()`. Throws `BootCheckFailure` naming the first check that fails.
26
+ */
27
+ export async function runBootChecks(options) {
28
+ const recordTables = options.recordTables ?? [];
29
+ const client = new Client({ connectionString: options.databaseUrl });
30
+ await client.connect();
31
+ try {
32
+ await checkE001(client, recordTables);
33
+ await checkE002(client, recordTables);
34
+ await checkE003(client, recordTables);
35
+ await checkE004(client);
36
+ await checkE005(options.appMigrationsDir);
37
+ await checkE006(client);
38
+ }
39
+ finally {
40
+ await client.end();
41
+ }
42
+ }
43
+ /** E001 — every registered record table has a `bigint` identity primary key named `id`. */
44
+ export async function checkE001(db, recordTables) {
45
+ const failures = [];
46
+ for (const { table } of recordTables) {
47
+ const { rows } = (await db.query(`SELECT a.attname AS name,
48
+ format_type(a.atttypid, a.atttypmod) AS type,
49
+ a.attidentity AS identity
50
+ FROM pg_index i
51
+ JOIN pg_class c ON c.oid = i.indrelid
52
+ JOIN pg_namespace n ON n.oid = c.relnamespace
53
+ JOIN pg_attribute a ON a.attrelid = c.oid AND a.attnum = ANY(i.indkey)
54
+ WHERE n.nspname = 'public' AND c.relname = $1 AND i.indisprimary`, [table]));
55
+ if (rows.length === 0) {
56
+ failures.push(`${table}: no primary key (or the table does not exist)`);
57
+ continue;
58
+ }
59
+ if (rows.length > 1) {
60
+ failures.push(`${table}: composite primary key (${rows.map((r) => r.name).join(", ")})`);
61
+ continue;
62
+ }
63
+ const [pk] = rows;
64
+ if (pk.name !== "id")
65
+ failures.push(`${table}: primary key is "${pk.name}", not "id"`);
66
+ else if (pk.type !== "bigint")
67
+ failures.push(`${table}: id is ${pk.type}, not bigint`);
68
+ else if (pk.identity !== "a" && pk.identity !== "d") {
69
+ failures.push(`${table}: id is not an identity column`);
70
+ }
71
+ }
72
+ if (failures.length > 0) {
73
+ throw new BootCheckFailure("E001", "a registered record table has no bigint identity primary key named id", failures);
74
+ }
75
+ }
76
+ /** E002 — every `record_type` stored in a machinery table is registered. */
77
+ export async function checkE002(db, recordTables) {
78
+ const registered = new Set(recordTables.map((r) => r.recordType));
79
+ const { rows: tableRows } = (await db.query(`SELECT c.relname AS table_name
80
+ FROM pg_attribute a
81
+ JOIN pg_class c ON c.oid = a.attrelid
82
+ JOIN pg_namespace n ON n.oid = c.relnamespace
83
+ WHERE n.nspname = 'public' AND c.relkind = 'r'
84
+ AND a.attname = 'record_type' AND a.attnum > 0 AND NOT a.attisdropped
85
+ AND c.relname LIKE 'hf\\_%'
86
+ ORDER BY c.relname`));
87
+ const failures = [];
88
+ for (const { table_name } of tableRows) {
89
+ // A null `record_type` is a row attached to no record — every `hf_run` that is not about
90
+ // one — not a row naming a type nobody registered.
91
+ const { rows } = (await db.query(`SELECT DISTINCT record_type FROM "${table_name.replace(/"/g, '""')}" WHERE record_type IS NOT NULL`));
92
+ for (const { record_type } of rows) {
93
+ if (!registered.has(record_type))
94
+ failures.push(`${table_name}: "${record_type}"`);
95
+ }
96
+ }
97
+ if (failures.length > 0) {
98
+ throw new BootCheckFailure("E002", "a machinery row references a record type no app registered", failures);
99
+ }
100
+ }
101
+ /** E003 — every registered record table has the trigram index on `normalized_name`. */
102
+ export async function checkE003(db, recordTables) {
103
+ const failures = [];
104
+ for (const { table } of recordTables) {
105
+ const { rows } = (await db.query(`SELECT 1
106
+ FROM pg_index i
107
+ JOIN pg_class c ON c.oid = i.indrelid
108
+ JOIN pg_class ic ON ic.oid = i.indexrelid
109
+ JOIN pg_am am ON am.oid = ic.relam
110
+ JOIN pg_namespace n ON n.oid = c.relnamespace
111
+ WHERE n.nspname = 'public' AND c.relname = $1 AND am.amname = 'gin'
112
+ AND pg_get_indexdef(i.indexrelid) ILIKE '%normalized\\_name%gin\\_trgm\\_ops%'`, [table]));
113
+ if (rows.length === 0) {
114
+ failures.push(`${table}: no GIN index on normalized_name using gin_trgm_ops`);
115
+ }
116
+ }
117
+ if (failures.length > 0) {
118
+ throw new BootCheckFailure("E003", "a registered record table is missing its trigram index", failures);
119
+ }
120
+ }
121
+ /** E004 — no app table references an `hf_*` table with a foreign key. */
122
+ export async function checkE004(db) {
123
+ const { rows } = (await db.query(`SELECT con.conname AS constraint_name,
124
+ child.relname AS referencing,
125
+ parent.relname AS referenced
126
+ FROM pg_constraint con
127
+ JOIN pg_class child ON child.oid = con.conrelid
128
+ JOIN pg_class parent ON parent.oid = con.confrelid
129
+ JOIN pg_namespace n ON n.oid = child.relnamespace
130
+ WHERE con.contype = 'f' AND n.nspname = 'public'
131
+ AND parent.relname LIKE 'hf\\_%' AND child.relname NOT LIKE 'hf\\_%'
132
+ ORDER BY con.conname`));
133
+ if (rows.length > 0) {
134
+ throw new BootCheckFailure("E004", "an app table has a foreign key into an hf_* table", rows.map((r) => `${r.referencing} -> ${r.referenced} (${r.constraint_name})`));
135
+ }
136
+ }
137
+ /** E005 — no app migration creates, alters or drops an `hf_*` table. */
138
+ export async function checkE005(appMigrationsDir) {
139
+ if (appMigrationsDir === undefined)
140
+ return;
141
+ const failures = [];
142
+ for (const file of await migrationFiles(appMigrationsDir)) {
143
+ const sql = await readFile(path.join(appMigrationsDir, file), "utf8");
144
+ for (const statement of splitStatements(sql)) {
145
+ let parsed;
146
+ try {
147
+ parsed = parse(statement);
148
+ }
149
+ catch (cause) {
150
+ failures.push(`${file}: unparseable statement (${cause.message})`);
151
+ continue;
152
+ }
153
+ for (const name of parsed.flatMap(hfTablesTouched)) {
154
+ failures.push(`${file}: ${name}`);
155
+ }
156
+ }
157
+ }
158
+ if (failures.length > 0) {
159
+ throw new BootCheckFailure("E005", "an app migration creates, alters or drops an hf_* table", failures);
160
+ }
161
+ }
162
+ /**
163
+ * E006 — the application role has `USAGE` on schema `dbos` and `INSERT` on
164
+ * `dbos.workflow_status`.
165
+ *
166
+ * Only the migrator's `dbos schema -s dbos -r hf_<app>` step grants these; the
167
+ * SDK's own system-database migrations contain no `GRANT` at all. Without it the
168
+ * worker dies at `DBOS.launch` with a bare `42501`, and `enqueueInTransaction`
169
+ * raises it from inside somebody's open transaction. So this runs before any
170
+ * statement that could hit `dbos.*`, and it reads privileges rather than
171
+ * exercising them.
172
+ *
173
+ * Two traps in reading them, both of which raise the very error the check exists
174
+ * to preempt: `has_*_privilege` on a missing schema raises `3F000`, and
175
+ * `to_regclass('dbos.workflow_status')` raises `42501` when the role has no
176
+ * `USAGE` on `dbos`, because resolving a qualified name needs the schema. Hence
177
+ * the nested `CASE` — arms are evaluated lazily, so the table lookup is never
178
+ * reached without `USAGE` — and the catch. The process must fail naming E006,
179
+ * never with a raw permission error.
180
+ */
181
+ export async function checkE006(db) {
182
+ const failures = [];
183
+ try {
184
+ const { rows } = (await db.query(`SELECT CASE WHEN to_regnamespace('dbos') IS NULL THEN false
185
+ ELSE has_schema_privilege('dbos', 'USAGE') END AS schema_usage,
186
+ CASE WHEN to_regnamespace('dbos') IS NULL THEN false
187
+ WHEN NOT has_schema_privilege('dbos', 'USAGE') THEN false
188
+ WHEN to_regclass('dbos.workflow_status') IS NULL THEN false
189
+ ELSE has_table_privilege('dbos.workflow_status', 'INSERT') END AS table_insert`));
190
+ const [row] = rows;
191
+ if (!row.schema_usage)
192
+ failures.push("has_schema_privilege('dbos', 'USAGE') is false");
193
+ if (!row.table_insert) {
194
+ failures.push("has_table_privilege('dbos.workflow_status', 'INSERT') is false");
195
+ }
196
+ }
197
+ catch (cause) {
198
+ failures.push(`privilege lookup failed: ${cause.message}`);
199
+ }
200
+ if (failures.length > 0) {
201
+ throw new BootCheckFailure("E006", "the application role lacks its dbos grants; run `dbos schema -s dbos -r <app role>`", failures);
202
+ }
203
+ }
204
+ function hfTablesTouched(node) {
205
+ switch (node.type) {
206
+ case "create table":
207
+ return isHfTable(node.name.name) ? [`CREATE TABLE ${node.name.name}`] : [];
208
+ case "alter table":
209
+ return isHfTable(node.table.name) ? [`ALTER TABLE ${node.table.name}`] : [];
210
+ case "drop table":
211
+ return node.names.filter((n) => isHfTable(n.name)).map((n) => `DROP TABLE ${n.name}`);
212
+ default:
213
+ return [];
214
+ }
215
+ }
216
+ function isHfTable(name) {
217
+ return name.toLowerCase().startsWith("hf_");
218
+ }
219
+ async function migrationFiles(dir) {
220
+ const entries = await readdir(dir).catch(() => []);
221
+ return entries.filter((f) => f.endsWith(".sql")).sort();
222
+ }
223
+ function splitStatements(sql) {
224
+ return sql
225
+ .split("--> statement-breakpoint")
226
+ .map((s) => s.trim())
227
+ .filter((s) => s.length > 0);
228
+ }
@@ -0,0 +1,6 @@
1
+ export type StatementKind = "read" | "write";
2
+ /**
3
+ * Anything that is not provably a read is a write: unparseable text, transaction control, `SET`,
4
+ * and every statement type the whitelist below omits.
5
+ */
6
+ export declare function classify(sql: string): StatementKind;
@@ -0,0 +1,75 @@
1
+ import { astVisitor, parse } from "pgsql-ast-parser";
2
+ /**
3
+ * Real volatility lives in `pg_proc.provolatile`, which a pure function with no connection cannot
4
+ * read; this denylist stands in for it, so it is deliberately incomplete rather than authoritative.
5
+ */
6
+ const MUTATING_FUNCTIONS = new Set([
7
+ "pg_notify",
8
+ "setval",
9
+ "nextval",
10
+ "lastval",
11
+ "pg_advisory_lock",
12
+ "pg_advisory_unlock",
13
+ "pg_advisory_xact_lock",
14
+ "dblink_exec",
15
+ ]);
16
+ const cache = new Map();
17
+ /**
18
+ * Anything that is not provably a read is a write: unparseable text, transaction control, `SET`,
19
+ * and every statement type the whitelist below omits.
20
+ */
21
+ export function classify(sql) {
22
+ const cached = cache.get(sql);
23
+ if (cached !== undefined)
24
+ return cached;
25
+ const kind = classifyUncached(sql);
26
+ cache.set(sql, kind);
27
+ return kind;
28
+ }
29
+ function classifyUncached(sql) {
30
+ let statements;
31
+ try {
32
+ statements = parse(sql);
33
+ }
34
+ catch {
35
+ return "write";
36
+ }
37
+ if (statements.length === 0)
38
+ return "write";
39
+ if (!statements.every(isReadStatement))
40
+ return "write";
41
+ return callsMutatingFunction(statements) ? "write" : "read";
42
+ }
43
+ function isReadStatement(statement) {
44
+ switch (statement.type) {
45
+ case "select":
46
+ // `FOR UPDATE`/`FOR SHARE` take row locks, which only a fenced transaction may hold.
47
+ return !statement.for;
48
+ case "union":
49
+ case "union all":
50
+ return isReadStatement(statement.left) && isReadStatement(statement.right);
51
+ case "values":
52
+ case "show":
53
+ return true;
54
+ case "with":
55
+ return (statement.bind.every((b) => isReadStatement(b.statement)) &&
56
+ isReadStatement(statement.in));
57
+ case "with recursive":
58
+ return isReadStatement(statement.bind) && isReadStatement(statement.in);
59
+ default:
60
+ return false;
61
+ }
62
+ }
63
+ function callsMutatingFunction(statements) {
64
+ let found = false;
65
+ const visitor = astVisitor((self) => ({
66
+ call: (expr) => {
67
+ if (MUTATING_FUNCTIONS.has(expr.function.name.toLowerCase()))
68
+ found = true;
69
+ self.super().call(expr);
70
+ },
71
+ }));
72
+ for (const statement of statements)
73
+ visitor.statement(statement);
74
+ return found;
75
+ }
@@ -0,0 +1,75 @@
1
+ import type { ClientBase, Pool, PoolClient } from "pg";
2
+ /**
3
+ * The bound on a control-plane wait. Fenced writes are short by construction, so a wait this
4
+ * long is a bug being reported rather than work being lost.
5
+ */
6
+ export declare const CONTROL_PLANE_LOCK_TIMEOUT = "30s";
7
+ /** Postgres `lock_not_available`, what `lock_timeout` raises. */
8
+ export declare const LOCK_NOT_AVAILABLE = "55P03";
9
+ export declare class ControlPlaneInWorkflow extends Error {
10
+ readonly operation: string;
11
+ constructor(operation: string);
12
+ }
13
+ export declare class CommitLost extends Error {
14
+ readonly operation: string;
15
+ readonly commandTag: string | undefined;
16
+ constructor(operation: string, commandTag: string | undefined);
17
+ }
18
+ export declare class RunNotFound extends Error {
19
+ readonly runId: string;
20
+ constructor(runId: string);
21
+ }
22
+ export declare class ConcurrentBump extends Error {
23
+ readonly runId: string;
24
+ readonly attempt: number;
25
+ constructor(runId: string, attempt: number);
26
+ }
27
+ export declare class WorkflowIdCollision extends Error {
28
+ readonly runId: string;
29
+ readonly workflowId: string;
30
+ constructor(runId: string, workflowId: string);
31
+ }
32
+ export declare class RunLockTimeout extends Error {
33
+ readonly code = "55P03";
34
+ readonly runId: string;
35
+ constructor(runId: string, cause: unknown);
36
+ }
37
+ /**
38
+ * The first thing every control-plane operation does, before any statement. `DBOS` is read on
39
+ * each call rather than destructured, so a stubbed predicate is seen (`fence.test.ts` (vii)).
40
+ */
41
+ export declare function assertNotInWorkflow(operation: string): void;
42
+ export interface ControlPlaneTxOptions {
43
+ /** Names the operation in `ControlPlaneInWorkflow` and `CommitLost`. */
44
+ operation: string;
45
+ /** A Postgres interval literal; tests shorten it to reach `55P03` in test time. */
46
+ lockTimeout?: string;
47
+ }
48
+ /**
49
+ * The only way a control-plane transaction is opened or committed. **Nothing inside `work` may
50
+ * catch**: a swallowed error leaves the transaction aborted, and Postgres then answers `COMMIT`
51
+ * with a `ROLLBACK` command tag that node-pg does not raise — the tag assert below is what makes
52
+ * that loud instead of silent (round-3 finding 5).
53
+ */
54
+ export declare function controlPlaneTx<T>(pool: Pool, options: ControlPlaneTxOptions, work: (client: PoolClient) => Promise<T>): Promise<T>;
55
+ /** Attempt 1 runs under the run's own id; attempt N under `${runId}:${N}`. */
56
+ export declare function attemptWorkflowId(runId: string, attempt: number): string;
57
+ export declare const LOCK_RUN_STATEMENT = "SELECT attempt, current_workflow_id, flow, input FROM hf_run WHERE run_id = $1 FOR UPDATE";
58
+ export declare const BUMP_STATEMENT: string;
59
+ export declare const WORKFLOW_ID_TAKEN_STATEMENT = "SELECT 1 FROM dbos.workflow_status WHERE workflow_uuid = $1";
60
+ export interface BumpedAttempt {
61
+ runId: string;
62
+ /** The flow and input the new attempt is to be enqueued with. */
63
+ flow: string;
64
+ input: unknown;
65
+ previousAttempt: number;
66
+ attempt: number;
67
+ workflowId: string;
68
+ }
69
+ /**
70
+ * The one attempt-bump path — `runs.start`, `decide()`, `resume` and `reconcile()` all bump
71
+ * through this function and no other. `client` must already be inside a `controlPlaneTx`; the
72
+ * caller enqueues the returned `workflowId` on the same client, so the enqueue commits with the
73
+ * bump or not at all.
74
+ */
75
+ export declare function bumpAttempt(client: ClientBase, runId: string): Promise<BumpedAttempt>;
@@ -0,0 +1,155 @@
1
+ import { DBOS } from "@dbos-inc/dbos-sdk";
2
+ /**
3
+ * The bound on a control-plane wait. Fenced writes are short by construction, so a wait this
4
+ * long is a bug being reported rather than work being lost.
5
+ */
6
+ export const CONTROL_PLANE_LOCK_TIMEOUT = "30s";
7
+ /** Postgres `lock_not_available`, what `lock_timeout` raises. */
8
+ export const LOCK_NOT_AVAILABLE = "55P03";
9
+ const LOCK_TIMEOUT_PATTERN = /^\d+(?:ms|s|min)$/;
10
+ export class ControlPlaneInWorkflow extends Error {
11
+ operation;
12
+ constructor(operation) {
13
+ super(`ControlPlaneInWorkflow: ${operation} is a control-plane operation and cannot run inside a run`);
14
+ this.name = "ControlPlaneInWorkflow";
15
+ this.operation = operation;
16
+ }
17
+ }
18
+ export class CommitLost extends Error {
19
+ operation;
20
+ commandTag;
21
+ constructor(operation, commandTag) {
22
+ super(`CommitLost: ${operation} answered COMMIT with ${commandTag ?? "no command tag"}; ` +
23
+ "nothing the transaction wrote is durable");
24
+ this.name = "CommitLost";
25
+ this.operation = operation;
26
+ this.commandTag = commandTag;
27
+ }
28
+ }
29
+ export class RunNotFound extends Error {
30
+ runId;
31
+ constructor(runId) {
32
+ super(`RunNotFound: no hf_run row for ${runId}`);
33
+ this.name = "RunNotFound";
34
+ this.runId = runId;
35
+ }
36
+ }
37
+ export class ConcurrentBump extends Error {
38
+ runId;
39
+ attempt;
40
+ constructor(runId, attempt) {
41
+ super(`ConcurrentBump: hf_run ${runId} left attempt ${attempt} before this bump could write it`);
42
+ this.name = "ConcurrentBump";
43
+ this.runId = runId;
44
+ this.attempt = attempt;
45
+ }
46
+ }
47
+ export class WorkflowIdCollision extends Error {
48
+ runId;
49
+ workflowId;
50
+ constructor(runId, workflowId) {
51
+ super(`WorkflowIdCollision: a dbos.workflow_status row already exists for ${workflowId}, ` +
52
+ `the id ${runId}'s next attempt was about to take`);
53
+ this.name = "WorkflowIdCollision";
54
+ this.runId = runId;
55
+ this.workflowId = workflowId;
56
+ }
57
+ }
58
+ export class RunLockTimeout extends Error {
59
+ code = LOCK_NOT_AVAILABLE;
60
+ runId;
61
+ constructor(runId, cause) {
62
+ super(`RunLockTimeout (${LOCK_NOT_AVAILABLE}): waiting on the hf_run row of ${runId} ` +
63
+ "exceeded the control-plane lock_timeout", { cause });
64
+ this.name = "RunLockTimeout";
65
+ this.runId = runId;
66
+ }
67
+ }
68
+ /**
69
+ * The first thing every control-plane operation does, before any statement. `DBOS` is read on
70
+ * each call rather than destructured, so a stubbed predicate is seen (`fence.test.ts` (vii)).
71
+ */
72
+ export function assertNotInWorkflow(operation) {
73
+ if (DBOS.isWithinWorkflow())
74
+ throw new ControlPlaneInWorkflow(operation);
75
+ }
76
+ /**
77
+ * The only way a control-plane transaction is opened or committed. **Nothing inside `work` may
78
+ * catch**: a swallowed error leaves the transaction aborted, and Postgres then answers `COMMIT`
79
+ * with a `ROLLBACK` command tag that node-pg does not raise — the tag assert below is what makes
80
+ * that loud instead of silent (round-3 finding 5).
81
+ */
82
+ export async function controlPlaneTx(pool, options, work) {
83
+ assertNotInWorkflow(options.operation);
84
+ const lockTimeout = options.lockTimeout ?? CONTROL_PLANE_LOCK_TIMEOUT;
85
+ if (!LOCK_TIMEOUT_PATTERN.test(lockTimeout)) {
86
+ throw new TypeError(`lockTimeout must be a Postgres interval literal such as '30s': ${lockTimeout}`);
87
+ }
88
+ const client = await pool.connect();
89
+ let result;
90
+ try {
91
+ await client.query("BEGIN");
92
+ await client.query(`SET LOCAL lock_timeout = '${lockTimeout}'`);
93
+ result = await work(client);
94
+ const commit = await client.query("COMMIT");
95
+ if (commit.command !== "COMMIT")
96
+ throw new CommitLost(options.operation, commit.command);
97
+ }
98
+ catch (error) {
99
+ // The connection's transaction state is aborted or unknown, so it goes back *with* the error
100
+ // and the pool destroys it. `ROLLBACK`'s own failure must not mask the error that got here.
101
+ await client.query("ROLLBACK").catch(() => undefined);
102
+ client.release(error);
103
+ throw error;
104
+ }
105
+ client.release();
106
+ return result;
107
+ }
108
+ /** Attempt 1 runs under the run's own id; attempt N under `${runId}:${N}`. */
109
+ export function attemptWorkflowId(runId, attempt) {
110
+ return attempt === 1 ? runId : `${runId}:${attempt}`;
111
+ }
112
+ export const LOCK_RUN_STATEMENT = "SELECT attempt, current_workflow_id, flow, input FROM hf_run WHERE run_id = $1 FOR UPDATE";
113
+ export const BUMP_STATEMENT = "UPDATE hf_run SET attempt = $1, current_workflow_id = $2, status = 'running' " +
114
+ "WHERE run_id = $3 AND attempt = $4";
115
+ export const WORKFLOW_ID_TAKEN_STATEMENT = "SELECT 1 FROM dbos.workflow_status WHERE workflow_uuid = $1";
116
+ /**
117
+ * The one attempt-bump path — `runs.start`, `decide()`, `resume` and `reconcile()` all bump
118
+ * through this function and no other. `client` must already be inside a `controlPlaneTx`; the
119
+ * caller enqueues the returned `workflowId` on the same client, so the enqueue commits with the
120
+ * bump or not at all.
121
+ */
122
+ export async function bumpAttempt(client, runId) {
123
+ const locked = await client.query(LOCK_RUN_STATEMENT, [runId]).catch((error) => {
124
+ // Rethrown, not handled: the transaction stays aborted and `controlPlaneTx` rolls it back.
125
+ // This only attaches the run id, which the raw `lock_timeout` message does not carry.
126
+ throw isLockTimeout(error) ? new RunLockTimeout(runId, error) : error;
127
+ });
128
+ if (locked.rowCount === 0)
129
+ throw new RunNotFound(runId);
130
+ const previousAttempt = Number(locked.rows[0].attempt);
131
+ // Computed here rather than as `attempt = attempt + 1` in SQL: the SQL form has no old value
132
+ // to compare against, so two bumps that both read N both "succeed" (round-2 finding 2). The
133
+ // number computed here is what the next statement's compare-and-set is against.
134
+ const attempt = previousAttempt + 1;
135
+ const workflowId = attemptWorkflowId(runId, attempt);
136
+ const bumped = await client.query(BUMP_STATEMENT, [attempt, workflowId, runId, previousAttempt]);
137
+ if (bumped.rowCount !== 1)
138
+ throw new ConcurrentBump(runId, previousAttempt);
139
+ // The id is fresh by construction, so a row under it is a bug in the construction — never a
140
+ // silent no-op, which would strand the run under a workflow this transaction did not enqueue.
141
+ const taken = await client.query(WORKFLOW_ID_TAKEN_STATEMENT, [workflowId]);
142
+ if (taken.rowCount !== 0)
143
+ throw new WorkflowIdCollision(runId, workflowId);
144
+ return {
145
+ runId,
146
+ flow: locked.rows[0].flow,
147
+ input: locked.rows[0].input,
148
+ previousAttempt,
149
+ attempt,
150
+ workflowId,
151
+ };
152
+ }
153
+ function isLockTimeout(error) {
154
+ return error?.code === LOCK_NOT_AVAILABLE;
155
+ }