octaflow 0.14.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,671 @@
1
+ //#region src/store-pg/ddl.ts
2
+ /**
3
+ * Schema for the Postgres WorkflowStore adapter. Two tables mirror flow-core's
4
+ * record shapes. Apply once at deploy time (or call `applySchema`).
5
+ *
6
+ * `partition_key` is the generic multi-tenancy column (the engine/store are bound
7
+ * to one partition). Add Row Level Security on top if you need hard isolation.
8
+ */
9
+ function flowStoreDdl(schema = "public") {
10
+ const wf = `${schema}.flow_workflow`;
11
+ const step = `${schema}.flow_workflow_step`;
12
+ return `
13
+ ${createSchemaDdl(schema)}CREATE TABLE IF NOT EXISTS ${wf} (
14
+ id bigserial PRIMARY KEY,
15
+ partition_key text NOT NULL,
16
+ type text NOT NULL,
17
+ status text NOT NULL,
18
+ input jsonb NOT NULL DEFAULT '{}'::jsonb,
19
+ output jsonb,
20
+ error text,
21
+ entity_ref text,
22
+ total_steps integer NOT NULL,
23
+ completed_steps integer NOT NULL DEFAULT 0,
24
+ failed_steps integer NOT NULL DEFAULT 0,
25
+ metadata jsonb,
26
+ idempotency_key text,
27
+ parent_workflow_id bigint,
28
+ parent_step_id bigint,
29
+ created_at timestamptz NOT NULL DEFAULT now(),
30
+ started_at timestamptz,
31
+ completed_at timestamptz
32
+ );
33
+
34
+ CREATE INDEX IF NOT EXISTS flow_workflow_partition_status_idx ON ${wf} (partition_key, status);
35
+ CREATE INDEX IF NOT EXISTS flow_workflow_parent_idx ON ${wf} (parent_workflow_id);
36
+ CREATE INDEX IF NOT EXISTS flow_workflow_partition_type_idx ON ${wf} (partition_key, type);
37
+ CREATE INDEX IF NOT EXISTS flow_workflow_partition_entity_idx ON ${wf} (partition_key, entity_ref);
38
+ CREATE UNIQUE INDEX IF NOT EXISTS flow_workflow_idempotency_idx ON ${wf} (partition_key, idempotency_key) WHERE idempotency_key IS NOT NULL;
39
+
40
+ CREATE TABLE IF NOT EXISTS ${step} (
41
+ id bigserial PRIMARY KEY,
42
+ workflow_id bigint NOT NULL REFERENCES ${wf}(id) ON DELETE CASCADE,
43
+ partition_key text NOT NULL,
44
+ key text NOT NULL,
45
+ type text NOT NULL,
46
+ status text NOT NULL,
47
+ dependencies jsonb NOT NULL DEFAULT '[]'::jsonb,
48
+ input jsonb,
49
+ output jsonb,
50
+ error text,
51
+ metadata jsonb,
52
+ attempts integer NOT NULL DEFAULT 0,
53
+ parent_step_id bigint REFERENCES ${step}(id) ON DELETE CASCADE,
54
+ started_at timestamptz,
55
+ completed_at timestamptz,
56
+ UNIQUE (workflow_id, key)
57
+ );
58
+
59
+ CREATE INDEX IF NOT EXISTS flow_workflow_step_workflow_idx ON ${step} (workflow_id);
60
+ CREATE INDEX IF NOT EXISTS flow_workflow_step_status_idx ON ${step} (workflow_id, status);
61
+ CREATE INDEX IF NOT EXISTS flow_workflow_step_parent_idx ON ${step} (parent_step_id);
62
+ `;
63
+ }
64
+ /** Default-schema DDL string. */
65
+ const FLOW_STORE_DDL = flowStoreDdl();
66
+ /** `CREATE SCHEMA IF NOT EXISTS` line for non-default schemas; empty for 'public'. */
67
+ function createSchemaDdl(schema) {
68
+ return schema === "public" ? "" : `CREATE SCHEMA IF NOT EXISTS ${schema};\n\n`;
69
+ }
70
+ //#endregion
71
+ //#region src/store-pg/executor.ts
72
+ /**
73
+ * Build a {@link SqlExecutor} over a `pg` {@link Pool}: top-level queries run on
74
+ * the pool (autocommit); `transaction` checks out a client and wraps `fn` in
75
+ * `BEGIN`/`COMMIT` (rolling back and always releasing on error). This is the
76
+ * batteries-included executor used by the `createPg*` adapters.
77
+ */
78
+ function poolExecutor(pool) {
79
+ const fromClient = (client) => ({
80
+ async query(sql, params) {
81
+ const res = await client.query(sql, params);
82
+ return {
83
+ rows: res.rows,
84
+ rowCount: res.rowCount
85
+ };
86
+ },
87
+ transaction(fn) {
88
+ return fn(fromClient(client));
89
+ }
90
+ });
91
+ return {
92
+ async query(sql, params) {
93
+ const res = await pool.query(sql, params);
94
+ return {
95
+ rows: res.rows,
96
+ rowCount: res.rowCount
97
+ };
98
+ },
99
+ async transaction(fn) {
100
+ const client = await pool.connect();
101
+ try {
102
+ await client.query("BEGIN");
103
+ const result = await fn(fromClient(client));
104
+ await client.query("COMMIT");
105
+ return result;
106
+ } catch (e) {
107
+ await client.query("ROLLBACK").catch(() => {});
108
+ throw e;
109
+ } finally {
110
+ client.release();
111
+ }
112
+ }
113
+ };
114
+ }
115
+ /**
116
+ * Normalize a `Pool | SqlExecutor` argument to a {@link SqlExecutor}. Lets the
117
+ * read helpers accept either the batteries-included pool or a host's RLS-scoped
118
+ * executor without a breaking signature change. `SqlExecutor` is discriminated by
119
+ * its `transaction` method (a `pg` `Pool` has none).
120
+ */
121
+ function toExecutor(db) {
122
+ return "transaction" in db ? db : poolExecutor(db);
123
+ }
124
+ //#endregion
125
+ //#region src/store-pg/store.ts
126
+ const iso = (v) => v == null ? null : v instanceof Date ? v.toISOString() : v;
127
+ function mapWorkflow(r) {
128
+ return {
129
+ id: Number(r.id),
130
+ type: r.type,
131
+ status: r.status,
132
+ partitionKey: r.partition_key,
133
+ input: r.input ?? {},
134
+ output: r.output ?? null,
135
+ error: r.error ?? null,
136
+ entityRef: r.entity_ref ?? null,
137
+ totalSteps: Number(r.total_steps),
138
+ completedSteps: Number(r.completed_steps),
139
+ failedSteps: Number(r.failed_steps),
140
+ metadata: r.metadata ?? null,
141
+ idempotencyKey: r.idempotency_key ?? null,
142
+ parentWorkflowId: r.parent_workflow_id == null ? null : Number(r.parent_workflow_id),
143
+ parentStepId: r.parent_step_id == null ? null : Number(r.parent_step_id),
144
+ createdAt: iso(r.created_at),
145
+ startedAt: iso(r.started_at),
146
+ completedAt: iso(r.completed_at)
147
+ };
148
+ }
149
+ function mapStep(r) {
150
+ return {
151
+ id: Number(r.id),
152
+ workflowId: Number(r.workflow_id),
153
+ key: r.key,
154
+ type: r.type,
155
+ status: r.status,
156
+ dependencies: r.dependencies ?? [],
157
+ input: r.input ?? null,
158
+ output: r.output ?? null,
159
+ error: r.error ?? null,
160
+ metadata: r.metadata ?? null,
161
+ attempts: Number(r.attempts ?? 0),
162
+ parentStepId: r.parent_step_id == null ? null : Number(r.parent_step_id),
163
+ startedAt: iso(r.started_at),
164
+ completedAt: iso(r.completed_at)
165
+ };
166
+ }
167
+ /**
168
+ * A `WorkflowStore` over Postgres, addressing raw parameterized SQL through an
169
+ * injected {@link SqlExecutor}. Partition-scoped at construction; every query is
170
+ * additionally filtered by `partition_key` as defense-in-depth. Requires the two
171
+ * tables from `flowStoreDdl()`.
172
+ *
173
+ * The executor decides *how* queries run: {@link createPgWorkflowStore} wires a
174
+ * plain pool; a host that wants Row Level Security injects an executor whose
175
+ * `transaction` sets the tenant GUC, so the store's own transactions run scoped.
176
+ */
177
+ function createWorkflowStore(deps) {
178
+ const { exec, partitionKey } = deps;
179
+ const schema = deps.schema ?? "public";
180
+ const WF = `${schema}.flow_workflow`;
181
+ const STEP = `${schema}.flow_workflow_step`;
182
+ async function createWorkflow(params) {
183
+ return exec.transaction(async (client) => {
184
+ const wfRes = await client.query(`INSERT INTO ${WF} (partition_key, type, status, input, total_steps, entity_ref, idempotency_key, metadata, parent_workflow_id, parent_step_id, created_at, started_at)
185
+ VALUES ($1, $2, 'running', $3::jsonb, $4, $5, $6, $7::jsonb, $8, $9, $10, $10)
186
+ ON CONFLICT (partition_key, idempotency_key) WHERE idempotency_key IS NOT NULL DO NOTHING
187
+ RETURNING id`, [
188
+ partitionKey,
189
+ params.type,
190
+ JSON.stringify(params.input ?? {}),
191
+ params.steps.length,
192
+ params.entityRef ?? null,
193
+ params.idempotencyKey ?? null,
194
+ params.metadata != null ? JSON.stringify(params.metadata) : null,
195
+ params.parentWorkflowId ?? null,
196
+ params.parentStepId ?? null,
197
+ params.startedAt
198
+ ]);
199
+ if (wfRes.rows.length === 0) {
200
+ const existing = await client.query(`SELECT id FROM ${WF} WHERE partition_key = $1 AND idempotency_key = $2`, [partitionKey, params.idempotencyKey]);
201
+ const existingId = Number(existing.rows[0].id);
202
+ return {
203
+ workflowId: existingId,
204
+ steps: (await client.query(`SELECT * FROM ${STEP} WHERE workflow_id = $1 AND partition_key = $2 ORDER BY id`, [existingId, partitionKey])).rows.map(mapStep),
205
+ alreadyExisted: true
206
+ };
207
+ }
208
+ const workflowId = Number(wfRes.rows[0].id);
209
+ const steps = [];
210
+ for (const s of params.steps) {
211
+ const stepRes = await client.query(`INSERT INTO ${STEP} (workflow_id, partition_key, key, type, status, dependencies, input)
212
+ VALUES ($1, $2, $3, $4, 'pending', $5::jsonb, $6::jsonb)
213
+ RETURNING *`, [
214
+ workflowId,
215
+ partitionKey,
216
+ s.key,
217
+ s.type,
218
+ JSON.stringify(s.dependencies ?? []),
219
+ s.input != null ? JSON.stringify(s.input) : null
220
+ ]);
221
+ steps.push(mapStep(stepRes.rows[0]));
222
+ }
223
+ return {
224
+ workflowId,
225
+ steps,
226
+ alreadyExisted: false
227
+ };
228
+ });
229
+ }
230
+ async function getWorkflow(workflowId) {
231
+ const res = await exec.query(`SELECT * FROM ${WF} WHERE id = $1 AND partition_key = $2`, [workflowId, partitionKey]);
232
+ return res.rows[0] ? mapWorkflow(res.rows[0]) : null;
233
+ }
234
+ async function getStep(stepId) {
235
+ const res = await exec.query(`SELECT * FROM ${STEP} WHERE id = $1 AND partition_key = $2`, [stepId, partitionKey]);
236
+ return res.rows[0] ? mapStep(res.rows[0]) : null;
237
+ }
238
+ async function listSteps(workflowId) {
239
+ return (await exec.query(`SELECT * FROM ${STEP} WHERE workflow_id = $1 AND partition_key = $2 ORDER BY id`, [workflowId, partitionKey])).rows.map(mapStep);
240
+ }
241
+ async function markStepRunning(stepId, startedAt) {
242
+ return ((await exec.query(`UPDATE ${STEP} SET status = 'running', started_at = $2, attempts = attempts + 1 WHERE id = $1 AND partition_key = $3 AND status = 'pending'`, [
243
+ stepId,
244
+ startedAt,
245
+ partitionKey
246
+ ])).rowCount ?? 0) > 0;
247
+ }
248
+ async function markStepPending(stepId) {
249
+ await exec.query(`UPDATE ${STEP} SET status = 'pending' WHERE id = $1 AND partition_key = $2`, [stepId, partitionKey]);
250
+ }
251
+ async function markStepWaiting(stepId) {
252
+ await exec.query(`UPDATE ${STEP} SET status = 'waiting' WHERE id = $1 AND partition_key = $2`, [stepId, partitionKey]);
253
+ }
254
+ async function markStepMapping(stepId) {
255
+ await exec.query(`UPDATE ${STEP} SET status = 'mapping' WHERE id = $1 AND partition_key = $2`, [stepId, partitionKey]);
256
+ }
257
+ async function markStepCompensating(stepId) {
258
+ await exec.query(`UPDATE ${STEP} SET status = 'compensating' WHERE id = $1 AND partition_key = $2`, [stepId, partitionKey]);
259
+ }
260
+ async function markStepCompensated(stepId, error) {
261
+ await exec.query(`UPDATE ${STEP} SET status = 'compensated', error = COALESCE($3, error) WHERE id = $1 AND partition_key = $2`, [
262
+ stepId,
263
+ partitionKey,
264
+ error ?? null
265
+ ]);
266
+ }
267
+ async function addChildSteps(workflowId, parentStepId, children) {
268
+ return exec.transaction(async (client) => {
269
+ const created = [];
270
+ for (const c of children) {
271
+ const res = await client.query(`INSERT INTO ${STEP} (workflow_id, partition_key, key, type, status, dependencies, input, parent_step_id)
272
+ VALUES ($1, $2, $3, $4, 'pending', '[]'::jsonb, $5::jsonb, $6)
273
+ RETURNING *`, [
274
+ workflowId,
275
+ partitionKey,
276
+ c.key,
277
+ c.type,
278
+ c.input != null ? JSON.stringify(c.input) : null,
279
+ parentStepId
280
+ ]);
281
+ created.push(mapStep(res.rows[0]));
282
+ }
283
+ await client.query(`UPDATE ${WF} SET total_steps = total_steps + $2 WHERE id = $1 AND partition_key = $3`, [
284
+ workflowId,
285
+ children.length,
286
+ partitionKey
287
+ ]);
288
+ return created;
289
+ });
290
+ }
291
+ async function listChildSteps(parentStepId) {
292
+ return (await exec.query(`SELECT * FROM ${STEP} WHERE parent_step_id = $1 AND partition_key = $2 ORDER BY id`, [parentStepId, partitionKey])).rows.map(mapStep);
293
+ }
294
+ async function completeStep(params) {
295
+ await exec.transaction(async (client) => {
296
+ await client.query(`UPDATE ${STEP} SET status = 'completed', output = $2::jsonb, completed_at = $3 WHERE id = $1 AND partition_key = $4`, [
297
+ params.stepId,
298
+ JSON.stringify(params.output),
299
+ params.completedAt,
300
+ partitionKey
301
+ ]);
302
+ await client.query(`UPDATE ${WF} SET completed_steps = completed_steps + 1 WHERE id = $1 AND partition_key = $2`, [params.workflowId, partitionKey]);
303
+ });
304
+ }
305
+ async function failStep(params) {
306
+ await exec.transaction(async (client) => {
307
+ await client.query(`UPDATE ${STEP} SET status = 'failed', error = $2, completed_at = $3 WHERE id = $1 AND partition_key = $4`, [
308
+ params.stepId,
309
+ params.error,
310
+ params.completedAt,
311
+ partitionKey
312
+ ]);
313
+ await client.query(`UPDATE ${WF} SET failed_steps = failed_steps + 1 WHERE id = $1 AND partition_key = $2`, [params.workflowId, partitionKey]);
314
+ });
315
+ }
316
+ async function skipStep(stepId, reason) {
317
+ await exec.query(`UPDATE ${STEP} SET status = 'skipped', error = $2 WHERE id = $1 AND partition_key = $3`, [
318
+ stepId,
319
+ reason,
320
+ partitionKey
321
+ ]);
322
+ }
323
+ async function skipPendingSteps(workflowId, reason) {
324
+ await exec.query(`UPDATE ${STEP} SET status = 'skipped', error = $2 WHERE workflow_id = $1 AND partition_key = $3 AND status IN ('pending', 'waiting')`, [
325
+ workflowId,
326
+ reason,
327
+ partitionKey
328
+ ]);
329
+ }
330
+ async function finishWorkflow(params) {
331
+ await exec.query(`UPDATE ${WF}
332
+ SET status = $2,
333
+ output = COALESCE($3::jsonb, output),
334
+ error = COALESCE($4, error),
335
+ completed_at = $5
336
+ WHERE id = $1 AND partition_key = $6`, [
337
+ params.workflowId,
338
+ params.status,
339
+ params.output != null ? JSON.stringify(params.output) : null,
340
+ params.error ?? null,
341
+ params.completedAt,
342
+ partitionKey
343
+ ]);
344
+ }
345
+ async function listWorkflows(filters) {
346
+ const conds = ["partition_key = $1"];
347
+ const args = [partitionKey];
348
+ if (filters.status) {
349
+ args.push(filters.status);
350
+ conds.push(`status = $${args.length}`);
351
+ }
352
+ if (filters.type) {
353
+ args.push(filters.type);
354
+ conds.push(`type = $${args.length}`);
355
+ }
356
+ if (filters.entityRef) {
357
+ args.push(filters.entityRef);
358
+ conds.push(`entity_ref = $${args.length}`);
359
+ }
360
+ args.push(filters.limit ?? 50);
361
+ const limitIdx = args.length;
362
+ const wfRes = await exec.query(`SELECT * FROM ${WF} WHERE ${conds.join(" AND ")} ORDER BY id DESC LIMIT $${limitIdx}`, args);
363
+ if (wfRes.rows.length === 0) return [];
364
+ const ids = wfRes.rows.map((r) => Number(r.id));
365
+ const stepRes = await exec.query(`SELECT * FROM ${STEP} WHERE workflow_id = ANY($1::bigint[]) AND partition_key = $2 ORDER BY id`, [ids, partitionKey]);
366
+ const stepsByWf = /* @__PURE__ */ new Map();
367
+ for (const row of stepRes.rows) {
368
+ const s = mapStep(row);
369
+ const list = stepsByWf.get(s.workflowId) ?? [];
370
+ list.push(s);
371
+ stepsByWf.set(s.workflowId, list);
372
+ }
373
+ return wfRes.rows.map((r) => ({
374
+ ...mapWorkflow(r),
375
+ steps: stepsByWf.get(Number(r.id)) ?? []
376
+ }));
377
+ }
378
+ async function listRunningWorkflows() {
379
+ return (await exec.query(`SELECT * FROM ${WF} WHERE partition_key = $1 AND status = 'running'`, [partitionKey])).rows.map(mapWorkflow);
380
+ }
381
+ async function findStuckSteps(workflowId, cutoff) {
382
+ return (await exec.query(`SELECT * FROM ${STEP}
383
+ WHERE workflow_id = $1 AND partition_key = $2 AND status = 'running' AND started_at IS NOT NULL AND started_at < $3::timestamptz`, [
384
+ workflowId,
385
+ partitionKey,
386
+ cutoff
387
+ ])).rows.map(mapStep);
388
+ }
389
+ return {
390
+ createWorkflow,
391
+ getWorkflow,
392
+ getStep,
393
+ listSteps,
394
+ markStepRunning,
395
+ markStepPending,
396
+ markStepWaiting,
397
+ markStepMapping,
398
+ markStepCompensating,
399
+ markStepCompensated,
400
+ addChildSteps,
401
+ listChildSteps,
402
+ completeStep,
403
+ failStep,
404
+ skipStep,
405
+ skipPendingSteps,
406
+ finishWorkflow,
407
+ listWorkflows,
408
+ listRunningWorkflows,
409
+ findStuckSteps
410
+ };
411
+ }
412
+ /**
413
+ * A `WorkflowStore` backed by a `pg` {@link Pool} — the batteries-included
414
+ * adapter that owns its own connections/transactions (via {@link poolExecutor})
415
+ * and requires the tables from `flowStoreDdl()`. Unchanged public surface: hosts
416
+ * that need Row Level Security or their own migrations should instead build an
417
+ * executor and call {@link createWorkflowStore}.
418
+ */
419
+ function createPgWorkflowStore(deps) {
420
+ return createWorkflowStore({
421
+ exec: poolExecutor(deps.pool),
422
+ partitionKey: deps.partitionKey,
423
+ schema: deps.schema
424
+ });
425
+ }
426
+ /** Convenience: apply the flow-store schema to a database. */
427
+ async function applySchema(pool, ddl) {
428
+ await pool.query(ddl);
429
+ }
430
+ //#endregion
431
+ //#region src/store-pg/gate.ts
432
+ const DEFAULT_RATE_TABLE = "flow_rate_bucket";
433
+ const DEFAULT_LEASE_TABLE = "flow_step_lease";
434
+ /** DDL for the gate's two tables. Apply once at deploy time (or via `applySchema`). */
435
+ function flowGateDdl(opts = {}) {
436
+ const schema = opts.schema ?? "public";
437
+ const rateName = opts.rateBucketTable ?? DEFAULT_RATE_TABLE;
438
+ const leaseName = opts.leaseTable ?? DEFAULT_LEASE_TABLE;
439
+ const rate = `${schema}.${rateName}`;
440
+ const lease = `${schema}.${leaseName}`;
441
+ return `
442
+ ${createSchemaDdl(schema)}CREATE TABLE IF NOT EXISTS ${rate} (
443
+ partition_key text NOT NULL,
444
+ step_type text NOT NULL,
445
+ tokens double precision NOT NULL,
446
+ updated_at timestamptz NOT NULL DEFAULT now(),
447
+ PRIMARY KEY (partition_key, step_type)
448
+ );
449
+
450
+ CREATE TABLE IF NOT EXISTS ${lease} (
451
+ partition_key text NOT NULL,
452
+ step_type text NOT NULL,
453
+ step_id bigint NOT NULL,
454
+ acquired_at timestamptz NOT NULL DEFAULT now(),
455
+ expires_at timestamptz NOT NULL,
456
+ PRIMARY KEY (partition_key, step_type, step_id)
457
+ );
458
+ CREATE INDEX IF NOT EXISTS ${leaseName}_active_idx ON ${lease} (partition_key, step_type, expires_at);
459
+ `;
460
+ }
461
+ /** Default-schema gate DDL string. */
462
+ const FLOW_GATE_DDL = flowGateDdl();
463
+ /** Sentinel thrown to roll back the acquire transaction when the concurrency cap is hit. */
464
+ const CAP_HIT = Symbol("flow.gate.capHit");
465
+ /**
466
+ * A Postgres-backed {@link StepGate} addressing all SQL through an injected
467
+ * {@link SqlExecutor}, so a host can run the concurrency/rate SQL under Row Level
468
+ * Security (inject an executor that sets the tenant GUC) instead of a plain pool.
469
+ * Global concurrency caps + rate limits shared across all workers; drop-in for
470
+ * the in-memory gate via `WorkflowEngineDeps.gate`.
471
+ */
472
+ function createStepGate(config) {
473
+ const { exec, partitionKey } = config;
474
+ const schema = config.schema ?? "public";
475
+ const RATE = `${schema}.${config.tables?.rateBucket ?? DEFAULT_RATE_TABLE}`;
476
+ const LEASE = `${schema}.${config.tables?.lease ?? DEFAULT_LEASE_TABLE}`;
477
+ const leaseTtl = Math.max(1, config.leaseTtlSeconds ?? 600);
478
+ const concurrencyRetry = Math.max(1, config.concurrencyRetrySeconds ?? 1);
479
+ async function releaseLease(req) {
480
+ await exec.query(`DELETE FROM ${LEASE} WHERE partition_key = $1 AND step_type = $2 AND step_id = $3`, [
481
+ partitionKey,
482
+ req.stepType,
483
+ req.stepId
484
+ ]);
485
+ }
486
+ /** Reserve a concurrency slot atomically. Returns whether a slot was granted. */
487
+ async function acquireLease(req, cap) {
488
+ try {
489
+ return await exec.transaction(async (tx) => {
490
+ await tx.query("SELECT pg_advisory_xact_lock(hashtext($1))", [`${partitionKey}:${req.stepType}`]);
491
+ await tx.query(`DELETE FROM ${LEASE} WHERE partition_key = $1 AND step_type = $2 AND expires_at < now()`, [partitionKey, req.stepType]);
492
+ if (((await tx.query(`SELECT count(*)::int AS n FROM ${LEASE} WHERE partition_key = $1 AND step_type = $2 AND step_id <> $3`, [
493
+ partitionKey,
494
+ req.stepType,
495
+ req.stepId
496
+ ])).rows[0]?.n ?? 0) >= cap) throw CAP_HIT;
497
+ await tx.query(`INSERT INTO ${LEASE} (partition_key, step_type, step_id, expires_at)
498
+ VALUES ($1, $2, $3, now() + ($4 || ' seconds')::interval)
499
+ ON CONFLICT (partition_key, step_type, step_id)
500
+ DO UPDATE SET acquired_at = now(), expires_at = EXCLUDED.expires_at`, [
501
+ partitionKey,
502
+ req.stepType,
503
+ req.stepId,
504
+ String(leaseTtl)
505
+ ]);
506
+ return true;
507
+ });
508
+ } catch (e) {
509
+ if (e === CAP_HIT) return false;
510
+ throw e;
511
+ }
512
+ }
513
+ /** Consume a rate token atomically (refill-then-decrement in one UPDATE). */
514
+ async function consumeToken(req, rule) {
515
+ const capacity = rule.burst ?? Math.max(1, Math.ceil(rule.perSecond));
516
+ return ((await exec.query(`INSERT INTO ${RATE} AS bucket (partition_key, step_type, tokens, updated_at)
517
+ VALUES ($1, $2, $3, now())
518
+ ON CONFLICT (partition_key, step_type) DO UPDATE
519
+ SET tokens = LEAST($4, bucket.tokens + EXTRACT(EPOCH FROM (now() - bucket.updated_at)) * $5) - 1,
520
+ updated_at = now()
521
+ WHERE LEAST($4, bucket.tokens + EXTRACT(EPOCH FROM (now() - bucket.updated_at)) * $5) >= 1
522
+ RETURNING tokens`, [
523
+ partitionKey,
524
+ req.stepType,
525
+ capacity - 1,
526
+ capacity,
527
+ rule.perSecond
528
+ ])).rowCount ?? 0) > 0;
529
+ }
530
+ return { async acquire(req) {
531
+ const cRule = config.concurrency?.[req.stepType];
532
+ const rRule = config.rateLimit?.[req.stepType];
533
+ let leaseHeld = false;
534
+ if (cRule) {
535
+ if (!await acquireLease(req, cRule.maxConcurrent)) return {
536
+ admitted: false,
537
+ retryAfterSeconds: concurrencyRetry
538
+ };
539
+ leaseHeld = true;
540
+ }
541
+ if (rRule) {
542
+ if (!await consumeToken(req, rRule)) {
543
+ if (leaseHeld) await releaseLease(req);
544
+ return {
545
+ admitted: false,
546
+ retryAfterSeconds: Math.max(1, Math.ceil(1 / rRule.perSecond))
547
+ };
548
+ }
549
+ }
550
+ return {
551
+ admitted: true,
552
+ release: async () => {
553
+ if (leaseHeld) await releaseLease(req);
554
+ }
555
+ };
556
+ } };
557
+ }
558
+ /**
559
+ * A Postgres-backed {@link StepGate} over a `pg` {@link Pool} — the
560
+ * batteries-included adapter (via {@link poolExecutor}). Hosts that need Row
561
+ * Level Security should build an executor and call {@link createStepGate}.
562
+ */
563
+ function createPgStepGate(config) {
564
+ const { pool, ...rest } = config;
565
+ return createStepGate({
566
+ exec: poolExecutor(pool),
567
+ ...rest
568
+ });
569
+ }
570
+ //#endregion
571
+ //#region src/store-pg/event-sink.ts
572
+ /**
573
+ * DDL for the append-only run-history table. Apply once at deploy time (alongside
574
+ * `flowStoreDdl`), or call `applySchema(pool, flowEventDdl())`.
575
+ */
576
+ function flowEventDdl(schema = "public") {
577
+ const evt = `${schema}.flow_step_event`;
578
+ return `
579
+ ${createSchemaDdl(schema)}CREATE TABLE IF NOT EXISTS ${evt} (
580
+ id bigserial PRIMARY KEY,
581
+ partition_key text NOT NULL,
582
+ workflow_id bigint NOT NULL,
583
+ workflow_type text,
584
+ step_id bigint,
585
+ step_key text,
586
+ step_type text,
587
+ event_type text NOT NULL,
588
+ attempt integer,
589
+ duration_ms integer,
590
+ error text,
591
+ at timestamptz NOT NULL
592
+ );
593
+
594
+ CREATE INDEX IF NOT EXISTS flow_step_event_workflow_idx ON ${evt} (partition_key, workflow_id, id);
595
+ `;
596
+ }
597
+ /** Default-schema event-table DDL string. */
598
+ const FLOW_EVENT_DDL = flowEventDdl();
599
+ /**
600
+ * A `FlowObserver` that appends engine events to `flow_step_event` through an
601
+ * injected {@link SqlExecutor}, so a host can run the write under Row Level
602
+ * Security (inject an executor that sets the tenant GUC) instead of a plain pool.
603
+ * `record` is fire-and-forget (never throws, never blocks the engine); `flush()`
604
+ * awaits the in-flight inserts.
605
+ */
606
+ function createEventSink(deps) {
607
+ const { exec, partitionKey } = deps;
608
+ const EVT = `${deps.schema ?? "public"}.flow_step_event`;
609
+ const pending = /* @__PURE__ */ new Set();
610
+ return {
611
+ record(event) {
612
+ if (event.partitionKey !== partitionKey) return;
613
+ const p = exec.query(`INSERT INTO ${EVT}
614
+ (partition_key, workflow_id, workflow_type, step_id, step_key, step_type, event_type, attempt, duration_ms, error, at)
615
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`, [
616
+ partitionKey,
617
+ event.workflowId,
618
+ event.workflowType ?? null,
619
+ event.stepId ?? null,
620
+ event.stepKey ?? null,
621
+ event.stepType ?? null,
622
+ event.type,
623
+ event.attempt ?? null,
624
+ event.durationMs ?? null,
625
+ event.error ?? null,
626
+ event.at
627
+ ]).catch(() => {}).finally(() => {
628
+ pending.delete(p);
629
+ });
630
+ pending.add(p);
631
+ },
632
+ async flush() {
633
+ await Promise.all(Array.from(pending));
634
+ }
635
+ };
636
+ }
637
+ /**
638
+ * A `FlowObserver` backed by a `pg` {@link Pool} — the batteries-included sink
639
+ * (via {@link poolExecutor}). Hosts that need Row Level Security should build an
640
+ * executor and call {@link createEventSink} instead.
641
+ */
642
+ function createPgEventSink(deps) {
643
+ return createEventSink({
644
+ exec: poolExecutor(deps.pool),
645
+ partitionKey: deps.partitionKey,
646
+ schema: deps.schema
647
+ });
648
+ }
649
+ /**
650
+ * Read a workflow's run history, ordered oldest-first. Accepts either a
651
+ * `pg` {@link Pool} (batteries-included) or a host's {@link SqlExecutor} (e.g.
652
+ * RLS-scoped) — both route through the same seam.
653
+ */
654
+ async function readFlowEvents(db, params) {
655
+ const EVT = `${params.schema ?? "public"}.flow_step_event`;
656
+ return (await toExecutor(db).query(`SELECT * FROM ${EVT} WHERE partition_key = $1 AND workflow_id = $2 ORDER BY id`, [params.partitionKey, params.workflowId])).rows.map((r) => ({
657
+ type: r.event_type,
658
+ at: r.at instanceof Date ? r.at.toISOString() : String(r.at),
659
+ partitionKey: r.partition_key,
660
+ workflowId: Number(r.workflow_id),
661
+ workflowType: r.workflow_type ?? void 0,
662
+ stepId: r.step_id == null ? void 0 : Number(r.step_id),
663
+ stepKey: r.step_key ?? void 0,
664
+ stepType: r.step_type ?? void 0,
665
+ attempt: r.attempt == null ? void 0 : Number(r.attempt),
666
+ durationMs: r.duration_ms == null ? void 0 : Number(r.duration_ms),
667
+ error: r.error ?? void 0
668
+ }));
669
+ }
670
+ //#endregion
671
+ export { FLOW_EVENT_DDL, FLOW_GATE_DDL, FLOW_STORE_DDL, applySchema, createEventSink, createPgEventSink, createPgStepGate, createPgWorkflowStore, createSchemaDdl, createStepGate, createWorkflowStore, flowEventDdl, flowGateDdl, flowStoreDdl, poolExecutor, readFlowEvents, toExecutor };