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/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..897ef8b 100644 --- a/docs/examples/order-api.md +++ b/docs/examples/order-api.md @@ -156,7 +156,7 @@ import { ErrAsync, OkAsync } from "unthrown"; import { HttpAuthenticator } from "./auth.js"; -export const bearerAuthenticator = HttpAuthenticator([], { +export const bearerAuthenticator = HttpAuthenticator({ sync: () => (headers) => { const header = headers.authorization ?? ""; const token = header.startsWith("Bearer ") @@ -189,9 +189,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 +205,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 +313,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 +410,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,7 +562,7 @@ 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 }>()([], { +const wrongAuthenticator = HttpAuthenticator<{ readonly sub: string }>()({ sync: () => () => OkAsync({ sub: "s-1" }), }); 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..4337453 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,7 +113,7 @@ import { ErrAsync, OkAsync } from "unthrown"; import { HttpAuthenticator } from "./auth.js"; -export const bearerAuthenticator = HttpAuthenticator([], { +export const bearerAuthenticator = HttpAuthenticator({ sync: () => (headers) => { const header = headers.authorization ?? ""; const token = header.startsWith("Bearer ") @@ -173,45 +173,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..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)([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 })`, 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 | @@ -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 })` / `({ 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,7 +312,7 @@ returning. Forwarding a reason would put "no such user" versus "bad signature" in a 401 body by default. ```ts -export const bearerAuthenticator = HttpAuthenticator([], { +export const bearerAuthenticator = HttpAuthenticator({ sync: () => (headers) => { const header = headers.authorization ?? ""; const token = header.startsWith("Bearer ") @@ -354,7 +353,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 +371,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 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/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], }); 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(); + }, + }, +); 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..793dd9f 100644 --- a/examples/order-api/src/authenticator.ts +++ b/examples/order-api/src/authenticator.ts @@ -15,7 +15,7 @@ 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([], { +export const bearerAuthenticator = HttpAuthenticator({ sync: () => (headers) => { const header = headers.authorization ?? ""; const token = header.startsWith("Bearer ") ? header.slice("Bearer ".length) : ""; 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..2563f0b 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,7 +94,7 @@ 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 }>()([], { +const wrongAuthenticator = HttpAuthenticator<{ readonly sub: string }>()({ sync: () => () => OkAsync({ sub: "s-1" }), }); 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 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) }, +); 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) 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..5a29349 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, @@ -66,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/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", 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/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 })), + ), + }, + ); } 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], }); diff --git a/packages/di/CLAUDE.md b/packages/di/CLAUDE.md index cf61fbd..0db79f5 100644 --- a/packages/di/CLAUDE.md +++ b/packages/di/CLAUDE.md @@ -30,11 +30,27 @@ 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 - `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`, @@ -72,13 +88,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..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 () => { @@ -45,14 +53,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/provider.ts b/packages/di/src/provider.ts index c6a7cbf..8de0530 100644 --- a/packages/di/src/provider.ts +++ b/packages/di/src/provider.ts @@ -2,13 +2,23 @@ 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 }; + +// 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` array requires. */ -type NeedsOf = InstanceType; +/** 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 +200,35 @@ export type Provider = { const descriptor = ( port: AnyPort, deps: readonly AnyPort[], + // `undefined` for the no-deps overload, an array — possibly EMPTY — for the + // 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 => { // 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 === undefined + ? [] + : [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 +242,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 +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 }; @@ -250,18 +273,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, [], undefined, 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; 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], }); diff --git a/packages/http/CLAUDE.md b/packages/http/CLAUDE.md index c2bef5b..16b04e3 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** @@ -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,19 +102,25 @@ 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. + **`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 + 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 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 @@ -131,15 +137,19 @@ 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 })`, 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)(deps, { 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> }` — +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 @@ -168,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

()([deps], { 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 @@ -221,7 +233,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 @@ -274,9 +286,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 +435,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..9f0269f 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 @@ -136,13 +139,14 @@ const orderRouter = HttpRouter(orderContract)({ }); ``` -`HttpController(name, fragment)([deps], { 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 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). @@ -200,7 +204,7 @@ 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([], { +const bearerAuthenticator = HttpAuthenticator({ sync: () => (headers) => { const header = headers.authorization ?? ""; const token = header.startsWith("Bearer ") @@ -219,24 +223,27 @@ const bearerAuthenticator = HttpAuthenticator([], { }); // 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..7a9941b 100644 --- a/packages/http/src/auth.test-d.ts +++ b/packages/http/src/auth.test-d.ts @@ -96,17 +96,17 @@ 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 })([], { +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([], { +const matching = IdentityAuthenticator({ sync: () => () => OkAsync({ userId: "u", tenantId: "t" }), }); -const other = httpAuth<{ readonly sub: string }>().HttpAuthenticator([], { +const other = httpAuth<{ readonly sub: string }>().HttpAuthenticator({ sync: () => () => OkAsync({ sub: "s" }), }); @@ -132,7 +132,7 @@ 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 })([], { +const publicRouter = IdentityRouter({ health: contract.health })({ sync: () => ({ health: { ping: () => OkAsync({ ok: true as const }) } }), }); const _public = start( @@ -150,10 +150,13 @@ 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)([], { +const rootOrders = IdentityController( + "RootOrders", + ordersFragment, +)({ sync: () => ({ whoami: ({ context }) => OkAsync(context.principal.userId) }), }); const rootMarkedContract = authenticated({ orders: { whoami: oc } }); @@ -171,28 +174,40 @@ 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)([], { +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)([], { +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)([], { +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)([], { +const scopedHealth = IdentityController( + "ScopedHealthOk", + contract.health, +)({ sync: () => ({ ping: () => OkAsync({ ok: true as const }) }), }); const _scoped = HttpModule("Scoped")({ @@ -206,7 +221,7 @@ 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 }>()([], { +const strayAuthenticator = HttpAuthenticator<{ readonly sub: string }>()({ sync: () => () => OkAsync({ sub: "s" }), }); const _strayScoped = HttpModule("StrayScoped")({ @@ -223,17 +238,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 +271,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..9883483 100644 --- a/packages/http/src/auth.ts +++ b/packages/http/src/auth.ts @@ -44,8 +44,13 @@ 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), + * }); + * + * // An authenticator that reads nothing but the headers declares no deps: + * export const bearerAuthenticator = HttpAuthenticator()({ + * sync: () => (headers) => principalOf(headers.authorization), * }); * ``` * @@ -53,17 +58,28 @@ export type AuthenticatorPort = PortInstance<"HttpAuthenticator", AuthenticatorS * 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: { [K in keyof D]: ServiceOf> } - ) => AuthenticatorService

; + 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.spec.ts b/packages/http/src/controller.spec.ts index 6175f1e..d37fdc1 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,39 @@ 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("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.test-d.ts b/packages/http/src/controller.test-d.ts index 9902a85..c223775 100644 --- a/packages/http/src/controller.test-d.ts +++ b/packages/http/src/controller.test-d.ts @@ -11,10 +11,16 @@ import { HttpRouter } from "./orpc.js"; const contract = { orders: { place: oc }, users: { find: oc } }; -const orders = HttpController("GateOrders", contract.orders)([], { +const orders = HttpController( + "GateOrders", + contract.orders, +)({ sync: () => ({ place: () => OkAsync("placed") }), }); -const users = HttpController("GateUsers", contract.users)([], { +const users = HttpController( + "GateUsers", + contract.users, +)({ sync: () => ({ find: () => OkAsync("found") }), }); @@ -31,7 +37,10 @@ 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)([], { +void HttpController( + "GateTypo", + contract.orders, +)({ // @ts-expect-error — the fragment declares `place`, not `plce` sync: () => ({ plce: () => OkAsync("placed") }), }); @@ -42,12 +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 }); - -// The correct composition, and the positional form, both still compile. +void HttpRouter(contract.orders)( + { implementation: orders.port }, + { sync: ({ implementation }) => implementation }, +); + +// 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 @@ -69,10 +88,16 @@ const { HttpController: IdentityController, HttpRouter: IdentityRouter } = httpA readonly userId: string; }>(); -const markedOrders = IdentityController("GateMarkedOrders", markedContract.orders)([], { +const markedOrders = IdentityController( + "GateMarkedOrders", + markedContract.orders, +)({ sync: () => ({ place: (opts) => OkAsync(opts.context.principal.userId) }), }); -const markedUsers = IdentityController("GateMarkedUsers", markedContract.users)([], { +const markedUsers = IdentityController( + "GateMarkedUsers", + markedContract.users, +)({ sync: () => ({ find: () => OkAsync("found") }), }); @@ -93,15 +118,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)([], { +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..7177ba9 100644 --- a/packages/http/src/controller.ts +++ b/packages/http/src/controller.ts @@ -26,25 +26,44 @@ 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) => - ( - deps: D, - options: { - readonly sync: ( - ...services: { [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; + }, + ): 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) + : Provider(port as never)(depsOrOptions as never, options as never); + } + return build; }; /** 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/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..9b24d00 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,18 +111,29 @@ 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. */ +/** 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) => { @@ -131,21 +146,20 @@ 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) - > & { - readonly port: PortClassOf<"HttpRouter", Router>>; - readonly identity: Identity; - }; + ): Built< + Identity, + InstanceType | (HasMark extends true ? AuthenticatorPort : never) + >; + function build(options: { + readonly sync: () => Implementation; + }): Built extends true ? AuthenticatorPort : never>; function build< M extends { readonly [K in Exclude]: ControllerFor< @@ -157,59 +171,79 @@ 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 { - // 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); + + // 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> => { + 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); + } + + // 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 +257,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..d1759a6 100644 --- a/packages/http/src/test-fixtures.ts +++ b/packages/http/src/test-fixtures.ts @@ -92,29 +92,64 @@ 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)([], { +const echoesController = HttpController( + "EchoesController", + nestedFragment, +)({ 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 }); + +/** + * 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, @@ -157,7 +192,10 @@ 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)([], { +const authedOrdersController = AuthedController( + "AuthedOrders", + authedContract.orders, +)({ sync: () => ({ whoami: ({ context }) => { authedRuns += 1; @@ -166,11 +204,14 @@ const authedOrdersController = AuthedController("AuthedOrders", authedContract.o }), }); -const authedHealthController = AuthedController("AuthedHealth", authedContract.health)([], { +const authedHealthController = AuthedController( + "AuthedHealth", + authedContract.health, +)({ sync: () => ({ ping: () => OkAsync({ ok: true as const }) }), }); -const authenticator = AuthedAuthenticator([], { +const authenticator = AuthedAuthenticator({ sync: () => (headers) => { if (headers.authorization === "Bearer boom") { return OkAsync().map((): Identity => { @@ -190,17 +231,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,7 +265,7 @@ const rootMarkedContract = authenticated({ orders: { whoami } }); let rootMarkedRuns = 0; -const rootMarkedRouter = AuthedRouter(rootMarkedContract)([], { +const rootMarkedRouter = AuthedRouter(rootMarkedContract)({ sync: () => ({ orders: { whoami: ({ context }) => { @@ -263,12 +307,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,7 +336,7 @@ const corsContract = oc.router({ greet: oc.input(ocType<{ readonly name: string }>()).output(ocType()), }); -const corsRouter = HttpRouter(corsContract)([], { +const corsRouter = HttpRouter(corsContract)({ sync: () => ({ greet: ({ input }) => OkAsync(`hello ${input.name}`) }), }); @@ -312,12 +359,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 +536,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 +767,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), }); }, 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/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..32195fb 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. @@ -69,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 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", 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);