diff --git a/.changeset/19893-turso-remote-url-replica-refusal.md b/.changeset/19893-turso-remote-url-replica-refusal.md index 3509542c02e..d23826de3f3 100644 --- a/.changeset/19893-turso-remote-url-replica-refusal.md +++ b/.changeset/19893-turso-remote-url-replica-refusal.md @@ -17,18 +17,18 @@ file: + syncUrl (unchanged) -> 1 row back, 1 row after restart 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`. A remote url here means one of the lowercase schemes `TursoDriver.detectMode` classifies as remote: `libsql://`, `https://`, `http://`, `wss://`, `ws://`. Refused: +**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 schemes `TursoDriver.detectMode` classifies as remote: `libsql://`, `https://`, `http://`, `wss://`, `ws://`. The #19976 entry in this same version matches them in any letter case, so an uppercase `LIBSQL://` is a remote url too. Refused: - 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. +- under a forced `mode: 'replica'`, a `url` that is none of `:memory:`, a `file:` url or a remote url, such as a bare path or an unsupported scheme. The #19976 entry in this same version refuses such a url in every local or replica mode, and matches the `file:` scheme in any letter case: an uppercase `FILE:` url naming a file is a `file:` url and is not refused, and the replica runs on that file (`FILE::memory:` is refused as in-memory, like `file::memory:`). 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. -**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. +**Not refused by this change:** a url with no `mode` that is none of a lowercase `file:` url, `:memory:` or a lowercase remote scheme, such as an uppercase `LIBSQL://`, an uppercase `FILE:` url or a bare path like `./data/app.db`, auto-detected `'local'` with or without `syncUrl`, and the local engine was handed `:memory:`. Under a forced `mode: 'local'` the same url got the same `:memory:` engine. The #19976 entry in this same version removes that fall-through: it matches every scheme in any letter case, so an uppercase remote url is a remote url and an uppercase `FILE:` url is a `file:` url, and it refuses every other url in a local or replica mode. No configuration runs on an in-memory database it did not name. **What an affected author does.** Each refusal names its ways out. For a remote url in a local or replica mode: diff --git a/.changeset/19976-turso-unrecognised-url-refusal.md b/.changeset/19976-turso-unrecognised-url-refusal.md new file mode 100644 index 00000000000..31a4814fd23 --- /dev/null +++ b/.changeset/19976-turso-unrecognised-url-refusal.md @@ -0,0 +1,41 @@ +--- +'@objectstack/driver-turso': minor +--- + +fix(driver-turso)!: a url scheme matches in any letter case, and a url the driver cannot open is refused instead of running on a private in-memory database + +Clause-②: no (narrowing) + +`TursoDriver.detectMode` matched `file:` and the five remote schemes case-sensitively, and answered `'local'` for any other url with no `mode`. The local engine can open only a `file:` path or `:memory:`, so for everything else it was handed `:memory:`: writes succeeded and read back, then vanished on restart. `@libsql/client` reads a scheme in any letter case (`@libsql/core@0.17.4` routes on `uri.scheme.toLowerCase()`), so an uppercase `LIBSQL://` url that the client routes to the remote database ran on that local `:memory:` engine instead. `@objectstack/cli` and `@objectstack/runtime` select this driver for an `OS_DATABASE_URL` matching `libsql://` in any letter case and hand it the url as written, so an uppercase `OS_DATABASE_URL` reached that engine too. Measured before the change, with `initObjects`, `create`, `find`, then a fresh driver on the same config: + +``` +LIBSQL://… (no mode) -> local, 1 row back, 0 rows after restart +FILE: (no mode) -> local, 1 row back, 0 rows after restart, file never created +.//app.db (no mode) -> local, 1 row back, 0 rows after restart, file never created +/app.db (no mode) -> local, 1 row back, 0 rows after restart, file never created +.//app.db + mode: 'local' -> local, 1 row back, 0 rows after restart, file never created +file: (unchanged) -> local, 1 row back, 1 row after restart +``` + +**The scheme now matches in any letter case**, as it does in `@libsql/client`, in `detectMode` and in every constructor check. `LIBSQL://`, `HTTPS://`, `Http://`, `WSS://` or `Ws://` with no `mode` is remote. `FILE:` is a local file, and an embedded replica beside `syncUrl`. The url itself is passed to the client as written. + +**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`. Refused: + +- in a local or replica mode, a `url` that is none of `:memory:`, a `file:` url or a remote url (`libsql://`, `https://`, `http://`, `wss://`, `ws://`, in any letter case). That is a bare path (`./data/app.db`, `data/app.db`, `/var/lib/app.db`, `C:\data\app.db`), an unsupported scheme (`sqlite:`, `memory://`), `:MEMORY:`, a remote scheme with no `//`, a url behind leading whitespace, and an empty url. Newly refused with no `mode` (with or without `syncUrl`) and under a forced `mode: 'local'`. Under a forced `mode: 'replica'` it was already refused, and the refusal now names the `file:` spelling for the replica. `@libsql/client@0.17.4` refuses each of these urls itself, as `URL_INVALID` or `URL_SCHEME_NOT_SUPPORTED`; +- an uppercase remote url beside `syncUrl` (no `mode`), or under a forced `mode: 'local'`. Both constructed on the private `:memory:` engine before, and both now meet the refusal their lowercase spelling already met. Under a forced `mode: 'replica'` it was already refused, now with that same remote-url refusal; +- an uppercase `WSS://` or `WS://` url with a non-zero `timeout`, no `mode` and no `syncUrl`. It is now detected as remote, so the existing refusal of a `timeout` on the WebSocket transport reaches it; +- `FILE::memory:` beside `syncUrl` (no `mode`), refused as an in-memory replica exactly like `file::memory:`. Under a forced `mode: 'replica'` it was already refused. + +The unrecognised-url refusal names the `file:` spelling (`url: 'file:./data/app.db'`, or `url: 'file:./data/replica.db'` beside `syncUrl`) and never echoes the url, which may carry a token. `TursoDriver.detectMode()` now answers `'replica'` for such a url beside `syncUrl` (it answered `'local'`), which is what the declaration asks for. The refusal sits in the constructor, not in a re-classification. + +**Newly accepted:** an uppercase or mixed-case `FILE:` url naming a file, under a forced `mode: 'replica'`, with or without `syncUrl`. The #19893 change refused it, because under a forced `mode: 'replica'` it refused every url that did not start with a lowercase `file:`. With this change it is a `file:` url: the replica runs on that file, and its rows survive a restart (pinned). It is the one configuration the #19893 change refused that this change accepts. + +**What stays accepted**, pinned by preservation tests: a lowercase `file:` url, alone or with `syncUrl`; `:memory:` as a local database; a lowercase remote url on its own or with `mode: 'remote'`. A forced `mode: 'remote'` runs no local engine, so this change does not judge its url: a bare path there still constructs, and `@libsql/client` refuses it at `connect()` as `URL_INVALID`. + +**What an affected author does.** A local database file needs the `file:` prefix: `url: 'file:./data/app.db'`. For a throwaway in-memory database, `url: ':memory:'`. An uppercase remote url with no `mode` and no `syncUrl` now reaches the remote database and needs no change. Beside `syncUrl` or under a forced local or replica mode it is refused with the same ways out as the lowercase spelling. + +The #19893 entry in this same version (the constructor refusal of a remote url in a local or replica mode) describes this fall-through as not refused by that change, and names this entry as the one that removes it. + +Blast radius, measured on this tree: no example, template, hand-written doc, published skill or factory default, and no test fixture outside this package's own tests, spells a turso url with an uppercase scheme or as a bare path. Neither host url sniffer selects this driver for a bare path (both select it only for `libsql://` or an `http(s)://` url naming a `.turso.` host); only an explicit `OS_DATABASE_DRIVER=turso` or a datasource declaring `driver: 'turso'` hands it one. Whether any out-of-repo deployment declares such a url 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 4b04e5c3ec7..52a57302882 100644 --- a/packages/drivers/driver-turso/README.md +++ b/packages/drivers/driver-turso/README.md @@ -130,24 +130,33 @@ Transport mode is automatically detected from the URL: | `libsql://...` | `remote` | @libsql/client only | | `https://...` | `remote` | @libsql/client only | +`http://`, `wss://` and `ws://` are remote too. A scheme matches in any letter +case, as it does in `@libsql/client`: `LIBSQL://...` is remote and `FILE:...` is +a local file. A path to a local database file needs the `file:` prefix. A bare +path such as `./data/app.db` is not a url, and `@libsql/client` refuses it too. + 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, 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'`; +can open only a `file:` url or `:memory:`, and a replica needs a file for the +sync to land in. The constructor therefore refuses (`VALIDATION_ERROR` / 400): + +- in a local or replica mode (auto-detected, with or without `syncUrl`, or + forced), a `url` that is none of `file:`, `:memory:` or a remote url, such as + a bare path or an unsupported scheme. The fix for a local database file is + `url: 'file:./data/app.db'`; +- a remote url (`libsql://`, `https://`, `http://`, `wss://`, `ws://`, in any + letter case) 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. + one forced with `mode: 'replica'`. 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`. +`url: 'file:./data/replica.db'` beside `syncUrl`. A forced `mode: 'remote'` runs +no local engine, so its url is not judged here: `@libsql/client` refuses a +url it cannot open when the driver connects. You can also force a specific mode: @@ -216,6 +225,9 @@ interface TursoDriverConfig { * - :memory: → local mode (ephemeral) * - libsql://my-db.turso.io → remote mode * - https://my-db.turso.io → remote mode + * Schemes match in any letter case. In a local or replica mode any other + * url (a bare path, an unsupported scheme) is refused at construction + * (VALIDATION_ERROR / 400): spell a local file as file:./data/app.db. */ url: string; diff --git a/packages/drivers/driver-turso/src/turso-driver-unrecognised-url-refusal.test.ts b/packages/drivers/driver-turso/src/turso-driver-unrecognised-url-refusal.test.ts new file mode 100644 index 00000000000..ff464395d7e --- /dev/null +++ b/packages/drivers/driver-turso/src/turso-driver-unrecognised-url-refusal.test.ts @@ -0,0 +1,337 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * A url the classifier did not recognise no longer runs on a silent `:memory:` + * engine. An uppercase remote scheme is classified the way `@libsql/client` + * routes it, and whatever is still unrecognised is refused at construction in + * a local or replica mode. + * + * # What was measured (the reading this change stands on) + * + * `TursoDriver.detectMode` matched `file:` and the five remote schemes + * case-sensitively and answered `'local'` for anything else with no `mode`; + * `toKnexConfig`'s last arm then handed the local engine `:memory:`. On `main` + * @ `a7581b326`, `initObjects`, `create`, `find`, then a fresh driver on the + * same config: + * + * ``` + * LIBSQL://… (no mode) -> local, 1 row, 0 after restart + * FILE:/upper.db (no mode) -> local, 1 row, 0 after restart, file never created + * .//app.db (no mode) -> local, 1 row, 0 after restart, file never created + * /bare.db (no mode) -> local, 1 row, 0 after restart, file never created + * .//app.db + mode: 'local' -> local, 1 row, 0 after restart, file never created + * file:/ctl.db (CONTROL) -> local, 1 row, 1 after restart, file created + * ``` + * + * `@libsql/core@0.17.4` routes on `uri.scheme.toLowerCase()` + * (`lib-esm/config.js`), so the client reads `LIBSQL://` as `https` and + * `FILE:` as `file`. The host's own url sniffers select this driver + * case-insensitively too (`/^libsql:\/\//i` in the CLI's + * `inferDriverTypeFromUrl` and the runtime's `detectDriverFromUrl`), so an + * uppercase `OS_DATABASE_URL` reached this constructor. And the client refuses + * everything that is still left over: `createClient` answers `URL_INVALID` for + * `./data/app.db`, `data/app.db`, `/abs/app.db`, `:MEMORY:`, `''` and + * `libsql:host` (no `//`), and `URL_SCHEME_NOT_SUPPORTED` for `sqlite:`, + * `memory://` and `C:\…`. + * + * # What this file pins + * + * - Uppercase (and mixed-case) schemes are classified by the client's reading: + * a remote one is remote, `FILE:` is a durable local file. Beside `syncUrl` + * or under a forced local/replica mode, an uppercase remote url meets the + * same refusal its lowercase spelling already met. + * - Every other url in a local or replica mode (auto-detected, with or without + * `syncUrl`, or forced) is refused as the ADR-0112 envelope, naming the + * `file:` spelling, never echoing the url. + * - SCOPE: a forced `mode: 'remote'` has no local engine and is not this + * refusal's; the client refuses a bare path there itself, at `connect()`. + * - PRESERVATION, beside this file's neighbours: `file:` local and replica, + * `:memory:` local, a lowercase remote url, `mode: 'remote'`. + * - The one WIDENED cell: an uppercase `FILE:` url naming a file under a + * forced `mode: 'replica'`, with or without `syncUrl`. At `a7581b326` the + * constructor refused it (a forced replica had to start with a lowercase + * `file:`); it is now a `file:` url, so the replica runs on that file and + * keeps its rows across a restart. + * + * # Reverse verification: direction predicted before it was run + * + * With `turso-driver.ts` restored to its `a7581b326` content, every refusal + * case and every uppercase-classification case goes RED (the constructor + * returns a `'local'` driver on `:memory:`, with no envelope to read, and the + * `FILE:` rows do not survive the restart) and every preservation and scope + * case stays GREEN, except the WIDENED cell's cases, which go RED the other + * way: there the constructor refuses. 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 unrecognised-url refusal: what identifies it. */ +const UNRECOGNISED = '`TursoDriverConfig.url` is not a url this driver recognises'; + +const PRIMARY = 'libsql://primary.example.turso.io'; +const HOST = 'db.example.turso.io'; +const NOTE = { name: 'note', fields: { title: { type: 'string' } } }; +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)}`; + +describe('an uppercase scheme is classified the way @libsql/client routes it', () => { + it.each([`LIBSQL://${HOST}`, `Libsql://${HOST}`, `HTTPS://${HOST}`, 'Http://127.0.0.1:8080', `WSS://${HOST}`, 'Ws://127.0.0.1:8080'])( + '%s with no `mode` is remote, not a local database on :memory:', + (url) => { + const driver = new TursoDriver({ url, authToken: 'token' }); + + expect(driver.transportMode).toBe('remote'); + expect(driver.isRemote).toBe(true); + expect(driver.getRemoteTransport()).not.toBeNull(); + // Only the comparison folds: the client is handed the url as authored. + expect(driver.getTursoConfig().url).toBe(url); + expect(TursoDriver.detectMode({ url })).toBe('remote'); + }, + ); + + it('an uppercase remote url beside `syncUrl` meets the refusal its lowercase spelling meets, naming the scheme as typed', () => { + const refusal = refusalOf(() => new TursoDriver({ url: `LIBSQL://${HOST}`, syncUrl: PRIMARY })); + + expectEnvelope(refusal); + expect(refusal.message).toContain('is a remote `LIBSQL://` url'); + expect(refusal.message).toContain('`syncUrl`'); + expect(refusal.message).not.toContain(UNRECOGNISED); + }); + + it.each<[string, TursoDriverConfig]>([ + ["LIBSQL:// + mode 'local'", { url: `LIBSQL://${HOST}`, mode: 'local' }], + ["HTTPS:// + mode 'local'", { url: `HTTPS://${HOST}`, mode: 'local' }], + ["LIBSQL:// + mode 'replica'", { url: `LIBSQL://${HOST}`, mode: 'replica' }], + ])('%s is refused as a remote url in a local-engine mode', (_label, config) => { + const refusal = refusalOf(() => new TursoDriver(config)); + + expectEnvelope(refusal); + expect(refusal.message).toContain(`\`mode: '${config.mode}'\``); + expect(refusal.message).toContain("mode: 'remote'"); + }); + + it('an uppercase `WSS://` with a `timeout` and no `mode` is now remote, so the WebSocket window refusal reaches it', () => { + const refusal = refusalOf(() => new TursoDriver({ url: `WSS://${HOST}`, timeout: 30_000 })); + + expectEnvelope(refusal); + expect(refusal.message).toContain('TursoDriverConfig.timeout'); + expect(refusal.message).toContain('`WSS://`'); + }); + + it('an uppercase `FILE:` url is a durable local file: the rows survive a restart', async () => { + const url = upperFile(files.next()); + const make = () => new TursoDriver({ url }); + + const first = make(); + expect(first.transportMode).toBe('local'); + await first.connect(); + await first.initObjects([NOTE as never]); + await first.create('note', { id: 'n1', title: 'kept' }); + 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(); + }); + + it('an uppercase `FILE:` url beside `syncUrl` is an embedded replica on that file', async () => { + const url = upperFile(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' }); + await first.disconnect(); + + const second = make(); + await second.connect(); + await second.initObjects([NOTE as never]); + expect(await second.find('note', {})).toHaveLength(1); + await second.disconnect(); + stub.close(); + }); + + it('an uppercase `FILE::memory:` replica is refused as in-memory, exactly as the lowercase spelling is', () => { + const refusal = refusalOf(() => new TursoDriver({ url: 'FILE::memory:', syncUrl: PRIMARY })); + + expectEnvelope(refusal); + expect(refusal.message).toContain('names an in-memory database'); + expect(refusal.message).toContain('embedded replica'); + }); +}); + +describe('a url that is none of file:, :memory: or a remote url: refused in a local or replica mode', () => { + it.each<[string, TursoDriverConfig]>([ + ['a relative bare path, no mode', { url: './data/app.db' }], + ['a bare path with no leading dot', { url: 'data/app.db' }], + ['an absolute bare path', { url: '/var/lib/objectstack/app.db' }], + ['a Windows path', { url: 'C:\\data\\app.db' }], + ['a sqlite: url', { url: 'sqlite:./data/app.db' }], + ['a memory:// url', { url: 'memory://scratch' }], + ['an uppercase :MEMORY:', { url: ':MEMORY:' }], + ['a remote scheme with no //', { url: 'libsql:db.example.turso.io' }], + ['a file: url behind leading whitespace', { url: ' file:./data/app.db' }], + ['an empty url', { url: '' }], + ])('%s', (_label, config) => { + const refusal = refusalOf(() => new TursoDriver(config)); + + expectEnvelope(refusal); + expect(refusal.message).toContain(UNRECOGNISED); + expect(refusal.message).toContain('a local database'); + // The way out, by the spelling that would have been right. + expect(refusal.message).toContain("url: 'file:./data/app.db'"); + }); + + it("a bare path under a forced mode 'local' is refused, and the remote way out names the key to drop", () => { + const refusal = refusalOf(() => new TursoDriver({ url: './data/app.db', mode: 'local' })); + + expectEnvelope(refusal); + expect(refusal.message).toContain(UNRECOGNISED); + expect(refusal.message).toContain("`mode: 'local'` makes this datasource a local database"); + expect(refusal.message).toContain("drop `mode: 'local'`"); + expect(refusal.message).toContain("url: 'file:./data/app.db'"); + }); + + it('a bare path beside `syncUrl` is refused as the embedded replica it asks for, never a local :memory: engine', () => { + 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: './data/replica.db', + syncUrl: PRIMARY, + client: stub as never, + sync: { onConnect: false }, + }), + ); + + expectEnvelope(refusal); + expect(refusal.message).toContain(UNRECOGNISED); + expect(refusal.message).toContain('`syncUrl` makes this datasource an embedded replica'); + expect(refusal.message).toContain("url: 'file:./data/replica.db'"); + expect(refusal.message).toContain('drop `syncUrl` (and `sync`)'); + // Refused before any client work: the stub is untouched. + expect(tablesInStub()).toBe(0); + stub.close(); + }); + + it("a bare path under a forced mode 'replica' is refused naming the file: spelling for the replica", () => { + const refusal = refusalOf(() => new TursoDriver({ url: './data/replica.db', mode: 'replica' })); + + expectEnvelope(refusal); + expect(refusal.message).toContain(UNRECOGNISED); + expect(refusal.message).toContain("`mode: 'replica'` makes this datasource an embedded replica"); + expect(refusal.message).toContain('name the remote in `syncUrl`'); + expect(refusal.message).toContain("url: 'file:./data/replica.db'"); + }); + + it('detectMode classifies it by what the declaration asks for; the refusal is in the constructor', () => { + expect(TursoDriver.detectMode({ url: './data/app.db' })).toBe('local'); + expect(TursoDriver.detectMode({ url: './data/app.db', syncUrl: PRIMARY })).toBe('replica'); + }); + + it('the factory door meets the same refusal: createTursoDriver is not a way around it', () => { + const refusal = refusalOf(() => createTursoDriver({ url: './data/app.db' })); + + expectEnvelope(refusal); + expect(refusal.message).toContain(UNRECOGNISED); + }); + + it('never echoes the url: a token or a path in it stays out of the boot log', () => { + const refusal = refusalOf(() => new TursoDriver({ url: './private-dir/app.db?authToken=SECRET-TOKEN-VALUE' })); + + expectEnvelope(refusal); + expect(refusal.message).not.toContain('SECRET-TOKEN-VALUE'); + expect(refusal.message).not.toContain('private-dir'); + }); +}); + +describe('SCOPE: a forced remote mode has no local engine, so this refusal does not judge its url', () => { + it("a bare path under mode 'remote' constructs, and @libsql/client refuses it at connect() as URL_INVALID", async () => { + const driver = new TursoDriver({ url: './data/app.db', mode: 'remote' }); + expect(driver.transportMode).toBe('remote'); + + await expect(driver.connect()).rejects.toMatchObject({ code: 'URL_INVALID' }); + }); +}); + +describe('PRESERVATION: the recognised spellings construct, and the one cell this change widens', () => { + it.each<[string, TursoDriverConfig, string]>([ + ['file: local', { url: 'file:./data/app.db' }, 'local'], + [':memory: local (ephemeral by declaration)', { url: ':memory:' }, 'local'], + ['file:./… + syncUrl replica', { url: 'file:./data/replica.db', syncUrl: PRIMARY }, 'replica'], + ["file: + mode 'local'", { url: 'file:./data/app.db', mode: 'local' }, 'local'], + [":memory: + mode 'local'", { url: ':memory:', mode: 'local' }, 'local'], + ['lowercase libsql:// remote', { url: `libsql://${HOST}`, authToken: 't' }, 'remote'], + ['lowercase https:// remote', { url: `https://${HOST}`, authToken: 't' }, 'remote'], + ["libsql:// + mode 'remote'", { url: `libsql://${HOST}`, mode: 'remote' }, 'remote'], + ["file: + mode 'remote'", { url: 'file:./data/app.db', mode: 'remote' }, 'remote'], + // WIDENED: refused at a7581b326, where a forced replica had to start with a + // lowercase `file:`. An uppercase `FILE:` url is a `file:` url now. + ["WIDENED: FILE: + mode 'replica', no syncUrl", { url: 'FILE:./data/replica.db', mode: 'replica' }, 'replica'], + ["WIDENED: FILE: + mode 'replica' + syncUrl", { url: 'FILE:./data/replica.db', syncUrl: PRIMARY, mode: 'replica' }, 'replica'], + ])('%s', (_label, config, mode) => { + // Knex opens its connection lazily, so constructing never touches the file. + expect(new TursoDriver(config).transportMode).toBe(mode); + }); + + it.each<[string, boolean]>([ + ['no syncUrl', false], + ['with syncUrl', true], + ])("WIDENED: FILE: + mode 'replica', %s, runs on the named file and keeps its rows across a restart", async (_label, withSync) => { + const url = upperFile(files.next()); + const stub = makeLibsqlSqliteStub(); + const make = () => + new TursoDriver({ + url, + mode: 'replica', + ...(withSync ? { 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' }); + 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(); + }); +}); diff --git a/packages/drivers/driver-turso/src/turso-driver-uppercase-ws-scheme-timeout-refusal.test.ts b/packages/drivers/driver-turso/src/turso-driver-uppercase-ws-scheme-timeout-refusal.test.ts index 0aee2cafd0b..8d3eb786bfa 100644 --- a/packages/drivers/driver-turso/src/turso-driver-uppercase-ws-scheme-timeout-refusal.test.ts +++ b/packages/drivers/driver-turso/src/turso-driver-uppercase-ws-scheme-timeout-refusal.test.ts @@ -43,10 +43,16 @@ * * And the three controls the refusal must not eat: * - * 1. `detectMode` is untouched — an uppercase url with NO explicit mode still - * falls through to `'local'`, the pre-existing behaviour this card - * deliberately does not change. (No pin held this before; the scope ruling - * on this card requires one, so it is written here rather than assumed.) + * 1. FLIPPED, deliberately. This control first pinned that an uppercase url + * with NO explicit mode fell through `detectMode` to `'local'`, the + * behaviour the window refusal was scoped not to change. That fall-through + * ran the local engine on a private `:memory:` database (writes read back, + * then vanished on restart), and it was then argued and removed on its own: + * `detectMode` now reads the scheme through the same case fold as + * `ridesWebSocketTransport`, the way `@libsql/client` routes it + * (`turso-driver-unrecognised-url-refusal.test.ts` is the primary pin). So + * an uppercase remote url with no mode is REMOTE, and an uppercase `WSS://` + * with a window and no mode meets this file's refusal like the lowercase one. * 2. The existing lowercase `wss://` / `ws://` refusals still fire * (`turso-driver-ws-timeout-refusal.test.ts` is the primary pin; repeated * here as the immediate neighbour of the widened predicate). @@ -58,9 +64,11 @@ * * Restore `ridesWebSocketTransport` to its literal-prefix form and the refusal * cases go RED (the constructor returns a driver with `transportMode: - * 'remote'`, and there is no envelope to read); all three controls stay GREEN, + * 'remote'`, and there is no envelope to read); controls 2 and 3 stay GREEN, * because each describes behaviour that is identical before and after. - * Measured both ways — see the PR. + * Measured both ways — see the PR. Since control 1 was flipped, its + * `WSS://` + window case reads the same predicate and goes RED with the + * refusal cases; its classification cases read `detectMode` and stay GREEN. */ import { describe, expect, it } from 'vitest'; @@ -122,22 +130,26 @@ describe('timeout beside an UPPERCASE WebSocket url in forced remote mode — re }); }); -describe('CONTROL 1 — detectMode is untouched: an uppercase url with no explicit mode is still local', () => { +describe('CONTROL 1 (FLIPPED) — an uppercase url with no explicit mode is detected as remote, as @libsql/client routes it', () => { it.each([`WSS://${HOST}`, 'Ws://127.0.0.1:8080', `HTTPS://${HOST}`, `LIBSQL://${HOST}`])( - '%s with no `mode` falls through to local, exactly as before this change', + '%s with no `mode` is remote, no longer a local database on a private :memory: engine', (url) => { const driver = new TursoDriver({ url }); - expect(driver.transportMode).toBe('local'); - expect(driver.isRemote).toBe(false); + expect(driver.transportMode).toBe('remote'); + expect(driver.isRemote).toBe(true); }, ); - it('and it stays local WITH a timeout — the refusal is scoped to remote mode, so it cannot reach here', () => { - const driver = new TursoDriver({ url: `WSS://${HOST}`, timeout: WINDOW_MS }); + it('so an uppercase WebSocket url WITH a timeout and no mode meets the refusal, exactly as the lowercase one does', () => { + const upper = refusalOf(() => new TursoDriver({ url: `WSS://${HOST}`, timeout: WINDOW_MS })); + const lower = refusalOf(() => new TursoDriver({ url: `wss://${HOST}`, timeout: WINDOW_MS })); - expect(driver.transportMode).toBe('local'); - expect(driver.getTursoConfig().timeout).toBe(WINDOW_MS); + expect(lower).not.toBeNull(); + expect(upper).not.toBeNull(); + expect(upper!.code).toBe('VALIDATION_ERROR'); + expect(upper!.status).toBe(400); + expect(upper!.message).toBe(lower!.message.split('`wss://`').join('`WSS://`')); }); }); diff --git a/packages/drivers/driver-turso/src/turso-driver.ts b/packages/drivers/driver-turso/src/turso-driver.ts index 07363cdbc4e..91426eb545d 100644 --- a/packages/drivers/driver-turso/src/turso-driver.ts +++ b/packages/drivers/driver-turso/src/turso-driver.ts @@ -12,16 +12,18 @@ * logic is inherited from SqlDriver. In remote mode, TursoDriver delegates * all operations to RemoteTransport which uses @libsql/client directly. * - * The transport mode is auto-detected from the URL: + * The transport mode is auto-detected from the URL, its scheme matched in any + * letter case, as `@libsql/client` matches it: * - `file:` or `:memory:` → local * - `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). + * would have run on a private `:memory:` database: in a local or replica mode, + * 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. */ import { SqlDriver, type SqlDriverConfig } from '@objectstack/driver-sql'; @@ -82,6 +84,13 @@ export interface TursoDriverConfig { * - `:memory:` → local mode (ephemeral) * - `libsql://my-db.turso.io` → remote mode (cloud-only) * - `https://my-db.turso.io` → remote mode (cloud-only) + * + * The scheme matches in any letter case (`LIBSQL://` is `libsql://`), as it + * does in `@libsql/client`. A path to a local database file needs the + * `file:` prefix: a bare path such as `./data/app.db` is not a url. In a + * local or replica mode the constructor refuses any url that is not `file:`, + * `:memory:` or a remote url (`VALIDATION_ERROR` / 400), because the local + * engine could open nothing but a private in-memory database for it. */ url: string; @@ -160,10 +169,14 @@ export interface TursoDriverConfig { * - `file:` with syncUrl → `'replica'` * - `libsql://` / `https://` / `http://` / `wss://` / `ws://` without syncUrl → `'remote'` * + * Schemes match in any letter case. A url that is none of these is refused + * at construction (`VALIDATION_ERROR` / 400), with or without `syncUrl`. + * * 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. + * is refused beside a remote url or any other url that is not `file:` or + * `:memory:` (`VALIDATION_ERROR` / 400), and `'replica'` is refused on an + * in-memory url too. The engine would otherwise run on a private in-memory + * database. */ mode?: TursoTransportMode; @@ -597,20 +610,15 @@ function timeoutWindow(config: TursoDriverConfig): number | undefined { * the refusal message echoes the operator's own spelling and stays greppable * against their config. * - * ⚠️ DELIBERATE INCONSISTENCY, and it is deliberate: `TursoDriver.detectMode` - * matches the same two schemes CASE-SENSITIVELY and is left that way. Folding - * case there as well would delete its uppercase → `'local'` fall-through — a - * mode-detection change on a published driver that predates this refusal - * entirely and is out of scope here; it must be argued on its own, not slipped - * in as a tidy-up. So the two readers of one url disagree on purpose: this one - * answers "does the WINDOW reach anything", `detectMode` answers "which - * transport is this", and only the first question is settled by the scheme the - * libsql client will actually route on. ⛔ Do not "unify" them without that - * argument. + * `TursoDriver.detectMode` reads the scheme through the same fold + * ({@link startsWithScheme}), so the two readers of one url agree: an uppercase + * `WSS://` with no `mode` is detected as remote and meets this refusal too. It + * used to be matched case-sensitively there and fell through to `'local'` on a + * private `:memory:` engine; that fall-through was argued and removed on its + * own (see {@link localEngineDefect}), not folded in here as a tidy-up. */ function ridesWebSocketTransport(url: string): boolean { - const scheme = url.toLowerCase(); - return scheme.startsWith('wss://') || scheme.startsWith('ws://'); + return startsWithScheme(url, 'wss://') || startsWithScheme(url, 'ws://'); } /** @@ -728,17 +736,38 @@ function refuseSuppliedClientTimeout(timeoutMs: number): never { // ── The local engine: a file, or a declared `:memory:` — never a silent one ─── +/** + * Does `url` begin with `prefix` (a lowercase scheme, with its `://` or `:`), + * comparing the scheme's letters in any case? + * + * A url's scheme is case-insensitive, and `@libsql/client` reads it that way: + * `@libsql/core@0.17.4` `lib-esm/config.js` routes on + * `uri.scheme.toLowerCase()`. Executed against that version, `expandConfig` + * answers `https` for `LIBSQL://…`, `wss` for `Wss://…` and `file` for + * `FILE:./x.db`, and `createClient` opens each of them. Every reader of the url + * in this driver compares through here, so none of them classifies a url + * differently from the client it is handed to. Only the comparison folds: the + * url itself is passed on exactly as authored. + */ +function startsWithScheme(url: string, prefix: string): boolean { + return url.slice(0, prefix.length).toLowerCase() === prefix; +} + /** * 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). + * never disagree with it about which urls are remote. Matched in any letter + * case, through {@link startsWithScheme}. */ 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)); + return REMOTE_URL_PREFIXES.some((prefix) => startsWithScheme(url, prefix)); +} + +/** A `file:` url, its scheme matched in any letter case: the local engine opens its path. */ +function isFileUrl(url: string): boolean { + return startsWithScheme(url, 'file:'); } /** @@ -752,12 +781,12 @@ function hasRemotePrefix(url: string): boolean { */ function namesInMemoryDatabase(url: string): boolean { if (url === ':memory:') return true; - if (!url.startsWith('file:')) return false; + if (!isFileUrl(url)) return false; const path = url.slice('file:'.length); return path === ':memory:' || path.startsWith(':memory:?'); } -type LocalEngineDefect = 'remote-url' | 'replica-without-file'; +type LocalEngineDefect = 'remote-url' | 'unrecognised-url' | 'in-memory-replica'; /** * Which way, if any, a LOCAL or REPLICA configuration would leave the local @@ -794,26 +823,42 @@ type LocalEngineDefect = 'remote-url' | 'replica-without-file'; * `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. + * - `'unrecognised-url'`: a url that is none of `file:`, `:memory:` or a + * remote url, such as a bare path (`./data/app.db`) or an unsupported + * scheme. With no `mode`, {@link TursoDriver.detectMode} used to answer + * `'local'` for it, matching remote schemes case-sensitively too, so an + * uppercase `LIBSQL://` landed here as well; a forced `mode: 'local'` sent it + * the same way (an uppercase `FILE:` too). Measured on `main` @ `a7581b326`, + * same probe: + * + * ``` + * LIBSQL://… (no mode) -> local, 1 row, 0 after restart + * FILE:/x.db (no mode) -> local, 1 row, 0 after restart, file never created + * .//app.db (no mode) -> local, 1 row, 0 after restart, file never created + * /app.db (no mode) -> local, 1 row, 0 after restart, file never created + * .//app.db + mode: 'local' -> local, 1 row, 0 after restart, file never created + * file:/x.db (control) -> local, 1 row, 1 after restart, file created + * ``` + * + * The scheme is now matched in any letter case (see {@link startsWithScheme}), + * so an uppercase remote url is remote, as the client routes it. What is left + * has no durable reading at all: `@libsql/client@0.17.4` refuses a bare path + * as `URL_INVALID` ("not in a valid format") and an unsupported scheme as + * `URL_SCHEME_NOT_SUPPORTED`. Treating a bare path as `file:` instead was + * rejected: that invents a url spelling the client refuses, so the same + * string would open a file as a local database and fail as a replica. + * - `'in-memory-replica'`: a replica on a url `@libsql/client` reads as + * in-memory. 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. + * + * Checked in that order, so each configuration meets the one refusal that + * names its way out. */ 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'; - } + if (url !== ':memory:' && !isFileUrl(url)) return 'unrecognised-url'; + if (mode === 'replica' && namesInMemoryDatabase(url)) return 'in-memory-replica'; return undefined; } @@ -835,11 +880,12 @@ function localEngineDefect(url: string, mode: 'local' | 'replica'): LocalEngineD * 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 + * ⛔ The url is not echoed, only a remote url's 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. + * reaches an operator's boot log and Studio's datasource form. An unrecognised + * url has no scheme this driver can name, so nothing of it is echoed. ⛔ No + * internal issue id in the message either, for the same reason. The ids live + * in the comments beside it. */ function refuseNonDurableLocalEngine( config: TursoDriverConfig, @@ -870,20 +916,49 @@ function refuseNonDurableLocalEngine( 'it, measured against @libsql/client 0.17.4, so there is no embedded replica to sync.) ' : '') + `To use the remote database, ${toRemote}. ${toLocal}`; + } else if (defect === 'unrecognised-url') { + const asks = config.mode + ? `${cause} makes this datasource ${arm}` + : config.syncUrl + ? `\`syncUrl\` makes this datasource ${arm}` + : `With no \`mode\` and no remote scheme, this datasource is ${arm}`; + // Every key that would still make a remote url a local or replica + // configuration, so the remote way out is complete as written. + const keepsItLocal = [ + ...(config.mode ? [`\`mode: '${config.mode}'\``] : []), + ...(config.syncUrl ? ['`syncUrl` (and `sync`)'] : []), + ]; + const toRemote = + 'For a remote database, ' + + (keepsItLocal.length > 0 ? `drop ${keepsItLocal.join(' and ')} and ` : '') + + 'use one of the remote schemes above.'; + const toFile = + mode === 'replica' + ? 'For an embedded replica, spell the local path as a `file:` url and ' + + (config.syncUrl ? 'keep' : 'name') + + " the remote in `syncUrl`: `url: 'file:./data/replica.db'`." + : "For a local database file, spell the path as a `file:` url: `url: 'file:./data/app.db'`. " + + "For a throwaway in-memory database, `url: ':memory:'`."; + message = + '`TursoDriverConfig.url` is not a url this driver recognises: it is not `:memory:`, not a ' + + '`file:` url, and not a remote `libsql://`, `https://`, `http://`, `wss://` or `ws://` url ' + + `(a scheme matches in any letter case). ${asks}, which runs every read and write through a ` + + 'local SQLite engine that can open only a `file:` url or `:memory:`. On this url it would run ' + + 'on a private in-memory database instead: writes would succeed and read back, then be lost on ' + + 'restart. (@libsql/client refuses such a url itself: a bare path as URL_INVALID, an unsupported ' + + `scheme as URL_SCHEME_NOT_SUPPORTED, measured against @libsql/client 0.17.4.) ${toFile} ${toRemote}`; } 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.`; + `\`TursoDriverConfig.url\` names an in-memory database, 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 in-memory local database, which is what the url ` + + 'names: ephemeral by declaration.'; } const err = new Error(message) as Error & { code?: string; status?: number }; err.code = StandardErrorCode.enum.VALIDATION_ERROR; @@ -1055,9 +1130,10 @@ 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. + // a url that is none of `file:` / `:memory:` / remote, or a replica on an + // in-memory url) 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); @@ -1161,6 +1237,10 @@ export class TursoDriver extends SqlDriver { /** * Detect the transport mode from the URL and config. + * + * The scheme is matched in any letter case ({@link startsWithScheme}), the + * way `@libsql/client` routes it, so an uppercase `LIBSQL://` is remote here + * exactly as it is remote to the client this driver hands it to. */ static detectMode(config: TursoDriverConfig): TursoTransportMode { // Explicit mode override @@ -1169,7 +1249,7 @@ export class TursoDriver extends SqlDriver { const url = config.url; // Local modes: file: or :memory: - if (url === ':memory:' || url.startsWith('file:')) { + if (url === ':memory:' || isFileUrl(url)) { return config.syncUrl ? 'replica' : 'local'; } @@ -1178,9 +1258,9 @@ export class TursoDriver extends SqlDriver { // self-hosted / local-dev Turso-compatible endpoint (e.g. an ObjectBase // gateway with no TLS termination, or sqld on localhost). @libsql/client // natively accepts these schemes; they MUST be classified as remote so - // 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). + // queries go over the wire. Before they were, such a url fell through to + // the fallback below and silently wrote to an ephemeral in-memory DB (data + // never reached the remote, lost on every restart). 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 @@ -1194,8 +1274,12 @@ export class TursoDriver extends SqlDriver { return 'remote'; } - // Fallback: treat as local - return 'local'; + // Anything else (a bare path, an unsupported scheme) is classified by what + // the declaration asks for, like the two arms above, and the constructor + // REFUSES it (`localEngineDefect`): the local engine can open nothing but + // a private `:memory:` database for it, and `@libsql/client` refuses such + // a url itself. It is never run. + return config.syncUrl ? 'replica' : 'local'; } /** @@ -1224,27 +1308,21 @@ export class TursoDriver extends SqlDriver { }; } - if (config.url.startsWith('file:')) { + if (isFileUrl(config.url)) { return { client: 'better-sqlite3', - connection: { filename: config.url.replace(/^file:/, '') }, + connection: { filename: config.url.slice('file:'.length) }, useNullAsDefault: true, }; } - // 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:' }, - useNullAsDefault: true, - }; + // Not reached from the constructor: every local or replica url that is + // neither `file:` nor `:memory:` is refused there first + // (`localEngineDefect`). This arm used to hand such a url a private + // `:memory:` database, which lost every write on restart. It refuses + // instead, with the same envelope, so no url can reach an in-memory + // engine it did not name, whatever the caller. + return refuseNonDurableLocalEngine(config, mode, 'unrecognised-url'); } /**