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/20437-turso-forced-replica-needs-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: 'replica'` with no `syncUrl` is refused where it is written and when the driver is built, instead of running as a plain local database that never syncs

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.

An embedded replica is a local file kept in sync with the remote named in `syncUrl`. A config that forced `mode: 'replica'` on a `file:` url with no `syncUrl` (or an empty one) was accepted by `@objectstack/spec`'s `TursoConfigSchema`, by the published mirror in `@objectstack/driver-turso`, and by `new TursoDriver()`. Measured on the built driver before this change, with and without `sync`: it constructed with `transportMode` `'replica'`, `isSyncEnabled()` answered `false`, no sync interval started, the sync call did nothing, and every read and write went to the local file. A datasource declared as a replica ran as a plain local database that never replicated, with no error and no warning.

**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. The sibling refusals keep their order. A forced replica on a remote url, an in-memory url or a bare path still meets its `url` refusal first. One with `sync` and no `syncUrl` still meets the `sync` refusal first; the schema now reports the `mode` issue beside it. 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: 'replica'` (no `syncUrl`, or `syncUrl: ''`) | an embedded replica: keep the `file:` url and name the remote, `syncUrl: 'libsql://my-db.turso.io'` |
| the same | a plain local database: drop `mode` (`url: 'file:./data/app.db'` alone) |

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 ran as a local database. 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` (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-replica-without-sync-url-refused -->
12 changes: 12 additions & 0 deletions packages/drivers/driver-turso/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,18 @@ 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
that nothing would honour, each with the message `@objectstack/spec`'s
`TursoConfigSchema` gives at authoring:

- `syncUrl` under a forced `mode: 'remote'`, where the remote client never
receives it. For a remote database, drop `syncUrl` (and `sync`);
- `sync` with no `syncUrl` (or an empty one), in any mode, where nothing reads
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.

You can also force a specific mode:

```typescript
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@
* - [#20200] where the constructor refuses on `syncUrl` or `sync`, its message
* is the spec contract's issue message, byte for byte: those two texts are
* 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.
*
* ⚠️ 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 @@ -74,7 +77,9 @@ interface Row {
/** The constructor's verdict on this config. */
ctor: 'accept' | 'refuse';
/** The key both schemas refuse on, or `undefined` when they accept. */
refusedOn?: 'url' | 'syncUrl' | 'timeoutMs' | 'sync';
refusedOn?: 'url' | 'syncUrl' | 'timeoutMs' | 'sync' | 'mode';
/** How many issues the spec contract raises on a refused row; 1 unless stated. */
issues?: number;
/** The constructor accepts it and ignores a key: refused at authoring only. None today (#20200). */
inert?: true;
}
Expand All @@ -97,7 +102,7 @@ const ROWS: Row[] = [
{ name: 'a url behind whitespace (the loaders trim it)', config: { url: ` ${FILE}` }, ctor: 'accept' },
{ name: 'a remote url behind whitespace', config: { url: ` ${REMOTE}` }, ctor: 'accept' },
{ name: 'an empty syncUrl (unset)', config: { url: REMOTE, syncUrl: '' }, ctor: 'accept' },
{ name: "file: under a forced mode: 'replica'", config: { url: FILE, mode: 'replica' }, 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: "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' },
Expand Down Expand Up @@ -147,13 +152,24 @@ const ROWS: Row[] = [
{ name: 'sync with no syncUrl', config: { url: FILE, sync: { intervalSeconds: 60 } }, ctor: 'refuse', refusedOn: 'sync' },
{ name: 'sync with no syncUrl on a remote url', config: { url: REMOTE, sync: { intervalSeconds: 60 } }, ctor: 'refuse', refusedOn: 'sync' },
{ name: "sync with no syncUrl under a forced mode: 'remote'", config: { url: REMOTE, mode: 'remote', sync: { onConnect: true } }, ctor: 'refuse', refusedOn: 'sync' },
{ name: "sync with no syncUrl under a forced mode: 'replica'", config: { url: FILE, mode: 'replica', sync: { intervalSeconds: 60 } }, ctor: 'refuse', refusedOn: 'sync' },
// The spec contract raises BOTH issues here (`sync`, then `mode`); the
// constructor throws one, the `sync` refusal, which is the spec's first.
{ name: "sync with no syncUrl under a forced mode: 'replica'", config: { url: FILE, mode: 'replica', sync: { intervalSeconds: 60 } }, ctor: 'refuse', refusedOn: 'sync', issues: 2 },
{ name: 'sync beside an empty syncUrl (unset)', config: { url: FILE, syncUrl: '', sync: { intervalSeconds: 60 } }, ctor: 'refuse', refusedOn: 'sync' },

// ── a forced replica with no remote to replicate from: refused on `mode` (#20437) ──
{ name: "file: under a forced mode: 'replica'", config: { url: FILE, mode: 'replica' }, ctor: 'refuse', refusedOn: 'mode' },
{ 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' },
];

/** The rows the constructor refuses on a sync key: its message is a copy of the spec's (#20200). */
/**
* 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).
*/
const SYNC_KEY_REFUSALS = ROWS.filter(
(r) => r.ctor === 'refuse' && (r.refusedOn === 'syncUrl' || r.refusedOn === 'sync'),
(r) => r.ctor === 'refuse' && (r.refusedOn === 'syncUrl' || r.refusedOn === 'sync' || r.refusedOn === 'mode'),
);

/**
Expand Down Expand Up @@ -209,7 +225,8 @@ 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(SYNC_KEY_REFUSALS.length).toBeGreaterThanOrEqual(8);
expect(ROWS.filter((r) => r.refusedOn === 'mode').length).toBeGreaterThanOrEqual(4);
expect(SYNC_KEY_REFUSALS.length).toBeGreaterThanOrEqual(12);
// [#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 All @@ -229,7 +246,7 @@ describe('turso config: the constructor, the spec contract and this mirror agree
const verdict = schemaVerdict(SpecTursoConfigSchema, row.config);
expect(verdict.refusedOn, verdict.message).toBe(row.refusedOn);
if (row.refusedOn) {
expect(verdict.count, verdict.message).toBe(1);
expect(verdict.count, verdict.message).toBe(row.issues ?? 1);
expect(verdict.code).toBe('custom');
}
});
Expand All @@ -241,11 +258,12 @@ describe('turso config: the constructor, the spec contract and this mirror agree
});
});

// [#20200] The two sync refusals 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, 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.
describe.each(SYNC_KEY_REFUSALS)('$name', (row) => {
it("the constructor's message is the spec contract's, byte for byte (#20200)", () => {
it("the constructor's message is the spec contract's, byte for byte (#20200, #20437)", () => {
const spec = schemaVerdict(SpecTursoConfigSchema, row.config);
expect(spec.refusedOn).toBe(row.refusedOn);
expect(spec.message).toBeTypeOf('string');
Expand Down
20 changes: 19 additions & 1 deletion packages/drivers/driver-turso/src/spec/turso.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@ interface TursoTransportKeys {

/** One refusal: the key it sits on and its message. */
interface TursoTransportIssue {
path: 'url' | 'syncUrl' | 'timeoutMs';
path: 'url' | 'syncUrl' | 'timeoutMs' | 'mode';
message: string;
}

Expand Down Expand Up @@ -239,6 +239,24 @@ function tursoTransportIssues(cfg: TursoTransportKeys): TursoTransportIssue[] {
+ `${drop} for a plain in-memory local database.`,
}];
}
if (mode === 'replica' && !hasSyncUrl) {
// #20437. Only a FORCED replica reaches here: with no `mode`, a replica is
// selected by `syncUrl` alone. The url is a `file:` url (every other one
// met a refusal above), so the url is fine and the MODE is what cannot be
// honoured — the issue sits on `mode`, as the `sync` refusal sits on `sync`.
// Unreachable through this mirror, which strips `mode` (see above); kept
// byte-identical to the spec contract's arm.
return [{
path: 'mode',
message:
"`mode: 'replica'` makes this datasource an embedded replica, a local file kept in sync with "
+ 'the remote named in `syncUrl`, but no `syncUrl` is set: nothing would ever sync, so it would '
+ 'run as a plain local database that never replicates — the turso driver refuses this '
+ 'configuration when it starts. For an embedded replica, name the remote in `syncUrl` beside '
+ "the local file: `url: 'file:./data/replica.db'` with `syncUrl` set to the `libsql://` or "
+ "`https://` Turso endpoint. For a plain local database, drop `mode: 'replica'`.",
}];
}
return [];
}

Expand Down
Loading
Loading