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

Filter by extension

Filter by extension

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

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`. 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
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 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:

- 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 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.

**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.

<!-- adr-0087: not-required (no-migration-prescription) An accept-set narrowing performed at the driver constructor: no key, spec symbol, Zod schema, object definition or stored representation is added, removed or renamed — `TursoDriverConfig.url`, `syncUrl` and `mode` keep their names and types, and both `TursoConfigSchema` copies are untouched. What moves is which CONFIGURATIONS `new TursoDriver()` accepts, so `objectstack migrate meta` has nothing to visit and there is no tombstone to mint. Each refusal names its ways out, and which one an author wants (a remote database, an embedded replica on a local file, or a plain local database) is authoring intent no ledger line can decide. -->
21 changes: 20 additions & 1 deletion packages/drivers/driver-turso/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,25 @@ 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, 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:

```typescript
Expand Down Expand Up @@ -216,7 +235,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://); the replica is the local file: named by `url` */
syncUrl?: string;

/** Sync configuration (requires syncUrl) */
Expand Down
44 changes: 44 additions & 0 deletions packages/drivers/driver-turso/src/replica-file.testkit.ts
Original file line number Diff line number Diff line change
@@ -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 });
},
};
}
Original file line number Diff line number Diff line change
@@ -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');
});
});
Loading
Loading