diff --git a/.changeset/lucky-pandas-relate.md b/.changeset/lucky-pandas-relate.md new file mode 100644 index 0000000..824d030 --- /dev/null +++ b/.changeset/lucky-pandas-relate.md @@ -0,0 +1,19 @@ +--- +"@btravstack/entity": minor +--- + +**BREAKING**: remove `encode()`; `toJSON()` is now the only public projection. + +The two returned identical data under two names, which is the alias this +package's "one concept = one name" rule exists to prevent. `toJSON()` is not a +name this package chose — it is the hook `JSON.stringify` looks for, and it has +to exist regardless, or serializing an entity leaks a subclass's own instance +fields. That made `encode()` the removable one. + +`encode()` was also misnamed: it returned the _stored_ (`decoded`) shape while +the exported `Encoded` helper names the _wire_ shape. For an entity using +`decoded: { omit, add }` those genuinely differ, which is why +`decode(x.encode())` never round-tripped. + +Migration: replace `x.encode()` with `x.toJSON()`. `toJSON()` pairs with +`make`, not `decode` — `Entity.make(x.toJSON())`. diff --git a/CLAUDE.md b/CLAUDE.md index 99b58e4..368b4b1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -43,7 +43,7 @@ Node is pinned in `.node-version` (24.16.0); pnpm 11.7.0 via `packageManager` ## Architecture -Five source modules under `packages/entity/src`, split by what they own: +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 @@ -52,8 +52,15 @@ Five source modules under `packages/entity/src`, split by what they own: `decode`; `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 `encode()`, - `JSON.stringify`, or spread. + and `_tag` non-enumerably, which is why `_tag` never reaches `toJSON()`, + `JSON.stringify`, or spread. `toJSON()` is the **only** public projection — + it, `equals` and `update` all route through a module-private `project`, so + there is no second public spelling of the same data. +- **`freeze.ts`** — `deepFreeze`, the runtime half of immutability. Freezes + and recurses into arrays and plain objects, freezes `Date` as a leaf, and + 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`, `CreateInputOf`, `PatchOf`, `UpdateInputShapeOf`, `EntityStatic`), plus `Sealed`, the module-private `unique symbol` that makes `new X(...)` a diff --git a/README.md b/README.md index e95dc37..83c7a18 100644 --- a/README.md +++ b/README.md @@ -110,8 +110,8 @@ const org = Organization.create( org.slug; // "acme" — typed, read-only org.update({ name: "Acme Inc" as z.infer }); // a NEW entity; Result -org.encode(); // the stored data — never carries `_tag` -org.equals(otherOrg); // true when both are `Organization` and their encoded data is equal +org.toJSON(); // the stored data — never carries `_tag` +org.equals(otherOrg); // true when both are `Organization` and their stored data is equal // A row mapper, or an event fold's final step: Organization.make(rowFromDatabase); @@ -223,13 +223,14 @@ hood, which is why nesting works). `invariants`, so a patch where every individual field is valid but the combination is not still fails. -`decode(x.encode())` does **not** round-trip for an entity with a `decoded` -option (see below) — a consumed field like a raw secret is absent from -`encode()` by design: +`toJSON()` returns the **stored** shape, not the wire shape, so it pairs with +`make` rather than `decode`. For an entity with a `decoded` option (see below) +the two genuinely differ — a consumed field like a raw secret is absent from +the stored data by design: ```ts -ApiKey.decode(apiKey.encode()); // ✗ Err(InvalidEntity) — `secret` is required and absent -ApiKey.make(apiKey.encode()); // ✓ Ok(ApiKey) +ApiKey.make(apiKey.toJSON()); // ✓ Ok(ApiKey) — the pairing that holds +ApiKey.decode(apiKey.toJSON()); // ✗ Err(InvalidEntity) — `secret` is required and absent ``` ## `generated` and `immutable` @@ -345,7 +346,7 @@ a.equals(a.update({ name: other }).getOrThrow()); // false — data differs someOrg.equals(someApiKey); // false — different entities, even with identical field values ``` -The comparison serialises both sides' `encode()` output, which is what makes +The comparison serialises both sides' `toJSON()` output, which is what makes two entities holding **equal arrays** compare equal — a naive reference-equality check gets this wrong, because arrays compare by reference even when their contents match. @@ -365,7 +366,7 @@ match(member) ``` **It never reaches the wire.** It is absent from every schema, from -`encode()`, `toJSON()`, `JSON.stringify(entity)`, `Object.keys(entity)` and +`toJSON()`, `JSON.stringify(entity)`, `Object.keys(entity)` and `{ ...entity }`. That has a direct consequence for unions: **a union that must survive a JSON round-trip cannot discriminate on `_tag`** — it isn't there after serialisation. Declare the discriminant as an ordinary domain field @@ -404,6 +405,19 @@ above — the two mechanisms solve different problems. A brand is _per field_ and _type-only_ (it disappears at runtime); the tag is _per entity_ and _runtime-present_, which is what makes it matchable. +The same string is also readable from the class itself, as `entityName`: + +```ts +Organization.entityName; // "Organization" — typed as the literal, not `string` +``` + +`_tag` and `entityName` are one concept with two access paths, not two names +for it: `_tag` exists on an **instance** and is what `P.tag(...)` matches on, +while `entityName` is the only way to read the tag from code holding the +**class** and no instance — a registry keyed by entity, or an error message +naming the entity a repository failed to load. Neither substitutes for the +other, and both derive from the single `Entity(tag)` declaration. + ## Error handling Every fallible entry point returns `Result`: @@ -488,9 +502,9 @@ 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. -`encode()` and `toJSON()` still return the plain `decoded` shape: they build -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). +`toJSON()` still returns the plain `decoded` 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). `Object.freeze(this)` is **not** used, and cannot be: a subclass's field initialisers run after `super()` returns, so the instance itself has to stay @@ -502,7 +516,7 @@ class OrgWithCache extends Organization { } const org = OrgWithCache.decode(raw).getOrThrow(); org.cachedSummary = "computed"; // ✓ still writable — it isn't declared data -org.encode(); // does NOT include cachedSummary — encode() projects only the declared schema's keys +org.toJSON(); // does NOT include cachedSummary — toJSON() projects only the declared schema's keys ``` ## Helper types diff --git a/packages/entity/README.md b/packages/entity/README.md index 4e7f557..2d883a3 100644 --- a/packages/entity/README.md +++ b/packages/entity/README.md @@ -28,8 +28,8 @@ const org = Organization.create( org.update({ name: newName }); // a NEW entity; immutable fields rejected at compile time Organization.make(row); // row mappers and event folds -org.encode(); // stored data — never carries _tag -org.equals(other); // equal encoded data +org.toJSON(); // stored data — never carries _tag +org.equals(other); // equal stored data ``` Every fallible entry point (`decode`, `make`, `create`, `update`) returns an @@ -89,18 +89,20 @@ test in `contract.spec.ts` pins that. why `Object.freeze(this)` is not used - `_tag` — a **non-enumerable, runtime-only** literal, matchable with `P.tag(...)`. It never reaches the wire: it is absent from every schema, and - from `encode()`, `toJSON()`, `Object.keys(...)` and `{ ...entity }`. A union + from `toJSON()`, `Object.keys(...)` and `{ ...entity }`. A union that must survive JSON round-tripping discriminates on a declared domain field, not on `_tag` — see `union.spec.ts`. - `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 -- `encode()` — the stored data, projected to exactly the `decoded` schema's - keys, even from a subclass with extra fields -- `toJSON()` — delegates to `encode()`, so `JSON.stringify(entity)` matches - `decoded` +- `toJSON()` — the stored data, projected to exactly the `decoded` schema's + keys, even from a subclass with 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 + the alias this package resists. A repository write is + `db.insert(org.toJSON())` - `equals(other)` — true when both are the same entity type and their - encoded data is deep-equal. "Same entity" means the entity the class was + stored data is deep-equal. "Same entity" means the entity the class was built from, not the class itself: two subclasses of a single `Entity(...)` call compare equal when their stored data matches, while two separate `Entity(...)` calls never do, even with identical fields diff --git a/packages/entity/src/decoded.spec.ts b/packages/entity/src/decoded.spec.ts index 35204d2..7fb5be9 100644 --- a/packages/entity/src/decoded.spec.ts +++ b/packages/entity/src/decoded.spec.ts @@ -29,23 +29,23 @@ const raw = { secret: "sk_live_9f3c2a7b41d8", }; -test("a computed field reaches the entity and its encoded output", () => { +test("a computed field reaches the entity and its stored output", () => { const key = ApiKey.decode(raw).getOrThrow(); expect(key.fingerprint).toBe("sk_live_9f3c"); - expect(key.encode().fingerprint).toBe("sk_live_9f3c"); + expect(key.toJSON().fingerprint).toBe("sk_live_9f3c"); }); test("an omitted field is consumed and never stored", () => { const key = ApiKey.decode(raw).getOrThrow(); - expect(key.encode()).not.toHaveProperty("secret"); + expect(key.toJSON()).not.toHaveProperty("secret"); expect(ApiKey.decoded.shape).not.toHaveProperty("secret"); expect(ApiKey.encoded.shape).toHaveProperty("secret"); }); -test("decode does not round-trip through encode for a split entity", () => { +test("decode does not round-trip through toJSON for a split entity", () => { const key = ApiKey.decode(raw).getOrThrow(); - expect(ApiKey.decode(key.encode()).isErr()).toBe(true); - expect(ApiKey.make(key.encode()).isOk()).toBe(true); + expect(ApiKey.decode(key.toJSON()).isErr()).toBe(true); + expect(ApiKey.make(key.toJSON()).isOk()).toBe(true); }); test("bad caller input is InvalidEntity, never a defect", () => { diff --git a/packages/entity/src/entity.spec.ts b/packages/entity/src/entity.spec.ts index c39021c..6511f53 100644 --- a/packages/entity/src/entity.spec.ts +++ b/packages/entity/src/entity.spec.ts @@ -27,8 +27,8 @@ test("decode produces an instance with typed data and working methods", () => { expect(org.shout()).toBe("ACME"); }); -test("encode returns the stored data", () => { - expect(Organization.decode(raw).getOrThrow().encode()).toEqual(raw); +test("toJSON returns the stored data", () => { + expect(Organization.decode(raw).getOrThrow().toJSON()).toEqual(raw); }); test("with no options, decoded and encoded describe the same fields", () => { @@ -41,7 +41,7 @@ test("the tag is readable but never part of the data", () => { const org = Organization.decode(raw).getOrThrow(); expect(org._tag).toBe("Organization"); expect(Object.keys(org)).not.toContain("_tag"); - expect(org.encode()).not.toHaveProperty("_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"); @@ -91,11 +91,11 @@ test("locking data fields leaves subclass instance fields writable", () => { expect(org.slug).toBe("acme"); }); -test("encode does not leak subclass instance fields", () => { +test("toJSON does not leak subclass instance fields", () => { class OrgWithCache extends Organization { cachedSummary = "leak me"; } - expect(OrgWithCache.decode(raw).getOrThrow().encode()).not.toHaveProperty("cachedSummary"); + expect(OrgWithCache.decode(raw).getOrThrow().toJSON()).not.toHaveProperty("cachedSummary"); }); const Instant = z.iso.datetime().brand("Instant"); @@ -178,7 +178,7 @@ test("an array field cannot be mutated in place", () => { expect(() => { asMutableArray(bag.tags)[0] = "hacked"; }).toThrow(TypeError); - expect(bag.encode().tags).toEqual(["a"]); + expect(bag.toJSON().tags).toEqual(["a"]); }); test("a nested object field, and the array inside it, are frozen too", () => { @@ -187,7 +187,7 @@ test("a nested object field, and the array inside it, are frozen too", () => { asMutableRecord(bag.address)["city"] = "Paris"; }).toThrow(TypeError); expect(() => asMutableArray(bag.address.lines).push("floor 2")).toThrow(TypeError); - expect(bag.encode().address).toEqual({ city: "Lyon", lines: ["1 rue de la Paix"] }); + expect(bag.toJSON().address).toEqual({ city: "Lyon", lines: ["1 rue de la Paix"] }); }); test("a construction-time invariant cannot be defeated after construction", () => { @@ -202,8 +202,8 @@ test("a construction-time invariant cannot be defeated after construction", () = test("update still produces a new entity from frozen data", () => { const bag = Bag.decode(bagRaw).getOrThrow(); const updated = bag.update({ tags: ["x", "y"] as unknown as z.infer[] }).getOrThrow(); - expect(updated.encode().tags).toEqual(["x", "y"]); - expect(bag.encode().tags).toEqual(["a"]); + 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", () => { @@ -211,5 +211,5 @@ test("update cannot smuggle a mutation in through the patch it was handed", () = const patch = { tags: ["x"] as unknown as z.infer[] }; const updated = bag.update(patch).getOrThrow(); asMutableArray(patch.tags).push("y"); - expect(updated.encode().tags).toEqual(["x"]); + expect(updated.toJSON().tags).toEqual(["x"]); }); diff --git a/packages/entity/src/entity.test-d.ts b/packages/entity/src/entity.test-d.ts index b5aeeba..d2fbbc4 100644 --- a/packages/entity/src/entity.test-d.ts +++ b/packages/entity/src/entity.test-d.ts @@ -54,14 +54,18 @@ test("a branded field survives DeepReadonly with its brand intact", () => { void wrong; }); -test("encode() and toJSON() still return the plain, mutable decoded shape", () => { +test("toJSON() returns the plain, mutable decoded shape", () => { const bag = Bag.decode({}).getOrThrow(); - // encode() builds a fresh object, so its own keys stay assignable — + // 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.encode(); - const json: Decoded = bag.toJSON(); + const state: Decoded = bag.toJSON(); state.tags = []; - void json; +}); + +test("toJSON() is the only projection — there is no second public spelling", () => { + const bag = Bag.decode({}).getOrThrow(); + // @ts-expect-error `encode()` was removed; `toJSON()` is the one projection + bag.encode(); }); test("updateInput.shape is a precise mapped type, not an index signature", () => { diff --git a/packages/entity/src/entity.ts b/packages/entity/src/entity.ts index e378f65..95c5b39 100644 --- a/packages/entity/src/entity.ts +++ b/packages/entity/src/entity.ts @@ -83,6 +83,20 @@ export function Entity(tag: Tag) { type EncodedShape = EncodedOf; const dataKeys = Object.keys(decoded.shape) as unknown as readonly (keyof DecodedShape)[]; + + /** + * Projects an instance down to exactly the `decoded` 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 parseEncoded = fromSchema(encoded); // `DecodedShape` is hand-rolled alias of the same values for better error clarity const parseDecoded = fromSchema(decoded) as (d: unknown) => Result; @@ -155,25 +169,34 @@ export function Entity(tag: Tag) { }); } // non-enumerable, so it is absent from Object.keys, spread, - // JSON.stringify and encode() — but still readable by P.tag + // JSON.stringify and toJSON() — but still readable by P.tag Object.defineProperty(this, "_tag", { value: tag, enumerable: false }); } - /** projects only the stored schema's keys, so subclass fields never leak */ - encode(): DecodedShape { - const self = this as unknown as Record; - return Object.fromEntries(dataKeys.map((k) => [k, self[k]])) as DecodedShape; - } - + /** + * The stored data, projected to exactly the `decoded` 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 + * to exist regardless: without it, `JSON.stringify(entity)` walks own + * enumerable properties, which includes a subclass's own instance + * fields, and leaks them. Projecting `dataKeys` is what excludes both + * those and `_tag`. + * + * There is deliberately no second method returning the same value under + * a domain name. One that existed here was removed: two public spellings + * 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 { - return this.encode(); + return project(this); } - /** Equal encoded data means equal entity. Compares JSON-serialized form to + /** Equal stored data means equal entity. Compares JSON-serialized form to * handle arrays correctly and ignore construction order. */ equals(other: unknown): boolean { if (!(other instanceof Base)) return false; - return JSON.stringify(this.encode()) === JSON.stringify(other.encode()); + return JSON.stringify(project(this)) === JSON.stringify(project(other)); } /** a full untrusted encoded payload → entity */ @@ -240,7 +263,7 @@ export function Entity(tag: Tag) { /** a partial of the mutable fields → a NEW entity */ update(this: Base, patch: PatchOf): Result { - const current = this.encode() as Record; + const current = project(this) as Record; const applied = { ...current }; for (const [k, v] of Object.entries(patch as object)) { // immutable keys are a compile error already; drop them at runtime diff --git a/packages/entity/src/equality.spec.ts b/packages/entity/src/equality.spec.ts index ee3a90e..ab6d129 100644 --- a/packages/entity/src/equality.spec.ts +++ b/packages/entity/src/equality.spec.ts @@ -33,7 +33,7 @@ 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(); - expect(a.encode().tags === b.encode().tags).toBe(false); + expect(a.toJSON().tags === b.toJSON().tags).toBe(false); expect(a.equals(b)).toBe(true); }); diff --git a/packages/entity/src/freeze.ts b/packages/entity/src/freeze.ts index c0b0cf7..0296d48 100644 --- a/packages/entity/src/freeze.ts +++ b/packages/entity/src/freeze.ts @@ -4,7 +4,7 @@ * The entity constructor installs each data field with * `Object.defineProperty(…, { writable: false })`, which locks the *binding* * and nothing else: `org.tags = []` throws, but `org.tags.push(…)` succeeds, - * shows up in `encode()`, and can push an entity into a state its own + * shows up in `toJSON()`, and can push an entity into a state its own * `invariants` already rejected. Freezing the values as they are installed is * what makes "a rule that holds at construction holds for the instance's * lifetime" true rather than aspirational. diff --git a/packages/entity/src/types.ts b/packages/entity/src/types.ts index 6298e05..29705db 100644 --- a/packages/entity/src/types.ts +++ b/packages/entity/src/types.ts @@ -193,7 +193,6 @@ interface BaseInstance< K extends readonly (keyof S)[], I extends readonly (keyof DecodedOf)[], > { - encode(): DecodedOf; toJSON(): DecodedOf; equals(other: unknown): boolean; update(patch: PatchOf): Result; @@ -211,9 +210,9 @@ interface BaseInstance< * The data half is `DeepReadonly`, not `Readonly`: a shallow `Readonly` would * 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. `encode()` and - * `toJSON()` keep returning the plain `DecodedOf` shape: they build a fresh - * object, so its own keys really are assignable. + * data, defeating an invariant that had already been checked. `toJSON()` keeps + * returning the plain `DecodedOf` shape: it builds a fresh object, so its own + * keys really are assignable. */ type ConstructedInstance< Tag extends string,