bunqueue 2.8.49 → 2.8.50

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (129) hide show
  1. package/README.md +19 -9
  2. package/dist/application/contextFactory.d.ts +4 -2
  3. package/dist/application/contextFactory.js +3 -0
  4. package/dist/application/flowFailureRecovery.d.ts +17 -0
  5. package/dist/application/flowFailureRecovery.js +92 -0
  6. package/dist/application/operations/ack.d.ts +3 -2
  7. package/dist/application/operations/ack.js +34 -8
  8. package/dist/application/operations/flowPush.d.ts +9 -0
  9. package/dist/application/operations/flowPush.js +112 -0
  10. package/dist/application/operations/flowTopologyValidation.d.ts +3 -0
  11. package/dist/application/operations/flowTopologyValidation.js +101 -0
  12. package/dist/application/operations/flowValidation.d.ts +3 -0
  13. package/dist/application/operations/flowValidation.js +166 -0
  14. package/dist/application/operations/push.d.ts +3 -0
  15. package/dist/application/operations/push.js +2 -2
  16. package/dist/application/operations/pushInsert.d.ts +7 -1
  17. package/dist/application/operations/pushInsert.js +18 -8
  18. package/dist/application/operations/pushLocks.d.ts +1 -0
  19. package/dist/application/operations/pushLocks.js +5 -1
  20. package/dist/application/operations/queryOperations.js +7 -6
  21. package/dist/application/queueManager.d.ts +5 -0
  22. package/dist/application/queueManager.js +195 -60
  23. package/dist/cli/commandRouter.d.ts +1 -1
  24. package/dist/client/flow.d.ts +3 -6
  25. package/dist/client/flow.js +71 -210
  26. package/dist/client/flowAtomic.d.ts +9 -0
  27. package/dist/client/flowAtomic.js +24 -0
  28. package/dist/client/flowJobCoreMethods.d.ts +28 -0
  29. package/dist/client/flowJobCoreMethods.js +151 -0
  30. package/dist/client/flowJobDependencies.d.ts +7 -0
  31. package/dist/client/flowJobDependencies.js +64 -0
  32. package/dist/client/flowJobFactory.d.ts +11 -24
  33. package/dist/client/flowJobFactory.js +91 -360
  34. package/dist/client/flowJobMoveMethods.d.ts +16 -0
  35. package/dist/client/flowJobMoveMethods.js +112 -0
  36. package/dist/client/flowJobTypes.d.ts +29 -0
  37. package/dist/client/flowJobTypes.js +6 -0
  38. package/dist/client/flowLegacyPlan.d.ts +16 -0
  39. package/dist/client/flowLegacyPlan.js +118 -0
  40. package/dist/client/flowOptions.d.ts +10 -0
  41. package/dist/client/flowOptions.js +44 -0
  42. package/dist/client/flowPlan.d.ts +16 -0
  43. package/dist/client/flowPlan.js +93 -0
  44. package/dist/client/flowPush.js +4 -1
  45. package/dist/client/flowReader.d.ts +10 -0
  46. package/dist/client/flowReader.js +147 -0
  47. package/dist/client/jobHelpers.js +2 -0
  48. package/dist/client/workflow/clock.d.ts +3 -1
  49. package/dist/client/workflow/clock.js +18 -1
  50. package/dist/client/workflow/compensationChild.d.ts +30 -0
  51. package/dist/client/workflow/compensationChild.js +65 -0
  52. package/dist/client/workflow/compensationPass.d.ts +16 -0
  53. package/dist/client/workflow/compensationPass.js +96 -0
  54. package/dist/client/workflow/compensationSupport.d.ts +24 -0
  55. package/dist/client/workflow/compensationSupport.js +57 -0
  56. package/dist/client/workflow/compensator.d.ts +11 -50
  57. package/dist/client/workflow/compensator.js +22 -374
  58. package/dist/client/workflow/definitionGuard.d.ts +11 -0
  59. package/dist/client/workflow/definitionGuard.js +22 -0
  60. package/dist/client/workflow/engine.d.ts +2 -2
  61. package/dist/client/workflow/engine.js +8 -8
  62. package/dist/client/workflow/eventTypes.d.ts +44 -0
  63. package/dist/client/workflow/eventTypes.js +2 -0
  64. package/dist/client/workflow/executionTypes.d.ts +133 -0
  65. package/dist/client/workflow/executionTypes.js +2 -0
  66. package/dist/client/workflow/executor.d.ts +4 -17
  67. package/dist/client/workflow/executor.js +50 -208
  68. package/dist/client/workflow/executorLifecycle.d.ts +18 -0
  69. package/dist/client/workflow/executorLifecycle.js +69 -0
  70. package/dist/client/workflow/executorNodes.d.ts +17 -0
  71. package/dist/client/workflow/executorNodes.js +138 -0
  72. package/dist/client/workflow/identity.d.ts +2 -0
  73. package/dist/client/workflow/identity.js +8 -0
  74. package/dist/client/workflow/index.d.ts +1 -1
  75. package/dist/client/workflow/loops.d.ts +3 -5
  76. package/dist/client/workflow/loops.js +25 -23
  77. package/dist/client/workflow/mapRunner.d.ts +4 -0
  78. package/dist/client/workflow/mapRunner.js +45 -0
  79. package/dist/client/workflow/recovery.js +10 -3
  80. package/dist/client/workflow/runner.d.ts +2 -21
  81. package/dist/client/workflow/runner.js +39 -104
  82. package/dist/client/workflow/runnerTiming.d.ts +14 -0
  83. package/dist/client/workflow/runnerTiming.js +66 -0
  84. package/dist/client/workflow/stepTypes.d.ts +171 -0
  85. package/dist/client/workflow/stepTypes.js +3 -0
  86. package/dist/client/workflow/store.d.ts +17 -17
  87. package/dist/client/workflow/store.js +65 -100
  88. package/dist/client/workflow/storeExecutionCodec.d.ts +11 -0
  89. package/dist/client/workflow/storeExecutionCodec.js +34 -0
  90. package/dist/client/workflow/storeListing.d.ts +11 -0
  91. package/dist/client/workflow/storeListing.js +45 -0
  92. package/dist/client/workflow/storeMaintenance.d.ts +4 -0
  93. package/dist/client/workflow/storeMaintenance.js +40 -0
  94. package/dist/client/workflow/storeSignals.d.ts +9 -0
  95. package/dist/client/workflow/storeSignals.js +38 -2
  96. package/dist/client/workflow/subWorkflowRunner.d.ts +13 -0
  97. package/dist/client/workflow/subWorkflowRunner.js +40 -0
  98. package/dist/client/workflow/types.d.ts +4 -356
  99. package/dist/client/workflow/types.js +1 -3
  100. package/dist/client/workflow/waitFor.js +39 -26
  101. package/dist/client/workflow/workflow.d.ts +16 -59
  102. package/dist/client/workflow/workflow.js +53 -179
  103. package/dist/client/workflow/workflowDecisions.d.ts +11 -0
  104. package/dist/client/workflow/workflowDecisions.js +27 -0
  105. package/dist/client/workflow/workflowDefinition.d.ts +16 -0
  106. package/dist/client/workflow/workflowDefinition.js +123 -0
  107. package/dist/client/workflow/workflowIntrospection.d.ts +5 -0
  108. package/dist/client/workflow/workflowIntrospection.js +46 -0
  109. package/dist/client/workflow/workflowValidation.d.ts +44 -0
  110. package/dist/client/workflow/workflowValidation.js +143 -0
  111. package/dist/domain/types/command.d.ts +7 -1
  112. package/dist/domain/types/flow.d.ts +25 -0
  113. package/dist/domain/types/flow.js +1 -0
  114. package/dist/infrastructure/persistence/schema.d.ts +2 -2
  115. package/dist/infrastructure/persistence/schema.js +52 -3
  116. package/dist/infrastructure/persistence/sqlite.d.ts +16 -0
  117. package/dist/infrastructure/persistence/sqlite.js +129 -7
  118. package/dist/infrastructure/persistence/sqliteBatch.js +10 -4
  119. package/dist/infrastructure/persistence/sqliteSerializer.d.ts +2 -0
  120. package/dist/infrastructure/persistence/sqliteSerializer.js +18 -5
  121. package/dist/infrastructure/persistence/statements.d.ts +4 -0
  122. package/dist/infrastructure/persistence/statements.js +8 -2
  123. package/dist/infrastructure/server/handlerRoutes.js +3 -0
  124. package/dist/infrastructure/server/handlers/advanced.js +2 -2
  125. package/dist/infrastructure/server/handlers/flow.d.ts +7 -0
  126. package/dist/infrastructure/server/handlers/flow.js +11 -0
  127. package/dist/infrastructure/server/handlers/index.d.ts +1 -0
  128. package/dist/infrastructure/server/handlers/index.js +1 -0
  129. package/package.json +4 -2
@@ -0,0 +1,138 @@
1
+ /** Workflow node dispatch, split from the executor's lifecycle orchestration. */
2
+ import { clock } from './clock';
3
+ import { describeError } from './identity';
4
+ import { executeDoUntil, executeDoWhile, executeForEach, executeMap } from './loops';
5
+ import { buildContext, executeParallelSteps, executeStepWithRetry, executeSubWorkflow, } from './runner';
6
+ import { runWaitFor } from './waitFor';
7
+ import { branchDecisionKey, resolveDecision, subWorkflowInputDecisionKey, } from './workflowDecisions';
8
+ export async function executeWorkflowNode(deps, exec, node, idx, wf) {
9
+ if (node.type === 'step') {
10
+ await executeStepWithRetry(node.def, buildContext(exec), exec, {
11
+ emitter: deps.emitter,
12
+ updateFn: deps.updateFn,
13
+ });
14
+ }
15
+ else if (node.type === 'branch') {
16
+ await runBranch(deps, exec, node, idx);
17
+ }
18
+ else if (node.type === 'parallel') {
19
+ await executeParallelSteps(node.def.steps, buildContext(exec), exec, deps.emitter, deps.updateFn);
20
+ }
21
+ else if (node.type === 'subWorkflow') {
22
+ await runSubWorkflow(deps, exec, node, idx);
23
+ }
24
+ else if (node.type === 'doUntil') {
25
+ await executeDoUntil(node.def, exec, deps.emitter, deps.updateFn);
26
+ }
27
+ else if (node.type === 'doWhile') {
28
+ await executeDoWhile(node.def, exec, deps.emitter, deps.updateFn);
29
+ }
30
+ else if (node.type === 'forEach') {
31
+ await executeForEach(node.def, exec, deps.emitter, deps.updateFn);
32
+ }
33
+ else if (node.type === 'map') {
34
+ await executeMap(node.def, exec, deps.emitter, deps.updateFn);
35
+ }
36
+ else if (node.type === 'pivot') {
37
+ // A pivot commits the saga before the cursor moves beyond it.
38
+ exec.committedAt = idx;
39
+ deps.store.update(exec);
40
+ }
41
+ else {
42
+ await runWaitFor(deps.waitFor, exec, node, idx, wf);
43
+ return;
44
+ }
45
+ await deps.advance(exec, idx + 1, wf);
46
+ }
47
+ async function runBranch(deps, exec, node, idx) {
48
+ const pathName = await resolveDecision(exec, branchDecisionKey(idx), () => node.def.condition(buildContext(exec)), deps.updateFn);
49
+ if (typeof pathName !== 'string') {
50
+ throw new Error(`Branch at node ${idx} returned a non-string path`);
51
+ }
52
+ const pathSteps = node.def.paths.get(pathName);
53
+ if (!pathSteps) {
54
+ throw new Error(`Branch at node ${idx} resolved unknown path "${pathName}"`);
55
+ }
56
+ const selectedNames = pathSteps.map((step) => step.name);
57
+ const resolved = new Set(exec.resolvedSteps ?? []);
58
+ const previousSize = resolved.size;
59
+ for (const name of selectedNames)
60
+ resolved.add(name);
61
+ if (resolved.size !== previousSize) {
62
+ exec.resolvedSteps = [...resolved];
63
+ deps.updateFn(exec);
64
+ }
65
+ for (const step of pathSteps) {
66
+ await executeStepWithRetry(step, buildContext(exec), exec, {
67
+ emitter: deps.emitter,
68
+ updateFn: deps.updateFn,
69
+ });
70
+ }
71
+ }
72
+ async function runSubWorkflow(deps, exec, node, idx) {
73
+ const subInput = await resolveDecision(exec, subWorkflowInputDecisionKey(idx), () => structuredClone(node.inputMapper(buildContext(exec))), deps.updateFn);
74
+ const recordKey = `sub:${node.name}`;
75
+ const childExecutionId = await adoptExistingChild(deps, exec, node.name, recordKey);
76
+ try {
77
+ const { results, executionId } = await executeSubWorkflow(node.name, subInput, async (name, input) => {
78
+ const handle = await deps.start(name, input, exec.id);
79
+ // Claim the child before polling it. A restart can then resume the existing
80
+ // child instead of starting another one.
81
+ exec.steps[recordKey] = { status: 'running', childExecutionId: handle.id };
82
+ deps.store.update(exec);
83
+ return handle;
84
+ }, (id) => deps.store.get(id), {
85
+ pollIntervalMs: node.pollInterval,
86
+ maxWaitMs: node.timeout,
87
+ existingChildId: childExecutionId,
88
+ });
89
+ exec.steps[recordKey] = {
90
+ status: 'completed',
91
+ result: results,
92
+ completedAt: clock().now(),
93
+ childExecutionId: executionId,
94
+ };
95
+ }
96
+ catch (error) {
97
+ settleFailedChild(deps.store, exec, recordKey, error);
98
+ throw error;
99
+ }
100
+ }
101
+ async function adoptExistingChild(deps, exec, workflowName, recordKey) {
102
+ const claimedId = exec.steps[recordKey]?.childExecutionId;
103
+ const child = claimedId ? deps.store.get(claimedId) : deps.store.findChild(exec.id, workflowName);
104
+ if (!child)
105
+ return claimedId;
106
+ if (!claimedId) {
107
+ exec.steps[recordKey] = {
108
+ ...exec.steps[recordKey],
109
+ status: 'running',
110
+ childExecutionId: child.id,
111
+ };
112
+ deps.store.update(exec);
113
+ }
114
+ // The child row is durable before its initial queue publication. A crash in that
115
+ // gap leaves it running but undelivered; duplicate publication is safe because node
116
+ // admission and persisted outcomes make it idempotent.
117
+ if (child.state === 'running')
118
+ await deps.enqueue(child);
119
+ return child.id;
120
+ }
121
+ function settleFailedChild(store, exec, recordKey, error) {
122
+ const claimed = exec.steps[recordKey];
123
+ if (!claimed)
124
+ return;
125
+ exec.steps[recordKey] = {
126
+ ...claimed,
127
+ status: 'failed',
128
+ error: describeError(error),
129
+ completedAt: clock().now(),
130
+ };
131
+ try {
132
+ store.update(exec);
133
+ }
134
+ catch {
135
+ // The generic failure path persists this in-memory record. Do not replace the
136
+ // child's diagnostic with an incidental write error.
137
+ }
138
+ }
@@ -18,6 +18,8 @@
18
18
  * must present the key iteration 2 already used, or the provider bills twice.
19
19
  */
20
20
  export type Direction = 'forward' | 'compensate';
21
+ /** Opaque 128-bit execution identity, deterministic under the simulated clock. */
22
+ export declare function newExecutionId(): string;
21
23
  export declare function idempotencyKey(runId: string, stepName: string, occurrence: number, direction: Direction): string;
22
24
  /**
23
25
  * A loop iteration record is `<step>:<digits>` and nothing else.
@@ -17,6 +17,14 @@
17
17
  * stable when a resumed loop replays its earlier iterations: replaying iteration 2
18
18
  * must present the key iteration 2 already used, or the provider bills twice.
19
19
  */
20
+ import { clock } from './clock';
21
+ /** Opaque 128-bit execution identity, deterministic under the simulated clock. */
22
+ export function newExecutionId() {
23
+ const hex = [...clock().randomBytes(16)]
24
+ .map((byte) => byte.toString(16).padStart(2, '0'))
25
+ .join('');
26
+ return `wf_${hex}`;
27
+ }
20
28
  export function idempotencyKey(runId, stepName, occurrence, direction) {
21
29
  return `${runId}:${stepName}#${occurrence}:${direction}`;
22
30
  }
@@ -20,4 +20,4 @@ import '../../require-bun';
20
20
  export { Workflow } from './workflow';
21
21
  export { Engine } from './engine';
22
22
  export { WorkflowEmitter } from './emitter';
23
- export type { StepContext, StepHandler, TypedStepHandler, CompensateHandler, TypedCompensateHandler, StepOptions, SchemaLike, Execution, ExecutionState, StepState, StepRecord, RollbackStatus, CompensationStatus, CompensationOutcome, BranchCondition, WorkflowNode, EngineOptions, RunHandle, ParallelDefinition, SubWorkflowInputMapper, LoopCondition, ForEachItemsExtractor, MapTransformFn, LoopDefinition, ForEachDefinition, MapDefinition, RecoverResult, CleanupOptions, WorkflowEventType, WorkflowEvent, StepEvent, WorkflowLifecycleEvent, SignalEvent, WorkflowEventListener, } from './types';
23
+ export type { StepContext, StepHandler, TypedStepHandler, CompensateHandler, TypedCompensateHandler, StepOptions, SchemaLike, Execution, ExecutionState, ExecutionListOptions, StepState, StepRecord, RollbackStatus, CompensationStatus, CompensationOutcome, BranchCondition, WorkflowNode, EngineOptions, RunHandle, ParallelDefinition, SubWorkflowInputMapper, SubWorkflowOptions, LoopCondition, ForEachItemsExtractor, MapTransformFn, LoopDefinition, ForEachDefinition, MapDefinition, RecoverResult, CleanupOptions, WorkflowEventType, WorkflowEvent, StepEvent, WorkflowLifecycleEvent, SignalEvent, WorkflowEventListener, } from './types';
@@ -1,14 +1,12 @@
1
1
  /**
2
- * Loop & Map execution logic for the Workflow Engine
3
- * Handles doUntil, doWhile, forEach, and map node types.
2
+ * Loop execution logic for the Workflow Engine.
4
3
  */
5
- import type { Execution, LoopDefinition, ForEachDefinition, MapDefinition } from './types';
4
+ import type { Execution, LoopDefinition, ForEachDefinition } from './types';
6
5
  import type { WorkflowEmitter } from './emitter';
6
+ export { executeMap } from './mapRunner';
7
7
  /** Execute a doUntil loop: run steps, then check condition. Repeat until condition returns true. */
8
8
  export declare function executeDoUntil(def: LoopDefinition, exec: Execution, emitter: WorkflowEmitter | null, updateFn: (exec: Execution) => void): Promise<void>;
9
9
  /** Execute a doWhile loop: check condition first, then run steps. Repeat while condition is true. */
10
10
  export declare function executeDoWhile(def: LoopDefinition, exec: Execution, emitter: WorkflowEmitter | null, updateFn: (exec: Execution) => void): Promise<void>;
11
11
  /** Execute a forEach loop: iterate over items, executing the step for each */
12
12
  export declare function executeForEach(def: ForEachDefinition, exec: Execution, emitter: WorkflowEmitter | null, updateFn: (exec: Execution) => void): Promise<void>;
13
- /** Execute a map node: transform step results into a new value */
14
- export declare function executeMap(def: MapDefinition, exec: Execution, emitter: WorkflowEmitter | null, updateFn: (exec: Execution) => void): Promise<void>;
@@ -1,9 +1,9 @@
1
1
  /**
2
- * Loop & Map execution logic for the Workflow Engine
3
- * Handles doUntil, doWhile, forEach, and map node types.
2
+ * Loop execution logic for the Workflow Engine.
4
3
  */
5
4
  import { executeStepWithRetry, buildContext } from './runner';
6
- import { clock } from './clock';
5
+ import { forEachItemsDecisionKey, loopDecisionKey, resolveDecision } from './workflowDecisions';
6
+ export { executeMap } from './mapRunner';
7
7
  /**
8
8
  * Run one iteration of a loop body step, unless it already ran.
9
9
  *
@@ -109,14 +109,20 @@ export async function executeDoUntil(def, exec, emitter, updateFn) {
109
109
  }
110
110
  iteration++;
111
111
  const ctx = buildContext(exec);
112
- shouldStop = await def.condition(ctx, iteration);
112
+ shouldStop = await resolveDecision(exec, loopDecisionKey('doUntil', def.steps[0].name, iteration), () => def.condition(ctx, iteration), updateFn);
113
+ if (typeof shouldStop !== 'boolean') {
114
+ throw new Error('doUntil condition must return a boolean');
115
+ }
113
116
  }
114
117
  }
115
118
  /** Execute a doWhile loop: check condition first, then run steps. Repeat while condition is true. */
116
119
  export async function executeDoWhile(def, exec, emitter, updateFn) {
117
120
  for (let iteration = 0;; iteration++) {
118
121
  const ctx = buildContext(exec);
119
- const shouldContinue = await def.condition(ctx, iteration);
122
+ const shouldContinue = await resolveDecision(exec, loopDecisionKey('doWhile', def.steps[0].name, iteration), () => def.condition(ctx, iteration), updateFn);
123
+ if (typeof shouldContinue !== 'boolean') {
124
+ throw new Error('doWhile condition must return a boolean');
125
+ }
120
126
  if (!shouldContinue)
121
127
  break;
122
128
  if (iteration >= def.maxIterations) {
@@ -130,7 +136,7 @@ export async function executeDoWhile(def, exec, emitter, updateFn) {
130
136
  /** Execute a forEach loop: iterate over items, executing the step for each */
131
137
  export async function executeForEach(def, exec, emitter, updateFn) {
132
138
  const ctx = buildContext(exec);
133
- const items = def.items(ctx);
139
+ const items = await resolveDecision(exec, forEachItemsDecisionKey(def.step.name), () => structuredClone(def.items(ctx)), updateFn);
134
140
  // Anything with a `length` used to be accepted, and JavaScript is generous about
135
141
  // what has one. A number iterated ZERO times and the run reported `completed`, so a
136
142
  // batch that processed nothing was indistinguishable from a batch with nothing to
@@ -145,7 +151,7 @@ export async function executeForEach(def, exec, emitter, updateFn) {
145
151
  throw new Error(`forEach items (${items.length}) exceeds maxIterations (${def.maxIterations})`);
146
152
  }
147
153
  for (let i = 0; i < items.length; i++) {
148
- const item = items[i];
154
+ const item = structuredClone(items[i]);
149
155
  const indexedName = `${def.step.name}:${i}`;
150
156
  const indexedStep = {
151
157
  ...def.step,
@@ -159,9 +165,15 @@ export async function executeForEach(def, exec, emitter, updateFn) {
159
165
  },
160
166
  };
161
167
  // Memoised the same way as doUntil/doWhile: an item already provisioned before a
162
- // crash must not be provisioned again when the node is re-entered.
163
- if (exec.steps[indexedName]?.status === 'completed')
168
+ // crash must not be provisioned again when the node is re-entered. Restore the
169
+ // declared bare name too: it is the public result of the final iteration, while
170
+ // the indexed record remains the durable execution/compensation identity.
171
+ const completed = exec.steps[indexedName];
172
+ if (completed?.status === 'completed') {
173
+ exec.steps[def.step.name] = { ...completed };
174
+ updateFn(exec);
164
175
  continue;
176
+ }
165
177
  const stepCtx = buildContext(exec);
166
178
  let thrown;
167
179
  let threw = false;
@@ -188,6 +200,10 @@ export async function executeForEach(def, exec, emitter, updateFn) {
188
200
  if (record) {
189
201
  record.loopItem = item;
190
202
  record.loopIndex = i;
203
+ // Keep the documented aggregate view in sync on success and failure. The
204
+ // indexed record is still the authoritative unit of work; compensator.ts
205
+ // excludes this mirror whenever an indexed sibling exists.
206
+ exec.steps[def.step.name] = { ...record };
191
207
  try {
192
208
  updateFn(exec);
193
209
  }
@@ -201,17 +217,3 @@ export async function executeForEach(def, exec, emitter, updateFn) {
201
217
  throw thrown;
202
218
  }
203
219
  }
204
- /** Execute a map node: transform step results into a new value */
205
- export async function executeMap(def, exec, emitter, updateFn) {
206
- const ctx = buildContext(exec);
207
- emitter?.emitStep('step:started', exec.id, exec.workflowName, def.name);
208
- const result = await def.transform(ctx);
209
- exec.steps[def.name] = {
210
- status: 'completed',
211
- result,
212
- startedAt: clock().now(),
213
- completedAt: clock().now(),
214
- };
215
- updateFn(exec);
216
- emitter?.emitStep('step:completed', exec.id, exec.workflowName, def.name, { result });
217
- }
@@ -0,0 +1,4 @@
1
+ import type { WorkflowEmitter } from './emitter';
2
+ import type { Execution, MapDefinition } from './types';
3
+ /** Execute a map node with the same durable lifecycle signals as a regular step. */
4
+ export declare function executeMap(def: MapDefinition, exec: Execution, emitter: WorkflowEmitter | null, updateFn: (exec: Execution) => void): Promise<void>;
@@ -0,0 +1,45 @@
1
+ import { clock } from './clock';
2
+ import { describeError } from './identity';
3
+ import { buildContext } from './runner';
4
+ /** Execute a map node with the same durable lifecycle signals as a regular step. */
5
+ export async function executeMap(def, exec, emitter, updateFn) {
6
+ if (exec.steps[def.name]?.status === 'completed')
7
+ return;
8
+ const startedAt = clock().now();
9
+ exec.steps[def.name] = { status: 'running', startedAt };
10
+ updateFn(exec);
11
+ emitter?.emitStep('step:started', exec.id, exec.workflowName, def.name);
12
+ let result;
13
+ try {
14
+ result = await def.transform(buildContext(exec));
15
+ }
16
+ catch (error) {
17
+ const failure = error instanceof Error ? error : new Error(describeError(error));
18
+ exec.steps[def.name] = {
19
+ status: 'failed',
20
+ error: describeError(failure),
21
+ startedAt,
22
+ completedAt: clock().now(),
23
+ };
24
+ try {
25
+ updateFn(exec);
26
+ }
27
+ catch {
28
+ // Preserve the transform failure; generic workflow failure handling retries the write.
29
+ }
30
+ emitter?.emitStep('step:failed', exec.id, exec.workflowName, def.name, {
31
+ error: describeError(failure),
32
+ });
33
+ throw failure;
34
+ }
35
+ // A completed transform whose write fails must stay completed in memory. Reclassifying
36
+ // that infrastructure failure as a transform failure would discard a valid result.
37
+ exec.steps[def.name] = {
38
+ status: 'completed',
39
+ result,
40
+ startedAt,
41
+ completedAt: clock().now(),
42
+ };
43
+ updateFn(exec);
44
+ emitter?.emitStep('step:completed', exec.id, exec.workflowName, def.name, { result });
45
+ }
@@ -10,6 +10,7 @@ import { runCompensation } from './compensator';
10
10
  import { hasSignal } from './storeSignals';
11
11
  import { clock } from './clock';
12
12
  import { decideAdmission } from './admission';
13
+ import { bindExecutionDefinition } from './definitionGuard';
13
14
  export async function recoverExecutions(deps) {
14
15
  const { store, workflows } = deps;
15
16
  const executions = store.listRecoverable();
@@ -29,6 +30,7 @@ export async function recoverExecutions(deps) {
29
30
  const exec = store.get(snapshot.id);
30
31
  if (!exec)
31
32
  continue;
33
+ bindExecutionDefinition(exec, wf, (value) => store.update(value));
32
34
  if (exec.state === 'running') {
33
35
  const admission = decideAdmission(exec, exec.currentNodeIndex, deps.nodesInFlight);
34
36
  // Only `already-in-flight` is reachable here: the state is `running` by the
@@ -42,7 +44,11 @@ export async function recoverExecutions(deps) {
42
44
  await recoverWaiting(exec, wf, deps);
43
45
  result.waiting++;
44
46
  }
45
- else if (exec.state === 'compensating') {
47
+ else if (exec.state === 'compensating' ||
48
+ (exec.state === 'failed' && exec.rollbackStatus === undefined)) {
49
+ // `runNode` persists the failure before it starts the unwind. A hard kill in
50
+ // between leaves a terminal-looking row with rollback still owed. Such rows are
51
+ // recoverable until runCompensation writes an explicit rollbackStatus.
46
52
  // A lost claim means another driver owns this unwind, so nothing happened here
47
53
  // and counting it as a recovery would overstate what recover() did.
48
54
  const outcome = await runCompensation(exec, wf, store, deps.emitter, deps.workflows);
@@ -71,8 +77,9 @@ async function recoverWaiting(exec, wf, deps) {
71
77
  await enqueueExecution(exec, deps.queue);
72
78
  return;
73
79
  }
74
- // Check if signal already arrived while we were down. Key presence, not value:
75
- // a payload-less signal records the key with an `undefined` value (see hasSignal).
80
+ // The signal may have been persisted before the crash, or accepted after this
81
+ // engine was recreated but before recover() reached the row. Key presence, not
82
+ // value: a payload-less signal records the key with an `undefined` value.
76
83
  if (hasSignal(exec.signals, node.event)) {
77
84
  exec.state = 'running';
78
85
  deps.store.update(exec);
@@ -4,8 +4,8 @@
4
4
  import type { StepDefinition, StepContext, Execution } from './types';
5
5
  import type { Workflow } from './workflow';
6
6
  import type { WorkflowEmitter } from './emitter';
7
- /** Run a promise with a timeout */
8
- export declare function runWithTimeout<T>(promise: Promise<T> | T, timeoutMs: number): Promise<T>;
7
+ export { runWithTimeout } from './runnerTiming';
8
+ export { executeSubWorkflow } from './subWorkflowRunner';
9
9
  /** Engine hooks a step needs; they always travel together. */
10
10
  export interface StepHooks {
11
11
  emitter: WorkflowEmitter | null;
@@ -15,25 +15,6 @@ export interface StepHooks {
15
15
  export declare function executeStepWithRetry(def: StepDefinition, ctx: StepContext, exec: Execution, hooks: StepHooks, occurrence?: number): Promise<void>;
16
16
  /** Execute multiple steps in parallel via Promise.allSettled */
17
17
  export declare function executeParallelSteps(steps: StepDefinition[], ctx: StepContext, exec: Execution, emitter: WorkflowEmitter | null, updateFn: (exec: Execution) => void): Promise<void>;
18
- /** Execute a sub-workflow by starting it and polling for completion */
19
- export declare function executeSubWorkflow(workflowName: string, input: unknown, startFn: (name: string, input: unknown) => Promise<{
20
- id: string;
21
- }>, getFn: (id: string) => Execution | null, pollIntervalMs?: number,
22
- /**
23
- * A child this node already started, from an earlier entry into the same node.
24
- *
25
- * Without it the node started a BRAND NEW child every time it was re-entered, and
26
- * re-entry is routine: a restart followed by `recover()` re-enqueues the parent's
27
- * current node. Measured across one restart, the child ran twice and both rows were
28
- * left `running` forever, since a child is excluded from recovery while its parent
29
- * exists and `cleanup`/`archive` only reap terminal states. Duplicated work, not just
30
- * a leaked row: a child that provisions a resource provisioned it twice
31
- * (`test/repro-workflow-orphan-child.test.ts`).
32
- */
33
- existingChildId?: string): Promise<{
34
- results: Record<string, unknown>;
35
- executionId: string;
36
- }>;
37
18
  /** Find a step definition by name across all node types */
38
19
  export declare function findStepDef(wf: Workflow, name: string): StepDefinition | null;
39
20
  /** Build a StepContext from the current execution state */
@@ -3,49 +3,36 @@
3
3
  */
4
4
  import { idempotencyKey, isIterationOf, describeError } from './identity';
5
5
  import { clock } from './clock';
6
- /** Exponential backoff with jitter */
7
- function backoffDelay(attempt, baseMs = 500, maxMs = 30_000) {
8
- const delay = Math.min(baseMs * 2 ** (attempt - 1), maxMs);
9
- const jitter = delay * 0.5 * clock().random();
10
- return delay + jitter;
11
- }
12
- /** Run a promise with a timeout */
13
- export function runWithTimeout(promise, timeoutMs) {
14
- if (!(promise instanceof Promise))
15
- return Promise.resolve(promise);
16
- if (timeoutMs <= 0)
17
- return promise;
18
- return new Promise((resolve, reject) => {
19
- const timer = clock().setTimeout(() => {
20
- reject(new Error(`Step timed out after ${timeoutMs}ms`));
21
- }, timeoutMs);
22
- promise.then((v) => {
23
- clock().clearTimeout(timer);
24
- resolve(v);
25
- }, (e) => {
26
- clock().clearTimeout(timer);
27
- // `describeError`, not `String`: this wrapper runs BEFORE the compensator's own
28
- // catch, so converting here with `String` is what destroyed a structured throw
29
- // into `[object Object]` no matter how carefully the catch handled it.
30
- reject(e instanceof Error ? e : new Error(describeError(e)));
31
- });
32
- });
33
- }
6
+ import { retryBackoffDelay, runWithTimeout } from './runnerTiming';
7
+ export { runWithTimeout } from './runnerTiming';
8
+ export { executeSubWorkflow } from './subWorkflowRunner';
34
9
  /** Execute a step with retry logic and exponential backoff */
35
10
  export async function executeStepWithRetry(def, ctx, exec, hooks, occurrence = 0) {
36
11
  const { emitter, updateFn } = hooks;
37
12
  const maxAttempts = def.retry;
13
+ const current = exec.steps[def.name];
14
+ const previous = current && (current.occurrence ?? 0) === occurrence ? current : undefined;
15
+ const attemptsUsed = previous?.attempts ?? 0;
16
+ if (previous?.status === 'completed')
17
+ return;
18
+ if (previous?.status === 'failed' && attemptsUsed >= maxAttempts) {
19
+ throw new Error(previous.error ?? `Step "${def.name}" exhausted its retry budget`);
20
+ }
38
21
  let lastError;
39
22
  // Derived once, outside the retry loop, and persisted with the START record: a
40
23
  // rollback for a step whose outcome is unknown needs this key to reconcile, and by
41
24
  // then the body may never have reached the point of writing anything.
42
- const forwardKey = idempotencyKey(exec.id, def.name, occurrence, 'forward');
25
+ const forwardKey = previous?.idempotencyKey ?? idempotencyKey(exec.id, def.name, occurrence, 'forward');
43
26
  const stepCtx = { ...ctx, idempotencyKey: forwardKey };
44
- for (let attempt = 1; attempt <= maxAttempts; attempt++) {
45
- const prev = exec.steps[def.name];
27
+ let validatedInput = stepCtx.input;
28
+ let inputValidationError;
29
+ let inputParsed = false;
30
+ let finalAttempt = attemptsUsed;
31
+ for (let attempt = attemptsUsed + 1; attempt <= maxAttempts; attempt++) {
32
+ finalAttempt = attempt;
46
33
  exec.steps[def.name] = {
47
34
  status: 'running',
48
- startedAt: prev?.startedAt ?? clock().now(),
35
+ startedAt: previous?.startedAt ?? clock().now(),
49
36
  attempts: attempt,
50
37
  idempotencyKey: forwardKey,
51
38
  occurrence,
@@ -56,29 +43,28 @@ export async function executeStepWithRetry(def, ctx, exec, hooks, occurrence = 0
56
43
  maxAttempts,
57
44
  });
58
45
  try {
59
- let input = stepCtx.input;
60
- if (def.inputSchema) {
46
+ if (!inputParsed) {
47
+ inputParsed = true;
61
48
  try {
62
- // The RETURN VALUE matters. `parse()` is the coercing entry point of every
63
- // schema library the docs point at: `.default()` fills gaps, `.transform()`
64
- // rewrites, `z.coerce.date()` builds a Date from a string. Calling it purely
65
- // for its throw validated the shape and silently dropped every coercion, so
66
- // a step declaring `.default('EUR')` ran with no currency at all
67
- // (`test/repro-workflow-gate-and-schema.test.ts`). A validator that returns
68
- // nothing is still supported: `undefined` means "I only assert", so the
69
- // original value is kept rather than blanked.
70
- const parsed = def.inputSchema.parse(stepCtx.input);
49
+ // Input is immutable for one step occurrence. Parse once so coercion is
50
+ // stable and handler retries do not repeat user schema side effects.
51
+ const parsed = def.inputSchema?.parse(stepCtx.input);
71
52
  if (parsed !== undefined)
72
- input = parsed;
53
+ validatedInput = parsed;
73
54
  }
74
- catch (e) {
75
- throw new Error(`Input validation failed for "${def.name}": ${describeError(e)}`, {
76
- cause: e,
77
- });
55
+ catch (error) {
56
+ inputValidationError = new Error(`Input validation failed for "${def.name}": ${describeError(error)}`, { cause: error });
78
57
  }
79
58
  }
80
- const handlerCtx = input === stepCtx.input ? stepCtx : { ...stepCtx, input };
81
- let result = await runWithTimeout(def.handler(handlerCtx), def.timeout);
59
+ if (inputValidationError)
60
+ throw inputValidationError;
61
+ const controller = new AbortController();
62
+ const handlerCtx = {
63
+ ...stepCtx,
64
+ input: validatedInput,
65
+ signal: controller.signal,
66
+ };
67
+ let result = await runWithTimeout(def.handler(handlerCtx), def.timeout, controller);
82
68
  if (def.outputSchema) {
83
69
  try {
84
70
  const parsed = def.outputSchema.parse(result);
@@ -117,7 +103,7 @@ export async function executeStepWithRetry(def, ctx, exec, hooks, occurrence = 0
117
103
  attempt,
118
104
  maxAttempts,
119
105
  });
120
- await new Promise((r) => clock().setTimeout(() => r(), backoffDelay(attempt)));
106
+ await new Promise((r) => clock().setTimeout(() => r(), retryBackoffDelay(attempt)));
121
107
  continue;
122
108
  }
123
109
  }
@@ -129,7 +115,7 @@ export async function executeStepWithRetry(def, ctx, exec, hooks, occurrence = 0
129
115
  error: String(finalError),
130
116
  startedAt: exec.steps[def.name].startedAt,
131
117
  completedAt: clock().now(),
132
- attempts: maxAttempts,
118
+ attempts: finalAttempt,
133
119
  idempotencyKey: forwardKey,
134
120
  occurrence,
135
121
  };
@@ -154,7 +140,7 @@ export async function executeStepWithRetry(def, ctx, exec, hooks, occurrence = 0
154
140
  }
155
141
  emitter?.emitStep('step:failed', exec.id, exec.workflowName, def.name, {
156
142
  error: String(finalError),
157
- attempt: maxAttempts,
143
+ attempt: finalAttempt,
158
144
  maxAttempts,
159
145
  });
160
146
  throw finalError;
@@ -168,57 +154,6 @@ export async function executeParallelSteps(steps, ctx, exec, emitter, updateFn)
168
154
  throw new AggregateError(errors, errors[0].message);
169
155
  }
170
156
  }
171
- /** Execute a sub-workflow by starting it and polling for completion */
172
- // 6 params, one over the limit. `existingChildId` is what makes re-entering this node resume
173
- // the child a restart already started, instead of abandoning it and provisioning a second
174
- // one, so it belongs in the signature where a caller cannot forget it rather than in an
175
- // options bag where omitting it looks deliberate.
176
- // biome-ignore lint/complexity/useMaxParams: see above
177
- export async function executeSubWorkflow(workflowName, input, startFn, getFn, pollIntervalMs = 100,
178
- /**
179
- * A child this node already started, from an earlier entry into the same node.
180
- *
181
- * Without it the node started a BRAND NEW child every time it was re-entered, and
182
- * re-entry is routine: a restart followed by `recover()` re-enqueues the parent's
183
- * current node. Measured across one restart, the child ran twice and both rows were
184
- * left `running` forever, since a child is excluded from recovery while its parent
185
- * exists and `cleanup`/`archive` only reap terminal states. Duplicated work, not just
186
- * a leaked row: a child that provisions a resource provisioned it twice
187
- * (`test/repro-workflow-orphan-child.test.ts`).
188
- */
189
- existingChildId) {
190
- // Resume the existing child when it is still there. A row that has been cleaned away
191
- // cannot be resumed, so that case starts fresh.
192
- const existing = existingChildId ? getFn(existingChildId) : null;
193
- const handle = existing ? { id: existing.id } : await startFn(workflowName, input);
194
- const maxWait = 300_000;
195
- const start = clock().now();
196
- while (clock().now() - start < maxWait) {
197
- const subExec = getFn(handle.id);
198
- if (subExec?.state === 'completed') {
199
- const results = {};
200
- for (const [name, record] of Object.entries(subExec.steps)) {
201
- if (record.status === 'completed')
202
- results[name] = record.result;
203
- }
204
- return { results, executionId: handle.id };
205
- }
206
- if (subExec?.state === 'failed') {
207
- throw new Error(`Sub-workflow "${workflowName}" (${handle.id}) failed`);
208
- }
209
- // A child that parks mid-rollback is terminal FOR THIS POLL: nothing it does next
210
- // happens without an operator. Waiting for it was measured at the full 300 s, after
211
- // which the parent reported a timeout, which is the wrong diagnostic for precisely
212
- // the scenario this module exists to handle, and it held a worker slot for five
213
- // minutes to say it. The parent parks too, with the real reason.
214
- if (subExec?.state === 'compensation-stuck') {
215
- throw new Error(`Sub-workflow "${workflowName}" (${handle.id}) is parked mid-rollback ` +
216
- `(compensation-stuck); resolve it with resumeCompensation or abandonCompensation`);
217
- }
218
- await new Promise((r) => clock().setTimeout(() => r(), pollIntervalMs));
219
- }
220
- throw new Error(`Sub-workflow "${workflowName}" (${handle.id}) timed out`);
221
- }
222
157
  /** Find a step definition by name across all node types */
223
158
  export function findStepDef(wf, name) {
224
159
  for (const node of wf.nodes) {
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Timing primitives used while running a workflow step.
3
+ *
4
+ * Kept separate from runner.ts so the runner stays focused on state transitions.
5
+ */
6
+ /** Exponential retry backoff with jitter. */
7
+ export declare function retryBackoffDelay(attempt: number, baseMs?: number, maxMs?: number): number;
8
+ /**
9
+ * Bound a value or PromiseLike without passing an overflowing delay to the runtime.
10
+ *
11
+ * Long deadlines are re-armed in platform-sized chunks. Promise.resolve is
12
+ * intentional: it assimilates userland thenables as well as native promises.
13
+ */
14
+ export declare function runWithTimeout<T>(value: PromiseLike<T> | T, timeoutMs: number, controller?: AbortController): Promise<T>;