@executablemd/testing 0.3.1 → 0.4.1

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.
Files changed (94) hide show
  1. package/esm/{testing/npm/src/src → src}/assertions.js +15 -24
  2. package/esm/{testing/npm/src/src → src}/handlers.js +140 -10
  3. package/esm/{testing/npm/src/src → src}/vocabulary.js +3 -0
  4. package/package.json +3 -3
  5. package/types/src/assertions.d.ts +11 -1
  6. package/types/src/handlers.d.ts +1 -0
  7. package/esm/core/mod.js +0 -55
  8. package/esm/core/src/api.js +0 -31
  9. package/esm/core/src/collect.js +0 -24
  10. package/esm/core/src/component-api.js +0 -47
  11. package/esm/core/src/content-context.js +0 -23
  12. package/esm/core/src/deno-compiler.js +0 -51
  13. package/esm/core/src/errors.js +0 -13
  14. package/esm/core/src/eval-context.js +0 -13
  15. package/esm/core/src/eval-handler.js +0 -79
  16. package/esm/core/src/eval-interpolate.js +0 -63
  17. package/esm/core/src/eval-transform.js +0 -378
  18. package/esm/core/src/execute.js +0 -378
  19. package/esm/core/src/expand.js +0 -988
  20. package/esm/core/src/frontmatter.js +0 -98
  21. package/esm/core/src/heal.js +0 -29
  22. package/esm/core/src/interpolate.js +0 -39
  23. package/esm/core/src/modifiers/daemon.js +0 -60
  24. package/esm/core/src/modifiers/persist.js +0 -35
  25. package/esm/core/src/modifiers/timeout.js +0 -55
  26. package/esm/core/src/modifiers.js +0 -113
  27. package/esm/core/src/output/normalize.js +0 -33
  28. package/esm/core/src/output/terminal.js +0 -20
  29. package/esm/core/src/render.js +0 -30
  30. package/esm/core/src/replay-stream.js +0 -58
  31. package/esm/core/src/sample-api.js +0 -45
  32. package/esm/core/src/scanner.js +0 -632
  33. package/esm/core/src/temp-file-compiler.js +0 -68
  34. package/esm/core/src/types.js +0 -7
  35. package/esm/core/src/validate.js +0 -82
  36. package/esm/durable-streams/combinators.js +0 -227
  37. package/esm/durable-streams/context.js +0 -13
  38. package/esm/durable-streams/divergence.js +0 -56
  39. package/esm/durable-streams/each.js +0 -193
  40. package/esm/durable-streams/effect.js +0 -273
  41. package/esm/durable-streams/ephemeral.js +0 -80
  42. package/esm/durable-streams/errors.js +0 -92
  43. package/esm/durable-streams/http-stream.js +0 -207
  44. package/esm/durable-streams/mod.js +0 -34
  45. package/esm/durable-streams/operations.js +0 -88
  46. package/esm/durable-streams/replay-guard.js +0 -87
  47. package/esm/durable-streams/replay-index.js +0 -132
  48. package/esm/durable-streams/run.js +0 -137
  49. package/esm/durable-streams/serialize.js +0 -65
  50. package/esm/durable-streams/stream.js +0 -56
  51. package/esm/durable-streams/types.js +0 -10
  52. package/esm/runtime/apis.js +0 -237
  53. package/esm/runtime/find-free-port.js +0 -33
  54. package/esm/runtime/mod.js +0 -20
  55. /package/esm/{testing/npm/src/_dnt.polyfills.js → _dnt.polyfills.js} +0 -0
  56. /package/esm/{testing/npm/src/_dnt.shims.js → _dnt.shims.js} +0 -0
  57. /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/assert/1.0.19/almost_equals.js +0 -0
  58. /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/assert/1.0.19/array_includes.js +0 -0
  59. /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/assert/1.0.19/assert.js +0 -0
  60. /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/assert/1.0.19/assertion_error.js +0 -0
  61. /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/assert/1.0.19/equal.js +0 -0
  62. /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/assert/1.0.19/equals.js +0 -0
  63. /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/assert/1.0.19/exists.js +0 -0
  64. /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/assert/1.0.19/fail.js +0 -0
  65. /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/assert/1.0.19/false.js +0 -0
  66. /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/assert/1.0.19/greater.js +0 -0
  67. /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/assert/1.0.19/greater_or_equal.js +0 -0
  68. /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/assert/1.0.19/instance_of.js +0 -0
  69. /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/assert/1.0.19/is_error.js +0 -0
  70. /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/assert/1.0.19/less.js +0 -0
  71. /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/assert/1.0.19/less_or_equal.js +0 -0
  72. /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/assert/1.0.19/match.js +0 -0
  73. /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/assert/1.0.19/mod.js +0 -0
  74. /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/assert/1.0.19/not_equals.js +0 -0
  75. /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/assert/1.0.19/not_instance_of.js +0 -0
  76. /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/assert/1.0.19/not_match.js +0 -0
  77. /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/assert/1.0.19/not_strict_equals.js +0 -0
  78. /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/assert/1.0.19/object_match.js +0 -0
  79. /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/assert/1.0.19/rejects.js +0 -0
  80. /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/assert/1.0.19/strict_equals.js +0 -0
  81. /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/assert/1.0.19/string_includes.js +0 -0
  82. /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/assert/1.0.19/throws.js +0 -0
  83. /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/assert/1.0.19/unimplemented.js +0 -0
  84. /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/assert/1.0.19/unreachable.js +0 -0
  85. /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/internal/1.0.14/build_message.js +0 -0
  86. /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/internal/1.0.14/diff.js +0 -0
  87. /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/internal/1.0.14/diff_str.js +0 -0
  88. /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/internal/1.0.14/format.js +0 -0
  89. /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/internal/1.0.14/styles.js +0 -0
  90. /package/esm/{testing/npm/src/deps → deps}/jsr.io/@std/internal/1.0.14/types.js +0 -0
  91. /package/esm/{testing/npm/src/mod.js → mod.js} +0 -0
  92. /package/esm/{testing/npm/src/src → src}/journal.js +0 -0
  93. /package/esm/{testing/npm/src/src → src}/test-api.js +0 -0
  94. /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";