diff --git a/.changeset/driver-turso-supplied-client-timeout-refusal.md b/.changeset/driver-turso-supplied-client-timeout-refusal.md new file mode 100644 index 0000000000..45bdeec1c7 --- /dev/null +++ b/.changeset/driver-turso-supplied-client-timeout-refusal.md @@ -0,0 +1,35 @@ +--- +"@objectstack/driver-turso": minor +--- + +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) + + + +`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. + +**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: + +``` +`TursoDriverConfig.timeout` (30000 ms) is set beside `TursoDriverConfig.client` in +remote mode, and on that pair it bounds nothing: the window is the `fetch` this +driver hands @libsql/client while CREATING the remote client, and a pre-configured +client is already built — its transport is not the driver's to replace … Either drop +`client` and let the driver create the remote client, where every request IS bounded +and a stalled endpoint fails as TIMEOUT / 504, or keep `client` and omit `timeout`, +building the bound into that client yourself when you call `createClient({ fetch })`. +Replica mode is unaffected: there `sync()` is bounded whatever client is in use. +``` + +**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. + +**What stays accepted — the refusal is no wider than the gap**, pinned by controls: + +- a supplied `client` with no `timeout`, and an explicit `client: undefined`, which the `??` at both sites treats as absent; +- `timeout` with no `client` — the client the driver builds IS bounded; +- `timeout: 0` beside a client, the documented "no bound", which asks for nothing; +- 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. + +**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. + +**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. diff --git a/packages/drivers/driver-turso/README.md b/packages/drivers/driver-turso/README.md index 861055bf28..cb867e7b68 100644 --- a/packages/drivers/driver-turso/README.md +++ b/packages/drivers/driver-turso/README.md @@ -160,6 +160,24 @@ const driver = new TursoDriver({ await driver.connect(); ``` +In **remote** mode a pre-configured client may not be combined with a non-zero +`timeout`: the driver installs that window as the `fetch` it hands +`@libsql/client` while creating the client, so it has no way to apply it to one +you built yourself, and the constructor refuses the pair +(`VALIDATION_ERROR` / 400) instead of accepting a bound it cannot deliver. Build +the bound into your own client if you need both: + +```typescript +const client = createClient({ + url: 'libsql://my-db.turso.io', + authToken: process.env.TURSO_AUTH_TOKEN, + fetch: (input, init) => fetch(input, { ...init, signal: AbortSignal.timeout(30_000) }), +}); +``` + +Replica mode is unaffected — `sync()`, the one remote operation on that arm, is +bounded by `timeout` whatever client is in use. + ## Multi-Tenant Routing **Not shipped by this package.** Database-per-tenant routing on top of @@ -211,11 +229,15 @@ interface TursoDriverConfig { * Effective in replica and remote modes; 0 or unset = no bound. * - 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 — 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. + * as TIMEOUT / 504 instead of hanging — when THIS driver creates the + * client. Two remote compositions cannot carry the window, and both are + * REFUSED at construction (VALIDATION_ERROR / 400) rather than accepted and + * never delivered: + * - a wss:// or ws:// URL, which uses the WebSocket transport and has no + * such seam: drop the key, or use a libsql:// / https:// URL; + * - a pre-configured `client`, which arrives with its transport already + * built: drop `client`, or drop `timeout` and build the bound into that + * client yourself. * - 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 @@ -234,6 +256,11 @@ interface TursoDriverConfig { /** * Pre-configured @libsql/client instance. * Useful for custom caching, connection pooling, or testing. + * In REMOTE mode it may not be combined with a non-zero `timeout` — the + * constructor refuses that pair (VALIDATION_ERROR / 400), because the window + * is installed while creating the client and a client the driver did not + * create cannot carry it. Replica mode is unaffected: sync() is bounded + * whatever client is in use. */ client?: Client; } diff --git a/packages/drivers/driver-turso/src/turso-driver-supplied-client-timeout-refusal.test.ts b/packages/drivers/driver-turso/src/turso-driver-supplied-client-timeout-refusal.test.ts new file mode 100644 index 0000000000..5a511d3a21 --- /dev/null +++ b/packages/drivers/driver-turso/src/turso-driver-supplied-client-timeout-refusal.test.ts @@ -0,0 +1,289 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `TursoDriverConfig.timeout` beside a pre-configured `TursoDriverConfig.client` + * in REMOTE mode is refused at construction — the ADR-0049 enforce-or-remove + * answer to the one remote COMPOSITION on which the key reaches nothing. + * + * # The defect this pins shut + * + * The window is installed in exactly one place: `createRemoteClient()` spreads + * `{ fetch: fetchBoundedBy(timeoutMs) }` into `createClient(...)`. TWO remote + * sites decide whether that builder runs at all, and both spell the choice + * identically — `this.tursoConfig.client ?? (await this.createRemoteClient())`: + * + * 1. `connect()`'s remote arm; + * 2. the lazy connect factory the constructor registers on `RemoteTransport` + * via `setConnectFactory`, which `ensureConnected()` calls when an + * operation runs before (or without) `connect()`. + * + * A supplied `client` short-circuits the `??` at BOTH, so the one place the + * window is installed is never reached and every request ran unbounded, while + * `timeout`'s docblock promised "every request the client's HTTP transport + * makes" and `client`'s said nothing about the key ceasing to apply. + * + * # Why this file drives both sites rather than asserting the refusal once + * + * A refusal that covered only `connect()` would leave the lazy factory open — + * a one-cut fix to a two-site defect — and no assertion about `connect()` can + * tell the two apart. So the CONTROLS below drive each site independently on + * the composition that stays accepted (`client` with no `timeout`) and prove + * each really does consume the supplied client; the refusal cases then prove + * the constructor throws BEFORE either can run, by observing that the supplied + * client is never touched and no driver is returned to touch it with. Both + * halves are needed: the first shows the two sites are live, the second shows + * the refusal sits upstream of both. + * + * # What else is pinned + * + * The envelope (ADR-0112 `code` + `status`) and a message naming BOTH keys, the + * window, the mode and both ways out — never a bare `toThrow()`, which any + * unrelated constructor failure would satisfy. And the refusal's WIDTH, by + * controls that must stay accepted: `client` without `timeout`; `timeout` + * without `client`; `timeout: 0` (the documented "no bound") beside a client; + * an explicit `client: undefined`, which the `??` at both sites treats as + * absent; and the REPLICA arm, where `sync()` is bounded by `boundedBy` + * whatever client is in use — pinned here by a stalled sync that still fails as + * `TIMEOUT` / 504 with a supplied client and a window, the measurement that + * makes "the replica arm is untouched" a reading rather than a claim. + * + * # Reverse verification — direction predicted before it was run + * + * Restore the constructor 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 type { Client, ResultSet } from '@libsql/client'; +import { afterEach, 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 EMPTY_RESULT: ResultSet = { + rows: [], + columns: [], + columnTypes: [], + rowsAffected: 0, + lastInsertRowid: undefined, + toJSON: () => ({}), +} as unknown as ResultSet; + +/** + * A pre-configured `@libsql/client` that records every call it receives — the + * caller's own object, as the driver sees it. `calls` is the observable that + * makes "the supplied client was reached" a measurement instead of an + * inference, and its staying at 0 is what shows the refusal ran first. + */ +function recordingClient(): Client & { calls: string[] } { + const calls: string[] = []; + const stub = { + calls, + execute: (statement: unknown) => { + calls.push(typeof statement === 'string' ? statement : JSON.stringify(statement)); + return Promise.resolve(EMPTY_RESULT); + }, + batch: () => Promise.resolve([EMPTY_RESULT]), + sync: () => Promise.resolve(), + close: () => {}, + closed: false, + protocol: 'http', + }; + return stub as unknown as Client & { calls: string[] }; +} + +/** A supplied client whose `sync()` never settles — a stalled primary on the replica arm. */ +function stalledSyncClient(): Client { + const stub = { + sync: () => new Promise(() => {}), + close: () => {}, + closed: false, + protocol: 'file', + }; + return stub as unknown as Client; +} + +/** The rejection an operation produced, or `null` when it resolved. */ +function failureOf(operation: Promise): Promise { + return operation.then( + () => null, + (error: Refusal) => error, + ); +} + +const WINDOW_MS = 30_000; +const HTTP_URL = 'https://db.example.turso.io'; +const PRIMARY_URL = 'libsql://primary.example.turso.io'; + +describe('`timeout` beside a supplied `client` in remote mode — refused at construction', () => { + it.each([ + ['libsql://', 'libsql://db.example.turso.io'], + ['https://', HTTP_URL], + ['http://', 'http://127.0.0.1:8080'], + ])( + '%s + client + timeout is refused as VALIDATION_ERROR / 400, naming both keys, the mode and both ways out', + (_scheme, url) => { + const client = recordingClient(); + const refusal = refusalOf(() => new TursoDriver({ url, authToken: 'token', client, timeout: WINDOW_MS })); + + expect(refusal).not.toBeNull(); + expect(refusal!.code).toBe('VALIDATION_ERROR'); + expect(refusal!.status).toBe(400); + // Both keys are named — a message naming only `timeout` would leave an + // author re-reading the one key that is not the problem. + expect(refusal!.message).toContain('TursoDriverConfig.timeout'); + expect(refusal!.message).toContain('TursoDriverConfig.client'); + expect(refusal!.message).toContain(`${WINDOW_MS} ms`); + // The mode, because the same pair IS accepted on the replica arm. + expect(refusal!.message).toContain('remote'); + // Both ways out, and which arm is unaffected. + expect(refusal!.message).toContain('drop `client`'); + expect(refusal!.message).toContain('omit `timeout`'); + expect(refusal!.message).toContain('Replica mode is unaffected'); + // Nothing downstream ran: no driver exists, so neither bypass site could + // have reached the caller's client. + expect(client.calls).toEqual([]); + }, + ); + + it('a forced `mode: "remote"` meets the same refusal — the override does not route around it', () => { + const refusal = refusalOf( + () => new TursoDriver({ url: ':memory:', mode: 'remote', client: recordingClient(), 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: HTTP_URL, client: recordingClient(), timeout: WINDOW_MS })); + + expect(refusal?.code).toBe('VALIDATION_ERROR'); + expect(refusal?.status).toBe(400); + }); +}); + +describe('BOTH bypass sites — each is live, and the refusal sits upstream of both', () => { + const cleanups: Array<() => Promise> = []; + afterEach(async () => { + while (cleanups.length > 0) await cleanups.pop()!(); + }); + + it('SITE 1 — `connect()` takes the supplied client, bypassing createRemoteClient()', async () => { + const client = recordingClient(); + const driver = new TursoDriver({ url: HTTP_URL, authToken: 'token', client }); + cleanups.push(() => driver.disconnect()); + + await driver.connect(); + + // The identity check is the whole point: `createRemoteClient()` would have + // returned a different object, and it is the only place the window is + // installed. + expect(driver.getLibsqlClient()).toBe(client); + }); + + it('SITE 2 — the lazy connect factory takes it too, on an operation that never called connect()', async () => { + const client = recordingClient(); + const driver = new TursoDriver({ url: HTTP_URL, authToken: 'token', client }); + cleanups.push(() => driver.disconnect()); + + // connect() is deliberately NOT called: this is the self-heal path + // `RemoteTransport.ensureConnected()` drives through the factory the + // constructor registered. + expect(driver.getLibsqlClient()).toBeNull(); + await failureOf(driver.find('probe', {})); + + expect(driver.getLibsqlClient()).toBe(client); + expect(client.calls.length).toBeGreaterThan(0); + }); + + it('the refused pair reaches NEITHER site — construction throws before a driver exists', () => { + const client = recordingClient(); + const refusal = refusalOf(() => new TursoDriver({ url: HTTP_URL, authToken: 'token', client, timeout: WINDOW_MS })); + + // Site 1 is unreachable because there is nothing to call `connect()` on; + // site 2 because the transport that would hold the factory was never + // constructed. The zero call count is the positive observable for both. + expect(refusal).not.toBeNull(); + expect(client.calls).toEqual([]); + }); +}); + +describe('CONTROLS — what the refusal must leave accepted', () => { + const cleanups: Array<() => Promise> = []; + afterEach(async () => { + while (cleanups.length > 0) await cleanups.pop()!(); + }); + + it('a supplied `client` with NO timeout constructs as remote, exactly as before', () => { + const client = recordingClient(); + const driver = new TursoDriver({ url: HTTP_URL, authToken: 'token', client }); + + expect(driver.transportMode).toBe('remote'); + expect(driver.getTursoConfig().timeout).toBeUndefined(); + expect(driver.getTursoConfig().client).toBe(client); + }); + + it('a `timeout` with NO client stays accepted — the driver-built client IS bounded', () => { + const driver = new TursoDriver({ url: HTTP_URL, authToken: 'token', timeout: WINDOW_MS }); + + expect(driver.transportMode).toBe('remote'); + expect(driver.getTursoConfig().timeout).toBe(WINDOW_MS); + expect(driver.getTursoConfig().client).toBeUndefined(); + }); + + it('`timeout: 0` beside a client is the documented "no bound", asks for nothing, and is not refused', () => { + const client = recordingClient(); + const driver = new TursoDriver({ url: HTTP_URL, authToken: 'token', client, timeout: 0 }); + + expect(driver.transportMode).toBe('remote'); + expect(driver.getTursoConfig().timeout).toBe(0); + }); + + it('an explicit `client: undefined` is absent to the `??` at both sites, so the pair is not the refused one', () => { + const driver = new TursoDriver({ url: HTTP_URL, authToken: 'token', client: undefined, timeout: WINDOW_MS }); + + expect(driver.transportMode).toBe('remote'); + expect(driver.getTursoConfig().timeout).toBe(WINDOW_MS); + }); + + it('THE REPLICA ARM IS UNTOUCHED: client + timeout is accepted there, and sync() is still bounded', async () => { + const driver = new TursoDriver({ + url: ':memory:', + syncUrl: PRIMARY_URL, + authToken: 'token', + client: stalledSyncClient(), + sync: { onConnect: false }, + timeout: 100, + }); + + expect(driver.transportMode).toBe('replica'); + expect(driver.getTursoConfig().timeout).toBe(100); + + await driver.connect(); + cleanups.push(() => driver.disconnect()); + expect(driver.isSyncEnabled()).toBe(true); + + // The key is NOT inert on this arm — which is exactly why the refusal is + // scoped away from it. A stalled sync beside a supplied client still fails + // as TIMEOUT / 504, on the `boundedBy` seam the remote arm does not have. + const failure = await failureOf(driver.sync()); + + expect(failure).not.toBeNull(); + expect(failure!.code).toBe('TIMEOUT'); + expect(failure!.status).toBe(504); + expect(failure!.message).toContain('sync'); + }); +}); diff --git a/packages/drivers/driver-turso/src/turso-driver.ts b/packages/drivers/driver-turso/src/turso-driver.ts index d8ed68ae89..dd5b42f3e7 100644 --- a/packages/drivers/driver-turso/src/turso-driver.ts +++ b/packages/drivers/driver-turso/src/turso-driver.ts @@ -108,14 +108,19 @@ export interface TursoDriverConfig { * What it bounds, per arm (measured against `@libsql/client@0.17.4`): * * - **Remote mode over HTTP** (`libsql://`, `https://`, `http://`): every - * request the client's HTTP transport makes. The driver hands - * `@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 — 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. + * request the client's HTTP transport makes, when THIS driver creates the + * client. The driver hands `@libsql/client` a `fetch` that aborts once the + * window elapses, so a stalled endpoint fails the operation as `TIMEOUT` / + * 504 instead of hanging it. Two remote compositions cannot carry that + * window, and the constructor REFUSES both (`VALIDATION_ERROR` / 400) + * rather than accept a bound it cannot deliver: + * - a `wss://` / `ws://` URL, which rides the WebSocket transport and + * exposes no such seam in this client version — drop `timeout`, or spell + * the url `libsql://` / `https://`, which IS bounded; + * - a pre-configured {@link TursoDriverConfig.client}, which arrives with + * its transport already built and no seam left to install the window on + * — drop `client`, or drop `timeout` and build the bound into that client + * when you create it. * - **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 @@ -144,6 +149,17 @@ export interface TursoDriverConfig { * caching, connection pooling, or testing. * * Only effective in remote and replica modes. + * + * **In REMOTE mode this key may not be combined with a non-zero + * {@link TursoDriverConfig.timeout}** — the constructor refuses the pair + * (`VALIDATION_ERROR` / 400). The window is the `fetch` this driver installs + * while CREATING the remote client, so a client it did not create cannot + * carry it; accepting the pair meant running every request unbounded while + * `timeout`'s contract promised otherwise. Build the bound into the client + * you hand in (`createClient({ fetch })`), or drop `client` and let the + * driver create the remote client. Replica mode is unaffected: `sync()` — + * the one remote operation on that arm — is bounded whatever client is in + * use, so the pair stays accepted there. */ client?: Client; } @@ -382,6 +398,68 @@ function refuseWebSocketTimeout(url: string, timeoutMs: number): never { throw err; } +/** + * `timeout` beside a caller-supplied `client` in REMOTE mode — refused at + * construction. + * + * On the HTTP arm the window is not a driver-side wrapper around each call: it + * is installed once, as the `fetch` this driver hands `@libsql/client` when it + * BUILDS the client ({@link createRemoteClient} — the single site that spreads + * `{ fetch: fetchBoundedBy(timeoutMs) }`). A pre-configured `client` arrives + * with its transport already constructed, and `Config.fetch` is read at + * `createClient()` time and kept inside the hrana transport; there is no + * after-the-fact seam on a built client for the driver to reach (the same + * reading that ruled out option (b) on the filing card). So both remote sites + * that take the supplied client — `connect()` and the transport's lazy connect + * factory, which spell the choice identically as + * `this.tursoConfig.client ?? (await this.createRemoteClient())` — skip the one + * place the window is installed, and every request runs unbounded. + * + * ADR-0049 enforce-or-remove: `timeout`'s own docblock promised "every request + * the client's HTTP transport makes", and `client`'s said nothing about the key + * ceasing to apply, so this composition was a declared setting that changed + * nothing — accepted silently, which is the defect. Refusing it says so at the + * one constructor every loader calls and changes no wire behaviour. + * + * ⛔ NOT done here, deliberately: 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; replacing its + * transport because `timeout` is set would discard exactly the configuration + * they built it to carry, behind their back — the same reason a `wss://` url is + * not silently re-routed over HTTP. + * + * Scoped to REMOTE mode. On the replica arm a supplied `client` keeps the key + * live: `sync()` — the one remote operation that arm performs — is bounded by + * {@link boundedBy} around the awaited promise, whatever client is in use, so + * the key is not inert there and the pair is accepted. `timeout: 0` and unset + * are the documented "no bound", ask for nothing, and are not refused. + * + * Ordered AFTER {@link refuseWebSocketTimeout} on purpose: that refusal already + * takes every `wss://` / `ws://` url with a window — its own contract records + * that "a caller-supplied `client` is not consulted" — so this one fires only + * on compositions the constructor accepts today, and no configuration changes + * which message it gets. + * + * ⛔ 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 refuseSuppliedClientTimeout(timeoutMs: number): never { + const err = new Error( + `\`TursoDriverConfig.timeout\` (${timeoutMs} ms) is set beside \`TursoDriverConfig.client\` in remote ` + + `mode, and on that pair it bounds nothing: the window is the \`fetch\` this driver hands ` + + `@libsql/client while CREATING the remote client, and a pre-configured client is already built — ` + + `its transport is not the driver's to replace (measured against @libsql/client 0.17.4), so the ` + + `window would be accepted and never delivered. Either drop \`client\` and let the driver create the ` + + `remote client, where every request IS bounded and a stalled endpoint fails as TIMEOUT / 504, or ` + + `keep \`client\` and omit \`timeout\`, building the bound into that client yourself when you call ` + + `\`createClient({ fetch })\`. Replica mode is unaffected: there \`sync()\` is bounded whatever ` + + `client is in use.`, + ) as Error & { code?: string; status?: number }; + err.code = StandardErrorCode.enum.VALIDATION_ERROR; + err.status = 400; + throw err; +} + // ── Turso Driver ───────────────────────────────────────────────────────────── /** @@ -523,6 +601,16 @@ export class TursoDriver extends SqlDriver { if (mode === 'remote' && timeoutMs !== undefined && ridesWebSocketTransport(config.url)) { refuseWebSocketTimeout(config.url, timeoutMs); } + // A window a pre-configured client cannot carry is refused here for the + // same reason and in the same place — see `refuseSuppliedClientTimeout`. + // The predicate mirrors the `??` at the two sites that consume the key + // (`connect()` and the transport's lazy connect factory) exactly: those + // take the supplied client for any non-nullish value, and fall through to + // `createRemoteClient()` — where the window IS installed — for `null` and + // `undefined` alike. + if (mode === 'remote' && timeoutMs !== undefined && config.client !== undefined && config.client !== null) { + refuseSuppliedClientTimeout(timeoutMs); + } const knexConfig = TursoDriver.toKnexConfig(config, mode); super(knexConfig); this.tursoConfig = config; @@ -686,6 +774,13 @@ export class TursoDriver extends SqlDriver { * `timeout` sat declared-but-unforwarded four lines from a forwarded * `concurrency` until the ADR-0049 ruling on it: see `fetchBoundedBy` for * why the window rides `Config.fetch` and not `Config.timeout`. + * + * Both sites can also SKIP this builder entirely — each spells the choice + * `this.tursoConfig.client ?? (await this.createRemoteClient())` — and when + * they do, the one place `timeout` is installed is not reached. That pair is + * refused at construction now (`refuseSuppliedClientTimeout`), which is what + * keeps "the window is applied here" true of the whole remote arm rather + * than only of the branch that calls this method. */ private async createRemoteClient(): Promise { const { createClient } = await import('@libsql/client');