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
2 changes: 1 addition & 1 deletion .oxlintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
},
"overrides": [
{
"files": ["**/*.spec.ts"],
"files": ["**/*.spec.ts", "**/test-fixtures.ts"],
"rules": {
"unthrown/no-get-or-throw": "off"
}
Expand Down
115 changes: 105 additions & 10 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -583,10 +583,12 @@ Source layout (`packages/start/src/`), one concept per file: `ambient.ts`
`boundPort`), and a throw that **is the subject under test**
(`events.spec.ts`'s throwing sink, `units.spec.ts`'s throwing unit,
`run-main.spec.ts`'s defect, which has no public constructor to mint it any
other way). `no-get-or-throw` is switched off for the `*.spec.ts` glob through
an `overrides` entry — the exemption the rule's own diagnostic prescribes,
since `getOrThrow()` is the right tool in a test; it stays on everywhere
else, where nothing uses it. An unused `oxlint-disable` is itself a warning,
other way). `no-get-or-throw` is switched off for the `**/*.spec.ts` **and
`**/test-fixtures.ts`** globs through an `overrides` entry — the exemption the
rule's own diagnostic prescribes, since `getOrThrow()` is the right tool in a
test, and a fixture module is test code that merely does not end in
`.spec.ts` (see Test conventions); it stays on everywhere else, where nothing
uses it. An unused `oxlint-disable` is itself a warning,
so do not add one pre-emptively.
- **Pre-lifted constructors, not `.toAsync()` on a fresh literal.** `OkAsync(v)`
/ `ErrAsync(e)` / `OkAsync()` are what unthrown ships for this;
Expand All @@ -601,16 +603,99 @@ Source layout (`packages/start/src/`), one concept per file: `ambient.ts`
- Conventional commits (`feat:`, `fix:`, `docs:`, `test:`, `chore:`).
- Coverage thresholds are 100% lines/functions on `packages/start`, with
`testing.ts` excluded (it is a re-export barrel).
- **Test conventions.** `@unthrown/vitest`'s matchers are registered via
`setupFiles` (`toBeOk`, `toBeOkWith`, `toBeErrTagged`, …). Timing is asserted
through `createFakeClock`, never a real `setTimeout` — a kernel whose own
tests are slow gets tested badly. `*.test-d.ts` files are excluded from the
build, from oxlint and from knip; they are checked by `tsc -p
tsconfig.test-d.json`, which `pnpm typecheck` runs.
- Test mechanics: `@unthrown/vitest`'s matchers are registered via `setupFiles`
(`toBeOk`, `toBeOkWith`, `toBeErrTagged`, …). Timing is asserted through
`createFakeClock`, never a real `setTimeout` — a kernel whose own tests are
slow gets tested badly. `*.test-d.ts` files are excluded from the build, from
oxlint and from knip; they are checked by `tsc -p tsconfig.test-d.json`, which
`pnpm typecheck` runs. The structural rules are in **Test conventions** below.
- Documentation drifts silently, and a sibling repo has already shipped a
falsehood this way. When the public surface changes, update `CLAUDE.md`, both
READMEs **and** `docs-examples.test-d.ts` in the same commit.

## Test conventions

Five rules, each with the reason it exists. They hold across `examples/`, which
is the teaching surface and where the shape is read as advice. `packages/start`'s
own 14 spec files still predate them — that sweep is deliberately deferred and
reviewed separately (see Status), so a **new or rewritten** kernel spec follows
these and an untouched one is not churned for it.

1. **`describe` is the first statement a reader meets.** After the imports,
nothing but `describe`. A file that opens with 144 lines of helpers makes a
reader scroll past the scaffolding to reach the subject, and every one of
those helpers is invisible state a test silently depends on. What a test needs
should arrive **through its own parameter list**, so the dependency is written
down at the point of use.
2. **Helpers are Vitest fixtures, injected via `test.extend`, and they live in a
sibling `src/test-fixtures.ts`.** The module exports an extended `it`, which
every spec in that package imports instead of vitest's own. Keeping the
`test.extend` block out of the spec is what makes rule 1 achievable — the
fixture bodies are themselves helpers, so leaving them above `describe` only
renames the problem. A shared module also lets several `describe` blocks (and
later, several spec files) draw on one set. Fixtures are **lazy**: a test that
does not name one never builds it, so an expensive fixture costs nothing in
the tests that ignore it.
Both `**/*.spec.ts` and `**/test-fixtures.ts` are in the `.oxlintrc.json`
`overrides` entry that switches `unthrown/no-get-or-throw` off, because a
fixture is test code and `getOrThrow()` is the right tool there.
3. **Teardown belongs in the fixture, never in `try`/`finally`.** Everything
after `await use(value)` runs on **every** exit path, including a failing
assertion — which is precisely what the `finally` blocks were hand-rolling,
at the cost of a `try` around every test body and one more level of
indentation around the part that matters. An `expect` in fixture cleanup is
still a test failure attributed to that test (verified, not assumed), so the
guarantee a `finally` carried survives the move intact.
4. **Every test body carries `// GIVEN`, `// WHEN`, `// THEN`.** They mark the
three phases so the assertion is not read as setup and the setup is not read
as the subject; a test that cannot be split into three is usually testing more
than one thing. These markers are **exempt from the sparse comment-density
rule** above — they are structure, not narration.
5. **One deep `expect` per test, asserting once against one resource.** Not a
scatter of shallow assertions, and never an assertion that can decline to
run. The failure mode is concrete: `expect(r).toBeErr(); if (r.isErr()) {
expect(r.error.code)… }` passes on the outer assertion alone the moment the
narrowing is false — every assertion inside silently does not run, and the
test still goes green. So does `descriptor?.writable`, and so does any
assertion reached through an `&&` guard. A single deep assertion has no such
hole: `await expect(call()).toBeErrWith(expect.objectContaining({ code:
"CONFLICT", data: { id } }))` either matches or fails. In practice:
- Collapse several properties of one resource into one deep assertion, with
`@unthrown/vitest`'s matchers (`toBeOkWith`, `toBeErrWith`,
`toBeErrTagged`, `toBeDefectWith`) plus `expect.objectContaining` /
`expect.any` / `expect.not.stringContaining` where a partial or loose match
is genuinely wanted. To pin a **class** inside the same assertion, put
`constructor: TheClass` in the `objectContaining` — asymmetric matchers
read through the prototype chain, so it is `toBeInstanceOf` without a
second `expect` (verified: it rejects a structural impostor).
Where the facts are not properties of one object, assert a **projection**
of them (`expect({ livez, readyz, ready }).toEqual({ … })`).
- **Two resources means two tests**, not two assertions. The test count
rising is the expected outcome.
- The GIVEN phase asserts nothing. Chain the setup into the subject
(`repository.save(x).flatMap(() => repository.find(id))`) so a failed setup
surfaces in the one assertion instead of needing a guard assertion of its
own — which also keeps the setup's `Result` consumed rather than dropped.
- Waiting is not asserting: use `vi.waitUntil(() => …)` to synchronise on a
state, and assert that state in the test's one `expect`. `expect.poll` used
as a barrier reads as an assertion and is not one.
- Fixture teardown keeps its own `expect` (rule 3) — that is cleanup, not the
test's assertion.

A sixth rule is about production code that tests keep honest:

6. **Configuration is validated through a schema and returned as a value, never
`.parse()`d.** `examples/order-api/src/env.ts` is the shape: a schema over
`process.env` run through `@unthrown/standard-schema`'s `fromSchema`, whose
issues are the modeled `E`, folded by the entry point into a message and a
non-zero exit code. A schema's own `.parse()` **throws**, which
`unthrown/no-throw` bans and which would contradict the example it appears in.
The schema reads **strings** rather than `z.coerce.number()`: coercion is
`Number()` underneath, so `PORT=abc` binds `NaN` and `PORT=` binds the
ephemeral port `0` — the exact silent failure the module exists to remove.
Note `fromSchema` is **curried** — `fromSchema(schema)(input)`, not
`fromSchema(schema, input)`.

## Status

Shipped: the whole kernel — phase tracker, injectable clock, ambient record,
Expand All @@ -627,3 +712,13 @@ Deferred, deliberately:
- Per-unit ports: the `unit` module wired into `run`'s fork. `RunUnit` is typed
for it; the `Module.forkScope` call lands when the first runtime needs a
per-request transaction.
- Bringing `packages/start`'s **14 spec files / 93 tests** under the Test
conventions above. All 14 need the GIVEN/WHEN/THEN markers; **9** also carry a
helper preamble to lift into a `test-fixtures.ts` (`drain` 82 lines,
`invariants` 74, `with-app` 37, `probes` 30, `run-main` 28, `test-runtime` 21,
`start` 17, `units` 12, `process-handlers` 7 — the other five have only
imports above `describe`), and exactly **one** `try`/`finally` needs moving
into a fixture (`drain.spec.ts`). Held back deliberately: it is a large
mechanical sweep over the tests that guard the nine invariants, so the
regression risk is real and it wants its own review rather than riding along
with an examples change.
188 changes: 188 additions & 0 deletions examples/order-api/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
# `@btravstack/start` example: the order API layer

The transport. An oRPC contract, a router, and a `Runtime` that serves them over
`node:http` under the kernel's lifecycle.

```
src/contract.ts the oRPC contract — the wire shapes and the declared error codes
src/router.ts the implementation, and the one place a domain error becomes an ORPCError
src/request-scope.ts RequestModule — a scope forked per request over the application's
src/orpc-runtime.ts the Runtime: start / drain / stop
src/client.ts an AsyncResult client for the same contract
src/module.ts OrderApiModule — the composition root
src/env.ts process.env validated through a schema, as a Result
src/main.ts the process: readEnv + start + runMain
src/test-fixtures.ts serve / clientFor / gate / tapped, as Vitest fixtures
```

## The two channels survive the wire

oRPC v2 splits failures the way unthrown does. An error a procedure **declares**
(or returns as a value) is _inferable_ — typed end to end; everything else
collapses to `INTERNAL_SERVER_ERROR`. That maps onto the variants with no
adapter in between:

| unthrown | oRPC |
| ------------ | ----------------------- |
| `Ok(value)` | the procedure's output |
| `Err(error)` | a returned `ORPCError` |
| `Defect` | `INTERNAL_SERVER_ERROR` |

`handlerResult` performs that elimination, and the `mapErrCases` in front of it
is the triage point — the boundary where the application's vocabulary stops:

```ts
context.scope
.get(PlaceOrder)
.execute(input.id, input.quantity)
.map(view)
.mapErrCases((matcher) =>
matcher
.with(P.tag("InvalidQuantity"), (error) =>
errors.INVALID_QUANTITY({
message: error.message,
data: { id: error.id },
}),
)
.with(P.tag("DuplicateOrder"), (error) =>
errors.CONFLICT({ message: error.message, data: { id: error.id } }),
),
);
```

Every case is named — this repo bans `P._`, and `mapErrCases` has no
`.otherwise()`. A new domain error is a compile error here, at the one file that
has to decide what a client sees. A `Defect` is never named: it has no code
because it was never modelled, and collapsing it to a 500 is the correct
treatment rather than a fallback.

## The runtime's three methods

- **`start`** binds the socket and hands back a `Serving`. A bind failure is a
modeled `Err(RuntimeStartFailed)`, never a throw.
- **`Serving.drain(signal)`** stops _accepting_: it closes the listener and the
idle keep-alive connections, and leaves requests already in flight to run to
completion. The kernel's deadline signal has nothing to cancel here — the
in-flight units are the kernel's to time out.
- **`Serving.stop()`** closes for good and **destroys** every remaining socket.
`node:http`'s `close()` waits out keep-alive connections, so without the socket
set the process would never exit.

### `Serving.info`, not an `onListening` hook

The runtime binds `port: 0` in every spec and publishes what it got:

```ts
const info = (await app.runtimeInfo()).get(); // { port, prefix }
```

`Serving.info` is the kernel's channel for exactly this, which is why there is
no `onListening` callback and no `boundPort()` accessor to keep in sync.

### One unit per call

```ts
host.run(metaFor(request), (ctx, _signal) =>
Module.forkScope(ctx, RequestModule, (scope) =>
fromSafePromise(
handler.handle(request, response, { prefix, context: { scope } }),
),
),
);
```

Two things in there are easy to get wrong:

- **`UnitMeta.id` is minted per request**, not set to the route. `traceId`
defaults to `id`, so a category there would give every request the same trace
id and silently defeat the ambient record. An inbound `x-request-id` becomes
the `traceId` — the correlation id is the one an outside caller may choose.
- **The response is flushed inside the unit.** oRPC's `handle` resolves only
once the response has closed, so the unit stays open until the bytes are on
the wire. Returning first and writing afterwards races `stop()` destroying the
socket.

### A request scope over the application scope

The application scope is opened once, by the kernel, and holds the database.
Opening another per request would give every request its own empty in-memory
database — so the runtime **forks**: `Module.forkScope` layers a short-lived
scope over the one already built, and a request-scoped provider reads what the
parent constructed instead of rebuilding it. `RequestSpan`'s `onStop` runs while
the unit is still open, which is what gives its line the request's own trace id.

## The client half

```ts
const client = createOrderApiClient("http://127.0.0.1:3000");

const named = (await client.orders.place({ id, quantity })).match({
ok: () => "placed",
errCases: (matcher) =>
matcher
.with({ code: "INVALID_QUANTITY" }, (error) => error.code)
.with({ code: "CONFLICT" }, (error) => error.code),
defect: () => "bug",
});
```

The error channel is the raw `ORPCError` union discriminated by `code` — not
re-wrapped into a second error concept — so the client's match is the mirror of
the server's `mapErrCases`.

## Running it

```bash
pnpm --filter @btravstack/start-example-order-api test # 8 runtime specs + 4 env specs
```

The specs run against a real HTTP server and a real oRPC client — genuine JSON
serialization, which is where the defect collapse to `INTERNAL_SERVER_ERROR`
actually happens. No Docker, nothing to install.

Every helper they need is a Vitest fixture in `src/test-fixtures.ts`, so the spec
opens on `describe` and each test names its dependencies in its own parameter
list. Shutting an app down is the `serve` fixture's job, which is why no test
here has a `try`/`finally`: fixture cleanup runs even when the body fails, and it
still asserts the app exited `Ok`.

```ts
it("lets an in-flight call finish while draining", async ({ serve, clientFor, gate }) => {
// GIVEN a call held open inside the repository
const app = serve(gate.api);
});
```

`src/main.ts` is the process itself — and it reads its configuration the same way
it reads everything else, as a value:

```ts
await readEnv().match({
ok: (env) =>
runMain(
start(OrderApiModule, {
runtime: orpcRuntime({ port: env.PORT }),
probes: { port: env.PROBE_PORT },
}),
),
errCases: (matcher) =>
matcher.with(P._, (issues) => abort(describeEnvIssues(issues))),
defect: (cause) =>
abort(`the environment could not be validated: ${String(cause)}`),
});
```

`src/env.ts` is where `PORT` and `PROBE_PORT` are validated. It goes through
`@unthrown/standard-schema`'s `fromSchema` rather than a schema's own `.parse()`,
because `.parse()` throws — which `unthrown/no-throw` bans, and which would
contradict the example it appears in. The issues are the modeled `E`, folded
above into a message and a non-zero exit code.

The schema reads **strings**, not `z.coerce.number()`: coercion is `Number()`
underneath, so `PORT=abc` would bind `NaN` and `PORT=` would bind `0`, the
ephemeral port. A malformed value is a validation issue instead.

It is typechecked by the gate rather than executed by it: the example packages
are source-only — no build step, `main` pointing straight at `src/` — so there
is no compiled entry for `node` to run, and every spec drives `start` directly.
38 changes: 38 additions & 0 deletions examples/order-api/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
{
"name": "@btravstack/start-example-order-api",
"private": true,
"description": "The transport layer of the clean-architecture example: an oRPC router over node:http, driven as a start Runtime",
"license": "MIT",
"author": "Benoit TRAVERS <benoit.travers.fr@gmail.com>",
"type": "module",
"main": "./src/index.ts",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"test": "vitest run",
"test:types": "tsc --noEmit -p tsconfig.test-d.json",
"typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.test-d.json"
},
"dependencies": {
"@btravstack/di": "catalog:",
"@btravstack/start": "workspace:*",
"@btravstack/start-example-order-application": "workspace:*",
"@btravstack/start-example-order-domain": "workspace:*",
"@btravstack/start-example-order-infrastructure": "workspace:*",
"@orpc/client": "catalog:",
"@orpc/contract": "catalog:",
"@orpc/server": "catalog:",
"@unthrown/orpc": "catalog:",
"@unthrown/standard-schema": "catalog:",
"unthrown": "catalog:",
"zod": "catalog:"
},
"devDependencies": {
"@btravstack/tsconfig": "catalog:",
"@types/node": "catalog:",
"@unthrown/vitest": "catalog:",
"typescript": "catalog:",
"vitest": "catalog:"
}
}
23 changes: 23 additions & 0 deletions examples/order-api/src/client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { createORPCClient } from "@orpc/client";
import { RPCLink } from "@orpc/client/fetch";
import type { RouterClient } from "@orpc/server";
import { createResultClient, type ResultClient } from "@unthrown/orpc/client";

import type { orderRouter } from "./router.js";

/**
* The caller's view of the API: every procedure returns an `AsyncResult` whose
* error channel is the inferable `ORPCError` union the contract declares,
* discriminated by `code`. Everything else — a network failure, a defect
* collapsed to `INTERNAL_SERVER_ERROR` — is a `Defect`, so the two channels
* survive the wire in both directions.
*/
export type OrderApiClient = ResultClient<RouterClient<typeof orderRouter>>;

export const createOrderApiClient = (
origin: string,
prefix: `/${string}` = "/rpc",
): OrderApiClient =>
createResultClient(
createORPCClient<RouterClient<typeof orderRouter>>(new RPCLink({ origin, url: prefix })),
);
Loading
Loading