diff --git a/CLAUDE.md b/CLAUDE.md index a71214ab..4395067a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 @@ -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 @@ -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. @@ -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>'`, + 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 diff --git a/README.md b/README.md index 63010e18..261d0e8b 100644 --- a/README.md +++ b/README.md @@ -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 }>() }, }), }; @@ -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, diff --git a/docs/examples/hexagonal-order-api.md b/docs/examples/hexagonal-order-api.md index 04f124df..db4da794 100644 --- a/docs/examples/hexagonal-order-api.md +++ b/docs/examples/hexagonal-order-api.md @@ -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 diff --git a/docs/examples/index.md b/docs/examples/index.md index 35f51fdd..1701157e 100644 --- a/docs/examples/index.md +++ b/docs/examples/index.md @@ -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 @@ -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 | @@ -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. diff --git a/docs/examples/order-amqp-worker.md b/docs/examples/order-amqp-worker.md index f59d8425..9b1209fa 100644 --- a/docs/examples/order-amqp-worker.md +++ b/docs/examples/order-amqp-worker.md @@ -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( @@ -76,6 +76,7 @@ export const orderNotifications = AmqpHandler( ? "order gone — notifying" : "order placed — notifying", { + tenantId, orderId: id, ...(payload === null ? {} : { quantity: payload.quantity }), }, @@ -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: @@ -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(), }, ); @@ -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) => { @@ -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 }, }); diff --git a/docs/examples/order-api.md b/docs/examples/order-api.md index 8f419c7e..fea3bf06 100644 --- a/docs/examples/order-api.md +++ b/docs/examples/order-api.md @@ -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; -const orderRef = z.object({ id: z.string() }); +const orderRef = z.object({ id: z.uuidv7() }); export type OrderRef = z.infer; +// 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; // 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; 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 @@ -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 } }), }; @@ -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, @@ -120,7 +127,7 @@ import { } from "@btravstack/http"; export type Identity = { - readonly tenantId: string; + readonly tenantId: TenantId; readonly userId: string; }; @@ -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"; @@ -168,13 +176,18 @@ export const bearerAuthenticator = HttpAuthenticator({ userId === undefined || userId === "" ? ErrAsync(new Unauthenticated()) - : OkAsync({ tenantId, userId }); + : OkAsync({ tenantId: TenantId(tenantId), userId }); }, }); ``` `Bearer :` 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 @@ -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, @@ -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 @@ -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, }), ); diff --git a/docs/examples/order-application.md b/docs/examples/order-application.md index 307b3a6c..afe97c1b 100644 --- a/docs/examples/order-application.md +++ b/docs/examples/order-application.md @@ -24,12 +24,14 @@ pnpm turbo run test --filter=@btravstack/example-order-domain \ ``` Nothing here starts a process. `order-infrastructure`'s suite runs against a -real Prisma client over in-memory SQLite (the client is generated by turbo's -`generate` task before `test` runs — nothing to install), and each contract -package's suite proves a client can use it with nothing from its server in -scope. +real Prisma client over the **shared PostgreSQL** every workspace's tests use +(the client is generated by turbo's `generate` task before `test` runs — +nothing to install, but a Docker daemon is needed), isolating itself by +minting a **tenant** of its own per test rather than a database; and each +contract package's suite proves a client can use it with nothing from its +server in scope. -## The domain: an entity, and three failures as values +## The domain: an entity, four failures as values, and a brand `order-domain` depends on `@btravstack/entity`, `unthrown` and `zod` — domain modelling tools, no framework. The `Order` is an entity whose one rule is an @@ -56,22 +58,44 @@ export class Order extends Entity("Order")( export const placeOrder = ( id: string, quantity: number, -): Result => +): Result => Order.make({ id, quantity }).mapErrCases((matcher) => - matcher.with( - P.tag("InvalidEntity"), - () => new InvalidQuantity({ id, quantity }), + matcher.with(P.tag("InvalidEntity"), (invalid) => + invalid.issues.some((issue) => Entity.keysOf(issue)[0] === "id") + ? new InvalidOrderId({ id }) + : new InvalidQuantity({ id, quantity }), ), ); ``` -`InvalidQuantity` is the only failure this layer can _raise_. `OrderNotFound` -and `DuplicateOrder` are declared here too but raised by whoever owns the +`InvalidQuantity` and `InvalidOrderId` are the only failures this layer can +_raise_, and they are told apart by **which field** the entity named — a schema +issue carries a `path`, an `Entity.invariant` violation carries none. +`OrderNotFound` and `DuplicateOrder` are declared here too but raised by whoever owns the storage: the domain names them so every outer layer speaks about them in the same terms, which is what stops a Prisma error code or an HTTP status from leaking inwards. `fulfillment.ts` adds `OutOfStock`, `ShippingUnavailable` and `PaymentDeclined` for the two sagas on the same grounds. +`InvalidOrderId` exists because `OrderId` is a `z.uuidv7()` brand. While the +id was an unconstrained string the quantity was the only field a typed caller +could get wrong, so collapsing `InvalidEntity` to `InvalidQuantity` was sound; +giving the id a format made that a mislabelling — `placeOrder("o-1", 2)` +answered _"asks for 2 items, which is not a positive quantity"_ about a +quantity the caller got right. + +`src/tenant.ts` is the layer's other brand, with no entity behind it: + +```ts +export const TenantIdSchema = z.uuidv7().brand("TenantId"); +export type TenantId = z.infer; +export const TenantId = (raw: string): TenantId => raw as TenantId; +``` + +It lives here because it is vocabulary the whole system speaks, and the +constructor is a **cast, not a parse**: every value that becomes one arrived +through a contract that already validated it, and `.parse()` throws. + ## The application: ports declared by the caller `order-application` declares, as di ports, what its use cases need from the @@ -80,12 +104,32 @@ what the use cases have to handle: ```ts export class OrderRepository extends Port("OrderRepository")<{ - readonly save: (order: Order) => AsyncResult; - readonly find: (id: string) => AsyncResult; - readonly remove: (id: string) => AsyncResult; + readonly save: ( + tenantId: TenantId, + order: Order, + ) => AsyncResult; + readonly find: ( + tenantId: TenantId, + id: string, + ) => AsyncResult; + readonly remove: ( + tenantId: TenantId, + id: string, + ) => AsyncResult; }> {} ``` +**Every method names its tenant, and the tenant is branded while the id beside +it is not.** This deployment serves several tenants from one database, so +"which tenant" is part of what a repository is being asked — an argument, not +something read from an ambient store, and nothing the kernel or a starter +knows about. `TenantId` is `order-domain`'s `z.uuidv7().brand("TenantId")`: +two `string`s in a fixed order are what the compiler has nothing to say about, +so `find(id, tenantId)` compiled and read the wrong tenant's rows, and +branding **one** of the pair is enough to refuse it. The ids stay `string` +here — they are `OrderId`/`CustomerId` on the entity, and a pair need differ +in one position. + Beside it: `Outbox` (the read side of the transactional outbox — `pending` and `markPublished`, both `E = never`, because a database that will not answer is a defect, not a domain outcome), `StockService` and @@ -101,7 +145,7 @@ arm: ```ts export const placeOrderProvider = Provider(PlaceOrder)( - [OrderRepository, Logger], + { repository: OrderRepository, logger: Logger }, { class: PlaceOrderInteractor, }, @@ -139,8 +183,9 @@ lets `order-temporal-worker` and `order-amqp-worker` import the orders vertical without carrying the customers one. There is no kernel touchpoint left here. The log calls are structured — -`this.#logger.info("placing an order", { orderId: id, quantity })`, a constant -message with the ids as fields — and correlation is not this layer's job: +`this.#logger.info("placing an order", { tenantId, orderId: id, quantity })`, +a constant message with the ids as fields — and correlation is not this +layer's job: `@btravstack/observability`'s implementation reads `currentUnit()` fresh on every call, so each line carries the trace id of the unit that wrote it — data from the ambient store, never a capability (see @@ -171,18 +216,21 @@ together or not at all — and its `mapErrCases` is where Prisma's vocabulary becomes the domain's: ```ts -save: (order) => +save: (tenantId, order) => db .$tryTransaction((tx) => - tx.order.tryCreate({ data: { orderId: order.id, quantity: order.quantity } }).flatMap(() => - tx.outboxMessage.tryCreate({ - data: { - kind: "order", - subjectId: order.id, - payload: JSON.stringify({ quantity: order.quantity }), - }, - }), - ), + tx.order + .tryCreate({ data: { tenantId, orderId: order.id, quantity: order.quantity } }) + .flatMap(() => + tx.outboxMessage.tryCreate({ + data: { + tenantId, + kind: "order", + subjectId: order.id, + payload: JSON.stringify({ quantity: order.quantity }), + }, + }), + ), ) .mapErrCases((matcher, defect) => matcher @@ -224,19 +272,24 @@ stops the router compiling: 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; -const orderRef = z.object({ id: z.string() }); +const orderRef = z.object({ id: z.uuidv7() }); export type OrderRef = z.infer; +// The one ref whose `id` is a bare string — the id **as received**, which is +// exactly the value that is not a UUIDv7. +const malformedRef = z.object({ id: z.string() }); + export const orderContract = { orders: { 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 @@ -288,7 +341,9 @@ activities or handlers that implement it. // required two-element tuple the call does not pass. // @ts-expect-error — UNSATISFIED DEPENDENCIES: no OrderRepository is provided. const _unwiredOrders = Module.scoped(OrderApplicationModule, (ctx) => - ctx.get(PlaceOrder).execute("o-1", 1), + ctx + .get(PlaceOrder) + .execute(TenantId("acme"), "0199a1e0-0000-7000-8000-000000000001", 1), ); ``` diff --git a/docs/examples/order-temporal-worker.md b/docs/examples/order-temporal-worker.md index 7d77d24e..271ae8ed 100644 --- a/docs/examples/order-temporal-worker.md +++ b/docs/examples/order-temporal-worker.md @@ -288,9 +288,11 @@ proving every piece was mounted under its own key: const { client } = await serve(fulfilling.module); const charged = client.executeWorkflow("chargeOrder", { workflowId: "wf-charge-1", - args: { orderId: "order-1", amount: 42 }, + args: { orderId: "0199a1e0-0000-7000-8000-00000000a001", amount: 42 }, +}); +await expect(charged).toBeOkWith({ + authorizationId: "auth-0199a1e0-0000-7000-8000-00000000a001", }); -await expect(charged).toBeOkWith({ authorizationId: "auth-order-1" }); ``` ## The drain, honouring the kernel's deadline diff --git a/docs/explanation/the-kernel-maps-nothing.md b/docs/explanation/the-kernel-maps-nothing.md index cdcb1a4f..73ff5555 100644 --- a/docs/explanation/the-kernel-maps-nothing.md +++ b/docs/explanation/the-kernel-maps-nothing.md @@ -63,6 +63,9 @@ error becomes a status is the `mapErrCases` in each procedure. From .with(P.tag("InvalidQuantity"), (error) => errors.INVALID_QUANTITY({ message: error.message, data: { id: error.id } }), ) + .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, data: { id: error.id } }), ), diff --git a/docs/how-to/configure-from-the-environment.md b/docs/how-to/configure-from-the-environment.md index 5127d213..1c6792e8 100644 --- a/docs/how-to/configure-from-the-environment.md +++ b/docs/how-to/configure-from-the-environment.md @@ -64,7 +64,7 @@ output — `{ url: string; poolSize: number }` — and hands back the provider carrying it as `databaseConfig.port`. **That is the shape for a slice one application owns**: nothing else ever needs to name the port, so no class line names it twice. `examples/order-amqp-worker/src/outbox-relay.ts` uses exactly -this for its `OUTBOX_POLL_MS`: +this: ```ts export const relayConfig = Config.provider("RelayConfig")( @@ -74,10 +74,16 @@ export const relayConfig = Config.provider("RelayConfig")( max: 60_000, default: 200, }), + tenants: Config.string("OUTBOX_TENANTS"), }), ); ``` +`tenants` has **no default**, and that is the interesting half: the relay +sweeps outside any unit, so there is no ambient record to read a tenant from, +and "whatever is in the table" is how one deployment starts broadcasting +another's facts. A required variable is what makes an operator say whose. + ## A port other packages name When the slice is public API — a starter's `HttpConfig`, which another package diff --git a/docs/how-to/consume-amqp-messages.md b/docs/how-to/consume-amqp-messages.md index 3d3080d0..9cf798e5 100644 --- a/docs/how-to/consume-amqp-messages.md +++ b/docs/how-to/consume-amqp-messages.md @@ -51,12 +51,13 @@ export const orderHandlers = AmqpHandlers(orderContract)( { sync: ({ logger }) => ({ orderNotifications: (message) => { - const { id, payload } = message.payload; + const { tenantId, id, payload } = message.payload; logger.info( payload === null ? "order gone — notifying" : "order placed — notifying", { + tenantId, orderId: id, ...(payload === null ? {} : { quantity: payload.quantity }), }, @@ -64,8 +65,9 @@ export const orderHandlers = AmqpHandlers(orderContract)( return OkAsync(); }, orderAudit: (message) => { - const { id, occurredAt, payload } = message.payload; + const { tenantId, id, occurredAt, payload } = message.payload; logger.info("recording an order change", { + tenantId, orderId: id, occurredAt, change: payload === null ? "removed" : "placed", @@ -105,6 +107,7 @@ handlers provider): ```ts import { AmqpHandlers } from "@btravstack/amqp"; import { NonRetryableError, RetryableError } from "@amqp-contract/worker"; +import { TenantId } from "@btravstack/example-order-domain"; import { ErrAsync, OkAsync, P } from "unthrown"; export const placingHandlers = AmqpHandlers(orderContract)( @@ -114,7 +117,7 @@ export const placingHandlers = AmqpHandlers(orderContract)( orderNotifications: (message) => place .execute( - message.payload.tenantId, + TenantId(message.payload.tenantId), message.payload.id, message.payload.payload?.quantity ?? 0, ) @@ -122,6 +125,7 @@ export const placingHandlers = AmqpHandlers(orderContract)( .mapErrCases((matcher) => matcher.with( P.tag("InvalidQuantity"), + P.tag("InvalidOrderId"), P.tag("DuplicateOrder"), (error) => new NonRetryableError(error._tag, error), ), @@ -271,14 +275,29 @@ export const relayConfig = Config.provider("RelayConfig")( max: 60_000, default: 200, }), + tenants: Config.string("OUTBOX_TENANTS"), }), ); 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(), }, ); @@ -288,6 +307,13 @@ It reads `AmqpConfig` — the broker the starter bound — and shares the worker's connection lease through `@amqp-contract/core`'s pool. A broker it cannot reach is the modeled `BrokerUnreachable`, a startup `Err` and exit `1`. +`OUTBOX_TENANTS` is a comma-separated list with **no default**, and +`tenantsOf` splits it and claims the `TenantId` brand once — the relay is the +one caller with no request, delivery or activity behind it, so its tenants are +deployment configuration rather than something to read off an ambient record. +The sweep then goes tenant by tenant, so one tenant's backlog cannot starve +another's. + ## See also - [`@btravstack/amqp`](/reference/amqp) — options, ports, `AmqpInfo`. diff --git a/docs/how-to/log-and-correlate.md b/docs/how-to/log-and-correlate.md index 1eaa7ec8..c7063d73 100644 --- a/docs/how-to/log-and-correlate.md +++ b/docs/how-to/log-and-correlate.md @@ -45,39 +45,48 @@ a unit carries that unit's ids. ## Log from a use case -`Logger` is an ordinary port, so it arrives the ordinary way — in the -dependency array, never from a global or an ambient read: +`Logger` is an ordinary port, so it arrives the ordinary way — named in the +provider's `deps` record, never from a global or an ambient read: ```ts class PlaceOrderInteractor { readonly #repository: ServiceOf; readonly #logger: ServiceOf; - constructor( - repository: ServiceOf, - logger: ServiceOf, - ) { + constructor({ + repository, + logger, + }: { + readonly repository: ServiceOf; + readonly logger: ServiceOf; + }) { this.#repository = repository; this.#logger = logger; } - execute(id: string, quantity: number) { - this.#logger.info("placing an order", { orderId: id, quantity }); + execute(tenantId: TenantId, id: string, quantity: number) { + this.#logger.info("placing an order", { tenantId, orderId: id, quantity }); return placeOrder(id, quantity) .toAsync() - .flatMap((order) => this.#repository.save(order)); + .flatMap((order) => this.#repository.save(tenantId, order)); } } export const placeOrderProvider = Provider(PlaceOrder)( - [OrderRepository, Logger], + { repository: OrderRepository, logger: Logger }, { class: PlaceOrderInteractor }, ); ``` +The tenant is an **argument**, not something read back out of the ambient +record — `TenantId` is `examples/order-domain`'s brand, and a use case that +forgot it, or swapped it with the id beside it, does not compile. It is a +field on the line for the same reason `orderId` is: a fact worth grouping by, +written down where the call is. + **The message is a constant and the ids are fields.** That is what makes a line groupable in the system that receives it: `message: "placing an order"` -finds every placement, `orderId: "o-1"` finds one. A rendered sentence — +finds every placement, `orderId: "0199a1e0-0000-7000-8000-000000000001"` finds one. A rendered sentence — `` `placing order ${id}` `` — is neither. Attributes are flat scalars (`string | number | boolean | undefined`), and a @@ -111,7 +120,8 @@ call, so one application-scope logger is correct for every request: ```json { - "orderId": "o-1", + "tenantId": "0199a1e0-0000-7000-8000-0000000000ff", + "orderId": "0199a1e0-0000-7000-8000-000000000001", "quantity": 2, "time": "2026-08-16T09:41:02.113Z", "level": "info", diff --git a/docs/how-to/protect-a-procedure.md b/docs/how-to/protect-a-procedure.md index a33ed707..a14fbb4f 100644 --- a/docs/how-to/protect-a-procedure.md +++ b/docs/how-to/protect-a-procedure.md @@ -40,15 +40,26 @@ import { authenticated } from "@btravstack/contract"; import { oc } from "@orpc/contract"; import { z } from "zod"; +const orderRef = z.object({ id: z.uuidv7() }); + +// `BAD_REQUEST` names the id **as received**, which is the one value that is +// not a UUIDv7: `orderRef` would reject the only payload it ever carries. +const malformedRef = z.object({ id: z.string() }); + const ordersContract = { place: oc - .input(z.object({ id: z.string(), quantity: z.number() })) - .output(z.object({ id: z.string() })), + .input(z.object({ id: z.uuidv7(), quantity: z.number() })) + .output(z.object({ id: z.uuidv7() })) + .errors({ + INVALID_QUANTITY: { data: orderRef }, + BAD_REQUEST: { data: malformedRef }, + CONFLICT: { data: orderRef }, + }), }; const customersContract = { find: oc - .input(z.object({ id: z.string() })) + .input(z.object({ id: z.uuidv7() })) .output(z.object({ name: z.string() })), }; @@ -75,6 +86,7 @@ application, which hands back `HttpController`, `HttpRouter` and ```ts // src/auth.ts +import type { TenantId } from "@btravstack/example-order-domain"; import { httpAuth, type HttpAuthenticatorOf, @@ -83,7 +95,7 @@ import { } from "@btravstack/http"; /** What this deployment knows about a caller. The contract names none. */ -export type Identity = { readonly tenantId: string; readonly userId: string }; +export type Identity = { readonly tenantId: TenantId; readonly userId: string }; const identity = httpAuth(); @@ -108,6 +120,7 @@ has no business reading a body, and the narrower argument is what keeps it testable without a socket. ```ts +import { TenantId } from "@btravstack/example-order-domain"; import { Unauthenticated } from "@btravstack/http"; import { ErrAsync, OkAsync } from "unthrown"; @@ -125,7 +138,7 @@ export const bearerAuthenticator = HttpAuthenticator({ userId === undefined || userId === "" ? ErrAsync(new Unauthenticated()) - : OkAsync({ tenantId, userId }); + : OkAsync({ tenantId: TenantId(tenantId), userId }); }, }); ``` @@ -192,6 +205,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, diff --git a/docs/how-to/read-the-ambient-unit.md b/docs/how-to/read-the-ambient-unit.md index a675c130..dafdd27b 100644 --- a/docs/how-to/read-the-ambient-unit.md +++ b/docs/how-to/read-the-ambient-unit.md @@ -59,20 +59,26 @@ argument a caller cannot forget and a reader can see: // examples/order-application/src/ports.ts export class OrderRepository extends Port("OrderRepository")<{ readonly save: ( - tenantId: string, + tenantId: TenantId, order: Order, ) => AsyncResult; readonly find: ( - tenantId: string, + tenantId: TenantId, id: string, ) => AsyncResult; readonly remove: ( - tenantId: string, + tenantId: TenantId, id: string, ) => AsyncResult; }> {} ``` +`TenantId` is a branded `string` the domain owns, and the ids beside it are +not: a pair need differ in one position to become unswappable, and +`find(id, tenantId)` used to compile and query the wrong tenant. Each +transport claims the brand once, where a validated value arrives — the +authenticator, an activity's input, the relay's own configuration. + Each transport then supplies it from its own contract, which is where a client already has to say what it wants: @@ -85,7 +91,7 @@ already has to say what it wants: Two consequences are the point rather than the price. A use case that forgot its tenant **does not compile**, where an ambient one would have failed at runtime or, worse, silently read the wrong tenant's rows. And a test needs no -machinery at all: `repository.find(tenant, "o-1")` says what it is scoped to +machinery at all: `repository.find(tenant, "0199a1e0-0000-7000-8000-000000000001")` says what it is scoped to at the call, with no fixture that "enters" a tenant and no store to set. The relay in `order-amqp-worker` is the case that shows why ambient would not @@ -132,7 +138,7 @@ logger.info("placing an order", { orderId: id, quantity }); ```json { - "orderId": "o-1", + "orderId": "0199a1e0-0000-7000-8000-000000000001", "quantity": 2, "time": "2026-08-16T09:41:02.113Z", "level": "info", @@ -218,7 +224,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( @@ -231,6 +237,7 @@ export const orderNotifications = AmqpHandler( ? "order gone — notifying" : "order placed — notifying", { + tenantId, orderId: id, ...(payload === null ? {} : { quantity: payload.quantity }), }, diff --git a/docs/how-to/run-a-temporal-worker.md b/docs/how-to/run-a-temporal-worker.md index 0bb73c7b..5f82c1cc 100644 --- a/docs/how-to/run-a-temporal-worker.md +++ b/docs/how-to/run-a-temporal-worker.md @@ -47,24 +47,34 @@ import { ShippingService, StockService, } from "@btravstack/example-order-application"; +import { TenantId } from "@btravstack/example-order-domain"; import { orderContract } from "@btravstack/example-order-temporal-contract"; import { TemporalActivities } from "@btravstack/temporal"; import { P } from "unthrown"; export const orderActivities = TemporalActivities(orderContract)( - [PlaceOrder, OrderRepository, StockService, ShippingService, PaymentService], { - sync: (place, repository, stock, shipping, payments) => ({ + place: PlaceOrder, + repository: OrderRepository, + stock: StockService, + shipping: ShippingService, + payments: PaymentService, + }, + { + sync: ({ place, repository, stock, shipping, payments }) => ({ fulfillOrder: { place: (args, { errors }) => place - .execute(args.orderId, args.quantity) + .execute(TenantId(args.tenantId), args.orderId, args.quantity) .map((order) => ({ id: order.id, quantity: order.quantity })) .mapErrCases((matcher) => matcher .with(P.tag("InvalidQuantity"), (error) => errors.InvalidQuantity({ id: error.id }), ) + .with(P.tag("InvalidOrderId"), (error) => + errors.InvalidOrderId({ id: error.id }), + ) .with(P.tag("DuplicateOrder"), (error) => errors.OrderAlreadyPlaced({ id: error.id }), ), @@ -88,7 +98,7 @@ export const orderActivities = TemporalActivities(orderContract)( releaseStock: (args) => stock.release(args.orderId), cancelPlacement: (args) => repository - .remove(args.orderId) + .remove(TenantId(args.tenantId), args.orderId) .recoverErrCases((matcher) => matcher.with(P.tag("OrderNotFound"), () => undefined), ), @@ -115,6 +125,14 @@ The package maps nothing further: `declareActivitiesHandler` already turns a declared contract error into a `nonRetryable` `ApplicationFailure` the workflow branches on, and leaves anything unmodeled to Temporal's retry policy. Naming a failure here is also what tells Temporal to stop retrying it. + +`args.tenantId` is the application's own, declared on every workflow and +activity input by the **contract** — so Temporal persists it in the event +history and a replay reconstructs it, and the package reads nothing about +tenancy. `TenantId(…)` claims `examples/order-domain`'s brand at each activity +that needs one, an activity being its own entry point; it casts rather than +parses, because the contract already validated the field as a UUIDv7. The +brand is what stops `execute(args.orderId, args.tenantId)` from compiling. `cancelPlacement` absorbs `OrderNotFound` on purpose — a compensation Temporal may re-run has to answer the same both times. diff --git a/docs/how-to/serve-orpc-over-http.md b/docs/how-to/serve-orpc-over-http.md index 39ee5506..0d7e3231 100644 --- a/docs/how-to/serve-orpc-over-http.md +++ b/docs/how-to/serve-orpc-over-http.md @@ -43,18 +43,23 @@ 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; -const orderRef = z.object({ id: z.string() }); +const orderRef = z.object({ id: z.uuidv7() }); export type OrderRef = z.infer; +// `BAD_REQUEST` names the id **as received**, which is the one value that is +// not a UUIDv7: `orderRef` would reject the only payload it ever carries. +const malformedRef = z.object({ id: z.string() }); + export const ordersContract = authenticated({ 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 @@ -99,9 +104,9 @@ const view = (order: Order): OrderView => ({ }); export const ordersRouter = HttpRouter(ordersContract)( - [PlaceOrder, FindOrder], + { place: PlaceOrder, find: FindOrder }, { - sync: (place, find) => ({ + sync: ({ place, find }) => ({ place: ({ errors, context }, input) => place .execute(context.principal.tenantId, input.id, input.quantity) @@ -114,6 +119,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, diff --git a/docs/how-to/split-a-router-into-controllers.md b/docs/how-to/split-a-router-into-controllers.md index db3f81af..43683890 100644 --- a/docs/how-to/split-a-router-into-controllers.md +++ b/docs/how-to/split-a-router-into-controllers.md @@ -29,26 +29,31 @@ 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; -const orderRef = z.object({ id: z.string() }); +const orderRef = z.object({ id: z.uuidv7() }); export type OrderRef = z.infer; -const customerView = z.object({ id: z.string(), name: z.string() }); +// `BAD_REQUEST` names the id **as received**, which is the one value that is +// not a UUIDv7: `orderRef` would reject the only payload it ever carries. +const malformedRef = z.object({ id: z.string() }); + +const customerView = z.object({ id: z.uuidv7(), name: z.string() }); export type CustomerView = z.infer; // The same shape as `orderRef` and deliberately its own schema: sharing that // one 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; 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 @@ -59,7 +64,7 @@ const ordersContract = { const customersContract = { find: oc - .input(z.object({ tenantId: z.string(), id: z.string() })) + .input(z.object({ tenantId: z.uuidv7(), id: z.uuidv7() })) .output(customerView) .errors({ NOT_FOUND: { data: customerRef } }), }; @@ -115,6 +120,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, diff --git a/docs/how-to/split-a-worker-into-slices.md b/docs/how-to/split-a-worker-into-slices.md index e438c9fb..3d71f7c8 100644 --- a/docs/how-to/split-a-worker-into-slices.md +++ b/docs/how-to/split-a-worker-into-slices.md @@ -60,12 +60,13 @@ export const orderNotifications = AmqpHandler( sync: ({ logger }) => (message) => { - const { id, payload } = message.payload; + const { tenantId, id, payload } = message.payload; logger.info( payload === null ? "order gone — notifying" : "order placed — notifying", { + tenantId, orderId: id, }, ); diff --git a/docs/how-to/swap-an-adapter.md b/docs/how-to/swap-an-adapter.md index a0a20c32..ab0f83c4 100644 --- a/docs/how-to/swap-an-adapter.md +++ b/docs/how-to/swap-an-adapter.md @@ -110,7 +110,7 @@ makes the next step work. // Module.scoped — which opens a scope and guarantees its close — accepts it. const result = await Module.scoped( makeAppModule(makePersistenceModule()), - (ctx) => ctx.get(GetOrder).execute("o-1"), + (ctx) => ctx.get(GetOrder).execute("0199a1e0-0000-7000-8000-000000000001"), ); // Tests: nothing resourceful, Needs is never, Module.build accepts it. @@ -141,8 +141,13 @@ its `Needs`, and a scope that releases nothing is harmless. it("returns the order", async () => { const result = await Module.build( makeAppModule(InMemoryPersistenceModule), - ).flatMap((ctx) => ctx.get(GetOrder).execute("o-1")); - expect(result).toBeOkWith({ id: "o-1", total: 99 }); + ).flatMap((ctx) => + ctx.get(GetOrder).execute("0199a1e0-0000-7000-8000-000000000001"), + ); + expect(result).toBeOkWith({ + id: "0199a1e0-0000-7000-8000-000000000001", + total: 99, + }); }); ``` diff --git a/docs/how-to/test-an-application.md b/docs/how-to/test-an-application.md index 0f8133f4..04be53a0 100644 --- a/docs/how-to/test-an-application.md +++ b/docs/how-to/test-an-application.md @@ -61,8 +61,13 @@ describe("order-api", () => { // WHEN a call goes over the wire // THEN it reached the use case behind the transport - await expect(client.orders.place({ id: "o-1", quantity: 2 })).toBeOkWith({ - id: "o-1", + await expect( + client.orders.place({ + id: "0199a1e0-0000-7000-8000-000000000001", + quantity: 2, + }), + ).toBeOkWith({ + id: "0199a1e0-0000-7000-8000-000000000001", quantity: 2, }); }); @@ -96,7 +101,10 @@ it was built with; boot `tap.module` in place of the module and read `tap.services()` afterwards: ```ts -it("broadcasts every committed write, end to end", async ({ serve }) => { +it("broadcasts every committed write, end to end", async ({ + tenant, + serve, +}) => { // GIVEN the real graph, tapped on the writer the spec places orders through const tap = tapped(OrderAmqpWorker, [PlaceOrder, OrderRepository, Outbox]); await serve(tap.module); @@ -105,8 +113,10 @@ it("broadcasts every committed write, end to end", async ({ serve }) => { // WHEN an order is placed — one ordinary write, no publish in sight // THEN it is the very instance the relay sweeps, so the fact crosses the // outbox, the broker and the queue - await expect(placeOrder.execute("o-1", 2)).toBeOkWith( - expect.objectContaining({ id: "o-1" }), + await expect( + placeOrder.execute(tenant, "0199a1e0-0000-7000-8000-000000000001", 2), + ).toBeOkWith( + expect.objectContaining({ id: "0199a1e0-0000-7000-8000-000000000001" }), ); }); ``` @@ -151,8 +161,13 @@ it("runs each call in its own unit, with its own trace id", async ({ // WHEN two calls are served — chained, so neither `Result` is dropped const served = await client.orders - .place({ id: "o-1", quantity: 1 }) - .flatMap(() => client.orders.place({ id: "o-2", quantity: 1 })); + .place({ id: "0199a1e0-0000-7000-8000-000000000001", quantity: 1 }) + .flatMap(() => + client.orders.place({ + id: "0199a1e0-0000-7000-8000-000000000002", + quantity: 1, + }), + ); // THEN four lines, two distinct trace ids, none written outside a unit const traced = served.map(() => ({ @@ -346,10 +361,10 @@ Reading a tenant back needs nothing at all, because the example application names it on its ports rather than reading it from ambient context: ```ts -export const it = test.extend<{ tenant: string }>({ +export const it = test.extend<{ tenant: TenantId }>({ // oxlint-disable-next-line no-empty-pattern -- depends on no other fixture tenant: async ({}, use) => { - await use(`t-${randomUUID()}`); + await use(TenantId(uuidv7())); }, }); @@ -361,15 +376,26 @@ it("reads back only its own tenant's order", async ({ // GIVEN an order saved under this test's tenant // WHEN it is read back const found = await repository - .save(tenant, anOrder("o-1", 3)) - .flatMap(() => repository.find(tenant, "o-1")); + .save(tenant, anOrder("0199a1e0-0000-7000-8000-000000000001", 3)) + .flatMap(() => + repository.find(tenant, "0199a1e0-0000-7000-8000-000000000001"), + ); // THEN the round trip is lossless, and scoped - expect(found).toBeOkWith({ id: "o-1", quantity: 3 }); + expect(found).toBeOkWith({ + id: "0199a1e0-0000-7000-8000-000000000001", + quantity: 3, + }); }); ``` -That is the whole fixture. See [Multi-tenancy is the application's, not the +That is the whole fixture. `TenantId` is `examples/order-domain`'s +`z.uuidv7().brand("TenantId")`, and `uuidv7()` is +`@btravstack/internal-test-infra`'s — `crypto.randomUUID()` mints a v4, which +the schema rejects. The brand is why the fixture's type matters rather than +being decoration: with two bare `string`s, `repository.find(id, tenant)` would +have compiled and read another tenant's rows. See [Multi-tenancy is the +application's, not the framework's](/how-to/read-the-ambient-unit#multi-tenancy-is-the-application-s-not-the-framework-s) for why the tenant is an argument rather than something the transport reads. diff --git a/docs/index.md b/docs/index.md index 2f84ee4a..5a654d47 100644 --- a/docs/index.md +++ b/docs/index.md @@ -52,17 +52,21 @@ import { OrderPersistenceModule } from "./persistence.js"; // The contract comes first; a client can take it without the server. // Schemas, not oRPC's `type()`: they check what arrives, not just what compiles. -const orderView = z.object({ id: z.string(), quantity: z.number() }); -const orderRef = z.object({ id: z.string() }); +const orderView = z.object({ id: z.uuidv7(), quantity: z.number() }); +const orderRef = z.object({ id: z.uuidv7() }); +// `BAD_REQUEST` names the id **as received**, which is the one value that is +// not a UUIDv7: `orderRef` would reject the only payload it ever carries. +const malformedRef = z.object({ id: z.string() }); // `authenticated` marks the fragment. It names no tenant on the input: a // caller does not get to pick the tenant it is served. const ordersContract = authenticated({ 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 }, }), }); @@ -85,6 +89,14 @@ 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, diff --git a/docs/reference/amqp.md b/docs/reference/amqp.md index 26c1b8b3..59f305c7 100644 --- a/docs/reference/amqp.md +++ b/docs/reference/amqp.md @@ -132,12 +132,13 @@ export const orderHandlers = AmqpHandlers(orderContract)( { sync: ({ logger }) => ({ orderNotifications: (message) => { - const { id, payload } = message.payload; + const { tenantId, id, payload } = message.payload; logger.info( payload === null ? "order gone — notifying" : "order placed — notifying", { + tenantId, orderId: id, ...(payload === null ? {} : { quantity: payload.quantity }), }, @@ -145,8 +146,9 @@ export const orderHandlers = AmqpHandlers(orderContract)( return OkAsync(); }, orderAudit: (message) => { - const { id, occurredAt, payload } = message.payload; + const { tenantId, id, occurredAt, payload } = message.payload; logger.info("recording an order change", { + tenantId, orderId: id, occurredAt, change: payload === null ? "removed" : "placed", diff --git a/docs/reference/http.md b/docs/reference/http.md index 2f27c47d..43c7e9fb 100644 --- a/docs/reference/http.md +++ b/docs/reference/http.md @@ -137,6 +137,14 @@ export const ordersRouter = HttpRouter(contract.orders)( 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, @@ -331,6 +339,8 @@ returning. Forwarding a reason would put "no such user" versus "bad signature" in a 401 body by default. ```ts +import { TenantId } from "@btravstack/example-order-domain"; + export const bearerAuthenticator = HttpAuthenticator({ sync: () => (headers) => { const header = headers.authorization ?? ""; @@ -343,7 +353,7 @@ export const bearerAuthenticator = HttpAuthenticator({ userId === undefined || userId === "" ? ErrAsync(new Unauthenticated()) - : OkAsync({ tenantId, userId }); + : OkAsync({ tenantId: TenantId(tenantId), userId }); }, }); ``` @@ -357,8 +367,10 @@ thing that gives a marked handler a readable `context.principal`. It states the identity once and hands back the three pieces fixed to it: ```ts +import type { TenantId } from "@btravstack/example-order-domain"; + // src/auth.ts — one per application -export type Identity = { readonly tenantId: string; readonly userId: string }; +export type Identity = { readonly tenantId: TenantId; readonly userId: string }; const identity = httpAuth(); diff --git a/docs/reference/observability.md b/docs/reference/observability.md index 8812bf28..8f6c348e 100644 --- a/docs/reference/observability.md +++ b/docs/reference/observability.md @@ -205,7 +205,7 @@ kernel's `stderrSink` writes its events in. ```json { - "orderId": "o-1", + "orderId": "0199a1e0-0000-7000-8000-000000000001", "quantity": 2, "time": "2026-08-16T09:41:02.113Z", "level": "info", diff --git a/docs/reference/temporal.md b/docs/reference/temporal.md index cf0f7ff1..406f13ee 100644 --- a/docs/reference/temporal.md +++ b/docs/reference/temporal.md @@ -132,13 +132,16 @@ export const orderActivities = TemporalActivities(orderContract)( fulfillOrder: { place: (args, { errors }) => place - .execute(args.orderId, args.quantity) + .execute(TenantId(args.tenantId), args.orderId, args.quantity) .map((order) => ({ id: order.id, quantity: order.quantity })) .mapErrCases((matcher) => matcher .with(P.tag("InvalidQuantity"), (error) => errors.InvalidQuantity({ id: error.id }), ) + .with(P.tag("InvalidOrderId"), (error) => + errors.InvalidOrderId({ id: error.id }), + ) .with(P.tag("DuplicateOrder"), (error) => errors.OrderAlreadyPlaced({ id: error.id }), ), @@ -162,7 +165,7 @@ export const orderActivities = TemporalActivities(orderContract)( releaseStock: (args) => stock.release(args.orderId), cancelPlacement: (args) => repository - .remove(args.orderId) + .remove(TenantId(args.tenantId), args.orderId) .recoverErrCases((matcher) => matcher.with(P.tag("OrderNotFound"), () => undefined), ), @@ -189,6 +192,15 @@ One record, one `sync`, both sagas' services in its `deps` — which is exactly the shape that stops scaling once a worker owns enough workflows, and why the composing form below exists. +`args.tenantId` is the application's, not the package's: it is a field the +**contract** declares on every workflow and activity input, which is what +makes it survive a replay — Temporal persists an activity's input in the +event history. `@btravstack/temporal` reads nothing about tenancy. +`TenantId(…)` is `examples/order-domain`'s brand claimed at the boundary the +activity is, so a use case cannot be handed an order id where a tenant goes; +the contract validated the field as a UUIDv7 before the activity was entered, +so the constructor casts rather than parses. + A hand-written `Provider(orderActivities.port)(…)` targets the same port; a port declared under any other id leaves the starter's need unmet, and `start` refuses the module. @@ -256,13 +268,16 @@ const orderFulfillment = TemporalWorkflowActivities( sync: ({ place, repository, stock, shipping }) => ({ place: (args, { errors }) => place - .execute(args.orderId, args.quantity) + .execute(TenantId(args.tenantId), args.orderId, args.quantity) .map((order) => ({ id: order.id, quantity: order.quantity })) .mapErrCases((matcher) => matcher .with(P.tag("InvalidQuantity"), (error) => errors.InvalidQuantity({ id: error.id }), ) + .with(P.tag("InvalidOrderId"), (error) => + errors.InvalidOrderId({ id: error.id }), + ) .with(P.tag("DuplicateOrder"), (error) => errors.OrderAlreadyPlaced({ id: error.id }), ), diff --git a/docs/tutorial/second-runtime.md b/docs/tutorial/second-runtime.md index 0c7a2c08..28830f7b 100644 --- a/docs/tutorial/second-runtime.md +++ b/docs/tutorial/second-runtime.md @@ -98,9 +98,9 @@ import { Greeter } from "./greeter.js"; import { greetingContract } from "./temporal-contract.js"; export const greetingActivities = TemporalActivities(greetingContract)( - [Greeter], + { greeter: Greeter }, { - sync: (greeter) => ({ + sync: ({ greeter }) => ({ greeting: { greet: (args) => OkAsync({ message: greeter.greet(args.name) }), }, diff --git a/examples/README.md b/examples/README.md index b8877eca..700d2b65 100644 --- a/examples/README.md +++ b/examples/README.md @@ -121,6 +121,7 @@ a caller 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 | diff --git a/examples/hexagonal-order-api/src/index.spec.ts b/examples/hexagonal-order-api/src/index.spec.ts index 15e69a9e..21919c07 100644 --- a/examples/hexagonal-order-api/src/index.spec.ts +++ b/examples/hexagonal-order-api/src/index.spec.ts @@ -23,11 +23,11 @@ test("the production graph resolves a use case through its ports, and releases w 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 }); // No teardown failures — proof the pool's `release` actually ran cleanly, // not just that the graph type-checked. expect(teardownErrors).toEqual([]); diff --git a/examples/hexagonal-order-api/src/index.ts b/examples/hexagonal-order-api/src/index.ts index 4741b0c5..9f3e206c 100644 --- a/examples/hexagonal-order-api/src/index.ts +++ b/examples/hexagonal-order-api/src/index.ts @@ -96,8 +96,8 @@ const openPool = ({ }): Result, never> => { void config.dbUrl; const seed: readonly Order[] = [ - { id: "o-1", total: 4_200 }, - { id: "o-2", total: 1_500 }, + { id: "0199a1e0-0000-7000-8000-000000000001", total: 4_200 }, + { id: "0199a1e0-0000-7000-8000-000000000002", total: 1_500 }, ]; let closed = false; return Ok({ diff --git a/examples/order-amqp-contract/src/contract.spec.ts b/examples/order-amqp-contract/src/contract.spec.ts index 3fac9ae4..fcff795d 100644 --- a/examples/order-amqp-contract/src/contract.spec.ts +++ b/examples/order-amqp-contract/src/contract.spec.ts @@ -34,9 +34,9 @@ describe("orderContract", () => { // GIVEN the contract's own schema, and nothing else — no worker, no // connection, no broker const event = { - tenantId: "acme", + tenantId: "0199a1e0-0000-7000-8000-000000009000", kind: "order", - id: "o-1", + id: "0199a1e0-0000-7000-8000-000000000001", occurredAt: "2026-08-13T22:00:00.000Z", payload: { quantity: 2 }, }; @@ -51,9 +51,9 @@ describe("orderContract", () => { }) => { // GIVEN the same schema const tombstone = { - tenantId: "acme", + tenantId: "0199a1e0-0000-7000-8000-000000009000", kind: "order", - id: "o-1", + id: "0199a1e0-0000-7000-8000-000000000001", occurredAt: "2026-08-13T22:00:00.000Z", payload: null, }; @@ -72,12 +72,30 @@ describe("orderContract", () => { // executable, not documentation, and a caller can run it expect( validate({ - tenantId: "acme", + tenantId: "0199a1e0-0000-7000-8000-000000009000", kind: "order", - id: "o-1", + id: "0199a1e0-0000-7000-8000-000000000001", occurredAt: "2026-08-13T22:00:00.000Z", payload: { quantity: "two" }, }), ).toBeErrWith([expect.objectContaining({ path: ["payload", "quantity"] })]); }); + + it("refuses an id that is not a UUIDv7", ({ validate }) => { + // GIVEN an event whose subject id is a plain string, not the wire's UUIDv7 shape + const event = { + tenantId: "0199a1e0-0000-7000-8000-000000009000", + kind: "order", + id: "o-1", + occurredAt: "2026-08-13T22:00:00.000Z", + payload: { quantity: 2 }, + }; + + // WHEN a relay checks the envelope it is about to publish + const result = validate(event); + + // THEN it is refused, naming the id field — proving the schema, not the + // tenant, is what caught it + expect(result).toBeErrWith([expect.objectContaining({ path: ["id"] })]); + }); }); diff --git a/examples/order-amqp-contract/src/contract.ts b/examples/order-amqp-contract/src/contract.ts index 37ea9523..015f8c17 100644 --- a/examples/order-amqp-contract/src/contract.ts +++ b/examples/order-amqp-contract/src/contract.ts @@ -36,9 +36,9 @@ const parked = defineExchange("orders-dlx", { type: "direct" }); */ const orderChanged = defineMessage( z.object({ - tenantId: z.string(), + tenantId: z.uuidv7(), kind: z.literal("order"), - id: z.string(), + id: z.uuidv7(), occurredAt: z.string(), payload: z.object({ quantity: z.number() }).nullable(), }), diff --git a/examples/order-amqp-worker/package.json b/examples/order-amqp-worker/package.json index 1e0b849b..54a2c133 100644 --- a/examples/order-amqp-worker/package.json +++ b/examples/order-amqp-worker/package.json @@ -23,6 +23,7 @@ "@btravstack/di": "workspace:*", "@btravstack/example-order-amqp-contract": "workspace:*", "@btravstack/example-order-application": "workspace:*", + "@btravstack/example-order-domain": "workspace:*", "@btravstack/example-order-infrastructure": "workspace:*", "@btravstack/observability": "workspace:*", "@opentelemetry/api": "catalog:", diff --git a/examples/order-amqp-worker/src/amqp-runtime.spec.ts b/examples/order-amqp-worker/src/amqp-runtime.spec.ts index 0a419269..1a0fd34d 100644 --- a/examples/order-amqp-worker/src/amqp-runtime.spec.ts +++ b/examples/order-amqp-worker/src/amqp-runtime.spec.ts @@ -31,22 +31,26 @@ describe("the broadcast deployment", () => { const { placeOrder } = tapped.services(); // WHEN an order is placed — one ordinary write, no publish in sight - await expect(placeOrder.execute(tenant, "o-1", 2)).toBeOkWith( - expect.objectContaining({ id: "o-1" }), + await expect(placeOrder.execute(tenant, "0199a1e0-0000-7000-8000-000000000001", 2)).toBeOkWith( + expect.objectContaining({ id: "0199a1e0-0000-7000-8000-000000000001" }), ); // THEN the fact crosses the outbox, the broker and the queue, and the // consumer reacts — the write-side never spoke AMQP await expect .poll(() => notifications(tapped.lines()), { timeout: 5_000 }) - .toContainEqual({ message: "order placed — notifying", orderId: "o-1", quantity: 2 }); + .toContainEqual({ + message: "order placed — notifying", + orderId: "0199a1e0-0000-7000-8000-000000000001", + quantity: 2, + }); }); it("marks relayed events published, exactly once each", async ({ tenant, serve, tapped }) => { // GIVEN a served app and a committed write await serve(tapped.module); const { placeOrder, outbox } = tapped.services(); - await expect(placeOrder.execute(tenant, "o-2", 1)).toBeOk(); + await expect(placeOrder.execute(tenant, "0199a1e0-0000-7000-8000-000000000002", 1)).toBeOk(); const pending = async (): Promise => (await outbox.pending(tenant, 10)).get(); @@ -62,7 +66,13 @@ describe("the broadcast deployment", () => { // claim and what a re-published event would break expect({ pending: await pending(), notified: notifications(tapped.lines()) }).toEqual({ pending: [], - notified: [{ message: "order placed — notifying", orderId: "o-2", quantity: 1 }], + notified: [ + { + message: "order placed — notifying", + orderId: "0199a1e0-0000-7000-8000-000000000002", + quantity: 1, + }, + ], }); }); @@ -72,16 +82,24 @@ describe("the broadcast deployment", () => { const { placeOrder } = tapped.services(); // WHEN two writes commit in order - await expect(placeOrder.execute(tenant, "o-3", 1)).toBeOk(); - await expect(placeOrder.execute(tenant, "o-4", 1)).toBeOk(); + await expect(placeOrder.execute(tenant, "0199a1e0-0000-7000-8000-000000000003", 1)).toBeOk(); + await expect(placeOrder.execute(tenant, "0199a1e0-0000-7000-8000-000000000004", 1)).toBeOk(); // THEN the notifications arrive in the same order: the relay publishes by // outbox id, the queue preserves it, the consumer is sequential await expect .poll(() => notifications(tapped.lines()), { timeout: 5_000 }) .toEqual([ - { message: "order placed — notifying", orderId: "o-3", quantity: 1 }, - { message: "order placed — notifying", orderId: "o-4", quantity: 1 }, + { + message: "order placed — notifying", + orderId: "0199a1e0-0000-7000-8000-000000000003", + quantity: 1, + }, + { + message: "order placed — notifying", + orderId: "0199a1e0-0000-7000-8000-000000000004", + quantity: 1, + }, ]); }); @@ -93,10 +111,10 @@ describe("the broadcast deployment", () => { // GIVEN a served app and a placed order await serve(tapped.module); const { placeOrder, repository } = tapped.services(); - await expect(placeOrder.execute(tenant, "o-6", 2)).toBeOk(); + await expect(placeOrder.execute(tenant, "0199a1e0-0000-7000-8000-000000000006", 2)).toBeOk(); // WHEN the order is cancelled — the write path the saga's compensation uses - await expect(repository.remove(tenant, "o-6")).toBeOk(); + await expect(repository.remove(tenant, "0199a1e0-0000-7000-8000-000000000006")).toBeOk(); // THEN the subscriber hears both words about the subject, in order: what // it was, then that it is gone. Without the tombstone a reader keeping its @@ -104,8 +122,12 @@ describe("the broadcast deployment", () => { await expect .poll(() => notifications(tapped.lines()), { timeout: 5_000 }) .toEqual([ - { message: "order placed — notifying", orderId: "o-6", quantity: 2 }, - { message: "order gone — notifying", orderId: "o-6" }, + { + message: "order placed — notifying", + orderId: "0199a1e0-0000-7000-8000-000000000006", + quantity: 2, + }, + { message: "order gone — notifying", orderId: "0199a1e0-0000-7000-8000-000000000006" }, ]); }); @@ -122,7 +144,9 @@ describe("the broadcast deployment", () => { const waitForMessages = await initConsumer("orders", "order.changed"); // WHEN an order is placed - await expect(tapped.services().placeOrder.execute(tenant, "o-5", 4)).toBeOk(); + await expect( + tapped.services().placeOrder.execute(tenant, "0199a1e0-0000-7000-8000-000000000005", 4), + ).toBeOk(); // THEN the foreign queue receives the same fact the notifier does — the // publisher addressed an exchange, never a consumer @@ -130,7 +154,7 @@ describe("the broadcast deployment", () => { expect(JSON.parse(String(message?.content))).toEqual({ tenantId: tenant, kind: "order", - id: "o-5", + id: "0199a1e0-0000-7000-8000-000000000005", occurredAt: expect.any(String), payload: { quantity: 4 }, }); @@ -143,7 +167,7 @@ describe("the broadcast deployment", () => { const { placeOrder } = tapped.services(); // WHEN one order is placed, so the relay publishes exactly one event - await expect(placeOrder.execute(tenant, "o-7", 2)).toBeOk(); + await expect(placeOrder.execute(tenant, "0199a1e0-0000-7000-8000-000000000007", 2)).toBeOk(); // THEN both subscribers logged it — a broadcast, not a work queue. The // writer's own line is named rather than filtered out by "has no kernel diff --git a/examples/order-amqp-worker/src/outbox-relay.ts b/examples/order-amqp-worker/src/outbox-relay.ts index adab5ba5..23335158 100644 --- a/examples/order-amqp-worker/src/outbox-relay.ts +++ b/examples/order-amqp-worker/src/outbox-relay.ts @@ -4,6 +4,7 @@ import { Config } from "@btravstack/config"; import { Port, Provider, type ServiceOf } from "@btravstack/di"; import { orderContract } from "@btravstack/example-order-amqp-contract"; import { Outbox } from "@btravstack/example-order-application"; +import { TenantId } from "@btravstack/example-order-domain"; import { Logger } from "@btravstack/observability"; import { ErrAsync, P, TaggedError, fromSafePromise, type AsyncResult } from "unthrown"; @@ -30,12 +31,21 @@ export const relayConfig = Config.provider("RelayConfig")( }), ); -/** `"acme, globex"` → `["acme", "globex"]`; blank entries dropped, so a trailing comma is not a tenant named `""`. */ -const tenantsOf = (value: string): readonly string[] => +/** + * `"acme, globex"` → `["acme", "globex"]`; blank entries dropped, so a trailing + * comma is not a tenant named `""`. + * + * The one place this deployment claims the `TenantId` brand: the relay's + * tenants come from configuration rather than from a contract, so this parse + * IS the boundary, and every sweep below carries the brand from here without + * casting again. + */ +const tenantsOf = (value: string): readonly TenantId[] => value .split(",") .map((tenant) => tenant.trim()) - .filter((tenant) => tenant !== ""); + .filter((tenant) => tenant !== "") + .map(TenantId); /** The running relay: nothing resolves it, and nothing needs to — it exists to be started and stopped. */ export class OutboxRelay extends Port("OutboxRelay")<{ @@ -93,7 +103,7 @@ const startOutboxRelay = ( url, pollMs, tenants, - }: { readonly url: string; readonly pollMs: number; readonly tenants: readonly string[] }, + }: { readonly url: string; readonly pollMs: number; readonly tenants: readonly TenantId[] }, ): AsyncResult, BrokerUnreachable> => TypedAmqpClient.create({ contract: orderContract, urls: [url] }) .recoverDefect((cause) => ErrAsync(new BrokerUnreachable({ url, cause }))) @@ -113,7 +123,7 @@ const startOutboxRelay = ( }; }); - const sweepTenant = async (tenantId: string): Promise => { + const sweepTenant = async (tenantId: TenantId): Promise => { await outbox.pending(tenantId, BATCH).match({ ok: async (events) => { const published: number[] = []; diff --git a/examples/order-amqp-worker/src/test-fixtures.ts b/examples/order-amqp-worker/src/test-fixtures.ts index d5476120..d9886976 100644 --- a/examples/order-amqp-worker/src/test-fixtures.ts +++ b/examples/order-amqp-worker/src/test-fixtures.ts @@ -1,5 +1,3 @@ -import { randomUUID } from "node:crypto"; - import { it as amqpIt } from "@amqp-contract/testing"; import type { AmqpTestFixtures } from "@amqp-contract/testing/extension"; import { AmqpModule, type AmqpInfo, type AmqpRuntime } from "@btravstack/amqp"; @@ -13,7 +11,9 @@ import { Outbox, PlaceOrder, } from "@btravstack/example-order-application"; +import { TenantId } from "@btravstack/example-order-domain"; import { OrderPersistenceModule } from "@btravstack/example-order-infrastructure"; +import { uuidv7 } from "@btravstack/internal-test-infra/uuid"; import { observability, type Line } from "@btravstack/observability"; import { bootFixture, tapped, type Boot } from "@btravstack/testing"; import { inject, type TestAPI } from "vitest"; @@ -96,7 +96,7 @@ export type AmqpFixtures = { * It is what `OUTBOX_TENANTS` points the relay at, and what every write in a * spec names, because the ports say so. */ - readonly tenant: string; + readonly tenant: TenantId; /** Boots an app against this test's own vhost, through `boot` — so its shutdown is the fixture's. */ readonly serve: Serve; /** @@ -116,7 +116,7 @@ export const it: TestAPI = amqpIt.extend { - await use(`t-${randomUUID()}`); + await use(TenantId(uuidv7())); }, serve: async ({ amqpConnectionUrl, tenant, boot }, use) => { diff --git a/examples/order-api-contract/src/client.spec.ts b/examples/order-api-contract/src/client.spec.ts index 2866e31b..3dc2730c 100644 --- a/examples/order-api-contract/src/client.spec.ts +++ b/examples/order-api-contract/src/client.spec.ts @@ -12,8 +12,10 @@ describe("contract", () => { // WHEN a procedure the contract declares is called // THEN the contract's own output shape comes back as a value - await expect(client.orders.place({ id: "o-1", quantity: 2 })).toBeOkWith({ - id: "o-1", + await expect( + client.orders.place({ id: "0199a1e0-0000-7000-8000-000000000001", quantity: 2 }), + ).toBeOkWith({ + id: "0199a1e0-0000-7000-8000-000000000001", quantity: 2, }); }); @@ -22,7 +24,7 @@ describe("contract", () => { // GIVEN the same client, and an order the stub does not hold // WHEN it is looked up - const missing = await client.orders.find({ id: "o-404" }); + const missing = await client.orders.find({ id: "0199a1e0-0000-7000-8000-000000000404" }); // THEN the code and payload the contract declares arrive on the error // channel, inferable — the half of contract-first design a client is @@ -31,7 +33,7 @@ describe("contract", () => { expect.objectContaining({ constructor: ORPCError, code: "NOT_FOUND", - data: { id: "o-404" }, + data: { id: "0199a1e0-0000-7000-8000-000000000404" }, inferable: true, }), ); diff --git a/examples/order-api-contract/src/contract.ts b/examples/order-api-contract/src/contract.ts index 364c887d..00c96622 100644 --- a/examples/order-api-contract/src/contract.ts +++ b/examples/order-api-contract/src/contract.ts @@ -15,13 +15,21 @@ import { z } from "zod"; * One definition means the checked shape and the compiled shape cannot drift, * which is what `order-temporal-contract` and `order-amqp-contract` already do. */ -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; /** The payload every declared error carries — which order it was about. */ -const orderRef = z.object({ id: z.string() }); +const orderRef = z.object({ id: z.uuidv7() }); export type OrderRef = z.infer; +/** + * What `BAD_REQUEST` carries, and the one ref whose `id` is a bare `string`. + * It names the id **as received**, which is precisely the value that is not a + * UUIDv7 — validating it against `z.uuidv7()` would reject the only payload + * this error is ever constructed with. + */ +const malformedRef = z.object({ id: z.string() }); + /** * An **unauthenticated** input names its tenant, because this API serves * several from one database and "which tenant" is then part of what is being @@ -37,11 +45,11 @@ export type OrderRef = z.infer; * contrast is the lesson — where a caller's identity establishes the tenant, * the input has nothing to say about it. */ -const tenanted = z.object({ tenantId: z.string() }); +const tenanted = z.object({ tenantId: z.uuidv7() }); export type Tenanted = z.infer; /** What a customer looks like on the wire. */ -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; /** @@ -50,16 +58,17 @@ export type CustomerView = z.infer; * a customer id as "which order it was about", and the exported type would lie * to a client about which entity it names. */ -const customerRef = z.object({ id: z.string() }); +const customerRef = z.object({ id: z.uuidv7() }); export type CustomerRef = z.infer; /** The orders slice's own fragment — a contract in its own right, so the slice can be served alone. */ 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 @@ -71,7 +80,7 @@ const ordersContract = { /** The customers slice's own fragment. Reached as `contract.customers`; a fragment is a contract in its own right, so the slice can be served alone. */ const customersContract = { find: oc - .input(tenanted.extend({ id: z.string() })) + .input(tenanted.extend({ id: z.uuidv7() })) .output(customerView) .errors({ NOT_FOUND: { data: customerRef } }), }; diff --git a/examples/order-api/README.md b/examples/order-api/README.md index 812c0e80..b64a92e5 100644 --- a/examples/order-api/README.md +++ b/examples/order-api/README.md @@ -65,6 +65,11 @@ place 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, data: { id: error.id } }), ), @@ -113,7 +118,7 @@ else. Where the identity is **stated** is `src/auth.ts`, the whole of it: ```ts -export type Identity = { readonly tenantId: string; readonly userId: string }; +export type Identity = { readonly tenantId: TenantId; readonly userId: string }; const identity = httpAuth(); @@ -214,6 +219,7 @@ const named = (await client.orders.place({ id, quantity })).match({ errCases: (matcher) => matcher.with( { code: "INVALID_QUANTITY" }, + { code: "BAD_REQUEST" }, { code: "CONFLICT" }, (error) => error.code, ), @@ -317,7 +323,11 @@ const ordersContract = { place: oc .input(type<{ readonly id: string; readonly quantity: number }>()) .output(type()) - .errors({ INVALID_QUANTITY: { data: type() }, CONFLICT: { data: type() } }), + .errors({ + INVALID_QUANTITY: { data: type() }, + BAD_REQUEST: { data: type<{ readonly id: string }>() }, + CONFLICT: { data: type() }, + }), … }; ``` diff --git a/examples/order-api/package.json b/examples/order-api/package.json index d000be27..a0f7d39c 100644 --- a/examples/order-api/package.json +++ b/examples/order-api/package.json @@ -30,6 +30,7 @@ "unthrown": "catalog:" }, "devDependencies": { + "@btravstack/internal-test-infra": "workspace:*", "@btravstack/testing": "workspace:*", "@btravstack/tsconfig": "catalog:", "@types/node": "catalog:", diff --git a/examples/order-api/src/api.spec.ts b/examples/order-api/src/api.spec.ts index da62cc1e..3715c109 100644 --- a/examples/order-api/src/api.spec.ts +++ b/examples/order-api/src/api.spec.ts @@ -15,8 +15,10 @@ describe("order-api", () => { // WHEN a call goes over the wire // THEN it reached the use case behind the transport - await expect(client.orders.place({ id: "o-1", quantity: 2 })).toBeOkWith({ - id: "o-1", + await expect( + client.orders.place({ id: "0199a1e0-0000-7000-8000-000000000001", quantity: 2 }), + ).toBeOkWith({ + id: "0199a1e0-0000-7000-8000-000000000001", quantity: 2, }); }); @@ -33,11 +35,11 @@ describe("order-api", () => { // a second unit — and the same database, because the application scope is // opened once by the kernel and only the request scope is forked per call. const found = await client.orders - .place({ id: "o-1", quantity: 2 }) - .flatMap(() => client.orders.find({ id: "o-1" })); + .place({ id: "0199a1e0-0000-7000-8000-000000000001", quantity: 2 }) + .flatMap(() => client.orders.find({ id: "0199a1e0-0000-7000-8000-000000000001" })); // THEN the write is visible to the read - expect(found).toBeOkWith({ id: "o-1", quantity: 2 }); + expect(found).toBeOkWith({ id: "0199a1e0-0000-7000-8000-000000000001", quantity: 2 }); }); it("publishes the bound port on Serving.info", async ({ serve, api }) => { @@ -62,8 +64,10 @@ describe("order-api", () => { // WHEN the same id is placed again — chained, so the first call's `Result` // is consumed and a failure there cannot be mistaken for the conflict const conflict = await client.orders - .place({ id: "o-1", quantity: 1 }) - .flatMap(() => client.orders.place({ id: "o-1", quantity: 1 })); + .place({ id: "0199a1e0-0000-7000-8000-000000000001", quantity: 1 }) + .flatMap(() => + client.orders.place({ id: "0199a1e0-0000-7000-8000-000000000001", quantity: 1 }), + ); // THEN the `Err` channel, not the defect one: the client got a value back. // `constructor` is read through the prototype chain, so the one assertion @@ -75,7 +79,7 @@ describe("order-api", () => { expect.objectContaining({ constructor: ORPCError, code: "CONFLICT", - data: { id: "o-1" }, + data: { id: "0199a1e0-0000-7000-8000-000000000001" }, inferable: true, }), ); @@ -90,14 +94,17 @@ describe("order-api", () => { const client = await clientFor(serve(api)); // WHEN a quantity the domain rejects is placed - const invalid = await client.orders.place({ id: "o-2", quantity: 0 }); + const invalid = await client.orders.place({ + id: "0199a1e0-0000-7000-8000-000000000002", + quantity: 0, + }); // THEN the second declared code crosses the wire the same way expect(invalid).toBeErrWith( expect.objectContaining({ constructor: ORPCError, code: "INVALID_QUANTITY", - data: { id: "o-2" }, + data: { id: "0199a1e0-0000-7000-8000-000000000002" }, inferable: true, }), ); @@ -110,16 +117,24 @@ describe("order-api", () => { }) => { // GIVEN an error the contract declares const client = await clientFor(serve(api)); - const invalid = await client.orders.place({ id: "o-2", quantity: 0 }); + const invalid = await client.orders.place({ + id: "0199a1e0-0000-7000-8000-000000000002", + quantity: 0, + }); // WHEN the channel is folded — the mirror of the `mapErrCases` that - // produced it, with no wildcard to fall back on. Both codes are named and - // grouped into one arm because they share a handler, which is what a + // produced it, with no wildcard to fall back on. All three codes are named + // and grouped into one arm because they share a handler, which is what a // wildcard would look like if it were still a decision const named = invalid.match({ ok: () => "WRONGLY ACCEPTED", errCases: (matcher) => - matcher.with({ code: "INVALID_QUANTITY" }, { code: "CONFLICT" }, (error) => error.code), + matcher.with( + { code: "INVALID_QUANTITY" }, + { code: "BAD_REQUEST" }, + { code: "CONFLICT" }, + (error) => error.code, + ), defect: () => "defect", }); @@ -136,7 +151,7 @@ describe("order-api", () => { const client = await clientFor(serve(unmodelled)); // WHEN a call reaches it - const result = await client.orders.find({ id: "o-1" }); + const result = await client.orders.find({ id: "0199a1e0-0000-7000-8000-000000000001" }); // THEN the raw cause does NOT leak over the wire; oRPC collapses it, and // the non-inferable result lands back in the defect channel. `inferable` @@ -165,9 +180,11 @@ describe("order-api", () => { // here rather than asserted — it is the subject of the test above; what // this one asks is what the process does next. const served = await client.orders - .find({ id: "o-1" }) + .find({ id: "0199a1e0-0000-7000-8000-000000000001" }) .recoverDefect(() => Ok("defected" as const)) - .flatMap(() => client.orders.place({ id: "o-1", quantity: 1 })) + .flatMap(() => + client.orders.place({ id: "0199a1e0-0000-7000-8000-000000000001", quantity: 1 }), + ) .map(() => app.phase()); // THEN the next call was served, by a process still in the serving phase @@ -184,8 +201,10 @@ describe("order-api", () => { // WHEN two calls are served — chained, so neither `Result` is dropped const served = await client.orders - .place({ id: "o-1", quantity: 1 }) - .flatMap(() => client.orders.place({ id: "o-2", quantity: 1 })); + .place({ id: "0199a1e0-0000-7000-8000-000000000001", quantity: 1 }) + .flatMap(() => + client.orders.place({ id: "0199a1e0-0000-7000-8000-000000000002", quantity: 1 }), + ); // THEN two calls, each writing a controller line, an interactor line and a // request-scope teardown line, carrying two distinct trace ids and never @@ -206,7 +225,7 @@ describe("order-api", () => { // GIVEN a call held open inside the repository const app = serve(gate.api); const client = await clientFor(app); - const inFlight = client.orders.find({ id: "o-1" }); + const inFlight = client.orders.find({ id: "0199a1e0-0000-7000-8000-000000000001" }); await gate.arrived; // WHEN the drain starts and the call is released only once the phase moved. @@ -218,7 +237,7 @@ describe("order-api", () => { gate.release(); // THEN the call ran to completion - await expect(inFlight).toBeOkWith({ id: "o-1", quantity: 1 }); + await expect(inFlight).toBeOkWith({ id: "0199a1e0-0000-7000-8000-000000000001", quantity: 1 }); }); it("counts the finished call as completed in the drain report", async ({ @@ -229,7 +248,7 @@ describe("order-api", () => { // GIVEN a call held open inside the repository const app = serve(gate.api); const client = await clientFor(app); - const inFlight = client.orders.find({ id: "o-1" }); + const inFlight = client.orders.find({ id: "0199a1e0-0000-7000-8000-000000000001" }); await gate.arrived; // WHEN the drain starts and the call is released only once the phase moved @@ -255,7 +274,7 @@ describe("order-api", () => { // GIVEN a call held open, and a drain with no time to give it const app = serve(gate.api, { drainTimeoutMs: 0 }); const client = await clientFor(app); - const hung = client.orders.find({ id: "o-1" }); + const hung = client.orders.find({ id: "0199a1e0-0000-7000-8000-000000000001" }); await gate.arrived; // WHEN the drain starts and the call is never released @@ -280,7 +299,7 @@ describe("order-api", () => { // GIVEN a call held open, and a drain with no time to give it const app = serve(gate.api, { drainTimeoutMs: 0 }); const client = await clientFor(app); - const hung = client.orders.find({ id: "o-1" }); + const hung = client.orders.find({ id: "0199a1e0-0000-7000-8000-000000000001" }); await gate.arrived; // WHEN the drain starts and the call is never released @@ -323,7 +342,10 @@ describe("order-api", () => { const client = await clientWith(serve(api), undefined); // WHEN a procedure of the authenticated fragment is called - const refused = await client.orders.place({ id: "o-1", quantity: 1 }); + const refused = await client.orders.place({ + id: "0199a1e0-0000-7000-8000-000000000001", + quantity: 1, + }); // THEN it was refused before the use case was reached. `UNAUTHORIZED` is // not a code the contract declares, so oRPC does not mark it inferable and @@ -346,7 +368,10 @@ describe("order-api", () => { // WHEN a procedure is called with an input the contract's schema rejects, // past the client's own types - const refused = await client.orders.place({ id: "o-1", quantity: "abc" } as never); + const refused = await client.orders.place({ + id: "0199a1e0-0000-7000-8000-000000000001", + quantity: "abc", + } as never); // THEN oRPC refused it before dispatch. This is the property `type()` // did not have: it validates nothing, so `"abc"` reached the use case @@ -357,6 +382,22 @@ describe("order-api", () => { ); }); + it("refuses an id that is not a UUIDv7", async ({ serve, clientFor, api }) => { + // GIVEN the real composition root and a credentialed caller + const app = serve(api); + const client = await clientFor(app); + + // WHEN a procedure is called with an id the contract's schema rejects, + // past the client's own types + const refused = await client.orders.place({ id: "o-1", quantity: 2 } as never); + + // THEN oRPC refused it before dispatch, the same schema-level defense a + // malformed quantity gets, now guarding the id's shape too + expect(refused).toBeDefectWith( + expect.objectContaining({ constructor: ORPCError, code: "BAD_REQUEST", inferable: false }), + ); + }); + it("never enters the handler for a malformed input", async ({ serve, clientFor, recording }) => { // GIVEN the real graph, recording every line its logger writes const client = await clientFor(serve(recording.api)); @@ -386,8 +427,10 @@ describe("order-api", () => { // WHEN a procedure of that fragment is called // THEN it answers: the marker is per-fragment, so protecting `orders` did // not quietly close the rest of the API - await expect(client.customers.find({ tenantId: tenant, id: "c-1" })).toBeOkWith({ - id: "c-1", + await expect( + client.customers.find({ tenantId: tenant, id: "0199a1e0-0000-7000-8000-0000000000c1" }), + ).toBeOkWith({ + id: "0199a1e0-0000-7000-8000-0000000000c1", name: "Ada", }); }); @@ -407,8 +450,8 @@ describe("order-api", () => { // WHEN the first places an order and the second looks that id up const found = await client.orders - .place({ id: "o-1", quantity: 2 }) - .flatMap(() => stranger.orders.find({ id: "o-1" })); + .place({ id: "0199a1e0-0000-7000-8000-000000000001", quantity: 2 }) + .flatMap(() => stranger.orders.find({ id: "0199a1e0-0000-7000-8000-000000000001" })); // THEN the second sees nothing: the tenant a marked handler serves is // `context.principal.tenantId`, and the fragment's inputs name no tenant @@ -417,7 +460,7 @@ describe("order-api", () => { expect.objectContaining({ constructor: ORPCError, code: "NOT_FOUND", - data: { id: "o-1" }, + data: { id: "0199a1e0-0000-7000-8000-000000000001" }, inferable: true, }), ); @@ -436,8 +479,10 @@ describe("order-api", () => { // WHEN a procedure from the second slice is called // THEN it answers with the contract's shape — the branded `Customer` the // use case returned, converted by that controller's own `view` - await expect(client.customers.find({ tenantId: tenant, id: "c-1" })).toBeOkWith({ - id: "c-1", + await expect( + client.customers.find({ tenantId: tenant, id: "0199a1e0-0000-7000-8000-0000000000c1" }), + ).toBeOkWith({ + id: "0199a1e0-0000-7000-8000-0000000000c1", name: "Ada", }); }); @@ -452,7 +497,10 @@ describe("order-api", () => { const client = await clientFor(serve(api)); // WHEN a customer nobody registered is looked up - const missing = await client.customers.find({ tenantId: tenant, id: "c-404" }); + const missing = await client.customers.find({ + tenantId: tenant, + id: "0199a1e0-0000-7000-8000-00000000c404", + }); // THEN the domain's `CustomerNotFound` crossed the second slice's own // triage the way `OrderNotFound` crosses the first's — a typed, inferable @@ -461,7 +509,7 @@ describe("order-api", () => { expect.objectContaining({ constructor: ORPCError, code: "NOT_FOUND", - data: { id: "c-404" }, + data: { id: "0199a1e0-0000-7000-8000-00000000c404" }, inferable: true, }), ); @@ -479,7 +527,7 @@ describe("order-api", () => { const app = serve(gate.api, { probes: { port: 0 } }); const probes = await probesFor(app); const client = await clientFor(app); - const inFlight = client.orders.find({ id: "o-1" }); + const inFlight = client.orders.find({ id: "0199a1e0-0000-7000-8000-000000000001" }); await gate.arrived; // WHEN the drain starts. The TRANSITION is awaited through `ready()`, which diff --git a/examples/order-api/src/auth.ts b/examples/order-api/src/auth.ts index ab03181f..f00d343f 100644 --- a/examples/order-api/src/auth.ts +++ b/examples/order-api/src/auth.ts @@ -1,3 +1,4 @@ +import type { TenantId } from "@btravstack/example-order-domain"; import { httpAuth, type HttpAuthenticatorOf, @@ -15,8 +16,14 @@ import { * below sees `Identity` with no annotation at its own call site, and * `HttpModule`'s gate compares the router's identity against the * authenticator's, both of which come from the one call here. + * + * `tenantId` is the domain's `TenantId` rather than a `string`, so the value + * the authenticator resolved is already the one every port in the application + * asks for: a handler passes `context.principal.tenantId` straight to a use + * case, and the brand travels with it instead of being re-claimed at each + * call. */ -export type Identity = { readonly tenantId: string; readonly userId: string }; +export type Identity = { readonly tenantId: TenantId; readonly userId: string }; /** * The three the factory mints, together — imported by the slices instead of diff --git a/examples/order-api/src/authenticator.ts b/examples/order-api/src/authenticator.ts index 793dd9fb..85d92ad9 100644 --- a/examples/order-api/src/authenticator.ts +++ b/examples/order-api/src/authenticator.ts @@ -1,3 +1,4 @@ +import { TenantId } from "@btravstack/example-order-domain"; import { Unauthenticated } from "@btravstack/http"; import { ErrAsync, OkAsync } from "unthrown"; @@ -11,9 +12,15 @@ import { HttpAuthenticator } from "./auth.js"; * * `[]` because this one needs no service; a verifier, a key set or a user * directory would be named there and injected the way any provider's - * dependencies are. The identity is `./auth.ts`'s — the same call the slices' - * controllers are minted from — so a token resolving to the wrong shape is a - * compile error here, and the handlers cannot be reading a different one. + * dependencies are. + * + * The identity is `./auth.ts`'s — the same call the slices' controllers are + * minted from — so a token resolving to the wrong shape is a compile error + * here, and the handlers cannot be reading a different one. + * + * This is also where a header becomes a **tenant**, and therefore the one + * place this path claims the `TenantId` brand: from here on the identity + * carries it, and neither controller casts anything. */ export const bearerAuthenticator = HttpAuthenticator({ sync: () => (headers) => { @@ -22,6 +29,6 @@ export const bearerAuthenticator = HttpAuthenticator({ const [tenantId, userId] = token.split(":"); return tenantId === undefined || tenantId === "" || userId === undefined || userId === "" ? ErrAsync(new Unauthenticated()) - : OkAsync({ tenantId, userId }); + : OkAsync({ tenantId: TenantId(tenantId), userId }); }, }); diff --git a/examples/order-api/src/docs-examples.test-d.ts b/examples/order-api/src/docs-examples.test-d.ts index eb57edeb..9fa447de 100644 --- a/examples/order-api/src/docs-examples.test-d.ts +++ b/examples/order-api/src/docs-examples.test-d.ts @@ -30,7 +30,7 @@ import { OrderApplicationModule, PlaceOrder, } from "@btravstack/example-order-application"; -import type { Customer, Order } from "@btravstack/example-order-domain"; +import { TenantId, type Customer, type Order } from "@btravstack/example-order-domain"; import { CustomerPersistenceModule, OrderPersistenceModule, @@ -74,6 +74,9 @@ const ordersController = HttpController("DocsOrdersController", contract.orders) .with(P.tag("InvalidQuantity"), (error) => errors.INVALID_QUANTITY({ message: error.message, data: { id: error.id } }), ) + .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, data: { id: error.id } }), ), @@ -100,7 +103,7 @@ const customersController = HttpController("DocsCustomersController", contract.c sync: ({ find }) => ({ find: ({ errors }, input) => find - .execute(input.tenantId, input.id) + .execute(TenantId(input.tenantId), input.id) .map(customerViewOf) .mapErrCases((matcher) => matcher.with(P.tag("CustomerNotFound"), (error) => @@ -183,6 +186,9 @@ const depsOrdersRouter = HttpRouter(contract.orders)( .with(P.tag("InvalidQuantity"), (error) => errors.INVALID_QUANTITY({ message: error.message, data: { id: error.id } }), ) + .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, data: { id: error.id } }), ), diff --git a/examples/order-api/src/slices/customers/controller.ts b/examples/order-api/src/slices/customers/controller.ts index 8a22ea46..4950ec0f 100644 --- a/examples/order-api/src/slices/customers/controller.ts +++ b/examples/order-api/src/slices/customers/controller.ts @@ -1,6 +1,6 @@ import { contract, type CustomerView } from "@btravstack/example-order-api-contract"; import { FindCustomer } from "@btravstack/example-order-application"; -import type { Customer } from "@btravstack/example-order-domain"; +import { TenantId, type Customer } from "@btravstack/example-order-domain"; import { P } from "unthrown"; import { HttpController } from "../../auth.js"; @@ -26,7 +26,7 @@ export const customersController = HttpController("CustomersController", contrac sync: ({ find }) => ({ find: ({ errors }, input) => find - .execute(input.tenantId, input.id) + .execute(TenantId(input.tenantId), input.id) .map(view) .mapErrCases((matcher) => matcher.with(P.tag("CustomerNotFound"), (error) => diff --git a/examples/order-api/src/slices/orders/controller.ts b/examples/order-api/src/slices/orders/controller.ts index 39def97f..fff32daa 100644 --- a/examples/order-api/src/slices/orders/controller.ts +++ b/examples/order-api/src/slices/orders/controller.ts @@ -61,6 +61,29 @@ export const ordersController = HttpController("OrdersController", contract.orde .with(P.tag("InvalidQuantity"), (error) => errors.INVALID_QUANTITY({ message: error.message, data: { id: error.id } }), ) + // `BAD_REQUEST`, not `CONFLICT`: a malformed id is the caller's + // mistake, and 400 is the only status that says so. The arm costs + // nothing — `mapErrCases` is exhaustive, so it is written or the + // build fails — and it is not dead code elsewhere: `placeOrder` is + // a public export whose own signature takes a bare `string`, so + // this fragment's `z.uuidv7()` is one caller's guard rather than + // the function's, and the documentation site's generic pages + // declare `id: z.string()`, where the arm is live. + // + // Two paths reach the same code: this one, and oRPC's own + // pre-dispatch refusal of an id the schema rejects. What tells + // them apart is `inferable`, not the payload — oRPC's refusal + // throws `ORPCError("BAD_REQUEST", { data: { issues } })`, so it + // carries data too. `inferable` defaults to `false` and is set + // only when a handler *returns* an `ORPCError` as its output, and + // `isInferableError` is `e instanceof ORPCError && e.inferable`, + // which is why `@unthrown/orpc` hands one back on the error + // channel and the other on the defect channel. `api.spec.ts` pins + // both halves, `inferable: true` here and `inferable: false` + // there — a structural mechanism, not a coincidence. + .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, data: { id: error.id } }), ), diff --git a/examples/order-api/src/test-fixtures.ts b/examples/order-api/src/test-fixtures.ts index fff0dbd3..fb82cc2e 100644 --- a/examples/order-api/src/test-fixtures.ts +++ b/examples/order-api/src/test-fixtures.ts @@ -1,5 +1,4 @@ import assert from "node:assert/strict"; -import { randomUUID } from "node:crypto"; import type { Env } from "@btravstack/config"; import type { RunningApp, StartOptions } from "@btravstack/core"; @@ -16,8 +15,10 @@ import { OrderNotFound, placeOrder, type Order, + type TenantId, } from "@btravstack/example-order-domain"; import { HttpModule, type HttpInfo, type HttpRuntime } from "@btravstack/http"; +import { uuidv7 } from "@btravstack/internal-test-infra/uuid"; import { Logger, observability, type Line, type Sink } from "@btravstack/observability"; import { bootFixture, type Boot } from "@btravstack/testing"; import { ErrAsync, fromSafePromise, OkAsync } from "unthrown"; @@ -46,8 +47,8 @@ const persistenceOf = (repository: ServiceOf) => Provider(OrderRepository)({ value: repository }), Provider(CustomerRepository)({ value: { - find: (_tenantId: string, id: string) => - id === "c-1" + find: (_tenantId: TenantId, id: string) => + id === "0199a1e0-0000-7000-8000-0000000000c1" ? OkAsync(Customer.make({ id, name: "Ada" }).getOrThrow()) : ErrAsync(new CustomerNotFound({ id })), }, @@ -129,7 +130,7 @@ const recordingApi = () => { /** * The stub root at rest: nothing hangs, nothing blows up, and the customer - * `c-1` is registered. What the customers slice's success path needs, which + * `0199a1e0-0000-7000-8000-0000000000c1` is registered. What the customers slice's success path needs, which * the real root cannot give it — its database is born empty inside the graph * and no procedure registers anyone. */ @@ -199,7 +200,7 @@ export type ApiFixtures = { /** * This test's tenant, and nobody else's. The database is shared by every * workspace's run — one migration for the whole gate rather than one per - * test — so a UUID here is what keeps one spec's `o-1` from being another's. + * test — so a UUID here is what keeps one spec's `0199a1e0-0000-7000-8000-000000000001` from being another's. * Every call names it, because the contract does. */ readonly tenant: string; @@ -272,7 +273,7 @@ export const it = test.extend({ // oxlint-disable-next-line no-empty-pattern -- Vitest fixtures require a destructuring pattern; this one depends on no other fixture tenant: async ({}, use) => { - await use(`t-${randomUUID()}`); + await use(uuidv7()); }, serve: async ({ boot }, use) => { diff --git a/examples/order-application/README.md b/examples/order-application/README.md index 2e79478d..6e78e9ef 100644 --- a/examples/order-application/README.md +++ b/examples/order-application/README.md @@ -99,8 +99,9 @@ const testModuleWith = (sink: Sink) => }); ``` -Seven specs cover placement, persistence, the duplicate path, the domain rule, -the log line and both arms of the customer lookup — with no Prisma, no HTTP and +Nine specs cover placement, persistence, the duplicate path, the domain rule, +the malformed id, the tenant boundary, the log line and both arms of the +customer lookup — with no Prisma, no HTTP and no kernel booted. `observability()` binds its level from the `Env` port `start` normally provides, so a kernel-free spec provides an empty one itself; the `sink` is the seam a spec reads lines @@ -109,12 +110,13 @@ back through. ## Logging is attributes, not sentences ```ts -this.#logger.info("placing an order", { orderId: id, quantity }); +this.#logger.info("placing an order", { tenantId, orderId: id, quantity }); ``` The message is a constant and the ids are fields, which is what makes a line groupable in the system that receives it — and what lets the spec assert -`attributes: { orderId: "o-1", quantity: 2 }` rather than match a substring. +`attributes: { tenantId: "acme", orderId: "0199a1e0-0000-7000-8000-000000000001", quantity: 2 }` +rather than match a substring. Correlation is not this layer's job either: `@btravstack/observability`'s logger reads `currentUnit()` on every call, so each line carries the trace id of whatever unit the runtime opened around it. In these specs there is no unit, so @@ -127,6 +129,6 @@ server or a worker. ## Running it ```bash -pnpm --filter @btravstack/example-order-application test # 7 specs +pnpm --filter @btravstack/example-order-application test # 9 specs pnpm --filter @btravstack/example-order-application test:types # the needs gate ``` diff --git a/examples/order-application/src/find-customer.spec.ts b/examples/order-application/src/find-customer.spec.ts index f2cd0754..73a4b595 100644 --- a/examples/order-application/src/find-customer.spec.ts +++ b/examples/order-application/src/find-customer.spec.ts @@ -1,4 +1,5 @@ import { Module } from "@btravstack/di"; +import { TenantId } from "@btravstack/example-order-domain"; import { describe, expect } from "vitest"; import { FindCustomer } from "./index.js"; @@ -9,19 +10,19 @@ describe("FindCustomer", () => { // GIVEN the application wired over an in-memory repository // WHEN a customer it holds is looked up const result = await Module.scoped(testModule, (ctx) => - ctx.get(FindCustomer).execute("acme", "c-1"), + ctx.get(FindCustomer).execute(TenantId("acme"), "0199a1e0-0000-7000-8000-0000000000c1"), ); // THEN the use case answers with the domain's own entity — converting it // for a transport is the controller's job, one layer out - expect(result).toBeOkWith({ id: "c-1", name: "Ada" }); + expect(result).toBeOkWith({ id: "0199a1e0-0000-7000-8000-0000000000c1", name: "Ada" }); }); it("returns CustomerNotFound for an unknown id", async ({ testModule }) => { // GIVEN the same repository // WHEN an id nobody registered is looked up const result = await Module.scoped(testModule, (ctx) => - ctx.get(FindCustomer).execute("acme", "missing"), + ctx.get(FindCustomer).execute(TenantId("acme"), "missing"), ); // THEN absence is a modeled error, not an empty success diff --git a/examples/order-application/src/needs-gate.test-d.ts b/examples/order-application/src/needs-gate.test-d.ts index 1dbe4bc6..fe45ce8a 100644 --- a/examples/order-application/src/needs-gate.test-d.ts +++ b/examples/order-application/src/needs-gate.test-d.ts @@ -13,6 +13,7 @@ import { CustomerNotFound, DuplicateOrder, OrderNotFound, + TenantId, type Order, } from "@btravstack/example-order-domain"; import { Logger, createLogger } from "@btravstack/observability"; @@ -30,14 +31,14 @@ import { const orderRepository = Provider(OrderRepository)({ value: { - save: (_tenantId: string, order: Order) => ErrAsync(new DuplicateOrder({ id: order.id })), - find: (_tenantId: string, id: string) => ErrAsync(new OrderNotFound({ id })), - remove: (_tenantId: string, id: string) => ErrAsync(new OrderNotFound({ id })), + save: (_tenantId: TenantId, order: Order) => ErrAsync(new DuplicateOrder({ id: order.id })), + find: (_tenantId: TenantId, id: string) => ErrAsync(new OrderNotFound({ id })), + remove: (_tenantId: TenantId, id: string) => ErrAsync(new OrderNotFound({ id })), }, }); const customerRepository = Provider(CustomerRepository)({ - value: { find: (_tenantId: string, id: string) => ErrAsync(new CustomerNotFound({ id })) }, + value: { find: (_tenantId: TenantId, id: string) => ErrAsync(new CustomerNotFound({ id })) }, }); const logger = Provider(Logger)({ value: createLogger(() => {}) }); @@ -50,7 +51,7 @@ const logger = Provider(Logger)({ value: createLogger(() => {}) }); // 'Logger | OrderRepository'`. // @ts-expect-error — UNSATISFIED DEPENDENCIES: no OrderRepository is provided. const _unwiredOrders = Module.scoped(OrderApplicationModule, (ctx) => - ctx.get(PlaceOrder).execute("acme", "o-1", 1), + ctx.get(PlaceOrder).execute(TenantId("acme"), "0199a1e0-0000-7000-8000-000000000001", 1), ); // Negative, the same gate on the sibling module and a different port: the @@ -58,7 +59,7 @@ const _unwiredOrders = Module.scoped(OrderApplicationModule, (ctx) => // logger, which only `PlaceOrder` writes to. // @ts-expect-error — UNSATISFIED DEPENDENCIES: no CustomerRepository is provided. const _unwiredCustomers = Module.scoped(CustomerApplicationModule, (ctx) => - ctx.get(FindCustomer).execute("acme", "c-1"), + ctx.get(FindCustomer).execute(TenantId("acme"), "0199a1e0-0000-7000-8000-0000000000c1"), ); // Negative, per vertical: the orders repository closes the orders module, and @@ -72,7 +73,7 @@ const MiswiredCustomers = Module("MiswiredCustomers")({ // @ts-expect-error — UNSATISFIED DEPENDENCIES: no CustomerRepository is provided. const _miswired = Module.scoped(MiswiredCustomers, (ctx) => - ctx.get(FindCustomer).execute("acme", "c-1"), + ctx.get(FindCustomer).execute(TenantId("acme"), "0199a1e0-0000-7000-8000-0000000000c1"), ); // Negative, the other port of the orders pair: the repository alone does not @@ -84,7 +85,9 @@ const LoglessOrders = Module("LoglessOrders")({ }); // @ts-expect-error — UNSATISFIED DEPENDENCIES: no Logger is provided. -const _logless = Module.scoped(LoglessOrders, (ctx) => ctx.get(FindOrder).execute("acme", "o-1")); +const _logless = Module.scoped(LoglessOrders, (ctx) => + ctx.get(FindOrder).execute(TenantId("acme"), "0199a1e0-0000-7000-8000-000000000001"), +); const WiredOrders = Module("WiredOrders")({ imports: [OrderApplicationModule], @@ -100,7 +103,9 @@ const WiredOrders = Module("WiredOrders")({ // Positive: the repository and a logger discharge every need the orders // vertical has, and this is an ordinary two-argument call. -const _wiredOrders = Module.scoped(WiredOrders, (ctx) => ctx.get(FindOrder).execute("acme", "o-1")); +const _wiredOrders = Module.scoped(WiredOrders, (ctx) => + ctx.get(FindOrder).execute(TenantId("acme"), "0199a1e0-0000-7000-8000-000000000001"), +); const WiredCustomers = Module("WiredCustomers")({ imports: [CustomerApplicationModule], @@ -111,5 +116,5 @@ const WiredCustomers = Module("WiredCustomers")({ // Positive, and one provider shorter than the orders half: what a vertical // owes is now its own. const _wiredCustomers = Module.scoped(WiredCustomers, (ctx) => - ctx.get(FindCustomer).execute("acme", "c-1"), + ctx.get(FindCustomer).execute(TenantId("acme"), "0199a1e0-0000-7000-8000-0000000000c1"), ); diff --git a/examples/order-application/src/place-order.spec.ts b/examples/order-application/src/place-order.spec.ts index 4e70d30a..5dd4ea79 100644 --- a/examples/order-application/src/place-order.spec.ts +++ b/examples/order-application/src/place-order.spec.ts @@ -1,4 +1,5 @@ import { Module } from "@btravstack/di"; +import { TenantId } from "@btravstack/example-order-domain"; import { describe, expect } from "vitest"; import { FindOrder, PlaceOrder } from "./index.js"; @@ -11,12 +12,14 @@ describe("PlaceOrder", () => { const result = await Module.scoped(testModule, (ctx) => ctx .get(PlaceOrder) - .execute("acme", "o-1", 2) - .flatMap(() => ctx.get(FindOrder).execute("acme", "o-1")), + .execute(TenantId("acme"), "0199a1e0-0000-7000-8000-000000000001", 2) + .flatMap(() => + ctx.get(FindOrder).execute(TenantId("acme"), "0199a1e0-0000-7000-8000-000000000001"), + ), ); // THEN the write is visible to the read - expect(result).toBeOkWith({ id: "o-1", quantity: 2 }); + expect(result).toBeOkWith({ id: "0199a1e0-0000-7000-8000-000000000001", quantity: 2 }); }); it("surfaces the repository's DuplicateOrder unchanged", async ({ testModule }) => { @@ -25,23 +28,39 @@ describe("PlaceOrder", () => { const result = await Module.scoped(testModule, (ctx) => { const placeOrder = ctx.get(PlaceOrder); return placeOrder - .execute("acme", "o-1", 1) - .flatMap(() => placeOrder.execute("acme", "o-1", 1)); + .execute(TenantId("acme"), "0199a1e0-0000-7000-8000-000000000001", 1) + .flatMap(() => + placeOrder.execute(TenantId("acme"), "0199a1e0-0000-7000-8000-000000000001", 1), + ); }); // THEN the repository's own error reaches the caller untranslated - expect(result).toBeErrTagged("DuplicateOrder", { id: "o-1" }); + expect(result).toBeErrTagged("DuplicateOrder", { id: "0199a1e0-0000-7000-8000-000000000001" }); }); it("rejects a non-positive quantity without reaching the repository", async ({ testModule }) => { // GIVEN a quantity the domain invariant rejects // WHEN it is placed const result = await Module.scoped(testModule, (ctx) => - ctx.get(PlaceOrder).execute("acme", "o-1", 0), + ctx.get(PlaceOrder).execute(TenantId("acme"), "0199a1e0-0000-7000-8000-000000000001", 0), ); // THEN the domain error short-circuits the use case - expect(result).toBeErrTagged("InvalidQuantity", { id: "o-1", quantity: 0 }); + expect(result).toBeErrTagged("InvalidQuantity", { + id: "0199a1e0-0000-7000-8000-000000000001", + quantity: 0, + }); + }); + + it("rejects a malformed id without blaming the quantity", async ({ testModule }) => { + // GIVEN an id the domain's `OrderId` format rejects, and a fine quantity + // WHEN it is placed + const result = await Module.scoped(testModule, (ctx) => + ctx.get(PlaceOrder).execute(TenantId("acme"), "o-1", 2), + ); + + // THEN the widened channel carries the id's own error to the caller + expect(result).toBeErrTagged("InvalidOrderId", { id: "o-1" }); }); it("writes a log line carrying the order as fields", async ({ testModule, recorder }) => { @@ -50,7 +69,7 @@ describe("PlaceOrder", () => { const result = await Module.scoped(testModule, (ctx) => ctx .get(PlaceOrder) - .execute("acme", "o-1", 2) + .execute(TenantId("acme"), "0199a1e0-0000-7000-8000-000000000001", 2) .map(() => recorder.lines()), ); @@ -60,7 +79,11 @@ describe("PlaceOrder", () => { expect.objectContaining({ level: "info", message: "placing an order", - attributes: { tenantId: "acme", orderId: "o-1", quantity: 2 }, + attributes: { + tenantId: "acme", + orderId: "0199a1e0-0000-7000-8000-000000000001", + quantity: 2, + }, unit: undefined, }), ]); @@ -74,15 +97,19 @@ describe("tenancy", () => { const result = await Module.scoped(testModule, (ctx) => { const placeOrder = ctx.get(PlaceOrder); return placeOrder - .execute("acme", "o-shared", 2) - .flatMap(() => placeOrder.execute("globex", "o-shared", 7)) - .flatMap(() => ctx.get(FindOrder).execute("acme", "o-shared")); + .execute(TenantId("acme"), "0199a1e0-0000-7000-8000-000000000501", 2) + .flatMap(() => + placeOrder.execute(TenantId("globex"), "0199a1e0-0000-7000-8000-000000000501", 7), + ) + .flatMap(() => + ctx.get(FindOrder).execute(TenantId("acme"), "0199a1e0-0000-7000-8000-000000000501"), + ); }); // WHEN the first tenant reads that id back // THEN it gets its own order: the tenant is an argument the use case // carries, so nothing about the wiring can leak one tenant into another - expect(result).toBeOkWith({ id: "o-shared", quantity: 2 }); + expect(result).toBeOkWith({ id: "0199a1e0-0000-7000-8000-000000000501", quantity: 2 }); }); }); @@ -91,7 +118,7 @@ describe("FindOrder", () => { // GIVEN an empty repository // WHEN an unknown id is looked up const result = await Module.scoped(testModule, (ctx) => - ctx.get(FindOrder).execute("acme", "missing"), + ctx.get(FindOrder).execute(TenantId("acme"), "missing"), ); // THEN absence is a modeled error, not an empty success diff --git a/examples/order-application/src/ports.ts b/examples/order-application/src/ports.ts index a5c5bb40..07d6ac3c 100644 --- a/examples/order-application/src/ports.ts +++ b/examples/order-application/src/ports.ts @@ -3,12 +3,14 @@ import type { Customer, CustomerNotFound, DuplicateOrder, + InvalidOrderId, InvalidQuantity, Order, OrderNotFound, OutOfStock, PaymentDeclined, ShippingUnavailable, + TenantId, } from "@btravstack/example-order-domain"; import type { AsyncResult } from "unthrown"; @@ -34,9 +36,9 @@ import type { AsyncResult } from "unthrown"; * tombstone). */ export class OrderRepository extends Port("OrderRepository")<{ - readonly save: (tenantId: string, order: Order) => AsyncResult; - readonly find: (tenantId: string, id: string) => AsyncResult; - readonly remove: (tenantId: string, id: string) => AsyncResult; + readonly save: (tenantId: TenantId, order: Order) => AsyncResult; + readonly find: (tenantId: TenantId, id: string) => AsyncResult; + readonly remove: (tenantId: TenantId, id: string) => AsyncResult; }> {} /** @@ -48,7 +50,7 @@ export class OrderRepository extends Port("OrderRepository")<{ * layer to redesign. */ export class CustomerRepository extends Port("CustomerRepository")<{ - readonly find: (tenantId: string, id: string) => AsyncResult; + readonly find: (tenantId: TenantId, id: string) => AsyncResult; }> {} /** @@ -67,7 +69,7 @@ export class CustomerRepository extends Port("CustomerRepository")<{ */ export type OrderEvent = { readonly id: number; - readonly tenantId: string; + readonly tenantId: TenantId; readonly kind: "order"; readonly subjectId: string; readonly occurredAt: Date; @@ -90,7 +92,10 @@ export type OrderEvent = { * names one row. */ export class Outbox extends Port("Outbox")<{ - readonly pending: (tenantId: string, limit: number) => AsyncResult; + readonly pending: ( + tenantId: TenantId, + limit: number, + ) => AsyncResult; readonly markPublished: (ids: readonly number[]) => AsyncResult; }> {} @@ -128,16 +133,16 @@ export class PaymentService extends Port("PaymentService")<{ export class PlaceOrder extends Port("PlaceOrder")<{ readonly execute: ( - tenantId: string, + tenantId: TenantId, id: string, quantity: number, - ) => AsyncResult; + ) => AsyncResult; }> {} export class FindOrder extends Port("FindOrder")<{ - readonly execute: (tenantId: string, id: string) => AsyncResult; + readonly execute: (tenantId: TenantId, id: string) => AsyncResult; }> {} export class FindCustomer extends Port("FindCustomer")<{ - readonly execute: (tenantId: string, id: string) => AsyncResult; + readonly execute: (tenantId: TenantId, id: string) => AsyncResult; }> {} diff --git a/examples/order-application/src/tenant.test-d.ts b/examples/order-application/src/tenant.test-d.ts new file mode 100644 index 00000000..8303b34b --- /dev/null +++ b/examples/order-application/src/tenant.test-d.ts @@ -0,0 +1,34 @@ +/** + * The gate the brand exists for. Every port in this layer names its tenant + * positionally, next to a string that is not one — `find(tenantId, id)`, + * `execute(tenantId, id, quantity)` — and two `string`s in a fixed order are + * precisely what the compiler has nothing to say about: swapping them + * compiles, and the result is a query scoped to somebody else's rows. + * `TenantId` gives one of the pair a nominal type, which is all it takes for + * the pair to become unswappable. Type-checked by this package's `test:types` + * script, never executed. + */ +import type { ServiceOf } from "@btravstack/di"; +import { TenantId } from "@btravstack/example-order-domain"; + +import type { OrderRepository, PlaceOrder } from "./index.js"; + +declare const repository: ServiceOf; +declare const placeOrder: ServiceOf; + +const tenant = TenantId("0199a1e0-0000-7000-8000-0000000000aa"); +const orderId = "0199a1e0-0000-7000-8000-000000000001"; + +// Positive: the tenant this caller was handed, then the id it is asking about. +const _found = repository.find(tenant, orderId); +const _placed = placeOrder.execute(tenant, orderId, 1); + +// Negative: the same two values, the other way round. A `TenantId` still +// passes where a plain `string` is asked for — the brand is only claimed in +// the position that names a tenant — so the id in first position is the one +// error, which is exactly the bug this file exists to catch. +// @ts-expect-error — an order id is not a TenantId +const _swappedFind = repository.find(orderId, tenant); + +// @ts-expect-error — an order id is not a TenantId +const _swappedPlace = placeOrder.execute(orderId, tenant, 1); diff --git a/examples/order-application/src/test-fixtures.ts b/examples/order-application/src/test-fixtures.ts index 07bf9609..269652e4 100644 --- a/examples/order-application/src/test-fixtures.ts +++ b/examples/order-application/src/test-fixtures.ts @@ -6,6 +6,7 @@ import { DuplicateOrder, OrderNotFound, type Order, + type TenantId, } from "@btravstack/example-order-domain"; import { observability, type Line, type Sink } from "@btravstack/observability"; import { ErrAsync, OkAsync } from "unthrown"; @@ -37,19 +38,19 @@ const stubRepository = Provider(OrderRepository)({ // is: a stub that ignored the tenant would let these specs pass against a // repository that leaks between tenants. const rows = new Map(); - const key = (tenantId: string, id: string): string => `${tenantId}/${id}`; + const key = (tenantId: TenantId, id: string): string => `${tenantId}/${id}`; return { - save: (tenantId: string, order: Order) => { + save: (tenantId: TenantId, order: Order) => { if (rows.has(key(tenantId, order.id))) return ErrAsync(new DuplicateOrder({ id: order.id })); rows.set(key(tenantId, order.id), order); return OkAsync(order); }, - find: (tenantId: string, id: string) => { + find: (tenantId: TenantId, id: string) => { const row = rows.get(key(tenantId, id)); return row === undefined ? ErrAsync(new OrderNotFound({ id })) : OkAsync(row); }, - remove: (tenantId: string, id: string) => + remove: (tenantId: TenantId, id: string) => rows.delete(key(tenantId, id)) ? OkAsync() : ErrAsync(new OrderNotFound({ id })), }; }, @@ -58,9 +59,14 @@ const stubRepository = Provider(OrderRepository)({ /** One customer on hand, so the read side has something to answer with. */ const stubCustomerRepository = Provider(CustomerRepository)({ sync: () => { - const rows = new Map([["acme/c-1", Customer.make({ id: "c-1", name: "Ada" }).getOrThrow()]]); + const rows = new Map([ + [ + "acme/0199a1e0-0000-7000-8000-0000000000c1", + Customer.make({ id: "0199a1e0-0000-7000-8000-0000000000c1", name: "Ada" }).getOrThrow(), + ], + ]); return { - find: (tenantId: string, id: string) => { + find: (tenantId: TenantId, id: string) => { const row = rows.get(`${tenantId}/${id}`); return row === undefined ? ErrAsync(new CustomerNotFound({ id })) : OkAsync(row); }, diff --git a/examples/order-application/src/use-cases.ts b/examples/order-application/src/use-cases.ts index 3237443f..d2ec5309 100644 --- a/examples/order-application/src/use-cases.ts +++ b/examples/order-application/src/use-cases.ts @@ -4,9 +4,11 @@ import { type Customer, type CustomerNotFound, type DuplicateOrder, + type InvalidOrderId, type InvalidQuantity, type Order, type OrderNotFound, + type TenantId, } from "@btravstack/example-order-domain"; import { Logger } from "@btravstack/observability"; import type { AsyncResult } from "unthrown"; @@ -35,10 +37,10 @@ class PlaceOrderInteractor { } execute( - tenantId: string, + tenantId: TenantId, id: string, quantity: number, - ): AsyncResult { + ): AsyncResult { this.#logger.info("placing an order", { tenantId, orderId: id, quantity }); return placeOrder(id, quantity) .toAsync() @@ -53,7 +55,7 @@ class FindOrderInteractor { this.#repository = repository; } - execute(tenantId: string, id: string): AsyncResult { + execute(tenantId: TenantId, id: string): AsyncResult { return this.#repository.find(tenantId, id); } } @@ -65,7 +67,7 @@ class FindCustomerInteractor { this.#repository = repository; } - execute(tenantId: string, id: string): AsyncResult { + execute(tenantId: TenantId, id: string): AsyncResult { return this.#repository.find(tenantId, id); } } diff --git a/examples/order-domain/README.md b/examples/order-domain/README.md index 43441d5a..d5d2e2c8 100644 --- a/examples/order-domain/README.md +++ b/examples/order-domain/README.md @@ -35,7 +35,7 @@ layer it is built on, and errors as values. Nothing here can reach a framework. ## The entity ```ts -export const OrderId = z.string().brand("OrderId"); +export const OrderId = z.uuidv7().brand("OrderId"); export const Quantity = z.number().int().brand("Quantity"); export class Order extends Entity("Order")( @@ -78,11 +78,12 @@ Nothing throws: `Order.make` and `update` both return an `unthrown` `Result`. export const placeOrder = ( id: string, quantity: number, -): Result => +): Result => Order.make({ id, quantity }).mapErrCases((matcher) => - matcher.with( - P.tag("InvalidEntity"), - () => new InvalidQuantity({ id, quantity }), + matcher.with(P.tag("InvalidEntity"), (invalid) => + invalid.issues.some((issue) => Entity.keysOf(issue)[0] === "id") + ? new InvalidOrderId({ id }) + : new InvalidQuantity({ id, quantity }), ), ); ``` @@ -90,8 +91,18 @@ export const placeOrder = ( `Order.make` validates, runs the invariants and constructs, reporting a structural failure as `InvalidEntity` with the issues attached. `placeOrder` names that failure in the layer's own vocabulary, which is what the outer layers -already speak. The translation is total: `OrderId` carries no rule of its own, -so for a typed caller the quantity is the only field that can be wrong. +already speak. + +**This used to be one error, and `OrderId` is what changed it.** While the id +was an unconstrained string the translation was total — the quantity was the +only field a typed caller could get wrong — so collapsing `InvalidEntity` to +`InvalidQuantity` was sound on its own terms. Giving `OrderId` a UUIDv7 format +added a second way to fail, and the collapse became a mislabelling: +`placeOrder("o-1", 2)` answered _"asks for 2 items, which is not a positive +quantity"_ about a quantity the caller got right. The two are told apart by +**which field** the entity named, never by the message text: a schema issue +carries a `path`, an `Entity.invariant` violation carries none, and +`Entity.keysOf` reads that path as plain keys. ## The other entity earns its place differently @@ -109,8 +120,33 @@ conversion happens at the controller where it belongs. Branding is not optional either: `@btravstack/entity` takes nominal fields only, so a bare `z.string()` name is a compile error at the field map rather than a convention. -`InvalidQuantity` is the only failure this layer can _raise_ — it is the only -one it can decide. `OrderNotFound` and `DuplicateOrder` are declared here too, +## A tenant is not a string + +```ts +export const TenantIdSchema = z.uuidv7().brand("TenantId"); +export type TenantId = z.infer; +export const TenantId = (raw: string): TenantId => raw as TenantId; +``` + +`src/tenant.ts` is a brand with no entity behind it, and it is here rather than +in the application layer because it is vocabulary the whole system speaks. This +deployment is multi-tenant, so every port names its tenant positionally, next +to an id — `find(tenantId, id)`, `execute(tenantId, id, quantity)` — and two +`string`s in a fixed order are what the compiler has nothing to say about: +swapping them compiled, and read another tenant's rows. Branding **one** of the +pair is enough to make it unswappable, which is why the ids stay `string` here. + +The constructor is a **cast, not a parse**. Every value that becomes a +`TenantId` arrives through a contract that has already validated it as a +UUIDv7 — an oRPC input, an AMQP envelope, a Temporal activity input — or +through deployment configuration; parsing again would spend a validation per +request on a question already answered, and `.parse()` throws, which this +repository bans. A brand costs nothing at run time and does not survive +serialization, which is exactly why the boundary is where it is claimed. + +`InvalidQuantity` and `InvalidOrderId` are the only failures this layer can +_raise_ — they are the only ones it can decide. `OrderNotFound` and +`DuplicateOrder` are declared here too, but raised by whoever owns the storage: the domain names them so that every outer layer speaks about them in the same terms, which is what stops a Prisma error code or an HTTP status from leaking inwards. @@ -135,6 +171,6 @@ fails in both directions, which is what makes it a guard rather than a comment. ## Running it ```bash -pnpm --filter @btravstack/example-order-domain test # 18 specs +pnpm --filter @btravstack/example-order-domain test # 21 specs pnpm --filter @btravstack/example-order-domain test:types # the layering guard ``` diff --git a/examples/order-domain/src/customer.spec.ts b/examples/order-domain/src/customer.spec.ts index f02fd249..5488ff77 100644 --- a/examples/order-domain/src/customer.spec.ts +++ b/examples/order-domain/src/customer.spec.ts @@ -10,7 +10,11 @@ describe("Customer", () => { // THEN it is the entity itself. `constructor` is read through the prototype // chain, so the class is pinned inside the one assertion expect(customer).toEqual( - expect.objectContaining({ constructor: Customer, id: "c-1", name: "Ada" }), + expect.objectContaining({ + constructor: Customer, + id: "0199a1e0-0000-7000-8000-0000000000c1", + name: "Ada", + }), ); }); @@ -20,7 +24,9 @@ describe("Customer", () => { // GIVEN a patch aimed at the immutable id // WHEN it is applied // THEN identity is settled at registration, and the entity says so as a value - expect(customer.update({ id: "c-2" } as never)).toBeErrTagged("InvalidEntity"); + expect(customer.update({ id: "0199a1e0-0000-7000-8000-0000000000c2" } as never)).toBeErrTagged( + "InvalidEntity", + ); }); }); @@ -29,6 +35,8 @@ describe("domain errors", () => { // GIVEN the error, constructed with its payload // WHEN its message is read // THEN the customer it is about is named in it - expect(new CustomerNotFound({ id: "c-9" }).message).toBe("no customer with id c-9"); + expect(new CustomerNotFound({ id: "0199a1e0-0000-7000-8000-0000000000c9" }).message).toBe( + "no customer with id 0199a1e0-0000-7000-8000-0000000000c9", + ); }); }); diff --git a/examples/order-domain/src/customer.ts b/examples/order-domain/src/customer.ts index 4f096347..7f9fc122 100644 --- a/examples/order-domain/src/customer.ts +++ b/examples/order-domain/src/customer.ts @@ -8,7 +8,7 @@ import { z } from "zod"; * name is a compile error at the field map rather than a convention held by * review. */ -export const CustomerId = z.string().brand("CustomerId"); +export const CustomerId = z.uuidv7().brand("CustomerId"); export const CustomerName = z.string().brand("CustomerName"); /** diff --git a/examples/order-domain/src/index.ts b/examples/order-domain/src/index.ts index 75dd67ec..ef4d5036 100644 --- a/examples/order-domain/src/index.ts +++ b/examples/order-domain/src/index.ts @@ -1,7 +1,8 @@ -export { Customer, CustomerNotFound } from "./customer.js"; +export { Customer, CustomerId, CustomerNotFound } from "./customer.js"; export { OutOfStock, PaymentDeclined, ShippingUnavailable } from "./fulfillment.js"; export { DuplicateOrder, + InvalidOrderId, InvalidQuantity, Order, OrderId, @@ -9,3 +10,4 @@ export { Quantity, placeOrder, } from "./order.js"; +export { TenantId } from "./tenant.js"; diff --git a/examples/order-domain/src/order.spec.ts b/examples/order-domain/src/order.spec.ts index d4743e4e..8cd0d9bf 100644 --- a/examples/order-domain/src/order.spec.ts +++ b/examples/order-domain/src/order.spec.ts @@ -3,6 +3,7 @@ import { describe, expect } from "vitest"; import { DuplicateOrder, + InvalidOrderId, InvalidQuantity, Order, OrderNotFound, @@ -18,35 +19,72 @@ describe("placeOrder", () => { // THEN it is the entity itself, carrying what was asked for. `constructor` // is read through the prototype chain, so the class `toBeInstanceOf` used // to check on its own is pinned inside the one assertion. - expect(placed).toEqual(expect.objectContaining({ constructor: Order, id: "o-1", quantity: 2 })); + expect(placed).toEqual( + expect.objectContaining({ + constructor: Order, + id: "0199a1e0-0000-7000-8000-000000000001", + quantity: 2, + }), + ); }); it("reports a zero quantity as a value, never a throw", () => { // GIVEN a quantity the invariant rejects // WHEN it is placed // THEN the failure comes back in the error channel - expect(placeOrder("o-1", 0)).toBeErrTagged("InvalidQuantity", { id: "o-1", quantity: 0 }); + expect(placeOrder("0199a1e0-0000-7000-8000-000000000001", 0)).toBeErrTagged("InvalidQuantity", { + id: "0199a1e0-0000-7000-8000-000000000001", + quantity: 0, + }); }); it("reports a negative quantity as a value, never a throw", () => { // GIVEN a quantity the invariant rejects // WHEN it is placed // THEN the failure comes back in the error channel - expect(placeOrder("o-1", -3)).toBeErrTagged("InvalidQuantity", { id: "o-1", quantity: -3 }); + expect(placeOrder("0199a1e0-0000-7000-8000-000000000001", -3)).toBeErrTagged( + "InvalidQuantity", + { id: "0199a1e0-0000-7000-8000-000000000001", quantity: -3 }, + ); + }); + + it("names a malformed id rather than blaming the quantity", () => { + // GIVEN an id that is not a UUIDv7, and a quantity that is fine + const id = "o-1"; + + // WHEN it is placed + const result = placeOrder(id, 2); + + // THEN the failure names the id, not the field the caller got right + expect(result).toBeErrWith(expect.objectContaining({ constructor: InvalidOrderId, id })); + }); + + it("blames the id when both fields are wrong", () => { + // GIVEN an id that is not a UUIDv7 AND a quantity the rules reject + const id = "o-1"; + + // WHEN it is placed + const result = placeOrder(id, 0); + + // THEN the id wins: it is the failure a caller is least likely to spot + expect(result).toBeErrWith(expect.objectContaining({ constructor: InvalidOrderId, id })); }); it("rejects a quantity that is not a whole number of items", () => { // GIVEN a fractional quantity // WHEN it is placed // THEN it fails the same rule as a non-positive one - expect(placeOrder("o-1", 2.5)).toBeErrTagged("InvalidQuantity", { id: "o-1", quantity: 2.5 }); + expect(placeOrder("0199a1e0-0000-7000-8000-000000000001", 2.5)).toBeErrTagged( + "InvalidQuantity", + { id: "0199a1e0-0000-7000-8000-000000000001", quantity: 2.5 }, + ); }); }); describe("Order", () => { it("carries the failing rule in the entity's own issues", () => { // GIVEN a construction the invariant rejects - const rejected = Order.make({ id: "o-1", quantity: 0 }); + const rejected = Order.make({ id: "0199a1e0-0000-7000-8000-000000000001", quantity: 0 }); // WHEN its error channel is folded const message = rejected.match({ @@ -57,7 +95,9 @@ describe("Order", () => { }); // THEN the rule that failed is named in the entity's own issues - expect(message).toBe("order o-1 asks for 0 items, which is not a positive quantity"); + expect(message).toBe( + "order 0199a1e0-0000-7000-8000-000000000001 asks for 0 items, which is not a positive quantity", + ); }); it("is non-writable at runtime, not merely readonly in the type", ({ placed }) => { @@ -90,7 +130,11 @@ describe("Order", () => { // THEN the change landed on an entity of the same class expect(raised).toBeOkWith( - expect.objectContaining({ constructor: Order, id: "o-1", quantity: 5 }), + expect.objectContaining({ + constructor: Order, + id: "0199a1e0-0000-7000-8000-000000000001", + quantity: 5, + }), ); }); @@ -102,7 +146,10 @@ describe("Order", () => { // THEN the original still reads as it was placed — the `map` keeps the // update's own outcome in the same assertion, so a failed update cannot // pass as an untouched original - expect(raised.map(() => placed.toJSON())).toBeOkWith({ id: "o-1", quantity: 2 }); + expect(raised.map(() => placed.toJSON())).toBeOkWith({ + id: "0199a1e0-0000-7000-8000-000000000001", + quantity: 2, + }); }); it("re-runs the invariant on the patch", ({ placed }) => { @@ -114,7 +161,7 @@ describe("Order", () => { it("refuses to patch the immutable id, even when it is smuggled past the type", ({ placed }) => { // GIVEN a patch aimed at the immutable id - const rejected = placed.update({ id: "o-2" } as never); + const rejected = placed.update({ id: "0199a1e0-0000-7000-8000-000000000002" } as never); // WHEN its error channel is folded const message = rejected.match({ @@ -137,7 +184,7 @@ describe("Order", () => { // beside the values, so a non-enumerable extra could not slip past a // value-only comparison expect({ stored, keys: Reflect.ownKeys(stored) }).toEqual({ - stored: { id: "o-1", quantity: 2 }, + stored: { id: "0199a1e0-0000-7000-8000-000000000001", quantity: 2 }, keys: ["id", "quantity"], }); }); @@ -148,22 +195,35 @@ describe("domain errors", () => { // GIVEN the error, constructed with its payload // WHEN its message is read // THEN the order it is about is named in it - expect(new InvalidQuantity({ id: "o-1", quantity: 0 }).message).toBe( - "order o-1 asks for 0 items, which is not a positive quantity", + expect( + new InvalidQuantity({ id: "0199a1e0-0000-7000-8000-000000000001", quantity: 0 }).message, + ).toBe( + "order 0199a1e0-0000-7000-8000-000000000001 asks for 0 items, which is not a positive quantity", ); }); + it("names the id in an InvalidOrderId message", () => { + // GIVEN the error, constructed with its payload + // WHEN its message is read + // THEN the id it is about is named in it + expect(new InvalidOrderId({ id: "o-1" }).message).toBe("order id o-1 is not a UUIDv7"); + }); + it("names the order in an OrderNotFound message", () => { // GIVEN the error, constructed with its payload // WHEN its message is read // THEN the order it is about is named in it - expect(new OrderNotFound({ id: "o-2" }).message).toBe("no order with id o-2"); + expect(new OrderNotFound({ id: "0199a1e0-0000-7000-8000-000000000002" }).message).toBe( + "no order with id 0199a1e0-0000-7000-8000-000000000002", + ); }); it("names the order in a DuplicateOrder message", () => { // GIVEN the error, constructed with its payload // WHEN its message is read // THEN the order it is about is named in it - expect(new DuplicateOrder({ id: "o-3" }).message).toBe("order o-3 already exists"); + expect(new DuplicateOrder({ id: "0199a1e0-0000-7000-8000-000000000003" }).message).toBe( + "order 0199a1e0-0000-7000-8000-000000000003 already exists", + ); }); }); diff --git a/examples/order-domain/src/order.ts b/examples/order-domain/src/order.ts index fb57f819..0be5d36e 100644 --- a/examples/order-domain/src/order.ts +++ b/examples/order-domain/src/order.ts @@ -7,11 +7,11 @@ import { z } from "zod"; * nominally distinct from the `string` and `number` they are made of — passing * one where the other belongs is a compile error rather than a bug. * - * `OrderId` carries no length rule on purpose. The only caller-reachable way to - * fail `placeOrder` must be the quantity rule, so that `InvalidQuantity` never - * has to stand in for a failure it does not name. + * `OrderId` is a UUIDv7 — the shape every id in this example carries on the + * wire and in the database. That format is what gives `placeOrder` a second + * failure to name; see its TSDoc. */ -export const OrderId = z.string().brand("OrderId"); +export const OrderId = z.uuidv7().brand("OrderId"); export const Quantity = z.number().int().brand("Quantity"); /** @@ -47,6 +47,13 @@ export class InvalidQuantity extends TaggedError("InvalidQuantity")<{ override message = `order ${this.id} asks for ${this.quantity} items, which is not a positive quantity`; } +/** The other rule a caller can break: an id that is not a UUIDv7. */ +export class InvalidOrderId extends TaggedError("InvalidOrderId")<{ + readonly id: string; +}> { + override message = `order id ${this.id} is not a UUIDv7`; +} + export class OrderNotFound extends TaggedError("OrderNotFound")<{ readonly id: string; }> { @@ -62,11 +69,35 @@ export class DuplicateOrder extends TaggedError("DuplicateOrder")<{ /** * Placement, in the layer's own vocabulary. `Order.make` validates, runs the * invariants and constructs — returning `Result` — and - * this names that structural failure `InvalidQuantity`, which is what the outer - * layers already speak. The translation is total: with an unconstrained - * `OrderId`, the quantity is the only field a typed caller can get wrong. + * this names that structural failure in terms the outer layers already speak. + * + * **This used to be one error, and the change is `OrderId`'s doing.** While + * the id was an unconstrained string, the quantity was the only field a typed + * caller could get wrong, so flattening `InvalidEntity` to `InvalidQuantity` + * was total and the earlier decision was sound on its own terms. Giving + * `OrderId` a UUIDv7 format added a second way to fail, and the flattening + * became a mislabelling: `placeOrder("o-1", 2)` answered _"asks for 2 items, + * which is not a positive quantity"_ about a quantity the caller got right. + * So there are two errors now, discriminated on **which field** the entity + * named. + * + * Which field, not which message — a message is prose, not an API. An issue + * from the *schema* carries a `path`; an `Entity.invariant` violation carries + * none, which is how the two kinds tell themselves apart. `Entity.keysOf` + * reads that path as plain keys, because a Standard Schema path element may + * be an object rather than a bare key. Note there are three failure kinds and + * only two errors: a fractional quantity fails the schema *with* a path and a + * non-positive one fails the invariant *without* one, and both are the same + * thing to a caller. */ -export const placeOrder = (id: string, quantity: number): Result => +export const placeOrder = ( + id: string, + quantity: number, +): Result => Order.make({ id, quantity }).mapErrCases((matcher) => - matcher.with(P.tag("InvalidEntity"), () => new InvalidQuantity({ id, quantity })), + matcher.with(P.tag("InvalidEntity"), (invalid) => + invalid.issues.some((issue) => Entity.keysOf(issue)[0] === "id") + ? new InvalidOrderId({ id }) + : new InvalidQuantity({ id, quantity }), + ), ); diff --git a/examples/order-domain/src/tenant.ts b/examples/order-domain/src/tenant.ts new file mode 100644 index 00000000..8cfa199c --- /dev/null +++ b/examples/order-domain/src/tenant.ts @@ -0,0 +1,28 @@ +import { z } from "zod"; + +/** + * Whose data this is. Branded so it cannot be swapped with the id beside it: + * every port in this application names both, positionally, and two `string`s + * in a fixed order are what the compiler has nothing to say about — + * `find(id, tenantId)` compiled, and queried the wrong tenant. Only the tenant + * is branded, because a pair need differ in one position to become + * unswappable; the ids stay `string` where a port names them. + * + * The constructor is a **cast, not a parse**, and the honesty of that rests on + * the boundary — which differs per caller, so it is stated per boundary + * rather than once. An oRPC input, an AMQP envelope and a Temporal activity + * input each arrive through a contract that has already validated the field + * as a UUIDv7, so parsing again would spend a validation per request + * re-answering a question already answered, and `.parse()` throws besides. + * Deployment configuration the operator wrote — `OUTBOX_TENANTS` — is + * trusted rather than validated at all: nothing upstream of it checks the + * shape. The HTTP-marked path is a third case, and neither of the above: its + * stand-in `bearerAuthenticator` checks only that the token's tenant segment + * is non-empty before casting, so this boundary *vouches* for the value + * rather than validating it — a real deployment swaps it for verification + * that does. A brand is a compile-time fiction: nothing is asked of a caller + * at run time, and nothing of it survives serialization. + */ +export const TenantIdSchema = z.uuidv7().brand("TenantId"); +export type TenantId = z.infer; +export const TenantId = (raw: string): TenantId => raw as TenantId; diff --git a/examples/order-domain/src/test-fixtures.ts b/examples/order-domain/src/test-fixtures.ts index 8539a960..14cc8e68 100644 --- a/examples/order-domain/src/test-fixtures.ts +++ b/examples/order-domain/src/test-fixtures.ts @@ -12,11 +12,13 @@ export type DomainFixtures = { export const it = test.extend({ // oxlint-disable-next-line no-empty-pattern -- Vitest fixtures require a destructuring pattern; this one depends on no other fixture placed: async ({}, use) => { - await use(placeOrder("o-1", 2).getOrThrow()); + await use(placeOrder("0199a1e0-0000-7000-8000-000000000001", 2).getOrThrow()); }, // oxlint-disable-next-line no-empty-pattern -- see above customer: async ({}, use) => { - await use(Customer.make({ id: "c-1", name: "Ada" }).getOrThrow()); + await use( + Customer.make({ id: "0199a1e0-0000-7000-8000-0000000000c1", name: "Ada" }).getOrThrow(), + ); }, }); diff --git a/examples/order-infrastructure/README.md b/examples/order-infrastructure/README.md index 4e3c095f..ad70fb58 100644 --- a/examples/order-infrastructure/README.md +++ b/examples/order-infrastructure/README.md @@ -133,15 +133,22 @@ The tenancy is **explicit**: every port names its tenant, so an adapter is handed one rather than finding one. ```ts -readonly find: (tenantId: string, id: string) => AsyncResult; +readonly find: (tenantId: TenantId, id: string) => AsyncResult; ``` +And it is **branded**: `TenantId` is the domain's own string, the id beside it +is not, so the pair cannot be swapped — `find(id, tenantId)` used to compile +and read another tenant's rows. Nothing in this layer casts: the adapters take +inferred parameters and inherit the brand from the port. The one exception is +`prisma-outbox.ts`, where a row becomes an `OrderEvent` — the only read-back in +the system, and so the only place the brand is re-applied. + That is the application's design, not the framework's — no starter has a tenancy concept, and none should, because what establishes a tenant is a decision about a specific system. Two things fall out of it. A caller that forgot its tenant does not compile, where an ambient one would have failed at runtime or read the wrong rows in silence. And a spec needs no machinery: -`repository.find(tenant, "o-1")` says what it is scoped to at the call, so the +`repository.find(tenant, "0199a1e0-0000-7000-8000-000000000001")` says what it is scoped to at the call, so the only fixture is the tenant string itself. The generated client is gitignored and minted by turbo's own `generate` task — @@ -208,5 +215,5 @@ query come back as a defect. ## Running it ```bash -pnpm --filter @btravstack/example-order-infrastructure test # 18 specs +pnpm --filter @btravstack/example-order-infrastructure test # 22 specs ``` diff --git a/examples/order-infrastructure/src/prisma-customer-repository.spec.ts b/examples/order-infrastructure/src/prisma-customer-repository.spec.ts index 08e6b40b..e07c697a 100644 --- a/examples/order-infrastructure/src/prisma-customer-repository.spec.ts +++ b/examples/order-infrastructure/src/prisma-customer-repository.spec.ts @@ -17,14 +17,14 @@ const scopedCustomers = () => describe("the Prisma CustomerRepository", () => { it("reads a stored row back as the domain's entity", async ({ tenant, customers, aCustomer }) => { // GIVEN a customer in this test's own tenant - await aCustomer("c-1", "Ada"); + await aCustomer("0199a1e0-0000-7000-8000-0000000000c1", "Ada"); // WHEN it is read back - const found = await customers.find(tenant, "c-1"); + const found = await customers.find(tenant, "0199a1e0-0000-7000-8000-0000000000c1"); // THEN what leaves the adapter is the entity, not the row and not the wire // shape — the conversion to `CustomerView` happens two layers out - expect(found).toBeOkWith({ id: "c-1", name: "Ada" }); + expect(found).toBeOkWith({ id: "0199a1e0-0000-7000-8000-0000000000c1", name: "Ada" }); }); it("returns the domain's CustomerNotFound for an unknown id", async ({ tenant, customers }) => { diff --git a/examples/order-infrastructure/src/prisma-order-repository.spec.ts b/examples/order-infrastructure/src/prisma-order-repository.spec.ts index 2030f35f..44794d53 100644 --- a/examples/order-infrastructure/src/prisma-order-repository.spec.ts +++ b/examples/order-infrastructure/src/prisma-order-repository.spec.ts @@ -1,6 +1,7 @@ import { Env } from "@btravstack/config"; import { Module, Provider } from "@btravstack/di"; import { OrderRepository } from "@btravstack/example-order-application"; +import { TenantId } from "@btravstack/example-order-domain"; import { fromSafePromise } from "unthrown"; import { describe, expect, inject, vi } from "vitest"; @@ -37,8 +38,10 @@ describe("the Prisma OrderRepository", () => { // GIVEN this test's own tenant // WHEN an order is saved under it // THEN the write answers with the entity itself - await expect(repository.save(tenant, anOrder("o-1", 3))).toBeOkWith({ - id: "o-1", + await expect( + repository.save(tenant, anOrder("0199a1e0-0000-7000-8000-000000000001", 3)), + ).toBeOkWith({ + id: "0199a1e0-0000-7000-8000-000000000001", quantity: 3, }); }); @@ -48,11 +51,11 @@ describe("the Prisma OrderRepository", () => { // WHEN it is read back — chained, so the write's own `Result` is consumed // and a failed write cannot be mistaken for a failed read const roundTripped = await repository - .save(tenant, anOrder("o-1", 3)) - .flatMap(() => repository.find(tenant, "o-1")); + .save(tenant, anOrder("0199a1e0-0000-7000-8000-000000000001", 3)) + .flatMap(() => repository.find(tenant, "0199a1e0-0000-7000-8000-000000000001")); // THEN the round trip is lossless - expect(roundTripped).toBeOkWith({ id: "o-1", quantity: 3 }); + expect(roundTripped).toBeOkWith({ id: "0199a1e0-0000-7000-8000-000000000001", quantity: 3 }); }); it("deletes the one row the unique key names", async ({ tenant, repository, anOrder }) => { @@ -60,13 +63,15 @@ describe("the Prisma OrderRepository", () => { // WHEN it is removed and then looked for — chained, so a failed removal // cannot be mistaken for a successful one const afterRemoval = await repository - .save(tenant, anOrder("o-1", 3)) - .flatMap(() => repository.remove(tenant, "o-1")) - .flatMap(() => repository.find(tenant, "o-1")); + .save(tenant, anOrder("0199a1e0-0000-7000-8000-000000000001", 3)) + .flatMap(() => repository.remove(tenant, "0199a1e0-0000-7000-8000-000000000001")) + .flatMap(() => repository.find(tenant, "0199a1e0-0000-7000-8000-000000000001")); // THEN it is gone: `(tenantId, orderId)` carries the UNIQUE index, so this // is a single-row `delete`, not a batch whose count has to be interpreted - expect(afterRemoval).toBeErrTagged("OrderNotFound", { id: "o-1" }); + expect(afterRemoval).toBeErrTagged("OrderNotFound", { + id: "0199a1e0-0000-7000-8000-000000000001", + }); }); it("answers OrderNotFound when there is nothing to remove", async ({ tenant, repository }) => { @@ -88,15 +93,17 @@ describe("the Prisma OrderRepository", () => { // GIVEN an order already stored in this tenant // WHEN the same id is saved again, in the same tenant const duplicate = await repository - .save(tenant, anOrder("o-1", 1)) - .flatMap(() => repository.save(tenant, anOrder("o-1", 2))); + .save(tenant, anOrder("0199a1e0-0000-7000-8000-000000000001", 1)) + .flatMap(() => repository.save(tenant, anOrder("0199a1e0-0000-7000-8000-000000000001", 2))); // THEN the load-bearing assertion: the UNIQUE index on // `Order(tenantId, orderId)` raises a real P2002, `@unthrown/prisma` hands // it over as `UniqueConstraintViolation`, and what leaves the adapter is // the application's own `DuplicateOrder` — a single `_tag`, so asserting // it is also the assertion that the infrastructure tag did not escape. - expect(duplicate).toBeErrTagged("DuplicateOrder", { id: "o-1" }); + expect(duplicate).toBeErrTagged("DuplicateOrder", { + id: "0199a1e0-0000-7000-8000-000000000001", + }); }); it("returns the domain's OrderNotFound for an unknown id", async ({ tenant, repository }) => { @@ -117,16 +124,16 @@ describe("tenancy", () => { }) => { // GIVEN the same order id placed by two different tenants — which the // composite unique key permits and a single-tenant schema would not - const other = `${tenant}-other`; + const other = TenantId(`${tenant}-other`); const seen = await repository - .save(tenant, anOrder("o-shared", 3)) - .flatMap(() => repository.save(other, anOrder("o-shared", 7))) - .flatMap(() => repository.find(tenant, "o-shared")); + .save(tenant, anOrder("0199a1e0-0000-7000-8000-000000000501", 3)) + .flatMap(() => repository.save(other, anOrder("0199a1e0-0000-7000-8000-000000000501", 7))) + .flatMap(() => repository.find(tenant, "0199a1e0-0000-7000-8000-000000000501")); // WHEN the first tenant reads that id back // THEN the read is scoped to the tenant the CALLER named: the first // tenant's quantity, never the second's, and never a duplicate at the write - expect(seen).toBeOkWith({ id: "o-shared", quantity: 3 }); + expect(seen).toBeOkWith({ id: "0199a1e0-0000-7000-8000-000000000501", quantity: 3 }); }); it("hides another tenant's order entirely, rather than merely reading past it", async ({ @@ -136,12 +143,12 @@ describe("tenancy", () => { }) => { // GIVEN an order that belongs to somebody else const seen = await repository - .save(`${tenant}-other`, anOrder("o-theirs", 3)) - .flatMap(() => repository.find(tenant, "o-theirs")); + .save(TenantId(`${tenant}-other`), anOrder("0199a1e0-0000-7000-8000-000000000502", 3)) + .flatMap(() => repository.find(tenant, "0199a1e0-0000-7000-8000-000000000502")); // WHEN this tenant looks for it // THEN it does not exist as far as this tenant is concerned - expect(seen).toBeErrTagged("OrderNotFound", { id: "o-theirs" }); + expect(seen).toBeErrTagged("OrderNotFound", { id: "0199a1e0-0000-7000-8000-000000000502" }); }); }); @@ -175,12 +182,12 @@ describe("OrderPersistenceModule", () => { const result = await Module.scoped(scopedPersistence(), (ctx) => { const repository = ctx.get(OrderRepository); return repository - .save(tenant, anOrder("o-1", 5)) - .flatMap(() => repository.find(tenant, "o-1")); + .save(tenant, anOrder("0199a1e0-0000-7000-8000-000000000001", 5)) + .flatMap(() => repository.find(tenant, "0199a1e0-0000-7000-8000-000000000001")); }); // THEN the port resolves to a working repository - expect(result).toBeOkWith({ id: "o-1", quantity: 5 }); + expect(result).toBeOkWith({ id: "0199a1e0-0000-7000-8000-000000000001", quantity: 5 }); }); it("ends the connection pool when the scope closes", async ({ db, tenant, anOrder }) => { @@ -200,7 +207,7 @@ describe("OrderPersistenceModule", () => { await Module.scoped(scopedPersistence(applicationName), (ctx) => ctx .get(OrderRepository) - .save(tenant, anOrder("o-1", 1)) + .save(tenant, anOrder("0199a1e0-0000-7000-8000-000000000001", 1)) .flatMap(() => fromSafePromise(backends().then((n) => (duringScope = n)))), ); // Synchronising, not asserting: PostgreSQL retires a backend a moment diff --git a/examples/order-infrastructure/src/prisma-outbox.spec.ts b/examples/order-infrastructure/src/prisma-outbox.spec.ts index 23c997c6..abe5ef65 100644 --- a/examples/order-infrastructure/src/prisma-outbox.spec.ts +++ b/examples/order-infrastructure/src/prisma-outbox.spec.ts @@ -1,3 +1,4 @@ +import { TenantId } from "@btravstack/example-order-domain"; import { P } from "unthrown"; import { describe, expect } from "vitest"; @@ -13,7 +14,7 @@ describe("the transactional outbox", () => { // GIVEN a tenant with nothing in it // WHEN an order is saved const events = await repository - .save(tenant, anOrder("o-1", 3)) + .save(tenant, anOrder("0199a1e0-0000-7000-8000-000000000001", 3)) .flatMap(() => outbox.pending(tenant, 10)); // THEN the fact of the write is already in the outbox — no second call, @@ -23,7 +24,7 @@ describe("the transactional outbox", () => { expect.objectContaining({ tenantId: tenant, kind: "order", - subjectId: "o-1", + subjectId: "0199a1e0-0000-7000-8000-000000000001", payload: { quantity: 3 }, }), ]); @@ -39,15 +40,18 @@ describe("the transactional outbox", () => { // WHEN the same id is saved again — a real UNIQUE violation, and the // transaction it happened in rolls back const events = await repository - .save(tenant, anOrder("o-1", 1)) - .flatMap(() => repository.save(tenant, anOrder("o-1", 2))) + .save(tenant, anOrder("0199a1e0-0000-7000-8000-000000000001", 1)) + .flatMap(() => repository.save(tenant, anOrder("0199a1e0-0000-7000-8000-000000000001", 2))) .recoverErrCases((matcher) => matcher.with(P.tag("DuplicateOrder"), () => undefined)) .flatMap(() => outbox.pending(tenant, 10)); // THEN only the first placement's event exists — the duplicate's outbox // row rolled back with its order row expect(events).toBeOkWith([ - expect.objectContaining({ subjectId: "o-1", payload: { quantity: 1 } }), + expect.objectContaining({ + subjectId: "0199a1e0-0000-7000-8000-000000000001", + payload: { quantity: 1 }, + }), ]); }); @@ -60,8 +64,8 @@ describe("the transactional outbox", () => { // GIVEN two placed orders and their pending events const pending = ( await repository - .save(tenant, anOrder("o-1", 1)) - .flatMap(() => repository.save(tenant, anOrder("o-2", 2))) + .save(tenant, anOrder("0199a1e0-0000-7000-8000-000000000001", 1)) + .flatMap(() => repository.save(tenant, anOrder("0199a1e0-0000-7000-8000-000000000002", 2))) .flatMap(() => outbox.pending(tenant, 10)) ).getOrThrow(); @@ -72,7 +76,9 @@ describe("the transactional outbox", () => { const rest = await outbox.markPublished([first.id]).flatMap(() => outbox.pending(tenant, 10)); // THEN only the second remains pending - expect(rest).toBeOkWith([expect.objectContaining({ subjectId: "o-2" })]); + expect(rest).toBeOkWith([ + expect.objectContaining({ subjectId: "0199a1e0-0000-7000-8000-000000000002" }), + ]); }); it("appends a tombstone when the order is removed", async ({ @@ -84,16 +90,19 @@ describe("the transactional outbox", () => { // GIVEN a placed order // WHEN it is removed const events = await repository - .save(tenant, anOrder("o-1", 3)) - .flatMap(() => repository.remove(tenant, "o-1")) + .save(tenant, anOrder("0199a1e0-0000-7000-8000-000000000001", 3)) + .flatMap(() => repository.remove(tenant, "0199a1e0-0000-7000-8000-000000000001")) .flatMap(() => outbox.pending(tenant, 10)); // THEN the log carries both words about the subject, in order: what it // was, then that it is gone. A null payload IS the deletion — a reader // that keeps its own copy drops it here, and needs no second event type expect(events).toBeOkWith([ - expect.objectContaining({ subjectId: "o-1", payload: { quantity: 3 } }), - expect.objectContaining({ subjectId: "o-1", payload: null }), + expect.objectContaining({ + subjectId: "0199a1e0-0000-7000-8000-000000000001", + payload: { quantity: 3 }, + }), + expect.objectContaining({ subjectId: "0199a1e0-0000-7000-8000-000000000001", payload: null }), ]); }); @@ -124,7 +133,7 @@ describe("the transactional outbox", () => { }) => { // GIVEN a write committed by somebody else const events = await repository - .save(`${tenant}-other`, anOrder("o-theirs", 1)) + .save(TenantId(`${tenant}-other`), anOrder("0199a1e0-0000-7000-8000-000000000502", 1)) .flatMap(() => outbox.pending(tenant, 10)); // WHEN this tenant's relay sweeps diff --git a/examples/order-infrastructure/src/prisma-outbox.ts b/examples/order-infrastructure/src/prisma-outbox.ts index 7ba18346..f46b5d2d 100644 --- a/examples/order-infrastructure/src/prisma-outbox.ts +++ b/examples/order-infrastructure/src/prisma-outbox.ts @@ -1,5 +1,6 @@ import { Provider, type ServiceOf } from "@btravstack/di"; import { Outbox } from "@btravstack/example-order-application"; +import { TenantId } from "@btravstack/example-order-domain"; import { P } from "unthrown"; import { OrderDatabase, type OrderDatabaseClient } from "./database.js"; @@ -34,8 +35,11 @@ export const prismaOutbox = (db: OrderDatabaseClient): ServiceOf => ({ id: row.id, // Echoed back rather than assumed from the query: the relay puts it // on the event it publishes, which is how the tenant crosses the - // broker to a subscriber in another process. - tenantId: row.tenantId, + // broker to a subscriber in another process. The one read-back in + // the system, so the one place the brand is re-applied: the column + // is a `string`, and every value in it was written by a call that + // named a `TenantId`. + tenantId: TenantId(row.tenantId), // The column is a `string`; the port's `kind` is the union of the // kinds this application emits, and `save`/`remove` are the only // writers. A row carrying anything else was not written by this diff --git a/examples/order-infrastructure/src/test-fixtures.ts b/examples/order-infrastructure/src/test-fixtures.ts index cb227e43..15b412ed 100644 --- a/examples/order-infrastructure/src/test-fixtures.ts +++ b/examples/order-infrastructure/src/test-fixtures.ts @@ -1,12 +1,11 @@ -import { randomUUID } from "node:crypto"; - import type { ServiceOf } from "@btravstack/di"; import type { CustomerRepository, Outbox, OrderRepository, } from "@btravstack/example-order-application"; -import { placeOrder, type Order } from "@btravstack/example-order-domain"; +import { TenantId, placeOrder, type Order } from "@btravstack/example-order-domain"; +import { uuidv7 } from "@btravstack/internal-test-infra/uuid"; import { inject, test } from "vitest"; import { @@ -37,7 +36,7 @@ export type PersistenceFixtures = { * set — which is exactly what makes these specs readable: what a call is * scoped to is written at the call. */ - readonly tenant: string; + readonly tenant: TenantId; readonly repository: ServiceOf; readonly customers: ServiceOf; readonly outbox: ServiceOf; @@ -64,7 +63,7 @@ export const it = test.extend({ // oxlint-disable-next-line no-empty-pattern -- see above tenant: async ({}, use) => { - await use(`t-${randomUUID()}`); + await use(TenantId(uuidv7())); }, repository: async ({ db }, use) => { diff --git a/examples/order-temporal-contract/src/contract.spec.ts b/examples/order-temporal-contract/src/contract.spec.ts index 66f3efcd..b6dc2419 100644 --- a/examples/order-temporal-contract/src/contract.spec.ts +++ b/examples/order-temporal-contract/src/contract.spec.ts @@ -9,9 +9,15 @@ describe("orderContract", () => { // WHEN a caller checks the payload it is about to start a workflow with // THEN it is accepted, in the shape Temporal will persist in the history - expect(validate({ tenantId: "acme", orderId: "o-1", quantity: 2 })).toBeOkWith({ - tenantId: "acme", - orderId: "o-1", + expect( + validate({ + tenantId: "0199a1e0-0000-7000-8000-000000009000", + orderId: "0199a1e0-0000-7000-8000-000000000001", + quantity: 2, + }), + ).toBeOkWith({ + tenantId: "0199a1e0-0000-7000-8000-000000009000", + orderId: "0199a1e0-0000-7000-8000-000000000001", quantity: 2, }); }); @@ -22,9 +28,13 @@ describe("orderContract", () => { // WHEN the quantity arrives as a string, the way an untyped caller sends it // THEN the issues come back as a value, naming the field — the contract is // executable, not documentation, and a client can run it - expect(validate({ tenantId: "acme", orderId: "o-1", quantity: "2" })).toBeErrWith([ - expect.objectContaining({ path: ["quantity"] }), - ]); + expect( + validate({ + tenantId: "0199a1e0-0000-7000-8000-000000009000", + orderId: "0199a1e0-0000-7000-8000-000000000001", + quantity: "2", + }), + ).toBeErrWith([expect.objectContaining({ path: ["quantity"] })]); }); it("validates the second workflow's input too — a different vertical, on the same queue", ({ @@ -34,10 +44,32 @@ describe("orderContract", () => { // WHEN a caller checks the payload it is about to start `chargeOrder` with // THEN it is accepted, proving the contract holds more than one workflow - expect(validateCharge({ tenantId: "acme", orderId: "o-1", amount: 42 })).toBeOkWith({ - tenantId: "acme", - orderId: "o-1", + expect( + validateCharge({ + tenantId: "0199a1e0-0000-7000-8000-000000009000", + orderId: "0199a1e0-0000-7000-8000-000000000001", + amount: 42, + }), + ).toBeOkWith({ + tenantId: "0199a1e0-0000-7000-8000-000000009000", + orderId: "0199a1e0-0000-7000-8000-000000000001", amount: 42, }); }); + + it("refuses an id that is not a UUIDv7", ({ validate }) => { + // GIVEN a payload whose order id is a plain string, not the contract's UUIDv7 shape + const input = { + tenantId: "0199a1e0-0000-7000-8000-000000009000", + orderId: "o-1", + quantity: 2, + }; + + // WHEN it is validated against the contract + const result = validate(input); + + // THEN it is refused, naming the order id — proving the schema, not the + // tenant, is what caught it + expect(result).toBeErrWith([expect.objectContaining({ path: ["orderId"] })]); + }); }); diff --git a/examples/order-temporal-contract/src/contract.ts b/examples/order-temporal-contract/src/contract.ts index bb9997f1..17d72722 100644 --- a/examples/order-temporal-contract/src/contract.ts +++ b/examples/order-temporal-contract/src/contract.ts @@ -8,10 +8,18 @@ import { z } from "zod"; * survive serialization. Temporal persists every activity input and output in * an event history, so the transport's shape has to be a real one. */ -const orderView = z.object({ id: z.string(), quantity: z.number() }); +const orderView = z.object({ id: z.uuidv7(), quantity: z.number() }); /** The payload every declared error carries — which order it was about. */ -const orderRef = z.object({ id: z.string() }); +const orderRef = z.object({ id: z.uuidv7() }); + +/** + * What `InvalidOrderId` carries, and the one ref whose `id` is a bare + * `string`. It names the id **as received**, which is precisely the value that + * is not a UUIDv7 — validating it against `z.uuidv7()` would reject the only + * payload this error is ever constructed with. + */ +const malformedRef = z.object({ id: z.string() }); /** * Every input carries the tenant, and that is not a field the domain gained: @@ -26,10 +34,10 @@ const orderRef = z.object({ id: z.string() }); * tenant along with everything else, which is the whole promise of running * this on a durable platform. */ -const tenanted = z.object({ tenantId: z.string() }); +const tenanted = z.object({ tenantId: z.uuidv7() }); -const orderInput = tenanted.extend({ orderId: z.string(), quantity: z.number() }); -const orderTarget = tenanted.extend({ orderId: z.string() }); +const orderInput = tenanted.extend({ orderId: z.uuidv7(), quantity: z.number() }); +const orderTarget = tenanted.extend({ orderId: z.uuidv7() }); /** * The forward steps: three calls into the application layer, one external @@ -47,6 +55,7 @@ const place = defineActivity({ output: orderView, errors: { InvalidQuantity: { data: orderRef, nonRetryable: true }, + InvalidOrderId: { data: malformedRef, nonRetryable: true }, OrderAlreadyPlaced: { data: orderRef, nonRetryable: true }, }, activityOptions: { @@ -125,6 +134,7 @@ const fulfillOrder = defineWorkflow({ idempotency: "allow-duplicate", errors: { InvalidQuantity: { data: orderRef, nonRetryable: true }, + InvalidOrderId: { data: malformedRef, nonRetryable: true }, OrderAlreadyPlaced: { data: orderRef, nonRetryable: true }, OutOfStock: { data: orderRef, nonRetryable: true }, ShippingUnavailable: { data: orderRef, nonRetryable: true }, @@ -132,7 +142,7 @@ const fulfillOrder = defineWorkflow({ activities: { place, reserveStock, arrangeShipping, releaseStock, cancelPlacement }, }); -const amountInput = tenanted.extend({ orderId: z.string(), amount: z.number() }); +const amountInput = tenanted.extend({ orderId: z.uuidv7(), amount: z.number() }); const authorizationTarget = tenanted.extend({ authorizationId: z.string() }); const authorizePayment = defineActivity({ diff --git a/examples/order-temporal-worker/src/slices/fulfillment/activities.ts b/examples/order-temporal-worker/src/slices/fulfillment/activities.ts index 870c2001..197f12c3 100644 --- a/examples/order-temporal-worker/src/slices/fulfillment/activities.ts +++ b/examples/order-temporal-worker/src/slices/fulfillment/activities.ts @@ -4,6 +4,7 @@ import { ShippingService, StockService, } from "@btravstack/example-order-application"; +import { TenantId } from "@btravstack/example-order-domain"; import { orderContract } from "@btravstack/example-order-temporal-contract"; import { TemporalWorkflowActivities } from "@btravstack/temporal"; import { P } from "unthrown"; @@ -52,6 +53,10 @@ import { P } from "unthrown"; * input because the CONTRACT declares it — `@btravstack/temporal` knows * nothing about tenants, and an input is what Temporal persists in the event * history, so a replay reconstructs the tenant along with everything else. + * `TenantId(args.tenantId)` claims the brand at each activity that needs one — + * an activity is its own entry point, so `place` and `cancelPlacement` are two + * boundaries rather than one crossed twice — and the contract validated the + * field as a UUIDv7 before either was entered. * * The compensations: `releaseStock`'s port already promises `never`; nothing * to triage. `cancelPlacement` absorbs `OrderNotFound` on purpose — undoing a @@ -69,11 +74,12 @@ export const fulfillOrder = TemporalWorkflowActivities(orderContract, "fulfillOr sync: ({ place, repository, stock, shipping }) => ({ place: (args, { errors }) => place - .execute(args.tenantId, args.orderId, args.quantity) + .execute(TenantId(args.tenantId), args.orderId, args.quantity) .map((order) => ({ id: order.id, quantity: order.quantity })) .mapErrCases((matcher) => matcher .with(P.tag("InvalidQuantity"), (error) => errors.InvalidQuantity({ id: error.id })) + .with(P.tag("InvalidOrderId"), (error) => errors.InvalidOrderId({ id: error.id })) .with(P.tag("DuplicateOrder"), (error) => errors.OrderAlreadyPlaced({ id: error.id }), ), @@ -95,7 +101,7 @@ export const fulfillOrder = TemporalWorkflowActivities(orderContract, "fulfillOr releaseStock: (args) => stock.release(args.orderId), cancelPlacement: (args) => repository - .remove(args.tenantId, args.orderId) + .remove(TenantId(args.tenantId), args.orderId) .recoverErrCases((matcher) => matcher.with(P.tag("OrderNotFound"), () => undefined)), }), }, diff --git a/examples/order-temporal-worker/src/temporal-runtime.spec.ts b/examples/order-temporal-worker/src/temporal-runtime.spec.ts index 1eb1fed1..df31d6e9 100644 --- a/examples/order-temporal-worker/src/temporal-runtime.spec.ts +++ b/examples/order-temporal-worker/src/temporal-runtime.spec.ts @@ -18,9 +18,9 @@ describe("the fulfillment saga", () => { await expect( client.executeWorkflow("fulfillOrder", { workflowId: "wf-fulfill-1", - args: { tenantId: tenant, orderId: "o-1", quantity: 2 }, + args: { tenantId: tenant, orderId: "0199a1e0-0000-7000-8000-000000000001", quantity: 2 }, }), - ).toBeOkWith({ id: "o-1", quantity: 2 }); + ).toBeOkWith({ id: "0199a1e0-0000-7000-8000-000000000001", quantity: 2 }); // AND the journey ran in the declared order, each step a log line whose // order id is a field rather than a word — the trace id every line also @@ -28,14 +28,21 @@ describe("the fulfillment saga", () => { expect( fulfilling.lines().map((line) => ({ message: line.message, ...line.attributes })), ).toEqual([ - { message: "placing an order", tenantId: tenant, orderId: "o-1", quantity: 2 }, - { message: "reserved stock", orderId: "o-1", quantity: 2 }, - { message: "arranged shipping", orderId: "o-1" }, + { + message: "placing an order", + tenantId: tenant, + orderId: "0199a1e0-0000-7000-8000-000000000001", + quantity: 2, + }, + { message: "reserved stock", orderId: "0199a1e0-0000-7000-8000-000000000001", quantity: 2 }, + { message: "arranged shipping", orderId: "0199a1e0-0000-7000-8000-000000000001" }, ]); // AND the placement is durably there - await expect(fulfilling.services().repository.find(tenant, "o-1")).toBeOkWith( - expect.objectContaining({ id: "o-1", quantity: 2 }), + await expect( + fulfilling.services().repository.find(tenant, "0199a1e0-0000-7000-8000-000000000001"), + ).toBeOkWith( + expect.objectContaining({ id: "0199a1e0-0000-7000-8000-000000000001", quantity: 2 }), ); }); @@ -51,7 +58,7 @@ describe("the fulfillment saga", () => { const outcome = await client .executeWorkflow("fulfillOrder", { workflowId: "wf-oos-1", - args: { tenantId: tenant, orderId: "o-2", quantity: 5 }, + args: { tenantId: tenant, orderId: "0199a1e0-0000-7000-8000-000000000002", quantity: 5 }, }) .match({ ok: () => "WRONGLY FULFILLED", @@ -61,22 +68,22 @@ describe("the fulfillment saga", () => { matcher .with({ errorName: "OutOfStock" }, (error) => `out-of-stock:${error.data.id}`) .with({ errorName: "InvalidQuantity" }, () => "WRONG ERROR") + .with({ errorName: "InvalidOrderId" }, () => "WRONG ERROR") .with({ errorName: "OrderAlreadyPlaced" }, () => "WRONG ERROR") .with({ errorName: "ShippingUnavailable" }, () => "WRONG ERROR") .with(...tagPatterns(WORKFLOW_START_ERROR_TAGS), (error) => `start:${error._tag}`) .with(...tagPatterns(WORKFLOW_RESULT_ERROR_TAGS), (error) => `result:${error._tag}`), defect: () => "DEFECT", }); - expect(outcome).toBe("out-of-stock:o-2"); + expect(outcome).toBe("out-of-stock:0199a1e0-0000-7000-8000-000000000002"); // AND the placement the saga made before the refusal is gone — the // compensation ran, and the database agrees with the answer - await expect(outOfStock.services().repository.find(tenant, "o-2")).toBeErrTagged( - "OrderNotFound", - { - id: "o-2", - }, - ); + await expect( + outOfStock.services().repository.find(tenant, "0199a1e0-0000-7000-8000-000000000002"), + ).toBeErrTagged("OrderNotFound", { + id: "0199a1e0-0000-7000-8000-000000000002", + }); }); it("compensates a shipping refusal in reverse order: release, then cancel", async ({ @@ -92,7 +99,7 @@ describe("the fulfillment saga", () => { const outcome = await client .executeWorkflow("fulfillOrder", { workflowId: "wf-ship-1", - args: { tenantId: tenant, orderId: "o-3", quantity: 1 }, + args: { tenantId: tenant, orderId: "0199a1e0-0000-7000-8000-000000000003", quantity: 1 }, }) .match({ ok: () => "WRONGLY FULFILLED", @@ -102,25 +109,25 @@ describe("the fulfillment saga", () => { matcher .with({ errorName: "ShippingUnavailable" }, (error) => `no-shipping:${error.data.id}`) .with({ errorName: "InvalidQuantity" }, () => "WRONG ERROR") + .with({ errorName: "InvalidOrderId" }, () => "WRONG ERROR") .with({ errorName: "OrderAlreadyPlaced" }, () => "WRONG ERROR") .with({ errorName: "OutOfStock" }, () => "WRONG ERROR") .with(...tagPatterns(WORKFLOW_START_ERROR_TAGS), (error) => `start:${error._tag}`) .with(...tagPatterns(WORKFLOW_RESULT_ERROR_TAGS), (error) => `result:${error._tag}`), defect: () => "DEFECT", }); - expect(outcome).toBe("no-shipping:o-3"); + expect(outcome).toBe("no-shipping:0199a1e0-0000-7000-8000-000000000003"); // THEN the reservation was released — the walk-back reached the earlier // step, not just the placement - expect(noShipping.released()).toEqual(["o-3"]); + expect(noShipping.released()).toEqual(["0199a1e0-0000-7000-8000-000000000003"]); // AND the placement is gone too - await expect(noShipping.services().repository.find(tenant, "o-3")).toBeErrTagged( - "OrderNotFound", - { - id: "o-3", - }, - ); + await expect( + noShipping.services().repository.find(tenant, "0199a1e0-0000-7000-8000-000000000003"), + ).toBeErrTagged("OrderNotFound", { + id: "0199a1e0-0000-7000-8000-000000000003", + }); }); it("hands the client the OrderAlreadyPlaced the API answers CONFLICT for, as a typed contract error", async ({ @@ -137,12 +144,12 @@ describe("the fulfillment saga", () => { const outcome = await client .executeWorkflow("fulfillOrder", { workflowId: "wf-dup-1", - args: { tenantId: tenant, orderId: "o-4", quantity: 2 }, + args: { tenantId: tenant, orderId: "0199a1e0-0000-7000-8000-000000000004", quantity: 2 }, }) .flatMap(() => client.executeWorkflow("fulfillOrder", { workflowId: "wf-dup-2", - args: { tenantId: tenant, orderId: "o-4", quantity: 2 }, + args: { tenantId: tenant, orderId: "0199a1e0-0000-7000-8000-000000000004", quantity: 2 }, }), ) .match({ @@ -154,6 +161,7 @@ describe("the fulfillment saga", () => { matcher .with({ errorName: "OrderAlreadyPlaced" }, (error) => `conflict:${error.data.id}`) .with({ errorName: "InvalidQuantity" }, () => "WRONG ERROR") + .with({ errorName: "InvalidOrderId" }, () => "WRONG ERROR") .with({ errorName: "OutOfStock" }, () => "WRONG ERROR") .with({ errorName: "ShippingUnavailable" }, () => "WRONG ERROR") .with(...tagPatterns(WORKFLOW_START_ERROR_TAGS), (error) => `start:${error._tag}`) @@ -161,7 +169,7 @@ describe("the fulfillment saga", () => { defect: () => "DEFECT", }); - expect(outcome).toBe("conflict:o-4"); + expect(outcome).toBe("conflict:0199a1e0-0000-7000-8000-000000000004"); }); }); @@ -174,10 +182,12 @@ describe("the billing saga", () => { // WHEN the workflow the SECOND slice owns is executed const charged = client.executeWorkflow("chargeOrder", { workflowId: "wf-charge-1", - args: { tenantId: tenant, orderId: "order-1", amount: 42 }, + args: { tenantId: tenant, orderId: "0199a1e0-0000-7000-8000-00000000a001", amount: 42 }, }); // THEN its own slice answered, so every piece was mounted under its key - await expect(charged).toBeOkWith({ authorizationId: "auth-order-1" }); + await expect(charged).toBeOkWith({ + authorizationId: "auth-0199a1e0-0000-7000-8000-00000000a001", + }); }); }); diff --git a/examples/order-temporal-worker/src/test-fixtures.ts b/examples/order-temporal-worker/src/test-fixtures.ts index cc61c5c6..57941552 100644 --- a/examples/order-temporal-worker/src/test-fixtures.ts +++ b/examples/order-temporal-worker/src/test-fixtures.ts @@ -1,5 +1,3 @@ -import { randomUUID } from "node:crypto"; - import type { ConfigInvalid, Env } from "@btravstack/config"; import type { RunningApp } from "@btravstack/core"; import { Module, Provider, type Scope, type ServiceOf } from "@btravstack/di"; @@ -10,10 +8,11 @@ import { ShippingService, StockService, } from "@btravstack/example-order-application"; -import { OutOfStock, ShippingUnavailable } from "@btravstack/example-order-domain"; +import { OutOfStock, ShippingUnavailable, TenantId } from "@btravstack/example-order-domain"; import { OrderPersistenceModule } from "@btravstack/example-order-infrastructure"; import { orderContract, type OrderContract } from "@btravstack/example-order-temporal-contract"; import { createNamespace } from "@btravstack/internal-test-infra/namespace"; +import { uuidv7 } from "@btravstack/internal-test-infra/uuid"; import { Logger, observability, type Line, type Sink } from "@btravstack/observability"; import { TemporalModule, type TemporalInfo, type TemporalUnreachable } from "@btravstack/temporal"; import { bootFixture, tapped, type Boot } from "@btravstack/testing"; @@ -173,11 +172,11 @@ export type TemporalFixtures = { /** * This test's tenant, and nobody else's. The database is shared by every * workspace's run — one migration for the whole gate rather than one per - * test — so a UUID here is what keeps one test's `o-1` from being another's. + * test — so a UUID here is what keeps one test's `0199a1e0-0000-7000-8000-000000000001` from being another's. * It rides every workflow's arguments — the contract declares it — which is * how it reaches the adapters. */ - readonly tenant: string; + readonly tenant: TenantId; /** `@btravstack/testing`'s boot: every app it starts is stopped when the test ends. */ readonly boot: Boot; /** @@ -206,7 +205,7 @@ export const it = test.extend({ // oxlint-disable-next-line no-empty-pattern -- Vitest fixtures require a destructuring pattern; this one depends on no other fixture tenant: async ({}, use) => { - await use(`t-${randomUUID()}`); + await use(TenantId(uuidv7())); }, serve: async ({ server, boot }, use) => { diff --git a/examples/order-temporal-worker/src/workflows.ts b/examples/order-temporal-worker/src/workflows.ts index 95fff21a..49bbd8e1 100644 --- a/examples/order-temporal-worker/src/workflows.ts +++ b/examples/order-temporal-worker/src/workflows.ts @@ -64,6 +64,9 @@ export const fulfillOrder = declareWorkflow({ .with({ errorName: "InvalidQuantity" }, (error) => context.errors.InvalidQuantity({ id: error.data.id }), ) + .with({ errorName: "InvalidOrderId" }, (error) => + context.errors.InvalidOrderId({ id: error.data.id }), + ) .with({ errorName: "OrderAlreadyPlaced" }, (error) => context.errors.OrderAlreadyPlaced({ id: error.data.id }), ) diff --git a/internal/test-infra/README.md b/internal/test-infra/README.md index dab000d2..902e7377 100644 --- a/internal/test-infra/README.md +++ b/internal/test-infra/README.md @@ -69,13 +69,14 @@ break. ## Entry points -| Import | What it is | -| -------------------------------------------- | ----------------------------------------------------------------------- | -| `@btravstack/internal-test-infra/rabbitmq` | a vitest `globalSetup` providing `@amqp-contract/testing`'s inject keys | -| `@btravstack/internal-test-infra/temporal` | a vitest `globalSetup` providing `@temporal-contract/testing`'s | -| `@btravstack/internal-test-infra/containers` | `sharedPostgres` / `sharedRabbitMq` / `sharedTemporal`, `postgresUrl` | -| `@btravstack/internal-test-infra/namespace` | `createNamespace(address, prefix)` | -| `@btravstack/internal-test-infra/lock` | `withLock(name, run)` | +| Import | What it is | +| -------------------------------------------- | ---------------------------------------------------------------------------------- | +| `@btravstack/internal-test-infra/rabbitmq` | a vitest `globalSetup` providing `@amqp-contract/testing`'s inject keys | +| `@btravstack/internal-test-infra/temporal` | a vitest `globalSetup` providing `@temporal-contract/testing`'s | +| `@btravstack/internal-test-infra/containers` | `sharedPostgres` / `sharedRabbitMq` / `sharedTemporal`, `postgresUrl` | +| `@btravstack/internal-test-infra/namespace` | `createNamespace(address, prefix)` | +| `@btravstack/internal-test-infra/lock` | `withLock(name, run)` | +| `@btravstack/internal-test-infra/uuid` | `uuidv7()`, a real UUIDv7 for the tenant fixtures — `crypto.randomUUID()` mints v4 | The two setup modules are drop-in replacements for `@amqp-contract/testing/global-setup` and diff --git a/internal/test-infra/package.json b/internal/test-infra/package.json index 9d68bf94..497ad3ac 100644 --- a/internal/test-infra/package.json +++ b/internal/test-infra/package.json @@ -10,9 +10,11 @@ "./lock": "./src/lock.ts", "./namespace": "./src/namespace.ts", "./rabbitmq": "./src/rabbitmq.ts", - "./temporal": "./src/temporal.ts" + "./temporal": "./src/temporal.ts", + "./uuid": "./src/uuid.ts" }, "scripts": { + "test": "vitest run", "typecheck": "tsc --noEmit" }, "dependencies": { @@ -23,7 +25,9 @@ "devDependencies": { "@btravstack/tsconfig": "catalog:", "@types/node": "catalog:", + "@unthrown/vitest": "catalog:", "typescript": "catalog:", - "vitest": "catalog:" + "vitest": "catalog:", + "zod": "catalog:" } } diff --git a/internal/test-infra/src/uuid.spec.ts b/internal/test-infra/src/uuid.spec.ts new file mode 100644 index 00000000..20ad2ac0 --- /dev/null +++ b/internal/test-infra/src/uuid.spec.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; +import { z } from "zod"; + +import { uuidv7 } from "./uuid.js"; + +describe("uuidv7", () => { + it("mints valid, distinct v7 ids", () => { + // GIVEN a schema that accepts only UUIDv7 + const schema = z.uuidv7(); + + // WHEN a thousand are minted + const minted = Array.from({ length: 1000 }, uuidv7); + + // THEN every one parses, and no two collide + expect({ + allValid: minted.every((id) => schema.safeParse(id).success), + distinct: new Set(minted).size, + }).toEqual({ allValid: true, distinct: 1000 }); + }); +}); diff --git a/internal/test-infra/src/uuid.ts b/internal/test-infra/src/uuid.ts new file mode 100644 index 00000000..e946a93b --- /dev/null +++ b/internal/test-infra/src/uuid.ts @@ -0,0 +1,19 @@ +/** + * A UUIDv7, which `crypto.randomUUID()` is not — it mints v4, and + * `z.uuidv7()` rejects it. Test-only: nothing in an example generates an id, + * they all arrive from a caller. + * + * Layout is RFC 9562: 48 bits of Unix milliseconds, the version nibble `7`, + * the variant bits `10`, random elsewhere. + */ +export const uuidv7 = (): string => { + const bytes = crypto.getRandomValues(new Uint8Array(16)); + const millis = BigInt(Date.now()); + for (let index = 0; index < 6; index++) { + bytes[index] = Number((millis >> BigInt(8 * (5 - index))) & 0xffn); + } + bytes[6] = (bytes[6]! & 0x0f) | 0x70; + bytes[8] = (bytes[8]! & 0x3f) | 0x80; + const hex = [...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join(""); + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; +}; diff --git a/internal/test-infra/vitest.config.ts b/internal/test-infra/vitest.config.ts new file mode 100644 index 00000000..9e7a2c74 --- /dev/null +++ b/internal/test-infra/vitest.config.ts @@ -0,0 +1 @@ +export { default } from "../../vitest.shared.js"; diff --git a/packages/amqp/README.md b/packages/amqp/README.md index 5a293496..721731c5 100644 --- a/packages/amqp/README.md +++ b/packages/amqp/README.md @@ -41,6 +41,7 @@ const orderHandlers = AmqpHandlers(orderContract)( .mapErrCases((matcher) => matcher.with( P.tag("InvalidQuantity"), + P.tag("InvalidOrderId"), P.tag("DuplicateOrder"), (error) => new NonRetryableError(error._tag, error), ), diff --git a/packages/http/README.md b/packages/http/README.md index 9f0269f4..744e58e3 100644 --- a/packages/http/README.md +++ b/packages/http/README.md @@ -47,6 +47,14 @@ 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, @@ -110,6 +118,14 @@ const ordersController = HttpController("OrdersController", 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, diff --git a/packages/observability/README.md b/packages/observability/README.md index d1fa983c..524b55a5 100644 --- a/packages/observability/README.md +++ b/packages/observability/README.md @@ -66,7 +66,7 @@ argument threaded through the call stack. ```json { - "orderId": "o-1", + "orderId": "0199a1e0-0000-7000-8000-000000000001", "time": "2026-08-16T09:41:02.113Z", "level": "info", "message": "order placed", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 637d30d7..5321c1b8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -327,6 +327,9 @@ importers: '@btravstack/example-order-application': specifier: workspace:* version: link:../order-application + '@btravstack/example-order-domain': + specifier: workspace:* + version: link:../order-domain '@btravstack/example-order-infrastructure': specifier: workspace:* version: link:../order-infrastructure @@ -407,6 +410,9 @@ importers: specifier: 'catalog:' version: 5.5.0 devDependencies: + '@btravstack/internal-test-infra': + specifier: workspace:* + version: link:../../internal/test-infra '@btravstack/testing': specifier: workspace:* version: link:../../packages/testing @@ -702,12 +708,18 @@ importers: '@types/node': specifier: 'catalog:' version: 26.2.0 + '@unthrown/vitest': + specifier: 'catalog:' + version: 5.5.0(unthrown@5.5.0)(vitest@4.1.10) typescript: specifier: 'catalog:' version: 7.0.2 vitest: specifier: 'catalog:' version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.2.0)(@vitest/coverage-v8@4.1.10)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.12)(yaml@2.9.0) + zod: + specifier: 'catalog:' + version: 4.4.3 packages/amqp: devDependencies: