From bebe1faae67f82682f84775bbca4c388a4f57013 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 13:20:03 +0000 Subject: [PATCH 1/3] fix(driver-turso): refuse a local or replica engine with nothing durable behind it A remote url beside `syncUrl` was classified `replica` and handed Knex a private `:memory:` database, so every write read back and then vanished on restart, and nothing reached the remote. `@libsql/client` builds no embedded replica for a remote url. The same last arm of `toKnexConfig` took a forced `mode: 'replica'` or `mode: 'local'` beside a remote url, and a replica on `:memory:`. The constructor now refuses those configurations before `super()`, as VALIDATION_ERROR / 400, naming the way out: drop `syncUrl` (or `mode`) for a remote database, or point `url` at a local `file:` for an embedded replica. The false "embedded replica with an in-memory local cache" comment is replaced. Replica-face fixtures that rode `:memory:` + `syncUrl` now use a fresh local file per driver. Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx Co-authored-by: Claude --- packages/drivers/driver-turso/README.md | 13 +- .../driver-turso/src/replica-file.testkit.ts | 44 ++++ ...-driver-remote-url-replica-refusal.test.ts | 220 ++++++++++++++++ ...er-supplied-client-timeout-refusal.test.ts | 9 +- .../src/turso-driver-timeout.test.ts | 9 +- .../turso-driver-ws-timeout-refusal.test.ts | 23 +- .../driver-turso/src/turso-driver.test.ts | 61 +++-- .../drivers/driver-turso/src/turso-driver.ts | 236 ++++++++++++++++-- .../turso-remote-autonumber-refusal.test.ts | 9 +- .../src/turso-remote-deferred-ddl.test.ts | 9 +- ...rso-remote-drift-detection-refusal.test.ts | 9 +- .../turso-remote-transaction-refusal.test.ts | 9 +- ...ansactions-unsupported-declaration.test.ts | 4 +- 13 files changed, 586 insertions(+), 69 deletions(-) create mode 100644 packages/drivers/driver-turso/src/replica-file.testkit.ts create mode 100644 packages/drivers/driver-turso/src/turso-driver-remote-url-replica-refusal.test.ts diff --git a/packages/drivers/driver-turso/README.md b/packages/drivers/driver-turso/README.md index c4fd445c240..82c7b974458 100644 --- a/packages/drivers/driver-turso/README.md +++ b/packages/drivers/driver-turso/README.md @@ -130,6 +130,17 @@ Transport mode is automatically detected from the URL: | `libsql://...` | `remote` | @libsql/client only | | `https://...` | `remote` | @libsql/client only | +An embedded replica is a local **file** kept in sync with a remote. The local and +replica modes run every read and write through the local SQLite engine, which +cannot open a remote url or keep a replica in `:memory:`. So the constructor +refuses (`VALIDATION_ERROR` / 400) a remote url (`libsql://`, `https://`, +`http://`, `wss://`, `ws://`) beside `syncUrl` or under a forced `mode: 'local'` +/ `'replica'`, and a replica whose `url` is not a local `file:` path. The engine +would otherwise run on a private in-memory database whose writes read back and +then vanish on restart, and `@libsql/client` builds no embedded replica for a +remote url anyway. For a remote database, drop `syncUrl`. For an embedded +replica, use `url: 'file:./data/replica.db'` beside `syncUrl`. + You can also force a specific mode: ```typescript @@ -216,7 +227,7 @@ interface TursoDriverConfig { */ concurrency?: number; - /** Remote sync URL for embedded replica mode (libsql:// or https://) */ + /** Remote sync URL for embedded replica mode (libsql:// or https://); `url` must be a local file: */ syncUrl?: string; /** Sync configuration (requires syncUrl) */ diff --git a/packages/drivers/driver-turso/src/replica-file.testkit.ts b/packages/drivers/driver-turso/src/replica-file.testkit.ts new file mode 100644 index 00000000000..0f4f157a16d --- /dev/null +++ b/packages/drivers/driver-turso/src/replica-file.testkit.ts @@ -0,0 +1,44 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * A fresh local FILE for an embedded-replica fixture. + * + * An embedded replica is a local file kept in sync with the remote named in + * `syncUrl`, and `TursoDriver` refuses a replica on anything else at + * construction (`:memory:`, a remote url). In those cases the local engine + * would have run on a private in-memory database that no sync ever reaches. + * A fixture that exercises the replica face therefore needs a real path. It + * needs a NEW one per driver, so each test starts from an empty database, + * exactly as the `:memory:` fixtures these replace did. + * + * ```ts + * const files = replicaFiles(); + * afterAll(() => files.removeAll()); + * new TursoDriver({ url: files.next(), syncUrl, client, sync: { onConnect: false } }); + * ``` + */ + +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +export interface ReplicaFiles { + /** A `file:` url naming a database file in a directory of its own; the file does not exist yet. */ + next(): string; + /** Delete every directory `next()` created. Idempotent. */ + removeAll(): void; +} + +export function replicaFiles(): ReplicaFiles { + const dirs: string[] = []; + return { + next() { + const dir = mkdtempSync(join(tmpdir(), 'turso-replica-')); + dirs.push(dir); + return `file:${join(dir, 'replica.db')}`; + }, + removeAll() { + while (dirs.length > 0) rmSync(dirs.pop()!, { recursive: true, force: true }); + }, + }; +} diff --git a/packages/drivers/driver-turso/src/turso-driver-remote-url-replica-refusal.test.ts b/packages/drivers/driver-turso/src/turso-driver-remote-url-replica-refusal.test.ts new file mode 100644 index 00000000000..b22754607f6 --- /dev/null +++ b/packages/drivers/driver-turso/src/turso-driver-remote-url-replica-refusal.test.ts @@ -0,0 +1,220 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * A local or replica `TursoDriver` with nothing durable behind its engine is + * refused at construction. Before this, it silently ran on a private + * `:memory:` database. + * + * # What was measured (the reading this refusal stands on) + * + * The local and replica arms run every read and write through the inherited + * Knex + better-sqlite3 engine, and `TursoDriver.toKnexConfig` could hand that + * engine only a `file:` path or `:memory:`. Everything else went to its last + * arm, which was `:memory:`. On `main` @ `2c1011b01b`, `initObjects`, then + * `create`, then `find`, then a fresh driver on the same config: + * + * ``` + * libsql:// + syncUrl + client stub, sync.onConnect false -> replica, knex :memory:, + * find 1 row, the client's own database held NO tables, 0 rows after restart + * libsql:// / https:// / wss:// + syncUrl, real client, onConnect false -> same, 0 after restart + * libsql:// + syncUrl, real client, default sync -> connect() rejects SYNC_NOT_SUPPORTED + * libsql:// + mode 'replica' (no syncUrl) -> 1 row, 0 after restart + * libsql:// + mode 'local' -> 1 row, 0 after restart + * :memory: + syncUrl, real client, default sync -> connect() rejects URL_INVALID + * :memory: + syncUrl + client stub, onConnect false -> 1 row, stub holds no tables, 0 after restart + * file: + syncUrl + client stub (CONTROL) -> knex on the file, 1 row, 1 after restart + * ``` + * + * `@libsql/client@0.17.4` builds no embedded replica for a remote url: + * `lib-esm/node.js` routes http/https to its HTTP client and ws/wss to its + * WebSocket client, `syncUrl` is read only in `lib-esm/sqlite3.js` (a `syncUrl` + * grep over `http.js` / `ws.js` returns zero, while `authToken` returns six in + * each), and both remote clients' `sync()` throw `SYNC_NOT_SUPPORTED`. The + * sqlite3 client refuses an in-memory replica itself: "Embedded replica must use + * file for local db". + * + * # What this file pins + * + * - The card's own reproduction (remote url, `syncUrl`, a client stub, + * `sync.onConnect: false`) is refused at construction, as the ADR-0112 + * envelope (`code` + `status`). The stub is never touched. Each remote scheme + * the classifier knows is covered. + * - Each other arm, as measured: a forced `mode: 'replica'` or `mode: 'local'` + * beside a remote url, and a replica on `:memory:` / `file::memory:` / a + * url that is not `file:`. + * - The message names the way out and never echoes the url, which may carry a + * live `?authToken=`. + * - PRESERVATION: the `file:` replica still constructs, connects, writes, and + * keeps its rows across a restart. So do the local, in-memory and remote + * faces. `detectMode` still classifies the refused pair `replica`: the + * refusal is in the constructor, not a re-classification. + * + * # Reverse verification: direction predicted before it was run + * + * With the constructor's `localEngineDefect` call removed, every refusal case + * goes RED (the constructor returns a driver on `:memory:`, with no envelope to + * read) and every preservation case stays 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); +} + +const PRIMARY = 'libsql://primary.example.turso.io'; +const NOTE = { name: 'note', fields: { title: { type: 'string' } } }; +const files = replicaFiles(); +afterAll(() => files.removeAll()); + +describe('a remote url beside syncUrl: refused, never a replica on :memory:', () => { + it("the card's reproduction: libsql:// + syncUrl + a client stub + sync.onConnect false", () => { + 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: 'libsql://r.turso.io', + syncUrl: 'libsql://r.turso.io', + client: stub as never, + sync: { onConnect: false }, + }), + ); + + expectEnvelope(refusal); + // Both ways out, by the subjects they name: the key to drop for a remote + // database, the url spelling for a replica. + expect(refusal.message).toContain('`syncUrl`'); + expect(refusal.message).toContain("url: 'file:"); + // Refused before any client work: the stub is untouched. + expect(tablesInStub()).toBe(0); + stub.close(); + }); + + it.each(['libsql://', 'https://', 'http://', 'wss://', 'ws://'])( + '%s + syncUrl is refused, naming the scheme it met', + (scheme) => { + const refusal = refusalOf(() => new TursoDriver({ url: `${scheme}db.example.turso.io`, syncUrl: PRIMARY })); + + expectEnvelope(refusal); + expect(refusal.message).toContain(`\`${scheme}\``); + expect(refusal.message).toContain('`syncUrl`'); + }, + ); + + it('the factory door meets the same refusal: createTursoDriver is not a way around it', () => { + expectEnvelope(refusalOf(() => createTursoDriver({ url: 'libsql://r.turso.io', syncUrl: PRIMARY }))); + }); + + it('a `timeout` beside the pair does not change which refusal fires: this one owns the pair', () => { + const refusal = refusalOf(() => new TursoDriver({ url: 'wss://r.turso.io', syncUrl: PRIMARY, timeout: 30_000 })); + + expectEnvelope(refusal); + expect(refusal.message).toContain('`syncUrl`'); + expect(refusal.message).not.toContain('TursoDriverConfig.timeout'); + }); + + it('never echoes the url: a live `?authToken=` in it stays out of the boot log', () => { + const refusal = refusalOf( + () => new TursoDriver({ url: 'libsql://r.turso.io?authToken=SECRET-TOKEN-VALUE', syncUrl: PRIMARY }), + ); + + expectEnvelope(refusal); + expect(refusal.message).not.toContain('SECRET-TOKEN-VALUE'); + expect(refusal.message).not.toContain('r.turso.io'); + }); +}); + +describe('a forced local or replica mode beside a remote url: refused', () => { + it.each<[string, TursoDriverConfig]>([ + ["mode 'replica', no syncUrl", { url: 'libsql://r.turso.io', mode: 'replica' }], + ["mode 'replica' + syncUrl", { url: 'libsql://r.turso.io', syncUrl: PRIMARY, mode: 'replica' }], + ["mode 'local'", { url: 'libsql://r.turso.io', mode: 'local' }], + ["mode 'local', https://", { url: 'https://r.turso.io', mode: 'local' }], + ])('%s', (_label, config) => { + const refusal = refusalOf(() => new TursoDriver(config)); + + expectEnvelope(refusal); + // The way out for a remote database names the key that forced the mode, + // and the value that would have been right. + expect(refusal.message).toContain(`\`mode: '${config.mode}'\``); + expect(refusal.message).toContain("mode: 'remote'"); + }); +}); + +describe('a replica that is not a local file: refused', () => { + it.each<[string, TursoDriverConfig]>([ + [':memory: + syncUrl', { url: ':memory:', syncUrl: PRIMARY }], + [':memory: + syncUrl + a supplied client', { url: ':memory:', syncUrl: PRIMARY, client: {} as never, sync: { onConnect: false } }], + ['file::memory: + syncUrl', { url: 'file::memory:', syncUrl: PRIMARY }], + ["file::memory:?cache=shared + mode 'replica'", { url: 'file::memory:?cache=shared', mode: 'replica' }], + [":memory: + mode 'replica'", { url: ':memory:', mode: 'replica' }], + ["a bare path + mode 'replica'", { url: './data/replica.db', mode: 'replica' }], + ])('%s', (_label, config) => { + const refusal = refusalOf(() => new TursoDriver(config)); + + expectEnvelope(refusal); + expect(refusal.message).toContain('embedded replica'); + expect(refusal.message).toContain("url: 'file:"); + }); +}); + +describe('PRESERVATION: every configuration with a durable (or declared-ephemeral) engine still constructs', () => { + it('the file: replica constructs, connects, writes, and keeps its rows across a restart', async () => { + const url = files.next(); + const stub = makeLibsqlSqliteStub(); + const make = () => new TursoDriver({ url, syncUrl: PRIMARY, client: stub as never, sync: { onConnect: false } }); + + const first = make(); + expect(first.transportMode).toBe('replica'); + await first.connect(); + await first.initObjects([NOTE as never]); + await first.create('note', { id: 'n1', title: 'kept' }); + expect(await first.find('note', {})).toHaveLength(1); + await first.disconnect(); + + const second = make(); + await second.connect(); + await second.initObjects([NOTE as never]); + expect((await second.find('note', {})).map((r: { title?: unknown }) => r.title)).toEqual(['kept']); + await second.disconnect(); + stub.close(); + }); + + it.each<[string, TursoDriverConfig, string]>([ + ['file: local', { url: 'file:./data/app.db' }, 'local'], + [':memory: local (ephemeral by declaration)', { url: ':memory:' }, 'local'], + ["file: + mode 'local'", { url: 'file:./data/app.db', mode: 'local' }, 'local'], + ["file: + mode 'replica' + syncUrl", { url: 'file:./data/replica.db', syncUrl: PRIMARY, mode: 'replica' }, 'replica'], + ['libsql:// remote', { url: 'libsql://r.turso.io', authToken: 't' }, 'remote'], + ["libsql:// + mode 'remote'", { url: 'libsql://r.turso.io', mode: 'remote' }, 'remote'], + ])('%s', (_label, config, mode) => { + // Knex opens its connection lazily, so constructing never touches the file. + expect(new TursoDriver(config).transportMode).toBe(mode); + }); + + it('detectMode still classifies the refused pair `replica`: the refusal is in the constructor, not a re-classification', () => { + expect(TursoDriver.detectMode({ url: 'libsql://r.turso.io', syncUrl: PRIMARY })).toBe('replica'); + expect(TursoDriver.detectMode({ url: ':memory:', syncUrl: PRIMARY })).toBe('replica'); + }); +}); diff --git a/packages/drivers/driver-turso/src/turso-driver-supplied-client-timeout-refusal.test.ts b/packages/drivers/driver-turso/src/turso-driver-supplied-client-timeout-refusal.test.ts index 5a511d3a21a..fa1a4baa8fa 100644 --- a/packages/drivers/driver-turso/src/turso-driver-supplied-client-timeout-refusal.test.ts +++ b/packages/drivers/driver-turso/src/turso-driver-supplied-client-timeout-refusal.test.ts @@ -56,10 +56,15 @@ */ import type { Client, ResultSet } from '@libsql/client'; -import { afterEach, describe, expect, it } from 'vitest'; +import { afterAll, afterEach, describe, expect, it } from 'vitest'; import { createTursoDriver } from './index.js'; +import { replicaFiles } from './replica-file.testkit.js'; import { TursoDriver } from './turso-driver.js'; +// A replica is a local FILE: the constructor refuses one on `:memory:`. +const files = replicaFiles(); +afterAll(() => files.removeAll()); + type Refusal = Error & { code?: string; status?: number }; /** The error `build` threw, or `null` when it returned. */ @@ -261,7 +266,7 @@ describe('CONTROLS — what the refusal must leave accepted', () => { it('THE REPLICA ARM IS UNTOUCHED: client + timeout is accepted there, and sync() is still bounded', async () => { const driver = new TursoDriver({ - url: ':memory:', + url: files.next(), syncUrl: PRIMARY_URL, authToken: 'token', client: stalledSyncClient(), diff --git a/packages/drivers/driver-turso/src/turso-driver-timeout.test.ts b/packages/drivers/driver-turso/src/turso-driver-timeout.test.ts index bcaed0863af..6b171cfd31a 100644 --- a/packages/drivers/driver-turso/src/turso-driver-timeout.test.ts +++ b/packages/drivers/driver-turso/src/turso-driver-timeout.test.ts @@ -56,9 +56,14 @@ import { createServer, type Server } from 'node:http'; import type { AddressInfo } from 'node:net'; import type { Client } from '@libsql/client'; -import { afterEach, describe, expect, it } from 'vitest'; +import { afterAll, afterEach, describe, expect, it } from 'vitest'; +import { replicaFiles } from './replica-file.testkit.js'; import { TursoDriver } from './turso-driver'; +// A replica is a local FILE: the constructor refuses one on `:memory:`. +const files = replicaFiles(); +afterAll(() => files.removeAll()); + /** The window the positive cases configure, and the slack the box is allowed. */ const WINDOW_MS = 100; const CONTROL_WAIT_MS = 1000; @@ -212,7 +217,7 @@ describe('TursoDriverConfig.timeout — replica mode (sync)', () => { function replicaDriver(timeout: number | undefined): TursoDriver { return new TursoDriver({ - url: ':memory:', + url: files.next(), syncUrl: 'libsql://primary.example.turso.io', authToken: 'token', client: stalledSyncClient(), diff --git a/packages/drivers/driver-turso/src/turso-driver-ws-timeout-refusal.test.ts b/packages/drivers/driver-turso/src/turso-driver-ws-timeout-refusal.test.ts index 4ca5e526235..fd3e86294b4 100644 --- a/packages/drivers/driver-turso/src/turso-driver-ws-timeout-refusal.test.ts +++ b/packages/drivers/driver-turso/src/turso-driver-ws-timeout-refusal.test.ts @@ -24,8 +24,10 @@ * would satisfy. And the refusal's WIDTH, by controls that must stay accepted: * the same url without `timeout`; with `timeout: 0` (the documented "no * bound"); every HTTP-side scheme WITH a window (`libsql://`, `https://`, - * `http://`); and the replica arm, where `sync()` is bounded whatever the url's - * scheme. A refusal that took any of those would be wider than the gap. + * `http://`); and the replica arm, where `sync()` is bounded (a replica's url is + * a local `file:`; a remote url beside `syncUrl` is refused on other grounds, + * pinned in `turso-driver-remote-url-replica-refusal.test.ts`). A refusal that + * took any of those would be wider than the gap. * * # Reverse verification — direction predicted before it was run * @@ -116,23 +118,18 @@ describe('CONTROLS — what the refusal must leave accepted', () => { }, ); - it('the replica arm keeps timeout whatever the url scheme — sync() is bounded there', () => { + it('the replica arm keeps timeout — sync() is bounded there', () => { + // A replica's url is a local `file:`. A `wss://` url beside `syncUrl` used to + // be a second case here; that pair is now refused on its own grounds (the + // local engine would have run on `:memory:`), and the refusal that owns it, + // not this one, is pinned in turso-driver-remote-url-replica-refusal.test.ts. const fileReplica = new TursoDriver({ - url: ':memory:', + url: 'file:./data/replica.db', syncUrl: PRIMARY_URL, timeout: WINDOW_MS, sync: { onConnect: false }, }); expect(fileReplica.transportMode).toBe('replica'); expect(fileReplica.getTursoConfig().timeout).toBe(WINDOW_MS); - - const wsReplica = new TursoDriver({ - url: WSS_URL, - syncUrl: PRIMARY_URL, - timeout: WINDOW_MS, - sync: { onConnect: false }, - }); - expect(wsReplica.transportMode).toBe('replica'); - expect(wsReplica.getTursoConfig().timeout).toBe(WINDOW_MS); }); }); diff --git a/packages/drivers/driver-turso/src/turso-driver.test.ts b/packages/drivers/driver-turso/src/turso-driver.test.ts index 4a92e31432c..342f28031e8 100644 --- a/packages/drivers/driver-turso/src/turso-driver.test.ts +++ b/packages/drivers/driver-turso/src/turso-driver.test.ts @@ -396,14 +396,22 @@ describe('TursoDriver URL Parsing', () => { expect(driver.isRemote).toBe(true); }); - it('should accept remote URL when syncUrl is provided', () => { - // Should not throw — embedded replica mode - const driver = new TursoDriver({ - url: 'libsql://test-db.turso.io', - syncUrl: 'libsql://test-db.turso.io', - authToken: 'test-token', - }); - expect(driver.getTursoConfig().syncUrl).toBe('libsql://test-db.turso.io'); + it('should refuse a remote URL beside syncUrl: an embedded replica is a local file', () => { + // This pair used to construct as a "replica" whose local engine was a + // private `:memory:` database: writes read back, then vanished on restart. + // The full set of arms lives in turso-driver-remote-url-replica-refusal.test.ts. + let refusal: (Error & { code?: string; status?: number }) | undefined; + try { + new TursoDriver({ + url: 'libsql://test-db.turso.io', + syncUrl: 'libsql://test-db.turso.io', + authToken: 'test-token', + }); + } catch (error) { + refusal = error as Error & { code?: string; status?: number }; + } + expect(refusal?.code).toBe('VALIDATION_ERROR'); + expect(refusal?.status).toBe(400); }); }); @@ -488,14 +496,16 @@ describe('TursoDriver Transport Mode Detection', () => { expect(driver.isRemote).toBe(false); }); - it('should detect replica mode for :memory: with syncUrl', () => { - const driver = new TursoDriver({ - url: ':memory:', - syncUrl: 'libsql://test.turso.io', - authToken: 'test-token', - }); - expect(driver.transportMode).toBe('replica'); - expect(driver.isRemote).toBe(false); + it('should classify :memory: with syncUrl as replica (which the constructor then refuses)', () => { + // The classifier's answer is unchanged. Constructing is refused, because + // a replica needs a local file (turso-driver-remote-url-replica-refusal.test.ts). + expect( + TursoDriver.detectMode({ + url: ':memory:', + syncUrl: 'libsql://test.turso.io', + authToken: 'test-token', + }), + ).toBe('replica'); }); it('should detect remote mode for libsql:// URL', () => { @@ -571,14 +581,17 @@ describe('TursoDriver Transport Mode Detection', () => { expect(driver.getRemoteTransport()).toBeNull(); }); - it('should detect replica mode for libsql:// URL with syncUrl', () => { - const driver = new TursoDriver({ - url: 'libsql://test-db.turso.io', - syncUrl: 'libsql://test-db.turso.io', - authToken: 'test-token', - }); - expect(driver.transportMode).toBe('replica'); - expect(driver.isRemote).toBe(false); + it('should classify a libsql:// URL with syncUrl as replica (which the constructor then refuses)', () => { + // Classified as what the declaration asks for, then refused at + // construction rather than re-classified: see + // turso-driver-remote-url-replica-refusal.test.ts. + expect( + TursoDriver.detectMode({ + url: 'libsql://test-db.turso.io', + syncUrl: 'libsql://test-db.turso.io', + authToken: 'test-token', + }), + ).toBe('replica'); }); }); diff --git a/packages/drivers/driver-turso/src/turso-driver.ts b/packages/drivers/driver-turso/src/turso-driver.ts index a686f4985bc..07363cdbc4e 100644 --- a/packages/drivers/driver-turso/src/turso-driver.ts +++ b/packages/drivers/driver-turso/src/turso-driver.ts @@ -14,9 +14,14 @@ * * The transport mode is auto-detected from the URL: * - `file:` or `:memory:` → local - * - `file:` or `:memory:` + `syncUrl` → replica + * - `file:` + `syncUrl` → replica (an embedded replica is a local FILE) * - `libsql://`, `https://`, `http://`, `wss://` or `ws://` (no syncUrl) → remote * (`http://` / `ws://` = plaintext, for self-hosted / local-dev endpoints) + * + * Refused at construction (`VALIDATION_ERROR` / 400), because the local engine + * would have run on a private `:memory:` database: a remote url beside + * `syncUrl` or under `mode: 'local'` / `'replica'`, and a replica whose url is + * not a `file:` path (`:memory:` included). */ import { SqlDriver, type SqlDriverConfig } from '@objectstack/driver-sql'; @@ -57,8 +62,9 @@ export type TursoTransportMode = 'local' | 'replica' | 'remote'; * Supports the following connection modes: * 1. **Local (Embedded):** `url: 'file:./data/local.db'` * 2. **In-memory (Ephemeral):** `url: ':memory:'` - * 3. **Embedded Replica (Hybrid):** `url` (local file or `:memory:`) + - * `syncUrl` (remote `libsql://` / `https://` Turso endpoint) + * 3. **Embedded Replica (Hybrid):** `url` (a local `file:`, never `:memory:` + * or a remote url, both refused at construction) + `syncUrl` (remote + * `libsql://` / `https://` Turso endpoint) * 4. **Remote (Cloud):** `url: 'libsql://...'` — pure remote queries * via @libsql/client, no local SQLite needed * @@ -95,7 +101,15 @@ export interface TursoDriverConfig { */ concurrency?: number; - /** Remote sync URL for embedded replica mode (`libsql://` or `https://`) */ + /** + * Remote sync URL for embedded replica mode (`libsql://` or `https://`). + * + * Turns a local `file:` `url` into an embedded replica. Beside a remote + * `url` or `:memory:` the constructor refuses it (`VALIDATION_ERROR` / 400): + * there is no local file for the replica to live in, so the local engine + * would run on a private in-memory database. For a remote database, drop + * `syncUrl` and keep the remote `url`. + */ syncUrl?: string; /** Sync configuration for embedded replica mode (requires `syncUrl`) */ @@ -143,8 +157,13 @@ export interface TursoDriverConfig { * from the URL: * * - `file:` or `:memory:` without syncUrl → `'local'` - * - `file:` or `:memory:` with syncUrl → `'replica'` + * - `file:` with syncUrl → `'replica'` * - `libsql://` / `https://` / `http://` / `wss://` / `ws://` without syncUrl → `'remote'` + * + * A forced `'local'` or `'replica'` still runs on the local engine, so it + * is refused beside a remote url (`VALIDATION_ERROR` / 400), and + * `'replica'` is refused on any url that is not a local `file:` path. The + * engine would otherwise run on a private in-memory database. */ mode?: TursoTransportMode; @@ -619,8 +638,10 @@ function ridesWebSocketTransport(url: string): boolean { * a boot that would have run unbounded fails at the one constructor every * loader calls (`buildTursoDriverConfig` → `new TursoDriver`). * - * Scoped to REMOTE mode: on the replica arm a `wss://` url beside `syncUrl` - * still has `sync()` bounded, so the key is not inert there. `timeout: 0` is + * Scoped to REMOTE mode: on the replica arm `sync()` is bounded, so the key is + * not inert there. (A `wss://` url never reaches that arm: a remote url beside + * `syncUrl` is refused on its own grounds by `localEngineDefect`, whatever + * `timeout` says, and a replica's url is always a local `file:`.) `timeout: 0` is * the documented "no bound", asks for nothing, and is not refused. A * caller-supplied `client` is not consulted — its transport is not the driver's * to know; the scheme of the `url` beside it is what decides here. @@ -705,6 +726,171 @@ function refuseSuppliedClientTimeout(timeoutMs: number): never { throw err; } +// ── The local engine: a file, or a declared `:memory:` — never a silent one ─── + +/** + * The url prefixes {@link TursoDriver.detectMode} classifies as remote — one + * list for the classifier and for {@link localEngineDefect}, so the refusal can + * never disagree with it about which urls are remote. Matched lowercase and + * case-sensitively, as `detectMode` always has (see + * {@link ridesWebSocketTransport} for why that reader does not fold case). + */ +const REMOTE_URL_PREFIXES = ['libsql://', 'https://', 'http://', 'wss://', 'ws://'] as const; + +function hasRemotePrefix(url: string): boolean { + return REMOTE_URL_PREFIXES.some((prefix) => url.startsWith(prefix)); +} + +/** + * Does this url name an in-memory database, by `@libsql/client`'s own reading? + * + * `@libsql/core@0.17.4` `lib-esm/config.js` expands a bare `:memory:` to + * `file::memory:`, and `isInMemoryConfig` then answers true for a `file` scheme + * whose path is `:memory:` or starts with `:memory:?`. Mirrored here so the + * replica refusal below covers exactly the urls the client's own embedded + * replica refuses. + */ +function namesInMemoryDatabase(url: string): boolean { + if (url === ':memory:') return true; + if (!url.startsWith('file:')) return false; + const path = url.slice('file:'.length); + return path === ':memory:' || path.startsWith(':memory:?'); +} + +type LocalEngineDefect = 'remote-url' | 'replica-without-file'; + +/** + * Which way, if any, a LOCAL or REPLICA configuration would leave the local + * engine with nothing durable behind it. + * + * Both non-remote arms run every read and write through the inherited Knex + + * better-sqlite3 engine, which can open exactly two things: + * {@link TursoDriver.toKnexConfig} hands it the path of a `file:` url, or + * `:memory:`. Anything else reached that method's last arm, which handed it + * `:memory:`: a private in-memory database. The writes succeed and read back, + * so from outside the datasource looks healthy, and all of it is gone on + * restart. Measured on `main` @ `2c1011b01b`, a `create` then a `find` then a + * fresh driver on the same config: + * + * ``` + * libsql:// + syncUrl (sync.onConnect: false) -> replica, knex :memory:, 1 row, 0 after restart + * https:// / wss:// + syncUrl (same) -> same + * libsql:// + mode: 'replica' (no syncUrl) -> replica, knex :memory:, 1 row, 0 after restart + * libsql:// + mode: 'local' -> local, knex :memory:, 1 row, 0 after restart + * :memory: + syncUrl + a supplied client -> replica, knex :memory:, 1 row, 0 after restart + * file: + syncUrl (control) -> replica, knex , 1 row, 1 after restart + * ``` + * + * With the driver building its own client and the default `sync.onConnect`, + * the first two rows failed at `connect()` rather than silently, but on + * libsql's error, not this driver's: an http/ws client's `sync()` throws + * `SYNC_NOT_SUPPORTED`, and a `:memory:` url beside `syncUrl` throws + * `URL_INVALID` ("Embedded replica must use file for local db"). So: + * + * - `'remote-url'`: a url {@link TursoDriver.detectMode} would call remote, in + * a local or replica mode. `@libsql/client@0.17.4` builds no embedded replica + * for it: `lib-esm/node.js` routes `http`/`https` to its HTTP client and + * `ws`/`wss` to its WebSocket client, and `syncUrl` is read by + * `lib-esm/sqlite3.js` alone (a `syncUrl` grep over `http.js` and `ws.js` + * returns zero, while `authToken` returns six in each: the control that + * makes the zero a reading). + * - `'replica-without-file'`: a replica whose url is not a local file. A + * replica IS a local file kept in sync with the remote; on anything else + * nothing the sync brings down can reach the engine the reads go through. + * The same rule as `@libsql/client`'s own `URL_INVALID` above, applied to + * every url that is not a `file:` path and not only to `:memory:`. + * + * ⛔ Deliberately NOT a defect here: a LOCAL mode whose url is some other + * string, such as an uppercase scheme or a bare path with no `file:`. That is + * the auto-detect fall-through {@link ridesWebSocketTransport} records as + * deliberately left alone, with a published control pinning that it constructs. + * It reaches the same `:memory:` arm, but turning it into a refusal changes a + * decision this refusal does not own, so it has to be argued separately. This + * predicate covers a url that is remote by the classifier's own list, and a + * replica. + */ +function localEngineDefect(url: string, mode: 'local' | 'replica'): LocalEngineDefect | undefined { + if (hasRemotePrefix(url)) return 'remote-url'; + if (mode === 'replica' && (!url.startsWith('file:') || namesInMemoryDatabase(url))) { + return 'replica-without-file'; + } + return undefined; +} + +/** + * A local or replica configuration with nothing durable behind its engine, + * refused at construction. See {@link localEngineDefect} for the measurement. + * + * Raised BEFORE `super()`, beside `detectMode`, like the two `timeout` + * refusals above: ahead of the Knex base and of any `@libsql/client`, at the + * one constructor every loader calls (`buildTursoDriverConfig` → + * `new TursoDriver`). Neither loader parses a config schema on the way in, so + * this is the runtime's only gate for a datasource that bypassed authoring + * validation. + * + * ADR-0049 enforce-or-remove, and AGENTS.md's durability rule (prefer failing + * to falling back): the configuration asked for a replica, or a local + * database, and the driver quietly delivered a scratch in-memory one. The + * refusal changes no wire behaviour. Re-classifying the pair as `remote` + * instead was rejected: that would accept a declared `syncUrl` and then ignore + * it, which is the same declared-but-not-enforced shape. + * + * ⛔ The url is not echoed, only its scheme: a url may carry a live + * `?authToken=` (see `turso-authtoken-url-channel.test.ts`), and this message + * reaches an operator's boot log and Studio's datasource form. ⛔ No internal + * issue id in the message either, for the same reason. The ids live in the + * comments beside it. + */ +function refuseNonDurableLocalEngine( + config: TursoDriverConfig, + mode: 'local' | 'replica', + defect: LocalEngineDefect, +): never { + const arm = mode === 'replica' ? 'an embedded replica' : 'a local database'; + const cause = config.mode ? `\`mode: '${config.mode}'\`` : '`syncUrl`'; + let message: string; + if (defect === 'remote-url') { + const scheme = config.url.slice(0, config.url.indexOf('://') + '://'.length); + const toRemote = config.mode + ? `drop \`mode\` (a \`${scheme}\` url is detected as remote) or set \`mode: 'remote'\`` + + (config.syncUrl ? ', and drop `syncUrl`' : '') + : 'drop `syncUrl` (and `sync`): the url alone sends every read and write to it'; + const toLocal = + mode === 'replica' + ? 'For an embedded replica, point `url` at a local file and keep the remote in `syncUrl`: ' + + "`url: 'file:./data/replica.db'`." + : "For a local database, point `url` at a file: `url: 'file:./data/app.db'`." + message = + `\`TursoDriverConfig.url\` is a remote \`${scheme}\` url, but ${cause} makes this datasource ` + + `${arm}, which runs every read and write through a local SQLite engine. That engine cannot open ` + + `a remote url, so it would run on a private in-memory database instead: writes would succeed and ` + + `read back, then be lost on restart, and none of them would reach the remote. ` + + (mode === 'replica' + ? '(@libsql/client builds a plain remote client for a remote url and ignores `syncUrl` beside ' + + 'it, measured against @libsql/client 0.17.4, so there is no embedded replica to sync.) ' + : '') + + `To use the remote database, ${toRemote}. ${toLocal}`; + } else { + const what = namesInMemoryDatabase(config.url) + ? 'names an in-memory database' + : 'is not a `file:` url'; + const drop = config.mode + ? "`mode: 'replica'`" + (config.syncUrl ? ' and `syncUrl`' : '') + : '`syncUrl` (and `sync`)'; + message = + `\`TursoDriverConfig.url\` ${what}, so it cannot hold an embedded replica, which ${cause} asks ` + + 'for. A replica is a local FILE kept in sync with the remote named in `syncUrl`. Here the local ' + + 'engine would run on a private in-memory database that no sync ever reaches: writes would ' + + 'succeed and read back, then be lost on restart. @libsql/client refuses an in-memory embedded ' + + "replica itself. Point `url` at a local file (`url: 'file:./data/replica.db'` beside " + + `\`syncUrl\`), or drop ${drop} for a plain local database.`; + } + const err = new Error(message) as Error & { code?: string; status?: number }; + err.code = StandardErrorCode.enum.VALIDATION_ERROR; + err.status = 400; + throw err; +} + // ── Turso Driver ───────────────────────────────────────────────────────────── /** @@ -868,6 +1054,14 @@ export class TursoDriver extends SqlDriver { constructor(config: TursoDriverConfig) { const mode = TursoDriver.detectMode(config); + // A local or replica engine with nothing durable behind it (a remote url, + // or a replica that is not a local file) is refused here, before the Knex + // base could open a private `:memory:` database in its place. See + // `localEngineDefect` for the measurement. + if (mode !== 'remote') { + const defect = localEngineDefect(config.url, mode); + if (defect) refuseNonDurableLocalEngine(config, mode, defect); + } // A window the WebSocket arm cannot deliver is refused here, ahead of the // Knex base and of any client — see `refuseWebSocketTimeout` for the // reading and the ruling behind it. @@ -987,16 +1181,15 @@ export class TursoDriver extends SqlDriver { // queries go over the wire — otherwise the URL falls through to the // local-SQLite fallback below and silently writes to an ephemeral // in-memory DB (data never reaches the remote, lost on every restart). - if ( - url.startsWith('libsql://') || - url.startsWith('https://') || - url.startsWith('http://') || - url.startsWith('wss://') || - url.startsWith('ws://') - ) { - // When both url and syncUrl are remote, @libsql/client operates in - // embedded replica mode with an in-memory local cache. The remote URL - // serves as the primary database and syncUrl configures the sync target. + if (hasRemotePrefix(url)) { + // A remote url beside `syncUrl` is still classified `replica`, because + // that is what the declaration asks for, and the constructor REFUSES it + // (`localEngineDefect`). It is not a working configuration: + // `@libsql/client` builds no embedded replica for a remote url. It + // routes the url to its HTTP or WebSocket client, which never reads + // `syncUrl` and throws `SYNC_NOT_SUPPORTED` from `sync()`, and the local + // engine could only have opened `:memory:`. An embedded replica is a + // `file:` url beside `syncUrl`. if (config.syncUrl) return 'replica'; return 'remote'; } @@ -1039,7 +1232,14 @@ export class TursoDriver extends SqlDriver { }; } - // Remote URL with syncUrl (replica mode) — use :memory: as local backend + // Reached only by a LOCAL mode whose url is neither `file:` nor `:memory:`: + // an uppercase or otherwise unrecognised scheme, or a bare path. A remote + // url in a local or replica mode, and a replica on anything but a local + // file, used to land here too and are now refused in the constructor + // (`localEngineDefect`). What still arrives gets a private in-memory + // database. That is the auto-detect fall-through recorded at + // `ridesWebSocketTransport` as deliberately left alone, and it needs its + // own decision; the refusal does not own it. return { client: 'better-sqlite3', connection: { filename: ':memory:' }, diff --git a/packages/drivers/driver-turso/src/turso-remote-autonumber-refusal.test.ts b/packages/drivers/driver-turso/src/turso-remote-autonumber-refusal.test.ts index 326c0c1b53c..ee59c933a21 100644 --- a/packages/drivers/driver-turso/src/turso-remote-autonumber-refusal.test.ts +++ b/packages/drivers/driver-turso/src/turso-remote-autonumber-refusal.test.ts @@ -131,10 +131,15 @@ * correct warning from a wolf-crying one, and the controls are what do. */ -import { describe, it, expect, afterEach, vi, assert } from 'vitest'; +import { describe, it, expect, afterAll, afterEach, vi, assert } from 'vitest'; import { TursoDriver } from './index.js'; import { RemoteTransport } from './remote-transport.js'; import { makeLibsqlSqliteStub, type LibsqlSqliteStub } from './libsql-sqlite-stub.testkit.js'; +import { replicaFiles } from './replica-file.testkit.js'; + +// A replica is a local FILE: the constructor refuses one on `:memory:`. +const replicaFileUrls = replicaFiles(); +afterAll(() => replicaFileUrls.removeAll()); interface WireBearingError extends Error { code?: string; @@ -193,7 +198,7 @@ async function makeLocal() { async function makeReplica() { const stub = makeLibsqlSqliteStub(); const driver = new TursoDriver({ - url: ':memory:', + url: replicaFileUrls.next(), syncUrl: 'libsql://probe.turso.io', client: stub as never, sync: { onConnect: false, intervalSeconds: 0 }, diff --git a/packages/drivers/driver-turso/src/turso-remote-deferred-ddl.test.ts b/packages/drivers/driver-turso/src/turso-remote-deferred-ddl.test.ts index 054fb9e3c8b..713a851c318 100644 --- a/packages/drivers/driver-turso/src/turso-remote-deferred-ddl.test.ts +++ b/packages/drivers/driver-turso/src/turso-remote-deferred-ddl.test.ts @@ -39,9 +39,14 @@ * backfill rewrites a value's spelling, not the instant it names. */ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, afterAll } from 'vitest'; import { TursoDriver } from './turso-driver.js'; import { makeLibsqlSqliteStub, type LibsqlSqliteStub } from './libsql-sqlite-stub.testkit.js'; +import { replicaFiles } from './replica-file.testkit.js'; + +// A replica is a local FILE: the constructor refuses one on `:memory:`. +const replicaFileUrls = replicaFiles(); +afterAll(() => replicaFileUrls.removeAll()); interface WireBearingError extends Error { code?: string; @@ -259,7 +264,7 @@ describe.each([ mode: 'replica', make: (client: unknown) => new TursoDriver({ - url: ':memory:', + url: replicaFileUrls.next(), syncUrl: 'libsql://primary.turso.io', authToken: 'token', client: client as never, diff --git a/packages/drivers/driver-turso/src/turso-remote-drift-detection-refusal.test.ts b/packages/drivers/driver-turso/src/turso-remote-drift-detection-refusal.test.ts index 53dd9574dda..5c0374c5cb2 100644 --- a/packages/drivers/driver-turso/src/turso-remote-drift-detection-refusal.test.ts +++ b/packages/drivers/driver-turso/src/turso-remote-drift-detection-refusal.test.ts @@ -35,9 +35,14 @@ * database while refusing. */ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, afterAll } from 'vitest'; import { TursoDriver } from './turso-driver.js'; import { makeLibsqlSqliteStub, type LibsqlSqliteStub } from './libsql-sqlite-stub.testkit.js'; +import { replicaFiles } from './replica-file.testkit.js'; + +// A replica is a local FILE: the constructor refuses one on `:memory:`. +const replicaFileUrls = replicaFiles(); +afterAll(() => replicaFileUrls.removeAll()); interface WireBearingError extends Error { code?: string; @@ -134,7 +139,7 @@ describe('controls — the Knex detector still reports the extra column', () => // keeps the (stubbed) sync target out of the measurement. const stub = makeLibsqlSqliteStub(); const driver = new TursoDriver({ - url: ':memory:', + url: replicaFileUrls.next(), syncUrl: 'libsql://drift.turso.io', client: record(stub).client as never, sync: { onConnect: false }, diff --git a/packages/drivers/driver-turso/src/turso-remote-transaction-refusal.test.ts b/packages/drivers/driver-turso/src/turso-remote-transaction-refusal.test.ts index aa1f8ee71f9..767b386b1fd 100644 --- a/packages/drivers/driver-turso/src/turso-remote-transaction-refusal.test.ts +++ b/packages/drivers/driver-turso/src/turso-remote-transaction-refusal.test.ts @@ -80,9 +80,14 @@ * the PR body. */ -import { describe, it, expect, afterEach } from 'vitest'; +import { describe, it, expect, afterAll, afterEach } from 'vitest'; import { TursoDriver } from './index.js'; import { makeLibsqlSqliteStub, type LibsqlSqliteStub } from './libsql-sqlite-stub.testkit.js'; +import { replicaFiles } from './replica-file.testkit.js'; + +// A replica is a local FILE: the constructor refuses one on `:memory:`. +const replicaFileUrls = replicaFiles(); +afterAll(() => replicaFileUrls.removeAll()); interface WireBearingError extends Error { code?: string; @@ -159,7 +164,7 @@ async function makeLocal() { async function makeReplica() { const stub = makeTransactionalStub(); const driver = new TursoDriver({ - url: ':memory:', + url: replicaFileUrls.next(), syncUrl: 'libsql://probe.turso.io', client: stub as never, sync: { onConnect: false, intervalSeconds: 0 }, diff --git a/packages/drivers/driver-turso/src/turso-transactions-unsupported-declaration.test.ts b/packages/drivers/driver-turso/src/turso-transactions-unsupported-declaration.test.ts index 67342aa7a98..0d4f90ed70c 100644 --- a/packages/drivers/driver-turso/src/turso-transactions-unsupported-declaration.test.ts +++ b/packages/drivers/driver-turso/src/turso-transactions-unsupported-declaration.test.ts @@ -30,9 +30,11 @@ import { TursoDriver } from './turso-driver.js'; const remote = () => new TursoDriver({ url: 'libsql://probe.turso.io', authToken: 't' }); const local = () => new TursoDriver({ url: ':memory:' }); +// A replica is a local FILE: the constructor refuses one on `:memory:`. +// Construct-only, and Knex opens lazily, so the path is never created. const replica = () => new TursoDriver({ - url: ':memory:', + url: 'file:./data/replica.db', syncUrl: 'libsql://probe.turso.io', authToken: 't', sync: { onConnect: false, intervalSeconds: 0 }, From 5fb95579ee5271ee45ecad3e08809db249e9dc90 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 13:25:50 +0000 Subject: [PATCH 2/3] chore(changeset): driver-turso refuses a non-durable local or replica engine Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx Co-authored-by: Claude --- .../19893-turso-remote-url-replica-refusal.md | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 .changeset/19893-turso-remote-url-replica-refusal.md diff --git a/.changeset/19893-turso-remote-url-replica-refusal.md b/.changeset/19893-turso-remote-url-replica-refusal.md new file mode 100644 index 00000000000..7c9bcb7dd6b --- /dev/null +++ b/.changeset/19893-turso-remote-url-replica-refusal.md @@ -0,0 +1,34 @@ +--- +'@objectstack/driver-turso': minor +--- + +fix(driver-turso)!: a local or replica `TursoDriver` whose engine would have nothing durable behind it is refused at construction, instead of silently running on a private in-memory database + +Clause-②: no (narrowing) + +A remote `url` beside `syncUrl` was classified as an embedded replica, and the local SQLite engine that every replica read and write goes through was handed `:memory:`. Writes succeeded and read back, then vanished on restart, and none of them reached the remote. `@libsql/client` builds no embedded replica for a remote url: it routes `libsql://` / `https://` / `http://` to its HTTP client and `wss://` / `ws://` to its WebSocket client, neither of which reads `syncUrl`. The same fallback caught a forced `mode: 'replica'` or `mode: 'local'` beside a remote url, and a replica on `:memory:`. Measured before the change, with `create`, `find`, then a fresh driver on the same config: + +``` +libsql:// + syncUrl (sync.onConnect: false) -> 1 row back, 0 rows after restart +libsql:// + mode: 'replica' or mode: 'local' -> 1 row back, 0 rows after restart +:memory: + syncUrl + a supplied client -> 1 row back, 0 rows after restart +file: + syncUrl (unchanged) -> 1 row back, 1 row after restart +``` + +With the driver's own client and the default `sync.onConnect`, the first and third rows already failed at `connect()`, but with a libsql error (`SYNC_NOT_SUPPORTED` / `URL_INVALID`) that did not say why. + +**BREAKING** accept-set narrowing on a published driver option, shipped as `minor` under the repo's launch-window convention for breaking changes (`scripts/check-changeset-no-major.mjs`). **The constructor now refuses configurations it accepted before**, at `new TursoDriver()`, ahead of the Knex base and of any client, with the ADR-0112 envelope `code: 'VALIDATION_ERROR'`, `status: 400`: + +- a remote url (`libsql://`, `https://`, `http://`, `wss://`, `ws://`) beside `syncUrl`; +- a remote url with a forced `mode: 'replica'` or `mode: 'local'`; +- a replica (`syncUrl`, or `mode: 'replica'`) whose `url` is not a local `file:` path, `:memory:` and `file::memory:` included. `@libsql/client` refuses an in-memory embedded replica itself. + +The message names the scheme it met, never the url, which may carry a token. Both loaders (`@objectstack/runtime`'s host factory and the datasource factory) reach this refusal through the same constructor, so a datasource declaring one of these configurations now fails its connect by name. + +**What stays accepted**, pinned by preservation tests: a `file:` url with `syncUrl` (the embedded replica), a `file:` or `:memory:` local database, a remote url on its own or with `mode: 'remote'`. `TursoDriver.detectMode()` still classifies a remote url beside `syncUrl` as `'replica'`: the refusal sits in the constructor, not in a re-classification. An uppercase or unrecognised scheme with no `mode` still falls through to `'local'`, unchanged here. + +**What an affected author does.** The refusal names both ways out. For a remote database, drop `syncUrl` (and `sync`), or the forced `mode`: the remote url alone sends every read and write to it. For an embedded replica, point `url` at a local file and keep the remote in `syncUrl`: `url: 'file:./data/replica.db', syncUrl: 'libsql://my-db.turso.io'`. + +Blast radius, measured on this tree: no example, template, published skill, hand-written doc or factory default declares a remote url beside `syncUrl`, and the host boot path (`OS_DATABASE_URL`) passes no `syncUrl`. The only in-repo configurations carrying the pair are loader fixtures that exercise the config builder or a capturing constructor, never the real driver. Whether any out-of-repo deployment declares it is NOT measured and is not claimed to be zero. + + From 7fc737470b73a61f1fb50d37315d24cf0c5f982c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 14:17:42 +0000 Subject: [PATCH 3/3] docs(driver-turso): state the refused set exactly in the changeset and README The replica-without-file refusal fires only when the resolved mode is `replica`. A bare path or an uppercase scheme with `syncUrl` and no `mode` auto-detects `local` and still constructs on `:memory:`, and so does the same url under a forced `mode: 'local'`. The changeset headline, the refusal list, the ways out and the blast-radius sentence now say exactly what the constructor refuses, and the README qualifies which replicas it covers. Prose only; no code or test changes. Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx Co-authored-by: Claude --- .../19893-turso-remote-url-replica-refusal.md | 32 ++++++++++++------- packages/drivers/driver-turso/README.md | 26 +++++++++------ 2 files changed, 37 insertions(+), 21 deletions(-) diff --git a/.changeset/19893-turso-remote-url-replica-refusal.md b/.changeset/19893-turso-remote-url-replica-refusal.md index 7c9bcb7dd6b..3509542c02e 100644 --- a/.changeset/19893-turso-remote-url-replica-refusal.md +++ b/.changeset/19893-turso-remote-url-replica-refusal.md @@ -2,11 +2,11 @@ '@objectstack/driver-turso': minor --- -fix(driver-turso)!: a local or replica `TursoDriver` whose engine would have nothing durable behind it is refused at construction, instead of silently running on a private in-memory database +fix(driver-turso)!: a local or replica `TursoDriver` on a remote url, or a replica off a local file, is refused at construction Clause-②: no (narrowing) -A remote `url` beside `syncUrl` was classified as an embedded replica, and the local SQLite engine that every replica read and write goes through was handed `:memory:`. Writes succeeded and read back, then vanished on restart, and none of them reached the remote. `@libsql/client` builds no embedded replica for a remote url: it routes `libsql://` / `https://` / `http://` to its HTTP client and `wss://` / `ws://` to its WebSocket client, neither of which reads `syncUrl`. The same fallback caught a forced `mode: 'replica'` or `mode: 'local'` beside a remote url, and a replica on `:memory:`. Measured before the change, with `create`, `find`, then a fresh driver on the same config: +A remote `url` beside `syncUrl` was classified as an embedded replica, and the local SQLite engine that every replica read and write goes through was handed `:memory:`. Writes succeeded and read back, then vanished on restart, and none of them reached the remote. `@libsql/client` builds no embedded replica for a remote url: it routes `libsql://` / `https://` / `http://` to its HTTP client and `wss://` / `ws://` to its WebSocket client, neither of which reads `syncUrl`. A forced `mode: 'replica'` or `mode: 'local'` beside a remote url was handed the same `:memory:` engine, and so was a replica on `:memory:`. Measured before the change, with `create`, `find`, then a fresh driver on the same config: ``` libsql:// + syncUrl (sync.onConnect: false) -> 1 row back, 0 rows after restart @@ -15,20 +15,28 @@ libsql:// + mode: 'replica' or mode: 'local' -> 1 row back, 0 rows after restart file: + syncUrl (unchanged) -> 1 row back, 1 row after restart ``` -With the driver's own client and the default `sync.onConnect`, the first and third rows already failed at `connect()`, but with a libsql error (`SYNC_NOT_SUPPORTED` / `URL_INVALID`) that did not say why. +With the driver building its own client and the default `sync.onConnect`, two of these did fail at `connect()`, but on a libsql error that did not say why: `libsql://` + `syncUrl` with `SYNC_NOT_SUPPORTED`, and `:memory:` + `syncUrl` with `URL_INVALID`. -**BREAKING** accept-set narrowing on a published driver option, shipped as `minor` under the repo's launch-window convention for breaking changes (`scripts/check-changeset-no-major.mjs`). **The constructor now refuses configurations it accepted before**, at `new TursoDriver()`, ahead of the Knex base and of any client, with the ADR-0112 envelope `code: 'VALIDATION_ERROR'`, `status: 400`: +**BREAKING** accept-set narrowing on a published driver option, shipped as `minor` under the repo's launch-window convention for breaking changes (`scripts/check-changeset-no-major.mjs`). **The constructor now refuses configurations it accepted before**, at `new TursoDriver()`, ahead of the Knex base and of any client, with the ADR-0112 envelope `code: 'VALIDATION_ERROR'`, `status: 400`. A remote url here means one of the lowercase schemes `TursoDriver.detectMode` classifies as remote: `libsql://`, `https://`, `http://`, `wss://`, `ws://`. Refused: -- a remote url (`libsql://`, `https://`, `http://`, `wss://`, `ws://`) beside `syncUrl`; -- a remote url with a forced `mode: 'replica'` or `mode: 'local'`; -- a replica (`syncUrl`, or `mode: 'replica'`) whose `url` is not a local `file:` path, `:memory:` and `file::memory:` included. `@libsql/client` refuses an in-memory embedded replica itself. +- a remote url beside `syncUrl`; +- a remote url under a forced `mode: 'replica'` or `mode: 'local'`; +- a replica on a url `@libsql/client` reads as in-memory (`:memory:`, or `file::memory:` with or without a query string), beside `syncUrl` or under a forced `mode: 'replica'`. `@libsql/client` refuses such an embedded replica itself. For `:memory:` and a bare `file::memory:` the local engine was a private in-memory database. With a query string it was a file literally named after the url's path (for example `:memory:?cache=shared`) in the working directory, which no sync reaches; +- under a forced `mode: 'replica'` only, any `url` that is not a local `file:` path, such as a bare path or an uppercase scheme. -The message names the scheme it met, never the url, which may carry a token. Both loaders (`@objectstack/runtime`'s host factory and the datasource factory) reach this refusal through the same constructor, so a datasource declaring one of these configurations now fails its connect by name. +The remote-url refusal names the scheme it met. Neither refusal echoes the url, which may carry a token. Both loaders (`@objectstack/runtime`'s host factory and the datasource factory) reach this refusal through the same constructor, so a datasource declaring one of these configurations now fails by name when its loader builds the driver. -**What stays accepted**, pinned by preservation tests: a `file:` url with `syncUrl` (the embedded replica), a `file:` or `:memory:` local database, a remote url on its own or with `mode: 'remote'`. `TursoDriver.detectMode()` still classifies a remote url beside `syncUrl` as `'replica'`: the refusal sits in the constructor, not in a re-classification. An uppercase or unrecognised scheme with no `mode` still falls through to `'local'`, unchanged here. +**What stays accepted**, pinned by preservation tests: a `file:` url with `syncUrl` (the embedded replica), a `file:` or `:memory:` local database, a remote url on its own or with `mode: 'remote'`. `TursoDriver.detectMode()` still classifies a remote url beside `syncUrl` as `'replica'`: the refusal sits in the constructor, not in a re-classification. -**What an affected author does.** The refusal names both ways out. For a remote database, drop `syncUrl` (and `sync`), or the forced `mode`: the remote url alone sends every read and write to it. For an embedded replica, point `url` at a local file and keep the remote in `syncUrl`: `url: 'file:./data/replica.db', syncUrl: 'libsql://my-db.turso.io'`. +**Not refused, unchanged here:** a url with no `mode` that is none of `file:`, `:memory:` or a lowercase remote scheme, such as an uppercase `LIBSQL://` or a bare path like `./data/app.db`, still auto-detects `'local'` and still runs on `:memory:`, with or without `syncUrl`. So does the same url under a forced `mode: 'local'`. That fall-through is tracked as #19976. -Blast radius, measured on this tree: no example, template, published skill, hand-written doc or factory default declares a remote url beside `syncUrl`, and the host boot path (`OS_DATABASE_URL`) passes no `syncUrl`. The only in-repo configurations carrying the pair are loader fixtures that exercise the config builder or a capturing constructor, never the real driver. Whether any out-of-repo deployment declares it is NOT measured and is not claimed to be zero. +**What an affected author does.** Each refusal names its ways out. For a remote url in a local or replica mode: - +- to use the remote database, drop `syncUrl` (and `sync`) and any forced `mode`; the remote url alone sends every read and write to it; +- for an embedded replica, point `url` at a local file and keep the remote in `syncUrl`: `url: 'file:./data/replica.db', syncUrl: 'libsql://my-db.turso.io'`. + +For a replica off a local file, point `url` at a local `file:` path beside `syncUrl`. A throwaway in-memory database instead drops `syncUrl` (and `sync`) and any forced `mode: 'replica'`, and keeps `url: ':memory:'`. + +Blast radius, measured on this tree: no example, template, published skill, hand-written doc or factory default declares a remote url beside `syncUrl`, and the host boot path (`OS_DATABASE_URL`) passes no `syncUrl`. Outside this package's own tests, the in-repo configurations carrying the pair are test fixtures that never construct the real driver: loader fixtures that exercise the config builder or a capturing constructor, stored-row redaction fixtures and a schema-parse fixture. Whether any out-of-repo deployment declares it is NOT measured and is not claimed to be zero. + + diff --git a/packages/drivers/driver-turso/README.md b/packages/drivers/driver-turso/README.md index 82c7b974458..4b04e5c3ec7 100644 --- a/packages/drivers/driver-turso/README.md +++ b/packages/drivers/driver-turso/README.md @@ -132,14 +132,22 @@ Transport mode is automatically detected from the URL: An embedded replica is a local **file** kept in sync with a remote. The local and replica modes run every read and write through the local SQLite engine, which -cannot open a remote url or keep a replica in `:memory:`. So the constructor -refuses (`VALIDATION_ERROR` / 400) a remote url (`libsql://`, `https://`, -`http://`, `wss://`, `ws://`) beside `syncUrl` or under a forced `mode: 'local'` -/ `'replica'`, and a replica whose `url` is not a local `file:` path. The engine -would otherwise run on a private in-memory database whose writes read back and -then vanish on restart, and `@libsql/client` builds no embedded replica for a -remote url anyway. For a remote database, drop `syncUrl`. For an embedded -replica, use `url: 'file:./data/replica.db'` beside `syncUrl`. +cannot open a remote url, and a replica needs a file for the sync to land in. +The constructor therefore refuses (`VALIDATION_ERROR` / 400): + +- a remote url (the lowercase `libsql://`, `https://`, `http://`, `wss://`, + `ws://` that auto-detection matches) beside `syncUrl`, or under a forced + `mode: 'local'` / `'replica'`; +- a replica on an in-memory url (`:memory:`, `file::memory:`). That covers a + replica auto-detected from a `:memory:` or `file:` url beside `syncUrl`, and + one forced with `mode: 'replica'`; +- under a forced `mode: 'replica'`, any `url` that is not a local `file:` path. + +In each case the engine would otherwise run on a private in-memory database +whose writes read back and then vanish on restart, and `@libsql/client` builds +no embedded replica for a remote url anyway. For a remote database, drop +`syncUrl` and any forced `mode`. For an embedded replica, use +`url: 'file:./data/replica.db'` beside `syncUrl`. You can also force a specific mode: @@ -227,7 +235,7 @@ interface TursoDriverConfig { */ concurrency?: number; - /** Remote sync URL for embedded replica mode (libsql:// or https://); `url` must be a local file: */ + /** Remote sync URL for embedded replica mode (libsql:// or https://); the replica is the local file: named by `url` */ syncUrl?: string; /** Sync configuration (requires syncUrl) */