From c83059e6065ab1713d7755b0a284086d439ee8fd Mon Sep 17 00:00:00 2001 From: Taras Mankovski <74687+taras@users.noreply.github.com> Date: Sun, 9 Aug 2026 07:24:54 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=92=A5=20Rename=20DurableCtx=20to=20Durab?= =?UTF-8?q?leContext?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The repository spells `Context` out in identifiers, and `DurableCtx` was the last exported holdout. It now shares its name with the `DurableContext` interface it carries — a value and a type occupy separate declaration spaces — so a scope read states its subject once: `scope.get(DurableContext)` and `scope.expect(DurableContext)`. The context key is untouched. `createContext("@effection/durable")` still names the same context, and Effection identifies a context by that string, so the state a running workflow observes is the state it observed before. What changes is the export: `@executablemd/durable-streams` no longer exports `DurableCtx`, which is a breaking change for anyone reading the context directly. `mod.ts` re-exports the name once, without `export type`, because a value re-export carries every meaning of the name; splitting it would leave the type exported and the context unreachable. The new `context.test.ts` runs a workflow that reads its own state through the exported value and returns the coroutine id `durableRun` assigned it, so dropping the value export fails the typecheck rather than passing silently. Co-authored-by: Taras Mankovski <74687+taras@users.noreply.github.com> --- packages/core/src/execute.ts | 2 +- packages/core/src/loop.ts | 11 ++++---- packages/durable-streams/combinators.ts | 14 +++++----- packages/durable-streams/context.ts | 2 +- packages/durable-streams/effect.ts | 6 ++-- packages/durable-streams/mod.ts | 3 +- packages/durable-streams/run.ts | 4 +-- packages/durable-streams/specs/DECISIONS.md | 4 +-- .../specs/effection-integration.md | 16 +++++------ .../durable-streams/tests/context.test.ts | 28 +++++++++++++++++++ specs/decisions.md | 2 +- specs/executable-mdx-spec.md | 2 +- 12 files changed, 60 insertions(+), 34 deletions(-) create mode 100644 packages/durable-streams/tests/context.test.ts diff --git a/packages/core/src/execute.ts b/packages/core/src/execute.ts index 40a54daf..8426f37f 100644 --- a/packages/core/src/execute.ts +++ b/packages/core/src/execute.ts @@ -834,7 +834,7 @@ function* executeDocument(options: ExecuteOptions): Operation yield* LiveFailure.set(liveFailure); // Create per-document eval scope (spec §3.1). - // Created in the same scope as durableRun so that DurableCtx + // Created in the same scope as durableRun so that DurableContext // (set by durableRun) is visible to eval code that calls // renderChildren → importComponent → createDurableOperation. const rootEvalScope = yield* useEvalScope(); diff --git a/packages/core/src/loop.ts b/packages/core/src/loop.ts index 9ae5090a..6a6156be 100644 --- a/packages/core/src/loop.ts +++ b/packages/core/src/loop.ts @@ -1,12 +1,11 @@ import { createContext, useScope } from "effection"; import type { Context, Operation } from "effection"; -import { createDurableOperation, DurableCtx, StaleInputError } from "@executablemd/durable-streams"; -import type { +import { + createDurableOperation, DurableContext, - EffectDescription, - Json, - Workflow, + StaleInputError, } from "@executablemd/durable-streams"; +import type { EffectDescription, Json, Workflow } from "@executablemd/durable-streams"; /** * The loop a `` exits (spec §6.5 ``). @@ -191,5 +190,5 @@ export function* recordOutcome(identity: LoopIdentity, record: LoopRecord): Oper function* durableContext(): Operation { const scope = yield* useScope(); - return scope.get(DurableCtx); + return scope.get(DurableContext); } diff --git a/packages/durable-streams/combinators.ts b/packages/durable-streams/combinators.ts index 30876ca3..a76d27b6 100644 --- a/packages/durable-streams/combinators.ts +++ b/packages/durable-streams/combinators.ts @@ -27,7 +27,7 @@ import { useScope, } from "effection"; import type { Operation, Task } from "effection"; -import { type DurableContext, DurableCtx } from "./context.ts"; +import { DurableContext } from "./context.ts"; import { activeDurabilityFailure, appendDurableEvent, @@ -46,7 +46,7 @@ import type { Close, Json, Workflow, WorkflowValue } from "./types.ts"; * * It: * 1. Checks if the child already completed (has Close event) — short-circuits - * 2. Sets DurableCtx on the child's scope with the child's coroutineId + * 2. Sets DurableContext on the child's scope with the child's coroutineId * 3. Runs the child workflow (its DurableEffects use the child's coroutineId) * 4. Appends Close(ok|err) when the child terminates * @@ -102,7 +102,7 @@ function* runDurableChild( childCounter: 0, durability: parentCtx.durability, }; - scope.set(DurableCtx, childCtx); + scope.set(DurableContext, childCtx); let closeEvent: Close | undefined; let suppressClose = false; @@ -144,7 +144,7 @@ function* runDurableChild( try { // Run the child workflow. DurableEffects inside the child read - // DurableCtx from the scope, so they'll use childId. + // DurableContext from the scope, so they'll use childId. const result: T = yield* childWorkflow(); const durabilityFailure = activeDurabilityFailure(childCtx); @@ -224,7 +224,7 @@ export function durableSpawn( return ephemeral( (function* (): Operation> { const scope = yield* useScope(); - const ctx = scope.expect(DurableCtx); + const ctx = scope.expect(DurableContext); // Assign deterministic child ID const childIndex = ctx.childCounter++; @@ -256,7 +256,7 @@ export function durableAll( return ephemeral( (function* (): Operation { const scope = yield* useScope(); - const ctx = scope.expect(DurableCtx); + const ctx = scope.expect(DurableContext); // Build child Operations, one per workflow. Each gets its own // deterministic coroutineId and Close event handling. @@ -303,7 +303,7 @@ export function durableRace( return ephemeral( (function* (): Operation { const scope = yield* useScope(); - const ctx = scope.expect(DurableCtx); + const ctx = scope.expect(DurableContext); // Build Operations for each child — each gets its own coroutineId // and Close event handling via runDurableChild. diff --git a/packages/durable-streams/context.ts b/packages/durable-streams/context.ts index d14c5ac6..6c12cc49 100644 --- a/packages/durable-streams/context.ts +++ b/packages/durable-streams/context.ts @@ -37,5 +37,5 @@ export interface DurableContext { * Effection Context for durable execution state. * Set on the root scope by durableRun(); inherited by child scopes. */ -export const DurableCtx: Context = +export const DurableContext: Context = createContext("@effection/durable"); diff --git a/packages/durable-streams/effect.ts b/packages/durable-streams/effect.ts index 000d3529..46ae2a61 100644 --- a/packages/durable-streams/effect.ts +++ b/packages/durable-streams/effect.ts @@ -21,7 +21,7 @@ */ import type { Operation } from "effection"; -import { type DurableContext, DurableCtx } from "./context.ts"; +import { DurableContext } from "./context.ts"; import { Divergence } from "./divergence.ts"; import { activeDurabilityFailure, @@ -209,7 +209,7 @@ export function createDurableEffect( resolve: Resolve>, routine, ): (resolve: Resolve>) => void { - const ctx = routine.scope.expect(DurableCtx); + const ctx = routine.scope.expect(DurableContext); const durabilityFailure = activeDurabilityFailure(ctx); if (durabilityFailure) { resolve({ ok: false, error: durabilityFailure }); @@ -328,7 +328,7 @@ export function createDurableOperation( resolve: Resolve>, routine, ): (resolve: Resolve>) => void { - const ctx = routine.scope.expect(DurableCtx); + const ctx = routine.scope.expect(DurableContext); const durabilityFailure = activeDurabilityFailure(ctx); if (durabilityFailure) { resolve({ ok: false, error: durabilityFailure }); diff --git a/packages/durable-streams/mod.ts b/packages/durable-streams/mod.ts index 111631f8..ef2fde44 100644 --- a/packages/durable-streams/mod.ts +++ b/packages/durable-streams/mod.ts @@ -59,8 +59,7 @@ export { ReplayGuard } from "./replay-guard.ts"; export type { ReplayOutcome } from "./replay-guard.ts"; // Context -export { DurableCtx } from "./context.ts"; -export type { DurableContext } from "./context.ts"; +export { DurableContext } from "./context.ts"; // Serialization utilities export { diff --git a/packages/durable-streams/run.ts b/packages/durable-streams/run.ts index edc3ff59..141ab862 100644 --- a/packages/durable-streams/run.ts +++ b/packages/durable-streams/run.ts @@ -15,7 +15,7 @@ import { useScope } from "effection"; import type { Operation, Scope } from "effection"; -import { DurableCtx } from "./context.ts"; +import { DurableContext } from "./context.ts"; import { activeDurabilityFailure, appendDurableEvent } from "./durability.ts"; import { EarlyReturnDivergenceError, TerminalDivergenceError } from "./errors.ts"; import { ReplayGuard } from "./replay-guard.ts"; @@ -97,7 +97,7 @@ export function* durableRun( childCounter: 0, durability: {}, }; - scope.set(DurableCtx, ctx); + scope.set(DurableContext, ctx); // ── REPLAY GUARD: Check phase ── // Run before the workflow starts. Middleware can yield* for I/O (hash diff --git a/packages/durable-streams/specs/DECISIONS.md b/packages/durable-streams/specs/DECISIONS.md index 04eae00b..cec63a89 100644 --- a/packages/durable-streams/specs/DECISIONS.md +++ b/packages/durable-streams/specs/DECISIONS.md @@ -264,7 +264,7 @@ Updated before completion of every phase and committed at the end of each phase. runner/reducer or inside each effect. - **Decision:** Each `DurableEffect.enter()` handles its own replay/live dispatch internally, reading `DurableContext` from the scope via - `routine.scope.expect(DurableCtx)`. + `routine.scope.expect(DurableContext)`. - **Rationale:** Keeps the Effection reducer completely untouched. The reducer calls `enter()` on every effect — whether `enter()` resolves synchronously (replay) or asynchronously (live + persist) is invisible to it. This is @@ -365,7 +365,7 @@ Updated before completion of every phase and committed at the end of each phase. the parent generator's try/catch). - **Consequences:** `durableAll` and `durableRace` delegate to Effection's native combinators. The durable layer wraps each child in an Operation - that (1) checks for replay short-circuit, (2) sets DurableCtx with a + that (1) checks for replay short-circuit, (2) sets DurableContext with a child coroutineId, and (3) emits Close events in finally. This is a thin wrapper that preserves Effection's error semantics perfectly. diff --git a/packages/durable-streams/specs/effection-integration.md b/packages/durable-streams/specs/effection-integration.md index 3ed7f1e0..63186393 100644 --- a/packages/durable-streams/specs/effection-integration.md +++ b/packages/durable-streams/specs/effection-integration.md @@ -355,7 +355,7 @@ interface DurableContext { childCounter: number; } -const DurableCtx = createContext("@effection/durable"); +const DurableContext = createContext("@effection/durable"); type Executor = (resolve: (result: Result) => void, reject: (error: Error) => void) => () => void; @@ -364,7 +364,7 @@ function createDurableEffect(desc: EffectDescription, execute: Executor): Dur description: `${desc.type}(${desc.name})`, effectDescription: desc, enter(resolve, routine) { - const ctx = routine.scope.expect(DurableCtx); + const ctx = routine.scope.expect(DurableContext); const entry = ctx.replayIndex.peekYield(ctx.coroutineId); if (entry) { @@ -623,9 +623,9 @@ interface DurableContext { When `durableSpawn()` creates a child scope: ```typescript -let parentCtx = scope.expect(DurableCtx); +let parentCtx = scope.expect(DurableContext); let childId = `${parentCtx.coroutineId}.${parentCtx.childCounter++}`; -childScope.set(DurableCtx, { +childScope.set(DurableContext, { replayIndex: parentCtx.replayIndex, // shared stream: parentCtx.stream, // shared coroutineId: childId, @@ -734,7 +734,7 @@ export function durableSpawn( return ephemeral( (function* (): Operation> { const scope = yield* useScope(); - const ctx = scope.expect(DurableCtx); + const ctx = scope.expect(DurableContext); const childId = `${ctx.coroutineId}.${ctx.childCounter++}`; return yield* spawn(() => runDurableChild(childWorkflow, childId, ctx)); })(), @@ -755,7 +755,7 @@ function* durableAll( workflows: (() => Workflow | Operation)[], ): Operation { const scope = yield* useScope(); - const ctx = scope.expect(DurableCtx); + const ctx = scope.expect(DurableContext); const childOps: Operation[] = workflows.map((workflow) => { const childId = `${ctx.coroutineId}.${ctx.childCounter++}`; @@ -803,7 +803,7 @@ function* durableRace( workflows: (() => Workflow | Operation)[], ): Operation { const scope = yield* useScope(); - const ctx = scope.expect(DurableCtx); + const ctx = scope.expect(DurableContext); const childOps: Operation[] = workflows.map((workflow) => { const childId = `${ctx.coroutineId}.${ctx.childCounter++}`; @@ -931,7 +931,7 @@ durable context, and runs the workflow: ```typescript const ctx = { replayIndex, stream, coroutineId, childCounter: 0, durability: {} }; -scope.set(DurableCtx, ctx); +scope.set(DurableContext, ctx); replayIndex.claim(coroutineId); try { diff --git a/packages/durable-streams/tests/context.test.ts b/packages/durable-streams/tests/context.test.ts new file mode 100644 index 00000000..5bb35aaa --- /dev/null +++ b/packages/durable-streams/tests/context.test.ts @@ -0,0 +1,28 @@ +/** + * The durable execution state is reachable from a workflow through the + * exported DurableContext, which names both the Effection context and the + * shape that context holds. + */ + +import { describe, it } from "@executablemd/test-support/bdd"; +import { expect } from "@executablemd/test-support/expect"; +import { type Operation, useScope } from "effection"; +import { DurableContext, durableRun, InMemoryStream } from "../mod.ts"; + +describe("DurableContext", () => { + it("holds the state durableRun installs on the workflow scope", function* () { + const stream = new InMemoryStream(); + + function* workflow(): Operation { + const scope = yield* useScope(); + const state: DurableContext = scope.expect(DurableContext); + expect(state.stream).toBe(stream); + expect(state.childCounter).toBe(0); + return state.coroutineId; + } + + const coroutineId = yield* durableRun(workflow, { stream, coroutineId: "root.7" }); + + expect(coroutineId).toBe("root.7"); + }); +}); diff --git a/specs/decisions.md b/specs/decisions.md index fa8342ed..52eb4225 100644 --- a/specs/decisions.md +++ b/specs/decisions.md @@ -458,7 +458,7 @@ operations that cannot proceed without a provider (`importComponent`, `applyModifiers`, `codeBlock`, `content`) throw named missing-provider errors that identify the missing installation. -Durable-streams' own contexts (e.g. `DurableCtx`) are unchanged: they +Durable-streams' own contexts (e.g. `DurableContext`) are unchanged: they store durable runtime state, not overridable core operations. --- diff --git a/specs/executable-mdx-spec.md b/specs/executable-mdx-spec.md index 69eeec40..a8f99f90 100644 --- a/specs/executable-mdx-spec.md +++ b/specs/executable-mdx-spec.md @@ -1337,7 +1337,7 @@ A component invocation creates its eval scope on its own expansion frame and runs its body inside a task that scope owns: ``` -invocation frame expansion providers, error mode, DurableCtx +invocation frame expansion providers, error mode, DurableContext └─ evalHost └─ A's loop task the invocation's eval scope └─ body task the component body, its resources and its middleware