@nanobpm/urban 0.44.0 → 0.45.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -64,8 +64,8 @@ urban run # start workers and serve surfaces
64
64
  ## Library API
65
65
 
66
66
  Everything the CLI does is available programmatically. Import the whole surface
67
- from `@nanobpm/urban`, or the focused subpaths `@nanobpm/urban/runtime` and
68
- `@nanobpm/urban/toolkit`.
67
+ from `@nanobpm/urban`, or the focused subpaths `@nanobpm/urban/runtime`,
68
+ `@nanobpm/urban/toolkit`, and `@nanobpm/urban/effect`.
69
69
 
70
70
  ### Runtime — run an app
71
71
 
@@ -244,6 +244,57 @@ survives restarts, model a timer **start**/**intermediate** event instead with
244
244
  `w.startOn(...)` / `w.timer(...)` from [`@nanobpm/workflow`](../workflow) — the
245
245
  engine owns those.
246
246
 
247
+ ### Effect — typed errors & scoped resources (`@nanobpm/urban/effect`)
248
+
249
+ A tiny, **zero-dependency**, Effect-like core for the imperative seams (workers,
250
+ provisioning, resource lifecycles) — without pulling in the `effect` package or
251
+ its viral paradigm. It gives you the three ergonomics you actually reach for:
252
+
253
+ - **Typed-error `Result<A, E>` with generator do-notation.** `gen(function* … )`
254
+ + `yield*` threads success values and short-circuits on the first failure,
255
+ automatically inferring the union of every failure type into `E` — like
256
+ `Effect.gen` + `yield*`.
257
+ - **Tagged errors + exhaustive matching.** `tag("NotFound")` builds a discriminated
258
+ error; `matchTags` forces you to handle **every** variant (omitting one is a
259
+ compile error), like `Data.TaggedError` + `catchTags`.
260
+ - **Scoped resources.** `scoped` + `acquireRelease` run every release on every exit
261
+ — success, failure, or thrown — LIFO, like `Effect.scoped`.
262
+
263
+ ```ts
264
+ import { gen, ok, fail, tag, matchTags, scoped, acquireRelease } from "@nanobpm/urban/effect";
265
+ import { mkdtempSync, rmSync } from "node:fs";
266
+ import { tmpdir } from "node:os";
267
+ import { join } from "node:path";
268
+
269
+ const parse = (s: string) => (s ? ok(s.length) : fail(tag("Empty")));
270
+ const check = (n: number) => (n > 3 ? fail(tag("TooLong", { n })) : ok(n));
271
+
272
+ const run = (s: string) =>
273
+ gen(function* () {
274
+ const n = yield* parse(s); // E gains "Empty"
275
+ return yield* check(n); // E gains "TooLong"
276
+ }); // Result<number, {_tag:"Empty"} | {_tag:"TooLong", n:number}>
277
+
278
+ const r = run("hello");
279
+ if (r._tag === "Fail") {
280
+ matchTags(r.error, { // must handle both — omit one and it won't compile
281
+ Empty: () => "was empty",
282
+ TooLong: (e) => `too long: ${e.n}`,
283
+ });
284
+ }
285
+
286
+ // `acquireRelease`'s `acquire` is synchronous, so use sync fs APIs (or `await`
287
+ // the acquisition yourself and register the disposer with `scope.add`).
288
+ await scoped(async (scope) => {
289
+ const dir = acquireRelease(
290
+ scope,
291
+ () => mkdtempSync(join(tmpdir(), "urban-")),
292
+ (d) => rmSync(d, { recursive: true, force: true }),
293
+ ); // released on any exit
294
+ // …use dir…
295
+ });
296
+ ```
297
+
247
298
  ## Related packages
248
299
 
249
300
  - [`@nanobpm/workflow`](../workflow) — the code-first process surface (`defineFlow`).
@@ -0,0 +1,39 @@
1
+ export type Result<A, E> = Ok<A> | Fail<E>;
2
+ export declare class Ok<A> {
3
+ readonly _tag: "Ok";
4
+ readonly value: A;
5
+ constructor(value: A);
6
+ [Symbol.iterator](): Generator<never, A, unknown>;
7
+ }
8
+ export declare class Fail<E> {
9
+ readonly _tag: "Fail";
10
+ readonly error: E;
11
+ constructor(error: E);
12
+ [Symbol.iterator](): Generator<Fail<E>, never, unknown>;
13
+ }
14
+ export declare const ok: <A>(value: A) => Ok<A>;
15
+ export declare const fail: <E>(error: E) => Fail<E>;
16
+ export declare const isOk: <A, E>(r: Result<A, E>) => r is Ok<A>;
17
+ export declare const isFail: <A, E>(r: Result<A, E>) => r is Fail<E>;
18
+ export declare function gen<Y extends Fail<unknown>, A>(body: () => Generator<Y, A, unknown>): Result<A, Y["error"]>;
19
+ export type OkOf<R> = R extends Ok<infer A> ? A : never;
20
+ export type ErrOf<R> = R extends Fail<infer E> ? E : never;
21
+ export declare const map: <R extends Result<unknown, unknown>, B>(r: R, f: (a: OkOf<R>) => B) => Result<B, ErrOf<R>>;
22
+ export declare const mapError: <R extends Result<unknown, unknown>, E2>(r: R, f: (e: ErrOf<R>) => E2) => Result<OkOf<R>, E2>;
23
+ export declare const match: <R extends Result<unknown, unknown>, T>(r: R, onOk: (a: OkOf<R>) => T, onFail: (e: ErrOf<R>) => T) => T;
24
+ export type Tagged<Tag extends string> = {
25
+ readonly _tag: Tag;
26
+ };
27
+ export type TagUnion<Tag extends string, P> = Tag extends string ? Tagged<Tag> & P : never;
28
+ export declare function tag<Tag extends string>(t: Tag): TagUnion<Tag, {}>;
29
+ export declare function tag<Tag extends string, P extends object>(t: Tag, props: P & {
30
+ _tag?: never;
31
+ }): TagUnion<Tag, P>;
32
+ export declare const matchTags: <E extends Tagged<string>, R>(e: E, handlers: { [K in E["_tag"]]: (e: Extract<E, Tagged<K>>) => R; }) => R;
33
+ export declare class Scope {
34
+ #private;
35
+ add(dispose: () => void | Promise<void>): void;
36
+ close(): Promise<void>;
37
+ }
38
+ export declare function acquireRelease<A>(scope: Scope, acquire: () => A, release: (a: A) => void | Promise<void>): A;
39
+ export declare function scoped<A>(body: (scope: Scope) => A | Promise<A>): Promise<A>;
@@ -0,0 +1,133 @@
1
+ // effectlite — a tiny, ZERO-DEPENDENCY, Effect-like core for Urban glue code.
2
+ //
3
+ // Urban's published surface is declarative JSON + a client renderer, so we do
4
+ // NOT want the `effect` package (bundle weight + viral paradigm) in the runtime.
5
+ // But the imperative seams — workers, provisioning, resource lifecycles — still
6
+ // benefit from the three Effect ergonomics we actually reach for. effectlite
7
+ // gives us those, in ~100 LOC of implementation with no runtime dependencies:
8
+ //
9
+ // 1. A typed-error `Result<A, E>` whose `E` composes automatically through
10
+ // generator do-notation (`gen(function*(){ const x = yield* step })`),
11
+ // short-circuiting on the first failure exactly like `Effect.gen` + `yield*`.
12
+ // 2. `tag()` tagged errors (cf. `Data.TaggedError`) + exhaustive `matchTags`
13
+ // (cf. `catchTags`) — the compiler forces every failure mode to be handled.
14
+ // 3. `scoped` + `acquireRelease` (cf. `Effect.scoped`) — releases run on every
15
+ // exit: success, failure, and thrown.
16
+ //
17
+ // It is synchronous over `Result`; the scope is async only so disposers may
18
+ // await. Consume via `import { gen, ok, fail } from "@nanobpm/urban/effect"`.
19
+ export class Ok {
20
+ _tag = "Ok";
21
+ value;
22
+ constructor(value) {
23
+ this.value = value;
24
+ }
25
+ // Delegated by `yield*`: returns the value, yields nothing to the driver.
26
+ *[Symbol.iterator]() {
27
+ return this.value;
28
+ }
29
+ }
30
+ export class Fail {
31
+ _tag = "Fail";
32
+ error;
33
+ constructor(error) {
34
+ this.error = error;
35
+ }
36
+ // Delegated by `yield*`: yields itself so the driver can short-circuit; never
37
+ // resumes (the driver abandons the generator on the first failure).
38
+ *[Symbol.iterator]() {
39
+ yield this;
40
+ throw new Error("effectlite: generator resumed after a failure");
41
+ }
42
+ }
43
+ export const ok = (value) => new Ok(value);
44
+ export const fail = (error) => new Fail(error);
45
+ export const isOk = (r) => r._tag === "Ok";
46
+ export const isFail = (r) => r._tag === "Fail";
47
+ // Run a generator that `yield*`s Results. Because only `Fail` yields to us, a
48
+ // single `next()` either lands on the first failure or on the final return.
49
+ // `Y` captures the UNION of every yielded `Fail<…>`; `Y["error"]` distributes
50
+ // that index access into the Result's error channel (a union in ⇒ a union out).
51
+ export function gen(body) {
52
+ // Typed as `Iterator` (not `Generator`) so `return` is the optional-arg
53
+ // variant — lets us close a short-circuited body without inventing a return value.
54
+ const it = body();
55
+ const step = it.next();
56
+ if (step.done)
57
+ return ok(step.value);
58
+ // Not done ⇒ a `Fail` yielded to short-circuit. Close the generator first so
59
+ // any `try/finally` in the body runs and captured resources aren't retained,
60
+ // then surface its error on the failure channel.
61
+ it.return?.();
62
+ return fail(step.value.error);
63
+ }
64
+ // Narrowing guards that carry the *extracted* channel type, so `r.value` /
65
+ // `r.error` land as `OkOf<R>` / `ErrOf<R>` (not `unknown`) with no assertion —
66
+ // keeping the combinators within the repo's no-type-assertion convention.
67
+ const isOkOf = (r) => r._tag === "Ok";
68
+ const isFailOf = (r) => r._tag === "Fail";
69
+ export const map = (r, f) => {
70
+ if (isOkOf(r))
71
+ return ok(f(r.value));
72
+ if (isFailOf(r))
73
+ return r;
74
+ throw new Error("effectlite: Result was neither Ok nor Fail");
75
+ };
76
+ export const mapError = (r, f) => {
77
+ if (isFailOf(r))
78
+ return fail(f(r.error));
79
+ if (isOkOf(r))
80
+ return r;
81
+ throw new Error("effectlite: Result was neither Ok nor Fail");
82
+ };
83
+ export const match = (r, onOk, onFail) => {
84
+ if (isOkOf(r))
85
+ return onOk(r.value);
86
+ if (isFailOf(r))
87
+ return onFail(r.error);
88
+ throw new Error("effectlite: Result was neither Ok nor Fail");
89
+ };
90
+ export function tag(t, props) {
91
+ return { ...props, _tag: t };
92
+ }
93
+ // Handle every tag in the union — the handlers object is keyed by `E["_tag"]`,
94
+ // so omitting a variant is a COMPILE error (no accidental dropped failure mode).
95
+ export const matchTags = (e, handlers) =>
96
+ // biome-ignore lint/plugin: the mapped handler type is precise for callers; internally we index it with the runtime tag, which needs this erasure to a plain record.
97
+ handlers[e._tag](e);
98
+ // ---------------------------------------------------------------------------
99
+ // Scoped resources
100
+ // ---------------------------------------------------------------------------
101
+ export class Scope {
102
+ #disposers = [];
103
+ add(dispose) {
104
+ this.#disposers.push(dispose);
105
+ }
106
+ async close() {
107
+ // LIFO, best-effort — a failing disposer never blocks the others.
108
+ for (const d of this.#disposers.reverse()) {
109
+ try {
110
+ await d();
111
+ }
112
+ catch { /* best effort */ }
113
+ }
114
+ this.#disposers = [];
115
+ }
116
+ }
117
+ // Acquire a resource and register its release with the scope in one step.
118
+ export function acquireRelease(scope, acquire, release) {
119
+ const a = acquire();
120
+ scope.add(() => release(a));
121
+ return a;
122
+ }
123
+ // Run `body` with a fresh scope; close it (releasing all resources) on every
124
+ // exit path — return, Result failure, or thrown.
125
+ export async function scoped(body) {
126
+ const scope = new Scope();
127
+ try {
128
+ return await body(scope);
129
+ }
130
+ finally {
131
+ await scope.close();
132
+ }
133
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/urban",
3
- "version": "0.44.0",
3
+ "version": "0.45.0",
4
4
  "description": "Urban: build and run code-first apps on Nano — runtime, derivation toolkit, and CLI in one. Author durable processes with @nanobpm/workflow (defineFlow), a typed datasource, forms, triggers and surfaces from a nano.app.json manifest, on Node or Deno.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -47,6 +47,10 @@
47
47
  "types": "./dist/worker.d.ts",
48
48
  "default": "./dist/worker.js"
49
49
  },
50
+ "./effect": {
51
+ "types": "./dist/effect/index.d.ts",
52
+ "default": "./dist/effect/index.js"
53
+ },
50
54
  "./source": "./src/index.ts"
51
55
  },
52
56
  "files": [
@@ -0,0 +1,185 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import {
4
+ ok,
5
+ fail,
6
+ isOk,
7
+ isFail,
8
+ gen,
9
+ map,
10
+ mapError,
11
+ match,
12
+ tag,
13
+ matchTags,
14
+ scoped,
15
+ acquireRelease,
16
+ type Result,
17
+ type Tagged,
18
+ } from "./index.ts";
19
+
20
+ test("ok/fail construct and narrow", () => {
21
+ const a = ok(42);
22
+ const b = fail("boom");
23
+ assert.equal(isOk(a), true);
24
+ assert.equal(isFail(a), false);
25
+ assert.equal(isOk(b), false);
26
+ assert.equal(isFail(b), true);
27
+ if (isOk(a)) assert.equal(a.value, 42);
28
+ if (isFail(b)) assert.equal(b.error, "boom");
29
+ });
30
+
31
+ test("gen threads values through yield* on the happy path", () => {
32
+ const r = gen(function* () {
33
+ const x = yield* ok(2);
34
+ const y = yield* ok(3);
35
+ return x + y;
36
+ });
37
+ assert.equal(isOk(r), true);
38
+ if (isOk(r)) assert.equal(r.value, 5);
39
+ });
40
+
41
+ test("gen short-circuits on the first failure", () => {
42
+ const steps: string[] = [];
43
+ const r = gen(function* () {
44
+ steps.push("a");
45
+ const x = yield* ok(1);
46
+ steps.push("b");
47
+ yield* fail("nope");
48
+ steps.push("c"); // must not run
49
+ return x;
50
+ });
51
+ assert.equal(isFail(r), true);
52
+ if (isFail(r)) assert.equal(r.error, "nope");
53
+ assert.deepEqual(steps, ["a", "b"]);
54
+ });
55
+
56
+ test("gen unions error types from multiple failure sources", () => {
57
+ const parse = (s: string) => (s === "" ? fail(tag("Empty")) : ok(s.length));
58
+ const check = (n: number) => (n > 3 ? fail(tag("TooLong")) : ok(n));
59
+ const run = (s: string) =>
60
+ gen(function* () {
61
+ const n = yield* parse(s);
62
+ const ok2 = yield* check(n);
63
+ return ok2;
64
+ });
65
+
66
+ const good = run("ab");
67
+ assert.equal(isOk(good), true);
68
+ if (isOk(good)) assert.equal(good.value, 2);
69
+
70
+ const empty = run("");
71
+ assert.equal(isFail(empty), true);
72
+ if (isFail(empty)) assert.equal(empty.error._tag, "Empty");
73
+
74
+ const long = run("abcd");
75
+ assert.equal(isFail(long), true);
76
+ if (isFail(long)) assert.equal(long.error._tag, "TooLong");
77
+ });
78
+
79
+ test("gen closes the generator so try/finally runs on short-circuit", () => {
80
+ const events: string[] = [];
81
+ const r = gen(function* () {
82
+ try {
83
+ events.push("enter");
84
+ yield* fail("boom");
85
+ events.push("after-fail"); // must not run
86
+ return 1;
87
+ } finally {
88
+ events.push("cleanup"); // must run even though we short-circuited
89
+ }
90
+ });
91
+ assert.equal(isFail(r), true);
92
+ if (isFail(r)) assert.equal(r.error, "boom");
93
+ // The failure short-circuits before "after-fail", but the generator is closed
94
+ // so its `finally` still releases (LIFO: enter → cleanup, no after-fail).
95
+ assert.deepEqual(events, ["enter", "cleanup"]);
96
+ });
97
+
98
+ test("map/mapError/match", () => {
99
+ assert.equal(match(map(ok(2), (n) => n * 10), (a) => a, () => -1), 20);
100
+ const failed: Result<number, string> = fail("e");
101
+ assert.equal(match(map(failed, (n) => n * 10), (n) => String(n), (e) => e), "e");
102
+ assert.equal(match(mapError(fail("e"), (e) => e + "!"), () => "", (e) => e), "e!");
103
+ assert.equal(match(mapError(ok(7), (e: string) => e), (a) => a, () => -1), 7);
104
+ });
105
+
106
+ test("tag builds tagged errors with props", () => {
107
+ const e = tag("Http", { status: 404 });
108
+ assert.equal(e._tag, "Http");
109
+ assert.equal(e.status, 404);
110
+ });
111
+
112
+ test("tag without props carries only the tag (no phantom prop keys)", () => {
113
+ const e = tag("Empty");
114
+ assert.equal(e._tag, "Empty");
115
+ assert.deepEqual(Object.keys(e), ["_tag"]);
116
+ });
117
+
118
+ test("tag() sets _tag last so untyped props cannot clobber the discriminant", () => {
119
+ // Props arriving through an untyped boundary must never override the tag —
120
+ // otherwise a stray `_tag` would break the discriminant `matchTags` relies on.
121
+ const untyped: object = JSON.parse('{"_tag":"EVIL","detail":1}');
122
+ const e = tag("Real", untyped);
123
+ assert.equal(e._tag, "Real");
124
+ });
125
+
126
+ test("matchTags dispatches on the tag", () => {
127
+ type E = Tagged<"NotFound"> | (Tagged<"Denied"> & { who: string });
128
+ const describe = (e: E) =>
129
+ matchTags(e, {
130
+ NotFound: () => "missing",
131
+ Denied: (d) => `denied:${d.who}`,
132
+ });
133
+ assert.equal(describe(tag("NotFound")), "missing");
134
+ assert.equal(describe(tag("Denied", { who: "bob" })), "denied:bob");
135
+ });
136
+
137
+ test("tag distributes a union tag into a discriminated union for matchTags", () => {
138
+ // `outcome` is a plain string union, as returned by e.g. a land/merge step.
139
+ const dispatch = (outcome: "merged" | "queued" | "blocked", detail: string) =>
140
+ matchTags(tag(outcome, { detail }), {
141
+ merged: () => "done",
142
+ queued: () => "waiting",
143
+ // The shared `detail` prop is present on every variant.
144
+ blocked: (b) => `blocked:${b.detail}`,
145
+ });
146
+ assert.equal(dispatch("merged", "x"), "done");
147
+ assert.equal(dispatch("queued", "x"), "waiting");
148
+ assert.equal(dispatch("blocked", "perms"), "blocked:perms");
149
+ });
150
+
151
+ test("scoped releases resources LIFO on success", async () => {
152
+ const released: string[] = [];
153
+ const out = await scoped(async (scope) => {
154
+ acquireRelease(scope, () => "a", () => { released.push("a"); });
155
+ acquireRelease(scope, () => "b", () => { released.push("b"); });
156
+ return "done";
157
+ });
158
+ assert.equal(out, "done");
159
+ assert.deepEqual(released, ["b", "a"]);
160
+ });
161
+
162
+ test("scoped releases resources even when the body throws", async () => {
163
+ const released: string[] = [];
164
+ await assert.rejects(
165
+ scoped(async (scope) => {
166
+ acquireRelease(scope, () => "a", () => { released.push("a"); });
167
+ acquireRelease(scope, () => "b", () => { released.push("b"); });
168
+ throw new Error("kaboom");
169
+ }),
170
+ /kaboom/,
171
+ );
172
+ assert.deepEqual(released, ["b", "a"]);
173
+ });
174
+
175
+ test("scoped best-effort: a failing disposer does not block the others", async () => {
176
+ const released: string[] = [];
177
+ await scoped(async (scope) => {
178
+ acquireRelease(scope, () => "a", () => { released.push("a"); });
179
+ acquireRelease(scope, () => "b", () => { throw new Error("dispose fail"); });
180
+ acquireRelease(scope, () => "c", () => { released.push("c"); });
181
+ return null;
182
+ });
183
+ // b's disposer threw but a and c still ran (LIFO: c, [b throws], a).
184
+ assert.deepEqual(released, ["c", "a"]);
185
+ });
@@ -0,0 +1,193 @@
1
+ // effectlite — a tiny, ZERO-DEPENDENCY, Effect-like core for Urban glue code.
2
+ //
3
+ // Urban's published surface is declarative JSON + a client renderer, so we do
4
+ // NOT want the `effect` package (bundle weight + viral paradigm) in the runtime.
5
+ // But the imperative seams — workers, provisioning, resource lifecycles — still
6
+ // benefit from the three Effect ergonomics we actually reach for. effectlite
7
+ // gives us those, in ~100 LOC of implementation with no runtime dependencies:
8
+ //
9
+ // 1. A typed-error `Result<A, E>` whose `E` composes automatically through
10
+ // generator do-notation (`gen(function*(){ const x = yield* step })`),
11
+ // short-circuiting on the first failure exactly like `Effect.gen` + `yield*`.
12
+ // 2. `tag()` tagged errors (cf. `Data.TaggedError`) + exhaustive `matchTags`
13
+ // (cf. `catchTags`) — the compiler forces every failure mode to be handled.
14
+ // 3. `scoped` + `acquireRelease` (cf. `Effect.scoped`) — releases run on every
15
+ // exit: success, failure, and thrown.
16
+ //
17
+ // It is synchronous over `Result`; the scope is async only so disposers may
18
+ // await. Consume via `import { gen, ok, fail } from "@nanobpm/urban/effect"`.
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // Result + generator do-notation
22
+ // ---------------------------------------------------------------------------
23
+
24
+ export type Result<A, E> = Ok<A> | Fail<E>;
25
+
26
+ export class Ok<A> {
27
+ readonly _tag = "Ok" as const;
28
+ readonly value: A;
29
+ constructor(value: A) {
30
+ this.value = value;
31
+ }
32
+ // Delegated by `yield*`: returns the value, yields nothing to the driver.
33
+ *[Symbol.iterator](): Generator<never, A, unknown> {
34
+ return this.value;
35
+ }
36
+ }
37
+
38
+ export class Fail<E> {
39
+ readonly _tag = "Fail" as const;
40
+ readonly error: E;
41
+ constructor(error: E) {
42
+ this.error = error;
43
+ }
44
+ // Delegated by `yield*`: yields itself so the driver can short-circuit; never
45
+ // resumes (the driver abandons the generator on the first failure).
46
+ *[Symbol.iterator](): Generator<Fail<E>, never, unknown> {
47
+ yield this;
48
+ throw new Error("effectlite: generator resumed after a failure");
49
+ }
50
+ }
51
+
52
+ export const ok = <A>(value: A): Ok<A> => new Ok(value);
53
+ export const fail = <E>(error: E): Fail<E> => new Fail(error);
54
+
55
+ export const isOk = <A, E>(r: Result<A, E>): r is Ok<A> => r._tag === "Ok";
56
+ export const isFail = <A, E>(r: Result<A, E>): r is Fail<E> => r._tag === "Fail";
57
+
58
+ // Run a generator that `yield*`s Results. Because only `Fail` yields to us, a
59
+ // single `next()` either lands on the first failure or on the final return.
60
+ // `Y` captures the UNION of every yielded `Fail<…>`; `Y["error"]` distributes
61
+ // that index access into the Result's error channel (a union in ⇒ a union out).
62
+ export function gen<Y extends Fail<unknown>, A>(
63
+ body: () => Generator<Y, A, unknown>,
64
+ ): Result<A, Y["error"]> {
65
+ // Typed as `Iterator` (not `Generator`) so `return` is the optional-arg
66
+ // variant — lets us close a short-circuited body without inventing a return value.
67
+ const it: Iterator<Y, A> = body();
68
+ const step = it.next();
69
+ if (step.done) return ok(step.value);
70
+ // Not done ⇒ a `Fail` yielded to short-circuit. Close the generator first so
71
+ // any `try/finally` in the body runs and captured resources aren't retained,
72
+ // then surface its error on the failure channel.
73
+ it.return?.();
74
+ return fail(step.value.error);
75
+ }
76
+
77
+ // ---------------------------------------------------------------------------
78
+ // Combinators
79
+ // ---------------------------------------------------------------------------
80
+
81
+ // Extract the success/error channels from a `Result` (including a value typed as
82
+ // the union `Result<A, E>`), so the combinators below infer `A`/`E` cleanly
83
+ // instead of collapsing them to `unknown` on union inputs.
84
+ export type OkOf<R> = R extends Ok<infer A> ? A : never;
85
+ export type ErrOf<R> = R extends Fail<infer E> ? E : never;
86
+
87
+ // Narrowing guards that carry the *extracted* channel type, so `r.value` /
88
+ // `r.error` land as `OkOf<R>` / `ErrOf<R>` (not `unknown`) with no assertion —
89
+ // keeping the combinators within the repo's no-type-assertion convention.
90
+ const isOkOf = <R extends Result<unknown, unknown>>(r: R): r is R & Ok<OkOf<R>> => r._tag === "Ok";
91
+ const isFailOf = <R extends Result<unknown, unknown>>(r: R): r is R & Fail<ErrOf<R>> => r._tag === "Fail";
92
+
93
+ export const map = <R extends Result<unknown, unknown>, B>(
94
+ r: R,
95
+ f: (a: OkOf<R>) => B,
96
+ ): Result<B, ErrOf<R>> => {
97
+ if (isOkOf(r)) return ok(f(r.value));
98
+ if (isFailOf(r)) return r;
99
+ throw new Error("effectlite: Result was neither Ok nor Fail");
100
+ };
101
+
102
+ export const mapError = <R extends Result<unknown, unknown>, E2>(
103
+ r: R,
104
+ f: (e: ErrOf<R>) => E2,
105
+ ): Result<OkOf<R>, E2> => {
106
+ if (isFailOf(r)) return fail(f(r.error));
107
+ if (isOkOf(r)) return r;
108
+ throw new Error("effectlite: Result was neither Ok nor Fail");
109
+ };
110
+
111
+ export const match = <R extends Result<unknown, unknown>, T>(
112
+ r: R,
113
+ onOk: (a: OkOf<R>) => T,
114
+ onFail: (e: ErrOf<R>) => T,
115
+ ): T => {
116
+ if (isOkOf(r)) return onOk(r.value);
117
+ if (isFailOf(r)) return onFail(r.error);
118
+ throw new Error("effectlite: Result was neither Ok nor Fail");
119
+ };
120
+
121
+ // ---------------------------------------------------------------------------
122
+ // Tagged errors + exhaustive matching
123
+ // ---------------------------------------------------------------------------
124
+
125
+ export type Tagged<Tag extends string> = { readonly _tag: Tag };
126
+
127
+ // A distributive `Tagged<Tag> & P`: when `Tag` is a union (e.g. a
128
+ // `"merged" | "queued" | "blocked"` outcome), this expands to a discriminated
129
+ // UNION of tagged objects — `({_tag:"merged"} & P) | ({_tag:"queued"} & P) | …`
130
+ // — not a single object with a union-typed `_tag`. That is what lets `matchTags`
131
+ // `Extract` each variant and force an exhaustive handler per case.
132
+ export type TagUnion<Tag extends string, P> = Tag extends string ? Tagged<Tag> & P : never;
133
+
134
+ // Overloaded so the extra props type only appears when props are actually
135
+ // passed: `tag("X")` is exactly `Tagged<"X">` (no phantom `& P` that could read
136
+ // as `undefined` at runtime), while `tag("X", props)` carries them. Both return
137
+ // the distributive `TagUnion`, so a union `Tag` expands to a discriminated union.
138
+ // `props` is forbidden from carrying its own `_tag` (`{ _tag?: never }`) so it
139
+ // cannot shadow the discriminant, and the runtime spreads `props` *first* so the
140
+ // tag always wins even for props arriving through an untyped boundary.
141
+ export function tag<Tag extends string>(t: Tag): TagUnion<Tag, {}>;
142
+ export function tag<Tag extends string, P extends object>(
143
+ t: Tag,
144
+ props: P & { _tag?: never },
145
+ ): TagUnion<Tag, P>;
146
+ export function tag(t: string, props?: object): Tagged<string> {
147
+ return { ...props, _tag: t };
148
+ }
149
+
150
+ // Handle every tag in the union — the handlers object is keyed by `E["_tag"]`,
151
+ // so omitting a variant is a COMPILE error (no accidental dropped failure mode).
152
+ export const matchTags = <E extends Tagged<string>, R>(
153
+ e: E,
154
+ handlers: { [K in E["_tag"]]: (e: Extract<E, Tagged<K>>) => R },
155
+ ): R =>
156
+ // biome-ignore lint/plugin: the mapped handler type is precise for callers; internally we index it with the runtime tag, which needs this erasure to a plain record.
157
+ (handlers as unknown as Record<string, (e: E) => R>)[e._tag](e);
158
+
159
+ // ---------------------------------------------------------------------------
160
+ // Scoped resources
161
+ // ---------------------------------------------------------------------------
162
+
163
+ export class Scope {
164
+ #disposers: Array<() => void | Promise<void>> = [];
165
+ add(dispose: () => void | Promise<void>): void {
166
+ this.#disposers.push(dispose);
167
+ }
168
+ async close(): Promise<void> {
169
+ // LIFO, best-effort — a failing disposer never blocks the others.
170
+ for (const d of this.#disposers.reverse()) {
171
+ try { await d(); } catch { /* best effort */ }
172
+ }
173
+ this.#disposers = [];
174
+ }
175
+ }
176
+
177
+ // Acquire a resource and register its release with the scope in one step.
178
+ export function acquireRelease<A>(scope: Scope, acquire: () => A, release: (a: A) => void | Promise<void>): A {
179
+ const a = acquire();
180
+ scope.add(() => release(a));
181
+ return a;
182
+ }
183
+
184
+ // Run `body` with a fresh scope; close it (releasing all resources) on every
185
+ // exit path — return, Result failure, or thrown.
186
+ export async function scoped<A>(body: (scope: Scope) => A | Promise<A>): Promise<A> {
187
+ const scope = new Scope();
188
+ try {
189
+ return await body(scope);
190
+ } finally {
191
+ await scope.close();
192
+ }
193
+ }