Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions .changeset/driver-turso-ws-timeout-refusal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
---
"@objectstack/driver-turso": minor
---

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)

<!-- 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. -->

`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.

**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:

```
`TursoDriverConfig.timeout` (30000 ms) is set beside a `wss://` url, and on that
scheme it bounds nothing: a `wss://` url rides @libsql/client's WebSocket
transport, which takes no fetch and no timeout option … Either omit `timeout`
and run this remote unbounded, or keep it and spell the url `libsql://` or
`https://` — the client resolves `libsql://` to HTTPS — where every request IS
bounded and a stalled endpoint fails as TIMEOUT / 504.
```

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.

**What stays accepted — the refusal is no wider than the gap**, pinned by controls:

- a `wss://` / `ws://` url with no `timeout`, or with `timeout: 0` (the documented "no bound");
- `libsql://`, `https://` and `http://` urls WITH a window — the HTTP arm is bounded;
- the replica arm with any url scheme — `sync()` is bounded there, so the key is not inert.

**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.

**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.

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.
2 changes: 1 addition & 1 deletion docs/design/driver-turso.md
Original file line number Diff line number Diff line change
Expand Up @@ -528,7 +528,7 @@ carries the prescription. The live keys:
| `syncUrl` | `string?` | — | Remote sync URL for embedded replica mode |
| `sync.intervalSeconds` | `number` | `60` | Periodic sync interval (0 = manual only) |
| `sync.onConnect` | `boolean` | `true` | Sync immediately on connect |
| `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 |
| `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 |

---

Expand Down
5 changes: 4 additions & 1 deletion packages/drivers/driver-turso/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,10 @@ interface TursoDriverConfig {
* - Remote mode over HTTP (libsql:// / https:// / http://): every request the
* client makes is aborted once the window elapses, and the operation fails
* as TIMEOUT / 504 instead of hanging. wss:// and ws:// URLs use the
* WebSocket transport, which has no such seam, and are not bounded.
* WebSocket transport, which has no such seam — so a non-zero timeout
* beside one of them is REFUSED at construction (VALIDATION_ERROR / 400)
* rather than accepted and never delivered: drop the key, or use a
* libsql:// / https:// URL, which is bounded.
* - Replica mode: bounds sync(), the one remote operation on that arm. A
* sync still running when the window closes rejects with the same
* envelope; the native binding's own sync is not cancelled, only no longer
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* `TursoDriverConfig.timeout` beside a `wss://` / `ws://` url is refused at
* construction — the ADR-0049 enforce-or-remove answer to the one remote scheme
* on which the key reaches nothing.
*
* # What was measured (the reading this refusal stands on)
*
* `@libsql/client@0.17.4` routes on scheme: `wss` / `ws` go to its WebSocket
* client (`lib-esm/ws.js`), which opens `hrana.openWs(url, authToken)` and reads
* neither `Config.fetch` — the seam the HTTP arm's window rides — nor any
* timeout option: over `@libsql/hrana-client@0.10.0`'s `lib-esm/ws/*.js` and
* `lib-esm/index.js` a `timeout` grep returns zero, while a `fetch` grep over
* `lib-esm/http/` finds the call sites (the control that makes the zero a
* reading). So once the HTTP and replica arms were bounded, a `wss://` url with
* `timeout: 30000` still constructed, connected and ran unbounded, silently.
*
* # What this file pins
*
* The refusal itself, on both schemes, as the ADR-0112 envelope (`code` +
* `status`) with a message naming the key, the scheme it met and the two ways
* out — never a bare `toThrow()`, which any unrelated constructor failure
* would satisfy. And the refusal's WIDTH, by controls that must stay accepted:
* the same url without `timeout`; with `timeout: 0` (the documented "no
* bound"); every HTTP-side scheme WITH a window (`libsql://`, `https://`,
* `http://`); and the replica arm, where `sync()` is bounded whatever the url's
* scheme. A refusal that took any of those would be wider than the gap.
*
* # Reverse verification — direction predicted before it was run
*
* Restore `turso-driver.ts` to its pre-refusal state and the refusal cases go
* RED (the constructor returns a driver, `transportMode: 'remote'`, and there
* is no envelope to read); every control stays GREEN, because the controls
* describe what was accepted before and after alike. Measured — see the PR.
*/

import { describe, expect, it } from 'vitest';
import { createTursoDriver } from './index.js';
import { TursoDriver } from './turso-driver.js';

type Refusal = Error & { code?: string; status?: number };

/** The error `build` threw, or `null` when it returned. */
function refusalOf(build: () => unknown): Refusal | null {
try {
build();
return null;
} catch (error) {
return error as Refusal;
}
}

const WINDOW_MS = 30_000;
const WSS_URL = 'wss://db.example.turso.io';
const WS_URL = 'ws://127.0.0.1:8080';
const PRIMARY_URL = 'libsql://primary.example.turso.io';

describe('TursoDriverConfig.timeout beside a WebSocket url — refused at construction', () => {
it.each([
['wss://', WSS_URL],
['ws://', WS_URL],
])('%s + timeout is refused as VALIDATION_ERROR / 400, naming the key, the scheme and the way out', (scheme, url) => {
const refusal = refusalOf(() => new TursoDriver({ url, authToken: 'token', timeout: WINDOW_MS }));

expect(refusal).not.toBeNull();
expect(refusal!.code).toBe('VALIDATION_ERROR');
expect(refusal!.status).toBe(400);
expect(refusal!.message).toContain('TursoDriverConfig.timeout');
expect(refusal!.message).toContain(`${WINDOW_MS} ms`);
expect(refusal!.message).toContain(`\`${scheme}\``);
// Both ways out are in the text: the bounded spellings, and the option of
// dropping the key.
expect(refusal!.message).toContain('libsql://');
expect(refusal!.message).toContain('https://');
expect(refusal!.message).toContain('omit `timeout`');
});

it('a forced `mode: "remote"` meets the same refusal — the override does not route around it', () => {
const refusal = refusalOf(() => new TursoDriver({ url: WSS_URL, mode: 'remote', timeout: WINDOW_MS }));

expect(refusal?.code).toBe('VALIDATION_ERROR');
expect(refusal?.status).toBe(400);
});

it('createTursoDriver() is the same constructor, and refuses the same pair', () => {
const refusal = refusalOf(() => createTursoDriver({ url: WSS_URL, timeout: WINDOW_MS }));

expect(refusal?.code).toBe('VALIDATION_ERROR');
expect(refusal?.status).toBe(400);
});
});

describe('CONTROLS — what the refusal must leave accepted', () => {
it.each([WSS_URL, WS_URL])('%s with no timeout constructs as remote, exactly as before', (url) => {
const driver = new TursoDriver({ url, authToken: 'token' });

expect(driver.transportMode).toBe('remote');
expect(driver.getTursoConfig().timeout).toBeUndefined();
});

it('`timeout: 0` is the documented "no bound", asks for nothing, and is not refused', () => {
const driver = new TursoDriver({ url: WSS_URL, authToken: 'token', timeout: 0 });

expect(driver.transportMode).toBe('remote');
expect(driver.getTursoConfig().timeout).toBe(0);
});

it.each(['libsql://db.example.turso.io', 'https://db.example.turso.io', 'http://127.0.0.1:8080'])(
'%s + timeout stays accepted — the HTTP arm IS bounded, so the refusal is no wider than the gap',
(url) => {
const driver = new TursoDriver({ url, authToken: 'token', timeout: WINDOW_MS });

expect(driver.transportMode).toBe('remote');
expect(driver.getTursoConfig().timeout).toBe(WINDOW_MS);
},
);

it('the replica arm keeps timeout whatever the url scheme — sync() is bounded there', () => {
const fileReplica = new TursoDriver({
url: ':memory:',
syncUrl: PRIMARY_URL,
timeout: WINDOW_MS,
sync: { onConnect: false },
});
expect(fileReplica.transportMode).toBe('replica');
expect(fileReplica.getTursoConfig().timeout).toBe(WINDOW_MS);

const wsReplica = new TursoDriver({
url: WSS_URL,
syncUrl: PRIMARY_URL,
timeout: WINDOW_MS,
sync: { onConnect: false },
});
expect(wsReplica.transportMode).toBe('replica');
expect(wsReplica.getTursoConfig().timeout).toBe(WINDOW_MS);
});
});
78 changes: 76 additions & 2 deletions packages/drivers/driver-turso/src/turso-driver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,10 @@ export interface TursoDriverConfig {
* `@libsql/client` a `fetch` that aborts once the window elapses, so a
* stalled endpoint fails the operation as `TIMEOUT` / 504 instead of
* hanging it. A `wss://` / `ws://` URL rides the WebSocket transport, which
* exposes no such seam in this client version; the key does not bound it.
* exposes no such seam in this client version — so the constructor REFUSES
* a non-zero `timeout` beside one of those two schemes (`VALIDATION_ERROR`
* / 400) rather than accept a window it cannot deliver: drop `timeout`, or
* spell the url `libsql://` / `https://`, which IS bounded.
* - **Replica mode**: `sync()` — the one remote operation on this arm (reads
* and writes run against the local file). A sync still running when the
* window closes rejects with the same envelope; the native binding's own
Expand Down Expand Up @@ -270,7 +273,9 @@ function remoteOperationTimedOut(what: string, timeoutMs: number): Error & { cod
* different setting under the same name. `Config.fetch` is the one seam the
* remote transport exposes: the hrana HTTP client routes EVERY request through
* it (the protocol-version probe included), and the WebSocket transport takes
* no such hook at all.
* no such hook at all — which is why a `wss://` / `ws://` url with a window is
* refused at construction ({@link refuseWebSocketTimeout}) instead of being
* handed a `fetch` that nothing reads.
*
* A signal already on the request is honoured alongside the window
* (`AbortSignal.any`), so a caller's own abort keeps working; only an abort the
Expand Down Expand Up @@ -315,6 +320,68 @@ function timeoutWindow(config: TursoDriverConfig): number | undefined {
return config.timeout && config.timeout > 0 ? config.timeout : undefined;
}

/**
* Whether a remote url rides `@libsql/client`'s WebSocket transport.
*
* The client routes on the literal scheme (`lib-esm/node.js`: `wss` / `ws` →
* its ws client, `https` / `http` → its HTTP client); `libsql://` is expanded
* by `@libsql/core` before that switch, and the entry this driver imports
* expands it to HTTPS. So these two spellings are the whole population that
* reaches the WebSocket arm from this driver.
*/
function ridesWebSocketTransport(url: string): boolean {
return url.startsWith('wss://') || url.startsWith('ws://');
}

/**
* `timeout` beside a `wss://` / `ws://` url — refused at construction.
*
* On those two schemes the window reaches nothing. `@libsql/client@0.17.4`'s
* WebSocket client (`lib-esm/ws.js` → `hrana.openWs(url, authToken)`) consults
* neither `Config.fetch` — the seam {@link fetchBoundedBy} rides — nor any
* timeout option of its own: over `@libsql/hrana-client@0.10.0`'s
* `lib-esm/ws/*.js` and `lib-esm/index.js` a `timeout` grep returns zero,
* while a `fetch` grep over `lib-esm/http/` finds the call sites — the control
* that makes the zero a reading. `Config.timeout` is not a seam either: it is
* the busy timeout for local `file:` lock contention, which "remote clients
* ignore".
*
* ADR-0049 enforce-or-remove: a declared setting that changes nothing is worse
* than absent, and "documented as not bounded" was still a `timeout: 30000`
* that an author reads as a bound. Accepting the pair silently was the defect;
* the refusal turns it into a loud one and changes no wire behaviour — routing
* a `wss://` url over HTTP because `timeout` is set would change the transport
* behind the author's back, and is deliberately NOT done here.
*
* Raised BEFORE `super()`, beside `detectMode`: ahead of the Knex base and of
* any `@libsql/client`, so it cannot be reached with a half-built driver, and
* a boot that would have run unbounded fails at the one constructor every
* loader calls (`buildTursoDriverConfig` → `new TursoDriver`).
*
* Scoped to REMOTE mode: on the replica arm a `wss://` url beside `syncUrl`
* still has `sync()` bounded, so the key is not inert there. `timeout: 0` is
* the documented "no bound", asks for nothing, and is not refused. A
* caller-supplied `client` is not consulted — its transport is not the driver's
* to know; the scheme of the `url` beside it is what decides here.
*
* ⛔ No internal issue id in the message: it reaches an operator's boot log and
* Studio's datasource form. The ids live in the comments beside it.
*/
function refuseWebSocketTimeout(url: string, timeoutMs: number): never {
const scheme = url.slice(0, url.indexOf('://') + '://'.length);
const err = new Error(
`\`TursoDriverConfig.timeout\` (${timeoutMs} ms) is set beside a \`${scheme}\` url, and on that ` +
`scheme it bounds nothing: a \`${scheme}\` url rides @libsql/client's WebSocket transport, which ` +
`takes no fetch and no timeout option (measured against @libsql/client 0.17.4), so the window would ` +
`be accepted and never delivered. Either omit \`timeout\` and run this remote unbounded, or keep it ` +
`and spell the url \`libsql://\` or \`https://\` — the client resolves \`libsql://\` to HTTPS — ` +
`where every request IS bounded and a stalled endpoint fails as TIMEOUT / 504.`,
) as Error & { code?: string; status?: number };
err.code = StandardErrorCode.enum.VALIDATION_ERROR;
err.status = 400;
throw err;
}

// ── Turso Driver ─────────────────────────────────────────────────────────────

/**
Expand Down Expand Up @@ -449,6 +516,13 @@ export class TursoDriver extends SqlDriver {

constructor(config: TursoDriverConfig) {
const mode = TursoDriver.detectMode(config);
// A window the WebSocket arm cannot deliver is refused here, ahead of the
// Knex base and of any client — see `refuseWebSocketTimeout` for the
// reading and the ruling behind it.
const timeoutMs = timeoutWindow(config);
if (mode === 'remote' && timeoutMs !== undefined && ridesWebSocketTransport(config.url)) {
refuseWebSocketTimeout(config.url, timeoutMs);
}
const knexConfig = TursoDriver.toKnexConfig(config, mode);
super(knexConfig);
this.tursoConfig = config;
Expand Down
Loading