Skip to content

Commit ed5d557

Browse files
claude[bot]claude
andauthored
feat(driver-turso)!: timeout bounds remote operations; localPath and wasm leave the published config schema (ADR-0049 enforce-or-remove) (#16376)
* wip(driver-turso): timeout bounds remote operations; localPath and wasm tombstoned (#16024) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ * docs(changeset): the spec ledger entry is an additive widening — minor, not patch (#16024) The Check Changeset step's WHICH LEVEL rule (maintainer, 2026-09-04, batch #35): a purely additive widening of a published package's public surface takes at least minor, and the act sets the floor. The new D3 entry in packages/spec/src/migrations/registry.ts is that act for @objectstack/spec. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ * docs(driver-turso): the replica arm's sync is not cancelled, and the mirror does not declare `mode` (#16024) Two prose repairs the contract review raised as non-blocking; no behaviour and no schema changes. - packages/drivers/driver-turso/README.md — the README named both arms and the WebSocket gap but dropped the clause `turso-driver.ts`'s docblock and the changeset both carry: on the replica arm the native binding's own sync is not cancelled, only no longer awaited. Wording matched to the docblock. - docs/design/driver-turso.md §10 — "Both declare exactly the keys the driver reads" overstated: the package-published mirror does not declare `mode`, which `TursoDriver.detectMode` reads. The sentence now claims only the direction ADR-0049 governs (no declared key the driver does not read) and names the gap. The gap itself is pre-existing and deliberately left alone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8f2ecb3 commit ed5d557

9 files changed

Lines changed: 579 additions & 46 deletions

File tree

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
---
2+
"@objectstack/driver-turso": minor
3+
"@objectstack/spec": minor
4+
---
5+
6+
feat(driver-turso)!: `timeout` bounds remote operations; `localPath` and `wasm` leave the published config schema (#16024, ADR-0049 enforce-or-remove)
7+
8+
<!-- adr-0087: registered driver-turso-config-local-path-wasm-retired -->
9+
10+
Three keys on this package's published Turso configuration were declared with a
11+
describe promising behaviour that no code delivered — ADR-0049's
12+
declared-but-unenforced shape, sitting beside `concurrency`, which was declared
13+
the same way and IS forwarded. The maintainer ruled per key: forward `timeout`;
14+
remove `localPath` and `wasm`. Not a rename for any of the three — an inert key
15+
with a better name is what ADR-0049 exists to prevent.
16+
17+
**`TursoDriverConfig.timeout` now does what its docblock has always said.** It
18+
never reached `@libsql/client`. It still does not reach that client's own
19+
`Config.timeout`, and deliberately: measured against `@libsql/client@0.17.4`,
20+
that option is the busy timeout for lock contention on local `file:` databases
21+
("remote clients ignore it"), so forwarding to it would have left remote mode
22+
exactly as inert as before. Instead:
23+
24+
- **Remote mode over HTTP** (`libsql://`, `https://`, `http://`): the driver
25+
hands the client a `fetch` that aborts every request once the window elapses,
26+
and the operation fails as `TIMEOUT` / 504 (the ADR-0112 envelope) instead of
27+
hanging on a stalled endpoint. `wss://` / `ws://` URLs ride the WebSocket
28+
transport, which exposes no such seam in this client version — they are not
29+
bounded, and the docblock says so.
30+
- **Replica mode**: `sync()` — the one remote operation on that arm — rejects
31+
with the same envelope when it has not completed within the window. The native
32+
binding's sync is not cancelled, only no longer awaited.
33+
- `0` or unset means no bound, as the published schema already documented.
34+
35+
A datasource authors this as `config.timeoutMs`; the datasource seam maps it
36+
onto the driver's `timeout`, so a `timeoutMs` that used to be silently dropped
37+
now bounds the connection it describes.
38+
39+
**BREAKING**`TursoConfigSchema` refuses `localPath` and `wasm`. Neither was
40+
read by any code: the replica arm names its local file via `url` (forwarding
41+
`localPath` would have created a second way to say the same thing), and nothing
42+
selects a WASM build of libSQL (forwarding `wasm` would have meant building
43+
one). The shape is a plain `z.object`, so a bare deletion would have stripped
44+
both keys in silence; they stay declared as `z.never()` tombstones instead —
45+
`tsc` refuses them on anything typed `TursoConfig`, and a value reaching the
46+
parse raises the prescription below rather than a generic unrecognised-key
47+
error. The same treatment this package's `timeout``timeoutMs` rename took.
48+
49+
## Migration
50+
51+
| Wrote | Write instead |
52+
| --- | --- |
53+
| `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` |
54+
| `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 |
55+
56+
`@objectstack/spec`'s own turso contract never declared either key, so no stack
57+
source or stored datasource row that passed the spec door can carry them; the
58+
ADR-0087 ledger records the removal as the D3 entry
59+
`driver-turso-config-local-path-wasm-retired` (no D2 conversion — there is no
60+
lossless rewrite for a value that never did anything), which is the
61+
`@objectstack/spec` `minor` here — the entry is a new member of the published migration
62+
registry (`packages/spec/src/migrations/registry.ts`), an additive widening of that package's
63+
surface, and the act sets the floor.

docs/design/driver-turso.md

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -510,20 +510,25 @@ packages/plugins/driver-turso/
510510

511511
## 10. Configuration Schema
512512

513-
The `TursoConfigSchema` is defined in `packages/spec/src/data/driver/turso.zod.ts` and supports:
513+
`TursoConfigSchema` exists twice on purpose: the authoring contract in
514+
`packages/spec/src/data/driver/turso.zod.ts` (strict — what a `datasource` may declare), and the
515+
package-published Spec / Studio mirror in `packages/drivers/driver-turso/src/spec/turso.zod.ts`.
516+
Neither declares a key the driver does not read — the direction ADR-0049 (enforce-or-remove)
517+
governs. The converse does not hold, and the mirror is the shorter list: it does not declare
518+
`mode`, which the driver does read (`TursoDriverConfig.mode`). The mirror keeps its retired keys
519+
(`timeout``timeoutMs`; `localPath` and `wasm`, removed) as `z.never()` tombstones whose refusal
520+
carries the prescription. The live keys:
514521

515522
| Property | Type | Default | Description |
516523
|:---|:---|:---:|:---|
517-
| `url` | `string` | (required) | Database URL (`libsql://`, `https://`, `file:`, `:memory:`) |
524+
| `url` | `string` | (required) | Database URL (`libsql://`, `https://`, `file:`, `:memory:`) — in replica mode, also the local file |
518525
| `authToken` | `string?` || JWT auth token for remote databases |
519526
| `encryptionKey` | `string?` || AES-256 encryption key for local files |
520527
| `concurrency` | `number` | `20` | Maximum concurrent requests |
521528
| `syncUrl` | `string?` || Remote sync URL for embedded replica mode |
522-
| `localPath` | `string?` || Local file path for embedded replica |
523529
| `sync.intervalSeconds` | `number` | `60` | Periodic sync interval (0 = manual only) |
524530
| `sync.onConnect` | `boolean` | `true` | Sync immediately on connect |
525-
| `timeout` | `number?` || Operation timeout in milliseconds |
526-
| `wasm` | `boolean?` || Use WASM build for edge/browser environments |
531+
| `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 |
527532

528533
---
529534

packages/drivers/driver-turso/README.md

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -208,7 +208,17 @@ interface TursoDriverConfig {
208208

209209
/**
210210
* Operation timeout in milliseconds for remote operations.
211-
* Effective in replica and remote modes.
211+
* Effective in replica and remote modes; 0 or unset = no bound.
212+
* - Remote mode over HTTP (libsql:// / https:// / http://): every request the
213+
* client makes is aborted once the window elapses, and the operation fails
214+
* as TIMEOUT / 504 instead of hanging. wss:// and ws:// URLs use the
215+
* WebSocket transport, which has no such seam, and are not bounded.
216+
* - Replica mode: bounds sync(), the one remote operation on that arm. A
217+
* sync still running when the window closes rejects with the same
218+
* envelope; the native binding's own sync is not cancelled, only no longer
219+
* awaited.
220+
* Not the libSQL busy timeout (`Config.timeout`), which is a local-file
221+
* lock-contention setting that remote clients ignore.
212222
*/
213223
timeout?: number;
214224

packages/drivers/driver-turso/src/spec/turso.test.ts

Lines changed: 57 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -31,45 +31,47 @@ describe('TursoConfigSchema', () => {
3131
expect(config.url).toBe(':memory:');
3232
});
3333

34+
// The replica's local file is named by `url` alone — the fixture used to
35+
// author a `localPath` beside it, which the schema accepted and nothing read
36+
// (#16024). It authors the shape the driver actually consumes.
3437
it('should accept embedded replica config', () => {
3538
const config = TursoConfigSchema.parse({
3639
url: 'file:./local-replica.db',
3740
syncUrl: 'libsql://my-db-orgname.turso.io',
3841
authToken: 'eyJhbGciOi...',
39-
localPath: './local-replica.db',
4042
sync: {
4143
intervalSeconds: 30,
4244
onConnect: true,
4345
},
4446
});
4547

48+
expect(config.url).toBe('file:./local-replica.db');
4649
expect(config.syncUrl).toBe('libsql://my-db-orgname.turso.io');
47-
expect(config.localPath).toBe('./local-replica.db');
4850
expect(config.sync).toBeDefined();
4951
expect(config.sync!.intervalSeconds).toBe(30);
5052
expect(config.sync!.onConnect).toBe(true);
5153
});
5254

55+
// "All fields" is every field the driver READS. `localPath` and `wasm` used
56+
// to sit in this fixture too, and their presence here is what the #16024
57+
// measurement found: accepted, asserted, consumed by nothing.
5358
it('should accept config with all fields', () => {
5459
const config = TursoConfigSchema.parse({
5560
url: 'libsql://my-db-orgname.turso.io',
5661
authToken: 'eyJhbGciOi...',
5762
encryptionKey: 'my-secret-key-256',
5863
concurrency: 50,
5964
syncUrl: 'libsql://my-db-orgname.turso.io',
60-
localPath: '/data/replica.db',
6165
sync: {
6266
intervalSeconds: 120,
6367
onConnect: false,
6468
},
6569
timeoutMs: 30000,
66-
wasm: true,
6770
});
6871

6972
expect(config.encryptionKey).toBe('my-secret-key-256');
7073
expect(config.concurrency).toBe(50);
7174
expect(config.timeoutMs).toBe(30000);
72-
expect(config.wasm).toBe(true);
7375
});
7476

7577
it('should apply correct defaults', () => {
@@ -81,10 +83,58 @@ describe('TursoConfigSchema', () => {
8183
expect(config.authToken).toBeUndefined();
8284
expect(config.encryptionKey).toBeUndefined();
8385
expect(config.syncUrl).toBeUndefined();
84-
expect(config.localPath).toBeUndefined();
8586
expect(config.sync).toBeUndefined();
8687
expect(config.timeoutMs).toBeUndefined();
87-
expect(config.wasm).toBeUndefined();
88+
// The two retired keys are tombstones: declared, unwritable, and absent
89+
// from a parse that never wrote them (mirrors the `timeout` case below).
90+
expect('localPath' in config).toBe(false);
91+
expect('wasm' in config).toBe(false);
92+
});
93+
94+
// [#16024, ADR-0049] The two REMOVED keys, asserted on the refusal envelope
95+
// for the same reason the `timeout` tombstone below is: a bare `toThrow()`
96+
// stays green for a schema that lost the tombstone and required `url`, and
97+
// green for a strip that never refused. What must hold is that each key is
98+
// REFUSED, that the refusal names what actually does the job the key
99+
// pretended to (`url` for the replica file; the remote arm for a runtime
100+
// without native bindings), and that it carries the migration command.
101+
it('refuses the removed `localPath`, and the refusal points at `url`', () => {
102+
const result = TursoConfigSchema.safeParse({
103+
url: 'file:./local-replica.db',
104+
syncUrl: 'libsql://my-db-orgname.turso.io',
105+
localPath: './local-replica.db',
106+
});
107+
108+
expect(result.success).toBe(false);
109+
const issue = result.error!.issues.find((i) => i.path.join('.') === 'localPath');
110+
expect(issue).toBeDefined();
111+
expect(issue!.message).toContain('`turso config.localPath` was removed');
112+
expect(issue!.message).toContain('named by `url`');
113+
expect(issue!.message).toContain('Delete the key');
114+
expect(issue!.message).toContain('os migrate meta --from 17');
115+
});
116+
117+
it('refuses the removed `wasm`, and the refusal says no WASM build was ever selected', () => {
118+
const result = TursoConfigSchema.safeParse({ url: 'libsql://my-db.turso.io', wasm: true });
119+
120+
expect(result.success).toBe(false);
121+
const issue = result.error!.issues.find((i) => i.path.join('.') === 'wasm');
122+
expect(issue).toBeDefined();
123+
expect(issue!.message).toContain('`turso config.wasm` was removed');
124+
expect(issue!.message).toContain('nothing selects a WASM build');
125+
expect(issue!.message).toContain('Delete the key');
126+
expect(issue!.message).toContain('os migrate meta --from 17');
127+
});
128+
129+
// The other half, as for `timeout`: a tombstone refuses a VALUE and leaves a
130+
// config that never wrote the key untouched — `false` is a value too.
131+
it('the two tombstones refuse `wasm: false` as firmly as `wasm: true`, and touch nothing else', () => {
132+
expect(TursoConfigSchema.safeParse({ url: ':memory:', wasm: false }).success).toBe(false);
133+
134+
const config = TursoConfigSchema.parse({ url: 'file:./replica.db', syncUrl: 'libsql://db.turso.io' });
135+
expect(config.url).toBe('file:./replica.db');
136+
expect('localPath' in config).toBe(false);
137+
expect('wasm' in config).toBe(false);
88138
});
89139

90140
it('should accept https URL', () => {

packages/drivers/driver-turso/src/spec/turso.zod.ts

Lines changed: 47 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,25 @@ const TIMEOUT_RETIRED =
6262
+ '`timeoutMs`; the value (milliseconds) is unchanged. '
6363
+ 'Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand.';
6464

65+
/**
66+
* The prescriptions the two REMOVED keys raise (ADR-0049 enforce-or-remove).
67+
* Same channels and closing sentence as the rename above; the middle clause
68+
* says what actually names the thing each key pretended to name.
69+
*/
70+
const LOCAL_PATH_RETIRED =
71+
'`turso config.localPath` was removed in @objectstack/driver-turso 17 (ADR-0049) — it never had an '
72+
+ 'effect: no code read it, and the embedded replica\'s local file has always been named by `url` '
73+
+ '(`file:./replica.db`, with `syncUrl` pointing at the remote primary). Delete the key; a path it '
74+
+ 'named that differs from `url` belongs in `url`. '
75+
+ 'Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand.';
76+
77+
const WASM_RETIRED =
78+
'`turso config.wasm` was removed in @objectstack/driver-turso 17 (ADR-0049) — it never had an '
79+
+ 'effect: nothing selects a WASM build of libSQL, and the driver loads whatever `@libsql/client` '
80+
+ 'resolves to on the host runtime. Delete the key; a runtime that cannot load native bindings uses '
81+
+ 'the remote arm (`libsql://` / `https://`), which needs none. '
82+
+ 'Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand.';
83+
6584
export const TursoConfigSchema = lazySchema(() => z.object({
6685
/**
6786
* Database URL.
@@ -102,20 +121,25 @@ export const TursoConfigSchema = lazySchema(() => z.object({
102121
syncUrl: z.string().optional().describe('Remote sync URL for embedded replica mode'),
103122

104123
/**
105-
* Local file path for the embedded replica.
106-
* Required when using embedded replica mode (syncUrl is provided).
107-
* The local file serves reads with microsecond latency while writes
108-
* propagate to the remote primary.
124+
* Tombstone for the REMOVED `localPath` (#16024, ADR-0049 enforce-or-remove).
125+
*
126+
* It promised "Local file path for embedded replica" and was read by no
127+
* code: the replica arm names its local file via `url`, which is what the
128+
* driver's own docs and `@objectstack/spec`'s turso contract both say —
129+
* forwarding it would have created a second way to say the same thing.
130+
* `z.never()` rather than a bare deletion for the reason `timeout` below
131+
* spells out: this shape is a plain `z.object`, and a deletion would strip
132+
* the key in silence.
109133
*/
110-
localPath: z.string().optional().describe('Local file path for embedded replica'),
134+
localPath: z.never({ error: () => LOCAL_PATH_RETIRED }).optional().describe(`[REMOVED] ${LOCAL_PATH_RETIRED}`),
111135

112136
/**
113137
* Sync configuration for embedded replicas.
114138
*/
115139
sync: TursoSyncConfigSchema.optional().describe('Sync settings for embedded replica mode'),
116140

117141
/**
118-
* Timeout for database operations in milliseconds.
142+
* Operation timeout in milliseconds for remote operations; `0` = no bound.
119143
*
120144
* Renamed from `timeout` (#15682, ruling B on #14478): the unit lived only in
121145
* the describe prose, while `sync.intervalSeconds` — the same shape, three
@@ -125,8 +149,15 @@ export const TursoConfigSchema = lazySchema(() => z.object({
125149
* key in #15680; this mirror now agrees with it, and with the ADR-0087
126150
* conversion (`turso-config-timeout-to-timeout-ms`) that rewrites the stored
127151
* spelling on load.
152+
*
153+
* It reaches the driver as `TursoDriverConfig.timeout` (the datasource seam
154+
* in `@objectstack/service-datasource` maps the authored `timeoutMs` onto the
155+
* driver's bare spelling), and since #16024 that key does what the describe
156+
* promises: remote mode over HTTP aborts every request once the window
157+
* elapses, replica mode bounds `sync()`. `TursoDriverConfig.timeout`'s own
158+
* docblock carries the per-arm detail.
128159
*/
129-
timeoutMs: z.number().int().min(0).optional().describe('Operation timeout in milliseconds'),
160+
timeoutMs: z.number().int().min(0).optional().describe('Operation timeout in milliseconds for remote operations (0 = no bound)'),
130161

131162
/**
132163
* Tombstone for the rename above (#15682, ruling B on #14478).
@@ -146,11 +177,16 @@ export const TursoConfigSchema = lazySchema(() => z.object({
146177
timeout: z.never({ error: () => TIMEOUT_RETIRED }).optional().describe(`[REMOVED] ${TIMEOUT_RETIRED}`),
147178

148179
/**
149-
* Enable WASM mode.
150-
* When true, uses the WASM build of libSQL for browser or edge runtime
151-
* environments that cannot run native bindings (e.g., Cloudflare Workers).
180+
* Tombstone for the REMOVED `wasm` (#16024, ADR-0049 enforce-or-remove).
181+
*
182+
* It promised "Use WASM build for edge/browser environments" and nothing
183+
* selected one: a browser or edge deployment got whatever
184+
* `import('@libsql/client')` resolved to, with or without the flag.
185+
* Forwarding would have meant building a WASM selection that does not
186+
* exist, so the key goes — as a tombstone, for the same silent-strip reason
187+
* as `localPath` above.
152188
*/
153-
wasm: z.boolean().optional().describe('Use WASM build for edge/browser environments'),
189+
wasm: z.never({ error: () => WASM_RETIRED }).optional().describe(`[REMOVED] ${WASM_RETIRED}`),
154190
}).describe('Turso/libSQL Connection Configuration'));
155191

156192
// ==========================================================================

0 commit comments

Comments
 (0)