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
32 changes: 31 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,10 @@ throws to callers: every fallible operation returns an
[`unthrown`](https://github.com/btravstack/unthrown) `Result`.

pnpm workspace + turbo monorepo. `packages/start` is the single published
package; there are no other workspaces yet.
package; `examples/` holds five private ones — a clean-architecture application
(`order-domain` → `order-application` → `order-infrastructure`) booted under two
different runtimes (`order-api`, `order-worker`). They are consumers, not
fixtures: they are part of the gate, and `examples/README.md` is their index.

## Commands

Expand Down Expand Up @@ -60,6 +63,11 @@ hook). User-facing changes need a changeset.
question of how two runtimes in one process share a drain deadline, or whose
failure takes the process down. `StartOptions.runtime` is therefore a single
value, not an array, and no future option should make it plural.
`examples/order-api` and `examples/order-worker` make this testable rather
than asserted: the same `ApplicationModule` + `PersistenceModule`
composition under two runtimes, with the same `DuplicateOrder` arriving as a
typed `CONFLICT` on one and as a dead-letter on the other — and neither
mapping anywhere near the kernel.

2. **Ambient carries DATA. The DI `Context` carries CAPABILITIES.** The kernel
opens one `AsyncLocalStorage` store per unit holding a small, fixed record —
Expand Down Expand Up @@ -559,6 +567,25 @@ Source layout (`packages/start/src/`), one concept per file: `ambient.ts`

## Toolchain & conventions

- **`examples/` is part of the gate, not a folder of illustrations.** All five
workspaces run under the same six commands as the kernel — 59 specs plus two
`needs-gate.test-d.ts` files — so an example that stops compiling, stops
linting or stops passing fails CI exactly as `packages/start` would. They are
also the only place a runtime with a **non-empty `needs`** meets a real
module, which is what exercises `start`'s phantom rest-tuple gate and
`RuntimeHost`'s `Context<InstanceType<Needs>>` end to end.
- **The Prisma client is generated at test time, and there is nothing to
install.** `@btravstack/start-example-order-infrastructure`'s `test` and
`typecheck` scripts both begin with `prisma generate`, writing a gitignored
client into `src/generated`, and turbo's `test` / `typecheck` / `test:types`
tasks carry a `^generate` edge so a dependent workspace gets one too. The
database is SQLite **in memory** with the schema applied by hand —
deliberately no Docker, so `pnpm test` stays self-contained on any machine.
- **oRPC is pinned to an exact beta.** `@orpc/{client,contract,server}` sit at
`2.0.0-beta.23` in the catalog because oRPC v2's `latest` dist-tag is still
the **1.x** line, while `@unthrown/orpc` peers on `^2.0.0-beta`: an unpinned
range resolves 1.x and fails `strictPeerDependencies`. The exact beta is the
contract until v2 goes stable; raise it deliberately, not on a bot bump.
- **Runtime dependencies: none.** `unthrown` and `@btravstack/di` are **peer**
dependencies — the dual-copy hazard is real for both (di's port identity and
unthrown's `isResult` each compare across copies). `node:` builtins only
Expand Down Expand Up @@ -701,6 +728,9 @@ A sixth rule is about production code that tests keep honest:
Shipped: the whole kernel — phase tracker, injectable clock, ambient record,
unit registry, `Runtime` contract, `start`, draining, signals, uncaught
handling, probes, `runMain`, the testing entry point, and the invariants suite.
Plus the five `examples/` workspaces: the clean-architecture application and its
**two** deployments, `order-api` (oRPC) and `order-worker` (an in-memory queue),
which together are the proof of Thesis #1.

Deferred, deliberately:

Expand Down
15 changes: 11 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,12 @@ and deploy independently, and it removes a whole class of design problem: there
is never a question of how two runtimes in one process share a drain deadline,
or whose failure takes the process down.

[`examples/`](./examples) proves this rather than asserting it: one
clean-architecture application, booted by an oRPC runtime and by a queue-worker
runtime, with the application and persistence layers unchanged between them —
and the same `DuplicateOrder` arriving as a typed `CONFLICT` on one and as a
dead-letter on the other.

## The `Runtime` contract

```ts
Expand Down Expand Up @@ -554,10 +560,11 @@ complete one.

## Documentation

See [`packages/start`](./packages/start) for the package README, and
[`CLAUDE.md`](./CLAUDE.md) for the authoritative spec: the theses, the
load-bearing invariants with the test that guards each, and the internal design
notes.
See [`packages/start`](./packages/start) for the package README,
[`examples/`](./examples) for a five-package clean-architecture application
booted under two different runtimes, and [`CLAUDE.md`](./CLAUDE.md) for the
authoritative spec: the theses, the load-bearing invariants with the test that
guards each, and the internal design notes.

## License

Expand Down
106 changes: 106 additions & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
# Examples

Five small packages that are **one application booted two ways**: a clean
architecture split across four layers, deployed once as an oRPC API and once as
a queue worker — and, at the same time, exercising `@btravstack/start` end to
end from a consumer's own workspace, `workspace:*` and all.

| Package | Layer | Shows |
| ------------------------------------------------ | --------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
| [`order-domain`](./order-domain) | domain | Entities and rules with no dependencies at all: branded fields, an `Entity.invariant` re-checked on every path, failures as values. |
| [`order-application`](./order-application) | use cases | Ports declared by the caller, interactors, and an `ApplicationModule` whose `OrderRepository` is deliberately an **unmet need**. |
| [`order-infrastructure`](./order-infrastructure) | adapters | A Prisma-backed repository over in-memory SQLite, translating P-codes into the domain's vocabulary and closing the application's one need. |
| [`order-api`](./order-api) | runtime | The first deployment: an oRPC router over `node:http`, a scope forked per request, and `Result` → `ORPCError`. |
| [`order-worker`](./order-worker) | runtime | The second deployment: an in-memory queue worker over the **same** composition, and `Result` → ack / retry / dead-letter. |

## The layering, and which way the arrows point

```
order-api order-worker ← one runtime each; one process each
└───────────┬───────────────┘
order-infrastructure ← Prisma, SQLite, P-codes
│ provides OrderRepository
order-application ← use cases, and the ports they declare
order-domain ← entities and rules; depends on nothing
```

Every arrow points **inwards**, and the one that looks like it goes the wrong
way is the whole idea: `order-infrastructure` imports `order-application`,
because the port it implements — `OrderRepository`, spelled in the domain's
vocabulary — is declared by the caller that needs it, not by the database that
happens to satisfy it. `ApplicationModule` therefore leaves that need **unmet**,
which is not documentation but a type: `Module.scoped(ApplicationModule, …)`
does not compile until an outer module provides one.

## One application, two deployments

`OrderApiModule` and `OrderWorkerModule` are the same three lines:

```ts
imports: [ApplicationModule, PersistenceModule],
provides: [],
exports: [PlaceOrder, FindOrder, Logger],
```

Nothing in `order-application` or `order-infrastructure` differs between them,
and nothing could: the use cases return a `Result`, and what a `Result` means to
a transport is the transport's business. The kernel's headline claim — several
runtime _kinds_, one per process, over the same module — is proved here rather
than asserted, and the sharpest form of the proof is that **the same `Err`
becomes two different outcomes**:

| unthrown | `order-api` | `order-worker` |
| ---------------------- | ----------------------- | --------------------------- |
| `Ok(order)` | the procedure's output | **ack** |
| `Err(InvalidQuantity)` | `INVALID_QUANTITY` | **dead-letter** |
| `Err(DuplicateOrder)` | `CONFLICT` | **dead-letter** |
| `Defect` | `INTERNAL_SERVER_ERROR` | **retry**, then dead-letter |

The kernel appears in neither column. `RunUnit` hands a runtime the work's own
`Result` and stays out of what it means.

## The only non-empty `needs` in the repo

`orpcRuntime` declares `[PlaceOrder, FindOrder, Logger]` and
`queueWorkerRuntime` declares `[PlaceOrder, Logger]` — two of the three the
module exports, because a runtime declares what _it_ needs. They are the **only
runtimes in this repository with a non-empty `needs`**: the kernel's own
`testRuntime` needs nothing, so `start`'s phantom rest-tuple gate — and
`RuntimeHost`'s `Context<InstanceType<Needs>>`, where a runtime names port
_classes_ while di parameterises contexts by port _instances_ — are exercised
against a real module here and nowhere else.

Both directions are pinned, in `order-api/src/needs-gate.test-d.ts` and
`order-worker/src/needs-gate.test-d.ts`: the wired call is an ordinary
two-argument one, and a module one port short fails on **arity**, naming the
missing need.

## Why these are tests, not just illustrations

Each package reads as application code, and each is covered by real specs — 59
of them, run by the repository's own `pnpm test`:

```sh
pnpm install
pnpm test # every example's specs, alongside the kernel's own
pnpm typecheck # includes the compile-time-only guarantees pinned with @ts-expect-error
```

Nothing is faked at the boundaries that matter. `order-infrastructure` runs
against a real Prisma client over in-memory SQLite, so a `DuplicateOrder` comes
from an actual `UNIQUE` index raising an actual P2002. `order-api` runs a real
`node:http` server and a real oRPC client over it, so the collapse of a `Defect`
to `INTERNAL_SERVER_ERROR` happens where it really happens. **No Docker, and
nothing to install**: the Prisma client is generated by the `test` script
itself.

Where a guarantee is compile-time only — an unmet port, a runtime's `needs` —
the assertion is a `@ts-expect-error` in a `*.test-d.ts` file, checked by `tsc`
rather than executed.

Nothing here is published: every package is `"private": true` and depends on the
kernel via `workspace:*`.
6 changes: 5 additions & 1 deletion examples/order-api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ adapter in between:
| `Err(error)` | a returned `ORPCError` |
| `Defect` | `INTERNAL_SERVER_ERROR` |

None of it is the kernel's doing — which is what [`order-worker`](../order-worker)
demonstrates by folding the very same `Result` into ack / retry / dead-letter
over the very same composition root.

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

Expand Down Expand Up @@ -133,7 +137,7 @@ the server's `mapErrCases`.
## Running it

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

The specs run against a real HTTP server and a real oRPC client — genuine JSON
Expand Down
164 changes: 164 additions & 0 deletions examples/order-worker/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
# `@btravstack/start` example: the order worker

The second deployment. The same application, the same persistence, the same
composition — consumed off a queue instead of served over HTTP.

```
src/queue.ts the broker, reduced to what a worker needs of one
src/queue-runtime.ts the Runtime: start / drain / stop, and the ack/retry/dead-letter mapping
src/module.ts OrderWorkerModule — 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 / queue / gate / tapped, as Vitest fixtures
```

## The point of this package

`OrderWorkerModule` is `OrderApiModule` with a different name:

```ts
export const OrderWorkerModule = Module("OrderWorker")({
imports: [ApplicationModule, PersistenceModule],
provides: [],
exports: [PlaceOrder, FindOrder, Logger],
});
```

Nothing in `order-application` or `order-infrastructure` changed to make this
work, and nothing could have: the use cases return a `Result`, and what a
`Result` means to a transport is the transport's business. **One process, one
runtime** is not a slogan the kernel makes you take on trust — it is two
composition roots, two `Runtime` values, and one application underneath.

## The same `Err`, two transports

`DuplicateOrder` is one value. Over HTTP there is a caller waiting to be told,
so it becomes a `CONFLICT` the client receives **as a value**. On a queue there
is no caller, so the message is **parked** for a human instead.

| unthrown | oRPC (`order-api`) | queue (this package) |
| ---------------------- | ----------------------- | --------------------------- |
| `Ok(order)` | the procedure's output | **ack** |
| `Err(InvalidQuantity)` | `INVALID_QUANTITY` | **dead-letter** |
| `Err(DuplicateOrder)` | `CONFLICT` | **dead-letter** |
| `Defect` | `INTERNAL_SERVER_ERROR` | **retry**, then dead-letter |

The third row is the sharp one and the fourth is its mirror. An unmodelled
failure is the infrastructure one — a dropped connection, a pool timeout — and
infrastructure comes back, so it is the one thing worth another delivery. A
modelled error never is: a redelivery would ask the same impossible thing again.

```ts
ctx
.get(PlaceOrder)
.execute(job.orderId, job.quantity)
.match({
ok: () => ack,
errCases: (matcher) =>
matcher
.with(P.tag("InvalidQuantity"), (error) => deadLetter(error._tag))
.with(P.tag("DuplicateOrder"), (error) => deadLetter(error._tag)),
defect: (cause) => retry(String(cause)),
});
```

Every case is named — this repo bans `P._`, and there is no `.otherwise()`. A
new domain error is a compile error here **and** in `order-api/src/router.ts`,
at the two places that have to decide what it means.

## Acking is flushing

The disposition is applied **inside the unit**, exactly as the API writes its
response inside the unit:

```ts
host.run(metaFor(delivery), (ctx, _signal) =>
dispositionOf(ctx, delivery.job).flatMap((disposition) =>
dispose(ctx.get(Logger), queue, delivery, maxAttempts, disposition),
),
);
```

A unit closes the instant its `Result` settles, and an idle registry is the
kernel's permission to call `Serving.stop()`. Settling the message afterwards
would race that: the message would be neither acked nor requeued when the
process went away. Flushing a response and acking a message are the same
obligation, wearing different clothes.

## A delivery is the unit; the message is the trace

```ts
const metaFor = (delivery: Delivery): UnitMeta => ({
kind: "job",
id: `${delivery.job.id}#${delivery.attempt}`,
traceId: delivery.job.id,
});
```

`UnitMeta.id` must be unique per unit, and a message id is not one: a retried
message is delivered twice and is two units. So the **delivery** is the id, and
the message id becomes the `traceId` — which is exactly what `traceId` is for.
It is the correlation id, minted outside this process, and holding it steady
across attempts is what joins three deliveries into one trace.

## A publish resolves on a **worker**, not on a broker

`OrderQueue.publish` hands the producer the consumer's outcome:

```ts
await expect(queue.publish(aJob("job-1", "o-1", 2))).toBeOkWith({
jobId: "job-1",
outcome: "acked",
attempts: 1,
});
```

That is a test convenience, and it carries a precondition worth stating: a real
AMQP `publish` resolves on the **broker's** ack and the producer never learns
how the message ended. Here it resolves when a **running worker settles** the
job — so the attempt budget, which bounds the retries of a job a worker has
claimed, says nothing about a job being claimed at all. Publish with no worker
running, or leave a message behind when one drains, and the returned
`AsyncResult` **never settles**: awaiting it waits forever.

That is not a bug in the queue — it is what a broker does with an unconsumed
message, and `Serving.drain` stopping at _claiming_ is the right shape (the
next worker on the queue takes it). It is a bug waiting to happen in a spec, so
two of them pin it, racing the publish against one macrotask turn rather than
awaiting it: _"never settles a job published with no worker running"_ and
_"leaves a job the drain never claimed unsettled, without waiting for it"_. Both
fail in a millisecond instead of hanging until Vitest's timeout.

## `Serving.info` with no port in it

```ts
const info = (await app.runtimeInfo()).get(); // { queue: "orders", concurrency: 1 }
```

The API publishes `{ port, prefix }` on the same channel. That is why `Info` is
the runtime's own type parameter rather than a port number baked into the
kernel: a queue consumer has none, and what an operator wants to know about one
is which queue it is on and how many messages it will take at a time.

## Running it

```bash
pnpm --filter @btravstack/start-example-order-worker test # 9 runtime specs + 4 env specs
pnpm --filter @btravstack/start-example-order-worker test:types # the needs gate
```

`src/needs-gate.test-d.ts` pins the compile-time half: `queueWorkerRuntime`
declares `[PlaceOrder, Logger]` — two of the three ports the module exports,
because a runtime declares what _it_ needs — and a module missing either fails
`start`'s arity gate before anything runs.

Every helper the specs need is a Vitest fixture in `src/test-fixtures.ts`, so
each file 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`.

`src/main.ts` is the process itself. It creates the queue, because this
example's broker is a plain in-memory object; a real deployment builds an AMQP
channel from the environment instead, and nothing above that line changes. Like
`order-api`'s, it is typechecked by the gate rather than executed by it — the
example packages are source-only, and every spec drives `start` directly.
Loading
Loading