diff --git a/.changeset/soft-hoops-hammer.md b/.changeset/soft-hoops-hammer.md new file mode 100644 index 0000000..de0acd9 --- /dev/null +++ b/.changeset/soft-hoops-hammer.md @@ -0,0 +1,5 @@ +--- +"@btravstack/entity": minor +--- + +Treat `decoded.add` fields as implicitly immutable: they are excluded from `updateInput` and `Patch`, and `update()` drops them at runtime. diff --git a/README.md b/README.md index bf7cb43..bcb7fe2 100644 --- a/README.md +++ b/README.md @@ -289,6 +289,17 @@ its output is what makes that cast honest: if `add` ever produces data its own declared schema would reject, that is a **defect** (a bug in domain code), not ordinary bad input. See [Error handling](#error-handling). +**Added fields are implicitly immutable**, whether or not `immutable` names +them: they are absent from `updateInput` and from the `Patch` type, and +`update()` drops them even if smuggled in at runtime. They are also not +recomputed. That is not an omission but the only honest option — `add` reads +the **encoded** object, and by `update()` time only the decoded one is left, +which no longer carries the omitted source field (`secret`) the computation +needs; this is the same asymmetry as `decode(x.encode())` not round-tripping. +Given the choice between a value that silently drifts out of step with its +source and one a caller can contradict outright, the package offers neither: a +derived value only changes by re-`decode`-ing a fresh encoded payload. + ## `invariants` An invariant is a rule spanning two or more fields that no single field's diff --git a/packages/entity/README.md b/packages/entity/README.md index 5f4837f..b757459 100644 --- a/packages/entity/README.md +++ b/packages/entity/README.md @@ -55,7 +55,7 @@ class name it labels, ahead of the field map. | `generated` | keys the domain supplies, not the caller — omitted from `createInput` | | `immutable` | keys that never change after creation — omitted from `updateInput` | | `decoded.omit` | keys present on the wire but not stored (e.g. a raw secret) | -| `decoded.add` | computed fields, declared with the `add` helper (see below) | +| `decoded.add` | computed fields, declared with the `add` helper — implicitly immutable (see below) | | `invariants` | `(decoded) => readonly string[]` — non-empty means rejected, checked on every `decode`, `make`, `create` and `update` | ## Statics @@ -122,6 +122,13 @@ add({ fingerprint: Fingerprint })((e) => ({ visible to compute from) and the callback's return type is checked against the declared fields, so every value must already be branded. +Added fields are **implicitly immutable**: they are absent from `updateInput` +and from `Patch`, and `update()` ignores them even if smuggled in at runtime. +They are not recomputed either, because `add` reads the encoded shape and +`update()` only holds the decoded one — the omitted source field is already +gone by then. A derived value therefore changes only by decoding a fresh +encoded payload. + ## Helper types Four generic type-level helpers name each shape by reading it off an entity diff --git a/packages/entity/src/contract.spec.ts b/packages/entity/src/contract.spec.ts index 77ce250..178ff92 100644 --- a/packages/entity/src/contract.spec.ts +++ b/packages/entity/src/contract.spec.ts @@ -9,9 +9,10 @@ const OrgId = z.uuid().brand("OrgId"); const Secret = z.string().min(16).brand("Secret"); const Fingerprint = z.string().length(12).brand("Fingerprint"); const Instant = z.iso.datetime().brand("Instant"); +const Label = z.string().min(1).brand("Label"); class ApiKey extends Entity("ApiKey")( - { id: ApiKeyId, orgId: OrgId.readonly(), secret: Secret, createdAt: Instant }, + { id: ApiKeyId, orgId: OrgId.readonly(), secret: Secret, label: Label, createdAt: Instant }, { generated: ["id", "createdAt"], immutable: ["id", "orgId", "createdAt"], @@ -35,19 +36,27 @@ const props = (s: z.ZodType, io: "input" | "output") => { }; test("encoded drives the full request schema", () => { - expect(props(ApiKey.encoded, "input")).toEqual(["createdAt", "id", "orgId", "secret"]); + expect(props(ApiKey.encoded, "input")).toEqual(["createdAt", "id", "label", "orgId", "secret"]); }); test("decoded drives the response schema", () => { - expect(props(ApiKey.decoded, "output")).toEqual(["createdAt", "fingerprint", "id", "orgId"]); + expect(props(ApiKey.decoded, "output")).toEqual([ + "createdAt", + "fingerprint", + "id", + "label", + "orgId", + ]); }); test("createInput is the create request schema", () => { - expect(props(ApiKey.createInput, "input")).toEqual(["orgId", "secret"]); + expect(props(ApiKey.createInput, "input")).toEqual(["label", "orgId", "secret"]); }); test("updateInput is the update request schema", () => { - expect(props(ApiKey.updateInput, "input")).toEqual(["fingerprint"]); + // `fingerprint` is on the response schema but not this one: `add` fields are + // implicitly immutable, so they are never part of an update request. + expect(props(ApiKey.updateInput, "input")).toEqual(["label"]); }); test("all four ZodObject members convert in both directions", () => { diff --git a/packages/entity/src/decoded.spec.ts b/packages/entity/src/decoded.spec.ts index b5fb0fa..e82811a 100644 --- a/packages/entity/src/decoded.spec.ts +++ b/packages/entity/src/decoded.spec.ts @@ -9,6 +9,8 @@ const ApiKeyId = z.uuid().brand("ApiKeyId"); const OrgId = z.uuid().brand("OrgId"); const Secret = z.string().min(16).brand("Secret"); const Fingerprint = z.string().length(12).brand("Fingerprint"); +const Slug = z.string().min(1).brand("Slug"); +const Upper = z.string().min(1).brand("Upper"); const fingerprintOf = (s: string) => s.slice(0, 12) as z.infer; @@ -61,6 +63,41 @@ const issuesOf = (r: ReturnType): readonly Flat[] => defect: () => [{ path: [], message: "DEFECT" }], }); +test("a computed field is absent from updateInput", () => { + // `add` fields are implicitly immutable: nothing declares `fingerprint` in + // `immutable`, yet it must not appear in the update request schema. + expect(Object.keys(ApiKey.updateInput.shape)).toEqual(["id", "orgId"]); + expect(ApiKey.decoded.shape).toHaveProperty("fingerprint"); +}); + +test("updating a source field leaves the entity consistent", () => { + class Org extends Entity("Org")( + { id: OrgId, slug: Slug }, + { + decoded: { + add: add({ slugUpper: Upper })((e) => ({ + slugUpper: e.slug.toUpperCase() as z.infer, + })), + }, + }, + ) {} + const org = Org.decode({ id: raw.orgId, slug: "acme" }).getOrThrow(); + const renamed = org.update({ slug: "beta" as z.infer }).getOrThrow(); + // `slugUpper` is carried over, not recomputed — the encoded object `add` + // reads from is gone by `update` time. It stays pinned to the value the + // entity was decoded with, so the pair is never internally contradictory in + // the way a silently stale recomputation would be. + expect(renamed.slug).toBe("beta"); + expect(renamed.slugUpper).toBe("ACME"); + expect(Org.decode({ id: raw.orgId, slug: renamed.slug }).getOrThrow().slugUpper).toBe("BETA"); +}); + +test("update ignores a computed field smuggled in at runtime", () => { + const key = ApiKey.decode(raw).getOrThrow(); + const updated = key.update({ fingerprint: "LIESLIESLIES" } as never).getOrThrow(); + expect(updated.fingerprint).toBe("sk_live_9f3c"); +}); + test("bad caller input is InvalidEntity, never a defect", () => { const outcome = ApiKey.decode({ ...raw, secret: "short" }).match({ ok: () => "WRONGLY ACCEPTED", diff --git a/packages/entity/src/entity.test-d.ts b/packages/entity/src/entity.test-d.ts index b5aeeba..c9955e6 100644 --- a/packages/entity/src/entity.test-d.ts +++ b/packages/entity/src/entity.test-d.ts @@ -143,6 +143,31 @@ test("create rejects a generated field and update rejects an immutable one", () org.update({ slug: "ok" as never }); }); +test("an added field is immutable without being declared immutable", () => { + const Upper = z.string().brand("Upper"); + class Org extends Entity("Org")( + { id: OrgId, slug: Slug }, + { + decoded: { + add: add({ slugUpper: Upper })((e) => ({ + slugUpper: e.slug.toUpperCase() as z.infer, + })), + }, + }, + ) {} + + const org = Org.make({}).getOrThrow(); + // @ts-expect-error `slugUpper` is computed by `add`, so it is implicitly + // immutable — patching it would let a caller contradict its own source + org.update({ slugUpper: "LIES" as never }); + org.update({ slug: "ok" as never }); + + // and it is gone from `updateInput` too, so the request schema agrees with + // the patch type; reading the key back is a compile error (TS2339) + // @ts-expect-error `slugUpper` is not part of the update request schema + void Org.updateInput.shape.slugUpper; +}); + test("a misspelled immutable key is a compile error, not a silently-mutable field", () => { // @ts-expect-error "slugg" is not a key of the decoded shape — a typo here // must not compile, or the misspelled field is mutable by accident diff --git a/packages/entity/src/entity.ts b/packages/entity/src/entity.ts index 7194871..bc74cba 100644 --- a/packages/entity/src/entity.ts +++ b/packages/entity/src/entity.ts @@ -70,15 +70,29 @@ export function Entity(tag: Tag) { const generatedKeys = options?.generated ?? []; const immutableKeys = options?.immutable ?? []; + /** + * Every key `update` refuses: the declared immutable ones, plus the keys + * `add` contributed. An added field is *implicitly* immutable — `add` + * reads the **encoded** object, and `update` only ever holds the decoded + * one, which no longer carries an omitted source field like `secret`, so + * there is nothing to recompute from. Freezing them is the only answer + * that keeps a computed field consistent with its source; see `PatchOf`. + * Typed at the widened runtime element type so both uses below — the + * `.omit()` mask and `update`'s drop-list — take it without a cast. + */ + const frozenKeys: readonly PropertyKey[] = [ + ...immutableKeys, + ...(addSpec ? Object.keys(addSpec.fields) : []), + ]; + /** what a caller may send to create */ const createInput = omitBy(encoded as z.ZodObject, generatedKeys) as z.ZodObject< Omit >; /** what a caller may send to update */ - const updateInput = omitBy( - decoded as z.ZodObject, - immutableKeys, - ).partial() as z.ZodObject>; + const updateInput = omitBy(decoded as z.ZodObject, frozenKeys).partial() as z.ZodObject< + UpdateInputShapeOf + >; type DecodedShape = DecodedOf; type EncodedShape = EncodedOf; @@ -244,10 +258,11 @@ export function Entity(tag: Tag) { const current = this.encode() 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 - // too — `immutableKeys` is generic `I`, so `.includes` narrows its - // parameter, hence the cast to the widened runtime element type. - if (!(immutableKeys as readonly PropertyKey[]).includes(k)) applied[k] = v; + // frozen keys — declared immutable, or contributed by `add` — are a + // compile error already; drop them at runtime too, so a patch that + // reached here as `unknown` cannot desynchronise a computed field + // from the source it was derived from. + if (!frozenKeys.includes(k)) applied[k] = v; } const Ctor = this.constructor as unknown as { make: (state: unknown) => Result; diff --git a/packages/entity/src/types.test-d.ts b/packages/entity/src/types.test-d.ts index d46c0e7..2c341f3 100644 --- a/packages/entity/src/types.test-d.ts +++ b/packages/entity/src/types.test-d.ts @@ -70,6 +70,9 @@ test("PatchOf is partial and drops the immutable fields", () => { type P = PatchOf; expectTypeOf

().not.toHaveProperty("id"); expectTypeOf().toEqualTypeOf | undefined>(); + // `fingerprint` is not in the immutable list, yet it is still gone: an + // `add`-produced field is implicitly immutable + expectTypeOf

().not.toHaveProperty("fingerprint"); }); test("Sealed cannot be produced from outside the module", () => { diff --git a/packages/entity/src/types.ts b/packages/entity/src/types.ts index 6298e05..6948f60 100644 --- a/packages/entity/src/types.ts +++ b/packages/entity/src/types.ts @@ -127,18 +127,29 @@ export type DeepReadonly = T extends Immutable ? T : { readonly [K in keyof T]: DeepReadonly }; -/** What `update` accepts: a partial of the stored data, minus the immutable fields. */ +/** + * What `update` accepts: a partial of the stored data, minus the immutable + * fields — and minus `keyof A`, because an `add`-produced field is + * *implicitly* immutable whether or not `immutable` names it. Nothing can + * honestly recompute one on update: `add`'s input is the *encoded* object, and + * `update` only has the decoded one, which no longer carries the omitted source + * field the computation reads from (the same asymmetry that stops + * `decode(x.encode())` round-tripping). Leaving a computed field patchable + * would let a caller set it to a value its own source contradicts, so it is + * excluded instead. + */ export type PatchOf< S extends Fields, A extends Fields, K extends readonly (keyof S)[], I extends readonly (keyof DecodedOf)[], -> = Partial, I[number]>>; +> = Partial, I[number] | keyof A>>; /** * The field *schemas* `updateInput` is built from: the decoded field map * (`Omit & A`, the same construction `EntityStatic["decoded"]` - * uses), minus the immutable keys, with every remaining schema wrapped in + * uses), minus the immutable keys and minus `keyof A` — the added fields are + * implicitly immutable, see `PatchOf` — with every remaining schema wrapped in * `ZodOptional` — the type-level mirror of what `.omit(...).partial()` * produces at runtime. A mapped object type rather than the `Fields` index * signature, so `Organization.updateInput.shape.name` is a named property @@ -150,7 +161,7 @@ export type UpdateInputShapeOf< K extends readonly (keyof S)[], I extends readonly (keyof DecodedOf)[], > = { - [Key in Exclude & A), I[number]>]: z.ZodOptional< + [Key in Exclude & A), I[number] | keyof A>]: z.ZodOptional< (Omit & A)[Key] >; };