@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
@@ -0,0 +1,32 @@
1
+ import type { Client } from "pg";
2
+ /**
3
+ * Machinery tables that point at an app record through `(record_type, record_id)`
4
+ * and whose history is meant to outlive the record. There is deliberately no
5
+ * foreign key — the core cannot know an app's tables at migration time — so the
6
+ * delete-guard trigger is the only thing standing between a hard `DELETE` and a
7
+ * lost approval, link, label or outcome.
8
+ */
9
+ export declare const DELETE_GUARD_REFERENCING_TABLES: readonly ["hf_approval", "hf_record_link", "hf_label", "hf_outcome"];
10
+ export declare const DELETE_GUARD_FUNCTION = "hf_delete_guard";
11
+ export interface RecordTable {
12
+ /** The app table carrying a `bigint` identity primary key named `id`. */
13
+ table: string;
14
+ /** The name the app registered with `defineRecord`; machinery rows store it. */
15
+ recordType: string;
16
+ }
17
+ export interface DeleteGuardResult {
18
+ referencingTables: string[];
19
+ guardedTables: string[];
20
+ }
21
+ /**
22
+ * Installs `hf_delete_guard()` and a `BEFORE DELETE` trigger on every registered
23
+ * record table. `records.archive()` is the supported path for making a record go
24
+ * away; a hard `DELETE` of a referenced record raises `restrict_violation`.
25
+ *
26
+ * The function body is regenerated on every deploy from the referencing tables
27
+ * that exist *now*: plpgsql bodies are validated at `CREATE FUNCTION` time, so a
28
+ * body naming a machinery table a later phase has not added yet would fail to
29
+ * install at all.
30
+ */
31
+ export declare function installDeleteGuards(client: Client, recordTables?: readonly RecordTable[]): Promise<DeleteGuardResult>;
32
+ export declare function deleteGuardFunctionSql(referencingTables: readonly string[]): string;
@@ -0,0 +1,62 @@
1
+ import { quoteIdent } from "./roles.js";
2
+ /**
3
+ * Machinery tables that point at an app record through `(record_type, record_id)`
4
+ * and whose history is meant to outlive the record. There is deliberately no
5
+ * foreign key — the core cannot know an app's tables at migration time — so the
6
+ * delete-guard trigger is the only thing standing between a hard `DELETE` and a
7
+ * lost approval, link, label or outcome.
8
+ */
9
+ export const DELETE_GUARD_REFERENCING_TABLES = [
10
+ "hf_approval",
11
+ "hf_record_link",
12
+ "hf_label",
13
+ "hf_outcome",
14
+ ];
15
+ export const DELETE_GUARD_FUNCTION = "hf_delete_guard";
16
+ /**
17
+ * Installs `hf_delete_guard()` and a `BEFORE DELETE` trigger on every registered
18
+ * record table. `records.archive()` is the supported path for making a record go
19
+ * away; a hard `DELETE` of a referenced record raises `restrict_violation`.
20
+ *
21
+ * The function body is regenerated on every deploy from the referencing tables
22
+ * that exist *now*: plpgsql bodies are validated at `CREATE FUNCTION` time, so a
23
+ * body naming a machinery table a later phase has not added yet would fail to
24
+ * install at all.
25
+ */
26
+ export async function installDeleteGuards(client, recordTables = []) {
27
+ const referencingTables = await existingReferencingTables(client);
28
+ await client.query(deleteGuardFunctionSql(referencingTables));
29
+ for (const { table, recordType } of recordTables) {
30
+ const ident = quoteIdent(table);
31
+ await client.query(`DROP TRIGGER IF EXISTS ${quoteIdent(DELETE_GUARD_FUNCTION)} ON ${ident}`);
32
+ await client.query(`CREATE TRIGGER ${quoteIdent(DELETE_GUARD_FUNCTION)} BEFORE DELETE ON ${ident} ` +
33
+ `FOR EACH ROW EXECUTE FUNCTION ${quoteIdent(DELETE_GUARD_FUNCTION)}('${recordType.replace(/'/g, "''")}')`);
34
+ }
35
+ return { referencingTables, guardedTables: recordTables.map((r) => r.table) };
36
+ }
37
+ async function existingReferencingTables(client) {
38
+ const { rows } = await client.query(`SELECT c.relname AS table_name
39
+ FROM pg_class c
40
+ JOIN pg_namespace n ON n.oid = c.relnamespace
41
+ WHERE n.nspname = 'public' AND c.relkind = 'r' AND c.relname = ANY($1::text[])
42
+ ORDER BY c.relname`, [[...DELETE_GUARD_REFERENCING_TABLES]]);
43
+ return rows.map((r) => r.table_name);
44
+ }
45
+ export function deleteGuardFunctionSql(referencingTables) {
46
+ const checks = referencingTables
47
+ .map(
48
+ // `record_id` is text on every machinery table — a record id is carried, not joined on —
49
+ // while a record table's `id` is the bigint identity E001 insists on.
50
+ (t) => ` IF EXISTS (SELECT 1 FROM ${quoteIdent(t)} WHERE record_type = TG_ARGV[0] AND record_id = OLD.id::text) THEN
51
+ RAISE EXCEPTION 'delete-guard: % % is referenced by ${t}; use records.archive()', TG_ARGV[0], OLD.id
52
+ USING ERRCODE = 'restrict_violation';
53
+ END IF;`)
54
+ .join("\n");
55
+ return `CREATE OR REPLACE FUNCTION ${quoteIdent(DELETE_GUARD_FUNCTION)}() RETURNS trigger
56
+ LANGUAGE plpgsql AS $hf_delete_guard$
57
+ BEGIN
58
+ ${checks}
59
+ RETURN OLD;
60
+ END;
61
+ $hf_delete_guard$`;
62
+ }
@@ -0,0 +1,35 @@
1
+ import type { Pool, PoolClient, QueryConfig, Submittable } from "pg";
2
+ export type QueryArg = string | QueryConfig | Submittable;
3
+ /**
4
+ * Stands in for a `Submittable` that carries no SQL text of its own. It does not parse, so
5
+ * `classify()` calls it a write.
6
+ */
7
+ export declare const OPAQUE_SUBMITTABLE = "<opaque submittable>";
8
+ /**
9
+ * Normalises the three shapes node-pg's `query()` accepts down to the SQL text `classify()` reads.
10
+ * `pg-copy-streams`, `pg-cursor` and `pg-query-stream` all expose their statement as `.text`.
11
+ */
12
+ export declare function extractStatementText(queryArg: QueryArg): string;
13
+ export declare class UnfencedWrite extends Error {
14
+ readonly statement: string;
15
+ constructor(statement: string);
16
+ }
17
+ /**
18
+ * Drizzle rethrows a driver error as `DrizzleQueryError`, so a refusal that reached the
19
+ * caller through a Drizzle handle is somewhere down the `cause` chain rather than at the top.
20
+ */
21
+ export declare function unfencedWriteOf(error: unknown): UnfencedWrite | undefined;
22
+ /** Tags a checked-out step-pool client for the life of one `ctx.tx` transaction. */
23
+ export declare function tagForTransaction(client: PoolClient): void;
24
+ /**
25
+ * Turns `pool` into the step pool by patching the instance — node-pg has no plugin hook,
26
+ * and patching the `pg` module would fence the control pool too. Every statement that is
27
+ * not a pure `SELECT` is refused with `UnfencedWrite` unless the specific connection it
28
+ * runs on is currently tagged by `ctx.tx`. No async context is consulted.
29
+ *
30
+ * What it does not stop, deliberately: the pool still hands the raw client to `'acquire'`,
31
+ * `'connect'`, `'release'` and `'remove'` listeners, and still carries its own
32
+ * `options.connectionString`. Those are bypasses someone has to reach for; the fence exists
33
+ * for the shapes that look like ordinary code.
34
+ */
35
+ export declare function fencePool(pool: Pool): Pool;
@@ -0,0 +1,153 @@
1
+ import { classify } from "./classify.js";
2
+ /**
3
+ * Stands in for a `Submittable` that carries no SQL text of its own. It does not parse, so
4
+ * `classify()` calls it a write.
5
+ */
6
+ export const OPAQUE_SUBMITTABLE = "<opaque submittable>";
7
+ /**
8
+ * Normalises the three shapes node-pg's `query()` accepts down to the SQL text `classify()` reads.
9
+ * `pg-copy-streams`, `pg-cursor` and `pg-query-stream` all expose their statement as `.text`.
10
+ */
11
+ export function extractStatementText(queryArg) {
12
+ if (typeof queryArg === "string")
13
+ return queryArg;
14
+ const text = queryArg.text;
15
+ return typeof text === "string" ? text : OPAQUE_SUBMITTABLE;
16
+ }
17
+ export class UnfencedWrite extends Error {
18
+ statement;
19
+ constructor(statement) {
20
+ super(`UnfencedWrite: the step pool refused a write issued outside ctx.tx: ${statement}`);
21
+ this.name = "UnfencedWrite";
22
+ this.statement = statement;
23
+ }
24
+ }
25
+ /**
26
+ * Drizzle rethrows a driver error as `DrizzleQueryError`, so a refusal that reached the
27
+ * caller through a Drizzle handle is somewhere down the `cause` chain rather than at the top.
28
+ */
29
+ export function unfencedWriteOf(error) {
30
+ let current = error;
31
+ while (current instanceof Error) {
32
+ if (current instanceof UnfencedWrite)
33
+ return current;
34
+ current = current.cause;
35
+ }
36
+ return undefined;
37
+ }
38
+ /**
39
+ * The lease that currently holds the tag on a connection, keyed by the underlying
40
+ * `pg.Client`. A lease is minted per checkout, so re-checking out the same connection
41
+ * mints a different one and a handle from an earlier checkout can never match again.
42
+ */
43
+ const taggedLease = new WeakMap();
44
+ const FENCE = Symbol("hyperfixation.fence");
45
+ function fenceOf(client) {
46
+ const fence = client[FENCE];
47
+ if (!fence)
48
+ throw new TypeError("not a step-pool client");
49
+ return fence;
50
+ }
51
+ /** Tags a checked-out step-pool client for the life of one `ctx.tx` transaction. */
52
+ export function tagForTransaction(client) {
53
+ const fence = fenceOf(client);
54
+ taggedLease.set(fence.client, fence.lease);
55
+ }
56
+ function isTagged(fence) {
57
+ return taggedLease.get(fence.client) === fence.lease;
58
+ }
59
+ function refuse(args, statement) {
60
+ const error = new UnfencedWrite(statement);
61
+ const callback = args.find((arg) => typeof arg === "function");
62
+ if (!callback)
63
+ throw error;
64
+ setImmediate(() => callback(error));
65
+ return undefined;
66
+ }
67
+ function guard(args, tagged) {
68
+ const [first] = args;
69
+ if (typeof first === "function")
70
+ return undefined;
71
+ const statement = extractStatementText(first);
72
+ return !tagged && classify(statement) === "write" ? statement : undefined;
73
+ }
74
+ /**
75
+ * Wraps one checkout of a step-pool connection. The wrapper is per-checkout, never shared:
76
+ * that is what makes a handle captured inside `ctx.tx` inert after the transaction ends,
77
+ * whether the connection then sits idle or is re-tagged by a later transaction.
78
+ */
79
+ function fenceClient(client) {
80
+ const fence = { client, lease: Symbol("hyperfixation.lease") };
81
+ let released = false;
82
+ // Untagging here rather than in `ctx.tx` makes it structural: no path returns a
83
+ // connection to the pool still carrying this checkout's tag. Idempotent because a refused
84
+ // query also releases (see below) — a caller's own release-in-finally must then be a no-op,
85
+ // not a double-release.
86
+ const release = (err) => {
87
+ if (released)
88
+ return;
89
+ released = true;
90
+ if (isTagged(fence))
91
+ taggedLease.delete(fence.client);
92
+ client.release(err);
93
+ };
94
+ const query = (...args) => {
95
+ const refused = guard(args, isTagged(fence));
96
+ if (refused !== undefined) {
97
+ // A refused statement never reaches the real connection, so it is always safe to hand
98
+ // straight back here. Without this, Drizzle's own `db.transaction()` leaks a checkout on
99
+ // every refusal: it checks a client out and issues `BEGIN` before opening the try/finally
100
+ // that would otherwise release it, so a refusal here would strand the connection forever.
101
+ release();
102
+ return refuse(args, refused);
103
+ }
104
+ return client.query.apply(client, args);
105
+ };
106
+ return new Proxy(client, {
107
+ get(target, property) {
108
+ if (property === FENCE)
109
+ return fence;
110
+ if (property === "query")
111
+ return query;
112
+ if (property === "release")
113
+ return release;
114
+ const value = Reflect.get(target, property, target);
115
+ return typeof value === "function" ? value.bind(target) : value;
116
+ },
117
+ });
118
+ }
119
+ /**
120
+ * Turns `pool` into the step pool by patching the instance — node-pg has no plugin hook,
121
+ * and patching the `pg` module would fence the control pool too. Every statement that is
122
+ * not a pure `SELECT` is refused with `UnfencedWrite` unless the specific connection it
123
+ * runs on is currently tagged by `ctx.tx`. No async context is consulted.
124
+ *
125
+ * What it does not stop, deliberately: the pool still hands the raw client to `'acquire'`,
126
+ * `'connect'`, `'release'` and `'remove'` listeners, and still carries its own
127
+ * `options.connectionString`. Those are bypasses someone has to reach for; the fence exists
128
+ * for the shapes that look like ordinary code.
129
+ */
130
+ export function fencePool(pool) {
131
+ const poolQuery = pool.query.bind(pool);
132
+ const poolConnect = pool.connect.bind(pool);
133
+ pool.query = ((...args) => {
134
+ // `pool.query` checks a connection out and returns it within the one call, so it can
135
+ // never be inside `ctx.tx`; refusing before `connect()` keeps a refusal free.
136
+ const refused = guard(args, false);
137
+ if (refused !== undefined)
138
+ return refuse(args, refused);
139
+ return poolQuery(...args);
140
+ });
141
+ pool.connect = ((callback) => {
142
+ if (typeof callback !== "function") {
143
+ return poolConnect().then(fenceClient);
144
+ }
145
+ return poolConnect((err, client) => {
146
+ if (err || !client)
147
+ return callback(err, client, undefined);
148
+ const fenced = fenceClient(client);
149
+ return callback(undefined, fenced, fenced.release);
150
+ });
151
+ });
152
+ return pool;
153
+ }
@@ -0,0 +1,23 @@
1
+ import type { Client } from "pg";
2
+ /**
3
+ * Tables the read-only role is never granted. Metabase reaches the database
4
+ * through this role, so anything that would let a reader mint or replay a staff
5
+ * session — credentials, sessions, verification codes, passkeys, linked social
6
+ * accounts — is excluded and actively revoked, not merely left ungranted.
7
+ */
8
+ export declare const GRANT_RO_EXCLUDED_TABLES: readonly ["hf_user", "hf_session", "hf_account", "hf_verification", "hf_passkey"];
9
+ export interface GrantRoResult {
10
+ /** False when the role does not exist: Metabase is optional per deployment. */
11
+ applied: boolean;
12
+ granted: string[];
13
+ revoked: string[];
14
+ }
15
+ /**
16
+ * Grants the read-only role `SELECT` on every table the migrator owns in
17
+ * `public` except the auth set, and revokes everything on that set.
18
+ *
19
+ * Per-table rather than `ON ALL TABLES` because the exclusion cannot be
20
+ * expressed as a default privilege, which is also why this is the migrator's
21
+ * last step: it has to see the tables the deploy's migrations just created.
22
+ */
23
+ export declare function grantReadOnly(client: Client, readonlyRole: string): Promise<GrantRoResult>;
@@ -0,0 +1,49 @@
1
+ import { quoteIdent } from "./roles.js";
2
+ /**
3
+ * Tables the read-only role is never granted. Metabase reaches the database
4
+ * through this role, so anything that would let a reader mint or replay a staff
5
+ * session — credentials, sessions, verification codes, passkeys, linked social
6
+ * accounts — is excluded and actively revoked, not merely left ungranted.
7
+ */
8
+ export const GRANT_RO_EXCLUDED_TABLES = [
9
+ "hf_user",
10
+ "hf_session",
11
+ "hf_account",
12
+ "hf_verification",
13
+ "hf_passkey",
14
+ ];
15
+ /**
16
+ * Grants the read-only role `SELECT` on every table the migrator owns in
17
+ * `public` except the auth set, and revokes everything on that set.
18
+ *
19
+ * Per-table rather than `ON ALL TABLES` because the exclusion cannot be
20
+ * expressed as a default privilege, which is also why this is the migrator's
21
+ * last step: it has to see the tables the deploy's migrations just created.
22
+ */
23
+ export async function grantReadOnly(client, readonlyRole) {
24
+ const { rows: roleRows } = await client.query("SELECT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = $1) AS exists", [readonlyRole]);
25
+ if (!roleRows[0]?.exists)
26
+ return { applied: false, granted: [], revoked: [] };
27
+ const role = quoteIdent(readonlyRole);
28
+ const excluded = [...GRANT_RO_EXCLUDED_TABLES];
29
+ const { rows } = await client.query(`SELECT c.relname AS table_name, (c.relname = ANY($1::text[])) AS excluded
30
+ FROM pg_class c
31
+ JOIN pg_namespace n ON n.oid = c.relnamespace
32
+ WHERE n.nspname = 'public'
33
+ AND c.relkind IN ('r', 'p', 'v', 'm')
34
+ AND pg_get_userbyid(c.relowner) = current_user
35
+ ORDER BY c.relname`, [excluded]);
36
+ const granted = [];
37
+ const revoked = [];
38
+ for (const row of rows) {
39
+ if (row.excluded) {
40
+ await client.query(`REVOKE ALL ON ${quoteIdent(row.table_name)} FROM ${role}`);
41
+ revoked.push(row.table_name);
42
+ }
43
+ else {
44
+ await client.query(`GRANT SELECT ON ${quoteIdent(row.table_name)} TO ${role}`);
45
+ granted.push(row.table_name);
46
+ }
47
+ }
48
+ return { applied: true, granted, revoked };
49
+ }
@@ -0,0 +1,12 @@
1
+ export * from "./schema/index.js";
2
+ export { classify, type StatementKind } from "./classify.js";
3
+ export { UnfencedWrite, unfencedWriteOf } from "./fenced-client.js";
4
+ export { createStepPool, StaleAttempt, FENCE_STATEMENT, STEP_POOL_SIZE, type StepDatabase, type StepPool, type StepPoolOptions, } from "./step-pool.js";
5
+ export { assertNotInWorkflow, attemptWorkflowId, bumpAttempt, controlPlaneTx, CommitLost, ConcurrentBump, ControlPlaneInWorkflow, RunLockTimeout, RunNotFound, WorkflowIdCollision, CONTROL_PLANE_LOCK_TIMEOUT, LOCK_NOT_AVAILABLE, BUMP_STATEMENT, LOCK_RUN_STATEMENT, WORKFLOW_ID_TAKEN_STATEMENT, type BumpedAttempt, type ControlPlaneTxOptions, } from "./control-plane.js";
6
+ export { loadSource, type SourceRowInput, type SourceRun } from "./loader.js";
7
+ export { appPaused, AppStateMissing, APP_PAUSED_STATEMENT, SET_APP_PAUSED_STATEMENT, } from "./app-state.js";
8
+ /** `runBootChecks` takes these, so the type travels with `.` even though the guards do not. */
9
+ export type { RecordTable } from "./delete-guard.js";
10
+ /** `records.archive()` names a registered record table in SQL; it quotes it with this. */
11
+ export { quoteIdent } from "./roles.js";
12
+ export { runBootChecks, checkE001, checkE002, checkE003, checkE004, checkE005, checkE006, BootCheckFailure, BOOT_CHECK_CODES, type BootCheckCode, type BootCheckOptions, type Queryable, } from "./boot-checks.js";
package/dist/index.js ADDED
@@ -0,0 +1,10 @@
1
+ export * from "./schema/index.js";
2
+ export { classify } from "./classify.js";
3
+ export { UnfencedWrite, unfencedWriteOf } from "./fenced-client.js";
4
+ export { createStepPool, StaleAttempt, FENCE_STATEMENT, STEP_POOL_SIZE, } from "./step-pool.js";
5
+ export { assertNotInWorkflow, attemptWorkflowId, bumpAttempt, controlPlaneTx, CommitLost, ConcurrentBump, ControlPlaneInWorkflow, RunLockTimeout, RunNotFound, WorkflowIdCollision, CONTROL_PLANE_LOCK_TIMEOUT, LOCK_NOT_AVAILABLE, BUMP_STATEMENT, LOCK_RUN_STATEMENT, WORKFLOW_ID_TAKEN_STATEMENT, } from "./control-plane.js";
6
+ export { loadSource } from "./loader.js";
7
+ export { appPaused, AppStateMissing, APP_PAUSED_STATEMENT, SET_APP_PAUSED_STATEMENT, } from "./app-state.js";
8
+ /** `records.archive()` names a registered record table in SQL; it quotes it with this. */
9
+ export { quoteIdent } from "./roles.js";
10
+ export { runBootChecks, checkE001, checkE002, checkE003, checkE004, checkE005, checkE006, BootCheckFailure, BOOT_CHECK_CODES, } from "./boot-checks.js";
@@ -0,0 +1,20 @@
1
+ import { type NodePgDatabase } from "drizzle-orm/node-postgres";
2
+ import { Pool, type PoolConfig } from "pg";
3
+ import * as schema from "../schema/index.js";
4
+ export declare const CONTROL_POOL_SIZE = 2;
5
+ export type ControlDatabase = NodePgDatabase<typeof schema>;
6
+ export interface ControlPool {
7
+ readonly pool: Pool;
8
+ readonly db: ControlDatabase;
9
+ end(): Promise<void>;
10
+ }
11
+ /**
12
+ * Core's own control-plane handle: the same `pg.Pool` usage as the step pool with no fence,
13
+ * because a control-plane operation's fence is a predicate (`WHERE current_workflow_id = …`)
14
+ * rather than a row lock.
15
+ *
16
+ * Unreachable by construction, not by convention: `package.json`'s `exports` map resolves
17
+ * only `.` (`src/index.ts`) and `./migrator`, neither of which re-exports this module, so
18
+ * no `import "@hyperfixation/db/…"` from another package can produce a control pool.
19
+ */
20
+ export declare function createControlPool(options: PoolConfig): ControlPool;
@@ -0,0 +1,17 @@
1
+ import { drizzle } from "drizzle-orm/node-postgres";
2
+ import { Pool } from "pg";
3
+ import * as schema from "../schema/index.js";
4
+ export const CONTROL_POOL_SIZE = 2;
5
+ /**
6
+ * Core's own control-plane handle: the same `pg.Pool` usage as the step pool with no fence,
7
+ * because a control-plane operation's fence is a predicate (`WHERE current_workflow_id = …`)
8
+ * rather than a row lock.
9
+ *
10
+ * Unreachable by construction, not by convention: `package.json`'s `exports` map resolves
11
+ * only `.` (`src/index.ts`) and `./migrator`, neither of which re-exports this module, so
12
+ * no `import "@hyperfixation/db/…"` from another package can produce a control pool.
13
+ */
14
+ export function createControlPool(options) {
15
+ const pool = new Pool({ max: CONTROL_POOL_SIZE, ...options });
16
+ return { pool, db: drizzle(pool, { schema }), end: () => pool.end() };
17
+ }
@@ -0,0 +1,15 @@
1
+ import { hfSourceRun } from "./schema/machinery.js";
2
+ import type { StepDatabase } from "./step-pool.js";
3
+ /** Structurally what `@hyperfixation/core`'s `SourceRow<P>` is; `db` cannot import `core`. */
4
+ export interface SourceRowInput {
5
+ readonly externalId: string;
6
+ readonly payload: unknown;
7
+ }
8
+ export type SourceRun = typeof hfSourceRun.$inferSelect;
9
+ /**
10
+ * COPYs `rows` into a staging table and upserts them into `hf_source_record`, all inside the
11
+ * caller's `ctx.tx`: the `hf_source_run` row and the records commit together or not at all.
12
+ * An unchanged payload moves only `last_seen`; a changed one resets the record to `new` so
13
+ * resolution reruns over it, and never touches its `hf_record_link`.
14
+ */
15
+ export declare function loadSource(tx: StepDatabase, source: string, rows: AsyncIterable<SourceRowInput>): Promise<SourceRun>;
package/dist/loader.js ADDED
@@ -0,0 +1,82 @@
1
+ import { Readable } from "node:stream";
2
+ import { pipeline } from "node:stream/promises";
3
+ import { eq, sql } from "drizzle-orm";
4
+ import { from as copyFrom } from "pg-copy-streams";
5
+ import { hfSourceRun } from "./schema/machinery.js";
6
+ /**
7
+ * `jsonb`'s text form is canonical, so two payloads differing only in key order hash the same.
8
+ */
9
+ const PAYLOAD_HASH = "encode(sha256(convert_to(payload::text, 'UTF8')), 'hex')";
10
+ /** The last occurrence of an external id in the batch wins. */
11
+ const deduped = (stage) => `SELECT DISTINCT ON (external_id) external_id, payload, ${PAYLOAD_HASH} AS payload_hash
12
+ FROM ${stage} ORDER BY external_id, seq DESC`;
13
+ function csvField(value) {
14
+ return `"${value.replaceAll('"', '""')}"`;
15
+ }
16
+ async function* csvLines(rows) {
17
+ for await (const row of rows) {
18
+ const payload = JSON.stringify(row.payload ?? null);
19
+ yield `${csvField(row.externalId)},${csvField(payload)}\n`;
20
+ }
21
+ }
22
+ /**
23
+ * COPYs `rows` into a staging table and upserts them into `hf_source_record`, all inside the
24
+ * caller's `ctx.tx`: the `hf_source_run` row and the records commit together or not at all.
25
+ * An unchanged payload moves only `last_seen`; a changed one resets the record to `new` so
26
+ * resolution reruns over it, and never touches its `hf_record_link`.
27
+ */
28
+ export async function loadSource(tx, source, rows) {
29
+ const client = tx.$client;
30
+ const [run] = await tx
31
+ .insert(hfSourceRun)
32
+ .values({ source, status: "running" })
33
+ .returning({ id: hfSourceRun.id });
34
+ const runId = run.id;
35
+ // TEMP, not UNLOGGED: the application role holds USAGE but not CREATE on `public`, and
36
+ // `ON COMMIT DROP` makes the cleanup structural rather than another statement to get right.
37
+ const stage = `hf_source_stage_${runId}`;
38
+ await client.query(`CREATE TEMP TABLE ${stage} (
39
+ seq bigint GENERATED ALWAYS AS IDENTITY,
40
+ external_id text NOT NULL,
41
+ payload jsonb NOT NULL
42
+ ) ON COMMIT DROP`);
43
+ const copy = client.query(copyFrom(`COPY ${stage} (external_id, payload) FROM STDIN WITH (FORMAT csv)`));
44
+ await pipeline(Readable.from(csvLines(rows)), copy);
45
+ const counted = await client.query(`WITH deduped AS (${deduped(stage)})
46
+ SELECT
47
+ (SELECT count(*) FROM ${stage}) AS rows_in,
48
+ count(*) FILTER (WHERE r.external_id IS NULL) AS rows_new,
49
+ count(*) FILTER (WHERE r.external_id IS NOT NULL
50
+ AND r.payload_hash <> d.payload_hash) AS rows_changed
51
+ FROM deduped d
52
+ LEFT JOIN hf_source_record r ON r.source = $1 AND r.external_id = d.external_id`, [source]);
53
+ const counts = counted.rows[0];
54
+ const changed = (column) => `CASE WHEN hf_source_record.payload_hash <> EXCLUDED.payload_hash
55
+ THEN EXCLUDED.${column} ELSE hf_source_record.${column} END`;
56
+ await client.query(`WITH deduped AS (${deduped(stage)})
57
+ INSERT INTO hf_source_record (source, external_id, payload, payload_hash, run_id)
58
+ SELECT $1, d.external_id, d.payload, d.payload_hash, $2 FROM deduped d
59
+ ON CONFLICT (source, external_id) DO UPDATE SET
60
+ last_seen = now(),
61
+ payload = ${changed("payload")},
62
+ payload_hash = ${changed("payload_hash")},
63
+ run_id = ${changed("run_id")},
64
+ status = CASE WHEN hf_source_record.payload_hash <> EXCLUDED.payload_hash
65
+ THEN 'new' ELSE hf_source_record.status END,
66
+ attempts = CASE WHEN hf_source_record.payload_hash <> EXCLUDED.payload_hash
67
+ THEN 0 ELSE hf_source_record.attempts END,
68
+ error = CASE WHEN hf_source_record.payload_hash <> EXCLUDED.payload_hash
69
+ THEN NULL ELSE hf_source_record.error END`, [source, runId]);
70
+ const [finished] = await tx
71
+ .update(hfSourceRun)
72
+ .set({
73
+ status: "ok",
74
+ finishedAt: sql `clock_timestamp()`,
75
+ rowsIn: Number(counts.rows_in),
76
+ rowsNew: Number(counts.rows_new),
77
+ rowsChanged: Number(counts.rows_changed),
78
+ })
79
+ .where(eq(hfSourceRun.id, runId))
80
+ .returning();
81
+ return finished;
82
+ }
@@ -0,0 +1,54 @@
1
+ import { type DeleteGuardResult, type RecordTable } from "./delete-guard.js";
2
+ import { type GrantRoResult } from "./grant-ro.js";
3
+ export declare const CORE_MIGRATIONS_TABLE = "hf_core_migrations";
4
+ export declare const CORE_MIGRATIONS_SCHEMA = "drizzle";
5
+ export declare const DBOS_SCHEMA = "dbos";
6
+ export declare const CORE_MIGRATIONS_DIR: string;
7
+ export declare class MigratorError extends Error {
8
+ constructor(message: string, options?: {
9
+ cause?: unknown;
10
+ });
11
+ }
12
+ export interface MigrateOptions {
13
+ appName: string;
14
+ /** Defaults to `hf_<appName>`; this is the role `dbos schema -r` grants. */
15
+ applicationRole?: string;
16
+ /** Defaults to `hf_<appName>_ro`; skipped when the role does not exist. */
17
+ readonlyRole?: string;
18
+ coreMigrationsDir?: string;
19
+ appMigrationsDir?: string;
20
+ /** Tables registered with `defineRecord`; each gets a delete-guard trigger. */
21
+ recordTables?: readonly RecordTable[];
22
+ /**
23
+ * Runs step 3 as `dbos schema -s dbos` with no `-r`. Exists only so a test can
24
+ * reproduce the deployment mistake E006 catches: the system schema is created
25
+ * and the application role is granted nothing on it. A real deploy that sets
26
+ * this produces a worker that cannot launch — the name is loud on purpose.
27
+ */
28
+ dangerouslySkipApplicationRoleGrant?: boolean;
29
+ }
30
+ export interface MigrateResult {
31
+ applicationRole: string;
32
+ dbosSchemaGranted: boolean;
33
+ appMigrationsApplied: boolean;
34
+ deleteGuards: DeleteGuardResult;
35
+ grantRo: GrantRoResult;
36
+ }
37
+ /**
38
+ * The five-step migrator, run as the migrator role on every deploy:
39
+ * core migrations, app migrations, `dbos schema -s dbos -r hf_<app>`,
40
+ * delete-guard triggers, `hf_grant_ro`.
41
+ *
42
+ * Order is load-bearing at both ends. Step 3 runs on every deploy, not just the
43
+ * first, so an SDK upgrade that adds a system table grants it before the worker
44
+ * that needs it starts; steps 4 and 5 run last because both have to see the
45
+ * tables steps 1 and 2 just created.
46
+ */
47
+ export declare function migrate(migratorConnectionString: string, options: MigrateOptions): Promise<MigrateResult>;
48
+ /**
49
+ * `dbos schema -s dbos -r <role>`: creates the DBOS system schema and grants the
50
+ * application role on it. The `-r` flag is the only path to those grants — the
51
+ * SDK's `getDbosSchemaPermissionsSql` is the sole source of `GRANT` statements
52
+ * anywhere in it — and without them the worker cannot launch at all.
53
+ */
54
+ export declare function runDbosSchema(migratorConnectionString: string, applicationRole: string | null, schema?: string): Promise<void>;