Skip to content

effects/node: add memoryRun, dropping the ToAsyncOperationMap casts - #1713

Merged
sergey-shandar merged 4 commits into
mainfrom
claude/epic-fermi-pp9y74
Aug 26, 2026
Merged

effects/node: add memoryRun, dropping the ToAsyncOperationMap casts#1713
sergey-shandar merged 4 commits into
mainfrom
claude/epic-fermi-pp9y74

Conversation

@sergey-shandar

@sergey-shandar sergey-shandar commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

asyncRun takes a ToAsyncOperationMap<O>, and memoryOperationMap() exists to supply one — but both call sites cast, so the call had no real O to check its argument against.

Why the cast was there

Not a mismatch between the map and MemOp. ToAsyncOperationMap<O> is a mapped type keyed on O[0], and TypeScript only reverses homomorphic mapped types ({[K in keyof T]: …}), so O cannot be inferred back out of the argument. Left to argument inference it falls back to its Operation constraint, whose payloads and outputs are never — the error names (...payload: never) => Promise<never> — and no real map is assignable to that.

That is why the issue's own proposal ("make memoryOperationMap() return something asyncRun accepts") could not be carried out as written: what it returns is already ToAsyncOperationMap<MemOp>. Nothing about the value was wrong; the inference was.

The fix

Annotate the runner's result so O is inferred from the return type instead — which is how runNodeEffect in fjs/effects/node/module.mjs has always been pinned. memoryRun is that runner, exported so the proof's reused-store case gets one too rather than rebuilding the cast.

What that is worth, stated exactly

An earlier version of this description claimed a drifted handler "compiled under the cast". That was wrong, and @o2alexanderfedin's review is what caught it. memoryOperationMap carries its own /** @type {(uuid?: Uuid) => MemoryOperationMap} */, so every handler was contextually typed at the merge-base too; the removed cast wrapped a call result, not the object literal, and never disabled per-handler checking. Re-verified at both commits: a wrong return, a wrong payload, a missing handler, and a wrong arity all report identically at base and head, rooted at the factory annotation, not at the runner.

The real delta is narrower. Declare MemoryOperationMap as ToAsyncOperationMap<MemCreate | MemRead> and drop the memWrite handler:

head memory/module.mjs(83,58)TS2741: Property 'memWrite' is missing in type 'MemoryOperationMap' but required in type 'ToAsyncOperationMap<MemOp>'
base silent at the memory runner; caught only incidentally at node/module.mjs(359,32), because runNodeEffect spreads the map

So what is gained is agreement between the declared map type and MemOp, checked at the runner. The doc comment and fjs/AGENTS.md now say that, and no longer imply memoryRun's annotation is doing what runNodeEffect's does — only the latter passes an object literal, where the annotation is the only thing checking any handler.

Store ownership is now pinned, both halves

The module documents two separate promises, and the review found each in turn unpinned — a mutant for either passed the whole suite while the doc comments claimed otherwise:

Promise Mutant that used to pass Proof that now fails it
a new runner starts empty one module-level store shared by every runner runnersDoNotShareStore — both runners mint the same key id, so store ownership is the only thing that can tell them apart
run uses a store per call const sharedRun = memoryRun(), run = effect => sharedRun(effect) runIsPerCall — the key the first call mints is unreadable in the second

Each was confirmed failing against its mutant and passing clean. runnersDoNotShareStore also gives memoryRun's uuid parameter its first coverage.

The two kept casts

The issue asked for fjs/effects/node/module.mjs's Erl<NodeOp> and fjs/effects/node/virtual/module.f.mjs's SandboxResult<unknown> to be checked at the same time. They were, and they are three causes rather than two: sandbox is genuine Pr erasure of a generic operation's type parameter, but CreateServer is not generictypes.ts:235 writes RequestListener<Operation> into the declaration, and the <O extends Operation> signature belongs to the effect constructor, which is itself a cast. That correction is the review's too; fjs/effects/node/todo/generic-operation-payload-erasure.md carries the corrected diagnosis for both, including Sandbox's real signature (<T>(f: () => T) => OpResult<SandboxResult<T>> — the wrapper is on the output side, which is why the handler needs the cast at all).

Gates

npx tsc clean, node ./fjs/module.mjs t 3380 pass / 0 fail (3378 at base; the two added are the isolation proofs — reusedRunnerPersists is the renamed reusedOperationMapPersists, not new coverage). Declaration-emit diff shows only MemoryRun and memoryRun added, run's type unchanged after alias expansion. Rust untouched.

Changelog:

  • effects/node/memory: new memoryRun — an asyncRun runner owning a fresh
    memory store, so a caller can keep one across effects instead of building the
    operation map and the runner by hand

🤖 Generated with Claude Code

https://claude.ai/code/session_01EYopL7TcTvXAUGanBJTxgP

`asyncRun` takes a `ToAsyncOperationMap<O>`, a mapped type keyed on `O[0]`.
TypeScript only reverses homomorphic mapped types, so `O` cannot be inferred
from the argument: it fell back to the `Operation` constraint, no real map was
assignable to that, and both memory call sites cast. A cast in that position is
the exact hazard fjs/AGENTS.md warns about — it stops each handler from being
checked against `O`.

Annotating the runner's own result infers `O` from the return type instead, the
way `runNodeEffect` already does. `memoryRun` is that runner, exported so the
proof's reused-store case has one too.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYopL7TcTvXAUGanBJTxgP
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Preview URL Updated (UTC)
✅ Deployment successful!
View logs
functionalscript 0aab47b Commit Preview URL

Branch Preview URL
Aug 26 2026, 11:41 AM

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYopL7TcTvXAUGanBJTxgP

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e93e265380

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread fjs/effects/node/memory/module.mjs

@o2alexanderfedin o2alexanderfedin left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The diagnosis is right and the code is clean. ToAsyncOperationMap<O> is keyed on the indexed access O[0] — neither keyof T nor a naked type parameter — so it is non-homomorphic and not reversible, and deleting the casts at the merge-base does name (...payload: never) => Promise<never> exactly as the body says. memoryOperationMap() really does already return ToAsyncOperationMap<MemOp>: the value was fine, the inference wasn't, and the old todo's proposal genuinely could not be carried out as written. No cast moved — neither changed file has an inline @type cast, as, @ts-ignore or any, and both annotations are load-bearing.

The problem is the evidence, not the change.

"Under the cast it compiled" is false. memoryOperationMap already carries /** @type {(uuid?: Uuid) => MemoryOperationMap} */, so its object literal was contextually typed at the merge-base too. The cast you removed wrapped a call result, not the literal, so it never disabled per-handler checking. Five drifts, one at a time, base vs head — wrong return on memWrite, wrong payload on memRead, missing handler, extra handler, wrong arity — all five behave identically at both commits. Your quoted TS2322 does reproduce, but it is line 4 of a cascade rooted at module.mjs(33,14), the factory annotation, not at the asyncRun call, and it appears verbatim at the merge-base. The extra-key drift is silent at both.

There is a real delta, and it's worth stating in its own terms: declare MemoryOperationMap as ToAsyncOperationMap<MemCreate | MemRead> and drop the memWrite handler, and head gives TS2741: Property 'memWrite' is missing in type 'MemoryOperationMap' but required in type 'ToAsyncOperationMap<MemOp>' at the runner. Base is silent there — it only catches it incidentally over in node/module.mjs(359,32), because runNodeEffect spreads the map. So what you gained is agreement between the declared map type and MemOp, checked at the runner. Narrower than "the check is live again", but genuine.

The same overstatement is now in fjs/AGENTS.md, and that's the part I'd most want fixed, since every future agent reads it. The constraint statement and the annotate-the-result cure are both correct and correctly scoped. The closing generalisation isn't: "Both Node runners are written that way … leaving the map checked" — only runNodeEffect's annotation checks a map, because it passes a fresh object literal. memoryRun passes an already-annotated call result, so its annotation isn't what keeps the map checked. Same conflation in memory/module.mjs's new doc comment.

One factual error in the new todo. types.ts:235 has CreateServer = ['createServer', (listener: RequestListener<Operation>) => OpResult<Server>]not generic. The <O extends Operation> signature the todo attributes to it exists only on the effect constructor at module.f.mjs:346, which is itself a cast. So the Erl<NodeOp> cast isn't Pr erasing a type parameter; Operation is written into the operation declaration. The sandbox case is genuine erasure, as are MemCreate/MemRead/MemWrite — two of three examples are right, but this one changes what the fix would be.

Both kept casts are correctly kept, verified: annotate-the-result cures neither, since both handlers already sit in result-annotated maps, and the todo's create claim reproduces exactly.

One surviving mutant, cheap to close. Making all runners share a single module-level store passes the whole suite — 3378/0. Nothing pins "a new runner starts empty", nor run's "a store per call, so nothing written by one run is visible to the next", both of which this PR's own doc comments promise. Rebuilding the store per call is caught (reusedRunnerPersists). A two-runner isolation proof closes it. Note assert sites are 5 → 5 and the total is unchanged at 3378, so reusedRunnerPersists is the renamed reusedOperationMapPersists, not new coverage.

Gates: npm test 3378/0 at head — and identical at the merge-base, so that number isn't evidence of anything added. tsc --noEmit exit 0 both. The declaration-emit diff claim is accurate: only MemoryRun and memoryRun appear, run's type unchanged after alias expansion. changelog/unreleased/1713.md conforms and no **BREAKING CHANGES:** is needed — the additions are additive and run is unchanged.

Body nit: the Changelog: section itself is correct and byte-identical to the file, but there are two trailer blocks — a 🤖 Generated with… block plus session URL, then a --- rule, then a second _Generated by…_ line pointing at the same session.

…laims

The isolation proof closes a mutant a reviewer found: making every runner
share one module-level store passed the whole suite, though `memoryRun` and
`run` both document the opposite. Both runners in the new proof mint the same
key id, so only store ownership can tell them apart.

Three claims were overstated or wrong, all verified against the merge-base:
per-handler checking was never disabled (the factory annotation does it at
both commits); what the runner annotation actually adds is agreement between
the declared map type and MemOp, reported at the runner instead of wherever
the map is spread next; and CreateServer is not a generic operation, so its
cast is a third cause, not Pr erasure.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYopL7TcTvXAUGanBJTxgP

Copy link
Copy Markdown
Contributor Author

@o2alexanderfedin — all five reproduced at the merge-base before I touched anything. Pushed 4875a96.

"Under the cast it compiled" was false. Confirmed: drifting the factory's memWrite return errors at both commits, rooted at module.mjs(33,14), the factory annotation. My demo drifted a literal at the call site, which is not what the diff changed — the removed cast wrapped a call result, so per-handler checking was never disabled. The claim is gone from the description.

Your delta reproduces exactly. ToAsyncOperationMap<MemCreate | MemRead> minus the handler: head reports TS2741 at memory/module.mjs(73,58), base is silent there and catches it only at node/module.mjs(359,32) via the spread. "Agreement between the declared map type and MemOp, checked at the runner" is now what the description, the doc comment, and fjs/AGENTS.md all say.

The AGENTS.md generalisation is fixed — that was the right thing to care most about. It now splits the two cases: runNodeEffect passes an object literal, where the annotation is the only thing checking any handler; memoryRun passes an annotated result, where it buys map/O agreement. Ends with "Don't claim the second is the first." Same split in the module doc.

CreateServer is not generic — you're right. types.ts:235 writes RequestListener<Operation> into the declaration; the <O extends Operation> signature is on the effect constructor at module.f.mjs:346, itself a cast. So it's a third cause, not Pr erasure, and the fix is a different one: whether CreateServer can carry the listener's op-set. The todo now says that under its own heading, with sandbox and the memory trio left as the genuine erasure cases.

Mutant closed. Confirmed a shared module-level store passes 3378/0 as you said. runnersDoNotShareStore mints the same key id from both runners, so store ownership is the only thing that can distinguish them; it fails against the mutant and passes clean. Suite is 3379/0, and you're right that the extra one is that proof — reusedRunnerPersists is a rename, not new coverage. It also gives memoryRun's uuid parameter its first coverage.

Duplicate trailer removed.


Generated by Claude Code

@o2alexanderfedin o2alexanderfedin left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Three of the four are properly fixed.

The restated body claim is accurate — I re-ran all five drifts at the merge-base and at head and they're byte-identical, all rooted at the factory annotation, and the TS2741 probe fires at the memory runner on head while the base only catches it incidentally over in node/module.mjs(359,32). The AGENTS.md correction is right and doesn't over-correct; I checked the substantive claim rather than the wording by narrowing a handler inside runNodeEffect's literal — as shipped it's caught, and moving the annotation onto the literal absorbs it, so "the annotation is the only thing checking any handler there" holds. Trailers fixed, Changelog: still byte-identical.

The two-runner mutant is dead: a module-level shared store now fails runnersDoNotShareStore, and reusedRunnerPersists still catches the rebuild-per-call case rather than being displaced. Both new assertions and the fixed uuid are load-bearing.

Two residuals, both small:

run's promise is still unpinned. memory/module.mjs's run documents "a store per call, so nothing written by one run is visible to the next", but const sharedRun = memoryRun(); run = effect => sharedRun(effect) passes the whole suite — 3379/0. Only the memoryRun two-runner case is pinned, so "Store isolation is now pinned" is still a shade broader than what's proven. This passes at head (3380/0) and fails under that mutant:

runIsPerCall: async () => {
    const key = unwrap(await run(unwrapStep(create(1), errorSummary)))
    const result = await run(read(key)).then(() => undefined, error => error)
    assert(result instanceof Error, result)
},

One factual error survives in the todo, and it's the one that matters to its own argument. generic-operation-payload-erasure.md:9-10 gives Sandbox as <T>(f: () => SandboxResult<T>) => …. types.ts:325 is readonly['sandbox', <T>(f: () => T) => OpResult<SandboxResult<T>>]SandboxResult is on the other side. Under the quoted signature f() at T = unknown would already be SandboxResult<unknown> and the cast the file exists to explain wouldn't be needed, which contradicts its own causal claim five lines down. Dropping the cast confirms the real shape: virtual/module.f.mjs(627,5): TS2322 … 'Ok<unknown>' is not assignable to 'OpResult<SandboxResult<unknown>>'. Pre-existing, but this delta reflowed that line in a commit titled "correct the review claims".

The CreateServer correction itself is right and every cited line lands at head. Nit: the body's table still says memory/module.mjs(73,58); it's (83,58) now — stale by exactly the ten lines this delta added to the doc comment.

Gates: npm test 3379/0 at head (was 3378, +1 = runnersDoNotShareStore), tsc --noEmit exit 0, purge matched nothing, no casts introduced.

`run` documents "a store per call, so nothing written by one `run` is visible
to the next", but one runner shared across every call passed the whole suite —
`runnersDoNotShareStore` pins only the two-runner half. `runIsPerCall` closes
the other half.

The todo gave `Sandbox` as `<T>(f: () => SandboxResult<T>) => …`; types.ts:325
is `<T>(f: () => T) => OpResult<SandboxResult<T>>`. The wrapper is on the
output side, which is exactly why the handler needs the cast — under the
signature as written there would be nothing to cast, contradicting the file's
own argument.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYopL7TcTvXAUGanBJTxgP

Copy link
Copy Markdown
Contributor Author

Both residuals confirmed and fixed in 0aab47b.

run's promise was genuinely unpinned. const sharedRun = memoryRun(); run = effect => sharedRun(effect) passes 3379/0 exactly as you said — runnersDoNotShareStore pins only the two-runner half. Added runIsPerCall; it fails under that mutant (3379/1) and passes clean at 3380/0. I tightened your version slightly to assert the message against asBase(key) rather than only instanceof Error, so it can't pass on some other throw. The description now has a two-row table — one promise, one mutant, one proof per row — instead of the broader "store isolation is now pinned".

The Sandbox signature was wrong and you're right that it mattered. types.ts:325 is <T>(f: () => T) => OpResult<SandboxResult<T>>; the wrapper is on the output side. Under the signature I wrote, erasure would hand the handler a SandboxResult<unknown> already and the cast the file exists to explain would be unnecessary — the line refuted its own argument five lines down. Fixed, with a paragraph making the placement explicit so it doesn't get re-flipped. Pre-existing or not, it went out under a commit titled "correct the review claims", so it's mine.

Line number: (83,58), as you said. I'd have written 89 from a grep — the probe removes six lines, which is exactly the trap. Re-ran it for the authoritative location; body updated.

Nothing outstanding from my side.


Generated by Claude Code

@o2alexanderfedin o2alexanderfedin left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving. Both residuals are closed.

run's per-call store is pinned, and the proof is stronger than what I suggested — it asserts the exact memory key not found: <key> message for the key the first run minted, so it fails for the right reason rather than on any incidental throw. I confirmed that by flipping the expected message and reading the observed value: a real randomUUID key from call 1, genuinely absent from call 2's store.

The shared-runner mutant now dies naming runIsPerCall, with every other row still green — which matches the proof's own comment. Neither neighbour was displaced: hoisting store to module level fails both runnersDoNotShareStore and runIsPerCall, and rebuilding the map per effect fails reusedRunnerPersists alone. Worth noting the naive rebuild-per-call spelling doesn't even compile — TS2345 … 'MemoryOperationMap' is not assignable to 'ToAsyncOperationMap<Operation>' — which is the same inference machinery this PR is about.

The Sandbox quote now matches types.ts:325 character for character, and the prose around it was reworked rather than patched: the new paragraph states the asymmetry explicitly and its counterfactual is exactly what dropping the cast produces — the gap is unknown vs SandboxResult<unknown> on the output side. The other examples survived the edit; every cited line lands and every link resolves. Body line fixed to (83,58), verified by running its own mutation.

Gates: npm test 3380/0 (+1 = runIsPerCall), tsc --noEmit exit 0, purge matched nothing, additive only — all four prior rows present and unrenamed, the -1 is just the import line, no casts introduced. No changelog item needed for a proof plus a todo doc.

@sergey-shandar
sergey-shandar added this pull request to the merge queue Aug 26, 2026
Merged via the queue into main with commit be345a7 Aug 26, 2026
19 checks passed
@sergey-shandar
sergey-shandar deleted the claude/epic-fermi-pp9y74 branch August 26, 2026 19:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants