Skip to content

Commit e35c40a

Browse files
feat(driver-turso): the remote transport issues auto_number values from the shared persistent sequence (#21113) (#21160)
Fixes #21113 Clause-②: yes (widening) ## Patch round 1 (review 5930100091) The one blocking item was `@objectstack/driver-sql` at `patch` beside a root `type` re-export (`AutoNumberReservation`). Branch taken: the re-export is dropped and the two re-seed overrides in `turso-driver.ts` are typed `Parameters` indexed over `SqlDriver`'s `autoNumberValueExists` (its second parameter) and `resyncSequenceToDataMax` (its first) — the premise (the protected member's parameter type is reachable from the subclass without the export) measured true: `driver-sql` typecheck exit 0, `driver-turso` typecheck exit 0, no structural copy. `driver-sql`'s export list is byte-identical to `main`, so `patch` stands; the changeset sentence that named the export now reads "No export is added". `origin/main` merged again (`fde553c50`, which also lands the `agentGuidance: false` opt-out of #21151); re-run on the merged tree: closure build exit 0, both typechecks exit 0, `driver-turso` `82 passed (82)` files / `2218 passed | 33 skipped` (83 files / 2219 with the throwaway probe present), `driver-sql` autonumber suites `66 passed | 2 skipped`, and the changeset, ADR-0087, nul-bytes and published-files gates all exit 0. Finding (b) was measured on the remote face as the review asked and reproduces; it is in the round-1 report on #21113 for the seat to file. ## What this does Disposition A of the remote-autonumber question, executed: a `create`, `bulkCreate` or `upsert` on the Turso REMOTE transport that leaves an `auto_number` slot empty now gets a generated value, issued atomically in the database from the same persistent `_objectstack_sequences` counter the local and embedded-replica faces draw from. `supports.autonumber` stays `true` on the remote face and is now honoured. The `NOT_IMPLEMENTED` / 501 refusal is gone; its suite is converted into the generation suite. Why now: the appetite door the refusal left shut (「for want of measured demand」) was met by objectstack-ai/cloud#2531 — `POST /api/v1/data/crm_account` answers `501` on a hosted tenant, whose database is on this transport, so no object declaring an `auto_number` field could get a new record on the hosted product. That card carries the cloud half, `Blocked-by:` this one. ## How, and what is shared rather than copied **Where the slots are filled.** On the driver, one layer above the statement builder, exactly where the refusal was raised: `RemoteTransport.create(object, data)` takes no schema and caches none (pinned), while `TursoDriver` holds `autoNumberFields` from remote schema sync. The remote `create` / `upsert` / `bulkCreate` call the inherited `SqlDriver.fillAutoNumberFields` on a copy of the caller's row, then hand the filled row to the transport, which builds the same `INSERT` it built for a caller-supplied number. **One semantics (H3).** The rules that decide WHICH counter a value is drawn from and WHERE a cold counter starts are `SqlDriver`'s and are called, never copied. Lifted into `protected` members in the sequence region of `sql-driver.ts` (`:7532`–`:8190` on this head; nothing else in that file): `resolveSequenceTenantId` (replaces three inline copies), `defineSequencesTable` (the table's one definition — the remote face compiles it to text with the connection-less Knex it already holds and sends that), `maxAutonumberCounter` + `escapeLikePrefix` (the bootstrap reading, suffix included, split from the Knex statement that fetches the values), `sequencesTableName`, `autoNumberCollisionRetries`. The two re-seed overrides take their parameter types through `Parameters` indexed over `SqlDriver`'s protected members, so `driver-sql`'s export list is unchanged. The format rendering and precedence were already shared (`resolveAutonumberFormat` at registration, `renderAutonumber` / `missingFieldValues` in `fillAutoNumberFields`), and are reached by calling that method. **What is this face's own: how the counter moves (H2).** `TursoDriver.getNextSequenceValue` routes by transport. Local and replica keep the inherited Knex transaction. Remote moves the counter in ONE statement: ``` warm: UPDATE _objectstack_sequences SET last_value = last_value + 1 WHERE key_hash = ? RETURNING last_value cold: scan the data table's MAX in JS by the shared reading, then INSERT … VALUES (…, max + 1) ON CONFLICT (key_hash) DO UPDATE SET last_value = last_value + 1 RETURNING last_value ``` Each is a single SQLite statement serialised by the database's write lock across connections and processes; no in-process state takes part (`remoteSequencesTableEnsured` remembers only that the TABLE exists). Two cold writers both scan and both INSERT; one wins the row, the other's `ON CONFLICT` arm increments it. H2 falsified on one point, reported rather than followed: the PM expected the seed folded into the statement as a SQL `MAX(…)`. It is not, because the bootstrap reading strips a declared suffix and reads the digit run after the prefix (`readAutonumberCounter`, #6468), which a dialect expression would be a second copy of — the exact collision H3 forbids. A Hrana batch was not needed either: the JS reading has to sit between the scan and the insert, and both insert arms are atomic on their own. **The legs (H4).** `create`, `bulkCreate` and `upsert` all generate. `bulkCreate` on the remote face now runs through the driver's own `create` per row, which is what `RemoteTransport.bulkCreate` always did underneath (it loops the transport's `create`: one INSERT and one read-back per row, never one statement — so the old comment's 「all-or-nothing on this transport too」 was not true, and the statements sent and the mid-batch failure state are unchanged). `upsert` reserves before the statement, as `SqlDriver.upsert` does, and names the autonumber columns to the transport as insert-only (`RemoteTransport.upsert` gains an optional fifth argument, schema-free: WHICH columns, not WHY), so a merge keeps the number already in the row (#7011) and the insert leg — the one #7099 recorded as writing NULL — gets its number. The reservation a merge does not use is a gap, never a renumbering, the local face's rule byte for byte (pinned: seeded 42, merge, next create is 44). `#7099`'s post-write warning is removed with the leg it reported. **Legacy shape and collision re-seed (H5), decided from the code.** The pre-`key_hash` table: `SqlDriver.ensureSequencesKeyHashShape` rebuilds it through a live Knex connection, which this face has none of, and a raw-SQL rewrite would be a second copy of a migration; keying by the legacy `(object, tenant_id, field)` rule would be a second keying rule beside the shared one. So it is **refused**, `DATABASE_ERROR` / 500 with the remedy in the message (open the database once through the local or replica face), not cached as a verdict, caller-supplied numbers still written. It cannot arise on a database this face created. The #5495 re-seed is **needed** on this face — the bypass paths that create a stale counter (`isSystem` seed replay, `preserveAudit` import) are exactly the seed/import paths, and `bulkCreate` is their common door — and it is carried: `autoNumberValueExists` and `resyncSequenceToDataMax` are overridden for the remote face (the forward-only move is one atomic UPDATE guarded by `last_value` being below the observed MAX), and `collidingAutoNumberReservations` is the inherited discriminator, so a duplicate on a value the caller typed stays the caller's 409 (pinned). **H6.** `Clause-②: yes (widening)` holds as measured: a create refused 501 today is accepted; no spec key moves; no export is added (the six lifted members are `protected`, and the overrides are typed through indexed types rather than a root re-export); `RemoteTransport.upsert` gains an optional parameter. Changeset: `@objectstack/driver-turso` minor, `@objectstack/driver-sql` patch. ## Tests Head for every number below: `52ab7ad76` (the merge of `origin/main` `1bd14c984`), unless stated. - **Converted, not deleted:** `turso-remote-autonumber-refusal.test.ts` → `turso-remote-autonumber-generation.test.ts` (`git mv` then rewritten; git's rename detection does not fire at this similarity, so the diff reads D+A — the three-face structure, the controls, the `update()` / plain-object / caller-supplied cases and the layer pins are the refusal suite's own). 27 cases: generation on every leg incl. the #7099 leg; caller-supplied numbers, merge, update, plain object untouched; the two-face block (local Knex face and remote native-libsql face on ONE file: numbers interleave 1,2,3,4 on one `key_hash` row equal to the local face's `sequenceKeyHash`; per-tenant buckets; the #6468 suffix seed read as 5 not 52026; the `{field}` refusal is the same sentence on both faces and writes nothing); the #5495 re-seed on `create` and `bulkCreate`; the caller's own 409; the legacy-shape refusal with `code` + `status`; local and replica still issue; `RemoteTransport.prototype` still has no autonumber member and `create.length === 2`; `supports.autonumber === true`. - **The two boundary pins rewritten, not deleted** (`turso-autonumber-resync.test.ts`, `turso-autonumber-batch-resync.test.ts`): the transport-surface probe verbatim, and the refusal half replaced by generation + re-seed on the remote face. - **Cross-process pin** `turso-remote-autonumber-concurrency.test.ts`: two child processes (`tsx` over the package source, as `packages/spec`'s process-boundary pins do; `tsx` added as a devDependency of `driver-turso`), each with its own `@libsql/client` native `file:` connection to one database file and its own remote-mode `TursoDriver`, released together by a file barrier; 2 × 200 creates per round → 400 distinct, exactly 1..400, strictly increasing per writer, no failed write, the table and the counter row agreeing. Those are the hard pins, asserted on every round. The overlap EVIDENCE (the writer changes at least twice in the global order) ends the loop on the first round that shows it; after three rounds without one the test is skipped with a note that says NOT MEASURED — because whether two processes overlap is the scheduler's decision, measured here once as a strictly serial run (1 change) under another seat's full-package run on this shared box. Final-head runs: 3/3 green standalone, measuring `400 writes · 400 distinct` with 31, 21 and 23 writer changes; inside the full package run, 9. **Said plainly:** no `sqld` runs in this container; what is measured is two processes on libSQL's engine under its write lock, not an HTTP server. - Driver-conformance ledger (lane commitment): identical before and after — `50 covered cell(s), 0 in the DEBT ledger, 0 exempt`; the matrix has no autonumber cell for any driver, so none moved. Runs, all under `scripts/pm/os-verify-lock.sh` after the merge: `pnpm --filter '@objectstack/driver-turso^...' build` exit 0 · `driver-sql` typecheck exit 0 · `driver-turso` typecheck exit 0 · `driver-turso` `vitest run`: `82 passed (82)` files, `2218 passed | 33 skipped (2251)` · `driver-sql` `vitest run` in two halves of 108 files: `103 passed | 5 skipped`, `1389 passed | 100 skipped`; `102 passed | 6 skipped`, `1914 passed | 88 skipped` (every file; the autonumber-named nine also run on their own: `66 passed | 2 skipped`). ## Reverse verification (predicted before each run; mutation and restore proven on disk by `scripts/ablation-replace.mjs`, blob == HEAD and `git diff HEAD` empty after each leg) 1. **Merge-set exclusion dropped** (`RemoteTransport.upsert` ignores `insertOnlyColumns`). Predicted: exactly the two `[#7011]` merge pins red. Measured: `2 failed | 25 passed`, both merge pins, `expected 'CASE-00043' to be 'CASE-00042'` and `expected 'CASE-00099' to be 'CASE-00042'` — the renumbering the exclusion exists to prevent. 2. **Warm path made non-atomic** (SELECT `last_value`, then UPDATE to `+1`). Predicted: the distinct count in the concurrency pin drops below N; the stub-backed generation suite stays green. Measured — **direction reversed, kept rather than tidied**: the generation suite stayed green (`27 passed`) as predicted, but the concurrency pin went red on its 「no write failed」 assertion, not on the distinct count: the fixture's tenant-scoped unique index refused the second row carrying a duplicated number, and the forward-only re-seed could not outrun a writer that kept reading the same stale value, so writes failed with `SQLITE_CONSTRAINT: UNIQUE constraint failed: index 'uniq_crm_case_organization_id_case_number'` — 2 of 3 runs with no gap between the two statements, 3 of 3 with a 1 ms gap (the order of one HTTP round trip). On a declared-unique column a counter that hands out a number twice surfaces as refused writes before duplicate rows; recorded in the pin's docblock. 3. **Shared bootstrap reading zeroed** in `driver-sql` (`maxAutonumberCounter`, reached through `dist/`: rebuilt, `ablation-dist-preflight` present in 2 built files, exit 0). Predicted: the bootstrap and re-seed pins red on BOTH faces. Measured: `10 failed | 25 passed` across the three suites — generation: explicit-then-continues-above, merge-then-44, #6468 suffix, re-seed `create`, re-seed `bulkCreate`; resync: LOCAL and REMOTE re-seed; batch-resync: LOCAL `bulkCreate`, LOCAL `upsert`, REMOTE batch. Restore: rebuilt, preflight `--absent` exit 0. A first attempt of this ablation was a null operation (the tool refused because the replacement restated the anchor; the preflight correctly reported the marker absent and that green run is void) and was redone with a different anchor. ## Gates Derived with `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` on `52ab7ad76` (no paths; 73 commands). Run on the final tree (`2c9d2985a`, every file identical to the commit): the 73 derived commands — **72 exited 0**, incl. `check:driver-conformance`, `check:query-options-erasure` (holds: 67 unswept non-test sites, none new — my first head had added one `as any` on a `find()` query in the new concurrency pin, retyped as `DriverQuery`), `check:test-source-alias`, `check:undeclared-dep-imports`, `check:doc-authoring`, `check:nul-bytes`, `check:lean-entry-closure` (measured after building `@objectstack/objectql`: 15 packages, 201/199 modules, admitted set held), `check:type-check-debt` (399.5 s re-measure, none above record), `check:pm-skill-ratchet` (0 with AGENTS.md at the merge-base blob — the turbo 2.11.5 agent-rules block, #21146, had rewritten it once in this worktree and is restored). `check:dts-closure` exited 1 on its first final-tree run naming `plugin-email` and `plugin-security` (neither in this diff nor in its closure) with `dist/index.d.ts` absent at that moment; a forced rebuild of the two and a rerun exits 0 (71 packages, 167/167 declarations) — concurrent turbo activity in the same tree, not this change. **NOT MEASURED (1):** `check:dual-build-cjs-loads` exits 3 PREREQUISITE NOT MET locally (78 packages without `dist/` in this worktree) — CI's. `dispatch-gates --ran`: 73 derived, 72 run, 1 NOT-MEASURED declared, 0 UNRUN (exit 0). Seven further families (`agent-test-spelling`, `docs-audit-scope`, `pm-governed-merges`, `pm-governed-prose`, `pm-skill-id-lint`, `pm-skill-ratchet`, `required-contexts`) were derived only while the turbo block sat in AGENTS.md; they were run anyway and all exit 0. The derivation itself still lists what no local run covers: the 53 artifact-roster families, 11 wide-population families, 6 path-scheduled CI jobs and the type-check lanes. `pnpm lint` narrowed, as a measurement: ① population = the lint script's own `eslint . --no-inline-config`, under one `eslint.config.mjs`; ② `--format json` on the changed source files: 9 files, 0 errors, 0 warnings; ③ invariance: the config enables no type-aware linting for any file (`eslint.config.mjs` states it, no `parserOptions.project`), so this diff moves no untouched file's verdict. ## Acceptance notes - Both faces: SQLite's `LIKE` has no escape character unless `ESCAPE` is declared, and the shared `escapeLikePrefix` writes backslashes. The remote statement declares `ESCAPE '\'`; the local Knex builder in `scanMaxNumericTail` does not (knex emits `like ?` with the escaped binding and no `ESCAPE`, measured on the sqlite and pg dialects), so on the local SQLite faces a format whose prefix contains `_` or `%` seeds from 0 (measured: `SO\_%` matches nothing on SQLite without `ESCAPE`). Pre-existing, outside this card's class; filed in the report as a finding, not fixed here. - `RemoteTransport.upsert` with caller `conflictKeys` leaves `id` in the merge set (`"id" = excluded."id"`) — the #8622 shape the local face closed. Not touched; noted for the lane. - The README's remote refusal table loses its 「Record numbers」 row; `content/docs/**` carries no statement of the remote refusal (grepped). ## 维护者速读(草稿) - **改了什么:** Turso 远程传输面上,`create` / `bulkCreate` / `upsert` 留空的 `auto_number` 字段现在会生成编号,编号来自与本地面相同的持久计数表,在数据库里原子递增;原先的 501 拒绝改为生成。 - **为什么改:** 托管产品的租户库全部走远程传输,任何声明了 `auto_number` 字段的对象都无法新建记录(cloud#2531 实测:HotCRM 建客户答 501)。这正是 #6944 当初留下的「等实测需求」的门,现在门开了。 - **风险与代价(含回滚):** 多进程并发只靠数据库写锁串行,无进程内计数;两个面共用一张计数表、一套键规则,已在同一数据库文件上对拍。遗留形状的计数表在远程面上明确拒绝、不迁移。回滚即还原本 PR;已生成的编号留在数据里,计数表形状与本地面一致。 - **席位意见:** (留空) - **你要做的:** 批准后 cloud#2531 推进 pin 并在托管环境复验建客户。 --- _Generated by [Claude Code](https://claude.ai/code/session_01Ujdtvqs7ree7WyQmEDwEnG)_ --------- Co-authored-by: Claude Code <noreply@anthropic.com>
1 parent f0cc16e commit e35c40a

13 files changed

Lines changed: 1587 additions & 840 deletions
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
---
2+
'@objectstack/driver-turso': minor
3+
'@objectstack/driver-sql': patch
4+
---
5+
6+
feat(driver-turso): the remote transport issues `auto_number` values (#21113)
7+
8+
Clause-②: yes (widening)
9+
10+
A `create()`, `bulkCreate()` or `upsert()` on the Turso REMOTE transport that
11+
leaves an `autonumber` field empty (`undefined`, `null` or `''`) now gets a
12+
generated value. It used to be refused with `NOT_IMPLEMENTED` / 501, so on a
13+
hosted tenant database — which is on this transport — no object declaring an
14+
`auto_number` field could get a new record at all. Nothing is declared anew in
15+
the spec or in the package exports; `supports.autonumber` stays `true` and is
16+
now honoured.
17+
18+
- The value comes from the same persistent `_objectstack_sequences` counter the
19+
local and embedded-replica transports use, rendered by the same format rules
20+
(`autonumberFormat` / `format`, organization scope, date and `{field}`
21+
tokens), bootstrapped from the table's highest existing value by the same
22+
reading, and re-seeded the same way after rows land above the counter by a
23+
seed replay or import. A remote driver and an embedded replica of one
24+
database draw from one counter row.
25+
- The counter moves in one statement over the connection (`UPDATE … RETURNING`,
26+
or on a cold counter `INSERT … ON CONFLICT (key_hash) DO UPDATE … RETURNING`),
27+
so writers in different processes never draw the same number.
28+
- An `upsert()` that merges into an existing row keeps the number already in
29+
the row; a row that carries its own number is written unchanged.
30+
- A `_objectstack_sequences` table in the pre-`key_hash` shape is refused in
31+
remote mode with `DATABASE_ERROR` / 500 and the remedy in the message (open
32+
the database once through the local or embedded-replica transport, which
33+
migrates it); remote mode does not migrate it and does not key by the legacy
34+
rule.
35+
- `RemoteTransport.upsert()` takes an optional fifth argument naming columns
36+
that are written on insert and left alone on merge.
37+
38+
`@objectstack/driver-sql`: the sequence rules a second transport shares are
39+
now `protected` members of `SqlDriver` (`resolveSequenceTenantId`,
40+
`defineSequencesTable`, `maxAutonumberCounter`, `escapeLikePrefix`,
41+
`sequencesTableName`, `autoNumberCollisionRetries`). No export is added and no
42+
behaviour changes on any dialect.

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

Lines changed: 76 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -7347,6 +7347,56 @@ export class SqlDriver implements IDataDriver {
73477347
.digest('hex');
73487348
}
73497349

7350+
// ── The sequence semantics a second face shares, stated once ──────────────
7351+
//
7352+
// `TursoDriver`'s REMOTE face issues record numbers over `@libsql/client`
7353+
// rather than through this class's Knex transaction, and an embedded-replica
7354+
// face and a remote face can point at ONE database. Two faces computing a
7355+
// different counter key, a different tenant bucket or a different data-table
7356+
// bootstrap would hand out colliding numbers on that database, so each rule
7357+
// below is a `protected` member that face CALLS — never a copy it keeps. The
7358+
// members are small on purpose: they are the whole of what decides which
7359+
// counter a value is drawn from and where a cold counter starts.
7360+
7361+
/** The sequence-counter table's name, for a face that spells its own statements against it. */
7362+
protected get sequencesTableName(): string {
7363+
return SEQUENCES_TABLE;
7364+
}
7365+
7366+
/** How many times a write re-seeds and retries on a provable autonumber collision (#5495). */
7367+
protected get autoNumberCollisionRetries(): number {
7368+
return AUTONUMBER_COLLISION_RETRIES;
7369+
}
7370+
7371+
/**
7372+
* The tenant bucket a counter row is keyed by: the row's tenant when the
7373+
* object HAS a tenant column and the row carries one, else the platform
7374+
* bucket `GLOBAL_TENANT`. One spelling for the issue path, the re-seed path
7375+
* and the counter identity, which used to carry it three times.
7376+
*/
7377+
protected resolveSequenceTenantId(tenantField: string | null, tenantId: string | null): string {
7378+
return tenantField && tenantId ? String(tenantId) : GLOBAL_TENANT;
7379+
}
7380+
7381+
/**
7382+
* The column definitions of the `key_hash`-keyed sequences table — the ONE
7383+
* place the shape is spelled. {@link createSequencesTable} runs it through a
7384+
* live connection; a face with no Knex connection compiles the same builder
7385+
* to text (`knex.schema.createTable(name, t => this.defineSequencesTable(t)).toSQL()`)
7386+
* and sends that, so both faces create byte-for-byte the same table.
7387+
*/
7388+
protected defineSequencesTable(t: Knex.CreateTableBuilder): void {
7389+
t.string('key_hash', 64).notNullable().primary();
7390+
t.string('object').notNullable();
7391+
t.string('tenant_id').notNullable();
7392+
t.string('field').notNullable();
7393+
// Non-indexed, so it is free of the PK length limit — a long `{plan_no}`
7394+
// composite scope fits. 1024 is far above any realistic rendered prefix.
7395+
t.string('scope', 1024).notNullable().defaultTo('');
7396+
t.bigInteger('last_value').notNullable().defaultTo(0);
7397+
t.timestamp('updated_at').defaultTo(this.knex.fn.now());
7398+
}
7399+
73507400
/**
73517401
* Create the current `key_hash`-keyed sequences table shape. `runner` is the
73527402
* connection the DDL runs on (a fresh pooled connection by default, or the
@@ -7356,17 +7406,7 @@ export class SqlDriver implements IDataDriver {
73567406
table: string,
73577407
runner: Knex | Knex.Transaction = this.knex,
73587408
): Promise<void> {
7359-
await runner.schema.createTable(table, (t) => {
7360-
t.string('key_hash', 64).notNullable().primary();
7361-
t.string('object').notNullable();
7362-
t.string('tenant_id').notNullable();
7363-
t.string('field').notNullable();
7364-
// Non-indexed, so it is free of the PK length limit — a long `{plan_no}`
7365-
// composite scope fits. 1024 is far above any realistic rendered prefix.
7366-
t.string('scope', 1024).notNullable().defaultTo('');
7367-
t.bigInteger('last_value').notNullable().defaultTo(0);
7368-
t.timestamp('updated_at').defaultTo(this.knex.fn.now());
7369-
});
7409+
await runner.schema.createTable(table, (t) => this.defineSequencesTable(t));
73707410
}
73717411

73727412
/**
@@ -7497,16 +7537,34 @@ export class SqlDriver implements IDataDriver {
74977537
tenantId: string | null,
74987538
suffix = '',
74997539
): Promise<number> {
7500-
const escapedPrefix = prefix.replace(/([\\%_])/g, '\\$1');
7501-
let builder = queryRunner(tableName).select(field).where(field, 'like', `${escapedPrefix}%`).whereNotNull(field);
7540+
let builder = queryRunner(tableName).select(field).where(field, 'like', `${this.escapeLikePrefix(prefix)}%`).whereNotNull(field);
75027541
if (tenantField && tenantId !== null) {
75037542
builder = builder.where(tenantField, tenantId);
75047543
}
75057544
const rows = await builder;
7545+
return this.maxAutonumberCounter((rows as any[]).map((r) => (r as any)[field]), prefix, suffix);
7546+
}
7547+
7548+
/**
7549+
* The rendered prefix as a `LIKE` anchor: `\`, `%` and `_` escaped with a
7550+
* backslash, so a prefix is matched literally. The predicate's pre-filter
7551+
* only — {@link maxAutonumberCounter} re-checks the prefix per row.
7552+
*/
7553+
protected escapeLikePrefix(prefix: string): string {
7554+
return prefix.replace(/([\\%_])/g, '\\$1');
7555+
}
7556+
7557+
/**
7558+
* The bootstrap reading itself — the highest counter among the stored values
7559+
* of one counter's partition, under the anchored / unanchored rules the
7560+
* {@link scanMaxNumericTail} docblock states. Separated from the statement
7561+
* that fetches the values so a face that fetches them through another
7562+
* transport reads them by this one rule rather than by a copy of it.
7563+
*/
7564+
protected maxAutonumberCounter(values: Iterable<unknown>, prefix: string, suffix: string): number {
75067565
let maxN = 0;
75077566
const anchored = prefix !== '' || suffix !== '';
7508-
for (const r of rows as any[]) {
7509-
const v: string = (r as any)[field];
7567+
for (const v of values) {
75107568
if (typeof v !== 'string') continue;
75117569
let n: number;
75127570
if (anchored) {
@@ -7647,7 +7705,7 @@ export class SqlDriver implements IDataDriver {
76477705
// locking on a second one (SQLite pool max=1). `initObjects` normally warms
76487706
// this up front, making the call a no-op — this only bites the lazy path.
76497707
await this.ensureSequencesTable(parentTrx);
7650-
const resolvedTenantId = tenantField && tenantId ? String(tenantId) : GLOBAL_TENANT;
7708+
const resolvedTenantId = this.resolveSequenceTenantId(tenantField, tenantId);
76517709
if (scope !== '' && !this.sequencesHasKeyHash) {
76527710
// The legacy sequences table could not be migrated to the key_hash shape,
76537711
// so it cannot represent per-scope counters. Fail with a clear, actionable
@@ -7907,8 +7965,7 @@ export class SqlDriver implements IDataDriver {
79077965
*/
79087966
protected async resyncSequenceToDataMax(reservation: AutoNumberReservation): Promise<void> {
79097967
await this.ensureSequencesTable();
7910-
const resolvedTenantId =
7911-
reservation.tenantField && reservation.tenantId ? String(reservation.tenantId) : GLOBAL_TENANT;
7968+
const resolvedTenantId = this.resolveSequenceTenantId(reservation.tenantField, reservation.tenantId);
79127969
const key = this.sequencesHasKeyHash
79137970
? { key_hash: this.sequenceKeyHash(reservation.tableName, resolvedTenantId, reservation.field, reservation.scope) }
79147971
: { object: reservation.tableName, tenant_id: resolvedTenantId, field: reservation.field };
@@ -7942,8 +7999,7 @@ export class SqlDriver implements IDataDriver {
79427999
* that drew from it. See {@link bulkCreate}.
79438000
*/
79448001
protected autoNumberCounterKey(reservation: AutoNumberReservation): string {
7945-
const resolvedTenantId =
7946-
reservation.tenantField && reservation.tenantId ? String(reservation.tenantId) : GLOBAL_TENANT;
8002+
const resolvedTenantId = this.resolveSequenceTenantId(reservation.tenantField, reservation.tenantId);
79478003
return this.sequenceKeyHash(reservation.tableName, resolvedTenantId, reservation.field, reservation.scope);
79488004
}
79498005

‎packages/drivers/driver-turso/README.md‎

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,6 @@ table lists the ones that answer differently from local mode.
130130
| Operation | Refused call | Use instead |
131131
|:---|:---|:---|
132132
| Transactions | `beginTransaction()`, `commit()`, `rollback()` (and their deprecated aliases `commitTransaction()` and `rollbackTransaction()`), and `options.transaction` passed to any `RemoteTransport` method in the tree above, to `aggregate()` or to `syncSchemasBatch()` | The local or embedded-replica transport, which run Knex transactions and honour `options.transaction` |
133-
| Record numbers | `create()`, `bulkCreate()`, and an `upsert()` with no `id`, `_id` or `conflictKeys`, when a row leaves an `autonumber` field empty (`undefined`, `null` or `''`) | The local or embedded-replica transport, which generate record numbers, or a value you supply, which is written unchanged |
134133
| Deferring schema DDL | `setDeferredDdl(true)`, which `os migrate plan` calls. `setDeferredDdl(false)` is accepted | Run the command against a local SQLite copy of the database (a `file:` URL) |
135134
| Schema drift detection | `detectManagedDrift()` | `os migrate plan` against a local SQLite copy of the database (a `file:` URL) |
136135
| Planning the ADR-0104 media column move | `planMediaColumnMove()`, the column step of `os migrate files-to-references` | The local or embedded-replica transport, which plan it |
@@ -147,18 +146,30 @@ table lists the ones that answer differently from local mode.
147146
reads that and does not call `beginTransaction()`: it runs the callback with
148147
no transaction and logs a warning once per datasource, or, called with
149148
`require: true`, throws `TransactionUnsupportedError` before the callback runs.
150-
- **Record numbers.** Remote mode never generates one. The check uses the
151-
`autonumber` fields this driver recorded when it synced the object's schema
152-
(`syncSchema`, `syncSchemasBatch` or `initObjects`). An `upsert()` that
153-
carries an `id`, `_id` or `conflictKeys` is not refused, because it may merge
154-
into an existing row. If it inserts instead, the row is written with the field
155-
empty and the driver logs a warning naming the row.
156149
- **Aggregation.** `aggregate()` called on the driver directly also refuses,
157150
with the same code, a `groupBy` entry that has a `dateGranularity`, and an
158151
`aggregations` entry with a non-empty `filter`. `engine.aggregate()` never
159152
sends either one to this driver: it fetches the rows and computes both in
160153
memory. The local transport also refuses a per-aggregation `filter`.
161154

155+
#### Record numbers in remote mode
156+
157+
An `autonumber` field a row leaves empty (`undefined`, `null` or `''`) is
158+
filled in remote mode as it is in local and embedded-replica mode: `create()`,
159+
`bulkCreate()` and `upsert()` issue the next value from the persistent
160+
`_objectstack_sequences` counter, rendered with the field's `autonumberFormat`
161+
(or `format`) and scoped per organization, per date token and per `{field}`
162+
token exactly as `SqlDriver` scopes it. The counter moves in one statement over
163+
the connection (`UPDATE … RETURNING`; on a cold counter, a bootstrap from the
164+
table's highest existing value followed by `INSERT … ON CONFLICT DO UPDATE …
165+
RETURNING`), so two processes writing to one database never draw the same
166+
number. A row that already carries a value keeps it (a seed replay or an
167+
import); an `upsert()` that merges into an existing row keeps the number
168+
already in the row. A `_objectstack_sequences` table in the legacy shape
169+
(created by a local-mode driver older than the `key_hash` column) is refused
170+
with `code: 'DATABASE_ERROR'` / `status: 500` and the remedy in the message;
171+
remote mode does not migrate it.
172+
162173
Answered in remote mode, differently from local mode:
163174

164175
- **`distinct()`** runs a `SELECT DISTINCT` on the remote database and answers

‎packages/drivers/driver-turso/package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@
4747
"devDependencies": {
4848
"@types/node": "^26.6.3",
4949
"better-sqlite3": "^13.0.3",
50+
"tsx": "^4.23.12",
5051
"typescript": "^6.0.3",
5152
"vitest": "^4.1.11"
5253
},

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

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1595,11 +1595,21 @@ export class RemoteTransport {
15951595
return rows[0] ?? null;
15961596
}
15971597

1598+
/**
1599+
* `insertOnlyColumns` — columns written on the INSERT leg and left alone on
1600+
* the MERGE leg, so a value the caller (or the driver, on its behalf) put on
1601+
* the row for a NEW row never overwrites the one an existing row already
1602+
* holds. The transport is told WHICH columns, not WHY: the driver decides
1603+
* from the schema it holds and this class does not (the same division
1604+
* `setFilterColumnSql` and `setTenantFieldResolver` draw). A column named
1605+
* here that is also a merge key is simply a merge key.
1606+
*/
15981607
async upsert(
15991608
object: string,
16001609
data: Record<string, unknown>,
16011610
conflictKeys?: string[],
16021611
table: string = object,
1612+
insertOnlyColumns: readonly string[] = [],
16031613
): Promise<Record<string, unknown>> {
16041614
await this.ensureConnected();
16051615

@@ -1618,7 +1628,7 @@ export class RemoteTransport {
16181628
const mergeKeys = conflictKeys && conflictKeys.length > 0 ? conflictKeys : ['id'];
16191629

16201630
// Build ON CONFLICT ... DO UPDATE SET
1621-
const updateCols = columns.filter((c) => !mergeKeys.includes(c));
1631+
const updateCols = columns.filter((c) => !mergeKeys.includes(c) && !insertOnlyColumns.includes(c));
16221632
const updateClauses = updateCols.map((col) => `"${col}" = excluded."${col}"`).join(', ');
16231633

16241634
let sql = `INSERT INTO ${this.tableSql(table)} (${columns.map((c) => `"${c}"`).join(', ')}) VALUES (${placeholders})`;

0 commit comments

Comments
 (0)