Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions changelog/unreleased/1713.md
Original file line number Diff line number Diff line change
@@ -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
35 changes: 29 additions & 6 deletions fjs/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -526,12 +526,35 @@ check its argument. A cast around a big object literal passed to a
`ToAsyncOperationMap<O>`-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<O>` 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 {_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
`@type`.

#### Mutually recursive constants: cross-reference with `typeof`

Expand Down
42 changes: 38 additions & 4 deletions fjs/effects/node/memory/module.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,42 @@ export const memoryOperationMap = (uuid = randomUUID) => {
}

/**
* Runs a memory-only effect using a fresh memory store.
* @type {<T, E>(effect: Effect<MemOp, T, E>) => Promise<Result<T, E>>}
* An {@link asyncRun} runner over {@link MemOp}: an effect in, its `Result` out.
* @typedef {<T, E>(effect: Effect<MemOp, T, E>) => Promise<Result<T, E>>} MemoryRun
*/
export const run = effect =>
asyncRun(/** @type {ToAsyncOperationMap<MemOp>} */ (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<O>`, 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. 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))
Comment thread
sergey-shandar marked this conversation as resolved.

/**
* 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)
42 changes: 36 additions & 6 deletions fjs/effects/node/memory/proof.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,16 @@
*
* @module
*
* @import { Key, MemOp } from '../../memory/types.ts'
* @import { Key } from '../../memory/types.ts'
* @import { Uuid } from './module.mjs'
*/

import { asyncRun } from '../../module.mjs'
import { errorSummary } from '../module.f.mjs'
import {
asNominal,
asBase, 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'
Expand All @@ -28,15 +28,45 @@ export const proof = {
assert(r[0] === 'ok', r)
assertEq(r[1], 2)
},
reusedOperationMapPersists: async () => {
const runner = asyncRun(/** @type {import('../../types.ts').ToAsyncOperationMap<MemOp>} */ (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)))
await runner(write(key, 2))
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)
},
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<number>} */
const key = asNominal('missing')
Expand Down
38 changes: 0 additions & 38 deletions fjs/effects/node/todo/async-operation-map-assignability.md

This file was deleted.

73 changes: 73 additions & 0 deletions fjs/effects/node/todo/generic-operation-payload-erasure.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
# `Pr` erases a generic operation's type parameter

**Priority:** P3
**Status:** open

### Problem

An `Operation` may declare a generic signature — `MemRead` is
`<T>(key: Key<T>) => OpResult<T>`, and `Sandbox` (`../types.ts:325`) is
`<T>(f: () => T) => OpResult<SandboxResult<T>>`. `Pr<O, K>` 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<unknown>} */ (f()))`, and without
the cast `f()` is `unknown`, which `OpResult<SandboxResult<unknown>>` 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<T>`, erasure would hand the handler a
`SandboxResult<unknown>` 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
declaration and needs no cast — but the declaration form was tried on `create`
and does not help: `Func<MemCreate>` is `(value: unknown) => Effect<MemCreate,
Key<unknown>>`, and `Key<unknown>` is not assignable to `Key<T>` in the
covariant result. `write` escapes only because `T` appears in its parameters
alone, where `unknown` is the accepting side.

### A neighbour that looks like this and is not

`fjs/effects/node/module.mjs:438` — `answerRequest(/** @type {Erl<NodeOp>} */
(requestListener))` — was grouped here first, and does not belong: `CreateServer`
is **not** a generic operation. `../types.ts:235` declares it as
`['createServer', (listener: RequestListener<Operation>) => OpResult<Server>]`,
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 `<O extends Operation>`
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<O>` 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

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<O, K>[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 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`.
Loading