From 0d098ec41502a3615306baefc36f4aaedfb5748b Mon Sep 17 00:00:00 2001 From: os-zhuang Date: Thu, 13 Aug 2026 15:27:19 +0000 Subject: [PATCH] =?UTF-8?q?docs(driver-sql):=20state=20the=20autonumber=20?= =?UTF-8?q?contract=20=E2=80=94=20unique=20and=20monotonic,=20not=20gaples?= =?UTF-8?q?s=20(#8283)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Document, beside the existing "an autonumber is an immutable business identifier" sentence, what that identifier does and does not guarantee: unique and monotonic per counter, NOT gapless. A rejected write consumes the number it reserved, because getNextSequenceValue commits the reservation in its own transaction, independent of the caller's insert. Comment and changeset only — no behaviour change. Per the maintainer ruling of 2026-08-13 on #8283 (option 1: document the contract; reservation-reordering rejected, opt-in gapless mode recorded as a restart condition, not built). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01VoxQqG5FiUHZKCST7KDoZC --- .changeset/autonumber-not-gapless-contract.md | 40 +++++++++++++++++ packages/drivers/driver-sql/src/sql-driver.ts | 45 +++++++++++++++++++ 2 files changed, 85 insertions(+) create mode 100644 .changeset/autonumber-not-gapless-contract.md diff --git a/.changeset/autonumber-not-gapless-contract.md b/.changeset/autonumber-not-gapless-contract.md new file mode 100644 index 0000000000..5496fc307c --- /dev/null +++ b/.changeset/autonumber-not-gapless-contract.md @@ -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. diff --git a/packages/drivers/driver-sql/src/sql-driver.ts b/packages/drivers/driver-sql/src/sql-driver.ts index a99dc86ac1..83549b70d5 100644 --- a/packages/drivers/driver-sql/src/sql-driver.ts +++ b/packages/drivers/driver-sql/src/sql-driver.ts @@ -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 { // Same config resolution as `fillAutoNumberFields`: object name first,