diff --git a/.changeset/20437-turso-forced-replica-needs-sync-url.md b/.changeset/20437-turso-forced-replica-needs-sync-url.md new file mode 100644 index 00000000000..575aec30e5e --- /dev/null +++ b/.changeset/20437-turso-forced-replica-needs-sync-url.md @@ -0,0 +1,30 @@ +--- +'@objectstack/spec': minor +'@objectstack/driver-turso': minor +--- + +fix(spec,driver-turso)!: a turso config that forces `mode: 'replica'` with no `syncUrl` is refused where it is written and when the driver is built, instead of running as a plain local database that never syncs + +Clause-②: yes (narrowing) — the accept set of the `turso` `datasource.config` contract narrows by one combination. No key is added, removed or renamed, and no exported symbol moves. + +An embedded replica is a local file kept in sync with the remote named in `syncUrl`. A config that forced `mode: 'replica'` on a `file:` url with no `syncUrl` (or an empty one) was accepted by `@objectstack/spec`'s `TursoConfigSchema`, by the published mirror in `@objectstack/driver-turso`, and by `new TursoDriver()`. Measured on the built driver before this change, with and without `sync`: it constructed with `transportMode` `'replica'`, `isSyncEnabled()` answered `false`, no sync interval started, the sync call did nothing, and every read and write went to the local file. A datasource declared as a replica ran as a plain local database that never replicated, with no error and no warning. + +**BREAKING** accept-set narrowing on a published schema and a published constructor, shipped as `minor` under the repo's launch-window convention for breaking changes (`scripts/check-changeset-no-major.mjs`). Refused now, at both doors together, with one message whose prescription names both ways out: + +- **at authoring**, as one `custom` issue on `mode` (`config.mode` on a datasource): `DatasourceSchema`, `validateDriverConfig`, `defineStack` / `os validate`, and a save or test connection through the datasource admin service; +- **at construction**, `VALIDATION_ERROR` / 400 from `new TursoDriver()` (and `createTursoDriver()`), before any client or database is opened. + +The message is the same text at both doors, and a test holds the constructor's copy equal to the schema's issue byte for byte. The sibling refusals keep their order. A forced replica on a remote url, an in-memory url or a bare path still meets its `url` refusal first. One with `sync` and no `syncUrl` still meets the `sync` refusal first; the schema now reports the `mode` issue beside it. The driver mirror declares no `mode` key and strips an authored one, so it cannot see a forced mode: this refusal reaches it only as byte-identical text, and the spec contract and the constructor are the two doors that judge it. + +### Migration: FROM → TO + +| You wrote | Write instead | +| --- | --- | +| `url: 'file:./data/replica.db', mode: 'replica'` (no `syncUrl`, or `syncUrl: ''`) | an embedded replica: keep the `file:` url and name the remote, `syncUrl: 'libsql://my-db.turso.io'` | +| the same | a plain local database: drop `mode` (`url: 'file:./data/app.db'` alone) | + +A datasource row stored in this shape is not re-parsed when it loads, so it now fails when the driver is built. `factory.create` throws the refusal. The connection service records the datasource as `failed-degraded` with the message, and a test connection answers `ok: false` ("Failed to build driver: …"). Under ADR-0062 D5, the boot fails fast when objects bind to that datasource or are routed to it, or when it is boot-critical, unless `OS_ALLOW_DRIVER_CONNECT_FAILURE` is set. Otherwise it is left unconnected with a warning. Before this change the same row booted and ran as a local database. The way out is the table above. + +Blast radius, measured on this tree: no example, template, published skill or hand-written doc authors the shape, and no host default or environment variable sets `mode` (a turso `mode` reaches the driver only from an authored `datasource.config`). Whether any out-of-repo deployment declares such a config is NOT measured and is not claimed to be zero. + + diff --git a/packages/drivers/driver-turso/README.md b/packages/drivers/driver-turso/README.md index d1833d09ad9..e14f8fe8a0e 100644 --- a/packages/drivers/driver-turso/README.md +++ b/packages/drivers/driver-turso/README.md @@ -223,6 +223,18 @@ no embedded replica for a remote url anyway. For a remote database, drop no local engine, so its url is not judged here: `@libsql/client` refuses a url it cannot open when the driver connects. +The constructor also refuses (`VALIDATION_ERROR` / 400) three sync settings +that nothing would honour, each with the message `@objectstack/spec`'s +`TursoConfigSchema` gives at authoring: + +- `syncUrl` under a forced `mode: 'remote'`, where the remote client never + receives it. For a remote database, drop `syncUrl` (and `sync`); +- `sync` with no `syncUrl` (or an empty one), in any mode, where nothing reads + it. Set `syncUrl`, or remove `sync`; +- a forced `mode: 'replica'` with no `syncUrl` (or an empty one), which would + never sync and would run as a plain local database. Name the remote in + `syncUrl` beside the `file:` url, or drop `mode` for a local database. + You can also force a specific mode: ```typescript 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 index a8f3ce6ab5c..a23007f640a 100644 --- 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 @@ -28,6 +28,9 @@ * - [#20200] where the constructor refuses on `syncUrl` or `sync`, its message * is the spec contract's issue message, byte for byte: those two texts are * copies in `../turso-driver.ts`, and this is the pin that holds them equal. + * [#20437] The same holds for a forced `mode: 'replica'` with no `syncUrl`, + * refused on `mode` — the third copy. That row used to be accepted + * everywhere, as a replica that never synced. * * ⚠️ 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 @@ -74,7 +77,9 @@ interface Row { /** 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'; + refusedOn?: 'url' | 'syncUrl' | 'timeoutMs' | 'sync' | 'mode'; + /** How many issues the spec contract raises on a refused row; 1 unless stated. */ + issues?: number; /** The constructor accepts it and ignores a key: refused at authoring only. None today (#20200). */ inert?: true; } @@ -97,7 +102,7 @@ const ROWS: Row[] = [ { 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: 'replica'", config: { url: FILE, mode: 'replica', syncUrl: REMOTE, sync: { onConnect: false } }, 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' }, @@ -147,13 +152,24 @@ const ROWS: Row[] = [ { name: 'sync with no syncUrl', config: { url: FILE, sync: { intervalSeconds: 60 } }, ctor: 'refuse', refusedOn: 'sync' }, { name: 'sync with no syncUrl on a remote url', config: { url: REMOTE, sync: { intervalSeconds: 60 } }, ctor: 'refuse', refusedOn: 'sync' }, { name: "sync with no syncUrl under a forced mode: 'remote'", config: { url: REMOTE, mode: 'remote', sync: { onConnect: true } }, ctor: 'refuse', refusedOn: 'sync' }, - { name: "sync with no syncUrl under a forced mode: 'replica'", config: { url: FILE, mode: 'replica', sync: { intervalSeconds: 60 } }, ctor: 'refuse', refusedOn: 'sync' }, + // The spec contract raises BOTH issues here (`sync`, then `mode`); the + // constructor throws one, the `sync` refusal, which is the spec's first. + { name: "sync with no syncUrl under a forced mode: 'replica'", config: { url: FILE, mode: 'replica', sync: { intervalSeconds: 60 } }, ctor: 'refuse', refusedOn: 'sync', issues: 2 }, { name: 'sync beside an empty syncUrl (unset)', config: { url: FILE, syncUrl: '', sync: { intervalSeconds: 60 } }, ctor: 'refuse', refusedOn: 'sync' }, + + // ── a forced replica with no remote to replicate from: refused on `mode` (#20437) ── + { name: "file: under a forced mode: 'replica'", config: { url: FILE, mode: 'replica' }, ctor: 'refuse', refusedOn: 'mode' }, + { name: "an uppercase FILE: url under a forced mode: 'replica'", config: { url: `FILE:${DIR}/upper-replica.db`, mode: 'replica' }, ctor: 'refuse', refusedOn: 'mode' }, + { name: "file: + an empty syncUrl (unset) under a forced mode: 'replica'", config: { url: FILE, mode: 'replica', syncUrl: '' }, ctor: 'refuse', refusedOn: 'mode' }, + { name: "file: + timeoutMs under a forced mode: 'replica'", config: { url: FILE, mode: 'replica', timeoutMs: 5000 }, ctor: 'refuse', refusedOn: 'mode' }, ]; -/** The rows the constructor refuses on a sync key: its message is a copy of the spec's (#20200). */ +/** + * The rows the constructor refuses on a sync key, or on a forced replica with + * no `syncUrl`: its message is a copy of the spec's (#20200, #20437). + */ const SYNC_KEY_REFUSALS = ROWS.filter( - (r) => r.ctor === 'refuse' && (r.refusedOn === 'syncUrl' || r.refusedOn === 'sync'), + (r) => r.ctor === 'refuse' && (r.refusedOn === 'syncUrl' || r.refusedOn === 'sync' || r.refusedOn === 'mode'), ); /** @@ -209,7 +225,8 @@ describe('turso config: the constructor, the spec contract and this mirror agree expect(ROWS.filter((r) => r.refusedOn === 'timeoutMs').length).toBeGreaterThanOrEqual(3); expect(ROWS.filter((r) => r.refusedOn === 'syncUrl').length).toBeGreaterThanOrEqual(3); expect(ROWS.filter((r) => r.refusedOn === 'sync').length).toBeGreaterThanOrEqual(5); - expect(SYNC_KEY_REFUSALS.length).toBeGreaterThanOrEqual(8); + expect(ROWS.filter((r) => r.refusedOn === 'mode').length).toBeGreaterThanOrEqual(4); + expect(SYNC_KEY_REFUSALS.length).toBeGreaterThanOrEqual(12); // [#20200] Exactly zero, not a floor: every key the constructor used to // build and ignore is refused at construction now (see the header). expect(ROWS.filter((r) => r.inert).length).toBe(0); @@ -229,7 +246,7 @@ describe('turso config: the constructor, the spec contract and this mirror agree 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.count, verdict.message).toBe(row.issues ?? 1); expect(verdict.code).toBe('custom'); } }); @@ -241,11 +258,12 @@ describe('turso config: the constructor, the spec contract and this mirror agree }); }); - // [#20200] The two sync refusals are copies of the spec contract's texts in - // `../turso-driver.ts` (the spec keeps them module-local); this is the pin - // that holds each copy equal to the schema's issue, byte for byte. + // [#20200] The two sync refusals, and [#20437] the forced-replica refusal, + // are copies of the spec contract's texts in `../turso-driver.ts` (the spec + // keeps them module-local); this is the pin that holds each copy equal to the + // schema's issue, byte for byte. describe.each(SYNC_KEY_REFUSALS)('$name', (row) => { - it("the constructor's message is the spec contract's, byte for byte (#20200)", () => { + it("the constructor's message is the spec contract's, byte for byte (#20200, #20437)", () => { const spec = schemaVerdict(SpecTursoConfigSchema, row.config); expect(spec.refusedOn).toBe(row.refusedOn); expect(spec.message).toBeTypeOf('string'); diff --git a/packages/drivers/driver-turso/src/spec/turso.zod.ts b/packages/drivers/driver-turso/src/spec/turso.zod.ts index 5c707023746..e3a9af98c89 100644 --- a/packages/drivers/driver-turso/src/spec/turso.zod.ts +++ b/packages/drivers/driver-turso/src/spec/turso.zod.ts @@ -155,7 +155,7 @@ interface TursoTransportKeys { /** One refusal: the key it sits on and its message. */ interface TursoTransportIssue { - path: 'url' | 'syncUrl' | 'timeoutMs'; + path: 'url' | 'syncUrl' | 'timeoutMs' | 'mode'; message: string; } @@ -239,6 +239,24 @@ function tursoTransportIssues(cfg: TursoTransportKeys): TursoTransportIssue[] { + `${drop} for a plain in-memory local database.`, }]; } + if (mode === 'replica' && !hasSyncUrl) { + // #20437. Only a FORCED replica reaches here: with no `mode`, a replica is + // selected by `syncUrl` alone. The url is a `file:` url (every other one + // met a refusal above), so the url is fine and the MODE is what cannot be + // honoured — the issue sits on `mode`, as the `sync` refusal sits on `sync`. + // Unreachable through this mirror, which strips `mode` (see above); kept + // byte-identical to the spec contract's arm. + return [{ + path: 'mode', + message: + "`mode: 'replica'` makes this datasource an embedded replica, a local file kept in sync with " + + 'the remote named in `syncUrl`, but no `syncUrl` is set: nothing would ever sync, so it would ' + + 'run as a plain local database that never replicates — the turso driver refuses this ' + + 'configuration when it starts. For an embedded replica, name the remote in `syncUrl` beside ' + + "the local file: `url: 'file:./data/replica.db'` with `syncUrl` set to the `libsql://` or " + + "`https://` Turso endpoint. For a plain local database, drop `mode: 'replica'`.", + }]; + } return []; } diff --git a/packages/drivers/driver-turso/src/turso-driver-forced-replica-without-sync-url-refusal.test.ts b/packages/drivers/driver-turso/src/turso-driver-forced-replica-without-sync-url-refusal.test.ts new file mode 100644 index 00000000000..444ab3a24be --- /dev/null +++ b/packages/drivers/driver-turso/src/turso-driver-forced-replica-without-sync-url-refusal.test.ts @@ -0,0 +1,194 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#20437] A forced `mode: 'replica'` with no `syncUrl` is refused at + * construction: a replica with no remote to replicate from. The constructor + * half of one paired change; `@objectstack/spec`'s `TursoConfigSchema` refuses + * the same config at authoring, on `mode`, in the same words. + * + * # What was measured (the reading this refusal stands on) + * + * On the built driver before the change (the dist probe the card cites, at + * `dbddf02c1` and again at `2242ad513`): + * + * ``` + * file: + mode 'replica', no syncUrl -> constructs, transportMode 'replica', + * isSyncEnabled() false, no interval, + * sync() a no-op, reads and writes on the file + * file: + mode 'replica' + sync, no syncUrl -> the same (refused since #20200, on `sync`) + * ``` + * + * `connect()` builds the sync client only inside its `syncUrl` arm, and `sync()` + * and `isSyncEnabled()` both test `syncUrl` first, so a forced replica with no + * `syncUrl` was a plain local database that declared itself a replica. Both + * schema copies accepted it (the parity row "file: under a forced + * mode: 'replica'"), and this package's tests pinned it as accepted. + * + * # What this file pins + * + * - The refusal as the ADR-0112 envelope (`code` + `status`) plus its first + * sentence, on a `file:` url in any letter case, beside an empty `syncUrl` + * (unset), beside `timeout`, and beside a supplied client (refused before + * any client work). The full message is the spec contract's issue text byte + * for byte; that equality is pinned in + * `spec/turso-config-constructor-parity.test.ts`, not here. + * - ORDER: a forced replica that another refusal already takes keeps that + * refusal's message — a remote url, `:memory:`, a bare path (the local-engine + * refusals), and `sync` with no `syncUrl` (the spec contract raises `sync` + * first too). + * - WIDTH, by controls that must stay accepted: a forced replica beside + * `syncUrl`, a replica selected by `syncUrl` alone, and a `file:` url with no + * `mode` or with `mode: 'local'`, which is the plain local database the + * refusal prescribes. + * + * # Reverse verification: direction predicted before it was run + * + * Remove the refusal from the constructor and every refusal case here goes RED + * (the constructor returns a `'replica'` driver, so there is no envelope to + * read); the ORDER cases and the CONTROLS stay GREEN. Measured; see the PR. + */ + +import { afterAll, describe, expect, it } from 'vitest'; +import { createTursoDriver } from './index.js'; +import { TursoDriver, type TursoDriverConfig } from './turso-driver.js'; +import { makeLibsqlSqliteStub } from './libsql-sqlite-stub.testkit.js'; +import { replicaFiles } from './replica-file.testkit.js'; + +type Refusal = Error & { code?: string; status?: number }; + +/** The error `build` threw, or `null` when it returned. */ +function refusalOf(build: () => unknown): Refusal | null { + try { + build(); + return null; + } catch (error) { + return error as Refusal; + } +} + +function expectEnvelope(refusal: Refusal | null): asserts refusal is Refusal { + expect(refusal, 'expected the constructor to refuse, and it returned a driver').not.toBeNull(); + expect(refusal!.code).toBe('VALIDATION_ERROR'); + expect(refusal!.status).toBe(400); +} + +/** The first sentence of the refusal: what identifies it. */ +const REPLICA_FIRST_SENTENCE = + "`mode: 'replica'` makes this datasource an embedded replica, a local file kept in sync with " + + 'the remote named in `syncUrl`, but no `syncUrl` is set: nothing would ever sync, so it would ' + + 'run as a plain local database that never replicates — the turso driver refuses this ' + + 'configuration when it starts.'; + +const PRIMARY = 'libsql://primary.example.turso.io'; +const files = replicaFiles(); +afterAll(() => files.removeAll()); + +/** `file:` → `FILE:`, the same database spelled with an uppercase scheme. */ +const upperFile = (fileUrl: string) => `FILE:${fileUrl.slice('file:'.length)}`; + +describe("a forced `mode: 'replica'` with no `syncUrl` — refused at construction", () => { + it.each<[string, () => TursoDriverConfig]>([ + ['a file: url', () => ({ url: files.next(), mode: 'replica' })], + ['an uppercase FILE: url', () => ({ url: upperFile(files.next()), mode: 'replica' })], + ['an empty syncUrl, which is unset', () => ({ url: files.next(), mode: 'replica', syncUrl: '' })], + ['beside a timeout', () => ({ url: files.next(), mode: 'replica', timeout: 5000 })], + ['beside an encryptionKey', () => ({ url: files.next(), mode: 'replica', encryptionKey: 'aes-256-key' })], + ])("%s: VALIDATION_ERROR / 400, in the spec contract's words", (_name, config) => { + const refusal = refusalOf(() => new TursoDriver(config())); + + expectEnvelope(refusal); + expect(refusal.message.startsWith(REPLICA_FIRST_SENTENCE)).toBe(true); + // The two ways out, each by its spelling. + expect(refusal.message).toContain("`url: 'file:./data/replica.db'` with `syncUrl` set to"); + expect(refusal.message).toContain("For a plain local database, drop `mode: 'replica'`."); + }); + + it('beside a supplied client: refused before any client work, the client untouched', () => { + const stub = makeLibsqlSqliteStub(); + const tablesInStub = () => + stub.raw.prepare(`select count(*) as c from sqlite_master where type='table'`).all()[0].c; + + const refusal = refusalOf(() => new TursoDriver({ url: files.next(), mode: 'replica', client: stub as never })); + + expectEnvelope(refusal); + expect(refusal.message.startsWith(REPLICA_FIRST_SENTENCE)).toBe(true); + expect(tablesInStub()).toBe(0); + stub.close(); + }); + + it('createTursoDriver() is the same constructor, and refuses the same config', () => { + const refusal = refusalOf(() => createTursoDriver({ url: files.next(), mode: 'replica' })); + + expectEnvelope(refusal); + expect(refusal.message.startsWith(REPLICA_FIRST_SENTENCE)).toBe(true); + }); + + it('never echoes the url: a path or a token in it stays out of the boot log', () => { + const refusal = refusalOf( + () => new TursoDriver({ url: 'file:./private-dir/app.db?authToken=SECRET-TOKEN-VALUE', mode: 'replica' }), + ); + + expectEnvelope(refusal); + expect(refusal.message).not.toContain('SECRET-TOKEN-VALUE'); + expect(refusal.message).not.toContain('private-dir'); + }); +}); + +describe('ORDER — a forced replica another refusal already takes keeps that refusal', () => { + it.each<[string, TursoDriverConfig, string]>([ + ['a remote url', { url: 'libsql://db.example.turso.io', mode: 'replica' }, 'is a remote `libsql://` url'], + [':memory:', { url: ':memory:', mode: 'replica' }, 'names an in-memory database'], + ['file::memory:', { url: 'file::memory:', mode: 'replica' }, 'names an in-memory database'], + ['a bare path', { url: './data/replica.db', mode: 'replica' }, 'is not a url this driver recognises'], + [ + '`sync` with no syncUrl', + { url: 'file:./data/replica.db', mode: 'replica', sync: { intervalSeconds: 60 } }, + '`sync` configures embedded-replica syncing', + ], + ])('%s: the earlier refusal, not this one', (_name, config, names) => { + const refusal = refusalOf(() => new TursoDriver(config)); + + expectEnvelope(refusal); + expect(refusal.message).toContain(names); + expect(refusal.message.startsWith(REPLICA_FIRST_SENTENCE)).toBe(false); + }); +}); + +describe('CONTROLS — what the refusal must leave accepted', () => { + it("a forced mode 'replica' beside syncUrl is a replica, and runs on its file across a restart", async () => { + const url = files.next(); + const stub = makeLibsqlSqliteStub(); + const note = { name: 'note', fields: { title: { type: 'string' } } }; + const make = () => + new TursoDriver({ url, mode: 'replica', syncUrl: PRIMARY, client: stub as never, sync: { onConnect: false } }); + + const first = make(); + expect(first.transportMode).toBe('replica'); + await first.connect(); + expect(first.isSyncEnabled()).toBe(true); + await first.initObjects([note as never]); + await first.create('note', { id: 'n1', title: 'kept' }); + await first.disconnect(); + + const second = make(); + await second.connect(); + await second.initObjects([note as never]); + expect((await second.find('note', {})).map((r: { title?: unknown }) => r.title)).toEqual(['kept']); + await second.disconnect(); + stub.close(); + }); + + it.each<[string, TursoDriverConfig, string]>([ + ['a replica selected by syncUrl alone', { url: 'file:./data/replica.db', syncUrl: PRIMARY, sync: { onConnect: false } }, 'replica'], + ['a file: url with no mode: the plain local database the refusal prescribes', { url: 'file:./data/app.db' }, 'local'], + ["a file: url under a forced mode 'local'", { url: 'file:./data/app.db', mode: 'local' }, 'local'], + [':memory: with no mode', { url: ':memory:' }, 'local'], + ])('%s constructs', (_name, config, mode) => { + // Knex opens its connection lazily, so constructing never touches the file. + expect(new TursoDriver(config).transportMode).toBe(mode); + }); + + it('detectMode still classifies a forced replica as a replica; the refusal is in the constructor', () => { + expect(TursoDriver.detectMode({ url: 'file:./data/replica.db', mode: 'replica' })).toBe('replica'); + }); +}); diff --git a/packages/drivers/driver-turso/src/turso-driver-ignored-sync-key-refusal.test.ts b/packages/drivers/driver-turso/src/turso-driver-ignored-sync-key-refusal.test.ts index a59b0f62966..184e544c446 100644 --- a/packages/drivers/driver-turso/src/turso-driver-ignored-sync-key-refusal.test.ts +++ b/packages/drivers/driver-turso/src/turso-driver-ignored-sync-key-refusal.test.ts @@ -26,9 +26,11 @@ * `TursoConfigSchema` issue texts byte for byte, and that equality is pinned in * `spec/turso-config-constructor-parity.test.ts`, not here. The refusals' * WIDTH is pinned by controls that must stay accepted: a remote config without - * `syncUrl`, a replica on a `file:` url beside `syncUrl`, `sync` beside - * `syncUrl` under a forced `mode: 'local'`, and the rider this card left - * standing (`mode: 'replica'` on a `file:` url with no `syncUrl`; see the PR). + * `syncUrl`, a replica on a `file:` url beside `syncUrl`, and `sync` beside + * `syncUrl` under a forced `mode: 'local'`. The rider this card left standing + * (`mode: 'replica'` on a `file:` url with no `syncUrl`) is refused since + * #20437, together with the spec contract; it is pinned in + * `turso-driver-forced-replica-without-sync-url-refusal.test.ts`. * * # Reverse verification: direction predicted before it was run * @@ -133,12 +135,4 @@ describe('CONTROLS — what the refusals must leave accepted', () => { expect(driver.transportMode).toBe('local'); }); - - it("the rider stays: mode 'replica' on a file: url with no syncUrl and no sync still constructs", () => { - // Left standing on purpose (see the PR): refusing it here alone would make - // construction refuse a config both TursoConfigSchema copies accept. - const driver = new TursoDriver({ url: file('rider'), mode: 'replica' }); - - expect(driver.transportMode).toBe('replica'); - }); }); diff --git a/packages/drivers/driver-turso/src/turso-driver-unrecognised-url-refusal.test.ts b/packages/drivers/driver-turso/src/turso-driver-unrecognised-url-refusal.test.ts index ff464395d7e..bffa0d6c779 100644 --- a/packages/drivers/driver-turso/src/turso-driver-unrecognised-url-refusal.test.ts +++ b/packages/drivers/driver-turso/src/turso-driver-unrecognised-url-refusal.test.ts @@ -48,10 +48,13 @@ * - PRESERVATION, beside this file's neighbours: `file:` local and replica, * `:memory:` local, a lowercase remote url, `mode: 'remote'`. * - The one WIDENED cell: an uppercase `FILE:` url naming a file under a - * forced `mode: 'replica'`, with or without `syncUrl`. At `a7581b326` the + * forced `mode: 'replica'`, beside `syncUrl`. At `a7581b326` the * constructor refused it (a forced replica had to start with a lowercase * `file:`); it is now a `file:` url, so the replica runs on that file and - * keeps its rows across a restart. + * keeps its rows across a restart. [#20437] The same cell with NO `syncUrl` + * was widened here too, and is refused again since, on other grounds: a + * forced replica with no remote never syncs and ran as a plain local + * database (`turso-driver-forced-replica-without-sync-url-refusal.test.ts`). * * # Reverse verification: direction predicted before it was run * @@ -299,26 +302,19 @@ describe('PRESERVATION: the recognised spellings construct, and the one cell thi ["libsql:// + mode 'remote'", { url: `libsql://${HOST}`, mode: 'remote' }, 'remote'], ["file: + mode 'remote'", { url: 'file:./data/app.db', mode: 'remote' }, 'remote'], // WIDENED: refused at a7581b326, where a forced replica had to start with a - // lowercase `file:`. An uppercase `FILE:` url is a `file:` url now. - ["WIDENED: FILE: + mode 'replica', no syncUrl", { url: 'FILE:./data/replica.db', mode: 'replica' }, 'replica'], + // lowercase `file:`. An uppercase `FILE:` url is a `file:` url now. (With no + // `syncUrl` it is refused since #20437, as a replica with no remote.) ["WIDENED: FILE: + mode 'replica' + syncUrl", { url: 'FILE:./data/replica.db', syncUrl: PRIMARY, mode: 'replica' }, 'replica'], ])('%s', (_label, config, mode) => { // Knex opens its connection lazily, so constructing never touches the file. expect(new TursoDriver(config).transportMode).toBe(mode); }); - it.each<[string, boolean]>([ - ['no syncUrl', false], - ['with syncUrl', true], - ])("WIDENED: FILE: + mode 'replica', %s, runs on the named file and keeps its rows across a restart", async (_label, withSync) => { + it("WIDENED: FILE: + mode 'replica' + syncUrl runs on the named file and keeps its rows across a restart", async () => { const url = upperFile(files.next()); const stub = makeLibsqlSqliteStub(); const make = () => - new TursoDriver({ - url, - mode: 'replica', - ...(withSync ? { syncUrl: PRIMARY, client: stub as never, sync: { onConnect: false } } : {}), - }); + new TursoDriver({ url, mode: 'replica', syncUrl: PRIMARY, client: stub as never, sync: { onConnect: false } }); const first = make(); expect(first.transportMode).toBe('replica'); diff --git a/packages/drivers/driver-turso/src/turso-driver.ts b/packages/drivers/driver-turso/src/turso-driver.ts index cd680a22392..361ab116ec1 100644 --- a/packages/drivers/driver-turso/src/turso-driver.ts +++ b/packages/drivers/driver-turso/src/turso-driver.ts @@ -23,7 +23,8 @@ * would have run on a private `:memory:` database: in a local or replica mode, * a url that is none of those (a bare path, an unsupported scheme); a remote * url beside `syncUrl` or under `mode: 'local'` / `'replica'`; and a replica - * whose url names an in-memory database. + * whose url names an in-memory database. A forced `mode: 'replica'` with no + * `syncUrl` is refused too, because nothing would ever sync it. */ import { @@ -189,6 +190,12 @@ export interface TursoDriverConfig { * `:memory:` (`VALIDATION_ERROR` / 400), and `'replica'` is refused on an * in-memory url too. The engine would otherwise run on a private in-memory * database. + * + * A forced `'replica'` also needs {@link TursoDriverConfig.syncUrl}, the + * remote it replicates from: without one (or with an empty one) the + * constructor refuses it (`VALIDATION_ERROR` / 400), because nothing would + * ever sync and the datasource would run as a plain local database. For a + * local database, drop `mode`; for a replica, set `syncUrl`. */ mode?: TursoTransportMode; @@ -1105,7 +1112,39 @@ const SYNC_WITHOUT_SYNC_URL_REFUSAL = 'nothing.'; /** - * Throw one of the two sync refusals above as the ADR-0112 envelope. + * [#20437] A forced `mode: 'replica'` with no `syncUrl` — refused at construction. + * + * An embedded replica is a local file kept in sync with the remote named in + * `syncUrl`. Forced with no `syncUrl` (or an empty one), the driver used to + * build it with `transportMode = 'replica'` and then run it as a plain local + * database: `connect()` builds the sync client only inside its `syncUrl` arm, + * so no sync ever ran, no interval started, `isSyncEnabled()` answered `false` + * and `sync()` returned without doing anything, while reads and writes went to + * the local file (measured on the built driver before this refusal, with and + * without `sync: { intervalSeconds: 60 }`). A declared mode the runtime never + * runs: the declared-but-not-enforced shape ADR-0049 does not ship. The + * datasource is not re-classified as local instead, for the same reason + * `localEngineDefect` gives: that would accept the declaration and ignore it. + * + * Only a FORCED replica reaches this: with no `mode`, {@link TursoDriver.detectMode} + * answers `replica` only beside a `syncUrl`. A forced replica on a url the local + * engine cannot open met `localEngineDefect` first, and one with `sync` met the + * `sync` refusal first, so this fires on a `file:` url alone. + * + * ⚠️ The text is `@objectstack/spec`'s `TursoConfigSchema` refusal on `mode`, + * byte for byte, held equal by `spec/turso-config-constructor-parity.test.ts`. + * Edit it there first. + */ +const REPLICA_MODE_WITHOUT_SYNC_URL_REFUSAL = + "`mode: 'replica'` makes this datasource an embedded replica, a local file kept in sync with " + + 'the remote named in `syncUrl`, but no `syncUrl` is set: nothing would ever sync, so it would ' + + 'run as a plain local database that never replicates — the turso driver refuses this ' + + 'configuration when it starts. For an embedded replica, name the remote in `syncUrl` beside ' + + "the local file: `url: 'file:./data/replica.db'` with `syncUrl` set to the `libsql://` or " + + "`https://` Turso endpoint. For a plain local database, drop `mode: 'replica'`."; + +/** + * Throw one of the three sync-key refusals above as the ADR-0112 envelope. * * Raised BEFORE `super()`, AFTER the three refusals above it in the * constructor, so no configuration those already refuse changes which message @@ -1553,6 +1592,14 @@ export class TursoDriver extends SqlDriver { if (config.sync && !config.syncUrl) { refuseIgnoredSyncKey(SYNC_WITHOUT_SYNC_URL_REFUSAL); } + // [#20437] A replica with no remote to replicate from: a forced + // `mode: 'replica'` with no `syncUrl` never syncs and runs as a plain local + // database, so it is refused here too, in the spec's words. After the `sync` + // refusal, which the spec contract also raises first. See + // `REPLICA_MODE_WITHOUT_SYNC_URL_REFUSAL`. + if (mode === 'replica' && !config.syncUrl) { + refuseIgnoredSyncKey(REPLICA_MODE_WITHOUT_SYNC_URL_REFUSAL); + } const knexConfig = TursoDriver.toKnexConfig(config, mode); super(knexConfig); this.tursoConfig = config; diff --git a/packages/spec/src/data/driver/turso.test.ts b/packages/spec/src/data/driver/turso.test.ts index 7fe99ebf9ef..8b41579643d 100644 --- a/packages/spec/src/data/driver/turso.test.ts +++ b/packages/spec/src/data/driver/turso.test.ts @@ -32,7 +32,7 @@ describe('TursoConfigSchema', () => { // #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/replica.db', mode: 'replica', syncUrl: 'libsql://x.turso.io' }, { 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' }, @@ -198,8 +198,9 @@ describe('TursoConfig.timeout carries its unit (#15680)', () => { // #19977 — the transport refusals: every combination `new TursoDriver` refuses // at construction (since #20200 that includes `syncUrl` under a forced -// `mode: 'remote'`, which it used to construct and ignore), refused at -// authoring. Asserted on the envelope — the +// `mode: 'remote'`, which it used to construct and ignore; since #20437 a +// forced `mode: 'replica'` with no `syncUrl`, which it used to construct as a +// replica that never synced), 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 @@ -328,6 +329,74 @@ describe('TursoConfigSchema refuses what the turso driver refuses (#19977)', () } }); + // #20437 — a forced replica with nothing to replicate from. The driver used to + // build it as a replica and run it as a plain local database that never + // synced; both now refuse it, on `mode`, in one message. + describe("a forced `mode: 'replica'` with no `syncUrl` — refused on `mode`", () => { + const FIRST_SENTENCE = + "`mode: 'replica'` makes this datasource an embedded replica, a local file kept in sync with " + + 'the remote named in `syncUrl`, but no `syncUrl` is set: nothing would ever sync, so it would ' + + 'run as a plain local database that never replicates — the turso driver refuses this ' + + 'configuration when it starts.'; + + it('on a `file:` url in any case, beside an empty `syncUrl` or `timeoutMs`: add `syncUrl`, or drop `mode`', () => { + for (const config of [ + { url: 'file:./data/replica.db', mode: 'replica' }, + { url: 'FILE:./data/replica.db', mode: 'replica' }, + { url: 'file:./data/replica.db', mode: 'replica', syncUrl: '' }, + { url: ' file:./data/replica.db', mode: 'replica', timeoutMs: 5000 }, + ]) { + const { path, message } = refusal(config); + expect(path, JSON.stringify(config)).toBe('mode'); + expect(message.startsWith(FIRST_SENTENCE), message).toBe(true); + expect(message).toContain( + "For an embedded replica, name the remote in `syncUrl` beside the local file: " + + "`url: 'file:./data/replica.db'` with `syncUrl` set to the `libsql://` or `https://` Turso endpoint.", + ); + expect(message).toContain("For a plain local database, drop `mode: 'replica'`."); + } + }); + + it('a url another refusal takes keeps that refusal, on `url`', () => { + for (const url of ['libsql://db.turso.io', ':memory:', './data/replica.db']) { + const { path, message } = refusal({ url, mode: 'replica' }); + expect(path, url).toBe('url'); + expect(message.startsWith(FIRST_SENTENCE)).toBe(false); + } + }); + + it('beside `sync`: both refusals stand, `sync` first — the one the constructor throws', () => { + const result = TursoConfigSchema.safeParse({ url: 'file:./data/replica.db', mode: 'replica', sync: { intervalSeconds: 60 } }); + expect(result.success).toBe(false); + expect(result.error!.issues.map((i) => i.path.join('.'))).toEqual(['sync', 'mode']); + expect(result.error!.issues[1].message.startsWith(FIRST_SENTENCE)).toBe(true); + }); + + it('reaches both authoring doors: `DatasourceSchema` (re-pathed under `config`) and `validateDriverConfig`', () => { + const config = { url: 'file:./data/replica.db', mode: 'replica' }; + 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.mode'); + expect(issue, JSON.stringify(datasource.error!.issues)).toBeDefined(); + expect(issue!.message.startsWith(FIRST_SENTENCE)).toBe(true); + + const verdict = validateDriverConfig('turso', config); + expect(verdict.known).toBe(true); + expect(verdict.known && verdict.issues.map((i) => i.path.join('.'))).toEqual(['mode']); + }); + + it('controls: a forced replica beside `syncUrl`, and a `file:` url with no `mode`, stay accepted', () => { + for (const config of [ + { url: 'file:./data/replica.db', mode: 'replica', syncUrl: 'libsql://db.turso.io' }, + { url: 'file:./data/app.db' }, + { url: 'file:./data/app.db', mode: 'local' }, + ]) { + const result = TursoConfigSchema.safeParse(config); + expect(result.success, JSON.stringify(result.error?.issues)).toBe(true); + } + }); + }); + 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); diff --git a/packages/spec/src/data/driver/turso.zod.ts b/packages/spec/src/data/driver/turso.zod.ts index cf8010a3786..fdfb4a6ff45 100644 --- a/packages/spec/src/data/driver/turso.zod.ts +++ b/packages/spec/src/data/driver/turso.zod.ts @@ -99,7 +99,11 @@ export type TursoTransportMode = z.input; // sync-enabled check still answered true. It was refused here first, // because a declared setting that changes nothing is the shape ADR-0049 // does not ship; since #20200 the constructor refuses it too, in this -// arm's own words. +// arm's own words; +// - a forced `mode: 'replica'` with no `syncUrl` (#20437): a replica with no +// remote to replicate from. The driver used to build it as a replica, never +// synced it and ran it as a plain local database; authoring and the +// constructor now refuse it together, in this arm's words. // // The predicates MIRROR the constructor's on `main` (`localEngineDefect`, // `refuseWebSocketTimeout` and `detectMode` in @@ -159,7 +163,7 @@ interface TursoTransportKeys { /** One refusal: the key it sits on and its message. */ interface TursoTransportIssue { - path: 'url' | 'syncUrl' | 'timeoutMs'; + path: 'url' | 'syncUrl' | 'timeoutMs' | 'mode'; message: string; } @@ -243,6 +247,22 @@ function tursoTransportIssues(cfg: TursoTransportKeys): TursoTransportIssue[] { + `${drop} for a plain in-memory local database.`, }]; } + if (mode === 'replica' && !hasSyncUrl) { + // #20437. Only a FORCED replica reaches here: with no `mode`, a replica is + // selected by `syncUrl` alone. The url is a `file:` url (every other one + // met a refusal above), so the url is fine and the MODE is what cannot be + // honoured — the issue sits on `mode`, as the `sync` refusal sits on `sync`. + return [{ + path: 'mode', + message: + "`mode: 'replica'` makes this datasource an embedded replica, a local file kept in sync with " + + 'the remote named in `syncUrl`, but no `syncUrl` is set: nothing would ever sync, so it would ' + + 'run as a plain local database that never replicates — the turso driver refuses this ' + + 'configuration when it starts. For an embedded replica, name the remote in `syncUrl` beside ' + + "the local file: `url: 'file:./data/replica.db'` with `syncUrl` set to the `libsql://` or " + + "`https://` Turso endpoint. For a plain local database, drop `mode: 'replica'`.", + }]; + } return []; } diff --git a/packages/spec/src/migrations/entries/semantic/18.turso-config-forced-replica-without-sync-url-refused.ts b/packages/spec/src/migrations/entries/semantic/18.turso-config-forced-replica-without-sync-url-refused.ts new file mode 100644 index 00000000000..e35382612b6 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.turso-config-forced-replica-without-sync-url-refused.ts @@ -0,0 +1,51 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// A forced embedded replica with no remote to replicate from, refused at both +// doors together: the datasource contract (on mode) and the turso driver's +// constructor, in one message. The driver used to build it as a replica that +// never synced and run it as a plain local database. A structured TODO, not a +// D2 conversion: whether the author meant a replica of some remote (and which +// one) or a plain local file is intent no artifact records. +export const entry: SemanticMigration = { + id: 'turso-config-forced-replica-without-sync-url-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 TursoDriver constructor of ' + + '@objectstack/driver-turso — mode replica with no syncUrl (or an empty one) is now refused, on ' + + 'mode at authoring and at construction. The published TursoConfigSchema mirror of ' + + '@objectstack/driver-turso carries the same text for parity but declares no mode key and strips ' + + 'an authored one, so it cannot see a forced mode and still accepts the config as a local file', + replacement: + 'the configuration the author meant. An embedded replica names the remote it replicates from: ' + + 'keep the file: url and set syncUrl to the libsql or https Turso endpoint, for example url ' + + 'file:./data/replica.db with syncUrl naming the remote. A plain local database drops mode: a ' + + 'file: url with no syncUrl and no mode is a local database', + reason: + 'An embedded replica is a local file kept in sync with the remote named in syncUrl, so a replica ' + + 'is defined by its remote. The ruling of 2026-09-28 weighed refusing this shape against ' + + 'documenting a replica with no remote as a local mode, and refused it: with no remote there is ' + + 'no replica mode to document, only a declaration nothing honours. A ' + + 'forced mode replica with no syncUrl parsed clean at authoring, and the turso driver built it as ' + + 'a replica that never synced: no sync client was created, no sync interval started, the sync ' + + 'call did nothing and the sync-enabled check answered false, while every read and write went ' + + 'to the local file (measured on the built driver). A declared mode the runtime never runs is ' + + 'the declared-but-not-enforced shape ADR-0049 does not ship, so the datasource contract and the ' + + 'constructor now refuse it together, with one message, which names both ways out. The sibling ' + + 'refusals keep their order: a forced replica on a remote url, an in-memory url or a bare path ' + + 'meets its url refusal first, and one with sync meets the sync refusal first. Stored datasource ' + + 'rows are not re-parsed when they load, so a stored row in this shape now fails when its ' + + 'driver is built: the connection service records it as failed-degraded, a test connection ' + + 'answers ok false, and under ADR-0062 D5 the boot fails fast when objects bind to that ' + + 'datasource, unless OS_ALLOW_DRIVER_CONNECT_FAILURE is set. Measured on this tree at the change: ' + + 'no example, template, published skill or hand-written doc authors the shape, and no host ' + + 'default or environment variable sets mode. 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 a forced replica with no ' + + 'syncUrl at config.mode with both ways out. Decide per datasource whether it is an embedded ' + + 'replica (set syncUrl) or a local file (drop mode). Done when every turso datasource parses, ' + + 'the driver builds from it, and every datasource that declares mode replica carries a syncUrl.', +}; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 5252470f021..b900ba569a7 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -16328,6 +16328,53 @@ const step18: MigrationStep = { + '(`@objectstack/service-settings`’s `settingsBuiltinTranslations`) — ⛔ do not re-add ' + 'app-side copy at either door, which is refused.', }, + // A forced embedded replica with no remote to replicate from, refused at both + // doors together: the datasource contract (on mode) and the turso driver's + // constructor, in one message. The driver used to build it as a replica that + // never synced and run it as a plain local database. A structured TODO, not a + // D2 conversion: whether the author meant a replica of some remote (and which + // one) or a plain local file is intent no artifact records. + { + id: 'turso-config-forced-replica-without-sync-url-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 TursoDriver constructor of ' + + '@objectstack/driver-turso — mode replica with no syncUrl (or an empty one) is now refused, on ' + + 'mode at authoring and at construction. The published TursoConfigSchema mirror of ' + + '@objectstack/driver-turso carries the same text for parity but declares no mode key and strips ' + + 'an authored one, so it cannot see a forced mode and still accepts the config as a local file', + replacement: + 'the configuration the author meant. An embedded replica names the remote it replicates from: ' + + 'keep the file: url and set syncUrl to the libsql or https Turso endpoint, for example url ' + + 'file:./data/replica.db with syncUrl naming the remote. A plain local database drops mode: a ' + + 'file: url with no syncUrl and no mode is a local database', + reason: + 'An embedded replica is a local file kept in sync with the remote named in syncUrl, so a replica ' + + 'is defined by its remote. The ruling of 2026-09-28 weighed refusing this shape against ' + + 'documenting a replica with no remote as a local mode, and refused it: with no remote there is ' + + 'no replica mode to document, only a declaration nothing honours. A ' + + 'forced mode replica with no syncUrl parsed clean at authoring, and the turso driver built it as ' + + 'a replica that never synced: no sync client was created, no sync interval started, the sync ' + + 'call did nothing and the sync-enabled check answered false, while every read and write went ' + + 'to the local file (measured on the built driver). A declared mode the runtime never runs is ' + + 'the declared-but-not-enforced shape ADR-0049 does not ship, so the datasource contract and the ' + + 'constructor now refuse it together, with one message, which names both ways out. The sibling ' + + 'refusals keep their order: a forced replica on a remote url, an in-memory url or a bare path ' + + 'meets its url refusal first, and one with sync meets the sync refusal first. Stored datasource ' + + 'rows are not re-parsed when they load, so a stored row in this shape now fails when its ' + + 'driver is built: the connection service records it as failed-degraded, a test connection ' + + 'answers ok false, and under ADR-0062 D5 the boot fails fast when objects bind to that ' + + 'datasource, unless OS_ALLOW_DRIVER_CONNECT_FAILURE is set. Measured on this tree at the change: ' + + 'no example, template, published skill or hand-written doc authors the shape, and no host ' + + 'default or environment variable sets mode. 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 a forced replica with no ' + + 'syncUrl at config.mode with both ways out. Decide per datasource whether it is an embedded ' + + 'replica (set syncUrl) or a local file (drop mode). Done when every turso datasource parses, ' + + 'the driver builds from it, and every datasource that declares mode replica carries a syncUrl.', + }, // #15680 / #15682 (stack card of #14478, maintainer ruling B: a duration key // carries its unit in its NAME) — the D3 entry of the // `turso-config-timeout-to-timeout-ms` family (ruling B on #17152: one D3 entry