octaflow 0.15.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
 
@@ -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.
@@ -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) {
@@ -275,7 +290,8 @@ function createPgBossScheduler(deps) {
275
290
  workflowType: input.workflowType,
276
291
  input: input.input ?? {},
277
292
  entityRef: input.entityRef,
278
- idempotencyKey: input.idempotencyKey
293
+ idempotencyKey: input.idempotencyKey,
294
+ idempotencyKeyPrefix: input.idempotencyKeyPrefix
279
295
  };
280
296
  await boss.schedule(queueName, input.cron, payload, {
281
297
  key: input.key,
@@ -309,7 +325,11 @@ function createPgBossStartWorker(deps) {
309
325
  logger?.error("Invalid start payload", void 0, { jobId: job.id });
310
326
  continue;
311
327
  }
312
- await process(parsed.data);
328
+ await process({
329
+ ...parsed.data,
330
+ jobId: job.id,
331
+ idempotencyKey: resolveStartIdempotencyKey(parsed.data, job.id)
332
+ });
313
333
  }
314
334
  });
315
335
  }
@@ -325,4 +345,4 @@ function createPgBossStartWorker(deps) {
325
345
  };
326
346
  }
327
347
  //#endregion
328
- 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "octaflow",
3
- "version": "0.15.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",