Skip to content

Commit 05cb2bc

Browse files
fix(spec,driver-turso)!: refuse a forced mode local beside syncUrl at authoring and at construction (#20586) (#20669)
Fixes #20586 Clause-②: yes (narrowing) The `Clause-②` line above is the claim's (comment 5891827128), copied as it stands. The changeset carries the same value. Session `session_01DEvba2nBuD4tWzfq8r8NFY` (PM dispatch, `domain:engine` seat 1, mode:subagent), branch `claude/issue-20586-forced-local-refuses-sync-url`. The container restarted mid-run. The branch was fast-forwarded to `origin/main` `f4ce10c89` (BASE) before the first edit, and later got a true merge of `origin/main` at `6bff748bb`. **Every final reading below was taken at head `cfe05ec40`** unless it says otherwise. ## What changes A turso config that forces `mode: 'local'` on a `file:` url (or `:memory:`) beside a non-empty `syncUrl` is now refused at both doors, with one message. Triage 5884612522 directed this: "A forced `mode: 'local'` beside `syncUrl` (or `sync`) is refused at both schema copies and at the constructor, naming the conflict." - **Authoring.** `tursoTransportIssues` in `packages/spec/src/data/driver/turso.zod.ts` gains a forced-local arm, placed after #20437's forced-replica arm. It returns one `custom` issue on **`mode`**, the key the runtime would ignore, as #20437's does. It reaches `DatasourceSchema` (as `config.mode`), `validateDriverConfig`, `defineStack` / `os validate`, and a save or test connection through the datasource admin service. - **Construction.** `new TursoDriver()` refuses the same config with `VALIDATION_ERROR` / 400, before `super()`, as the last of the sync-key refusals. The message is a module constant, `LOCAL_MODE_WITH_SYNC_URL_REFUSAL`, next to #20437's `REPLICA_MODE_WITHOUT_SYNC_URL_REFUSAL`. It is thrown through the same `refuseIgnoredSyncKey` helper, and the parity table pins it byte-equal to the schema's issue. - **The driver mirror** (`packages/drivers/driver-turso/src/spec/turso.zod.ts`) carries the same arm byte for byte. The mirror declares no `mode` and strips an authored one, so the arm is unreachable through it. It stays for copy parity, like the mirror's other forced-mode branches (see Deviations). - **"(or `sync`)".** `sync` with no `syncUrl` was already refused in every mode (#20200). `sync` beside a `syncUrl` under a forced local mode is refused by this new arm. So every `sync` shape under a forced local mode is covered, with no extra arm. The message, the same text at both doors: > `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`). It names both ways out. It echoes no url and no `syncUrl`, and carries no tracker id. ## H1: the premise, measured before the change (held) At BASE `f4ce10c89`, a temporary probe ran on the driver source (deleted after one run, never committed). The config was a `file:` url, `syncUrl`, `sync: { intervalSeconds: 1 }`, and a client that counts `sync()` calls: | config | transportMode | syncs after connect | isSyncEnabled() | interval | syncs after 1.3 s | | --- | --- | --- | --- | --- | --- | | forced `mode: 'local'` + `syncUrl` | `local` | 1 | true | started | 2 | | `syncUrl`, no `mode` (the replica control) | `replica` | 1 | true | started | 2 | | forced `mode: 'local'`, no `syncUrl` | `local` | 0 | false | none | 0 | So a forced local mode beside `syncUrl` ran exactly as a replica, and only the label said `local`. The pins holding today's answer passed at BASE: - the parity row "file: + syncUrl under a forced mode: 'local'" read constructor accept and spec accept (parity file 177 passed / 22 skipped); - the spec test's accept fixture `{ url: 'file:./data/app.db', mode: 'local', syncUrl: … }`; - the #20200 file's control "sync beside syncUrl under a forced mode: 'local' stays accepted". `isSyncEnabled()` is `!!this.tursoConfig.syncUrl && this.libsqlClient !== null`, as H1 states. ## H2: #20437 is the template (held), and what differs in this direction It is mirrored arm for arm: one spec arm on `mode`, a byte-identical mirror arm, a module constant, one constructor check, a new refusal test file, a parity flip and a new D3 entry. What differs: - The ignored key is `mode`, as in #20437, but the cause is the opposite. A `syncUrl` is present, so the conflict is between two declared keys, not a missing one. The message therefore offers "drop `mode`" (replica) or "drop `syncUrl` (and `sync`)" (local). - The arm is reachable on an in-memory url too. A local mode accepts `:memory:` (`localEngineDefect` passes it), so `:memory:` and `file::memory:` beside `syncUrl` under a forced local mode meet this refusal. The replica way out points at a file url, so it is correct there as well. - No config meets two issues here. #20437 has one row where the schema raises `sync` and `mode` together. This arm needs a non-empty `syncUrl` and the `sync` refusal needs none, so they are exclusive, and every row here raises exactly one issue. ## H3: ADR-0087 disposition — a new D3 entry, `registered` Neither existing turso entry's `surface` names this shape. `turso-config-transport-mismatch-refused` lists the url, in-memory, WebSocket and remote-`syncUrl` combinations. `turso-config-forced-replica-without-sync-url-refused` is the opposite shape. The gate accepts `registered` only with an id that is new in this diff, and `already-registered` only for an id that already covers the refusal. So a new entry lands, following #20437's precedent: `packages/spec/src/migrations/entries/semantic/18.turso-config-forced-local-with-sync-url-refused.ts`, id `turso-config-forced-local-with-sync-url-refused`. - `src/migrations/registry.ts` was regenerated by `gen:migration-registry`, never by hand: +50 / -0, one entry. It reads `320 semantic, 236 retired-key, 207 retired-def`. - The changeset carries the disposition marker `registered turso-config-forced-local-with-sync-url-refused`. - `check-adr-0087-registration` reads it as `[BREAKING+bang+clause-②-narrowing] registered turso-config-forced-local-with-sync-url-refused (new here: …)`. - `check:generated` reads "All 15 generated artifacts are up to date", including `check:spec-changes` and `check:upgrade-guide`. ## H4: refusal order (held; the new arm is last) - **Spec.** The arm sits after #20437's forced-replica arm, inside the non-remote branch. Every url-shape refusal returns first, and they are unchanged. - **Constructor.** The check sits after the forced-replica check, which is last among the existing refusals. - **Exclusivity.** Every other refusal is exclusive of this one except the url refusals, which both doors raise first. The remote refusals need remote mode. The `sync` refusal needs no `syncUrl`. The replica refusal needs a replica mode. - **Pins.** ORDER rows cover a remote url, a bare path, `sync` with no `syncUrl`, and #20437's forced replica with no `syncUrl`. Each keeps its own message. ## H5: producer census (before any edit, at BASE `f4ce10c89`, repo `objectstack-ai/objectstack`) | query | hits | | --- | --- | | `git grep -E "mode:\s*['\"]local['\"]\|\"mode\"\s*:\s*\"local\""` (whole tree) | 49 in 11 files: 11 in two CHANGELOGs, 38 in `packages/drivers/driver-turso` and `packages/spec` (source, tests, README). Of those, 3 author the shape beside a `syncUrl`, all tests: the parity row, the spec accept fixture and the #20200 control, each flipped here. The other `mode: 'local'` spellings carry no `syncUrl` or are describe labels | | `syncUrl` / `sync_url` / `SYNC_URL`, case-insensitive, per tree (turso control count in brackets) | `examples/` 0 [2], `packages/create-objectstack` 0 [0], `skills/` 0 [6], hand-written `content/docs` 0 [109], `apps/` 0 [0] | | env names read in `packages/**/src` (non-test) matching `TURSO_*` / `OS_DATABASE_*` / `OS_TURSO_*` | `OS_DATABASE_URL`, `OS_DATABASE_DRIVER`, `OS_DATABASE_AUTH_TOKEN`, `OS_DATABASE_POOL_MAX`, `OS_DATABASE_SQLITE_JOURNAL_MODE`, `TURSO_DATABASE_URL`, `TURSO_AUTH_TOKEN`, `TURSO_TOKEN` (plus code constants). None maps to `mode` or `syncUrl` | | who sets a turso `mode` | only `buildTursoDriverConfig`'s `mode` reader (`packages/services/service-datasource/src/turso-driver-config.ts:205`), from an authored `datasource.config.mode`. Its two callers are `packages/runtime/src/turso-driver-factory.ts:288` and `packages/services/service-datasource/src/default-datasource-driver-factory.ts:1337`. No other non-test `new TursoDriver` / `createTursoDriver` call sets `mode` | No shipped in-repo producer authors the shape, and no deployment default sets it, so the `needs_decision` branch does not trigger. **`objectstack-ai/cloud`: NOT MEASURED.** Attaching it read-only was refused by the session's permission classifier. The seat or the maintainer should census cloud before this lands. ## Tests (at `7ee1acb58`, the last code commit; the merge after it touched no turso or spec path) | suite | result | | --- | --- | | `@objectstack/driver-turso` vitest, whole package | 80 files · 2175 passed · 33 skipped · exit 0 | | `@objectstack/driver-turso` typecheck (`tsc --noEmit`) | exit 0. `tsc --listFilesOnly` lists all 3 touched test files | | `@objectstack/spec` vitest `--project local`, 3 shards | 575 files · 16946 passed · 1 todo (6106 + 5231 + 5609), exit 0 each | | `@objectstack/spec` typecheck (tsc + scripts + `check:test-typecheck`) | exit 0 | | `@objectstack/spec` `check:generated` (after the spec build) | "All 15 generated artifacts are up to date" | The 33 skips are the parity table's forced-mode rows for the mirror, which strips `mode`. There were 22 before; this PR adds 11: 8 `mode` rows, 2 ORDER `url` rows and 2 accept controls, less the 1 row that moved. - **`spec/turso-config-constructor-parity.test.ts`.** The row "file: + syncUrl under a forced mode: 'local'" flips from `accept` to `refuse`, `refusedOn: 'mode'`. Seven more `mode` rows are added: no `sync`, an uppercase `FILE:` url, a url behind whitespace, `timeoutMs`, a `wss://` `syncUrl`, `:memory:` and `file::memory:`. Two ORDER rows keep their `url` refusal (a `libsql://` url and a bare path, each beside `syncUrl` under a forced local mode). Two accept controls are added: a forced local mode alone, and one beside an empty `syncUrl`. `SYNC_KEY_REFUSALS` takes the new `mode` rows, so each of the 8 asserts the constructor's message equals the spec issue's. New floors: `mode` at least 12, the forced-local `mode` rows at least 8, sync-key refusals at least 20. The unforced `file:` + `syncUrl` replica row ("a replica: file: + syncUrl") is the unchanged control triage names. - **`turso-driver-ignored-sync-key-refusal.test.ts`.** The control "sync beside syncUrl under a forced mode: 'local' stays accepted" is removed, because it pinned the defect. The header points to the new file. The #20200 refusal assertions are untouched. - **`turso-driver-forced-local-with-sync-url-refusal.test.ts` (new).** The refusal is asserted as the envelope (`code` + `status`), plus its first sentence and both ways out. It covers `file:` and `FILE:` urls, `:memory:`, `file::memory:`, and a config beside `sync`, `timeout` or `encryptionKey`. With a supplied client it is refused before any client work. It also covers `createTursoDriver()` and a check that neither url is echoed. ORDER: a remote url, a bare path, `sync` with no `syncUrl`, and a forced replica with no `syncUrl` each keep their own refusal. CONTROLS: the unforced replica still connects and syncs once; a forced local mode with no `syncUrl` or an empty one syncs nothing; a forced replica beside `syncUrl` and `:memory:` under a forced local mode both construct; and `detectMode` still answers `local`. - **`packages/spec/src/data/driver/turso.test.ts`.** The accept fixture becomes `{ …, mode: 'local', syncUrl: '' }`, because an empty `syncUrl` is unset. A new block asserts the refusal on `mode` over 6 configs, the url echo, url-refusal ORDER, both authoring doors (`config.mode` and `validateDriverConfig`), and the controls (the unforced replica with and without `sync`, and a forced local mode alone, beside an empty `syncUrl`, and on `:memory:`). **Reverse verification** ran through `scripts/ablation-replace.mjs` from the committed state at `7ee1acb58`. Each direction was predicted before the run, and all three matched: 1. **The constructor refusal disabled.** ` if (mode === 'local' && config.syncUrl) {` became `if (false && …) {`, and the mutation landed (anchor 1 → 0, blob `f9af3e93c573` → `35630f956f8f`). Predicted 26 RED. Got **26 failed** / 201 passed: the new file's 10 refusal cases, plus the parity table's 8 constructor verdicts and 8 byte-equality pins. No ORDER or CONTROLS case failed. Restored: blob == HEAD, `git diff HEAD` empty. 2. **One byte of the driver copy.** A doubled space was put after the constant's first sentence (blob → `019694bf2026`). Predicted exactly the 8 byte-equality pins. Got **8 failed**, all "the constructor's message is the spec contract's, byte for byte", while every verdict and first-sentence case stayed green. Restored the same way. 3. **The spec arm disabled** in `packages/spec/src/data/driver/turso.zod.ts` (blob `eed1efdaa33a` → `7850f9fcb53e`), against the spec's own source-level test. Predicted 3 RED. Got **3 failed** / 36 passed: the refusal, the url-echo and the both-doors cases. ORDER and controls stayed green. Restored the same way. The driver tests import the driver from `src`, so ablations 1 and 2 needed no build. Ablation 3 was read on the spec's own source tests only. The parity table's spec half reads the built spec dist, and that half was not re-ablated. ## Gates `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` ran at head `cfe05ec40`, after the final commit and the merge. It lists 10 paths vs merge base `6bff748bb` and **91 commands**. Every command ran, with its exit code written to disk before any pipe. `--ran` reconciliation reads `91 derived famil(ies) accounted for — 89 run, 2 NOT-MEASURED (2 DERIVED from a recorded exit 3)`, with 0 UNRUN. - **NOT MEASURED (exit 3, PREREQUISITE NOT MET):** `check:dual-build-cjs-loads` (workspace packages with no `dist/`) and `check:type-check-debt` (it needs a whole-workspace build). Both are CI's run. - **Run twice:** `check:doc-formula-expressions` and `check:lean-entry-closure` first answered exit 3. They are exit 0 after building the `@objectstack/lint` and `@objectstack/objectql` closures. - **Notable readings:** - `check-adr-0087-registration`: `registered turso-config-forced-local-with-sync-url-refused` (new here), BREAKING, bang, clause-② narrowing; - `check-changeset-no-major`: "This diff introduces no `major` bump"; - `check-empty-changeset`: exit 0; - `check:migration-registry`, `check:spec-changes`, `check:upgrade-guide`, `check:api-surface`, `check:authorable-surface`, `check:docs`, `check:doc-authoring`, `check:nul-bytes`, `check:test-source-alias`, `check:cross-package-test-inputs` and `check:driver-conformance`: exit 0. - **Narrowed lint:** `eslint --no-inline-config --format json` over the 9 changed TS files reports 9 files, 0 errors and 0 warnings. The population is `eslint.config.mjs`'s lint object, `files: ['**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}']`; the changeset `.md` is outside it. Invariance holds because the config enables no type-aware linting (its one `parserOptions.project` mention is a comment saying so), so this diff cannot move any untouched file's verdict. The full `pnpm lint` is CI's. - **Not run locally, left to CI:** the whole-workspace type-check lanes; the Test Core, Dogfood and Build Core jobs; and the downstream suites of `@objectstack/service-datasource`, `@objectstack/runtime` and `@objectstack/cli`. The narrowing is declared. The public surface's bytes are unchanged (`check:api-surface` and `check:authorable-surface` are green, and refinements are not in the JSON Schema). The census above finds no consumer fixture that authors the refused shape. **Driver-conformance ledger:** `check:driver-conformance` read `OK — 50 covered cell(s), 0 in the DEBT ledger, 0 exempt` both before (`f4ce10c89`) and after (`cfe05ec40`). `driver-turso` is `ok` on all 10 case-sets both times, so there was no movement. ## Deviations (declared) - **"Both schema copies" is met as text, not as a verdict, at the mirror.** Triage's pin names the refusal "at both schema copies". The driver mirror declares no `mode` key and strips an authored one, so it cannot see a forced mode. It carries the arm byte for byte and still accepts this config, judging it as the replica its url and `syncUrl` select. That is #20437's documented reading, and the parity table skips the mirror half of forced-mode rows. Giving the mirror a `mode` key is outside this card: the parity test's header calls that shortness "not this card's to change". The D3 entry's `surface` and the changeset say this rather than claiming the mirror refuses. - **Cloud producers were not measured** (H5). Attaching `objectstack-ai/cloud` was refused by the permission classifier. Nothing in-repo triggers `needs_decision`. - **The H1 runtime probe used a supplied client.** A client that counts syncs stood in for `@libsql/client`, so the probe made no network call. The arm it exercises (`connect()`'s `syncUrl` branch) is the one a driver-built client takes too. ## Acceptance notes - `packages/drivers/driver-turso/README.md`: the constructor-refusal list now reads four sync settings and gains an item for a forced `mode: 'local'` beside a non-empty `syncUrl`, with both ways out. Patch round 1 (`a0c4c033a`, README text only) made this change after contract review 5894260625 found the "three sync settings" sentence false at `cfe05ec40`. Its gate re-run reconciles 91 derived, 89 run, 2 NOT-MEASURED, 0 UNRUN. - The spec's `mode` key keeps its one-line TSDoc. The rule is carried by the refusal text and by `TursoDriverConfig.mode`'s TSDoc in the driver, which now names it (as does `syncUrl`'s). - The existing D3 entries `turso-config-transport-mismatch-refused` and `turso-config-forced-replica-without-sync-url-refused` are untouched. Both stay true. - Not touched: `turso-driver.ts`'s remote filter lowering (`toRemoteUpperBound`, the `$between` / `$lte` arms), which the spec lane's PR #20643 edits. This PR's hunks there are the file header, the `syncUrl` / `mode` TSDoc, the new constant beside the sync-key refusals, and one constructor check. --- _Generated by [Claude Code](https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 6981abf commit 05cb2bc

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)