@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
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Taras Mankovski
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
const dntGlobals = {};
|
|
2
|
+
export const dntGlobalThis = createMergeProxy(globalThis, dntGlobals);
|
|
3
|
+
function createMergeProxy(baseObj, extObj) {
|
|
4
|
+
return new Proxy(baseObj, {
|
|
5
|
+
get(_target, prop, _receiver) {
|
|
6
|
+
if (prop in extObj) {
|
|
7
|
+
return extObj[prop];
|
|
8
|
+
}
|
|
9
|
+
else {
|
|
10
|
+
return baseObj[prop];
|
|
11
|
+
}
|
|
12
|
+
},
|
|
13
|
+
set(_target, prop, value) {
|
|
14
|
+
if (prop in extObj) {
|
|
15
|
+
delete extObj[prop];
|
|
16
|
+
}
|
|
17
|
+
baseObj[prop] = value;
|
|
18
|
+
return true;
|
|
19
|
+
},
|
|
20
|
+
deleteProperty(_target, prop) {
|
|
21
|
+
let success = false;
|
|
22
|
+
if (prop in extObj) {
|
|
23
|
+
delete extObj[prop];
|
|
24
|
+
success = true;
|
|
25
|
+
}
|
|
26
|
+
if (prop in baseObj) {
|
|
27
|
+
delete baseObj[prop];
|
|
28
|
+
success = true;
|
|
29
|
+
}
|
|
30
|
+
return success;
|
|
31
|
+
},
|
|
32
|
+
ownKeys(_target) {
|
|
33
|
+
const baseKeys = Reflect.ownKeys(baseObj);
|
|
34
|
+
const extKeys = Reflect.ownKeys(extObj);
|
|
35
|
+
const extKeysSet = new Set(extKeys);
|
|
36
|
+
return [...baseKeys.filter((k) => !extKeysSet.has(k)), ...extKeys];
|
|
37
|
+
},
|
|
38
|
+
defineProperty(_target, prop, desc) {
|
|
39
|
+
if (prop in extObj) {
|
|
40
|
+
delete extObj[prop];
|
|
41
|
+
}
|
|
42
|
+
Reflect.defineProperty(baseObj, prop, desc);
|
|
43
|
+
return true;
|
|
44
|
+
},
|
|
45
|
+
getOwnPropertyDescriptor(_target, prop) {
|
|
46
|
+
if (prop in extObj) {
|
|
47
|
+
return Reflect.getOwnPropertyDescriptor(extObj, prop);
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
return Reflect.getOwnPropertyDescriptor(baseObj, prop);
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
has(_target, prop) {
|
|
54
|
+
return prop in extObj || prop in baseObj;
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
}
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structured concurrency combinators for durable workflows.
|
|
3
|
+
*
|
|
4
|
+
* durableSpawn, durableAll, durableRace — each wraps child workflows
|
|
5
|
+
* with DurableContext (coroutine IDs, Close events) so that structured
|
|
6
|
+
* concurrency is fully journaled and replayable.
|
|
7
|
+
*
|
|
8
|
+
* Each combinator returns Workflow<T> (not Operation<T>) so it can be
|
|
9
|
+
* used directly inside a Workflow via yield*. Internally, the infrastructure
|
|
10
|
+
* effects (useScope, spawn, all, race) are wrapped with ephemeral() —
|
|
11
|
+
* these are durable-safe operations that set up scope/context and don't
|
|
12
|
+
* need journaling. See DEC-034.
|
|
13
|
+
*
|
|
14
|
+
* Child workflows must be Workflow<T> — bare Operations are rejected at
|
|
15
|
+
* compile time. Use ephemeral() to explicitly opt in to non-durable
|
|
16
|
+
* children.
|
|
17
|
+
*
|
|
18
|
+
* See protocol spec §7 (structured concurrency), §10 (race semantics).
|
|
19
|
+
*/
|
|
20
|
+
import { all as effectionAll, ensure, race as effectionRace, spawn, suspend, useScope, } from "effection";
|
|
21
|
+
import { DurableCtx } from "./context.js";
|
|
22
|
+
import { ephemeral } from "./ephemeral.js";
|
|
23
|
+
import { deserializeError, serializeError } from "./serialize.js";
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
// Internal: wrap a child workflow with DurableContext + Close emission
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
/**
|
|
28
|
+
* Run a child workflow within a spawned scope, setting up its own
|
|
29
|
+
* DurableContext and emitting a Close event when it terminates.
|
|
30
|
+
*
|
|
31
|
+
* This is the core building block for all structured concurrency combinators.
|
|
32
|
+
*
|
|
33
|
+
* It:
|
|
34
|
+
* 1. Checks if the child already completed (has Close event) — short-circuits
|
|
35
|
+
* 2. Sets DurableCtx on the child's scope with the child's coroutineId
|
|
36
|
+
* 3. Runs the child workflow (its DurableEffects use the child's coroutineId)
|
|
37
|
+
* 4. Appends Close(ok|err) when the child terminates
|
|
38
|
+
*
|
|
39
|
+
* IMPORTANT: This must be called inside a spawn() so it gets its own scope.
|
|
40
|
+
* The caller is responsible for spawn().
|
|
41
|
+
*/
|
|
42
|
+
function* runDurableChild(childWorkflow, childId, parentCtx) {
|
|
43
|
+
const { replayIndex, stream } = parentCtx;
|
|
44
|
+
// Short-circuit: child already completed in a previous run.
|
|
45
|
+
// NOTE: Replay guard validation is not bypassed here — the check phase
|
|
46
|
+
// (runCheckPhase in durableRun) already iterated ALL Yield events
|
|
47
|
+
// (including this child's) before any workflow code runs. Guards have
|
|
48
|
+
// already had a chance to veto stale data. This fast-path only skips
|
|
49
|
+
// re-running the child's generator, not the guard validation.
|
|
50
|
+
if (replayIndex.hasClose(childId)) {
|
|
51
|
+
const closeEvent = replayIndex.getClose(childId);
|
|
52
|
+
if (closeEvent.result.status === "ok") {
|
|
53
|
+
return closeEvent.result.value;
|
|
54
|
+
}
|
|
55
|
+
else if (closeEvent.result.status === "err") {
|
|
56
|
+
throw deserializeError(closeEvent.result.error);
|
|
57
|
+
}
|
|
58
|
+
else {
|
|
59
|
+
// cancelled — this child was cancelled in a previous run (e.g.,
|
|
60
|
+
// a race loser). Instead of throwing, we suspend forever. The
|
|
61
|
+
// parent combinator (race/all) will cancel this child as part of
|
|
62
|
+
// normal structured concurrency teardown, just like the original
|
|
63
|
+
// run. The Close(cancelled) event already exists in the journal,
|
|
64
|
+
// so we skip re-emitting it (the ensure teardown checks for this).
|
|
65
|
+
//
|
|
66
|
+
// INVARIANT: This branch is only reachable when a parent combinator
|
|
67
|
+
// (durableRace or durableAll with a failed sibling) will cancel this
|
|
68
|
+
// child. Close(cancelled) in the journal means the child was
|
|
69
|
+
// previously cancelled by structured concurrency, so on replay the
|
|
70
|
+
// same combinator will cancel it again. This cannot deadlock.
|
|
71
|
+
yield* suspend();
|
|
72
|
+
// unreachable — suspend blocks until cancelled
|
|
73
|
+
return undefined;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
// Set child's DurableContext on this scope
|
|
77
|
+
const scope = yield* useScope();
|
|
78
|
+
scope.set(DurableCtx, {
|
|
79
|
+
replayIndex,
|
|
80
|
+
stream,
|
|
81
|
+
coroutineId: childId,
|
|
82
|
+
childCounter: 0,
|
|
83
|
+
});
|
|
84
|
+
let closeEvent;
|
|
85
|
+
yield* ensure(function* () {
|
|
86
|
+
// closeEvent still undefined means the child was cancelled before the
|
|
87
|
+
// normal-return or catch path ran.
|
|
88
|
+
if (!closeEvent) {
|
|
89
|
+
closeEvent = {
|
|
90
|
+
type: "close",
|
|
91
|
+
coroutineId: childId,
|
|
92
|
+
result: { status: "cancelled" },
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
// Don't re-emit a Close event if one already exists in the journal
|
|
96
|
+
// (e.g., a cancelled child being replayed via suspend()).
|
|
97
|
+
if (!replayIndex.hasClose(childId)) {
|
|
98
|
+
yield* stream.append(closeEvent);
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
try {
|
|
102
|
+
// Run the child workflow. DurableEffects inside the child read
|
|
103
|
+
// DurableCtx from the scope, so they'll use childId.
|
|
104
|
+
const result = yield* childWorkflow();
|
|
105
|
+
closeEvent = {
|
|
106
|
+
type: "close",
|
|
107
|
+
coroutineId: childId,
|
|
108
|
+
result: { status: "ok", value: result },
|
|
109
|
+
};
|
|
110
|
+
return result;
|
|
111
|
+
}
|
|
112
|
+
catch (error) {
|
|
113
|
+
closeEvent = {
|
|
114
|
+
type: "close",
|
|
115
|
+
coroutineId: childId,
|
|
116
|
+
result: {
|
|
117
|
+
status: "err",
|
|
118
|
+
error: serializeError(error instanceof Error ? error : new Error(String(error))),
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
throw error;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
// ---------------------------------------------------------------------------
|
|
125
|
+
// durableSpawn — spawn a single durable child, returns Task<T>
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
/**
|
|
128
|
+
* Spawn a durable child workflow.
|
|
129
|
+
*
|
|
130
|
+
* Assigns a deterministic coroutine ID (parentId.N), sets up DurableContext
|
|
131
|
+
* on the child scope, and ensures Close events are emitted.
|
|
132
|
+
*
|
|
133
|
+
* Returns a Task<T> that can be yield*-ed to get the child's result.
|
|
134
|
+
*
|
|
135
|
+
* Returns Workflow<Task<T>> via ephemeral() — the infrastructure effects
|
|
136
|
+
* (useScope, spawn) are durable-safe scope setup that doesn't need
|
|
137
|
+
* journaling and re-runs correctly on replay.
|
|
138
|
+
*/
|
|
139
|
+
export function durableSpawn(childWorkflow) {
|
|
140
|
+
return ephemeral((function* () {
|
|
141
|
+
const scope = yield* useScope();
|
|
142
|
+
const ctx = scope.expect(DurableCtx);
|
|
143
|
+
// Assign deterministic child ID
|
|
144
|
+
const childIndex = ctx.childCounter++;
|
|
145
|
+
const childId = `${ctx.coroutineId}.${childIndex}`;
|
|
146
|
+
// Spawn the child with durable wrapping
|
|
147
|
+
return yield* spawn(() => runDurableChild(childWorkflow, childId, ctx));
|
|
148
|
+
})());
|
|
149
|
+
}
|
|
150
|
+
// ---------------------------------------------------------------------------
|
|
151
|
+
// durableAll — fork/join, wait for all children
|
|
152
|
+
// ---------------------------------------------------------------------------
|
|
153
|
+
/**
|
|
154
|
+
* Run multiple durable workflows concurrently and wait for all to complete.
|
|
155
|
+
*
|
|
156
|
+
* Each child gets a deterministic coroutine ID (parentId.0, parentId.1, ...).
|
|
157
|
+
* Each child's effects are journaled under its own coroutineId.
|
|
158
|
+
* Each child emits a Close event on termination.
|
|
159
|
+
*
|
|
160
|
+
* If any child fails, remaining children are cancelled (fail-fast,
|
|
161
|
+
* Effection's default structured concurrency behavior via all()).
|
|
162
|
+
*
|
|
163
|
+
* Returns an array of results in the same order as the input workflows.
|
|
164
|
+
*
|
|
165
|
+
* See spec §7, §11.5.
|
|
166
|
+
*/
|
|
167
|
+
export function durableAll(workflows) {
|
|
168
|
+
return ephemeral((function* () {
|
|
169
|
+
const scope = yield* useScope();
|
|
170
|
+
const ctx = scope.expect(DurableCtx);
|
|
171
|
+
// Build child Operations, one per workflow. Each gets its own
|
|
172
|
+
// deterministic coroutineId and Close event handling.
|
|
173
|
+
const childOps = workflows.map((workflow) => {
|
|
174
|
+
const childIndex = ctx.childCounter++;
|
|
175
|
+
const childId = `${ctx.coroutineId}.${childIndex}`;
|
|
176
|
+
return {
|
|
177
|
+
*[Symbol.iterator]() {
|
|
178
|
+
return yield* runDurableChild(workflow, childId, ctx);
|
|
179
|
+
},
|
|
180
|
+
};
|
|
181
|
+
});
|
|
182
|
+
// Delegate to Effection's native all() which uses trap() internally
|
|
183
|
+
// for proper error isolation. This means:
|
|
184
|
+
// - Child errors are catchable by the caller via try/catch
|
|
185
|
+
// - When any child fails, remaining siblings are cancelled
|
|
186
|
+
// - The error propagates with the original message intact
|
|
187
|
+
return yield* effectionAll(childOps);
|
|
188
|
+
})());
|
|
189
|
+
}
|
|
190
|
+
// ---------------------------------------------------------------------------
|
|
191
|
+
// durableRace — first child to complete wins, others cancelled
|
|
192
|
+
// ---------------------------------------------------------------------------
|
|
193
|
+
/**
|
|
194
|
+
* Race multiple durable workflows. The first to complete wins;
|
|
195
|
+
* remaining children are cancelled.
|
|
196
|
+
*
|
|
197
|
+
* Each child gets a deterministic coroutine ID. When the winner
|
|
198
|
+
* completes, Effection cancels the remaining children via
|
|
199
|
+
* iterator.return(). The runDurableChild wrapper detects this
|
|
200
|
+
* (closeEvent is undefined in the finally block) and emits
|
|
201
|
+
* Close(cancelled) for each loser.
|
|
202
|
+
*
|
|
203
|
+
* On replay, children with Close(cancelled) in the journal suspend
|
|
204
|
+
* indefinitely (yield* suspend()), letting the parent race cancel
|
|
205
|
+
* them naturally — matching the original live behavior.
|
|
206
|
+
*
|
|
207
|
+
* See spec §10.
|
|
208
|
+
*/
|
|
209
|
+
export function durableRace(workflows) {
|
|
210
|
+
return ephemeral((function* () {
|
|
211
|
+
const scope = yield* useScope();
|
|
212
|
+
const ctx = scope.expect(DurableCtx);
|
|
213
|
+
// Build Operations for each child — each gets its own coroutineId
|
|
214
|
+
// and Close event handling via runDurableChild.
|
|
215
|
+
const childOps = workflows.map((workflow) => {
|
|
216
|
+
const childIndex = ctx.childCounter++;
|
|
217
|
+
const childId = `${ctx.coroutineId}.${childIndex}`;
|
|
218
|
+
return {
|
|
219
|
+
*[Symbol.iterator]() {
|
|
220
|
+
return yield* runDurableChild(workflow, childId, ctx);
|
|
221
|
+
},
|
|
222
|
+
};
|
|
223
|
+
});
|
|
224
|
+
// Use Effection's native race() which handles cancellation properly
|
|
225
|
+
return yield* effectionRace(childOps);
|
|
226
|
+
})());
|
|
227
|
+
}
|
package/esm/context.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
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 { createContext } from "effection";
|
|
9
|
+
/**
|
|
10
|
+
* Effection Context for durable execution state.
|
|
11
|
+
* Set on the root scope by durableRun(); inherited by child scopes.
|
|
12
|
+
*/
|
|
13
|
+
export const DurableCtx = createContext("@effection/durable");
|
|
@@ -0,0 +1,56 @@
|
|
|
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 { createApi } from "effection/experimental";
|
|
23
|
+
import { ContinuePastCloseDivergenceError, DivergenceError } from "./errors.js";
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
// Default policy (strict — all divergences are fatal)
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
/** The default (strict) decide function. */
|
|
28
|
+
function defaultDecide(info) {
|
|
29
|
+
if (info.kind === "description-mismatch") {
|
|
30
|
+
return {
|
|
31
|
+
type: "throw",
|
|
32
|
+
error: new DivergenceError(info.coroutineId, info.cursor, info.expected, info.actual),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
else {
|
|
36
|
+
return {
|
|
37
|
+
type: "throw",
|
|
38
|
+
error: new ContinuePastCloseDivergenceError(info.coroutineId, info.yieldCount),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
// The Divergence API instance
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
/**
|
|
46
|
+
* The Divergence API.
|
|
47
|
+
*
|
|
48
|
+
* Created via Effection's createApi() which provides proper middleware
|
|
49
|
+
* dispatch with WeakMap-based handle caching, automatic cache
|
|
50
|
+
* invalidation on Api.around(), and a fast-path that skips
|
|
51
|
+
* middleware dispatch entirely when no middleware is installed.
|
|
52
|
+
*
|
|
53
|
+
* Default behavior is strict: all divergences produce a throw decision
|
|
54
|
+
* with the appropriate error type.
|
|
55
|
+
*/
|
|
56
|
+
export const Divergence = createApi("DurableEffection.Divergence", { decide: defaultDecide });
|
package/esm/each.js
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
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 { ensure } from "effection";
|
|
17
|
+
import { createDurableOperation } from "./effect.js";
|
|
18
|
+
import { ephemeral } from "./ephemeral.js";
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
// Internal state
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
/** Sentinel for source exhaustion. Not exported — cannot collide with JSON. */
|
|
23
|
+
const DONE = Symbol("durableEach.done");
|
|
24
|
+
/** Type guard for the DONE sentinel. */
|
|
25
|
+
function isDone(value) {
|
|
26
|
+
return value === DONE;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Module-level active state for sharing between durableEach() and durableEach.next().
|
|
30
|
+
*
|
|
31
|
+
* Safe because durable execution is single-threaded — only one coroutine
|
|
32
|
+
* runs at a time, so there's no concurrent access. durableEach() sets this
|
|
33
|
+
* before returning the iterable; durableEach.next() reads it directly.
|
|
34
|
+
*
|
|
35
|
+
* This avoids using Effection context, which doesn't work when both
|
|
36
|
+
* functions are individually wrapped in ephemeral() (each gets its own
|
|
37
|
+
* child scope, making context invisible across them).
|
|
38
|
+
*
|
|
39
|
+
* LIMITATION: Only one durableEach iteration can be active at a time across
|
|
40
|
+
* the entire process. Concurrent durableEach calls (even in separate
|
|
41
|
+
* durableRun instances) will collide. The nesting guard below throws a
|
|
42
|
+
* clear error if this happens. For concurrent iterations, use child scopes
|
|
43
|
+
* via durableSpawn so each finishes its iteration before another starts.
|
|
44
|
+
*
|
|
45
|
+
* TODO: Consider migrating to a scope-local mechanism if Effection adds
|
|
46
|
+
* support for context that spans across ephemeral() boundaries.
|
|
47
|
+
*/
|
|
48
|
+
let activeState = null;
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
// durableEachFetch — shared helper for fetching one item
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
/**
|
|
53
|
+
* Fetch a single item from the source (or replay it from the journal).
|
|
54
|
+
*
|
|
55
|
+
* Both the initial fetch (inside durableEach) and subsequent fetches
|
|
56
|
+
* (inside durableEach.next) go through this helper. Same effect
|
|
57
|
+
* description, same journal format, same replay path.
|
|
58
|
+
*
|
|
59
|
+
* Uses createDurableOperation to run the source's Operation-native next()
|
|
60
|
+
* with full structured concurrency — cancellation of the scope cancels
|
|
61
|
+
* the in-flight source.next() call.
|
|
62
|
+
*
|
|
63
|
+
* Journal shape: Yield event with description { type: "each", name }
|
|
64
|
+
* and result value { value: T } | { done: true }.
|
|
65
|
+
*/
|
|
66
|
+
function durableEachFetch(name, source) {
|
|
67
|
+
return (function* () {
|
|
68
|
+
const result = (yield createDurableOperation({ type: "each", name }, () => source.next()));
|
|
69
|
+
if ("done" in result)
|
|
70
|
+
return DONE;
|
|
71
|
+
return result.value;
|
|
72
|
+
})();
|
|
73
|
+
}
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
// durableEach — initial fetch + returns synchronous iterable
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
/**
|
|
78
|
+
* Durable iteration over a DurableSource (internal implementation).
|
|
79
|
+
*
|
|
80
|
+
* Returns Operation<Iterable<T>> because it uses ensure() (an
|
|
81
|
+
* infrastructure Operation). The public API wraps this in ephemeral()
|
|
82
|
+
* to return Workflow<Iterable<T>>.
|
|
83
|
+
*
|
|
84
|
+
* durableEach and durableEach.next share state through a module-level
|
|
85
|
+
* variable (activeState). This is safe because durable execution is
|
|
86
|
+
* single-threaded — only one coroutine runs at a time. This avoids
|
|
87
|
+
* Effection context, which doesn't work when both functions are
|
|
88
|
+
* individually wrapped in ephemeral() (each would get its own child
|
|
89
|
+
* scope, making context invisible across them).
|
|
90
|
+
*/
|
|
91
|
+
function* _durableEachOp(name, source) {
|
|
92
|
+
// Guard against nested durableEach — the single module-level slot
|
|
93
|
+
// would clobber the outer iteration's state.
|
|
94
|
+
if (activeState !== null) {
|
|
95
|
+
throw new Error(`durableEach("${name}"): cannot nest durableEach calls in the same scope. Use a child scope (e.g., via spawn) for inner iterations.`);
|
|
96
|
+
}
|
|
97
|
+
// Register source teardown on scope exit — ensures cleanup even if
|
|
98
|
+
// the for...of loop breaks or the scope is cancelled without an
|
|
99
|
+
// active effect. Safe to call alongside effect-level teardown
|
|
100
|
+
// because DurableSource.close() must be idempotent.
|
|
101
|
+
yield* ensure(() => {
|
|
102
|
+
source.close?.();
|
|
103
|
+
});
|
|
104
|
+
// Durable fetch of first item — journaled as a Yield event.
|
|
105
|
+
// ensure() is already registered, so cancellation here is safe.
|
|
106
|
+
const first = yield* durableEachFetch(name, source);
|
|
107
|
+
// Store state in module-level slot for durableEach.next() to access.
|
|
108
|
+
// Cleared when the iteration completes (done or break).
|
|
109
|
+
const state = {
|
|
110
|
+
name,
|
|
111
|
+
source,
|
|
112
|
+
current: first,
|
|
113
|
+
advanced: true, // first item was just fetched
|
|
114
|
+
};
|
|
115
|
+
activeState = state;
|
|
116
|
+
// Return a synchronous iterable. The iterator generator checks
|
|
117
|
+
// the shared state on each re-entry. The try/finally ensures
|
|
118
|
+
// source.close() is called when the loop exits — whether by
|
|
119
|
+
// exhaustion (DONE), break, or throw. This provides immediate
|
|
120
|
+
// cleanup without waiting for scope teardown (ensure() is still
|
|
121
|
+
// registered as a safety net for cancellation during fetch).
|
|
122
|
+
return {
|
|
123
|
+
*[Symbol.iterator]() {
|
|
124
|
+
try {
|
|
125
|
+
while (!isDone(state.current)) {
|
|
126
|
+
// Advance guard: detect missing yield* durableEach.next()
|
|
127
|
+
if (!state.advanced) {
|
|
128
|
+
throw new Error(`durableEach("${name}"): yield* durableEach.next() must be called before the next iteration. Each loop body must end with yield* durableEach.next() to checkpoint progress and fetch the next item.`);
|
|
129
|
+
}
|
|
130
|
+
state.advanced = false;
|
|
131
|
+
yield state.current;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
finally {
|
|
135
|
+
// Clear module-level state so a subsequent durableEach can run.
|
|
136
|
+
activeState = null;
|
|
137
|
+
source.close?.();
|
|
138
|
+
}
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Durable iteration over a DurableSource.
|
|
144
|
+
*
|
|
145
|
+
* Returns Workflow<Iterable<T>> via ephemeral() — the infrastructure
|
|
146
|
+
* effect (ensure) is durable-safe and re-runs correctly on replay.
|
|
147
|
+
*/
|
|
148
|
+
function _durableEach(name, source) {
|
|
149
|
+
return ephemeral(_durableEachOp(name, source));
|
|
150
|
+
}
|
|
151
|
+
// ---------------------------------------------------------------------------
|
|
152
|
+
// durableEach.next — static method to advance iteration
|
|
153
|
+
// ---------------------------------------------------------------------------
|
|
154
|
+
/**
|
|
155
|
+
* Advance the current durable iteration.
|
|
156
|
+
*
|
|
157
|
+
* Reads state from the module-level activeState slot (set by durableEach).
|
|
158
|
+
* This is a pure Workflow — no infrastructure effects, no ephemeral()
|
|
159
|
+
* needed. The only yielded effect is durableEachFetch, which is already
|
|
160
|
+
* a DurableEffect (journaled).
|
|
161
|
+
*/
|
|
162
|
+
function* _durableEachNext() {
|
|
163
|
+
if (activeState === null) {
|
|
164
|
+
throw new Error("durableEach.next(): no active durableEach iteration. " +
|
|
165
|
+
"durableEach.next() must be called inside a durableEach loop.");
|
|
166
|
+
}
|
|
167
|
+
const state = activeState;
|
|
168
|
+
// Fetch next item first, then mark advanced. If the fetch throws
|
|
169
|
+
// (source error), advanced stays false and re-entry triggers the
|
|
170
|
+
// advance guard — preventing stale current from being re-yielded.
|
|
171
|
+
state.current = yield* durableEachFetch(state.name, state.source);
|
|
172
|
+
state.advanced = true;
|
|
173
|
+
}
|
|
174
|
+
// ---------------------------------------------------------------------------
|
|
175
|
+
// Public API — durableEach with static .next() method
|
|
176
|
+
// ---------------------------------------------------------------------------
|
|
177
|
+
/**
|
|
178
|
+
* Durable iteration over a DurableSource.
|
|
179
|
+
*
|
|
180
|
+
* Returns a Workflow that fetches the first item and yields a
|
|
181
|
+
* synchronous iterable. Use with `for...of` inside a Workflow:
|
|
182
|
+
*
|
|
183
|
+
* ```typescript
|
|
184
|
+
* for (let msg of yield* durableEach("queue", source)) {
|
|
185
|
+
* yield* durableCall("process", () => process(msg));
|
|
186
|
+
* yield* durableEach.next();
|
|
187
|
+
* }
|
|
188
|
+
* ```
|
|
189
|
+
*
|
|
190
|
+
* @param name Stable name for the iteration
|
|
191
|
+
* @param source Operation-native source of items
|
|
192
|
+
*/
|
|
193
|
+
export const durableEach = Object.assign(_durableEach, { next: _durableEachNext });
|