Skip to content

Commit fe9ecc7

Browse files
committed
fix(driver-turso)!: refuse timeout beside a pre-configured client 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
1 parent 7c12e47 commit fe9ecc7

4 files changed

Lines changed: 459 additions & 13 deletions

File tree

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
---
2+
"@objectstack/driver-turso": minor
3+
---
4+
5+
fix(driver-turso)!: `timeout` beside a pre-configured `client` in remote mode is refused at construction instead of being accepted and never delivered (ADR-0049 enforce-or-remove)
6+
7+
<!-- adr-0087: not-required (no-migration-prescription) An accept-set narrowing performed at the driver constructor: no key, spec symbol, Zod schema, object definition or stored representation is added, removed or renamed — `TursoDriverConfig.timeout` and `TursoDriverConfig.client` keep their names and types, and the published `turso` config schema is untouched (it never declared `client`, which is a live object rather than authorable metadata). What moves is which CONFIGURATIONS `new TursoDriver()` accepts, so `objectstack migrate meta` has nothing to visit and there is no tombstone to mint. The refusal itself names both keys, the mode and both ways out, and which of the two an author wants is authoring intent no ledger line can decide. -->
8+
9+
`TursoDriverConfig.timeout` bounds remote operations over HTTP by installing a `fetch` that aborts at the window — and it installs it in exactly one place, while the driver is CREATING its `@libsql/client`. A pre-configured `TursoDriverConfig.client` arrives with its transport already built, and both remote sites that consume it (`connect()` and the lazy connect factory the transport self-heals through) skip the builder entirely. So on that one composition the window reached nothing: the driver constructed, connected, and ran every request unbounded, while `timeout`'s contract promised "every request the client's HTTP transport makes" and `client`'s said nothing about the key ceasing to apply.
10+
11+
**BREAKING** accept-set narrowing on a published driver option, shipped as `minor` under the repo's launch-window convention for breaking changes (`scripts/check-changeset-no-major.mjs`). **The constructor now refuses a configuration it accepted before**: a non-zero `timeout` beside a supplied `client` in remote mode throws at `new TursoDriver()` — ahead of the Knex base and of any client, so no half-built driver exists — with the ADR-0112 envelope `code: 'VALIDATION_ERROR'`, `status: 400`, and a message that names both keys, the window, the mode and both ways out:
12+
13+
```
14+
`TursoDriverConfig.timeout` (30000 ms) is set beside `TursoDriverConfig.client` in
15+
remote mode, and on that pair it bounds nothing: the window is the `fetch` this
16+
driver hands @libsql/client while CREATING the remote client, and a pre-configured
17+
client is already built — its transport is not the driver's to replace … Either drop
18+
`client` and let the driver create the remote client, where every request IS bounded
19+
and a stalled endpoint fails as TIMEOUT / 504, or keep `client` and omit `timeout`,
20+
building the bound into that client yourself when you call `createClient({ fetch })`.
21+
Replica mode is unaffected: there `sync()` is bounded whatever client is in use.
22+
```
23+
24+
**Who can reach this, measured on this tree.** The datasource seam cannot: `buildTursoDriverConfig` emits nine keys (`url`, `authToken`, `encryptionKey`, `concurrency`, `syncUrl`, `sync`, `timeout`, `mode`, `schemaMode`) and `client` is not among them — it is a live object, not authorable metadata, and the published `turso` schema documents its absence deliberately. So no datasource, environment variable or `sys_metadata` row can produce this pair; only code calling `new TursoDriver(...)` / `createTursoDriver(...)` directly. Across the 138 construction sites in this repository, the only one pairing the two keys outside the new pin file is a replica-arm test fixture, which stays accepted. Whether any out-of-repo host composes them is NOT measured and is not claimed to be zero.
25+
26+
**What stays accepted — the refusal is no wider than the gap**, pinned by controls:
27+
28+
- a supplied `client` with no `timeout`, and an explicit `client: undefined`, which the `??` at both sites treats as absent;
29+
- `timeout` with no `client` — the client the driver builds IS bounded;
30+
- `timeout: 0` beside a client, the documented "no bound", which asks for nothing;
31+
- the whole REPLICA arm, where `sync()` is bounded by the driver around the awaited promise whatever client is in use, so the key is not inert there and the pair is still accepted.
32+
33+
**What is deliberately NOT done**: wrapping or re-creating the caller's client so the window rides after all. A client handed in for custom caching, connection pooling or testing is the caller's object, and replacing its transport because `timeout` is set would discard the configuration it was built to carry, behind the author's back — the same reason a `wss://` url is not silently re-routed over HTTP.
34+
35+
**What an affected author does.** The refusal text says which two: drop `client` and let the driver create the remote client, which bounds every request; or keep `client` and drop `timeout`, building the bound into that client where it is created, since `@libsql/client` reads its `fetch` at creation. Which of the two is wanted is authoring intent, and the choice is made in place at the driver config.

packages/drivers/driver-turso/README.md

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,24 @@ const driver = new TursoDriver({
160160
await driver.connect();
161161
```
162162

163+
In **remote** mode a pre-configured client may not be combined with a non-zero
164+
`timeout`: the driver installs that window as the `fetch` it hands
165+
`@libsql/client` while creating the client, so it has no way to apply it to one
166+
you built yourself, and the constructor refuses the pair
167+
(`VALIDATION_ERROR` / 400) instead of accepting a bound it cannot deliver. Build
168+
the bound into your own client if you need both:
169+
170+
```typescript
171+
const client = createClient({
172+
url: 'libsql://my-db.turso.io',
173+
authToken: process.env.TURSO_AUTH_TOKEN,
174+
fetch: (input, init) => fetch(input, { ...init, signal: AbortSignal.timeout(30_000) }),
175+
});
176+
```
177+
178+
Replica mode is unaffected — `sync()`, the one remote operation on that arm, is
179+
bounded by `timeout` whatever client is in use.
180+
163181
## Multi-Tenant Routing
164182

165183
**Not shipped by this package.** Database-per-tenant routing on top of
@@ -211,11 +229,15 @@ interface TursoDriverConfig {
211229
* Effective in replica and remote modes; 0 or unset = no bound.
212230
* - Remote mode over HTTP (libsql:// / https:// / http://): every request the
213231
* client makes is aborted once the window elapses, and the operation fails
214-
* as TIMEOUT / 504 instead of hanging. wss:// and ws:// URLs use the
215-
* WebSocket transport, which has no such seam — so a non-zero timeout
216-
* beside one of them is REFUSED at construction (VALIDATION_ERROR / 400)
217-
* rather than accepted and never delivered: drop the key, or use a
218-
* libsql:// / https:// URL, which is bounded.
232+
* as TIMEOUT / 504 instead of hanging — when THIS driver creates the
233+
* client. Two remote compositions cannot carry the window, and both are
234+
* REFUSED at construction (VALIDATION_ERROR / 400) rather than accepted and
235+
* never delivered:
236+
* - a wss:// or ws:// URL, which uses the WebSocket transport and has no
237+
* such seam: drop the key, or use a libsql:// / https:// URL;
238+
* - a pre-configured `client`, which arrives with its transport already
239+
* built: drop `client`, or drop `timeout` and build the bound into that
240+
* client yourself.
219241
* - Replica mode: bounds sync(), the one remote operation on that arm. A
220242
* sync still running when the window closes rejects with the same
221243
* envelope; the native binding's own sync is not cancelled, only no longer
@@ -234,6 +256,11 @@ interface TursoDriverConfig {
234256
/**
235257
* Pre-configured @libsql/client instance.
236258
* Useful for custom caching, connection pooling, or testing.
259+
* In REMOTE mode it may not be combined with a non-zero `timeout` — the
260+
* constructor refuses that pair (VALIDATION_ERROR / 400), because the window
261+
* is installed while creating the client and a client the driver did not
262+
* create cannot carry it. Replica mode is unaffected: sync() is bounded
263+
* whatever client is in use.
237264
*/
238265
client?: Client;
239266
}

0 commit comments

Comments
 (0)