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
30 changes: 30 additions & 0 deletions .changeset/driver-turso-uppercase-ws-scheme-timeout-refusal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
---
"@objectstack/driver-turso": minor
---

fix(driver-turso)!: `timeout` beside an UPPERCASE `WSS://` / `WS://` url in forced remote mode is refused at construction, closing the last corner of the same gap (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`, `url` and `mode` keep their names and types, and `TursoConfigSchema` is untouched. What moves is which CONFIGURATIONS `new TursoDriver()` accepts — one predicate now compares the url's scheme case-insensitively, exactly as `@libsql/client` itself does before routing — so `objectstack migrate meta` has nothing to visit and there is no tombstone to mint. The refusal is the one the lowercase spelling already produces, naming the key, the scheme it met and both ways out; which of the two an author wants is authoring intent no ledger line can decide. -->

The refusal that closed `timeout` beside a `wss://` / `ws://` url matched the two schemes **literally**, so one composition still constructed with a window that reaches nothing:

```ts
new TursoDriver({ url: 'WSS://db.example.turso.io', mode: 'remote', timeout: 30000 })
```

Reading `@libsql/client`'s routing switch alone says that cannot happen — the switch really does match the literal lowercase (`lib-esm/node.js`: `config.scheme === "wss" || config.scheme === "ws"`). But the switch never sees the url as the author spelled it. The node entry is `_createClient(expandConfig(config, true))`, and `expandConfig` has already lowercased the scheme by then — `@libsql/core@0.17.4`, `lib-esm/config.js`: `const originalUriScheme = uri.scheme.toLowerCase();`. Executed against that version: `expandConfig({ url: 'WSS://db.example.turso.io' }, true).scheme === 'wss'`, and `'Ws://127.0.0.1:8080'` → `'ws'`. So an uppercase `WSS://` url does reach the WebSocket client, which takes no `fetch` and no timeout option of its own — the driver constructed, connected, and ran unbounded.

**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 an uppercase-or-mixed-case `wss://` / `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 **the same message the lowercase spelling already produced**, echoing the scheme in the caller's own casing so an operator can grep their config for what they actually typed.

**The explicit `mode: 'remote'` is load-bearing.** Without it an uppercase url falls through `TursoDriver.detectMode` to `'local'` — behaviour that predates the refusal entirely and is **unchanged here**. Only the window predicate folds case; the mode detector is deliberately left case-sensitive, and the code says so at the predicate, because folding it there too would delete that fall-through: a mode-detection change on a published driver, which must be argued on its own rather than slipped in as a tidy-up.

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

- an uppercase url with **no** explicit `mode` still detects as `'local'`, with or without a `timeout`;
- the uppercase WebSocket url with no `timeout`, or with `timeout: 0` (the documented "no bound");
- `https://` / `HTTPS://` / `LIBSQL://` / `HTTP://` remote urls **with** a window — the HTTP arm is bounded, so every casing of every HTTP-side scheme keeps the key;
- the existing lowercase refusals, unchanged in code, message and envelope.

**What an affected author does.** Unchanged from the lowercase case, and the refusal text says it: keep the window and spell the url `libsql://` or `https://` (bounded — the client resolves `libsql://` to HTTPS), or drop the window and run the WebSocket remote unbounded, as it always did.

Blast radius, measured on this tree: no in-repo deployment, example, test or doc pairs an uppercase remote scheme with a window; the host boot path (`OS_DATABASE_URL`) forwards only `url` and `authToken`, and the datasource seam's `buildTursoDriverConfig` normalises no casing either — so the pair is reachable in principle from both and is not observed in this repository. Whether any out-of-repo deployment spells a Turso url with an uppercase scheme is NOT measured and is not claimed to be zero.
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* `TursoDriverConfig.timeout` beside an UPPERCASE `WSS://` / `WS://` url in
* forced remote mode is refused at construction, exactly as the lowercase pair
* already is — the last corner of the same ADR-0049 gap.
*
* # Why the corner was open (the upstream step the first reading missed)
*
* `@libsql/client`'s routing switch really does match the literal lowercase
* (`lib-esm/node.js`: `config.scheme === "wss" || config.scheme === "ws"`), and
* reading only that switch says an uppercase url can never reach the WebSocket
* arm. It can: `expandConfig` runs BEFORE the switch and has already lowercased
* the scheme, so the switch never sees the original casing. Measured against
* `@libsql/core@0.17.4`, whose `lib-esm/config.js` does it on one line —
* `const originalUriScheme = uri.scheme.toLowerCase();`:
*
* ```
* expandConfig({ url: 'WSS://db.example.turso.io' }, true).scheme === 'wss'
* expandConfig({ url: 'Ws://127.0.0.1:8080' }, true).scheme === 'ws'
* ```
*
* (and the control that makes those two a reading rather than a coincidence:
* `'LIBSQL://db.example.turso.io'` expands to `'https'`, so the same call is
* observably capable of answering something other than the input's own letters.)
* `@libsql/client@0.17.4`'s node entry is `_createClient(expandConfig(config,
* true))`, so that lowercased scheme IS what the switch reads. An uppercase
* `WSS://` url therefore reaches the WebSocket client, which has no window seam
* at all — the reading `refuseWebSocketTimeout` already stands on.
*
* A correct local observation plus a missed upstream step yields a wrong
* conclusion, and the observation itself survives re-checking; that is why this
* pin spells the chain out rather than asserting the outcome alone.
*
* # What this file pins
*
* The refusal reaching the uppercase spellings, as the ADR-0112 envelope
* (`code` + `status`) and as THE SAME MESSAGE the lowercase refusal produces —
* asserted by construction, not by re-typing the text: the uppercase message
* must equal the lowercase one with the echoed scheme swapped. The echo is the
* one deliberate difference, and it is deliberate because an operator greps the
* config for what they actually typed.
*
* And the three controls the refusal must not eat:
*
* 1. `detectMode` is untouched — an uppercase url with NO explicit mode still
* falls through to `'local'`, the pre-existing behaviour this card
* deliberately does not change. (No pin held this before; the scope ruling
* on this card requires one, so it is written here rather than assumed.)
* 2. The existing lowercase `wss://` / `ws://` refusals still fire
* (`turso-driver-ws-timeout-refusal.test.ts` is the primary pin; repeated
* here as the immediate neighbour of the widened predicate).
* 3. `https://` remote + `timeout` is still ACCEPTED, in every casing. An
* implementation that refused `timeout` on every remote would turn this
* file green while deleting the whole option.
*
* # Reverse verification — direction predicted before it was run
*
* Restore `ridesWebSocketTransport` to its literal-prefix form and the refusal
* cases go RED (the constructor returns a driver with `transportMode:
* 'remote'`, and there is no envelope to read); all three controls stay GREEN,
* because each describes behaviour that is identical before and after.
* Measured both ways — 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 HOST = 'db.example.turso.io';

describe('timeout beside an UPPERCASE WebSocket url in forced remote mode — refused at construction', () => {
it.each([
['WSS://', `WSS://${HOST}`],
['Wss://', `Wss://${HOST}`],
['WS://', 'WS://127.0.0.1:8080'],
['Ws://', 'Ws://127.0.0.1:8080'],
])('%s + explicit remote + timeout is refused as VALIDATION_ERROR / 400', (scheme, url) => {
const refusal = refusalOf(() => new TursoDriver({ url, mode: 'remote', 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`);
// The scheme is echoed in the caller's own spelling.
expect(refusal!.message).toContain(`\`${scheme}\``);
// Both ways out survive.
expect(refusal!.message).toContain('libsql://');
expect(refusal!.message).toContain('omit `timeout`');
});

it('is the SAME message the lowercase refusal produces, differing only in the echoed scheme', () => {
const upper = refusalOf(() => new TursoDriver({ url: `WSS://${HOST}`, mode: 'remote', timeout: WINDOW_MS }));
const lower = refusalOf(() => new TursoDriver({ url: `wss://${HOST}`, mode: 'remote', timeout: WINDOW_MS }));

expect(lower).not.toBeNull();
expect(upper).not.toBeNull();
expect(upper!.code).toBe(lower!.code);
expect(upper!.status).toBe(lower!.status);
expect(upper!.message).toBe(lower!.message.split('`wss://`').join('`WSS://`'));
});

it('createTursoDriver() is the same constructor, and refuses the same pair', () => {
const refusal = refusalOf(() => createTursoDriver({ url: `WSS://${HOST}`, mode: 'remote', timeout: WINDOW_MS }));

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

describe('CONTROL 1 — detectMode is untouched: an uppercase url with no explicit mode is still local', () => {
it.each([`WSS://${HOST}`, 'Ws://127.0.0.1:8080', `HTTPS://${HOST}`, `LIBSQL://${HOST}`])(
'%s with no `mode` falls through to local, exactly as before this change',
(url) => {
const driver = new TursoDriver({ url });

expect(driver.transportMode).toBe('local');
expect(driver.isRemote).toBe(false);
},
);

it('and it stays local WITH a timeout — the refusal is scoped to remote mode, so it cannot reach here', () => {
const driver = new TursoDriver({ url: `WSS://${HOST}`, timeout: WINDOW_MS });

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

describe('CONTROL 2 — the existing lowercase refusals still fire', () => {
it.each([
['wss://', `wss://${HOST}`],
['ws://', 'ws://127.0.0.1:8080'],
])('%s + timeout is still refused as VALIDATION_ERROR / 400', (_scheme, url) => {
const refusal = refusalOf(() => new TursoDriver({ url, authToken: 'token', timeout: WINDOW_MS }));

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

describe('CONTROL 3 — https:// remote + timeout stays ACCEPTED, in every casing', () => {
it.each([`https://${HOST}`, `HTTPS://${HOST}`, `Https://${HOST}`, `LIBSQL://${HOST}`, `HTTP://127.0.0.1:8080`])(
'%s + explicit remote + timeout constructs and keeps the window — the HTTP arm IS bounded',
(url) => {
const driver = new TursoDriver({ url, mode: 'remote', authToken: 'token', timeout: WINDOW_MS });

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

it('an uppercase WebSocket url with NO timeout still constructs as remote when mode is forced', () => {
const driver = new TursoDriver({ url: `WSS://${HOST}`, mode: 'remote', authToken: 'token' });

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

it('`timeout: 0` is the documented "no bound" and is not refused on an uppercase url either', () => {
const driver = new TursoDriver({ url: `WSS://${HOST}`, mode: 'remote', authToken: 'token', timeout: 0 });

expect(driver.transportMode).toBe('remote');
expect(driver.getTursoConfig().timeout).toBe(0);
});
});
39 changes: 33 additions & 6 deletions packages/drivers/driver-turso/src/turso-driver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -339,14 +339,41 @@ function timeoutWindow(config: TursoDriverConfig): number | 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.
* The client's routing switch matches the literal lowercase (`lib-esm/node.js`:
* `wss` / `ws` → its ws client, `https` / `http` → its HTTP client), but it
* never sees the url as the author spelled it: the node entry is
* `_createClient(expandConfig(config, true))`, and `expandConfig` has ALREADY
* lowercased the scheme by then — `@libsql/core@0.17.4`,
* `lib-esm/config.js`: `const originalUriScheme = uri.scheme.toLowerCase();`.
* Executed against that version:
* `expandConfig({ url: 'WSS://db.example.turso.io' }, true).scheme === 'wss'`
* and `'Ws://127.0.0.1:8080'` → `'ws'`; the control that makes those a reading
* is `'LIBSQL://…'` → `'https'`, the same call answering something other than
* the input's own letters. (`libsql://` is expanded before the switch too, and
* the entry this driver imports expands it to HTTPS.)
*
* ⇒ Case is folded HERE so this predicate agrees with the client it hands the
* url to. Reading the switch alone says an uppercase `WSS://` cannot reach the
* WebSocket arm; it can, and a window beside it would be accepted and never
* delivered — the corner {@link refuseWebSocketTimeout} exists to close. Only
* the comparison is folded: the url itself is passed on exactly as authored, so
* the refusal message echoes the operator's own spelling and stays greppable
* against their config.
*
* ⚠️ DELIBERATE INCONSISTENCY, and it is deliberate: `TursoDriver.detectMode`
* matches the same two schemes CASE-SENSITIVELY and is left that way. Folding
* case there as well would delete its uppercase → `'local'` fall-through — a
* mode-detection change on a published driver that predates this refusal
* entirely and is out of scope here; it must be argued on its own, not slipped
* in as a tidy-up. So the two readers of one url disagree on purpose: this one
* answers "does the WINDOW reach anything", `detectMode` answers "which
* transport is this", and only the first question is settled by the scheme the
* libsql client will actually route on. ⛔ Do not "unify" them without that
* argument.
*/
function ridesWebSocketTransport(url: string): boolean {
return url.startsWith('wss://') || url.startsWith('ws://');
const scheme = url.toLowerCase();
return scheme.startsWith('wss://') || scheme.startsWith('ws://');
}

/**
Expand Down
Loading