Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions .changeset/runtime-resolves.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
---
"@btravstack/contract": minor
"@btravstack/di": minor
"@btravstack/config": minor
"@btravstack/core": minor
"@btravstack/testing": minor
"@btravstack/observability": minor
"@btravstack/http": minor
"@btravstack/temporal": minor
"@btravstack/amqp": minor
---

`Runtime.needs` is `Runtime.resolves`

Two different `needs` in one framework was one too many. di's `Module` has a
`needs` — what a composition root supplies it — and the kernel's `Runtime` had
one too, meaning something else entirely: the ports the runtime reads back out
of the built application context. They never appear in the same object, which
is exactly why the collision was easy to miss and easy to misread.

```ts
const runtime: Runtime<typeof Clock> = {
name: "ticker",
resolves: [Clock],
start: (host) => OkAsync(serving),
};
```

The type parameter is `Resolves` rather than `Needs` throughout —
`Runtime<Resolves, Info>`, `RuntimeHost<Resolves>`, `RunUnit<Resolves>` — and
`start`'s gate sentence follows:
`"UNSATISFIED RUNTIME PORTS — the runtime resolves a port the module does not export"`.

Every shipped runtime declares `resolves: []`, so an application that composes
`http()` / `temporal()` / `amqp()` and never writes a runtime by hand is
unaffected. A **hand-rolled** runtime renames one field.

The array is still never read at run time — it exists so `Resolves` is
inferable from the value, and `start`'s gate checks it against the module's
exports.
8 changes: 4 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -444,7 +444,7 @@ type checker already verifies.
exactly as `packages/core` would. Three of the four needs-gate files pin
**`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
resolves anything any more; `order-api`'s also pins the `unit` halves) and
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
Expand All @@ -462,8 +462,8 @@ type checker already verifies.
**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
`start.test-d.ts`, since every shipped runtime declares `needs: []`.
`UNSATISFIED RUNTIME PORTS` arm is pinned only by `packages/core`'s own
`start.test-d.ts`, since every shipped runtime declares `resolves: []`.
`examples/` is not the only place the gate is pinned by a **type test**:
`packages/amqp/src/amqp-runtime.test-d.ts` pins the handlers-port half of
`amqp`'s own gate, and its sibling `packages/amqp/src/handler.test-d.ts` pins
Expand Down Expand Up @@ -782,7 +782,7 @@ CustomersSlice, observability()], exports: [Logger] })`** is the whole
request scope write to, and `Logger` is in `exports` because `RequestModule`
reads it out of the application scope. `RequestModule` rides
`StartOptions.unit` so
the per-request fork is the kernel's. There is no `runtime`, `needs`,
the per-request fork is the kernel's. There is no `runtime`, `resolves`,
`handler`, `port` or env-reading to spell anywhere. It is also the **one**
`main.ts` that is not a single line: it passes
`onEvent: kernelEvents(createLogger(jsonSink()))` so the kernel's nine events
Expand Down
4 changes: 2 additions & 2 deletions docs/examples/order-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -565,8 +565,8 @@ and the diagnostic names the port:
`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
the shipped runtime declares no needs.
having both pinned here. There is no `UNSATISFIED RUNTIME PORTS` arm, because
the shipped runtime resolves nothing.

```ts
// @ts-expect-error — UNSATISFIED UNIT NEEDS: the module does not export Logger for RequestModule to read.
Expand Down
12 changes: 6 additions & 6 deletions docs/explanation/compile-time-wiring.md
Original file line number Diff line number Diff line change
Expand Up @@ -164,11 +164,11 @@ the `module` parameter** — `unknown`, and invisible, when the gate is satisfie
a sentence otherwise. `start`, `runMain` and `@btravstack/testing`'s `Boot` all
carry it:

| Arm | Fires when |
| --------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `NO RUNTIME` | The module exports no port declared over `RuntimePort`. A process boots exactly one runtime, and it is a service of the module — a root that forgets `HttpModule`/`http(...)` is refused here. |
| `UNSATISFIED RUNTIME NEEDS` | The runtime's declared `needs` are not among the module's exports — the **module's alone**, never the unit module's, because `RuntimeHost.ctx` is the application context and a unit-only port does not exist at startup. No shipped starter declares any today. |
| `UNSATISFIED UNIT NEEDS` | With `StartOptions.unit`, the unit module's needs are not covered by the module's exports, `Scope` or `Env` — `forkScope`'s gate, stated at `start`'s call site, where the parent is actually known. |
| Arm | Fires when |
| --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `NO RUNTIME` | The module exports no port declared over `RuntimePort`. A process boots exactly one runtime, and it is a service of the module — a root that forgets `HttpModule`/`http(...)` is refused here. |
| `UNSATISFIED RUNTIME PORTS` | The runtime's declared `resolves` are not among the module's exports — the **module's alone**, never the unit module's, because `RuntimeHost.ctx` is the application context and a unit-only port does not exist at startup. No shipped starter declares any today. |
| `UNSATISFIED UNIT NEEDS` | With `StartOptions.unit`, the unit module's needs are not covered by the module's exports, `Scope` or `Env` — `forkScope`'s gate, stated at `start`'s call site, where the parent is actually known. |

```ts
const Application = Module("Application")({
Expand Down Expand Up @@ -281,7 +281,7 @@ ends on, which is the payload of the whole message.
| di's own gate (a conditional rest tuple) | `Module.scoped`/`build`/`forkScope` | `Expected 3 arguments, but got 1.` — [the whole message](#the-gate-an-arity-error), unchanged | same — no task on this branch touched it |
| An unmet need at `start` (plain assignability) | a starter's own port, e.g. `AmqpHandlers` | `'"AmqpHandlers"' is not assignable to type '"@di/Scope"'` | same — this was always the best diagnostic in the repo; the thirteen corrections were to the documentation calling it di's gate, not to the gate |
| `start`'s `StartGate` — `NO RUNTIME` | [the Greeter example above](#the-kernels-own-gate) | `Expected 4 arguments, but got 1.` | ends on `"NO RUNTIME — the module exports no port declared over RuntimePort"` — [full example above](#the-kernels-own-gate) |
| `start`'s `StartGate` — `UNSATISFIED RUNTIME NEEDS` | a runtime's `needs` uncovered by the module's exports | `Expected 4 arguments, but got 1.` | ends on `"UNSATISFIED RUNTIME NEEDS — the runtime needs a port the module does not export"` |
| `start`'s `StartGate` — `UNSATISFIED RUNTIME PORTS` | a runtime's `resolves` uncovered by the module's exports | `Expected 4 arguments, but got 1.` | ends on `"UNSATISFIED RUNTIME PORTS — the runtime resolves a port the module does not export"` |
| `start`'s `StartGate` — `UNSATISFIED UNIT NEEDS` | a unit module's needs uncovered | `Expected 4 arguments, but got 2.` | ends on `"UNSATISFIED UNIT NEEDS — the unit module needs a port the module does not export"` |
| amqp's/temporal's composer — `UNCOVERED HANDLERS`/`UNCOVERED ACTIVITIES` | `AmqpHandlers(contract)([...])` / `TemporalActivities(contract)([...])` missing a key | ends on `'"UNCOVERED HANDLERS"'` / `'"UNCOVERED ACTIVITIES"'` | ends on `'"UNCOVERED HANDLERS — the contract declares a consumer this array does not cover"'` / the `ACTIVITIES` twin; the missing key prints too, as a separate diagnostic on the trailing element, once the array is as long as the marker tuple (measured: `'"orderAudit"'`, `'"fulfillOrder"'`) |
| http's keyed router — `UNDECLARED KEY` | `HttpRouter(contract)(controllers)` with a key the contract does not declare | ends on `'never'` | ends on `'"UNDECLARED KEY — the contract declares no fragment under billing"'` — the key is named too, straight from the mapped type's own `K` |
Expand Down
4 changes: 2 additions & 2 deletions docs/explanation/design-decisions.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,8 @@ with it a runtime constructed outside the graph that reaches back into it.

`start`, `runMain` and `@btravstack/testing`'s `Boot` all intersect
`StartGate<X, UnitNeeds>` onto their `module` parameter — `unknown`, and
invisible, when the module exports a runtime whose needs its exports cover; one
of three sentences (`NO RUNTIME — …`, `UNSATISFIED RUNTIME NEEDS — …`,
invisible, when the module exports a runtime whose ports its exports cover; one
of three sentences (`NO RUNTIME — …`, `UNSATISFIED RUNTIME PORTS — …`,
`UNSATISFIED UNIT NEEDS — …`) otherwise. It rides the parameter so that the
sentence **prints**: an argument that fails a parameter type makes TypeScript
name that type, where the trailing rest tuple this used to be failed as an
Expand Down
2 changes: 1 addition & 1 deletion docs/explanation/one-process-one-runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,5 +117,5 @@ never does arithmetic on time.

- The three beats the drain runs, and why each is where it is:
[Draining, in three beats](/explanation/draining-in-three-beats).
- What a starter is, and why the shipped runtimes have no `needs`:
- What a starter is, and why the shipped runtimes resolve nothing:
[Starters](/explanation/starters).
14 changes: 7 additions & 7 deletions docs/explanation/starters.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,28 +146,28 @@ application never writes `implement`, `os.…`, `declareHandler` or
`declareActivitiesHandler`, and a typo'd or missing procedure is a compile
error at the record.

## Why `needs` disappeared
## Why the starters resolve nothing

The kernel's `Runtime` has a `needs` field, and `start`'s gate checks it
The kernel's `Runtime` has a `resolves` 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)({ 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`
`Provider(AmqpRuntime)({ config: AmqpConfig, handlers: AmqpHandlersPort }, …)` — so their `Resolves` is `never` and `RuntimeHost.ctx`
goes unread.

The reason is not tidiness. A port's service type is fixed at declaration, so
a runtime with application-specific `needs` — "I need whatever this
a runtime with application-specific `resolves` — "I read whatever this
application's router port is" — could not ship its port: `HttpRuntime` has to
be one class in `@btravstack/http`, and its type cannot mention a port only
the application knows. Making the router a dependency of the runtime's
provider moves that knowledge to where it exists — the composition root that
provides the router — and the `Needs` channel checks it there: a root that
imports `http()` without providing the router carries an unmet need `start`
refuses, naming the port
(`Type 'HttpRouterPort' is not assignable to type 'Env | Scope'`). The kernel keeps `Runtime.needs`, `RunUnit`'s typed `ctx` and the
`UNSATISFIED RUNTIME NEEDS` arm as the general contract for a hand-rolled
runtime; the starters simply do not need them.
(`Type 'HttpRouterPort' is not assignable to type 'Env | Scope'`). The kernel keeps `Runtime.resolves`, `RunUnit`'s typed `ctx` and the
`UNSATISFIED RUNTIME PORTS` arm as the general contract for a hand-rolled
runtime; the starters simply do not use them.

## What a starter does not do

Expand Down
2 changes: 1 addition & 1 deletion docs/explanation/the-kernel-maps-nothing.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,7 @@ the id for whoever wants it.

## Where to go next

- The starters, and why the runtime's `needs` disappeared once the router,
- The starters, and why the shipped runtimes resolve nothing once the router,
activities and handlers became ports: [Starters](/explanation/starters).
- The record a handler runs inside, and who may read it:
[Ambient data, injected capabilities](/explanation/ambient-vs-context).
2 changes: 1 addition & 1 deletion docs/explanation/why-start.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ holding it.

`di` answers "does this composition hold together?" before the process
exists: a missing provider, a private port reached across a module boundary, a
runtime whose needs the root does not export — each is a compile error at the
runtime whose ports the root does not export — each is a compile error at the
call site (see [Compile errors, not
surprises](/explanation/compile-time-wiring)). By the time `start` sees a
module there is nothing left to discover about it.
Expand Down
4 changes: 2 additions & 2 deletions docs/how-to/open-a-per-request-scope.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,8 +155,8 @@ OrderPersistenceModule, observability()], exports: [Logger] })`.
A unit-provided port exists only while a unit is open, and reaches a runtime
through `host.run`'s work callback alone. `host.ctx.get(RequestSpan)` at
runtime startup type-checks against nothing and would be a defect, so the
gate rejects a runtime whose `needs` name a unit-only port: `UNSATISFIED
RUNTIME NEEDS` is checked against the module's exports **only**, never the
gate rejects a runtime whose `resolves` names a unit-only port: `UNSATISFIED
RUNTIME PORTS` is checked against the module's exports **only**, never the
unit's. Resolve at start what the application module itself exports.
:::

Expand Down
18 changes: 9 additions & 9 deletions docs/how-to/write-a-runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,18 +26,18 @@ class Ticker extends RuntimePort<Runtime<typeof Greeter>> {}

`RuntimePort` is `Port("Runtime")` left generic: every runtime port shares one
id at runtime — a process boots exactly one — while each carries its own
`Needs` and `Info` in the type. `Runtime<typeof Greeter>` says this runtime
`Resolves` and `Info` in the type. `Runtime<typeof Greeter>` says this runtime
resolves `Greeter` from the application context; `start` refuses, at compile
time, a module that exports the port without exporting `Greeter`.

## Step 2 — implement `Runtime`

```ts
type Runtime<Needs extends AnyPort = never, Info = never> = {
type Runtime<Resolves extends AnyPort = never, Info = never> = {
readonly name: string;
readonly needs: readonly Needs[];
readonly resolves: readonly Resolves[];
readonly start: (
host: RuntimeHost<Needs>,
host: RuntimeHost<Resolves>,
) => AsyncResult<Serving<Info>, RuntimeStartFailed>;
};
```
Expand All @@ -54,7 +54,7 @@ import type { Runtime, Serving } from "@btravstack/core";

const ticker: Runtime<typeof Greeter> = {
name: "ticker",
needs: [Greeter],
resolves: [Greeter],
start: (host) => {
const timer = setInterval(() => {
// Every piece of work goes through `host.run`: that is what makes it
Expand Down Expand Up @@ -109,7 +109,7 @@ type TcpInfo = { readonly port: number };

const tcpish: Runtime<typeof Greeter, TcpInfo> = {
name: "tcpish",
needs: [Greeter],
resolves: [Greeter],
start: () =>
fromExecutor<Serving<TcpInfo>, RuntimeStartFailed>((settle) => {
const server = createServer();
Expand Down Expand Up @@ -157,7 +157,7 @@ The composition root is what differs between an `api`, a `worker` and a
`Ticker` from `exports` and `runMain` refuses the module against
`"NO RUNTIME — the module exports no port declared over RuntimePort"`; drop
`Greeter` and it refuses it against
`"UNSATISFIED RUNTIME NEEDS — the runtime needs a port the module does not export"`.
`"UNSATISFIED RUNTIME PORTS — the runtime resolves a port the module does not export"`.
Either way the sentence is the error's **last** line; the first names the two
`Module<…>` types.

Expand Down Expand Up @@ -229,9 +229,9 @@ per-request acquire leaves the unit open for the process lifetime.

The three shipped runtimes go one step further and take what the application
supplies — a router, activities, handlers — as a **port their runtime provider
depends on**, so their `needs` is `never` and `host.ctx` goes unread. Do the
depends on**, so their `resolves` is `never` and `host.ctx` goes unread. Do the
same once your runtime has application-specific inputs: a port's service type
is fixed at declaration, so a runtime with `needs: [OrderRouter]` cannot ship
is fixed at declaration, so a runtime with `resolves: [OrderRouter]` cannot ship
its port; a provider that depends on `OrderRouter` can. See
[Starters](/explanation/starters).

Expand Down
Loading
Loading