octaflow 0.14.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -9,7 +9,8 @@
9
9
  **Durable workflows for TypeScript that run on the Postgres you already have.**
10
10
 
11
11
  Declare a DAG of Zod-typed steps. The engine runs each step as soon as its dependencies
12
- complete, persists every transition, retries failures, and resumes after a crash. There is
12
+ complete, persists every transition, retries failures, and picks up where it left off after a
13
+ restart. There is
13
14
  no workflow server to operate, no control plane, and no vendor — it's a library you import,
14
15
  not a platform you adopt.
15
16
 
@@ -104,7 +105,7 @@ workflow is expressed**.
104
105
  | **Model** | declarative — a static DAG value | imperative workflow code | imperative step functions | imperative tasks | job queue; flows are **trees** |
105
106
  | **Fan-in / diamond deps** | yes | yes | yes | yes | no — a job can't be shared by two branches |
106
107
  | **Inspect before running** | yes — the DAG is a value | no — the graph is the execution trace | no | no | yes — the flow tree is data |
107
- | **Web dashboard** | **none** — build one on `toPublicWorkflow()` | yes | yes | yes | via third-party UIs |
108
+ | **Web dashboard** | **none** — [build one](https://octabits-io.github.io/octaflow/running/live-progress/) | yes | yes | yes | via third-party UIs |
108
109
  | **Languages** | TypeScript | polyglot SDKs | TS, Python, Go, Kotlin | TS | Node (+ ports) |
109
110
  | **Maturity** | **pre-1.0** | mature | mature | mature | mature, widely deployed |
110
111
 
@@ -241,7 +242,8 @@ Full docs — concepts, every feature, production wiring and the API reference
241
242
  | [Fan-out & map](https://octabits-io.github.io/octaflow/core/fan-out-and-map/) | one child step per item of a runtime list |
242
243
  | [Signals](https://octabits-io.github.io/octaflow/core/signals/) · [Sub-workflows](https://octabits-io.github.io/octaflow/core/sub-workflows/) · [Saga](https://octabits-io.github.io/octaflow/core/saga-compensation/) | suspend, nest, and roll back |
243
244
  | [Postgres & pg-boss](https://octabits-io.github.io/octaflow/running/postgres-and-pg-boss/) | production wiring, workers, DLQ, cron |
244
- | [Observability](https://octabits-io.github.io/octaflow/running/observability/) | lifecycle events and spans |
245
+ | [Cancellation & recovery](https://octabits-io.github.io/octaflow/running/cancellation-and-recovery/) | cancelling a run, and sweeping steps a crash left behind |
246
+ | [Observability](https://octabits-io.github.io/octaflow/running/observability/) · [Live progress](https://octabits-io.github.io/octaflow/running/live-progress/) | lifecycle events, spans, and streaming them to a browser |
245
247
  | [The AI add-on](https://octabits-io.github.io/octaflow/extending/ai/) | token/cost capture, quota, usage rollups |
246
248
  | [Extending](https://octabits-io.github.io/octaflow/extending/interfaces/) | custom stores, dispatchers and gates |
247
249
  | [API reference](https://octabits-io.github.io/octaflow/reference/api/) | every export, by entry point |
@@ -267,6 +269,7 @@ Runnable, focused examples live in [`examples/`](./examples) — see [`examples/
267
269
  | 11 | `11-observability.ts` | observer events + tracer spans |
268
270
  | 12 | `12-postgres-pgboss-production.ts` | full pg store + gate + event sink + pg-boss + cron |
269
271
  | 13 | `13-ai-workflow.ts` | AI add-on (instrumented model + cost) |
272
+ | 14 | `14-live-progress.ts` | `FlowObserver` → SSE fan-out (build your own dashboard) |
270
273
 
271
274
  The in-memory examples (01–11) share a small driver, [`examples/runtime.ts`](./examples/runtime.ts),
272
275
  that builds an engine over the in-memory store and an in-process queue you drain.
@@ -21,6 +21,17 @@ interface EnqueueOptions {
21
21
  */
22
22
  interface Dispatcher {
23
23
  enqueueStep(payload: DispatchStepPayload, options?: EnqueueOptions): Promise<Result<void, FlowErrorShape>>;
24
+ /**
25
+ * **Optional capability.** Enqueue inside the caller's store transaction, so
26
+ * the job and the state change that produced it commit together.
27
+ *
28
+ * `handle` comes from `WorkflowStore.runInTransaction` and is opaque to the
29
+ * engine — the store and dispatcher agree on its meaning. Implement this only
30
+ * when the queue lives in the same database as the store (pg-boss on the same
31
+ * Postgres does; SQS or a separate Redis cannot). Without it the engine writes
32
+ * state and then enqueues, which is at-least-once with a crash window.
33
+ */
34
+ enqueueStepIn?(handle: unknown, payload: DispatchStepPayload, options?: EnqueueOptions): Promise<Result<void, FlowErrorShape>>;
24
35
  }
25
36
  //#endregion
26
37
  export { Dispatcher as n, EnqueueOptions as r, DispatchStepPayload as t };
@@ -1,6 +1,6 @@
1
1
  import "../index-D_fOYfmf.js";
2
2
  import { n as Logger } from "../logger-BCus3AOa.js";
3
- import { n as Dispatcher } from "../dispatcher-BydjyrgU.js";
3
+ import { n as Dispatcher } from "../dispatcher-CnMTwfCc.js";
4
4
  import { z } from "zod";
5
5
  import { PgBoss } from "pg-boss";
6
6
  //#region src/dispatcher-pgboss/payload.d.ts
@@ -29,8 +29,15 @@ declare const DEFAULT_STEP_QUEUE_CONFIG: Required<StepQueueConfig>;
29
29
  /**
30
30
  * The wire payload for a scheduled (or ad-hoc) workflow **start**. Carries the
31
31
  * partition plus what `engine.startWorkflow` needs; the host resolves `workflowType`
32
- * to a definition. `idempotencyKey` is forwarded to the start so overlapping
33
- * cron ticks don't double-start.
32
+ * to a definition.
33
+ *
34
+ * Two mutually exclusive ways to ask for start idempotency, because a cron schedule
35
+ * stores its payload **once** and pg-boss redelivers that same payload on every tick:
36
+ *
37
+ * - `idempotencyKeyPrefix` — the usual choice. The start worker appends the job id,
38
+ * so each tick gets a distinct key while a redelivery of the *same* tick reuses it.
39
+ * - `idempotencyKey` — used verbatim. On a schedule this means "start exactly one
40
+ * workflow, ever"; every later tick returns the first one.
34
41
  */
35
42
  declare const WIRE_START_PAYLOAD_SCHEMA: z.ZodObject<{
36
43
  partitionKey: z.ZodString;
@@ -38,8 +45,22 @@ declare const WIRE_START_PAYLOAD_SCHEMA: z.ZodObject<{
38
45
  input: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
39
46
  entityRef: z.ZodOptional<z.ZodString>;
40
47
  idempotencyKey: z.ZodOptional<z.ZodString>;
48
+ idempotencyKeyPrefix: z.ZodOptional<z.ZodString>;
41
49
  }, z.core.$strip>;
42
50
  type WireStartPayload = z.infer<typeof WIRE_START_PAYLOAD_SCHEMA>;
51
+ /**
52
+ * What a {@link WireStartPayload} becomes at delivery time: the same fields, plus the
53
+ * pg-boss job id and the **resolved** `idempotencyKey` to hand `engine.startWorkflow`.
54
+ *
55
+ * `idempotencyKey` is verbatim when the payload set one, `${idempotencyKeyPrefix}:${jobId}`
56
+ * when it set a prefix, and `undefined` when it set neither.
57
+ */
58
+ interface StartJobContext extends WireStartPayload {
59
+ /** pg-boss job id — unique per cron tick, stable across that job's retries. */
60
+ jobId: string;
61
+ }
62
+ /** Resolve the per-delivery idempotency key. Explicit key wins over a prefix. */
63
+ declare function resolveStartIdempotencyKey(payload: WireStartPayload, jobId: string): string | undefined;
43
64
  //#endregion
44
65
  //#region src/dispatcher-pgboss/queue.d.ts
45
66
  /**
@@ -136,7 +157,20 @@ interface ScheduleStartInput {
136
157
  workflowType: string;
137
158
  input?: Record<string, unknown>;
138
159
  entityRef?: string;
139
- /** Forwarded to the start so overlapping ticks don't double-start. */
160
+ /**
161
+ * Per-tick start idempotency. The start worker resolves this to
162
+ * `${idempotencyKeyPrefix}:${jobId}`, so each cron tick gets a distinct key while a
163
+ * redelivery of the same tick reuses it — the usual thing you want on a schedule.
164
+ */
165
+ idempotencyKeyPrefix?: string;
166
+ /**
167
+ * Verbatim start idempotency key.
168
+ *
169
+ * A schedule stores its payload **once**, so this same key rides every tick — and
170
+ * start keys never expire. On a cron schedule that means **exactly one workflow,
171
+ * ever**; every later tick returns the first one. That is occasionally what you
172
+ * want ("run this once and never again"); if it isn't, use `idempotencyKeyPrefix`.
173
+ */
140
174
  idempotencyKey?: string;
141
175
  /** Optional IANA time zone (default UTC). */
142
176
  tz?: string;
@@ -150,8 +184,20 @@ declare function createPgBossScheduler(deps: PgBossSchedulerDeps): {
150
184
  /** List the schedules registered on this queue. */
151
185
  list(): Promise<import("pg-boss").Schedule[]>;
152
186
  };
153
- /** Invoked for each start job — typically resolves the type and calls `engine.startWorkflow`. */
154
- type StartJobProcessor = (payload: WireStartPayload) => Promise<void>;
187
+ /**
188
+ * Invoked for each start job — typically resolves the type and calls `engine.startWorkflow`.
189
+ *
190
+ * The context's `idempotencyKey` is already resolved for **this delivery** (see
191
+ * {@link resolveStartIdempotencyKey}), so forwarding it is the right default:
192
+ *
193
+ * ```ts
194
+ * await starter.start(async (job) => {
195
+ * const wf = workflowsByType[job.workflowType];
196
+ * await engine.startWorkflow(wf.definition, job.input, { idempotencyKey: job.idempotencyKey });
197
+ * });
198
+ * ```
199
+ */
200
+ type StartJobProcessor = (payload: StartJobContext) => Promise<void>;
155
201
  interface PgBossStartWorkerDeps {
156
202
  boss: PgBoss;
157
203
  queueName: string;
@@ -167,4 +213,4 @@ declare function createPgBossStartWorker(deps: PgBossStartWorkerDeps): {
167
213
  stop: () => Promise<void>;
168
214
  };
169
215
  //#endregion
170
- export { DEFAULT_STEP_QUEUE_CONFIG, DlqProcessor, PgBossDispatcherDeps, PgBossDlqWorkerDeps, PgBossSchedulerDeps, PgBossStartWorkerDeps, PgBossStepWorkerDeps, PgBossStepWorkerOptions, ScheduleStartInput, StartJobProcessor, StepJobProcessor, StepQueueConfig, WIRE_START_PAYLOAD_SCHEMA, WIRE_STEP_PAYLOAD_SCHEMA, WireStartPayload, WireStepPayload, createPgBossDispatcher, createPgBossDlqWorker, createPgBossScheduler, createPgBossStartWorker, createPgBossStepWorker, ensureStartQueue, ensureStepQueue };
216
+ export { DEFAULT_STEP_QUEUE_CONFIG, DlqProcessor, PgBossDispatcherDeps, PgBossDlqWorkerDeps, PgBossSchedulerDeps, PgBossStartWorkerDeps, PgBossStepWorkerDeps, PgBossStepWorkerOptions, ScheduleStartInput, StartJobContext, StartJobProcessor, StepJobProcessor, StepQueueConfig, WIRE_START_PAYLOAD_SCHEMA, WIRE_STEP_PAYLOAD_SCHEMA, WireStartPayload, WireStepPayload, createPgBossDispatcher, createPgBossDlqWorker, createPgBossScheduler, createPgBossStartWorker, createPgBossStepWorker, ensureStartQueue, ensureStepQueue, resolveStartIdempotencyKey };
@@ -20,16 +20,31 @@ const DEFAULT_STEP_QUEUE_CONFIG = {
20
20
  /**
21
21
  * The wire payload for a scheduled (or ad-hoc) workflow **start**. Carries the
22
22
  * partition plus what `engine.startWorkflow` needs; the host resolves `workflowType`
23
- * to a definition. `idempotencyKey` is forwarded to the start so overlapping
24
- * cron ticks don't double-start.
23
+ * to a definition.
24
+ *
25
+ * Two mutually exclusive ways to ask for start idempotency, because a cron schedule
26
+ * stores its payload **once** and pg-boss redelivers that same payload on every tick:
27
+ *
28
+ * - `idempotencyKeyPrefix` — the usual choice. The start worker appends the job id,
29
+ * so each tick gets a distinct key while a redelivery of the *same* tick reuses it.
30
+ * - `idempotencyKey` — used verbatim. On a schedule this means "start exactly one
31
+ * workflow, ever"; every later tick returns the first one.
25
32
  */
26
33
  const WIRE_START_PAYLOAD_SCHEMA = z.object({
27
34
  partitionKey: z.string().min(1),
28
35
  workflowType: z.string().min(1),
29
36
  input: z.record(z.string(), z.unknown()).default({}),
30
37
  entityRef: z.string().optional(),
31
- idempotencyKey: z.string().optional()
38
+ /** Verbatim dedup key — collapses *every* delivery, including future cron ticks. */
39
+ idempotencyKey: z.string().optional(),
40
+ /** Per-tick dedup: the start worker resolves this to `${prefix}:${jobId}`. */
41
+ idempotencyKeyPrefix: z.string().optional()
32
42
  });
43
+ /** Resolve the per-delivery idempotency key. Explicit key wins over a prefix. */
44
+ function resolveStartIdempotencyKey(payload, jobId) {
45
+ if (payload.idempotencyKey !== void 0) return payload.idempotencyKey;
46
+ if (payload.idempotencyKeyPrefix !== void 0) return `${payload.idempotencyKeyPrefix}:${jobId}`;
47
+ }
33
48
  //#endregion
34
49
  //#region src/dispatcher-pgboss/queue.ts
35
50
  function dlqName(queueName) {
@@ -73,7 +88,16 @@ function createPgBossDispatcher(deps) {
73
88
  ...deps.config
74
89
  };
75
90
  let ensured = false;
76
- return { async enqueueStep(payload, options) {
91
+ /**
92
+ * Adapt flow's `SqlExecutor` to the shape pg-boss wants for a caller-supplied
93
+ * connection. The two are the same idea under different names.
94
+ */
95
+ function asPgBossDb(handle) {
96
+ const exec = handle;
97
+ if (!exec || typeof exec.query !== "function") return void 0;
98
+ return { executeSql: (text, values) => exec.query(text, values) };
99
+ }
100
+ async function send(payload, options, handle) {
77
101
  try {
78
102
  const wire = {
79
103
  partitionKey,
@@ -92,11 +116,20 @@ function createPgBossDispatcher(deps) {
92
116
  ensured = true;
93
117
  }
94
118
  const startAfter = options?.startAfterSeconds;
119
+ const db = handle === void 0 ? void 0 : asPgBossDb(handle);
120
+ if (handle !== void 0 && !db) return {
121
+ ok: false,
122
+ error: {
123
+ key: "queue_error",
124
+ message: "Transaction handle is not a SqlExecutor — is the store store-pg?"
125
+ }
126
+ };
95
127
  if (!await boss.send(queueName, parsed.data, {
96
128
  retryLimit: cfg.retryLimit,
97
129
  retryDelay: cfg.retryDelay,
98
130
  expireInSeconds: cfg.expireInSeconds,
99
- ...startAfter != null && startAfter > 0 ? { startAfter } : {}
131
+ ...startAfter != null && startAfter > 0 ? { startAfter } : {},
132
+ ...db ? { db } : {}
100
133
  })) return {
101
134
  ok: false,
102
135
  error: {
@@ -117,7 +150,16 @@ function createPgBossDispatcher(deps) {
117
150
  }
118
151
  };
119
152
  }
120
- } };
153
+ }
154
+ return {
155
+ enqueueStep: (payload, options) => send(payload, options),
156
+ /**
157
+ * Enqueue on the store's open transaction, so the job and the state change
158
+ * that produced it commit together. `handle` is the transaction-bound
159
+ * `SqlExecutor` that `createWorkflowStore.runInTransaction` hands out.
160
+ */
161
+ enqueueStepIn: (handle, payload, options) => send(payload, options, handle)
162
+ };
121
163
  }
122
164
  /** A pg-boss worker that drives `executeStep` for step jobs. */
123
165
  function createPgBossStepWorker(deps) {
@@ -248,7 +290,8 @@ function createPgBossScheduler(deps) {
248
290
  workflowType: input.workflowType,
249
291
  input: input.input ?? {},
250
292
  entityRef: input.entityRef,
251
- idempotencyKey: input.idempotencyKey
293
+ idempotencyKey: input.idempotencyKey,
294
+ idempotencyKeyPrefix: input.idempotencyKeyPrefix
252
295
  };
253
296
  await boss.schedule(queueName, input.cron, payload, {
254
297
  key: input.key,
@@ -282,7 +325,11 @@ function createPgBossStartWorker(deps) {
282
325
  logger?.error("Invalid start payload", void 0, { jobId: job.id });
283
326
  continue;
284
327
  }
285
- await process(parsed.data);
328
+ await process({
329
+ ...parsed.data,
330
+ jobId: job.id,
331
+ idempotencyKey: resolveStartIdempotencyKey(parsed.data, job.id)
332
+ });
286
333
  }
287
334
  });
288
335
  }
@@ -298,4 +345,4 @@ function createPgBossStartWorker(deps) {
298
345
  };
299
346
  }
300
347
  //#endregion
301
- export { DEFAULT_STEP_QUEUE_CONFIG, WIRE_START_PAYLOAD_SCHEMA, WIRE_STEP_PAYLOAD_SCHEMA, createPgBossDispatcher, createPgBossDlqWorker, createPgBossScheduler, createPgBossStartWorker, createPgBossStepWorker, ensureStartQueue, ensureStepQueue };
348
+ export { DEFAULT_STEP_QUEUE_CONFIG, WIRE_START_PAYLOAD_SCHEMA, WIRE_STEP_PAYLOAD_SCHEMA, createPgBossDispatcher, createPgBossDlqWorker, createPgBossScheduler, createPgBossStartWorker, createPgBossStepWorker, ensureStartQueue, ensureStepQueue, resolveStartIdempotencyKey };
package/dist/index.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { A as StepError, B as WorkflowDefinition, C as FlowError, D as StepCompensateHandler, E as StartOptions, F as StepId, G as WorkflowWithSteps, H as WorkflowNotFoundError, I as StepRecord, J as err, K as FlowErrorShape, L as StepRegistration, M as StepHandler, N as StepHandlerNotFoundError, O as StepCompensationContext, P as StepHandlerRegistry, R as StepStatus, S as defineWaitStep, T as RetryPolicy, U as WorkflowRecord, V as WorkflowId, W as WorkflowStatus, Y as ok, _ as buildWorkflow, a as STEP_DISPLAY_STATUS, b as defineStep, c as WORKFLOW_STATUS_SCHEMA, d as toPublicWorkflow, f as StepOutput, g as WorkflowOutput, h as TypedWorkflow, i as PublicWorkflowStep, j as StepExecutionContext, k as StepDefinition, l as toDisplayStepStatus, m as TypedStepContext, n as PUBLIC_WORKFLOW_STEP_SCHEMA, o as STEP_DISPLAY_STATUS_SCHEMA, p as TypedStep, q as Result, r as PublicWorkflow, s as StepDisplayStatus, t as PUBLIC_WORKFLOW_SCHEMA, u as toPublicStep, v as defineMapStep, w as InvalidWorkflowDefinitionError, x as defineSubWorkflowStep, y as defineSleepStep, z as WorkflowCreatedResult } from "./index-D_fOYfmf.js";
2
2
  import { n as Logger, r as noopLogger, t as LogAttributes } from "./logger-BCus3AOa.js";
3
- import { C as CreateWorkflowStep, D as ListWorkflowsFilters, E as FinishWorkflowParams, O as WorkflowStore, S as CreateWorkflowParams, T as FailStepParams, _ as StepGateDecision, a as FlowTracer, b as AddChildStep, c as RecordingTracer, d as noopObserver, f as noopTracer, g as StepGate, h as RateRule, i as FlowSpan, l as createRecordingObserver, m as InMemoryStepGateConfig, n as FlowEventType, o as RecordedSpan, p as ConcurrencyRule, r as FlowObserver, s as RecordingObserver, t as FlowEvent, u as createRecordingTracer, v as StepGateRequest, w as CreatedWorkflow, x as CompleteStepParams, y as createInMemoryStepGate } from "./observability-BsrvbhDr.js";
4
- import { n as Dispatcher, r as EnqueueOptions, t as DispatchStepPayload } from "./dispatcher-BydjyrgU.js";
3
+ import { C as CreateWorkflowStep, D as ListWorkflowsFilters, E as FinishWorkflowParams, O as TransactionalScope, S as CreateWorkflowParams, T as FailStepParams, _ as StepGateDecision, a as FlowTracer, b as AddChildStep, c as RecordingTracer, d as noopObserver, f as noopTracer, g as StepGate, h as RateRule, i as FlowSpan, k as WorkflowStore, l as createRecordingObserver, m as InMemoryStepGateConfig, n as FlowEventType, o as RecordedSpan, p as ConcurrencyRule, r as FlowObserver, s as RecordingObserver, t as FlowEvent, u as createRecordingTracer, v as StepGateRequest, w as CreatedWorkflow, x as CompleteStepParams, y as createInMemoryStepGate } from "./observability-3tcmRwIw.js";
4
+ import { n as Dispatcher, r as EnqueueOptions, t as DispatchStepPayload } from "./dispatcher-CnMTwfCc.js";
5
5
  import { a as WorkflowCompletedArgs, i as BuildStepContextArgs, n as BeforeStartArgs, o as WorkflowHooks, r as BeforeStartResult, t as AfterStepArgs } from "./hooks-CDRFQZbT.js";
6
6
  //#region src/core/registry.d.ts
7
7
  /**
@@ -134,4 +134,4 @@ declare function createInMemoryWorkflowStore(partitionKey?: string): WorkflowSto
134
134
  };
135
135
  };
136
136
  //#endregion
137
- export { AddChildStep, AfterStepArgs, BeforeStartArgs, BeforeStartResult, BuildStepContextArgs, CompleteStepParams, ConcurrencyRule, CreateWorkflowParams, CreateWorkflowStep, CreatedWorkflow, DispatchStepPayload, Dispatcher, EnqueueOptions, FailStepParams, FinishWorkflowParams, FlowError, FlowErrorShape, FlowEvent, FlowEventType, FlowObserver, FlowSpan, FlowTracer, InMemoryStepGateConfig, InvalidWorkflowDefinitionError, ListWorkflowsFilters, LogAttributes, Logger, PUBLIC_WORKFLOW_SCHEMA, PUBLIC_WORKFLOW_STEP_SCHEMA, PublicWorkflow, PublicWorkflowStep, RateRule, RecordedSpan, RecordingObserver, RecordingTracer, Result, RetryPolicy, STEP_DISPLAY_STATUS, STEP_DISPLAY_STATUS_SCHEMA, StartOptions, StepCompensateHandler, StepCompensationContext, StepDefinition, StepDisplayStatus, StepError, StepExecutionContext, StepGate, StepGateDecision, StepGateRequest, StepHandler, StepHandlerNotFoundError, StepHandlerRegistry, StepId, StepOutput, StepRecord, StepRegistration, StepStatus, TypedStep, TypedStepContext, TypedWorkflow, WORKFLOW_STATUS_SCHEMA, WorkflowCompletedArgs, WorkflowCreatedResult, WorkflowDefinition, WorkflowEngine, WorkflowEngineConfig, WorkflowEngineDeps, WorkflowHooks, WorkflowId, WorkflowNotFoundError, WorkflowOutput, WorkflowRecord, WorkflowStatus, WorkflowStore, WorkflowWithSteps, buildWorkflow, createInMemoryStepGate, createInMemoryWorkflowStore, createRecordingObserver, createRecordingTracer, createStepHandlerRegistry, createWorkflowEngine, defineMapStep, defineSleepStep, defineStep, defineSubWorkflowStep, defineWaitStep, err, explicitRetryability, isRetryableError, markRetryable, nonRetryableError, noopLogger, noopObserver, noopTracer, ok, retryableError, toDisplayStepStatus, toPublicStep, toPublicWorkflow };
137
+ export { AddChildStep, AfterStepArgs, BeforeStartArgs, BeforeStartResult, BuildStepContextArgs, CompleteStepParams, ConcurrencyRule, CreateWorkflowParams, CreateWorkflowStep, CreatedWorkflow, DispatchStepPayload, Dispatcher, EnqueueOptions, FailStepParams, FinishWorkflowParams, FlowError, FlowErrorShape, FlowEvent, FlowEventType, FlowObserver, FlowSpan, FlowTracer, InMemoryStepGateConfig, InvalidWorkflowDefinitionError, ListWorkflowsFilters, LogAttributes, Logger, PUBLIC_WORKFLOW_SCHEMA, PUBLIC_WORKFLOW_STEP_SCHEMA, PublicWorkflow, PublicWorkflowStep, RateRule, RecordedSpan, RecordingObserver, RecordingTracer, Result, RetryPolicy, STEP_DISPLAY_STATUS, STEP_DISPLAY_STATUS_SCHEMA, StartOptions, StepCompensateHandler, StepCompensationContext, StepDefinition, StepDisplayStatus, StepError, StepExecutionContext, StepGate, StepGateDecision, StepGateRequest, StepHandler, StepHandlerNotFoundError, StepHandlerRegistry, StepId, StepOutput, StepRecord, StepRegistration, StepStatus, TransactionalScope, TypedStep, TypedStepContext, TypedWorkflow, WORKFLOW_STATUS_SCHEMA, WorkflowCompletedArgs, WorkflowCreatedResult, WorkflowDefinition, WorkflowEngine, WorkflowEngineConfig, WorkflowEngineDeps, WorkflowHooks, WorkflowId, WorkflowNotFoundError, WorkflowOutput, WorkflowRecord, WorkflowStatus, WorkflowStore, WorkflowWithSteps, buildWorkflow, createInMemoryStepGate, createInMemoryWorkflowStore, createRecordingObserver, createRecordingTracer, createStepHandlerRegistry, createWorkflowEngine, defineMapStep, defineSleepStep, defineStep, defineSubWorkflowStep, defineWaitStep, err, explicitRetryability, isRetryableError, markRetryable, nonRetryableError, noopLogger, noopObserver, noopTracer, ok, retryableError, toDisplayStepStatus, toPublicStep, toPublicWorkflow };
package/dist/index.js CHANGED
@@ -221,6 +221,26 @@ function createWorkflowEngine(deps) {
221
221
  const stepExpirySeconds = deps.config?.stepExpirySeconds ?? 600;
222
222
  const stuckStepBufferSeconds = deps.config?.stuckStepBufferSeconds ?? 300;
223
223
  const defaultRetryable = deps.config?.defaultRetryable;
224
+ /**
225
+ * Both halves must opt in: the store has to offer a transaction and the
226
+ * dispatcher has to be able to enqueue inside it. When they do, a state change
227
+ * and the dispatches it unlocks commit atomically — there is no window in
228
+ * which a step is `pending` with no job behind it. When either cannot, the
229
+ * engine writes and then enqueues, exactly as before.
230
+ */
231
+ const atomicDispatch = typeof store.runInTransaction === "function" && typeof dispatcher.enqueueStepIn === "function";
232
+ /**
233
+ * Run `fn` atomically when both halves support it, otherwise run it directly.
234
+ * `fn` receives the scope to write through — pass it to `dispatchReadyStep`.
235
+ *
236
+ * Only writes and enqueues belong in here. Anything that can run user code
237
+ * (saga compensation, hooks) must stay outside, or a rollback handler making a
238
+ * network call would hold a database transaction open while it does.
239
+ */
240
+ async function withDispatchScope(fn) {
241
+ if (!atomicDispatch) return fn(void 0);
242
+ return store.runInTransaction((scope) => fn(scope));
243
+ }
224
244
  const nowIso = () => now().toISOString();
225
245
  /** Emit a run-history / metrics event. Guarded — a faulty observer never breaks a run. */
226
246
  function emit(event) {
@@ -249,9 +269,10 @@ function createWorkflowEngine(deps) {
249
269
  * and awaits `resumeStep`; everything else is enqueued (with any durable delay).
250
270
  * Returns whether the step was enqueued.
251
271
  */
252
- async function dispatchReadyStep(workflowId, stepId, stepKey, stepType) {
272
+ async function dispatchReadyStep(workflowId, stepId, stepKey, stepType, scope) {
273
+ const st = scope?.store ?? store;
253
274
  if (isWaitStep(stepType)) {
254
- await store.markStepWaiting(stepId);
275
+ await st.markStepWaiting(stepId);
255
276
  logger.info("Step is waiting for an event", {
256
277
  workflowId,
257
278
  stepId,
@@ -266,12 +287,14 @@ function createWorkflowEngine(deps) {
266
287
  });
267
288
  return false;
268
289
  }
269
- const result = await dispatcher.enqueueStep({
290
+ const payload = {
270
291
  workflowId,
271
292
  stepId,
272
293
  stepKey,
273
294
  stepType
274
- }, readyStepDelay(stepType));
295
+ };
296
+ const delay = readyStepDelay(stepType);
297
+ const result = scope ? await dispatcher.enqueueStepIn(scope.handle, payload, delay) : await dispatcher.enqueueStep(payload, delay);
275
298
  if (!result.ok) {
276
299
  logger.error("Failed to enqueue step", new Error(result.error.message), {
277
300
  workflowId,
@@ -377,21 +400,38 @@ function createWorkflowEngine(deps) {
377
400
  };
378
401
  }
379
402
  const { steps } = definition;
380
- const created = await store.createWorkflow({
381
- type: definition.type,
382
- input,
383
- entityRef: options?.entityRef,
384
- metadata,
385
- idempotencyKey: options?.idempotencyKey,
386
- parentWorkflowId: options?.parentWorkflowId,
387
- parentStepId: options?.parentStepId,
388
- startedAt: nowIso(),
389
- steps: steps.map((s) => ({
390
- key: s.key,
391
- type: s.type,
392
- dependencies: s.dependencies ?? [],
393
- input: s.input ?? null
394
- }))
403
+ const { created, enqueuedStepKeys } = await withDispatchScope(async (scope) => {
404
+ const created = await (scope?.store ?? store).createWorkflow({
405
+ type: definition.type,
406
+ input,
407
+ entityRef: options?.entityRef,
408
+ metadata,
409
+ idempotencyKey: options?.idempotencyKey,
410
+ parentWorkflowId: options?.parentWorkflowId,
411
+ parentStepId: options?.parentStepId,
412
+ startedAt: nowIso(),
413
+ steps: steps.map((s) => ({
414
+ key: s.key,
415
+ type: s.type,
416
+ dependencies: s.dependencies ?? [],
417
+ input: s.input ?? null
418
+ }))
419
+ });
420
+ if (created.alreadyExisted) return {
421
+ created,
422
+ enqueuedStepKeys: []
423
+ };
424
+ const readySteps = steps.filter((s) => !s.dependencies || s.dependencies.length === 0);
425
+ const keys = [];
426
+ for (const readyStep of readySteps) {
427
+ const dbStep = created.steps.find((s) => s.key === readyStep.key);
428
+ if (!dbStep) continue;
429
+ if (await dispatchReadyStep(created.workflowId, dbStep.id, readyStep.key, readyStep.type, scope)) keys.push(readyStep.key);
430
+ }
431
+ return {
432
+ created,
433
+ enqueuedStepKeys: keys
434
+ };
395
435
  });
396
436
  const workflowId = created.workflowId;
397
437
  if (created.alreadyExisted) {
@@ -413,13 +453,6 @@ function createWorkflowEngine(deps) {
413
453
  workflowId,
414
454
  workflowType: definition.type
415
455
  });
416
- const readySteps = steps.filter((s) => !s.dependencies || s.dependencies.length === 0);
417
- const enqueuedStepKeys = [];
418
- for (const readyStep of readySteps) {
419
- const dbStep = created.steps.find((s) => s.key === readyStep.key);
420
- if (!dbStep) continue;
421
- if (await dispatchReadyStep(workflowId, dbStep.id, readyStep.key, readyStep.type)) enqueuedStepKeys.push(readyStep.key);
422
- }
423
456
  logger.info("Workflow started", {
424
457
  workflowId,
425
458
  type: definition.type,
@@ -468,6 +501,20 @@ function createWorkflowEngine(deps) {
468
501
  }
469
502
  };
470
503
  if (step.status !== "pending") {
504
+ const workflowLive = workflow.status === "running" || workflow.status === "pending";
505
+ if (step.status === "completed" && workflowLive) {
506
+ logger.info("Re-driving advance for a redelivered completed step", {
507
+ workflowId,
508
+ stepId,
509
+ stepKey: step.key
510
+ });
511
+ if (step.parentStepId != null) await advanceAfterChildCompleted(workflowId, step);
512
+ else await advanceAfterStepCompleted(workflowId, stepId, step.output ?? {});
513
+ return {
514
+ ok: true,
515
+ value: void 0
516
+ };
517
+ }
471
518
  logger.info("Skipping already-processed step", {
472
519
  workflowId,
473
520
  stepId,
@@ -791,32 +838,60 @@ function createWorkflowEngine(deps) {
791
838
  };
792
839
  }
793
840
  async function onStepCompleted(workflowId, stepId, output) {
794
- const completedAt = nowIso();
795
- await store.completeStep({
796
- workflowId,
797
- stepId,
798
- output,
799
- completedAt
800
- });
801
- const keyedSteps = (await store.listSteps(workflowId)).filter((s) => s.parentStepId == null);
841
+ await finalizeAfterStepCompleted(workflowId, stepId, output, await withDispatchScope(async (scope) => {
842
+ await (scope?.store ?? store).completeStep({
843
+ workflowId,
844
+ stepId,
845
+ output,
846
+ completedAt: nowIso()
847
+ });
848
+ return dispatchNewlyReady(workflowId, stepId, scope);
849
+ }));
850
+ }
851
+ /**
852
+ * The readiness half of a completion, without the write. Split out so a
853
+ * redelivered job for an already-completed step can re-drive it — see the
854
+ * recovery branch in {@link executeStep}. Idempotent: dispatching a step that
855
+ * is already queued is harmless, because claiming it is atomic.
856
+ */
857
+ async function advanceAfterStepCompleted(workflowId, stepId, output) {
858
+ await finalizeAfterStepCompleted(workflowId, stepId, output, await withDispatchScope((scope) => dispatchNewlyReady(workflowId, stepId, scope)));
859
+ }
860
+ /**
861
+ * Transactional half: read readiness and dispatch whatever just became ready.
862
+ * Contains only store writes and enqueues — never user code.
863
+ */
864
+ async function dispatchNewlyReady(workflowId, stepId, scope) {
865
+ const keyedSteps = (await (scope?.store ?? store).listSteps(workflowId)).filter((s) => s.parentStepId == null);
802
866
  const completedKeys = new Set(keyedSteps.filter((s) => s.status === "completed").map((s) => s.key));
803
867
  const newlyReady = keyedSteps.filter((s) => {
804
868
  if (s.status !== "pending") return false;
805
869
  return (s.dependencies ?? []).every((dep) => completedKeys.has(dep));
806
870
  });
807
- for (const readyStep of newlyReady) await dispatchReadyStep(workflowId, readyStep.id, readyStep.key, readyStep.type);
871
+ for (const readyStep of newlyReady) await dispatchReadyStep(workflowId, readyStep.id, readyStep.key, readyStep.type, scope);
872
+ return {
873
+ keyedSteps,
874
+ newlyReadyCount: newlyReady.length
875
+ };
876
+ }
877
+ /**
878
+ * Non-transactional half: decide whether the workflow is now failed or done.
879
+ * Deliberately outside any transaction — the failure path runs saga
880
+ * compensation, i.e. user handlers that may do network I/O.
881
+ */
882
+ async function finalizeAfterStepCompleted(workflowId, stepId, output, { keyedSteps, newlyReadyCount }) {
808
883
  if (keyedSteps.some((s) => s.id !== stepId && s.status === "failed")) {
809
884
  await checkWorkflowFailure(workflowId);
810
885
  return;
811
886
  }
812
- if (keyedSteps.every((s) => s.id === stepId || s.status === "completed" || s.status === "skipped") && newlyReady.length === 0) {
887
+ if (keyedSteps.every((s) => s.id === stepId || s.status === "completed" || s.status === "skipped") && newlyReadyCount === 0) {
813
888
  const aggregatedOutput = {};
814
889
  for (const s of keyedSteps) if (s.status === "completed" || s.id === stepId) aggregatedOutput[s.key] = s.id === stepId ? output : s.output ?? null;
815
890
  await store.finishWorkflow({
816
891
  workflowId,
817
892
  status: "completed",
818
893
  output: aggregatedOutput,
819
- completedAt
894
+ completedAt: nowIso()
820
895
  });
821
896
  logger.info("Workflow completed", { workflowId });
822
897
  emit({
@@ -877,6 +952,10 @@ function createWorkflowEngine(deps) {
877
952
  output,
878
953
  completedAt: nowIso()
879
954
  });
955
+ await advanceAfterChildCompleted(workflowId, childStep);
956
+ }
957
+ /** The aggregation half of a map child's completion, without the write. */
958
+ async function advanceAfterChildCompleted(workflowId, childStep) {
880
959
  const parentId = childStep.parentStepId;
881
960
  const children = await store.listChildSteps(parentId);
882
961
  if (children.some((c) => c.status === "failed")) {
@@ -61,6 +61,18 @@ interface ListWorkflowsFilters {
61
61
  entityRef?: string;
62
62
  limit?: number;
63
63
  }
64
+ /**
65
+ * A store bound to an open transaction, plus an opaque handle a capable
66
+ * {@link Dispatcher} can join so a job and the state change that produced it
67
+ * commit together. Core never inspects `handle` — it belongs to the adapter
68
+ * pair (e.g. store-pg hands dispatcher-pgboss its transaction-bound executor).
69
+ */
70
+ interface TransactionalScope {
71
+ /** A store whose writes go through the open transaction. */
72
+ store: WorkflowStore;
73
+ /** Opaque; pass to `Dispatcher.enqueueStepIn`. */
74
+ handle: unknown;
75
+ }
64
76
  /**
65
77
  * Persistence boundary for the engine. Implementations are **partition-scoped at
66
78
  * construction** (e.g. bound to one tenant) — exactly like the engine — so methods
@@ -72,6 +84,19 @@ interface ListWorkflowsFilters {
72
84
  * are computed from `listSteps`.
73
85
  */
74
86
  interface WorkflowStore {
87
+ /**
88
+ * **Optional capability.** Run `fn` inside one store transaction.
89
+ *
90
+ * When the dispatcher also implements `enqueueStepIn`, the engine uses both so
91
+ * that a state change and the dispatches it unlocks commit atomically —
92
+ * closing the window where a crash leaves a step `pending` with no job behind
93
+ * it. A store that cannot offer this simply omits it, and the engine falls
94
+ * back to writing then enqueueing.
95
+ *
96
+ * Implementations MUST run every operation on `scope.store` inside the same
97
+ * transaction, and MUST roll back if `fn` throws.
98
+ */
99
+ runInTransaction?<T>(fn: (scope: TransactionalScope) => Promise<T>): Promise<T>;
75
100
  /** Create the workflow + all step rows atomically (transactional). */
76
101
  createWorkflow(params: CreateWorkflowParams): Promise<CreatedWorkflow>;
77
102
  getWorkflow(workflowId: WorkflowId): Promise<WorkflowRecord | null>;
@@ -246,4 +271,4 @@ interface RecordingTracer extends FlowTracer {
246
271
  }
247
272
  declare function createRecordingTracer(): RecordingTracer;
248
273
  //#endregion
249
- export { CreateWorkflowStep as C, ListWorkflowsFilters as D, FinishWorkflowParams as E, WorkflowStore as O, CreateWorkflowParams as S, FailStepParams as T, StepGateDecision as _, FlowTracer as a, AddChildStep as b, RecordingTracer as c, noopObserver as d, noopTracer as f, StepGate as g, RateRule as h, FlowSpan as i, createRecordingObserver as l, InMemoryStepGateConfig as m, FlowEventType as n, RecordedSpan as o, ConcurrencyRule as p, FlowObserver as r, RecordingObserver as s, FlowEvent as t, createRecordingTracer as u, StepGateRequest as v, CreatedWorkflow as w, CompleteStepParams as x, createInMemoryStepGate as y };
274
+ export { CreateWorkflowStep as C, ListWorkflowsFilters as D, FinishWorkflowParams as E, TransactionalScope as O, CreateWorkflowParams as S, FailStepParams as T, StepGateDecision as _, FlowTracer as a, AddChildStep as b, RecordingTracer as c, noopObserver as d, noopTracer as f, StepGate as g, RateRule as h, FlowSpan as i, WorkflowStore as k, createRecordingObserver as l, InMemoryStepGateConfig as m, FlowEventType as n, RecordedSpan as o, ConcurrencyRule as p, FlowObserver as r, RecordingObserver as s, FlowEvent as t, createRecordingTracer as u, StepGateRequest as v, CreatedWorkflow as w, CompleteStepParams as x, createInMemoryStepGate as y };
@@ -1,5 +1,5 @@
1
1
  import { V as WorkflowId } from "../index-D_fOYfmf.js";
2
- import { O as WorkflowStore, g as StepGate, h as RateRule, p as ConcurrencyRule, r as FlowObserver, t as FlowEvent } from "../observability-BsrvbhDr.js";
2
+ import { g as StepGate, h as RateRule, k as WorkflowStore, p as ConcurrencyRule, r as FlowObserver, t as FlowEvent } from "../observability-3tcmRwIw.js";
3
3
  import { Pool } from "pg";
4
4
  //#region src/store-pg/ddl.d.ts
5
5
  /**
@@ -386,7 +386,27 @@ function createWorkflowStore(deps) {
386
386
  cutoff
387
387
  ])).rows.map(mapStep);
388
388
  }
389
+ /**
390
+ * Optional transaction capability (see `WorkflowStore.runInTransaction`). Hands
391
+ * back a store bound to the open transaction plus the transaction-bound
392
+ * executor as the opaque handle — `createPgBossDispatcher` knows how to enqueue
393
+ * through it, so a job and the state change that produced it commit together.
394
+ *
395
+ * Recursing is not supported (adapters never nest transactions); a nested call
396
+ * would reuse the same connection via `poolExecutor`, so it is simply the same
397
+ * transaction rather than a new one.
398
+ */
399
+ async function runInTransaction(fn) {
400
+ return exec.transaction((tx) => fn({
401
+ store: createWorkflowStore({
402
+ ...deps,
403
+ exec: tx
404
+ }),
405
+ handle: tx
406
+ }));
407
+ }
389
408
  return {
409
+ runInTransaction,
390
410
  createWorkflow,
391
411
  getWorkflow,
392
412
  getStep,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "octaflow",
3
- "version": "0.14.0",
3
+ "version": "0.16.0",
4
4
  "description": "Durable DAG workflow engine: Zod-typed steps over Postgres + pg-boss, with an optional AI add-on (token/cost/quota). Layered single package.",
5
5
  "license": "MIT",
6
6
  "type": "module",