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
30 changes: 30 additions & 0 deletions .changeset/20586-turso-forced-local-refuses-sync-url.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
---
'@objectstack/spec': minor
'@objectstack/driver-turso': minor
---

fix(spec,driver-turso)!: a turso config that forces `mode: 'local'` beside a `syncUrl` is refused where it is written and when the driver is built, instead of running as an embedded replica under a `local` label

Clause-②: yes (narrowing) — the accept set of the `turso` `datasource.config` contract narrows by one combination. No key is added, removed or renamed, and no exported symbol moves.

A `syncUrl` names the remote an embedded replica syncs with. A config that forced `mode: 'local'` on a `file:` url (or `:memory:`) beside a non-empty `syncUrl` was accepted by `@objectstack/spec`'s `TursoConfigSchema`, by the published mirror in `@objectstack/driver-turso`, and by `new TursoDriver()`. Measured on the driver source before this change, with a client that counts syncs: it constructed with `transportMode` `'local'`, then synced on connect, started the sync interval, and `isSyncEnabled()` answered `true` — exactly what the same config with no `mode` (a replica) did. A datasource declared local was kept in sync with a remote, and only a label said otherwise.

**BREAKING** accept-set narrowing on a published schema and a published constructor, shipped as `minor` under the repo's launch-window convention for breaking changes (`scripts/check-changeset-no-major.mjs`). Refused now, at both doors together, with one message whose prescription names both ways out:

- **at authoring**, as one `custom` issue on `mode` (`config.mode` on a datasource): `DatasourceSchema`, `validateDriverConfig`, `defineStack` / `os validate`, and a save or test connection through the datasource admin service;
- **at construction**, `VALIDATION_ERROR` / 400 from `new TursoDriver()` (and `createTursoDriver()`), before any client or database is opened.

The message is the same text at both doors, and a test holds the constructor's copy equal to the schema's issue byte for byte. It is the twin of the forced `mode: 'replica'`-without-`syncUrl` refusal, the other way round: honouring `mode: 'local'` by skipping the sync would ignore a declared `syncUrl` instead, which is the same defect with the keys swapped. The sibling refusals keep their order: a forced local mode on a remote url or a bare path still meets its `url` refusal first. An empty `syncUrl` is unset and is still accepted. The driver mirror declares no `mode` key and strips an authored one, so it cannot see a forced mode: this refusal reaches it only as byte-identical text, and the spec contract and the constructor are the two doors that judge it.

### Migration: FROM → TO

| You wrote | Write instead |
| --- | --- |
| `url: 'file:./data/replica.db', mode: 'local', syncUrl: 'libsql://my-db.turso.io'` | an embedded replica: drop `mode` (`url` and `syncUrl` select the replica) |
| the same | a plain local database: drop `syncUrl` (and `sync`), keeping `url: 'file:./data/app.db'` with or without `mode: 'local'` |

A datasource row stored in this shape is not re-parsed when it loads, so it now fails when the driver is built. `factory.create` throws the refusal. The connection service records the datasource as `failed-degraded` with the message, and a test connection answers `ok: false` ("Failed to build driver: …"). Under ADR-0062 D5, the boot fails fast when objects bind to that datasource or are routed to it, or when it is boot-critical, unless `OS_ALLOW_DRIVER_CONNECT_FAILURE` is set. Otherwise it is left unconnected with a warning. Before this change the same row booted and synced with the remote under a `local` label. The way out is the table above.

Blast radius, measured on this tree: no example, template, published skill or hand-written doc authors the shape, and no host default or environment variable sets `mode` or `syncUrl` (a turso `mode` reaches the driver only from an authored `datasource.config`). Whether any out-of-repo deployment declares such a config is NOT measured and is not claimed to be zero.

<!-- adr-0087: registered turso-config-forced-local-with-sync-url-refused -->
8 changes: 6 additions & 2 deletions packages/drivers/driver-turso/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,7 @@ no embedded replica for a remote url anyway. For a remote database, drop
no local engine, so its url is not judged here: `@libsql/client` refuses a
url it cannot open when the driver connects.

The constructor also refuses (`VALIDATION_ERROR` / 400) three sync settings
The constructor also refuses (`VALIDATION_ERROR` / 400) four sync settings
that nothing would honour, each with the message `@objectstack/spec`'s
`TursoConfigSchema` gives at authoring:

Expand All @@ -233,7 +233,11 @@ that nothing would honour, each with the message `@objectstack/spec`'s
it. Set `syncUrl`, or remove `sync`;
- a forced `mode: 'replica'` with no `syncUrl` (or an empty one), which would
never sync and would run as a plain local database. Name the remote in
`syncUrl` beside the `file:` url, or drop `mode` for a local database.
`syncUrl` beside the `file:` url, or drop `mode` for a local database;
- a forced `mode: 'local'` beside a non-empty `syncUrl`, which would still be
synced with that remote as an embedded replica, so the declared local mode
would be ignored. Drop `mode` for an embedded replica, or drop `syncUrl` (and
`sync`) for a plain local database.

You can also force a specific mode:

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,10 @@
* copies in `../turso-driver.ts`, and this is the pin that holds them equal.
* [#20437] The same holds for a forced `mode: 'replica'` with no `syncUrl`,
* refused on `mode` — the third copy. That row used to be accepted
* everywhere, as a replica that never synced.
* everywhere, as a replica that never synced. [#20586] And for a forced
* `mode: 'local'` beside a `syncUrl`, refused on `mode` too — the fourth
* copy. That row used to be accepted everywhere as well, as a "local"
* database the driver synced with the remote anyway.
*
* ⚠️ The mirror declares no `mode`, so zod strips an authored one before its
* refinement runs: rows that FORCE a mode are judged by the constructor and the
Expand Down Expand Up @@ -103,7 +106,8 @@ const ROWS: Row[] = [
{ name: 'a remote url behind whitespace', config: { url: ` ${REMOTE}` }, ctor: 'accept' },
{ name: 'an empty syncUrl (unset)', config: { url: REMOTE, syncUrl: '' }, ctor: 'accept' },
{ name: "file: + syncUrl under a forced mode: 'replica'", config: { url: FILE, mode: 'replica', syncUrl: REMOTE, sync: { onConnect: false } }, ctor: 'accept' },
{ name: "file: + syncUrl under a forced mode: 'local'", config: { url: FILE, mode: 'local', syncUrl: REMOTE, sync: { onConnect: false } }, ctor: 'accept' },
{ name: "file: under a forced mode: 'local'", config: { url: FILE, mode: 'local' }, ctor: 'accept' },
{ name: "file: + an empty syncUrl (unset) under a forced mode: 'local'", config: { url: FILE, mode: 'local', syncUrl: '' }, ctor: 'accept' },
{ name: "libsql:// under a forced mode: 'remote'", config: { url: REMOTE, mode: 'remote' }, ctor: 'accept' },
{ name: "file: under a forced mode: 'remote'", config: { url: FILE, mode: 'remote' }, ctor: 'accept' },
{ name: "a bare path under a forced mode: 'remote' (the client refuses it at connect)", config: { url: './data/app.db', mode: 'remote' }, ctor: 'accept' },
Expand All @@ -119,6 +123,8 @@ const ROWS: Row[] = [
{ name: "libsql:// + syncUrl under a forced mode: 'replica'", config: { url: REMOTE, mode: 'replica', syncUrl: REMOTE }, ctor: 'refuse', refusedOn: 'url' },
{ name: "libsql:// under a forced mode: 'local'", config: { url: REMOTE, mode: 'local' }, ctor: 'refuse', refusedOn: 'url' },
{ name: "https:// under a forced mode: 'local'", config: { url: 'https://db.example.turso.io', mode: 'local' }, ctor: 'refuse', refusedOn: 'url' },
// [#20586] ORDER: a remote url keeps its `url` refusal ahead of the forced-local `syncUrl` one.
{ name: "libsql:// + syncUrl under a forced mode: 'local'", config: { url: REMOTE, mode: 'local', syncUrl: REMOTE }, ctor: 'refuse', refusedOn: 'url' },

// ── a url that is none of file:, :memory: or remote, in a local or replica mode ──
{ name: 'a bare relative path', config: { url: './data/app.db' }, ctor: 'refuse', refusedOn: 'url' },
Expand All @@ -131,6 +137,8 @@ const ROWS: Row[] = [
{ name: 'a whitespace-only url', config: { url: ' ' }, ctor: 'refuse', refusedOn: 'url' },
{ name: 'a bare path beside syncUrl', config: { url: './data/replica.db', syncUrl: REMOTE }, ctor: 'refuse', refusedOn: 'url' },
{ name: "a bare path under a forced mode: 'local'", config: { url: './data/app.db', mode: 'local' }, ctor: 'refuse', refusedOn: 'url' },
// [#20586] ORDER: a bare path keeps its `url` refusal ahead of the forced-local `syncUrl` one.
{ name: "a bare path + syncUrl under a forced mode: 'local'", config: { url: './data/app.db', mode: 'local', syncUrl: REMOTE }, ctor: 'refuse', refusedOn: 'url' },
{ name: "a bare path under a forced mode: 'replica'", config: { url: './data/replica.db', mode: 'replica' }, ctor: 'refuse', refusedOn: 'url' },

// ── a replica on an in-memory url ───────────────────────────────────────
Expand Down Expand Up @@ -162,11 +170,23 @@ const ROWS: Row[] = [
{ name: "an uppercase FILE: url under a forced mode: 'replica'", config: { url: `FILE:${DIR}/upper-replica.db`, mode: 'replica' }, ctor: 'refuse', refusedOn: 'mode' },
{ name: "file: + an empty syncUrl (unset) under a forced mode: 'replica'", config: { url: FILE, mode: 'replica', syncUrl: '' }, ctor: 'refuse', refusedOn: 'mode' },
{ name: "file: + timeoutMs under a forced mode: 'replica'", config: { url: FILE, mode: 'replica', timeoutMs: 5000 }, ctor: 'refuse', refusedOn: 'mode' },

// ── a forced local mode beside a remote to replicate from: refused on `mode` (#20586) ──
// The first row was pinned `accept` until #20586: the driver labelled it local and synced it anyway.
{ name: "file: + syncUrl under a forced mode: 'local'", config: { url: FILE, mode: 'local', syncUrl: REMOTE, sync: { onConnect: false } }, ctor: 'refuse', refusedOn: 'mode' },
{ name: "file: + syncUrl, no sync, under a forced mode: 'local'", config: { url: FILE, mode: 'local', syncUrl: REMOTE }, ctor: 'refuse', refusedOn: 'mode' },
{ name: "an uppercase FILE: url + syncUrl under a forced mode: 'local'", config: { url: `FILE:${DIR}/upper-local.db`, mode: 'local', syncUrl: REMOTE }, ctor: 'refuse', refusedOn: 'mode' },
{ name: "a file: url behind whitespace + syncUrl under a forced mode: 'local'", config: { url: ` ${FILE}`, mode: 'local', syncUrl: REMOTE }, ctor: 'refuse', refusedOn: 'mode' },
{ name: "file: + syncUrl + timeoutMs under a forced mode: 'local'", config: { url: FILE, mode: 'local', syncUrl: REMOTE, timeoutMs: 5000 }, ctor: 'refuse', refusedOn: 'mode' },
{ name: "file: + a wss:// syncUrl under a forced mode: 'local'", config: { url: FILE, mode: 'local', syncUrl: 'wss://db.example.turso.io' }, ctor: 'refuse', refusedOn: 'mode' },
{ name: ":memory: + syncUrl under a forced mode: 'local'", config: { url: ':memory:', mode: 'local', syncUrl: REMOTE }, ctor: 'refuse', refusedOn: 'mode' },
{ name: "file::memory: + syncUrl under a forced mode: 'local'", config: { url: 'file::memory:', mode: 'local', syncUrl: REMOTE }, ctor: 'refuse', refusedOn: 'mode' },
];

/**
* The rows the constructor refuses on a sync key, or on a forced replica with
* no `syncUrl`: its message is a copy of the spec's (#20200, #20437).
* The rows the constructor refuses on a sync key, on a forced replica with no
* `syncUrl`, or on a forced local mode beside a `syncUrl`: its message is a
* copy of the spec's (#20200, #20437, #20586).
*/
const SYNC_KEY_REFUSALS = ROWS.filter(
(r) => r.ctor === 'refuse' && (r.refusedOn === 'syncUrl' || r.refusedOn === 'sync' || r.refusedOn === 'mode'),
Expand Down Expand Up @@ -225,8 +245,11 @@ describe('turso config: the constructor, the spec contract and this mirror agree
expect(ROWS.filter((r) => r.refusedOn === 'timeoutMs').length).toBeGreaterThanOrEqual(3);
expect(ROWS.filter((r) => r.refusedOn === 'syncUrl').length).toBeGreaterThanOrEqual(3);
expect(ROWS.filter((r) => r.refusedOn === 'sync').length).toBeGreaterThanOrEqual(5);
expect(ROWS.filter((r) => r.refusedOn === 'mode').length).toBeGreaterThanOrEqual(4);
expect(SYNC_KEY_REFUSALS.length).toBeGreaterThanOrEqual(12);
expect(ROWS.filter((r) => r.refusedOn === 'mode').length).toBeGreaterThanOrEqual(12);
// [#20586] The forced-local half of the `mode` rows, floored on its own so
// it cannot shrink behind the forced-replica half.
expect(ROWS.filter((r) => r.refusedOn === 'mode' && r.config.mode === 'local').length).toBeGreaterThanOrEqual(8);
expect(SYNC_KEY_REFUSALS.length).toBeGreaterThanOrEqual(20);
// [#20200] Exactly zero, not a floor: every key the constructor used to
// build and ignore is refused at construction now (see the header).
expect(ROWS.filter((r) => r.inert).length).toBe(0);
Expand Down Expand Up @@ -258,12 +281,12 @@ describe('turso config: the constructor, the spec contract and this mirror agree
});
});

// [#20200] The two sync refusals, and [#20437] the forced-replica refusal,
// are copies of the spec contract's texts in `../turso-driver.ts` (the spec
// keeps them module-local); this is the pin that holds each copy equal to the
// schema's issue, byte for byte.
// [#20200] The two sync refusals, [#20437] the forced-replica refusal and
// [#20586] the forced-local one are copies of the spec contract's texts in
// `../turso-driver.ts` (the spec keeps them module-local); this is the pin
// that holds each copy equal to the schema's issue, byte for byte.
describe.each(SYNC_KEY_REFUSALS)('$name', (row) => {
it("the constructor's message is the spec contract's, byte for byte (#20200, #20437)", () => {
it("the constructor's message is the spec contract's, byte for byte (#20200, #20437, #20586)", () => {
const spec = schemaVerdict(SpecTursoConfigSchema, row.config);
expect(spec.refusedOn).toBe(row.refusedOn);
expect(spec.message).toBeTypeOf('string');
Expand Down
18 changes: 18 additions & 0 deletions packages/drivers/driver-turso/src/spec/turso.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,24 @@ function tursoTransportIssues(cfg: TursoTransportKeys): TursoTransportIssue[] {
+ "`https://` Turso endpoint. For a plain local database, drop `mode: 'replica'`.",
}];
}
if (mode === 'local' && hasSyncUrl) {
// #20586. Only a FORCED local mode reaches here: with no `mode`, a
// `syncUrl` selects a replica. The url is a `file:` url or `:memory:`
// (every other one met a refusal above), so the url is fine; what the
// runtime would ignore is the MODE, because the driver syncs whenever
// `syncUrl` is set — so the issue sits on `mode`, as #20437's does.
// Unreachable through this mirror, which strips `mode` (see above); kept
// byte-identical to the spec contract's arm.
return [{
path: 'mode',
message:
"`mode: 'local'` makes this datasource a plain local database, but `syncUrl` names a remote to "
+ 'replicate from: the database would still be synced with that remote as an embedded replica, '
+ 'so the declared local mode would be ignored — the turso driver refuses this configuration '
+ 'when it starts. For an embedded replica, drop `mode` and keep `syncUrl` beside the local file: '
+ "`url: 'file:./data/replica.db'`. For a plain local database, drop `syncUrl` (and `sync`).",
}];
}
return [];
}

Expand Down
Loading
Loading