Skip to content

Commit 83e9d71

Browse files
refactor(driver-sql): move the autonumber sequence path out of sql-driver.ts into sql-driver-autonumber.ts (#25521 cut 11) (#26322)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1XXYaoUKRhjtA4WcF7E86RCH)_ Part of #25521 Clause-②: no Metadata-protocol: none Ruling-ref: 6270097840 Ruling-ref: 6348891160 Cut 11 of the `sql-driver.ts` split (#24586 ruling A, recorded in `6270097840`). The claim is `6424911800`. The cut opens with a fresh seam table (below), taken on `origin/main` after cut 10 merged, and takes the area that pays best without breaking a series limit. The bodies of six `SqlDriver` methods move unedited into a new seam module, `packages/drivers/driver-sql/src/sql-driver-autonumber.ts`, through a new host, `AutonumberHost`. Together they are the autonumber sequence path: - `getNextSequenceValue`, which reads and advances one counter row under its lock (and bootstraps a cold counter from the data); - `fillAutoNumberFields`, which fills a row's `autonumber` fields from those counters; - `issueAutonumbers` (public), the issue-ahead door, with its collision follow-up; - `maxAutonumberCounter`, the bootstrap reading of the highest counter in a set of values; - `collidingAutoNumberReservations` and `resyncSequenceToDataMax`, the #5495 collision re-seed. The change is move-only, and the card stays open for later cuts. There is no `docs/adr/**` edit, no `packages/spec/**` edit, no anchor entry change and no new gate. Branch `claude/project-thread-r8r6to` (the platform assigned it to this card thread, as the claim records). One commit, `69181bf14`, on `origin/main` `817cb8983` (cut 10's merge). **Net line count (the seat's rule).** `sql-driver.ts` goes from 21,018 lines to 20,827: **-191**. - Out: the six bodies, 248 lines (79 + 61 + 51 + 21 + 11 + 25), and the two import lines of `isUniqueViolationError` and `uniqueViolationColumn`, whose last reader moved. Three more such names (`renderAutonumber`, `readAutonumberCounter`, `missingFieldValues`) leave a line that stays. - In: the six stub bodies, 54 lines (13 + 9 + 11 + 2 + 7 + 12, each 4 + 2 per field read + 1 per method read; `maxAutonumberCounter` reads no member, so its stub is a comment and a direct call), and 5 lines for the new import and its comment. - Priced before the move at -194 for the bodies alone. - The new module is 367 lines. **Serial with the file's in-flight PRs.** I read the full file list of every open PR at start (36 PRs, on cut 10's head) and again after cut 10 merged, before the first edit (28 PRs). No open PR touches `sql-driver.ts`, `turso-driver.ts`, the CRUD section, `check-tenant-chokepoint.mjs` or the driver-sql anchor entries. - #26261 (`$now` comparand) was open at start, editing the preamble and Filter helpers. It merged before cut 10 did, so it is in this base. - #26138 touches `driver-sql/src/builtin-column-collision.ts` only, and #26267 the `vitest.config.ts` of driver-turso and driver-sqlite-wasm only. - `origin/main` has moved 8 commits past the base since; none touches `packages/drivers/**`. ## Seam table, fresh, on `817cb8983` `origin/main` is at `817cb8983`, after cut 10 (#26287) and #26261. - **Size and churn.** `sql-driver.ts` is 21,018 lines. 30-day lines are `git blame` lines from commits on or after 2026-09-26T19:27:54Z, 30 days before the base commit. History reaches 2026-09-15, and `scripts/pm/git-history.mjs count` proves 71 commits on the file in that window (29 in the last 7 days). In all, 3,523 lines (16.8%) are from the last 30 days. - **Class shape.** `SqlDriver` spans lines 5591-20943 with 350 members: 243 instance methods (49 public), 13 accessors and 32 statics, all 32 of them private or protected. 30 of the instance methods are already stubs from cuts 1-10. - **Inferred return types.** One public member has one: the `supports` getter, in the class head. - **How it was measured.** A TypeScript compiler AST script, which strips casts, parentheses, `!` and `satisfies` before it reads `this`. Sections are the class's own banner comments (`// ===` blocks and `// ──` sub-banners), as cuts 9 and 10 cut them, with the docblock heading "The sequence semantics a second face shares" folded into CRUD. | Lines | Section | First … last member | Lines | 30-day | Methods (public) | Inferred public | getBuilder sites | Statics (private/protected) | |:--|:--|:--|--:|--:|:--|--:|--:|:--| | 1-5590 | Module preamble | - | 5,590 | 843 | - | - | - | - | | 5591-7182 | Class head: fields, statics, constructor, capability getters | `name` … `assertDialectEmits` | 1,592 | 290 | 8 (1) | 1 | 0 | 18 (18) | | 7183-7614 | Lifecycle | `connect` … `disconnect` | 432 | 155 | 8 (3) | 0 | 0 | 6 (6) | | 7615-10319 | CRUD — IDataDriver core | `find` … `delete` | 2,705 | 739 | 43 (7) | 0 | 12 | 0 (0) | | 10320-10931 | Bulk & Batch Operations | `bulkCreate` … `count` | 612 | 119 | 9 (6) | 0 | 7 | 0 (0) | | 10932-11028 | Raw Execution | `rawStatementFault` … `noteRuntimeIndexDdl` | 97 | 28 | 3 (1) | 0 | 0 | 0 (0) | | 11029-11121 | Transactions | `beginTransaction` … `rollbackTransaction` | 93 | 46 | 7 (5) | 0 | 0 | 0 (0) | | 11122-11653 | Aggregation | `aggregate` … `unresolvableAggregateColumnRefusal` | 532 | 60 | 5 (1) | 0 | 1 | 0 (0) | | 11654-11941 | Distinct | `distinct` … `unresolvableDistinctColumnRefusal` | 288 | 17 | 3 (1) | 0 | 1 | 0 (0) | | 11942-12057 | Window Functions | `findWithWindowFunctions` … `findWithWindowFunctions` | 116 | 4 | 1 (1) | 0 | 1 | 0 (0) | | 12058-12156 | Query Plan Analysis | `explain` … `analyzeQuery` | 99 | 1 | 2 (2) | 0 | 1 | 0 (0) | | 12157-12215 | Schema Sync (syncSchema / init) | `syncSchema` … `reclaimSpace` | 59 | 34 | 3 (3) | 0 | 0 | 0 (0) | | 12216-13957 | Schema Sync (syncSchema / init) › Data-lifecycle rotation (ADR-0057 P2) | `rotationStateByTable` … `migrateMysqlTimeColumns` | 1,742 | 72 | 26 (4) | 0 | 0 | 0 (0) | | 13958-14214 | Schema Sync (syncSchema / init) › Deferred schema DDL (#3917) | `setDeferredDdl` … `flushDeferredSchemaDdl` | 257 | 25 | 6 (4) | 0 | 0 | 0 (0) | | 14215-15029 | Schema Sync (syncSchema / init) › Managed-schema drift & reconcile (#2186) | `dialectName` … `createNullSafeUniqueIndex` | 815 | 237 | 23 (3) | 0 | 0 | 0 (0) | | 15030-15081 | Schema Introspection | `listPhysicalColumns` … `introspectSchema` | 52 | 46 | 2 (2) | 0 | 0 | 0 (0) | | 15082-15352 | Internal helpers | `getKnex` … `auditMissingTenant` | 271 | 73 | 8 (1) | 0 | 1 | 0 (0) | | 15353-17793 | Internal helpers › Filter helpers | `tableNameForBuilder` … `applyFalseConstant` | 2,441 | 438 | 44 (2) | 0 | 0 | 0 (0) | | 17794-17969 | Internal helpers › Field mapping | `mapSortField` … `mapAggregateFunc` | 176 | 2 | 6 (0) | 0 | 0 | 0 (0) | | 17970-18017 | Internal helpers › Window function builder | `buildWindowFunction` … `buildWindowFunction` | 48 | 26 | 1 (0) | 0 | 0 | 0 (0) | | 18018-19964 | Internal helpers › Column creation helper | `nowColumnDefault` … `applyDeclaredColumnDefault` | 1,947 | 92 | 19 (0) | 0 | 0 | 8 (8) | | 19965-20262 | Internal helpers › Database helpers | `sqliteFilename` … `isJsonField` | 298 | 76 | 9 (2) | 0 | 0 | 0 (0) | | 20263-20739 | Internal helpers › SQLite serialisation | `formatInput` … `formatOutput` | 477 | 27 | 2 (0) | 0 | 0 | 0 (0) | | 20740-20943 | Internal helpers › Introspection internals | `introspectColumnOrder` … `introspectUniqueConstraints` | 204 | 73 | 5 (0) | 0 | 0 | 0 (0) | | 20944-21018 | After the class | - | 75 | 0 | - | - | - | - | ### Pricing the eligible methods A method is eligible to move when its return type is annotated or it is not public; it has no `getBuilder` site, no `this.FIELD.delete/insert/update(` site, no private or protected static read, no `super` read, no bare `this` pass and no write to `this`; and it is not `insertOnlyUpsertColumns`. 164 members are eligible on this base. Each is priced as its stub minus its body (a stub costs 4 lines, plus 2 per field or getter read and 1 per method read). **Priced by area** (every eligible body with a negative price and no `sql-driver.ts#…` pointer, summed per section): Filter helpers -550 (22 bodies), Rotation -388 (15), CRUD -351 (18), and every other section -93 or less. The highest-priced candidates, and why each one stays or moves: | Method (section) | Net | Disposition | |:--|--:|:--| | `createColumn` (Column creation helper) | -463 | **Stays.** ADR-0113 cites `sql-driver.ts#createColumn`, and five cli `generate-*.pin.test.ts` files read this file's text. | | `applyFilterCondition` (Filter helpers) | -368 | **Stays.** ADR-0139 cites `sql-driver.ts#applyFilterCondition`. | | `formatOutput`, `formatInput` (SQLite serialisation) | -236, -183 | **Stay.** ADR-0104 and ADR-0053 cite them, and the `sql-driver.ts` anchor entry's ADR-0053 paragraph describes `formatOutput`. | | `initObjects` (Rotation) | -195 | **Stays.** `packages/spec/liveness/object.json` cites it. | | `applyCrossFieldComparison` (Filter helpers) | -179 | Not taken: it reads four module-local helpers that other members also call (`uncompilableFieldReferenceError` alone has 16 readers). | | `applyNowAnchorComparison` (Filter helpers) | -101 | Not taken: it reads five module-local declarations, two of them (`crossFieldComparisonClass`, `fieldReferenceOffsetOf`) shared with the cross-field code. #26261 added it at 19:11Z today. | | `assertConflictTargetHonoured` (CRUD) | -89 | Not taken: it reads five module-local declarations of the upsert conflict-target refusal. | | `backendStatementFault` (Bulk & Batch), `compileFilters` (Filter helpers) | -75 each | Not taken: each reads module-local declarations that other code also reads (`PRE_DDL_QUESTION_SCOPE`; `refusalSubtree`, `assertCompilableComparand` and three more). | | **`getNextSequenceValue`** (CRUD) | **-66** | **Moves (this cut).** Reads one module-local declaration, `SEQUENCES_TABLE` (see below). | | **`fillAutoNumberFields`** (CRUD) | **-52** | **Moves (this cut).** | | **`issueAutonumbers`** (CRUD, public) | **-40** | **Moves (this cut).** | | **`maxAutonumberCounter`** (CRUD) | **-19** | **Moves (this cut).** | | **`resyncSequenceToDataMax`** (CRUD) | **-13** | **Moves (this cut).** Reads `SEQUENCES_TABLE`. | | **`collidingAutoNumberReservations`** (CRUD) | **-4** | **Moves (this cut).** | ## Why this area - **It pays the most of any area that moves cleanly.** Filter helpers' paying bodies each drag module-local refusal helpers that other members also read; under the series' helper rule (a body that drags a shared helper stays) what is left there pays about -100. Rotation's paying bodies read eight shared module-local declarations, as cut 10 measured. In CRUD, the sequence path is the part that moves with one shared declaration. - **One shared declaration, exported in place (cut 2's shape).** `SEQUENCES_TABLE` is read by the two moved bodies and by three members that stay: `ensureSequencesTable`, `ensureSequencesKeyHashShape` and the `sequencesTableName` getter. It stays in `sql-driver.ts`, gains `export`, and the module imports it back, as cut 2 did with seven declarations. The value import is read only inside function bodies, so the module cycle is evaluation-safe. `index.ts` does not re-export it, and the published `.d.ts` does not move (proof below). - **What stays, and why.** - `ensureSequencesTable` and `ensureSequencesKeyHashShape` write `this`. - `autoNumberValueExists` and `scanMaxNumericTail` each hold one of the four tenant-column predicates that `check:tenant-chokepoint`'s ADR-0131 D8 rule recognises. That gate scans the three driver files, not seam modules, and does not print the count on success, so moving them would narrow it silently (its floor is 3). I measured the count with the gate's own `nullArmsOf`: **4 at base and 4 at head**, all four in `sql-driver.ts`. - The sequence-semantics members `TursoDriver`'s remote face calls (`resolveSequenceTenantId`, `sequenceKeyHash`, `defineSequencesTable`, `createSequencesTable` and two getters) and the generic `attemptWithoutPoisoning` price at -3 or better, so they stay. - **No series limit is touched:** none of the six builds through `getBuilder`, reads a static, has a `this.FIELD.delete/insert/update(` site, uses `super` or a bare `this`, or writes to `this`. `issueAutonumbers`, the one public method, has an annotated return type (a promise of `string[][]`). - **Subclasses.** `TursoDriver` overrides `getNextSequenceValue`, `autoNumberValueExists` and `resyncSequenceToDataMax`, and each override calls `super` on its local path. The stubs stay on the class with the same signatures, so `super` still reaches them. The moved bodies reach every method through a late-bound closure on the instance, so `fillAutoNumberFields` still reaches Turso's counter, and the collision path still reaches its `autoNumberValueExists` and `resyncSequenceToDataMax`. - **Path readers, screened.** No script, test, doc or ADR names any of the six by a `sql-driver.ts#…` path or a `sql-driver.ts::…` key. No test slices their text out of `sql-driver.ts`. See Acceptance notes for prose that names them. ## The shape (#23050 shape B, as cuts 1-10 apply it) - Each class method keeps its docblock and signature, byte-identical less `async`. Its body is now one host literal plus `NAME.call(host, ...)`, marked `[#25521 cut 11]`. The five `async` methods return the moved function's own promise. `maxAutonumberCounter` reads no member, so its stub calls the function directly, as cut 3/16 of #25467 did. - Fields go as GETTERS that read the driver at call time: `autoNumberFields`, `issuedAutoNumbers`, `knex`, `physicalTableByObject` and `sequencesHasKeyHash`. Methods go as closures that call the method on the driver at call time. So a subclass override, an instance spy, or a field replaced after construction is still the one the body reaches (see the probe below). - Each moved function types its `this` as the Pick of `AutonumberHost` over exactly the members it reads. Each stub annotates its literal as the ThisParameterType of the moved function. There is no cast, and no member becomes public. - `AutonumberHost` lists 17 members, each typed as the class member's indexed access. - **Module-level declarations.** The module imports `DriverOptions` (type), `missingFieldValues`, `readAutonumberCounter` and `renderAutonumber` from `@objectstack/spec/data`, `StorageNameMapping` from `@objectstack/spec/system`, `isUniqueViolationError` and `uniqueViolationColumn` from `@objectstack/types`, the type `Knex`, `GLOBAL_TENANT` from `schema-drift.ts`, the types `AutoNumberReservation` and `SqlDriver`, and `SEQUENCES_TABLE` from `sql-driver.ts`. `sql-driver.ts` drops the five of those names it no longer reads. - **Comments.** The module header says what moved, what stayed and why, and how the bodies reach the class. The module has no anchor entry, so the header's own mention of the ADR-0131 D8 rule counts nowhere. The `sql-driver.ts` import comment names cut 11 in 4 lines. - **How the move was made.** A script produced it from the source text; nothing was retyped. Each body line is re-indented two columns. No line in the moved bodies starts inside template-literal text. ## ADR anchors `check:adr-anchors` counts decision-letter citations on the anchored surface: each anchor's `invariant`, plus the full text of each anchored file. - **The count.** It is **1880 at base `817cb8983` and 1879 at head `69181bf14`**, over 90 anchored files at both ends. Both runs exit 0. - **Why it drops by one.** `getNextSequenceValue`'s body carries one comment that cites `ADR-0131 D9` (the organization-less counter refusal happens first). That comment moves from the anchored `sql-driver.ts` to the new module, which has no anchor entry. The refusal itself (`resolveSequenceTenantId`) stays in `sql-driver.ts`. - **No entry needs a new home.** The only `sql-driver.ts` entry is ADR-0053's, whose paragraph describes `formatOutput` and `presentReadValue`; neither moved. No new entry was added, and no `scripts/adr-anchors/**` file changed. ## The published `.d.ts` does not change This is measured under the series bar (ruling `6348891160`, item 1). - **How each end was built.** `pnpm turbo run build --filter=@objectstack/driver-sql --force` at each end, against the same dependency tree. The base leg writes base `sql-driver.ts` into this worktree and removes the new module after the commit, under an EXIT/INT/TERM trap that restores HEAD. The restore is proved by the HEAD blob hashes (`13a5215f`, `026dfdda`), an empty `git diff HEAD` and 0 status entries. - **The hashes.** The raw sha256 of `dist/index.d.ts` and `dist/index.d.mts` is the same at base and at head: `0f33bf3febaac3c7aa96f68a4226d752766b8203ab7acade523b69f9aaa44d74`. `cmp` finds them identical. - **Positive control.** I dropped `protected` from `getNextSequenceValue` in the head `index.d.ts`. The result hashes to `bbd6f18ba89c1fdbfa84c1bb16628d3e6956f8c4b2c8296d7629f75e63de20cb`, with a 1-line diff and `cmp` exit 1. - **Export lists.** CJS `require` and ESM `import` of the built dist give 59 / 59 names at both ends, identical (`SEQUENCES_TABLE` is not among them). - **The head dist carries the move.** `.call(host` sites in `index.mjs` go from 30 at base to 35 at head (five; `maxAutonumberCounter` is a direct call). ## Move-only proof The proof compares TypeScript compiler AST leaf tokens (JSDoc nodes skipped), base `817cb8983` against head `69181bf14`. It is a scratch script and is not committed. | Moved body | Body tokens | Comments | Parameter tokens less `this` | Return type tokens | Body lines | |:--|--:|--:|--:|--:|--:| | `getNextSequenceValue` | 394 | 27 | 34 | 4 | 79 | | `fillAutoNumberFields` | 393 | 11 | 15 | 6 | 61 | | `issueAutonumbers` | 481 | 5 | 21 | 8 | 51 | | `maxAutonumberCounter` | 110 | 6 | 12 | 1 | 21 | | `collidingAutoNumberReservations` | 118 | 0 | 12 | 6 | 11 | | `resyncSequenceToDataMax` | 207 | 0 | 3 | 4 | 25 | - **Each moved body.** In every row, 0 tokens differ and every comment is identical. `async` matches on both ends, and there are no type parameters. Each body line is the base line less two leading spaces. - **The six stubs.** Leading trivia (the docblock), modifiers and the signature are identical to base, less `async`. - **The class.** It has 350 members at both ends, in the same order, and the 344 unmoved members are byte-identical, trivia included. - **`sql-driver.ts` top level.** 210 statements at base, 206 of them byte-identical in head. The four that differ: the `@objectstack/spec/data` and `@objectstack/types` imports (names dropped), `SEQUENCES_TABLE` (a leading `export`, nothing else) and the class. The one new statement is the import of the new module. - **Result.** `PROOF OK`, exit 0. - **Negative control.** I changed `legacy shape. ` to `legacy shap3. ` in a module copy. The proof then reports `getNextSequenceValue` with its body tokens and lines differing, prints `PROOF FAILED`, and exits 1. ## Tests Each end ran under one `os-verify-lock` hold, after building that end's driver-sql dist. I compared the vitest JSON per test name and status, base `817cb8983` against head `69181bf14`. Every part's exit code was captured on its own (build, probe, three suites: all 0 at both ends). | Suite | Base | Head | Lost / changed / added | |:--|:--|:--|:--| | `@objectstack/driver-sql` | 4060 (3846 passed, 214 skipped; 248 files) | same | 0 / 0 / 0 | | `@objectstack/driver-turso`, against that end's built driver-sql dist | 2526 (2492 passed, 34 skipped; 95 files) | same | 0 / 0 / 0 | | `@objectstack/driver-sqlite-wasm` | 746 (746 passed; 39 files) | same | 0 / 0 / 0 | - **Type check and lint.** `tsc --noEmit -p .` in driver-sql exits 0. `pnpm exec eslint --no-inline-config packages/drivers/driver-sql` exits 0, with 0 problems. - **Late-binding probe.** A scratch script, not committed. It runs node against each end's built dist on in-memory SQLite. Its 33 readings print identical output at base and head (`diff` is empty). They cover: 1. `create` reaching a SUBCLASS override of `getNextSequenceValue` through `fillAutoNumberFields`, with the arguments it passes; 2. `getNextSequenceValue` reaching instance spies on `ensureSequencesTable`, `resolveSequenceTenantId`, `sequenceKeyHash`, `scanMaxNumericTail` and `attemptWithoutPoisoning`, cold and warm; 3. `sequencesHasKeyHash` flipped on the instance: a scoped format answers the legacy-shape refusal; 4. `fillAutoNumberFields` reading `physicalTableByObject` and `autoNumberFields` replaced after construction (the scan then names the replaced table; an emptied registry fills nothing), through an instance spy on `getNextSequenceValue`; 5. `issueAutonumbers` ahead of a write and after a collision (a column-named one and a column-blind one), through instance spies on `injectTenantOnInsert`, `fillAutoNumberFields`, `collidingAutoNumberReservations`, `autoNumberValueExists`, `autoNumberCounterKey` and `resyncSequenceToDataMax`; the transaction early-out; and `issuedAutoNumbers` replaced after construction; 6. the #5495 re-seed through `create`, reaching instance overrides of `autoNumberValueExists` and `resyncSequenceToDataMax`; 7. `maxAutonumberCounter` on three value sets; 8. bad arguments: each async method answers a rejection, and `maxAutonumberCounter` throws synchronously, at both ends (it was never `async`). - **Not run.** No ablation, because no tested behaviour changed. `test:repo` was not run locally: the diff touches no repo-project test, and CI's Test Core runs it. ## Gates - **Derived list.** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derives 53 commands for the 2 changed paths (merge base `817cb8983`). On head `69181bf14`, 50 exit 0 on the first run. Two exit 3 on a missing prerequisite and pass after it is supplied: `check-plugin-teardown-shape.mjs --self-test` (shallow clone; exit 0 after `git fetch origin 621a487`) and `check:lean-entry-closure` (exit 0 after building objectql under the lock). `check:dual-build-cjs-loads` exits 3, `PREREQUISITE NOT MET` (no full build here): **NOT MEASURED** locally, CI runs it. - **Reconciliation.** `dispatch-gates --ran`: 53 derived families accounted for, 52 run, 1 NOT-MEASURED (derived from the recorded exit 3), exit 0. - **Card-named gates, read at base (a clean detached worktree at `817cb8983`) and at head, all exit 0:** | Gate | Base | Head | |:--|:--|:--| | `check:adr-anchors` | 90 files, 1880 decision-letter citations | 90 files, **1879** (see ADR anchors) | | `check:adr-symbol-anchors` | 2664 resolve | 2664 resolve | | `check:tenant-chokepoint` | 24 bindings / 3 files, D8 clean; 4 tenant predicates | same; 4 tenant predicates | | `check:driver-conformance` | 55 covered / 0 DEBT | same | | `check:keyed-text-bounds` | 112 files / 238 index entries | same | | `check:object-def-param-keys` | 541 classes / 7 positions | same | | `check:system-context-census` | 153 sites / 132 symbols | same | | `check-tenant-audit-census` | 243 sites | same | | `check:logger-receiver-detach` | 0 detaches | 0 detaches | - Pre-push `check:commit-card-trailers` and `check:nul-bytes` pass. ## Acceptance notes - **Prose that names a moved body by file.** ADR-0034 says `getNextSequenceValue` "uses `runner = parentTrx ?? this.knex` … (`sql-driver.ts`)". The method still lives on `SqlDriver` in that file; its body now runs from `sql-driver-autonumber.ts`. Left as is: no `docs/adr/**` edit in this series. - **One comment now points at an import that left.** The comment on `isUnbackedConflictTargetError` in `sql-driver.ts` (line 2196) warns "Deliberately NOT `isUniqueViolationError`, which is imported one line away". `sql-driver.ts` no longer imports that predicate (its last reader moved). The warning stays true; "imported one line away" no longer does. Left unedited because the cut is move-only. - `TursoDriver`'s docblocks name `SqlDriver.getNextSequenceValue`, `SqlDriver.maxAutonumberCounter` and the others as the members its remote face shares; each is still a member of `SqlDriver`. - **Next slice, priced on this base.** The remaining CRUD upsert code needs the five conflict-target declarations (cut 2's export shape would cover them). Filter helpers stays the largest area, but its paying bodies share refusal helpers with the members that stay. --- _Generated by [Claude Code](https://claude.ai/code/session_012UMsT3A7YJv6eQtR5HED2B)_ 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_012UMsT3A7YJv6eQtR5HED2B Co-authored-by: Claude <noreply@anthropic.com>
1 parent 7158025 commit 83e9d71

2 files changed

Lines changed: 433 additions & 257 deletions

File tree

Lines changed: 367 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,367 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#25521 cut 11] The autonumber sequence path of `SqlDriver`: the counter
5+
* read-and-advance `getNextSequenceValue`, the per-row fill
6+
* `fillAutoNumberFields`, the issue-ahead door `issueAutonumbers`, the
7+
* bootstrap reading `maxAutonumberCounter`, and the #5495 collision re-seed
8+
* (`collidingAutoNumberReservations`, `resyncSequenceToDataMax`). They moved
9+
* out of `sql-driver.ts` whole.
10+
*
11+
* A MOVE, not a redesign. Each function below is the body of the `SqlDriver`
12+
* method of the same name as it stood, re-indented one level and otherwise
13+
* unedited: the same counter key, the same tenant bucket, the same lock, the
14+
* same bootstrap and the same re-seed.
15+
*
16+
* What did NOT move, and why:
17+
*
18+
* - `ensureSequencesTable` and `ensureSequencesKeyHashShape` write the
19+
* driver's fields, and the series keeps a body that writes `this` in the
20+
* class.
21+
* - `autoNumberValueExists` and `scanMaxNumericTail` each hold a
22+
* tenant-column predicate that `check:tenant-chokepoint`'s ADR-0131 D8 rule
23+
* counts, and that gate scans the driver files, not this module.
24+
* - The small members the sequence-semantics block names (the counter key,
25+
* the tenant bucket, the table definition and the two getters) and
26+
* `attemptWithoutPoisoning` pay nothing to move.
27+
*
28+
* ## How the moved bodies reach the class
29+
*
30+
* The shape the other seam modules apply, after #23050. The class keeps every
31+
* method, because the published `dist/*.d.ts` lists each one's name,
32+
* docblock and position. `TursoDriver` overrides `getNextSequenceValue`,
33+
* `autoNumberValueExists` and `resyncSequenceToDataMax`, and each override
34+
* calls `super` on its local path. Each body is now one literal of exactly the
35+
* {@link AutonumberHost} members that method reads, plus a `.call`. The five
36+
* `async` methods are no longer `async`: each returns the moved function's own
37+
* promise. `maxAutonumberCounter` reads no member, so its method calls the
38+
* function directly.
39+
*
40+
* Methods go as closures that call them on the driver at call time, so a
41+
* subclass override is still the one a body reaches: `fillAutoNumberFields`
42+
* reaches `TursoDriver`'s `getNextSequenceValue`, and
43+
* `collidingAutoNumberReservations` and `issueAutonumbers` reach its
44+
* `autoNumberValueExists` and `resyncSequenceToDataMax`, as they did when
45+
* they called `this.…`. A test spy on the instance is reached the same way. The fields `autoNumberFields`,
46+
* `issuedAutoNumbers`, `knex`, `physicalTableByObject` and
47+
* `sequencesHasKeyHash` go as GETTERS, so each read happens when the body
48+
* makes it, as it did on the class.
49+
*
50+
* The one module-level declaration the bodies read that is not imported from
51+
* elsewhere stays in `sql-driver.ts`: `SEQUENCES_TABLE`. It gained `export`
52+
* there and is imported here, because `ensureSequencesTable`,
53+
* `ensureSequencesKeyHashShape` and the `sequencesTableName` getter still read
54+
* it. The module cycle is evaluation-safe because the value import is read
55+
* only inside a function body, never while the two modules are first
56+
* evaluated. `index.ts` re-exports nothing from this module, so the published
57+
* surface does not move.
58+
*/
59+
60+
import type { DriverOptions } from '@objectstack/spec/data';
61+
import { missingFieldValues, readAutonumberCounter, renderAutonumber } from '@objectstack/spec/data';
62+
import { StorageNameMapping } from '@objectstack/spec/system';
63+
import { isUniqueViolationError, uniqueViolationColumn } from '@objectstack/types';
64+
import type { Knex } from 'knex';
65+
import { GLOBAL_TENANT } from './schema-drift.js';
66+
import type { AutoNumberReservation, SqlDriver } from './sql-driver.js';
67+
import { SEQUENCES_TABLE } from './sql-driver.js';
68+
69+
/**
70+
* The `SqlDriver` members the functions below read — every one of them, and
71+
* no other; each function's `this` is the `Pick` of the members IT reads. Each
72+
* is typed AS the class member (an indexed access on the class), so a change
73+
* to a member's signature on the class is a change here too, and naming a
74+
* member the class does not have is a compile error. See the module header
75+
* for which members are getters.
76+
*/
77+
export interface AutonumberHost {
78+
attemptWithoutPoisoning: SqlDriver['attemptWithoutPoisoning'];
79+
autoNumberCounterKey: SqlDriver['autoNumberCounterKey'];
80+
readonly autoNumberFields: SqlDriver['autoNumberFields'];
81+
autoNumberValueExists: SqlDriver['autoNumberValueExists'];
82+
collidingAutoNumberReservations: SqlDriver['collidingAutoNumberReservations'];
83+
ensureSequencesTable: SqlDriver['ensureSequencesTable'];
84+
fillAutoNumberFields: SqlDriver['fillAutoNumberFields'];
85+
getNextSequenceValue: SqlDriver['getNextSequenceValue'];
86+
injectTenantOnInsert: SqlDriver['injectTenantOnInsert'];
87+
readonly issuedAutoNumbers: SqlDriver['issuedAutoNumbers'];
88+
readonly knex: SqlDriver['knex'];
89+
readonly physicalTableByObject: SqlDriver['physicalTableByObject'];
90+
resolveSequenceTenantId: SqlDriver['resolveSequenceTenantId'];
91+
resyncSequenceToDataMax: SqlDriver['resyncSequenceToDataMax'];
92+
scanMaxNumericTail: SqlDriver['scanMaxNumericTail'];
93+
sequenceKeyHash: SqlDriver['sequenceKeyHash'];
94+
readonly sequencesHasKeyHash: SqlDriver['sequencesHasKeyHash'];
95+
}
96+
97+
/** The body of `SqlDriver#getNextSequenceValue`; the method's docblock there is its contract. */
98+
export async function getNextSequenceValue(this: Pick<AutonumberHost, 'attemptWithoutPoisoning' | 'ensureSequencesTable' | 'knex' | 'resolveSequenceTenantId' | 'scanMaxNumericTail' | 'sequenceKeyHash' | 'sequencesHasKeyHash'>, object: string, tableName: string, field: string, prefix: string, tenantField: string | null, tenantId: string | null, parentTrx?: Knex.Transaction, scope = '', suffix = ''): Promise<number> {
99+
// Pass the caller's transaction so a cold-cache first write inside a batch
100+
// transaction ensures the table on the right connection instead of dead-
101+
// locking on a second one (SQLite pool max=1). `initObjects` normally warms
102+
// this up front, making the call a no-op — this only bites the lazy path.
103+
// The bucket first: an organization-less row on a tenant table is refused
104+
// here (ADR-0131 D9), before the lazy DDL below or any counter read.
105+
const resolvedTenantId = this.resolveSequenceTenantId(tenantField, tenantId, tableName, field);
106+
await this.ensureSequencesTable(parentTrx);
107+
if (scope !== '' && !this.sequencesHasKeyHash) {
108+
// The legacy sequences table could not be migrated to the key_hash shape,
109+
// so it cannot represent per-scope counters. Fail with a clear, actionable
110+
// message instead of corrupting the single legacy counter.
111+
throw new Error(
112+
`Cannot generate a per-scope autonumber for "${object}.${field}": the ` +
113+
`${SEQUENCES_TABLE} table is still the legacy shape. ` +
114+
`Migrate it to the key_hash shape before using date/{field}/per-parent formats.`,
115+
);
116+
}
117+
// `scope` (rendered date/field prefix, boundary-delimited) gives each
118+
// period/group its own counter; '' keeps the single global counter for
119+
// fixed-prefix formats. `prefix` is the full rendered prefix used to
120+
// bootstrap from existing data. The row is keyed by a hash of the composite;
121+
// on an un-migrated legacy table only fixed-prefix (scope '') reaches here,
122+
// so fall back to the original `(object, tenant_id, field)` key for it.
123+
const key = this.sequencesHasKeyHash
124+
? { key_hash: this.sequenceKeyHash(tableName, resolvedTenantId, field, scope) }
125+
: { object: tableName, tenant_id: resolvedTenantId, field };
126+
const insertRow = this.sequencesHasKeyHash
127+
? { ...key, object: tableName, tenant_id: resolvedTenantId, field, scope }
128+
: { ...key };
129+
130+
const runner: Knex | Knex.Transaction = parentTrx ?? this.knex;
131+
132+
return runner.transaction(async (trx) => {
133+
// Lock the row (no-op on SQLite, real lock on Postgres/MySQL).
134+
//
135+
// Speculative, so it runs under a savepoint: `.forUpdate()` on a MISSING
136+
// row does NOT throw on Postgres (measured: returns zero rows), so the
137+
// fallback below is unreachable for the reason the old comment gave. It
138+
// IS reachable for lock-level failures — deadlock (40P01), lock/statement
139+
// timeout (55P03/57014) — and without the savepoint each of those turned
140+
// into a `25P02` from the fallback SELECT, masking the real error.
141+
let existing: any;
142+
const locked = await this.attemptWithoutPoisoning(trx, (scoped) =>
143+
scoped(SEQUENCES_TABLE).where(key).forUpdate().first(),
144+
);
145+
if (locked.ok) {
146+
existing = locked.value;
147+
} else {
148+
// Fall back to a plain SELECT then rely on transaction isolation.
149+
existing = await trx(SEQUENCES_TABLE).where(key).first();
150+
}
151+
152+
if (!existing) {
153+
const seedMax = await this.scanMaxNumericTail(
154+
trx,
155+
tableName,
156+
field,
157+
prefix,
158+
tenantField,
159+
resolvedTenantId === GLOBAL_TENANT ? null : resolvedTenantId,
160+
suffix,
161+
);
162+
const initial = seedMax + 1;
163+
const inserted = await this.attemptWithoutPoisoning(trx, (scoped) =>
164+
scoped(SEQUENCES_TABLE).insert({ ...insertRow, last_value: initial }),
165+
);
166+
if (inserted.ok) return initial;
167+
// Another writer raced us to the first INSERT. Fall through to
168+
// the UPDATE path with the now-present row. This `SELECT … FOR UPDATE`
169+
// blocks until the winner commits, so it sees the committed counter.
170+
existing = await trx(SEQUENCES_TABLE).where(key).forUpdate().first();
171+
if (!existing) throw inserted.error;
172+
}
173+
174+
const next = Number(existing.last_value) + 1;
175+
await trx(SEQUENCES_TABLE).where(key).update({ last_value: next, updated_at: this.knex.fn.now() });
176+
return next;
177+
});
178+
}
179+
180+
/** The body of `SqlDriver#fillAutoNumberFields`; the method's docblock there is its contract. */
181+
export async function fillAutoNumberFields(this: Pick<AutonumberHost, 'autoNumberFields' | 'getNextSequenceValue' | 'physicalTableByObject'>, object: string, row: Record<string, any>, options?: DriverOptions): Promise<AutoNumberReservation[]> {
182+
// Scan/seed the physical (remote) table for an external object; managed
183+
// objects fall through to the storage-mapped name. Config lookup stays
184+
// keyed by object name (matching initObjects/registerExternalObject).
185+
const tableName = this.physicalTableByObject[object] ?? StorageNameMapping.resolveTableName({ name: object } as any);
186+
const cfgs = this.autoNumberFields[object] || this.autoNumberFields[tableName];
187+
if (!cfgs || cfgs.length === 0) return [];
188+
const reservations: AutoNumberReservation[] = [];
189+
const parentTrx = options?.transaction as Knex.Transaction | undefined;
190+
const timezone = options?.timezone;
191+
const now = new Date();
192+
for (const cfg of cfgs) {
193+
if (row[cfg.name] !== undefined && row[cfg.name] !== null && row[cfg.name] !== '') continue;
194+
// A `{field}` token with no value would render to an empty prefix and
195+
// silently merge this record into the wrong counter scope, so refuse to
196+
// generate rather than emit a wrong record number (the referenced field
197+
// must be populated before the autonumber — see field.zod docs).
198+
const missing = missingFieldValues(cfg.tokens, row);
199+
if (missing.length > 0) {
200+
throw new Error(
201+
`Cannot generate autonumber "${object}.${cfg.name}" (format "${cfg.format}"): ` +
202+
`referenced field(s) [${missing.join(', ')}] are empty on the record. ` +
203+
`Fields interpolated into an autonumber format must be set before the record is created.`,
204+
);
205+
}
206+
// Resolve tenant for this row: explicit field on the record wins,
207+
// then driver options, else null → global sequence.
208+
const rowTenant = cfg.tenantField ? row[cfg.tenantField] : undefined;
209+
const optTenant = options?.tenantId;
210+
const tenantId = rowTenant != null && rowTenant !== ''
211+
? String(rowTenant)
212+
: optTenant != null && optTenant !== ''
213+
? String(optTenant)
214+
: null;
215+
// Resolve the scope/prefix for this row (counter-value-independent),
216+
// reserve the next value under that scope, then render the final string.
217+
const probe = renderAutonumber({ tokens: cfg.tokens, seq: 0, record: row, now, timezone });
218+
const next = await this.getNextSequenceValue(
219+
object,
220+
tableName,
221+
cfg.name,
222+
probe.prefix,
223+
cfg.tenantField,
224+
tenantId,
225+
parentTrx,
226+
probe.scope,
227+
probe.suffix,
228+
);
229+
const value = renderAutonumber({ tokens: cfg.tokens, seq: next, record: row, now, timezone }).value;
230+
row[cfg.name] = value;
231+
reservations.push({
232+
field: cfg.name,
233+
tableName,
234+
prefix: probe.prefix,
235+
scope: probe.scope,
236+
suffix: probe.suffix,
237+
tenantField: cfg.tenantField ?? null,
238+
tenantId,
239+
value,
240+
});
241+
}
242+
return reservations;
243+
}
244+
245+
/** The body of `SqlDriver#issueAutonumbers`; the method's docblock there is its contract. */
246+
export async function issueAutonumbers(this: Pick<AutonumberHost, 'autoNumberCounterKey' | 'collidingAutoNumberReservations' | 'fillAutoNumberFields' | 'injectTenantOnInsert' | 'issuedAutoNumbers' | 'resyncSequenceToDataMax'>, object: string, rows: Record<string, unknown>[], options?: DriverOptions, afterCollision?: unknown): Promise<string[][]> {
247+
if (afterCollision === undefined) {
248+
const filled: string[][] = [];
249+
for (const row of rows) {
250+
if (!row || typeof row !== 'object') {
251+
filled.push([]);
252+
continue;
253+
}
254+
// Filled as `create()` fills it: the caller's organization stamped
255+
// first (numbers are issued per organization), on a view, so only
256+
// the numbers land on the caller's row.
257+
const view: Record<string, any> = { ...row };
258+
this.injectTenantOnInsert(object, view, options);
259+
const reserved = await this.fillAutoNumberFields(object, view, options);
260+
for (const r of reserved) row[r.field] = view[r.field];
261+
// A value kept from an earlier issue on this row is still this
262+
// driver's, so it stays described beside the fresh ones (#6943).
263+
const fresh = new Set(reserved.map((r) => r.field));
264+
const kept = (this.issuedAutoNumbers.get(row) ?? []).filter((r) => !fresh.has(r.field));
265+
this.issuedAutoNumbers.set(row, [...kept, ...reserved]);
266+
filled.push(reserved.map((r) => r.field));
267+
}
268+
return filled;
269+
}
270+
271+
const none = rows.map((): string[] => []);
272+
if (options?.transaction !== undefined) return none;
273+
const issued = rows.map((row) => (row && typeof row === 'object' ? this.issuedAutoNumbers.get(row) ?? [] : []));
274+
const colliding = await this.collidingAutoNumberReservations(afterCollision, issued.flat(), options);
275+
if (colliding.length === 0) return none;
276+
277+
const stale = new Set<string>();
278+
for (const reservation of colliding) {
279+
const key = this.autoNumberCounterKey(reservation);
280+
if (stale.has(key)) continue;
281+
stale.add(key);
282+
await this.resyncSequenceToDataMax(reservation);
283+
}
284+
return rows.map((row, i) => {
285+
const emptied: string[] = [];
286+
const kept: AutoNumberReservation[] = [];
287+
for (const reservation of issued[i]) {
288+
if (stale.has(this.autoNumberCounterKey(reservation))) {
289+
delete row[reservation.field];
290+
emptied.push(reservation.field);
291+
} else {
292+
kept.push(reservation);
293+
}
294+
}
295+
if (emptied.length > 0) this.issuedAutoNumbers.set(row, kept);
296+
return emptied;
297+
});
298+
}
299+
300+
/** The body of `SqlDriver#maxAutonumberCounter`; the method's docblock there is its contract. */
301+
export function maxAutonumberCounter(values: Iterable<unknown>, prefix: string, suffix: string): number {
302+
let maxN = 0;
303+
const anchored = prefix !== '' || suffix !== '';
304+
for (const v of values) {
305+
if (typeof v !== 'string') continue;
306+
let n: number;
307+
if (anchored) {
308+
// Spec's inverse of `renderAutonumber` (#6560). It also re-checks the
309+
// prefix in JS: a driver-side `LIKE` can match looser than `startsWith`
310+
// (collation, case-insensitive columns), and a row from another scope
311+
// must not inflate this counter — it reads as `undefined`, same as a
312+
// row carrying no counter at all.
313+
const read = readAutonumberCounter(v, prefix, suffix);
314+
if (read === undefined) continue;
315+
n = read;
316+
} else {
317+
// Unanchored: `prefix` is '' here, so this is the whole value.
318+
n = parseInt(v.replace(/[^0-9]/g, ''), 10);
319+
}
320+
if (Number.isFinite(n) && n > maxN) maxN = n;
321+
}
322+
return maxN;
323+
}
324+
325+
/** The body of `SqlDriver#collidingAutoNumberReservations`; the method's docblock there is its contract. */
326+
export async function collidingAutoNumberReservations(this: Pick<AutonumberHost, 'autoNumberValueExists' | 'knex'>, error: unknown, reservations: AutoNumberReservation[], options?: DriverOptions): Promise<AutoNumberReservation[]> {
327+
if (reservations.length === 0 || !isUniqueViolationError(error)) return [];
328+
329+
const column = uniqueViolationColumn(error);
330+
if (column !== undefined) return reservations.filter((r) => r.field === column);
331+
332+
const runner: Knex | Knex.Transaction = (options?.transaction as Knex.Transaction | undefined) ?? this.knex;
333+
const colliding: AutoNumberReservation[] = [];
334+
for (const reservation of reservations) {
335+
if (await this.autoNumberValueExists(runner, reservation)) colliding.push(reservation);
336+
}
337+
return colliding;
338+
}
339+
340+
/** The body of `SqlDriver#resyncSequenceToDataMax`; the method's docblock there is its contract. */
341+
export async function resyncSequenceToDataMax(this: Pick<AutonumberHost, 'ensureSequencesTable' | 'knex' | 'resolveSequenceTenantId' | 'scanMaxNumericTail' | 'sequenceKeyHash' | 'sequencesHasKeyHash'>, reservation: AutoNumberReservation): Promise<void> {
342+
await this.ensureSequencesTable();
343+
const resolvedTenantId = this.resolveSequenceTenantId(
344+
reservation.tenantField,
345+
reservation.tenantId,
346+
reservation.tableName,
347+
reservation.field,
348+
);
349+
const key = this.sequencesHasKeyHash
350+
? { key_hash: this.sequenceKeyHash(reservation.tableName, resolvedTenantId, reservation.field, reservation.scope) }
351+
: { object: reservation.tableName, tenant_id: resolvedTenantId, field: reservation.field };
352+
353+
await this.knex.transaction(async (trx) => {
354+
const observedMax = await this.scanMaxNumericTail(
355+
trx,
356+
reservation.tableName,
357+
reservation.field,
358+
reservation.prefix,
359+
reservation.tenantField,
360+
resolvedTenantId === GLOBAL_TENANT ? null : resolvedTenantId,
361+
reservation.suffix,
362+
);
363+
const existing = await trx(SEQUENCES_TABLE).where(key).first();
364+
if (!existing || Number(existing.last_value) >= observedMax) return;
365+
await trx(SEQUENCES_TABLE).where(key).update({ last_value: observedMax, updated_at: this.knex.fn.now() });
366+
});
367+
}

0 commit comments

Comments
 (0)