Skip to content

Commit 584eacd

Browse files
os-steveclaude
andcommitted
fix(driver-turso): refuse auto_number writes on the remote transport (#6944)
Remote overrides the write path to RemoteTransport, which builds its own INSERT and never enters fillAutoNumberFields — so auto_number slots were silently written NULL on that face while local/replica generated numbers. Triage ruled disposition B (explicit refusal, per #3724). - TursoDriver.create / bulkCreate / upsert refuse with NOT_IMPLEMENTED/501 when a slot the driver would have had to fill is empty. Raised on the driver, not the transport: RemoteTransport.create(object, data) sees no schema, while the driver's autoNumberFields IS populated in remote mode. - Caller-supplied numbers (seed replay / preserveAudit import) and merging upserts are deliberately untouched. - The two absence-assertions from #6932/#6943 are rewritten to pin the refusal rather than deleted, keeping the transport-surface guard. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UtmF8wiWUuHFcezsXhYygb
1 parent 224c55b commit 584eacd

4 files changed

Lines changed: 220 additions & 15 deletions

File tree

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
---
2+
"@objectstack/driver-turso": minor
3+
---
4+
5+
fix(driver-turso): the remote face refuses an `auto_number` create instead of silently writing NULL (#6944)
6+
7+
`TursoDriver` picks its transport from `url`. Local and embedded-replica inherit
8+
`SqlDriver`'s write path and issue record numbers from the persistent
9+
`_objectstack_sequences` table. Remote overrides the write path to
10+
`RemoteTransport`, which builds its own `INSERT` and never enters
11+
`fillAutoNumberFields` — so on that face `auto_number` was only a column mapped
12+
to `TEXT`, and the slot the engine deliberately leaves empty stayed empty.
13+
Measured on `main` @ `2f3e79351`:
14+
15+
```
16+
REMOTE create -> RESOLVED case_number=null
17+
REMOTE bulkCreate -> RESOLVED [null, null]
18+
REMOTE upsert -> RESOLVED case_number=null
19+
LOCAL create -> RESOLVED case_number="CASE-00001"
20+
```
21+
22+
Nothing upstream caught it. `supports.autonumber` is `true` on this face
23+
(inherited via `...super.supports`), so the engine defers generation to the
24+
driver entirely and never runs its own fallback — `engine.ts` already records
25+
`driver-turso` in its driver table as "inherited, no fallback path". A driver
26+
face that boots and quietly fails to deliver a declared capability is the shape
27+
#3724 ruled on; triage applied that ruling here on 2026-08-09 as **disposition
28+
B — explicit refusal**. Implementing autonumber on the remote transport (A)
29+
stays deferred for want of measured demand.
30+
31+
## What changed
32+
33+
`TursoDriver.create` / `bulkCreate` / `upsert` now refuse, in remote mode, a
34+
write that would need a record number this face cannot issue:
35+
36+
```
37+
NOT_IMPLEMENTED / 501
38+
Object "crm_case" declares auto_number field(s) [case_number] left empty for
39+
this create, and the Turso REMOTE transport does not generate record numbers. …
40+
```
41+
42+
`NOT_IMPLEMENTED` / 501 is the same class this package already gives an
43+
aggregate function it cannot compile (#5907) or a date bucket it cannot emit
44+
(#6212), for the same reason and per ADR-0112: `autonumber` is a field type
45+
`@objectstack/spec` declares and this very driver's other faces generate, so the
46+
caller's object definition is correct and the gap is the backend's.
47+
48+
The refusal is raised on `TursoDriver`, not inside `RemoteTransport`, because
49+
the transport cannot see what it would need in order to decide:
50+
`RemoteTransport.create(object, data)` takes no schema and caches none. The
51+
driver can — `registerRemoteFieldMetadata` → `registerExternalObject` classifies
52+
every field at remote schema-sync time and populates `autoNumberFields`,
53+
measured live in remote mode.
54+
55+
## What is deliberately NOT refused
56+
57+
- **A record that already carries a value in the slot.** That is the `isSystem`
58+
seed replay and the `preserveAudit` historical import, which the engine
59+
exempts from its strip on purpose (#5503); on this face they were, and remain,
60+
written through unchanged and correctly. The generate predicate
61+
(`undefined` / `null` / `''`) is `fillAutoNumberFields`' own, reused rather
62+
than re-derived.
63+
- **A merging upsert.** `RemoteTransport.upsert` emits
64+
`INSERT … ON CONFLICT DO UPDATE`, so a row that matches keeps the number
65+
already in its column — measured. Only the provably-inserting shape (no `id`,
66+
no explicit conflict keys, so the transport mints a fresh id for the sole
67+
merge key) is refused. An id- or conflict-key-bearing upsert that turns out to
68+
insert is a known residue: classifying it needs the round trip the refusal
69+
exists to avoid, and it is pinned as such in the suite.
70+
- **Local and embedded-replica.** Both still generate; pinned across all three
71+
faces so the refusal cannot leak onto the wrong one (#6203).
72+
73+
`packages/drivers/driver-turso` only. `driver-memory` / `driver-mongodb` sit
74+
inside the #5499 freeze and declare no `supports.autonumber`; they take the
75+
engine's own fallback path and are unrelated.

‎packages/drivers/driver-turso/src/turso-autonumber-batch-resync.test.ts‎

Lines changed: 62 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,14 +18,35 @@
1818
* - **REMOTE** hands the batch to `RemoteTransport.bulkCreate`, which builds
1919
* its own INSERT and never enters `fillAutoNumberFields` — so it has neither
2020
* the defect nor the fix. On that face `auto_number` is only a column-type
21-
* mapping and no sequence machinery exists to be stale; the absence is
21+
* mapping and no sequence machinery exists to be stale; the boundary is
2222
* pinned as an ASSERTION rather than a comment, so wiring autonumber into
2323
* the remote transport later cannot silently inherit this file's green.
24-
* (That gap is #6944's, not this card's.)
24+
* (That gap was #6944's, not this card's — and #6944 has since answered it.)
25+
*
26+
* # ⚠️ [#6944] The pin below was rewritten, not deleted
27+
*
28+
* This file's REMOTE case pinned an ABSENCE: `RemoteTransport` has no autonumber
29+
* surface on the batch path any more than on the single-row one, and a remote
30+
* `bulkCreate` therefore resolved with `[null, null]` in the slots. #6944
31+
* carried out triage's disposition B and made that face refuse LOUDLY
32+
* (`NOT_IMPLEMENTED`/501), raised on `TursoDriver` because
33+
* `RemoteTransport.create(object, data)` cannot see a field type. The absence
34+
* half is still true and still worth guarding; the "and therefore nothing
35+
* happens" half is not.
36+
*
37+
* Deleting the pin would remove the guard against a silent half-implementation
38+
* inside the transport; leaving it untouched would keep a green assertion
39+
* standing next to a claim that no longer holds. So it keeps the surface probe
40+
* verbatim and gains the refusal — asserted on `bulkCreate` specifically,
41+
* because Turso OVERRIDES it and `RemoteTransport.bulkCreate` loops its OWN
42+
* `create`, so "`create` refuses" is not on its own an answer about this path
43+
* either. The refusal's own suite is
44+
* `turso-remote-autonumber-refusal.test.ts`.
2545
*/
2646

2747
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2848
import { TursoDriver } from './index.js';
49+
import { makeLibsqlSqliteStub } from './libsql-sqlite-stub.testkit.js';
2950

3051
describe('[#6943] TursoDriver batch/upsert autonumber re-seed', () => {
3152
let driver: TursoDriver;
@@ -110,18 +131,54 @@ describe('[#6943] TursoDriver batch/upsert autonumber re-seed', () => {
110131
expect(merged.title).toBe('edited');
111132
});
112133

113-
it('REMOTE: the transport that bypasses this path has no autonumber machinery to re-seed', async () => {
134+
it('REMOTE: the transport that bypasses this path still has no autonumber machinery to re-seed', async () => {
114135
const remote = new TursoDriver({ url: 'libsql://example.turso.io', authToken: 'placeholder' });
115136
expect(remote.transportMode).toBe('remote');
116137

117138
// The boundary, stated as a fact about the code rather than about a live
118139
// connection: `RemoteTransport` has no autonumber surface at all, on the
119-
// batch path any more than the single-row one. When one is added (#6944),
120-
// this assertion is what has to be revisited.
140+
// batch path any more than the single-row one. #6944 did not add one — it
141+
// refused one layer up — so this half is unchanged, and it remains what a
142+
// future half-implementation inside the transport has to go through.
121143
const transportSurface = Object.getOwnPropertyNames(
122144
Object.getPrototypeOf((remote as any).remoteTransport),
123145
);
124146
expect(transportSurface).toContain('bulkCreate');
125147
expect(transportSurface.some((m) => /autonumber|sequence/i.test(m))).toBe(false);
126148
});
149+
150+
it('REMOTE: [#6944] and having none, it refuses the batch rather than writing nothing', async () => {
151+
const remote = new TursoDriver({
152+
url: 'libsql://example.turso.io',
153+
client: makeLibsqlSqliteStub() as never,
154+
});
155+
await remote.connect();
156+
await remote.initObjects([
157+
{
158+
name: 'crm_case',
159+
fields: {
160+
organization_id: { type: 'string' },
161+
case_number: { type: 'autonumber', format: 'CASE-{00000}', unique: true },
162+
title: { type: 'string' },
163+
},
164+
} as any,
165+
]);
166+
167+
// ⚠️ `code` AND `status`, never a bare `toThrow` (ADR-0112, #6144).
168+
const err = await remote
169+
.bulkCreate('crm_case', [
170+
{ organization_id: 'orgA', title: 'b1' },
171+
{ organization_id: 'orgA', title: 'b2' },
172+
])
173+
.then(
174+
(rows) => {
175+
throw new Error(`expected a refusal, got ${JSON.stringify(rows)}`);
176+
},
177+
(e) => e as Error & { code?: string; status?: number },
178+
);
179+
expect(err.code).toBe('NOT_IMPLEMENTED');
180+
expect(err.status).toBe(501);
181+
182+
await remote.disconnect();
183+
});
127184
});

‎packages/drivers/driver-turso/src/turso-autonumber-resync.test.ts‎

Lines changed: 68 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,38 @@
1616
* closes: on that face `auto_number` is only a column-type mapping
1717
* (`remote-transport.ts` maps it to `TEXT`) and no sequence machinery exists
1818
* to be stale. It is stated here so the boundary is on the record, and the
19-
* absence is pinned as an ASSERTION rather than a comment, so that wiring
19+
* boundary is pinned as an ASSERTION rather than a comment, so that wiring
2020
* autonumber into the remote transport later cannot silently inherit this
2121
* file's green.
22+
*
23+
* # ⚠️ [#6944] What that pin says now — rewritten, not deleted
24+
*
25+
* When this file was written the remote face was SILENTLY ABSENT: a create
26+
* resolved and left NULL in the slot. The pin below said exactly that —
27+
* `RemoteTransport` carries no autonumber surface at all. #6944 carried out
28+
* triage's disposition B and made that face refuse LOUDLY instead
29+
* (`NOT_IMPLEMENTED`/501), raised on `TursoDriver` rather than on the transport,
30+
* because `RemoteTransport.create(object, data)` cannot see a field type.
31+
*
32+
* So the shape moved from ABSENT to EXPLICITLY REFUSED, and this pin had two
33+
* wrong options and one right one:
34+
*
35+
* - DELETE it — and lose the only guard that stops a future half-implementation
36+
* landing quietly inside the transport;
37+
* - LEAVE it unchanged — and keep a green assertion whose surrounding claim,
38+
* "that face simply doesn't have this", is no longer the whole truth.
39+
*
40+
* It is therefore rewritten to pin BOTH halves of the fact as it now stands: the
41+
* transport still carries no autonumber surface (the original guard, verbatim),
42+
* and the driver now answers with a wire identity instead of writing nothing
43+
* (the new fact). The refusal's own suite is
44+
* `turso-remote-autonumber-refusal.test.ts`; what belongs here is only the
45+
* boundary this file's LOCAL half is held against.
2246
*/
2347

2448
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2549
import { TursoDriver } from './index.js';
50+
import { makeLibsqlSqliteStub } from './libsql-sqlite-stub.testkit.js';
2651

2752
describe('[#5495] TursoDriver autonumber re-seed', () => {
2853
let driver: TursoDriver;
@@ -65,16 +90,55 @@ describe('[#5495] TursoDriver autonumber re-seed', () => {
6590
expect(created.case_number).toBe('CASE-00031');
6691
});
6792

68-
it('REMOTE: the transport that bypasses this path has no autonumber machinery to re-seed', async () => {
93+
it('REMOTE: the transport that bypasses this path still has no autonumber machinery to re-seed', async () => {
6994
const remote = new TursoDriver({ url: 'libsql://example.turso.io', authToken: 'placeholder' });
7095
expect(remote.transportMode).toBe('remote');
7196

7297
// The boundary, stated as a fact about the code rather than about a live
73-
// connection: `RemoteTransport` has no autonumber surface at all. When one
74-
// is added, this assertion is the thing that has to be revisited.
98+
// connection: `RemoteTransport` has no autonumber surface at all. #6944 did
99+
// not add one — it refused one layer up — so this half is unchanged, and it
100+
// remains what a future half-implementation inside the transport has to go
101+
// through.
75102
const transportSurface = Object.getOwnPropertyNames(
76103
Object.getPrototypeOf((remote as any).remoteTransport),
77104
);
78105
expect(transportSurface.some((m) => /autonumber|sequence/i.test(m))).toBe(false);
79106
});
107+
108+
it('REMOTE: [#6944] and having none, it refuses the create rather than writing nothing', async () => {
109+
// The other half of the same boundary, and the half that moved. Needs a
110+
// client because the refusal reads `autoNumberFields`, which only remote
111+
// schema-sync populates — the same registration `find()` depends on.
112+
const remote = new TursoDriver({
113+
url: 'libsql://example.turso.io',
114+
client: makeLibsqlSqliteStub() as never,
115+
});
116+
await remote.connect();
117+
await remote.initObjects([
118+
{
119+
name: 'crm_case',
120+
fields: {
121+
organization_id: { type: 'string' },
122+
case_number: { type: 'autonumber', format: 'CASE-{00000}', unique: true },
123+
title: { type: 'string' },
124+
},
125+
} as any,
126+
]);
127+
128+
// ⚠️ `code` AND `status`, never a bare `toThrow` (ADR-0112, #6144): the
129+
// point of this pin is the wire identity, and a bare throw assertion would
130+
// be satisfied by any error a later refactor happens to raise here.
131+
const err = await remote
132+
.create('crm_case', { organization_id: 'orgA', title: 'first' })
133+
.then(
134+
(row) => {
135+
throw new Error(`expected a refusal, got ${JSON.stringify(row)}`);
136+
},
137+
(e) => e as Error & { code?: string; status?: number },
138+
);
139+
expect(err.code).toBe('NOT_IMPLEMENTED');
140+
expect(err.status).toBe(501);
141+
142+
await remote.disconnect();
143+
});
80144
});

‎packages/drivers/driver-turso/src/turso-remote-autonumber-refusal.test.ts‎

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -63,12 +63,21 @@
6363
* green, since none of them depends on the refusal existing.
6464
*
6565
* Measured with `refuseUngeneratableRemoteAutonumber`'s body emptied
66-
* (`return;`), and the prediction held with one addition worth recording: the
67-
* REMOTE block goes 0/6, and every one of the six failures is the "it resolved"
68-
* message carrying `case_number=null` in it — so the reverted suite does not
69-
* merely go red, it reprints the defect. The 12 cases outside that block stay
70-
* green, confirming they pin facts that are true on both sides of the change and
71-
* would survive a future re-implementation of A.
66+
* (`return;`) — `8 failed | 923 passed` across the package, and the prediction
67+
* held:
68+
*
69+
* - this file's REMOTE block goes 0/6, every failure through "it resolved";
70+
* - the two rewritten boundary pins (`turso-autonumber-resync.test.ts`,
71+
* `turso-autonumber-batch-resync.test.ts`) fail the same way, which is the
72+
* point of rewriting rather than deleting them — they now go red for the
73+
* defect too;
74+
* - the 11 cases outside the REMOTE block stay green, confirming they pin
75+
* facts true on both sides of the change and would survive a future
76+
* re-implementation of A.
77+
*
78+
* One detail not predicted and worth keeping: the reverted suite does not merely
79+
* go red, it REPRINTS the defect — every failure message carries the resolved
80+
* row with `"case_number":null` (or `""` for the empty-string case) inside it.
7281
*/
7382

7483
import { describe, it, expect, afterEach } from 'vitest';

0 commit comments

Comments
 (0)