From 632bc5cd52efa11fa981bc0bd64fe1fdea702666 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 03:43:42 +0000 Subject: [PATCH 1/5] fix(spec)!: TursoConfigSchema refuses the transport combinations the turso driver refuses or ignores A remote url in a local or replica mode, a url that is none of file:, :memory: or remote in those modes, a replica on an in-memory url, and timeoutMs beside a WebSocket url in remote mode are refused at construction by the driver; syncUrl under a forced mode: 'remote' is constructed and ignored. The spec contract now refuses all five at authoring, each naming the supported spelling. The url describe names the file: spelling instead of "a file path". Claude-Session: https://claude.ai/code/session_01Rjy9MeetSfq34PKn81CRiN Co-authored-by: Claude --- packages/spec/src/data/driver/turso.test.ts | 179 +++++++++++++++- packages/spec/src/data/driver/turso.zod.ts | 213 +++++++++++++++++++- 2 files changed, 390 insertions(+), 2 deletions(-) diff --git a/packages/spec/src/data/driver/turso.test.ts b/packages/spec/src/data/driver/turso.test.ts index db5aa8782ba..bcc30fa7143 100644 --- a/packages/spec/src/data/driver/turso.test.ts +++ b/packages/spec/src/data/driver/turso.test.ts @@ -29,6 +29,18 @@ describe('TursoConfigSchema', () => { { url: ':memory:' }, { url: 'file:./local.db', syncUrl: 'libsql://my-db.turso.io', sync: { intervalSeconds: 60 } }, { url: 'libsql://x.turso.io', concurrency: 10, timeoutMs: 5000, mode: 'remote' }, + // #19977 preservation: what the driver accepts stays accepted. + { url: 'LIBSQL://x.turso.io' }, + { url: 'FILE:./data/replica.db', syncUrl: 'libsql://x.turso.io' }, + { url: 'file:./data/replica.db', mode: 'replica' }, + { url: 'file:./data/app.db', mode: 'local', syncUrl: 'libsql://x.turso.io' }, + { url: 'file:./data/app.db', mode: 'remote' }, + { url: './data/app.db', mode: 'remote' }, + { url: 'wss://x.turso.io' }, + { url: 'https://x.turso.io', timeoutMs: 5000 }, + { url: 'file:./data/replica.db', syncUrl: 'wss://x.turso.io', timeoutMs: 5000 }, + { url: ' file:./data/app.db' }, + { url: 'libsql://x.turso.io', syncUrl: '' }, ]) { const result = TursoConfigSchema.safeParse(config); expect(result.success, JSON.stringify(result.error?.issues)).toBe(true); @@ -173,11 +185,176 @@ describe('TursoConfig.timeout carries its unit (#15680)', () => { }); it('leaves `sync.intervalSeconds` alone — it already carried its unit, and is the neighbour that made the bare `timeout` a collision', () => { + // A replica on a local file (#19977): `syncUrl` beside the remote `base` + // url is a configuration the driver refuses, and this schema now does too. const parsed = TursoConfigSchema.parse({ - ...base, + url: 'file:./data/replica.db', syncUrl: 'libsql://replica.turso.io', sync: { intervalSeconds: 60 }, }); expect(parsed.sync!.intervalSeconds).toBe(60); }); }); + +// #19977 — the transport refusals: every combination `new TursoDriver` refuses +// at construction, plus `syncUrl` under a forced `mode: 'remote'` (which it +// constructs and ignores), refused at authoring. Asserted on the envelope — the +// issue code, the key it sits on, and the spelling it prescribes — never on a +// bare `success: false`, which a schema refusing for some OTHER reason (a +// credential, a placeholder) would satisfy identically. The driver-local mirror +// and the constructor are held to the same table in +// `packages/drivers/driver-turso/src/spec/turso-config-constructor-parity.test.ts`. +describe('TursoConfigSchema refuses what the turso driver refuses (#19977)', () => { + /** The one refusal a config earns, asserted to be the only issue there is. */ + const refusal = (config: Record) => { + const result = TursoConfigSchema.safeParse(config); + expect(result.success, 'the config is refused').toBe(false); + const issues = result.error!.issues; + expect(issues, JSON.stringify(issues)).toHaveLength(1); + expect(issues[0].code).toBe('custom'); + return { path: issues[0].path.join('.'), message: issues[0].message }; + }; + + describe('a remote url in a local or replica mode — refused on `url`', () => { + it('beside `syncUrl`: drop `syncUrl`, or put the replica on a local file', () => { + for (const url of ['libsql://db.turso.io', 'https://db.turso.io', 'http://127.0.0.1:8080', 'wss://db.turso.io', 'Ws://127.0.0.1:8080', 'LIBSQL://db.turso.io']) { + const { path, message } = refusal({ url, syncUrl: 'libsql://db.turso.io' }); + expect(path).toBe('url'); + expect(message).toContain('is a remote'); + expect(message).toContain('`syncUrl` makes this datasource an embedded replica'); + expect(message).toContain('For a remote database, drop `syncUrl` (and `sync`) and keep the remote url alone.'); + expect(message).toContain("keep the remote in `syncUrl`: `url: 'file:./data/replica.db'`."); + } + }); + + it('echoes the scheme as written and nothing else of the url', () => { + const { message } = refusal({ url: 'LIBSQL://secret-host.turso.io', syncUrl: 'libsql://db.turso.io' }); + expect(message).toContain('`url` is a remote `LIBSQL://` url'); + expect(message).not.toContain('secret-host'); + }); + + it("under a forced `mode: 'replica'`: drop `mode` or set it remote", () => { + const { path, message } = refusal({ url: 'libsql://db.turso.io', mode: 'replica' }); + expect(path).toBe('url'); + expect(message).toContain("`mode: 'replica'` makes this datasource an embedded replica"); + expect(message).toContain("For a remote database, drop `mode` (a `libsql://` url is detected as remote) or set `mode: 'remote'`."); + expect(message).toContain("name the remote in `syncUrl`: `url: 'file:./data/replica.db'`."); + }); + + it("under a forced `mode: 'replica'` with `syncUrl`: the remote way out drops `syncUrl` too", () => { + const { message } = refusal({ url: 'libsql://db.turso.io', mode: 'replica', syncUrl: 'libsql://db.turso.io' }); + expect(message).toContain("or set `mode: 'remote'`, and drop `syncUrl` (and `sync`)."); + }); + + it("under a forced `mode: 'local'`: the local way out is a `file:` url", () => { + const { path, message } = refusal({ url: 'https://db.turso.io', mode: 'local' }); + expect(path).toBe('url'); + expect(message).toContain("`mode: 'local'` makes this datasource a local database"); + expect(message).toContain("For a local database, point `url` at a file: `url: 'file:./data/app.db'`."); + }); + }); + + describe('a url that is none of `file:`, `:memory:` or remote, in a local or replica mode — refused on `url`', () => { + it('with no `mode` and no `syncUrl`: a bare path, another scheme, `:MEMORY:`, a scheme with no `//`', () => { + for (const url of ['./data/app.db', 'data/app.db', '/var/lib/app.db', 'sqlite:./x.db', 'memory://', ':MEMORY:', 'libsql:db.turso.io']) { + const { path, message } = refusal({ url }); + expect(path, url).toBe('url'); + expect(message).toContain('`url` is not a url the turso driver can open'); + expect(message).toContain('With no `mode` and no remote scheme, this datasource is a local database'); + expect(message).toContain("spell the path as a `file:` url: `url: 'file:./data/app.db'`."); + expect(message).toContain("For a throwaway in-memory database, `url: ':memory:'`."); + expect(message).toContain('For a remote database, use one of the remote schemes above.'); + } + }); + + it('beside `syncUrl`: the replica spelling, and `syncUrl` named in the remote way out', () => { + const { path, message } = refusal({ url: './data/replica.db', syncUrl: 'libsql://db.turso.io' }); + expect(path).toBe('url'); + expect(message).toContain('`syncUrl` makes this datasource an embedded replica'); + expect(message).toContain("keep the remote in `syncUrl`: `url: 'file:./data/replica.db'`."); + expect(message).toContain('For a remote database, drop `syncUrl` (and `sync`) and use one of the remote schemes above.'); + }); + + it("under a forced `mode: 'local'`: `mode` named in the remote way out", () => { + const { message } = refusal({ url: './data/app.db', mode: 'local' }); + expect(message).toContain("`mode: 'local'` makes this datasource a local database"); + expect(message).toContain("For a remote database, drop `mode: 'local'` and use one of the remote schemes above."); + }); + }); + + describe('a replica on an in-memory url — refused on `url`', () => { + it('`:memory:`, `file::memory:` in any case, and a `:memory:?` query, beside `syncUrl`', () => { + for (const url of [':memory:', 'file::memory:', 'FILE::memory:', 'file::memory:?cache=shared']) { + const { path, message } = refusal({ url, syncUrl: 'libsql://db.turso.io' }); + expect(path, url).toBe('url'); + expect(message).toContain('`url` names an in-memory database, so it cannot hold the embedded replica `syncUrl` asks for'); + expect(message).toContain("Point `url` at a local file (`url: 'file:./data/replica.db'` beside `syncUrl`), or drop `syncUrl` (and `sync`) for a plain in-memory local database."); + } + }); + + it("under a forced `mode: 'replica'`: dropping `mode` is the in-memory way out", () => { + const { message } = refusal({ url: ':memory:', mode: 'replica' }); + expect(message).toContain("cannot hold the embedded replica `mode: 'replica'` asks for"); + expect(message).toContain("or drop `mode: 'replica'` for a plain in-memory local database."); + }); + }); + + it('`timeoutMs` beside a WebSocket url in remote mode — refused on `timeoutMs`', () => { + for (const config of [ + { url: 'wss://db.turso.io', timeoutMs: 5000 }, + { url: 'WS://127.0.0.1:8080', timeoutMs: 5000 }, + { url: 'wss://db.turso.io', timeoutMs: 5000, mode: 'remote' }, + ]) { + const { path, message } = refusal(config); + expect(path, config.url).toBe('timeoutMs'); + expect(message).toContain('rides @libsql/client\'s WebSocket transport'); + expect(message).toContain('Either drop `timeoutMs` and run this remote database unbounded, or keep it and spell the url `libsql://` or `https://`'); + } + }); + + it("`syncUrl` under a forced `mode: 'remote'` — the driver ignores it, so it is refused on `syncUrl`", () => { + for (const config of [ + { url: 'libsql://db.turso.io', mode: 'remote', syncUrl: 'libsql://db.turso.io' }, + { url: 'libsql://db.turso.io', mode: 'remote', syncUrl: 'libsql://db.turso.io', sync: { intervalSeconds: 60 } }, + { url: 'file:./data/replica.db', mode: 'remote', syncUrl: 'libsql://db.turso.io' }, + ]) { + const { path, message } = refusal(config); + expect(path).toBe('syncUrl'); + expect(message).toContain("`syncUrl` configures an embedded replica, but `mode: 'remote'`"); + expect(message).toContain('never hands `syncUrl` to the remote client and runs no sync'); + expect(message).toContain('For a remote database, drop `syncUrl` (and `sync`).'); + expect(message).toContain("For an embedded replica, drop `mode` and point `url` at a local file beside `syncUrl`: `url: 'file:./data/replica.db'`."); + } + }); + + it('the pre-existing `sync`-without-`syncUrl` refusal is unchanged, and stands beside a transport refusal', () => { + const result = TursoConfigSchema.safeParse({ url: './data/app.db', sync: { intervalSeconds: 60 } }); + expect(result.success).toBe(false); + const byPath = Object.fromEntries(result.error!.issues.map((i) => [i.path.join('.'), i])); + expect(Object.keys(byPath).sort()).toEqual(['sync', 'url']); + expect(byPath.sync.message).toBe( + '`sync` configures embedded-replica syncing, which only runs when `syncUrl` names the ' + + 'remote to replicate from. Set `syncUrl`, or remove `sync` — on its own it configures ' + + 'nothing.', + ); + }); + + it('reaches both authoring doors: `DatasourceSchema` (re-pathed under `config`) and `validateDriverConfig`', () => { + const config = { url: 'libsql://db.turso.io', syncUrl: 'libsql://db.turso.io' }; + const datasource = DatasourceSchema.safeParse({ name: 'edge', driver: 'turso', config }); + expect(datasource.success).toBe(false); + const issue = datasource.error!.issues.find((i) => i.path.join('.') === 'config.url'); + expect(issue, JSON.stringify(datasource.error!.issues)).toBeDefined(); + expect(issue!.message).toContain("keep the remote in `syncUrl`: `url: 'file:./data/replica.db'`."); + + const verdict = validateDriverConfig('turso', config); + expect(verdict.known).toBe(true); + expect(verdict.known && verdict.issues.map((i) => i.path.join('.'))).toEqual(['url']); + }); + + it('the `url` describe names the `file:` spelling, never a bare path', () => { + const description = TursoConfigSchema.shape.url.description ?? ''; + expect(description).toContain('file: URL'); + expect(description).not.toContain('a file path'); + }); +}); diff --git a/packages/spec/src/data/driver/turso.zod.ts b/packages/spec/src/data/driver/turso.zod.ts index 12387bd2813..5e1932a6664 100644 --- a/packages/spec/src/data/driver/turso.zod.ts +++ b/packages/spec/src/data/driver/turso.zod.ts @@ -77,6 +77,200 @@ export const TursoTransportModeSchema = z.enum(['local', 'replica', 'remote']) */ export type TursoTransportMode = z.input; +// ========================================================================== +// 1b. Transport coherence — what `new TursoDriver` refuses or ignores +// ========================================================================== +// +// #19977. `url`, `syncUrl`, `mode` and `timeoutMs` each parsed on their own, +// so this shape accepted combinations the driver refuses at construction +// (`VALIDATION_ERROR` / 400) — or constructs and then ignores. Authoring now +// refuses exactly those, with the supported spelling in the message: +// +// - a local or replica mode (forced by `mode`, or selected by `syncUrl` +// beside a `file:` / `:memory:` url, or by a url that is none of those) +// on a url its local SQLite engine cannot open: a remote url, or anything +// that is not a `file:` url or `:memory:` (a bare path, another scheme); +// - a replica on an in-memory url; +// - `timeoutMs` beside a `wss://` / `ws://` url in remote mode; +// - `syncUrl` under a forced `mode: 'remote'`, which the driver accepts and +// then IGNORES: the remote client is built without it, no sync ever runs, +// and the driver's sync call fails as not supported while its sync-enabled +// check still answers true. That arm has no constructor refusal behind it; +// it is refused here because a declared setting that changes nothing is +// the shape ADR-0049 does not ship. +// +// The predicates MIRROR the constructor's on `main` (`localEngineDefect`, +// `refuseWebSocketTimeout` and `detectMode` in +// `packages/drivers/driver-turso/src/turso-driver.ts`): a scheme matches in +// any letter case, `:memory:` is matched exactly, and a `file:` url whose path +// is `:memory:` (or starts `:memory:?`) is in-memory. Nothing the constructor +// accepts is refused here, the `syncUrl`-under-`mode: 'remote'` arm aside. The +// url is classified TRIMMED, because +// both loaders trim it before construction (`resolveTursoUrl` in +// `@objectstack/service-datasource`); `syncUrl` counts when it is a non-empty +// string, as `buildTursoDriverConfig` forwards it. The driver-local mirror +// (`packages/drivers/driver-turso/src/spec/turso.zod.ts`) carries the same +// predicates and messages, and `turso-config-constructor-parity.test.ts` there +// holds both copies and the constructor to one case table. +// +// ⛔ No url is echoed, only a remote url's scheme: a url may carry a token. +// ⛔ No internal issue id in any message: they reach Studio's datasource form. + +/** The url prefixes the driver classifies as remote, matched in any letter case. */ +const TURSO_REMOTE_URL_PREFIXES = ['libsql://', 'https://', 'http://', 'wss://', 'ws://'] as const; + +function startsWithScheme(url: string, prefix: string): boolean { + return url.slice(0, prefix.length).toLowerCase() === prefix; +} + +function isRemoteTursoUrl(url: string): boolean { + return TURSO_REMOTE_URL_PREFIXES.some((prefix) => startsWithScheme(url, prefix)); +} + +function isFileTursoUrl(url: string): boolean { + return startsWithScheme(url, 'file:'); +} + +/** `@libsql/client`'s own in-memory reading: `:memory:`, or a `file:` url whose path is `:memory:`. */ +function namesInMemoryTursoDatabase(url: string): boolean { + if (url === ':memory:') return true; + if (!isFileTursoUrl(url)) return false; + const path = url.slice('file:'.length); + return path === ':memory:' || path.startsWith(':memory:?'); +} + +/** The mode the driver runs a config in: the forced one, else what the url and `syncUrl` select. */ +function tursoTransportModeOf(url: string, hasSyncUrl: boolean, mode: TursoTransportMode | undefined): TursoTransportMode { + if (mode) return mode; + if (isRemoteTursoUrl(url)) return hasSyncUrl ? 'replica' : 'remote'; + return hasSyncUrl ? 'replica' : 'local'; +} + +/** The keys the transport refusals read, as they arrive in the refinement. */ +interface TursoTransportKeys { + url?: unknown; + syncUrl?: unknown; + mode?: TursoTransportMode; + timeoutMs?: unknown; +} + +/** One refusal: the key it sits on and its message. */ +interface TursoTransportIssue { + path: 'url' | 'syncUrl' | 'timeoutMs'; + message: string; +} + +/** + * Every transport refusal a turso config earns — empty for a coherent one. + * Checked in the constructor's order, so each config meets the refusal that + * names its way out. + */ +function tursoTransportIssues(cfg: TursoTransportKeys): TursoTransportIssue[] { + if (typeof cfg.url !== 'string') return []; + const url = cfg.url.trim(); + const hasSyncUrl = typeof cfg.syncUrl === 'string' && cfg.syncUrl.length > 0; + const mode = tursoTransportModeOf(url, hasSyncUrl, cfg.mode); + const cause = cfg.mode ? `\`mode: '${cfg.mode}'\`` : '`syncUrl`'; + const syncUrlKeys = '`syncUrl` (and `sync`)'; + + if (mode !== 'remote') { + const arm = mode === 'replica' ? 'an embedded replica' : 'a local database'; + if (isRemoteTursoUrl(url)) { + const scheme = url.slice(0, url.indexOf('://') + '://'.length); + const toRemote = cfg.mode + ? `drop \`mode\` (a \`${scheme}\` url is detected as remote) or set \`mode: 'remote'\`` + + (hasSyncUrl ? `, and drop ${syncUrlKeys}` : '') + : `drop ${syncUrlKeys} and keep the remote url alone`; + const toLocal = mode === 'replica' + ? `For an embedded replica, point \`url\` at a local file and ${hasSyncUrl ? 'keep' : 'name'} the ` + + "remote in `syncUrl`: `url: 'file:./data/replica.db'`." + : "For a local database, point `url` at a file: `url: 'file:./data/app.db'`."; + return [{ + path: 'url', + message: + `\`url\` is a remote \`${scheme}\` url, but ${cause} makes this datasource ${arm}, which runs ` + + 'every read and write through a local SQLite engine that cannot open a remote url — the ' + + 'turso driver refuses this configuration when it starts. ' + + (mode === 'replica' + ? '(An embedded replica is a local file kept in sync with the remote: @libsql/client builds ' + + 'no replica for a remote url and ignores `syncUrl` beside one.) ' + : '') + + `For a remote database, ${toRemote}. ${toLocal}`, + }]; + } + if (url !== ':memory:' && !isFileTursoUrl(url)) { + const asks = cfg.mode + ? `${cause} makes this datasource ${arm}` + : hasSyncUrl + ? `\`syncUrl\` makes this datasource ${arm}` + : `With no \`mode\` and no remote scheme, this datasource is ${arm}`; + const keepsItLocal = [ + ...(cfg.mode ? [`\`mode: '${cfg.mode}'\``] : []), + ...(hasSyncUrl ? [syncUrlKeys] : []), + ]; + const toFile = mode === 'replica' + ? 'For an embedded replica, spell the local path as a `file:` url and ' + + `${hasSyncUrl ? 'keep' : 'name'} the remote in \`syncUrl\`: \`url: 'file:./data/replica.db'\`.` + : "For a local database file, spell the path as a `file:` url: `url: 'file:./data/app.db'`. " + + "For a throwaway in-memory database, `url: ':memory:'`."; + const toRemote = 'For a remote database, ' + + (keepsItLocal.length > 0 ? `drop ${keepsItLocal.join(' and ')} and ` : '') + + 'use one of the remote schemes above.'; + return [{ + path: 'url', + message: + '`url` is not a url the turso driver can open: it is not `:memory:`, not a `file:` url, and ' + + 'not a remote `libsql://`, `https://`, `http://`, `wss://` or `ws://` url (a scheme matches ' + + `in any letter case). ${asks}, which runs every read and write through a local SQLite ` + + 'engine that opens only a `file:` url or `:memory:` — the turso driver refuses this ' + + `configuration when it starts. ${toFile} ${toRemote}`, + }]; + } + if (mode === 'replica' && namesInMemoryTursoDatabase(url)) { + const drop = cfg.mode + ? "`mode: 'replica'`" + (hasSyncUrl ? ` and ${syncUrlKeys}` : '') + : syncUrlKeys; + return [{ + path: 'url', + message: + `\`url\` names an in-memory database, so it cannot hold the embedded replica ${cause} asks ` + + 'for: a replica is a local file kept in sync with the remote named in `syncUrl` — the ' + + "turso driver refuses this configuration when it starts. Point `url` at a local file " + + "(`url: 'file:./data/replica.db'` beside `syncUrl`), or drop " + + `${drop} for a plain in-memory local database.`, + }]; + } + return []; + } + + const issues: TursoTransportIssue[] = []; + const ridesWebSocket = startsWithScheme(url, 'wss://') || startsWithScheme(url, 'ws://'); + if (ridesWebSocket && typeof cfg.timeoutMs === 'number' && cfg.timeoutMs > 0) { + const scheme = url.slice(0, url.indexOf('://') + '://'.length); + issues.push({ + path: 'timeoutMs', + message: + `\`timeoutMs\` is set beside a \`${scheme}\` url, which rides @libsql/client's WebSocket ` + + 'transport, and that transport takes no timeout: the window would bound nothing, so the ' + + 'turso driver refuses this configuration when it starts. Either drop `timeoutMs` and run ' + + 'this remote database unbounded, or keep it and spell the url `libsql://` or `https://`, ' + + 'where every request is bounded.', + }); + } + if (hasSyncUrl) { + issues.push({ + path: 'syncUrl', + message: + "`syncUrl` configures an embedded replica, but `mode: 'remote'` sends every read and write " + + 'straight to `url` and builds no replica: the turso driver never hands `syncUrl` to the ' + + 'remote client and runs no sync, so the setting changes nothing. For a remote database, ' + + `drop ${syncUrlKeys}. For an embedded replica, drop \`mode\` and point \`url\` at a local ` + + "file beside `syncUrl`: `url: 'file:./data/replica.db'`.", + }); + } + return issues; +} + export const TursoConfigSchema = lazySchema(() => strictObject( { surface: "this turso datasource's config", @@ -150,6 +344,12 @@ export const TursoConfigSchema = lazySchema(() => strictObject( * authored metadata are resolved by nothing. Runtime-environment * DSNs (`OS_DATABASE_URL` + `OS_DATABASE_AUTH_TOKEN`) never pass through * this schema and are unaffected. + * + * Transport-coherent since #19977: the url must be one the transport the + * config selects can open — see {@link tursoTransportIssues} at the foot + * of this shape. In a local or replica mode that is a `file:` url or + * `:memory:` (a replica: a `file:` url that is not in-memory), exactly the + * set `new TursoDriver` accepts there. */ // The description names the SHAPES in words rather than pasting URL // prefixes, matching how the postgres/mysql/mongo `url` keys describe @@ -159,8 +359,14 @@ export const TursoConfigSchema = lazySchema(() => strictObject( // docs link checker resolves as an internationalised domain name, and // fails on (caught by CI on this very key). Concrete example URLs belong // in the TSDoc above the key, which the reference tables do not inline. + // + // "a file path" used to stand where "a file: URL" stands now, and it named + // the one spelling the driver refuses: a bare path (`./data/app.db`) is not + // a url, `@libsql/client` refuses it, and `new TursoDriver` refuses it in a + // local or replica mode. The transport refusals at the foot of this shape + // refuse it here too, so the describe may not invite it. url: placeholderFree(credentialFreeUrl(z.string().min(1), 'url', CREDENTIAL_URL_QUERY_PARAMS.turso), 'url') - .describe('libSQL endpoint or local file: a remote libsql/https Turso URL, a file path, or :memory:') + .describe('libSQL endpoint or local file: a remote libsql/https Turso URL, a local file written as a file: URL (never a bare path), or :memory:') .meta({ title: 'Database URL' }), /** @@ -261,6 +467,11 @@ export const TursoConfigSchema = lazySchema(() => strictObject( + 'nothing.', }); } + // What the driver refuses at construction, or constructs and ignores — + // see `tursoTransportIssues` above the shape. + for (const issue of tursoTransportIssues(cfg)) { + ctx.addIssue({ code: 'custom', path: [issue.path], message: issue.message }); + } })); /** From bffe3f6db3c6c02a5462340219f237900deaae04 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 03:55:34 +0000 Subject: [PATCH 2/5] fix(driver-turso)!: the published TursoConfigSchema mirror refuses what the driver refuses or ignores The mirror carries the spec contract's transport refusals byte for byte, plus its sync-without-syncUrl refusal. One case table holds the constructor, the spec contract and this mirror to the same verdicts. Claude-Session: https://claude.ai/code/session_01Rjy9MeetSfq34PKn81CRiN Co-authored-by: Claude --- .../turso-config-constructor-parity.test.ts | 222 ++++++++++++++++++ .../driver-turso/src/spec/turso.test.ts | 27 ++- .../driver-turso/src/spec/turso.zod.ts | 208 ++++++++++++++++ 3 files changed, 452 insertions(+), 5 deletions(-) create mode 100644 packages/drivers/driver-turso/src/spec/turso-config-constructor-parity.test.ts diff --git a/packages/drivers/driver-turso/src/spec/turso-config-constructor-parity.test.ts b/packages/drivers/driver-turso/src/spec/turso-config-constructor-parity.test.ts new file mode 100644 index 00000000000..824049059ba --- /dev/null +++ b/packages/drivers/driver-turso/src/spec/turso-config-constructor-parity.test.ts @@ -0,0 +1,222 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#19977] One case table for the three places a turso config is judged: + * + * 1. `new TursoDriver(...)` — the runtime's refused set, `VALIDATION_ERROR` / 400; + * 2. `@objectstack/spec`'s `TursoConfigSchema` — the authoring contract a + * `datasource.config` is parsed against (`DatasourceSchema`, the Setup + * wizard's `validateDriverConfig`); + * 3. this package's own `TursoConfigSchema` mirror (`./turso.zod.ts`). + * + * The two schemas used to accept every combination below, so a datasource the + * driver refuses at construction (or constructs and ignores) was accepted at + * authoring and failed a boot later. Each row states the constructor's verdict + * and the schemas' verdict, and the test asserts all three against it: + * + * - a row the constructor REFUSES is refused by both schemas, on the key the + * row names, with one `custom` issue; + * - a row the constructor ACCEPTS is accepted by both — except the `inert` + * rows, which the constructor builds and then ignores a key of (`syncUrl` + * under a forced `mode: 'remote'`; `sync` with no `syncUrl`). Those are + * refused at authoring only, because a declared setting that changes + * nothing is the shape ADR-0049 does not ship; + * - the two schemas' messages are byte-identical wherever both judge a row. + * + * ⚠️ The mirror declares no `mode`, so zod strips an authored one before its + * refinement runs: rows that FORCE a mode are judged by the constructor and the + * spec contract only. That shortness is the mirror's, documented in + * `docs/design/driver-turso.md` §10, and is not this card's to change. + * + * ⚠️ The spec half resolves `@objectstack/spec/data` through its `exports`, i.e. + * the BUILT `packages/spec/dist` (this package has no source alias for it — + * `KNOWN_UNALIASED_TEST_IMPORTS` in `scripts/check-test-source-alias.mjs`). + * Rebuild the spec before reading this file's verdict on a spec edit. + * + * Not in the table, deliberately: `timeout` beside a pre-built `client` (also + * refused by the constructor) — `client` is a live object, not authorable + * config, so neither schema can see it. + */ + +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterAll, describe, expect, it } from 'vitest'; +import { TursoConfigSchema as SpecTursoConfigSchema } from '@objectstack/spec/data'; + +import { TursoDriver, type TursoDriverConfig } from '../turso-driver'; +import { TursoConfigSchema as MirrorTursoConfigSchema } from './turso.zod'; + +const DIR = mkdtempSync(join(tmpdir(), 'turso-19977-parity-')); +afterAll(() => rmSync(DIR, { recursive: true, force: true })); + +const FILE = `file:${DIR}/app.db`; +const REMOTE = 'libsql://db.example.turso.io'; + +type Authored = { + url: string; + syncUrl?: string; + sync?: { intervalSeconds?: number; onConnect?: boolean }; + mode?: 'local' | 'replica' | 'remote'; + timeoutMs?: number; +}; + +interface Row { + name: string; + config: Authored; + /** The constructor's verdict on this config. */ + ctor: 'accept' | 'refuse'; + /** The key both schemas refuse on, or `undefined` when they accept. */ + refusedOn?: 'url' | 'syncUrl' | 'timeoutMs' | 'sync'; + /** The constructor accepts it and ignores a key: refused at authoring only. */ + inert?: true; +} + +const ROWS: Row[] = [ + // ── accepted everywhere ───────────────────────────────────────────────── + { name: 'a file: url', config: { url: FILE }, ctor: 'accept' }, + { name: ':memory:', config: { url: ':memory:' }, ctor: 'accept' }, + { name: 'a libsql:// url', config: { url: REMOTE }, ctor: 'accept' }, + { name: 'an uppercase LIBSQL:// url', config: { url: 'LIBSQL://db.example.turso.io' }, ctor: 'accept' }, + { name: 'an https:// url', config: { url: 'https://db.example.turso.io' }, ctor: 'accept' }, + { name: 'an http:// url', config: { url: 'http://127.0.0.1:8080' }, ctor: 'accept' }, + { name: 'a wss:// url', config: { url: 'wss://db.example.turso.io' }, ctor: 'accept' }, + { name: 'a ws:// url', config: { url: 'ws://127.0.0.1:8080' }, ctor: 'accept' }, + { name: 'a replica: file: + syncUrl', config: { url: FILE, syncUrl: REMOTE, sync: { onConnect: false } }, ctor: 'accept' }, + { name: 'a replica on an uppercase FILE: url', config: { url: `FILE:${DIR}/upper.db`, syncUrl: REMOTE }, ctor: 'accept' }, + { name: 'a replica bounded by timeoutMs, syncing from wss://', config: { url: FILE, syncUrl: 'wss://db.example.turso.io', timeoutMs: 5000 }, ctor: 'accept' }, + { name: 'timeoutMs beside libsql://', config: { url: REMOTE, timeoutMs: 5000 }, ctor: 'accept' }, + { name: 'timeoutMs beside https://', config: { url: 'https://db.example.turso.io', timeoutMs: 5000 }, ctor: 'accept' }, + { name: 'a url behind whitespace (the loaders trim it)', config: { url: ` ${FILE}` }, ctor: 'accept' }, + { name: 'a remote url behind whitespace', config: { url: ` ${REMOTE}` }, ctor: 'accept' }, + { name: 'an empty syncUrl (unset)', config: { url: REMOTE, syncUrl: '' }, ctor: 'accept' }, + { name: "file: under a forced mode: 'replica'", config: { url: FILE, mode: 'replica' }, ctor: 'accept' }, + { name: "file: + syncUrl under a forced mode: 'local'", config: { url: FILE, mode: 'local', syncUrl: REMOTE, sync: { onConnect: false } }, ctor: 'accept' }, + { name: "libsql:// under a forced mode: 'remote'", config: { url: REMOTE, mode: 'remote' }, ctor: 'accept' }, + { name: "file: under a forced mode: 'remote'", config: { url: FILE, mode: 'remote' }, ctor: 'accept' }, + { name: "a bare path under a forced mode: 'remote' (the client refuses it at connect)", config: { url: './data/app.db', mode: 'remote' }, ctor: 'accept' }, + + // ── a remote url in a local or replica mode ───────────────────────────── + { name: 'libsql:// beside syncUrl', config: { url: REMOTE, syncUrl: REMOTE }, ctor: 'refuse', refusedOn: 'url' }, + { name: 'https:// beside syncUrl', config: { url: 'https://db.example.turso.io', syncUrl: REMOTE }, ctor: 'refuse', refusedOn: 'url' }, + { name: 'http:// beside syncUrl', config: { url: 'http://127.0.0.1:8080', syncUrl: REMOTE }, ctor: 'refuse', refusedOn: 'url' }, + { name: 'wss:// beside syncUrl', config: { url: 'wss://db.example.turso.io', syncUrl: REMOTE }, ctor: 'refuse', refusedOn: 'url' }, + { name: 'Ws:// beside syncUrl', config: { url: 'Ws://127.0.0.1:8080', syncUrl: REMOTE }, ctor: 'refuse', refusedOn: 'url' }, + { name: 'LIBSQL:// beside syncUrl', config: { url: 'LIBSQL://db.example.turso.io', syncUrl: REMOTE }, ctor: 'refuse', refusedOn: 'url' }, + { name: "libsql:// under a forced mode: 'replica'", config: { url: REMOTE, mode: 'replica' }, ctor: 'refuse', refusedOn: 'url' }, + { name: "libsql:// + syncUrl under a forced mode: 'replica'", config: { url: REMOTE, mode: 'replica', syncUrl: REMOTE }, ctor: 'refuse', refusedOn: 'url' }, + { name: "libsql:// under a forced mode: 'local'", config: { url: REMOTE, mode: 'local' }, ctor: 'refuse', refusedOn: 'url' }, + { name: "https:// under a forced mode: 'local'", config: { url: 'https://db.example.turso.io', mode: 'local' }, ctor: 'refuse', refusedOn: 'url' }, + + // ── a url that is none of file:, :memory: or remote, in a local or replica mode ── + { name: 'a bare relative path', config: { url: './data/app.db' }, ctor: 'refuse', refusedOn: 'url' }, + { name: 'a bare path with no dot', config: { url: 'data/app.db' }, ctor: 'refuse', refusedOn: 'url' }, + { name: 'a bare absolute path', config: { url: '/var/lib/app.db' }, ctor: 'refuse', refusedOn: 'url' }, + { name: 'an unsupported sqlite: scheme', config: { url: 'sqlite:./x.db' }, ctor: 'refuse', refusedOn: 'url' }, + { name: 'memory://', config: { url: 'memory://' }, ctor: 'refuse', refusedOn: 'url' }, + { name: ':MEMORY: (matched exactly, like the client)', config: { url: ':MEMORY:' }, ctor: 'refuse', refusedOn: 'url' }, + { name: 'a remote scheme with no //', config: { url: 'libsql:db.example.turso.io' }, ctor: 'refuse', refusedOn: 'url' }, + { name: 'a whitespace-only url', config: { url: ' ' }, ctor: 'refuse', refusedOn: 'url' }, + { name: 'a bare path beside syncUrl', config: { url: './data/replica.db', syncUrl: REMOTE }, ctor: 'refuse', refusedOn: 'url' }, + { name: "a bare path under a forced mode: 'local'", config: { url: './data/app.db', mode: 'local' }, ctor: 'refuse', refusedOn: 'url' }, + { name: "a bare path under a forced mode: 'replica'", config: { url: './data/replica.db', mode: 'replica' }, ctor: 'refuse', refusedOn: 'url' }, + + // ── a replica on an in-memory url ─────────────────────────────────────── + { name: ':memory: beside syncUrl', config: { url: ':memory:', syncUrl: REMOTE }, ctor: 'refuse', refusedOn: 'url' }, + { name: 'file::memory: beside syncUrl', config: { url: 'file::memory:', syncUrl: REMOTE }, ctor: 'refuse', refusedOn: 'url' }, + { name: 'FILE::memory: beside syncUrl', config: { url: 'FILE::memory:', syncUrl: REMOTE }, ctor: 'refuse', refusedOn: 'url' }, + { name: 'file::memory:?cache=shared beside syncUrl', config: { url: 'file::memory:?cache=shared', syncUrl: REMOTE }, ctor: 'refuse', refusedOn: 'url' }, + { name: ":memory: under a forced mode: 'replica'", config: { url: ':memory:', mode: 'replica' }, ctor: 'refuse', refusedOn: 'url' }, + + // ── timeoutMs beside a WebSocket url in remote mode ───────────────────── + { name: 'timeoutMs beside wss://', config: { url: 'wss://db.example.turso.io', timeoutMs: 5000 }, ctor: 'refuse', refusedOn: 'timeoutMs' }, + { name: 'timeoutMs beside an uppercase WS://', config: { url: 'WS://127.0.0.1:8080', timeoutMs: 5000 }, ctor: 'refuse', refusedOn: 'timeoutMs' }, + { name: "timeoutMs beside wss:// under a forced mode: 'remote'", config: { url: 'wss://db.example.turso.io', timeoutMs: 5000, mode: 'remote' }, ctor: 'refuse', refusedOn: 'timeoutMs' }, + + // ── accepted by the constructor, ignored by the driver: refused at authoring ── + { name: "syncUrl under a forced mode: 'remote'", config: { url: REMOTE, mode: 'remote', syncUrl: REMOTE }, ctor: 'accept', refusedOn: 'syncUrl', inert: true }, + { name: "syncUrl + sync under a forced mode: 'remote'", config: { url: REMOTE, mode: 'remote', syncUrl: REMOTE, sync: { intervalSeconds: 60 } }, ctor: 'accept', refusedOn: 'syncUrl', inert: true }, + { name: "file: + syncUrl under a forced mode: 'remote'", config: { url: FILE, mode: 'remote', syncUrl: REMOTE }, ctor: 'accept', refusedOn: 'syncUrl', inert: true }, + { name: 'sync with no syncUrl', config: { url: FILE, sync: { intervalSeconds: 60 } }, ctor: 'accept', refusedOn: 'sync', inert: true }, +]; + +/** + * The driver config a datasource loader builds from an authored one — the + * reading `buildTursoDriverConfig` / `resolveTursoUrl` in + * `@objectstack/service-datasource` perform (not a dependency of this package, + * hence spelled here): the url TRIMMED, an empty `syncUrl` unset, and the + * authored `timeoutMs` landing on the driver's `timeout`. + */ +function driverConfigOf(authored: Authored): TursoDriverConfig { + const { url, syncUrl, sync, mode, timeoutMs } = authored; + return { + url: url.trim(), + ...(syncUrl ? { syncUrl } : {}), + ...(sync ? { sync } : {}), + ...(mode ? { mode } : {}), + ...(timeoutMs !== undefined ? { timeout: timeoutMs } : {}), + }; +} + +function constructorVerdict(authored: Authored): 'accept' | 'refuse' { + try { + new TursoDriver(driverConfigOf(authored)); + return 'accept'; + } catch (error) { + // A refusal is the ADR-0112 envelope, never some other throw. + expect((error as { code?: string }).code).toBe('VALIDATION_ERROR'); + expect((error as { status?: number }).status).toBe(400); + return 'refuse'; + } +} + +type Verdict = { refusedOn?: string; code?: string; message?: string; count: number }; + +function schemaVerdict(schema: { safeParse: (input: unknown) => any }, config: unknown): Verdict { + const result = schema.safeParse(config); + if (result.success) return { count: 0 }; + const issues = result.error.issues as Array<{ code: string; path: PropertyKey[]; message: string }>; + return { + refusedOn: issues[0].path.join('.'), + code: issues[0].code, + message: issues[0].message, + count: issues.length, + }; +} + +describe('turso config: the constructor, the spec contract and this mirror agree (#19977)', () => { + it('the table covers every verdict shape it claims to', () => { + // A table that silently lost a class of rows would still pass every + // per-row assertion below; these floors make the loss loud. + expect(ROWS.filter((r) => r.ctor === 'accept' && !r.refusedOn).length).toBeGreaterThanOrEqual(20); + expect(ROWS.filter((r) => r.refusedOn === 'url').length).toBeGreaterThanOrEqual(25); + expect(ROWS.filter((r) => r.refusedOn === 'timeoutMs').length).toBeGreaterThanOrEqual(3); + expect(ROWS.filter((r) => r.inert).length).toBeGreaterThanOrEqual(4); + expect(ROWS.filter((r) => !r.config.mode).length).toBeGreaterThanOrEqual(35); + }); + + describe.each(ROWS)('$name', (row) => { + it(`the constructor ${row.ctor}s it`, () => { + expect(constructorVerdict(row.config)).toBe(row.ctor); + }); + + it(row.refusedOn ? `the spec contract refuses it on \`${row.refusedOn}\`` : 'the spec contract accepts it', () => { + // Authoring refuses what construction refuses, and nothing it accepts + // but the declared-and-ignored keys. + expect(Boolean(row.refusedOn)).toBe(row.ctor === 'refuse' || Boolean(row.inert)); + const verdict = schemaVerdict(SpecTursoConfigSchema, row.config); + expect(verdict.refusedOn, verdict.message).toBe(row.refusedOn); + if (row.refusedOn) { + expect(verdict.count, verdict.message).toBe(1); + expect(verdict.code).toBe('custom'); + } + }); + + it.skipIf(row.config.mode !== undefined)('this mirror gives the same verdict, in the same words', () => { + const spec = schemaVerdict(SpecTursoConfigSchema, row.config); + const mirror = schemaVerdict(MirrorTursoConfigSchema, row.config); + expect(mirror).toEqual(spec); + }); + }); +}); diff --git a/packages/drivers/driver-turso/src/spec/turso.test.ts b/packages/drivers/driver-turso/src/spec/turso.test.ts index 175282ba641..420cf5f9fef 100644 --- a/packages/drivers/driver-turso/src/spec/turso.test.ts +++ b/packages/drivers/driver-turso/src/spec/turso.test.ts @@ -55,9 +55,13 @@ describe('TursoConfigSchema', () => { // "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. + // [#19977] An embedded replica — the one mode that reads every field — on a + // local `file:` url. The fixture used to put `syncUrl` beside a remote + // `libsql://` url, a configuration the driver refuses at construction and + // this schema now refuses too. it('should accept config with all fields', () => { const config = TursoConfigSchema.parse({ - url: 'libsql://my-db-orgname.turso.io', + url: 'file:./local-replica.db', authToken: 'eyJhbGciOi...', encryptionKey: 'my-secret-key-256', concurrency: 50, @@ -210,14 +214,27 @@ describe('TursoConfigSchema', () => { expect('timeout' in config).toBe(false); }); - it('should accept config with environment variable patterns', () => { - const config = TursoConfigSchema.parse({ + // [#19977] This used to pin the OPPOSITE: `url: '${TURSO_DATABASE_URL}'` + // parsed green. Nothing resolves a placeholder in a config (the spec + // contract refuses one outright), so the driver received the literal string, + // which is not `:memory:`, not a `file:` url and not a remote url — and + // `new TursoDriver` refuses exactly that in the local mode it selects. The + // mirror now refuses it at the door, on `url`, naming the spellings that + // work. The token beside it is no part of the refusal. + it('refuses a placeholder url — the driver cannot open it — and says what it can open', () => { + const result = TursoConfigSchema.safeParse({ url: '${TURSO_DATABASE_URL}', authToken: '${TURSO_AUTH_TOKEN}', }); - expect(config.url).toBe('${TURSO_DATABASE_URL}'); - expect(config.authToken).toBe('${TURSO_AUTH_TOKEN}'); + expect(result.success).toBe(false); + expect(result.error!.issues).toHaveLength(1); + const [issue] = result.error!.issues; + expect(issue.code).toBe('custom'); + expect(issue.path).toEqual(['url']); + expect(issue.message).toContain('`url` is not a url the turso driver can open'); + expect(issue.message).toContain("spell the path as a `file:` url: `url: 'file:./data/app.db'`."); + expect(issue.message).toContain('For a remote database, use one of the remote schemes above.'); }); it('should accept config with custom concurrency', () => { diff --git a/packages/drivers/driver-turso/src/spec/turso.zod.ts b/packages/drivers/driver-turso/src/spec/turso.zod.ts index 396744dd45f..7b32d246c88 100644 --- a/packages/drivers/driver-turso/src/spec/turso.zod.ts +++ b/packages/drivers/driver-turso/src/spec/turso.zod.ts @@ -81,6 +81,194 @@ const WASM_RETIRED = + '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.'; +// ========================================================================== +// 2a. Transport coherence — what `new TursoDriver` refuses or ignores +// ========================================================================== +// +// #19977. Each key below parsed on its own, so this mirror accepted +// combinations the driver in this very package refuses at construction +// (`VALIDATION_ERROR` / 400), or constructs and ignores. It now refuses them, +// with the supported spelling in the message. The predicates and the messages +// are the ones `@objectstack/spec`'s own turso contract +// (`packages/spec/src/data/driver/turso.zod.ts`, `tursoTransportIssues`) +// carries, byte for byte: the helper is internal to that package and not on +// a published entry point, and publishing it only to share it here would grow +// the spec's public surface for one consumer. `turso-config-constructor-parity.test.ts` +// holds both copies and the constructor to one case table instead, messages +// included, so the copy cannot drift silently. +// +// The predicates mirror the constructor's (`localEngineDefect`, +// `refuseWebSocketTimeout` and `detectMode` in `../turso-driver.ts`): a scheme +// matches in any letter case, `:memory:` is matched exactly, and a `file:` url +// whose path is `:memory:` (or starts `:memory:?`) is in-memory. The url is +// classified TRIMMED, as both datasource loaders hand it to the constructor. +// +// ⚠️ This mirror declares no `mode` (the spec contract does), so zod strips an +// authored `mode` before this refinement sees it: every config here is judged +// in the mode its url and `syncUrl` select. The forced-mode branches of the +// message builder are therefore unreachable from this schema, and are kept so +// the two copies stay byte-identical. +// +// ⛔ No url is echoed, only a remote url's scheme: a url may carry a token. +// ⛔ No internal issue id in any message: they are customer-facing text. + +type TursoTransportModeName = 'local' | 'replica' | 'remote'; + +/** The url prefixes the driver classifies as remote, matched in any letter case. */ +const TURSO_REMOTE_URL_PREFIXES = ['libsql://', 'https://', 'http://', 'wss://', 'ws://'] as const; + +function startsWithScheme(url: string, prefix: string): boolean { + return url.slice(0, prefix.length).toLowerCase() === prefix; +} + +function isRemoteTursoUrl(url: string): boolean { + return TURSO_REMOTE_URL_PREFIXES.some((prefix) => startsWithScheme(url, prefix)); +} + +function isFileTursoUrl(url: string): boolean { + return startsWithScheme(url, 'file:'); +} + +/** `@libsql/client`'s own in-memory reading: `:memory:`, or a `file:` url whose path is `:memory:`. */ +function namesInMemoryTursoDatabase(url: string): boolean { + if (url === ':memory:') return true; + if (!isFileTursoUrl(url)) return false; + const path = url.slice('file:'.length); + return path === ':memory:' || path.startsWith(':memory:?'); +} + +/** The mode the driver runs a config in: the forced one, else what the url and `syncUrl` select. */ +function tursoTransportModeOf(url: string, hasSyncUrl: boolean, mode: TursoTransportModeName | undefined): TursoTransportModeName { + if (mode) return mode; + if (isRemoteTursoUrl(url)) return hasSyncUrl ? 'replica' : 'remote'; + return hasSyncUrl ? 'replica' : 'local'; +} + +/** The keys the transport refusals read, as they arrive in the refinement. */ +interface TursoTransportKeys { + url?: unknown; + syncUrl?: unknown; + mode?: TursoTransportModeName; + timeoutMs?: unknown; +} + +/** One refusal: the key it sits on and its message. */ +interface TursoTransportIssue { + path: 'url' | 'syncUrl' | 'timeoutMs'; + message: string; +} + +/** + * Every transport refusal a turso config earns — empty for a coherent one. + * Checked in the constructor's order, so each config meets the refusal that + * names its way out. + */ +function tursoTransportIssues(cfg: TursoTransportKeys): TursoTransportIssue[] { + if (typeof cfg.url !== 'string') return []; + const url = cfg.url.trim(); + const hasSyncUrl = typeof cfg.syncUrl === 'string' && cfg.syncUrl.length > 0; + const mode = tursoTransportModeOf(url, hasSyncUrl, cfg.mode); + const cause = cfg.mode ? `\`mode: '${cfg.mode}'\`` : '`syncUrl`'; + const syncUrlKeys = '`syncUrl` (and `sync`)'; + + if (mode !== 'remote') { + const arm = mode === 'replica' ? 'an embedded replica' : 'a local database'; + if (isRemoteTursoUrl(url)) { + const scheme = url.slice(0, url.indexOf('://') + '://'.length); + const toRemote = cfg.mode + ? `drop \`mode\` (a \`${scheme}\` url is detected as remote) or set \`mode: 'remote'\`` + + (hasSyncUrl ? `, and drop ${syncUrlKeys}` : '') + : `drop ${syncUrlKeys} and keep the remote url alone`; + const toLocal = mode === 'replica' + ? `For an embedded replica, point \`url\` at a local file and ${hasSyncUrl ? 'keep' : 'name'} the ` + + "remote in `syncUrl`: `url: 'file:./data/replica.db'`." + : "For a local database, point `url` at a file: `url: 'file:./data/app.db'`."; + return [{ + path: 'url', + message: + `\`url\` is a remote \`${scheme}\` url, but ${cause} makes this datasource ${arm}, which runs ` + + 'every read and write through a local SQLite engine that cannot open a remote url — the ' + + 'turso driver refuses this configuration when it starts. ' + + (mode === 'replica' + ? '(An embedded replica is a local file kept in sync with the remote: @libsql/client builds ' + + 'no replica for a remote url and ignores `syncUrl` beside one.) ' + : '') + + `For a remote database, ${toRemote}. ${toLocal}`, + }]; + } + if (url !== ':memory:' && !isFileTursoUrl(url)) { + const asks = cfg.mode + ? `${cause} makes this datasource ${arm}` + : hasSyncUrl + ? `\`syncUrl\` makes this datasource ${arm}` + : `With no \`mode\` and no remote scheme, this datasource is ${arm}`; + const keepsItLocal = [ + ...(cfg.mode ? [`\`mode: '${cfg.mode}'\``] : []), + ...(hasSyncUrl ? [syncUrlKeys] : []), + ]; + const toFile = mode === 'replica' + ? 'For an embedded replica, spell the local path as a `file:` url and ' + + `${hasSyncUrl ? 'keep' : 'name'} the remote in \`syncUrl\`: \`url: 'file:./data/replica.db'\`.` + : "For a local database file, spell the path as a `file:` url: `url: 'file:./data/app.db'`. " + + "For a throwaway in-memory database, `url: ':memory:'`."; + const toRemote = 'For a remote database, ' + + (keepsItLocal.length > 0 ? `drop ${keepsItLocal.join(' and ')} and ` : '') + + 'use one of the remote schemes above.'; + return [{ + path: 'url', + message: + '`url` is not a url the turso driver can open: it is not `:memory:`, not a `file:` url, and ' + + 'not a remote `libsql://`, `https://`, `http://`, `wss://` or `ws://` url (a scheme matches ' + + `in any letter case). ${asks}, which runs every read and write through a local SQLite ` + + 'engine that opens only a `file:` url or `:memory:` — the turso driver refuses this ' + + `configuration when it starts. ${toFile} ${toRemote}`, + }]; + } + if (mode === 'replica' && namesInMemoryTursoDatabase(url)) { + const drop = cfg.mode + ? "`mode: 'replica'`" + (hasSyncUrl ? ` and ${syncUrlKeys}` : '') + : syncUrlKeys; + return [{ + path: 'url', + message: + `\`url\` names an in-memory database, so it cannot hold the embedded replica ${cause} asks ` + + 'for: a replica is a local file kept in sync with the remote named in `syncUrl` — the ' + + "turso driver refuses this configuration when it starts. Point `url` at a local file " + + "(`url: 'file:./data/replica.db'` beside `syncUrl`), or drop " + + `${drop} for a plain in-memory local database.`, + }]; + } + return []; + } + + const issues: TursoTransportIssue[] = []; + const ridesWebSocket = startsWithScheme(url, 'wss://') || startsWithScheme(url, 'ws://'); + if (ridesWebSocket && typeof cfg.timeoutMs === 'number' && cfg.timeoutMs > 0) { + const scheme = url.slice(0, url.indexOf('://') + '://'.length); + issues.push({ + path: 'timeoutMs', + message: + `\`timeoutMs\` is set beside a \`${scheme}\` url, which rides @libsql/client's WebSocket ` + + 'transport, and that transport takes no timeout: the window would bound nothing, so the ' + + 'turso driver refuses this configuration when it starts. Either drop `timeoutMs` and run ' + + 'this remote database unbounded, or keep it and spell the url `libsql://` or `https://`, ' + + 'where every request is bounded.', + }); + } + if (hasSyncUrl) { + issues.push({ + path: 'syncUrl', + message: + "`syncUrl` configures an embedded replica, but `mode: 'remote'` sends every read and write " + + 'straight to `url` and builds no replica: the turso driver never hands `syncUrl` to the ' + + 'remote client and runs no sync, so the setting changes nothing. For a remote database, ' + + `drop ${syncUrlKeys}. For an embedded replica, drop \`mode\` and point \`url\` at a local ` + + "file beside `syncUrl`: `url: 'file:./data/replica.db'`.", + }); + } + return issues; +} + export const TursoConfigSchema = lazySchema(() => z.object({ /** * Database URL. @@ -187,6 +375,26 @@ export const TursoConfigSchema = lazySchema(() => z.object({ * as `localPath` above. */ wasm: z.never({ error: () => WASM_RETIRED }).optional().describe(`[REMOVED] ${WASM_RETIRED}`), +}).superRefine((cfg, ctx) => { + // `sync` configures a replica, and the driver reads it only beside + // `syncUrl` (its connect arm starts no sync and no interval without one). + // The spec contract has refused `sync` alone since it was written; #19977 + // brings the same refusal, in the same words, to this mirror. + if (cfg.sync && !cfg.syncUrl) { + ctx.addIssue({ + code: 'custom', + path: ['sync'], + message: + '`sync` configures embedded-replica syncing, which only runs when `syncUrl` names the ' + + 'remote to replicate from. Set `syncUrl`, or remove `sync` — on its own it configures ' + + 'nothing.', + }); + } + // What the driver refuses at construction, or constructs and ignores — + // see `tursoTransportIssues` above. + for (const issue of tursoTransportIssues(cfg)) { + ctx.addIssue({ code: 'custom', path: [issue.path], message: issue.message }); + } }).describe('Turso/libSQL Connection Configuration')); // ========================================================================== From dd67e8edcb83e7650ed04e09f2235b245b3d8588 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 04:09:42 +0000 Subject: [PATCH 3/5] chore(spec): register the turso transport refusals (ADR-0087), changeset, regenerated reference Claude-Session: https://claude.ai/code/session_01Rjy9MeetSfq34PKn81CRiN Co-authored-by: Claude --- .../19977-turso-config-transport-refusals.md | 49 ++++++++++++++++ content/docs/references/data/driver-turso.mdx | 2 +- ...turso-config-transport-mismatch-refused.ts | 58 +++++++++++++++++++ packages/spec/src/migrations/registry.ts | 54 +++++++++++++++++ 4 files changed, 162 insertions(+), 1 deletion(-) create mode 100644 .changeset/19977-turso-config-transport-refusals.md create mode 100644 packages/spec/src/migrations/entries/semantic/18.turso-config-transport-mismatch-refused.ts diff --git a/.changeset/19977-turso-config-transport-refusals.md b/.changeset/19977-turso-config-transport-refusals.md new file mode 100644 index 00000000000..cb12fbfac59 --- /dev/null +++ b/.changeset/19977-turso-config-transport-refusals.md @@ -0,0 +1,49 @@ +--- +'@objectstack/spec': minor +'@objectstack/driver-turso': minor +--- + +fix(spec)!: a turso datasource config the driver refuses, or ignores a key of, is refused where it is written + +Clause-②: no (narrowing) — nothing is widened. No key is added, removed or renamed and no exported symbol moves. Combinations of `url`, `syncUrl`, `mode` and `timeoutMs` that the turso driver refuses when it is built, and one it builds and then ignores, are now refused at parse. + +`TursoConfigSchema` (the `turso` / `libsql` `datasource.config` contract in `@objectstack/spec`, and the published mirror in `@objectstack/driver-turso`) parsed each key on its own. So it accepted configurations that `new TursoDriver()` refuses with `VALIDATION_ERROR` / 400: a datasource published clean and then failed at boot, or at a test connection. Measured on `main` before the change, both schemas accepting every row: + +``` +libsql:// (any scheme, any case) + syncUrl -> constructor refuses +libsql:// + mode: 'replica' or mode: 'local' -> constructor refuses +./data/app.db (a bare path), sqlite:, :MEMORY: -> constructor refuses +:memory: or file::memory: + syncUrl -> constructor refuses +wss:// or ws:// + timeoutMs -> constructor refuses +libsql:// + mode: 'remote' + syncUrl (+ sync) -> constructs; syncUrl ignored +``` + +On that last row the remote client is built without `syncUrl`, no sync interval starts, the driver's sync call rejects `SYNC_NOT_SUPPORTED`, and the driver still reports sync as enabled. + +**BREAKING** accept-set narrowing on a published schema, shipped as `minor` under the repo's launch-window convention for breaking changes (`scripts/check-changeset-no-major.mjs`). Refused now, each as one `custom` issue on the key it names: + +- **on `url`**, in a local or replica mode (a forced `mode: 'local'` / `'replica'`, or `syncUrl`, or a url that is not remote): a remote url (`libsql://`, `https://`, `http://`, `wss://`, `ws://`, in any letter case); a url that is none of a `file:` url, `:memory:` or a remote url, such as a bare path, another scheme, `:MEMORY:`, a remote scheme with no `//` or a blank url; and a replica on an in-memory url (`:memory:`, `file::memory:` in any case, with or without a query string); +- **on `timeoutMs`**: a window beside a `wss://` / `ws://` url in remote mode; +- **on `syncUrl`**: `syncUrl` under a forced `mode: 'remote'`. The constructor accepts this one, so it is refused at authoring only; +- **on `sync`**, in the `@objectstack/driver-turso` mirror only: `sync` with no `syncUrl`, in the words the spec contract has always used for it. + +The rules mirror the constructor's own: a scheme matches in any letter case, `:memory:` matches exactly, and the url is read trimmed, as both datasource loaders hand it to the driver. A forced `mode: 'remote'` keeps its url unjudged, as the constructor does. Nothing the constructor accepts is refused, the `syncUrl`-under-`mode: 'remote'` row aside. The mirror declares no `mode` key and strips an authored one, so it judges every config in the mode its url and `syncUrl` select. A test in `@objectstack/driver-turso` holds the constructor and both schemas to one case table of 54 rows, with messages compared byte for byte. + +The spec `url` describe named "a file path" among the accepted spellings, which is the one spelling the driver refuses. It now reads "a local file written as a file: URL (never a bare path)". + +### Migration: FROM → TO + +| You wrote | Write instead | +| --- | --- | +| `url: 'libsql://my-db.turso.io', syncUrl: 'libsql://my-db.turso.io'` | a remote database: `url: 'libsql://my-db.turso.io'` alone. An embedded replica: `url: 'file:./data/replica.db', syncUrl: 'libsql://my-db.turso.io'` | +| `url: 'libsql://my-db.turso.io', mode: 'replica'` (or `'local'`) | drop `mode`, or set `mode: 'remote'` | +| `url: './data/app.db'` | `url: 'file:./data/app.db'` | +| `url: ':memory:', syncUrl: …` | a replica on a file: `url: 'file:./data/replica.db'` beside `syncUrl`. An in-memory database: drop `syncUrl` and `sync` | +| `url: 'wss://my-db.turso.io', timeoutMs: 30000` | `url: 'libsql://my-db.turso.io', timeoutMs: 30000`, or drop `timeoutMs` | +| `url: 'libsql://my-db.turso.io', mode: 'remote', syncUrl: …` | drop `syncUrl` and `sync` | + +Each refusal prints these ways out and names only a remote url's scheme, never the url, which may carry a token. Stored datasource rows are not re-parsed when they load, so a stored row keeps loading as before; creating, testing or editing its `config` through the datasource admin service, `defineStack` or `os validate` is refused at the key until it is rewritten. The constructor already refuses the first five rows at boot. + +Blast radius, measured on this tree: no example, template, published skill or hand-written doc authors a refused combination. Three test fixtures, in the spec package and in the driver package, spelled one and are rewritten in this change, each named in the PR. Loader fixtures in `@objectstack/runtime`, `@objectstack/cli` and `@objectstack/service-datasource` that spell a remote url beside `syncUrl` exercise only the config builder or a capturing constructor. They never parse this schema or build the real driver, and are unchanged. Whether any out-of-repo deployment declares such a config is NOT measured and is not claimed to be zero. + + diff --git a/content/docs/references/data/driver-turso.mdx b/content/docs/references/data/driver-turso.mdx index c8233200b74..51e3102bc1c 100644 --- a/content/docs/references/data/driver-turso.mdx +++ b/content/docs/references/data/driver-turso.mdx @@ -65,7 +65,7 @@ Turso / libSQL Connection Configuration | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **url** | `string` | ✅ | libSQL endpoint or local file: a remote libsql/https Turso URL, a file path, or :memory: | +| **url** | `string` | ✅ | libSQL endpoint or local file: a remote libsql/https Turso URL, a local file written as a file: URL (never a bare path), or :memory: | | **authToken** | `never` | optional | Set through the connection form's secret field or `external.credentialsRef` — encrypted into `sys_secret`, never stored in `config` | | **encryptionKey** | `string` | optional | AES-256 encryption key for the local database file (local/replica modes) | | **concurrency** | `integer` | optional | Maximum concurrent requests to the remote database | diff --git a/packages/spec/src/migrations/entries/semantic/18.turso-config-transport-mismatch-refused.ts b/packages/spec/src/migrations/entries/semantic/18.turso-config-transport-mismatch-refused.ts new file mode 100644 index 00000000000..4229c12c27b --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.turso-config-transport-mismatch-refused.ts @@ -0,0 +1,58 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// The AUTHORING half of the turso driver's constructor refusals. The driver +// refuses these configurations when it is built; this entry records that the +// datasource contract now refuses them where they are written, together with +// the one combination the driver builds and then ignores. A structured TODO, +// not a D2 conversion: which way out an author wants — a remote database, an +// embedded replica on a local file, or a plain local file — is intent no +// artifact records. +export const entry: SemanticMigration = { + id: 'turso-config-transport-mismatch-refused', + // No backticks in `surface` — build-upgrade-guide renders it inside a code + // span already, and a nested backtick would close it. + surface: + 'data.TursoConfig (a turso / libsql datasource.config) and the published TursoConfigSchema ' + + 'mirror of @objectstack/driver-turso — combinations of url, syncUrl, mode and timeoutMs that ' + + 'are now refused at parse: a remote url (libsql, https, http, wss, ws, any letter case) ' + + 'beside syncUrl or under a forced local or replica mode; in a local or replica mode, a url ' + + 'that is none of a file: url, :memory: or a remote url (a bare path, another scheme, ' + + ':MEMORY:, a blank url); a replica on an in-memory url; timeoutMs beside a wss or ws url in ' + + 'remote mode; and syncUrl under a forced remote mode. The driver mirror also refuses sync ' + + 'with no syncUrl, as the spec contract already did', + replacement: + 'the configuration the author meant, spelled the way the driver runs it. A remote database ' + + 'is the remote url alone (drop syncUrl and sync, and drop a forced local or replica mode or ' + + 'set it to remote). An embedded replica is a local file written as a file: url beside ' + + 'syncUrl, for example url file:./data/replica.db with syncUrl naming the remote. A local ' + + 'database is a file: url (file:./data/app.db, never the bare path ./data/app.db) or ' + + ':memory: for a throwaway one. A remote database that needs timeoutMs spells its url libsql ' + + 'or https, or drops timeoutMs. Each refusal names the key it sits on (url, syncUrl or ' + + 'timeoutMs) and prints the spellings above', + reason: + '#19977. Each key parsed on its own, so the contract accepted configurations the turso driver ' + + 'refuses when it is built (VALIDATION_ERROR / 400 from the constructor, since the #19893 ' + + 'and #19976 changes) — a datasource published clean and then failed at boot or at test ' + + 'connection. One more it builds and then ignores: syncUrl under a forced remote mode, where ' + + 'the remote client is created without it, no sync ever runs and the sync call fails as not ' + + 'supported while the driver reports sync as enabled (measured on the built driver). ' + + 'Authoring now refuses exactly the constructor\'s refused set — the same predicates, a ' + + 'scheme matched in any letter case, the url read trimmed as both datasource loaders hand it ' + + 'over — plus that ignored key, which is the declared-but-not-enforced shape ADR-0049 does ' + + 'not ship. Nothing the constructor accepts is refused, that key aside: a forced remote mode ' + + 'keeps its url unjudged, as the constructor does. Stored datasource rows are not re-parsed ' + + 'when they load, so a stored row keeps loading exactly as before (the constructor refuses ' + + 'the first four shapes there already); what changes is that creating, testing or editing ' + + 'its config through the datasource admin service, defineStack or os validate is refused at ' + + 'the key. Measured on this tree at the change: no example, template, published skill or ' + + 'hand-written doc authors a refused combination. ADR-0049 / ADR-0087 / ADR-0112.', + acceptanceCriteria: + 'Validate every stack and re-save every turso datasource: os validate or defineStack, and a ' + + 'save or test connection through the datasource admin service, report each refused ' + + 'combination at config.url, config.syncUrl or config.timeoutMs with the ways out. Decide per ' + + 'datasource whether it is a remote database, an embedded replica on a local file, or a ' + + 'local file, and rewrite it to that spelling. Done when every turso datasource parses, the ' + + 'driver builds from it, and a replica datasource reports a file: url beside its syncUrl.', +}; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index c92e1d2c3ba..27cc1b31308 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -13355,6 +13355,60 @@ const step18: MigrationStep = { + '(`@objectstack/service-settings`’s `settingsBuiltinTranslations`) — ⛔ do not re-add ' + 'app-side copy at either door, which is refused.', }, + // The AUTHORING half of the turso driver's constructor refusals. The driver + // refuses these configurations when it is built; this entry records that the + // datasource contract now refuses them where they are written, together with + // the one combination the driver builds and then ignores. A structured TODO, + // not a D2 conversion: which way out an author wants — a remote database, an + // embedded replica on a local file, or a plain local file — is intent no + // artifact records. + { + id: 'turso-config-transport-mismatch-refused', + // No backticks in `surface` — build-upgrade-guide renders it inside a code + // span already, and a nested backtick would close it. + surface: + 'data.TursoConfig (a turso / libsql datasource.config) and the published TursoConfigSchema ' + + 'mirror of @objectstack/driver-turso — combinations of url, syncUrl, mode and timeoutMs that ' + + 'are now refused at parse: a remote url (libsql, https, http, wss, ws, any letter case) ' + + 'beside syncUrl or under a forced local or replica mode; in a local or replica mode, a url ' + + 'that is none of a file: url, :memory: or a remote url (a bare path, another scheme, ' + + ':MEMORY:, a blank url); a replica on an in-memory url; timeoutMs beside a wss or ws url in ' + + 'remote mode; and syncUrl under a forced remote mode. The driver mirror also refuses sync ' + + 'with no syncUrl, as the spec contract already did', + replacement: + 'the configuration the author meant, spelled the way the driver runs it. A remote database ' + + 'is the remote url alone (drop syncUrl and sync, and drop a forced local or replica mode or ' + + 'set it to remote). An embedded replica is a local file written as a file: url beside ' + + 'syncUrl, for example url file:./data/replica.db with syncUrl naming the remote. A local ' + + 'database is a file: url (file:./data/app.db, never the bare path ./data/app.db) or ' + + ':memory: for a throwaway one. A remote database that needs timeoutMs spells its url libsql ' + + 'or https, or drops timeoutMs. Each refusal names the key it sits on (url, syncUrl or ' + + 'timeoutMs) and prints the spellings above', + reason: + '#19977. Each key parsed on its own, so the contract accepted configurations the turso driver ' + + 'refuses when it is built (VALIDATION_ERROR / 400 from the constructor, since the #19893 ' + + 'and #19976 changes) — a datasource published clean and then failed at boot or at test ' + + 'connection. One more it builds and then ignores: syncUrl under a forced remote mode, where ' + + 'the remote client is created without it, no sync ever runs and the sync call fails as not ' + + 'supported while the driver reports sync as enabled (measured on the built driver). ' + + 'Authoring now refuses exactly the constructor\'s refused set — the same predicates, a ' + + 'scheme matched in any letter case, the url read trimmed as both datasource loaders hand it ' + + 'over — plus that ignored key, which is the declared-but-not-enforced shape ADR-0049 does ' + + 'not ship. Nothing the constructor accepts is refused, that key aside: a forced remote mode ' + + 'keeps its url unjudged, as the constructor does. Stored datasource rows are not re-parsed ' + + 'when they load, so a stored row keeps loading exactly as before (the constructor refuses ' + + 'the first four shapes there already); what changes is that creating, testing or editing ' + + 'its config through the datasource admin service, defineStack or os validate is refused at ' + + 'the key. Measured on this tree at the change: no example, template, published skill or ' + + 'hand-written doc authors a refused combination. ADR-0049 / ADR-0087 / ADR-0112.', + acceptanceCriteria: + 'Validate every stack and re-save every turso datasource: os validate or defineStack, and a ' + + 'save or test connection through the datasource admin service, report each refused ' + + 'combination at config.url, config.syncUrl or config.timeoutMs with the ways out. Decide per ' + + 'datasource whether it is a remote database, an embedded replica on a local file, or a ' + + 'local file, and rewrite it to that spelling. Done when every turso datasource parses, the ' + + 'driver builds from it, and a replica datasource reports a file: url beside its syncUrl.', + }, { id: 'ui-action-undoable-unfulfillable-refused', surface: '`action` documents declaring `undoable: true` on a shape no runtime fulfils — ' From cf7d32e0357f9c75742ad45b03a6503b912e0940 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 04:54:05 +0000 Subject: [PATCH 4/5] test(service-datasource): the #8337 redaction fixture is a coherent replica, not a remote url beside syncUrl Claude-Session: https://claude.ai/code/session_01Rjy9MeetSfq34PKn81CRiN Co-authored-by: Claude --- .../datasource-config-redaction.test.ts | 20 +++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/packages/services/service-datasource/src/__tests__/datasource-config-redaction.test.ts b/packages/services/service-datasource/src/__tests__/datasource-config-redaction.test.ts index 229195b0c97..568ba142396 100644 --- a/packages/services/service-datasource/src/__tests__/datasource-config-redaction.test.ts +++ b/packages/services/service-datasource/src/__tests__/datasource-config-redaction.test.ts @@ -283,13 +283,21 @@ describe('write path: the scrub must not turn "Save" into credential deletion', }); describe('#8337 — the query-parameter spelling, both halves at the service door', () => { - /** A legacy turso row written before #8337: the JWT rides the URL query string. */ + /** + * A legacy turso row written before #8337: the JWT rides the URL query string, + * on BOTH url-bearing keys. An embedded replica, because that is the one + * coherent config carrying both: its `url` is a local `file:` url, and since + * #19977 the contract refuses `syncUrl` beside a remote one (the driver + * refuses it at construction). `@libsql/core` reads `?authToken=` off any + * url's query string, a `file:` one included, so the replica's token could + * be spelled on either key — which is why both are redacted. + */ const LEGACY_TURSO: StoredDatasource = { name: 'legacy_turso', driver: 'turso', origin: 'runtime', config: { - url: 'libsql://app-org.turso.io?authToken=eyJhbGci.x.y', + url: 'file:./data/replica.db?authToken=eyJhbGci.x.y', syncUrl: 'libsql://app-org.turso.io?tls=1&authToken=eyJhbGci.x.y', }, }; @@ -298,7 +306,7 @@ describe('#8337 — the query-parameter spelling, both halves at the service doo const { service } = makeService([LEGACY_TURSO]); const read = await service.getDatasource('legacy_turso'); expect(read!.config).toEqual({ - url: 'libsql://app-org.turso.io', + url: 'file:./data/replica.db', syncUrl: 'libsql://app-org.turso.io?tls=1', }); expect(read!.redactedConfigKeys).toEqual(['syncUrl', 'url']); @@ -310,7 +318,7 @@ describe('#8337 — the query-parameter spelling, both halves at the service doo await service.updateDatasource('legacy_turso', { config: read!.config, label: 'Renamed' }); expect(records[0].label).toBe('Renamed'); expect(records[0].config).toMatchObject({ - url: 'libsql://app-org.turso.io?authToken=eyJhbGci.x.y', + url: 'file:./data/replica.db?authToken=eyJhbGci.x.y', syncUrl: 'libsql://app-org.turso.io?tls=1&authToken=eyJhbGci.x.y', }); }); @@ -318,9 +326,9 @@ describe('#8337 — the query-parameter spelling, both halves at the service doo it('an author who rewrites the URL by hand WINS — the restore never overrides an edit', async () => { const { service, records } = makeService([LEGACY_TURSO]); await service.updateDatasource('legacy_turso', { - config: { url: 'libsql://elsewhere.turso.io', syncUrl: 'libsql://app-org.turso.io?tls=1' }, + config: { url: 'file:./data/elsewhere.db', syncUrl: 'libsql://app-org.turso.io?tls=1' }, }); - expect(records[0].config!.url).toBe('libsql://elsewhere.turso.io'); + expect(records[0].config!.url).toBe('file:./data/elsewhere.db'); // The untouched syncUrl round-trips its token back, independently. expect(records[0].config!.syncUrl).toBe('libsql://app-org.turso.io?tls=1&authToken=eyJhbGci.x.y'); }); From 59c2391e2a91e6ec2b26626a2c85bb6c710cd5d4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 06:27:06 +0000 Subject: [PATCH 5/5] docs(changeset): name the fourth rewritten fixture in the turso refusal changeset Claude-Session: https://claude.ai/code/session_01Rjy9MeetSfq34PKn81CRiN Co-authored-by: Claude --- .changeset/19977-turso-config-transport-refusals.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/19977-turso-config-transport-refusals.md b/.changeset/19977-turso-config-transport-refusals.md index cb12fbfac59..c43da549b85 100644 --- a/.changeset/19977-turso-config-transport-refusals.md +++ b/.changeset/19977-turso-config-transport-refusals.md @@ -44,6 +44,6 @@ The spec `url` describe named "a file path" among the accepted spellings, which Each refusal prints these ways out and names only a remote url's scheme, never the url, which may carry a token. Stored datasource rows are not re-parsed when they load, so a stored row keeps loading as before; creating, testing or editing its `config` through the datasource admin service, `defineStack` or `os validate` is refused at the key until it is rewritten. The constructor already refuses the first five rows at boot. -Blast radius, measured on this tree: no example, template, published skill or hand-written doc authors a refused combination. Three test fixtures, in the spec package and in the driver package, spelled one and are rewritten in this change, each named in the PR. Loader fixtures in `@objectstack/runtime`, `@objectstack/cli` and `@objectstack/service-datasource` that spell a remote url beside `syncUrl` exercise only the config builder or a capturing constructor. They never parse this schema or build the real driver, and are unchanged. Whether any out-of-repo deployment declares such a config is NOT measured and is not claimed to be zero. +Blast radius, measured on this tree: no example, template, published skill or hand-written doc authors a refused combination. Four test fixtures spelled one and are rewritten in this change, each named in the PR: one in `@objectstack/spec`, two in `@objectstack/driver-turso` (one of them pinned a placeholder url as accepted), and the stored-row redaction fixture in `@objectstack/service-datasource`, now an embedded replica on a `file:` url. Loader fixtures in `@objectstack/runtime`, `@objectstack/cli` and `@objectstack/service-datasource` that spell a remote url beside `syncUrl` exercise only the config builder or a capturing constructor. They never parse this schema or build the real driver, and are unchanged. Whether any out-of-repo deployment declares such a config is NOT measured and is not claimed to be zero.