Skip to content

fix(driver-turso): remote mode constructs without better-sqlite3 — its Knex base is built with no connection (#20054) - #20073

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-20054-turso-remote-no-native-driver
Sep 25, 2026
Merged

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-20054-turso-remote-no-native-driver

Conversation

@objectstack-fleet

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

Copy link
Copy Markdown
Contributor

Fixes #20054

Clause-②: no (narrowing)

What this changes

A remote TursoDriver (libsql://, https://, wss:// and the other remote schemes) now constructs, connects and runs CRUD with better-sqlite3 not installed. That is the install package.json (an optional peer) and the README's "Dependencies by Mode" table give remote mode, for Vercel and Edge deployments.

The fix is one arm of TursoDriver.toKnexConfig. The SqlDriver base constructor always builds a Knex instance. Remote mode used to hand it { client: 'better-sqlite3', connection: { filename: ':memory:' } }. knex's Client constructor loads the dialect's native driver (initializeDriver, which runs require('better-sqlite3')) and builds a pool only when the config carries a connection. Remote mode now passes { client: 'better-sqlite3', useNullAsDefault: true } with no connection: the SQLite dialect's compiler, with no native module, no pool and no private :memory: database. The client is still spelled better-sqlite3, so isSqlite and every dialect-keyed rule the remote arms borrow from the base answer as before. SqlDriver is not touched, and no export changes.

Files:

  • packages/drivers/driver-turso/src/turso-driver.ts: the remote arm of toKnexConfig and its comment. Also the text of two remote refusals this change made false (see Deviations).
  • packages/drivers/driver-turso/src/turso-remote-no-native-driver.test.ts: new pin.
  • Two refusal test docblocks now use the past tense for the placeholder (comments only).
  • .changeset/20054-turso-remote-no-native-driver.md: @objectstack/driver-turso minor, BREAKING (patch round 1).

Measurements

All readings were taken in objectstack-ai/objectstack. Base: 3557f85fa5. Head: this branch. The probes used dist built at each tree. "Absent" means better-sqlite3 made unresolvable in a separate node process (a Module._resolveFilename hook). The pin uses a Module._load hook instead.

H1 (the throw), confirmed at base. knex 3.3.0 lib/client.js: if (this.driverName && config.connection) this.initializeDriver();, which calls Client_BetterSQLite3._driver() (require('better-sqlite3')).

construction, module absent base head
remote libsql://probe-db.example.turso.io throws Knex: run $ npm install better-sqlite3 --save constructs, transportMode = 'remote'
local :memory: (control) throws, same message throws, same message
local file: (control) throws, same message throws, same message
all three, module present construct construct

H2: what the remote face uses this.knex for. I wrapped driver.knex in a Proxy that records every property read and call after construction. The remote driver ran over a real @libsql/client file: client.

  • Construction: the SqlDriver constructor's knex(config) call, and installQueryTiming (this.knex.on for three events). Read from the source, not recorded.
  • 22 remote-armed doors: connect, checkHealth, initObjects, syncSchema, syncSchemasBatch, create, bulkCreate, find, findOne, count, aggregate, update, upsert, bulkUpdate, updateMany, execute, paginationTieBreaker, bulkDelete, deleteMany, delete, dropTable and disconnect. Plus the 4 refusals: beginTransaction, detectManagedDrift, planMediaColumnMove, setDeferredDdl(true). Zero reads, at base and at head.
  • The inherited helpers those arms call have no this.knex reference in their bodies (dist, 14 names): temporalFilterValue, temporalFilterColumnSql, the three sqlite*Sql rules, isNonTextColumn, formatInput, formatOutput, computeTenantField, calendarDayUpperBoundRewrite, orderKeysFor, registerExternalObject, toDateOnly and rawStatementFault.
  • Only the SqlDriver methods remote mode does not override reach it: introspectSchema (.raw), distinct (builder), findWithWindowFunctions (builder, .raw, .client), analyzeQuery and explain (builder, .raw), and reclaimSpace (.raw).
  • On this state, previewDeferredSchemaWork, flushDeferredSchemaDdl, applyMigrationEntries([]) and getSchemaSyncStats made zero reads. rotateShards threw before it reached Knex.
  • Remote disconnect() never calls super.disconnect(), so knex is never destroyed on the remote face.

So nothing on the remote face needs a live Knex, and nothing needs a compiling one either. A Knex with no connection was the smallest seam: it lives in toKnexConfig, and SqlDriver's constructor contract stays as it is.

H3: the inherited methods, before and after. A remote face over a libsql file: client holding rows in probe_t.

method base, module present head, present head, absent
introspectSchema() resolves { tables: {} } (silent) rejects Unable to acquire a connection same
distinct('probe_t', 'name') rejects DATABASE_ERROR / 500 same same
findWithWindowFunctions(…) rejects raw SqliteError no such table: probe_t rejects Unable to acquire a connection same
analyzeQuery / explain resolves { sql, bindings, client, error: 'EXPLAIN QUERY PLAN … no such table …' } resolves, same shape, error: 'Unable to acquire a connection' same
reclaimSpace() resolves (vacuumed the private database) rejects Unable to acquire a connection same
previewDeferredSchemaWork, flushDeferredSchemaDdl, applyMigrationEntries([]), getSchemaSyncStats resolve, no Knex read same same
rotateShards('probe_t') throws, no rotation policy same same
  • Loud to silent: 0. Silent to loud: 2 (introspectSchema, reclaimSpace).
  • The head readings with the module present and absent are byte-identical (diffed).
  • A side reading: at base, a remote driver that had run introspectSchema() kept the process alive after disconnect(). timeout 20 killed it (exit 124), because a pooled better-sqlite3 connection was never destroyed. At head, the process exits (no pool is built).

H4: in-repo constructions. git grep 'new TursoDriver(' on non-test .ts files: 9 hits, 3 of them real calls:

  • createTursoDriver and the plugin's onEnable in driver-turso/src/index.ts;
  • the turso arm of service-datasource/src/default-datasource-driver-factory.ts.

The runtime loader adds new TursoDriverCtor( in runtime/src/turso-driver-factory.ts. None of the four reads .knex. The datasource factory's sqlServerVersion goes through driver.execute, which is the remote transport.

Repo-wide reads of a driver's Knex outside driver-sql, non-test:

  • getKnex(): 0 calls (1 comment). Control: sql-driver.ts has 1 hit.
  • .knex reads: 2 files. metadata-protocol's resolveDriverClientName reads driver.config.client first, and that answer is unchanged (better-sqlite3). runtime/src/raw-foreign-key-fixture.ts is used by two SqlDriver integration tests and not by any Turso test.

Tests

  • New pin turso-remote-no-native-driver.test.ts: 10 tests.

  • Reverse verification. The fix was committed first. node scripts/ablation-replace.mjs restored connection: { filename: ':memory:' } in the remote arm: anchor 1 to 0, blob 227df64b3046 to a5b36d46ed8f. The pin went 6 failed, 4 passed, the direction predicted before the run:

    • construction and CRUD failed on knex's install error;
    • introspectSchema, module present, failed on expected [] to include 'probe_t';
    • all three inherited cases with the module absent failed at construction;
    • the two controls stayed green, and so did distinct and findWithWindowFunctions with the module present, which were already loud at base.

    After the restore, the blob equals HEAD and git diff HEAD is empty. The test imports ./index.js (source), so no rebuild was part of either leg.

  • pnpm --filter @objectstack/driver-turso test: 67 files, 1510 passed. typecheck: exit 0. tsc --listFiles includes all 4 changed .ts files.

  • pnpm --filter @objectstack/driver-sql test: 184 files passed, 11 skipped. 2845 tests passed, 170 skipped.

  • Consumers that construct a TursoDriver (targeted files):

    • service-datasource: default-datasource-driver-factory, datasource-pool-support, turso-bound-secret-authoring and turso-driver-config. 4 files, 147 passed.
    • runtime: turso-driver-factory.convergence and standalone-stack.libsql. 2 files, 38 passed.
    • dogfood: date-bucket-parity-turso, which builds a real remote driver. 1 file, 5 passed.
    • The CLI's storage-driver.test.ts builds a fake TursoDriver class, not this one. It is declared to CI, not run here.
  • pnpm check:driver-conformance, before (base, in a detached worktree) and after: OK — 50 covered cell(s), 0 in the DEBT ledger, 0 exempt, both.

Gates at head 014b58a689, derived from the real diff with node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack (merge base 3557f85fa): 61 commands. The dispatch's list (derived at 2c1011b) had 54, and all 54 are included. The 7 new ones are check-adr-0087-registration x2, check-empty-changeset x2, release-rehearsal-clone --self-test, check:objectui-changeset and check:pm-changeset-deadline-census.

  • 59 exit 0.
  • 2 NOT MEASURED (exit 3, PREREQUISITE NOT MET): check:dual-build-cjs-loads and check:type-check-debt. Both read the whole workspace's built output, and this worktree built only the closures listed above. lint.yml builds the whole closure first, so CI measures them.
  • check:dts-closure first exited 1. It named 26 packages whose dist/ I had built locally with OS_SKIP_DTS=1 to run the consumer suites. I removed those dist/ directories and re-ran it: exit 0, 46/46 declared declaration file(s) present across 8 package(s), driver-turso among them.
  • The --ran reconciliation with exit codes recorded: 61 derived, 59 run, 2 NOT-MEASURED, 0 UNRUN.
  • node scripts/check-issue-citations.mjs --base 3557f85fa5: exit 0, 3 citations resolve.
  • Out-of-gate control-byte scan of the 5 changed files: no match.
  • Not run locally, and owned by CI: the repo-wide pnpm lint, the CLI unit layer, and the path-scheduled CI jobs dispatch-gates names.

Deviations

  • Two runtime strings in turso-driver.ts outside the claimed arm. The NOT_IMPLEMENTED / 501 messages of the remote detectManagedDrift() and planMediaColumnMove() said "in remote mode that connection is a placeholder in-memory database holding none of this datasource's tables". This change made that false. They now say remote mode has no Knex connection. Each still gives a reason that holds at head, measured by calling the inherited methods on a head remote driver:

    • the no-argument drift call answers [] from the empty managedObjectFields;
    • the media planner answers an empty scan;
    • an explicit-objects drift call now fails on hasTable.

    First sentences, codes and statuses are unchanged, so both existing pins stay green. Their docblocks and the two comments on the overrides were edited to match.

Acceptance notes

Semver

@objectstack/driver-turso: minor, fix(driver-turso)!:, Clause-②: no (narrowing), with an ADR-0087 not-required (no-migration-prescription) disposition in the changeset. This was ruled in patch round 1, below.

Patch round 1

The PM seat answered the open question with B, for consistency with this lane's precedents on the same driver face: #19893 (PR #19971) and #19894 (PR #20014). On a remote driver, introspectSchema() and reclaimSpace() resolved before and reject now. That is a call the published driver answered and no longer answers, so it is declared, whatever the old answer's quality.

What changed, in .changeset/20054-turso-remote-no-native-driver.md only (commit 93d49b886e, no code change):

The version axis is unchanged: .changeset/19894-turso-remote-media-column-move-refusal.md already bumps @objectstack/driver-turso minor in the pending release.

Gate verdicts at 93d49b886e, against merge base 3557f85fa5:

  • check-changeset-no-major: ✓ This diff introduces no `major` bump. With this body as the --event payload: ✓ LEVEL AXIS: this PR declares clause-② `no (narrowing)`, and no package whose `packages/**/src/**` it moves is graded `patch`.
  • check-adr-0087-registration: ✓ check-adr-0087-registration: 1 declared-breaking changeset(s), each carrying an ADR-0087 disposition. The changeset is tagged [BREAKING+bang+clause-②-narrowing] not-required (no-migration-prescription).
  • check-empty-changeset: ✓ No empty-frontmatter changeset introduced by this diff (1 declaring changeset(s) added).

Generated by Claude Code

…ion, so it never loads better-sqlite3

Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx
Co-authored-by: Claude <noreply@anthropic.com>
…qlite3; refusal texts stop naming the placeholder

Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx
Co-authored-by: Claude <noreply@anthropic.com>
…-sqlite3; record the measured reverse verification

Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Sep 25, 2026
@github-actions

github-actions Bot commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/driver-turso, touching 4 documentable anchor(s).

2 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/data-modeling/drivers.mdx (via TursoDriver (symbol, a top-level class))
  • content/docs/plugins/packages.mdx (via TursoDriver (symbol, a top-level class))
What this run could not see
  • the SDK route bridge reached 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 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; 100 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 — 6 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 b76aad5f6fac71cbbcd4ea6bfdf21a59e2fd4d56 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from a75ccd92361f0e60765a4b14f3a282275b3c0958 — the merge of head 93d49b886e8e7d53dc5ee9e77e0d109bc89b7db6 into base b76aad5f6fac71cbbcd4ea6bfdf21a59e2fd4d56, 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 a75ccd92361f0e60765a4b14f3a282275b3c0958 && git checkout a75ccd92361f0e60765a4b14f3a282275b3c0958
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin b76aad5f6fac71cbbcd4ea6bfdf21a59e2fd4d56 93d49b886e8e7d53dc5ee9e77e0d109bc89b7db6 && git checkout -B drift-repro b76aad5f6fac71cbbcd4ea6bfdf21a59e2fd4d56 && git merge --no-ff 93d49b886e8e7d53dc5ee9e77e0d109bc89b7db6

node scripts/docs-audit/affected-docs.mjs --json b76aad5f6fac71cbbcd4ea6bfdf21a59e2fd4d56

⚠️ 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 b76aad5f6fac71cbbcd4ea6bfdf21a59e2fd4d56 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

… narrowing — minor, Clause-② no (narrowing), ADR-0087 not-required

Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 93d49b886e8e7d53dc5ee9e77e0d109bc89b7db6

Scope: 5 files (+343/−31) on merge base 3557f85fa5:

  • the changeset;
  • the remote arm of turso-driver.ts's toKnexConfig, and the two reworded refusal messages;
  • two refusal-test docblocks;
  • the new pin turso-remote-no-native-driver.test.ts.

No governed surface is touched. Review is owed for the .changeset prose.

Read: cards #20054 and #20055, the PR body, the diff, all 41 check-runs, AGENTS.md, and the code at head and base.

Measured in two detached worktrees (head and base, each with the driver-turso closure built and each dist verified to carry its own remote arm, both removed afterwards), under knex 3.3.0 and @libsql/client 0.17.4. One scenario ran per process, at base and head, with better-sqlite3 present and with it refused through a Module._load hook that counts load attempts.

① Derived judgments

  • The defect and the fix: RIGHT.
    • knex runs initializeDriver(), which requires better-sqlite3, only when config.connection is set.
    • With the module refused, at base: remote, local :memory: and local file: all throw Knex: run $ npm install better-sqlite3 --save, with 1 load attempt each.
    • At head: remote constructs, with 0 attempts. Local :memory: and file: still throw, with 1 attempt each (the control).
  • Remote creation, connection and CRUD without the module: RIGHT. Over a real libSQL file: client these all answer, with 0 load attempts, and the rows land in the libSQL file: connect, checkHealth, initObjects, create, bulkCreate, update, count, find, findOne, aggregate, execute, delete and disconnect.
  • No remote path reaches Knex: RIGHT.
    • A Proxy over driver.knex recorded 0 reads across 26 remote doors, at base and at head. That includes registerExternalObject, the four refusals and disconnect.
    • The query-timing listeners are unchanged.
    • At base, a remote driver that had run introspectSchema() kept the process alive after disconnect() (killed at the timeout). At head, the process exits.
    • resolveDriverClientName still answers better-sqlite3.
    • The serve banner's address goes from a false :memory: to (unknown).
  • The inherited methods: the PR's table is exact.
    • introspectSchema() resolved { tables: {} } while the remote file held the table; it now rejects with Unable to acquire a connection. reclaimSpace() resolved and now rejects the same way.
    • distinct() answers DATABASE_ERROR / 500 at both.
    • findWithWindowFunctions() and analyzeQuery() / explain() change only their error wording.
    • Tally: silent→loud 2, loud→silent 0.
  • Traced callers of introspectSchema():
    • ExternalDatasourceService.testConnection now answers ok: false where base answered ok: true, tableCount: 0;
    • the boot validation sweep rows unreachable (warn, boot continues) where base rowed a false missing_table;
    • the admin test prefers checkHealth, which TursoDriver routes to the transport, so it is unaffected.
  • The two reworded refusals: every sentence TRUE, and code and status are unchanged (NOT_IMPLEMENTED / 501, the first sentences byte-identical). managedObjectFields is empty after a remote initObjects.
  • Local and replica are unchanged: RIGHT. The config, introspectSchema, distinct, reclaimSpace and count are identical at base and head.
  • The pin discriminates: RIGHT. 10/10 at head. On the base tree it gives 6 failed / 4 passed, exactly the PR's reverse-verification claim.
  • CI at this head: 41 runs, 36 success, 5 skipped, 0 failures, and all seven required contexts are success.

② Semver level

@objectstack/driver-turso: minor, fix(driver-turso)!:, Clause-②: no (narrowing), with the ADR-0087 disposition not-required (no-migration-prescription): CONSISTENT.

The dev's original patch / no reading was wrong, for the reason the seat's ruling B gives.

③ Boundary flags

  • Changeset: every sentence is TRUE, including the BREAKING paragraph and the ADR-0087 marker's "no key, schema, object definition or stored representation moves". TursoDriverConfig and both TursoConfigSchema copies are untouched. "objectstack migrate meta has nothing to visit" is UNMEASURED, but consistent.
  • PR body: TRUE where measured. H4's .knex census says 2 files; there are 3. driver-sqlite-wasm's self-read is irrelevant to Turso. No judgment depends on it.
  • Not blocking, and belongs to driver-turso remote: inherited SqlDriver methods the remote face does not override answer from the placeholder :memory: Knex database — introspectSchema() returns no tables, distinct() a 500, findWithWindowFunctions() a raw SQLite error #20055: reclaimSpace() has an in-repo caller the PR does not name, LifecycleService.sweep() (packages/objectql/src/lifecycle/lifecycle-service.ts). On a remote-Turso datasource, a sweep that deleted rows now logs one warn and leaves the datasource out of report.reclaimed. At base it silently vacuumed a private database and reported it reclaimed. No data is at risk.
  • Docs: the README's "Dependencies by Mode" better-sqlite3 sentences go from FALSE at base to TRUE at head. Nothing in content/docs/data-modeling/drivers.mdx or content/docs/plugins/packages.mdx is made false. A pre-existing inexactness is unchanged: on a Node host, @libsql/client's Node entry loads its own native libsql binding.

Implemented-by: claude/issue-20054-turso-remote-no-native-driver
Reviewed-by: session_01Bvd69VPa6puiNzzPUroDBx

VERDICT: PASS

Isolated reviewer: a contract-review-tier subagent, fed only the cards, the PR and AGENTS.md; adopted by the seat.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review September 25, 2026 02:15
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 25, 2026
Merged via the queue into main with commit 2491729 Sep 25, 2026
43 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-20054-turso-remote-no-native-driver branch September 25, 2026 02:56
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…erited SqlDriver member (objectstack-ai#20055) (objectstack-ai#20104)

Fixes objectstack-ai#20055

Clause-②: yes (narrowing)

## What this changes

`TursoDriver extends SqlDriver`, and on the remote face (`transportMode
=== 'remote'`) 23 public `SqlDriver` members had no remote arm. Since PR
objectstack-ai#20073 the remote face builds its Knex with no connection, so those
members either failed with knex's `Unable to acquire a connection` (a
connectivity reading of a capability gap) or answered from state no
remote door fills. Each one is now answered on the remote database,
refused with `NOT_IMPLEMENTED` / 501, or shown to be true on this face
as inherited.

- `packages/drivers/driver-turso/src/turso-driver.ts`
- `REMOTE_FACE_ANSWERS`: every public `SqlDriver` member (52) mapped to
`remote`, `refused` or `inherited`, each `inherited` row with its
reason. The object `satisfies` a record keyed by `keyof SqlDriver`,
which is exactly the public instance members, so a member added to
`SqlDriver` fails this package's typecheck and build until its remote
answer is decided. Exported from the module, not from the package index.
- `refuseRemoteInheritedMember()`: one builder in the objectstack-ai#19845 / objectstack-ai#19894
shape (operation, what the caller cannot have, why, the 501 rationale,
the alternative). No new error code.
- New overrides, each with the local / replica arm unchanged:
`distinct`, `findWithWindowFunctions`, `analyzeQuery` (which `explain`
calls), `introspectSchema`, `getKnex`, `reclaimSpace`,
`supportsRotation`, `rotateShards`, `applyMigrationEntries`,
`setFileColumnsMovedResolver`.
- `packages/drivers/driver-turso/src/remote-transport.ts`: one member,
`compileDistinct(object, field, where)`, which compiles the `SELECT
DISTINCT` with the transport's own where compiler and does not execute
it. The `buildWhereSQL` comment's count of external call sites goes from
five to six.
-
`packages/drivers/driver-turso/src/turso-remote-inherited-members.test.ts`:
new pin, 76 tests.
-
`packages/drivers/driver-turso/src/turso-remote-media-column-move-refusal.test.ts`:
one assertion flips from `true` to `false`
(`setFileColumnsMovedResolver` on the remote face; see the table).
- `packages/drivers/driver-turso/README.md`: the "What remote mode
refuses" table gains seven rows, a new list covers the members answered
differently from local mode, and the Capabilities section is rewritten
from the code at this head (triage point 6, pointer 5824443053).
- `.changeset/20055-turso-remote-inherited-members.md`:
`@objectstack/driver-turso` minor, `fix(driver-turso)!:`.

## The 23 inherited members: measured at base and at head

Base is `d4c897e0e7` (past `249172975b`, PR objectstack-ai#20073). Each reading was
taken on a remote face over a real `@libsql/client` `file:` client
holding the rows a local control (`file:` URL, Knex) answered from,
driver dist built at each tree. The probe is a node script over the
built `dist/index.mjs`, kept in the session scratchpad and not
committed, with one fresh database per call and per face.

| member | remote at base | remote at head | answer | narrowing |
|:--|:--|:--|:--|:--|
| `distinct` | `DATABASE_ERROR` / 500 | the local values (5 call
shapes); unknown column `INVALID_FIELD` / 400 as local; tenant-scoped
call 501 | remote | no |
| `findWithWindowFunctions` | bare `Unable to acquire a connection` |
501 | refused | no |
| `analyzeQuery` | resolved: the local compiler's SQL plus `error` | 501
| refused | **yes** |
| `explain` | resolved, as `analyzeQuery` | 501 (it calls
`analyzeQuery`) | inherited | **yes** |
| `introspectSchema` | bare `Unable to acquire a connection` | 501 |
refused | no |
| `getKnex` | returned a Knex whose `raw('select 1')` failed | throws
501 | refused | **yes** |
| `reclaimSpace` | bare `Unable to acquire a connection` | resolves;
freelist 50 to 49 on an INCREMENTAL database | remote | no |
| `supportsRotation` | `true` | `false` | remote | **yes** |
| `rotateShards` | bare `Unable to acquire a connection` | 501 | refused
| no |
| `applyMigrationEntries` | resolved; a destructive entry with
`allowDestructive: true` came back `skipped` | 501, with or without
entries | refused | **yes** |
| `setFileColumnsMovedResolver` | `true`, then asked 0 times | `false` |
remote | **yes** |
| `commitTransaction`, `rollbackTransaction` | 501 through `commit()` /
`rollback()` | same | inherited | no |
| `registerExternalObject`, `registerObjectMetadata` | answer, no Knex |
same | inherited | no |
| `deferredSchemaObjectCount`, `previewDeferredSchemaWork`,
`flushDeferredSchemaDdl` | `0`, `[]`, `[]` (arming is refused) | same |
inherited | no |
| `getSchemaSyncStats` | `{ created: 0, existing: 0 }` (local control:
`{ created: 2, existing: 0 }`) | same | inherited | no |
| `dialectName`, `sqliteOpenedEmptyInMemory` | `'sqlite'`, `false` |
same | inherited | no |
| `temporalFilterValue`, `temporalFilterColumnSql` | equal to local |
same | inherited | no |

Loud to silent: 0. Answered to refused, or a declaration narrowed: 6
(bold). Failed to answered: 2 (`distinct` unscoped, `reclaimSpace`).
Failed to refused with the envelope: 5.

**Enumeration.** A TypeScript-parser walk (`ts.createSourceFile`) over
the `SqlDriver` class at base: 52 public instance members (45 methods, 5
getters, 2 fields). The `TursoDriver` class declared 29 of them,
including the control `find`, so 23 were inherited (19 methods, 4
getters). The compiler repeats the count at head: `REMOTE_FACE_ANSWERS`
has 52 rows and satisfies the `keyof SqlDriver` record (`tsc` exit 0).

## PM hypotheses

- **H1, confirmed and extended.** All 12 named members are inherited.
The walk adds 11: `registerExternalObject`, `registerObjectMetadata`,
`getSchemaSyncStats`, `getKnex`, `temporalFilterValue`,
`temporalFilterColumnSql`, `setFileColumnsMovedResolver` and 4 getters
(`sqliteOpenedEmptyInMemory`, `supportsRotation`,
`deferredSchemaObjectCount`, `dialectName`). "The ones that touch Knex
reject with `Unable to acquire a connection`" held for 4
(`introspectSchema`, `findWithWindowFunctions`, `reclaimSpace`,
`rotateShards`) and not for 3: `analyzeQuery` / `explain` and
`applyMigrationEntries` resolved, catching the knex failure into their
answers. `commitTransaction` / `rollbackTransaction` never reached Knex:
they call the overridden `commit` / `rollback`, which already refuse.
- **H2.**
- `introspectSchema`: refused. The inherited member reads each table
through `columnInfo()` and three SQLite pragma readers (columns, foreign
keys, primary keys, unique constraints), so a remote answer is a second
copy of four Knex readers, the copy objectstack-ai#19845 declined for drift.
- `distinct`: routed, and equal to the local control on 5 shapes (one
column, under a filter, a declared boolean presented as `true` /
`false`, a tenant object unscoped, `tenantId` on an object with no
tenant column). One shape is refused instead: `tenantId` on an object
with a tenant column. The local face applies the tenant scope there and
answers `['A', 'P']`, while no remote read applies it (see Acceptance
notes), so a routed answer would list every organization's values.
- Window functions: libSQL runs them (a raw `row_number() over (...)`
through `execute()` answers on both faces), but
`findWithWindowFunctions` compiles its spec through the Knex builder, so
routing means a second window compiler. Refused, and the message names
`execute()` as the remote alternative.
- Beyond H2: `analyzeQuery` / `explain` are refused rather than routed.
Neither has a caller in this repository (grep: 0), and a remote plan
needs the transport to hand out its compiled read statement, a second
new member. The route is about 40 lines if the seat wants it (open
question in the report).
- **H3, measured.** The real caller code ran against the head remote
driver and against a double that replays the base answer measured above.
The callers are unchanged by this PR, so the double gives the base
reading.
- `ExternalDatasourceService.testConnection`: base `ok: false`, error
`Unable to acquire a connection`. Head `ok: false`, error `Schema
introspection (introspectSchema()) is not supported by the Turso REMOTE
transport ...`. Local control `ok: true, tableCount: 1`.
- Its validation sweep (`validateAll`): base, one diff of kind
`unreachable` carrying the knex message. Head, the same kind carrying
the 501 message.
- `LifecycleService.sweep()`, one retention object and one rotation
object (`storage.strategy: 'rotation'`, no `ttl`):
- base: `report.errors` holds `sys_activity: Unable to acquire a
connection` (the rotation object is never bounded), and `reclaimed` is
`[]`, with two warns;
- head: the rotation object takes the `rotation-fallback` age reap,
`reclaimed` is `['com.objectstack.driver.turso']`, and there are no
warns.
- **H4.** Six members narrow (bold rows). The claim carries `Clause-②:
no (narrowing)`. This body declares `yes (narrowing)` instead, because
`RemoteTransport` is exported from the package and gains a public method
(`compileDistinct`), which widens the public surface. See Deviations.

## Tests

At head `3c1cc7a316`:
- `pnpm --filter @objectstack/driver-turso test`: 68 files, 1586 passed.
The new file contributes 76 of those tests.
- `pnpm --filter @objectstack/driver-turso typecheck`: exit 0. `tsc
--listFiles` includes all four changed `.ts` files.
- Consumer files that construct a `TursoDriver`, in `service-datasource`
(8 files that mention Turso): 226 passed. `runtime` and `dogfood`
consumers are declared to CI. Their closures were not built here.
- `pnpm check:driver-conformance`: `OK — 50 covered cell(s), 0 in the
DEBT ledger, 0 exempt`, both at base (a detached worktree at
`d4c897e0e7`) and at head.

**Reverse verification.** The fix was committed first. Each leg went
through `node scripts/ablation-replace.mjs`, which restored the file to
the HEAD blob (`git diff HEAD` empty) after each run. The test imports
`./turso-driver.js` from source, so no leg needed a rebuild.
- Table pin, leg 1: deleting the `introspectSchema` row gave `TS1360 ...
does not satisfy the expected type` (typecheck exit 2).
- Table pin, leg 2: adding a row `getKnexTwice` gave `TS2353 ...
'getKnexTwice' does not exist in type` (exit 2). The first spelling of
leg 2 was refused by the tool as a no-op anchor and never ran; it was
re-run with a new anchor.
- Behaviour, leg 3: removing the `introspectSchema` remote arm reddened
1 of 76 tests, the `introspectSchema` refusal case (`expected undefined
to be 'NOT_IMPLEMENTED'`), as predicted.
- Behaviour, leg 4: `supportsRotation` answering the base reddened 1 of
76, the `supportsRotation` case (`expected true to be false`), as
predicted.
- Behaviour, leg 5: removing the tenant-scoped `distinct` refusal
reddened 1 of 76, the tenant-scoped case (`expected a refusal, but the
call answered`), as predicted.

## Gates

Derived at head `3c1cc7a316` from the real diff with `node
scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` (no paths): 62 commands. The dispatch's list
(derived at `2c1011b`) had 54, and all 54 are included. The 8 new ones
are `check-adr-0087-registration` x2, `check-empty-changeset` x2,
`release-rehearsal-clone --self-test`,
`check:dispatcher-error-vocabulary`, `check:objectui-changeset` and
`check:pm-changeset-deadline-census`.
- 60 exited 0.
- 2 are NOT MEASURED (exit 3, `PREREQUISITE NOT MET`):
`check:dual-build-cjs-loads` and `check:type-check-debt`. Both need the
whole workspace built. CI builds it.
- `--ran` reconciliation, exit codes recorded: `62 derived, 60 run, 2
NOT-MEASURED, 0 UNRUN`.
- `check-adr-0087-registration`: `✓ check-adr-0087-registration: 1
declared-breaking changeset(s), each carrying an ADR-0087 disposition.`
- `check-changeset-no-major`: `✓ This diff introduces no major bump.`
With this body as the `--event` payload: `✓ LEVEL AXIS: this PR declares
clause-② yes (narrowing), and no package whose packages/**/src/** it
moves is graded patch.` `check-adr-0087-registration` tags the changeset
`[BREAKING+bang+clause-②-narrowing] not-required
(no-migration-prescription)`.
- `node scripts/check-issue-citations.mjs --base d4c897e`: exit 0, 6
citations resolve.
- Lint, narrowed and proven:
- the population is read from eslint itself: of the 6 changed files, the
2 Markdown files are outside the config ("no matching configuration");
- the 4 `.ts` files: 0 errors, 0 warnings (`--no-inline-config --format
json`, at `e004f9ce81`, with the same `.ts` bytes as head);
- invariance: `eslint.config.mjs` enables no type-aware linting (no
`parserOptions.project`), so this diff cannot change a verdict on an
untouched file.
- Out-of-gate control-byte scan of the changed files: no match.
- CI owns the repo-wide `pnpm lint` and the path-scheduled jobs.

## Deviations

- **Clause-② reads `yes (narrowing)`, where the claim read `no
(narrowing)`.** `RemoteTransport` is a public export (`src/index.ts`),
and it gains `compileDistinct()`. The narrowing half is the six bold
rows. The bump is `minor` either way.
- **The file surface stayed inside the claim.** One line outside the new
overrides moved in an existing test (the `setFileColumnsMovedResolver`
assertion in the media-column-move test). It pinned the old `true`, the
answer this card measured as false.
- **`explain` / `analyzeQuery` are refused, not routed.** They have no
in-repo caller, and a route needs a second transport member. Offered as
an open question.

## Acceptance notes

Found on the way, measured, not changed here (reported to the seat with
evidence):
- The remote read doors ignore `options.tenantId`. Remote
`find('probe_org', {}, { tenantId: 'org_a' })` answered `t1, t2, t3`
(org_b's row included); the local control answered `t1, t3`. This is why
the scoped `distinct()` is refused.
- The remote face ignores a federated object's `external.remoteName`.
After `registerExternalObject({ name: 'ext_t', external: { remoteName:
'probe_t' } })`, remote `find('ext_t')` threw a bare `LibsqlError` (`no
such table: ext_t`, no envelope); local read `probe_t`.
- `SqlDriver.reclaimSpace()` returns ONE free page per call on both
faces (freelist 300 to 299 locally, 50 to 49 remotely; the same `PRAGMA
incremental_vacuum` over the libSQL client directly: 300 to 299).
- Traced, not run: `ObjectQL.wasDatastoreCreatedFromEmpty()` counts a
driver answering `{ created: 0, existing: 0 }` as reporting, with
nothing to veto. Next to a driver that created tables, a remote Turso
datasource therefore does not make the answer "no", while the contract
reads zero-zero as "cannot say".
- Raw `SAVEPOINT` statements through `execute()` work on a libSQL
`file:` client. Against a remote Turso server they remain unmeasured,
and the README says so.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx)_

---------

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 size/m tests tooling

Projects

None yet

1 participant