@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 +53 -2
- package/dist/effect/index.d.ts +39 -0
- package/dist/effect/index.js +133 -0
- package/package.json +5 -1
- package/src/effect/index.test.ts +185 -0
- package/src/effect/index.ts +193 -0
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
|
|
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.
|
|
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
|
+
}
|