diff --git a/.changeset/driver-turso-inert-config-keys.md b/.changeset/driver-turso-inert-config-keys.md new file mode 100644 index 0000000000..15dd9ebac2 --- /dev/null +++ b/.changeset/driver-turso-inert-config-keys.md @@ -0,0 +1,63 @@ +--- +"@objectstack/driver-turso": minor +"@objectstack/spec": minor +--- + +feat(driver-turso)!: `timeout` bounds remote operations; `localPath` and `wasm` leave the published config schema (#16024, ADR-0049 enforce-or-remove) + + + +Three keys on this package's published Turso configuration were declared with a +describe promising behaviour that no code delivered — ADR-0049's +declared-but-unenforced shape, sitting beside `concurrency`, which was declared +the same way and IS forwarded. The maintainer ruled per key: forward `timeout`; +remove `localPath` and `wasm`. Not a rename for any of the three — an inert key +with a better name is what ADR-0049 exists to prevent. + +**`TursoDriverConfig.timeout` now does what its docblock has always said.** It +never reached `@libsql/client`. It still does not reach that client's own +`Config.timeout`, and deliberately: measured against `@libsql/client@0.17.4`, +that option is the busy timeout for lock contention on local `file:` databases +("remote clients ignore it"), so forwarding to it would have left remote mode +exactly as inert as before. Instead: + +- **Remote mode over HTTP** (`libsql://`, `https://`, `http://`): the driver + hands the client a `fetch` that aborts every request once the window elapses, + and the operation fails as `TIMEOUT` / 504 (the ADR-0112 envelope) instead of + hanging on a stalled endpoint. `wss://` / `ws://` URLs ride the WebSocket + transport, which exposes no such seam in this client version — they are not + bounded, and the docblock says so. +- **Replica mode**: `sync()` — the one remote operation on that arm — rejects + with the same envelope when it has not completed within the window. The native + binding's sync is not cancelled, only no longer awaited. +- `0` or unset means no bound, as the published schema already documented. + +A datasource authors this as `config.timeoutMs`; the datasource seam maps it +onto the driver's `timeout`, so a `timeoutMs` that used to be silently dropped +now bounds the connection it describes. + +**BREAKING** — `TursoConfigSchema` refuses `localPath` and `wasm`. Neither was +read by any code: the replica arm names its local file via `url` (forwarding +`localPath` would have created a second way to say the same thing), and nothing +selects a WASM build of libSQL (forwarding `wasm` would have meant building +one). The shape is a plain `z.object`, so a bare deletion would have stripped +both keys in silence; they stay declared as `z.never()` tombstones instead — +`tsc` refuses them on anything typed `TursoConfig`, and a value reaching the +parse raises the prescription below rather than a generic unrecognised-key +error. The same treatment this package's `timeout` → `timeoutMs` rename took. + +## Migration + +| Wrote | Write instead | +| --- | --- | +| `localPath: './replica.db'` beside `url: 'file:./replica.db'` | delete `localPath` — `url` names the replica's local file, `syncUrl` the remote primary; a path that differed from `url` belongs in `url` | +| `wasm: true` | delete `wasm` — no WASM build was ever selected; a runtime that cannot load native bindings uses the remote arm (`libsql://` / `https://`), which needs none | + +`@objectstack/spec`'s own turso contract never declared either key, so no stack +source or stored datasource row that passed the spec door can carry them; the +ADR-0087 ledger records the removal as the D3 entry +`driver-turso-config-local-path-wasm-retired` (no D2 conversion — there is no +lossless rewrite for a value that never did anything), which is the +`@objectstack/spec` `minor` here — the entry is a new member of the published migration +registry (`packages/spec/src/migrations/registry.ts`), an additive widening of that package's +surface, and the act sets the floor. diff --git a/docs/design/driver-turso.md b/docs/design/driver-turso.md index 2d26c2115d..d98e6bf2b3 100644 --- a/docs/design/driver-turso.md +++ b/docs/design/driver-turso.md @@ -510,20 +510,25 @@ packages/plugins/driver-turso/ ## 10. Configuration Schema -The `TursoConfigSchema` is defined in `packages/spec/src/data/driver/turso.zod.ts` and supports: +`TursoConfigSchema` exists twice on purpose: the authoring contract in +`packages/spec/src/data/driver/turso.zod.ts` (strict — what a `datasource` may declare), and the +package-published Spec / Studio mirror in `packages/drivers/driver-turso/src/spec/turso.zod.ts`. +Neither declares a key the driver does not read — the direction ADR-0049 (enforce-or-remove) +governs. The converse does not hold, and the mirror is the shorter list: it does not declare +`mode`, which the driver does read (`TursoDriverConfig.mode`). The mirror keeps its retired keys +(`timeout` → `timeoutMs`; `localPath` and `wasm`, removed) as `z.never()` tombstones whose refusal +carries the prescription. The live keys: | Property | Type | Default | Description | |:---|:---|:---:|:---| -| `url` | `string` | (required) | Database URL (`libsql://`, `https://`, `file:`, `:memory:`) | +| `url` | `string` | (required) | Database URL (`libsql://`, `https://`, `file:`, `:memory:`) — in replica mode, also the local file | | `authToken` | `string?` | — | JWT auth token for remote databases | | `encryptionKey` | `string?` | — | AES-256 encryption key for local files | | `concurrency` | `number` | `20` | Maximum concurrent requests | | `syncUrl` | `string?` | — | Remote sync URL for embedded replica mode | -| `localPath` | `string?` | — | Local file path for embedded replica | | `sync.intervalSeconds` | `number` | `60` | Periodic sync interval (0 = manual only) | | `sync.onConnect` | `boolean` | `true` | Sync immediately on connect | -| `timeout` | `number?` | — | Operation timeout in milliseconds | -| `wasm` | `boolean?` | — | Use WASM build for edge/browser environments | +| `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 | --- diff --git a/packages/drivers/driver-turso/README.md b/packages/drivers/driver-turso/README.md index db27a427ae..c518a40858 100644 --- a/packages/drivers/driver-turso/README.md +++ b/packages/drivers/driver-turso/README.md @@ -208,7 +208,17 @@ interface TursoDriverConfig { /** * Operation timeout in milliseconds for remote operations. - * Effective in replica and remote modes. + * 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, and are not 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 + * awaited. + * Not the libSQL busy timeout (`Config.timeout`), which is a local-file + * lock-contention setting that remote clients ignore. */ timeout?: number; diff --git a/packages/drivers/driver-turso/src/spec/turso.test.ts b/packages/drivers/driver-turso/src/spec/turso.test.ts index ce19c484f7..175282ba64 100644 --- a/packages/drivers/driver-turso/src/spec/turso.test.ts +++ b/packages/drivers/driver-turso/src/spec/turso.test.ts @@ -31,25 +31,30 @@ describe('TursoConfigSchema', () => { expect(config.url).toBe(':memory:'); }); + // The replica's local file is named by `url` alone — the fixture used to + // author a `localPath` beside it, which the schema accepted and nothing read + // (#16024). It authors the shape the driver actually consumes. it('should accept embedded replica config', () => { const config = TursoConfigSchema.parse({ url: 'file:./local-replica.db', syncUrl: 'libsql://my-db-orgname.turso.io', authToken: 'eyJhbGciOi...', - localPath: './local-replica.db', sync: { intervalSeconds: 30, onConnect: true, }, }); + expect(config.url).toBe('file:./local-replica.db'); expect(config.syncUrl).toBe('libsql://my-db-orgname.turso.io'); - expect(config.localPath).toBe('./local-replica.db'); expect(config.sync).toBeDefined(); expect(config.sync!.intervalSeconds).toBe(30); expect(config.sync!.onConnect).toBe(true); }); + // "All fields" is every field the driver READS. `localPath` and `wasm` used + // to sit in this fixture too, and their presence here is what the #16024 + // measurement found: accepted, asserted, consumed by nothing. it('should accept config with all fields', () => { const config = TursoConfigSchema.parse({ url: 'libsql://my-db-orgname.turso.io', @@ -57,19 +62,16 @@ describe('TursoConfigSchema', () => { encryptionKey: 'my-secret-key-256', concurrency: 50, syncUrl: 'libsql://my-db-orgname.turso.io', - localPath: '/data/replica.db', sync: { intervalSeconds: 120, onConnect: false, }, timeoutMs: 30000, - wasm: true, }); expect(config.encryptionKey).toBe('my-secret-key-256'); expect(config.concurrency).toBe(50); expect(config.timeoutMs).toBe(30000); - expect(config.wasm).toBe(true); }); it('should apply correct defaults', () => { @@ -81,10 +83,58 @@ describe('TursoConfigSchema', () => { expect(config.authToken).toBeUndefined(); expect(config.encryptionKey).toBeUndefined(); expect(config.syncUrl).toBeUndefined(); - expect(config.localPath).toBeUndefined(); expect(config.sync).toBeUndefined(); expect(config.timeoutMs).toBeUndefined(); - expect(config.wasm).toBeUndefined(); + // The two retired keys are tombstones: declared, unwritable, and absent + // from a parse that never wrote them (mirrors the `timeout` case below). + expect('localPath' in config).toBe(false); + expect('wasm' in config).toBe(false); + }); + + // [#16024, ADR-0049] The two REMOVED keys, asserted on the refusal envelope + // for the same reason the `timeout` tombstone below is: a bare `toThrow()` + // stays green for a schema that lost the tombstone and required `url`, and + // green for a strip that never refused. What must hold is that each key is + // REFUSED, that the refusal names what actually does the job the key + // pretended to (`url` for the replica file; the remote arm for a runtime + // without native bindings), and that it carries the migration command. + it('refuses the removed `localPath`, and the refusal points at `url`', () => { + const result = TursoConfigSchema.safeParse({ + url: 'file:./local-replica.db', + syncUrl: 'libsql://my-db-orgname.turso.io', + localPath: './local-replica.db', + }); + + expect(result.success).toBe(false); + const issue = result.error!.issues.find((i) => i.path.join('.') === 'localPath'); + expect(issue).toBeDefined(); + expect(issue!.message).toContain('`turso config.localPath` was removed'); + expect(issue!.message).toContain('named by `url`'); + expect(issue!.message).toContain('Delete the key'); + expect(issue!.message).toContain('os migrate meta --from 17'); + }); + + it('refuses the removed `wasm`, and the refusal says no WASM build was ever selected', () => { + const result = TursoConfigSchema.safeParse({ url: 'libsql://my-db.turso.io', wasm: true }); + + expect(result.success).toBe(false); + const issue = result.error!.issues.find((i) => i.path.join('.') === 'wasm'); + expect(issue).toBeDefined(); + expect(issue!.message).toContain('`turso config.wasm` was removed'); + expect(issue!.message).toContain('nothing selects a WASM build'); + expect(issue!.message).toContain('Delete the key'); + expect(issue!.message).toContain('os migrate meta --from 17'); + }); + + // The other half, as for `timeout`: a tombstone refuses a VALUE and leaves a + // config that never wrote the key untouched — `false` is a value too. + it('the two tombstones refuse `wasm: false` as firmly as `wasm: true`, and touch nothing else', () => { + expect(TursoConfigSchema.safeParse({ url: ':memory:', wasm: false }).success).toBe(false); + + const config = TursoConfigSchema.parse({ url: 'file:./replica.db', syncUrl: 'libsql://db.turso.io' }); + expect(config.url).toBe('file:./replica.db'); + expect('localPath' in config).toBe(false); + expect('wasm' in config).toBe(false); }); it('should accept https URL', () => { diff --git a/packages/drivers/driver-turso/src/spec/turso.zod.ts b/packages/drivers/driver-turso/src/spec/turso.zod.ts index c3ddc7a9f2..396744dd45 100644 --- a/packages/drivers/driver-turso/src/spec/turso.zod.ts +++ b/packages/drivers/driver-turso/src/spec/turso.zod.ts @@ -62,6 +62,25 @@ const TIMEOUT_RETIRED = + '`timeoutMs`; the value (milliseconds) is unchanged. ' + 'Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand.'; +/** + * The prescriptions the two REMOVED keys raise (ADR-0049 enforce-or-remove). + * Same channels and closing sentence as the rename above; the middle clause + * says what actually names the thing each key pretended to name. + */ +const LOCAL_PATH_RETIRED = + '`turso config.localPath` was removed in @objectstack/driver-turso 17 (ADR-0049) — it never had an ' + + 'effect: no code read it, and the embedded replica\'s local file has always been named by `url` ' + + '(`file:./replica.db`, with `syncUrl` pointing at the remote primary). Delete the key; a path it ' + + 'named that differs from `url` belongs in `url`. ' + + 'Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand.'; + +const WASM_RETIRED = + '`turso config.wasm` was removed in @objectstack/driver-turso 17 (ADR-0049) — it never had an ' + + 'effect: nothing selects a WASM build of libSQL, and the driver loads whatever `@libsql/client` ' + + 'resolves to on the host runtime. Delete the key; a runtime that cannot load native bindings uses ' + + 'the remote arm (`libsql://` / `https://`), which needs none. ' + + 'Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand.'; + export const TursoConfigSchema = lazySchema(() => z.object({ /** * Database URL. @@ -102,12 +121,17 @@ export const TursoConfigSchema = lazySchema(() => z.object({ syncUrl: z.string().optional().describe('Remote sync URL for embedded replica mode'), /** - * Local file path for the embedded replica. - * Required when using embedded replica mode (syncUrl is provided). - * The local file serves reads with microsecond latency while writes - * propagate to the remote primary. + * Tombstone for the REMOVED `localPath` (#16024, ADR-0049 enforce-or-remove). + * + * It promised "Local file path for embedded replica" and was read by no + * code: the replica arm names its local file via `url`, which is what the + * driver's own docs and `@objectstack/spec`'s turso contract both say — + * forwarding it would have created a second way to say the same thing. + * `z.never()` rather than a bare deletion for the reason `timeout` below + * spells out: this shape is a plain `z.object`, and a deletion would strip + * the key in silence. */ - localPath: z.string().optional().describe('Local file path for embedded replica'), + localPath: z.never({ error: () => LOCAL_PATH_RETIRED }).optional().describe(`[REMOVED] ${LOCAL_PATH_RETIRED}`), /** * Sync configuration for embedded replicas. @@ -115,7 +139,7 @@ export const TursoConfigSchema = lazySchema(() => z.object({ sync: TursoSyncConfigSchema.optional().describe('Sync settings for embedded replica mode'), /** - * Timeout for database operations in milliseconds. + * Operation timeout in milliseconds for remote operations; `0` = no bound. * * Renamed from `timeout` (#15682, ruling B on #14478): the unit lived only in * the describe prose, while `sync.intervalSeconds` — the same shape, three @@ -125,8 +149,15 @@ export const TursoConfigSchema = lazySchema(() => z.object({ * key in #15680; this mirror now agrees with it, and with the ADR-0087 * conversion (`turso-config-timeout-to-timeout-ms`) that rewrites the stored * spelling on load. + * + * It reaches the driver as `TursoDriverConfig.timeout` (the datasource seam + * in `@objectstack/service-datasource` maps the authored `timeoutMs` onto the + * driver's bare spelling), and since #16024 that key does what the describe + * promises: remote mode over HTTP aborts every request once the window + * elapses, replica mode bounds `sync()`. `TursoDriverConfig.timeout`'s own + * docblock carries the per-arm detail. */ - timeoutMs: z.number().int().min(0).optional().describe('Operation timeout in milliseconds'), + timeoutMs: z.number().int().min(0).optional().describe('Operation timeout in milliseconds for remote operations (0 = no bound)'), /** * Tombstone for the rename above (#15682, ruling B on #14478). @@ -146,11 +177,16 @@ export const TursoConfigSchema = lazySchema(() => z.object({ timeout: z.never({ error: () => TIMEOUT_RETIRED }).optional().describe(`[REMOVED] ${TIMEOUT_RETIRED}`), /** - * Enable WASM mode. - * When true, uses the WASM build of libSQL for browser or edge runtime - * environments that cannot run native bindings (e.g., Cloudflare Workers). + * Tombstone for the REMOVED `wasm` (#16024, ADR-0049 enforce-or-remove). + * + * It promised "Use WASM build for edge/browser environments" and nothing + * selected one: a browser or edge deployment got whatever + * `import('@libsql/client')` resolved to, with or without the flag. + * Forwarding would have meant building a WASM selection that does not + * exist, so the key goes — as a tombstone, for the same silent-strip reason + * as `localPath` above. */ - wasm: z.boolean().optional().describe('Use WASM build for edge/browser environments'), + wasm: z.never({ error: () => WASM_RETIRED }).optional().describe(`[REMOVED] ${WASM_RETIRED}`), }).describe('Turso/libSQL Connection Configuration')); // ========================================================================== diff --git a/packages/drivers/driver-turso/src/turso-driver-timeout.test.ts b/packages/drivers/driver-turso/src/turso-driver-timeout.test.ts new file mode 100644 index 0000000000..80f8c61a0b --- /dev/null +++ b/packages/drivers/driver-turso/src/turso-driver-timeout.test.ts @@ -0,0 +1,193 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `TursoDriverConfig.timeout` bounds the remote operations the driver performs + * — the promise its docblock has made since the key was declared ("Operation + * timeout in milliseconds for remote operations. Effective in replica and + * remote modes."), delivered by no code until the ADR-0049 enforce-or-remove + * ruling on it. Two arms, two seams, both measured against + * `@libsql/client@0.17.4`: + * + * - REMOTE over HTTP: the hrana transport takes a custom `fetch` + * (`Config.fetch`) and routes EVERY request through it, the protocol-version + * probe included. The driver hands it one that aborts after `timeout` ms. + * The stalled remote here is a REAL `http.Server` that accepts the + * connection and never answers, so what is measured is the platform fetch + * under a real abort — not a stub that honours `signal` by construction. + * - REPLICA: the only remote operation on this arm is `sync()`, and it runs in + * the native `libsql` binding, which consults no `fetch`. The driver bounds + * the awaited `sync()` itself. A stub client whose `sync()` never settles is + * the stalled remote. + * + * Each arm carries a NEGATIVE control — the same stalled remote with no + * `timeout` (and, on the replica arm, `timeout: 0`, the documented "no bound") + * is still pending well past the window — so the failure the positive case + * measures is the key's doing and not the fixture's. The assertions are on the + * refusal ENVELOPE (`code` + `status`, ADR-0112), never on a bare rejection: a + * fixture torn down early rejects too, and a bare `rejects` cannot tell the two + * apart. + */ + +import { createServer, type Server } from 'node:http'; +import type { AddressInfo } from 'node:net'; +import type { Client } from '@libsql/client'; +import { afterEach, describe, expect, it } from 'vitest'; +import { TursoDriver } from './turso-driver'; + +/** The window the positive cases configure, and the slack the box is allowed. */ +const WINDOW_MS = 100; +const CONTROL_WAIT_MS = 1000; +const ELAPSED_BOUND_MS = 5000; + +const PENDING = Symbol('still pending'); + +/** Resolves to `PENDING` when `operation` has not settled within `ms`. */ +function stillPendingAfter(operation: Promise, ms: number): Promise { + let timer: ReturnType | undefined; + const window = new Promise((resolve) => { + timer = setTimeout(() => resolve(PENDING), ms); + }); + return Promise.race([operation, window]).finally(() => clearTimeout(timer)); +} + +/** The rejection an operation produced, or `null` when it resolved. */ +function failureOf(operation: Promise): Promise<(Error & { code?: string; status?: number }) | null> { + return operation.then( + () => null, + (error: Error & { code?: string; status?: number }) => error, + ); +} + +/** + * A remote that accepts every TCP connection and never writes a byte back — + * the shape of a stalled Turso endpoint as the driver's HTTP transport sees it. + */ +async function stalledHttpServer(): Promise<{ url: string; requests: () => number; close: () => Promise }> { + let requests = 0; + const server: Server = createServer(() => { + requests += 1; + // Deliberately no response: the request hangs until the socket is torn down. + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const { port } = server.address() as AddressInfo; + return { + url: `http://127.0.0.1:${port}`, + requests: () => requests, + close: () => + new Promise((resolve) => { + server.closeAllConnections(); + server.close(() => resolve()); + }), + }; +} + +/** A `@libsql/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; +} + +describe('TursoDriverConfig.timeout — remote mode over HTTP', () => { + const cleanups: Array<() => Promise> = []; + afterEach(async () => { + while (cleanups.length > 0) await cleanups.pop()!(); + }); + + it('a stalled remote fails the operation within the configured window, as TIMEOUT / 504', async () => { + const remote = await stalledHttpServer(); + cleanups.push(remote.close); + + const driver = new TursoDriver({ url: remote.url, timeout: WINDOW_MS }); + expect(driver.transportMode).toBe('remote'); + await driver.connect(); + cleanups.push(() => driver.disconnect()); + + const started = Date.now(); + const failure = await failureOf(driver.find('probe', {})); + const elapsed = Date.now() - started; + + expect(failure).not.toBeNull(); + expect(failure!.code).toBe('TIMEOUT'); + expect(failure!.status).toBe(504); + expect(failure!.message).toContain(`${WINDOW_MS} ms`); + expect(failure!.message).toContain('TursoDriverConfig.timeout'); + expect(elapsed).toBeLessThan(ELAPSED_BOUND_MS); + // The remote really was reached — the window closed a live request, not a + // connection that never happened. + expect(remote.requests()).toBeGreaterThan(0); + }); + + it('NEGATIVE CONTROL: with no timeout the same stalled remote leaves the operation pending', async () => { + const remote = await stalledHttpServer(); + cleanups.push(remote.close); + + const driver = new TursoDriver({ url: remote.url }); + await driver.connect(); + cleanups.push(() => driver.disconnect()); + + const operation = driver.find('probe', {}); + // It settles only when the fixture tears the socket down; nobody reads that. + operation.catch(() => {}); + + expect(await stillPendingAfter(operation, CONTROL_WAIT_MS)).toBe(PENDING); + expect(remote.requests()).toBeGreaterThan(0); + }); +}); + +describe('TursoDriverConfig.timeout — replica mode (sync)', () => { + const cleanups: Array<() => Promise> = []; + afterEach(async () => { + while (cleanups.length > 0) await cleanups.pop()!(); + }); + + function replicaDriver(timeout: number | undefined): TursoDriver { + return new TursoDriver({ + url: ':memory:', + syncUrl: 'libsql://primary.example.turso.io', + authToken: 'token', + client: stalledSyncClient(), + sync: { onConnect: false }, + ...(timeout === undefined ? {} : { timeout }), + }); + } + + it('a sync that does not complete within the window rejects as TIMEOUT / 504', async () => { + const driver = replicaDriver(WINDOW_MS); + expect(driver.transportMode).toBe('replica'); + await driver.connect(); + cleanups.push(() => driver.disconnect()); + expect(driver.isSyncEnabled()).toBe(true); + + const started = Date.now(); + const failure = await failureOf(driver.sync()); + const elapsed = Date.now() - started; + + expect(failure).not.toBeNull(); + expect(failure!.code).toBe('TIMEOUT'); + expect(failure!.status).toBe(504); + expect(failure!.message).toContain(`${WINDOW_MS} ms`); + expect(failure!.message).toContain('sync'); + expect(elapsed).toBeLessThan(ELAPSED_BOUND_MS); + }); + + it('NEGATIVE CONTROL: with no timeout the stalled sync is still pending past the window', async () => { + const driver = replicaDriver(undefined); + await driver.connect(); + cleanups.push(() => driver.disconnect()); + + expect(await stillPendingAfter(driver.sync(), CONTROL_WAIT_MS)).toBe(PENDING); + }); + + it('NEGATIVE CONTROL: `timeout: 0` means no bound, as the published schema documents', async () => { + const driver = replicaDriver(0); + await driver.connect(); + cleanups.push(() => driver.disconnect()); + + expect(await stillPendingAfter(driver.sync(), CONTROL_WAIT_MS)).toBe(PENDING); + }); +}); diff --git a/packages/drivers/driver-turso/src/turso-driver.ts b/packages/drivers/driver-turso/src/turso-driver.ts index 4231aeca67..84c634d156 100644 --- a/packages/drivers/driver-turso/src/turso-driver.ts +++ b/packages/drivers/driver-turso/src/turso-driver.ts @@ -103,7 +103,25 @@ export interface TursoDriverConfig { /** * Operation timeout in milliseconds for remote operations. - * Effective in replica and remote modes. + * Effective in replica and remote modes; `0` or unset means no bound. + * + * 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; the key does not bound 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 + * sync is not cancelled, only no longer awaited. + * + * Deliberately NOT forwarded to `@libsql/client`'s `Config.timeout`: that is + * the busy timeout for lock contention on local `file:` databases, which + * "remote clients ignore" — a different setting that happens to share the + * name. */ timeout?: number; @@ -221,6 +239,82 @@ function refuseRemoteAutonumber(object: string, fields: string[], path: string): throw err; } +// ── Remote operation timeout ───────────────────────────────────────────────── + +/** + * The failure a remote operation raises when `TursoDriverConfig.timeout` + * closes on it — the ADR-0112 envelope (`code` + `status`), so a caller and the + * REST layer read a stalled Turso endpoint as a gateway timeout rather than as + * the platform's bare `TimeoutError` DOMException or an anonymous `Error`. + */ +function remoteOperationTimedOut(what: string, timeoutMs: number): Error & { code: string; status: number } { + const err = new Error( + `Turso ${what} did not complete within the configured timeout of ${timeoutMs} ms ` + + `(\`TursoDriverConfig.timeout\`): the remote did not answer inside the window, so the ` + + `operation was abandoned rather than left hanging. Raise \`timeout\`, or omit it for no bound.`, + ) as Error & { code: string; status: number }; + err.code = StandardErrorCode.enum.TIMEOUT; + err.status = 504; + return err; +} + +/** + * The `fetch` handed to `@libsql/client`'s HTTP transport when `timeout` is + * set. + * + * Why this seam and not `Config.timeout`: measured against + * `@libsql/client@0.17.4` (`@libsql/core@0.17.4`), that option is the BUSY + * timeout for lock contention on local `file:` databases — its own docblock + * says "remote clients ignore it" — so forwarding the driver's key to it would + * have left remote mode exactly as inert as before while giving replica mode a + * 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. + * + * 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 + * window itself raised is translated into the timeout envelope. + */ +function fetchBoundedBy(timeoutMs: number): typeof globalThis.fetch { + return async (input, init) => { + const deadline = AbortSignal.timeout(timeoutMs); + const upstream = init?.signal ?? (input instanceof Request ? input.signal : undefined); + const signal = upstream ? AbortSignal.any([upstream, deadline]) : deadline; + try { + return await globalThis.fetch(input, { ...init, signal }); + } catch (error) { + if (deadline.aborted) throw remoteOperationTimedOut('remote request', timeoutMs); + throw error; + } + }; +} + +/** + * Await `operation` for at most `timeoutMs`, rejecting with the timeout + * envelope when the window closes first. The operation itself is not + * cancelled — the replica arm's `sync()` runs in the native binding, which + * offers no cancellation — it is simply no longer what the caller waits on. + * `Promise.race` keeps a handler on it, so a late rejection is observed rather + * than unhandled. + */ +async function boundedBy(operation: Promise, timeoutMs: number, what: string): Promise { + let timer: ReturnType | undefined; + const deadline = new Promise((_, reject) => { + timer = setTimeout(() => reject(remoteOperationTimedOut(what, timeoutMs)), timeoutMs); + }); + try { + return await Promise.race([operation, deadline]); + } finally { + clearTimeout(timer); + } +} + +/** The configured window, or `undefined` for "no bound" — `0` and unset alike. */ +function timeoutWindow(config: TursoDriverConfig): number | undefined { + return config.timeout && config.timeout > 0 ? config.timeout : undefined; +} + // ── Turso Driver ───────────────────────────────────────────────────────────── /** @@ -423,16 +517,7 @@ export class TursoDriver extends SqlDriver { // connect() was never called, failed on first attempt, or the client // was lost (e.g. serverless cold-start, transient network error). this.remoteTransport.setConnectFactory(async () => { - if (this.tursoConfig.client) { - this.libsqlClient = this.tursoConfig.client; - } else { - const { createClient } = await import('@libsql/client'); - this.libsqlClient = createClient({ - url: this.tursoConfig.url, - authToken: this.tursoConfig.authToken, - concurrency: this.tursoConfig.concurrency, - }); - } + this.libsqlClient = this.tursoConfig.client ?? (await this.createRemoteClient()); return this.libsqlClient; }); } @@ -520,6 +605,25 @@ export class TursoDriver extends SqlDriver { }; } + /** + * The `@libsql/client` for the remote arm — one builder for both sites that + * need it (`connect()` and the transport's lazy connect factory), so the two + * cannot drift apart on which config keys reach the client. That drift is how + * `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`. + */ + private async createRemoteClient(): Promise { + const { createClient } = await import('@libsql/client'); + const timeoutMs = timeoutWindow(this.tursoConfig); + return createClient({ + url: this.tursoConfig.url, + authToken: this.tursoConfig.authToken, + concurrency: this.tursoConfig.concurrency, + ...(timeoutMs === undefined ? {} : { fetch: fetchBoundedBy(timeoutMs) }), + }); + } + /** * Check if this driver instance is in remote mode. */ @@ -554,16 +658,7 @@ export class TursoDriver extends SqlDriver { override async connect(): Promise { if (this.isRemote) { // Remote mode: initialize @libsql/client only - if (this.tursoConfig.client) { - this.libsqlClient = this.tursoConfig.client; - } else { - const { createClient } = await import('@libsql/client'); - this.libsqlClient = createClient({ - url: this.tursoConfig.url, - authToken: this.tursoConfig.authToken, - concurrency: this.tursoConfig.concurrency, - }); - } + this.libsqlClient = this.tursoConfig.client ?? (await this.createRemoteClient()); this.remoteTransport!.setClient(this.libsqlClient); return; } @@ -577,6 +672,13 @@ export class TursoDriver extends SqlDriver { this.libsqlClient = this.tursoConfig.client; } else { const { createClient } = await import('@libsql/client'); + // No `fetch` and no `Config.timeout` here, on purpose. This arm is the + // native `libsql` binding (a `file:` url with `syncUrl`), which consults + // no fetch — a wrapped one would be forwarded to a channel that ignores + // it, the inert shape this key just left. And `Config.timeout` is that + // binding's BUSY timeout for local lock contention, not the remote + // operation timeout `TursoDriverConfig.timeout` promises. That promise + // is kept on `sync()`, the one remote operation this arm performs. this.libsqlClient = createClient({ url: this.tursoConfig.url, authToken: this.tursoConfig.authToken, @@ -1412,11 +1514,21 @@ export class TursoDriver extends SqlDriver { /** * Trigger manual sync of the embedded replica with the remote primary. * No-op if no syncUrl is configured or libSQL client is not initialized. + * + * Bounded by `TursoDriverConfig.timeout` when one is set: a sync that has not + * completed within the window rejects with the `TIMEOUT` / 504 envelope. This + * is the replica arm's whole share of that key — the native binding runs the + * sync and offers neither a `fetch` seam nor cancellation, so the bound is on + * what the caller awaits (see `boundedBy`). */ async sync(): Promise { - if (this.libsqlClient && this.tursoConfig.syncUrl) { + if (!(this.libsqlClient && this.tursoConfig.syncUrl)) return; + const timeoutMs = timeoutWindow(this.tursoConfig); + if (timeoutMs === undefined) { await this.libsqlClient.sync(); + return; } + await boundedBy(this.libsqlClient.sync(), timeoutMs, 'embedded replica sync'); } /** diff --git a/packages/spec/src/migrations/entries/semantic/18.driver-turso-config-local-path-wasm-retired.ts b/packages/spec/src/migrations/entries/semantic/18.driver-turso-config-local-path-wasm-retired.ts new file mode 100644 index 0000000000..21ecc03264 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.driver-turso-config-local-path-wasm-retired.ts @@ -0,0 +1,34 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'driver-turso-config-local-path-wasm-retired', + surface: '`@objectstack/driver-turso`\'s published `TursoConfigSchema` — the Spec / Studio mirror ' + + 'of the turso connection config a host may render configuration UI from — keys `localPath` ' + + 'and `wasm`', + replacement: 'delete both keys. The embedded replica\'s local file is named by `url` ' + + '(`file:./replica.db`) with `syncUrl` pointing at the remote primary, which is what the driver ' + + 'has always read; nothing selects a WASM build of libSQL, and a runtime that cannot load native ' + + 'bindings uses the remote arm (`libsql://` / `https://`), which needs none', + reason: + 'ADR-0049 enforce-or-remove, ruled per key on the card that measured them (#16024): both keys ' + + 'were declared on the package schema with a describe promising behaviour ("Local file path for ' + + 'embedded replica", "Use WASM build for edge/browser environments") and were read by no code ' + + '— the driver names the replica file via `url`, and no mechanism picks a WASM build. Forwarding ' + + '`localPath` would have created a second way to say what `url` says; forwarding `wasm` would ' + + 'have meant building a WASM selection that does not exist. Why a semantic entry and not a D2 ' + + 'conversion: `@objectstack/spec`\'s own turso contract (`data/TursoConfig`, strict) never ' + + 'declared either key, so no stack source or stored datasource row that passed the spec door can ' + + 'carry them, and a value that never did anything has no lossless rewrite — the key is deleted by ' + + 'hand. Both stay declared on the package schema as `z.never()` tombstones (the shape is a plain ' + + 'z.object, so a bare deletion would strip in silence) carrying this prescription. The third key ' + + 'the same card measured, `TursoDriverConfig.timeout`, was forwarded rather than removed and ' + + 'needs no entry. ADR-0049, ADR-0087.', + acceptanceCriteria: + 'No `TursoConfigSchema.parse(…)` input spells `localPath` or `wasm`; authoring either fails to ' + + 'compile (input type `never`) and fails to parse with the prescription naming the key. A replica ' + + 'config that named its file only through `url` + `syncUrl` parses byte-identically to before, ' + + 'and every other declared key — `url`, `authToken`, `encryptionKey`, `concurrency`, `syncUrl`, ' + + '`sync`, `timeoutMs` — keeps its bound, default and optionality.', +}; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index d7fd7cc542..4f2b6229ef 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -6782,6 +6782,36 @@ const step18: MigrationStep = { + 'correct but whose other columns belong to a different record, since no error was ever ' + 'raised for those writes.', }, + { + id: 'driver-turso-config-local-path-wasm-retired', + surface: '`@objectstack/driver-turso`\'s published `TursoConfigSchema` — the Spec / Studio mirror ' + + 'of the turso connection config a host may render configuration UI from — keys `localPath` ' + + 'and `wasm`', + replacement: 'delete both keys. The embedded replica\'s local file is named by `url` ' + + '(`file:./replica.db`) with `syncUrl` pointing at the remote primary, which is what the driver ' + + 'has always read; nothing selects a WASM build of libSQL, and a runtime that cannot load native ' + + 'bindings uses the remote arm (`libsql://` / `https://`), which needs none', + reason: + 'ADR-0049 enforce-or-remove, ruled per key on the card that measured them (#16024): both keys ' + + 'were declared on the package schema with a describe promising behaviour ("Local file path for ' + + 'embedded replica", "Use WASM build for edge/browser environments") and were read by no code ' + + '— the driver names the replica file via `url`, and no mechanism picks a WASM build. Forwarding ' + + '`localPath` would have created a second way to say what `url` says; forwarding `wasm` would ' + + 'have meant building a WASM selection that does not exist. Why a semantic entry and not a D2 ' + + 'conversion: `@objectstack/spec`\'s own turso contract (`data/TursoConfig`, strict) never ' + + 'declared either key, so no stack source or stored datasource row that passed the spec door can ' + + 'carry them, and a value that never did anything has no lossless rewrite — the key is deleted by ' + + 'hand. Both stay declared on the package schema as `z.never()` tombstones (the shape is a plain ' + + 'z.object, so a bare deletion would strip in silence) carrying this prescription. The third key ' + + 'the same card measured, `TursoDriverConfig.timeout`, was forwarded rather than removed and ' + + 'needs no entry. ADR-0049, ADR-0087.', + acceptanceCriteria: + 'No `TursoConfigSchema.parse(…)` input spells `localPath` or `wasm`; authoring either fails to ' + + 'compile (input type `never`) and fails to parse with the prescription naming the key. A replica ' + + 'config that named its file only through `url` + `syncUrl` parses byte-identically to before, ' + + 'and every other declared key — `url`, `authToken`, `encryptionKey`, `concurrency`, `syncUrl`, ' + + '`sync`, `timeoutMs` — keeps its bound, default and optionality.', + }, { id: 'element-number-filter-rule-array', surface: