Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions .changeset/turso-remote-autonumber-refusal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
---
"@objectstack/driver-turso": minor
---

fix(driver-turso): the remote face refuses an `auto_number` create instead of silently writing NULL (#6944)

`TursoDriver` picks its transport from `url`. Local and embedded-replica inherit
`SqlDriver`'s write path and issue record numbers from the persistent
`_objectstack_sequences` table. Remote overrides the write path to
`RemoteTransport`, which builds its own `INSERT` and never enters
`fillAutoNumberFields` — so on that face `auto_number` was only a column mapped
to `TEXT`, and the slot the engine deliberately leaves empty stayed empty.
Measured on `main` @ `2f3e79351`:

```
REMOTE create -> RESOLVED case_number=null
REMOTE bulkCreate -> RESOLVED [null, null]
REMOTE upsert -> RESOLVED case_number=null
LOCAL create -> RESOLVED case_number="CASE-00001"
```

Nothing upstream caught it. `supports.autonumber` is `true` on this face
(inherited via `...super.supports`), so the engine defers generation to the
driver entirely and never runs its own fallback — `engine.ts` already records
`driver-turso` in its driver table as "inherited, no fallback path". A driver
face that boots and quietly fails to deliver a declared capability is the shape
#3724 ruled on; triage applied that ruling here on 2026-08-09 as **disposition
B — explicit refusal**. Implementing autonumber on the remote transport (A)
stays deferred for want of measured demand.

## What changed

`TursoDriver.create` / `bulkCreate` / `upsert` now refuse, in remote mode, a
write that would need a record number this face cannot issue:

```
NOT_IMPLEMENTED / 501
Object "crm_case" declares auto_number field(s) [case_number] left empty for
this create, and the Turso REMOTE transport does not generate record numbers. …
```

`NOT_IMPLEMENTED` / 501 is the same class this package already gives an
aggregate function it cannot compile (#5907) or a date bucket it cannot emit
(#6212), for the same reason and per ADR-0112: `autonumber` is a field type
`@objectstack/spec` declares and this very driver's other faces generate, so the
caller's object definition is correct and the gap is the backend's.

The refusal is raised on `TursoDriver`, not inside `RemoteTransport`, because
the transport cannot see what it would need in order to decide:
`RemoteTransport.create(object, data)` takes no schema and caches none. The
driver can — `registerRemoteFieldMetadata` → `registerExternalObject` classifies
every field at remote schema-sync time and populates `autoNumberFields`,
measured live in remote mode.

## What is deliberately NOT refused

- **A record that already carries a value in the slot.** That is the `isSystem`
seed replay and the `preserveAudit` historical import, which the engine
exempts from its strip on purpose (#5503); on this face they were, and remain,
written through unchanged and correctly. The generate predicate
(`undefined` / `null` / `''`) is `fillAutoNumberFields`' own, reused rather
than re-derived.
- **A merging upsert.** `RemoteTransport.upsert` emits
`INSERT … ON CONFLICT DO UPDATE`, so a row that matches keeps the number
already in its column — measured. Only the provably-inserting shape (no `id`,
no explicit conflict keys, so the transport mints a fresh id for the sole
merge key) is refused. An id- or conflict-key-bearing upsert that turns out to
insert is a known residue: classifying it needs the round trip the refusal
exists to avoid, and it is pinned as such in the suite.
- **Local and embedded-replica.** Both still generate; pinned across all three
faces so the refusal cannot leak onto the wrong one (#6203).

`packages/drivers/driver-turso` only. `driver-memory` / `driver-mongodb` sit
inside the #5499 freeze and declare no `supports.autonumber`; they take the
engine's own fallback path and are unrelated.
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,35 @@
* - **REMOTE** hands the batch to `RemoteTransport.bulkCreate`, which builds
* its own INSERT and never enters `fillAutoNumberFields` — so it has neither
* the defect nor the fix. On that face `auto_number` is only a column-type
* mapping and no sequence machinery exists to be stale; the absence is
* mapping and no sequence machinery exists to be stale; the boundary is
* pinned as an ASSERTION rather than a comment, so wiring autonumber into
* the remote transport later cannot silently inherit this file's green.
* (That gap is #6944's, not this card's.)
* (That gap was #6944's, not this card's — and #6944 has since answered it.)
*
* # ⚠️ [#6944] The pin below was rewritten, not deleted
*
* This file's REMOTE case pinned an ABSENCE: `RemoteTransport` has no autonumber
* surface on the batch path any more than on the single-row one, and a remote
* `bulkCreate` therefore resolved with `[null, null]` in the slots. #6944
* carried out triage's disposition B and made that face refuse LOUDLY
* (`NOT_IMPLEMENTED`/501), raised on `TursoDriver` because
* `RemoteTransport.create(object, data)` cannot see a field type. The absence
* half is still true and still worth guarding; the "and therefore nothing
* happens" half is not.
*
* Deleting the pin would remove the guard against a silent half-implementation
* inside the transport; leaving it untouched would keep a green assertion
* standing next to a claim that no longer holds. So it keeps the surface probe
* verbatim and gains the refusal — asserted on `bulkCreate` specifically,
* because Turso OVERRIDES it and `RemoteTransport.bulkCreate` loops its OWN
* `create`, so "`create` refuses" is not on its own an answer about this path
* either. The refusal's own suite is
* `turso-remote-autonumber-refusal.test.ts`.
*/

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

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

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

// The boundary, stated as a fact about the code rather than about a live
// connection: `RemoteTransport` has no autonumber surface at all, on the
// batch path any more than the single-row one. When one is added (#6944),
// this assertion is what has to be revisited.
// batch path any more than the single-row one. #6944 did not add one — it
// refused one layer up — so this half is unchanged, and it remains what a
// future half-implementation inside the transport has to go through.
const transportSurface = Object.getOwnPropertyNames(
Object.getPrototypeOf((remote as any).remoteTransport),
);
expect(transportSurface).toContain('bulkCreate');
expect(transportSurface.some((m) => /autonumber|sequence/i.test(m))).toBe(false);
});

it('REMOTE: [#6944] and having none, it refuses the batch rather than writing nothing', async () => {
const remote = new TursoDriver({
url: 'libsql://example.turso.io',
client: makeLibsqlSqliteStub() as never,
});
await remote.connect();
await remote.initObjects([
{
name: 'crm_case',
fields: {
organization_id: { type: 'string' },
case_number: { type: 'autonumber', format: 'CASE-{00000}', unique: true },
title: { type: 'string' },
},
} as any,
]);

// ⚠️ `code` AND `status`, never a bare `toThrow` (ADR-0112, #6144).
const err = await remote
.bulkCreate('crm_case', [
{ organization_id: 'orgA', title: 'b1' },
{ organization_id: 'orgA', title: 'b2' },
])
.then(
(rows) => {
throw new Error(`expected a refusal, got ${JSON.stringify(rows)}`);
},
(e) => e as Error & { code?: string; status?: number },
);
expect(err.code).toBe('NOT_IMPLEMENTED');
expect(err.status).toBe(501);

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

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

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

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

// The boundary, stated as a fact about the code rather than about a live
// connection: `RemoteTransport` has no autonumber surface at all. When one
// is added, this assertion is the thing that has to be revisited.
// connection: `RemoteTransport` has no autonumber surface at all. #6944 did
// not add one — it refused one layer up — so this half is unchanged, and it
// remains what a future half-implementation inside the transport has to go
// through.
const transportSurface = Object.getOwnPropertyNames(
Object.getPrototypeOf((remote as any).remoteTransport),
);
expect(transportSurface.some((m) => /autonumber|sequence/i.test(m))).toBe(false);
});

it('REMOTE: [#6944] and having none, it refuses the create rather than writing nothing', async () => {
// The other half of the same boundary, and the half that moved. Needs a
// client because the refusal reads `autoNumberFields`, which only remote
// schema-sync populates — the same registration `find()` depends on.
const remote = new TursoDriver({
url: 'libsql://example.turso.io',
client: makeLibsqlSqliteStub() as never,
});
await remote.connect();
await remote.initObjects([
{
name: 'crm_case',
fields: {
organization_id: { type: 'string' },
case_number: { type: 'autonumber', format: 'CASE-{00000}', unique: true },
title: { type: 'string' },
},
} as any,
]);

// ⚠️ `code` AND `status`, never a bare `toThrow` (ADR-0112, #6144): the
// point of this pin is the wire identity, and a bare throw assertion would
// be satisfied by any error a later refactor happens to raise here.
const err = await remote
.create('crm_case', { organization_id: 'orgA', title: 'first' })
.then(
(row) => {
throw new Error(`expected a refusal, got ${JSON.stringify(row)}`);
},
(e) => e as Error & { code?: string; status?: number },
);
expect(err.code).toBe('NOT_IMPLEMENTED');
expect(err.status).toBe(501);

await remote.disconnect();
});
});
Loading
Loading