Uh oh!
There was an error while loading. Please reload this page.
docs: gapless numbering, as two variants rather than a nullable field - #68
Merged
Conversation
A number a regulator counts has to be consecutive, and the shape that suggests itself — one entity with a nullable `number`, stamped in later — types every read site as nullable forever to describe a state that lasts milliseconds. Model it as two variants of one root instead: a `DraftInvoice` with no `number` field at all, an `IssuedInvoice` whose `number` is `generated` and `immutable`, and a `factoryAsync` call between them. Adds the how-to page and a runnable example in `billing-persistence`, whose stricter issued-only invariant makes a stamp genuinely fallible — which is what pins the case gaplessness exists for, an allocated number handed back after construction fails. The guarantee itself stays where it belongs: in the transaction. The page says why a database sequence cannot provide it, and the in-memory counter carries a `ponytail:` comment naming what it stands in for. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Adds documentation and a runnable example showing how to model legally gapless numbering (e.g., invoice numbers) using two entity variants (draft vs issued) instead of a nullable field, and demonstrates allocating/releasing numbers in a transaction-like boundary.
Changes:
- Introduces a new
DraftInvoice→IssuedInvoicetransition example with an in-memory series counter and a release-on-failure pattern. - Adds Vitest coverage for consecutive numbering, transition behavior, and defect vs invalid-entity outcomes.
- Publishes a new how-to doc page and links it from the VitePress sidebar and the example workspace README.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| examples/billing-persistence/src/numbering.ts | New example implementation of gapless numbering using draft/issued variants and an allocator port. |
| examples/billing-persistence/src/numbering.spec.ts | Tests covering consecutive numbering, failed issuance returning numbers, and defect handling. |
| examples/billing-persistence/README.md | Documents the new numbering example and how it relates to persistence/rehydration. |
| docs/how-to/number-without-gaps.md | New how-to explaining transaction-based allocation and modeling numbered state as a variant. |
| docs/.vitepress/config.ts | Adds the new how-to page to the guide sidebar. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Two review comments, both accurate. The README described `issue` as returning a `Result` when it returns an `AsyncResult`, and the how-to's allocation snippet branded a driver's `unknown` with an assertion rather than a parse — which is exactly what the branded-fields guidance tells readers not to do, in the one snippet showing a value crossing a driver boundary. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Uh oh!
There was an error while loading. Please reload this page.
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 freeto 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.
What
A how-to page and a runnable example for giving an entity a number that is legally required to be consecutive.
docs/how-to/number-without-gaps.md+ a sidebar entryexamples/billing-persistence/src/numbering.tsand its specWhy
The shape that suggests itself is one entity with
number: number | null, stamped in later. It types every read site as nullable forever to describe a state that lasts milliseconds, and it hands you nothing to hold when you want "an invoice that definitely has a number".Two variants of one root say the same thing without the null:
DraftInvoicehas nonumberfield at all,IssuedInvoicehas one that isgeneratedandimmutable, and the transition between them is afactoryAsynccall. The page also answers the question a reader arrives with — why there is no "lazy" field flag — since spelling it as a flag would type the field asnumber | undefinedat every read site anyway.The guarantee itself is a property of a transaction, not of a field, and the page says so plainly:
serial,identityandnextvalall hand out numbers outside the transaction, so a rollback burns one permanently.The case the spec pins
The issued variant carries a stricter invariant than the root (
total > 0against>= 0), which is what makes a stamp genuinely fallible. That gives the example the one case gaplessness exists for: an invariant firing after the number was allocated, the number handed back, and the next invoice taking it. Five tests, covering that plus the transition carrying the draft's data and root behaviour over, generated fields winning over a doctored projection, drafts having nonumberkey at all, consecutive output, and an unreachable counter surfacing as a Defect rather than anInvalidEntity.Notes
packages/entityis untouched.issuereturns anAsyncResultrather thanPromise<Result<…>>—unthrown/prefer-async-resultasked, and it made the defect path explicit: a rejected allocation never took a number, so there is nothing to hand back.InMemorySeriesCounter.releaseno-ops unless the number is the newest one; handing back any other would open a hole mid-series instead of closing one at the end. It carries aponytail:comment naming what it stands in for.Gate is green locally:
format --check,lint,typecheck,test,knip,build.🤖 Generated with Claude Code