Repository navigation
fix(driver-turso)!: new TursoDriver refuses syncUrl under a forced remote mode, and sync with no syncUrl (#20200) - #20447
Conversation
…nc with no syncUrl, at construction The constructor built both configurations and ignored the key: under mode 'remote' the remote client never receives syncUrl, no sync runs and isSyncEnabled() answered true; sync with no syncUrl is read by nothing. Both are now refused as VALIDATION_ERROR / 400 before super(), in the spec contract's own words (module constants pinned byte-equal to the schema issues by the parity test). The spec's syncUrl-under-remote message, the driver mirror's copy and the ADR-0087 entry's stored-row sentence are reworded to match. The parity table's four inert rows flip to refused. Claude-Session: https://claude.ai/code/session_01N8TPEsoJxPsdSdNKGnNGEN Co-authored-by: Claude <noreply@anthropic.com>
…ble, not as 50 skipped cases Claude-Session: https://claude.ai/code/session_01N8TPEsoJxPsdSdNKGnNGEN Co-authored-by: Claude <noreply@anthropic.com>
…o entry reason Claude-Session: https://claude.ai/code/session_01N8TPEsoJxPsdSdNKGnNGEN Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N8TPEsoJxPsdSdNKGnNGEN Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 2 package(s): ⛔ 1 release-owned page(s) name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 140 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 9b4415682fd9b39647ff0dd1b4a986f4e8f3e37e && git checkout 9b4415682fd9b39647ff0dd1b4a986f4e8f3e37e
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 8cdbe0c6e5af61613749f22105c03652fc0f4043 7052b91110cbaea15fdb07422ed4f3af41f8afb0 && git checkout -B drift-repro 8cdbe0c6e5af61613749f22105c03652fc0f4043 && git merge --no-ff 7052b91110cbaea15fdb07422ed4f3af41f8afb0
node scripts/docs-audit/affected-docs.mjs --json 8cdbe0c6e5af61613749f22105c03652fc0f4043
|
Contract reviewServed-tier: ① Derived judgmentsInputs read: card #20200 (body and all six comments — triage 5854158738, unblock 5854953310, claim 5868842793, report 5869079328, amended claim 5869128452 as the surface and rulings, report 5870627032); #19977 (body, ten comments); rider #20437 (open); PR #20447 (body, nine-file list, net diff against merge-base Accept-set changes the diff implies, each judged:
Check-runs on ② Semver level
③ Boundary flagsDev flags (report 5870627032, PR body), each answered:
The three carriers, judged:
Implemented-by: VERDICT: PASS |
…Url-under-remote constructed-and-ignored With the inert floor at 0 the driver constructs-and-ignores nothing the schemas refuse. The comments beside tursoTransportIssues (spec and mirror), both section headings, the spec test's block comment, the parity test's two comments, and the turso-config-transport-mismatch-refused entry's header comment and reason clause now say what holds since #20200, the entry's clause put in the past tense. Text only. Claude-Session: https://claude.ai/code/session_01N8TPEsoJxPsdSdNKGnNGEN Co-authored-by: Claude <noreply@anthropic.com>
…so entry text Claude-Session: https://claude.ai/code/session_01N8TPEsoJxPsdSdNKGnNGEN Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N8TPEsoJxPsdSdNKGnNGEN Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: ① Derived judgmentsDelta review on top of the PASS record 5870987840 at
② Semver levelUnchanged from 5870987840 and carried: ③ Boundary flags
Implemented-by: VERDICT: PASS |
…te -wal sidecar too, never waiting on another connection (objectstack-ai#20426) (objectstack-ai#20463) Fixes objectstack-ai#20426 Clause-②: no `reclaimSpace()` on better-sqlite3 now returns the freed bytes from the `-wal` sidecar as well as the freelist, and it never waits on another connection. Every size below is the database file plus its `-wal` file, read from the file system while the driver is still open. Every freelist and page count is read from a second connection. Measured head: `effb34a8a` (the branch after merging `origin/main` at `b28550818`, which carries PR objectstack-ai#20427). ## What was wrong With PR objectstack-ai#20425, one `Database.exec('PRAGMA incremental_vacuum')` returns the whole freelist in one transaction. In WAL mode, the file-backed default, that transaction's dirty pages outgrow the page cache, so SQLite spills them into the WAL before the commit truncates them away. Nothing afterwards truncates the WAL, so the sidecar keeps its high-water size until the last connection closes. ## What changed - `packages/drivers/driver-sql/src/sql-driver.ts`: the better-sqlite3 arm of `SqlDriver.reclaimSpace` calls a module-local `reclaimBetterSqlite3(connection)`. It is module-local, like `formatDuplicateGroups`, because `SqlDriver`'s `.d.ts` carries its non-public members and this helper is no entry point. The published types are unchanged; the `.d.ts` gains one doc-comment sentence on `reclaimSpace`. The helper: 1. reads `PRAGMA freelist_count`, and sends nothing more when it is `0`; 2. runs `PRAGMA incremental_vacuum(N)` in chunks, N being a quarter of this connection's page cache (1,000 pages at better-sqlite3's default `cache_size = -16000` and 4 KiB pages), with a `PASSIVE` checkpoint after each chunk; 3. stops when the freelist is empty or a chunk frees nothing (an `auto_vacuum = NONE` file never shrinks its freelist); 4. ends with one `PRAGMA wal_checkpoint(TRUNCATE)` under a busy timeout of `0`, and puts the connection's own busy timeout back in a `finally`. Every statement goes through the binding's `exec()` / `pragma()`, which step to completion. The loop is synchronous, so nothing else runs on the connection between chunks. Every other SQLite client stays on `knex.raw`, as before. - Tests in `driver-sql` and `driver-turso` (below), and `.changeset/20426-reclaim-space-wal-sidecar.md` (`@objectstack/driver-sql`: `patch`). - `.changeset/20106-reclaim-space-full-freelist.md`: one paragraph removed. It said the freed pages pass through the `-wal` file, "which keeps its size until the last connection closes". This PR makes that false, and that note is still pending release. **This keeps `check-empty-changeset` red on purpose** — see "The one red gate" below. ## The dispatch's hypotheses - **H1 — confirmed** on `origin/main` `8cdbe0c6e`, through `SqlDriver` (25,754 free pages): | step | database file | `-wal` | freelist / pages | |:--|--:|--:|:--| | after the delete | 103,149,568 | 4,255,992 | 25,754 / 25,789 | | after `reclaimSpace()` (351 ms) | 16,384 | 94,430,432 | 0 / 4 | | after one more write | 16,384 | 94,430,432 | 0 / 4 | | after `disconnect()` | 16,384 | 0 | 0 / 4 | The DELETE-journal control on the same tree: 105,631,744 → 16,384 while open, with no `-wal` file. - **H2 — re-measured on this tree, and the picked variant is a fourth one.** Each variant ran on `SqlDriver`'s own pooled connection after the real fill-and-delete path (25,754 free pages, chunk 1,000). The rows show database file + `-wal` after the call, driver open. This is one run per cell on a shared box, so read the ratios, not the absolute times. | variant | no reader | reader in this process (read transaction open) | reader in another process (open for 1.5 s) | |:--|:--|:--|:--| | `exec` alone (PR objectstack-ai#20425) | 16,384 + 94,430,432 · 315 ms | 103,149,568 + 94,430,432 · 715 ms | 103,149,568 + 94,430,432 · 273 ms | | + `wal_checkpoint(TRUNCATE)` | 16,384 + 0 · 476 ms | 103,149,568 + 94,430,432, busy · **5,333 ms** | 16,384 + 0 · **1,526 ms** (waited out the reader) | | chunked + `PASSIVE` | 16,384 + 4,255,992 · 157 ms | 103,149,568 + 4,255,992 · 47 ms | 103,149,568 + 4,255,992 · 62 ms | | **chunked + `PASSIVE` + `TRUNCATE` at busy timeout 0 (this PR)** | **16,384 + 0** · 276 ms, 108 ms on a rerun | 103,149,568 + 4,255,992, busy · 48 ms | 103,149,568 + 4,255,992, busy · 61 ms | - The objectstack-ai#20106 reading of about 210 KB for chunked + `PASSIVE` does not hold through `SqlDriver`. `PASSIVE` never shrinks the sidecar: it stays at whatever high-water size the sweep's own deletes left (4,255,992 here). Only a `TRUNCATE` checkpoint returns it. - A waiting `TRUNCATE` checkpoint blocks the whole process on this synchronous binding, for up to the connection's busy timeout (5,000 ms; knex's better-sqlite3 client passes no `timeout`, so it is always better-sqlite3's default). The lifecycle sweep runs in the server process, so the triage's never-wait direction holds. - So this PR takes the triage's chunked, never-waiting variant, plus one `TRUNCATE` checkpoint that cannot wait. It is the only row that both returns the space with no reader and never waits with one. - With a reader present, no variant can shrink the database file. The chunked rows keep the pair at its size before the call (107,405,560). The one-statement rows grow it to 197,580,000. **The chunk size, and why.** A chunk that outgrows the page cache spills its pages into the WAL, just as one statement does. Frames left in the WAL by the call, with a reader pinning every frame so none is reused: | chunk (pages) | 100 | 250 | 500 | 1,000 | 2,000 | 4,000 | 8,000 | one statement | |:--|--:|--:|--:|--:|--:|--:|--:|--:| | default cache (`-16000`) | 1,437 | 1,121 | 1,003 | 928 | 883 | 3,779 | 14,216 | 22,920 | | 2 MB cache (`-2000`) | | 1,121 | 4,554 | 15,491 | | | | | - The spill starts where the chunk reaches the page cache: between 2,000 and 4,000 pages at the default (`PRAGMA cache_spill` reads 3,871), and between 250 and 500 at `-2000`. - Below that point, larger chunks mean fewer commits and fewer frames. - A fixed 1,000 would spill on a connection with a smaller cache or larger pages. So N is derived from the connection's own `cache_size` and `page_size`, and the quarter leaves room for the per-page overhead and the b-tree pages each chunk rewrites. At the default that is 1,000 pages (4 MB). - **H3 — confirmed.** `resolveSqliteJournalMode()` answers `wal` for a file-backed database unless configured otherwise, and the probe's second connection reads `journal_mode = wal`. The DELETE-journal control is unchanged by the fix. Before and after, the file shrinks while the driver is open and no `-wal` file exists: 105,631,744 → 16,384, 216 ms before and 127 ms after. - **H4 — confirmed.** The local `TursoDriver` face uses knex's `better-sqlite3` client, so it takes this arm through `super.reclaimSpace()`. Its suite reached the method, but it read only the freelist and the page count. It now has a WAL-size case. The remote route is untouched. - **H5 — nothing new is thrown, so the sweep logs nothing new.** Both checkpoints report "busy" as a result row, not as an error. So a busy checkpoint degrades to "vacuumed, not checkpointed": the call resolves, the pages are off the freelist, and `LifecycleService.sweep()` lists the datasource as reclaimed, as before. - Their bytes leave the files at a later checkpoint: the next reclaim with free pages, SQLite's auto-checkpoint at 1,000 frames, or the last connection closing. The reader case of the new test measures the next reclaim. - What can still throw is unchanged. Another connection holding the write lock (`BEGIN IMMEDIATE`) makes the vacuum statement itself wait out the busy timeout and throw `SQLITE_BUSY`. Measured: `main` 5,021 ms and this PR 5,014 ms, both freelist unchanged, busy timeout 5,000 afterwards. - In that case the sweep logs its existing warning (`space reclaim on datasource 'X' failed (database is locked)`) and does not list the datasource. - The busy-timeout swap comes after the loop, so a throw inside the loop never reaches it. ## The fix through `SqlDriver` Same 25,754-page fixture: | condition | database file + `-wal` after the call | call | busy timeout after | |:--|:--|--:|--:| | WAL, no reader (was 103,149,568 + 4,255,992) | 16,384 + 0 | 101 ms, 109 ms | 5,000 | | DELETE journal | 16,384, no `-wal` | 127 ms | 5,000 | | WAL, reader in this process | 103,149,568 + 4,255,992 (unchanged; freelist 0) | 47 ms | 5,000 | | WAL, reader in another process | 103,149,568 + 4,255,992 (unchanged; freelist 0) | 66 ms | 5,000 | ## Tests `driver-sql/src/sql-driver-sqlite-reclaim-space.test.ts`, 7 cases (4 before). Each size is the database file plus the `-wal` file, read while the driver is open. Each freelist and page count is read from a second connection. - **WAL:** freelist 0, and `{ file: pages × 4096, wal: 0 }` while open and again after close. - **WAL with a reader holding a read transaction.** The reopened file has no WAL, the cache is set to about 100 pages, and 600 pages are free. The case asserts: - the call resolves in under half the busy timeout; - the busy timeout reads 5,000 afterwards; - freelist 0; - WAL growth under a quarter of the freed bytes. Measured: 0.08 for this PR, and 0.87 for both one statement and a fixed 1,000-page chunk. - Once the reader commits, the next reclaim returns everything: `{ file: pages × 4096, wal: 0 }`. - **The `auto_vacuum = NONE` control:** - the call resolves, so the loop stopped; - freelist and pages are unchanged; - the database file equals pages × 4096; - file + `-wal` is no larger than before. - **DELETE journal:** freelist 0, and `{ file: pages × 4096, wal: 0 }` while open. - **The empty-freelist control, for both journal modes:** nothing changes, the sizes included. - **Unchanged:** the pooled connection is handed back. `driver-turso/src/turso-remote-inherited-members.test.ts`: new case "local face: in WAL mode the freed bytes leave the -wal sidecar too, while the driver is still open". Suites on the merged head `effb34a8a`, all through `scripts/pm/os-verify-lock.sh`, each exit code recorded: - `pnpm --filter @objectstack/driver-sql test`: exit 0, 195 files passed and 11 skipped; 3,179 tests passed and 178 skipped. The count before the merge was 3,227; the merge brought in PR objectstack-ai#20427, which removed tests of its own. - `pnpm --filter @objectstack/driver-turso test`: exit 0, 74 files; 1,982 passed and 16 skipped. - `typecheck` for `driver-sql` and `driver-turso`: exit 0 each. `tsc --listFilesOnly` shows both changed test files are in each package's program. ## Ablations Every leg ran on the committed state through `scripts/ablation-replace.mjs`. In each, the anchor went from 1 hit to 0, and the restore was proven blob-equal to HEAD with an empty `git diff HEAD`. The `driver-sql` suite imports `./sql-driver.js` (source), so those legs needed no build. | leg | mutation | result | |:--|:--|:--| | A | final `TRUNCATE` checkpoint removed | 3 red: WAL `{16,384 + 1,334,912}` vs `{16,384 + 0}`; the reader case's follow-up `{16,384 + 296,672}`; the NONE control's pair grew 1,318,384 → 2,555,376. 4 green. | | B | one statement instead of chunks | 1 red: the reader case, WAL growth 2,142,400 vs a bound of 618,496. 6 green. | | C | fixed 1,000-page chunk instead of the derived one | 1 red: the reader case, 2,142,400 vs 618,496. 6 green. | | D | busy timeout not zeroed for the `TRUNCATE` | 1 red: the reader case, elapsed 5,034.99 ms vs under 2,500. 6 green. | | E | busy timeout not restored | 1 red: the reader case, busy timeout 0 vs 5,000. 6 green. | | F | the no-progress stop removed | the NONE control hung in the synchronous loop and was killed after 60 s (SIGKILL). | | A, dist | leg A built into `driver-sql`'s `dist/`, which `driver-turso` resolves | `ablation-dist-preflight` found the marker in 2 built files. `driver-turso`: 1 red (local face `{32,768 + 280,192}` vs `{32,768 + 0}`), 82 green. After the restore and a rebuild, `--absent` found the marker in none of the 6 built files, and the tree was clean. | In the first B–E runs, the red reader case also timed out its cleanup hook: the failed assertion left the reader's transaction open. The fixed case rolls the transaction back first. A rerun of leg B went red in 91 ms with no hook timeout. ## Gates - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` at `effb34a8a` derived 63 commands. All 63 ran, and every exit code was recorded before any pipe. 62 exited 0; `check-empty-changeset --base origin/main` exited 1 (next section). - `--ran`: 63 derived, 63 run, 0 NOT-MEASURED, 0 UNRUN. - The `--ran` pass printed a STALE TREE warning: `origin/main` moved 6 commits after the merge, and `scripts/cross-package-test-inputs.mjs` changed in that range. Of those 6 commits, only PR objectstack-ai#20447 touches a driver: it changes the `driver-turso` constructor, and none of this PR's files. CI reads the merge ref. - `check:driver-conformance`: 50 covered, 0 DEBT, 0 exempt, both before (`8cdbe0c6e`) and after (`effb34a8a`). - `pnpm lint` is CI's run. The narrowed run: `eslint --no-inline-config --format json` over the 3 changed `.ts` files reports 3 files, 0 errors and 0 warnings. `ESLint.isPathIgnored` answers false for each, so all three are in `pnpm lint`'s population. `eslint.config.mjs` sets no `parserOptions.project` and no typed rule, so this diff cannot move the verdict of an untouched file. ## The one red gate: `check-empty-changeset` (a deliberate correction, for confirmation) This PR edits `.changeset/20106-reclaim-space-full-freelist.md`, which exists on the merge base. The gate refuses that by name, and its own text sets out two classes. This is the **deliberate correction** class, not a collision. - The removed paragraph says the `-wal` file "keeps its size until the last connection closes". After this PR it is truncated at the end of the call unless another connection is reading. - That note has not been released, so restoring it from the base would publish the false sentence. - The gate's prescription for this class is to leave it red and get the correction confirmed on the PR. `skip-changeset` is not applied and must not be: this PR publishes a `patch`. **For the seat: please confirm, or choose the other route.** The other route is to restore the 20106 file from the merge base. The gate then goes green, but the release would carry that sentence beside this PR's own changeset, which describes the new behaviour. ## Acceptance notes - **The file surface is widened by one file.** The claim names `.changeset/20426-*.md`, and this PR also edits `.changeset/20106-reclaim-space-full-freelist.md` (one paragraph removed). It is the same defect, a mechanical removal, a card that has already landed, and the same changeset gate family. - **Behind a long reader, the bytes wait.** When a reader holds a snapshot during the call, the database file keeps its size until a later checkpoint. `LifecycleService.sweep()` still lists the datasource as reclaimed. The next sweep that deletes rows returns it, and SQLite's auto-checkpoint or the last close returns it sooner. No producer is left worse off than on `main`, where the same reader left 197,580,000 bytes instead of 107,405,560. - **Partial progress is possible.** Chunks commit one by one. Another process can take the write lock between two chunks, and then the next chunk waits up to the busy timeout and may throw with the earlier chunks already committed. This was not measured. A one-statement vacuum waited and threw the same way, all or nothing. - **Blocking is shorter, not gone.** The call still blocks the event loop while it runs: 101 to 276 ms at 25,754 pages on this shared box, against 315 to 351 ms for PR objectstack-ai#20425's single statement. - The remote `TursoDriver` route, `SqliteWasmDriver`, `LifecycleService` and `packages/spec` are untouched. --- _Generated by [Claude Code](https://claude.ai/code/session_01N8TPEsoJxPsdSdNKGnNGEN)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…with the local face's code instead of answering [] (objectstack-ai#20461) Fixes objectstack-ai#20424 Clause-②: no (narrowing) Seat ruling: the amended claim 5872640432 on objectstack-ai#20424 rules this change an accept-set narrowing: `Clause-②: no (narrowing)`, `@objectstack/driver-turso` `minor` with the BREAKING banner, and the ADR-0087 disposition `not-required (already-registered driver-sql-unresolvable-where-column-refused)`. ## Summary On the Turso **remote** face, `aggregate`, `find`, `findOne` and `count` now answer a missing table or a missing column the way the local face of the same driver answers it over the same file: `DATABASE_ERROR` / 500, `INVALID_FIELD` / 400 or `INVALID_FILTER` / 400, and never "no rows". Two catches in `RemoteTransport` read the backend's `no such table` / `no such column` as `[]`. They now let the error out, and `TursoDriver`'s remote read exits classify it with the local face's inherited seam, `SqlDriver.aggregateBackendFault`. No code is minted, nothing in `driver-sql` or `spec` is edited, and no classification is copied. Measured head: `daad09aa1` (this branch after merging `origin/main` at `3cf644938`). Patch round 1 changed only the changeset and this body, then merged `origin/main` at `2304b1608`: head `3b56eb769`, with `packages/drivers` byte-identical to `daad09aa1` (see "Patch round 1" below). ## H1: reproduced on `main` before any change Base `6e3e5462c`. The remote face is a `TursoDriver` over a real `@libsql/client` on a `file:` database, and the local control is a `TursoDriver` over the same file (a throwaway probe, not committed). "Declared field, column absent" is a field the object declares and the table lacks. | read | local | remote | |:--|:--|:--| | row 1: `aggregate`, mapped table really absent | `DATABASE_ERROR` / 500 | `[]` | | row 2: `aggregate` grouped by a declared field, column absent | `INVALID_FIELD` / 400 | `[]` | | row 3: `find` whose `where` names that field | `INVALID_FILTER` / 400 | `[]` | | `findOne`, the same `where` | `INVALID_FILTER` / 400 | `null` | | `find` with a projection and that `where` | `INVALID_FILTER` / 400 | `[]` | | `count`, the same `where` | `INVALID_FILTER` / 400 | `DATABASE_ERROR` / 500 | | `aggregate` summing that field | `INVALID_FIELD` / 400 | `[]` | | `aggregate` whose `where` names that field | `INVALID_FILTER` / 400 | `[]` | | `find` ordered by that field | the rows, unordered | `[]` | | `find` projecting that field | the rows | the rows | | `find`, mapped table absent | `DATABASE_ERROR` / 500 | `DATABASE_ERROR` / 500 | | controls: a real table and column | the rows | the rows | H1 holds for all three card rows. **H4 holds too**: rows 1 to 3 answered the same way for a managed object whose synced table or column was dropped under it (`aggregate` `[]`, `aggregate` grouped `[]`, `find` `[]`, where the local face refused with 500 / 400 / 400). ## H2: where the `[]` came from, and why each catch existed - **`RemoteTransport.aggregate`'s catch.** `git log -S` finds it in the method's first version, `101d5c345` ("Add i18n, analytics, turso aggregate, auth date normalization", 2026-05-16). It had no comment, no test and no card, and nothing in that commit names a first-boot or not-yet-created table. The local face refuses both conditions (a missing table since objectstack-ai#11455, a missing column since objectstack-ai#11541), so under the order's rule there is no case to keep. The catch is removed. - **`RemoteTransport.find`'s `$select` backstop.** It arrived with the migration from the cloud repository (`06ba03627`). Its comment names one case, a list view that projects fields the object lacks, and that case is kept: the projection is still dropped and the rows answer. Its terminal `return []` (no projection to drop, or the retry failed too) was the pre-objectstack-ai#8790 behaviour it mirrored. The local face's terminal became a refusal in `716ac9bf8`, and this copy never followed. The unit pin `still returns empty when even SELECT * fails (e.g. unknown table)` asserted that `[]`, and its stub threw `no such column`, not a table error: a real `no such table` was already rethrown by this catch. It is replaced, with the same input and the opposite assertion. ## H3: the seam. The mechanism is falsified; the seam is reachable The hypothesis was a field check that decides before the statement runs. The local face has none for this condition: the ingress field checks pass because the field *is* declared. The local face decides **after** the statement runs, from the backend's error. `count` and `findRows` send an unresolvable column to `unresolvableFilterColumnRefusal` and everything else to `backendStatementFault`. `aggregate` goes through `aggregateBackendFault`, which attributes the column to the groupBy, the aggregation or the `where` from the caller's own query. All of those compositions are `protected` on `SqlDriver`, and `TursoDriver` inherits them. The class predicate they share, `isUnresolvableColumnError`, is a module function that `@objectstack/driver-sql` does not export. So: - The remote `aggregate` exit calls `aggregateBackendFault(object, query, error)` with the caller's own query. That is the local exit verbatim. - The remote `find` / `findOne` / `count` exits call the same method with the `where` alone. With no groupBy and no aggregation, arm 1 cannot fire. Arm 2 is `unresolvableFilterColumnRefusal(object, error, where)`, and the terminal is `backendStatementFault`, which is the local exit. The two can differ only on a recognised wording whose column name does not parse, and that cannot arise on libSQL, whose only wording is `no such column: NAME`. - A remote door compiles and executes inside one transport call, while the local face guards only the execution. So the new `remoteReadFault` first returns anything that already declares a numeric `status` unchanged. That is the same "is it already ours" gate `backendStatementFault` applies, so the classifier sees what the local one sees. The transport's own compile refusals and the timeout envelope keep their answers. The `where` handed over is the caller's own, from before `toRemoteFilter` rewrote it, so the read-scope provenance marks decide whether the column is named, exactly as they do locally. ## What changes - `packages/drivers/driver-turso/src/remote-transport.ts`: - `aggregate`: the catch is removed, and the backend's error leaves the method. - `find`: the ladder has the local face's two rungs, the projection and then the ORDER BY (the new one), each rebuilt with the caller's `where`, which neither drops. The terminal throws the last rung's error instead of answering `[]`. - `packages/drivers/driver-turso/src/turso-driver.ts`: - `remoteReadExit(object, query, read)` ends in a new private `remoteReadFault`: the status gate, then `aggregateBackendFault`. - The remote `aggregate` arm now goes through that exit. `find`, `findOne` and `count` pass the caller's `where`. - One sentence in `refuseRemoteColumnMap`'s docblock moves to the past tense: the `[]` it described is now a refusal, and the 501 refusal stays. A parenthetical sentence is also added after it, saying so: that read is now refused `INVALID_FILTER` / 400, and the 501 refusal stays the answer. - No change to `RemoteTransport`'s or `TursoDriver`'s published signatures. The constructor (PR objectstack-ai#20447) and the write doors are untouched. ## Bounded in-place fixes, declared 1. **`count`'s remote exit.** For the same `where`, local answers `INVALID_FILTER` / 400 and remote answers `DATABASE_ERROR` / 500. This is not one of the card's three rows. It shares `remoteReadExit` with `find`, and leaving it would reopen the split objectstack-ai#8790 ruled out: a list view calls both halves. ① Same class: a remote read exit answers an unresolvable `where` column differently from the local face. ② Mechanical, with a pinned shape: the local seam, called. ③ Inside the claimed surface (the remote read arms of `turso-driver.ts`), and no other claim holds those lines. ④ Same package tests. 2. **The ORDER BY rung.** Without it, removing the terminal `[]` turns "ordered by a column the table lacks" from `[]` into an `INVALID_FILTER` refusal that tells the caller their filter was wrong (measured, ablation leg D below). The local face answers the rows, unordered (objectstack-ai#3821). The registered migration entry `driver-sql-unresolvable-where-column-refused` already says the ladder drops an ORDER BY and keeps the rows. ① The same `return []` line. ② Mechanical: the local ladder's second rung. ③ The claimed catch, in the claimed file. ④ Same package tests. ## Tests - New `turso-local-remote-missing-table-column-parity.test.ts`: 22 cases over a real `@libsql/client` `file:` database, with a local driver over the same file. - The card's three rows as local-and-remote pairs, for a federated and a managed object (6 cases). Each asserts `code` + `status` on both faces. - The neighbours: `findOne`, find with projection and `where`, `count` (federated and managed), `sum` over the field, `aggregate` with that `where`, and ORDER BY and projection recoveries answering the literal rows on both faces (8 cases). - Controls: literal answers for real tables and columns on both faces (6 cases). An undeclared aggregate function is refused with the same envelope on both faces, with no statement sent. A synthetic already-enveloped refusal passes the remote exit unchanged. - `remote-transport-unknown-select.test.ts`: the `[]` pin is replaced as described under H2. - At the measured head `daad09aa1`: `pnpm --filter @objectstack/driver-turso exec vitest run --maxWorkers=2` gave `Test Files 75 passed (75)` and `Tests 2003 passed | 16 skipped (2019)`. `pnpm --filter @objectstack/driver-turso typecheck` exited 0, and `tsc --listFiles` includes both test files. Run after rebuilding the dependency closure on the merged tree. ### Reverse verification The fix was committed first (`a10647239`). Every leg went through `node scripts/ablation-replace.mjs`: the anchor hit 1 → 0, the blob changed, and the restore was proven by blob == HEAD and an empty `git diff HEAD`. An outer trap re-verified the restore against the HEAD blob. The subject resolves from `src/` through a relative import (vitest, no alias), so no `dist/` is in the path. Suites: the new parity file, the unit file and the objectstack-ai#20107 external-object parity file (79 cases). Directions were predicted in the test header before running: - **A**, `aggregate`'s `[]` catch restored: **6 failed / 73 passed**, exactly the six aggregate pairs. - **B**, the find terminal back to `return []`: **5 failed / 74 passed**. That is the find, findOne and find-with-projection pairs, plus the replaced unit pin. `count`, `aggregate` and ORDER BY stayed green. - **C**, `remoteReadFault` reduced to `backendStatementFault`: **10 failed / 69 passed**, every 400 pair. Both missing-table pairs stayed green, since they answer 500 on both faces anyway. - **D**, the ORDER BY rung deleted: **1 failed / 78 passed**, the ORDER BY case. The remote face refused with the unnamed `INVALID_FILTER` wording ("A filter on object 'ext_t' names a column the database could not resolve …") where the local face answered the rows. - **E**, the status gate deleted: **1 failed / 78 passed**, the synthetic case, re-worded as `INVALID_FIELD` / 400. The real compile-refusal control stayed green, as predicted: no real refusal text parses as a backend column fault today. A first harness dry run was refused by the tool itself (its replacement contained the anchor, so the anchor count did not drop), and it restored. Nothing was measured there. ### Gates at `daad09aa1` `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 63 commands (5 paths vs merge base `3cf644938`). All 63 were run, and each exit code was written to a file before any pipe. Three gates first answered PREREQUISITE NOT MET (exit 3): `check:dual-build-cjs-loads`, `check:lean-entry-closure` and `check:type-check-debt`. I built every package with `pnpm turbo run build --filter='./packages/*' --filter='./packages/*/*' --concurrency=2`: 71 of 71 tasks succeeded. All three then exited 0. The dist-sweeping `check:dts-closure` (71 packages), `check:sourcemap-no-sources-content` (68) and `check:published-files` were re-run on the full build and exited 0. `--ran` answered: `63 derived famil(ies) accounted for — 63 run, 0 NOT-MEASURED (a DERIVED zero — all 63 recorded an exit code and none of them is 3)`. **Driver conformance ledger:** - Before, at `6e3e5462c`: `OK — 50 covered cell(s), 0 in the DEBT ledger, 0 exempt.` - After, at `daad09aa1`: `OK — 50 covered cell(s), 0 in the DEBT ledger, 0 exempt.` - The `driver-turso` row is `ok` in every column at both readings. **Lint, narrowed:** `eslint --no-inline-config --format json` over the 4 changed `.ts` files gave 4 files, 0 errors and 0 warnings. - The population comes from `eslint.config.mjs`: `**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}` minus its ignores. - The file count is from the JSON. - The effective `parserOptions` are `{ecmaVersion: latest, sourceType: module}`, with no `project`. All 6 active rules are per-file AST rules, so type-aware linting is off and this diff cannot move a verdict on any untouched file. The full `pnpm lint` is CI's. ## Clause-② and the changeset `.changeset/20424-turso-remote-missing-table-column-refused.md` ships `@objectstack/driver-turso` `minor`, with `Clause-②: no (narrowing)`, the **BREAKING** banner in the launch-window form (`check-changeset-no-major` refuses `major`), a FROM → TO line with the fix, and the ADR-0087 disposition `not-required (already-registered driver-sql-unresolvable-where-column-refused)`. This follows the seat's ruling in the amended claim 5872640432. The precedent the ruling reads: - objectstack-ai#8790 (`716ac9bf8`, `find` `[]` → `INVALID_FILTER` / 400, the same shape as row 3) declared **BREAKING accept-set narrowing**: `minor`, with an ADR-0087 `registered` disposition and the migration entry `driver-sql-unresolvable-where-column-refused`. - That registered entry's `surface` already names "`driver-sql` (and its `TursoDriver` / `SqliteWasmDriver` subclasses)" and prescribes this change's remedy (name a real column, or run schema sync). So no new entry is owed; the `aggregate` legs are the same drifted-schema family with the same remedy. Remote-face users meet the refusal for the first time here, so the banner is owed. - objectstack-ai#11541 (`ef52884a8`, `aggregate` 500 → `INVALID_FIELD` / 400) shipped as `patch` with no narrowing: a refusal that changed its code, not an accept set. - `RemoteTransport.find` and `RemoteTransport.aggregate`, exported from the package root, now raise the backend's error where they answered `[]`. The changeset says so. ## Files - `.changeset/20424-turso-remote-missing-table-column-refused.md` (+33 / -0) - `packages/drivers/driver-turso/src/remote-transport.ts` (+45 / -22) - `packages/drivers/driver-turso/src/turso-driver.ts` (+90 / -15) - `packages/drivers/driver-turso/src/remote-transport-unknown-select.test.ts` (+15 / -4) - `packages/drivers/driver-turso/src/turso-local-remote-missing-table-column-parity.test.ts` (+345 / -0) - `packages/drivers/driver-turso/src/turso-local-remote-external-object-parity.test.ts` (+3 / -2, a header prediction line only) In total, +531 / -43 over 6 files, under the 5,000-line threshold. No governed surface. ## Patch round 1 The seat's amended claim 5872640432 answered the round-0 open question with B, and accepted the two bounded in-place fixes and the H3 route. This round changes only the changeset and this body, with no code or test change: - The changeset: `patch` → `minor`; `Clause-②: no` → `Clause-②: no (narrowing)`; the **BREAKING** banner; the ADR-0087 marker `not-required (already-registered driver-sql-unresolvable-where-column-refused)` with its reason; a FROM → TO line naming the refusals, the fix, and `RemoteTransport.find` / `aggregate` now raising where they answered `[]`. - This body: the `Clause-②` line, the seat-ruling line under it, the "Clause-② and the changeset" section, the Files line and one Acceptance note. - `origin/main` merged at `2304b1608` (a true merge commit, five incoming commits, none on a `driver-*` path). Head `3b56eb769`. `git diff daad09a 3b56eb7 -- packages/drivers` is empty. After rebuilding the dependency closure on the merged tree: `pnpm --filter @objectstack/driver-turso exec vitest run --maxWorkers=2` gave `Test Files 75 passed (75)` and `Tests 2003 passed | 16 skipped (2019)`, and `typecheck` exited 0. - Changeset gates at `3b56eb769`, each exit code recorded before any pipe: `pnpm check:adr-0087-registration` exit 0; `node scripts/check-changeset-no-major.mjs --base origin/main --event` (this body) exit 0; `node scripts/check-empty-changeset.mjs --base origin/main` exit 0. ## Patch round 2 After the at-tier review 5873239611 (PASS at `3b56eb769`) and the seat's amended claim 5873267997, this round is text only plus a merge, with no logic or test-assertion change: - `remote-transport.ts`, the `$`-prefixed-key comment: its sentence saying `find()`'s `no such column` backstop "swallows the error into `[]` anyway" under `SQLITE_DQS=0` was made false by this PR. It now says the backstop refuses, as the local face does (`INVALID_FILTER` / 400), and that it used to answer `[]`. - `turso-local-remote-external-object-parity.test.ts`, the objectstack-ai#20107 suite's header prediction for its first ablation leg: `aggregate` now refuses the missing table as `DATABASE_ERROR` / 500, instead of answering `[]` in place of the sum. - `origin/main` merged at `acd009521` (a true merge commit), carrying PR objectstack-ai#20447 (`bea6d2ea3`, the constructor region of `turso-driver.ts`). The merge was clean. Head `0c47b7bbe`. - On the combined tree, after rebuilding the dependency closure: `pnpm --filter @objectstack/driver-turso test` gave `Test Files 76 passed (76)` and `Tests 2036 passed | 18 skipped (2054)` (the added file and cases are objectstack-ai#20447's), and `typecheck` exited 0. - `dispatch-gates --commands` at `0c47b7bbe` derived the same 63 commands. After a full package build (71 of 71), all 63 exited 0 on the first pass. `--ran`: `63 derived famil(ies) accounted for — 63 run, 0 NOT-MEASURED (a DERIVED zero — all 63 recorded an exit code and none of them is 3)`. Driver conformance: `OK — 50 covered cell(s), 0 in the DEBT ledger, 0 exempt.` Narrowed eslint over the 5 changed `.ts` files: 0 errors, 0 warnings. - Not changed, outside the claimed surface: the same objectstack-ai#20107 header's third prediction ("A filter on the renamed field answers `[]`") is also out of date after this PR. With the column-map refusal deleted, that filter would now be refused `INVALID_FILTER` / 400. It is prediction prose, and no assertion depends on it. ## Acceptance notes - **The shared predicate is not exported.** `isUnresolvableColumnError` is not exported from `@objectstack/driver-sql`, so the remote find/count exit reaches it through `aggregateBackendFault` with a `where`-only query. An exported predicate, or a protected `where`-exit on `SqlDriver` shared by `count` and `findRows`, would be the plainer seam. This card may not edit `driver-sql`. Carrier: none. - **The transport keeps an inline recognizer.** `RemoteTransport.find` still recognises the unresolvable-column class inline (`no such column`, or `column` + `does not exist`) to gate its ladder. That pre-existing copy of the predicate lacks the MySQL arm, which libSQL never speaks. - **An unmeasured edge.** If a ladder rung fails with an error that is not a column error, the local face refuses `INVALID_FILTER` (unnamed wording) and this face answers `DATABASE_ERROR` / 500. No producer is known. - **Standalone transport use.** `RemoteTransport.find` and `RemoteTransport.aggregate`, used on their own without `TursoDriver`, now raise the backend's error where they answered `[]`. The changeset says so. - **`main` moved after the measured merge.** Round 0 did not re-merge `b28550818` (additive in `packages/spec` only). Patch round 1 merged `origin/main` at `2304b1608` (five commits, none on a `driver-*` path). It then moved once more, to `fbeb56e4b` (one `packages/spec` test pin, `turbo.json` and `scripts/cross-package-test-inputs.mjs`, no `driver-*` path). Patch round 2 merged `origin/main` at `acd009521`, which carries both. PR objectstack-ai#20447 (objectstack-ai#20200) landed as `bea6d2ea3`, is merged here, and driver-turso was re-tested on the combined tree (see "Patch round 2"). --- _Generated by [Claude Code](https://claude.ai/code/session_01N8TPEsoJxPsdSdNKGnNGEN)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #20200
Clause-②: no (narrowing)
The
Clause-②line above is the amended claim's (comment 5869128452, unchanged in 5871001040), copied as it stands. The changeset carries the same value.Session
session_01N8TPEsoJxPsdSdNKGnNGEN(PM dispatch,domain:engineseat 1, mode:subagent), branchclaude/issue-20200-turso-remote-sync-refused. The branch starts ate01d34730, which already contains PR #20422 and PR #20425. It was then merged withorigin/mainat87c37aec1in a true merge commit. Every reading below was taken at head2242ad513unless it says otherwise.What changes
new TursoDriver(...)refuses two configurations it used to build while ignoring one of their keys. Both are refused withVALIDATION_ERROR/ 400, beforesuper(), after the constructor's three existing refusals, so no configuration they already refuse gets a different message:syncUrlunder a forcedmode: 'remote'. A remote url besidesyncUrlwith nomodewas already refused, as a replica on a remote url (localEngineDefect), so only a forced remote mode reaches this refusal.syncwith nosyncUrl, in every mode: local, replica and remote. An emptysyncUrlcounts as unset, which matchesdetectMode,connect(),sync()and the spec's refinement.Each refusal's message is
@objectstack/spec'sTursoConfigSchemaissue message for that key, byte for byte. Per the seat's ruling (option A of the report5869079328), each is a module-level constant inturso-driver.ts:REMOTE_MODE_SYNC_URL_REFUSALandSYNC_WITHOUT_SYNC_URL_REFUSAL. The parity test holds each constant equal to the schema's issue, read from the built spec dist. No newpackages/specexport.The message must be true where it is thrown (seat ruling 2, a declared cross-lane text edit). The spec's
syncUrl-under-remote text said "the turso driver never handssyncUrlto the remote client and runs no sync, so the setting changes nothing". That clause now reads "the turso driver refuses this configuration when it starts", the form its sibling refusals intursoTransportIssuesuse. The rest of the message is byte-identical. The three copies now read the same: the spec, the driver'sTursoConfigSchemamirror and the constructor constant. Thesyncmessage is reused unchanged. The ADR-0087 entry18.turso-config-transport-mismatch-refusedhas itsreasonsentence on stored rows corrected. It now says the constructor also refusessyncUrlunder a forced remote mode andsyncwith nosyncUrlwhen the datasource boots, citing #20200. Patch round 1 also put its header comment and its "One more it builds and then ignores" clause in the past tense, bounded by #20200. Nothing else in the entry moved.check:generatedthen provedsrc/migrations/registry.tsstale, because the registry embeds the entry text, andcheck:generated --fixregenerated it: a 4-line text diff. No gate refused editing a registered entry.H1: before and after (dist probe, 9 configs)
The same scratch script ran against
packages/drivers/driver-turso/dist/index.mjs. Before is atdbddf02c1(origin/main when the first round measured). After is this branch's build.dbddf02c1)libsql://+mode: 'remote'+syncUrl+syncisSyncEnabled()true, no interval,sync()rejectsSYNC_NOT_SUPPORTEDsyncUrlmessagelibsql://+mode: 'remote'+syncUrlsyncUrlmessagefile:+mode: 'remote'+syncUrl+syncisSyncEnabled()true, no interval,sync()rejectsSyncNotSupported("File")syncUrlmessagelibsql://+mode: 'remote'+sync, nosyncUrlisSyncEnabled()false,sync()a no-opsyncmessagelibsql://(no mode) +sync, nosyncUrlsyncmessagelibsql://+mode: 'remote'isSyncEnabled()falsefile:+mode: 'replica', nosyncUrlreplica,isSyncEnabled()false,sync()a no-opsync, nosyncUrlsyncmessage (thesynckey, not the rider)file:+sync, nosyncUrl, no modesyncignoredsyncmessageH4: what a stored row answers at boot now
buildTursoDriverConfig(packages/services/service-datasource/src/turso-driver-config.ts, read and not edited) forwards a stored row'ssyncUrl,syncandmodeunparsed. PR #20199's ADR-0087 entry is semantic, so no D2 conversion rewrites such a row either. A row stored before PR #20199 with a remote url,mode: 'remote'andsyncUrl, or withsyncand nosyncUrl, therefore reaches the constructor as written:factory.createthrows the refusal (default-datasource-driver-factory.tsfor open-core,turso-driver-factory.tsfor the host default).DatasourceConnectionServicecatches it and records the datasource asfailed-degraded, with "datasource NAME: connect failed — MESSAGE".OS_ALLOW_DRIVER_CONNECT_FAILUREis set. Otherwise it is left unconnected with a warning.ok: false, with "Failed to build driver: MESSAGE".Before this change the same row booted, reported sync as enabled, and never synced. No in-repo caller reads
isSyncEnabled()or calls the driver's sync outside@objectstack/driver-turso's own tests. The changeset's FROM → TO paragraph carries these readings and the way out: dropsyncUrl/syncfrom a remote config, or use afile:url with the remote insyncUrl.The rider stays:
mode: 'replica'on afile:url with nosyncUrlThis configuration still constructs and runs as a plain local database. The table's H1 rider row shows it: a declared replica that never syncs. It is deliberately not refused here. A constructor-only refusal would make construction refuse a configuration both
TursoConfigSchemacopies accept, which reopens #19977's defect class in reverse (a datasource that authors clean and fails at boot). The parity table would go red on its row "file: under a forced mode: 'replica'", andturso-driver-unrecognised-url-refusal.test.tspinsFILE: + mode 'replica', no syncUrlas accepted. Closing it needs the spec half and the constructor half together, and the spec's accept set is outside this card, so the seat files it as its own card. The new test file pins the rider as still accepted, so whoever closes it moves that pin on purpose.Tests
2242ad513@objectstack/driver-tursovitest, whole package@objectstack/driver-tursotypecheck (tsc --noEmit)--listFilesOnlyon the pre-merge commit shows both touched test files in the program@objectstack/specvitest--project local, 3 shards@objectstack/spectypecheck (tsc + scripts +check:test-typecheck)The 18 skips are the parity table's forced-mode rows for the mirror, which strips
mode: 16 before, plus the two new forced-modesyncrows.spec/turso-config-constructor-parity.test.ts: the fourinertrows flip toctor: 'refuse'(threesyncUrl, onesync). Foursyncrows are added: a remote url, a forced remote, a forced replica onfile:, and an emptysyncUrl. Theinertfloor moves from at least 4 to exactly 0, with floors added forsyncUrl(at least 3),sync(at least 5) and the sync-key refusals (at least 8). A new table,SYNC_KEY_REFUSALS, asserts for each of those 8 rows that the constructor'serror.messageequals the spec issue's message.turso-driver-ignored-sync-key-refusal.test.ts(new): both refusals are asserted as the envelope (code+status) plus the message's first sentence, acrosslibsql://,https://,file:,:memory:, forced remote, forced replica and an emptysyncUrl.createTursoDriver()is covered too. Controls: forced remote with nosyncUrlconnects and reports sync off; afile:replica besidesyncUrl;syncbesidesyncUrlunder forcedmode: 'local'; the rider.packages/spec/src/data/driver/turso.test.ts: one assertion and its test title pinned the old clause ("never handssyncUrlto the remote client and runs no sync"). They now pin the new clause. This file is not named in the claim's surface; the change is the mechanical consequence of ruling 2's text edit (see Deviations).Reverse verification, via
scripts/ablation-replace.mjsfrom the committed state (turso-driver.ts blobafe3ad31). The driver tests import../turso-driverfrom source, so no build is involved. Directions were predicted before each run, and all three matched:if (mode === 'remote' && config.syncUrl) {becameif (false && …) {, mutation landed (anchor 1 → 0, blobafe3ad31→5f9aca88). 11 failed / 169 passed: exactly the 5syncUrlcases in the new file, plus the 3syncUrlrows' constructor verdicts and their 3 message pins. Restored: blob == HEAD,git diff HEADempty.if (config.sync && !config.syncUrl) {got the same mutation (blob →32b6a28c). 16 failed / 164 passed: the 6synccases, plus 5 constructor verdicts and 5 message pins. Restored the same way.syncUrlconstant, after its first sentence (blob →084c8d96). 3 failed: exactly the 3syncUrlmessage pins, while every verdict and first-sentence case stayed green. This proves the byte-equality pin is what holds the copy. Restored the same way.Gates
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack, run after the last commit at2242ad513, lists 9 paths vs merge base87c37aec1and 91 commands. Every command ran, with its exit code written to disk before any pipe.--ranreconciliation reads91 derived famil(ies) accounted for — 89 run, 2 NOT-MEASURED (2 DERIVED from a recorded exit 3), with 0 UNRUN.check:dual-build-cjs-loads(dozens of workspace packages have nodist/) andcheck:type-check-debt(it needs a whole-workspace build). Both need a full-workspace build, which is CI's run.check:doc-formula-expressionsandcheck:lean-entry-closurefirst answered exit 3. They are exit 0 after building@objectstack/lintand@objectstack/objectqlwith their closures.check-adr-0087-registration→not-required (already-registered)forturso-config-transport-mismatch-refused, clause-② narrowing, BREAKING, bang;check-changeset-no-majorexit 0;check:migration-registry→registry.ts is current;check:driver-conformance,check:nul-bytes,check:doc-authoring,check:test-source-alias,check:cross-package-test-inputs,check:api-surface,check:authorable-surface,check:docs,check:spec-changesandcheck:upgrade-guide→ exit 0.check-changeset-fixed, speccheck:meta-url-spelling,check:authz-resolver,check:error-code-casingandcheck:filter-alias-parity.eslint --no-inline-config --format jsonover the 8 changed TS files reports 8 files, 0 errors and 0 warnings. The population iseslint.config.mjs's lint object,files: ['**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}']; the changeset.mdis outside it. Invariance holds because that config never enables type-aware linting (noparserOptions.project, per its own header), so this diff cannot move any untouched file's verdict. The fullpnpm lintis CI's.@objectstack/service-datasource,@objectstack/runtimeand@objectstack/cli. Their turso fixtures go throughbuildTursoDriverConfigor a capturing constructor and never build the real driver. The downstream real-driver constructions are both in dogfood'sdate-bucket-parity-turso.test.ts:new TursoDriver({ url: ':memory:' }), and at:144a remote{ url: 'libsql://test-db.turso.io', authToken }. Neither carries a sync key, so neither refusal touches them.Driver-conformance ledger (
lanes/engine.md):check:driver-conformancereadOK — 50 covered cell(s), 0 in the DEBT ledger, 0 exemptboth before (dbddf02c1) and after (2242ad513).driver-tursoisokon all 10 case-sets both times. No movement.Deviations (declared)
packages/spec/src/data/driver/turso.test.ts: one assertion and one test title, rewritten because ruling 2's text edit makes the old pin false. It is outside the claim's listed surface ("nothing else inpackages/spec"). Without it the spec suite goes red.turso-driver.tsoutside the constructor body. It carries the two module constants and a small throw helper (refuseIgnoredSyncKey) besiderefuseSuppliedClientTimeout, as ruling 1 prescribes. It also extends the TSDoc onTursoDriverConfig.syncUrland.syncso the declared config names the two new refusals. No other region of the file changed.Acceptance notes
src/spec/turso.zod.ts) stripsmode, so its copy of thesyncUrl-under-remote text is still unreachable through a parse, and no test can hold it equal. This diff applies the same one-clause edit there, so the three copies read identically today.syncUrlunder a forced remote mode, orsyncwith nosyncUrl, as constructed and ignored now says what holds since driver-turso:new TursoDriveracceptssyncUrl/syncundermode: 'remote'and ignores them —isSyncEnabled()answers true, no sync runs, andsync()rejectsSYNC_NOT_SUPPORTED#20200 (list below). Agit grep -n -i ignoreover the touched files finds no such wording left. What remains is onlypackages/drivers/driver-turso/README.md's list of constructor refusals. It is scoped to the local-engine refusals and does not name the two new ones: incomplete, not false (the review's reading), and a docs follow-up. It is not runtime text.@objectstack/service-datasource(turso-driver-config.test.ts, themode: 'remote'+syncUrlkey-list case) spell a configuration the real constructor now refuses. They exercisebuildTursoDriverConfigonly and never construct the driver, and the package is fenced, so they are unchanged.Patch round 1 (text only, head
7052b9111)The at-tier review 5870987840 PASSed
2242ad513and escalated comments this diff made false. With theinertfloor at 0, the driver no longer constructs and ignores anything the schemas refuse. Per the amended claim 5871001040, those comments are corrected here as text only. No logic and no test assertion changed.packages/spec/src/data/driver/turso.zod.ts:new TursoDriveracceptssyncUrl/syncundermode: 'remote'and ignores them —isSyncEnabled()answers true, no sync runs, andsync()rejectsSYNC_NOT_SUPPORTED#20200, constructed and then ignored";syncUrl-under-remote bullet ("which the driver accepts and then IGNORES … That arm has no constructor refusal behind it") is now in the past tense and says the constructor refuses it too since driver-turso:new TursoDriveracceptssyncUrl/syncundermode: 'remote'and ignores them —isSyncEnabled()answers true, no sync runs, andsync()rejectsSYNC_NOT_SUPPORTED#20200;syncUrl-under-mode: 'remote'arm aside" now says that exception has been a constructor refusal too since driver-turso:new TursoDriveracceptssyncUrl/syncundermode: 'remote'and ignores them —isSyncEnabled()answers true, no sync runs, andsync()rejectsSYNC_NOT_SUPPORTED#20200;packages/drivers/driver-turso/src/spec/turso.zod.ts: section heading 2a "refuses or ignores" → "refuses"; the intro "or constructs and ignores" → "or, until driver-turso:new TursoDriveracceptssyncUrl/syncundermode: 'remote'and ignores them —isSyncEnabled()answers true, no sync runs, andsync()rejectsSYNC_NOT_SUPPORTED#20200, constructed and ignored"; the superRefine comment corrected as in the spec.turso.test.ts: the block comment "(which it constructs and ignores)" → "(since driver-turso:new TursoDriveracceptssyncUrl/syncundermode: 'remote'and ignores them —isSyncEnabled()answers true, no sync runs, andsync()rejectsSYNC_NOT_SUPPORTED#20200 that includessyncUrlunder a forcedmode: 'remote', which it used to construct and ignore)".new TursoDriveracceptssyncUrl/syncundermode: 'remote'and ignores them —isSyncEnabled()answers true, no sync runs, andsync()rejectsSYNC_NOT_SUPPORTED#20200, constructed and ignored)", and "nothing it accepts but the declared-and-ignored keys" → "… but aninertrow's declared-and-ignored key (none since driver-turso:new TursoDriveracceptssyncUrl/syncundermode: 'remote'and ignores them —isSyncEnabled()answers true, no sync runs, andsync()rejectsSYNC_NOT_SUPPORTED#20200)".18.turso-config-transport-mismatch-refused:new TursoDriveracceptssyncUrl/syncundermode: 'remote'and ignores them —isSyncEnabled()answers true, no sync runs, andsync()rejectsSYNC_NOT_SUPPORTED#20200";reasonclause "One more it builds and then ignores … Nothing the constructor accepts is refused, that key aside" is in the past tense and bounded by driver-turso:new TursoDriveracceptssyncUrl/syncundermode: 'remote'and ignores them —isSyncEnabled()answers true, no sync runs, andsync()rejectsSYNC_NOT_SUPPORTED#20200 ("One more it built and then ignored until driver-turso:new TursoDriveracceptssyncUrl/syncundermode: 'remote'and ignores them —isSyncEnabled()answers true, no sync runs, andsync()rejectsSYNC_NOT_SUPPORTED#20200 …"; "Nothing the constructor accepts is refused (at spec:TursoConfigSchemaaccepts turso configs the driver refuses or ignores — a remoteurlbesidesyncUrlor a forced replica/localmode, a non-file:replica,syncUrl/syncundermode: remote#19977 that key was the one exception; since driver-turso:new TursoDriveracceptssyncUrl/syncundermode: 'remote'and ignores them —isSyncEnabled()answers true, no sync runs, andsync()rejectsSYNC_NOT_SUPPORTED#20200 there is none)");registry.tswas regenerated bygen:migration-registry, its hunk equal to the entry's.inertmechanism's own definition (with none today), the "before" measurement table in the new test file, and@libsql/clientignoringsyncUrlbeside a remote url (a fact about the client).Readings at
7052b9111. That head is the round's two commits plus a true merge oforigin/main8cdbe0c6e, which regeneratedregistry.tsfor its own new entry; the registry is current, with 308 semantic entries.2242ad513before the push: 35 check runs, 32 success, 3 skipped, 0 failed. Test Core 1/6, 3/6 and 5/6 and Type Check · workspace all concluded success.@objectstack/driver-turso75 files · 2014 passed · 18 skipped, typecheck exit 0.@objectstack/spec--project localin 3 shards: 6025 + 5141 (1 todo) + 5473 passed, exit 0 on each. A first shard-2 attempt was killed by the runner's own timeout and re-run. Spec typecheck exit 0.dispatch-gates --commandsfinds 9 paths vs merge base8cdbe0c6eand 91 commands, all run with exit codes recorded before any pipe.--ranreads91 derived famil(ies) accounted for — 89 run, 2 NOT-MEASURED (2 DERIVED from a recorded exit 3), 0 UNRUN; the two NOT MEASURED arecheck:dual-build-cjs-loadsandcheck:type-check-debt(whole-workspace build; both green in CI at2242ad513). Also run: the five roster families under touched directories, and speccheck:generated("All 15 generated artifacts are up to date"), all exit 0.check:driver-conformanceis unchanged at 50 covered, 0 DEBT. The narrowed eslint run finds 8 files, 0 errors. The control-byte scan finds nothing.Generated by Claude Code