Skip to content

Commit 0142415

Browse files
fix(driver-turso)!: refuse a remote url beside syncUrl, and any replica not on a local file, instead of running on :memory: (#19971)
Fixes #19893 Clause-②: no (narrowing) ## What was wrong `TursoDriver.detectMode` classified a remote `url` (`libsql://`, `https://`, `http://`, `wss://`, `ws://`) beside `syncUrl` as `replica`. The last arm of `toKnexConfig` then handed that mode's local engine (Knex + better-sqlite3) `:memory:`. Every write read back, then vanished on restart, and nothing reached the remote. The code comment saying `@libsql/client` "operates in embedded replica mode with an in-memory local cache" was false. ## What this changes - **The constructor refuses** these configurations before `super()`, beside the two existing `timeout` refusals and with the same envelope (`VALIDATION_ERROR` / 400): - a remote url in a local or replica mode: auto-detected through `syncUrl` (the card), or forced with `mode: 'replica'` / `mode: 'local'`; - a replica whose `url` is not a local `file:` path: `:memory:`, `file::memory:`, or a bare path under a forced `mode: 'replica'`. - **The message** names the scheme it met and both ways out: drop `syncUrl` (or the forced `mode`) for a remote database, or use `url: 'file:./data/replica.db'` beside `syncUrl` for an embedded replica. It never echoes the url, which may carry a live `?authToken=` (pinned). - **`detectMode` returns the same answers as before**: the pair still classifies `replica`, and the refusal is a construction check, not a re-classification (pinned). It now shares one remote-prefix list with the refusal, and the false comment is replaced with the measured reading. - **README**: a paragraph under the auto-detection table, plus a correction to the `syncUrl` config comment. The mode table never listed the pair. - **Test fixtures**: replica-face fixtures that rode `:memory:` + `syncUrl` now get a fresh local file per driver (`replica-file.testkit.ts`), keeping the per-test isolation `:memory:` gave them. Two tests pinned the defect itself and were replaced (see Acceptance notes). **In-place fold, named per the bounded in-place fix rule.** A forced `mode: 'local'` beside a remote url is not the card's arm, but it reaches the identical `toKnexConfig` last arm (measured below: 1 row back, 0 after restart). It is the same defect class, caught by the same predicate (the classifier's own remote-prefix list), in the same file, under the same gates. So it is folded into the one guard rather than filed. **Deliberately NOT changed.** 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, still auto-detects `local` and still runs on `:memory:`. That fall-through is recorded at `ridesWebSocketTransport` as deliberately left alone, and `turso-driver-uppercase-ws-scheme-timeout-refusal.test.ts` (CONTROL 1) pins it as constructing. Changing it needs its own decision, so it goes back to the seat as a separate finding. `planMediaColumnMove` and the remote media-column paths are untouched; #19894 remains open. ## Measurements, BASE `2c1011b01b` A throwaway probe (not committed) ran `initObjects`, `create` and `find`, then built a fresh driver on the same config (a restart): | configuration | mode | knex engine | rows back | rows after restart | |:---|:---|:---|:---|:---| | `libsql://` + `syncUrl` + client stub, `sync.onConnect: false` (the card's repro) | replica | `:memory:` | 1 (the stub held no tables) | 0 | | `libsql://` / `https://` / `wss://` + `syncUrl`, real client, `onConnect: false` | replica | `:memory:` | 1 | 0 | | `libsql://` + `syncUrl`, real client, default sync | replica | `:memory:` | `connect()` rejects `SYNC_NOT_SUPPORTED` | n/a | | `libsql://` + `mode: 'replica'`, no `syncUrl` | replica | `:memory:` | 1 | 0 | | `libsql://` + `mode: 'local'` | local | `:memory:` | 1 | 0 | | `:memory:` + `syncUrl`, real client, default sync | replica | `:memory:` | `connect()` rejects `URL_INVALID` | n/a | | `:memory:` + `syncUrl` + client stub, `onConnect: false` | replica | `:memory:` | 1 (the stub held no tables) | 0 | | uppercase `LIBSQL://`, no mode (not changed here) | local | `:memory:` | 1 | 0 | | bare path, no mode (not changed here) | local | `:memory:` | 1; the file was never created | 0 | | CONTROL `file:` + `syncUrl` + client stub | replica | the file | 1 | 1 | | CONTROL `file:` local | local | the file | 1 | 1 | **Why `@libsql/client@0.17.4` builds no replica here.** - `lib-esm/node.js` `_createClient` routes `wss`/`ws` to its WebSocket client and `https`/`http` to its HTTP client; only anything else reaches `sqlite3.js`. - `syncUrl` is read only in `lib-esm/sqlite3.js`. A `syncUrl` grep over `http.js` and `ws.js` returns 0; the control grep for `authToken` returns 6 in each. - `http.js` / `ws.js` `sync()`: `throw new LibsqlError("sync not supported in http mode", "SYNC_NOT_SUPPORTED")`, and the ws variant is the same. - `sqlite3.js`: `if (isInMemory && config.syncUrl) throw new LibsqlError("Embedded replica must use file for local db but URI with in-memory mode were provided instead: …", "URL_INVALID")`. **Where the config is parsed.** Neither factory parses a schema: `packages/runtime/src/turso-driver-factory.ts` and `packages/services/service-datasource/src/default-datasource-driver-factory.ts` both go `buildTursoDriverConfig` then `new TursoDriver`. So this constructor is the runtime's only gate. The spec-lane `TursoConfigSchema` is parsed at authoring and wizard time through `validateDriverConfig`. The driver-local copy has no in-repo parse caller outside its own test. The matching authoring-time refinement is outside this PR's surface (`packages/spec` untouched) and is handed to the seat. ## Verification, HEAD `5fb95579` - `pnpm --filter @objectstack/driver-turso typecheck`: `tsc --noEmit`, exit 0. The program covers all 60 test files, including the new pin and testkit (counted with `--listFiles`). - `pnpm --filter @objectstack/driver-turso test`: `Test Files 60 passed (60)`, `Tests 1358 passed (1358)`. - **New pin** `turso-driver-remote-url-replica-refusal.test.ts`, 27 cases: - 19 refusal cases assert the envelope (`code` + `status`), plus named subjects in the message (the keys, the scheme, `url: 'file:`) and the absence of an echoed token/host; - 8 preservation cases: a `file:` replica that connects, writes and keeps its row across a restart; the `file:` / `:memory:` local and remote faces; `detectMode` unchanged. - **Reverse verification**, direction predicted before running. With the constructor's guard call deleted (`scripts/ablation-replace.mjs`, anchor hit 1 to 0, blob `07363cdb` to `5ee02e4e`): - `Tests 20 failed | 105 passed`: all 19 refusal cases plus the converted `turso-driver.test.ts` case went red, each at "expected the constructor to refuse, and it returned a driver"; - all 8 preservation cases stayed green; - restore proven: blob equals HEAD (`07363cdb`) and `git diff HEAD` is empty. The subject resolves from `src` (relative imports), so no `dist` leg applies. - **`pnpm check:driver-conformance`**: before `OK — 50 covered cell(s), 0 in the DEBT ledger, 0 exempt.`; after the same. - **Changeset gates**: - `check-adr-0087-registration`: `1 declared-breaking changeset(s), each carrying an ADR-0087 disposition.`, with this changeset classified `[BREAKING+bang+clause-②-narrowing]` `not-required (no-migration-prescription)`; - `check-changeset-no-major`: `This diff introduces no major bump.`; - `check-empty-changeset`: green. - **Gates**: `dispatch-gates --commands` on the real diff derived 61 commands; `--ran` reconciles `61 derived, 59 run, 2 NOT-MEASURED, 0 UNRUN`, and all 59 run exit 0. The four artifact-roster gates whose roster sits under a touched directory were also run, all exit 0. - **NOT MEASURED** (both `PREREQUISITE NOT MET`, needing a whole-workspace build): - `check:dual-build-cjs-loads`. Narrowed stand-in: the built `driver-turso` CJS and ESM entries both load `TursoDriver`; - `check:type-check-debt`. Narrowed stand-in: `driver-turso` has no DEBT, TEST_DEBT or test-typecheck-debt entry, and its `tsc --noEmit` is clean. CI runs both over the whole tree. - **Lint, narrowed and proven.** - Population, read from eslint itself: of the 14 changed paths it lints the 12 `.ts` files and ignores the 2 `.md`. - Count, from `--format json`: 12 files, 0 errors, 0 warnings. - Invariance: `eslint.config.mjs` never enables type-aware linting (no `parserOptions.project`, no typed rules; its own header says so), so this diff cannot move any untouched file's verdict. - Full `pnpm lint` is CI's. ## Acceptance notes - **Replaced pins**: - `turso-driver.test.ts` "should accept remote URL when syncUrl is provided" pinned the defect and now asserts the refusal; - its two "detect replica mode" cases for `:memory:` / `libsql://` + `syncUrl` now pin `TursoDriver.detectMode` (unchanged) instead of constructing; - `turso-driver-ws-timeout-refusal.test.ts` built a `wss://` + `syncUrl` replica (the card's pair). That half is removed and its successor lives in the new pin file. - **Loader fixtures outside this package** still pair `url: 'libsql://my-db.turso.io'` with `syncUrl` and `mode: 'replica'`: `packages/cli/src/utils/storage-driver.test.ts` ("produces a config the real driver accepts"), `packages/runtime/src/turso-driver-factory.convergence.test.ts` and `packages/services/service-datasource/src/__tests__/turso-driver-config.test.ts`. They exercise the key-forwarding builder or a capturing constructor, never the real driver, so they stay green. The first title now overstates what the real driver accepts. They are outside this card's file surface; there is no carrier. - **Not measured**: whether writes on a `file:` + `syncUrl` replica reach the remote primary. Knex writes the file through better-sqlite3, not through the libsql connection. Measuring it needs a live sqld. - **Reach, for the seat's grading**: - in-repo, no example, template, published skill, hand-written doc or factory default declares a remote url beside `syncUrl`; the host boot path (`OS_DATABASE_URL`) passes none; - with the driver's own client and the default `sync.onConnect`, the card's pair failed at `connect()` rather than silently; silent loss needed `sync.onConnect: false`, a supplied client, or a forced `mode`; - out-of-repo deployments are not measured. --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 9bfbacb commit 0142415

14 files changed

Lines changed: 636 additions & 69 deletions
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
---
2+
'@objectstack/driver-turso': minor
3+
---
4+
5+
fix(driver-turso)!: a local or replica `TursoDriver` on a remote url, or a replica off a local file, is refused at construction
6+
7+
Clause-②: no (narrowing)
8+
9+
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:
10+
11+
```
12+
libsql:// + syncUrl (sync.onConnect: false) -> 1 row back, 0 rows after restart
13+
libsql:// + mode: 'replica' or mode: 'local' -> 1 row back, 0 rows after restart
14+
:memory: + syncUrl + a supplied client -> 1 row back, 0 rows after restart
15+
file: + syncUrl (unchanged) -> 1 row back, 1 row after restart
16+
```
17+
18+
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`.
19+
20+
**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:
21+
22+
- a remote url beside `syncUrl`;
23+
- a remote url under a forced `mode: 'replica'` or `mode: 'local'`;
24+
- 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;
25+
- under a forced `mode: 'replica'` only, any `url` that is not a local `file:` path, such as a bare path or an uppercase scheme.
26+
27+
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.
28+
29+
**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.
30+
31+
**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.
32+
33+
**What an affected author does.** Each refusal names its ways out. For a remote url in a local or replica mode:
34+
35+
- to use the remote database, drop `syncUrl` (and `sync`) and any forced `mode`; the remote url alone sends every read and write to it;
36+
- 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'`.
37+
38+
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:'`.
39+
40+
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.
41+
42+
<!-- 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. -->

‎packages/drivers/driver-turso/README.md‎

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,25 @@ Transport mode is automatically detected from the URL:
130130
| `libsql://...` | `remote` | @libsql/client only |
131131
| `https://...` | `remote` | @libsql/client only |
132132

133+
An embedded replica is a local **file** kept in sync with a remote. The local and
134+
replica modes run every read and write through the local SQLite engine, which
135+
cannot open a remote url, and a replica needs a file for the sync to land in.
136+
The constructor therefore refuses (`VALIDATION_ERROR` / 400):
137+
138+
- a remote url (the lowercase `libsql://`, `https://`, `http://`, `wss://`,
139+
`ws://` that auto-detection matches) beside `syncUrl`, or under a forced
140+
`mode: 'local'` / `'replica'`;
141+
- a replica on an in-memory url (`:memory:`, `file::memory:`). That covers a
142+
replica auto-detected from a `:memory:` or `file:` url beside `syncUrl`, and
143+
one forced with `mode: 'replica'`;
144+
- under a forced `mode: 'replica'`, any `url` that is not a local `file:` path.
145+
146+
In each case the engine would otherwise run on a private in-memory database
147+
whose writes read back and then vanish on restart, and `@libsql/client` builds
148+
no embedded replica for a remote url anyway. For a remote database, drop
149+
`syncUrl` and any forced `mode`. For an embedded replica, use
150+
`url: 'file:./data/replica.db'` beside `syncUrl`.
151+
133152
You can also force a specific mode:
134153

135154
```typescript
@@ -216,7 +235,7 @@ interface TursoDriverConfig {
216235
*/
217236
concurrency?: number;
218237

219-
/** Remote sync URL for embedded replica mode (libsql:// or https://) */
238+
/** Remote sync URL for embedded replica mode (libsql:// or https://); the replica is the local file: named by `url` */
220239
syncUrl?: string;
221240

222241
/** Sync configuration (requires syncUrl) */
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* A fresh local FILE for an embedded-replica fixture.
5+
*
6+
* An embedded replica is a local file kept in sync with the remote named in
7+
* `syncUrl`, and `TursoDriver` refuses a replica on anything else at
8+
* construction (`:memory:`, a remote url). In those cases the local engine
9+
* would have run on a private in-memory database that no sync ever reaches.
10+
* A fixture that exercises the replica face therefore needs a real path. It
11+
* needs a NEW one per driver, so each test starts from an empty database,
12+
* exactly as the `:memory:` fixtures these replace did.
13+
*
14+
* ```ts
15+
* const files = replicaFiles();
16+
* afterAll(() => files.removeAll());
17+
* new TursoDriver({ url: files.next(), syncUrl, client, sync: { onConnect: false } });
18+
* ```
19+
*/
20+
21+
import { mkdtempSync, rmSync } from 'node:fs';
22+
import { tmpdir } from 'node:os';
23+
import { join } from 'node:path';
24+
25+
export interface ReplicaFiles {
26+
/** A `file:` url naming a database file in a directory of its own; the file does not exist yet. */
27+
next(): string;
28+
/** Delete every directory `next()` created. Idempotent. */
29+
removeAll(): void;
30+
}
31+
32+
export function replicaFiles(): ReplicaFiles {
33+
const dirs: string[] = [];
34+
return {
35+
next() {
36+
const dir = mkdtempSync(join(tmpdir(), 'turso-replica-'));
37+
dirs.push(dir);
38+
return `file:${join(dir, 'replica.db')}`;
39+
},
40+
removeAll() {
41+
while (dirs.length > 0) rmSync(dirs.pop()!, { recursive: true, force: true });
42+
},
43+
};
44+
}
Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* A local or replica `TursoDriver` with nothing durable behind its engine is
5+
* refused at construction. Before this, it silently ran on a private
6+
* `:memory:` database.
7+
*
8+
* # What was measured (the reading this refusal stands on)
9+
*
10+
* The local and replica arms run every read and write through the inherited
11+
* Knex + better-sqlite3 engine, and `TursoDriver.toKnexConfig` could hand that
12+
* engine only a `file:` path or `:memory:`. Everything else went to its last
13+
* arm, which was `:memory:`. On `main` @ `2c1011b01b`, `initObjects`, then
14+
* `create`, then `find`, then a fresh driver on the same config:
15+
*
16+
* ```
17+
* libsql:// + syncUrl + client stub, sync.onConnect false -> replica, knex :memory:,
18+
* find 1 row, the client's own database held NO tables, 0 rows after restart
19+
* libsql:// / https:// / wss:// + syncUrl, real client, onConnect false -> same, 0 after restart
20+
* libsql:// + syncUrl, real client, default sync -> connect() rejects SYNC_NOT_SUPPORTED
21+
* libsql:// + mode 'replica' (no syncUrl) -> 1 row, 0 after restart
22+
* libsql:// + mode 'local' -> 1 row, 0 after restart
23+
* :memory: + syncUrl, real client, default sync -> connect() rejects URL_INVALID
24+
* :memory: + syncUrl + client stub, onConnect false -> 1 row, stub holds no tables, 0 after restart
25+
* file: + syncUrl + client stub (CONTROL) -> knex on the file, 1 row, 1 after restart
26+
* ```
27+
*
28+
* `@libsql/client@0.17.4` builds no embedded replica for a remote url:
29+
* `lib-esm/node.js` routes http/https to its HTTP client and ws/wss to its
30+
* WebSocket client, `syncUrl` is read only in `lib-esm/sqlite3.js` (a `syncUrl`
31+
* grep over `http.js` / `ws.js` returns zero, while `authToken` returns six in
32+
* each), and both remote clients' `sync()` throw `SYNC_NOT_SUPPORTED`. The
33+
* sqlite3 client refuses an in-memory replica itself: "Embedded replica must use
34+
* file for local db".
35+
*
36+
* # What this file pins
37+
*
38+
* - The card's own reproduction (remote url, `syncUrl`, a client stub,
39+
* `sync.onConnect: false`) is refused at construction, as the ADR-0112
40+
* envelope (`code` + `status`). The stub is never touched. Each remote scheme
41+
* the classifier knows is covered.
42+
* - Each other arm, as measured: a forced `mode: 'replica'` or `mode: 'local'`
43+
* beside a remote url, and a replica on `:memory:` / `file::memory:` / a
44+
* url that is not `file:`.
45+
* - The message names the way out and never echoes the url, which may carry a
46+
* live `?authToken=`.
47+
* - PRESERVATION: the `file:` replica still constructs, connects, writes, and
48+
* keeps its rows across a restart. So do the local, in-memory and remote
49+
* faces. `detectMode` still classifies the refused pair `replica`: the
50+
* refusal is in the constructor, not a re-classification.
51+
*
52+
* # Reverse verification: direction predicted before it was run
53+
*
54+
* With the constructor's `localEngineDefect` call removed, every refusal case
55+
* goes RED (the constructor returns a driver on `:memory:`, with no envelope to
56+
* read) and every preservation case stays GREEN. Measured; see the PR.
57+
*/
58+
59+
import { afterAll, describe, expect, it } from 'vitest';
60+
import { createTursoDriver } from './index.js';
61+
import { TursoDriver, type TursoDriverConfig } from './turso-driver.js';
62+
import { makeLibsqlSqliteStub } from './libsql-sqlite-stub.testkit.js';
63+
import { replicaFiles } from './replica-file.testkit.js';
64+
65+
type Refusal = Error & { code?: string; status?: number };
66+
67+
/** The error `build` threw, or `null` when it returned. */
68+
function refusalOf(build: () => unknown): Refusal | null {
69+
try {
70+
build();
71+
return null;
72+
} catch (error) {
73+
return error as Refusal;
74+
}
75+
}
76+
77+
function expectEnvelope(refusal: Refusal | null): asserts refusal is Refusal {
78+
expect(refusal, 'expected the constructor to refuse, and it returned a driver').not.toBeNull();
79+
expect(refusal!.code).toBe('VALIDATION_ERROR');
80+
expect(refusal!.status).toBe(400);
81+
}
82+
83+
const PRIMARY = 'libsql://primary.example.turso.io';
84+
const NOTE = { name: 'note', fields: { title: { type: 'string' } } };
85+
const files = replicaFiles();
86+
afterAll(() => files.removeAll());
87+
88+
describe('a remote url beside syncUrl: refused, never a replica on :memory:', () => {
89+
it("the card's reproduction: libsql:// + syncUrl + a client stub + sync.onConnect false", () => {
90+
const stub = makeLibsqlSqliteStub();
91+
const tablesInStub = () =>
92+
stub.raw.prepare(`select count(*) as c from sqlite_master where type='table'`).all()[0].c;
93+
94+
const refusal = refusalOf(
95+
() =>
96+
new TursoDriver({
97+
url: 'libsql://r.turso.io',
98+
syncUrl: 'libsql://r.turso.io',
99+
client: stub as never,
100+
sync: { onConnect: false },
101+
}),
102+
);
103+
104+
expectEnvelope(refusal);
105+
// Both ways out, by the subjects they name: the key to drop for a remote
106+
// database, the url spelling for a replica.
107+
expect(refusal.message).toContain('`syncUrl`');
108+
expect(refusal.message).toContain("url: 'file:");
109+
// Refused before any client work: the stub is untouched.
110+
expect(tablesInStub()).toBe(0);
111+
stub.close();
112+
});
113+
114+
it.each(['libsql://', 'https://', 'http://', 'wss://', 'ws://'])(
115+
'%s + syncUrl is refused, naming the scheme it met',
116+
(scheme) => {
117+
const refusal = refusalOf(() => new TursoDriver({ url: `${scheme}db.example.turso.io`, syncUrl: PRIMARY }));
118+
119+
expectEnvelope(refusal);
120+
expect(refusal.message).toContain(`\`${scheme}\``);
121+
expect(refusal.message).toContain('`syncUrl`');
122+
},
123+
);
124+
125+
it('the factory door meets the same refusal: createTursoDriver is not a way around it', () => {
126+
expectEnvelope(refusalOf(() => createTursoDriver({ url: 'libsql://r.turso.io', syncUrl: PRIMARY })));
127+
});
128+
129+
it('a `timeout` beside the pair does not change which refusal fires: this one owns the pair', () => {
130+
const refusal = refusalOf(() => new TursoDriver({ url: 'wss://r.turso.io', syncUrl: PRIMARY, timeout: 30_000 }));
131+
132+
expectEnvelope(refusal);
133+
expect(refusal.message).toContain('`syncUrl`');
134+
expect(refusal.message).not.toContain('TursoDriverConfig.timeout');
135+
});
136+
137+
it('never echoes the url: a live `?authToken=` in it stays out of the boot log', () => {
138+
const refusal = refusalOf(
139+
() => new TursoDriver({ url: 'libsql://r.turso.io?authToken=SECRET-TOKEN-VALUE', syncUrl: PRIMARY }),
140+
);
141+
142+
expectEnvelope(refusal);
143+
expect(refusal.message).not.toContain('SECRET-TOKEN-VALUE');
144+
expect(refusal.message).not.toContain('r.turso.io');
145+
});
146+
});
147+
148+
describe('a forced local or replica mode beside a remote url: refused', () => {
149+
it.each<[string, TursoDriverConfig]>([
150+
["mode 'replica', no syncUrl", { url: 'libsql://r.turso.io', mode: 'replica' }],
151+
["mode 'replica' + syncUrl", { url: 'libsql://r.turso.io', syncUrl: PRIMARY, mode: 'replica' }],
152+
["mode 'local'", { url: 'libsql://r.turso.io', mode: 'local' }],
153+
["mode 'local', https://", { url: 'https://r.turso.io', mode: 'local' }],
154+
])('%s', (_label, config) => {
155+
const refusal = refusalOf(() => new TursoDriver(config));
156+
157+
expectEnvelope(refusal);
158+
// The way out for a remote database names the key that forced the mode,
159+
// and the value that would have been right.
160+
expect(refusal.message).toContain(`\`mode: '${config.mode}'\``);
161+
expect(refusal.message).toContain("mode: 'remote'");
162+
});
163+
});
164+
165+
describe('a replica that is not a local file: refused', () => {
166+
it.each<[string, TursoDriverConfig]>([
167+
[':memory: + syncUrl', { url: ':memory:', syncUrl: PRIMARY }],
168+
[':memory: + syncUrl + a supplied client', { url: ':memory:', syncUrl: PRIMARY, client: {} as never, sync: { onConnect: false } }],
169+
['file::memory: + syncUrl', { url: 'file::memory:', syncUrl: PRIMARY }],
170+
["file::memory:?cache=shared + mode 'replica'", { url: 'file::memory:?cache=shared', mode: 'replica' }],
171+
[":memory: + mode 'replica'", { url: ':memory:', mode: 'replica' }],
172+
["a bare path + mode 'replica'", { url: './data/replica.db', mode: 'replica' }],
173+
])('%s', (_label, config) => {
174+
const refusal = refusalOf(() => new TursoDriver(config));
175+
176+
expectEnvelope(refusal);
177+
expect(refusal.message).toContain('embedded replica');
178+
expect(refusal.message).toContain("url: 'file:");
179+
});
180+
});
181+
182+
describe('PRESERVATION: every configuration with a durable (or declared-ephemeral) engine still constructs', () => {
183+
it('the file: replica constructs, connects, writes, and keeps its rows across a restart', async () => {
184+
const url = files.next();
185+
const stub = makeLibsqlSqliteStub();
186+
const make = () => new TursoDriver({ url, syncUrl: PRIMARY, client: stub as never, sync: { onConnect: false } });
187+
188+
const first = make();
189+
expect(first.transportMode).toBe('replica');
190+
await first.connect();
191+
await first.initObjects([NOTE as never]);
192+
await first.create('note', { id: 'n1', title: 'kept' });
193+
expect(await first.find('note', {})).toHaveLength(1);
194+
await first.disconnect();
195+
196+
const second = make();
197+
await second.connect();
198+
await second.initObjects([NOTE as never]);
199+
expect((await second.find('note', {})).map((r: { title?: unknown }) => r.title)).toEqual(['kept']);
200+
await second.disconnect();
201+
stub.close();
202+
});
203+
204+
it.each<[string, TursoDriverConfig, string]>([
205+
['file: local', { url: 'file:./data/app.db' }, 'local'],
206+
[':memory: local (ephemeral by declaration)', { url: ':memory:' }, 'local'],
207+
["file: + mode 'local'", { url: 'file:./data/app.db', mode: 'local' }, 'local'],
208+
["file: + mode 'replica' + syncUrl", { url: 'file:./data/replica.db', syncUrl: PRIMARY, mode: 'replica' }, 'replica'],
209+
['libsql:// remote', { url: 'libsql://r.turso.io', authToken: 't' }, 'remote'],
210+
["libsql:// + mode 'remote'", { url: 'libsql://r.turso.io', mode: 'remote' }, 'remote'],
211+
])('%s', (_label, config, mode) => {
212+
// Knex opens its connection lazily, so constructing never touches the file.
213+
expect(new TursoDriver(config).transportMode).toBe(mode);
214+
});
215+
216+
it('detectMode still classifies the refused pair `replica`: the refusal is in the constructor, not a re-classification', () => {
217+
expect(TursoDriver.detectMode({ url: 'libsql://r.turso.io', syncUrl: PRIMARY })).toBe('replica');
218+
expect(TursoDriver.detectMode({ url: ':memory:', syncUrl: PRIMARY })).toBe('replica');
219+
});
220+
});

0 commit comments

Comments
 (0)