@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.
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,7 +0,0 @@
1
- /**
2
- * Core types for Executable MDX.
3
- *
4
- * Defines the Segment intermediate representation, component model types,
5
- * modifier system types, and shared interfaces.
6
- */
7
- export {};
@@ -1,82 +0,0 @@
1
- /**
2
- * Prop validation for component invocations (spec §5.5).
3
- *
4
- * Components only accept props declared in their `inputs` frontmatter.
5
- * Runtime operation — deterministic, no journal entry.
6
- */
7
- /**
8
- * Error thrown when prop validation fails.
9
- */
10
- export class PropValidationError extends Error {
11
- componentName;
12
- errors;
13
- constructor(componentName, errors) {
14
- super(`Prop validation failed for <${componentName} />:\n - ${errors.join("\n - ")}`);
15
- this.name = "PropValidationError";
16
- this.componentName = componentName;
17
- this.errors = errors;
18
- }
19
- }
20
- /**
21
- * Validate caller props against declared inputs.
22
- *
23
- * - Rejects undeclared props
24
- * - Enforces required props
25
- * - Applies default values
26
- * - Validates types and enum constraints
27
- */
28
- export function validateProps(componentName, callerProps, inputs) {
29
- const validated = {};
30
- const errors = [];
31
- // Check for undeclared props
32
- for (const key of Object.keys(callerProps)) {
33
- if (!(key in inputs)) {
34
- errors.push(`Unknown prop "${key}" passed to <${componentName} />. ` +
35
- `Declared inputs: ${Object.keys(inputs).join(", ") || "(none)"}`);
36
- }
37
- }
38
- // Validate and fill defaults for each declared input
39
- for (const [key, def] of Object.entries(inputs)) {
40
- if (key in callerProps) {
41
- const value = callerProps[key];
42
- // Type check
43
- if (def.type !== "any" && !checkType(value, def.type)) {
44
- errors.push(`Prop "${key}" on <${componentName} /> expected ${def.type}, ` + `got ${typeof value}`);
45
- }
46
- // Enum check
47
- if (def.enum && !def.enum.includes(value)) {
48
- errors.push(`Prop "${key}" on <${componentName} /> must be one of: ` +
49
- `${def.enum.join(", ")}. Got: ${JSON.stringify(value)}`);
50
- }
51
- validated[key] = value;
52
- }
53
- else if ("default" in def && def.default !== undefined) {
54
- // Apply default
55
- validated[key] = def.default;
56
- }
57
- else if (def.required) {
58
- errors.push(`Required prop "${key}" missing on <${componentName} />`);
59
- }
60
- // Optional with no default and not provided → not in validated
61
- }
62
- if (errors.length > 0) {
63
- throw new PropValidationError(componentName, errors);
64
- }
65
- return validated;
66
- }
67
- function checkType(value, type) {
68
- switch (type) {
69
- case "string":
70
- return typeof value === "string";
71
- case "number":
72
- return typeof value === "number";
73
- case "boolean":
74
- return typeof value === "boolean";
75
- case "array":
76
- return Array.isArray(value);
77
- case "object":
78
- return typeof value === "object" && value !== null && !Array.isArray(value);
79
- case "any":
80
- return true;
81
- }
82
- }
@@ -1,227 +0,0 @@
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.ts";
22
- import { ephemeral } from "./ephemeral.ts";
23
- import { deserializeError, serializeError } from "./serialize.ts";
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
- }
@@ -1,13 +0,0 @@
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");
@@ -1,56 +0,0 @@
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.ts";
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 });
@@ -1,193 +0,0 @@
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.ts";
18
- import { ephemeral } from "./ephemeral.ts";
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 });