Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions .changeset/autonumber-not-gapless-contract.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
---
"@objectstack/driver-sql": patch
---

docs(driver-sql): state the autonumber contract — unique and monotonic per scope, NOT gapless (#8283)

An autonumber's gap behaviour was undocumented, so the only way to learn it was
to hit it. A write rejected for a reason unrelated to the autonumber still
consumes the number it reserved: `TK-0001`, a failed insert, then `TK-0003`
(measured on both SQLite and Postgres). Nothing was wrong with that — it is
ordinary sequence semantics — but nothing said so, which is exactly the
ambiguity that gets a gapless series promised to a customer.

**The contract, now stated in the driver's TSDoc beside the existing "an
autonumber is an immutable business identifier" sentence.** Per counter — the
`(table, tenant, field, scope)` key `getNextSequenceValue` issues from — an
autonumber is **unique** (no two rows get the same value), **monotonic** (each
value issued exceeds the last), and **NOT gapless** (the series may skip values,
permanently). A rejected write — a unique violation on another field, a
validation rule, a throwing `beforeInsert` — burns its reserved number, and the
next write gets the one after it.

The reservation is committed by `getNextSequenceValue` in **its own
transaction** (`runner.transaction` over `parentTrx ?? this.knex`), deliberately
independent of the caller's insert, which is why a later failure cannot take it
back; inside a caller transaction it nests and rolls back with the refused
insert, so that path burns nothing. The comment says this is by design and asks
the next reader not to "fix" it.

**Behaviour is unchanged — this release is a comment and this changeset.** The
maintainer ruled on 2026-08-13 (#8283) that documenting the contract is the
close: reserving the number only after the row is known to be insertable was
rejected (it narrows gaps without closing them — a post-reservation crash still
burns one — and would have to compose with the savepoint structure at both
speculative sites), and an opt-in gapless mode is recorded as a restart
condition for the first compliance-grade gapless requirement, not built.

Consumers who need the same statement in author-facing documentation: the
`content/docs/data-modeling/**` half is tracked separately as #8479 and is not
in this change.
45 changes: 45 additions & 0 deletions packages/drivers/driver-sql/src/sql-driver.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -4831,6 +4831,51 @@ export class SqlDriver implements IDataDriver {
* logical fields via `external.columnMap`), matching the
* `applyWriteColumnMap`-processed row the merge column list is derived from;
* `created_at` stays the literal post-map key it has always been filtered as.
*
* ## What that identifier guarantees: unique and monotonic, NOT gapless (#8283)
*
* Immutable-once-assigned is one half of the contract; this is the other. It
* is a **decided** property, not an undocumented default — ruled by the
* maintainer on 2026-08-13 (#8283), which weighed and rejected both reserving
* the number only after the row is known to be insertable, and an opt-in
* gapless mode.
*
* Per counter — the `(table, tenant, field, scope)` key
* {@link getNextSequenceValue} issues from — an autonumber is:
*
* - **unique**: no two rows are issued the same value;
* - **monotonic**: each value issued is greater than the last;
* - **NOT gapless**: the series may skip values, permanently.
*
* **A rejected write consumes the number it reserved.** The value is reserved
* before the INSERT is attempted, so any rejection after that point burns it:
* a unique violation on another field, a validation rule, a `beforeInsert`
* hook that throws. That number is issued to no row and will never be issued
* again — the next write gets the one after it. `TK-0001`, a failed insert,
* then `TK-0003` is this contract behaving correctly (measured on both
* SQLite and Postgres in #8283).
*
* Why it cannot be taken back: {@link getNextSequenceValue} commits the
* reservation in **its own transaction** (`runner.transaction` over
* `parentTrx ?? this.knex`), deliberately independent of the caller's insert
* — which is what makes a forward-only counter meaningful and what the batch
* paths' re-seed logic stands on. With no caller transaction the reservation
* is already committed when the insert fails, and nothing reclaims it.
* (Inside a caller transaction it nests, rolling back with the refused
* insert, so that path burns nothing — measured, and relied on at the
* `upsert` retry site below.)
*
* This is ordinary sequence semantics — every standard database sequence
* behaves this way — and **not a defect to repair**. Do not add reclamation,
* reservation-reordering, or a "return the number on a clean rejection" path:
* that shape was rejected in #8283 because it only narrows gaps rather than
* closing them (a crash after reservation still burns a number) while having
* to compose with the savepoint structure at both speculative sites.
*
* For callers: an autonumber is sound as a business identifier and must not
* be presented as a **gapless** series (an audit-grade invoice or contract
* number). A customer with a compliance-grade gapless requirement is the
* recorded restart condition for an opt-in gapless mode — it is not built.
*/
protected insertOnlyUpsertColumns(object: string): Set<string> {
// Same config resolution as `fillAutoNumberFields`: object name first,
Expand Down
Loading