Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .changeset/lucky-pandas-relate.md
Original file line number Diff line number Diff line change
@@ -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<T>` 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())`.
13 changes: 10 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<D>`, the module-private `unique symbol` that makes `new X(...)` a
Expand Down
40 changes: 27 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,8 +110,8 @@ const org = Organization.create(

org.slug; // "acme" — typed, read-only
org.update({ name: "Acme Inc" as z.infer<typeof DisplayName> }); // a NEW entity; Result<Organization, InvalidEntity>
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);
Expand Down Expand Up @@ -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`
Expand Down Expand Up @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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<T, InvalidEntity>`:
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
18 changes: 10 additions & 8 deletions packages/entity/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
12 changes: 6 additions & 6 deletions packages/entity/src/decoded.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
20 changes: 10 additions & 10 deletions packages/entity/src/entity.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand All @@ -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");
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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", () => {
Expand All @@ -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", () => {
Expand All @@ -202,14 +202,14 @@ 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<typeof Tag>[] }).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", () => {
const bag = Bag.decode(bagRaw).getOrThrow();
const patch = { tags: ["x"] as unknown as z.infer<typeof Tag>[] };
const updated = bag.update(patch).getOrThrow();
asMutableArray(patch.tags).push("y");
expect(updated.encode().tags).toEqual(["x"]);
expect(updated.toJSON().tags).toEqual(["x"]);
});
14 changes: 9 additions & 5 deletions packages/entity/src/entity.test-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof Bag> = bag.encode();
const json: Decoded<typeof Bag> = bag.toJSON();
const state: Decoded<typeof Bag> = 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", () => {
Expand Down
45 changes: 34 additions & 11 deletions packages/entity/src/entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,20 @@ export function Entity<Tag extends string>(tag: Tag) {
type EncodedShape = EncodedOf<S>;

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<keyof DecodedShape, unknown>;
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<DecodedShape, SchemaIssues>;
Expand Down Expand Up @@ -155,25 +169,34 @@ export function Entity<Tag extends string>(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<keyof DecodedShape, unknown>;
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 */
Expand Down Expand Up @@ -240,7 +263,7 @@ export function Entity<Tag extends string>(tag: Tag) {

/** a partial of the mutable fields → a NEW entity */
update(this: Base, patch: PatchOf<S, A, K, I>): Result<Base, InvalidEntity> {
const current = this.encode() as Record<PropertyKey, unknown>;
const current = project(this) as Record<PropertyKey, unknown>;
const applied = { ...current };
for (const [k, v] of Object.entries(patch as object)) {
// immutable keys are a compile error already; drop them at runtime
Expand Down
2 changes: 1 addition & 1 deletion packages/entity/src/equality.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});

Expand Down
2 changes: 1 addition & 1 deletion packages/entity/src/freeze.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading
Loading