bunqueue 2.8.48 → 2.8.50

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (129) hide show
  1. package/README.md +19 -9
  2. package/dist/application/contextFactory.d.ts +4 -2
  3. package/dist/application/contextFactory.js +3 -0
  4. package/dist/application/flowFailureRecovery.d.ts +17 -0
  5. package/dist/application/flowFailureRecovery.js +92 -0
  6. package/dist/application/operations/ack.d.ts +3 -2
  7. package/dist/application/operations/ack.js +34 -8
  8. package/dist/application/operations/flowPush.d.ts +9 -0
  9. package/dist/application/operations/flowPush.js +112 -0
  10. package/dist/application/operations/flowTopologyValidation.d.ts +3 -0
  11. package/dist/application/operations/flowTopologyValidation.js +101 -0
  12. package/dist/application/operations/flowValidation.d.ts +3 -0
  13. package/dist/application/operations/flowValidation.js +166 -0
  14. package/dist/application/operations/push.d.ts +3 -0
  15. package/dist/application/operations/push.js +2 -2
  16. package/dist/application/operations/pushInsert.d.ts +7 -1
  17. package/dist/application/operations/pushInsert.js +18 -8
  18. package/dist/application/operations/pushLocks.d.ts +1 -0
  19. package/dist/application/operations/pushLocks.js +5 -1
  20. package/dist/application/operations/queryOperations.js +7 -6
  21. package/dist/application/queueManager.d.ts +5 -0
  22. package/dist/application/queueManager.js +195 -60
  23. package/dist/cli/commandRouter.d.ts +1 -1
  24. package/dist/client/flow.d.ts +3 -6
  25. package/dist/client/flow.js +71 -210
  26. package/dist/client/flowAtomic.d.ts +9 -0
  27. package/dist/client/flowAtomic.js +24 -0
  28. package/dist/client/flowJobCoreMethods.d.ts +28 -0
  29. package/dist/client/flowJobCoreMethods.js +151 -0
  30. package/dist/client/flowJobDependencies.d.ts +7 -0
  31. package/dist/client/flowJobDependencies.js +64 -0
  32. package/dist/client/flowJobFactory.d.ts +11 -24
  33. package/dist/client/flowJobFactory.js +91 -360
  34. package/dist/client/flowJobMoveMethods.d.ts +16 -0
  35. package/dist/client/flowJobMoveMethods.js +112 -0
  36. package/dist/client/flowJobTypes.d.ts +29 -0
  37. package/dist/client/flowJobTypes.js +6 -0
  38. package/dist/client/flowLegacyPlan.d.ts +16 -0
  39. package/dist/client/flowLegacyPlan.js +118 -0
  40. package/dist/client/flowOptions.d.ts +10 -0
  41. package/dist/client/flowOptions.js +44 -0
  42. package/dist/client/flowPlan.d.ts +16 -0
  43. package/dist/client/flowPlan.js +93 -0
  44. package/dist/client/flowPush.js +4 -1
  45. package/dist/client/flowReader.d.ts +10 -0
  46. package/dist/client/flowReader.js +147 -0
  47. package/dist/client/jobHelpers.js +2 -0
  48. package/dist/client/workflow/clock.d.ts +3 -1
  49. package/dist/client/workflow/clock.js +18 -1
  50. package/dist/client/workflow/compensationChild.d.ts +30 -0
  51. package/dist/client/workflow/compensationChild.js +65 -0
  52. package/dist/client/workflow/compensationPass.d.ts +16 -0
  53. package/dist/client/workflow/compensationPass.js +96 -0
  54. package/dist/client/workflow/compensationSupport.d.ts +24 -0
  55. package/dist/client/workflow/compensationSupport.js +57 -0
  56. package/dist/client/workflow/compensator.d.ts +11 -50
  57. package/dist/client/workflow/compensator.js +22 -374
  58. package/dist/client/workflow/definitionGuard.d.ts +11 -0
  59. package/dist/client/workflow/definitionGuard.js +22 -0
  60. package/dist/client/workflow/engine.d.ts +2 -2
  61. package/dist/client/workflow/engine.js +8 -8
  62. package/dist/client/workflow/eventTypes.d.ts +44 -0
  63. package/dist/client/workflow/eventTypes.js +2 -0
  64. package/dist/client/workflow/executionTypes.d.ts +133 -0
  65. package/dist/client/workflow/executionTypes.js +2 -0
  66. package/dist/client/workflow/executor.d.ts +4 -17
  67. package/dist/client/workflow/executor.js +50 -208
  68. package/dist/client/workflow/executorLifecycle.d.ts +18 -0
  69. package/dist/client/workflow/executorLifecycle.js +69 -0
  70. package/dist/client/workflow/executorNodes.d.ts +17 -0
  71. package/dist/client/workflow/executorNodes.js +138 -0
  72. package/dist/client/workflow/identity.d.ts +2 -0
  73. package/dist/client/workflow/identity.js +8 -0
  74. package/dist/client/workflow/index.d.ts +1 -1
  75. package/dist/client/workflow/loops.d.ts +3 -5
  76. package/dist/client/workflow/loops.js +25 -23
  77. package/dist/client/workflow/mapRunner.d.ts +4 -0
  78. package/dist/client/workflow/mapRunner.js +45 -0
  79. package/dist/client/workflow/recovery.js +10 -3
  80. package/dist/client/workflow/runner.d.ts +2 -21
  81. package/dist/client/workflow/runner.js +39 -104
  82. package/dist/client/workflow/runnerTiming.d.ts +14 -0
  83. package/dist/client/workflow/runnerTiming.js +66 -0
  84. package/dist/client/workflow/stepTypes.d.ts +171 -0
  85. package/dist/client/workflow/stepTypes.js +3 -0
  86. package/dist/client/workflow/store.d.ts +17 -17
  87. package/dist/client/workflow/store.js +65 -100
  88. package/dist/client/workflow/storeExecutionCodec.d.ts +11 -0
  89. package/dist/client/workflow/storeExecutionCodec.js +34 -0
  90. package/dist/client/workflow/storeListing.d.ts +11 -0
  91. package/dist/client/workflow/storeListing.js +45 -0
  92. package/dist/client/workflow/storeMaintenance.d.ts +4 -0
  93. package/dist/client/workflow/storeMaintenance.js +40 -0
  94. package/dist/client/workflow/storeSignals.d.ts +9 -0
  95. package/dist/client/workflow/storeSignals.js +38 -2
  96. package/dist/client/workflow/subWorkflowRunner.d.ts +13 -0
  97. package/dist/client/workflow/subWorkflowRunner.js +40 -0
  98. package/dist/client/workflow/types.d.ts +4 -356
  99. package/dist/client/workflow/types.js +1 -3
  100. package/dist/client/workflow/waitFor.js +39 -26
  101. package/dist/client/workflow/workflow.d.ts +16 -59
  102. package/dist/client/workflow/workflow.js +53 -179
  103. package/dist/client/workflow/workflowDecisions.d.ts +11 -0
  104. package/dist/client/workflow/workflowDecisions.js +27 -0
  105. package/dist/client/workflow/workflowDefinition.d.ts +16 -0
  106. package/dist/client/workflow/workflowDefinition.js +123 -0
  107. package/dist/client/workflow/workflowIntrospection.d.ts +5 -0
  108. package/dist/client/workflow/workflowIntrospection.js +46 -0
  109. package/dist/client/workflow/workflowValidation.d.ts +44 -0
  110. package/dist/client/workflow/workflowValidation.js +143 -0
  111. package/dist/domain/types/command.d.ts +7 -1
  112. package/dist/domain/types/flow.d.ts +25 -0
  113. package/dist/domain/types/flow.js +1 -0
  114. package/dist/infrastructure/persistence/schema.d.ts +2 -2
  115. package/dist/infrastructure/persistence/schema.js +52 -3
  116. package/dist/infrastructure/persistence/sqlite.d.ts +16 -0
  117. package/dist/infrastructure/persistence/sqlite.js +129 -7
  118. package/dist/infrastructure/persistence/sqliteBatch.js +10 -4
  119. package/dist/infrastructure/persistence/sqliteSerializer.d.ts +2 -0
  120. package/dist/infrastructure/persistence/sqliteSerializer.js +18 -5
  121. package/dist/infrastructure/persistence/statements.d.ts +4 -0
  122. package/dist/infrastructure/persistence/statements.js +8 -2
  123. package/dist/infrastructure/server/handlerRoutes.js +3 -0
  124. package/dist/infrastructure/server/handlers/advanced.js +2 -2
  125. package/dist/infrastructure/server/handlers/flow.d.ts +7 -0
  126. package/dist/infrastructure/server/handlers/flow.js +11 -0
  127. package/dist/infrastructure/server/handlers/index.d.ts +1 -0
  128. package/dist/infrastructure/server/handlers/index.js +1 -0
  129. package/package.json +4 -2
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Timing primitives used while running a workflow step.
3
+ *
4
+ * Kept separate from runner.ts so the runner stays focused on state transitions.
5
+ */
6
+ import { clock } from './clock';
7
+ import { describeError } from './identity';
8
+ /** Largest delay accepted by Bun/Node timers without wrapping to 1ms. */
9
+ const MAX_TIMER_DELAY_MS = 2_147_483_647;
10
+ /** Exponential retry backoff with jitter. */
11
+ export function retryBackoffDelay(attempt, baseMs = 500, maxMs = 30_000) {
12
+ const delay = Math.min(baseMs * 2 ** (attempt - 1), maxMs);
13
+ return delay + delay * 0.5 * clock().random();
14
+ }
15
+ /**
16
+ * Bound a value or PromiseLike without passing an overflowing delay to the runtime.
17
+ *
18
+ * Long deadlines are re-armed in platform-sized chunks. Promise.resolve is
19
+ * intentional: it assimilates userland thenables as well as native promises.
20
+ */
21
+ export function runWithTimeout(value, timeoutMs, controller) {
22
+ const operation = Promise.resolve(value);
23
+ if (!(timeoutMs > 0) || !Number.isFinite(timeoutMs))
24
+ return operation;
25
+ const scheduler = clock();
26
+ const deadline = scheduler.now() + timeoutMs;
27
+ if (!Number.isFinite(deadline))
28
+ return operation;
29
+ return new Promise((resolve, reject) => {
30
+ let timer;
31
+ let settled = false;
32
+ const clearTimer = () => {
33
+ if (timer)
34
+ scheduler.clearTimeout(timer);
35
+ timer = undefined;
36
+ };
37
+ const onTimeout = () => {
38
+ if (settled)
39
+ return;
40
+ const remaining = deadline - scheduler.now();
41
+ if (remaining > 0) {
42
+ timer = scheduler.setTimeout(onTimeout, Math.min(remaining, MAX_TIMER_DELAY_MS));
43
+ return;
44
+ }
45
+ settled = true;
46
+ timer = undefined;
47
+ const error = new Error(`Step timed out after ${timeoutMs}ms`);
48
+ controller?.abort(error);
49
+ reject(error);
50
+ };
51
+ timer = scheduler.setTimeout(onTimeout, Math.min(timeoutMs, MAX_TIMER_DELAY_MS));
52
+ operation.then((result) => {
53
+ if (settled)
54
+ return;
55
+ settled = true;
56
+ clearTimer();
57
+ resolve(result);
58
+ }, (reason) => {
59
+ if (settled)
60
+ return;
61
+ settled = true;
62
+ clearTimer();
63
+ reject(reason instanceof Error ? reason : new Error(describeError(reason)));
64
+ });
65
+ });
66
+ }
@@ -0,0 +1,171 @@
1
+ /** Workflow handler, definition, and graph types. */
2
+ /** Context passed to step handlers */
3
+ export interface StepContext<TInput = unknown, TSteps extends Record<string, unknown> = Record<string, unknown>> {
4
+ /** Original workflow input */
5
+ readonly input: TInput;
6
+ /** Results from completed steps (step name → result) */
7
+ readonly steps: Readonly<TSteps>;
8
+ /** Signals received via engine.signal() */
9
+ readonly signals: Readonly<Record<string, unknown>>;
10
+ /** Current execution ID */
11
+ readonly executionId: string;
12
+ /**
13
+ * Aborted when this handler attempt reaches its timeout.
14
+ *
15
+ * Handlers that support cooperative cancellation should pass this signal to
16
+ * downstream I/O so timed-out work does not continue after the workflow moves on.
17
+ */
18
+ readonly signal?: AbortSignal;
19
+ /**
20
+ * Idempotency key for THIS execution of the step. Stable across automatic retries
21
+ * and across crash-resume; different for a different run. Pass it straight to the
22
+ * provider so a repeat lands on the same operation instead of a new one.
23
+ */
24
+ readonly idempotencyKey?: string;
25
+ /**
26
+ * Compensation only: the key the FORWARD step used. When the forward outcome is
27
+ * in doubt, this is what lets a rollback ask the provider "did this actually
28
+ * happen?" instead of depending on an output that may never have been persisted.
29
+ */
30
+ readonly forwardIdempotencyKey?: string;
31
+ }
32
+ /** Step handler function (type-erased for internal storage) */
33
+ export type StepHandler<TInput = unknown, TResult = unknown> = (ctx: StepContext<TInput, any>) => Promise<TResult> | TResult;
34
+ /** Typed step handler — preserves accumulated step types */
35
+ export type TypedStepHandler<TInput, TSteps extends Record<string, unknown>, TResult> = (ctx: StepContext<TInput, TSteps>) => Promise<TResult> | TResult;
36
+ /** Compensate handler (type-erased for internal storage) */
37
+ export type CompensateHandler<TInput = unknown> = (ctx: StepContext<TInput, any>) => Promise<void> | void;
38
+ /** Typed compensate handler — preserves accumulated step types */
39
+ export type TypedCompensateHandler<TInput, TSteps extends Record<string, unknown>> = (ctx: StepContext<TInput, TSteps>) => Promise<void> | void;
40
+ /** Schema-like object — any object with a .parse() method (Zod, ArkType, Valibot, etc.) */
41
+ export interface SchemaLike {
42
+ parse(data: unknown): unknown;
43
+ }
44
+ /**
45
+ * Options for a single step.
46
+ *
47
+ * `TSteps` is part of the signature for source compatibility and for symmetry with
48
+ * `TypedStepHandler`, even though `compensate` no longer narrows on it (see below).
49
+ * Removing the parameter would break every explicit `StepOptions<In, Steps>` in
50
+ * user code.
51
+ */
52
+ export interface StepOptions<TInput = unknown, TSteps extends Record<string, unknown> = Record<string, unknown>> {
53
+ retry?: number;
54
+ timeout?: number;
55
+ /**
56
+ * A METHOD taking a permissively-typed context, and every part of that is load
57
+ * bearing. Three forms were measured against real handler shapes:
58
+ *
59
+ * shape union method<TSteps> method<any>
60
+ * compensate: async (ctx) => ... TS7006 ok ok
61
+ * annotated with steps it reads ok ok ok
62
+ * annotated with a step that does not exist ok TS2322 ok
63
+ * CompensateHandler<TInput> alias ok ok ok
64
+ *
65
+ * Not a union (`TypedCompensateHandler | CompensateHandler`): TypeScript cannot
66
+ * contextually type a parameter against a union of signatures, so the inline arrow
67
+ * every documented example uses was an implicit `any` and failed `noImplicitAny`.
68
+ *
69
+ * A method rather than a property so parameters stay bivariant under
70
+ * `strictFunctionTypes`, which is what lets an explicitly annotated handler through.
71
+ *
72
+ * `any` rather than `TSteps` for the step map, deliberately: with `TSteps` an
73
+ * annotation naming a step this workflow does not declare is rejected, and the
74
+ * published union accepted it. Keeping the looser map costs typed access to
75
+ * `ctx.steps` inside a rollback, which handlers already narrow with a cast in
76
+ * practice, and buys source compatibility with every handler written before.
77
+ */
78
+ compensate?(ctx: StepContext<TInput, TSteps extends never ? never : any>): Promise<void> | void;
79
+ /** Validate step input before execution */
80
+ inputSchema?: SchemaLike;
81
+ /** Validate step output after execution */
82
+ outputSchema?: SchemaLike;
83
+ }
84
+ /** Internal step definition */
85
+ export interface StepDefinition {
86
+ name: string;
87
+ handler: StepHandler;
88
+ compensate?: CompensateHandler;
89
+ retry: number;
90
+ timeout: number;
91
+ inputSchema?: SchemaLike;
92
+ outputSchema?: SchemaLike;
93
+ }
94
+ /** Branch condition function */
95
+ export type BranchCondition<TInput = unknown, TSteps extends Record<string, unknown> = Record<string, unknown>> = (ctx: StepContext<TInput, TSteps>) => string;
96
+ /** Internal branch definition (type-erased) */
97
+ export interface BranchDefinition {
98
+ condition: BranchCondition<any, any>;
99
+ paths: Map<string, StepDefinition[]>;
100
+ }
101
+ /** Definition of a parallel step group */
102
+ export interface ParallelDefinition {
103
+ steps: StepDefinition[];
104
+ }
105
+ /** Input mapper for sub-workflows */
106
+ export type SubWorkflowInputMapper<TInput = unknown, TSteps extends Record<string, unknown> = Record<string, unknown>> = (ctx: StepContext<TInput, TSteps>) => unknown;
107
+ /** Bounds for polling a child workflow. */
108
+ export interface SubWorkflowOptions {
109
+ /** Maximum time to wait for the child. Defaults to five minutes. */
110
+ timeout?: number;
111
+ /** Delay between durable child-state reads. Defaults to 100ms. */
112
+ pollInterval?: number;
113
+ }
114
+ /** Loop condition: receives context + iteration count, returns boolean */
115
+ export type LoopCondition<TInput = unknown, TSteps extends Record<string, unknown> = Record<string, unknown>> = (ctx: StepContext<TInput, TSteps>, iteration: number) => boolean | Promise<boolean>;
116
+ /** Definition of a doUntil/doWhile loop (type-erased) */
117
+ export interface LoopDefinition {
118
+ condition: LoopCondition<any, any>;
119
+ steps: StepDefinition[];
120
+ maxIterations: number;
121
+ }
122
+ /** Item extractor for forEach */
123
+ export type ForEachItemsExtractor<TInput = unknown, TSteps extends Record<string, unknown> = Record<string, unknown>> = (ctx: StepContext<TInput, TSteps>) => unknown[];
124
+ /** Definition of a forEach loop (type-erased) */
125
+ export interface ForEachDefinition {
126
+ items: ForEachItemsExtractor<any, any>;
127
+ step: StepDefinition;
128
+ maxIterations: number;
129
+ }
130
+ /** Transform function for map */
131
+ export type MapTransformFn<TInput = unknown, TSteps extends Record<string, unknown> = Record<string, unknown>> = (ctx: StepContext<TInput, TSteps>) => unknown;
132
+ /** Definition of a map node (type-erased) */
133
+ export interface MapDefinition {
134
+ name: string;
135
+ transform: MapTransformFn<any, any>;
136
+ }
137
+ /** Workflow node (discriminated union) */
138
+ export type WorkflowNode = {
139
+ type: 'step';
140
+ def: StepDefinition;
141
+ } | {
142
+ type: 'branch';
143
+ def: BranchDefinition;
144
+ } | {
145
+ type: 'waitFor';
146
+ event: string;
147
+ timeout?: number;
148
+ } | {
149
+ type: 'parallel';
150
+ def: ParallelDefinition;
151
+ } | {
152
+ type: 'subWorkflow';
153
+ name: string;
154
+ inputMapper: SubWorkflowInputMapper<any, any>;
155
+ timeout: number;
156
+ pollInterval: number;
157
+ } | {
158
+ type: 'doUntil';
159
+ def: LoopDefinition;
160
+ } | {
161
+ type: 'doWhile';
162
+ def: LoopDefinition;
163
+ } | {
164
+ type: 'forEach';
165
+ def: ForEachDefinition;
166
+ } | {
167
+ type: 'map';
168
+ def: MapDefinition;
169
+ } | {
170
+ type: 'pivot';
171
+ };
@@ -0,0 +1,3 @@
1
+ /* biome-ignore-all lint/suspicious/noExplicitAny: runtime definitions intentionally erase accumulated generics */
2
+ /** Workflow handler, definition, and graph types. */
3
+ export {};
@@ -1,31 +1,24 @@
1
1
  /**
2
2
  * WorkflowStore - SQLite persistence for workflow executions
3
3
  */
4
- import type { Execution, ExecutionState, ParkOutcome, SignalOutcome } from './types';
4
+ import type { Execution, ExecutionListOptions, ExecutionState, ParkOutcome, SignalOutcome } from './types';
5
5
  export declare class WorkflowStore {
6
6
  private readonly db;
7
7
  private readonly stmts;
8
8
  /** Sole owner of the `signals` column — see storeSignals.ts */
9
9
  private readonly signals;
10
+ private readonly listing;
10
11
  constructor(dbPath?: string);
11
12
  /**
12
- * INSERT a brand-new execution. Not an upsert in spirit, despite the statement.
13
- *
14
- * A plain INSERT, not an upsert: the statement used to be `INSERT OR REPLACE`, so a
15
- * duplicate execution id silently OVERWROTE a live run instead of failing. Ids carry
16
- * a random component, which makes that vanishingly rare in production and reachable
17
- * in a simulation, where a seeded generator draws from a far smaller space. A lost
18
- * execution is the worst possible presentation of a collision; a constraint error is
19
- * the best.
20
- *
21
- * This is the ONLY writer of `signals` outside SignalCoordinator, and it is safe
22
- * only because its single caller passes a fresh execution whose signals are `{}`.
23
- * Calling it on a live run would write a stale snapshot over signals delivered
24
- * since it was read, which is exactly the lost-update SignalCoordinator exists to
25
- * prevent. Use `update()` for anything that already exists.
13
+ * Insert a fresh execution. A plain INSERT exposes id collisions instead of
14
+ * replacing live runs. This is the only signal writer outside SignalCoordinator and
15
+ * is safe because callers pass a new execution with no signals.
26
16
  */
27
17
  save(exec: Execution): void;
28
18
  get(id: string): Execution | null;
19
+ findChild(parentExecutionId: string, workflowName: string): Execution | null;
20
+ /** Remove an execution whose initial queue publication failed. */
21
+ remove(id: string): boolean;
29
22
  /**
30
23
  * Persist the step-level columns of an execution.
31
24
  *
@@ -43,12 +36,20 @@ export declare class WorkflowStore {
43
36
  * claim the single resume for this caller.
44
37
  */
45
38
  recordSignal(id: string, event: string, payload: unknown): SignalOutcome;
39
+ /**
40
+ * Restore the wait claim if publishing its resume job failed.
41
+ *
42
+ * The signal remains durable, so recover() can publish the resume again.
43
+ */
44
+ restoreSignalWait(id: string, event: string, nodeIndex: number): boolean;
46
45
  /**
47
46
  * Park a running execution at a `waitFor`, unless the awaited signal has already
48
47
  * been recorded (in which case the caller must advance instead).
49
48
  */
50
49
  parkForSignal(id: string, event: string): ParkOutcome;
51
- list(workflowName?: string, state?: ExecutionState): Execution[];
50
+ list(workflowName?: string, state?: ExecutionState, options?: ExecutionListOptions): Execution[];
51
+ /** Runs whose registered definition must remain available and structurally stable. */
52
+ listActive(workflowName: string): Execution[];
52
53
  /**
53
54
  * Executions in a recoverable state that recovery may drive ON ITS OWN.
54
55
  *
@@ -79,5 +80,4 @@ export declare class WorkflowStore {
79
80
  /** Get archived execution count */
80
81
  getArchivedCount(): number;
81
82
  close(): void;
82
- private rowToExecution;
83
83
  }
@@ -2,9 +2,12 @@
2
2
  * WorkflowStore - SQLite persistence for workflow executions
3
3
  */
4
4
  import { Database } from 'bun:sqlite';
5
- import { pack, unpack } from './storeCodec';
5
+ import { pack } from './storeCodec';
6
6
  import { SignalCoordinator } from './storeSignals';
7
7
  import { clock } from './clock';
8
+ import { decodeExecution, packExecutionMeta } from './storeExecutionCodec';
9
+ import { archivedExecutionCount, archiveExecutions, cleanupExecutions } from './storeMaintenance';
10
+ import { ExecutionListing } from './storeListing';
8
11
  const CREATE_TABLE = `
9
12
  CREATE TABLE IF NOT EXISTS workflow_executions (
10
13
  id TEXT PRIMARY KEY,
@@ -34,23 +37,19 @@ CREATE TABLE IF NOT EXISTS workflow_executions_archive (
34
37
  )`;
35
38
  const CREATE_IDX_NAME = `CREATE INDEX IF NOT EXISTS idx_wf_name ON workflow_executions(workflow_name)`;
36
39
  const CREATE_IDX_STATE = `CREATE INDEX IF NOT EXISTS idx_wf_state ON workflow_executions(state)`;
37
- function packMeta(exec) {
38
- const meta = {};
39
- if (exec.rollbackStatus !== undefined)
40
- meta.rollbackStatus = exec.rollbackStatus;
41
- if (exec.failureReason !== undefined)
42
- meta.failureReason = exec.failureReason;
43
- if (exec.committedAt !== undefined)
44
- meta.committedAt = exec.committedAt;
45
- if (exec.parentExecutionId !== undefined)
46
- meta.parentExecutionId = exec.parentExecutionId;
47
- return Object.keys(meta).length > 0 ? pack(meta) : null;
48
- }
40
+ const CREATE_LIST_INDEXES = [
41
+ `CREATE INDEX IF NOT EXISTS idx_wf_created ON workflow_executions(created_at DESC, id DESC)`,
42
+ `CREATE INDEX IF NOT EXISTS idx_wf_name_created ON workflow_executions(workflow_name, created_at DESC, id DESC)`,
43
+ `CREATE INDEX IF NOT EXISTS idx_wf_state_created ON workflow_executions(state, created_at DESC, id DESC)`,
44
+ `CREATE INDEX IF NOT EXISTS idx_wf_name_state_created ON workflow_executions(workflow_name, state, created_at DESC, id DESC)`,
45
+ `CREATE INDEX IF NOT EXISTS idx_wf_state_updated ON workflow_executions(state, updated_at ASC, id ASC)`,
46
+ ];
49
47
  export class WorkflowStore {
50
48
  db;
51
49
  stmts;
52
50
  /** Sole owner of the `signals` column — see storeSignals.ts */
53
51
  signals;
52
+ listing;
54
53
  constructor(dbPath) {
55
54
  this.db = new Database(dbPath ?? ':memory:', { create: true });
56
55
  this.db.run('PRAGMA journal_mode = WAL');
@@ -64,6 +63,8 @@ export class WorkflowStore {
64
63
  this.db.run(CREATE_ARCHIVE_TABLE);
65
64
  this.db.run(CREATE_IDX_NAME);
66
65
  this.db.run(CREATE_IDX_STATE);
66
+ for (const statement of CREATE_LIST_INDEXES)
67
+ this.db.run(statement);
67
68
  // Rollback bookkeeping arrived after the original schema. One nullable blob
68
69
  // rather than three columns keeps the migration to a single guarded statement
69
70
  // per table; SQLite has no ADD COLUMN IF NOT EXISTS, so the throw IS the check.
@@ -89,39 +90,42 @@ export class WorkflowStore {
89
90
  SET state = ?, steps = ?, current_node_index = ?, resolved_steps = ?, updated_at = ?, meta = ?
90
91
  WHERE id = ?
91
92
  `),
92
- list: this.db.prepare(`SELECT * FROM workflow_executions ORDER BY created_at DESC LIMIT 100`),
93
- listByName: this.db.prepare(`SELECT * FROM workflow_executions WHERE workflow_name = ? ORDER BY created_at DESC LIMIT 100`),
94
- listByState: this.db.prepare(`SELECT * FROM workflow_executions WHERE state = ? ORDER BY created_at DESC LIMIT 100`),
95
- listByBoth: this.db.prepare(`SELECT * FROM workflow_executions WHERE workflow_name = ? AND state = ? ORDER BY created_at DESC LIMIT 100`),
96
- listRecoverable: this.db.prepare(`SELECT * FROM workflow_executions WHERE state IN ('running', 'waiting', 'compensating') ORDER BY updated_at ASC`),
93
+ childrenByName: this.db.prepare(`SELECT * FROM workflow_executions WHERE workflow_name = ? ORDER BY created_at ASC, id ASC`),
94
+ listActiveByName: this.db.prepare(`SELECT * FROM workflow_executions
95
+ WHERE workflow_name = ?
96
+ AND state IN ('running', 'waiting', 'compensating', 'compensation-stuck', 'failed')`),
97
+ listRecoverable: this.db.prepare(`SELECT * FROM workflow_executions
98
+ WHERE state IN ('running', 'waiting', 'compensating', 'failed')
99
+ ORDER BY updated_at ASC, id ASC`),
100
+ remove: this.db.prepare(`DELETE FROM workflow_executions WHERE id = ?`),
97
101
  };
98
102
  this.signals = new SignalCoordinator(this.db);
103
+ this.listing = new ExecutionListing(this.db);
99
104
  }
100
105
  /**
101
- * INSERT a brand-new execution. Not an upsert in spirit, despite the statement.
102
- *
103
- * A plain INSERT, not an upsert: the statement used to be `INSERT OR REPLACE`, so a
104
- * duplicate execution id silently OVERWROTE a live run instead of failing. Ids carry
105
- * a random component, which makes that vanishingly rare in production and reachable
106
- * in a simulation, where a seeded generator draws from a far smaller space. A lost
107
- * execution is the worst possible presentation of a collision; a constraint error is
108
- * the best.
109
- *
110
- * This is the ONLY writer of `signals` outside SignalCoordinator, and it is safe
111
- * only because its single caller passes a fresh execution whose signals are `{}`.
112
- * Calling it on a live run would write a stale snapshot over signals delivered
113
- * since it was read, which is exactly the lost-update SignalCoordinator exists to
114
- * prevent. Use `update()` for anything that already exists.
106
+ * Insert a fresh execution. A plain INSERT exposes id collisions instead of
107
+ * replacing live runs. This is the only signal writer outside SignalCoordinator and
108
+ * is safe because callers pass a new execution with no signals.
115
109
  */
116
110
  save(exec) {
117
111
  if (Object.keys(exec.signals).length > 0) {
118
112
  throw new Error('WorkflowStore.save() is for new executions only; it would overwrite delivered signals. Use update().');
119
113
  }
120
- this.stmts.upsert.run(exec.id, exec.workflowName, exec.state, pack(exec.input), pack(exec.steps), exec.currentNodeIndex, exec.resolvedSteps ? pack(exec.resolvedSteps) : null, pack(exec.signals), exec.createdAt, exec.updatedAt, packMeta(exec));
114
+ this.stmts.upsert.run(exec.id, exec.workflowName, exec.state, pack(exec.input), pack(exec.steps), exec.currentNodeIndex, exec.resolvedSteps ? pack(exec.resolvedSteps) : null, pack(exec.signals), exec.createdAt, exec.updatedAt, packExecutionMeta(exec));
121
115
  }
122
116
  get(id) {
123
117
  const row = this.stmts.get.get(id);
124
- return row ? this.rowToExecution(row) : null;
118
+ return row ? decodeExecution(row) : null;
119
+ }
120
+ findChild(parentExecutionId, workflowName) {
121
+ const rows = this.stmts.childrenByName.all(workflowName);
122
+ return (rows.map(decodeExecution).find((child) => child.parentExecutionId === parentExecutionId) ??
123
+ null);
124
+ }
125
+ /** Remove an execution whose initial queue publication failed. */
126
+ remove(id) {
127
+ const result = this.stmts.remove.run(id);
128
+ return result.changes === 1;
125
129
  }
126
130
  /**
127
131
  * Persist the step-level columns of an execution.
@@ -136,7 +140,7 @@ export class WorkflowStore {
136
140
  */
137
141
  update(exec) {
138
142
  exec.updatedAt = clock().now();
139
- this.stmts.updateState.run(exec.state, pack(exec.steps), exec.currentNodeIndex, exec.resolvedSteps ? pack(exec.resolvedSteps) : null, exec.updatedAt, packMeta(exec), exec.id);
143
+ this.stmts.updateState.run(exec.state, pack(exec.steps), exec.currentNodeIndex, exec.resolvedSteps ? pack(exec.resolvedSteps) : null, exec.updatedAt, packExecutionMeta(exec), exec.id);
140
144
  }
141
145
  /**
142
146
  * Record a signal payload and, if the run is parked at a `waitFor`, atomically
@@ -145,6 +149,14 @@ export class WorkflowStore {
145
149
  recordSignal(id, event, payload) {
146
150
  return this.signals.record(id, event, payload);
147
151
  }
152
+ /**
153
+ * Restore the wait claim if publishing its resume job failed.
154
+ *
155
+ * The signal remains durable, so recover() can publish the resume again.
156
+ */
157
+ restoreSignalWait(id, event, nodeIndex) {
158
+ return this.signals.restoreWaiting(id, event, nodeIndex);
159
+ }
148
160
  /**
149
161
  * Park a running execution at a `waitFor`, unless the awaited signal has already
150
162
  * been recorded (in which case the caller must advance instead).
@@ -152,21 +164,15 @@ export class WorkflowStore {
152
164
  parkForSignal(id, event) {
153
165
  return this.signals.park(id, event);
154
166
  }
155
- list(workflowName, state) {
156
- let rows;
157
- if (workflowName && state) {
158
- rows = this.stmts.listByBoth.all(workflowName, state);
159
- }
160
- else if (workflowName) {
161
- rows = this.stmts.listByName.all(workflowName);
162
- }
163
- else if (state) {
164
- rows = this.stmts.listByState.all(state);
165
- }
166
- else {
167
- rows = this.stmts.list.all();
168
- }
169
- return rows.map((r) => this.rowToExecution(r));
167
+ list(workflowName, state, options) {
168
+ return this.listing.list(workflowName, state, options);
169
+ }
170
+ /** Runs whose registered definition must remain available and structurally stable. */
171
+ listActive(workflowName) {
172
+ const rows = this.stmts.listActiveByName.all(workflowName);
173
+ return rows
174
+ .map(decodeExecution)
175
+ .filter((exec) => exec.state !== 'failed' || exec.rollbackStatus === undefined);
170
176
  }
171
177
  /**
172
178
  * Executions in a recoverable state that recovery may drive ON ITS OWN.
@@ -185,8 +191,12 @@ export class WorkflowStore {
185
191
  */
186
192
  listRecoverable() {
187
193
  const rows = this.stmts.listRecoverable.all();
188
- const all = rows.map((r) => this.rowToExecution(r));
189
- return all.filter((e) => !e.parentExecutionId || this.get(e.parentExecutionId) === null);
194
+ const all = rows.map(decodeExecution);
195
+ return all.filter((execution) => {
196
+ if (execution.state === 'failed' && execution.rollbackStatus !== undefined)
197
+ return false;
198
+ return !execution.parentExecutionId || this.get(execution.parentExecutionId) === null;
199
+ });
190
200
  }
191
201
  /**
192
202
  * Delete executions at least `maxAgeMs` old in terminal states.
@@ -197,62 +207,17 @@ export class WorkflowStore {
197
207
  * (`test/repro-workflow-archive-boundary.test.ts`).
198
208
  */
199
209
  cleanup(maxAgeMs, states = ['completed', 'failed']) {
200
- const cutoff = clock().now() - maxAgeMs;
201
- const placeholders = states.map(() => '?').join(',');
202
- const stmt = this.db.prepare(`DELETE FROM workflow_executions WHERE updated_at <= ? AND state IN (${placeholders})`);
203
- const result = stmt.run(cutoff, ...states);
204
- return result.changes;
210
+ return cleanupExecutions(this.db, maxAgeMs, states);
205
211
  }
206
212
  /** Archive executions at least `maxAgeMs` old to the archive table. Cutoff inclusive, as in `cleanup`. */
207
213
  archive(maxAgeMs, states = ['completed', 'failed']) {
208
- const cutoff = clock().now() - maxAgeMs;
209
- const now = clock().now();
210
- const placeholders = states.map(() => '?').join(',');
211
- const rows = this.db
212
- .prepare(`SELECT * FROM workflow_executions WHERE updated_at <= ? AND state IN (${placeholders}) LIMIT 1000`)
213
- .all(cutoff, ...states);
214
- if (rows.length === 0)
215
- return 0;
216
- const insertArchive = this.db.prepare(`
217
- INSERT OR REPLACE INTO workflow_executions_archive
218
- (id, workflow_name, state, input, steps, current_node_index, resolved_steps, signals, created_at, updated_at, archived_at, meta)
219
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
220
- `);
221
- const deleteOriginal = this.db.prepare(`DELETE FROM workflow_executions WHERE id = ?`);
222
- const tx = this.db.transaction(() => {
223
- for (const row of rows) {
224
- insertArchive.run(row.id, row.workflow_name, row.state, row.input, row.steps, row.current_node_index, row.resolved_steps, row.signals, row.created_at, row.updated_at, now, row.meta ?? null);
225
- deleteOriginal.run(row.id);
226
- }
227
- });
228
- tx();
229
- return rows.length;
214
+ return archiveExecutions(this.db, maxAgeMs, states);
230
215
  }
231
216
  /** Get archived execution count */
232
217
  getArchivedCount() {
233
- const row = this.db
234
- .prepare(`SELECT COUNT(*) as cnt FROM workflow_executions_archive`)
235
- .get();
236
- return row.cnt;
218
+ return archivedExecutionCount(this.db);
237
219
  }
238
220
  close() {
239
221
  this.db.close();
240
222
  }
241
- rowToExecution(row) {
242
- return {
243
- id: row.id,
244
- workflowName: row.workflow_name,
245
- state: row.state,
246
- input: unpack(row.input),
247
- steps: unpack(row.steps) ?? {},
248
- currentNodeIndex: row.current_node_index,
249
- resolvedSteps: row.resolved_steps
250
- ? unpack(row.resolved_steps)
251
- : undefined,
252
- signals: unpack(row.signals) ?? {},
253
- createdAt: row.created_at,
254
- updatedAt: row.updated_at,
255
- ...(unpack(row.meta) ?? {}),
256
- };
257
- }
258
223
  }
@@ -0,0 +1,11 @@
1
+ import type { Execution } from './types';
2
+ export interface ExecutionMeta {
3
+ rollbackStatus?: Execution['rollbackStatus'];
4
+ failureReason?: string;
5
+ committedAt?: number;
6
+ parentExecutionId?: string;
7
+ decisions?: Record<string, unknown>;
8
+ definitionHash?: string;
9
+ }
10
+ export declare function packExecutionMeta(exec: Execution): Uint8Array | null;
11
+ export declare function decodeExecution(row: Record<string, unknown>): Execution;
@@ -0,0 +1,34 @@
1
+ import { pack, unpack } from './storeCodec';
2
+ export function packExecutionMeta(exec) {
3
+ const meta = {};
4
+ if (exec.rollbackStatus !== undefined)
5
+ meta.rollbackStatus = exec.rollbackStatus;
6
+ if (exec.failureReason !== undefined)
7
+ meta.failureReason = exec.failureReason;
8
+ if (exec.committedAt !== undefined)
9
+ meta.committedAt = exec.committedAt;
10
+ if (exec.parentExecutionId !== undefined)
11
+ meta.parentExecutionId = exec.parentExecutionId;
12
+ if (exec.decisions !== undefined)
13
+ meta.decisions = exec.decisions;
14
+ if (exec.definitionHash !== undefined)
15
+ meta.definitionHash = exec.definitionHash;
16
+ return Object.keys(meta).length > 0 ? pack(meta) : null;
17
+ }
18
+ export function decodeExecution(row) {
19
+ return {
20
+ id: row.id,
21
+ workflowName: row.workflow_name,
22
+ state: row.state,
23
+ input: unpack(row.input),
24
+ steps: unpack(row.steps) ?? {},
25
+ currentNodeIndex: row.current_node_index,
26
+ resolvedSteps: row.resolved_steps
27
+ ? unpack(row.resolved_steps)
28
+ : undefined,
29
+ signals: unpack(row.signals) ?? {},
30
+ createdAt: row.created_at,
31
+ updatedAt: row.updated_at,
32
+ ...(unpack(row.meta) ?? {}),
33
+ };
34
+ }
@@ -0,0 +1,11 @@
1
+ import type { Database } from 'bun:sqlite';
2
+ import type { Execution, ExecutionListOptions, ExecutionState } from './types';
3
+ /** Prepared, consistently ordered execution-list queries. */
4
+ export declare class ExecutionListing {
5
+ private readonly all;
6
+ private readonly byName;
7
+ private readonly byState;
8
+ private readonly byBoth;
9
+ constructor(db: Database);
10
+ list(workflowName?: string, state?: ExecutionState, options?: ExecutionListOptions): Execution[];
11
+ }