Skip to content

Commit c876a74

Browse files
fix(spec,driver-turso)!: refuse a forced mode replica with no syncUrl at authoring and at construction (#20437) (#20504)
Fixes #20437 Clause-②: yes (narrowing) The `Clause-②` line above is the claim's (comment 5876481971), copied as it stands. The changeset carries the same value. Session `session_01N8TPEsoJxPsdSdNKGnNGEN` (PM dispatch, `domain:engine` seat 1, mode:subagent), branch `claude/issue-20437-replica-needs-syncurl`. The branch starts at `fc0db22bc` and was merged with `origin/main` at `9bf5e67af` in a true merge commit. **Every final reading below was taken at head `7bb7b3aee`** unless it says otherwise. ## What changes A turso config that forces `mode: 'replica'` on a `file:` url with no `syncUrl` (or an empty one) is now refused at both doors, with one message, as triage ruled (5871347046: "Refuse, with one message, at both doors in one PR"): - **Authoring.** `tursoTransportIssues` in `packages/spec/src/data/driver/turso.zod.ts` gains a replica arm after the in-memory one. It returns one `custom` issue on **`mode`**, the key that cannot be honoured, as the `sync` refusal sits on `sync`. It reaches `DatasourceSchema` (as `config.mode`), `validateDriverConfig`, `defineStack` / `os validate`, and a save or test connection through the datasource admin service. - **Construction.** `new TursoDriver()` refuses the same config with `VALIDATION_ERROR` / 400, before `super()`. The check sits after the `sync`-without-`syncUrl` refusal, so a config with both defects meets the `sync` message first, which is also the spec's first issue. The message is a module constant, `REPLICA_MODE_WITHOUT_SYNC_URL_REFUSAL`, next to #20200's two, and thrown through the same `refuseIgnoredSyncKey` helper. The parity table pins it byte-equal to the schema's issue. That is #20200's pattern (H3). - **The driver mirror** (`packages/drivers/driver-turso/src/spec/turso.zod.ts`) carries the same arm byte for byte. The mirror strips `mode`, so the arm is unreachable through it (see H4), and it stays for copy parity like the mirror's other forced-mode branches. The message, the same text at both doors: > `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'`. It gives both fixes the ruling prescribes: add `syncUrl`, or drop `mode: 'replica'` for a plain local file. It echoes no url and carries no tracker id. ## H1: the premise, measured before the change At the base `fc0db22bc`, the existing pins that hold today's answer passed: 3 files, 38 passed (`-t replica`). They were the parity row "file: under a forced mode: 'replica'" (constructor accept, spec accept), the unrecognised-url file's WIDENED `FILE:` + `mode 'replica'`, no `syncUrl` cell (constructs, and the rows survive a restart), and the #20200 file's "the rider stays" control. So both schemas accepted the config, and the constructor built it with `transportMode = 'replica'`. That half of H1 holds as stated. The runtime half (`isSyncEnabled()` false, no interval, `sync()` a no-op) rests on two things. The first is the card's dist probe (the #20200 dev, at `dbddf02c1` and again at `2242ad513`). The second is the source at the base: `connect()` builds the sync client only inside `if (this.tursoConfig.syncUrl)` (`turso-driver.ts:1821`), `sync()` returns early on `!(this.libsqlClient && this.tursoConfig.syncUrl)` (`:3522`), and `isSyncEnabled()` is `!!this.tursoConfig.syncUrl && this.libsqlClient !== null` (`:3535`). **My own dist probe was NOT MEASURED**: the session's permission classifier refused writing the probe script to the scratchpad, so I did not re-run it through another channel (see Deviations). ## H2: producers, before refusing anything Census of `mode: 'replica'` / `"replica"` across the tree at `fc0db22bc`, plus every spelling that builds a `TursoConfig`: - **examples/, `packages/create-objectstack` (templates), `skills/`, hand-written `content/docs`**: zero authors of a forced replica. The only `content/docs` hit is the auto-generated reference row for the `mode` enum. - **Environment mappings**: the only `TURSO_*` / `OS_DATABASE_*` names read in `packages/**/src` are `OS_DATABASE_URL`, `OS_DATABASE_AUTH_TOKEN`, `OS_DATABASE_DRIVER`, `OS_DATABASE_POOL_MAX`, `OS_DATABASE_SQLITE_JOURNAL_MODE`, `TURSO_DATABASE_URL`, `TURSO_AUTH_TOKEN` and `TURSO_TOKEN`. None of them maps to `mode` or `syncUrl`. - **Programmatic builders**: a turso `mode` reaches the driver only through `buildTursoDriverConfig`'s `mode` reader (`packages/services/service-datasource/src/turso-driver-config.ts:205`), which reads an authored `datasource.config.mode`. The CLI and the standalone host build their default datasource from the env url and token alone. - **In-repo fixtures that spell `mode: 'replica'`**: `packages/cli/src/utils/storage-driver.test.ts:478`, `packages/runtime/src/turso-driver-factory.convergence.test.ts:70/:102` and `packages/services/service-datasource/src/__tests__/turso-driver-config.test.ts:49/:60`. Every one carries a `syncUrl`, and none constructs the real driver. - **`objectstack-ai/cloud`: NOT MEASURED.** Triage names it. The repo is not reachable from this session: REST code search answers "sessions are bound to their configured repositories", and `git ls-remote` is refused. Attaching it through the session tool was refused by the permission classifier. The seat or the maintainer should census cloud before this lands. So no shipped in-repo config declares a replica with no remote, and the ruling's `needs_decision` branch does not trigger on anything measured here. ## H4: every mode / url cell, before and after | config | before: constructor | before: spec / mirror | after | | --- | --- | --- | --- | | `file:`, no `mode`, no `syncUrl` | local | accept / accept | unchanged (a plain local file, not a replica) | | `file:` + forced `replica`, no `syncUrl` | replica, never syncs | accept / accept (the mirror strips `mode` and judges it local) | **refused** at construction and by the spec, on `mode` | | the same on an uppercase `FILE:` url | the same (the WIDENED cell) | accept / accept | **refused** | | the same with `syncUrl: ''` (unset) | the same | accept / accept | **refused** | | the same with `timeoutMs` | the same | accept / accept | **refused** | | the same with `sync`, no `syncUrl` | refused, `sync` message (#20200) | 1 issue on `sync` | constructor unchanged (the `sync` message). The spec now raises 2 issues, `sync` then `mode` | | `libsql://` + forced `replica`, no `syncUrl` | refused, remote-url message | refused on `url` | unchanged. It keeps its `url` refusal, which already names both ways out (drop `mode` or set it remote; or a `file:` url beside `syncUrl`). Not the same message, and no second refusal | | `:memory:` / `file::memory:` + forced `replica` | refused, in-memory message | refused on `url` | unchanged | | a bare path + forced `replica` | refused, unrecognised-url message | refused on `url` | unchanged | | `file:` + forced `replica` + `syncUrl` | replica | accept / accept | unchanged (control) | The "after" column is pinned by the new test file (the refusal, ORDER and CONTROLS blocks) and by the parity table. ## H5: ADR-0087 disposition — `registered` The family's entry `turso-config-transport-mismatch-refused` does not cover this refusal. Its `surface` enumerates the refused combinations (a remote url beside `syncUrl` or under a forced local/replica mode, an unrecognised url, an in-memory replica, `timeoutMs` beside a WebSocket url, and `syncUrl` under a forced remote mode). A forced replica on a `file:` url with no `syncUrl` is not among them, and its `acceptanceCriteria` name only `config.url`, `config.syncUrl` and `config.timeoutMs`. Its `replacement` would fit, but the surface an upgrading author greps would not name the shape. So, per the order's H5, a new D3 entry lands with the change: `packages/spec/src/migrations/entries/semantic/18.turso-config-forced-replica-without-sync-url-refused.ts`, id `turso-config-forced-replica-without-sync-url-refused`, with its `surface`, `replacement`, `reason` (the measurement, the order of the sibling refusals, and what a stored row now does at boot) and `acceptanceCriteria` (reported at `config.mode`). `src/migrations/registry.ts` was regenerated by `gen:migration-registry` (311 semantic), never by hand. **Patch round 1 corrected the entry's `surface`.** It had named the driver's `TursoConfigSchema` mirror among the surfaces where this shape "is now refused, on mode". The mirror cannot refuse it, because it declares no `mode` key and strips an authored one. The `surface` now names the two doors, the spec contract and the constructor. It says the mirror carries the arm's text for parity but cannot see a forced mode and still accepts the config as a local file (review 5877910448, judgment 4). `registry.ts` was regenerated again by `gen:migration-registry`. The existing entry is untouched, because it is #20200's text and stays true. The changeset carries `registered turso-config-forced-replica-without-sync-url-refused`, and `check-adr-0087-registration` reads it as `[BREAKING+bang+clause-②-narrowing] registered … (new here: …)`. `spec-changes.json` and `docs/protocol-upgrade-guide.md` do not project major-18 entries yet (the sibling entry is absent from both too), and `check:spec-changes` / `check:upgrade-guide` are green. **Stored rows** (read, not edited): `buildTursoDriverConfig` forwards a stored row's `mode` unparsed, so a row in this shape now fails at `factory.create`. `DatasourceConnectionService` records it `failed-degraded` (`datasource-connection-service.ts:355/:457`), and a test connection answers `ok: false` with "Failed to build driver: MESSAGE" (`datasource-admin-plugin.ts:707`). Under ADR-0062 D5 the boot fails fast when objects bind to it, unless `OS_ALLOW_DRIVER_CONNECT_FAILURE` is set. The changeset's FROM → TO paragraph says so. ## Tests | suite at `7bb7b3aee` | result | | --- | --- | | `@objectstack/driver-turso` vitest, whole package | 77 files · 2065 passed · 22 skipped · exit 0 | | `@objectstack/driver-turso` typecheck (`tsc --noEmit`) | exit 0. `tsc --listFilesOnly` (pre-merge) counts all 4 touched test files in the program | | `@objectstack/spec` vitest `--project local`, 3 shards | 572 files · 16796 passed · 1 todo (6070 + 5190 + 5536), exit 0 on each shard | | `@objectstack/spec` typecheck (tsc + scripts + `check:test-typecheck`) | exit 0 | | `@objectstack/spec` `check:generated` (pre-merge, after the spec build) | "All 15 generated artifacts are up to date" | The 22 skips are the parity table's forced-mode rows for the mirror, which strips `mode`: 18 before, plus the 4 new `mode` rows. - **`spec/turso-config-constructor-parity.test.ts`**: the row "file: under a forced mode: 'replica'" flips from `accept` to `refuse`, `refusedOn: 'mode'`. Three `mode` rows are added (an uppercase `FILE:` url, an empty `syncUrl`, and `timeoutMs`), plus an accept control, "file: + syncUrl under a forced mode: 'replica'". The row "sync with no syncUrl under a forced mode: 'replica'" now declares `issues: 2`, a new per-row field (default 1). `SYNC_KEY_REFUSALS` takes the `mode` rows, so each of the 4 asserts the constructor's message equals the spec issue's. New floors: `mode` at least 4, sync-key refusals at least 12. - **`turso-driver-unrecognised-url-refusal.test.ts`**: the WIDENED `FILE:` + `mode 'replica'`, no `syncUrl` cell leaves PRESERVATION, and the restart test keeps only its `syncUrl` half. The header records why. - **`turso-driver-ignored-sync-key-refusal.test.ts`**: the "rider stays" control is removed, and the header points to the new file. - **`turso-driver-forced-replica-without-sync-url-refusal.test.ts` (new)**: the refusal as the envelope (`code` + `status`) plus its first sentence, on `file:` and `FILE:` urls, beside an empty `syncUrl`, `timeout`, `encryptionKey`, and a supplied client (the client stays untouched). It also covers `createTursoDriver()` and a url-echo check. ORDER: a remote url, `:memory:`, `file::memory:`, a bare path and `sync` each keep their own refusal. CONTROLS: a forced replica beside `syncUrl` connects, reports sync on and keeps its rows across a restart; a replica selected by `syncUrl`; `file:` with no `mode`; `mode: 'local'`; `:memory:`; and `detectMode` still answers `replica`. - **`packages/spec/src/data/driver/turso.test.ts`**: the accept fixture `{ url: 'file:./data/replica.db', mode: 'replica' }` gains a `syncUrl`. A new block asserts the refusal on `mode` (the envelope, the first sentence, both ways out), that the url refusals keep their order, the two issues beside `sync`, both authoring doors (`config.mode` and `validateDriverConfig`), and the controls. **Reverse verification**, via `scripts/ablation-replace.mjs` from the committed state, pre-merge head `79fbad660`. Each direction was predicted before the run, and all three matched: 1. Constructor refusal disabled: `if (mode === 'replica' && !config.syncUrl) {` became `if (false && …) {`, and the mutation landed (anchor 1 → 0, blob `8c7f7be9` → `a0c11cd6`). Predicted 16 RED. Got **16 failed** / 236 passed: the new file's 8 refusal cases, plus the parity table's 4 constructor verdicts and 4 message pins. ORDER and CONTROLS stayed green. Restored: blob == HEAD, `git diff HEAD` empty. 2. One byte of the copy: a doubled space after the constant's first sentence (blob → `e70d75a1`). Predicted exactly the 4 message pins. Got **4 failed**: exactly the 4 parity message pins, while every verdict and first-sentence case stayed green. The byte-equality pin is what holds the copy. Restored the same way. 3. The spec arm disabled in `packages/spec/src/data/driver/turso.zod.ts` (blob `fdfb4a6f` → `0634285e`), against the spec's own source-level test. Predicted 3 RED. Got **3 failed** / 31 passed: the refusal, the two-issue and the both-doors cases. The order and control cases stayed green. Restored the same way. The driver tests import the driver from `src`, so ablations 1 and 2 needed no build. Ablation 3 was read on the spec's own source tests only; the parity table's spec half reads the built spec dist, and that was not re-ablated. ## Gates `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`, run after the last commit at `7bb7b3aee`, lists 11 paths vs merge base `9bf5e67af` and **91 commands**. Every command ran, with its exit code written to disk before any pipe. `--ran` reconciliation reads `91 derived famil(ies) accounted for — 89 run, 2 NOT-MEASURED (2 DERIVED from a recorded exit 3)`, with 0 UNRUN. - **NOT MEASURED (exit 3, PREREQUISITE NOT MET):** `check:dual-build-cjs-loads` (dozens of workspace packages have no `dist/`) and `check:type-check-debt` (it needs a whole-workspace build). Both are CI's run. - **Run twice:** `check:doc-formula-expressions` and `check:lean-entry-closure` first answered exit 3. They are exit 0 after building the `@objectstack/lint` and `@objectstack/objectql` closures. - **Notable readings:** - `check-adr-0087-registration` → `registered turso-config-forced-replica-without-sync-url-refused` (new here), BREAKING, bang, clause-② narrowing; - `check-changeset-no-major` exit 0; - `check:migration-registry` → `registry.ts is current (311 semantic, 230 retired-key, 206 retired-def)`; - `check:driver-conformance`, `check:nul-bytes`, `check:doc-authoring`, `check:test-source-alias`, `check:cross-package-test-inputs`, `check:api-surface` ("unchanged"), `check:authorable-surface`, `check:docs`, `check:spec-changes`, `check:upgrade-guide` and `check-empty-changeset` → exit 0. - **Roster families under a touched directory, also run:** `check-changeset-fixed`, spec `check:meta-url-spelling`, `check:authz-resolver`, `check:error-code-casing` and `check:filter-alias-parity`, all exit 0. - **Narrowed lint:** `eslint --no-inline-config --format json` over the 10 changed TS files reports 10 files, 0 errors and 0 warnings. The population is `eslint.config.mjs`'s lint object, `files: ['**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}']`; the changeset `.md` is outside it. Invariance holds because the config enables no type-aware linting (no `parserOptions.project`), so this diff cannot move any untouched file's verdict. The full `pnpm lint` is CI's. - **Not run locally, left to CI:** the whole-workspace type-check lanes; the Test Core, Dogfood and Build Core jobs; and the downstream suites of `@objectstack/service-datasource`, `@objectstack/runtime` and `@objectstack/cli`. The narrowing is declared: the public surface's bytes are unchanged (`check:api-surface` and `check:authorable-surface` are green, and refinements are not in the JSON Schema). The fixture census above finds no consumer fixture that spells the refused shape, and none that constructs the real driver with a forced mode. **Driver-conformance ledger (`lanes/engine.md`):** `check:driver-conformance` read `OK — 50 covered cell(s), 0 in the DEBT ledger, 0 exempt` both before (`fc0db22bc`) and after (`7bb7b3aee`). `driver-turso` is `ok` on all 10 case-sets both times. No movement. ## Deviations (declared) - **H1 dist probe not re-run.** Writing a scratch probe script was refused by the session's permission classifier. The runtime half rests on the card's cited probe and the base source lines above, and the constructor/schema half on the existing pins at the base. - **Cloud producers not measured** (H2): the repo is unreachable from this session, and attaching it was refused. Nothing in-repo triggers `needs_decision`. - **Parity harness:** a per-row `issues` count (default 1) was added for the one row where the spec now raises two issues. The alternative was to suppress the `mode` issue when `sync` also fires, which would couple the two arms. Raising both matches how the schema reports every other independent defect. - **Issue path `mode`:** the triage ruling names the message, not the key. `mode` was chosen by the sibling convention (the `sync` refusal sits on the present, unhonourable key). `TursoTransportIssue.path` widens to include `'mode'` in both copies (module-local types, no export). ## Acceptance notes - `packages/drivers/driver-turso/README.md`'s list of constructor refusals named only the url refusals. **Patch round 1 adds** this refusal and #20200's two sync-key refusals, one line each. It is docs text only and outside the claim's letter, so it is declared as a deviation. - The spec's `mode` key keeps its one-line TSDoc. The requirement is carried by the refusal text and by `TursoDriverConfig.mode`'s TSDoc in the driver, which now names it. That avoids a describe/TSDoc regeneration lap for no authoring gain. - The existing D3 entry `turso-config-transport-mismatch-refused` is untouched. Its "Nothing the constructor accepts is refused" stays true, because both doors refuse this shape together. ## Patch round 1 (text only, head `5dfa45e9f`) This round follows the at-tier review 5877910448 (PASS at `7bb7b3aee`; judgment 4 names one wrong clause). It makes two edits and changes no code, test, schema text or message: 1. **The D3 entry `turso-config-forced-replica-without-sync-url-refused`**: its `surface` no longer lists the driver mirror among the refusing surfaces. It now names the spec contract and the constructor, and says what the mirror does (the H5 note above). `registry.ts` was regenerated by `gen:migration-registry`, and its hunk equals the entry's, re-indented. The sibling family entry `turso-config-transport-mismatch-refused` is not edited here; the seat carries its same overstatement as a note. 2. **`packages/drivers/driver-turso/README.md`**: the list of constructor refusals gains one line each for `syncUrl` under a forced `mode: 'remote'`, `sync` with no `syncUrl`, and a forced `mode: 'replica'` with no `syncUrl`, in the list's own style. This is a declared deviation (docs text, outside the claim's letter). The head is `6a076723d` (the text commit) plus a true merge of `origin/main` at `4a1df1965`. The merge touched no migrations or turso path, and `gen:migration-registry` after it wrote no change. Readings at `5dfa45e9f`, each exit code recorded before any pipe: - `pnpm --filter @objectstack/spec check:migration-registry` exit 0: `registry.ts is current (311 semantic, 230 retired-key, 206 retired-def)`; - `pnpm check:adr-0087-registration` exit 0: `[BREAKING+bang+clause-②-narrowing] registered turso-config-forced-replica-without-sync-url-refused (new here …)`; - spec `check:spec-changes`, `check:upgrade-guide`, `check:doc-authoring`, `check:nul-bytes` and `check-changeset-no-major` all exit 0; - spec vitest `--project local` over `src/migrations`, `src/conversions` and `src/data/driver`: 22 files, 1044 passed, exit 0. `dispatch-gates --commands` at this head derives the same 91 commands as round 0 (12 paths vs merge base `4a1df1965`; the README adds no family). The full union was not re-run for this text-only delta. The round-0 union at `7bb7b3aee` stands, and CI measures this head. ## Patch round 2 (merge + form D), head `519cfbce5` This round follows the maintainer's ruling recorded in 5883364572, 「20504 不考虑 cloud 现有数据」: the cloud producer census stays NOT MEASURED, waived by the maintainer, and nothing in `objectstack-ai/cloud` was read or edited. The round changes no code, test, schema text, refusal message or changeset. The changeset's level and the `Clause-②` line do not move. 1. **The merge.** `origin/main` at `1c761c0d7` was merged into the branch in a true merge commit, `04be827ce` (parents `5dfa45e9f` and `1c761c0d7`), by `bash scripts/pm/os-regen-merge.sh`. There was no rebase and no force-push. Since the old base `4a1df1965`, main had moved over two of this PR's files, and both auto-merged with no conflict: - `packages/spec/src/migrations/registry.ts` (generated): stages 4–6 of the guidance rewrite restated other entries, and new entries landed. - `packages/drivers/driver-turso/src/turso-driver.ts`: the `$empty` value-shape resolver wiring and the `$empty` presence-flag case. The merged file carries both sides: `setDeclaredValueShapeResolver` and `case '$empty':` sit beside `REPLICA_MODE_WITHOUT_SYNC_URL_REFUSAL`. `packages/spec/src/data/driver/turso.zod.ts` did not move on main in that window. The merge left no os-regen deferral. 2. **Regeneration, with the repo's own tooling.** `pnpm --filter @objectstack/spec check:migration-registry` read the textually merged `registry.ts` as current (315 semantic, 232 retired-key, 206 retired-def), and `pnpm --filter @objectstack/spec gen:migration-registry` then wrote no change. After the entry edit in item 3, `gen:migration-registry` ran again, and its `registry.ts` hunk is the entry's hunk re-indented (+4 / −1 in each file). `spec-changes.json` and `docs/protocol-upgrade-guide.md` still project no major-18 entry. `check:spec-changes`, `check:upgrade-guide` and `pnpm --filter @objectstack/spec check:generated` ("All 15 generated artifacts are up to date", after the spec build at this head) are green, so no other artifact needed regenerating. Against the new merge base the net diff is 12 files, +539 / −43. 3. **Form D in the entry's `reason`.** The file is `18.turso-config-forced-replica-without-sync-url-refused.ts`, and `reason` is the `why:` line `os migrate meta` prints. The id, `surface`, `replacement`, `acceptanceCriteria`, the registration and the ADR-0087 disposition (`registered turso-config-forced-replica-without-sync-url-refused`) are unchanged. Only the opening moves: - **Before:** "`#20437`. An embedded replica is a local file kept in sync with the remote named in syncUrl. A forced mode replica with no syncUrl parsed clean at authoring, …" - **After:** "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, …" The rest of `reason` is byte-identical. The date and the lesson are triage's ruling (5871347046): an embedded replica is defined by its remote, so there is no legitimate local replica mode to document instead. The four author-shown fields were evaluated with their string literals joined, and scanned with the CLI pin's detector (`#` followed by 4 or 5 digits). Each field reads 0 now. The lit control is the same probe over the entry at `5dfa45e9f`, which reads 1 in `reason`. No test asserted the old sentence, so nothing was re-pinned. The CLI pin `migrate-meta-engine-guidance.test.ts` selects entries by id prefix, and `turso-` is not one of its covered families, before or after stage 7 on main. No other test quotes the text. The sibling `turso-config-transport-mismatch-refused` still opens with a tracker number, and it is untouched, as ordered. **Readings at `519cfbce5`, the head measured.** Each exit code was recorded before any pipe. - **Builds.** `turbo run build` over the `@objectstack/driver-turso`, `@objectstack/lint` and `@objectstack/objectql` closures: 15 tasks, exit 0. - **`@objectstack/driver-turso`.** Vitest over the whole package: 78 files, 2108 passed, 22 skipped, exit 0. Typecheck exit 0, and `tsc --listFilesOnly` counts all 4 touched test files. - **`@objectstack/spec`.** Vitest `--project local` in 3 shards: 574 files, 16884 passed, 1 todo (6106 + 5220 + 5558), exit 0 on each shard. Typecheck (tsc, scripts and `check:test-typecheck`) exit 0. - **Derived gates.** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` lists 12 paths vs merge base `1c761c0d7` and 91 commands, and all 91 ran. `--ran` reads "91 derived famil(ies) accounted for — 89 run, 2 NOT-MEASURED (2 DERIVED from a recorded exit 3)". The 2 NOT MEASURED are `check:dual-build-cjs-loads` and `check:type-check-debt`, both PREREQUISITE NOT MET because they need a whole-workspace build. CI runs both. - **Gates the order names.** `pnpm check:doc-authoring` exit 0. `pnpm check:adr-0087-registration` exit 0: the self-test holds 441 assertions, and the gate reads `[BREAKING+bang+clause-②-narrowing] registered turso-config-forced-replica-without-sync-url-refused (new here …)`. `pnpm --filter @objectstack/spec check:migration-registry` exit 0 (315 semantic). `check:spec-changes`, `check:upgrade-guide`, `check:release-spec-changes`, `check-changeset-no-major --base origin/main` and `check:nul-bytes` exit 0. `check:doc-authoring` does not read a migration entry's prose fields: patch round 1 recorded it green at `5dfa45e9f` with the number still present. So the proof for item 3 is the field census above. - **Roster families under a touched directory.** `check-changeset-fixed`, spec `check:meta-url-spelling`, `check:authz-resolver`, `check:error-code-casing` and `check:filter-alias-parity`, all exit 0. - **Narrowed lint.** `eslint --no-inline-config --format json` over the 10 changed TS files reports 10 files, 0 errors and 0 warnings. The population is `eslint.config.mjs`'s `files: ['**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}']`. The config enables no type-aware linting, so this diff cannot move an untouched file's verdict. - **Main moved after the merge.** It is now at `f11b5f20a`, stage 7 of the guidance rewrite, which touched `registry.ts` and other families' entries. A driver-free probe (a bare shared clone, `merge-tree --write-tree`) merges this head with it cleanly. `build-migration-registry.ts --check` over the probed tree reads `registry.ts` as current (315 semantic). The branch was not merged again this round. **Deviations (declared).** The branch took two pushes this round, the merge commit and then the reword commit, following AGENTS.md's push-before-every-long-step rule. The merge commit carries no trailer. The reword commit ends with the model-free trailer pair. --- _Generated by [Claude Code](https://claude.ai/code/session_01N8TPEsoJxPsdSdNKGnNGEN)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent c9d234c commit c876a74

12 files changed

Lines changed: 539 additions & 43 deletions
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/driver-turso': minor
4+
---
5+
6+
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
7+
8+
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.
9+
10+
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.
11+
12+
**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:
13+
14+
- **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;
15+
- **at construction**, `VALIDATION_ERROR` / 400 from `new TursoDriver()` (and `createTursoDriver()`), before any client or database is opened.
16+
17+
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.
18+
19+
### Migration: FROM → TO
20+
21+
| You wrote | Write instead |
22+
| --- | --- |
23+
| `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'` |
24+
| the same | a plain local database: drop `mode` (`url: 'file:./data/app.db'` alone) |
25+
26+
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.
27+
28+
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.
29+
30+
<!-- adr-0087: registered turso-config-forced-replica-without-sync-url-refused -->

‎packages/drivers/driver-turso/README.md‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,18 @@ no embedded replica for a remote url anyway. For a remote database, drop
223223
no local engine, so its url is not judged here: `@libsql/client` refuses a
224224
url it cannot open when the driver connects.
225225

226+
The constructor also refuses (`VALIDATION_ERROR` / 400) three sync settings
227+
that nothing would honour, each with the message `@objectstack/spec`'s
228+
`TursoConfigSchema` gives at authoring:
229+
230+
- `syncUrl` under a forced `mode: 'remote'`, where the remote client never
231+
receives it. For a remote database, drop `syncUrl` (and `sync`);
232+
- `sync` with no `syncUrl` (or an empty one), in any mode, where nothing reads
233+
it. Set `syncUrl`, or remove `sync`;
234+
- a forced `mode: 'replica'` with no `syncUrl` (or an empty one), which would
235+
never sync and would run as a plain local database. Name the remote in
236+
`syncUrl` beside the `file:` url, or drop `mode` for a local database.
237+
226238
You can also force a specific mode:
227239

228240
```typescript

‎packages/drivers/driver-turso/src/spec/turso-config-constructor-parity.test.ts‎

Lines changed: 29 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@
2828
* - [#20200] where the constructor refuses on `syncUrl` or `sync`, its message
2929
* is the spec contract's issue message, byte for byte: those two texts are
3030
* copies in `../turso-driver.ts`, and this is the pin that holds them equal.
31+
* [#20437] The same holds for a forced `mode: 'replica'` with no `syncUrl`,
32+
* refused on `mode` — the third copy. That row used to be accepted
33+
* everywhere, as a replica that never synced.
3134
*
3235
* ⚠️ The mirror declares no `mode`, so zod strips an authored one before its
3336
* refinement runs: rows that FORCE a mode are judged by the constructor and the
@@ -74,7 +77,9 @@ interface Row {
7477
/** The constructor's verdict on this config. */
7578
ctor: 'accept' | 'refuse';
7679
/** The key both schemas refuse on, or `undefined` when they accept. */
77-
refusedOn?: 'url' | 'syncUrl' | 'timeoutMs' | 'sync';
80+
refusedOn?: 'url' | 'syncUrl' | 'timeoutMs' | 'sync' | 'mode';
81+
/** How many issues the spec contract raises on a refused row; 1 unless stated. */
82+
issues?: number;
7883
/** The constructor accepts it and ignores a key: refused at authoring only. None today (#20200). */
7984
inert?: true;
8085
}
@@ -97,7 +102,7 @@ const ROWS: Row[] = [
97102
{ name: 'a url behind whitespace (the loaders trim it)', config: { url: ` ${FILE}` }, ctor: 'accept' },
98103
{ name: 'a remote url behind whitespace', config: { url: ` ${REMOTE}` }, ctor: 'accept' },
99104
{ name: 'an empty syncUrl (unset)', config: { url: REMOTE, syncUrl: '' }, ctor: 'accept' },
100-
{ name: "file: under a forced mode: 'replica'", config: { url: FILE, mode: 'replica' }, ctor: 'accept' },
105+
{ name: "file: + syncUrl under a forced mode: 'replica'", config: { url: FILE, mode: 'replica', syncUrl: REMOTE, sync: { onConnect: false } }, ctor: 'accept' },
101106
{ name: "file: + syncUrl under a forced mode: 'local'", config: { url: FILE, mode: 'local', syncUrl: REMOTE, sync: { onConnect: false } }, ctor: 'accept' },
102107
{ name: "libsql:// under a forced mode: 'remote'", config: { url: REMOTE, mode: 'remote' }, ctor: 'accept' },
103108
{ name: "file: under a forced mode: 'remote'", config: { url: FILE, mode: 'remote' }, ctor: 'accept' },
@@ -147,13 +152,24 @@ const ROWS: Row[] = [
147152
{ name: 'sync with no syncUrl', config: { url: FILE, sync: { intervalSeconds: 60 } }, ctor: 'refuse', refusedOn: 'sync' },
148153
{ name: 'sync with no syncUrl on a remote url', config: { url: REMOTE, sync: { intervalSeconds: 60 } }, ctor: 'refuse', refusedOn: 'sync' },
149154
{ name: "sync with no syncUrl under a forced mode: 'remote'", config: { url: REMOTE, mode: 'remote', sync: { onConnect: true } }, ctor: 'refuse', refusedOn: 'sync' },
150-
{ name: "sync with no syncUrl under a forced mode: 'replica'", config: { url: FILE, mode: 'replica', sync: { intervalSeconds: 60 } }, ctor: 'refuse', refusedOn: 'sync' },
155+
// The spec contract raises BOTH issues here (`sync`, then `mode`); the
156+
// constructor throws one, the `sync` refusal, which is the spec's first.
157+
{ name: "sync with no syncUrl under a forced mode: 'replica'", config: { url: FILE, mode: 'replica', sync: { intervalSeconds: 60 } }, ctor: 'refuse', refusedOn: 'sync', issues: 2 },
151158
{ name: 'sync beside an empty syncUrl (unset)', config: { url: FILE, syncUrl: '', sync: { intervalSeconds: 60 } }, ctor: 'refuse', refusedOn: 'sync' },
159+
160+
// ── a forced replica with no remote to replicate from: refused on `mode` (#20437) ──
161+
{ name: "file: under a forced mode: 'replica'", config: { url: FILE, mode: 'replica' }, ctor: 'refuse', refusedOn: 'mode' },
162+
{ name: "an uppercase FILE: url under a forced mode: 'replica'", config: { url: `FILE:${DIR}/upper-replica.db`, mode: 'replica' }, ctor: 'refuse', refusedOn: 'mode' },
163+
{ name: "file: + an empty syncUrl (unset) under a forced mode: 'replica'", config: { url: FILE, mode: 'replica', syncUrl: '' }, ctor: 'refuse', refusedOn: 'mode' },
164+
{ name: "file: + timeoutMs under a forced mode: 'replica'", config: { url: FILE, mode: 'replica', timeoutMs: 5000 }, ctor: 'refuse', refusedOn: 'mode' },
152165
];
153166

154-
/** The rows the constructor refuses on a sync key: its message is a copy of the spec's (#20200). */
167+
/**
168+
* The rows the constructor refuses on a sync key, or on a forced replica with
169+
* no `syncUrl`: its message is a copy of the spec's (#20200, #20437).
170+
*/
155171
const SYNC_KEY_REFUSALS = ROWS.filter(
156-
(r) => r.ctor === 'refuse' && (r.refusedOn === 'syncUrl' || r.refusedOn === 'sync'),
172+
(r) => r.ctor === 'refuse' && (r.refusedOn === 'syncUrl' || r.refusedOn === 'sync' || r.refusedOn === 'mode'),
157173
);
158174

159175
/**
@@ -209,7 +225,8 @@ describe('turso config: the constructor, the spec contract and this mirror agree
209225
expect(ROWS.filter((r) => r.refusedOn === 'timeoutMs').length).toBeGreaterThanOrEqual(3);
210226
expect(ROWS.filter((r) => r.refusedOn === 'syncUrl').length).toBeGreaterThanOrEqual(3);
211227
expect(ROWS.filter((r) => r.refusedOn === 'sync').length).toBeGreaterThanOrEqual(5);
212-
expect(SYNC_KEY_REFUSALS.length).toBeGreaterThanOrEqual(8);
228+
expect(ROWS.filter((r) => r.refusedOn === 'mode').length).toBeGreaterThanOrEqual(4);
229+
expect(SYNC_KEY_REFUSALS.length).toBeGreaterThanOrEqual(12);
213230
// [#20200] Exactly zero, not a floor: every key the constructor used to
214231
// build and ignore is refused at construction now (see the header).
215232
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
229246
const verdict = schemaVerdict(SpecTursoConfigSchema, row.config);
230247
expect(verdict.refusedOn, verdict.message).toBe(row.refusedOn);
231248
if (row.refusedOn) {
232-
expect(verdict.count, verdict.message).toBe(1);
249+
expect(verdict.count, verdict.message).toBe(row.issues ?? 1);
233250
expect(verdict.code).toBe('custom');
234251
}
235252
});
@@ -241,11 +258,12 @@ describe('turso config: the constructor, the spec contract and this mirror agree
241258
});
242259
});
243260

244-
// [#20200] The two sync refusals are copies of the spec contract's texts in
245-
// `../turso-driver.ts` (the spec keeps them module-local); this is the pin
246-
// that holds each copy equal to the schema's issue, byte for byte.
261+
// [#20200] The two sync refusals, and [#20437] the forced-replica refusal,
262+
// are copies of the spec contract's texts in `../turso-driver.ts` (the spec
263+
// keeps them module-local); this is the pin that holds each copy equal to the
264+
// schema's issue, byte for byte.
247265
describe.each(SYNC_KEY_REFUSALS)('$name', (row) => {
248-
it("the constructor's message is the spec contract's, byte for byte (#20200)", () => {
266+
it("the constructor's message is the spec contract's, byte for byte (#20200, #20437)", () => {
249267
const spec = schemaVerdict(SpecTursoConfigSchema, row.config);
250268
expect(spec.refusedOn).toBe(row.refusedOn);
251269
expect(spec.message).toBeTypeOf('string');

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

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,7 @@ interface TursoTransportKeys {
155155

156156
/** One refusal: the key it sits on and its message. */
157157
interface TursoTransportIssue {
158-
path: 'url' | 'syncUrl' | 'timeoutMs';
158+
path: 'url' | 'syncUrl' | 'timeoutMs' | 'mode';
159159
message: string;
160160
}
161161

@@ -239,6 +239,24 @@ function tursoTransportIssues(cfg: TursoTransportKeys): TursoTransportIssue[] {
239239
+ `${drop} for a plain in-memory local database.`,
240240
}];
241241
}
242+
if (mode === 'replica' && !hasSyncUrl) {
243+
// #20437. Only a FORCED replica reaches here: with no `mode`, a replica is
244+
// selected by `syncUrl` alone. The url is a `file:` url (every other one
245+
// met a refusal above), so the url is fine and the MODE is what cannot be
246+
// honoured — the issue sits on `mode`, as the `sync` refusal sits on `sync`.
247+
// Unreachable through this mirror, which strips `mode` (see above); kept
248+
// byte-identical to the spec contract's arm.
249+
return [{
250+
path: 'mode',
251+
message:
252+
"`mode: 'replica'` makes this datasource an embedded replica, a local file kept in sync with "
253+
+ 'the remote named in `syncUrl`, but no `syncUrl` is set: nothing would ever sync, so it would '
254+
+ 'run as a plain local database that never replicates — the turso driver refuses this '
255+
+ 'configuration when it starts. For an embedded replica, name the remote in `syncUrl` beside '
256+
+ "the local file: `url: 'file:./data/replica.db'` with `syncUrl` set to the `libsql://` or "
257+
+ "`https://` Turso endpoint. For a plain local database, drop `mode: 'replica'`.",
258+
}];
259+
}
242260
return [];
243261
}
244262

0 commit comments

Comments
 (0)