diff --git a/.changeset/entity-invariant.md b/.changeset/entity-invariant.md new file mode 100644 index 0000000..bb24038 --- /dev/null +++ b/.changeset/entity-invariant.md @@ -0,0 +1,41 @@ +--- +"@btravstack/entity": minor +--- + +**Breaking.** `invariants` is now a list of rules built with `Entity.invariant`, +replacing the single function that returned messages. + +```diff + class Organization extends Entity("Organization")( + { name: DisplayName, note: Line }, + { +- invariants: (d) => [ +- ...(d.name.length <= 80 ? [] : ["name must be at most 80 characters"]), +- ...(d.note.length >= d.name.length ? [] : ["note must be at least as long"]), +- ], ++ invariants: [ ++ Entity.invariant((d) => d.name.length <= 80, "name must be at most 80 characters"), ++ Entity.invariant( ++ (d) => d.note.length >= d.name.length, ++ (d) => `note must be at least ${d.name.length} characters`, ++ ), ++ ], + }, + ) {} +``` + +A rule and its message are now one value, so several rules no longer need +hand-rolled accumulation. `ensure` returning **true** means valid. `message` +takes the data when the text depends on it. Every failing rule reports, not just +the first — unchanged from before. + +**A rule now sees the declared fields only.** It can no longer read a computed +field. Every computed value is a function of declared data, so any rule about +one is expressible over its sources, and a computed value that fails its own +schema is already a Defect rather than something to re-check in an invariant. + +**`extend` no longer lets an extension shed its parent's rules.** `invariants` +is the one option that concatenates parent-then-child instead of the child +replacing the parent. An extension can add rules; it cannot remove them, which +is what the design always intended. Code relying on `{ invariants: () => [] }` +to relax a parent has no replacement — that escape hatch is gone deliberately. diff --git a/.gitignore b/.gitignore index 2bf5eb5..aca1408 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,6 @@ coverage/ # macOS .DS_Store + +# Local design scratch, not part of the published docs +docs/superpowers/ diff --git a/CLAUDE.md b/CLAUDE.md index f930b71..e9b1c66 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -25,7 +25,7 @@ that are not derivable from there: ## Architecture -Eight source modules under `packages/entity/src`, split by what they own: +Nine source modules under `packages/entity/src`, split by what they own: - **`entity.ts`** — the builder. `Entity(tag)(fields, options)` derives the four `ZodObject`s (`input`, `output`, `createInput`, `updateInput`) from @@ -72,6 +72,12 @@ Eight source modules under `packages/entity/src`, split by what they own: (public as `Entity.computed`) and the `InvalidEntity` tagged error. Computed fields are re-derived on every construction path, so they cannot drift from their sources. +- **`invariant.ts`** — `invariant(ensure, message)`, public as + `Entity.invariant`. A rule's `d` is `InputOf`, **not** `OutputOf`, + and that is not a simplification: `OutputOf` carries the deferred + `ComputedOf` conditional, `A` is unresolved while the invariants array is + checked, and typing `d` as the output degrades it to a bag of `unknown` + wherever an entity declares `computed` too. Measured — see the comment there. The design rule the whole package turns on: **contracts compose the four plain `ZodObject`s; domain code composes the class itself.** The class carries a diff --git a/README.md b/README.md index de7c1bd..7e123bc 100644 --- a/README.md +++ b/README.md @@ -32,8 +32,12 @@ class Organization extends Entity("Organization")( (d) => d.name.toUpperCase() as z.infer, ), }, - invariants: (d) => - d.name.length <= 80 ? [] : ["name must be at most 80 characters"], + invariants: [ + Entity.invariant( + (d) => d.name.length <= 80, + "name must be at most 80 characters", + ), + ], }, ) { get greeting(): string { @@ -120,7 +124,7 @@ Organization.make({ ...row, name: "" }).match({ | `generated` | fields the domain supplies, never the caller | | `immutable` | fields that never change after creation | | `computed` | fields derived from the declared ones, re-derived on every construction | -| `invariants` | `(output) => string[]` — a non-empty result rejects | +| `invariants` | rules built with `Entity.invariant`; any failing rule rejects | Also `Entity.union(discriminant, members)` for a union that is itself entity-like, and `SomeEntity.extend(tag)(fields)` to build a new entity from an diff --git a/docs/how-to/model-an-aggregate.md b/docs/how-to/model-an-aggregate.md index 50aae43..f2f76b9 100644 --- a/docs/how-to/model-an-aggregate.md +++ b/docs/how-to/model-an-aggregate.md @@ -55,10 +55,12 @@ order.watchers[0].equals(other); // its behaviour class Order extends Entity("Order")( { id: OrderId, customer: Customer, note: Line }, { - invariants: (d) => - d.note.length >= d.customer.name.length - ? [] - : ["note must be at least as long as the name"], + invariants: [ + Entity.invariant( + (d) => d.note.length >= d.customer.name.length, + "note must be at least as long as the name", + ), + ], }, ) {} ``` diff --git a/docs/reference.md b/docs/reference.md index 2a2cb3a..655899d 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -32,16 +32,21 @@ Four names are reserved, because an entity installs them on every instance: ### `options` -| Option | Type | Effect | -| ------------ | ------------------------------- | -------------------------------------------------------------------------------- | -| `generated` | `readonly (keyof fields)[]` | omitted from `createInput`; supplied by a factory's generators | -| `immutable` | `readonly (keyof output)[]` | omitted from `updateInput`; `update()` drops them even if smuggled in at runtime | -| `computed` | `{ [name]: ComputedField }` | derived fields; added to `output`, re-derived on every construction | -| `invariants` | `(output) => readonly string[]` | rules spanning two or more fields; a non-empty result rejects | +| Option | Type | Effect | +| ------------ | ---------------------------------- | -------------------------------------------------------------------------------- | +| `generated` | `readonly (keyof fields)[]` | omitted from `createInput`; supplied by a factory's generators | +| `immutable` | `readonly (keyof output)[]` | omitted from `updateInput`; `update()` drops them even if smuggled in at runtime | +| `computed` | `{ [name]: Entity.ComputedField }` | derived fields; added to `output`, re-derived on every construction | +| `invariants` | `readonly Entity.Invariant[]` | rules spanning two or more declared fields; any failing rule rejects | `generated` and `immutable` are keyed off the field names, so a typo is a compile error rather than a silently-inert entry. +`Entity.ComputedField` and `Entity.Invariant` are both generic; the +parameters are elided above because you never write them. `Entity.computed` and +`Entity.invariant` infer them from the surrounding declaration, which is what +makes `d` contextually typed with no annotation. + ## Schema members ```ts @@ -142,6 +147,40 @@ field — every derivation is a function of declared data only. Output that fails its own schema is a `Defect`, named for the field (`Person.computed.initials: …`). +## `Entity.invariant(ensure, message)` + +One rule spanning the whole entity: the predicate, and what to say when it +fails. + +```ts +invariants: [ + Entity.invariant( + (d) => d.name.length <= 80, + "name must be at most 80 characters", + ), + Entity.invariant( + (d) => d.endsAt > d.startsAt, + (d) => `endsAt must be after ${d.startsAt}`, + ), +]; +``` + +`ensure` returning **true** means valid — a rule reads as the assertion it +makes. `d` is contextually typed and needs no annotation. `message` takes the +data when the text depends on it. + +Every failing rule in the list reports, not just the first, and none of them +carries a `path`: an invariant spans the entity, which is what separates it from +a field complaint. + +`d` is the **declared** fields, not the output — a rule cannot read a computed +field. Every computed value is a function of declared data, so any rule about +one is expressible over its sources, and a computed value failing its own schema +is already a Defect rather than something to re-check here. + +A predicate that throws is a Defect, not an `InvalidEntity`, on the same +reasoning as `computed`. + ## `SomeEntity.extend(tag)(fields, options?)` A **new** entity carrying the parent's fields plus more, under its own tag — @@ -155,8 +194,13 @@ class PersonWithAge extends Person.extend("PersonWithAge")({ age: Age }) { } ``` -Options merge per key, child winning. `extend` rebuilds from the -**declaration**, so class-body members do not carry over — re-declare them. +Options merge per key, child winning — **except `invariants`**, which +concatenates parent-then-child. An extension can add rules; it cannot shed them, +so it is never quietly laxer than what it extends. Declaring `invariants: []` on +a child does not clear the parent's. + +`extend` rebuilds from the **declaration**, so class-body members do not carry +over — re-declare them. ## `Entity.union(discriminant, members)` diff --git a/docs/superpowers/specs/2026-08-07-entity-namespace-design.md b/docs/superpowers/specs/2026-08-07-entity-namespace-design.md deleted file mode 100644 index b6a0769..0000000 --- a/docs/superpowers/specs/2026-08-07-entity-namespace-design.md +++ /dev/null @@ -1,244 +0,0 @@ -# Collapse the public surface onto `Entity` - -**Status:** implemented -**Date:** 2026-08-07 - -> One claim below did not survive implementation: `index.ts` cannot export a -> single name. See [Implementation notes](#implementation-notes). - -## Problem - -`@btravstack/entity` exports three values (`Entity`, `computed`, `InvalidEntity`) -and nine types at the top level. Two of those names are generic enough to be -hostile in a consumer's import scope — `computed` above all, which collides -directly with Vue, MobX, Angular signals and Solid. - -The repository already made this call once and wrote down the reasoning, at -`packages/entity/src/entity.ts:387-393`: - -> Grouped under `Entity` rather than exported loose: `union` alone is too -> generic a name to take from a consumer's import scope, and it reads as -> `z.union`'s sibling when it is nothing of the sort. - -So the convention is not "standalone by default". It is a name-pollution test -that `union` failed and `computed` fails harder. Applying the test consistently -collapses the surface to a single exported name. - -## Decision - -`index.ts` exports exactly one name you write against: `Entity`. (Three -declaration-emit escape hatches also stay top-level — see -[Implementation notes](#implementation-notes).) - -| today | after | -| ----------------------------------------- | ----------------------- | -| `computed` | `Entity.computed` | -| `InvalidEntity` | `Entity.InvalidEntity` | -| `Input` `Output` `CreateInput` `Patch` | `Entity.Input` … | -| `ComputedField` | `Entity.ComputedField` | -| `EntityUnion` | `Entity.Union` | -| `BaseInstance` `ConstructionKey` `Sealed` | `Entity.BaseInstance` … | -| `Entity.union` (already grouped) | unchanged | - -Resulting usage: - -```ts -import { Entity } from "@btravstack/entity"; - -class Person extends Entity("Person")( - { first: First, last: Last }, - { - computed: { - fullName: Entity.computed( - FullName, - (d) => `${d.first} ${d.last}` as FullName, - ), - }, - }, -) {} - -type Row = Entity.Output; -const isInvalid = (e: unknown) => e instanceof Entity.InvalidEntity; -``` - -### Decisions taken explicitly - -- **`InvalidEntity` moves.** Its name is specific rather than generic, so it - passes the name-pollution test on its own merits and could have stayed. It - moves anyway: one exported name with no exceptions is worth more than the - characters saved at each `instanceof`. -- **`EntityUnion` becomes `Entity.Union`.** The stutter only existed to - disambiguate a top-level name. `Entity.Union` (type) sits beside - `Entity.union` (value), differing only in case — legal, since types and - values occupy separate declaration spaces, and the same shape as zod's - `z.union` / `z.ZodUnion`. -- **`shape()` is out of scope.** `CLAUDE.md` calls it "the only sanctioned way - to build a domain object", but it is not exported from `index.ts`. Treated as - internal; the stale `CLAUDE.md` claim is corrected as part of this change, not - by making `shape` public. Growing the surface cuts against the repo's - "small enough to be done" rule. - -## Mechanism - -`Entity` stays a `function` **declaration** in `entity.ts`, merged with a -type-only `export declare namespace Entity` in the same file. Values attach as -expando properties, exactly as `Entity.union` already does: - -```ts -Entity.computed = computed; -Entity.InvalidEntity = InvalidEntity; - -export declare namespace Entity { - export type Output = E["__output"]; - export type ComputedField = ComputedFieldSrc< - T, - D - >; - export type ConstructionKey = ConstructionKeySrc; - // … -} -``` - -This is the only viable mechanism, not one of several. `export const Entity = -Object.assign(fn, { … })` cannot merge with a namespace — TypeScript requires a -function _declaration_ for the merge. - -### Binding constraint: a namespace member must not share its name with the type it aliases - -Verified by spike against the real pipeline (tsdown + TypeScript 7.0.2 + the -consumer declaration-emit fixture). - -Writing the obvious thing: - -```ts -export declare namespace Entity { - export type ConstructionKey = import("./types.js").ConstructionKey; -} -``` - -emits this into `dist/index.d.mts`: - -```ts -declare namespace Entity { - type ConstructionKey = ConstructionKey; // circular self-alias -} -``` - -tsdown's dts bundler collapses the dynamic import to a bare local name, which -inside the namespace resolves to the member itself. **This compiles.** Nothing -fails loudly. What happens instead is that the type degenerates, and in the -spike that silently voided the construction seal: the consumer fixture's -`@ts-expect-error` on a forged `ConstructionKey` became _unused_, which was the -only signal that a compile-time guarantee had been destroyed. - -The fix is to import each source type under a distinct internal alias -(`ConstructionKeySrc`, `ComputedFieldSrc`, …) and have the namespace member -reference that. Confirmed to emit and consume cleanly. - -Two consequences for implementation: - -1. Every namespace member that aliases an imported type needs a differently - named internal alias. This must be recorded as a comment in `entity.ts`, - in the style of the repo's other measured-behaviour guards. -2. `tsconfig.consumer.json` is the only thing standing between this failure mode - and a shipped release. Its `@ts-expect-error` assertions are load-bearing; - an _unused_ one is a failure signal here, not noise. - -This is the same class of defect as the `TS4020` history recorded on -`ConstructionKey` in `types.ts`. - -## Files affected - -- `packages/entity/src/entity.ts` — namespace declaration, expando assignments, - internal source aliases, the constraint comment. -- `packages/entity/src/index.ts` — `Entity`, plus the three declaration-emit - escape hatches. -- `packages/entity/src/union.ts` — `UnionMember` exported (internally only, so - `Entity.Union` can name its own constraint). -- `packages/entity/consumer/index.ts` — exercise `Entity.computed`, - `Entity.ConstructionKey`, `Entity.Output`, `Entity.InvalidEntity` through the - built `d.mts`. -- Specs and type-level tests — 12 `computed(` call sites: `computed.spec.ts` - (6), `entity.test-d.ts` (3), and one each in `contract.spec.ts`, - `extend.spec.ts`, `nesting.spec.ts`. Plus their `import { computed }` lines. -- Docs — `README.md`, `packages/entity/README.md`, `docs/reference.md`, - `docs/explanation.md`, all four `docs/how-to/*.md` (including the shared - imports preamble in each). -- `CLAUDE.md` — the entry-point list, and the stale `shape()` claim. - -## Versioning - -Breaking change to the public API. Package is at `0.1.0`, so a **minor** bump -under changesets' 0.x semantics. No deprecated top-level aliases are kept: the -repo's "one concept, one name / resist convenience aliases" rule forbids -carrying both spellings, and at 0.1.0 the installed base does not justify an -exception. - -## Verification - -The existing gate, in order: `format --check`, `lint`, `typecheck` (all three -passes), `test`, `knip`, `build`. - -Two checks specific to this change: - -- The **consumer pass must report zero diagnostics**, including no - `TS2578: Unused '@ts-expect-error'`. An unused directive there means a type - degenerated — see the constraint above. -- Inspect the emitted `dist/index.d.mts` by eye for self-referential aliases - inside `declare namespace Entity`. The spike showed the compiler will not - catch these for you. - -## Sequencing - -Resolved. PR #23 (`docs/fix-restructure-fallout`) edited `docs/reference.md`, -`docs/explanation.md` and three of the four how-to guides — the same files this -change rewrites. It merged as `8c2485b`, and this branch is rebased onto it, so -the docs pass here starts from the corrected text. - -One consequence for the docs pass: #23 gave each guide a shared imports -preamble reading `import { Entity, computed } from "@btravstack/entity"`. Eight -files carry that line — `docs/reference.md`, `docs/explanation.md`, all four -`docs/how-to/*.md`, `README.md` and `packages/entity/README.md` — and every one -collapses to `import { Entity }`. - -## Implementation notes - -Two things the design got wrong, both caught by the gate rather than by review. - -### `index.ts` exports four names, not one - -`BaseInstance`, `ConstructionKey` and `Sealed` must stay **top-level** exports. -Moving them behind `Entity` built and typechecked fine, and then failed the -consumer pass: - -``` -consumer/index.ts(26,35): error TS4020: 'extends' clause of exported class - 'Organization' has or is using private name 'BaseInstance'. -consumer/index.ts(26,14): error TS4094: Property 'seal' of exported anonymous - class type may not be private or protected. -``` - -A downstream library compiling with `declaration: true` emits the _underlying_ -type into its own `.d.ts`, not the namespace path that aliases it. With -`export { Entity }` alone those three are declared-but-unexported in the built -`d.mts`, so they are private names to anyone emitting declarations — which is -the precise regression `types.ts` already documents for `ConstructionKey`. - -They are exported both ways now: top level for declaration emit, and as -`Entity.BaseInstance` etc. for anyone annotating by hand. The rule is therefore -"one name you _write against_", not "one export". - -### `Entity.InvalidEntity` is a re-export, not a type alias - -Declaring `export type InvalidEntity = InvalidEntitySrc` inside the namespace -made the runtime assignment `Entity.InvalidEntity = InvalidEntity` fail with -`TS2339`: once the namespace declares the name, expando inference stops -supplying a value for it. `export { InvalidEntity }` inside the namespace -carries both meanings and accepts the assignment. `computed` and `union` are -unaffected — they have no type member of the same name, so expando inference -still applies. - -## Out of scope - -- Making `shape()` public. -- Any change to entity runtime behaviour. This is a relocation of names. diff --git a/packages/entity/consumer/index.ts b/packages/entity/consumer/index.ts index f61658f..9b2aa18 100644 --- a/packages/entity/consumer/index.ts +++ b/packages/entity/consumer/index.ts @@ -30,6 +30,7 @@ export class Organization extends Entity("Organization")( 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")], }, ) {} @@ -56,6 +57,7 @@ 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, []>; diff --git a/packages/entity/src/computed.spec.ts b/packages/entity/src/computed.spec.ts index 909f70c..301476d 100644 --- a/packages/entity/src/computed.spec.ts +++ b/packages/entity/src/computed.spec.ts @@ -84,7 +84,10 @@ test("toJSON round-trips through make", () => { expect(Person.make(p.toJSON()).getOrThrow().fullName).toBe("Ada Lovelace"); }); -test("invariants see the computed fields", () => { +test("an invariant constrains a computed value through its sources", () => { + // A rule reads the *declared* fields, never a computed one. Every computed + // value is a function of declared data, so the rule is expressed over the + // sources it derives from — here, the length `fullName` will end up with. class Checked extends Entity("Checked")( { id: PersonId, first: NamePart, last: NamePart }, { @@ -94,7 +97,12 @@ test("invariants see the computed fields", () => { (d) => `${d.first} ${d.last}` as z.infer, ), }, - invariants: (d) => (d.fullName.length <= 20 ? [] : ["fullName must be at most 20 chars"]), + invariants: [ + Entity.invariant( + (d) => d.first.length + 1 + d.last.length <= 20, + "fullName must be at most 20 chars", + ), + ], }, ) {} expect(Checked.make(raw).isOk()).toBe(true); diff --git a/packages/entity/src/crud.spec.ts b/packages/entity/src/crud.spec.ts index 27518eb..12549e9 100644 --- a/packages/entity/src/crud.spec.ts +++ b/packages/entity/src/crud.spec.ts @@ -14,7 +14,9 @@ class Organization extends Entity("Organization")( { generated: ["id", "createdAt"], immutable: ["id", "createdAt", "slug"], - invariants: (d) => (d.trialEndsAt > d.createdAt ? [] : ["trialEndsAt must be after createdAt"]), + invariants: [ + Entity.invariant((d) => d.trialEndsAt > d.createdAt, "trialEndsAt must be after createdAt"), + ], }, ) {} diff --git a/packages/entity/src/entity.spec.ts b/packages/entity/src/entity.spec.ts index 8fe9998..74ddb06 100644 --- a/packages/entity/src/entity.spec.ts +++ b/packages/entity/src/entity.spec.ts @@ -184,9 +184,9 @@ class Trial extends Entity("Trial")( seatsUsed: SeatsUsed, }, { - invariants: (d) => [ - ...(d.trialEndsAt > d.createdAt ? [] : ["trialEndsAt must be after createdAt"]), - ...(d.seatLimit >= d.seatsUsed ? [] : ["seatsUsed must not exceed seatLimit"]), + invariants: [ + Entity.invariant((d) => d.trialEndsAt > d.createdAt, "trialEndsAt must be after createdAt"), + Entity.invariant((d) => d.seatLimit >= d.seatsUsed, "seatsUsed must not exceed seatLimit"), ], }, ) {} @@ -238,7 +238,7 @@ const Address = z.object({ city: z.string(), lines: z.array(z.string()) }).brand /** at most two tags — the rule a post-construction `push` used to defeat */ class Bag extends Entity("Bag")( { id: OrgId, tags: z.array(Tag), address: Address }, - { invariants: (d) => (d.tags.length <= 2 ? [] : ["at most 2 tags"]) }, + { invariants: [Entity.invariant((d) => d.tags.length <= 2, "at most 2 tags")] }, ) {} const bagRaw = { diff --git a/packages/entity/src/entity.test-d.ts b/packages/entity/src/entity.test-d.ts index 14430e3..fd487dd 100644 --- a/packages/entity/src/entity.test-d.ts +++ b/packages/entity/src/entity.test-d.ts @@ -90,13 +90,46 @@ test("the invariants parameter is contextually typed and branded", () => { Entity("Probe")( { id: OrgId, slug: Slug }, { - invariants: (d) => { - // @ts-expect-error `nope` is not a field, so `d` is not `any` - void d.nope; - const slug: z.infer = d.slug; - void slug; - return []; + invariants: [ + Entity.invariant((d) => { + // @ts-expect-error `nope` is not a field, so `d` is not `any` + void d.nope; + const slug: z.infer = d.slug; + void slug; + return true; + }, "probe"), + // the message function is contextually typed the same way + Entity.invariant( + () => true, + (d) => { + // @ts-expect-error `nope` is not a field, so `d` is not `any` + void d.nope; + return d.slug; + }, + ), + ], + }, + ); +}); + +test("an invariant sees the declared fields, never a computed one", () => { + const Upper = z.string().brand("Upper"); + Entity("Derived")( + { id: OrgId, slug: Slug }, + { + computed: { + shout: Entity.computed(Upper, (d) => d.slug.toUpperCase() as z.infer), }, + invariants: [ + Entity.invariant((d) => { + // the declared fields are there, fully branded + const slug: z.infer = d.slug; + void slug; + // @ts-expect-error a computed field is not visible to an invariant + void d.shout; + return true; + }, "probe"), + ], }, ); }); diff --git a/packages/entity/src/entity.ts b/packages/entity/src/entity.ts index e22e2cf..89c7e1e 100644 --- a/packages/entity/src/entity.ts +++ b/packages/entity/src/entity.ts @@ -5,6 +5,7 @@ import type { z } from "zod"; import { computed, type ComputedField } from "./computed.js"; import { InvalidEntity } from "./errors.js"; import { deepFreeze } from "./freeze.js"; +import { invariant, type Invariant } from "./invariant.js"; import { renderIssue } from "./issues.js"; import { attachSchema } from "./schema.js"; import { shape, type OnlyNominal } from "./shape.js"; @@ -71,7 +72,9 @@ export function Entity(tag: Tag) { readonly generated?: G; readonly immutable?: I; readonly computed?: { [K in keyof A]: ComputedField> }; - readonly invariants?: (d: OutputOf) => readonly string[]; + // The *declared* fields, not `OutputOf`. A rule cannot read a computed + // field — see `invariant.ts` for why that is both sound and necessary. + readonly invariants?: readonly Invariant>[]; }, ): EntityStatic { const input = shape(fields); @@ -205,7 +208,12 @@ export function Entity(tag: Tag) { Ctor: new (d: Sealed) => T, d: OutputShape, ): Result => { - const broken = invariants?.(d) ?? []; + // Every failing rule reports, not just the first. A predicate that throws + // escapes to the defect channel via the `fromThrowable` around `make`, + // which is what a bug in a rule should be. + const broken = (invariants ?? []) + .filter((rule) => !rule.ensure(d)) + .map((rule) => rule.describe(d)); // no `path` — an invariant spans the entity, not one field if (broken.length > 0) { return Err( @@ -369,17 +377,32 @@ export function Entity(tag: Tag) { * `equals`, and its own schemas. `class X extends Parent {}` would have * had none of those — same fields, same tag, no way to tell the two apart. * - * Options merge per key, child winning. Inheriting matters more than it - * might look: silently dropping the parent's `immutable` or `invariants` - * would leave the extension quietly laxer than what it extends. + * Options merge per key, child winning — except `invariants`, which + * **concatenates** parent-then-child. Inheriting matters more than it might + * look: silently dropping the parent's `immutable` or `invariants` would + * leave the extension quietly laxer than what it extends, and child-wins on + * a list of rules is exactly how that happens. An extension can add rules; + * it cannot shed them. Chained extends compose without duplicating, because + * each `extend` stores the list it already merged. */ Object.defineProperty(Base, "extend", { enumerable: false, value: (nextTag: string) => (nextFields: Fields, nextOptions?: Record) => { const parent = declarations.get(Base); + const parentOptions = parent?.options as + | { readonly invariants?: readonly Invariant[] } + | undefined; + const childInvariants = ( + nextOptions as { readonly invariants?: readonly Invariant[] } | undefined + )?.invariants; + const invariants = [...(parentOptions?.invariants ?? []), ...(childInvariants ?? [])]; return (Entity as (t: string) => (f: Fields, o?: unknown) => unknown)(nextTag)( { ...parent?.fields, ...nextFields }, - { ...parent?.options, ...nextOptions }, + { + ...parent?.options, + ...nextOptions, + ...(invariants.length > 0 ? { invariants } : {}), + }, ); }, }); @@ -397,6 +420,7 @@ export function Entity(tag: Tag) { * that test on its own, and is grouped anyway so the rule has no exceptions. */ Entity.computed = computed; +Entity.invariant = invariant; Entity.union = union; Entity.InvalidEntity = InvalidEntity; @@ -414,6 +438,7 @@ Entity.InvalidEntity = InvalidEntity; * `consumer/` is a failure here, not noise. */ type ComputedFieldSrc = ComputedField; +type InvariantSrc = Invariant; type EntityUnionSrc = EntityUnion; type ConstructionKeySrc = ConstructionKey; type SealedSrc = Sealed; @@ -439,6 +464,9 @@ export declare namespace Entity { /** One derived field: its schema, and the function that produces it. */ export type ComputedField = ComputedFieldSrc; + /** One whole-entity rule: the predicate, and what to say when it fails. */ + export type Invariant = InvariantSrc; + // `InvalidEntity` is a class, so it needs both meanings under `Entity`: the // value for `instanceof`, the type for annotations. A re-export carries both, // where a `type` member would shadow the value and reject the runtime diff --git a/packages/entity/src/extend.spec.ts b/packages/entity/src/extend.spec.ts index cb01bf1..0c6ece0 100644 --- a/packages/entity/src/extend.spec.ts +++ b/packages/entity/src/extend.spec.ts @@ -16,7 +16,7 @@ class Person extends Entity("Person")( computed: { shout: Entity.computed(Upper, (d) => d.name.toUpperCase() as z.infer), }, - invariants: (d) => (d.name.length <= 20 ? [] : ["name must be at most 20 chars"]), + invariants: [Entity.invariant((d) => d.name.length <= 20, "name must be at most 20 chars")], }, ) {} @@ -63,8 +63,24 @@ test("the parent's immutable list carries over", () => { }); test("a child option overrides the parent's for that key", () => { - class Loose extends Person.extend("Loose")({ age: Age }, { invariants: () => [] }) {} - expect(Loose.make({ id, name: "x".repeat(21), age: 1 }).isOk()).toBe(true); + class Loose extends Person.extend("Loose")({ age: Age }, { immutable: [] }) {} + expect(Object.keys(Loose.updateInput.shape).toSorted()).toEqual(["age", "id", "name"]); +}); + +test("invariants are the exception: a child adds to the parent's, never replaces", () => { + class Stricter extends Person.extend("Stricter")( + { age: Age }, + { invariants: [Entity.invariant((d) => d.age >= 18, "must be an adult")] }, + ) {} + // the child's own rule applies + expect(Stricter.make({ id, name: "ada", age: 1 }).isErr()).toBe(true); + // and the parent's still does — declaring invariants must not shed them + expect(Stricter.make({ id, name: "x".repeat(21), age: 30 }).isErr()).toBe(true); +}); + +test("an extension cannot relax the parent by declaring an empty list", () => { + class Loose extends Person.extend("Loose")({ age: Age }, { invariants: [] }) {} + expect(Loose.make({ id, name: "x".repeat(21), age: 1 }).isErr()).toBe(true); }); test("the extension's own schemas include both halves", () => { diff --git a/packages/entity/src/invariant.ts b/packages/entity/src/invariant.ts new file mode 100644 index 0000000..8e0973d --- /dev/null +++ b/packages/entity/src/invariant.ts @@ -0,0 +1,53 @@ +/** + * One whole-entity rule: the predicate, and what to say when it fails. + * + * `describe` is always a function — `invariant` normalises a plain string into + * one — so a rule has a single uniform shape and `construct` needs no branch. + */ +export type Invariant = { + readonly ensure: (d: D) => boolean; + readonly describe: (d: D) => string; +}; + +/** + * Declares one rule spanning the whole entity: + * + * ```ts + * invariants: [ + * invariant((d) => d.name.length <= 80, "name must be at most 80 characters"), + * invariant( + * (d) => d.endsAt > d.startsAt, + * (d) => `endsAt must be after ${d.startsAt}`, + * ), + * ] + * ``` + * + * `ensure` returning **true** means valid — the rule reads as the assertion it + * makes, not as the failure it detects. `D` is fixed by the expected element + * type of the surrounding array, so `d` needs no annotation. + * + * `d` is the **declared** fields, not the output: a rule cannot read a computed + * field. Every computed value is a function of the declared data, so any rule + * about one is expressible over its sources, and a computed value that fails + * its own schema is already a Defect rather than something to re-check here. + * Typing `d` as the output would also make it unusable — `OutputOf` + * carries the deferred `ComputedOf` conditional, and `A` is not yet resolved + * when this array is checked, so `d` would degrade to a bag of `unknown`. + * + * `message` takes the data when the text depends on it. Every failing rule in + * the list reports, not just the first, and none carries a `path`: an invariant + * spans the entity, which is what distinguishes it from a field complaint. + * + * A predicate that throws is a Defect rather than an `InvalidEntity`, on the + * same reasoning as `computed` — a rule is pure and total, so a violation is a + * bug in domain code rather than bad caller input. + */ +export function invariant( + ensure: (d: D) => boolean, + message: string | ((d: D) => string), +): Invariant { + return { + ensure, + describe: typeof message === "function" ? message : () => message, + }; +} diff --git a/packages/entity/src/nesting.spec.ts b/packages/entity/src/nesting.spec.ts index d07ed6c..d4f178b 100644 --- a/packages/entity/src/nesting.spec.ts +++ b/packages/entity/src/nesting.spec.ts @@ -96,10 +96,12 @@ test("an invariant can span the outer entity and a nested one", () => { class Checked extends Entity("Checked")( { id: OrderId, customer: Customer, note: Line }, { - invariants: (d) => - d.note.length >= d.customer.name.length - ? [] - : ["note must be at least as long as the name"], + invariants: [ + Entity.invariant( + (d) => d.note.length >= d.customer.name.length, + "note must be at least as long as the name", + ), + ], }, ) {} const ok = Checked.make({ id: oid, customer: { id: cid, name: "ada" }, note: "rush" }); diff --git a/packages/entity/src/schema.spec.ts b/packages/entity/src/schema.spec.ts index e16a14d..45356fc 100644 --- a/packages/entity/src/schema.spec.ts +++ b/packages/entity/src/schema.spec.ts @@ -9,7 +9,7 @@ const Slug = z.string().min(1).brand("Slug"); class Organization extends Entity("Organization")( { id: OrgId, slug: Slug }, - { invariants: (d) => (d.slug === "reserved" ? ["slug must not be reserved"] : []) }, + { invariants: [Entity.invariant((d) => d.slug !== "reserved", "slug must not be reserved")] }, ) {} const raw = { id: "0199b1f4-1b1e-7000-8000-000000000000", slug: "acme" }; @@ -63,12 +63,14 @@ test("a defect during make propagates instead of becoming a validation issue", ( class Buggy extends Entity("Buggy")( { id: OrgId }, { - invariants: () => { - // deliberately simulate an unmodeled defect, to pin that the schema - // lets it propagate rather than folding it into a zod issue - // oxlint-disable-next-line unthrown/no-throw - throw new Error("boom"); - }, + invariants: [ + Entity.invariant(() => { + // deliberately simulate an unmodeled defect, to pin that the schema + // lets it propagate rather than folding it into a zod issue + // oxlint-disable-next-line unthrown/no-throw + throw new Error("boom"); + }, "unreachable — the predicate always throws"), + ], }, ) {} expect(() => z.array(Buggy).parse([{ id: raw.id }])).toThrow("boom"); diff --git a/packages/entity/src/types.ts b/packages/entity/src/types.ts index e8b8930..218169f 100644 --- a/packages/entity/src/types.ts +++ b/packages/entity/src/types.ts @@ -3,6 +3,7 @@ import type { z } from "zod"; import type { ComputedField as ComputedFieldOf } from "./computed.js"; import type { InvalidEntity } from "./errors.js"; +import type { Invariant as InvariantOf } from "./invariant.js"; import type { OnlyNominal } from "./shape.js"; /** @@ -319,7 +320,7 @@ export type EntityStatic< readonly generated?: G2; readonly immutable?: I2; readonly computed?: { [K in keyof A2]: ComputedFieldOf> }; - readonly invariants?: (d: OutputOf) => readonly string[]; + readonly invariants?: readonly InvariantOf>[]; }, ) => EntityStatic; factory(