From 6f0ac56fa4c92ed5babbbf215aa98a8ed12d8c85 Mon Sep 17 00:00:00 2001 From: Benoit Travers Date: Thu, 13 Aug 2026 16:05:21 +0200 Subject: [PATCH 1/2] docs: add a gapless numbering how-to and example MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- docs/.vitepress/config.ts | 1 + docs/how-to/number-without-gaps.md | 205 ++++++++++++++++++ examples/billing-persistence/README.md | 22 ++ .../billing-persistence/src/numbering.spec.ts | 90 ++++++++ examples/billing-persistence/src/numbering.ts | 149 +++++++++++++ 5 files changed, 467 insertions(+) create mode 100644 docs/how-to/number-without-gaps.md create mode 100644 examples/billing-persistence/src/numbering.spec.ts create mode 100644 examples/billing-persistence/src/numbering.ts diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 7556e2c..d415b42 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -22,6 +22,7 @@ const GUIDE_SIDEBAR = [ { text: "Persist and rehydrate", link: "/how-to/persist-and-rehydrate" }, { text: "Evolve an entity", link: "/how-to/evolve-an-entity" }, { text: "Model an aggregate", link: "/how-to/model-an-aggregate" }, + { text: "Number without gaps", link: "/how-to/number-without-gaps" }, { text: "Test domain logic", link: "/how-to/test-domain-logic" }, ], }, diff --git a/docs/how-to/number-without-gaps.md b/docs/how-to/number-without-gaps.md new file mode 100644 index 0000000..3082130 --- /dev/null +++ b/docs/how-to/number-without-gaps.md @@ -0,0 +1,205 @@ +--- +title: Number without gaps +description: Give an entity a consecutive, gapless number by modelling the numbered state as its own variant, and by allocating the number where a rollback can take it back. +--- + +# Number without gaps + +**Problem:** an entity needs a number that is legally required to be +consecutive — an invoice series, a receipt book — and a hole in the sequence is +a finding at audit rather than a cosmetic defect. + +> Snippets below assume these imports: +> +> ```ts +> import { z } from "zod"; +> import { Entity } from "@btravstack/entity"; +> ``` +> +> Domain vocabulary — entities, brands, factories — is whatever your own +> domain declares. + +## Decide whether you need it at all + +Gapless is not the same as unique. If the number is an identifier, take a +database sequence and accept the holes: everything below costs you write +concurrency, and it buys nothing a `uuid` does not already give you. Reach for +this only when something outside your system counts the sequence. + +Note the other half of that requirement while you are here. A gapless series +cannot survive a `DELETE`, so the rows are append-only forever and a mistake is +corrected by issuing a reversal, never by removing a row. + +## Allocate where a rollback can take the number back + +The guarantee is a property of a transaction, not of a field. No declaration in +this library can provide it, and no database sequence can either: +`serial`, `identity` and `nextval` all hand out numbers outside the +transaction, so a rollback burns one permanently. That is the intended +behaviour of a sequence, not a bug in it. + +What works is a counter row bumped inside the same transaction as the insert: + +```sql +create table invoice_counter (series text primary key, last int not null default 0); +alter table invoice add constraint uq_invoice_number unique (series, number); +``` + +```ts +const allocateNumber = async (tx: Tx, series: string) => + ( + await tx.query( + `update invoice_counter set last = last + 1 where series = $1 returning last`, + [series], + ) + ).rows[0].last as InvoiceNumber; +``` + +The row lock that `update` takes serialises concurrent writers, and the +rollback un-bumps the counter along with the insert it was for. The unique +constraint is the belt to those braces. + +## Model the numbered state as its own entity + +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 gives you nothing to hold when you want +"an invoice that definitely has a number". + +Declare two variants of one root instead. The shared data and the shared +behaviour are written once, on the root: + +```ts +abstract class InvoiceBase extends Entity.abstract("Invoice")( + { + series: Entity.field(Series, { immutable: true }), + issuedTo: Entity.field(Slug, { immutable: true }), + total: Money, + }, + { + invariants: [ + Entity.invariant( + (d) => d.total.amount >= 0, + "total must not be negative", + ), + ], + }, +) { + get reference(): string { + return `${this.series}/${this.issuedTo}`; + } +} + +export class DraftInvoice extends InvoiceBase.extend("DraftInvoice")({ + state: Entity.field(z.literal("DRAFT"), { generated: true, immutable: true }), +}) {} + +export class IssuedInvoice extends InvoiceBase.extend("IssuedInvoice")( + { + state: Entity.field(z.literal("ISSUED"), { + generated: true, + immutable: true, + }), + number: Entity.field(InvoiceNumber, { generated: true, immutable: true }), + issuedAt: Entity.field(Instant, { generated: true, immutable: true }), + }, + { + invariants: [ + Entity.invariant( + (d) => d.total.amount > 0, + "an issued invoice must bill something", + ), + ], + }, +) {} +``` + +A draft has no `number` field, so forgetting to check for one is a compile +error rather than a `null` in a report. `IssuedInvoice["number"]` is +`InvoiceNumber`, never `InvoiceNumber | null`. Variants accumulate onto a root, +so the stricter invariant on the issued variant runs in addition to the root's, +not instead of it — see [Declaring an entity](/reference/declaration). + +A `status` field is still the right tool when both states carry the same data. +What makes this a second entity is that one of the states holds a field the +other cannot. + +## Write the transition as a factory call + +Generated fields spread last, so the transition is the draft's own projection +handed to the issued variant's factory: + +```ts +const issue = + (numbers: NumberAllocator, at: Instant) => async (draft: DraftInvoice) => { + let allocated: InvoiceNumber | undefined; + + const issued = await IssuedInvoice.factoryAsync({ + state: () => Promise.resolve("ISSUED"), + issuedAt: () => Promise.resolve(at), + number: async () => { + allocated = await numbers.next(draft.series); + return allocated; + }, + })(draft.toJSON()); + + if (!issued.isOk() && allocated !== undefined) + numbers.release(draft.series, allocated); + + return issued; + }; +``` + +Three things are load bearing there. + +The draft's own `state` cannot survive into an issued invoice, because the +generated fields are spread over the caller's input rather than under it. A +caller cannot forge an issued invoice by handing over a doctored projection. + +The allocation sits **inside** the generator rather than in front of the call. +A generator that rejects becomes a Defect, so an unreachable counter is +reported as infrastructure failing rather than as bad domain input — and it +never escapes this function as a rejection. + +The number is handed back when construction fails. An invariant that fires +after the number was allocated is precisely the case gaplessness has to +survive; with a real transaction the rollback does this for you, which is why +the allocator's `release` disappears when you swap the in-memory counter for +the counter row. + +## Number after the fact when throughput demands it + +Everything above allocates on the request path, which means the counter row is +a lock every writer queues behind. If that becomes the bottleneck, keep the +same two entities and move the transition to a worker: requests write drafts, +and a stamping process turns them into issued invoices later. + +The counter does not go away, it relocates. Each stamp is still one transaction +that bumps the counter and writes the number, so a failed stamp still rolls its +number back. What you gain is that the user's insert no longer waits on the +lock. + +It costs three constraints, and all three are easy to discover late: + +- **One consumer per series.** Two workers stamping the same series race for + the same number. Partition by series, or run a single consumer. +- **One transaction per stamp**, not one per batch. A batch that fails halfway + either rolls back numbers it should have kept or keeps numbers it should have + released. +- **Numbering order may diverge from creation order** when a stamp is retried. + That satisfies the legal requirement, which is about holes rather than order, + but it will be reported as a bug by someone eventually. + +## Why there is no lazy field flag + +The declaration this page works around — a field that is generated, immutable, +and filled in later — is not a field. It is a state transition, and spelling it +as a flag would type the field as `number | undefined` at every read site +anyway. That is the same cost as the nullable column, moved somewhere harder to +notice. + +Two variants pay the cost once, at the boundary where the state actually +changes, and let every other line of the model say what it means. Store them +however you like: the two projections differ by exactly the fields the states +differ by, which is what +[Persist and rehydrate](/how-to/persist-and-rehydrate) is about. diff --git a/examples/billing-persistence/README.md b/examples/billing-persistence/README.md index 012f861..3a46bdd 100644 --- a/examples/billing-persistence/README.md +++ b/examples/billing-persistence/README.md @@ -47,3 +47,25 @@ persistence. See also the how-to: [Persist and rehydrate](https://btravstack.github.io/entity/how-to/persist-and-rehydrate). + +## Gapless numbering + +`numbering.ts` is the other half of writing an entity down: giving it a number +a regulator will count. A `DraftInvoice` has no `number` field at all, an +`IssuedInvoice` has one that is `generated` and `immutable`, and both are +variants of one root — so the numberless state is not a nullable column typed +into every read site forever. + +```ts +issue(numbers, at)(draft); // DraftInvoice → Result +``` + +The counter here is a `Map`, standing in for `update invoice_counter set last = +last + 1 … returning last` inside the transaction that writes the row. That +transaction is where gaplessness actually comes from: a database _sequence_ +hands out numbers outside it, so a rollback burns one permanently. The spec +pins the case that proves it — an invariant rejecting an issue **after** the +number was allocated, and the next invoice still taking that number. + +See also the how-to: [Number without +gaps](https://btravstack.github.io/entity/how-to/number-without-gaps). diff --git a/examples/billing-persistence/src/numbering.spec.ts b/examples/billing-persistence/src/numbering.spec.ts new file mode 100644 index 0000000..e8a825e --- /dev/null +++ b/examples/billing-persistence/src/numbering.spec.ts @@ -0,0 +1,90 @@ +import { Money, Slug } from "@btravstack/entity-example-billing-domain"; +import { P } from "unthrown"; +import { expect, test } from "vitest"; + +import { + InMemorySeriesCounter, + Series, + createDraftInvoice, + issue, + type DraftInvoice, + type NumberAllocator, +} from "./numbering.js"; + +const AT = "2026-08-13T09:00:00Z" as never; +const FY = Series.parse("FY26"); + +const draft = (amount: number): DraftInvoice => + createDraftInvoice({ + series: FY, + issuedTo: Slug.parse("acme"), + total: Money.parse({ amount, currency: "EUR" }), + }).getOrThrow(); + +test("issuing carries the draft's data over and adds the number", async () => { + const numbers = new InMemorySeriesCounter(); + const issued = (await issue(numbers, AT)(draft(12_00))).getOrThrow(); + + expect(issued.number).toBe(1); + expect(issued.total.amount).toBe(12_00); + // Behaviour declared on the root survives the transition, like the data does. + expect(issued.reference).toBe("FY26/acme"); +}); + +test("the draft's own state loses to the generated one", async () => { + const numbers = new InMemorySeriesCounter(); + const before = draft(12_00); + const issued = (await issue(numbers, AT)(before)).getOrThrow(); + + expect(before.state).toBe("DRAFT"); + expect(issued.state).toBe("ISSUED"); + // A draft has no `number` at all — not a null one. That is the whole point of + // two variants rather than one nullable field. + expect("number" in before.toJSON()).toBe(false); +}); + +test("numbers within a series are consecutive", async () => { + const numbers = new InMemorySeriesCounter(); + const issuing = issue(numbers, AT); + + const first = (await issuing(draft(1_00))).getOrThrow(); + const second = (await issuing(draft(2_00))).getOrThrow(); + const third = (await issuing(draft(3_00))).getOrThrow(); + + expect([first.number, second.number, third.number]).toEqual([1, 2, 3]); +}); + +test("a rejected issue hands the number back, so the series keeps no gap", async () => { + const numbers = new InMemorySeriesCounter(); + const issuing = issue(numbers, AT); + + const first = (await issuing(draft(1_00))).getOrThrow(); + // A draft may total zero; an issued invoice may not. The invariant fires + // after the number was allocated, which is exactly the case gaplessness has + // to survive. + const rejected = await issuing(draft(0)); + const next = (await issuing(draft(2_00))).getOrThrow(); + + expect(rejected.isErr()).toBe(true); + expect([first.number, next.number]).toEqual([1, 2]); +}); + +test("an unreachable number source is a defect, not an InvalidEntity", async () => { + const unreachable: NumberAllocator = { + next: () => Promise.reject(new Error("counter unreachable")), + release: () => undefined, + }; + + const outcome = await issue( + unreachable, + AT, + )(draft(12_00)).then((result) => + result.match({ + ok: () => "issued", + errCases: (m) => m.with(P.tag("InvalidEntity"), () => "invalid"), + defect: () => "defect", + }), + ); + + expect(outcome).toBe("defect"); +}); diff --git a/examples/billing-persistence/src/numbering.ts b/examples/billing-persistence/src/numbering.ts new file mode 100644 index 0000000..fb0b5c7 --- /dev/null +++ b/examples/billing-persistence/src/numbering.ts @@ -0,0 +1,149 @@ +/** + * Gapless numbering, modelled as two states rather than one nullable field. + * + * Some numbers are legally required to be consecutive — an invoice series with + * a hole in it is a finding at audit. That guarantee is a property of a + * *transaction*, never of a field: a database sequence hands out numbers + * outside the transaction, so a rollback burns one permanently. What the entity + * can do is refuse to exist without its number. + * + * The tempting shape is one `Invoice` with `number: number | null`, stamped + * later. It types every read site as nullable forever to describe a state that + * lasts milliseconds. Two variants of one root say the same thing without the + * null: a `DraftInvoice` has no `number` **field**, an `IssuedInvoice` has a + * `number` that is `generated` and `immutable`, and the transition between them + * is a `factoryAsync` call. Shared data and shared behaviour live on the root, + * declared once. + * + * Contrast `Invoice` in the domain package, which carries a `status` field + * covering DRAFT and ISSUED. A status field is the right tool when both states + * hold the same data. Here they do not — one of them has a number — and that is + * what makes it a second entity. + * + * See also the how-to: . + */ +import { Entity } from "@btravstack/entity"; +import { Instant, Money, Slug } from "@btravstack/entity-example-billing-domain"; +import { P, type AsyncResult } from "unthrown"; +import { z } from "zod"; + +export const InvoiceNumber = z.number().int().positive().brand("InvoiceNumber"); +export const Series = z.string().min(1).max(8).brand("Series"); + +type SeriesValue = z.infer; +type InvoiceNumberValue = z.infer; + +/** + * What both states are. Tagless, so it carries the shared fields and the shared + * behaviour into each variant without being an entity itself. + */ +abstract class InvoiceBase extends Entity.abstract("Invoice")( + { + series: Entity.field(Series, { immutable: true }), + issuedTo: Entity.field(Slug, { immutable: true }), + total: Money, + }, + { + invariants: [Entity.invariant((d) => d.total.amount >= 0, "total must not be negative")], + }, +) { + get reference(): string { + return `${this.series}/${this.issuedTo}`; + } +} + +/** Numberless by construction, so no read site has to check for one. */ +export class DraftInvoice extends InvoiceBase.extend("DraftInvoice")({ + state: Entity.field(z.literal("DRAFT"), { generated: true, immutable: true }), +}) {} + +/** + * Numbered by construction. The invariant is stricter than the root's on + * purpose: a draft may total zero while it is being assembled, an issued + * invoice may not — which is what makes a stamp fallible, and therefore what + * makes handing the number back a case that has to work. + */ +export class IssuedInvoice extends InvoiceBase.extend("IssuedInvoice")( + { + state: Entity.field(z.literal("ISSUED"), { generated: true, immutable: true }), + number: Entity.field(InvoiceNumber, { generated: true, immutable: true }), + issuedAt: Entity.field(Instant, { generated: true, immutable: true }), + }, + { + invariants: [ + Entity.invariant((d) => d.total.amount > 0, "an issued invoice must bill something"), + ], + }, +) {} + +export const createDraftInvoice = DraftInvoice.factory({ state: () => "DRAFT" }); + +/** + * The port. `next` is what the database's counter row does; `release` is what + * its transaction does for free. + */ +export type NumberAllocator = { + next(series: SeriesValue): Promise; + release(series: SeriesValue, number: InvoiceNumberValue): void; +}; + +/** + * Stands in for `update invoice_counter set last = last + 1 where series = $1 + * returning last`. The row lock that statement takes is what serialises + * concurrent writers; the surrounding transaction is what returns the number + * when the insert fails. + * + * ponytail: a Map plus an explicit `release`, sound only because this runs one + * draft at a time. Swap it for the counter row when it stops being a demo — + * nothing above this line changes. + */ +export class InMemorySeriesCounter implements NumberAllocator { + readonly #last = new Map(); + + next(series: SeriesValue): Promise { + const next = (this.#last.get(series) ?? 0) + 1; + this.#last.set(series, next); + return Promise.resolve(InvoiceNumber.parse(next)); + } + + release(series: SeriesValue, number: InvoiceNumberValue): void { + // Only the newest number can go back. Releasing any other would reopen a + // hole in the middle of the series instead of closing one at the end. + if (this.#last.get(series) === number) this.#last.set(series, number - 1); + } +} + +/** + * The transition. `toJSON()` is the draft's data and the generated fields + * spread last, so the draft's own `state` cannot survive into an issued + * invoice — a caller cannot forge one by handing over a doctored projection. + * + * The allocation sits inside the generator rather than in front of the call: + * that is what turns an unreachable counter into a Defect instead of a + * rejection escaping this function. + * + * One draft at a time, in order, or two workers race for the same number. In + * production that is one consumer per series, not a comment. + */ +export const issue = + (numbers: NumberAllocator, at: z.infer) => + (draft: DraftInvoice): AsyncResult => { + let allocated: InvoiceNumberValue | undefined; + + return IssuedInvoice.factoryAsync({ + state: () => Promise.resolve("ISSUED"), + issuedAt: () => Promise.resolve(at), + number: async () => { + allocated = await numbers.next(draft.series); + return allocated; + }, + // An invariant that fires after the number was taken is the case the + // whole design exists for: give it back, or the series has a gap in it + // forever. A rejected allocation never took one, so `allocated` is + // undefined on that path and there is nothing to return. + })(draft.toJSON()).tapErrCases((m) => + m.with(P.tag("InvalidEntity"), () => { + if (allocated !== undefined) numbers.release(draft.series, allocated); + }), + ); + }; From 9255f820309f1a8ed98017a7be9fda1ca913f92f Mon Sep 17 00:00:00 2001 From: Benoit Travers Date: Thu, 13 Aug 2026 16:12:04 +0200 Subject: [PATCH 2/2] docs: mint the allocated number by parsing, and name the async return MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- docs/how-to/number-without-gaps.md | 16 +++++++++------- examples/billing-persistence/README.md | 2 +- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/docs/how-to/number-without-gaps.md b/docs/how-to/number-without-gaps.md index 3082130..325c5b8 100644 --- a/docs/how-to/number-without-gaps.md +++ b/docs/how-to/number-without-gaps.md @@ -46,13 +46,15 @@ alter table invoice add constraint uq_invoice_number unique (series, number); ``` ```ts -const allocateNumber = async (tx: Tx, series: string) => - ( - await tx.query( - `update invoice_counter set last = last + 1 where series = $1 returning last`, - [series], - ) - ).rows[0].last as InvoiceNumber; +const allocateNumber = async (tx: Tx, series: string) => { + const { rows } = await tx.query( + `update invoice_counter set last = last + 1 where series = $1 returning last`, + [series], + ); + // A driver hands back `unknown`. Mint the brand by parsing it, never by + // asserting it — the row is data crossing a boundary like any other. + return InvoiceNumber.parse(rows[0].last); +}; ``` The row lock that `update` takes serialises concurrent writers, and the diff --git a/examples/billing-persistence/README.md b/examples/billing-persistence/README.md index 3a46bdd..2a0a2bc 100644 --- a/examples/billing-persistence/README.md +++ b/examples/billing-persistence/README.md @@ -57,7 +57,7 @@ variants of one root — so the numberless state is not a nullable column typed into every read site forever. ```ts -issue(numbers, at)(draft); // DraftInvoice → Result +await issue(numbers, at)(draft); // DraftInvoice → AsyncResult ``` The counter here is a `Map`, standing in for `update invoice_counter set last =