feat(core): add StartOptions.unit, the per-unit scope no handler manages - #40
Open
btravers wants to merge 2 commits into
Open
feat(core): add StartOptions.unit, the per-unit scope no handler manages#40btravers wants to merge 2 commits into
btravers wants to merge 2 commits into
Conversation
`unit` is a module the kernel forks around every unit: built as the unit opens, reading anything the application context carries, torn down as it closes — while the unit's ambient record is still open, so a teardown log line carries the request's own trace id. Unit work receives the forked Context, which is what makes a per-request scope transparent: a handler routes, and no application code calls `Module.forkScope`. This closes the "Per-unit ports" deferral. `RunUnit` was typed for the fork from the start and the fork lands exactly where the annotation in `start.ts` said it would; no runtime package changes, so all three transports get it at once. Design points: - The fork happens INSIDE `registry.run`, and both halves of the placement are load-bearing (`unit-module.spec.ts` guards them): teardown sees the ambient record, and the unit is not counted closed until the scope is, so a drain waits for unit teardown too. - `RuntimeNeedsGate` grows two defaulted parameters and checks both directions of the fork: runtime `needs` may draw on the unit module's exports, and the unit module's own needs must be met by the application module's exports (or `Scope`). Runtime needs are checked first — the branch order matters, since inference through the escape hatch resolves against the first branch's error literal (`start.test-d.ts` pins both branches and the clean runtime-need-from-unit-export call). - The unit module's error channel is pinned to `never`: a construction failure at unit scope has no modeled channel to land in, so it rides the unit's defect path, which every runtime already answers. - `RuntimeHost.ctx` remains the application context; a unit-provided port exists only inside unit work, and resolving one at runtime startup is a defect. Documented in the option's TSDoc and CLAUDE.md. - Without the option, unit work receives the application context exactly as before — zero overhead on the default path. `examples/order-api` is the consumer that motivated it: `apiHandler` drops `Module.forkScope` and `RequestModule` entirely — it routes — and the composition root passes `unit: RequestModule` instead. Its `needs-gate.test-d.ts` pins the new gate against a real module. The kernel gains its first `src/test-fixtures.ts` (excluded from coverage like the examples'), since a new spec follows all five test conventions. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
btravers
commented
Aug 14, 2026
btravers
commented
Aug 14, 2026
There was a problem hiding this comment.
Pull request overview
This PR extends @btravstack/core’s kernel startup API to support a per-unit DI scope (StartOptions.unit) that is forked and torn down by the kernel for every unit of work, removing the need for handlers/runtimes to manage Module.forkScope themselves (e.g., HTTP request scope).
Changes:
- Added
StartOptions.unitand extended the runtime needs gate to account for unit exports and unit-module needs. - Updated kernel internals so the fork happens inside
registry.run, ensuring teardown occurs while the unit’s ambient record is still open and that drains wait for teardown. - Updated docs/examples/type-tests and introduced a core test fixture + spec to pin the new behavior.
Reviewed changes
Copilot reviewed 18 out of 18 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| README.md | Updates high-level docs to describe per-unit ports via StartOptions.unit. |
| CLAUDE.md | Documents the new StartOptions.unit surface and updates core narrative. |
| packages/core/vitest.config.ts | Excludes src/test-fixtures.ts from coverage collection. |
| packages/core/src/with-app.ts | Extends withApp generics/signature to support unit and the expanded gate. |
| packages/core/src/unit-module.spec.ts | Adds spec coverage for per-unit forking and teardown timing relative to ambient unit record. |
| packages/core/src/test-fixtures.ts | Introduces core Vitest fixtures used by new specs to exercise unit-module behavior. |
| packages/core/src/start.ts | Implements StartOptions.unit, expands RuntimeNeedsGate, and forks scope per unit inside registry.run. |
| packages/core/src/start.test-d.ts | Adds type-level pins for the new two-direction gate behavior. |
| packages/core/src/run-main.ts | Extends runMain signature/gate to pass through unit generics/options. |
| packages/core/src/docs-examples.test-d.ts | Adds compiled docs example for StartOptions.unit usage. |
| packages/core/README.md | Documents the per-unit scope feature in the package README. |
| packages/core/CLAUDE.md | Updates internal core invariants/design notes to reflect the unit-module fork placement. |
| examples/order-api/src/test-fixtures.ts | Passes RequestModule via unit instead of managing per-request scope in handler. |
| examples/order-api/src/request-scope.ts | Updates request-scope documentation to reflect kernel-managed unit scoping. |
| examples/order-api/src/needs-gate.test-d.ts | Adds pins for unit-module gate behavior in a real example module. |
| examples/order-api/src/main.ts | Supplies unit: RequestModule to runMain for per-request scope. |
| examples/order-api/src/handler.ts | Removes Module.forkScope from handler and routes using the already-forked context. |
| .changeset/unit-module.md | Adds a changeset documenting the new minor feature. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
btravers
commented
Aug 14, 2026
Review of #40 set the direction: Hono + oRPC + @unthrown/orpc as the one way, and everything in the di context. Three moves: - **The HTTP surface is a di-provided service.** `ApiModule` provides the `ApiHandler` port — a Hono app with oRPC's fetch adapter mounted under `/rpc`, built by a provider, so nothing about the transport is a free-floating module-level singleton. The runtime's handler is one line: resolve the port, call it. The request's forked context flows through Hono's `Bindings` into oRPC's procedure context, so every procedure reads its use cases out of the request's own di scope. - **Hono owns routing and the fetch idiom.** `getRequestListener` bridges the node pair `@btravstack/http` hands over onto `app.fetch`, per request, because the scope it must carry is per-request too. An unmatched path is Hono's 404; a defect stays oRPC's own `INTERNAL_SERVER_ERROR` collapse. hono and @hono/node-server join the catalog. - **The router uses `@unthrown/orpc`'s `.result()` builder extension** rather than wrapping each handler in `handlerResult(...)` — the integration is the surface, not a wrapper around it. `ApiNeeds` becomes what the runtime resolves (`ApiHandler` included); `Logger` leaves the runtime's needs — the unit gate now proves `RequestModule`'s read against the module's exports directly, which the rewritten `needs-gate.test-d.ts` pins in isolation: one negative per gate half, each unmet for exactly one reason. Also closes the review's wording nit in `docs-examples.test-d.ts`: `onStop` puts `Scope` in the unit module's NEEDS; the fork is what opens one. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 25 out of 26 changed files in this pull request and generated no new comments.
Files not reviewed (1)
- pnpm-lock.yaml: Generated file
Suppressed comments (1)
packages/core/src/test-fixtures.ts:79
- Fixture teardown awaits
app.exitedbut discards theResult. If shutdown fails (Err/Defect), the fixture will still complete and the test can pass while the application exited uncleanly. Capture and assert/throw on the exitResultso teardown failures fail the test.
app.stop();
await app.exited;
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
examples/order-apicarried the per-request plumbing by hand:apiHandleropeneda
Module.forkScopearound every request and threadedRequestModulethrough.That was the repo's own deferred item — "Per-unit ports: the
unitmodule wiredinto
run's fork; theModule.forkScopecall lands when the first runtime needsa per-request transaction" — and order-api was that first consumer. This closes
the deferral at the kernel, where
start.ts's annotation said the fork would land.The design
StartOptions.unit— a module the kernel forks around every unit: built asthe unit opens (reading anything the application context carries), torn down as
it closes. Unit work receives the forked
Context; without the option, theapplication context exactly as before.
registry.run, and both halves of that placement areload-bearing (
unit-module.spec.tsguards them): teardown runs while theunit's ambient record is still open — a span's
onStoplogs under therequest's own trace id — and the unit is not counted closed until the scope is,
so a drain waits for unit teardown too.
RuntimeNeedsGategrows two defaultedparameters: runtime
needsmay draw on the unit module's exports (unit work iswhat receives them), and the unit module's own needs must be met by the
application module's exports or
Scope. Runtime needs check first — branchorder matters, since inference through the documented escape hatch resolves
against the first branch's error literal.
start.test-d.tspins both branchesplus the clean runtime-need-from-unit-export call; order-api's
needs-gate.test-d.tspins the same against a real module.Eis pinned tonever. A construction failure at unit scope has nomodeled channel to land in, so it rides the unit's defect path — which every
runtime already answers (an HTTP 500, a dead-letter).
RuntimeHost.ctxremains the application context.A unit-provided port exists only while a unit is open; resolving one at runtime
startup is a defect.
ctxhanded to unitwork, so http, temporal and amqp all get this at once —
RunUnitwas typed forit from the start.
Also in here
The kernel gains its first
src/test-fixtures.ts(new specs follow all five testconventions; excluded from coverage the way the examples' fixtures are), and the
"Per-unit ports" entry leaves Deferred, deliberately. Root CLAUDE.md,
packages/core/CLAUDE.md, both core READMEs anddocs-examples.test-d.tsmovein the same commit; changeset included.
Gate
format✓lint✓typecheck✓ (22 tasks)knip✓build✓test21/22 —core is 102/103 with the known local port-9000 clash;
unit-module.spec.ts2/2,order-api 18/18 (its two per-request span-teardown log assertions now pass
through the kernel's fork).
🤖 Generated with Claude Code