diff --git a/changelog/unreleased/1753.md b/changelog/unreleased/1753.md new file mode 100644 index 000000000..1aa8fe757 --- /dev/null +++ b/changelog/unreleased/1753.md @@ -0,0 +1,3 @@ +- `effects`: `OpResult`, `IoChannel`, `IoError`, `IoErrorInfo`, `IoResult` and + the `ioError` / `toIoError` constructors are now importable from the core + module, not only through `effects/node`, which re-exports them unchanged diff --git a/fjs/effects/memory/types.ts b/fjs/effects/memory/types.ts index 844dbb80c..9d3fb2834 100644 --- a/fjs/effects/memory/types.ts +++ b/fjs/effects/memory/types.ts @@ -6,7 +6,7 @@ import type { Phantom } from '../../types/phantom/types.ts' import type { Nominal } from '../../types/nominal/types.ts' -import type { OpResult } from '../node/types.ts' +import type { OpResult } from '../types.ts' /** Nominal brand version for memory keys. */ export type _MemKeyHash = '3f114fa6036a8da026b827f0c3e6d901f5e81ad9a320e431ccce31451892d286' diff --git a/fjs/effects/module.f.mjs b/fjs/effects/module.f.mjs index 382e75340..1440876a1 100644 --- a/fjs/effects/module.f.mjs +++ b/fjs/effects/module.f.mjs @@ -82,7 +82,7 @@ * @import { Fold } from '../types/function/operator/types.ts' * @import { Option } from '../types/option/types.ts' * @import { Result } from '../types/result/types.ts' - * @import { Commands, Effect, ErrOf, Func, MatchResult, NotImplemented, OkOf, Operation, OperationMap, PartialOperationMap } from './types.ts' + * @import { Commands, Effect, ErrOf, Func, IoChannel, IoError, IoErrorInfo, MatchResult, NotImplemented, OkOf, Operation, OperationMap, PartialOperationMap } from './types.ts' */ import { assert } from '../asserts/module.f.mjs' @@ -90,6 +90,40 @@ import { fold } from '../types/list/module.f.mjs' import { error, mapOk, ok } from '../types/result/module.f.mjs' import { at } from '../types/object/module.f.mjs' +/** + * Builds a normalized host error. The constructor exists so the shape is + * written once: every runner reports its failures through it, and a consumer + * matching on `'ioError'` knows what the payload holds. + * + * @type {(info: IoErrorInfo) => IoError} + */ +export const ioError = info => ['ioError', info] + +/** + * Normalizes a **thrown** value into an {@link IoError}: the OS error code when + * the host attached a string one, and a message that is the `Error`'s own or + * the value's string form. + * + * This is the boundary where an impure runner's `catch` becomes ordinary effect + * data. Nothing past it sees the thrown object, which is the point — a stack, a + * `cause`, and arbitrary own properties do not survive a wire hop, and a + * program that branched on them would be reading the host's implementation + * rather than the operation's contract. + * + * The `code` convention is node's in origin and not node's in reach: a browser + * `DOMException` carries a string `name` and not a `code`, so it normalizes + * through the message branch — correctly, since there is no OS code to report. + * + * @type {(e: unknown) => IoError} + */ +export const toIoError = e => { + const message = e instanceof Error ? e.message : String(e) + if (typeof e !== 'object' || e === null || !('code' in e) || typeof e.code !== 'string') { + return ioError({ message }) + } + return ioError({ code: e.code, message }) +} + /** * Lifts an already-computed {@link Result} into an effect that performs no * command. diff --git a/fjs/effects/node/module.f.mjs b/fjs/effects/node/module.f.mjs index 5d1c6dddd..e3bd8e3f6 100644 --- a/fjs/effects/node/module.f.mjs +++ b/fjs/effects/node/module.f.mjs @@ -23,19 +23,26 @@ import { codePointListToString } from '../../text/utf16/module.f.mjs' import { reverse } from '../../types/list/module.f.mjs' import { length } from '../../types/bit_vec/module.f.mjs' import { error as resultError, ok as resultOk, unwrap } from '../../types/result/module.f.mjs' -import { do_, pure } from '../module.f.mjs' +import { do_, ioError, pure, toIoError } from '../module.f.mjs' import { mapStep as ioMapStep, pureError, pureOk, resultMapStep, resultStep, step as ioStep, } from '../module.f.mjs' /** - * Builds a normalized host error. The constructor exists so the shape is - * written once: every runner reports its failures through it, and a consumer - * matching on `'ioError'` knows what the payload holds. - * - * @type {(info: IoErrorInfo) => IoError} + * `ioError` and `toIoError` are declared in + * [`../module.f.mjs`](../module.f.mjs) beside the effect representation, + * because neither is node's: normalizing a thrown value into serializable + * effect data is what any host's interpreter does at its `catch`. They are + * re-exported here so the modules that reach for them through the node module + * keep working, and so an operation's declaration and its failure constructor + * still read as one vocabulary. + * + * {@link isNotFound} stayed, and the difference is the test for where any of + * this belongs: it reads `ENOENT`, a POSIX filesystem code that no browser + * ever reports. Being about a *host failure* does not make a thing + * host-agnostic — being about no host in particular does. */ -export const ioError = info => ['ioError', info] +export { ioError, toIoError } /** * The host a {@link Listen} refuses. @@ -83,27 +90,6 @@ export const emptyHostError = ioError({ message: emptyHostMessage, }) -/** - * Normalizes a **thrown** value into an {@link IoError}: the OS error code when - * the host attached a string one, and a message that is the `Error`'s own or - * the value's string form. - * - * This is the boundary where an impure runner's `catch` becomes ordinary effect - * data. Nothing past it sees the thrown object, which is the point — a stack, a - * `cause`, and arbitrary own properties do not survive a wire hop, and a - * program that branched on them would be reading the host's implementation - * rather than the operation's contract. - * - * @type {(e: unknown) => IoError} - */ -export const toIoError = e => { - const message = e instanceof Error ? e.message : String(e) - if (typeof e !== 'object' || e === null || !('code' in e) || typeof e.code !== 'string') { - return ioError({ message }) - } - return ioError({ code: e.code, message }) -} - /** * True if `e` is a "file or directory does not exist" (`ENOENT`) error. * @@ -118,6 +104,10 @@ export const toIoError = e => { * collapse into one benign branch — which is exactly what a bare `unknown` * error channel used to allow. * + * **It belongs to this layer, unlike the constructors above.** `ENOENT` is a + * POSIX filesystem code; a host without a filesystem never reports one, so a + * shared `isNotFound` would be a node predicate wearing a host-agnostic name. + * * @type {(e: IoChannel) => boolean} */ export const isNotFound = ([tag, payload]) => diff --git a/fjs/effects/node/proof.f.mjs b/fjs/effects/node/proof.f.mjs index e04035da3..bd094f375 100644 --- a/fjs/effects/node/proof.f.mjs +++ b/fjs/effects/node/proof.f.mjs @@ -10,7 +10,7 @@ import { empty, isVec, uint, vec, vec8 } from "../../types/bit_vec/module.f.mjs" import { utf8, utf8ToString } from "../../text/module.f.mjs" import { match } from "../module.f.mjs" import { mapStep, step as ioStep } from "../module.f.mjs" -import { both, errorMessage, errorSummary, exitStep, fetch, ioError, isNotFound, mkdir, now, readdir, readFile, readUtf8File, rm, sandbox, toIoError, writeFile, writeUtf8File, rename, readBytes, randomInt, writeFromStream, usesInlineTestContext, versionLessThan } from "./module.f.mjs" +import { both, errorMessage, errorSummary, exitStep, fetch, ioError, isNotFound, mkdir, now, readdir, readFile, readUtf8File, rm, sandbox, writeFile, writeUtf8File, rename, readBytes, randomInt, writeFromStream, usesInlineTestContext, versionLessThan } from "./module.f.mjs" import { create as memCreate, read as memRead, write as memWrite } from "../memory/module.f.mjs" import { empty as listEmpty, nonEmpty as listNonEmpty } from "../list/module.f.mjs" import { emptyState, virtual } from "./virtual/module.f.mjs" @@ -50,43 +50,6 @@ const assertOk = (r, expected) => { } export const proof = { - // The one boundary where a runner's `catch` becomes effect data: whatever - // was thrown is reduced to a code (when the host attached a string one) - // and a message. - toIoError: { - error: () => { - assertIoMessage(toIoError(new Error('boom')), 'boom') - }, - withCode: () => { - const e = toIoError(Object.assign(new Error('missing'), { code: 'ENOENT' })) - assert(e[0] === 'ioError', e) - assertEq(e[1].code, 'ENOENT', e) - assertEq(e[1].message, 'missing', e) - }, - // A thrown non-`Error` still normalizes: the value's string form is the - // message, and there is no code to carry. - string: () => { - const e = toIoError('plain') - assert(e[0] === 'ioError', e) - assertEq(e[1].code, undefined, e) - assertEq(e[1].message, 'plain', e) - }, - null: () => { - assertIoMessage(toIoError(null), 'null') - }, - // An object whose `code` is not a string is not an OS error code, so it - // is dropped rather than carried as one. - nonStringCode: () => { - const e = toIoError({ code: 42 }) - assert(e[0] === 'ioError', e) - assertEq(e[1].code, undefined, e) - }, - noCode: () => { - const e = toIoError({}) - assert(e[0] === 'ioError', e) - assertEq(e[1].code, undefined, e) - }, - }, isNotFound: { enoent: () => { assert(isNotFound(ioError({ code: 'ENOENT', message: 'no such file or directory' }))) diff --git a/fjs/effects/node/types.ts b/fjs/effects/node/types.ts index 946b2d393..2855658a0 100644 --- a/fjs/effects/node/types.ts +++ b/fjs/effects/node/types.ts @@ -10,66 +10,21 @@ import type { MemOp } from '../memory/types.ts' import type { Nominal } from '../../types/nominal/types.ts' import type { Result } from '../../types/result/types.ts' import type { StringMap } from '../../types/object/types.ts' -import type { Effect, NotImplemented, Operation, ToAsyncOperationMap } from '../types.ts' +import type { + Effect, IoChannel, IoError, IoErrorInfo, IoResult, NotImplemented, OpResult, + Operation, ToAsyncOperationMap, +} from '../types.ts' import type { List } from '../list/types.ts' /** - * A host failure, normalized: whatever the runtime threw reduced to a - * serializable record. `code` is the OS error code when the host supplied one - * (`'ENOENT'`, `'EEXIST'`), absent otherwise. - * - * It is a tagged tuple for the same reason {@link NotImplemented} is — the two - * share an error channel, and the tag is what tells them apart. That - * distinction is the whole reason this type exists: with a bare `unknown` - * error, `NotImplemented | unknown` collapses to `unknown` and a program can no - * longer tell "this runner cannot do it" from "the host tried and failed". - * - * Normalizing also keeps the channel serializable. A thrown `Error` carries a - * stack, a `cause`, and arbitrary own properties; none of it survives a wire - * hop, and a runner in another process could not reproduce it. - */ -export type IoError = readonly['ioError', IoErrorInfo] - -export type IoErrorInfo = { - readonly code?: string - readonly message: string -} - -/** - * The result of an operation with no failures of its own: it either produces - * its value or reports that the runner does not implement it. - * - * Every operation's return type is a `Result`, including the ones that cannot - * fail on their own terms — an operation left on a raw contract would be a hole - * in the error channel, and a runner may omit a handler for any of them. - */ -export type OpResult = Result - -/** - * The error channel of anything that performs host IO: a normalized host - * failure, or the report that the runner does not implement the operation. - * - * It is one name rather than a union spelled at each site, and that is a - * migration property rather than brevity. An effect that does no IO *yet* is - * one added `readFile` away from doing some, and if each signature names its - * own errors, that one change walks up every enclosing signature — the failure - * mode that sank `throws` clauses elsewhere, where engineers eventually - * declared everything throwing rather than maintain the cascade. Declaring the - * standard channel once is that concession made deliberately: an IO-touching - * effect says it fails *the way node IO fails*, and gaining a new way to do so - * changes nothing above it. - * - * It is not a licence to widen. An operation with failures of its own extends - * the channel (`IoChannel | ParseError`), and a computation whose errors are - * genuinely narrower should say so — this is the default for IO, not a ceiling. - */ -export type IoChannel = NotImplemented | IoError - -/** - * The result of an operation that performs host IO: its value, a normalized - * host failure, or the missing-handler report. + * The vocabulary every operation is declared in — how a runner reports that it + * cannot dispatch, and how a host reports that it tried and failed — now lives + * in [`../types.ts`](../types.ts), beside {@link NotImplemented}, because none + * of it is node's. It is re-exported here so that the several dozen modules + * naming these through the node module keep doing so, and so a signature can go + * on reading as one vocabulary rather than two. */ -export type IoResult = Result +export type { IoChannel, IoError, IoErrorInfo, IoResult, OpResult } // all diff --git a/fjs/effects/proof.f.mjs b/fjs/effects/proof.f.mjs index 0f095b347..4b7e376d5 100644 --- a/fjs/effects/proof.f.mjs +++ b/fjs/effects/proof.f.mjs @@ -1,14 +1,14 @@ /** * @import { Assert } from '../asserts/types.ts' - * @import { Effect, Func, NotImplemented, Operation } from './types.ts' + * @import { Effect, Func, IoChannel, NotImplemented, Operation } from './types.ts' * @import { Result } from '../types/result/types.ts' * @import { Equal } from '../types/ts/types.ts' */ import { - catchStep, do_, foldStep, forEachStep, history, historyStep, mapStep, match, - partialMatch, pure, pureError, pureOk, resultMapStep, resultStep, runPure, step, - unwrapStep, + catchStep, do_, foldStep, forEachStep, history, historyStep, mapStep, + match, partialMatch, pure, pureError, pureOk, resultMapStep, resultStep, + runPure, step, toIoError, unwrapStep, } from './module.f.mjs' import { error, ok } from '../types/result/module.f.mjs' import { assert, assertEq, todo } from '../asserts/module.f.mjs' @@ -172,7 +172,56 @@ const checked = v => { */ const show = e => `${e}` +/** + * Asserts that a channel error is a host failure carrying `message`. Every + * runner reports through the same normalized `IoError`, so a proof names the + * message rather than the shape. + * + * @type {(e: IoChannel, message: string) => void} + */ +const assertIoMessage = (e, message) => { + assert(e[0] === 'ioError', e) + assertEq(e[1].message, message) +} + export const proof = { + // The one boundary where a runner's `catch` becomes effect data: whatever + // was thrown is reduced to a code (when the host attached a string one) + // and a message. + toIoError: { + error: () => { + assertIoMessage(toIoError(new Error('boom')), 'boom') + }, + withCode: () => { + const e = toIoError(Object.assign(new Error('missing'), { code: 'ENOENT' })) + assert(e[0] === 'ioError', e) + assertEq(e[1].code, 'ENOENT', e) + assertEq(e[1].message, 'missing', e) + }, + // A thrown non-`Error` still normalizes: the value's string form is the + // message, and there is no code to carry. + string: () => { + const e = toIoError('plain') + assert(e[0] === 'ioError', e) + assertEq(e[1].code, undefined, e) + assertEq(e[1].message, 'plain', e) + }, + null: () => { + assertIoMessage(toIoError(null), 'null') + }, + // An object whose `code` is not a string is not an OS error code, so it + // is dropped rather than carried as one. + nonStringCode: () => { + const e = toIoError({ code: 42 }) + assert(e[0] === 'ioError', e) + assertEq(e[1].code, undefined, e) + }, + noCode: () => { + const e = toIoError({}) + assert(e[0] === 'ioError', e) + assertEq(e[1].code, undefined, e) + }, + }, /** * Every combinator's signature, pinned at a concrete instantiation. These * verify `./module.f.mjs`, so they live here rather than in `./types.ts`; diff --git a/fjs/effects/todo/io-effect-migration.md b/fjs/effects/todo/io-effect-migration.md index ad398885d..5ff901367 100644 --- a/fjs/effects/todo/io-effect-migration.md +++ b/fjs/effects/todo/io-effect-migration.md @@ -303,12 +303,23 @@ from `Result` — for one that performs host IO (`Result`). `IoError` is `readonly['ioError', { code?, message }]`, a tagged tuple beside -`NotImplemented` so the shared channel stays discriminable, in -`fjs/effects/node/types.ts` beside the operations it belongs to. `toIoError` +`NotImplemented` so the shared channel stays discriminable. `toIoError` normalizes a thrown host value at the one boundary where an impure runner catches; the virtual runner reports the same shape, so a proof against the virtual filesystem stays evidence about the real one. +**Both, and the two aliases above, have since moved to `fjs/effects/types.ts`.** +This migration put them in `fjs/effects/node/types.ts`, "beside the operations +they belong to", which was true while node's were the only operations there +were. What overturned it is a second host: `effects/memory` — no host at +all — was importing `OpResult` from the node module, and a browser +interpreter could not declare an operation without doing the same. `effects/node` +re-exports all of them, so nothing this record describes about their *shape* or +their use has changed. `isNotFound` stayed behind, being about `ENOENT` +specifically. See +[node-module-layering](./node-module-layering.md), which owns that question +now. + **`Write` and `Read` stayed `OpResult`.** They are host IO and could fail (`EPIPE`), but this stage's rule for a currently-infallible handler is to wrap its output in `ok(...)`, not to invent a failure it never reported. Promoting diff --git a/fjs/effects/todo/node-module-layering.md b/fjs/effects/todo/node-module-layering.md index 5c4821075..81acbb6f2 100644 --- a/fjs/effects/todo/node-module-layering.md +++ b/fjs/effects/todo/node-module-layering.md @@ -54,7 +54,9 @@ provides*. Proposed destinations: | `fjs/effects/sandbox/module.f.mjs` | `Sandbox`, `SandboxResult`, `sandbox`, `Await`, `awaitIfPromise` — the "run foreign code and observe what happened" pair | | `fjs/effects/console/module.f.mjs` | `Read`, `Write`, `ReadConsoles`, `WriteConsoles`, `Console`, `log`, `error`, `readLine`, `errorExit`, and a **new named `Std`** (see below) | | `fjs/effects/test/module.f.mjs` | `Test`, `TestFn`, `TestContext`, `test` — registration with an external framework, not I/O | -| stays in `fjs/effects/node` | `Fs` and its members, `Http`, `Fetch`, `Import`, `Forever`, `Now`, `RandomInt`, `IoResult`, `isNotFound`, `Env`, `Engine`, `NodeOp`, `NodeProgramOptions`, `Program`, `NodeProgram`, `NodeOperationMap` | +| stays in `fjs/effects/node` | `Fs` and its members, `Http`, `Forever`, `RandomInt`, `isNotFound`, `Env`, `Engine`, `NodeOp`, `NodeProgramOptions`, `Program`, `NodeProgram`, `NodeOperationMap` | +| unsettled | `Now`, `Fetch`, `Import` — this issue and share-browser-console-runner's step 4 disagree; step 5 decides (see the judgement call below) | +| already moved to `fjs/effects` | `OpResult`, `IoChannel`, `IoError`, `IoErrorInfo`, `IoResult`, `ioError`, `toIoError` — the vocabulary every operation is declared in; `effects/node` re-exports them (see the judgement call below) | `NodeOp` stays where it is and keeps unioning every family — it is the *runner's* op-set, which is legitimately "everything this host can do", and both @@ -64,37 +66,57 @@ union that names them all does not. Judgement calls worth deciding explicitly rather than by accident: -- **`Now` / `RandomInt` stay.** They are ambient host capabilities with no - cross-runtime abstraction to gain, and no consumer outside `fjs/cas` and the - interpreters. Moving them would be motion without a reader benefit. -- **`isNotFound` stays.** It encodes Node's `ENOENT` shape specifically; that - *is* a Node concern. -- **`IoResult` stays too — pure consumers should stop importing it instead.** - An earlier draft of this issue moved it to the effects core, on the reasoning - that core already imports `Result` so the move costs no new dependency. That - reasoning picks a destination by convenience rather than by concern, and the - destination is wrong on its own terms: `Result` is not an effect - constructor or combinator, so moving it would swap Node coupling for - core-effects coupling and leave a non-effect type in the effects core. - `fjs/types/result` is not the answer either — the *name* is about the host I/O - boundary ("the error is whatever the host threw"), and a generic types module - should not mint I/O vocabulary. - - Read the other way, `IoResult` is exactly a Node-layer contract and belongs - beside the operations it describes. The fix for a **pure** consumer is to - spell the underlying type, not to relocate the alias: - `fjs/media/type/module.f.mjs:45` imports `IoResult` from - `../../effects/node/types.ts` purely to write `IoResult` and - `IoResult`; writing `Result` from - `fjs/types/result` says the same thing and drops the `effects/node` import - **entirely**, which is a better outcome than moving where it points. - [fold-stream-combinator](./fold-stream-combinator.md) reached the same - conclusion independently for `fjs/effects/list` — its `Result`-spelled - signature is the right design, not the workaround that issue calls it. - - This is an independent, one-site cleanup: it neither depends on nor supports - the moves below. Listed here because that is where the wrong answer was - written down; it can land on its own. +- **`RandomInt` stays.** An ambient host capability with no cross-runtime + abstraction to gain and no consumer outside `fjs/cas` and the interpreters. + Moving it would be motion without a reader benefit. +- **`Now`, `Fetch` and `Import` are unsettled, and step 5 of + [share-browser-console-runner](../../emergent_testing/todo/share-browser-console-runner.md) + decides them.** This issue put all three in the "stays" row on the reader-benefit + argument above; that issue's step 4 lists `now`, `fetch` and `import` among the + operations to move. Both were written without the fact that settles it — **which + operations a browser interpreter actually implements** — so neither ruling is + authoritative and the disagreement is recorded here rather than resolved by + whichever file a later reader opens first. + + The test to apply is the one `isNotFound` failed: not "does a browser also + have one of these", but "is this operation about no host in particular". By + that test `now` and `import` look likely to move — a browser proof run needs a + clock and dynamic import, so step 5 gives them a second implementer — and + `fetch` looks likely to stay, since nothing in the shared runner performs one + and DESIGN.md §4 extracts at the second *real* consumer, not the second + possible one. Those are expectations, not rulings: whichever way step 5 goes, + it updates both files in the same change. +- **`isNotFound` stays, and this was tested.** It encodes `ENOENT` + specifically — a POSIX filesystem code that a host without a filesystem never + reports — so it *is* a Node-layer concern. A change that moved it to the core + along with the error vocabulary was reviewed against this line and reverted + on it: being about a *host failure* does not make a thing host-agnostic, + being about no host in particular does. +- **`IoResult` moved to the effects core, and this issue was wrong to say it + should not.** The reasoning here was that `Result` is not an + effect constructor or combinator, so the core is the wrong home and pure + consumers should spell the underlying type instead. What that reasoning did + not have was a **second host**. `IoResult` is not "exactly a Node-layer + contract": it is the shape every host's IO operations answer in, and a + browser interpreter cannot declare `fetch` or `import` without it. The same + goes for `OpResult`, `IoChannel`, `IoError` and `IoErrorInfo`, which this + issue never listed — `OpResult` is `Result`, defined + purely in terms of a type the core already owns, and `effects/memory` (no + host at all) was importing it from `effects/node`. + + The "not an effect constructor or combinator" test also did not describe the + file it was applied to: `NotImplemented` already lives in the core and is + neither. What the core holds is the vocabulary an operation is *declared* + in, and this is that. + + The one-site cleanup this bullet also proposed is still worth doing and is + now the task below: `fjs/media/type/module.f.mjs` imports `IoResult` only to + spell two signatures, and `Result` from `fjs/types/result` says + the same thing without reaching into the effects package at all. + [fold-stream-combinator](./fold-stream-combinator.md) reached that conclusion + independently for `fjs/effects/list`. That a pure consumer should not name an + IO alias and that a *second host* needs one to exist somewhere shared are + both true; the old bullet collapsed them into one answer. - **`Test` goes to an effects module, not to `fjs/emergent_testing`.** `emergent_testing` looks like the natural owner — it is the only consumer of `test` and the module that defines what a test *is* — but putting the @@ -177,6 +199,16 @@ Judgement calls worth deciding explicitly rather than by accident: - **Every move is a breaking change** to an import path. Per `AGENTS.md`, do one concern per PR, update every importer in the same PR, and prefix the CHANGELOG entry with `**BREAKING CHANGES:**`. Do not leave re-export shims behind. + + **The vocabulary move is the one exception, and for a reason that does not + generalize.** A re-export is a shim when it keeps a *dead* coupling alive — + which is the case for every move in the table above, where the whole goal is + that `fjs/text/sgr` stops naming `effects/node` at all. It is not the case + for `IoChannel` and its siblings: node's own operations are declared in + them, so `effects/node` re-exporting what it genuinely uses keeps one + vocabulary readable at one import rather than preserving a coupling anyone + wants gone. That is why that move was additive and needed no importer churn, + and why the moves below still need theirs. - **The obsolete Playwright adapter is already gone.** This task must preserve only the process-side `TestContext` fields that still have consumers. It must not use relocation as a reason to revive the Playwright engine, context, @@ -191,9 +223,14 @@ Judgement calls worth deciding explicitly rather than by accident: ### Tasks +- [x] Move the operation vocabulary (`OpResult`, `IoChannel`, `IoError`, + `IoErrorInfo`, `IoResult`, `ioError`, `toIoError`) to `fjs/effects`, + with `effects/node` re-exporting it and `effects/memory` taking + `OpResult` from the core. `isNotFound` stayed — see the judgement calls. - [ ] Independent of the moves: replace `fjs/media/type`'s `IoResult` import with `Result` from `fjs/types/result`, dropping its - `effects/node` import. `IoResult` itself does **not** move. + `effects` import — a pure consumer should not name an IO alias, whichever + module the alias lives in. - [ ] Move `All` / `all` / `allOk` / `both` to `fjs/effects/all/module.f.mjs`. `allOk` is the ok-channel wrapper over `all` and belongs with it; [allvoid-combinator](./allvoid-combinator.md) builds on it, so leaving it diff --git a/fjs/effects/types.ts b/fjs/effects/types.ts index 8ea1f3a5b..974a02db1 100644 --- a/fjs/effects/types.ts +++ b/fjs/effects/types.ts @@ -18,11 +18,11 @@ import type { Equal } from '../types/ts/types.ts' * `error(notImplemented(command))` through the command's own output — so an * operation whose return admitted no error would be a hole in that mechanism: * there would be nowhere to put the refusal. Every *host* operation already - * returned `OpResult<…>` or `IoResult<…>` when this constraint was added, and - * so did four of the six declared inside proofs — through a bare `Result` - * rather than either alias, since those two are node conveniences. The two in - * `./proof.f.mjs` returned a bare `number`, and the commit that added the rule - * rewrote them. + * returned {@link OpResult} or {@link IoResult} when this constraint was added, + * and so did four of the six declared inside proofs — through a bare `Result` + * rather than either alias, which were then declared in `./node/types.ts` and + * so read as node conveniences. The two in `./proof.f.mjs` returned a bare + * `number`, and the commit that added the rule rewrote them. * * It is also the latch the whole representation now rests on. An operation * *cannot* be declared infallible, so every {@link Effect} built from one has a @@ -62,6 +62,71 @@ export type Operation = */ export type NotImplemented = readonly['notImplemented', string] +/** + * The result of an operation with no failures of its own: it either produces + * its value or reports that the runner does not implement it. + * + * Every operation's return type is a `Result`, including the ones that cannot + * fail on their own terms — an operation left on a raw contract would be a hole + * in the error channel, and a runner may omit a handler for any of them. + */ +export type OpResult = Result + +/** + * A host failure, normalized: whatever the runtime threw reduced to a + * serializable record. `code` is the OS error code when the host supplied one + * (`'ENOENT'`, `'EEXIST'`), absent otherwise. + * + * It is a tagged tuple for the same reason {@link NotImplemented} is — the two + * share an error channel, and the tag is what tells them apart. That + * distinction is the whole reason this type exists: with a bare `unknown` + * error, `NotImplemented | unknown` collapses to `unknown` and a program can no + * longer tell "this runner cannot do it" from "the host tried and failed". + * + * Normalizing also keeps the channel serializable. A thrown `Error` carries a + * stack, a `cause`, and arbitrary own properties; none of it survives a wire + * hop, and a runner in another process could not reproduce it. + */ +export type IoError = readonly['ioError', IoErrorInfo] + +export type IoErrorInfo = { + readonly code?: string + readonly message: string +} + +/** + * The error channel of anything that performs host IO: a normalized host + * failure, or the report that the runner does not implement the operation. + * + * It is one name rather than a union spelled at each site, and that is a + * migration property rather than brevity. An effect that does no IO *yet* is + * one added `readFile` away from doing some, and if each signature names its + * own errors, that one change walks up every enclosing signature — the failure + * mode that sank `throws` clauses elsewhere, where engineers eventually + * declared everything throwing rather than maintain the cascade. Declaring the + * standard channel once is that concession made deliberately: an IO-touching + * effect says it fails *the way host IO fails*, and gaining a new way to do so + * changes nothing above it. + * + * It is not a licence to widen. An operation with failures of its own extends + * the channel (`IoChannel | ParseError`), and a computation whose errors are + * genuinely narrower should say so — this is the default for IO, not a ceiling. + * + * **It is not node's, though it was declared there.** Nothing in either half + * names a host: a runner that cannot dispatch and a host that tried and failed + * are the two ways any operation goes wrong, on any host. Living in + * `./node/types.ts` meant that `./memory/types.ts` — which has no host at + * all — reached into the node module for {@link OpResult}, and that a second + * host's operations could not be typed without doing the same. + */ +export type IoChannel = NotImplemented | IoError + +/** + * The result of an operation that performs host IO: its value, a normalized + * host failure, or the missing-handler report. + */ +export type IoResult = Result + /** * An `Effect` is the raw value: a {@link Pure} thunk yielding * `Result`, or a {@link Do} node describing a command to perform. It is @@ -83,8 +148,8 @@ export type NotImplemented = readonly['notImplemented', string] * **`E` defaults to {@link NotImplemented}**, the one error every operation can * answer with, so the common case is written `Effect`. An * operation's own failures extend the channel — `Effect`, that alias being the node standard of - * `NotImplemented | IoError`. + * IoChannel>`, that alias being the standard {@link IoChannel} of + * `NotImplemented | IoError` that any host's IO answers in. * * **`Effect` is a claim, not an absence.** It says this code * absorbs its own failures *here* — an MCP handler turning one into a JSON-RPC diff --git a/fjs/emergent_testing/todo/share-browser-console-runner.md b/fjs/emergent_testing/todo/share-browser-console-runner.md index 0bb159d66..1f6691eba 100644 --- a/fjs/emergent_testing/todo/share-browser-console-runner.md +++ b/fjs/emergent_testing/todo/share-browser-console-runner.md @@ -126,11 +126,61 @@ and is reviewable without the next one. rule they rest on is in [browser testing](browser-testing.md). - [ ] **4. Common effects.** Move the host-independent operations (`all`, - `await`, `fetch`, `import`, `now`, `sandbox`) out of `effects/node` into a - shared module that `effects/node` re-exports unchanged, so nothing has to - move with them. + `await`, `sandbox`, and whichever of `now`, `fetch` and `import` survive + the test below) out of `effects/node` into a shared module that + `effects/node` re-exports unchanged, so nothing has to move with them. + + **Three of that list are unsettled, and this step does not get to assume + them.** `all`, `await` and `sandbox` are agreed: + [node-module-layering](../../effects/todo/node-module-layering.md) moves + them too. But that issue keeps `Now`, `Fetch` and `Import` in + `effects/node` on a reader-benefit argument, and this step was written + listing all three as moving. Neither was written knowing the fact that + decides it — which operations the step-5 interpreter actually implements — + so step 5 settles them and updates both files in the same change. The + expectation recorded there: `now` and `import` move (a browser proof run + needs a clock and dynamic import), `fetch` stays (nothing in the shared + runner performs one, and DESIGN.md §4 extracts at the second *real* + consumer). + + **The vocabulary went first, and it was not speculative.** Before an + operation can move, the types it is *declared in* have to have a home: + `OpResult`, `IoError`, `IoErrorInfo`, `IoChannel`, `IoResult` and the + `ioError`/`toIoError` constructors were all in `effects/node`, and none + of them names a host — "the runner cannot dispatch" and "the host tried + and failed" are how *any* operation goes wrong. That misfiling already + had a victim: `effects/memory/types.ts`, which has no host at all, + imported `OpResult` from `../node/types.ts`. So that move is separation + of concerns with a consumer today + ([DESIGN.md §4](../../../DESIGN.md)), not an extraction on the promise of + one — which is the test the operations themselves have yet to pass, and + why they wait for step 5. `effects/node` re-exports every moved name, so + the several dozen modules that reach for them through it are untouched. + + **`isNotFound` stayed, and it is the boundary marker for this step.** It + reads `ENOENT`, a POSIX filesystem code a browser never reports, so it is + a node predicate however much it looks like the constructors beside it. + Being about a *host failure* does not make a thing host-agnostic; being + about no host in particular does. Apply that test to each operation below + rather than moving the list wholesale — + [node-module-layering](../../effects/todo/node-module-layering.md) is + where those rulings live, and it already declines to move `Now` and + `RandomInt` for a related reason. - [ ] **5. A browser interpreter** for exactly those operations, with no - scheduling policy of its own. + scheduling policy of its own. This is also what earns step 4's *operation* + move its second consumer: until a second host implements `sandbox`, + `await` and `all`, moving them out of `effects/node` makes nothing shorter + or clearer, and DESIGN.md §4 says to extract at the second real consumer + rather than before it. The two are therefore one design in two commits, + not one step deferred. + + **Its operation set is also the ruling** on `now`, `fetch` and `import`, + which step 4 and + [node-module-layering](../../effects/todo/node-module-layering.md) + currently disagree about. What this interpreter implements is what has a + second consumer; what it does not implement stays in `effects/node` until + something needs it. Write the answer into both files in this step's own + change, so neither is left asserting what the other denies. - [x] **6. One reporter.** The event stream — a leaf landed, a run ended — that both hosts subscribe to. Step 2 gave them the *value*; this gave them the seam it travels through. `Reporter.result` now receives the