You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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.
{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'},
107
111
{name: "libsql:// under a forced mode: 'remote'",config: {url: REMOTE,mode: 'remote'},ctor: 'accept'},
108
112
{name: "file: under a forced mode: 'remote'",config: {url: FILE,mode: 'remote'},ctor: 'accept'},
109
113
{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[] = [
119
123
{name: "libsql:// + syncUrl under a forced mode: 'replica'",config: {url: REMOTE,mode: 'replica',syncUrl: REMOTE},ctor: 'refuse',refusedOn: 'url'},
120
124
{name: "libsql:// under a forced mode: 'local'",config: {url: REMOTE,mode: 'local'},ctor: 'refuse',refusedOn: 'url'},
121
125
{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'},
122
128
123
129
// ── a url that is none of file:, :memory: or remote, in a local or replica mode ──
124
130
{name: 'a bare relative path',config: {url: './data/app.db'},ctor: 'refuse',refusedOn: 'url'},
@@ -131,6 +137,8 @@ const ROWS: Row[] = [
131
137
{name: 'a whitespace-only url',config: {url: ' '},ctor: 'refuse',refusedOn: 'url'},
132
138
{name: 'a bare path beside syncUrl',config: {url: './data/replica.db',syncUrl: REMOTE},ctor: 'refuse',refusedOn: 'url'},
133
139
{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'},
134
142
{name: "a bare path under a forced mode: 'replica'",config: {url: './data/replica.db',mode: 'replica'},ctor: 'refuse',refusedOn: 'url'},
135
143
136
144
// ── a replica on an in-memory url ───────────────────────────────────────
@@ -162,11 +170,23 @@ const ROWS: Row[] = [
162
170
{name: "an uppercase FILE: url under a forced mode: 'replica'",config: {url: `FILE:${DIR}/upper-replica.db`,mode: 'replica'},ctor: 'refuse',refusedOn: 'mode'},
163
171
{name: "file: + an empty syncUrl (unset) under a forced mode: 'replica'",config: {url: FILE,mode: 'replica',syncUrl: ''},ctor: 'refuse',refusedOn: 'mode'},
164
172
{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'},
165
184
];
166
185
167
186
/**
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
0 commit comments