Skip to content

Commit c8f4c43

Browse files
fix(objectql, driver-sql, spec): a stored expression that reads an autonumber stores the value computed with the number (#25218)
<!-- claude-projects-attribution: {"github_login":"os-bill"} --> _Requested by **Bill** · [project thread](https://claude.ai/code/project/chan_016iMXWSZDzqWRWvWzdNo8wg?thread=cmsg_016iMXWSZDzqWRWvWzdNo8wgJ9pAzFfjwt5n1qN6Xkkmh5)_ Fixes #25073 Clause-②: yes (widening) Metadata-protocol: none Ruling-ref: 6335312761 (letter A). Contract review: `CONTRACT_REVIEW_TIER`, run by the engine seat. Cross-lane declaration to `domain:spec` on #6017. ## What changes A stored field whose `expression` reads an `autonumber` (`record.case_number + " - " + record.subject`, or `has(record.product_code) ? … : record.name`) was computed before the number existed, so an insert stored `null` or the `has()` fallback until a later update by id. Now the insert computes it again right after the number is issued, and the row is written once, carrying both. - **`IDataDriver.issueAutonumbers?(object, rows, options?, afterCollision?)`** (`packages/spec/src/contracts/data-driver.ts`), the one optional member. Issue mode fills a row's empty autonumber slots in place from the driver's own counter and answers the filled names per row. After-collision mode judges whether a `create()` / `bulkCreate()` error is a unique violation on a number it issued to those row objects; if so it re-seeds each stale counter once, empties the slots drawn from it and answers their names. Otherwise (a value the caller typed, a non-unique error, inside a caller transaction) it answers empty lists and changes nothing. - **`SqlDriver.issueAutonumbers`** is `fillAutoNumberFields` on request (organization stamped first, on a view, as `create()` does) plus `create()`'s own #5495 / #6943 re-seed split where the caller must go on (`collidingAutoNumberReservations`, `resyncSequenceToDataMax`, `autoNumberCounterKey`). `driver-sqlite-wasm` and `driver-turso` inherit it; Turso's `REMOTE_FACE_ANSWERS` names it `inherited` (the #20055 compile-time table requires a row for every public `SqlDriver` member). - **The engine** (`packages/objectql/src/engine.ts`). At the point `applyAutonumbers` already runs (after validation, so a rejected row burns no number): - it plans the stored fields that read an autonumber, directly or through another stored field (`storedExpressionsReading`, using formula's `analyzeRelationshipTraversals`, the one member reader); - where the engine issues (`supports.autonumber` absent: driver-memory, driver-mongodb), it computes them again after `applyAutonumbers`; - where the driver owns the counter and the object has such a field, it calls `driver.issueAutonumbers` per live row, computes them again, and hands `create()` / `bulkCreate()` the filled row: one INSERT, no transaction. Objects with no such field keep the driver's issue-at-write path unchanged; - `createWithAutonumberResync` is the one single-row retry loop for both issuers: on a driver-issued number the member judges the error and re-seeds, the loop re-issues through the member and computes the readers again, under the same `AUTONUMBER_COLLISION_ATTEMPTS`; - a batch collision on driver-issued numbers asks the member to re-seed and is not re-issued (the engine's existing batch policy), raising `DUPLICATE_RECORD`; - a driver that owns its counter without the member: the number stays inside `create()`, and the engine says so once per object at `error` level with the remedy; - no `instanceof SqlDriver` or driver-name branch anywhere: the member's presence and `supports.autonumber` decide. - **`ObjectQL.registerInsertRecomputeListener(id, fn)`**: called per row after that second computation (and after each re-issue), on the row the driver is handed. `plugin-pinyin-search` registers its existing `stampCompanion` on it, narrowed to the recomputed fields, so the `__search` companion is stamped from the value carrying the number. ⚠️ **Approved file-surface extension**: `packages/plugins/plugin-pinyin-search/src/companion-projection.ts` and this engine method were outside the dispatched surface. The seat decided (A) on the card thread on 2026-10-22, recorded on #25073: ruling item 4 binds the same-step recompute, no new `HookEvent` name, an additive plugin-facing registration in the `registerWriteGateProbe` family. ## Measurements | shape | before (base, #25073's probe) | after (this branch) | |:--|:--|:--| | SQLite, concat title | `display_title: null` | `"CASE-00001 - one"` | | SQLite, `has()` title | `"Probe product"` | `"PRD-0001 Probe product"` | | driver-memory, concat title | `null` | `"CASE-00001 - one"` | | `__search`, concat title as `nameField` (SQLite and memory) | `null`; `$search` "zhangwei" 0 hits, "case00001" 0 hits | `"case00001zhangwei case00001zw"`; 1 hit each | | `__search`, `has()` title | `"chabei cb"` | `"prd0001chabei prd0001cb"` | The `__search` "before" column was measured on this branch before the listener existed (the number in the title, the companion still stamped in `beforeInsert` from the pre-number value). **Turso remote face, with the inherited member** (`turso-remote-autonumber-issue-ahead.test.ts`, a real SQLite behind the `@libsql/client` interface): the member is not redeclared on `TursoDriver`; it issues from the counter the remote `create()` draws from (`last_value` 1 → 2, `create()` of the issued row leaves it at 2); after rows 2..30 land by a bypass path, the collision call re-seeds the remote counter to 30 and empties the slot, and the next issue writes `CASE-00031`; a duplicate on a typed value answers nothing. No fork. ## Pins and ablations | file | what | result | |:--|:--|:--| | `driver-sql/src/sql-driver-autonumber-issue-ahead.test.ts` | issue (same counter as `create()`, per organization from options, typed value untouched, batch alignment); after collision (re-seed + empty, typed duplicate, non-unique error, caller transaction, batch per counter) | 9/9 | | `objectql/src/engine-autonumber-stored-expression.test.ts` | both issuers over hand-rolled drivers: single, batch (one `bulkCreate`), partial mode, `has()`, forced collision, listener per row and per re-issue; controls (expression reading no number, update by id); driver-only: member not asked for an object with no reader, batch collision re-seeds and does not re-issue, exhaustion `ERR_AUTONUMBER_COLLISION`, no-member `error` once | 20/20 | | `runtime/src/autonumber-stored-expression.integration.test.ts` | real `SqlDriver` on better-sqlite3: single (one `create`, one number drawn), batch (one `bulkCreate`), partial (dead row burns none), `has()`, forced single collision, batch collision (nothing lands, re-seed, next past the store), `afterInsert` sees the value, inside a caller transaction; controls (object with no reader keeps issue-at-write, update by id) | 10/10 | | `plugin-pinyin-search/src/companion-autonumber-recompute.test.ts` | real engine, both issuers: concat and `has()` companions carry the number, `$search` finds by pinyin and by number, batch; controls (companion source not recomputed, object with no companion column) | 10/10 | | `driver-turso/src/turso-remote-autonumber-issue-ahead.test.ts` (+ `turso-remote-inherited-members.test.ts`) | the remote face measurement above | 97/97 | Ablations, each through `scripts/ablation-replace.mjs` (mutation proved on disk, restored to HEAD): - **Remove the recompute** (`recomputeAutonumberReaders` returns at entry), objectql suite: 13 red (every autonumber pin on both issuers, plus the batch-collision pin's next title), 7 green (the 4 controls, member-not-asked, exhaustion, no-member). - **The same, through `dist`** for the runtime suite (`ablation-dist-preflight.mjs` saw the marker in 4 built files): 8 red, 2 green (the two controls). - **Unregister the listener** in `companion-projection.ts`, plugin suite: 6 red (the companion pins, both issuers), 4 green (the controls). ## Acceptance notes - **"One retry loop for every driver", read as the single-row loop.** A single-row collision on a driver-issued number goes through the engine's `createWithAutonumberResync`, re-issuing through the member, for every driver. The batch path keeps the engine's existing "re-seed, never re-issue" policy for driver-issued numbers too, because the engine cannot know whether a driver's `bulkCreate` is all-or-nothing (Turso's remote `bulkCreate` writes row by row), and re-issuing a partly landed batch duplicates rows. And the engine issues through the member only for objects with a reader: other objects keep `SqlDriver`'s own issue-at-write and its in-driver #6943 batch heal, which the engine could not take over without re-issuing batches. Reviewer's call if the ruling meant more. - **What a refused insert reports moved on objects with a reader**: a SQL collision that survives every retry now leaves as `ERR_AUTONUMBER_COLLISION` after the engine's 3 attempts, where the driver's own loop used to retry and the engine then raised `DUPLICATE_RECORD`. Named in the changeset. - **Validation and the RLS write seam judge the pre-number value** of a reader field: they run before the number is issued, by the ruling's order (a rejected row burns no number). A rule over such a field sees `null` / the fallback. - **Upsert is not covered**: `engine.upsert` → `driver.upsert` still issues inside the driver. - **Turso remote `bulkCreate` partial application** is pre-existing (row by row) and unchanged. - **Inside a caller transaction** the member answers no collision, as `create()` retried nothing there (P4 unchanged): the caller sees `DUPLICATE_RECORD`. - **A driver that owns its counter but issues nothing ahead** (a third-party driver without the member) keeps today's stored value, now reported once per object at `error`. - **Changeset level is `patch`, not `minor`**: AGENTS.md Post-Task step 3 sets the level from the `Metadata-protocol` kind alone (`none` ⇒ `patch`), the precedent being #25065's optional `IEmailService` member. The dispatch said `minor` where the contract widens. ## Gates Local, at `800779bd` (the last commit before merging `main`): - `dispatch-gates --commands` derived 98 families; all 98 run, exit codes recorded before any pipe; `--ran`: 98 run, 0 NOT-MEASURED, 0 UNRUN. - Four were non-zero on the first pass and are 0 on rerun: `check-engine-split-ratio --days 90` and `check-plugin-teardown-shape --self-test` refused on the shallow clone (history deepened, then 0); `check:dual-build-cjs-loads` refused without a whole-workspace build (0 after one); `check:query-options-erasure` caught one `as any` on a `find` query in the new plugin test (typed in `800779bd`, then 0). - `pnpm --filter @objectstack/spec check:generated`: 0, all 14 artifacts current. - `check:driver-conformance`: 0 at the base and at the head, 55 covered cells and 0 in the DEBT ledger both times. - `typecheck`: 0 for spec, objectql, driver-sql, driver-turso, plugin-pinyin-search, runtime. The rerun on the merged head and the dogfood battery are reported in the `os-dev-report` on #25073. --- _Generated by [Claude Code](https://claude.ai/code/session_01LMugqQAMxZFLH5rBUFsFHC)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent b2e964f commit c8f4c43

11 files changed

Lines changed: 1613 additions & 20 deletions
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
'@objectstack/spec': patch
3+
'@objectstack/objectql': patch
4+
'@objectstack/driver-sql': patch
5+
'@objectstack/driver-turso': patch
6+
'@objectstack/plugin-pinyin-search': patch
7+
---
8+
9+
fix(objectql, driver-sql, spec): a stored field whose `expression` reads an `autonumber` stores the value computed with the number
10+
11+
Clause-②: yes (widening)
12+
Metadata-protocol: none
13+
14+
A stored (non-`formula`) field whose `expression` reads a record number, such as `record.case_number + " - " + record.subject`, was computed before the number existed. On insert it stored `null` (or the `has()` fallback) until a later update by id computed it again. Now the insert computes it again right after the number is issued, and writes the row once, carrying both.
15+
16+
- **`IDataDriver.issueAutonumbers` (new, optional).** A driver that owns its record-number counter can issue a row's numbers ahead of the write, in place, and answer after a unique violation whether the collision was on a number it issued (re-seeding the counter and emptying those slots). `SqlDriver` implements it over its existing counter, so `driver-sqlite-wasm` and `driver-turso` (both faces) inherit it. A driver without it is unchanged.
17+
- **The engine.** For an object whose stored expression reads a record number (directly or through another stored field), the engine asks the driver for the number after validation, where it already issued numbers itself, so a rejected row still burns none. It computes the readers again and hands `create()` / `bulkCreate()` the row with the number filled: one INSERT, no transaction. A collision on a single row is re-issued through the member by the engine's existing retry loop, which computes the readers again. A batch collision re-seeds the counter and is not re-issued (the batch policy the engine already had). Objects with no such field keep the driver's own issue-at-write path. Where the engine issues the number itself (driver-memory, driver-mongodb), the readers are computed again after it does.
18+
- **A driver that owns its counter without the member** stores the readers computed without the number, as before, and the engine now says so once per object at `error` level, with the remedy.
19+
- **`ObjectQL.registerInsertRecomputeListener(id, fn)` (new).** Called per row after that second computation, on the row the driver is handed. `plugin-pinyin-search` registers on it, so a `__search` companion whose name field reads the record number is stamped from the value that carries the number, and `$search` finds the record by pinyin and by its number.
20+
- **One change in what a refused insert reports.** On an object whose stored expression reads the number, a SQL driver's collision that survives every retry now leaves as `ERR_AUTONUMBER_COLLISION` (the engine's three attempts) instead of `DUPLICATE_RECORD` (the driver's own retries). Every other object is unchanged.
Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#25073] `SqlDriver.issueAutonumbers` — the record number on request, ahead
5+
* of the write.
6+
*
7+
* `create()` / `bulkCreate()` fill an empty `autonumber` slot as the last step
8+
* before the INSERT, so a stored field whose `expression` reads the number was
9+
* computed by the engine before the number existed (null for a concatenation,
10+
* the `has()` fallback otherwise). The member issues the same number earlier,
11+
* on the caller's row, and `create()` then finds the slot filled and writes
12+
* the row as it stands: one statement, carrying the number the engine computed
13+
* from.
14+
*
15+
* What is pinned here is the member's own half, on a real SQLite database:
16+
*
17+
* 1. ISSUE fills what `create()` would have filled (same counter, same
18+
* organization scope), leaves a typed value alone, and `create()` of the
19+
* filled row draws no second number.
20+
* 2. AFTER A COLLISION it is `create()`'s #5495 re-seed split at the point
21+
* only the caller can go on: it judges the error as `create()` does,
22+
* re-seeds the stale counter once and EMPTIES the slot, so the caller's
23+
* next issue lands on MAX + 1. It never answers for a duplicate on a
24+
* value the caller typed, for a non-unique error, or inside a caller's
25+
* transaction.
26+
* 3. A batch: every row that drew from the stale counter is emptied (#6943's
27+
* per-counter reading), the counter re-seeded once.
28+
*
29+
* The engine's half (issue after validation, recompute, retry) is pinned in
30+
* `packages/runtime/src/autonumber-stored-expression.integration.test.ts`.
31+
*/
32+
33+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
34+
import { isUniqueViolationError } from '@objectstack/types';
35+
import { SqlDriver } from './index.js';
36+
37+
const SEQUENCES_TABLE = '_objectstack_sequences';
38+
39+
const CRM_CASE = {
40+
name: 'crm_case',
41+
fields: {
42+
organization_id: { type: 'string' },
43+
case_number: { type: 'autonumber', format: 'CASE-{00000}', unique: true },
44+
title: { type: 'string' },
45+
},
46+
} as any;
47+
48+
describe('[#25073] SqlDriver.issueAutonumbers', () => {
49+
let driver: SqlDriver;
50+
const knex = () => (driver as any).knex;
51+
52+
const lastValue = async (tenant: string): Promise<number | undefined> => {
53+
const rows = await knex()(SEQUENCES_TABLE).select('*');
54+
const row = rows.find((r: any) => r.object === 'crm_case' && r.field === 'case_number' && r.tenant_id === tenant);
55+
return row ? Number(row.last_value) : undefined;
56+
};
57+
58+
/** Land rows by a path that bypasses the counter, as a seed replay does. */
59+
const bypassInsert = async (org: string, from: number, to: number) => {
60+
const rows = [];
61+
for (let n = from; n <= to; n++) {
62+
rows.push({ id: `${org}-s${n}`, organization_id: org, case_number: `CASE-${String(n).padStart(5, '0')}`, title: `seed ${n}` });
63+
}
64+
await knex()('crm_case').insert(rows);
65+
};
66+
67+
/** The error a write raises, or a failure when it lands. */
68+
const refusal = async (write: Promise<unknown>): Promise<unknown> => {
69+
try {
70+
await write;
71+
} catch (error) {
72+
return error;
73+
}
74+
throw new Error('expected the write to be refused');
75+
};
76+
77+
beforeEach(async () => {
78+
driver = new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true });
79+
await driver.initObjects([CRM_CASE]);
80+
});
81+
82+
afterEach(async () => {
83+
await driver.disconnect();
84+
});
85+
86+
describe('issue', () => {
87+
it('fills the empty slot in place, from the counter create() draws from, and create() draws no second number', async () => {
88+
await driver.create('crm_case', { organization_id: 'orgA', title: 'by create' });
89+
expect(await lastValue('orgA')).toBe(1);
90+
91+
const row: Record<string, unknown> = { organization_id: 'orgA', title: 'ahead' };
92+
expect(await driver.issueAutonumbers('crm_case', [row])).toEqual([['case_number']]);
93+
expect(row.case_number).toBe('CASE-00002');
94+
expect(await lastValue('orgA')).toBe(2);
95+
96+
const stored = await driver.create('crm_case', row);
97+
expect(stored.case_number).toBe('CASE-00002');
98+
expect(await lastValue('orgA')).toBe(2);
99+
// And the counter simply goes on from there.
100+
expect((await driver.create('crm_case', { organization_id: 'orgA', title: 'next' })).case_number).toBe('CASE-00003');
101+
});
102+
103+
it('issues per organization from the call options, as create() stamps the organization first', async () => {
104+
await driver.create('crm_case', { organization_id: 'orgA', title: 'a1' });
105+
const row: Record<string, unknown> = { title: 'b, organization from options' };
106+
expect(await driver.issueAutonumbers('crm_case', [row], { tenantId: 'orgB' } as any)).toEqual([['case_number']]);
107+
expect(row.case_number).toBe('CASE-00001');
108+
expect(await lastValue('orgB')).toBe(1);
109+
expect(await lastValue('orgA')).toBe(1);
110+
// Only the number lands on the caller's row; `create()` stamps the organization.
111+
expect(row).not.toHaveProperty('organization_id');
112+
expect((await driver.create('crm_case', row, { tenantId: 'orgB' } as any)).organization_id).toBe('orgB');
113+
});
114+
115+
it('leaves a typed value alone and does not report it', async () => {
116+
const row: Record<string, unknown> = { organization_id: 'orgA', case_number: 'CASE-07777', title: 'import' };
117+
expect(await driver.issueAutonumbers('crm_case', [row])).toEqual([[]]);
118+
expect(row.case_number).toBe('CASE-07777');
119+
expect(await lastValue('orgA')).toBeUndefined();
120+
});
121+
122+
it('answers index-aligned with the rows of a batch', async () => {
123+
const rows: Record<string, unknown>[] = [
124+
{ organization_id: 'orgA', title: 'one' },
125+
{ organization_id: 'orgA', case_number: 'CASE-00500', title: 'typed' },
126+
{ organization_id: 'orgA', title: 'two' },
127+
];
128+
expect(await driver.issueAutonumbers('crm_case', rows)).toEqual([['case_number'], [], ['case_number']]);
129+
expect(rows.map((r) => r.case_number)).toEqual(['CASE-00001', 'CASE-00500', 'CASE-00002']);
130+
const stored = await driver.bulkCreate('crm_case', rows);
131+
expect(stored.map((r) => r.case_number)).toEqual(['CASE-00001', 'CASE-00500', 'CASE-00002']);
132+
});
133+
});
134+
135+
describe('after a collision', () => {
136+
it('re-seeds the counter it proves stale and empties the slot, so the next issue lands on MAX + 1', async () => {
137+
await bypassInsert('orgA', 9, 9);
138+
await driver.create('crm_case', { organization_id: 'orgA', title: 'first' });
139+
expect(await lastValue('orgA')).toBe(10);
140+
await bypassInsert('orgA', 11, 39);
141+
142+
const row: Record<string, unknown> = { organization_id: 'orgA', title: 'after the seeds' };
143+
await driver.issueAutonumbers('crm_case', [row]);
144+
expect(row.case_number).toBe('CASE-00011');
145+
const error = await refusal(driver.create('crm_case', row));
146+
expect(isUniqueViolationError(error)).toBe(true);
147+
148+
expect(await driver.issueAutonumbers('crm_case', [row], undefined, error)).toEqual([['case_number']]);
149+
expect(row).not.toHaveProperty('case_number');
150+
expect(await lastValue('orgA')).toBe(39);
151+
152+
expect(await driver.issueAutonumbers('crm_case', [row])).toEqual([['case_number']]);
153+
expect(row.case_number).toBe('CASE-00040');
154+
expect((await driver.create('crm_case', row)).case_number).toBe('CASE-00040');
155+
});
156+
157+
it('answers nothing, and changes nothing, for a duplicate on a value the caller typed', async () => {
158+
await driver.create('crm_case', { organization_id: 'orgA', case_number: 'CASE-00001', title: 'typed' });
159+
const row: Record<string, unknown> = { organization_id: 'orgA', case_number: 'CASE-00001', title: 'typed again' };
160+
expect(await driver.issueAutonumbers('crm_case', [row])).toEqual([[]]);
161+
const error = await refusal(driver.create('crm_case', row));
162+
expect(isUniqueViolationError(error)).toBe(true);
163+
164+
expect(await driver.issueAutonumbers('crm_case', [row], undefined, error)).toEqual([[]]);
165+
expect(row.case_number).toBe('CASE-00001');
166+
});
167+
168+
it('answers nothing for an error that is not a unique violation', async () => {
169+
const row: Record<string, unknown> = { organization_id: 'orgA', title: 'x' };
170+
await driver.issueAutonumbers('crm_case', [row]);
171+
const issued = row.case_number;
172+
expect(await driver.issueAutonumbers('crm_case', [row], undefined, new Error('SQLITE_BUSY: database is locked'))).toEqual([[]]);
173+
expect(row.case_number).toBe(issued);
174+
});
175+
176+
it('answers nothing inside a caller transaction, where create() retries nothing either', async () => {
177+
await driver.create('crm_case', { organization_id: 'orgA', title: 'seeds the counter at 1' });
178+
await bypassInsert('orgA', 2, 5);
179+
const row: Record<string, unknown> = { organization_id: 'orgA', title: 'in a transaction' };
180+
await driver.issueAutonumbers('crm_case', [row]);
181+
const error = await refusal(driver.create('crm_case', row));
182+
expect(isUniqueViolationError(error)).toBe(true);
183+
const trx = await knex().transaction();
184+
try {
185+
expect(await driver.issueAutonumbers('crm_case', [row], { transaction: trx } as any, error)).toEqual([[]]);
186+
} finally {
187+
await trx.rollback();
188+
}
189+
expect(row.case_number).toBe('CASE-00002');
190+
expect(await lastValue('orgA')).toBe(2);
191+
});
192+
193+
it('empties every row of a batch that drew from the stale counter, and re-seeds it once', async () => {
194+
await bypassInsert('orgA', 1, 1);
195+
await driver.create('crm_case', { organization_id: 'orgA', title: 'seeds the counter at 2' });
196+
await bypassInsert('orgA', 3, 3);
197+
198+
const rows: Record<string, unknown>[] = [
199+
{ organization_id: 'orgA', title: 'collides on 3' },
200+
{ organization_id: 'orgA', title: 'would be 4, did not collide' },
201+
{ organization_id: 'orgB', title: 'another counter' },
202+
];
203+
await driver.issueAutonumbers('crm_case', rows);
204+
expect(rows.map((r) => r.case_number)).toEqual(['CASE-00003', 'CASE-00004', 'CASE-00001']);
205+
const error = await refusal(driver.bulkCreate('crm_case', rows));
206+
207+
expect(await driver.issueAutonumbers('crm_case', rows, undefined, error)).toEqual([['case_number'], ['case_number'], []]);
208+
expect(rows[0]).not.toHaveProperty('case_number');
209+
expect(rows[1]).not.toHaveProperty('case_number');
210+
expect(rows[2].case_number).toBe('CASE-00001');
211+
expect(await lastValue('orgA')).toBe(4);
212+
expect(await lastValue('orgB')).toBe(1);
213+
});
214+
});
215+
});

‎packages/drivers/driver-sql/src/sql-driver.ts‎

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8489,6 +8489,98 @@ export class SqlDriver implements IDataDriver {
84898489
return reservations;
84908490
}
84918491

8492+
/**
8493+
* The reservations {@link issueAutonumbers} made, keyed by the caller's row
8494+
* object, so its collision call can tell a number it issued from one the
8495+
* caller typed — the distinction `create()` draws from its own local
8496+
* `reservations`. Weak, so a row the caller drops takes its entry with it.
8497+
*/
8498+
private readonly issuedAutoNumbers = new WeakMap<object, AutoNumberReservation[]>();
8499+
8500+
/**
8501+
* [#25073] `IDataDriver.issueAutonumbers`: {@link fillAutoNumberFields} on
8502+
* request, ahead of the write, so the engine can compute a stored field
8503+
* whose `expression` reads the number before it hands the row to
8504+
* {@link create} / {@link bulkCreate}. Those find the slot filled and
8505+
* reserve nothing, so the row goes in as ONE statement carrying the number
8506+
* the engine computed from.
8507+
*
8508+
* After a collision it is {@link create}'s and {@link bulkCreate}'s own
8509+
* re-seed (#5495, #6943), split at the point where only the caller can go
8510+
* on: which numbers collided is decided as there
8511+
* ({@link collidingAutoNumberReservations}, so a duplicate on a value the
8512+
* caller typed is never this counter's), each stale counter is re-seeded
8513+
* ONCE, and every slot drawn from a stale counter is emptied — on a batch
8514+
* that includes rows whose own value did not collide, for the reason
8515+
* {@link bulkCreate} gives. The caller issues again; this method never
8516+
* re-issues on its own, because the caller has to recompute what reads
8517+
* the number. Inside a caller's transaction nothing is retried, as
8518+
* {@link create} retries nothing there (Postgres has aborted the handle).
8519+
*
8520+
* Inherited by `driver-sqlite-wasm` and `driver-turso`: the remote face
8521+
* issues through its own `getNextSequenceValue` and probes through its own
8522+
* `autoNumberValueExists` / `resyncSequenceToDataMax`, the overrides its
8523+
* `create()` already calls.
8524+
*/
8525+
async issueAutonumbers(
8526+
object: string,
8527+
rows: Record<string, unknown>[],
8528+
options?: DriverOptions,
8529+
afterCollision?: unknown,
8530+
): Promise<string[][]> {
8531+
if (afterCollision === undefined) {
8532+
const filled: string[][] = [];
8533+
for (const row of rows) {
8534+
if (!row || typeof row !== 'object') {
8535+
filled.push([]);
8536+
continue;
8537+
}
8538+
// Filled as `create()` fills it: the caller's organization stamped
8539+
// first (numbers are issued per organization), on a view, so only
8540+
// the numbers land on the caller's row.
8541+
const view: Record<string, any> = { ...row };
8542+
this.injectTenantOnInsert(object, view, options);
8543+
const reserved = await this.fillAutoNumberFields(object, view, options);
8544+
for (const r of reserved) row[r.field] = view[r.field];
8545+
// A value kept from an earlier issue on this row is still this
8546+
// driver's, so it stays described beside the fresh ones (#6943).
8547+
const fresh = new Set(reserved.map((r) => r.field));
8548+
const kept = (this.issuedAutoNumbers.get(row) ?? []).filter((r) => !fresh.has(r.field));
8549+
this.issuedAutoNumbers.set(row, [...kept, ...reserved]);
8550+
filled.push(reserved.map((r) => r.field));
8551+
}
8552+
return filled;
8553+
}
8554+
8555+
const none = rows.map((): string[] => []);
8556+
if (options?.transaction !== undefined) return none;
8557+
const issued = rows.map((row) => (row && typeof row === 'object' ? this.issuedAutoNumbers.get(row) ?? [] : []));
8558+
const colliding = await this.collidingAutoNumberReservations(afterCollision, issued.flat(), options);
8559+
if (colliding.length === 0) return none;
8560+
8561+
const stale = new Set<string>();
8562+
for (const reservation of colliding) {
8563+
const key = this.autoNumberCounterKey(reservation);
8564+
if (stale.has(key)) continue;
8565+
stale.add(key);
8566+
await this.resyncSequenceToDataMax(reservation);
8567+
}
8568+
return rows.map((row, i) => {
8569+
const emptied: string[] = [];
8570+
const kept: AutoNumberReservation[] = [];
8571+
for (const reservation of issued[i]) {
8572+
if (stale.has(this.autoNumberCounterKey(reservation))) {
8573+
delete row[reservation.field];
8574+
emptied.push(reservation.field);
8575+
} else {
8576+
kept.push(reservation);
8577+
}
8578+
}
8579+
if (emptied.length > 0) this.issuedAutoNumbers.set(row, kept);
8580+
return emptied;
8581+
});
8582+
}
8583+
84928584
/**
84938585
* Does a row already hold `reservation.value` in the partition this
84948586
* counter covers? (#5495)

‎packages/drivers/driver-turso/src/turso-driver.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1051,6 +1051,11 @@ export const REMOTE_FACE_ANSWERS = {
10511051
// Deprecated aliases: they call `this.commit` / `this.rollback`, which refuse.
10521052
commitTransaction: 'inherited',
10531053
rollbackTransaction: 'inherited',
1054+
// [#25073] Record numbers ahead of the write: `fillAutoNumberFields` and the
1055+
// collision re-seed, which already reach the remote database through this
1056+
// face's own `getNextSequenceValue`, `autoNumberValueExists` and
1057+
// `resyncSequenceToDataMax` (see "Autonumber on the remote face" above).
1058+
issueAutonumbers: 'inherited',
10541059
// Knex-only reads.
10551060
findWithWindowFunctions: 'refused',
10561061
analyzeQuery: 'refused',

0 commit comments

Comments
 (0)