diff --git a/.changeset/nest-entities.md b/.changeset/nest-entities.md new file mode 100644 index 0000000..da64566 --- /dev/null +++ b/.changeset/nest-entities.md @@ -0,0 +1,33 @@ +--- +"@btravstack/entity": minor +--- + +An entity can now declare another entity as a field, so an aggregate is itself +an entity rather than a bare `z.object(...)`: + +```ts +class Order extends Entity("Order")({ + id: OrderId, + customer: Customer.instance, + watchers: z.array(Customer.instance), +}) {} +``` + +The nested entities keep their behaviour, computed fields and `_tag`; +invariants can span the outer entity and a nested one; a nested validation +failure reports the full path; and `JSON.stringify` walks the tree to plain +data. Previously the field map rejected `Customer.instance`, so an aggregate +had to be a plain schema and lost `make`, `update`, invariants and immutability. + +`instance` also now carries `_tag` in its type, matching what it has always set +at runtime. + +The rejection message for a genuinely unbranded field is readable now — it +names `DomainFieldMustBeBrandedOrAnEntity` instead of a tuple TypeScript +truncated to `& [...]`. + +A field may no longer take a name the entity installs on every instance — +`_tag`, `equals`, `toJSON` or `update`. Such a field used to shadow the member +silently: a field called `update` left `entity.update` holding a string, with +the method gone and no error anywhere. It is now a compile error naming +`FieldNameIsReservedByEntity`. diff --git a/README.md b/README.md index 8c288e9..56e8635 100644 --- a/README.md +++ b/README.md @@ -56,9 +56,9 @@ That one declaration gives you: straight to a class instance; - **behaviour** — the class body (`greeting` above) plus built-in `update`/`encode`/`toJSON`/`equals`; -- **composability** — `Organization.instance` nests inside `z.object({...})` - or `z.array(...)` and parses to a real `Organization`, so an aggregate can - hold other entities without losing their behaviour. +- **composability** — `Organization.instance` is a field like any other, so an + aggregate is itself an entity rather than a bare schema, and the entities + inside it keep their behaviour. Every fallible operation returns an [`unthrown`](https://github.com/btravstack/unthrown) `Result` @@ -159,13 +159,30 @@ const ResponseBody = Organization.output; ``` `instance` is the composable surface for domain code — the only member that -produces real class instances: +produces real class instances. It is a valid **field**, so an aggregate is an +entity in its own right, with the invariants, immutability and entry points +that implies: ```ts -const Aggregate = z.object({ - organization: Organization.instance, - members: z.array(Member.instance), -}); +class Order extends Entity("Order")({ + id: OrderId, + customer: Customer.instance, + watchers: z.array(Customer.instance), +}) {} + +const order = Order.make(row).getOrThrow(); +order.customer instanceof Customer; // true +order.customer.shout; // the nested entity keeps its computed fields +order.customer._tag; // and its tag, for `P.tag(...)` matching +``` + +An invariant can span the outer entity and a nested one, a nested failure +reports the full path (`["customer", "name"]`), and `JSON.stringify` walks the +whole tree down to plain data. It still composes into a plain `z.object(...)` +too, when what you want is a schema rather than an entity: + +```ts +const Aggregate = z.object({ organization: Organization.instance }); Aggregate.parse(raw).organization instanceof Organization; // true ``` @@ -249,6 +266,10 @@ Person.make(person.toJSON()); // ✓ also fine — computed keys are re-derived } ``` +A field may not be named `_tag`, `equals`, `toJSON` or `update`: those are +installed on every instance, and a data field of the same name would shadow +one silently. The field map rejects them. + Both are **arrays of field names**, and both are keyed off `keyof S` (`generated`) or `keyof output` (`immutable`), so a typo — `immutable: ["slugg"]` — is a compile error, not a silently-mutable field. diff --git a/packages/entity/README.md b/packages/entity/README.md index e5833db..ad79e36 100644 --- a/packages/entity/README.md +++ b/packages/entity/README.md @@ -62,18 +62,18 @@ class name it labels, ahead of the field map. ## Statics -| Static | Kind | Purpose | -| -------------------- | --------------- | ------------------------------------------------------------------------- | -| `entityName` | `string` | the tag passed to `Entity(tag)` | -| `input` | `ZodObject` | the full wire object | -| `output` | `ZodObject` | stored state and response body | -| `createInput` | `ZodObject` | create request — `input` minus `generated` | -| `updateInput` | `ZodObject` | update request — `output` minus `immutable`, partial | -| `instance` | `ZodType` | parses straight to a class instance, for nesting entities in domain code | -| `~standard` | Standard Schema | `instance`'s Standard Schema entry point | -| `make(state)` | method | already-stored state → entity, for row mappers and event folds | -| `factory(gens)` | method | binds the generated fields' sources → `{ create(input) }` | -| `factoryAsync(gens)` | method | same, for promise-returning generators → `{ create(input): AsyncResult }` | +| Static | Kind | Purpose | +| -------------------- | --------------- | --------------------------------------------------------------------------------- | +| `entityName` | `string` | the tag passed to `Entity(tag)` | +| `input` | `ZodObject` | the full wire object | +| `output` | `ZodObject` | stored state and response body | +| `createInput` | `ZodObject` | create request — `input` minus `generated` | +| `updateInput` | `ZodObject` | update request — `output` minus `immutable`, partial | +| `instance` | `ZodType` | parses straight to a class instance; valid as a field, so aggregates are entities | +| `~standard` | Standard Schema | `instance`'s Standard Schema entry point | +| `make(state)` | method | already-stored state → entity, for row mappers and event folds | +| `factory(gens)` | method | binds the generated fields' sources → `{ create(input) }` | +| `factoryAsync(gens)` | method | same, for promise-returning generators → `{ create(input): AsyncResult }` | **Contracts compose the four `ZodObject`s (`input`, `output`, `createInput`, `updateInput`); domain code composes `instance`.** All four `ZodObject`s diff --git a/packages/entity/src/nesting.spec.ts b/packages/entity/src/nesting.spec.ts new file mode 100644 index 0000000..57300d1 --- /dev/null +++ b/packages/entity/src/nesting.spec.ts @@ -0,0 +1,113 @@ +import { P } from "unthrown"; +import { expect, test } from "vitest"; +import { z } from "zod"; + +import { Entity, computed } from "./index.js"; + +const CustomerId = z.uuid().brand("CustomerId"); +const OrderId = z.uuid().brand("OrderId"); +const Name = z.string().min(1).brand("Name"); +const Upper = z.string().min(1).brand("Upper"); +const Line = z.string().min(1).brand("Line"); + +class Customer extends Entity("Customer")( + { id: CustomerId, name: Name }, + { computed: { shout: computed(Upper, (d) => d.name.toUpperCase() as z.infer) } }, +) {} + +/** An aggregate: an entity whose fields are other entities. */ +class Order extends Entity("Order")({ + id: OrderId, + customer: Customer.instance, + watchers: z.array(Customer.instance), + note: Line, +}) {} + +const cid = "0199b1f4-1b1e-7000-8000-000000000001"; +const cid2 = "0199b1f4-1b1e-7000-8000-000000000002"; +const oid = "0199b1f4-1b1e-7000-8000-000000000003"; + +const raw = { + id: oid, + customer: { id: cid, name: "ada" }, + watchers: [{ id: cid2, name: "grace" }], + note: "rush", +}; + +test("an entity can declare another entity as a field", () => { + const order = Order.make(raw).getOrThrow(); + expect(order.customer).toBeInstanceOf(Customer); + expect(order.customer.name).toBe("ada"); +}); + +test("a nested entity keeps its behaviour and its computed fields", () => { + const order = Order.make(raw).getOrThrow(); + expect(order.customer.shout).toBe("ADA"); + expect(order.customer._tag).toBe("Customer"); + expect(order.customer.equals(Customer.make({ id: cid, name: "ada" }).getOrThrow())).toBe(true); +}); + +test("entities nest inside an array field too", () => { + const order = Order.make(raw).getOrThrow(); + expect(order.watchers[0]).toBeInstanceOf(Customer); + expect(order.watchers[0]?.shout).toBe("GRACE"); +}); + +test("a nested entity's own validation failure surfaces with its path", () => { + const issues = Order.make({ ...raw, customer: { id: cid, name: "" } }).match({ + ok: () => [] as readonly (readonly PropertyKey[])[], + errCases: (m) => + m.with(P.tag("InvalidEntity"), (e) => e.issues.map((i) => [...(i.path ?? [])])), + defect: () => [["DEFECT"]], + }); + expect(issues).toEqual([["customer", "name"]]); +}); + +test("JSON.stringify walks nested entities down to plain data", () => { + const order = Order.make(raw).getOrThrow(); + expect(JSON.parse(JSON.stringify(order))).toEqual({ + id: oid, + customer: { id: cid, name: "ada", shout: "ADA" }, + watchers: [{ id: cid2, name: "grace", shout: "GRACE" }], + note: "rush", + }); +}); + +test("a nested entity survives a round trip through make", () => { + const order = Order.make(raw).getOrThrow(); + const again = Order.make(JSON.parse(JSON.stringify(order))).getOrThrow(); + expect(again.customer).toBeInstanceOf(Customer); + expect(again.customer.shout).toBe("ADA"); +}); + +test("updating a sibling field leaves the nested entity intact", () => { + const order = Order.make(raw).getOrThrow(); + const updated = order.update({ note: "later" as z.infer }).getOrThrow(); + expect(updated.note).toBe("later"); + expect(updated.customer).toBeInstanceOf(Customer); + expect(updated.customer.shout).toBe("ADA"); +}); + +test("an invariant can span the outer entity and a nested one", () => { + class Checked extends Entity("Checked")( + { id: OrderId, customer: Customer.instance, note: Line }, + { + invariants: (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" }); + const bad = Checked.make({ id: oid, customer: { id: cid, name: "grace" }, note: "x" }); + expect(ok.isOk()).toBe(true); + expect(bad.isErr()).toBe(true); +}); + +test("a nested entity is not re-frozen into uselessness", () => { + const order = Order.make(raw).getOrThrow(); + // the nested entity locked its own fields; the outer freeze must not have + // stripped its prototype methods + expect(typeof order.customer.toJSON).toBe("function"); + expect(order.customer.toJSON()).toEqual({ id: cid, name: "ada", shout: "ADA" }); +}); diff --git a/packages/entity/src/shape.test-d.ts b/packages/entity/src/shape.test-d.ts index 7314430..453c195 100644 --- a/packages/entity/src/shape.test-d.ts +++ b/packages/entity/src/shape.test-d.ts @@ -1,10 +1,12 @@ import { assertType, describe, test } from "vitest"; import { z } from "zod"; +import { Entity } from "./index.js"; import { shape } from "./shape.js"; describe("shape() rejects unbranded scalars", () => { const Id = z.uuid().brand("Id"); + const Name = z.string().min(1).brand("Name"); const Slug = z .string() .regex(/^[a-z0-9-]{3,40}$/u) @@ -68,4 +70,37 @@ describe("shape() rejects unbranded scalars", () => { const wrong: OrgId = null as unknown as z.infer; void wrong; }); + + test("another entity's `.instance` is a valid field", () => { + class Customer extends Entity("Customer")({ id: Id, name: Name }) {} + shape({ id: Id, customer: Customer.instance }); + shape({ id: Id, watchers: z.array(Customer.instance) }); + }); + + test("a nested entity keeps its behaviour and tag through the field", () => { + class Customer extends Entity("Customer")({ id: Id, name: Name }) {} + class Order extends Entity("Order")({ id: Id, customer: Customer.instance }) {} + const order = Order.make({}).getOrThrow(); + const tag: "Customer" = order.customer._tag; + const name: z.infer = order.customer.name; + void tag; + void name; + // @ts-expect-error a nested entity's data is still read-only + order.customer.name = name; + }); + + test("a field may not take a name the entity installs on every instance", () => { + // @ts-expect-error `update` would shadow the prototype method + shape({ id: Id, update: Name }); + // @ts-expect-error `equals` would shadow the prototype method + shape({ id: Id, equals: Name }); + // @ts-expect-error `toJSON` would shadow the projection + shape({ id: Id, toJSON: Name }); + // @ts-expect-error `_tag` is the runtime tag + shape({ id: Id, _tag: Name }); + }); + + test("names that merely resemble reserved ones are fine", () => { + shape({ id: Id, updatedAt: Name, equality: Name, tag: Name, json: Name }); + }); }); diff --git a/packages/entity/src/shape.ts b/packages/entity/src/shape.ts index abaf95e..d152793 100644 --- a/packages/entity/src/shape.ts +++ b/packages/entity/src/shape.ts @@ -27,7 +27,30 @@ type IsNarrowLiteral = T extends string type StripUndefined = T extends undefined ? never : T; -type IsNominalScalar = T extends Nominal ? true : IsNarrowLiteral extends true ? true : false; +/** + * Another entity, reached through its `.instance` schema. + * + * Checked structurally rather than against `BaseInstance` itself: that + * interface is generic in the entity's own shape, and there is no argument + * that matches every entity — `never` is too narrow to match any, and the + * field map has no way to name the specific one. These three members are what + * every entity instance has and nothing else in a field map does. + */ +type IsEntity = T extends { + readonly toJSON: () => unknown; + readonly equals: (other: unknown) => boolean; + readonly update: (patch: never) => unknown; +} + ? true + : false; + +type IsNominalScalar = T extends Nominal + ? true + : IsEntity extends true + ? true + : IsNarrowLiteral extends true + ? true + : false; /** Strips `undefined` (for `.optional()`) and unwraps one array level before checking. */ type IsNominalField = @@ -35,10 +58,38 @@ type IsNominalField = ? IsNominalScalar> : IsNominalScalar>; +/** + * The rejection types. Named rather than tuples of strings: a tuple prints as + * `& [...]` once TypeScript truncates, hiding the advice, whereas a name + * survives truncation and *is* the message. + */ +type DomainFieldMustBeBrandedOrAnEntity = { + readonly __domainFieldMustBeBrandedOrAnEntity: never; +}; + +type FieldNameIsReservedByEntity = { + readonly __fieldNameIsReservedByEntity: never; +}; + +/** + * Names an entity installs on every instance. A data field taking one of these + * would shadow it silently — measured: a field called `update` leaves + * `entity.update` holding a string, with the method simply gone and no error + * anywhere. Rejecting the name is the only signal available, since the clash + * is invisible at runtime. + * + * Statics (`input`, `make`, …) are deliberately absent: shadowing one takes a + * `static` declaration the author wrote themselves, so it is visible in a way + * this is not. + */ +type ReservedFieldName = "_tag" | "equals" | "toJSON" | "update"; + type OnlyNominal> = { - [K in keyof T]: IsNominalField> extends true - ? T[K] - : ["ERROR: domain fields must be branded", K]; + [K in keyof T]: K extends ReservedFieldName + ? FieldNameIsReservedByEntity + : IsNominalField> extends true + ? T[K] + : DomainFieldMustBeBrandedOrAnEntity; }; /** The only sanctioned way to declare a domain shape. */ diff --git a/packages/entity/src/types.ts b/packages/entity/src/types.ts index 12d9a92..c43b888 100644 --- a/packages/entity/src/types.ts +++ b/packages/entity/src/types.ts @@ -273,9 +273,11 @@ export type EntityStatic< * needs the subclass's own members back must narrow explicitly (e.g. * `instanceof`) after parsing. */ - readonly instance: z.ZodType & DeepReadonly>>; + readonly instance: z.ZodType< + BaseInstance & DeepReadonly> & { readonly _tag: Tag } + >; readonly "~standard": z.ZodType< - BaseInstance & DeepReadonly> + BaseInstance & DeepReadonly> & { readonly _tag: Tag } >["~standard"]; /** phantom carriers, so consumers can recover the shapes for annotations */ readonly __input: InputOf;