From d70c2051c0fdb2fc79f358c4b75b98f2027a5cb5 Mon Sep 17 00:00:00 2001 From: Benoit Travers Date: Fri, 7 Aug 2026 15:42:26 +0200 Subject: [PATCH] chore: make 0.1.0 the first published version The package has never been published, so the 22 accumulated changesets all described migrations between states nobody could have installed -- and `changeset version` would have consumed them into a 0.2.0 whose changelog re-announced the entire history as breaking changes to a version that never existed. They are replaced by a hand-written 0.1.0 entry describing the surface as it is rather than the route to it. The version stays 0.1.0 and `changeset status` reports nothing to bump, so the first automated release will be whatever comes *after* the manual bootstrap publish. The empty changeset is the sanctioned "no release needed" marker, and is accurate: this commit is release plumbing. Co-Authored-By: Claude Opus 5 (1M context) --- .changeset/computed-derived-from-stored.md | 36 ----------- .changeset/consumer-declaration-emit.md | 23 -------- .changeset/deep-immutable-entity-data.md | 9 --- .changeset/docs-diataxis.md | 10 ---- .changeset/drop-class-standard-schema.md | 17 ------ .changeset/drop-node-20-matrix.md | 8 --- .changeset/entity-extend.md | 26 -------- .changeset/entity-factory.md | 26 -------- .changeset/entity-invariant.md | 41 ------------- .changeset/entity-is-a-schema.md | 30 ---------- .changeset/entity-namespace.md | 42 ------------- .changeset/entity-union.md | 25 -------- .changeset/factory-returns-a-function.md | 26 -------- .changeset/fix-equality-union-freeze.md | 44 -------------- .changeset/forbid-entity-subclassing.md | 17 ------ .../invalid-entity-structured-issues.md | 15 ----- .changeset/lucky-pandas-relate.md | 19 ------ .changeset/nest-entities.md | 33 ----------- .changeset/no-release-first-publish.md | 6 ++ .changeset/one-entry-point.md | 23 -------- .changeset/soft-hoops-hammer.md | 5 -- .changeset/tsconfig-comments.md | 8 --- .changeset/wild-pandas-shout.md | 6 -- packages/entity/CHANGELOG.md | 59 +++++++++++++++++++ 24 files changed, 65 insertions(+), 489 deletions(-) delete mode 100644 .changeset/computed-derived-from-stored.md delete mode 100644 .changeset/consumer-declaration-emit.md delete mode 100644 .changeset/deep-immutable-entity-data.md delete mode 100644 .changeset/docs-diataxis.md delete mode 100644 .changeset/drop-class-standard-schema.md delete mode 100644 .changeset/drop-node-20-matrix.md delete mode 100644 .changeset/entity-extend.md delete mode 100644 .changeset/entity-factory.md delete mode 100644 .changeset/entity-invariant.md delete mode 100644 .changeset/entity-is-a-schema.md delete mode 100644 .changeset/entity-namespace.md delete mode 100644 .changeset/entity-union.md delete mode 100644 .changeset/factory-returns-a-function.md delete mode 100644 .changeset/fix-equality-union-freeze.md delete mode 100644 .changeset/forbid-entity-subclassing.md delete mode 100644 .changeset/invalid-entity-structured-issues.md delete mode 100644 .changeset/lucky-pandas-relate.md delete mode 100644 .changeset/nest-entities.md create mode 100644 .changeset/no-release-first-publish.md delete mode 100644 .changeset/one-entry-point.md delete mode 100644 .changeset/soft-hoops-hammer.md delete mode 100644 .changeset/tsconfig-comments.md delete mode 100644 .changeset/wild-pandas-shout.md create mode 100644 packages/entity/CHANGELOG.md diff --git a/.changeset/computed-derived-from-stored.md b/.changeset/computed-derived-from-stored.md deleted file mode 100644 index b33d1c9..0000000 --- a/.changeset/computed-derived-from-stored.md +++ /dev/null @@ -1,36 +0,0 @@ ---- -"@btravstack/entity": minor ---- - -**BREAKING**: `decoded: { omit, add }` is replaced by a top-level `computed`. - -`omit` is removed. A field the entity should not store is transformed before -`create` is called, in the use case that owns the transformation — which also -lets it be async, as password hashing is. - -`add` becomes `computed`, takes two arguments instead of being curried, and — -the substantive change — reads the **declared** fields and is **re-derived on -every construction** rather than computed once from the wire payload and -frozen. A derived value can no longer go stale against its sources, and `make` -heals a row written before the derivation changed. - -```ts -// before — computed once from the wire payload, then frozen -decoded: { - omit: ["secret"], - add: add({ fingerprint: Fingerprint })((e) => ({ fingerprint: hash(e.secret) })), -} - -// after — one entry per derived field, re-derived on every construction -computed: { - fullName: computed(FullName, (d) => `${d.first} ${d.last}`), - initials: computed(Initials, (d) => `${d.first[0]}${d.last[0]}`), -} -``` - -`make` validates against the declared fields only, so a row heals whether its -stored computed value drifted, is invalid, or predates the field. - -Computed fields remain absent from `updateInput` and `Patch`. `DecodedOf`, -`PatchOf` and `UpdateInputShapeOf` lose their omit type parameter, and -`AddedOf`/`AddSpec` are renamed `ComputedOf`/`ComputedField`. diff --git a/.changeset/consumer-declaration-emit.md b/.changeset/consumer-declaration-emit.md deleted file mode 100644 index 028255d..0000000 --- a/.changeset/consumer-declaration-emit.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -"@btravstack/entity": minor ---- - -Fix: a downstream library that emits its own declarations can now use the -package at all. - -`class X extends Entity("X")(...)` failed with `TS4020: 'extends' clause of -exported class has or is using private name 'CtorKey'` (and `'BaseInstance'`) -for any consumer compiling with `declaration: true` — which is every published -TypeScript library. The package's own build never surfaced it, because its -`tsc` pass is `noEmit`. - -The construction seal now uses an exported-but-unconstructable -`ConstructionKey` instead of a module-private `unique symbol`: a `unique -symbol` in computed-key position cannot be named across a module boundary even -when exported, while an ordinary property whose _type_ is an exported class -can. `ConstructionKey`, `Sealed` and `BaseInstance` are exported as types so -the emitted declarations can reference them; none is constructible and none is -meant to be used directly. - -The seal is unchanged in strength — `new SomeEntity(...)` is still a compile -error, and `ConstructionKey` cannot be forged structurally. diff --git a/.changeset/deep-immutable-entity-data.md b/.changeset/deep-immutable-entity-data.md deleted file mode 100644 index 0e3fcc6..0000000 --- a/.changeset/deep-immutable-entity-data.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -"@btravstack/entity": minor ---- - -Make entity data deeply immutable — field values are now deep-frozen at -construction and typed `DeepReadonly<...>`, so `entity.tags.push(...)` is a -compile error and a `TypeError` instead of a silent mutation that could defeat -an invariant; this tightens an existing type and may surface pre-existing -mutation bugs in consumer code. diff --git a/.changeset/docs-diataxis.md b/.changeset/docs-diataxis.md deleted file mode 100644 index ed502be..0000000 --- a/.changeset/docs-diataxis.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -"@btravstack/entity": patch ---- - -Documentation restructured along Diátaxis lines. The README was a 764-line -design document serving two different readers badly; it is now a ~140-line -landing page with one worked example covering the whole lifecycle, and the -rest lives in `docs/` split by what the reader is trying to do: `reference.md` -for lookup, `explanation.md` for rationale, and four how-to guides. The -npm-facing package README becomes a short card pointing at them. diff --git a/.changeset/drop-class-standard-schema.md b/.changeset/drop-class-standard-schema.md deleted file mode 100644 index 79e695c..0000000 --- a/.changeset/drop-class-standard-schema.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -"@btravstack/entity": minor ---- - -**BREAKING**: the entity class no longer carries `~standard`. - -It bought one thing — `fromSchema(Organization)` in place of -`fromSchema(Organization.instance)` — while making the class a validator in -some contexts and not others: `z.object({ owner: Organization })` never -worked, because zod needs a real `ZodType`. Two spellings of one concept that -were not interchangeable. - -`instance` is a zod schema and zod implements Standard Schema, so it already -carries `~standard` and is accepted by anything that takes one. - -Migration: `fromSchema(Organization)` → `fromSchema(Organization.instance)`, -which is the spelling that works everywhere rather than most places. diff --git a/.changeset/drop-node-20-matrix.md b/.changeset/drop-node-20-matrix.md deleted file mode 100644 index 5fa018d..0000000 --- a/.changeset/drop-node-20-matrix.md +++ /dev/null @@ -1,8 +0,0 @@ ---- -"@btravstack/entity": patch ---- - -CI no longer attempts to test on Node 20. The matrix runs the development -toolchain, and pnpm 11 requires `node:sqlite`, so that row could never install -anything. The published `engines` floor is documented as declared-but-unproven -until a consumer-side check exists. diff --git a/.changeset/entity-extend.md b/.changeset/entity-extend.md deleted file mode 100644 index 4a495d4..0000000 --- a/.changeset/entity-extend.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -"@btravstack/entity": minor ---- - -New `extend` static on every entity: build a new entity from an existing -one's declaration. It is `SomeEntity.extend(tag)(fields)`, a static on the -class — not a property of the `Entity` builder. - -```ts -class PersonWithAge extends Person.extend("PersonWithAge")({ age: Age }) { - get isAdult(): boolean { - return this.age >= 18; - } -} -``` - -The result is its own entity — own tag, own schemas, own `equals` identity — -rather than a variant of the parent, which is what distinguishes it from the -bare subclassing that remains refused. - -The parent's options are inherited and merged per key, child winning, so an -extension is never quietly laxer than what it extends. Extensions can -themselves be extended. - -`extend` rebuilds from the declaration, so class-body members (a getter, a -method) are not carried over; re-declare them or use a plain function. diff --git a/.changeset/entity-factory.md b/.changeset/entity-factory.md deleted file mode 100644 index 79e91aa..0000000 --- a/.changeset/entity-factory.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -"@btravstack/entity": minor ---- - -**BREAKING**: `Entity.create(input, generated)` is replaced by -`Entity.factory(generators).create(input)`. - -Generators are bound once where your ports already live, so a create use case -passes only caller input. They are functions, never values, and each is called -once per `create`. `Entity.factoryAsync(generators)` is the promise-returning -variant; its `create` returns an `AsyncResult`, and a generator that rejects -surfaces as a `Defect` rather than an `InvalidEntity`. - -Migration: - -```ts -// before -Organization.create({ slug, name }, { id: ids.next(), createdAt: clock.now() }); - -// after -const orgs = Organization.factory({ - id: () => ids.next(), - createdAt: () => clock.now(), -}); -orgs.create({ slug, name }); -``` diff --git a/.changeset/entity-invariant.md b/.changeset/entity-invariant.md deleted file mode 100644 index bb24038..0000000 --- a/.changeset/entity-invariant.md +++ /dev/null @@ -1,41 +0,0 @@ ---- -"@btravstack/entity": minor ---- - -**Breaking.** `invariants` is now a list of rules built with `Entity.invariant`, -replacing the single function that returned messages. - -```diff - class Organization extends Entity("Organization")( - { name: DisplayName, note: Line }, - { -- invariants: (d) => [ -- ...(d.name.length <= 80 ? [] : ["name must be at most 80 characters"]), -- ...(d.note.length >= d.name.length ? [] : ["note must be at least as long"]), -- ], -+ invariants: [ -+ Entity.invariant((d) => d.name.length <= 80, "name must be at most 80 characters"), -+ Entity.invariant( -+ (d) => d.note.length >= d.name.length, -+ (d) => `note must be at least ${d.name.length} characters`, -+ ), -+ ], - }, - ) {} -``` - -A rule and its message are now one value, so several rules no longer need -hand-rolled accumulation. `ensure` returning **true** means valid. `message` -takes the data when the text depends on it. Every failing rule reports, not just -the first — unchanged from before. - -**A rule now sees the declared fields only.** It can no longer read a computed -field. Every computed value is a function of declared data, so any rule about -one is expressible over its sources, and a computed value that fails its own -schema is already a Defect rather than something to re-check in an invariant. - -**`extend` no longer lets an extension shed its parent's rules.** `invariants` -is the one option that concatenates parent-then-child instead of the child -replacing the parent. An extension can add rules; it cannot remove them, which -is what the design always intended. Code relying on `{ invariants: () => [] }` -to relax a parent has no replacement — that escape hatch is gone deliberately. diff --git a/.changeset/entity-is-a-schema.md b/.changeset/entity-is-a-schema.md deleted file mode 100644 index 7c4863b..0000000 --- a/.changeset/entity-is-a-schema.md +++ /dev/null @@ -1,30 +0,0 @@ ---- -"@btravstack/entity": minor ---- - -**BREAKING**: `instance` is removed — the entity class is now itself a zod -schema. - -```ts -// before -class Order extends Entity("Order")({ customer: Customer.instance }) {} -z.object({ owner: Organization.instance }); -fromSchema(Organization.instance); - -// after -class Order extends Entity("Order")({ customer: Customer }) {} -z.object({ owner: Organization }); -fromSchema(Organization); -``` - -The class carries zod's internal slots (`_zod`, `~standard`) but **not** its -methods. That is deliberate: the full `ZodType` surface would put a throwing -`.parse()` on every entity beside the `make` that returns a `Result`. Use -`make` to parse, and zod's function forms to wrap — `z.optional(Organization)` -rather than `Organization.optional()`. - -`Entity.union(...)` is a schema on the same terms, so a union composes and -nests identically. - -Migration: delete `.instance`. `z.object({ owner: Organization })` now works, -which it never did before. diff --git a/.changeset/entity-namespace.md b/.changeset/entity-namespace.md deleted file mode 100644 index b88c7c7..0000000 --- a/.changeset/entity-namespace.md +++ /dev/null @@ -1,42 +0,0 @@ ---- -"@btravstack/entity": minor ---- - -**Breaking.** The package now exports one name to write against: `Entity`. - -`computed`, `InvalidEntity` and every public type move onto it. A bare -`computed` was too generic to take from a consumer's import scope — it collides -outright with Vue, MobX, Angular signals and Solid — and the same reasoning -already put `union` under `Entity`. Applying it consistently collapses the -surface to a single import. - -| before | after | -| -------------------------------------- | ---------------------- | -| `computed` | `Entity.computed` | -| `InvalidEntity` | `Entity.InvalidEntity` | -| `Input` `Output` `CreateInput` `Patch` | `Entity.Input` … | -| `ComputedField` | `Entity.ComputedField` | -| `EntityUnion` | `Entity.Union` | - -```diff --import { Entity, computed } from "@btravstack/entity"; --import type { Output } from "@btravstack/entity"; -+import { Entity } from "@btravstack/entity"; - - class Person extends Entity("Person")( - { first: First, last: Last }, -- { computed: { fullName: computed(FullName, (d) => …) } }, -+ { computed: { fullName: Entity.computed(FullName, (d) => …) } }, - ) {} - --type Row = Output; -+type Row = Entity.Output; -``` - -No deprecated top-level aliases are kept. - -`BaseInstance`, `ConstructionKey` and `Sealed` stay top-level exports as well as -namespace members, and are the one exception. A downstream library compiling -with `declaration: true` emits the underlying type name rather than the -namespace path that aliases it, so hiding them behind `Entity` fails that build -with `TS4020`. They were never part of the API you write against. diff --git a/.changeset/entity-union.md b/.changeset/entity-union.md deleted file mode 100644 index 82331fe..0000000 --- a/.changeset/entity-union.md +++ /dev/null @@ -1,25 +0,0 @@ ---- -"@btravstack/entity": minor ---- - -New `Entity.union(discriminant, members)`: a union of entities that is itself -entity-like. Grouped under `Entity` rather than exported loose — `union` alone -is too generic a name to take from a consumer's import scope. - -```ts -const Member = Entity.union("kind", [User, ServiceAccount]); - -Member.make(row).getOrThrow(); // User | ServiceAccount -Member.input; // discriminated union, one branch per member -Member.output; // ditto — JSON Schema in both directions -Member.instance; // parses to the member class, and nests as a field -Member.members; // the tuple, for registries and exhaustiveness -``` - -Previously a union of entities was a plain zod schema and you had to choose -which half to lose: `z.discriminatedUnion` over the `output` schemas gave a -contract but plain data, while `z.union` over the `instance` schemas gave -instances but no output JSON Schema. Neither had `make`. - -It dispatches on the discriminant rather than trying each branch, so a member -whose own validation fails reports its own issues rather than every branch's. diff --git a/.changeset/factory-returns-a-function.md b/.changeset/factory-returns-a-function.md deleted file mode 100644 index 32049c8..0000000 --- a/.changeset/factory-returns-a-function.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -"@btravstack/entity": minor ---- - -**BREAKING**: a factory is a function, not an object with `.create`. - -```ts -// before -const orgs = Organization.factory({ - id: () => ids.next(), - createdAt: () => clock.now(), -}); -orgs.create({ slug, name }); - -// after -const createOrg = Organization.factory({ - id: () => ids.next(), - createdAt: () => clock.now(), -}); -createOrg({ slug, name }); -``` - -Nothing but `create` ever consumed the generators, so the object around it was -ceremony. `factoryAsync` changes the same way. - -Migration: drop `.create`. diff --git a/.changeset/fix-equality-union-freeze.md b/.changeset/fix-equality-union-freeze.md deleted file mode 100644 index 19ff0a9..0000000 --- a/.changeset/fix-equality-union-freeze.md +++ /dev/null @@ -1,44 +0,0 @@ ---- -"@btravstack/entity": patch ---- - -Five bugs, all in field types the package already accepts, all reproduced -before being fixed. - -**`equals` no longer uses `JSON.stringify`.** Serialising was wrong three ways: -a `z.bigint()` field made `equals` **throw** `Do not know how to serialize a -BigInt` — escaping as an uncaught exception, since `equals` returns a bare -`boolean` with no Result channel; a `Set`, `Map` or typed-array field serialised -to `{}`, so entities with entirely different contents compared **equal**; and a -nested record holding `{a,b}` versus `{b,a}` compared **unequal** despite -identical contents. Comparison is now structural: `Set`/`Map` by contents, -`Date` by timestamp, typed arrays bytewise, nested objects key-by-key. -Arrays stay order-sensitive. - -**A union discriminant may be an enum or a multi-value literal.** The lookup -read `.value`, which only a single-valued `z.literal` has — but `z.enum([...])` -is a blessed nominal field and `z.discriminatedUnion` dispatches on it happily. -The member registered under `undefined`, so `input.safeParse` accepted payloads -`make` then rejected, a payload _missing_ the discriminant was misrouted to that -member instead of reporting `Invalid discriminant`, and the error message -rendered the key as empty. A multi-value `z.literal(["a","b"])` was worse: it -**threw at union construction**. - -**The four schema members are four distinct objects.** For an entity with no -`generated` and no `computed`, `input`, `output` and `createInput` were one -object under three names, so anything keying off schema identity collapsed — -registering them under distinct ids in `z.globalRegistry` silently kept only the -last, and `z.toJSONSchema` emitted a single `$def` all three `$ref`'d. - -**A `z.custom` / `z.instanceof` value is no longer frozen, at any depth.** The freeze -dispatched on runtime shape, so a plain-object custom value — the caller's own -reference, handed straight back — was deep-frozen in place, and the caller's -next write threw. Which fields to skip is now decided by the schema, which is -the only thing that knows what was passed through. This is what the -documentation already promised. The walk carries each field's schema down with -its value, so a custom value nested inside an object, array, tuple or record — -and behind `optional`/`default`/`nullable`/`lazy` wrappers — is skipped too. - -Comparison also guards cycles. A cyclic value reaching `equals` exhausted the -stack, which matters more now that a `z.custom` value is not frozen and so is -free to close a loop. diff --git a/.changeset/forbid-entity-subclassing.md b/.changeset/forbid-entity-subclassing.md deleted file mode 100644 index ac4c812..0000000 --- a/.changeset/forbid-entity-subclassing.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -"@btravstack/entity": minor ---- - -**BREAKING**: subclassing an entity class is no longer supported. - -`class Sub extends SomeEntity {}` now fails at construction with a `Defect` — -a bug in domain code, not bad caller input, so it is not an `InvalidEntity`. -One `extends` is the declaration form and is unaffected; so is using the -builder's return directly without `extends`. - -Behaviour belongs in the entity's own class body, which is unchanged: extra -fields stay writable and are still absent from `toJSON()`. - -The prohibition is runtime-only. TypeScript has no `final`, so -`class Sub extends SomeEntity {}` still compiles and reports on first -construction. diff --git a/.changeset/invalid-entity-structured-issues.md b/.changeset/invalid-entity-structured-issues.md deleted file mode 100644 index 9ddaeaf..0000000 --- a/.changeset/invalid-entity-structured-issues.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -"@btravstack/entity": minor ---- - -**BREAKING**: `InvalidEntity.issues` is now `SchemaIssues` (Standard Schema -issues) instead of `readonly string[]`. - -Schema failures keep the `path` of the field that failed, so a caller can key a -field-level error response off it instead of parsing a rendered string. An -`invariants` violation has no `path` — the absence distinguishes a whole-entity -rule from a field complaint. Through `instance`, paths now compose with the -nested entity's position (`["owner", "slug"]`). - -Migration: `e.issues` yields objects, not strings — use `i.message`, and -`i.path` where you want the field. diff --git a/.changeset/lucky-pandas-relate.md b/.changeset/lucky-pandas-relate.md deleted file mode 100644 index 824d030..0000000 --- a/.changeset/lucky-pandas-relate.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -"@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/.changeset/nest-entities.md b/.changeset/nest-entities.md deleted file mode 100644 index da64566..0000000 --- a/.changeset/nest-entities.md +++ /dev/null @@ -1,33 +0,0 @@ ---- -"@btravstack/entity": minor ---- - -An entity can now declare another entity as a field, so an aggregate is itself -an entity rather than a bare `z.object(...)`: - -```ts -class Order extends Entity("Order")({ - id: OrderId, - customer: Customer.instance, - watchers: z.array(Customer.instance), -}) {} -``` - -The nested entities keep their behaviour, computed fields and `_tag`; -invariants can span the outer entity and a nested one; a nested validation -failure reports the full path; and `JSON.stringify` walks the tree to plain -data. Previously the field map rejected `Customer.instance`, so an aggregate -had to be a plain schema and lost `make`, `update`, invariants and immutability. - -`instance` also now carries `_tag` in its type, matching what it has always set -at runtime. - -The rejection message for a genuinely unbranded field is readable now — it -names `DomainFieldMustBeBrandedOrAnEntity` instead of a tuple TypeScript -truncated to `& [...]`. - -A field may no longer take a name the entity installs on every instance — -`_tag`, `equals`, `toJSON` or `update`. Such a field used to shadow the member -silently: a field called `update` left `entity.update` holding a string, with -the method gone and no error anywhere. It is now a compile error naming -`FieldNameIsReservedByEntity`. diff --git a/.changeset/no-release-first-publish.md b/.changeset/no-release-first-publish.md new file mode 100644 index 0000000..02bb977 --- /dev/null +++ b/.changeset/no-release-first-publish.md @@ -0,0 +1,6 @@ +--- +--- + +Release plumbing only, deliberately no bump: the 0.1.0 CHANGELOG is written by +hand because the changesets it replaces described migrations between states that +were never published. diff --git a/.changeset/one-entry-point.md b/.changeset/one-entry-point.md deleted file mode 100644 index 941f50e..0000000 --- a/.changeset/one-entry-point.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -"@btravstack/entity": minor ---- - -**BREAKING**: `decode` is removed and the schema members are renamed. - -`decode` and `make` had become the same function — both parse against the input -schema, re-derive the computed fields, check the invariants and construct. Two -public names for one operation, so there is now one: `make`. - -`encoded`/`decoded` were named after those operations, so they are renamed for -what they are _for_, matching the `createInput`/`updateInput` vocabulary they -sit beside: - -| before | after | -| ------------------ | ---------------- | -| `Entity.decode(x)` | `Entity.make(x)` | -| `Entity.encoded` | `Entity.input` | -| `Entity.decoded` | `Entity.output` | -| `Encoded` | `Input` | -| `Decoded` | `Output` | - -`createInput` and `updateInput` are unchanged. diff --git a/.changeset/soft-hoops-hammer.md b/.changeset/soft-hoops-hammer.md deleted file mode 100644 index de0acd9..0000000 --- a/.changeset/soft-hoops-hammer.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@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/.changeset/tsconfig-comments.md b/.changeset/tsconfig-comments.md deleted file mode 100644 index 72dd66c..0000000 --- a/.changeset/tsconfig-comments.md +++ /dev/null @@ -1,8 +0,0 @@ ---- -"@btravstack/entity": patch ---- - -Documentation only: the build and publishing rationale that lived as comments -in `packages/entity/tsconfig.json` moves into CONTRIBUTING, matching the plain -JSON of `tsconfig.consumer.json`. A stale block describing a consumer-side -TS4020 hazard — fixed by the exported `ConstructionKey` seal — is dropped. diff --git a/.changeset/wild-pandas-shout.md b/.changeset/wild-pandas-shout.md deleted file mode 100644 index 79e7256..0000000 --- a/.changeset/wild-pandas-shout.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"@btravstack/entity": patch ---- - -Document that `equals` compares by the entity a class was built from, so sibling -subclasses of one `Entity(...)` call are equal when their stored data matches diff --git a/packages/entity/CHANGELOG.md b/packages/entity/CHANGELOG.md new file mode 100644 index 0000000..9efaa23 --- /dev/null +++ b/packages/entity/CHANGELOG.md @@ -0,0 +1,59 @@ +# @btravstack/entity + +## 0.1.0 + +Initial release. + +A domain-entity builder on zod v4. One declaration — +`class X extends Entity("X")(fields, options)` — yields a type, four plain +`ZodObject` validators, behaviour, and a class that is itself a zod schema. +Every fallible operation returns an `unthrown` `Result` +instead of throwing. + +### The surface + +- **`Entity(tag)(fields, options?)`** derives `input`, `output`, `createInput` + and `updateInput` from one field map plus `generated`, `immutable`, + `computed` and `invariants`. Fields must be nominal — a branded schema, a + narrow literal union, a boolean, or another entity — enforced at compile time. +- **`Entity.computed(schema, from)`** declares a derived field. It is re-derived + on every construction path, so it cannot drift from its sources, and a stored + row carrying a stale value is corrected on read rather than trusted. +- **`Entity.invariant(ensure, message)`** declares a rule spanning the whole + entity. Every failing rule reports, and its issue carries no `path` — that + absence is what distinguishes a whole-entity rule from a field complaint. +- **`Entity.union(discriminant, members)`** dispatches on a declared field + rather than trying each branch, so a failing member reports its own issues. +- **`SomeEntity.make`, `.factory`, `.factoryAsync`, `.extend`**, and instance + `update`, `toJSON`, `equals`. `make` is the only way in: a database row, a + folded event stream and an untrusted payload all take the same path. + +Everything you write against hangs off `Entity`. `BaseInstance`, +`ConstructionKey` and `Sealed` are also exported, but only so a downstream +library compiling with `declaration: true` can name them. + +### What it guarantees + +- **Sealed construction.** `new SomeEntity(...)` does not compile, so every + instance has passed its invariants. The seal is a type, not a runtime check — + a runtime guard would mean throwing. +- **Deep immutability.** Fields are installed non-writable _and_ their values + deep-frozen, so `org.tags.push(…)` cannot push an entity into a state its own + invariants rejected. A `z.custom`/`z.instanceof` value is left alone at any + depth: it is the caller's own reference, and freezing it in place would break + code that still owns it. +- **Errors are values.** Bad input is `InvalidEntity`, carrying structured + Standard Schema issues. A bug in domain code — a `computed` function throwing, + a rejecting async generator, subclassing an entity — is a separate defect + channel. +- **No I/O.** The package reads no clock and generates no id. Generators are + bound at your composition root, which is also what lets a test supply fixed + ones without stubbing globals. + +### Composition + +An entity class is a zod schema, so entities nest inside each other and inside +ordinary `z.object`/`z.array` without losing their identity, behaviour or +issue paths. Contracts compose the four plain `ZodObject`s; domain code composes +the class. `z.toJSONSchema(SomeEntity, { io: "output" })` throws by design — the +class carries a transform, which is why the four plain objects exist separately.