diff --git a/.changeset/one-entry-point.md b/.changeset/one-entry-point.md new file mode 100644 index 0000000..941f50e --- /dev/null +++ b/.changeset/one-entry-point.md @@ -0,0 +1,23 @@ +--- +"@btravstack/entity": minor +--- + +**BREAKING**: `decode` is removed and the schema members are renamed. + +`decode` and `make` had become the same function — both parse against the input +schema, re-derive the computed fields, check the invariants and construct. Two +public names for one operation, so there is now one: `make`. + +`encoded`/`decoded` were named after those operations, so they are renamed for +what they are _for_, matching the `createInput`/`updateInput` vocabulary they +sit beside: + +| before | after | +| ------------------ | ---------------- | +| `Entity.decode(x)` | `Entity.make(x)` | +| `Entity.encoded` | `Entity.input` | +| `Entity.decoded` | `Entity.output` | +| `Encoded` | `Input` | +| `Decoded` | `Output` | + +`createInput` and `updateInput` are unchanged. diff --git a/CLAUDE.md b/CLAUDE.md index 1c44045..25c76bf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -46,10 +46,10 @@ Node is pinned in `.node-version` (24.16.0); pnpm 11.7.0 via `packageManager` Six source modules under `packages/entity/src`, split by what they own: - **`entity.ts`** — the builder. `Entity(tag)(fields, options)` derives the - four `ZodObject`s (`encoded`, `decoded`, `createInput`, `updateInput`) from + four `ZodObject`s (`input`, `output`, `createInput`, `updateInput`) from one field map plus `generated` / `immutable` / `computed`, then returns a `Base` class carrying them as statics. `create` delegates to - `decode`; `update` delegates to `make`; every path funnels through + `make`; `update` delegates to `make`; every path funnels through `construct`, which runs `invariants` and seals the constructor call. Data fields are installed with `Object.defineProperty(..., { writable: false })` and `_tag` non-enumerably, which is why `_tag` never reaches `toJSON()`, @@ -61,7 +61,7 @@ Six source modules under `packages/entity/src`, split by what they own: deliberately leaves `Map`/`Set`/class instances alone. The constructor passes one `WeakSet` across every field, so a subtree two fields share is walked once. -- **`types.ts`** — the whole type-level derivation (`DecodedOf`, +- **`types.ts`** — the whole type-level derivation (`OutputOf`, `CreateInputOf`, `PatchOf`, `UpdateInputShapeOf`, `EntityStatic`), plus `Sealed`, the module-private `unique symbol` that makes `new X(...)` a compile error. Written independently of the builder's body-local values so diff --git a/README.md b/README.md index b8e8351..8c288e9 100644 --- a/README.md +++ b/README.md @@ -20,8 +20,8 @@ absorb. gets all four right at once, and is the closest existing prior art. This package targets the same shape of solution on top of **zod v4** and **[Standard Schema](https://standardschema.dev)**, with entry points named for -the use case they serve (`create`, `update`, `make`, `decode`) instead of one -generic `decode`. +the use case they serve (`create`, `update`, `make`, `make`) instead of one +generic `make`. ```ts import { z } from "zod"; @@ -50,14 +50,14 @@ That one declaration gives you: - a **type** — `Organization`'s data fields, read-only, each carrying a branded (nominal) type; -- **validators** — `Organization.encoded` / `.decoded` / `.createInput` / +- **validators** — `Organization.input` / `.output` / `.createInput` / `.updateInput`, four plain `ZodObject`s a contract layer can hand straight to a JSON Schema converter, plus `Organization.instance` for decoding 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 decodes to a real `Organization`, so an aggregate can + or `z.array(...)` and parses to a real `Organization`, so an aggregate can hold other entities without losing their behaviour. Every fallible operation returns an @@ -118,7 +118,7 @@ org.equals(otherOrg); // true when both are `Organization` and their stored data Organization.make(rowFromDatabase); // A full untrusted payload — an import, a replayed integration event: -Organization.decode(rawJson); +Organization.make(rawJson); ``` `new Organization(...)` does not compile — construction is **sealed**; see @@ -127,11 +127,11 @@ Organization.decode(rawJson); ## The five schema members ```ts -Organization.encoded; // ZodObject — full wire object; decode() accepts -Organization.decoded; // ZodObject — stored state and response body; make() accepts -Organization.createInput; // ZodObject — encoded minus generated -Organization.updateInput; // ZodObject — decoded minus immutable, partial -Organization.instance; // ZodType — decodes to a class instance +Organization.input; // ZodObject — everything make() accepts +Organization.output; // ZodObject — stored state and response body; make() accepts +Organization.createInput; // ZodObject — input minus generated +Organization.updateInput; // ZodObject — output minus immutable, partial +Organization.instance; // ZodType — parses to a class instance ``` **Contracts compose the four `ZodObject`s. Domain code composes `instance`.** @@ -140,14 +140,14 @@ constraint in zod's schema-to-JSON-Schema conversion: A schema that carries a `.transform()` — which is what turns parsed data into a class instance — **has no output representation**. `instance` does exactly -that (it decodes to `Organization`, not to plain data), so: +that (it parses to `Organization`, not to plain data), so: ```ts -z.toJSONSchema(Organization.decoded, { io: "output" }); // ✓ real JSON Schema +z.toJSONSchema(Organization.output, { io: "output" }); // ✓ real JSON Schema z.toJSONSchema(Organization.instance, { io: "output" }); // ✗ throws — by design ``` -The four plain `ZodObject`s (`encoded`, `decoded`, `createInput`, +The four plain `ZodObject`s (`input`, `output`, `createInput`, `updateInput`) generate JSON Schema in **both** `"input"` and `"output"` directions, so an HTTP contract layer can hand them straight to a schema converter with no hand-written omit lists: @@ -155,7 +155,7 @@ converter with no hand-written omit lists: ```ts const CreateBody = Organization.createInput; const UpdateBody = Organization.updateInput; -const ResponseBody = Organization.decoded; +const ResponseBody = Organization.output; ``` `instance` is the composable surface for domain code — the only member that @@ -184,17 +184,16 @@ parseOrg(raw).getOrThrow(); // Organization It does **not** make `z.object({ owner: Organization })` work — zod requires a real `ZodType`, so nesting always goes through `Organization.instance`. -## The four entry points +## The three entry points -| Entry point | Input | Use | -| ------------------------------------ | ------------------------------- | -------------------------------------------- | -| `Entity.factory(gens).create(input)` | caller fields only | a create use case | -| `entity.update(patch)` | a partial of the mutable fields | an update use case | -| `Entity.make(state)` | full stored state | row mappers, event folds | -| `Entity.decode(raw)` | full encoded payload | untrusted input already carrying every field | +| Entry point | Input | Use | +| ------------------------------------ | ------------------------------- | ------------------------------------------------------------------ | +| `Entity.factory(gens).create(input)` | caller fields only | a create use case | +| `entity.update(patch)` | a partial of the mutable fields | an update use case | +| `Entity.make(data)` | everything `input` describes | a row, a folded event stream, an untrusted import, a nested entity | -`create` is the one entry point reached through a factory, because it is the -only one that needs values the domain generates rather than receives. +`create` is the one reached through a factory, because it is the only one that +needs values the domain generates rather than receives. The types and the schemas are derived from the same declarations, so the rules are compile-time facts: @@ -220,26 +219,25 @@ and a test binds fixed generators instead of stubbing the domain is the _rule_ — which fields the domain owns, and that a caller may never send them. -**Why `decode` survives alongside `create`/`update`/`make`.** `create` and -`update` cover the use-case paths and `make` covers rehydration from state -that's already valid shape (a database row, a folded event stream). `decode` -remains for a full encoded payload from an untrusted source that legitimately -carries every field: an import, a replayed integration event, an entity -nested inside another entity's payload (`instance` runs `decode` under the -hood, which is why nesting works). +**Why one `make` and not a separate `decode`.** They would be the same +function. Rehydrating a database row and validating an untrusted import differ +in where the data came from, not in what has to happen to it — parse against +`input`, re-derive the computed fields, check the invariants, construct. A +second name for that would be an alias, so there is one: `make`. `instance` +runs it under the hood, which is why nesting works. `update` returns a **new** entity — data is immutable — and re-runs `invariants`, so a patch where every individual field is valid but the combination is not still fails. `toJSON()` returns the **stored** shape, so it pairs naturally with `make`. -`decode` accepts it too: the stored shape is the wire shape plus the computed +`make` accepts it too: the stored shape is the wire shape plus the computed fields, and a computed field is re-derived rather than read, so the extra keys are simply ignored. ```ts Person.make(person.toJSON()); // ✓ the natural pairing -Person.decode(person.toJSON()); // ✓ also fine — computed keys are re-derived +Person.make(person.toJSON()); // ✓ also fine — computed keys are re-derived ``` ## `generated` and `immutable` @@ -252,13 +250,13 @@ Person.decode(person.toJSON()); // ✓ also fine — computed keys are re-derive ``` Both are **arrays of field names**, and both are keyed off `keyof S` -(`generated`) or `keyof decoded` (`immutable`), so a typo — `immutable: +(`generated`) or `keyof output` (`immutable`), so a typo — `immutable: ["slugg"]` — is a compile error, not a silently-mutable field. ## `computed` A computed field is derived from the declared ones, carries a schema, and is -**re-derived on every construction** — `decode`, `make` and `update` alike: +**re-derived on every construction** — `make`, `make` and `update` alike: ```ts import { z } from "zod"; @@ -286,7 +284,7 @@ class Person extends Entity("Person")( }, ) {} -const p = Person.decode({ +const p = Person.make({ id: "0199b1f4-1b1e-7000-8000-000000000000", first: "Ada", last: "Lovelace", @@ -303,7 +301,7 @@ checked against **that field's** schema, so a wrong brand reports on the field that produced it rather than on the whole map. **Why not a getter?** Because a getter carries no schema. It cannot appear in -`decoded`, cannot generate JSON Schema, and is skipped by `toJSON()` — it lives +`output`, cannot generate JSON Schema, and is skipped by `toJSON()` — it lives on the prototype, not in the data. The rule: | | use | @@ -350,7 +348,7 @@ class Organization extends Entity("Organization")( ) {} ``` -`invariants` receives the stored (`decoded`) data and returns the messages of +`invariants` receives the stored (`output`) data and returns the messages of the broken rules — an empty array means valid, and it can return **more than one** message, so a caller violating two rules at once learns about both: @@ -366,7 +364,7 @@ invariants: (d) => [ ``` It runs before the instance exists, on **every** entry point — `create`, -`update`, `make` and `decode`. Because data is _deeply_ immutable once +`update`, `make` and `make`. Because data is _deeply_ immutable once constructed — frozen values, not just locked bindings, see [Immutability](#immutability) — a rule that holds at construction holds for the instance's entire lifetime: an entity that rejects three tags cannot be @@ -375,13 +373,13 @@ pushed into holding three tags afterwards. ## `equals` Two entities are equal when they are instances of the **same entity** and -their **encoded data is deep-equal**: +their **stored data is deep-equal**: ```ts const a = Organization.make(state).getOrThrow(); const b = Organization.make(state).getOrThrow(); a === b; // false — different instances -a.equals(b); // true — same encoded data +a.equals(b); // true — same stored data a.equals(a.update({ name: other }).getOrThrow()); // false — data differs someOrg.equals(someApiKey); // false — different entities, even with identical field values @@ -426,15 +424,15 @@ class ServiceAccount extends Entity("ServiceAccount")({ }) {} const Member = z.discriminatedUnion("kind", [ - User.decoded, - ServiceAccount.decoded, + User.output, + ServiceAccount.output, ]); ``` This parses both members, rejects an unknown discriminant, and generates JSON Schema in both directions with one branch per member — because `kind` is a real field on a real `ZodObject`, not framework metadata layered on top. A -union of the `instance` surfaces decodes to the right class: +union of the `instance` surfaces parses to the right class: ```ts const Instances = z.union([User.instance, ServiceAccount.instance]); @@ -484,13 +482,13 @@ path (`["tags", 0]`, `["address", "city"]`); an `invariants` violation has none, which is what distinguishes a whole-entity rule from a field complaint. ```ts -ApiKey.decode({ ...raw, secret: "short" }); +ApiKey.make({ ...raw, secret: "short" }); // Err(InvalidEntity { // entity: "ApiKey", // issues: [{ path: ["secret"], message: "Too small: expected string to have >=16 characters" }], // }) -Trial.decode(brokenRow); +Trial.make(brokenRow); // Err(InvalidEntity { // entity: "Trial", // issues: [{ message: "trialEndsAt must be after createdAt" }], // an invariant: no path @@ -519,8 +517,8 @@ new Organization({ id, slug, name, createdAt }); // ✗ compile error The constructor is closed by a module-private `unique symbol` the package never exports, so no outside code can produce a value satisfying it — every -instance is built through `create`/`update`/`make`/`decode`, which means -`invariants` has run and the stored data is exactly the shape `decoded` +instance is built through `create`/`update`/`make`/`make`, which means +`invariants` has run and the stored data is exactly the shape `output` describes. This is a compile-time-only mechanism (there is no runtime constructor guard to keep in sync, and none is needed for the type-level guarantee it gives you), and it survives a published build: the package's @@ -561,7 +559,7 @@ works). It deliberately leaves `Map`, `Set` and anything a `z.custom(...)` or either ineffective (a frozen `Map` still accepts `.set`) or destructive. A field whose schema yields a live mutable object is outside the guarantee. -`toJSON()` still returns the plain `decoded` shape: it builds a fresh object, +`toJSON()` still returns the plain `output` shape: it builds a fresh object, so assigning to _its_ keys is fine and mappers keep working unchanged (the values inside it are the entity's own, and stay frozen). @@ -573,7 +571,7 @@ extensible. Freezing the field values individually leaves that intact: class OrgWithCache extends Entity("OrgWithCache")({ id: OrgId, slug: Slug }) { cachedSummary = ""; } -const org = OrgWithCache.decode(raw).getOrThrow(); +const org = OrgWithCache.make(raw).getOrThrow(); org.cachedSummary = "computed"; // ✓ still writable — it isn't declared data org.toJSON(); // does NOT include cachedSummary — toJSON() projects only the declared schema's keys ``` @@ -585,7 +583,7 @@ supported, and fails at construction with a `Defect`: ```ts class Sub extends Organization {} -Sub.decode(raw); // Defect — not an InvalidEntity: this is a bug in domain code +Sub.make(raw); // Defect — not an InvalidEntity: this is a bug in domain code ``` Put the behaviour in the entity's own class body, which is what it is for: @@ -616,10 +614,10 @@ Four generic type-level helpers name each schema by reading it off an entity class, instead of re-declaring the shape by hand: ```ts -import type { CreateInput, Decoded, Encoded, Patch } from "@btravstack/entity"; +import type { CreateInput, Input, Output, Patch } from "@btravstack/entity"; -type OrgWire = Encoded; // what the wire sends — mapper/request signatures -type OrgState = Decoded; // what make() takes — repository signatures +type OrgWire = Input; // what the wire sends — mapper/request signatures +type OrgState = Output; // what make() takes — repository signatures type OrgCreate = CreateInput; // what create() takes from a caller type OrgPatch = Patch; // what update() takes ``` diff --git a/packages/entity/README.md b/packages/entity/README.md index 39d85a3..e5833db 100644 --- a/packages/entity/README.md +++ b/packages/entity/README.md @@ -35,7 +35,7 @@ org.toJSON(); // stored data — never carries _tag org.equals(other); // equal stored data ``` -Every fallible entry point (`decode`, `make`, `create`, `update`) returns an +Every fallible entry point (`make`, `make`, `create`, `update`) returns an `unthrown` `Result` — call `.getOrThrow()`, `.match()`, or any other `Result` combinator on it, per this library's error-as-values convention. `InvalidEntity.issues` is `SchemaIssues` — Standard Schema issues, @@ -53,30 +53,29 @@ class name it labels, ahead of the field map. `options` are all optional: -| Option | Meaning | -| ------------ | --------------------------------------------------------------------------------------------------------------------- | -| `generated` | keys the domain supplies, not the caller — omitted from `createInput` | -| `immutable` | keys that never change after creation — omitted from `updateInput` | -| `computed` | fields derived from the declared ones, declared with the `computed` helper — re-derived on every construction | -| `invariants` | `(decoded) => readonly string[]` — non-empty means rejected, checked on every `decode`, `make`, `create` and `update` | +| Option | Meaning | +| ------------ | ------------------------------------------------------------------------------------------------------------- | +| `generated` | keys the domain supplies, not the caller — omitted from `createInput` | +| `immutable` | keys that never change after creation — omitted from `updateInput` | +| `computed` | fields derived from the declared ones, declared with the `computed` helper — re-derived on every construction | +| `invariants` | `(output) => readonly string[]` — non-empty means rejected, checked on every `make`, `create` and `update` | ## Statics | Static | Kind | Purpose | | -------------------- | --------------- | ------------------------------------------------------------------------- | | `entityName` | `string` | the tag passed to `Entity(tag)` | -| `encoded` | `ZodObject` | the full wire object | -| `decoded` | `ZodObject` | stored state and response body | -| `createInput` | `ZodObject` | create request — `encoded` minus `generated` | -| `updateInput` | `ZodObject` | update request — `decoded` minus `immutable`, partial | -| `instance` | `ZodType` | decodes straight to a class instance, for nesting entities in domain code | +| `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 | -| `decode(raw)` | method | a full untrusted encoded payload → entity | | `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 (`encoded`, `decoded`, `createInput`, +**Contracts compose the four `ZodObject`s (`input`, `output`, `createInput`, `updateInput`); domain code composes `instance`.** All four `ZodObject`s generate JSON Schema in both `"input"` and `"output"` directions. `instance` carries a transform, so it has no _output_ representation — @@ -100,7 +99,7 @@ test in `contract.spec.ts` pins that. - `update(patch)` — a partial of the mutable fields → a **new** entity, re-running the invariants; immutable fields are dropped even if smuggled in at runtime past the type check -- `toJSON()` — the stored data, projected to exactly the `decoded` schema's +- `toJSON()` — the stored data, projected to exactly the `output` schema's keys, even when the class body declares extra fields. This is the **only** public projection: it is the hook `JSON.stringify` looks for, so it has to exist, and a second method returning the same value under a domain name would be @@ -125,14 +124,14 @@ One entry per derived field. `d` is the declared shape, contextually typed, and each return value is checked against that field's own schema, so every value must already be branded. -A computed field is **re-derived on every construction** — `decode`, `make` and +A computed field is **re-derived on every construction** — `make`, `make` and `update` alike — so it cannot drift from the data it derives from, and `make` heals a row written before the derivation changed. It follows that it is not patchable: absent from `updateInput` and `Patch`, and dropped by `update()` even if smuggled in at runtime. Use a **getter** instead when the derived value is domain-only. A getter -carries no schema, so it cannot reach `decoded`, the JSON Schema, or +carries no schema, so it cannot reach `output`, the JSON Schema, or `toJSON()`; `computed` exists for exactly the cases where it must. ## Helper types @@ -141,10 +140,10 @@ Four generic type-level helpers name each shape by reading it off an entity class, instead of re-declaring it: ```ts -import type { CreateInput, Decoded, Encoded, Patch } from "@btravstack/entity"; +import type { CreateInput, Input, Output, Patch } from "@btravstack/entity"; -type OrgWire = Encoded; // for mapper and request signatures -type OrgState = Decoded; // for `make` and repository signatures +type OrgWire = Input; // for mapper and request signatures +type OrgState = Output; // for `make` and repository signatures type OrgCreate = CreateInput; // what `create` accepts from a caller type OrgPatch = Patch; // what `update` accepts ``` diff --git a/packages/entity/src/computed.spec.ts b/packages/entity/src/computed.spec.ts index 5fe98cf..7fb570f 100644 --- a/packages/entity/src/computed.spec.ts +++ b/packages/entity/src/computed.spec.ts @@ -23,25 +23,25 @@ class Person extends Entity("Person")( const raw = { id: "0199b1f4-1b1e-7000-8000-000000000000", first: "Ada", last: "Lovelace" }; test("every computed field reaches the entity and its stored output", () => { - const p = Person.decode(raw).getOrThrow(); + const p = Person.make(raw).getOrThrow(); expect(p.fullName).toBe("Ada Lovelace"); expect(p.initials).toBe("AL"); expect(p.toJSON()).toMatchObject({ fullName: "Ada Lovelace", initials: "AL" }); }); -test("computed fields are in decoded but not in encoded", () => { - expect(Object.keys(Person.decoded.shape).toSorted()).toEqual([ +test("computed fields are in output but not in input", () => { + expect(Object.keys(Person.output.shape).toSorted()).toEqual([ "first", "fullName", "id", "initials", "last", ]); - expect(Person.encoded.shape).not.toHaveProperty("fullName"); + expect(Person.input.shape).not.toHaveProperty("fullName"); }); test("update re-derives every computed field instead of leaving them stale", () => { - const p = Person.decode(raw).getOrThrow(); + const p = Person.make(raw).getOrThrow(); const renamed = p.update({ last: "Byron" as z.infer }).getOrThrow(); expect(renamed.fullName).toBe("Ada Byron"); expect(renamed.initials).toBe("AB"); @@ -68,14 +68,13 @@ test("make heals a row written before the computed field existed", () => { test("computed fields are absent from updateInput and dropped if smuggled in", () => { expect(Object.keys(Person.updateInput.shape).toSorted()).toEqual(["first", "last"]); - const p = Person.decode(raw).getOrThrow(); + const p = Person.make(raw).getOrThrow(); const lied = p.update({ fullName: "LIES" } as never).getOrThrow(); expect(lied.fullName).toBe("Ada Lovelace"); }); -test("toJSON round-trips through both decode and make", () => { - const p = Person.decode(raw).getOrThrow(); - expect(Person.decode(p.toJSON()).getOrThrow().fullName).toBe("Ada Lovelace"); +test("toJSON round-trips through make", () => { + const p = Person.make(raw).getOrThrow(); expect(Person.make(p.toJSON()).getOrThrow().fullName).toBe("Ada Lovelace"); }); @@ -89,11 +88,11 @@ test("invariants see the computed fields", () => { invariants: (d) => (d.fullName.length <= 20 ? [] : ["fullName must be at most 20 chars"]), }, ) {} - expect(Checked.decode(raw).isOk()).toBe(true); - expect(Checked.decode({ ...raw, last: "Lovelace-Byron-Of-Somewhere" }).isErr()).toBe(true); + expect(Checked.make(raw).isOk()).toBe(true); + expect(Checked.make({ ...raw, last: "Lovelace-Byron-Of-Somewhere" }).isErr()).toBe(true); }); -const outcomeOf = (r: ReturnType) => +const outcomeOf = (r: ReturnType) => r.match({ ok: () => "WRONGLY ACCEPTED", errCases: (m) => m.with(P.tag("InvalidEntity"), () => "invalid"), @@ -110,7 +109,7 @@ test("computed output failing its own schema is a defect, not bad input", () => }, }, ) {} - expect(outcomeOf(Broken.decode(raw) as never)).toBe("defect"); + expect(outcomeOf(Broken.make(raw) as never)).toBe("defect"); }); test("a throwing derivation is a defect, not an escaped exception", () => { @@ -126,7 +125,7 @@ test("a throwing derivation is a defect, not an escaped exception", () => { }, ) {} // the point is that this does NOT throw out of decode() - expect(outcomeOf(Throws.decode(raw) as never)).toBe("defect"); + expect(outcomeOf(Throws.make(raw) as never)).toBe("defect"); }); test("a defect names the field that produced it", () => { @@ -140,7 +139,7 @@ test("a defect names the field that produced it", () => { ) {} let message = ""; try { - Broken.decode(raw).getOrThrow(); + Broken.make(raw).getOrThrow(); } catch (e) { message = (e as Error).message; } @@ -157,6 +156,6 @@ test("a field transform is applied exactly once, not once per validation pass", }) .brand("NamePart"); class Once extends Entity("Once")({ id: PersonId, first: Counted }) {} - Once.decode({ id: raw.id, first: "Ada" }).getOrThrow(); + Once.make({ id: raw.id, first: "Ada" }).getOrThrow(); expect(calls).toBe(1); }); diff --git a/packages/entity/src/contract.spec.ts b/packages/entity/src/contract.spec.ts index 1d273eb..53fbcb2 100644 --- a/packages/entity/src/contract.spec.ts +++ b/packages/entity/src/contract.spec.ts @@ -33,12 +33,12 @@ const props = (s: z.ZodType, io: "input" | "output") => { ).toSorted(); }; -test("encoded drives the full request schema", () => { - expect(props(ApiKey.encoded, "input")).toEqual(["createdAt", "id", "label", "orgId"]); +test("input drives the full request schema", () => { + expect(props(ApiKey.input, "input")).toEqual(["createdAt", "id", "label", "orgId"]); }); -test("decoded drives the response schema", () => { - expect(props(ApiKey.decoded, "output")).toEqual([ +test("output drives the response schema", () => { + expect(props(ApiKey.output, "output")).toEqual([ "createdAt", "id", "label", @@ -58,7 +58,7 @@ test("updateInput is the update request schema", () => { }); test("all four ZodObject members convert in both directions", () => { - for (const s of [ApiKey.encoded, ApiKey.decoded, ApiKey.createInput, ApiKey.updateInput]) { + for (const s of [ApiKey.input, ApiKey.output, ApiKey.createInput, ApiKey.updateInput]) { for (const io of ["input", "output"] as const) { expect(() => z.toJSONSchema(s as never, { io })).not.toThrow(); } @@ -70,19 +70,19 @@ test("the instance surface has no output representation, which is why it is sepa }); test("no schema leaks the runtime tag", () => { - for (const s of [ApiKey.encoded, ApiKey.decoded, ApiKey.createInput, ApiKey.updateInput]) { + for (const s of [ApiKey.input, ApiKey.output, ApiKey.createInput, ApiKey.updateInput]) { expect(Object.keys((s as z.ZodObject).shape)).not.toContain("_tag"); } }); test("readonly() surfaces as readOnly in the generated schema", () => { - const json = z.toJSONSchema(ApiKey.decoded, { io: "output" }) as unknown as { + const json = z.toJSONSchema(ApiKey.output, { io: "output" }) as unknown as { properties: { orgId: { readOnly?: boolean } }; }; expect(json.properties.orgId.readOnly).toBe(true); }); test("contracts can still derive further views", () => { - const Summary = ApiKey.decoded.pick({ id: true, searchKey: true }); + const Summary = ApiKey.output.pick({ id: true, searchKey: true }); expect(Object.keys(Summary.shape).toSorted()).toEqual(["id", "searchKey"]); }); diff --git a/packages/entity/src/entity.spec.ts b/packages/entity/src/entity.spec.ts index 544d497..8fe9998 100644 --- a/packages/entity/src/entity.spec.ts +++ b/packages/entity/src/entity.spec.ts @@ -22,43 +22,43 @@ class Organization extends Entity("Organization")({ const raw = { id: "0199b1f4-1b1e-7000-8000-000000000000", slug: "acme", name: "Acme" }; -test("decode produces an instance with typed data and working methods", () => { - const org = Organization.decode(raw).getOrThrow(); +test("make produces an instance with typed data and working methods", () => { + const org = Organization.make(raw).getOrThrow(); expect(org).toBeInstanceOf(Organization); expect(org.slug).toBe("acme"); expect(org.shout()).toBe("ACME"); }); test("toJSON returns the stored data", () => { - expect(Organization.decode(raw).getOrThrow().toJSON()).toEqual(raw); + expect(Organization.make(raw).getOrThrow().toJSON()).toEqual(raw); }); -test("with no options, decoded and encoded describe the same fields", () => { - expect(Object.keys(Organization.decoded.shape).toSorted()).toEqual( - Object.keys(Organization.encoded.shape).toSorted(), +test("with no options, input and output describe the same fields", () => { + expect(Object.keys(Organization.output.shape).toSorted()).toEqual( + Object.keys(Organization.input.shape).toSorted(), ); }); test("the tag is readable but never part of the data", () => { - const org = Organization.decode(raw).getOrThrow(); + const org = Organization.make(raw).getOrThrow(); expect(org._tag).toBe("Organization"); expect(Object.keys(org)).not.toContain("_tag"); expect(org.toJSON()).not.toHaveProperty("_tag"); expect(JSON.stringify(org)).not.toContain("_tag"); expect({ ...org }).not.toHaveProperty("_tag"); - expect(Organization.encoded.shape).not.toHaveProperty("_tag"); - expect(Organization.decoded.shape).not.toHaveProperty("_tag"); + expect(Organization.input.shape).not.toHaveProperty("_tag"); + expect(Organization.output.shape).not.toHaveProperty("_tag"); }); test("JSON.stringify emits data only, because methods live on the prototype", () => { - expect(JSON.parse(JSON.stringify(Organization.decode(raw).getOrThrow()))).toEqual(raw); + expect(JSON.parse(JSON.stringify(Organization.make(raw).getOrThrow()))).toEqual(raw); }); type Flat = { readonly path: readonly PropertyKey[]; readonly message: string }; const flatten = (issues: SchemaIssues): readonly Flat[] => issues.map((i) => ({ path: keysOf(i), message: i.message })); -const orgIssuesOf = (r: ReturnType): readonly Flat[] => +const orgIssuesOf = (r: ReturnType): readonly Flat[] => r.match({ ok: () => [{ path: [], message: "WRONGLY ACCEPTED" }], errCases: (m) => m.with(P.tag("InvalidEntity"), (e) => flatten(e.issues)), @@ -66,7 +66,7 @@ const orgIssuesOf = (r: ReturnType): readonly Flat[] }); test("schema validation failure surfaces as InvalidEntity, not a defect", () => { - const failure = Organization.decode({ ...raw, slug: "" }).match({ + const failure = Organization.make({ ...raw, slug: "" }).match({ ok: () => "WRONGLY ACCEPTED", errCases: (m) => m.with(P.tag("InvalidEntity"), (e) => `${e.entity}:${flatten(e.issues)[0]?.path.join(".")}`), @@ -76,20 +76,20 @@ test("schema validation failure surfaces as InvalidEntity, not a defect", () => }); test("a schema issue carries the path of the field that failed", () => { - expect(orgIssuesOf(Organization.decode({ ...raw, slug: "" }))).toEqual([ + expect(orgIssuesOf(Organization.make({ ...raw, slug: "" }))).toEqual([ { path: ["slug"], message: "Too small: expected string to have >=1 characters" }, ]); }); test("every failing field is reported, not just the first", () => { - expect(orgIssuesOf(Organization.decode({ ...raw, slug: "", name: "" }))).toEqual([ + expect(orgIssuesOf(Organization.make({ ...raw, slug: "", name: "" }))).toEqual([ { path: ["slug"], message: "Too small: expected string to have >=1 characters" }, { path: ["name"], message: "Too small: expected string to have >=1 characters" }, ]); }); test("a whole-object issue has an empty path", () => { - expect(orgIssuesOf(Organization.decode("not an object"))).toEqual([ + expect(orgIssuesOf(Organization.make("not an object"))).toEqual([ { path: [], message: "Invalid input: expected object, received string" }, ]); }); @@ -102,7 +102,7 @@ test("a nested path keeps its segments, array indices included", () => { tags: z.array(Tag).brand("Tags"), address: Address, }) {} - const issues = Profile.decode({ id: raw.id, tags: ["ok", "x"], address: { city: "y" } }).match({ + const issues = Profile.make({ id: raw.id, tags: ["ok", "x"], address: { city: "y" } }).match({ ok: () => [] as readonly Flat[], errCases: (m) => m.with(P.tag("InvalidEntity"), (e) => flatten(e.issues)), defect: () => [{ path: [], message: "DEFECT" }], @@ -124,7 +124,7 @@ test("entityName carries the tag", () => { }); test("data fields are locked against mutation at runtime", () => { - const org = Organization.decode(raw).getOrThrow(); + const org = Organization.make(raw).getOrThrow(); expect(() => { (org as unknown as Record)["slug"] = "hacked"; }).toThrow(TypeError); @@ -144,21 +144,21 @@ class OrgWithCache extends Entity("OrgWithCache")({ test("locking data fields leaves class-body instance fields writable", () => { // Object.freeze(this) would break this: class-body field initialisers run // after super() returns, so the object must stay extensible. - const org = OrgWithCache.decode(raw).getOrThrow(); + const org = OrgWithCache.make(raw).getOrThrow(); org.cachedSummary = "computed"; expect(org.cachedSummary).toBe("computed"); expect(org.slug).toBe("acme"); }); test("toJSON does not leak class-body instance fields", () => { - const org = OrgWithCache.decode(raw).getOrThrow(); + const org = OrgWithCache.make(raw).getOrThrow(); org.cachedSummary = "leak me"; expect(org.toJSON()).not.toHaveProperty("cachedSummary"); }); test("subclassing an entity is a defect, not a silent success", () => { class Sub extends Organization {} - const outcome = Sub.decode(raw).match({ + const outcome = Sub.make(raw).match({ ok: () => "WRONGLY ACCEPTED", errCases: (m) => m.with(P.tag("InvalidEntity"), () => "invalid"), defect: () => "defect", @@ -168,7 +168,7 @@ test("subclassing an entity is a defect, not a silent success", () => { test("using the builder's return directly, without extends, still works", () => { const Anon = Entity("Anon")({ id: OrgId, slug: Slug, name: DisplayName }); - expect(Anon.decode(raw).getOrThrow().slug).toBe("acme"); + expect(Anon.make(raw).getOrThrow().slug).toBe("acme"); }); const Instant = z.iso.datetime().brand("Instant"); @@ -199,7 +199,7 @@ const trialRaw = { seatsUsed: 2, }; -const issuesOf = (r: ReturnType): readonly Flat[] => +const issuesOf = (r: ReturnType): readonly Flat[] => r.match({ ok: () => [] as readonly Flat[], errCases: (m) => m.with(P.tag("InvalidEntity"), (e) => flatten(e.issues)), @@ -207,23 +207,23 @@ const issuesOf = (r: ReturnType): readonly Flat[] => }); test("a satisfied invariant lets the entity through", () => { - expect(Trial.decode(trialRaw).isOk()).toBe(true); + expect(Trial.make(trialRaw).isOk()).toBe(true); }); test("a broken invariant surfaces as InvalidEntity", () => { - expect(issuesOf(Trial.decode({ ...trialRaw, trialEndsAt: "2026-07-01T09:00:00Z" }))).toEqual([ + expect(issuesOf(Trial.make({ ...trialRaw, trialEndsAt: "2026-07-01T09:00:00Z" }))).toEqual([ { path: [], message: "trialEndsAt must be after createdAt" }, ]); }); test("every broken rule is reported, not just the first", () => { expect( - issuesOf(Trial.decode({ ...trialRaw, trialEndsAt: "2026-07-01T09:00:00Z", seatsUsed: 9 })), + issuesOf(Trial.make({ ...trialRaw, trialEndsAt: "2026-07-01T09:00:00Z", seatsUsed: 9 })), ).toHaveLength(2); }); test("an invariant issue has no path, unlike a schema issue", () => { - const [issue] = issuesOf(Trial.decode({ ...trialRaw, trialEndsAt: "2026-07-01T09:00:00Z" })); + const [issue] = issuesOf(Trial.make({ ...trialRaw, trialEndsAt: "2026-07-01T09:00:00Z" })); expect(issue?.path).toEqual([]); expect(issue?.message).toBe("trialEndsAt must be after createdAt"); }); @@ -252,7 +252,7 @@ const asMutableArray = (value: unknown) => value as string[]; const asMutableRecord = (value: unknown) => value as Record; test("an array field cannot be mutated in place", () => { - const bag = Bag.decode(bagRaw).getOrThrow(); + const bag = Bag.make(bagRaw).getOrThrow(); expect(() => asMutableArray(bag.tags).push("b")).toThrow(TypeError); expect(() => { asMutableArray(bag.tags)[0] = "hacked"; @@ -261,7 +261,7 @@ test("an array field cannot be mutated in place", () => { }); test("a nested object field, and the array inside it, are frozen too", () => { - const bag = Bag.decode(bagRaw).getOrThrow(); + const bag = Bag.make(bagRaw).getOrThrow(); expect(() => { asMutableRecord(bag.address)["city"] = "Paris"; }).toThrow(TypeError); @@ -271,22 +271,22 @@ test("a nested object field, and the array inside it, are frozen too", () => { test("a construction-time invariant cannot be defeated after construction", () => { // decoding straight into the forbidden state is rejected … - expect(Bag.decode({ ...bagRaw, tags: ["a", "b", "c"] }).isErr()).toBe(true); + expect(Bag.make({ ...bagRaw, tags: ["a", "b", "c"] }).isErr()).toBe(true); // … and so is reaching it one push at a time - const bag = Bag.decode({ ...bagRaw, tags: ["a", "b"] }).getOrThrow(); + const bag = Bag.make({ ...bagRaw, tags: ["a", "b"] }).getOrThrow(); expect(() => asMutableArray(bag.tags).push("c")).toThrow(TypeError); expect(bag.tags).toHaveLength(2); }); test("update still produces a new entity from frozen data", () => { - const bag = Bag.decode(bagRaw).getOrThrow(); + const bag = Bag.make(bagRaw).getOrThrow(); const updated = bag.update({ tags: ["x", "y"] as unknown as z.infer[] }).getOrThrow(); expect(updated.toJSON().tags).toEqual(["x", "y"]); expect(bag.toJSON().tags).toEqual(["a"]); }); test("update cannot smuggle a mutation in through the patch it was handed", () => { - const bag = Bag.decode(bagRaw).getOrThrow(); + const bag = Bag.make(bagRaw).getOrThrow(); const patch = { tags: ["x"] as unknown as z.infer[] }; const updated = bag.update(patch).getOrThrow(); asMutableArray(patch.tags).push("y"); diff --git a/packages/entity/src/entity.test-d.ts b/packages/entity/src/entity.test-d.ts index ccf6f89..1727d23 100644 --- a/packages/entity/src/entity.test-d.ts +++ b/packages/entity/src/entity.test-d.ts @@ -5,8 +5,8 @@ import { Entity, computed, type CreateInput, - type Decoded, - type Encoded, + type Output, + type Input, type Patch, } from "./index.js"; @@ -22,7 +22,7 @@ test("construction outside decode/make is sealed", () => { }); test("data fields are readonly at compile time", () => { - const org = Organization.decode({}).getOrThrow(); + const org = Organization.make({}).getOrThrow(); // @ts-expect-error data is immutable org.slug = "other"; }); @@ -32,7 +32,7 @@ const Address = z.object({ city: z.string(), lines: z.array(z.string()) }).brand class Bag extends Entity("Bag")({ id: OrgId, tags: z.array(Tag), address: Address }) {} test("data is readonly all the way down, not just at the top level", () => { - const bag = Bag.decode({}).getOrThrow(); + const bag = Bag.make({}).getOrThrow(); // @ts-expect-error an array field is `readonly Tag[]`, so it has no `push` bag.tags.push("x" as z.infer); @@ -51,7 +51,7 @@ test("data is readonly all the way down, not just at the top level", () => { }); test("a branded field survives DeepReadonly with its brand intact", () => { - const org = Organization.decode({}).getOrThrow(); + const org = Organization.make({}).getOrThrow(); // a naive DeepReadonly maps over `string & $brand<…>` and loses the // primitive, so this assignment is the regression guard for that const slug: z.infer = org.slug; @@ -62,15 +62,15 @@ test("a branded field survives DeepReadonly with its brand intact", () => { }); test("toJSON() returns the plain, mutable decoded shape", () => { - const bag = Bag.decode({}).getOrThrow(); + const bag = Bag.make({}).getOrThrow(); // toJSON() builds a fresh object, so its own keys stay assignable — // DeepReadonly applies to the instance's fields, not to this projection - const state: Decoded = bag.toJSON(); + const state: Output = bag.toJSON(); state.tags = []; }); test("toJSON() is the only projection — there is no second public spelling", () => { - const bag = Bag.decode({}).getOrThrow(); + const bag = Bag.make({}).getOrThrow(); // @ts-expect-error `encode()` was removed; `toJSON()` is the one projection bag.encode(); }); @@ -89,7 +89,7 @@ test("unbranded fields are rejected", () => { }); test("the tag is a literal, not a wide string", () => { - const tag: "Organization" = Organization.decode({}).getOrThrow()._tag; + const tag: "Organization" = Organization.make({}).getOrThrow()._tag; void tag; }); @@ -218,7 +218,7 @@ test("update() preserves the subclass type and its methods", () => { return this.slug.toUpperCase(); } } - const org = UpdateTestOrg.decode({}).getOrThrow(); + const org = UpdateTestOrg.make({}).getOrThrow(); const updated = org.update({ slug: "new" as z.infer }).getOrThrow(); // Both class-body method and _tag must be accessible on the result, // proving update() preserves the subclass type and not just the base shape @@ -235,12 +235,12 @@ test("the helper types name each shape", () => { { generated: ["id", "createdAt"], immutable: ["id", "createdAt"] }, ) {} - const wire: Encoded = { + const wire: Input = { id: "x" as z.infer, slug: "s" as z.infer, createdAt: "t" as z.infer, }; - const state: Decoded = wire; + const state: Output = wire; const created: CreateInput = { slug: "s" as z.infer }; const patch: Patch = { slug: "s" as z.infer }; void wire; diff --git a/packages/entity/src/entity.ts b/packages/entity/src/entity.ts index 372af6e..6e13b66 100644 --- a/packages/entity/src/entity.ts +++ b/packages/entity/src/entity.ts @@ -13,9 +13,9 @@ import type { AsyncGenerators, EntityFactory, Generators, - DecodedOf, + OutputOf, DeepReadonly, - EncodedOf, + InputOf, EntityStatic, Fields, PatchOf, @@ -51,17 +51,17 @@ export function Entity(tag: Tag) { S extends Fields, A extends Fields = Record, const G extends readonly (keyof S)[] = [], - const I extends readonly (keyof DecodedOf)[] = [], + const I extends readonly (keyof OutputOf)[] = [], >( fields: S & OnlyNominal, options?: { readonly generated?: G; readonly immutable?: I; - readonly computed?: { [K in keyof A]: ComputedField> }; - readonly invariants?: (d: DecodedOf) => readonly string[]; + readonly computed?: { [K in keyof A]: ComputedField> }; + readonly invariants?: (d: OutputOf) => readonly string[]; }, ): EntityStatic { - const encoded = shape(fields); + const input = shape(fields); // `.omit()`'s mask can't be satisfied by a mask built from a generic key // list — TS won't reduce `Exclude` (or the equivalent @@ -74,15 +74,15 @@ export function Entity(tag: Tag) { /** [key, validate its output, produce it] per computed field. */ const computedFields = Object.entries( - (options?.computed ?? {}) as Record>, + (options?.computed ?? {}) as Record>, ); - const decoded = ( + const output = ( computedFields.length > 0 - ? (encoded as z.ZodObject).extend( + ? (input as z.ZodObject).extend( Object.fromEntries(computedFields.map(([k, f]) => [k, f.schema])), ) - : encoded + : input ) as z.ZodObject; const generatedKeys = options?.generated ?? []; @@ -102,33 +102,33 @@ export function Entity(tag: Tag) { ]; /** what a caller may send to create */ - const createInput = omitBy(encoded as z.ZodObject, generatedKeys) as z.ZodObject< + const createInput = omitBy(input as z.ZodObject, generatedKeys) as z.ZodObject< Omit >; /** what a caller may send to update */ - const updateInput = omitBy(decoded as z.ZodObject, frozenKeys).partial() as z.ZodObject< + const updateInput = omitBy(output as z.ZodObject, frozenKeys).partial() as z.ZodObject< UpdateInputShapeOf >; - type DecodedShape = DecodedOf; - type EncodedShape = EncodedOf; + type OutputShape = OutputOf; + type InputShape = InputOf; - const dataKeys = Object.keys(decoded.shape) as unknown as readonly (keyof DecodedShape)[]; + const dataKeys = Object.keys(output.shape) as unknown as readonly (keyof OutputShape)[]; /** - * Projects an instance down to exactly the `decoded` schema's keys. + * Projects an instance down to exactly the `output` schema's keys. * * Module-private on purpose. `toJSON`, `equals` and `update` all need this * projection, but only `toJSON` is a public surface — routing the other * two through a shared function rather than through `toJSON` keeps them * from depending on a serialization hook a subclass is free to override. */ - const project = (self: object): DecodedShape => { - const source = self as Record; - return Object.fromEntries(dataKeys.map((k) => [k, source[k]])) as DecodedShape; + const project = (self: object): OutputShape => { + const source = self as Record; + return Object.fromEntries(dataKeys.map((k) => [k, source[k]])) as OutputShape; }; - const parseEncoded = fromSchema(encoded); + const parseInput = fromSchema(input); const toInvalidEntity = (issues: SchemaIssues) => new InvalidEntity({ entity: tag, issues }); @@ -136,7 +136,7 @@ export function Entity(tag: Tag) { * Each computed field's own validator, so a failure names that field. * * Only the derived values are checked, never the declared ones: those were - * already validated against `encoded`, and re-running a field schema over + * already validated against `input`, and re-running a field schema over * its own output is not a no-op — a non-idempotent transform applies twice, * and a type-changing one rejects its own output. Checking the derived * output is what makes `from`'s unchecked `as Brand` cast honest. @@ -159,8 +159,8 @@ export function Entity(tag: Tag) { * output its own schema rejects are defects — `from` is pure, total and * typed, so either is a bug rather than bad caller input. */ - const recompute = (base: EncodedShape): Result => { - if (computedParsers.length === 0) return Ok({ ...base } as unknown as DecodedShape); + const recompute = (base: InputShape): Result => { + if (computedParsers.length === 0) return Ok({ ...base } as unknown as OutputShape); return all( computedParsers.map(([key, from, parse]) => fromThrowable( @@ -181,16 +181,16 @@ export function Entity(tag: Tag) { // `.flatMap` rather than `.map`: `map`'s NotThenable guard cannot // resolve while the shape is still generic. ).flatMap((pairs) => - Ok({ ...base, ...Object.fromEntries(pairs) } as unknown as DecodedShape), - ) as Result; + Ok({ ...base, ...Object.fromEntries(pairs) } as unknown as OutputShape), + ) as Result; }; const invariants = options?.invariants; /** The tail every entry point shares: check the invariants, then seal and construct. */ const construct = ( - Ctor: new (d: Sealed) => T, - d: DecodedShape, + Ctor: new (d: Sealed) => T, + d: OutputShape, ): Result => { const broken = invariants?.(d) ?? []; // no `path` — an invariant spans the entity, not one field @@ -211,7 +211,7 @@ export function Entity(tag: Tag) { `${tag}: subclassing an entity class is not supported — put the behaviour in the entity's own class body.`, ); } - return new Ctor(d as Sealed); + return new Ctor(d as Sealed); }, (cause, defect) => defect(cause), )() as Result; @@ -219,16 +219,16 @@ export function Entity(tag: Tag) { class Base { static readonly entityName = tag; - /** the full wire object */ - static readonly encoded = encoded; + /** everything `make` accepts */ + static readonly input = input; /** stored state and response body */ - static readonly decoded = decoded; + static readonly output = output; /** what a caller may send to create */ static readonly createInput = createInput; /** what a caller may send to update */ static readonly updateInput = updateInput; - constructor(d: Sealed) { + constructor(d: Sealed) { const source = d as unknown as Record; // One set for the whole instance, not one per field: fields can share // a subtree, and a per-field set would re-walk it once per field that @@ -254,7 +254,7 @@ export function Entity(tag: Tag) { } /** - * The stored data, projected to exactly the `decoded` schema's keys. + * The stored data, projected to exactly the `output` schema's keys. * * This is the *only* public projection. `toJSON` is not a name this * package chose — it is the hook `JSON.stringify` looks for — and it has @@ -268,7 +268,7 @@ export function Entity(tag: Tag) { * of one projection is the alias CONTRIBUTING tells us to resist, and a * repository write reads perfectly well as `db.insert(org.toJSON())`. */ - toJSON(): DecodedShape { + toJSON(): OutputShape { return project(this); } @@ -279,25 +279,11 @@ export function Entity(tag: Tag) { return JSON.stringify(project(this)) === JSON.stringify(project(other)); } - /** a full untrusted encoded payload → entity */ - static decode( - this: new (d: Sealed) => T, - raw: unknown, - ): Result { - return parseEncoded(raw) - .mapErrCases((m) => - // SchemaIssues is `readonly Issue[]` — a single non-union type, nothing to enumerate - // oxlint-disable-next-line unthrown/no-catch-all-pattern - m.with(P._, toInvalidEntity), - ) - .flatMap(recompute) - .flatMap((d) => construct(this, d)); - } - /** - * already-stored state → entity, for row mappers and event folds + * data → entity. The only way in: a database row, a folded event stream, + * an untrusted import, a replayed integration event. * - * Validated against `encoded`, not `decoded`, even though a stored row + * Validated against `input`, not `output`, even though a stored row * carries the computed keys too. Those keys are re-derived rather than * read, so validating them would reject exactly the rows this is meant to * heal: one written before a derivation changed, or written before the @@ -305,10 +291,10 @@ export function Entity(tag: Tag) { * any unknown key. */ static make( - this: new (d: Sealed) => T, + this: new (d: Sealed) => T, state: unknown, ): Result { - return parseEncoded(state) + return parseInput(state) .mapErrCases((m) => // SchemaIssues is `readonly Issue[]` — a single non-union type, nothing to enumerate // oxlint-disable-next-line unthrown/no-catch-all-pattern @@ -320,28 +306,28 @@ export function Entity(tag: Tag) { /** caller fields + domain-generated fields → entity */ static factory( - this: new (d: Sealed) => T, + this: new (d: Sealed) => T, generators: Generators, ): EntityFactory { - const Ctor = this as unknown as { decode: (raw: unknown) => Result }; + const Ctor = this as unknown as { make: (state: unknown) => Result }; return { create: (input) => // generated spreads last, so a caller cannot override a domain-owned field - Ctor.decode({ ...(input as object), ...callAll(generators) }), + Ctor.make({ ...(input as object), ...callAll(generators) }), }; } static factoryAsync( - this: new (d: Sealed) => T, + this: new (d: Sealed) => T, generators: AsyncGenerators, ): AsyncEntityFactory { - const Ctor = this as unknown as { decode: (raw: unknown) => Result }; + const Ctor = this as unknown as { make: (state: unknown) => Result }; return { create: (input) => // a generator that rejects is infrastructure failing, not bad domain // input, so it stays a Defect rather than becoming an InvalidEntity fromPromise(resolveAll(generators), (cause, defect) => defect(cause)).flatMap( - (generated) => Ctor.decode({ ...(input as object), ...generated }), + (generated) => Ctor.make({ ...(input as object), ...generated }), ), }; } @@ -364,17 +350,17 @@ export function Entity(tag: Tag) { } } - attachInstance>(Base, encoded); + attachInstance>(Base, input); return Base as unknown as EntityStatic; }; } /** What the wire sends — for mapper and request signatures. */ -export type Encoded = E["__encoded"]; +export type Input = E["__input"]; /** What the entity stores — for `make` and repository signatures. */ -export type Decoded = E["__decoded"]; +export type Output = E["__output"]; /** What `create` accepts from a caller. */ export type CreateInput = E["__createInput"]; diff --git a/packages/entity/src/equality.spec.ts b/packages/entity/src/equality.spec.ts index 1c06dce..f8c4c10 100644 --- a/packages/entity/src/equality.spec.ts +++ b/packages/entity/src/equality.spec.ts @@ -17,32 +17,32 @@ class Team extends Entity("Team")({ id: OrgId, slug: Slug, tags: z.array(Tag) }) const raw = { id: "0199b1f4-1b1e-7000-8000-000000000000", slug: "acme", tags: ["a", "b"] }; test("distinct instances with the same data are equal", () => { - const a = Organization.decode(raw).getOrThrow(); - const b = Organization.decode(raw).getOrThrow(); + const a = Organization.make(raw).getOrThrow(); + const b = Organization.make(raw).getOrThrow(); expect(a === b).toBe(false); expect(a.equals(b)).toBe(true); }); test("differing data is unequal", () => { - const a = Organization.decode(raw).getOrThrow(); - const b = Organization.decode({ ...raw, slug: "other" }).getOrThrow(); + const a = Organization.make(raw).getOrThrow(); + const b = Organization.make({ ...raw, slug: "other" }).getOrThrow(); expect(a.equals(b)).toBe(false); }); test("entities holding equal arrays are equal", () => { // This is the case a shallow, reference-comparing rule got wrong. - const a = Organization.decode({ ...raw, tags: ["x", "y"] }).getOrThrow(); - const b = Organization.decode({ ...raw, tags: ["x", "y"] }).getOrThrow(); + const a = Organization.make({ ...raw, tags: ["x", "y"] }).getOrThrow(); + const b = Organization.make({ ...raw, tags: ["x", "y"] }).getOrThrow(); expect(a.toJSON().tags === b.toJSON().tags).toBe(false); expect(a.equals(b)).toBe(true); }); test("different entity types with identical data are unequal", () => { - expect(Organization.decode(raw).getOrThrow().equals(Team.decode(raw).getOrThrow())).toBe(false); + expect(Organization.make(raw).getOrThrow().equals(Team.make(raw).getOrThrow())).toBe(false); }); test("comparing against a non-entity is false, not a throw", () => { - const org = Organization.decode(raw).getOrThrow(); + const org = Organization.make(raw).getOrThrow(); expect(org.equals(raw)).toBe(false); expect(org.equals(undefined)).toBe(false); }); diff --git a/packages/entity/src/freeze.ts b/packages/entity/src/freeze.ts index 0296d48..623f187 100644 --- a/packages/entity/src/freeze.ts +++ b/packages/entity/src/freeze.ts @@ -40,7 +40,7 @@ const isPlainObject = (value: object): boolean => { }; const freezeInto = (value: object, seen: WeakSet): void => { - // `seen` guards the cyclic case — decoded data is normally a tree, but a + // `seen` guards the cyclic case — entity data is normally a tree, but a // `z.custom` field or a caller-supplied object can close a loop, and a // shared subtree would otherwise be walked once per reference. if (seen.has(value)) return; diff --git a/packages/entity/src/index.ts b/packages/entity/src/index.ts index b54b07d..7bfb0e2 100644 --- a/packages/entity/src/index.ts +++ b/packages/entity/src/index.ts @@ -1,3 +1,3 @@ -export { Entity, type CreateInput, type Decoded, type Encoded, type Patch } from "./entity.js"; +export { Entity, type CreateInput, type Input, type Output, type Patch } from "./entity.js"; export { computed, type ComputedField } from "./computed.js"; export { InvalidEntity } from "./errors.js"; diff --git a/packages/entity/src/instance.spec.ts b/packages/entity/src/instance.spec.ts index a8cca92..a6270c0 100644 --- a/packages/entity/src/instance.spec.ts +++ b/packages/entity/src/instance.spec.ts @@ -14,7 +14,7 @@ class Organization extends Entity("Organization")( const raw = { id: "0199b1f4-1b1e-7000-8000-000000000000", slug: "acme" }; -test("instance decodes encoded input to a class instance", () => { +test("instance parses input data into a class instance", () => { expect(Organization.instance.parse(raw)).toBeInstanceOf(Organization); }); @@ -52,7 +52,7 @@ test("instance and ~standard are built once and reused", () => { expect(Organization["~standard"]).toBe(Organization["~standard"]); }); -test("a defect during decode propagates instead of becoming a validation issue", () => { +test("a defect during make propagates instead of becoming a validation issue", () => { class Buggy extends Entity("Buggy")( { id: OrgId }, { diff --git a/packages/entity/src/instance.ts b/packages/entity/src/instance.ts index b008093..f8fb3c7 100644 --- a/packages/entity/src/instance.ts +++ b/packages/entity/src/instance.ts @@ -5,7 +5,7 @@ import type { InvalidEntity } from "./errors.js"; import { keysOf } from "./issues.js"; /** - * The composable surface: encoded input decoded to a class instance. + * The composable surface: input data parsed into a class instance. * * Failures cross into zod's issue channel so a nested entity reports which * member failed — `z.object({ owner: Organization.instance })` yields @@ -18,15 +18,15 @@ import { keysOf } from "./issues.js"; * per invariant message and yielding `z.NEVER`), which empties the error * channel and leaves an unrecovered `Defect` as the only way `.get()` can * still fail — at which point it panics (rethrows the original cause) - * instead of reporting an ordinary issue. An unexpected bug in `decode` + * instead of reporting an ordinary issue. An unexpected bug in `make` * must stay distinguishable from bad caller input, not surface as one. */ function instanceSchema( - encoded: z.ZodType, - decodeFrom: (d: unknown) => Result, + input: z.ZodType, + makeFrom: (d: unknown) => Result, ): z.ZodType { - return encoded.transform((d, ctx) => - decodeFrom(d) + return input.transform((d, ctx) => + makeFrom(d) .recoverErrCases((m) => m.with(P.tag("InvalidEntity"), (invalid) => { for (const issue of invalid.issues) { @@ -68,13 +68,13 @@ function instanceSchema( * ever reads it, and `Y.instance` then resolves to that inherited property: * `Y.instance.parse(...)` silently builds an `X`, not a `Y`, with no error. */ -export function attachInstance(Base: object, encoded: z.ZodType): void { +export function attachInstance(Base: object, input: z.ZodType): void { Object.defineProperty(Base, "instance", { configurable: true, enumerable: false, get(this: object) { - const built = instanceSchema(encoded, (d) => - (this as unknown as { decode: (raw: unknown) => Result }).decode(d), + const built = instanceSchema(input, (d) => + (this as unknown as { make: (state: unknown) => Result }).make(d), ); Object.defineProperty(this, "instance", { value: built, enumerable: false }); return built; diff --git a/packages/entity/src/types.test-d.ts b/packages/entity/src/types.test-d.ts index 0ef6922..7160d9a 100644 --- a/packages/entity/src/types.test-d.ts +++ b/packages/entity/src/types.test-d.ts @@ -4,8 +4,8 @@ import { z } from "zod"; import type { ComputedOf, CreateInputOf, - DecodedOf, - EncodedOf, + OutputOf, + InputOf, Fields, GeneratedOf, PatchOf, @@ -33,8 +33,8 @@ test("the test fixtures satisfy the Fields constraint", () => { expectTypeOf().toEqualTypeOf(); }); -test("EncodedOf is the plain inferred object", () => { - expectTypeOf["slug"]>().toEqualTypeOf>(); +test("InputOf is the plain inferred object", () => { + expectTypeOf["slug"]>().toEqualTypeOf>(); }); test("ComputedOf of an empty shape has no keys and no index signature", () => { @@ -43,16 +43,16 @@ test("ComputedOf of an empty shape has no keys and no index signature", () => { expectTypeOf>>().toEqualTypeOf(); }); -test("DecodedOf is the declared fields plus the computed ones, and carries no _tag", () => { - type D = DecodedOf; +test("OutputOf is the declared fields plus the computed ones, and carries no _tag", () => { + type D = OutputOf; expectTypeOf().toEqualTypeOf>(); expectTypeOf().toEqualTypeOf>(); // the tag is a runtime-only instance property, never part of the data expectTypeOf().not.toHaveProperty("_tag"); }); -test("DecodedOf with no computed fields is the encoded object", () => { - type D = DecodedOf>; +test("OutputOf with no computed fields is the encoded object", () => { + type D = OutputOf>; expectTypeOf().toEqualTypeOf>(); }); @@ -76,8 +76,8 @@ test("PatchOf is partial and drops the immutable fields", () => { }); test("Sealed cannot be produced from outside the module", () => { - const plain = {} as unknown as EncodedOf; + const plain = {} as unknown as InputOf; // @ts-expect-error the construction key cannot be named outside types.ts - const sealed: Sealed> = plain; + const sealed: Sealed> = plain; void sealed; }); diff --git a/packages/entity/src/types.ts b/packages/entity/src/types.ts index 038a503..a054864 100644 --- a/packages/entity/src/types.ts +++ b/packages/entity/src/types.ts @@ -6,14 +6,14 @@ import type { InvalidEntity } from "./errors.js"; export type Fields = Record; /** The data an entity accepts on the wire. */ -export type EncodedOf = z.infer>; +export type InputOf = z.infer>; /** * The values the computed fields contribute. * * Zod's `$InferObjectOutput` special-cases an *empty* shape to * `Record` — a real index signature, not `{}`. Unguarded, that - * makes an option-less entity's decoded type read as "any string key, valued + * makes an option-less entity's output type read as "any string key, valued * never", and intersecting it poisons the whole type (TS2411: a subclass * instance method is "not assignable to 'string' index type 'never'"). * `Record` is inert under intersection. @@ -34,11 +34,11 @@ export type ComputedOf = [keyof A] extends [never] * ones. There is deliberately no `_tag` — the tag is a * non-enumerable instance property and never part of the data. */ -export type DecodedOf = EncodedOf & ComputedOf; +export type OutputOf = InputOf & ComputedOf; /** What `create` accepts from a caller: everything the domain does not generate. */ export type CreateInputOf = Omit< - EncodedOf, + InputOf, G[number] >; @@ -46,13 +46,13 @@ export type CreateInputOf = Om * What `create` requires the use case to supply. * * `Pick` constrains its second parameter to `keyof T`, and TypeScript cannot - * prove `G[number]` — which is `keyof S` — satisfies `keyof EncodedOf` + * prove `G[number]` — which is `keyof S` — satisfies `keyof InputOf` * through zod's inference chain. This mapped type with key remapping achieves * the same semantics. `CreateInputOf` uses `Omit` (no such constraint); * `GeneratedOf` uses this mapped form for that reason. */ export type GeneratedOf = { - [K in keyof EncodedOf as K extends G[number] ? K : never]: EncodedOf[K]; + [K in keyof InputOf as K extends G[number] ? K : never]: InputOf[K]; }; /** @@ -132,12 +132,12 @@ export type DeepReadonly = T extends Immutable export type PatchOf< S extends Fields, A extends Fields, - I extends readonly (keyof DecodedOf)[], -> = Partial, I[number] | keyof A>>; + I extends readonly (keyof OutputOf)[], +> = Partial, I[number] | keyof A>>; /** - * The field *schemas* `updateInput` is built from: the decoded field map - * (`S & A`, the same construction `EntityStatic["decoded"]` + * The field *schemas* `updateInput` is built from: the output field map + * (`S & A`, the same construction `EntityStatic["output"]` * uses), minus the immutable keys and minus `keyof A` — the computed fields are * implicitly immutable, see `PatchOf` — with every remaining schema wrapped in * `ZodOptional` — the type-level mirror of what `.omit(...).partial()` @@ -148,7 +148,7 @@ export type PatchOf< export type UpdateInputShapeOf< S extends Fields, A extends Fields, - I extends readonly (keyof DecodedOf)[], + I extends readonly (keyof OutputOf)[], > = { [Key in Exclude]: z.ZodOptional<(S & A)[Key]>; }; @@ -188,9 +188,9 @@ export type Sealed = D & { readonly [CtorKey]: true }; interface BaseInstance< S extends Fields, A extends Fields, - I extends readonly (keyof DecodedOf)[], + I extends readonly (keyof OutputOf)[], > { - toJSON(): DecodedOf; + toJSON(): OutputOf; equals(other: unknown): boolean; update(patch: PatchOf): Result; } @@ -208,16 +208,16 @@ interface BaseInstance< * type an array field as a mutable `Tag[]`, so `entity.tags.push(…)` would * compile and — before the constructor started deep-freezing — mutate stored * data, defeating an invariant that had already been checked. `toJSON()` keeps - * returning the plain `DecodedOf` shape: it builds a fresh object, so its own + * returning the plain `OutputOf` shape: it builds a fresh object, so its own * keys really are assignable. */ type ConstructedInstance< Tag extends string, S extends Fields, A extends Fields, - I extends readonly (keyof DecodedOf)[], + I extends readonly (keyof OutputOf)[], > = BaseInstance & - DeepReadonly> & { + DeepReadonly> & { readonly _tag: Tag; }; @@ -227,10 +227,10 @@ type ConstructedInstance< * Named here, in `entity.ts`'s companion types module, rather than left as * the inline `Base as unknown as {…}` cast at the end of the builder, so it * can double as the builder's *explicit* return-type annotation, and so the - * package's exported helper types (`Encoded`, `Decoded`, `CreateInput`, + * package's exported helper types (`Input`, `Output`, `CreateInput`, * `Patch`) have a single surface to read the shapes off. This is why every * member below is expressed from `S`/`A`/`G`/`I`/`Tag` alone instead of - * the builder's body-local `Base`/`encoded`/`decoded`/etc. — those aren't in + * the builder's body-local `Base`/`input`/`output`/etc. — those aren't in * scope at the annotation position, before the body that declares them. */ export type EntityStatic< @@ -238,12 +238,12 @@ export type EntityStatic< S extends Fields, A extends Fields, G extends readonly (keyof S)[], - I extends readonly (keyof DecodedOf)[], + I extends readonly (keyof OutputOf)[], > = { - new (d: Sealed>): ConstructedInstance; + new (d: Sealed>): ConstructedInstance; readonly entityName: Tag; - readonly encoded: z.ZodObject; - readonly decoded: z.ZodObject; + readonly input: z.ZodObject; + readonly output: z.ZodObject; readonly createInput: z.ZodObject>; readonly updateInput: z.ZodObject>; /** @@ -260,23 +260,22 @@ 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 & DeepReadonly>>; readonly "~standard": z.ZodType< - BaseInstance & DeepReadonly> + BaseInstance & DeepReadonly> >["~standard"]; /** phantom carriers, so consumers can recover the shapes for annotations */ - readonly __encoded: EncodedOf; - readonly __decoded: DecodedOf; + readonly __input: InputOf; + readonly __output: OutputOf; readonly __createInput: CreateInputOf; readonly __patch: PatchOf; - decode(this: new (d: Sealed>) => T, raw: unknown): Result; - make(this: new (d: Sealed>) => T, state: unknown): Result; + make(this: new (d: Sealed>) => T, state: unknown): Result; factory( - this: new (d: Sealed>) => T, + this: new (d: Sealed>) => T, generators: Generators, ): EntityFactory; factoryAsync( - this: new (d: Sealed>) => T, + this: new (d: Sealed>) => T, generators: AsyncGenerators, ): AsyncEntityFactory; }; diff --git a/packages/entity/src/union.spec.ts b/packages/entity/src/union.spec.ts index 8ad5051..391a16b 100644 --- a/packages/entity/src/union.spec.ts +++ b/packages/entity/src/union.spec.ts @@ -21,7 +21,7 @@ class ServiceAccount extends Entity("ServiceAccount")({ label: Label, }) {} -const Member = z.discriminatedUnion("kind", [User.decoded, ServiceAccount.decoded]); +const Member = z.discriminatedUnion("kind", [User.output, ServiceAccount.output]); const userRow = { kind: "user", @@ -63,6 +63,6 @@ const describe = (m: User | ServiceAccount) => .exhaustive(); test("entities match with P.tag on the runtime tag", () => { - expect(describe(User.decode(userRow).getOrThrow())).toBe("user:a@b.com"); - expect(describe(ServiceAccount.decode(svcRow).getOrThrow())).toBe("svc:deploy-bot"); + expect(describe(User.make(userRow).getOrThrow())).toBe("user:a@b.com"); + expect(describe(ServiceAccount.make(svcRow).getOrThrow())).toBe("svc:deploy-bot"); });