Repository navigation
Commit 61609ed
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
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
17 | 17 | | |
18 | 18 | | |
19 | 19 | | |
20 | | - | |
| 20 | + | |
21 | 21 | | |
22 | 22 | | |
23 | 23 | | |
24 | 24 | | |
25 | | - | |
| 25 | + | |
26 | 26 | | |
27 | 27 | | |
28 | 28 | | |
29 | 29 | | |
30 | 30 | | |
31 | | - | |
| 31 | + | |
32 | 32 | | |
33 | 33 | | |
34 | 34 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
130 | 130 | | |
131 | 131 | | |
132 | 132 | | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
133 | 138 | | |
134 | 139 | | |
135 | | - | |
136 | | - | |
137 | | - | |
138 | | - | |
139 | | - | |
140 | | - | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
| 147 | + | |
| 148 | + | |
141 | 149 | | |
142 | 150 | | |
143 | | - | |
144 | | - | |
| 151 | + | |
145 | 152 | | |
146 | 153 | | |
147 | 154 | | |
148 | 155 | | |
149 | 156 | | |
150 | | - | |
| 157 | + | |
| 158 | + | |
| 159 | + | |
151 | 160 | | |
152 | 161 | | |
153 | 162 | | |
| |||
216 | 225 | | |
217 | 226 | | |
218 | 227 | | |
| 228 | + | |
| 229 | + | |
| 230 | + | |
219 | 231 | | |
220 | 232 | | |
221 | 233 | | |
| |||
0 commit comments