Skip to content

feat(core): add StartOptions.unit, the per-unit scope no handler manages - #40

Open
btravers wants to merge 2 commits into
mainfrom
feat/unit-module
Open

feat(core): add StartOptions.unit, the per-unit scope no handler manages#40
btravers wants to merge 2 commits into
mainfrom
feat/unit-module

Conversation

@btravers

Copy link
Copy Markdown
Contributor

examples/order-api carried the per-request plumbing by hand: apiHandler opened
a Module.forkScope around every request and threaded RequestModule through.
That was the repo's own deferred item — "Per-unit ports: the unit module wired
into run's fork; the Module.forkScope call lands when the first runtime needs
a 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 handler, before                              // after
Module.forkScope(ctx, RequestModule, (scope) =>     fromSafePromise(
  fromSafePromise(handler.handle(req, res, {          handler.handle(req, res, {
    prefix, context: { scope },                         prefix, context: { scope: ctx },
  })),                                                }),
);                                                  );

// the composition root
runMain(OrderApiModule, { runtime, unit: RequestModule, probes });

The design

  • StartOptions.unit — a module the kernel forks around every unit: built as
    the unit opens (reading anything the application context carries), torn down as
    it closes. Unit work receives the forked Context; without the option, the
    application context exactly as before.
  • The fork sits INSIDE registry.run, and both halves of that placement are
    load-bearing (unit-module.spec.ts guards them): teardown runs while the
    unit's ambient record is still open — a span's onStop logs under the
    request's own trace id — and the unit is not counted closed until the scope is,
    so a drain waits for unit teardown too.
  • The gate covers both directions. RuntimeNeedsGate grows two defaulted
    parameters: runtime needs may draw on the unit module's exports (unit work is
    what receives them), and the unit module's own needs must be met by the
    application module's exports or Scope. Runtime needs check first — branch
    order matters, since inference through the documented escape hatch resolves
    against the first branch's error literal. start.test-d.ts pins both branches
    plus the clean runtime-need-from-unit-export call; order-api's
    needs-gate.test-d.ts pins the same against a real module.
  • Unit E 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 (an HTTP 500, a dead-letter).
  • One caveat, documented: RuntimeHost.ctx remains the application context.
    A unit-provided port exists only while a unit is open; resolving one at runtime
    startup is a defect.
  • No runtime package changes. The kernel controls the ctx handed to unit
    work, so http, temporal and amqp all get this at once — RunUnit was typed for
    it from the start.

Also in here

The kernel gains its first src/test-fixtures.ts (new specs follow all five test
conventions; 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 and docs-examples.test-d.ts move
in the same commit; changeset included.

Gate

formatlinttypecheck ✓ (22 tasks) knipbuildtest 21/22 —
core is 102/103 with the known local port-9000 clash; unit-module.spec.ts 2/2,
order-api 18/18 (its two per-request span-teardown log assertions now pass
through the kernel's fork).

🤖 Generated with Claude Code

`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>
Copilot AI lite review requested due to automatic review settings August 14, 2026 22:52
Comment thread examples/order-api/src/handler.ts Outdated
Comment thread examples/order-api/src/main.ts

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.unit and 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.

Comment thread packages/core/src/docs-examples.test-d.ts Outdated
Comment thread examples/order-api/src/main.ts Outdated
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>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.exited but discards the Result. 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 exit Result so teardown failures fail the test.
    app.stop();
    await app.exited;

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants