From fadd8c02de0f6460b690b55894f4fd2abf3c9f2e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 15:27:40 +0000 Subject: [PATCH 1/4] wip(spec,driver-turso): refuse a forced mode local beside syncUrl at both doors Claude-Session: https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY Co-authored-by: Claude --- .../driver-turso/src/spec/turso.zod.ts | 18 ++++++ .../drivers/driver-turso/src/turso-driver.ts | 57 ++++++++++++++++++- packages/spec/src/data/driver/turso.zod.ts | 23 +++++++- 3 files changed, 94 insertions(+), 4 deletions(-) diff --git a/packages/drivers/driver-turso/src/spec/turso.zod.ts b/packages/drivers/driver-turso/src/spec/turso.zod.ts index e3a9af98c89..a05fe3f0973 100644 --- a/packages/drivers/driver-turso/src/spec/turso.zod.ts +++ b/packages/drivers/driver-turso/src/spec/turso.zod.ts @@ -257,6 +257,24 @@ function tursoTransportIssues(cfg: TursoTransportKeys): TursoTransportIssue[] { + "`https://` Turso endpoint. For a plain local database, drop `mode: 'replica'`.", }]; } + if (mode === 'local' && hasSyncUrl) { + // #20586. Only a FORCED local mode reaches here: with no `mode`, a + // `syncUrl` selects a replica. The url is a `file:` url or `:memory:` + // (every other one met a refusal above), so the url is fine; what the + // runtime would ignore is the MODE, because the driver syncs whenever + // `syncUrl` is set — so the issue sits on `mode`, as #20437's does. + // Unreachable through this mirror, which strips `mode` (see above); kept + // byte-identical to the spec contract's arm. + return [{ + path: 'mode', + message: + "`mode: 'local'` makes this datasource a plain local database, but `syncUrl` names a remote to " + + 'replicate from: the database would still be synced with that remote as an embedded replica, ' + + 'so the declared local mode would be ignored — the turso driver refuses this configuration ' + + 'when it starts. For an embedded replica, drop `mode` and keep `syncUrl` beside the local file: ' + + "`url: 'file:./data/replica.db'`. For a plain local database, drop `syncUrl` (and `sync`).", + }]; + } return []; } diff --git a/packages/drivers/driver-turso/src/turso-driver.ts b/packages/drivers/driver-turso/src/turso-driver.ts index 361ab116ec1..f9af3e93c57 100644 --- a/packages/drivers/driver-turso/src/turso-driver.ts +++ b/packages/drivers/driver-turso/src/turso-driver.ts @@ -24,7 +24,8 @@ * a url that is none of those (a bare path, an unsupported scheme); a remote * url beside `syncUrl` or under `mode: 'local'` / `'replica'`; and a replica * whose url names an in-memory database. A forced `mode: 'replica'` with no - * `syncUrl` is refused too, because nothing would ever sync it. + * `syncUrl` is refused too, because nothing would ever sync it, and so is a + * forced `mode: 'local'` beside a `syncUrl`, because it would sync anyway. */ import { @@ -126,7 +127,9 @@ export interface TursoDriverConfig { * would run on a private in-memory database. For a remote database, drop * `syncUrl` and keep the remote `url`. Under a forced `mode: 'remote'` it is * refused too (`VALIDATION_ERROR` / 400): the remote client never receives - * it, so no sync would ever run. + * it, so no sync would ever run. Under a forced `mode: 'local'` it is + * refused as well (`VALIDATION_ERROR` / 400): the driver would sync the + * database with it anyway and the declared local mode would be ignored. */ syncUrl?: string; @@ -196,6 +199,12 @@ export interface TursoDriverConfig { * constructor refuses it (`VALIDATION_ERROR` / 400), because nothing would * ever sync and the datasource would run as a plain local database. For a * local database, drop `mode`; for a replica, set `syncUrl`. + * + * A forced `'local'` is refused beside a (non-empty) + * {@link TursoDriverConfig.syncUrl} (`VALIDATION_ERROR` / 400): the driver + * syncs whenever `syncUrl` is set, so the datasource would run as an + * embedded replica under a `local` label. For a replica, drop `mode`; for a + * local database, drop `syncUrl` (and `sync`). */ mode?: TursoTransportMode; @@ -1144,7 +1153,41 @@ const REPLICA_MODE_WITHOUT_SYNC_URL_REFUSAL = "`https://` Turso endpoint. For a plain local database, drop `mode: 'replica'`."; /** - * Throw one of the three sync-key refusals above as the ADR-0112 envelope. + * [#20586] A forced `mode: 'local'` beside a `syncUrl` — refused at construction. + * + * The same defect as the replica refusal above, the other way round. The + * driver used to build it with `transportMode = 'local'` and then run it as an + * embedded replica: `connect()` builds the sync client whenever `syncUrl` is + * set in a non-remote mode, whatever `mode` says. Measured on this package's + * source before this refusal (a `file:` url, forced `mode: 'local'`, + * `syncUrl`, `sync: { intervalSeconds: 1 }`, a sync-counting client): one sync + * on connect, `isSyncEnabled()` answered `true`, the interval started and + * synced again — exactly what the same config with no `mode` (a replica) did, + * while the same `file:` url with no `syncUrl` synced nothing. A declared mode + * the runtime ignores: the declared-but-not-enforced shape ADR-0049 does not + * ship. The datasource is not honoured as local by skipping the sync instead: + * that would ignore a declared `syncUrl`, the same defect with the keys + * swapped. + * + * Only a FORCED local mode reaches this: with no `mode`, + * {@link TursoDriver.detectMode} answers `replica` beside a `syncUrl`. A forced + * local mode on a url the local engine cannot open met `localEngineDefect` + * first, so this fires on a `file:` url or `:memory:`. An empty `syncUrl` is + * unset, as everywhere in this driver, and is not refused. + * + * ⚠️ The text is `@objectstack/spec`'s `TursoConfigSchema` refusal on `mode` + * for this shape, byte for byte, held equal by + * `spec/turso-config-constructor-parity.test.ts`. Edit it there first. + */ +const LOCAL_MODE_WITH_SYNC_URL_REFUSAL = + "`mode: 'local'` makes this datasource a plain local database, but `syncUrl` names a remote to " + + 'replicate from: the database would still be synced with that remote as an embedded replica, ' + + 'so the declared local mode would be ignored — the turso driver refuses this configuration ' + + 'when it starts. For an embedded replica, drop `mode` and keep `syncUrl` beside the local file: ' + + "`url: 'file:./data/replica.db'`. For a plain local database, drop `syncUrl` (and `sync`)."; + +/** + * Throw one of the four sync-key refusals above as the ADR-0112 envelope. * * Raised BEFORE `super()`, AFTER the three refusals above it in the * constructor, so no configuration those already refuse changes which message @@ -1600,6 +1643,14 @@ export class TursoDriver extends SqlDriver { if (mode === 'replica' && !config.syncUrl) { refuseIgnoredSyncKey(REPLICA_MODE_WITHOUT_SYNC_URL_REFUSAL); } + // [#20586] The same defect the other way round: a forced `mode: 'local'` + // beside a `syncUrl` would still be synced by `connect()`, so the declared + // local mode would be ignored. Refused here too, in the spec's words, last: + // every refusal above is exclusive of it except the url ones, which the + // spec contract also raises first. See `LOCAL_MODE_WITH_SYNC_URL_REFUSAL`. + if (mode === 'local' && config.syncUrl) { + refuseIgnoredSyncKey(LOCAL_MODE_WITH_SYNC_URL_REFUSAL); + } const knexConfig = TursoDriver.toKnexConfig(config, mode); super(knexConfig); this.tursoConfig = config; diff --git a/packages/spec/src/data/driver/turso.zod.ts b/packages/spec/src/data/driver/turso.zod.ts index 5b90891a02d..eed1efdaa33 100644 --- a/packages/spec/src/data/driver/turso.zod.ts +++ b/packages/spec/src/data/driver/turso.zod.ts @@ -103,7 +103,12 @@ export type TursoTransportMode = z.input; // - a forced `mode: 'replica'` with no `syncUrl` (#20437): a replica with no // remote to replicate from. The driver used to build it as a replica, never // synced it and ran it as a plain local database; authoring and the -// constructor now refuse it together, in this arm's words. +// constructor now refuse it together, in this arm's words; +// - a forced `mode: 'local'` beside a `syncUrl` (#20586), the same defect the +// other way round: the driver used to label it local and then run it as an +// embedded replica, syncing with the remote on connect and on the interval +// while its sync-enabled check answered true. Authoring and the constructor +// now refuse it together, in this arm's words. // // The predicates MIRROR the constructor's on `main` (`localEngineDefect`, // `refuseWebSocketTimeout` and `detectMode` in @@ -263,6 +268,22 @@ function tursoTransportIssues(cfg: TursoTransportKeys): TursoTransportIssue[] { + "`https://` Turso endpoint. For a plain local database, drop `mode: 'replica'`.", }]; } + if (mode === 'local' && hasSyncUrl) { + // #20586. Only a FORCED local mode reaches here: with no `mode`, a + // `syncUrl` selects a replica. The url is a `file:` url or `:memory:` + // (every other one met a refusal above), so the url is fine; what the + // runtime would ignore is the MODE, because the driver syncs whenever + // `syncUrl` is set — so the issue sits on `mode`, as #20437's does. + return [{ + path: 'mode', + message: + "`mode: 'local'` makes this datasource a plain local database, but `syncUrl` names a remote to " + + 'replicate from: the database would still be synced with that remote as an embedded replica, ' + + 'so the declared local mode would be ignored — the turso driver refuses this configuration ' + + 'when it starts. For an embedded replica, drop `mode` and keep `syncUrl` beside the local file: ' + + "`url: 'file:./data/replica.db'`. For a plain local database, drop `syncUrl` (and `sync`).", + }]; + } return []; } From 8a38e4e8be546cac3fe670fd4427c1c42ed3c048 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 15:30:12 +0000 Subject: [PATCH 2/4] wip(spec,driver-turso): flip the forced-local parity pins and add the refusal tests Claude-Session: https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY Co-authored-by: Claude --- .../turso-config-constructor-parity.test.ts | 45 +++- ...forced-local-with-sync-url-refusal.test.ts | 223 ++++++++++++++++++ ...so-driver-ignored-sync-key-refusal.test.ts | 19 +- packages/spec/src/data/driver/turso.test.ts | 78 +++++- 4 files changed, 341 insertions(+), 24 deletions(-) create mode 100644 packages/drivers/driver-turso/src/turso-driver-forced-local-with-sync-url-refusal.test.ts diff --git a/packages/drivers/driver-turso/src/spec/turso-config-constructor-parity.test.ts b/packages/drivers/driver-turso/src/spec/turso-config-constructor-parity.test.ts index a23007f640a..20132e15544 100644 --- a/packages/drivers/driver-turso/src/spec/turso-config-constructor-parity.test.ts +++ b/packages/drivers/driver-turso/src/spec/turso-config-constructor-parity.test.ts @@ -30,7 +30,10 @@ * copies in `../turso-driver.ts`, and this is the pin that holds them equal. * [#20437] The same holds for a forced `mode: 'replica'` with no `syncUrl`, * refused on `mode` — the third copy. That row used to be accepted - * everywhere, as a replica that never synced. + * everywhere, as a replica that never synced. [#20586] And for a forced + * `mode: 'local'` beside a `syncUrl`, refused on `mode` too — the fourth + * copy. That row used to be accepted everywhere as well, as a "local" + * database the driver synced with the remote anyway. * * ⚠️ The mirror declares no `mode`, so zod strips an authored one before its * refinement runs: rows that FORCE a mode are judged by the constructor and the @@ -103,7 +106,8 @@ const ROWS: Row[] = [ { name: 'a remote url behind whitespace', config: { url: ` ${REMOTE}` }, ctor: 'accept' }, { name: 'an empty syncUrl (unset)', config: { url: REMOTE, syncUrl: '' }, ctor: 'accept' }, { name: "file: + syncUrl under a forced mode: 'replica'", config: { url: FILE, mode: 'replica', syncUrl: REMOTE, sync: { onConnect: false } }, ctor: 'accept' }, - { name: "file: + syncUrl under a forced mode: 'local'", config: { url: FILE, mode: 'local', syncUrl: REMOTE, sync: { onConnect: false } }, ctor: 'accept' }, + { name: "file: under a forced mode: 'local'", config: { url: FILE, mode: 'local' }, ctor: 'accept' }, + { name: "file: + an empty syncUrl (unset) under a forced mode: 'local'", config: { url: FILE, mode: 'local', syncUrl: '' }, ctor: 'accept' }, { name: "libsql:// under a forced mode: 'remote'", config: { url: REMOTE, mode: 'remote' }, ctor: 'accept' }, { name: "file: under a forced mode: 'remote'", config: { url: FILE, mode: 'remote' }, ctor: 'accept' }, { name: "a bare path under a forced mode: 'remote' (the client refuses it at connect)", config: { url: './data/app.db', mode: 'remote' }, ctor: 'accept' }, @@ -119,6 +123,8 @@ const ROWS: Row[] = [ { name: "libsql:// + syncUrl under a forced mode: 'replica'", config: { url: REMOTE, mode: 'replica', syncUrl: REMOTE }, ctor: 'refuse', refusedOn: 'url' }, { name: "libsql:// under a forced mode: 'local'", config: { url: REMOTE, mode: 'local' }, ctor: 'refuse', refusedOn: 'url' }, { name: "https:// under a forced mode: 'local'", config: { url: 'https://db.example.turso.io', mode: 'local' }, ctor: 'refuse', refusedOn: 'url' }, + // [#20586] ORDER: a remote url keeps its `url` refusal ahead of the forced-local `syncUrl` one. + { name: "libsql:// + syncUrl under a forced mode: 'local'", config: { url: REMOTE, mode: 'local', syncUrl: REMOTE }, ctor: 'refuse', refusedOn: 'url' }, // ── a url that is none of file:, :memory: or remote, in a local or replica mode ── { name: 'a bare relative path', config: { url: './data/app.db' }, ctor: 'refuse', refusedOn: 'url' }, @@ -131,6 +137,8 @@ const ROWS: Row[] = [ { name: 'a whitespace-only url', config: { url: ' ' }, ctor: 'refuse', refusedOn: 'url' }, { name: 'a bare path beside syncUrl', config: { url: './data/replica.db', syncUrl: REMOTE }, ctor: 'refuse', refusedOn: 'url' }, { name: "a bare path under a forced mode: 'local'", config: { url: './data/app.db', mode: 'local' }, ctor: 'refuse', refusedOn: 'url' }, + // [#20586] ORDER: a bare path keeps its `url` refusal ahead of the forced-local `syncUrl` one. + { name: "a bare path + syncUrl under a forced mode: 'local'", config: { url: './data/app.db', mode: 'local', syncUrl: REMOTE }, ctor: 'refuse', refusedOn: 'url' }, { name: "a bare path under a forced mode: 'replica'", config: { url: './data/replica.db', mode: 'replica' }, ctor: 'refuse', refusedOn: 'url' }, // ── a replica on an in-memory url ─────────────────────────────────────── @@ -162,11 +170,23 @@ const ROWS: Row[] = [ { name: "an uppercase FILE: url under a forced mode: 'replica'", config: { url: `FILE:${DIR}/upper-replica.db`, mode: 'replica' }, ctor: 'refuse', refusedOn: 'mode' }, { name: "file: + an empty syncUrl (unset) under a forced mode: 'replica'", config: { url: FILE, mode: 'replica', syncUrl: '' }, ctor: 'refuse', refusedOn: 'mode' }, { name: "file: + timeoutMs under a forced mode: 'replica'", config: { url: FILE, mode: 'replica', timeoutMs: 5000 }, ctor: 'refuse', refusedOn: 'mode' }, + + // ── a forced local mode beside a remote to replicate from: refused on `mode` (#20586) ── + // The first row was pinned `accept` until #20586: the driver labelled it local and synced it anyway. + { name: "file: + syncUrl under a forced mode: 'local'", config: { url: FILE, mode: 'local', syncUrl: REMOTE, sync: { onConnect: false } }, ctor: 'refuse', refusedOn: 'mode' }, + { name: "file: + syncUrl, no sync, under a forced mode: 'local'", config: { url: FILE, mode: 'local', syncUrl: REMOTE }, ctor: 'refuse', refusedOn: 'mode' }, + { name: "an uppercase FILE: url + syncUrl under a forced mode: 'local'", config: { url: `FILE:${DIR}/upper-local.db`, mode: 'local', syncUrl: REMOTE }, ctor: 'refuse', refusedOn: 'mode' }, + { name: "a file: url behind whitespace + syncUrl under a forced mode: 'local'", config: { url: ` ${FILE}`, mode: 'local', syncUrl: REMOTE }, ctor: 'refuse', refusedOn: 'mode' }, + { name: "file: + syncUrl + timeoutMs under a forced mode: 'local'", config: { url: FILE, mode: 'local', syncUrl: REMOTE, timeoutMs: 5000 }, ctor: 'refuse', refusedOn: 'mode' }, + { name: "file: + a wss:// syncUrl under a forced mode: 'local'", config: { url: FILE, mode: 'local', syncUrl: 'wss://db.example.turso.io' }, ctor: 'refuse', refusedOn: 'mode' }, + { name: ":memory: + syncUrl under a forced mode: 'local'", config: { url: ':memory:', mode: 'local', syncUrl: REMOTE }, ctor: 'refuse', refusedOn: 'mode' }, + { name: "file::memory: + syncUrl under a forced mode: 'local'", config: { url: 'file::memory:', mode: 'local', syncUrl: REMOTE }, ctor: 'refuse', refusedOn: 'mode' }, ]; /** - * The rows the constructor refuses on a sync key, or on a forced replica with - * no `syncUrl`: its message is a copy of the spec's (#20200, #20437). + * The rows the constructor refuses on a sync key, on a forced replica with no + * `syncUrl`, or on a forced local mode beside a `syncUrl`: its message is a + * copy of the spec's (#20200, #20437, #20586). */ const SYNC_KEY_REFUSALS = ROWS.filter( (r) => r.ctor === 'refuse' && (r.refusedOn === 'syncUrl' || r.refusedOn === 'sync' || r.refusedOn === 'mode'), @@ -225,8 +245,11 @@ describe('turso config: the constructor, the spec contract and this mirror agree expect(ROWS.filter((r) => r.refusedOn === 'timeoutMs').length).toBeGreaterThanOrEqual(3); expect(ROWS.filter((r) => r.refusedOn === 'syncUrl').length).toBeGreaterThanOrEqual(3); expect(ROWS.filter((r) => r.refusedOn === 'sync').length).toBeGreaterThanOrEqual(5); - expect(ROWS.filter((r) => r.refusedOn === 'mode').length).toBeGreaterThanOrEqual(4); - expect(SYNC_KEY_REFUSALS.length).toBeGreaterThanOrEqual(12); + expect(ROWS.filter((r) => r.refusedOn === 'mode').length).toBeGreaterThanOrEqual(12); + // [#20586] The forced-local half of the `mode` rows, floored on its own so + // it cannot shrink behind the forced-replica half. + expect(ROWS.filter((r) => r.refusedOn === 'mode' && r.config.mode === 'local').length).toBeGreaterThanOrEqual(8); + expect(SYNC_KEY_REFUSALS.length).toBeGreaterThanOrEqual(20); // [#20200] Exactly zero, not a floor: every key the constructor used to // build and ignore is refused at construction now (see the header). expect(ROWS.filter((r) => r.inert).length).toBe(0); @@ -258,12 +281,12 @@ describe('turso config: the constructor, the spec contract and this mirror agree }); }); - // [#20200] The two sync refusals, and [#20437] the forced-replica refusal, - // are copies of the spec contract's texts in `../turso-driver.ts` (the spec - // keeps them module-local); this is the pin that holds each copy equal to the - // schema's issue, byte for byte. + // [#20200] The two sync refusals, [#20437] the forced-replica refusal and + // [#20586] the forced-local one are copies of the spec contract's texts in + // `../turso-driver.ts` (the spec keeps them module-local); this is the pin + // that holds each copy equal to the schema's issue, byte for byte. describe.each(SYNC_KEY_REFUSALS)('$name', (row) => { - it("the constructor's message is the spec contract's, byte for byte (#20200, #20437)", () => { + it("the constructor's message is the spec contract's, byte for byte (#20200, #20437, #20586)", () => { const spec = schemaVerdict(SpecTursoConfigSchema, row.config); expect(spec.refusedOn).toBe(row.refusedOn); expect(spec.message).toBeTypeOf('string'); diff --git a/packages/drivers/driver-turso/src/turso-driver-forced-local-with-sync-url-refusal.test.ts b/packages/drivers/driver-turso/src/turso-driver-forced-local-with-sync-url-refusal.test.ts new file mode 100644 index 00000000000..c60f4e19304 --- /dev/null +++ b/packages/drivers/driver-turso/src/turso-driver-forced-local-with-sync-url-refusal.test.ts @@ -0,0 +1,223 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#20586] A forced `mode: 'local'` beside a `syncUrl` is refused at + * construction: a datasource declared local that the driver would sync with a + * remote anyway. The constructor half of one paired change; + * `@objectstack/spec`'s `TursoConfigSchema` refuses the same config at + * authoring, on `mode`, in the same words. #20437's forced-replica refusal is + * the same defect the other way round. + * + * # What was measured (the reading this refusal stands on) + * + * On this package's source before the change (base `f4ce10c89`), with a client + * that counts `sync()` calls and `sync: { intervalSeconds: 1 }`: + * + * ``` + * file: + mode 'local' + syncUrl -> transportMode 'local', 1 sync on connect, + * isSyncEnabled() true, interval started, 2 syncs after 1.3 s + * file: + syncUrl (no mode) -> transportMode 'replica', the same four readings + * file: + mode 'local' (no syncUrl) -> transportMode 'local', 0 syncs, isSyncEnabled() false, no interval + * ``` + * + * `connect()` builds the sync client whenever `syncUrl` is set in a non-remote + * mode, so a forced local mode beside `syncUrl` ran as an embedded replica and + * only the `transportMode` label said `local`. Both schema copies accepted it + * (the parity row "file: + syncUrl under a forced mode: 'local'"), and this + * package's tests pinned it as accepted. + * + * # What this file pins + * + * - The refusal as the ADR-0112 envelope (`code` + `status`) plus its first + * sentence and both ways out, on a `file:` url in any letter case, on an + * in-memory url, beside `sync`, `timeout` and `encryptionKey`, and beside a + * supplied client (refused before any client work: the client is never + * synced). The full message is the spec contract's issue text byte for byte; + * that equality is pinned in `spec/turso-config-constructor-parity.test.ts`, + * not here. + * - ORDER: a config another refusal already takes keeps that refusal's message + * — a remote url or a bare path (the local-engine refusals), `sync` with no + * `syncUrl`, and #20437's forced replica with no `syncUrl`. + * - WIDTH, by controls that must stay accepted: the unforced `file:` + + * `syncUrl` replica (it still syncs), and a forced local mode with no + * `syncUrl` or an empty one (it still syncs nothing). + * + * # Reverse verification: direction predicted before it was run + * + * Remove the refusal from the constructor and every refusal case here goes RED + * (the constructor returns a `'local'` driver, so there is no envelope to + * read); the ORDER cases and the CONTROLS stay GREEN. Measured; see the PR. + */ + +import { afterAll, describe, expect, it } from 'vitest'; +import { createTursoDriver } from './index.js'; +import { TursoDriver, type TursoDriverConfig } from './turso-driver.js'; +import { makeLibsqlSqliteStub } from './libsql-sqlite-stub.testkit.js'; +import { replicaFiles } from './replica-file.testkit.js'; + +type Refusal = Error & { code?: string; status?: number }; + +/** The error `build` threw, or `null` when it returned. */ +function refusalOf(build: () => unknown): Refusal | null { + try { + build(); + return null; + } catch (error) { + return error as Refusal; + } +} + +function expectEnvelope(refusal: Refusal | null): asserts refusal is Refusal { + expect(refusal, 'expected the constructor to refuse, and it returned a driver').not.toBeNull(); + expect(refusal!.code).toBe('VALIDATION_ERROR'); + expect(refusal!.status).toBe(400); +} + +/** The first sentence of the refusal: what identifies it. */ +const LOCAL_FIRST_SENTENCE = + "`mode: 'local'` makes this datasource a plain local database, but `syncUrl` names a remote to " + + 'replicate from: the database would still be synced with that remote as an embedded replica, ' + + 'so the declared local mode would be ignored — the turso driver refuses this configuration ' + + 'when it starts.'; + +/** #20437's first sentence, which a forced replica with no `syncUrl` keeps. */ +const REPLICA_FIRST_SENTENCE = + "`mode: 'replica'` makes this datasource an embedded replica, a local file kept in sync with " + + 'the remote named in `syncUrl`, but no `syncUrl` is set:'; + +const PRIMARY = 'libsql://primary.example.turso.io'; +const files = replicaFiles(); +afterAll(() => files.removeAll()); + +/** `file:` → `FILE:`, the same database spelled with an uppercase scheme. */ +const upperFile = (fileUrl: string) => `FILE:${fileUrl.slice('file:'.length)}`; + +/** A libSQL client that only counts the syncs the driver asks it for. */ +function syncCountingClient() { + const client = { + syncs: 0, + async sync() { + client.syncs += 1; + }, + close() {}, + }; + return client; +} + +describe("a forced `mode: 'local'` beside a `syncUrl` — refused at construction", () => { + it.each<[string, () => TursoDriverConfig]>([ + ['a file: url', () => ({ url: files.next(), mode: 'local', syncUrl: PRIMARY })], + ['an uppercase FILE: url', () => ({ url: upperFile(files.next()), mode: 'local', syncUrl: PRIMARY })], + [':memory:', () => ({ url: ':memory:', mode: 'local', syncUrl: PRIMARY })], + ['file::memory:', () => ({ url: 'file::memory:', mode: 'local', syncUrl: PRIMARY })], + ['beside sync', () => ({ url: files.next(), mode: 'local', syncUrl: PRIMARY, sync: { intervalSeconds: 60, onConnect: true } })], + ['beside a timeout', () => ({ url: files.next(), mode: 'local', syncUrl: PRIMARY, timeout: 5000 })], + ['beside an encryptionKey', () => ({ url: files.next(), mode: 'local', syncUrl: PRIMARY, encryptionKey: 'aes-256-key' })], + ])("%s: VALIDATION_ERROR / 400, in the spec contract's words", (_name, config) => { + const refusal = refusalOf(() => new TursoDriver(config())); + + expectEnvelope(refusal); + expect(refusal.message.startsWith(LOCAL_FIRST_SENTENCE)).toBe(true); + // The two ways out, each by its spelling. + expect(refusal.message).toContain( + "For an embedded replica, drop `mode` and keep `syncUrl` beside the local file: `url: 'file:./data/replica.db'`.", + ); + expect(refusal.message).toContain('For a plain local database, drop `syncUrl` (and `sync`).'); + }); + + it('beside a supplied client: refused before any client work, the client never synced or written', () => { + const stub = makeLibsqlSqliteStub(); + const tablesInStub = () => + stub.raw.prepare(`select count(*) as c from sqlite_master where type='table'`).all()[0].c; + + const refusal = refusalOf( + () => new TursoDriver({ url: files.next(), mode: 'local', syncUrl: PRIMARY, client: stub as never }), + ); + + expectEnvelope(refusal); + expect(refusal.message.startsWith(LOCAL_FIRST_SENTENCE)).toBe(true); + expect(tablesInStub()).toBe(0); + stub.close(); + }); + + it('createTursoDriver() is the same constructor, and refuses the same config', () => { + const refusal = refusalOf(() => createTursoDriver({ url: files.next(), mode: 'local', syncUrl: PRIMARY })); + + expectEnvelope(refusal); + expect(refusal.message.startsWith(LOCAL_FIRST_SENTENCE)).toBe(true); + }); + + it('never echoes either url: a path or a token in them stays out of the boot log', () => { + const refusal = refusalOf( + () => + new TursoDriver({ + url: 'file:./private-dir/app.db?authToken=SECRET-FILE-TOKEN', + mode: 'local', + syncUrl: 'libsql://private-host.turso.io?authToken=SECRET-SYNC-TOKEN', + }), + ); + + expectEnvelope(refusal); + for (const secret of ['SECRET-FILE-TOKEN', 'private-dir', 'SECRET-SYNC-TOKEN', 'private-host']) { + expect(refusal.message).not.toContain(secret); + } + }); +}); + +describe('ORDER — a config another refusal already takes keeps that refusal', () => { + it.each<[string, TursoDriverConfig, string]>([ + ['a remote url + syncUrl', { url: 'libsql://db.example.turso.io', mode: 'local', syncUrl: PRIMARY }, 'is a remote `libsql://` url'], + ['a bare path + syncUrl', { url: './data/app.db', mode: 'local', syncUrl: PRIMARY }, 'is not a url this driver recognises'], + [ + '`sync` with no syncUrl', + { url: 'file:./data/app.db', mode: 'local', sync: { intervalSeconds: 60 } }, + '`sync` configures embedded-replica syncing', + ], + ["a forced mode 'replica' with no syncUrl", { url: 'file:./data/replica.db', mode: 'replica' }, REPLICA_FIRST_SENTENCE], + ])('%s: the earlier refusal, not this one', (_name, config, names) => { + const refusal = refusalOf(() => new TursoDriver(config)); + + expectEnvelope(refusal); + expect(refusal.message).toContain(names); + expect(refusal.message.startsWith(LOCAL_FIRST_SENTENCE)).toBe(false); + }); +}); + +describe('CONTROLS — what the refusal must leave accepted', () => { + it('the unforced file: + syncUrl replica still constructs, connects and syncs', async () => { + const client = syncCountingClient(); + const driver = new TursoDriver({ url: files.next(), syncUrl: PRIMARY, client: client as never }); + + expect(driver.transportMode).toBe('replica'); + await driver.connect(); + expect(driver.isSyncEnabled()).toBe(true); + expect(client.syncs).toBe(1); + await driver.disconnect(); + }); + + it.each<[string, () => TursoDriverConfig]>([ + ["a file: url under a forced mode 'local', no syncUrl", () => ({ url: files.next(), mode: 'local' })], + ["a file: url under a forced mode 'local', an empty syncUrl (unset)", () => ({ url: files.next(), mode: 'local', syncUrl: '' })], + ])('%s is a local database that syncs nothing', async (_name, config) => { + const client = syncCountingClient(); + const driver = new TursoDriver({ ...config(), client: client as never }); + + expect(driver.transportMode).toBe('local'); + await driver.connect(); + expect(driver.isSyncEnabled()).toBe(false); + expect(client.syncs).toBe(0); + await driver.disconnect(); + }); + + it.each<[string, TursoDriverConfig, string]>([ + ["a forced mode 'replica' beside syncUrl", { url: 'file:./data/replica.db', mode: 'replica', syncUrl: PRIMARY, sync: { onConnect: false } }, 'replica'], + [":memory: under a forced mode 'local', no syncUrl", { url: ':memory:', mode: 'local' }, 'local'], + ])('%s constructs', (_name, config, mode) => { + // Knex opens its connection lazily, so constructing never touches the file. + expect(new TursoDriver(config).transportMode).toBe(mode); + }); + + it('detectMode still classifies a forced local mode as local; the refusal is in the constructor', () => { + expect(TursoDriver.detectMode({ url: 'file:./data/app.db', mode: 'local', syncUrl: PRIMARY })).toBe('local'); + }); +}); diff --git a/packages/drivers/driver-turso/src/turso-driver-ignored-sync-key-refusal.test.ts b/packages/drivers/driver-turso/src/turso-driver-ignored-sync-key-refusal.test.ts index 184e544c446..273182ea2c0 100644 --- a/packages/drivers/driver-turso/src/turso-driver-ignored-sync-key-refusal.test.ts +++ b/packages/drivers/driver-turso/src/turso-driver-ignored-sync-key-refusal.test.ts @@ -26,11 +26,14 @@ * `TursoConfigSchema` issue texts byte for byte, and that equality is pinned in * `spec/turso-config-constructor-parity.test.ts`, not here. The refusals' * WIDTH is pinned by controls that must stay accepted: a remote config without - * `syncUrl`, a replica on a `file:` url beside `syncUrl`, and `sync` beside - * `syncUrl` under a forced `mode: 'local'`. The rider this card left standing - * (`mode: 'replica'` on a `file:` url with no `syncUrl`) is refused since - * #20437, together with the spec contract; it is pinned in - * `turso-driver-forced-replica-without-sync-url-refusal.test.ts`. + * `syncUrl`, and a replica on a `file:` url beside `syncUrl`. The rider this + * card left standing (`mode: 'replica'` on a `file:` url with no `syncUrl`) is + * refused since #20437, together with the spec contract; it is pinned in + * `turso-driver-forced-replica-without-sync-url-refusal.test.ts`. The third + * control this file used to carry, `sync` beside `syncUrl` under a forced + * `mode: 'local'`, is refused since #20586 (the driver synced it anyway, under + * a `local` label); it is pinned in + * `turso-driver-forced-local-with-sync-url-refusal.test.ts`. * * # Reverse verification: direction predicted before it was run * @@ -129,10 +132,4 @@ describe('CONTROLS — what the refusals must leave accepted', () => { expect(driver.transportMode).toBe('replica'); }); - - it("sync beside syncUrl under a forced mode: 'local' stays accepted, as the contract accepts it", () => { - const driver = new TursoDriver({ url: file('forced-local'), mode: 'local', syncUrl: PRIMARY, sync: { onConnect: false } }); - - expect(driver.transportMode).toBe('local'); - }); }); diff --git a/packages/spec/src/data/driver/turso.test.ts b/packages/spec/src/data/driver/turso.test.ts index 8b41579643d..c73f40542ad 100644 --- a/packages/spec/src/data/driver/turso.test.ts +++ b/packages/spec/src/data/driver/turso.test.ts @@ -33,7 +33,9 @@ describe('TursoConfigSchema', () => { { url: 'LIBSQL://x.turso.io' }, { url: 'FILE:./data/replica.db', syncUrl: 'libsql://x.turso.io' }, { url: 'file:./data/replica.db', mode: 'replica', syncUrl: 'libsql://x.turso.io' }, - { url: 'file:./data/app.db', mode: 'local', syncUrl: 'libsql://x.turso.io' }, + // #20586: a forced `mode: 'local'` beside a NON-empty `syncUrl` left this + // list (refused on `mode`, below); an empty `syncUrl` is unset, so this stays. + { url: 'file:./data/app.db', mode: 'local', syncUrl: '' }, { url: 'file:./data/app.db', mode: 'remote' }, { url: './data/app.db', mode: 'remote' }, { url: 'wss://x.turso.io' }, @@ -200,7 +202,9 @@ describe('TursoConfig.timeout carries its unit (#15680)', () => { // at construction (since #20200 that includes `syncUrl` under a forced // `mode: 'remote'`, which it used to construct and ignore; since #20437 a // forced `mode: 'replica'` with no `syncUrl`, which it used to construct as a -// replica that never synced), refused at authoring. Asserted on the envelope — the +// replica that never synced; since #20586 a forced `mode: 'local'` beside a +// `syncUrl`, which it used to construct as a `local` database and then sync +// anyway), refused at authoring. Asserted on the envelope — the // issue code, the key it sits on, and the spelling it prescribes — never on a // bare `success: false`, which a schema refusing for some OTHER reason (a // credential, a placeholder) would satisfy identically. The driver-local mirror @@ -397,6 +401,76 @@ describe('TursoConfigSchema refuses what the turso driver refuses (#19977)', () }); }); + // #20586 — the same defect the other way round: a forced local mode beside a + // remote to replicate from. The driver used to label it local and sync it + // anyway; both now refuse it, on `mode`, in one message. + describe("a forced `mode: 'local'` beside a `syncUrl` — refused on `mode`", () => { + const FIRST_SENTENCE = + "`mode: 'local'` makes this datasource a plain local database, but `syncUrl` names a remote to " + + 'replicate from: the database would still be synced with that remote as an embedded replica, ' + + 'so the declared local mode would be ignored — the turso driver refuses this configuration ' + + 'when it starts.'; + + it('on a `file:` url in any case or an in-memory url, beside `sync` or `timeoutMs`: drop `mode`, or drop `syncUrl`', () => { + for (const config of [ + { url: 'file:./data/app.db', mode: 'local', syncUrl: 'libsql://db.turso.io' }, + { url: 'FILE:./data/app.db', mode: 'local', syncUrl: 'libsql://db.turso.io' }, + { url: ' file:./data/app.db', mode: 'local', syncUrl: 'https://db.turso.io', timeoutMs: 5000 }, + { url: 'file:./data/app.db', mode: 'local', syncUrl: 'libsql://db.turso.io', sync: { intervalSeconds: 60 } }, + { url: ':memory:', mode: 'local', syncUrl: 'libsql://db.turso.io' }, + { url: 'file::memory:', mode: 'local', syncUrl: 'libsql://db.turso.io' }, + ]) { + const { path, message } = refusal(config); + expect(path, JSON.stringify(config)).toBe('mode'); + expect(message.startsWith(FIRST_SENTENCE), message).toBe(true); + expect(message).toContain( + "For an embedded replica, drop `mode` and keep `syncUrl` beside the local file: `url: 'file:./data/replica.db'`.", + ); + expect(message).toContain('For a plain local database, drop `syncUrl` (and `sync`).'); + } + }); + + it('never echoes either url', () => { + const { message } = refusal({ url: 'file:./private-dir/app.db', mode: 'local', syncUrl: 'libsql://private-host.turso.io' }); + expect(message).not.toContain('private-dir'); + expect(message).not.toContain('private-host'); + }); + + it('a url another refusal takes keeps that refusal, on `url`', () => { + for (const url of ['libsql://db.turso.io', 'https://db.turso.io', './data/app.db']) { + const { path, message } = refusal({ url, mode: 'local', syncUrl: 'libsql://db.turso.io' }); + expect(path, url).toBe('url'); + expect(message.startsWith(FIRST_SENTENCE)).toBe(false); + } + }); + + it('reaches both authoring doors: `DatasourceSchema` (re-pathed under `config`) and `validateDriverConfig`', () => { + const config = { url: 'file:./data/app.db', mode: 'local', syncUrl: 'libsql://db.turso.io' }; + const datasource = DatasourceSchema.safeParse({ name: 'edge', driver: 'turso', config }); + expect(datasource.success).toBe(false); + const issue = datasource.error!.issues.find((i) => i.path.join('.') === 'config.mode'); + expect(issue, JSON.stringify(datasource.error!.issues)).toBeDefined(); + expect(issue!.message.startsWith(FIRST_SENTENCE)).toBe(true); + + const verdict = validateDriverConfig('turso', config); + expect(verdict.known).toBe(true); + expect(verdict.known && verdict.issues.map((i) => i.path.join('.'))).toEqual(['mode']); + }); + + it('controls: the unforced `file:` + `syncUrl` replica, and a forced local mode with no or an empty `syncUrl`, stay accepted', () => { + for (const config of [ + { url: 'file:./data/replica.db', syncUrl: 'libsql://db.turso.io' }, + { url: 'file:./data/replica.db', syncUrl: 'libsql://db.turso.io', sync: { intervalSeconds: 60 } }, + { url: 'file:./data/app.db', mode: 'local' }, + { url: 'file:./data/app.db', mode: 'local', syncUrl: '' }, + { url: ':memory:', mode: 'local' }, + ]) { + const result = TursoConfigSchema.safeParse(config); + expect(result.success, JSON.stringify(result.error?.issues)).toBe(true); + } + }); + }); + it('the pre-existing `sync`-without-`syncUrl` refusal is unchanged, and stands beside a transport refusal', () => { const result = TursoConfigSchema.safeParse({ url: './data/app.db', sync: { intervalSeconds: 60 } }); expect(result.success).toBe(false); From 7ee1acb58219688da06bd20ce335372ac79c74b5 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 15:30:55 +0000 Subject: [PATCH 3/4] wip(spec): register the forced-local refusal as a D3 entry, and its changeset Claude-Session: https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY Co-authored-by: Claude --- ...586-turso-forced-local-refuses-sync-url.md | 30 +++++++++++ ...nfig-forced-local-with-sync-url-refused.ts | 54 +++++++++++++++++++ packages/spec/src/migrations/registry.ts | 50 +++++++++++++++++ 3 files changed, 134 insertions(+) create mode 100644 .changeset/20586-turso-forced-local-refuses-sync-url.md create mode 100644 packages/spec/src/migrations/entries/semantic/18.turso-config-forced-local-with-sync-url-refused.ts diff --git a/.changeset/20586-turso-forced-local-refuses-sync-url.md b/.changeset/20586-turso-forced-local-refuses-sync-url.md new file mode 100644 index 00000000000..fe7c25cfce8 --- /dev/null +++ b/.changeset/20586-turso-forced-local-refuses-sync-url.md @@ -0,0 +1,30 @@ +--- +'@objectstack/spec': minor +'@objectstack/driver-turso': minor +--- + +fix(spec,driver-turso)!: a turso config that forces `mode: 'local'` beside a `syncUrl` is refused where it is written and when the driver is built, instead of running as an embedded replica under a `local` label + +Clause-②: yes (narrowing) — the accept set of the `turso` `datasource.config` contract narrows by one combination. No key is added, removed or renamed, and no exported symbol moves. + +A `syncUrl` names the remote an embedded replica syncs with. A config that forced `mode: 'local'` on a `file:` url (or `:memory:`) beside a non-empty `syncUrl` was accepted by `@objectstack/spec`'s `TursoConfigSchema`, by the published mirror in `@objectstack/driver-turso`, and by `new TursoDriver()`. Measured on the driver source before this change, with a client that counts syncs: it constructed with `transportMode` `'local'`, then synced on connect, started the sync interval, and `isSyncEnabled()` answered `true` — exactly what the same config with no `mode` (a replica) did. A datasource declared local was kept in sync with a remote, and only a label said otherwise. + +**BREAKING** accept-set narrowing on a published schema and a published constructor, shipped as `minor` under the repo's launch-window convention for breaking changes (`scripts/check-changeset-no-major.mjs`). Refused now, at both doors together, with one message whose prescription names both ways out: + +- **at authoring**, as one `custom` issue on `mode` (`config.mode` on a datasource): `DatasourceSchema`, `validateDriverConfig`, `defineStack` / `os validate`, and a save or test connection through the datasource admin service; +- **at construction**, `VALIDATION_ERROR` / 400 from `new TursoDriver()` (and `createTursoDriver()`), before any client or database is opened. + +The message is the same text at both doors, and a test holds the constructor's copy equal to the schema's issue byte for byte. It is the twin of the forced `mode: 'replica'`-without-`syncUrl` refusal, the other way round: honouring `mode: 'local'` by skipping the sync would ignore a declared `syncUrl` instead, which is the same defect with the keys swapped. The sibling refusals keep their order: a forced local mode on a remote url or a bare path still meets its `url` refusal first. An empty `syncUrl` is unset and is still accepted. The driver mirror declares no `mode` key and strips an authored one, so it cannot see a forced mode: this refusal reaches it only as byte-identical text, and the spec contract and the constructor are the two doors that judge it. + +### Migration: FROM → TO + +| You wrote | Write instead | +| --- | --- | +| `url: 'file:./data/replica.db', mode: 'local', syncUrl: 'libsql://my-db.turso.io'` | an embedded replica: drop `mode` (`url` and `syncUrl` select the replica) | +| the same | a plain local database: drop `syncUrl` (and `sync`), keeping `url: 'file:./data/app.db'` with or without `mode: 'local'` | + +A datasource row stored in this shape is not re-parsed when it loads, so it now fails when the driver is built. `factory.create` throws the refusal. The connection service records the datasource as `failed-degraded` with the message, and a test connection answers `ok: false` ("Failed to build driver: …"). Under ADR-0062 D5, the boot fails fast when objects bind to that datasource or are routed to it, or when it is boot-critical, unless `OS_ALLOW_DRIVER_CONNECT_FAILURE` is set. Otherwise it is left unconnected with a warning. Before this change the same row booted and synced with the remote under a `local` label. The way out is the table above. + +Blast radius, measured on this tree: no example, template, published skill or hand-written doc authors the shape, and no host default or environment variable sets `mode` or `syncUrl` (a turso `mode` reaches the driver only from an authored `datasource.config`). Whether any out-of-repo deployment declares such a config is NOT measured and is not claimed to be zero. + + diff --git a/packages/spec/src/migrations/entries/semantic/18.turso-config-forced-local-with-sync-url-refused.ts b/packages/spec/src/migrations/entries/semantic/18.turso-config-forced-local-with-sync-url-refused.ts new file mode 100644 index 00000000000..76a68995619 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.turso-config-forced-local-with-sync-url-refused.ts @@ -0,0 +1,54 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// A forced local mode beside a remote to replicate from, refused at both doors +// together: the datasource contract (on mode) and the turso driver's +// constructor, in one message. The driver used to label it local and run it as +// an embedded replica anyway. The twin of +// turso-config-forced-replica-without-sync-url-refused, the other way round. A +// structured TODO, not a D2 conversion: whether the author meant an embedded +// replica of that remote or a plain local file is intent no artifact records. +export const entry: SemanticMigration = { + id: 'turso-config-forced-local-with-sync-url-refused', + // No backticks in `surface` — build-upgrade-guide renders it inside a code + // span already, and a nested backtick would close it. + surface: + 'data.TursoConfig (a turso / libsql datasource.config) and the TursoDriver constructor of ' + + '@objectstack/driver-turso — mode local beside a non-empty syncUrl is now refused, on mode at ' + + 'authoring and at construction. The published TursoConfigSchema mirror of ' + + '@objectstack/driver-turso carries the same text for parity but declares no mode key and strips ' + + 'an authored one, so it cannot see a forced mode and still accepts the config as a replica', + replacement: + 'the configuration the author meant. An embedded replica drops mode: keep the file: url and ' + + 'syncUrl, for example url file:./data/replica.db with syncUrl naming the remote, and the url ' + + 'and syncUrl select the replica. A plain local database drops syncUrl and sync: a file: url ' + + '(or :memory:) with no syncUrl is a local database, with or without mode local', + reason: + 'A syncUrl names the remote an embedded replica syncs with, and the turso driver syncs whenever ' + + 'it is set on a local engine, whatever mode says. The triage ruling of 2026-09-29 weighed ' + + 'refusing this shape against honouring mode local by skipping the sync, and refused it: ' + + 'honouring it would ignore a declared syncUrl, the same defect with the keys swapped, and a ' + + 'loud contradiction is the author\'s to resolve. A forced mode local beside a syncUrl parsed ' + + 'clean at authoring, and the driver built it with a local transport label and then ran it as ' + + 'a replica: it synced on connect, started the sync interval and answered true to the ' + + 'sync-enabled check, exactly as the same config with no mode did (measured on the driver ' + + 'source). A declared mode the runtime ignores is the declared-but-not-enforced shape ADR-0049 ' + + 'does not ship, so the datasource contract and the constructor now refuse it together, with ' + + 'one message, which names both ways out. The sibling refusals keep their order: a forced ' + + 'local mode on a remote url or a bare path meets its url refusal first. An empty syncUrl is ' + + 'unset and is not refused. Stored datasource rows are not re-parsed when they load, so a ' + + 'stored row in this shape now fails when its driver is built: the connection service records ' + + 'it as failed-degraded, a test connection answers ok false, and under ADR-0062 D5 the boot ' + + 'fails fast when objects bind to that datasource, unless OS_ALLOW_DRIVER_CONNECT_FAILURE is ' + + 'set. Measured on this tree at the change: no example, template, published skill or ' + + 'hand-written doc authors the shape, and no host default or environment variable sets mode ' + + 'or syncUrl. ADR-0049 / ADR-0087 / ADR-0112.', + acceptanceCriteria: + 'Validate every stack and re-save every turso datasource: os validate or defineStack, and a ' + + 'save or test connection through the datasource admin service, report a forced local mode ' + + 'beside a syncUrl at config.mode with both ways out. Decide per datasource whether it is an ' + + 'embedded replica (drop mode) or a local file (drop syncUrl and sync). Done when every turso ' + + 'datasource parses, the driver builds from it, and no datasource that declares mode local ' + + 'carries a syncUrl.', +}; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index fda8905b1ec..e013f3ddb06 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -16982,6 +16982,56 @@ const step18: MigrationStep = { + '(`@objectstack/service-settings`’s `settingsBuiltinTranslations`) — ⛔ do not re-add ' + 'app-side copy at either door, which is refused.', }, + // A forced local mode beside a remote to replicate from, refused at both doors + // together: the datasource contract (on mode) and the turso driver's + // constructor, in one message. The driver used to label it local and run it as + // an embedded replica anyway. The twin of + // turso-config-forced-replica-without-sync-url-refused, the other way round. A + // structured TODO, not a D2 conversion: whether the author meant an embedded + // replica of that remote or a plain local file is intent no artifact records. + { + id: 'turso-config-forced-local-with-sync-url-refused', + // No backticks in `surface` — build-upgrade-guide renders it inside a code + // span already, and a nested backtick would close it. + surface: + 'data.TursoConfig (a turso / libsql datasource.config) and the TursoDriver constructor of ' + + '@objectstack/driver-turso — mode local beside a non-empty syncUrl is now refused, on mode at ' + + 'authoring and at construction. The published TursoConfigSchema mirror of ' + + '@objectstack/driver-turso carries the same text for parity but declares no mode key and strips ' + + 'an authored one, so it cannot see a forced mode and still accepts the config as a replica', + replacement: + 'the configuration the author meant. An embedded replica drops mode: keep the file: url and ' + + 'syncUrl, for example url file:./data/replica.db with syncUrl naming the remote, and the url ' + + 'and syncUrl select the replica. A plain local database drops syncUrl and sync: a file: url ' + + '(or :memory:) with no syncUrl is a local database, with or without mode local', + reason: + 'A syncUrl names the remote an embedded replica syncs with, and the turso driver syncs whenever ' + + 'it is set on a local engine, whatever mode says. The triage ruling of 2026-09-29 weighed ' + + 'refusing this shape against honouring mode local by skipping the sync, and refused it: ' + + 'honouring it would ignore a declared syncUrl, the same defect with the keys swapped, and a ' + + 'loud contradiction is the author\'s to resolve. A forced mode local beside a syncUrl parsed ' + + 'clean at authoring, and the driver built it with a local transport label and then ran it as ' + + 'a replica: it synced on connect, started the sync interval and answered true to the ' + + 'sync-enabled check, exactly as the same config with no mode did (measured on the driver ' + + 'source). A declared mode the runtime ignores is the declared-but-not-enforced shape ADR-0049 ' + + 'does not ship, so the datasource contract and the constructor now refuse it together, with ' + + 'one message, which names both ways out. The sibling refusals keep their order: a forced ' + + 'local mode on a remote url or a bare path meets its url refusal first. An empty syncUrl is ' + + 'unset and is not refused. Stored datasource rows are not re-parsed when they load, so a ' + + 'stored row in this shape now fails when its driver is built: the connection service records ' + + 'it as failed-degraded, a test connection answers ok false, and under ADR-0062 D5 the boot ' + + 'fails fast when objects bind to that datasource, unless OS_ALLOW_DRIVER_CONNECT_FAILURE is ' + + 'set. Measured on this tree at the change: no example, template, published skill or ' + + 'hand-written doc authors the shape, and no host default or environment variable sets mode ' + + 'or syncUrl. ADR-0049 / ADR-0087 / ADR-0112.', + acceptanceCriteria: + 'Validate every stack and re-save every turso datasource: os validate or defineStack, and a ' + + 'save or test connection through the datasource admin service, report a forced local mode ' + + 'beside a syncUrl at config.mode with both ways out. Decide per datasource whether it is an ' + + 'embedded replica (drop mode) or a local file (drop syncUrl and sync). Done when every turso ' + + 'datasource parses, the driver builds from it, and no datasource that declares mode local ' + + 'carries a syncUrl.', + }, // A forced embedded replica with no remote to replicate from, refused at both // doors together: the datasource contract (on mode) and the turso driver's // constructor, in one message. The driver used to build it as a replica that From a0c4c033af1498b5dfd41105b3e3c406b3acab1e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 16:25:36 +0000 Subject: [PATCH 4/4] docs(driver-turso): the README lists the forced mode local beside syncUrl refusal as the fourth sync setting the constructor refuses Claude-Session: https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY Co-authored-by: Claude --- packages/drivers/driver-turso/README.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/drivers/driver-turso/README.md b/packages/drivers/driver-turso/README.md index e14f8fe8a0e..313fc6615a8 100644 --- a/packages/drivers/driver-turso/README.md +++ b/packages/drivers/driver-turso/README.md @@ -223,7 +223,7 @@ no embedded replica for a remote url anyway. For a remote database, drop no local engine, so its url is not judged here: `@libsql/client` refuses a url it cannot open when the driver connects. -The constructor also refuses (`VALIDATION_ERROR` / 400) three sync settings +The constructor also refuses (`VALIDATION_ERROR` / 400) four sync settings that nothing would honour, each with the message `@objectstack/spec`'s `TursoConfigSchema` gives at authoring: @@ -233,7 +233,11 @@ that nothing would honour, each with the message `@objectstack/spec`'s it. Set `syncUrl`, or remove `sync`; - a forced `mode: 'replica'` with no `syncUrl` (or an empty one), which would never sync and would run as a plain local database. Name the remote in - `syncUrl` beside the `file:` url, or drop `mode` for a local database. + `syncUrl` beside the `file:` url, or drop `mode` for a local database; +- a forced `mode: 'local'` beside a non-empty `syncUrl`, which would still be + synced with that remote as an embedded replica, so the declared local mode + would be ignored. Drop `mode` for an embedded replica, or drop `syncUrl` (and + `sync`) for a plain local database. You can also force a specific mode: