Skip to content

Commit 61609ed

Browse files
fix(driver-turso)!: match the url scheme in any case, and refuse a url the local engine cannot open instead of running it on :memory: (#19996)
Fixes #19976 Clause-②: no (narrowing) `TursoDriver` no longer runs a url it cannot open on a private `:memory:` engine. The classifier now reads the url scheme in any letter case, the way `@libsql/client` routes it, so an uppercase `LIBSQL://` is remote. Whatever is still unrecognised (a bare path, an unsupported scheme) is refused at construction in a local or replica mode, as `VALIDATION_ERROR` / 400, naming the `file:` spelling. Both options in triage's execution note were weighed. This PR case-folds the scheme like the client and refuses what is left. Neither half routes any configuration to a `:memory:` engine it did not name. ## Why fold the case instead of refusing uppercase too - `@libsql/core@0.17.4` `lib-esm/config.js` line 26: `const originalUriScheme = uri.scheme.toLowerCase();`. Executed: `expandConfig({ url: 'LIBSQL://r.turso.io' }, true).scheme === 'https'`, and `createClient` opens it (`protocol=http`). `FILE:./x.db` expands to `file` and opens. - The host url sniffers already read it case-insensitively and hand this driver the url as written: `/^libsql:\/\//i` in `inferDriverTypeFromUrl` (`packages/cli/src/utils/storage-driver.ts`) and in `detectDriverFromUrl` (`packages/runtime/src/standalone-stack.ts`). So an uppercase `OS_DATABASE_URL` selected this driver and then ran on `:memory:`. - The driver's own `ridesWebSocketTransport` already folded case, with an in-code note that folding `detectMode` "must be argued on its own". That argument is above: after this PR, every reader of one url (host sniffer, client, both driver predicates) reads the scheme one way. The note is replaced with a single helper, `startsWithScheme`, that every predicate in the file compares through. - Folding widens nothing. An uppercase remote url constructed before, but it ran on the wrong engine. Refusing the uppercase spelling would have refused a url the client, the CLI and the runtime all accept. ## Why refuse a bare path instead of treating it as `file:` H3, measured against the installed `@libsql/client@0.17.4`: `createClient` refuses `./data/app.db`, `data/app.db` and `/abs/app.db` as `URL_INVALID` ("The URL '…' is not in a valid format"), and `C:\data\app.db`, `sqlite:./x.db` and `memory://x` as `URL_SCHEME_NOT_SUPPORTED`. Also `URL_INVALID`: `:MEMORY:`, `''`, `' file:./y.db'` (leading space) and `libsql:host` (no `//`). Control: `file:./x.db` opens with `protocol=file`. Reading a bare path as `file:` would invent a spelling the client refuses, so the same string would open a local file and fail as a replica. ## The refused set, exactly (for #19977 to mirror at authoring time) Mode is the forced `mode`, or else auto-detected from `url` (and `syncUrl`). Schemes compare case-insensitively. "A remote url" means one starting with `libsql://`, `https://`, `http://`, `wss://` or `ws://`. 1. **unrecognised-url (new)**: mode is `'local'` or `'replica'` (forced, or auto-detected with or without `syncUrl`), and `url` is none of: exactly `:memory:`; a url starting `file:`; a remote url. 2. **remote-url (unchanged, now case-insensitive)**: mode is `'local'` or `'replica'` and `url` is a remote url. That means a forced local or replica mode, or no `mode` with `syncUrl` set. 3. **in-memory-replica (renamed from `replica-without-file`, now covering only in-memory urls; see "The one widened cell" below)**: mode is `'replica'` and `url` is exactly `:memory:`, or a `file:` url whose remainder is `:memory:` or starts with `:memory:?`. Checked in the order 2, 1, 3. The two remote-mode `timeout` refusals are unchanged: `timeout` over 0 with a `wss://`/`ws://` url, and `timeout` over 0 with a supplied `client`. Case-folding now brings an uppercase WebSocket url with no `mode` into the first. **Not refused:** a forced `mode: 'remote'` with any url. It runs no local engine, and `@libsql/client` refuses a bare path there itself at `connect()` (`URL_INVALID`, pinned). `TursoDriver.detectMode` now also answers `'replica'` for an unrecognised url beside `syncUrl` (it answered `'local'`), like its other two arms. The refusal stays in the constructor. `toKnexConfig`'s last arm, which handed such a url `:memory:`, now calls the same refusal. It cannot be reached from the constructor, and it can no longer produce an in-memory engine nobody named. ## CONTROL 1 of `turso-driver-uppercase-ws-scheme-timeout-refusal.test.ts`: flipped, deliberately It pinned that `WSS://` / `Ws://` / `HTTPS://` / `LIBSQL://` with no `mode` construct as `'local'`, and that `WSS://` + `timeout` with no mode stays local and unrefused. That was the fall-through this card removes: a local engine on a private `:memory:` database. The control now pins the opposite: those urls are `'remote'`, and `WSS://` + `timeout` with no mode meets the WebSocket refusal with the same message as `wss://`, except for the echoed scheme. The file's docblock records the flip and its reason. Its reverse-verification paragraph was re-measured (below) and rewritten to match. ## Measurements Before/after probe: `initObjects`, `create`, `find`, then a fresh driver on the same config. BASE = `a7581b326`'s `turso-driver.ts`, loaded next to HEAD's in one vitest run over the same dependency closure. TMP and DIR stand for temp and relative directories. | configuration | BASE `a7581b326` | HEAD | |:--|:--|:--| | `LIBSQL://…`, no mode | local, 1 row, 0 after restart | remote (constructed) | | `./DIR/app.db`, no mode | local, 1 row, 0 after restart, file never created | refused `VALIDATION_ERROR` / 400 | | `TMP/bare.db` (absolute), no mode | local, 1 row, 0 after restart, file never created | refused `VALIDATION_ERROR` / 400 | | `./DIR/app.db` + `mode: 'local'` | local, 1 row, 0 after restart, file never created | refused `VALIDATION_ERROR` / 400 | | `FILE:TMP/upper.db`, no mode | local, 1 row, 0 after restart, file never created | local, 1 row, 1 after restart, file created | | CONTROL `file:TMP/ctl.db` | local, 1 row, 1 after restart | local, 1 row, 1 after restart | H1 confirmed by reading `a7581b326` (the `// Fallback: treat as local` in `detectMode`, the `:memory:` last arm of `toKnexConfig`) and by the rows above. H2 and H3 are covered above. H4: the guard extended is `localEngineDefect` / `refuseNonDurableLocalEngine`. The "or drop `mode: 'replica'` … for a plain local database" sentence is now emitted only for an in-memory url: a bare path takes the new refusal. It reads "for a plain in-memory local database, which is what the url names: ephemeral by declaration", which is true because dropping the replica on `:memory:` / `file::memory:` gives exactly that. The new refusal's own ways out were checked per arm: a `file:` url for a local file or replica, and "drop" every key (`mode`, `syncUrl`) that would still keep a remote url local. H5 reach, on this tree (`a7581b326` + this diff), `git grep`: - uppercase or mixed-case `libsql://` spellings, excluding this package's tests: 3 hits, all prose (the #19893 changeset, `CHANGELOG.md`, a driver comment). Positive control, lowercase `libsql://`: 487 hits. - uppercase `HTTP(S)`/`WS(S)`/`FILE` scheme literals, excluding this package's tests and changelogs: 5 hits, none a turso config (a CLI `File:` label, driver comments, a spec redirect-url test). Positive control, the same pattern lowercase: 3,652 hits. - `url` literals in every file that mentions turso or libsql, excluding this package's tests: 269 listed. None is a turso config with a bare path or unknown scheme. The leftovers are other drivers, route paths and a `' '` empty-url refusal fixture. Positive control: 46 `file:` values in the same listing. - `OS_DATABASE_DRIVER=turso` / `driver: 'turso'`: no bare path anywhere. Neither host sniffer selects this driver for a bare path. The runtime maps it to sqlite, and both select turso only for `libsql://` or an `http(s)://` url with a `.turso.` host. Out-of-repo deployments: NOT MEASURED, not claimed zero. ## Tests HEAD `e5e29cb11`: - `pnpm --filter @objectstack/driver-turso test`: `Test Files 62 passed (62)`, `Tests 1404 passed (1404)`. - `pnpm --filter @objectstack/driver-turso typecheck` (`tsc --noEmit`): clean. `--listFiles` includes both touched test files. - New file `turso-driver-unrecognised-url-refusal.test.ts` (40 cases) pins: - refusals as the envelope (`code` + `status` + the identifying first sentence), before and after, for the uppercase url and the bare path, with and without `syncUrl`, under a forced `mode: 'local'` and `mode: 'replica'`, and through `createTursoDriver`; - that the url is never echoed; - that the client stub is untouched; - uppercase `FILE:` durability across a restart, local and replica; - the forced-remote scope, where the client answers `URL_INVALID` at connect; - preservation: `file:` local and replica, `:memory:` local, lowercase remote, `mode: 'remote'`. Reverse verification. The direction was predicted in the new file's header before the run. It ran at `457d65f23`, where `src/` is byte-identical to `e5e29cb11` except comments; `git diff 457d65f e5e29cb -- packages/drivers/driver-turso/src` is comments only. `turso-driver.ts` was restored to the `a7581b326` blob (`07363cdb…`, on-disk hash verified, `startsWithScheme` count 8 then 0) under an absolute-path trap, over the three suites: `Tests 35 failed | 52 passed (87)`. - New file: 30 RED (every refusal and uppercase case) and 10 GREEN (scope and 9 preservation). - ws file: 5 RED (the flipped CONTROL 1) and 15 GREEN. - `turso-driver-remote-url-replica-refusal.test.ts`: all GREEN. - Restore: blob `02da2f8f` equals HEAD's, and `git diff HEAD` is empty. Ablation. Using `node scripts/ablation-replace.mjs`, `ridesWebSocketTransport` was reverted to `url.startsWith('wss://') || url.startsWith('ws://')`. The anchor fell from 1 hit to 0 and the blob went `02da2f8f` to `5d086ff9`. Result: `Tests 7 failed | 13 passed (20)`. RED: the 6 refusal cases and CONTROL 1's timeout case. GREEN: CONTROL 1's classification cases and controls 2 and 3, which is what the rewritten docblock says. Restored, with the blob equal to HEAD's and `git diff HEAD` empty. ## Gates (HEAD `e5e29cb11`) - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` returns the same 61 commands at `bc2e4f5f8` and `e5e29cb11`. All 61 were run at `e5e29cb11`. `--ran`: `✓ dispatch-gates --ran: 61 derived famil(ies) accounted for — 59 run, 2 NOT-MEASURED (2 DERIVED from a recorded exit 3).` - NOT MEASURED: `check:dual-build-cjs-loads` and `check:type-check-debt`. Reason: `PREREQUISITE NOT MET`, exit 3. Each needs a whole-workspace `dist/` build (`turbo run build --filter='./packages/*' …`, as in `lint.yml`), which was not run locally. Targeted substitute: the built `dist/index.js` (CJS) and `dist/index.mjs` (ESM) of this package load, and each refuses a bare path as `VALIDATION_ERROR 400`. `driver-turso` has no `DEBT` / `TEST_DEBT` entry, and its `tsc --noEmit` is clean. - `check:lean-entry-closure` first answered exit 3 (objectql not built). It is green after `turbo run build --filter=@objectstack/objectql`. - `node scripts/check-changeset-no-major.mjs --base origin/main`: `✓ This diff introduces no \`major\` bump.` The clause-② axis reads this PR's body in CI: `LEVEL AXIS: NOT APPLICABLE` locally, because a local run has no `pull_request` payload. - `node scripts/check-adr-0087-registration.mjs --base origin/main`: `✓ check-adr-0087-registration: 1 declared-breaking changeset(s), each carrying an ADR-0087 disposition.` Classified as `[BREAKING+bang+clause-②-narrowing] not-required (no-migration-prescription)`, the same shape as the precedent changeset from PR #19971. - `pnpm check:driver-conformance`: `OK — 50 covered cell(s), 0 in the DEBT ledger, 0 exempt.` Same reading on base `a7581b326` before the change and on `e5e29cb11` after. - `node scripts/check-issue-citations.mjs --base origin/main`: `✅ check-issue-citations: every citation this change adds resolves (or is a declared cross-repo reference).` - Lint, a declared narrowing to the three changed `.ts` files: `eslint --no-inline-config --format json` counts 3 files, 0 errors and 0 warnings at `e5e29cb11`. Each file is in eslint's own config (`--print-config` exit 0; none reported as ignored). Invariance: `eslint.config.mjs` enables no type-aware linting (no `parserOptions.project`, no typed rules; its own note at line 327), so this diff cannot move the verdict on an untouched file. `pnpm lint` over the repo is CI's. - Stale-tree caveat: dispatch-gates reports this tree 10 commits behind `origin/main` (`67ebc84a7`). The 17 family-definition files changed there are sdui/objectui manifest scripts, `lint.yml`, `cut-rc.yml`, `package.json` and similar. None of them touches `packages/drivers/driver-turso`, and no merge was taken. CI runs the current definitions on the merge ref. ## Acceptance notes - The #19893 changeset (`.changeset/19893-turso-remote-url-replica-refusal.md`, unreleased) is rewritten by this PR at lines 20, 25 and 31 as a DELIBERATE CORRECTION, because this change made those three sentences false for the shipped code; see "Deliberate correction of a pending changeset" below. No other sentence of that note moves. - `toKnexConfig`'s last arm now refuses too, so an ablation of the constructor's `unrecognised-url` line alone leaves the tests green by design: the same envelope comes from the second site. The reverse verification above removes both. - A `file:` url with a query string (for example `file:./x.db?mode=ro`) is still handed to better-sqlite3 as a literal filename. That behaviour predates this PR, is durable, is NOT MEASURED here, and no finding is claimed. - #19894 (same file, queued) is not touched: no change to `planMediaColumnMove` or the remote media-column paths. ## The one widened cell (seat correction of this body, after contract review 5818338847) The earlier text of this body called arm 3 "same coverage". That was wrong. Under a forced `mode: 'replica'`, a url that is none of `file:`, `:memory:` or a remote url now meets arm 1 (`unrecognised-url`) instead. An uppercase or mixed-case `FILE:` url naming a file, under a forced `mode: 'replica'`, with or without `syncUrl`, is **no longer refused**: it is a `file:` url, the replica runs on that file, and its rows survive a restart. The #19893 change refused it, because it read the scheme case-sensitively. It is the only configuration refused at base `a7581b326` and accepted at this head. It is stated in `.changeset/19976-turso-unrecognised-url-refusal.md` ("Newly accepted") and pinned in the PRESERVATION table, 4 cases that go red on the base `turso-driver.ts`. Measured at `4a7cb4de`: with `turso-driver.ts` restored to the base blob `07363cdb`, `-t PRESERVATION` gives `Tests 4 failed | 9 passed | 31 skipped (44)`. The 4 red cases are exactly the widened ones, each refused `VALIDATION_ERROR` / 400 by the base replica-without-file message. At head they are 4 of 4 green. ## Deliberate correction of a pending changeset This PR changes `.changeset/19893-turso-remote-url-replica-refusal.md`. That note is pending: it landed with PR #19971 and no release has consumed it, and it compiles into the same version's CHANGELOG as this PR's note (lockstep `fixed` group). This change made three of its sentences false for the shipped code, so they are rewritten here, from the code at this head. No other sentence of that note moves (`git diff -U0`: lines 20, 25, 31 only). `Check Changeset` is therefore red **by design** (the DELIBERATE CORRECTION class of `scripts/check-empty-changeset.mjs`). `skip-changeset` is not applied, and the note is not restored. The confirmation is the same-head at-tier contract review record that names this note and judges each rewritten sentence (ruling 1A, #19940, 5814546887). - **Line 20 (S3).** Old: "A remote url here means one of the lowercase schemes `TursoDriver.detectMode` classifies as remote: …". New: "A remote url here means one of the schemes `TursoDriver.detectMode` classifies as remote: `libsql://`, `https://`, `http://`, `wss://`, `ws://`. The #19976 entry in this same version matches them in any letter case, so an uppercase `LIBSQL://` is a remote url too." - **Line 25 (S2).** Old: "under a forced `mode: 'replica'` only, any `url` that is not a local `file:` path, such as a bare path or an uppercase scheme." New: "under a forced `mode: 'replica'`, a `url` that is none of `:memory:`, a `file:` url or a remote url, such as a bare path or an unsupported scheme. The #19976 entry in this same version refuses such a url in every local or replica mode, and matches the `file:` scheme in any letter case: an uppercase `FILE:` url naming a file is a `file:` url and is not refused, and the replica runs on that file (`FILE::memory:` is refused as in-memory, like `file::memory:`)." - **Line 31 (S1).** Old: "**Not refused, unchanged here:** … still auto-detects `'local'` and still runs on `:memory:`, with or without `syncUrl`. So does the same url under a forced `mode: 'local'`. That fall-through is tracked as #19976." New: "**Not refused by this change:** … auto-detected `'local'` with or without `syncUrl`, and the local engine was handed `:memory:`. Under a forced `mode: 'local'` the same url got the same `:memory:` engine. The #19976 entry in this same version removes that fall-through … No configuration runs on an in-memory database it did not name." --- _Generated by [Claude Code](https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent b9e9609 commit 61609ed

6 files changed

Lines changed: 585 additions & 105 deletions

‎.changeset/19893-turso-remote-url-replica-refusal.md‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,18 +17,18 @@ file: + syncUrl (unchanged) -> 1 row back, 1 row after restart
1717

1818
With the driver building its own client and the default `sync.onConnect`, two of these did fail at `connect()`, but on a libsql error that did not say why: `libsql://` + `syncUrl` with `SYNC_NOT_SUPPORTED`, and `:memory:` + `syncUrl` with `URL_INVALID`.
1919

20-
**BREAKING** accept-set narrowing on a published driver option, shipped as `minor` under the repo's launch-window convention for breaking changes (`scripts/check-changeset-no-major.mjs`). **The constructor now refuses configurations it accepted before**, at `new TursoDriver()`, ahead of the Knex base and of any client, with the ADR-0112 envelope `code: 'VALIDATION_ERROR'`, `status: 400`. A remote url here means one of the lowercase schemes `TursoDriver.detectMode` classifies as remote: `libsql://`, `https://`, `http://`, `wss://`, `ws://`. Refused:
20+
**BREAKING** accept-set narrowing on a published driver option, shipped as `minor` under the repo's launch-window convention for breaking changes (`scripts/check-changeset-no-major.mjs`). **The constructor now refuses configurations it accepted before**, at `new TursoDriver()`, ahead of the Knex base and of any client, with the ADR-0112 envelope `code: 'VALIDATION_ERROR'`, `status: 400`. A remote url here means one of the schemes `TursoDriver.detectMode` classifies as remote: `libsql://`, `https://`, `http://`, `wss://`, `ws://`. The #19976 entry in this same version matches them in any letter case, so an uppercase `LIBSQL://` is a remote url too. Refused:
2121

2222
- a remote url beside `syncUrl`;
2323
- a remote url under a forced `mode: 'replica'` or `mode: 'local'`;
2424
- a replica on a url `@libsql/client` reads as in-memory (`:memory:`, or `file::memory:` with or without a query string), beside `syncUrl` or under a forced `mode: 'replica'`. `@libsql/client` refuses such an embedded replica itself. For `:memory:` and a bare `file::memory:` the local engine was a private in-memory database. With a query string it was a file literally named after the url's path (for example `:memory:?cache=shared`) in the working directory, which no sync reaches;
25-
- under a forced `mode: 'replica'` only, any `url` that is not a local `file:` path, such as a bare path or an uppercase scheme.
25+
- under a forced `mode: 'replica'`, a `url` that is none of `:memory:`, a `file:` url or a remote url, such as a bare path or an unsupported scheme. The #19976 entry in this same version refuses such a url in every local or replica mode, and matches the `file:` scheme in any letter case: an uppercase `FILE:` url naming a file is a `file:` url and is not refused, and the replica runs on that file (`FILE::memory:` is refused as in-memory, like `file::memory:`).
2626

2727
The remote-url refusal names the scheme it met. Neither refusal echoes the url, which may carry a token. Both loaders (`@objectstack/runtime`'s host factory and the datasource factory) reach this refusal through the same constructor, so a datasource declaring one of these configurations now fails by name when its loader builds the driver.
2828

2929
**What stays accepted**, pinned by preservation tests: a `file:` url with `syncUrl` (the embedded replica), a `file:` or `:memory:` local database, a remote url on its own or with `mode: 'remote'`. `TursoDriver.detectMode()` still classifies a remote url beside `syncUrl` as `'replica'`: the refusal sits in the constructor, not in a re-classification.
3030

31-
**Not refused, unchanged here:** a url with no `mode` that is none of `file:`, `:memory:` or a lowercase remote scheme, such as an uppercase `LIBSQL://` or a bare path like `./data/app.db`, still auto-detects `'local'` and still runs on `:memory:`, with or without `syncUrl`. So does the same url under a forced `mode: 'local'`. That fall-through is tracked as #19976.
31+
**Not refused by this change:** a url with no `mode` that is none of a lowercase `file:` url, `:memory:` or a lowercase remote scheme, such as an uppercase `LIBSQL://`, an uppercase `FILE:` url or a bare path like `./data/app.db`, auto-detected `'local'` with or without `syncUrl`, and the local engine was handed `:memory:`. Under a forced `mode: 'local'` the same url got the same `:memory:` engine. The #19976 entry in this same version removes that fall-through: it matches every scheme in any letter case, so an uppercase remote url is a remote url and an uppercase `FILE:` url is a `file:` url, and it refuses every other url in a local or replica mode. No configuration runs on an in-memory database it did not name.
3232

3333
**What an affected author does.** Each refusal names its ways out. For a remote url in a local or replica mode:
3434

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
---
2+
'@objectstack/driver-turso': minor
3+
---
4+
5+
fix(driver-turso)!: a url scheme matches in any letter case, and a url the driver cannot open is refused instead of running on a private in-memory database
6+
7+
Clause-②: no (narrowing)
8+
9+
`TursoDriver.detectMode` matched `file:` and the five remote schemes case-sensitively, and answered `'local'` for any other url with no `mode`. The local engine can open only a `file:` path or `:memory:`, so for everything else it was handed `:memory:`: writes succeeded and read back, then vanished on restart. `@libsql/client` reads a scheme in any letter case (`@libsql/core@0.17.4` routes on `uri.scheme.toLowerCase()`), so an uppercase `LIBSQL://` url that the client routes to the remote database ran on that local `:memory:` engine instead. `@objectstack/cli` and `@objectstack/runtime` select this driver for an `OS_DATABASE_URL` matching `libsql://` in any letter case and hand it the url as written, so an uppercase `OS_DATABASE_URL` reached that engine too. Measured before the change, with `initObjects`, `create`, `find`, then a fresh driver on the same config:
10+
11+
```
12+
LIBSQL://… (no mode) -> local, 1 row back, 0 rows after restart
13+
FILE:<path> (no mode) -> local, 1 row back, 0 rows after restart, file never created
14+
./<dir>/app.db (no mode) -> local, 1 row back, 0 rows after restart, file never created
15+
<tmp>/app.db (no mode) -> local, 1 row back, 0 rows after restart, file never created
16+
./<dir>/app.db + mode: 'local' -> local, 1 row back, 0 rows after restart, file never created
17+
file:<path> (unchanged) -> local, 1 row back, 1 row after restart
18+
```
19+
20+
**The scheme now matches in any letter case**, as it does in `@libsql/client`, in `detectMode` and in every constructor check. `LIBSQL://`, `HTTPS://`, `Http://`, `WSS://` or `Ws://` with no `mode` is remote. `FILE:<path>` is a local file, and an embedded replica beside `syncUrl`. The url itself is passed to the client as written.
21+
22+
**BREAKING** accept-set narrowing on a published driver option, shipped as `minor` under the repo's launch-window convention for breaking changes (`scripts/check-changeset-no-major.mjs`). **The constructor now refuses configurations it accepted before**, at `new TursoDriver()`, ahead of the Knex base and of any client, with the ADR-0112 envelope `code: 'VALIDATION_ERROR'`, `status: 400`. Refused:
23+
24+
- in a local or replica mode, a `url` that is none of `:memory:`, a `file:` url or a remote url (`libsql://`, `https://`, `http://`, `wss://`, `ws://`, in any letter case). That is a bare path (`./data/app.db`, `data/app.db`, `/var/lib/app.db`, `C:\data\app.db`), an unsupported scheme (`sqlite:`, `memory://`), `:MEMORY:`, a remote scheme with no `//`, a url behind leading whitespace, and an empty url. Newly refused with no `mode` (with or without `syncUrl`) and under a forced `mode: 'local'`. Under a forced `mode: 'replica'` it was already refused, and the refusal now names the `file:` spelling for the replica. `@libsql/client@0.17.4` refuses each of these urls itself, as `URL_INVALID` or `URL_SCHEME_NOT_SUPPORTED`;
25+
- an uppercase remote url beside `syncUrl` (no `mode`), or under a forced `mode: 'local'`. Both constructed on the private `:memory:` engine before, and both now meet the refusal their lowercase spelling already met. Under a forced `mode: 'replica'` it was already refused, now with that same remote-url refusal;
26+
- an uppercase `WSS://` or `WS://` url with a non-zero `timeout`, no `mode` and no `syncUrl`. It is now detected as remote, so the existing refusal of a `timeout` on the WebSocket transport reaches it;
27+
- `FILE::memory:` beside `syncUrl` (no `mode`), refused as an in-memory replica exactly like `file::memory:`. Under a forced `mode: 'replica'` it was already refused.
28+
29+
The unrecognised-url refusal names the `file:` spelling (`url: 'file:./data/app.db'`, or `url: 'file:./data/replica.db'` beside `syncUrl`) and never echoes the url, which may carry a token. `TursoDriver.detectMode()` now answers `'replica'` for such a url beside `syncUrl` (it answered `'local'`), which is what the declaration asks for. The refusal sits in the constructor, not in a re-classification.
30+
31+
**Newly accepted:** an uppercase or mixed-case `FILE:` url naming a file, under a forced `mode: 'replica'`, with or without `syncUrl`. The #19893 change refused it, because under a forced `mode: 'replica'` it refused every url that did not start with a lowercase `file:`. With this change it is a `file:` url: the replica runs on that file, and its rows survive a restart (pinned). It is the one configuration the #19893 change refused that this change accepts.
32+
33+
**What stays accepted**, pinned by preservation tests: a lowercase `file:` url, alone or with `syncUrl`; `:memory:` as a local database; a lowercase remote url on its own or with `mode: 'remote'`. A forced `mode: 'remote'` runs no local engine, so this change does not judge its url: a bare path there still constructs, and `@libsql/client` refuses it at `connect()` as `URL_INVALID`.
34+
35+
**What an affected author does.** A local database file needs the `file:` prefix: `url: 'file:./data/app.db'`. For a throwaway in-memory database, `url: ':memory:'`. An uppercase remote url with no `mode` and no `syncUrl` now reaches the remote database and needs no change. Beside `syncUrl` or under a forced local or replica mode it is refused with the same ways out as the lowercase spelling.
36+
37+
The #19893 entry in this same version (the constructor refusal of a remote url in a local or replica mode) describes this fall-through as not refused by that change, and names this entry as the one that removes it.
38+
39+
Blast radius, measured on this tree: no example, template, hand-written doc, published skill or factory default, and no test fixture outside this package's own tests, spells a turso url with an uppercase scheme or as a bare path. Neither host url sniffer selects this driver for a bare path (both select it only for `libsql://` or an `http(s)://` url naming a `.turso.` host); only an explicit `OS_DATABASE_DRIVER=turso` or a datasource declaring `driver: 'turso'` hands it one. Whether any out-of-repo deployment declares such a url is NOT measured and is not claimed to be zero.
40+
41+
<!-- adr-0087: not-required (no-migration-prescription) An accept-set narrowing performed at the driver constructor, together with a case-insensitive reading of the url scheme: no key, spec symbol, Zod schema, object definition or stored representation is added, removed or renamed — `TursoDriverConfig.url`, `syncUrl` and `mode` keep their names and types, and both `TursoConfigSchema` copies are untouched. What moves is which CONFIGURATIONS `new TursoDriver()` accepts and how it classifies an uppercase scheme, so `objectstack migrate meta` has nothing to visit and there is no tombstone to mint. The refusal names the `file:` spelling, and whether an author meant a local file, a replica or a remote database is authoring intent no ledger line can decide. -->

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

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -130,24 +130,33 @@ Transport mode is automatically detected from the URL:
130130
| `libsql://...` | `remote` | @libsql/client only |
131131
| `https://...` | `remote` | @libsql/client only |
132132

133+
`http://`, `wss://` and `ws://` are remote too. A scheme matches in any letter
134+
case, as it does in `@libsql/client`: `LIBSQL://...` is remote and `FILE:...` is
135+
a local file. A path to a local database file needs the `file:` prefix. A bare
136+
path such as `./data/app.db` is not a url, and `@libsql/client` refuses it too.
137+
133138
An embedded replica is a local **file** kept in sync with a remote. The local and
134139
replica modes run every read and write through the local SQLite engine, which
135-
cannot open a remote url, and a replica needs a file for the sync to land in.
136-
The constructor therefore refuses (`VALIDATION_ERROR` / 400):
137-
138-
- a remote url (the lowercase `libsql://`, `https://`, `http://`, `wss://`,
139-
`ws://` that auto-detection matches) beside `syncUrl`, or under a forced
140-
`mode: 'local'` / `'replica'`;
140+
can open only a `file:` url or `:memory:`, and a replica needs a file for the
141+
sync to land in. The constructor therefore refuses (`VALIDATION_ERROR` / 400):
142+
143+
- in a local or replica mode (auto-detected, with or without `syncUrl`, or
144+
forced), a `url` that is none of `file:`, `:memory:` or a remote url, such as
145+
a bare path or an unsupported scheme. The fix for a local database file is
146+
`url: 'file:./data/app.db'`;
147+
- a remote url (`libsql://`, `https://`, `http://`, `wss://`, `ws://`, in any
148+
letter case) beside `syncUrl`, or under a forced `mode: 'local'` / `'replica'`;
141149
- a replica on an in-memory url (`:memory:`, `file::memory:`). That covers a
142150
replica auto-detected from a `:memory:` or `file:` url beside `syncUrl`, and
143-
one forced with `mode: 'replica'`;
144-
- under a forced `mode: 'replica'`, any `url` that is not a local `file:` path.
151+
one forced with `mode: 'replica'`.
145152

146153
In each case the engine would otherwise run on a private in-memory database
147154
whose writes read back and then vanish on restart, and `@libsql/client` builds
148155
no embedded replica for a remote url anyway. For a remote database, drop
149156
`syncUrl` and any forced `mode`. For an embedded replica, use
150-
`url: 'file:./data/replica.db'` beside `syncUrl`.
157+
`url: 'file:./data/replica.db'` beside `syncUrl`. A forced `mode: 'remote'` runs
158+
no local engine, so its url is not judged here: `@libsql/client` refuses a
159+
url it cannot open when the driver connects.
151160

152161
You can also force a specific mode:
153162

@@ -216,6 +225,9 @@ interface TursoDriverConfig {
216225
* - :memory: → local mode (ephemeral)
217226
* - libsql://my-db.turso.io → remote mode
218227
* - https://my-db.turso.io → remote mode
228+
* Schemes match in any letter case. In a local or replica mode any other
229+
* url (a bare path, an unsupported scheme) is refused at construction
230+
* (VALIDATION_ERROR / 400): spell a local file as file:./data/app.db.
219231
*/
220232
url: string;
221233

0 commit comments

Comments
 (0)