diff --git a/.size-limit.json b/.size-limit.json index acab700..47600c3 100644 --- a/.size-limit.json +++ b/.size-limit.json @@ -119,6 +119,12 @@ "limit": "850 B", "brotli": true }, + { + "name": "@peerigon/typescript-toolkit/runtime", + "path": "dist/runtime/runtime.js", + "limit": "375 B", + "brotli": true + }, { "name": "@peerigon/typescript-toolkit/signals", "path": "dist/signals/signals.js", @@ -128,7 +134,7 @@ { "name": "@peerigon/typescript-toolkit/sleep", "path": "dist/sleep/sleep.js", - "limit": "175 B", + "limit": "190 B", "brotli": true }, { diff --git a/README.md b/README.md index e24bceb..d2fd77b 100644 --- a/README.md +++ b/README.md @@ -38,33 +38,34 @@ import { assert } from "@peerigon/typescript-toolkit/assert"; ## Utilities -| Module | Description | Docs | -| ---------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | --------------------------------------------------- | -| [`api`](./src/api/README.md) | Define a typed JSON API client with an overridable hook pipeline | [→](./src/api/README.md) | -| [`api/rate-limit`](./src/api/rate-limit/README.md) | Rate-limited `fetch` for `defineApi` (pacing + Retry-After) | [→](./src/api/rate-limit/README.md) | -| [`api/result`](./src/api/result/README.md) | `defineApiResult` — like `defineApi`, but returns `Result.Sync` | [→](./src/api/result/README.md) | -| [`assert`](./src/assert/README.md) | Assert a value is not `null` or `undefined`, with TypeScript narrowing | [→](./src/assert/README.md) | -| [`concurrency/once`](./src/concurrency/once/README.md) | Run an async function at most once (single-flight + cache) | [→](./src/concurrency/once/README.md) | -| [`concurrency/once/result`](./src/concurrency/once/result/README.md) | `once` with a synchronous `Result` snapshot | [→](./src/concurrency/once/result/README.md) | -| [`concurrency/exactlyOnce`](./src/concurrency/exactlyOnce/README.md) | Invoke an async function exactly once; second call throws | [→](./src/concurrency/exactlyOnce/README.md) | -| [`concurrency/exactlyOnce/result`](./src/concurrency/exactlyOnce/result/README.md) | `exactlyOnce` with a synchronous `Result` snapshot | [→](./src/concurrency/exactlyOnce/result/README.md) | -| [`concurrency/mutex`](./src/concurrency/mutex/README.md) | Serialize async tasks (concurrency 1) | [→](./src/concurrency/mutex/README.md) | -| [`concurrency/rate-limit`](./src/concurrency/rate-limit/README.md) | Pace async task starts (`max` per sliding `interval`) | [→](./src/concurrency/rate-limit/README.md) | -| [`need`](./src/need/README.md) | Assert a value is not `null` or `undefined` and return it with a narrowed type | [→](./src/need/README.md) | -| [`no-null`](./src/no-null/README.md) | Convert between `null` and `undefined` in JSON-like values (runtime + types) | [→](./src/no-null/README.md) | -| [`dedupe`](./src/dedupe/README.md) | Remove duplicate values from an array while preserving first-occurrence order | [→](./src/dedupe/README.md) | -| [`emitter`](./src/emitter/README.md) | Minimal typed event emitter with payload objects per event | [→](./src/emitter/README.md) | -| [`enums`](./src/enums/README.md) | Lightweight string-enum alternative for `erasableSyntaxOnly` TypeScript projects | [→](./src/enums/README.md) | -| [`map-leaves`](./src/map-leaves/README.md) | Deeply map leaves in JSON-like values (mutates arrays/objects in place) | [→](./src/map-leaves/README.md) | -| [`match`](./src/match/README.md) | Exhaustive pattern matching with compile-time case checks, similar to `switch` | [→](./src/match/README.md) | -| [`metadata`](./src/metadata/README.md) | Typed metadata attached to objects via per-channel WeakMap stores | [→](./src/metadata/README.md) | -| [`namespace`](./src/namespace/README.md) | Hierarchical namespaces with exclusive member claims and dotted prefixes | [→](./src/namespace/README.md) | -| [`result`](./src/result/README.md) | Type-safe error handling with pending, success, and error states | [→](./src/result/README.md) | -| [`signals`](./src/signals/README.md) | Push-based reactive state with explicit watchers and `signal.from` adapters | [→](./src/signals/README.md) | -| [`sleep`](./src/sleep/README.md) | Promise-based delay with optional `AbortSignal` cancellation | [→](./src/sleep/README.md) | -| [`stable-stringify`](./src/stable-stringify/README.md) | Deterministic stringify for JSON + Map/Set/BigInt/Symbol/Date/RegExp | [→](./src/stable-stringify/README.md) | -| [`stable-stringify/json`](./src/stable-stringify/json/README.md) | JSON-only deterministic `JSON.stringify` with sorted object keys | [→](./src/stable-stringify/json/README.md) | -| [`unwrap`](./src/unwrap/README.md) | Extract values from `Result` or nullable types, with optional fallback support | [→](./src/unwrap/README.md) | +| Module | Description | Docs | +| ---------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- | --------------------------------------------------- | +| [`api`](./src/api/README.md) | Define a typed JSON API client with an overridable hook pipeline | [→](./src/api/README.md) | +| [`api/rate-limit`](./src/api/rate-limit/README.md) | Rate-limited `fetch` for `defineApi` (pacing + Retry-After) | [→](./src/api/rate-limit/README.md) | +| [`api/result`](./src/api/result/README.md) | `defineApiResult` — like `defineApi`, but returns `Result.Sync` | [→](./src/api/result/README.md) | +| [`assert`](./src/assert/README.md) | Assert a value is not `null` or `undefined`, with TypeScript narrowing | [→](./src/assert/README.md) | +| [`concurrency/once`](./src/concurrency/once/README.md) | Run an async function at most once (single-flight + cache) | [→](./src/concurrency/once/README.md) | +| [`concurrency/once/result`](./src/concurrency/once/result/README.md) | `once` with a synchronous `Result` snapshot | [→](./src/concurrency/once/result/README.md) | +| [`concurrency/exactlyOnce`](./src/concurrency/exactlyOnce/README.md) | Invoke an async function exactly once; second call throws | [→](./src/concurrency/exactlyOnce/README.md) | +| [`concurrency/exactlyOnce/result`](./src/concurrency/exactlyOnce/result/README.md) | `exactlyOnce` with a synchronous `Result` snapshot | [→](./src/concurrency/exactlyOnce/result/README.md) | +| [`concurrency/mutex`](./src/concurrency/mutex/README.md) | Serialize async tasks (concurrency 1) | [→](./src/concurrency/mutex/README.md) | +| [`concurrency/rate-limit`](./src/concurrency/rate-limit/README.md) | Pace async task starts (`max` per sliding `interval`) | [→](./src/concurrency/rate-limit/README.md) | +| [`need`](./src/need/README.md) | Assert a value is not `null` or `undefined` and return it with a narrowed type | [→](./src/need/README.md) | +| [`no-null`](./src/no-null/README.md) | Convert between `null` and `undefined` in JSON-like values (runtime + types) | [→](./src/no-null/README.md) | +| [`dedupe`](./src/dedupe/README.md) | Remove duplicate values from an array while preserving first-occurrence order | [→](./src/dedupe/README.md) | +| [`emitter`](./src/emitter/README.md) | Minimal typed event emitter with payload objects per event | [→](./src/emitter/README.md) | +| [`enums`](./src/enums/README.md) | Lightweight string-enum alternative for `erasableSyntaxOnly` TypeScript projects | [→](./src/enums/README.md) | +| [`map-leaves`](./src/map-leaves/README.md) | Deeply map leaves in JSON-like values (mutates arrays/objects in place) | [→](./src/map-leaves/README.md) | +| [`match`](./src/match/README.md) | Exhaustive pattern matching with compile-time case checks, similar to `switch` | [→](./src/match/README.md) | +| [`metadata`](./src/metadata/README.md) | Typed metadata attached to objects via per-channel WeakMap stores | [→](./src/metadata/README.md) | +| [`namespace`](./src/namespace/README.md) | Hierarchical namespaces with exclusive member claims and dotted prefixes | [→](./src/namespace/README.md) | +| [`result`](./src/result/README.md) | Type-safe error handling with pending, success, and error states | [→](./src/result/README.md) | +| [`runtime`](./src/runtime/README.md) | Generator-based runtime for the generator context pattern, with type-safe `yield*` | [→](./src/runtime/README.md) | +| [`signals`](./src/signals/README.md) | Push-based reactive state with explicit watchers and `signal.from` adapters | [→](./src/signals/README.md) | +| [`sleep`](./src/sleep/README.md) | Promise-based delay with optional `AbortSignal` cancellation | [→](./src/sleep/README.md) | +| [`stable-stringify`](./src/stable-stringify/README.md) | Deterministic stringify for JSON + Map/Set/BigInt/Symbol/Date/RegExp | [→](./src/stable-stringify/README.md) | +| [`stable-stringify/json`](./src/stable-stringify/json/README.md) | JSON-only deterministic `JSON.stringify` with sorted object keys | [→](./src/stable-stringify/json/README.md) | +| [`unwrap`](./src/unwrap/README.md) | Extract values from `Result` or nullable types, with optional fallback support | [→](./src/unwrap/README.md) | ## License diff --git a/jsr.json b/jsr.json index d5dd6d8..5fd3d75 100644 --- a/jsr.json +++ b/jsr.json @@ -23,6 +23,7 @@ "./need": "./src/need/need.ts", "./no-null": "./src/no-null/no-null.ts", "./result": "./src/result/result.ts", + "./runtime": "./src/runtime/runtime.ts", "./signals": "./src/signals/signals.ts", "./sleep": "./src/sleep/sleep.ts", "./stable-stringify": "./src/stable-stringify/stable-stringify.ts", diff --git a/package.json b/package.json index 975b32d..8b888ce 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "./need": "./dist/need/need.js", "./no-null": "./dist/no-null/no-null.js", "./result": "./dist/result/result.js", + "./runtime": "./dist/runtime/runtime.js", "./signals": "./dist/signals/signals.js", "./sleep": "./dist/sleep/sleep.js", "./stable-stringify": "./dist/stable-stringify/stable-stringify.js", diff --git a/src/api/rate-limit/rate-limit.ts b/src/api/rate-limit/rate-limit.ts index fc96a59..66e4ffd 100644 --- a/src/api/rate-limit/rate-limit.ts +++ b/src/api/rate-limit/rate-limit.ts @@ -68,7 +68,7 @@ const isRateLimited = (response: Response) => */ export const parseRetryAfter = ( value: string | null, - now = Date.now(), + now: number = Date.now(), ): number | undefined => { if (value === null || value === "") { return undefined; diff --git a/src/runtime/README.md b/src/runtime/README.md new file mode 100644 index 0000000..66f3290 --- /dev/null +++ b/src/runtime/README.md @@ -0,0 +1,265 @@ +## `runtime` + +- 📦 Below 375 Bytes minified + compressed (brotli) +- ✅ Zero dependencies + +A generator-based runtime for the [generator context pattern](https://github.com/doeixd/effectively/blob/main/docs/generator-context-pattern.md): workflows request dependencies on demand via `yield*` instead of having them threaded through every function signature. + +Write a workflow as a generator function, `yield*` the operations it needs, and pass the actual (or mocked) dependencies only once, at the point where you run it: + +```ts +import { + createRuntime, + type Workflow, +} from "@peerigon/typescript-toolkit/runtime"; + +type User = { id: string; name: string }; + +type AppContext = { + db: { findUser: (id: string) => User }; + logger: { info: (message: string) => void }; +}; + +type AppWorkflow = Workflow; + +const { action, call, provide, run } = createRuntime(); + +const getUser = action((context, id: string) => context.db.findUser(id)); +const log = action((context, message: string) => context.logger.info(message)); + +function* getUsername(id: string): AppWorkflow { + yield* log(`looking up ${id}`); + const user = yield* getUser(id); + return user.name; +} + +const name = await run(() => getUsername("42"))({ + db: { findUser: (id) => ({ id, name: "Ada" }) }, + logger: { info: console.log }, +}); +``` + +`user` above is inferred as `User` — no `as User` cast needed. That's the whole point of `yield*` here: it isn't optional decoration, plain `yield` cannot carry a distinct type per call site (see [Why `yield*`](#why-yield-and-not-yield) below). + +### Why `yield*` and not `yield`? + +`yield*` resolves to the `TReturn` of whatever you delegate to — each call site gets its own type, because it comes from that specific operation. Plain `yield expr` resolves to the _generator's own_ `TNext` type parameter, which is fixed once for the whole function. A workflow calls many operations with many different return types, so plain `yield` can't give you more than one shared type across all of them without a cast. + +`Workflow` pins `TNext` to `never`, which makes this the compiler's rule rather than a convention: writing plain `yield` inside a workflow resolves to `never` and is effectively unusable. Always `yield*`. + +### The `Context` type parameter + +`Workflow` names the dependencies a workflow requests, so the compiler can tell workflows from different runtimes apart. Mixing them is a type error rather than a runtime surprise: + +```ts +const mailRuntime = createRuntime(); + +// Error: AppWorkflow needs AppContext, this runtime only supplies MailContext +mailRuntime.run(() => getUsername("42")); +``` + +`Context` only appears in a parameter position, so it behaves contravariantly: a workflow written against a _narrower_ context still runs in a runtime that supplies more, but never the other way around. Aliasing it once per app — the `AppWorkflow` line in the example above — keeps annotations to a single type argument at each call site. + +### Composing workflows with `call()` + +A workflow can delegate into another workflow, reusing the same context: + +```ts +function* auditTrail(user: User): AppWorkflow { + yield* log(`audit: touched user ${user.id}`); +} + +function* updateProfile(id: string): AppWorkflow { + const user = yield* getUser(id); + yield* call(auditTrail(user)); + return user; +} +``` + +`call()` takes an already-invoked generator, so the operation it returns owns that generator instance and is single-use — `yield*` the same one twice and the second throws. Call `call(auditTrail(user))` afresh each time instead. (Operations from `action()` hold no state and can be re-yielded freely.) + +### Scoping a context override with `provide()` + +`provide()` runs a nested workflow against a context that locally overrides some values — useful for pointing a sub-step at a different dependency (a replica, a transaction, a test double) without threading it through the whole call chain. The override only applies inside that nested workflow; the outer context is unaffected once it returns. + +```ts +function* updateProfile(id: string): AppWorkflow { + const user = yield* getUser(id); + // auditTrail's ctx.db is auditReplicaDb here, and nowhere else + yield* provide({ db: auditReplicaDb }, auditTrail(user)); + return user; +} +``` + +Like `call()`, the returned operation is single-use. + +### Testing + +Because a workflow only ever touches the context it's given, tests can mock exactly what's used and nothing else: + +```ts +import { describe, expect, it, vi } from "vitest"; + +it("looks up the user and returns their name", async () => { + const context: AppContext = { + db: { findUser: vi.fn().mockReturnValue({ id: "42", name: "Ada" }) }, + logger: { info: vi.fn() }, + }; + + const name = await run(() => getUsername("42"))(context); + + expect(name).toBe("Ada"); + expect(context.db.findUser).toHaveBeenCalledWith("42"); +}); +``` + +### Error handling + +Errors thrown by an action propagate into the workflow at the `yield*` that requested it, so ordinary `try`/`catch` works: + +```ts +function* getUsername(id: string): AppWorkflow { + try { + const user = yield* getUser(id); + return user.name; + } catch { + return "unknown"; + } +} +``` + +An uncaught error rejects the promise returned by `run()`. + +### API Reference + +#### `createRuntime()` + +Creates a runtime bound to a specific context type. + +```ts +createRuntime(): { + action: ( + fn: (context: Context, ...args: Args) => Return | Promise, + ) => (...args: Args) => Operation; + call: ( + workflow: Workflow, + ) => Operation; + provide: ( + overrides: Partial, + workflow: Workflow, + ) => Operation; + run: ( + workflow: () => Workflow, + ) => (context: Context) => Promise; +} +``` + +| Type parameter | Description | +| -------------- | ----------------------------------------------------------------------- | +| `Context` | Shape of the dependencies workflows built with this runtime can request | + +#### `action(fn)` + +Defines a context-dependent operation. The returned function is called with `yield*` inside a workflow. + +```ts +action( + fn: (context: Context, ...args: Args) => Return | Promise, +): (...args: Args) => Operation +``` + +| Parameter | Type | Description | +| --------- | ---------------------------------------------------------------- | -------------------------------------------- | +| `fn` | `(context: Context, ...args: Args) => Return \| Promise` | Runs against the context supplied to `run()` | + +**Returns:** a function that, called with `Args`, produces an `Operation` to `yield*`. The operation is stateless and may be `yield*`-ed more than once. + +#### `call(workflow)` + +Composes another workflow into the current one, `yield*`-able, reusing the same context. + +```ts +call(workflow: Workflow): Operation +``` + +| Parameter | Type | Description | +| ---------- | --------------------------- | ------------------------------------------------------- | +| `workflow` | `Workflow` | An already-invoked generator, e.g. `otherWorkflow(arg)` | + +**Returns:** `Operation` resolving to the nested workflow's return value + +**Throws:** `Error` when the returned operation is `yield*`-ed a second time — it owns the generator it was given, and a generator can only be driven to completion once. Like an action's error, this surfaces at the `yield*` site, so a `try`/`catch` around it in the workflow will catch it. + +#### `provide(overrides, workflow)` + +Composes another workflow into the current one, `yield*`-able, running it against a context that locally overrides some of the current context's values. + +```ts +provide( + overrides: Partial, + workflow: Workflow, +): Operation +``` + +| Parameter | Type | Description | +| ----------- | --------------------------- | ----------------------------------------------- | +| `overrides` | `Partial` | Values to override for the nested workflow only | +| `workflow` | `Workflow` | An already-invoked generator | + +**Returns:** `Operation` resolving to the nested workflow's return value + +**Throws:** `Error` when the returned operation is `yield*`-ed a second time, for the same reason as `call()`. + +#### `run(workflow)` + +Executes a workflow against a context, resolving with its return value. + +```ts +run( + workflow: () => Workflow, +): (context: Context) => Promise +``` + +| Parameter | Type | Description | +| ---------- | --------------------------------- | ------------------------------------------------------------ | +| `workflow` | `() => Workflow` | A thunk invoking the generator, e.g. `() => myWorkflow(arg)` | + +**Returns:** a function that takes a `Context` and returns `Promise`. Since `workflow` is a thunk, the returned function can be called more than once — each call drives a fresh generator instance. + +**Note:** `workflow` is a thunk here (unlike `call`/`provide`) so the same `run(...)` result can be reused across multiple contexts, e.g. once per test. + +#### `isOperation(value)` + +Type guard for `Operation` instances created by `action()`, `call()`, or `provide()`. + +```ts +isOperation(value: unknown): value is Operation +``` + +| Parameter | Type | Description | +| --------- | --------- | -------------- | +| `value` | `unknown` | Value to check | + +**Returns:** `boolean` — `true` when `value` is an `Operation` + +### Type Reference + +#### `Workflow` + +The type every generator function built with a runtime's `action`, `call`, or `provide` should be annotated with. + +```ts +type Workflow = Generator< + Operation, + Return, + never +>; +``` + +#### `Operation` + +A single-yield description of a context-dependent unit of work, produced by `action()`, `call()`, and `provide()`. Not constructed directly. + +### What's out of scope + +This is a small runtime, not a full effect system. No retry/backoff, middleware, or scheduling — compose those yourself with plain functions and `try`/`catch` around `yield*`. Cancellation isn't first-class either: put an `AbortSignal` in your own `Context` and check it inside actions, the same convention [`sleep`](../sleep/README.md) uses. diff --git a/src/runtime/runtime.lib.ts b/src/runtime/runtime.lib.ts new file mode 100644 index 0000000..9560eca --- /dev/null +++ b/src/runtime/runtime.lib.ts @@ -0,0 +1,86 @@ +/** + * A description of a context-dependent unit of work. `Operation` objects are + * created by `action()`, `call()`, and `provide()` — never constructed directly. + * + * `Context` is what binds an operation to the runtime that created it. It only + * occurs in `run`'s parameter position, so it behaves contravariantly: a + * runtime accepts operations whose context it can satisfy and rejects the + * rest, which is what stops workflows from two different runtimes being mixed. + */ +export type Operation = { + readonly run: (context: Context) => Return | Promise; + [Symbol.iterator]: () => Iterator, Return, Return>; +}; + +const operationInstances = new WeakSet(); + +/** + * Checks if the given value is an `Operation` created by `action()`, `call()`, + * or `provide()`. + * + * @param maybeValue - The value to check + * @returns True if the value is an Operation, false otherwise + */ +export const isOperation = ( + maybeValue: unknown, +): maybeValue is Operation => { + // WeakSet#has is spec'd to return false for anything that can't be held + // weakly, so primitives need no separate guard. + return operationInstances.has(maybeValue as object); +}; + +/** + * Wraps `fn` so that a second call throws instead of running it again. Used for + * operations that own a generator instance: a generator can only be driven to + * completion once, so re-running one would silently resolve to `undefined` + * instead of the workflow's return value. + * + * Not to be confused with `concurrency/exactlyOnce`, which is async-only and + * carries a passive `promise` — more than this needs, and more bytes. + * + * @param fn - The function to run at most once + * @returns A wrapped `fn` that throws once it has already been called + */ +export const singleUse = ( + fn: (arg: Arg) => Return, +): ((arg: Arg) => Return) => { + let called = false; + + return (arg) => { + if (called) { + throw new Error( + "The workflow has already run. Create a new one for each yield*.", + ); + } + + called = true; + + return fn(arg); + }; +}; + +/** + * Wraps a context-dependent function into a single-yield `Operation`. Yielding + * it via `yield*` inside a workflow pauses the generator, hands `operation` to + * the runtime driving loop, and resumes with whatever `run` returned — typed + * precisely as `Return`, without a cast. + */ +export const isolate = ( + run: (context: Context) => Return | Promise, +): Operation => { + const operation: Operation = { + run, + // Delegating to a generator gives us the full iterator protocol for one + // line — including the `throw` that yield* needs to forward an injected + // error into the workflow's try/catch. Without it the spec's yield* + // AbruptCompletion handling raises its own "iterator does not provide a + // 'throw' method" TypeError instead. + *[Symbol.iterator]() { + return yield operation; + }, + }; + + operationInstances.add(operation); + + return operation; +}; diff --git a/src/runtime/runtime.test.ts b/src/runtime/runtime.test.ts new file mode 100644 index 0000000..b1dfa4f --- /dev/null +++ b/src/runtime/runtime.test.ts @@ -0,0 +1,310 @@ +// This test uses @ts-expect-error to test for specific type errors +import { describe, expect, expectTypeOf, it, vi } from "vitest"; +import { createRuntime, isOperation, type Workflow } from "./runtime.ts"; + +type User = { id: string; name: string }; + +type AppContext = { + db: { findUser: (id: string) => User; recordAudit: (user: User) => void }; + logger: { info: (message: string) => void }; +}; + +type AppWorkflow = Workflow; + +const createContext = (overrides: Partial = {}): AppContext => ({ + db: { + findUser: vi.fn((id: string) => ({ id, name: "Ada" })), + recordAudit: vi.fn(), + }, + logger: { info: vi.fn() }, + ...overrides, +}); + +describe("createRuntime()", () => { + const { action, call, provide, run } = createRuntime(); + const getUser = action((context, id: string) => context.db.findUser(id)); + const log = action((context, message: string) => { + context.logger.info(message); + }); + + it("runs a workflow against a context and resolves with its return value", async () => { + const getUsername = function* (id: string): AppWorkflow { + const user = yield* getUser(id); + return user.name; + }; + + const context = createContext(); + const name = await run(() => getUsername("42"))(context); + + expect(name).toBe("Ada"); + expect(context.db.findUser).toHaveBeenCalledWith("42"); + }); + + it("only calls the actions a workflow actually yields", async () => { + const onlyLogs = function* (): AppWorkflow { + yield* log("hello"); + }; + + const context = createContext(); + await run(onlyLogs)(context); + + expect(context.logger.info).toHaveBeenCalledWith("hello"); + expect(context.db.findUser).not.toHaveBeenCalled(); + }); + + it("propagates a thrown error into the workflow's try/catch", async () => { + const boom = new Error("boom"); + const failingAction = action(() => { + throw boom; + }); + + const recovers = function* (): AppWorkflow { + try { + yield* failingAction(); + return "unreachable"; + } catch (error) { + return `recovered: ${(error as Error).message}`; + } + }; + + const result = await run(recovers)(createContext()); + + expect(result).toBe("recovered: boom"); + }); + + it("rejects when a workflow doesn't catch a thrown error", async () => { + const boom = new Error("boom"); + const failingAction = action(() => { + throw boom; + }); + + const uncaught = function* (): AppWorkflow { + yield* failingAction(); + }; + + await expect(run(uncaught)(createContext())).rejects.toThrow(boom); + }); + + it("rejects when the workflow body itself throws after an action resumed it", async () => { + const boom = new Error("boom"); + const throwsAfterResuming = function* (): AppWorkflow { + yield* log("before"); + throw boom; + }; + + const context = createContext(); + + await expect(run(throwsAfterResuming)(context)).rejects.toThrow(boom); + expect(context.logger.info).toHaveBeenCalledWith("before"); + }); + + it("composes a nested workflow via call(), reusing the same context", async () => { + const auditTrail = function* (user: User): AppWorkflow { + yield* log(`audit: touched user ${user.id}`); + yield* action((context, u: User) => context.db.recordAudit(u))(user); + }; + + const updateProfile = function* (id: string): AppWorkflow { + yield* log(`updating ${id}`); + const user = yield* getUser(id); + yield* call(auditTrail(user)); + return user; + }; + + const context = createContext(); + const user = await run(() => updateProfile("42"))(context); + + expect(user).toEqual({ id: "42", name: "Ada" }); + expect(context.db.recordAudit).toHaveBeenCalledWith(user); + expect(context.logger.info).toHaveBeenCalledWith("updating 42"); + expect(context.logger.info).toHaveBeenCalledWith("audit: touched user 42"); + }); + + it("provide() scopes an override to the nested workflow only", async () => { + const auditTrail = function* (user: User): AppWorkflow { + yield* action((context, u: User) => context.db.recordAudit(u))(user); + }; + + const updateProfile = function* (id: string): AppWorkflow { + const user = yield* getUser(id); + const auditDb = { + findUser: vi.fn(), + recordAudit: vi.fn(), + }; + yield* provide({ db: auditDb }, auditTrail(user)); + // Back in the outer context: recording here must hit the primary db. + yield* action((context, u: User) => context.db.recordAudit(u))(user); + return user; + }; + + const context = createContext(); + const user = await run(() => updateProfile("42"))(context); + + expect(user).toEqual({ id: "42", name: "Ada" }); + // The primary db only saw the call made after provide() returned. + expect(context.db.recordAudit).toHaveBeenCalledTimes(1); + expect(context.db.recordAudit).toHaveBeenCalledWith(user); + }); + + it("run() re-invokes the workflow thunk, so it can be called more than once", async () => { + const getUsername = function* (id: string): AppWorkflow { + const user = yield* getUser(id); + return user.name; + }; + + const runGetUsername = run(() => getUsername("1")); + const firstContext = createContext(); + const secondContext = createContext(); + + await expect(runGetUsername(firstContext)).resolves.toBe("Ada"); + await expect(runGetUsername(secondContext)).resolves.toBe("Ada"); + expect(firstContext.db.findUser).toHaveBeenCalledTimes(1); + expect(secondContext.db.findUser).toHaveBeenCalledTimes(1); + }); + + it("re-runs a workflow that composes via call(), since each run builds a fresh operation", async () => { + const inner = function* (id: string): AppWorkflow { + return yield* getUser(id); + }; + + const outer = function* (id: string): AppWorkflow { + const user = yield* call(inner(id)); + + return user.name; + }; + + const runOuter = run(() => outer("1")); + + await expect(runOuter(createContext())).resolves.toBe("Ada"); + await expect(runOuter(createContext())).resolves.toBe("Ada"); + }); + + it("rejects with a clear error when a workflow yields something that isn't an Operation", async () => { + const invalid = function* (): AppWorkflow { + yield* [1, 2, 3] as unknown as AppWorkflow; + }; + + await expect(run(invalid)(createContext())).rejects.toThrow( + /Only yield\* the result of action\(\)/, + ); + }); + + it("rejects when a call() operation is yielded a second time", async () => { + const inner = function* (): AppWorkflow { + const user = yield* getUser("42"); + + return user.name; + }; + + const reusesTheOperation = function* (): AppWorkflow { + const operation = call(inner()); + + yield* operation; + yield* operation; + }; + + await expect(run(reusesTheOperation)(createContext())).rejects.toThrow( + /The workflow has already run/, + ); + }); + + it("rejects when a provide() operation is yielded a second time", async () => { + const inner = function* (): AppWorkflow { + yield* log("inner"); + }; + + const reusesTheOperation = function* (): AppWorkflow { + const operation = provide({ logger: { info: vi.fn() } }, inner()); + + yield* operation; + yield* operation; + }; + + await expect(run(reusesTheOperation)(createContext())).rejects.toThrow( + /The workflow has already run/, + ); + }); + + it("allows an action() operation to be yielded more than once", async () => { + const reusesTheOperation = function* (): AppWorkflow { + const operation = getUser("42"); + const first = yield* operation; + const second = yield* operation; + + return first.name + second.name; + }; + + const context = createContext(); + + await expect(run(reusesTheOperation)(context)).resolves.toBe("AdaAda"); + expect(context.db.findUser).toHaveBeenCalledTimes(2); + }); +}); + +describe("Workflow", () => { + type OtherContext = { mailer: { send: (to: string) => void } }; + + const appRuntime = createRuntime(); + const otherRuntime = createRuntime(); + const getUser = appRuntime.action((context, id: string) => + context.db.findUser(id), + ); + + const appWorkflow = function* (): AppWorkflow { + return yield* getUser("42"); + }; + + it("resolves yield* to the operation's return type without a cast", async () => { + const inferred = function* (): AppWorkflow { + const user = yield* getUser("42"); + + expectTypeOf(user).toEqualTypeOf(); + + return user.name; + }; + + await expect(appRuntime.run(inferred)(createContext())).resolves.toBe( + "Ada", + ); + }); + + it("rejects a workflow from another runtime at compile time", () => { + // @ts-expect-error a workflow built against AppContext can't run in a + // runtime that only supplies OtherContext + const runInWrongRuntime = otherRuntime.run(appWorkflow); + + // @ts-expect-error ...and the same holds for composing it via call() + otherRuntime.call(appWorkflow()); + + expect(runInWrongRuntime).toBeTypeOf("function"); + }); + + it("rejects an operation from another runtime at compile time", () => { + const send = otherRuntime.action((context, to: string) => + context.mailer.send(to), + ); + + const mixed = function* (): AppWorkflow { + // @ts-expect-error the operation needs OtherContext, the workflow supplies AppContext + yield* send("someone@example.com"); + }; + + expect(mixed).toBeTypeOf("function"); + }); +}); + +describe("isOperation()", () => { + it("returns true for an Operation created by action()", () => { + const { action } = createRuntime(); + const doNothing = action(() => {}); + + expect(isOperation(doNothing())).toBe(true); + }); + + it("returns false for arbitrary values", () => { + expect(isOperation(null)).toBe(false); + expect(isOperation(undefined)).toBe(false); + expect(isOperation({})).toBe(false); + expect(isOperation([1, 2, 3])).toBe(false); + }); +}); diff --git a/src/runtime/runtime.ts b/src/runtime/runtime.ts new file mode 100644 index 0000000..0d5d9f9 --- /dev/null +++ b/src/runtime/runtime.ts @@ -0,0 +1,141 @@ +import { + isolate, + isOperation, + singleUse, + type Operation, +} from "./runtime.lib.ts"; + +/** + * A generator-based workflow that requests context-dependent operations via + * `yield*`. Annotate every generator function built with a runtime's `action`, + * `call`, or `provide` as `Workflow` — plain `yield` isn't + * usable here since the resumed value always types as `never`; only `yield*` + * on an `Operation` resolves to its precise `Return` type. + * + * `Context` names the dependencies the workflow requests, which is what keeps + * workflows from different runtimes apart: passing one to a runtime that + * can't supply its context is a compile error, not a runtime surprise. Alias + * it once per app to keep annotations short: + * + * ```ts + * type AppWorkflow = Workflow; + * ``` + */ +export type Workflow = Generator< + Operation, + Return, + never +>; + +export type Runtime = { + /** + * Defines a context-dependent operation. The returned function can be + * called with `yield*` inside a workflow to run `fn` against whatever + * context `run()` was given. + */ + action: , Return>( + fn: (context: Context, ...args: Args) => Return | Promise, + ) => (...args: Args) => Operation; + + /** + * Composes another workflow into the current one, `yield*`-able, reusing + * the same context. The resulting operation is single-use, since it owns + * the generator instance it was given — `yield*` it twice and the second + * one throws. + */ + call: ( + workflow: Workflow, + ) => Operation; + + /** + * Executes a workflow against a context, resolving with its return value. + * Errors thrown by an action propagate into the workflow at the `yield*` + * that requested it, so `try`/`catch` inside the workflow works as usual. + */ + run: ( + workflow: () => Workflow, + ) => (context: Context) => Promise; + + /** + * Composes another workflow into the current one, `yield*`-able, running it + * against a context that locally overrides some of the current context's + * values. The override only applies within `workflow` — the outer context + * is unaffected once it returns. Like `call`, the resulting operation is + * single-use. + */ + provide: ( + overrides: Partial, + workflow: Workflow, + ) => Operation; +}; + +const driveWorkflow = async ( + workflow: Workflow, + context: Context, +): Promise => { + let step = workflow.next(); + + while (!step.done) { + const operation = step.value; + + if (!isOperation(operation)) { + throw new TypeError( + "Only yield* the result of action(), call() or provide() inside a workflow.", + ); + } + + let result: unknown; + + try { + // eslint-disable-next-line no-await-in-loop -- each step depends on the previous one's result, this is the sequential generator driving loop + result = await operation.run(context); + } catch (caughtError) { + // Only the action itself is guarded here: its errors surface at the + // workflow's `yield*` so the workflow's own try/catch can handle them. + // Resuming happens outside, so an error thrown by the workflow body + // propagates instead of being fed back into the workflow it came from. + step = workflow.throw(caughtError); + continue; + } + + step = workflow.next(result as never); + } + + return step.value; +}; + +/** + * Creates a runtime bound to a specific context type: `action` to define + * context-dependent operations, `call` and `provide` to compose workflows into + * each other, and `run` to execute a workflow against a real (or mock) + * context — the generator-context pattern for pulling dependencies on demand + * instead of threading them through every function signature. + * + * @see https://github.com/doeixd/effectively/blob/main/docs/generator-context-pattern.md + */ +export const createRuntime = (): Runtime => { + const action: Runtime["action"] = (fn) => { + return (...args) => + isolate(async (context: Context) => fn(context, ...args)); + }; + + const call: Runtime["call"] = (workflow) => + isolate( + singleUse(async (context: Context) => driveWorkflow(workflow, context)), + ); + + const provide: Runtime["provide"] = (overrides, workflow) => + isolate( + singleUse(async (context: Context) => + driveWorkflow(workflow, { ...context, ...overrides }), + ), + ); + + const run: Runtime["run"] = (workflow) => async (context) => + driveWorkflow(workflow(), context); + + return { action, call, provide, run }; +}; + +export { isOperation } from "./runtime.lib.ts"; +export type { Operation } from "./runtime.lib.ts"; diff --git a/src/sleep/README.md b/src/sleep/README.md index 15c755f..bda8f40 100644 --- a/src/sleep/README.md +++ b/src/sleep/README.md @@ -1,6 +1,6 @@ ## `sleep` -- 📦 Below **175 B** minified + compressed (brotli) +- 📦 Below **190 B** minified + compressed (brotli) - ✅ Zero dependencies Promise-based delay with optional `AbortSignal` cancellation. diff --git a/tsconfig.json b/tsconfig.json index 4d5c712..8e25b46 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,6 @@ { "extends": "@peerigon/configs/typescript", "compilerOptions": { - "lib": ["es2024", "esnext.disposable"] + "lib": ["es2024", "dom", "esnext.disposable"] } }