bunqueue 2.8.49 → 2.8.51
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +62 -9
- package/dist/application/backgroundTasks.js +21 -4
- package/dist/application/cleanupTasks.js +7 -3
- package/dist/application/contextFactory.d.ts +6 -3
- package/dist/application/contextFactory.js +8 -0
- package/dist/application/dependencyCompletions.d.ts +53 -0
- package/dist/application/dependencyCompletions.js +123 -0
- package/dist/application/dependencyProcessor.d.ts +5 -0
- package/dist/application/dependencyProcessor.js +25 -13
- package/dist/application/flowFailureRecovery.d.ts +20 -0
- package/dist/application/flowFailureRecovery.js +94 -0
- package/dist/application/flowParentBackpatch.d.ts +27 -0
- package/dist/application/flowParentBackpatch.js +120 -0
- package/dist/application/operations/ack.d.ts +6 -3
- package/dist/application/operations/ack.js +36 -10
- package/dist/application/operations/ackHelpers.d.ts +5 -4
- package/dist/application/operations/ackHelpers.js +4 -10
- package/dist/application/operations/customId.d.ts +3 -0
- package/dist/application/operations/customId.js +10 -0
- package/dist/application/operations/flowPush.d.ts +9 -0
- package/dist/application/operations/flowPush.js +112 -0
- package/dist/application/operations/flowTopologyValidation.d.ts +3 -0
- package/dist/application/operations/flowTopologyValidation.js +101 -0
- package/dist/application/operations/flowValidation.d.ts +3 -0
- package/dist/application/operations/flowValidation.js +166 -0
- package/dist/application/operations/jobManagement.d.ts +3 -0
- package/dist/application/operations/jobManagement.js +10 -4
- package/dist/application/operations/push.d.ts +6 -1
- package/dist/application/operations/push.js +2 -2
- package/dist/application/operations/pushInsert.d.ts +11 -2
- package/dist/application/operations/pushInsert.js +20 -8
- package/dist/application/operations/pushLocks.d.ts +1 -0
- package/dist/application/operations/pushLocks.js +5 -1
- package/dist/application/operations/queryOperations.js +7 -6
- package/dist/application/queueManager.d.ts +7 -0
- package/dist/application/queueManager.js +261 -67
- package/dist/application/types.d.ts +3 -1
- package/dist/cli/commandRouter.d.ts +1 -1
- package/dist/client/flow.d.ts +3 -6
- package/dist/client/flow.js +71 -210
- package/dist/client/flowAtomic.d.ts +9 -0
- package/dist/client/flowAtomic.js +24 -0
- package/dist/client/flowJobCoreMethods.d.ts +28 -0
- package/dist/client/flowJobCoreMethods.js +151 -0
- package/dist/client/flowJobDependencies.d.ts +7 -0
- package/dist/client/flowJobDependencies.js +64 -0
- package/dist/client/flowJobFactory.d.ts +11 -24
- package/dist/client/flowJobFactory.js +91 -360
- package/dist/client/flowJobMoveMethods.d.ts +16 -0
- package/dist/client/flowJobMoveMethods.js +112 -0
- package/dist/client/flowJobTypes.d.ts +29 -0
- package/dist/client/flowJobTypes.js +6 -0
- package/dist/client/flowLegacyPlan.d.ts +16 -0
- package/dist/client/flowLegacyPlan.js +118 -0
- package/dist/client/flowOptions.d.ts +10 -0
- package/dist/client/flowOptions.js +44 -0
- package/dist/client/flowPlan.d.ts +16 -0
- package/dist/client/flowPlan.js +101 -0
- package/dist/client/flowPush.js +4 -1
- package/dist/client/flowReader.d.ts +10 -0
- package/dist/client/flowReader.js +147 -0
- package/dist/client/flowTypes.d.ts +2 -2
- package/dist/client/jobHelpers.js +2 -0
- package/dist/client/workflow/clock.d.ts +3 -1
- package/dist/client/workflow/clock.js +18 -1
- package/dist/client/workflow/compensationChild.d.ts +30 -0
- package/dist/client/workflow/compensationChild.js +65 -0
- package/dist/client/workflow/compensationPass.d.ts +16 -0
- package/dist/client/workflow/compensationPass.js +96 -0
- package/dist/client/workflow/compensationSupport.d.ts +24 -0
- package/dist/client/workflow/compensationSupport.js +57 -0
- package/dist/client/workflow/compensator.d.ts +11 -50
- package/dist/client/workflow/compensator.js +22 -374
- package/dist/client/workflow/definitionGuard.d.ts +11 -0
- package/dist/client/workflow/definitionGuard.js +22 -0
- package/dist/client/workflow/engine.d.ts +2 -2
- package/dist/client/workflow/engine.js +8 -8
- package/dist/client/workflow/eventTypes.d.ts +44 -0
- package/dist/client/workflow/eventTypes.js +2 -0
- package/dist/client/workflow/executionTypes.d.ts +133 -0
- package/dist/client/workflow/executionTypes.js +2 -0
- package/dist/client/workflow/executor.d.ts +4 -17
- package/dist/client/workflow/executor.js +50 -208
- package/dist/client/workflow/executorLifecycle.d.ts +18 -0
- package/dist/client/workflow/executorLifecycle.js +69 -0
- package/dist/client/workflow/executorNodes.d.ts +17 -0
- package/dist/client/workflow/executorNodes.js +138 -0
- package/dist/client/workflow/identity.d.ts +2 -0
- package/dist/client/workflow/identity.js +8 -0
- package/dist/client/workflow/index.d.ts +1 -1
- package/dist/client/workflow/loops.d.ts +3 -5
- package/dist/client/workflow/loops.js +25 -23
- package/dist/client/workflow/mapRunner.d.ts +4 -0
- package/dist/client/workflow/mapRunner.js +45 -0
- package/dist/client/workflow/recovery.js +10 -3
- package/dist/client/workflow/runner.d.ts +2 -21
- package/dist/client/workflow/runner.js +39 -104
- package/dist/client/workflow/runnerTiming.d.ts +14 -0
- package/dist/client/workflow/runnerTiming.js +66 -0
- package/dist/client/workflow/stepTypes.d.ts +171 -0
- package/dist/client/workflow/stepTypes.js +3 -0
- package/dist/client/workflow/store.d.ts +17 -17
- package/dist/client/workflow/store.js +65 -100
- package/dist/client/workflow/storeExecutionCodec.d.ts +11 -0
- package/dist/client/workflow/storeExecutionCodec.js +34 -0
- package/dist/client/workflow/storeListing.d.ts +11 -0
- package/dist/client/workflow/storeListing.js +45 -0
- package/dist/client/workflow/storeMaintenance.d.ts +4 -0
- package/dist/client/workflow/storeMaintenance.js +40 -0
- package/dist/client/workflow/storeSignals.d.ts +9 -0
- package/dist/client/workflow/storeSignals.js +38 -2
- package/dist/client/workflow/subWorkflowRunner.d.ts +13 -0
- package/dist/client/workflow/subWorkflowRunner.js +40 -0
- package/dist/client/workflow/types.d.ts +4 -356
- package/dist/client/workflow/types.js +1 -3
- package/dist/client/workflow/waitFor.js +39 -26
- package/dist/client/workflow/workflow.d.ts +16 -59
- package/dist/client/workflow/workflow.js +53 -179
- package/dist/client/workflow/workflowDecisions.d.ts +11 -0
- package/dist/client/workflow/workflowDecisions.js +27 -0
- package/dist/client/workflow/workflowDefinition.d.ts +16 -0
- package/dist/client/workflow/workflowDefinition.js +123 -0
- package/dist/client/workflow/workflowIntrospection.d.ts +5 -0
- package/dist/client/workflow/workflowIntrospection.js +46 -0
- package/dist/client/workflow/workflowValidation.d.ts +44 -0
- package/dist/client/workflow/workflowValidation.js +143 -0
- package/dist/domain/types/command.d.ts +7 -1
- package/dist/domain/types/flow.d.ts +25 -0
- package/dist/domain/types/flow.js +1 -0
- package/dist/infrastructure/persistence/dependencyCompletionSchema.d.ts +6 -0
- package/dist/infrastructure/persistence/dependencyCompletionSchema.js +16 -0
- package/dist/infrastructure/persistence/dependencyCompletionStore.d.ts +38 -0
- package/dist/infrastructure/persistence/dependencyCompletionStore.js +105 -0
- package/dist/infrastructure/persistence/schema.d.ts +2 -5
- package/dist/infrastructure/persistence/schema.js +57 -3
- package/dist/infrastructure/persistence/sqlite.d.ts +35 -0
- package/dist/infrastructure/persistence/sqlite.js +238 -12
- package/dist/infrastructure/persistence/sqliteBatch.js +10 -4
- package/dist/infrastructure/persistence/sqliteSerializer.d.ts +5 -0
- package/dist/infrastructure/persistence/sqliteSerializer.js +28 -5
- package/dist/infrastructure/persistence/statements.d.ts +4 -0
- package/dist/infrastructure/persistence/statements.js +8 -2
- package/dist/infrastructure/server/handlerRoutes.js +3 -0
- package/dist/infrastructure/server/handlers/advanced.js +2 -2
- package/dist/infrastructure/server/handlers/flow.d.ts +7 -0
- package/dist/infrastructure/server/handlers/flow.js +11 -0
- package/dist/infrastructure/server/handlers/index.d.ts +1 -0
- package/dist/infrastructure/server/handlers/index.js +1 -0
- package/package.json +4 -2
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { decodeExecution } from './storeExecutionCodec';
|
|
2
|
+
const DEFAULT_LIMIT = 100;
|
|
3
|
+
const MAX_LIMIT = 1000;
|
|
4
|
+
const ORDER_AND_PAGE = 'ORDER BY created_at DESC, id DESC LIMIT ? OFFSET ?';
|
|
5
|
+
/** Prepared, consistently ordered execution-list queries. */
|
|
6
|
+
export class ExecutionListing {
|
|
7
|
+
all;
|
|
8
|
+
byName;
|
|
9
|
+
byState;
|
|
10
|
+
byBoth;
|
|
11
|
+
constructor(db) {
|
|
12
|
+
this.all = db.prepare(`SELECT * FROM workflow_executions ${ORDER_AND_PAGE}`);
|
|
13
|
+
this.byName = db.prepare(`SELECT * FROM workflow_executions WHERE workflow_name = ? ${ORDER_AND_PAGE}`);
|
|
14
|
+
this.byState = db.prepare(`SELECT * FROM workflow_executions WHERE state = ? ${ORDER_AND_PAGE}`);
|
|
15
|
+
this.byBoth = db.prepare(`SELECT * FROM workflow_executions WHERE workflow_name = ? AND state = ? ${ORDER_AND_PAGE}`);
|
|
16
|
+
}
|
|
17
|
+
list(workflowName, state, options = {}) {
|
|
18
|
+
const { limit, offset } = pageBounds(options);
|
|
19
|
+
let rows;
|
|
20
|
+
if (workflowName !== undefined && state !== undefined) {
|
|
21
|
+
rows = this.byBoth.all(workflowName, state, limit, offset);
|
|
22
|
+
}
|
|
23
|
+
else if (workflowName !== undefined) {
|
|
24
|
+
rows = this.byName.all(workflowName, limit, offset);
|
|
25
|
+
}
|
|
26
|
+
else if (state !== undefined) {
|
|
27
|
+
rows = this.byState.all(state, limit, offset);
|
|
28
|
+
}
|
|
29
|
+
else {
|
|
30
|
+
rows = this.all.all(limit, offset);
|
|
31
|
+
}
|
|
32
|
+
return rows.map(decodeExecution);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
function pageBounds(options) {
|
|
36
|
+
const limit = options.limit ?? DEFAULT_LIMIT;
|
|
37
|
+
const offset = options.offset ?? 0;
|
|
38
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > MAX_LIMIT) {
|
|
39
|
+
throw new RangeError(`Workflow execution list limit must be an integer from 1 to ${MAX_LIMIT}`);
|
|
40
|
+
}
|
|
41
|
+
if (!Number.isSafeInteger(offset) || offset < 0) {
|
|
42
|
+
throw new RangeError('Workflow execution list offset must be a non-negative integer');
|
|
43
|
+
}
|
|
44
|
+
return { limit, offset };
|
|
45
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { Database } from 'bun:sqlite';
|
|
2
|
+
export declare function cleanupExecutions(db: Database, maxAgeMs: number, states?: string[]): number;
|
|
3
|
+
export declare function archiveExecutions(db: Database, maxAgeMs: number, states?: string[]): number;
|
|
4
|
+
export declare function archivedExecutionCount(db: Database): number;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { clock } from './clock';
|
|
2
|
+
export function cleanupExecutions(db, maxAgeMs, states = ['completed', 'failed']) {
|
|
3
|
+
const cutoff = clock().now() - maxAgeMs;
|
|
4
|
+
const placeholders = states.map(() => '?').join(',');
|
|
5
|
+
const stmt = db.prepare(`DELETE FROM workflow_executions WHERE updated_at <= ? AND state IN (${placeholders})`);
|
|
6
|
+
const result = stmt.run(cutoff, ...states);
|
|
7
|
+
return result.changes;
|
|
8
|
+
}
|
|
9
|
+
export function archiveExecutions(db, maxAgeMs, states = ['completed', 'failed']) {
|
|
10
|
+
const cutoff = clock().now() - maxAgeMs;
|
|
11
|
+
const now = clock().now();
|
|
12
|
+
const placeholders = states.map(() => '?').join(',');
|
|
13
|
+
const rows = db
|
|
14
|
+
.prepare(`SELECT * FROM workflow_executions
|
|
15
|
+
WHERE updated_at <= ? AND state IN (${placeholders})
|
|
16
|
+
ORDER BY updated_at ASC, id ASC
|
|
17
|
+
LIMIT 1000`)
|
|
18
|
+
.all(cutoff, ...states);
|
|
19
|
+
if (rows.length === 0)
|
|
20
|
+
return 0;
|
|
21
|
+
const insertArchive = db.prepare(`
|
|
22
|
+
INSERT OR REPLACE INTO workflow_executions_archive
|
|
23
|
+
(id, workflow_name, state, input, steps, current_node_index, resolved_steps,
|
|
24
|
+
signals, created_at, updated_at, archived_at, meta)
|
|
25
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
26
|
+
`);
|
|
27
|
+
const deleteOriginal = db.prepare(`DELETE FROM workflow_executions WHERE id = ?`);
|
|
28
|
+
const tx = db.transaction(() => {
|
|
29
|
+
for (const row of rows) {
|
|
30
|
+
insertArchive.run(row.id, row.workflow_name, row.state, row.input, row.steps, row.current_node_index, row.resolved_steps, row.signals, row.created_at, row.updated_at, now, row.meta ?? null);
|
|
31
|
+
deleteOriginal.run(row.id);
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
tx();
|
|
35
|
+
return rows.length;
|
|
36
|
+
}
|
|
37
|
+
export function archivedExecutionCount(db) {
|
|
38
|
+
const row = db.prepare(`SELECT COUNT(*) as cnt FROM workflow_executions_archive`).get();
|
|
39
|
+
return row.cnt;
|
|
40
|
+
}
|
|
@@ -41,6 +41,7 @@ export declare class SignalCoordinator {
|
|
|
41
41
|
private readonly read;
|
|
42
42
|
private readonly write;
|
|
43
43
|
private readonly claimResume;
|
|
44
|
+
private readonly restoreWait;
|
|
44
45
|
private readonly claimPark;
|
|
45
46
|
constructor(db: Database);
|
|
46
47
|
/**
|
|
@@ -48,6 +49,14 @@ export declare class SignalCoordinator {
|
|
|
48
49
|
* claim the single resume for this caller.
|
|
49
50
|
*/
|
|
50
51
|
record(id: string, event: string, payload: unknown): SignalOutcome;
|
|
52
|
+
/**
|
|
53
|
+
* Put a claimed wait back when publishing its resume job failed.
|
|
54
|
+
*
|
|
55
|
+
* The signal stays recorded: recovery sees the waiting row plus the signal key and
|
|
56
|
+
* retries the resume. The cursor predicate prevents a delayed enqueue rejection
|
|
57
|
+
* from parking an execution that another driver has already advanced.
|
|
58
|
+
*/
|
|
59
|
+
restoreWaiting(id: string, event: string, nodeIndex: number): boolean;
|
|
51
60
|
/**
|
|
52
61
|
* Park a running execution at a `waitFor`, unless the awaited signal has already
|
|
53
62
|
* been recorded.
|
|
@@ -57,12 +57,16 @@ export class SignalCoordinator {
|
|
|
57
57
|
read;
|
|
58
58
|
write;
|
|
59
59
|
claimResume;
|
|
60
|
+
restoreWait;
|
|
60
61
|
claimPark;
|
|
61
62
|
constructor(db) {
|
|
62
63
|
this.db = db;
|
|
63
64
|
this.read = db.prepare(`SELECT workflow_name, state, current_node_index, signals FROM workflow_executions WHERE id = ?`);
|
|
64
65
|
this.write = db.prepare(`UPDATE workflow_executions SET signals = ?, updated_at = ? WHERE id = ?`);
|
|
65
66
|
this.claimResume = db.prepare(`UPDATE workflow_executions SET state = 'running', updated_at = ? WHERE id = ? AND state = 'waiting'`);
|
|
67
|
+
this.restoreWait = db.prepare(`UPDATE workflow_executions
|
|
68
|
+
SET state = 'waiting', updated_at = ?
|
|
69
|
+
WHERE id = ? AND state = 'running' AND current_node_index = ?`);
|
|
66
70
|
// 'waiting' is accepted as a source state so re-parking is idempotent: a clamped
|
|
67
71
|
// or partial timeout re-arm re-enters the same waitFor node while the row is
|
|
68
72
|
// still 'waiting', and must be allowed to park (and re-arm) again.
|
|
@@ -77,7 +81,14 @@ export class SignalCoordinator {
|
|
|
77
81
|
const row = this.read.get(id);
|
|
78
82
|
if (!row)
|
|
79
83
|
return { found: false, resumed: false, workflowName: '', currentNodeIndex: 0 };
|
|
84
|
+
const state = row.state;
|
|
85
|
+
if (state !== 'running' && state !== 'waiting') {
|
|
86
|
+
throw new Error(`Execution "${id}" is "${state}" and cannot receive the signal "${event}"`);
|
|
87
|
+
}
|
|
80
88
|
const signals = this.decode(row);
|
|
89
|
+
if (hasSignal(signals, event)) {
|
|
90
|
+
throw new Error(`Signal "${event}" was already received for execution "${id}"`);
|
|
91
|
+
}
|
|
81
92
|
signals[event] = payload;
|
|
82
93
|
const now = clock().now();
|
|
83
94
|
this.write.run(pack(signals), now, id);
|
|
@@ -89,7 +100,30 @@ export class SignalCoordinator {
|
|
|
89
100
|
currentNodeIndex: row.current_node_index,
|
|
90
101
|
};
|
|
91
102
|
});
|
|
92
|
-
|
|
103
|
+
// A signal key lives inside one msgpack blob, so there is no SQL UNIQUE
|
|
104
|
+
// constraint that can arbitrate two writers. Taking the write reservation before
|
|
105
|
+
// the read makes the read/check/write sequence first-writer-wins across database
|
|
106
|
+
// connections as well as within this process.
|
|
107
|
+
return tx.immediate();
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Put a claimed wait back when publishing its resume job failed.
|
|
111
|
+
*
|
|
112
|
+
* The signal stays recorded: recovery sees the waiting row plus the signal key and
|
|
113
|
+
* retries the resume. The cursor predicate prevents a delayed enqueue rejection
|
|
114
|
+
* from parking an execution that another driver has already advanced.
|
|
115
|
+
*/
|
|
116
|
+
restoreWaiting(id, event, nodeIndex) {
|
|
117
|
+
const tx = this.db.transaction(() => {
|
|
118
|
+
const row = this.read.get(id);
|
|
119
|
+
if (!row || row.current_node_index !== nodeIndex || row.state !== 'running')
|
|
120
|
+
return false;
|
|
121
|
+
if (!hasSignal(this.decode(row), event))
|
|
122
|
+
return false;
|
|
123
|
+
const restored = this.restoreWait.run(clock().now(), id, nodeIndex);
|
|
124
|
+
return restored.changes === 1;
|
|
125
|
+
});
|
|
126
|
+
return tx.immediate();
|
|
93
127
|
}
|
|
94
128
|
/**
|
|
95
129
|
* Park a running execution at a `waitFor`, unless the awaited signal has already
|
|
@@ -110,7 +144,9 @@ export class SignalCoordinator {
|
|
|
110
144
|
const claimed = this.claimPark.run(clock().now(), id);
|
|
111
145
|
return { signalPresent: false, parked: claimed.changes === 1, signals };
|
|
112
146
|
});
|
|
113
|
-
|
|
147
|
+
// Serialize the signal check with record(): a deferred transaction can let both
|
|
148
|
+
// sides read first and then surface SQLITE_BUSY while upgrading their locks.
|
|
149
|
+
return tx.immediate();
|
|
114
150
|
}
|
|
115
151
|
decode(row) {
|
|
116
152
|
return unpack(row?.signals) ?? {};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { Execution } from './types';
|
|
2
|
+
export interface SubWorkflowRunOptions {
|
|
3
|
+
pollIntervalMs?: number;
|
|
4
|
+
maxWaitMs?: number;
|
|
5
|
+
existingChildId?: string;
|
|
6
|
+
}
|
|
7
|
+
/** Start or resume a child execution and poll its durable terminal state. */
|
|
8
|
+
export declare function executeSubWorkflow(workflowName: string, input: unknown, startFn: (name: string, input: unknown) => Promise<{
|
|
9
|
+
id: string;
|
|
10
|
+
}>, getFn: (id: string) => Execution | null, options?: SubWorkflowRunOptions): Promise<{
|
|
11
|
+
results: Record<string, unknown>;
|
|
12
|
+
executionId: string;
|
|
13
|
+
}>;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { clock } from './clock';
|
|
2
|
+
const MAX_TIMER_DELAY_MS = 2_147_483_647;
|
|
3
|
+
/** Start or resume a child execution and poll its durable terminal state. */
|
|
4
|
+
export async function executeSubWorkflow(workflowName, input, startFn, getFn, options = {}) {
|
|
5
|
+
const pollIntervalMs = options.pollIntervalMs ?? 100;
|
|
6
|
+
const maxWaitMs = options.maxWaitMs ?? 300_000;
|
|
7
|
+
if (!Number.isFinite(pollIntervalMs) || pollIntervalMs <= 0) {
|
|
8
|
+
throw new Error('Sub-workflow pollIntervalMs must be finite and greater than 0');
|
|
9
|
+
}
|
|
10
|
+
if (!Number.isFinite(maxWaitMs) || maxWaitMs <= 0) {
|
|
11
|
+
throw new Error('Sub-workflow maxWaitMs must be finite and greater than 0');
|
|
12
|
+
}
|
|
13
|
+
// Resume a durable child claimed by an earlier entry into this parent node.
|
|
14
|
+
const existing = options.existingChildId ? getFn(options.existingChildId) : null;
|
|
15
|
+
const handle = existing ? { id: existing.id } : await startFn(workflowName, input);
|
|
16
|
+
const startedAt = existing?.createdAt ?? getFn(handle.id)?.createdAt ?? clock().now();
|
|
17
|
+
for (;;) {
|
|
18
|
+
const subExec = getFn(handle.id);
|
|
19
|
+
if (subExec?.state === 'completed') {
|
|
20
|
+
const results = {};
|
|
21
|
+
for (const [name, record] of Object.entries(subExec.steps)) {
|
|
22
|
+
if (record.status === 'completed')
|
|
23
|
+
results[name] = record.result;
|
|
24
|
+
}
|
|
25
|
+
return { results, executionId: handle.id };
|
|
26
|
+
}
|
|
27
|
+
if (subExec?.state === 'failed') {
|
|
28
|
+
throw new Error(`Sub-workflow "${workflowName}" (${handle.id}) failed`);
|
|
29
|
+
}
|
|
30
|
+
if (subExec?.state === 'compensation-stuck') {
|
|
31
|
+
throw new Error(`Sub-workflow "${workflowName}" (${handle.id}) is parked mid-rollback ` +
|
|
32
|
+
'(compensation-stuck); resolve it with resumeCompensation or abandonCompensation');
|
|
33
|
+
}
|
|
34
|
+
const remaining = maxWaitMs - (clock().now() - startedAt);
|
|
35
|
+
if (remaining <= 0) {
|
|
36
|
+
throw new Error(`Sub-workflow "${workflowName}" (${handle.id}) timed out after ${maxWaitMs}ms`);
|
|
37
|
+
}
|
|
38
|
+
await new Promise((resolve) => clock().setTimeout(() => resolve(), Math.min(pollIntervalMs, remaining, MAX_TIMER_DELAY_MS)));
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -1,356 +1,4 @@
|
|
|
1
|
-
/**
|
|
2
|
-
*
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
/** Context passed to step handlers */
|
|
6
|
-
export interface StepContext<TInput = unknown, TSteps extends Record<string, unknown> = Record<string, unknown>> {
|
|
7
|
-
/** Original workflow input */
|
|
8
|
-
readonly input: TInput;
|
|
9
|
-
/** Results from completed steps (step name → result) */
|
|
10
|
-
readonly steps: Readonly<TSteps>;
|
|
11
|
-
/** Signals received via engine.signal() */
|
|
12
|
-
readonly signals: Readonly<Record<string, unknown>>;
|
|
13
|
-
/** Current execution ID */
|
|
14
|
-
readonly executionId: string;
|
|
15
|
-
/**
|
|
16
|
-
* Idempotency key for THIS execution of the step. Stable across automatic retries
|
|
17
|
-
* and across crash-resume; different for a different run. Pass it straight to the
|
|
18
|
-
* provider so a repeat lands on the same operation instead of a new one.
|
|
19
|
-
*/
|
|
20
|
-
readonly idempotencyKey?: string;
|
|
21
|
-
/**
|
|
22
|
-
* Compensation only: the key the FORWARD step used. When the forward outcome is
|
|
23
|
-
* in doubt, this is what lets a rollback ask the provider "did this actually
|
|
24
|
-
* happen?" instead of depending on an output that may never have been persisted.
|
|
25
|
-
*/
|
|
26
|
-
readonly forwardIdempotencyKey?: string;
|
|
27
|
-
}
|
|
28
|
-
/** Step handler function (type-erased for internal storage) */
|
|
29
|
-
export type StepHandler<TInput = unknown, TResult = unknown> = (ctx: StepContext<TInput, any>) => Promise<TResult> | TResult;
|
|
30
|
-
/** Typed step handler — preserves accumulated step types */
|
|
31
|
-
export type TypedStepHandler<TInput, TSteps extends Record<string, unknown>, TResult> = (ctx: StepContext<TInput, TSteps>) => Promise<TResult> | TResult;
|
|
32
|
-
/** Compensate handler (type-erased for internal storage) */
|
|
33
|
-
export type CompensateHandler<TInput = unknown> = (ctx: StepContext<TInput, any>) => Promise<void> | void;
|
|
34
|
-
/** Typed compensate handler — preserves accumulated step types */
|
|
35
|
-
export type TypedCompensateHandler<TInput, TSteps extends Record<string, unknown>> = (ctx: StepContext<TInput, TSteps>) => Promise<void> | void;
|
|
36
|
-
/** Schema-like object — any object with a .parse() method (Zod, ArkType, Valibot, etc.) */
|
|
37
|
-
export interface SchemaLike {
|
|
38
|
-
parse(data: unknown): unknown;
|
|
39
|
-
}
|
|
40
|
-
/**
|
|
41
|
-
* Options for a single step.
|
|
42
|
-
*
|
|
43
|
-
* `TSteps` is part of the signature for source compatibility and for symmetry with
|
|
44
|
-
* `TypedStepHandler`, even though `compensate` no longer narrows on it (see below).
|
|
45
|
-
* Removing the parameter would break every explicit `StepOptions<In, Steps>` in
|
|
46
|
-
* user code.
|
|
47
|
-
*/
|
|
48
|
-
export interface StepOptions<TInput = unknown, TSteps extends Record<string, unknown> = Record<string, unknown>> {
|
|
49
|
-
retry?: number;
|
|
50
|
-
timeout?: number;
|
|
51
|
-
/**
|
|
52
|
-
* A METHOD taking a permissively-typed context, and every part of that is load
|
|
53
|
-
* bearing. Three forms were measured against real handler shapes:
|
|
54
|
-
*
|
|
55
|
-
* shape union method<TSteps> method<any>
|
|
56
|
-
* compensate: async (ctx) => ... TS7006 ok ok
|
|
57
|
-
* annotated with steps it reads ok ok ok
|
|
58
|
-
* annotated with a step that does not exist ok TS2322 ok
|
|
59
|
-
* CompensateHandler<TInput> alias ok ok ok
|
|
60
|
-
*
|
|
61
|
-
* Not a union (`TypedCompensateHandler | CompensateHandler`): TypeScript cannot
|
|
62
|
-
* contextually type a parameter against a union of signatures, so the inline arrow
|
|
63
|
-
* every documented example uses was an implicit `any` and failed `noImplicitAny`.
|
|
64
|
-
*
|
|
65
|
-
* A method rather than a property so parameters stay bivariant under
|
|
66
|
-
* `strictFunctionTypes`, which is what lets an explicitly annotated handler through.
|
|
67
|
-
*
|
|
68
|
-
* `any` rather than `TSteps` for the step map, deliberately: with `TSteps` an
|
|
69
|
-
* annotation naming a step this workflow does not declare is rejected, and the
|
|
70
|
-
* published union accepted it. Keeping the looser map costs typed access to
|
|
71
|
-
* `ctx.steps` inside a rollback, which handlers already narrow with a cast in
|
|
72
|
-
* practice, and buys source compatibility with every handler written before.
|
|
73
|
-
*/
|
|
74
|
-
compensate?(ctx: StepContext<TInput, TSteps extends never ? never : any>): Promise<void> | void;
|
|
75
|
-
/** Validate step input before execution */
|
|
76
|
-
inputSchema?: SchemaLike;
|
|
77
|
-
/** Validate step output after execution */
|
|
78
|
-
outputSchema?: SchemaLike;
|
|
79
|
-
}
|
|
80
|
-
/** Internal step definition */
|
|
81
|
-
export interface StepDefinition {
|
|
82
|
-
name: string;
|
|
83
|
-
handler: StepHandler;
|
|
84
|
-
compensate?: CompensateHandler;
|
|
85
|
-
retry: number;
|
|
86
|
-
timeout: number;
|
|
87
|
-
inputSchema?: SchemaLike;
|
|
88
|
-
outputSchema?: SchemaLike;
|
|
89
|
-
}
|
|
90
|
-
/** Branch condition function */
|
|
91
|
-
export type BranchCondition<TInput = unknown, TSteps extends Record<string, unknown> = Record<string, unknown>> = (ctx: StepContext<TInput, TSteps>) => string;
|
|
92
|
-
/** Internal branch definition (type-erased) */
|
|
93
|
-
export interface BranchDefinition {
|
|
94
|
-
condition: BranchCondition<any, any>;
|
|
95
|
-
paths: Map<string, StepDefinition[]>;
|
|
96
|
-
}
|
|
97
|
-
/** Definition of a parallel step group */
|
|
98
|
-
export interface ParallelDefinition {
|
|
99
|
-
steps: StepDefinition[];
|
|
100
|
-
}
|
|
101
|
-
/** Input mapper for sub-workflows */
|
|
102
|
-
export type SubWorkflowInputMapper<TInput = unknown, TSteps extends Record<string, unknown> = Record<string, unknown>> = (ctx: StepContext<TInput, TSteps>) => unknown;
|
|
103
|
-
/** Loop condition: receives context + iteration count, returns boolean */
|
|
104
|
-
export type LoopCondition<TInput = unknown, TSteps extends Record<string, unknown> = Record<string, unknown>> = (ctx: StepContext<TInput, TSteps>, iteration: number) => boolean | Promise<boolean>;
|
|
105
|
-
/** Definition of a doUntil/doWhile loop (type-erased) */
|
|
106
|
-
export interface LoopDefinition {
|
|
107
|
-
condition: LoopCondition<any, any>;
|
|
108
|
-
steps: StepDefinition[];
|
|
109
|
-
maxIterations: number;
|
|
110
|
-
}
|
|
111
|
-
/** Item extractor for forEach */
|
|
112
|
-
export type ForEachItemsExtractor<TInput = unknown, TSteps extends Record<string, unknown> = Record<string, unknown>> = (ctx: StepContext<TInput, TSteps>) => unknown[];
|
|
113
|
-
/** Definition of a forEach loop (type-erased) */
|
|
114
|
-
export interface ForEachDefinition {
|
|
115
|
-
items: ForEachItemsExtractor<any, any>;
|
|
116
|
-
step: StepDefinition;
|
|
117
|
-
maxIterations: number;
|
|
118
|
-
}
|
|
119
|
-
/** Transform function for map */
|
|
120
|
-
export type MapTransformFn<TInput = unknown, TSteps extends Record<string, unknown> = Record<string, unknown>> = (ctx: StepContext<TInput, TSteps>) => unknown;
|
|
121
|
-
/** Definition of a map node (type-erased) */
|
|
122
|
-
export interface MapDefinition {
|
|
123
|
-
name: string;
|
|
124
|
-
transform: MapTransformFn<any, any>;
|
|
125
|
-
}
|
|
126
|
-
/** Workflow node (discriminated union) */
|
|
127
|
-
export type WorkflowNode = {
|
|
128
|
-
type: 'step';
|
|
129
|
-
def: StepDefinition;
|
|
130
|
-
} | {
|
|
131
|
-
type: 'branch';
|
|
132
|
-
def: BranchDefinition;
|
|
133
|
-
} | {
|
|
134
|
-
type: 'waitFor';
|
|
135
|
-
event: string;
|
|
136
|
-
timeout?: number;
|
|
137
|
-
} | {
|
|
138
|
-
type: 'parallel';
|
|
139
|
-
def: ParallelDefinition;
|
|
140
|
-
} | {
|
|
141
|
-
type: 'subWorkflow';
|
|
142
|
-
name: string;
|
|
143
|
-
inputMapper: SubWorkflowInputMapper<any, any>;
|
|
144
|
-
} | {
|
|
145
|
-
type: 'doUntil';
|
|
146
|
-
def: LoopDefinition;
|
|
147
|
-
} | {
|
|
148
|
-
type: 'doWhile';
|
|
149
|
-
def: LoopDefinition;
|
|
150
|
-
} | {
|
|
151
|
-
type: 'forEach';
|
|
152
|
-
def: ForEachDefinition;
|
|
153
|
-
} | {
|
|
154
|
-
type: 'map';
|
|
155
|
-
def: MapDefinition;
|
|
156
|
-
} | {
|
|
157
|
-
type: 'pivot';
|
|
158
|
-
};
|
|
159
|
-
/** Execution state */
|
|
160
|
-
export type ExecutionState = 'running' | 'waiting' | 'completed' | 'failed' | 'compensating'
|
|
161
|
-
/**
|
|
162
|
-
* A compensation failed definitively and the unwind stopped. Deliberately NOT
|
|
163
|
-
* terminal: halting and calling it done leaves the operator nothing to act on,
|
|
164
|
-
* and ploughing on would undo work whose dependencies are still standing. The run
|
|
165
|
-
* parks here until someone retries the failed handler or abandons the unwind.
|
|
166
|
-
*/
|
|
167
|
-
| 'compensation-stuck';
|
|
168
|
-
/**
|
|
169
|
-
* What the engine did AFTER a failure. Deliberately a separate axis from the
|
|
170
|
-
* failure reason: the rollback is not the cause of the failure, and collapsing the
|
|
171
|
-
* two makes it impossible to alert on the right thing — "the payment failed" and
|
|
172
|
-
* "the refund never went through" need different pagers.
|
|
173
|
-
*
|
|
174
|
-
* Absent means no unwind was attempted, which is the case for a run that has not
|
|
175
|
-
* failed. There is deliberately no `'not-started'` member: it was documented in three
|
|
176
|
-
* places and never once assigned, so anything branching on it was dead code.
|
|
177
|
-
*/
|
|
178
|
-
export type RollbackStatus =
|
|
179
|
-
/** Every eligible step was compensated successfully. */
|
|
180
|
-
'completed'
|
|
181
|
-
/** The unwind ran past the pivot cutoff or had nothing left to do. */
|
|
182
|
-
| 'not-applicable'
|
|
183
|
-
/** A compensation failed definitively; the remaining ones were not attempted. */
|
|
184
|
-
| 'stuck';
|
|
185
|
-
/** Step execution state */
|
|
186
|
-
export type StepState = 'pending' | 'running' | 'completed' | 'failed';
|
|
187
|
-
/** Record of a step's execution */
|
|
188
|
-
export interface StepRecord {
|
|
189
|
-
status: StepState;
|
|
190
|
-
result?: unknown;
|
|
191
|
-
error?: string;
|
|
192
|
-
startedAt?: number;
|
|
193
|
-
completedAt?: number;
|
|
194
|
-
attempts?: number;
|
|
195
|
-
/**
|
|
196
|
-
* Did this step declare a `compensate` handler when it ran?
|
|
197
|
-
*
|
|
198
|
-
* Persisted because a later deploy can remove or rename the step, and then nothing
|
|
199
|
-
* else can tell "this step never owed a reversal" from "this step owed one and the
|
|
200
|
-
* handler is gone". Without it a renamed step that had not been reversed yet was
|
|
201
|
-
* silently dropped from the unwind and the run reported a clean rollback.
|
|
202
|
-
*/
|
|
203
|
-
compensatable?: boolean;
|
|
204
|
-
/** forEach iteration item — persisted so compensation can restore __item */
|
|
205
|
-
loopItem?: unknown;
|
|
206
|
-
/** forEach iteration index — persisted so compensation can restore __index */
|
|
207
|
-
loopIndex?: number;
|
|
208
|
-
/**
|
|
209
|
-
* Outcome of this step's rollback. Written exactly once per unwind, for every
|
|
210
|
-
* eligible step — including the ones the unwind never reached, which are recorded
|
|
211
|
-
* as 'compensation-skipped' rather than left blank. "Never zero, never two" is
|
|
212
|
-
* only checkable if success is recorded as loudly as failure.
|
|
213
|
-
*/
|
|
214
|
-
compensation?: CompensationOutcome;
|
|
215
|
-
/** Idempotency key of the FORWARD execution, persisted before the body runs. */
|
|
216
|
-
idempotencyKey?: string;
|
|
217
|
-
/**
|
|
218
|
-
* For a `sub:<name>` record: the child execution this step started. Rolling back a
|
|
219
|
-
* nested workflow means running the CHILD's own unwind, so the parent has to keep
|
|
220
|
-
* a handle on it — otherwise a child that succeeded before the parent failed is
|
|
221
|
-
* left standing, with every resource it created orphaned.
|
|
222
|
-
*/
|
|
223
|
-
childExecutionId?: string;
|
|
224
|
-
/** Occurrence of this step name within the run — loops reuse a single name. */
|
|
225
|
-
occurrence?: number;
|
|
226
|
-
}
|
|
227
|
-
/** Terminal outcome of one step's rollback. */
|
|
228
|
-
export type CompensationStatus = 'compensated' | 'compensation-failed' | 'compensation-skipped';
|
|
229
|
-
export interface CompensationOutcome {
|
|
230
|
-
status: CompensationStatus;
|
|
231
|
-
at: number;
|
|
232
|
-
/** Why it failed, or why it was skipped. */
|
|
233
|
-
error?: string;
|
|
234
|
-
}
|
|
235
|
-
/** Full execution state */
|
|
236
|
-
export interface Execution {
|
|
237
|
-
id: string;
|
|
238
|
-
workflowName: string;
|
|
239
|
-
state: ExecutionState;
|
|
240
|
-
input: unknown;
|
|
241
|
-
steps: Record<string, StepRecord>;
|
|
242
|
-
currentNodeIndex: number;
|
|
243
|
-
/** Flattened step list for branch resolution */
|
|
244
|
-
resolvedSteps?: string[];
|
|
245
|
-
/** What happened to the rollback. Independent of `failureReason`. */
|
|
246
|
-
rollbackStatus?: RollbackStatus;
|
|
247
|
-
/** Why the run failed. Independent of `rollbackStatus`. */
|
|
248
|
-
failureReason?: string;
|
|
249
|
-
/**
|
|
250
|
-
* Node index at which `.pivot()` committed, if it was reached.
|
|
251
|
-
*
|
|
252
|
-
* Once set, the saga is committed and backward recovery is OFF ENTIRELY — not just
|
|
253
|
-
* for the steps after it. That is what "point of no return" means: releasing the
|
|
254
|
-
* subdomain of a tenant who has already been sent a welcome email is precisely the
|
|
255
|
-
* outcome the pivot exists to prevent. After it, the only correct recovery is
|
|
256
|
-
* forward.
|
|
257
|
-
*/
|
|
258
|
-
committedAt?: number;
|
|
259
|
-
signals: Record<string, unknown>;
|
|
260
|
-
/**
|
|
261
|
-
* Set when this run was started BY a `subWorkflow` node, naming the parent that owns
|
|
262
|
-
* it. A child is a row like any other, so without this nothing distinguishes it from
|
|
263
|
-
* a top-level run and `recover()` drives it on its own, re-running its steps and
|
|
264
|
-
* re-arming its rollback behind the parent's back
|
|
265
|
-
* (`test/repro-model-child-recovered-alone.test.ts`).
|
|
266
|
-
*/
|
|
267
|
-
parentExecutionId?: string;
|
|
268
|
-
createdAt: number;
|
|
269
|
-
updatedAt: number;
|
|
270
|
-
}
|
|
271
|
-
/** All workflow event types */
|
|
272
|
-
export type WorkflowEventType = 'step:started' | 'step:completed' | 'step:failed' | 'step:retry' | 'workflow:started' | 'workflow:completed' | 'workflow:failed' | 'workflow:compensating' | 'workflow:waiting' | 'signal:received' | 'signal:timeout' | 'compensation:started' | 'compensation:completed' | 'compensation:failed' | 'compensation:skipped';
|
|
273
|
-
/** Base event payload */
|
|
274
|
-
export interface WorkflowEvent {
|
|
275
|
-
type: WorkflowEventType;
|
|
276
|
-
executionId: string;
|
|
277
|
-
workflowName: string;
|
|
278
|
-
timestamp: number;
|
|
279
|
-
}
|
|
280
|
-
/** Step-level event payload */
|
|
281
|
-
export interface StepEvent extends WorkflowEvent {
|
|
282
|
-
stepName: string;
|
|
283
|
-
result?: unknown;
|
|
284
|
-
error?: string;
|
|
285
|
-
attempt?: number;
|
|
286
|
-
maxAttempts?: number;
|
|
287
|
-
}
|
|
288
|
-
/** Workflow lifecycle event payload */
|
|
289
|
-
export interface WorkflowLifecycleEvent extends WorkflowEvent {
|
|
290
|
-
state: ExecutionState;
|
|
291
|
-
input?: unknown;
|
|
292
|
-
}
|
|
293
|
-
/** Signal event payload */
|
|
294
|
-
export interface SignalEvent extends WorkflowEvent {
|
|
295
|
-
event: string;
|
|
296
|
-
payload?: unknown;
|
|
297
|
-
}
|
|
298
|
-
/** Event listener function */
|
|
299
|
-
export type WorkflowEventListener = (event: WorkflowEvent | StepEvent | WorkflowLifecycleEvent | SignalEvent) => void;
|
|
300
|
-
/** Engine configuration */
|
|
301
|
-
export interface EngineOptions {
|
|
302
|
-
embedded?: boolean;
|
|
303
|
-
dataPath?: string;
|
|
304
|
-
connection?: ConnectionOptions;
|
|
305
|
-
/** Internal queue name (default: __wf:steps) */
|
|
306
|
-
queueName?: string;
|
|
307
|
-
/** Worker concurrency (default: 5) */
|
|
308
|
-
concurrency?: number;
|
|
309
|
-
/** Global event listener for observability */
|
|
310
|
-
onEvent?: WorkflowEventListener;
|
|
311
|
-
}
|
|
312
|
-
/** Handle returned from engine.start() */
|
|
313
|
-
export interface RunHandle {
|
|
314
|
-
id: string;
|
|
315
|
-
workflowName: string;
|
|
316
|
-
}
|
|
317
|
-
/** Internal job data for step execution */
|
|
318
|
-
export interface StepJobData {
|
|
319
|
-
executionId: string;
|
|
320
|
-
workflowName: string;
|
|
321
|
-
nodeIndex: number;
|
|
322
|
-
}
|
|
323
|
-
/** Result of engine.recover() */
|
|
324
|
-
export interface RecoverResult {
|
|
325
|
-
/** Number of running executions re-enqueued */
|
|
326
|
-
running: number;
|
|
327
|
-
/** Number of waiting executions with re-armed timers */
|
|
328
|
-
waiting: number;
|
|
329
|
-
/** Number of compensating executions re-run */
|
|
330
|
-
compensating: number;
|
|
331
|
-
/** Total recovered */
|
|
332
|
-
total: number;
|
|
333
|
-
}
|
|
334
|
-
/** Result of WorkflowStore.recordSignal() */
|
|
335
|
-
export interface SignalOutcome {
|
|
336
|
-
/** Whether the execution row exists */
|
|
337
|
-
found: boolean;
|
|
338
|
-
/** True for the single caller that claimed the resume of a parked run */
|
|
339
|
-
resumed: boolean;
|
|
340
|
-
workflowName: string;
|
|
341
|
-
currentNodeIndex: number;
|
|
342
|
-
}
|
|
343
|
-
/** Result of WorkflowStore.parkForSignal() */
|
|
344
|
-
export interface ParkOutcome {
|
|
345
|
-
/** The awaited signal was already recorded — advance instead of parking */
|
|
346
|
-
signalPresent: boolean;
|
|
347
|
-
/** This caller transitioned the run to 'waiting' */
|
|
348
|
-
parked: boolean;
|
|
349
|
-
/** Signals as persisted, for refreshing a stale in-memory snapshot */
|
|
350
|
-
signals: Record<string, unknown>;
|
|
351
|
-
}
|
|
352
|
-
/** Options for cleanup */
|
|
353
|
-
export interface CleanupOptions {
|
|
354
|
-
maxAge: number;
|
|
355
|
-
states?: ExecutionState[];
|
|
356
|
-
}
|
|
1
|
+
/** Stable public barrel for workflow types. */
|
|
2
|
+
export type * from './stepTypes';
|
|
3
|
+
export type * from './executionTypes';
|
|
4
|
+
export type * from './eventTypes';
|