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.
Files changed (36) hide show
  1. package/dist/client/workflow/admission.d.ts +45 -0
  2. package/dist/client/workflow/admission.js +50 -0
  3. package/dist/client/workflow/clock.d.ts +58 -0
  4. package/dist/client/workflow/clock.js +104 -0
  5. package/dist/client/workflow/compensator.d.ts +56 -3
  6. package/dist/client/workflow/compensator.js +406 -27
  7. package/dist/client/workflow/emitter.d.ts +1 -1
  8. package/dist/client/workflow/emitter.js +4 -3
  9. package/dist/client/workflow/engine.d.ts +17 -0
  10. package/dist/client/workflow/engine.js +25 -0
  11. package/dist/client/workflow/executor.d.ts +40 -5
  12. package/dist/client/workflow/executor.js +227 -83
  13. package/dist/client/workflow/identity.d.ts +46 -0
  14. package/dist/client/workflow/identity.js +93 -0
  15. package/dist/client/workflow/index.d.ts +1 -1
  16. package/dist/client/workflow/loops.js +147 -14
  17. package/dist/client/workflow/recovery.d.ts +10 -1
  18. package/dist/client/workflow/recovery.js +36 -9
  19. package/dist/client/workflow/rollbackControl.d.ts +36 -0
  20. package/dist/client/workflow/rollbackControl.js +51 -0
  21. package/dist/client/workflow/runner.d.ts +22 -2
  22. package/dist/client/workflow/runner.js +125 -27
  23. package/dist/client/workflow/store.d.ts +64 -4
  24. package/dist/client/workflow/store.js +123 -30
  25. package/dist/client/workflow/storeCodec.d.ts +7 -0
  26. package/dist/client/workflow/storeCodec.js +16 -0
  27. package/dist/client/workflow/storeSignals.d.ts +61 -0
  28. package/dist/client/workflow/storeSignals.js +118 -0
  29. package/dist/client/workflow/types.d.ts +147 -4
  30. package/dist/client/workflow/unwindPlan.d.ts +87 -0
  31. package/dist/client/workflow/unwindPlan.js +142 -0
  32. package/dist/client/workflow/waitFor.d.ts +52 -0
  33. package/dist/client/workflow/waitFor.js +137 -0
  34. package/dist/client/workflow/workflow.d.ts +71 -1
  35. package/dist/client/workflow/workflow.js +184 -14
  36. 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>): Workflow<TInput, TSteps & Record<TName, Awaited<TResult>>>;
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, options) {
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
- const steps = sub.nodes
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.nodes
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.nodes
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.nodes
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.46",
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/throughput.ts && bun run bench/latency.ts",
64
- "bench:throughput": "bun run bench/throughput.ts",
65
- "bench:latency": "bun run bench/latency.ts",
66
- "bench:internal": "bun run benchmarks/index.ts",
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
  },