diff --git a/CLAUDE.md b/CLAUDE.md index 6a48737..1ae1145 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,9 +10,10 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co fallible operation returns an `unthrown` `Result` instead of throwing. -pnpm + turbo monorepo with two workspaces: the package, `packages/entity`, and -the documentation site, `docs`. Root scripts delegate to turbo; workspace -scripts are where the real commands live. +pnpm + turbo monorepo with five workspaces: the package, `packages/entity`; the +documentation site, `docs`; and three example packages under `examples/`, which +document the library and double as its declaration-emit fixtures. Root scripts +delegate to turbo; workspace scripts are where the real commands live. ## Commands @@ -20,14 +21,24 @@ Scripts are in `package.json`; the root ones delegate to turbo. Three things that are not derivable from there: - **The gate CI runs, in order**: `format --check`, `lint`, `typecheck`, - `test`, `knip`, `build`. `typecheck` is four passes — the main `tsc`, the - `.test-d.ts` pass, and the consumer declaration-emit pass run **twice**: once - on the repo's TypeScript (7.0.2, the native port) and once on 5.9.3 through - the `typescript-consumer` alias. The second is not redundant. The native port - does not enforce the 5.x ceiling on serialised type length, so `TS7056` is - invisible to it — that gap shipped two declaration-emit bugs to a consumer - (#31, #32) while this repo's own gate stayed green. Consumers build with 5.x; - the gate has to as well. + `test`, `knip`, `build`. `typecheck` spans two workspaces: + `packages/entity` runs the main `tsc` plus the `.test-d.ts` pass, and + `examples/billing-domain` compiles **its own declarations twice** — once on + the repo's TypeScript (7.0.2) and once on 5.9.3 through the + `typescript-consumer` alias. That example is the fixture proving a downstream + library can build against this package; it is not decoration. + The second compiler is not redundant, though the reason is narrower than it + looks. **Both versions enforce `TS7056`; 5.9.3's threshold is simply lower.** + Measured on one entity carrying a 30-member enum, a branded timestamp and a + six-member literal union: 5.9.3 reported `TS7056`, 7.0.2 accepted the same + shape and reported only `TS4020`. Widen the entity and both report it. So a + band of realistic domain widths fails for consumers and passes here — + which is the band issues #31 and #32 shipped through. +- **A `paths` mapping is not how the emit fixture resolves the package.** + `examples/billing-domain` depends on `@btravstack/entity` as `workspace:*` + and reaches `dist/index.d.mts` through its real `exports`, the way an actual + consumer does. The deleted `packages/entity/consumer/` faked that with + `paths`. - **A single test file runs from inside `packages/entity`**, not the root. - **The docs site runs from inside `docs`**: `pnpm --filter ./docs dev`. `pnpm build` at the root builds it too, since it is a workspace. diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index f9912bc..75a4b8e 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -50,6 +50,18 @@ const GUIDE_SIDEBAR = [ }, ]; +// The runnable packages under `examples/`. Unlike every fenced block in the +// guide, that code compiles and its specs run in CI. +const EXAMPLES_SECTION = { + text: "Examples", + items: [ + { text: "Overview", link: "/examples/" }, + { text: "Billing domain", link: "/examples/billing-domain" }, + { text: "HTTP contract", link: "/examples/billing-api" }, + { text: "Persistence", link: "/examples/billing-persistence" }, + ], +}; + // https://vitepress.dev/reference/site-config export default defineConfig({ title: "entity", @@ -125,6 +137,7 @@ export default defineConfig({ { text: "Explanation", link: "/explanation/why-entity" }, ], }, + { text: "Examples", link: "/examples/" }, { text: "API", link: "/api/" }, { text: "Changelog", @@ -143,6 +156,10 @@ export default defineConfig({ GUIDE_SIDEBAR, ]), ), + // The examples carry the guide sidebar too, with their own section on + // top: they are walkthroughs of the same material, so a reader landing on + // one should still reach every page of the guide. + "/examples/": [EXAMPLES_SECTION, ...GUIDE_SIDEBAR], "/api/": [ { text: "API Reference", diff --git a/docs/examples/billing-api.md b/docs/examples/billing-api.md new file mode 100644 index 0000000..8986862 --- /dev/null +++ b/docs/examples/billing-api.md @@ -0,0 +1,85 @@ +--- +title: HTTP contract example +description: Composing an entity's four plain ZodObjects into an oRPC contract and JSON Schema, with no hand-written omit lists. +--- + +# HTTP contract + +[`examples/billing-api`](https://github.com/btravstack/entity/tree/main/examples/billing-api) +— turning an entity into request and response schemas for routes. + +```sh +pnpm --filter @btravstack/entity-example-billing-api test +``` + +## The rule the package turns on + +> **Contracts compose the four plain `ZodObject`s; domain code composes the +> class.** + +```ts +export const CreateOrganizationBody = Organization.createInput; +export const UpdateOrganizationBody = Organization.updateInput; +export const OrganizationResponse = Organization.output; +``` + +There is nothing to maintain here. `createInput` is the field map minus whatever +the entity declares `generated`; `updateInput` is it minus `immutable` and minus +the computed fields, every remaining key optional. Add a generated field to the +entity and the create body follows on its own — that is the omit list nobody had +to write, and the spec asserts it by checking the generated JSON Schema has +exactly `name` and `slug`. + +They are ordinary `ZodObject`s, so the usual combinators work: + +```ts +export const OrganizationSummary = Organization.output.pick({ + id: true, + slug: true, +}); +export const OrganizationListing = z.object({ + items: z.array(Organization.output), + total: z.number().int(), +}); +``` + +## Both directions + +```ts +const converter = new ZodToJsonSchemaConverter(); +converter.convert(Organization.createInput, "input"); +converter.convert(Organization.output, "output"); +``` + +Or through zod directly, with `z.toJSONSchema(…, { io: "input" | "output" })`. + +## And the class, deliberately, does not + +```ts +z.toJSONSchema(Organization, { io: "output" }); // throws, by design +``` + +The class carries a `.transform()` — it parses to an _instance_, not to plain +data — and a transforming schema has no output representation. That is the whole +reason the four plain `ZodObject`s exist separately, and the example's spec pins +it in both directions: the four convert, the class throws. + +## One detail worth copying + +The JSON Schema exports carry an explicit `JsonSchema` annotation: + +```ts +export const createOrganizationSchema: JsonSchema = jsonSchemaOf( + CreateOrganizationBody, + "input", +); +``` + +Without it TypeScript infers a type it cannot **name** from outside the package, +and any consumer emitting declarations fails with `TS2883` — _"cannot be named +without a reference to 'JsonSchema' … this is likely not portable"_. It is the +same class of problem as [#31](https://github.com/btravstack/entity/issues/31) +and [#32](https://github.com/btravstack/entity/issues/32), met from the other +side of the boundary, and the cure is the same: give the type a name. + +Related how-to: [Expose an HTTP contract](/how-to/http-contract). diff --git a/docs/examples/billing-domain.md b/docs/examples/billing-domain.md new file mode 100644 index 0000000..2e432d0 --- /dev/null +++ b/docs/examples/billing-domain.md @@ -0,0 +1,149 @@ +--- +title: Billing domain example +description: Declaring entities — branded fields, generated/immutable/computed, invariants, nesting, unions and factories — in a runnable package. +--- + +# Billing domain + +[`examples/billing-domain`](https://github.com/btravstack/entity/tree/main/examples/billing-domain) +— the modelling half: two entities and the vocabulary they are built from. + +```sh +pnpm --filter @btravstack/entity-example-billing-domain test +``` + +## The vocabulary comes first + +```ts +export const OrganizationId = z.uuid().brand("OrganizationId"); +export const Slug = z.string().min(1).max(40).brand("Slug"); +export const Instant = z.iso.datetime().brand("Instant"); +``` + +Every data field is branded, and a bare `z.string()` is a **compile error**. +That is the guard, not an inconvenience: an `OrganizationId` and a `Slug` are +both strings at runtime, and nothing except a brand stops you passing one where +the other belongs. + +`Money` is branded too, but it is an _object_: + +```ts +export const Money = z + .object({ amount: z.number().int(), currency: Currency }) + .brand("Money"); +``` + +A value object — no identity, so it is branded rather than made an entity. +Amounts are integer minor units because binary floats are the wrong tool for +money. Minting one takes `Money.parse({ … })`; a plain object literal does not +satisfy the branded type, which is exactly the point. + +## The entity + +```ts +export class Organization extends Entity("Organization")( + { id: OrganizationId, slug: Slug, name: DisplayName, createdAt: Instant }, + { + generated: ["id", "createdAt"], + immutable: ["id", "createdAt", "slug"], + computed: { + displayLabel: Entity.computed( + DisplayLabel, + (d) => `${d.name} (${d.slug})` as z.infer, + ), + }, + invariants: [ + Entity.invariant( + (d) => d.name.length <= 80, + "name must be at most 80 characters", + ), + ], + }, +) { + get isSelfTitled(): boolean { + return this.name.toLowerCase().startsWith(this.slug.toLowerCase()); + } +} +``` + +`generated` names what the domain produces rather than the caller, so those +fields drop out of `createInput`. `immutable` names what `update` refuses. +`computed` is re-derived on **every** construction path, so it cannot drift from +its sources — the spec checks that by renaming an organization and asserting the +label followed. + +Behaviour lives in the class body. This is a real class, not a record with +functions bolted beside it. + +## Nesting, and the factory + +`Invoice.issuedTo` is an `Organization` used directly as a field. The class is +itself a zod schema, so it parses back to a real instance: + +```ts +const rehydrated = Invoice.make(invoice.toJSON()).getOrThrow(); +rehydrated.issuedTo instanceof Organization; // true +``` + +The package reads no clock and generates no id, so a factory is where those come +in — bound once, at the composition root: + +```ts +export const createOrganization = Organization.factory({ + id: () => crypto.randomUUID() as z.infer, + createdAt: () => new Date().toISOString() as z.infer, +}); +``` + +That is what leaves the entities trivially testable: nothing inside them reaches +for ambient state. + +## Two things in this package that look odd on purpose + +**`DunningReason` has thirty members.** Vocabularies that wide are ordinary in +billing, and this one is held at full width because it pins +[#31](https://github.com/btravstack/entity/issues/31). `TS7056` is a threshold +on serialised _characters_, so trimming the enum puts the example back under the +ceiling, where it compiles and guards nothing. + +**`src/emit-guards.ts` is not example code.** It carries the assertions that +have no runtime moment — construction staying sealed, a construction key that +cannot be forged structurally, every `Entity.*` namespace member named so +declaration emit walks it. An **unused** `@ts-expect-error` in that file is a +failure rather than noise, because a namespace member emitted as a circular +self-alias still compiles and simply degenerates. + +## The union discriminates data, not instances + +```ts +export const BillingDocument = Entity.union("kind", [ + Invoice, + CreditNote, +] as const); +``` + +`kind` is a **declared domain field** — `z.literal("INVOICE")` on one member and +`z.literal("CREDIT_NOTE")` on the other, both `generated` so no caller can supply +the wrong one. + +It is tempting to reach for `_tag` here, since every entity has one. That does +not work, and fails quietly rather than loudly: `_tag` is non-enumerable, so it +is absent from `toJSON()` and from anything that has been through JSON. A union +built on it registers no members and rejects every payload with + +``` +Invalid discriminant undefined; expected one of +``` + +— an empty set. This example shipped that exact bug for one commit, because the +spec never called `make()` through the union. The specs now do, which is the +only reason it is not still there. + +The two mechanisms are complementary, not alternatives: + +| | Discriminates | Use | +| ---------------- | -------------------------------------- | ------------------------- | +| A declared field | **data** arriving from a wire or a row | `Entity.union("kind", …)` | +| `_tag` | an **instance** you already hold | `P.tag("Invoice")` | + +Related reference: [Declaring an entity](/reference/declaration). diff --git a/docs/examples/billing-persistence.md b/docs/examples/billing-persistence.md new file mode 100644 index 0000000..782acbe --- /dev/null +++ b/docs/examples/billing-persistence.md @@ -0,0 +1,77 @@ +--- +title: Persistence example +description: Storing and rehydrating entities — toJSON() out, make() back, with a corrupt row arriving as a Result rather than a throw. +--- + +# Persistence + +[`examples/billing-persistence`](https://github.com/btravstack/entity/tree/main/examples/billing-persistence) +— an entity out to a row, and a row back to an entity. + +```sh +pnpm --filter @btravstack/entity-example-billing-persistence test +``` + +## The round trip + +```ts +save(organization: Organization): void { + this.#rows.set(organization.id, organization.toJSON()); +} +``` + +`toJSON()` is the only projection the package offers, and it **is** the stored +shape. No mapper to keep in sync, and `_tag` never reaches a row — it is a +non-enumerable instance property, so it survives neither `JSON.stringify` nor a +spread. The spec asserts that explicitly, because it is the sort of thing that +starts leaking quietly. + +```ts +byId(id): Result { + const row = this.#rows.get(id); + if (row === undefined) return Err(new OrganizationNotFound()); + return Organization.make(row); +} +``` + +`make()` validates on the way in. Rows outlive models — a column dropped two +migrations ago is still sitting in production — so the boundary where old data +becomes a live object is exactly where a check belongs. + +What comes back is a real instance, behaviour included, not a bag of data. The +spec checks that by calling a getter on a rehydrated entity. + +## Two errors, not one + +A missing row and a **corrupt** row are different facts: the first is a 404, the +second is data worth paging someone about. Folding them into one error discards +the only thing that separates them. + +The library defines no `NotFound` on purpose — whether an absent row is +exceptional is a repository's decision, not an entity's — so the example models +it with `unthrown`'s `TaggedError` and discriminates the two with an exhaustive +matcher: + +```ts +loaded.match({ + ok: (organization) => organization, + errCases: (m) => + m + .with(P.tag("InvalidEntity"), () => 422) + .with(P.tag("OrganizationNotFound"), () => 404), + defect: () => 500, +}); +``` + +Because the matcher is exhaustive, the day this repository grows a third error +those call sites stop compiling until someone decides what to do about it. + +There is no `try`/`catch` anywhere in the file. + +## Swapping the store + +The store is a `Map`. Replace it with a driver and nothing else in the file +changes shape — which is the point of the entity knowing nothing about +persistence in the first place. + +Related how-to: [Persist and rehydrate](/how-to/persist-and-rehydrate). diff --git a/docs/examples/index.md b/docs/examples/index.md new file mode 100644 index 0000000..2b053d3 --- /dev/null +++ b/docs/examples/index.md @@ -0,0 +1,54 @@ +--- +title: Examples +description: Three small packages modelling one billing domain — code that compiles and is covered by tests, unlike the snippets in the guide. +--- + +# Examples + +Annotated tours of the runnable packages under +[`examples/`](https://github.com/btravstack/entity/tree/main/examples). They +model one small billing domain between them, each showing a different job. + +**Unlike the snippets elsewhere in this guide, this code compiles and is covered +by tests.** One of the three goes further: `billing-domain` is the fixture +proving a downstream library can emit its own declarations against this package. + +Nothing needs installing and nothing needs to be listening: + +```sh +pnpm install +pnpm test +``` + +## [Billing domain](/examples/billing-domain) + +Declaring the entities: branded fields, `generated` / `immutable` / `computed`, +invariants as values, one entity nested inside another, a discriminated union, +and factories binding the id and clock the package refuses to read for itself. + +## [HTTP contract](/examples/billing-api) + +The four plain `ZodObject`s composed into an oRPC contract and converted to JSON +Schema in both directions — and the class deliberately refusing to convert, +because it parses to an instance rather than to data. + +## [Persistence](/examples/billing-persistence) + +`toJSON()` out, `make()` back, over an in-memory store: the round trip, the +absent `_tag`, and a corrupt row arriving as a `Result` rather than a throw. + +## Why these exist as packages rather than snippets + +Every fenced block in the rest of this guide is written by hand. It is checked +by review and nothing else, so it can drift from the library without any build +noticing. + +These three cannot. They are workspace packages: they typecheck, their specs +run in CI, and they consume `@btravstack/entity` through its real published +entry point rather than a path alias. If the library changes underneath them, +something goes red. + +That property is not theoretical. Writing `billing-domain` immediately surfaced +a declaration-emit bug — an exported `const` holding `Entity.union(...)` failed +with `TS4023` — that the package's own test suite could not see, because +`vitest` never typechecks and the bug lived only in emitted declarations. diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..a40a9fa --- /dev/null +++ b/examples/README.md @@ -0,0 +1,46 @@ +# Examples + +Three small packages modelling one billing domain, each showing a different job +`@btravstack/entity` does. + +📖 **[Annotated walkthroughs →](https://btravstack.github.io/entity/examples/)** + +| Package | Shows | +| ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | +| [`billing-domain`](./billing-domain) | Declaring entities: branded fields, `generated` / `immutable` / `computed`, invariants, nesting, unions, factories. | +| [`billing-api`](./billing-api) | Composing the four plain `ZodObject`s into an HTTP contract and JSON Schema. | +| [`billing-persistence`](./billing-persistence) | Storing and rehydrating: `toJSON()` out, `make()` back. | + +Unlike the snippets in the guide, **this code compiles and is covered by +tests**. There is no broker, no database and no server to start: + +```sh +pnpm install +pnpm test # every example's specs +pnpm typecheck # includes the declaration-emit passes +``` + +## Why `billing-domain` compiles its own declarations + +It is not only an example. It is also the fixture proving a **downstream library +can build against this package** — the case where a consumer sets +`declaration: true` and TypeScript has to write entity's types into its own +`.d.ts`. + +That pass runs twice, on TypeScript 7.0.2 and 5.9.3, and the second one earns +its place: **both enforce the ceiling on serialised type length, but 5.9.3's is +lower.** Measured on one entity carrying a 30-member enum, a branded timestamp +and a six-member literal union — 5.9.3 rejected it with `TS7056` while 7.0.2 +accepted the same shape. Widen the entity and both reject it. + +So there is a band of perfectly realistic domain widths that fails for a +consumer on 5.x and passes on the version this repo builds with. Two +declaration-emit bugs shipped through that band ([#31], [#32]) while every other +check stayed green. + +`billing-domain/src/emit-guards.ts` carries the assertions that have no runtime +moment — the construction seal, a forged construction key, every namespace +member. It is a test, not a pattern to copy, and it says so. + +[#31]: https://github.com/btravstack/entity/issues/31 +[#32]: https://github.com/btravstack/entity/issues/32 diff --git a/examples/billing-api/README.md b/examples/billing-api/README.md new file mode 100644 index 0000000..b712b28 --- /dev/null +++ b/examples/billing-api/README.md @@ -0,0 +1,50 @@ +# billing-api + +The contract half: turning an entity into request and response schemas for +routes, without hand-writing omit lists that drift from the model. + +```sh +pnpm --filter @btravstack/entity-example-billing-api test +``` + +## The rule + +> **Contracts compose the four plain `ZodObject`s; domain code composes the +> class.** + +`Organization.createInput`, `.updateInput`, `.input` and `.output` are ordinary +`ZodObject`s derived from one field map, so: + +- they convert to JSON Schema in **both** directions, +- the usual combinators work on them (`.pick`, nesting in `z.object`, arrays), +- nothing restates the shape of an `Organization` anywhere in this package. + +`createInput` is the field map minus whatever the entity declares `generated`; +`updateInput` is it minus `immutable` and minus the computed fields, with every +remaining key optional. Add a generated field to the entity and the create body +follows on its own — that is the omit list you did not have to write. + +The class itself deliberately **does not** convert: + +```ts +z.toJSONSchema(Organization, { io: "output" }); // throws, by design +``` + +It carries a `.transform()` — it parses to an _instance_, not to plain data — +and a transforming schema has no output representation. That is the reason the +four plain `ZodObject`s exist separately, and the spec pins it both ways. + +See also the how-to: [Expose an HTTP +contract](https://btravstack.github.io/entity/how-to/http-contract). + +## One thing worth copying + +The JSON Schema exports carry an explicit `JsonSchema` annotation. That is not +style. Without it TypeScript infers a type it cannot _name_ from outside the +package, and any consumer emitting declarations fails with `TS2883` — "cannot +be named without a reference to 'JsonSchema' … this is likely not portable". +It is the same class of problem as [#31] and [#32], met from the other side, +and the cure is the same: give the type a name. + +[#31]: https://github.com/btravstack/entity/issues/31 +[#32]: https://github.com/btravstack/entity/issues/32 diff --git a/examples/billing-api/package.json b/examples/billing-api/package.json new file mode 100644 index 0000000..50b6425 --- /dev/null +++ b/examples/billing-api/package.json @@ -0,0 +1,30 @@ +{ + "name": "@btravstack/entity-example-billing-api", + "private": true, + "description": "Composing an entity's four ZodObjects into an HTTP contract", + "license": "MIT", + "author": "Benoit TRAVERS ", + "type": "module", + "main": "./src/index.ts", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "test": "vitest run", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@btravstack/entity-example-billing-domain": "workspace:*", + "@orpc/contract": "catalog:", + "@orpc/json-schema": "catalog:", + "@orpc/zod": "catalog:", + "zod": "catalog:" + }, + "devDependencies": { + "@btravstack/tsconfig": "catalog:", + "@types/node": "catalog:", + "@unthrown/vitest": "catalog:", + "typescript": "catalog:", + "vitest": "catalog:" + } +} diff --git a/examples/billing-api/src/index.spec.ts b/examples/billing-api/src/index.spec.ts new file mode 100644 index 0000000..2a95507 --- /dev/null +++ b/examples/billing-api/src/index.spec.ts @@ -0,0 +1,46 @@ +import { Organization } from "@btravstack/entity-example-billing-domain"; +import { expect, test } from "vitest"; +import { z } from "zod"; + +import { + createOrganizationSchema, + organizationContract, + organizationResponseSchema, +} from "./index.js"; + +const propertiesOf = (schema: unknown) => + Object.keys((schema as { properties: Record }).properties).sort(); + +test("the create body drops whatever the domain generates", () => { + // `id` and `createdAt` are `generated`, so no caller ever supplies them — + // and nobody had to write an omit list saying so. + expect(propertiesOf(createOrganizationSchema)).toEqual(["name", "slug"]); +}); + +test("the response body is the stored shape, computed field included", () => { + expect(propertiesOf(organizationResponseSchema)).toEqual([ + "createdAt", + "displayLabel", + "id", + "name", + "slug", + ]); +}); + +test("the four ZodObjects convert in both directions", () => { + expect(() => z.toJSONSchema(Organization.createInput, { io: "input" })).not.toThrow(); + expect(() => z.toJSONSchema(Organization.updateInput, { io: "input" })).not.toThrow(); + expect(() => z.toJSONSchema(Organization.input, { io: "input" })).not.toThrow(); + expect(() => z.toJSONSchema(Organization.output, { io: "output" })).not.toThrow(); +}); + +test("the class itself does not convert — and that is the design", () => { + // It carries a .transform(): it parses to an *instance*, not to plain data, + // and a transforming schema has no output representation. That is exactly + // why the four plain ZodObjects exist separately. + expect(() => z.toJSONSchema(Organization, { io: "output" })).toThrow(); +}); + +test("the contract exposes one procedure per route", () => { + expect(Object.keys(organizationContract).sort()).toEqual(["create", "list", "update"]); +}); diff --git a/examples/billing-api/src/index.ts b/examples/billing-api/src/index.ts new file mode 100644 index 0000000..aef44fb --- /dev/null +++ b/examples/billing-api/src/index.ts @@ -0,0 +1,62 @@ +/** + * The rule this package exists to show: + * **contracts compose the four plain `ZodObject`s; domain code composes the class.** + * + * `Organization.createInput` / `.updateInput` / `.input` / `.output` are + * ordinary `ZodObject`s derived from one field map, so they convert to JSON + * Schema in both directions and need no hand-written omit lists. The class + * itself deliberately does not convert — it parses to an *instance*, and a + * transforming schema has no output representation. + */ +import { Organization } from "@btravstack/entity-example-billing-domain"; +import { oc } from "@orpc/contract"; +import type { JsonSchema } from "@orpc/json-schema"; +import { ZodToJsonSchemaConverter } from "@orpc/zod"; +import { z } from "zod"; + +/* ── The request and response shapes ─────────────────────────────────── + Nothing to maintain here. `createInput` is the field map minus whatever + the entity declares `generated`; `updateInput` is it minus `immutable` + and minus the computed fields, every remaining key optional. Add a + generated field to the entity and the create body follows on its own. */ + +export const CreateOrganizationBody = Organization.createInput; +export const UpdateOrganizationBody = Organization.updateInput; +export const OrganizationResponse = Organization.output; + +/** They are ordinary `ZodObject`s, so the usual combinators work. */ +export const OrganizationSummary = Organization.output.pick({ id: true, slug: true }); +export const OrganizationListing = z.object({ + items: z.array(Organization.output), + total: z.number().int(), +}); + +/* ── JSON Schema, in both directions ─────────────────────────────────── + `convert` returns `[jsonSchema, optional]`. The explicit `JsonSchema` + annotation is load bearing rather than decorative: without it TypeScript + infers a type it cannot *name* from outside this package, and any consumer + emitting declarations fails with `TS2883` — "cannot be named without a + reference to 'JsonSchema' … this is likely not portable". Exactly the class + of problem behind issues #31 and #32, met from the other side; the cure + here is simply to name the type. */ + +const converter = new ZodToJsonSchemaConverter(); + +const jsonSchemaOf = ( + schema: Parameters[0], + direction: "input" | "output", +): JsonSchema => converter.convert(schema, direction)[0]; + +export const createOrganizationSchema: JsonSchema = jsonSchemaOf(CreateOrganizationBody, "input"); +export const updateOrganizationSchema: JsonSchema = jsonSchemaOf(UpdateOrganizationBody, "input"); +export const organizationResponseSchema: JsonSchema = jsonSchemaOf(OrganizationResponse, "output"); + +/* ── The contract ────────────────────────────────────────────────────── + An oRPC procedure per route, each one taking the entity's own schemas. + Nothing here restates the shape of an Organization. */ + +export const organizationContract = { + create: oc.input(CreateOrganizationBody).output(OrganizationResponse), + update: oc.input(UpdateOrganizationBody).output(OrganizationResponse), + list: oc.output(OrganizationListing), +}; diff --git a/examples/billing-api/tsconfig.json b/examples/billing-api/tsconfig.json new file mode 100644 index 0000000..7fa4349 --- /dev/null +++ b/examples/billing-api/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "@btravstack/tsconfig/base.json", + "compilerOptions": { + "rootDir": "./src", + "types": ["node"] + }, + "include": ["src/**/*"] +} diff --git a/examples/billing-api/vitest.config.ts b/examples/billing-api/vitest.config.ts new file mode 100644 index 0000000..fb76260 --- /dev/null +++ b/examples/billing-api/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + include: ["src/**/*.spec.ts"], + setupFiles: ["@unthrown/vitest"], + }, +}); diff --git a/examples/billing-domain/README.md b/examples/billing-domain/README.md new file mode 100644 index 0000000..8c9700b --- /dev/null +++ b/examples/billing-domain/README.md @@ -0,0 +1,52 @@ +# billing-domain + +The modelling half of the example: two entities and the vocabulary they are +built from. + +```sh +pnpm --filter @btravstack/entity-example-billing-domain test +pnpm --filter @btravstack/entity-example-billing-domain typecheck +``` + +## What it shows + +`src/index.ts`, top to bottom: + +- **Branded fields.** Every data field is branded, so an `OrganizationId` and a + `Slug` stop being interchangeable strings. A bare `z.string()` is a compile + error — that is the guard, not an inconvenience. +- **`Money` as a branded object.** A value object with no identity: branded + rather than made an entity. Amounts are integer minor units, because binary + floats are the wrong tool for money. +- **`generated` / `immutable` / `computed`.** `generated` drops fields out of + `createInput`; `immutable` is what `update` refuses; `computed` is re-derived + on every construction path, so it cannot drift from its sources. +- **Invariants as values.** A broken rule comes back as an `InvalidEntity` + `Result`, never an exception. +- **Nesting.** `Invoice.issuedTo` is an `Organization` — the class is itself a + zod schema, so it parses back to a real instance with its behaviour intact. +- **A union** over `Invoice` and `CreditNote`, dispatching on `kind` — a + _declared_ field, never `_tag`. `_tag` is non-enumerable and absent from + `toJSON()`, so a union built on it matches nothing and says so with an empty + `expected one of` set. This package shipped that bug for exactly one commit; + the specs now call `make()` through the union, which is what would have caught + it. +- **Factories.** The package reads no clock and generates no id; a factory is + where those come in, bound once. That is what leaves the entities trivially + testable. + +## Two things that look odd on purpose + +**`DunningReason` has thirty members.** Vocabularies that wide are ordinary in +billing, and this one is held at full width because it pins [#31]: `TS7056` is a +threshold on serialised _characters_, so trimming it puts the example back under +the ceiling where it compiles and guards nothing. + +**`src/emit-guards.ts` is not example code.** It holds compile-time assertions +that have no runtime moment — construction staying sealed, a construction key +that cannot be forged, every `Entity.*` namespace member named so declaration +emit walks it. Its header explains the rules; the short version is that an +**unused** `@ts-expect-error` in that file is a failure, not noise. Do not copy +anything out of it. + +[#31]: https://github.com/btravstack/entity/issues/31 diff --git a/examples/billing-domain/package.json b/examples/billing-domain/package.json new file mode 100644 index 0000000..9e3c160 --- /dev/null +++ b/examples/billing-domain/package.json @@ -0,0 +1,30 @@ +{ + "name": "@btravstack/entity-example-billing-domain", + "private": true, + "description": "A small billing domain, modelled with @btravstack/entity", + "license": "MIT", + "author": "Benoit TRAVERS ", + "type": "module", + "main": "./src/index.ts", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "test": "vitest run", + "typecheck": "tsc --noEmit && tsc -p tsconfig.emit.json && node ./node_modules/typescript-consumer/bin/tsc -p tsconfig.emit.json" + }, + "dependencies": { + "@btravstack/entity": "workspace:*", + "unthrown": "catalog:", + "zod": "catalog:" + }, + "devDependencies": { + "@btravstack/tsconfig": "catalog:", + "@types/node": "catalog:", + "@unthrown/standard-schema": "catalog:", + "@unthrown/vitest": "catalog:", + "typescript": "catalog:", + "typescript-consumer": "catalog:", + "vitest": "catalog:" + } +} diff --git a/examples/billing-domain/src/emit-guards.ts b/examples/billing-domain/src/emit-guards.ts new file mode 100644 index 0000000..6ef249d --- /dev/null +++ b/examples/billing-domain/src/emit-guards.ts @@ -0,0 +1,78 @@ +/** + * NOT example code. Do not copy anything out of this file. + * + * It is a compile-time test that happens to live beside an example, because + * what it tests *is* what an example is: a downstream package that uses the + * library **and emits its own declarations**. It replaced + * `packages/entity/consumer/`. + * + * Two rules that are easy to destroy by tidying: + * + * 1. **An unused `@ts-expect-error` here is a failure, not noise.** A + * namespace member emitted as a circular self-alias still *compiles*; the + * type simply degenerates, and a directive going unused is the only + * signal. Every member of `Entity` is therefore named below, so + * declaration emit has to walk each one. + * + * 2. **The widths in `index.ts` are load bearing.** `TS7056` is a threshold on + * serialised *characters*, so `Invoice` needs its full dunning vocabulary, + * its branded timestamp and its six-member level union to stay above it. + * Measured: trimming them put the old fixture back under the ceiling, + * where it compiled happily and guarded nothing. + * + * What went wrong when nothing checked this: `EntityStatic` was unexported, so + * TypeScript had no name to write for the builder's return type and serialised + * the entire static surface into every consumer's `.d.ts` — a one-field entity + * emitted 274,048 bytes, against 240 now. A wide enum then crossed the ceiling + * (`TS7056`, #31) and a branded object reached zod's module-private `$brand` + * through `DeepReadonly` (`TS4020`, #32). `Entity.union` had the same problem + * one type further along (`TS4023`), found while writing this example. + */ +import { Entity } from "@btravstack/entity"; +import type { z } from "zod"; + +// `Organization` is imported as a value: the sealed-construction assertion +// below needs the runtime binding to write `new Organization(...)` at all. +import { Organization } from "./index.js"; +import type { CreditNote, DisplayLabel, Invoice, Slug } from "./index.js"; + +/* ── Construction stays sealed from outside the package ───────────────── */ + +// @ts-expect-error `new` is a compile error: every instance comes through make, update or a factory +void new Organization({ id: "x" as never, slug: "y" as never }); + +// @ts-expect-error the construction key cannot be forged structurally +const forged: Entity.ConstructionKey = {} as { seal: never }; +void forged; + +/* ── A branded object stays deep-readonly (issue #32) ─────────────────── */ + +export const readTotal = (invoice: Invoice): number => invoice.total.amount; + +export const mutateTotal = (invoice: Invoice): void => { + // @ts-expect-error a branded object's members are readonly all the way down + invoice.total.amount = 1; +}; + +/* ── Every namespace member, named so declaration emit walks it ───────── */ + +export type Row = Entity.Output; +export type Wire = Entity.Input; +export type NewOrg = Entity.CreateInput; +export type OrgPatch = Entity.Patch; +export type Derived = Entity.ComputedField }>; +export type Rule = Entity.Invariant<{ slug: z.infer }>; +export type SealedRow = Entity.Sealed; +export type Base = Entity.BaseInstance<{ slug: typeof Slug }, Record, []>; +export type Static = Entity.Static< + "Organization", + { slug: typeof Slug }, + Record, + [], + [] +>; +export type Members = Entity.Union<"kind", [typeof Invoice, typeof CreditNote]>; + +/** The error is reachable as both a value and a type. */ +export const isInvalid = (error: unknown): error is Entity.InvalidEntity => + error instanceof Entity.InvalidEntity; diff --git a/examples/billing-domain/src/index.spec.ts b/examples/billing-domain/src/index.spec.ts new file mode 100644 index 0000000..ab6ad38 --- /dev/null +++ b/examples/billing-domain/src/index.spec.ts @@ -0,0 +1,141 @@ +import { P } from "unthrown"; +import { expect, test } from "vitest"; + +import { + BillingDocument, + CreditNote, + DisplayName, + Invoice, + InvoiceId, + Money, + Organization, + Slug, + createCreditNote, + createInvoice, + createOrganization, +} from "./index.js"; + +/** + * Every field here is branded, so a plain string or object literal does not + * satisfy its type — that is the whole point of branding. `parse` is how you + * mint one, and it is why these helpers exist rather than inline literals. + * + * Worth knowing while reading: this file passed `vitest` before it typechecked. + * vitest transpiles without checking types, so branding violations are invisible + * to it — which is exactly why this package also compiles its own declarations. + */ +const slug = (value: string) => Slug.parse(value); +const name = (value: string) => DisplayName.parse(value); +const money = (amount: number, currency: "EUR" | "USD" | "GBP") => + Money.parse({ amount, currency }); + +const org = () => createOrganization({ slug: slug("acme"), name: name("Acme SA") }).getOrThrow(); + +const invoice = (total = money(12_00, "EUR")) => + createInvoice({ + issuedTo: org(), + lines: [], + total, + status: "DRAFT", + dunningReasons: [], + level: 0, + }).getOrThrow(); + +test("a factory supplies the generated fields", () => { + const acme = org(); + expect(acme.slug).toBe("acme"); + expect(acme.id).toMatch(/^[0-9a-f-]{36}$/); +}); + +test("a computed field is derived, and re-derived on update", () => { + const acme = org(); + expect(acme.displayLabel).toBe("Acme SA (acme)"); + + const renamed = acme.update({ name: name("Acme SAS") }).getOrThrow(); + expect(renamed.displayLabel).toBe("Acme SAS (acme)"); +}); + +test("an invariant returns an error rather than throwing", () => { + expect(createOrganization({ slug: slug("acme"), name: name("x".repeat(81)) }).isErr()).toBe(true); +}); + +test("toJSON is the stored shape, and never carries _tag", () => { + const stored = org().toJSON(); + + expect(Object.keys(stored).sort()).toEqual(["createdAt", "displayLabel", "id", "name", "slug"]); + expect("_tag" in stored).toBe(false); +}); + +test("update returns a new entity and leaves the original alone", () => { + const acme = org(); + const renamed = acme.update({ name: name("Acme SAS") }).getOrThrow(); + + expect(acme.name).toBe("Acme SA"); + expect(renamed.name).toBe("Acme SAS"); + expect(acme.equals(renamed)).toBe(false); +}); + +test("an entity nests inside another and survives the round trip", () => { + const drafted = invoice(); + expect(drafted.issuedTo).toBeInstanceOf(Organization); + + const rehydrated = Invoice.make(drafted.toJSON()).getOrThrow(); + expect(rehydrated.equals(drafted)).toBe(true); + expect(rehydrated.issuedTo).toBeInstanceOf(Organization); +}); + +test("a branded object field keeps its members", () => { + const drafted = invoice(money(999, "USD")); + + expect(drafted.total.amount).toBe(999); + expect(drafted.total.currency).toBe("USD"); +}); + +test("a malformed row comes back as an error, not an exception", () => { + expect(Organization.make({ slug: "", name: "" }).isErr()).toBe(true); +}); + +/* ── The union dispatches on a DECLARED field, never on `_tag` ────────── + These four are the tests whose absence let a broken union ship: the first + version of this file discriminated on "_tag", which is non-enumerable and + therefore missing from every row, so `make` rejected everything with an + empty "expected one of " set. Nothing noticed, because nothing called it. */ + +test("the union makes the right class from a row", async () => { + const invoiceRow = invoice().toJSON(); + const made = BillingDocument.make(invoiceRow).getOrThrow(); + + expect(made).toBeInstanceOf(Invoice); + expect(made).not.toBeInstanceOf(CreditNote); +}); + +test("the union dispatches to the other member on the other value", () => { + const note = createCreditNote({ + issuedTo: org(), + against: InvoiceId.parse("33333333-3333-4333-8333-333333333333"), + total: money(500, "EUR"), + }).getOrThrow(); + + const made = BillingDocument.make(note.toJSON()).getOrThrow(); + expect(made).toBeInstanceOf(CreditNote); +}); + +test("the discriminant survives toJSON, which is why it is a declared field", () => { + const row = invoice().toJSON(); + + expect(row.kind).toBe("INVOICE"); + // `_tag` does NOT survive — a union built on it could never match a row. + expect("_tag" in row).toBe(false); +}); + +test("an unknown discriminant is a reported error, not a silent miss", async () => { + const message = await BillingDocument.make({ kind: "PROFORMA" }).match({ + ok: () => "ok", + errCases: (m) => m.with(P.tag("InvalidEntity"), (e) => e.issues[0]?.message ?? ""), + defect: () => "defect", + }); + + expect(message).toContain("Invalid discriminant"); + expect(message).toContain('"INVOICE"'); + expect(message).toContain('"CREDIT_NOTE"'); +}); diff --git a/examples/billing-domain/src/index.ts b/examples/billing-domain/src/index.ts new file mode 100644 index 0000000..2e47230 --- /dev/null +++ b/examples/billing-domain/src/index.ts @@ -0,0 +1,212 @@ +/** + * A small billing domain, modelled with `@btravstack/entity`. + * + * Read it top to bottom: the field vocabulary first, then the two entities, + * then the factories binding them to their effect sources. Every shape here is + * one a billing model actually needs — including the two that once broke + * declaration emit for consumers: a branded `Money` object, and a dunning + * vocabulary wide enough to matter. See `emit-guards.ts`. + */ +import { Entity } from "@btravstack/entity"; +import { z } from "zod"; + +/* ── The field vocabulary ────────────────────────────────────────────── + Every data field is branded. A bare `z.string()` is a compile error, and + that is the point: an OrganizationId and a Slug are both strings, and the + model should not let you pass one where the other belongs. */ + +export const OrganizationId = z.uuid().brand("OrganizationId"); +export const InvoiceId = z.uuid().brand("InvoiceId"); +export const CreditNoteId = z.uuid().brand("CreditNoteId"); +export const Slug = z.string().min(1).max(40).brand("Slug"); +export const DisplayName = z.string().min(1).brand("DisplayName"); +export const DisplayLabel = z.string().min(1).brand("DisplayLabel"); +export const Instant = z.iso.datetime().brand("Instant"); +export const LineLabel = z.string().min(1).brand("LineLabel"); + +export const Currency = z.enum(["EUR", "USD", "GBP"]); + +/** + * A value object: no identity, so it is a *branded object* rather than an + * entity. Amounts are integer minor units — `12_00` is €12.00 — because binary + * floats are the wrong tool for money. + */ +export const Money = z.object({ amount: z.number().int(), currency: Currency }).brand("Money"); + +export const LineItem = z + .object({ label: LineLabel, unit: Money, quantity: z.number().int().positive() }) + .brand("LineItem"); + +export const InvoiceStatus = z.enum(["DRAFT", "ISSUED", "PAID", "VOID", "UNCOLLECTIBLE"]); + +/** Escalation step of a dunning run. */ +export const Level = z.union([ + z.literal(0), + z.literal(1), + z.literal(2), + z.literal(3), + z.literal(4), + z.literal(5), +]); + +/** + * Why an invoice entered dunning. Vocabularies this wide are ordinary in + * billing — and this one is kept at full width deliberately, because it is what + * pins issue #31. Read the note in `emit-guards.ts` before trimming it. + */ +export const DunningReason = z.enum([ + "CANCELED_LEASE", + "TENANT_LEAVE_BALANCE_DONE", + "SUBROGATIVE_RECEIPT_TO_BE_SIGNED", + "SUBROGATIVE_RECEIPT_SIGNED", + "NO_RGI_CLAIM", + "VISALE", + "MONTHLY_PAYMENT", + "GROWTH", + "UNIT_SOLD", + "EXPENSE_TRANSFER", + "DECEASED_TENANT", + "DECEASED_COOWNER", + "DISPUTE_CHARGES", + "DISPUTE_REPAIRS", + "OWNER_INSTRUCTIONS_EXCLUDING_GLI", + "CHECK_OR_CASH_NOT_RECORDED", + "AWAITING_CAF_PAYMENT", + "NEW_BUILDING", + "NEW_COOWNER", + "MANAGEMENT_DIFFICULTIES", + "SALE_IN_PROGRESS", + "PROMISE_OF_PAYMENT", + "FALSE_DISTRIBUTIONS", + "INSTITUTIONAL_COOWNER", + "HISTORICAL", + "TENANT_LEAVE_NO_REMINDER", + "EXTERNAL_RGI_DISASTER", + "OVER_INDEBTEDNESS_LEGAL_PROCEEDINGS", + "MEMORANDUM_OF_AGREEMENT", + "MANUAL_EXPENSE_TRANSFER", +]); + +/* ── The entities ──────────────────────────────────────────────────────── */ + +/** + * `generated` names the fields the domain produces rather than the caller, so + * they drop out of `createInput`. `immutable` names the ones `update` refuses. + * `computed` is re-derived on every construction path, so it cannot drift from + * its sources. + */ +export class Organization extends Entity("Organization")( + { id: OrganizationId, slug: Slug, name: DisplayName, createdAt: Instant }, + { + generated: ["id", "createdAt"], + immutable: ["id", "createdAt", "slug"], + computed: { + displayLabel: Entity.computed( + DisplayLabel, + (d) => `${d.name} (${d.slug})` as z.infer, + ), + }, + invariants: [ + Entity.invariant((d) => d.name.length <= 80, "name must be at most 80 characters"), + ], + }, +) { + /** Behaviour goes in the class body — this is a real class. */ + get isSelfTitled(): boolean { + return this.name.toLowerCase().startsWith(this.slug.toLowerCase()); + } +} + +/** + * `issuedTo` is another entity used directly as a field: the class is itself a + * zod schema, so it parses back to a real `Organization`, behaviour and all. + */ +export class Invoice extends Entity("Invoice")( + { + id: InvoiceId, + kind: z.literal("INVOICE"), + issuedTo: Organization, + lines: z.array(LineItem), + total: Money, + status: InvoiceStatus, + dunningReasons: z.array(DunningReason), + level: Level, + issuedAt: Instant, + }, + { + generated: ["id", "issuedAt", "kind"], + immutable: ["id", "issuedAt", "issuedTo", "kind"], + invariants: [ + Entity.invariant((d) => d.total.amount >= 0, "total must not be negative"), + Entity.invariant( + (d) => d.status !== "VOID" || d.dunningReasons.length === 0, + "a void invoice cannot be in dunning", + ), + ], + }, +) { + get isCollectable(): boolean { + return this.status === "ISSUED" || this.status === "DRAFT"; + } +} + +/** + * A credit note is an invoice's sibling, not its subtype: same counterparty and + * money, opposite direction, its own identity. Modelling it as a second entity + * sharing the `kind` discriminant is what lets both travel down one channel and + * come back as the right class. + */ +export class CreditNote extends Entity("CreditNote")( + { + id: CreditNoteId, + kind: z.literal("CREDIT_NOTE"), + issuedTo: Organization, + against: InvoiceId, + total: Money, + issuedAt: Instant, + }, + { + generated: ["id", "issuedAt", "kind"], + immutable: ["id", "issuedAt", "issuedTo", "against", "kind"], + invariants: [Entity.invariant((d) => d.total.amount >= 0, "total must not be negative")], + }, +) {} + +/** + * Dispatches on `kind` — a **declared domain field**, never the entity's + * `_tag`. That distinction is the whole design: `_tag` is non-enumerable, so it + * is absent from `toJSON()` and from anything that has been through JSON, and a + * union built on it would register no members and reject every payload with + * "expected one of " — an empty set. + * + * The two mechanisms are not redundant. This field discriminates **data** on + * the way in; `P.tag(...)` matches an **instance** you already hold. + */ +export const BillingDocument = Entity.union("kind", [Invoice, CreditNote] as const); + +/* ── Binding the effect sources ──────────────────────────────────────── + The package reads no clock and generates no id. A factory is where those + come in, bound once at the composition root — which is what leaves the + entities themselves trivially testable. */ + +const now = () => new Date().toISOString() as z.infer; + +export const createOrganization = Organization.factory({ + id: () => crypto.randomUUID() as z.infer, + createdAt: now, +}); + +export const createInvoice = Invoice.factory({ + id: () => crypto.randomUUID() as z.infer, + issuedAt: now, + // The discriminant is domain-generated, not caller-supplied: an invoice that + // could be created claiming `kind: "CREDIT_NOTE"` would be a bug waiting to + // happen, and `generated` keeps it out of `createInput` entirely. + kind: () => "INVOICE" as const, +}); + +export const createCreditNote = CreditNote.factory({ + id: () => crypto.randomUUID() as z.infer, + issuedAt: now, + kind: () => "CREDIT_NOTE" as const, +}); diff --git a/examples/billing-domain/tsconfig.emit.json b/examples/billing-domain/tsconfig.emit.json new file mode 100644 index 0000000..957b2b5 --- /dev/null +++ b/examples/billing-domain/tsconfig.emit.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "declaration": true, + "declarationMap": false, + "emitDeclarationOnly": true, + "outDir": "./node_modules/.emit-check" + } +} diff --git a/examples/billing-domain/tsconfig.json b/examples/billing-domain/tsconfig.json new file mode 100644 index 0000000..7fa4349 --- /dev/null +++ b/examples/billing-domain/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "@btravstack/tsconfig/base.json", + "compilerOptions": { + "rootDir": "./src", + "types": ["node"] + }, + "include": ["src/**/*"] +} diff --git a/examples/billing-domain/vitest.config.ts b/examples/billing-domain/vitest.config.ts new file mode 100644 index 0000000..fb76260 --- /dev/null +++ b/examples/billing-domain/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + include: ["src/**/*.spec.ts"], + setupFiles: ["@unthrown/vitest"], + }, +}); diff --git a/examples/billing-persistence/README.md b/examples/billing-persistence/README.md new file mode 100644 index 0000000..012f861 --- /dev/null +++ b/examples/billing-persistence/README.md @@ -0,0 +1,49 @@ +# billing-persistence + +The storage half: an entity out to a row, and a row back to an entity. + +```sh +pnpm --filter @btravstack/entity-example-billing-persistence test +``` + +## The round trip + +```ts +repository.save(organization); // organization.toJSON() → the row +repository.byId(id); // Organization.make(row) → Result +``` + +`toJSON()` is the only projection the package offers, and it **is** the stored +shape. There is no mapper to keep in sync, and `_tag` never appears in a row: +it is a non-enumerable instance property, so it survives neither +`JSON.stringify` nor a spread. The spec asserts that, because it is the sort of +thing that silently starts leaking. + +`make()` is the way back in, and it validates. Rows outlive models — a column +you dropped two migrations ago is still sitting in production — so the boundary +where old data becomes a live object is exactly where you want a check. + +## Two errors, not one + +`byId` returns `Result`. + +A missing row and a _corrupt_ row are different facts: the first is a 404, the +second is data worth paging someone about. Collapsing them into one error +throws away the only information the caller needs to tell them apart. + +The library deliberately defines no `NotFound` — whether an absent row is +exceptional is a repository's decision, not an entity's — so this package +models it with `unthrown`'s `TaggedError`. The specs discriminate the two with +an exhaustive matcher, which means the day this repository grows a third error, +those call sites stop compiling until someone decides what to do about it. + +Nothing here throws. There is no `try`/`catch` in the file. + +## Swapping the store + +The store is a `Map`. Replace it with a driver and nothing else in this file +changes shape — which is the point of the entity knowing nothing about +persistence. + +See also the how-to: [Persist and +rehydrate](https://btravstack.github.io/entity/how-to/persist-and-rehydrate). diff --git a/examples/billing-persistence/package.json b/examples/billing-persistence/package.json new file mode 100644 index 0000000..d07de5a --- /dev/null +++ b/examples/billing-persistence/package.json @@ -0,0 +1,29 @@ +{ + "name": "@btravstack/entity-example-billing-persistence", + "private": true, + "description": "Storing and rehydrating entities", + "license": "MIT", + "author": "Benoit TRAVERS ", + "type": "module", + "main": "./src/index.ts", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "test": "vitest run", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@btravstack/entity": "workspace:*", + "@btravstack/entity-example-billing-domain": "workspace:*", + "unthrown": "catalog:", + "zod": "catalog:" + }, + "devDependencies": { + "@btravstack/tsconfig": "catalog:", + "@types/node": "catalog:", + "@unthrown/vitest": "catalog:", + "typescript": "catalog:", + "vitest": "catalog:" + } +} diff --git a/examples/billing-persistence/src/index.spec.ts b/examples/billing-persistence/src/index.spec.ts new file mode 100644 index 0000000..9c17b74 --- /dev/null +++ b/examples/billing-persistence/src/index.spec.ts @@ -0,0 +1,80 @@ +import { DisplayName, Slug, createOrganization } from "@btravstack/entity-example-billing-domain"; +import { P } from "unthrown"; +import { expect, test } from "vitest"; + +import { InMemoryOrganizationRepository } from "./index.js"; + +const acme = () => + createOrganization({ + slug: Slug.parse("acme"), + name: DisplayName.parse("Acme SA"), + }).getOrThrow(); + +test("an entity survives the round trip through storage", () => { + const repo = new InMemoryOrganizationRepository(); + const saved = acme(); + repo.save(saved); + + const loaded = repo.byId(saved.id).getOrThrow(); + expect(loaded.equals(saved)).toBe(true); + // Behaviour comes back too, not just data — `make` returns a real instance. + expect(loaded.isSelfTitled).toBe(true); +}); + +test("the stored row is exactly the shape, and never carries _tag", () => { + const repo = new InMemoryOrganizationRepository(); + const saved = acme(); + repo.save(saved); + + const row = repo.rawRow(saved.id); + expect(row).toBeDefined(); + expect("_tag" in row!).toBe(false); + expect(Object.keys(row!).sort()).toEqual(["createdAt", "displayLabel", "id", "name", "slug"]); +}); + +test("a corrupt row comes back as an error, not an exception", async () => { + const repo = new InMemoryOrganizationRepository(); + const id = "11111111-1111-4111-8111-111111111111"; + repo.seedRaw(id, { id, slug: "", name: "" }); + + const loaded = repo.byId(id as never); + expect(loaded.isErr()).toBe(true); + + const kind = await loaded.match({ + ok: () => "ok", + errCases: (m) => + m + .with(P.tag("InvalidEntity"), () => "invalid") + .with(P.tag("OrganizationNotFound"), () => "not-found"), + defect: () => "defect", + }); + expect(kind).toBe("invalid"); +}); + +test("a missing row is NotFound, which is a different fact from corrupt", async () => { + const repo = new InMemoryOrganizationRepository(); + const missing = repo.byId("22222222-2222-4222-8222-222222222222" as never); + + expect(missing.isErr()).toBe(true); + const kind = await missing.match({ + ok: () => "ok", + errCases: (m) => + m + .with(P.tag("InvalidEntity"), () => "invalid") + .with(P.tag("OrganizationNotFound"), () => "not-found"), + defect: () => "defect", + }); + expect(kind).toBe("not-found"); +}); + +test("update writes a new row and leaves the entity in hand untouched", () => { + const repo = new InMemoryOrganizationRepository(); + const saved = acme(); + repo.save(saved); + + const renamed = saved.update({ name: DisplayName.parse("Acme SAS") }).getOrThrow(); + repo.save(renamed); + + expect(repo.byId(saved.id).getOrThrow().name).toBe("Acme SAS"); + expect(saved.name).toBe("Acme SA"); +}); diff --git a/examples/billing-persistence/src/index.ts b/examples/billing-persistence/src/index.ts new file mode 100644 index 0000000..25b36ec --- /dev/null +++ b/examples/billing-persistence/src/index.ts @@ -0,0 +1,63 @@ +/** + * Storing and rehydrating entities. + * + * `toJSON()` is the only projection the package offers, and it is exactly the + * stored shape — `_tag` is a non-enumerable instance property, so it never + * reaches a row, a `JSON.stringify`, or a spread. `make()` is the way back in, + * and it validates, so a corrupt row is an `InvalidEntity` **value** rather + * than an exception: this repository has nothing to `try`/`catch`. + * + * The store here is a `Map`. Swap it for a driver and nothing else moves — + * that is the point of the entity knowing nothing about persistence. + */ +import type { Entity } from "@btravstack/entity"; +import { Organization } from "@btravstack/entity-example-billing-domain"; +import { Err, TaggedError, type Result } from "unthrown"; + +type OrganizationId = Organization["id"]; +type Row = ReturnType; + +/** + * A missing row is not a *malformed* row, and collapsing the two would throw + * away the distinction the caller needs: one is a 404, the other is corrupt + * data worth paging someone about. The library deliberately defines no + * `NotFound` — whether an absent row is exceptional is a repository's decision, + * so it is modelled here. + */ +export class OrganizationNotFound extends TaggedError("OrganizationNotFound") {} + +export type OrganizationRepository = { + save(organization: Organization): void; + byId(id: OrganizationId): Result; +}; + +export class InMemoryOrganizationRepository implements OrganizationRepository { + readonly #rows = new Map(); + + save(organization: Organization): void { + // Exactly the stored shape. No mapper, no omit list, no `_tag`. + this.#rows.set(organization.id, organization.toJSON()); + } + + byId(id: OrganizationId): Result { + const row = this.#rows.get(id); + + if (row === undefined) { + return Err(new OrganizationNotFound()); + } + + // `make` validates on the way in, so whatever the store handed back is + // checked before it becomes an entity — rows predate the current model. + return Organization.make(row); + } + + /** Test seam: what actually landed in the store. */ + rawRow(id: OrganizationId): Row | undefined { + return this.#rows.get(id) as Row | undefined; + } + + /** Test seam: a row that never came from a valid entity. */ + seedRaw(id: string, row: unknown): void { + this.#rows.set(id, row); + } +} diff --git a/examples/billing-persistence/tsconfig.json b/examples/billing-persistence/tsconfig.json new file mode 100644 index 0000000..7fa4349 --- /dev/null +++ b/examples/billing-persistence/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "@btravstack/tsconfig/base.json", + "compilerOptions": { + "rootDir": "./src", + "types": ["node"] + }, + "include": ["src/**/*"] +} diff --git a/examples/billing-persistence/vitest.config.ts b/examples/billing-persistence/vitest.config.ts new file mode 100644 index 0000000..fb76260 --- /dev/null +++ b/examples/billing-persistence/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + include: ["src/**/*.spec.ts"], + setupFiles: ["@unthrown/vitest"], + }, +}); diff --git a/knip.json b/knip.json deleted file mode 100644 index 12eac17..0000000 --- a/knip.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "$schema": "https://unpkg.com/knip@6/schema.json", - "ignoreExportsUsedInFile": true, - "ignore": ["**/*.test-d.ts"], - "workspaces": { - "packages/*": { - "project": ["src/**/*.ts"] - }, - "docs": { - "project": [".vitepress/**/*.ts"] - } - }, - "ignoreDependencies": [ - "@btravstack/oxlint", - "@btravstack/lefthook", - "@btravstack/typedoc", - "typedoc-plugin-markdown" - ] -} diff --git a/knip.jsonc b/knip.jsonc new file mode 100644 index 0000000..b678bd3 --- /dev/null +++ b/knip.jsonc @@ -0,0 +1,45 @@ +{ + "$schema": "https://unpkg.com/knip@6/schema.json", + + // `ComputedOf`, `CreateInputOf` and `GeneratedOf` are exported from + // `types.ts` and consumed inside it — by `OutputOf`, `EntityStatic` and + // `Generators` respectively — plus `types.test-d.ts`, which this repo + // deliberately keeps out of knip's scope. Deleting + // `packages/entity/consumer/` removed the last cross-file reference and knip + // began reporting all three as dead. They are not: this setting says + // "used inside its own file still counts", which is the truth here and what + // the sibling repos configure. + "ignoreExportsUsedInFile": true, + + // Type-level behaviour lives in `*.test-d.ts`, checked by its own tsc pass and + // deliberately kept out of the main one, out of oxlint (`.oxlintrc.json`'s + // `ignorePatterns`) and out of knip. Nothing imports them — that is the design + // — so knip reads them as dead files. Spelled out here because introducing + // this config file at all replaced the defaults that had been excluding them. + "ignore": ["**/*.test-d.ts"], + + // Four config packages knip cannot trace, each verified referenced: + // `@btravstack/oxlint` from `.oxlintrc.json`'s `extends`, `@btravstack/lefthook` + // from `lefthook.yml`'s `extends`, and `@btravstack/typedoc` plus + // `typedoc-plugin-markdown` from `docs/typedoc.json`. Knip resolves none of + // these three config formats, so it reads all four as unused devDependencies. + "ignoreDependencies": [ + "@btravstack/lefthook", + "@btravstack/oxlint", + "@btravstack/typedoc", + "typedoc-plugin-markdown", + ], + + "workspaces": { + "examples/billing-domain": { + // `emit-guards.ts` is deliberately imported by nothing. It exists to be + // COMPILED: it is the declaration-emit fixture that replaced + // `packages/entity/consumer/`, and its assertions are `@ts-expect-error` + // directives with no runtime moment. Naming it an entry is what stops + // knip reporting the guard as dead code and someone helpfully deleting + // it. `src/index.ts` is not listed: it is already the package entry. + "entry": ["src/emit-guards.ts"], + "project": ["src/**/*.ts"], + }, + }, +} diff --git a/packages/entity/consumer/index.ts b/packages/entity/consumer/index.ts deleted file mode 100644 index 6e183f5..0000000 --- a/packages/entity/consumer/index.ts +++ /dev/null @@ -1,168 +0,0 @@ -/** - * A stand-in for a downstream *library*: it exports an entity subclass **and** - * emits its own declarations. That combination is what regressed before — a - * module-private `unique symbol` in the seal made every such consumer fail - * with TS4020, while this repo's own build (which does not emit declarations - * through `tsc`) stayed green. - * - * `tsconfig.consumer.json` compiles this, and both of its overrides are load - * bearing: it turns `noEmit` off because declaration emit is what surfaces a - * leaked private name, and points `paths` at `dist/*.d.mts` so this exercises - * the published types rather than `src`. - * - * It is also what guards the namespace: every member of `Entity` is reached - * through the built `d.mts` below, because a member emitted as a circular - * self-alias still *compiles* and only shows up as a `@ts-expect-error` here - * going unused. See the `Src` comment in `entity.ts`. An unused directive in - * this file is a failure, not noise. - */ -import { Entity } from "@btravstack/entity"; -import { z } from "zod"; - -const OrgId = z.uuid().brand("OrgId"); -const Slug = z.string().min(1).brand("Slug"); -const Upper = z.string().min(1).brand("Upper"); - -export class Organization extends Entity("Organization")( - { id: OrgId, slug: Slug }, - { - immutable: ["id"], - computed: { - shout: Entity.computed(Upper, (d) => d.slug.toUpperCase() as z.infer), - }, - invariants: [Entity.invariant((d) => d.slug.length <= 40, "slug must be at most 40 chars")], - }, -) {} - -/** The statics must still yield the subclass, not the structural base. */ -export const load = (raw: unknown): Organization => Organization.make(raw).getOrThrow(); - -/** The class must still compose as a schema from outside the package. */ -export const Aggregate = z.object({ owner: Organization }); - -// @ts-expect-error construction stays sealed for a consumer -new Organization({ id: "x" as never, slug: "y" as never }); - -// @ts-expect-error the construction key cannot be forged structurally -const forged: Entity.ConstructionKey = {} as { seal: never }; -void forged; - -/** - * Every remaining namespace member, named from outside the package. A member - * emitted as a circular self-alias degenerates silently, so each one has to be - * *used* somewhere declaration emit will walk it. - */ -export type Row = Entity.Output; -export type Wire = Entity.Input; -export type NewOrg = Entity.CreateInput; -export type OrgPatch = Entity.Patch; -export type Derived = Entity.ComputedField }>; -export type Rule = Entity.Invariant<{ slug: z.infer }>; -export type Sealed = Entity.Sealed; -export type Base = Entity.BaseInstance<{ id: typeof OrgId }, Record, []>; -export type Static = Entity.Static< - "Organization", - { id: typeof OrgId }, - Record, - [], - [] ->; - -/** The error is reachable as both a value and a type. */ -export const isInvalid = (e: unknown): e is Entity.InvalidEntity => - e instanceof Entity.InvalidEntity; - -const Member = Entity.union("kind", [Organization, Organization] as const); -export type MemberUnion = Entity.Union<"kind", [typeof Organization, typeof Organization]>; -void Member; - -/** - * The two field shapes that broke declaration emit while `EntityStatic` was - * unexported, both reported from a real adoption. - * - * With no name to write for the builder's return type, TypeScript serialised - * the whole static surface into this file's declarations, repeating the field - * map a dozen times. A **branded object** field was expanded through - * `DeepReadonly` until zod's module-private `$brand` symbol reached - * computed-key position, which cannot be named across a module boundary - * (`TS4020`, issue #32); and a realistically wide domain enum pushed the - * repeated field map past the compiler's serialisation ceiling (`TS7056`, - * issue #31). Both now emit as `EntityStatic<…>` by reference. - * - * Their guard value differs, and only one of them is carried by *this* pass: - * `TS4020` reproduces on the repo's TypeScript, so the branded object below - * fails here the moment the export is removed. `TS7056` is a 5.x-era limit the - * native port does not enforce, so the wide enum is checked by - * `tsconfig.consumer5.json` instead — see the comment there. - */ -const Money = z.object({ amount: z.number(), currency: z.enum(["EUR", "USD"]) }).brand("Money"); - -export class Invoice extends Entity("Invoice")({ id: OrgId, total: Money }) {} - -/** The branded object must stay *usable*, not merely compile. */ -export const invoiceTotal = (i: Invoice): number => i.total.amount; -export const invoiceCurrency = (i: Invoice): "EUR" | "USD" => i.total.currency; - -// @ts-expect-error a branded object's members stay deep-readonly -export const mutateTotal = (i: Invoice): void => void (i.total.amount = 1); - -const Reason = z.enum([ - "CANCELED_LEASE", - "TENANT_LEAVE_BALANCE_DONE", - "SUBROGATIVE_RECEIPT_TO_BE_SIGNED", - "SUBROGATIVE_RECEIPT_SIGNED", - "NO_RGI_CLAIM", - "VISALE", - "MONTHLY_PAYMENT", - "GROWTH", - "UNIT_SOLD", - "EXPENSE_TRANSFER", - "DECEASED_TENANT", - "DECEASED_COOWNER", - "DISPUTE_CHARGES", - "DISPUTE_REPAIRS", - "OWNER_INSTRUCTIONS_EXCLUDING_GLI", - "CHECK_OR_CASH_NOT_RECORDED", - "AWAITING_CAF_PAYMENT", - "NEW_BUILDING", - "NEW_COOWNER", - "MANAGEMENT_DIFFICULTIES", - "SALE_IN_PROGRESS", - "PROMISE_OF_PAYMENT", - "FALSE_DISTRIBUTIONS", - "INSTITUTIONAL_COOWNER", - "HISTORICAL", - "TENANT_LEAVE_NO_REMINDER", - "EXTERNAL_RGI_DISASTER", - "OVER_INDEBTEDNESS_LEGAL_PROCEEDINGS", - "MEMORANDUM_OF_AGREEMENT", - "MANUAL_EXPENSE_TRANSFER", -]); -const Level = z.union([ - z.literal(0), - z.literal(1), - z.literal(2), - z.literal(3), - z.literal(4), - z.literal(5), -]); -const Instant = z.date().brand("Instant"); - -/** - * Kept at the reported width on purpose. `TS7056` is a threshold on serialised - * *characters*, so a field map trimmed even slightly — the timestamp dropped, - * `Level` down to four members — lands back under the ceiling and the case - * silently stops guarding anything. Measured while writing this fixture. If - * this entity ever needs editing, re-check it still fails with the export - * removed. - */ -export class Reminder extends Entity("Reminder")({ - id: OrgId, - reasons: z.array(Reason), - createdAt: Instant, - status: z.enum(["ONGOING_REMINDER", "CLOSE"]), - kind: z.enum(["TENANT_IN_PLACE", "TENANT_LEAVE", "CO_OWNER"]), - level: Level, - nextLevel: Level, - flag: z.boolean(), -}) {} diff --git a/packages/entity/package.json b/packages/entity/package.json index b8db275..c23e518 100644 --- a/packages/entity/package.json +++ b/packages/entity/package.json @@ -49,7 +49,7 @@ "dev": "tsdown src/index.ts --format cjs,esm --dts --watch", "test": "vitest run", "test:types": "tsc --noEmit -p tsconfig.test-d.json", - "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.test-d.json && tsc -p tsconfig.consumer.json && node ./node_modules/typescript-consumer/bin/tsc -p tsconfig.consumer5.json" + "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.test-d.json" }, "devDependencies": { "@btravstack/tsconfig": "catalog:", @@ -60,7 +60,6 @@ "@vitest/coverage-v8": "catalog:", "tsdown": "catalog:", "typescript": "catalog:", - "typescript-consumer": "catalog:", "unthrown": "catalog:", "vitest": "catalog:", "zod": "catalog:" diff --git a/packages/entity/tsconfig.consumer.json b/packages/entity/tsconfig.consumer.json deleted file mode 100644 index 1f9d266..0000000 --- a/packages/entity/tsconfig.consumer.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "extends": "@btravstack/tsconfig/base.json", - "compilerOptions": { - "noEmit": false, - "declaration": true, - "declarationMap": false, - "emitDeclarationOnly": true, - "outDir": "./node_modules/.consumer-check", - "rootDir": "./consumer", - "types": ["node"], - "paths": { "@btravstack/entity": ["./dist/index.d.mts"] } - }, - "include": ["consumer/**/*"] -} diff --git a/packages/entity/tsconfig.consumer5.json b/packages/entity/tsconfig.consumer5.json deleted file mode 100644 index 2d1ccf0..0000000 --- a/packages/entity/tsconfig.consumer5.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "extends": "./tsconfig.consumer.json", - "compilerOptions": { - "outDir": "./node_modules/.consumer5-check" - } -} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 735c2a6..8f40470 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -30,6 +30,12 @@ catalogs: '@commitlint/cli': specifier: 21.2.1 version: 21.2.1 + '@orpc/contract': + specifier: 2.0.0-beta.24 + version: 2.0.0-beta.24 + '@orpc/json-schema': + specifier: 2.0.0-beta.24 + version: 2.0.0-beta.24 '@orpc/zod': specifier: 2.0.0-beta.24 version: 2.0.0-beta.24 @@ -160,6 +166,105 @@ importers: specifier: 'catalog:' version: 1.6.4(@algolia/client-search@5.56.0)(@types/node@26.1.2)(jiti@2.7.0)(postcss@8.5.25)(typescript@6.0.3)(yaml@2.9.0) + examples/billing-api: + dependencies: + '@btravstack/entity-example-billing-domain': + specifier: workspace:* + version: link:../billing-domain + '@orpc/contract': + specifier: 'catalog:' + version: 2.0.0-beta.24 + '@orpc/json-schema': + specifier: 'catalog:' + version: 2.0.0-beta.24 + '@orpc/zod': + specifier: 'catalog:' + version: 2.0.0-beta.24(zod@4.4.3) + zod: + specifier: 'catalog:' + version: 4.4.3 + devDependencies: + '@btravstack/tsconfig': + specifier: 'catalog:' + version: 0.2.0 + '@types/node': + specifier: 'catalog:' + version: 26.1.2 + '@unthrown/vitest': + specifier: 'catalog:' + version: 5.1.0(unthrown@5.1.0)(vitest@4.1.10) + typescript: + specifier: 'catalog:' + version: 7.0.2 + vitest: + specifier: 'catalog:' + version: 4.1.10(@types/node@26.1.2)(@vitest/coverage-v8@4.1.10)(jiti@2.7.0)(yaml@2.9.0) + + examples/billing-domain: + dependencies: + '@btravstack/entity': + specifier: workspace:* + version: link:../../packages/entity + unthrown: + specifier: 'catalog:' + version: 5.1.0 + zod: + specifier: 'catalog:' + version: 4.4.3 + devDependencies: + '@btravstack/tsconfig': + specifier: 'catalog:' + version: 0.2.0 + '@types/node': + specifier: 'catalog:' + version: 26.1.2 + '@unthrown/standard-schema': + specifier: 'catalog:' + version: 5.1.0 + '@unthrown/vitest': + specifier: 'catalog:' + version: 5.1.0(unthrown@5.1.0)(vitest@4.1.10) + typescript: + specifier: 'catalog:' + version: 7.0.2 + typescript-consumer: + specifier: 'catalog:' + version: typescript@5.9.3 + vitest: + specifier: 'catalog:' + version: 4.1.10(@types/node@26.1.2)(@vitest/coverage-v8@4.1.10)(jiti@2.7.0)(yaml@2.9.0) + + examples/billing-persistence: + dependencies: + '@btravstack/entity': + specifier: workspace:* + version: link:../../packages/entity + '@btravstack/entity-example-billing-domain': + specifier: workspace:* + version: link:../billing-domain + unthrown: + specifier: 'catalog:' + version: 5.1.0 + zod: + specifier: 'catalog:' + version: 4.4.3 + devDependencies: + '@btravstack/tsconfig': + specifier: 'catalog:' + version: 0.2.0 + '@types/node': + specifier: 'catalog:' + version: 26.1.2 + '@unthrown/vitest': + specifier: 'catalog:' + version: 5.1.0(unthrown@5.1.0)(vitest@4.1.10) + typescript: + specifier: 'catalog:' + version: 7.0.2 + vitest: + specifier: 'catalog:' + version: 4.1.10(@types/node@26.1.2)(@vitest/coverage-v8@4.1.10)(jiti@2.7.0)(yaml@2.9.0) + packages/entity: devDependencies: '@btravstack/tsconfig': @@ -186,9 +291,6 @@ importers: typescript: specifier: 'catalog:' version: 7.0.2 - typescript-consumer: - specifier: 'catalog:' - version: typescript@5.9.3 unthrown: specifier: 'catalog:' version: 5.1.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index e20794d..f920d3a 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -6,6 +6,7 @@ strictPeerDependencies: true packages: - packages/* + - examples/* - docs catalog: @@ -23,6 +24,12 @@ catalog: # flag). `@orpc/zod`'s stable "latest" line (1.x) predates the v2 API this # package's contract tests exercise. "@orpc/zod": 2.0.0-beta.24 + # Same release train as `@orpc/zod` above — the billing-api example defines a + # contract and converts it to JSON Schema in the same file, so they move together. + "@orpc/contract": 2.0.0-beta.24 + # `ZodToJsonSchemaConverter.convert` returns `[JsonSchema, optional]`, and + # naming `JsonSchema` is what keeps an exported schema portable (TS2883). + "@orpc/json-schema": 2.0.0-beta.24 "@types/node": 26.1.2 "@unthrown/oxlint": 5.1.0 "@unthrown/standard-schema": 5.1.0 @@ -41,11 +48,13 @@ catalog: # `types.d.ts`). Kept in step with that measurement rather than trailing it. typescript: 7.0.2 # The TypeScript consumers actually build with, for the second declaration-emit - # pass (`tsconfig.consumer5.json`). The native port above does **not** enforce - # the 5.x serialised-type ceiling, so `TS7056` is invisible to it: issue #31 - # reproduced on 5.9.3 against a 30-member domain enum while the repo's own - # consumer pass stayed green on 7.0.2. Aliased because one `package.json` - # cannot name `typescript` twice. + # pass in `examples/billing-domain` (`tsconfig.emit.json`, run twice). + # Both versions enforce the ceiling on serialised type length; 5.9.3's is + # simply lower. Measured on one entity carrying a 30-member enum, a branded + # timestamp and a six-member literal union: 5.9.3 rejected it with `TS7056` + # while 7.0.2 accepted the same shape — widen the entity and both reject it. + # That band of realistic domain widths is where issues #31 and #32 shipped. + # Aliased because one `package.json` cannot name `typescript` twice. typescript-consumer: "npm:typescript@5.9.3" unthrown: 5.1.0 "@vitest/coverage-v8": 4.1.10