Skip to content
Draft
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
63 changes: 63 additions & 0 deletions .changeset/driver-turso-inert-config-keys.md
Original file line number Diff line number Diff line change
@@ -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)

<!-- adr-0087: registered driver-turso-config-local-path-wasm-retired -->

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.
13 changes: 8 additions & 5 deletions docs/design/driver-turso.md
Original file line number Diff line number Diff line change
Expand Up @@ -510,20 +510,23 @@ 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`.
Both declare exactly the keys the driver reads (ADR-0049 enforce-or-remove); 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 |

---

Expand Down
9 changes: 8 additions & 1 deletion packages/drivers/driver-turso/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,14 @@ 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.
* Not the libSQL busy timeout (`Config.timeout`), which is a local-file
* lock-contention setting that remote clients ignore.
*/
timeout?: number;

Expand Down
64 changes: 57 additions & 7 deletions packages/drivers/driver-turso/src/spec/turso.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,45 +31,47 @@ 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',
authToken: 'eyJhbGciOi...',
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', () => {
Expand All @@ -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', () => {
Expand Down
58 changes: 47 additions & 11 deletions packages/drivers/driver-turso/src/spec/turso.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -102,20 +121,25 @@ 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.
*/
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
Expand All @@ -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).
Expand All @@ -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'));

// ==========================================================================
Expand Down
Loading
Loading