Skip to content

Commit bc0ac1d

Browse files
os-muskclaude
andauthored
fix(driver-turso)!: refuse timeout beside a wss:// / ws:// url at construction (ADR-0049 enforce-or-remove) (#16616)
* fix(driver-turso)!: refuse timeout beside a wss:// / ws:// url at construction (ADR-0049 enforce-or-remove) A non-zero TursoDriverConfig.timeout beside a WebSocket url in remote mode reached nothing (@libsql/client 0.17.4's ws client takes no fetch and no timeout option); the constructor now refuses the pair as VALIDATION_ERROR / 400 before super(), naming the key, the scheme and both ways out. Controls pin the width: no timeout, timeout: 0, every HTTP-side scheme with a window, and the replica arm stay accepted. Docblock, README and design-doc lines that promised "not bounded" now say "refused". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg * changeset: state the way out as prose, not a migration table (no ledger entry — nothing is retired) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 4d72868 commit bc0ac1d

5 files changed

Lines changed: 253 additions & 4 deletions

File tree

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
---
2+
"@objectstack/driver-turso": minor
3+
---
4+
5+
fix(driver-turso)!: `timeout` beside a `wss://` / `ws://` url 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 `url` keep their names and types, and `TursoConfigSchema` is untouched. 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 the key, the scheme and both ways out, and which of the two an author wants (drop the window, or move the url to HTTPS) is authoring intent no ledger line can decide. -->
8+
9+
`TursoDriverConfig.timeout` bounds remote operations over HTTP (`libsql://`, `https://`, `http://` — the driver hands `@libsql/client` a `fetch` that aborts at the window) and bounds `sync()` on the replica arm. A remote url spelled `wss://` / `ws://` rides the client's WebSocket transport, which — measured against `@libsql/client@0.17.4` / `@libsql/hrana-client@0.10.0` — takes no `fetch` and no timeout option of its own, so on that one scheme the window reached nothing: the configuration constructed, connected, and ran unbounded, with the gap stated only in a docblock.
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 `wss://` or `ws://` `url` 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 the key, the scheme it met, and both ways out:
12+
13+
```
14+
`TursoDriverConfig.timeout` (30000 ms) is set beside a `wss://` url, and on that
15+
scheme it bounds nothing: a `wss://` url rides @libsql/client's WebSocket
16+
transport, which takes no fetch and no timeout option … Either omit `timeout`
17+
and run this remote unbounded, or keep it and spell the url `libsql://` or
18+
`https://` — the client resolves `libsql://` to HTTPS — where every request IS
19+
bounded and a stalled endpoint fails as TIMEOUT / 504.
20+
```
21+
22+
A datasource authors the window as `config.timeoutMs`; the datasource seam maps it onto the driver's `timeout`, so a `timeoutMs` beside a WebSocket url now fails the datasource's connect by name instead of quietly running unbounded. Both loaders (`@objectstack/runtime`'s host factory and the open-core datasource factory) reach this refusal through the same constructor.
23+
24+
**What stays accepted — the refusal is no wider than the gap**, pinned by controls:
25+
26+
- a `wss://` / `ws://` url with no `timeout`, or with `timeout: 0` (the documented "no bound");
27+
- `libsql://`, `https://` and `http://` urls WITH a window — the HTTP arm is bounded;
28+
- the replica arm with any url scheme — `sync()` is bounded there, so the key is not inert.
29+
30+
**What is deliberately NOT done**: routing a `wss://` url over HTTP because `timeout` is set. That would change the wire transport behind the author's back and is a contract decision, not a driver's; the refusal changes no wire behaviour.
31+
32+
**What an affected author does.** The refusal text itself says which two: keep the window and spell the url as `libsql://` or `https://` (bounded — `libsql://` resolves to HTTPS), or drop the window and run the WebSocket remote unbounded, as it always did. Which of the two is wanted is authoring intent, and the choice is made at the datasource or driver config, in place.
33+
34+
Blast radius, measured on this tree: no in-repo deployment, example or doc pairs a WebSocket url with a window, and the host boot path (`OS_DATABASE_URL`) forwards only `url` and `authToken`, so an env-configured deployment cannot carry `timeout` at all.

docs/design/driver-turso.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -528,7 +528,7 @@ carries the prescription. The live keys:
528528
| `syncUrl` | `string?` || Remote sync URL for embedded replica mode |
529529
| `sync.intervalSeconds` | `number` | `60` | Periodic sync interval (0 = manual only) |
530530
| `sync.onConnect` | `boolean` | `true` | Sync immediately on connect |
531-
| `timeoutMs` | `number?` || Operation timeout in milliseconds for remote operations (0 = no bound): remote mode over HTTP aborts each request at the window (`TIMEOUT` / 504); replica mode bounds `sync()`; WebSocket URLs are not bounded |
531+
| `timeoutMs` | `number?` || Operation timeout in milliseconds for remote operations (0 = no bound): remote mode over HTTP aborts each request at the window (`TIMEOUT` / 504); replica mode bounds `sync()`; a WebSocket url (`wss://` / `ws://`) beside a non-zero window is refused by the driver at construction (`VALIDATION_ERROR` / 400) — that transport takes no seam the window could ride |
532532

533533
---
534534

packages/drivers/driver-turso/README.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -212,7 +212,10 @@ interface TursoDriverConfig {
212212
* - Remote mode over HTTP (libsql:// / https:// / http://): every request the
213213
* client makes is aborted once the window elapses, and the operation fails
214214
* as TIMEOUT / 504 instead of hanging. wss:// and ws:// URLs use the
215-
* WebSocket transport, which has no such seam, and are not bounded.
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.
216219
* - Replica mode: bounds sync(), the one remote operation on that arm. A
217220
* sync still running when the window closes rejects with the same
218221
* envelope; the native binding's own sync is not cancelled, only no longer
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* `TursoDriverConfig.timeout` beside a `wss://` / `ws://` url is refused at
5+
* construction — the ADR-0049 enforce-or-remove answer to the one remote scheme
6+
* on which the key reaches nothing.
7+
*
8+
* # What was measured (the reading this refusal stands on)
9+
*
10+
* `@libsql/client@0.17.4` routes on scheme: `wss` / `ws` go to its WebSocket
11+
* client (`lib-esm/ws.js`), which opens `hrana.openWs(url, authToken)` and reads
12+
* neither `Config.fetch` — the seam the HTTP arm's window rides — nor any
13+
* timeout option: over `@libsql/hrana-client@0.10.0`'s `lib-esm/ws/*.js` and
14+
* `lib-esm/index.js` a `timeout` grep returns zero, while a `fetch` grep over
15+
* `lib-esm/http/` finds the call sites (the control that makes the zero a
16+
* reading). So once the HTTP and replica arms were bounded, a `wss://` url with
17+
* `timeout: 30000` still constructed, connected and ran unbounded, silently.
18+
*
19+
* # What this file pins
20+
*
21+
* The refusal itself, on both schemes, as the ADR-0112 envelope (`code` +
22+
* `status`) with a message naming the key, the scheme it met and the two ways
23+
* out — never a bare `toThrow()`, which any unrelated constructor failure
24+
* would satisfy. And the refusal's WIDTH, by controls that must stay accepted:
25+
* the same url without `timeout`; with `timeout: 0` (the documented "no
26+
* bound"); every HTTP-side scheme WITH a window (`libsql://`, `https://`,
27+
* `http://`); and the replica arm, where `sync()` is bounded whatever the url's
28+
* scheme. A refusal that took any of those would be wider than the gap.
29+
*
30+
* # Reverse verification — direction predicted before it was run
31+
*
32+
* Restore `turso-driver.ts` to its pre-refusal state and the refusal cases go
33+
* RED (the constructor returns a driver, `transportMode: 'remote'`, and there
34+
* is no envelope to read); every control stays GREEN, because the controls
35+
* describe what was accepted before and after alike. Measured — see the PR.
36+
*/
37+
38+
import { describe, expect, it } from 'vitest';
39+
import { createTursoDriver } from './index.js';
40+
import { TursoDriver } from './turso-driver.js';
41+
42+
type Refusal = Error & { code?: string; status?: number };
43+
44+
/** The error `build` threw, or `null` when it returned. */
45+
function refusalOf(build: () => unknown): Refusal | null {
46+
try {
47+
build();
48+
return null;
49+
} catch (error) {
50+
return error as Refusal;
51+
}
52+
}
53+
54+
const WINDOW_MS = 30_000;
55+
const WSS_URL = 'wss://db.example.turso.io';
56+
const WS_URL = 'ws://127.0.0.1:8080';
57+
const PRIMARY_URL = 'libsql://primary.example.turso.io';
58+
59+
describe('TursoDriverConfig.timeout beside a WebSocket url — refused at construction', () => {
60+
it.each([
61+
['wss://', WSS_URL],
62+
['ws://', WS_URL],
63+
])('%s + timeout is refused as VALIDATION_ERROR / 400, naming the key, the scheme and the way out', (scheme, url) => {
64+
const refusal = refusalOf(() => new TursoDriver({ url, authToken: 'token', timeout: WINDOW_MS }));
65+
66+
expect(refusal).not.toBeNull();
67+
expect(refusal!.code).toBe('VALIDATION_ERROR');
68+
expect(refusal!.status).toBe(400);
69+
expect(refusal!.message).toContain('TursoDriverConfig.timeout');
70+
expect(refusal!.message).toContain(`${WINDOW_MS} ms`);
71+
expect(refusal!.message).toContain(`\`${scheme}\``);
72+
// Both ways out are in the text: the bounded spellings, and the option of
73+
// dropping the key.
74+
expect(refusal!.message).toContain('libsql://');
75+
expect(refusal!.message).toContain('https://');
76+
expect(refusal!.message).toContain('omit `timeout`');
77+
});
78+
79+
it('a forced `mode: "remote"` meets the same refusal — the override does not route around it', () => {
80+
const refusal = refusalOf(() => new TursoDriver({ url: WSS_URL, mode: 'remote', timeout: WINDOW_MS }));
81+
82+
expect(refusal?.code).toBe('VALIDATION_ERROR');
83+
expect(refusal?.status).toBe(400);
84+
});
85+
86+
it('createTursoDriver() is the same constructor, and refuses the same pair', () => {
87+
const refusal = refusalOf(() => createTursoDriver({ url: WSS_URL, timeout: WINDOW_MS }));
88+
89+
expect(refusal?.code).toBe('VALIDATION_ERROR');
90+
expect(refusal?.status).toBe(400);
91+
});
92+
});
93+
94+
describe('CONTROLS — what the refusal must leave accepted', () => {
95+
it.each([WSS_URL, WS_URL])('%s with no timeout constructs as remote, exactly as before', (url) => {
96+
const driver = new TursoDriver({ url, authToken: 'token' });
97+
98+
expect(driver.transportMode).toBe('remote');
99+
expect(driver.getTursoConfig().timeout).toBeUndefined();
100+
});
101+
102+
it('`timeout: 0` is the documented "no bound", asks for nothing, and is not refused', () => {
103+
const driver = new TursoDriver({ url: WSS_URL, authToken: 'token', timeout: 0 });
104+
105+
expect(driver.transportMode).toBe('remote');
106+
expect(driver.getTursoConfig().timeout).toBe(0);
107+
});
108+
109+
it.each(['libsql://db.example.turso.io', 'https://db.example.turso.io', 'http://127.0.0.1:8080'])(
110+
'%s + timeout stays accepted — the HTTP arm IS bounded, so the refusal is no wider than the gap',
111+
(url) => {
112+
const driver = new TursoDriver({ url, authToken: 'token', timeout: WINDOW_MS });
113+
114+
expect(driver.transportMode).toBe('remote');
115+
expect(driver.getTursoConfig().timeout).toBe(WINDOW_MS);
116+
},
117+
);
118+
119+
it('the replica arm keeps timeout whatever the url scheme — sync() is bounded there', () => {
120+
const fileReplica = new TursoDriver({
121+
url: ':memory:',
122+
syncUrl: PRIMARY_URL,
123+
timeout: WINDOW_MS,
124+
sync: { onConnect: false },
125+
});
126+
expect(fileReplica.transportMode).toBe('replica');
127+
expect(fileReplica.getTursoConfig().timeout).toBe(WINDOW_MS);
128+
129+
const wsReplica = new TursoDriver({
130+
url: WSS_URL,
131+
syncUrl: PRIMARY_URL,
132+
timeout: WINDOW_MS,
133+
sync: { onConnect: false },
134+
});
135+
expect(wsReplica.transportMode).toBe('replica');
136+
expect(wsReplica.getTursoConfig().timeout).toBe(WINDOW_MS);
137+
});
138+
});

packages/drivers/driver-turso/src/turso-driver.ts

Lines changed: 76 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,10 @@ export interface TursoDriverConfig {
112112
* `@libsql/client` a `fetch` that aborts once the window elapses, so a
113113
* stalled endpoint fails the operation as `TIMEOUT` / 504 instead of
114114
* hanging it. A `wss://` / `ws://` URL rides the WebSocket transport, which
115-
* exposes no such seam in this client version; the key does not bound it.
115+
* exposes no such seam in this client version — so the constructor REFUSES
116+
* a non-zero `timeout` beside one of those two schemes (`VALIDATION_ERROR`
117+
* / 400) rather than accept a window it cannot deliver: drop `timeout`, or
118+
* spell the url `libsql://` / `https://`, which IS bounded.
116119
* - **Replica mode**: `sync()` — the one remote operation on this arm (reads
117120
* and writes run against the local file). A sync still running when the
118121
* window closes rejects with the same envelope; the native binding's own
@@ -270,7 +273,9 @@ function remoteOperationTimedOut(what: string, timeoutMs: number): Error & { cod
270273
* different setting under the same name. `Config.fetch` is the one seam the
271274
* remote transport exposes: the hrana HTTP client routes EVERY request through
272275
* it (the protocol-version probe included), and the WebSocket transport takes
273-
* no such hook at all.
276+
* no such hook at all — which is why a `wss://` / `ws://` url with a window is
277+
* refused at construction ({@link refuseWebSocketTimeout}) instead of being
278+
* handed a `fetch` that nothing reads.
274279
*
275280
* A signal already on the request is honoured alongside the window
276281
* (`AbortSignal.any`), so a caller's own abort keeps working; only an abort the
@@ -315,6 +320,68 @@ function timeoutWindow(config: TursoDriverConfig): number | undefined {
315320
return config.timeout && config.timeout > 0 ? config.timeout : undefined;
316321
}
317322

323+
/**
324+
* Whether a remote url rides `@libsql/client`'s WebSocket transport.
325+
*
326+
* The client routes on the literal scheme (`lib-esm/node.js`: `wss` / `ws` →
327+
* its ws client, `https` / `http` → its HTTP client); `libsql://` is expanded
328+
* by `@libsql/core` before that switch, and the entry this driver imports
329+
* expands it to HTTPS. So these two spellings are the whole population that
330+
* reaches the WebSocket arm from this driver.
331+
*/
332+
function ridesWebSocketTransport(url: string): boolean {
333+
return url.startsWith('wss://') || url.startsWith('ws://');
334+
}
335+
336+
/**
337+
* `timeout` beside a `wss://` / `ws://` url — refused at construction.
338+
*
339+
* On those two schemes the window reaches nothing. `@libsql/client@0.17.4`'s
340+
* WebSocket client (`lib-esm/ws.js` → `hrana.openWs(url, authToken)`) consults
341+
* neither `Config.fetch` — the seam {@link fetchBoundedBy} rides — nor any
342+
* timeout option of its own: over `@libsql/hrana-client@0.10.0`'s
343+
* `lib-esm/ws/*.js` and `lib-esm/index.js` a `timeout` grep returns zero,
344+
* while a `fetch` grep over `lib-esm/http/` finds the call sites — the control
345+
* that makes the zero a reading. `Config.timeout` is not a seam either: it is
346+
* the busy timeout for local `file:` lock contention, which "remote clients
347+
* ignore".
348+
*
349+
* ADR-0049 enforce-or-remove: a declared setting that changes nothing is worse
350+
* than absent, and "documented as not bounded" was still a `timeout: 30000`
351+
* that an author reads as a bound. Accepting the pair silently was the defect;
352+
* the refusal turns it into a loud one and changes no wire behaviour — routing
353+
* a `wss://` url over HTTP because `timeout` is set would change the transport
354+
* behind the author's back, and is deliberately NOT done here.
355+
*
356+
* Raised BEFORE `super()`, beside `detectMode`: ahead of the Knex base and of
357+
* any `@libsql/client`, so it cannot be reached with a half-built driver, and
358+
* a boot that would have run unbounded fails at the one constructor every
359+
* loader calls (`buildTursoDriverConfig` → `new TursoDriver`).
360+
*
361+
* Scoped to REMOTE mode: on the replica arm a `wss://` url beside `syncUrl`
362+
* still has `sync()` bounded, so the key is not inert there. `timeout: 0` is
363+
* the documented "no bound", asks for nothing, and is not refused. A
364+
* caller-supplied `client` is not consulted — its transport is not the driver's
365+
* to know; the scheme of the `url` beside it is what decides here.
366+
*
367+
* ⛔ No internal issue id in the message: it reaches an operator's boot log and
368+
* Studio's datasource form. The ids live in the comments beside it.
369+
*/
370+
function refuseWebSocketTimeout(url: string, timeoutMs: number): never {
371+
const scheme = url.slice(0, url.indexOf('://') + '://'.length);
372+
const err = new Error(
373+
`\`TursoDriverConfig.timeout\` (${timeoutMs} ms) is set beside a \`${scheme}\` url, and on that ` +
374+
`scheme it bounds nothing: a \`${scheme}\` url rides @libsql/client's WebSocket transport, which ` +
375+
`takes no fetch and no timeout option (measured against @libsql/client 0.17.4), so the window would ` +
376+
`be accepted and never delivered. Either omit \`timeout\` and run this remote unbounded, or keep it ` +
377+
`and spell the url \`libsql://\` or \`https://\` — the client resolves \`libsql://\` to HTTPS — ` +
378+
`where every request IS bounded and a stalled endpoint fails as TIMEOUT / 504.`,
379+
) as Error & { code?: string; status?: number };
380+
err.code = StandardErrorCode.enum.VALIDATION_ERROR;
381+
err.status = 400;
382+
throw err;
383+
}
384+
318385
// ── Turso Driver ─────────────────────────────────────────────────────────────
319386

320387
/**
@@ -449,6 +516,13 @@ export class TursoDriver extends SqlDriver {
449516

450517
constructor(config: TursoDriverConfig) {
451518
const mode = TursoDriver.detectMode(config);
519+
// A window the WebSocket arm cannot deliver is refused here, ahead of the
520+
// Knex base and of any client — see `refuseWebSocketTimeout` for the
521+
// reading and the ruling behind it.
522+
const timeoutMs = timeoutWindow(config);
523+
if (mode === 'remote' && timeoutMs !== undefined && ridesWebSocketTransport(config.url)) {
524+
refuseWebSocketTimeout(config.url, timeoutMs);
525+
}
452526
const knexConfig = TursoDriver.toKnexConfig(config, mode);
453527
super(knexConfig);
454528
this.tursoConfig = config;

0 commit comments

Comments
 (0)