Skip to content

Commit e739a50

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-20602-export-year-pad
2 parents 9415820 + 05cb2bc commit e739a50

11 files changed

Lines changed: 575 additions & 30 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: '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
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+
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.
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. 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.
18+
19+
### Migration: FROM → TO
20+
21+
| You wrote | Write instead |
22+
| --- | --- |
23+
| `url: 'file:./data/replica.db', mode: 'local', syncUrl: 'libsql://my-db.turso.io'` | an embedded replica: drop `mode` (`url` and `syncUrl` select the replica) |
24+
| the same | a plain local database: drop `syncUrl` (and `sync`), keeping `url: 'file:./data/app.db'` with or without `mode: 'local'` |
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 synced with the remote under a `local` label. 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` 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.
29+
30+
<!-- adr-0087: registered turso-config-forced-local-with-sync-url-refused -->

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

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -223,7 +223,7 @@ no embedded replica for a remote url anyway. For a remote database, drop
223223
no local engine, so its url is not judged here: `@libsql/client` refuses a
224224
url it cannot open when the driver connects.
225225

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

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

238242
You can also force a specific mode:
239243

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

Lines changed: 34 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,10 @@
3030
* copies in `../turso-driver.ts`, and this is the pin that holds them equal.
3131
* [#20437] The same holds for a forced `mode: 'replica'` with no `syncUrl`,
3232
* refused on `mode` — the third copy. That row used to be accepted
33-
* everywhere, as a replica that never synced.
33+
* everywhere, as a replica that never synced. [#20586] And for a forced
34+
* `mode: 'local'` beside a `syncUrl`, refused on `mode` too — the fourth
35+
* copy. That row used to be accepted everywhere as well, as a "local"
36+
* database the driver synced with the remote anyway.
3437
*
3538
* ⚠️ The mirror declares no `mode`, so zod strips an authored one before its
3639
* refinement runs: rows that FORCE a mode are judged by the constructor and the
@@ -103,7 +106,8 @@ const ROWS: Row[] = [
103106
{ name: 'a remote url behind whitespace', config: { url: ` ${REMOTE}` }, ctor: 'accept' },
104107
{ name: 'an empty syncUrl (unset)', config: { url: REMOTE, syncUrl: '' }, ctor: 'accept' },
105108
{ name: "file: + syncUrl under a forced mode: 'replica'", config: { url: FILE, mode: 'replica', syncUrl: REMOTE, sync: { onConnect: false } }, ctor: 'accept' },
106-
{ name: "file: + syncUrl under a forced mode: 'local'", config: { url: FILE, mode: 'local', syncUrl: REMOTE, sync: { onConnect: false } }, ctor: 'accept' },
109+
{ name: "file: under a forced mode: 'local'", config: { url: FILE, mode: 'local' }, ctor: 'accept' },
110+
{ name: "file: + an empty syncUrl (unset) under a forced mode: 'local'", config: { url: FILE, mode: 'local', syncUrl: '' }, ctor: 'accept' },
107111
{ name: "libsql:// under a forced mode: 'remote'", config: { url: REMOTE, mode: 'remote' }, ctor: 'accept' },
108112
{ name: "file: under a forced mode: 'remote'", config: { url: FILE, mode: 'remote' }, ctor: 'accept' },
109113
{ name: "a bare path under a forced mode: 'remote' (the client refuses it at connect)", config: { url: './data/app.db', mode: 'remote' }, ctor: 'accept' },
@@ -119,6 +123,8 @@ const ROWS: Row[] = [
119123
{ name: "libsql:// + syncUrl under a forced mode: 'replica'", config: { url: REMOTE, mode: 'replica', syncUrl: REMOTE }, ctor: 'refuse', refusedOn: 'url' },
120124
{ name: "libsql:// under a forced mode: 'local'", config: { url: REMOTE, mode: 'local' }, ctor: 'refuse', refusedOn: 'url' },
121125
{ name: "https:// under a forced mode: 'local'", config: { url: 'https://db.example.turso.io', mode: 'local' }, ctor: 'refuse', refusedOn: 'url' },
126+
// [#20586] ORDER: a remote url keeps its `url` refusal ahead of the forced-local `syncUrl` one.
127+
{ name: "libsql:// + syncUrl under a forced mode: 'local'", config: { url: REMOTE, mode: 'local', syncUrl: REMOTE }, ctor: 'refuse', refusedOn: 'url' },
122128

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

136144
// ── a replica on an in-memory url ───────────────────────────────────────
@@ -162,11 +170,23 @@ const ROWS: Row[] = [
162170
{ name: "an uppercase FILE: url under a forced mode: 'replica'", config: { url: `FILE:${DIR}/upper-replica.db`, mode: 'replica' }, ctor: 'refuse', refusedOn: 'mode' },
163171
{ name: "file: + an empty syncUrl (unset) under a forced mode: 'replica'", config: { url: FILE, mode: 'replica', syncUrl: '' }, ctor: 'refuse', refusedOn: 'mode' },
164172
{ name: "file: + timeoutMs under a forced mode: 'replica'", config: { url: FILE, mode: 'replica', timeoutMs: 5000 }, ctor: 'refuse', refusedOn: 'mode' },
173+
174+
// ── a forced local mode beside a remote to replicate from: refused on `mode` (#20586) ──
175+
// The first row was pinned `accept` until #20586: the driver labelled it local and synced it anyway.
176+
{ name: "file: + syncUrl under a forced mode: 'local'", config: { url: FILE, mode: 'local', syncUrl: REMOTE, sync: { onConnect: false } }, ctor: 'refuse', refusedOn: 'mode' },
177+
{ name: "file: + syncUrl, no sync, under a forced mode: 'local'", config: { url: FILE, mode: 'local', syncUrl: REMOTE }, ctor: 'refuse', refusedOn: 'mode' },
178+
{ 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' },
179+
{ name: "a file: url behind whitespace + syncUrl under a forced mode: 'local'", config: { url: ` ${FILE}`, mode: 'local', syncUrl: REMOTE }, ctor: 'refuse', refusedOn: 'mode' },
180+
{ name: "file: + syncUrl + timeoutMs under a forced mode: 'local'", config: { url: FILE, mode: 'local', syncUrl: REMOTE, timeoutMs: 5000 }, ctor: 'refuse', refusedOn: 'mode' },
181+
{ 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' },
182+
{ name: ":memory: + syncUrl under a forced mode: 'local'", config: { url: ':memory:', mode: 'local', syncUrl: REMOTE }, ctor: 'refuse', refusedOn: 'mode' },
183+
{ name: "file::memory: + syncUrl under a forced mode: 'local'", config: { url: 'file::memory:', mode: 'local', syncUrl: REMOTE }, ctor: 'refuse', refusedOn: 'mode' },
165184
];
166185

167186
/**
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).
187+
* The rows the constructor refuses on a sync key, on a forced replica with no
188+
* `syncUrl`, or on a forced local mode beside a `syncUrl`: its message is a
189+
* copy of the spec's (#20200, #20437, #20586).
170190
*/
171191
const SYNC_KEY_REFUSALS = ROWS.filter(
172192
(r) => r.ctor === 'refuse' && (r.refusedOn === 'syncUrl' || r.refusedOn === 'sync' || r.refusedOn === 'mode'),
@@ -225,8 +245,11 @@ describe('turso config: the constructor, the spec contract and this mirror agree
225245
expect(ROWS.filter((r) => r.refusedOn === 'timeoutMs').length).toBeGreaterThanOrEqual(3);
226246
expect(ROWS.filter((r) => r.refusedOn === 'syncUrl').length).toBeGreaterThanOrEqual(3);
227247
expect(ROWS.filter((r) => r.refusedOn === 'sync').length).toBeGreaterThanOrEqual(5);
228-
expect(ROWS.filter((r) => r.refusedOn === 'mode').length).toBeGreaterThanOrEqual(4);
229-
expect(SYNC_KEY_REFUSALS.length).toBeGreaterThanOrEqual(12);
248+
expect(ROWS.filter((r) => r.refusedOn === 'mode').length).toBeGreaterThanOrEqual(12);
249+
// [#20586] The forced-local half of the `mode` rows, floored on its own so
250+
// it cannot shrink behind the forced-replica half.
251+
expect(ROWS.filter((r) => r.refusedOn === 'mode' && r.config.mode === 'local').length).toBeGreaterThanOrEqual(8);
252+
expect(SYNC_KEY_REFUSALS.length).toBeGreaterThanOrEqual(20);
230253
// [#20200] Exactly zero, not a floor: every key the constructor used to
231254
// build and ignore is refused at construction now (see the header).
232255
expect(ROWS.filter((r) => r.inert).length).toBe(0);
@@ -258,12 +281,12 @@ describe('turso config: the constructor, the spec contract and this mirror agree
258281
});
259282
});
260283

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.
284+
// [#20200] The two sync refusals, [#20437] the forced-replica refusal and
285+
// [#20586] the forced-local one are copies of the spec contract's texts in
286+
// `../turso-driver.ts` (the spec keeps them module-local); this is the pin
287+
// that holds each copy equal to the schema's issue, byte for byte.
265288
describe.each(SYNC_KEY_REFUSALS)('$name', (row) => {
266-
it("the constructor's message is the spec contract's, byte for byte (#20200, #20437)", () => {
289+
it("the constructor's message is the spec contract's, byte for byte (#20200, #20437, #20586)", () => {
267290
const spec = schemaVerdict(SpecTursoConfigSchema, row.config);
268291
expect(spec.refusedOn).toBe(row.refusedOn);
269292
expect(spec.message).toBeTypeOf('string');

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

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,24 @@ function tursoTransportIssues(cfg: TursoTransportKeys): TursoTransportIssue[] {
257257
+ "`https://` Turso endpoint. For a plain local database, drop `mode: 'replica'`.",
258258
}];
259259
}
260+
if (mode === 'local' && hasSyncUrl) {
261+
// #20586. Only a FORCED local mode reaches here: with no `mode`, a
262+
// `syncUrl` selects a replica. The url is a `file:` url or `:memory:`
263+
// (every other one met a refusal above), so the url is fine; what the
264+
// runtime would ignore is the MODE, because the driver syncs whenever
265+
// `syncUrl` is set — so the issue sits on `mode`, as #20437's does.
266+
// Unreachable through this mirror, which strips `mode` (see above); kept
267+
// byte-identical to the spec contract's arm.
268+
return [{
269+
path: 'mode',
270+
message:
271+
"`mode: 'local'` makes this datasource a plain local database, but `syncUrl` names a remote to "
272+
+ 'replicate from: the database would still be synced with that remote as an embedded replica, '
273+
+ 'so the declared local mode would be ignored — the turso driver refuses this configuration '
274+
+ 'when it starts. For an embedded replica, drop `mode` and keep `syncUrl` beside the local file: '
275+
+ "`url: 'file:./data/replica.db'`. For a plain local database, drop `syncUrl` (and `sync`).",
276+
}];
277+
}
260278
return [];
261279
}
262280

0 commit comments

Comments
 (0)