@hyperfixation/workflows 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.
@@ -0,0 +1,350 @@
1
+ import { assertNotInWorkflow, controlPlaneTx, } from "@hyperfixation/db";
2
+ import { sql } from "drizzle-orm";
3
+ import { randomUUID } from "node:crypto";
4
+ import { prettifyError } from "zod";
5
+ import { bumpAndEnqueueOn } from "./bump.js";
6
+ import { currentRun } from "./run-context.js";
7
+ import { concludeRun } from "./run-status.js";
8
+ import { step } from "./step.js";
9
+ import { Suspend } from "./suspend.js";
10
+ import { workerRuntime } from "./worker-runtime.js";
11
+ /** Named in `ControlPlaneInWorkflow` and `CommitLost`. */
12
+ export const DECIDE_OPERATION = "approvals.decide";
13
+ /** The statuses `decide()` may write; `pending` is the only one it accepts as input. */
14
+ export const APPROVAL_DECISIONS = ["approved", "rejected", "expired", "cancelled"];
15
+ /**
16
+ * The gate a flow opts into. Not a `step()` itself but a pair of them plus a `Suspend`: the
17
+ * throw has to leave from the flow body, because a step that throws has its error checkpointed
18
+ * through `serialize-error` and a replay would revive a plain `Error` that `defineFlow`'s
19
+ * `instanceof Suspend` no longer catches.
20
+ *
21
+ * A decided row returns its decision and the flow carries on — that is how the attempt
22
+ * `decide()` enqueued passes through the gate it stopped at. Everything before this call runs
23
+ * again on that attempt and must be idempotent.
24
+ */
25
+ export async function waitForApproval(options) {
26
+ const operation = `waitForApproval(${options.key})`;
27
+ const run = currentRun(operation);
28
+ const runtime = workerRuntime(operation);
29
+ const row = await step("approval", (ctx) => createOrRead(ctx, options), { key: options.key });
30
+ if (row.status !== "pending")
31
+ return decisionOf(row, options.key);
32
+ const notifier = options.notify ?? runtime.approvalNotifier;
33
+ await step("approval:notify", (ctx) => notify(ctx, options, row, notifier), {
34
+ key: options.key,
35
+ });
36
+ await concludeRun(runtime.control.pool, run.runId, run.workflowId, "waiting", null);
37
+ throw new Suspend(run.runId, "waiting", `approval ${options.key} is pending`);
38
+ }
39
+ /**
40
+ * Insert and read back in one `ctx.tx`, so the run is locked `FOR SHARE` before `hf_approval`
41
+ * is touched — the same order `decide()` takes them in. `ON CONFLICT DO NOTHING` is what keeps
42
+ * a crash inside this step from orphaning a second row for the same gate.
43
+ */
44
+ async function createOrRead(ctx, options) {
45
+ return ctx.tx(async (db) => {
46
+ await db.execute(sql `
47
+ INSERT INTO hf_approval
48
+ (run_id, key, workflow_id, type, record_type, record_id, draft, status, assignee_id,
49
+ expires_at)
50
+ VALUES (${ctx.runId}, ${options.key}, ${ctx.workflowId}, ${options.type},
51
+ ${options.recordType ?? null}, ${options.recordId ?? null},
52
+ ${JSON.stringify(options.draft) ?? null}::jsonb, 'pending',
53
+ ${options.assigneeId ?? null},
54
+ ${options.expiresInMs ?? null}::bigint * interval '1 millisecond' + now())
55
+ ON CONFLICT (run_id, key) DO NOTHING
56
+ `);
57
+ const read = await db.execute(sql `
58
+ SELECT id, status, draft, edited_draft, decided_by, decided_via, notified_at,
59
+ assignee_id, record_type, record_id, to_json(expires_at) #>> '{}' AS expires_at
60
+ FROM hf_approval WHERE run_id = ${ctx.runId} AND key = ${options.key}
61
+ `);
62
+ return read.rows[0];
63
+ });
64
+ }
65
+ /** `notified_at` is set after the notifier returned, so a lost notification is retried. */
66
+ async function notify(ctx, options, row, notifier) {
67
+ if (row.notified_at !== null)
68
+ return;
69
+ await notifier?.({
70
+ approvalId: Number(row.id),
71
+ runId: ctx.runId,
72
+ key: options.key,
73
+ type: options.type,
74
+ draft: row.draft,
75
+ assigneeId: row.assignee_id,
76
+ recordType: row.record_type,
77
+ recordId: row.record_id,
78
+ expiresAt: row.expires_at === null ? null : new Date(row.expires_at),
79
+ }, ctx);
80
+ await ctx.tx(async (db) => {
81
+ await db.execute(sql `
82
+ UPDATE hf_approval SET notified_at = now()
83
+ WHERE run_id = ${ctx.runId} AND key = ${options.key} AND notified_at IS NULL
84
+ `);
85
+ });
86
+ }
87
+ function decisionOf(row, key) {
88
+ return {
89
+ approvalId: Number(row.id),
90
+ key,
91
+ status: row.status,
92
+ draft: row.edited_draft ?? row.draft,
93
+ decidedBy: row.decided_by,
94
+ decidedVia: row.decided_via,
95
+ };
96
+ }
97
+ export class ApprovalBatchRefused extends Error {
98
+ reasons;
99
+ constructor(reasons) {
100
+ super(`ApprovalBatchRefused: ${reasons
101
+ .map((r) => `${r.approvalId} ${r.reason}`)
102
+ .join("; ")} — the whole batch was rolled back`);
103
+ this.name = "ApprovalBatchRefused";
104
+ this.reasons = reasons;
105
+ }
106
+ }
107
+ export class ApprovalRunMoved extends Error {
108
+ constructor(approvalId) {
109
+ super(`ApprovalRunMoved: approval ${approvalId} changed run between the unlocked read that ` +
110
+ "chose which runs to lock and the locked one");
111
+ this.name = "ApprovalRunMoved";
112
+ }
113
+ }
114
+ export class ApprovalWriteLost extends Error {
115
+ constructor(approvalId, rowCount) {
116
+ super(`ApprovalWriteLost: the conditional update of approval ${approvalId} matched ${rowCount} ` +
117
+ "rows under its own FOR UPDATE lock");
118
+ this.name = "ApprovalWriteLost";
119
+ }
120
+ }
121
+ const RUN_IDS_STATEMENT = "SELECT id, run_id FROM hf_approval WHERE id = ANY($1::bigint[])";
122
+ /**
123
+ * The runs are locked before the approvals and in id order — the order `waitForApproval` takes
124
+ * them in through `ctx.tx`, and the one that stops a step's `INSERT … ON CONFLICT` deadlocking
125
+ * against a decision on the same run (round 3).
126
+ */
127
+ const LOCK_RUNS_STATEMENT = "SELECT run_id, attempt, current_workflow_id FROM hf_run WHERE run_id = ANY($1::text[]) " +
128
+ "ORDER BY run_id FOR UPDATE";
129
+ const LOCK_APPROVALS_STATEMENT = "SELECT id, run_id, key, status, decision_key, type, assignee_id, record_type, record_id " +
130
+ "FROM hf_approval WHERE id = ANY($1::bigint[]) ORDER BY id FOR UPDATE";
131
+ const DECIDE_STATEMENT = "UPDATE hf_approval SET status = $2, decided_by = $3, decided_at = now(), decided_via = $4, " +
132
+ "edited_draft = COALESCE($5::jsonb, edited_draft), decision_key = $6, batch_id = $7 " +
133
+ "WHERE id = $1 AND status = 'pending'";
134
+ const RESUME_WORKFLOW_STATEMENT = "UPDATE hf_approval SET resume_workflow_id = $2 WHERE id = ANY($1::bigint[])";
135
+ const AUDIT_STATEMENT = "INSERT INTO hf_audit (actor_id, action, target_type, target_id, meta) " +
136
+ "VALUES ($1, $2, 'hf_approval', $3, $4::jsonb)";
137
+ const ACTIVITY_STATEMENT = "INSERT INTO hf_activity (record_type, record_id, kind, actor_id, run_id, meta) " +
138
+ "VALUES ($1, $2, $3, $4, NULL, $5::jsonb)";
139
+ /**
140
+ * The one way an approval is decided, whoever decides it: the inbox, the admin, a Telegram
141
+ * callback, `records.archive()` and `reconcile()`'s expiry sweep all come through here.
142
+ *
143
+ * A control-plane operation, and one transaction: the decision, the attempt bump and the
144
+ * enqueue of the resume workflow commit together or not at all, so a crash anywhere before the
145
+ * tag-asserted `COMMIT` leaves the approval `pending` and the run untouched, and a retry with
146
+ * the same `decisionKey` starts over. **Nothing in here catches** — a swallowed error would
147
+ * leave the transaction aborted and Postgres would answer `COMMIT` with a `ROLLBACK` tag
148
+ * (round-3 finding 5).
149
+ */
150
+ export async function decide(pool, dbosClient, options) {
151
+ assertNotInWorkflow(DECIDE_OPERATION);
152
+ // A caller that hands edits with no way to validate them is a wiring bug, not a refused row.
153
+ if (Object.keys(options.edits ?? {}).length > 0 && options.schemaFor === undefined) {
154
+ throw new TypeError(`${DECIDE_OPERATION}: edits were given with no schemaFor; an edited draft is only written ` +
155
+ "once it parses against its approval type's schema");
156
+ }
157
+ try {
158
+ return await decideOnce(pool, dbosClient, options);
159
+ }
160
+ catch (error) {
161
+ // The read that chose which runs to lock is unlocked by necessity — an approval names its
162
+ // run, not the other way round. Losing that race once is ordinary; twice is not.
163
+ if (!(error instanceof ApprovalRunMoved))
164
+ throw error;
165
+ return await decideOnce(pool, dbosClient, options);
166
+ }
167
+ }
168
+ async function decideOnce(pool, dbosClient, options) {
169
+ const ids = [...new Set(options.ids)].sort((a, b) => a - b);
170
+ const lockTimeout = options.lockTimeout === undefined ? {} : { lockTimeout: options.lockTimeout };
171
+ return controlPlaneTx(pool, { operation: DECIDE_OPERATION, ...lockTimeout }, async (client) => {
172
+ const scouted = await client.query(RUN_IDS_STATEMENT, [ids]);
173
+ const runIds = [...new Set(scouted.rows.map((row) => row.run_id))].sort();
174
+ const runs = await client.query(LOCK_RUNS_STATEMENT, [runIds]);
175
+ const locked = await client.query(LOCK_APPROVALS_STATEMENT, [ids]);
176
+ for (const row of locked.rows) {
177
+ if (!runIds.includes(row.run_id))
178
+ throw new ApprovalRunMoved(Number(row.id));
179
+ }
180
+ const byId = new Map(locked.rows.map((row) => [Number(row.id), row]));
181
+ const replay = locked.rows.filter((row) => row.decision_key === options.decisionKey);
182
+ if (replay.length > 0) {
183
+ if (replay.length !== locked.rows.length) {
184
+ throw new ApprovalBatchRefused(locked.rows
185
+ .filter((row) => row.decision_key !== options.decisionKey)
186
+ .map((row) => ({
187
+ approvalId: Number(row.id),
188
+ reason: `was not part of the batch decided under ${options.decisionKey}`,
189
+ })));
190
+ }
191
+ return replayed(client, locked.rows, runs.rows);
192
+ }
193
+ const parsed = assertDecidable(ids, locked.rows, options);
194
+ // One id is one row; a batch id would only name a batch of it.
195
+ const batchId = ids.length > 1 ? randomUUID() : null;
196
+ for (const row of locked.rows) {
197
+ const id = Number(row.id);
198
+ const written = await client.query(DECIDE_STATEMENT, [
199
+ row.id,
200
+ options.decision,
201
+ options.userId ?? null,
202
+ options.via,
203
+ parsed.has(id) ? JSON.stringify(parsed.get(id)) : null,
204
+ options.decisionKey,
205
+ batchId,
206
+ ]);
207
+ if (written.rowCount !== 1)
208
+ throw new ApprovalWriteLost(Number(row.id), written.rowCount ?? 0);
209
+ }
210
+ const decided = [];
211
+ const reattempted = [];
212
+ for (const runId of runIds) {
213
+ const bumped = await bumpAndEnqueueOn(client, dbosClient, runId);
214
+ reattempted.push({
215
+ runId,
216
+ attempt: bumped.attempt,
217
+ workflowId: bumped.workflowId,
218
+ });
219
+ const theirs = locked.rows.filter((row) => row.run_id === runId);
220
+ await client.query(RESUME_WORKFLOW_STATEMENT, [
221
+ theirs.map((row) => row.id),
222
+ bumped.workflowId,
223
+ ]);
224
+ for (const row of theirs) {
225
+ decided.push({
226
+ approvalId: Number(row.id),
227
+ runId,
228
+ key: row.key,
229
+ status: options.decision,
230
+ resumeWorkflowId: bumped.workflowId,
231
+ });
232
+ }
233
+ }
234
+ // Fatal by construction: an audit row this transaction could not write is a decision with
235
+ // no record of who made it, and there is no catch anywhere for it to be demoted by.
236
+ for (const row of decided) {
237
+ const meta = JSON.stringify({
238
+ runId: row.runId,
239
+ key: row.key,
240
+ via: options.via,
241
+ decisionKey: options.decisionKey,
242
+ batchId,
243
+ resumeWorkflowId: row.resumeWorkflowId,
244
+ });
245
+ await client.query(AUDIT_STATEMENT, [
246
+ options.userId ?? null,
247
+ `approval.${options.decision}`,
248
+ String(row.approvalId),
249
+ meta,
250
+ ]);
251
+ // Last, because `hf_activity` is in the last lock tier and every `hf_approval` write is
252
+ // already behind us. Fatal under the same rule as the audit row above. The record columns
253
+ // follow the approval's, NULL included — a stand-in type would fail E002 at the next boot.
254
+ const target = byId.get(row.approvalId);
255
+ await client.query(ACTIVITY_STATEMENT, [
256
+ target.record_type,
257
+ target.record_id,
258
+ `approval.${options.decision}`,
259
+ options.userId ?? null,
260
+ meta,
261
+ ]);
262
+ }
263
+ return { replayed: false, decided, reattempted, batchId };
264
+ });
265
+ }
266
+ /**
267
+ * Every reason the batch is refused, gathered in one pass before anything is written, and the
268
+ * parsed edits the write then stores — what the schema returned, not what the caller sent.
269
+ */
270
+ function assertDecidable(ids, locked, options) {
271
+ const reasons = [];
272
+ const parsed = new Map();
273
+ const found = new Set(locked.map((row) => Number(row.id)));
274
+ for (const id of ids) {
275
+ // `ids` is typed `number[]`, but a caller crossing a query string or a JSON body can still
276
+ // hand a numeric string: Postgres accepts it, `found` is keyed by number, and the row would
277
+ // be reported missing when it is right there.
278
+ if (typeof id !== "number") {
279
+ reasons.push({
280
+ approvalId: Number(id),
281
+ reason: `was given as a ${typeof id}, not a number`,
282
+ });
283
+ continue;
284
+ }
285
+ if (!found.has(id))
286
+ reasons.push({ approvalId: id, reason: "has no hf_approval row" });
287
+ }
288
+ // `archive` and `sweep` carry no human decider — the record went, or the row timed out — so
289
+ // the assignee rule would only stop an assigned row from ever being cancelled or expired.
290
+ const humanDecision = options.via !== "archive" && options.via !== "sweep";
291
+ for (const row of locked) {
292
+ const id = Number(row.id);
293
+ if (row.status !== "pending") {
294
+ reasons.push({ approvalId: id, reason: `is already ${row.status}` });
295
+ }
296
+ if (humanDecision &&
297
+ row.assignee_id !== null &&
298
+ row.assignee_id !== options.userId &&
299
+ options.admin !== true) {
300
+ reasons.push({ approvalId: id, reason: `is assigned to ${row.assignee_id}` });
301
+ }
302
+ const edit = options.edits?.[id];
303
+ if (edit === undefined)
304
+ continue;
305
+ const schema = options.schemaFor?.(row.type);
306
+ if (schema === undefined) {
307
+ reasons.push({
308
+ approvalId: id,
309
+ reason: `has an edit but type ${row.type} has no registered schema`,
310
+ });
311
+ continue;
312
+ }
313
+ const result = schema.safeParse(edit);
314
+ if (!result.success) {
315
+ reasons.push({
316
+ approvalId: id,
317
+ reason: `has an edit that does not match schema for type ${row.type}: ${prettifyError(result.error)}`,
318
+ });
319
+ continue;
320
+ }
321
+ parsed.set(id, result.data);
322
+ }
323
+ if (reasons.length > 0)
324
+ throw new ApprovalBatchRefused(reasons);
325
+ return parsed;
326
+ }
327
+ /**
328
+ * What the transaction that first carried this `decisionKey` returned, read back rather than
329
+ * recomputed: the resume workflow it enqueued is on the rows it wrote.
330
+ */
331
+ async function replayed(client, locked, runs) {
332
+ const { rows } = await client.query("SELECT id, run_id, key, status, resume_workflow_id, batch_id FROM hf_approval WHERE id = ANY($1::bigint[]) ORDER BY id", [locked.map((row) => row.id)]);
333
+ return {
334
+ replayed: true,
335
+ batchId: rows[0]?.batch_id ?? null,
336
+ decided: rows.map((row) => ({
337
+ approvalId: Number(row.id),
338
+ runId: row.run_id,
339
+ key: row.key,
340
+ status: row.status,
341
+ resumeWorkflowId: row.resume_workflow_id ?? "",
342
+ })),
343
+ reattempted: runs.map((run) => ({
344
+ runId: run.run_id,
345
+ attempt: Number(run.attempt),
346
+ workflowId: run.current_workflow_id,
347
+ })),
348
+ };
349
+ }
350
+ export const approvals = { decide, waitForApproval };
package/dist/bump.d.ts ADDED
@@ -0,0 +1,18 @@
1
+ import type { DBOSClient } from "@dbos-inc/dbos-sdk";
2
+ import { type BumpedAttempt } from "@hyperfixation/db";
3
+ import type { ClientBase } from "pg";
4
+ export declare class UnknownFlow extends Error {
5
+ readonly runId: string;
6
+ constructor(runId: string, flow: string);
7
+ }
8
+ /**
9
+ * The one attempt-bump path with its enqueue, on a client already inside a control-plane
10
+ * transaction: `reconcile()` opens one per run, `decide()` bumps inside the transaction that
11
+ * writes the decision, and neither has a bump of its own. The queue name comes from the flow
12
+ * registry and from nowhere else (round-2 finding 2).
13
+ */
14
+ export declare function bumpAndEnqueueOn(client: ClientBase, dbosClient: DBOSClient, runId: string): Promise<BumpedAttempt>;
15
+ export declare function flowForRun(runId: string, name: string): {
16
+ name: string;
17
+ queue: string;
18
+ };
package/dist/bump.js ADDED
@@ -0,0 +1,29 @@
1
+ import { bumpAttempt } from "@hyperfixation/db";
2
+ import { definedFlows } from "./define-flow.js";
3
+ export class UnknownFlow extends Error {
4
+ runId;
5
+ constructor(runId, flow) {
6
+ super(`UnknownFlow: hf_run ${runId} names flow ${JSON.stringify(flow)}, which this worker does ` +
7
+ "not register; its next attempt has no queue to be enqueued on");
8
+ this.name = "UnknownFlow";
9
+ this.runId = runId;
10
+ }
11
+ }
12
+ /**
13
+ * The one attempt-bump path with its enqueue, on a client already inside a control-plane
14
+ * transaction: `reconcile()` opens one per run, `decide()` bumps inside the transaction that
15
+ * writes the decision, and neither has a bump of its own. The queue name comes from the flow
16
+ * registry and from nowhere else (round-2 finding 2).
17
+ */
18
+ export async function bumpAndEnqueueOn(client, dbosClient, runId) {
19
+ const bumped = await bumpAttempt(client, runId);
20
+ const flow = flowForRun(runId, bumped.flow);
21
+ await dbosClient.enqueueInTransaction(client, { queueName: flow.queue, workflowName: flow.name, workflowID: bumped.workflowId }, { runId, attempt: bumped.attempt, input: bumped.input });
22
+ return bumped;
23
+ }
24
+ export function flowForRun(runId, name) {
25
+ const flow = definedFlows().get(name);
26
+ if (flow === undefined)
27
+ throw new UnknownFlow(runId, name);
28
+ return flow;
29
+ }
@@ -0,0 +1,25 @@
1
+ import { DBOSClient } from "@dbos-inc/dbos-sdk";
2
+ import { type RecordTable } from "@hyperfixation/db";
3
+ export declare const CLIENT_POOL_SIZE = 2;
4
+ export interface GetClientOptions {
5
+ appName: string;
6
+ /** The application role's connection string, the same one the worker launches on. */
7
+ databaseUrl: string;
8
+ recordTables?: readonly RecordTable[];
9
+ appMigrationsDir?: string;
10
+ }
11
+ /**
12
+ * The web process's whole relationship with DBOS: it enqueues through this client and never
13
+ * calls `DBOS.launch()`.
14
+ *
15
+ * The promise is cached rather than the client, so concurrent first callers share one
16
+ * boot-check pass and one client; a failed boot caches nothing, so the next caller retries.
17
+ */
18
+ export declare function getClient(options: GetClientOptions): Promise<DBOSClient>;
19
+ /**
20
+ * Drops the cached client so the next `getClient()` call creates a fresh one. A real process
21
+ * has one `databaseUrl` for its whole life and never needs this; it exists for tests that spin
22
+ * up more than one database in a single process, where the cache would otherwise hand a later
23
+ * test a client still bound to an earlier test's already-dropped database.
24
+ */
25
+ export declare function resetClient(): Promise<void>;
package/dist/client.js ADDED
@@ -0,0 +1,43 @@
1
+ import { DBOSClient } from "@dbos-inc/dbos-sdk";
2
+ import { runBootChecks } from "@hyperfixation/db";
3
+ import { SYSTEM_DATABASE_SCHEMA } from "./start-worker.js";
4
+ export const CLIENT_POOL_SIZE = 2;
5
+ let client;
6
+ /**
7
+ * The web process's whole relationship with DBOS: it enqueues through this client and never
8
+ * calls `DBOS.launch()`.
9
+ *
10
+ * The promise is cached rather than the client, so concurrent first callers share one
11
+ * boot-check pass and one client; a failed boot caches nothing, so the next caller retries.
12
+ */
13
+ export function getClient(options) {
14
+ client ??= createClient(options).catch((error) => {
15
+ client = undefined;
16
+ throw error;
17
+ });
18
+ return client;
19
+ }
20
+ /**
21
+ * Drops the cached client so the next `getClient()` call creates a fresh one. A real process
22
+ * has one `databaseUrl` for its whole life and never needs this; it exists for tests that spin
23
+ * up more than one database in a single process, where the cache would otherwise hand a later
24
+ * test a client still bound to an earlier test's already-dropped database.
25
+ */
26
+ export async function resetClient() {
27
+ const current = client;
28
+ client = undefined;
29
+ await current?.then((c) => c.destroy()).catch(() => undefined);
30
+ }
31
+ async function createClient(options) {
32
+ await runBootChecks({
33
+ databaseUrl: options.databaseUrl,
34
+ recordTables: options.recordTables,
35
+ appMigrationsDir: options.appMigrationsDir,
36
+ });
37
+ return await DBOSClient.create({
38
+ systemDatabaseUrl: options.databaseUrl,
39
+ systemDatabaseSchemaName: SYSTEM_DATABASE_SCHEMA,
40
+ systemDatabasePoolSize: CLIENT_POOL_SIZE,
41
+ applicationName: options.appName,
42
+ });
43
+ }
@@ -0,0 +1,21 @@
1
+ import * as schema from "@hyperfixation/db";
2
+ import { type NodePgDatabase } from "drizzle-orm/node-postgres";
3
+ import { Pool, type PoolConfig } from "pg";
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 handle in the worker, carrying the writes whose fence is a predicate
13
+ * (`WHERE current_workflow_id = …`) rather than a row lock. No fence wrapper: that is the
14
+ * step pool's job.
15
+ *
16
+ * `@hyperfixation/db` holds the identical factory in `src/internal/`, which its `exports`
17
+ * map deliberately does not resolve — "no export resolves to the control pool" is contract
18
+ * surface, enforced by the resolver rather than by convention. Repeating ten lines of `pg`
19
+ * boilerplate here is what keeps that structural.
20
+ */
21
+ export declare function createControlPool(options: PoolConfig): ControlPool;
@@ -0,0 +1,18 @@
1
+ import * as schema from "@hyperfixation/db";
2
+ import { drizzle } from "drizzle-orm/node-postgres";
3
+ import { Pool } from "pg";
4
+ export const CONTROL_POOL_SIZE = 2;
5
+ /**
6
+ * Core's own handle in the worker, carrying the writes whose fence is a predicate
7
+ * (`WHERE current_workflow_id = …`) rather than a row lock. No fence wrapper: that is the
8
+ * step pool's job.
9
+ *
10
+ * `@hyperfixation/db` holds the identical factory in `src/internal/`, which its `exports`
11
+ * map deliberately does not resolve — "no export resolves to the control pool" is contract
12
+ * surface, enforced by the resolver rather than by convention. Repeating ten lines of `pg`
13
+ * boilerplate here is what keeps that structural.
14
+ */
15
+ export function createControlPool(options) {
16
+ const pool = new Pool({ max: CONTROL_POOL_SIZE, ...options });
17
+ return { pool, db: drizzle(pool, { schema }), end: () => pool.end() };
18
+ }
@@ -0,0 +1,40 @@
1
+ import { type RunContext } from "./run-context.js";
2
+ import { type QueueName } from "./start-worker.js";
3
+ /** What DBOS carries as the workflow's one argument. */
4
+ export interface FlowArgs<I> {
5
+ runId: string;
6
+ attempt: number;
7
+ input: I;
8
+ }
9
+ export interface Flow<I = unknown, O = unknown> {
10
+ readonly name: string;
11
+ readonly queue: QueueName;
12
+ /**
13
+ * The registered DBOS workflow in a worker, and the bare body anywhere else — see
14
+ * `defineFlow`. Either way nothing calls it: `runs.start` enqueues by name, never by
15
+ * reference, and calling it outside a run throws `OutsideRun`.
16
+ */
17
+ readonly workflow: (args: FlowArgs<I>) => Promise<O | undefined>;
18
+ }
19
+ export interface DefineFlowOptions {
20
+ queue: QueueName;
21
+ }
22
+ export declare class DuplicateFlow extends Error {
23
+ constructor(name: string);
24
+ }
25
+ export declare class UnknownQueue extends Error {
26
+ constructor(name: string, queue: string);
27
+ }
28
+ /** Logged when an attempt finds the run already on a later one; it runs nothing. */
29
+ export declare const SUPERSEDED_MARKER = "hf-run: superseded attempt, the flow was not run";
30
+ /** The one source of a queue name for an enqueue, and of a flow name for `runs.start`. */
31
+ export declare function definedFlows(): ReadonlyMap<string, Flow<never, unknown>>;
32
+ /**
33
+ * Registers `fn` as a DBOS workflow whose lifecycle is the run's. The wrapper owns every
34
+ * `hf_run` status write of the attempt, and every one of them carries the
35
+ * `AND current_workflow_id = …` fence — the wrapper's job is as much to *not* write after a
36
+ * bump as it is to write.
37
+ *
38
+ * The DBOS registration itself happens only in a worker process; see the comment on it.
39
+ */
40
+ export declare function defineFlow<I, O>(name: string, fn: (input: I, run: RunContext) => Promise<O>, options: DefineFlowOptions): Flow<I, O>;
@@ -0,0 +1,83 @@
1
+ import { DBOS } from "@dbos-inc/dbos-sdk";
2
+ import { OutsideRun, withRunContext } from "./run-context.js";
3
+ import { claimRun, concludeRun } from "./run-status.js";
4
+ import { QUEUES, WORKER_PROCESS } from "./start-worker.js";
5
+ import { Suspend } from "./suspend.js";
6
+ import { workerRuntime } from "./worker-runtime.js";
7
+ export class DuplicateFlow extends Error {
8
+ constructor(name) {
9
+ super(`DuplicateFlow: a flow named ${JSON.stringify(name)} is already defined`);
10
+ this.name = "DuplicateFlow";
11
+ }
12
+ }
13
+ export class UnknownQueue extends Error {
14
+ constructor(name, queue) {
15
+ super(`UnknownQueue: flow ${JSON.stringify(name)} names queue ${JSON.stringify(queue)}; ` +
16
+ `the queues are ${QUEUES.map((q) => q.name).join(", ")}`);
17
+ this.name = "UnknownQueue";
18
+ }
19
+ }
20
+ /** Logged when an attempt finds the run already on a later one; it runs nothing. */
21
+ export const SUPERSEDED_MARKER = "hf-run: superseded attempt, the flow was not run";
22
+ const flows = new Map();
23
+ /** The one source of a queue name for an enqueue, and of a flow name for `runs.start`. */
24
+ export function definedFlows() {
25
+ return flows;
26
+ }
27
+ /**
28
+ * Registers `fn` as a DBOS workflow whose lifecycle is the run's. The wrapper owns every
29
+ * `hf_run` status write of the attempt, and every one of them carries the
30
+ * `AND current_workflow_id = …` fence — the wrapper's job is as much to *not* write after a
31
+ * bump as it is to write.
32
+ *
33
+ * The DBOS registration itself happens only in a worker process; see the comment on it.
34
+ */
35
+ export function defineFlow(name, fn, options) {
36
+ if (flows.has(name))
37
+ throw new DuplicateFlow(name);
38
+ if (!QUEUES.some((queue) => queue.name === options.queue)) {
39
+ throw new UnknownQueue(name, options.queue);
40
+ }
41
+ const body = async (args) => {
42
+ const workflowId = DBOS.workflowID;
43
+ if (workflowId === undefined)
44
+ throw new OutsideRun(`flow ${name}`);
45
+ const runtime = workerRuntime(`flow ${name}`);
46
+ const run = { runId: args.runId, attempt: args.attempt, workflowId };
47
+ // The claim is the fence as well as the status write: zero rows means a bump moved the
48
+ // run on before this attempt was dequeued, so it stops here without touching the run.
49
+ // Not an error — a superseded attempt ending quietly is the design working.
50
+ if (!(await claimRun(runtime.control.pool, run.runId, workflowId, runtime.applicationVersion))) {
51
+ console.info(SUPERSEDED_MARKER, JSON.stringify({ flow: name, ...run }));
52
+ return undefined;
53
+ }
54
+ try {
55
+ const output = await withRunContext(run, () => fn(args.input, run));
56
+ await concludeRun(runtime.control.pool, run.runId, workflowId, "done", null);
57
+ return output;
58
+ }
59
+ catch (error) {
60
+ if (error instanceof Suspend) {
61
+ await concludeRun(runtime.control.pool, run.runId, workflowId, error.status, null);
62
+ return undefined;
63
+ }
64
+ await concludeRun(runtime.control.pool, run.runId, workflowId, "failed", messageOf(error));
65
+ throw error;
66
+ }
67
+ };
68
+ // Only in the worker, and for the same reason `DBOS.launch()` is only there: a registration
69
+ // is a global side effect in the one object DBOS keeps per process, and the web's copy of
70
+ // this module is not one per process. Next splits an app's server code per route, so
71
+ // `src/flows/*.ts` is evaluated once per chunk that reaches it while `@dbos-inc/dbos-sdk`
72
+ // stays external and singular — the second evaluation is refused and every route that
73
+ // touches the app 500s from then on. The web never dispatches a workflow anyway; it enqueues
74
+ // by name through `DBOSClient`, and the name comes from `flows` below, which is per-instance
75
+ // and identical in every instance.
76
+ const workflow = process.env.HF_PROCESS === WORKER_PROCESS ? DBOS.registerWorkflow(body, { name }) : body;
77
+ const flow = { name, queue: options.queue, workflow };
78
+ flows.set(name, flow);
79
+ return flow;
80
+ }
81
+ function messageOf(error) {
82
+ return error instanceof Error ? `${error.name}: ${error.message}` : String(error);
83
+ }
@@ -0,0 +1,22 @@
1
+ export { startWorker, MissingBuildSha, NotAWorkerProcess, DRAIN_TIMEOUT_MS, LAUNCHED_MARKER, LAUNCHING_MARKER, MIN_BUILD_SHA_LENGTH, QUEUES, RECONCILER_POOL_SIZE, SHUTDOWN_FAILED_MARKER, SHUTDOWN_IGNORED_MARKER, SHUTDOWN_MARKER, SHUTDOWN_WATCHDOG_MS, SYSTEM_DATABASE_POOL_SIZE, SYSTEM_DATABASE_SCHEMA, WORKER_PROCESS, type QueueName, type StartWorkerOptions, type Worker, } from "./start-worker.js";
2
+ export { registerLangfuse, LANGFUSE_ENV, type LangfuseRegistration } from "./langfuse.js";
3
+ export { getClient, resetClient, CLIENT_POOL_SIZE, type GetClientOptions } from "./client.js";
4
+ export { setPausedQueueConcurrency, PAUSED_CONCURRENCY, PAUSED_QUEUES, type QueueConcurrency, } from "./queue-concurrency.js";
5
+ export { WorkerLockUnavailable, LOCK_ACQUIRED_MARKER, type WorkerLock } from "./worker-lock.js";
6
+ export { defineFlow, definedFlows, DuplicateFlow, UnknownQueue, SUPERSEDED_MARKER, type DefineFlowOptions, type Flow, type FlowArgs, } from "./define-flow.js";
7
+ export { step, STEP_GATE_STATEMENT, type StepContext, type StepOptions } from "./step.js";
8
+ export { actions, perform, idempotencyKey, stubChannel, ActionUncertain, type ActionChannel, type ActionDispatch, type ActionResult, type ActionsPerformOptions, } from "./actions.js";
9
+ export { approvals, decide, waitForApproval, ApprovalBatchRefused, ApprovalRunMoved, ApprovalWriteLost, APPROVAL_DECISIONS, DECIDE_OPERATION, type ApprovalDecision, type ApprovalDecisionKind, type ApprovalDraftSchema, type ApprovalNotice, type ApprovalNotifier, type DecideOptions, type DecideResult, type DecidedApproval, type WaitForApprovalOptions, } from "./approvals.js";
10
+ export { createApprovalNotifier, NO_RECIPIENTS_MARKER, type ApprovalMessage, type ApprovalNotifierOptions, } from "./approval-notifier.js";
11
+ export { handleTelegramCallback, encodeCallbackData, decodeCallbackData, decisionKeyFor, maxNonceLength, CallbackDataTooLong, CALLBACK_DATA_MAX_BYTES, CALLBACK_DATA_VERSION, type TelegramCallbackData, type TelegramCallbackFrom, type TelegramCallbackOptions, type TelegramCallbackOutcome, type TelegramCallbackResult, type TelegramDecision, } from "./telegram.js";
12
+ export { UnknownFlow } from "./bump.js";
13
+ export { Suspend, SUSPEND_STATUSES, type SuspendStatus } from "./suspend.js";
14
+ export { currentRun, OutsideRun, type RunContext } from "./run-context.js";
15
+ export { runsStart, START_RUN_STATEMENT, type RunsStartOptions, type StartedRun } from "./runs.js";
16
+ export { reconcile, startReconciler, sweepDecisionKey, ABANDON_LLM_CALLS_STATEMENT, DRIFT_STATEMENT, EXPIRED_APPROVALS_STATEMENT, PAUSED_RUNS_STATEMENT, RECONCILE_ACTION_MARKER, RECONCILE_ANOMALY_MARKER, RECONCILE_FAILED_MARKER, RECONCILE_INTERVAL_MS, RECONCILE_PASS_MARKER, RECONCILE_QUEUE_MARKER, RUNNING_RUNS_STATEMENT, UNCERTAIN_ACTIONS_STATEMENT, type Concluded, type ExpiredApproval, type PeriodDrift, type QueueConcurrencyCorrection, type Reattempted, type ReconcileAnomaly, type ReconcileFailure, type ReconcileOptions, type ReconcileReport, type Reconciler, } from "./reconcile.js";
17
+ /**
18
+ * The types travel with `Worker`, the factory does not: `startWorker()` is the only way to
19
+ * get a control pool, which is what keeps "no export resolves to the control pool" true of
20
+ * this package as well as of `@hyperfixation/db`.
21
+ */
22
+ export type { ControlDatabase, ControlPool } from "./control-pool.js";