Skip to content

feat!: derive computed fields from stored data on every construction - #10

Merged
btravers merged 2 commits into
mainfrom
feat/computed-derived-from-stored
Aug 6, 2026
Merged

feat!: derive computed fields from stored data on every construction#10
btravers merged 2 commits into
mainfrom
feat/computed-derived-from-stored

Conversation

@btravers

@btravers btravers commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Replaces decoded: { omit, add } with a top-level computed, and changes what
a 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:

Person { first: "Ada", last: "Lovelace" }  →  fullName: "Ada Lovelace"
update({ last: "Byron" })                  →  fullName: "Ada Lovelace"   ✗
updateInput keys: ["first","last"]         →  and not patchable

That made computed sound 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 shape
worked. Re-deriving makes the whole category correct:

computed: computed({ fullName: FullName }, (d) => ({
  fullName: `${d.first} ${d.last}`,
}))

p.update({ last: "Byron" }).getOrThrow().fullName; // "Ada Byron"

make re-derives too, so a row written before the derivation changed heals on
read rather than being trusted.

omit is gone

Its 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 add at all: argon2 and bcrypt are async, and add was sync and
pure.

Removing it drops the K type parameter from DecodedOf, PatchOf,
UpdateInputShapeOf, BaseInstance, ConstructedInstance and EntityStatic.
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 a
    comment 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 what
    is actually true.
  • AddedOf/AddSpecComputedOf/ComputedSpec; add.tscomputed.ts.
  • A computed field stays unpatchable, but for a better reason: patching a
    derived value would only be overwritten by the next derivation.
  • decode(x.toJSON()) now round-trips — the stored shape is the wire shape
    plus computed fields, and those are re-derived rather than read. The README
    passage explaining why it didn't is gone.
  • README gains the getter-vs-computed rule, which is the first question a
    reader will have: a getter carries no schema, so it cannot reach decoded,
    the JSON Schema, or toJSON().

Fixture change worth reviewing

contract.spec.ts was built around an ApiKey with a raw secret that omit
dropped. Without omit that fixture would have stored the secret, so it is now
an entity with a denormalised searchKey derived from label — a case the new
semantics actually serve.

Gate

format --check, lint, typecheck (both passes), test (90, 9 files),
knip, build — all green.

Copilot AI lite review requested due to automatic review settings August 6, 2026 22:44

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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) with computed(fields, fn) and rename related types (AddedOfComputedOf, AddSpecComputedSpec).
  • Change construction logic to re-derive computed fields each time and update docs/tests accordingly.
  • Remove legacy omit/decoded split, 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 to computed, 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 from add. Since the code path is now driven by computed, 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 PatchOf comment still describes the old add/omit behavior. With computed now being re-derived on every construction, the rationale for excluding keyof A should 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.

Comment thread packages/entity/src/entity.ts Outdated
Comment thread packages/entity/src/entity.ts Outdated
Comment thread packages/entity/src/entity.ts Outdated
Comment on lines 32 to 36
/**
* 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.
*/

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread README.md
Comment thread packages/entity/src/entity.test-d.ts Outdated
Comment thread packages/entity/src/entity.ts Outdated
Comment thread packages/entity/src/computed.spec.ts Outdated
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants