@executablemd/durable-streams 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,26 @@
1
+ /**
2
+ * DurableContext — the scope-local state for durable execution.
3
+ *
4
+ * Stored on each Effection scope via createContext(). Child scopes
5
+ * inherit the shared replayIndex and stream, but get their own
6
+ * coroutineId and childCounter.
7
+ */
8
+ import { type Context } from "effection";
9
+ import type { ReplayIndex } from "./replay-index.js";
10
+ import type { DurableStream } from "./stream.js";
11
+ import type { CoroutineId } from "./types.js";
12
+ export interface DurableContext {
13
+ /** Shared replay index (built from stream on startup). */
14
+ replayIndex: ReplayIndex;
15
+ /** Shared durable stream for appending events. */
16
+ stream: DurableStream;
17
+ /** This coroutine's hierarchical ID. */
18
+ coroutineId: CoroutineId;
19
+ /** Counter for assigning child IDs. */
20
+ childCounter: number;
21
+ }
22
+ /**
23
+ * Effection Context for durable execution state.
24
+ * Set on the root scope by durableRun(); inherited by child scopes.
25
+ */
26
+ export declare const DurableCtx: Context<DurableContext>;
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Divergence API — pluggable policy for handling replay mismatches.
3
+ *
4
+ * When a durable effect's description doesn't match the replay index
5
+ * during replay, or when a generator continues to yield effects past
6
+ * a recorded Close event, a divergence is detected.
7
+ *
8
+ * By default, divergence is fatal (throws DivergenceError). Users can
9
+ * override this behavior per-scope via Effection's around() middleware
10
+ * to implement custom policies (e.g., switching to live execution).
11
+ *
12
+ * Uses createApi() from effection/experimental (not @effectionx/context-api)
13
+ * because it requires synchronous Api.invoke() dispatch, which the
14
+ * vendored context-api does not yet support. Will migrate once invoke()
15
+ * lands in @effectionx/context-api.
16
+ *
17
+ * The core decide() function is synchronous (not a generator) because
18
+ * it is called from inside Effect.enter(), which is a synchronous
19
+ * callback. createApi().invoke() dispatches synchronously, so this
20
+ * is safe. See DEC-031.
21
+ */
22
+ import type { Api } from "effection";
23
+ import type { CoroutineId, EffectDescription } from "./types.js";
24
+ /** The two kinds of divergence detected during replay. */
25
+ export type DivergenceKind = "description-mismatch" | "continue-past-close";
26
+ /**
27
+ * Information about a detected divergence.
28
+ *
29
+ * Discriminated union on `kind` so that TypeScript enforces correct
30
+ * field access per variant.
31
+ */
32
+ export type DivergenceInfo = {
33
+ kind: "description-mismatch";
34
+ coroutineId: CoroutineId;
35
+ /** Cursor position (yield index) where divergence was detected. */
36
+ cursor: number;
37
+ /** The description from the journal (what was expected). */
38
+ expected: EffectDescription;
39
+ /** The description from the generator (what was actually yielded). */
40
+ actual: EffectDescription;
41
+ } | {
42
+ kind: "continue-past-close";
43
+ coroutineId: CoroutineId;
44
+ /** Number of yield entries recorded for this coroutine. */
45
+ yieldCount: number;
46
+ };
47
+ /**
48
+ * The policy decision returned by the Divergence API.
49
+ *
50
+ * - "throw": Fail the workflow with the provided error (default behavior).
51
+ * - "run-live": Disable replay for this coroutine and execute live from
52
+ * this point forward. Previous replay entries are ignored.
53
+ */
54
+ export type DivergenceDecision = {
55
+ type: "throw";
56
+ error: Error;
57
+ } | {
58
+ type: "run-live";
59
+ };
60
+ /**
61
+ * The core shape of the Divergence API.
62
+ *
63
+ * decide() is synchronous because it is called from Effect.enter(),
64
+ * which cannot yield. Middleware installed via Api.around() also
65
+ * runs synchronously in the chain.
66
+ *
67
+ * Usage from Effect.enter() (synchronous):
68
+ * Divergence.invoke(scope, "decide", [info])
69
+ *
70
+ * Middleware installation (from a generator):
71
+ * yield* Divergence.around({ decide: ([info], next) => next(info) })
72
+ */
73
+ interface DivergenceApi {
74
+ decide(info: DivergenceInfo): DivergenceDecision;
75
+ }
76
+ /**
77
+ * The Divergence API.
78
+ *
79
+ * Created via Effection's createApi() which provides proper middleware
80
+ * dispatch with WeakMap-based handle caching, automatic cache
81
+ * invalidation on Api.around(), and a fast-path that skips
82
+ * middleware dispatch entirely when no middleware is installed.
83
+ *
84
+ * Default behavior is strict: all divergences produce a throw decision
85
+ * with the appropriate error type.
86
+ */
87
+ export declare const Divergence: Api<DivergenceApi>;
88
+ export {};
@@ -0,0 +1,62 @@
1
+ /**
2
+ * durableEach — durable iteration primitive for Effection workflows.
3
+ *
4
+ * Mirrors Effection's `each()` / `each.next()` pattern but journals
5
+ * every fetch as a DurableEffect, so iteration survives crashes and
6
+ * replays from the journal.
7
+ *
8
+ * Usage:
9
+ * for (let msg of yield* durableEach("queue", source)) {
10
+ * yield* durableCall("process", () => process(msg));
11
+ * yield* durableEach.next();
12
+ * }
13
+ *
14
+ * See effection-integration.md §12.6 for the full design.
15
+ */
16
+ import type { Operation } from "effection";
17
+ import type { Json, Workflow } from "./types.js";
18
+ /**
19
+ * Source of items for durable iteration.
20
+ *
21
+ * Each call to `next()` blocks until the next item is available.
22
+ * Returns `{ value: T }` for an item, `{ done: true }` for exhaustion.
23
+ *
24
+ * The `{ done: true }` wrapper (rather than `T | null`) avoids ambiguity
25
+ * when `null` is a legitimate JSON value from the source.
26
+ */
27
+ export interface DurableSource<T extends Json> {
28
+ /** Read the next item, blocking until available. */
29
+ next(): Operation<{
30
+ value: T;
31
+ } | {
32
+ done: true;
33
+ }>;
34
+ /**
35
+ * Teardown — called on cancellation or completion.
36
+ *
37
+ * Must be idempotent: may be called more than once (once from effect
38
+ * teardown during an in-flight fetch, once from scope cleanup via
39
+ * ensure()). Subsequent calls after the first should be no-ops.
40
+ */
41
+ close?(): void;
42
+ }
43
+ /**
44
+ * Durable iteration over a DurableSource.
45
+ *
46
+ * Returns a Workflow that fetches the first item and yields a
47
+ * synchronous iterable. Use with `for...of` inside a Workflow:
48
+ *
49
+ * ```typescript
50
+ * for (let msg of yield* durableEach("queue", source)) {
51
+ * yield* durableCall("process", () => process(msg));
52
+ * yield* durableEach.next();
53
+ * }
54
+ * ```
55
+ *
56
+ * @param name Stable name for the iteration
57
+ * @param source Operation-native source of items
58
+ */
59
+ export declare const durableEach: {
60
+ <T extends Json>(name: string, source: DurableSource<T>): Workflow<Iterable<T>>;
61
+ next<T extends Json>(): Workflow<void>;
62
+ };
@@ -0,0 +1,60 @@
1
+ /**
2
+ * createDurableEffect / createDurableOperation — core factories for durable effects.
3
+ *
4
+ * Each DurableEffect handles its own replay/live dispatch inside enter().
5
+ * It reads DurableContext from the scope, checks the replay index, and
6
+ * either feeds the stored result (replay) or executes live with
7
+ * persist-before-resume semantics.
8
+ *
9
+ * Two factories are provided:
10
+ * - createDurableEffect: callback-based executor (resolve/reject/teardown)
11
+ * for timer-like and callback-based APIs (durableSleep, durableAction).
12
+ * - createDurableOperation: Operation-based executor for structured
13
+ * concurrency. The live path runs entirely as a generator — execute,
14
+ * capture result, persist, resolve. No callbacks, no .then().
15
+ *
16
+ * Divergence policy is delegated to the Divergence API (DEC-031).
17
+ * By default, mismatches are fatal. Users can install middleware via
18
+ * yield* Divergence.around(...) to override behavior per-scope.
19
+ *
20
+ * See integration doc §5.1, protocol spec §4.2, §5, §6.
21
+ */
22
+ import type { Operation } from "effection";
23
+ import type { DurableEffect, EffectDescription, Json, Result } from "./types.js";
24
+ /**
25
+ * Executor function signature for live execution (callback-based).
26
+ *
27
+ * The executor receives:
28
+ * - resolve: call with a protocol Result when the effect completes
29
+ * - reject: call with an Error for unexpected failures
30
+ *
31
+ * Returns a teardown function called during scope destruction/cancellation.
32
+ */
33
+ export type Executor = (resolve: (result: Result) => void, reject: (error: Error) => void) => () => void;
34
+ /**
35
+ * Creates a DurableEffect using a callback-based executor.
36
+ *
37
+ * Use this for timer-like and callback-based APIs (durableSleep, durableAction)
38
+ * where the resolve/reject/teardown pattern is natural.
39
+ *
40
+ * For Operation-based effects, prefer createDurableOperation.
41
+ *
42
+ * @param desc Structured description for the journal and divergence detection
43
+ * @param execute Called only during live execution (skipped during replay)
44
+ */
45
+ export declare function createDurableEffect<T>(desc: EffectDescription, execute: Executor): DurableEffect<T>;
46
+ /**
47
+ * Creates a DurableEffect from an Operation-returning function.
48
+ *
49
+ * The live path runs entirely as a generator inside scope.run():
50
+ * execute the Operation, capture the result, persist the Yield event,
51
+ * then resolve the generator. No callbacks, no .then(), full structured
52
+ * concurrency — if the scope tears down, the operation is cancelled.
53
+ *
54
+ * Use this for durableCall and any effect where the work is expressed
55
+ * as an Operation (or can be wrapped as one via Effection's call()).
56
+ *
57
+ * @param desc Structured description for the journal and divergence detection
58
+ * @param execute Returns an Operation to run during live execution
59
+ */
60
+ export declare function createDurableOperation<T extends Json>(desc: EffectDescription, execute: () => Operation<T>): DurableEffect<T>;
@@ -0,0 +1,40 @@
1
+ /**
2
+ * ephemeral — explicit escape hatch for non-durable Operations inside Workflows.
3
+ *
4
+ * Wraps an Operation<T> so it satisfies the Workflow<T> type contract.
5
+ * The wrapped operation is **transparent to the journal** — no Yield event
6
+ * is written, no replay index entry is consumed. On replay, the operation
7
+ * simply re-runs.
8
+ *
9
+ * This is analogous to Rust's `unsafe {}` — it marks the boundary where
10
+ * the user is opting out of durable guarantees. Every non-durable
11
+ * Operation that needs to participate in a Workflow must go through
12
+ * ephemeral() to make the escape explicit and auditable.
13
+ *
14
+ * Usage:
15
+ * yield* durableAll([
16
+ * () => myDurableWorkflow(), // Workflow<T> — journaled
17
+ * function*() { // Workflow<T> with ephemeral child
18
+ * return yield* ephemeral(someOperation());
19
+ * },
20
+ * ]);
21
+ *
22
+ * See DEC-034 for the design rationale.
23
+ */
24
+ import type { Operation } from "effection";
25
+ import type { Workflow } from "./types.js";
26
+ /**
27
+ * Wrap a non-durable Operation so it can be used inside a Workflow.
28
+ *
29
+ * The operation is transparent to the durable execution protocol:
30
+ * - No Yield event is written to the journal
31
+ * - On replay, the operation re-runs (it is not cached)
32
+ * - Cancellation flows through normally via structured concurrency
33
+ *
34
+ * Use this when you need to run infrastructure Operations (or intentionally
35
+ * non-durable work) inside a Workflow where only DurableEffects are allowed.
36
+ *
37
+ * @param operation The Operation to wrap
38
+ * @returns A Workflow that yields the operation's result
39
+ */
40
+ export declare function ephemeral<T>(operation: Operation<T>): Workflow<T>;
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Error types for the durable execution protocol.
3
+ */
4
+ import type { CoroutineId, EffectDescription } from "./types.js";
5
+ /**
6
+ * Raised when the replay index entry at the current cursor position
7
+ * does not match the effect yielded by the generator. See spec §6.2.
8
+ *
9
+ * A DivergenceError is NOT recoverable. The workflow cannot continue
10
+ * because the generator's execution path has diverged from the recorded
11
+ * history.
12
+ */
13
+ export declare class DivergenceError extends Error {
14
+ name: string;
15
+ coroutineId: CoroutineId;
16
+ /** Cursor position within the coroutine where divergence was detected. */
17
+ position: number;
18
+ /** The description from the journal (what was expected). */
19
+ expected: EffectDescription;
20
+ /** The description from the generator (what was actually yielded). */
21
+ actual: EffectDescription;
22
+ constructor(coroutineId: CoroutineId, position: number, expected: EffectDescription, actual: EffectDescription, message?: string);
23
+ }
24
+ /**
25
+ * Raised when the generator finishes (returns) while the replay index
26
+ * still has unconsumed entries for this coroutine. See spec §6.3.
27
+ */
28
+ export declare class EarlyReturnDivergenceError extends Error {
29
+ name: string;
30
+ coroutineId: CoroutineId;
31
+ consumedCount: number;
32
+ totalCount: number;
33
+ constructor(coroutineId: CoroutineId, consumedCount: number, totalCount: number);
34
+ }
35
+ /**
36
+ * Raised when the journal has a Close event for a coroutine but the
37
+ * generator has not finished after consuming all recorded yields.
38
+ * See spec §6.3.
39
+ */
40
+ export declare class ContinuePastCloseDivergenceError extends Error {
41
+ name: string;
42
+ coroutineId: CoroutineId;
43
+ yieldCount: number;
44
+ constructor(coroutineId: CoroutineId, yieldCount: number);
45
+ }
46
+ /**
47
+ * Raised by a replay guard when a journal entry's recorded result is
48
+ * stale (e.g., the source file has changed since the effect was
49
+ * originally executed).
50
+ *
51
+ * Guards detect staleness by comparing current state against data stored
52
+ * in the effect description (input fields like file path) and result
53
+ * value (output fields like content hash).
54
+ *
55
+ * StaleInputError is NOT a divergence — the effect identity matches,
56
+ * but the external world has changed. The correct response depends on
57
+ * application policy: re-run from scratch, accept stale results, or
58
+ * (in future versions) re-execute the effect and continue.
59
+ *
60
+ * See replay-guard-spec.md §4.4.
61
+ */
62
+ export declare class StaleInputError extends Error {
63
+ name: string;
64
+ /** The Yield event that was detected as stale. */
65
+ event?: {
66
+ coroutineId: string;
67
+ description: {
68
+ type: string;
69
+ name: string;
70
+ };
71
+ };
72
+ constructor(
73
+ /** Human-readable description of what changed. */
74
+ message: string,
75
+ /** The Yield event that was detected as stale. */
76
+ event?: {
77
+ coroutineId: string;
78
+ description: {
79
+ type: string;
80
+ name: string;
81
+ };
82
+ });
83
+ }
@@ -0,0 +1,42 @@
1
+ import type { Operation } from "effection";
2
+ import type { DurableStream } from "./stream.js";
3
+ /**
4
+ * Configuration for useHttpDurableStream.
5
+ */
6
+ export interface HttpDurableStreamOptions {
7
+ /** Base URL of the Durable Streams server (e.g. "http://localhost:4437"). */
8
+ baseUrl: string;
9
+ /** Stream identifier. Will be used as the URL path segment. */
10
+ streamId: string;
11
+ /** Unique producer identifier for idempotent append tracking. */
12
+ producerId: string;
13
+ /** Producer epoch — monotonically increasing. Stale epochs are fenced. */
14
+ epoch: number;
15
+ /** Optional custom fetch implementation (for testing). */
16
+ fetch?: typeof globalThis.fetch;
17
+ }
18
+ /**
19
+ * Extended DurableStream with HTTP-specific observable state.
20
+ */
21
+ export interface HttpDurableStreamHandle extends DurableStream {
22
+ /**
23
+ * Last Stream-Next-Offset received from the server.
24
+ * Tracked from both reads and writes (DEC-029).
25
+ * This is the resumption point for future tail() calls.
26
+ */
27
+ lastOffset: string | undefined;
28
+ }
29
+ /**
30
+ * Create an HTTP-backed DurableStream as an Effection resource.
31
+ *
32
+ * The resource:
33
+ * 1. Creates the stream on the server (idempotent PUT)
34
+ * 2. Spawns a serial worker that processes appends in FIFO order
35
+ * 3. Returns a DurableStream handle with Operation-native readAll/append
36
+ *
37
+ * The worker is cancelled when the resource scope is torn down.
38
+ *
39
+ * Usage:
40
+ * yield* useHttpDurableStream({ baseUrl, streamId, producerId, epoch })
41
+ */
42
+ export declare function useHttpDurableStream(opts: HttpDurableStreamOptions): Operation<HttpDurableStreamHandle>;
package/types/mod.d.ts ADDED
@@ -0,0 +1,31 @@
1
+ /**
2
+ * @module
3
+ * Durable execution for Effection.
4
+ *
5
+ * Implements the two-event durable execution protocol for generator-based
6
+ * structured concurrency, with Durable Streams as the persistence backend.
7
+ */
8
+ export type { Close, CoroutineId, CoroutineView, DurableEffect, DurableEvent, EffectDescription, EffectionResult, Json, Resolve, Result, SerializedError, Workflow, Yield, } from "./types.js";
9
+ export { ReplayIndex } from "./replay-index.js";
10
+ export type { YieldEntry } from "./replay-index.js";
11
+ export type { DurableStream } from "./stream.js";
12
+ export { InMemoryStream } from "./stream.js";
13
+ export { useHttpDurableStream } from "./http-stream.js";
14
+ export type { HttpDurableStreamHandle, HttpDurableStreamOptions } from "./http-stream.js";
15
+ export { ContinuePastCloseDivergenceError, DivergenceError, EarlyReturnDivergenceError, StaleInputError, } from "./errors.js";
16
+ export { Divergence } from "./divergence.js";
17
+ export type { DivergenceDecision, DivergenceInfo, DivergenceKind } from "./divergence.js";
18
+ export { ReplayGuard } from "./replay-guard.js";
19
+ export type { ReplayOutcome } from "./replay-guard.js";
20
+ export { DurableCtx } from "./context.js";
21
+ export type { DurableContext } from "./context.js";
22
+ export { deserializeError, effectionToProtocol, protocolToEffection, serializeError, } from "./serialize.js";
23
+ export { createDurableEffect, createDurableOperation } from "./effect.js";
24
+ export type { Executor } from "./effect.js";
25
+ export { durableAction, durableCall, durableSleep, versionCheck } from "./operations.js";
26
+ export { durableAll, durableRace, durableSpawn } from "./combinators.js";
27
+ export { durableEach } from "./each.js";
28
+ export type { DurableSource } from "./each.js";
29
+ export { ephemeral } from "./ephemeral.js";
30
+ export { durableRun } from "./run.js";
31
+ export type { DurableRunOptions } from "./run.js";
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Workflow-enabled effects — durable equivalents of Effection's built-in
3
+ * operations.
4
+ *
5
+ * Each returns a Workflow<T> (a generator that yields a single DurableEffect).
6
+ * These are the building blocks for durable workflows.
7
+ *
8
+ * See integration doc §6.
9
+ */
10
+ import type { Operation } from "effection";
11
+ import type { Json, Workflow } from "./types.js";
12
+ /**
13
+ * Durable sleep — pauses the workflow for `ms` milliseconds.
14
+ *
15
+ * During replay, resolves synchronously with the stored result.
16
+ * During live execution, uses setTimeout and persists the Yield event.
17
+ *
18
+ * Description: { type: "sleep", name: "sleep" }
19
+ */
20
+ export declare function durableSleep(ms: number): Workflow<void>;
21
+ /**
22
+ * Durable call — wraps a function for durable execution.
23
+ *
24
+ * Accepts functions returning either a Promise or an Operation.
25
+ * Effection's call() handles the dispatch at runtime: Promises are
26
+ * bridged, Operations run with full structured concurrency.
27
+ *
28
+ * The function is called during live execution; its resolved value is
29
+ * serialized and persisted. During replay, the stored value is returned
30
+ * without calling the function.
31
+ *
32
+ * Description: { type: "call", name }
33
+ *
34
+ * IMPORTANT: The function's return value must be JSON-serializable.
35
+ *
36
+ * @param name Stable identifier for the effect (used for divergence detection)
37
+ * @param fn Function returning a Promise or Operation (only called during live execution)
38
+ */
39
+ export declare function durableCall<T extends Json>(name: string, fn: () => Promise<T> | Operation<T>): Workflow<T>;
40
+ /**
41
+ * Durable action — generic effect with a custom executor.
42
+ *
43
+ * Like Effection's action(), but durable. The executor receives resolve/reject
44
+ * callbacks and returns a teardown function.
45
+ *
46
+ * Description: { type: "action", name }
47
+ */
48
+ export declare function durableAction<T extends Json>(name: string, executor: (resolve: (value: T) => void, reject: (error: Error) => void) => () => void): Workflow<T>;
49
+ /**
50
+ * Version gate — enables safe code evolution for durable workflows.
51
+ *
52
+ * During live execution, resolves with `maxVersion`. During replay, the
53
+ * stored version determines which code path the workflow takes.
54
+ *
55
+ * Description: { type: "version_gate", name }
56
+ *
57
+ * See spec §9.
58
+ */
59
+ export declare function versionCheck(name: string, opts: {
60
+ minVersion: number;
61
+ maxVersion: number;
62
+ }): Workflow<number>;
@@ -0,0 +1,102 @@
1
+ /**
2
+ * ReplayGuard API — pluggable validation for replay staleness detection.
3
+ *
4
+ * The durable execution protocol's default behavior is "logs are authoritative"
5
+ * — the journal is unconditionally trusted during replay. ReplayGuard extends
6
+ * this with opt-in validation: guards can examine effect descriptions and
7
+ * result values to validate that recorded results are still valid against
8
+ * current state before allowing replay to proceed.
9
+ *
10
+ * Guards access `event.description.*` for effect input fields (e.g., file
11
+ * path, URL, encoding) and `event.result.value.*` for effect output fields
12
+ * (e.g., content hash, status code). There is no separate metadata field —
13
+ * inputs belong in the effect description, outputs belong in the result.
14
+ *
15
+ * The API has two phases:
16
+ *
17
+ * 1. **check** (before replay begins): Runs in generator context inside
18
+ * `durableRun`, after the journal is loaded but before the workflow starts.
19
+ * I/O is allowed — this is where file hashing, network checks, and other
20
+ * observation-gathering happens. Results are cached in middleware closures.
21
+ *
22
+ * 2. **decide** (during replay): Runs synchronously inside
23
+ * `DurableEffect.enter()`, after identity matching succeeds but before
24
+ * the stored result is fed to the generator. Must be pure and side-effect-
25
+ * free. Reads from the cache populated during the check phase.
26
+ *
27
+ * Multiple guards compose via Effection's `Api.around()`. A guard that has
28
+ * an opinion returns an outcome directly; one that doesn't calls `next(event)`
29
+ * to delegate. The first `error` outcome wins — the chain short-circuits.
30
+ *
31
+ * See replay-guard-spec.md for the full design.
32
+ */
33
+ import type { Api, Operation } from "effection";
34
+ import type { Yield } from "./types.js";
35
+ /**
36
+ * The outcome of a replay guard's decision.
37
+ *
38
+ * - "replay": Proceed with replay — use the stored journal result.
39
+ * - "error": Halt replay with an error — the journal entry is stale.
40
+ *
41
+ * Future versions may add:
42
+ * - "reexecute": Re-execute the effect and replace the journal entry.
43
+ * - "fork": Create a new execution branch from this point.
44
+ */
45
+ export type ReplayOutcome = {
46
+ outcome: "replay";
47
+ } | {
48
+ outcome: "error";
49
+ error?: Error;
50
+ };
51
+ /**
52
+ * The core shape of the ReplayGuard API.
53
+ *
54
+ * - `check`: Called once per Yield event, before replay begins. Runs in
55
+ * generator context — I/O is allowed. Use to gather current state (hash
56
+ * files, check timestamps) and cache results for the decide phase.
57
+ *
58
+ * - `decide`: Called during replay, after identity matching succeeds.
59
+ * Must be pure and synchronous — no I/O, no side effects. Returns the
60
+ * replay outcome based on cached observations.
61
+ */
62
+ interface ReplayGuardApi {
63
+ /** Phase 1: Check — gather observations before replay (I/O allowed). */
64
+ check(event: Yield): Operation<void>;
65
+ /** Phase 2: Decide — return replay outcome (synchronous, pure). */
66
+ decide(event: Yield): ReplayOutcome;
67
+ }
68
+ /**
69
+ * The ReplayGuard API.
70
+ *
71
+ * Default behavior is pass-through: `check` does nothing, `decide` returns
72
+ * `{ outcome: "replay" }`. This preserves "logs are authoritative" unless
73
+ * middleware says otherwise.
74
+ *
75
+ * Install guards via `yield* ReplayGuard.around({ ... })` before calling
76
+ * `durableRun`. Guards are inherited by child scopes through Effection's
77
+ * context inheritance.
78
+ *
79
+ * Example:
80
+ * ```ts
81
+ * function* myWorkflow(): Operation<void> {
82
+ * const scope = yield* useScope();
83
+ * yield* ReplayGuard.around({
84
+ * *check([event], next) {
85
+ * // Gather observations (I/O allowed here)
86
+ * return yield* next(event);
87
+ * },
88
+ * decide([event], next) {
89
+ * // Make decision (pure, synchronous)
90
+ * if (isStale(event)) {
91
+ * return { outcome: "error", error: new StaleInputError(...) };
92
+ * }
93
+ * return next(event);
94
+ * },
95
+ * });
96
+ *
97
+ * yield* durableRun(workflow, { stream });
98
+ * }
99
+ * ```
100
+ */
101
+ export declare const ReplayGuard: Api<ReplayGuardApi>;
102
+ export {};
@@ -0,0 +1,74 @@
1
+ /**
2
+ * ReplayIndex — derived, in-memory structure built from the stream on startup.
3
+ *
4
+ * Provides per-coroutine cursored access to Yield events and keyed access
5
+ * to Close events. See spec §4.1.
6
+ */
7
+ import type { Close, CoroutineId, DurableEvent, EffectDescription, Result } from "./types.js";
8
+ export interface YieldEntry {
9
+ description: EffectDescription;
10
+ result: Result;
11
+ }
12
+ export declare class ReplayIndex {
13
+ private yields;
14
+ private cursors;
15
+ private closes;
16
+ /** Coroutines where replay has been disabled (run-live mode). */
17
+ private disabled;
18
+ constructor(events: DurableEvent[]);
19
+ /**
20
+ * Disable replay for a coroutine (run-live mode).
21
+ *
22
+ * Once disabled, peekYield() returns undefined and hasClose() returns
23
+ * false for this coroutine, so all subsequent effects execute live
24
+ * and no further divergence checks are triggered.
25
+ */
26
+ disableReplay(coroutineId: CoroutineId): void;
27
+ /** Returns true if replay has been disabled for this coroutine. */
28
+ isReplayDisabled(coroutineId: CoroutineId): boolean;
29
+ /**
30
+ * Returns the next unconsumed yield for this coroutine,
31
+ * or undefined if the cursor is past the end or replay is disabled.
32
+ */
33
+ peekYield(coroutineId: CoroutineId): YieldEntry | undefined;
34
+ /** Advances the cursor for this coroutine by one position. */
35
+ consumeYield(coroutineId: CoroutineId): void;
36
+ /** Returns the current cursor position for this coroutine. */
37
+ getCursor(coroutineId: CoroutineId): number;
38
+ /** Returns true if a Close event exists for this coroutine (and replay is not disabled). */
39
+ hasClose(coroutineId: CoroutineId): boolean;
40
+ /** Returns the Close event for this coroutine, or undefined. */
41
+ getClose(coroutineId: CoroutineId): Close | undefined;
42
+ /**
43
+ * Returns true if any non-disabled coroutine still has unconsumed yields.
44
+ *
45
+ * This is used by durableRun to detect early-return divergence even when
46
+ * unconsumed entries belong to child coroutines rather than the root.
47
+ */
48
+ hasAnyUnconsumedYields(): boolean;
49
+ /**
50
+ * Return the first non-disabled coroutine with unconsumed yields.
51
+ *
52
+ * NOTE: Closed coroutines are skipped because their yields were consumed
53
+ * by the child's own replay path (via runDurableChild). This means
54
+ * orphaned children (recorded in the journal but never spawned in the
55
+ * current run) are not detected here. Orphan detection requires tracking
56
+ * which coroutine IDs were visited during the current run, which is a
57
+ * future enhancement.
58
+ */
59
+ firstUnconsumed(): {
60
+ coroutineId: CoroutineId;
61
+ cursor: number;
62
+ totalYields: number;
63
+ } | undefined;
64
+ /**
65
+ * Returns true if the cursor for this coroutine has been fully consumed
66
+ * AND a Close event exists. This means the coroutine completed in a
67
+ * previous run and can be treated as fully replayed.
68
+ *
69
+ * Returns false if replay is disabled (run-live mode).
70
+ */
71
+ isFullyReplayed(coroutineId: CoroutineId): boolean;
72
+ /** Returns the total number of yield entries for this coroutine. */
73
+ yieldCount(coroutineId: CoroutineId): number;
74
+ }