feat!: derive computed fields from stored data on every construction - #10
Conversation
There was a problem hiding this comment.
Pull request overview
Refactors the entity builder’s “computed fields” feature by replacing decoded: { omit, add } with a top-level computed, and changing computed fields to be re-derived from declared data on every construction path (decode, make, update) to prevent drift/staleness.
Changes:
- Replace
add(fields)(fn)withcomputed(fields, fn)and rename related types (AddedOf→ComputedOf,AddSpec→ComputedSpec). - Change construction logic to re-derive computed fields each time and update docs/tests accordingly.
- Remove legacy
omit/decodedsplit, update fixtures and contract tests to match the new stored/decoded semantics.
Reviewed changes
Copilot reviewed 14 out of 14 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
| README.md | Updates public docs to explain computed, round-tripping, and getter-vs-computed guidance. |
| packages/entity/src/entity.ts | Implements computed support and re-derivation during construction/update. |
| packages/entity/src/types.ts | Updates exported helper types to remove omit/add parameters and model computed fields. |
| packages/entity/src/index.ts | Exports computed / ComputedSpec instead of add / AddSpec. |
| packages/entity/src/computed.ts | Adds the new computed(fields, from) helper and ComputedSpec type. |
| packages/entity/src/computed.spec.ts | Adds runtime tests covering recomputation, patch exclusion, and defect behavior. |
| packages/entity/src/decoded.spec.ts | Removes old decoded/omit/add behavior tests. |
| packages/entity/src/contract.spec.ts | Updates contract fixture from secret-omission to denormalized computed searchKey. |
| packages/entity/src/types.test-d.ts | Updates type-level assertions for ComputedOf / new DecodedOf / PatchOf shapes. |
| packages/entity/src/entity.test-d.ts | Updates type-level tests to use computed instead of add. |
| packages/entity/src/add.ts | Removes the old add helper implementation. |
| packages/entity/README.md | Updates package README to describe computed instead of decoded.add. |
| CLAUDE.md | Updates internal architecture docs to reference computed instead of decoded.omit/decoded.add. |
| .changeset/computed-derived-from-stored.md | Adds a changeset documenting the breaking API/semantic shift. |
Suppressed comments (3)
packages/entity/src/types.ts:148
- This comment still says “added fields” when referring to
A; with the rename tocomputed, it should say “computed fields” for consistency.
* The field *schemas* `updateInput` is built from: the decoded field map
* (`S & A`, the same construction `EntityStatic["decoded"]`
* 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()`
packages/entity/src/entity.ts:327
- The comment inside
update()still describes computed fields as coming fromadd. Since the code path is now driven bycomputed, the comment should be updated to match the actual semantics and avoid confusion during debugging.
update(this: Base, patch: PatchOf<S, A, I>): Result<Base, InvalidEntity> {
packages/entity/src/types.ts:141
- This
PatchOfcomment still describes the oldadd/omitbehavior. Withcomputednow being re-derived on every construction, the rationale for excludingkeyof Ashould be updated accordingly.
export type PatchOf<
S extends Fields,
A extends Fields,
I extends readonly (keyof DecodedOf<S, A>)[],
> = Partial<Omit<DecodedOf<S, A>, I[number] | keyof A>>;
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| /** | ||
| * What the entity stores and returns: encoded, minus the omitted fields, plus | ||
| * the added ones. There is deliberately no `_tag` — the tag is a | ||
| * non-enumerable instance property and never part of the data. | ||
| */ |
There was a problem hiding this comment.
Fixed in fb7819f. ComputedOf, DecodedOf, PatchOf and UpdateInputShapeOf all had comments describing the omit/add split; they now describe "declared fields plus computed fields". PatchOf's reasoning also changed substantively, not just its wording: a computed field is excluded because it is derived and update re-runs every derivation, so a patched value would only be overwritten — not, as before, because there was nothing left to recompute from.
Replaces
decoded: { omit, add }with a top-levelcomputed, and changes whata computed field is: derived from the declared fields and re-derived on every
construction, rather than computed once from the wire payload and frozen.
The bug this fixes
Today a computed field goes stale, silently and unrepairably:
That made
computedsound only when every source was immutable or consumed —which is why every plausible example (
fullName,totalCents,tier,wordCount,durationDays) was broken, and only the secret/fingerprint shapeworked. Re-deriving makes the whole category correct:
makere-derives too, so a row written before the derivation changed heals onread rather than being trusted.
omitis goneIts two jobs — feed a computed field then vanish, and validate-then-discard —
both belong in the use case that owns the transformation, which also lets that
transformation be async. Password hashing, the canonical example, could
never use
addat all: argon2 and bcrypt are async, andaddwas sync andpure.
Removing it drops the
Ktype parameter fromDecodedOf,PatchOf,UpdateInputShapeOf,BaseInstance,ConstructedInstanceandEntityStatic.Consumers see it in every diagnostic, so this shortens type errors on entities
that use none of these features.
Also
add(fields)(fn)→computed(fields, fn). The curry was justified by acomment claiming it was what made the callback contextually typed. Measured:
the two-argument form infers identically — a negative test confirms the
parameter is the exact declared shape, not
any. Comment replaced with whatis actually true.
AddedOf/AddSpec→ComputedOf/ComputedSpec;add.ts→computed.ts.derived value would only be overwritten by the next derivation.
decode(x.toJSON())now round-trips — the stored shape is the wire shapeplus computed fields, and those are re-derived rather than read. The README
passage explaining why it didn't is gone.
reader will have: a getter carries no schema, so it cannot reach
decoded,the JSON Schema, or
toJSON().Fixture change worth reviewing
contract.spec.tswas built around anApiKeywith a rawsecretthatomitdropped. Without
omitthat fixture would have stored the secret, so it is nowan entity with a denormalised
searchKeyderived fromlabel— a case the newsemantics actually serve.
Gate
format --check,lint,typecheck(both passes),test(90, 9 files),knip,build— all green.