From c0c63dcceaa174bb2d6f924dd5fcd61f9c80c2ec Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Fri, 21 Aug 2026 14:10:16 +0200 Subject: [PATCH 01/11] test: a real UUIDv7 for the tenant fixtures --- .../order-amqp-worker/src/test-fixtures.ts | 5 ++--- examples/order-api/package.json | 1 + examples/order-api/src/test-fixtures.ts | 4 ++-- .../order-infrastructure/src/test-fixtures.ts | 5 ++--- .../src/test-fixtures.ts | 5 ++--- internal/test-infra/README.md | 15 +++++++------- internal/test-infra/package.json | 8 ++++++-- internal/test-infra/src/uuid.spec.ts | 20 +++++++++++++++++++ internal/test-infra/src/uuid.ts | 19 ++++++++++++++++++ internal/test-infra/vitest.config.ts | 1 + pnpm-lock.yaml | 9 +++++++++ 11 files changed, 72 insertions(+), 20 deletions(-) create mode 100644 internal/test-infra/src/uuid.spec.ts create mode 100644 internal/test-infra/src/uuid.ts create mode 100644 internal/test-infra/vitest.config.ts diff --git a/examples/order-amqp-worker/src/test-fixtures.ts b/examples/order-amqp-worker/src/test-fixtures.ts index d5476120..ede54e43 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"; @@ -14,6 +12,7 @@ import { PlaceOrder, } from "@btravstack/example-order-application"; 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"; @@ -116,7 +115,7 @@ export const it: TestAPI = amqpIt.extend { - await use(`t-${randomUUID()}`); + await use(uuidv7()); }, serve: async ({ amqpConnectionUrl, tenant, boot }, use) => { 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/test-fixtures.ts b/examples/order-api/src/test-fixtures.ts index fff0dbd3..d441470c 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"; @@ -18,6 +17,7 @@ import { type Order, } 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"; @@ -272,7 +272,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-infrastructure/src/test-fixtures.ts b/examples/order-infrastructure/src/test-fixtures.ts index cb227e43..3e897f0c 100644 --- a/examples/order-infrastructure/src/test-fixtures.ts +++ b/examples/order-infrastructure/src/test-fixtures.ts @@ -1,5 +1,3 @@ -import { randomUUID } from "node:crypto"; - import type { ServiceOf } from "@btravstack/di"; import type { CustomerRepository, @@ -7,6 +5,7 @@ import type { OrderRepository, } from "@btravstack/example-order-application"; import { placeOrder, type Order } from "@btravstack/example-order-domain"; +import { uuidv7 } from "@btravstack/internal-test-infra/uuid"; import { inject, test } from "vitest"; import { @@ -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(uuidv7()); }, repository: async ({ db }, use) => { diff --git a/examples/order-temporal-worker/src/test-fixtures.ts b/examples/order-temporal-worker/src/test-fixtures.ts index cc61c5c6..45d70288 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"; @@ -14,6 +12,7 @@ import { OutOfStock, ShippingUnavailable } from "@btravstack/example-order-domai 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"; @@ -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(uuidv7()); }, serve: async ({ server, boot }, use) => { 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/pnpm-lock.yaml b/pnpm-lock.yaml index 637d30d7..c76527c7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -407,6 +407,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 +705,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: From edb326fd3be4086142efe8f4bac73c3d09f60fe3 Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Fri, 21 Aug 2026 15:23:01 +0200 Subject: [PATCH 02/11] test: every id literal is a UUIDv7 Ids are still z.string() at this point, so this is a pure literal sweep: every short order/customer id ("o-1", "c-1", "order-1", ...) across the examples and docs becomes its mapped UUIDv7, per .superpowers/sdd/2026-08-21-uuidv7-ids-and-branded-tenant/uuid-map.md. Non-literal occurrences (error-message assertions, derived strings like `auth-${orderId}`, doc sample output) moved with their inputs so no test or sample went false. The tightening to a UUID format is Task 3's job. --- docs/examples/hexagonal-order-api.md | 7 +- docs/examples/order-amqp-worker.md | 2 +- docs/examples/order-api.md | 2 +- docs/examples/order-application.md | 2 +- docs/examples/order-temporal-worker.md | 6 +- docs/how-to/log-and-correlate.md | 4 +- docs/how-to/read-the-ambient-unit.md | 4 +- docs/how-to/swap-an-adapter.md | 11 ++- docs/how-to/test-an-application.md | 35 +++++-- docs/reference/observability.md | 2 +- .../hexagonal-order-api/src/index.spec.ts | 4 +- examples/hexagonal-order-api/src/index.ts | 4 +- .../order-amqp-contract/src/contract.spec.ts | 6 +- .../src/amqp-runtime.spec.ts | 56 +++++++---- .../order-api-contract/src/client.spec.ts | 10 +- examples/order-api/src/api.spec.ts | 93 ++++++++++++------- examples/order-api/src/test-fixtures.ts | 6 +- .../src/find-customer.spec.ts | 4 +- .../src/needs-gate.test-d.ts | 16 ++-- .../order-application/src/place-order.spec.ts | 27 ++++-- .../order-application/src/test-fixtures.ts | 7 +- examples/order-domain/src/customer.spec.ts | 14 ++- examples/order-domain/src/order.spec.ts | 58 +++++++++--- examples/order-domain/src/test-fixtures.ts | 6 +- .../src/prisma-customer-repository.spec.ts | 6 +- .../src/prisma-order-repository.spec.ts | 38 ++++---- .../src/prisma-outbox.spec.ts | 32 ++++--- .../src/contract.spec.ts | 26 ++++-- .../src/temporal-runtime.spec.ts | 65 +++++++------ .../src/test-fixtures.ts | 2 +- 30 files changed, 362 insertions(+), 193 deletions(-) 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/order-amqp-worker.md b/docs/examples/order-amqp-worker.md index f59d8425..90754cd8 100644 --- a/docs/examples/order-amqp-worker.md +++ b/docs/examples/order-amqp-worker.md @@ -290,7 +290,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..2c5ff58c 100644 --- a/docs/examples/order-api.md +++ b/docs/examples/order-api.md @@ -480,7 +480,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..112bc014 100644 --- a/docs/examples/order-application.md +++ b/docs/examples/order-application.md @@ -288,7 +288,7 @@ 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("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/how-to/log-and-correlate.md b/docs/how-to/log-and-correlate.md index 1eaa7ec8..fda90825 100644 --- a/docs/how-to/log-and-correlate.md +++ b/docs/how-to/log-and-correlate.md @@ -77,7 +77,7 @@ export const placeOrderProvider = Provider(PlaceOrder)( **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 +111,7 @@ call, so one application-scope logger is correct for every request: ```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/how-to/read-the-ambient-unit.md b/docs/how-to/read-the-ambient-unit.md index a675c130..56b0ed5a 100644 --- a/docs/how-to/read-the-ambient-unit.md +++ b/docs/how-to/read-the-ambient-unit.md @@ -85,7 +85,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 +132,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", 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..ec02f195 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, }); }); @@ -105,8 +110,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("0199a1e0-0000-7000-8000-000000000001", 2), + ).toBeOkWith( + expect.objectContaining({ id: "0199a1e0-0000-7000-8000-000000000001" }), ); }); ``` @@ -151,8 +158,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(() => ({ @@ -361,11 +373,16 @@ 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, + }); }); ``` 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/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..f3ceb879 100644 --- a/examples/order-amqp-contract/src/contract.spec.ts +++ b/examples/order-amqp-contract/src/contract.spec.ts @@ -36,7 +36,7 @@ describe("orderContract", () => { const event = { tenantId: "acme", kind: "order", - id: "o-1", + id: "0199a1e0-0000-7000-8000-000000000001", occurredAt: "2026-08-13T22:00:00.000Z", payload: { quantity: 2 }, }; @@ -53,7 +53,7 @@ describe("orderContract", () => { const tombstone = { tenantId: "acme", kind: "order", - id: "o-1", + id: "0199a1e0-0000-7000-8000-000000000001", occurredAt: "2026-08-13T22:00:00.000Z", payload: null, }; @@ -74,7 +74,7 @@ describe("orderContract", () => { validate({ tenantId: "acme", kind: "order", - id: "o-1", + id: "0199a1e0-0000-7000-8000-000000000001", occurredAt: "2026-08-13T22:00:00.000Z", payload: { quantity: "two" }, }), 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-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/src/api.spec.ts b/examples/order-api/src/api.spec.ts index da62cc1e..b08ac2ad 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,7 +117,10 @@ 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 @@ -136,7 +146,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 +175,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 +196,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 +220,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 +232,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 +243,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 +269,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 +294,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 +337,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 +363,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 @@ -386,8 +406,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 +429,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 +439,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 +458,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 +476,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 +488,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 +506,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/test-fixtures.ts b/examples/order-api/src/test-fixtures.ts index d441470c..51064856 100644 --- a/examples/order-api/src/test-fixtures.ts +++ b/examples/order-api/src/test-fixtures.ts @@ -47,7 +47,7 @@ const persistenceOf = (repository: ServiceOf) => Provider(CustomerRepository)({ value: { find: (_tenantId: string, id: string) => - id === "c-1" + id === "0199a1e0-0000-7000-8000-0000000000c1" ? OkAsync(Customer.make({ id, name: "Ada" }).getOrThrow()) : ErrAsync(new CustomerNotFound({ id })), }, @@ -129,7 +129,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 +199,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; diff --git a/examples/order-application/src/find-customer.spec.ts b/examples/order-application/src/find-customer.spec.ts index f2cd0754..0ec7f898 100644 --- a/examples/order-application/src/find-customer.spec.ts +++ b/examples/order-application/src/find-customer.spec.ts @@ -9,12 +9,12 @@ 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("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 }) => { diff --git a/examples/order-application/src/needs-gate.test-d.ts b/examples/order-application/src/needs-gate.test-d.ts index 1dbe4bc6..47aeccc2 100644 --- a/examples/order-application/src/needs-gate.test-d.ts +++ b/examples/order-application/src/needs-gate.test-d.ts @@ -50,7 +50,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("acme", "0199a1e0-0000-7000-8000-000000000001", 1), ); // Negative, the same gate on the sibling module and a different port: the @@ -58,7 +58,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("acme", "0199a1e0-0000-7000-8000-0000000000c1"), ); // Negative, per vertical: the orders repository closes the orders module, and @@ -72,7 +72,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("acme", "0199a1e0-0000-7000-8000-0000000000c1"), ); // Negative, the other port of the orders pair: the repository alone does not @@ -84,7 +84,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("acme", "0199a1e0-0000-7000-8000-000000000001"), +); const WiredOrders = Module("WiredOrders")({ imports: [OrderApplicationModule], @@ -100,7 +102,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("acme", "0199a1e0-0000-7000-8000-000000000001"), +); const WiredCustomers = Module("WiredCustomers")({ imports: [CustomerApplicationModule], @@ -111,5 +115,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("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..28802e27 100644 --- a/examples/order-application/src/place-order.spec.ts +++ b/examples/order-application/src/place-order.spec.ts @@ -11,12 +11,12 @@ 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("acme", "0199a1e0-0000-7000-8000-000000000001", 2) + .flatMap(() => ctx.get(FindOrder).execute("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 +25,26 @@ 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("acme", "0199a1e0-0000-7000-8000-000000000001", 1) + .flatMap(() => placeOrder.execute("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("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("writes a log line carrying the order as fields", async ({ testModule, recorder }) => { @@ -50,7 +53,7 @@ describe("PlaceOrder", () => { const result = await Module.scoped(testModule, (ctx) => ctx .get(PlaceOrder) - .execute("acme", "o-1", 2) + .execute("acme", "0199a1e0-0000-7000-8000-000000000001", 2) .map(() => recorder.lines()), ); @@ -60,7 +63,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, }), ]); diff --git a/examples/order-application/src/test-fixtures.ts b/examples/order-application/src/test-fixtures.ts index 07bf9609..221973bc 100644 --- a/examples/order-application/src/test-fixtures.ts +++ b/examples/order-application/src/test-fixtures.ts @@ -58,7 +58,12 @@ 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) => { const row = rows.get(`${tenantId}/${id}`); 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/order.spec.ts b/examples/order-domain/src/order.spec.ts index d4743e4e..7df588b7 100644 --- a/examples/order-domain/src/order.spec.ts +++ b/examples/order-domain/src/order.spec.ts @@ -18,35 +18,50 @@ 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("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 +72,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 +107,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 +123,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 +138,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 +161,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,8 +172,10 @@ 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", ); }); @@ -157,13 +183,17 @@ 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 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/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/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..01f6f2a3 100644 --- a/examples/order-infrastructure/src/prisma-order-repository.spec.ts +++ b/examples/order-infrastructure/src/prisma-order-repository.spec.ts @@ -37,8 +37,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 +50,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 +62,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 +92,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 }) => { @@ -175,12 +181,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 +206,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..1e60027f 100644 --- a/examples/order-infrastructure/src/prisma-outbox.spec.ts +++ b/examples/order-infrastructure/src/prisma-outbox.spec.ts @@ -13,7 +13,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 +23,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 +39,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 +63,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 +75,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 +89,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 }), ]); }); diff --git a/examples/order-temporal-contract/src/contract.spec.ts b/examples/order-temporal-contract/src/contract.spec.ts index 66f3efcd..8a747f57 100644 --- a/examples/order-temporal-contract/src/contract.spec.ts +++ b/examples/order-temporal-contract/src/contract.spec.ts @@ -9,9 +9,11 @@ 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({ + expect( + validate({ tenantId: "acme", orderId: "0199a1e0-0000-7000-8000-000000000001", quantity: 2 }), + ).toBeOkWith({ tenantId: "acme", - orderId: "o-1", + orderId: "0199a1e0-0000-7000-8000-000000000001", quantity: 2, }); }); @@ -22,9 +24,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: "acme", + 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,9 +40,15 @@ 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({ + expect( + validateCharge({ + tenantId: "acme", + orderId: "0199a1e0-0000-7000-8000-000000000001", + amount: 42, + }), + ).toBeOkWith({ tenantId: "acme", - orderId: "o-1", + orderId: "0199a1e0-0000-7000-8000-000000000001", amount: 42, }); }); diff --git a/examples/order-temporal-worker/src/temporal-runtime.spec.ts b/examples/order-temporal-worker/src/temporal-runtime.spec.ts index 1eb1fed1..be292d5f 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", @@ -67,16 +74,15 @@ describe("the fulfillment saga", () => { .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 +98,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", @@ -108,19 +114,18 @@ describe("the fulfillment saga", () => { .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 +142,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({ @@ -161,7 +166,7 @@ describe("the fulfillment saga", () => { defect: () => "DEFECT", }); - expect(outcome).toBe("conflict:o-4"); + expect(outcome).toBe("conflict:0199a1e0-0000-7000-8000-000000000004"); }); }); @@ -174,10 +179,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 45d70288..941e62bb 100644 --- a/examples/order-temporal-worker/src/test-fixtures.ts +++ b/examples/order-temporal-worker/src/test-fixtures.ts @@ -172,7 +172,7 @@ 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. */ From 15053b6914431a07c10a45ea13181d16298412f7 Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Fri, 21 Aug 2026 15:25:55 +0200 Subject: [PATCH 03/11] docs: the READMEs' ids match the sweep packages/observability/README.md and docs/reference/observability.md both depict examples/order-application's log line, so sweeping one and not the other is the drift CLAUDE.md warns about. packages/di's sample is its own -- it appears nowhere else and no di docs page carries a matching id -- and stays. --- examples/order-application/README.md | 2 +- examples/order-infrastructure/README.md | 2 +- packages/observability/README.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/examples/order-application/README.md b/examples/order-application/README.md index 2e79478d..27ae82c0 100644 --- a/examples/order-application/README.md +++ b/examples/order-application/README.md @@ -114,7 +114,7 @@ this.#logger.info("placing an order", { 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: { 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 diff --git a/examples/order-infrastructure/README.md b/examples/order-infrastructure/README.md index 4e3c095f..0dc75c75 100644 --- a/examples/order-infrastructure/README.md +++ b/examples/order-infrastructure/README.md @@ -141,7 +141,7 @@ 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 — 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", From f11d7b9c3e52568009d18e40bc71139339553fbd Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Fri, 21 Aug 2026 15:47:45 +0200 Subject: [PATCH 04/11] feat(examples)!: every id is a UUIDv7 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OrderId and CustomerId now parse as z.uuidv7().brand(...), and the three transport contracts (order-api, order-amqp, order-temporal) tighten every id- and tenant-shaped z.string() field to z.uuidv7() to match. Task 2 swept every literal id and dynamic tenant fixture ahead of this; tightening turned up a handful it missed — fixed alongside the schemas, detailed in task-3-report.md. --- .../order-amqp-contract/src/contract.spec.ts | 24 +++++++++++++-- examples/order-amqp-contract/src/contract.ts | 4 +-- examples/order-api-contract/src/contract.ts | 14 ++++----- examples/order-api/src/api.spec.ts | 16 ++++++++++ .../order-application/src/place-order.spec.ts | 8 ++--- examples/order-domain/src/customer.ts | 2 +- examples/order-domain/src/order.ts | 8 ++--- .../src/prisma-order-repository.spec.ts | 14 ++++----- .../src/prisma-outbox.spec.ts | 2 +- .../src/contract.spec.ts | 30 +++++++++++++++---- .../order-temporal-contract/src/contract.ts | 12 ++++---- 11 files changed, 94 insertions(+), 40 deletions(-) diff --git a/examples/order-amqp-contract/src/contract.spec.ts b/examples/order-amqp-contract/src/contract.spec.ts index f3ceb879..fcff795d 100644 --- a/examples/order-amqp-contract/src/contract.spec.ts +++ b/examples/order-amqp-contract/src/contract.spec.ts @@ -34,7 +34,7 @@ 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: "0199a1e0-0000-7000-8000-000000000001", occurredAt: "2026-08-13T22:00:00.000Z", @@ -51,7 +51,7 @@ describe("orderContract", () => { }) => { // GIVEN the same schema const tombstone = { - tenantId: "acme", + tenantId: "0199a1e0-0000-7000-8000-000000009000", kind: "order", id: "0199a1e0-0000-7000-8000-000000000001", occurredAt: "2026-08-13T22:00:00.000Z", @@ -72,7 +72,7 @@ describe("orderContract", () => { // executable, not documentation, and a caller can run it expect( validate({ - tenantId: "acme", + tenantId: "0199a1e0-0000-7000-8000-000000009000", kind: "order", id: "0199a1e0-0000-7000-8000-000000000001", occurredAt: "2026-08-13T22:00:00.000Z", @@ -80,4 +80,22 @@ describe("orderContract", () => { }), ).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-api-contract/src/contract.ts b/examples/order-api-contract/src/contract.ts index 364c887d..2be92392 100644 --- a/examples/order-api-contract/src/contract.ts +++ b/examples/order-api-contract/src/contract.ts @@ -15,11 +15,11 @@ 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; /** @@ -37,11 +37,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,13 +50,13 @@ 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 }, @@ -71,7 +71,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/src/api.spec.ts b/examples/order-api/src/api.spec.ts index b08ac2ad..98d371e8 100644 --- a/examples/order-api/src/api.spec.ts +++ b/examples/order-api/src/api.spec.ts @@ -377,6 +377,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)); diff --git a/examples/order-application/src/place-order.spec.ts b/examples/order-application/src/place-order.spec.ts index 28802e27..ae04b4eb 100644 --- a/examples/order-application/src/place-order.spec.ts +++ b/examples/order-application/src/place-order.spec.ts @@ -81,15 +81,15 @@ 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("acme", "0199a1e0-0000-7000-8000-000000000501", 2) + .flatMap(() => placeOrder.execute("globex", "0199a1e0-0000-7000-8000-000000000501", 7)) + .flatMap(() => ctx.get(FindOrder).execute("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 }); }); }); 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/order.ts b/examples/order-domain/src/order.ts index fb57f819..49a01fdc 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. `placeOrder`'s own TSDoc still describes an + * unconstrained id; that drift is Task 4's to close. */ -export const OrderId = z.string().brand("OrderId"); +export const OrderId = z.uuidv7().brand("OrderId"); export const Quantity = z.number().int().brand("Quantity"); /** diff --git a/examples/order-infrastructure/src/prisma-order-repository.spec.ts b/examples/order-infrastructure/src/prisma-order-repository.spec.ts index 01f6f2a3..396b38da 100644 --- a/examples/order-infrastructure/src/prisma-order-repository.spec.ts +++ b/examples/order-infrastructure/src/prisma-order-repository.spec.ts @@ -125,14 +125,14 @@ describe("tenancy", () => { // composite unique key permits and a single-tenant schema would not const other = `${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 ({ @@ -142,12 +142,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(`${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" }); }); }); diff --git a/examples/order-infrastructure/src/prisma-outbox.spec.ts b/examples/order-infrastructure/src/prisma-outbox.spec.ts index 1e60027f..3718c619 100644 --- a/examples/order-infrastructure/src/prisma-outbox.spec.ts +++ b/examples/order-infrastructure/src/prisma-outbox.spec.ts @@ -132,7 +132,7 @@ describe("the transactional outbox", () => { }) => { // GIVEN a write committed by somebody else const events = await repository - .save(`${tenant}-other`, anOrder("o-theirs", 1)) + .save(`${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-temporal-contract/src/contract.spec.ts b/examples/order-temporal-contract/src/contract.spec.ts index 8a747f57..b6dc2419 100644 --- a/examples/order-temporal-contract/src/contract.spec.ts +++ b/examples/order-temporal-contract/src/contract.spec.ts @@ -10,9 +10,13 @@ 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: "0199a1e0-0000-7000-8000-000000000001", quantity: 2 }), + validate({ + tenantId: "0199a1e0-0000-7000-8000-000000009000", + orderId: "0199a1e0-0000-7000-8000-000000000001", + quantity: 2, + }), ).toBeOkWith({ - tenantId: "acme", + tenantId: "0199a1e0-0000-7000-8000-000000009000", orderId: "0199a1e0-0000-7000-8000-000000000001", quantity: 2, }); @@ -26,7 +30,7 @@ describe("orderContract", () => { // executable, not documentation, and a client can run it expect( validate({ - tenantId: "acme", + tenantId: "0199a1e0-0000-7000-8000-000000009000", orderId: "0199a1e0-0000-7000-8000-000000000001", quantity: "2", }), @@ -42,14 +46,30 @@ describe("orderContract", () => { // THEN it is accepted, proving the contract holds more than one workflow expect( validateCharge({ - tenantId: "acme", + tenantId: "0199a1e0-0000-7000-8000-000000009000", orderId: "0199a1e0-0000-7000-8000-000000000001", amount: 42, }), ).toBeOkWith({ - tenantId: "acme", + 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..1c940905 100644 --- a/examples/order-temporal-contract/src/contract.ts +++ b/examples/order-temporal-contract/src/contract.ts @@ -8,10 +8,10 @@ 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() }); /** * Every input carries the tenant, and that is not a field the domain gained: @@ -26,10 +26,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 @@ -132,7 +132,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({ From 46f385c556db2dca8cf22946ced4a9c1156fe47e Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Fri, 21 Aug 2026 16:07:53 +0200 Subject: [PATCH 05/11] feat(examples)!: a malformed order id is its own failure placeOrder's TSDoc argued its InvalidEntity -> InvalidQuantity translation was total "with an unconstrained OrderId". Giving OrderId a UUIDv7 format ended that: placeOrder("o-1", 2) answered "asks for 2 items, which is not a positive quantity" about a field the caller got right -- the mislabelling this branch exists to remove. InvalidOrderId is the second error, discriminated on which FIELD the entity named rather than on message text: a schema issue carries a path, an Entity.invariant violation carries none, and Entity.keysOf reads that path as plain keys. When both fields are wrong the id wins. Per transport: order-api answers BAD_REQUEST, because a malformed id is the caller's mistake and 409 would point at the server's data; order-temporal declares it nonRetryable on both the activity and the workflow, because a bad id will never become good; order-amqp changes nothing -- its slices react to a committed fact, so a placement's Err never crosses the broker. The error's payload is a bare-string ref on both contracts: validating a malformed id against z.uuidv7() would reject the only payload it ever carries. Both new arms are exhaustiveness rather than live routes -- each contract's own input schema refuses a malformed id first -- and the TSDoc, both READMEs and the fourteen documentation samples say that the earlier decision was superseded rather than wrong. --- README.md | 9 ++++ docs/examples/index.md | 1 + docs/examples/order-api.md | 28 +++++++++--- docs/examples/order-application.md | 26 +++++++---- docs/explanation/the-kernel-maps-nothing.md | 3 ++ docs/how-to/consume-amqp-messages.md | 1 + docs/how-to/protect-a-procedure.md | 8 ++++ docs/how-to/run-a-temporal-worker.md | 3 ++ docs/how-to/serve-orpc-over-http.md | 9 ++++ .../how-to/split-a-router-into-controllers.md | 9 ++++ docs/index.md | 1 + docs/reference/http.md | 8 ++++ docs/reference/temporal.md | 6 +++ examples/README.md | 1 + examples/order-api-contract/src/contract.ts | 9 ++++ examples/order-api/README.md | 12 ++++- examples/order-api/src/api.spec.ts | 11 +++-- .../order-api/src/docs-examples.test-d.ts | 6 +++ .../order-api/src/slices/orders/controller.ts | 10 +++++ .../order-application/src/place-order.spec.ts | 11 +++++ examples/order-application/src/ports.ts | 3 +- examples/order-application/src/use-cases.ts | 3 +- examples/order-domain/README.md | 30 +++++++++---- examples/order-domain/src/index.ts | 1 + examples/order-domain/src/order.spec.ts | 30 +++++++++++++ examples/order-domain/src/order.ts | 45 ++++++++++++++++--- .../order-temporal-contract/src/contract.ts | 10 +++++ .../src/slices/fulfillment/activities.ts | 1 + .../src/temporal-runtime.spec.ts | 3 ++ .../order-temporal-worker/src/workflows.ts | 3 ++ packages/amqp/README.md | 1 + packages/http/README.md | 16 +++++++ 32 files changed, 280 insertions(+), 38 deletions(-) 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/index.md b/docs/examples/index.md index 35f51fdd..1e52c3bf 100644 --- a/docs/examples/index.md +++ b/docs/examples/index.md @@ -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 | diff --git a/docs/examples/order-api.md b/docs/examples/order-api.md index 2c5ff58c..4342fdc3 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 } }), }; @@ -224,6 +230,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/examples/order-application.md b/docs/examples/order-application.md index 112bc014..d9f18610 100644 --- a/docs/examples/order-application.md +++ b/docs/examples/order-application.md @@ -56,17 +56,20 @@ 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 @@ -224,19 +227,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 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/consume-amqp-messages.md b/docs/how-to/consume-amqp-messages.md index 3d3080d0..489bab0c 100644 --- a/docs/how-to/consume-amqp-messages.md +++ b/docs/how-to/consume-amqp-messages.md @@ -122,6 +122,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), ), diff --git a/docs/how-to/protect-a-procedure.md b/docs/how-to/protect-a-procedure.md index a33ed707..8469436f 100644 --- a/docs/how-to/protect-a-procedure.md +++ b/docs/how-to/protect-a-procedure.md @@ -192,6 +192,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/run-a-temporal-worker.md b/docs/how-to/run-a-temporal-worker.md index 0bb73c7b..6b74c590 100644 --- a/docs/how-to/run-a-temporal-worker.md +++ b/docs/how-to/run-a-temporal-worker.md @@ -65,6 +65,9 @@ export const orderActivities = TemporalActivities(orderContract)( .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/how-to/serve-orpc-over-http.md b/docs/how-to/serve-orpc-over-http.md index 39ee5506..7825ee56 100644 --- a/docs/how-to/serve-orpc-over-http.md +++ b/docs/how-to/serve-orpc-over-http.md @@ -55,6 +55,7 @@ export const ordersContract = authenticated({ .output(orderView) .errors({ INVALID_QUANTITY: { data: orderRef }, + BAD_REQUEST: { data: orderRef }, CONFLICT: { data: orderRef }, }), find: oc @@ -114,6 +115,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..354859f2 100644 --- a/docs/how-to/split-a-router-into-controllers.md +++ b/docs/how-to/split-a-router-into-controllers.md @@ -49,6 +49,7 @@ const ordersContract = { .output(orderView) .errors({ INVALID_QUANTITY: { data: orderRef }, + BAD_REQUEST: { data: orderRef }, CONFLICT: { data: orderRef }, }), find: oc @@ -115,6 +116,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/index.md b/docs/index.md index 2f84ee4a..eba64506 100644 --- a/docs/index.md +++ b/docs/index.md @@ -63,6 +63,7 @@ const ordersContract = authenticated({ .output(orderView) .errors({ INVALID_QUANTITY: { data: orderRef }, + BAD_REQUEST: { data: orderRef }, CONFLICT: { data: orderRef }, }), }); diff --git a/docs/reference/http.md b/docs/reference/http.md index 2f27c47d..24f3da6a 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, diff --git a/docs/reference/temporal.md b/docs/reference/temporal.md index cf0f7ff1..d8fbf4d7 100644 --- a/docs/reference/temporal.md +++ b/docs/reference/temporal.md @@ -139,6 +139,9 @@ export const orderActivities = TemporalActivities(orderContract)( .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 }), ), @@ -263,6 +266,9 @@ const orderFulfillment = TemporalWorkflowActivities( .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/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/order-api-contract/src/contract.ts b/examples/order-api-contract/src/contract.ts index 2be92392..00c96622 100644 --- a/examples/order-api-contract/src/contract.ts +++ b/examples/order-api-contract/src/contract.ts @@ -22,6 +22,14 @@ export type OrderView = z.infer; 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 @@ -60,6 +68,7 @@ const ordersContract = { .output(orderView) .errors({ INVALID_QUANTITY: { data: orderRef }, + BAD_REQUEST: { data: malformedRef }, CONFLICT: { data: orderRef }, }), find: oc diff --git a/examples/order-api/README.md b/examples/order-api/README.md index 812c0e80..9ffd6fc4 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 } }), ), @@ -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/src/api.spec.ts b/examples/order-api/src/api.spec.ts index 98d371e8..3715c109 100644 --- a/examples/order-api/src/api.spec.ts +++ b/examples/order-api/src/api.spec.ts @@ -123,13 +123,18 @@ describe("order-api", () => { }); // 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", }); diff --git a/examples/order-api/src/docs-examples.test-d.ts b/examples/order-api/src/docs-examples.test-d.ts index eb57edeb..049a324b 100644 --- a/examples/order-api/src/docs-examples.test-d.ts +++ b/examples/order-api/src/docs-examples.test-d.ts @@ -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 } }), ), @@ -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/orders/controller.ts b/examples/order-api/src/slices/orders/controller.ts index 39def97f..28189cb2 100644 --- a/examples/order-api/src/slices/orders/controller.ts +++ b/examples/order-api/src/slices/orders/controller.ts @@ -61,6 +61,16 @@ 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 is + // exhaustiveness rather than a live route — the fragment's own + // `z.uuidv7()` refuses such an id before dispatch, which + // `api.spec.ts` pins as an *undeclared* `BAD_REQUEST` on the + // defect channel. Same code, two paths, told apart by whether the + // data matches: oRPC's validation failure carries none. + .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-application/src/place-order.spec.ts b/examples/order-application/src/place-order.spec.ts index ae04b4eb..51e64b6c 100644 --- a/examples/order-application/src/place-order.spec.ts +++ b/examples/order-application/src/place-order.spec.ts @@ -47,6 +47,17 @@ describe("PlaceOrder", () => { }); }); + 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("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 }) => { // GIVEN a successful placement // WHEN the sink the graph's logger writes to is read back diff --git a/examples/order-application/src/ports.ts b/examples/order-application/src/ports.ts index a5c5bb40..28bbb644 100644 --- a/examples/order-application/src/ports.ts +++ b/examples/order-application/src/ports.ts @@ -3,6 +3,7 @@ import type { Customer, CustomerNotFound, DuplicateOrder, + InvalidOrderId, InvalidQuantity, Order, OrderNotFound, @@ -131,7 +132,7 @@ export class PlaceOrder extends Port("PlaceOrder")<{ tenantId: string, id: string, quantity: number, - ) => AsyncResult; + ) => AsyncResult; }> {} export class FindOrder extends Port("FindOrder")<{ diff --git a/examples/order-application/src/use-cases.ts b/examples/order-application/src/use-cases.ts index 3237443f..b6af56c7 100644 --- a/examples/order-application/src/use-cases.ts +++ b/examples/order-application/src/use-cases.ts @@ -4,6 +4,7 @@ import { type Customer, type CustomerNotFound, type DuplicateOrder, + type InvalidOrderId, type InvalidQuantity, type Order, type OrderNotFound, @@ -38,7 +39,7 @@ class PlaceOrderInteractor { tenantId: string, id: string, quantity: number, - ): AsyncResult { + ): AsyncResult { this.#logger.info("placing an order", { tenantId, orderId: id, quantity }); return placeOrder(id, quantity) .toAsync() diff --git a/examples/order-domain/README.md b/examples/order-domain/README.md index 43441d5a..a6d1fa46 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,9 @@ 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, +`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. diff --git a/examples/order-domain/src/index.ts b/examples/order-domain/src/index.ts index 75dd67ec..e715ecc3 100644 --- a/examples/order-domain/src/index.ts +++ b/examples/order-domain/src/index.ts @@ -2,6 +2,7 @@ export { Customer, CustomerNotFound } from "./customer.js"; export { OutOfStock, PaymentDeclined, ShippingUnavailable } from "./fulfillment.js"; export { DuplicateOrder, + InvalidOrderId, InvalidQuantity, Order, OrderId, diff --git a/examples/order-domain/src/order.spec.ts b/examples/order-domain/src/order.spec.ts index 7df588b7..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, @@ -47,6 +48,28 @@ describe("placeOrder", () => { ); }); + 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 @@ -179,6 +202,13 @@ describe("domain errors", () => { ); }); + 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 diff --git a/examples/order-domain/src/order.ts b/examples/order-domain/src/order.ts index 49a01fdc..0be5d36e 100644 --- a/examples/order-domain/src/order.ts +++ b/examples/order-domain/src/order.ts @@ -8,8 +8,8 @@ import { z } from "zod"; * one where the other belongs is a compile error rather than a bug. * * `OrderId` is a UUIDv7 — the shape every id in this example carries on the - * wire and in the database. `placeOrder`'s own TSDoc still describes an - * unconstrained id; that drift is Task 4's to close. + * wire and in the database. That format is what gives `placeOrder` a second + * failure to name; see its TSDoc. */ 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-temporal-contract/src/contract.ts b/examples/order-temporal-contract/src/contract.ts index 1c940905..17d72722 100644 --- a/examples/order-temporal-contract/src/contract.ts +++ b/examples/order-temporal-contract/src/contract.ts @@ -13,6 +13,14 @@ 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.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: * it is who the work is being done for. A worker has no request and no @@ -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 }, diff --git a/examples/order-temporal-worker/src/slices/fulfillment/activities.ts b/examples/order-temporal-worker/src/slices/fulfillment/activities.ts index 870c2001..29e30e07 100644 --- a/examples/order-temporal-worker/src/slices/fulfillment/activities.ts +++ b/examples/order-temporal-worker/src/slices/fulfillment/activities.ts @@ -74,6 +74,7 @@ export const fulfillOrder = TemporalWorkflowActivities(orderContract, "fulfillOr .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/examples/order-temporal-worker/src/temporal-runtime.spec.ts b/examples/order-temporal-worker/src/temporal-runtime.spec.ts index be292d5f..df31d6e9 100644 --- a/examples/order-temporal-worker/src/temporal-runtime.spec.ts +++ b/examples/order-temporal-worker/src/temporal-runtime.spec.ts @@ -68,6 +68,7 @@ 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}`) @@ -108,6 +109,7 @@ 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}`) @@ -159,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}`) 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/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, From c5b8cf97348caf79cea08a5ecb157fcb48a34ea3 Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Fri, 21 Aug 2026 16:22:40 +0200 Subject: [PATCH 06/11] fix(docs): the home page's sample names every domain error again MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `docs/index.md`'s contract grew `BAD_REQUEST` while its `mapErrCases` kept two arms, so the sample stopped compiling against an exhaustive matcher — proved by extracting it into `examples/order-api/src` and watching tsc refuse it. `docs/how-to/protect-a-procedure.md`'s fragment now declares the three codes its controller calls. The orders controller's comment named the wrong separator: oRPC's own validation refusal does carry `data: { issues }`. What tells the two `BAD_REQUEST`s apart is `inferable`, set only when a handler returns an `ORPCError` as its output, which `isInferableError` reads — so `api.spec.ts`'s `inferable: false` pins a mechanism, not a coincidence. --- docs/how-to/protect-a-procedure.md | 9 ++++++- docs/index.md | 8 ++++++ .../order-api/src/slices/orders/controller.ts | 25 ++++++++++++++----- 3 files changed, 35 insertions(+), 7 deletions(-) diff --git a/docs/how-to/protect-a-procedure.md b/docs/how-to/protect-a-procedure.md index 8469436f..0c9df810 100644 --- a/docs/how-to/protect-a-procedure.md +++ b/docs/how-to/protect-a-procedure.md @@ -40,10 +40,17 @@ import { authenticated } from "@btravstack/contract"; import { oc } from "@orpc/contract"; import { z } from "zod"; +const orderRef = z.object({ id: z.string() }); + const ordersContract = { place: oc .input(z.object({ id: z.string(), quantity: z.number() })) - .output(z.object({ id: z.string() })), + .output(z.object({ id: z.string() })) + .errors({ + INVALID_QUANTITY: { data: orderRef }, + BAD_REQUEST: { data: orderRef }, + CONFLICT: { data: orderRef }, + }), }; const customersContract = { diff --git a/docs/index.md b/docs/index.md index eba64506..2e1f73c1 100644 --- a/docs/index.md +++ b/docs/index.md @@ -86,6 +86,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/examples/order-api/src/slices/orders/controller.ts b/examples/order-api/src/slices/orders/controller.ts index 28189cb2..fff32daa 100644 --- a/examples/order-api/src/slices/orders/controller.ts +++ b/examples/order-api/src/slices/orders/controller.ts @@ -62,12 +62,25 @@ export const ordersController = HttpController("OrdersController", contract.orde 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 is - // exhaustiveness rather than a live route — the fragment's own - // `z.uuidv7()` refuses such an id before dispatch, which - // `api.spec.ts` pins as an *undeclared* `BAD_REQUEST` on the - // defect channel. Same code, two paths, told apart by whether the - // data matches: oRPC's validation failure carries none. + // 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 } }), ) From 8f6f5efdbfc539cae94ff1bdd6cc47e4d116949b Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Fri, 21 Aug 2026 16:40:15 +0200 Subject: [PATCH 07/11] feat(examples)!: a tenant is not a string MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every port in the example application names its tenant positionally, next to a string that is not one — find(tenantId, id), execute(tenantId, id, quantity) — and two strings in a fixed order are what the compiler has nothing to say about: the swap compiled and queried the wrong tenant. TenantId (order-domain) brands one half of each pair, which is all it takes for the pair to become unswappable; the ids stay string, and branding them is a separate question. The constructor is a cast, not a parse: the value arrived through a contract that already validated it as a UUIDv7, and .parse() throws. Each path claims the brand exactly once — bearerAuthenticator for the marked HTTP half, TenantId(input.tenantId) for the unmarked one, each Temporal activity's own input, and tenantsOf for the relay's OUTBOX_TENANTS. The AMQP handlers claim nothing: neither calls a port that names a tenant. prisma-outbox is the one read-back, so the one place the brand is re-applied. tenant.test-d.ts is the gate. --- CLAUDE.md | 18 ++++++++++ docs/examples/order-api.md | 18 +++++++--- docs/how-to/consume-amqp-messages.md | 3 +- docs/how-to/read-the-ambient-unit.md | 12 +++++-- examples/order-amqp-worker/package.json | 1 + .../order-amqp-worker/src/outbox-relay.ts | 20 ++++++++--- .../order-amqp-worker/src/test-fixtures.ts | 5 +-- examples/order-api/README.md | 2 +- examples/order-api/src/auth.ts | 9 ++++- examples/order-api/src/authenticator.ts | 15 +++++--- .../order-api/src/docs-examples.test-d.ts | 4 +-- .../src/slices/customers/controller.ts | 4 +-- examples/order-api/src/test-fixtures.ts | 3 +- .../src/find-customer.spec.ts | 5 +-- .../src/needs-gate.test-d.ts | 21 ++++++------ .../order-application/src/place-order.spec.ts | 31 +++++++++++------ examples/order-application/src/ports.ts | 22 +++++++----- .../order-application/src/tenant.test-d.ts | 34 +++++++++++++++++++ .../order-application/src/test-fixtures.ts | 11 +++--- examples/order-application/src/use-cases.ts | 7 ++-- examples/order-domain/README.md | 24 +++++++++++++ examples/order-domain/src/index.ts | 3 +- examples/order-domain/src/tenant.ts | 22 ++++++++++++ examples/order-infrastructure/README.md | 9 ++++- .../src/prisma-order-repository.spec.ts | 5 +-- .../src/prisma-outbox.spec.ts | 3 +- .../order-infrastructure/src/prisma-outbox.ts | 8 +++-- .../order-infrastructure/src/test-fixtures.ts | 6 ++-- .../src/slices/fulfillment/activities.ts | 9 +++-- .../src/test-fixtures.ts | 6 ++-- pnpm-lock.yaml | 3 ++ 31 files changed, 262 insertions(+), 81 deletions(-) create mode 100644 examples/order-application/src/tenant.test-d.ts create mode 100644 examples/order-domain/src/tenant.ts diff --git a/CLAUDE.md b/CLAUDE.md index a71214ab..0a2a0742 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -564,6 +564,24 @@ 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. + 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 diff --git a/docs/examples/order-api.md b/docs/examples/order-api.md index 4342fdc3..fea3bf06 100644 --- a/docs/examples/order-api.md +++ b/docs/examples/order-api.md @@ -118,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, @@ -126,7 +127,7 @@ import { } from "@btravstack/http"; export type Identity = { - readonly tenantId: string; + readonly tenantId: TenantId; readonly userId: string; }; @@ -157,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"; @@ -174,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 @@ -294,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 diff --git a/docs/how-to/consume-amqp-messages.md b/docs/how-to/consume-amqp-messages.md index 489bab0c..b3ad8578 100644 --- a/docs/how-to/consume-amqp-messages.md +++ b/docs/how-to/consume-amqp-messages.md @@ -105,6 +105,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 +115,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, ) diff --git a/docs/how-to/read-the-ambient-unit.md b/docs/how-to/read-the-ambient-unit.md index 56b0ed5a..20544fde 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: 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/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 ede54e43..d9886976 100644 --- a/examples/order-amqp-worker/src/test-fixtures.ts +++ b/examples/order-amqp-worker/src/test-fixtures.ts @@ -11,6 +11,7 @@ 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"; @@ -95,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; /** @@ -115,7 +116,7 @@ export const it: TestAPI = amqpIt.extend { - await use(uuidv7()); + await use(TenantId(uuidv7())); }, serve: async ({ amqpConnectionUrl, tenant, boot }, use) => { diff --git a/examples/order-api/README.md b/examples/order-api/README.md index 9ffd6fc4..b64a92e5 100644 --- a/examples/order-api/README.md +++ b/examples/order-api/README.md @@ -118,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(); 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 049a324b..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, @@ -103,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) => 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/test-fixtures.ts b/examples/order-api/src/test-fixtures.ts index 51064856..fb82cc2e 100644 --- a/examples/order-api/src/test-fixtures.ts +++ b/examples/order-api/src/test-fixtures.ts @@ -15,6 +15,7 @@ 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"; @@ -46,7 +47,7 @@ const persistenceOf = (repository: ServiceOf) => Provider(OrderRepository)({ value: repository }), Provider(CustomerRepository)({ value: { - find: (_tenantId: string, id: string) => + find: (_tenantId: TenantId, id: string) => id === "0199a1e0-0000-7000-8000-0000000000c1" ? OkAsync(Customer.make({ id, name: "Ada" }).getOrThrow()) : ErrAsync(new CustomerNotFound({ id })), diff --git a/examples/order-application/src/find-customer.spec.ts b/examples/order-application/src/find-customer.spec.ts index 0ec7f898..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,7 +10,7 @@ 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", "0199a1e0-0000-7000-8000-0000000000c1"), + ctx.get(FindCustomer).execute(TenantId("acme"), "0199a1e0-0000-7000-8000-0000000000c1"), ); // THEN the use case answers with the domain's own entity — converting it @@ -21,7 +22,7 @@ describe("FindCustomer", () => { // 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 47aeccc2..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", "0199a1e0-0000-7000-8000-000000000001", 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", "0199a1e0-0000-7000-8000-0000000000c1"), + 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", "0199a1e0-0000-7000-8000-0000000000c1"), + ctx.get(FindCustomer).execute(TenantId("acme"), "0199a1e0-0000-7000-8000-0000000000c1"), ); // Negative, the other port of the orders pair: the repository alone does not @@ -85,7 +86,7 @@ const LoglessOrders = Module("LoglessOrders")({ // @ts-expect-error — UNSATISFIED DEPENDENCIES: no Logger is provided. const _logless = Module.scoped(LoglessOrders, (ctx) => - ctx.get(FindOrder).execute("acme", "0199a1e0-0000-7000-8000-000000000001"), + ctx.get(FindOrder).execute(TenantId("acme"), "0199a1e0-0000-7000-8000-000000000001"), ); const WiredOrders = Module("WiredOrders")({ @@ -103,7 +104,7 @@ 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", "0199a1e0-0000-7000-8000-000000000001"), + ctx.get(FindOrder).execute(TenantId("acme"), "0199a1e0-0000-7000-8000-000000000001"), ); const WiredCustomers = Module("WiredCustomers")({ @@ -115,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", "0199a1e0-0000-7000-8000-0000000000c1"), + 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 51e64b6c..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,8 +12,10 @@ describe("PlaceOrder", () => { const result = await Module.scoped(testModule, (ctx) => ctx .get(PlaceOrder) - .execute("acme", "0199a1e0-0000-7000-8000-000000000001", 2) - .flatMap(() => ctx.get(FindOrder).execute("acme", "0199a1e0-0000-7000-8000-000000000001")), + .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 @@ -25,8 +28,10 @@ describe("PlaceOrder", () => { const result = await Module.scoped(testModule, (ctx) => { const placeOrder = ctx.get(PlaceOrder); return placeOrder - .execute("acme", "0199a1e0-0000-7000-8000-000000000001", 1) - .flatMap(() => placeOrder.execute("acme", "0199a1e0-0000-7000-8000-000000000001", 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 @@ -37,7 +42,7 @@ describe("PlaceOrder", () => { // GIVEN a quantity the domain invariant rejects // WHEN it is placed const result = await Module.scoped(testModule, (ctx) => - ctx.get(PlaceOrder).execute("acme", "0199a1e0-0000-7000-8000-000000000001", 0), + ctx.get(PlaceOrder).execute(TenantId("acme"), "0199a1e0-0000-7000-8000-000000000001", 0), ); // THEN the domain error short-circuits the use case @@ -51,7 +56,7 @@ describe("PlaceOrder", () => { // 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("acme", "o-1", 2), + ctx.get(PlaceOrder).execute(TenantId("acme"), "o-1", 2), ); // THEN the widened channel carries the id's own error to the caller @@ -64,7 +69,7 @@ describe("PlaceOrder", () => { const result = await Module.scoped(testModule, (ctx) => ctx .get(PlaceOrder) - .execute("acme", "0199a1e0-0000-7000-8000-000000000001", 2) + .execute(TenantId("acme"), "0199a1e0-0000-7000-8000-000000000001", 2) .map(() => recorder.lines()), ); @@ -92,9 +97,13 @@ describe("tenancy", () => { const result = await Module.scoped(testModule, (ctx) => { const placeOrder = ctx.get(PlaceOrder); return placeOrder - .execute("acme", "0199a1e0-0000-7000-8000-000000000501", 2) - .flatMap(() => placeOrder.execute("globex", "0199a1e0-0000-7000-8000-000000000501", 7)) - .flatMap(() => ctx.get(FindOrder).execute("acme", "0199a1e0-0000-7000-8000-000000000501")); + .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 @@ -109,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 28bbb644..07d6ac3c 100644 --- a/examples/order-application/src/ports.ts +++ b/examples/order-application/src/ports.ts @@ -10,6 +10,7 @@ import type { OutOfStock, PaymentDeclined, ShippingUnavailable, + TenantId, } from "@btravstack/example-order-domain"; import type { AsyncResult } from "unthrown"; @@ -35,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; }> {} /** @@ -49,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; }> {} /** @@ -68,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; @@ -91,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; }> {} @@ -129,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; }> {} 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 221973bc..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 })), }; }, @@ -65,7 +66,7 @@ const stubCustomerRepository = Provider(CustomerRepository)({ ], ]); 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 b6af56c7..d2ec5309 100644 --- a/examples/order-application/src/use-cases.ts +++ b/examples/order-application/src/use-cases.ts @@ -8,6 +8,7 @@ import { type InvalidQuantity, type Order, type OrderNotFound, + type TenantId, } from "@btravstack/example-order-domain"; import { Logger } from "@btravstack/observability"; import type { AsyncResult } from "unthrown"; @@ -36,7 +37,7 @@ class PlaceOrderInteractor { } execute( - tenantId: string, + tenantId: TenantId, id: string, quantity: number, ): AsyncResult { @@ -54,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); } } @@ -66,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 a6d1fa46..22364fae 100644 --- a/examples/order-domain/README.md +++ b/examples/order-domain/README.md @@ -120,6 +120,30 @@ 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. +## 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, diff --git a/examples/order-domain/src/index.ts b/examples/order-domain/src/index.ts index e715ecc3..ef4d5036 100644 --- a/examples/order-domain/src/index.ts +++ b/examples/order-domain/src/index.ts @@ -1,4 +1,4 @@ -export { Customer, CustomerNotFound } from "./customer.js"; +export { Customer, CustomerId, CustomerNotFound } from "./customer.js"; export { OutOfStock, PaymentDeclined, ShippingUnavailable } from "./fulfillment.js"; export { DuplicateOrder, @@ -10,3 +10,4 @@ export { Quantity, placeOrder, } from "./order.js"; +export { TenantId } from "./tenant.js"; diff --git a/examples/order-domain/src/tenant.ts b/examples/order-domain/src/tenant.ts new file mode 100644 index 00000000..8b7e2624 --- /dev/null +++ b/examples/order-domain/src/tenant.ts @@ -0,0 +1,22 @@ +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: every value that becomes one 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 the operator + * wrote. Parsing again would spend a validation per request re-answering a + * question already answered, and `.parse()` throws besides. 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-infrastructure/README.md b/examples/order-infrastructure/README.md index 0dc75c75..7b7d47ad 100644 --- a/examples/order-infrastructure/README.md +++ b/examples/order-infrastructure/README.md @@ -133,9 +133,16 @@ 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 diff --git a/examples/order-infrastructure/src/prisma-order-repository.spec.ts b/examples/order-infrastructure/src/prisma-order-repository.spec.ts index 396b38da..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"; @@ -123,7 +124,7 @@ 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("0199a1e0-0000-7000-8000-000000000501", 3)) .flatMap(() => repository.save(other, anOrder("0199a1e0-0000-7000-8000-000000000501", 7))) @@ -142,7 +143,7 @@ describe("tenancy", () => { }) => { // GIVEN an order that belongs to somebody else const seen = await repository - .save(`${tenant}-other`, anOrder("0199a1e0-0000-7000-8000-000000000502", 3)) + .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 diff --git a/examples/order-infrastructure/src/prisma-outbox.spec.ts b/examples/order-infrastructure/src/prisma-outbox.spec.ts index 3718c619..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"; @@ -132,7 +133,7 @@ describe("the transactional outbox", () => { }) => { // GIVEN a write committed by somebody else const events = await repository - .save(`${tenant}-other`, anOrder("0199a1e0-0000-7000-8000-000000000502", 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 3e897f0c..15b412ed 100644 --- a/examples/order-infrastructure/src/test-fixtures.ts +++ b/examples/order-infrastructure/src/test-fixtures.ts @@ -4,7 +4,7 @@ import type { 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"; @@ -36,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; @@ -63,7 +63,7 @@ export const it = test.extend({ // oxlint-disable-next-line no-empty-pattern -- see above tenant: async ({}, use) => { - await use(uuidv7()); + await use(TenantId(uuidv7())); }, repository: async ({ db }, use) => { diff --git a/examples/order-temporal-worker/src/slices/fulfillment/activities.ts b/examples/order-temporal-worker/src/slices/fulfillment/activities.ts index 29e30e07..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,7 +74,7 @@ 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 @@ -96,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/test-fixtures.ts b/examples/order-temporal-worker/src/test-fixtures.ts index 941e62bb..57941552 100644 --- a/examples/order-temporal-worker/src/test-fixtures.ts +++ b/examples/order-temporal-worker/src/test-fixtures.ts @@ -8,7 +8,7 @@ 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"; @@ -176,7 +176,7 @@ export type TemporalFixtures = { * 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; /** @@ -205,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(uuidv7()); + await use(TenantId(uuidv7())); }, serve: async ({ server, boot }, use) => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c76527c7..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 From 94c03a4dbbf0ac8ec42a197c50250833b46bbf5c Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Fri, 21 Aug 2026 16:54:53 +0200 Subject: [PATCH 08/11] fix(docs): brand the identity's tenant, and say who casts vs. who validates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit http.md and protect-a-procedure.md each declared Identity with tenantId: string and then passed context.principal.tenantId to a use case expecting the branded TenantId — a contradiction the two pages only exposed if compiled together. Both now import and declare tenantId: TenantId, matching docs/examples/order-api.md's spelling, and the two bearerAuthenticator samples cast with TenantId(tenantId). tenant.ts's TSDoc claimed every TenantId "arrives through a contract that has already validated it as a UUIDv7," which is false for the HTTP-marked path: bearerAuthenticator only checks that the header's tenant segment is non-empty before casting. Narrow the claim to name the three boundaries separately — a contract validates, deployment configuration is trusted, and the stand-in authenticator vouches. --- docs/how-to/protect-a-procedure.md | 6 ++++-- docs/reference/http.md | 8 ++++++-- examples/order-domain/src/tenant.ts | 20 +++++++++++++------- 3 files changed, 23 insertions(+), 11 deletions(-) diff --git a/docs/how-to/protect-a-procedure.md b/docs/how-to/protect-a-procedure.md index 0c9df810..b09fe66b 100644 --- a/docs/how-to/protect-a-procedure.md +++ b/docs/how-to/protect-a-procedure.md @@ -82,6 +82,7 @@ application, which hands back `HttpController`, `HttpRouter` and ```ts // src/auth.ts +import type { TenantId } from "@btravstack/example-order-domain"; import { httpAuth, type HttpAuthenticatorOf, @@ -90,7 +91,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(); @@ -115,6 +116,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"; @@ -132,7 +134,7 @@ export const bearerAuthenticator = HttpAuthenticator({ userId === undefined || userId === "" ? ErrAsync(new Unauthenticated()) - : OkAsync({ tenantId, userId }); + : OkAsync({ tenantId: TenantId(tenantId), userId }); }, }); ``` diff --git a/docs/reference/http.md b/docs/reference/http.md index 24f3da6a..43c7e9fb 100644 --- a/docs/reference/http.md +++ b/docs/reference/http.md @@ -339,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 ?? ""; @@ -351,7 +353,7 @@ export const bearerAuthenticator = HttpAuthenticator({ userId === undefined || userId === "" ? ErrAsync(new Unauthenticated()) - : OkAsync({ tenantId, userId }); + : OkAsync({ tenantId: TenantId(tenantId), userId }); }, }); ``` @@ -365,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/examples/order-domain/src/tenant.ts b/examples/order-domain/src/tenant.ts index 8b7e2624..8cfa199c 100644 --- a/examples/order-domain/src/tenant.ts +++ b/examples/order-domain/src/tenant.ts @@ -9,13 +9,19 @@ import { z } from "zod"; * 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: every value that becomes one 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 the operator - * wrote. Parsing again would spend a validation per request re-answering a - * question already answered, and `.parse()` throws besides. A brand is a - * compile-time fiction: nothing is asked of a caller at run time, and nothing - * of it survives serialization. + * 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; From 7875af359d1485fbdc57f2b11bd4d53b465fa32b Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Fri, 21 Aug 2026 17:22:02 +0200 Subject: [PATCH 09/11] docs: a tenant is branded and an id is a UUIDv7 The prose still described the ports as they were before issue #81. docs/examples/order-application.md showed an OrderRepository with no tenant argument at all; docs/reference/temporal.md and run-a-temporal-worker.md called place.execute(args.orderId, args.quantity); log-and-correlate.md's interactor took (id, quantity) and saved without a tenant; the two AMQP relay samples were missing OUTBOX_TENANTS and tenantsOf; and test-an-application.md's fixture typed the tenant `string` and minted it with randomUUID, which is a v4 the schema rejects. Every touched sample was compiled in a scratch file inside a workspace that has the dependencies, then deleted. CLAUDE.md's multi-tenancy section claimed every order-api procedure names its tenant on the input, which the authenticated `orders` fragment stopped doing; said nothing about the UUIDv7 format or the second failure it gave placeOrder; and argued only that a caller who FORGETS a tenant does not compile, when the branded pair now refuses one that swaps it. The "positional form the three router-shaped pages share" was wrong three ways: docs-examples.test-d.ts pins the KEYED deps form, serve-orpc-over-http.md used the positional one, and the positional one has not compiled since `feat(di)!: a provider declares its dependencies by name` -- measured, TS2345 `not assignable to parameter of type 'Readonly>'`. That page's deps record is fixed, so the three pages really do share one form and it really is gated; the sentence now names them and records the diagnostic. Five samples elsewhere still carry the dead array and are left for a commit of their own, since that drift is di's, not this branch's. No changeset: `git diff --name-only main..HEAD | grep '^packages/'` is three README files and no src/, so no published API moved. Spec counts in three READMEs drifted on this branch and are re-measured by running the suites: order-domain 18 -> 21, order-application 7 -> 9, order-infrastructure 18 -> 22. --- CLAUDE.md | 41 +++++++-- docs/examples/index.md | 7 +- docs/examples/order-amqp-worker.md | 52 +++++++++-- docs/examples/order-application.md | 89 ++++++++++++++----- docs/how-to/configure-from-the-environment.md | 8 +- docs/how-to/consume-amqp-messages.md | 34 +++++-- docs/how-to/log-and-correlate.md | 30 ++++--- docs/how-to/read-the-ambient-unit.md | 3 +- docs/how-to/run-a-temporal-worker.md | 23 ++++- docs/how-to/serve-orpc-over-http.md | 4 +- docs/how-to/split-a-worker-into-slices.md | 3 +- docs/how-to/test-an-application.md | 19 ++-- docs/reference/amqp.md | 6 +- docs/reference/temporal.md | 15 +++- examples/order-application/README.md | 12 +-- examples/order-domain/README.md | 2 +- examples/order-infrastructure/README.md | 2 +- 17 files changed, 270 insertions(+), 80 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 0a2a0742..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 @@ -582,12 +584,28 @@ label=com.btravstack.test-infra)` clears them), and testcontainers' own reuse 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 @@ -1108,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. @@ -1157,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/docs/examples/index.md b/docs/examples/index.md index 1e52c3bf..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 @@ -112,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 90754cd8..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) => { diff --git a/docs/examples/order-application.md b/docs/examples/order-application.md index d9f18610..78db0ac8 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 @@ -75,6 +77,25 @@ 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 @@ -83,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 @@ -142,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 @@ -174,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 @@ -296,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("0199a1e0-0000-7000-8000-000000000001", 1), + ctx + .get(PlaceOrder) + .execute(TenantId("acme"), "0199a1e0-0000-7000-8000-000000000001", 1), ); ``` 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 b3ad8578..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", @@ -273,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(), }, ); @@ -290,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 fda90825..c7063d73 100644 --- a/docs/how-to/log-and-correlate.md +++ b/docs/how-to/log-and-correlate.md @@ -45,36 +45,45 @@ 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: "0199a1e0-0000-7000-8000-000000000001"` finds one. A rendered sentence — @@ -111,6 +120,7 @@ call, so one application-scope logger is correct for every request: ```json { + "tenantId": "0199a1e0-0000-7000-8000-0000000000ff", "orderId": "0199a1e0-0000-7000-8000-000000000001", "quantity": 2, "time": "2026-08-16T09:41:02.113Z", diff --git a/docs/how-to/read-the-ambient-unit.md b/docs/how-to/read-the-ambient-unit.md index 20544fde..dafdd27b 100644 --- a/docs/how-to/read-the-ambient-unit.md +++ b/docs/how-to/read-the-ambient-unit.md @@ -224,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( @@ -237,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 6b74c590..5f82c1cc 100644 --- a/docs/how-to/run-a-temporal-worker.md +++ b/docs/how-to/run-a-temporal-worker.md @@ -47,18 +47,25 @@ 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 @@ -91,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), ), @@ -118,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 7825ee56..54794bce 100644 --- a/docs/how-to/serve-orpc-over-http.md +++ b/docs/how-to/serve-orpc-over-http.md @@ -100,9 +100,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) 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/test-an-application.md b/docs/how-to/test-an-application.md index ec02f195..04be53a0 100644 --- a/docs/how-to/test-an-application.md +++ b/docs/how-to/test-an-application.md @@ -101,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); @@ -111,7 +114,7 @@ it("broadcasts every committed write, end to end", async ({ serve }) => { // THEN it is the very instance the relay sweeps, so the fact crosses the // outbox, the broker and the queue await expect( - placeOrder.execute("0199a1e0-0000-7000-8000-000000000001", 2), + placeOrder.execute(tenant, "0199a1e0-0000-7000-8000-000000000001", 2), ).toBeOkWith( expect.objectContaining({ id: "0199a1e0-0000-7000-8000-000000000001" }), ); @@ -358,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())); }, }); @@ -386,7 +389,13 @@ it("reads back only its own tenant's order", async ({ }); ``` -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/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/temporal.md b/docs/reference/temporal.md index d8fbf4d7..406f13ee 100644 --- a/docs/reference/temporal.md +++ b/docs/reference/temporal.md @@ -132,7 +132,7 @@ 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 @@ -165,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), ), @@ -192,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. @@ -259,7 +268,7 @@ 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 diff --git a/examples/order-application/README.md b/examples/order-application/README.md index 27ae82c0..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: "0199a1e0-0000-7000-8000-000000000001", 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-domain/README.md b/examples/order-domain/README.md index 22364fae..d5d2e2c8 100644 --- a/examples/order-domain/README.md +++ b/examples/order-domain/README.md @@ -171,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-infrastructure/README.md b/examples/order-infrastructure/README.md index 7b7d47ad..ad70fb58 100644 --- a/examples/order-infrastructure/README.md +++ b/examples/order-infrastructure/README.md @@ -215,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 ``` From 17de1f18ab95e45599caec571ab35d3eeb7f1288 Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Fri, 21 Aug 2026 17:26:40 +0200 Subject: [PATCH 10/11] fix(docs): two samples still declare di deps positionally Dead since 5807214 landed the keyed form on main; neither compiles today. Both replacements compiled in scratch files inside order-application and order-temporal-worker. Not an id or tenant matter -- a separate commit so it stays reviewable on its own. --- docs/examples/order-application.md | 2 +- docs/tutorial/second-runtime.md | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/examples/order-application.md b/docs/examples/order-application.md index 78db0ac8..afe97c1b 100644 --- a/docs/examples/order-application.md +++ b/docs/examples/order-application.md @@ -145,7 +145,7 @@ arm: ```ts export const placeOrderProvider = Provider(PlaceOrder)( - [OrderRepository, Logger], + { repository: OrderRepository, logger: Logger }, { class: PlaceOrderInteractor, }, 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) }), }, From 3098604b78d7605f9571de344373f2b83ff61f29 Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Fri, 21 Aug 2026 18:33:31 +0200 Subject: [PATCH 11/11] fix(docs): four contracts still say BAD_REQUEST carries a UUIDv7 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The UUIDv7 sweep missed docs/index.md, protect-a-procedure, split-a-router-into-controllers and serve-orpc-over-http: each still declared its ids as z.string(), which left BAD_REQUEST sharing orderRef and hid why the example contract gives that one error a schema of its own. orderRef is a UUIDv7 in all four now, so malformedRef has to exist: the id it names is the value that failed the format, and validating it against the ref would reject the only payload the error is ever constructed with. Found by review on #84; these are the pages docs-examples.test-d.ts deliberately does not compile — the contracts are order-api-contract's dependency, not order-api's — so nothing but a reader was going to catch it. --- docs/how-to/protect-a-procedure.md | 14 +++++++++----- docs/how-to/serve-orpc-over-http.md | 12 ++++++++---- docs/how-to/split-a-router-into-controllers.md | 18 +++++++++++------- docs/index.md | 11 +++++++---- 4 files changed, 35 insertions(+), 20 deletions(-) diff --git a/docs/how-to/protect-a-procedure.md b/docs/how-to/protect-a-procedure.md index b09fe66b..a14fbb4f 100644 --- a/docs/how-to/protect-a-procedure.md +++ b/docs/how-to/protect-a-procedure.md @@ -40,22 +40,26 @@ import { authenticated } from "@btravstack/contract"; import { oc } from "@orpc/contract"; import { z } from "zod"; -const orderRef = z.object({ id: z.string() }); +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: 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() })), }; diff --git a/docs/how-to/serve-orpc-over-http.md b/docs/how-to/serve-orpc-over-http.md index 54794bce..0d7e3231 100644 --- a/docs/how-to/serve-orpc-over-http.md +++ b/docs/how-to/serve-orpc-over-http.md @@ -43,19 +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: orderRef }, + BAD_REQUEST: { data: malformedRef }, CONFLICT: { data: orderRef }, }), find: oc diff --git a/docs/how-to/split-a-router-into-controllers.md b/docs/how-to/split-a-router-into-controllers.md index 354859f2..43683890 100644 --- a/docs/how-to/split-a-router-into-controllers.md +++ b/docs/how-to/split-a-router-into-controllers.md @@ -29,27 +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: orderRef }, + BAD_REQUEST: { data: malformedRef }, CONFLICT: { data: orderRef }, }), find: oc @@ -60,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 } }), }; diff --git a/docs/index.md b/docs/index.md index 2e1f73c1..5a654d47 100644 --- a/docs/index.md +++ b/docs/index.md @@ -52,18 +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: orderRef }, + BAD_REQUEST: { data: malformedRef }, CONFLICT: { data: orderRef }, }), });