Skip to content

Commit 79fbad6

Browse files
committed
fix(spec,driver-turso)!: refuse a forced mode 'replica' with no syncUrl at authoring and at construction
A turso config that forces mode 'replica' on a file: url with no syncUrl (or an empty one) was accepted by both TursoConfigSchema copies and by the constructor, and ran as a plain local database that never synced. Both doors now refuse it with one message: the spec contract on mode, and new TursoDriver with VALIDATION_ERROR / 400 after the sync refusal, its text a module constant pinned byte-equal by the parity table. The parity row, the unrecognised-url WIDENED cell and the rider control flip to refused; a new constructor test file and spec tests pin the refusal, its order and its width. ADR-0087: a new D3 entry turso-config-forced-replica-without-sync-url-refused, registry regenerated. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N8TPEsoJxPsdSdNKGnNGEN
1 parent fc0db22 commit 79fbad6

11 files changed

Lines changed: 517 additions & 43 deletions
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/driver-turso': minor
4+
---
5+
6+
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
7+
8+
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.
9+
10+
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.
11+
12+
**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:
13+
14+
- **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;
15+
- **at construction**, `VALIDATION_ERROR` / 400 from `new TursoDriver()` (and `createTursoDriver()`), before any client or database is opened.
16+
17+
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.
18+
19+
### Migration: FROM → TO
20+
21+
| You wrote | Write instead |
22+
| --- | --- |
23+
| `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'` |
24+
| the same | a plain local database: drop `mode` (`url: 'file:./data/app.db'` alone) |
25+
26+
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.
27+
28+
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.
29+
30+
<!-- adr-0087: registered turso-config-forced-replica-without-sync-url-refused -->

‎packages/drivers/driver-turso/src/spec/turso-config-constructor-parity.test.ts‎

Lines changed: 29 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@
2828
* - [#20200] where the constructor refuses on `syncUrl` or `sync`, its message
2929
* is the spec contract's issue message, byte for byte: those two texts are
3030
* copies in `../turso-driver.ts`, and this is the pin that holds them equal.
31+
* [#20437] The same holds for a forced `mode: 'replica'` with no `syncUrl`,
32+
* refused on `mode` — the third copy. That row used to be accepted
33+
* everywhere, as a replica that never synced.
3134
*
3235
* ⚠️ The mirror declares no `mode`, so zod strips an authored one before its
3336
* refinement runs: rows that FORCE a mode are judged by the constructor and the
@@ -74,7 +77,9 @@ interface Row {
7477
/** The constructor's verdict on this config. */
7578
ctor: 'accept' | 'refuse';
7679
/** The key both schemas refuse on, or `undefined` when they accept. */
77-
refusedOn?: 'url' | 'syncUrl' | 'timeoutMs' | 'sync';
80+
refusedOn?: 'url' | 'syncUrl' | 'timeoutMs' | 'sync' | 'mode';
81+
/** How many issues the spec contract raises on a refused row; 1 unless stated. */
82+
issues?: number;
7883
/** The constructor accepts it and ignores a key: refused at authoring only. None today (#20200). */
7984
inert?: true;
8085
}
@@ -97,7 +102,7 @@ const ROWS: Row[] = [
97102
{ name: 'a url behind whitespace (the loaders trim it)', config: { url: ` ${FILE}` }, ctor: 'accept' },
98103
{ name: 'a remote url behind whitespace', config: { url: ` ${REMOTE}` }, ctor: 'accept' },
99104
{ name: 'an empty syncUrl (unset)', config: { url: REMOTE, syncUrl: '' }, ctor: 'accept' },
100-
{ name: "file: under a forced mode: 'replica'", config: { url: FILE, mode: 'replica' }, ctor: 'accept' },
105+
{ name: "file: + syncUrl under a forced mode: 'replica'", config: { url: FILE, mode: 'replica', syncUrl: REMOTE, sync: { onConnect: false } }, ctor: 'accept' },
101106
{ name: "file: + syncUrl under a forced mode: 'local'", config: { url: FILE, mode: 'local', syncUrl: REMOTE, sync: { onConnect: false } }, ctor: 'accept' },
102107
{ name: "libsql:// under a forced mode: 'remote'", config: { url: REMOTE, mode: 'remote' }, ctor: 'accept' },
103108
{ name: "file: under a forced mode: 'remote'", config: { url: FILE, mode: 'remote' }, ctor: 'accept' },
@@ -147,13 +152,24 @@ const ROWS: Row[] = [
147152
{ name: 'sync with no syncUrl', config: { url: FILE, sync: { intervalSeconds: 60 } }, ctor: 'refuse', refusedOn: 'sync' },
148153
{ name: 'sync with no syncUrl on a remote url', config: { url: REMOTE, sync: { intervalSeconds: 60 } }, ctor: 'refuse', refusedOn: 'sync' },
149154
{ name: "sync with no syncUrl under a forced mode: 'remote'", config: { url: REMOTE, mode: 'remote', sync: { onConnect: true } }, ctor: 'refuse', refusedOn: 'sync' },
150-
{ name: "sync with no syncUrl under a forced mode: 'replica'", config: { url: FILE, mode: 'replica', sync: { intervalSeconds: 60 } }, ctor: 'refuse', refusedOn: 'sync' },
155+
// The spec contract raises BOTH issues here (`sync`, then `mode`); the
156+
// constructor throws one, the `sync` refusal, which is the spec's first.
157+
{ name: "sync with no syncUrl under a forced mode: 'replica'", config: { url: FILE, mode: 'replica', sync: { intervalSeconds: 60 } }, ctor: 'refuse', refusedOn: 'sync', issues: 2 },
151158
{ name: 'sync beside an empty syncUrl (unset)', config: { url: FILE, syncUrl: '', sync: { intervalSeconds: 60 } }, ctor: 'refuse', refusedOn: 'sync' },
159+
160+
// ── a forced replica with no remote to replicate from: refused on `mode` (#20437) ──
161+
{ name: "file: under a forced mode: 'replica'", config: { url: FILE, mode: 'replica' }, ctor: 'refuse', refusedOn: 'mode' },
162+
{ name: "an uppercase FILE: url under a forced mode: 'replica'", config: { url: `FILE:${DIR}/upper-replica.db`, mode: 'replica' }, ctor: 'refuse', refusedOn: 'mode' },
163+
{ name: "file: + an empty syncUrl (unset) under a forced mode: 'replica'", config: { url: FILE, mode: 'replica', syncUrl: '' }, ctor: 'refuse', refusedOn: 'mode' },
164+
{ name: "file: + timeoutMs under a forced mode: 'replica'", config: { url: FILE, mode: 'replica', timeoutMs: 5000 }, ctor: 'refuse', refusedOn: 'mode' },
152165
];
153166

154-
/** The rows the constructor refuses on a sync key: its message is a copy of the spec's (#20200). */
167+
/**
168+
* The rows the constructor refuses on a sync key, or on a forced replica with
169+
* no `syncUrl`: its message is a copy of the spec's (#20200, #20437).
170+
*/
155171
const SYNC_KEY_REFUSALS = ROWS.filter(
156-
(r) => r.ctor === 'refuse' && (r.refusedOn === 'syncUrl' || r.refusedOn === 'sync'),
172+
(r) => r.ctor === 'refuse' && (r.refusedOn === 'syncUrl' || r.refusedOn === 'sync' || r.refusedOn === 'mode'),
157173
);
158174

159175
/**
@@ -209,7 +225,8 @@ describe('turso config: the constructor, the spec contract and this mirror agree
209225
expect(ROWS.filter((r) => r.refusedOn === 'timeoutMs').length).toBeGreaterThanOrEqual(3);
210226
expect(ROWS.filter((r) => r.refusedOn === 'syncUrl').length).toBeGreaterThanOrEqual(3);
211227
expect(ROWS.filter((r) => r.refusedOn === 'sync').length).toBeGreaterThanOrEqual(5);
212-
expect(SYNC_KEY_REFUSALS.length).toBeGreaterThanOrEqual(8);
228+
expect(ROWS.filter((r) => r.refusedOn === 'mode').length).toBeGreaterThanOrEqual(4);
229+
expect(SYNC_KEY_REFUSALS.length).toBeGreaterThanOrEqual(12);
213230
// [#20200] Exactly zero, not a floor: every key the constructor used to
214231
// build and ignore is refused at construction now (see the header).
215232
expect(ROWS.filter((r) => r.inert).length).toBe(0);
@@ -229,7 +246,7 @@ describe('turso config: the constructor, the spec contract and this mirror agree
229246
const verdict = schemaVerdict(SpecTursoConfigSchema, row.config);
230247
expect(verdict.refusedOn, verdict.message).toBe(row.refusedOn);
231248
if (row.refusedOn) {
232-
expect(verdict.count, verdict.message).toBe(1);
249+
expect(verdict.count, verdict.message).toBe(row.issues ?? 1);
233250
expect(verdict.code).toBe('custom');
234251
}
235252
});
@@ -241,11 +258,12 @@ describe('turso config: the constructor, the spec contract and this mirror agree
241258
});
242259
});
243260

244-
// [#20200] The two sync refusals are copies of the spec contract's texts in
245-
// `../turso-driver.ts` (the spec keeps them module-local); this is the pin
246-
// that holds each copy equal to the schema's issue, byte for byte.
261+
// [#20200] The two sync refusals, and [#20437] the forced-replica refusal,
262+
// are copies of the spec contract's texts in `../turso-driver.ts` (the spec
263+
// keeps them module-local); this is the pin that holds each copy equal to the
264+
// schema's issue, byte for byte.
247265
describe.each(SYNC_KEY_REFUSALS)('$name', (row) => {
248-
it("the constructor's message is the spec contract's, byte for byte (#20200)", () => {
266+
it("the constructor's message is the spec contract's, byte for byte (#20200, #20437)", () => {
249267
const spec = schemaVerdict(SpecTursoConfigSchema, row.config);
250268
expect(spec.refusedOn).toBe(row.refusedOn);
251269
expect(spec.message).toBeTypeOf('string');

‎packages/drivers/driver-turso/src/spec/turso.zod.ts‎

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,7 @@ interface TursoTransportKeys {
155155

156156
/** One refusal: the key it sits on and its message. */
157157
interface TursoTransportIssue {
158-
path: 'url' | 'syncUrl' | 'timeoutMs';
158+
path: 'url' | 'syncUrl' | 'timeoutMs' | 'mode';
159159
message: string;
160160
}
161161

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

0 commit comments

Comments
 (0)