@executablemd/testing 0.3.1 → 0.4.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/esm/{testing/npm/src/src → src}/assertions.js +15 -24
- package/esm/{testing/npm/src/src → src}/handlers.js +140 -10
- package/esm/{testing/npm/src/src → src}/vocabulary.js +3 -0
- package/package.json +3 -3
- package/types/src/assertions.d.ts +11 -1
- package/types/src/handlers.d.ts +1 -0
- package/esm/core/mod.js +0 -55
- package/esm/core/src/api.js +0 -31
- package/esm/core/src/collect.js +0 -24
- package/esm/core/src/component-api.js +0 -47
- package/esm/core/src/content-context.js +0 -23
- package/esm/core/src/deno-compiler.js +0 -51
- package/esm/core/src/errors.js +0 -13
- package/esm/core/src/eval-context.js +0 -13
- package/esm/core/src/eval-handler.js +0 -79
- package/esm/core/src/eval-interpolate.js +0 -63
- package/esm/core/src/eval-transform.js +0 -378
- package/esm/core/src/execute.js +0 -378
- package/esm/core/src/expand.js +0 -988
- package/esm/core/src/frontmatter.js +0 -98
- package/esm/core/src/heal.js +0 -29
- package/esm/core/src/interpolate.js +0 -39
- package/esm/core/src/modifiers/daemon.js +0 -60
- package/esm/core/src/modifiers/persist.js +0 -35
- package/esm/core/src/modifiers/timeout.js +0 -55
- package/esm/core/src/modifiers.js +0 -113
- package/esm/core/src/output/normalize.js +0 -33
- package/esm/core/src/output/terminal.js +0 -20
- package/esm/core/src/render.js +0 -30
- package/esm/core/src/replay-stream.js +0 -58
- package/esm/core/src/sample-api.js +0 -45
- package/esm/core/src/scanner.js +0 -632
- package/esm/core/src/temp-file-compiler.js +0 -68
- package/esm/core/src/types.js +0 -7
- package/esm/core/src/validate.js +0 -82
- package/esm/durable-streams/combinators.js +0 -227
- package/esm/durable-streams/context.js +0 -13
- package/esm/durable-streams/divergence.js +0 -56
- package/esm/durable-streams/each.js +0 -193
- package/esm/durable-streams/effect.js +0 -273
- package/esm/durable-streams/ephemeral.js +0 -80
- package/esm/durable-streams/errors.js +0 -92
- package/esm/durable-streams/http-stream.js +0 -207
- package/esm/durable-streams/mod.js +0 -34
- package/esm/durable-streams/operations.js +0 -88
- package/esm/durable-streams/replay-guard.js +0 -87
- package/esm/durable-streams/replay-index.js +0 -132
- package/esm/durable-streams/run.js +0 -137
- package/esm/durable-streams/serialize.js +0 -65
- package/esm/durable-streams/stream.js +0 -56
- package/esm/durable-streams/types.js +0 -10
- package/esm/runtime/apis.js +0 -237
- package/esm/runtime/find-free-port.js +0 -33
- package/esm/runtime/mod.js +0 -20
- /package/esm/{testing/npm/src/_dnt.polyfills.js → _dnt.polyfills.js} +0 -0
- /package/esm/{testing/npm/src/_dnt.shims.js → _dnt.shims.js} +0 -0
- /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/assert/1.0.19/almost_equals.js +0 -0
- /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/assert/1.0.19/array_includes.js +0 -0
- /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/assert/1.0.19/assert.js +0 -0
- /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/assert/1.0.19/assertion_error.js +0 -0
- /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/assert/1.0.19/equal.js +0 -0
- /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/assert/1.0.19/equals.js +0 -0
- /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/assert/1.0.19/exists.js +0 -0
- /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/assert/1.0.19/fail.js +0 -0
- /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/assert/1.0.19/false.js +0 -0
- /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/assert/1.0.19/greater.js +0 -0
- /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/assert/1.0.19/greater_or_equal.js +0 -0
- /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/assert/1.0.19/instance_of.js +0 -0
- /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/assert/1.0.19/is_error.js +0 -0
- /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/assert/1.0.19/less.js +0 -0
- /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/assert/1.0.19/less_or_equal.js +0 -0
- /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/assert/1.0.19/match.js +0 -0
- /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/assert/1.0.19/mod.js +0 -0
- /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/assert/1.0.19/not_equals.js +0 -0
- /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/assert/1.0.19/not_instance_of.js +0 -0
- /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/assert/1.0.19/not_match.js +0 -0
- /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/assert/1.0.19/not_strict_equals.js +0 -0
- /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/assert/1.0.19/object_match.js +0 -0
- /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/assert/1.0.19/rejects.js +0 -0
- /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/assert/1.0.19/strict_equals.js +0 -0
- /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/assert/1.0.19/string_includes.js +0 -0
- /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/assert/1.0.19/throws.js +0 -0
- /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/assert/1.0.19/unimplemented.js +0 -0
- /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/assert/1.0.19/unreachable.js +0 -0
- /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/internal/1.0.14/build_message.js +0 -0
- /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/internal/1.0.14/diff.js +0 -0
- /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/internal/1.0.14/diff_str.js +0 -0
- /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/internal/1.0.14/format.js +0 -0
- /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/internal/1.0.14/styles.js +0 -0
- /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/internal/1.0.14/types.js +0 -0
- /package/esm/{testing/npm/src/mod.js → mod.js} +0 -0
- /package/esm/{testing/npm/src/src → src}/journal.js +0 -0
- /package/esm/{testing/npm/src/src → src}/test-api.js +0 -0
- /package/esm/{testing/npm/src/src → src}/use-testing.js +0 -0
|
@@ -1,273 +0,0 @@
|
|
|
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 { DurableCtx } from "./context.ts";
|
|
23
|
-
import { Divergence } from "./divergence.ts";
|
|
24
|
-
import { StaleInputError } from "./errors.ts";
|
|
25
|
-
import { ReplayGuard } from "./replay-guard.ts";
|
|
26
|
-
import { protocolToEffection, serializeError } from "./serialize.ts";
|
|
27
|
-
/** Effection void-ok result, used for no-op teardowns. */
|
|
28
|
-
const VOID_OK = {
|
|
29
|
-
ok: true,
|
|
30
|
-
value: undefined,
|
|
31
|
-
};
|
|
32
|
-
/**
|
|
33
|
-
* Shared replay logic for both createDurableEffect and createDurableOperation.
|
|
34
|
-
*
|
|
35
|
-
* Checks the replay index for a matching entry, runs divergence detection,
|
|
36
|
-
* and runs replay guards. If replay succeeds, resolves the generator
|
|
37
|
-
* synchronously and returns "replayed". Otherwise returns "live" to
|
|
38
|
-
* indicate the caller should execute the effect.
|
|
39
|
-
*/
|
|
40
|
-
function checkReplay(desc, resolve, routine, ctx) {
|
|
41
|
-
const entry = ctx.replayIndex.peekYield(ctx.coroutineId);
|
|
42
|
-
// ── REPLAY PATH ──
|
|
43
|
-
// Use a labeled block so that divergence decisions of type "run-live"
|
|
44
|
-
// can break out to fall through to the live execution path.
|
|
45
|
-
// biome-ignore lint/suspicious/noConfusingLabels: deliberate labeled block for break-out-of-replay pattern
|
|
46
|
-
replay: {
|
|
47
|
-
if (entry) {
|
|
48
|
-
// §6.2: Validate description identity match.
|
|
49
|
-
// Only type and name are identity fields — extra metadata fields
|
|
50
|
-
// (path, URL, marker, etc.) are stored for replay guards to inspect
|
|
51
|
-
// but do not participate in divergence detection.
|
|
52
|
-
// The current desc is passed to the Divergence API as `actual` so
|
|
53
|
-
// guards can compare both sides if needed.
|
|
54
|
-
if (entry.description.type !== desc.type || entry.description.name !== desc.name) {
|
|
55
|
-
// Delegate divergence policy to the Divergence API.
|
|
56
|
-
const cursor = ctx.replayIndex.getCursor(ctx.coroutineId);
|
|
57
|
-
const decision = Divergence.invoke(routine.scope, "decide", [
|
|
58
|
-
{
|
|
59
|
-
kind: "description-mismatch",
|
|
60
|
-
coroutineId: ctx.coroutineId,
|
|
61
|
-
cursor,
|
|
62
|
-
expected: entry.description,
|
|
63
|
-
actual: desc,
|
|
64
|
-
},
|
|
65
|
-
]);
|
|
66
|
-
if (decision.type === "throw") {
|
|
67
|
-
resolve({ ok: false, error: decision.error });
|
|
68
|
-
return { path: "replayed", teardown: (exit) => exit(VOID_OK) };
|
|
69
|
-
}
|
|
70
|
-
// decision.type === "run-live"
|
|
71
|
-
ctx.replayIndex.disableReplay(ctx.coroutineId);
|
|
72
|
-
break replay;
|
|
73
|
-
}
|
|
74
|
-
// Description matches — now check replay guards before replaying.
|
|
75
|
-
// ── REPLAY GUARD: Decide phase ──
|
|
76
|
-
const yieldEvent = {
|
|
77
|
-
type: "yield",
|
|
78
|
-
coroutineId: ctx.coroutineId,
|
|
79
|
-
description: entry.description,
|
|
80
|
-
result: entry.result,
|
|
81
|
-
};
|
|
82
|
-
const outcome = ReplayGuard.invoke(routine.scope, "decide", [yieldEvent]);
|
|
83
|
-
if (outcome.outcome === "error") {
|
|
84
|
-
ctx.replayIndex.consumeYield(ctx.coroutineId);
|
|
85
|
-
const error = outcome.error ??
|
|
86
|
-
new StaleInputError(`Stale input detected for ${desc.type}("${desc.name}")`, {
|
|
87
|
-
coroutineId: ctx.coroutineId,
|
|
88
|
-
description: desc,
|
|
89
|
-
});
|
|
90
|
-
resolve({ ok: false, error });
|
|
91
|
-
return { path: "replayed", teardown: (exit) => exit(VOID_OK) };
|
|
92
|
-
}
|
|
93
|
-
// All guards approved — consume the entry and advance cursor
|
|
94
|
-
ctx.replayIndex.consumeYield(ctx.coroutineId);
|
|
95
|
-
// Feed stored result synchronously
|
|
96
|
-
resolve(protocolToEffection(entry.result));
|
|
97
|
-
return { path: "replayed", teardown: (exit) => exit(VOID_OK) };
|
|
98
|
-
}
|
|
99
|
-
// No replay entry. Check for continue-past-close divergence (§6.3).
|
|
100
|
-
if (ctx.replayIndex.hasClose(ctx.coroutineId)) {
|
|
101
|
-
const yieldCount = ctx.replayIndex.yieldCount(ctx.coroutineId);
|
|
102
|
-
const decision = Divergence.invoke(routine.scope, "decide", [
|
|
103
|
-
{
|
|
104
|
-
kind: "continue-past-close",
|
|
105
|
-
coroutineId: ctx.coroutineId,
|
|
106
|
-
yieldCount,
|
|
107
|
-
},
|
|
108
|
-
]);
|
|
109
|
-
if (decision.type === "throw") {
|
|
110
|
-
resolve({ ok: false, error: decision.error });
|
|
111
|
-
return { path: "replayed", teardown: (exit) => exit(VOID_OK) };
|
|
112
|
-
}
|
|
113
|
-
// decision.type === "run-live"
|
|
114
|
-
ctx.replayIndex.disableReplay(ctx.coroutineId);
|
|
115
|
-
break replay;
|
|
116
|
-
}
|
|
117
|
-
} // end replay block
|
|
118
|
-
return { path: "live" };
|
|
119
|
-
}
|
|
120
|
-
// ---------------------------------------------------------------------------
|
|
121
|
-
// createDurableEffect — callback-based (Executor pattern)
|
|
122
|
-
// ---------------------------------------------------------------------------
|
|
123
|
-
/**
|
|
124
|
-
* Creates a DurableEffect using a callback-based executor.
|
|
125
|
-
*
|
|
126
|
-
* Use this for timer-like and callback-based APIs (durableSleep, durableAction)
|
|
127
|
-
* where the resolve/reject/teardown pattern is natural.
|
|
128
|
-
*
|
|
129
|
-
* For Operation-based effects, prefer createDurableOperation.
|
|
130
|
-
*
|
|
131
|
-
* @param desc Structured description for the journal and divergence detection
|
|
132
|
-
* @param execute Called only during live execution (skipped during replay)
|
|
133
|
-
*/
|
|
134
|
-
export function createDurableEffect(desc, execute) {
|
|
135
|
-
return {
|
|
136
|
-
description: `${desc.type}(${desc.name})`,
|
|
137
|
-
effectDescription: desc,
|
|
138
|
-
enter(resolve, routine) {
|
|
139
|
-
const ctx = routine.scope.expect(DurableCtx);
|
|
140
|
-
const replay = checkReplay(desc, resolve, routine, ctx);
|
|
141
|
-
if (replay.path === "replayed")
|
|
142
|
-
return replay.teardown;
|
|
143
|
-
// ── LIVE PATH ──
|
|
144
|
-
let settled = false;
|
|
145
|
-
let tornDown = false;
|
|
146
|
-
let teardown = () => { };
|
|
147
|
-
/** Persist a Yield event then resume the generator. */
|
|
148
|
-
function persistAndResolve(result) {
|
|
149
|
-
if (settled)
|
|
150
|
-
return;
|
|
151
|
-
settled = true;
|
|
152
|
-
const event = {
|
|
153
|
-
type: "yield",
|
|
154
|
-
coroutineId: ctx.coroutineId,
|
|
155
|
-
description: desc,
|
|
156
|
-
result,
|
|
157
|
-
};
|
|
158
|
-
// Uses scope.run() to call the Operation-returning stream.append()
|
|
159
|
-
// from inside the callback-based enter(). The append runs as a
|
|
160
|
-
// structured operation in the routine's scope — if the scope tears
|
|
161
|
-
// down, the append is cancelled.
|
|
162
|
-
routine.scope.run(function* () {
|
|
163
|
-
try {
|
|
164
|
-
yield* ctx.stream.append(event);
|
|
165
|
-
resolve(protocolToEffection(result));
|
|
166
|
-
}
|
|
167
|
-
catch (err) {
|
|
168
|
-
resolve({
|
|
169
|
-
ok: false,
|
|
170
|
-
error: err instanceof Error ? err : new Error(String(err)),
|
|
171
|
-
});
|
|
172
|
-
}
|
|
173
|
-
});
|
|
174
|
-
}
|
|
175
|
-
// Guard against synchronous throws from the executor.
|
|
176
|
-
try {
|
|
177
|
-
teardown = execute((result) => persistAndResolve(result), (error) => {
|
|
178
|
-
persistAndResolve({
|
|
179
|
-
status: "err",
|
|
180
|
-
error: serializeError(error),
|
|
181
|
-
});
|
|
182
|
-
});
|
|
183
|
-
}
|
|
184
|
-
catch (e) {
|
|
185
|
-
const error = e instanceof Error ? e : new Error(String(e));
|
|
186
|
-
persistAndResolve({
|
|
187
|
-
status: "err",
|
|
188
|
-
error: serializeError(error),
|
|
189
|
-
});
|
|
190
|
-
return (exit) => exit(VOID_OK);
|
|
191
|
-
}
|
|
192
|
-
// Return teardown that Effection calls during scope destruction
|
|
193
|
-
return (exit) => {
|
|
194
|
-
if (tornDown) {
|
|
195
|
-
exit(VOID_OK);
|
|
196
|
-
return;
|
|
197
|
-
}
|
|
198
|
-
tornDown = true;
|
|
199
|
-
settled = true;
|
|
200
|
-
try {
|
|
201
|
-
teardown();
|
|
202
|
-
exit(VOID_OK);
|
|
203
|
-
}
|
|
204
|
-
catch (e) {
|
|
205
|
-
const error = e instanceof Error ? e : new Error(String(e));
|
|
206
|
-
exit({ ok: false, error });
|
|
207
|
-
}
|
|
208
|
-
};
|
|
209
|
-
},
|
|
210
|
-
};
|
|
211
|
-
}
|
|
212
|
-
// ---------------------------------------------------------------------------
|
|
213
|
-
// createDurableOperation — Operation-based (structured concurrency)
|
|
214
|
-
// ---------------------------------------------------------------------------
|
|
215
|
-
/**
|
|
216
|
-
* Creates a DurableEffect from an Operation-returning function.
|
|
217
|
-
*
|
|
218
|
-
* The live path runs entirely as a generator inside scope.run():
|
|
219
|
-
* execute the Operation, capture the result, persist the Yield event,
|
|
220
|
-
* then resolve the generator. No callbacks, no .then(), full structured
|
|
221
|
-
* concurrency — if the scope tears down, the operation is cancelled.
|
|
222
|
-
*
|
|
223
|
-
* Use this for durableCall and any effect where the work is expressed
|
|
224
|
-
* as an Operation (or can be wrapped as one via Effection's call()).
|
|
225
|
-
*
|
|
226
|
-
* @param desc Structured description for the journal and divergence detection
|
|
227
|
-
* @param execute Returns an Operation to run during live execution
|
|
228
|
-
*/
|
|
229
|
-
export function createDurableOperation(desc, execute) {
|
|
230
|
-
return {
|
|
231
|
-
description: `${desc.type}(${desc.name})`,
|
|
232
|
-
effectDescription: desc,
|
|
233
|
-
enter(resolve, routine) {
|
|
234
|
-
const ctx = routine.scope.expect(DurableCtx);
|
|
235
|
-
const replay = checkReplay(desc, resolve, routine, ctx);
|
|
236
|
-
if (replay.path === "replayed")
|
|
237
|
-
return replay.teardown;
|
|
238
|
-
// ── LIVE PATH ──
|
|
239
|
-
// Run the entire execute → capture → persist → resolve sequence
|
|
240
|
-
// as a structured operation in the routine's scope.
|
|
241
|
-
routine.scope.run(function* () {
|
|
242
|
-
let result;
|
|
243
|
-
try {
|
|
244
|
-
const value = yield* execute();
|
|
245
|
-
result = { status: "ok", value: value };
|
|
246
|
-
}
|
|
247
|
-
catch (e) {
|
|
248
|
-
const error = e instanceof Error ? e : new Error(String(e));
|
|
249
|
-
result = { status: "err", error: serializeError(error) };
|
|
250
|
-
}
|
|
251
|
-
const event = {
|
|
252
|
-
type: "yield",
|
|
253
|
-
coroutineId: ctx.coroutineId,
|
|
254
|
-
description: desc,
|
|
255
|
-
result,
|
|
256
|
-
};
|
|
257
|
-
try {
|
|
258
|
-
yield* ctx.stream.append(event);
|
|
259
|
-
resolve(protocolToEffection(result));
|
|
260
|
-
}
|
|
261
|
-
catch (err) {
|
|
262
|
-
resolve({
|
|
263
|
-
ok: false,
|
|
264
|
-
error: err instanceof Error ? err : new Error(String(err)),
|
|
265
|
-
});
|
|
266
|
-
}
|
|
267
|
-
});
|
|
268
|
-
// No teardown needed — scope.run() ties the operation's lifecycle
|
|
269
|
-
// to the routine's scope. Cancellation is handled by Effection.
|
|
270
|
-
return (exit) => exit(VOID_OK);
|
|
271
|
-
},
|
|
272
|
-
};
|
|
273
|
-
}
|
|
@@ -1,80 +0,0 @@
|
|
|
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
|
-
/** Effection void-ok result, used for no-op teardowns. */
|
|
25
|
-
const VOID_OK = {
|
|
26
|
-
ok: true,
|
|
27
|
-
value: undefined,
|
|
28
|
-
};
|
|
29
|
-
/**
|
|
30
|
-
* Create a DurableEffect that runs an Operation transparently.
|
|
31
|
-
*
|
|
32
|
-
* - No journal write (no Yield event appended)
|
|
33
|
-
* - No replay index consumption (cursor not advanced)
|
|
34
|
-
* - The Operation runs via scope.run() with full structured concurrency
|
|
35
|
-
* - Cancellation of the scope cancels the inner Operation
|
|
36
|
-
* - On replay, the Operation re-runs (not cached)
|
|
37
|
-
*
|
|
38
|
-
* The effect is invisible to the durable execution protocol — it exists
|
|
39
|
-
* solely to satisfy the Workflow<T> type constraint.
|
|
40
|
-
*/
|
|
41
|
-
function createEphemeralEffect(operation) {
|
|
42
|
-
return {
|
|
43
|
-
description: "ephemeral",
|
|
44
|
-
effectDescription: { type: "ephemeral", name: "ephemeral" },
|
|
45
|
-
enter(resolve, routine) {
|
|
46
|
-
// Run the operation in the routine's scope — full structured
|
|
47
|
-
// concurrency, proper cancellation. No journal interaction.
|
|
48
|
-
routine.scope.run(function* () {
|
|
49
|
-
try {
|
|
50
|
-
const value = yield* operation;
|
|
51
|
-
resolve({ ok: true, value });
|
|
52
|
-
}
|
|
53
|
-
catch (e) {
|
|
54
|
-
const error = e instanceof Error ? e : new Error(String(e));
|
|
55
|
-
resolve({ ok: false, error });
|
|
56
|
-
}
|
|
57
|
-
});
|
|
58
|
-
// No teardown needed — scope.run() ties the operation's lifecycle
|
|
59
|
-
// to the routine's scope.
|
|
60
|
-
return (exit) => exit(VOID_OK);
|
|
61
|
-
},
|
|
62
|
-
};
|
|
63
|
-
}
|
|
64
|
-
/**
|
|
65
|
-
* Wrap a non-durable Operation so it can be used inside a Workflow.
|
|
66
|
-
*
|
|
67
|
-
* The operation is transparent to the durable execution protocol:
|
|
68
|
-
* - No Yield event is written to the journal
|
|
69
|
-
* - On replay, the operation re-runs (it is not cached)
|
|
70
|
-
* - Cancellation flows through normally via structured concurrency
|
|
71
|
-
*
|
|
72
|
-
* Use this when you need to run infrastructure Operations (or intentionally
|
|
73
|
-
* non-durable work) inside a Workflow where only DurableEffects are allowed.
|
|
74
|
-
*
|
|
75
|
-
* @param operation The Operation to wrap
|
|
76
|
-
* @returns A Workflow that yields the operation's result
|
|
77
|
-
*/
|
|
78
|
-
export function* ephemeral(operation) {
|
|
79
|
-
return (yield createEphemeralEffect(operation));
|
|
80
|
-
}
|
|
@@ -1,92 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Error types for the durable execution protocol.
|
|
3
|
-
*/
|
|
4
|
-
/**
|
|
5
|
-
* Raised when the replay index entry at the current cursor position
|
|
6
|
-
* does not match the effect yielded by the generator. See spec §6.2.
|
|
7
|
-
*
|
|
8
|
-
* A DivergenceError is NOT recoverable. The workflow cannot continue
|
|
9
|
-
* because the generator's execution path has diverged from the recorded
|
|
10
|
-
* history.
|
|
11
|
-
*/
|
|
12
|
-
export class DivergenceError extends Error {
|
|
13
|
-
name = "DivergenceError";
|
|
14
|
-
coroutineId;
|
|
15
|
-
/** Cursor position within the coroutine where divergence was detected. */
|
|
16
|
-
position;
|
|
17
|
-
/** The description from the journal (what was expected). */
|
|
18
|
-
expected;
|
|
19
|
-
/** The description from the generator (what was actually yielded). */
|
|
20
|
-
actual;
|
|
21
|
-
constructor(coroutineId, position, expected, actual, message) {
|
|
22
|
-
super(message ??
|
|
23
|
-
`Divergence at ${coroutineId}[${position}]: ` +
|
|
24
|
-
`expected ${expected.type}("${expected.name}"), ` +
|
|
25
|
-
`got ${actual.type}("${actual.name}")`);
|
|
26
|
-
this.coroutineId = coroutineId;
|
|
27
|
-
this.position = position;
|
|
28
|
-
this.expected = expected;
|
|
29
|
-
this.actual = actual;
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
/**
|
|
33
|
-
* Raised when the generator finishes (returns) while the replay index
|
|
34
|
-
* still has unconsumed entries for this coroutine. See spec §6.3.
|
|
35
|
-
*/
|
|
36
|
-
export class EarlyReturnDivergenceError extends Error {
|
|
37
|
-
name = "EarlyReturnDivergenceError";
|
|
38
|
-
coroutineId;
|
|
39
|
-
consumedCount;
|
|
40
|
-
totalCount;
|
|
41
|
-
constructor(coroutineId, consumedCount, totalCount) {
|
|
42
|
-
super(`Divergence: generator ${coroutineId} returned after ${consumedCount} yields, ` +
|
|
43
|
-
`but journal has ${totalCount} yield entries`);
|
|
44
|
-
this.coroutineId = coroutineId;
|
|
45
|
-
this.consumedCount = consumedCount;
|
|
46
|
-
this.totalCount = totalCount;
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
/**
|
|
50
|
-
* Raised when the journal has a Close event for a coroutine but the
|
|
51
|
-
* generator has not finished after consuming all recorded yields.
|
|
52
|
-
* See spec §6.3.
|
|
53
|
-
*/
|
|
54
|
-
export class ContinuePastCloseDivergenceError extends Error {
|
|
55
|
-
name = "ContinuePastCloseDivergenceError";
|
|
56
|
-
coroutineId;
|
|
57
|
-
yieldCount;
|
|
58
|
-
constructor(coroutineId, yieldCount) {
|
|
59
|
-
super(`Divergence: journal shows ${coroutineId} closed after ${yieldCount} yields, but generator continues to yield effects`);
|
|
60
|
-
this.coroutineId = coroutineId;
|
|
61
|
-
this.yieldCount = yieldCount;
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
/**
|
|
65
|
-
* Raised by a replay guard when a journal entry's recorded result is
|
|
66
|
-
* stale (e.g., the source file has changed since the effect was
|
|
67
|
-
* originally executed).
|
|
68
|
-
*
|
|
69
|
-
* Guards detect staleness by comparing current state against data stored
|
|
70
|
-
* in the effect description (input fields like file path) and result
|
|
71
|
-
* value (output fields like content hash).
|
|
72
|
-
*
|
|
73
|
-
* StaleInputError is NOT a divergence — the effect identity matches,
|
|
74
|
-
* but the external world has changed. The correct response depends on
|
|
75
|
-
* application policy: re-run from scratch, accept stale results, or
|
|
76
|
-
* (in future versions) re-execute the effect and continue.
|
|
77
|
-
*
|
|
78
|
-
* See replay-guard-spec.md §4.4.
|
|
79
|
-
*/
|
|
80
|
-
export class StaleInputError extends Error {
|
|
81
|
-
name = "StaleInputError";
|
|
82
|
-
/** The Yield event that was detected as stale. */
|
|
83
|
-
event;
|
|
84
|
-
constructor(
|
|
85
|
-
/** Human-readable description of what changed. */
|
|
86
|
-
message,
|
|
87
|
-
/** The Yield event that was detected as stale. */
|
|
88
|
-
event) {
|
|
89
|
-
super(message);
|
|
90
|
-
this.event = event;
|
|
91
|
-
}
|
|
92
|
-
}
|
|
@@ -1,207 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* HTTP-backed DurableStream implementation using the Durable Streams protocol.
|
|
3
|
-
*
|
|
4
|
-
* Uses raw fetch() for appends (not IdempotentProducer) because durable
|
|
5
|
-
* execution requires synchronous acknowledgment on every write
|
|
6
|
-
* (persist-before-resume). See DEC-026.
|
|
7
|
-
*
|
|
8
|
-
* Concurrent appends are serialized via a Queue + spawned worker so that
|
|
9
|
-
* the server always receives them in sequence order. The worker lives
|
|
10
|
-
* inside a resource scope and is cancelled when the stream is no longer
|
|
11
|
-
* in use. See DEC-033 (supersedes DEC-027's Promise chain approach).
|
|
12
|
-
*
|
|
13
|
-
* The stream is created as an Effection resource via useHttpDurableStream().
|
|
14
|
-
*/
|
|
15
|
-
import { stream, PRODUCER_EPOCH_HEADER, PRODUCER_EXPECTED_SEQ_HEADER, PRODUCER_ID_HEADER, PRODUCER_RECEIVED_SEQ_HEADER, PRODUCER_SEQ_HEADER, STREAM_OFFSET_HEADER, SequenceGapError, StaleEpochError, } from "@durable-streams/client";
|
|
16
|
-
import { call, createQueue, resource, spawn, withResolvers } from "effection";
|
|
17
|
-
/**
|
|
18
|
-
* Create an HTTP-backed DurableStream as an Effection resource.
|
|
19
|
-
*
|
|
20
|
-
* The resource:
|
|
21
|
-
* 1. Creates the stream on the server (idempotent PUT)
|
|
22
|
-
* 2. Spawns a serial worker that processes appends in FIFO order
|
|
23
|
-
* 3. Returns a DurableStream handle with Operation-native readAll/append
|
|
24
|
-
*
|
|
25
|
-
* The worker is cancelled when the resource scope is torn down.
|
|
26
|
-
*
|
|
27
|
-
* Usage:
|
|
28
|
-
* yield* useHttpDurableStream({ baseUrl, streamId, producerId, epoch })
|
|
29
|
-
*/
|
|
30
|
-
export function useHttpDurableStream(opts) {
|
|
31
|
-
return resource(function* (provide) {
|
|
32
|
-
const baseUrl = new URL(opts.baseUrl);
|
|
33
|
-
if (!baseUrl.pathname.endsWith("/")) {
|
|
34
|
-
baseUrl.pathname = `${baseUrl.pathname}/`;
|
|
35
|
-
}
|
|
36
|
-
const streamUrl = new URL(encodeURIComponent(opts.streamId), baseUrl).toString();
|
|
37
|
-
const producerId = opts.producerId;
|
|
38
|
-
const epoch = opts.epoch;
|
|
39
|
-
const fetchFn = opts.fetch ?? globalThis.fetch.bind(globalThis);
|
|
40
|
-
// ── One-time setup: create the stream on the server ──
|
|
41
|
-
yield* call(async () => {
|
|
42
|
-
const res = await fetchFn(streamUrl, {
|
|
43
|
-
method: "PUT",
|
|
44
|
-
headers: { "Content-Type": "application/json" },
|
|
45
|
-
});
|
|
46
|
-
await res.text(); // consume body to free connection
|
|
47
|
-
if (res.status !== 201 && res.status !== 200) {
|
|
48
|
-
throw new Error(`Failed to create stream: HTTP ${res.status}`);
|
|
49
|
-
}
|
|
50
|
-
});
|
|
51
|
-
// ── Mutable state owned by the resource scope ──
|
|
52
|
-
let nextSeq = 0;
|
|
53
|
-
let fatalError;
|
|
54
|
-
let lastOffset;
|
|
55
|
-
// ── Append worker queue ──
|
|
56
|
-
const queue = createQueue();
|
|
57
|
-
// ── Spawn the serial append worker ──
|
|
58
|
-
// Processes one append at a time in FIFO order. Each HTTP POST
|
|
59
|
-
// completes before the next one starts, guaranteeing server-side
|
|
60
|
-
// sequence ordering. Fatal errors (stale epoch, network failure)
|
|
61
|
-
// are propagated to the specific caller and stored for fail-fast.
|
|
62
|
-
yield* spawn(function* () {
|
|
63
|
-
let item = yield* queue.next();
|
|
64
|
-
while (!item.done) {
|
|
65
|
-
const { event, seq, resolve, reject } = item.value;
|
|
66
|
-
try {
|
|
67
|
-
yield* call(() => doAppend(event, seq));
|
|
68
|
-
resolve(undefined);
|
|
69
|
-
}
|
|
70
|
-
catch (err) {
|
|
71
|
-
reject(err instanceof Error ? err : new Error(String(err)));
|
|
72
|
-
}
|
|
73
|
-
item = yield* queue.next();
|
|
74
|
-
}
|
|
75
|
-
});
|
|
76
|
-
/**
|
|
77
|
-
* Execute a single HTTP append with the given event and sequence number.
|
|
78
|
-
*
|
|
79
|
-
* Any uncertain write outcome (network error, unexpected HTTP status,
|
|
80
|
-
* sequence gap) is treated as fatal — `fatalError` is set so all future
|
|
81
|
-
* appends fail-fast.
|
|
82
|
-
*/
|
|
83
|
-
async function doAppend(event, seq) {
|
|
84
|
-
// Double-check fatal error (may have been set by a preceding append)
|
|
85
|
-
if (fatalError) {
|
|
86
|
-
throw fatalError;
|
|
87
|
-
}
|
|
88
|
-
let res;
|
|
89
|
-
try {
|
|
90
|
-
res = await fetchFn(streamUrl, {
|
|
91
|
-
method: "POST",
|
|
92
|
-
headers: {
|
|
93
|
-
"Content-Type": "application/json",
|
|
94
|
-
[PRODUCER_ID_HEADER]: producerId,
|
|
95
|
-
[PRODUCER_EPOCH_HEADER]: String(epoch),
|
|
96
|
-
[PRODUCER_SEQ_HEADER]: String(seq),
|
|
97
|
-
},
|
|
98
|
-
body: JSON.stringify(event),
|
|
99
|
-
});
|
|
100
|
-
}
|
|
101
|
-
catch (err) {
|
|
102
|
-
// Network failure — fatal, sequence state is now uncertain
|
|
103
|
-
const error = err instanceof Error ? err : new Error(String(err));
|
|
104
|
-
fatalError = error;
|
|
105
|
-
throw error;
|
|
106
|
-
}
|
|
107
|
-
// Always consume the body to free the connection
|
|
108
|
-
await res.text();
|
|
109
|
-
switch (res.status) {
|
|
110
|
-
case 200: {
|
|
111
|
-
// Success — capture offset
|
|
112
|
-
const offset = res.headers.get(STREAM_OFFSET_HEADER);
|
|
113
|
-
if (offset) {
|
|
114
|
-
lastOffset = offset;
|
|
115
|
-
}
|
|
116
|
-
return;
|
|
117
|
-
}
|
|
118
|
-
case 204: {
|
|
119
|
-
// Duplicate (idempotent success) — capture offset if present
|
|
120
|
-
const offset = res.headers.get(STREAM_OFFSET_HEADER);
|
|
121
|
-
if (offset) {
|
|
122
|
-
lastOffset = offset;
|
|
123
|
-
}
|
|
124
|
-
return;
|
|
125
|
-
}
|
|
126
|
-
case 403: {
|
|
127
|
-
// Stale epoch — fatal error
|
|
128
|
-
const currentEpoch = Number(res.headers.get(PRODUCER_EPOCH_HEADER) ?? 0);
|
|
129
|
-
const error = new StaleEpochError(currentEpoch);
|
|
130
|
-
fatalError = error;
|
|
131
|
-
throw error;
|
|
132
|
-
}
|
|
133
|
-
case 409: {
|
|
134
|
-
// Sequence gap — fatal (should never happen due to serialization,
|
|
135
|
-
// but if it does, sequence state is irrecoverably desynchronized)
|
|
136
|
-
const expected = Number(res.headers.get(PRODUCER_EXPECTED_SEQ_HEADER) ?? 0);
|
|
137
|
-
const received = Number(res.headers.get(PRODUCER_RECEIVED_SEQ_HEADER) ?? 0);
|
|
138
|
-
const error = new SequenceGapError(expected, received);
|
|
139
|
-
fatalError = error;
|
|
140
|
-
throw error;
|
|
141
|
-
}
|
|
142
|
-
default: {
|
|
143
|
-
// Unexpected status — fatal, write outcome is uncertain.
|
|
144
|
-
// TODO: Transient errors (500, 503) could be retried with the
|
|
145
|
-
// same seq in a future version. See DEC-026 rationale.
|
|
146
|
-
const error = new Error(`Unexpected append response: HTTP ${res.status}`);
|
|
147
|
-
fatalError = error;
|
|
148
|
-
throw error;
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
// ── Provide the DurableStream handle ──
|
|
153
|
-
yield* provide({
|
|
154
|
-
get lastOffset() {
|
|
155
|
-
return lastOffset;
|
|
156
|
-
},
|
|
157
|
-
/**
|
|
158
|
-
* Read all events in the stream, in append order.
|
|
159
|
-
*
|
|
160
|
-
* Uses the stream() function from @durable-streams/client with
|
|
161
|
-
* offset="-1" (start of stream) and live=false (no tailing).
|
|
162
|
-
*/
|
|
163
|
-
*readAll() {
|
|
164
|
-
return yield* call(async () => {
|
|
165
|
-
const res = await stream({
|
|
166
|
-
url: streamUrl,
|
|
167
|
-
offset: "-1",
|
|
168
|
-
live: false,
|
|
169
|
-
fetch: fetchFn,
|
|
170
|
-
});
|
|
171
|
-
const events = (await res.json());
|
|
172
|
-
// Track offset from read (DEC-029)
|
|
173
|
-
if (res.offset) {
|
|
174
|
-
lastOffset = res.offset;
|
|
175
|
-
}
|
|
176
|
-
return events;
|
|
177
|
-
});
|
|
178
|
-
},
|
|
179
|
-
/**
|
|
180
|
-
* Append an event to the stream.
|
|
181
|
-
*
|
|
182
|
-
* Sequence numbers are assigned synchronously when the generator is
|
|
183
|
-
* started. The actual HTTP call is dispatched to the serial worker
|
|
184
|
-
* via the queue. The caller suspends until the worker completes the
|
|
185
|
-
* POST and signals via withResolvers.
|
|
186
|
-
*
|
|
187
|
-
* Fatal errors (stale epoch, network failure) are stored and cause
|
|
188
|
-
* all future appends to fail-fast without enqueuing.
|
|
189
|
-
*/
|
|
190
|
-
*append(event) {
|
|
191
|
-
// Fail-fast if a fatal error has been set
|
|
192
|
-
if (fatalError) {
|
|
193
|
-
throw fatalError;
|
|
194
|
-
}
|
|
195
|
-
// Assign seq synchronously — ordering is locked in before any
|
|
196
|
-
// async work, even when multiple coroutines call append concurrently
|
|
197
|
-
const seq = nextSeq++;
|
|
198
|
-
// Create a resolver so we can wait for this specific append to complete
|
|
199
|
-
const { operation, resolve, reject } = withResolvers();
|
|
200
|
-
// Enqueue the request — the worker will process it in FIFO order
|
|
201
|
-
queue.add({ event, seq, resolve, reject });
|
|
202
|
-
// Suspend until the worker finishes the HTTP POST for this item
|
|
203
|
-
yield* operation;
|
|
204
|
-
},
|
|
205
|
-
});
|
|
206
|
-
});
|
|
207
|
-
}
|
|
@@ -1,34 +0,0 @@
|
|
|
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
|
-
// ReplayIndex
|
|
9
|
-
export { ReplayIndex } from "./replay-index.ts";
|
|
10
|
-
export { InMemoryStream } from "./stream.ts";
|
|
11
|
-
// HTTP-backed stream adapter
|
|
12
|
-
export { useHttpDurableStream } from "./http-stream.ts";
|
|
13
|
-
// Errors
|
|
14
|
-
export { ContinuePastCloseDivergenceError, DivergenceError, EarlyReturnDivergenceError, StaleInputError, } from "./errors.ts";
|
|
15
|
-
// Divergence API — pluggable policy for replay mismatches (DEC-031)
|
|
16
|
-
export { Divergence } from "./divergence.ts";
|
|
17
|
-
// ReplayGuard API — pluggable validation for replay staleness detection
|
|
18
|
-
export { ReplayGuard } from "./replay-guard.ts";
|
|
19
|
-
// Context
|
|
20
|
-
export { DurableCtx } from "./context.ts";
|
|
21
|
-
// Serialization utilities
|
|
22
|
-
export { deserializeError, effectionToProtocol, protocolToEffection, serializeError, } from "./serialize.ts";
|
|
23
|
-
// Core effect factories
|
|
24
|
-
export { createDurableEffect, createDurableOperation } from "./effect.ts";
|
|
25
|
-
// Workflow-enabled effects
|
|
26
|
-
export { durableAction, durableCall, durableSleep, versionCheck } from "./operations.ts";
|
|
27
|
-
// Structured concurrency combinators
|
|
28
|
-
export { durableAll, durableRace, durableSpawn } from "./combinators.ts";
|
|
29
|
-
// Durable iteration
|
|
30
|
-
export { durableEach } from "./each.ts";
|
|
31
|
-
// Ephemeral — explicit escape hatch for non-durable Operations in Workflows
|
|
32
|
-
export { ephemeral } from "./ephemeral.ts";
|
|
33
|
-
// Entry point
|
|
34
|
-
export { durableRun } from "./run.ts";
|