diff --git a/.changeset/declared-module-needs.md b/.changeset/declared-module-needs.md index 014daaa1..12509ef2 100644 --- a/.changeset/declared-module-needs.md +++ b/.changeset/declared-module-needs.md @@ -10,11 +10,12 @@ "@btravstack/amqp": minor --- -A module declares what it expects from outside +A module declares what its own providers expect from outside -`Module(name)({ … })` takes a fourth list, `needs`, and a port the module -depends on but neither provides nor imports must be named there. Anything it -owes and does not name is refused at that call, with the port in the message: +`Module(name)({ … })` takes a fourth list, `needs`. A port **this module's own +providers** read, and that nothing here satisfies, must be named there; anything +they owe and it does not name is refused at that call, with the port in the +message: ``` Property '"UNDECLARED NEEDS — name it in `needs`"' is missing in type @@ -32,9 +33,15 @@ directory could not be read on its own. does not have and now does not need: the port is named, the supplier is not, so the slice still composes into any root that answers it. +**An import's own needs are not the importer's to re-declare.** They are already +published in the import's type, and the entry point still refuses a root that +has not discharged them — so the declaration lands on the feature that reads the +port, once, rather than on every module between it and the root. That is +`ConfigModule.forFeature`'s shape reached without a global: `DatabaseModule` +says `needs: [Env]` because it reads `DATABASE_URL`, and the persistence modules +and slices that import it say nothing. + `Scope` is exempt — nothing can provide it, and the entry point discharges it. -`Env` is not: every module that reads the environment says `needs: [Env]`, and -so does every module that imports one, up to the root `start` hands one to. The three starter sugars — `HttpModule`, `AmqpModule`, `TemporalModule` — take `needs` too and re-declare the gate over their augmented tuples, so a diff --git a/CLAUDE.md b/CLAUDE.md index 44e1d7f1..f141b86b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -445,22 +445,21 @@ type checker already verifies. **`start`'s** gate (`order-api`, `order-temporal-worker`, `order-amqp-worker` — its `NO RUNTIME` arm, since no starter's runtime declares a `needs` any more; `order-api`'s also pins the `unit` halves) and - the **undeclared need** on the starter's port (a composition importing - `http()` / `temporal({ contract, workflows })` / `amqp({ contract })` without - providing the router / activities / handlers owes the starter's port, and - since #50 that is refused at the **module** rather than at `start` — di's - `NeedsGate`, measured: `Property '"UNDECLARED NEEDS — name it in `needs`"' is -missing … but required in type -'{ readonly "UNDECLARED NEEDS — name it in `needs`": HttpRouterPort; }'`. - Declaring it is not the escape either: each starter exports its port's TYPE - only, so an application has nothing to name and providing the router / - handlers / activities is the only way past); the fourth, - `order-application`'s, pins **di's** + the **unmet need** on the starter's port (a composition importing `http()` / + `temporal({ contract, workflows })` / `amqp({ contract })` without providing + the router / activities / handlers carries the starter's port in `Needs`, and + `start`'s `module` parameter takes only `Scope | Env`, so it fails to assign — + the starter is an IMPORT, and an import's needs travel without the importer + re-declaring them, so di's declaration gate has nothing to say and this stays + the kernel's); the fourth, `order-application`'s, pins **di's** `UNSATISFIED DEPENDENCIES` gate on `Module.scoped`, which is a rest-tuple - **arity** error printing `Expected 5 arguments, but got 2` and nothing else — - reached only once the module DECLARES what it owes, since an undeclared one - never gets that far. - **Four** different mechanisms now, easy to conflate — and only two print a + **arity** error printing `Expected 5 arguments, but got 2` and nothing else. + A **fourth** mechanism joined them in #50 and is pinned beside the third: + di's `NeedsGate`, which fires when a module's OWN provider reads a port + nothing local satisfies and `needs` does not name it — + `order-temporal-worker`'s `FulfillmentlessSlice`, printing + `'{ readonly "UNDECLARED NEEDS — name it in `needs`": StockService | ShippingService; }'`. + **Four** mechanisms, easy to conflate — and only two print a name. Do not call the second "di's `UNSATISFIED DEPENDENCIES` gate": an earlier revision of this file did, and it is wrong in both halves. `start`'s `UNSATISFIED RUNTIME NEEDS` arm is pinned only by `packages/core`'s own @@ -693,13 +692,16 @@ AuditSlice, observability()], … })`), Temporal workflow and its activities, an AMQP consumer and its handler — and (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. It also **declares what it expects from the root**, in `needs`: - `AuditSlice` is `needs: [Logger]`, `OrdersSlice` is `needs: [Env, Logger]`, - and a slice that owed a port and named none does not compile (#50, di's - `NeedsGate` — the full rule is in `packages/di/CLAUDE.md`). That is what - makes a slice directory readable on its own: it says which ports come from - outside without naming who supplies them, so the slice still composes into - any root that answers them. `@btravstack/http`'s + private. It also **declares what its own providers expect from the + root**, in `needs`: `AuditSlice` is `needs: [Logger]` because its handler + reads one, `OrdersSlice` is `needs: [Logger]` because its controller does, + and a slice whose provider owed a port and named none does not compile (#50, + di's `NeedsGate` — the full rule is in `packages/di/CLAUDE.md`). An + **import's** needs are not restated: `OrdersSlice` says nothing about `Env`, + because the module that reads `DATABASE_URL` is `DatabaseModule` and it says + so there. That is what makes a slice directory readable on its own — which + ports come from outside, without naming who supplies them — and what keeps a + `needs` list one line per feature instead of one per hop. `@btravstack/http`'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 diff --git a/README.md b/README.md index 78959624..c5f48f67 100644 --- a/README.md +++ b/README.md @@ -140,12 +140,10 @@ export const ordersRouter = HttpRouter(ordersContract)( ```ts // main.ts — the whole process. -import { Env } from "@btravstack/config"; import { runMain } from "@btravstack/core"; import { HttpModule } from "@btravstack/http"; const OrdersApi = HttpModule("OrdersApi")({ - needs: [Env], router: ordersRouter, imports: [OrderApplicationModule, OrderPersistenceModule], }); diff --git a/docs/examples/order-amqp-worker.md b/docs/examples/order-amqp-worker.md index 4cddb7dc..1ec6ff91 100644 --- a/docs/examples/order-amqp-worker.md +++ b/docs/examples/order-amqp-worker.md @@ -345,9 +345,7 @@ spelled with the `amqp()` primitive — the sugar cannot leave the handlers out, which is what it is for: ```ts -// @ts-expect-error — UNDECLARED NEEDS: the starter's handlers port. const HandlerlessAmqp = Module("HandlerlessAmqp")({ - needs: [Env], imports: [ OrderApplicationModule, OrderPersistenceModule, @@ -356,24 +354,23 @@ const HandlerlessAmqp = Module("HandlerlessAmqp")({ ], exports: [AmqpRuntime, PlaceOrder, Logger], }); + +// @ts-expect-error — the module's needs channel carries the handlers port, which nothing provides. +const _missingHandlers = start(HandlerlessAmqp, options); ``` Two different diagnostics, worth telling apart. The first is `start`'s marker: the module argument fails to match `Module<…> & "NO RUNTIME — the module exports no port declared over RuntimePort"`, -and the sentence is the last line. The second is di's own -[declaration gate](/explanation/modules-and-privacy): the handlers port is owed -here and not named in `needs`, so the module never gets as far as `start`, and -what prints ends on - -``` -'{ readonly "UNDECLARED NEEDS — name it in `needs`": HandlersInstanceOf<…>; }' -``` - -Declaring it is not the escape: `@btravstack/amqp` exports its handlers port's -TYPE only, so an application has nothing to name — providing the handlers is -the only way past, which is what the gate is for. Neither diagnostic is di's -`UNSATISFIED DEPENDENCIES` arity gate. +and the sentence is the last line. The second is the `Needs` channel: the +handlers port is owed by `amqp()`, an **import** — so di's +[declaration gate](/explanation/modules-and-privacy) has nothing to say, an +import's needs travel without being restated, and `start`'s `module` parameter +takes only `Scope | Env`, so what prints is +`Type 'HandlersInstanceOf<…>' is not assignable to type 'Env | Scope'` — wide, +because the contract expands, but ending on +`Type '"AmqpHandlers"' is not assignable to type '"@di/Scope"'`, which names the +port. Neither is di's `UNSATISFIED DEPENDENCIES` arity gate. ## Where to go next diff --git a/docs/examples/order-api.md b/docs/examples/order-api.md index b2075fdc..e83f0e29 100644 --- a/docs/examples/order-api.md +++ b/docs/examples/order-api.md @@ -347,7 +347,6 @@ composition root and one fewer import, not a rewrite. ```ts export const OrderApi = HttpModule("OrderApi")({ - needs: [Env], router: orderRouter, authenticator: bearerAuthenticator, imports: [OrdersSlice, CustomersSlice, observability()], @@ -369,9 +368,11 @@ happens to depend on: ```ts export const OrdersSlice = Module("OrdersSlice")({ - // The environment its persistence reads `DATABASE_URL` from, and the logger - // its interactors write to — both the root's to supply, both named here. - needs: [Env, Logger], + // The controller writes a line itself, so `Logger` is this slice's own + // provider's need. The environment its persistence reads `DATABASE_URL` from + // is not: that one is `DatabaseModule`'s, declared there and inherited + // through the imports below. + needs: [Logger], imports: [OrderApplicationModule, OrderPersistenceModule], provides: [ordersController], exports: [ordersController], @@ -546,27 +547,22 @@ marks `orders`, so a graph carrying the router without an authenticator has an unmet need too, and an arm that could fail either way pins neither gate. ```ts -// @ts-expect-error — UNDECLARED NEEDS: the starter's router port. const RouterlessApi = Module("RouterlessApi")({ - needs: [Env], imports: [OrdersSlice, CustomersSlice, observability(), http()], exports: [HttpRuntime, Logger], }); -``` -This one is di's own **declaration gate**, not the kernel's marker: `http()`'s -runtime provider depends on the starter's own router port, so a composition -that imports the starter without providing the router owes it — and owing a -port it does not name in `needs` is refused at the module, before `start` is -reached at all. What prints names the port: - -``` -'{ readonly "UNDECLARED NEEDS — name it in `needs`": HttpRouterPort; }' +// @ts-expect-error — the composition needs the router port and nothing provides it. +const _missingRouter = start(RouterlessApi, options); ``` -Naming it is not the escape: `@btravstack/http` exports that port's TYPE only, -so an application has nothing to write there — providing the router is the way -past, which is what the gate is for. It is **not** di's +This one is the **`Needs` channel**, not the kernel's marker and not di's +declaration gate either: the port is owed by `http()`, an **import**, and an +import's needs travel without the importer re-declaring them. `start` — whose +`module` parameter accepts only `Scope | Env` outstanding — is what refuses it, +and the diagnostic names the port: +`Type 'HttpRouterPort' is not assignable to type 'Env | Scope'`, down to +`Type '"HttpRouter"' is not assignable to type '"@di/Scope"'`. It is **not** di's `UNSATISFIED DEPENDENCIES` arity gate, which guards `Module.build` and `Module.scoped`; conflating the two is easy and the distinction is the point of having both pinned here. There is no `UNSATISFIED RUNTIME NEEDS` arm, because @@ -587,7 +583,6 @@ The last two are the authenticator's, and they are different gates on purpose: ```ts const UnauthenticatedApi = HttpModule("UnauthenticatedApi")({ - needs: [Env], router: orderRouter, imports: [OrdersSlice, CustomersSlice, observability()], exports: [Logger], @@ -609,7 +604,6 @@ const wrongAuthenticator = HttpAuthenticator<{ readonly sub: string }>()({ }); const _mismatchedApi = HttpModule("MismatchedApi")({ - needs: [Env], router: orderRouter, // @ts-expect-error — the authenticator resolves `{ sub }`, not the router's Identity. authenticator: wrongAuthenticator, diff --git a/docs/examples/order-temporal-worker.md b/docs/examples/order-temporal-worker.md index dcf9ca22..271ae8ed 100644 --- a/docs/examples/order-temporal-worker.md +++ b/docs/examples/order-temporal-worker.md @@ -87,7 +87,6 @@ export const orderActivities = TemporalActivities(orderContract)([ ]); export const OrderTemporalWorker = TemporalModule("OrderTemporalWorker")({ - needs: [Env], contract: orderContract, activities: orderActivities, workflows: { @@ -262,7 +261,6 @@ billing is never swapped: ```ts const worker = TemporalModule("StubTemporalWorker")({ - needs: [Env], contract, activities: orderActivities, workflows: { workflowBundle }, diff --git a/docs/explanation/compile-time-wiring.md b/docs/explanation/compile-time-wiring.md index a4a59d18..76f9dd9e 100644 --- a/docs/explanation/compile-time-wiring.md +++ b/docs/explanation/compile-time-wiring.md @@ -55,9 +55,10 @@ Persistence (provides Pool, exports OrderRepository) Needs: Scope ← Pool ne App (imports Persistence) Needs: Scope ← still unpaid ``` -An unpaid balance may only travel if the module **signed for it**. A module -that owes a port it neither provides nor imports has to name it in `needs`, and -one that does not is refused where it is written: +An unpaid balance run up by a module's **own providers** may only travel if that +module **signed for it**. A provider reading a port nothing here satisfies has +to be answered by a `needs` entry, and a module that does not is refused where +it is written: ``` Property '"UNDECLARED NEEDS — name it in `needs`"' is missing in type @@ -66,10 +67,10 @@ Property '"UNDECLARED NEEDS — name it in `needs`"' is missing in type ``` That is the first of the checks, and the only one that fires at a module rather -than at a call that builds one. `Scope` is exempt — nothing can provide it, so -it is never something an ancestor signs over. Everything else that survives the -subtraction has been declared on purpose, and travels to whoever composes the -module. +than at a call that builds one. It reads a module's own providers alone: a +balance inherited from an **import** travels without being signed for again, +because it is already published in that import's type. `Scope` is exempt — +nothing can provide it, so it is never something an ancestor signs over. The remaining checks happen at the one place a graph becomes running services. diff --git a/docs/explanation/modules-and-privacy.md b/docs/explanation/modules-and-privacy.md index 1787faf7..2dd1a956 100644 --- a/docs/explanation/modules-and-privacy.md +++ b/docs/explanation/modules-and-privacy.md @@ -62,7 +62,7 @@ needs `@Global`, a way for a cross-cutting module to be visible without being imported. `di` splits that differently. A module may be handed a port by whoever composes -it — but only one it **asked for by name**: +it — but only one **its own providers asked for by name**: ```ts export const AuditSlice = Module("AuditSlice")({ @@ -72,26 +72,49 @@ export const AuditSlice = Module("AuditSlice")({ }); ``` -`needs` does not make `Logger` visible the way an import would, and it does not -manufacture an obligation for a root that owes nothing. It says: _this module -depends on a `Logger` it does not build, and something above it has to_. Leave -it out and the module does not compile at all — the diagnostic names the port — -so a slice can never quietly absorb whatever the composition root happens to be -holding. - -That is the whole difference from `@Global`. A global module is invisible -plumbing: a slice benefits from it without mentioning it, and reading -`slices/audit/` still tells you nothing about where its logger comes from. A -declared need is the opposite — the slice states the port and stays silent -about the supplier, which is exactly the pair that lets it be recomposed. The -same `AuditSlice` drops into a different root, or -[lifts into a process of its own](/how-to/split-a-router-into-controllers), -with no edit: any root that answers `Logger` will do. +The provider may depend on that `Logger` and will be handed whatever an ancestor +supplies. What `needs` does **not** do is provide the port here: it stays +outside what the module can see, so it cannot be exported, and it manufactures +no obligation for a root that owes nothing. It says: _the provider in this +module depends on a `Logger` it does not build, and something above it has to_. Leave it out and the module does not compile at all — the diagnostic +names the port — so a slice can never quietly absorb whatever the composition +root happens to be holding. + +**An import's needs travel on their own.** A module that merely imports +`AuditSlice` does not restate `Logger`: the obligation is already in +`AuditSlice`'s type, at the `imports` entry a reader is looking at, and the +[entry point](/reference/di/entry-points) still refuses a root that has not +discharged it. Restating it at every level would put one line on every module +between the reader of a port and the root that supplies it — for `Env`, that +was six declarations in the order API and only one of them a module that reads +an environment variable. + +So the declaration lands where the feature is: + +```ts +// reads DATABASE_URL — declares it +const DatabaseModule = Module("Database")({ + needs: [Env], + provides: [databaseConfig, orderDatabaseProvider], + exports: [OrderDatabase], +}); -The cost is one line per module, and it compounds — `Env` is declared by every -module that reads the environment, and again by every module that imports one -of those, up to the root that `start` hands one to. That chain is the thing a -`@Global` would have hidden, and seeing it is the point. +// only imports it — declares nothing +export const OrderPersistenceModule = Module("OrderPersistence")({ + imports: [DatabaseModule], + provides: [orderRepositoryProvider, outboxProvider], + exports: [OrderRepository, Outbox], +}); +``` + +That is NestJS's `ConfigModule.forFeature` shape without a global to reach it +through — and it is the whole difference from `@Global`. A global module is +invisible plumbing: a slice benefits from it without mentioning it, and reading +`slices/audit/` still tells you nothing. A declared need is the opposite — the +slice states the port and stays silent about the supplier, which is exactly the +pair that lets it be recomposed. The same `AuditSlice` drops into a different +root, or [lifts into a process of its own](/how-to/split-a-router-into-controllers), +with no edit: any root that answers `Logger` will do. `Scope` is the one exemption, and it is forced rather than chosen: nothing can provide `Scope` — a provider for it is a diff --git a/docs/explanation/starters.md b/docs/explanation/starters.md index 5246008f..5f2d84eb 100644 --- a/docs/explanation/starters.md +++ b/docs/explanation/starters.md @@ -90,7 +90,6 @@ spelled once. From [`examples/order-api`](/examples/order-api): ```ts export const OrderApi = HttpModule("OrderApi")({ - needs: [Env], router: orderRouter, imports: [OrderApplicationModule, OrderPersistenceModule, observability()], exports: [Logger], diff --git a/docs/how-to/log-and-correlate.md b/docs/how-to/log-and-correlate.md index d7a77878..c7063d73 100644 --- a/docs/how-to/log-and-correlate.md +++ b/docs/how-to/log-and-correlate.md @@ -28,13 +28,11 @@ happened. The recipe is one import. `StartOptions.unit` module, a test. ```ts -import { Env } from "@btravstack/config"; import { Module, Provider } from "@btravstack/di"; import { HttpModule } from "@btravstack/http"; import { Logger, observability } from "@btravstack/observability"; export const OrderApi = HttpModule("OrderApi")({ - needs: [Env], router: orderRouter, imports: [OrderApplicationModule, OrderPersistenceModule, observability()], exports: [Logger], @@ -251,7 +249,6 @@ values: const lines: Line[] = []; const RecordingApi = HttpModule("RecordingApi")({ - needs: [Env], router: orderRouter, imports: [ OrderApplicationModule, diff --git a/docs/how-to/open-a-per-request-scope.md b/docs/how-to/open-a-per-request-scope.md index 7cb7038d..7090b8b7 100644 --- a/docs/how-to/open-a-per-request-scope.md +++ b/docs/how-to/open-a-per-request-scope.md @@ -131,7 +131,6 @@ the last line of the error: ```ts const UnloggedApi = Module("UnloggedApi")({ - needs: [Env], imports: [ OrderApplicationModule, OrderPersistenceModule, diff --git a/docs/how-to/protect-a-procedure.md b/docs/how-to/protect-a-procedure.md index bfd5dde2..fa2dc937 100644 --- a/docs/how-to/protect-a-procedure.md +++ b/docs/how-to/protect-a-procedure.md @@ -251,7 +251,6 @@ answer per process. ```ts export const OrderApi = HttpModule("OrderApi")({ - needs: [Env], router: orderRouter, authenticator: bearerAuthenticator, imports: [OrdersSlice, CustomersSlice, observability()], diff --git a/docs/how-to/run-a-temporal-worker.md b/docs/how-to/run-a-temporal-worker.md index e7d72aa1..c7a7c38f 100644 --- a/docs/how-to/run-a-temporal-worker.md +++ b/docs/how-to/run-a-temporal-worker.md @@ -139,7 +139,6 @@ may re-run has to answer the same both times. ## Step 2 — the composition root ```ts -import { Env } from "@btravstack/config"; import { OrderApplicationModule } from "@btravstack/example-order-application"; import { OrderPersistenceModule } from "@btravstack/example-order-infrastructure"; import { orderContract } from "@btravstack/example-order-temporal-contract"; @@ -152,7 +151,6 @@ import { BillingModule } from "./billing.js"; import { FulfillmentModule } from "./fulfillment.js"; export const OrderTemporalWorker = TemporalModule("OrderTemporalWorker")({ - needs: [Env], contract: orderContract, activities: orderActivities, workflows: { @@ -217,7 +215,6 @@ environment beats default, per field: ```ts export const Pinned = TemporalModule("OrderTemporalWorkerLocal")({ - needs: [Env], contract: orderContract, activities: orderActivities, workflows: { diff --git a/docs/how-to/serve-orpc-over-http.md b/docs/how-to/serve-orpc-over-http.md index 1cfe5dc1..f1c7224a 100644 --- a/docs/how-to/serve-orpc-over-http.md +++ b/docs/how-to/serve-orpc-over-http.md @@ -171,7 +171,6 @@ authenticator below. ## Step 3 — the composition root ```ts -import { Env } from "@btravstack/config"; import { OrderApplicationModule } from "@btravstack/example-order-application"; import { OrderPersistenceModule } from "@btravstack/example-order-infrastructure"; import { HttpModule } from "@btravstack/http"; @@ -181,7 +180,6 @@ import { bearerAuthenticator } from "./authenticator.js"; import { ordersRouter } from "./router.js"; export const OrdersApi = HttpModule("OrdersApi")({ - needs: [Env], router: ordersRouter, authenticator: bearerAuthenticator, imports: [OrderApplicationModule, OrderPersistenceModule, observability()], diff --git a/docs/how-to/split-a-router-into-controllers.md b/docs/how-to/split-a-router-into-controllers.md index f60a81ea..01ef5af2 100644 --- a/docs/how-to/split-a-router-into-controllers.md +++ b/docs/how-to/split-a-router-into-controllers.md @@ -170,9 +170,11 @@ same privacy di already gives any provider: ```ts export const OrdersSlice = Module("OrdersSlice")({ - // The environment its persistence reads `DATABASE_URL` from, and the logger - // its interactors write to — both the root's to supply, both named here. - needs: [Env, Logger], + // The controller writes a line itself, so `Logger` is this slice's own + // provider's need. The environment its persistence reads `DATABASE_URL` from + // is not: that one is `DatabaseModule`'s, declared there and inherited + // through the imports below. + needs: [Logger], imports: [OrderApplicationModule, OrderPersistenceModule], provides: [ordersController], exports: [ordersController], @@ -209,7 +211,6 @@ owns: ```ts export const OrderApi = HttpModule("OrderApi")({ - needs: [Env], router: orderRouter, authenticator: bearerAuthenticator, imports: [OrdersSlice, CustomersSlice, observability()], @@ -247,7 +248,6 @@ export const ordersRouter = HttpRouter(contract.orders)( ); export const OrdersApi = HttpModule("OrdersApi")({ - needs: [Env], router: ordersRouter, authenticator: bearerAuthenticator, imports: [OrdersSlice, observability()], diff --git a/docs/how-to/split-a-worker-into-slices.md b/docs/how-to/split-a-worker-into-slices.md index 72d24408..b8383033 100644 --- a/docs/how-to/split-a-worker-into-slices.md +++ b/docs/how-to/split-a-worker-into-slices.md @@ -119,8 +119,6 @@ export const NotificationsSlice = Module("NotificationsSlice")({ }); export const BillingSlice = Module("BillingSlice")({ - // What the slice expects from the root, named rather than absorbed. - needs: [Logger], imports: [BillingModule], provides: [chargeOrder], exports: [chargeOrder], diff --git a/docs/how-to/test-an-application.md b/docs/how-to/test-an-application.md index 58f543bb..06255728 100644 --- a/docs/how-to/test-an-application.md +++ b/docs/how-to/test-an-application.md @@ -138,7 +138,6 @@ string. Compose the root's own shape with a recording sink, and boot that: const lines: Line[] = []; const recordingApi = HttpModule("RecordingApi")({ - needs: [Env], router: orderRouter, // The same authenticator as the real root: the contract marks `orders`, so // every composition serving that router owes one. diff --git a/docs/index.md b/docs/index.md index 9efed2ab..5a654d47 100644 --- a/docs/index.md +++ b/docs/index.md @@ -38,7 +38,6 @@ composition root, and one call. The example itself composes that slice and a [controllers](/how-to/split-a-router-into-controllers). ```ts -import { Env } from "@btravstack/config"; import { authenticated } from "@btravstack/contract"; import { runMain } from "@btravstack/core"; import { HttpModule } from "@btravstack/http"; @@ -111,7 +110,6 @@ const ordersRouter = HttpRouter(ordersContract)( // The composition root. The runtime is a service of this module. const OrdersApi = HttpModule("OrdersApi")({ - needs: [Env], router: ordersRouter, authenticator: bearerAuthenticator, imports: [OrderApplicationModule, OrderPersistenceModule], diff --git a/docs/reference/config.md b/docs/reference/config.md index b86600d5..5f9f94e5 100644 --- a/docs/reference/config.md +++ b/docs/reference/config.md @@ -139,7 +139,7 @@ The port is built with the rest of the graph, so a bad environment is a modeled startup `Err` in the module's own error channel, still typed. ```ts -import { Config } from "@btravstack/config"; +import { Config, Env } from "@btravstack/config"; import { Module, Port, Provider } from "@btravstack/di"; class Database extends Port("Database")<{ readonly url: string }> {} diff --git a/docs/reference/di/modules.md b/docs/reference/di/modules.md index 40e1a13c..71dd9f3c 100644 --- a/docs/reference/di/modules.md +++ b/docs/reference/di/modules.md @@ -34,12 +34,12 @@ const Persistence = Module("Persistence")({ All four lists are optional and default to empty. -| List | Contents | -| ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `imports` | Modules whose **exports** become visible inside this one. A diamond — two imports that both import a third — is fine: providers are de-duplicated by reference at build time, so the shared module's services construct once. | -| `provides` | This module's own providers. A provider here may depend on anything **available** in this module: ports provided here, plus ports exported by the imports. Order within the list does not matter for correctness — dependency order is computed at build time — but it is what makes error selection deterministic when several fail at once. | -| `needs` | The ports this module expects a composition root to supply — what it depends on and does not satisfy itself. Declaring one does not make it available inside the module and does not manufacture an obligation: it is permission for the need to travel outward. Anything the module owes and does not name here is a compile error **at this call** (below). `Scope` is exempt — nothing can provide it. | -| `exports` | The ports outside code may see. Each entry must be an **available port** — provided here, or exported by an import — a **provider for one**, which is normalised to `provider.port`, or an **imported module**, a whole-module re-export forwarding that module's own `exports` (never its internals). Anything else is a compile error at the declaration. | +| List | Contents | +| ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `imports` | Modules whose **exports** become visible inside this one. A diamond — two imports that both import a third — is fine: providers are de-duplicated by reference at build time, so the shared module's services construct once. | +| `provides` | This module's own providers. A provider here may depend on anything **available** in this module: ports provided here, plus ports exported by the imports. Order within the list does not matter for correctness — dependency order is computed at build time — but it is what makes error selection deterministic when several fail at once. | +| `needs` | The ports **this module's own providers** expect a composition root to supply. A provider here may depend on a declared need and have it satisfied by whatever an ancestor supplies; what declaring does **not** do is provide the port locally, so it is not `Available` and cannot be exported. Nor does it manufacture an obligation — it is permission for a real one to travel outward. Anything this module's providers owe and it does not name here is a compile error **at this call** (below). An **import's** own needs travel without being restated — they are already in that import's type. `Scope` is exempt: nothing can provide it. | +| `exports` | The ports outside code may see. Each entry must be an **available port** — provided here, or exported by an import — a **provider for one**, which is normalised to `provider.port`, or an **imported module**, a whole-module re-export forwarding that module's own `exports` (never its internals). Anything else is a compile error at the declaration. | Exporting a provider means exactly what exporting its port class means — same `Exports` channel, same gates — and it is the only spelling available when the @@ -58,11 +58,11 @@ flat map at runtime, unnameable through the built `Context`'s type. `Module`: -| Channel | Computed as | -| --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `Exports` | The union of exported ports' instance types, whole-module re-exports contributing their own `Exports`. This becomes the `Context` channel an entry point hands back. | -| `E` | Every way construction can fail: the union of all providers' error channels, here and in every import, transitively. | -| `Needs` | Everything still unmet: the union of all providers' needs and all imports' needs, **minus** what is available here. A dependency satisfied by a sibling provider or an import's export disappears from `Needs`; one nothing supplies travels outward — **provided the module named it in `needs`** — until some module satisfies it, or is refused at the entry point by the [`UNSATISFIED DEPENDENCIES` gate](/reference/di/entry-points#the-gate). `Scope`, once introduced by a resourceful provider, travels the same way without being declared, and is discharged only by `Module.scoped`, `Module.forkScope` or `start`. | +| Channel | Computed as | +| --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `Exports` | The union of exported ports' instance types, whole-module re-exports contributing their own `Exports`. This becomes the `Context` channel an entry point hands back. | +| `E` | Every way construction can fail: the union of all providers' error channels, here and in every import, transitively. | +| `Needs` | Everything still unmet: the union of all providers' needs and all imports' needs, **minus** what is available here. A dependency satisfied by a sibling provider or an import's export disappears from `Needs`; one nothing supplies travels outward — declared by the module whose own providers read it, then inherited by importers without being restated — until some module satisfies it, or is refused at the entry point by the [`UNSATISFIED DEPENDENCIES` gate](/reference/di/entry-points#the-gate). `Scope`, once introduced by a resourceful provider, travels the same way without being declared, and is discharged only by `Module.scoped`, `Module.forkScope` or `start`. | ## The declaration gate @@ -84,6 +84,12 @@ Property '"UNDECLARED NEEDS — name it in `needs`"' is missing in type '{ readonly "UNDECLARED NEEDS — name it in `needs`": Logger; }'. ``` +The gate reads **this module's own providers only**. A module that merely +imports `Slice` restates nothing — the obligation is in `Slice`'s type, and the +entry point is still what refuses a root that has not discharged it. So the +declaration lands on the feature that reads the port, once, rather than on every +module between it and the root. + This is why a slice directory can be read on its own: it names the ports that come from outside without naming who supplies them, so the same slice still composes into any root that answers them. See diff --git a/docs/reference/http.md b/docs/reference/http.md index aa403945..470e7228 100644 --- a/docs/reference/http.md +++ b/docs/reference/http.md @@ -74,7 +74,6 @@ The worked composition root, from `examples/order-api/src/module.ts`: ```ts export const OrderApi = HttpModule("OrderApi")({ - needs: [Env], router: orderRouter, authenticator: bearerAuthenticator, imports: [OrdersSlice, CustomersSlice, observability()], diff --git a/docs/reference/observability.md b/docs/reference/observability.md index 39015fdb..8f6c348e 100644 --- a/docs/reference/observability.md +++ b/docs/reference/observability.md @@ -251,7 +251,6 @@ application and export `Logger` if anything outside the root reads it — ```ts export const OrderApi = HttpModule("OrderApi")({ - needs: [Env], router: orderRouter, imports: [OrderApplicationModule, OrderPersistenceModule, observability()], exports: [Logger], diff --git a/docs/reference/temporal.md b/docs/reference/temporal.md index b8805cbf..b789a9fd 100644 --- a/docs/reference/temporal.md +++ b/docs/reference/temporal.md @@ -83,7 +83,6 @@ The worked composition root, from ```ts export const OrderTemporalWorker = TemporalModule("OrderTemporalWorker")({ - needs: [Env], contract: orderContract, activities: orderActivities, workflows: { diff --git a/docs/tutorial/getting-started.md b/docs/tutorial/getting-started.md index 96fadc1d..16e102f8 100644 --- a/docs/tutorial/getting-started.md +++ b/docs/tutorial/getting-started.md @@ -130,14 +130,12 @@ exports `HttpRuntime` — the one port the kernel resolves and drives: ```ts // app.ts -import { Env } from "@btravstack/config"; import { HttpModule } from "@btravstack/http"; import { GreetingModule } from "./greeter.js"; import { greetingRouter } from "./router.js"; export const App = HttpModule("App")({ - needs: [Env], router: greetingRouter, imports: [GreetingModule], }); diff --git a/docs/tutorial/second-runtime.md b/docs/tutorial/second-runtime.md index b91bb2ba..28830f7b 100644 --- a/docs/tutorial/second-runtime.md +++ b/docs/tutorial/second-runtime.md @@ -148,7 +148,6 @@ code lives, imports the Temporal starter, and exports `TemporalRuntime`: ```ts // worker.ts -import { Env } from "@btravstack/config"; import { TemporalModule } from "@btravstack/temporal"; import { workflowsPathFromURL } from "@temporal-contract/worker/worker"; @@ -157,7 +156,6 @@ import { GreetingModule } from "./greeter.js"; import { greetingContract } from "./temporal-contract.js"; export const Worker = TemporalModule("Worker")({ - needs: [Env], contract: greetingContract, activities: greetingActivities, workflows: { diff --git a/examples/order-amqp-worker/src/module.ts b/examples/order-amqp-worker/src/module.ts index d74d5a06..c45dfdf1 100644 --- a/examples/order-amqp-worker/src/module.ts +++ b/examples/order-amqp-worker/src/module.ts @@ -64,9 +64,9 @@ export const orderHandlers = AmqpHandlers(orderContract)([orderNotifications, or * own vhost. */ export const OrderAmqpWorker = AmqpModule("OrderAmqpWorker")({ - // The composition root owes the environment and nothing else: `start` is - // what provides `Env`, and every other need in this graph has been - // discharged by a module in the list below. + // This root provides `relayConfig` itself — `OUTBOX_POLL_MS` / + // `OUTBOX_TENANTS` off the environment — so `Env` is its own provider's + // need, not one inherited from the slices below. needs: [Env], contract: orderContract, handlers: orderHandlers, diff --git a/examples/order-amqp-worker/src/needs-gate.test-d.ts b/examples/order-amqp-worker/src/needs-gate.test-d.ts index 99decfec..f837443a 100644 --- a/examples/order-amqp-worker/src/needs-gate.test-d.ts +++ b/examples/order-amqp-worker/src/needs-gate.test-d.ts @@ -60,14 +60,11 @@ const _noRuntime = start(RuntimelessAmqp, options); // primitive rather than `AmqpModule`, since the sugar cannot leave the // handlers out — that is what it is for. // -// Negative, and since di's `needs` gate this one no longer waits for `start`: -// the handlers port is owed here and undeclared, and declaring it is not an -// escape either — `AmqpHandlersPort` is the starter's own and the package -// exports only its TYPE, so an application has nothing to name. Providing the -// handlers is the only way out, which is the point. -// @ts-expect-error — UNDECLARED NEEDS: the starter's handlers port. +// The port is owed by the STARTER, which is an import — so di's declaration +// gate has nothing to say here, and the refusal is the kernel's, on the needs +// channel. That is the division the two gates draw: a module declares what its +// OWN providers read, and an import's needs travel published in its type. const HandlerlessAmqp = Module("HandlerlessAmqp")({ - needs: [Env], imports: [ OrderApplicationModule, OrderPersistenceModule, @@ -77,7 +74,8 @@ const HandlerlessAmqp = Module("HandlerlessAmqp")({ exports: [AmqpRuntime, PlaceOrder, Logger], }); -void HandlerlessAmqp; +// @ts-expect-error — the module's needs channel carries the handlers port, which nothing provides. +const _missingHandlers = start(HandlerlessAmqp, options); // The two real slices, composed into a root that forgets `observability()`. // Neither slice imports it — a subscriber owns no vertical, so `Logger` is the @@ -86,17 +84,15 @@ void HandlerlessAmqp; // including it would leave the negative unable to say which of the two leaked. // Negative, and the one this file exists to add: a slice does NOT shield the // ports its own pieces declare. Composition shields a piece's deps from the -// root; being inside a slice shields nothing — and each slice now says +// root; being inside a slice shields nothing — and each slice says // `needs: [Logger]` out loud, so what reaches this root is a DECLARED -// obligation rather than an inferred one. Either way the root has to answer -// it, and this one does not. -// @ts-expect-error — UNDECLARED NEEDS: Logger, carried in by both slices. +// obligation. It is still the root's to answer, and this one does not — the +// refusal is the kernel's, since `Logger` arrives through an import. const LoggerlessAmqp = AmqpModule("LoggerlessAmqp")({ - needs: [Env], contract: orderContract, handlers: orderHandlers, imports: [NotificationsSlice, AuditSlice], }); -// @ts-expect-error — and the kernel's gate refuses it too: `Logger` is not assignable to `Env | Scope`. +// @ts-expect-error — UNMET NEED: `Logger` is not assignable to `Env | Scope`. const _missingLogger = start(LoggerlessAmqp, options); diff --git a/examples/order-amqp-worker/src/test-fixtures.ts b/examples/order-amqp-worker/src/test-fixtures.ts index 282ec6ef..7a06c931 100644 --- a/examples/order-amqp-worker/src/test-fixtures.ts +++ b/examples/order-amqp-worker/src/test-fixtures.ts @@ -63,6 +63,7 @@ type Serve = ( const tappedAmqp = () => { const lines: Line[] = []; const recording = AmqpModule("RecordingAmqpWorker")({ + // The recording root provides `relayConfig` itself, so `Env` is its own. needs: [Env], contract: orderContract, handlers: orderHandlers, diff --git a/examples/order-api/README.md b/examples/order-api/README.md index b8dbf167..f5501010 100644 --- a/examples/order-api/README.md +++ b/examples/order-api/README.md @@ -100,7 +100,6 @@ root that is a `Module(...)` which also knows about it: ```ts export const OrderApi = HttpModule("OrderApi")({ - needs: [Env], router: orderRouter, authenticator: bearerAuthenticator, imports: [OrdersSlice, CustomersSlice, observability()], @@ -153,9 +152,11 @@ The root is a list of **slices**. Each one imports the vertical it needs — ```ts export const OrdersSlice = Module("OrdersSlice")({ - // The environment its persistence reads `DATABASE_URL` from, and the logger - // its interactors write to — both the root's to supply, both named here. - needs: [Env, Logger], + // The controller writes a line itself, so `Logger` is this slice's own + // provider's need. The environment its persistence reads `DATABASE_URL` from + // is not: that one is `DatabaseModule`'s, declared there and inherited + // through the imports below. + needs: [Logger], imports: [OrderApplicationModule, OrderPersistenceModule], provides: [ordersController], exports: [ordersController], diff --git a/examples/order-api/src/module.ts b/examples/order-api/src/module.ts index d7609d92..c2bcad8c 100644 --- a/examples/order-api/src/module.ts +++ b/examples/order-api/src/module.ts @@ -1,4 +1,3 @@ -import { Env } from "@btravstack/config"; import { contract } from "@btravstack/example-order-api-contract"; import { HttpModule } from "@btravstack/http"; import { Logger, observability } from "@btravstack/observability"; @@ -51,10 +50,6 @@ export const orderRouter = HttpRouter(contract)({ * `start` does, once, for the whole process. */ export const OrderApi = HttpModule("OrderApi")({ - // The composition root owes the environment and nothing else: `start` is - // what provides `Env`, and every other need in this graph has been - // discharged by a module in the list below. - needs: [Env], router: orderRouter, authenticator: bearerAuthenticator, imports: [OrdersSlice, CustomersSlice, observability()], diff --git a/examples/order-api/src/needs-gate.test-d.ts b/examples/order-api/src/needs-gate.test-d.ts index ae85446e..23133be2 100644 --- a/examples/order-api/src/needs-gate.test-d.ts +++ b/examples/order-api/src/needs-gate.test-d.ts @@ -57,18 +57,16 @@ const _missingRuntime = start(RuntimelessApi, options); // provider depends on the starter's own router port (the one // `HttpRouter(contract)({ name: Dep }, arm)` provides), so the composition owes it. // -// Since di's `needs` gate that is refused HERE rather than at `start`, and -// declaring it is not an escape: `HttpRouterPort` is the starter's own and -// `@btravstack/http` does not export the value, so an application has nothing -// to name. Providing the router is the only way out, which is the point. -// @ts-expect-error — UNDECLARED NEEDS: the starter's router port. +// It is the KERNEL's gate rather than di's declaration one, and the division +// is the point: the port is owed by `http()`, an IMPORT, and an import's needs +// travel published in its type rather than being re-declared here. const RouterlessApi = Module("RouterlessApi")({ - needs: [Env], imports: [OrdersSlice, CustomersSlice, observability(), http()], exports: [HttpRuntime, Logger], }); -void RouterlessApi; +// @ts-expect-error — the composition needs the router port and nothing provides it. +const _missingRouter = start(RouterlessApi, options); // Positive: a `unit` module rides the same gate — `RequestModule` needs // `Logger`, which the composition root exports, so the fork the kernel opens diff --git a/examples/order-api/src/slices/customers/module.ts b/examples/order-api/src/slices/customers/module.ts index ed397987..dc33cf8b 100644 --- a/examples/order-api/src/slices/customers/module.ts +++ b/examples/order-api/src/slices/customers/module.ts @@ -1,4 +1,3 @@ -import { Env } from "@btravstack/config"; import { Module } from "@btravstack/di"; import { CustomerApplicationModule } from "@btravstack/example-order-application"; import { CustomerPersistenceModule } from "@btravstack/example-order-infrastructure"; @@ -16,9 +15,6 @@ import { customersController } from "./controller.js"; * modules: one provider reference, so one database. */ export const CustomersSlice = Module("CustomersSlice")({ - // Shorter than the orders slice's by one: `FindCustomer` writes no line, so - // this slice owes the environment and not the logger. - needs: [Env], imports: [CustomerApplicationModule, CustomerPersistenceModule], provides: [customersController], exports: [customersController], diff --git a/examples/order-api/src/slices/orders/module.ts b/examples/order-api/src/slices/orders/module.ts index 7cbfb8e6..df89e8c0 100644 --- a/examples/order-api/src/slices/orders/module.ts +++ b/examples/order-api/src/slices/orders/module.ts @@ -1,4 +1,3 @@ -import { Env } from "@btravstack/config"; import { Module } from "@btravstack/di"; import { OrderApplicationModule } from "@btravstack/example-order-application"; import { OrderPersistenceModule } from "@btravstack/example-order-infrastructure"; @@ -26,11 +25,10 @@ import { ordersController } from "./controller.js"; * `HttpController` mints the port for you, so there is no class to name. */ export const OrdersSlice = Module("OrdersSlice")({ - // What this slice expects from the root: the environment its persistence - // reads `DATABASE_URL` from, and the logger its interactors write to. - // Neither is the slice's to provide, and both are now stated here rather - // than absorbed from whatever the root happens to hold. - needs: [Env, Logger], + // The controller writes a line itself, so `Logger` is this slice's own + // provider's need. The environment its persistence reads is not: that is + // `DatabaseModule`'s, declared there and inherited here. + needs: [Logger], imports: [OrderApplicationModule, OrderPersistenceModule], provides: [ordersController], exports: [ordersController], diff --git a/examples/order-api/src/test-fixtures.ts b/examples/order-api/src/test-fixtures.ts index 9987493a..fb82cc2e 100644 --- a/examples/order-api/src/test-fixtures.ts +++ b/examples/order-api/src/test-fixtures.ts @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; -import { Env } from "@btravstack/config"; +import type { Env } from "@btravstack/config"; import type { RunningApp, StartOptions } from "@btravstack/core"; import { Module, Provider, type Scope, type ServiceOf } from "@btravstack/di"; import { @@ -80,7 +80,6 @@ const recorderOf = () => { */ const apiWith = (repository: ServiceOf, sink: Sink = () => {}) => HttpModule("StubApi")({ - needs: [Env], router: orderRouter, // The same authenticator as the real root: the contract marks `orders`, so // every composition serving that router owes one. Swapping it out is how a @@ -114,7 +113,6 @@ const recordingApi = () => { const recorder = recorderOf(); return { api: HttpModule("RecordingApi")({ - needs: [Env], router: orderRouter, authenticator: bearerAuthenticator, // `level` pinned rather than bound: `boot`'s `LOG_LEVEL` silences the diff --git a/examples/order-application/src/needs-gate.test-d.ts b/examples/order-application/src/needs-gate.test-d.ts index 26e40610..b54174d9 100644 --- a/examples/order-application/src/needs-gate.test-d.ts +++ b/examples/order-application/src/needs-gate.test-d.ts @@ -29,6 +29,7 @@ import { OrderRepository, PlaceOrder, } from "./index.js"; +import { findOrderProvider, placeOrderProvider } from "./use-cases.js"; const orderRepository = Provider(OrderRepository)({ value: { @@ -65,44 +66,45 @@ const _unwiredCustomers = Module.scoped(CustomerApplicationModule, (ctx) => // Negative, per vertical: the orders repository closes the orders module, and // says nothing about the customers one — a graph that wires the wrong -// vertical's adapter is still rejected. Since di's `needs` gate that is -// refused HERE, at the module with the gap, rather than at `Module.scoped`: -// this root neither provides `CustomerRepository` nor declares it. -// @ts-expect-error — UNDECLARED NEEDS: CustomerRepository. +// vertical's adapter is still rejected. It is `Module.scoped`'s arity gate, +// not di's declaration one: `CustomerRepository` is owed by an IMPORT, and an +// import's needs travel published in its type rather than being re-declared +// by whoever composes it. const MiswiredCustomers = Module("MiswiredCustomers")({ imports: [CustomerApplicationModule], provides: [orderRepository, logger], exports: [FindCustomer], }); -void MiswiredCustomers; +// @ts-expect-error — UNSATISFIED DEPENDENCIES: no CustomerRepository is provided. +const _miswired = Module.scoped(MiswiredCustomers, (ctx) => + ctx.get(FindCustomer).execute(TenantId("acme"), "0199a1e0-0000-7000-8000-0000000000c1"), +); // Negative, the other port of the orders pair: the repository alone does not -// close the module, because `PlaceOrder` writes a line — and again the module -// is where that is said, not the entry point. -// @ts-expect-error — UNDECLARED NEEDS: Logger. +// close the module, because `PlaceOrder` writes a line. const LoglessOrders = Module("LoglessOrders")({ imports: [OrderApplicationModule], provides: [orderRepository], exports: [PlaceOrder, FindOrder], }); -void LoglessOrders; +// @ts-expect-error — UNSATISFIED DEPENDENCIES: no Logger is provided. +const _logless = Module.scoped(LoglessOrders, (ctx) => + ctx.get(FindOrder).execute(TenantId("acme"), "0199a1e0-0000-7000-8000-000000000001"), +); -// Negative, and the distinction the two gates now draw: DECLARING the logger -// makes the module itself legal — what is left is that nothing supplies it, -// which is `Module.scoped`'s arity gate and not di's declaration one. -const DeclaredLogless = Module("DeclaredLogless")({ - needs: [Logger], - imports: [OrderApplicationModule], - provides: [orderRepository], +// Negative, and the OTHER gate — the distinction the two draw. Here the +// interactors are this module's OWN providers rather than an import's, so +// `OrderRepository` and `Logger` are its to name, and leaving `needs` out is +// refused at the declaration instead of at `Module.scoped`. +// @ts-expect-error — UNDECLARED NEEDS: Logger | OrderRepository. +const UndeclaredOrders = Module("UndeclaredOrders")({ + provides: [placeOrderProvider, findOrderProvider], exports: [PlaceOrder, FindOrder], }); -// @ts-expect-error — UNSATISFIED DEPENDENCIES: no Logger is provided. -const _logless = Module.scoped(DeclaredLogless, (ctx) => - ctx.get(FindOrder).execute(TenantId("acme"), "0199a1e0-0000-7000-8000-000000000001"), -); +void UndeclaredOrders; const WiredOrders = Module("WiredOrders")({ imports: [OrderApplicationModule], diff --git a/examples/order-infrastructure/README.md b/examples/order-infrastructure/README.md index be883aff..ad70fb58 100644 --- a/examples/order-infrastructure/README.md +++ b/examples/order-infrastructure/README.md @@ -168,14 +168,12 @@ const DatabaseModule = Module("Database")({ }); export const OrderPersistenceModule = Module("OrderPersistence")({ - needs: [Env], imports: [DatabaseModule], provides: [orderRepositoryProvider, outboxProvider], exports: [OrderRepository, Outbox], }); export const CustomerPersistenceModule = Module("CustomerPersistence")({ - needs: [Env], imports: [DatabaseModule], provides: [customerRepositoryProvider], exports: [CustomerRepository], @@ -196,7 +194,6 @@ vertical it serves and the graph is closed: ```ts const AppModule = Module("App")({ - needs: [Env], imports: [OrderApplicationModule, OrderPersistenceModule, observability()], exports: [PlaceOrder, FindOrder], }); diff --git a/examples/order-infrastructure/src/module.ts b/examples/order-infrastructure/src/module.ts index 5a334d8c..42ce17cd 100644 --- a/examples/order-infrastructure/src/module.ts +++ b/examples/order-infrastructure/src/module.ts @@ -22,6 +22,9 @@ import { outboxProvider } from "./prisma-outbox.js"; * declared for the same reason every reader of the environment declares it. */ const DatabaseModule = Module("Database")({ + // The feature that reads the environment is the one that declares it — and + // the only one: importers of this module inherit the obligation without + // restating it. needs: [Env], provides: [databaseConfig, orderDatabaseProvider], exports: [OrderDatabase], @@ -39,9 +42,6 @@ const DatabaseModule = Module("Database")({ * the connection with the customers vertical did not spend it. */ export const OrderPersistenceModule = Module("OrderPersistence")({ - // `Env` again: a need travels only as far as the module that declares it, - // and importing `DatabaseModule` makes its unmet one this module's to state. - needs: [Env], imports: [DatabaseModule], provides: [orderRepositoryProvider, outboxProvider], exports: [OrderRepository, Outbox], @@ -54,9 +54,6 @@ export const OrderPersistenceModule = Module("OrderPersistence")({ * database, not two — the diamond that makes splitting the layer free. */ export const CustomerPersistenceModule = Module("CustomerPersistence")({ - // `Env` again: a need travels only as far as the module that declares it, - // and importing `DatabaseModule` makes its unmet one this module's to state. - needs: [Env], imports: [DatabaseModule], provides: [customerRepositoryProvider], exports: [CustomerRepository], diff --git a/examples/order-temporal-worker/README.md b/examples/order-temporal-worker/README.md index be165001..c59b81f9 100644 --- a/examples/order-temporal-worker/README.md +++ b/examples/order-temporal-worker/README.md @@ -82,7 +82,6 @@ export const orderActivities = TemporalActivities(orderContract)([ ]); export const OrderTemporalWorker = TemporalModule("OrderTemporalWorker")({ - needs: [Env], contract: orderContract, activities: orderActivities, workflows: { diff --git a/examples/order-temporal-worker/src/module.ts b/examples/order-temporal-worker/src/module.ts index 7f292435..f9a2c3ca 100644 --- a/examples/order-temporal-worker/src/module.ts +++ b/examples/order-temporal-worker/src/module.ts @@ -1,4 +1,3 @@ -import { Env } from "@btravstack/config"; import { orderContract } from "@btravstack/example-order-temporal-contract"; import { observability } from "@btravstack/observability"; import { TemporalActivities, TemporalModule } from "@btravstack/temporal"; @@ -52,10 +51,6 @@ export const orderActivities = TemporalActivities(orderContract)([fulfillOrder, * has two arms. */ export const OrderTemporalWorker = TemporalModule("OrderTemporalWorker")({ - // The composition root owes the environment and nothing else: `start` is - // what provides `Env`, and every other need in this graph has been - // discharged by a module in the list below. - needs: [Env], contract: orderContract, activities: orderActivities, workflows: { workflowsPath: workflowsPathFromURL(import.meta.url, "./workflows.js") }, diff --git a/examples/order-temporal-worker/src/needs-gate.test-d.ts b/examples/order-temporal-worker/src/needs-gate.test-d.ts index 95625320..dea68822 100644 --- a/examples/order-temporal-worker/src/needs-gate.test-d.ts +++ b/examples/order-temporal-worker/src/needs-gate.test-d.ts @@ -72,14 +72,10 @@ const _noRuntime = start(RuntimelessTemporal, options); // `temporal()` primitive rather than `TemporalModule`, since the sugar cannot // leave the activities out — that is what it is for. // -// Negative, and since di's `needs` gate this one no longer waits for `start`: -// the composed activities port is owed here and undeclared, and declaring it -// is not an escape either — the port is the starter's own and only its TYPE is -// exported, so an application has nothing to name. Providing the activities is -// the only way out, which is the point. -// @ts-expect-error — UNDECLARED NEEDS: the starter's activities port. +// The KERNEL's gate rather than di's declaration one: the port is owed by +// `temporal()`, an IMPORT, and an import's needs travel published in its type +// rather than being re-declared here. const ActivitylessTemporal = Module("ActivitylessTemporal")({ - needs: [Env], imports: [ temporal({ contract: orderContract, @@ -89,16 +85,19 @@ const ActivitylessTemporal = Module("ActivitylessTemporal")({ exports: [TemporalRuntime], }); -void ActivitylessTemporal; +// @ts-expect-error — UNMET NEED: the module's needs channel carries the activities port. +const _missingActivities = start(ActivitylessTemporal, options); // The real `fulfillOrder` piece, composed into a slice that forgets // `FulfillmentModule`: the piece's own `deps` (`PlaceOrder`, // `OrderRepository`, `StockService`, `ShippingService`) are real ports, and // only the first two are met here. +// The slice's OWN provider is what reads them, so this one IS di's declaration +// gate — the distinction the two negatives above draw. // @ts-expect-error — UNDECLARED NEEDS: StockService | ShippingService, which // `FulfillmentModule` would have provided. const FulfillmentlessSlice = Module("FulfillmentlessSlice")({ - needs: [Env, Logger], + needs: [Logger], imports: [OrderApplicationModule, OrderPersistenceModule], provides: [fulfillOrder], exports: [fulfillOrder], diff --git a/examples/order-temporal-worker/src/slices/billing/module.ts b/examples/order-temporal-worker/src/slices/billing/module.ts index dcf9896c..6d63c9ee 100644 --- a/examples/order-temporal-worker/src/slices/billing/module.ts +++ b/examples/order-temporal-worker/src/slices/billing/module.ts @@ -1,5 +1,4 @@ import { Module } from "@btravstack/di"; -import { Logger } from "@btravstack/observability"; import { BillingModule } from "../../billing.js"; import { chargeOrder } from "./activities.js"; @@ -14,10 +13,6 @@ import { chargeOrder } from "./activities.js"; * `TemporalWorkflowActivities` mints the port from the contract key. */ export const BillingSlice = Module("BillingSlice")({ - // What this slice expects from the root: the logger its stand-in payment - // service writes to. `BillingModule` is imported and owes it too, and a - // need travels only as far as the module that declares it. - needs: [Logger], imports: [BillingModule], provides: [chargeOrder], exports: [chargeOrder], diff --git a/examples/order-temporal-worker/src/slices/fulfillment/module.ts b/examples/order-temporal-worker/src/slices/fulfillment/module.ts index 72aebc43..f5b3b884 100644 --- a/examples/order-temporal-worker/src/slices/fulfillment/module.ts +++ b/examples/order-temporal-worker/src/slices/fulfillment/module.ts @@ -1,8 +1,6 @@ -import { Env } from "@btravstack/config"; import { Module } from "@btravstack/di"; import { OrderApplicationModule } from "@btravstack/example-order-application"; import { OrderPersistenceModule } from "@btravstack/example-order-infrastructure"; -import { Logger } from "@btravstack/observability"; import { FulfillmentModule } from "../../fulfillment.js"; import { fulfillOrder } from "./activities.js"; @@ -17,10 +15,6 @@ import { fulfillOrder } from "./activities.js"; * `TemporalWorkflowActivities` mints the port from the contract key. */ export const FulfillmentSlice = Module("FulfillmentSlice")({ - // The environment its persistence reads `DATABASE_URL` from, and the logger - // the interactors and the stand-in services write to. Both come from the - // root; neither is this slice's to provide. - needs: [Env, Logger], imports: [OrderApplicationModule, OrderPersistenceModule, FulfillmentModule], provides: [fulfillOrder], exports: [fulfillOrder], diff --git a/examples/order-temporal-worker/src/test-fixtures.ts b/examples/order-temporal-worker/src/test-fixtures.ts index c3865590..57941552 100644 --- a/examples/order-temporal-worker/src/test-fixtures.ts +++ b/examples/order-temporal-worker/src/test-fixtures.ts @@ -1,4 +1,4 @@ -import { Env, type ConfigInvalid } from "@btravstack/config"; +import type { ConfigInvalid, Env } from "@btravstack/config"; import type { RunningApp } from "@btravstack/core"; import { Module, Provider, type Scope, type ServiceOf } from "@btravstack/di"; import { @@ -87,7 +87,6 @@ type Serve = ( */ const rootWith = (fulfillment: typeof FulfillmentModule, sink: Sink) => Module("StubTemporal")({ - needs: [Env], imports: [OrderApplicationModule, OrderPersistenceModule, fulfillment, observability({ sink })], exports: [PlaceOrder, OrderRepository, StockService, ShippingService, Logger], }); @@ -239,7 +238,6 @@ export const it = test.extend({ // `orderActivities`'s `deps` are the two pieces' PORTS, and nothing // discharges them unless something in this tree does. const worker = TemporalModule("StubTemporalWorker")({ - needs: [Env], contract, activities: orderActivities, workflows: { workflowBundle }, diff --git a/packages/amqp/CLAUDE.md b/packages/amqp/CLAUDE.md index c552380f..6afeb501 100644 --- a/packages/amqp/CLAUDE.md +++ b/packages/amqp/CLAUDE.md @@ -83,12 +83,15 @@ key)`, both of which cast it to the typed alias), so there is nothing a hand-declared port of another id leaves the starter's need unmet, so `start` refuses the module. - It also takes **`needs`**, forwarded to di's own — what this root expects - from outside, which is `[Env]` for every real deployment since the starter - binds its configuration from the environment and `start` is what provides - it. The sugar **re-declares di's `NeedsGate`** over its augmented tuples, so - a root that forgets one is refused at THIS call rather than slipping past - into `start`; see `packages/di/CLAUDE.md`'s **Module visibility**. + It also takes **`needs`**, forwarded to di's own — what this root's OWN + providers expect from outside. The starter's `Env` is not among them: the + starter is an import, and an import's needs travel without being restated. A + root that provides a config provider of its own does declare it — + `examples/order-amqp-worker` says `needs: [Env]` for `relayConfig`. The sugar + **re-declares di's `NeedsGate`** over its augmented tuples, so a root whose + own provider owes a port it does not name is refused at THIS call rather than + slipping past into `start`; see `packages/di/CLAUDE.md`'s **Module + visibility**. A third call composes **pieces** instead of a record: `AmqpHandlers(contract)([piece, piece, ...])`, one piece per diff --git a/packages/amqp/src/amqp-runtime.test-d.ts b/packages/amqp/src/amqp-runtime.test-d.ts index 5d9c545f..c3f9665f 100644 --- a/packages/amqp/src/amqp-runtime.test-d.ts +++ b/packages/amqp/src/amqp-runtime.test-d.ts @@ -81,10 +81,10 @@ AmqpModule("Other")({ // Negative: a hand-declared port of another id is not the starter's — the // starter needs ITS port, so a root providing a different one still owes it. // Since the `needs` gate that is refused HERE, at the module that owes it, -// rather than at `start`: the port is undeclared, and declaring it is not the -// escape either — the module would then have to be handed one. +// and `start` refuses the module. It is the KERNEL's gate rather than di's +// declaration one: the port is owed by the STARTER, an import, and an +// import's needs travel without the importer re-declaring them. class NoHandlers extends Port("NoHandlers")> {} -// @ts-expect-error -- UNDECLARED NEEDS: the starter's handlers port is still owed const Unmet = Module("Unmet")({ imports: [amqp({ contract: pinContract })], provides: [Provider(NoHandlers)({ value: {} })], diff --git a/packages/amqp/src/test-fixtures.ts b/packages/amqp/src/test-fixtures.ts index 56b0b55b..6853d039 100644 --- a/packages/amqp/src/test-fixtures.ts +++ b/packages/amqp/src/test-fixtures.ts @@ -8,7 +8,7 @@ import { } from "@amqp-contract/contract"; import { it as amqpIt } from "@amqp-contract/testing"; import type { AmqpTestFixtures } from "@amqp-contract/testing/extension"; -import { Env, type ConfigInvalid } from "@btravstack/config"; +import type { ConfigInvalid } from "@btravstack/config"; import { currentUnit, type RunningApp, type UnitRecord } from "@btravstack/core"; import { Module, Port, Provider } from "@btravstack/di"; import { bootFixture, type Boot } from "@btravstack/testing"; @@ -63,7 +63,6 @@ type EchoProvider = Provider, never, Greeting>; */ const consuming = (url: string, handlers: EchoProvider, connectTimeoutMs?: number) => AmqpModule("Consuming")({ - needs: [Env], contract: echoContract, handlers, url, @@ -316,7 +315,6 @@ export const it: TestAPI = amqpIt.extend { const app = boot( AmqpModule("Sliced")({ - needs: [Env], contract: slicedContract, handlers: slices.handlers, url: amqpConnectionUrl, diff --git a/packages/config/README.md b/packages/config/README.md index 70ace514..206f6bbc 100644 --- a/packages/config/README.md +++ b/packages/config/README.md @@ -22,7 +22,7 @@ Not yet published: this repository has not cut a release yet. ## A slice of the environment, as a port ```ts -import { Config } from "@btravstack/config"; +import { Config, Env } from "@btravstack/config"; import { Module, Provider } from "@btravstack/di"; const databaseConfig = Config.provider("DatabaseConfig")( diff --git a/packages/core/src/test-fixtures.ts b/packages/core/src/test-fixtures.ts index 8612473f..60cf7a1d 100644 --- a/packages/core/src/test-fixtures.ts +++ b/packages/core/src/test-fixtures.ts @@ -63,6 +63,8 @@ export type ConfiguredApp = { const settingsApp = () => Module("ConfigFixtureApp")({ + // This module provides the config provider itself, so `Env` is its own + // provider's need rather than one inherited from an import. needs: [Env], imports: [testRuntime().module], provides: [Config.provider(Settings)(settingsSchema)], diff --git a/packages/di/CLAUDE.md b/packages/di/CLAUDE.md index 720d9f62..f54f2e77 100644 --- a/packages/di/CLAUDE.md +++ b/packages/di/CLAUDE.md @@ -160,11 +160,22 @@ itself. ## Module visibility: a need is DECLARED, never absorbed -**Decided in #50: a module states what it expects from outside, and anything -it owes and did not state is a compile error at that module.** The rule in one -line: `needs` is the explicit stand-in for NestJS's `@Global` — a composition -root may supply a port to a module it imports, but only one the module asked -for by name. +**Decided in #50: a module states what its OWN providers expect from outside, +and anything they owe and it did not state is a compile error at that module.** +`needs` is the explicit stand-in for NestJS's `@Global` — a composition root +may supply a port to a module it imports, but only one that module asked for by +name. + +**An import's own unmet needs are not the importer's to re-declare**, and that +half is deliberate. They are already published in the import's type — the +`imports` entry a reader is looking at says `Module` — and `start` +still refuses a root that has not discharged them, so leaving them out hides +nothing. Re-declaring them bought one line per module per hop: measured on this +repo, **12 of 22 declarations were pure propagation**, and dropping them leaves +exactly the modules that read the port. `Env` is the case that showed it — six +declarations in `order-api`, one of them the feature that reads +`DATABASE_URL`. This is the per-feature shape NestJS's +`ConfigModule.forFeature` has, reached without a global. ```ts export const AuditSlice = Module("AuditSlice")({ @@ -243,10 +254,12 @@ Nothing can provide `Scope` — a provider for it is a `WiringDefect` — so it never something an ancestor supplies; `Module.scoped` and `start` discharge it by opening one. A resourceful module therefore declares nothing. -`Env` is **not** exempt, and that is the point rather than an oversight: every -module that reads the environment says `needs: [Env]`, and the port travels, -declared at each step, up to the root that `start` hands one to. That chain is -what a `@Global` would have hidden. +`Env` is **not** exempt, and exempting it was refused: the module that reads +the environment says `needs: [Env]` — `DatabaseModule`, `observability()`, each +starter — and from there it travels through importers without being restated, +up to the root `start` hands one to. Naming it at the feature is what a +`@Global` would have hidden; naming it at every hop was what made the first cut +of this gate noisy. ### The gate cannot be computed generically — and that is why the casts exist @@ -266,13 +279,17 @@ around `StartGate` one layer down: sugar's return type to `Module` (measured). `start` and `tapped` may use `as never`, because both already cast their result. -### What a module cannot declare its way out of - -A starter's own port — `HttpRouterPort`, the AMQP handlers port, the Temporal -activities port — is exported as a TYPE only, so an application has nothing to -name in `needs`. Providing the router / handlers / activities is the only way -past the gate, which is what those gates are for. Three negatives pin it, one -per starter, and each moved from `start` to the module in this change. +### Which gate catches what + +A starter's port — `HttpRouterPort`, the AMQP handlers port, the Temporal +activities port — is owed by the **starter**, which an application _imports_. +So those three are the KERNEL's gate, on the needs channel at `start`, not +di's declaration one, and the three `needs-gate.test-d.ts` negatives say so. +The declaration gate catches the other half: a module whose OWN provider reads +a port nothing here satisfies — +`examples/order-temporal-worker`'s `FulfillmentlessSlice`, whose `fulfillOrder` +piece names `StockService` and `ShippingService`. Both are pinned, side by side, +because conflating them is easy. ## Binding design rules diff --git a/packages/di/src/module.test-d.ts b/packages/di/src/module.test-d.ts index 9eb225e0..e19dd097 100644 --- a/packages/di/src/module.test-d.ts +++ b/packages/di/src/module.test-d.ts @@ -194,6 +194,29 @@ describe("Module algebra", () => { }); }); + test("an import's unmet needs travel without being re-declared", () => { + // The other half of the gate, and the reason a `needs` list stays one line + // per FEATURE rather than one per hop. `Orphan` reads `Database` through + // its own provider and declares it; `Importer` only imports `Orphan` and + // declares nothing — the obligation still reaches its channel, and `start` + // (or `Module.build`) is still what refuses a root that has not discharged + // it. Nothing is hidden: `Orphan`'s type says `Database` at the `imports` + // entry a reader is looking at. + const orphan = Module("Orphan")({ + needs: [Database], + provides: [OrderRepositoryProvider], + exports: [OrderRepository], + }); + const importer = Module("Importer")({ + imports: [orphan], + exports: [OrderRepository], + }); + + type Channels = ChannelsOf; + const stillOwesDatabase: Equal = true; + void stillOwesDatabase; + }); + test("declaring a need nothing owes is inert", () => { // `needs` says what this module expects from outside; it does not // manufacture an obligation. `ConfigModule` provides everything it uses, diff --git a/packages/di/src/module.ts b/packages/di/src/module.ts index 1a4b863c..b4a15da0 100644 --- a/packages/di/src/module.ts +++ b/packages/di/src/module.ts @@ -162,10 +162,12 @@ type ResolvedExports = /** * What the module still owes: its providers' dependencies and its imports' - * declared needs, minus everything visible to it. Exported because a shaped - * module — a starter's `HttpModule(name)({...})` — re-declares this package's - * gates over its own augmented tuples, the way it already re-declares - * `Exportable`. + * own needs, minus everything visible to it. This is the `Needs` CHANNEL — + * everything outstanding, however it got there — and is deliberately wider + * than what `NeedsGate` makes a module declare, which is its own providers' + * half alone. Exported because a shaped module — a starter's + * `HttpModule(name)({...})` — re-declares this package's gates over its own + * augmented tuples, the way it already re-declares `Exportable`. */ export type Unmet = Exclude< NeedOf | NeedsOfModule, @@ -173,11 +175,26 @@ export type Unmet; /** - * The declaration gate. A port this module owes and did not name in `needs` is - * an error **here**, at the module that owes it, rather than an obligation - * that travels silently to whoever composes it. `needs` is how a module says - * "my composition root supplies this" out loud — the explicit stand-in for - * NestJS's `@Global`, which this package does not have and now does not need. + * The declaration gate. A port **this module's own providers** read, and that + * nothing here satisfies, is an error unless it is named in `needs` — so a + * provider can never silently receive a service from whoever composed the + * module. Named, it may: the provider is handed whatever an ancestor supplies. + * What naming does not do is make the port `Available` here, so a declared + * need is still not exportable. + * + * `needs` is how a module says "my composition root supplies this" + * out loud, the explicit stand-in for NestJS's `@Global`, which this package + * does not have and does not need. + * + * **An import's own unmet needs are NOT this module's to re-declare**, and + * that is the deliberate half. They are already published in the import's + * type — `Module` says so at the `imports` entry a reader is + * looking at — and `start` still refuses a root that has not discharged them, + * so nothing is hidden by leaving them out. What re-declaring them bought was + * one line per module per hop: measured on this repo, 12 of 22 declarations + * were pure propagation, and dropping them leaves exactly the modules that + * actually read the port. `Env` is the one that showed it — six declarations + * in `order-api`, one of them the feature that reads `DATABASE_URL`. * * `Scope` is the one exemption, and it is forced rather than chosen: nothing * can provide `Scope` — a provider for it is a `WiringDefect` — so it is never @@ -203,14 +220,14 @@ export type NeedsGate< I extends readonly AnyModule[], P extends readonly AnyProvider[], N extends readonly AnyPort[], -> = [Unmet] extends [InstanceType | Scope] +> = [Exclude, Available>] extends [InstanceType | Scope] ? unknown : { - // Inline, not a named `Undeclared` alias: an alias prints as - // itself, unreduced, and the reader is left reading their own tuples - // back. Written out, the message ends on the port (measured, both ways). + // Inline, not a named alias: an alias prints as itself, unreduced, and + // the reader is left reading their own tuples back. Written out, the + // message ends on the port (measured, both ways). readonly "UNDECLARED NEEDS — name it in `needs`": Exclude< - Unmet, + Exclude, Available>, InstanceType | Scope >; }; diff --git a/packages/http/CLAUDE.md b/packages/http/CLAUDE.md index 9fe673a3..07d07527 100644 --- a/packages/http/CLAUDE.md +++ b/packages/http/CLAUDE.md @@ -307,12 +307,15 @@ InstanceType> & { readonly port: PortClassOf every caller, so the leaf answers `401` instead of serving unprotected. Unreachable while the two halves agree, which is exactly why it is there. - It also takes **`needs`**, forwarded to di's own — what this root expects - from outside, which is `[Env]` for every real deployment since the starter - binds its configuration from the environment and `start` is what provides - it. The sugar **re-declares di's `NeedsGate`** over its augmented tuples, so - a root that forgets one is refused at THIS call rather than slipping past - into `start`; see `packages/di/CLAUDE.md`'s **Module visibility**. + It also takes **`needs`**, forwarded to di's own — what this root's OWN + providers expect from outside. The starter's `Env` is not among them: the + starter is an import, and an import's needs travel without being restated. A + root that provides a config provider of its own does declare it — + `examples/order-amqp-worker` says `needs: [Env]` for `relayConfig`. The sugar + **re-declares di's `NeedsGate`** over its augmented tuples, so a root whose + own provider owes a port it does not name is refused at THIS call rather than + slipping past into `start`; see `packages/di/CLAUDE.md`'s **Module + visibility**. When `hasMarked(contract)` answers true, `AuthenticatorPort` joins the provider's deps record under the **namespaced** diff --git a/packages/http/README.md b/packages/http/README.md index a41cfc13..744e58e3 100644 --- a/packages/http/README.md +++ b/packages/http/README.md @@ -22,7 +22,6 @@ has not cut a release yet. ## A worked example ```ts -import { Env } from "@btravstack/config"; import { runMain } from "@btravstack/core"; import { HttpModule, HttpRouter } from "@btravstack/http"; import { P } from "unthrown"; @@ -83,7 +82,6 @@ const ordersRouter = HttpRouter(ordersContract)( // the router on the starter's own port (a process serves one router, so // there is nothing to name), exports the runtime port — nothing else to spell. const OrdersApi = HttpModule("OrdersApi")({ - needs: [Env], router: ordersRouter, imports: [Application, Persistence], }); @@ -204,7 +202,6 @@ a type carrying the marker's phantom `unique symbol`, which a consumer's `.d.ts` cannot name. ```ts -import { Env } from "@btravstack/config"; import { authenticated } from "@btravstack/contract"; import { HttpModule, Unauthenticated } from "@btravstack/http"; import { oc, type } from "@orpc/contract"; @@ -265,7 +262,6 @@ const ordersRouter = HttpRouter({ orders: ordersContract })( ); const OrdersApi = HttpModule("OrdersApi")({ - needs: [Env], router: ordersRouter, authenticator: bearerAuthenticator, imports: [Application, Persistence], diff --git a/packages/http/src/test-fixtures.ts b/packages/http/src/test-fixtures.ts index 484d0886..d1759a67 100644 --- a/packages/http/src/test-fixtures.ts +++ b/packages/http/src/test-fixtures.ts @@ -25,7 +25,7 @@ import { once } from "node:events"; import { createServer } from "node:http"; import { connect, type Socket } from "node:net"; -import { Env, type ConfigInvalid, type Environment } from "@btravstack/config"; +import type { ConfigInvalid, Environment } from "@btravstack/config"; import { authenticated } from "@btravstack/contract"; import { currentUnit, type RunningApp } from "@btravstack/core"; import { Module, Port, Provider, type ServiceOf } from "@btravstack/di"; @@ -61,7 +61,6 @@ type Handler = ServiceOf; */ const appOf = (handler: Handler, port = 0, securityHeaders?: HttpOptions["securityHeaders"]) => Module("App")({ - needs: [Env], imports: [ httpModule( { @@ -160,7 +159,6 @@ const slicedRouter = HttpRouter(slicedContract)({ /** `HttpModule` over the composed router, mirroring `rpcAppOf`. */ const rpcSlicedAppOf = () => HttpModule("RpcSlicedApp")({ - needs: [Env], router: slicedRouter, port: 0, hostname: "127.0.0.1", @@ -251,7 +249,6 @@ const authedPositionalRouter = AuthedRouter(authedContract)( /** `HttpModule` over the protected router, with the authenticator the router now needs. */ const rpcAuthedAppOf = () => HttpModule("RpcAuthedApp")({ - needs: [Env], router: authedRouter, port: 0, hostname: "127.0.0.1", @@ -281,7 +278,6 @@ const rootMarkedRouter = AuthedRouter(rootMarkedContract)({ const rpcRootMarkedAppOf = () => HttpModule("RpcRootMarkedApp")({ - needs: [Env], router: rootMarkedRouter, port: 0, hostname: "127.0.0.1", @@ -324,7 +320,6 @@ const strayRouter = HttpRouter(greetingContract)( /** The starter as an application uses it: `HttpModule` sugar over a router provider. */ const rpcAppOf = (prefix?: `/${string}`, stray = false) => HttpModule("RpcApp")({ - needs: [Env], router: stray ? strayRouter : greetingRouter, port: 0, hostname: "127.0.0.1", @@ -348,7 +343,6 @@ const corsRouter = HttpRouter(corsContract)({ /** The same starter shape as `rpcAppOf`, with oRPC's CORS plugin configured. */ const rpcWithCorsAppOf = () => HttpModule("RpcWithCorsApp")({ - needs: [Env], router: corsRouter, port: 0, hostname: "127.0.0.1", @@ -363,7 +357,6 @@ const configuredAppOf = (options: { readonly port?: number; readonly hostname?: let bound: ServiceOf | undefined; return { module: Module("ConfiguredApp")({ - needs: [Env], imports: [httpModule(options, Provider(HttpHandler)({ value: noop }))], provides: [ Provider(BoundConfig)( diff --git a/packages/observability/README.md b/packages/observability/README.md index 18174541..524b55a5 100644 --- a/packages/observability/README.md +++ b/packages/observability/README.md @@ -21,7 +21,6 @@ repository has not cut a release yet. ## A worked example ```ts -import { Env } from "@btravstack/config"; import { runMain } from "@btravstack/core"; import { Logger, @@ -49,7 +48,6 @@ const placeOrder = Provider(PlaceOrder)( // once — `verbose` is a startup failure naming the variable, not a silent // fallback to `info`. const OrderApi = HttpModule("OrderApi")({ - needs: [Env], router: orderRouter, imports: [OrderApplicationModule, OrderPersistenceModule, observability()], exports: [Logger], diff --git a/packages/observability/src/test-fixtures.ts b/packages/observability/src/test-fixtures.ts index 5beb66c6..c2cf9a1b 100644 --- a/packages/observability/src/test-fixtures.ts +++ b/packages/observability/src/test-fixtures.ts @@ -1,4 +1,3 @@ -import { Env } from "@btravstack/config"; import { RuntimePort, type Runtime } from "@btravstack/core"; import { Module, Port, Provider } from "@btravstack/di"; import { bootFixture, testRuntime, TestRuntimePort, type Boot } from "@btravstack/testing"; @@ -145,7 +144,6 @@ export const it = test.extend({ await use( (tenantId, options = {}) => Module("TenantApp")({ - needs: [Env], imports: [tenantRuntimeModule(tenantId), observability(options)], exports: [Logger, TenantRuntime], }) as unknown as Module, @@ -159,7 +157,6 @@ export const it = test.extend({ return { runtime, module: Module("ObservabilityApp")({ - needs: [Env], imports: [runtime.module, observability(options)], provides: [Provider(Greeting)({ value: { text: "hello" } })], exports: [Logger, LoggerConfig, Greeting, TestRuntimePort], diff --git a/packages/temporal/CLAUDE.md b/packages/temporal/CLAUDE.md index 3fe062fa..7f53075b 100644 --- a/packages/temporal/CLAUDE.md +++ b/packages/temporal/CLAUDE.md @@ -69,12 +69,15 @@ ActivitiesPortOf`, so the next call is di's `(deps, arm)` unchanged and `examples/order-temporal-worker/src/slices/billing/activities.ts` are the worked examples (no port class, no name, anywhere). - It also takes **`needs`**, forwarded to di's own — what this root expects - from outside, which is `[Env]` for every real deployment since the starter - binds its configuration from the environment and `start` is what provides - it. The sugar **re-declares di's `NeedsGate`** over its augmented tuples, so - a root that forgets one is refused at THIS call rather than slipping past - into `start`; see `packages/di/CLAUDE.md`'s **Module visibility**. + It also takes **`needs`**, forwarded to di's own — what this root's OWN + providers expect from outside. The starter's `Env` is not among them: the + starter is an import, and an import's needs travel without being restated. A + root that provides a config provider of its own does declare it — + `examples/order-amqp-worker` says `needs: [Env]` for `relayConfig`. The sugar + **re-declares di's `NeedsGate`** over its augmented tuples, so a root whose + own provider owes a port it does not name is refused at THIS call rather than + slipping past into `start`; see `packages/di/CLAUDE.md`'s **Module + visibility**. A third call composes **pieces** instead of a record: `TemporalActivities(contract)([piece, piece, ...])`, one piece per diff --git a/packages/temporal/README.md b/packages/temporal/README.md index 351dbea3..32195fbc 100644 --- a/packages/temporal/README.md +++ b/packages/temporal/README.md @@ -21,7 +21,6 @@ this repository has not cut a release yet. ## A worked example ```ts -import { Env } from "@btravstack/config"; import { runMain } from "@btravstack/core"; import { TemporalActivities, TemporalModule } from "@btravstack/temporal"; import { P } from "unthrown"; @@ -51,7 +50,6 @@ const orderActivities = TemporalActivities(contract)( // The composition root: a di module, plus the contract, the activities // provider and the workflow source — and nothing else to know. const OrderWorker = TemporalModule("OrderWorker")({ - needs: [Env], contract, activities: orderActivities, workflows: { diff --git a/packages/temporal/src/test-fixtures.ts b/packages/temporal/src/test-fixtures.ts index becf8f51..da80d9c0 100644 --- a/packages/temporal/src/test-fixtures.ts +++ b/packages/temporal/src/test-fixtures.ts @@ -1,6 +1,6 @@ import { fileURLToPath } from "node:url"; -import { Env, type ConfigInvalid, type Environment } from "@btravstack/config"; +import type { ConfigInvalid, Environment } from "@btravstack/config"; import { currentUnit, type RunningApp, type UnitRecord } from "@btravstack/core"; import { Port, Provider, type ServiceOf } from "@btravstack/di"; import { createNamespace } from "@btravstack/internal-test-infra/namespace"; @@ -364,7 +364,6 @@ export type TemporalFixtures = { const compose = (server: Server, boot: Boot, options: BootOptions) => { const taskQueue = nextTaskQueue(); const worker = TemporalModule("Worker")({ - needs: [Env], contract: { ...echoContract, taskQueue }, activities: options.activities ?? echoing, workflows: options.workflows ?? echoWorkflows, @@ -454,7 +453,6 @@ export const it = test.extend({ const taskQueue = nextTaskQueue(); const app: App = boot( TemporalModule("Sliced")({ - needs: [Env], contract: withTaskQueue(slicedContract, taskQueue), activities: slices.activities, workflows: echoWorkflows,