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
59 changes: 51 additions & 8 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -551,9 +551,11 @@ label=com.btravstack.test-infra)` clears them), and testcontainers' own reuse
**The tenancy is the APPLICATION's, and the framework has no concept of
one.** Every port names its tenant — `OrderRepository.find(tenantId, id)`,
`PlaceOrder.execute(tenantId, id, quantity)` — and each transport supplies it
from its own **contract**: an input field on every `order-api` procedure, a
field on the AMQP envelope, a field on every Temporal workflow and activity
input. No starter reads a tenant off anything.
from its own **contract**: an input field on `order-api`'s **unmarked**
`customers` procedures — the marked `orders` half names none, because an
authenticated caller's own principal establishes it — a field on the AMQP
envelope, and a field on every Temporal workflow and activity input. No
starter reads a tenant off anything.

That line was drawn deliberately, and an earlier revision of this file
described the opposite. A tenant is _context_, and what establishes it — a
Expand All @@ -564,12 +566,46 @@ label=com.btravstack.test-infra)` clears them), and testcontainers' own reuse
`UnitRecord.tenantId` stays what it always was: a field for a **hand-rolled**
runtime whose author has already answered them, set by no shipped starter.

**The tenant is branded and the ids beside it are not** (`TenantId` in
`examples/order-domain/src/tenant.ts`, a `z.uuidv7().brand("TenantId")`).
Two strings in a fixed order are what the compiler has nothing to say about,
so `find(id, tenantId)` compiled and queried the wrong tenant; a pair need
differ in ONE position to become unswappable, which is why branding every id
is a separate question and not this one. The constructor is a **cast, not a
parse** — `.parse()` throws, and the value arrived through a contract that
already validated it — so each path claims the brand exactly once, where an
outside value becomes the application's vocabulary: the API's
`bearerAuthenticator` (from there the `Identity` carries it and neither
controller casts), the customers controller's `TenantId(input.tenantId)`,
each Temporal activity's `TenantId(args.tenantId)`, and the relay's
`tenantsOf`, which brands the `OUTBOX_TENANTS` list once at the config
boundary. The AMQP handlers cast nothing: neither calls a port that names a
tenant, so there is no boundary there to claim. `prisma-outbox.ts` is the
one **read-back** — a row becoming an `OrderEvent` — and so the one place
the brand is re-applied rather than carried.

**Every id beside it is a UUIDv7**, declared once on the entity
(`OrderId`, `CustomerId`) and again on each contract's own schema, so a
malformed id is refused at the transport before a use case sees it. That
format is what gave `placeOrder` a **second** way to fail: while the id was
an unconstrained string the quantity was the only field a typed caller could
get wrong, so collapsing `Order.make`'s `InvalidEntity` to `InvalidQuantity`
was sound; with a format it became a mislabelling, and `InvalidOrderId` is
the arm that fixes it. The two are told apart by **which field** the entity
named — `Entity.keysOf` over the issue's path — never by the message text,
and each transport now carries a third arm for it: `BAD_REQUEST` over HTTP,
a `nonRetryable` `InvalidOrderId` on Temporal, a `NonRetryableError` on the
queue.

Two things fall out of making it an argument, and they are the reason rather
than the price. A caller that forgets its tenant **does not compile**, where
an ambient one fails at runtime or silently reads another tenant's rows. And
an ambient one fails at runtime or silently reads another tenant's rows —
and because the tenant is branded and the id beside it is not, neither does
a caller that **swaps** them, which is the failure issue #81 named:
`find(id, tenantId)` type-checked and queried the wrong tenant. And
a test needs no machinery at all — no fixture that "enters" a tenant, no
store to set — which is why the persistence specs read
`repository.find(tenant, "o-1")`.
`repository.find(tenant, "0199a1e0-0000-7000-8000-000000000001")`.

`Outbox.pending(tenantId, limit)` is the case that shows ambient could not
have covered this anyway: the relay reading it is a background sweep with no
Expand Down Expand Up @@ -1090,7 +1126,7 @@ And a seventh, about the infrastructure a suite runs against:
runs at the same instant.

The tenant needs no machinery to reach a spec, because the application's
ports name it: `repository.find(tenant, "o-1")` says what a call is scoped
ports name it: `repository.find(tenant, id)` says what a call is scoped
to at the call. That is a consequence of the design choice below, not a
coincidence — an ambient tenant would have needed a fixture to establish
one, and the kernel exports no way to open a unit.
Expand Down Expand Up @@ -1139,8 +1175,15 @@ And a seventh, about the infrastructure a suite runs against:
`src/auth.ts`, and a stub would have accepted every broken call — passing an
order id where a tenant goes was exactly the drift. It covers both
controllers, the keyed router, the `HttpModule` root with its authenticator,
the lifted single-slice root and the positional form the three
router-shaped pages share. It does **not** cover the pages' own contract
the lifted single-slice root and the bare `HttpRouter(contract)(deps, arm)`
form the three router-shaped pages share — `docs/index.md`,
`docs/reference/http.md` and `docs/how-to/serve-orpc-over-http.md`, none of
which puts a controller in between. Every deps record it compiles is
**keyed**, di's one shape since `feat(di)!: a provider declares its
dependencies by name`; a positional array is refused as
`not assignable to parameter of type 'Readonly<Record<string, AnyPort>>'`,
which is what several pages outside this gate still carry (measured).
It does **not** cover the pages' own contract
declarations: `zod` and `@btravstack/contract` are
`examples/order-api-contract`'s dependencies, not `examples/order-api`'s, so
a fragment is compiled where it lives — though a marker removed from it
Expand Down
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ export const ordersContract = {
.output(type<{ readonly id: string; readonly quantity: number }>())
.errors({
INVALID_QUANTITY: { data: type<{ readonly id: string }>() },
BAD_REQUEST: { data: type<{ readonly id: string }>() },
CONFLICT: { data: type<{ readonly id: string }>() },
}),
};
Expand Down Expand Up @@ -117,6 +118,14 @@ export const ordersRouter = HttpRouter(ordersContract)(
data: { id: error.id },
}),
)
// A malformed id is the caller's mistake, so 400 — not the
// 409 a duplicate gets.
.with(P.tag("InvalidOrderId"), (error) =>
errors.BAD_REQUEST({
message: error.message,
data: { id: error.id },
}),
)
.with(P.tag("DuplicateOrder"), (error) =>
errors.CONFLICT({
message: error.message,
Expand Down
7 changes: 5 additions & 2 deletions docs/examples/hexagonal-order-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,11 +130,14 @@ with no scope required.
```ts
const outcome = await Module.scoped(
makeAppModule(makePersistenceModule()),
(ctx) => ctx.get(GetOrder).execute("o-1"),
(ctx) => ctx.get(GetOrder).execute("0199a1e0-0000-7000-8000-000000000001"),
options,
);

expect(outcome).toBeOkWith({ id: "o-1", total: 4_200 });
expect(outcome).toBeOkWith({
id: "0199a1e0-0000-7000-8000-000000000001",
total: 4_200,
});
```

## What the type-level test pins
Expand Down
8 changes: 5 additions & 3 deletions docs/examples/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ depended upon, depending on nothing.
order-api order-temporal-worker order-amqp-worker ← one runtime each; one process each
└────────────────┼──────────────────┘
order-infrastructure ← Prisma, SQLite, P-codes
order-infrastructure ← Prisma, PostgreSQL, P-codes
│ provides OrderRepository
order-application ← use cases, and the ports they declare
Expand Down Expand Up @@ -85,6 +85,7 @@ exists to hear it:
| ---------------------- | ----------------------- | --------------------------------------- |
| `Ok(order)` | the procedure's output | the workflow's output |
| `Err(InvalidQuantity)` | `INVALID_QUANTITY` | `InvalidQuantity`, **non-retryable** |
| `Err(InvalidOrderId)` | `BAD_REQUEST` | `InvalidOrderId`, **non-retryable** |
| `Err(DuplicateOrder)` | `CONFLICT` | `OrderAlreadyPlaced`, **non-retryable** |
| `Defect` | `INTERNAL_SERVER_ERROR` | **retried by the platform**, then fails |

Expand All @@ -111,8 +112,9 @@ care:

`order-domain`, `order-application`, `order-infrastructure` and the three
contract packages: an `Entity` with a re-checked invariant and failures as
values, use cases as providers over ports the caller declares, a Prisma
repository over in-memory SQLite translating P-codes into the domain's
values, use cases as providers over ports the caller declares — every one of
them naming a branded `TenantId` next to the id it acts on — a Prisma
repository over PostgreSQL translating P-codes into the domain's
vocabulary, the outbox written in the same transaction as the row, and the
two kinds of type test that keep the arrows pointing the right way.

Expand Down
54 changes: 46 additions & 8 deletions docs/examples/order-amqp-worker.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ export const orderNotifications = AmqpHandler(
sync:
({ logger }) =>
(message) => {
const { id, payload } = message.payload;
const { tenantId, id, payload } = message.payload;
if (currentUnit()?.signal.aborted === true) {
return ErrAsync(
new RetryableError(
Expand All @@ -76,6 +76,7 @@ export const orderNotifications = AmqpHandler(
? "order gone — notifying"
: "order placed — notifying",
{
tenantId,
orderId: id,
...(payload === null ? {} : { quantity: payload.quantity }),
},
Expand Down Expand Up @@ -142,13 +143,34 @@ export const relayConfig = Config.provider("RelayConfig")(
max: 60_000,
default: 200,
}),
tenants: Config.string("OUTBOX_TENANTS"),
}),
);
```

`OUTBOX_POLL_MS=0` is rejected — a relay that never sleeps is a busy loop —
and so is anything above a minute; either is a `ConfigInvalid`, `startFailed`
and exit `78`. A broker the relay cannot reach is modeled rather than left the
and exit `78`. `OUTBOX_TENANTS` has **no default**, deliberately: the relay
runs outside any unit, so it cannot read a tenant off the ambient record the
way every other adapter does, and "whatever is in the table" is how one
deployment starts broadcasting another's facts. It is a comma-separated list,
and `tenantsOf` is the one place this deployment claims the `TenantId` brand
from configuration rather than from a contract:

```ts
const tenantsOf = (value: string): readonly TenantId[] =>
value
.split(",")
.map((tenant) => tenant.trim())
.filter((tenant) => tenant !== "")
.map(TenantId);
```

Naming the tenants is also how a relay is **sharded** — two deployments, half
the list each, and neither can starve the other's backlog. The sweep then
goes tenant by tenant, `outbox.pending(tenantId, BATCH)` at a time.

A broker the relay cannot reach is modeled rather than left the
defect `TypedAmqpClient.create` reports it as, because an operator can act on
it:

Expand All @@ -165,10 +187,24 @@ application scope closes:

```ts
export const outboxRelay = Provider(OutboxRelay)(
[Outbox, Logger, AmqpConfig, relayConfig.port],
{
acquire: (outbox, logger, { url }, { pollMs }) =>
startOutboxRelay(outbox, logger, { url, pollMs }),
outbox: Outbox,
logger: Logger,
broker: AmqpConfig,
config: relayConfig.port,
},
{
acquire: ({
outbox,
logger,
broker: { url },
config: { pollMs, tenants },
}) =>
startOutboxRelay(outbox, logger, {
url,
pollMs,
tenants: tenantsOf(tenants),
}),
release: (running) => running.stop().get(),
},
);
Expand Down Expand Up @@ -254,8 +290,10 @@ independently.
`amqpConnectionUrl` is this test's own vhost, with `@btravstack/testing`'s
`boot: bootFixture()` and a `serve` over it that boots the same
`OrderAmqpWorker` `main.ts` does with
`env: { AMQP_URL: amqpConnectionUrl, OUTBOX_POLL_MS: "25" }` — the poll tight
because every spec waits on real broker round trips:
`env: { AMQP_URL: amqpConnectionUrl, OUTBOX_POLL_MS: "25", OUTBOX_TENANTS:
tenant }` — the poll tight because every spec waits on real broker round
trips, and the tenant this test's alone, so the relay sweeps its rows and
nobody else's:

```ts
await use(async (module, options) => {
Expand Down Expand Up @@ -290,7 +328,7 @@ event too:
const [message] = await waitForMessages({ count: 1, timeoutMs: 5_000 });
expect(JSON.parse(String(message?.content))).toEqual({
kind: "order",
id: "o-5",
id: "0199a1e0-0000-7000-8000-000000000005",
occurredAt: expect.any(String),
payload: { quantity: 4 },
});
Expand Down
48 changes: 36 additions & 12 deletions docs/examples/order-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,30 +26,36 @@ import { authenticated } from "@btravstack/contract";
import { oc } from "@orpc/contract";
import { z } from "zod";

const orderView = z.object({ id: z.string(), quantity: z.number() });
const orderView = z.object({ id: z.uuidv7(), quantity: z.number() });
export type OrderView = z.infer<typeof orderView>;

const orderRef = z.object({ id: z.string() });
const orderRef = z.object({ id: z.uuidv7() });
export type OrderRef = z.infer<typeof orderRef>;

// The one ref whose `id` is a bare string. It names the id **as received**,
// which is exactly the value that is not a UUIDv7 — validating it against
// `z.uuidv7()` would reject the only payload `BAD_REQUEST` ever carries.
const malformedRef = z.object({ id: z.string() });

// The unmarked fragment names its tenant on the input; the marked one does not,
// because a caller's identity establishes it there.
const tenanted = z.object({ tenantId: z.string() });
const tenanted = z.object({ tenantId: z.uuidv7() });

const customerView = z.object({ id: z.string(), name: z.string() });
const customerView = z.object({ id: z.uuidv7(), name: z.string() });
export type CustomerView = z.infer<typeof customerView>;

// Same shape as `orderRef`, deliberately not the same schema: reusing it would
// type a customer id as "which order it was about".
const customerRef = z.object({ id: z.string() });
const customerRef = z.object({ id: z.uuidv7() });
export type CustomerRef = z.infer<typeof customerRef>;

const ordersContract = {
place: oc
.input(z.object({ id: z.string(), quantity: z.number() }))
.input(z.object({ id: z.uuidv7(), quantity: z.number() }))
.output(orderView)
.errors({
INVALID_QUANTITY: { data: orderRef },
BAD_REQUEST: { data: malformedRef },
CONFLICT: { data: orderRef },
}),
find: oc
Expand All @@ -60,7 +66,7 @@ const ordersContract = {

const customersContract = {
find: oc
.input(tenanted.extend({ id: z.string() }))
.input(tenanted.extend({ id: z.uuidv7() }))
.output(customerView)
.errors({ NOT_FOUND: { data: customerRef } }),
};
Expand Down Expand Up @@ -112,6 +118,7 @@ src/authenticator.ts bearerAuthenticator — the provider that resolves an Id
and the root import come back fixed to it:

```ts
import type { TenantId } from "@btravstack/example-order-domain";
import {
httpAuth,
type HttpAuthenticatorOf,
Expand All @@ -120,7 +127,7 @@ import {
} from "@btravstack/http";

export type Identity = {
readonly tenantId: string;
readonly tenantId: TenantId;
readonly userId: string;
};

Expand Down Expand Up @@ -151,6 +158,7 @@ the factory, not a fallback.
to state:

```ts
import { TenantId } from "@btravstack/example-order-domain";
import { Unauthenticated } from "@btravstack/http";
import { ErrAsync, OkAsync } from "unthrown";

Expand All @@ -168,13 +176,18 @@ export const bearerAuthenticator = HttpAuthenticator({
userId === undefined ||
userId === ""
? ErrAsync(new Unauthenticated())
: OkAsync({ tenantId, userId });
: OkAsync({ tenantId: TenantId(tenantId), userId });
},
});
```

`Bearer <tenantId>:<userId>` is a stand-in, not a recommendation — what matters
is the shape. `[]` because this one needs no service; a JWT verifier, a key set
is the shape. This is also where a header becomes a **tenant**: `TenantId` is
the domain's branded string, so the identity carries the brand from here and no
handler on this path casts anything. The constructor is a cast rather than a
parse — a brand is a compile-time fiction, and what it buys is that
`repository.find(tenantId, id)` can no longer be called with its two arguments
the other way round. `[]` because this one needs no service; a JWT verifier, a key set
or a user directory would be named there and injected the way any provider's
dependencies are, so swapping the stand-in for real verification changes
nothing else in the composition. See
Expand Down Expand Up @@ -224,6 +237,14 @@ export const ordersController = HttpController(
data: { id: error.id },
}),
)
// A malformed id is the caller's mistake, so 400 — not the
// 409 a duplicate gets.
.with(P.tag("InvalidOrderId"), (error) =>
errors.BAD_REQUEST({
message: error.message,
data: { id: error.id },
}),
)
.with(P.tag("DuplicateOrder"), (error) =>
errors.CONFLICT({
message: error.message,
Expand Down Expand Up @@ -280,7 +301,10 @@ from `FindCustomer` and mapping `CustomerNotFound` to the fragment's own
`NOT_FOUND`. Its fragment is **unmarked**, so its context has no `principal`
at all — reading one there is a compile error — and it takes its tenant from
`input.tenantId` instead. The contrast is the lesson: where a caller's identity
establishes the tenant, the input has nothing to say about it. It has its own `view` too, because its use case answers with the
establishes the tenant, the input has nothing to say about it. That is the one
`TenantId(input.tenantId)` in the application: the fragment validated the field
as a UUIDv7, and the brand is claimed once, where the wire's `string` becomes
the application's vocabulary. It has its own `view` too, because its use case answers with the
branded `Customer` entity and `CustomerView` is the wire's shape — a slice is
defined by owning its fragment, its controller and its triage, not by owning a
private adapter. The throwaway in-memory directory this replaced declared its
Expand Down Expand Up @@ -480,7 +504,7 @@ expect(conflict).toBeErrWith(
expect.objectContaining({
constructor: ORPCError,
code: "CONFLICT",
data: { id: "o-1" },
data: { id: "0199a1e0-0000-7000-8000-000000000001" },
inferable: true,
}),
);
Expand Down
Loading
Loading