refactor: replace the consumer fixture with an examples workspace - #36
Merged
btravers merged 6 commits intoAug 7, 2026
Merged
Conversation
added 5 commits
August 7, 2026 23:18
Two entities and the vocabulary they are built from: branded fields, a branded Money value object, generated/immutable/computed, invariants as values, nesting, a union and factories — with specs, and the two declaration-emit passes that will take over from packages/entity/consumer/. Writing it immediately found a real bug: an exported const holding Entity.union(...) failed with TS4023 on $brand, fixed separately. The dunning vocabulary is held at thirty members deliberately; the README and emit-guards.ts say why, since it is the sort of thing a reader would otherwise helpfully trim.
…/entity consumer/ proved a downstream library emitting its own declarations could build against this package, but it documented nothing and looked like nothing else in the stack. examples/billing-domain is that same proof as a readable example, and it resolves @btravstack/entity through the real `exports` rather than a paths mapping — closer to what a consumer does. Verified by breaking the library on purpose: with EntityStatic un-exported, billing-domain fails with TS4020 and TS7056, exactly as the old fixture did. Restored afterwards. Corrects a claim made when the second pass was added. It is NOT that the 7.x native port ignores TS7056 — both versions enforce it, and 5.9.3's threshold is simply lower. Measured on the old fixture's narrower entity: 5.9.3 reported TS7056, 7.0.2 accepted the same shape and reported only TS4020; widen the entity and both report it. The second pass is still worth its cost, for the sharper reason that a band of realistic domain widths fails for consumers and passes here. knip needed a config for the first time: introducing one replaces the defaults, so the *.test-d.ts exclusion and four config-file-only dependencies had to be spelled out. Each is verified referenced, and the reason is inline.
An oRPC contract and JSON Schema built from Organization.createInput / .updateInput / .output, with nothing restating the shape of an Organization. The spec pins the design rule both ways: the four plain ZodObjects convert in both directions, and handing the class to a converter throws, because it parses to an instance. The JSON Schema exports carry an explicit JsonSchema annotation. Without it TypeScript infers a type it cannot name from outside the package and consumers emitting declarations fail with TS2883 — the same class of problem as #31 and #32, met from the other side.
toJSON() out, make() back, over an in-memory Map. The specs pin what is easy to lose: _tag never reaching a row, behaviour surviving rehydration (not just data), and update leaving the entity in hand untouched. byId returns InvalidEntity | OrganizationNotFound rather than folding the two together — a missing row is a 404, a corrupt row is worth paging someone about, and collapsing them discards the only fact that separates them. The library defines no NotFound on purpose: whether an absent row is exceptional belongs to the repository, so the example models it. No try/catch anywhere in the file.
An overview plus a page per example, in the nav and sharing the guide sidebar so a reader landing on one still reaches every other page. The overview says what amqp-contract's does and what is true here: unlike every fenced block in the rest of the guide, this code compiles and its specs run in CI. Those snippets are checked by review and nothing else, so they can drift from the library without any build noticing.
There was a problem hiding this comment.
Pull request overview
Refactors the declaration-emit “consumer” fixture into a set of runnable examples/ workspace packages, so the same downstream-emit guarantees are exercised by code that also documents how to use @btravstack/entity.
Changes:
- Replace
packages/entity/consumer/+ its consumer tsconfigs withexamples/billing-domain, which runs declaration emit twice (TS 7.0.2 and 5.9.3). - Add two additional example workspaces (
billing-api,billing-persistence) with Vitest coverage, plus documentation pages and sidebar integration. - Extend the monorepo workspace + catalog deps (oRPC packages) and update knip configuration to account for the new fixture layout.
Reviewed changes
Copilot reviewed 34 out of 35 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| pnpm-workspace.yaml | Adds examples/* as a workspace and catalogs oRPC deps used by the examples. |
| pnpm-lock.yaml | Captures the new example workspaces and their dependency graph. |
| packages/entity/tsconfig.consumer5.json | Removes the old consumer TS 5.x emit config (fixture moved to examples/). |
| packages/entity/tsconfig.consumer.json | Removes the old consumer emit config (fixture moved to examples/). |
| packages/entity/package.json | Drops consumer emit passes from this workspace’s typecheck and removes typescript-consumer devDep. |
| packages/entity/consumer/index.ts | Deletes the old downstream “library emits declarations” fixture. |
| knip.jsonc | Introduces updated knip config and pins examples/billing-domain/src/emit-guards.ts as an entry. |
| knip.json | Removes the prior knip config file (replaced by knip.jsonc). |
| examples/README.md | Adds an overview of the runnable example packages and how they participate in CI/typecheck. |
| examples/billing-domain/package.json | New example package that also serves as the declaration-emit fixture (dual-TS emit). |
| examples/billing-domain/README.md | Explains what billing-domain demonstrates and what’s intentionally “odd” in the fixture. |
| examples/billing-domain/tsconfig.json | Base TS config for the example package. |
| examples/billing-domain/tsconfig.emit.json | Declaration-only emit config used to verify downstream .d.ts portability. |
| examples/billing-domain/vitest.config.ts | Vitest configuration for the example package. |
| examples/billing-domain/src/index.ts | Implements a small billing domain using entities, unions, factories, and branded fields. |
| examples/billing-domain/src/index.spec.ts | Runtime-focused tests for the example domain behavior and projections. |
| examples/billing-domain/src/emit-guards.ts | Compile-time “emit guards” (ts-expect-error assertions) replacing the old consumer fixture. |
| examples/billing-api/package.json | New example package showing contract composition + JSON Schema conversion. |
| examples/billing-api/README.md | Documents the contract/JSON Schema example and the rationale for explicit type naming. |
| examples/billing-api/tsconfig.json | Base TS config for the API example package. |
| examples/billing-api/vitest.config.ts | Vitest configuration for the API example package. |
| examples/billing-api/src/index.ts | Demonstrates composing entity ZodObjects into an oRPC contract and JSON Schemas. |
| examples/billing-api/src/index.spec.ts | Tests schema conversion behavior and contract surface. |
| examples/billing-persistence/package.json | New example package showing persistence/rehydration and error modelling. |
| examples/billing-persistence/README.md | Documents the persistence example and the “two errors, not one” distinction. |
| examples/billing-persistence/tsconfig.json | Base TS config for the persistence example package. |
| examples/billing-persistence/vitest.config.ts | Vitest configuration for the persistence example package. |
| examples/billing-persistence/src/index.ts | Implements an in-memory repository demonstrating toJSON() + make() round-trip. |
| examples/billing-persistence/src/index.spec.ts | Tests storage round trip, _tag absence, corrupt row handling, and immutability on update. |
| docs/examples/index.md | Adds an examples landing page in the docs site. |
| docs/examples/billing-domain.md | Adds the docs walkthrough for the billing domain example. |
| docs/examples/billing-api.md | Adds the docs walkthrough for the HTTP contract example. |
| docs/examples/billing-persistence.md | Adds the docs walkthrough for the persistence example. |
| docs/.vitepress/config.ts | Adds “Examples” to nav and injects an examples section into the shared sidebar. |
| CLAUDE.md | Updates repo guidance to reflect the new examples workspaces and corrected TS7056 behavior notes. |
Files not reviewed (1)
- pnpm-lock.yaml: Generated file
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
`Entity.union("_tag", …)` could never match anything. `_tag` is
non-enumerable, so it is absent from toJSON() and from every row, which
means discriminantValues() registered no keys and make() rejected every
payload with:
[{"path":["_tag"],"message":"Invalid discriminant undefined; expected
one of "}]
— an empty set. union.ts documents this exact prohibition; the example
did the opposite, and the spec never called make() through the union, so
nothing caught it. Raised in review on #36.
Replaced with the modelling the union is for: CreditNote joins Invoice as
a sibling document, both declaring `kind` as a generated literal, and
BillingDocument dispatches on that. Four specs now cover it — both
members round-tripping to the right class, the discriminant surviving
toJSON, and an unknown value reporting a populated "expected one of".
The trap is written down in the package README and the docs page, since
reaching for _tag is the obvious wrong move and it fails silently.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Replaces
packages/entity/consumer/with a rootexamples/workspace — the conventionamqp-contract,temporal-contractanddemesnealready use — so the same guarantee is carried by code that also documents the library.What
consumer/was, and why this is not a downgradeIt proved one thing: a downstream library compiling with
declaration: truecan build against this package. It did that well, and documented nothing.examples/billing-domaintakes the role over and is stricter in one respect — it depends on@btravstack/entityasworkspace:*and resolvesdist/index.d.mtsthrough the package's realexports, where the old fixture faked it with apathsmapping.The migration is only safe if the new fixture still fails when it should, so that is verified rather than assumed:
billing-domainreportsEntityStaticTS4020+TS7056EntityUnionTS4023It found a bug before it was finished
Writing the domain immediately surfaced
TS4023on an exportedconstholdingEntity.union(...)— the second error reported in #32, which theEntityStaticfix alone did not cover.EntityUnionandUnionMemberare now exported too. That commit is cherry-picked onto #35 so it closes #32 properly.This is the argument for the whole change in one incident: the package's own 145-test suite could not see it, because vitest never typechecks and the bug lived only in emitted declarations.
Correcting something in #35's description
#35 says the 7.x native port "does not enforce"
TS7056. That is wrong, and I've corrected it inCLAUDE.md,pnpm-workspace.yamlandexamples/README.md. Both versions enforce it — 5.9.3's threshold is simply lower:TS4020onlyTS4020+TS7056Invoice)TS4020+TS7056TS4020+TS7056The second pass still earns its cost, for a sharper reason: there is a band of realistic domain widths that fails for consumers on 5.x and passes on the version this repo builds with. That band is where #31 and #32 lived.
The three packages
billing-domainMoneyobject,generated/immutable/computed, invariants, nesting, a union, factories. Carries the emit passes.billing-apiZodObjects → oRPC contract + JSON Schema both ways, and the class refusing to convert.billing-persistencetoJSON()out,make()back,InvalidEntity | OrganizationNotFound, notry/catch.No Docker, no broker, no database, no server —
pnpm testruns all of it. amqp's examples need RabbitMQ and demesne's needs Prisma; entity does no I/O, so there was no reason to inherit that.Two things kept deliberately odd, both flagged inline because they invite tidying:
TS7056is a threshold on serialised characters. My first fixture attempt was narrower, compiled fine unfixed, and guarded nothing.emit-guards.tsis not example code and says so — a forged construction key is not a pattern to copy. Isolating it is what keepsindex.tsreadable as documentation.Notes for review
consumer/removed the last cross-file use of threetypes.tsexports; adding a config file then replaced knip's defaults, surfacing the*.test-d.tsfiles and four config-only dependencies. Every one verified genuinely used before being ignored, reasons inline inknip.jsonc.billing-apiannotates its JSON Schema exports withJsonSchema. Without it,TS2883— the same unnameable-type problem as Declaration emit fails with TS7056 on entities with a realistically-wide enum field #31/Branded object fields break declaration emit with TS4020 (OnlyNominal accepts them) #32, from the consumer's side.Verification
Full gate green:
format --check,lint,typecheck(6 tasks),test(5 tasks, 163 specs),knip,build. Docs site builds with dead-link checking on.🤖 Generated with Claude Code