fix(driver-turso)!: refuse timeout beside a pre-configured client at construction (ADR-0049 enforce-or-remove) - #16757
Conversation
… at construction (ADR-0049 enforce-or-remove) `TursoDriverConfig.timeout` installs its window in exactly one place — the `fetch` handed to `@libsql/client` inside `createRemoteClient()`. Both remote sites that consume a caller-supplied `client` (`connect()` and the lazy connect factory registered on `RemoteTransport`) spell the choice `this.tursoConfig.client ?? (await this.createRemoteClient())`, so a supplied client skipped the builder at both and every request ran unbounded, silently, while `timeout`'s docblock promised "every request the client's HTTP transport makes" and `client`'s said nothing about the key ceasing to apply. The constructor now refuses the pair in remote mode as VALIDATION_ERROR / 400 before super(), naming both keys, the mode and both ways out. Controls pin the width: `client` with no window, a window with no `client`, `timeout: 0`, an explicit `client: undefined`, and the replica arm — where `sync()` is bounded whatever client is in use — all stay accepted. Docblocks and README lines that promised the window unconditionally now say what is refused. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg
📓 Docs Drift CheckThis PR changes 1 package(s): 2 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 6 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin fbff9664546c153ce8721eabfdcb54451c348a85 && git checkout fbff9664546c153ce8721eabfdcb54451c348a85
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 8ccf7a1dfde5ae2280a91f35c468d3b26114e00d fe9ecc72f29049356382d51e6c76b742aaaab10a && git checkout -B drift-repro 8ccf7a1dfde5ae2280a91f35c468d3b26114e00d && git merge --no-ff fe9ecc72f29049356382d51e6c76b742aaaab10a
node scripts/docs-audit/affected-docs.mjs --json 8ccf7a1dfde5ae2280a91f35c468d3b26114e00d
|
|
Dedup follow-up on the gate observation in the body (the level axis being blind to nested package directories): it is already filed as #16713, opened about three hours before this PR and measured wider than I had it — "51 of 74 workspace packages (all drivers/services/adapters) can pair Two nearby cards the same search surfaced, neither the same defect: #16692 (the axis cannot see a shipped That observation does not change this PR's grading: 🤖 Generated with Claude Code https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg Generated by Claude Code |
Contract review (
|
Fixes #16617
Clause-②: yes
A construction-time refusal narrows the accept set of a published driver option, so this carries the contract-review carrier and stays draft until a tier review returns.
The defect
TursoDriverConfig.timeoutinstalls its window in exactly one place — thefetchspread intocreateClient(...)insidecreateRemoteClient(). Two remote sites decide whether that builder runs at all, and both spell the choice identically:A supplied
clientshort-circuits the??at both, so the one place the window is installed is never reached.new TursoDriver({ url: 'https://…', client: myClient, timeout: 30000 })constructed, connected and ran every request unbounded, silently — whiletimeout's docblock promised "every request the client's HTTP transport makes" andclient's docblock said nothing about the key ceasing to apply.Premises re-derived by symbol on
origin/main7c12e475e0The card's anchors were measured at
434ca2d64and the dispatch re-derived at1ea349f0eb;origin/mainadvanced to7c12e475e0while this was in flight. Re-derived here by symbol, not by line, and the numbers held becauseturso-driver.tswas untouched in between (last touched by #16650, #16616, #16376):packages/drivers/driver-turso/src/turso-driver.ts, 1641 lines):697—...(timeoutMs === undefined ? {} : { fetch: fetchBoundedBy(timeoutMs) }), insidecreateRemoteClient(:690). SolefetchBoundedBycall site; the other three occurrences are the definition (:284) and two prose references (:341,:687):594— insidethis.remoteTransport.setConnectFactory(...), the lazy connect factory registered in the constructor; and:735—connect()'s remote arm. These are the only twotursoConfig.client ??sites;:745/:746is the replica arm and uses truthiness, not??:1712(post-change;:1617pre-change) —await boundedBy(this.libsqlClient.sync(), timeoutMs, 'embedded replica sync'), insidesync()at:1705timeoutandclientdocblocks, both edited hereFiring controls.
timeoutoccurs 64 times in the file by occurrence and hits 52 lines — the dispatch's "52" is the line count, and the two are worth separating. A nonsense token (zzzznotatoken) matches 0, so the counter fires.The refusal, and both bypass sites
The refusal is at construction, before
super(), immediately after the existingrefuseWebSocketTimeoutcheck. That placement is what covers both sites at once: neither can run, because no driver exists to run them on.Ordered after the WebSocket refusal deliberately. That refusal's own contract already records that "a caller-supplied
clientis not consulted — its transport is not the driver's to know", so it already takes everywss:///ws://url with a window. Putting this one second means it fires only on compositions the constructor accepts today, and no existing configuration changes which message it gets.Envelope and text follow #16378 / PR #16616's precedent —
VALIDATION_ERROR/ 400, no internal issue id in the message (it reaches an operator's boot log and Studio's datasource form), the key named, and both ways out stated. What differs is that this message names two keys and the mode, because the same pair is accepted on the replica arm:Proving both sites are covered, rather than asserting it. A refusal that covered only
connect()would leave the lazy factory open — a one-cut fix to a two-site defect — and no assertion aboutconnect()can tell the two apart. So the pins do it in two halves:client, notimeout):SITE 1callsconnect()and assertsgetLibsqlClient()is the caller's object;SITE 2never callsconnect()at all and drives an operation, soRemoteTransport.ensureConnected()goes through the registered factory, then asserts the same identity plus a non-zero call count on the recording client;What stays accepted
clientwith notimeout·timeoutwith noclient·timeout: 0beside a client · an explicitclient: undefined(which the??at both sites treats as absent) · the whole replica arm. The replica control is not a construction assertion: it stallssync()beside a supplied client and a 100 ms window and asserts it still failsTIMEOUT/ 504, which makes "the replica arm is untouched" a measurement rather than a claim. The pre-existingturso-driver-timeout.test.ts:149fixture already pairsclientwithtimeouton that arm and stays green.Reachability — what is measured, and what is not
buildTursoDriverConfig's reader table emits nine keys —url,authToken,encryptionKey,concurrency,syncUrl,sync,timeout,mode,schemaMode— andclientis not among them. Firing control: the same parse reportstimeoutpresent, so a false onclientis a reading and not a broken parse. The generated protocol reference says the same independently:clientis "a live object, not authorable metadata". So no datasource, env var orsys_metadatarow can produce the pair.new TursoDriver/createTursoDriver/new TursoDriverCtorconstruction sites, brace-matched. Outside the new pin file, exactly one pairs the two keys —turso-driver-timeout.test.ts:149, the replica-arm helper, which stays accepted.client\s*:/timeout\s*:and returned 4 hits; the control fired, but it silently missed object shorthand (client,) and the spread form (...(timeout === undefined ? {} : { timeout })) — including known-true instances in the very files it scanned. Recalibrated to the bare identifier inside the config literal, it returns 8 and now catches both previously-missed classes. The first reading measured nothing and is not the one quoted above.What could not be measured: any out-of-repo consumer.
@objectstack/driver-tursois published, and the closedobjectstack-ai/cloudrepo is not in this session. A zero-hit census of this repository is not evidence that no deployment composes the pair — which is exactly why the refusal is loud and diagnosable rather than a silent drop or a quiet "the client wins" fallback.Verification
All at commit
fe9ecc72f2unless noted.pnpm --filter @objectstack/driver-turso test— 48 files, 1206 tests, 0 failed, 0 skipped, 0 todo, 0.only(scanned for skip markers in both the log and the sources; no faces were skipped, so there are none to name).pnpm --filter @objectstack/driver-turso typecheck— the package declares one leg,tsc --noEmit(read out of itspackage.json; there is no siblingtsconfig.test.json). Exit 0 with an empty error body. The new pin file is in the program that grades it:tsc --noEmit --listFilesnames it once across 300 files, and a nonsense control filename matches 0.pnpm lint(eslint . --no-inline-config, the repo-wide scan) — exit 0 in 85 s. No narrowing was needed, so none is declared.node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackderived 56 families; all 56 run, every exit code captured after a redirect. Reconciliation verbatim:check:type-check-debtandcheck:dual-build-cjs-loadsfirst returned exit 3 — PREREQUISITE NOT MET, which is not a pass. Rather than declare the gap, the closure was built (turbo run build --filter='./packages/*' --filter='./packages/*/*', 72/72 successful) and both re-run to a real verdict:✓ check:dual-build-cjs-loads — 104 published require entry point(s) across 67 package(s) load, andcheck-type-check-coverage --re-measure: OK — 5 ledger entr(ies) re-measured in 77.2s, 55 raw tsc error(s) total, none above its recorded number.driver-tursocarries no DEBT ledger entry (0 matches incheck-type-check-coverage.mjs; firing control@objectstack/lintmatches 3).Two-leg ablation, blob-hash-verified restore
Direction predicted before the run: the six refusal-side cases go RED, all seven controls stay GREEN.
1 → 0, injected marker lines1, and the blob hash moveddd5b42f3…→67c4d32b…. Result: 6 failed | 7 passed (13) — exactly the predicted split, exactly the predicted six.git checkout HEAD -- PATH(never the bare form, which would take the mutation back out of the index), verified by blob hash rather than by exit code:RESTORE OK: blob dd5b42f3e76da027cbf8210e3bae25cf04997719 == HEAD blob dd5b42f3e76da027cbf8210e3bae25cf04997719, withgit diff HEADempty. The script carried atrap … EXIT INT TERMwith absolute paths seeded fromgit rev-parse --show-toplevel, and treats an empty hash as failure.Nothing stayed green that should not have, so there is no coerced layer to report. The subject resolves through relative
srcimports (./turso-driver.js), not through the packageexports, so nodistrebuild sits between the mutation and the observation.Changeset —
minor, derived from the governing textGoverning text used: the WHICH LEVEL maintainer ruling at
.github/workflows/pr-automation.yml:667-682(2026-09-04, decision batch #35, on #15294), mechanized byscripts/check-changeset-no-major.mjs.Rejected, and why:
major— refused during the launch window bycheck-changeset-no-major.mjs; the ruling puts breaking-ness on "the BREAKING banner plus the ADR-0087 disposition, not … the level".patch— the level the ruling reserves for "afix(that changes no public surface". This changes which configurations the published constructor accepts, sopatchunderstates the act.AGENTS.md:1028grades a bug fixpatch, but it is the floor againstnone, not a ceiling, and it is not the governing text on level.skip-changeset— that label is for a diff publishing nothing from any released package;@objectstack/driver-tursopublishes.A
**BREAKING**banner and an ADR-0087 disposition are both owed — an accept-set narrowing on a published option, andcheck-adr-0087-registration.mjsrefuses one without the other. The disposition isnot-required (no-migration-prescription): no key, spec symbol, Zod schema or stored representation is added, removed or renamed, soobjectstack migrate metahas nothing to visit and there is no tombstone to mint. The way out is written as prose, not as a migration table, because that category is refused when the body carries a prescription. This matches the disposition PR #16616 took for the sibling half of the same defect class.check-changeset-no-major.mjskeys onPUBLISHED_SOURCE_PATH = /^packages\/([^/]+)\/src\//, which does not matchpackages/drivers/driver-turso/src/**— one path segment too shallow for every nested package directory (packages/drivers/*,packages/adapters/*,packages/apps/*,packages/services/*, …). So on this PR the axis that would have mechanically refused apatchbesideClause-②: yesreads no packages at all. Theminorabove is therefore chosen from the prose and the precedent, not conferred by a gate that fired.Files touched
packages/drivers/driver-turso/src/turso-driver.ts·packages/drivers/driver-turso/src/turso-driver-supplied-client-timeout-refusal.test.ts(new) ·packages/drivers/driver-turso/README.md·.changeset/driver-turso-supplied-client-timeout-refusal.md.RemoteTransport.aggregateinremote-transport.ts— a different file in the same package. #16378 is not addressed here; PR #16616 is its own change and nothing of it was folded in.docs/design/driver-turso.mdwas read and deliberately not edited: itstimeoutMsrow documents the datasource-authorable key, and the seam that consumes it cannot emitclient, so this refusal is unreachable from that surface.content/docs/references/data/driver-turso.mdxis auto-generated and already recordsclientas deliberately non-authorable.验收备注
Noted while reading, not filed (observations, not defects):
turso-driver.tsis 1641 lines and now carries three sibling construction-time refusals whose predicates all re-derivetimeoutWindow(config)at the same point; a single guard table would read better, but that is a refactor, not a defect, and it is outside this card.🤖 Generated with Claude Code
https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg
Generated by Claude Code