From 114fc7dc941a8843117a44166b5a3d22a30edf62 Mon Sep 17 00:00:00 2001 From: Carlos Mimoso Date: Sun, 1 Mar 2026 09:41:50 +0000 Subject: [PATCH 1/7] chore: update release workflow to include publishing to GitHub Packages --- .github/workflows/release.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f8d9db8..de1da21 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -10,6 +10,7 @@ on: permissions: contents: write id-token: write + packages: write jobs: release: @@ -50,3 +51,8 @@ jobs: - if: steps.release.outputs.new_release == 'true' run: npx nx release publish + + - if: steps.release.outputs.new_release == 'true' + run: npx nx release publish --registry=https://npm.pkg.github.com + env: + NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} From febff835ee9d49e42b805f15bff5abceb6284feb Mon Sep 17 00:00:00 2001 From: Carlos Mimoso Date: Mon, 18 May 2026 22:30:23 +0100 Subject: [PATCH 2/7] feat(prose): add skip-ahead checkpointing durability system --- packages/prose/eslint.config.mjs | 2 + .../src/lib/__tests__/durability.spec.ts | 486 ++++++++++++++++++ .../src/lib/__tests__/memory-store.spec.ts | 73 +++ .../lib/{ => __tests__}/pino-observer.spec.ts | 2 +- .../src/lib/__tests__/store-conformance.ts | 136 +++++ .../src/lib/{ => __tests__}/workflow.spec.ts | 2 +- packages/prose/src/lib/flow-executor.ts | 152 +++++- packages/prose/src/lib/index.ts | 3 + packages/prose/src/lib/memory-store.ts | 46 ++ packages/prose/src/lib/types.ts | 85 +++ packages/prose/tsconfig.lib.json | 1 + packages/prose/tsconfig.spec.json | 2 + 12 files changed, 984 insertions(+), 6 deletions(-) create mode 100644 packages/prose/src/lib/__tests__/durability.spec.ts create mode 100644 packages/prose/src/lib/__tests__/memory-store.spec.ts rename packages/prose/src/lib/{ => __tests__}/pino-observer.spec.ts (98%) create mode 100644 packages/prose/src/lib/__tests__/store-conformance.ts rename packages/prose/src/lib/{ => __tests__}/workflow.spec.ts (99%) create mode 100644 packages/prose/src/lib/memory-store.ts diff --git a/packages/prose/eslint.config.mjs b/packages/prose/eslint.config.mjs index 776f6f3..f45293f 100644 --- a/packages/prose/eslint.config.mjs +++ b/packages/prose/eslint.config.mjs @@ -11,6 +11,8 @@ export default [ ignoredFiles: [ '{projectRoot}/eslint.config.{js,cjs,mjs,ts,cts,mts}', '{projectRoot}/vite.config.{js,ts,mjs,mts}', + // Test files and shared test helpers (may import vitest) + '{projectRoot}/src/**/__tests__/**', ], ignoredDependencies: [ '@modelcontextprotocol/sdk', diff --git a/packages/prose/src/lib/__tests__/durability.spec.ts b/packages/prose/src/lib/__tests__/durability.spec.ts new file mode 100644 index 0000000..05816af --- /dev/null +++ b/packages/prose/src/lib/__tests__/durability.spec.ts @@ -0,0 +1,486 @@ +/** + * Tests for durability — skip-ahead checkpointing. + * + * These tests verify the observable behavior of `flow.execute()` with a + * `durability` option configured. Where possible, behavior is observed + * through handler call counts (via vi.fn) and execute() return values — + * not by inspecting checkpoint internals. Checkpoint contents are asserted + * only when the checkpoint shape itself is the contract under test. + * + * Adapter behavior (clone-on-read, size tracking, etc.) lives in + * memory-store.spec.ts. The shared adapter conformance suite lives in + * store-conformance.ts. + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { createFlow, MemoryDurabilityStore } from '../index.js'; +import type { FlowEvent } from '../index.js'; + +type EmptyDeps = Record; + +describe('Durability', () => { + let store: MemoryDurabilityStore; + + beforeEach(() => { + store = new MemoryDurabilityStore(); + }); + + describe('first run', () => { + it('returns final state on successful completion', async () => { + const flow = createFlow<{ x: number }, EmptyDeps>('test') + .step('double', (ctx) => ({ doubled: ctx.input.x * 2 })) + .build(); + + const result = await flow.execute( + { x: 21 }, + {}, + { durability: { store, runId: 'r1' } }, + ); + + expect(result).toEqual({ doubled: 42 }); + }); + + it('exposes per-step idempotencyKey in ctx.meta', async () => { + const seenKeys: Array = []; + const flow = createFlow('test') + .step('alpha', (ctx) => { + seenKeys.push(ctx.meta.idempotencyKey); + }) + .step('beta', (ctx) => { + seenKeys.push(ctx.meta.idempotencyKey); + }) + .build(); + + await flow.execute( + {}, + {}, + { durability: { store, runId: 'order-7' } }, + ); + + expect(seenKeys).toEqual(['order-7:alpha', 'order-7:beta']); + }); + + it('exposes runId and isResuming=false on a fresh run', async () => { + let observed: { runId?: string; isResuming?: boolean } | null = null; + const flow = createFlow('test') + .step('a', (ctx) => { + observed = { + runId: ctx.meta.runId, + isResuming: ctx.meta.isResuming, + }; + }) + .build(); + + await flow.execute({}, {}, { durability: { store, runId: 'r1' } }); + + expect(observed).toEqual({ runId: 'r1', isResuming: false }); + }); + }); + + describe('resume after crash', () => { + it('does not re-execute completed steps on resume', async () => { + const a = vi.fn(() => ({ aRan: true })); + let bAttempts = 0; + const b = vi.fn(() => { + bAttempts++; + if (bAttempts === 1) throw new Error('boom'); + return { bRan: true }; + }); + const c = vi.fn(() => ({ cRan: true })); + + const flow = createFlow('test') + .step('a', a) + .step('b', b) + .step('c', c) + .build(); + + await expect( + flow.execute({}, {}, { durability: { store, runId: 'r1' } }), + ).rejects.toThrow('boom'); + + // First run: a ran once, b ran once and threw, c didn't run + expect(a).toHaveBeenCalledTimes(1); + expect(b).toHaveBeenCalledTimes(1); + expect(c).not.toHaveBeenCalled(); + + const result = await flow.execute( + {}, + {}, + { durability: { store, runId: 'r1' } }, + ); + + // Resume: a was NOT re-run, b ran a second time and succeeded, c ran + expect(a).toHaveBeenCalledTimes(1); + expect(b).toHaveBeenCalledTimes(2); + expect(c).toHaveBeenCalledTimes(1); + expect(result).toEqual({ aRan: true, bRan: true, cRan: true }); + }); + + it('exposes isResuming=true on the resumed execution', async () => { + const seenResuming: boolean[] = []; + let firstAttempt = true; + const flow = createFlow('test') + .step('a', (ctx) => { + seenResuming.push(ctx.meta.isResuming === true); + }) + .step('b', (ctx) => { + seenResuming.push(ctx.meta.isResuming === true); + if (firstAttempt) { + firstAttempt = false; + throw new Error('boom'); + } + }) + .build(); + + await expect( + flow.execute({}, {}, { durability: { store, runId: 'r1' } }), + ).rejects.toThrow(); + await flow.execute({}, {}, { durability: { store, runId: 'r1' } }); + + // First run: a → false, b → false. Resume: a is skipped, b → true. + expect(seenResuming).toEqual([false, false, true]); + }); + + it('uses the saved input on resume, ignoring the input argument', async () => { + let observedInput: unknown; + let firstAttempt = true; + const flow = createFlow<{ value: number }, EmptyDeps>('test') + .step('a', () => ({ aRan: true })) + .step('b', (ctx) => { + observedInput = ctx.input; + if (firstAttempt) { + firstAttempt = false; + throw new Error('boom'); + } + }) + .build(); + + await expect( + flow.execute( + { value: 100 }, + {}, + { durability: { store, runId: 'r1' } }, + ), + ).rejects.toThrow(); + + // Resume with an intentionally different input — should be ignored + await flow.execute( + { value: 999 }, + {}, + { durability: { store, runId: 'r1' } }, + ); + + expect(observedInput).toEqual({ value: 100 }); + }); + + it('preserves createdAt across resumes', async () => { + let firstAttempt = true; + const flow = createFlow('test') + .step('a', () => ({ aRan: true })) + .step('b', () => { + if (firstAttempt) { + firstAttempt = false; + throw new Error('boom'); + } + }) + .build(); + + await expect( + flow.execute({}, {}, { durability: { store, runId: 'r1' } }), + ).rejects.toThrow(); + const afterFailure = await store.load('r1'); + const originalCreatedAt = afterFailure?.createdAt; + + await flow.execute({}, {}, { durability: { store, runId: 'r1' } }); + const afterCompletion = await store.load('r1'); + + expect(afterCompletion?.createdAt).toEqual(originalCreatedAt); + }); + }); + + describe('idempotent replay on completed runs', () => { + it('returns the saved result without re-executing on a completed run', async () => { + const handler = vi.fn(() => ({ v: 1 })); + const flow = createFlow('test') + .step('a', handler) + .build(); + + const first = await flow.execute( + {}, + {}, + { durability: { store, runId: 'r1' } }, + ); + expect(handler).toHaveBeenCalledTimes(1); + expect(first).toEqual({ v: 1 }); + + const second = await flow.execute( + {}, + {}, + { durability: { store, runId: 'r1' } }, + ); + expect(handler).toHaveBeenCalledTimes(1); + expect(second).toEqual({ v: 1 }); + }); + }); + + describe('breakIf interaction', () => { + it('replays the break value without re-executing the flow', async () => { + const aHandler = vi.fn(() => ({ shouldBreak: true })); + const cHandler = vi.fn(() => ({ cRan: true })); + + const flow = createFlow('test') + .step('a', aHandler) + .breakIf( + (ctx) => ctx.state.shouldBreak === true, + () => ({ broken: true }), + ) + .step('c', cHandler) + .build(); + + const first = await flow.execute( + {}, + {}, + { durability: { store, runId: 'r1' } }, + ); + expect(first).toEqual({ broken: true }); + expect(cHandler).not.toHaveBeenCalled(); + + const second = await flow.execute( + {}, + {}, + { durability: { store, runId: 'r1' } }, + ); + expect(second).toEqual({ broken: true }); + // Confirm replay short-circuits the entire execution, not just step c + expect(aHandler).toHaveBeenCalledTimes(1); + expect(cHandler).not.toHaveBeenCalled(); + }); + + it('a non-breaking breakIf does not block resume of subsequent steps', async () => { + const cHandler = vi.fn(() => ({ cRan: true })); + let cAttempt = 0; + const flakyC = vi.fn(() => { + cAttempt++; + if (cAttempt === 1) throw new Error('boom'); + return cHandler(); + }); + + const flow = createFlow('test') + .step('a', () => ({ shouldBreak: false })) + .breakIf((ctx) => ctx.state.shouldBreak === true) + .step('c', flakyC) + .build(); + + await expect( + flow.execute({}, {}, { durability: { store, runId: 'r1' } }), + ).rejects.toThrow(); + + const result = await flow.execute( + {}, + {}, + { durability: { store, runId: 'r1' } }, + ); + expect(result).toEqual({ shouldBreak: false, cRan: true }); + expect(cHandler).toHaveBeenCalledTimes(1); + expect(flakyC).toHaveBeenCalledTimes(2); + }); + }); + + describe('conditional steps', () => { + it('does not re-evaluate a skipped condition on resume', async () => { + const skippedHandler = vi.fn(() => ({ shouldNotMerge: true })); + const conditionCheck = vi.fn( + (ctx: { state: { skip?: boolean } }) => ctx.state.skip !== true, + ); + let firstAttempt = true; + const flow = createFlow('test') + .step('a', () => ({ skip: true })) + .stepIf('b', conditionCheck, skippedHandler) + .step('c', () => { + if (firstAttempt) { + firstAttempt = false; + throw new Error('boom'); + } + return { cRan: true }; + }) + .build(); + + await expect( + flow.execute({}, {}, { durability: { store, runId: 'r1' } }), + ).rejects.toThrow(); + + expect(conditionCheck).toHaveBeenCalledTimes(1); + expect(skippedHandler).not.toHaveBeenCalled(); + + await flow.execute({}, {}, { durability: { store, runId: 'r1' } }); + + // Condition was recorded as decided; resume must NOT re-evaluate it + expect(conditionCheck).toHaveBeenCalledTimes(1); + expect(skippedHandler).not.toHaveBeenCalled(); + }); + }); + + describe('step type integration', () => { + it('checkpoints a retried step once after retries succeed, not per attempt', async () => { + let attempts = 0; + const flow = createFlow('test') + .step('flaky', () => { + attempts++; + if (attempts < 3) throw new Error('transient'); + return { ok: true }; + }) + .withRetry({ maxAttempts: 5, delayMs: 1 }) + .build(); + + await flow.execute({}, {}, { durability: { store, runId: 'r1' } }); + + expect(attempts).toBe(3); + + // The retry contract: completedSteps grows by exactly one entry for the + // retried step, regardless of how many attempts it took. This IS the + // checkpoint contract under test, so direct inspection is appropriate. + const cp = await store.load('r1'); + expect(cp?.completedSteps).toEqual(['flaky']); + }); + + it('re-runs a transaction on resume when it failed before checkpoint', async () => { + let txAttempts = 0; + const db = { + transaction: async (fn: (tx: unknown) => Promise): Promise => { + txAttempts++; + return fn({}); + }, + }; + let shouldFail = true; + const flow = createFlow('test') + .transaction('write', async () => { + if (shouldFail) { + shouldFail = false; + throw new Error('boom'); + } + return { written: true }; + }) + .build(); + + await expect( + flow.execute( + {}, + { db }, + { durability: { store, runId: 'r1' } }, + ), + ).rejects.toThrow(); + + const result = await flow.execute( + {}, + { db }, + { durability: { store, runId: 'r1' } }, + ); + + expect(txAttempts).toBe(2); + expect(result).toEqual({ written: true }); + }); + + it('does not re-publish events when replaying a completed run', async () => { + const published: FlowEvent[] = []; + const eventPublisher = { + publish: (_channel: string, event: FlowEvent) => { + published.push(event); + }, + }; + + const flow = createFlow< + unknown, + { eventPublisher: typeof eventPublisher } + >('test') + .event('orders', () => ({ eventType: 'order.created' })) + .build(); + + await flow.execute( + {}, + { eventPublisher }, + { durability: { store, runId: 'r1' } }, + ); + await flow.execute( + {}, + { eventPublisher }, + { durability: { store, runId: 'r1' } }, + ); + + expect(published).toHaveLength(1); + expect(published[0].eventType).toBe('order.created'); + }); + + it('treats a parallel block as atomic — all handlers re-run if any fails', async () => { + const aCount = vi.fn(() => ({ a: 1 })); + const bCount = vi.fn(() => ({ b: 2 })); + let cShouldFail = true; + const cCount = vi.fn(() => { + if (cShouldFail) { + cShouldFail = false; + throw new Error('boom'); + } + return { c: 3 }; + }); + + const flow = createFlow('test') + .parallel('fanout', 'shallow', aCount, bCount, cCount) + .build(); + + await expect( + flow.execute({}, {}, { durability: { store, runId: 'r1' } }), + ).rejects.toThrow(); + + expect(aCount).toHaveBeenCalledTimes(1); + expect(bCount).toHaveBeenCalledTimes(1); + expect(cCount).toHaveBeenCalledTimes(1); + + const result = await flow.execute( + {}, + {}, + { durability: { store, runId: 'r1' } }, + ); + + // Parallel is atomic: all three handlers re-run on resume, not just c + expect(aCount).toHaveBeenCalledTimes(2); + expect(bCount).toHaveBeenCalledTimes(2); + expect(cCount).toHaveBeenCalledTimes(2); + expect(result).toEqual({ a: 1, b: 2, c: 3 }); + }); + }); + + describe('zero-impact when durability is absent', () => { + it('writes nothing to any store when no durability option is passed', async () => { + const flow = createFlow('test') + .step('a', () => ({ v: 1 })) + .build(); + + const result = await flow.execute({}, {}); + expect(result).toEqual({ v: 1 }); + expect(store.size()).toBe(0); + }); + + it('does not expose runId, idempotencyKey, or isResuming in meta', async () => { + let captured: { + runId?: string; + idempotencyKey?: string; + isResuming?: boolean; + } | null = null; + const flow = createFlow('test') + .step('a', (ctx) => { + captured = { + runId: ctx.meta.runId, + idempotencyKey: ctx.meta.idempotencyKey, + isResuming: ctx.meta.isResuming, + }; + }) + .build(); + + await flow.execute({}, {}); + + expect(captured).not.toBeNull(); + expect(captured!.runId).toBeUndefined(); + expect(captured!.idempotencyKey).toBeUndefined(); + expect(captured!.isResuming).toBeUndefined(); + }); + }); +}); diff --git a/packages/prose/src/lib/__tests__/memory-store.spec.ts b/packages/prose/src/lib/__tests__/memory-store.spec.ts new file mode 100644 index 0000000..31162e3 --- /dev/null +++ b/packages/prose/src/lib/__tests__/memory-store.spec.ts @@ -0,0 +1,73 @@ +/** + * Tests for the in-memory DurabilityStore adapter. + * + * Two layers: + * 1. The shared {@link storeConformanceSuite} verifies the public + * {@link DurabilityStore} contract — every adapter must pass these. + * 2. The adapter-specific block below tests invariants particular to + * the in-memory implementation (clone-on-read isolation). + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { MemoryDurabilityStore } from '../index.js'; +import { storeConformanceSuite } from './store-conformance.js'; +import type { FlowCheckpoint } from '../index.js'; + +storeConformanceSuite('MemoryDurabilityStore', () => new MemoryDurabilityStore()); + +function fixture(): FlowCheckpoint { + return { + flowName: 'f', + runId: 'r1', + input: { v: 1 }, + state: { v: 1 }, + completedSteps: ['a'], + status: 'running', + createdAt: new Date(), + updatedAt: new Date(), + }; +} + +describe('MemoryDurabilityStore — adapter-specific invariants', () => { + let store: MemoryDurabilityStore; + + beforeEach(() => { + store = new MemoryDurabilityStore(); + }); + + it('returns cloned state on load — external mutation does not leak back', async () => { + await store.save(fixture()); + + const cp1 = await store.load('r1'); + (cp1!.state as Record).v = 999; + + const cp2 = await store.load('r1'); + expect((cp2!.state as Record).v).toBe(1); + }); + + it('clones on save — caller can mutate their copy after saving', async () => { + const original = fixture(); + await store.save(original); + (original.state as Record).v = 999; + + const loaded = await store.load('r1'); + expect((loaded!.state as Record).v).toBe(1); + }); + + it('size() reports the number of stored runs (test helper)', async () => { + expect(store.size()).toBe(0); + await store.save(fixture()); + expect(store.size()).toBe(1); + await store.delete('r1'); + expect(store.size()).toBe(0); + }); + + it('clear() drops all entries (test helper)', async () => { + await store.save(fixture()); + await store.save({ ...fixture(), runId: 'r2' }); + expect(store.size()).toBe(2); + + store.clear(); + expect(store.size()).toBe(0); + }); +}); diff --git a/packages/prose/src/lib/pino-observer.spec.ts b/packages/prose/src/lib/__tests__/pino-observer.spec.ts similarity index 98% rename from packages/prose/src/lib/pino-observer.spec.ts rename to packages/prose/src/lib/__tests__/pino-observer.spec.ts index df9372e..2f7b19a 100644 --- a/packages/prose/src/lib/pino-observer.spec.ts +++ b/packages/prose/src/lib/__tests__/pino-observer.spec.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { PinoFlowObserver, type PinoLike } from './pino-observer.js'; +import { PinoFlowObserver, type PinoLike } from '../pino-observer.js'; function createMockLogger(): PinoLike { const logger: PinoLike = { diff --git a/packages/prose/src/lib/__tests__/store-conformance.ts b/packages/prose/src/lib/__tests__/store-conformance.ts new file mode 100644 index 0000000..09d3923 --- /dev/null +++ b/packages/prose/src/lib/__tests__/store-conformance.ts @@ -0,0 +1,136 @@ +/** + * Shared conformance suite for {@link DurabilityStore} implementations. + * + * Used by every adapter's spec file to verify the public contract is upheld. + * Not exported from the package's main entry — adapter packages in this repo + * import via deep path; external adapter authors can copy or vendor this file. + * + * The suite uses only the public {@link DurabilityStore} surface — adapter- + * specific invariants (e.g. clone-on-read for in-memory implementations) + * should be tested separately in the adapter's own spec. + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import type { DurabilityStore, FlowCheckpoint } from '../types.js'; + +function fixture(overrides: Partial = {}): FlowCheckpoint { + const now = new Date('2026-01-01T00:00:00Z'); + return { + flowName: 'fixture-flow', + runId: 'fixture-run', + input: { value: 1 }, + state: { ranA: true }, + completedSteps: ['a'], + status: 'running', + createdAt: now, + updatedAt: now, + ...overrides, + }; +} + +export function storeConformanceSuite( + name: string, + makeStore: () => DurabilityStore, +): void { + describe(`DurabilityStore conformance: ${name}`, () => { + let store: DurabilityStore; + + beforeEach(() => { + store = makeStore(); + }); + + it('load() returns null for an unknown runId', async () => { + expect(await store.load('no-such-run')).toBeNull(); + }); + + it('save() then load() round-trips the checkpoint', async () => { + const cp = fixture({ runId: 'r1' }); + await store.save(cp); + + const loaded = await store.load('r1'); + expect(loaded).not.toBeNull(); + expect(loaded?.flowName).toBe('fixture-flow'); + expect(loaded?.runId).toBe('r1'); + expect(loaded?.input).toEqual({ value: 1 }); + expect(loaded?.state).toEqual({ ranA: true }); + expect(loaded?.completedSteps).toEqual(['a']); + expect(loaded?.status).toBe('running'); + }); + + it('save() overwrites an existing entry for the same runId', async () => { + await store.save(fixture({ runId: 'r1', status: 'running' })); + await store.save( + fixture({ runId: 'r1', status: 'completed', state: { final: true } }), + ); + + const loaded = await store.load('r1'); + expect(loaded?.status).toBe('completed'); + expect(loaded?.state).toEqual({ final: true }); + }); + + it('save() preserves the failedStep field when present', async () => { + await store.save( + fixture({ + runId: 'r1', + status: 'failed', + failedStep: { name: 'chargePayment', error: 'declined' }, + }), + ); + + const loaded = await store.load('r1'); + expect(loaded?.status).toBe('failed'); + expect(loaded?.failedStep).toEqual({ + name: 'chargePayment', + error: 'declined', + }); + }); + + it('save() preserves the breakValue field when present (including undefined values)', async () => { + // breakValue may legitimately be undefined; the field's presence is + // what distinguishes "broke" from "completed normally". + await store.save( + fixture({ + runId: 'normal', + status: 'completed', + }), + ); + await store.save( + fixture({ + runId: 'broke-with-value', + status: 'completed', + breakValue: { broken: true }, + }), + ); + + const normal = await store.load('normal'); + const broken = await store.load('broke-with-value'); + + expect('breakValue' in (normal ?? {})).toBe(false); + expect(broken?.breakValue).toEqual({ broken: true }); + }); + + it('delete() removes the entry', async () => { + await store.save(fixture({ runId: 'r1' })); + expect(await store.load('r1')).not.toBeNull(); + + await store.delete('r1'); + expect(await store.load('r1')).toBeNull(); + }); + + it('delete() of a non-existent runId is a no-op', async () => { + await expect(store.delete('never-existed')).resolves.not.toThrow(); + }); + + it('isolates runIds — saving one does not affect another', async () => { + await store.save(fixture({ runId: 'a', state: { for: 'a' } })); + await store.save(fixture({ runId: 'b', state: { for: 'b' } })); + + expect((await store.load('a'))?.state).toEqual({ for: 'a' }); + expect((await store.load('b'))?.state).toEqual({ for: 'b' }); + + await store.delete('a'); + expect(await store.load('a')).toBeNull(); + expect(await store.load('b')).not.toBeNull(); + }); + }); +} diff --git a/packages/prose/src/lib/workflow.spec.ts b/packages/prose/src/lib/__tests__/workflow.spec.ts similarity index 99% rename from packages/prose/src/lib/workflow.spec.ts rename to packages/prose/src/lib/__tests__/workflow.spec.ts index 5aefd35..9b64efd 100644 --- a/packages/prose/src/lib/workflow.spec.ts +++ b/packages/prose/src/lib/__tests__/workflow.spec.ts @@ -7,7 +7,7 @@ import { createFlow, ValidationError, TimeoutError, -} from './index.js'; +} from '../index.js'; type EmptyDeps = Record; diff --git a/packages/prose/src/lib/flow-executor.ts b/packages/prose/src/lib/flow-executor.ts index a9ef5c0..6a0821f 100644 --- a/packages/prose/src/lib/flow-executor.ts +++ b/packages/prose/src/lib/flow-executor.ts @@ -17,8 +17,70 @@ import type { FlowExecutionResult, FlowEvent, FlowEventPublisher, + DurabilityOptions, + FlowCheckpoint, } from './types.js'; +/** + * @internal + * Discriminated union describing the four kinds of checkpoint writes. + * Internal protocol between the executor loop and {@link createCheckpointWriter} + * — translated into a {@link FlowCheckpoint} before reaching any store. + * + * `breakValue` is wrapped in a `{ value }` envelope so that `undefined` can + * be a valid break return value while still being distinguishable from + * "this was not a break completion." + */ +type CheckpointWrite = + | { kind: 'running' } + | { kind: 'completed'; breakValue?: { value: unknown } } + | { kind: 'failed'; failedStep: { name: string; error: string } }; + +/** + * @internal + * Build a checkpoint-writer bound to the invariant fields of a single run. + * Returns a no-op when durability is not configured, so call sites stay + * branch-free. + */ +function createCheckpointWriter( + durability: DurabilityOptions | undefined, + flowName: string, + input: unknown, + createdAt: Date, +): ( + state: unknown, + completedSteps: ReadonlySet, + write: CheckpointWrite, +) => Promise { + if (!durability) { + return async () => { + /* no-op */ + }; + } + return async (state, completedSteps, write) => { + const status: FlowCheckpoint['status'] = + write.kind === 'running' ? 'running' + : write.kind === 'failed' ? 'failed' + : 'completed'; + + const checkpoint: FlowCheckpoint = { + flowName, + runId: durability.runId, + input, + state, + completedSteps: [...completedSteps], + status, + createdAt, + updatedAt: new Date(), + ...(write.kind === 'completed' && write.breakValue + ? { breakValue: write.breakValue.value } + : {}), + ...(write.kind === 'failed' ? { failedStep: write.failedStep } : {}), + }; + await durability.store.save(checkpoint); + }; +} + /** * Throws if the signal is already aborted. * When the abort was caused by a TimeoutError, re-throws the original error. @@ -104,10 +166,46 @@ export class FlowExecutor< options?: FlowExecutionOptions, ): Promise> { const startTime = Date.now(); + const durability = options?.durability; + + // Load existing checkpoint if durability is configured. + // If the run already completed, return the saved value without re-executing. + let checkpoint: FlowCheckpoint | null = null; + if (durability) { + checkpoint = await durability.store.load(durability.runId); + if (checkpoint?.status === 'completed') { + const hasBreakValue = 'breakValue' in checkpoint; + return { + value: (hasBreakValue + ? checkpoint.breakValue + : checkpoint.state) as TState, + didBreak: hasBreakValue, + }; + } + } + + // On resume, the checkpoint's input/state/completedSteps are authoritative. + // The caller's `input` argument is ignored on resume to keep state deterministic. + const isResuming = checkpoint !== null; + const effectiveInput = (checkpoint ? checkpoint.input : input) as TInput; + const completedSteps = new Set(checkpoint?.completedSteps ?? []); + const originalCreatedAt = checkpoint?.createdAt ?? new Date(startTime); + const persist = createCheckpointWriter( + durability, + config.name, + effectiveInput, + originalCreatedAt, + ); + const meta: FlowMeta = { flowName: config.name, startedAt: new Date(startTime), correlationId: options?.correlationId, + // Durability-only fields. Left undefined when durability isn't configured + // so handlers can rely on `meta.runId !== undefined` to detect durable runs. + ...(durability + ? { runId: durability.runId, isResuming } + : {}), }; const observer = options?.observer; @@ -139,15 +237,15 @@ export class FlowExecutor< // Initialize context let context: FlowContext = { - input: Object.freeze(input), - state: {} as TState, + input: Object.freeze(effectiveInput), + state: (checkpoint?.state ?? {}) as TState, deps, meta, signal: flowSignal, }; // Notify observer of flow start - observer?.onFlowStart?.(config.name, input); + observer?.onFlowStart?.(config.name, effectiveInput); try { // Execute each step in sequence @@ -155,13 +253,25 @@ export class FlowExecutor< // Check if flow has been aborted before starting next step throwIfAborted(flowSignal); + // Skip steps already recorded as completed in a prior run. + // Their state is already merged in via the loaded checkpoint. + if (completedSteps.has(step.name)) { + continue; + } + // Update current step in meta context.meta.currentStep = step.name; + if (durability) { + context.meta.idempotencyKey = `${durability.runId}:${step.name}`; + } // Check condition if present if (step.condition && !step.condition(context)) { observer?.onStepSkipped?.(step.name, context); - continue; // Skip this step + // Persist the skip so resume doesn't re-evaluate the condition. + completedSteps.add(step.name); + await persist(context.state, completedSteps, { kind: 'running' }); + continue; } // Handle break step type - short-circuits the flow if condition is met @@ -180,6 +290,14 @@ export class FlowExecutor< const duration = Date.now() - stepStart; const totalDuration = Date.now() - startTime; + // Persist completed-with-break BEFORE notifying observers, so a + // crash in observer code doesn't lose the completion record. + completedSteps.add(step.name); + await persist(context.state, completedSteps, { + kind: 'completed', + breakValue: { value: breakResult }, + }); + // Notify observers observer?.onStepComplete?.(step.name, breakResult, duration, context); observer?.onFlowBreak?.( @@ -201,6 +319,8 @@ export class FlowExecutor< duration, context, ); + completedSteps.add(step.name); + await persist(context.state, completedSteps, { kind: 'running' }); continue; } @@ -214,8 +334,17 @@ export class FlowExecutor< state: { ...context.state, ...result }, }; } + + // Persist after each successful step. The step is only added to + // completedSteps AFTER its result has been merged into state — so + // a crash between handler success and this save will re-run the step. + completedSteps.add(step.name); + await persist(context.state, completedSteps, { kind: 'running' }); } + // Mark run as completed. Persist BEFORE observer notifications. + await persist(context.state, completedSteps, { kind: 'completed' }); + // Return the final state as output const totalDuration = Date.now() - startTime; observer?.onFlowComplete?.(config.name, context.state, totalDuration); @@ -223,6 +352,21 @@ export class FlowExecutor< return { value: context.state, didBreak: false }; } catch (error) { const totalDuration = Date.now() - startTime; + + // Best-effort failure persistence. Swallow store errors here so the + // original flow error is what the caller sees. + try { + await persist(context.state, completedSteps, { + kind: 'failed', + failedStep: { + name: context.meta.currentStep ?? '?', + error: (error as Error).message, + }, + }); + } catch { + // ignore + } + observer?.onFlowError?.(config.name, error as Error, totalDuration); // If the caller wants no exception, return the partial state diff --git a/packages/prose/src/lib/index.ts b/packages/prose/src/lib/index.ts index 86a6653..20928b1 100644 --- a/packages/prose/src/lib/index.ts +++ b/packages/prose/src/lib/index.ts @@ -19,3 +19,6 @@ export type { FlowObserver } from './observer.js'; export { DefaultObserver, NoOpObserver } from './observer.js'; export { PinoFlowObserver, type PinoLike } from './pino-observer.js'; +// Export durability store implementation +export { MemoryDurabilityStore } from './memory-store.js'; + diff --git a/packages/prose/src/lib/memory-store.ts b/packages/prose/src/lib/memory-store.ts new file mode 100644 index 0000000..5bc4f83 --- /dev/null +++ b/packages/prose/src/lib/memory-store.ts @@ -0,0 +1,46 @@ +/** + * In-memory DurabilityStore — intended for tests, single-process dev, and + * as a reference implementation for adapter authors. + * + * For production single-node use, prefer a SQLite-backed store. For multi- + * worker deployments, use Postgres or another shared store. + */ + +import type { DurabilityStore, FlowCheckpoint } from './types.js'; + +function clone(checkpoint: FlowCheckpoint): FlowCheckpoint { + return structuredClone(checkpoint); +} + +export class MemoryDurabilityStore implements DurabilityStore { + private readonly checkpoints = new Map(); + + async load(runId: string): Promise { + const cp = this.checkpoints.get(runId); + return cp ? clone(cp) : null; + } + + async save(checkpoint: FlowCheckpoint): Promise { + this.checkpoints.set(checkpoint.runId, clone(checkpoint)); + } + + async delete(runId: string): Promise { + this.checkpoints.delete(runId); + } + + /** Test helper: number of stored runs. */ + size(): number { + return this.checkpoints.size; + } + + /** Test helper: synchronous snapshot of a checkpoint (returns a clone). */ + snapshot(runId: string): FlowCheckpoint | null { + const cp = this.checkpoints.get(runId); + return cp ? clone(cp) : null; + } + + /** Test helper: drop everything. */ + clear(): void { + this.checkpoints.clear(); + } +} diff --git a/packages/prose/src/lib/types.ts b/packages/prose/src/lib/types.ts index f8ddd40..855cffd 100644 --- a/packages/prose/src/lib/types.ts +++ b/packages/prose/src/lib/types.ts @@ -49,6 +49,74 @@ export interface FlowEventPublisher { publish(channel: string, event: FlowEvent): Promise | void; } +// ────────────────────────────────────────────────────────── +// Durability interfaces +// ────────────────────────────────────────────────────────── + +/** + * Persistent state of a single flow run. The store reads/writes this object + * verbatim — adapters are responsible for serialization (typically JSON). + * + * Steps may run more than once across resumes if the process dies between + * a step's success and its checkpoint write. Handlers must therefore be + * idempotent — typically by passing `ctx.meta.idempotencyKey` to external + * APIs that support it. + */ +export interface FlowCheckpoint { + flowName: string; + runId: string; + /** The input the flow was first started with. Used verbatim on resume. */ + input: unknown; + /** Accumulated state at the moment of the last successful step. */ + state: unknown; + /** Names of steps that have completed (or been skipped by condition). */ + completedSteps: string[]; + status: 'running' | 'completed' | 'failed'; + /** + * When status is 'completed' AND the flow short-circuited via breakIf, + * holds the break return value (bypasses .map()). + * Distinct from `state`: a flow completing normally has `breakValue === undefined`. + */ + breakValue?: unknown; + /** When status is 'failed', the step that errored after all retries. */ + failedStep?: { name: string; error: string }; + createdAt: Date; + updatedAt: Date; +} + +/** + * Pluggable durability store. Adapters may live in separate packages + * (e.g. @celom/prose-store-sqlite, @celom/prose-store-postgres). + * + * Implementations should serialize checkpoints atomically — partial writes + * defeat the purpose. Adapters MAY clone on read/write to prevent the + * executor from mutating stored state. + */ +export interface DurabilityStore { + /** Return the checkpoint for `runId`, or `null` if none exists. */ + load(runId: string): Promise; + /** Persist the checkpoint, overwriting any existing entry for the same runId. */ + save(checkpoint: FlowCheckpoint): Promise; + /** Remove the checkpoint. Optional housekeeping for callers. */ + delete(runId: string): Promise; +} + +/** + * Durability configuration for a flow execution. + * + * Passing the same `runId` to `execute()` after a crash resumes from the + * next undone step. Passing the same `runId` after completion returns the + * saved result without re-executing. + */ +export interface DurabilityOptions { + store: DurabilityStore; + /** + * Stable identifier for this run. Same runId → resume or replay; new runId → fresh run. + * Typically derived from a business identifier (e.g., the order ID). + */ + runId: string; +} + /** * Base dependencies required by all flows. Extend this with additional dependencies */ @@ -67,6 +135,16 @@ export interface FlowMeta { startedAt: Date; currentStep?: string; correlationId?: string; + /** + * Stable per-step idempotency key (`${runId}:${currentStep}`). + * Present only when durability is configured. Pass this to external APIs + * that support idempotency (Stripe, SQS, etc.) so re-runs are safe. + */ + idempotencyKey?: string; + /** The DurabilityOptions.runId for this execution, if durability is configured. */ + runId?: string; + /** True when this execution loaded an existing checkpoint rather than starting fresh. */ + isResuming?: boolean; } /** @@ -127,6 +205,13 @@ export interface FlowExecutionOptions< signal?: AbortSignal; observer?: FlowObserver; errorHandling?: ErrorHandlingConfig; + /** + * Opt-in durability. When set, each successful step persists a checkpoint + * to the configured store. Re-invoking execute() with the same runId + * resumes from the next undone step (or returns the saved result if the + * run already completed). + */ + durability?: DurabilityOptions; } /** diff --git a/packages/prose/tsconfig.lib.json b/packages/prose/tsconfig.lib.json index e9e008f..9b4ba63 100644 --- a/packages/prose/tsconfig.lib.json +++ b/packages/prose/tsconfig.lib.json @@ -16,6 +16,7 @@ "vite.config.mts", "vitest.config.ts", "vitest.config.mts", + "src/**/__tests__/**", "src/**/*.test.ts", "src/**/*.spec.ts", "src/**/*.test.tsx", diff --git a/packages/prose/tsconfig.spec.json b/packages/prose/tsconfig.spec.json index f68d9d3..e7f737c 100644 --- a/packages/prose/tsconfig.spec.json +++ b/packages/prose/tsconfig.spec.json @@ -16,6 +16,8 @@ "vite.config.mts", "vitest.config.ts", "vitest.config.mts", + "src/**/__tests__/**/*.ts", + "src/**/__tests__/**/*.tsx", "src/**/*.test.ts", "src/**/*.spec.ts", "src/**/*.test.tsx", From 74615de880d73bd31a95d866debdd35c4a98f8c1 Mon Sep 17 00:00:00 2001 From: Carlos Mimoso Date: Mon, 18 May 2026 23:25:03 +0100 Subject: [PATCH 3/7] docs: add durability guide, example, and API reference Documents the skip-ahead checkpointing system shipped in febff83. Adds a primary durability guide (concepts, idempotency contract, feature-interaction matrix, "what this isn't"), an extended order-processing example walking through a concrete crash scenario, and a DurabilityStore API reference. Updates execution-options, comparison, types, index, both READMEs, and renumbers sidebar orders to fit the new pages. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 2 +- .../src/content/docs/api/durability-store.mdx | 129 ++++++++++ .../docs/src/content/docs/api/error-types.mdx | 2 +- .../content/docs/api/execution-options.mdx | 18 ++ apps/docs/src/content/docs/api/observers.mdx | 2 +- apps/docs/src/content/docs/api/types.mdx | 16 +- apps/docs/src/content/docs/comparison.mdx | 16 +- .../order-processing-with-durability.mdx | 212 ++++++++++++++++ .../content/docs/examples/user-onboarding.mdx | 2 +- .../src/content/docs/guides/durability.mdx | 240 ++++++++++++++++++ .../src/content/docs/guides/observability.mdx | 2 +- .../content/docs/guides/project-structure.mdx | 2 +- apps/docs/src/content/docs/index.mdx | 3 + packages/prose/README.md | 61 ++++- 14 files changed, 692 insertions(+), 15 deletions(-) create mode 100644 apps/docs/src/content/docs/api/durability-store.mdx create mode 100644 apps/docs/src/content/docs/examples/order-processing-with-durability.mdx create mode 100644 apps/docs/src/content/docs/guides/durability.mdx diff --git a/README.md b/README.md index b643527..15797c2 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ const flow = createFlow<{ orderId: string }>('process-order') await flow.execute({ orderId: 'ord_123' }, { db, eventPublisher }); ``` -Type-safe state threading, retries with exponential backoff, timeouts, database transactions, event publishing, parallel execution, and observability hooks — using plain async/await with zero dependencies. +Type-safe state threading, retries with exponential backoff, timeouts, database transactions, event publishing, parallel execution, durability, and observability hooks — using plain async/await with zero dependencies. ## Packages diff --git a/apps/docs/src/content/docs/api/durability-store.mdx b/apps/docs/src/content/docs/api/durability-store.mdx new file mode 100644 index 0000000..ac5a5b0 --- /dev/null +++ b/apps/docs/src/content/docs/api/durability-store.mdx @@ -0,0 +1,129 @@ +--- +title: Durability Store +description: API reference for DurabilityStore, FlowCheckpoint, DurabilityOptions, and MemoryDurabilityStore. +sidebar: + order: 4 +--- + +import { Aside } from '@astrojs/starlight/components'; + +For the conceptual overview and patterns, see the [durability guide](/prose/guides/durability/). + +## `DurabilityStore` + +Pluggable persistence layer for flow checkpoints. Adapters may live in separate packages. + +```typescript +interface DurabilityStore { + load(runId: string): Promise; + save(checkpoint: FlowCheckpoint): Promise; + delete(runId: string): Promise; +} +``` + +| Method | Semantics | +|--------|-----------| +| `load(runId)` | Return the checkpoint for `runId`, or `null` if none exists. Must not throw on unknown ids. | +| `save(checkpoint)` | Persist the checkpoint, overwriting any existing entry for the same `runId`. Must be atomic — partial writes defeat checkpointing. | +| `delete(runId)` | Remove the checkpoint. No-op for an unknown `runId` — must not throw. | + +Implementations are responsible for serialization. JSON is the typical choice; whatever you pick must round-trip `Date` objects (`createdAt`, `updatedAt`) and support `undefined` for `breakValue` slots that are absent. + +## `FlowCheckpoint` + +The shape persisted to the store. Adapters store and return this verbatim. + +```typescript +interface FlowCheckpoint { + flowName: string; + runId: string; + input: unknown; + state: unknown; + completedSteps: string[]; + status: 'running' | 'completed' | 'failed'; + breakValue?: unknown; + failedStep?: { name: string; error: string }; + createdAt: Date; + updatedAt: Date; +} +``` + +| Field | Description | +|-------|-------------| +| `flowName` | The flow's name (from `createFlow('name')`). Useful for diagnostics; not used to route resume. | +| `runId` | The `DurabilityOptions.runId` of this run. | +| `input` | The argument the flow was first started with. Used verbatim on resume — the caller's second `input` argument is ignored. | +| `state` | Accumulated state at the moment of the last successful step. | +| `completedSteps` | Names of finished or condition-skipped steps, in execution order. | +| `status` | `'running'` (in progress or crashed), `'completed'` (saved result is final), `'failed'` (errored after exhausting retries). | +| `breakValue` | Only present when the flow short-circuited via `breakIf`. The field's _presence_ distinguishes "broke" from "completed normally" — the value itself may be `undefined`. | +| `failedStep` | Only present when `status === 'failed'`. The step that errored, plus its error message. | +| `createdAt` | When the run first started. Preserved across resumes. | +| `updatedAt` | When the checkpoint was last written. | + +## `DurabilityOptions` + +The shape passed via `FlowExecutionOptions.durability`. + +```typescript +interface DurabilityOptions { + store: DurabilityStore; + runId: string; +} +``` + +| Field | Description | +|-------|-------------| +| `store` | The store to read and write checkpoints through. | +| `runId` | Stable identifier for this run. Same `runId` across `execute()` calls → resume or replay. New `runId` → fresh run. Typically derived from a business identifier. | + +## `MemoryDurabilityStore` + +A reference `DurabilityStore` that holds checkpoints in a `Map`. + +```typescript +import { MemoryDurabilityStore } from '@celom/prose'; + +const store = new MemoryDurabilityStore(); +``` + + + +Test helpers (not part of the `DurabilityStore` interface): + +| Method | Purpose | +|--------|---------| +| `size()` | Number of stored runs. | +| `snapshot(runId)` | Synchronous read of a single checkpoint (returns a clone). | +| `clear()` | Drop all stored checkpoints. | + +`MemoryDurabilityStore` clones on both `load()` and `save()` so the executor cannot mutate stored state. Adapters that serialize to bytes get this property for free. + +## Implementation contract + +Conformance tests for adapter authors live at `packages/prose/src/lib/__tests__/store-conformance.ts`. The file is **not** re-exported from the package entry point and is **not** included in the published `dist/`. Copy or vendor it into your adapter package — or, for adapters in this monorepo, import it directly from source. + +```typescript +// Once copied or vendored, run the suite in your adapter's spec file: +import { storeConformanceSuite } from './store-conformance.js'; +import { MyStore } from './my-store.js'; + +storeConformanceSuite('MyStore', () => new MyStore()); +``` + +The suite covers the public `DurabilityStore` contract — round-tripping checkpoints, overwriting on second `save()`, preserving the `failedStep` / `breakValue` fields, no-op `delete()` for unknown ids, and isolation between runIds. Adapter-specific invariants (clone-on-read for in-memory adapters, transaction semantics for SQL adapters) belong in your adapter's own spec. + +Implementation notes: + +- **Atomic writes.** A reader observing a partial checkpoint defeats the entire point of durability. Wrap the write in a transaction, or write to a temp row and swap. +- **`load()` returns `null`, not throws.** Unknown `runId`s are normal — they mean "fresh run." +- **`delete()` of an unknown id is a no-op.** No error. +- **Clone on read for non-serializing stores.** If your store keeps the checkpoint in memory (or shares an object reference with the caller), clone on `load()` and `save()` to prevent accidental mutation. + +## Related + +- [Durability guide](/prose/guides/durability/) — concepts, patterns, and feature interactions. +- [Order processing with durability](/prose/examples/order-processing-with-durability/) — end-to-end example with a crash scenario. +- [Execution options](/prose/api/execution-options/) — the `durability` option on `flow.execute()`. diff --git a/apps/docs/src/content/docs/api/error-types.mdx b/apps/docs/src/content/docs/api/error-types.mdx index 70495e6..8175f7f 100644 --- a/apps/docs/src/content/docs/api/error-types.mdx +++ b/apps/docs/src/content/docs/api/error-types.mdx @@ -2,7 +2,7 @@ title: Error Types description: API reference for FlowExecutionError, ValidationError, and TimeoutError. sidebar: - order: 4 + order: 5 --- Prose exports three error classes, all extending `Error`. diff --git a/apps/docs/src/content/docs/api/execution-options.mdx b/apps/docs/src/content/docs/api/execution-options.mdx index cb1aef0..d6379b4 100644 --- a/apps/docs/src/content/docs/api/execution-options.mdx +++ b/apps/docs/src/content/docs/api/execution-options.mdx @@ -69,3 +69,21 @@ Control behavior for missing optional dependencies. } } ``` + +### `durability` + +**Type:** `DurabilityOptions` + +Opt-in skip-ahead checkpointing. When set, each successful step persists a checkpoint to the configured store. Re-invoking `execute()` with the same `runId` resumes from the next undone step (or returns the saved result if the run already completed). + +```typescript +import { MemoryDurabilityStore } from '@celom/prose'; + +const store = new MemoryDurabilityStore(); + +await flow.execute(input, deps, { + durability: { store, runId: input.orderId }, +}); +``` + +See the [durability guide](/prose/guides/durability/) for the full story and the [DurabilityStore API reference](/prose/api/durability-store/) for the types. diff --git a/apps/docs/src/content/docs/api/observers.mdx b/apps/docs/src/content/docs/api/observers.mdx index 35567af..75de355 100644 --- a/apps/docs/src/content/docs/api/observers.mdx +++ b/apps/docs/src/content/docs/api/observers.mdx @@ -2,7 +2,7 @@ title: Observers description: API reference for FlowObserver interface and built-in observer implementations. sidebar: - order: 5 + order: 6 --- ## `FlowObserver` interface diff --git a/apps/docs/src/content/docs/api/types.mdx b/apps/docs/src/content/docs/api/types.mdx index c8f34c8..26fe9d5 100644 --- a/apps/docs/src/content/docs/api/types.mdx +++ b/apps/docs/src/content/docs/api/types.mdx @@ -2,7 +2,7 @@ title: Types description: API reference for core TypeScript types exported by Prose. sidebar: - order: 6 + order: 7 --- ## `FlowContext` @@ -58,9 +58,23 @@ interface FlowMeta { startedAt: Date; currentStep?: string; correlationId?: string; + // Durability-only — present when execute() was passed a `durability` option + runId?: string; + idempotencyKey?: string; + isResuming?: boolean; } ``` +| Field | Description | +|-------|-------------| +| `flowName` | The flow's name. | +| `startedAt` | When this `execute()` call began. Not preserved across resumes — set on each invocation. | +| `currentStep` | The name of the step currently executing. | +| `correlationId` | Custom or auto-generated ID propagated to events and observers. | +| `runId` | The `DurabilityOptions.runId` of this run. Present only when durability is configured. | +| `idempotencyKey` | Stable per-step key (`${runId}:${currentStep}`). Pass to external APIs that support idempotency so re-runs are safe. Present only when durability is configured. | +| `isResuming` | `true` when this execution loaded an existing checkpoint rather than starting fresh. Present only when durability is configured. | + ## `RetryOptions` Configuration for `.withRetry()`. diff --git a/apps/docs/src/content/docs/comparison.mdx b/apps/docs/src/content/docs/comparison.mdx index 5ec8721..2c75295 100644 --- a/apps/docs/src/content/docs/comparison.mdx +++ b/apps/docs/src/content/docs/comparison.mdx @@ -5,9 +5,18 @@ description: Understand where Prose fits compared to Temporal, Effect-TS, XState Prose is an **in-process** workflow orchestration library. It runs inside your existing Node.js process with zero external dependencies. Before adopting it, it's worth understanding what it does _not_ try to be. -## Not a durable execution engine +## Not a full durable execution engine -If you need workflows that survive process restarts, resume after hours or days, or coordinate across distributed services, look at [Temporal](https://temporal.io), [Inngest](https://www.inngest.com), or [Trigger.dev](https://trigger.dev). These require infrastructure (servers, queues, databases) but give you persistence and replay guarantees that an in-process library fundamentally cannot. +Prose ships opt-in [skip-ahead durability](/prose/guides/durability/) — successful steps are checkpointed, and re-invoking `execute()` with the same `runId` resumes from the next undone step or replays the saved result. That covers crash recovery for in-process flows without external infrastructure. + +It is **not** a substitute for [Temporal](https://temporal.io), [Inngest](https://www.inngest.com), or [Trigger.dev]( https://trigger.dev). Specifically, Prose does **not** give you: + +- Long sleeps that survive process death (`await sleep(7.days)`) +- An automatic resumer service that re-invokes crashed runs for you +- Distributed worker claim / lease coordination +- Event-history replay or workflow versioning + +Reach for a hosted orchestrator when you need durable timers, cross-service coordination, or schedule-driven retries. The new sweet spot for Prose: business logic shape + crash safety in-process, with you bringing the scheduler (cron, queue, next request) when something has to invoke a resume. ## Not a full effect system @@ -27,7 +36,8 @@ Prose is for teams building backend services with multi-step business logic (pro | Need | Tool | |------|------| -| Durable workflows, cross-service orchestration | Temporal, Inngest, Trigger.dev | +| Hosted durable workflows, durable timers, cross-service orchestration | Temporal, Inngest, Trigger.dev | +| In-process flows with opt-in crash recovery | **Prose** (with `durability`) | | Full effect system with typed errors | Effect-TS | | State machines with complex transitions | XState | | In-process business logic pipelines | **Prose** | diff --git a/apps/docs/src/content/docs/examples/order-processing-with-durability.mdx b/apps/docs/src/content/docs/examples/order-processing-with-durability.mdx new file mode 100644 index 0000000..3e0eca9 --- /dev/null +++ b/apps/docs/src/content/docs/examples/order-processing-with-durability.mdx @@ -0,0 +1,212 @@ +--- +title: Order Processing with Durability +description: Adding skip-ahead checkpointing to the order processing pipeline — crash recovery without double-charging. +sidebar: + order: 2 +--- + +import { Aside } from '@astrojs/starlight/components'; + +This example extends the [order processing pipeline](/prose/examples/order-processing/) with [durability](/prose/guides/durability/). The same flow now survives a process crash mid-execution: completed steps are not re-run, and a re-execution returns the saved result instead of charging the card twice. + +## The same flow, with durability + +Two things change. The handlers pass `ctx.meta.idempotencyKey` to any external side effect that supports idempotency, and the caller passes a `durability` option to `execute()`. + +```typescript +import { createFlow, ValidationError, MemoryDurabilityStore } from '@celom/prose'; + +const processOrder = createFlow('process-order') + + .validate('validateOrder', (ctx) => { + if (ctx.input.items.length === 0) + throw ValidationError.single('items', 'Order must have at least one item'); + }) + + .step('calculateTotal', (ctx) => { + const subtotal = ctx.input.items.reduce( + (sum, item) => sum + item.price * item.quantity, 0 + ); + const tax = subtotal * 0.08; + return { subtotal, tax, total: subtotal + tax }; + }) + + // Stripe accepts an idempotency key — pass ctx.meta.idempotencyKey so a + // resumed run that re-invokes this step doesn't charge the card twice. + .step('chargePayment', async (ctx) => { + const receipt = await stripe.paymentIntents.create( + { + amount: Math.round(ctx.state.total * 100), + currency: 'usd', + customer: ctx.input.userId, + }, + { idempotencyKey: ctx.meta.idempotencyKey }, + ); + return { receipt }; + }) + .withRetry({ + maxAttempts: 3, + delayMs: 500, + backoffMultiplier: 2, + shouldRetry: (err) => err.code !== 'CARD_DECLINED', + }) + + .transaction('persistOrder', async (ctx, tx) => { + // Upsert by orderId so a re-run after a crash mid-transaction + // doesn't create a duplicate row. + const persistedOrderId = await tx.upsert('orders', { + id: ctx.input.orderId, + userId: ctx.input.userId, + total: ctx.state.total, + receiptId: ctx.state.receipt.id, + status: 'confirmed', + }); + return { persistedOrderId }; + }) + + // No retry on email — the queue itself handles redelivery. The + // idempotencyKey on the queue message protects against duplicates. + .step('sendConfirmation', async (ctx) => { + await mailer.send(ctx.input.userId, { + template: 'order-confirmed', + orderId: ctx.state.persistedOrderId, + idempotencyKey: ctx.meta.idempotencyKey, + }); + }) + + .event('orders', (ctx) => ({ + eventType: 'order.confirmed', + orderId: ctx.state.persistedOrderId, + userId: ctx.input.userId, + total: ctx.state.total, + })) + + .map((_, state) => ({ + orderId: state.persistedOrderId, + total: state.total, + receiptId: state.receipt.id, + status: 'confirmed' as const, + })) + .build(); +``` + +## Running with crash recovery + +Use the `orderId` as the `runId`. Same business ID → same run. + +```typescript +const store = new MemoryDurabilityStore(); // replace with a persistent adapter in production + +const result = await processOrder.execute( + { + orderId: 'ord_abc123', + userId: 'user_42', + items: [ + { sku: 'WIDGET-A', quantity: 2, price: 29.99 }, + { sku: 'GADGET-B', quantity: 1, price: 49.99 }, + ], + }, + { db, eventPublisher }, + { + durability: { store, runId: 'ord_abc123' }, + }, +); +``` + + + +## A concrete crash scenario + +The process is killed by an OOM after `chargePayment` succeeds but before `persistOrder` finishes. The customer was charged but the order is not in your database. + +After `chargePayment` succeeds, the checkpoint in the store looks like this: + +```typescript +await store.load('ord_abc123'); +// { +// flowName: 'process-order', +// runId: 'ord_abc123', +// input: { orderId: 'ord_abc123', userId: 'user_42', items: [...] }, +// state: { subtotal: 109.97, tax: 8.80, total: 118.77, receipt: { id: 'pi_...' } }, +// completedSteps: ['validateOrder', 'calculateTotal', 'chargePayment'], +// status: 'running', +// createdAt: ..., +// updatedAt: ..., +// } +``` + +Process restarts. A janitor (or the next webhook call from the upstream system) re-invokes the flow with the same `runId`: + +```typescript +const result = await processOrder.execute( + { + orderId: 'ord_abc123', + userId: 'user_42', + items: [...], + }, + { db, eventPublisher }, + { + durability: { store, runId: 'ord_abc123' }, + }, +); +``` + +On this second call: + +- `validateOrder`, `calculateTotal`, and `chargePayment` are **skipped** — they're recorded as completed and their state is already loaded from the checkpoint. The card is **not** charged again. +- `persistOrder` runs. Because it upserts by `orderId`, any partial transaction from the first attempt is consistent on commit. +- `sendConfirmation` runs. The mailer sees `idempotencyKey = 'ord_abc123:sendConfirmation'` and deduplicates if the first attempt managed to enqueue a message before crashing. +- `event('orders', ...)` publishes the confirmation event. Consumers must be idempotent — this event has at-least-once semantics under durability. + +After completion the checkpoint becomes: + +```typescript +await store.load('ord_abc123'); +// { +// ... +// completedSteps: ['validateOrder', 'calculateTotal', 'chargePayment', 'persistOrder', 'sendConfirmation', 'orders'], +// status: 'completed', +// state: { ..., persistedOrderId: 'ord_abc123' }, +// } +``` + +A **third** call to `execute()` with the same `runId` returns the saved result without invoking any handler. The card is not charged a third time and the customer doesn't receive a third email. + +## What `ctx.meta.idempotencyKey` looks like + +When durability is configured, every step handler sees a stable per-step key: + +| Step name | `ctx.meta.idempotencyKey` | +|-----------|--------------------------| +| `chargePayment` | `'ord_abc123:chargePayment'` | +| `persistOrder` | `'ord_abc123:persistOrder'` | +| `sendConfirmation` | `'ord_abc123:sendConfirmation'` | + +The key is the same on every attempt of the same logical step — that's the whole point. When the second `execute()` call re-runs `persistOrder`, it sees the same `idempotencyKey` it would have used the first time, so external systems can deduplicate. + +When durability is **not** configured, `ctx.meta.idempotencyKey` is `undefined`. Handlers that need a key in both modes should fall back to something else (`ctx.meta.correlationId` for example). + +## Cleanup + +Completed checkpoints stay in the store until you remove them. For a high-volume flow, delete after the saved result has been observed by the caller — or run a periodic prune for rows older than _N_ days. + +```typescript +const result = await processOrder.execute(input, deps, { + durability: { store, runId: orderId }, +}); + +await store.delete(orderId); +return result; +``` + +If you want replay to work for a window (e.g. retry-safe webhooks), keep checkpoints around for the duration of that window before deleting. + +## What this demonstrates + +- **Skip-ahead checkpointing** — completed steps are not re-run on the second `execute()` call +- **Idempotency keys** — `ctx.meta.idempotencyKey` propagated to Stripe and the mailer so re-runs don't double-charge or double-send +- **Upsert by business ID** — the transaction step is safe to re-run because it upserts, not inserts +- **At-least-once events** — consumers of `order.confirmed` must be idempotent under durability +- **Replay on completion** — a third call with the same `runId` returns the saved result without re-execution diff --git a/apps/docs/src/content/docs/examples/user-onboarding.mdx b/apps/docs/src/content/docs/examples/user-onboarding.mdx index 41f2ff1..c341881 100644 --- a/apps/docs/src/content/docs/examples/user-onboarding.mdx +++ b/apps/docs/src/content/docs/examples/user-onboarding.mdx @@ -2,7 +2,7 @@ title: User Onboarding description: A user onboarding flow with validation, retries, conditional steps, and event publishing. sidebar: - order: 2 + order: 3 --- This example shows a user onboarding flow that validates email, creates an account, optionally sends an SMS, and publishes domain events. diff --git a/apps/docs/src/content/docs/guides/durability.mdx b/apps/docs/src/content/docs/guides/durability.mdx new file mode 100644 index 0000000..b068e38 --- /dev/null +++ b/apps/docs/src/content/docs/guides/durability.mdx @@ -0,0 +1,240 @@ +--- +title: Durability +description: Survive process crashes without re-running completed steps or duplicating side effects. +sidebar: + order: 9 +--- + +import { Aside } from '@astrojs/starlight/components'; + +Opt-in skip-ahead checkpointing. After every successful step, Prose persists a checkpoint. If the process crashes, calling `execute()` again with the same `runId` resumes from the next undone step. Same `runId` after completion replays the saved result without re-executing. + +```typescript +import { createFlow, MemoryDurabilityStore } from '@celom/prose'; + +const store = new MemoryDurabilityStore(); + +const processOrder = createFlow<{ orderId: string }>('process-order') + .step('chargePayment', async (ctx) => { + const receipt = await payments.charge({ + amount: 100, + idempotencyKey: ctx.meta.idempotencyKey, + }); + return { receipt }; + }) + .step('persistOrder', async (ctx) => { + await db.orders.insert({ id: ctx.input.orderId, receiptId: ctx.state.receipt.id }); + }) + .build(); + +// First call — process dies after chargePayment, before persistOrder +await processOrder.execute({ orderId: 'ord_42' }, { db }, { + durability: { store, runId: 'ord_42' }, +}); + +// Second call (after restart) with same runId — chargePayment is skipped, +// persistOrder runs to completion. +await processOrder.execute({ orderId: 'ord_42' }, { db }, { + durability: { store, runId: 'ord_42' }, +}); +``` + +## How it works + +After each successful step, Prose writes a checkpoint containing the original input, accumulated state, and the names of completed steps. On entry, `execute()` loads any existing checkpoint and behaves according to its status: + +| Stored status | `execute()` behavior | +|---------------|---------------------| +| _no checkpoint_ | Fresh run — every step executes. | +| `running` or `failed` | Resume — completed steps are skipped, state is loaded from the checkpoint, execution continues at the first undone step. | +| `completed` | Replay — the saved result is returned without invoking any handler. | + +Checkpoints are persisted before observer notifications fire, so a crash in observer code can't lose the completion record. + +## Setting it up + +### Choose a store + +| Store | Use case | +|-------|----------| +| `MemoryDurabilityStore` | Built-in. Tests, local dev, single-process scripts. **Not production** — state is lost when the process exits. | +| Custom adapter | Implement [`DurabilityStore`](/prose/api/durability-store/) over your own persistence layer (SQLite, Postgres, Redis, S3). | + + + +Dedicated `@celom/prose-store-sqlite` and `@celom/prose-store-postgres` packages are on the roadmap. Until then, implement the three-method `DurabilityStore` interface yourself — see [Writing a custom store](#writing-a-custom-store). + +### Choose a runId + +The `runId` is the identity of a single run. Pass the same `runId` across processes and you get the same run. + +- Derive it from a business identifier — order ID, signup ID, message ID — so the second call after a crash is the obvious thing to write. +- Don't use a random UUID generated inside the request handler. The next request won't know it. + +```typescript +await processOrder.execute(input, deps, { + durability: { store, runId: input.orderId }, +}); +``` + +## The idempotency contract + +A step may run twice. If the process dies between handler success and the checkpoint write, the next resume re-runs that step. Handlers must therefore be safe to invoke more than once for the same logical operation. + +When durability is configured, Prose exposes `ctx.meta.idempotencyKey` — a stable per-step key of the form `${runId}:${stepName}`. Pass it to any external API that supports idempotency. + +```typescript +.step('chargePayment', async (ctx) => { + const receipt = await stripe.paymentIntents.create( + { amount: ctx.state.total, currency: 'usd' }, + { idempotencyKey: ctx.meta.idempotencyKey }, + ); + return { receipt }; +}) +``` + +For databases: use upserts keyed by `${runId}:${stepName}` or by a business identifier the handler can derive deterministically. For queues: send with a deduplication ID. For things you can't deduplicate (sending email, calling a webhook that has no idempotency support), accept that at-least-once is the contract you're working with — or move that side effect into an outbox table that a separate worker drains. + + + +## Resuming a failed run + +There is no automatic resumer. Something must call `execute()` again with the same `runId`. Common patterns: + +- A web handler keyed by the business ID — every retry of the same logical request hits the same `runId`. +- A cron / queue worker that scans the store for `status === 'failed'` checkpoints and re-invokes the flow. + +Inside step handlers, `ctx.meta.isResuming` is `true` when this execution loaded an existing checkpoint. Use it to skip work that's only needed on a fresh run (sending an initial acknowledgement email, for example). + +```typescript +.step('sendStartedEmail', async (ctx) => { + if (ctx.meta.isResuming) return; + await mailer.send(ctx.input.email, 'We started processing your order'); +}) +``` + +## What gets persisted + +The checkpoint stores: + +- `input` — the original argument from the first `execute()` call, frozen. +- `state` — accumulated state at the moment of the last successful step. +- `completedSteps` — the names of finished (or condition-skipped) steps. +- `status` — `'running'`, `'completed'`, or `'failed'`. +- `breakValue` — the return value when the flow short-circuited via `breakIf`. +- `failedStep` — `{ name, error }` when the run failed after exhausting retries. +- `createdAt` / `updatedAt`. + +What does **not** get persisted: + +- `deps` — you pass these on every call. They are not part of the run identity. +- In-flight retry attempts inside a step — the checkpoint only updates _after_ retries succeed. +- Observer notifications — observers are local to each `execute()` call. +- The input argument to the second `execute()` call on a resume — the saved `input` is authoritative. Pass any input you want; Prose will use the checkpoint's copy. + +## Interaction with other features + +| Feature | Behavior under durability | +|---------|--------------------------| +| `.parallel(...)` | Atomic checkpoint — all handlers in the block re-run if any one fails. The parallel block as a whole is what's marked complete, not the individual handlers. | +| `.withRetry(...)` | Retries happen within the step. The checkpoint is written once, after retries succeed. | +| `.breakIf(...)` | The break value is persisted in the checkpoint. Replay returns it without invoking any handler. | +| `.stepIf(...)` / `.step({ condition })` | The decision to skip is recorded. Resume does not re-evaluate the condition. | +| `.transaction(...)` | The transaction runs again if it failed before the checkpoint write. Database-level idempotency (upsert, unique constraints) is your responsibility. | +| `.event(...)` | At-least-once: an event may be re-published if the crash falls between `publisher.publish()` and the checkpoint write. Consumers must be idempotent. | +| `.validate(...)` | Re-runs on resume only if it threw on the previous attempt. Successful validations are recorded as completed. | + +## What this isn't + + + +- **No long sleeps that survive process death.** There is no `await sleep(7.days)`. If the process dies during a wait, the wait is gone. +- **No automatic resumer.** Something outside Prose has to call `execute()` again. A cron, a queue retry, the next request — whatever fits your shape. +- **No distributed worker claim or lease.** Two workers calling `execute()` with the same `runId` at the same time will both run the flow. If that matters to you, build a claim mechanism in your custom store or upstream of it. +- **No workflow versioning.** Changing the shape of a flow while a checkpoint for it exists is undefined behavior. If you reorder steps, rename them, or change handler logic in a way that depends on state shape, drop the old checkpoint (`store.delete(runId)`) before deploying. + +## Patterns + +### Resuming on the next request + +Make the runId equal to the business ID. Every request for the same order hits the same run. + +```typescript +app.post('/orders/:id/process', async (req, res) => { + const result = await processOrder.execute( + { orderId: req.params.id }, + { db, payments, mailer }, + { durability: { store, runId: req.params.id } }, + ); + res.json(result); +}); +``` + +If the previous attempt crashed mid-flow, this call resumes from the next undone step. If it completed, this call returns the saved result without re-charging or re-emailing. + +### A janitor that retries failed runs + +For runs that don't have an obvious caller to retry them, scan the store from a cron. + +```typescript +// Custom store extension — exposing a scan() is up to the adapter. +async function retryFailedRuns(store: ScanningStore) { + const failed = await store.scanByStatus('failed'); + for (const cp of failed) { + try { + await processOrder.execute({} as never, deps, { + durability: { store, runId: cp.runId }, + }); + } catch (err) { + logger.error({ runId: cp.runId, err }, 'janitor: retry failed'); + } + } +} +``` + +The first-call input is ignored on resume, so the janitor doesn't need to recover it. + +### Cleanup + +Prose does not delete completed checkpoints. The store grows monotonically until you do something about it. + +```typescript +const result = await processOrder.execute(input, deps, { + durability: { store, runId }, +}); +await store.delete(runId); +``` + +Or implement a TTL in your custom store. Or run a periodic prune job over rows older than _N_ days. There's no one-size-fits-all answer here — it depends on whether you want to replay completed runs, how long you need an audit trail for, etc. + +## Writing a custom store + +Implement the [`DurabilityStore`](/prose/api/durability-store/) interface — three methods: + +```typescript +interface DurabilityStore { + load(runId: string): Promise; + save(checkpoint: FlowCheckpoint): Promise; + delete(runId: string): Promise; +} +``` + +[`MemoryDurabilityStore`](https://github.com/celom/prose/blob/main/packages/prose/src/lib/memory-store.ts) is around 45 lines and serves as a reference implementation. It clones on both read and write so that the executor never mutates stored state — a good invariant to uphold in adapters that don't serialize to bytes. + +A canonical contract test suite lives at `packages/prose/src/lib/__tests__/store-conformance.ts`. It is **not** re-exported from the package entry point and is **not** included in the published `dist/`. Adapter authors can either: + +- Copy or vendor the file into your own adapter package (recommended for external adapters). +- Import it directly from source if your adapter lives inside this monorepo. + +Implementation notes: + +- `save()` must be atomic — partial writes defeat the purpose of checkpointing. +- `load()` should return `null` (not throw) for unknown `runId`s. +- `delete()` of an unknown `runId` is a no-op, not an error. +- Cloning on read is encouraged for non-serializing stores so that callers can't accidentally mutate stored state. diff --git a/apps/docs/src/content/docs/guides/observability.mdx b/apps/docs/src/content/docs/guides/observability.mdx index 979f042..98be557 100644 --- a/apps/docs/src/content/docs/guides/observability.mdx +++ b/apps/docs/src/content/docs/guides/observability.mdx @@ -2,7 +2,7 @@ title: Observability description: Hook into flow and step lifecycle events for logging, metrics, and tracing. sidebar: - order: 9 + order: 10 --- Pass an observer to `.execute()` to hook into lifecycle events for logging, metrics, or distributed tracing. diff --git a/apps/docs/src/content/docs/guides/project-structure.mdx b/apps/docs/src/content/docs/guides/project-structure.mdx index 2ce4750..d9b4664 100644 --- a/apps/docs/src/content/docs/guides/project-structure.mdx +++ b/apps/docs/src/content/docs/guides/project-structure.mdx @@ -2,7 +2,7 @@ title: Project Structure description: An opinionated guide for organizing flows, steps, and dependencies into testable, auditable modules. sidebar: - order: 10 + order: 11 --- This guide presents an opinionated convention for structuring your flows. The goal is to enforce a clear separation of concerns where every step is a pure, testable function with an explicit contract for its inputs and dependencies. diff --git a/apps/docs/src/content/docs/index.mdx b/apps/docs/src/content/docs/index.mdx index 3b46361..c5b5d8a 100644 --- a/apps/docs/src/content/docs/index.mdx +++ b/apps/docs/src/content/docs/index.mdx @@ -44,6 +44,9 @@ import { Card, CardGrid } from '@astrojs/starlight/components'; Plug in logging, metrics, or tracing through the observer interface. + + Opt-in skip-ahead checkpointing — survive process crashes without re-running completed steps. + ## Quick Example diff --git a/packages/prose/README.md b/packages/prose/README.md index c5d0ca2..f3fdc12 100644 --- a/packages/prose/README.md +++ b/packages/prose/README.md @@ -50,6 +50,7 @@ npm install @celom/prose - **Conditional steps & early exit** — skip steps based on runtime conditions or short-circuit the flow entirely - **Composable sub-flows** — extract and reuse step sequences via `.pipe()` - **Observability hooks** — plug in logging, metrics, or tracing through the observer interface +- **Durable execution (opt-in)** — checkpoint after each step so a crashed run resumes from the next undone step instead of starting over - **Zero dependencies** — runs in-process with no external infrastructure ## Guide @@ -405,13 +406,63 @@ Every step handler receives `ctx.meta` with runtime metadata: ```typescript flow.step('example', (ctx) => { - ctx.meta.flowName; // 'process-order' - ctx.meta.currentStep; // 'example' - ctx.meta.startedAt; // Date - ctx.meta.correlationId; // auto-generated or custom + ctx.meta.flowName; // 'process-order' + ctx.meta.currentStep; // 'example' + ctx.meta.startedAt; // Date + ctx.meta.correlationId; // auto-generated or custom + ctx.meta.runId; // present only when durability is configured + ctx.meta.idempotencyKey; // `${runId}:${stepName}` — pass to external APIs + ctx.meta.isResuming; // true when this execution loaded a saved checkpoint }); ``` +### Durability + +Opt-in skip-ahead checkpointing. After every successful step, Prose persists a checkpoint to the configured store. Re-invoking `execute()` with the same `runId` resumes from the next undone step. Re-invoking on a completed run returns the saved result without re-execution. + +```typescript +import { createFlow, MemoryDurabilityStore } from '@celom/prose'; + +const store = new MemoryDurabilityStore(); + +const processOrder = createFlow<{ orderId: string }>('process-order') + .step('chargePayment', async (ctx) => { + const receipt = await payments.charge({ + amount: 100, + idempotencyKey: ctx.meta.idempotencyKey, + }); + return { receipt }; + }) + .step('persistOrder', async (ctx) => { + await db.orders.upsert({ id: ctx.input.orderId, receiptId: ctx.state.receipt.id }); + }) + .build(); + +// First call — crashes after chargePayment, before persistOrder +await processOrder.execute({ orderId: 'ord_42' }, { db, payments }, { + durability: { store, runId: 'ord_42' }, +}); + +// After restart — chargePayment is skipped, persistOrder runs +await processOrder.execute({ orderId: 'ord_42' }, { db, payments }, { + durability: { store, runId: 'ord_42' }, +}); +``` + +The three behaviors of `execute()` with a `durability` option: + +| Stored status | Behavior | +|---------------|----------| +| _no checkpoint_ | Fresh run — every step executes | +| `running` / `failed` | Resume — completed steps are skipped, state is loaded, execution continues at the first undone step | +| `completed` | Replay — the saved result is returned without invoking any handler | + +A step may run twice across a crash. `ctx.meta.idempotencyKey` is a stable per-step key (`${runId}:${stepName}`) — pass it to Stripe, SQS, or any external API that supports idempotency. + +`MemoryDurabilityStore` is for tests and dev. For production, implement the three-method `DurabilityStore` interface against your own persistence layer. A conformance test suite lives at `src/lib/__tests__/store-conformance.ts` (not re-exported — deep-import or copy-vendor for now). + +See [the durability guide](https://celom.github.io/prose/guides/durability/) for feature interactions, patterns, and the full idempotency contract. + ## MCP Server Prose includes a built-in [Model Context Protocol](https://modelcontextprotocol.io/) server that helps AI assistants write correct flow code. It provides tools for scaffolding, analyzing, listing, and validating flows, along with API reference resources and interactive prompts. @@ -433,7 +484,7 @@ Add to your MCP client configuration (Claude Code, Cursor, etc.): Prose is an **in-process** workflow orchestration library. It runs inside your existing Node.js process with zero external dependencies. Before adopting it, it's worth understanding what it does _not_ try to be: -**Not a durable execution engine.** If you need workflows that survive process restarts, resume after hours or days, or coordinate across distributed services, look at [Temporal](https://temporal.io), [Inngest](https://www.inngest.com), or [Trigger.dev](https://trigger.dev). These require infrastructure (servers, queues, databases) but give you persistence and replay guarantees that an in-process library fundamentally cannot. +**Not a Temporal replacement.** Prose ships opt-in [skip-ahead durability](https://celom.github.io/prose/guides/durability/) — successful steps are checkpointed, and re-invoking `execute()` with the same `runId` resumes from the next undone step or replays the saved result. That covers crash recovery for in-process flows. It does **not** give you long sleeps that survive process death (`await sleep(7.days)`), an automatic resumer that re-invokes crashed runs for you, or distributed worker claim/lease coordination. For those, reach for [Temporal](https://temporal.io), [Inngest](https://www.inngest.com), or [Trigger.dev](https://trigger.dev). **Not a full effect system.** [Effect-TS](https://effect.website) is more powerful in every technical dimension — typed errors in the return signature, type-level dependency injection via Layers, fibers, streams, and a massive standard library. If your team can invest in learning its functional programming model, Effect is the more capable choice. Prose trades that power for simplicity: pure async/await, no monads, no new paradigms to learn. From a3b72be9b2fa49f3866a5f4d6e2c51890f86e5ce Mon Sep 17 00:00:00 2001 From: Carlos Mimoso Date: Mon, 18 May 2026 23:31:54 +0100 Subject: [PATCH 4/7] docs(mcp): teach the MCP server about durability Brings the LLM-facing knowledge base in line with the docs site shipped in 74615de. Adds DURABILITY_REFERENCE and registers it as prose://api/durability-store, adds a 'durability' entry to the guides registry, and updates FlowMeta + EXECUTION_OPTIONS_REFERENCE + quick-reference.ts to surface the new ctx.meta fields and the durability execute() option. AI assistants wired up to the MCP server can now write flows that take advantage of opt-in checkpointing. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../prose/src/mcp/content/api-reference.ts | 123 ++++++++++++++++++ packages/prose/src/mcp/content/guides.ts | 108 +++++++++++++++ .../prose/src/mcp/content/quick-reference.ts | 17 +++ packages/prose/src/mcp/resources/index.ts | 9 ++ 4 files changed, 257 insertions(+) diff --git a/packages/prose/src/mcp/content/api-reference.ts b/packages/prose/src/mcp/content/api-reference.ts index ac12d4e..24e4afb 100644 --- a/packages/prose/src/mcp/content/api-reference.ts +++ b/packages/prose/src/mcp/content/api-reference.ts @@ -243,9 +243,23 @@ interface FlowMeta { startedAt: Date; currentStep?: string; correlationId?: string; + // Durability-only — present when execute() was passed a \`durability\` option + runId?: string; + idempotencyKey?: string; + isResuming?: boolean; } \`\`\` +| Field | Description | +|-------|-------------| +| \`flowName\` | The flow's name. | +| \`startedAt\` | When this \`execute()\` call began. Set on each invocation; not preserved across resumes. | +| \`currentStep\` | The name of the step currently executing. | +| \`correlationId\` | Custom or auto-generated ID propagated to events and observers. | +| \`runId\` | The \`DurabilityOptions.runId\` of this run. Present only when durability is configured. | +| \`idempotencyKey\` | Stable per-step key (\`\${runId}:\${currentStep}\`). Pass to external APIs that support idempotency so re-runs are safe. Present only when durability is configured. | +| \`isResuming\` | \`true\` when this execution loaded an existing checkpoint rather than starting fresh. Present only when durability is configured. | + ## FlowState Base constraint for accumulated state. All state objects must satisfy this type. @@ -432,6 +446,115 @@ Control behavior for missing optional dependencies. } } \`\`\` + +### durability +**Type:** \`DurabilityOptions\` +Opt-in skip-ahead checkpointing. When set, each successful step persists a checkpoint to the configured store. Re-invoking \`execute()\` with the same \`runId\` resumes from the next undone step (or returns the saved result if the run already completed). + +\`\`\`typescript +import { MemoryDurabilityStore } from '@celom/prose'; + +const store = new MemoryDurabilityStore(); + +await flow.execute(input, deps, { + durability: { store, runId: input.orderId }, +}); +\`\`\` + +See \`prose://api/durability-store\` for the full \`DurabilityStore\` / \`DurabilityOptions\` / \`FlowCheckpoint\` type references, and \`prose://guides/durability\` for the conceptual guide. +`; + +export const DURABILITY_REFERENCE = `# Durability Store + +Opt-in skip-ahead checkpointing. After every successful step, Prose persists a checkpoint to a configured \`DurabilityStore\`. Re-invoking \`execute()\` with the same \`runId\` resumes from the next undone step. Same \`runId\` after completion replays the saved result without re-execution. + +## DurabilityStore + +Pluggable persistence layer for flow checkpoints. Adapters may live in separate packages. + +\`\`\`typescript +interface DurabilityStore { + load(runId: string): Promise; + save(checkpoint: FlowCheckpoint): Promise; + delete(runId: string): Promise; +} +\`\`\` + +| Method | Semantics | +|--------|-----------| +| \`load(runId)\` | Return the checkpoint for \`runId\`, or \`null\` if none exists. Must not throw on unknown ids. | +| \`save(checkpoint)\` | Persist the checkpoint, overwriting any existing entry for the same \`runId\`. Must be atomic — partial writes defeat checkpointing. | +| \`delete(runId)\` | Remove the checkpoint. No-op for unknown \`runId\` — must not throw. | + +## FlowCheckpoint + +The shape persisted to the store. Adapters store and return this verbatim. + +\`\`\`typescript +interface FlowCheckpoint { + flowName: string; + runId: string; + input: unknown; + state: unknown; + completedSteps: string[]; + status: 'running' | 'completed' | 'failed'; + breakValue?: unknown; + failedStep?: { name: string; error: string }; + createdAt: Date; + updatedAt: Date; +} +\`\`\` + +| Field | Description | +|-------|-------------| +| \`flowName\` | The flow's name (from \`createFlow('name')\`). | +| \`runId\` | The \`DurabilityOptions.runId\` of this run. | +| \`input\` | The argument the flow was first started with. Used verbatim on resume — the caller's second \`input\` argument is ignored. | +| \`state\` | Accumulated state at the moment of the last successful step. | +| \`completedSteps\` | Names of finished or condition-skipped steps, in execution order. | +| \`status\` | \`'running'\` (in progress or crashed), \`'completed'\` (saved result is final), \`'failed'\` (errored after exhausting retries). | +| \`breakValue\` | Only present when the flow short-circuited via \`breakIf\`. The field's _presence_ distinguishes "broke" from "completed normally" — the value itself may be \`undefined\`. | +| \`failedStep\` | Only present when \`status === 'failed'\`. The step that errored, plus its error message. | +| \`createdAt\` | When the run first started. Preserved across resumes. | +| \`updatedAt\` | When the checkpoint was last written. | + +## DurabilityOptions + +The shape passed via \`FlowExecutionOptions.durability\`. + +\`\`\`typescript +interface DurabilityOptions { + store: DurabilityStore; + runId: string; +} +\`\`\` + +| Field | Description | +|-------|-------------| +| \`store\` | The store to read and write checkpoints through. | +| \`runId\` | Stable identifier for this run. Same \`runId\` across \`execute()\` calls → resume or replay. New \`runId\` → fresh run. Typically derived from a business identifier (order ID, signup ID). | + +## MemoryDurabilityStore + +A reference \`DurabilityStore\` that holds checkpoints in a \`Map\`. **For tests, local dev, and as a reference implementation only — does NOT survive process exit.** Use a persistent adapter in production. + +\`\`\`typescript +import { MemoryDurabilityStore } from '@celom/prose'; + +const store = new MemoryDurabilityStore(); +\`\`\` + +Test helpers (not part of \`DurabilityStore\`): \`size()\`, \`snapshot(runId)\`, \`clear()\`. + +## Implementation contract + +Conformance tests for adapter authors live at \`packages/prose/src/lib/__tests__/store-conformance.ts\`. The file is **not** re-exported from the package entry point and is **not** included in the published \`dist/\`. Copy or vendor it into your adapter package, or import directly from source for adapters in this monorepo. + +Implementation notes: +- \`save()\` must be atomic — partial writes defeat checkpointing. +- \`load()\` returns \`null\` (not throws) for unknown \`runId\`s. +- \`delete()\` of an unknown \`runId\` is a no-op. +- Cloning on read is encouraged for non-serializing stores so callers can't mutate stored state. `; export const ERROR_TYPES_REFERENCE = `# Error Types diff --git a/packages/prose/src/mcp/content/guides.ts b/packages/prose/src/mcp/content/guides.ts index f86b377..e0ce8d3 100644 --- a/packages/prose/src/mcp/content/guides.ts +++ b/packages/prose/src/mcp/content/guides.ts @@ -601,6 +601,114 @@ await flow.execute(input, deps, { - **DefaultObserver** — Console logging - **NoOpObserver** — Silent (testing) - **PinoFlowObserver** — Structured JSON logging (Pino/Fastify)`, + + durability: `# Durability + +Opt-in skip-ahead checkpointing. After every successful step, Prose persists a checkpoint. If the process crashes, calling \`execute()\` again with the same \`runId\` resumes from the next undone step. Same \`runId\` after completion replays the saved result without re-executing. + +\`\`\`typescript +import { createFlow, MemoryDurabilityStore } from '@celom/prose'; + +const store = new MemoryDurabilityStore(); + +const processOrder = createFlow<{ orderId: string }>('process-order') + .step('chargePayment', async (ctx) => { + const receipt = await payments.charge({ + amount: 100, + idempotencyKey: ctx.meta.idempotencyKey, + }); + return { receipt }; + }) + .step('persistOrder', async (ctx) => { + await db.orders.upsert({ id: ctx.input.orderId, receiptId: ctx.state.receipt.id }); + }) + .build(); + +// First call — crashes after chargePayment, before persistOrder +await processOrder.execute({ orderId: 'ord_42' }, { db, payments }, { + durability: { store, runId: 'ord_42' }, +}); + +// After restart — chargePayment is skipped, persistOrder runs +await processOrder.execute({ orderId: 'ord_42' }, { db, payments }, { + durability: { store, runId: 'ord_42' }, +}); +\`\`\` + +## The three behaviors of execute() with durability + +| Stored status | Behavior | +|---------------|----------| +| _no checkpoint_ | Fresh run — every step executes | +| \`running\` or \`failed\` | Resume — completed steps are skipped, state is loaded, execution continues at the first undone step | +| \`completed\` | Replay — the saved result is returned without invoking any handler | + +## The idempotency contract + +A step may run twice across a crash. \`ctx.meta.idempotencyKey\` is a stable per-step key (\`\${runId}:\${stepName}\`) — pass it to any external API that supports idempotency. + +\`\`\`typescript +.step('chargePayment', async (ctx) => { + const receipt = await stripe.paymentIntents.create( + { amount: ctx.state.total, currency: 'usd' }, + { idempotencyKey: ctx.meta.idempotencyKey }, + ); + return { receipt }; +}) +\`\`\` + +For databases: use upserts. For queues: send with a deduplication ID. For things you can't deduplicate (email, webhooks without idempotency), accept at-least-once or move that side effect into an outbox. + +## Choosing a runId + +The \`runId\` is the identity of a single run. Pass the same \`runId\` across processes and you get the same run. + +- Derive it from a business identifier — order ID, signup ID, message ID +- Don't use a random UUID generated inside a request handler; the next request won't know it + +## meta fields under durability + +When \`durability\` is configured, every step handler sees three extra \`ctx.meta\` fields: + +| Field | Description | +|-------|-------------| +| \`ctx.meta.runId\` | The \`DurabilityOptions.runId\` of this run | +| \`ctx.meta.idempotencyKey\` | Stable per-step key (\`\${runId}:\${stepName}\`) | +| \`ctx.meta.isResuming\` | \`true\` when this execution loaded a saved checkpoint rather than starting fresh | + +Use \`isResuming\` to skip work that's only needed on a fresh run (sending an initial acknowledgement, for example). + +## Interaction with other features + +| Feature | Behavior under durability | +|---------|--------------------------| +| \`.parallel(...)\` | Atomic checkpoint — all handlers re-run if any one fails | +| \`.withRetry(...)\` | Retries happen within the step; checkpoint is written once, after retries succeed | +| \`.breakIf(...)\` | Break value is persisted; replay returns it without invoking any handler | +| \`.stepIf(...)\` / step \`condition\` | Skip decision is recorded; resume does not re-evaluate | +| \`.transaction(...)\` | Transaction runs again if it failed before checkpoint write — database-level idempotency is your responsibility | +| \`.event(...)\` | At-least-once: an event may be re-published if crash falls between publish and checkpoint write — consumers must be idempotent | +| \`.validate(...)\` | Re-runs on resume only if it threw on the previous attempt | + +## What this isn't + +This is **not** Temporal-style durable execution. It does NOT provide: + +- Long sleeps that survive process death (\`await sleep(7.days)\`) +- An automatic resumer service — something outside Prose must call \`execute()\` again +- Distributed worker claim/lease coordination +- Workflow versioning — changing a flow while a checkpoint exists is undefined behavior + +For those, use Temporal, Inngest, or Trigger.dev. + +## Stores + +- **\`MemoryDurabilityStore\`** — Built-in. Tests, dev, single-process scripts only. State is lost on process exit — NOT for production. +- **Custom adapter** — Implement the three-method \`DurabilityStore\` interface (\`load\`, \`save\`, \`delete\`). See \`prose://api/durability-store\` for the contract. + +## Cleanup + +Prose does not delete completed checkpoints. Call \`store.delete(runId)\` after the result has been consumed, or implement a TTL in your store.`, }; export const GUIDE_TOPICS = Object.keys(GUIDES); diff --git a/packages/prose/src/mcp/content/quick-reference.ts b/packages/prose/src/mcp/content/quick-reference.ts index b6a80ec..360300d 100644 --- a/packages/prose/src/mcp/content/quick-reference.ts +++ b/packages/prose/src/mcp/content/quick-reference.ts @@ -66,6 +66,10 @@ interface FlowMeta { startedAt: Date; currentStep?: string; correlationId?: string; + // Durability-only — present when execute() was passed a \`durability\` option + runId?: string; + idempotencyKey?: string; + isResuming?: boolean; } interface RetryOptions { @@ -122,6 +126,10 @@ await flow.execute(input, deps, { throwOnMissingDatabase: true, throwOnMissingEventPublisher: true, }, + durability: { // Opt-in skip-ahead checkpointing + store: new MemoryDurabilityStore(), // or your custom DurabilityStore + runId: input.orderId, // Stable per-run identifier (typically a business ID) + }, }); \`\`\` @@ -164,6 +172,15 @@ Pass \`ctx.signal\` to async operations inside step handlers: }) \`\`\` +### Durability (opt-in skip-ahead checkpointing) +Pass \`durability: { store, runId }\` to \`.execute()\`. After every successful step, Prose persists a checkpoint. A subsequent \`execute()\` with the same \`runId\` resumes from the next undone step (or replays the saved result if the run already completed). + +Handlers must be idempotent — a step may run twice across a crash. Pass \`ctx.meta.idempotencyKey\` (\`\${runId}:\${stepName}\`) to external APIs that support idempotency (Stripe, SQS, etc.). + +\`MemoryDurabilityStore\` is built in but is for tests/dev only. Production needs a persistent \`DurabilityStore\` adapter. + +See \`prose://guides/durability\` and \`prose://api/durability-store\`. + ## Observers | Observer | Purpose | diff --git a/packages/prose/src/mcp/resources/index.ts b/packages/prose/src/mcp/resources/index.ts index 4e39033..6a5119c 100644 --- a/packages/prose/src/mcp/resources/index.ts +++ b/packages/prose/src/mcp/resources/index.ts @@ -8,6 +8,7 @@ import { EXECUTION_OPTIONS_REFERENCE, ERROR_TYPES_REFERENCE, OBSERVERS_REFERENCE, + DURABILITY_REFERENCE, } from '../content/api-reference.js'; import { GUIDES, GUIDE_TOPICS } from '../content/guides.js'; import { EXAMPLES, EXAMPLE_NAMES } from '../content/examples.js'; @@ -68,6 +69,14 @@ export function registerResources(server: McpServer) { contents: [{ uri: uri.href, text: OBSERVERS_REFERENCE, mimeType: 'text/markdown' }], })); + server.registerResource('api-durability-store', 'prose://api/durability-store', { + description: + 'API reference for opt-in durability — DurabilityStore, DurabilityOptions, FlowCheckpoint, and MemoryDurabilityStore', + mimeType: 'text/markdown', + }, async (uri) => ({ + contents: [{ uri: uri.href, text: DURABILITY_REFERENCE, mimeType: 'text/markdown' }], + })); + // Guide resources (dynamic template) const guideTemplate = new ResourceTemplate('prose://guides/{topic}', { list: async () => ({ From 7e761a19eea8a0264ee75aba2db68a1259d4175e Mon Sep 17 00:00:00 2001 From: Carlos Mimoso Date: Tue, 19 May 2026 12:07:01 +0100 Subject: [PATCH 5/7] chore: apply prettier formatting to satisfy CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI's nx format:check fails on every file in the branch's diff range against main. Running nx format:write fixes them. Pure whitespace — collapses single-element arrays, normalizes table column padding in .mdx files, splits long callback chains, etc. No semantic change. Verified with: nx run-many -t test build (120 tests, both builds clean). Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/release.yml | 2 +- README.md | 21 +- .../src/content/docs/api/durability-store.mdx | 50 +-- .../docs/src/content/docs/api/error-types.mdx | 28 +- apps/docs/src/content/docs/api/observers.mdx | 28 +- apps/docs/src/content/docs/api/types.mdx | 35 +- apps/docs/src/content/docs/comparison.mdx | 14 +- .../order-processing-with-durability.mdx | 31 +- .../content/docs/examples/user-onboarding.mdx | 1 - .../src/content/docs/guides/durability.mdx | 68 ++-- .../src/content/docs/guides/observability.mdx | 22 +- .../content/docs/guides/project-structure.mdx | 4 +- apps/docs/src/content/docs/index.mdx | 15 +- packages/prose/README.md | 159 ++++++---- packages/prose/eslint.config.mjs | 5 +- .../src/lib/__tests__/durability.spec.ts | 58 ++-- .../src/lib/__tests__/memory-store.spec.ts | 5 +- .../src/lib/__tests__/pino-observer.spec.ts | 27 +- .../src/lib/__tests__/store-conformance.ts | 10 +- .../prose/src/lib/__tests__/workflow.spec.ts | 299 +++++++++++------- packages/prose/src/lib/flow-executor.ts | 113 ++++--- packages/prose/src/lib/index.ts | 6 +- packages/prose/src/lib/types.ts | 55 ++-- packages/prose/src/mcp/resources/index.ts | 200 ++++++++---- 24 files changed, 761 insertions(+), 495 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index de1da21..ed1c062 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,7 +2,7 @@ name: Release on: workflow_run: - workflows: ["CI"] + workflows: ['CI'] types: [completed] branches: [main] workflow_dispatch: diff --git a/README.md b/README.md index 15797c2..151c58e 100644 --- a/README.md +++ b/README.md @@ -4,11 +4,20 @@ Declarative workflow DSL for orchestrating complex business operations in Javasc ```typescript const flow = createFlow<{ orderId: string }>('process-order') - .validate('checkInput', (ctx) => { /* ... */ }) - .step('fetchOrder', async (ctx) => { /* ... */ }) - .step('chargePayment', async (ctx) => { /* ... */ }) + .validate('checkInput', (ctx) => { + /* ... */ + }) + .step('fetchOrder', async (ctx) => { + /* ... */ + }) + .step('chargePayment', async (ctx) => { + /* ... */ + }) .withRetry({ maxAttempts: 3, delayMs: 200, backoffMultiplier: 2 }) - .event('orders', (ctx) => ({ eventType: 'order.charged', orderId: ctx.state.order.id })) + .event('orders', (ctx) => ({ + eventType: 'order.charged', + orderId: ctx.state.order.id, + })) .build(); await flow.execute({ orderId: 'ord_123' }, { db, eventPublisher }); @@ -18,8 +27,8 @@ Type-safe state threading, retries with exponential backoff, timeouts, database ## Packages -| Package | Description | -|---------|-------------| +| Package | Description | +| --------------------------------- | --------------------- | | [`@celom/prose`](packages/prose/) | Core workflow library | ## Development diff --git a/apps/docs/src/content/docs/api/durability-store.mdx b/apps/docs/src/content/docs/api/durability-store.mdx index ac5a5b0..a8db302 100644 --- a/apps/docs/src/content/docs/api/durability-store.mdx +++ b/apps/docs/src/content/docs/api/durability-store.mdx @@ -21,11 +21,11 @@ interface DurabilityStore { } ``` -| Method | Semantics | -|--------|-----------| -| `load(runId)` | Return the checkpoint for `runId`, or `null` if none exists. Must not throw on unknown ids. | +| Method | Semantics | +| ------------------ | ---------------------------------------------------------------------------------------------------------------------------------- | +| `load(runId)` | Return the checkpoint for `runId`, or `null` if none exists. Must not throw on unknown ids. | | `save(checkpoint)` | Persist the checkpoint, overwriting any existing entry for the same `runId`. Must be atomic — partial writes defeat checkpointing. | -| `delete(runId)` | Remove the checkpoint. No-op for an unknown `runId` — must not throw. | +| `delete(runId)` | Remove the checkpoint. No-op for an unknown `runId` — must not throw. | Implementations are responsible for serialization. JSON is the typical choice; whatever you pick must round-trip `Date` objects (`createdAt`, `updatedAt`) and support `undefined` for `breakValue` slots that are absent. @@ -48,18 +48,18 @@ interface FlowCheckpoint { } ``` -| Field | Description | -|-------|-------------| -| `flowName` | The flow's name (from `createFlow('name')`). Useful for diagnostics; not used to route resume. | -| `runId` | The `DurabilityOptions.runId` of this run. | -| `input` | The argument the flow was first started with. Used verbatim on resume — the caller's second `input` argument is ignored. | -| `state` | Accumulated state at the moment of the last successful step. | -| `completedSteps` | Names of finished or condition-skipped steps, in execution order. | -| `status` | `'running'` (in progress or crashed), `'completed'` (saved result is final), `'failed'` (errored after exhausting retries). | -| `breakValue` | Only present when the flow short-circuited via `breakIf`. The field's _presence_ distinguishes "broke" from "completed normally" — the value itself may be `undefined`. | -| `failedStep` | Only present when `status === 'failed'`. The step that errored, plus its error message. | -| `createdAt` | When the run first started. Preserved across resumes. | -| `updatedAt` | When the checkpoint was last written. | +| Field | Description | +| ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `flowName` | The flow's name (from `createFlow('name')`). Useful for diagnostics; not used to route resume. | +| `runId` | The `DurabilityOptions.runId` of this run. | +| `input` | The argument the flow was first started with. Used verbatim on resume — the caller's second `input` argument is ignored. | +| `state` | Accumulated state at the moment of the last successful step. | +| `completedSteps` | Names of finished or condition-skipped steps, in execution order. | +| `status` | `'running'` (in progress or crashed), `'completed'` (saved result is final), `'failed'` (errored after exhausting retries). | +| `breakValue` | Only present when the flow short-circuited via `breakIf`. The field's _presence_ distinguishes "broke" from "completed normally" — the value itself may be `undefined`. | +| `failedStep` | Only present when `status === 'failed'`. The step that errored, plus its error message. | +| `createdAt` | When the run first started. Preserved across resumes. | +| `updatedAt` | When the checkpoint was last written. | ## `DurabilityOptions` @@ -72,9 +72,9 @@ interface DurabilityOptions { } ``` -| Field | Description | -|-------|-------------| -| `store` | The store to read and write checkpoints through. | +| Field | Description | +| ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `store` | The store to read and write checkpoints through. | | `runId` | Stable identifier for this run. Same `runId` across `execute()` calls → resume or replay. New `runId` → fresh run. Typically derived from a business identifier. | ## `MemoryDurabilityStore` @@ -88,16 +88,18 @@ const store = new MemoryDurabilityStore(); ``` Test helpers (not part of the `DurabilityStore` interface): -| Method | Purpose | -|--------|---------| -| `size()` | Number of stored runs. | +| Method | Purpose | +| ----------------- | ---------------------------------------------------------- | +| `size()` | Number of stored runs. | | `snapshot(runId)` | Synchronous read of a single checkpoint (returns a clone). | -| `clear()` | Drop all stored checkpoints. | +| `clear()` | Drop all stored checkpoints. | `MemoryDurabilityStore` clones on both `load()` and `save()` so the executor cannot mutate stored state. Adapters that serialize to bytes get this property for free. diff --git a/apps/docs/src/content/docs/api/error-types.mdx b/apps/docs/src/content/docs/api/error-types.mdx index 8175f7f..616aed1 100644 --- a/apps/docs/src/content/docs/api/error-types.mdx +++ b/apps/docs/src/content/docs/api/error-types.mdx @@ -26,10 +26,10 @@ ValidationError.multiple(issues: ValidationIssue[]): ValidationError ### Properties -| Property | Type | Description | -|----------|------|-------------| -| `message` | `string` | Error message | -| `issues` | `ValidationIssue[]` | Array of `{ field, message }` objects | +| Property | Type | Description | +| --------- | ------------------- | ------------------------------------- | +| `message` | `string` | Error message | +| `issues` | `ValidationIssue[]` | Array of `{ field, message }` objects | ### Methods @@ -60,11 +60,11 @@ new FlowExecutionError(flowName: string, stepName: string, originalError: Error) ### Properties -| Property | Type | Description | -|----------|------|-------------| -| `flowName` | `string` | Name of the flow | -| `stepName` | `string` | Name of the step that failed | -| `originalError` | `Error` | The actual error thrown by the step | +| Property | Type | Description | +| --------------- | -------- | ----------------------------------- | +| `flowName` | `string` | Name of the flow | +| `stepName` | `string` | Name of the step that failed | +| `originalError` | `Error` | The actual error thrown by the step | ## `TimeoutError` @@ -72,8 +72,8 @@ Thrown when a flow or step exceeds its configured timeout. ### Properties -| Property | Type | Description | -|----------|------|-------------| -| `flowName` | `string` | Name of the flow | -| `stepName` | `string \| undefined` | Step name (undefined for flow-level timeout) | -| `timeoutMs` | `number` | The timeout value that was exceeded | +| Property | Type | Description | +| ----------- | --------------------- | -------------------------------------------- | +| `flowName` | `string` | Name of the flow | +| `stepName` | `string \| undefined` | Step name (undefined for flow-level timeout) | +| `timeoutMs` | `number` | The timeout value that was exceeded | diff --git a/apps/docs/src/content/docs/api/observers.mdx b/apps/docs/src/content/docs/api/observers.mdx index 75de355..f6c2cc2 100644 --- a/apps/docs/src/content/docs/api/observers.mdx +++ b/apps/docs/src/content/docs/api/observers.mdx @@ -14,11 +14,31 @@ interface FlowObserver { onFlowStart?(flowName: string, input: unknown): void; onFlowComplete?(flowName: string, output: unknown, duration: number): void; onFlowError?(flowName: string, error: Error, duration: number): void; - onFlowBreak?(flowName: string, breakStepName: string, returnValue: unknown, duration: number): void; + onFlowBreak?( + flowName: string, + breakStepName: string, + returnValue: unknown, + duration: number + ): void; onStepStart?(stepName: string, context: FlowContext): void; - onStepComplete?(stepName: string, result: unknown, duration: number, context: FlowContext): void; - onStepError?(stepName: string, error: Error, duration: number, context: FlowContext): void; - onStepRetry?(stepName: string, attempt: number, maxAttempts: number, error: Error): void; + onStepComplete?( + stepName: string, + result: unknown, + duration: number, + context: FlowContext + ): void; + onStepError?( + stepName: string, + error: Error, + duration: number, + context: FlowContext + ): void; + onStepRetry?( + stepName: string, + attempt: number, + maxAttempts: number, + error: Error + ): void; onStepSkipped?(stepName: string, context: FlowContext): void; } ``` diff --git a/apps/docs/src/content/docs/api/types.mdx b/apps/docs/src/content/docs/api/types.mdx index 26fe9d5..abf11c9 100644 --- a/apps/docs/src/content/docs/api/types.mdx +++ b/apps/docs/src/content/docs/api/types.mdx @@ -19,12 +19,12 @@ interface FlowContext { } ``` -| Property | Description | -|----------|-------------| -| `input` | Original input, readonly. Never changes during execution. | -| `state` | Accumulated state from prior steps. Each step's return merges into this. | -| `deps` | Dependencies injected via `.execute()`. | -| `meta` | Runtime metadata (see `FlowMeta`). | +| Property | Description | +| -------- | --------------------------------------------------------------------------- | +| `input` | Original input, readonly. Never changes during execution. | +| `state` | Accumulated state from prior steps. Each step's return merges into this. | +| `deps` | Dependencies injected via `.execute()`. | +| `meta` | Runtime metadata (see `FlowMeta`). | | `signal` | Combined abort signal from flow timeout, step timeout, and external signal. | ## `BaseFlowDependencies` @@ -65,15 +65,15 @@ interface FlowMeta { } ``` -| Field | Description | -|-------|-------------| -| `flowName` | The flow's name. | -| `startedAt` | When this `execute()` call began. Not preserved across resumes — set on each invocation. | -| `currentStep` | The name of the step currently executing. | -| `correlationId` | Custom or auto-generated ID propagated to events and observers. | -| `runId` | The `DurabilityOptions.runId` of this run. Present only when durability is configured. | +| Field | Description | +| ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `flowName` | The flow's name. | +| `startedAt` | When this `execute()` call began. Not preserved across resumes — set on each invocation. | +| `currentStep` | The name of the step currently executing. | +| `correlationId` | Custom or auto-generated ID propagated to events and observers. | +| `runId` | The `DurabilityOptions.runId` of this run. Present only when durability is configured. | | `idempotencyKey` | Stable per-step key (`${runId}:${currentStep}`). Pass to external APIs that support idempotency so re-runs are safe. Present only when durability is configured. | -| `isResuming` | `true` when this execution loaded an existing checkpoint rather than starting fresh. Present only when durability is configured. | +| `isResuming` | `true` when this execution loaded an existing checkpoint rather than starting fresh. Present only when durability is configured. | ## `RetryOptions` @@ -130,8 +130,11 @@ When you provide a typed `DatabaseClient` (e.g. from Drizzle), Prose infers `TTx Utility type that extracts the transaction client type from your dependencies. Useful when extracting transaction step handlers into standalone functions. ```typescript -type TxClientOf = - TDeps extends { db: DatabaseClient } ? TTx : unknown; +type TxClientOf = TDeps extends { + db: DatabaseClient; +} + ? TTx + : unknown; ``` Define it once in your application types and reuse across extracted step functions: diff --git a/apps/docs/src/content/docs/comparison.mdx b/apps/docs/src/content/docs/comparison.mdx index 2c75295..0ba51ae 100644 --- a/apps/docs/src/content/docs/comparison.mdx +++ b/apps/docs/src/content/docs/comparison.mdx @@ -9,7 +9,7 @@ Prose is an **in-process** workflow orchestration library. It runs inside your e Prose ships opt-in [skip-ahead durability](/prose/guides/durability/) — successful steps are checkpointed, and re-invoking `execute()` with the same `runId` resumes from the next undone step or replays the saved result. That covers crash recovery for in-process flows without external infrastructure. -It is **not** a substitute for [Temporal](https://temporal.io), [Inngest](https://www.inngest.com), or [Trigger.dev]( https://trigger.dev). Specifically, Prose does **not** give you: +It is **not** a substitute for [Temporal](https://temporal.io), [Inngest](https://www.inngest.com), or [Trigger.dev](https://trigger.dev). Specifically, Prose does **not** give you: - Long sleeps that survive process death (`await sleep(7.days)`) - An automatic resumer service that re-invokes crashed runs for you @@ -34,10 +34,10 @@ Libraries like [neverthrow](https://github.com/supermacro/neverthrow) or [fp-ts] Prose is for teams building backend services with multi-step business logic (process an order, onboard a user, handle a payment) who want structured retries, timeouts, transactions, observability, and type-safe state threading — without adopting new infrastructure or a new programming paradigm. -| Need | Tool | -|------|------| +| Need | Tool | +| --------------------------------------------------------------------- | ------------------------------ | | Hosted durable workflows, durable timers, cross-service orchestration | Temporal, Inngest, Trigger.dev | -| In-process flows with opt-in crash recovery | **Prose** (with `durability`) | -| Full effect system with typed errors | Effect-TS | -| State machines with complex transitions | XState | -| In-process business logic pipelines | **Prose** | +| In-process flows with opt-in crash recovery | **Prose** (with `durability`) | +| Full effect system with typed errors | Effect-TS | +| State machines with complex transitions | XState | +| In-process business logic pipelines | **Prose** | diff --git a/apps/docs/src/content/docs/examples/order-processing-with-durability.mdx b/apps/docs/src/content/docs/examples/order-processing-with-durability.mdx index 3e0eca9..d1d5e60 100644 --- a/apps/docs/src/content/docs/examples/order-processing-with-durability.mdx +++ b/apps/docs/src/content/docs/examples/order-processing-with-durability.mdx @@ -14,18 +14,25 @@ This example extends the [order processing pipeline](/prose/examples/order-proce Two things change. The handlers pass `ctx.meta.idempotencyKey` to any external side effect that supports idempotency, and the caller passes a `durability` option to `execute()`. ```typescript -import { createFlow, ValidationError, MemoryDurabilityStore } from '@celom/prose'; +import { + createFlow, + ValidationError, + MemoryDurabilityStore, +} from '@celom/prose'; const processOrder = createFlow('process-order') - .validate('validateOrder', (ctx) => { if (ctx.input.items.length === 0) - throw ValidationError.single('items', 'Order must have at least one item'); + throw ValidationError.single( + 'items', + 'Order must have at least one item' + ); }) .step('calculateTotal', (ctx) => { const subtotal = ctx.input.items.reduce( - (sum, item) => sum + item.price * item.quantity, 0 + (sum, item) => sum + item.price * item.quantity, + 0 ); const tax = subtotal * 0.08; return { subtotal, tax, total: subtotal + tax }; @@ -40,7 +47,7 @@ const processOrder = createFlow('process-order') currency: 'usd', customer: ctx.input.userId, }, - { idempotencyKey: ctx.meta.idempotencyKey }, + { idempotencyKey: ctx.meta.idempotencyKey } ); return { receipt }; }) @@ -109,12 +116,14 @@ const result = await processOrder.execute( { db, eventPublisher }, { durability: { store, runId: 'ord_abc123' }, - }, + } ); ``` ## A concrete crash scenario @@ -178,10 +187,10 @@ A **third** call to `execute()` with the same `runId` returns the saved result w When durability is configured, every step handler sees a stable per-step key: -| Step name | `ctx.meta.idempotencyKey` | -|-----------|--------------------------| -| `chargePayment` | `'ord_abc123:chargePayment'` | -| `persistOrder` | `'ord_abc123:persistOrder'` | +| Step name | `ctx.meta.idempotencyKey` | +| ------------------ | ------------------------------- | +| `chargePayment` | `'ord_abc123:chargePayment'` | +| `persistOrder` | `'ord_abc123:persistOrder'` | | `sendConfirmation` | `'ord_abc123:sendConfirmation'` | The key is the same on every attempt of the same logical step — that's the whole point. When the second `execute()` call re-runs `persistOrder`, it sees the same `idempotencyKey` it would have used the first time, so external systems can deduplicate. diff --git a/apps/docs/src/content/docs/examples/user-onboarding.mdx b/apps/docs/src/content/docs/examples/user-onboarding.mdx index c341881..406125a 100644 --- a/apps/docs/src/content/docs/examples/user-onboarding.mdx +++ b/apps/docs/src/content/docs/examples/user-onboarding.mdx @@ -19,7 +19,6 @@ interface OnboardInput { } const onboardUser = createFlow('onboard-user') - // 1. Validate email format .validate('checkEmail', (ctx) => { if (!ctx.input.email.includes('@')) diff --git a/apps/docs/src/content/docs/guides/durability.mdx b/apps/docs/src/content/docs/guides/durability.mdx index b068e38..54e54d2 100644 --- a/apps/docs/src/content/docs/guides/durability.mdx +++ b/apps/docs/src/content/docs/guides/durability.mdx @@ -23,31 +23,42 @@ const processOrder = createFlow<{ orderId: string }>('process-order') return { receipt }; }) .step('persistOrder', async (ctx) => { - await db.orders.insert({ id: ctx.input.orderId, receiptId: ctx.state.receipt.id }); + await db.orders.insert({ + id: ctx.input.orderId, + receiptId: ctx.state.receipt.id, + }); }) .build(); // First call — process dies after chargePayment, before persistOrder -await processOrder.execute({ orderId: 'ord_42' }, { db }, { - durability: { store, runId: 'ord_42' }, -}); +await processOrder.execute( + { orderId: 'ord_42' }, + { db }, + { + durability: { store, runId: 'ord_42' }, + } +); // Second call (after restart) with same runId — chargePayment is skipped, // persistOrder runs to completion. -await processOrder.execute({ orderId: 'ord_42' }, { db }, { - durability: { store, runId: 'ord_42' }, -}); +await processOrder.execute( + { orderId: 'ord_42' }, + { db }, + { + durability: { store, runId: 'ord_42' }, + } +); ``` ## How it works After each successful step, Prose writes a checkpoint containing the original input, accumulated state, and the names of completed steps. On entry, `execute()` loads any existing checkpoint and behaves according to its status: -| Stored status | `execute()` behavior | -|---------------|---------------------| -| _no checkpoint_ | Fresh run — every step executes. | +| Stored status | `execute()` behavior | +| --------------------- | ------------------------------------------------------------------------------------------------------------------------ | +| _no checkpoint_ | Fresh run — every step executes. | | `running` or `failed` | Resume — completed steps are skipped, state is loaded from the checkpoint, execution continues at the first undone step. | -| `completed` | Replay — the saved result is returned without invoking any handler. | +| `completed` | Replay — the saved result is returned without invoking any handler. | Checkpoints are persisted before observer notifications fire, so a crash in observer code can't lose the completion record. @@ -55,13 +66,15 @@ Checkpoints are persisted before observer notifications fire, so a crash in obse ### Choose a store -| Store | Use case | -|-------|----------| -| `MemoryDurabilityStore` | Built-in. Tests, local dev, single-process scripts. **Not production** — state is lost when the process exits. | -| Custom adapter | Implement [`DurabilityStore`](/prose/api/durability-store/) over your own persistence layer (SQLite, Postgres, Redis, S3). | +| Store | Use case | +| ----------------------- | -------------------------------------------------------------------------------------------------------------------------- | +| `MemoryDurabilityStore` | Built-in. Tests, local dev, single-process scripts. **Not production** — state is lost when the process exits. | +| Custom adapter | Implement [`DurabilityStore`](/prose/api/durability-store/) over your own persistence layer (SQLite, Postgres, Redis, S3). | Dedicated `@celom/prose-store-sqlite` and `@celom/prose-store-postgres` packages are on the roadmap. Until then, implement the three-method `DurabilityStore` interface yourself — see [Writing a custom store](#writing-a-custom-store). @@ -98,7 +111,8 @@ When durability is configured, Prose exposes `ctx.meta.idempotencyKey` — a sta For databases: use upserts keyed by `${runId}:${stepName}` or by a business identifier the handler can derive deterministically. For queues: send with a deduplication ID. For things you can't deduplicate (sending email, calling a webhook that has no idempotency support), accept that at-least-once is the contract you're working with — or move that side effect into an outbox table that a separate worker drains. ## Resuming a failed run @@ -138,15 +152,15 @@ What does **not** get persisted: ## Interaction with other features -| Feature | Behavior under durability | -|---------|--------------------------| -| `.parallel(...)` | Atomic checkpoint — all handlers in the block re-run if any one fails. The parallel block as a whole is what's marked complete, not the individual handlers. | -| `.withRetry(...)` | Retries happen within the step. The checkpoint is written once, after retries succeed. | -| `.breakIf(...)` | The break value is persisted in the checkpoint. Replay returns it without invoking any handler. | -| `.stepIf(...)` / `.step({ condition })` | The decision to skip is recorded. Resume does not re-evaluate the condition. | -| `.transaction(...)` | The transaction runs again if it failed before the checkpoint write. Database-level idempotency (upsert, unique constraints) is your responsibility. | -| `.event(...)` | At-least-once: an event may be re-published if the crash falls between `publisher.publish()` and the checkpoint write. Consumers must be idempotent. | -| `.validate(...)` | Re-runs on resume only if it threw on the previous attempt. Successful validations are recorded as completed. | +| Feature | Behavior under durability | +| --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `.parallel(...)` | Atomic checkpoint — all handlers in the block re-run if any one fails. The parallel block as a whole is what's marked complete, not the individual handlers. | +| `.withRetry(...)` | Retries happen within the step. The checkpoint is written once, after retries succeed. | +| `.breakIf(...)` | The break value is persisted in the checkpoint. Replay returns it without invoking any handler. | +| `.stepIf(...)` / `.step({ condition })` | The decision to skip is recorded. Resume does not re-evaluate the condition. | +| `.transaction(...)` | The transaction runs again if it failed before the checkpoint write. Database-level idempotency (upsert, unique constraints) is your responsibility. | +| `.event(...)` | At-least-once: an event may be re-published if the crash falls between `publisher.publish()` and the checkpoint write. Consumers must be idempotent. | +| `.validate(...)` | Re-runs on resume only if it threw on the previous attempt. Successful validations are recorded as completed. | ## What this isn't @@ -170,7 +184,7 @@ app.post('/orders/:id/process', async (req, res) => { const result = await processOrder.execute( { orderId: req.params.id }, { db, payments, mailer }, - { durability: { store, runId: req.params.id } }, + { durability: { store, runId: req.params.id } } ); res.json(result); }); diff --git a/apps/docs/src/content/docs/guides/observability.mdx b/apps/docs/src/content/docs/guides/observability.mdx index 98be557..5246e5f 100644 --- a/apps/docs/src/content/docs/guides/observability.mdx +++ b/apps/docs/src/content/docs/guides/observability.mdx @@ -21,17 +21,17 @@ await flow.execute(input, deps, { observer }); All hooks are optional — implement only what you need. -| Hook | Called when | -|------|------------| -| `onFlowStart(flowName, input)` | Flow begins | -| `onFlowComplete(flowName, output, duration)` | Flow finishes successfully | -| `onFlowError(flowName, error, duration)` | Flow fails | -| `onFlowBreak(flowName, breakStepName, returnValue, duration)` | Flow exits early via `breakIf` | -| `onStepStart(stepName, context)` | Step begins | -| `onStepComplete(stepName, result, duration, context)` | Step finishes | -| `onStepError(stepName, error, duration, context)` | Step fails (after exhausting retries) | -| `onStepRetry(stepName, attempt, maxAttempts, error)` | Step is about to be retried | -| `onStepSkipped(stepName, context)` | Conditional step is skipped | +| Hook | Called when | +| ------------------------------------------------------------- | ------------------------------------- | +| `onFlowStart(flowName, input)` | Flow begins | +| `onFlowComplete(flowName, output, duration)` | Flow finishes successfully | +| `onFlowError(flowName, error, duration)` | Flow fails | +| `onFlowBreak(flowName, breakStepName, returnValue, duration)` | Flow exits early via `breakIf` | +| `onStepStart(stepName, context)` | Step begins | +| `onStepComplete(stepName, result, duration, context)` | Step finishes | +| `onStepError(stepName, error, duration, context)` | Step fails (after exhausting retries) | +| `onStepRetry(stepName, attempt, maxAttempts, error)` | Step is about to be retried | +| `onStepSkipped(stepName, context)` | Conditional step is skipped | ## Inline observer diff --git a/apps/docs/src/content/docs/guides/project-structure.mdx b/apps/docs/src/content/docs/guides/project-structure.mdx index d9b4664..c589f3c 100644 --- a/apps/docs/src/content/docs/guides/project-structure.mdx +++ b/apps/docs/src/content/docs/guides/project-structure.mdx @@ -64,7 +64,7 @@ export const processOrder = createFlow('process-order') .validate('validateOrder', validateOrder) .step('calculateTotal', calculateTotal) .step('chargePayment', chargePayment) - .withRetry({ maxAttempts: 3, delayMs: 500 }) + .withRetry({ maxAttempts: 3, delayMs: 500 }) .transaction('persistOrder', persistOrder) .event('orderCreated', (ctx) => ({ channel: 'orders', @@ -230,6 +230,7 @@ describe('calculateTotal', () => { ### The flow file is auditable When reviewing a business operation, the flow file is the single source of truth. You can verify: + - The order of operations - Which steps have retries and what the policy is - Which steps run in a transaction @@ -243,6 +244,7 @@ Each step's state interface is a declaration of its upstream dependencies. Readi ### AI agents can reason about each piece independently When an AI agent needs to modify or extend the flow, the structure gives it clear boundaries: + - To understand the operation: read `flow.ts` - To modify a specific behavior: edit the relevant step file - To add a new step: create a new file in `steps/`, define its state interface, wire it into `flow.ts` diff --git a/apps/docs/src/content/docs/index.mdx b/apps/docs/src/content/docs/index.mdx index c5b5d8a..956696b 100644 --- a/apps/docs/src/content/docs/index.mdx +++ b/apps/docs/src/content/docs/index.mdx @@ -21,19 +21,23 @@ import { Card, CardGrid } from '@astrojs/starlight/components'; - Each step's return type merges into `ctx.state`, giving full autocomplete and compile-time checks across the entire pipeline. + Each step's return type merges into `ctx.state`, giving full autocomplete + and compile-time checks across the entire pipeline. Runs in-process with no external infrastructure. Just `async`/`await`. - Per-step retry policies with exponential backoff, delay caps, and conditional predicates. + Per-step retry policies with exponential backoff, delay caps, and + conditional predicates. - Flow-level and step-level timeouts backed by `AbortSignal` with cooperative cancellation. + Flow-level and step-level timeouts backed by `AbortSignal` with cooperative + cancellation. - Wrap steps in `db.transaction()` — works with Drizzle, Knex, Prisma, or any ORM. + Wrap steps in `db.transaction()` — works with Drizzle, Knex, Prisma, or any + ORM. Emit domain events to named channels with automatic correlation IDs. @@ -45,7 +49,8 @@ import { Card, CardGrid } from '@astrojs/starlight/components'; Plug in logging, metrics, or tracing through the observer interface. - Opt-in skip-ahead checkpointing — survive process crashes without re-running completed steps. + Opt-in skip-ahead checkpointing — survive process crashes without re-running + completed steps. diff --git a/packages/prose/README.md b/packages/prose/README.md index f3fdc12..29b3a05 100644 --- a/packages/prose/README.md +++ b/packages/prose/README.md @@ -80,23 +80,23 @@ The generic parameter defines the input shape. TypeScript infers the state type ```typescript const result = await flow.execute( - { orderId: 'ord_123' }, // input - { db, eventPublisher }, // dependencies - { timeout: 30_000 } // options (optional) + { orderId: 'ord_123' }, // input + { db, eventPublisher }, // dependencies + { timeout: 30_000 } // options (optional) ); ``` **Execution options:** -| Option | Type | Description | -|--------|------|-------------| -| `timeout` | `number` | Max duration for the entire flow (ms) | -| `stepTimeout` | `number` | Default max duration per step (ms) | -| `signal` | `AbortSignal` | External signal for cancellation | -| `observer` | `FlowObserver` | Lifecycle hooks for logging/metrics | -| `throwOnError` | `boolean` | `false` returns partial state instead of throwing | -| `correlationId` | `string` | Custom ID propagated to events and observers | -| `errorHandling` | `object` | Control behavior for missing deps (see below) | +| Option | Type | Description | +| --------------- | -------------- | ------------------------------------------------- | +| `timeout` | `number` | Max duration for the entire flow (ms) | +| `stepTimeout` | `number` | Default max duration per step (ms) | +| `signal` | `AbortSignal` | External signal for cancellation | +| `observer` | `FlowObserver` | Lifecycle hooks for logging/metrics | +| `throwOnError` | `boolean` | `false` returns partial state instead of throwing | +| `correlationId` | `string` | Custom ID propagated to events and observers | +| `errorHandling` | `object` | Control behavior for missing deps (see below) | ### Validation @@ -137,17 +137,17 @@ flow maxDelayMs: 5_000, shouldRetry: (err) => err.status !== 400, stepTimeout: 10_000, // override the flow-level stepTimeout for this step - }) + }); ``` -| Option | Type | Default | Description | -|--------|------|---------|-------------| -| `maxAttempts` | `number` | — | Total attempts (including the first) | -| `delayMs` | `number` | — | Initial delay between retries | -| `backoffMultiplier` | `number` | `1` | Multiplier applied to delay after each retry | -| `maxDelayMs` | `number` | `Infinity` | Upper bound on delay | -| `shouldRetry` | `(error) => boolean` | — | Predicate to conditionally retry | -| `stepTimeout` | `number` | — | Timeout override for this step | +| Option | Type | Default | Description | +| ------------------- | -------------------- | ---------- | -------------------------------------------- | +| `maxAttempts` | `number` | — | Total attempts (including the first) | +| `delayMs` | `number` | — | Initial delay between retries | +| `backoffMultiplier` | `number` | `1` | Multiplier applied to delay after each retry | +| `maxDelayMs` | `number` | `Infinity` | Upper bound on delay | +| `shouldRetry` | `(error) => boolean` | — | Predicate to conditionally retry | +| `stepTimeout` | `number` | — | Timeout override for this step | ### Timeouts & cancellation @@ -155,8 +155,8 @@ flow const controller = new AbortController(); const result = await flow.execute(input, deps, { - timeout: 30_000, // abort if the flow exceeds 30s - stepTimeout: 5_000, // abort any step that exceeds 5s + timeout: 30_000, // abort if the flow exceeds 30s + stepTimeout: 5_000, // abort any step that exceeds 5s signal: controller.signal, // cancel from outside }); @@ -182,12 +182,20 @@ flow .step('checkCache', (ctx) => { return { cached: cache.has(ctx.input.key) }; }) - .stepIf('fromCache', (ctx) => ctx.state.cached, (ctx) => { - return { value: cache.get(ctx.input.key) }; - }) - .stepIf('fromDb', (ctx) => !ctx.state.cached, async (ctx) => { - return { value: await db.get(ctx.input.key) }; - }) + .stepIf( + 'fromCache', + (ctx) => ctx.state.cached, + (ctx) => { + return { value: cache.get(ctx.input.key) }; + } + ) + .stepIf( + 'fromDb', + (ctx) => !ctx.state.cached, + async (ctx) => { + return { value: await db.get(ctx.input.key) }; + } + ); ``` ### Early exit with breakIf @@ -264,20 +272,22 @@ interface FlowEventPublisher { Run independent handlers concurrently and merge results into state. ```typescript -flow.parallel('fetchAll', 'deep', +flow.parallel( + 'fetchAll', + 'deep', async (ctx) => ({ users: await fetchUsers() }), - async (ctx) => ({ posts: await fetchPosts() }), + async (ctx) => ({ posts: await fetchPosts() }) ); // ctx.state now has both `users` and `posts` ``` **Merge strategies:** -| Strategy | Behavior | -|----------|----------| -| `'shallow'` | `Object.assign()` — later results override earlier ones | -| `'error-on-conflict'` | Throws if any keys overlap between results | -| `'deep'` | Recursive merge; arrays are concatenated | +| Strategy | Behavior | +| --------------------- | ------------------------------------------------------- | +| `'shallow'` | `Object.assign()` — later results override earlier ones | +| `'error-on-conflict'` | Throws if any keys overlap between results | +| `'deep'` | Recursive merge; arrays are concatenated | ### Output transformation @@ -338,17 +348,17 @@ await flow.execute(input, deps, { observer }); **Observer hooks:** -| Hook | Called when | -|------|------------| -| `onFlowStart` | Flow begins | -| `onFlowComplete` | Flow finishes successfully | -| `onFlowError` | Flow fails | -| `onFlowBreak` | Flow exits early via `breakIf` | -| `onStepStart` | Step begins | -| `onStepComplete` | Step finishes | -| `onStepError` | Step fails (after exhausting retries) | -| `onStepRetry` | Step is about to be retried | -| `onStepSkipped` | Conditional step is skipped | +| Hook | Called when | +| ---------------- | ------------------------------------- | +| `onFlowStart` | Flow begins | +| `onFlowComplete` | Flow finishes successfully | +| `onFlowError` | Flow fails | +| `onFlowBreak` | Flow exits early via `breakIf` | +| `onStepStart` | Step begins | +| `onStepComplete` | Step finishes | +| `onStepError` | Step fails (after exhausting retries) | +| `onStepRetry` | Step is about to be retried | +| `onStepSkipped` | Conditional step is skipped | All hooks are optional — implement only what you need: @@ -368,7 +378,11 @@ await flow.execute(input, deps, { By default, step errors are wrapped in `FlowExecutionError` and thrown. ```typescript -import { FlowExecutionError, ValidationError, TimeoutError } from '@celom/prose'; +import { + FlowExecutionError, + ValidationError, + TimeoutError, +} from '@celom/prose'; try { await flow.execute(input, deps); @@ -394,8 +408,8 @@ Control behavior when optional dependencies are missing: ```typescript await flow.execute(input, deps, { errorHandling: { - throwOnMissingDatabase: false, // warn instead of throwing - throwOnMissingEventPublisher: false, // warn instead of throwing + throwOnMissingDatabase: false, // warn instead of throwing + throwOnMissingEventPublisher: false, // warn instead of throwing }, }); ``` @@ -406,13 +420,13 @@ Every step handler receives `ctx.meta` with runtime metadata: ```typescript flow.step('example', (ctx) => { - ctx.meta.flowName; // 'process-order' - ctx.meta.currentStep; // 'example' - ctx.meta.startedAt; // Date - ctx.meta.correlationId; // auto-generated or custom - ctx.meta.runId; // present only when durability is configured + ctx.meta.flowName; // 'process-order' + ctx.meta.currentStep; // 'example' + ctx.meta.startedAt; // Date + ctx.meta.correlationId; // auto-generated or custom + ctx.meta.runId; // present only when durability is configured ctx.meta.idempotencyKey; // `${runId}:${stepName}` — pass to external APIs - ctx.meta.isResuming; // true when this execution loaded a saved checkpoint + ctx.meta.isResuming; // true when this execution loaded a saved checkpoint }); ``` @@ -434,28 +448,39 @@ const processOrder = createFlow<{ orderId: string }>('process-order') return { receipt }; }) .step('persistOrder', async (ctx) => { - await db.orders.upsert({ id: ctx.input.orderId, receiptId: ctx.state.receipt.id }); + await db.orders.upsert({ + id: ctx.input.orderId, + receiptId: ctx.state.receipt.id, + }); }) .build(); // First call — crashes after chargePayment, before persistOrder -await processOrder.execute({ orderId: 'ord_42' }, { db, payments }, { - durability: { store, runId: 'ord_42' }, -}); +await processOrder.execute( + { orderId: 'ord_42' }, + { db, payments }, + { + durability: { store, runId: 'ord_42' }, + } +); // After restart — chargePayment is skipped, persistOrder runs -await processOrder.execute({ orderId: 'ord_42' }, { db, payments }, { - durability: { store, runId: 'ord_42' }, -}); +await processOrder.execute( + { orderId: 'ord_42' }, + { db, payments }, + { + durability: { store, runId: 'ord_42' }, + } +); ``` The three behaviors of `execute()` with a `durability` option: -| Stored status | Behavior | -|---------------|----------| -| _no checkpoint_ | Fresh run — every step executes | +| Stored status | Behavior | +| -------------------- | --------------------------------------------------------------------------------------------------- | +| _no checkpoint_ | Fresh run — every step executes | | `running` / `failed` | Resume — completed steps are skipped, state is loaded, execution continues at the first undone step | -| `completed` | Replay — the saved result is returned without invoking any handler | +| `completed` | Replay — the saved result is returned without invoking any handler | A step may run twice across a crash. `ctx.meta.idempotencyKey` is a stable per-step key (`${runId}:${stepName}`) — pass it to Stripe, SQS, or any external API that supports idempotency. diff --git a/packages/prose/eslint.config.mjs b/packages/prose/eslint.config.mjs index f45293f..ced8c09 100644 --- a/packages/prose/eslint.config.mjs +++ b/packages/prose/eslint.config.mjs @@ -14,10 +14,7 @@ export default [ // Test files and shared test helpers (may import vitest) '{projectRoot}/src/**/__tests__/**', ], - ignoredDependencies: [ - '@modelcontextprotocol/sdk', - 'zod', - ], + ignoredDependencies: ['@modelcontextprotocol/sdk', 'zod'], }, ], }, diff --git a/packages/prose/src/lib/__tests__/durability.spec.ts b/packages/prose/src/lib/__tests__/durability.spec.ts index 05816af..e89971c 100644 --- a/packages/prose/src/lib/__tests__/durability.spec.ts +++ b/packages/prose/src/lib/__tests__/durability.spec.ts @@ -34,7 +34,7 @@ describe('Durability', () => { const result = await flow.execute( { x: 21 }, {}, - { durability: { store, runId: 'r1' } }, + { durability: { store, runId: 'r1' } } ); expect(result).toEqual({ doubled: 42 }); @@ -51,11 +51,7 @@ describe('Durability', () => { }) .build(); - await flow.execute( - {}, - {}, - { durability: { store, runId: 'order-7' } }, - ); + await flow.execute({}, {}, { durability: { store, runId: 'order-7' } }); expect(seenKeys).toEqual(['order-7:alpha', 'order-7:beta']); }); @@ -95,7 +91,7 @@ describe('Durability', () => { .build(); await expect( - flow.execute({}, {}, { durability: { store, runId: 'r1' } }), + flow.execute({}, {}, { durability: { store, runId: 'r1' } }) ).rejects.toThrow('boom'); // First run: a ran once, b ran once and threw, c didn't run @@ -106,7 +102,7 @@ describe('Durability', () => { const result = await flow.execute( {}, {}, - { durability: { store, runId: 'r1' } }, + { durability: { store, runId: 'r1' } } ); // Resume: a was NOT re-run, b ran a second time and succeeded, c ran @@ -133,7 +129,7 @@ describe('Durability', () => { .build(); await expect( - flow.execute({}, {}, { durability: { store, runId: 'r1' } }), + flow.execute({}, {}, { durability: { store, runId: 'r1' } }) ).rejects.toThrow(); await flow.execute({}, {}, { durability: { store, runId: 'r1' } }); @@ -156,18 +152,14 @@ describe('Durability', () => { .build(); await expect( - flow.execute( - { value: 100 }, - {}, - { durability: { store, runId: 'r1' } }, - ), + flow.execute({ value: 100 }, {}, { durability: { store, runId: 'r1' } }) ).rejects.toThrow(); // Resume with an intentionally different input — should be ignored await flow.execute( { value: 999 }, {}, - { durability: { store, runId: 'r1' } }, + { durability: { store, runId: 'r1' } } ); expect(observedInput).toEqual({ value: 100 }); @@ -186,7 +178,7 @@ describe('Durability', () => { .build(); await expect( - flow.execute({}, {}, { durability: { store, runId: 'r1' } }), + flow.execute({}, {}, { durability: { store, runId: 'r1' } }) ).rejects.toThrow(); const afterFailure = await store.load('r1'); const originalCreatedAt = afterFailure?.createdAt; @@ -208,7 +200,7 @@ describe('Durability', () => { const first = await flow.execute( {}, {}, - { durability: { store, runId: 'r1' } }, + { durability: { store, runId: 'r1' } } ); expect(handler).toHaveBeenCalledTimes(1); expect(first).toEqual({ v: 1 }); @@ -216,7 +208,7 @@ describe('Durability', () => { const second = await flow.execute( {}, {}, - { durability: { store, runId: 'r1' } }, + { durability: { store, runId: 'r1' } } ); expect(handler).toHaveBeenCalledTimes(1); expect(second).toEqual({ v: 1 }); @@ -232,7 +224,7 @@ describe('Durability', () => { .step('a', aHandler) .breakIf( (ctx) => ctx.state.shouldBreak === true, - () => ({ broken: true }), + () => ({ broken: true }) ) .step('c', cHandler) .build(); @@ -240,7 +232,7 @@ describe('Durability', () => { const first = await flow.execute( {}, {}, - { durability: { store, runId: 'r1' } }, + { durability: { store, runId: 'r1' } } ); expect(first).toEqual({ broken: true }); expect(cHandler).not.toHaveBeenCalled(); @@ -248,7 +240,7 @@ describe('Durability', () => { const second = await flow.execute( {}, {}, - { durability: { store, runId: 'r1' } }, + { durability: { store, runId: 'r1' } } ); expect(second).toEqual({ broken: true }); // Confirm replay short-circuits the entire execution, not just step c @@ -272,13 +264,13 @@ describe('Durability', () => { .build(); await expect( - flow.execute({}, {}, { durability: { store, runId: 'r1' } }), + flow.execute({}, {}, { durability: { store, runId: 'r1' } }) ).rejects.toThrow(); const result = await flow.execute( {}, {}, - { durability: { store, runId: 'r1' } }, + { durability: { store, runId: 'r1' } } ); expect(result).toEqual({ shouldBreak: false, cRan: true }); expect(cHandler).toHaveBeenCalledTimes(1); @@ -290,7 +282,7 @@ describe('Durability', () => { it('does not re-evaluate a skipped condition on resume', async () => { const skippedHandler = vi.fn(() => ({ shouldNotMerge: true })); const conditionCheck = vi.fn( - (ctx: { state: { skip?: boolean } }) => ctx.state.skip !== true, + (ctx: { state: { skip?: boolean } }) => ctx.state.skip !== true ); let firstAttempt = true; const flow = createFlow('test') @@ -306,7 +298,7 @@ describe('Durability', () => { .build(); await expect( - flow.execute({}, {}, { durability: { store, runId: 'r1' } }), + flow.execute({}, {}, { durability: { store, runId: 'r1' } }) ).rejects.toThrow(); expect(conditionCheck).toHaveBeenCalledTimes(1); @@ -363,17 +355,13 @@ describe('Durability', () => { .build(); await expect( - flow.execute( - {}, - { db }, - { durability: { store, runId: 'r1' } }, - ), + flow.execute({}, { db }, { durability: { store, runId: 'r1' } }) ).rejects.toThrow(); const result = await flow.execute( {}, { db }, - { durability: { store, runId: 'r1' } }, + { durability: { store, runId: 'r1' } } ); expect(txAttempts).toBe(2); @@ -398,12 +386,12 @@ describe('Durability', () => { await flow.execute( {}, { eventPublisher }, - { durability: { store, runId: 'r1' } }, + { durability: { store, runId: 'r1' } } ); await flow.execute( {}, { eventPublisher }, - { durability: { store, runId: 'r1' } }, + { durability: { store, runId: 'r1' } } ); expect(published).toHaveLength(1); @@ -427,7 +415,7 @@ describe('Durability', () => { .build(); await expect( - flow.execute({}, {}, { durability: { store, runId: 'r1' } }), + flow.execute({}, {}, { durability: { store, runId: 'r1' } }) ).rejects.toThrow(); expect(aCount).toHaveBeenCalledTimes(1); @@ -437,7 +425,7 @@ describe('Durability', () => { const result = await flow.execute( {}, {}, - { durability: { store, runId: 'r1' } }, + { durability: { store, runId: 'r1' } } ); // Parallel is atomic: all three handlers re-run on resume, not just c diff --git a/packages/prose/src/lib/__tests__/memory-store.spec.ts b/packages/prose/src/lib/__tests__/memory-store.spec.ts index 31162e3..4d75096 100644 --- a/packages/prose/src/lib/__tests__/memory-store.spec.ts +++ b/packages/prose/src/lib/__tests__/memory-store.spec.ts @@ -13,7 +13,10 @@ import { MemoryDurabilityStore } from '../index.js'; import { storeConformanceSuite } from './store-conformance.js'; import type { FlowCheckpoint } from '../index.js'; -storeConformanceSuite('MemoryDurabilityStore', () => new MemoryDurabilityStore()); +storeConformanceSuite( + 'MemoryDurabilityStore', + () => new MemoryDurabilityStore() +); function fixture(): FlowCheckpoint { return { diff --git a/packages/prose/src/lib/__tests__/pino-observer.spec.ts b/packages/prose/src/lib/__tests__/pino-observer.spec.ts index 2f7b19a..566c50a 100644 --- a/packages/prose/src/lib/__tests__/pino-observer.spec.ts +++ b/packages/prose/src/lib/__tests__/pino-observer.spec.ts @@ -33,7 +33,7 @@ describe('PinoFlowObserver', () => { observer.onFlowStart('my-flow', { userId: '1' }); expect(childLogger.info).toHaveBeenCalledWith( { flow: 'my-flow' }, - 'Flow started', + 'Flow started' ); }); @@ -41,7 +41,7 @@ describe('PinoFlowObserver', () => { observer.onFlowComplete('my-flow', {} as any, 1234); expect(childLogger.info).toHaveBeenCalledWith( { flow: 'my-flow', durationMs: 1234 }, - 'Flow completed', + 'Flow completed' ); }); @@ -50,7 +50,7 @@ describe('PinoFlowObserver', () => { observer.onFlowError('my-flow', err, 500); expect(childLogger.error).toHaveBeenCalledWith( { flow: 'my-flow', durationMs: 500, err }, - 'Flow failed', + 'Flow failed' ); }); @@ -58,7 +58,7 @@ describe('PinoFlowObserver', () => { observer.onFlowBreak('my-flow', 'check-guard', undefined, 300); expect(childLogger.info).toHaveBeenCalledWith( { flow: 'my-flow', breakStep: 'check-guard', durationMs: 300 }, - 'Flow short-circuited', + 'Flow short-circuited' ); }); }); @@ -68,15 +68,20 @@ describe('PinoFlowObserver', () => { observer.onStepStart('fetch-data', {} as any); expect(childLogger.debug).toHaveBeenCalledWith( { step: 'fetch-data' }, - 'Step started', + 'Step started' ); }); it('logs step completion with duration and result keys', () => { - observer.onStepComplete('fetch-data', { users: [], count: 5 }, 42, {} as any); + observer.onStepComplete( + 'fetch-data', + { users: [], count: 5 }, + 42, + {} as any + ); expect(childLogger.info).toHaveBeenCalledWith( { step: 'fetch-data', durationMs: 42, resultKeys: ['users', 'count'] }, - 'Step completed', + 'Step completed' ); }); @@ -84,7 +89,7 @@ describe('PinoFlowObserver', () => { observer.onStepComplete('validate', undefined, 1, {} as any); expect(childLogger.info).toHaveBeenCalledWith( { step: 'validate', durationMs: 1 }, - 'Step completed', + 'Step completed' ); }); @@ -93,7 +98,7 @@ describe('PinoFlowObserver', () => { observer.onStepError('persist', err, 5000, {} as any); expect(childLogger.error).toHaveBeenCalledWith( { step: 'persist', durationMs: 5000, err }, - 'Step failed', + 'Step failed' ); }); @@ -102,7 +107,7 @@ describe('PinoFlowObserver', () => { observer.onStepRetry('fetch-data', 2, 3, err); expect(childLogger.warn).toHaveBeenCalledWith( { step: 'fetch-data', attempt: 2, maxAttempts: 3, err }, - 'Step retrying', + 'Step retrying' ); }); @@ -110,7 +115,7 @@ describe('PinoFlowObserver', () => { observer.onStepSkipped('enrich', {} as any); expect(childLogger.debug).toHaveBeenCalledWith( { step: 'enrich' }, - 'Step skipped', + 'Step skipped' ); }); }); diff --git a/packages/prose/src/lib/__tests__/store-conformance.ts b/packages/prose/src/lib/__tests__/store-conformance.ts index 09d3923..9bd08a0 100644 --- a/packages/prose/src/lib/__tests__/store-conformance.ts +++ b/packages/prose/src/lib/__tests__/store-conformance.ts @@ -30,7 +30,7 @@ function fixture(overrides: Partial = {}): FlowCheckpoint { export function storeConformanceSuite( name: string, - makeStore: () => DurabilityStore, + makeStore: () => DurabilityStore ): void { describe(`DurabilityStore conformance: ${name}`, () => { let store: DurabilityStore; @@ -60,7 +60,7 @@ export function storeConformanceSuite( it('save() overwrites an existing entry for the same runId', async () => { await store.save(fixture({ runId: 'r1', status: 'running' })); await store.save( - fixture({ runId: 'r1', status: 'completed', state: { final: true } }), + fixture({ runId: 'r1', status: 'completed', state: { final: true } }) ); const loaded = await store.load('r1'); @@ -74,7 +74,7 @@ export function storeConformanceSuite( runId: 'r1', status: 'failed', failedStep: { name: 'chargePayment', error: 'declined' }, - }), + }) ); const loaded = await store.load('r1'); @@ -92,14 +92,14 @@ export function storeConformanceSuite( fixture({ runId: 'normal', status: 'completed', - }), + }) ); await store.save( fixture({ runId: 'broke-with-value', status: 'completed', breakValue: { broken: true }, - }), + }) ); const normal = await store.load('normal'); diff --git a/packages/prose/src/lib/__tests__/workflow.spec.ts b/packages/prose/src/lib/__tests__/workflow.spec.ts index 9b64efd..f11c037 100644 --- a/packages/prose/src/lib/__tests__/workflow.spec.ts +++ b/packages/prose/src/lib/__tests__/workflow.spec.ts @@ -3,11 +3,7 @@ */ import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { - createFlow, - ValidationError, - TimeoutError, -} from '../index.js'; +import { createFlow, ValidationError, TimeoutError } from '../index.js'; type EmptyDeps = Record; @@ -61,7 +57,7 @@ describe('Workflow Library', () => { .stepIf( 'optionalStep', (ctx) => ctx.input.enabled, - (ctx) => ({ executed: true }), + (ctx) => ({ executed: true }) ) .build(); @@ -129,7 +125,7 @@ describe('Workflow Library', () => { it('should accumulate state through steps', async () => { const flow = createFlow<{ a: number; b: number }, typeof mockDeps>( - 'accumulate', + 'accumulate' ) .step('add', (ctx) => ({ sum: ctx.input.a + ctx.input.b, @@ -148,7 +144,7 @@ describe('Workflow Library', () => { it('should handle validation errors', async () => { const flow = createFlow<{ email: string }, typeof mockDeps>( - 'validate-fail', + 'validate-fail' ) .validate('checkEmail', (ctx) => { if (!ctx.input.email.includes('@')) { @@ -158,7 +154,7 @@ describe('Workflow Library', () => { .build(); await expect( - flow.execute({ email: 'invalid' }, mockDeps), + flow.execute({ email: 'invalid' }, mockDeps) ).rejects.toThrow(ValidationError); }); @@ -223,7 +219,7 @@ describe('Workflow Library', () => { expect.objectContaining({ eventType: 'assignment.submit', data: true, - }), + }) ); }); @@ -305,7 +301,7 @@ describe('Workflow Library', () => { .build(); await expect(flow.execute({}, mockDeps, { timeout: 50 })).rejects.toThrow( - TimeoutError, + TimeoutError ); }); @@ -319,7 +315,7 @@ describe('Workflow Library', () => { const start = Date.now(); await expect( - flow.execute({}, mockDeps, { stepTimeout: 20 }), + flow.execute({}, mockDeps, { stepTimeout: 20 }) ).rejects.toThrow(TimeoutError); const duration = Date.now() - start; @@ -344,7 +340,7 @@ describe('Workflow Library', () => { input: {}, deps: mockDeps, }), - mockDb, + mockDb ); }); @@ -354,25 +350,41 @@ describe('Workflow Library', () => { .build(); await expect(flow.execute({}, { notADb: 'wrong' })).rejects.toThrow( - 'No database found in dependencies for transaction', + 'No database found in dependencies for transaction' ); }); it('should use explicit channels for events', async () => { const flow = createFlow('channel-mapping') - .event('auth', () => ({ eventType: 'user.create', data: 'user' }), 'authEvent') - .event('content', () => ({ - eventType: 'subject.create', - data: 'subject', - }), 'contentEvent') - .event('notification', () => ({ - eventType: 'notification.send', - data: 'notif', - }), 'notificationEvent') - .event('notification', () => ({ - eventType: 'email.send', - data: 'default', - }), 'emailEvent') + .event( + 'auth', + () => ({ eventType: 'user.create', data: 'user' }), + 'authEvent' + ) + .event( + 'content', + () => ({ + eventType: 'subject.create', + data: 'subject', + }), + 'contentEvent' + ) + .event( + 'notification', + () => ({ + eventType: 'notification.send', + data: 'notif', + }), + 'notificationEvent' + ) + .event( + 'notification', + () => ({ + eventType: 'email.send', + data: 'default', + }), + 'emailEvent' + ) .build(); await flow.execute({}, mockDeps); @@ -380,22 +392,22 @@ describe('Workflow Library', () => { expect(mockEventPublisher.publish).toHaveBeenNthCalledWith( 1, 'auth', - expect.objectContaining({ eventType: 'user.create' }), + expect.objectContaining({ eventType: 'user.create' }) ); expect(mockEventPublisher.publish).toHaveBeenNthCalledWith( 2, 'content', - expect.objectContaining({ eventType: 'subject.create' }), + expect.objectContaining({ eventType: 'subject.create' }) ); expect(mockEventPublisher.publish).toHaveBeenNthCalledWith( 3, 'notification', - expect.objectContaining({ eventType: 'notification.send' }), + expect.objectContaining({ eventType: 'notification.send' }) ); expect(mockEventPublisher.publish).toHaveBeenNthCalledWith( 4, 'notification', - expect.objectContaining({ eventType: 'email.send' }), + expect.objectContaining({ eventType: 'email.send' }) ); }); @@ -411,7 +423,7 @@ describe('Workflow Library', () => { expect.objectContaining({ eventType: 'user.create', correlationId: 'test-123', - }), + }) ); }); @@ -421,23 +433,29 @@ describe('Workflow Library', () => { .build(); await expect(flow.execute({}, { db: mockDb })).rejects.toThrow( - 'No event publisher found in dependencies', + 'No event publisher found in dependencies' ); }); it('should handle missing event publisher gracefully when configured', async () => { - const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => { /* empty */ }); + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => { + /* empty */ + }); const flow = createFlow('no-publisher-warn') .event('auth', () => ({ eventType: 'user.create', data: 'test' })) .build(); - await flow.execute({}, { db: mockDb }, { - errorHandling: { throwOnMissingEventPublisher: false }, - }); + await flow.execute( + {}, + { db: mockDb }, + { + errorHandling: { throwOnMissingEventPublisher: false }, + } + ); expect(consoleSpy).toHaveBeenCalledWith( - expect.stringContaining('No event publisher found'), + expect.stringContaining('No event publisher found') ); consoleSpy.mockRestore(); @@ -446,10 +464,14 @@ describe('Workflow Library', () => { it('should skip undefined events', async () => { const flow = createFlow('undefined-events') .event('notification', () => undefined, 'undefinedEvent') - .event('notification', () => ({ - eventType: 'email.send', - data: 'data', - }), 'emailEvent') + .event( + 'notification', + () => ({ + eventType: 'email.send', + data: 'data', + }), + 'emailEvent' + ) .build(); await flow.execute({}, mockDeps); @@ -457,7 +479,7 @@ describe('Workflow Library', () => { expect(mockEventPublisher.publish).toHaveBeenCalledTimes(1); expect(mockEventPublisher.publish).toHaveBeenCalledWith( 'notification', - expect.objectContaining({ eventType: 'email.send' }), + expect.objectContaining({ eventType: 'email.send' }) ); }); @@ -468,7 +490,7 @@ describe('Workflow Library', () => { vi.spyOn(global, 'setTimeout').mockImplementation((( fn: any, - delay: number, + delay: number ) => { delays.push(delay); return originalSetTimeout(fn, 0); @@ -505,7 +527,7 @@ describe('Workflow Library', () => { vi.spyOn(global, 'setTimeout').mockImplementation((( fn: any, - delay: number, + delay: number ) => { delays.push(delay); return originalSetTimeout(fn, 0); @@ -643,10 +665,12 @@ describe('Workflow Library', () => { describe('Builder .parallel() method', () => { it('should execute handlers in parallel and merge results', async () => { const flow = createFlow<{ org: string }, EmptyDeps>('builder-parallel') - .parallel('fetchAll', 'shallow', + .parallel( + 'fetchAll', + 'shallow', () => ({ users: ['alice', 'bob'] }), () => ({ posts: ['p1', 'p2'] }), - () => ({ comments: ['c1'] }), + () => ({ comments: ['c1'] }) ) .build(); @@ -670,7 +694,9 @@ describe('Workflow Library', () => { return { b: 2 }; }; - const flow = createFlow, EmptyDeps>('concurrent-test') + const flow = createFlow, EmptyDeps>( + 'concurrent-test' + ) .parallel('ops', 'shallow', handler1, handler2) .build(); @@ -685,9 +711,11 @@ describe('Workflow Library', () => { it('should merge with prior state from earlier steps', async () => { const flow = createFlow<{ x: number }, EmptyDeps>('merge-prior') .step('init', (ctx) => ({ base: ctx.input.x })) - .parallel('fetch', 'shallow', + .parallel( + 'fetch', + 'shallow', () => ({ a: 10 }), - () => ({ b: 20 }), + () => ({ b: 20 }) ) .build(); @@ -698,22 +726,26 @@ describe('Workflow Library', () => { it('should support error-on-conflict strategy', async () => { const flow = createFlow, EmptyDeps>('conflict-test') - .parallel('ops', 'error-on-conflict', + .parallel( + 'ops', + 'error-on-conflict', () => ({ key: 'first' }), - () => ({ key: 'second' }), + () => ({ key: 'second' }) ) .build(); await expect(flow.execute({}, {})).rejects.toThrow( - "Key conflict detected in parallel merge: 'key'", + "Key conflict detected in parallel merge: 'key'" ); }); it('should support deep merge strategy', async () => { const flow = createFlow, EmptyDeps>('deep-merge') - .parallel('ops', 'deep', + .parallel( + 'ops', + 'deep', () => ({ config: { theme: 'dark' } }), - () => ({ config: { lang: 'en' } }), + () => ({ config: { lang: 'en' } }) ) .build(); @@ -726,9 +758,11 @@ describe('Workflow Library', () => { it('should allow chaining after parallel', async () => { const flow = createFlow, EmptyDeps>('chain-after') - .parallel('fetch', 'shallow', + .parallel( + 'fetch', + 'shallow', () => ({ users: ['a'] }), - () => ({ posts: ['p'] }), + () => ({ posts: ['p'] }) ) .step('count', (ctx) => ({ total: ctx.state.users.length + ctx.state.posts.length, @@ -747,7 +781,7 @@ describe('Workflow Library', () => { describe('Builder .pipe() method', () => { function withSetup( - builder: ReturnType, EmptyDeps>>, + builder: ReturnType, EmptyDeps>> ) { return builder .step('setupA', () => ({ a: 1 })) @@ -776,9 +810,7 @@ describe('Workflow Library', () => { }); it('should compose multiple pipes', async () => { - function withExtra( - builder: ReturnType, - ) { + function withExtra(builder: ReturnType) { return builder.step('extra', () => ({ c: 3 })); } @@ -836,7 +868,7 @@ describe('Workflow Library', () => { expect(observer.onFlowComplete).toHaveBeenCalledWith( 'test', { result: 'ok' }, - expect.any(Number), // duration + expect.any(Number) // duration ); expect(observer.onFlowComplete).toHaveBeenCalledTimes(1); }); @@ -857,7 +889,7 @@ describe('Workflow Library', () => { expect(observer.onFlowError).toHaveBeenCalledWith( 'test', expect.any(Error), - expect.any(Number), // duration + expect.any(Number) // duration ); expect(observer.onFlowError).toHaveBeenCalledTimes(1); }); @@ -882,28 +914,28 @@ describe('Workflow Library', () => { expect(observer.onStepStart).toHaveBeenNthCalledWith( 1, 'step1', - expect.any(Object), + expect.any(Object) ); expect(observer.onStepComplete).toHaveBeenNthCalledWith( 1, 'step1', { a: 1 }, expect.any(Number), - expect.any(Object), + expect.any(Object) ); // Check step2 was called expect(observer.onStepStart).toHaveBeenNthCalledWith( 2, 'step2', - expect.any(Object), + expect.any(Object) ); expect(observer.onStepComplete).toHaveBeenNthCalledWith( 2, 'step2', { b: 2 }, expect.any(Number), - expect.any(Object), + expect.any(Object) ); }); @@ -925,7 +957,7 @@ describe('Workflow Library', () => { { maxAttempts: 3, delayMs: 10, - }, + } ) .build(); @@ -935,7 +967,7 @@ describe('Workflow Library', () => { 'retry', 1, // attempt number 3, // max attempts - expect.any(Error), + expect.any(Error) ); expect(observer.onStepRetry).toHaveBeenCalledTimes(1); expect(observer.onStepComplete).toHaveBeenCalledTimes(1); @@ -951,7 +983,7 @@ describe('Workflow Library', () => { .stepIf( 'conditional', (ctx) => !ctx.input.skip, - () => ({ ran: true }), + () => ({ ran: true }) ) .build(); @@ -959,7 +991,7 @@ describe('Workflow Library', () => { expect(observer.onStepSkipped).toHaveBeenCalledWith( 'conditional', - expect.any(Object), + expect.any(Object) ); expect(observer.onStepSkipped).toHaveBeenCalledTimes(1); expect(observer.onStepComplete).not.toHaveBeenCalled(); @@ -1021,7 +1053,7 @@ describe('Workflow Library', () => { for (const key in result) { if (allKeys.has(key)) { throw new Error( - `Key conflict detected in parallel merge: '${key}'`, + `Key conflict detected in parallel merge: '${key}'` ); } allKeys.add(key); @@ -1033,7 +1065,7 @@ describe('Workflow Library', () => { .build(); await expect(flow.execute({}, {})).rejects.toThrow( - "Key conflict detected in parallel merge: 'users'", + "Key conflict detected in parallel merge: 'users'" ); }); @@ -1071,9 +1103,11 @@ describe('Workflow Library', () => { it('should deep merge nested objects', async () => { const flow = createFlow, EmptyDeps>('test') - .parallel('deepMergeTest', 'deep', + .parallel( + 'deepMergeTest', + 'deep', () => ({ config: { host: 'localhost' } }), - () => ({ config: { port: 5432 } }), + () => ({ config: { port: 5432 } }) ) .build(); @@ -1084,9 +1118,11 @@ describe('Workflow Library', () => { it('should concatenate arrays in deep merge (not overwrite)', async () => { const flow = createFlow, EmptyDeps>('array-merge') - .parallel('arrayMergeTest', 'deep', + .parallel( + 'arrayMergeTest', + 'deep', () => ({ items: [{ id: 1 }] }), - () => ({ items: [{ id: 2 }] }), + () => ({ items: [{ id: 2 }] }) ) .build(); @@ -1098,9 +1134,17 @@ describe('Workflow Library', () => { it('should handle mixed nested structures in deep merge', async () => { const flow = createFlow, EmptyDeps>('mixed-merge') - .parallel('mixedMergeTest', 'deep', - () => ({ config: { features: ['auth'] }, users: [{ name: 'alice' }] }), - () => ({ config: { features: ['logging'], timeout: 30 }, users: [{ name: 'bob' }] }), + .parallel( + 'mixedMergeTest', + 'deep', + () => ({ + config: { features: ['auth'] }, + users: [{ name: 'alice' }], + }), + () => ({ + config: { features: ['logging'], timeout: 30 }, + users: [{ name: 'bob' }], + }) ) .build(); @@ -1147,7 +1191,7 @@ describe('Workflow Library', () => { .step('step1', (ctx) => ({ shouldBreak: ctx.input.shouldBreak })) .breakIf( (ctx) => ctx.state.shouldBreak, - () => ({ earlyReturn: true }), + () => ({ earlyReturn: true }) ) .step('step2', step2Handler) .build(); @@ -1165,7 +1209,7 @@ describe('Workflow Library', () => { .step('step1', (ctx) => ({ shouldBreak: ctx.input.shouldBreak })) .breakIf( (ctx) => ctx.state.shouldBreak, - () => ({ earlyReturn: true }), + () => ({ earlyReturn: true }) ) .step('step2', step2Handler) .build(); @@ -1195,7 +1239,7 @@ describe('Workflow Library', () => { .step('step1', () => ({ value: 1 })) .breakIf( () => true, - () => ({ breakValue: true }), + () => ({ breakValue: true }) ) .map(mapper) .build(); @@ -1211,17 +1255,21 @@ describe('Workflow Library', () => { .step('setLevel', (ctx) => ({ level: ctx.input.level })) .breakIf( (ctx) => ctx.state.level === 1, - () => ({ result: 'level1' }), + () => ({ result: 'level1' }) ) .breakIf( (ctx) => ctx.state.level === 2, - () => ({ result: 'level2' }), + () => ({ result: 'level2' }) ) .step('default', () => ({ result: 'default' })) .build(); - expect(await flow.execute({ level: 1 }, {})).toEqual({ result: 'level1' }); - expect(await flow.execute({ level: 2 }, {})).toEqual({ result: 'level2' }); + expect(await flow.execute({ level: 1 }, {})).toEqual({ + result: 'level1', + }); + expect(await flow.execute({ level: 2 }, {})).toEqual({ + result: 'level2', + }); expect(await flow.execute({ level: 3 }, {})).toEqual({ level: 3, result: 'default', @@ -1237,7 +1285,7 @@ describe('Workflow Library', () => { .transaction('persist', async () => ({ txComplete: true })) .breakIf( (ctx) => ctx.state.txComplete, - () => ({ result: 'after-tx-break' }), + () => ({ result: 'after-tx-break' }) ) .step('unreachable', () => ({ unreachable: true })) .build(); @@ -1258,12 +1306,15 @@ describe('Workflow Library', () => { .event('auth', () => ({ eventType: 'user.create', data: {} })) .breakIf( (ctx) => ctx.state.ready, - () => ({ result: 'after-event-break' }), + () => ({ result: 'after-event-break' }) ) .step('unreachable', () => ({ unreachable: true })) .build(); - const result = await flow.execute({}, { eventPublisher: mockEventPublisher }); + const result = await flow.execute( + {}, + { eventPublisher: mockEventPublisher } + ); expect(result).toEqual({ result: 'after-event-break' }); expect(mockEventPublisher.publish).toHaveBeenCalled(); @@ -1282,7 +1333,7 @@ describe('Workflow Library', () => { .step('step1', () => ({ value: 1 })) .breakIf( () => true, - () => ({ breakResult: true }), + () => ({ breakResult: true }) ) .build(); @@ -1292,14 +1343,16 @@ describe('Workflow Library', () => { 'observe-break', 'break_1', { breakResult: true }, - expect.any(Number), + expect.any(Number) ); // onFlowComplete should NOT be called when breaking expect(observer.onFlowComplete).not.toHaveBeenCalled(); }); it('should generate unique step names for multiple breakIf', () => { - const flow = createFlow, EmptyDeps>('multi-break-names') + const flow = createFlow, EmptyDeps>( + 'multi-break-names' + ) .step('step1', () => ({})) .breakIf(() => false) .step('step2', () => ({})) @@ -1343,7 +1396,9 @@ describe('Workflow Library', () => { const controller = new AbortController(); const step2Handler = vi.fn().mockReturnValue({ step2: true }); - const flow = createFlow, EmptyDeps>('external-abort') + const flow = createFlow, EmptyDeps>( + 'external-abort' + ) .step('step1', async () => { // Abort after this step completes controller.abort(); @@ -1353,7 +1408,7 @@ describe('Workflow Library', () => { .build(); await expect( - flow.execute({}, {}, { signal: controller.signal }), + flow.execute({}, {}, { signal: controller.signal }) ).rejects.toThrow(); expect(step2Handler).not.toHaveBeenCalled(); @@ -1370,7 +1425,7 @@ describe('Workflow Library', () => { .build(); await expect( - flow.execute({}, {}, { signal: controller.signal }), + flow.execute({}, {}, { signal: controller.signal }) ).rejects.toThrow(); expect(stepHandler).not.toHaveBeenCalled(); @@ -1379,7 +1434,9 @@ describe('Workflow Library', () => { it('should propagate step-scoped signal on step timeout', async () => { let capturedSignal: AbortSignal | undefined; - const flow = createFlow, EmptyDeps>('step-timeout-signal') + const flow = createFlow, EmptyDeps>( + 'step-timeout-signal' + ) .step('slow', async (ctx) => { capturedSignal = ctx.signal; await new Promise((resolve) => setTimeout(resolve, 200)); @@ -1387,9 +1444,9 @@ describe('Workflow Library', () => { }) .build(); - await expect( - flow.execute({}, {}, { stepTimeout: 20 }), - ).rejects.toThrow(TimeoutError); + await expect(flow.execute({}, {}, { stepTimeout: 20 })).rejects.toThrow( + TimeoutError + ); // The signal should have been aborted by the step timeout expect(capturedSignal).toBeDefined(); @@ -1397,7 +1454,9 @@ describe('Workflow Library', () => { }); it('should still produce TimeoutError for flow-level timeout', async () => { - const flow = createFlow, EmptyDeps>('flow-timeout-signal') + const flow = createFlow, EmptyDeps>( + 'flow-timeout-signal' + ) .step('step1', async () => { await new Promise((resolve) => setTimeout(resolve, 30)); return { step1: true }; @@ -1412,9 +1471,9 @@ describe('Workflow Library', () => { }) .build(); - await expect( - flow.execute({}, {}, { timeout: 50 }), - ).rejects.toThrow(TimeoutError); + await expect(flow.execute({}, {}, { timeout: 50 })).rejects.toThrow( + TimeoutError + ); }); it('should abort retry delay immediately when signal fires', async () => { @@ -1436,7 +1495,7 @@ describe('Workflow Library', () => { const start = Date.now(); await expect( - flow.execute({}, {}, { signal: controller.signal }), + flow.execute({}, {}, { signal: controller.signal }) ).rejects.toThrow(); const duration = Date.now() - start; @@ -1448,8 +1507,12 @@ describe('Workflow Library', () => { it('should provide signal to parallel handlers', async () => { const signals: AbortSignal[] = []; - const flow = createFlow, EmptyDeps>('parallel-signal') - .parallel('fetch', 'shallow', + const flow = createFlow, EmptyDeps>( + 'parallel-signal' + ) + .parallel( + 'fetch', + 'shallow', (ctx) => { signals.push(ctx.signal); return { a: 1 }; @@ -1457,7 +1520,7 @@ describe('Workflow Library', () => { (ctx) => { signals.push(ctx.signal); return { b: 2 }; - }, + } ) .build(); @@ -1471,7 +1534,9 @@ describe('Workflow Library', () => { it('should abort the signal on flow-level timeout', async () => { let capturedSignal: AbortSignal | undefined; - const flow = createFlow, EmptyDeps>('flow-abort-signal') + const flow = createFlow, EmptyDeps>( + 'flow-abort-signal' + ) .step('slow', async (ctx) => { capturedSignal = ctx.signal; await new Promise((resolve) => setTimeout(resolve, 200)); @@ -1479,16 +1544,18 @@ describe('Workflow Library', () => { }) .build(); - await expect( - flow.execute({}, {}, { timeout: 20 }), - ).rejects.toThrow(TimeoutError); + await expect(flow.execute({}, {}, { timeout: 20 })).rejects.toThrow( + TimeoutError + ); expect(capturedSignal).toBeDefined(); expect(capturedSignal!.aborted).toBe(true); }); it('should respect stepTimeout and interrupt quickly', async () => { - const flow = createFlow, EmptyDeps>('step-timeout-interrupt') + const flow = createFlow, EmptyDeps>( + 'step-timeout-interrupt' + ) .step('slowStep', async () => { await new Promise((resolve) => setTimeout(resolve, 200)); return { slow: true }; @@ -1496,9 +1563,9 @@ describe('Workflow Library', () => { .build(); const start = Date.now(); - await expect( - flow.execute({}, {}, { stepTimeout: 20 }), - ).rejects.toThrow(TimeoutError); + await expect(flow.execute({}, {}, { stepTimeout: 20 })).rejects.toThrow( + TimeoutError + ); const duration = Date.now() - start; expect(duration).toBeLessThan(80); diff --git a/packages/prose/src/lib/flow-executor.ts b/packages/prose/src/lib/flow-executor.ts index 6a0821f..423cea3 100644 --- a/packages/prose/src/lib/flow-executor.ts +++ b/packages/prose/src/lib/flow-executor.ts @@ -46,11 +46,11 @@ function createCheckpointWriter( durability: DurabilityOptions | undefined, flowName: string, input: unknown, - createdAt: Date, + createdAt: Date ): ( state: unknown, completedSteps: ReadonlySet, - write: CheckpointWrite, + write: CheckpointWrite ) => Promise { if (!durability) { return async () => { @@ -59,9 +59,11 @@ function createCheckpointWriter( } return async (state, completedSteps, write) => { const status: FlowCheckpoint['status'] = - write.kind === 'running' ? 'running' - : write.kind === 'failed' ? 'failed' - : 'completed'; + write.kind === 'running' + ? 'running' + : write.kind === 'failed' + ? 'failed' + : 'completed'; const checkpoint: FlowCheckpoint = { flowName, @@ -101,13 +103,15 @@ function throwIfAborted(signal: AbortSignal): void { function raceAbort(promise: Promise, signal: AbortSignal): Promise { if (signal.aborted) { return Promise.reject( - signal.reason instanceof Error ? signal.reason : new Error('Aborted'), + signal.reason instanceof Error ? signal.reason : new Error('Aborted') ); } return new Promise((resolve, reject) => { function onAbort() { - reject(signal.reason instanceof Error ? signal.reason : new Error('Aborted')); + reject( + signal.reason instanceof Error ? signal.reason : new Error('Aborted') + ); } signal.addEventListener('abort', onAbort, { once: true }); @@ -120,7 +124,7 @@ function raceAbort(promise: Promise, signal: AbortSignal): Promise { (error) => { signal.removeEventListener('abort', onAbort); reject(error); - }, + } ); }); } @@ -132,7 +136,9 @@ function raceAbort(promise: Promise, signal: AbortSignal): Promise { function abortableDelay(ms: number, signal: AbortSignal): Promise { return new Promise((resolve, reject) => { if (signal.aborted) { - reject(signal.reason instanceof Error ? signal.reason : new Error('Aborted')); + reject( + signal.reason instanceof Error ? signal.reason : new Error('Aborted') + ); return; } @@ -143,7 +149,9 @@ function abortableDelay(ms: number, signal: AbortSignal): Promise { function onAbort() { clearTimeout(timer); - reject(signal.reason instanceof Error ? signal.reason : new Error('Aborted')); + reject( + signal.reason instanceof Error ? signal.reason : new Error('Aborted') + ); } signal.addEventListener('abort', onAbort, { once: true }); @@ -153,7 +161,7 @@ function abortableDelay(ms: number, signal: AbortSignal): Promise { export class FlowExecutor< TInput, TDeps extends BaseFlowDependencies, - TState extends FlowState = Record, + TState extends FlowState = Record > { /** * Execute a complete flow from configuration @@ -163,7 +171,7 @@ export class FlowExecutor< config: FlowConfig, input: TInput, deps: TDeps, - options?: FlowExecutionOptions, + options?: FlowExecutionOptions ): Promise> { const startTime = Date.now(); const durability = options?.durability; @@ -194,7 +202,7 @@ export class FlowExecutor< durability, config.name, effectiveInput, - originalCreatedAt, + originalCreatedAt ); const meta: FlowMeta = { @@ -203,9 +211,7 @@ export class FlowExecutor< correlationId: options?.correlationId, // Durability-only fields. Left undefined when durability isn't configured // so handlers can rely on `meta.runId !== undefined` to detect durable runs. - ...(durability - ? { runId: durability.runId, isResuming } - : {}), + ...(durability ? { runId: durability.runId, isResuming } : {}), }; const observer = options?.observer; @@ -228,10 +234,10 @@ export class FlowExecutor< `Flow execution timeout after ${options.timeout}ms`, config.name, undefined, - options.timeout, - ), + options.timeout + ) ), - options.timeout, + options.timeout ); } @@ -299,12 +305,17 @@ export class FlowExecutor< }); // Notify observers - observer?.onStepComplete?.(step.name, breakResult, duration, context); + observer?.onStepComplete?.( + step.name, + breakResult, + duration, + context + ); observer?.onFlowBreak?.( config.name, step.name, breakResult, - totalDuration, + totalDuration ); // Return early, bypassing remaining steps and .map() @@ -317,7 +328,7 @@ export class FlowExecutor< step.name, { __breakConditionMet: false }, duration, - context, + context ); completedSteps.add(step.name); await persist(context.state, completedSteps, { kind: 'running' }); @@ -325,7 +336,13 @@ export class FlowExecutor< } // Execute step based on type - const result = await this.executeStep(step, context, deps, options, flowSignal); + const result = await this.executeStep( + step, + context, + deps, + options, + flowSignal + ); // Merge result into state if applicable if (result && typeof result === 'object' && step.type !== 'event') { @@ -394,12 +411,19 @@ export class FlowExecutor< context: FlowContext, deps: TDeps, options: FlowExecutionOptions | undefined, - flowSignal: AbortSignal, + flowSignal: AbortSignal ) { const retryOptions = step.retryOptions; if (retryOptions) { - return this.executeWithRetry(step, context, deps, retryOptions, options, flowSignal); + return this.executeWithRetry( + step, + context, + deps, + retryOptions, + options, + flowSignal + ); } return this.executeSingleStep(step, context, deps, options, flowSignal); @@ -414,7 +438,7 @@ export class FlowExecutor< deps: TDeps, retryOptions: RetryOptions, options: FlowExecutionOptions | undefined, - flowSignal: AbortSignal, + flowSignal: AbortSignal ) { const observer = options?.observer; let lastError: Error | undefined; @@ -425,7 +449,13 @@ export class FlowExecutor< throwIfAborted(flowSignal); try { - return await this.executeSingleStep(step, context, deps, options, flowSignal); + return await this.executeSingleStep( + step, + context, + deps, + options, + flowSignal + ); } catch (error) { lastError = error instanceof Error ? error : new Error(String(error)); @@ -449,7 +479,7 @@ export class FlowExecutor< step.name, attempt, retryOptions.maxAttempts, - lastError, + lastError ); // Wait before retrying (abortable — exits immediately if signal fires) @@ -459,7 +489,7 @@ export class FlowExecutor< if (retryOptions.backoffMultiplier) { delay = Math.min( delay * retryOptions.backoffMultiplier, - retryOptions.maxDelayMs || Number.MAX_SAFE_INTEGER, + retryOptions.maxDelayMs || Number.MAX_SAFE_INTEGER ); } } @@ -476,7 +506,7 @@ export class FlowExecutor< context: FlowContext, deps: TDeps, options: FlowExecutionOptions | undefined, - flowSignal: AbortSignal, + flowSignal: AbortSignal ) { const observer = options?.observer; const stepStart = Date.now(); @@ -504,10 +534,10 @@ export class FlowExecutor< `Step '${step.name}' timed out after ${stepTimeout}ms`, context.meta.flowName, step.name, - stepTimeout, - ), + stepTimeout + ) ), - stepTimeout, + stepTimeout ); } @@ -525,7 +555,12 @@ export class FlowExecutor< } case 'transaction': { - return await this.executeTransaction(step, stepContext, deps, options); + return await this.executeTransaction( + step, + stepContext, + deps, + options + ); } case 'event': { @@ -535,7 +570,7 @@ export class FlowExecutor< default: { throw new FlowExecutionError( `Unknown step type`, - context.meta.flowName, + context.meta.flowName ); } } @@ -573,7 +608,7 @@ export class FlowExecutor< step: TransactionStepDefinition, context: FlowContext, deps: TDeps, - options?: FlowExecutionOptions, + options?: FlowExecutionOptions ) { // Get database instance from deps const db = deps.db; @@ -603,7 +638,7 @@ export class FlowExecutor< private async executeEvent( step: EventStepDefinition, context: FlowContext, - options?: FlowExecutionOptions, + options?: FlowExecutionOptions ): Promise { // Get event publisher from deps const eventPublisher = context.deps.eventPublisher; @@ -626,7 +661,7 @@ export class FlowExecutor< throw new FlowExecutionError( 'Event publisher must have publish() method', context.meta.flowName, - step.name, + step.name ); } @@ -654,7 +689,7 @@ export class FlowExecutor< publisher: FlowEventPublisher, event: FlowEvent, channel: string, - context: FlowContext, + context: FlowContext ): Promise { // Add correlation ID if available const eventWithMeta = { diff --git a/packages/prose/src/lib/index.ts b/packages/prose/src/lib/index.ts index 20928b1..6e960cb 100644 --- a/packages/prose/src/lib/index.ts +++ b/packages/prose/src/lib/index.ts @@ -9,10 +9,7 @@ export * from './types.js'; export { ValidationError, FlowExecutionError, TimeoutError } from './types.js'; // Export flow builder API -export { - createFlow, - FlowBuilder, -} from './flow-builder.js'; +export { createFlow, FlowBuilder } from './flow-builder.js'; // Export observer types and implementations export type { FlowObserver } from './observer.js'; @@ -21,4 +18,3 @@ export { PinoFlowObserver, type PinoLike } from './pino-observer.js'; // Export durability store implementation export { MemoryDurabilityStore } from './memory-store.js'; - diff --git a/packages/prose/src/lib/types.ts b/packages/prose/src/lib/types.ts index 855cffd..505d42a 100644 --- a/packages/prose/src/lib/types.ts +++ b/packages/prose/src/lib/types.ts @@ -24,8 +24,11 @@ export interface DatabaseClient { /** * Helper type to extract the transaction client type from the dependencies */ -export type TxClientOf = - TDeps extends { db: DatabaseClient } ? TTx : unknown; +export type TxClientOf = TDeps extends { + db: DatabaseClient; +} + ? TTx + : unknown; // ────────────────────────────────────────────────────────── // Event interfaces @@ -153,7 +156,7 @@ export interface FlowMeta { export interface FlowContext< TInput, TDeps extends BaseFlowDependencies, - TState extends FlowState, + TState extends FlowState > { readonly input: Readonly; state: TState; @@ -174,7 +177,7 @@ export type StepResult = T | void | undefined; export type StepCondition< TInput, TDeps extends BaseFlowDependencies, - TState extends FlowState, + TState extends FlowState > = (ctx: FlowContext) => boolean; /** @@ -193,7 +196,7 @@ export interface ErrorHandlingConfig { export interface FlowExecutionOptions< TInput, TDeps extends BaseFlowDependencies, - TState extends FlowState, + TState extends FlowState > { correlationId?: string; throwOnError?: boolean; @@ -220,7 +223,7 @@ export interface FlowExecutionOptions< interface BaseStepDefinition< TInput, TDeps extends BaseFlowDependencies, - TState extends FlowState, + TState extends FlowState > { name: string; condition?: StepCondition; @@ -233,7 +236,7 @@ interface BaseStepDefinition< export interface ValidationStepDefinition< TInput, TDeps extends BaseFlowDependencies, - TState extends FlowState, + TState extends FlowState > extends BaseStepDefinition { type: 'validate'; handler: (ctx: FlowContext) => void | Promise; @@ -245,11 +248,11 @@ export interface ValidationStepDefinition< export interface ExecutorStepDefinition< TInput, TDeps extends BaseFlowDependencies, - TState extends FlowState, + TState extends FlowState > extends BaseStepDefinition { type: 'step'; handler: ( - ctx: FlowContext, + ctx: FlowContext ) => StepResult | Promise>; } @@ -259,12 +262,12 @@ export interface ExecutorStepDefinition< export interface TransactionStepDefinition< TInput, TDeps extends BaseFlowDependencies, - TState extends FlowState, + TState extends FlowState > extends BaseStepDefinition { type: 'transaction'; handler: ( ctx: FlowContext, - tx: unknown, + tx: unknown ) => unknown | Promise; } @@ -274,12 +277,12 @@ export interface TransactionStepDefinition< export interface EventStepDefinition< TInput, TDeps extends BaseFlowDependencies, - TState extends FlowState, + TState extends FlowState > extends BaseStepDefinition { type: 'event'; channel: string; handler: ( - ctx: FlowContext, + ctx: FlowContext ) => FlowEvent | FlowEvent[] | void | Promise; } @@ -289,7 +292,7 @@ export interface EventStepDefinition< export type BreakCondition< TInput, TDeps extends BaseFlowDependencies, - TState extends FlowState, + TState extends FlowState > = (ctx: FlowContext) => boolean; /** @@ -299,7 +302,7 @@ export type BreakReturnValue< TInput, TDeps extends BaseFlowDependencies, TState extends FlowState, - TBreakOutput, + TBreakOutput > = (ctx: FlowContext) => TBreakOutput; /** @@ -315,7 +318,7 @@ export type BreakReturnValue< export interface BreakStepDefinition< TInput, TDeps extends BaseFlowDependencies, - TState extends FlowState, + TState extends FlowState > extends BaseStepDefinition { type: 'break'; breakCondition: BreakCondition; @@ -328,7 +331,7 @@ export interface BreakStepDefinition< export type StepDefinition< TInput, TDeps extends BaseFlowDependencies, - TState extends FlowState, + TState extends FlowState > = | ValidationStepDefinition | ExecutorStepDefinition @@ -375,7 +378,7 @@ export class TimeoutError extends Error { message: string, public flowName: string, public stepName?: string, - public timeoutMs?: number, + public timeoutMs?: number ) { super(message); this.name = 'TimeoutError'; @@ -398,7 +401,7 @@ export interface FlowExecutionResult { export interface FlowConfig< TInput, TDeps extends BaseFlowDependencies, - TState extends FlowState, + TState extends FlowState > { name: string; steps: StepDefinition[]; @@ -409,7 +412,7 @@ export interface FlowConfig< * Uses tuple wrapping to properly handle the never type */ export type InferFlowOutput = [TMapperOutput] extends [ - never, + never ] ? TState : TMapperOutput; @@ -422,14 +425,14 @@ export interface FlowDefinition< TDeps extends BaseFlowDependencies, TState extends FlowState, TMapperOutput = never, - TBreakOutputs = never, + TBreakOutputs = never > { name: string; steps: StepDefinition[]; execute: ( input: TInput, deps: TDeps, - options?: FlowExecutionOptions, + options?: FlowExecutionOptions ) => Promise | TBreakOutputs>; } @@ -470,7 +473,7 @@ export class ValidationError extends Error { static single( field: string, message: string, - value?: unknown, + value?: unknown ): ValidationError { return new ValidationError(message, [{ field, message, value }]); } @@ -479,7 +482,9 @@ export class ValidationError extends Error { * Create a validation error for multiple fields */ static multiple(issues: ValidationIssue[]): ValidationError { - const message = `Validation failed: ${issues.map((i) => i.field).join(', ')}`; + const message = `Validation failed: ${issues + .map((i) => i.field) + .join(', ')}`; return new ValidationError(message, issues); } @@ -510,7 +515,7 @@ export class FlowExecutionError extends Error { message: string, public flowName: string, public stepName?: string, - public originalError?: Error, + public originalError?: Error ) { super(message); this.name = 'FlowExecutionError'; diff --git a/packages/prose/src/mcp/resources/index.ts b/packages/prose/src/mcp/resources/index.ts index 6a5119c..4d44617 100644 --- a/packages/prose/src/mcp/resources/index.ts +++ b/packages/prose/src/mcp/resources/index.ts @@ -15,67 +15,145 @@ import { EXAMPLES, EXAMPLE_NAMES } from '../content/examples.js'; export function registerResources(server: McpServer) { // Quick reference cheatsheet - server.registerResource('quick-reference', 'prose://api/quick-reference', { - description: - 'Concise cheatsheet for all @celom/prose FlowBuilder methods, types, and patterns', - mimeType: 'text/markdown', - }, async (uri) => ({ - contents: [{ uri: uri.href, text: QUICK_REFERENCE, mimeType: 'text/markdown' }], - })); + server.registerResource( + 'quick-reference', + 'prose://api/quick-reference', + { + description: + 'Concise cheatsheet for all @celom/prose FlowBuilder methods, types, and patterns', + mimeType: 'text/markdown', + }, + async (uri) => ({ + contents: [ + { uri: uri.href, text: QUICK_REFERENCE, mimeType: 'text/markdown' }, + ], + }) + ); // API references - server.registerResource('api-create-flow', 'prose://api/create-flow', { - description: 'API reference for createFlow() — the entry point for building flows', - mimeType: 'text/markdown', - }, async (uri) => ({ - contents: [{ uri: uri.href, text: CREATE_FLOW_REFERENCE, mimeType: 'text/markdown' }], - })); + server.registerResource( + 'api-create-flow', + 'prose://api/create-flow', + { + description: + 'API reference for createFlow() — the entry point for building flows', + mimeType: 'text/markdown', + }, + async (uri) => ({ + contents: [ + { + uri: uri.href, + text: CREATE_FLOW_REFERENCE, + mimeType: 'text/markdown', + }, + ], + }) + ); - server.registerResource('api-flow-builder', 'prose://api/flow-builder', { - description: 'API reference for all FlowBuilder methods', - mimeType: 'text/markdown', - }, async (uri) => ({ - contents: [{ uri: uri.href, text: FLOW_BUILDER_REFERENCE, mimeType: 'text/markdown' }], - })); + server.registerResource( + 'api-flow-builder', + 'prose://api/flow-builder', + { + description: 'API reference for all FlowBuilder methods', + mimeType: 'text/markdown', + }, + async (uri) => ({ + contents: [ + { + uri: uri.href, + text: FLOW_BUILDER_REFERENCE, + mimeType: 'text/markdown', + }, + ], + }) + ); - server.registerResource('api-types', 'prose://api/types', { - description: - 'Type reference for FlowContext, FlowMeta, RetryOptions, FlowEvent, DatabaseClient, etc.', - mimeType: 'text/markdown', - }, async (uri) => ({ - contents: [{ uri: uri.href, text: TYPES_REFERENCE, mimeType: 'text/markdown' }], - })); + server.registerResource( + 'api-types', + 'prose://api/types', + { + description: + 'Type reference for FlowContext, FlowMeta, RetryOptions, FlowEvent, DatabaseClient, etc.', + mimeType: 'text/markdown', + }, + async (uri) => ({ + contents: [ + { uri: uri.href, text: TYPES_REFERENCE, mimeType: 'text/markdown' }, + ], + }) + ); - server.registerResource('api-execution-options', 'prose://api/execution-options', { - description: 'API reference for FlowExecutionOptions passed to flow.execute()', - mimeType: 'text/markdown', - }, async (uri) => ({ - contents: [{ uri: uri.href, text: EXECUTION_OPTIONS_REFERENCE, mimeType: 'text/markdown' }], - })); + server.registerResource( + 'api-execution-options', + 'prose://api/execution-options', + { + description: + 'API reference for FlowExecutionOptions passed to flow.execute()', + mimeType: 'text/markdown', + }, + async (uri) => ({ + contents: [ + { + uri: uri.href, + text: EXECUTION_OPTIONS_REFERENCE, + mimeType: 'text/markdown', + }, + ], + }) + ); - server.registerResource('api-error-types', 'prose://api/error-types', { - description: - 'API reference for ValidationError, FlowExecutionError, and TimeoutError', - mimeType: 'text/markdown', - }, async (uri) => ({ - contents: [{ uri: uri.href, text: ERROR_TYPES_REFERENCE, mimeType: 'text/markdown' }], - })); + server.registerResource( + 'api-error-types', + 'prose://api/error-types', + { + description: + 'API reference for ValidationError, FlowExecutionError, and TimeoutError', + mimeType: 'text/markdown', + }, + async (uri) => ({ + contents: [ + { + uri: uri.href, + text: ERROR_TYPES_REFERENCE, + mimeType: 'text/markdown', + }, + ], + }) + ); - server.registerResource('api-observers', 'prose://api/observers', { - description: - 'API reference for FlowObserver interface and built-in observer implementations', - mimeType: 'text/markdown', - }, async (uri) => ({ - contents: [{ uri: uri.href, text: OBSERVERS_REFERENCE, mimeType: 'text/markdown' }], - })); + server.registerResource( + 'api-observers', + 'prose://api/observers', + { + description: + 'API reference for FlowObserver interface and built-in observer implementations', + mimeType: 'text/markdown', + }, + async (uri) => ({ + contents: [ + { uri: uri.href, text: OBSERVERS_REFERENCE, mimeType: 'text/markdown' }, + ], + }) + ); - server.registerResource('api-durability-store', 'prose://api/durability-store', { - description: - 'API reference for opt-in durability — DurabilityStore, DurabilityOptions, FlowCheckpoint, and MemoryDurabilityStore', - mimeType: 'text/markdown', - }, async (uri) => ({ - contents: [{ uri: uri.href, text: DURABILITY_REFERENCE, mimeType: 'text/markdown' }], - })); + server.registerResource( + 'api-durability-store', + 'prose://api/durability-store', + { + description: + 'API reference for opt-in durability — DurabilityStore, DurabilityOptions, FlowCheckpoint, and MemoryDurabilityStore', + mimeType: 'text/markdown', + }, + async (uri) => ({ + contents: [ + { + uri: uri.href, + text: DURABILITY_REFERENCE, + mimeType: 'text/markdown', + }, + ], + }) + ); // Guide resources (dynamic template) const guideTemplate = new ResourceTemplate('prose://guides/{topic}', { @@ -93,7 +171,9 @@ export function registerResources(server: McpServer) { 'guide', guideTemplate, { - description: `Feature guides for @celom/prose. Available topics: ${GUIDE_TOPICS.join(', ')}`, + description: `Feature guides for @celom/prose. Available topics: ${GUIDE_TOPICS.join( + ', ' + )}`, mimeType: 'text/markdown', }, async (uri, variables) => { @@ -101,13 +181,13 @@ export function registerResources(server: McpServer) { const content = GUIDES[topic]; if (!content) { throw new Error( - `Unknown guide topic: ${topic}. Available: ${GUIDE_TOPICS.join(', ')}`, + `Unknown guide topic: ${topic}. Available: ${GUIDE_TOPICS.join(', ')}` ); } return { contents: [{ uri: uri.href, text: content, mimeType: 'text/markdown' }], }; - }, + } ); // Example resources (dynamic template) @@ -126,7 +206,9 @@ export function registerResources(server: McpServer) { 'example', exampleTemplate, { - description: `Complete worked examples. Available: ${EXAMPLE_NAMES.join(', ')}`, + description: `Complete worked examples. Available: ${EXAMPLE_NAMES.join( + ', ' + )}`, mimeType: 'text/markdown', }, async (uri, variables) => { @@ -134,12 +216,12 @@ export function registerResources(server: McpServer) { const content = EXAMPLES[name]; if (!content) { throw new Error( - `Unknown example: ${name}. Available: ${EXAMPLE_NAMES.join(', ')}`, + `Unknown example: ${name}. Available: ${EXAMPLE_NAMES.join(', ')}` ); } return { contents: [{ uri: uri.href, text: content, mimeType: 'text/markdown' }], }; - }, + } ); } From 36a16a531b31c82dff48d19f3bfd9daa18a602fe Mon Sep 17 00:00:00 2001 From: Carlos Mimoso Date: Tue, 19 May 2026 13:35:10 +0100 Subject: [PATCH 6/7] test(prose): add durability store type-assertion puppet tests --- packages/prose/src/puppet.ts | 92 +++++++++++++++++++++++++++++++++++- 1 file changed, 91 insertions(+), 1 deletion(-) diff --git a/packages/prose/src/puppet.ts b/packages/prose/src/puppet.ts index 3238464..001a5a8 100644 --- a/packages/prose/src/puppet.ts +++ b/packages/prose/src/puppet.ts @@ -8,7 +8,13 @@ */ import { createFlow, type FlowBuilder } from './lib/flow-builder.js'; -import type { BaseFlowDependencies } from './lib/types.js'; +import { MemoryDurabilityStore } from './lib/memory-store.js'; +import type { + BaseFlowDependencies, + DurabilityOptions, + DurabilityStore, + FlowCheckpoint, +} from './lib/types.js'; // ── Setup ──────────────────────────────────────────────── @@ -224,6 +230,73 @@ const fulfillOrder = createFlow<{ orderId: string; items: InventoryItem[] }, War }) .build(); +// ── Durability: meta fields, options, store adapter ───── + +const durableFlow = createFlow<{ orderId: string }, never>('durableOrder') + .step('reserve', (ctx) => { + // Durability-only meta fields are optional (undefined when no store is configured) + void (ctx.meta.idempotencyKey satisfies string | undefined); + void (ctx.meta.runId satisfies string | undefined); + void (ctx.meta.isResuming satisfies boolean | undefined); + return { reserved: true as const }; + }) + .step('ship', (ctx) => { + void (ctx.state.reserved satisfies true); + return { shipmentId: `ship-${ctx.input.orderId}` }; + }) + .map((input, state) => ({ + orderId: input.orderId, + shipmentId: state.shipmentId, + })) + .build(); + +// MemoryDurabilityStore satisfies the DurabilityStore interface +const _memStore: DurabilityStore = new MemoryDurabilityStore(); + +// DurabilityOptions structural shape +const _durabilityOpts: DurabilityOptions = { + store: new MemoryDurabilityStore(), + runId: 'order-42', +}; +void _durabilityOpts; + +// Custom DurabilityStore adapter — verifies the interface contract +const customStore: DurabilityStore = { + async load(runId) { + void (runId satisfies string); + return null; + }, + async save(checkpoint) { + void (checkpoint.flowName satisfies string); + void (checkpoint.runId satisfies string); + void (checkpoint.input satisfies unknown); + void (checkpoint.state satisfies unknown); + void (checkpoint.completedSteps satisfies string[]); + void (checkpoint.status satisfies 'running' | 'completed' | 'failed'); + void (checkpoint.breakValue satisfies unknown); + void (checkpoint.failedStep satisfies { name: string; error: string } | undefined); + void (checkpoint.createdAt satisfies Date); + void (checkpoint.updatedAt satisfies Date); + }, + async delete(runId) { + void (runId satisfies string); + }, +}; +void customStore; + +// FlowCheckpoint structural shape (the value an adapter would persist) +const _checkpoint: FlowCheckpoint = { + flowName: 'durableOrder', + runId: 'order-42', + input: { orderId: 'o1' }, + state: { reserved: true }, + completedSteps: ['reserve'], + status: 'running', + createdAt: new Date(), + updatedAt: new Date(), +}; +void _checkpoint; + // ── Verify output types ────────────────────────────────── async function _typeAssertions() { @@ -280,6 +353,21 @@ async function _typeAssertions() { void (fulfillResult.shipmentIds satisfies string[]); void (fulfillResult.confirmed satisfies true); + // Durability option accepted by execute() — output type unaffected by store + const store = new MemoryDurabilityStore(); + const durableResult = await durableFlow.execute( + { orderId: 'o1' }, + undefined as never, + { durability: { store, runId: 'order-42' } }, + ); + void (durableResult.orderId satisfies string); + void (durableResult.shipmentId satisfies string); + + // Store helpers are typed + void ((await store.load('order-42')) satisfies FlowCheckpoint | null); + void (store.size() satisfies number); + void (store.snapshot('order-42') satisfies FlowCheckpoint | null); + // Combined flow chains parallel with subsequent steps const combinedResult = await combinedFlow.execute({ id: 'x' }, undefined as never); void (combinedResult.loaded satisfies true); @@ -297,4 +385,6 @@ void parallelFlow; void authedFlow; void combinedFlow; void fulfillOrder; +void durableFlow; +void _memStore; void _typeAssertions; From d833b07a8c6c3f31ff66f24a63ead93116d027c1 Mon Sep 17 00:00:00 2001 From: Carlos Mimoso Date: Tue, 19 May 2026 13:42:51 +0100 Subject: [PATCH 7/7] chore(prose): prettier-format puppet durability additions Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/prose/src/puppet.ts | 88 +++++++++++++++++++++++++----------- 1 file changed, 62 insertions(+), 26 deletions(-) diff --git a/packages/prose/src/puppet.ts b/packages/prose/src/puppet.ts index 001a5a8..43db3e1 100644 --- a/packages/prose/src/puppet.ts +++ b/packages/prose/src/puppet.ts @@ -25,7 +25,9 @@ interface OrderInput { } interface OrderDeps extends BaseFlowDependencies { - paymentGateway: { charge(amount: number): Promise<{ transactionId: string }> }; + paymentGateway: { + charge(amount: number): Promise<{ transactionId: string }>; + }; } // ── Flow definition ────────────────────────────────────── @@ -56,7 +58,7 @@ const orderFlow = createFlow('processOrder') void (ctx.state.transactionId satisfies string); void (ctx.state.user satisfies { id: string; name: string }); return { discountApplied: true as const }; - }, + } ) .map((input, state) => { // State should have user, transactionId, and discountApplied @@ -71,8 +73,10 @@ const orderFlow = createFlow('processOrder') // ── Branching from a shared base ───────────────────────── -const base = createFlow<{ x: number }, never>('branching') - .step('double', (ctx) => ({ doubled: ctx.input.x * 2 })); +const base = createFlow<{ x: number }, never>('branching').step( + 'double', + (ctx) => ({ doubled: ctx.input.x * 2 }) +); const branchA = base .step('addTen', (ctx) => { @@ -105,7 +109,7 @@ const cachedLookup = createFlow('cachedLookup') }) .breakIf( (ctx) => ctx.state.cacheHit, - (ctx) => ({ fromCache: true as const, value: ctx.state.cachedValue! }), + (ctx) => ({ fromCache: true as const, value: ctx.state.cachedValue! }) ) .step('fetchFromDb', (ctx) => { void (ctx.input.key satisfies string); @@ -125,7 +129,9 @@ interface FetchDeps extends BaseFlowDependencies { const parallelFlow = createFlow<{ orgId: string }, FetchDeps>('parallelFetch') .step('init', (ctx) => ({ orgId: ctx.input.orgId })) - .parallel('fetchAll', 'shallow', + .parallel( + 'fetchAll', + 'shallow', async (ctx) => { const users = await ctx.deps.api.getUsers(); return { users }; @@ -133,14 +139,16 @@ const parallelFlow = createFlow<{ orgId: string }, FetchDeps>('parallelFetch') async (ctx) => { const posts = await ctx.deps.api.getPosts(); return { posts }; - }, + } ) .step('summarize', (ctx) => { // State should have orgId, users, and posts from the parallel step void (ctx.state.orgId satisfies string); void (ctx.state.users satisfies string[]); void (ctx.state.posts satisfies string[]); - return { summary: `${ctx.state.users.length} users, ${ctx.state.posts.length} posts` }; + return { + summary: `${ctx.state.users.length} users, ${ctx.state.posts.length} posts`, + }; }) .build(); @@ -149,14 +157,18 @@ const parallelFlow = createFlow<{ orgId: string }, FetchDeps>('parallelFetch') function withAuth< TInput extends { token: string }, TDeps extends BaseFlowDependencies, - TState extends Record, + TState extends Record >(builder: FlowBuilder) { return builder .step('validateToken', (ctx) => ({ tokenData: { sub: ctx.input.token } })) - .step('loadUser', (ctx) => ({ user: { id: ctx.state.tokenData.sub, role: 'admin' as const } })); + .step('loadUser', (ctx) => ({ + user: { id: ctx.state.tokenData.sub, role: 'admin' as const }, + })); } -const authedFlow = createFlow<{ token: string; action: string }, never>('authed-action') +const authedFlow = createFlow<{ token: string; action: string }, never>( + 'authed-action' +) .pipe(withAuth) .step('doAction', (ctx) => { // State should have tokenData and user from the piped sub-flow @@ -171,9 +183,11 @@ const authedFlow = createFlow<{ token: string; action: string }, never>('authed- const combinedFlow = createFlow<{ id: string }, never>('combined') .step('load', () => ({ loaded: true as const })) - .parallel('fetch', 'shallow', + .parallel( + 'fetch', + 'shallow', () => ({ a: 1 }), - () => ({ b: 'two' }), + () => ({ b: 'two' }) ) .step('process', (ctx) => { // Should see loaded, a, b from prior steps @@ -194,7 +208,11 @@ interface InventoryItem { interface WarehouseTx { deduct(sku: string, qty: number): Promise<{ remaining: number }>; - insertShipment(data: { orderId: string; sku: string; qty: number }): Promise<{ shipmentId: string }>; + insertShipment(data: { + orderId: string; + sku: string; + qty: number; + }): Promise<{ shipmentId: string }>; } interface WarehouseDeps extends BaseFlowDependencies { @@ -203,7 +221,10 @@ interface WarehouseDeps extends BaseFlowDependencies { }; } -const fulfillOrder = createFlow<{ orderId: string; items: InventoryItem[] }, WarehouseDeps>('fulfillOrder') +const fulfillOrder = createFlow< + { orderId: string; items: InventoryItem[] }, + WarehouseDeps +>('fulfillOrder') .step('loadOrder', (ctx) => { return { orderId: ctx.input.orderId, items: ctx.input.items }; }) @@ -274,7 +295,9 @@ const customStore: DurabilityStore = { void (checkpoint.completedSteps satisfies string[]); void (checkpoint.status satisfies 'running' | 'completed' | 'failed'); void (checkpoint.breakValue satisfies unknown); - void (checkpoint.failedStep satisfies { name: string; error: string } | undefined); + void (checkpoint.failedStep satisfies + | { name: string; error: string } + | undefined); void (checkpoint.createdAt satisfies Date); void (checkpoint.updatedAt satisfies Date); }, @@ -302,7 +325,7 @@ void _checkpoint; async function _typeAssertions() { const orderResult = await orderFlow.execute( { orderId: '1', userId: 'u1', amount: 50 }, - { paymentGateway: { charge: async () => ({ transactionId: 'tx1' }) } }, + { paymentGateway: { charge: async () => ({ transactionId: 'tx1' }) } } ); void (orderResult.orderId satisfies string); void (orderResult.transactionId satisfies string); @@ -320,23 +343,25 @@ async function _typeAssertions() { // Break output produces a union: normal path | break path const cacheResult = await cachedLookup.execute( { key: 'test' }, - { cache: { get: () => null } }, + { cache: { get: () => null } } ); void (cacheResult.fromCache satisfies boolean); - void ('value' in cacheResult && cacheResult.value satisfies string); - void ('freshValue' in cacheResult && cacheResult.freshValue satisfies string); + void ('value' in cacheResult && (cacheResult.value satisfies string)); + void ( + 'freshValue' in cacheResult && (cacheResult.freshValue satisfies string) + ); // Narrowing works via the discriminant if (cacheResult.fromCache) { - void (cacheResult satisfies { fromCache: true; value: string; }); + void (cacheResult satisfies { fromCache: true; value: string }); } else { - void (cacheResult satisfies { fromCache: false; freshValue: string; }); + void (cacheResult satisfies { fromCache: false; freshValue: string }); } // Parallel builder output should merge all handler results + prior state const parallelResult = await parallelFlow.execute( { orgId: 'org1' }, - { api: { getUsers: async () => ['a'], getPosts: async () => ['p'] } }, + { api: { getUsers: async () => ['a'], getPosts: async () => ['p'] } } ); void (parallelResult.orgId satisfies string); void (parallelResult.users satisfies string[]); @@ -346,7 +371,15 @@ async function _typeAssertions() { // Transaction output should merge into state const fulfillResult = await fulfillOrder.execute( { orderId: 'o1', items: [{ sku: 'ABC', qty: 2 }] }, - { db: { transaction: async (fn) => fn({ deduct: async () => ({ remaining: 8 }), insertShipment: async () => ({ shipmentId: 's1' }) } as WarehouseTx) } }, + { + db: { + transaction: async (fn) => + fn({ + deduct: async () => ({ remaining: 8 }), + insertShipment: async () => ({ shipmentId: 's1' }), + } as WarehouseTx), + }, + } ); void (fulfillResult.orderId satisfies string); void (fulfillResult.items satisfies InventoryItem[]); @@ -358,7 +391,7 @@ async function _typeAssertions() { const durableResult = await durableFlow.execute( { orderId: 'o1' }, undefined as never, - { durability: { store, runId: 'order-42' } }, + { durability: { store, runId: 'order-42' } } ); void (durableResult.orderId satisfies string); void (durableResult.shipmentId satisfies string); @@ -369,7 +402,10 @@ async function _typeAssertions() { void (store.snapshot('order-42') satisfies FlowCheckpoint | null); // Combined flow chains parallel with subsequent steps - const combinedResult = await combinedFlow.execute({ id: 'x' }, undefined as never); + const combinedResult = await combinedFlow.execute( + { id: 'x' }, + undefined as never + ); void (combinedResult.loaded satisfies true); void (combinedResult.a satisfies number); void (combinedResult.b satisfies string);