octaflow 0.14.0 → 0.15.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
@@ -104,7 +104,7 @@ workflow is expressed**.
104
104
  | **Model** | declarative — a static DAG value | imperative workflow code | imperative step functions | imperative tasks | job queue; flows are **trees** |
105
105
  | **Fan-in / diamond deps** | yes | yes | yes | yes | no — a job can't be shared by two branches |
106
106
  | **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 |
107
+ | **Web dashboard** | **none** — [build one](https://octabits-io.github.io/octaflow/running/live-progress/) | yes | yes | yes | via third-party UIs |
108
108
  | **Languages** | TypeScript | polyglot SDKs | TS, Python, Go, Kotlin | TS | Node (+ ports) |
109
109
  | **Maturity** | **pre-1.0** | mature | mature | mature | mature, widely deployed |
110
110
 
@@ -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
@@ -73,7 +73,16 @@ function createPgBossDispatcher(deps) {
73
73
  ...deps.config
74
74
  };
75
75
  let ensured = false;
76
- return { async enqueueStep(payload, options) {
76
+ /**
77
+ * Adapt flow's `SqlExecutor` to the shape pg-boss wants for a caller-supplied
78
+ * connection. The two are the same idea under different names.
79
+ */
80
+ function asPgBossDb(handle) {
81
+ const exec = handle;
82
+ if (!exec || typeof exec.query !== "function") return void 0;
83
+ return { executeSql: (text, values) => exec.query(text, values) };
84
+ }
85
+ async function send(payload, options, handle) {
77
86
  try {
78
87
  const wire = {
79
88
  partitionKey,
@@ -92,11 +101,20 @@ function createPgBossDispatcher(deps) {
92
101
  ensured = true;
93
102
  }
94
103
  const startAfter = options?.startAfterSeconds;
104
+ const db = handle === void 0 ? void 0 : asPgBossDb(handle);
105
+ if (handle !== void 0 && !db) return {
106
+ ok: false,
107
+ error: {
108
+ key: "queue_error",
109
+ message: "Transaction handle is not a SqlExecutor — is the store store-pg?"
110
+ }
111
+ };
95
112
  if (!await boss.send(queueName, parsed.data, {
96
113
  retryLimit: cfg.retryLimit,
97
114
  retryDelay: cfg.retryDelay,
98
115
  expireInSeconds: cfg.expireInSeconds,
99
- ...startAfter != null && startAfter > 0 ? { startAfter } : {}
116
+ ...startAfter != null && startAfter > 0 ? { startAfter } : {},
117
+ ...db ? { db } : {}
100
118
  })) return {
101
119
  ok: false,
102
120
  error: {
@@ -117,7 +135,16 @@ function createPgBossDispatcher(deps) {
117
135
  }
118
136
  };
119
137
  }
120
- } };
138
+ }
139
+ return {
140
+ enqueueStep: (payload, options) => send(payload, options),
141
+ /**
142
+ * Enqueue on the store's open transaction, so the job and the state change
143
+ * that produced it commit together. `handle` is the transaction-bound
144
+ * `SqlExecutor` that `createWorkflowStore.runInTransaction` hands out.
145
+ */
146
+ enqueueStepIn: (handle, payload, options) => send(payload, options, handle)
147
+ };
121
148
  }
122
149
  /** A pg-boss worker that drives `executeStep` for step jobs. */
123
150
  function createPgBossStepWorker(deps) {
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.15.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",