diff --git a/changelog/unreleased/1607.md b/changelog/unreleased/1607.md new file mode 100644 index 0000000000..c80448ec3b --- /dev/null +++ b/changelog/unreleased/1607.md @@ -0,0 +1,10 @@ +- **BREAKING CHANGES:** `effects`: every operation's return type carries a + `Result`. Infallible operations answer `OpResult` + (`Result`); host IO answers `IoResult`, whose error is + now the structured `NotImplemented | IoError` rather than `unknown`. Runner + handlers wrap their output in `ok(...)`; `isNotFound` takes a channel error +- `effects/node`: new `IoError` / `IoErrorInfo` / `OpResult` types with + `ioError`, `toIoError`, `errorMessage` and `exitStep`, the `NodeProgram` + exit-code policy +- `effects/io`: new `unwrapStep`, which leaves the layer by panicking on the + error branch diff --git a/fjs/cas/cli/module.f.mjs b/fjs/cas/cli/module.f.mjs index 516e4879ce..bb75980e3e 100644 --- a/fjs/cas/cli/module.f.mjs +++ b/fjs/cas/cli/module.f.mjs @@ -12,7 +12,8 @@ import { sha256 } from '../../crypto/sha2/module.f.mjs' import { cBase32ToVec, vecToCBase32 } from '../../basen/cbase32/module.f.mjs' import { forEachStep, pure, step } from '../../effects/module.f.mjs' -import { errorExit, log, writeFromStream } from '../../effects/node/module.f.mjs' +import { errorExit, exitStep, log, writeFromStream } from '../../effects/node/module.f.mjs' +import { step as ioStep, unwrapStep } from '../../effects/io/module.f.mjs' import { dispatch } from '../../cli/module.f.mjs' import { casAddFile, fileCas } from '../module.f.mjs' @@ -26,15 +27,9 @@ export const commands = [ return errorExit("'cas add' expects one parameter") } const c = fileCas(sha256)(home) - return step( - casAddFile(c)(path), - hashResult => hashResult[0] === 'error' - ? pure(1) - : step( - log(vecToCBase32(hashResult[1])), - () => pure(0) - ) - ) + const added = casAddFile(c)(path) + const logged = ioStep(added, hash => log(vecToCBase32(hash))) + return exitStep(logged) }, }, { @@ -50,10 +45,7 @@ export const commands = [ } const c = fileCas(sha256)(home) const x = c.read(hash) - return step( - writeFromStream(path, x), - ([r, v]) => r === 'error' ? errorExit(`e: ` + String(v)) : pure(0), - ) + return exitStep(writeFromStream(path, x)) }, }, { @@ -61,7 +53,10 @@ export const commands = [ description: 'List all stored content hashes', handler: ({ home }) => { const c = fileCas(sha256)(home) - const x0 = forEachStep(c.list(), j => log(vecToCBase32(j))) + // A listing that cannot reach stdout has no useful fallback, and + // `forEachStep`'s `void` accumulator would otherwise discard each + // write's outcome silently. + const x0 = forEachStep(c.list(), j => unwrapStep(log(vecToCBase32(j)))) return step( x0, () => pure(0) diff --git a/fjs/cas/cli/proof.f.mjs b/fjs/cas/cli/proof.f.mjs index 7e1ddfdd9e..d06eb6553d 100644 --- a/fjs/cas/cli/proof.f.mjs +++ b/fjs/cas/cli/proof.f.mjs @@ -54,9 +54,10 @@ export const proof = { mainAddMissingFile: () => { // The source path doesn't exist, so `streamFile`'s first read comes back as an // error item; `write` fails closed with that error and the handler exits 1 - // without ever calling `log` — covers the `hashResult[0] === 'error'` branch. - const [, exitCode] = virtual(emptyState)(main(makeOptions(['add', 'missing']))) + // without ever calling `log` — covers `exitStep`'s error branch. + const [finalState, exitCode] = virtual(emptyState)(main(makeOptions(['add', 'missing']))) assertEq(exitCode, 1) + assertEq(finalState.stderr, 'no such file or directory\n', finalState.stderr) }, mainGetFound: () => { const content = vec8(0x2An) @@ -76,7 +77,10 @@ export const proof = { // use an empty store so the hash is not found const [finalState, exitCode] = virtual(emptyState)(main(makeOptions(['get', hashStr, 'output']))) assertEq(exitCode, 1, ['expected exit 1', exitCode]) - assert(finalState.stderr.length !== 0, 'expected error in stderr') + // The *message*, not just a non-empty line: the failure reaches the user + // as the host's own words. Asserting only that something was written is + // what let a stringified error tuple (`ioError,[object Object]`) pass. + assertEq(finalState.stderr, 'no such file or directory\n', finalState.stderr) }, mainGetWrongArgs: () => { const [finalState, exitCode] = virtual(emptyState)(main(makeOptions(['get']))) diff --git a/fjs/cas/evo/module.f.mjs b/fjs/cas/evo/module.f.mjs index 0a3a662c8b..216d1bb991 100644 --- a/fjs/cas/evo/module.f.mjs +++ b/fjs/cas/evo/module.f.mjs @@ -53,6 +53,7 @@ */ import { pure, foldStep, mapStep, step } from '../../effects/module.f.mjs' +import { unwrapStep } from '../../effects/io/module.f.mjs' import { create, read, write } from '../../effects/memory/module.f.mjs' import { collectRead } from '../module.f.mjs' import { cBase32ToVec, vecToCBase32 } from '../../basen/cbase32/module.f.mjs' @@ -229,13 +230,15 @@ export const buildCache = cas => * @returns {Effect>} */ export const initEvo = cas => - step(buildCache(cas), cache => create(cache)) + step(buildCache(cas), cache => unwrapStep(create(cache))) /** Reads, then rewrites, the cache at `cacheKey` with `revision` folded in at `hash`. * @type {(cacheKey: Key) => (hash: Hash) => (revision: Revision) => Effect} */ const foldIntoCache = cacheKey => hash => revision => - step(read(cacheKey), cache => write(cacheKey, addRevisionToCache(hash, revision)(cache))) + step( + unwrapStep(read(cacheKey)), + cache => unwrapStep(write(cacheKey, addRevisionToCache(hash, revision)(cache)))) /** * Folds `value` — bytes already written to a `Cas` at `hash` by some other @@ -564,11 +567,11 @@ export const evo = cas => cacheKey => ({ list: archived => { const listed = subjectListed(archived) return mapStep( - read(cacheKey), + unwrapStep(read(cacheKey)), cache => definedEntries(cache.bySubject) .flatMap(([subject, state]) => listed(state) ? [subject] : [])) }, - head: subject => mapStep(read(cacheKey), cache => { + head: subject => mapStep(unwrapStep(read(cacheKey)), cache => { const state = at(subject)(cache.bySubject) return state === null ? [] : headsOf(state) }), diff --git a/fjs/cas/evo/proof.f.mjs b/fjs/cas/evo/proof.f.mjs index 4125565b01..acc9d46670 100644 --- a/fjs/cas/evo/proof.f.mjs +++ b/fjs/cas/evo/proof.f.mjs @@ -12,6 +12,7 @@ import { assertStructurallySame, } from '../../asserts/module.f.mjs' import { pure } from '../../effects/module.f.mjs' +import { ioError } from '../../effects/node/module.f.mjs' import { fileCas } from '../module.f.mjs' import { sha256 } from '../../crypto/sha2/module.f.mjs' import { emptyState, virtual } from '../../effects/node/virtual/module.f.mjs' @@ -33,7 +34,7 @@ const home = '.' /** @type {Cas} */ const writeFailingCas = { read: () => elEmpty(), - write: () => pure(error('boom')), + write: () => pure(error(ioError({ message: 'boom' }))), list: () => pure([]), } @@ -42,8 +43,8 @@ const writeFailingCas = { // large for `collectRead` to buffer looks like to a caller. /** @type {Cas} */ const readFailingCas = { - read: () => nonEmpty(error('boom'), elEmpty()), - write: () => pure(error('write not supported')), + read: () => nonEmpty(error(ioError({ message: 'boom' })), elEmpty()), + write: () => pure(error(ioError({ message: 'write not supported' }))), list: () => pure([]), } @@ -56,10 +57,10 @@ const fixedCas = entries => ({ read: hash => { const found = entries.find(([h]) => vecToCBase32(h) === vecToCBase32(hash)) return found === undefined - ? nonEmpty(error('not found'), elEmpty()) + ? nonEmpty(error(ioError({ message: 'not found' })), elEmpty()) : nonEmpty(ok(found[1]), elEmpty()) }, - write: () => pure(error('write not supported')), + write: () => pure(error(ioError({ message: 'write not supported' }))), list: () => pure(entries.map(([h]) => h)), }) diff --git a/fjs/cas/module.f.mjs b/fjs/cas/module.f.mjs index be23582d25..e79964d26a 100644 --- a/fjs/cas/module.f.mjs +++ b/fjs/cas/module.f.mjs @@ -6,7 +6,9 @@ * @import { Sha2, State as Sha2State } from '../crypto/sha2/types.ts' * @import { Vec } from '../types/bit_vec/types.ts' * @import { Effect, Operation } from '../effects/types.ts' + * @import { NotImplemented } from '../effects/io/types.ts' * @import { + * IoError, * IoResult, * Now, * RandomInt, @@ -22,9 +24,11 @@ import { join, normalize, parse } from '../path/module.f.mjs' import { empty, length, maxLength, maxLengthBytes, msb, vec } from '../types/bit_vec/module.f.mjs' import { cBase32ToVec, vecToCBase32 } from '../basen/cbase32/module.f.mjs' import { foldStep, forEachStep, history, historyStep, mapStep, okStep, pure, step } from '../effects/module.f.mjs' +import { unwrapStep } from '../effects/io/module.f.mjs' import { access, createExclusive, + ioError, isNotFound, mkdir, now, @@ -78,7 +82,7 @@ export const collectRead = stream => { const [t, v] = first if (t === 'error') { return pure(first) } if (length(acc) + length(v) > maxLength) { - return pure(error(`cas blob exceeds maximum vector length of ${maxLength} bits`)) + return pure(error(ioError({ message: `cas blob exceeds maximum vector length of ${maxLength} bits` }))) } return loop(msb.concat(acc)(v))(tail) }) @@ -134,7 +138,7 @@ const gcStage = stageDir => { // link, so the timestamp is carried forward in a history rather than closed // over by a nested continuation. const listed = historyStep( - history(now()), + history(unwrapStep(now())), () => readdir(stageDir, {})) return step( listed, @@ -180,10 +184,10 @@ const writeImpl = (sha2, path, stageDir, payload) => { const stated = step(removed, () => stat(dst)) return mapStep( stated, - st => st[0] === 'ok' && st[1].size === offset ? ok(hash) : error('publish size mismatch')) + st => st[0] === 'ok' && st[1].size === offset ? ok(hash) : error(ioError({ message: 'publish size mismatch' }))) } // Any streaming error fails closed: delete the partial file, return the error. - /** @type {(curPath: string, e: unknown) => Effect>} */ + /** @type {(curPath: string, e: NotImplemented | IoError) => Effect>} */ const fail = (curPath, e) => mapStep(rm(curPath), () => error(e)) const rndEffect = step(gcStage(stageDir), () => random256) @@ -208,9 +212,9 @@ const writeImpl = (sha2, path, stageDir, payload) => { // Renew the lease: rename to a fresh deadline (keeps `delta` constant). // The new path is still needed after the rename, to recurse with, // so the rename captures it rather than closing over it. - const nextPath = step( - now(), - t => pure(join(stageDir, stageName(t + leaseDelta, rndStr)))) + const nextPath = mapStep( + unwrapStep(now()), + t => join(stageDir, stageName(t + leaseDelta, rndStr))) const renamed = historyStep( history(nextPath), next => rename(curPath, next)) @@ -222,7 +226,7 @@ const writeImpl = (sha2, path, stageDir, payload) => { : loop(newState, newOffset, next)(tail)) }) }) - const started = step(mkdir(stageDir, { recursive: true }), () => now()) + const started = step(mkdir(stageDir, { recursive: true }), () => unwrapStep(now())) return step(started, t0 => { const path0 = join(stageDir, stageName(t0 + leaseDelta, rndStr)) return step( @@ -294,7 +298,7 @@ const random256 = pure([0, 1, 2, 3, 4, 5, 6, 7]), empty, () => (/** @type {Vec} */ acc) => - mapStep(randomInt(), r => msb.concat(acc)(vec(32n)(BigInt(r))))) + mapStep(unwrapStep(randomInt()), r => msb.concat(acc)(vec(32n)(BigInt(r))))) /** Streams any file at `filePath` in `<=128 KiB` chunks as a `ListEffect` of `ok` items. * diff --git a/fjs/cas/proof.f.mjs b/fjs/cas/proof.f.mjs index ea8b2aa82f..92af112518 100644 --- a/fjs/cas/proof.f.mjs +++ b/fjs/cas/proof.f.mjs @@ -12,7 +12,7 @@ import { cBase32ToVec, vecToCBase32 } from '../basen/cbase32/module.f.mjs' import { computeSync, sha256 } from '../crypto/sha2/module.f.mjs' import { fileCas, casAddFile, collectRead } from './module.f.mjs' import { match, pure, runPure, step } from '../effects/module.f.mjs' -import { mkdir, writeFile, rm, readFile, access } from '../effects/node/module.f.mjs' +import { ioError, mkdir, writeFile, rm, readFile, access } from '../effects/node/module.f.mjs' import { error, ok } from '../types/result/module.f.mjs' import { emptyState, virtual } from '../effects/node/virtual/module.f.mjs' import { join } from '../path/module.f.mjs' @@ -48,8 +48,8 @@ const casCommand = match({ /** @type {(cmd: string) => unknown} */ const casDefaultResponse = cmd => { switch (cmd) { - case 'now': return 0 - case 'randomInt': return 0 + case 'now': return ok(0) + case 'randomInt': return ok(0) case 'mkdir': case 'createExclusive': case 'rename': case 'rm': case 'writeBytes': case 'access': return ok(undefined) @@ -117,6 +117,15 @@ const drive = overrides => { * result really is an `error` pair rather than assuming it: these proofs exist * to establish that a write fails closed, so the shape is the claim. */ +/** + * Asserts that a channel error is a host failure carrying `message`. + * @type {(e: unknown, message: string) => void} + */ +const assertIoMessage = (e, message) => { + assert(e instanceof Array && e[0] === 'ioError', ['expected an ioError', e]) + assertEq(e[1].message, message) +} + /** @type {(result: unknown) => unknown} */ const errorMessage = result => { assert(result instanceof Array && result.length === 2 && result[0] === 'error', @@ -298,7 +307,7 @@ export const proof = { /** @type {IoResult} */ const okItem = ok(vec8(0x11n)) /** @type {IoResult} */ - const errItem = error({ code: 'BOOM' }) + const errItem = error(ioError({ code: 'BOOM', message: 'boom' })) /** @type {List>} */ const payload = nonEmpty(okItem, nonEmpty(errItem, /** @satisfies {List>} */ (empty()))) const [state1, result] = virtual(emptyState)(c.write(payload)) @@ -382,8 +391,8 @@ export const proof = { const c = fileCas(sha256)('.') /** @type {List>} */ const payload = nonEmpty(ok(vec8(0x11n)), empty()) - const [result, log] = drive({ writeBytes: [error('disk full')] })(c.write(payload)) - assertEq(errorMessage(result), 'disk full') + const [result, log] = drive({ writeBytes: [error(ioError({ message: 'disk full' }))] })(c.write(payload)) + assertIoMessage(errorMessage(result), 'disk full') // The cleanup `rm` of the partial staging file must actually run, not just be // implied by the returned error tag. assertEq(log[log.length - 1], 'rm', ['expected cleanup rm to run', log]) @@ -394,8 +403,8 @@ export const proof = { const c = fileCas(sha256)('.') /** @type {List>} */ const payload = nonEmpty(ok(vec8(0x11n)), empty()) - const [result, log] = drive({ rename: [error('rename failed')] })(c.write(payload)) - assertEq(errorMessage(result), 'rename failed') + const [result, log] = drive({ rename: [error(ioError({ message: 'rename failed' }))] })(c.write(payload)) + assertIoMessage(errorMessage(result), 'rename failed') assertEq(log[log.length - 1], 'rm', ['expected cleanup rm to run', log]) }, casWritePublishSizeMismatchErrors: () => { @@ -410,7 +419,7 @@ export const proof = { /** @type {List>} */ const payload = nonEmpty(ok(vec8(0x11n)), empty()) const [result] = drive({ stat: [ok({ size: 999 })] })(c.write(payload)) - assertEq(errorMessage(result), 'publish size mismatch') + assertIoMessage(errorMessage(result), 'publish size mismatch') }, casWritePublishStatErrorErrorsEvenWithMatchingSize: () => { // Pins the tag half of the same check: a `stat` that fails outright must still @@ -423,7 +432,7 @@ export const proof = { /** @type {List>} */ const payload = nonEmpty(ok(vec8(0x11n)), empty()) const [result] = drive({ stat: [error({ size: 1 })] })(c.write(payload)) - assertEq(errorMessage(result), 'publish size mismatch') + assertIoMessage(errorMessage(result), 'publish size mismatch') }, collectReadDrainsChunks: () => { // The common path: every chunk is `ok`, so collectRead concatenates them all @@ -437,14 +446,14 @@ export const proof = { collectReadPropagatesErrorItem: () => { // An error item mid-stream short-circuits collectRead with that same error. /** @type {IoResult} */ - const boom = error('boom') + const boom = error(ioError({ message: 'boom' })) /** @type {List>} */ const stream = nonEmpty(ok(vec8(0x11n)), nonEmpty(boom, /** @satisfies {List>} */ (empty()))) const o = runPure(collectRead(stream)) assert(o.length === 1, 'expected collectRead to finish without issuing a command') const [r] = o assertEq(r[0], 'error') - assertEq(r[1], 'boom') + assertEq(r[1], boom[1]) }, // A single `Vec` cannot exceed `maxLength` bits — feed a pure stream whose second // chunk pushes the running total just over the limit so the overflow guard fires @@ -463,7 +472,7 @@ export const proof = { // A non-ENOENT `access` failure (permissions, corruption) is a genuine storage // error and must propagate out of `list`, not be swallowed as an empty store. const c = fileCas(sha256)('.') - const boom = { code: 'EACCES' } + const boom = ioError({ code: 'EACCES', message: 'permission denied' }) const r = casCommand(c.list()) assert(r[0] === 'cont', 'expected list() to issue an access command first') assertEq(r[1], 'access') diff --git a/fjs/ci/module.f.mjs b/fjs/ci/module.f.mjs index b213a63037..aad5cb5b93 100644 --- a/fjs/ci/module.f.mjs +++ b/fjs/ci/module.f.mjs @@ -14,6 +14,7 @@ import { mapStep, step } from '../effects/module.f.mjs' import { access, writeUtf8File } from '../effects/node/module.f.mjs' +import { unwrapStep } from '../effects/io/module.f.mjs' import { functionalscript, images } from './config/module.f.mjs' import { architecture, @@ -79,9 +80,11 @@ export const ci = ({ nodeExtra }) => step( }, jobs, } - const workflowWritten = writeUtf8File( + // A generator that cannot write its own workflow file has nothing to + // fall back on, so the failure is this program's panic. + const workflowWritten = unwrapStep(writeUtf8File( '.github/workflows/ci.yml', - JSON.stringify(gha, null, ' ')) + JSON.stringify(gha, null, ' '))) const flakesWritten = step(workflowWritten, () => nixFlakes(nixJobs)) return mapStep(flakesWritten, () => 0) }) diff --git a/fjs/ci/nix/module.f.mjs b/fjs/ci/nix/module.f.mjs index 366780b0da..83b215a57f 100644 --- a/fjs/ci/nix/module.f.mjs +++ b/fjs/ci/nix/module.f.mjs @@ -18,8 +18,9 @@ * @import { NixJob } from './types.ts' */ -import { forEachStep, mapStep, pure, step } from '../../effects/module.f.mjs' +import { forEachStep, pure, step } from '../../effects/module.f.mjs' import { mkdir, writeUtf8File } from '../../effects/node/module.f.mjs' +import { unwrapStep } from '../../effects/io/module.f.mjs' import { nixToString } from '../../media/nix/module.f.mjs' import { fromUndefined, unwrap as unwrapNullable } from '../../types/nullable/module.f.mjs' import { unwrap } from '../../types/result/module.f.mjs' @@ -75,11 +76,11 @@ export const flakeText = job => /** @type {(job: NixJob) => Effect} */ const writeFlake = job => { const directory = `${generatedDirectory}/${job.id}` - const created = mapStep(mkdir(directory, { recursive: true }), unwrap) + const created = unwrapStep(mkdir(directory, { recursive: true })) const written = step( created, () => writeUtf8File(`${directory}/flake.nix`, flakeText(job))) - return mapStep(written, unwrap) + return unwrapStep(written) } /** diff --git a/fjs/cli/module.f.mjs b/fjs/cli/module.f.mjs index 00fba8f1f5..3496b221dc 100644 --- a/fjs/cli/module.f.mjs +++ b/fjs/cli/module.f.mjs @@ -10,8 +10,7 @@ * @import { Commands } from './types.ts' */ -import { errorExit, log } from '../effects/node/module.f.mjs' -import { pure, step } from '../effects/module.f.mjs' +import { errorExit, exitStep, log } from '../effects/node/module.f.mjs' import { at, fromEntries } from '../types/object/module.f.mjs' const helpMeta = { names: ['help', 'h', '?'], description: 'Print this help message' } @@ -38,9 +37,7 @@ export const dispatch = commands => options => { return dispatch(targetCmd.handler)({ ...options, args: ['help'] }) } } - return step( - log(helpText), - () => pure(0)) + return exitStep(log(helpText)) } const found = at(cmd)(map) if (found === null) { diff --git a/fjs/dev/module.f.mjs b/fjs/dev/module.f.mjs index 2b4a64932f..882222e687 100644 --- a/fjs/dev/module.f.mjs +++ b/fjs/dev/module.f.mjs @@ -73,9 +73,12 @@ const allFiles = (s, predicate) => { } return all(...result) }) + // `all`'s own result is unwrapped like every other operation's in this + // module: a dev tool that cannot list a directory has no fallback, so + // the failure is a panic here rather than a value threaded upward. return step( x0, - v => pure(v.flat())) + v => pure(unwrap(v).flat())) } return load(s) } @@ -118,7 +121,7 @@ export const loadModuleMap = env => { return step( x0, entries => pure(fromEntries( - entries + unwrap(entries) .flat() .map(([k, v]) => /** @type {const} */ ([relativize(prefix, k), v])) .toSorted(([a], [b]) => strCmp(a)(b)) diff --git a/fjs/dev/update/module.f.mjs b/fjs/dev/update/module.f.mjs index 503f8d71b9..d964423b7f 100644 --- a/fjs/dev/update/module.f.mjs +++ b/fjs/dev/update/module.f.mjs @@ -9,7 +9,7 @@ import { history, historyStep, mapStep, step } from '../../effects/module.f.mjs' import { mkdir, readUtf8File, writeUtf8File } from '../../effects/node/module.f.mjs' -import { unwrap } from '../../types/result/module.f.mjs' +import { unwrapStep } from '../../effects/io/module.f.mjs' const source = /** @type {const} */ ('.copilot/mcp.json') const targetDirectory = /** @type {const} */ ('.vscode') @@ -21,12 +21,12 @@ const target = /** @type {const} */ ('.vscode/mcp.json') * @type {() => Effect} */ export const syncMcp = () => { - const sourceText = history(mapStep(readUtf8File(source), unwrap)) + const sourceText = history(unwrapStep(readUtf8File(source))) const targetDirectoryReady = historyStep( sourceText, - () => mapStep(mkdir(targetDirectory, { recursive: true }), unwrap)) + () => unwrapStep(mkdir(targetDirectory, { recursive: true }))) const targetWritten = step(targetDirectoryReady, ([, text]) => writeUtf8File(target, text)) - return mapStep(targetWritten, unwrap) + return unwrapStep(targetWritten) } /** diff --git a/fjs/djs/module.f.mjs b/fjs/djs/module.f.mjs index c8b251e42d..41dbe2e8f5 100644 --- a/fjs/djs/module.f.mjs +++ b/fjs/djs/module.f.mjs @@ -13,8 +13,8 @@ import { transpile } from './transpiler/module.f.mjs' import { stringify, stringifyAsTree } from './serializer/module.f.mjs' import { sort } from '../types/object/module.f.mjs' -import { pure, step } from '../effects/module.f.mjs' -import { writeUtf8File, error } from '../effects/node/module.f.mjs' +import { step } from '../effects/module.f.mjs' +import { errorExit, exitStep, writeUtf8File } from '../effects/node/module.f.mjs' /** @typedef {ReadFile | WriteFile | Write} _CompileOp */ @@ -30,9 +30,7 @@ import { writeUtf8File, error } from '../effects/node/module.f.mjs' */ export const compile = args => { if (args.length < 2) { - return step( - error('Error: Requires 2 or more arguments'), - () => pure(1)) + return errorExit('Error: Requires 2 or more arguments') } const inputFileName = args[0] const outputFileName = args[1] @@ -42,15 +40,11 @@ export const compile = args => { (result) => { if (result[0] === 'error') { const metadata = result[1].metadata - return step( - error(`${metadata?.path}:${metadata?.line}:${metadata?.column} - error: ${result[1].message}`), - () => pure(1)) + return errorExit(`${metadata?.path}:${metadata?.line}:${metadata?.column} - error: ${result[1].message}`) } const content = outputFileName.endsWith('.json') ? stringifyAsTree(sort)(result[1]) : stringify(sort)(result[1]) - return step( - writeUtf8File(outputFileName, content), - () => pure(0)) + return exitStep(writeUtf8File(outputFileName, content)) }) } diff --git a/fjs/effects/io/README.md b/fjs/effects/io/README.md index a7d0c731fa..e472b6a490 100644 --- a/fjs/effects/io/README.md +++ b/fjs/effects/io/README.md @@ -6,13 +6,13 @@ It is the **preferred high-level abstraction for fallible work**; the raw `Effect` remains the low-level representation both it and the raw combinators are built from. -This directory is stages 1 and 2 of the migration planned in -[`../todo/io-effect-migration.md`](../todo/io-effect-migration.md): the types -([`./types.ts`](./types.ts)) and the composition API -([`./module.f.mjs`](./module.f.mjs)). No operation, runner, or consumer produces -an `IoEffect` yet — stage 3 moves the `Result` envelope into the operations' -declared return types, and stage 4 migrates the consumers — so adopting the -layer is still additive. +This directory is the layer itself — the types ([`./types.ts`](./types.ts)) and +the composition API ([`./module.f.mjs`](./module.f.mjs)) — from the migration +planned in +[`../todo/io-effect-migration.md`](../todo/io-effect-migration.md). Every +operation now declares a `Result` return and every runner answers with one +(stage 3); what remains is migrating the consumers to compose with `step` / +`catchStep` / `resultStep` instead of stating a policy per site (stage 4). ## Why the layer exists @@ -76,9 +76,9 @@ shown it is. ### `pureOk` / `pureError`, not `ok` / `error` -The two lifts are the only way into the layer until stage 3 gives the -operations their `Result` envelope, so they are entry points rather than -speculative API. They are *not* spelled `ok` / `error`: those names are +The two lifts enter the layer from a plain value — the other way in is an +operation, which now declares a `Result` return of its own. They are *not* +spelled `ok` / `error`: those names are `fjs/types/result`'s, and a consumer that both builds bare `Result`s and lifts them — which is every consumer during the migration — would have to alias one pair at each import. `pure` is not free to shadow either; it is the raw lift, @@ -115,6 +115,24 @@ conflated in either direction — a capability the runner merely lacks is answer with `NotImplemented`, never by killing the program, and a refusal to continue is an interruption, never dressed up as `NotImplemented`. +## Leaving the layer + +Not every consumer is ready to compose. Two named policies exist so that a site +which has not adopted the layer still has to *say* what it does with a failure +rather than discard it: + +- `unwrapStep` (here) — panic on the error branch. It belongs where the caller + genuinely has no answer: a build tool that cannot read its own sources, a + reporter that cannot reach stdout. It is one greppable name rather than an + `unwrap` buried in a continuation, so the sites that have not yet chosen a + real policy are exactly the sites this name marks — and that is the worklist + stage 4 starts from. +- `exitStep` / `errorMessage` (`../node/module.f.mjs`) — a `NodeProgram`'s + exit-code policy: report the failure on `stderr` and exit `1`. + +Neither is composition, and neither should grow: a consumer that can do +something better with a failure wants `catchStep` or `resultStep`. + ## What is deliberately absent - **No `notImplemented` value.** Nothing produces this error until stage 6, diff --git a/fjs/effects/io/module.f.mjs b/fjs/effects/io/module.f.mjs index 146927ea65..85ca8338aa 100644 --- a/fjs/effects/io/module.f.mjs +++ b/fjs/effects/io/module.f.mjs @@ -42,11 +42,11 @@ * @module * * @import { Result } from '../../types/result/types.ts' - * @import { Operation } from '../types.ts' + * @import { Effect, Operation } from '../types.ts' * @import { IoEffect } from './types.ts' */ -import { error, mapOk, ok } from '../../types/result/module.f.mjs' +import { error, mapOk, ok, unwrap } from '../../types/result/module.f.mjs' import { mapStep as rawMapStep, okStep, pure, step as rawStep } from '../module.f.mjs' /** @@ -197,3 +197,23 @@ export const resultStep = rawStep * @type {(e: IoEffect, f: (t: T) => R) => IoEffect} */ export const mapStep = (e, f) => rawMapStep(e, mapOk(f)) + +/** + * Leaves the layer by **panicking** on the error branch: `ok` values continue + * as an ordinary raw `Effect`, an `error` is thrown. + * + * This is the program exercising its right to treat a failure as fatal, and it + * is a policy — not a conversion. It belongs at a site that genuinely has no + * answer to the failure: a build tool that cannot read its own sources, a + * proof whose fixture is missing. Where a caller could do something else, + * {@link catchStep} or {@link resultStep} is the honest spelling, and a chain + * that merely passes the failure along wants {@link step}. + * + * It is deliberately one greppable name rather than an `unwrap` buried in each + * continuation. Every occurrence is a site that has chosen to panic, so the + * choice can be reviewed, and the set of sites that have not yet chosen + * anything better is exactly the set this name marks. + * + * @type {(e: IoEffect) => Effect} + */ +export const unwrapStep = e => rawMapStep(e, unwrap) diff --git a/fjs/effects/io/proof.f.mjs b/fjs/effects/io/proof.f.mjs index 6fd2efe611..2f019c310c 100644 --- a/fjs/effects/io/proof.f.mjs +++ b/fjs/effects/io/proof.f.mjs @@ -7,7 +7,7 @@ import { assert, assertEq, todo } from '../../asserts/module.f.mjs' import { error, ok } from '../../types/result/module.f.mjs' import { do_, match, runPure } from '../module.f.mjs' -import { catchStep, mapStep, pureError, pureOk, resultStep, step } from './module.f.mjs' +import { catchStep, mapStep, pureError, pureOk, resultStep, step, unwrapStep } from './module.f.mjs' /** * A fallible operation, spelled the way stage 3 will spell every operation: the @@ -201,6 +201,23 @@ export const proof = { assertOk(run(e), 'error') }, }, + unwrapStep: { + // An `ok` leaves the layer as an ordinary raw effect, carrying the + // value rather than the `Result` around it. + ok: () => { + const o = runPure(unwrapStep(pureOk(5))) + assert(o.length === 1, o) + assertEq(o[0], 5) + }, + // An `error` is a panic — the policy the name exists to make greppable. + // It throws where the composition is written, since `mapStep` forces a + // `Pure` head immediately. + throw: { + error: () => { + unwrapStep(pureError('boom')) + }, + }, + }, mapStep: { ok: () => { assertOk(pureResult(mapStep(pureOk(3), v => v + 1)), 4) diff --git a/fjs/effects/memory/module.f.mjs b/fjs/effects/memory/module.f.mjs index c857a07164..18e7ec8eb0 100644 --- a/fjs/effects/memory/module.f.mjs +++ b/fjs/effects/memory/module.f.mjs @@ -17,6 +17,7 @@ * * @import { Nominal } from '../../types/nominal/types.ts' * @import { Effect } from '../types.ts' + * @import { OpResult } from '../node/types.ts' * @import { Key, MemCreate, MemRead, MemWrite, _MemKeyHash } from './types.ts' */ @@ -31,14 +32,14 @@ export const asNominal = nominalAsNominal /** Creates a new typed memory slot with `value` as its initial contents. */ export const create = - /** @type {(value: T) => Effect>} */ + /** @type {(value: T) => Effect>>} */ (do_('memCreate')) /** Reads the current contents of a typed memory slot. */ export const read = - /** @type {(key: Key) => Effect} */ + /** @type {(key: Key) => Effect>} */ (do_('memRead')) /** Replaces the current contents of a typed memory slot. */ -/** @type {(key: Key, value: T) => Effect} */ +/** @type {(key: Key, value: T) => Effect>} */ export const write = do_('memWrite') diff --git a/fjs/effects/memory/proof.f.mjs b/fjs/effects/memory/proof.f.mjs index b5785d451b..5580b0e8ce 100644 --- a/fjs/effects/memory/proof.f.mjs +++ b/fjs/effects/memory/proof.f.mjs @@ -4,8 +4,9 @@ */ import { assert, assertEq } from '../../asserts/module.f.mjs' +import { ok } from '../../types/result/module.f.mjs' import { run } from '../mock/module.f.mjs' -import { pure, step } from '../module.f.mjs' +import { pureOk, step } from '../io/module.f.mjs' import { asBase, asNominal, create, read, write, @@ -30,20 +31,22 @@ const mock = { return [{ next: state.next + 1, values: { ...state.values, [id]: value }, - }, key] + }, ok(key)] }, memRead: key => state => - [state, state.values[asBase(key)]], + [state, ok(state.values[asBase(key)])], memWrite: (key, value) => state => { const id = asBase(key) assert(id in state.values, id) return [{ ...state, values: { ...state.values, [id]: value }, - }, undefined] + }, ok(undefined)] }, } +// The Io `step`: each link runs only because the previous one returned `ok`, +// and a runner that omitted a handler would propagate rather than be ignored. const program = step( create(1), key => { @@ -58,7 +61,8 @@ const program = step( export const proof = { roundTrip: () => { const [state, result] = run(mock)(initial)(program) - assertEq(result, 42) + assert(result[0] === 'ok', result) + assertEq(result[1], 42) assertEq(state.values.k0, 42, state) }, allocatesFreshKeys: () => { @@ -66,8 +70,10 @@ export const proof = { create('a'), a => step( create('b'), - b => pure(/** @type {const} */ ([asBase(a), asBase(b)])))) - const [state, [a, b]] = run(mock)(initial)(effect) + b => pureOk(/** @type {const} */ ([asBase(a), asBase(b)])))) + const [state, result] = run(mock)(initial)(effect) + assert(result[0] === 'ok', result) + const [a, b] = result[1] assertEq(a, 'k0') assertEq(b, 'k1') assertEq(state.values.k0, 'a', state) diff --git a/fjs/effects/memory/types.ts b/fjs/effects/memory/types.ts index a1a5f29def..844dbb80ce 100644 --- a/fjs/effects/memory/types.ts +++ b/fjs/effects/memory/types.ts @@ -6,6 +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' /** Nominal brand version for memory keys. */ export type _MemKeyHash = '3f114fa6036a8da026b827f0c3e6d901f5e81ad9a320e431ccce31451892d286' @@ -14,12 +15,12 @@ export type _MemKeyHash = '3f114fa6036a8da026b827f0c3e6d901f5e81ad9a320e431ccce3 export type Key = Phantom, T> /** Allocates a fresh memory slot and initializes it with `value`. */ -export type MemCreate = readonly['memCreate', (value: T) => Key] +export type MemCreate = readonly['memCreate', (value: T) => OpResult>] /** Reads the current value stored at `key`. */ -export type MemRead = readonly['memRead', (key: Key) => T] +export type MemRead = readonly['memRead', (key: Key) => OpResult] /** Replaces the current value stored at `key`. */ -export type MemWrite = readonly['memWrite', (key: Key, value: T) => void] +export type MemWrite = readonly['memWrite', (key: Key, value: T) => OpResult] export type MemOp = MemCreate | MemRead | MemWrite diff --git a/fjs/effects/node/memory/module.mjs b/fjs/effects/node/memory/module.mjs index 168d18e219..2df88de4e4 100644 --- a/fjs/effects/node/memory/module.mjs +++ b/fjs/effects/node/memory/module.mjs @@ -9,6 +9,7 @@ import { randomUUID } from 'node:crypto' import { asyncRun } from '../../module.mjs' +import { ok } from '../../../types/result/module.f.mjs' import { asBase, asNominal } from '../../memory/module.f.mjs' /** @typedef {ToAsyncOperationMap} MemoryOperationMap */ @@ -37,17 +38,18 @@ export const memoryOperationMap = (uuid = randomUUID) => { /** @type {Key} */ const key = asNominal(id) store.set(id, value) - return key + return ok(key) }, memRead: async key => { const id = asBase(key) if (!store.has(id)) { throw missingKey(id) } - return store.get(id) + return ok(store.get(id)) }, memWrite: async (key, value) => { const id = asBase(key) if (!store.has(id)) { throw missingKey(id) } store.set(id, value) + return ok(undefined) }, } } diff --git a/fjs/effects/node/memory/proof.mjs b/fjs/effects/node/memory/proof.mjs index 2f74928d96..b890214882 100644 --- a/fjs/effects/node/memory/proof.mjs +++ b/fjs/effects/node/memory/proof.mjs @@ -13,7 +13,7 @@ import { } from '../../memory/module.f.mjs' import { memoryOperationMap, run } from './module.mjs' import { assert, assertEq } from '../../../asserts/module.f.mjs' -import { step } from '../../module.f.mjs' +import { step, unwrapStep } from '../../io/module.f.mjs' export const proof = { nodeInterpreter: async () => { @@ -22,13 +22,15 @@ export const proof = { key => step( write(key, 2), () => read(key))) - assertEq(await run(x), 2) + const r = await run(x) + assert(r[0] === 'ok', r) + assertEq(r[1], 2) }, reusedOperationMapPersists: async () => { const runner = asyncRun(/** @type {import('../../types.ts').ToAsyncOperationMap} */ (memoryOperationMap())) - const key = await runner(create(1)) + const key = await runner(unwrapStep(create(1))) await runner(write(key, 2)) - const result = await runner(read(key)) + const result = await runner(unwrapStep(read(key))) assertEq(result, 2) }, missingKeyThrows: async () => { diff --git a/fjs/effects/node/module.f.mjs b/fjs/effects/node/module.f.mjs index a823575ac8..dd6a9bcd14 100644 --- a/fjs/effects/node/module.f.mjs +++ b/fjs/effects/node/module.f.mjs @@ -13,7 +13,8 @@ * @import { Vec } from '../../types/bit_vec/types.ts' * @import { Effect, Func, Operation } from '../types.ts' * @import { List } from '../list/types.ts' - * @import { All, Access, Await, Console, CreateExclusive, CreateServer, Dirent, Engine, Env, Exec, ExecResult, Fetch, FileStat, Forever, Fs, Headers, Http, IncomingMessage, Import, IoResult, Listen, MakeDirectoryOptions, Mkdir, Module, Now, NodeOp, NodeProgramOptions, RandomInt, Read, ReadBytes, ReadConsoles, ReadFile, Readdir, ReaddirOptions, RequestListener, Rename, Rm, Sandbox, SandboxResult, Server, ServerResponse, Stat, Test, TestContext, TestFn, Write, WriteBytes, WriteConsoles, WriteFile, _UtfList, _WriteLoop, } from './types.ts' + * @import { All, Access, Await, Console, CreateExclusive, CreateServer, Dirent, Engine, Env, Exec, ExecResult, Fetch, FileStat, Forever, Fs, Headers, Http, IncomingMessage, Import, IoError, IoErrorInfo, IoResult, Listen, MakeDirectoryOptions, Mkdir, Module, Now, NodeOp, NodeProgramOptions, OpResult, RandomInt, Read, ReadBytes, ReadConsoles, ReadFile, Readdir, ReaddirOptions, RequestListener, Rename, Rm, Sandbox, SandboxResult, Server, ServerResponse, Stat, Test, TestContext, TestFn, Write, WriteBytes, WriteConsoles, WriteFile, _UtfList, _WriteLoop, } from './types.ts' + * @import { IoEffect, NotImplemented } from '../io/types.ts' */ import { utf8, utf8ToString } from '../../text/module.f.mjs' @@ -21,21 +22,58 @@ import { toCodePointList } from '../../text/utf8/module.f.mjs' 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 { ok, error as resultError, mapOk } from '../../types/result/module.f.mjs' -import { do_, mapStep, okStep, pure, step } from '../module.f.mjs' +import { error as resultError } from '../../types/result/module.f.mjs' +import { do_, mapStep, pure, step } from '../module.f.mjs' +import { mapStep as ioMapStep, pureError, pureOk, step as ioStep } from '../io/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. + * + * @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. * - * Node's filesystem rejections are `Error`s carrying `code: 'ENOENT'`; the - * virtual interpreter mirrors that shape for absent paths. Lets callers swallow - * only the missing-path case (e.g. a fresh store) while propagating genuine - * failures (permissions, corruption) rather than masking them. + * Node's filesystem rejections are `Error`s carrying `code: 'ENOENT'`, which + * {@link toIoError} keeps; the virtual interpreter reports the same code for + * absent paths. Lets callers swallow only the missing-path case (e.g. a fresh + * store) while propagating genuine failures (permissions, corruption) rather + * than masking them. + * + * A {@link NotImplemented} is never "not found": a runner that cannot perform + * the operation has not looked for the path at all, so the two must not + * collapse into one benign branch — which is exactly what a bare `unknown` + * error channel used to allow. * - * @type {(e: unknown) => boolean} + * @type {(e: NotImplemented | IoError) => boolean} */ -export const isNotFound = e => - typeof e === 'object' && e !== null && 'code' in e && e.code === 'ENOENT' +export const isNotFound = ([tag, payload]) => + tag === 'ioError' && payload.code === 'ENOENT' // all @@ -44,14 +82,14 @@ export const isNotFound = e => * This is the reason why we merge `O` with `All` in the resulted `Effect`. */ export const all = - /** @type {(...a: readonly Effect[]) => Effect} */ + /** @type {(...a: readonly Effect[]) => Effect>} */ (do_('all')) /** * @template {Operation} O0 * @template T0 * @param {Effect} a - * @returns {(b: Effect) => Effect} + * @returns {(b: Effect) => Effect>} */ export const both = a => b => /** @type {any} */ (all)(a, b) @@ -74,14 +112,14 @@ export const readFile = do_('readFile') /** * Reads a file as UTF-8 text. * - * Preserves the `IoResult` instead of unwrapping so callers can pattern-match - * on errors (e.g. convert them into domain-specific errors) or `unwrap` at the - * call site. + * Preserves the error channel instead of unwrapping so callers can + * pattern-match on it (e.g. convert a failure into a domain-specific error) or + * `unwrap` at the call site. * - * @type {(path: string) => Effect>} + * @type {(path: string) => IoEffect} */ export const readUtf8File = path => - mapStep(readFile(path), mapOk(utf8ToString)) + ioMapStep(readFile(path), utf8ToString) // readdir @@ -96,7 +134,7 @@ export const writeFile = do_('writeFile') /** * Writes a string to `path` as UTF-8 bytes. * - * @type {(path: string, content: string) => Effect>} + * @type {(path: string, content: string) => IoEffect} */ export const writeUtf8File = (path, content) => writeFile(path, utf8(content)) @@ -147,7 +185,7 @@ const writeLoop = path => { const f = (offset, e) => step(e, r => { if (r === undefined) { - return pure(ok(undefined)) + return pureOk(undefined) } const { first: [t, v], tail } = r if (t === 'error') { @@ -155,11 +193,11 @@ const writeLoop = path => { } const lenV = length(v) if ((lenV & 0b111n) !== 0n) { - return pure(resultError('invalid buffer size')) + return pureError(ioError({ message: 'invalid buffer size' })) } - return step( + return ioStep( writeBytes(path, offset, v), - okStep(() => f(offset + Number(lenV >> 3n), tail))) + () => f(offset + Number(lenV >> 3n), tail)) }) return f } @@ -171,9 +209,9 @@ const writeLoop = path => { * @returns {Effect>} */ export const writeFromStream = (path, e) => - step( + ioStep( createExclusive(path), - okStep(() => writeLoop(path)(0, e))) + () => writeLoop(path)(0, e)) // stat @@ -183,7 +221,7 @@ export const stat = do_('stat') // createServer export const createServer = - /** @type {(listener: RequestListener) => Effect} */ + /** @type {(listener: RequestListener) => Effect>} */ (do_('createServer')) // listen @@ -211,7 +249,7 @@ export const write = do_('write') * Encodes `s + '\n'` as UTF-8 and emits a `Write` effect to `stream`. * Shared implementation for `log` and `error`. * - * @type {(stream: WriteConsoles) => (s: string) => Effect} + * @type {(stream: WriteConsoles) => Console} */ const writeString = stream => s => write(stream, utf8(s + '\n')) @@ -251,17 +289,20 @@ const lf = 0x0a * reversed and decoded once at the terminator, so a large line costs O(n) * rather than the O(n²) of copying a growing array on every byte. * - * @type {(stream: ReadConsoles) => Effect} + * A failed `read` — a runner without the operation — propagates: the line is + * not silently truncated into a `null` that a caller would read as EOF. + * + * @type {(stream: ReadConsoles) => IoEffect} */ export const readLine = stream => { - /** @type {(acc: _UtfList) => Effect} */ + /** @type {(acc: _UtfList) => IoEffect} */ const loop = acc => - step( + ioStep( read(stream), b => b === null - ? pure(acc === null ? null : utf8ListToString(reverse(acc))) + ? pureOk(acc === null ? null : utf8ListToString(reverse(acc))) : b === lf - ? pure(utf8ListToString(reverse(acc))) + ? pureOk(utf8ListToString(reverse(acc))) : loop({ first: b, tail: acc }) ) return loop(null) @@ -299,9 +340,9 @@ export const sandbox = do_('sandbox') /** @type {Func} */ const awaitPromise = do_('await') -/** @type {(p: unknown) => Effect} */ +/** @type {(p: unknown) => IoEffect} */ export const awaitIfPromise = p => - mapStep(awaitPromise(p), ([x]) => x) + ioMapStep(awaitPromise(p), ([x]) => x) // Test registration @@ -315,11 +356,40 @@ export const test = do_('test') * "fail with a message" program for a `NodeProgram`. For non-`1` exit codes, * compose `mapStep(error(s), () => n)` directly. * + * **The write's own outcome is deliberately discarded**, which is why this is + * the raw `mapStep` rather than the Io one. The program is already failing and + * the exit code is `1` whether or not `stderr` accepted the bytes; propagating + * here would hand every caller a "failed to report a failure" branch with no + * better answer available to it than the one taken here. + * * @type {(s: string) => Effect} */ export const errorExit = s => mapStep(error(s), () => 1) +/** + * Renders a channel error as a human line: an {@link IoError}'s own message, or + * the command name a runner could not dispatch. + * + * @type {(e: NotImplemented | IoError) => string} + */ +export const errorMessage = ([tag, payload]) => + tag === 'notImplemented' ? `operation not implemented: ${payload}` : payload.message + +/** + * Ends a program with an exit code that reflects `e`: `ok` yields `0`, and a + * failure is reported on `stderr` and yields `1` ({@link errorExit}). + * + * This is the exit-code policy a `NodeProgram` needs at the end of its chain, + * and the reason a program does not have to invent one per command. It is the + * counterpart of {@link isNotFound} at the other end of the channel: where that + * one asks which failure this is, this one stops asking and reports. + * + * @type {(e: IoEffect) => Effect} + */ +export const exitStep = e => + step(e, r => r[0] === 'error' ? errorExit(errorMessage(r[1])) : pure(0)) + /** @type {(version: string) => readonly number[]} */ const versionParts = version => version.replace(/^v/, '').split('.').map(Number) diff --git a/fjs/effects/node/module.mjs b/fjs/effects/node/module.mjs index 7ee0dca869..7996c9b04a 100644 --- a/fjs/effects/node/module.mjs +++ b/fjs/effects/node/module.mjs @@ -13,7 +13,7 @@ * @module * * @import { Effect } from '../types.ts' - * @import { Server as EffectServer, Headers, Module, NodeOp, RequestListener as Erl, NodeProgram, NodeProgramOptions, WriteConsoles, TestContext, TestFn, } from './types.ts' + * @import { IoResult, Server as EffectServer, Headers, Module, NodeOp, RequestListener as Erl, NodeProgram, NodeProgramOptions, WriteConsoles, TestContext, TestFn, } from './types.ts' * @import { Result } from '../../types/result/types.ts' * @import { StringMap } from '../../types/object/types.ts' */ @@ -30,7 +30,7 @@ import * as testContext from 'node:test' import { concat, normalize, toPosix } from '../../path/module.f.mjs' import { asyncRun } from '../module.mjs' import { memoryOperationMap } from './memory/module.mjs' -import { usesInlineTestContext } from './module.f.mjs' +import { toIoError, usesInlineTestContext } from './module.f.mjs' import { asBase, asNominal } from '../../types/nominal/module.f.mjs' import { error, ok } from '../../types/result/module.f.mjs' import { asyncTryCatch } from '../../types/result/module.mjs' @@ -69,6 +69,22 @@ const createServer = http.createServer /** @typedef {(effect: Effect) => Promise} _EffectToPromise */ +/** + * Performs host IO, reporting a thrown failure as an {@link IoResult} error. + * + * Every filesystem, network, and subprocess handler below goes through it, so + * the `catch` that turns an exception into effect data — and the normalization + * that keeps the channel serializable — happens in exactly one place. + * + * @template T + * @param {() => Promise} f + * @returns {Promise>} + */ +const io = async f => { + const r = await asyncTryCatch(f) + return r[0] === 'ok' ? r : error(toIoError(r[1])) +} + /** * @template T * @param {AsyncIterable} v @@ -210,16 +226,16 @@ const { randomInt } = crypto /** @type {_EffectToPromise} */ const runNodeEffect = asyncRun({ ...memoryOperationMap(), - all: async (...effects) => await Promise.all(effects.map(runNodeEffect)), - fetch: async url => asyncTryCatch(async () => { + all: async (...effects) => ok(await Promise.all(effects.map(runNodeEffect))), + fetch: url => io(async () => { const response = await fetch(url) if (!response.ok) { throw new Error(`Fetch error: ${response.status} ${response.statusText}`) } return toVec(new Uint8Array(await response.arrayBuffer())) }), - mkdir: (path, options) => asyncTryCatch(async () => { await mkdir(path, options) }), - readFile: path => asyncTryCatch(async () => { + mkdir: (path, options) => io(async () => { await mkdir(path, options) }), + readFile: path => io(async () => { const fileStats = await stat(path) // if the file is too big, toVec should fail anyway but in this case we don't want to load the file. if (fileStats.size > maxFileSizeBytes) { @@ -227,7 +243,7 @@ const runNodeEffect = asyncRun({ } return toVec(await readFile(path)) }), - readdir: (path, r) => asyncTryCatch(async () => + readdir: (path, r) => io(async () => (await readdir(path, { ...r, withFileTypes: true })) .map(v => ({ name: v.name, @@ -235,10 +251,10 @@ const runNodeEffect = asyncRun({ isFile: v.isFile() })) ), - writeFile: (path, data) => asyncTryCatch(() => writeFile(path, fromVec(data))), - rm: path => asyncTryCatch(() => rm(path)), - rename: (src, dst) => asyncTryCatch(() => rename(src, dst)), - readBytes: (path, offset, size) => asyncTryCatch(async () => { + writeFile: (path, data) => io(() => writeFile(path, fromVec(data))), + rm: path => io(() => rm(path)), + rename: (src, dst) => io(() => rename(src, dst)), + readBytes: (path, offset, size) => io(async () => { if (offset < 0) { throw new Error(`Offset ${offset} is negative`) } @@ -254,13 +270,13 @@ const runNodeEffect = asyncRun({ await fh.close() } }), - randomInt: async () => randomInt(randomMax), - access: path => asyncTryCatch(() => access(path)), - createExclusive: path => asyncTryCatch(async () => { + randomInt: async () => ok(randomInt(randomMax)), + access: path => io(() => access(path)), + createExclusive: path => io(async () => { const fh = await open(path, 'wx') await fh.close() }), - writeBytes: (path, offset, data) => asyncTryCatch(async () => { + writeBytes: (path, offset, data) => io(async () => { const fh = await open(path, 'r+') try { const buffer = fromVec(data) @@ -275,11 +291,11 @@ const runNodeEffect = asyncRun({ await fh.close() } }), - stat: path => asyncTryCatch(async () => ({ size: (await stat(path)).size })), - import: path => asyncTryCatch(() => asyncImport(path)), + stat: path => io(async () => ({ size: (await stat(path)).size })), + import: path => io(() => asyncImport(path)), exec: (command, stdin) => new Promise(resolve => { const child = exec(command, (e, stdout, stderr) => - resolve(e !== null ? /** @type {const} */ (['error', e]) : ok({ stdout, stderr })) + resolve(e !== null ? error(toIoError(e)) : ok({ stdout, stderr })) ) child.stdin?.end(stdin) }), @@ -299,20 +315,21 @@ const runNodeEffect = asyncRun({ .writeHead(status, outHeaders) .end(fromVec(outBody)) } - return /** @satisfies {EffectServer} */ (asNominal(createServer(nodeRl))) + return ok(/** @satisfies {EffectServer} */ (asNominal(createServer(nodeRl)))) }, listen: async (server, port) => { const s = /** @type {_Server} */ (asBase(server)) s.listen(port) + return ok(undefined) }, forever: () => new Promise(() => {}), - now: async () => now(), - sandbox, - await: awaitPromise, - write: (stream, data) => writeAll(streams[stream], fromVec(data)), - read: readStdinByte, + now: async () => ok(now()), + sandbox: async f => ok(await sandbox(f)), + await: async p => ok(await awaitPromise(p)), + write: async (stream, data) => ok(await writeAll(streams[stream], fromVec(data))), + read: async () => ok(await readStdinByte()), test: async (ctx, name, expectFailure, test) => - ctx.test(name, { expectFailure }, async t => runNodeEffect(test(t))), + ok(await ctx.test(name, { expectFailure }, async t => runNodeEffect(test(t)))), }) /** @type {TestFn} */ diff --git a/fjs/effects/node/proof.f.mjs b/fjs/effects/node/proof.f.mjs index 684582470a..1a461cef46 100644 --- a/fjs/effects/node/proof.f.mjs +++ b/fjs/effects/node/proof.f.mjs @@ -1,6 +1,8 @@ /** * @import { Vec } from "../../types/bit_vec/types.ts" - * @import { IoResult, ReadFile } from "./types.ts" + * @import { IoError, IoResult, ReadFile } from "./types.ts" + * @import { NotImplemented } from "../io/types.ts" + * @import { Result } from "../../types/result/types.ts" * @import { List } from "../list/types.ts" * @import { OperationMap } from "../types.ts" */ @@ -8,7 +10,8 @@ import { empty, isVec, uint, vec, vec8 } from "../../types/bit_vec/module.f.mjs" import { utf8, utf8ToString } from "../../text/module.f.mjs" import { match, pure, step } from "../module.f.mjs" -import { both, fetch, mkdir, now, readdir, readFile, readUtf8File, rm, sandbox, writeFile, writeUtf8File, rename, readBytes, randomInt, writeFromStream, usesInlineTestContext, versionLessThan } from "./module.f.mjs" +import { step as ioStep } from "../io/module.f.mjs" +import { both, errorMessage, 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 { 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" @@ -28,7 +31,98 @@ const readHelloMap = { const readHello = match(readHelloMap) +/** + * Asserts that a channel error is a host failure carrying `message`. Every + * runner reports through the same normalized {@link IoError}, so a proof + * against the virtual filesystem names the message rather than the shape. + * @type {(e: NotImplemented | IoError, message: string) => void} + */ +const assertIoMessage = (e, message) => { + assert(e[0] === 'ioError', e) + assertEq(e[1].message, message) +} + +/** Asserts that an operation succeeded with `expected`. + * @type {(r: Result, expected: T) => void} + */ +const assertOk = (r, expected) => { + assert(r[0] === 'ok', r) + assertEq(r[1], 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' }))) + }, + otherCode: () => { + assert(!isNotFound(ioError({ code: 'EACCES', message: 'permission denied' }))) + }, + // A runner that cannot perform the operation has not looked for the + // path at all, so a missing handler is never "not found". + notImplemented: () => { + assert(!isNotFound(['notImplemented', 'readFile'])) + }, + }, + errorMessage: { + io: () => { + assertEq(errorMessage(ioError({ message: 'disk full' })), 'disk full') + }, + notImplemented: () => { + assertEq(errorMessage(['notImplemented', 'readFile']), 'operation not implemented: readFile') + }, + }, + exitStep: { + // The exit-code policy a `NodeProgram` ends with: success is `0`... + ok: () => { + const [state, code] = virtual(emptyState)(exitStep(writeFile('hello', vec8(0x2An)))) + assertEq(code, 0) + assertEq(state.stderr, '') + }, + // ...and a failure is reported on `stderr` and exits `1`. + error: () => { + const [state, code] = virtual(emptyState)(exitStep(readFile('missing'))) + assertEq(code, 1) + assertEq(state.stderr, 'no such file or directory\n') + }, + }, externalTestContext: () => { assert(usesInlineTestContext('node', 'v22.20.0')) assert(usesInlineTestContext('node', '25.99.99')) @@ -124,8 +218,8 @@ export const proof = { nestedPath: () => { const [_, [t, result]] = virtual(emptyState)(readFile('tmp/cache')) assert(t === 'error', result) - assert(typeof result === 'object' && result !== null && 'code' in result, result) - if (result.code !== 'ENOENT') { throw result } + assert(result[0] === 'ioError', result) + assertEq(result[1].code, 'ENOENT', result) }, withinLimit: () => { // Test with a small file well within the 131,072 byte limit @@ -200,7 +294,7 @@ export const proof = { noSuchDir: () => { const [_, [t, result]] = virtual(emptyState)(readdir('tmp', { recursive: true })) assert(t === 'error', result) - assertEq(result, 'invalid path') + assertIoMessage(result, 'invalid path') }, }, writeFile: { @@ -232,7 +326,7 @@ export const proof = { writeFile('tmp/cache', vec8(0x2An)) ) assert(t === 'error', result) - assertEq(result, 'invalid file') + assertIoMessage(result, 'invalid file') assertEq(state.root.tmp, undefined, state.root) }, directory: () => { @@ -245,7 +339,7 @@ export const proof = { writeFile('tmp', vec8(0x2An)) ) assert(t === 'error', result) - assertEq(result, 'invalid file') + assertIoMessage(result, 'invalid file') const tmp = state.root.tmp assert(!(tmp === undefined || Array.isArray(tmp)), tmp) }, @@ -281,7 +375,7 @@ export const proof = { noSuchFile: () => { const [_, [t, result]] = virtual(emptyState)(rm('hello')) assert(t === 'error', result) - assertEq(result, 'no such file') + assertIoMessage(result, 'no such file') }, isDirectory: () => { const [state, [t, result]] = virtual({ @@ -289,18 +383,20 @@ export const proof = { root: { tmp: {} }, })(rm('tmp')) assert(t === 'error', result) - assertEq(result, 'invalid path') + assertIoMessage(result, 'invalid path') assert(state.root.tmp !== undefined, state.root) }, }, both: () => { - const [_, results] = virtual({ + const [_, both2] = virtual({ ...emptyState, root: { a: [vec8(0x2An)], b: [vec8(0x15n)], }, })(both(readFile('a'))(readFile('b'))) + assert(both2[0] === 'ok', both2) + const results = both2[1] assert(results[0][0] === 'ok', results[0]) assert(results[1][0] === 'ok', results[1]) assertEq(uint(results[0][1]), 0x2An, results[0][1]) @@ -308,41 +404,48 @@ export const proof = { }, now: () => { const [_, result] = virtual({ ...emptyState, epochNs: 1_000_000 })(now()) - assertEq(result, 1_000_000) + assertOk(result, 1_000_000) }, sandbox: { // Virtual `sandbox` is now a pass-through: the function is expected // to return a `SandboxResult` directly. Fixtures dictate the result // (and `duration`) instead of the runner measuring. ok: () => { - const [_, { result, duration }] = virtual(emptyState)( + const [_, sandboxed] = virtual(emptyState)( sandbox(() => ({ result: ['ok', 42], duration: 0 }))) + // Two `Result`s, one inside the other on purpose: the outer one is + // the operation's own status, the inner one is the sandboxed + // function's outcome — returned data, not effect status. + assert(sandboxed[0] === 'ok', sandboxed) + const { result, duration } = sandboxed[1] assert(result[0] === 'ok', result) assertEq(result[1], 42) assertEq(duration, 0) }, error: () => { const err = new Error('fail') - const [_, { result }] = virtual(emptyState)( + const [_, sandboxed] = virtual(emptyState)( sandbox(() => ({ result: ['error', err], duration: 0 }))) + assert(sandboxed[0] === 'ok', sandboxed) + const { result } = sandboxed[1] assert(result[0] === 'error', result) assertEq(result[1], err) }, }, memory: { createAndRead: () => { - const effect = step(memCreate(42), key => memRead(key)) + const effect = ioStep(memCreate(42), key => memRead(key)) const [_, value] = virtual(emptyState)(effect) - assertEq(value, 42) + assertOk(value, 42) }, createAndWrite: () => { - const effect = step( + const effect = ioStep( memCreate(1), - key => step( + key => ioStep( memWrite(key, 99), () => memRead(key))) const [_, value] = virtual(emptyState)(effect) - assertEq(value, 99) + assertOk(value, 99) }, }, rename: { @@ -411,11 +514,11 @@ export const proof = { randomInt: { increments: () => { const [state1, r1] = virtual(emptyState)(randomInt()) - assertEq(r1, 0) + assertOk(r1, 0) const [state2, r2] = virtual(state1)(randomInt()) - assertEq(r2, 1) + assertOk(r2, 1) const [_, r3] = virtual(state2)(randomInt()) - assertEq(r3, 2) + assertOk(r3, 2) }, }, writeFromStream: { @@ -441,7 +544,7 @@ export const proof = { writeFromStream('hello', chunks) ) assert(t === 'error', result) - assertEq(result, 'invalid buffer size') + assertIoMessage(result, 'invalid buffer size') }, }, } diff --git a/fjs/effects/node/types.ts b/fjs/effects/node/types.ts index 320827abb6..995724eca2 100644 --- a/fjs/effects/node/types.ts +++ b/fjs/effects/node/types.ts @@ -12,12 +12,49 @@ import type { Result } from '../../types/result/types.ts' import type { StringMap } from '../../types/object/types.ts' import type { Effect, Operation, ToAsyncOperationMap } from '../types.ts' import type { List } from '../list/types.ts' +import type { NotImplemented } from '../io/types.ts' -export type IoResult = 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 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 result of an operation that performs host IO: its value, a normalized + * host failure, or the missing-handler report. + */ +export type IoResult = Result // all -export type All = ['all', (...effects: Effect[]) => readonly T[]] +export type All = ['all', (...effects: Effect[]) => OpResult] // fetch @@ -75,7 +112,7 @@ export type ReadBytes = readonly['readBytes', (path: string, offset: number, siz // randomInt -export type RandomInt = readonly['randomInt', () => number] +export type RandomInt = readonly['randomInt', () => OpResult] // exec @@ -150,11 +187,11 @@ export type ServerResponse = { export type RequestListener = (_: IncomingMessage) => Effect -export type CreateServer = ['createServer', (listener: RequestListener) => Server] +export type CreateServer = ['createServer', (listener: RequestListener) => OpResult] // listen -export type Listen = ['listen', (server: Server, port: number) => void] +export type Listen = ['listen', (server: Server, port: number) => OpResult] // HTTP @@ -162,7 +199,7 @@ export type Http = CreateServer | Listen // Wait forever -export type Forever = ['forever', () => never] +export type Forever = ['forever', () => OpResult] // import @@ -180,9 +217,9 @@ export type WriteConsoles = 'stdout' | 'stderr' * a `Vec`. The Node runner maps each stream name to the appropriate fd and * delegates to the OS via `stream.write()` with backpressure handling. */ -export type Write = readonly['write', (stream: WriteConsoles, data: Vec) => void] +export type Write = readonly['write', (stream: WriteConsoles, data: Vec) => OpResult] -export type Console = (s: string) => Effect +export type Console = (s: string) => Effect> // read @@ -197,14 +234,14 @@ export type ReadConsoles = 'stdin' * rather than the interpreter. Back-pressure is naturally sequential — the next * `read` is only issued once the previous byte is consumed. */ -export type Read = readonly['read', (stream: ReadConsoles) => number | null] +export type Read = readonly['read', (stream: ReadConsoles) => OpResult] /** @internal */ export type _UtfList = EffectList // now -export type Now = readonly['now', () => number] +export type Now = readonly['now', () => OpResult] // sandbox @@ -225,7 +262,7 @@ export type SandboxResult = { readonly duration: number } -export type Sandbox = readonly['sandbox', (f: () => T) => SandboxResult] +export type Sandbox = readonly['sandbox', (f: () => T) => OpResult>] /** * Resolves the return value of a test function inside the effect runner. @@ -234,7 +271,7 @@ export type Sandbox = readonly['sandbox', (f: () => T) => SandboxResult] * (objects with a `.then` method that are not `instanceof Promise`) are * treated as ordinary values — not awaited. See `fjs/dev/tf/README.md`. */ -export type Await = readonly['await', (p: unknown) => readonly[unknown]] +export type Await = readonly['await', (p: unknown) => OpResult] // Test registration @@ -260,7 +297,7 @@ export type TestContext = { /** Effect operation that registers a named test with the active `TestContext`. */ export type Test = - readonly['test', (ctx: TestContext, name: string, expectFailure: boolean, test: (t: TestContext) => Effect) => void] + readonly['test', (ctx: TestContext, name: string, expectFailure: boolean, test: (t: TestContext) => Effect) => OpResult] // Node diff --git a/fjs/effects/node/virtual/module.f.mjs b/fjs/effects/node/virtual/module.f.mjs index 0685aac54a..578f7471c1 100644 --- a/fjs/effects/node/virtual/module.f.mjs +++ b/fjs/effects/node/virtual/module.f.mjs @@ -5,7 +5,8 @@ * * @import { Vec } from '../../../types/bit_vec/types.ts' * @import { MemOperationMap, RunInstance } from '../../mock/types.ts' - * @import { Dirent, FileStat, IoResult, Module, NodeOp, NodeProgramOptions, SandboxResult } from '../types.ts' + * @import { Dirent, FileStat, IoError, IoResult, Module, NodeOp, NodeProgramOptions, SandboxResult } from '../types.ts' + * @import { Error } from '../../../types/result/types.ts' * @import { Dir, State, _Entity } from './types.ts' */ @@ -14,6 +15,7 @@ import { isProperPrefix, join, parse } from '../../../path/module.f.mjs' import { utf8ToString } from '../../../text/module.f.mjs' import { empty, length, maxLengthBytes, msb, vec } from '../../../types/bit_vec/module.f.mjs' import { error, ok } from '../../../types/result/module.f.mjs' +import { ioError } from '../module.f.mjs' import { run } from '../../mock/module.f.mjs' import { asBase, asNominal } from '../../memory/module.f.mjs' @@ -67,12 +69,22 @@ const readOperation = op => operation((dir, path) => [dir, op(dir, path)]) const okVoid = ok(undefined) +/** + * A virtual host failure. The virtual runner reports the same normalized + * {@link IoError} the Node runner does, so a program cannot tell the two apart + * by the shape of what it catches — which is what makes a proof against the + * virtual filesystem evidence about the real one. + * + * @type {(message: string) => Error} + */ +const fail = message => error(ioError({ message })) + /** @type {(recursive: boolean) => (dir: Dir, path: readonly string[]) => readonly [Dir, IoResult]} */ const mkdirOp = recursive => (dir, path) => { let d = {} let i = path.length if (i > 1 && !recursive) { - return [dir, error('non-recursive')] + return [dir, fail('non-recursive')] } while (i > 0) { i -= 1 @@ -86,7 +98,7 @@ const mkdirOp = recursive => (dir, path) => { const mkdir = recursive => operation(mkdirOp(recursive)) /** Absent-path error mirroring Node's `ENOENT`, so `isNotFound` recognizes it. */ -const enoent = error({ code: 'ENOENT' }) +const enoent = error(ioError({ code: 'ENOENT', message: 'no such file or directory' })) /** @type {(path: string) => (state: State) => readonly [State, IoResult]} */ const readFile = readOperation((dir, path) => { @@ -105,7 +117,7 @@ const readFile = readOperation((dir, path) => { const chunkLen = length(chunk) if (chunkLen === 0n) { continue } if (length(result) + chunkLen > capBits) { - return error(`File size exceeds maximum allowed size of ${maxLengthBytes} bytes`) + return fail(`File size exceeds maximum allowed size of ${maxLengthBytes} bytes`) } result = msb.concat(result)(chunk) } @@ -114,13 +126,13 @@ const readFile = readOperation((dir, path) => { /** @type {(path: string) => (state: State) => readonly [State, IoResult]} */ const import_ = readOperation((dir, path) => { - if (path.length !== 1) { return error('no such file') } + if (path.length !== 1) { return fail('no such file') } const entry = dir[path[0]] - if (typeof entry !== 'function') { return error(`'${path[0]}' is not a JsModule`) } + if (typeof entry !== 'function') { return fail(`'${path[0]}' is not a JsModule`) } return ok(entry()) }) -const writeFileError = error('invalid file') +const writeFileError = fail('invalid file') /** @type {(payload: Vec) => (dir: Dir, path: readonly string[]) => readonly [Dir, IoResult]} */ const writeFileOp = payload => (dir, path) => { @@ -135,7 +147,7 @@ const writeFileOp = payload => (dir, path) => { /** @type {(payload: Vec) => (path: string) => (state: State) => readonly [State, IoResult]} */ const writeFile = payload => operation(writeFileOp(payload)) -const invalidPath = error('invalid path') +const invalidPath = fail('invalid path') const { entries } = Object @@ -168,10 +180,10 @@ const access = readOperation((dir, path) => { /** @type {(dir: Dir, path: readonly string[]) => readonly [Dir, IoResult]} */ const rmOp = (dir, path) => { - if (path.length !== 1) { return [dir, error('invalid path')] } + if (path.length !== 1) { return [dir, fail('invalid path')] } const [name] = path const entry = dir[name] - if (entry === undefined) { return [dir, error('no such file')] } + if (entry === undefined) { return [dir, fail('no such file')] } // No "is a directory" guard here: `operation`'s wrapper descends into // every plain-object (`Dir`) entry before this op ever runs, so `entry` // is always a `Vec[]` or a `JsModule` — never a bare `Dir` — and rm can @@ -186,7 +198,7 @@ const rm = operation(rmOp) /** @type {(dir: Dir, path: readonly string[]) => readonly [Dir, IoResult<_Entity>]} */ const extractEntity = (dir, path) => { - if (path.length === 0) { return [dir, error('cannot extract root')] } + if (path.length === 0) { return [dir, fail('cannot extract root')] } if (path.length === 1) { const [name] = path const entry = dir[name] @@ -218,16 +230,16 @@ const insertEntityAt = (dir, path, entity) => { const entityIsDir = !Array.isArray(entity) && typeof entity === 'object' const existingIsDir = !Array.isArray(existing) && typeof existing === 'object' if (entityIsDir && !existingIsDir) { - return [dir, error(`cannot overwrite file '${name}' with a directory`)] + return [dir, fail(`cannot overwrite file '${name}' with a directory`)] } if (!entityIsDir && existingIsDir) { - return [dir, error(`'${name}' is a directory`)] + return [dir, fail(`'${name}' is a directory`)] } if (entityIsDir && existingIsDir) { const existingDir = existing const hasContent = Object.values(existingDir).some(v => v !== undefined) if (hasContent) { - return [dir, error(`cannot overwrite non-empty directory '${name}'`)] + return [dir, fail(`cannot overwrite non-empty directory '${name}'`)] } } } @@ -236,7 +248,7 @@ const insertEntityAt = (dir, path, entity) => { const [first, ...rest] = path const sub = dir[first] if (sub === undefined) { return [dir, enoent] } - if (sub instanceof Array || typeof sub === 'function') { return [dir, error('not a directory')] } + if (sub instanceof Array || typeof sub === 'function') { return [dir, fail('not a directory')] } const [newSub, result] = insertEntityAt(sub, rest, entity) if (result[0] === 'error') { return [dir, result] } return [{ ...dir, [first]: newSub }, result] @@ -252,7 +264,7 @@ const rename = (src, dst) => state => { // now that source exists, reject if dst is strictly inside src's subtree (rename into own descendant) // or if src is strictly inside dst's subtree (rename onto own ancestor) if (isProperPrefix(srcParsed, dstParsed) || isProperPrefix(dstParsed, srcParsed)) { - return [state, error('cannot rename a directory into its own subtree or onto an ancestor')] + return [state, fail('cannot rename a directory into its own subtree or onto an ancestor')] } const [dstRoot, dstResult] = insertEntityAt(srcRoot, dstParsed, srcResult[1]) if (dstResult[0] === 'error') { return [state, dstResult] } @@ -269,11 +281,11 @@ const readBytesOp = (path, offset, size) => readOperation((dir, p) => { // before this op ever runs, and the `JsModule` case already threw above, // so `file` here is always a `Vec[]` — never a bare `Dir`. assert(Array.isArray(file), `'${p[0]}' is not a file`) - if (!Number.isInteger(offset)) { return error(`Offset ${offset} is not an integer`) } - if (!Number.isInteger(size)) { return error(`Chunk size ${size} is not an integer`) } - if (offset < 0) { return error(`Offset ${offset} is negative`) } - if (size < 0) { return error(`Chunk size ${size} is negative`) } - if (BigInt(size) > maxLengthBytes) { return error(`Chunk size ${size} exceeds maximum allowed size of ${maxLengthBytes} bytes`) } + if (!Number.isInteger(offset)) { return fail(`Offset ${offset} is not an integer`) } + if (!Number.isInteger(size)) { return fail(`Chunk size ${size} is not an integer`) } + if (offset < 0) { return fail(`Offset ${offset} is negative`) } + if (size < 0) { return fail(`Chunk size ${size} is negative`) } + if (BigInt(size) > maxLengthBytes) { return fail(`Chunk size ${size} exceeds maximum allowed size of ${maxLengthBytes} bytes`) } const chunks = file let toSkip = BigInt(offset) * 8n let toRead = BigInt(size) * 8n @@ -301,7 +313,7 @@ const fileSizeBytes = chunks => chunks.reduce((acc, c) => acc + Number(length(c) / 8n), 0) /** Absent-path error for an already-existing exclusive create, mirroring `EEXIST`. */ -const eexist = error({ code: 'EEXIST' }) +const eexist = error(ioError({ code: 'EEXIST', message: 'file already exists' })) /** @type {(dir: Dir, path: readonly string[]) => readonly [Dir, IoResult]} */ const createExclusiveOp = (dir, path) => { @@ -326,11 +338,11 @@ const writeBytesRawOp = (offset, data) => (dir, p) => { const [name] = p const file = dir[name] if (file === undefined) { return [dir, enoent] } // writeBytes never creates - if (!Array.isArray(file)) { return [dir, error(`'${name}' is not a file`)] } - if (!Number.isInteger(offset) || offset < 0) { return [dir, error(`Offset ${offset} is invalid`)] } + if (!Array.isArray(file)) { return [dir, fail(`'${name}' is not a file`)] } + if (!Number.isInteger(offset) || offset < 0) { return [dir, fail(`Offset ${offset} is invalid`)] } const chunks = file if (offset !== fileSizeBytes(chunks)) { - return [dir, error(`writeBytes offset ${offset} must equal the file size (append-only)`)] + return [dir, fail(`writeBytes offset ${offset} must equal the file size (append-only)`)] } return [{ ...dir, [name]: [...chunks, data] }, okVoid] } @@ -343,7 +355,7 @@ const statOp = readOperation((dir, path) => { if (path.length !== 1) { return enoent } const file = dir[path[0]] if (file === undefined) { return enoent } - if (!Array.isArray(file)) { return error(`'${path[0]}' is not a file`) } + if (!Array.isArray(file)) { return fail(`'${path[0]}' is not a file`) } return ok({ size: fileSizeBytes(file) }) }) @@ -357,7 +369,7 @@ const map = { state = ns e = [...e, ei] } - return [state, e] + return [state, ok(e)] }, memCreate: value => state => { const id = `mem${state.memoryNext}` @@ -366,20 +378,20 @@ const map = { ...state, memoryNext: state.memoryNext + 1, memoryValues: { ...state.memoryValues, [id]: value }, - }, key] + }, ok(key)] }, memRead: key => state => - [state, state.memoryValues[asBase(key)]], + [state, ok(state.memoryValues[asBase(key)])], memWrite: (key, value) => state => { const id = asBase(key) return [{ ...state, memoryValues: { ...state.memoryValues, [id]: value }, - }, undefined] + }, okVoid] }, fetch: url => state => { const result = state.internet[url] - return result === undefined ? [state, error('not found')] : [state, ok(result)] + return result === undefined ? [state, fail('not found')] : [state, ok(result)] }, mkdir: (path, p) => mkdir(p !== undefined)(path), readFile, @@ -393,12 +405,12 @@ const map = { createExclusive, writeBytes: writeBytesOp, stat: statOp, - randomInt: () => state => [{ ...state, randomNext: state.randomNext + 1 }, state.randomNext], + randomInt: () => state => [{ ...state, randomNext: state.randomNext + 1 }, ok(state.randomNext)], exec: todo, createServer: todo, listen: todo, forever: todo, - now: () => state => [state, state.epochNs], + now: () => state => [state, ok(state.epochNs)], // Virtual sandbox is a pass-through: the fixture's test function is // expected to return a `SandboxResult` directly (encoding pass/fail and a // chosen duration), so the handler invokes it without try/catch or clock @@ -406,18 +418,18 @@ const map = { // result instead of the runner measuring real execution. A genuine // exception in a fixture propagates loudly as a bug in the fixture. // See: issues/156-tf-virtual-tests.md - sandbox: f => state => [state, /** @type {SandboxResult} */ (f())], - await: p => state => [state, [p]], + sandbox: f => state => [state, ok(/** @type {SandboxResult} */ (f()))], + await: p => state => [state, ok([p])], test: todo, write: (stream, data) => state => { const s = utf8ToString(data) - return [{ ...state, [stream]: `${state[stream]}${s}` }, undefined] + return [{ ...state, [stream]: `${state[stream]}${s}` }, okVoid] }, read: () => state => { const [first, ...rest] = state.stdin return state.stdin.length === 0 - ? [state, null] - : [{ ...state, stdin: rest }, first] + ? [state, ok(null)] + : [{ ...state, stdin: rest }, ok(first)] }, } diff --git a/fjs/effects/node/virtual/proof.f.mjs b/fjs/effects/node/virtual/proof.f.mjs index 5078e62868..be92d1ab83 100644 --- a/fjs/effects/node/virtual/proof.f.mjs +++ b/fjs/effects/node/virtual/proof.f.mjs @@ -1,5 +1,7 @@ /** * @import { Dir } from './types.ts' + * @import { IoError } from '../types.ts' + * @import { NotImplemented } from '../../io/types.ts' */ import { assert, assertEq } from '../../../asserts/module.f.mjs' @@ -7,6 +9,16 @@ import { access, awaitIfPromise, fetch, rm, writeFile, readFile, readdir, import import { empty, length, maxLengthBytes, vec, vec8 } from '../../../types/bit_vec/module.f.mjs' import { emptyState, virtual } from './module.f.mjs' +/** + * Asserts that a channel error is a host failure carrying `message` — the + * normalized shape every runner reports, virtual and Node alike. + * @type {(e: NotImplemented | IoError, message: string) => void} + */ +const assertIoMessage = (e, message) => { + assert(e[0] === 'ioError', e) + assertEq(e[1].message, message) +} + export const proof = { rm: { success: () => { @@ -86,7 +98,8 @@ export const proof = { awaitNonPromise: () => { // a non-promise value passes through the virtual `await` handler as-is const [, result] = virtual(emptyState)(awaitIfPromise(42)) - assertEq(result, 42) + assert(result[0] === 'ok', result) + assertEq(result[1], 42) }, fetchNotFound: () => { // covers the `result === undefined` branch of the `fetch` handler @@ -144,7 +157,7 @@ export const proof = { // rename('', dst): src parses to the root path itself. const [, result] = virtual(emptyState)(rename('', 'dst')) assert(result[0] === 'error') - assertEq(result[1], 'cannot extract root') + assertIoMessage(result[1], 'cannot extract root') }, renameSrcThroughFile: () => { // rename('a/b', dst) where 'a' is a file, not a directory: the @@ -176,7 +189,7 @@ export const proof = { const root = { 'src': [vec8(0x42n)], 'blocker': [vec8(0x1n)] } const [, result] = virtual({ ...emptyState, root })(rename('src', 'blocker/x')) assert(result[0] === 'error') - assertEq(result[1], 'not a directory') + assertIoMessage(result[1], 'not a directory') }, renameDstNestedError: () => { // rename(src, 'a/b/c') where 'a/b' is a file: insertEntityAt's error @@ -185,7 +198,7 @@ export const proof = { const root = { 'src': [vec8(0x1n)], 'a': { 'b': [vec8(0x2n)] } } const [, result] = virtual({ ...emptyState, root })(rename('src', 'a/b/c')) assert(result[0] === 'error') - assertEq(result[1], 'not a directory') + assertIoMessage(result[1], 'not a directory') }, createExclusiveNestedMissing: () => { // createExclusive('a/b') where 'a' doesn't exist: the operation @@ -229,7 +242,7 @@ export const proof = { const root = { 'file': [vec8(0x1n)] } const [, result] = virtual({ ...emptyState, root })(writeBytes('file', -1, vec8(0x2n))) assert(result[0] === 'error') - assertEq(result[1], 'Offset -1 is invalid') + assertIoMessage(result[1], 'Offset -1 is invalid') }, statNestedMissing: () => { // stat('a/b') where 'a' doesn't exist. @@ -351,7 +364,7 @@ export const proof = { const root = { 'a.f.ts': () => ({}) } const [, result] = virtual({ ...emptyState, root })(stat('a.f.ts')) assert(result[0] === 'error') - assertEq(result[1], `'a.f.ts' is not a file`) + assertIoMessage(result[1], `'a.f.ts' is not a file`) }, largeFileReadBytes: () => { // A file stored as two 128 KiB chunks is larger than maxLengthBytes. diff --git a/fjs/effects/todo/io-effect-migration.md b/fjs/effects/todo/io-effect-migration.md index abcdf51207..361fbd19bb 100644 --- a/fjs/effects/todo/io-effect-migration.md +++ b/fjs/effects/todo/io-effect-migration.md @@ -250,15 +250,17 @@ represented as a pair of effects. ## Stage 3. Make operations IoEffect-compatible -**Blocked by:** Stage 2 (done). +**Done.** Every operation in `fjs/effects/node/types.ts` and +`fjs/effects/memory/types.ts` now declares a `Result` return, every runner +answers with one, and `IoError` exists. -- [ ] Convert operation contracts so the `Result` envelope is in the +- [x] Convert operation contracts so the `Result` envelope is in the operation's declared return type and normal operation constructors return `IoEffect` (see "Where the `Result` envelope lives"). -- [ ] Update every runner handler in the same change as its operation: +- [x] Update every runner handler in the same change as its operation: infallible handlers wrap their output in `ok(...)`; fallible handlers keep their behavior with a refined error type. -- [ ] **Every operation converts; the sweep is total.** There is no list of +- [x] **Every operation converts; the sweep is total.** There is no list of operations worth converting and no exemption for trivially-total ones — an operation left on the raw contract is a hole in the error channel, and Stage 6's "a runner may omit any handler" applies to all operations @@ -268,26 +270,60 @@ represented as a pair of effects. changes the value its consumers receive. Degenerate shapes convert on the same principle — `Result` simply has only its error branch inhabited. -- [ ] Convert each operation together with a sweep of its value-discarding +- [x] Convert each operation together with a sweep of its value-discarding call sites: continuations that use the value break loudly at `tsc`, while continuations that discard it (`() => next`) keep compiling and silently ignore the new error channel — the very hazard motivating this migration. -- [ ] Even operations without domain-specific failures include +- [x] Even operations without domain-specific failures include `NotImplemented`. -- [ ] Fallible operations extend the error channel with their own errors, e.g. +- [x] Fallible operations extend the error channel with their own errors, e.g. `NotImplemented | IoError`. -- [ ] Avoid bare `unknown` as the long-term error type when it would erase the +- [x] Avoid bare `unknown` as the long-term error type when it would erase the `NotImplemented` distinction; normalize host failures into a distinct error representation where needed. -- [ ] Preserve nested/domain `Result` only when `Result` is genuinely returned +- [x] Preserve nested/domain `Result` only when `Result` is genuinely returned data rather than effect execution status (e.g. `SandboxResult.result`, which reports the sandboxed function's outcome, stays as data). -- [ ] Do not migrate all consumers in this stage. +- [x] Do not migrate all consumers in this stage. + +Two aliases carry the envelope: `OpResult` (`Result`) for +an operation with no failures of its own, and `IoResult` — refined in place +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` +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. + +**`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 +them to `IoResult` is a behavior change and belongs in its own issue. + +**The value-discarding sweep needed a vocabulary for policy**, since a site +that discarded the outcome had to start stating what it wants instead: + +- `unwrapStep` (`fjs/effects/io/module.f.mjs`) — leave the layer by panicking. + It is one greppable name rather than an `unwrap` buried in a continuation, so + the set of sites that have *not* yet chosen a real policy is exactly the set + Stage 4 has to visit. +- `exitStep` and `errorMessage` (`fjs/effects/node/module.f.mjs`) — a + `NodeProgram`'s exit-code policy: report on `stderr`, exit `1`. + +`errorExit` keeps discarding its own write's outcome, deliberately: the program +is already failing, and the exit code is `1` whether or not `stderr` accepted +the bytes. ## Stage 4. Migrate consumers -**Blocked by:** Stage 3. +**Blocked by:** Stage 3 (done). + +Start from the `unwrapStep` call sites: each one is a consumer that has not yet +chosen a policy beyond "panic". - [ ] Migrate consumers module by module to IoEffect composition. - [ ] Replace `step(e, okStep(f))` and equivalent manual propagation with diff --git a/fjs/emergent_testing/module.f.mjs b/fjs/emergent_testing/module.f.mjs index 466ed05a56..d977947daf 100644 --- a/fjs/emergent_testing/module.f.mjs +++ b/fjs/emergent_testing/module.f.mjs @@ -19,6 +19,7 @@ import { reset, fgGreen, fgRed, bold, csiWrite } from '../text/sgr/module.f.mjs' import { all, awaitIfPromise, sandbox, test } from '../effects/node/module.f.mjs' import { history, historyStep, mapStep, pure, step } from '../effects/module.f.mjs' +import { unwrapStep } from '../effects/io/module.f.mjs' import { loadModuleMap } from '../dev/module.f.mjs' import { invert } from '../types/result/module.f.mjs' import { definedEntries } from '../types/object/module.f.mjs' @@ -117,8 +118,8 @@ export const registerModule = (ctx, k, v, star) => { // extra suffix is needed. const base = fmtImport(k, path) const name = throws ? base : `${base}${star}` - return test(ctx, name, throws, (/** @type {TestContext} */ t) => - step(awaitIfPromise(fn()), resolved => { + return unwrapStep(test(ctx, name, throws, (/** @type {TestContext} */ t) => + step(unwrapStep(awaitIfPromise(fn())), resolved => { if (throws) { return pure(undefined) } @@ -126,13 +127,13 @@ export const registerModule = (ctx, k, v, star) => { if (sub.length === 0) { return pure(undefined) } - return mapStep(all(...sub.map(e => registerOne(t, e))), () => undefined) + return mapStep(unwrapStep(all(...sub.map(e => registerOne(t, e)))), () => undefined) }) - ) + )) } const tests = collectTests([], false, v) if (tests.length === 0) { return pure(undefined) } - return mapStep(all(...tests.map(e => registerOne(ctx, e))), () => undefined) + return mapStep(unwrapStep(all(...tests.map(e => registerOne(ctx, e)))), () => undefined) } /** @type {(a: _TestState, b: _TestState) => _TestState} */ @@ -175,7 +176,7 @@ const runModule = ({ result, test }) => (k, v) => ts => { /** @type {(path: Path, throws: boolean, v: unknown) => Effect} */ const walk = (path, throws, v) => { const effects = collectTests(path, throws, v).map(one) - return mapStep(all(...effects), states => states.reduce(mergeState, zero)) + return mapStep(unwrapStep(all(...effects)), states => states.reduce(mergeState, zero)) } return mapStep(walk([], false, v), delta => mergeState(ts, delta)) } @@ -198,7 +199,7 @@ export const runModuleMap = reporter => moduleMap => { const { summary } = reporter const modules = proofEntries(moduleMap) const total = mapStep( - all(...modules.map(([k, v]) => runModule(reporter)(k, v)(zero))), + unwrapStep(all(...modules.map(([k, v]) => runModule(reporter)(k, v)(zero)))), m => m.reduce(mergeState, zero)) // The totals are still needed after the summary has been printed, so they // are carried forward in a history rather than closed over by a nested @@ -319,7 +320,7 @@ export const ghEscape = s => * @type {(file: string, path: Path, entry: TestEntry) => Effect>} */ export const defaultTest = (file, path, { fn, throws }) => - mapStep(sandbox(fn), r => throws ? { ...r, result: invert(r.result) } : r) + mapStep(unwrapStep(sandbox(fn)), r => throws ? { ...r, result: invert(r.result) } : r) /** @type {(file: string, path: Path, color: string, label: string, duration: number) => string} */ const fmtResultLine = (file, path, color, label, duration) => @@ -336,10 +337,14 @@ const fmtResultLine = (file, path, color, label, duration) => */ export const defaultReporter = options => { const write = csiWrite(options) + // A reporter that cannot emit its own output has no fallback to choose — + // there is nowhere left to report the failure — so a failed write is this + // program's panic. One `unwrapStep` here covers every line the reporter + // writes. /** @type {(w: WriteConsoles) => (s: string) => Effect} */ const line = w => { const x = write(w) - return s => x(s + '\n') + return s => unwrapStep(x(s + '\n')) } const csiLog = line('stdout') const csiError = line('stderr') diff --git a/fjs/emergent_testing/proof.f.mjs b/fjs/emergent_testing/proof.f.mjs index e15959a534..d41f1e1607 100644 --- a/fjs/emergent_testing/proof.f.mjs +++ b/fjs/emergent_testing/proof.f.mjs @@ -1,6 +1,6 @@ /** * @import { Effect } from '../effects/types.ts' - * @import { NodeProgramOptions, Sandbox, Write } from '../effects/node/types.ts' + * @import { NodeProgramOptions, OpResult, Sandbox, Write } from '../effects/node/types.ts' * @import { JsModule } from '../effects/node/virtual/types.ts' * @import { Reporter } from './types.ts' * @import { All, Await, Import, Readdir, Test, TestContext } from '../effects/node/types.ts' @@ -21,6 +21,7 @@ import { parse as parseJson } from '../media/json/module.f.mjs' import { array, number as rttiNumber, or, string as rttiString } from '../types/rtti/module.f.mjs' import { parse as rttiParse } from '../types/rtti/parse/module.f.mjs' import { ok, unwrap } from '../types/result/module.f.mjs' +import { unwrapStep } from '../effects/io/module.f.mjs' /** * The mock reporter's stdout lines. A schema rather than a hand-written type: @@ -43,8 +44,8 @@ const parseEvent = rttiParse(event) /** @typedef {Reporter} _TestReporter */ -/** @type {(e: _Event) => ReturnType} */ -const writeEvent = e => log(JSON.stringify(e)) +/** @type {(e: _Event) => Effect} */ +const writeEvent = e => unwrapStep(log(JSON.stringify(e))) /** @type {(stdout: string) => readonly _Event[]} */ const parseEvents = stdout => @@ -315,7 +316,7 @@ export const githubReporterOutput = () => { * name: string, * expectFailure: boolean, * fn: (t: TestContext) => Effect<_RegisterMockOps, void>, - * ) => (s: _RegisterMockState) => readonly [_RegisterMockState, void]} _RegisterTestOp + * ) => (s: _RegisterMockState) => readonly [_RegisterMockState, OpResult]} _RegisterTestOp */ /** @type {TestContext} */ @@ -332,15 +333,17 @@ const makeRegisterRunner = testOp => { let runner runner = mockRun(/** @type {Parameters>[0]} */ ({ test: (ctx, name, xf, fn) => testOp(runner, ctx, name, xf, fn), - all: (...effects) => s => - effects.reduce( - ([st, rs], e) => { - const [ns, r] = runner(st)(e) - return [ns, [...rs, r]] + all: (...effects) => s => { + const [st, rs] = effects.reduce( + ([st1, rs1], e) => { + const [ns, r] = runner(st1)(e) + return [ns, [...rs1, r]] }, /** @type {readonly [_RegisterMockState, readonly unknown[]]} */ ([s, []]), - ), - await: p => s => /** @type {const} */ ([s, [p]]), + ) + return [st, ok(rs)] + }, + await: p => s => /** @type {const} */ ([s, ok([p])]), })) return runner } @@ -348,7 +351,7 @@ const makeRegisterRunner = testOp => { // registerModule appends ' ...' for inline runners (Bun). // This mock never invokes the registered callback; it only records names. export const registerSuffixes = () => { - const runner = makeRegisterRunner((_runner, _ctx, name, _xf, _fn) => s => [[...s, name], undefined]) + const runner = makeRegisterRunner((_runner, _ctx, name, _xf, _fn) => s => [[...s, name], ok(undefined)]) const proof = /** @type {const} */ ({ ok: () => {}, @@ -381,7 +384,7 @@ export const registerThrowsWithoutThrowing = () => { const runner = makeRegisterRunner((runner, ctx, name, xf, fn) => s => { assert(xf) const [ns] = runner(s)(fn(ctx)) - return [[...ns, name], undefined] + return [[...ns, name], ok(undefined)] }) // Returns a sub-tree that would register more tests if it were walked. @@ -396,7 +399,7 @@ export const registerThrowsWithoutThrowing = () => { // registerModule with an empty proof object registers zero tests and // returns without invoking the mock's `test` op at all. export const registerEmptyProof = () => { - const runner = makeRegisterRunner((_runner, _ctx, name, _xf, _fn) => s => [[...s, name], undefined]) + const runner = makeRegisterRunner((_runner, _ctx, name, _xf, _fn) => s => [[...s, name], ok(undefined)]) const [names] = runner([])(registerModule(registerNoopCtx, './a.f.ts', {}, '')) assertEq(names.length, 0) } @@ -434,16 +437,18 @@ export const registerSelectsContextAndStar = () => { runner = mockRun(/** @type {Parameters>[0]} */ ({ readdir: (_path, _o) => s => [s, ok([{ name: 'a.proof.f.ts', parentPath: '.', isFile: true }])], import: _path => s => [s, ok({ proof: { ok: () => {} } })], - all: (...effects) => s => - effects.reduce( - ([st, rs], e) => { - const [ns, r] = runner(st)(e) - return [ns, [...rs, r]] + all: (...effects) => s => { + const [st, rs] = effects.reduce( + ([st1, rs1], e) => { + const [ns, r] = runner(st1)(e) + return [ns, [...rs1, r]] }, /** @type {readonly [undefined, readonly unknown[]]} */ ([s, []]), - ), - await: p => s => [s, [p]], - test: (ctx, name, _xf, _fn) => s => { calls = [...calls, [ctx, name]]; return [s, undefined] }, + ) + return [st, ok(rs)] + }, + await: p => s => [s, ok([p])], + test: (ctx, name, _xf, _fn) => s => { calls = [...calls, [ctx, name]]; return [s, ok(undefined)] }, })) runner(undefined)(/** @type {Effect<_RegisterMockOps | Readdir | Import, number>} */ (register({ ...defaultNodeProgramOptions, env: {}, testContext: nodeCtx, bunTestContext: bunCtx, ...extra, diff --git a/fjs/mcp/cas/proof.f.mjs b/fjs/mcp/cas/proof.f.mjs index eb59ecb789..af2e633b0d 100644 --- a/fjs/mcp/cas/proof.f.mjs +++ b/fjs/mcp/cas/proof.f.mjs @@ -10,6 +10,7 @@ import { casToolRegistry } from './module.f.mjs' import { match } from '../../effects/module.f.mjs' import { error, ok } from '../../types/result/module.f.mjs' +import { ioError } from '../../effects/node/module.f.mjs' import { vec, vec8 } from '../../types/bit_vec/module.f.mjs' import { vecToCBase32 } from '../../basen/cbase32/module.f.mjs' import { assert, assertEq } from '../../asserts/module.f.mjs' @@ -25,8 +26,8 @@ import { parse as parseJson } from '../../media/json/module.f.mjs' /** @type {(cmd: string) => unknown} */ const defaultResponse = cmd => { switch (cmd) { - case 'now': return 0 - case 'randomInt': return 0 + case 'now': return ok(0) + case 'randomInt': return ok(0) case 'mkdir': case 'createExclusive': case 'rename': case 'rm': case 'writeBytes': case 'access': return ok(undefined) @@ -35,9 +36,9 @@ const defaultResponse = cmd => { // An empty chunk reads as end-of-stream, so a `readBytes` call with no // override reads as an immediately-empty file by default. case 'readBytes': return ok(vec(0n)(0n)) - case 'memCreate': return /** @type {unknown} */ ('mem-key') - case 'memRead': return undefined - case 'memWrite': return undefined + case 'memCreate': return ok(/** @type {unknown} */ ('mem-key')) + case 'memRead': return ok(undefined) + case 'memWrite': return ok(undefined) default: return ok(undefined) } } @@ -119,7 +120,7 @@ export const proof = { // success. casAddWriteErrorReturnsError: () => { const result = /** @type {ToolsCallResult} */ ( - drive({ writeBytes: [error('disk full')] })(toolHandle('cas_add')({ content: 'hello' })) + drive({ writeBytes: [error(ioError({ message: 'disk full' }))] })(toolHandle('cas_add')({ content: 'hello' })) ) assert(result.isError === true, ['expected isError', result]) }, @@ -130,7 +131,7 @@ export const proof = { // streaming verdict rather than fail the whole request. casGetMetadataRefineHashVanishesFallsBackToStreamingVerdict: () => { const result = /** @type {ToolsCallResult} */ ( - drive({ readBytes: [ok(vec8(0x41n)), ok(vec(0n)(0n)), error('vanished')] }) + drive({ readBytes: [ok(vec8(0x41n)), ok(vec(0n)(0n)), error(ioError({ message: 'vanished' }))] }) (toolHandle('cas_get')({ hash: someHash, content: false })) ) assert(result.isError !== true, ['expected ok result', result]) @@ -147,7 +148,7 @@ export const proof = { // to fall back to once inline content was actually promised. casGetContentFetchHashVanishesReturnsError: () => { const result = /** @type {ToolsCallResult} */ ( - drive({ readBytes: [ok(vec8(0x41n)), ok(vec(0n)(0n)), error('vanished')] }) + drive({ readBytes: [ok(vec8(0x41n)), ok(vec(0n)(0n)), error(ioError({ message: 'vanished' }))] }) (toolHandle('cas_get')({ hash: someHash, content: true })) ) assert(result.isError === true, ['expected isError', result]) diff --git a/fjs/mcp/module.f.mjs b/fjs/mcp/module.f.mjs index 431f1d0548..52facc6d1f 100644 --- a/fjs/mcp/module.f.mjs +++ b/fjs/mcp/module.f.mjs @@ -36,6 +36,7 @@ */ import { step } from '../effects/module.f.mjs' +import { unwrapStep } from '../effects/io/module.f.mjs' import { create } from '../effects/memory/module.f.mjs' import { stdioTransport } from '../protocol/mcp/stdio/module.f.mjs' import { @@ -83,7 +84,7 @@ export const casConfig = { export const casMcpServer = home => step( initEvo(fileCas(sha256)(home)), cacheKey => step( - create(uninitializedState), + unwrapStep(create(uninitializedState)), sessionKey => stdioTransport(mcpStep(casConfig)(casMcpHandlers(home)(cacheKey))(sessionKey)), ), diff --git a/fjs/mcp/proof.f.mjs b/fjs/mcp/proof.f.mjs index 7298aaf391..4ddb5c8a36 100644 --- a/fjs/mcp/proof.f.mjs +++ b/fjs/mcp/proof.f.mjs @@ -12,6 +12,7 @@ import { assert, assertEq } from '../asserts/module.f.mjs' import { pure, step } from '../effects/module.f.mjs' +import { unwrapStep } from '../effects/io/module.f.mjs' import { create } from '../effects/memory/module.f.mjs' import { parse as parseJson } from '../media/json/module.f.mjs' import { number as rttiNumber, option, string as rttiString } from '../types/rtti/module.f.mjs' @@ -80,7 +81,7 @@ const runSessionVirtual = const effect = step( initEvo(fileCas(sha256)(home)), cacheKey => step( - create(uninitializedState), + unwrapStep(create(uninitializedState)), sessionKey => { const step = mcpStep(casConfig)(casMcpHandlers(home)(cacheKey))(sessionKey) return feed(step)(msgs) @@ -143,7 +144,7 @@ const runStdio = const effect = step( initEvo(fileCas(sha256)(home)), cacheKey => step( - create(uninitializedState), + unwrapStep(create(uninitializedState)), sessionKey => stdioTransport(mcpStep(casConfig)(casMcpHandlers(home)(cacheKey))(sessionKey)) ) diff --git a/fjs/media/type/proof.f.mjs b/fjs/media/type/proof.f.mjs index 6c7adddaab..4cbc259c30 100644 --- a/fjs/media/type/proof.f.mjs +++ b/fjs/media/type/proof.f.mjs @@ -1,7 +1,7 @@ /** * @import { Vec } from '../../types/bit_vec/types.ts' * @import { List } from '../../effects/list/types.ts' - * @import { Result } from '../../types/result/types.ts' + * @import { IoResult } from '../../effects/node/types.ts' * @import { DetectMeta } from './types.ts' */ @@ -9,7 +9,8 @@ import { assert, assertEq } from '../../asserts/module.f.mjs' import { msb, u8ListToVec, vec8, repeat, empty } from '../../types/bit_vec/module.f.mjs' import { runPure } from '../../effects/module.f.mjs' import { nonEmpty, empty as emptyList } from '../../effects/list/module.f.mjs' -import { ok } from '../../types/result/module.f.mjs' +import { error, ok } from '../../types/result/module.f.mjs' +import { ioError } from '../../effects/node/module.f.mjs' import { detect, detectStream, detectVec } from './module.f.mjs' // Builds a big-endian `Vec` from a list of byte values — mirrors how the CAS @@ -20,11 +21,11 @@ const bytes = (...b) => u8ListToVec(msb)(b) // ── Streaming detector helpers ────────────────────────────────────────────────── // Builds a CAS-style read stream from a sequence of ok(chunk) items. -/** @type {(...chunks: readonly Vec[]) => List>} */ +/** @type {(...chunks: readonly Vec[]) => List>} */ const stream = (...chunks) => chunks.reduceRight( (tail, c) => nonEmpty(ok(c), tail), - /** @satisfies {List>} */ (emptyList())) + /** @satisfies {List>} */ (emptyList())) // Runs the streaming detector over the given chunks and unwraps the metadata. /** @type {(...chunks: readonly Vec[]) => DetectMeta} */ @@ -291,9 +292,9 @@ export const proof = { // A read `error` item short-circuits into the IoResult error. readErrorSurfaces: () => { - /** @type {List>} */ + /** @type {List>} */ const errStream = - nonEmpty(/** @type {const} */ (['error', 'boom']), emptyList()) + nonEmpty(error(ioError({ message: 'boom' })), emptyList()) const o = runPure(detectStream(errStream)) assert(o.length === 1, 'effect is not pure') assert(o[0][0] === 'error') diff --git a/fjs/nanvm/update/module.f.mjs b/fjs/nanvm/update/module.f.mjs index 291cd308e1..e64cfb7b61 100644 --- a/fjs/nanvm/update/module.f.mjs +++ b/fjs/nanvm/update/module.f.mjs @@ -14,7 +14,7 @@ import { mapStep, step } from '../../effects/module.f.mjs' import { mkdir, writeUtf8File } from '../../effects/node/module.f.mjs' -import { unwrap } from '../../types/result/module.f.mjs' +import { unwrapStep } from '../../effects/io/module.f.mjs' import { data } from '../module.f.mjs' import { directory, generate, path } from '../rust/module.f.mjs' @@ -24,9 +24,9 @@ import { directory, generate, path } from '../rust/module.f.mjs' * @type {() => Effect} */ export const generateRustTests = () => { - const directoryReady = mapStep(mkdir(directory, { recursive: true }), unwrap) + const directoryReady = unwrapStep(mkdir(directory, { recursive: true })) const written = step(directoryReady, () => writeUtf8File(path, generate(data))) - return mapStep(written, unwrap) + return unwrapStep(written) } /** @type {NodeProgram} */ diff --git a/fjs/protocol/mcp/module.f.mjs b/fjs/protocol/mcp/module.f.mjs index 846cb6da8c..fe3010a243 100644 --- a/fjs/protocol/mcp/module.f.mjs +++ b/fjs/protocol/mcp/module.f.mjs @@ -35,6 +35,7 @@ import { boolean, string, option, array, record, or } from '../../types/rtti/module.f.mjs' import { pure, step } from '../../effects/module.f.mjs' +import { unwrapStep } from '../../effects/io/module.f.mjs' import { read, write } from '../../effects/memory/module.f.mjs' import { decodeRequest, @@ -289,10 +290,10 @@ export const mcpStep = ({ return pure(null) } return step( - read(stateKey), + unwrapStep(read(stateKey)), ([t]) => t === 'initializing' ? step( - write(stateKey, ['initialized', true]), + unwrapStep(write(stateKey, ['initialized', true])), () => pure(null), ) : pure(null), @@ -313,7 +314,7 @@ export const mcpStep = ({ // `initialize` transitions uninitialized → initializing; reject if already done. if (method === 'initialize') { return step( - read(stateKey), + unwrapStep(read(stateKey)), ([t]) => { if (t !== 'uninitialized') { return pure(_errResponse(id)(invalidRequest)) @@ -338,7 +339,7 @@ export const mcpStep = ({ // All other methods require fully initialized state — read it first. return step( - read(stateKey), + unwrapStep(read(stateKey)), ([t]) => { if (t !== 'initialized') { return pure(_errResponse(id)(notInitialized)) diff --git a/fjs/protocol/mcp/proof.f.mjs b/fjs/protocol/mcp/proof.f.mjs index 344ed62b00..56422a3a12 100644 --- a/fjs/protocol/mcp/proof.f.mjs +++ b/fjs/protocol/mcp/proof.f.mjs @@ -14,6 +14,8 @@ import { assert, assertEq } from '../../asserts/module.f.mjs' import { history, historyStep, mapStep, pure, step, runPure } from '../../effects/module.f.mjs' +import { unwrapStep } from '../../effects/io/module.f.mjs' +import { ok } from '../../types/result/module.f.mjs' import { run } from '../../effects/mock/module.f.mjs' import { asBase, asNominal, create, read } from '../../effects/memory/module.f.mjs' import { @@ -36,12 +38,12 @@ const mock = { const id = `k${state.next}` /** @type {Key} */ const key = asNominal(id) - return [{ next: state.next + 1, values: { ...state.values, [id]: value } }, key] + return [{ next: state.next + 1, values: { ...state.values, [id]: value } }, ok(key)] }, - memRead: key => state => [state, state.values[asBase(key)]], + memRead: key => state => [state, ok(state.values[asBase(key)])], memWrite: (key, value) => state => { const id = asBase(key) - return [{ ...state, values: { ...state.values, [id]: value } }, undefined] + return [{ ...state, values: { ...state.values, [id]: value } }, ok(undefined)] }, } @@ -85,7 +87,7 @@ const asMemEffect = e => /** @type {Effect} */ (e) // history rather than closed over by a nested continuation. /** @type {(key: Key) => (e: Effect) => Effect} */ const withState = key => e => { - const read0 = historyStep(history(e), () => read(key)) + const read0 = historyStep(history(e), () => unwrapStep(read(key))) return mapStep(read0, ([state, resp]) => /** @type {const} */ ([resp, state])) } @@ -93,14 +95,14 @@ const withState = key => e => { /** @type {(cfg: McpConfig) => (msg: Unknown) => _StepResult} */ const step1 = cfg => msg => runMem(asMemEffect(step( - create(uninitializedState), + unwrapStep(create(uninitializedState)), key => withState(key)(mcpStep(cfg)(handlers)(key)(msg))))) // Run initialize then a second step, return [response, newState] of the second. /** @type {(cfg: McpConfig) => (msg1: Unknown) => (msg2: Unknown) => _StepResult} */ const step2 = cfg => msg1 => msg2 => runMem(asMemEffect(step( - create(uninitializedState), + unwrapStep(create(uninitializedState)), key => { const r1 = mcpStep(cfg)(handlers)(key)(msg1) const r2 = step(r1, () => mcpStep(cfg)(handlers)(key)(msg2)) @@ -111,7 +113,7 @@ const step2 = cfg => msg1 => msg2 => /** @type {(cfg: McpConfig) => (msg1: Unknown) => (msg2: Unknown) => (msg3: Unknown) => _StepResult} */ const step3 = cfg => msg1 => msg2 => msg3 => runMem(asMemEffect(step( - create(uninitializedState), + unwrapStep(create(uninitializedState)), key => { const r1 = mcpStep(cfg)(handlers)(key)(msg1) const r2 = step(r1, () => mcpStep(cfg)(handlers)(key)(msg2)) diff --git a/fjs/protocol/mcp/stdio/module.f.mjs b/fjs/protocol/mcp/stdio/module.f.mjs index 2a1fc42a61..24b28ba25e 100644 --- a/fjs/protocol/mcp/stdio/module.f.mjs +++ b/fjs/protocol/mcp/stdio/module.f.mjs @@ -36,12 +36,13 @@ */ import { pure, step } from '../../../effects/module.f.mjs' -import { readLine, write } from '../../../effects/node/module.f.mjs' +import { unwrapStep } from '../../../effects/io/module.f.mjs' +import { ioError, readLine, write } from '../../../effects/node/module.f.mjs' import { tryUtf8 } from '../../../text/module.f.mjs' import { parse, stringify } from '../../../media/json/module.f.mjs' import { sort } from '../../../types/object/module.f.mjs' import { internalError, jsonrpc, parseError } from '../../json_rpc/module.f.mjs' -import { error, ok } from '../../../types/result/module.f.mjs' +import { error } from '../../../types/result/module.f.mjs' const stringifyJson = stringify(sort) @@ -60,11 +61,8 @@ const internalErrorResponse = id => ({ jsonrpc, error: internalError, id }) const writeResponse = resp => { const v = tryUtf8(stringifyJson(resp) + '\n') return v === null - ? pure(error(undefined)) - : step( - write('stdout', v), - () => pure(ok(undefined)), - ) + ? pure(error(ioError({ message: 'response does not encode as UTF-8' }))) + : write('stdout', v) } /** @@ -79,7 +77,10 @@ const writeResponse = resp => { */ export const stdioTransport = handler => step( - readLine('stdin'), + // A transport that cannot read its own input has no fallback to choose, + // so a `readLine` failure is this program's panic rather than a value + // threaded through the loop. + unwrapStep(readLine('stdin')), line => line === null ? pure(undefined) : handleLine(handler)(line), diff --git a/fjs/text/sgr/module.f.mjs b/fjs/text/sgr/module.f.mjs index b3bed8c2c2..ec33ee4955 100644 --- a/fjs/text/sgr/module.f.mjs +++ b/fjs/text/sgr/module.f.mjs @@ -6,7 +6,7 @@ * * @module * - * @import { Write, WriteConsoles, NodeProgramOptions } from '../../effects/node/types.ts' + * @import { Console, Write, WriteConsoles, NodeProgramOptions } from '../../effects/node/types.ts' * @import { Effect } from '../../effects/types.ts' * @import { Stdout, WriteText, CsiConsole } from './types.ts' */ @@ -92,7 +92,7 @@ const str = isTTY => s => * stream is not a TTY, then encodes to UTF-8 and emits a `Write` effect. * Does NOT append `\n` — callers are responsible for line termination. * - * @type {(options: NodeProgramOptions) => (stream: WriteConsoles) => (s: string) => Effect} + * @type {(options: NodeProgramOptions) => (stream: WriteConsoles) => Console} */ export const csiWrite = ({ std }) => stream => { const toStr = str(std[stream].isTTY) diff --git a/fjs/website/module.f.mjs b/fjs/website/module.f.mjs index d7e86144a7..32f540d61b 100644 --- a/fjs/website/module.f.mjs +++ b/fjs/website/module.f.mjs @@ -3,13 +3,12 @@ * * @module * - * @import { WriteFile } from '../effects/node/types.ts' + * @import { Write, WriteFile } from '../effects/node/types.ts' * @import { Effect } from '../effects/types.ts' */ import { htmlUtf8 } from '../media/html/module.f.mjs' -import { writeFile } from '../effects/node/module.f.mjs' -import { pure, step } from '../effects/module.f.mjs' +import { exitStep, writeFile } from '../effects/node/module.f.mjs' const html = htmlUtf8()( ['a', @@ -17,9 +16,7 @@ const html = htmlUtf8()( 'GitHub Repository' ]) -/** @type {Effect} */ -const program = step( - writeFile('index.html', html), - () => pure(0)) +/** @type {Effect} */ +const program = exitStep(writeFile('index.html', html)) export const main = () => program