Skip to content

fix(driver-turso)!: new TursoDriver refuses syncUrl under a forced remote mode, and sync with no syncUrl (#20200) - #20447

Merged
objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/issue-20200-turso-remote-sync-refused
Sep 28, 2026
Merged

objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/issue-20200-turso-remote-sync-refused

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor

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:engine seat 1, mode:subagent), branch claude/issue-20200-turso-remote-sync-refused. The branch starts at e01d34730, which already contains PR #20422 and PR #20425. It was then merged with origin/main at 87c37aec1 in a true merge commit. Every reading below was taken at head 2242ad513 unless it says otherwise.

What changes

new TursoDriver(...) refuses two configurations it used to build while ignoring one of their keys. Both are refused with VALIDATION_ERROR / 400, before super(), after the constructor's three existing refusals, so no configuration they already refuse gets a different message:

  • syncUrl under a forced mode: 'remote'. A remote url beside syncUrl with no mode was already refused, as a replica on a remote url (localEngineDefect), so only a forced remote mode reaches this refusal.
  • sync with no syncUrl, in every mode: local, replica and remote. An empty syncUrl counts as unset, which matches detectMode, connect(), sync() and the spec's refinement.

Each refusal's message is @objectstack/spec's TursoConfigSchema issue message for that key, byte for byte. Per the seat's ruling (option A of the report 5869079328), each is a module-level constant in turso-driver.ts: REMOTE_MODE_SYNC_URL_REFUSAL and SYNC_WITHOUT_SYNC_URL_REFUSAL. The parity test holds each constant equal to the schema's issue, read from the built spec dist. No new packages/spec export.

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 hands syncUrl to 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 in tursoTransportIssues use. The rest of the message is byte-identical. The three copies now read the same: the spec, the driver's TursoConfigSchema mirror and the constructor constant. The sync message is reused unchanged. The ADR-0087 entry 18.turso-config-transport-mismatch-refused has its reason sentence on stored rows corrected. It now says the constructor also refuses syncUrl under a forced remote mode and sync with no syncUrl when 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:generated then proved src/migrations/registry.ts stale, because the registry embeds the entry text, and check:generated --fix regenerated 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 at dbddf02c1 (origin/main when the first round measured). After is this branch's build.

config before (dbddf02c1) after
libsql:// + mode: 'remote' + syncUrl + sync constructs, connects, isSyncEnabled() true, no interval, sync() rejects SYNC_NOT_SUPPORTED refused, VALIDATION_ERROR / 400, syncUrl message
libsql:// + mode: 'remote' + syncUrl the same refused, syncUrl message
file: + mode: 'remote' + syncUrl + sync constructs, connects, isSyncEnabled() true, no interval, sync() rejects SyncNotSupported("File") refused, syncUrl message
libsql:// + mode: 'remote' + sync, no syncUrl constructs, isSyncEnabled() false, sync() a no-op refused, sync message
libsql:// (no mode) + sync, no syncUrl the same refused, sync message
CONTROL libsql:// + mode: 'remote' constructs, connects, isSyncEnabled() false unchanged
RIDER file: + mode: 'replica', no syncUrl constructs as replica, isSyncEnabled() false, sync() a no-op unchanged (see the rider section below)
RIDER + sync, no syncUrl the same refused, sync message (the sync key, not the rider)
file: + sync, no syncUrl, no mode local, sync ignored refused, sync message

H4: 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's syncUrl, sync and mode unparsed. 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' and syncUrl, or with sync and no syncUrl, therefore reaches the constructor as written:

  • factory.create throws the refusal (default-datasource-driver-factory.ts for open-core, turso-driver-factory.ts for the host default).
  • DatasourceConnectionService catches it and records the datasource as failed-degraded, with "datasource NAME: connect failed — MESSAGE".
  • Under ADR-0062 D5, boot fails fast when objects bind to that datasource or are routed to it, or when it is boot-critical, unless OS_ALLOW_DRIVER_CONNECT_FAILURE is set. Otherwise it is left unconnected with a warning.
  • A test connection answers 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: drop syncUrl / sync from a remote config, or use a file: url with the remote in syncUrl.

The rider stays: mode: 'replica' on a file: url with no syncUrl

This 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 TursoConfigSchema copies 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'", and turso-driver-unrecognised-url-refusal.test.ts pins FILE: + mode 'replica', no syncUrl as 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

suite at 2242ad513 result
@objectstack/driver-turso vitest, whole package 75 files · 2014 passed · 18 skipped · exit 0
@objectstack/driver-turso typecheck (tsc --noEmit) exit 0; --listFilesOnly on the pre-merge commit shows both touched test files in the program
@objectstack/spec vitest --project local, 3 shards 564 files · 16640 passed · 1 todo (6026 + 5141 + 5473), exit 0 on each shard
@objectstack/spec typecheck (tsc + scripts + check:test-typecheck) exit 0

The 18 skips are the parity table's forced-mode rows for the mirror, which strips mode: 16 before, plus the two new forced-mode sync rows.

  • spec/turso-config-constructor-parity.test.ts: the four inert rows flip to ctor: 'refuse' (three syncUrl, one sync). Four sync rows are added: a remote url, a forced remote, a forced replica on file:, and an empty syncUrl. The inert floor moves from at least 4 to exactly 0, with floors added for syncUrl (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's error.message equals 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, across libsql://, https://, file:, :memory:, forced remote, forced replica and an empty syncUrl. createTursoDriver() is covered too. Controls: forced remote with no syncUrl connects and reports sync off; a file: replica beside syncUrl; sync beside syncUrl under forced mode: 'local'; the rider.
  • packages/spec/src/data/driver/turso.test.ts: one assertion and its test title pinned the old clause ("never hands syncUrl to 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.mjs from the committed state (turso-driver.ts blob afe3ad31). The driver tests import ../turso-driver from source, so no build is involved. Directions were predicted before each run, and all three matched:

  1. if (mode === 'remote' && config.syncUrl) { became if (false && …) {, mutation landed (anchor 1 → 0, blob afe3ad31 → 5f9aca88). 11 failed / 169 passed: exactly the 5 syncUrl cases in the new file, plus the 3 syncUrl rows' constructor verdicts and their 3 message pins. Restored: blob == HEAD, git diff HEAD empty.
  2. if (config.sync && !config.syncUrl) { got the same mutation (blob → 32b6a28c). 16 failed / 164 passed: the 6 sync cases, plus 5 constructor verdicts and 5 message pins. Restored the same way.
  3. One byte of the copy: a doubled space inside the syncUrl constant, after its first sentence (blob → 084c8d96). 3 failed: exactly the 3 syncUrl message 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 at 2242ad513, lists 9 paths vs merge base 87c37aec1 and 91 commands. Every command ran, with its exit code written to disk before any pipe. --ran reconciliation reads 91 derived famil(ies) accounted for — 89 run, 2 NOT-MEASURED (2 DERIVED from a recorded exit 3), with 0 UNRUN.

  • NOT MEASURED (exit 3, PREREQUISITE NOT MET): check:dual-build-cjs-loads (dozens of workspace packages have no dist/) and check:type-check-debt (it needs a whole-workspace build). Both need a full-workspace build, which is CI's run.
  • Run twice: check:doc-formula-expressions and check:lean-entry-closure first answered exit 3. They are exit 0 after building @objectstack/lint and @objectstack/objectql with their closures.
  • Notable readings:
    • check-adr-0087-registration → not-required (already-registered) for turso-config-transport-mismatch-refused, clause-② narrowing, BREAKING, bang;
    • check-changeset-no-major exit 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-changes and check:upgrade-guide → exit 0.
  • Roster families under a touched directory, also run: five artifact-roster families whose roster sits under a directory this diff touches, all exit 0: check-changeset-fixed, spec check:meta-url-spelling, check:authz-resolver, check:error-code-casing and check:filter-alias-parity.
  • Narrowed lint: eslint --no-inline-config --format json over the 8 changed TS files reports 8 files, 0 errors and 0 warnings. The population is eslint.config.mjs's lint object, files: ['**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}']; the changeset .md is outside it. Invariance holds because that config never enables type-aware linting (no parserOptions.project, per its own header), so this diff cannot move any untouched file's verdict. The full pnpm lint is CI's.
  • Not run locally, left to CI:
    • the whole-workspace type-check lanes;
    • the Test Core, Dogfood and Build Core jobs;
    • downstream suites of @objectstack/service-datasource, @objectstack/runtime and @objectstack/cli. Their turso fixtures go through buildTursoDriverConfig or a capturing constructor and never build the real driver. The downstream real-driver constructions are both in dogfood's date-bucket-parity-turso.test.ts: new TursoDriver({ url: ':memory:' }), and at :144 a 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-conformance read OK — 50 covered cell(s), 0 in the DEBT ledger, 0 exempt both before (dbddf02c1) and after (2242ad513). driver-turso is ok on 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 in packages/spec"). Without it the spec suite goes red.
  • turso-driver.ts outside the constructor body. It carries the two module constants and a small throw helper (refuseIgnoredSyncKey) beside refuseSuppliedClientTimeout, as ruling 1 prescribes. It also extends the TSDoc on TursoDriverConfig.syncUrl and .sync so the declared config names the two new refusals. No other region of the file changed.

Acceptance notes

  • The mirror's dormant copy is now kept equal by this diff. The driver mirror (src/spec/turso.zod.ts) strips mode, so its copy of the syncUrl-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.
  • The spec wording item from the report is done here (ruling 2).
  • Comments that described the ignored sync keys: corrected in patch round 1 (the amended claim 5871001040). Every comment in this diff's files that described syncUrl under a forced remote mode, or sync with no syncUrl, as constructed and ignored now says what holds since driver-turso: new TursoDriver accepts syncUrl / sync under mode: 'remote' and ignores them — isSyncEnabled() answers true, no sync runs, and sync() rejects SYNC_NOT_SUPPORTED #20200 (list below). A git grep -n -i ignore over the touched files finds no such wording left. What remains is only packages/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.
  • Loader fixtures in @objectstack/service-datasource (turso-driver-config.test.ts, the mode: 'remote' + syncUrl key-list case) spell a configuration the real constructor now refuses. They exercise buildTursoDriverConfig only 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 2242ad513 and escalated comments this diff made false. With the inert floor 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.

Readings at 7052b9111. That head is the round's two commits plus a true merge of origin/main 8cdbe0c6e, which regenerated registry.ts for its own new entry; the registry is current, with 308 semantic entries.

  • CI on 2242ad513 before 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.
  • Tests: @objectstack/driver-turso 75 files · 2014 passed · 18 skipped, typecheck exit 0. @objectstack/spec --project local in 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.
  • Gates: dispatch-gates --commands finds 9 paths vs merge base 8cdbe0c6e and 91 commands, all run with exit codes recorded before any pipe. --ran reads 91 derived famil(ies) accounted for — 89 run, 2 NOT-MEASURED (2 DERIVED from a recorded exit 3), 0 UNRUN; the two NOT MEASURED are check:dual-build-cjs-loads and check:type-check-debt (whole-workspace build; both green in CI at 2242ad513). Also run: the five roster families under touched directories, and spec check:generated ("All 15 generated artifacts are up to date"), all exit 0.
  • Other readings: check:driver-conformance is 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

…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>
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation protocol:data tests tooling labels Sep 28, 2026
@github-actions

github-actions Bot commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/driver-turso, @objectstack/spec, touching 6 documentable anchor(s).

⛔ 1 release-owned page(s) name something this change touched. These are read-only:

  • content/docs/releases/v17/17-4.mdx (via TursoDriverConfig (symbol, a top-level interface))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 3 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 140 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 8cdbe0c6e5af61613749f22105c03652fc0f4043 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 9b4415682fd9b39647ff0dd1b4a986f4e8f3e37e — the merge of head 7052b91110cbaea15fdb07422ed4f3af41f8afb0 into base 8cdbe0c6e5af61613749f22105c03652fc0f4043, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 8cdbe0c6e5af61613749f22105c03652fc0f4043 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 2242ad5138d6a7857b54f576d06784196124b991
Local-runs: none

① Derived judgments

Inputs 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 87c37aec1: 9 files, +328/−29, head is a true merge of origin/main into the branch's three commits); the check-runs on the head, read last. No worktree, build, test, gate or ablation. Byte-equality of the two constants was checked by comparing the source literals from git show as text in the scratchpad — no project code was run.

Accept-set changes the diff implies, each judged:

  1. new TursoDriver refuses syncUrl under a forced mode: 'remote' — RIGHT. The predicate is mode === 'remote' && config.syncUrl with mode = detectMode(config); with syncUrl truthy and no forced mode detectMode answers replica for every url class (:1649, :1669, :1678), so only a forced remote reaches it, exactly as the PR body and the constant's docblock say. Placed after the three existing refusals and before super(), so no already-refused configuration changes message. Ruling 1's shape.
  2. sync with no syncUrl refused in every mode — RIGHT. config.sync && !config.syncUrl; an empty syncUrl is unset by the same truthiness detectMode, connect() (:1819) and sync() (:3447) read and the spec's own refinement (cfg.sync && !cfg.syncUrl) applies. The seat's planned decision 1, accepted in 5869128452.
  3. Nothing the parity table holds as accepted is refused — RIGHT. The control (forced remote, no syncUrl), a file: replica beside syncUrl, sync beside syncUrl under a forced mode: 'local', an empty syncUrl alone, and the rider (file: + mode: 'replica', no syncUrl, no sync) all still construct: table rows :92–:101 at head and the new file's CONTROLS block pin them. The rider plus sync is refused on the sync key, not the rider, consistent with turso: a datasource config with mode: 'replica' on a file: url and no syncUrl is accepted at authoring and at construction, then runs as a plain local database that never syncs #20437.
  4. The two constants are byte-equal to the schema's issue texts — RIGHT, verified: REMOTE_MODE_SYNC_URL_REFUSAL (362 chars) equals the spec's and the mirror's syncUrl literal; SYNC_WITHOUT_SYNC_URL_REFUSAL (178 chars) equals the spec's sync literal. The SYNC_KEY_REFUSALS pin (8 rows) holds them against the BUILT spec dist; its own verdict is Test Core's.
  5. The reworded spec clause ("the turso driver refuses this configuration when it starts") — TRUE for the constructor since this diff and TRUE as the schema's statement about the driver; the rest of the message is byte-identical (the two hunks are the same two lines); the mirror's dormant copy follows. The sync message is reused unchanged and stays TRUE. Ruling 2 delivered.
  6. The D3 entry edit — TRUE: buildTursoDriverConfig forwards syncUrl, sync and mode unparsed (:169–:206), the entry is semantic so no conversion rewrites a row, and the constructor now refuses both shapes at boot. registry.ts's hunk is byte-identical to the entry's: generator output only. No copy of the old sentence survives anywhere at head (git grep: 0 hits).
  7. not-required (already-registered turso-config-transport-mismatch-refused) — HONEST. The gate verifies only that the id resolves and pre-dates the base (it does: PR fix(spec)!: TursoConfigSchema refuses the turso configs the driver refuses or ignores (#19977) #20199 at 172b4cf30, before 87c37aec1), never coverage. Judged on content: the entry is this family's, its replacement already names the same three ways out the changeset's FROM → TO table gives, and its acceptanceCriteria ("re-save every turso datasource ... the driver builds from it") already prescribes what a stored row needs at boot; this diff adds the boot half to its reason. registered would be false (no new id) and no-migration-prescription is refused by the body's own FROM → TO block.
  8. Public surface — none moved: no export added in either package, TursoDriverConfig keys unchanged; the syncUrl / sync TSDoc extensions are TRUE; check:api-surface rides Lint & Repo Gates (success).
  9. H4 readings — TRUE at head: default-datasource-driver-factory.ts:1337 builds the real driver; DatasourceConnectionService records failed-degraded with datasource 'NAME': connect failed — REASON (:936); a test connection answers Failed to build driver: ... (datasource-admin-plugin.ts:707); ADR-0062 D5's fail-fast set (bound objects, external onMismatch: 'fail', bootCritical) shares OS_ALLOW_DRIVER_CONNECT_FAILURE.
  10. Blast-radius sentences — TRUE: isSyncEnabled() has no caller outside driver-turso (only the changeset names it); the only .sync() outside is sqlite-wasm's own handle; no example, template, skill or doc authors either shape (grep of mode: 'remote' and syncUrl across examples, templates, skills, content, docs). One imprecision in the PR body: "the one downstream real-driver construction is dogfood's :memory:" — dogfood also constructs { url: 'libsql://test-db.turso.io', authToken } (date-bucket-parity-turso.test.ts:144); neither carries a sync key, so both are untouched. Harmless.
  11. Parity-table sentences — TRUE by count at head vs merge-base: 59 vs 55 rows (four sync rows added), 18 vs 16 forced-mode rows (the 18 mirror skips: 16 plus the two new forced-mode sync rows), inert 0 vs 4; the floors (syncUrl ≥ 3, sync ≥ 5, sync-key refusals ≥ 8, inert exactly 0) are as the PR says.
  12. Every other changeset and PR-body sentence checked reads TRUE, with one FALSE: the Acceptance note "Comments that still describe the pre-change behaviour sit in fenced files" is FALSE for the driver mirror src/spec/turso.zod.ts — that file is in the amended claim's surface and in this diff (its :260–:265 hunk); it is TRUE for the spec copy ("nothing else in packages/spec"). The README is outside the listed surface but under no ⛔. Carried to ③.
  13. Acceptance note, pre-existing and not moved by this diff: for a config with two defects (sync with no syncUrl beside a refused url, e.g. a remote url under mode: 'local') the schema's first issue is on sync (the superRefine runs that check before tursoTransportIssues) while the constructor refuses on url first. No table row spells it; both doors refuse. Noted, not this card's.

Check-runs on 2242ad513, final read 2026-09-28T13:28:46Z: 33 runs — 26 success, 3 skipped (Build Docs, Console Pin Gate, Packed-tarball smoke), 0 failed, 4 IN PROGRESS: Test Core (1/6), Test Core (3/6), Test Core (5/6), Type Check · workspace. Required contexts: Lint & Repo Gates success (carries check:adr-0087-registration, check-changeset-no-major, check:migration-registry, check:doc-authoring, check:api-surface); Build Core success; Dogfood Regression Gate success (all three shards); Temporal Conformance (live PG + MySQL) success; Governed Surface Queue Guard success; Test Core in progress (shards 2, 4, 6 success; 1, 3, 5 in progress); Type Check · workspace in progress. The two families NOT MEASURED locally: check:type-check-debt → Type Check · debt ledger, success; check:dual-build-cjs-loads → Build Core, success. This record does not vouch for the in-progress runs: the byte pin and both packages' suites land in Test Core, and the landing waits on those shards and the workspace type-check.

② Semver level

  • @objectstack/driver-turso: minor with the BREAKING banner — RIGHT. An accept-set narrowing on the published constructor, shipped minor under the launch-window convention (scripts/check-changeset-no-major.mjs header), bang in the title, check-changeset-no-major green on the head.
  • @objectstack/spec: patch — RIGHT. Message text only, one reason sentence in a registered entry, and the regenerated registry; no accept set, key or export moves.
  • Clause-②: no (narrowing) — RIGHT and the TRUE answer: nothing widened, nothing added or renamed; the value is identical on the changeset and on the PR body's second line, both copied from claim 5869128452; (narrowing) is BREAKING and the changeset says so. Not skip-changeset.
  • FROM → TO for a stored pre-fix(spec)!: TursoConfigSchema refuses the turso configs the driver refuses or ignores (#19977) #20199 row: three rows plus the paragraph that names the boot consequence (factory.create throws, failed-degraded, ok: false on test, ADR-0062 D5 fast-fail, the OS_ALLOW_DRIVER_CONNECT_FAILURE hatch), the before-behaviour and the way out — sufficient. It does not name the edit door (Setup → Datasources, the datasource admin service); the entry's acceptance criteria does. Adequate.

③ Boundary flags

Dev flags (report 5870627032, PR body), each answered:

  1. Deviation, packages/spec/src/data/driver/turso.test.ts — TRUE (one title, one toContain; 2 lines) and minimal: the mechanical consequence of ruling 2, without which the spec suite reds. Accepted.
  2. Deviation, turso-driver.ts outside the constructor body — TRUE and minimal: two constants (ruling 1 prescribes them), a 7-line refuseIgnoredSyncKey in the envelope shape refuseSuppliedClientTimeout already uses, and two TSDoc extensions that read TRUE. Accepted.
  3. The rider stays — answered: filed as turso: a datasource config with mode: 'replica' on a file: url and no syncUrl is accepted at authoring and at construction, then runs as a plain local database that never syncs #20437 (open, read), pinned as accepted by the new test, reason given in the PR body and the changeset. Matches ruling 3.
  4. open_questions: none in the delivering report; round 1's A/B was ruled A in 5869128452 and A is what landed (module constants, parity pin, no spec export).

The three carriers, judged:

  • (a) driver-turso/README.md constructor-refusal list — NOT made FALSE. That list is scoped to the local-engine refusals (its lead sentence: the local and replica modes run through a local SQLite engine, "the constructor therefore refuses"), its config snippet already says sync "(requires syncUrl)", and no README sentence claims a sync key is ignored. Incomplete, not false; not owed in this PR — a docs follow-up in the dev's package.
  • (b) The comments beside tursoTransportIssues in both copies ("What the driver refuses at construction, or constructs and ignores" — mirror :393, spec :470) — made FALSE by this diff: with inert pinned at exactly 0, the driver constructs-and-ignores nothing the schemas refuse. The mirror's copy is in a file this diff edits and inside the claim's surface — it should be fixed in this PR; the spec's copy is fenced by "nothing else in packages/spec", so its fix is the seat's call, though ruling 2's rationale (text must be true) reaches it. Same class, same files, not declared by the dev: spec turso.test.ts:199–:201 "(which it constructs and ignores)"; the D3 entry's header comment ("together with the one combination the driver builds and then ignores") and its reason's present-tense "One more it builds and then ignores: syncUrl under a forced remote mode ... Nothing the constructor accepts is refused, that key aside", eight lines above the corrected sentence and contradicting it in tense (the "spec: TursoConfigSchema accepts turso configs the driver refuses or ignores — a remote url beside syncUrl or a forced replica/local mode, a non-file: replica, syncUrl/sync under mode: remote #19977." dateline makes it readable as history; an upgrade-guide reader gets both); the mirror's section heading :85 "refuses or ignores". ESCALATED to the seat: reword in this PR (four comment lines and two clauses of ledger text, the entry being already in the amended surface as "text only"), or carry as a filed note. None is a TSDoc, changeset or PR-body sentence and none is runtime text; the verdict does not turn on them. The PR body's "sit in fenced files" (①.12) is FALSE for the mirror and should be corrected when the body is next edited.
  • (c) service-datasource's turso-driver-config.test.ts key-list case (mode: 'remote' + syncUrl + sync: {}, :68–:85) — NOT made FALSE: it pins key forwarding only ("exposes exactly the keys it can emit") and asserts nothing about validity; its first case already spells a shape the constructor has refused since driver-turso: a remote url plus syncUrl is classified replica and handed a :memory: Knex connection, so every write lands in process memory and never reaches the remote #19893. Correctly left alone: the package is ⛔ in the claim. Acceptance note, as filed.

Implemented-by: claude/issue-20200-turso-remote-sync-refused
Reviewed-by: session_01N8TPEsoJxPsdSdNKGnNGEN

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>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 7052b91110cbaea15fdb07422ed4f3af41f8afb0
Local-runs: none

① Derived judgments

Delta review on top of the PASS record 5870987840 at 2242ad513. Inputs added and read: the seat's amended claim 5871001040 on #20200; the patch-round-1 os-dev-report 5872311757 (the newest on the card); the edited PR body (footer restored, session-URL form); git diff 2242ad513 7052b9111, separated into the PR's own delta (bd9ce0342, 1c1db32b1) and the true merge of origin/main 8cdbe0c6e (the head's two parents are 1c1db32b1 and 8cdbe0c6e); the check-runs on the new head, read last. Read-only throughout: no worktree, build, test, gate or ablation.

  1. The PR's own delta since 2242ad513 (git diff 2242ad513 1c1db32b1): 6 files, +58/−47 — comment lines in the spec schema (5 sites), the mirror (3 sites), the spec test (1), the parity test (2), the D3 entry's header comment and reason clause, and the registry lines regenerated from that entry. No statement, predicate, constant, test row, assertion, floor or refusal message moves; the only shipped text that moves is the entry's reason (ledger prose the upgrade guide renders). So ① and ② of 5870987840 carry over unchanged: the constructor refuses exactly the two shapes (forced-remote syncUrl; sync with no or an empty syncUrl, every mode), nothing the parity table holds as accepted is refused (control and rider included), the two constants are byte-equal to the schema's issue texts, the reworded spec clause is TRUE, the D3 stored-row sentence is TRUE, already-registered is honest, no public surface moves.
  2. Every rewritten comment and the entry's rewritten clauses — TRUE, each read at the new head:
  3. The five kinds left as they are, each still TRUE: "would ignore" (parity :20, :143; the new test file :4; turso-driver.ts :1054, :1541 — conditionals naming what the refusals prevent); the inert mechanism's definition (parity :20–:25, :78 "None today (driver-turso: new TursoDriver accepts syncUrl / sync under mode: 'remote' and ignores them — isSyncEnabled() answers true, no sync runs, and sync() rejects SYNC_NOT_SUPPORTED #20200)"); the new test file's "before" table (:18, under "On the built driver before the change"); @libsql/client ignoring syncUrl beside a remote url (the unchanged message text at spec :199 / mirror :195 and turso-driver.ts :1298 — a fact about the client); and the unrelated turso-driver.ts hits (:171, :854 the client's Config.timeout; :398 the deferral; :675 the renamed-field filter; :952; :1826 the fetch channel; :2027 a qualifier). turso-driver.ts :1263 ("would accept a declared syncUrl and then ignore") is a rejected-design conditional, TRUE; the changeset's "instead of building and ignoring them" / "used to build and then ignore" are past tense, TRUE.
  4. The PR body's corrected sentences: the Acceptance note on remaining comments (every such comment corrected; "a git grep -n -i ignore over the touched files finds no such wording left"; only the README's incomplete list remains) — TRUE, matches my grep; the dogfood sentence ("both in dogfood's date-bucket-parity-turso.test.ts: :memory: and at :144 a remote url with authToken; neither carries a sync key") — TRUE (:77, :96, :123, :159 and :144), and correctly scoped to downstream suites; the D3-entry sentence in "What changes" ("Patch round 1 also put its header comment and its 'One more it builds and then ignores' clause in the past tense, bounded by driver-turso: new TursoDriver accepts syncUrl / sync under mode: 'remote' and ignores them — isSyncEnabled() answers true, no sync runs, and sync() rejects SYNC_NOT_SUPPORTED #20200. Nothing else in the entry moved") — TRUE, the "clause" spanning through "that key aside" as the Patch-round section spells it; the "4-line text diff" still names round 2's regeneration, TRUE; the Patch round 1 section — TRUE: "true merge of origin/main 8cdbe0c6e, which regenerated registry.ts for its own new entry" (main's +71/−1 is the list-view-tabs-retired entry), "CI on 2242ad513 before the push: 35 check runs, 32 success, 3 skipped, 0 failed" (verified by a read of the old head: 35 runs, 32 success, 3 skipped), the "Left as they are, each true" list (judged above). The Clause-② provenance line now names 5869128452 and 5871001040 with one value — TRUE. The footer is present in the session-URL form.
  5. The merge brought nothing by hand into the PR's 9 files. The files differing from origin/main at the new head are exactly the 9. Of those, the merge moved only registry.ts, and its carry (1c1db32b1 → 7052b9111, +71/−1) is content-identical to what main brought between 87c37aec1 and 8cdbe0c6e (72 +/− lines and every context line equal; only hunk offsets differ, by the entry's own +2 lines above). The other 39 files the merge touched are main's, byte-equal to origin/main.
  6. Check-runs on 7052b9111, final read 2026-09-28T14:48:54Z: 49 runs — 42 success, 7 skipped, 0 failed, 0 in progress. Duplicate names are the PR-metadata workflows re-fired by the round's PR edits (Auto Label, Check PR Size, Check Changeset, the four claim guards), each success or skipped. Required contexts, all success: Lint & Repo Gates; TypeScript Type Check (and its four legs: source gates, consumer gates, debt ledger, workspace); Test Core (rollup and all six shards); Dogfood Regression Gate (rollup and three shards); Build Core; Temporal Conformance (live PG + MySQL); Governed Surface Queue Guard. The two families not measured locally: check:type-check-debt → Type Check · debt ledger, success; check:dual-build-cjs-loads → Build Core, success. Skipped: Build Docs, Console Pin Gate, Packed-tarball smoke, and the re-fired Auto Label / Check PR Size duplicates.

② Semver level

Unchanged from 5870987840 and carried: @objectstack/driver-turso minor with the BREAKING banner (accept-set narrowing on the published constructor under the launch-window convention); @objectstack/spec patch (message text, one entry's ledger text, regenerated registry); Clause-②: no (narrowing) on the changeset and on the PR body's second line, one value. The round publishes nothing new: the changeset is untouched (.changeset/20200-turso-remote-sync-refused.md is not in the delta), and the entry-text rewrite is inside the patch already declared. check-changeset-no-major and check:adr-0087-registration ride Lint & Repo Gates, success on the new head.

③ Boundary flags

  • The escalation in 5870987840 ③(b) is answered by the amended claim 5871001040 (every such comment, the entry's header comment and reason clause, text only) and delivered: the mirror's comment, the spec's comment, the spec test's comment, the entry's two clauses, and the sites the dev found beyond the four named (the spec's intro, syncUrl bullet and "arm aside" paragraph; the mirror's intro; the parity test's two comments), all within the claim's wording "every comment in this diff's files". Deviation (3) of the report declares them; accepted.
  • Deviation (1), the PR-body footer dropped by the relay's issue_patch: the seat restored it; the body now ends with the session-URL footer. Answered. Deviation (2), the worktree re-added clean at 2242ad513: no bearing on the diff. Deviation (4), the Clause-② provenance sentence naming both claims: TRUE.
  • open_questions: none. out_of_scope_findings: the README's incomplete refusal list (a docs follow-up, not false — my ③(a) reading, carried as filed) and the pre-existing schema-vs-constructor first-issue ordering for a doubly defective config (my ①.13, carried as filed). Nothing new is filed and nothing is owed.
  • The PR-body sentence judged FALSE in 5870987840 ①.12 ("sit in fenced files") is gone, replaced by the corrected Acceptance note. No sentence in the edited body, the changeset or the diff's TSDoc reads FALSE at this head.

Implemented-by: claude/issue-20200-turso-remote-sync-refused
Reviewed-by: session_01N8TPEsoJxPsdSdNKGnNGEN

VERDICT: PASS

veigajoao pushed a commit to veigajoao/objectstack that referenced this pull request Sep 29, 2026
…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>
veigajoao pushed a commit to veigajoao/objectstack that referenced this pull request Sep 29, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation protocol:data size/m tests tooling

Projects

None yet

2 participants