@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.
- package/LICENSE +21 -0
- package/dist/app-state.d.ts +12 -0
- package/dist/app-state.js +18 -0
- package/dist/boot-checks.d.ts +56 -0
- package/dist/boot-checks.js +228 -0
- package/dist/classify.d.ts +6 -0
- package/dist/classify.js +75 -0
- package/dist/control-plane.d.ts +75 -0
- package/dist/control-plane.js +155 -0
- package/dist/delete-guard.d.ts +32 -0
- package/dist/delete-guard.js +62 -0
- package/dist/fenced-client.d.ts +35 -0
- package/dist/fenced-client.js +153 -0
- package/dist/grant-ro.d.ts +23 -0
- package/dist/grant-ro.js +49 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +10 -0
- package/dist/internal/control-pool.d.ts +20 -0
- package/dist/internal/control-pool.js +17 -0
- package/dist/loader.d.ts +15 -0
- package/dist/loader.js +82 -0
- package/dist/migrate.d.ts +54 -0
- package/dist/migrate.js +143 -0
- package/dist/migration-policy.d.ts +14 -0
- package/dist/migration-policy.js +85 -0
- package/dist/migrator.d.ts +9 -0
- package/dist/migrator.js +9 -0
- package/dist/roles.d.ts +47 -0
- package/dist/roles.js +112 -0
- package/dist/schema/app.d.ts +235 -0
- package/dist/schema/app.js +23 -0
- package/dist/schema/approvals.d.ts +352 -0
- package/dist/schema/approvals.js +43 -0
- package/dist/schema/auth.d.ts +1272 -0
- package/dist/schema/auth.js +120 -0
- package/dist/schema/index.d.ts +7 -0
- package/dist/schema/index.js +7 -0
- package/dist/schema/ledger.d.ts +722 -0
- package/dist/schema/ledger.js +68 -0
- package/dist/schema/machinery.d.ts +1343 -0
- package/dist/schema/machinery.js +146 -0
- package/dist/schema/records.d.ts +15 -0
- package/dist/schema/records.js +16 -0
- package/dist/schema/runs.d.ts +213 -0
- package/dist/schema/runs.js +26 -0
- package/dist/step-pool.d.ts +26 -0
- package/dist/step-pool.js +57 -0
- package/migrations/0000_core_schema.sql +185 -0
- package/migrations/0001_llm_call_reservation_index.sql +4 -0
- package/migrations/0002_approvals.sql +25 -0
- package/migrations/0003_auth_invitation.sql +13 -0
- package/migrations/0004_machinery.sql +105 -0
- package/migrations/0005_nullable_activity_task_record.sql +4 -0
- package/migrations/0006_activity_score_key.sql +5 -0
- package/migrations/0007_score_spec_name.sql +3 -0
- package/migrations/meta/0000_snapshot.json +1238 -0
- package/migrations/meta/0001_snapshot.json +1238 -0
- package/migrations/meta/0002_snapshot.json +1426 -0
- package/migrations/meta/0003_snapshot.json +1516 -0
- package/migrations/meta/0004_snapshot.json +2353 -0
- package/migrations/meta/0005_snapshot.json +2353 -0
- package/migrations/meta/0006_snapshot.json +2415 -0
- package/migrations/meta/0007_snapshot.json +2427 -0
- package/migrations/meta/_journal.json +62 -0
- 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;
|
package/dist/classify.js
ADDED
|
@@ -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
|
+
}
|