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.
- package/README.md +19 -9
- package/dist/application/contextFactory.d.ts +4 -2
- package/dist/application/contextFactory.js +3 -0
- package/dist/application/flowFailureRecovery.d.ts +17 -0
- package/dist/application/flowFailureRecovery.js +92 -0
- package/dist/application/operations/ack.d.ts +3 -2
- package/dist/application/operations/ack.js +34 -8
- 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/push.d.ts +3 -0
- package/dist/application/operations/push.js +2 -2
- package/dist/application/operations/pushInsert.d.ts +7 -1
- package/dist/application/operations/pushInsert.js +18 -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 +5 -0
- package/dist/application/queueManager.js +195 -60
- 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 +93 -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/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/schema.d.ts +2 -2
- package/dist/infrastructure/persistence/schema.js +52 -3
- package/dist/infrastructure/persistence/sqlite.d.ts +16 -0
- package/dist/infrastructure/persistence/sqlite.js +129 -7
- package/dist/infrastructure/persistence/sqliteBatch.js +10 -4
- package/dist/infrastructure/persistence/sqliteSerializer.d.ts +2 -0
- package/dist/infrastructure/persistence/sqliteSerializer.js +18 -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
|
@@ -12,6 +12,7 @@ import { hasSignal } from './storeSignals';
|
|
|
12
12
|
import { clock } from './clock';
|
|
13
13
|
/** Largest delay setTimeout accepts before wrapping (2**31-1 ms, ~24.8 days) */
|
|
14
14
|
export const MAX_TIMER_MS = 2_147_483_647;
|
|
15
|
+
const TIMEOUT_ENQUEUE_RETRY_MS = 5_000;
|
|
15
16
|
/**
|
|
16
17
|
* Arm the timer that re-enters a parked node once its wait budget elapses.
|
|
17
18
|
*
|
|
@@ -21,31 +22,41 @@ export const MAX_TIMER_MS = 2_147_483_647;
|
|
|
21
22
|
* (test/repro-workflow-timeout-overflow.test.ts).
|
|
22
23
|
*/
|
|
23
24
|
export function scheduleTimeoutCheck(deps, execId, workflowName, nodeIdx, ms) {
|
|
24
|
-
const
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
25
|
+
const arm = (requestedDelay) => {
|
|
26
|
+
const delay = Math.min(Math.max(requestedDelay, 0), MAX_TIMER_MS);
|
|
27
|
+
// Replacing a live timer for the same execution — re-entering a waitFor node used
|
|
28
|
+
// to leak the previous one, which then fired against a node the run had left.
|
|
29
|
+
const previous = deps.timers.get(execId);
|
|
30
|
+
if (previous)
|
|
31
|
+
clock().clearTimeout(previous);
|
|
32
|
+
const timer = clock().setTimeout(() => {
|
|
33
|
+
const jobData = { executionId: execId, workflowName, nodeIndex: nodeIdx };
|
|
34
|
+
const published = () => {
|
|
35
|
+
// Keep the handle in the map while add() is pending. signal() and close() use
|
|
36
|
+
// deletion as cancellation; a late rejection must not resurrect their timer.
|
|
37
|
+
if (deps.timers.get(execId) === timer)
|
|
38
|
+
deps.timers.delete(execId);
|
|
39
|
+
};
|
|
40
|
+
const failed = () => {
|
|
41
|
+
if (deps.timers.get(execId) !== timer)
|
|
42
|
+
return;
|
|
43
|
+
arm(TIMEOUT_ENQUEUE_RETRY_MS);
|
|
44
|
+
};
|
|
45
|
+
try {
|
|
46
|
+
deps.queue
|
|
47
|
+
.add('wf:step', jobData)
|
|
48
|
+
.then(published, failed);
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
failed();
|
|
52
|
+
}
|
|
53
|
+
}, delay);
|
|
54
|
+
// A parked approval gate is a normal steady state. unref keeps its timer
|
|
55
|
+
// functional without making it the reason the process remains alive.
|
|
56
|
+
timer.unref?.();
|
|
57
|
+
deps.timers.set(execId, timer);
|
|
58
|
+
};
|
|
59
|
+
arm(ms);
|
|
49
60
|
}
|
|
50
61
|
/**
|
|
51
62
|
* Cancel every armed wait timer. Called on engine shutdown: `unref` alone lets a
|
|
@@ -86,13 +97,15 @@ export async function runWaitFor(deps, exec, node, idx, wf) {
|
|
|
86
97
|
return;
|
|
87
98
|
}
|
|
88
99
|
deps.emitter?.emitSignal('signal:timeout', exec.id, exec.workflowName, node.event);
|
|
100
|
+
const timeoutReason = `Signal "${node.event}" timed out after ${node.timeout}ms`;
|
|
89
101
|
exec.steps[waitKey] = {
|
|
90
102
|
status: 'failed',
|
|
91
103
|
startedAt: waitingSince,
|
|
92
104
|
completedAt: clock().now(),
|
|
93
|
-
error:
|
|
105
|
+
error: timeoutReason,
|
|
94
106
|
};
|
|
95
107
|
exec.state = 'failed';
|
|
108
|
+
exec.failureReason = timeoutReason;
|
|
96
109
|
// Unguarded on purpose, same reasoning as the failure write in `executor.ts`. Disk
|
|
97
110
|
// still says `waiting`, which `listRecoverable()` covers, so `recoverWaiting`
|
|
98
111
|
// recomputes the remaining time, re-enqueues, and the gate times out again rather
|
|
@@ -5,67 +5,24 @@
|
|
|
5
5
|
* Supports type-safe step chaining: each .step() narrows the return type
|
|
6
6
|
* so subsequent steps can access previous results without casting.
|
|
7
7
|
*/
|
|
8
|
-
import type { WorkflowNode, StepOptions, StepContext, BranchCondition, LoopCondition, ForEachItemsExtractor, TypedStepHandler } from './types';
|
|
9
|
-
|
|
10
|
-
* Reject a workflow whose step names collide with the `name:index` namespace a loop
|
|
11
|
-
* reserves for its iterations.
|
|
12
|
-
*
|
|
13
|
-
* A step literally called `process:0` next to a forEach called `process` is a real
|
|
14
|
-
* collision, and both possible silent outcomes are corruption: the loop overwrites
|
|
15
|
-
* the user's step, or — once iterations are memoised — the loop mistakes the user's
|
|
16
|
-
* record for its own completed work and skips the iteration entirely. Neither can be
|
|
17
|
-
* detected at runtime, so it has to be refused at registration.
|
|
18
|
-
*/
|
|
19
|
-
/**
|
|
20
|
-
* Two `waitFor` nodes on the SAME event cannot both gate.
|
|
21
|
-
*
|
|
22
|
-
* `exec.signals` is a permanent record keyed by event name and a wait is satisfied by
|
|
23
|
-
* the key being present, so nothing marks a signal as consumed and nothing ties a
|
|
24
|
-
* delivery to the gate that was waiting for it. A run shaped `waitFor('approve')`,
|
|
25
|
-
* pay, `waitFor('approve')` is therefore walked end to end by ONE
|
|
26
|
-
* `signal(id, 'approve')`: the second gate never pauses, because the key is already
|
|
27
|
-
* there. A four-eyes control silently degrades to a one-eye control, and no state,
|
|
28
|
-
* event or log records that a gate was skipped
|
|
29
|
-
* (`test/repro-workflow-gate-and-schema.test.ts`).
|
|
30
|
-
*
|
|
31
|
-
* Refused at registration rather than fixed at runtime: consuming the signal would
|
|
32
|
-
* change what `ctx.signals` means for every workflow already written, and a
|
|
33
|
-
* build-time error cannot be missed, while a runtime one shows up when the money is
|
|
34
|
-
* already moving. Distinct event names per gate are the correct shape and cost
|
|
35
|
-
* nothing.
|
|
36
|
-
*/
|
|
37
|
-
/**
|
|
38
|
-
* Why an event name cannot be used as a gate, or `null` if it can.
|
|
39
|
-
*
|
|
40
|
-
* `__proto__` is refused because assignment to that name writes an object's PROTOTYPE
|
|
41
|
-
* instead of creating an own key, so `SignalCoordinator.record()` stored the payload
|
|
42
|
-
* nowhere: the gate never saw its own signal, re-parked, expired, and the unwind
|
|
43
|
-
* reversed work the approver had authorised
|
|
44
|
-
* (`test/repro-workflow-proto-gate-signal.test.ts`). Making it work instead would mean
|
|
45
|
-
* reconciling the storage codec, which deliberately renames `__proto__` to `__proto_`
|
|
46
|
-
* as its own pollution defence, so the gate would be stored under a different name
|
|
47
|
-
* than it was signalled with. A gate with two spellings is not a gate.
|
|
48
|
-
*
|
|
49
|
-
* An empty or non-string name is refused for the plainer reason that nobody can
|
|
50
|
-
* signal it, and two of them would be opened by one signal.
|
|
51
|
-
*/
|
|
52
|
-
export declare function unusableEventName(event: unknown): string | null;
|
|
53
|
-
export declare function assertNoDuplicateWaitFor(wf: {
|
|
54
|
-
name: string;
|
|
55
|
-
nodes: readonly {
|
|
56
|
-
type: string;
|
|
57
|
-
event?: string;
|
|
58
|
-
}[];
|
|
59
|
-
}): void;
|
|
60
|
-
export declare function assertNoIndexCollision(wf: {
|
|
61
|
-
name: string;
|
|
62
|
-
getStepNames(): string[];
|
|
63
|
-
getIndexedStepNames(): string[];
|
|
64
|
-
}): void;
|
|
8
|
+
import type { WorkflowNode, StepOptions, StepContext, BranchCondition, SubWorkflowOptions, LoopCondition, ForEachItemsExtractor, TypedStepHandler } from './types';
|
|
9
|
+
export { assertNoDuplicateWaitFor, assertNoIndexCollision, unusableEventName, } from './workflowValidation';
|
|
65
10
|
export declare class Workflow<TInput = unknown, TSteps extends Record<string, unknown> = Record<string, unknown>> {
|
|
66
11
|
readonly name: string;
|
|
12
|
+
/**
|
|
13
|
+
* Explicit semantic revision. Bump it when handler or condition behavior changes
|
|
14
|
+
* incompatibly without changing the graph shape.
|
|
15
|
+
*/
|
|
16
|
+
readonly revision: string;
|
|
67
17
|
readonly nodes: WorkflowNode[];
|
|
68
|
-
|
|
18
|
+
private sealed;
|
|
19
|
+
private sealedHash?;
|
|
20
|
+
constructor(name: string, options?: {
|
|
21
|
+
revision?: string | number;
|
|
22
|
+
});
|
|
23
|
+
/** Freeze this definition and return its durable structural identity. */
|
|
24
|
+
seal(): string;
|
|
25
|
+
private assertMutable;
|
|
69
26
|
/** Add a step to the workflow — return type accumulates into TSteps */
|
|
70
27
|
step<TName extends string, TResult>(name: TName, handler: TypedStepHandler<TInput, TSteps, TResult>, options?: StepOptions<TInput, TSteps & Record<TName, Awaited<TResult>>>): Workflow<TInput, TSteps & Record<TName, Awaited<TResult>>>;
|
|
71
28
|
/** Add a branch point — call .path() after this to define paths */
|
|
@@ -75,7 +32,7 @@ export declare class Workflow<TInput = unknown, TSteps extends Record<string, un
|
|
|
75
32
|
/** Run multiple steps in parallel — accumulated types from sub-builder merge into TSteps */
|
|
76
33
|
parallel<TNewSteps extends Record<string, unknown>>(builder: (w: Workflow<TInput, TSteps>) => Workflow<TInput, TSteps & TNewSteps>): Workflow<TInput, TSteps & TNewSteps>;
|
|
77
34
|
/** Call another registered workflow as a step */
|
|
78
|
-
subWorkflow<TName extends string>(name: TName, inputMapper: (ctx: StepContext<TInput, TSteps>) => unknown): Workflow<TInput, TSteps & Record<`sub:${TName}`, Record<string, unknown>>>;
|
|
35
|
+
subWorkflow<TName extends string>(name: TName, inputMapper: (ctx: StepContext<TInput, TSteps>) => unknown, options?: SubWorkflowOptions): Workflow<TInput, TSteps & Record<`sub:${TName}`, Record<string, unknown>>>;
|
|
79
36
|
/** Wait for an external signal before continuing */
|
|
80
37
|
waitFor(event: string, options?: {
|
|
81
38
|
timeout?: number;
|
|
@@ -5,152 +5,36 @@
|
|
|
5
5
|
* Supports type-safe step chaining: each .step() narrows the return type
|
|
6
6
|
* so subsequent steps can access previous results without casting.
|
|
7
7
|
*/
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
* `wf:step` job, so they can only host plain steps — a `waitFor` there has no node
|
|
13
|
-
* index to park at, a nested `branch` has no dispatcher. These used to be filtered
|
|
14
|
-
* out silently, which turned an approval gate written inside a path into a no-op the
|
|
15
|
-
* run sailed straight through (test/repro-workflow-guide-claims.test.ts). Failing at
|
|
16
|
-
* build time is the only safe answer: the alternative is skipping a human approval
|
|
17
|
-
* and reporting success.
|
|
18
|
-
*/
|
|
19
|
-
function onlySteps(sub, where, label) {
|
|
20
|
-
const rejected = sub.nodes.filter((n) => n.type !== 'step');
|
|
21
|
-
if (rejected.length > 0) {
|
|
22
|
-
const kinds = [...new Set(rejected.map((n) => n.type))].join(', ');
|
|
23
|
-
const which = label ? ` (path "${label}")` : '';
|
|
24
|
-
throw new Error(`${where} accepts step() nodes only${which}, but received: ${kinds}. ` +
|
|
25
|
-
`Move those nodes to the top level of the workflow, or extract them into a ` +
|
|
26
|
-
`separate workflow and call it with subWorkflow().`);
|
|
27
|
-
}
|
|
28
|
-
return sub.nodes
|
|
29
|
-
.filter((n) => n.type === 'step')
|
|
30
|
-
.map((n) => n.def);
|
|
31
|
-
}
|
|
32
|
-
/**
|
|
33
|
-
* Reject a workflow whose step names collide with the `name:index` namespace a loop
|
|
34
|
-
* reserves for its iterations.
|
|
35
|
-
*
|
|
36
|
-
* A step literally called `process:0` next to a forEach called `process` is a real
|
|
37
|
-
* collision, and both possible silent outcomes are corruption: the loop overwrites
|
|
38
|
-
* the user's step, or — once iterations are memoised — the loop mistakes the user's
|
|
39
|
-
* record for its own completed work and skips the iteration entirely. Neither can be
|
|
40
|
-
* detected at runtime, so it has to be refused at registration.
|
|
41
|
-
*/
|
|
42
|
-
/**
|
|
43
|
-
* Two `waitFor` nodes on the SAME event cannot both gate.
|
|
44
|
-
*
|
|
45
|
-
* `exec.signals` is a permanent record keyed by event name and a wait is satisfied by
|
|
46
|
-
* the key being present, so nothing marks a signal as consumed and nothing ties a
|
|
47
|
-
* delivery to the gate that was waiting for it. A run shaped `waitFor('approve')`,
|
|
48
|
-
* pay, `waitFor('approve')` is therefore walked end to end by ONE
|
|
49
|
-
* `signal(id, 'approve')`: the second gate never pauses, because the key is already
|
|
50
|
-
* there. A four-eyes control silently degrades to a one-eye control, and no state,
|
|
51
|
-
* event or log records that a gate was skipped
|
|
52
|
-
* (`test/repro-workflow-gate-and-schema.test.ts`).
|
|
53
|
-
*
|
|
54
|
-
* Refused at registration rather than fixed at runtime: consuming the signal would
|
|
55
|
-
* change what `ctx.signals` means for every workflow already written, and a
|
|
56
|
-
* build-time error cannot be missed, while a runtime one shows up when the money is
|
|
57
|
-
* already moving. Distinct event names per gate are the correct shape and cost
|
|
58
|
-
* nothing.
|
|
59
|
-
*/
|
|
60
|
-
/**
|
|
61
|
-
* Why an event name cannot be used as a gate, or `null` if it can.
|
|
62
|
-
*
|
|
63
|
-
* `__proto__` is refused because assignment to that name writes an object's PROTOTYPE
|
|
64
|
-
* instead of creating an own key, so `SignalCoordinator.record()` stored the payload
|
|
65
|
-
* nowhere: the gate never saw its own signal, re-parked, expired, and the unwind
|
|
66
|
-
* reversed work the approver had authorised
|
|
67
|
-
* (`test/repro-workflow-proto-gate-signal.test.ts`). Making it work instead would mean
|
|
68
|
-
* reconciling the storage codec, which deliberately renames `__proto__` to `__proto_`
|
|
69
|
-
* as its own pollution defence, so the gate would be stored under a different name
|
|
70
|
-
* than it was signalled with. A gate with two spellings is not a gate.
|
|
71
|
-
*
|
|
72
|
-
* An empty or non-string name is refused for the plainer reason that nobody can
|
|
73
|
-
* signal it, and two of them would be opened by one signal.
|
|
74
|
-
*/
|
|
75
|
-
export function unusableEventName(event) {
|
|
76
|
-
if (typeof event !== 'string' || event.length === 0)
|
|
77
|
-
return 'with no event name';
|
|
78
|
-
if (event === '__proto__') {
|
|
79
|
-
return 'named "__proto__", which cannot be stored as a signal key and would never receive its signal';
|
|
80
|
-
}
|
|
81
|
-
return null;
|
|
82
|
-
}
|
|
83
|
-
export function assertNoDuplicateWaitFor(wf) {
|
|
84
|
-
const seen = new Set();
|
|
85
|
-
for (const node of wf.nodes) {
|
|
86
|
-
if (node.type !== 'waitFor')
|
|
87
|
-
continue;
|
|
88
|
-
// Skipping a nameless gate here would reopen the very bypass this function
|
|
89
|
-
// exists to close: two `waitFor(undefined)` nodes registered cleanly and one
|
|
90
|
-
// `signal(id, undefined)` walked both. Not theoretical either, since
|
|
91
|
-
// `noUncheckedIndexedAccess` is off: `.waitFor(gates.manager)` on a
|
|
92
|
-
// `Record<string, string>` with a missing key types as `string`, is `undefined` at
|
|
93
|
-
// runtime, and compiles. A gate nobody can name is a gate nobody can open, so it
|
|
94
|
-
// is refused outright rather than ignored.
|
|
95
|
-
const bad = unusableEventName(node.event);
|
|
96
|
-
if (bad !== null)
|
|
97
|
-
throw new Error(`Workflow "${wf.name}" has a waitFor gate ${bad}`);
|
|
98
|
-
// Narrowed by the guard above: `unusableEventName` returns null only for a
|
|
99
|
-
// non-empty string.
|
|
100
|
-
const event = node.event;
|
|
101
|
-
if (seen.has(event)) {
|
|
102
|
-
throw new Error(`Workflow "${wf.name}" waits for the event "${event}" more than once. ` +
|
|
103
|
-
`One signal would open every one of those gates, because a delivered signal ` +
|
|
104
|
-
`is never consumed. Give each gate its own event name.`);
|
|
105
|
-
}
|
|
106
|
-
seen.add(event);
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
export function assertNoIndexCollision(wf) {
|
|
110
|
-
const generators = wf.getIndexedStepNames();
|
|
111
|
-
if (generators.length === 0)
|
|
112
|
-
return;
|
|
113
|
-
for (const declared of wf.getStepNames()) {
|
|
114
|
-
for (const base of generators) {
|
|
115
|
-
if (declared !== base && new RegExp(`^${escapeRe(base)}:\\d+$`).test(declared)) {
|
|
116
|
-
throw new Error(`Step "${declared}" in "${wf.name}" collides with the per-iteration names ` +
|
|
117
|
-
`reserved by the loop step "${base}" ("${base}:0", "${base}:1", ...). ` +
|
|
118
|
-
`Rename one of them.`);
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
function escapeRe(value) {
|
|
124
|
-
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
125
|
-
}
|
|
126
|
-
/**
|
|
127
|
-
* `retry` is the number of ATTEMPTS, not the number of extra tries: the retry loop is
|
|
128
|
-
* `for (attempt = 1; attempt <= def.retry; attempt++)`.
|
|
129
|
-
*
|
|
130
|
-
* `retry: 0` therefore never executed the body at all, and the code after the loop read
|
|
131
|
-
* `exec.steps[def.name].startedAt` on a record that was never written, so the run's
|
|
132
|
-
* `failureReason` became `undefined is not an object (...)`: a TypeError where an operator
|
|
133
|
-
* looks for what went wrong. In a RESUMED loop the memo path restores the bare record, so
|
|
134
|
-
* `startedAt` resolves and a `failed` record is written for a handler that was never
|
|
135
|
-
* called, which the unwind then reverses: a rollback of a side effect that never happened
|
|
136
|
-
* (`test/repro-workflow-retry-zero.test.ts`).
|
|
137
|
-
*
|
|
138
|
-
* Refused where it is written rather than coerced to 1. A workflow asking for zero
|
|
139
|
-
* attempts is asking for something the engine cannot do, and quietly running the step
|
|
140
|
-
* once would be a different thing from what was written.
|
|
141
|
-
*/
|
|
142
|
-
function assertUsableRetry(step, retry) {
|
|
143
|
-
if (retry === undefined)
|
|
144
|
-
return;
|
|
145
|
-
if (!Number.isInteger(retry) || retry < 1) {
|
|
146
|
-
throw new Error(`Step "${step}" declares retry: ${retry}. retry is the number of attempts, so it must be an integer of 1 or more (1 means a single attempt with no retry).`);
|
|
147
|
-
}
|
|
148
|
-
}
|
|
8
|
+
import { assertPositiveDuration, assertUsableIterations, assertUsableRetry, assertUsableTimeout, onlySteps, } from './workflowValidation';
|
|
9
|
+
import { sealWorkflowDefinition } from './workflowDefinition';
|
|
10
|
+
import { indexedStepNames, stepNames } from './workflowIntrospection';
|
|
11
|
+
export { assertNoDuplicateWaitFor, assertNoIndexCollision, unusableEventName, } from './workflowValidation';
|
|
149
12
|
export class Workflow {
|
|
150
13
|
name;
|
|
14
|
+
/**
|
|
15
|
+
* Explicit semantic revision. Bump it when handler or condition behavior changes
|
|
16
|
+
* incompatibly without changing the graph shape.
|
|
17
|
+
*/
|
|
18
|
+
revision;
|
|
151
19
|
nodes = [];
|
|
152
|
-
|
|
20
|
+
sealed = false;
|
|
21
|
+
sealedHash;
|
|
22
|
+
constructor(name, options = {}) {
|
|
153
23
|
this.name = name;
|
|
24
|
+
this.revision = String(options.revision ?? '1');
|
|
25
|
+
}
|
|
26
|
+
/** Freeze this definition and return its durable structural identity. */
|
|
27
|
+
seal() {
|
|
28
|
+
if (!this.sealedHash) {
|
|
29
|
+
this.sealedHash = sealWorkflowDefinition(this);
|
|
30
|
+
this.sealed = true;
|
|
31
|
+
}
|
|
32
|
+
return this.sealedHash;
|
|
33
|
+
}
|
|
34
|
+
assertMutable() {
|
|
35
|
+
if (this.sealed) {
|
|
36
|
+
throw new Error(`Workflow "${this.name}" definition is sealed after registration`);
|
|
37
|
+
}
|
|
154
38
|
}
|
|
155
39
|
/** Add a step to the workflow — return type accumulates into TSteps */
|
|
156
40
|
step(name, handler,
|
|
@@ -159,7 +43,9 @@ export class Workflow {
|
|
|
159
43
|
// omit, so `ctx.steps.charge` inside charge's own rollback was a type error while
|
|
160
44
|
// working perfectly at run time.
|
|
161
45
|
options) {
|
|
46
|
+
this.assertMutable();
|
|
162
47
|
assertUsableRetry(name, options?.retry);
|
|
48
|
+
assertUsableTimeout(`Step "${name}"`, options?.timeout);
|
|
163
49
|
this.nodes.push({
|
|
164
50
|
type: 'step',
|
|
165
51
|
def: {
|
|
@@ -176,6 +62,7 @@ export class Workflow {
|
|
|
176
62
|
}
|
|
177
63
|
/** Add a branch point — call .path() after this to define paths */
|
|
178
64
|
branch(condition) {
|
|
65
|
+
this.assertMutable();
|
|
179
66
|
this.nodes.push({
|
|
180
67
|
type: 'branch',
|
|
181
68
|
def: { condition: condition, paths: new Map() },
|
|
@@ -184,10 +71,14 @@ export class Workflow {
|
|
|
184
71
|
}
|
|
185
72
|
/** Define a branch path (must follow a .branch() call) */
|
|
186
73
|
path(name, builder) {
|
|
74
|
+
this.assertMutable();
|
|
187
75
|
const lastNode = this.nodes[this.nodes.length - 1];
|
|
188
76
|
if (lastNode?.type !== 'branch') {
|
|
189
77
|
throw new Error('path() must follow a branch() call');
|
|
190
78
|
}
|
|
79
|
+
if (lastNode.def.paths.has(name)) {
|
|
80
|
+
throw new Error(`Branch path "${name}" is already defined`);
|
|
81
|
+
}
|
|
191
82
|
const sub = new Workflow(`${this.name}:${name}`);
|
|
192
83
|
builder(sub);
|
|
193
84
|
lastNode.def.paths.set(name, onlySteps(sub, 'path()', name));
|
|
@@ -195,6 +86,7 @@ export class Workflow {
|
|
|
195
86
|
}
|
|
196
87
|
/** Run multiple steps in parallel — accumulated types from sub-builder merge into TSteps */
|
|
197
88
|
parallel(builder) {
|
|
89
|
+
this.assertMutable();
|
|
198
90
|
const sub = new Workflow(`${this.name}:parallel`);
|
|
199
91
|
builder(sub);
|
|
200
92
|
const steps = onlySteps(sub, 'parallel()');
|
|
@@ -205,21 +97,30 @@ export class Workflow {
|
|
|
205
97
|
return this;
|
|
206
98
|
}
|
|
207
99
|
/** Call another registered workflow as a step */
|
|
208
|
-
subWorkflow(name, inputMapper) {
|
|
100
|
+
subWorkflow(name, inputMapper, options) {
|
|
101
|
+
this.assertMutable();
|
|
102
|
+
assertPositiveDuration(`Sub-workflow "${name}" timeout`, options?.timeout);
|
|
103
|
+
assertPositiveDuration(`Sub-workflow "${name}" pollInterval`, options?.pollInterval);
|
|
209
104
|
this.nodes.push({
|
|
210
105
|
type: 'subWorkflow',
|
|
211
106
|
name,
|
|
212
107
|
inputMapper: inputMapper,
|
|
108
|
+
timeout: options?.timeout ?? 300_000,
|
|
109
|
+
pollInterval: options?.pollInterval ?? 100,
|
|
213
110
|
});
|
|
214
111
|
return this;
|
|
215
112
|
}
|
|
216
113
|
/** Wait for an external signal before continuing */
|
|
217
114
|
waitFor(event, options) {
|
|
115
|
+
this.assertMutable();
|
|
116
|
+
assertUsableTimeout(`waitFor("${event}")`, options?.timeout);
|
|
218
117
|
this.nodes.push({ type: 'waitFor', event, timeout: options?.timeout });
|
|
219
118
|
return this;
|
|
220
119
|
}
|
|
221
120
|
/** Repeat steps until condition returns true (checked after each iteration) */
|
|
222
121
|
doUntil(condition, builder, options) {
|
|
122
|
+
this.assertMutable();
|
|
123
|
+
assertUsableIterations('doUntil()', options?.maxIterations);
|
|
223
124
|
const sub = new Workflow(`${this.name}:doUntil`);
|
|
224
125
|
builder(sub);
|
|
225
126
|
const steps = onlySteps(sub, 'doUntil()');
|
|
@@ -237,6 +138,8 @@ export class Workflow {
|
|
|
237
138
|
}
|
|
238
139
|
/** Repeat steps while condition returns true (checked before each iteration) */
|
|
239
140
|
doWhile(condition, builder, options) {
|
|
141
|
+
this.assertMutable();
|
|
142
|
+
assertUsableIterations('doWhile()', options?.maxIterations);
|
|
240
143
|
const sub = new Workflow(`${this.name}:doWhile`);
|
|
241
144
|
builder(sub);
|
|
242
145
|
const steps = onlySteps(sub, 'doWhile()');
|
|
@@ -254,10 +157,13 @@ export class Workflow {
|
|
|
254
157
|
}
|
|
255
158
|
/** Iterate over items, executing a step for each */
|
|
256
159
|
forEach(items, name, handler, options) {
|
|
160
|
+
this.assertMutable();
|
|
257
161
|
// `forEach` builds its step definition here rather than going through `step()`, so it
|
|
258
162
|
// needs the same guard: without it, `forEach(..., { retry: 0 })` was accepted and the
|
|
259
163
|
// per-iteration mirror recorded a `failed` record for a handler that never ran.
|
|
260
164
|
assertUsableRetry(name, options?.retry);
|
|
165
|
+
assertUsableTimeout(`forEach step "${name}"`, options?.timeout);
|
|
166
|
+
assertUsableIterations('forEach()', options?.maxIterations);
|
|
261
167
|
const step = {
|
|
262
168
|
name,
|
|
263
169
|
handler: handler,
|
|
@@ -279,6 +185,7 @@ export class Workflow {
|
|
|
279
185
|
}
|
|
280
186
|
/** Transform step results into a new value stored under the given name */
|
|
281
187
|
map(name, transform) {
|
|
188
|
+
this.assertMutable();
|
|
282
189
|
this.nodes.push({
|
|
283
190
|
type: 'map',
|
|
284
191
|
def: { name, transform: transform },
|
|
@@ -294,6 +201,7 @@ export class Workflow {
|
|
|
294
201
|
* for "the welcome email was sent". Declare it explicitly; it is never inferred.
|
|
295
202
|
*/
|
|
296
203
|
pivot() {
|
|
204
|
+
this.assertMutable();
|
|
297
205
|
this.nodes.push({ type: 'pivot' });
|
|
298
206
|
return this;
|
|
299
207
|
}
|
|
@@ -302,44 +210,10 @@ export class Workflow {
|
|
|
302
210
|
* Loop bodies and forEach steps both do; a plain step never does.
|
|
303
211
|
*/
|
|
304
212
|
getIndexedStepNames() {
|
|
305
|
-
|
|
306
|
-
for (const node of this.nodes) {
|
|
307
|
-
if (node.type === 'doUntil' || node.type === 'doWhile') {
|
|
308
|
-
for (const s of node.def.steps)
|
|
309
|
-
names.push(s.name);
|
|
310
|
-
}
|
|
311
|
-
else if (node.type === 'forEach')
|
|
312
|
-
names.push(node.def.step.name);
|
|
313
|
-
}
|
|
314
|
-
return names;
|
|
213
|
+
return indexedStepNames(this.nodes);
|
|
315
214
|
}
|
|
316
215
|
/** Get flat list of step names for validation */
|
|
317
216
|
getStepNames() {
|
|
318
|
-
|
|
319
|
-
for (const node of this.nodes) {
|
|
320
|
-
if (node.type === 'step')
|
|
321
|
-
names.push(node.def.name);
|
|
322
|
-
else if (node.type === 'branch') {
|
|
323
|
-
for (const steps of node.def.paths.values()) {
|
|
324
|
-
for (const s of steps)
|
|
325
|
-
names.push(s.name);
|
|
326
|
-
}
|
|
327
|
-
}
|
|
328
|
-
else if (node.type === 'parallel') {
|
|
329
|
-
for (const s of node.def.steps)
|
|
330
|
-
names.push(s.name);
|
|
331
|
-
}
|
|
332
|
-
else if (node.type === 'subWorkflow')
|
|
333
|
-
names.push(`sub:${node.name}`);
|
|
334
|
-
else if (node.type === 'doUntil' || node.type === 'doWhile') {
|
|
335
|
-
for (const s of node.def.steps)
|
|
336
|
-
names.push(s.name);
|
|
337
|
-
}
|
|
338
|
-
else if (node.type === 'forEach')
|
|
339
|
-
names.push(node.def.step.name);
|
|
340
|
-
else if (node.type === 'map')
|
|
341
|
-
names.push(node.def.name);
|
|
342
|
-
}
|
|
343
|
-
return names;
|
|
217
|
+
return stepNames(this.nodes);
|
|
344
218
|
}
|
|
345
219
|
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** Durable control-flow decision journal. */
|
|
2
|
+
import type { Execution } from './types';
|
|
3
|
+
/**
|
|
4
|
+
* Return an already persisted decision, or evaluate and persist it before the caller
|
|
5
|
+
* performs any effects selected by that decision.
|
|
6
|
+
*/
|
|
7
|
+
export declare function resolveDecision<T>(exec: Execution, key: string, evaluate: () => T | Promise<T>, updateFn: (exec: Execution) => void): Promise<T>;
|
|
8
|
+
export declare function branchDecisionKey(nodeIndex: number): string;
|
|
9
|
+
export declare function forEachItemsDecisionKey(stepName: string): string;
|
|
10
|
+
export declare function loopDecisionKey(kind: 'doUntil' | 'doWhile', firstStepName: string, iteration: number): string;
|
|
11
|
+
export declare function subWorkflowInputDecisionKey(nodeIndex: number): string;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/** Durable control-flow decision journal. */
|
|
2
|
+
const hasOwn = (value, key) => Object.hasOwn(value, key);
|
|
3
|
+
/**
|
|
4
|
+
* Return an already persisted decision, or evaluate and persist it before the caller
|
|
5
|
+
* performs any effects selected by that decision.
|
|
6
|
+
*/
|
|
7
|
+
export async function resolveDecision(exec, key, evaluate, updateFn) {
|
|
8
|
+
const decisions = (exec.decisions ??= {});
|
|
9
|
+
if (hasOwn(decisions, key))
|
|
10
|
+
return decisions[key];
|
|
11
|
+
const value = await evaluate();
|
|
12
|
+
decisions[key] = value;
|
|
13
|
+
updateFn(exec);
|
|
14
|
+
return value;
|
|
15
|
+
}
|
|
16
|
+
export function branchDecisionKey(nodeIndex) {
|
|
17
|
+
return `branch:${nodeIndex}`;
|
|
18
|
+
}
|
|
19
|
+
export function forEachItemsDecisionKey(stepName) {
|
|
20
|
+
return `forEach:${stepName}:items`;
|
|
21
|
+
}
|
|
22
|
+
export function loopDecisionKey(kind, firstStepName, iteration) {
|
|
23
|
+
return `${kind}:${firstStepName}:${iteration}`;
|
|
24
|
+
}
|
|
25
|
+
export function subWorkflowInputDecisionKey(nodeIndex) {
|
|
26
|
+
return `subWorkflow:${nodeIndex}:input`;
|
|
27
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stable workflow-definition identity and sealing.
|
|
3
|
+
*
|
|
4
|
+
* Executions persist the hash produced here. It describes the durable graph, while
|
|
5
|
+
* `revision` lets callers explicitly mark handler/condition changes that cannot be
|
|
6
|
+
* inferred safely from closures.
|
|
7
|
+
*/
|
|
8
|
+
import type { WorkflowNode } from './types';
|
|
9
|
+
interface WorkflowDefinitionSource {
|
|
10
|
+
readonly name: string;
|
|
11
|
+
readonly revision: string;
|
|
12
|
+
readonly nodes: WorkflowNode[];
|
|
13
|
+
}
|
|
14
|
+
export declare function workflowDefinitionHash(workflow: WorkflowDefinitionSource): string;
|
|
15
|
+
export declare function sealWorkflowDefinition(workflow: WorkflowDefinitionSource): string;
|
|
16
|
+
export {};
|