From e93e265380f5ae3e657ec75bbdb74fd880fadb19 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 26 Aug 2026 10:24:37 +0000 Subject: [PATCH 1/4] effects/node: add memoryRun, dropping the ToAsyncOperationMap casts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `asyncRun` takes a `ToAsyncOperationMap`, 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 Claude-Session: https://claude.ai/code/session_01EYopL7TcTvXAUGanBJTxgP --- fjs/AGENTS.md | 25 +++++++-- fjs/effects/node/memory/module.mjs | 32 +++++++++-- fjs/effects/node/memory/proof.mjs | 9 ++- .../todo/async-operation-map-assignability.md | 38 ------------- .../todo/generic-operation-payload-erasure.md | 56 +++++++++++++++++++ 5 files changed, 107 insertions(+), 53 deletions(-) delete mode 100644 fjs/effects/node/todo/async-operation-map-assignability.md create mode 100644 fjs/effects/node/todo/generic-operation-payload-erasure.md diff --git a/fjs/AGENTS.md b/fjs/AGENTS.md index f1d91e79c..d35bde932 100644 --- a/fjs/AGENTS.md +++ b/fjs/AGENTS.md @@ -526,12 +526,25 @@ check its argument. A cast around a big object literal passed to a `ToAsyncOperationMap`-shaped parameter, for example, blocks TypeScript from checking each operation's implementation against `O` — the object literal is no longer contextually typed by the call site, so a drifted handler shape is -absorbed by the cast instead of flagged. Prefer no cast at all when the callee -already supplies enough context (as `asyncRun(map)` does here) so the object -literal is checked structurally on its own; reach for `@satisfies` only where a -check without adopting the target type is actually wanted, e.g. a value that -must additionally be nominal-branded — `asNominal(x) satisfies T` becomes -`/** @satisfies {T} */ (asNominal(x))`, not `@type`. +absorbed by the cast instead of flagged. Prefer no cast at all, so the object +literal is checked structurally on its own. + +`asyncRun(map)` is worth spelling out, because the callee does **not** supply +that context on its own: `ToAsyncOperationMap` is a mapped type keyed on +`O[0]`, not a homomorphic `{[K in keyof T]: …}`, so TypeScript cannot infer `O` +back out of the argument. Left to argument inference `O` falls back to its +`Operation` constraint — payloads and outputs `never` — which no real map is +assignable to, and the call site reaches for exactly the cast this section warns +about. **Annotate the result instead**: pin the runner's own type +(`/** @type {MemoryRun} */`, `/** @type {_EffectToPromise} */`) and `O` is +inferred from the return type, leaving the map checked. Both Node runners are +written that way — `fjs/effects/node/module.mjs`'s `runNodeEffect` and +`fjs/effects/node/memory/module.mjs`'s `memoryRun`. + +Reach for `@satisfies` only where a check without adopting the target type is +actually wanted, e.g. a value that must additionally be nominal-branded — +`asNominal(x) satisfies T` becomes `/** @satisfies {T} */ (asNominal(x))`, not +`@type`. #### Mutually recursive constants: cross-reference with `typeof` diff --git a/fjs/effects/node/memory/module.mjs b/fjs/effects/node/memory/module.mjs index 6fba29eda..89c2f1ed0 100644 --- a/fjs/effects/node/memory/module.mjs +++ b/fjs/effects/node/memory/module.mjs @@ -56,8 +56,32 @@ export const memoryOperationMap = (uuid = randomUUID) => { } /** - * Runs a memory-only effect using a fresh memory store. - * @type {(effect: Effect) => Promise>} + * An {@link asyncRun} runner over {@link MemOp}: an effect in, its `Result` out. + * @typedef {(effect: Effect) => Promise>} MemoryRun */ -export const run = effect => - asyncRun(/** @type {ToAsyncOperationMap} */ (memoryOperationMap()))(effect) + +/** + * Creates a runner owning a fresh memory store. Every effect passed to the + * *same* runner shares that store; a new runner starts empty. + * + * **The `MemoryRun` annotation is what types the `asyncRun` call.** `asyncRun` + * takes a `ToAsyncOperationMap`, a mapped type keyed on `O[0]`, and + * TypeScript cannot infer `O` back out of one — only a homomorphic + * `{[K in keyof T]: …}` is reversed. Left to argument inference `O` falls back + * to its `Operation` constraint, whose payloads and outputs are `never`, and no + * real map is assignable to that; the call site then needs a cast, and a cast in + * *that* position stops each handler from being checked against `O` at all (see + * [`fjs/AGENTS.md`](../../../AGENTS.md)). Annotating the result instead lets + * `O` be inferred from the return type, so the map stays checked. The Node + * runner pins `runNodeEffect` the same way (`../module.mjs`). + * @type {(uuid?: Uuid) => MemoryRun} + */ +export const memoryRun = (uuid = randomUUID) => asyncRun(memoryOperationMap(uuid)) + +/** + * Runs a memory-only effect using a fresh memory store — a store per call, so + * nothing written by one `run` is visible to the next. Use {@link memoryRun} + * to keep one. + * @type {MemoryRun} + */ +export const run = effect => memoryRun()(effect) diff --git a/fjs/effects/node/memory/proof.mjs b/fjs/effects/node/memory/proof.mjs index f55da9a1d..36bb3fa90 100644 --- a/fjs/effects/node/memory/proof.mjs +++ b/fjs/effects/node/memory/proof.mjs @@ -3,16 +3,15 @@ * * @module * - * @import { Key, MemOp } from '../../memory/types.ts' + * @import { Key } from '../../memory/types.ts' */ -import { asyncRun } from '../../module.mjs' import { errorSummary } from '../module.f.mjs' import { asNominal, create, read, write, } from '../../memory/module.f.mjs' -import { memoryOperationMap, run } from './module.mjs' +import { memoryRun, run } from './module.mjs' import { assert, assertEq } from '../../../asserts/module.f.mjs' import { unwrap } from "../../../types/result/module.f.mjs" import { step, unwrapStep } from '../../module.f.mjs' @@ -28,8 +27,8 @@ export const proof = { assert(r[0] === 'ok', r) assertEq(r[1], 2) }, - reusedOperationMapPersists: async () => { - const runner = asyncRun(/** @type {import('../../types.ts').ToAsyncOperationMap} */ (memoryOperationMap())) + reusedRunnerPersists: async () => { + const runner = memoryRun() // `unwrapStep` empties the channel, so what the runner hands back is // an `ok` and these unwraps are total. const key = unwrap(await runner(unwrapStep(create(1), errorSummary))) diff --git a/fjs/effects/node/todo/async-operation-map-assignability.md b/fjs/effects/node/todo/async-operation-map-assignability.md deleted file mode 100644 index a7aebf241..000000000 --- a/fjs/effects/node/todo/async-operation-map-assignability.md +++ /dev/null @@ -1,38 +0,0 @@ -# `ToAsyncOperationMap` rejects the operation maps built for it - -**Priority:** P3 -**Status:** open - -### Problem - -`asyncRun` takes a `ToAsyncOperationMap`, and `memoryOperationMap()` exists to -supply one — but the result is not assignable, so both call sites cast: - -- `fjs/effects/node/memory/module.mjs:60` — `asyncRun(/** @type {ToAsyncOperationMap} */ (memoryOperationMap()))` -- `fjs/effects/node/memory/proof.mjs:28` — the same cast, spelled with an inline `import(…)` - -`npx tsc` reports `MemoryOperationMap` is not assignable to -`ToAsyncOperationMap`, so the mismatch is between the map type the -factory returns and the shape the runner asks for — not between `MemOp` and some -other operation set. - -This matters beyond tidiness: [`fjs/AGENTS.md`](../../../AGENTS.md) notes that a -cast around a value handed to a `ToAsyncOperationMap`-shaped parameter blocks -TypeScript from checking each operation's implementation against `O`, so a -drifted handler shape is absorbed rather than reported. These two casts are that -exact hazard. - -Two nearby casts in the same area may or may not share a cause, and are worth -checking at the same time: - -- `fjs/effects/node/module.mjs:287` — `Erl` on a request listener -- `fjs/effects/node/virtual/module.f.mjs:410` — `SandboxResult` on `f()` - -### Proposal - -Make `memoryOperationMap()` return something `asyncRun` accepts, so the object -literal is checked structurally against `O` at the call site and both casts go. - -### Related - -- [`todo/inline-type-casts.md`](../../../../todo/inline-type-casts.md) diff --git a/fjs/effects/node/todo/generic-operation-payload-erasure.md b/fjs/effects/node/todo/generic-operation-payload-erasure.md new file mode 100644 index 000000000..1cb0af668 --- /dev/null +++ b/fjs/effects/node/todo/generic-operation-payload-erasure.md @@ -0,0 +1,56 @@ +# `Pr` erases a generic operation's type parameter + +**Priority:** P3 +**Status:** open + +### Problem + +An `Operation` may declare a generic signature — `MemRead` is +`(key: Key) => OpResult`, `Sandbox` is `(f: () => SandboxResult) +=> …`, `CreateServer` is `(listener: RequestListener) => +…`. `Pr` reads the payload and the output off that signature with +`infer P` / `infer R`, and inference through a generic signature instantiates +its type parameter at the constraint. So the handler an operation map writes +receives `T = unknown`, `O = Operation`, and has to cast its way back: + +- `fjs/effects/node/virtual/module.f.mjs:627` — `sandbox: f => … ok(/** @type + {SandboxResult} */ (f()))`; without the cast, `f()` is `unknown` + and `Ok` is not `OpResult>`. +- `fjs/effects/node/module.mjs:438` — `answerRequest(/** @type {Erl} */ + (requestListener))`; without the cast, the payload is + `RequestListener` and `Operation` is not assignable to `NodeOp`. + +The same erasure is why `fjs/effects/memory/module.f.mjs:34,39` cast the +`do_('memCreate')` / `do_('memRead')` results back to their generic shapes. +`write`, three lines below them, is the same `do_` call written as an annotated +declaration and needs no cast — but the declaration form was tried on `create` +and does not help: `Func` is `(value: unknown) => Effect>`, and `Key` is not assignable to `Key` in the +covariant result. `write` escapes only because `T` appears in its parameters +alone, where `unknown` is the accepting side. + +**This is not the same cause as the `asyncRun` casts that used to sit beside +them**, and the two were checked together before being split apart. That one +was inference of `O` from `ToAsyncOperationMap` at the call, and annotating +the runner's own result cured it — see "Prefer `@satisfies` over `@type` when +checking, not overriding" in [`fjs/AGENTS.md`](../../../AGENTS.md), and +`memoryRun` in [`../memory/module.mjs`](../memory/module.mjs). These two survive +that treatment: the loss happens inside `Pr`, before any call site has a say. +Deleting each cast and reading the compiler error is what separated them, and it +is the check to repeat on any further candidate. + +### Proposal + +No design yet. Work out whether a generic operation's type parameter can be +carried through `Pr` at all — the handler would have to be typed as a generic +function rather than an instantiation of one, which `OperationMap`'s +`(...payload: Pr[0]) => R` cannot currently express — or record that it +cannot and that a generic operation costs one cast per handler by construction. + +### Related + +- [`todo/inline-type-casts.md`](../../../../todo/inline-type-casts.md) — the + audit that measured all four sites and asked for "its own issue against the + API it is papering over"; this is that issue for the generic ones. +- [`fjs/effects/types.ts`](../../types.ts) — `Pr`, `OperationMap`, + `ToAsyncOperationMap`. From 0d953059cf99e03b08e9b23ac4ceb196fd24a7d5 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 26 Aug 2026 10:25:52 +0000 Subject: [PATCH 2/4] changelog: add entry for #1713 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01EYopL7TcTvXAUGanBJTxgP --- changelog/unreleased/1713.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 changelog/unreleased/1713.md diff --git a/changelog/unreleased/1713.md b/changelog/unreleased/1713.md new file mode 100644 index 000000000..f471257bf --- /dev/null +++ b/changelog/unreleased/1713.md @@ -0,0 +1,3 @@ +- `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 From 4875a96ce767bafd47d4dc5c5b949ff7bafa5829 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 26 Aug 2026 11:08:44 +0000 Subject: [PATCH 3/4] effects/node/memory: pin runner store isolation; correct the review claims 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 Claude-Session: https://claude.ai/code/session_01EYopL7TcTvXAUGanBJTxgP --- fjs/AGENTS.md | 16 ++++- fjs/effects/node/memory/module.mjs | 20 +++++-- fjs/effects/node/memory/proof.mjs | 18 ++++++ .../todo/generic-operation-payload-erasure.md | 60 +++++++++++-------- 4 files changed, 82 insertions(+), 32 deletions(-) diff --git a/fjs/AGENTS.md b/fjs/AGENTS.md index d35bde932..6f1d7e71c 100644 --- a/fjs/AGENTS.md +++ b/fjs/AGENTS.md @@ -536,11 +536,21 @@ back out of the argument. Left to argument inference `O` falls back to its `Operation` constraint — payloads and outputs `never` — which no real map is assignable to, and the call site reaches for exactly the cast this section warns about. **Annotate the result instead**: pin the runner's own type -(`/** @type {MemoryRun} */`, `/** @type {_EffectToPromise} */`) and `O` is -inferred from the return type, leaving the map checked. Both Node runners are -written that way — `fjs/effects/node/module.mjs`'s `runNodeEffect` and +(`/** @type {_EffectToPromise} */`, `/** @type {MemoryRun} */`) and `O` is +inferred from the return type, giving the call a real `O` to check its argument +against. Both Node runners are written that way — +`fjs/effects/node/module.mjs`'s `runNodeEffect` and `fjs/effects/node/memory/module.mjs`'s `memoryRun`. +What that buys differs with what the call passes, and only the first case is +the hazard this section is about. `runNodeEffect` passes an object **literal**, +so its annotation is the only thing checking any handler — without it nothing +is checked. `memoryRun` passes an already-annotated call result, whose handlers +are checked at the factory regardless; there the annotation buys agreement +between the *declared map type* and `O`, caught at the runner rather than +wherever the map is spread next. Both are worth having. Don't claim the second +is the first. + Reach for `@satisfies` only where a check without adopting the target type is actually wanted, e.g. a value that must additionally be nominal-branded — `asNominal(x) satisfies T` becomes `/** @satisfies {T} */ (asNominal(x))`, not diff --git a/fjs/effects/node/memory/module.mjs b/fjs/effects/node/memory/module.mjs index 89c2f1ed0..6aa3ab29e 100644 --- a/fjs/effects/node/memory/module.mjs +++ b/fjs/effects/node/memory/module.mjs @@ -69,11 +69,21 @@ export const memoryOperationMap = (uuid = randomUUID) => { * TypeScript cannot infer `O` back out of one — only a homomorphic * `{[K in keyof T]: …}` is reversed. Left to argument inference `O` falls back * to its `Operation` constraint, whose payloads and outputs are `never`, and no - * real map is assignable to that; the call site then needs a cast, and a cast in - * *that* position stops each handler from being checked against `O` at all (see - * [`fjs/AGENTS.md`](../../../AGENTS.md)). Annotating the result instead lets - * `O` be inferred from the return type, so the map stays checked. The Node - * runner pins `runNodeEffect` the same way (`../module.mjs`). + * real map is assignable to that; the call site then needs a cast. Annotating + * the result instead lets `O` be inferred from the return type, which is what + * gives the call a real `O` to check the argument against. + * + * **What that check is worth here is narrow, so it is worth stating exactly.** + * Each handler is already checked against `MemOp` by + * {@link memoryOperationMap}'s own annotation, and was before this call was + * written — a drifted handler is reported there, at the factory, either way. + * What the runner adds is agreement between the *declared map type* and + * `MemOp`: give `MemoryOperationMap` a narrower operation set and this line + * reports the missing handler, where an unchecked call site would leave it to + * whoever spreads the map next. The wider hazard — a cast stripping the + * contextual type from an object *literal*, so no handler is checked at all — + * is `runNodeEffect`'s (`../module.mjs`), which passes a literal; this call + * passes an already-annotated result. * @type {(uuid?: Uuid) => MemoryRun} */ export const memoryRun = (uuid = randomUUID) => asyncRun(memoryOperationMap(uuid)) diff --git a/fjs/effects/node/memory/proof.mjs b/fjs/effects/node/memory/proof.mjs index 36bb3fa90..f96096ce8 100644 --- a/fjs/effects/node/memory/proof.mjs +++ b/fjs/effects/node/memory/proof.mjs @@ -4,6 +4,7 @@ * @module * * @import { Key } from '../../memory/types.ts' + * @import { Uuid } from './module.mjs' */ import { errorSummary } from '../module.f.mjs' @@ -36,6 +37,23 @@ export const proof = { const result = unwrap(await runner(unwrapStep(read(key), errorSummary))) assertEq(result, 2) }, + runnersDoNotShareStore: async () => { + // Both runners mint the *same* key id, so the id cannot be what tells + // them apart — only store ownership can. A store shared between + // runners (module-level rather than per call) passes every other proof + // here; this is the one that fails it. + /** @type {Uuid} */ + const uuid = () => 'fixed' + const a = memoryRun(uuid) + const b = memoryRun(uuid) + const key = unwrap(await a(unwrapStep(create(1), errorSummary))) + const result = await b(read(key)).then( + () => undefined, + error => error, + ) + assert(result instanceof Error, result) + assertEq(result.message, 'memory key not found: fixed', result) + }, missingKeyThrows: async () => { /** @type {Key} */ const key = asNominal('missing') diff --git a/fjs/effects/node/todo/generic-operation-payload-erasure.md b/fjs/effects/node/todo/generic-operation-payload-erasure.md index 1cb0af668..3e4495062 100644 --- a/fjs/effects/node/todo/generic-operation-payload-erasure.md +++ b/fjs/effects/node/todo/generic-operation-payload-erasure.md @@ -6,19 +6,15 @@ ### Problem An `Operation` may declare a generic signature — `MemRead` is -`(key: Key) => OpResult`, `Sandbox` is `(f: () => SandboxResult) -=> …`, `CreateServer` is `(listener: RequestListener) => -…`. `Pr` reads the payload and the output off that signature with -`infer P` / `infer R`, and inference through a generic signature instantiates -its type parameter at the constraint. So the handler an operation map writes -receives `T = unknown`, `O = Operation`, and has to cast its way back: - -- `fjs/effects/node/virtual/module.f.mjs:627` — `sandbox: f => … ok(/** @type - {SandboxResult} */ (f()))`; without the cast, `f()` is `unknown` - and `Ok` is not `OpResult>`. -- `fjs/effects/node/module.mjs:438` — `answerRequest(/** @type {Erl} */ - (requestListener))`; without the cast, the payload is - `RequestListener` and `Operation` is not assignable to `NodeOp`. +`(key: Key) => OpResult`, `Sandbox` is +`(f: () => SandboxResult) => …`. `Pr` reads the payload and the +output off that signature with `infer P` / `infer R`, and inference through a +generic signature instantiates its type parameter at the constraint. So the +handler an operation map writes receives `T = unknown` and has to cast its way +back — `fjs/effects/node/virtual/module.f.mjs:627` is +`sandbox: f => … ok(/** @type {SandboxResult} */ (f()))`, and without +the cast `f()` is `unknown`, which `OpResult>` will not +take. The same erasure is why `fjs/effects/memory/module.f.mjs:34,39` cast the `do_('memCreate')` / `do_('memRead')` results back to their generic shapes. @@ -29,15 +25,31 @@ Key>`, and `Key` is not assignable to `Key` in the covariant result. `write` escapes only because `T` appears in its parameters alone, where `unknown` is the accepting side. -**This is not the same cause as the `asyncRun` casts that used to sit beside -them**, and the two were checked together before being split apart. That one -was inference of `O` from `ToAsyncOperationMap` at the call, and annotating -the runner's own result cured it — see "Prefer `@satisfies` over `@type` when -checking, not overriding" in [`fjs/AGENTS.md`](../../../AGENTS.md), and -`memoryRun` in [`../memory/module.mjs`](../memory/module.mjs). These two survive -that treatment: the loss happens inside `Pr`, before any call site has a say. -Deleting each cast and reading the compiler error is what separated them, and it -is the check to repeat on any further candidate. +### A neighbour that looks like this and is not + +`fjs/effects/node/module.mjs:438` — `answerRequest(/** @type {Erl} */ +(requestListener))` — was grouped here first, and does not belong: `CreateServer` +is **not** a generic operation. `../types.ts:235` declares it as +`['createServer', (listener: RequestListener) => OpResult]`, +with `Operation` written into the declaration, so `Pr` erases nothing — the +handler is handed the widest listener the type says it may be handed, and +narrowing it to `NodeOp` is what the cast does. The `` +signature it looks like it should have exists only on the effect constructor +(`../module.f.mjs:346`), which is itself a cast. + +That makes it a third cause, needing its own answer: whether `CreateServer` can +carry the listener's op-set instead of pinning `Operation`, and what the +constructor's cast is standing in for. Left here as the nearest home rather than +filed separately, because whoever takes `Pr` will read this file first. + +**Neither of these is the `asyncRun` cause**, which sat beside them and was +checked at the same time: inference of `O` from `ToAsyncOperationMap` at the +call, cured by annotating the runner's own result — see "Prefer `@satisfies` +over `@type` when checking, not overriding" in +[`fjs/AGENTS.md`](../../../AGENTS.md), and `memoryRun` in +[`../memory/module.mjs`](../memory/module.mjs). Deleting each cast and reading +the compiler error is what separated the three, and it is the check to repeat on +any further candidate. ### Proposal @@ -50,7 +62,7 @@ cannot and that a generic operation costs one cast per handler by construction. ### Related - [`todo/inline-type-casts.md`](../../../../todo/inline-type-casts.md) — the - audit that measured all four sites and asked for "its own issue against the - API it is papering over"; this is that issue for the generic ones. + audit that measured these sites and asked for "its own issue against the + API it is papering over"; this is that issue for them. - [`fjs/effects/types.ts`](../../types.ts) — `Pr`, `OperationMap`, `ToAsyncOperationMap`. From 0aab47b7a1e26aeb0a2b053376742702dd4b4b55 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 26 Aug 2026 11:40:23 +0000 Subject: [PATCH 4/4] effects/node/memory: pin run's per-call store; fix the Sandbox signature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 `(f: () => SandboxResult) => …`; types.ts:325 is `(f: () => T) => OpResult>`. 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 Claude-Session: https://claude.ai/code/session_01EYopL7TcTvXAUGanBJTxgP --- fjs/effects/node/memory/proof.mjs | 15 ++++++++++++++- .../todo/generic-operation-payload-erasure.md | 17 +++++++++++------ 2 files changed, 25 insertions(+), 7 deletions(-) diff --git a/fjs/effects/node/memory/proof.mjs b/fjs/effects/node/memory/proof.mjs index f96096ce8..6453027d6 100644 --- a/fjs/effects/node/memory/proof.mjs +++ b/fjs/effects/node/memory/proof.mjs @@ -9,7 +9,7 @@ import { errorSummary } from '../module.f.mjs' import { - asNominal, + asBase, asNominal, create, read, write, } from '../../memory/module.f.mjs' import { memoryRun, run } from './module.mjs' @@ -54,6 +54,19 @@ export const proof = { assert(result instanceof Error, result) assertEq(result.message, 'memory key not found: fixed', result) }, + runIsPerCall: async () => { + // `run` builds a runner per call, so the store the first call wrote to + // is gone by the second. One runner shared across every `run` — the + // other half of the store-ownership mutant — passes every other proof + // here, including `runnersDoNotShareStore`. + const key = unwrap(await run(unwrapStep(create(1), errorSummary))) + const result = await run(read(key)).then( + () => undefined, + error => error, + ) + assert(result instanceof Error, result) + assertEq(result.message, `memory key not found: ${asBase(key)}`, result) + }, missingKeyThrows: async () => { /** @type {Key} */ const key = asNominal('missing') diff --git a/fjs/effects/node/todo/generic-operation-payload-erasure.md b/fjs/effects/node/todo/generic-operation-payload-erasure.md index 3e4495062..8e0ba9bb6 100644 --- a/fjs/effects/node/todo/generic-operation-payload-erasure.md +++ b/fjs/effects/node/todo/generic-operation-payload-erasure.md @@ -6,16 +6,21 @@ ### Problem An `Operation` may declare a generic signature — `MemRead` is -`(key: Key) => OpResult`, `Sandbox` is -`(f: () => SandboxResult) => …`. `Pr` reads the payload and the -output off that signature with `infer P` / `infer R`, and inference through a -generic signature instantiates its type parameter at the constraint. So the -handler an operation map writes receives `T = unknown` and has to cast its way -back — `fjs/effects/node/virtual/module.f.mjs:627` is +`(key: Key) => OpResult`, and `Sandbox` (`../types.ts:325`) is +`(f: () => T) => OpResult>`. `Pr` reads the payload +and the output off that signature with `infer P` / `infer R`, and inference +through a generic signature instantiates its type parameter at the constraint. +So the handler an operation map writes receives `T = unknown` and has to cast +its way back — `fjs/effects/node/virtual/module.f.mjs:627` is `sandbox: f => … ok(/** @type {SandboxResult} */ (f()))`, and without the cast `f()` is `unknown`, which `OpResult>` will not take. +Note where `SandboxResult` sits: on the *output* side, wrapping what the handler +must produce, not inside `f`. That is the whole reason the cast is needed. Were +the payload `f: () => SandboxResult`, erasure would hand the handler a +`SandboxResult` already and there would be nothing to cast. + The same erasure is why `fjs/effects/memory/module.f.mjs:34,39` cast the `do_('memCreate')` / `do_('memRead')` results back to their generic shapes. `write`, three lines below them, is the same `do_` call written as an annotated