Skip to content
3 changes: 3 additions & 0 deletions changelog/unreleased/1753.md
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion fjs/effects/memory/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
36 changes: 35 additions & 1 deletion fjs/effects/module.f.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -82,14 +82,48 @@
* @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'
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]
Comment thread
sergey-shandar marked this conversation as resolved.

/**
* 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.
Expand Down
46 changes: 18 additions & 28 deletions fjs/effects/node/module.f.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
*
Expand All @@ -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]) =>
Expand Down
39 changes: 1 addition & 38 deletions fjs/effects/node/proof.f.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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' })))
Expand Down
67 changes: 11 additions & 56 deletions fjs/effects/node/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T> = Result<T, NotImplemented>

/**
* 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<T> = Result<T, IoChannel>
export type { IoChannel, IoError, IoErrorInfo, IoResult, OpResult }

// all

Expand Down
57 changes: 53 additions & 4 deletions fjs/effects/proof.f.mjs
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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`;
Expand Down
15 changes: 13 additions & 2 deletions fjs/effects/todo/io-effect-migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -303,12 +303,23 @@ from `Result<T, unknown>` — for one that performs host IO
(`Result<T, NotImplemented | IoError>`).

`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
Expand Down
Loading
Loading