diff --git a/AGENTS.md b/AGENTS.md index cb6d504..455fbe6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,6 +39,14 @@ Low-value wrappers: Development guidance: - Prefer existing patterns in `src/Fx.ts`, `src/Effect.ts`, and `src/Handler.ts`. - Preserve strong effect typing. Changes should keep `E` unions meaningful and narrowed by handlers. +- Default global scope discipline: + - `GlobalScope` is a real exported scope value, not an ambient capability. + - Choose `GlobalScope` only when the producer and handler are owned together in local app code. + - Choose a named scope when the scope is part of a reusable or composable boundary. + - Prefer explicit named scopes in reusable functions, public APIs, libraries, nested control regions, resources, retries, and examples that teach composition. + - Do not broaden default-scope overloads to every scoped effect by default; add focused runtime and type tests for each new default-scope API. + - Keep default-scope requirements visible in types as `typeof GlobalScope` rather than hiding scope requirements behind ambient state. + - Do not use `GlobalScope` as a catch-all for unrelated control effects; a global-scope handler may catch exits it did not intend to own. - Use `Fail` for recoverable errors. In fx code, JS `throw` is reserved for intentionally hard-crashing the program, enforcing internal invariants, or clearly named unsafe/assert APIs such as `Fail.assert`. - At runtime and platform boundaries, convert rejected promises, thrown platform errors, and recoverable exceptional states into `Fail` with `tryPromise`, `trySync`, or `fail`. Recover with `catchAll`, `catchOnly`, or `catchIf` rather than throwing from handlers. - Organize source modules from higher-level public constructs to lower-level implementation details: effect declarations first, public constructors/handlers next, exported support types after that, and internal helpers last. diff --git a/docs/agent-guide.md b/docs/agent-guide.md index ff15205..9d33952 100644 --- a/docs/agent-guide.md +++ b/docs/agent-guide.md @@ -124,6 +124,21 @@ const main = program.pipe( Do not hide large handler stacks behind broad framework-like layers unless the named boundary is real and useful for the application. +## Default global scope + +`GlobalScope` is a real typed scope value, not ambient state. Use the default +global scope when the program and its handler are owned together in local +application code and there is only one intended control region. Default scope +overloads such as `scope()`, `abort()`, `orReturn(value)`, and +`returnFrom(value)` keep that case compact while preserving the effect +requirement in the type. + +Use a named scope when the control region has an owner that should remain +visible to callers: reusable helpers, public APIs, nested regions, resources, +retries, or examples that teach handler composition. A handler for the global +scope can catch any same-scope exit, so do not use it as a catch-all for +unrelated control effects. + ## Choose the right runner - Use `run` only after all async, failure, handler-capture, and platform effects diff --git a/docs/recipes/manage-resources-and-finalizers.md b/docs/recipes/manage-resources-and-finalizers.md index 445d805..1b5c774 100644 --- a/docs/recipes/manage-resources-and-finalizers.md +++ b/docs/recipes/manage-resources-and-finalizers.md @@ -27,6 +27,10 @@ const program = fx(function* () { Use `using`, `usingExit`, or `usingManaged` to acquire and register cleanup in a small uninterruptible region. +Use `scope()` only for small local programs with one obvious cleanup or control +region. Prefer named scopes when resources are nested, reusable, or exposed from +a helper. + Handler pipeline: ```ts diff --git a/docs/recipes/use-scoped-control.md b/docs/recipes/use-scoped-control.md new file mode 100644 index 0000000..0791228 --- /dev/null +++ b/docs/recipes/use-scoped-control.md @@ -0,0 +1,67 @@ +# Use Scoped Control + +Use this when a computation needs to exit a delimited region early. + +Use the default global scope when the program and its handler are owned together +in local application code and there is only one intended control region. It +keeps that case compact while preserving the effect requirement in the type. + +```ts +import { abort, orReturn } from "@briancavalier/fx/Abort" +import { fx, run } from "@briancavalier/fx" +import { scope } from "@briancavalier/fx/Scope" + +const loadUser = (id: string | undefined) => fx(function* () { + if (id === undefined) yield* abort() + + return yield* fetchUser(id) +}) + +const user = loadUser(input.id).pipe( + scope(), + orReturn(guestUser), + run +) +``` + +Use an explicit named scope when the control region has an owner that should +remain visible to callers. That includes reusable helpers, public APIs, nested +regions, resources, retries, and examples that teach handler composition. + +```ts +import { abort, orReturn, restartOnAbort } from "@briancavalier/fx/Abort" +import { fx, run } from "@briancavalier/fx" +import { returnFrom } from "@briancavalier/fx/ReturnFrom" +import { scope } from "@briancavalier/fx/Scope" + +const RequestScope = "app/Request" as const + +const loadRequest = (request: Request) => fx(function* () { + if (!request.authorized) yield* returnFrom(RequestScope, unauthorized) + + const user = yield* loadUser(request.userId) + if (user.disabled) yield* abort(RequestScope) + + return okResponse(user) +}) + +const response = loadRequest(request).pipe( + restartOnAbort(RequestScope, { restarts: 1 }), + scope(RequestScope), + orReturn(RequestScope, unavailable), + run +) +``` + +`restartOnAbort` takes an explicit scope. Retryable control has a visible owner, +so do not treat the global scope as its default. + +For `YieldFrom`, use `yieldScope()("scope/name")` when one named scope +has one yield protocol. When one runtime scope intentionally carries multiple +protocols, compose protocol-map `Yielding` brands on that +scope. Output types compose by union and input types compose by intersection. +Prefer explicit scopes when request and response protocols need tighter +correlation. + +Common mistake: using the global scope as a catch-all for unrelated exits. A +global-scope handler can catch any exit that uses the same global scope. diff --git a/examples/intermediate/read-csv.ts b/examples/intermediate/read-csv.ts index 209baae..10b65c0 100644 --- a/examples/intermediate/read-csv.ts +++ b/examples/intermediate/read-csv.ts @@ -4,8 +4,8 @@ import { assert as assertNoFail } from '../../src/Fail.js' import { managed, usingManaged } from '../../src/Finalization.js' import { handleScoped } from '../../src/Handler.js' import { returnFrom } from '../../src/ReturnFrom.js' -import { brand, scope } from '../../src/Scope.js' -import { yieldFrom, YieldFrom, type Yielding } from '../../src/YieldFrom.js' +import { scope } from '../../src/Scope.js' +import { yieldFrom, YieldFrom, yieldScope } from '../../src/YieldFrom.js' const ImportCsv = 'examples/intermediate/ImportCsv' as const @@ -25,8 +25,8 @@ type IndexedCsvRow = { readonly value: CsvRow } -const CsvRows = brand>()('examples/intermediate/CsvRows') -const IndexedCsvRows = brand>()('examples/intermediate/IndexedCsvRows') +const CsvRows = yieldScope()('examples/intermediate/CsvRows') +const IndexedCsvRows = yieldScope()('examples/intermediate/IndexedCsvRows') const stopImport = (reason: string) => returnFrom(ImportCsv, { type: 'skipped', reason } satisfies ImportResult) diff --git a/package.json b/package.json index 3ff5d1c..28f0492 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,10 @@ "types": "./dist/Fail.d.ts", "import": "./dist/Fail.js" }, + "./GlobalScope": { + "types": "./dist/GlobalScope.d.ts", + "import": "./dist/GlobalScope.js" + }, "./Concurrent": { "types": "./dist/Concurrent.d.ts", "import": "./dist/Concurrent.js" diff --git a/src/Abort.test.ts b/src/Abort.test.ts index 9e615a7..6e14071 100644 --- a/src/Abort.test.ts +++ b/src/Abort.test.ts @@ -7,12 +7,29 @@ import { fail, Fail, returnFail } from './Fail.js' import { andFinally } from './Finalization.js' import { fx, ok, run, type Fx } from './Fx.js' import { returnFrom } from './ReturnFrom.js' -import { scope } from './Scope.js' +import { scope, GlobalScope } from './Scope.js' describe('Abort', () => { const TestScope = 'test/Abort' as const describe('scope', () => { + it('defaults to the global scope', () => { + const r = Math.random() + const a = abort().pipe(scope(), orReturn(r), run) + + assert.equal(a, r) + }) + + it('leaves default Abort unhandled when global fallback is omitted', () => { + const f = abort().pipe(scope()) + const _: typeof f extends import('./Fx.js').Fx, never> ? true : false = true + + const next = f[Symbol.iterator]().next() + + assert.equal(Abort.is(next.value), true) + assert.equal((next.value as Abort).scope, GlobalScope) + }) + it('given matching Abort with fallback, returns alternative', () => { const r = Math.random() const a = abort(TestScope).pipe(scope(TestScope), orReturn(TestScope, r), run) @@ -43,6 +60,18 @@ describe('Abort', () => { assert.equal(Abort.is(f[Symbol.iterator]().next().value), true) }) + + it('does not handle explicit Abort with the global fallback', () => { + const f = fx(function* () { + yield* abort(TestScope) + return 'done' + }).pipe(scope(TestScope), orReturn('aborted')) + + const next = f[Symbol.iterator]().next() + + assert.equal(Abort.is(next.value), true) + assert.equal((next.value as Abort).scope, TestScope) + }) }) describe('restartOnAbort', () => { diff --git a/src/Abort.ts b/src/Abort.ts index 6e13cf6..1eba7c6 100644 --- a/src/Abort.ts +++ b/src/Abort.ts @@ -1,6 +1,7 @@ import { at } from './Breadcrumb.js' import { ScopedEffect, withOrigin } from './Effect.js' import { Fx, fx, ok } from './Fx.js' +import { GlobalScope } from './GlobalScope.js' import { control } from './Handler.js' import { scope as scoped, type ReturnValue, type ScopeEffects } from './Scope.js' @@ -9,22 +10,35 @@ import { scope as scoped, type ReturnValue, type ScopeEffects } from './Scope.js */ export class Abort extends ScopedEffect('fx/Abort') { } -export const abort = (scope: Scope): Fx, never> => - withOrigin(new Abort(scope, undefined), at('fx/Abort/abort', abort)) +export function abort(): Fx, never> +export function abort(scope: Scope): Fx, never> +export function abort(scope: string = GlobalScope): Fx, never> { + return withOrigin(new Abort(scope, undefined), at('fx/Abort/abort', abort)) +} /** * Return a default value from an abort of the named scope. */ -export const orReturn = ( +export function orReturn( + value: R +): (f: Fx) => Fx>, A | R> +export function orReturn( scope: Scope, value: R -) => ( - f: Fx -): Fx>, A | R> => +): (f: Fx) => Fx>, A | R> +export function orReturn( + scopeOrValue: unknown, + maybeValue?: unknown +): unknown { + const scope = arguments.length === 1 ? GlobalScope : scopeOrValue as string + const value = arguments.length === 1 ? scopeOrValue : maybeValue + + return (f: Fx): Fx>, unknown> => f.pipe( control(Abort, (_, abort) => - (abort.scope === scope ? ok(value) : abort) as Fx>, A | R>) - ) as Fx>, A | R> + (abort.scope === scope ? ok(value) : abort) as Fx>, unknown>) + ) as Fx>, unknown> +} export interface RestartOnAbortOptions { /** diff --git a/src/Finalization.test.ts b/src/Finalization.test.ts index 969b888..9e7882c 100644 --- a/src/Finalization.test.ts +++ b/src/Finalization.test.ts @@ -7,12 +7,12 @@ import { fx, ok, run, type Fx } from './Fx.js' import { andFinally, andFinallyExit, managed, using, usingExit, usingManaged, type Finally, type Managed } from './Finalization.js' import type { Interrupt } from './Interrupt.js' import { returnFrom } from './ReturnFrom.js' -import { brand, scope, type Exit } from './Scope.js' -import { collectFrom, YieldFrom, yieldFrom, type Yielding } from './YieldFrom.js' +import { scope, type Exit } from './Scope.js' +import { collectFrom, YieldFrom, yieldFrom, yieldScope } from './YieldFrom.js' describe('Finalization', () => { const TestScope = 'test/Finalization' as const - const CleanupEvents = brand>()('test/Finalization/cleanup') + const CleanupEvents = yieldScope<'cleanup'>()('test/Finalization/cleanup') it('preserves finalizer effects in constructor types', () => { const releaseFailure = new Error('release failed') diff --git a/src/GlobalScope.ts b/src/GlobalScope.ts new file mode 100644 index 0000000..f6437ee --- /dev/null +++ b/src/GlobalScope.ts @@ -0,0 +1,4 @@ +/** + * Default scope for local application code that does not need a named scope. + */ +export const GlobalScope = 'fx/Scope/Global' as const diff --git a/src/ReturnFrom.test.ts b/src/ReturnFrom.test.ts index 0adebf7..108ee1c 100644 --- a/src/ReturnFrom.test.ts +++ b/src/ReturnFrom.test.ts @@ -2,11 +2,23 @@ import * as assert from 'node:assert/strict' import { describe, it } from 'node:test' import { fx, run } from './Fx.js' import { ReturnFrom, returnFrom } from './ReturnFrom.js' -import { scope } from './Scope.js' +import { scope, GlobalScope } from './Scope.js' describe('ReturnFrom', () => { const TestScope = 'test/ReturnFrom' as const + it('defaults to returning early from the global scope', () => { + const result = fx(function* () { + yield* returnFrom('early') + return 'late' + }).pipe( + scope(), + run + ) + + assert.equal(result, 'early') + }) + it('returns early from the matching scope with a value', () => { const result = fx(function* () { yield* returnFrom(TestScope, 'early') @@ -19,6 +31,20 @@ describe('ReturnFrom', () => { assert.equal(result, 'early') }) + it('propagates global returnFrom through an explicit scope', () => { + const f = fx(function* () { + yield* returnFrom('global') + return 'late' + }).pipe(scope(TestScope)) + + const next = f[Symbol.iterator]().next() + + assert.equal(ReturnFrom.is(next.value), true) + const effect = next.value as ReturnFrom + assert.equal(effect.scope, GlobalScope) + assert.equal(effect.arg, 'global') + }) + it('does not run code after returnFrom', () => { let ran = false diff --git a/src/ReturnFrom.ts b/src/ReturnFrom.ts index 4b4b657..0357185 100644 --- a/src/ReturnFrom.ts +++ b/src/ReturnFrom.ts @@ -1,13 +1,24 @@ import { ScopedEffect } from './Effect.js' import { Fx } from './Fx.js' +import { GlobalScope } from './GlobalScope.js' /** * Return early from the named scope with a value. */ export class ReturnFrom extends ScopedEffect('fx/ReturnFrom') { } -export const returnFrom = ( +export function returnFrom( + value: A +): Fx, never> +export function returnFrom( scope: Scope, value: A -): Fx, never> => - new ReturnFrom(scope, value) +): Fx, never> +export function returnFrom( + scopeOrValue: unknown, + maybeValue?: unknown +): Fx, never> { + return arguments.length === 1 + ? new ReturnFrom(GlobalScope, scopeOrValue) + : new ReturnFrom(scopeOrValue as string, maybeValue) +} diff --git a/src/Scope.ts b/src/Scope.ts index 608eaf6..fc5fe2f 100644 --- a/src/Scope.ts +++ b/src/Scope.ts @@ -3,6 +3,7 @@ import { isEffect } from './Effect.js' import { Fail, fail, returnFail } from './Fail.js' import { Finalizer, Finally } from './Finalization.js' import { Fx, fx } from './Fx.js' +import { GlobalScope } from './GlobalScope.js' import { CapturedHandler, HandlerCapture } from './HandlerCapture.js' import { ReturnFrom } from './ReturnFrom.js' import { drainIteratorReturn, isInterpretingReturn } from './internal/iteratorClose.js' @@ -13,6 +14,8 @@ export const brand = () => (name: Name): Name & Brand => name as Name & Brand +export { GlobalScope } + export type Exit< Scope extends string = string, A = unknown, @@ -51,11 +54,15 @@ export interface Interrupted { readonly scope: Scope } +export function scope(): (f: Fx) => Fx, A | ReturnValue> export function scope( name: Scope -): (f: Fx) => Fx, A | ReturnValue> { +): (f: Fx) => Fx, A | ReturnValue> +export function scope( + name: string = GlobalScope +): unknown { return (f: Fx) => - new ScopeBoundary(f, name) as Fx, A | ReturnValue> + new ScopeBoundary(f, name) as Fx, A | ReturnValue> } export type ScopeEffects = diff --git a/src/YieldFrom.test.ts b/src/YieldFrom.test.ts index f1b4741..1544f34 100644 --- a/src/YieldFrom.test.ts +++ b/src/YieldFrom.test.ts @@ -2,16 +2,69 @@ import * as assert from 'node:assert/strict' import { describe, it } from 'node:test' import { abort, orReturn } from './Abort.js' import { fx, ok, run, type Fx } from './Fx.js' +import { GlobalScope } from './GlobalScope.js' import { handleScoped } from './Handler.js' import { returnFrom } from './ReturnFrom.js' -import { brand, scope } from './Scope.js' -import { collectFrom, YieldFrom, yieldFrom } from './YieldFrom.js' -import type { Yielding } from './YieldFrom.js' +import { scope } from './Scope.js' +import { collectFrom, YieldFrom, yieldFrom, yieldScope } from './YieldFrom.js' +import type { Yielding, YieldInput, YieldOutput } from './YieldFrom.js' describe('YieldFrom', () => { - const NumberScope = brand>()('test/YieldFrom/numbers') - const ItemScope = brand>()('test/YieldFrom/item') - const DecisionScope = brand>()('test/YieldFrom/decision') + const NumberScope = yieldScope()('test/YieldFrom/numbers') + const ItemScope = yieldScope<'item'>()('test/YieldFrom/item') + const DecisionScope = yieldScope()('test/YieldFrom/decision') + + it('coalesces global scope yield outputs by union and inputs by intersection', () => { + type AskUser = Yielding< + 'askUser', + { readonly type: 'askUser'; readonly id: string }, + { readonly askUser: { readonly name: string } } + > + type AskConfig = Yielding< + 'askConfig', + { readonly type: 'askConfig'; readonly key: string }, + { readonly askConfig: { readonly value: string } } + > + type AskLocale = Yielding< + 'askLocale', + { readonly type: 'askLocale' }, + { readonly askLocale: string } + > + type AskTrace = Yielding< + 'askTrace', + { readonly type: 'askTrace' }, + { readonly askTrace: string } + > + type AskFeature = Yielding< + 'askFeature', + { readonly type: 'askFeature'; readonly name: string }, + { readonly askFeature: boolean } + > + type GlobalYieldScope = typeof GlobalScope & AskUser & AskConfig & AskLocale & AskTrace & AskFeature + + const out = null as unknown as YieldOutput + const _: { readonly type: 'askUser'; readonly id: string } + | { readonly type: 'askConfig'; readonly key: string } + | { readonly type: 'askLocale' } + | { readonly type: 'askTrace' } + | { readonly type: 'askFeature'; readonly name: string } = out + const __: YieldOutput = { type: 'askUser', id: '1' } + const ___: YieldOutput = { type: 'askFeature', name: 'beta' } + const input = null as unknown as YieldInput + const ____: { + readonly askUser: { readonly name: string } + } & { + readonly askConfig: { readonly value: string } + } & { + readonly askLocale: string + } & { + readonly askTrace: string + } & { + readonly askFeature: boolean + } = input + + assert.equal(GlobalScope, 'fx/Scope/Global') + }) it('collects one-way yields from the matching scope', () => { const result = fx(function* () { @@ -38,8 +91,26 @@ describe('YieldFrom', () => { assert.deepEqual(result, [4, [0, 1, 2, 3]]) }) + it('collectFrom requires every protocol in the scope to be one-way', () => { + type OneWay = Yielding<'oneWay', { readonly type: 'oneWay' }, void> + type Ask = Yielding<'ask', { readonly type: 'ask' }, { readonly answer: string }> + const OneWayScope = GlobalScope as typeof GlobalScope & OneWay + const AskScope = GlobalScope as typeof GlobalScope & Ask + const MixedScope = GlobalScope as typeof GlobalScope & OneWay & Ask + + const _ = collectFrom(OneWayScope) + // @ts-expect-error bidirectional scopes require a response + const __ = collectFrom(AskScope) + // @ts-expect-error mixed scopes may include yields that require a response + const ___ = collectFrom(MixedScope) + + assert.equal(typeof _, 'function') + assert.equal(typeof __, 'function') + assert.equal(typeof ___, 'function') + }) + it('propagates yields from a different scope', () => { - const OtherScope = brand>()('test/YieldFrom/other') + const OtherScope = yieldScope<'other'>()('test/YieldFrom/other') const f = fx(function* () { yield* yieldFrom(OtherScope, 'other') @@ -56,7 +127,7 @@ describe('YieldFrom', () => { }) it('handles nested named yield scopes independently', () => { - const InnerScope = brand>()('test/YieldFrom/inner') + const InnerScope = yieldScope<'inner'>()('test/YieldFrom/inner') const outer = [] as number[] const inner = [] as string[] diff --git a/src/YieldFrom.ts b/src/YieldFrom.ts index 4071892..f4210f1 100644 --- a/src/YieldFrom.ts +++ b/src/YieldFrom.ts @@ -4,45 +4,59 @@ import { handleScoped } from './Handler.js' declare const YieldingTypeId: unique symbol -export type Yielding = { +export type Yielding = { readonly [YieldingTypeId]: { - readonly out: Out - readonly in: In + readonly [P in Protocol]: { + readonly out: Out + readonly in: In + } } } +export const yieldScope = + () => + (scope: Scope): Scope & Yielding => + scope as Scope & Yielding + export type YieldOutput = - Scope extends Yielding ? Out : never + YieldProtocolOutput> export type YieldInput = - Scope extends Yielding ? In : never + YieldProtocolInput> extends (input: infer In) => void ? In : never + +type AnyYielding = { readonly [YieldingTypeId]: object } /** * Yield a value to the named scope. */ export class YieldFrom< - const Scope extends string & Yielding + const Scope extends string & AnyYielding > extends ScopedEffect('fx/YieldFrom'), YieldInput> { } /** * Yield a value to the named scope. */ -export const yieldFrom = >( +export const yieldFrom = ( scope: Scope, value: YieldOutput ): YieldFrom => new YieldFrom(scope, value) -export type YieldValue> = +export type YieldValue = E extends YieldFrom ? YieldOutput : never -export type ExcludeYieldFrom, E2 = never> = +export type ExcludeYieldFrom = E extends YieldFrom ? E2 : E +type CollectableScope = + Exclude>, void> extends never ? Scope : never + /** * Collect all one-way yields from the named scope. */ -export const collectFrom = >(scope: Scope) => +export const collectFrom = ( + scope: CollectableScope +) => ( f: Fx ): Fx, readonly [A, readonly YieldValue[]]> => { @@ -54,3 +68,18 @@ export const collectFrom = map(result => [result, values] as const) ) as Fx, readonly [A, readonly YieldValue[]]> } + +type YieldProtocols = + Scope extends { readonly [YieldingTypeId]: infer Protocols } ? Protocols : never + +type YieldProtocolValue = + YieldProtocols[keyof YieldProtocols] + +type YieldProtocolOutput = + Protocol extends { readonly out: infer Out } ? Out : never + +type YieldProtocolRawInput = + Protocol extends { readonly in: infer In } ? In : never + +type YieldProtocolInput = + Protocol extends { readonly in: infer In } ? (input: In) => void : never diff --git a/src/index.ts b/src/index.ts index a1df099..2c79ef5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,6 @@ export * from './Effect.js' export * from './Fx.js' +export * from './GlobalScope.js' export * from './Handler.js' export * from './HandlerCapture.js' export * from './Interrupt.js'