bunqueue 2.8.46 → 2.8.47
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/dist/client/workflow/admission.d.ts +45 -0
- package/dist/client/workflow/admission.js +50 -0
- package/dist/client/workflow/clock.d.ts +58 -0
- package/dist/client/workflow/clock.js +104 -0
- package/dist/client/workflow/compensator.d.ts +56 -3
- package/dist/client/workflow/compensator.js +406 -27
- package/dist/client/workflow/emitter.d.ts +1 -1
- package/dist/client/workflow/emitter.js +4 -3
- package/dist/client/workflow/engine.d.ts +17 -0
- package/dist/client/workflow/engine.js +25 -0
- package/dist/client/workflow/executor.d.ts +40 -5
- package/dist/client/workflow/executor.js +227 -83
- package/dist/client/workflow/identity.d.ts +46 -0
- package/dist/client/workflow/identity.js +93 -0
- package/dist/client/workflow/index.d.ts +1 -1
- package/dist/client/workflow/loops.js +147 -14
- package/dist/client/workflow/recovery.d.ts +10 -1
- package/dist/client/workflow/recovery.js +36 -9
- package/dist/client/workflow/rollbackControl.d.ts +36 -0
- package/dist/client/workflow/rollbackControl.js +51 -0
- package/dist/client/workflow/runner.d.ts +22 -2
- package/dist/client/workflow/runner.js +125 -27
- package/dist/client/workflow/store.d.ts +64 -4
- package/dist/client/workflow/store.js +123 -30
- package/dist/client/workflow/storeCodec.d.ts +7 -0
- package/dist/client/workflow/storeCodec.js +16 -0
- package/dist/client/workflow/storeSignals.d.ts +61 -0
- package/dist/client/workflow/storeSignals.js +118 -0
- package/dist/client/workflow/types.d.ts +147 -4
- package/dist/client/workflow/unwindPlan.d.ts +87 -0
- package/dist/client/workflow/unwindPlan.js +142 -0
- package/dist/client/workflow/waitFor.d.ts +52 -0
- package/dist/client/workflow/waitFor.js +137 -0
- package/dist/client/workflow/workflow.d.ts +71 -1
- package/dist/client/workflow/workflow.js +184 -14
- package/package.json +16 -6
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* waitFor node execution — parking a run for a human/external signal, and the
|
|
3
|
+
* timeout timers that bound the wait.
|
|
4
|
+
*
|
|
5
|
+
* Split out of the executor because parking is the one node type with a genuinely
|
|
6
|
+
* concurrent counterpart: `signal()` mutates the same row from outside the worker,
|
|
7
|
+
* so every transition here has to be expressed as a claim against the store rather
|
|
8
|
+
* than an in-memory state change. See storeSignals.ts for the ownership rules.
|
|
9
|
+
*/
|
|
10
|
+
import { WaitForSignalError } from './compensator';
|
|
11
|
+
import { hasSignal } from './storeSignals';
|
|
12
|
+
import { clock } from './clock';
|
|
13
|
+
/** Largest delay setTimeout accepts before wrapping (2**31-1 ms, ~24.8 days) */
|
|
14
|
+
export const MAX_TIMER_MS = 2_147_483_647;
|
|
15
|
+
/**
|
|
16
|
+
* Arm the timer that re-enters a parked node once its wait budget elapses.
|
|
17
|
+
*
|
|
18
|
+
* setTimeout takes a 32-bit signed delay; anything larger wraps to 1ms and fires
|
|
19
|
+
* immediately (`TimeoutOverflowWarning`). Clamping and letting the re-check job
|
|
20
|
+
* re-arm for whatever remains is what makes multi-week approval windows survive
|
|
21
|
+
* (test/repro-workflow-timeout-overflow.test.ts).
|
|
22
|
+
*/
|
|
23
|
+
export function scheduleTimeoutCheck(deps, execId, workflowName, nodeIdx, ms) {
|
|
24
|
+
const delay = Math.min(Math.max(ms, 0), MAX_TIMER_MS);
|
|
25
|
+
// Replacing a live timer for the same execution — re-entering a waitFor node used
|
|
26
|
+
// to leak the previous one, which then fired against a node the run had left.
|
|
27
|
+
const previous = deps.timers.get(execId);
|
|
28
|
+
if (previous)
|
|
29
|
+
clock().clearTimeout(previous);
|
|
30
|
+
const timer = clock().setTimeout(() => {
|
|
31
|
+
deps.timers.delete(execId);
|
|
32
|
+
const jobData = { executionId: execId, workflowName, nodeIndex: nodeIdx };
|
|
33
|
+
deps.queue.add('wf:step', jobData).catch(() => {
|
|
34
|
+
/* Queue may be closed */
|
|
35
|
+
});
|
|
36
|
+
}, delay);
|
|
37
|
+
// A parked approval gate is a NORMAL steady state, and the clamp above makes this
|
|
38
|
+
// timer up to ~24.8 days long. Without unref a process whose only remaining work is
|
|
39
|
+
// a parked run never exits, even after close(): the event loop stays alive holding a
|
|
40
|
+
// timer for a signal that may never come. unref keeps the timer fully functional
|
|
41
|
+
// while the process has other work, and stops it from being the reason to stay up.
|
|
42
|
+
// `clearTimers()` covers the explicit-shutdown path. This does NOT, on its own, let
|
|
43
|
+
// a parked engine's process exit: measured, a child that omits `close()` hangs with
|
|
44
|
+
// or without this line, because the embedded queue and worker pin the process
|
|
45
|
+
// independently. It is defence in depth for the timer itself, no more, and the
|
|
46
|
+
// earlier version of this comment claimed the stronger property.
|
|
47
|
+
timer.unref?.();
|
|
48
|
+
deps.timers.set(execId, timer);
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Cancel every armed wait timer. Called on engine shutdown: `unref` alone lets a
|
|
52
|
+
* process exit, but a still-armed timer can also fire into a queue that is closing,
|
|
53
|
+
* and a caller that shuts one engine down while keeping the process alive has no
|
|
54
|
+
* other way to release them.
|
|
55
|
+
*/
|
|
56
|
+
export function clearTimers(timers) {
|
|
57
|
+
for (const timer of timers.values())
|
|
58
|
+
clock().clearTimeout(timer);
|
|
59
|
+
timers.clear();
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Execute a `waitFor` node: advance if the signal is already there, fail if the wait
|
|
63
|
+
* has expired, otherwise park the run and throw the sentinel so processStep
|
|
64
|
+
* short-circuits without treating the pause as an error.
|
|
65
|
+
*/
|
|
66
|
+
export async function runWaitFor(deps, exec, node, idx, wf) {
|
|
67
|
+
if (hasSignal(exec.signals, node.event)) {
|
|
68
|
+
await deps.advance(exec, idx + 1, wf);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
const waitKey = `__waitFor:${node.event}`;
|
|
72
|
+
let remaining = 0;
|
|
73
|
+
if (node.timeout !== undefined) {
|
|
74
|
+
const existing = exec.steps[waitKey];
|
|
75
|
+
const waitingSince = existing?.startedAt ?? clock().now();
|
|
76
|
+
if (!existing)
|
|
77
|
+
exec.steps[waitKey] = { status: 'running', startedAt: waitingSince };
|
|
78
|
+
if (clock().now() - waitingSince >= node.timeout) {
|
|
79
|
+
// Re-read before failing: a signal delivered while this node was executing is
|
|
80
|
+
// not in our snapshot, and expiring an already-approved run would compensate
|
|
81
|
+
// (refund, release stock) work the approver just authorised.
|
|
82
|
+
const fresh = deps.store.get(exec.id);
|
|
83
|
+
if (fresh && hasSignal(fresh.signals, node.event)) {
|
|
84
|
+
exec.signals = fresh.signals;
|
|
85
|
+
await deps.advance(exec, idx + 1, wf);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
deps.emitter?.emitSignal('signal:timeout', exec.id, exec.workflowName, node.event);
|
|
89
|
+
exec.steps[waitKey] = {
|
|
90
|
+
status: 'failed',
|
|
91
|
+
startedAt: waitingSince,
|
|
92
|
+
completedAt: clock().now(),
|
|
93
|
+
error: `Signal "${node.event}" timed out after ${node.timeout}ms`,
|
|
94
|
+
};
|
|
95
|
+
exec.state = 'failed';
|
|
96
|
+
// Unguarded on purpose, same reasoning as the failure write in `executor.ts`. Disk
|
|
97
|
+
// still says `waiting`, which `listRecoverable()` covers, so `recoverWaiting`
|
|
98
|
+
// recomputes the remaining time, re-enqueues, and the gate times out again rather
|
|
99
|
+
// than leaving a run that looks settled and was never unwound.
|
|
100
|
+
//
|
|
101
|
+
// A throw here skips the compensate CALL below but not the compensation: it
|
|
102
|
+
// propagates into `runNode`'s catch, which compensates anyway. That is the generic
|
|
103
|
+
// path the `WaitForSignalError` sentinel below exists to avoid on the normal route,
|
|
104
|
+
// so the outcome is a redundant route, not a missing rollback.
|
|
105
|
+
deps.store.update(exec);
|
|
106
|
+
// Compensate here, then signal completion via the WaitForSignalError sentinel
|
|
107
|
+
// so processStep short-circuits (return null) instead of re-running
|
|
108
|
+
// compensation through its generic catch path.
|
|
109
|
+
await deps.compensate(exec, wf);
|
|
110
|
+
deps.emitter?.emitWorkflow('workflow:failed', exec.id, exec.workflowName, 'failed');
|
|
111
|
+
throw new WaitForSignalError(node.event);
|
|
112
|
+
}
|
|
113
|
+
deps.store.update(exec);
|
|
114
|
+
remaining = node.timeout - (clock().now() - waitingSince);
|
|
115
|
+
}
|
|
116
|
+
// Park transactionally. Between the in-memory check above and this point a signal
|
|
117
|
+
// can land: it would be recorded with no parked run left to claim the resume,
|
|
118
|
+
// hanging the execution forever. parkForSignal() re-reads the persisted signals
|
|
119
|
+
// and only transitions 'running' -> 'waiting' when none has arrived.
|
|
120
|
+
const park = deps.store.parkForSignal(exec.id, node.event);
|
|
121
|
+
if (park.signalPresent) {
|
|
122
|
+
exec.signals = park.signals;
|
|
123
|
+
await deps.advance(exec, idx + 1, wf);
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
if (!park.parked) {
|
|
127
|
+
// Another job already moved this execution off 'running'; don't advance or emit
|
|
128
|
+
// a second waiting event for the same node.
|
|
129
|
+
throw new WaitForSignalError(node.event);
|
|
130
|
+
}
|
|
131
|
+
exec.state = 'waiting';
|
|
132
|
+
if (node.timeout !== undefined) {
|
|
133
|
+
deps.scheduleTimeoutCheck(exec.id, exec.workflowName, idx, remaining);
|
|
134
|
+
}
|
|
135
|
+
deps.emitter?.emitWorkflow('workflow:waiting', exec.id, exec.workflowName, 'waiting');
|
|
136
|
+
throw new WaitForSignalError(node.event);
|
|
137
|
+
}
|
|
@@ -6,12 +6,68 @@
|
|
|
6
6
|
* so subsequent steps can access previous results without casting.
|
|
7
7
|
*/
|
|
8
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;
|
|
9
65
|
export declare class Workflow<TInput = unknown, TSteps extends Record<string, unknown> = Record<string, unknown>> {
|
|
10
66
|
readonly name: string;
|
|
11
67
|
readonly nodes: WorkflowNode[];
|
|
12
68
|
constructor(name: string);
|
|
13
69
|
/** Add a step to the workflow — return type accumulates into TSteps */
|
|
14
|
-
step<TName extends string, TResult>(name: TName, handler: TypedStepHandler<TInput, TSteps, TResult>, options?: StepOptions<TInput, TSteps
|
|
70
|
+
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>>>;
|
|
15
71
|
/** Add a branch point — call .path() after this to define paths */
|
|
16
72
|
branch(condition: BranchCondition<TInput, TSteps>): this;
|
|
17
73
|
/** Define a branch path (must follow a .branch() call) */
|
|
@@ -38,6 +94,20 @@ export declare class Workflow<TInput = unknown, TSteps extends Record<string, un
|
|
|
38
94
|
}): Workflow<TInput, TSteps & Record<TName, Awaited<TResult>>>;
|
|
39
95
|
/** Transform step results into a new value stored under the given name */
|
|
40
96
|
map<TName extends string, TResult>(name: TName, transform: (ctx: StepContext<TInput, TSteps>) => TResult): Workflow<TInput, TSteps & Record<TName, Awaited<TResult>>>;
|
|
97
|
+
/**
|
|
98
|
+
* Mark the point of no return.
|
|
99
|
+
*
|
|
100
|
+
* Everything before it stays compensatable; everything after is committed and is
|
|
101
|
+
* never rolled back, however the run ends. Past the pivot the only correct
|
|
102
|
+
* recovery is forward — retry, alert, fix — because there is no semantic inverse
|
|
103
|
+
* for "the welcome email was sent". Declare it explicitly; it is never inferred.
|
|
104
|
+
*/
|
|
105
|
+
pivot(): this;
|
|
106
|
+
/**
|
|
107
|
+
* Step names that generate indexed per-iteration records (`name:0`, `name:1`, ...).
|
|
108
|
+
* Loop bodies and forEach steps both do; a plain step never does.
|
|
109
|
+
*/
|
|
110
|
+
getIndexedStepNames(): string[];
|
|
41
111
|
/** Get flat list of step names for validation */
|
|
42
112
|
getStepNames(): string[];
|
|
43
113
|
}
|
|
@@ -5,6 +5,147 @@
|
|
|
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
|
+
* Extract the step definitions from a sub-builder, refusing anything else.
|
|
10
|
+
*
|
|
11
|
+
* Branch paths, parallel groups and loop bodies all execute inline inside a single
|
|
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
149
|
export class Workflow {
|
|
9
150
|
name;
|
|
10
151
|
nodes = [];
|
|
@@ -12,7 +153,13 @@ export class Workflow {
|
|
|
12
153
|
this.name = name;
|
|
13
154
|
}
|
|
14
155
|
/** Add a step to the workflow — return type accumulates into TSteps */
|
|
15
|
-
step(name, handler,
|
|
156
|
+
step(name, handler,
|
|
157
|
+
// The compensate handler in `options` sees this step's OWN result too, which the
|
|
158
|
+
// runtime already provides (compensationContext binds it) but the type used to
|
|
159
|
+
// omit, so `ctx.steps.charge` inside charge's own rollback was a type error while
|
|
160
|
+
// working perfectly at run time.
|
|
161
|
+
options) {
|
|
162
|
+
assertUsableRetry(name, options?.retry);
|
|
16
163
|
this.nodes.push({
|
|
17
164
|
type: 'step',
|
|
18
165
|
def: {
|
|
@@ -43,19 +190,14 @@ export class Workflow {
|
|
|
43
190
|
}
|
|
44
191
|
const sub = new Workflow(`${this.name}:${name}`);
|
|
45
192
|
builder(sub);
|
|
46
|
-
|
|
47
|
-
.filter((n) => n.type === 'step')
|
|
48
|
-
.map((n) => n.def);
|
|
49
|
-
lastNode.def.paths.set(name, steps);
|
|
193
|
+
lastNode.def.paths.set(name, onlySteps(sub, 'path()', name));
|
|
50
194
|
return this;
|
|
51
195
|
}
|
|
52
196
|
/** Run multiple steps in parallel — accumulated types from sub-builder merge into TSteps */
|
|
53
197
|
parallel(builder) {
|
|
54
198
|
const sub = new Workflow(`${this.name}:parallel`);
|
|
55
199
|
builder(sub);
|
|
56
|
-
const steps = sub
|
|
57
|
-
.filter((n) => n.type === 'step')
|
|
58
|
-
.map((n) => n.def);
|
|
200
|
+
const steps = onlySteps(sub, 'parallel()');
|
|
59
201
|
if (steps.length === 0) {
|
|
60
202
|
throw new Error('parallel() requires at least one step');
|
|
61
203
|
}
|
|
@@ -80,9 +222,7 @@ export class Workflow {
|
|
|
80
222
|
doUntil(condition, builder, options) {
|
|
81
223
|
const sub = new Workflow(`${this.name}:doUntil`);
|
|
82
224
|
builder(sub);
|
|
83
|
-
const steps = sub
|
|
84
|
-
.filter((n) => n.type === 'step')
|
|
85
|
-
.map((n) => n.def);
|
|
225
|
+
const steps = onlySteps(sub, 'doUntil()');
|
|
86
226
|
if (steps.length === 0)
|
|
87
227
|
throw new Error('doUntil() requires at least one step');
|
|
88
228
|
this.nodes.push({
|
|
@@ -99,9 +239,7 @@ export class Workflow {
|
|
|
99
239
|
doWhile(condition, builder, options) {
|
|
100
240
|
const sub = new Workflow(`${this.name}:doWhile`);
|
|
101
241
|
builder(sub);
|
|
102
|
-
const steps = sub
|
|
103
|
-
.filter((n) => n.type === 'step')
|
|
104
|
-
.map((n) => n.def);
|
|
242
|
+
const steps = onlySteps(sub, 'doWhile()');
|
|
105
243
|
if (steps.length === 0)
|
|
106
244
|
throw new Error('doWhile() requires at least one step');
|
|
107
245
|
this.nodes.push({
|
|
@@ -116,6 +254,10 @@ export class Workflow {
|
|
|
116
254
|
}
|
|
117
255
|
/** Iterate over items, executing a step for each */
|
|
118
256
|
forEach(items, name, handler, options) {
|
|
257
|
+
// `forEach` builds its step definition here rather than going through `step()`, so it
|
|
258
|
+
// needs the same guard: without it, `forEach(..., { retry: 0 })` was accepted and the
|
|
259
|
+
// per-iteration mirror recorded a `failed` record for a handler that never ran.
|
|
260
|
+
assertUsableRetry(name, options?.retry);
|
|
119
261
|
const step = {
|
|
120
262
|
name,
|
|
121
263
|
handler: handler,
|
|
@@ -143,6 +285,34 @@ export class Workflow {
|
|
|
143
285
|
});
|
|
144
286
|
return this;
|
|
145
287
|
}
|
|
288
|
+
/**
|
|
289
|
+
* Mark the point of no return.
|
|
290
|
+
*
|
|
291
|
+
* Everything before it stays compensatable; everything after is committed and is
|
|
292
|
+
* never rolled back, however the run ends. Past the pivot the only correct
|
|
293
|
+
* recovery is forward — retry, alert, fix — because there is no semantic inverse
|
|
294
|
+
* for "the welcome email was sent". Declare it explicitly; it is never inferred.
|
|
295
|
+
*/
|
|
296
|
+
pivot() {
|
|
297
|
+
this.nodes.push({ type: 'pivot' });
|
|
298
|
+
return this;
|
|
299
|
+
}
|
|
300
|
+
/**
|
|
301
|
+
* Step names that generate indexed per-iteration records (`name:0`, `name:1`, ...).
|
|
302
|
+
* Loop bodies and forEach steps both do; a plain step never does.
|
|
303
|
+
*/
|
|
304
|
+
getIndexedStepNames() {
|
|
305
|
+
const names = [];
|
|
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;
|
|
315
|
+
}
|
|
146
316
|
/** Get flat list of step names for validation */
|
|
147
317
|
getStepNames() {
|
|
148
318
|
const names = [];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bunqueue",
|
|
3
|
-
"version": "2.8.
|
|
3
|
+
"version": "2.8.47",
|
|
4
4
|
"description": "High-performance job queue for Bun & AI agents. SQLite persistence, cron scheduling, priorities, retries, DLQ, webhooks, native MCP server. Zero external infrastructure.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/main.js",
|
|
@@ -56,14 +56,16 @@
|
|
|
56
56
|
"start": "bun run src/main.ts",
|
|
57
57
|
"build": "bun build --compile --minify src/main.ts --outfile dist/bunqueue",
|
|
58
58
|
"build:lib": "tsc -p tsconfig.build.json",
|
|
59
|
+
"docs:api": "bun run scripts/build-api-reference.ts",
|
|
59
60
|
"test": "BUNQUEUE_EMBEDDED=1 bun test",
|
|
60
|
-
"test:model": "BUNQUEUE_EMBEDDED=1 bun test test/model-based/queue-model.test.ts test/model-based/backup-model.test.ts test/model-based/monitoring-model.test.ts test/model-based/enterprise-telemetry-model.test.ts",
|
|
61
|
+
"test:model": "BUNQUEUE_EMBEDDED=1 bun test test/model-based/queue-model.test.ts test/model-based/workflow-model.test.ts test/model-based/backup-model.test.ts test/model-based/monitoring-model.test.ts test/model-based/enterprise-telemetry-model.test.ts",
|
|
61
62
|
"test:sandbox": "bun run scripts/test-sandbox.ts",
|
|
62
63
|
"test:sandbox:sdk": "bun run scripts/test-sdk-sandbox.ts",
|
|
63
|
-
"bench": "bun run bench/
|
|
64
|
-
"bench:
|
|
65
|
-
"bench:
|
|
66
|
-
"bench:
|
|
64
|
+
"bench": "bun run bench/comprehensive.ts",
|
|
65
|
+
"bench:tcp": "bun run bench/tcp-bench.ts",
|
|
66
|
+
"bench:autobatch": "bun run bench/local-autobatch.ts",
|
|
67
|
+
"bench:pushbulk": "bun run bench/pushbulk-delta.ts",
|
|
68
|
+
"bench:joblist": "bun run bench/job-list-perf.ts",
|
|
67
69
|
"bench:compare": "bun run bench/comparison/run.ts",
|
|
68
70
|
"lint": "biome lint src",
|
|
69
71
|
"lint:fix": "biome lint --write src",
|
|
@@ -79,13 +81,21 @@
|
|
|
79
81
|
"msgpackr": "^1.11.8"
|
|
80
82
|
},
|
|
81
83
|
"devDependencies": {
|
|
84
|
+
"@ai-sdk/anthropic": "^4.0.22",
|
|
85
|
+
"@anthropic-ai/claude-agent-sdk": "^0.3.220",
|
|
82
86
|
"@biomejs/biome": "2.5.1",
|
|
87
|
+
"@langchain/core": "^1.2.3",
|
|
88
|
+
"@langchain/langgraph": "^1.4.8",
|
|
89
|
+
"@mastra/core": "^1.53.0",
|
|
83
90
|
"@modelcontextprotocol/sdk": "^1.26.0",
|
|
91
|
+
"@openai/agents": "^0.13.5",
|
|
84
92
|
"@types/bun": "^1.3.9",
|
|
93
|
+
"ai": "^7.0.38",
|
|
85
94
|
"bullmq": "^5.79.3",
|
|
86
95
|
"elysia": "^1.4.25",
|
|
87
96
|
"fast-check": "^4.9.0",
|
|
88
97
|
"ioredis": "^5.11.1",
|
|
98
|
+
"typedoc": "^0.28.20",
|
|
89
99
|
"typescript": "^5.9.3",
|
|
90
100
|
"zod": "^4.3.6"
|
|
91
101
|
},
|