@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.
- package/LICENSE +21 -0
- package/esm/_dnt.shims.js +57 -0
- package/esm/combinators.js +227 -0
- package/esm/context.js +13 -0
- package/esm/divergence.js +56 -0
- package/esm/each.js +193 -0
- package/esm/effect.js +273 -0
- package/esm/ephemeral.js +80 -0
- package/esm/errors.js +92 -0
- package/esm/http-stream.js +208 -0
- package/esm/mod.js +34 -0
- package/esm/operations.js +88 -0
- package/esm/package.json +3 -0
- package/esm/replay-guard.js +87 -0
- package/esm/replay-index.js +132 -0
- package/esm/run.js +137 -0
- package/esm/serialize.js +65 -0
- package/esm/stream.js +56 -0
- package/esm/types.js +10 -0
- package/package.json +30 -0
- package/types/_dnt.shims.d.ts +1 -0
- package/types/combinators.d.ts +66 -0
- package/types/context.d.ts +26 -0
- package/types/divergence.d.ts +88 -0
- package/types/each.d.ts +62 -0
- package/types/effect.d.ts +60 -0
- package/types/ephemeral.d.ts +40 -0
- package/types/errors.d.ts +83 -0
- package/types/http-stream.d.ts +42 -0
- package/types/mod.d.ts +31 -0
- package/types/operations.d.ts +62 -0
- package/types/replay-guard.d.ts +102 -0
- package/types/replay-index.d.ts +74 -0
- package/types/run.d.ts +46 -0
- package/types/serialize.d.ts +31 -0
- package/types/stream.d.ts +49 -0
- package/types/types.d.ts +130 -0
|
@@ -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 {};
|
package/types/each.d.ts
ADDED
|
@@ -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
|
+
}
|