From 580721439d8c96d039b8ccecbb045c770422a2c1 Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Thu, 20 Aug 2026 23:01:42 +0200 Subject: [PATCH 01/19] feat(di)!: a provider declares its dependencies by name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Positional deps are correct by position: reordering the array silently rebinds the parameters, and TypeScript only catches it when the service shapes differ, which they routinely do not — two config ports over { url: string }, two repositories with the same methods. ServiceOf erases the port's brand, so the nominal identity that makes the rest of di safe is exactly what is missing at that call. deps is a record now, and the factory receives one keyed the same way. The Qualification arms are untouched: ArgsOf makes the services record a ONE-ELEMENT tuple, so (...args: Args) => S becomes (services) => S while a no-deps factory keeps taking no arguments at all. Arity discriminates, not shape — a deps record and an options object are both non-array objects, so two arguments means the first is deps and one means it is not. Object.entries fixes the order once at the call, and construct puts the positionally-resolved services back under the names the caller wrote. --- packages/di/src/provider.ts | 74 +++++++++++++++++++++++++++---------- 1 file changed, 54 insertions(+), 20 deletions(-) diff --git a/packages/di/src/provider.ts b/packages/di/src/provider.ts index c6a7cbf..38f1d9f 100644 --- a/packages/di/src/provider.ts +++ b/packages/di/src/provider.ts @@ -2,13 +2,26 @@ import { Ok, type AsyncResult, type Result } from "unthrown"; import type { AnyPort, Scope, ServiceOf } from "./port.js"; -/** Internal: the resolved service tuple a `deps` array's factory must accept. */ -type ServicesOf = { - readonly [K in keyof D]: ServiceOf; -}; +/** Internal: a `deps` record — the one shape a provider declares dependencies in. */ +type Deps = Readonly>; + +/** + * Internal: the services record a factory receives, keyed exactly as `deps` + * was. Homomorphic, so the keys and their optionality survive; each value is + * the port's service. + */ +type ServicesOf = { readonly [K in keyof D]: ServiceOf }; -/** Internal: the union of instance types a `deps` array requires. */ -type NeedsOf = InstanceType; +/** + * Internal: what the factory arms are spread over. A **one-element tuple**, so + * `Qualification`'s arms stay variadic (`(...args: Args) => S`) and become + * `(services: ServicesOf) => S` without a single arm changing — while the + * no-deps form keeps `readonly []`, and with it a factory of no arguments. + */ +type ArgsOf = readonly [ServicesOf]; + +/** Internal: the union of instance types a `deps` record requires. */ +type NeedsOf = InstanceType; type ErrorOfResult = R extends Result ? E : R extends AsyncResult ? E : never; @@ -190,22 +203,31 @@ export type Provider = { const descriptor = ( port: AnyPort, deps: readonly AnyPort[], + keys: readonly string[], options: Record, ): Provider => { // oxlint-disable-next-line unthrown/no-ambiguous-error-type -- see the field comment on `Provider.construct` const construct = (services: readonly unknown[]): AsyncResult => { if ("value" in options) return Ok(options["value"]).toAsync(); + // The build pipeline resolves `deps` positionally, so the record the caller + // declared is rebuilt here, under the names they wrote. `args` is what every + // arm below spreads: one element for a provider with deps, none without — + // which is why a no-deps factory still takes no arguments. + const args: readonly unknown[] = + keys.length === 0 + ? [] + : [Object.fromEntries(keys.map((key, index) => [key, services[index]]))]; if ("sync" in options) { const f = options["sync"] as (...a: readonly unknown[]) => unknown; return Ok() .toAsync() - .map(() => f(...services)); + .map(() => f(...args)); } if ("class" in options) { const C = options["class"] as new (...a: readonly unknown[]) => unknown; return Ok() .toAsync() - .map(() => new C(...services)); + .map(() => new C(...args)); } // Whichever of `make`/`acquire` was supplied — `Qualification`'s // exclusivity guarantees at most one — is the fallible path. `acquire` is @@ -219,7 +241,7 @@ const descriptor = ( // with no runtime type-sniffing — the same trick demesne (this library's retired predecessor) used in Layer.make. return Ok() .toAsync() - .flatMap(() => f(...services)); + .flatMap(() => f(...args)); }; return { port, @@ -242,7 +264,7 @@ export function Provider

>(port: P) { // hold: `provider.port` // is what another provider lists in its deps or a starter reads the port // off. Purely additive — the intersection is still a `Provider`. - function build, S>>( + function build, S>>( deps: D, options: O, ): Provider, ErrorOf, NeedsOf | ScopeOf> & { readonly port: P }; @@ -250,18 +272,30 @@ export function Provider

>(port: P) { options: O, ): Provider, ErrorOf, ScopeOf> & { readonly port: P }; function build( - depsOrOptions: readonly AnyPort[] | Record, + depsOrOptions: Deps | Record, maybeOptions?: Record, ): Provider & { readonly port: P } { - // `Array.isArray`'s predicate is `arg is any[]` — a *mutable* array type, - // which a `readonly AnyPort[]` in the union is not assignable to, so the - // false branch does not narrow away the array member on its own. The cast - // is a true narrowing (the runtime check already ruled the array case - // out), not a workaround for something unsound. - return ( - Array.isArray(depsOrOptions) - ? descriptor(port, depsOrOptions, maybeOptions ?? {}) - : descriptor(port, [], depsOrOptions as Record) + // ARITY discriminates, not the argument's shape: a `deps` record and an + // options object are both non-array objects, so there is nothing to sniff. + // Two arguments means the first is `deps`; one means it is the options of a + // provider that declares none. + if (maybeOptions === undefined) { + return descriptor(port, [], [], depsOrOptions as Record) as Provider< + unknown, + never, + never + > & { readonly port: P }; + } + // `Object.entries` fixes the order once, here: `deps` reaches the build + // pipeline as the array it resolves positionally, and `keys` is what lets + // `construct` put the resolved services back under the names the caller + // wrote. + const entries = Object.entries(depsOrOptions as Deps); + return descriptor( + port, + entries.map(([, dependency]) => dependency), + entries.map(([key]) => key), + maybeOptions, ) as Provider & { readonly port: P }; } return build; From 7f49fa54ab809f2b5c732201858ed142fd5ada44 Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Thu, 20 Aug 2026 23:05:48 +0200 Subject: [PATCH 02/19] refactor(di): declare a provider's dependencies by name Migrates the container's own specs, type tests, README sample and spec notes to the keyed deps record. Adds three type tests for the new surface: an undeclared key on the services record, a non-port deps value, and a services entry carrying the service shape. --- packages/di/CLAUDE.md | 6 +- packages/di/README.md | 15 +++-- packages/di/src/build.spec.ts | 37 +++++++----- packages/di/src/build.test-d.ts | 21 ++++--- packages/di/src/example.spec.ts | 49 ++++++++------- packages/di/src/example.test-d.ts | 11 ++-- packages/di/src/fork.spec.ts | 11 ++-- packages/di/src/fork.test-d.ts | 4 +- packages/di/src/lifecycle.spec.ts | 35 +++++++---- packages/di/src/module.test-d.ts | 35 +++++++---- packages/di/src/provider.spec.ts | 4 +- packages/di/src/provider.test-d.ts | 96 +++++++++++++++++++++--------- packages/di/src/scoped.spec.ts | 52 +++++++++------- packages/di/src/scoped.test-d.ts | 11 ++-- 14 files changed, 245 insertions(+), 142 deletions(-) diff --git a/packages/di/CLAUDE.md b/packages/di/CLAUDE.md index cf61fbd..e505dfa 100644 --- a/packages/di/CLAUDE.md +++ b/packages/di/CLAUDE.md @@ -30,7 +30,7 @@ All runtime code lives in `packages/di/src`, one concept per file: once an audit found no consumer in any of the eight packages or ten examples, and the exemption they needed had rippled into `plan`'s levelling (a `Set` plus two count maps, now one membership test). -- **`provider.ts`** — `Provider(Port)([deps], arm)` with a construction family of +- **`provider.ts`** — `Provider(Port)({ name: Dep }, arm)` with a construction family of mutually exclusive option arms: `value` / `sync` / `make` (fallible, returns `Result`) / `class` / `acquire`+`release` (resourceful — puts `Scope` in `Needs`). Exclusivity is enforced by giving each arm the other keys as optional @@ -72,13 +72,13 @@ missing: N]`. `exports` accepts an available **port class**, a **provider** for (`{ portId: Id; new (): PortInstance }`, both types only) so a provider over a port declared inside a helper — one minted per call (`Config.provider("RelayConfig")(schema)`) or the helper's own fixed one - (`HttpRouter(contract)(deps, { sync })`, on `@btravstack/http`'s + (`HttpRouter(contract)({ name: Dep }, { sync })`, on `@btravstack/http`'s `HttpRouterPort`) — has a nameable declared type when a consumer exports it: the class expression `class extends Port(id) {}` has an anonymous type declaration emit cannot name across packages (TS4023, measured), `PortClassOf` is its nameable spelling, and naming the instance type forges nothing (the brand keys stay - private). `Provider(port)(deps, arm)`'s return type is `Provider & + private). `Provider(port)({ name: Dep }, arm)`'s return type is `Provider & { readonly port: typeof port }` — the provider carries its port class typed, so `provider.port` is what a dependent lists in its deps; purely additive. `AnyModule`, `AnyProvider` and `Exportable` are exported so a package offering a **shaped module** (a diff --git a/packages/di/README.md b/packages/di/README.md index 3c702bc..de82311 100644 --- a/packages/di/README.md +++ b/packages/di/README.md @@ -37,12 +37,15 @@ class GetOrder extends Port("GetOrder")<{ }> {} // A provider binds a port to a construction and declares what it needs — the -// arguments arrive typed from the ports listed, in order. -const getOrder = Provider(GetOrder)([OrderRepository], { - sync: (orders): ServiceOf => ({ - execute: (id) => orders.findById(id), - }), -}); +// services arrive typed, under the names the deps record gave them. +const getOrder = Provider(GetOrder)( + { orders: OrderRepository }, + { + sync: ({ orders }): ServiceOf => ({ + execute: (id) => orders.findById(id), + }), + }, +); const inMemoryOrders = Provider(OrderRepository)({ sync: () => { diff --git a/packages/di/src/build.spec.ts b/packages/di/src/build.spec.ts index 0e55e8f..a69ab55 100644 --- a/packages/di/src/build.spec.ts +++ b/packages/di/src/build.spec.ts @@ -14,18 +14,24 @@ test("providers construct in dependency order, not declaration order", async () const order: string[] = []; const mod = Module("Ordered")({ provides: [ - Provider(C)([B], { - sync: (b) => { - order.push("C"); - return { v: `${b.v}C` }; + Provider(C)( + { b: B }, + { + sync: ({ b }) => { + order.push("C"); + return { v: `${b.v}C` }; + }, }, - }), - Provider(B)([A], { - sync: (a) => { - order.push("B"); - return { v: `${a.v}B` }; + ), + Provider(B)( + { a: A }, + { + sync: ({ a }) => { + order.push("B"); + return { v: `${a.v}B` }; + }, }, - }), + ), Provider(A)({ sync: () => { order.push("A"); @@ -49,12 +55,12 @@ test("a port shared by two branches constructs exactly once", async () => { }); const left = Module("Left")({ imports: [shared], - provides: [Provider(B)([A], { sync: (a) => ({ v: a.v }) })], + provides: [Provider(B)({ a: A }, { sync: ({ a }) => ({ v: a.v }) })], exports: [B], }); const right = Module("Right")({ imports: [shared], - provides: [Provider(C)([A], { sync: (a) => ({ v: a.v }) })], + provides: [Provider(C)({ a: A }, { sync: ({ a }) => ({ v: a.v }) })], exports: [C], }); const app = Module("App")({ imports: [left, right], exports: [left, right] }); @@ -66,7 +72,10 @@ test("a port shared by two branches constructs exactly once", async () => { test("a cycle within one module is a defect, reported before any factory runs", async () => { const ran = vi.fn(); const cyclic = Module("Cyclic")({ - provides: [Provider(A)([B], { sync: ran as never }), Provider(B)([A], { sync: ran as never })], + provides: [ + Provider(A)({ b: B }, { sync: ran as never }), + Provider(B)({ a: A }, { sync: ran as never }), + ], exports: [A], }); const built = await Module.build(cyclic); @@ -160,7 +169,7 @@ test("a dependency no provider supplies is a defect, before any factory runs", a provides: [ Provider(A)({ sync: sibling }), // `B` is provided by nobody, here or in any import. - Provider(C)([A, B], { sync: dependent }), + Provider(C)({ a: A, b: B }, { sync: dependent }), ], exports: [A, C], }); diff --git a/packages/di/src/build.test-d.ts b/packages/di/src/build.test-d.ts index bff1b8e..61528a6 100644 --- a/packages/di/src/build.test-d.ts +++ b/packages/di/src/build.test-d.ts @@ -28,7 +28,7 @@ describe("Module.build", () => { const mod = Module("Complete")({ provides: [ Provider(Cfg)({ value: { url: "u" } }), - Provider(Repo)([Cfg], { sync: (c) => ({ find: () => c.url }) }), + Provider(Repo)({ config: Cfg }, { sync: ({ config }) => ({ find: () => config.url }) }), ], exports: [Repo], }); @@ -50,12 +50,15 @@ describe("Module.build", () => { const mod = Module("Fallible")({ provides: [ Provider(Env)({ value: {} }), - Provider(Cfg)([Env], { - make: (env) => - env["URL"] === undefined - ? Err(new CfgError({ reason: "unset" })) - : Ok({ url: env["URL"] }), - }), + Provider(Cfg)( + { env: Env }, + { + make: ({ env }) => + env["URL"] === undefined + ? Err(new CfgError({ reason: "unset" })) + : Ok({ url: env["URL"] }), + }, + ), ], exports: [Cfg], }); @@ -75,7 +78,9 @@ describe("Module.build", () => { test("a module with unmet needs does not compile", () => { const mod = Module("Incomplete")({ - provides: [Provider(Repo)([Cfg], { sync: (c) => ({ find: () => c.url }) })], + provides: [ + Provider(Repo)({ config: Cfg }, { sync: ({ config }) => ({ find: () => config.url }) }), + ], exports: [Repo], }); // @ts-expect-error unsatisfied dependency: Cfg — the rest parameter diff --git a/packages/di/src/example.spec.ts b/packages/di/src/example.spec.ts index 7d5d487..27bd5f1 100644 --- a/packages/di/src/example.spec.ts +++ b/packages/di/src/example.spec.ts @@ -39,7 +39,7 @@ class GetOrderInteractor { // `erasableSyntaxOnly` (this repo's tsconfig) rejects TypeScript's parameter-property // shorthand — `constructor(private readonly orders: ...)` — since it has no // type-erasure-only meaning; the field is declared and assigned explicitly instead. - constructor(orders: ServiceOf) { + constructor({ orders }: { readonly orders: ServiceOf }) { this.orders = orders; } execute(id: string): AsyncResult { @@ -50,12 +50,15 @@ class GetOrderInteractor { const ConfigModule = Module("Config")({ provides: [ Provider(Env)({ value: { XDATABASE_URL: "postgres://localhost/app" } }), - Provider(AppConfig)([Env], { - make: (env) => - env["XDATABASE_URL"] === undefined - ? Err(new ConfigError({ reason: "XDATABASE_URL is unset" })) - : Ok({ dbUrl: env["XDATABASE_URL"] }), - }), + Provider(AppConfig)( + { env: Env }, + { + make: ({ env }) => + env["XDATABASE_URL"] === undefined + ? Err(new ConfigError({ reason: "XDATABASE_URL is unset" })) + : Ok({ dbUrl: env["XDATABASE_URL"] }), + }, + ), ], exports: [AppConfig], }); @@ -72,18 +75,24 @@ const makePersistenceModule = (released: string[]) => Module("Persistence")({ imports: [ConfigModule], provides: [ - Provider(Database)([AppConfig], { - acquire: () => Ok({ rows: [{ id: "o-1", total: 10 }] }), - release: () => void released.push("database"), - }), - Provider(OrderRepository)([Database], { - sync: (db) => ({ - findById: (id) => { - const row = db.rows.find((r) => r.id === id); - return (row === undefined ? Err(new OrderNotFound({ id })) : Ok(row)).toAsync(); - }, - }), - }), + Provider(Database)( + { config: AppConfig }, + { + acquire: () => Ok({ rows: [{ id: "o-1", total: 10 }] }), + release: () => void released.push("database"), + }, + ), + Provider(OrderRepository)( + { db: Database }, + { + sync: ({ db }) => ({ + findById: (id) => { + const row = db.rows.find((r) => r.id === id); + return (row === undefined ? Err(new OrderNotFound({ id })) : Ok(row)).toAsync(); + }, + }), + }, + ), ], exports: [OrderRepository], }); @@ -110,7 +119,7 @@ const InMemoryPersistenceModule = Module("InMemoryPersistence")({ const makeAppModule = (persistence: Module) => Module("App")({ imports: [persistence], - provides: [Provider(GetOrder)([OrderRepository], { class: GetOrderInteractor })], + provides: [Provider(GetOrder)({ orders: OrderRepository }, { class: GetOrderInteractor })], exports: [GetOrder], }); diff --git a/packages/di/src/example.test-d.ts b/packages/di/src/example.test-d.ts index be9230c..e209e5f 100644 --- a/packages/di/src/example.test-d.ts +++ b/packages/di/src/example.test-d.ts @@ -25,7 +25,7 @@ class GetOrder extends Port("YGetOrder")<{ readonly execute: () => string }> {} const Persistence = Module("YPersistence")({ provides: [ Provider(Database)({ value: { rows: [] } }), - Provider(OrderRepository)([Database], { sync: () => ({ findById: () => "o-1" }) }), + Provider(OrderRepository)({ db: Database }, { sync: () => ({ findById: () => "o-1" }) }), ], exports: [OrderRepository], }); @@ -34,9 +34,12 @@ const makeAppModule = (persistence: Module) => Module("YApp")({ imports: [persistence], provides: [ - Provider(GetOrder)([OrderRepository], { - sync: (orders) => ({ execute: () => orders.findById() }), - }), + Provider(GetOrder)( + { orders: OrderRepository }, + { + sync: ({ orders }) => ({ execute: () => orders.findById() }), + }, + ), ], exports: [GetOrder], }); diff --git a/packages/di/src/fork.spec.ts b/packages/di/src/fork.spec.ts index 5edffe0..cb9b91d 100644 --- a/packages/di/src/fork.spec.ts +++ b/packages/di/src/fork.spec.ts @@ -19,10 +19,13 @@ test("a fork releases only its own resources and leaves the parent up", async () }); const request = Module("Request")({ provides: [ - Provider(Txn)([Pool], { - acquire: (pool) => Ok({ id: `txn-on-${pool.id}` }), - release: () => void released.push("txn"), - }), + Provider(Txn)( + { pool: Pool }, + { + acquire: ({ pool }) => Ok({ id: `txn-on-${pool.id}` }), + release: () => void released.push("txn"), + }, + ), ], exports: [Txn], }); diff --git a/packages/di/src/fork.test-d.ts b/packages/di/src/fork.test-d.ts index 2d58c09..f0e2028 100644 --- a/packages/di/src/fork.test-d.ts +++ b/packages/di/src/fork.test-d.ts @@ -9,12 +9,12 @@ class RequestId extends Port("FRequestId")<{ readonly value: string }> {} class Missing extends Port("FMissing")<{ readonly nope: true }> {} const RequestModule = Module("Request")({ - provides: [Provider(RequestId)([Db], { sync: (db) => ({ value: db.q() }) })], + provides: [Provider(RequestId)({ db: Db }, { sync: ({ db }) => ({ value: db.q() }) })], exports: [RequestId], }); const NeedsMissing = Module("NeedsMissing")({ - provides: [Provider(RequestId)([Missing], { sync: () => ({ value: "x" }) })], + provides: [Provider(RequestId)({ missing: Missing }, { sync: () => ({ value: "x" }) })], exports: [RequestId], }); diff --git a/packages/di/src/lifecycle.spec.ts b/packages/di/src/lifecycle.spec.ts index 3123f52..e9441f0 100644 --- a/packages/di/src/lifecycle.spec.ts +++ b/packages/di/src/lifecycle.spec.ts @@ -16,10 +16,13 @@ test("onStart runs after the whole graph is built, in declaration order", async value: { port: 8080 }, onStart: (s) => void events.push(`start-server-${s.port}`), }), - Provider(Worker)([Server], { - sync: () => ({ name: "w" }), - onStart: (w) => void events.push(`start-${w.name}`), - }), + Provider(Worker)( + { server: Server }, + { + sync: () => ({ name: "w" }), + onStart: (w) => void events.push(`start-${w.name}`), + }, + ), ], exports: [Server, Worker], }); @@ -36,10 +39,13 @@ test("onStop runs in reverse declaration order during teardown", async () => { value: { port: 1 }, onStop: () => void events.push("stop-server"), }), - Provider(Worker)([Server], { - sync: () => ({ name: "w" }), - onStop: () => void events.push("stop-worker"), - }), + Provider(Worker)( + { server: Server }, + { + sync: () => ({ name: "w" }), + onStop: () => void events.push("stop-worker"), + }, + ), ], exports: [Server, Worker], }); @@ -74,11 +80,14 @@ test("release and onStop interleave in one combined LIFO unwind, not two separat value: { port: 1 }, onStop: () => void events.push("server-onStop"), }), - Provider(Worker)([Server], { - acquire: () => Ok({ name: "w" }), - release: () => void events.push("worker-release"), - onStop: () => void events.push("worker-onStop"), - }), + Provider(Worker)( + { server: Server }, + { + acquire: () => Ok({ name: "w" }), + release: () => void events.push("worker-release"), + onStop: () => void events.push("worker-onStop"), + }, + ), ], exports: [Server, Worker], }); diff --git a/packages/di/src/module.test-d.ts b/packages/di/src/module.test-d.ts index 9bf2926..a68f64f 100644 --- a/packages/di/src/module.test-d.ts +++ b/packages/di/src/module.test-d.ts @@ -13,19 +13,28 @@ class Database extends Port("MDatabase")<{ readonly query: () => readonly unknow class OrderRepository extends Port("MOrderRepository")<{ readonly find: () => string }> {} const EnvProvider = Provider(Env)({ value: {} }); -const AppConfigProvider = Provider(AppConfig)([Env], { - make: (env) => - env["DATABASE_URL"] === undefined - ? Err(new ConfigError({ reason: "unset" })) - : Ok({ dbUrl: env["DATABASE_URL"] }), -}); -const DatabaseProvider = Provider(Database)([AppConfig], { - make: (cfg) => - cfg.dbUrl === "" ? Err(new PoolError({ url: cfg.dbUrl })) : Ok({ query: () => [] }), -}); -const OrderRepositoryProvider = Provider(OrderRepository)([Database], { - sync: (db) => ({ find: () => String(db.query().length) }), -}); +const AppConfigProvider = Provider(AppConfig)( + { env: Env }, + { + make: ({ env }) => + env["DATABASE_URL"] === undefined + ? Err(new ConfigError({ reason: "unset" })) + : Ok({ dbUrl: env["DATABASE_URL"] }), + }, +); +const DatabaseProvider = Provider(Database)( + { config: AppConfig }, + { + make: ({ config }) => + config.dbUrl === "" ? Err(new PoolError({ url: config.dbUrl })) : Ok({ query: () => [] }), + }, +); +const OrderRepositoryProvider = Provider(OrderRepository)( + { db: Database }, + { + sync: ({ db }) => ({ find: () => String(db.query().length) }), + }, +); const ConfigModule = Module("Config")({ provides: [EnvProvider, AppConfigProvider], diff --git a/packages/di/src/provider.spec.ts b/packages/di/src/provider.spec.ts index 52f52f7..2579c02 100644 --- a/packages/di/src/provider.spec.ts +++ b/packages/di/src/provider.spec.ts @@ -45,14 +45,14 @@ test("a throw inside a factory becomes a defect, not an error", async () => { test("a class provider constructs with the resolved dependencies", async () => { class Impl { private readonly seed: number; - constructor(seed: number) { + constructor({ seed }: { readonly seed: number }) { this.seed = seed; } get n(): number { return this.seed + 1; } } - const p = Provider(Value)([Seed], { class: Impl }); + const p = Provider(Value)({ seed: Seed }, { class: Impl }); const built = await p.construct([41]); expect(built.isOk() && (built.value as Impl).n).toBe(42); }); diff --git a/packages/di/src/provider.test-d.ts b/packages/di/src/provider.test-d.ts index 838bf45..2d5ecda 100644 --- a/packages/di/src/provider.test-d.ts +++ b/packages/di/src/provider.test-d.ts @@ -33,8 +33,8 @@ type ChannelsOf = T extends Provider ? readonly [P class RepoImpl { private readonly cfg: ServiceOf; - constructor(cfg: ServiceOf) { - this.cfg = cfg; + constructor({ config }: { readonly config: ServiceOf }) { + this.cfg = config; } find(): string { return this.cfg.dbUrl; @@ -60,28 +60,57 @@ describe("Provider", () => { void needsIsNever; }); - test("deps are typed into the factory parameters, positionally", () => { - Provider(AppConfig)([Env], { - sync: (env) => ({ dbUrl: env["DATABASE_URL"] ?? "" }), - }); + test("deps are typed into the services record, under the names they were declared with", () => { + Provider(AppConfig)( + { env: Env }, + { + sync: ({ env }) => ({ dbUrl: env["DATABASE_URL"] ?? "" }), + }, + ); }); - test("a factory parameter has the dependency's service shape, not the port", () => { - Provider(AppConfig)([Env], { - // @ts-expect-error the parameter is the env record, which has no `portId` - sync: (env) => ({ dbUrl: env.portId }), - }); + test("a key the deps record does not declare is not on the services record", () => { + Provider(AppConfig)( + { env: Env }, + { + // @ts-expect-error `logger` was never declared as a dependency + sync: ({ env, logger }) => ({ dbUrl: (env["DATABASE_URL"] ?? "") + String(logger) }), + }, + ); + }); + + test("a deps value that is not a port is rejected", () => { + Provider(AppConfig)( + // @ts-expect-error `"Env"` is a string, not a port class + { env: "Env" }, + { + sync: () => ({ dbUrl: "" }), + }, + ); + }); + + test("a services record entry has the dependency's service shape, not the port", () => { + Provider(AppConfig)( + { env: Env }, + { + // @ts-expect-error the entry is the env record, which has no `portId` + sync: ({ env }) => ({ dbUrl: env.portId }), + }, + ); }); test("make infers E from the Err it returns", () => { - const p = Provider(AppConfig)([Env], { - make: (env) => { - const url = env["DATABASE_URL"]; - return url === undefined - ? Err(new ConfigError({ reason: "DATABASE_URL is unset" })) - : Ok({ dbUrl: url }); + const p = Provider(AppConfig)( + { env: Env }, + { + make: ({ env }) => { + const url = env["DATABASE_URL"]; + return url === undefined + ? Err(new ConfigError({ reason: "DATABASE_URL is unset" })) + : Ok({ dbUrl: url }); + }, }, - }); + ); const typed: Provider = p; void typed; @@ -102,12 +131,12 @@ describe("Provider", () => { }); test("class checks the constructor against the declared deps", () => { - Provider(Repo)([AppConfig], { class: RepoImpl }); + Provider(Repo)({ config: AppConfig }, { class: RepoImpl }); }); test("a class whose constructor does not match the deps is rejected", () => { // @ts-expect-error RepoImpl takes an AppConfig service, not a Logger service - Provider(Repo)([Logger], { class: RepoImpl }); + Provider(Repo)({ config: Logger }, { class: RepoImpl }); }); test("two qualifications at once are rejected", () => { @@ -185,7 +214,10 @@ describe("Provider", () => { * launders the channel silently. */ test("an unmet requirement cannot be laundered to no requirement", () => { - const p = Provider(Repo)([AppConfig], { sync: (cfg) => ({ find: () => cfg.dbUrl }) }); + const p = Provider(Repo)( + { config: AppConfig }, + { sync: ({ config }) => ({ find: () => config.dbUrl }) }, + ); // @ts-expect-error AppConfig is still an unmet requirement const typed: Provider = p; void typed; @@ -197,19 +229,25 @@ describe("Provider", () => { // nothing registered for `AppConfig` at all. const makeRepoProvider = (): Provider => // @ts-expect-error AppConfig is still an unmet requirement - Provider(Repo)([AppConfig], { sync: (cfg) => ({ find: () => cfg.dbUrl }) }); + Provider(Repo)( + { config: AppConfig }, + { sync: ({ config }) => ({ find: () => config.dbUrl }) }, + ); void makeRepoProvider; }); test("a wider error union cannot be narrowed away", () => { - const p = Provider(AppConfig)([Env], { - make: (env) => { - const url = env["DATABASE_URL"]; - if (url === undefined) return Err(new ConfigError({ reason: "unset" })); - if (url === "") return Err(new PoolError({ url })); - return Ok({ dbUrl: url }); + const p = Provider(AppConfig)( + { env: Env }, + { + make: ({ env }) => { + const url = env["DATABASE_URL"]; + if (url === undefined) return Err(new ConfigError({ reason: "unset" })); + if (url === "") return Err(new PoolError({ url })); + return Ok({ dbUrl: url }); + }, }, - }); + ); type Channels = ChannelsOf; const errorIsUnion: Equal = true; diff --git a/packages/di/src/scoped.spec.ts b/packages/di/src/scoped.spec.ts index 9d77f79..00a756a 100644 --- a/packages/di/src/scoped.spec.ts +++ b/packages/di/src/scoped.spec.ts @@ -22,10 +22,13 @@ test("resources release in reverse acquisition order after use", async () => { acquire: () => Ok({ n: 1 as const }), release: () => void released.push("first"), }), - Provider(Second)([First], { - acquire: () => Ok({ n: 2 as const }), - release: () => void released.push("second"), - }), + Provider(Second)( + { first: First }, + { + acquire: () => Ok({ n: 2 as const }), + release: () => void released.push("second"), + }, + ), ], exports: [First, Second], }); @@ -42,14 +45,17 @@ test("a mid-graph failure releases everything already acquired", async () => { acquire: () => Ok({ n: 1 as const }), release: () => void released.push("first"), }), - Provider(Second)([First], { - // `acquire` (not `make`) on purpose: `Second` has a `release`, so this - // exercises the real guarantee — a failed *acquire* never registers its - // own release (the `.tap` in `constructLevel` only fires on `Ok`) — not - // just "a provider with no `release` field has nothing to release". - acquire: () => Err(new OpenError({ which: "second" })), - release: () => void released.push("second"), - }), + Provider(Second)( + { first: First }, + { + // `acquire` (not `make`) on purpose: `Second` has a `release`, so this + // exercises the real guarantee — a failed *acquire* never registers its + // own release (the `.tap` in `constructLevel` only fires on `Ok`) — not + // just "a provider with no `release` field has nothing to release". + acquire: () => Err(new OpenError({ which: "second" })), + release: () => void released.push("second"), + }, + ), ], exports: [First, Second], }); @@ -68,10 +74,13 @@ test("a rejecting release neither masks the failure nor stops the unwind", async acquire: () => Ok({ n: 1 as const }), release: () => void released.push("first"), }), - Provider(Second)([First], { - acquire: () => Ok({ n: 2 as const }), - release: () => Promise.reject(new Error("close failed")), - }), + Provider(Second)( + { first: First }, + { + acquire: () => Ok({ n: 2 as const }), + release: () => Promise.reject(new Error("close failed")), + }, + ), ], exports: [First, Second], }); @@ -105,10 +114,13 @@ test("a throwing onTeardownError does not abandon the unwind or mask the origina acquire: () => Ok({ n: 1 as const }), release: () => void released.push("first"), }), - Provider(Second)([First], { - acquire: () => Ok({ n: 2 as const }), - release: () => Promise.reject(new Error("close failed")), - }), + Provider(Second)( + { first: First }, + { + acquire: () => Ok({ n: 2 as const }), + release: () => Promise.reject(new Error("close failed")), + }, + ), ], exports: [First, Second], }); diff --git a/packages/di/src/scoped.test-d.ts b/packages/di/src/scoped.test-d.ts index ec582b3..a0f454e 100644 --- a/packages/di/src/scoped.test-d.ts +++ b/packages/di/src/scoped.test-d.ts @@ -85,10 +85,13 @@ describe("resources", () => { class Other extends Port("SOther")<{ readonly n: number }> {} const needsOther = Module("NeedsOther")({ provides: [ - Provider(Pool)([Other], { - acquire: () => Ok({ close: async () => {} }), - release: (pool) => pool.close(), - }), + Provider(Pool)( + { other: Other }, + { + acquire: () => Ok({ close: async () => {} }), + release: (pool) => pool.close(), + }, + ), ], exports: [Pool], }); From db3f0edf399e9f3c61131a05f8dfae42f6ef3510 Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Thu, 20 Aug 2026 23:06:18 +0200 Subject: [PATCH 03/19] refactor(config): name the Env dependency Config.provider binds --- packages/config/src/config.ts | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/packages/config/src/config.ts b/packages/config/src/config.ts index 6a31cb0..8ef3c90 100644 --- a/packages/config/src/config.ts +++ b/packages/config/src/config.ts @@ -258,13 +258,16 @@ function configProvider(portOrName: AnyPort | string): unknown { const port: AnyPort = typeof portOrName === "string" ? class extends Port(portOrName) {} : portOrName; return (schema: ConfigSchema) => - Provider(port)([Env], { - make: (env): AsyncResult => - fromSafePromise((async () => await schema["~standard"].validate(env))()).flatMap( - (result) => - result.issues === undefined - ? Ok(result.value) - : Err(new ConfigInvalid({ port: port.portId, issues: result.issues })), - ), - }); + Provider(port)( + { env: Env }, + { + make: ({ env }): AsyncResult => + fromSafePromise((async () => await schema["~standard"].validate(env))()).flatMap( + (result) => + result.issues === undefined + ? Ok(result.value) + : Err(new ConfigInvalid({ port: port.portId, issues: result.issues })), + ), + }, + ); } From 53f09ee3a262f85de7cb7ea6df3edd499235c09a Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Thu, 20 Aug 2026 23:07:06 +0200 Subject: [PATCH 04/19] refactor(core): name the dependencies the kernel's fixtures and type tests declare --- packages/core/src/docs-examples.test-d.ts | 15 ++++++++------ packages/core/src/start.test-d.ts | 22 ++++++++++++-------- packages/core/src/test-fixtures.ts | 25 +++++++++++++---------- 3 files changed, 37 insertions(+), 25 deletions(-) diff --git a/packages/core/src/docs-examples.test-d.ts b/packages/core/src/docs-examples.test-d.ts index 7816749..819d84d 100644 --- a/packages/core/src/docs-examples.test-d.ts +++ b/packages/core/src/docs-examples.test-d.ts @@ -113,12 +113,15 @@ class TickSpan extends Port("TickSpan")<{ readonly finish: () => void }> {} const TickModule = Module("Tick")({ provides: [ - Provider(TickSpan)([Greeter], { - sync: (greeter) => ({ - finish: () => process.stderr.write(`${greeter.greet("span")}\n`), - }), - onStop: (span) => span.finish(), - }), + Provider(TickSpan)( + { greeter: Greeter }, + { + sync: ({ greeter }) => ({ + finish: () => process.stderr.write(`${greeter.greet("span")}\n`), + }), + onStop: (span) => span.finish(), + }, + ), ], exports: [TickSpan], }); diff --git a/packages/core/src/start.test-d.ts b/packages/core/src/start.test-d.ts index 321b58b..6ed37e9 100644 --- a/packages/core/src/start.test-d.ts +++ b/packages/core/src/start.test-d.ts @@ -78,10 +78,13 @@ class Span extends Port("GateSpan")<{ readonly note: string }> {} const ClockyUnit = Module("ClockyUnit")({ provides: [ - Provider(Span)([Clock], { - sync: (clock) => ({ note: `${clock.now()}` }), - onStop: () => {}, - }), + Provider(Span)( + { clock: Clock }, + { + sync: ({ clock }) => ({ note: `${clock.now()}` }), + onStop: () => {}, + }, + ), ], exports: [Span], }); @@ -99,10 +102,13 @@ start(Satisfied, { unit: ClockyUnit }, "UNSATISFIED UNIT NEEDS", new Clock()); // throwing at startup. const GreetingSpanUnit = Module("GreetingSpanUnit")({ provides: [ - Provider(Span)([Greeting], { - sync: (greeting) => ({ note: greeting.text }), - onStop: () => {}, - }), + Provider(Span)( + { greeting: Greeting }, + { + sync: ({ greeting }) => ({ note: greeting.text }), + onStop: () => {}, + }, + ), ], exports: [Span], }); diff --git a/packages/core/src/test-fixtures.ts b/packages/core/src/test-fixtures.ts index 5ba1c83..2d4f7b1 100644 --- a/packages/core/src/test-fixtures.ts +++ b/packages/core/src/test-fixtures.ts @@ -165,18 +165,21 @@ export const it = test.extend<{ boot: Boot; unitApp: UnitApp; configured: Config const UnitModule = Module("UnitFixtureUnit")({ provides: [ - Provider(Span)([Parent], { - sync: () => { - counts.spanBuilds += 1; - seen.build = currentUnit()?.unitId; - return { openedIn: seen.build }; - }, - onStop: () => { - counts.spanStops += 1; - seen.stop = currentUnit()?.unitId; - return teardown(); + Provider(Span)( + { parent: Parent }, + { + sync: () => { + counts.spanBuilds += 1; + seen.build = currentUnit()?.unitId; + return { openedIn: seen.build }; + }, + onStop: () => { + counts.spanStops += 1; + seen.stop = currentUnit()?.unitId; + return teardown(); + }, }, - }), + ), ], exports: [Span], }); From ace24bd2ea90c407c9918b81a8236f27fc7e01ee Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Thu, 20 Aug 2026 23:07:50 +0200 Subject: [PATCH 05/19] refactor(example): name the dependencies the container example declares --- .../hexagonal-order-api/src/emit-guards.ts | 9 ++- examples/hexagonal-order-api/src/index.ts | 55 ++++++++++++------- 2 files changed, 40 insertions(+), 24 deletions(-) diff --git a/examples/hexagonal-order-api/src/emit-guards.ts b/examples/hexagonal-order-api/src/emit-guards.ts index 8d4e407..74367bf 100644 --- a/examples/hexagonal-order-api/src/emit-guards.ts +++ b/examples/hexagonal-order-api/src/emit-guards.ts @@ -115,9 +115,12 @@ export const ObservabilityModule = Module("Observability")({ provides: [ MetricsProvider, Provider(OrderCache)({ value: { peek: () => undefined } }), - Provider(Auditor)([OrderRepository], { - sync: (orders) => ({ orders, record: () => Ok(undefined).toAsync() }), - }), + Provider(Auditor)( + { orders: OrderRepository }, + { + sync: ({ orders }) => ({ orders, record: () => Ok(undefined).toAsync() }), + }, + ), ], exports: [Metrics, OrderCache, Auditor], }); diff --git a/examples/hexagonal-order-api/src/index.ts b/examples/hexagonal-order-api/src/index.ts index 20d782c..4741b0c 100644 --- a/examples/hexagonal-order-api/src/index.ts +++ b/examples/hexagonal-order-api/src/index.ts @@ -54,7 +54,7 @@ export class GetOrderInteractor { // Parameter-property shorthand (`constructor(private readonly orders: …)`) // has no type-erasure-only meaning, so this repo's tsconfig rejects it; the // field is declared and assigned explicitly instead. - constructor(orders: ServiceOf) { + constructor({ orders }: { readonly orders: ServiceOf }) { this.orders = orders; } execute(id: string): AsyncResult { @@ -71,12 +71,15 @@ export const ConfigModule = Module("Config")({ // real one would read `process.env` and let a genuinely unset variable // surface as the `ConfigError` below. Provider(Env)({ value: { ORDER_API_DATABASE_URL: "postgres://localhost/orders" } }), - Provider(AppConfig)([Env], { - make: (env) => - env["ORDER_API_DATABASE_URL"] === undefined - ? Err(new ConfigError({ reason: "ORDER_API_DATABASE_URL is unset" })) - : Ok({ dbUrl: env["ORDER_API_DATABASE_URL"] }), - }), + Provider(AppConfig)( + { env: Env }, + { + make: ({ env }) => + env["ORDER_API_DATABASE_URL"] === undefined + ? Err(new ConfigError({ reason: "ORDER_API_DATABASE_URL is unset" })) + : Ok({ dbUrl: env["ORDER_API_DATABASE_URL"] }), + }, + ), ], exports: [AppConfig], }); @@ -86,7 +89,11 @@ export const ConfigModule = Module("Config")({ * exactly once. `config.dbUrl` is read but otherwise unused — a real adapter * would pass it straight to whatever client it opens. */ -const openPool = (config: ServiceOf): Result, never> => { +const openPool = ({ + config, +}: { + readonly config: ServiceOf; +}): Result, never> => { void config.dbUrl; const seed: readonly Order[] = [ { id: "o-1", total: 4_200 }, @@ -111,18 +118,24 @@ export const makePersistenceModule = () => Module("Persistence")({ imports: [ConfigModule], provides: [ - Provider(Pool)([AppConfig], { - acquire: openPool, - release: (pool) => pool.close(), - }), - Provider(OrderRepository)([Pool], { - sync: (pool) => ({ - findById: (id) => { - const row = pool.findById(id); - return (row === undefined ? Err(new OrderNotFound({ id })) : Ok(row)).toAsync(); - }, - }), - }), + Provider(Pool)( + { config: AppConfig }, + { + acquire: openPool, + release: (pool) => pool.close(), + }, + ), + Provider(OrderRepository)( + { pool: Pool }, + { + sync: ({ pool }) => ({ + findById: (id) => { + const row = pool.findById(id); + return (row === undefined ? Err(new OrderNotFound({ id })) : Ok(row)).toAsync(); + }, + }), + }, + ), ], // `Pool` never appears here — the only port this module makes visible is // `OrderRepository`. `Pool` is still genuinely present in the built @@ -155,6 +168,6 @@ export const InMemoryPersistenceModule = Module("InMemoryPersistence")({ export const makeAppModule = (persistence: Module) => Module("App")({ imports: [persistence], - provides: [Provider(GetOrder)([OrderRepository], { class: GetOrderInteractor })], + provides: [Provider(GetOrder)({ orders: OrderRepository }, { class: GetOrderInteractor })], exports: [GetOrder], }); From 19f50c2648b4e7cf4a25ddd3edca9607ee61f60e Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Thu, 20 Aug 2026 23:09:22 +0200 Subject: [PATCH 06/19] refactor(observability): name the LoggerConfig dependency; key tapped's ports by index --- packages/observability/README.md | 19 +++++++++++-------- packages/observability/src/observability.ts | 9 ++++++--- packages/observability/src/test-fixtures.ts | 13 ++++++++----- packages/testing/src/tapped.ts | 10 +++++++--- 4 files changed, 32 insertions(+), 19 deletions(-) diff --git a/packages/observability/README.md b/packages/observability/README.md index 0cdc69f..d1fa983 100644 --- a/packages/observability/README.md +++ b/packages/observability/README.md @@ -32,14 +32,17 @@ import { import { Module, Provider } from "@btravstack/di"; // The application depends on the port, like any other service. -const placeOrder = Provider(PlaceOrder)([OrderRepository, Logger], { - sync: (orders, logger) => ({ - execute: (id, quantity) => - orders - .save({ id, quantity }) - .tap(() => logger.info("order placed", { id, quantity })), - }), -}); +const placeOrder = Provider(PlaceOrder)( + { orders: OrderRepository, logger: Logger }, + { + sync: ({ orders, logger }) => ({ + execute: (id, quantity) => + orders + .save({ id, quantity }) + .tap(() => logger.info("order placed", { id, quantity })), + }), + }, +); // The starter provides it. `LOG_LEVEL` is read inside the graph and validated // once — `verbose` is a startup failure naming the variable, not a silent diff --git a/packages/observability/src/observability.ts b/packages/observability/src/observability.ts index 2548357..7f123e8 100644 --- a/packages/observability/src/observability.ts +++ b/packages/observability/src/observability.ts @@ -48,9 +48,12 @@ export const observability = ( Module("Observability")({ provides: [ Config.provider(LoggerConfig)(loggerSchema(options.level)), - Provider(Logger)([LoggerConfig], { - sync: (config) => createLogger(options.sink ?? jsonSink(), config.level), - }), + Provider(Logger)( + { config: LoggerConfig }, + { + sync: ({ config }) => createLogger(options.sink ?? jsonSink(), config.level), + }, + ), ], exports: [Logger, LoggerConfig], }); diff --git a/packages/observability/src/test-fixtures.ts b/packages/observability/src/test-fixtures.ts index 710ad7b..62a4331 100644 --- a/packages/observability/src/test-fixtures.ts +++ b/packages/observability/src/test-fixtures.ts @@ -123,12 +123,15 @@ export const it = test.extend({ await use( Module("UnitLogging")({ provides: [ - Provider(UnitSpan)([Logger], { - sync: (logger) => { - logger.info("inside the unit"); - return { opened: true }; + Provider(UnitSpan)( + { logger: Logger }, + { + sync: ({ logger }) => { + logger.info("inside the unit"); + return { opened: true }; + }, }, - }), + ), ], exports: [UnitSpan], }), diff --git a/packages/testing/src/tapped.ts b/packages/testing/src/tapped.ts index d49a954..29922bd 100644 --- a/packages/testing/src/tapped.ts +++ b/packages/testing/src/tapped.ts @@ -38,9 +38,13 @@ export const tapped = ( ): { readonly module: Module; readonly services: () => ServicesOf

} => { void gate; let services: ServicesOf

| undefined; - const tap = Provider(Tap)(ports, { - sync: (...built: readonly unknown[]) => { - services = built as unknown as ServicesOf

; + // `ports` stays an array — it is what `services()` answers positionally, not + // a dependency declaration a reader writes. Keying it by index is the + // translation into the one shape `Provider` takes, and index keys are what + // put the services record back in `ports` order. + const tap = Provider(Tap)(Object.fromEntries(ports.map((port, index) => [index, port])), { + sync: (built: Record) => { + services = ports.map((_, index) => built[index]) as unknown as ServicesOf

; return {}; }, } as never); From 4d419c388ff4797dac8584fd38154edbb9367fa1 Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Thu, 20 Aug 2026 23:13:25 +0200 Subject: [PATCH 07/19] fix(di): an empty deps record still hands the factory a record Arity is what tells a provider that declares dependencies from one that declares none, so `keys.length === 0` was the wrong test: a caller who wrote `Provider(P)({}, { sync })` got no argument at all where the types promised a record. `HttpRouter(contract)({}, { sync })` is the shape that found it. --- packages/di/src/provider.spec.ts | 14 +++++++++++--- packages/di/src/provider.ts | 11 ++++++++--- 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/packages/di/src/provider.spec.ts b/packages/di/src/provider.spec.ts index 2579c02..1041d87 100644 --- a/packages/di/src/provider.spec.ts +++ b/packages/di/src/provider.spec.ts @@ -20,9 +20,17 @@ test("a value provider yields its service and declares no deps", async () => { await expect(p.construct([])).resolves.toBeOkWith({ n: 1 }); }); -test("a sync provider receives its dependencies positionally", async () => { - const p = Provider(Value)({ sync: () => ({ n: 2 }) }); - await expect(p.construct([])).resolves.toBeOkWith({ n: 2 }); +test("a sync provider receives its dependencies by name", async () => { + const p = Provider(Value)({ seed: Seed }, { sync: ({ seed }) => ({ n: seed + 1 }) }); + await expect(p.construct([1])).resolves.toBeOkWith({ n: 2 }); +}); + +test("an EMPTY deps record still hands the factory a record, not nothing", async () => { + // Arity is what says a factory takes a services record, so a caller who + // wrote `{}` gets `{}` — not the `undefined` a key count would hand them. + // `HttpRouter(contract)({}, { sync })` is the shape that found this. + const p = Provider(Value)({}, { sync: (services) => ({ n: Object.keys(services).length }) }); + await expect(p.construct([])).resolves.toBeOkWith({ n: 0 }); }); test("a make provider propagates the Err it returns", async () => { diff --git a/packages/di/src/provider.ts b/packages/di/src/provider.ts index 38f1d9f..f4c8d5e 100644 --- a/packages/di/src/provider.ts +++ b/packages/di/src/provider.ts @@ -203,7 +203,12 @@ export type Provider = { const descriptor = ( port: AnyPort, deps: readonly AnyPort[], - keys: readonly string[], + // `undefined` for the no-deps overload, an array — possibly EMPTY — for the + // one that declares a record. The distinction is arity, not key count: a + // caller who writes `Provider(P)({}, { sync })` declared a record and the + // types hand their factory one, so `keys.length === 0` is the wrong test and + // handing that factory nothing is how it read `undefined` instead. + keys: readonly string[] | undefined, options: Record, ): Provider => { // oxlint-disable-next-line unthrown/no-ambiguous-error-type -- see the field comment on `Provider.construct` @@ -214,7 +219,7 @@ const descriptor = ( // arm below spreads: one element for a provider with deps, none without — // which is why a no-deps factory still takes no arguments. const args: readonly unknown[] = - keys.length === 0 + keys === undefined ? [] : [Object.fromEntries(keys.map((key, index) => [key, services[index]]))]; if ("sync" in options) { @@ -280,7 +285,7 @@ export function Provider

>(port: P) { // Two arguments means the first is `deps`; one means it is the options of a // provider that declares none. if (maybeOptions === undefined) { - return descriptor(port, [], [], depsOrOptions as Record) as Provider< + return descriptor(port, [], undefined, depsOrOptions as Record) as Provider< unknown, never, never From 4ff9cdbdc5747c4c3c6d4b88a76937a76dee6eb5 Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Thu, 20 Aug 2026 23:15:06 +0200 Subject: [PATCH 08/19] refactor(http): take a deps record in the three helpers that declare deps HttpRouter's positional arm, HttpController and HttpAuthenticator now declare their dependencies by name, matching di's provider. Arity is what tells HttpRouter's deps form from its keyed-controllers form, since both arguments are non-array objects. The authenticator rides a namespaced key on the deps record instead of a last array slot, and the controllers form's services record is already keyed by contract key, so nothing is reassembled. --- packages/http/CLAUDE.md | 37 +++--- packages/http/README.md | 143 +++++++++++---------- packages/http/src/auth.spec.ts | 6 +- packages/http/src/auth.test-d.ts | 128 ++++++++++++------- packages/http/src/auth.ts | 14 +- packages/http/src/controller.test-d.ts | 84 ++++++++---- packages/http/src/controller.ts | 10 +- packages/http/src/http-runtime.ts | 9 +- packages/http/src/orpc.ts | 125 ++++++++++-------- packages/http/src/test-fixtures.ts | 169 +++++++++++++++---------- 10 files changed, 430 insertions(+), 295 deletions(-) diff --git a/packages/http/CLAUDE.md b/packages/http/CLAUDE.md index c2bef5b..1ee260a 100644 --- a/packages/http/CLAUDE.md +++ b/packages/http/CLAUDE.md @@ -67,10 +67,10 @@ I, O, E>["result"]>[0]` — the `.result()` handler `@unthrown/orpc` gives that `undefined` for an undeclared key, measured), and `os.router(built)` is the port's service. `C` is bounded `Record` — a router record, not a bare procedure, since a bare procedure has no keys to walk. The - second call is di's `Provider(HttpRouterPort)(deps, { sync })` with the + second call is di's `Provider(HttpRouterPort)({ name: Dep }, { sync })` with the router built from what `sync` returns; there is no name to give. The return is `Provider>, never, -InstanceType> & { port: PortClassOf<"HttpRouter", Router<…>> }`, +InstanceType> & { port: PortClassOf<"HttpRouter", Router<…>> }`, spelled through di's `PortInstance` / `PortClassOf` (`{ portId; new (): PortInstance<…> }`) rather than the class's own type because a class expression's type expands the brand keys in a consumer's declaration emit @@ -91,7 +91,7 @@ PortInstance<…> }`) rather than the class's own type because a class IsMarked>, Identity> }`, and the `controllers` **parameter** is typed `M & { readonly [K in Exclude>]: never }` — the same `Exclude` and the same `Inherit` the - positional arm's `Implementation` carries, so a **root-marked** contract + deps arm's `Implementation` carries, so a **root-marked** contract composes here at all (the phantom key is not a controller to supply) and each fragment inherits the root's mark (a controller under it types `context.principal`). Both were missing until `auth.test-d.ts`'s eleventh arm @@ -102,13 +102,15 @@ PrincipalKey>>]: never }` — the same `Exclude` and the same `Inherit` the dropping the key, without the intersection leaking into `M` and collapsing the needs channel di orders the controllers by (the failure mode `controller.test-d.ts`'s `_ComposedNeedsAreDeclared` check exists to catch). - `Array.isArray(depsOrControllers)` discriminates this arm - from the positional one, the same way `Provider(port)(depsOrOptions, …)` - discriminates its own two forms. `deps` for the underlying - `Provider(HttpRouterPort)(...)` are the record's values' `.port`, in - declaration order, so di builds every controller before the router; `sync` - rebuilds the flat implementation record from what each controller's `sync` - returned and hands it to the same `routerOf` walk the positional form uses. + **Arity** discriminates this arm from the deps one — one argument versus + two — the same way `Provider(port)(depsOrOptions, …)` discriminates its own + two forms, since a deps record and a controllers record are both non-array + objects and there is nothing to sniff. `deps` for the underlying + `Provider(HttpRouterPort)(...)` is the controllers record with each value + replaced by its `.port`, so di builds every controller before the router — + and the services record comes back keyed by the SAME contract keys, so the + implementation record needs no reassembling before the `routerOf` walk the + deps form uses. Five compile-time gates are pinned by `controller.test-d.ts`: every contract key covered, an undeclared key rejected, a controller under the wrong key rejected, a procedure a controller's fragment does not declare rejected @@ -137,9 +139,9 @@ PrincipalKey>>]: never }` — the same `Exclude` and the same `Inherit` the fragment does not declare or a handler whose input or output has drifted is a compile error inside the controller rather than at the root — and mints `class extends Port(name)> {}`; the second is di's - `Provider(port)(deps, { sync })`, unchanged. Returns + `Provider(port)({ name: Dep }, { sync })`, unchanged. Returns `Provider>, never, -InstanceType> & { readonly port: PortClassOf> }` — +InstanceType> & { readonly port: PortClassOf> }` — the same `PortInstance`/`PortClassOf` spelling `HttpRouter` uses and for the same reason (TS4023 on a class expression's own type). The controller does no oRPC work: it is a plain record; `HttpRouter`'s `routerOf` walk is what @@ -274,9 +276,12 @@ InstanceType> & { readonly port: PortClassOf> Unreachable while the two halves agree, which is exactly why it is there. When `hasMarked(contract)` answers true, - `AuthenticatorPort` is appended **last** to the provider's dependency array - (so every existing positional service keeps its index; `sync` is called with - the leading slice) and both `build` overloads add + `AuthenticatorPort` joins the provider's deps record under the **namespaced** + key `"@btravstack/http/authenticator"` — namespaced for the same reason + `tapped`'s port id is, since every other key on that record is a name the + caller chose and this one must not be able to collide with a dependency + somebody called `authenticator`; `sync` reads it off the services record and + hands the caller's own `sync` the rest — and both `build` overloads add `HasMark extends true ? AuthenticatorPort : never` to the needs channel plus `readonly identity: Identity` to the result. A marked router whose root provides no authenticator is therefore di's @@ -420,7 +425,7 @@ plugins })`: CORS, body limits, compression, CSRF are transport policy oRPC di `Port` whose service is the node listener, `(request, response, signal) => PromiseLike`. `http()` provides it from the router port (`orpc.ts`'s `orpc({ prefix })`, a - `Provider(HttpHandler)([HttpRouterPort], …)`: `@orpc/server/node`'s + `Provider(HttpHandler)({ router: HttpRouterPort }, …)`: `@orpc/server/node`'s `RPCHandler`, `(request, response) => rpc.handle(request, response, { prefix })`, unmatched → resolves unwritten), and the `HttpRuntime` provider depends on it through di. It returns `PromiseLike` rather than `void` because the diff --git a/packages/http/README.md b/packages/http/README.md index a95cba7..56b00b8 100644 --- a/packages/http/README.md +++ b/packages/http/README.md @@ -27,45 +27,48 @@ import { HttpModule, HttpRouter } from "@btravstack/http"; import { P } from "unthrown"; // Contract-first: the record is shaped like the contract, each leaf a plain -// Result-returning function typed by it. The use cases arrive as arguments — -// di injects them; oRPC's context stays empty. -const ordersRouter = HttpRouter(ordersContract)([PlaceOrder, FindOrder], { - sync: (place, find) => ({ - place: ({ errors }, input) => - place - .execute(input.id, input.quantity) - .map(view) - // The one place a domain error becomes a transport one — exhaustive, - // so a new domain error is a compile error right here. - .mapErrCases((matcher) => - matcher - .with(P.tag("InvalidQuantity"), (error) => - errors.INVALID_QUANTITY({ - message: error.message, - data: { id: error.id }, - }), - ) - .with(P.tag("DuplicateOrder"), (error) => - errors.CONFLICT({ +// Result-returning function typed by it. The use cases arrive under the names +// the deps record gave them — di injects them; oRPC's context stays empty. +const ordersRouter = HttpRouter(ordersContract)( + { place: PlaceOrder, find: FindOrder }, + { + sync: ({ place, find }) => ({ + place: ({ errors }, input) => + place + .execute(input.id, input.quantity) + .map(view) + // The one place a domain error becomes a transport one — exhaustive, + // so a new domain error is a compile error right here. + .mapErrCases((matcher) => + matcher + .with(P.tag("InvalidQuantity"), (error) => + errors.INVALID_QUANTITY({ + message: error.message, + data: { id: error.id }, + }), + ) + .with(P.tag("DuplicateOrder"), (error) => + errors.CONFLICT({ + message: error.message, + data: { id: error.id }, + }), + ), + ), + find: ({ errors }, input) => + find + .execute(input.id) + .map(view) + .mapErrCases((matcher) => + matcher.with(P.tag("OrderNotFound"), (error) => + errors.NOT_FOUND({ message: error.message, data: { id: error.id }, }), ), - ), - find: ({ errors }, input) => - find - .execute(input.id) - .map(view) - .mapErrCases((matcher) => - matcher.with(P.tag("OrderNotFound"), (error) => - errors.NOT_FOUND({ - message: error.message, - data: { id: error.id }, - }), ), - ), - }), -}); + }), + }, +); // A di module that also knows about its router: imports the starter, provides // the router on the starter's own port (a process serves one router, so @@ -200,43 +203,49 @@ const ordersContract = authenticated({ // verifier or a user directory is injected the way any provider's are. It // takes no type argument — `httpAuth()` already fixed one, which is // why the authenticator and the controllers cannot disagree. -const bearerAuthenticator = HttpAuthenticator([], { - sync: () => (headers) => { - const header = headers.authorization ?? ""; - const token = header.startsWith("Bearer ") - ? header.slice("Bearer ".length) - : ""; - const [tenantId, userId] = token.split(":"); - // Empty is not absent: `Authorization: :` splits into two defined strings, - // and admitting them is admitting an anonymous caller as tenant "". - return tenantId === undefined || - tenantId === "" || - userId === undefined || - userId === "" - ? ErrAsync(new Unauthenticated()) - : OkAsync({ tenantId, userId }); +const bearerAuthenticator = HttpAuthenticator( + {}, + { + sync: () => (headers) => { + const header = headers.authorization ?? ""; + const token = header.startsWith("Bearer ") + ? header.slice("Bearer ".length) + : ""; + const [tenantId, userId] = token.split(":"); + // Empty is not absent: `Authorization: :` splits into two defined strings, + // and admitting them is admitting an anonymous caller as tenant "". + return tenantId === undefined || + tenantId === "" || + userId === undefined || + userId === "" + ? ErrAsync(new Unauthenticated()) + : OkAsync({ tenantId, userId }); + }, }, -}); +); // The principal arrives on oRPC's own context channel, typed by `Identity`. -const ordersRouter = HttpRouter({ orders: ordersContract })([FindOrder], { - sync: (find) => ({ - orders: { - find: ({ context, errors }, input) => - find - .execute(context.principal.tenantId, input.id) - .map(view) - .mapErrCases((matcher) => - matcher.with(P.tag("OrderNotFound"), (error) => - errors.NOT_FOUND({ - message: error.message, - data: { id: error.id }, - }), +const ordersRouter = HttpRouter({ orders: ordersContract })( + { find: FindOrder }, + { + sync: ({ find }) => ({ + orders: { + find: ({ context, errors }, input) => + find + .execute(context.principal.tenantId, input.id) + .map(view) + .mapErrCases((matcher) => + matcher.with(P.tag("OrderNotFound"), (error) => + errors.NOT_FOUND({ + message: error.message, + data: { id: error.id }, + }), + ), ), - ), - }, - }), -}); + }, + }), + }, +); const OrdersApi = HttpModule("OrdersApi")({ router: ordersRouter, diff --git a/packages/http/src/auth.spec.ts b/packages/http/src/auth.spec.ts index b09956a..5660fa7 100644 --- a/packages/http/src/auth.spec.ts +++ b/packages/http/src/auth.spec.ts @@ -92,15 +92,15 @@ describe("a contract marked at its root", () => { }); describe("a router over a marked contract", () => { - it("appends the authenticator after the dependencies it already declared", ({ + it("adds the authenticator to the dependencies the caller already declared", ({ authedRouterDeps, }) => { // GIVEN the same marked contract composed through both arms of HttpRouter // WHEN each provider's declared dependencies are read - // THEN the authenticator is last in both, so every existing service keeps its index + // THEN the authenticator joins both, alongside — never in place of — the caller's own expect(authedRouterDeps).toEqual({ keyed: ["AuthedOrders", "AuthedHealth", "HttpAuthenticator"], - positional: ["Greeter", "HttpAuthenticator"], + fromDeps: ["Greeter", "HttpAuthenticator"], }); }); diff --git a/packages/http/src/auth.test-d.ts b/packages/http/src/auth.test-d.ts index 8303c88..5e710f4 100644 --- a/packages/http/src/auth.test-d.ts +++ b/packages/http/src/auth.test-d.ts @@ -96,19 +96,28 @@ void _none; // unknown>`: the need cannot carry the identity, so only the options type // can compare it — the ROUTER's identity against the AUTHENTICATOR's, since // the contract declares none. -const markedRouter = IdentityRouter({ orders: contract.orders, health: contract.health })([], { - sync: () => ({ - orders: { place: ({ context }) => OkAsync({ id: context.principal.userId }) }, - health: { ping: () => OkAsync({ ok: true as const }) }, - }), -}); +const markedRouter = IdentityRouter({ orders: contract.orders, health: contract.health })( + {}, + { + sync: () => ({ + orders: { place: ({ context }) => OkAsync({ id: context.principal.userId }) }, + health: { ping: () => OkAsync({ ok: true as const }) }, + }), + }, +); -const matching = IdentityAuthenticator([], { - sync: () => () => OkAsync({ userId: "u", tenantId: "t" }), -}); -const other = httpAuth<{ readonly sub: string }>().HttpAuthenticator([], { - sync: () => () => OkAsync({ sub: "s" }), -}); +const matching = IdentityAuthenticator( + {}, + { + sync: () => () => OkAsync({ userId: "u", tenantId: "t" }), + }, +); +const other = httpAuth<{ readonly sub: string }>().HttpAuthenticator( + {}, + { + sync: () => () => OkAsync({ sub: "s" }), + }, +); const options = { signals: false, probes: false } as const; @@ -132,9 +141,12 @@ const _wired = start(WiredApi, options); // 10. An unmarked router with an authenticator supplied is not this package's // error to raise: di decides, and a provider nothing needs is no defect. -const publicRouter = IdentityRouter({ health: contract.health })([], { - sync: () => ({ health: { ping: () => OkAsync({ ok: true as const }) } }), -}); +const publicRouter = IdentityRouter({ health: contract.health })( + {}, + { + sync: () => ({ health: { ping: () => OkAsync({ ok: true as const }) } }), + }, +); const _public = start( HttpModule("Public")({ router: publicRouter, authenticator: matching }), options, @@ -150,12 +162,15 @@ void _public; // must therefore `Exclude` the phantom key from the keys it demands (or the // record can never be complete) and `Inherit` the root's mark down to each // fragment (or no controller under it could type `context.principal`) — -// both of which the positional arm already did. `contract.orders` above +// both of which the deps arm already did. `contract.orders` above // marks a KEY, so neither omission showed there. declare const ordersFragment: Authenticated<{ readonly whoami: typeof oc }>; -const rootOrders = IdentityController("RootOrders", ordersFragment)([], { - sync: () => ({ whoami: ({ context }) => OkAsync(context.principal.userId) }), -}); +const rootOrders = IdentityController("RootOrders", ordersFragment)( + {}, + { + sync: () => ({ whoami: ({ context }) => OkAsync(context.principal.userId) }), + }, +); const rootMarkedContract = authenticated({ orders: { whoami: oc } }); const _rootKeyed = HttpModule("RootKeyed")({ router: IdentityRouter(rootMarkedContract)({ orders: rootOrders }), @@ -171,30 +186,42 @@ void _rootKeyed; // 12. A factory-minted controller's MARKED handler sees the factory's identity, // a type the contract declares nowhere. -const scopedOrders = IdentityController("ScopedOrders", contract.orders)([], { - sync: () => ({ place: ({ context }) => OkAsync(context.principal.tenantId) }), -}); +const scopedOrders = IdentityController("ScopedOrders", contract.orders)( + {}, + { + sync: () => ({ place: ({ context }) => OkAsync(context.principal.tenantId) }), + }, +); // 13. The top-level `HttpController` mints no identity, so the same marked // fragment types `principal: never` — the "use the factory" signal, since // any read of it is a compile error. -void HttpController("ContractOrders", contract.orders)([], { - // @ts-expect-error — no factory, so there is no principal type to read - sync: () => ({ place: ({ context }) => OkAsync(context.principal.userId) }), -}); +void HttpController("ContractOrders", contract.orders)( + {}, + { + // @ts-expect-error — no factory, so there is no principal type to read + sync: () => ({ place: ({ context }) => OkAsync(context.principal.userId) }), + }, +); // 14. A factory invents no principal on an UNMARKED fragment: the identity // reaches a marked leaf and no other. -void IdentityController("ScopedHealth", contract.health)([], { - // @ts-expect-error — `principal` is not on an unmarked handler's context - sync: () => ({ ping: ({ context }) => OkAsync(context.principal.tenantId) }), -}); +void IdentityController("ScopedHealth", contract.health)( + {}, + { + // @ts-expect-error — `principal` is not on an unmarked handler's context + sync: () => ({ ping: ({ context }) => OkAsync(context.principal.tenantId) }), + }, +); // 15. A factory-minted router composes factory-minted controllers, and the // `HttpModule` gate checks the authenticator against the ROUTER's identity. -const scopedHealth = IdentityController("ScopedHealthOk", contract.health)([], { - sync: () => ({ ping: () => OkAsync({ ok: true as const }) }), -}); +const scopedHealth = IdentityController("ScopedHealthOk", contract.health)( + {}, + { + sync: () => ({ ping: () => OkAsync({ ok: true as const }) }), + }, +); const _scoped = HttpModule("Scoped")({ router: IdentityRouter({ orders: contract.orders, health: contract.health })({ orders: scopedOrders, @@ -206,9 +233,12 @@ const _scoped = HttpModule("Scoped")({ // 16. An authenticator minted on another identity is still refused, and a // hand-written `HttpAuthenticator

()` is no way around it. -const strayAuthenticator = HttpAuthenticator<{ readonly sub: string }>()([], { - sync: () => () => OkAsync({ sub: "s" }), -}); +const strayAuthenticator = HttpAuthenticator<{ readonly sub: string }>()( + {}, + { + sync: () => () => OkAsync({ sub: "s" }), + }, +); const _strayScoped = HttpModule("StrayScoped")({ router: IdentityRouter({ orders: contract.orders, health: contract.health })({ orders: scopedOrders, @@ -223,17 +253,22 @@ const _strayScoped = HttpModule("StrayScoped")({ // like any other. Pinned because nothing else covers it: every other // authenticator on this branch takes `[]`, so the one form every adopter // actually writes was checked by a reviewer's scratch file and by nothing -// that runs. `deps` are di's, so the services arrive positionally and +// that runs. `deps` are di's, so the services arrive by name and // `sync` closes over them; what reaches `HttpModule` is still a provider // on the same identity. class Verifier extends Port("Verifier")<(token: string) => Identity | undefined> {} -const verifiedAuthenticator = IdentityAuthenticator([Verifier], { - sync: (verify) => (headers) => { - const claimed = verify(headers.authorization ?? ""); - return claimed === undefined ? ErrAsync(new Unauthenticated()) : OkAsync(claimed); +const verifiedAuthenticator = IdentityAuthenticator( + { verify: Verifier }, + { + sync: + ({ verify }) => + (headers) => { + const claimed = verify(headers.authorization ?? ""); + return claimed === undefined ? ErrAsync(new Unauthenticated()) : OkAsync(claimed); + }, }, -}); +); const _verified = HttpModule("Verified")({ router: IdentityRouter({ orders: contract.orders, health: contract.health })({ @@ -251,9 +286,12 @@ const _verified = HttpModule("Verified")({ // 18. The dependency does not loosen the identity check: the same declared // deps with a foreign identity are still refused at the same call. -const verifiedStray = HttpAuthenticator<{ readonly sub: string }>()([Verifier], { - sync: () => () => OkAsync({ sub: "s" }), -}); +const verifiedStray = HttpAuthenticator<{ readonly sub: string }>()( + { verify: Verifier }, + { + sync: () => () => OkAsync({ sub: "s" }), + }, +); const _verifiedStray = HttpModule("VerifiedStray")({ router: IdentityRouter({ orders: contract.orders, health: contract.health })({ orders: scopedOrders, diff --git a/packages/http/src/auth.ts b/packages/http/src/auth.ts index 074a9e7..5261e0e 100644 --- a/packages/http/src/auth.ts +++ b/packages/http/src/auth.ts @@ -44,8 +44,8 @@ export type AuthenticatorPort = PortInstance<"HttpAuthenticator", AuthenticatorS * The authenticator as a provider, with its principal type stated at the call: * * ```ts - * export const jwtAuthenticator = HttpAuthenticator()([JwtVerifier], { - * sync: (verify) => (headers) => verify(headers.authorization), + * export const jwtAuthenticator = HttpAuthenticator()({ verify: JwtVerifier }, { + * sync: ({ verify }) => (headers) => verify(headers.authorization), * }); * ``` * @@ -55,14 +55,14 @@ export type AuthenticatorPort = PortInstance<"HttpAuthenticator", AuthenticatorS */ export const HttpAuthenticator =

() => - ( + >>( deps: D, options: { - readonly sync: ( - ...services: { [K in keyof D]: ServiceOf> } - ) => AuthenticatorService

; + readonly sync: (services: { + readonly [K in keyof D]: ServiceOf>; + }) => AuthenticatorService

; }, - ): Provider> & { readonly principal: P } => + ): Provider> & { readonly principal: P } => Provider(AuthenticatorPort)(deps, options as never) as never; /** diff --git a/packages/http/src/controller.test-d.ts b/packages/http/src/controller.test-d.ts index 9902a85..ab71ba8 100644 --- a/packages/http/src/controller.test-d.ts +++ b/packages/http/src/controller.test-d.ts @@ -11,12 +11,18 @@ import { HttpRouter } from "./orpc.js"; const contract = { orders: { place: oc }, users: { find: oc } }; -const orders = HttpController("GateOrders", contract.orders)([], { - sync: () => ({ place: () => OkAsync("placed") }), -}); -const users = HttpController("GateUsers", contract.users)([], { - sync: () => ({ find: () => OkAsync("found") }), -}); +const orders = HttpController("GateOrders", contract.orders)( + {}, + { + sync: () => ({ place: () => OkAsync("placed") }), + }, +); +const users = HttpController("GateUsers", contract.users)( + {}, + { + sync: () => ({ find: () => OkAsync("found") }), + }, +); // 1. Every contract key must be covered. // @ts-expect-error — `users` is missing from the record @@ -31,10 +37,13 @@ void HttpRouter(contract)({ orders, users, billing: orders }); void HttpRouter(contract)({ orders: users, users: orders }); // 4. A procedure the fragment does not declare is rejected inside the controller. -void HttpController("GateTypo", contract.orders)([], { - // @ts-expect-error — the fragment declares `place`, not `plce` - sync: () => ({ plce: () => OkAsync("placed") }), -}); +void HttpController("GateTypo", contract.orders)( + {}, + { + // @ts-expect-error — the fragment declares `place`, not `plce` + sync: () => ({ plce: () => OkAsync("placed") }), + }, +); // 5. A slice lifts out into its own process with its controller UNCHANGED: a // fragment is a valid contract in its own right, and the lifted root takes @@ -42,13 +51,22 @@ void HttpController("GateTypo", contract.orders)([], { // that controller built. Strictly stronger than re-implementing the fragment // with a fresh `sync`, which would prove nothing about the controller. The // spec marks this "do not break"; this is what would catch breaking it. -void HttpRouter(contract.orders)([orders.port], { sync: (implementation) => implementation }); +void HttpRouter(contract.orders)( + { implementation: orders.port }, + { sync: ({ implementation }) => implementation }, +); -// The correct composition, and the positional form, both still compile. +// The correct composition, and the deps form, both still compile. const composed = HttpRouter(contract)({ orders, users }); -void HttpRouter(contract)([], { - sync: () => ({ orders: { place: () => OkAsync("placed") }, users: { find: () => OkAsync("f") } }), -}); +void HttpRouter(contract)( + {}, + { + sync: () => ({ + orders: { place: () => OkAsync("placed") }, + users: { find: () => OkAsync("f") }, + }), + }, +); // The composed provider must DECLARE its controllers as needs — if the // exactness intersection on the keyed `build` overload (orpc.ts) ever @@ -69,12 +87,18 @@ const { HttpController: IdentityController, HttpRouter: IdentityRouter } = httpA readonly userId: string; }>(); -const markedOrders = IdentityController("GateMarkedOrders", markedContract.orders)([], { - sync: () => ({ place: (opts) => OkAsync(opts.context.principal.userId) }), -}); -const markedUsers = IdentityController("GateMarkedUsers", markedContract.users)([], { - sync: () => ({ find: () => OkAsync("found") }), -}); +const markedOrders = IdentityController("GateMarkedOrders", markedContract.orders)( + {}, + { + sync: () => ({ place: (opts) => OkAsync(opts.context.principal.userId) }), + }, +); +const markedUsers = IdentityController("GateMarkedUsers", markedContract.users)( + {}, + { + sync: () => ({ find: () => OkAsync("found") }), + }, +); // 1. Every contract key must be covered. // @ts-expect-error — `users` is missing from the record @@ -93,15 +117,19 @@ void IdentityRouter(markedContract)({ void IdentityRouter(markedContract)({ orders: markedUsers, users: markedOrders }); // 4. A procedure the fragment does not declare is rejected inside the controller. -void IdentityController("GateMarkedTypo", markedContract.orders)([], { - // @ts-expect-error — the fragment declares `place`, not `plce` - sync: () => ({ plce: () => OkAsync("placed") }), -}); +void IdentityController("GateMarkedTypo", markedContract.orders)( + {}, + { + // @ts-expect-error — the fragment declares `place`, not `plce` + sync: () => ({ plce: () => OkAsync("placed") }), + }, +); // 5. The do-not-break lift, for a marked fragment. -void IdentityRouter(markedContract.orders)([markedOrders.port], { - sync: (implementation) => implementation, -}); +void IdentityRouter(markedContract.orders)( + { implementation: markedOrders.port }, + { sync: ({ implementation }) => implementation }, +); // The correct composition still compiles. The other direction is what has to // be refused: a controller whose handler READS a principal cannot be mounted diff --git a/packages/http/src/controller.ts b/packages/http/src/controller.ts index 39f39e7..52c3c2a 100644 --- a/packages/http/src/controller.ts +++ b/packages/http/src/controller.ts @@ -29,14 +29,14 @@ import type { Implementation } from "./orpc.js"; export const controllerFor = () => (name: Name, contract: C) => - ( + >>( deps: D, options: { - readonly sync: ( - ...services: { [K in keyof D]: ServiceOf> } - ) => Implementation; + readonly sync: (services: { + readonly [K in keyof D]: ServiceOf>; + }) => Implementation; }, - ): Provider>, never, InstanceType> & { + ): Provider>, never, InstanceType> & { readonly port: PortClassOf>; } => { // The parameter is named, not `_`-prefixed, so it reads as `contract` in the diff --git a/packages/http/src/http-runtime.ts b/packages/http/src/http-runtime.ts index fdff946..a79d78e 100644 --- a/packages/http/src/http-runtime.ts +++ b/packages/http/src/http-runtime.ts @@ -119,9 +119,12 @@ export const httpModule = ( provides: [ config, handler, - Provider(HttpRuntime)([HttpConfig, HttpHandler], { - sync: (c, h) => httpRuntime(c, h, securityHeaders), - }), + Provider(HttpRuntime)( + { config: HttpConfig, handler: HttpHandler }, + { + sync: ({ config: bound, handler: handle }) => httpRuntime(bound, handle, securityHeaders), + }, + ), ], exports: [HttpRuntime, HttpConfig], }) as unknown as Module; diff --git a/packages/http/src/orpc.ts b/packages/http/src/orpc.ts index d28af71..4dae1db 100644 --- a/packages/http/src/orpc.ts +++ b/packages/http/src/orpc.ts @@ -73,22 +73,26 @@ export type HttpRouterPort = PortInstance<"HttpRouter", Router { const prefix = options.prefix ?? "/rpc"; - return Provider(HttpHandler)([HttpRouterPort], { - sync: (service) => { - const rpc = new RPCHandler(service, { plugins: [...(options.plugins ?? [])] }); - // The request rides oRPC's initial context so `principalMiddleware` can - // read its headers; nothing else in this package reads it. - return (request, response) => rpc.handle(request, response, { prefix, context: { request } }); + return Provider(HttpHandler)( + { router: HttpRouterPort }, + { + sync: ({ router }) => { + const rpc = new RPCHandler(router, { plugins: [...(options.plugins ?? [])] }); + // The request rides oRPC's initial context so `principalMiddleware` can + // read its headers; nothing else in this package reads it. + return (request, response) => + rpc.handle(request, response, { prefix, context: { request } }); + }, }, - }); + ); }; /** * The router as a provider, **from the contract**: * * ```ts - * const orderRouter = HttpRouter(orderContract)([PlaceOrder, FindOrder], { - * sync: (place, find) => ({ + * const orderRouter = HttpRouter(orderContract)({ place: PlaceOrder, find: FindOrder }, { + * sync: ({ place, find }) => ({ * orders: { * place: ({ errors }, input) => place.execute(input.id, input.quantity).map(view).mapErrCases(…), * find: ({ errors }, input) => find.execute(input.id).map(view).mapErrCases(…), @@ -107,14 +111,15 @@ export const orpc = (options: OrpcOptions = {}) => { * `os.router(...)` are what this call does for you. * * The first call fixes the contract; the second is di's - * `Provider(port)([deps], { sync })` on the starter's own router port, with + * `Provider(port)({ name: Dep }, { sync })` on the starter's own router port, with * one difference: `sync` returns the implementation record and the router is * built from it. There is no name to give — a process serves one router, so * the port is the starter's (`HttpRouterPort`), and the provider carries it * typed (`orderRouter.port`) for whoever else needs the class. * * The second call also takes a **keyed record of controllers** instead of - * `(deps, { sync })`: `HttpRouter(contract)({ orders: ordersController, users: + * `(deps, { sync })` — one argument rather than two, which is what tells the + * two apart, exactly as `Provider(port)(…)` discriminates its own: `HttpRouter(contract)({ orders: ordersController, users: * usersController })`, one `HttpController` per top-level contract key. Each * fragment is composed as-is rather than re-implemented, and every key of the * contract must be covered — a missing or extra key is a compile error. @@ -131,17 +136,17 @@ export const routerFor = readonly router: (record: Record) => Router>; }; - function build( + function build>>( deps: D, options: { - readonly sync: ( - ...services: { [K in keyof D]: ServiceOf> } - ) => Implementation; + readonly sync: (services: { + readonly [K in keyof D]: ServiceOf>; + }) => Implementation; }, ): Provider< PortInstance<"HttpRouter", Router>>, never, - InstanceType | (HasMark extends true ? AuthenticatorPort : never) + InstanceType | (HasMark extends true ? AuthenticatorPort : never) > & { readonly port: PortClassOf<"HttpRouter", Router>>; readonly identity: Identity; @@ -166,50 +171,63 @@ export const routerFor = readonly identity: Identity; }; function build(depsOrControllers: unknown, options?: unknown): unknown { - // The authenticator is appended LAST to the dependency array so every - // existing positional service keeps the index `sync` already reads it at. const guarded = hasMarked(contract); - const authenticatorOf = (services: readonly unknown[]): AuthenticatorService => - services.at(-1) as AuthenticatorService; - - // `Array.isArray` discriminates the two forms, the same way - // `Provider(port)(depsOrOptions, …)` discriminates its own. - if (Array.isArray(depsOrControllers)) { - const deps = depsOrControllers as readonly AnyPort[]; - const sync = (...services: readonly unknown[]): Router> => - os.router( - routerOf( - os, - (options as { readonly sync: (...s: readonly unknown[]) => unknown }).sync( - ...services.slice(0, deps.length), - ) as Record, - contract, - isAuthenticated(contract), - guarded ? authenticatorOf(services) : undefined, - ), - ); - return Provider(HttpRouterPort)(guarded ? [...deps, AuthenticatorPort] : deps, { - sync, - } as never); - } - - const entries = Object.entries( - depsOrControllers as Record, - ); - const sync = (...services: readonly unknown[]): Router> => + // The authenticator rides a NAMESPACED key on the deps record, for the + // same reason `tapped`'s port id is namespaced: the other keys are the + // caller's own names, and this one must not be able to collide with a + // dependency somebody called `authenticator`. + const own = (services: Record): Record => { + const { [AUTHENTICATOR]: _authenticator, ...rest } = services; + return rest; + }; + const withAuthenticator = (deps: Record): Record => + guarded ? { ...deps, [AUTHENTICATOR]: AuthenticatorPort } : deps; + const routerFrom = ( + implementation: Record, + services: Record, + ): Router> => os.router( routerOf( os, - Object.fromEntries(entries.map(([key], index) => [key, services[index]])), + implementation, contract, isAuthenticated(contract), - guarded ? authenticatorOf(services) : undefined, + guarded ? (services[AUTHENTICATOR] as AuthenticatorService) : undefined, ), ); - const ports = entries.map(([, controller]) => controller.port); - return Provider(HttpRouterPort)(guarded ? [...ports, AuthenticatorPort] : ports, { - sync, - } as never); + + // ARITY discriminates the two forms, the same way + // `Provider(port)(depsOrOptions, …)` discriminates its own: a deps record + // and a controllers record are both non-array objects, so there is + // nothing to sniff. + if (options !== undefined) { + const sync = (services: Record): Router> => + routerFrom( + (options as { readonly sync: (s: Record) => unknown }).sync( + own(services), + ) as Record, + services, + ); + return Provider(HttpRouterPort)( + withAuthenticator(depsOrControllers as Record), + { sync } as never, + ); + } + + // The controllers record is keyed by contract key, and so is the services + // record it becomes — so the implementation IS what the graph resolved, + // with nothing to reassemble positionally. + const controllers = depsOrControllers as Record; + const sync = (services: Record): Router> => + routerFrom(own(services), services); + return Provider(HttpRouterPort)( + withAuthenticator( + Object.fromEntries( + Object.entries(controllers).map(([key, controller]) => [key, controller.port]), + ), + ), + { sync } as never, + ); } return build; @@ -223,6 +241,9 @@ export const routerFor = */ export const HttpRouter: ReturnType> = routerFor(); +// Namespaced so it cannot collide with a key the caller wrote; see `build`. +const AUTHENTICATOR = "@btravstack/http/authenticator"; + /** A controller for one fragment — what `HttpController` returns, as the keyed form consumes it. */ type ControllerFor = { readonly port: PortClassOf>; diff --git a/packages/http/src/test-fixtures.ts b/packages/http/src/test-fixtures.ts index 18046b8..572d7c7 100644 --- a/packages/http/src/test-fixtures.ts +++ b/packages/http/src/test-fixtures.ts @@ -92,28 +92,35 @@ const greetingImplementation = (greeter: ServiceOf) => ({ }); /** The router as a service, built from the greeter it declares — contract-first, on the starter's own router port. */ -const greetingRouter = HttpRouter(greetingContract)([Greeter], { - sync: greetingImplementation, -}); +const greetingRouter = HttpRouter(greetingContract)( + { greeter: Greeter }, + { + sync: ({ greeter }) => greetingImplementation(greeter), + }, +); /** Two controllers over the same contract's two halves — what the keyed router composes. */ -export const helloController = HttpController("HelloController", helloFragment)([Greeter], { - sync: (greeter) => ({ hello: () => OkAsync(greeter.greet("world")) }), -}); +export const helloController = HttpController("HelloController", helloFragment)( + { greeter: Greeter }, + { sync: ({ greeter }) => ({ hello: () => OkAsync(greeter.greet("world")) }) }, +); /** * The keyed form's own contract, over the same two fragments. Not a constraint * — a bare procedure is a `RouterContract` too, so a controller sits at such a - * key as happily as at a nested one — but `greetingContract` is the positional + * key as happily as at a nested one — but `greetingContract` is the deps * form's fixture, carrying its `boom` defect and the stray key smuggled past * the types, and the two arms are worth exercising side by side. */ const slicedContract = oc.router({ greetings: helloFragment, echoes: nestedFragment }); /** The other half of `slicedContract`, alongside the reused `helloController`. */ -const echoesController = HttpController("EchoesController", nestedFragment)([], { - sync: () => ({ ping: () => OkAsync("pong") }), -}); +const echoesController = HttpController("EchoesController", nestedFragment)( + {}, + { + sync: () => ({ ping: () => OkAsync("pong") }), + }, +); /** The same kind of API as `greetingRouter`, composed from controllers instead of one `sync`. */ const slicedRouter = HttpRouter(slicedContract)({ @@ -157,32 +164,41 @@ const authedContract = { orders: authenticated({ whoami }), health: { ping } }; /** Counted so a test can assert the handler was never entered on a refusal. */ let authedRuns = 0; -const authedOrdersController = AuthedController("AuthedOrders", authedContract.orders)([], { - sync: () => ({ - whoami: ({ context }) => { - authedRuns += 1; - return OkAsync({ userId: context.principal.userId }); - }, - }), -}); - -const authedHealthController = AuthedController("AuthedHealth", authedContract.health)([], { - sync: () => ({ ping: () => OkAsync({ ok: true as const }) }), -}); +const authedOrdersController = AuthedController("AuthedOrders", authedContract.orders)( + {}, + { + sync: () => ({ + whoami: ({ context }) => { + authedRuns += 1; + return OkAsync({ userId: context.principal.userId }); + }, + }), + }, +); -const authenticator = AuthedAuthenticator([], { - sync: () => (headers) => { - if (headers.authorization === "Bearer boom") { - return OkAsync().map((): Identity => { - // oxlint-disable-next-line unthrown/no-throw -- an authenticator bug IS the subject under test, and a throw inside a combinator is the only way to mint a Defect - throw new Error("authenticator bug"); - }); - } - return headers.authorization === "Bearer good" - ? OkAsync({ tenantId: "t-good", userId: "u-good" }) - : ErrAsync(new Unauthenticated()); +const authedHealthController = AuthedController("AuthedHealth", authedContract.health)( + {}, + { + sync: () => ({ ping: () => OkAsync({ ok: true as const }) }), }, -}); +); + +const authenticator = AuthedAuthenticator( + {}, + { + sync: () => (headers) => { + if (headers.authorization === "Bearer boom") { + return OkAsync().map((): Identity => { + // oxlint-disable-next-line unthrown/no-throw -- an authenticator bug IS the subject under test, and a throw inside a combinator is the only way to mint a Defect + throw new Error("authenticator bug"); + }); + } + return headers.authorization === "Bearer good" + ? OkAsync({ tenantId: "t-good", userId: "u-good" }) + : ErrAsync(new Unauthenticated()); + }, + }, +); const authedRouter = AuthedRouter(authedContract)({ orders: authedOrdersController, @@ -190,17 +206,20 @@ const authedRouter = AuthedRouter(authedContract)({ }); /** - * The same marked contract through the positional form, so where the - * authenticator lands in `deps` is pinned for both arms of `build`. + * The same marked contract through the deps form, so the authenticator's own + * key on the deps record is pinned for both arms of `build`. */ -const authedPositionalRouter = AuthedRouter(authedContract)([Greeter], { - sync: (greeter) => ({ - orders: { - whoami: ({ context }) => OkAsync({ userId: greeter.greet(context.principal.userId) }), - }, - health: { ping: () => OkAsync({ ok: true as const }) }, - }), -}); +const authedPositionalRouter = AuthedRouter(authedContract)( + { greeter: Greeter }, + { + sync: ({ greeter }) => ({ + orders: { + whoami: ({ context }) => OkAsync({ userId: greeter.greet(context.principal.userId) }), + }, + health: { ping: () => OkAsync({ ok: true as const }) }, + }), + }, +); /** `HttpModule` over the protected router, with the authenticator the router now needs. */ const rpcAuthedAppOf = () => @@ -221,16 +240,19 @@ const rootMarkedContract = authenticated({ orders: { whoami } }); let rootMarkedRuns = 0; -const rootMarkedRouter = AuthedRouter(rootMarkedContract)([], { - sync: () => ({ - orders: { - whoami: ({ context }) => { - rootMarkedRuns += 1; - return OkAsync({ userId: context.principal.userId }); +const rootMarkedRouter = AuthedRouter(rootMarkedContract)( + {}, + { + sync: () => ({ + orders: { + whoami: ({ context }) => { + rootMarkedRuns += 1; + return OkAsync({ userId: context.principal.userId }); + }, }, - }, - }), -}); + }), + }, +); const rpcRootMarkedAppOf = () => HttpModule("RpcRootMarkedApp")({ @@ -263,12 +285,15 @@ type RootMarkedClient = RouterContractClient<{ * reachable past the types (the assertion is the bypass), which is what * `routerOf`'s own guard exists for: the stray key is dropped, not defected on. */ -const strayRouter = HttpRouter(greetingContract)([Greeter], { - sync: (greeter) => - ({ ...greetingImplementation(greeter), stray: () => OkAsync("stray") }) as ReturnType< - typeof greetingImplementation - >, -}); +const strayRouter = HttpRouter(greetingContract)( + { greeter: Greeter }, + { + sync: ({ greeter }) => + ({ ...greetingImplementation(greeter), stray: () => OkAsync("stray") }) as ReturnType< + typeof greetingImplementation + >, + }, +); /** The starter as an application uses it: `HttpModule` sugar over a router provider. */ const rpcAppOf = (prefix?: `/${string}`, stray = false) => @@ -289,9 +314,12 @@ const corsContract = oc.router({ greet: oc.input(ocType<{ readonly name: string }>()).output(ocType()), }); -const corsRouter = HttpRouter(corsContract)([], { - sync: () => ({ greet: ({ input }) => OkAsync(`hello ${input.name}`) }), -}); +const corsRouter = HttpRouter(corsContract)( + {}, + { + sync: () => ({ greet: ({ input }) => OkAsync(`hello ${input.name}`) }), + }, +); /** The same starter shape as `rpcAppOf`, with oRPC's CORS plugin configured. */ const rpcWithCorsAppOf = () => @@ -312,12 +340,15 @@ const configuredAppOf = (options: { readonly port?: number; readonly hostname?: module: Module("ConfiguredApp")({ imports: [httpModule(options, Provider(HttpHandler)({ value: noop }))], provides: [ - Provider(BoundConfig)([HttpConfig], { - sync: (config) => { - bound = config; - return { value: config }; + Provider(BoundConfig)( + { config: HttpConfig }, + { + sync: ({ config }) => { + bound = config; + return { value: config }; + }, }, - }), + ), ], exports: [HttpRuntime], }), @@ -486,7 +517,7 @@ export type HttpFixtures = { /** What each `HttpRouter` arm declares as its dependencies over the same marked contract. */ readonly authedRouterDeps: { readonly keyed: readonly string[]; - readonly positional: readonly string[]; + readonly fromDeps: readonly string[]; }; /** The starter over a router with oRPC's CORS plugin configured. Shut down by the fixture. */ readonly rpcWithCors: { readonly url: string }; @@ -717,7 +748,7 @@ export const it = test.extend({ authedRouterDeps: async ({}, use) => { await use({ keyed: authedRouter.deps.map((dep) => dep.portId), - positional: authedPositionalRouter.deps.map((dep) => dep.portId), + fromDeps: authedPositionalRouter.deps.map((dep) => dep.portId), }); }, From 32f77cbde8c40502e62b599176f61f35d964eb0e Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Thu, 20 Aug 2026 23:17:29 +0200 Subject: [PATCH 09/19] refactor(temporal): take a deps record in the activities builders TemporalActivities' deps arm and TemporalWorkflowActivities both inherit di's record form. The composing arm now declares each piece under the contract key its port id carries, so the services record IS the activities record and construct hands it straight back. --- packages/temporal/CLAUDE.md | 5 +- packages/temporal/README.md | 29 ++++++----- packages/temporal/src/temporal-module.ts | 34 ++++++------- packages/temporal/src/temporal-runtime.ts | 62 +++++++++++++---------- packages/temporal/src/test-fixtures.ts | 55 +++++++++++--------- 5 files changed, 104 insertions(+), 81 deletions(-) diff --git a/packages/temporal/CLAUDE.md b/packages/temporal/CLAUDE.md index 1150429..445ff5e 100644 --- a/packages/temporal/CLAUDE.md +++ b/packages/temporal/CLAUDE.md @@ -159,8 +159,9 @@ close`, failure the modeled **`TemporalUnreachable`** `{ address, cause }`. `DeclareActivitiesHandlerOptions["activities"]` — the implementations record `declareActivitiesHandler` takes for `C`, with no injected context — built by a provider from the application's own services (closures; nothing - resolved from a `ctx`). Inside, `Provider(TemporalRuntime)([TemporalConnection, -TemporalConfig, TemporalActivitiesPort as ActivitiesPortOf], { sync })` — + resolved from a `ctx`). Inside, `Provider(TemporalRuntime)({ connection: TemporalConnection, +config: TemporalConfig, activities: TemporalActivitiesPort as +ActivitiesPortOf }, { sync })` — the port rides di, which is why `ActivitiesInstanceOf` is in the module's `Needs` and a root that imports the starter without providing the activities is rejected by `start` for still owing it (the diff --git a/packages/temporal/README.md b/packages/temporal/README.md index c008c9e..2e85892 100644 --- a/packages/temporal/README.md +++ b/packages/temporal/README.md @@ -29,20 +29,23 @@ import { P } from "unthrown"; // cases it declares — closures over them, no context read at call time — on // the starter's own activities port, typed by the contract (a worker serves // one activities record, so there is nothing to name). -const orderActivities = TemporalActivities(contract)([PlaceOrder], { - sync: (place) => ({ - placeOrder: { - place: (args, { errors }) => - place - .execute(args.orderId, args.quantity) - .mapErrCases((matcher) => - matcher.with(P.tag("DuplicateOrder"), (error) => - errors.OrderAlreadyPlaced({ id: error.id }), +const orderActivities = TemporalActivities(contract)( + { place: PlaceOrder }, + { + sync: ({ place }) => ({ + placeOrder: { + place: (args, { errors }) => + place + .execute(args.orderId, args.quantity) + .mapErrCases((matcher) => + matcher.with(P.tag("DuplicateOrder"), (error) => + errors.OrderAlreadyPlaced({ id: error.id }), + ), ), - ), - }, - }), -}); + }, + }), + }, +); // The composition root: a di module, plus the contract, the activities // provider and the workflow source — and nothing else to know. diff --git a/packages/temporal/src/temporal-module.ts b/packages/temporal/src/temporal-module.ts index f9982fe..f1cee91 100644 --- a/packages/temporal/src/temporal-module.ts +++ b/packages/temporal/src/temporal-module.ts @@ -172,15 +172,16 @@ type Compose = ({ fulfillOrder: { … } }) }) + * TemporalActivities(orderContract)({ place: PlaceOrder }, { sync: ({ place }) => ({ fulfillOrder: { … } }) }) * TemporalActivities(orderContract)([fulfillOrder, chargeOrder]) * ``` * * The first two are di's own `Provider(port)` on the starter's activities port * typed for the contract — any arm, same typing. The third takes the **pieces** * `TemporalWorkflowActivities(contract, key)` builds, one per top-level key of - * the record: di constructs every piece first (they are the provider's deps, in - * array order) and this reassembles the record from them. Every key must be + * the record: di constructs every piece first — they are the provider's deps, + * keyed by the very contract key each piece's port id carries, so the services + * record IS the activities record. Every key must be * covered, and two slices claiming one key are two providers for one port — di's * duplicate-provider defect at build, which is the point. */ @@ -189,22 +190,21 @@ export const TemporalActivities = ( ): ReturnType>> & Compose => { void contract; const build = Provider(TemporalActivitiesPort as ActivitiesPortOf); - const compose = (pieces: readonly { readonly port: { readonly portId: string } }[]): unknown => { - const keys = pieces.map((piece) => piece.port.portId.slice(WORKFLOW_ACTIVITIES_PREFIX.length)); - return build( - pieces.map((piece) => piece.port) as never, - { - sync: (...services: readonly unknown[]) => - Object.fromEntries(keys.map((key, index) => [key, services[index]])), - } as never, + const compose = (pieces: readonly { readonly port: { readonly portId: string } }[]): unknown => + build( + Object.fromEntries( + pieces.map((piece) => [ + piece.port.portId.slice(WORKFLOW_ACTIVITIES_PREFIX.length), + piece.port, + ]), + ) as never, + { sync: (services: unknown) => services } as never, ); - }; // One array argument is never a valid `Provider(port)` call — its arms are - // `(deps, options)` and `(options)` — so the arity plus `Array.isArray` is a - // sound discriminator. Not the same dispatch di's own `Provider(port)` - // build uses, though: that one narrows on `Array.isArray` alone (`provider.ts`), - // which is enough for ITS two arms since a lone array is never valid there; - // the arity check here is what this THIRD, composing arm needs on top of it. + // `(deps, options)` and `(options)`, and both objects are records — so + // `Array.isArray` alone identifies this THIRD, composing arm. The arity check + // rides along because di's own build discriminates on arity (`provider.ts`) + // and a two-argument call is never this arm. return ((first: unknown, second?: unknown) => second === undefined && Array.isArray(first) ? compose(first as readonly { readonly port: { readonly portId: string } }[]) diff --git a/packages/temporal/src/temporal-runtime.ts b/packages/temporal/src/temporal-runtime.ts index 43cffd8..c95102c 100644 --- a/packages/temporal/src/temporal-runtime.ts +++ b/packages/temporal/src/temporal-runtime.ts @@ -178,32 +178,42 @@ export const temporal = ( return Module("Temporal")({ provides: [ config, - Provider(TemporalConnection)([TemporalConfig], { - acquire: (bound) => - fromPromise( - NativeConnection.connect({ address: bound.address }), - (cause) => new TemporalUnreachable({ address: bound.address, cause }), - ), - // On the drain-deadline path the kernel has already been handed its - // thread back while `worker.run()` is still winding down on Temporal's - // own clock, and `NativeConnection.close()` refuses while a Worker - // holds it (`IllegalStateError`). That is not a teardown failure to - // report — the worker releases the connection when its force-stop - // lands, and the process is exiting — so only that refusal is - // absorbed; any other close failure is still the finaliser's to - // surface. - release: (connection) => - connection - .close() - .catch((cause: unknown) => (heldByWorker(cause) ? undefined : Promise.reject(cause))), - }), - Provider(TemporalRuntime)([TemporalConnection, TemporalConfig, activities], { - sync: (connection, bound, impls): Runtime => ({ - name: "temporal", - needs: [], - start: (host) => createWorker(host, connection, bound, impls, options), - }), - }), + Provider(TemporalConnection)( + { config: TemporalConfig }, + { + acquire: ({ config: bound }) => + fromPromise( + NativeConnection.connect({ address: bound.address }), + (cause) => new TemporalUnreachable({ address: bound.address, cause }), + ), + // On the drain-deadline path the kernel has already been handed its + // thread back while `worker.run()` is still winding down on Temporal's + // own clock, and `NativeConnection.close()` refuses while a Worker + // holds it (`IllegalStateError`). That is not a teardown failure to + // report — the worker releases the connection when its force-stop + // lands, and the process is exiting — so only that refusal is + // absorbed; any other close failure is still the finaliser's to + // surface. + release: (connection) => + connection + .close() + .catch((cause: unknown) => (heldByWorker(cause) ? undefined : Promise.reject(cause))), + }, + ), + Provider(TemporalRuntime)( + { connection: TemporalConnection, config: TemporalConfig, activities }, + { + sync: ({ + connection, + config: bound, + activities: impls, + }): Runtime => ({ + name: "temporal", + needs: [], + start: (host) => createWorker(host, connection, bound, impls, options), + }), + }, + ), ], exports: [TemporalRuntime, TemporalConfig, TemporalConnection], }); diff --git a/packages/temporal/src/test-fixtures.ts b/packages/temporal/src/test-fixtures.ts index 1d8679d..da80d9c 100644 --- a/packages/temporal/src/test-fixtures.ts +++ b/packages/temporal/src/test-fixtures.ts @@ -88,17 +88,20 @@ const contractSeamOf = () => { let greeting = ""; return { - activities: EchoActivities([Greeting], { - sync: (service) => ({ - runEcho: { - echo: (value) => { - seen.push(currentUnit()); - greeting = service.text; - return OkAsync(value); + activities: EchoActivities( + { greeting: Greeting }, + { + sync: ({ greeting: service }) => ({ + runEcho: { + echo: (value) => { + seen.push(currentUnit()); + greeting = service.text; + return OkAsync(value); + }, }, - }, - }), - }), + }), + }, + ), seen: (): readonly (UnitRecord | undefined)[] => seen, greeting: (): string => greeting, }; @@ -203,12 +206,15 @@ const gateOf = () => { const configuredOf = () => { let bound: ServiceOf | undefined; return { - tap: Provider(BoundConfig)([TemporalConfig], { - sync: (config) => { - bound = config; - return config; + tap: Provider(BoundConfig)( + { config: TemporalConfig }, + { + sync: ({ config }) => { + bound = config; + return config; + }, }, - }), + ), bound: (): ServiceOf | undefined => bound, }; }; @@ -262,14 +268,17 @@ const slicedContract = defineContract({ */ const slicesOf = () => { let greeting = ""; - const echo = TemporalWorkflowActivities(slicedContract, "runEcho")([Greeting], { - sync: (service) => ({ - echo: (value) => { - greeting = service.text; - return OkAsync(value); - }, - }), - }); + const echo = TemporalWorkflowActivities(slicedContract, "runEcho")( + { greeting: Greeting }, + { + sync: ({ greeting: service }) => ({ + echo: (value) => { + greeting = service.text; + return OkAsync(value); + }, + }), + }, + ); const shout = TemporalWorkflowActivities( slicedContract, "runShout", From 515d1366cf613828bf70f34f271d15afaed5ab10 Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Thu, 20 Aug 2026 23:18:54 +0200 Subject: [PATCH 10/19] refactor(amqp): take a deps record in the handlers builders AmqpHandlers' deps arm and AmqpHandler both inherit di's record form. The composing arm now declares each piece under the contract key its port id carries, so the services record IS the handlers record and construct hands it straight back. --- packages/amqp/CLAUDE.md | 13 ++++---- packages/amqp/README.md | 41 +++++++++++++------------ packages/amqp/src/amqp-runtime.ts | 48 +++++++++++++++--------------- packages/amqp/src/test-fixtures.ts | 38 +++++++++++++---------- 4 files changed, 76 insertions(+), 64 deletions(-) diff --git a/packages/amqp/CLAUDE.md b/packages/amqp/CLAUDE.md index f016a4c..deb2ce1 100644 --- a/packages/amqp/CLAUDE.md +++ b/packages/amqp/CLAUDE.md @@ -64,7 +64,7 @@ key)`, both of which cast it to the typed alias), so there is nothing a (`amqp-runtime.ts`) — the way to the handlers provider `AmqpModule` takes, next to it: the one call fixes `C` and returns di's own `Provider(port)` on `AmqpHandlersPort as HandlersPortOf`, so the next call is exactly - `Provider(port)(deps, arm)` — any arm, same typing, checked against the + `Provider(port)({ name: Dep }, arm)` — any arm, same typing, checked against the contract's record before any module sees it — and the provider carries the port typed (`provider.port`, di's `& { readonly port: P }`, for a hand-declared provider or a type test). No name, no class line. @@ -96,11 +96,12 @@ K]`, which always names the marker — the missing key `K` itself appears only when the array's length matches that marker tuple's own length of 2; a single-element array's diagnostic names the marker alone — measured, not stylistic. The - composed provider's own `deps` are the array of **piece ports** + composed provider's own `deps` are the **piece ports** (`InstanceType` in its return type), not what a piece closes over: di constructs each piece first, as its own provider, and the - composing call's `construct` just reassembles their results into a record - keyed by what each piece's port id carries past `HANDLER_PREFIX`. That + composing call declares them under the very key each piece's port id carries + past `HANDLER_PREFIX` — so the services record IS the handlers record and + `construct` hands it straight back. That means the pieces themselves still need discharging — typically listed in `provides` alongside `handlers`, or exported by a slice module imported in — the same as any other unmet need; `AmqpModule` does not do this for you, @@ -166,8 +167,8 @@ AmqpConfig, ConfigInvalid, Env | HandlersInstanceOf>` either way, - **The handlers port's service is `WorkerInferHandlers`** — the record `TypedAmqpWorker.create` takes, with **no injected context**. - Inside, `Provider(AmqpRuntime)([AmqpConfig, AmqpHandlersPort as -HandlersPortOf], { sync })` — the port rides di, typed for the + Inside, `Provider(AmqpRuntime)({ config: AmqpConfig, handlers: +AmqpHandlersPort as HandlersPortOf }, { sync })` — the port rides di, typed for the contract, so `sync` reads the record through it and hands it to `create` with no cast on the record itself: the former `handlers as WorkerInferHandlers` is gone, and `AmqpHandlersPort as diff --git a/packages/amqp/README.md b/packages/amqp/README.md index a16b00b..b9f36d9 100644 --- a/packages/amqp/README.md +++ b/packages/amqp/README.md @@ -29,26 +29,29 @@ import { ErrAsync, P } from "unthrown"; // built by di from the use cases it lists — no injected context — on the // starter's own handlers port, typed by the contract (a consumer serves one // handlers record, so there is nothing to name). -const orderHandlers = AmqpHandlers(orderContract)([PlaceOrder], { - sync: (placeOrder) => ({ - placeOrder: (message) => - placeOrder - .execute(message.payload.orderId, message.payload.quantity) - .map(() => undefined) - // A modeled domain error is permanent: dead-letter it, no retry. - .mapErrCases((matcher) => - matcher.with( - P.tag("InvalidQuantity"), - P.tag("DuplicateOrder"), - (error) => new NonRetryableError(error._tag, error), +const orderHandlers = AmqpHandlers(orderContract)( + { placeOrder: PlaceOrder }, + { + sync: ({ placeOrder }) => ({ + placeOrder: (message) => + placeOrder + .execute(message.payload.orderId, message.payload.quantity) + .map(() => undefined) + // A modeled domain error is permanent: dead-letter it, no retry. + .mapErrCases((matcher) => + matcher.with( + P.tag("InvalidQuantity"), + P.tag("DuplicateOrder"), + (error) => new NonRetryableError(error._tag, error), + ), + ) + // Infrastructure failing is not: recover the defect into a retry. + .recoverDefect((cause) => + ErrAsync(new RetryableError("placing the order failed", cause)), ), - ) - // Infrastructure failing is not: recover the defect into a retry. - .recoverDefect((cause) => - ErrAsync(new RetryableError("placing the order failed", cause)), - ), - }), -}); + }), + }, +); const Worker = AmqpModule("Worker")({ contract: orderContract, diff --git a/packages/amqp/src/amqp-runtime.ts b/packages/amqp/src/amqp-runtime.ts index be6d621..3669cf9 100644 --- a/packages/amqp/src/amqp-runtime.ts +++ b/packages/amqp/src/amqp-runtime.ts @@ -114,13 +114,16 @@ export const amqp = ( return Module("Amqp")({ provides: [ config, - Provider(AmqpRuntime)([AmqpConfig, AmqpHandlersPort as HandlersPortOf], { - sync: (c, handlers): Runtime => ({ - name: "amqp", - needs: [], - start: (host) => createWorker(host, c, options, handlers), - }), - }), + Provider(AmqpRuntime)( + { config: AmqpConfig, handlers: AmqpHandlersPort as HandlersPortOf }, + { + sync: ({ config: bound, handlers }): Runtime => ({ + name: "amqp", + needs: [], + start: (host) => createWorker(host, bound, options, handlers), + }), + }, + ), ], exports: [AmqpRuntime, AmqpConfig], }); @@ -162,7 +165,7 @@ type Compose = [] * The handlers as a provider, from the contract. Three call forms, one port. * * ```ts - * AmqpHandlers(orderContract)([Logger], { sync: (logger) => ({ orderNotifications: (m) => … }) }) + * AmqpHandlers(orderContract)({ logger: Logger }, { sync: ({ logger }) => ({ orderNotifications: (m) => … }) }) * AmqpHandlers(orderContract)([orderNotifications, orderAudit]) * ``` * @@ -170,8 +173,9 @@ type Compose = [] * typed for the contract — any arm, same typing, checked against the record * before any module sees it. The third takes the **pieces** * `AmqpHandler(contract, key)` builds, one per consumer or rpc: di constructs - * every piece first (they are the provider's deps, in array order) and this - * reassembles the record from them. Every key the contract declares must be + * every piece first — they are the provider's deps, keyed by the very contract + * key each piece's port id carries, so the services record IS the handlers + * record. Every key the contract declares must be * covered, and two slices claiming one key are two providers for one port — * di's duplicate-provider defect at build, which is the point. * @@ -183,22 +187,18 @@ export const AmqpHandlers = ( ): ReturnType>> & Compose => { void contract; const build = Provider(AmqpHandlersPort as HandlersPortOf); - const compose = (pieces: readonly { readonly port: { readonly portId: string } }[]): unknown => { - const keys = pieces.map((piece) => piece.port.portId.slice(HANDLER_PREFIX.length)); - return build( - pieces.map((piece) => piece.port) as never, - { - sync: (...services: readonly unknown[]) => - Object.fromEntries(keys.map((key, index) => [key, services[index]])), - } as never, + const compose = (pieces: readonly { readonly port: { readonly portId: string } }[]): unknown => + build( + Object.fromEntries( + pieces.map((piece) => [piece.port.portId.slice(HANDLER_PREFIX.length), piece.port]), + ) as never, + { sync: (services: unknown) => services } as never, ); - }; // One array argument is never a valid `Provider(port)` call — its arms are - // `(deps, options)` and `(options)` — so the arity plus `Array.isArray` is a - // sound discriminator. Not the same dispatch di's own `Provider(port)` - // build uses, though: that one narrows on `Array.isArray` alone (`provider.ts`), - // which is enough for ITS two arms since a lone array is never valid there; - // the arity check here is what this THIRD, composing arm needs on top of it. + // `(deps, options)` and `(options)`, and both objects are records — so + // `Array.isArray` alone identifies this THIRD, composing arm. The arity check + // rides along because di's own build discriminates on arity (`provider.ts`) + // and a two-argument call is never this arm. return ((first: unknown, second?: unknown) => second === undefined && Array.isArray(first) ? compose(first as readonly { readonly port: { readonly portId: string } }[]) diff --git a/packages/amqp/src/test-fixtures.ts b/packages/amqp/src/test-fixtures.ts index b8a5d10..6853d03 100644 --- a/packages/amqp/src/test-fixtures.ts +++ b/packages/amqp/src/test-fixtures.ts @@ -92,15 +92,18 @@ const seamOf = () => { let greeting = ""; return { - handlers: echoHandlers([Greeting], { - sync: (g) => ({ - echo: () => { - seen.push(currentUnit()); - greeting = g.text; - return OkAsync(undefined); - }, - }), - }), + handlers: echoHandlers( + { greeting: Greeting }, + { + sync: ({ greeting: g }) => ({ + echo: () => { + seen.push(currentUnit()); + greeting = g.text; + return OkAsync(undefined); + }, + }), + }, + ), seen: (): readonly (UnitRecord | undefined)[] => seen, greeting: (): string => greeting, }; @@ -222,13 +225,18 @@ const slicesOf = () => { const ran: string[] = []; let greeting = ""; - const left = AmqpHandler(slicedContract, "left")([Greeting], { - sync: (g) => () => { - greeting = g.text; - ran.push("left"); - return OkAsync(undefined); + const left = AmqpHandler(slicedContract, "left")( + { greeting: Greeting }, + { + sync: + ({ greeting: g }) => + () => { + greeting = g.text; + ran.push("left"); + return OkAsync(undefined); + }, }, - }); + ); const right = AmqpHandler( slicedContract, "right", From 4cef35e27537d55d3942ba9427cdc25ecb3da55c Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Thu, 20 Aug 2026 23:20:01 +0200 Subject: [PATCH 11/19] refactor(example): name the dependencies the application and persistence declare The three interactors take their collaborators as one destructured record, and each adapter provider names the client it binds `db`. --- examples/order-application/src/use-cases.ts | 39 +++++++++++++------ examples/order-infrastructure/src/database.ts | 11 ++++-- .../src/prisma-customer-repository.ts | 7 ++-- .../src/prisma-order-repository.ts | 7 ++-- .../order-infrastructure/src/prisma-outbox.ts | 7 ++-- 5 files changed, 46 insertions(+), 25 deletions(-) diff --git a/examples/order-application/src/use-cases.ts b/examples/order-application/src/use-cases.ts index b3f9550..3237443 100644 --- a/examples/order-application/src/use-cases.ts +++ b/examples/order-application/src/use-cases.ts @@ -23,7 +23,13 @@ class PlaceOrderInteractor { readonly #repository: ServiceOf; readonly #logger: ServiceOf; - constructor(repository: ServiceOf, logger: ServiceOf) { + constructor({ + repository, + logger, + }: { + readonly repository: ServiceOf; + readonly logger: ServiceOf; + }) { this.#repository = repository; this.#logger = logger; } @@ -43,7 +49,7 @@ class PlaceOrderInteractor { class FindOrderInteractor { readonly #repository: ServiceOf; - constructor(repository: ServiceOf) { + constructor({ repository }: { readonly repository: ServiceOf }) { this.#repository = repository; } @@ -55,7 +61,7 @@ class FindOrderInteractor { class FindCustomerInteractor { readonly #repository: ServiceOf; - constructor(repository: ServiceOf) { + constructor({ repository }: { readonly repository: ServiceOf }) { this.#repository = repository; } @@ -64,14 +70,23 @@ class FindCustomerInteractor { } } -export const placeOrderProvider = Provider(PlaceOrder)([OrderRepository, Logger], { - class: PlaceOrderInteractor, -}); +export const placeOrderProvider = Provider(PlaceOrder)( + { repository: OrderRepository, logger: Logger }, + { + class: PlaceOrderInteractor, + }, +); -export const findOrderProvider = Provider(FindOrder)([OrderRepository], { - class: FindOrderInteractor, -}); +export const findOrderProvider = Provider(FindOrder)( + { repository: OrderRepository }, + { + class: FindOrderInteractor, + }, +); -export const findCustomerProvider = Provider(FindCustomer)([CustomerRepository], { - class: FindCustomerInteractor, -}); +export const findCustomerProvider = Provider(FindCustomer)( + { repository: CustomerRepository }, + { + class: FindCustomerInteractor, + }, +); diff --git a/examples/order-infrastructure/src/database.ts b/examples/order-infrastructure/src/database.ts index 6207b51..05ebea9 100644 --- a/examples/order-infrastructure/src/database.ts +++ b/examples/order-infrastructure/src/database.ts @@ -55,7 +55,10 @@ export const openDatabase = (url: string): AsyncResult openDatabase(config.url), - release: (db) => db.$disconnect(), -}); +export const orderDatabaseProvider = Provider(OrderDatabase)( + { config: databaseConfig.port }, + { + acquire: ({ config }) => openDatabase(config.url), + release: (db) => db.$disconnect(), + }, +); diff --git a/examples/order-infrastructure/src/prisma-customer-repository.ts b/examples/order-infrastructure/src/prisma-customer-repository.ts index fa45a09..bdeebe7 100644 --- a/examples/order-infrastructure/src/prisma-customer-repository.ts +++ b/examples/order-infrastructure/src/prisma-customer-repository.ts @@ -38,6 +38,7 @@ export const prismaCustomerRepository = ( .flatMap((row) => (row === null ? Err(new CustomerNotFound({ id })) : hydrate(row))), }); -export const customerRepositoryProvider = Provider(CustomerRepository)([OrderDatabase], { - sync: prismaCustomerRepository, -}); +export const customerRepositoryProvider = Provider(CustomerRepository)( + { db: OrderDatabase }, + { sync: ({ db }) => prismaCustomerRepository(db) }, +); diff --git a/examples/order-infrastructure/src/prisma-order-repository.ts b/examples/order-infrastructure/src/prisma-order-repository.ts index 7247e65..7ccbc43 100644 --- a/examples/order-infrastructure/src/prisma-order-repository.ts +++ b/examples/order-infrastructure/src/prisma-order-repository.ts @@ -108,6 +108,7 @@ export const prismaOrderRepository = (db: OrderDatabaseClient): ServiceOf prismaOrderRepository(db) }, +); diff --git a/examples/order-infrastructure/src/prisma-outbox.ts b/examples/order-infrastructure/src/prisma-outbox.ts index a177ec5..7ba1834 100644 --- a/examples/order-infrastructure/src/prisma-outbox.ts +++ b/examples/order-infrastructure/src/prisma-outbox.ts @@ -72,6 +72,7 @@ export const prismaOutbox = (db: OrderDatabaseClient): ServiceOf => ({ ), }); -export const outboxProvider = Provider(Outbox)([OrderDatabase], { - sync: prismaOutbox, -}); +export const outboxProvider = Provider(Outbox)( + { db: OrderDatabase }, + { sync: ({ db }) => prismaOutbox(db) }, +); From 08366ba0c4f7d24a4d4743b6bfb0e67c4362915c Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Thu, 20 Aug 2026 23:22:15 +0200 Subject: [PATCH 12/19] refactor(example): name the dependencies the order API's slices declare Both controllers, the request scope and the docs-examples gate take a deps record. The gate's "positional" router is renamed to depsOrdersRouter, since positional is what it no longer is. --- examples/order-api/README.md | 6 +- examples/order-api/src/authenticator.ts | 21 +++--- .../order-api/src/docs-examples.test-d.ts | 74 ++++++++++--------- examples/order-api/src/needs-gate.test-d.ts | 11 ++- examples/order-api/src/request-scope.ts | 19 +++-- .../src/slices/customers/controller.ts | 4 +- .../order-api/src/slices/orders/controller.ts | 4 +- 7 files changed, 76 insertions(+), 63 deletions(-) diff --git a/examples/order-api/README.md b/examples/order-api/README.md index 6eed193..812c0e8 100644 --- a/examples/order-api/README.md +++ b/examples/order-api/README.md @@ -11,9 +11,9 @@ its own package, because a client needs it and needs none of this. ``` src/auth.ts Identity, and the HttpController / HttpRouter / HttpAuthenticator httpAuth() mints from it src/authenticator.ts bearerAuthenticator — headers in, Identity out, on the starter's port -src/slices/orders/controller.ts HttpController("OrdersController", contract.orders)([PlaceOrder, FindOrder, Logger], { sync }) — where the orders slice's own domain error becomes an ORPCError +src/slices/orders/controller.ts HttpController("OrdersController", contract.orders)({ place: PlaceOrder, find: FindOrder, logger: Logger }, { sync }) — where the orders slice's own domain error becomes an ORPCError src/slices/orders/module.ts OrdersSlice — provides the controller, exports only it -src/slices/customers/controller.ts HttpController("CustomersController", contract.customers)([FindCustomer], { sync }) — same shape, for the customers slice's own domain error +src/slices/customers/controller.ts HttpController("CustomersController", contract.customers)({ find: FindCustomer }, { sync }) — same shape, for the customers slice's own domain error src/slices/customers/module.ts CustomersSlice — same shape as OrdersSlice src/request-scope.ts RequestModule — passed as StartOptions.unit; the kernel forks it per request src/client.ts an AsyncResult client for the same contract @@ -83,7 +83,7 @@ Binding the socket, one unit per request, the drain that retires a busy keep-alive connection, the trace-id policy, oRPC's node adapter mounted under `/rpc` all live in [`@btravstack/http`](../../packages/http) — see its README for the guarantee it makes and the one way it answers HTTP. -What this example writes is two slices, each an `HttpController(name, fragment)([deps], { sync })` +What this example writes is two slices, each an `HttpController(name, fragment)({ name: Dep }, { sync })` over its own contract fragment, and a root router composed by the **keyed** `HttpRouter(contract)({ orders: ordersController, customers: customersController })` — contract-first, exact (a missing slice, a stray diff --git a/examples/order-api/src/authenticator.ts b/examples/order-api/src/authenticator.ts index 7655115..2856860 100644 --- a/examples/order-api/src/authenticator.ts +++ b/examples/order-api/src/authenticator.ts @@ -15,13 +15,16 @@ import { HttpAuthenticator } from "./auth.js"; * controllers are minted from — so a token resolving to the wrong shape is a * compile error here, and the handlers cannot be reading a different one. */ -export const bearerAuthenticator = HttpAuthenticator([], { - sync: () => (headers) => { - const header = headers.authorization ?? ""; - const token = header.startsWith("Bearer ") ? header.slice("Bearer ".length) : ""; - const [tenantId, userId] = token.split(":"); - return tenantId === undefined || tenantId === "" || userId === undefined || userId === "" - ? ErrAsync(new Unauthenticated()) - : OkAsync({ tenantId, userId }); +export const bearerAuthenticator = HttpAuthenticator( + {}, + { + sync: () => (headers) => { + const header = headers.authorization ?? ""; + const token = header.startsWith("Bearer ") ? header.slice("Bearer ".length) : ""; + const [tenantId, userId] = token.split(":"); + return tenantId === undefined || tenantId === "" || userId === undefined || userId === "" + ? ErrAsync(new Unauthenticated()) + : OkAsync({ tenantId, userId }); + }, }, -}); +); diff --git a/examples/order-api/src/docs-examples.test-d.ts b/examples/order-api/src/docs-examples.test-d.ts index ae11276..eb57ede 100644 --- a/examples/order-api/src/docs-examples.test-d.ts +++ b/examples/order-api/src/docs-examples.test-d.ts @@ -61,9 +61,9 @@ const customerViewOf = (customer: Customer): CustomerView => ({ // --------------------------------------------------------------------------- const ordersController = HttpController("DocsOrdersController", contract.orders)( - [PlaceOrder, FindOrder, Logger], + { place: PlaceOrder, find: FindOrder, logger: Logger }, { - sync: (place, find, logger) => ({ + sync: ({ place, find, logger }) => ({ place: ({ errors, context }, input) => { logger.info("order placement requested", { userId: context.principal.userId }); return place @@ -95,9 +95,9 @@ const ordersController = HttpController("DocsOrdersController", contract.orders) // The unmarked half, and the contrast every page draws: no `principal` on the // context at all, the tenant off the input instead. const customersController = HttpController("DocsCustomersController", contract.customers)( - [FindCustomer], + { find: FindCustomer }, { - sync: (find) => ({ + sync: ({ find }) => ({ find: ({ errors }, input) => find .execute(input.tenantId, input.id) @@ -151,9 +151,10 @@ const _DocsOrderApi = HttpModule("DocsOrderApi")({ // the same authenticator. // --------------------------------------------------------------------------- -const liftedOrdersRouter = HttpRouter(contract.orders)([ordersController.port], { - sync: (implementation) => implementation, -}); +const liftedOrdersRouter = HttpRouter(contract.orders)( + { implementation: ordersController.port }, + { sync: ({ implementation }) => implementation }, +); const _DocsOrdersApi = HttpModule("DocsOrdersApi")({ router: liftedOrdersRouter, @@ -163,41 +164,44 @@ const _DocsOrdersApi = HttpModule("DocsOrdersApi")({ // --------------------------------------------------------------------------- // "Step 2 — the router, as a provider" — docs/how-to/serve-orpc-over-http.md; -// "`HttpRouter(contract)(deps, arm)`" — docs/reference/http.md; "At a glance" — -// docs/index.md. The positional form over the same marked fragment, with no +// "`HttpRouter(contract)({ name: Dep }, arm)`" — docs/reference/http.md; "At a +// glance" — docs/index.md. The deps form over the same marked fragment, with no // controller layer: the three pages that show a router rather than a // controller all reduce to this call. // --------------------------------------------------------------------------- -const positionalOrdersRouter = HttpRouter(contract.orders)([PlaceOrder, FindOrder], { - sync: (place, find) => ({ - place: ({ errors, context }, input) => - place - .execute(context.principal.tenantId, input.id, input.quantity) - .map(view) - .mapErrCases((matcher) => - matcher - .with(P.tag("InvalidQuantity"), (error) => - errors.INVALID_QUANTITY({ message: error.message, data: { id: error.id } }), - ) - .with(P.tag("DuplicateOrder"), (error) => - errors.CONFLICT({ message: error.message, data: { id: error.id } }), +const depsOrdersRouter = HttpRouter(contract.orders)( + { place: PlaceOrder, find: FindOrder }, + { + sync: ({ place, find }) => ({ + place: ({ errors, context }, input) => + place + .execute(context.principal.tenantId, input.id, input.quantity) + .map(view) + .mapErrCases((matcher) => + matcher + .with(P.tag("InvalidQuantity"), (error) => + errors.INVALID_QUANTITY({ message: error.message, data: { id: error.id } }), + ) + .with(P.tag("DuplicateOrder"), (error) => + errors.CONFLICT({ message: error.message, data: { id: error.id } }), + ), + ), + find: ({ errors, context }, input) => + find + .execute(context.principal.tenantId, input.id) + .map(view) + .mapErrCases((matcher) => + matcher.with(P.tag("OrderNotFound"), (error) => + errors.NOT_FOUND({ message: error.message, data: { id: error.id } }), ), - ), - find: ({ errors, context }, input) => - find - .execute(context.principal.tenantId, input.id) - .map(view) - .mapErrCases((matcher) => - matcher.with(P.tag("OrderNotFound"), (error) => - errors.NOT_FOUND({ message: error.message, data: { id: error.id } }), ), - ), - }), -}); + }), + }, +); -const _DocsPositionalApi = HttpModule("DocsPositionalApi")({ - router: positionalOrdersRouter, +const _DocsDepsApi = HttpModule("DocsDepsApi")({ + router: depsOrdersRouter, authenticator: bearerAuthenticator, imports: [OrderApplicationModule, OrderPersistenceModule, observability()], exports: [Logger], diff --git a/examples/order-api/src/needs-gate.test-d.ts b/examples/order-api/src/needs-gate.test-d.ts index 43361e3..cf8fc36 100644 --- a/examples/order-api/src/needs-gate.test-d.ts +++ b/examples/order-api/src/needs-gate.test-d.ts @@ -44,7 +44,7 @@ const _missingRuntime = start(RuntimelessApi, options); // The starter imported without its router provided: `http()`'s runtime // provider depends on the starter's own router port (the one -// `HttpRouter(contract)(deps, arm)` provides), so the composition carries it +// `HttpRouter(contract)({ name: Dep }, arm)` provides), so the composition carries it // as an unmet need — di's gate, not the kernel's, and it rejects the module // at `start` rather than at arity. const RouterlessApi = Module("RouterlessApi")({ @@ -94,9 +94,12 @@ const _missingAuthenticator = start(UnauthenticatedApi, options); // authenticator's itself, at the `HttpModule(...)` call, which is why this // directive sits on the option and not on a `start` below it. The contract // declares no principal to compare against; `./auth.ts` is what declares one. -const wrongAuthenticator = HttpAuthenticator<{ readonly sub: string }>()([], { - sync: () => () => OkAsync({ sub: "s-1" }), -}); +const wrongAuthenticator = HttpAuthenticator<{ readonly sub: string }>()( + {}, + { + sync: () => () => OkAsync({ sub: "s-1" }), + }, +); const _mismatchedApi = HttpModule("MismatchedApi")({ router: orderRouter, diff --git a/examples/order-api/src/request-scope.ts b/examples/order-api/src/request-scope.ts index 2203e60..1cfb122 100644 --- a/examples/order-api/src/request-scope.ts +++ b/examples/order-api/src/request-scope.ts @@ -21,15 +21,18 @@ export class RequestSpan extends Port("RequestSpan")<{ readonly finish: () => vo */ export const RequestModule = Module("Request")({ provides: [ - Provider(RequestSpan)([Logger], { - sync: (logger) => { - const startedAt = Date.now(); - return { - finish: () => logger.info("request finished", { durationMs: Date.now() - startedAt }), - }; + Provider(RequestSpan)( + { logger: Logger }, + { + sync: ({ logger }) => { + const startedAt = Date.now(); + return { + finish: () => logger.info("request finished", { durationMs: Date.now() - startedAt }), + }; + }, + onStop: (span) => span.finish(), }, - onStop: (span) => span.finish(), - }), + ), ], exports: [RequestSpan], }); diff --git a/examples/order-api/src/slices/customers/controller.ts b/examples/order-api/src/slices/customers/controller.ts index 9251282..8a22ea4 100644 --- a/examples/order-api/src/slices/customers/controller.ts +++ b/examples/order-api/src/slices/customers/controller.ts @@ -21,9 +21,9 @@ const view = (customer: Customer): CustomerView => ({ id: customer.id, name: cus * triage, not by owning a private adapter. */ export const customersController = HttpController("CustomersController", contract.customers)( - [FindCustomer], + { find: FindCustomer }, { - sync: (find) => ({ + sync: ({ find }) => ({ find: ({ errors }, input) => find .execute(input.tenantId, input.id) diff --git a/examples/order-api/src/slices/orders/controller.ts b/examples/order-api/src/slices/orders/controller.ts index e004fe1..39def97 100644 --- a/examples/order-api/src/slices/orders/controller.ts +++ b/examples/order-api/src/slices/orders/controller.ts @@ -48,9 +48,9 @@ const view = (order: Order): OrderView => ({ id: order.id, quantity: order.quant * provider like any other in the graph. */ export const ordersController = HttpController("OrdersController", contract.orders)( - [PlaceOrder, FindOrder, Logger], + { place: PlaceOrder, find: FindOrder, logger: Logger }, { - sync: (place, find, logger) => ({ + sync: ({ place, find, logger }) => ({ place: ({ errors, context }, input) => { logger.info("order placement requested", { userId: context.principal.userId }); return place From 9f84407fc33bcf815a1178f5a42fc535fedbfffb Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Thu, 20 Aug 2026 23:38:31 +0200 Subject: [PATCH 13/19] refactor(example): name the dependencies the AMQP worker declares Both slice handlers name their logger, and the outbox relay names its four: outbox, logger, broker and config. --- examples/order-amqp-worker/README.md | 4 +- .../order-amqp-worker/src/outbox-relay.ts | 13 ++++--- .../src/slices/audit/handler.ts | 29 ++++++++------- .../src/slices/notifications/handler.ts | 37 ++++++++++--------- 4 files changed, 46 insertions(+), 37 deletions(-) diff --git a/examples/order-amqp-worker/README.md b/examples/order-amqp-worker/README.md index 15bda80..d7a5ca2 100644 --- a/examples/order-amqp-worker/README.md +++ b/examples/order-amqp-worker/README.md @@ -85,8 +85,8 @@ _foreign_ queue to the same exchange and receiving the same event too. `@btravstack/amqp`'s starter needs one thing from the application: its **handlers, as a service**. This deployment builds that record from two pieces rather than one function. `src/slices/notifications/handler.ts` is one -call — `AmqpHandler(orderContract, "orderNotifications")([Logger], { sync: -… })` — di's own `Provider(port)` on a port minted from the contract key, +call — `AmqpHandler(orderContract, "orderNotifications")({ logger: Logger }, +{ sync: … })` — di's own `Provider(port)` on a port minted from the contract key, typed for that one consumer's message; `src/slices/audit/handler.ts` is the same shape for `"orderAudit"`. Neither declares a port class or a name: the contract key IS the port's name, and each piece closes over only what its own diff --git a/examples/order-amqp-worker/src/outbox-relay.ts b/examples/order-amqp-worker/src/outbox-relay.ts index 51d1dd1..adab5ba 100644 --- a/examples/order-amqp-worker/src/outbox-relay.ts +++ b/examples/order-amqp-worker/src/outbox-relay.ts @@ -210,8 +210,11 @@ const startOutboxRelay = ( * `Promise` a finaliser speaks, rejecting only on a defect — which the * kernel then reports as a `teardownError`. */ -export const outboxRelay = Provider(OutboxRelay)([Outbox, Logger, AmqpConfig, relayConfig.port], { - acquire: (outbox, logger, { url }, { pollMs, tenants }) => - startOutboxRelay(outbox, logger, { url, pollMs, tenants: tenantsOf(tenants) }), - release: (running) => running.stop().get(), -}); +export const outboxRelay = Provider(OutboxRelay)( + { outbox: Outbox, logger: Logger, broker: AmqpConfig, config: relayConfig.port }, + { + acquire: ({ outbox, logger, broker: { url }, config: { pollMs, tenants } }) => + startOutboxRelay(outbox, logger, { url, pollMs, tenants: tenantsOf(tenants) }), + release: (running) => running.stop().get(), + }, +); diff --git a/examples/order-amqp-worker/src/slices/audit/handler.ts b/examples/order-amqp-worker/src/slices/audit/handler.ts index 37bfe58..e079203 100644 --- a/examples/order-amqp-worker/src/slices/audit/handler.ts +++ b/examples/order-amqp-worker/src/slices/audit/handler.ts @@ -15,16 +15,19 @@ import { OkAsync } from "unthrown"; * not. What a slice answers when the kernel stops waiting is the slice's own * business. */ -export const orderAudit = AmqpHandler(orderContract, "orderAudit")([Logger], { - sync: - (logger) => - ({ payload: { tenantId, id, occurredAt, payload } }) => { - logger.info("recording an order change", { - tenantId, - orderId: id, - occurredAt, - change: payload === null ? "removed" : "placed", - }); - return OkAsync(); - }, -}); +export const orderAudit = AmqpHandler(orderContract, "orderAudit")( + { logger: Logger }, + { + sync: + ({ logger }) => + ({ payload: { tenantId, id, occurredAt, payload } }) => { + logger.info("recording an order change", { + tenantId, + orderId: id, + occurredAt, + change: payload === null ? "removed" : "placed", + }); + return OkAsync(); + }, + }, +); diff --git a/examples/order-amqp-worker/src/slices/notifications/handler.ts b/examples/order-amqp-worker/src/slices/notifications/handler.ts index fc7fde7..e473fd7 100644 --- a/examples/order-amqp-worker/src/slices/notifications/handler.ts +++ b/examples/order-amqp-worker/src/slices/notifications/handler.ts @@ -24,20 +24,23 @@ import { ErrAsync, OkAsync } from "unthrown"; * `RetryableError` leaves the message un-acked, so the broker hands it to the * next worker. */ -export const orderNotifications = AmqpHandler(orderContract, "orderNotifications")([Logger], { - sync: - (logger) => - ({ payload: { tenantId, id, payload } }) => { - if (currentUnit()?.signal.aborted === true) { - return ErrAsync( - new RetryableError(`the drain deadline passed before order ${id} was notified`), - ); - } - logger.info(payload === null ? "order gone — notifying" : "order placed — notifying", { - tenantId, - orderId: id, - ...(payload === null ? {} : { quantity: payload.quantity }), - }); - return OkAsync(); - }, -}); +export const orderNotifications = AmqpHandler(orderContract, "orderNotifications")( + { logger: Logger }, + { + sync: + ({ logger }) => + ({ payload: { tenantId, id, payload } }) => { + if (currentUnit()?.signal.aborted === true) { + return ErrAsync( + new RetryableError(`the drain deadline passed before order ${id} was notified`), + ); + } + logger.info(payload === null ? "order gone — notifying" : "order placed — notifying", { + tenantId, + orderId: id, + ...(payload === null ? {} : { quantity: payload.quantity }), + }); + return OkAsync(); + }, + }, +); From f7278cc1c1e048823f6c437e39a690b743255b61 Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Thu, 20 Aug 2026 23:38:58 +0200 Subject: [PATCH 14/19] refactor(example): name the dependencies the Temporal worker declares Both saga pieces and the three stand-in services name what they call. --- examples/order-temporal-worker/README.md | 31 +++++----- examples/order-temporal-worker/src/billing.ts | 35 ++++++------ .../order-temporal-worker/src/fulfillment.ts | 56 ++++++++++--------- .../src/slices/billing/activities.ts | 4 +- .../src/slices/fulfillment/activities.ts | 9 ++- 5 files changed, 76 insertions(+), 59 deletions(-) diff --git a/examples/order-temporal-worker/README.md b/examples/order-temporal-worker/README.md index 063a228..c59b81f 100644 --- a/examples/order-temporal-worker/README.md +++ b/examples/order-temporal-worker/README.md @@ -52,21 +52,24 @@ does not declare is a compile error in that slice's own file, not a defect export const chargeOrder = TemporalWorkflowActivities( orderContract, "chargeOrder", -)([PaymentService], { - sync: (payments) => ({ - authorizePayment: (args, { errors }) => - payments - .authorize(args.orderId, args.amount) - .map((authorizationId) => ({ authorizationId })) - .mapErrCases((matcher) => - matcher.with(P.tag("PaymentDeclined"), (error) => - errors.PaymentDeclined({ id: error.id }), +)( + { payments: PaymentService }, + { + sync: ({ payments }) => ({ + authorizePayment: (args, { errors }) => + payments + .authorize(args.orderId, args.amount) + .map((authorizationId) => ({ authorizationId })) + .mapErrCases((matcher) => + matcher.with(P.tag("PaymentDeclined"), (error) => + errors.PaymentDeclined({ id: error.id }), + ), ), - ), - capturePayment: (args) => payments.capture(args.authorizationId), - refundPayment: (args) => payments.refund(args.authorizationId), - }), -}); + capturePayment: (args) => payments.capture(args.authorizationId), + refundPayment: (args) => payments.refund(args.authorizationId), + }), + }, +); ``` The root composes both pieces into the one activities record the starter diff --git a/examples/order-temporal-worker/src/billing.ts b/examples/order-temporal-worker/src/billing.ts index 236c2ea..b21824b 100644 --- a/examples/order-temporal-worker/src/billing.ts +++ b/examples/order-temporal-worker/src/billing.ts @@ -15,22 +15,25 @@ import { OkAsync } from "unthrown"; */ export const BillingModule = Module("Billing")({ provides: [ - Provider(PaymentService)([Logger], { - sync: (logger) => ({ - authorize: (orderId, amount) => { - logger.info("authorized the payment", { orderId, amount }); - return OkAsync(`auth-${orderId}`); - }, - capture: (authorizationId) => { - logger.info("captured the payment", { authorizationId }); - return OkAsync(); - }, - refund: (authorizationId) => { - logger.info("refunded the payment", { authorizationId }); - return OkAsync(); - }, - }), - }), + Provider(PaymentService)( + { logger: Logger }, + { + sync: ({ logger }) => ({ + authorize: (orderId, amount) => { + logger.info("authorized the payment", { orderId, amount }); + return OkAsync(`auth-${orderId}`); + }, + capture: (authorizationId) => { + logger.info("captured the payment", { authorizationId }); + return OkAsync(); + }, + refund: (authorizationId) => { + logger.info("refunded the payment", { authorizationId }); + return OkAsync(); + }, + }), + }, + ), ], exports: [PaymentService], }); diff --git a/examples/order-temporal-worker/src/fulfillment.ts b/examples/order-temporal-worker/src/fulfillment.ts index c713c53..85334bb 100644 --- a/examples/order-temporal-worker/src/fulfillment.ts +++ b/examples/order-temporal-worker/src/fulfillment.ts @@ -30,32 +30,38 @@ import { OkAsync, fromSafePromise } from "unthrown"; */ export const FulfillmentModule = Module("Fulfillment")({ provides: [ - Provider(StockService)([Logger], { - sync: (logger) => ({ - reserve: (orderId, quantity) => { - logger.info("reserved stock", { orderId, quantity }); - return OkAsync(); - }, - release: (orderId) => { - logger.info("released the reservation", { orderId }); - return OkAsync(); - }, - }), - }), - Provider(ShippingService)([Logger], { - sync: (logger) => ({ - arrange: (orderId) => - currentUnit()?.signal.aborted === true - ? fromSafePromise( - Promise.reject( - new Error( - `the drain deadline passed before shipping for ${orderId} was arranged`, + Provider(StockService)( + { logger: Logger }, + { + sync: ({ logger }) => ({ + reserve: (orderId, quantity) => { + logger.info("reserved stock", { orderId, quantity }); + return OkAsync(); + }, + release: (orderId) => { + logger.info("released the reservation", { orderId }); + return OkAsync(); + }, + }), + }, + ), + Provider(ShippingService)( + { logger: Logger }, + { + sync: ({ logger }) => ({ + arrange: (orderId) => + currentUnit()?.signal.aborted === true + ? fromSafePromise( + Promise.reject( + new Error( + `the drain deadline passed before shipping for ${orderId} was arranged`, + ), ), - ), - ) - : (logger.info("arranged shipping", { orderId }), OkAsync()), - }), - }), + ) + : (logger.info("arranged shipping", { orderId }), OkAsync()), + }), + }, + ), ], exports: [StockService, ShippingService], }); diff --git a/examples/order-temporal-worker/src/slices/billing/activities.ts b/examples/order-temporal-worker/src/slices/billing/activities.ts index f35dd4c..2b4150f 100644 --- a/examples/order-temporal-worker/src/slices/billing/activities.ts +++ b/examples/order-temporal-worker/src/slices/billing/activities.ts @@ -15,9 +15,9 @@ import { P } from "unthrown"; * stuck half-done. */ export const chargeOrder = TemporalWorkflowActivities(orderContract, "chargeOrder")( - [PaymentService], + { payments: PaymentService }, { - sync: (payments) => ({ + sync: ({ payments }) => ({ authorizePayment: (args, { errors }) => payments .authorize(args.orderId, args.amount) diff --git a/examples/order-temporal-worker/src/slices/fulfillment/activities.ts b/examples/order-temporal-worker/src/slices/fulfillment/activities.ts index 20e9d62..870c200 100644 --- a/examples/order-temporal-worker/src/slices/fulfillment/activities.ts +++ b/examples/order-temporal-worker/src/slices/fulfillment/activities.ts @@ -59,9 +59,14 @@ import { P } from "unthrown"; * and an activity Temporal may re-run has to answer the same both times. */ export const fulfillOrder = TemporalWorkflowActivities(orderContract, "fulfillOrder")( - [PlaceOrder, OrderRepository, StockService, ShippingService], { - sync: (place, repository, stock, shipping) => ({ + place: PlaceOrder, + repository: OrderRepository, + stock: StockService, + shipping: ShippingService, + }, + { + sync: ({ place, repository, stock, shipping }) => ({ place: (args, { errors }) => place .execute(args.tenantId, args.orderId, args.quantity) From 63a41957e8764e5ad72587813a688d3a1fa249eb Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Thu, 20 Aug 2026 23:41:51 +0200 Subject: [PATCH 15/19] docs: the deps record in every README and package spec Every sample and prose reference that spelled a provider's dependencies as an array now spells the record. No behaviour change. --- CLAUDE.md | 14 ++++----- README.md | 53 +++++++++++++++++--------------- examples/README.md | 8 ++--- packages/amqp/README.md | 2 +- packages/config/CLAUDE.md | 2 +- packages/config/README.md | 2 +- packages/http/CLAUDE.md | 10 +++--- packages/http/README.md | 4 +-- packages/http/src/http-module.ts | 2 +- packages/temporal/README.md | 2 +- 10 files changed, 51 insertions(+), 48 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 06c1494..40d5a6c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -598,9 +598,9 @@ label=com.btravstack.test-infra)` clears them), and testcontainers' own reuse namespace }` back off `Serving.info`. The Worker's lifecycle, the unit per attempt and the deadline race are the package's. It is a **two-slice modulith**: `FulfillmentSlice`'s `fulfillOrder = TemporalWorkflowActivities(orderContract, -"fulfillOrder")([PlaceOrder, OrderRepository, StockService, ShippingService], -{ sync })` and `BillingSlice`'s `chargeOrder = TemporalWorkflowActivities(orderContract, -"chargeOrder")([PaymentService], { sync })` are each a **piece** — a provider +"fulfillOrder")({ place: PlaceOrder, repository: OrderRepository, stock: StockService, +shipping: ShippingService }, { sync })` and `BillingSlice`'s `chargeOrder = TemporalWorkflowActivities(orderContract, +"chargeOrder")({ payments: PaymentService }, { sync })` are each a **piece** — a provider on the port its own contract key mints, closing over only the services its own saga calls, no context read at call time — and the root composes them, `orderActivities = TemporalActivities(orderContract)([fulfillOrder, @@ -615,8 +615,8 @@ observability()] })`, the sugar importing the starter. `FulfillmentSlice` from the starter, and `LOG_LEVEL` and the `Logger` the sagas' stand-in services write to come from `observability()`. `order-amqp-worker` is the same shape — `NotificationsSlice`'s `orderNotifications = AmqpHandler(orderContract, -"orderNotifications")([Logger], { sync })` and `AuditSlice`'s `orderAudit = -AmqpHandler(orderContract, "orderAudit")([Logger], { sync })`, composed as +"orderNotifications")({ logger: Logger }, { sync })` and `AuditSlice`'s `orderAudit = +AmqpHandler(orderContract, "orderAudit")({ logger: Logger }, { sync })`, composed as `orderHandlers = AmqpHandlers(orderContract)([orderNotifications, orderAudit])` — but **neither** slice imports a vertical: a subscriber reacts to a fact somebody else already committed, so the orders vertical stays at @@ -646,13 +646,13 @@ AuditSlice, observability()], … })`), (if it needs one) its own adapter, and ships as an ordinary di `Module` that exports only that piece's port — everything else about the slice stays private. `@btravstack/http`'s - `HttpController(name, fragment)([deps], { sync })` mints the controller's + `HttpController(name, fragment)({ name: Dep }, { sync })` mints the controller's port; the root composes every slice's controller into one router with the keyed `HttpRouter(contract)(controllers)` form, exact against the contract (see `packages/http/CLAUDE.md`). **A fragment is itself a valid contract**, so a slice lifts out of the modulith into a process of its own without its controller changing at all: the lifted root is - `HttpRouter(contract.orders)([ordersController.port], { sync: (implementation) => implementation })`, + `HttpRouter(contract.orders)({ implementation: ordersController.port }, { sync: ({ implementation }) => implementation })`, declaring the very provider the modulith composed and handing back what it built — a new composition root and one fewer import, not a rewrite of the slice. That exact call is `controller.test-d.ts`'s fifth diff --git a/README.md b/README.md index 3f97b41..63010e1 100644 --- a/README.md +++ b/README.md @@ -99,31 +99,34 @@ export const ordersContract = { import { HttpRouter } from "@btravstack/http"; import { P } from "unthrown"; -export const ordersRouter = HttpRouter(ordersContract)([PlaceOrder], { - sync: (place) => ({ - place: ({ errors }, input) => - place - .execute(input.id, input.quantity) - .map((order) => ({ id: order.id, quantity: order.quantity })) - // The one place a domain error becomes a transport one — exhaustive, - // so a new domain error is a compile error here. - .mapErrCases((matcher) => - matcher - .with(P.tag("InvalidQuantity"), (error) => - errors.INVALID_QUANTITY({ - message: error.message, - data: { id: error.id }, - }), - ) - .with(P.tag("DuplicateOrder"), (error) => - errors.CONFLICT({ - message: error.message, - data: { id: error.id }, - }), - ), - ), - }), -}); +export const ordersRouter = HttpRouter(ordersContract)( + { place: PlaceOrder }, + { + sync: ({ place }) => ({ + place: ({ errors }, input) => + place + .execute(input.id, input.quantity) + .map((order) => ({ id: order.id, quantity: order.quantity })) + // The one place a domain error becomes a transport one — exhaustive, + // so a new domain error is a compile error here. + .mapErrCases((matcher) => + matcher + .with(P.tag("InvalidQuantity"), (error) => + errors.INVALID_QUANTITY({ + message: error.message, + data: { id: error.id }, + }), + ) + .with(P.tag("DuplicateOrder"), (error) => + errors.CONFLICT({ + message: error.message, + data: { id: error.id }, + }), + ), + ), + }), + }, +); ``` ```ts diff --git a/examples/README.md b/examples/README.md index 4307b30..99ada37 100644 --- a/examples/README.md +++ b/examples/README.md @@ -221,7 +221,7 @@ same three-package vertical below it. order-api-contract contract.orders contract.customers ← private fragments; the root contract is { orders, customers } │ │ order-api slices/orders/ slices/customers/ - controller.ts controller.ts ← HttpController(name, fragment)([deps], { sync }) + controller.ts controller.ts ← HttpController(name, fragment)({ name: Dep }, { sync }) module.ts module.ts ← the slice's own di module └───────────┬────────────┘ module.ts ← HttpRouter(contract)({ orders, customers }) @@ -229,8 +229,8 @@ order-api slices/orders/ slices/customers/ PlaceOrder / FindOrder FindCustomer ← use cases, entities, Prisma adapters — the same three packages ``` -A **controller** is `HttpController("OrdersController", contract.orders)([PlaceOrder, -FindOrder], { sync })` — an ordinary di provider on a port `HttpController` +A **controller** is `HttpController("OrdersController", contract.orders)({ place: +PlaceOrder, find: FindOrder }, { sync })` — an ordinary di provider on a port `HttpController` mints and hands back on `.port`. A **slice** is an ordinary di `Module` that **imports the vertical it needs**, provides its controller and exports **only** that controller, so nothing outside the slice can reach anything else @@ -255,7 +255,7 @@ module, a modulith is several slice modules in one root — and `exports: port and there is no class to name. And because a fragment is itself a valid contract, lifting `orders` into a process of its own leaves the slice untouched — -`HttpRouter(contract.orders)([ordersController.port], { sync: (implementation) => implementation })` +`HttpRouter(contract.orders)({ implementation: ordersController.port }, { sync: ({ implementation }) => implementation })` is the whole of the lifted root's router. `packages/http/src/controller.test-d.ts` pins that, and the four other gates, at compile time. diff --git a/packages/amqp/README.md b/packages/amqp/README.md index b9f36d9..5a29349 100644 --- a/packages/amqp/README.md +++ b/packages/amqp/README.md @@ -69,7 +69,7 @@ call, not on the first delivery. `runtimeInfo()` reads `{ queues }` back once consuming. A worker with several consumers can be several slices instead of one record: -`AmqpHandler(contract, key)([deps], arm)` mints a provider for ONE consumer or +`AmqpHandler(contract, key)({ name: Dep }, arm)` mints a provider for ONE consumer or rpc, typed by the key alone, and `AmqpHandlers(contract)([...])` composes an array of them into the same handlers provider `AmqpModule` takes — the array must cover every key the contract declares, and each piece's own port must diff --git a/packages/config/CLAUDE.md b/packages/config/CLAUDE.md index 976452a..f5085ec 100644 --- a/packages/config/CLAUDE.md +++ b/packages/config/CLAUDE.md @@ -33,7 +33,7 @@ Ok(value)`). What a starter's options do to its own fields — explicit beats whose `parse` defects (a bug in the field) is folded into an issue against its variable. - **`Config.provider(port)(schema)` / `Config.provider(name)(schema)`** — two - overloads over one body: `Provider(port)([Env], { make })`, `make` awaiting + overloads over one body: `Provider(port)({ env: Env }, { make })`, `make` awaiting `schema["~standard"].validate(env)` inside `fromSafePromise` over an `async` wrapper (a third-party schema may be async and may throw — the throw becomes the defect it is) and answering `Ok(value)` or diff --git a/packages/config/README.md b/packages/config/README.md index 91c3b25..70eba32 100644 --- a/packages/config/README.md +++ b/packages/config/README.md @@ -35,7 +35,7 @@ const databaseConfig = Config.provider("DatabaseConfig")( const Persistence = Module("Persistence")({ provides: [ databaseConfig, - Provider(Database)([databaseConfig.port], { acquire: (config) => …, release: … }), + Provider(Database)({ config: databaseConfig.port }, { acquire: ({ config }) => …, release: … }), ], exports: [Database], }); diff --git a/packages/http/CLAUDE.md b/packages/http/CLAUDE.md index 1ee260a..4fc59ed 100644 --- a/packages/http/CLAUDE.md +++ b/packages/http/CLAUDE.md @@ -28,7 +28,7 @@ the same commit, and with `README.md` — the package ships no job is to provide it. Covered by the package's own `rpc` fixture, which composes `RpcApp` through it. Options `port`/`hostname` pin as for `http()`. **`authenticator`** is what a marked contract needs — - `HttpAuthenticator

()([deps], { sync })` — and it is a plain optional + `HttpAuthenticator

()({ name: Dep }, { sync })` — and it is a plain optional field: present, it joins `provides`, which is all discharging di's need takes. `Auth` is inferred from it and `Provides` spreads `[Auth] extends [undefined] ? [] : [NonNullable]`, so an **omitted** @@ -116,7 +116,7 @@ PrincipalKey>>]: never }` — the same `Exclude` and the same `Inherit` the rejected, a procedure a controller's fragment does not declare rejected inside the controller, and — the fifth, marked "do not break" — a slice lifting out of the composed router **with its controller unchanged**: - `HttpRouter(contract.orders)([orders.port], { sync: (implementation) => implementation })` + `HttpRouter(contract.orders)({ implementation: orders.port }, { sync: ({ implementation }) => implementation })` compiles, so the lifted root declares the very controller the modulith composed and hands back what it built. The gate names the controller deliberately — a fresh `sync` literal over the fragment would pin only that @@ -133,7 +133,7 @@ PrincipalKey>>]: never }` — the same `Exclude` and the same `Inherit` the Covered at runtime by the `rpcSliced` fixture, composing `helloController` and `echoesController` over `slicedContract`'s two fragments. -- **`HttpController(name, fragment)([deps], { sync })`** (`controller.ts`) — +- **`HttpController(name, fragment)({ name: Dep }, { sync })`** (`controller.ts`) — one slice of a contract, as a provider on a port minted for it. The first call fixes `fragment`'s type — read for its type only, so a procedure the fragment does not declare or a handler whose input or output has drifted is @@ -170,7 +170,7 @@ InstanceType> & { readonly port: PortClassOf `auth.test-d.ts` because a `boolean` result would satisfy either. Pinned by `auth.test-d.ts`, mutation-checked. What makes the type true at runtime is `principalMiddleware`, below. -- **`HttpAuthenticator

()([deps], { sync })`, `AuthenticatorPort`, +- **`HttpAuthenticator

()({ name: Dep }, { sync })`, `AuthenticatorPort`, `Unauthenticated`, `AuthenticatorService

`** (`auth.ts`) — what an application provides so a marked procedure can name its caller. `AuthenticatorService

` is @@ -223,7 +223,7 @@ InstanceType> & { readonly port: PortClassOf `HttpRouter` themselves are annotated `ReturnType>` here). `HttpAuthenticator` is handed back **already applied** — the type argument it exists to state is what the factory just fixed — so it is called - `HttpAuthenticator([deps], { sync })`. + `HttpAuthenticator({ name: Dep }, { sync })`. `HttpModule`'s gate compares the authenticator's principal to the **router's** identity, both of which come from the same `httpAuth` call in an ordinary application. Pinned by `auth.test-d.ts`'s arms 12–16 (the identity diff --git a/packages/http/README.md b/packages/http/README.md index 56b00b8..84026e1 100644 --- a/packages/http/README.md +++ b/packages/http/README.md @@ -139,13 +139,13 @@ const orderRouter = HttpRouter(orderContract)({ }); ``` -`HttpController(name, fragment)([deps], { sync })` is the same two-call shape +`HttpController(name, fragment)({ name: Dep }, { sync })` is the same two-call shape as `HttpRouter`, aimed at one fragment: it mints a port under `name` and returns the provider carrying it on `.port`. The keyed form is **exact** — a missing slice, an undeclared key and a controller under the wrong key are all compile errors — and because a fragment is itself a valid contract, a slice can be served alone, its controller unchanged: the lifted root is -`HttpRouter(ordersContract)([ordersController.port], { sync: (implementation) => implementation })`, +`HttpRouter(ordersContract)({ implementation: ordersController.port }, { sync: ({ implementation }) => implementation })`, declaring the very provider the modulith composed. See [Split a router into controllers](https://btravstack.github.io/start/how-to/split-a-router-into-controllers). diff --git a/packages/http/src/http-module.ts b/packages/http/src/http-module.ts index 54affd5..d6b3ff9 100644 --- a/packages/http/src/http-module.ts +++ b/packages/http/src/http-module.ts @@ -51,7 +51,7 @@ export type HttpModuleOptions< }; /** * Resolves the principal a marked procedure's handler receives — - * `HttpAuthenticator()([deps], { sync })`. Required exactly when + * `HttpAuthenticator()({ name: Dep }, { sync })`. Required exactly when * the router's contract marks something: a marked router declares * `AuthenticatorPort` as a need, and di refuses a graph that does not * discharge it. Whether it resolves what the handlers actually read is the diff --git a/packages/temporal/README.md b/packages/temporal/README.md index 2e85892..32195fb 100644 --- a/packages/temporal/README.md +++ b/packages/temporal/README.md @@ -72,7 +72,7 @@ startup `Err`, not a defect. `runtimeInfo()` reads `{ taskQueue, namespace }` back once the worker is polling. A worker polling for several workflows can be several slices instead of one -record: `TemporalWorkflowActivities(contract, key)([deps], arm)` mints a +record: `TemporalWorkflowActivities(contract, key)({ name: Dep }, arm)` mints a provider for ONE workflow's activities (or a contract-global activity), typed by the key alone, and `TemporalActivities(contract)([...])` composes an array of them into the same activities provider `TemporalModule` takes — the From 139e92b14d01133d1f4498dd03e4919f0c9171d8 Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Thu, 20 Aug 2026 23:51:10 +0200 Subject: [PATCH 16/19] docs: the deps record across the documentation site Every TypeScript sample on the site that declared a provider's dependencies as an array now declares the record, with the key names the code uses. Also fixes a pre-existing drift the recompile surfaced: the AMQP how-to's placingHandlers called PlaceOrder.execute without its tenant. --- docs/examples/hexagonal-order-api.md | 39 +++-- docs/examples/order-amqp-worker.md | 43 +++--- docs/examples/order-api.md | 142 ++++++++++-------- docs/examples/order-temporal-worker.md | 31 ++-- docs/explanation/compile-time-wiring.md | 2 +- docs/explanation/starters.md | 6 +- docs/how-to/configure-from-the-environment.md | 7 +- docs/how-to/consume-amqp-messages.md | 100 ++++++------ docs/how-to/keep-a-port-private.md | 31 ++-- docs/how-to/manage-a-resource.md | 24 +-- docs/how-to/open-a-per-request-scope.md | 25 +-- docs/how-to/protect-a-procedure.md | 104 +++++++------ docs/how-to/read-the-ambient-unit.md | 72 +++++---- .../how-to/split-a-router-into-controllers.md | 82 +++++----- docs/how-to/split-a-worker-into-slices.md | 64 ++++---- docs/how-to/swap-an-adapter.md | 39 +++-- docs/index.md | 49 +++--- docs/reference/amqp.md | 116 +++++++------- docs/reference/config.md | 7 +- docs/reference/di/modules.md | 10 +- docs/reference/di/providers.md | 64 ++++---- docs/reference/http.md | 87 +++++------ docs/reference/temporal.md | 124 ++++++++------- docs/tutorial/getting-started.md | 14 +- 24 files changed, 710 insertions(+), 572 deletions(-) diff --git a/docs/examples/hexagonal-order-api.md b/docs/examples/hexagonal-order-api.md index 1b40c99..04f124d 100644 --- a/docs/examples/hexagonal-order-api.md +++ b/docs/examples/hexagonal-order-api.md @@ -59,20 +59,26 @@ export const makePersistenceModule = () => Module("Persistence")({ imports: [ConfigModule], provides: [ - Provider(Pool)([AppConfig], { - acquire: openPool, - release: (pool) => pool.close(), - }), - Provider(OrderRepository)([Pool], { - sync: (pool) => ({ - findById: (id) => { - const row = pool.findById(id); - return ( - row === undefined ? Err(new OrderNotFound({ id })) : Ok(row) - ).toAsync(); - }, - }), - }), + Provider(Pool)( + { config: AppConfig }, + { + acquire: openPool, + release: (pool) => pool.close(), + }, + ), + Provider(OrderRepository)( + { pool: Pool }, + { + sync: ({ pool }) => ({ + findById: (id) => { + const row = pool.findById(id); + return ( + row === undefined ? Err(new OrderNotFound({ id })) : Ok(row) + ).toAsync(); + }, + }), + }, + ), ], exports: [OrderRepository], }); @@ -103,7 +109,10 @@ export const makeAppModule = ( Module("App")({ imports: [persistence], provides: [ - Provider(GetOrder)([OrderRepository], { class: GetOrderInteractor }), + Provider(GetOrder)( + { orders: OrderRepository }, + { class: GetOrderInteractor }, + ), ], exports: [GetOrder], }); diff --git a/docs/examples/order-amqp-worker.md b/docs/examples/order-amqp-worker.md index bd0674c..1c51c94 100644 --- a/docs/examples/order-amqp-worker.md +++ b/docs/examples/order-amqp-worker.md @@ -57,26 +57,33 @@ name, since the contract key IS the port's name: export const orderNotifications = AmqpHandler( orderContract, "orderNotifications", -)([Logger], { - sync: (logger) => (message) => { - const { id, payload } = message.payload; - if (currentUnit()?.signal.aborted === true) { - return ErrAsync( - new RetryableError( - `the drain deadline passed before order ${id} was notified`, - ), - ); - } - logger.info( - payload === null ? "order gone — notifying" : "order placed — notifying", - { - orderId: id, - ...(payload === null ? {} : { quantity: payload.quantity }), +)( + { logger: Logger }, + { + sync: + ({ logger }) => + (message) => { + const { id, payload } = message.payload; + if (currentUnit()?.signal.aborted === true) { + return ErrAsync( + new RetryableError( + `the drain deadline passed before order ${id} was notified`, + ), + ); + } + logger.info( + payload === null + ? "order gone — notifying" + : "order placed — notifying", + { + orderId: id, + ...(payload === null ? {} : { quantity: payload.quantity }), + }, + ); + return OkAsync(); }, - ); - return OkAsync(); }, -}); +); ``` The audit slice is the same shape over `"orderAudit"`, minus the deadline diff --git a/docs/examples/order-api.md b/docs/examples/order-api.md index e199e3c..09a9f08 100644 --- a/docs/examples/order-api.md +++ b/docs/examples/order-api.md @@ -156,21 +156,24 @@ import { ErrAsync, OkAsync } from "unthrown"; import { HttpAuthenticator } from "./auth.js"; -export const bearerAuthenticator = HttpAuthenticator([], { - sync: () => (headers) => { - const header = headers.authorization ?? ""; - const token = header.startsWith("Bearer ") - ? header.slice("Bearer ".length) - : ""; - const [tenantId, userId] = token.split(":"); - return tenantId === undefined || - tenantId === "" || - userId === undefined || - userId === "" - ? ErrAsync(new Unauthenticated()) - : OkAsync({ tenantId, userId }); +export const bearerAuthenticator = HttpAuthenticator( + {}, + { + sync: () => (headers) => { + const header = headers.authorization ?? ""; + const token = header.startsWith("Bearer ") + ? header.slice("Bearer ".length) + : ""; + const [tenantId, userId] = token.split(":"); + return tenantId === undefined || + tenantId === "" || + userId === undefined || + userId === "" + ? ErrAsync(new Unauthenticated()) + : OkAsync({ tenantId, userId }); + }, }, -}); +); ``` `Bearer :` is a stand-in, not a recommendation — what matters @@ -189,9 +192,9 @@ use cases in [`order-application`](/examples/order-application), and the entities and Prisma adapters behind it. ``` -src/slices/orders/controller.ts HttpController("OrdersController", contract.orders)([PlaceOrder, FindOrder, Logger], { sync }) +src/slices/orders/controller.ts HttpController("OrdersController", contract.orders)({ place: PlaceOrder, find: FindOrder, logger: Logger }, { sync }) src/slices/orders/module.ts OrdersSlice — imports the vertical, provides the controller, exports only it -src/slices/customers/controller.ts HttpController("CustomersController", contract.customers)([FindCustomer], { sync }) +src/slices/customers/controller.ts HttpController("CustomersController", contract.customers)({ find: FindCustomer }, { sync }) src/slices/customers/module.ts CustomersSlice — same shape as OrdersSlice ``` @@ -205,45 +208,48 @@ import { HttpController } from "../../auth.js"; export const ordersController = HttpController( "OrdersController", contract.orders, -)([PlaceOrder, FindOrder, Logger], { - sync: (place, find, logger) => ({ - place: ({ errors, context }, input) => { - logger.info("order placement requested", { - userId: context.principal.userId, - }); - return place - .execute(context.principal.tenantId, input.id, input.quantity) - .map(view) - .mapErrCases((matcher) => - matcher - .with(P.tag("InvalidQuantity"), (error) => - errors.INVALID_QUANTITY({ - message: error.message, - data: { id: error.id }, - }), - ) - .with(P.tag("DuplicateOrder"), (error) => - errors.CONFLICT({ +)( + { place: PlaceOrder, find: FindOrder, logger: Logger }, + { + sync: ({ place, find, logger }) => ({ + place: ({ errors, context }, input) => { + logger.info("order placement requested", { + userId: context.principal.userId, + }); + return place + .execute(context.principal.tenantId, input.id, input.quantity) + .map(view) + .mapErrCases((matcher) => + matcher + .with(P.tag("InvalidQuantity"), (error) => + errors.INVALID_QUANTITY({ + message: error.message, + data: { id: error.id }, + }), + ) + .with(P.tag("DuplicateOrder"), (error) => + errors.CONFLICT({ + message: error.message, + data: { id: error.id }, + }), + ), + ); + }, + find: ({ errors, context }, input) => + find + .execute(context.principal.tenantId, input.id) + .map(view) + .mapErrCases((matcher) => + matcher.with(P.tag("OrderNotFound"), (error) => + errors.NOT_FOUND({ message: error.message, data: { id: error.id }, }), ), - ); - }, - find: ({ errors, context }, input) => - find - .execute(context.principal.tenantId, input.id) - .map(view) - .mapErrCases((matcher) => - matcher.with(P.tag("OrderNotFound"), (error) => - errors.NOT_FOUND({ - message: error.message, - data: { id: error.id }, - }), ), - ), - }), -}); + }), + }, +); ``` Each leaf is the `.result()` handler `@unthrown/orpc` gives that procedure's @@ -310,7 +316,7 @@ the recipe, and `packages/http/src/controller.test-d.ts` for the five gates that pin these errors and the lift below. Because a fragment is itself a valid contract, `ordersController` serves `contract.orders` alone unchanged: the lifted root is -`HttpRouter(contract.orders)([ordersController.port], { sync: (implementation) => implementation })` +`HttpRouter(contract.orders)({ implementation: ordersController.port }, { sync: ({ implementation }) => implementation })` over `OrdersSlice`, so extracting a slice out of this modulith is a new composition root and one fewer import, not a rewrite. @@ -407,18 +413,21 @@ export class RequestSpan extends Port("RequestSpan")<{ export const RequestModule = Module("Request")({ provides: [ - Provider(RequestSpan)([Logger], { - sync: (logger) => { - const startedAt = Date.now(); - return { - finish: () => - logger.info("request finished", { - durationMs: Date.now() - startedAt, - }), - }; + Provider(RequestSpan)( + { logger: Logger }, + { + sync: ({ logger }) => { + const startedAt = Date.now(); + return { + finish: () => + logger.info("request finished", { + durationMs: Date.now() - startedAt, + }), + }; + }, + onStop: (span) => span.finish(), }, - onStop: (span) => span.finish(), - }), + ), ], exports: [RequestSpan], }); @@ -556,9 +565,12 @@ authenticator discharges the need. `HttpModule` compares the router's identity against the authenticator's itself, at the option: ```ts -const wrongAuthenticator = HttpAuthenticator<{ readonly sub: string }>()([], { - sync: () => () => OkAsync({ sub: "s-1" }), -}); +const wrongAuthenticator = HttpAuthenticator<{ readonly sub: string }>()( + {}, + { + sync: () => () => OkAsync({ sub: "s-1" }), + }, +); const _mismatchedApi = HttpModule("MismatchedApi")({ router: orderRouter, diff --git a/docs/examples/order-temporal-worker.md b/docs/examples/order-temporal-worker.md index 30f2592..a5b4523 100644 --- a/docs/examples/order-temporal-worker.md +++ b/docs/examples/order-temporal-worker.md @@ -55,21 +55,24 @@ declare is a compile error in that slice's own file, not a defect export const chargeOrder = TemporalWorkflowActivities( orderContract, "chargeOrder", -)([PaymentService], { - sync: (payments) => ({ - authorizePayment: (args, { errors }) => - payments - .authorize(args.orderId, args.amount) - .map((authorizationId) => ({ authorizationId })) - .mapErrCases((matcher) => - matcher.with(P.tag("PaymentDeclined"), (error) => - errors.PaymentDeclined({ id: error.id }), +)( + { payments: PaymentService }, + { + sync: ({ payments }) => ({ + authorizePayment: (args, { errors }) => + payments + .authorize(args.orderId, args.amount) + .map((authorizationId) => ({ authorizationId })) + .mapErrCases((matcher) => + matcher.with(P.tag("PaymentDeclined"), (error) => + errors.PaymentDeclined({ id: error.id }), + ), ), - ), - capturePayment: (args) => payments.capture(args.authorizationId), - refundPayment: (args) => payments.refund(args.authorizationId), - }), -}); + capturePayment: (args) => payments.capture(args.authorizationId), + refundPayment: (args) => payments.refund(args.authorizationId), + }), + }, +); ``` `fulfillOrder`'s own piece is the same activities record this example always diff --git a/docs/explanation/compile-time-wiring.md b/docs/explanation/compile-time-wiring.md index f1b08f3..f338caf 100644 --- a/docs/explanation/compile-time-wiring.md +++ b/docs/explanation/compile-time-wiring.md @@ -50,7 +50,7 @@ its imports' exports. What survives the subtraction propagates upward, module by module, exactly like an unpaid balance: ``` -Provider(OrderRepository)([Pool], ...) Needs: Pool +Provider(OrderRepository)({ pool: Pool }, ...) Needs: Pool Persistence (provides Pool, exports OrderRepository) Needs: Scope ← Pool netted out; Pool's acquire owes Scope App (imports Persistence) Needs: Scope ← still unpaid ``` diff --git a/docs/explanation/starters.md b/docs/explanation/starters.md index 52401d4..a3b8960 100644 --- a/docs/explanation/starters.md +++ b/docs/explanation/starters.md @@ -151,9 +151,9 @@ error at the record. The kernel's `Runtime` has a `needs` field, and `start`'s gate checks it against the module's exports. **No shipped starter uses it any more.** Each takes the application's router / activities / handlers as a port its runtime -provider _depends on_ through di — `Provider(HttpRuntime)([HttpConfig, HttpHandler], …)` where `HttpHandler` -is built from the router port, `Provider(AmqpRuntime)([AmqpConfig, -AmqpHandlersPort], …)` — so their `Needs` is `never` and `RuntimeHost.ctx` +provider _depends on_ through di — `Provider(HttpRuntime)({ config: HttpConfig, handler: HttpHandler }, …)` where +`HttpHandler` is built from the router port, +`Provider(AmqpRuntime)({ config: AmqpConfig, handlers: AmqpHandlersPort }, …)` — so their `Needs` is `never` and `RuntimeHost.ctx` goes unread. The reason is not tidiness. A port's service type is fixed at declaration, so diff --git a/docs/how-to/configure-from-the-environment.md b/docs/how-to/configure-from-the-environment.md index 72c1167..5127d21 100644 --- a/docs/how-to/configure-from-the-environment.md +++ b/docs/how-to/configure-from-the-environment.md @@ -50,9 +50,10 @@ const databaseConfig = Config.provider("DatabaseConfig")( export const Persistence = Module("Persistence")({ provides: [ databaseConfig, - Provider(Database)([databaseConfig.port], { - sync: (config) => openDatabase(config), - }), + Provider(Database)( + { config: databaseConfig.port }, + { sync: ({ config }) => openDatabase(config) }, + ), ], exports: [Database], }); diff --git a/docs/how-to/consume-amqp-messages.md b/docs/how-to/consume-amqp-messages.md index 32cfa16..3d3080d 100644 --- a/docs/how-to/consume-amqp-messages.md +++ b/docs/how-to/consume-amqp-messages.md @@ -46,32 +46,35 @@ import { orderContract } from "@btravstack/example-order-amqp-contract"; import { Logger } from "@btravstack/observability"; import { OkAsync } from "unthrown"; -export const orderHandlers = AmqpHandlers(orderContract)([Logger], { - sync: (logger) => ({ - orderNotifications: (message) => { - const { id, payload } = message.payload; - logger.info( - payload === null - ? "order gone — notifying" - : "order placed — notifying", - { +export const orderHandlers = AmqpHandlers(orderContract)( + { logger: Logger }, + { + sync: ({ logger }) => ({ + orderNotifications: (message) => { + const { id, payload } = message.payload; + logger.info( + payload === null + ? "order gone — notifying" + : "order placed — notifying", + { + orderId: id, + ...(payload === null ? {} : { quantity: payload.quantity }), + }, + ); + return OkAsync(); + }, + orderAudit: (message) => { + const { id, occurredAt, payload } = message.payload; + logger.info("recording an order change", { orderId: id, - ...(payload === null ? {} : { quantity: payload.quantity }), - }, - ); - return OkAsync(); - }, - orderAudit: (message) => { - const { id, occurredAt, payload } = message.payload; - logger.info("recording an order change", { - orderId: id, - occurredAt, - change: payload === null ? "removed" : "placed", - }); - return OkAsync(); - }, - }), -}); + occurredAt, + change: payload === null ? "removed" : "placed", + }); + return OkAsync(); + }, + }), + }, +); ``` `examples/order-amqp-worker` composes these two consumers from a slice each @@ -104,27 +107,34 @@ import { AmqpHandlers } from "@btravstack/amqp"; import { NonRetryableError, RetryableError } from "@amqp-contract/worker"; import { ErrAsync, OkAsync, P } from "unthrown"; -export const placingHandlers = AmqpHandlers(orderContract)([PlaceOrder], { - sync: (place) => ({ - orderNotifications: (message) => - place - .execute(message.payload.id, message.payload.payload?.quantity ?? 0) - .map(() => undefined) - .mapErrCases((matcher) => - matcher.with( - P.tag("InvalidQuantity"), - P.tag("DuplicateOrder"), - (error) => new NonRetryableError(error._tag, error), +export const placingHandlers = AmqpHandlers(orderContract)( + { place: PlaceOrder }, + { + sync: ({ place }) => ({ + orderNotifications: (message) => + place + .execute( + message.payload.tenantId, + message.payload.id, + message.payload.payload?.quantity ?? 0, + ) + .map(() => undefined) + .mapErrCases((matcher) => + matcher.with( + P.tag("InvalidQuantity"), + P.tag("DuplicateOrder"), + (error) => new NonRetryableError(error._tag, error), + ), + ) + .recoverDefect((cause) => + ErrAsync(new RetryableError("placing the order failed", cause)), ), - ) - .recoverDefect((cause) => - ErrAsync(new RetryableError("placing the order failed", cause)), - ), - // Not the point of this example — a bare ack keeps `placingHandlers` - // focused on the triage `PlaceOrder` needs. - orderAudit: () => OkAsync(), - }), -}); + // Not the point of this example — a bare ack keeps `placingHandlers` + // focused on the triage `PlaceOrder` needs. + orderAudit: () => OkAsync(), + }), + }, +); ``` The queue's policy is contract configuration the broker enforces — the diff --git a/docs/how-to/keep-a-port-private.md b/docs/how-to/keep-a-port-private.md index 7b58a64..762d108 100644 --- a/docs/how-to/keep-a-port-private.md +++ b/docs/how-to/keep-a-port-private.md @@ -22,13 +22,19 @@ module provides but does not export is internal: const Persistence = Module("Persistence")({ imports: [Config], provides: [ - Provider(Pool)([AppConfig], { - acquire: openPool, - release: (pool) => pool.close(), - }), - Provider(OrderRepository)([Pool], { - sync: (pool) => makeRepository(pool), - }), + Provider(Pool)( + { config: AppConfig }, + { + acquire: openPool, + release: (pool) => pool.close(), + }, + ), + Provider(OrderRepository)( + { pool: Pool }, + { + sync: ({ pool }) => makeRepository(pool), + }, + ), ], exports: [OrderRepository], // Pool and AppConfig: not listed, not visible }); @@ -40,8 +46,11 @@ Any module importing `Persistence` sees exactly one port: const App = Module("App")({ imports: [Persistence], provides: [ - Provider(GetOrder)([OrderRepository], { class: GetOrderInteractor }), - Provider(Audit)([Pool], { sync: makeAudit }), // Pool is not visible here + Provider(GetOrder)( + { orders: OrderRepository }, + { class: GetOrderInteractor }, + ), + Provider(Audit)({ pool: Pool }, { sync: makeAudit }), // Pool is not visible here ], exports: [GetOrder], }); @@ -78,7 +87,9 @@ The `exports` list cannot lie: ```ts Module("Persistence")({ - provides: [Provider(OrderRepository)([Pool], { sync: makeRepository })], + provides: [ + Provider(OrderRepository)({ pool: Pool }, { sync: makeRepository }), + ], exports: [OrderRepository, Metrics], // Metrics: neither provided nor imported }); ``` diff --git a/docs/how-to/manage-a-resource.md b/docs/how-to/manage-a-resource.md index 1c2b8d2..d254de4 100644 --- a/docs/how-to/manage-a-resource.md +++ b/docs/how-to/manage-a-resource.md @@ -22,10 +22,13 @@ release, nor an `acquire` never torn down: const Persistence = Module("Persistence")({ imports: [Config], provides: [ - Provider(Database)([AppConfig], { - acquire: (config) => openPool(config.dbUrl), // Result | AsyncResult — may fail - release: (pool) => pool.close(), // void | Promise - }), + Provider(Database)( + { config: AppConfig }, + { + acquire: ({ config }) => openPool(config.dbUrl), // Result | AsyncResult — may fail + release: (pool) => pool.close(), // void | Promise + }, + ), ], exports: [Database], }); @@ -99,11 +102,14 @@ Every arm — not only `acquire`/`release` — accepts optional lifecycle hooks the same options literal: ```ts -Provider(Cache)([AppConfig], { - make: (config) => connectCache(config), - onStart: (cache) => cache.warm(), // after the WHOLE graph is built - onStop: (cache) => cache.flush(), // during teardown, LIFO with releases -}); +Provider(Cache)( + { config: AppConfig }, + { + make: ({ config }) => connectCache(config), + onStart: (cache) => cache.warm(), // after the WHOLE graph is built + onStop: (cache) => cache.flush(), // during teardown, LIFO with releases + }, +); ``` - `onStart` fires only once the **entire** graph has finished constructing — diff --git a/docs/how-to/open-a-per-request-scope.md b/docs/how-to/open-a-per-request-scope.md index 766035f..91aed71 100644 --- a/docs/how-to/open-a-per-request-scope.md +++ b/docs/how-to/open-a-per-request-scope.md @@ -41,18 +41,21 @@ export class RequestSpan extends Port("RequestSpan")<{ export const RequestModule = Module("Request")({ provides: [ - Provider(RequestSpan)([Logger], { - sync: (logger) => { - const startedAt = Date.now(); - return { - finish: () => - logger.info("request finished", { - durationMs: Date.now() - startedAt, - }), - }; + Provider(RequestSpan)( + { logger: Logger }, + { + sync: ({ logger }) => { + const startedAt = Date.now(); + return { + finish: () => + logger.info("request finished", { + durationMs: Date.now() - startedAt, + }), + }; + }, + onStop: (span) => span.finish(), }, - onStop: (span) => span.finish(), - }), + ), ], exports: [RequestSpan], }); diff --git a/docs/how-to/protect-a-procedure.md b/docs/how-to/protect-a-procedure.md index 2f7959e..fc2302b 100644 --- a/docs/how-to/protect-a-procedure.md +++ b/docs/how-to/protect-a-procedure.md @@ -101,7 +101,7 @@ handler is. The three aliases are annotations rather than ceremony — a controller's port expands to a type carrying the marker's phantom `unique symbol`, which the file's own `.d.ts` cannot name. -`HttpAuthenticator([deps], { sync })` is then an ordinary di provider on the +`HttpAuthenticator({ name: Dep }, { sync })` is then an ordinary di provider on the starter's `AuthenticatorPort`, with no type argument left to state. It resolves the identity from the request's **headers** — not the request: an authenticator has no business reading a body, and the narrower argument is what keeps it @@ -113,21 +113,24 @@ import { ErrAsync, OkAsync } from "unthrown"; import { HttpAuthenticator } from "./auth.js"; -export const bearerAuthenticator = HttpAuthenticator([], { - sync: () => (headers) => { - const header = headers.authorization ?? ""; - const token = header.startsWith("Bearer ") - ? header.slice("Bearer ".length) - : ""; - const [tenantId, userId] = token.split(":"); - return tenantId === undefined || - tenantId === "" || - userId === undefined || - userId === "" - ? ErrAsync(new Unauthenticated()) - : OkAsync({ tenantId, userId }); +export const bearerAuthenticator = HttpAuthenticator( + {}, + { + sync: () => (headers) => { + const header = headers.authorization ?? ""; + const token = header.startsWith("Bearer ") + ? header.slice("Bearer ".length) + : ""; + const [tenantId, userId] = token.split(":"); + return tenantId === undefined || + tenantId === "" || + userId === undefined || + userId === "" + ? ErrAsync(new Unauthenticated()) + : OkAsync({ tenantId, userId }); + }, }, -}); +); ``` Enriching what a deployment knows about its callers — roles, an org tier, an @@ -173,45 +176,48 @@ import { HttpController } from "../../auth.js"; export const ordersController = HttpController( "OrdersController", contract.orders, -)([PlaceOrder, FindOrder, Logger], { - sync: (place, find, logger) => ({ - place: ({ errors, context }, input) => { - logger.info("order placement requested", { - userId: context.principal.userId, - }); - return place - .execute(context.principal.tenantId, input.id, input.quantity) - .map(view) - .mapErrCases((matcher) => - matcher - .with(P.tag("InvalidQuantity"), (error) => - errors.INVALID_QUANTITY({ - message: error.message, - data: { id: error.id }, - }), - ) - .with(P.tag("DuplicateOrder"), (error) => - errors.CONFLICT({ +)( + { place: PlaceOrder, find: FindOrder, logger: Logger }, + { + sync: ({ place, find, logger }) => ({ + place: ({ errors, context }, input) => { + logger.info("order placement requested", { + userId: context.principal.userId, + }); + return place + .execute(context.principal.tenantId, input.id, input.quantity) + .map(view) + .mapErrCases((matcher) => + matcher + .with(P.tag("InvalidQuantity"), (error) => + errors.INVALID_QUANTITY({ + message: error.message, + data: { id: error.id }, + }), + ) + .with(P.tag("DuplicateOrder"), (error) => + errors.CONFLICT({ + message: error.message, + data: { id: error.id }, + }), + ), + ); + }, + find: ({ errors, context }, input) => + find + .execute(context.principal.tenantId, input.id) + .map(view) + .mapErrCases((matcher) => + matcher.with(P.tag("OrderNotFound"), (error) => + errors.NOT_FOUND({ message: error.message, data: { id: error.id }, }), ), - ); - }, - find: ({ errors, context }, input) => - find - .execute(context.principal.tenantId, input.id) - .map(view) - .mapErrCases((matcher) => - matcher.with(P.tag("OrderNotFound"), (error) => - errors.NOT_FOUND({ - message: error.message, - data: { id: error.id }, - }), ), - ), - }), -}); + }), + }, +); ``` An **unmarked** procedure's `context` has no `principal`, so reading one there diff --git a/docs/how-to/read-the-ambient-unit.md b/docs/how-to/read-the-ambient-unit.md index 5a52f7d..a675c13 100644 --- a/docs/how-to/read-the-ambient-unit.md +++ b/docs/how-to/read-the-ambient-unit.md @@ -212,26 +212,33 @@ broker, so a `RetryableError` hands the message to the next worker — export const orderNotifications = AmqpHandler( orderContract, "orderNotifications", -)([Logger], { - sync: (logger) => (message) => { - const { id, payload } = message.payload; - if (currentUnit()?.signal.aborted === true) { - return ErrAsync( - new RetryableError( - `the drain deadline passed before order ${id} was notified`, - ), - ); - } - logger.info( - payload === null ? "order gone — notifying" : "order placed — notifying", - { - orderId: id, - ...(payload === null ? {} : { quantity: payload.quantity }), +)( + { logger: Logger }, + { + sync: + ({ logger }) => + (message) => { + const { id, payload } = message.payload; + if (currentUnit()?.signal.aborted === true) { + return ErrAsync( + new RetryableError( + `the drain deadline passed before order ${id} was notified`, + ), + ); + } + logger.info( + payload === null + ? "order gone — notifying" + : "order placed — notifying", + { + orderId: id, + ...(payload === null ? {} : { quantity: payload.quantity }), + }, + ); + return OkAsync(); }, - ); - return OkAsync(); }, -}); +); ``` On Temporal, the platform retries an attempt that fails as a **defect** on @@ -240,20 +247,23 @@ contract's own `ShippingUnavailable` is a permanent no and would be the wrong error. `examples/order-temporal-worker/src/fulfillment.ts`: ```ts -Provider(ShippingService)([Logger], { - sync: (logger) => ({ - arrange: (orderId) => - currentUnit()?.signal.aborted === true - ? fromSafePromise( - Promise.reject( - new Error( - `the drain deadline passed before shipping for ${orderId} was arranged`, +Provider(ShippingService)( + { logger: Logger }, + { + sync: ({ logger }) => ({ + arrange: (orderId) => + currentUnit()?.signal.aborted === true + ? fromSafePromise( + Promise.reject( + new Error( + `the drain deadline passed before shipping for ${orderId} was arranged`, + ), ), - ), - ) - : (logger.info("arranged shipping", { orderId }), OkAsync()), - }), -}); + ) + : (logger.info("arranged shipping", { orderId }), OkAsync()), + }), + }, +); ``` A transport's **own** cancellation is a different clock and stays separate. diff --git a/docs/how-to/split-a-router-into-controllers.md b/docs/how-to/split-a-router-into-controllers.md index bd74661..5b86c7a 100644 --- a/docs/how-to/split-a-router-into-controllers.md +++ b/docs/how-to/split-a-router-into-controllers.md @@ -21,7 +21,7 @@ lifted from `examples/order-api`, which serves an `orders` slice and a ## Step 1 — a fragment per slice A slice's contract is a plain `RouterContract` — the same shape the -positional form already takes, just smaller — and the root contract is a +whole-contract form already takes, just smaller — and the root contract is a record of them: ```ts @@ -86,9 +86,10 @@ public half and a protected one stop being one undifferentiated surface. ## Step 2 — a controller per slice -`HttpController(name, fragment)([deps], { sync })` is `HttpRouter`'s own +`HttpController(name, fragment)({ name: Dep }, { sync })` is `HttpRouter`'s own shape, aimed at one fragment: the first call fixes the fragment's type and -mints a port under `name`; the second is di's `Provider(port)(deps, { sync })`, +mints a port under `name`; the second is di's +`Provider(port)({ name: Dep }, { sync })`, so `sync`'s return is typed by the fragment at the call — a typo'd or missing procedure is a compile error inside the controller itself, not at the root: @@ -98,41 +99,44 @@ import { HttpController } from "../../auth.js"; export const ordersController = HttpController( "OrdersController", contract.orders, -)([PlaceOrder, FindOrder], { - sync: (place, find) => ({ - place: ({ errors, context }, input) => - place - .execute(context.principal.tenantId, input.id, input.quantity) - .map(view) - .mapErrCases((matcher) => - matcher - .with(P.tag("InvalidQuantity"), (error) => - errors.INVALID_QUANTITY({ - message: error.message, - data: { id: error.id }, - }), - ) - .with(P.tag("DuplicateOrder"), (error) => - errors.CONFLICT({ +)( + { place: PlaceOrder, find: FindOrder }, + { + sync: ({ place, find }) => ({ + place: ({ errors, context }, input) => + place + .execute(context.principal.tenantId, input.id, input.quantity) + .map(view) + .mapErrCases((matcher) => + matcher + .with(P.tag("InvalidQuantity"), (error) => + errors.INVALID_QUANTITY({ + message: error.message, + data: { id: error.id }, + }), + ) + .with(P.tag("DuplicateOrder"), (error) => + errors.CONFLICT({ + message: error.message, + data: { id: error.id }, + }), + ), + ), + find: ({ errors, context }, input) => + find + .execute(context.principal.tenantId, input.id) + .map(view) + .mapErrCases((matcher) => + matcher.with(P.tag("OrderNotFound"), (error) => + errors.NOT_FOUND({ message: error.message, data: { id: error.id }, }), ), - ), - find: ({ errors, context }, input) => - find - .execute(context.principal.tenantId, input.id) - .map(view) - .mapErrCases((matcher) => - matcher.with(P.tag("OrderNotFound"), (error) => - errors.NOT_FOUND({ - message: error.message, - data: { id: error.id }, - }), ), - ), - }), -}); + }), + }, +); ``` `HttpController` comes from the application's own `auth.ts`, not from @@ -174,8 +178,8 @@ is built. ## Step 3 — the keyed root `HttpRouter(contract)(controllers)` — a record keyed by the contract's own -top-level keys, one `HttpController` per key — replaces the positional -`(deps, { sync })` call at the root: +top-level keys, one `HttpController` per key — replaces the +`(deps, { sync })` call at the root, and is told apart from it by **arity**: ```ts export const orderRouter = HttpRouter(contract)({ @@ -218,10 +222,8 @@ controller built: ```ts export const ordersRouter = HttpRouter(contract.orders)( - [ordersController.port], - { - sync: (implementation) => implementation, - }, + { implementation: ordersController.port }, + { sync: ({ implementation }) => implementation }, ); export const OrdersApi = HttpModule("OrdersApi")({ @@ -245,7 +247,7 @@ composing slices into one router a starting point rather than a trap. ## See also - [Serve an oRPC contract over HTTP](/how-to/serve-orpc-over-http) — the - positional form, and everything the starter itself decides. + one-router form, and everything the starter itself decides. - [`@btravstack/http`](/reference/http) — `HttpController` and `HttpRouter`'s full signatures. - [Protect a procedure](/how-to/protect-a-procedure) — `auth.ts`, the diff --git a/docs/how-to/split-a-worker-into-slices.md b/docs/how-to/split-a-worker-into-slices.md index d1ec734..a278f9b 100644 --- a/docs/how-to/split-a-worker-into-slices.md +++ b/docs/how-to/split-a-worker-into-slices.md @@ -54,18 +54,25 @@ piece's own file, not at the root: export const orderNotifications = AmqpHandler( orderContract, "orderNotifications", -)([Logger], { - sync: (logger) => (message) => { - const { id, payload } = message.payload; - logger.info( - payload === null ? "order gone — notifying" : "order placed — notifying", - { - orderId: id, +)( + { logger: Logger }, + { + sync: + ({ logger }) => + (message) => { + const { id, payload } = message.payload; + logger.info( + payload === null + ? "order gone — notifying" + : "order placed — notifying", + { + orderId: id, + }, + ); + return OkAsync(); }, - ); - return OkAsync(); }, -}); +); ``` ```ts @@ -73,21 +80,24 @@ export const orderNotifications = AmqpHandler( export const chargeOrder = TemporalWorkflowActivities( orderContract, "chargeOrder", -)([PaymentService], { - sync: (payments) => ({ - authorizePayment: (args, { errors }) => - payments - .authorize(args.orderId, args.amount) - .map((authorizationId) => ({ authorizationId })) - .mapErrCases((matcher) => - matcher.with(P.tag("PaymentDeclined"), (error) => - errors.PaymentDeclined({ id: error.id }), +)( + { payments: PaymentService }, + { + sync: ({ payments }) => ({ + authorizePayment: (args, { errors }) => + payments + .authorize(args.orderId, args.amount) + .map((authorizationId) => ({ authorizationId })) + .mapErrCases((matcher) => + matcher.with(P.tag("PaymentDeclined"), (error) => + errors.PaymentDeclined({ id: error.id }), + ), ), - ), - capturePayment: (args) => payments.capture(args.authorizationId), - refundPayment: (args) => payments.refund(args.authorizationId), - }), -}); + capturePayment: (args) => payments.capture(args.authorizationId), + refundPayment: (args) => payments.refund(args.authorizationId), + }), + }, +); ``` Each piece declares only the ports **it** calls: `orderNotifications` takes @@ -142,9 +152,9 @@ export const orderActivities = TemporalActivities(orderContract)([ ``` Di constructs every piece first — they are the composed provider's own -`deps`, in array order — and this reassembles the record from them, keyed by -what each piece's port id carries. The composed provider's own `deps` are the -**pieces' ports**, not what a piece closes over, so a piece still needs +`deps`, declared under the very key each piece's port id carries, so the +services record IS the record the starter needs. The composed provider's own +`deps` are the **pieces' ports**, not what a piece closes over, so a piece still needs discharging like any other need: the root **imports every slice module**, even though nothing in it names a piece directly — diff --git a/docs/how-to/swap-an-adapter.md b/docs/how-to/swap-an-adapter.md index 91ada56..74005d3 100644 --- a/docs/how-to/swap-an-adapter.md +++ b/docs/how-to/swap-an-adapter.md @@ -39,20 +39,26 @@ const makePersistenceModule = () => Module("Persistence")({ imports: [ConfigModule], provides: [ - Provider(Pool)([AppConfig], { - acquire: openPool, - release: (pool) => pool.close(), - }), - Provider(OrderRepository)([Pool], { - sync: (pool) => ({ - findById: (id) => { - const row = pool.findById(id); - return ( - row === undefined ? Err(new OrderNotFound({ id })) : Ok(row) - ).toAsync(); - }, - }), - }), + Provider(Pool)( + { config: AppConfig }, + { + acquire: openPool, + release: (pool) => pool.close(), + }, + ), + Provider(OrderRepository)( + { pool: Pool }, + { + sync: ({ pool }) => ({ + findById: (id) => { + const row = pool.findById(id); + return ( + row === undefined ? Err(new OrderNotFound({ id })) : Ok(row) + ).toAsync(); + }, + }), + }, + ), ], exports: [OrderRepository], // Pool stays internal }); @@ -83,7 +89,10 @@ const makeAppModule = (persistence: Module) => Module("App")({ imports: [persistence], provides: [ - Provider(GetOrder)([OrderRepository], { class: GetOrderInteractor }), + Provider(GetOrder)( + { orders: OrderRepository }, + { class: GetOrderInteractor }, + ), ], exports: [GetOrder], }); diff --git a/docs/index.md b/docs/index.md index 28c8073..1d13a00 100644 --- a/docs/index.md +++ b/docs/index.md @@ -69,29 +69,32 @@ const ordersContract = authenticated({ // The router is a provider: it declares the use case its procedure calls. // Every domain error is named here — the one place a Result becomes HTTP. -const ordersRouter = HttpRouter(ordersContract)([PlaceOrder], { - sync: (place) => ({ - place: ({ errors, context }, input) => - place - .execute(context.principal.tenantId, input.id, input.quantity) - .map((order) => ({ id: order.id, quantity: order.quantity })) - .mapErrCases((matcher) => - matcher - .with(P.tag("InvalidQuantity"), (error) => - errors.INVALID_QUANTITY({ - message: error.message, - data: { id: error.id }, - }), - ) - .with(P.tag("DuplicateOrder"), (error) => - errors.CONFLICT({ - message: error.message, - data: { id: error.id }, - }), - ), - ), - }), -}); +const ordersRouter = HttpRouter(ordersContract)( + { place: PlaceOrder }, + { + sync: ({ place }) => ({ + place: ({ errors, context }, input) => + place + .execute(context.principal.tenantId, input.id, input.quantity) + .map((order) => ({ id: order.id, quantity: order.quantity })) + .mapErrCases((matcher) => + matcher + .with(P.tag("InvalidQuantity"), (error) => + errors.INVALID_QUANTITY({ + message: error.message, + data: { id: error.id }, + }), + ) + .with(P.tag("DuplicateOrder"), (error) => + errors.CONFLICT({ + message: error.message, + data: { id: error.id }, + }), + ), + ), + }), + }, +); // The composition root. The runtime is a service of this module. const OrdersApi = HttpModule("OrdersApi")({ diff --git a/docs/reference/amqp.md b/docs/reference/amqp.md index 49db8f3..60fea3a 100644 --- a/docs/reference/amqp.md +++ b/docs/reference/amqp.md @@ -19,20 +19,20 @@ description: The AMQP starter — AmqpModule, AmqpHandlers, amqp(), AmqpRuntime, `packages/amqp/src/index.ts` exports exactly this: -| Export | Kind | What it is | -| ----------------------- | ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `AmqpModule` | value | `AmqpModule(name)({ contract, handlers, url?, connectionOptions?, defaultConsumerOptions?, connectTimeoutMs?, imports?, provides?, exports? })` — a di `Module(name)({...})` that also takes the handlers provider | -| `AmqpModuleOptions` | type | The options object `AmqpModule(name)` takes | -| `AmqpHandlers` | value | `AmqpHandlers(contract)` — di's `Provider(port)` builder on the starter's own handlers port, typed for `contract`, so the next call is `(deps, arm)`, or `([pieces])` to compose one provider per consumer/rpc | -| `HandlersPortOf` | type | The handlers port's class typed for `C` — what a composed `orderHandlers`'s `.port` is | -| `HandlersInstanceOf` | type | That port's instance typed for `C` (service `WorkerInferHandlers`) | -| `AmqpHandler` | value | `AmqpHandler(contract, key)` — one consumer or rpc as a provider of its own, typed by `key` alone; the next call is `(deps, arm)`, and the piece is what `AmqpHandlers(contract)([...])` composes | -| `HandlerPortOf` | type | One piece's port class, typed for the one key `K` it implements | -| `amqp` | value | `amqp({ contract, … })` — the starter module itself, needing the handlers port for `contract`; what `AmqpModule` imports | -| `AmqpOptions` | type | `amqp()`'s options | -| `AmqpRuntime` | value | `class AmqpRuntime extends RuntimePort> {}` — the runtime's port | -| `AmqpConfig` | value | `class AmqpConfig extends Port("AmqpConfig")<{ url: string }> {}` — the broker, bound from `AMQP_URL`; a publisher sharing the consumer's broker reads it too | -| `AmqpInfo` | type | `{ readonly queues: readonly string[] }` — published on `Serving.info` once consuming | +| Export | Kind | What it is | +| ----------------------- | ----- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `AmqpModule` | value | `AmqpModule(name)({ contract, handlers, url?, connectionOptions?, defaultConsumerOptions?, connectTimeoutMs?, imports?, provides?, exports? })` — a di `Module(name)({...})` that also takes the handlers provider | +| `AmqpModuleOptions` | type | The options object `AmqpModule(name)` takes | +| `AmqpHandlers` | value | `AmqpHandlers(contract)` — di's `Provider(port)` builder on the starter's own handlers port, typed for `contract`, so the next call is `({ name: Dep }, arm)`, or `([pieces])` to compose one provider per consumer/rpc | +| `HandlersPortOf` | type | The handlers port's class typed for `C` — what a composed `orderHandlers`'s `.port` is | +| `HandlersInstanceOf` | type | That port's instance typed for `C` (service `WorkerInferHandlers`) | +| `AmqpHandler` | value | `AmqpHandler(contract, key)` — one consumer or rpc as a provider of its own, typed by `key` alone; the next call is `({ name: Dep }, arm)`, and the piece is what `AmqpHandlers(contract)([...])` composes | +| `HandlerPortOf` | type | One piece's port class, typed for the one key `K` it implements | +| `amqp` | value | `amqp({ contract, … })` — the starter module itself, needing the handlers port for `contract`; what `AmqpModule` imports | +| `AmqpOptions` | type | `amqp()`'s options | +| `AmqpRuntime` | value | `class AmqpRuntime extends RuntimePort> {}` — the runtime's port | +| `AmqpConfig` | value | `class AmqpConfig extends Port("AmqpConfig")<{ url: string }> {}` — the broker, bound from `AMQP_URL`; a publisher sharing the consumer's broker reads it too | +| `AmqpInfo` | type | `{ readonly queues: readonly string[] }` — published on `Serving.info` once consuming | `HandlersPortOf` / `HandlersInstanceOf` / `HandlerPortOf` are exported as **types only**, and only because declaration emit forces it: an @@ -127,32 +127,35 @@ record covers **every** consumer and rpc the contract declares — the one `orderChanged` event on their own queue: ```ts -export const orderHandlers = AmqpHandlers(orderContract)([Logger], { - sync: (logger) => ({ - orderNotifications: (message) => { - const { id, payload } = message.payload; - logger.info( - payload === null - ? "order gone — notifying" - : "order placed — notifying", - { +export const orderHandlers = AmqpHandlers(orderContract)( + { logger: Logger }, + { + sync: ({ logger }) => ({ + orderNotifications: (message) => { + const { id, payload } = message.payload; + logger.info( + payload === null + ? "order gone — notifying" + : "order placed — notifying", + { + orderId: id, + ...(payload === null ? {} : { quantity: payload.quantity }), + }, + ); + return OkAsync(); + }, + orderAudit: (message) => { + const { id, occurredAt, payload } = message.payload; + logger.info("recording an order change", { orderId: id, - ...(payload === null ? {} : { quantity: payload.quantity }), - }, - ); - return OkAsync(); - }, - orderAudit: (message) => { - const { id, occurredAt, payload } = message.payload; - logger.info("recording an order change", { - orderId: id, - occurredAt, - change: payload === null ? "removed" : "placed", - }); - return OkAsync(); - }, - }), -}); + occurredAt, + change: payload === null ? "removed" : "placed", + }); + return OkAsync(); + }, + }), + }, +); ``` `examples/order-amqp-worker` no longer calls `AmqpHandlers` this way — its two @@ -163,10 +166,10 @@ that has not outgrown one function. A third call composes several **pieces** instead of one record: `AmqpHandlers(contract)([piece, piece, ...])`, where each piece is what -`AmqpHandler(contract, key)(deps, arm)` returns. Di constructs every piece -first — they are the composed provider's own `deps`, in array order — and this -call reassembles the handlers record from them, keyed by what each piece's -port id carries. Every key the contract declares must be covered: an array +`AmqpHandler(contract, key)({ name: Dep }, arm)` returns. Di constructs every +piece first — they are the composed provider's own `deps`, declared under the +very key each piece's port id carries, so the services record IS the handlers +record. Every key the contract declares must be covered: an array missing one is refused at the call, against an `"UNCOVERED HANDLERS"` marker (`readonly ["UNCOVERED HANDLERS", ...]`) — the missing key itself is named too once the array's length matches that marker tuple's own length of 2; a @@ -198,21 +201,28 @@ is on `AmqpHandlers(contract)`, and the provider carries its port as ```ts const orderNotifications = AmqpHandler(orderContract, "orderNotifications")( - [Logger], + { logger: Logger }, { - sync: (logger) => (message) => { - logger.info("order changed", { orderId: message.payload.id }); - return OkAsync(undefined); - }, + sync: + ({ logger }) => + (message) => { + logger.info("order changed", { orderId: message.payload.id }); + return OkAsync(undefined); + }, }, ); -const orderAudit = AmqpHandler(orderContract, "orderAudit")([Logger], { - sync: (logger) => (message) => { - logger.info("order audited", { orderId: message.payload.id }); - return OkAsync(undefined); +const orderAudit = AmqpHandler(orderContract, "orderAudit")( + { logger: Logger }, + { + sync: + ({ logger }) => + (message) => { + logger.info("order audited", { orderId: message.payload.id }); + return OkAsync(undefined); + }, }, -}); +); const orderHandlers = AmqpHandlers(orderContract)([ orderNotifications, diff --git a/docs/reference/config.md b/docs/reference/config.md index 728eb01..519de62 100644 --- a/docs/reference/config.md +++ b/docs/reference/config.md @@ -158,9 +158,10 @@ const databaseConfig = Config.provider("DatabaseConfig")( const Persistence = Module("Persistence")({ provides: [ databaseConfig, - Provider(Database)([databaseConfig.port], { - sync: (config) => ({ url: config.url }), - }), + Provider(Database)( + { config: databaseConfig.port }, + { sync: ({ config }) => ({ url: config.url }) }, + ), ], exports: [Database], }); diff --git a/docs/reference/di/modules.md b/docs/reference/di/modules.md index b3e1e7b..8ee997b 100644 --- a/docs/reference/di/modules.md +++ b/docs/reference/di/modules.md @@ -21,11 +21,11 @@ is a declaration — building happens only at an const Persistence = Module("Persistence")({ imports: [Config], provides: [ - Provider(Pool)([AppConfig], { - acquire: openPool, - release: (p) => p.close(), - }), - Provider(OrderRepository)([Pool], { sync: makeRepository }), + Provider(Pool)( + { config: AppConfig }, + { acquire: openPool, release: (p) => p.close() }, + ), + Provider(OrderRepository)({ pool: Pool }, { sync: makeRepository }), ], exports: [OrderRepository], }); diff --git a/docs/reference/di/providers.md b/docs/reference/di/providers.md index 36474ad..e5ebaf5 100644 --- a/docs/reference/di/providers.md +++ b/docs/reference/di/providers.md @@ -18,20 +18,21 @@ not an instance: nothing runs until a module containing it is built. ## `Provider(port)(deps, options)` / `Provider(port)(options)` ```ts -Provider(OrderRepository)([Database], { - sync: (db) => ({ findById: (id) => db.query(id) }), -}); +Provider(OrderRepository)( + { db: Database }, + { sync: ({ db }) => ({ findById: (id) => db.query(id) }) }, +); Provider(AppConfig)({ value: { dbUrl: "postgres://localhost/orders" } }); // no deps ``` -| Parameter | Meaning | -| --------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `deps` | An array of ports this construction reads. The resolved services are passed to the arm's function (or constructor) **positionally**, and their types are checked against its parameters. Omitting the array is the zero-dependency form. | -| `options` | Exactly one construction arm, plus the optional hooks. | +| Parameter | Meaning | +| --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `deps` | A **record** of the ports this construction reads, under the names you choose for them. The arm's function (or constructor) receives one argument: a record with the same keys, holding the resolved services. A key the record does not declare is a compile error, and a value that is not a port is too. Omitting the record is the zero-dependency form. | +| `options` | Exactly one construction arm, plus the optional hooks. | -The dependency array is also what feeds the module's `Needs` channel: every -port listed here must be available where the module is built, or the graph is +The dependency record is also what feeds the module's `Needs` channel: every +port named here must be available where the module is built, or the graph is rejected — at compile time if the type is missing, as a [wiring defect](/reference/di/wiring-defects) if a widened type slipped past. @@ -44,13 +45,13 @@ provider carries the very port class it was declared for, typed — see Exactly one arm per provider. The arms are mutually exclusive by construction — an options literal supplying two arms' keys fails to compile, not merely warns: -| Arm | Shape | When | `Scope` in `Needs`? | -| --------------------- | -------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- | ------------------- | -| `value` | `S` | The service is already at hand — a config object, a constant. | No | -| `sync` | `(...deps) => S` | Built synchronously from its dependencies, and cannot fail. | No | -| `make` | `(...deps) => Result \| AsyncResult` | Built fallibly, possibly asynchronously — a parsed config, a validated client. | No | -| `class` | `new (...deps) => S` | Built by constructing a class, dependencies passed positionally to the constructor. | No | -| `acquire` + `release` | `acquire: (...deps) => Result \| AsyncResult`, `release: (s) => void \| Promise` | A real resource — a connection, a file handle — that must be torn down. | Yes | +| Arm | Shape | When | `Scope` in `Needs`? | +| --------------------- | --------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- | ------------------- | +| `value` | `S` | The service is already at hand — a config object, a constant. | No | +| `sync` | `(services) => S` | Built synchronously from its dependencies, and cannot fail. | No | +| `make` | `(services) => Result \| AsyncResult` | Built fallibly, possibly asynchronously — a parsed config, a validated client. | No | +| `class` | `new (services) => S` | Built by constructing a class, which takes the services record as its one argument. | No | +| `acquire` + `release` | `acquire: (services) => Result \| AsyncResult`, `release: (s) => void \| Promise` | A real resource — a connection, a file handle — that must be torn down. | Yes | Notes per arm: @@ -61,7 +62,8 @@ Notes per arm: and surfaces through the entry point's `Result` as an `Err`. A `make` that **throws** instead of returning is a defect, not an `Err`. - **`class`** — the port's service type is the class's **instance** type; the - constructor's parameters are checked against `deps`. + constructor's one parameter is checked against the services record `deps` + describes, so it destructures the same keys. - **`acquire`/`release`** come as a pair; neither exists without the other. `acquire` may fail exactly as `make` may. `release` runs during scope close, in reverse acquisition order; a failure in it is reported (see @@ -73,11 +75,14 @@ Notes per arm: Optional on **every** arm, supplied inline in the same options literal: ```ts -Provider(Cache)([AppConfig], { - make: (config) => connectCache(config), - onStart: (cache) => cache.warm(), - onStop: (cache) => cache.flush(), -}); +Provider(Cache)( + { config: AppConfig }, + { + make: ({ config }) => connectCache(config), + onStart: (cache) => cache.warm(), + onStop: (cache) => cache.flush(), + }, +); ``` | Hook | When | @@ -94,22 +99,21 @@ What `Provider(port)(…)` returns is `Provider & { readonly port: P }` — the port class, typed, rides on the provider. It exists for the helpers that hand back a provider on a port the application never declared — `Config.provider("Name")(schema)`, which mints one; a starter's -`HttpRouter(contract)(deps, arm)` / `TemporalActivities(…)` / +`HttpRouter(contract)({ name: Dep }, arm)` / `TemporalActivities(…)` / `AmqpHandlers(…)`, which target the starter's own fixed port — so the application holds one value and reads the port off it: `provider.port` is what another provider lists in its `deps`, what a module lists in `exports`, and what a hand-declared provider or a type test names. ```ts -const cacheProvider = Provider(Cache)([AppConfig], { - make: (config) => connectCache(config), -}); +const cacheProvider = Provider(Cache)( + { config: AppConfig }, + { make: ({ config }) => connectCache(config) }, +); const Warmer = Provider(Port("Warmer")<{ readonly go: () => void }>)( - [cacheProvider.port], - { - sync: (cache) => ({ go: () => void cache.warm() }), - }, + { cache: cacheProvider.port }, + { sync: ({ cache }) => ({ go: () => void cache.warm() }) }, ); ``` diff --git a/docs/reference/http.md b/docs/reference/http.md index e33bddf..5c10a5b 100644 --- a/docs/reference/http.md +++ b/docs/reference/http.md @@ -23,8 +23,8 @@ description: The HTTP starter — HttpModule, HttpRouter, HttpController, HttpAu | `HttpModule` | value | `HttpModule(name)({ router, authenticator?, prefix?, port?, hostname?, plugins?, securityHeaders?, imports?, provides?, exports? })` — a di `Module(name)({...})` that also takes the router provider; the composition root of an HTTP deployment | | `HttpModuleOptions` | type | The options object `HttpModule(name)` takes | | `HttpRouter` | value | `HttpRouter(contract)(deps, { sync })`, or `HttpRouter(contract)(controllers)` — the router as a provider on the starter's own router port, contract-first, either from one `sync` or from a keyed record of controllers | -| `HttpController` | value | `HttpController(name, fragment)([deps], { sync })` — one slice of a contract, as a provider on a port minted for it | -| `HttpAuthenticator` | value | `HttpAuthenticator

()([deps], { sync })` — the provider that turns a request's headers into a principal `P`, on `AuthenticatorPort` | +| `HttpController` | value | `HttpController(name, fragment)({ name: Dep }, { sync })` — one slice of a contract, as a provider on a port minted for it | +| `HttpAuthenticator` | value | `HttpAuthenticator

()({ name: Dep }, { sync })` — the provider that turns a request's headers into a principal `P`, on `AuthenticatorPort` | | `httpAuth` | value | `httpAuth()` — mints `HttpController`, `HttpRouter` and `HttpAuthenticator` fixed to the server's own identity; the only thing that gives a marked handler a readable `context.principal` | | `HttpAuth` | type | what `httpAuth()` returns — the three, as one type | | `HttpControllerOf` | type | `HttpControllerOf` — the annotation a file exporting the factory's `HttpController` needs | @@ -60,7 +60,7 @@ a plain module. | Option | Required | Default | What it is | | ----------------- | -------- | ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `router` | yes | — | the application's router **provider** — a `Provider`, what `HttpRouter(contract)(deps, arm)` returns; a provider on any other port fails at the call | -| `authenticator` | no\* | — | what `HttpAuthenticator

()([deps], { sync })` returns; \*owed whenever the contract marks anything (see [Authentication](#authentication)) | +| `authenticator` | no\* | — | what `HttpAuthenticator

()({ name: Dep }, { sync })` returns; \*owed whenever the contract marks anything (see [Authentication](#authentication)) | | `prefix` | no | `/rpc` | where the RPC endpoint is mounted; typed `` `/${string}` `` | | `port` | no | read from `PORT` | pins the port instead of reading it | | `hostname` | no | read from `HOST` | pins the host instead of reading it | @@ -110,11 +110,11 @@ There is no name to give: a process serves one router as it boots one runtime, so the port is the starter's — `Port("HttpRouter")`, declared once, framework-owned like `HttpConfig` — and two router providers in one graph are di's duplicate-provider defect at build. Returns -`Provider>, never, InstanceType> & { readonly port: PortClassOf<"HttpRouter", Router<…>> }` — +`Provider>, never, InstanceType> & { readonly port: PortClassOf<"HttpRouter", Router<…>> }` — `provider.port` is the port class, for a hand-declared provider or a type test. The implementation below is the one in `examples/order-api/src/slices/orders/controller.ts`, served through the -positional form — the example composes it as a controller instead (see the +deps form — the example composes it as a controller instead (see the keyed form), and a fragment is a contract, so the same `sync` reads either way. `contract.orders` is `authenticated`, so `HttpRouter` here is the application's own — `httpAuth()`'s, from its `src/auth.ts` — and the tenant comes @@ -122,9 +122,9 @@ off `context.principal` rather than off the input: ```ts export const ordersRouter = HttpRouter(contract.orders)( - [PlaceOrder, FindOrder], + { place: PlaceOrder, find: FindOrder }, { - sync: (place, find) => ({ + sync: ({ place, find }) => ({ place: ({ errors, context }, input) => place .execute(context.principal.tenantId, input.id, input.quantity) @@ -196,12 +196,13 @@ wrong key is rejected (its fragment does not match that key's); a procedure a controller's own fragment does not declare is rejected inside the controller, before the root ever sees it; and a slice lifts into a process of its own with its controller untouched — -`HttpRouter(contract.orders)([ordersController.port], { sync: (implementation) => implementation })` +`HttpRouter(contract.orders)({ implementation: ordersController.port }, { sync: ({ implementation }) => implementation })` compiles — the property a slice's independent deployability -rests on. The positional `(deps, { sync })` +rests on. The `(deps, { sync })` form is unchanged and stays correct for a small API — the two are -discriminated at the call the same way `Provider(port)(depsOrOptions, …)` -discriminates its own two forms. See +discriminated **by arity** at the call, the same way +`Provider(port)(depsOrOptions, …)` discriminates its own two forms, since a +deps record and a controllers record are both objects. See [Split a router into controllers](/how-to/split-a-router-into-controllers) for the worked recipe. @@ -211,24 +212,24 @@ the worked recipe. const HttpController: ( name: Name, fragment: C, -) => ( +) => >>( deps: D, options: { - readonly sync: ( - ...services: { [K in keyof D]: ServiceOf> } - ) => Implementation; + readonly sync: (services: { + readonly [K in keyof D]: ServiceOf>; + }) => Implementation; }, ) => Provider< PortInstance>, never, - InstanceType + InstanceType > & { readonly port: PortClassOf>; }; ``` One slice of a contract, as a provider over a port minted for it — the same -two-call shape as `HttpRouter(contract)(deps, { sync })`, aimed at a +two-call shape as `HttpRouter(contract)({ name: Dep }, { sync })`, aimed at a `fragment` rather than the whole contract. `fragment` is read for its **type** only: it shapes `sync`'s return, so a procedure the fragment does not declare, or a handler whose input or output has drifted, is a compile @@ -252,10 +253,8 @@ declares the controller's own port and hands back what it built: ```ts export const ordersRouter = HttpRouter(contract.orders)( - [ordersController.port], - { - sync: (implementation) => implementation, - }, + { implementation: ordersController.port }, + { sync: ({ implementation }) => implementation }, ); ``` @@ -287,7 +286,7 @@ the request off oRPC's initial context, calls the authenticator with its headers, and either injects `{ context: { principal } }` or terminates the request. -### `HttpAuthenticator

()([deps], { sync })` +### `HttpAuthenticator

()({ name: Dep }, { sync })` An ordinary di provider on `AuthenticatorPort`, whose service is `AuthenticatorService

`: @@ -305,7 +304,7 @@ provider's dependencies are. The type argument is **explicit** rather than inferred from `sync` — inference through a returned function's `AsyncResult` is where a principal silently widens to `unknown` — though in an application that has an `src/auth.ts` the argument is already fixed by `httpAuth()` -and the call is `HttpAuthenticator([deps], { sync })`. `Unauthenticated` is a +and the call is `HttpAuthenticator({ name: Dep }, { sync })`. `Unauthenticated` is a `TaggedError` with an **empty payload**: the starter surfaces no reason, so a field would be write-only. A rejected caller gets an `UNAUTHORIZED` and oRPC's default message; an authenticator that wants to record why logs it before @@ -313,21 +312,24 @@ returning. Forwarding a reason would put "no such user" versus "bad signature" in a 401 body by default. ```ts -export const bearerAuthenticator = HttpAuthenticator([], { - sync: () => (headers) => { - const header = headers.authorization ?? ""; - const token = header.startsWith("Bearer ") - ? header.slice("Bearer ".length) - : ""; - const [tenantId, userId] = token.split(":"); - return tenantId === undefined || - tenantId === "" || - userId === undefined || - userId === "" - ? ErrAsync(new Unauthenticated()) - : OkAsync({ tenantId, userId }); +export const bearerAuthenticator = HttpAuthenticator( + {}, + { + sync: () => (headers) => { + const header = headers.authorization ?? ""; + const token = header.startsWith("Bearer ") + ? header.slice("Bearer ".length) + : ""; + const [tenantId, userId] = token.split(":"); + return tenantId === undefined || + tenantId === "" || + userId === undefined || + userId === "" + ? ErrAsync(new Unauthenticated()) + : OkAsync({ tenantId, userId }); + }, }, -}); +); ``` ### `httpAuth()` — what the principal is, server-side @@ -354,7 +356,7 @@ export const HttpAuthenticator: HttpAuthenticatorOf = Every slice imports `HttpController` from there, and its marked handlers see `Identity` on `context.principal` with no annotation of their own; nothing else about a controller changes. The `HttpAuthenticator` handed back is already -applied, so it is called `HttpAuthenticator([deps], { sync })` — which is also +applied, so it is called `HttpAuthenticator({ name: Dep }, { sync })` — which is also why the authenticator and the controllers cannot disagree about the identity. It is per application rather than per slice because a handler's parameter types @@ -372,10 +374,11 @@ is a compile error. That is the signal to use the factory, not a fallback. ### Two gates, and why they are two -When the contract marks anything, `HttpRouter` appends `AuthenticatorPort` -**last** to the router provider's dependency array — so every existing -positional service keeps its index — and adds it to the provider's needs -channel. Which makes a marked router with no authenticator behind it di's +When the contract marks anything, `HttpRouter` adds `AuthenticatorPort` to the +router provider's deps record under a **namespaced** key +(`"@btravstack/http/authenticator"`, so it cannot collide with one you wrote), +strips it back out before your own `sync` sees the record, and adds it to the +provider's needs channel. Which makes a marked router with no authenticator behind it di's existing `UNSATISFIED DEPENDENCIES` gate at `start`, not a gate this package invented. diff --git a/docs/reference/temporal.md b/docs/reference/temporal.md index b016dfe..4c4164a 100644 --- a/docs/reference/temporal.md +++ b/docs/reference/temporal.md @@ -18,23 +18,23 @@ description: The Temporal worker starter — TemporalModule, TemporalActivities, `packages/temporal/src/index.ts` exports exactly this: -| Export | Kind | What it is | -| -------------------------------- | ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `TemporalModule` | value | `TemporalModule(name)({ contract, activities, workflows, address?, namespace?, gracePeriod?, forceAfter?, imports?, provides?, exports? })` — a di `Module(name)({...})` that also takes the activities provider | -| `TemporalModuleOptions` | type | The options object `TemporalModule(name)` takes | -| `TemporalActivities` | value | `TemporalActivities(contract)` — di's `Provider(port)` builder on the starter's own activities port, typed for `contract`, so the next call is `(deps, arm)`, or `([pieces])` to compose one provider per workflow | -| `ActivitiesPortOf` | type | The activities port's class typed for `C` — what a composed `orderActivities`'s `.port` is | -| `ActivitiesInstanceOf` | type | That port's instance typed for `C` | -| `TemporalWorkflowActivities` | value | `TemporalWorkflowActivities(contract, key)` — one workflow's activities (or a contract-global activity) as a provider of its own, typed by `key` alone; the next call is `(deps, arm)`, and the piece is what `TemporalActivities(contract)([...])` composes | -| `WorkflowActivitiesPortOf` | type | One piece's port class, typed for the one key `K` it implements | -| `temporal` | value | `temporal({ contract, workflows, … })` — the starter module itself, needing the activities port for `contract`; what `TemporalModule` imports | -| `TemporalOptions` | type | `temporal()`'s options | -| `TemporalRuntime` | value | `class TemporalRuntime extends RuntimePort> {}` — the runtime's port | -| `TemporalConfig` | value | `class TemporalConfig extends Port("TemporalConfig")<{ address: string; namespace: string }> {}` — where the service is, bound from the environment | -| `TemporalConnection` | value | `class TemporalConnection extends Port("TemporalConnection") {}` — the connection, a resource of the graph | -| `TemporalUnreachable` | value | `TaggedError("TemporalUnreachable")<{ address: string; cause: unknown }>` — the service did not answer | -| `TemporalInfo` | type | `{ readonly taskQueue: string; readonly namespace: string }` — published on `Serving.info` once polling | -| `WorkflowSource` | type | `{ workflowsPath: string } \| { workflowBundle: WorkflowBundleWithSourceMap }` — where the sandbox's code comes from | +| Export | Kind | What it is | +| -------------------------------- | ----- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `TemporalModule` | value | `TemporalModule(name)({ contract, activities, workflows, address?, namespace?, gracePeriod?, forceAfter?, imports?, provides?, exports? })` — a di `Module(name)({...})` that also takes the activities provider | +| `TemporalModuleOptions` | type | The options object `TemporalModule(name)` takes | +| `TemporalActivities` | value | `TemporalActivities(contract)` — di's `Provider(port)` builder on the starter's own activities port, typed for `contract`, so the next call is `({ name: Dep }, arm)`, or `([pieces])` to compose one provider per workflow | +| `ActivitiesPortOf` | type | The activities port's class typed for `C` — what a composed `orderActivities`'s `.port` is | +| `ActivitiesInstanceOf` | type | That port's instance typed for `C` | +| `TemporalWorkflowActivities` | value | `TemporalWorkflowActivities(contract, key)` — one workflow's activities (or a contract-global activity) as a provider of its own, typed by `key` alone; the next call is `({ name: Dep }, arm)`, and the piece is what `TemporalActivities(contract)([...])` composes | +| `WorkflowActivitiesPortOf` | type | One piece's port class, typed for the one key `K` it implements | +| `temporal` | value | `temporal({ contract, workflows, … })` — the starter module itself, needing the activities port for `contract`; what `TemporalModule` imports | +| `TemporalOptions` | type | `temporal()`'s options | +| `TemporalRuntime` | value | `class TemporalRuntime extends RuntimePort> {}` — the runtime's port | +| `TemporalConfig` | value | `class TemporalConfig extends Port("TemporalConfig")<{ address: string; namespace: string }> {}` — where the service is, bound from the environment | +| `TemporalConnection` | value | `class TemporalConnection extends Port("TemporalConnection") {}` — the connection, a resource of the graph | +| `TemporalUnreachable` | value | `TaggedError("TemporalUnreachable")<{ address: string; cause: unknown }>` — the service did not answer | +| `TemporalInfo` | type | `{ readonly taskQueue: string; readonly namespace: string }` — published on `Serving.info` once polling | +| `WorkflowSource` | type | `{ workflowsPath: string } \| { workflowBundle: WorkflowBundleWithSourceMap }` — where the sandbox's code comes from | `ActivitiesPortOf` / `ActivitiesInstanceOf` / `WorkflowActivitiesPortOf` are exported as **types only**, and only because declaration emit forces @@ -65,18 +65,18 @@ provider and the workflow source. It appends `imports`, prepends `activities` to `provides`, prepends `TemporalRuntime` to `exports`, and hands the augmented tuples to di's own `Module(name)`. -| Option | Required | Default | What it is | -| ------------- | -------- | ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `contract` | yes | — | a `temporal-contract` `ContractDefinition`; the task queue this worker polls is read off it | -| `activities` | yes | — | the activities **provider** — a `Provider, E, N>`, what `TemporalActivities(contract)(deps, arm)` returns for **this** `contract`; one built for another contract fails at the call | -| `workflows` | yes | — | a `WorkflowSource` | -| `address` | no | read from `TEMPORAL_ADDRESS` | pins `TemporalConfig.address` | -| `namespace` | no | read from `TEMPORAL_NAMESPACE` | pins `TemporalConfig.namespace` | -| `gracePeriod` | no | `"10 seconds"` | Temporal's `shutdownGraceTime`, a `Duration` | -| `forceAfter` | no | `"15 seconds"` | Temporal's `shutdownForceTime`, a `Duration`; keep it at or below the kernel's `drainTimeoutMs` | -| `imports` | no | `[]` | the application's modules | -| `provides` | no | `[]` | the application's own providers | -| `exports` | no | `[]` | the application's own exports; `TemporalRuntime` is added | +| Option | Required | Default | What it is | +| ------------- | -------- | ------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `contract` | yes | — | a `temporal-contract` `ContractDefinition`; the task queue this worker polls is read off it | +| `activities` | yes | — | the activities **provider** — a `Provider, E, N>`, what `TemporalActivities(contract)({ name: Dep }, arm)` returns for **this** `contract`; one built for another contract fails at the call | +| `workflows` | yes | — | a `WorkflowSource` | +| `address` | no | read from `TEMPORAL_ADDRESS` | pins `TemporalConfig.address` | +| `namespace` | no | read from `TEMPORAL_NAMESPACE` | pins `TemporalConfig.namespace` | +| `gracePeriod` | no | `"10 seconds"` | Temporal's `shutdownGraceTime`, a `Duration` | +| `forceAfter` | no | `"15 seconds"` | Temporal's `shutdownForceTime`, a `Duration`; keep it at or below the kernel's `drainTimeoutMs` | +| `imports` | no | `[]` | the application's modules | +| `provides` | no | `[]` | the application's own providers | +| `exports` | no | `[]` | the application's own exports; `TemporalRuntime` is added | The worked composition root, from `examples/order-temporal-worker/src/module.ts`: @@ -120,9 +120,15 @@ carries `chargeOrder` too, not `fulfillOrder` alone: ```ts export const orderActivities = TemporalActivities(orderContract)( - [PlaceOrder, OrderRepository, StockService, ShippingService, PaymentService], { - sync: (place, repository, stock, shipping, payments) => ({ + place: PlaceOrder, + repository: OrderRepository, + stock: StockService, + shipping: ShippingService, + payments: PaymentService, + }, + { + sync: ({ place, repository, stock, shipping, payments }) => ({ fulfillOrder: { place: (args, { errors }) => place @@ -189,10 +195,10 @@ refuses the module. A third call composes several **pieces** instead of one record: `TemporalActivities(contract)([piece, piece, ...])`, where each piece is what -`TemporalWorkflowActivities(contract, key)(deps, arm)` returns. Di constructs -every piece first — they are the composed provider's own `deps`, in array -order — and this call reassembles the activities record from them, keyed by -what each piece's port id carries. Every top-level key the contract's +`TemporalWorkflowActivities(contract, key)({ name: Dep }, arm)` returns. Di +constructs every piece first — they are the composed provider's own `deps`, +declared under the very key each piece's port id carries, so the services +record IS the activities record. Every top-level key the contract's activities record declares must be covered: an array missing one is refused at the call, against an `"UNCOVERED ACTIVITIES"` marker (`readonly ["UNCOVERED ACTIVITIES", ...]`) — the missing key itself is named @@ -232,29 +238,37 @@ di's own `Provider(port)`, so every arm is available exactly as it is on const orderFulfillment = TemporalWorkflowActivities( orderContract, "fulfillOrder", -)([PlaceOrder, OrderRepository, StockService, ShippingService], { - sync: (place, repository, stock, shipping) => ({ - place: (args, { errors }) => - place - .execute(args.orderId, args.quantity) - .map((order) => ({ id: order.id, quantity: order.quantity })) - .mapErrCases((matcher) => - matcher - .with(P.tag("InvalidQuantity"), (error) => - errors.InvalidQuantity({ id: error.id }), - ) - .with(P.tag("DuplicateOrder"), (error) => - errors.OrderAlreadyPlaced({ id: error.id }), - ), - ), - // … the rest of the record, one arm per activity `fulfillOrder` declares - }), -}); +)( + { + place: PlaceOrder, + repository: OrderRepository, + stock: StockService, + shipping: ShippingService, + }, + { + sync: ({ place, repository, stock, shipping }) => ({ + place: (args, { errors }) => + place + .execute(args.orderId, args.quantity) + .map((order) => ({ id: order.id, quantity: order.quantity })) + .mapErrCases((matcher) => + matcher + .with(P.tag("InvalidQuantity"), (error) => + errors.InvalidQuantity({ id: error.id }), + ) + .with(P.tag("DuplicateOrder"), (error) => + errors.OrderAlreadyPlaced({ id: error.id }), + ), + ), + // … the rest of the record, one arm per activity `fulfillOrder` declares + }), + }, +); const orderBilling = TemporalWorkflowActivities(orderContract, "chargeOrder")( - [PaymentService], + { payments: PaymentService }, { - sync: (payments) => ({ + sync: ({ payments }) => ({ authorizePayment: (args, { errors }) => payments .authorize(args.orderId, args.amount) diff --git a/docs/tutorial/getting-started.md b/docs/tutorial/getting-started.md index 25437ab..16e102f 100644 --- a/docs/tutorial/getting-started.md +++ b/docs/tutorial/getting-started.md @@ -106,11 +106,15 @@ import { OkAsync } from "unthrown"; import { contract } from "./contract.js"; import { Greeter } from "./greeter.js"; -export const greetingRouter = HttpRouter(contract)([Greeter], { - sync: (greeter) => ({ - hello: (_helpers, input) => OkAsync({ message: greeter.greet(input.name) }), - }), -}); +export const greetingRouter = HttpRouter(contract)( + { greeter: Greeter }, + { + sync: ({ greeter }) => ({ + hello: (_helpers, input) => + OkAsync({ message: greeter.greet(input.name) }), + }), + }, +); ``` Each leaf is a plain function returning a `Result`. `OkAsync` is the success From 07e9cd1df9aa32c5308bb0f5f17fd11a6579bcc2 Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Fri, 21 Aug 2026 00:11:49 +0200 Subject: [PATCH 17/19] refactor(http): give the three helpers a no-deps arm, as di's provider has MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A controller or an authenticator that calls nothing is the common shape in this package, not an edge case: `({}, { sync })` was the most repeated token in its type tests, 25 times across five files. HttpController, HttpAuthenticator and HttpRouter's deps arm now mirror `Provider(port)`'s own two overloads, discriminated by arity, with the no-deps factory taking no argument at all. AmqpHandler and TemporalWorkflowActivities already had it — they return di's build directly. HttpRouter is the one helper with three forms and two arguments' worth of arity, so it is the one place arity cannot decide alone: its two one-argument forms are told apart by whether `sync` holds a function, which is total rather than a heuristic, since a contract key called `sync` would hold a controller and a controller is never a function. This also makes the empty-record defect unreachable from a call site rather than merely guarded against; `provider.ts` says why the guard stays anyway. --- docs/examples/order-api.md | 40 +++---- docs/how-to/protect-a-procedure.md | 31 +++--- docs/reference/http.md | 37 +++---- examples/order-api/src/authenticator.ts | 21 ++-- examples/order-api/src/needs-gate.test-d.ts | 9 +- packages/di/CLAUDE.md | 18 ++- packages/di/src/provider.ts | 9 ++ packages/http/CLAUDE.md | 24 ++-- packages/http/README.md | 38 +++---- packages/http/src/auth.test-d.ts | 115 +++++++++----------- packages/http/src/auth.ts | 26 ++++- packages/http/src/controller.test-d.ts | 97 +++++++++-------- packages/http/src/controller.ts | 46 ++++++-- packages/http/src/orpc.ts | 43 +++++--- packages/http/src/test-fixtures.ts | 103 ++++++++---------- 15 files changed, 350 insertions(+), 307 deletions(-) diff --git a/docs/examples/order-api.md b/docs/examples/order-api.md index 09a9f08..897ef8b 100644 --- a/docs/examples/order-api.md +++ b/docs/examples/order-api.md @@ -156,24 +156,21 @@ import { ErrAsync, OkAsync } from "unthrown"; import { HttpAuthenticator } from "./auth.js"; -export const bearerAuthenticator = HttpAuthenticator( - {}, - { - sync: () => (headers) => { - const header = headers.authorization ?? ""; - const token = header.startsWith("Bearer ") - ? header.slice("Bearer ".length) - : ""; - const [tenantId, userId] = token.split(":"); - return tenantId === undefined || - tenantId === "" || - userId === undefined || - userId === "" - ? ErrAsync(new Unauthenticated()) - : OkAsync({ tenantId, userId }); - }, +export const bearerAuthenticator = HttpAuthenticator({ + sync: () => (headers) => { + const header = headers.authorization ?? ""; + const token = header.startsWith("Bearer ") + ? header.slice("Bearer ".length) + : ""; + const [tenantId, userId] = token.split(":"); + return tenantId === undefined || + tenantId === "" || + userId === undefined || + userId === "" + ? ErrAsync(new Unauthenticated()) + : OkAsync({ tenantId, userId }); }, -); +}); ``` `Bearer :` is a stand-in, not a recommendation — what matters @@ -565,12 +562,9 @@ authenticator discharges the need. `HttpModule` compares the router's identity against the authenticator's itself, at the option: ```ts -const wrongAuthenticator = HttpAuthenticator<{ readonly sub: string }>()( - {}, - { - sync: () => () => OkAsync({ sub: "s-1" }), - }, -); +const wrongAuthenticator = HttpAuthenticator<{ readonly sub: string }>()({ + sync: () => () => OkAsync({ sub: "s-1" }), +}); const _mismatchedApi = HttpModule("MismatchedApi")({ router: orderRouter, diff --git a/docs/how-to/protect-a-procedure.md b/docs/how-to/protect-a-procedure.md index fc2302b..4337453 100644 --- a/docs/how-to/protect-a-procedure.md +++ b/docs/how-to/protect-a-procedure.md @@ -113,24 +113,21 @@ import { ErrAsync, OkAsync } from "unthrown"; import { HttpAuthenticator } from "./auth.js"; -export const bearerAuthenticator = HttpAuthenticator( - {}, - { - sync: () => (headers) => { - const header = headers.authorization ?? ""; - const token = header.startsWith("Bearer ") - ? header.slice("Bearer ".length) - : ""; - const [tenantId, userId] = token.split(":"); - return tenantId === undefined || - tenantId === "" || - userId === undefined || - userId === "" - ? ErrAsync(new Unauthenticated()) - : OkAsync({ tenantId, userId }); - }, +export const bearerAuthenticator = HttpAuthenticator({ + sync: () => (headers) => { + const header = headers.authorization ?? ""; + const token = header.startsWith("Bearer ") + ? header.slice("Bearer ".length) + : ""; + const [tenantId, userId] = token.split(":"); + return tenantId === undefined || + tenantId === "" || + userId === undefined || + userId === "" + ? ErrAsync(new Unauthenticated()) + : OkAsync({ tenantId, userId }); }, -); +}); ``` Enriching what a deployment knows about its callers — roles, an org tier, an diff --git a/docs/reference/http.md b/docs/reference/http.md index 5c10a5b..48a4831 100644 --- a/docs/reference/http.md +++ b/docs/reference/http.md @@ -23,8 +23,8 @@ description: The HTTP starter — HttpModule, HttpRouter, HttpController, HttpAu | `HttpModule` | value | `HttpModule(name)({ router, authenticator?, prefix?, port?, hostname?, plugins?, securityHeaders?, imports?, provides?, exports? })` — a di `Module(name)({...})` that also takes the router provider; the composition root of an HTTP deployment | | `HttpModuleOptions` | type | The options object `HttpModule(name)` takes | | `HttpRouter` | value | `HttpRouter(contract)(deps, { sync })`, or `HttpRouter(contract)(controllers)` — the router as a provider on the starter's own router port, contract-first, either from one `sync` or from a keyed record of controllers | -| `HttpController` | value | `HttpController(name, fragment)({ name: Dep }, { sync })` — one slice of a contract, as a provider on a port minted for it | -| `HttpAuthenticator` | value | `HttpAuthenticator

()({ name: Dep }, { sync })` — the provider that turns a request's headers into a principal `P`, on `AuthenticatorPort` | +| `HttpController` | value | `HttpController(name, fragment)({ name: Dep }, { sync })`, or `({ sync })` with no deps — one slice of a contract, as a provider on a port minted for it | +| `HttpAuthenticator` | value | `HttpAuthenticator

()({ name: Dep }, { sync })`, or `({ sync })` with no deps — the provider that turns a request's headers into a principal `P`, on `AuthenticatorPort` | | `httpAuth` | value | `httpAuth()` — mints `HttpController`, `HttpRouter` and `HttpAuthenticator` fixed to the server's own identity; the only thing that gives a marked handler a readable `context.principal` | | `HttpAuth` | type | what `httpAuth()` returns — the three, as one type | | `HttpControllerOf` | type | `HttpControllerOf` — the annotation a file exporting the factory's `HttpController` needs | @@ -286,7 +286,7 @@ the request off oRPC's initial context, calls the authenticator with its headers, and either injects `{ context: { principal } }` or terminates the request. -### `HttpAuthenticator

()({ name: Dep }, { sync })` +### `HttpAuthenticator

()({ name: Dep }, { sync })` / `({ sync })` An ordinary di provider on `AuthenticatorPort`, whose service is `AuthenticatorService

`: @@ -312,24 +312,21 @@ returning. Forwarding a reason would put "no such user" versus "bad signature" in a 401 body by default. ```ts -export const bearerAuthenticator = HttpAuthenticator( - {}, - { - sync: () => (headers) => { - const header = headers.authorization ?? ""; - const token = header.startsWith("Bearer ") - ? header.slice("Bearer ".length) - : ""; - const [tenantId, userId] = token.split(":"); - return tenantId === undefined || - tenantId === "" || - userId === undefined || - userId === "" - ? ErrAsync(new Unauthenticated()) - : OkAsync({ tenantId, userId }); - }, +export const bearerAuthenticator = HttpAuthenticator({ + sync: () => (headers) => { + const header = headers.authorization ?? ""; + const token = header.startsWith("Bearer ") + ? header.slice("Bearer ".length) + : ""; + const [tenantId, userId] = token.split(":"); + return tenantId === undefined || + tenantId === "" || + userId === undefined || + userId === "" + ? ErrAsync(new Unauthenticated()) + : OkAsync({ tenantId, userId }); }, -); +}); ``` ### `httpAuth()` — what the principal is, server-side diff --git a/examples/order-api/src/authenticator.ts b/examples/order-api/src/authenticator.ts index 2856860..793dd9f 100644 --- a/examples/order-api/src/authenticator.ts +++ b/examples/order-api/src/authenticator.ts @@ -15,16 +15,13 @@ import { HttpAuthenticator } from "./auth.js"; * controllers are minted from — so a token resolving to the wrong shape is a * compile error here, and the handlers cannot be reading a different one. */ -export const bearerAuthenticator = HttpAuthenticator( - {}, - { - sync: () => (headers) => { - const header = headers.authorization ?? ""; - const token = header.startsWith("Bearer ") ? header.slice("Bearer ".length) : ""; - const [tenantId, userId] = token.split(":"); - return tenantId === undefined || tenantId === "" || userId === undefined || userId === "" - ? ErrAsync(new Unauthenticated()) - : OkAsync({ tenantId, userId }); - }, +export const bearerAuthenticator = HttpAuthenticator({ + sync: () => (headers) => { + const header = headers.authorization ?? ""; + const token = header.startsWith("Bearer ") ? header.slice("Bearer ".length) : ""; + const [tenantId, userId] = token.split(":"); + return tenantId === undefined || tenantId === "" || userId === undefined || userId === "" + ? ErrAsync(new Unauthenticated()) + : OkAsync({ tenantId, userId }); }, -); +}); diff --git a/examples/order-api/src/needs-gate.test-d.ts b/examples/order-api/src/needs-gate.test-d.ts index cf8fc36..2563f0b 100644 --- a/examples/order-api/src/needs-gate.test-d.ts +++ b/examples/order-api/src/needs-gate.test-d.ts @@ -94,12 +94,9 @@ const _missingAuthenticator = start(UnauthenticatedApi, options); // authenticator's itself, at the `HttpModule(...)` call, which is why this // directive sits on the option and not on a `start` below it. The contract // declares no principal to compare against; `./auth.ts` is what declares one. -const wrongAuthenticator = HttpAuthenticator<{ readonly sub: string }>()( - {}, - { - sync: () => () => OkAsync({ sub: "s-1" }), - }, -); +const wrongAuthenticator = HttpAuthenticator<{ readonly sub: string }>()({ + sync: () => () => OkAsync({ sub: "s-1" }), +}); const _mismatchedApi = HttpModule("MismatchedApi")({ router: orderRouter, diff --git a/packages/di/CLAUDE.md b/packages/di/CLAUDE.md index e505dfa..0db79f5 100644 --- a/packages/di/CLAUDE.md +++ b/packages/di/CLAUDE.md @@ -34,7 +34,23 @@ All runtime code lives in `packages/di/src`, one concept per file: mutually exclusive option arms: `value` / `sync` / `make` (fallible, returns `Result`) / `class` / `acquire`+`release` (resourceful — puts `Scope` in `Needs`). Exclusivity is enforced by giving each arm the other keys as optional - `never`. + `never`. Deps are a **record**, never an array or a parameter list, and the + factory receives one services record keyed the same way; a provider that + declares none omits the record entirely, which is what the two overloads' + arity discriminates. + + **The keyed form costs point-free, and that is accepted rather than a + formatting accident.** `Provider(OrderRepository)([Database], { sync: +prismaOrderRepository })` handed the factory straight to `sync`; the same + provider now has to spell `{ db: Database }, { sync: ({ db }) => +prismaOrderRepository(db) }` — an adapter factory takes the client, not a + record, so the wrapper arrow is unavoidable, and oxfmt then breaks the + two-argument call across lines. That is inherent to naming dependencies: + a name only exists at a call site if something writes it. One shape was the + decision (the library is experimental and two spellings of one idea is the + thing it declines to ship), so this is its price, not a bug to route around. + Do not reintroduce a positional arm to recover it. + - **`module.ts`** — the `Module` algebra. Three phantom channels with a deliberate variance rule: capability channels (`_exports`) are contravariant ("you may forget what you have"), obligation channels (`_error`, diff --git a/packages/di/src/provider.ts b/packages/di/src/provider.ts index f4c8d5e..15a3db0 100644 --- a/packages/di/src/provider.ts +++ b/packages/di/src/provider.ts @@ -208,6 +208,15 @@ const descriptor = ( // caller who writes `Provider(P)({}, { sync })` declared a record and the // types hand their factory one, so `keys.length === 0` is the wrong test and // handing that factory nothing is how it read `undefined` instead. + // + // The starters' own helpers (`HttpController`, `HttpRouter`, + // `HttpAuthenticator`) now mirror this arity discrimination, so no CALLER + // has to write `{}` any more. That does NOT make this distinction dead: + // `Provider(P)({}, arm)` is still a legal, typed call, the helpers still + // reach `build` with an empty record of their own (`HttpRouter` passes one + // whenever an unguarded router declares no deps), and the two questions are + // different anyway — the helpers decide what a caller may omit, this decides + // what a factory is handed. Do not delete one because the other exists. keys: readonly string[] | undefined, options: Record, ): Provider => { diff --git a/packages/http/CLAUDE.md b/packages/http/CLAUDE.md index 4fc59ed..16b04e3 100644 --- a/packages/http/CLAUDE.md +++ b/packages/http/CLAUDE.md @@ -102,10 +102,14 @@ PrincipalKey>>]: never }` — the same `Exclude` and the same `Inherit` the dropping the key, without the intersection leaking into `M` and collapsing the needs channel di orders the controllers by (the failure mode `controller.test-d.ts`'s `_ComposedNeedsAreDeclared` check exists to catch). - **Arity** discriminates this arm from the deps one — one argument versus - two — the same way `Provider(port)(depsOrOptions, …)` discriminates its own - two forms, since a deps record and a controllers record are both non-array - objects and there is nothing to sniff. `deps` for the underlying + **`HttpRouter` is the one helper in the family with THREE forms and only two + arguments' worth of arity**, so it is the one place arity alone cannot + decide. `(deps, arm)` is settled by arity as everywhere else; the two + one-argument forms — an arm, and a controllers record — are told apart by + whether **`sync` holds a function**. That is total rather than a heuristic: + this helper accepts no arm but `sync`, so a contract free to declare a key + called `sync` would put a _controller_ there, and a controller is an object + carrying a `.port`, never a function. `deps` for the underlying `Provider(HttpRouterPort)(...)` is the controllers record with each value replaced by its `.port`, so di builds every controller before the router — and the services record comes back keyed by the SAME contract keys, so the @@ -133,13 +137,17 @@ PrincipalKey>>]: never }` — the same `Exclude` and the same `Inherit` the Covered at runtime by the `rpcSliced` fixture, composing `helloController` and `echoesController` over `slicedContract`'s two fragments. -- **`HttpController(name, fragment)({ name: Dep }, { sync })`** (`controller.ts`) — +- **`HttpController(name, fragment)({ name: Dep }, { sync })`, or `({ sync })` + with no deps** (`controller.ts`) — one slice of a contract, as a provider on a port minted for it. The first call fixes `fragment`'s type — read for its type only, so a procedure the fragment does not declare or a handler whose input or output has drifted is a compile error inside the controller rather than at the root — and mints `class extends Port(name)> {}`; the second is di's - `Provider(port)({ name: Dep }, { sync })`, unchanged. Returns + `Provider(port)({ name: Dep }, { sync })`, unchanged — **including its + no-deps arm**, which this helper mirrors by arity for the same reason di + has one: a controller that calls no use case is the common shape here, not + an edge case, and `({}, { sync })` is what it would otherwise spell. Returns `Provider>, never, InstanceType> & { readonly port: PortClassOf> }` — the same `PortInstance`/`PortClassOf` spelling `HttpRouter` uses and for the @@ -170,7 +178,9 @@ InstanceType> & { readonly port: PortClassOf `auth.test-d.ts` because a `boolean` result would satisfy either. Pinned by `auth.test-d.ts`, mutation-checked. What makes the type true at runtime is `principalMiddleware`, below. -- **`HttpAuthenticator

()({ name: Dep }, { sync })`, `AuthenticatorPort`, +- **`HttpAuthenticator

()({ name: Dep }, { sync })` — or `({ sync })`, the + common shape, since an authenticator reading only headers declares no + dependencies — plus `AuthenticatorPort`, `Unauthenticated`, `AuthenticatorService

`** (`auth.ts`) — what an application provides so a marked procedure can name its caller. `AuthenticatorService

` is diff --git a/packages/http/README.md b/packages/http/README.md index 84026e1..9f0269f 100644 --- a/packages/http/README.md +++ b/packages/http/README.md @@ -139,7 +139,8 @@ const orderRouter = HttpRouter(orderContract)({ }); ``` -`HttpController(name, fragment)({ name: Dep }, { sync })` is the same two-call shape +`HttpController(name, fragment)({ name: Dep }, { sync })` — or just +`({ sync })` when the slice calls nothing — is the same two-call shape as `HttpRouter`, aimed at one fragment: it mints a port under `name` and returns the provider carrying it on `.port`. The keyed form is **exact** — a missing slice, an undeclared key and a controller under the wrong key are all @@ -203,26 +204,23 @@ const ordersContract = authenticated({ // verifier or a user directory is injected the way any provider's are. It // takes no type argument — `httpAuth()` already fixed one, which is // why the authenticator and the controllers cannot disagree. -const bearerAuthenticator = HttpAuthenticator( - {}, - { - sync: () => (headers) => { - const header = headers.authorization ?? ""; - const token = header.startsWith("Bearer ") - ? header.slice("Bearer ".length) - : ""; - const [tenantId, userId] = token.split(":"); - // Empty is not absent: `Authorization: :` splits into two defined strings, - // and admitting them is admitting an anonymous caller as tenant "". - return tenantId === undefined || - tenantId === "" || - userId === undefined || - userId === "" - ? ErrAsync(new Unauthenticated()) - : OkAsync({ tenantId, userId }); - }, +const bearerAuthenticator = HttpAuthenticator({ + sync: () => (headers) => { + const header = headers.authorization ?? ""; + const token = header.startsWith("Bearer ") + ? header.slice("Bearer ".length) + : ""; + const [tenantId, userId] = token.split(":"); + // Empty is not absent: `Authorization: :` splits into two defined strings, + // and admitting them is admitting an anonymous caller as tenant "". + return tenantId === undefined || + tenantId === "" || + userId === undefined || + userId === "" + ? ErrAsync(new Unauthenticated()) + : OkAsync({ tenantId, userId }); }, -); +}); // The principal arrives on oRPC's own context channel, typed by `Identity`. const ordersRouter = HttpRouter({ orders: ordersContract })( diff --git a/packages/http/src/auth.test-d.ts b/packages/http/src/auth.test-d.ts index 5e710f4..7a9941b 100644 --- a/packages/http/src/auth.test-d.ts +++ b/packages/http/src/auth.test-d.ts @@ -96,28 +96,19 @@ void _none; // unknown>`: the need cannot carry the identity, so only the options type // can compare it — the ROUTER's identity against the AUTHENTICATOR's, since // the contract declares none. -const markedRouter = IdentityRouter({ orders: contract.orders, health: contract.health })( - {}, - { - sync: () => ({ - orders: { place: ({ context }) => OkAsync({ id: context.principal.userId }) }, - health: { ping: () => OkAsync({ ok: true as const }) }, - }), - }, -); +const markedRouter = IdentityRouter({ orders: contract.orders, health: contract.health })({ + sync: () => ({ + orders: { place: ({ context }) => OkAsync({ id: context.principal.userId }) }, + health: { ping: () => OkAsync({ ok: true as const }) }, + }), +}); -const matching = IdentityAuthenticator( - {}, - { - sync: () => () => OkAsync({ userId: "u", tenantId: "t" }), - }, -); -const other = httpAuth<{ readonly sub: string }>().HttpAuthenticator( - {}, - { - sync: () => () => OkAsync({ sub: "s" }), - }, -); +const matching = IdentityAuthenticator({ + sync: () => () => OkAsync({ userId: "u", tenantId: "t" }), +}); +const other = httpAuth<{ readonly sub: string }>().HttpAuthenticator({ + sync: () => () => OkAsync({ sub: "s" }), +}); const options = { signals: false, probes: false } as const; @@ -141,12 +132,9 @@ const _wired = start(WiredApi, options); // 10. An unmarked router with an authenticator supplied is not this package's // error to raise: di decides, and a provider nothing needs is no defect. -const publicRouter = IdentityRouter({ health: contract.health })( - {}, - { - sync: () => ({ health: { ping: () => OkAsync({ ok: true as const }) } }), - }, -); +const publicRouter = IdentityRouter({ health: contract.health })({ + sync: () => ({ health: { ping: () => OkAsync({ ok: true as const }) } }), +}); const _public = start( HttpModule("Public")({ router: publicRouter, authenticator: matching }), options, @@ -165,12 +153,12 @@ void _public; // both of which the deps arm already did. `contract.orders` above // marks a KEY, so neither omission showed there. declare const ordersFragment: Authenticated<{ readonly whoami: typeof oc }>; -const rootOrders = IdentityController("RootOrders", ordersFragment)( - {}, - { - sync: () => ({ whoami: ({ context }) => OkAsync(context.principal.userId) }), - }, -); +const rootOrders = IdentityController( + "RootOrders", + ordersFragment, +)({ + sync: () => ({ whoami: ({ context }) => OkAsync(context.principal.userId) }), +}); const rootMarkedContract = authenticated({ orders: { whoami: oc } }); const _rootKeyed = HttpModule("RootKeyed")({ router: IdentityRouter(rootMarkedContract)({ orders: rootOrders }), @@ -186,42 +174,42 @@ void _rootKeyed; // 12. A factory-minted controller's MARKED handler sees the factory's identity, // a type the contract declares nowhere. -const scopedOrders = IdentityController("ScopedOrders", contract.orders)( - {}, - { - sync: () => ({ place: ({ context }) => OkAsync(context.principal.tenantId) }), - }, -); +const scopedOrders = IdentityController( + "ScopedOrders", + contract.orders, +)({ + sync: () => ({ place: ({ context }) => OkAsync(context.principal.tenantId) }), +}); // 13. The top-level `HttpController` mints no identity, so the same marked // fragment types `principal: never` — the "use the factory" signal, since // any read of it is a compile error. -void HttpController("ContractOrders", contract.orders)( - {}, - { - // @ts-expect-error — no factory, so there is no principal type to read - sync: () => ({ place: ({ context }) => OkAsync(context.principal.userId) }), - }, -); +void HttpController( + "ContractOrders", + contract.orders, +)({ + // @ts-expect-error — no factory, so there is no principal type to read + sync: () => ({ place: ({ context }) => OkAsync(context.principal.userId) }), +}); // 14. A factory invents no principal on an UNMARKED fragment: the identity // reaches a marked leaf and no other. -void IdentityController("ScopedHealth", contract.health)( - {}, - { - // @ts-expect-error — `principal` is not on an unmarked handler's context - sync: () => ({ ping: ({ context }) => OkAsync(context.principal.tenantId) }), - }, -); +void IdentityController( + "ScopedHealth", + contract.health, +)({ + // @ts-expect-error — `principal` is not on an unmarked handler's context + sync: () => ({ ping: ({ context }) => OkAsync(context.principal.tenantId) }), +}); // 15. A factory-minted router composes factory-minted controllers, and the // `HttpModule` gate checks the authenticator against the ROUTER's identity. -const scopedHealth = IdentityController("ScopedHealthOk", contract.health)( - {}, - { - sync: () => ({ ping: () => OkAsync({ ok: true as const }) }), - }, -); +const scopedHealth = IdentityController( + "ScopedHealthOk", + contract.health, +)({ + sync: () => ({ ping: () => OkAsync({ ok: true as const }) }), +}); const _scoped = HttpModule("Scoped")({ router: IdentityRouter({ orders: contract.orders, health: contract.health })({ orders: scopedOrders, @@ -233,12 +221,9 @@ const _scoped = HttpModule("Scoped")({ // 16. An authenticator minted on another identity is still refused, and a // hand-written `HttpAuthenticator

()` is no way around it. -const strayAuthenticator = HttpAuthenticator<{ readonly sub: string }>()( - {}, - { - sync: () => () => OkAsync({ sub: "s" }), - }, -); +const strayAuthenticator = HttpAuthenticator<{ readonly sub: string }>()({ + sync: () => () => OkAsync({ sub: "s" }), +}); const _strayScoped = HttpModule("StrayScoped")({ router: IdentityRouter({ orders: contract.orders, health: contract.health })({ orders: scopedOrders, diff --git a/packages/http/src/auth.ts b/packages/http/src/auth.ts index 5261e0e..9883483 100644 --- a/packages/http/src/auth.ts +++ b/packages/http/src/auth.ts @@ -47,23 +47,39 @@ export type AuthenticatorPort = PortInstance<"HttpAuthenticator", AuthenticatorS * export const jwtAuthenticator = HttpAuthenticator()({ verify: JwtVerifier }, { * sync: ({ verify }) => (headers) => verify(headers.authorization), * }); + * + * // An authenticator that reads nothing but the headers declares no deps: + * export const bearerAuthenticator = HttpAuthenticator()({ + * sync: () => (headers) => principalOf(headers.authorization), + * }); * ``` * * The type argument is explicit rather than inferred from `sync`: inference * through a returned function's `AsyncResult` is exactly where a `Principal` * silently widens to `unknown`, and the whole point is that it cannot. */ -export const HttpAuthenticator = -

() => - >>( +export const HttpAuthenticator =

() => { + // Two arms, discriminated by ARITY, mirroring `Provider(port)`'s own — an + // authenticator that reads only the request's headers declares no + // dependencies, which is the common shape rather than an edge case. + function build>>( deps: D, options: { readonly sync: (services: { readonly [K in keyof D]: ServiceOf>; }) => AuthenticatorService

; }, - ): Provider> & { readonly principal: P } => - Provider(AuthenticatorPort)(deps, options as never) as never; + ): Provider> & { readonly principal: P }; + function build(options: { + readonly sync: () => AuthenticatorService

; + }): Provider & { readonly principal: P }; + function build(depsOrOptions: unknown, options?: unknown): unknown { + return options === undefined + ? Provider(AuthenticatorPort)(depsOrOptions as never) + : Provider(AuthenticatorPort)(depsOrOptions as never, options as never); + } + return build; +}; /** * Unreachable today, and kept anyway. `routerOf` falls back to this when a diff --git a/packages/http/src/controller.test-d.ts b/packages/http/src/controller.test-d.ts index ab71ba8..c223775 100644 --- a/packages/http/src/controller.test-d.ts +++ b/packages/http/src/controller.test-d.ts @@ -11,18 +11,18 @@ import { HttpRouter } from "./orpc.js"; const contract = { orders: { place: oc }, users: { find: oc } }; -const orders = HttpController("GateOrders", contract.orders)( - {}, - { - sync: () => ({ place: () => OkAsync("placed") }), - }, -); -const users = HttpController("GateUsers", contract.users)( - {}, - { - sync: () => ({ find: () => OkAsync("found") }), - }, -); +const orders = HttpController( + "GateOrders", + contract.orders, +)({ + sync: () => ({ place: () => OkAsync("placed") }), +}); +const users = HttpController( + "GateUsers", + contract.users, +)({ + sync: () => ({ find: () => OkAsync("found") }), +}); // 1. Every contract key must be covered. // @ts-expect-error — `users` is missing from the record @@ -37,13 +37,13 @@ void HttpRouter(contract)({ orders, users, billing: orders }); void HttpRouter(contract)({ orders: users, users: orders }); // 4. A procedure the fragment does not declare is rejected inside the controller. -void HttpController("GateTypo", contract.orders)( - {}, - { - // @ts-expect-error — the fragment declares `place`, not `plce` - sync: () => ({ plce: () => OkAsync("placed") }), - }, -); +void HttpController( + "GateTypo", + contract.orders, +)({ + // @ts-expect-error — the fragment declares `place`, not `plce` + sync: () => ({ plce: () => OkAsync("placed") }), +}); // 5. A slice lifts out into its own process with its controller UNCHANGED: a // fragment is a valid contract in its own right, and the lifted root takes @@ -56,17 +56,18 @@ void HttpRouter(contract.orders)( { sync: ({ implementation }) => implementation }, ); -// The correct composition, and the deps form, both still compile. +// The correct composition and the ARM-ONLY form, over the same contract, both +// still compile. This pair is `HttpRouter`'s discrimination gate: it is the one +// helper in the family with three forms and two arguments' worth of arity, so +// these two one-argument calls are told apart by whether `sync` holds a +// function (orpc.ts). Break that and one of these two lines stops compiling. const composed = HttpRouter(contract)({ orders, users }); -void HttpRouter(contract)( - {}, - { - sync: () => ({ - orders: { place: () => OkAsync("placed") }, - users: { find: () => OkAsync("f") }, - }), - }, -); +void HttpRouter(contract)({ + sync: () => ({ + orders: { place: () => OkAsync("placed") }, + users: { find: () => OkAsync("f") }, + }), +}); // The composed provider must DECLARE its controllers as needs — if the // exactness intersection on the keyed `build` overload (orpc.ts) ever @@ -87,18 +88,18 @@ const { HttpController: IdentityController, HttpRouter: IdentityRouter } = httpA readonly userId: string; }>(); -const markedOrders = IdentityController("GateMarkedOrders", markedContract.orders)( - {}, - { - sync: () => ({ place: (opts) => OkAsync(opts.context.principal.userId) }), - }, -); -const markedUsers = IdentityController("GateMarkedUsers", markedContract.users)( - {}, - { - sync: () => ({ find: () => OkAsync("found") }), - }, -); +const markedOrders = IdentityController( + "GateMarkedOrders", + markedContract.orders, +)({ + sync: () => ({ place: (opts) => OkAsync(opts.context.principal.userId) }), +}); +const markedUsers = IdentityController( + "GateMarkedUsers", + markedContract.users, +)({ + sync: () => ({ find: () => OkAsync("found") }), +}); // 1. Every contract key must be covered. // @ts-expect-error — `users` is missing from the record @@ -117,13 +118,13 @@ void IdentityRouter(markedContract)({ void IdentityRouter(markedContract)({ orders: markedUsers, users: markedOrders }); // 4. A procedure the fragment does not declare is rejected inside the controller. -void IdentityController("GateMarkedTypo", markedContract.orders)( - {}, - { - // @ts-expect-error — the fragment declares `place`, not `plce` - sync: () => ({ plce: () => OkAsync("placed") }), - }, -); +void IdentityController( + "GateMarkedTypo", + markedContract.orders, +)({ + // @ts-expect-error — the fragment declares `place`, not `plce` + sync: () => ({ plce: () => OkAsync("placed") }), +}); // 5. The do-not-break lift, for a marked fragment. void IdentityRouter(markedContract.orders)( diff --git a/packages/http/src/controller.ts b/packages/http/src/controller.ts index 52c3c2a..3dcfe34 100644 --- a/packages/http/src/controller.ts +++ b/packages/http/src/controller.ts @@ -28,23 +28,45 @@ import type { Implementation } from "./orpc.js"; */ export const controllerFor = () => - (name: Name, contract: C) => - >>( - deps: D, - options: { - readonly sync: (services: { - readonly [K in keyof D]: ServiceOf>; - }) => Implementation; - }, - ): Provider>, never, InstanceType> & { - readonly port: PortClassOf>; - } => { + (name: Name, contract: C) => { // The parameter is named, not `_`-prefixed, so it reads as `contract` in the // published `.d.ts` and in an editor hint; nothing needs its value. void contract; // oxlint-disable-next-line typescript/no-extraneous-class -- a port is a phantom token; only a class expression carries the construct signature `PortClassOf` describes const port = class extends Port(name)> {}; - return Provider(port as never)(deps, options as never) as never; + + // Two arms, discriminated by ARITY, mirroring `Provider(port)`'s own — + // a controller that calls no use case is the common shape here, not an + // edge case, and `({}, { sync })` is what it would otherwise have to + // spell. Delegating both to di's `build` is also what keeps the + // no-deps factory taking no argument at all. + function build>>( + deps: D, + options: { + readonly sync: (services: { + readonly [K in keyof D]: ServiceOf>; + }) => Implementation; + }, + ): Provider< + PortInstance>, + never, + InstanceType + > & { + readonly port: PortClassOf>; + }; + function build(options: { readonly sync: () => Implementation }): Provider< + PortInstance>, + never, + never + > & { + readonly port: PortClassOf>; + }; + function build(depsOrOptions: unknown, options?: unknown): unknown { + return options === undefined + ? Provider(port as never)(depsOrOptions as never) + : Provider(port as never)(depsOrOptions as never, options as never); + } + return build; }; /** diff --git a/packages/http/src/orpc.ts b/packages/http/src/orpc.ts index 4dae1db..c5a80f3 100644 --- a/packages/http/src/orpc.ts +++ b/packages/http/src/orpc.ts @@ -151,6 +151,14 @@ export const routerFor = readonly port: PortClassOf<"HttpRouter", Router>>; readonly identity: Identity; }; + function build(options: { readonly sync: () => Implementation }): Provider< + PortInstance<"HttpRouter", Router>>, + never, + HasMark extends true ? AuthenticatorPort : never + > & { + readonly port: PortClassOf<"HttpRouter", Router>>; + readonly identity: Identity; + }; function build< M extends { readonly [K in Exclude]: ControllerFor< @@ -196,22 +204,27 @@ export const routerFor = ), ); - // ARITY discriminates the two forms, the same way - // `Provider(port)(depsOrOptions, …)` discriminates its own: a deps record - // and a controllers record are both non-array objects, so there is - // nothing to sniff. - if (options !== undefined) { + // THREE forms, two arguments' worth of arity — so this is the one place + // in the family that cannot discriminate on arity alone. `(deps, arm)` + // is settled by arity as everywhere else; the two one-argument forms — + // an arm, and a controllers record — are told apart by whether `sync` + // holds a FUNCTION. That is total rather than a heuristic: this helper + // accepts no arm but `sync`, and a contract free to declare a key called + // `sync` would put a *controller* there, which is an object carrying a + // `.port`, never a function. + const arm = (first: unknown): { readonly sync: (s: never) => unknown } | undefined => + typeof (first as { readonly sync?: unknown }).sync === "function" + ? (first as { readonly sync: (s: never) => unknown }) + : undefined; + const armOnly = options === undefined ? arm(depsOrControllers) : undefined; + if (options !== undefined || armOnly !== undefined) { + const supplied = (options ?? armOnly) as { + readonly sync: (s: Record) => unknown; + }; + const deps = armOnly === undefined ? (depsOrControllers as Record) : {}; const sync = (services: Record): Router> => - routerFrom( - (options as { readonly sync: (s: Record) => unknown }).sync( - own(services), - ) as Record, - services, - ); - return Provider(HttpRouterPort)( - withAuthenticator(depsOrControllers as Record), - { sync } as never, - ); + routerFrom(supplied.sync(own(services)) as Record, services); + return Provider(HttpRouterPort)(withAuthenticator(deps), { sync } as never); } // The controllers record is keyed by contract key, and so is the services diff --git a/packages/http/src/test-fixtures.ts b/packages/http/src/test-fixtures.ts index 572d7c7..73c4f44 100644 --- a/packages/http/src/test-fixtures.ts +++ b/packages/http/src/test-fixtures.ts @@ -115,12 +115,12 @@ export const helloController = HttpController("HelloController", helloFragment)( const slicedContract = oc.router({ greetings: helloFragment, echoes: nestedFragment }); /** The other half of `slicedContract`, alongside the reused `helloController`. */ -const echoesController = HttpController("EchoesController", nestedFragment)( - {}, - { - sync: () => ({ ping: () => OkAsync("pong") }), - }, -); +const echoesController = HttpController( + "EchoesController", + nestedFragment, +)({ + sync: () => ({ ping: () => OkAsync("pong") }), +}); /** The same kind of API as `greetingRouter`, composed from controllers instead of one `sync`. */ const slicedRouter = HttpRouter(slicedContract)({ @@ -164,41 +164,38 @@ const authedContract = { orders: authenticated({ whoami }), health: { ping } }; /** Counted so a test can assert the handler was never entered on a refusal. */ let authedRuns = 0; -const authedOrdersController = AuthedController("AuthedOrders", authedContract.orders)( - {}, - { - sync: () => ({ - whoami: ({ context }) => { - authedRuns += 1; - return OkAsync({ userId: context.principal.userId }); - }, - }), - }, -); +const authedOrdersController = AuthedController( + "AuthedOrders", + authedContract.orders, +)({ + sync: () => ({ + whoami: ({ context }) => { + authedRuns += 1; + return OkAsync({ userId: context.principal.userId }); + }, + }), +}); -const authedHealthController = AuthedController("AuthedHealth", authedContract.health)( - {}, - { - sync: () => ({ ping: () => OkAsync({ ok: true as const }) }), - }, -); +const authedHealthController = AuthedController( + "AuthedHealth", + authedContract.health, +)({ + sync: () => ({ ping: () => OkAsync({ ok: true as const }) }), +}); -const authenticator = AuthedAuthenticator( - {}, - { - sync: () => (headers) => { - if (headers.authorization === "Bearer boom") { - return OkAsync().map((): Identity => { - // oxlint-disable-next-line unthrown/no-throw -- an authenticator bug IS the subject under test, and a throw inside a combinator is the only way to mint a Defect - throw new Error("authenticator bug"); - }); - } - return headers.authorization === "Bearer good" - ? OkAsync({ tenantId: "t-good", userId: "u-good" }) - : ErrAsync(new Unauthenticated()); - }, +const authenticator = AuthedAuthenticator({ + sync: () => (headers) => { + if (headers.authorization === "Bearer boom") { + return OkAsync().map((): Identity => { + // oxlint-disable-next-line unthrown/no-throw -- an authenticator bug IS the subject under test, and a throw inside a combinator is the only way to mint a Defect + throw new Error("authenticator bug"); + }); + } + return headers.authorization === "Bearer good" + ? OkAsync({ tenantId: "t-good", userId: "u-good" }) + : ErrAsync(new Unauthenticated()); }, -); +}); const authedRouter = AuthedRouter(authedContract)({ orders: authedOrdersController, @@ -240,19 +237,16 @@ const rootMarkedContract = authenticated({ orders: { whoami } }); let rootMarkedRuns = 0; -const rootMarkedRouter = AuthedRouter(rootMarkedContract)( - {}, - { - sync: () => ({ - orders: { - whoami: ({ context }) => { - rootMarkedRuns += 1; - return OkAsync({ userId: context.principal.userId }); - }, +const rootMarkedRouter = AuthedRouter(rootMarkedContract)({ + sync: () => ({ + orders: { + whoami: ({ context }) => { + rootMarkedRuns += 1; + return OkAsync({ userId: context.principal.userId }); }, - }), - }, -); + }, + }), +}); const rpcRootMarkedAppOf = () => HttpModule("RpcRootMarkedApp")({ @@ -314,12 +308,9 @@ const corsContract = oc.router({ greet: oc.input(ocType<{ readonly name: string }>()).output(ocType()), }); -const corsRouter = HttpRouter(corsContract)( - {}, - { - sync: () => ({ greet: ({ input }) => OkAsync(`hello ${input.name}`) }), - }, -); +const corsRouter = HttpRouter(corsContract)({ + sync: () => ({ greet: ({ input }) => OkAsync(`hello ${input.name}`) }), +}); /** The same starter shape as `rpcAppOf`, with oRPC's CORS plugin configured. */ const rpcWithCorsAppOf = () => From fac8df77248344fa689cf437d75089949625495c Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Fri, 21 Aug 2026 00:17:07 +0200 Subject: [PATCH 18/19] test(http): the contract key that could confuse the router's discriminator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HttpRouter has three forms and two arguments' worth of arity, so one call has to be told apart by whether `sync` holds a function. The argument that this is total — a contract may name a key `sync`, but the value under it is a controller, an object carrying .port, never a function — was sound and untested. A contract whose top-level key is literally `sync`, composed through the keyed form. Mutation-checked against the simplification it guards: weakening the check to `"sync" in first` fails this test and only this test, where breaking the discriminator outright fails four. --- packages/http/src/controller.spec.ts | 18 ++++++++++++++++++ packages/http/src/test-fixtures.ts | 12 ++++++++++++ 2 files changed, 30 insertions(+) diff --git a/packages/http/src/controller.spec.ts b/packages/http/src/controller.spec.ts index 6175f1e..a3dd7a0 100644 --- a/packages/http/src/controller.spec.ts +++ b/packages/http/src/controller.spec.ts @@ -1,3 +1,4 @@ +import { OkAsync } from "unthrown"; import { describe, expect } from "vitest"; import { it } from "./test-fixtures.js"; @@ -15,6 +16,23 @@ describe("HttpController", () => { }).toEqual({ portId: "HelloController", deps: ["Greeter"] }); }); + it("keeps the keyed form when a contract names a key `sync`", async () => { + // GIVEN a contract whose top-level key is literally `sync` — the one input + // that could confuse `HttpRouter`'s runtime discriminator, which tells its + // arm-only form from its keyed-controllers form by whether `sync` holds a + // function + const { syncKeyedRouter } = await import("./test-fixtures.js"); + + // WHEN the router provider is constructed from its controller's service + const built = await syncKeyedRouter.construct([{ hello: () => OkAsync("hello world") }]); + + // THEN the keyed arm ran: the contract's `sync` key is a mounted procedure, + // not a factory the arm-only form would have called. A controller is an + // object carrying `.port`, never a function, which is what makes the check + // total rather than a heuristic + expect(built).toBeOkWith(expect.objectContaining({ sync: expect.anything() })); + }); + it("serves a router composed from several controllers", async ({ rpcSliced }) => { // GIVEN an API whose contract is implemented by two separate controllers const { client } = await rpcSliced(); diff --git a/packages/http/src/test-fixtures.ts b/packages/http/src/test-fixtures.ts index 73c4f44..df35ad2 100644 --- a/packages/http/src/test-fixtures.ts +++ b/packages/http/src/test-fixtures.ts @@ -122,6 +122,18 @@ const echoesController = HttpController( sync: () => ({ ping: () => OkAsync("pong") }), }); +/** + * A contract whose top-level key is literally `sync` — the one input that could + * confuse `HttpRouter`'s runtime discriminator, which tells its arm-only form + * from its keyed-controllers form by whether `sync` holds a function. A + * contract may name a key anything, so this is the adversarial case: the value + * under `sync` here is a CONTROLLER, an object carrying `.port`, and the check + * must keep picking the keyed arm. + */ +const syncKeyedContract = oc.router({ sync: helloFragment }); + +export const syncKeyedRouter = HttpRouter(syncKeyedContract)({ sync: helloController }); + /** The same kind of API as `greetingRouter`, composed from controllers instead of one `sync`. */ const slicedRouter = HttpRouter(slicedContract)({ greetings: helloController, From 85e744408960df9009ecd834033168eac5039790 Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Fri, 21 Aug 2026 00:31:11 +0200 Subject: [PATCH 19/19] fix(http): an arm-only router's sync is handed nothing, and the arms name one type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review found the arm-only form calling supplied.sync(own(services)) while its declared type is () => Implementation. JS ignores the extra argument, so nothing crashed — but it contradicts the arity guarantee this branch just gave a no-deps factory, and a rest parameter would see it. Nothing caught it either: reverting the fix left all 41 tests green, so there is a spec now, mutation- checked (always passing the record fails it and only it). Also from a complexity pass: HttpRouter's three overloads and HttpController's two spelled the same return type each time, differing only in the needs channel. One named alias per helper. Two findings from that pass I dropped rather than applied. Swapping the arity ternary for a spread saves a line and costs a cast — boring wins. And I priced the duplication at ~40 lines across three helpers; auth.ts's returns are one-liners, so it was two. --- packages/di/src/provider.ts | 29 +++++----------- packages/http/src/controller.spec.ts | 16 +++++++++ packages/http/src/controller.ts | 25 ++++++-------- packages/http/src/orpc.ts | 51 +++++++++++++++------------- packages/http/src/test-fixtures.ts | 16 +++++++++ 5 files changed, 78 insertions(+), 59 deletions(-) diff --git a/packages/di/src/provider.ts b/packages/di/src/provider.ts index 15a3db0..8de0530 100644 --- a/packages/di/src/provider.ts +++ b/packages/di/src/provider.ts @@ -12,13 +12,10 @@ type Deps = Readonly>; */ type ServicesOf = { readonly [K in keyof D]: ServiceOf }; -/** - * Internal: what the factory arms are spread over. A **one-element tuple**, so - * `Qualification`'s arms stay variadic (`(...args: Args) => S`) and become - * `(services: ServicesOf) => S` without a single arm changing — while the - * no-deps form keeps `readonly []`, and with it a factory of no arguments. - */ -type ArgsOf = readonly [ServicesOf]; +// Spread as a ONE-ELEMENT tuple below, which is what keeps `Qualification`'s +// arms variadic and unchanged: `(...args: [Services]) => S` IS +// `(services: Services) => S`, while no deps keeps `readonly []` and a factory +// of no arguments. /** Internal: the union of instance types a `deps` record requires. */ type NeedsOf = InstanceType; @@ -204,19 +201,9 @@ const descriptor = ( port: AnyPort, deps: readonly AnyPort[], // `undefined` for the no-deps overload, an array — possibly EMPTY — for the - // one that declares a record. The distinction is arity, not key count: a - // caller who writes `Provider(P)({}, { sync })` declared a record and the - // types hand their factory one, so `keys.length === 0` is the wrong test and - // handing that factory nothing is how it read `undefined` instead. - // - // The starters' own helpers (`HttpController`, `HttpRouter`, - // `HttpAuthenticator`) now mirror this arity discrimination, so no CALLER - // has to write `{}` any more. That does NOT make this distinction dead: - // `Provider(P)({}, arm)` is still a legal, typed call, the helpers still - // reach `build` with an empty record of their own (`HttpRouter` passes one - // whenever an unguarded router declares no deps), and the two questions are - // different anyway — the helpers decide what a caller may omit, this decides - // what a factory is handed. Do not delete one because the other exists. + // one that declares a record. Arity, not key count: `Provider(P)({}, arm)` + // declared a record and its factory is handed one, so `keys.length === 0` + // would be the wrong test. See `packages/di/CLAUDE.md`. keys: readonly string[] | undefined, options: Record, ): Provider => { @@ -278,7 +265,7 @@ export function Provider

>(port: P) { // hold: `provider.port` // is what another provider lists in its deps or a starter reads the port // off. Purely additive — the intersection is still a `Provider`. - function build, S>>( + function build], S>>( deps: D, options: O, ): Provider, ErrorOf, NeedsOf | ScopeOf> & { readonly port: P }; diff --git a/packages/http/src/controller.spec.ts b/packages/http/src/controller.spec.ts index a3dd7a0..d37fdc1 100644 --- a/packages/http/src/controller.spec.ts +++ b/packages/http/src/controller.spec.ts @@ -33,6 +33,22 @@ describe("HttpController", () => { expect(built).toBeOkWith(expect.objectContaining({ sync: expect.anything() })); }); + it("hands an arm-only router's sync no arguments", async () => { + // GIVEN an arm-only router whose `sync` records how many arguments it got. + // Its declared type is `() => Implementation`, and the whole point of the + // no-deps arm is that the runtime honours that + const { armOnlyRouterRecording } = await import("./test-fixtures.js"); + const { provider, arity } = armOnlyRouterRecording(); + + // WHEN the graph constructs it + await provider.construct([]); + + // THEN it was called with none — a record would be ignored by an arrow but + // seen by a rest parameter, and it would contradict the arity `Provider` + // guarantees a no-deps factory + expect(arity()).toBe(0); + }); + it("serves a router composed from several controllers", async ({ rpcSliced }) => { // GIVEN an API whose contract is implemented by two separate controllers const { client } = await rpcSliced(); diff --git a/packages/http/src/controller.ts b/packages/http/src/controller.ts index 3dcfe34..7177ba9 100644 --- a/packages/http/src/controller.ts +++ b/packages/http/src/controller.ts @@ -26,6 +26,13 @@ import type { Implementation } from "./orpc.js"; * and a consumer that exports the provider cannot emit its declaration (TS4023, * measured on `examples/order-api`). */ +/** What both arms of a minted controller return; `N` is the only thing that differs. */ +type Minted = Provider< + PortInstance>, + never, + N +> & { readonly port: PortClassOf> }; + export const controllerFor = () => (name: Name, contract: C) => { @@ -47,20 +54,10 @@ export const controllerFor = readonly [K in keyof D]: ServiceOf>; }) => Implementation; }, - ): Provider< - PortInstance>, - never, - InstanceType - > & { - readonly port: PortClassOf>; - }; - function build(options: { readonly sync: () => Implementation }): Provider< - PortInstance>, - never, - never - > & { - readonly port: PortClassOf>; - }; + ): Minted>; + function build(options: { + readonly sync: () => Implementation; + }): Minted; function build(depsOrOptions: unknown, options?: unknown): unknown { return options === undefined ? Provider(port as never)(depsOrOptions as never) diff --git a/packages/http/src/orpc.ts b/packages/http/src/orpc.ts index c5a80f3..9b24d00 100644 --- a/packages/http/src/orpc.ts +++ b/packages/http/src/orpc.ts @@ -124,6 +124,16 @@ export const orpc = (options: OrpcOptions = {}) => { * fragment is composed as-is rather than re-implemented, and every key of the * contract must be covered — a missing or extra key is a compile error. */ +/** What every `HttpRouter` arm returns; only the needs channel `N` differs. */ +type Built = Provider< + PortInstance<"HttpRouter", Router>>, + never, + N +> & { + readonly port: PortClassOf<"HttpRouter", Router>>; + readonly identity: Identity; +}; + export const routerFor = () => >(contract: C) => { @@ -143,22 +153,13 @@ export const routerFor = readonly [K in keyof D]: ServiceOf>; }) => Implementation; }, - ): Provider< - PortInstance<"HttpRouter", Router>>, - never, + ): Built< + Identity, InstanceType | (HasMark extends true ? AuthenticatorPort : never) - > & { - readonly port: PortClassOf<"HttpRouter", Router>>; - readonly identity: Identity; - }; - function build(options: { readonly sync: () => Implementation }): Provider< - PortInstance<"HttpRouter", Router>>, - never, - HasMark extends true ? AuthenticatorPort : never - > & { - readonly port: PortClassOf<"HttpRouter", Router>>; - readonly identity: Identity; - }; + >; + function build(options: { + readonly sync: () => Implementation; + }): Built extends true ? AuthenticatorPort : never>; function build< M extends { readonly [K in Exclude]: ControllerFor< @@ -170,14 +171,10 @@ export const routerFor = controllers: M & { readonly [K in Exclude>]: never; }, - ): Provider< - PortInstance<"HttpRouter", Router>>, - never, + ): Built< + Identity, InstanceType | (HasMark extends true ? AuthenticatorPort : never) - > & { - readonly port: PortClassOf<"HttpRouter", Router>>; - readonly identity: Identity; - }; + >; function build(depsOrControllers: unknown, options?: unknown): unknown { const guarded = hasMarked(contract); // The authenticator rides a NAMESPACED key on the deps record, for the @@ -222,8 +219,14 @@ export const routerFor = readonly sync: (s: Record) => unknown; }; const deps = armOnly === undefined ? (depsOrControllers as Record) : {}; - const sync = (services: Record): Router> => - routerFrom(supplied.sync(own(services)) as Record, services); + const sync = (services: Record): Router> => { + const call = supplied.sync as (...args: readonly unknown[]) => unknown; + // The arm-only form's `sync` is typed `() => …`, so it is handed + // nothing — the same arity guarantee `Provider` makes a no-deps + // factory, and the reason this cannot just always pass a record. + const built = armOnly === undefined ? call(own(services)) : call(); + return routerFrom(built as Record, services); + }; return Provider(HttpRouterPort)(withAuthenticator(deps), { sync } as never); } diff --git a/packages/http/src/test-fixtures.ts b/packages/http/src/test-fixtures.ts index df35ad2..d1759a6 100644 --- a/packages/http/src/test-fixtures.ts +++ b/packages/http/src/test-fixtures.ts @@ -134,6 +134,22 @@ const syncKeyedContract = oc.router({ sync: helloFragment }); export const syncKeyedRouter = HttpRouter(syncKeyedContract)({ sync: helloController }); +/** + * An arm-only router whose `sync` records its own arity. The arm-only form is + * typed `() => Implementation`, so the runtime must hand it nothing; passing a + * record would be invisible to an arrow and visible to a rest parameter. + */ +export const armOnlyRouterRecording = () => { + let seen = -1; + const provider = HttpRouter(oc.router({ greetings: helloFragment }))({ + sync: (...args: readonly unknown[]) => { + seen = args.length; + return { greetings: { hello: () => OkAsync("hello world") } }; + }, + } as never); + return { provider, arity: () => seen }; +}; + /** The same kind of API as `greetingRouter`, composed from controllers instead of one `sync`. */ const slicedRouter = HttpRouter(slicedContract)({ greetings: helloController,