diff --git a/.changeset/driver-vocabulary-single-table-hosts.md b/.changeset/driver-vocabulary-single-table-hosts.md new file mode 100644 index 0000000000..31116dc2d0 --- /dev/null +++ b/.changeset/driver-vocabulary-single-table-hosts.md @@ -0,0 +1,85 @@ +--- +"@objectstack/cli": major +"@objectstack/runtime": major +--- + +fix(cli,runtime)!: `os start` and `os migrate` finally read the same driver vocabulary (#6345) + +One environment variable had two answers. Measured on `main` by driving the real +entry points — `resolveDriverType` + `resolveStorageDefinition` for the `os start` +side, `resolveStandaloneDatabase` for the `os migrate` side — **10 of 21 +spellings disagreed**: + +``` +OS_DATABASE_DRIVER=pg OS_DATABASE_URL=postgres://… os start → boots +OS_DATABASE_DRIVER=pg OS_DATABASE_URL=postgres://… os migrate plan → refused by name +``` + +`sql`, `wasm`, `wasm-sqlite`, `postgresql`, `pg`, `mysql2`, `mongo`, `mingo`, +`in-memory` and `libsql` were accepted by the CLI and refused by the standalone +stack. Both sides were separately correct and separately pinned; the missing test +was the CROSS-host one, and it now exists +(`packages/cli/src/utils/driver-vocabulary-parity.test.ts` — the only place that +can import both). + +**Both hosts now resolve through `@objectstack/spec`'s one driver table.** The +CLI's hand-written `driverType === 'pg' || driverType === 'postgresql'` chains +and the standalone stack's canonical-only `z.enum` are both gone; a driver added +to the spec table appears on both hosts at once, which is the only shape in which +this fork cannot re-open. The standalone `databaseDriver` CONFIG key accepts the +same aliases as `OS_DATABASE_DRIVER`, so the fork cannot relocate to inside one +host either. + +**BREAKING ① — selecting a driver whose database lives elsewhere, without saying +where, now refuses.** Four kinds have no local default (`postgres`, `mysql`, +`mongodb`, `turso`), and before this change each side guessed, differently: + +| selection, no URL | `os start` before | `os migrate` before | now, both | +| :-- | :-- | :-- | :-- | +| `postgres` | `config.url === undefined` → `pg` connects to ITS localhost:5432 | `file:/data/objectstack.db` | typed refusal | +| `mysql` | `config.url === undefined` | `file:…objectstack.db` | typed refusal | +| `mongodb` | invented `mongodb://localhost:27017/objectstack` | `file:…objectstack.db` | typed refusal | +| `turso` | typed refusal (#5602) | `file:…objectstack.db` | typed refusal | + +Eight cells, seven of them wrong in one of two ways: connect the operator to a +database they never named, or hand a server driver a `file:` DSN and let it fail +two layers from the cause. `turso` already said the right sentence; this +generalizes it rather than leaving one kind honest and three guessing. Only the +FALLBACK rungs are refused — a URL from `--database`, `OS_DATABASE_URL`, +`DATABASE_URL`, `TURSO_DATABASE_URL` or the project's declared default datasource +is a statement about where the database is, and is honoured as before, `file:` +DSN included. + +**BREAKING ② — an explicitly-named unknown driver refuses on the CLI side too.** +`os dev --database-driver sqlite3` used to fall through to the dev SQLite default +and boot in silence, while `os migrate` refused the same value by name (#6344 +killed the silent fallback on that side only). `''` (nobody chose) keeps its old +answer — dev default, `null` in production; a non-empty value can only have come +from an operator, since URL inference yields a canonical id or `''`. The refusal +enumerates the spellings that actually work, from the shared table. + +**Widened, not narrowed:** every spelling either host accepted before is accepted +by both now. `sqlite3` / `better-sqlite3` / `mariadb` / `inmemory` stay out of the +selection face on both — neither host ever accepted them as a boot selection, and +converging two hosts is not a licence to widen the flag. They keep resolving a +config CONTRACT, so a stored `driver: 'sqlite3'` datasource is unaffected. + +**Why `major` on both.** ① and ② each turn a boot that started into a boot that +refuses. A deployment that really did run postgres on localhost with trust auth, +or that relied on `mongodb://localhost:27017/objectstack`, was working by +accident and now gets a message telling it what to set — but it was working, and +calling that a `patch` because the old behaviour was a bug would let the change +arrive unannounced in a changelog. The alias widening on its own would be +`minor`; the refusals are what price this at `major`. + +**Migration.** The stored half of this change is the `mongo` → `mongodb` +canonical-id rename, which both hosts now resolve through the shared table; it is +registered as the ADR-0087 D2 conversion `datasource-driver-mongo-to-mongodb` +and needs no action from anyone — `migrate meta` converges the rows and `mongo` +stays accepted meanwhile. The two refusals have no stored form and no codemod: +they prescribe an operator action (set the database URL, or fix the driver +value) whose correct answer is a fact only the operator has, which is why the +messages name the variable, show the target shape, and say what booting anyway +would have cost. + + diff --git a/.changeset/driver-vocabulary-single-table-service-datasource.md b/.changeset/driver-vocabulary-single-table-service-datasource.md new file mode 100644 index 0000000000..6a8d99fc2a --- /dev/null +++ b/.changeset/driver-vocabulary-single-table-service-datasource.md @@ -0,0 +1,57 @@ +--- +"@objectstack/service-datasource": major +--- + +feat(service-datasource)!: `DRIVER_CATALOG` publishes `mongodb`, and the factory can no longer fall through to `memory` (#6345) + +**BREAKING — `DRIVER_CATALOG`'s MongoDB entry publishes `id: 'mongodb'`.** That +field is documented as "used as `datasource.driver`" and it is literally what the +Studio connection form writes into a datasource row, so this is the face of +#6345's `mongo` → `mongodb` rename that reaches stored data. Rows written before +the rename carry `mongo`; the ADR-0087 D2 conversion +`datasource-driver-mongo-to-mongodb` converges them at every rehydration seam, +and `mongo` remains an accepted alias so a deployment that skipped the migration +still connects. The factory's dispatch arm renames with it (`kind === 'mongodb'`). + +**A `turso` construction arm — which the rename made mandatory, not optional.** +`createDefaultDatasourceDriverFactory().supports()` is +`resolveDriverId(id) !== undefined`, so the moment `turso` gained a config +contract in `@objectstack/spec` this factory began claiming it. Before this arm, +that claim was answered by `create()`'s trailing `memory` fall-through: a libSQL +datasource would have been built as an ephemeral in-process store that accepts +writes, reports success and loses everything — the #3276 silent-wrong-engine +class with a new spelling. The arm is the same shape `mongodb` and `sqlite-wasm` +already use (lazy import, typed not-installed error), because all three ride in +optional packages and being an optional INSTALL has never meant lacking a +contract. + +The CLI and standalone stack still inject their own turso factory for the +`default` datasource (#5602's host-factory seam), and an injected factory +replaces this one — so this arm serves every OTHER door: a runtime datasource +created in Setup, `testConnection`, a declared non-default datasource. Those +doors previously got `supports() === false` and degraded; they now build. + +**The fall-through itself is gone.** `memory` was the last arm's *implicit* +position — no `if`, just the end of the function — so any `BuiltinDriverId` the +switch did not handle silently became an in-memory store. It is now an explicit +`kind === 'memory'` arm followed by an exhaustiveness stop typed `never`: adding +a builtin without an arm is a compile error, and if a stale published +`@objectstack/spec` ever reaches a newer consumer at run time, the result is a +named refusal rather than a different engine. This is the trap the next driver +would have inherited; turso is simply the one that found it. + +**Why `major`.** The published `DRIVER_CATALOG[].id` value changes. Any consumer +that compares a stored `datasource.driver` against the catalog id — a form +pre-selecting the current driver, a grouped list, an equality filter — stops +matching pre-rename rows until the conversion has run. Nothing throws, which is +precisely why this is not a `minor`: the failure is a dropdown that silently +shows no selection, and a bump that lets it arrive unannounced would be the same +class of quiet as the defect the rename fixes. + +**Not renamed, deliberately:** `SqlDialect`'s `'mongo'` member +(`data/type-compat.ts`). That is a different vocabulary — it names the type +system of an EXTERNAL schema being introspected, alongside `snowflake` and +`bigquery`, and is never a `datasource.driver`. Renaming it would have been +sympathetic magic on a matching string. + + diff --git a/.changeset/driver-vocabulary-single-table-spec.md b/.changeset/driver-vocabulary-single-table-spec.md new file mode 100644 index 0000000000..b39b75221f --- /dev/null +++ b/.changeset/driver-vocabulary-single-table-spec.md @@ -0,0 +1,92 @@ +--- +"@objectstack/spec": major +--- + +feat(spec)!: one driver vocabulary — `mongo` → `mongodb`, `turso` gets a config contract (#6345) + +`packages/spec` has owned the driver alias table since #4410, for one reason +stated in its own module comment: two tables would let the id that SELECTS a +driver and the id that selects that driver's CONFIG CONTRACT disagree. That +argument was right and the table was right; it just never reached the two boot +hosts. Measured on `main` before this change, driving the real entry points: + +| | `os start` | `os migrate` | +| :-- | :-- | :-- | +| `OS_DATABASE_DRIVER=pg` | accepted (`postgres`) | **refused by name** | +| `OS_DATABASE_DRIVER=libsql` | accepted (`turso`) | **refused by name** | + +**10 of 21 spellings disagreed.** Three prior cards (#3276, #5820, #6265) each +fixed one spelling on one side, each with a green pin — and every pin drove +exactly one host, which is why the fork survived all three. + +**What this changeset changes in `@objectstack/spec`.** + +The flat `Record` becomes one table with a row per +driver carrying `id`, `aliases`, `contractOnlyAliases` and `hasLocalDefault`. +`BUILTIN_DRIVER_IDS`, `DRIVER_ID_ALIASES` and `resolveDriverId` are projections +of it — `BUILTIN_DRIVER_IDS` keeps its exact tuple type, so the api-surface delta +for this PR is purely additive (10 new exports, nothing removed or renamed). + +Three faces are new, and they are what the two hosts consume: +`resolveDatabaseDriverId()` (the selection face), `driverHasLocalDefault()` (does +this driver have anything to fall back on with no URL) and +`DATABASE_DRIVER_SELECTION_ALIASES` (what a refusal message enumerates). + +**BREAKING — the canonical mongo id is `mongodb`.** `resolveDriverId('mongo')` +now returns `'mongodb'`; `BuiltinDriverId` no longer includes `'mongo'`; +`DRIVER_CONFIG_SCHEMAS` and `MongoDriverSpec.id` follow. The old canon was the +one string on the platform that said `mongo` while both hosts, the npm package +(`@objectstack/driver-mongodb`) and every URL scheme said `mongodb`, and the +maintainer's ruling renames it rather than adding a mapping layer, so that +selection canon and contract canon are one string. + +`mongo` **stays an accepted alias**, deliberately: nothing that authored it +breaks, and a deployment that never replays the conversion still resolves the +same contract and builds the same driver. What needs migrating is the STORED +value, because the canonical id is published as `DRIVER_CATALOG.id` — what Studio +writes into `datasource.driver` — so after the rename the form emits `mongodb` +while older rows carry `mongo`, and a reader matching stored rows against the +catalog id silently misses them. The ADR-0087 D2 conversion +`datasource-driver-mongo-to-mongodb` converges them at every rehydration seam. + +**`turso`/libSQL becomes a complete builtin.** It was the mirror image of the +mongo problem: both hosts dispatched it while spec shipped no contract, so +`validateDriverConfig('turso', …)` answered `{ known: false }` and a libSQL +`config` was the one connection block on the platform with no gate — `{ token }` +(the wrong key; it is `authToken`) was accepted in silence and the connection +attempted unauthenticated. `TursoConfigSchema` closes that. The keys are drawn +from what `TursoDriverConfig` actually READS, not from what libSQL supports, so +the fix does not open a new inert slot: `client` (a live object, unauthorable), +`pool` and `schemaMode`/`readOnly` (datasource-level) are deliberately absent. + +**Consumers of the `{ known: false }` answer, and what the flip does to each** — +established before making it, since a consumer depending on the negative answer +would have been a stop condition: + +1. `DatasourceSchema`'s `reportDriverConfigIssues` — was a no-op for turso, now + parses. An authored turso `config` gains a real verdict. +2. `service-datasource`'s `assertValidConfig` (the Setup wizard's door) — same + flip, same reason. +3. `DRIVER_CATALOG` — turso is deliberately NOT curated into the connection form, + the same call `sqlite-wasm` has carried since #4410. No visible change. +4. `driverReadsDeclaredPool` — answers `true` for turso before AND after (via the + unknown-id branch before, the not-rejected branch now). Verdict unchanged. + +**`sql` and `wasm` join the selection face; `sqlite3`, `better-sqlite3`, +`mariadb` and `inmemory` do not.** The ruling fixes the selection face as the +union of what the two hosts accepted, and those four were accepted by neither — +so they stay `contractOnlyAliases`: they keep resolving a config contract +(dropping that would silently un-validate a stored `driver: 'sqlite3'` row) while +`resolveDatabaseDriverId` refuses them, because converging two hosts is not a +licence to widen a boot flag on no ruling. That distinction is the thing the flat +`Record` could not express and is why the table has two alias columns. + +**Why `major` and not `minor`.** The alias widening alone would be `minor` — it +only accepts more. The rename is what forces `major`: `BuiltinDriverId` loses a +member, so every TypeScript consumer that switches on it or types a variable as +it fails to compile, and `DRIVER_CONFIG_SCHEMAS['mongo']` is gone. That is a +compile-time break even though the runtime behaviour is compatible, and pricing +it as `minor` because "nothing breaks at run time" would be exactly the +half-truth a consumer discovers at build time. + + diff --git a/content/docs/references/data/driver-turso.mdx b/content/docs/references/data/driver-turso.mdx new file mode 100644 index 0000000000..9e793a480a --- /dev/null +++ b/content/docs/references/data/driver-turso.mdx @@ -0,0 +1,92 @@ +--- +title: Driver Turso +description: Driver Turso protocol schemas +--- + +{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} + +Turso / libSQL Driver Protocol (#6345). + +## Why this arrives late, and what it closes + +`turso` was the one connection block on the platform with NO gate. #4410 gave +every built-in driver's `datasource.config` a contract and made +`DatasourceSchema` parse against it, but turso was not a builtin: its driver +ships in an OPTIONAL package (`@objectstack/driver-turso`, #5602), so +`resolveDriverId('turso')` returned `undefined` and `validateDriverConfig` +answered `{ known: false }` — "nothing to check against". Meanwhile both boot +hosts dispatched `turso` for real. So a libSQL datasource could carry +`{ token: … }` (the wrong key — it is `authToken`) and be accepted in silence, +then connect unauthenticated, which is precisely the failure #4410 exists to +end, surviving in the one driver #4410 could not see. + +The maintainer's #6345 ruling closes it by making turso a complete builtin +rather than a permanent exception. Optionality of the PACKAGE is orthogonal to +existence of the CONTRACT — `mongodb` and `sqlite-wasm` are optional installs +too, and both have had a contract since #4410. + +## What is declared here, and what is deliberately not + +The keys below are exactly the `TursoDriverConfig` fields the driver reads and +that an author can express as data. Three are deliberately absent: + + - `client` (a pre-constructed `@libsql/client` instance) — a live object, not + authorable metadata; declaring it would promise a JSON slot that can never + be filled from a `sys_metadata` row. + - `pool` — connection pooling is the datasource's own block, not driver + config, exactly as on postgres/mysql/mongo. + - `schemaMode` / `readOnly` — datasource-level, same as every other driver. + +ADR-0049 (enforce-or-remove) is why the list is drawn from what the driver +READS rather than from what libSQL supports: a key declared here that no +driver consults would be a new inert slot, and this file exists to close one. + + +**Source:** `packages/spec/src/data/driver/turso.zod.ts` + + +## TypeScript Usage + +```typescript +import { TursoConfigSchema, TursoTransportModeSchema } from '@objectstack/spec/data'; +import type { TursoConfig, TursoTransportMode } from '@objectstack/spec/data'; + +// Validate data +const result = TursoConfigSchema.parse(data); +``` + +--- + +## TursoConfig + +Turso / libSQL Connection Configuration + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **url** | `string` | ✅ | libSQL endpoint or local file: a remote libsql/https Turso URL, a file path, or :memory: | +| **authToken** | `string` | optional | JWT auth token for a remote libSQL database (prefer external.credentialsRef) | +| **encryptionKey** | `string` | optional | AES-256 encryption key for the local database file (local/replica modes) | +| **concurrency** | `integer` | optional | Maximum concurrent requests to the remote database | +| **syncUrl** | `string` | optional | Remote sync URL for embedded-replica mode: a libsql or https Turso endpoint | +| **sync** | `{ intervalSeconds?: integer; onConnect?: boolean }` | optional | Embedded-replica sync configuration (requires `syncUrl`) | +| **timeout** | `integer` | optional | Operation timeout in milliseconds for remote operations | +| **mode** | `Enum<'local' \| 'replica' \| 'remote'>` | optional | Force a transport mode instead of inferring it from `url` | + + +--- + +## TursoTransportMode + +Force a transport mode instead of inferring it from `url` + +### Allowed Values + +* `local` +* `replica` +* `remote` + + +--- + diff --git a/content/docs/references/data/index.mdx b/content/docs/references/data/index.mdx index ce71a76e1c..f98ecea006 100644 --- a/content/docs/references/data/index.mdx +++ b/content/docs/references/data/index.mdx @@ -21,6 +21,7 @@ This section contains all protocol schemas for the data layer of ObjectStack. + diff --git a/content/docs/references/data/meta.json b/content/docs/references/data/meta.json index d53d6530bc..ab512bcd57 100644 --- a/content/docs/references/data/meta.json +++ b/content/docs/references/data/meta.json @@ -34,6 +34,7 @@ "driver-mysql", "driver-postgres", "driver-sqlite", + "driver-turso", "field-value" ] } \ No newline at end of file diff --git a/content/docs/references/index.mdx b/content/docs/references/index.mdx index 5faafb095f..471515fdfc 100644 --- a/content/docs/references/index.mdx +++ b/content/docs/references/index.mdx @@ -1,6 +1,6 @@ --- title: Protocol Reference -description: Every schema published by @objectstack/spec — 1584 schemas across 14 protocol modules +description: Every schema published by @objectstack/spec — 1586 schemas across 14 protocol modules --- {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} @@ -23,7 +23,7 @@ counts are sums of the rows they head. Regenerate with | [API Protocol](/docs/references/api) | 28 | 410 | REST/GraphQL contracts, endpoints, routing, realtime, batch, discovery. | | [Automation Protocol](/docs/references/automation) | 13 | 68 | Flows and their nodes, approvals, ETL pipelines, webhooks, state machines, execution records. | | [Cloud Protocol](/docs/references/cloud) | 11 | 94 | Environments, packages and versions, marketplace, developer portal, tenancy. | -| [Data Protocol](/docs/references/data) | 29 | 164 | Objects, fields, queries, filters, datasources and drivers — the ObjectQL layer. | +| [Data Protocol](/docs/references/data) | 30 | 166 | Objects, fields, queries, filters, datasources and drivers — the ObjectQL layer. | | [Identity Protocol](/docs/references/identity) | 5 | 28 | Users and accounts, organizations, positions, API keys, SCIM provisioning. | | [Integration Protocol](/docs/references/integration) | 1 | 27 | The single connector protocol (ADR-0097) — catalog descriptors and provider-bound instances. | | [Kernel Protocol](/docs/references/kernel) | 31 | 187 | Plugin lifecycle and manifests, capabilities and security, metadata loading, service registry. | @@ -33,7 +33,7 @@ counts are sums of the rows they head. Regenerate with | [Studio Protocol](/docs/references/studio) | 3 | 35 | Studio designer metadata — the authoring surfaces for the protocols above. | | [System Protocol](/docs/references/system) | 37 | 292 | The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. | | [UI Protocol](/docs/references/ui) | 16 | 147 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. | -| **Total** | **199** | **1584** | 14 protocol modules | +| **Total** | **200** | **1586** | 14 protocol modules | --- @@ -146,7 +146,7 @@ Environments, packages and versions, marketplace, developer portal, tenancy. ## Data Protocol -**Source:** `packages/spec/src/data/` · **Import:** `@objectstack/spec/data` · **29 pages, 164 schemas** +**Source:** `packages/spec/src/data/` · **Import:** `@objectstack/spec/data` · **30 pages, 166 schemas** Objects, fields, queries, filters, datasources and drivers — the ObjectQL layer. @@ -167,6 +167,7 @@ Objects, fields, queries, filters, datasources and drivers — the ObjectQL laye | [`driver/postgres.zod.ts`](/docs/references/data/driver-postgres) | `PostgresConfig` | | [`driver-sql.zod.ts`](/docs/references/data/driver-sql) | `DataTypeMapping`, `SQLDialect`, `SQLDriverConfig`, `SSLConfig` | | [`driver/sqlite.zod.ts`](/docs/references/data/driver-sqlite) | `SqliteConfig`, `SqliteWasmConfig`, `SqliteWasmPersistMode` | +| [`driver/turso.zod.ts`](/docs/references/data/driver-turso) | `TursoConfig`, `TursoTransportMode` | | [`external-catalog.zod.ts`](/docs/references/data/external-catalog) | `ExternalCatalog`, `ExternalColumn`, `ExternalTable` | | [`external-lookup.zod.ts`](/docs/references/data/external-lookup) | `ExternalDataSource`, `ExternalFieldMapping`, `ExternalLookup` | | [`feed.zod.ts`](/docs/references/data/feed) | `FeedFilterMode`, `FeedItemType` | diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md index 1c385ae174..c6ed9f6704 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md @@ -21,7 +21,7 @@ regenerate. | Measure | Value | |---|---| | Triaged directories | 5 | -| Object sites in them | 434 | +| Object sites in them | 436 | | Still-open (strip) sites | 180 | | Files carrying at least one | 27 | @@ -45,11 +45,11 @@ The `strict` column is the one the campaign schedules against; it counts both th | Dir | Sites | strict | passthrough | catchall | strip | |---|---|---|---|---|---| | `ui/` | 160 | 118 | 5 | 0 | 37 | -| `data/` | 162 | 54 | 1 | 0 | 107 | +| `data/` | 164 | 56 | 1 | 0 | 107 | | `automation/` | 65 | 42 | 0 | 0 | 23 | | `security/` | 20 | 7 | 0 | 0 | 13 | | `studio/` | 27 | 27 | 0 | 0 | 0 | -| **total** | **434** | **248** | **6** | **0** | **180** | +| **total** | **436** | **250** | **6** | **0** | **180** | ## File-level triage — site counts @@ -95,6 +95,7 @@ classify and is not listed (it becomes reportable the day it grows its first sit | `driver/mysql.zod.ts` | 1 | | `driver/postgres.zod.ts` | 1 | | `driver/sqlite.zod.ts` | 2 | +| `driver/turso.zod.ts` | 2 | | `external-catalog.zod.ts` | 4 | | `external-lookup.zod.ts` | 12 | | `field-value.zod.ts` | 2 | @@ -108,7 +109,7 @@ classify and is not listed (it becomes reportable the day it grows its first sit | `seed-loader.zod.ts` | 12 | | `seed.zod.ts` | 1 | | `validation.zod.ts` | 6 | -| **total** | **162** | +| **total** | **164** | ### `automation/` — sites @@ -178,7 +179,7 @@ over it is here. ### `data/` — open -**107 strip of 162**, in 16 file(s). +**107 strip of 164**, in 16 file(s). | File | Strip | Sites | |---|---|---| @@ -198,7 +199,7 @@ over it is here. | `object.zod.ts` | 1 | 20 | | `query.zod.ts` | 4 | 5 | | `seed-loader.zod.ts` | 12 | 12 | -| **total** | **107** | **162** | +| **total** | **107** | **164** | | Bucket | Sites | |---|---| diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.md b/docs/audits/2026-07-unknown-key-strictness-ledger.md index 00925a0f33..36b3b34f48 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.md @@ -697,6 +697,7 @@ column does not move and the `strip` column falls by the count of what left. | `driver-nosql.zod.ts` / `driver.zod.ts` / `driver-sql.zod.ts` | wire | driver capability contracts | | `datasource.zod.ts` | authorable | **strict as of #4001 data step** — all 6: `DatasourceSchema` (+ `pool` / `ssl`), `ExternalDatasourceSettingsSchema` (+ `validation`), `DriverDefinitionSchema`. **#4583 B/C dropped two more sites**: the `healthCheck` and `retryPolicy` blocks are gone — nothing scheduled a probe and nothing retried, so their strictness was validating a shape no code consumed. `config` stays `z.record` **at this level** by construction (per-driver shapes), but is no longer unchecked: **#4410** made `DatasourceSchema`'s refinement parse it against the contract for the declared driver (`driver/config-registry.zod.ts`), so the openness here is a shape this level cannot express rather than the absence of one. This row used to add "the driver's own `configSchema` validates them", which was false until #4410 landed the parse site it names. #4410 extended the same parse to each `readReplicas` entry; **#4468 retired that key** — no driver ever opened a replica connection and no query path splits reads from writes, so the entries were being checked against a contract nothing would apply. Strictness makes a dropped key loud; it cannot make a slot live, and a *precisely validated* dead slot is the more convincing lie | **#4583 dropped the ninth site**: `DatasourceCapabilities` is gone — eleven flags no code read, on a block whose strictness was the clearest case of this row's own closing sentence. `readOnly` in particular was *precisely validated* and completely inert, and had been relocated twice (#4410, #4465) toward somewhere it might be enforced; the shipped CRM example called a datasource a read replica on the strength of it while writes went through. Class unchanged | `driver/memory.zod.ts` / `driver/mongo.zod.ts` / `driver/postgres.zod.ts` | authorable | The per-driver shapes for the `config` slot — what an author actually writes under `datasource.config` (`host`, `port`, `filename`). **Undeclared here until the coverage walk went recursive** (see below): a subdirectory was invisible to the gate, so these sites sat outside the map while the map reported full coverage. **Strict as of #4410**, which is also what unblocked them: this row previously read "strictness here would enforce nothing" because nothing parsed `datasource.config` against these schemas and both `*DriverSpec.configSchema` literals were `{}`. Now `DatasourceSchema` parses `config` against them, and the same schemas project onto `configSchema` and onto the Studio connection form. (#4410 also ran the parse over each `readReplicas` entry; #4468 retired that key outright — see the row above.) `postgres.zod.ts` drops a site: its `ssl` was a `boolean | {ca, cert, key, …}` union, and the object arm is gone — certificates now live in the datasource-level `ssl` block (declared, strict, and until #4410 read by nobody), leaving `config.ssl` as the on/off shorthand. That narrowing is forced by the same projection: the Studio form renders anything that is not boolean/enum/number as a TEXT INPUT, so a union here would have produced a wizard whose every `ssl` value the new gate rejects. `memory.zod.ts` keeps 6 but loses two KEYS — `indexes` / `maxRecordsPerObject`, which `InMemoryDriverConfig` has no field for, removed under ADR-0049 rather than blessed by the new gate | +| `driver/turso.zod.ts` | authorable | The libSQL/Turso `config` contract, added by **#6345** — and the last driver on the platform whose `config` had no gate at all. It was not an oversight of #4410 but a consequence of turso not being a BUILTIN: its driver ships in the optional `@objectstack/driver-turso` package, so `resolveDriverId('turso')` returned `undefined` and `validateDriverConfig` answered `{ known: false }` — "nothing to check against" — while both boot hosts dispatched `turso` for real. A datasource carrying `{ token: … }` (the plausible spelling; the driver reads `authToken`) was therefore accepted in silence and then connected UNAUTHENTICATED, which is #4410's own failure mode surviving in the one driver #4410 could not see. Every site strict, same error factory as the rest of the campaign, including the nested `sync` block — a bare `z.object` there would have dropped `sync: { interval: 60 }` and synced on the default while the author believed otherwise, i.e. added a strip site to this map instead of closing one. The declared keys are drawn from what `TursoDriverConfig` actually READS, not from what libSQL supports, so closing this gap does not open an ADR-0049 one: `client` (a live `@libsql/client` instance — not authorable metadata), `pool` and `schemaMode`/`readOnly` (datasource-level, like every other driver) are deliberately absent | | `driver/mysql.zod.ts` / `driver/sqlite.zod.ts` | authorable | The rest of the `config` contract, added by #4410. `mysql.zod.ts` and `sqlite.zod.ts` (sqlite + sqlite-wasm) are shapes that **never existed** — both driver ids were offered by the connection form and buildable by the shared factory, with no config contract anywhere, so `driver: 'sqlite'` + a misspelled `filename` was an ephemeral `:memory:` database reported as configured. All three sites strict, same error factory as the rest of the campaign. (Their sibling `driver/common.zod.ts` holds shared enums and prescription strings and has no `z.object(` site, so the coverage gate skips it) | | `analytics.zod.ts` | mixed (p) | | | `document.zod.ts` | wire (p) | | diff --git a/docs/protocol-upgrade-guide.md b/docs/protocol-upgrade-guide.md index e8801744eb..d08a140fed 100644 --- a/docs/protocol-upgrade-guide.md +++ b/docs/protocol-upgrade-guide.md @@ -162,6 +162,8 @@ Closing the same audit on the data side, `datasource.readReplicas` is removed (# The datasource close-out also graduates the four legacy `datasource.config` spellings the shared driver factory still tolerated via undeclared read-side `??` fallbacks (#4456, the #4410 follow-up): sqlite `file`/`database` (use `filename`), postgres/mysql `connectionString` (use `url`) and `user` (use `username`), and mongo `uri` (use `url`) and `user` (use `username`). #4410 made the authoring gate reject each with a rename hint, but a runtime datasource persisted in `sys_metadata` before the gate kept working only because the factory read leniently — and deleting that tolerance without a conversion would have silently moved data (a stored sqlite `file:` row falls back to `:memory:`). The `datasource-config-driver-key-aliases` conversion rewrites the stored shape to the canonical keys at every rehydration seam, the factory now reads exactly one spelling per key, and the four `??` chains are deleted. Driver-aware by construction: `database` renames only under sqlite, where it aliased the file path — for every other driver it is a canonical key and is untouched. Retired from the load path not for lying but because the authoring gate already rejects the spellings loudly; the chain and the stored-row replay are the seams that accept them. +Finishing the same datasource surface, the canonical driver id `mongo` is renamed to `mongodb` (#6345). The two spellings have both been accepted since #4410 and both still are, so no boot breaks and no data moves — what changed is which one is CANONICAL, and that string is published as `DRIVER_CATALOG.id` and is what the Studio connection form writes into `datasource.driver`. Every row written before the rename therefore carries `mongo` while the form now emits `mongodb`, leaving one deployment with two spellings of one driver and any reader that matches a stored driver against the published catalog id silently missing the older rows. The `datasource-driver-mongo-to-mongodb` conversion converges the stored value at every rehydration seam; it stays on the LIVE load path (unlike the config-key aliases beside it) precisely because `mongo` is still legal — there is no loud rejection for it to pre-empt, and nothing to lose by converging early. The rename is what let the driver-selection id and the config-contract id become one string: `packages/spec`'s driver vocabulary is now a single table both boot hosts read, which closed the last fork where `OS_DATABASE_DRIVER=pg` booted under `os start` and was refused by `os migrate`. `turso`/libSQL joins the same table with a real config contract, so a libSQL `config` is validated instead of waved through. + The `script` flow node converges on its one real path (#4343). It had four ways to name what it ran and only one of them ran anything: `config.actionType: 'email' | 'slack'` were logger-backed stubs that wrote a line, reported success and delivered nothing under any configuration — with `config.template` / `.recipients` / `.variables` feeding a message no channel ever sent; inline `config.script` was recognized and never executed (the built-in runtime has no server-side JS sandbox), so the node warned and no-op'd; and every other `actionType` value was shorthand for a registered-function name, a second spelling of `config.function`. All five keys are retired and `function` becomes required, which is also what finally made the contract PARSEABLE: while the legal key set depended on `actionType`, a flat parse would either reject valid shapes or wave everything through, so `script` (with `subflow`) now runs through the same execute-time contract parse #4277 gave the flat builtins. A shorthand `actionType` CONVERTS into `function` — that is what it meant — unless `function` is already set, in which case it was dead metadata the executor never reached. The other four are dropped outright: nothing read them, so there is no value to preserve, and rebuilding the intent is an authoring decision the tombstones prescribe per branch (a `notify` node for mail — it delivers through the messaging service, the in-app inbox by default and real email once `@objectstack/plugin-email` is installed; a `connector_action` with the Slack connector, or an `http` node posting to a webhook, for Slack; a registered function for an inline body). Retired from the load path for the same reason as the rest: absorbing `actionType: 'email'` silently would let an author keep believing the flow sends mail. The same audit reaches the driver contract itself: `IDataDriver.findStream` is removed (#4484). It was REQUIRED — every driver and every test double had to implement it — and documented as the read "optimized for large datasets to avoid memory overflow", while two of its three implementations awaited `find()` for the whole result set and then yielded it row by row, reaching exactly the peak it promised to avoid; the third streamed for real but was the one read in that driver that skipped `buildFindOptions`, so it dropped `query.fields`. Nothing anywhere called it, which is why a contract method could carry an inverted guarantee for this long and why ~20 test doubles could satisfy it by throwing `not implemented`. Paged `find()` is the read that exists and is enforced (its total-order guarantee is checked by the shared pagination-conformance cases); a cursor-based read is worth building when a caller asks for one, which is the honest order. A TS/API surface, never stored — one semantic TODO for driver authors, no source rewrite, and no tombstone: `DriverInterfaceSchema` describes a contract that code IMPLEMENTS and nothing ever `.parse()`d a driver, so tsc is the only channel that could carry the prescription, and it carries it where it matters — at a call site. @@ -265,6 +267,7 @@ The same descriptor loses a key in this step, and the pairing is the point (#674 | `job-id-removed` | `job.id` | job key 'id' removed (#4667 — nothing read it; `name` is the job's identity everywhere, so two jobs differing only in `id` were the same job, and the key's own description advertised an override that did not exist) | retired — `migrate meta` only | | `translation-validation-messages-removed` | `translation.validationMessages` | translation key 'validationMessages' removed (#4667 — no resolver read it, so a translated rule message was stored and never shown; #3778's migration table had been steering retired `errors:` authors into it). Author the message on the rule itself (`object.validations[].message`) | retired — `migrate meta` only | | `datasource-config-driver-key-aliases` | `datasource.config` | datasource config keys → canonical per driver: sqlite 'file'/'database' → 'filename', postgres/mysql 'connectionString' → 'url' and 'user' → 'username', mongo 'uri' → 'url' and 'user' → 'username' (#4456 — driver-factory `??` fallback graduation) | retired — `migrate meta` only | +| `datasource-driver-mongo-to-mongodb` | `datasource.driver` | datasource driver id 'mongo' → 'mongodb' — the canonical id both boot hosts, the driver package and the published DRIVER_CATALOG already used (#6345) | live — protocol 17 loader accepts the old shape | | `flow-node-script-branch-keys-removed` | `flow.node.script.config.actionType / flow.node.script.config.template / flow.node.script.config.recipients / flow.node.script.config.variables / flow.node.script.config.script` | script flow-node config keys 'actionType' (→ 'function' when it was shorthand for one; otherwise removed — 'email'/'slack' were logger-backed stubs that delivered nothing), plus 'template' / 'recipients' / 'variables' (fed those stubs) and 'script' (inline JS the runtime never executed) (#4343) | retired — `migrate meta` only | | `retry-policy-converged` | `flow.errorHandling.retryDelayMs / flow.node.config.retry.retryDelayMs / job.retryPolicy.maxRetries / job.retryPolicy.backoffMultiplier` | retry policy unified across job.retryPolicy, try_catch retry and flow.errorHandling: base delay 'retryDelayMs' → 'backoffMs', and the pre-17 job defaults (maxRetries 3, backoffMultiplier 2) written out explicitly now that the merged default is 0 / 1 (#4661, #4964) | live — protocol 17 loader accepts the old shape | | `object-managed-by-system-to-system-data` | `object.managedBy` | object managedBy 'system' → 'system-data' (#3355 — ADR-0103's residual bucket named the engine-owned half v16 had already moved out to `engine-owned`; the rename leaves the name describing what the bucket actually holds: admin/user-writable platform data) | retired — `migrate meta` only | diff --git a/packages/cli/src/commands/database-driver-allowlist.pin.test.ts b/packages/cli/src/commands/database-driver-allowlist.pin.test.ts index 8637c5ab05..a09471ada8 100644 --- a/packages/cli/src/commands/database-driver-allowlist.pin.test.ts +++ b/packages/cli/src/commands/database-driver-allowlist.pin.test.ts @@ -100,6 +100,17 @@ function candidateTokens(): string[] { * today that is `turso` with no URL, which throws `UnsupportedDriverError`. A URL * is supplied so it resolves normally; the catch is kept so the derivation * survives another kind growing the same "recognized but unusable" shape. + * + * `err.recognized` is what keeps that catch honest (#6345). The resolver now + * ALSO throws `UnsupportedDriverError` for a spelling nothing claims — the CLI + * half of "both hosts refuse the same input", which replaced a silent fall-through + * to the dev SQLite default. Reading `driverType` off that error would report the + * operator's raw token as a driver kind, and since the candidate net below is a + * deliberately over-broad scan of every lowercase literal in `storage-driver.ts`, + * the derived set would have grown `safe`, `on-disconnect`, `factory`, `string` + * and the rest — a set that no allowlist could ever equal. The distinction is + * carried on the error rather than re-derived here, so this file keeps asking the + * RESOLVER what a token means instead of growing its own opinion. */ function canonicalDriverIdOf(token: string): string | null { try { @@ -109,7 +120,7 @@ function canonicalDriverIdOf(token: string): string | null { }); return resolution?.driverId ?? null; } catch (err) { - if (err instanceof UnsupportedDriverError) return err.driverType; + if (err instanceof UnsupportedDriverError) return err.recognized ? err.driverType : null; throw err; } } diff --git a/packages/cli/src/utils/driver-vocabulary-parity.test.ts b/packages/cli/src/utils/driver-vocabulary-parity.test.ts new file mode 100644 index 0000000000..013872dcc4 --- /dev/null +++ b/packages/cli/src/utils/driver-vocabulary-parity.test.ts @@ -0,0 +1,262 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * THE pin #6345 exists for: both boot hosts answer the SAME question about the + * SAME `OS_DATABASE_DRIVER` value the same way. + * + * ## Why this file, and why here + * + * The fork survived three separate cards (#3276, #5820, #6265) that each fixed + * one spelling on one side. Every one of them was pinned — by a test that drove + * exactly one host. `packages/cli/src/utils/storage-driver.test.ts` proved the + * CLI accepted `pg`; `packages/runtime/src/standalone-stack*.test.ts` proved the + * standalone stack refused an unknown value loudly. Both were green, both were + * right, and together they described a platform where `OS_DATABASE_DRIVER=pg` + * booted under `os start` and was refused by `os migrate`. Measured on `main` at + * the start of this card: **10 of 21 spellings disagreed**. + * + * No amount of per-host testing finds that. The missing assertion is the + * CROSS-host one, and it can only live in a package that can import both — which + * `@objectstack/cli` is (it depends on `@objectstack/runtime` and + * `@objectstack/spec`), and neither of the other two is. + * + * ## What it drives + * + * The real entry points, not the table: + * - `os start` side → `resolveDriverType` + `resolveStorageDefinition` + * (`commands/serve.ts` calls exactly this pair); + * - `os migrate` side → `resolveStandaloneDatabase` (the pre-boot resolution + * `os migrate plan` and every `createStandaloneStack` embedder run). + * + * Driving the shared spec table instead would pin that the table equals itself. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { + BUILTIN_DRIVER_IDS, + DATABASE_DRIVER_SELECTION_ALIASES, + driverHasLocalDefault, + resolveDatabaseDriverId, + resolveDriverId, +} from '@objectstack/spec/data'; +import { resolveStandaloneDatabase } from '@objectstack/runtime'; +import { resolveDriverType, resolveStorageDefinition, UnsupportedDriverError } from './storage-driver.js'; + +/** A URL whose scheme matches each canonical kind, so only the SPELLING varies. */ +const URL_FOR: Readonly> = { + memory: 'memory://', + sqlite: 'file:/tmp/os6345-parity.db', + 'sqlite-wasm': 'file:/tmp/os6345-parity.db', + postgres: 'postgres://u:p@localhost:5432/db', + mysql: 'mysql://u:p@localhost:3306/db', + mongodb: 'mongodb://localhost:27017/db', + turso: 'libsql://my-db.turso.io', +}; + +/** Spellings NEITHER host accepted before #6345, and which must stay refused. */ +const CONTRACT_ONLY_SPELLINGS = ['sqlite3', 'better-sqlite3', 'mariadb', 'inmemory'] as const; + +type Verdict = { accepted: true; driverId: string } | { accepted: false }; + +/** + * The `os start` verdict for one spelling — driving serve.ts's own two calls. + * + * `isDev` is a PARAMETER, and the refusal cases below run both values, because + * the two modes did not answer alike: in production an unrecognised selection + * returned `null` (refused), but in DEV it fell through to the trailing SQLite + * default and booted in silence. A parity test that only ran `isDev: false` + * would have been green with the CLI's half of fork 1 reverted — measured, in + * this PR's own reverse verification, which is why the parameter is here. + */ +function cliVerdict(spelling: string, databaseUrl: string | undefined, isDev = false): Verdict { + try { + const kind = resolveDriverType(spelling, databaseUrl); + const definition = resolveStorageDefinition(kind, { databaseUrl, isDev }); + return definition ? { accepted: true, driverId: definition.driverId } : { accepted: false }; + } catch { + return { accepted: false }; + } +} + +/** The `os migrate` verdict for one spelling — driving the pre-boot resolution. */ +function standaloneVerdict(spelling: string, databaseUrl: string | undefined): Verdict { + process.env.OS_DATABASE_DRIVER = spelling; + if (databaseUrl) process.env.OS_DATABASE_URL = databaseUrl; + else delete process.env.OS_DATABASE_URL; + try { + const resolved = resolveStandaloneDatabase({ artifactPath: '/nonexistent/objectstack.json' }); + return { accepted: true, driverId: resolved.driver }; + } catch { + return { accepted: false }; + } +} + +describe('driver vocabulary parity: `os start` and `os migrate` answer alike (#6345)', () => { + const saved: Record = {}; + const ENV_KEYS = [ + 'OS_DATABASE_DRIVER', 'OS_DATABASE_URL', 'DATABASE_URL', 'TURSO_DATABASE_URL', 'OS_HOME', + ]; + + beforeEach(() => { + for (const key of ENV_KEYS) saved[key] = process.env[key]; + for (const key of ENV_KEYS) delete process.env[key]; + // Pin the state dir so the unified-default rung resolves under a scratch + // directory rather than the machine's real `~/.objectstack`. + process.env.OS_HOME = mkdtempSync(join(tmpdir(), 'os6345-parity-')); + }); + + afterEach(() => { + for (const key of ENV_KEYS) { + if (saved[key] === undefined) delete process.env[key]; + else process.env[key] = saved[key]!; + } + }); + + // The core assertion. Table-driven over EVERY selection spelling the shared + // vocabulary publishes, so a spelling added to one host and not the other + // cannot pass — which is precisely how the fork was able to widen unnoticed. + it.each([...DATABASE_DRIVER_SELECTION_ALIASES])( + 'both hosts accept `%s` and resolve it to the same canonical driver id', + (spelling) => { + const canonical = resolveDatabaseDriverId(spelling)!; + expect(canonical, `${spelling} must resolve`).toBeDefined(); + const url = URL_FOR[canonical]; + + const cli = cliVerdict(spelling, url); + const standalone = standaloneVerdict(spelling, url); + + expect(cli, `os start refused '${spelling}'`).toEqual({ accepted: true, driverId: canonical }); + expect(standalone, `os migrate refused '${spelling}'`).toEqual({ accepted: true, driverId: canonical }); + }, + ); + + // The other half of "the same answer": a spelling one host refuses, the other + // must refuse too. Before #6345 the CLI silently booted SQLite in dev for + // these while `os migrate` named them in a refusal. + it.each([...CONTRACT_ONLY_SPELLINGS, 'nonsense', 'com.vendor.snowflake'])( + 'both hosts REFUSE `%s`, in dev AND in prod', + (spelling) => { + for (const isDev of [false, true]) { + expect( + cliVerdict(spelling, undefined, isDev).accepted, + `os start (isDev=${isDev}) accepted '${spelling}'`, + ).toBe(false); + } + expect(standaloneVerdict(spelling, undefined).accepted, `os migrate accepted '${spelling}'`).toBe(false); + }, + ); + + // The card's own reproduction, kept verbatim as a named case: it is the line a + // reader of #6345 will look for, and a table row does not read as one. + it('the card repro: OS_DATABASE_DRIVER=pg is accepted by BOTH (was: start yes, migrate no)', () => { + const url = 'postgres://u:p@localhost:5432/db'; + expect(cliVerdict('pg', url)).toEqual({ accepted: true, driverId: 'postgres' }); + expect(standaloneVerdict('pg', url)).toEqual({ accepted: true, driverId: 'postgres' }); + }); + + // The contract-only aliases must keep resolving a CONFIG CONTRACT even though + // they are not selectable — the distinction the single flat `Record` could not + // express. Dropping them would silently un-validate a stored + // `driver: 'sqlite3'` datasource's config. + it.each([ + ['sqlite3', 'sqlite'], + ['better-sqlite3', 'sqlite'], + ['mariadb', 'mysql'], + ['inmemory', 'memory'], + ])('`%s` still resolves the %s config contract while not being selectable', (alias, canonical) => { + expect(resolveDriverId(alias)).toBe(canonical); + expect(resolveDatabaseDriverId(alias)).toBeUndefined(); + }); + + it('`mongo` and `mongodb` both select the renamed canonical id on both hosts', () => { + const url = URL_FOR.mongodb!; + for (const spelling of ['mongo', 'mongodb']) { + expect(cliVerdict(spelling, url)).toEqual({ accepted: true, driverId: 'mongodb' }); + expect(standaloneVerdict(spelling, url)).toEqual({ accepted: true, driverId: 'mongodb' }); + } + expect(resolveDriverId('mongo')).toBe('mongodb'); + }); +}); + +describe('fork 2: no local default + no URL is refused on BOTH sides — all 8 cells (#6345)', () => { + const saved: Record = {}; + const ENV_KEYS = [ + 'OS_DATABASE_DRIVER', 'OS_DATABASE_URL', 'DATABASE_URL', 'TURSO_DATABASE_URL', 'OS_HOME', + ]; + + beforeEach(() => { + for (const key of ENV_KEYS) saved[key] = process.env[key]; + for (const key of ENV_KEYS) delete process.env[key]; + process.env.OS_HOME = mkdtempSync(join(tmpdir(), 'os6345-fork2-')); + }); + + afterEach(() => { + for (const key of ENV_KEYS) { + if (saved[key] === undefined) delete process.env[key]; + else process.env[key] = saved[key]!; + } + }); + + const NO_LOCAL_DEFAULT = ['postgres', 'mysql', 'mongodb', 'turso'] as const; + + // The four kinds are derived, not listed twice: if the spec table ever marks a + // fifth driver `hasLocalDefault: false`, this assertion fails until the matrix + // below covers it, so the "8 cells" stay 8 only while 8 is the truth. + it('the no-local-default set is exactly what the shared table says', () => { + const fromTable = BUILTIN_DRIVER_IDS.filter((id) => !driverHasLocalDefault(id)); + expect([...fromTable].sort()).toEqual([...NO_LOCAL_DEFAULT].sort()); + }); + + it.each(NO_LOCAL_DEFAULT)( + 'cell A — `os start` refuses `%s` with no URL instead of guessing one', + (kind) => { + expect(() => resolveStorageDefinition(kind, { isDev: false })).toThrow(UnsupportedDriverError); + // Dev is not an escape hatch: the pre-#6345 dev path was the one that + // silently produced a definition. + expect(() => resolveStorageDefinition(kind, { isDev: true })).toThrow(UnsupportedDriverError); + }, + ); + + it.each(NO_LOCAL_DEFAULT)( + 'cell B — `os migrate` refuses `%s` with no URL instead of handing it a file: DSN', + (kind) => { + process.env.OS_DATABASE_DRIVER = kind; + expect(() => resolveStandaloneDatabase({ artifactPath: '/nonexistent/objectstack.json' })) + .toThrow(/no database URL was given/); + }, + ); + + // What the refusals must NOT do: swallow a URL the operator actually gave. + it.each(NO_LOCAL_DEFAULT)('`%s` WITH a URL is still accepted on both sides', (kind) => { + const url = URL_FOR[kind]!; + expect(resolveStorageDefinition(kind, { databaseUrl: url, isDev: false })!.driverId).toBe(kind); + process.env.OS_DATABASE_DRIVER = kind; + process.env.OS_DATABASE_URL = url; + expect(resolveStandaloneDatabase({ artifactPath: '/nonexistent/objectstack.json' }).driver).toBe(kind); + }); + + // Each refusal must name ITS OWN driver and target shape. A shared sentence + // that pointed every operator at a libSQL endpoint would be worse than terse: + // it sends a postgres operator looking for a knob that does not exist. + it.each(NO_LOCAL_DEFAULT)('the `%s` refusal names that driver and a target it could have', (kind) => { + let message = ''; + try { + resolveStorageDefinition(kind, { isDev: false }); + } catch (err) { + message = (err as Error).message; + } + expect(message).toContain(`\`${kind}\``); + expect(message).toContain('OS_DATABASE_URL'); + // The generic fallback clause must not be what an operator actually sees. + expect(message).not.toContain('the URL of the database this driver connects to'); + }); + + // The three local engines keep their defaults — the refusal must be scoped to + // "no local default", not to "no URL". + it.each(['memory', 'sqlite', 'sqlite-wasm'] as const)('`%s` with no URL still resolves', (kind) => { + expect(resolveStorageDefinition(kind, { isDev: false })!.driverId).toBe(kind); + }); +}); diff --git a/packages/cli/src/utils/storage-driver.test.ts b/packages/cli/src/utils/storage-driver.test.ts index a6b2262c7f..5c86ca3bd7 100644 --- a/packages/cli/src/utils/storage-driver.test.ts +++ b/packages/cli/src/utils/storage-driver.test.ts @@ -88,13 +88,30 @@ describe('resolveStorageDefinition (#3826 — a definition, not a driver)', () = expect(resolveStorageDefinition('in-memory', { isDev: false })!.driverId).toBe('memory'); }); - it('declares mongodb with the default URL when none is supplied', () => { - const r = resolveStorageDefinition('mongodb', { isDev: false }); + it('declares mongodb from the URL it was given', () => { + const r = resolveStorageDefinition('mongodb', { + databaseUrl: 'mongodb://db.internal:27017/app', + isDev: false, + }); expect(r!.driverId).toBe('mongodb'); - expect(r!.config).toEqual({ url: 'mongodb://localhost:27017/objectstack' }); + expect(r!.config).toEqual({ url: 'mongodb://db.internal:27017/app' }); expect(r!.trackName).toBe('MongoDBDriver'); }); + // VERDICT FLIPPED by #6345 fork 2 (maintainer ruling, 2026-08-09). This pin + // used to assert `config: { url: 'mongodb://localhost:27017/objectstack' }` for + // a mongodb selection with no URL — a DSN the CLI invented, naming a host the + // operator never did. It is the same defect as postgres's `url: undefined` + // (which let the `pg` client pick its own localhost) wearing a different + // mechanism, and the standalone stack answered the same selection with a + // `file:` DSN. All three now refuse, in the wording `turso` has carried since + // #5602. The full 8-cell matrix lives in `driver-vocabulary-parity.test.ts`; + // this one stays here because it is the assertion that changed. + it('REFUSES mongodb with no URL rather than inventing localhost:27017 (#6345)', () => { + expect(() => resolveStorageDefinition('mongodb', { isDev: false })).toThrow(UnsupportedDriverError); + expect(() => resolveStorageDefinition('mongodb', { isDev: true })).toThrow(/no database URL was given/); + }); + it('declares postgres / mysql with the DSN in config and their SqlDriver labels', () => { const pg = resolveStorageDefinition('postgres', { databaseUrl: 'postgres://u:p@h/db', isDev: false }); expect(pg!.driverId).toBe('postgres'); @@ -139,9 +156,31 @@ describe('resolveStorageDefinition (#3826 — a definition, not a driver)', () = // Production with no driver configured registers nothing (loud downstream // failure), rather than silently inventing an engine. - it('returns null for an unknown/absent driver in PROD', () => { + it('returns null when NO driver is configured in PROD', () => { expect(resolveStorageDefinition('', { isDev: false })).toBeNull(); - expect(resolveStorageDefinition('nonsense', { isDev: false })).toBeNull(); + }); + + // VERDICT FLIPPED by #6345 fork 1. `'nonsense'` used to share the `''` answer — + // null in prod, and in DEV the trailing SQLite default, i.e. + // `os dev --database-driver sqlite3` silently booted SQLite while `os migrate` + // refused the same value by name (#6344 killed the silent fallback on that + // side only). The two are not the same input: `''` means "nobody chose", while + // a non-empty value can only have come from an operator naming a driver, since + // URL inference yields a canonical id or `''`. So the two answers separate. + it('REFUSES an explicitly-named unknown driver, in dev AND prod (#6345)', () => { + for (const isDev of [false, true]) { + expect(() => resolveStorageDefinition('nonsense', { isDev })).toThrow(UnsupportedDriverError); + // The four contract-only aliases: they resolve a CONFIG contract but no + // host has ever accepted them as a boot selection, and the ruling keeps it + // that way rather than widening a flag nobody asked to widen. + expect(() => resolveStorageDefinition('sqlite3', { isDev })).toThrow(UnsupportedDriverError); + expect(() => resolveStorageDefinition('mariadb', { isDev })).toThrow(UnsupportedDriverError); + } + // The refusal enumerates what DOES work, from the shared table. + let message = ''; + try { resolveStorageDefinition('nonsense', { isDev: false }); } catch (e) { message = (e as Error).message; } + expect(message).toContain('pg'); + expect(message).toContain('mongodb'); }); }); diff --git a/packages/cli/src/utils/storage-driver.ts b/packages/cli/src/utils/storage-driver.ts index 6e58fa6dc7..666fa05de4 100644 --- a/packages/cli/src/utils/storage-driver.ts +++ b/packages/cli/src/utils/storage-driver.ts @@ -55,6 +55,12 @@ import type { DatasourceDriverHandle, IDatasourceDriverFactory, } from '@objectstack/service-datasource'; +import { + type BuiltinDriverId, + DATABASE_DRIVER_SELECTION_ALIASES, + driverHasLocalDefault, + resolveDatabaseDriverId, +} from '@objectstack/spec/data'; /** Engines the shared sqlite step-down (`resolveSqliteDriver`) can produce. */ export type SqliteFamilyEngine = 'better-sqlite3' | 'sqlite-wasm' | 'memory'; @@ -62,16 +68,65 @@ export type SqliteFamilyEngine = 'better-sqlite3' | 'sqlite-wasm' | 'memory'; /** The optional package that provides the libSQL/Turso driver. */ export const TURSO_DRIVER_PACKAGE = '@objectstack/driver-turso'; -/** Driver kinds this resolver treats as libSQL/Turso. */ -const TURSO_DRIVER_KINDS = new Set(['turso', 'libsql']); +/** + * Where each no-local-default kind's connection target actually comes from — + * the one clause that differs between their otherwise identical refusals. + * + * Covers exactly the kinds the shared table marks `hasLocalDefault: false`. + * That set is runtime data from `@objectstack/spec`, not a type, so the + * completeness of this table is pinned by a test rather than by the compiler + * (`driver-vocabulary-parity.test.ts`) — the point either way is that a driver + * cannot get another driver's example and send an operator to the wrong server. + */ +const MISSING_URL_EXAMPLES: Readonly>> = { + postgres: 'postgres://user:password@host:5432/dbname', + mysql: 'mysql://user:password@host:3306/dbname', + mongodb: 'mongodb://host:27017/dbname (or mongodb+srv://…)', + turso: + 'libsql://my-db.turso.io with OS_DATABASE_AUTH_TOKEN / --database-auth-token, ' + + 'or file:./data/objectstack.db for a local libSQL file', +}; /** - * Thrown by {@link resolveStorageDefinition} when a driver kind is *recognized* but the - * selection cannot be turned into a datasource definition at all — today only - * `turso`/libSQL selected with **no URL** (`OS_DATABASE_DRIVER=turso` / - * `--database-driver turso` on its own). Every other kind has a meaningful default - * for a missing URL; libSQL has none — `TursoDriverConfig.url` is required and there - * is no local file, host or database name to guess. + * The refusal for "you named a driver whose database lives somewhere I cannot + * guess, and then did not tell me where" (#6345 fork 2). + * + * Generalized from the wording `turso` has carried since #5602, because that + * wording was already right for every one of these kinds — the maintainer's + * ruling is that all four say it, on both hosts, instead of three of them + * inventing a different wrong default. It is a FIX instruction: it names the + * variable to set, shows the shape, and states what booting anyway would have + * cost, because the failure it replaces (connecting to some localhost the + * operator never named) is one that LOOKS like success. + */ +function missingUrlMessage(kind: BuiltinDriverId): string { + // The fallback is deliberately TRUE rather than borrowed from another arm: a + // driver added to the shared table with `hasLocalDefault: false` and no example + // here still gets a correct instruction. `driver-vocabulary-parity.test.ts` pins + // that every such id HAS an example, so the generic branch stays unused rather + // than quietly becoming the normal answer. + const example = MISSING_URL_EXAMPLES[kind] ?? 'the URL of the database this driver connects to'; + return ( + `The \`${kind}\` driver was selected (OS_DATABASE_DRIVER / --database-driver) but no database ` + + `URL was given, and ${kind} has no local default to fall back on — its database lives on a ` + + 'server or endpoint this process cannot guess. Set OS_DATABASE_URL (or --database) to it — ' + + `e.g. ${example}. Booting on a guessed default instead would connect you ` + + 'to a database you never named, and every write would land in the wrong place (#3276).' + ); +} + +/** + * Thrown by {@link resolveStorageDefinition} for a driver selection that cannot + * become a datasource definition. Two cases since #6345: + * + * - a spelling no builtin claims (`--database-driver sqlite3`), which used to + * fall through to the dev SQLite default while `os migrate` refused the same + * value by name; + * - a recognized kind with **no local default** selected with **no URL** + * (`postgres` / `mysql` / `mongodb` / `turso`). Only `turso` refused before; + * the other three guessed — `url: undefined` into `pg`, an invented + * `mongodb://localhost:27017/objectstack` — and connected an operator to a + * database they never named. The ruling generalizes the refusal instead. * * Not the "package missing" case: that is {@link MissingDriverPackageError}, which * says something completely different to the operator (install this, versus tell me @@ -84,11 +139,38 @@ const TURSO_DRIVER_KINDS = new Set(['turso', 'libsql']); * silently became SQLite-in-memory). */ export class UnsupportedDriverError extends Error { + /** + * The selection that was refused. + * + * Read {@link recognized} before treating this as a driver id: for the + * unknown-spelling case it is the operator's raw token (`sqlite3`), NOT a + * canonical kind. + */ readonly driverType: string; - constructor(driverType: string, message: string) { + /** + * Was the refused selection a driver this CLI KNOWS (`turso` with no URL), or + * a spelling nothing claims (`--database-driver sqlite3`)? + * + * Both are fatal and both are this class — `serve.ts` re-throws on the type, + * and an operator needs the same "stop, do not fall back to SQLite" outcome + * either way. But they are not the same fact, and a consumer asking "which + * driver kinds exist" must not read an unrecognized token as one. + * + * That consumer is real: `commands/database-driver-allowlist.pin.test.ts` + * (#6860) derives the canonical kinds by using {@link resolveStorageDefinition} + * as its oracle and reading `driverType` out of this error. When #6345 taught + * the resolver to refuse unknown spellings too, that oracle started reporting + * every stray string literal in this file (`safe`, `on-disconnect`, `factory`) + * as a driver kind. This flag is what keeps the two answers apart. + */ + readonly recognized: boolean; + constructor(driverType: string, message: string, opts: { recognized?: boolean } = {}) { super(message); this.name = 'UnsupportedDriverError'; this.driverType = driverType; + // Defaults to `true` so the pre-#6345 call sites (turso with no URL) keep + // their meaning without restating it. + this.recognized = opts.recognized ?? true; } } @@ -230,8 +312,51 @@ export function resolveStorageDefinition( // kinds. Never in production, never destructive. const autoMigrate = isDev ? ({ autoMigrate: 'safe' } as const) : {}; - if (driverType === 'mongodb' || driverType === 'mongo') { - const url = databaseUrl ?? 'mongodb://localhost:27017/objectstack'; + // ONE vocabulary since #6345 (`@objectstack/spec`'s driver table). The arms + // below therefore branch on the CANONICAL id and never on a spelling: the + // hand-written `driverType === 'pg' || driverType === 'postgresql'` chains + // were half of the fork this card closes — the standalone stack's enum had + // its own answer, and 10 of 21 spellings disagreed. + const kind = resolveDatabaseDriverId(driverType); + + // An EXPLICIT selection nothing claims is refused, loudly (#6345 fork 1). + // + // `driverType` is `explicit || inferDriverTypeFromUrl(url)`, and the inferring + // half only ever yields a canonical id or `''` — so a non-empty value that + // resolves to nothing can only have come from an operator naming a driver. + // It used to fall through to the trailing dev default, i.e. `os dev + // --database-driver sqlite3` silently booted SQLite while `os migrate` refused + // the same value by name. #6344 killed that silent fallback on the standalone + // side; this is its mirror, and it is what makes the two hosts answer the same + // question the same way for EVERY input rather than only for the legal ones. + if (driverType && !kind) { + throw new UnsupportedDriverError( + driverType, + `Unsupported driver "${driverType}" (OS_DATABASE_DRIVER / --database-driver). ` + + `Supported drivers: ${DATABASE_DRIVER_SELECTION_ALIASES.join(', ')}. ` + + 'Booting on the SQLite default instead would silently ignore the driver you asked for ' + + 'and write into a local database (#3276). Fix the value, or leave the driver unset to ' + + 'let the database URL scheme select it.', + // NOT a driver kind — `driverType` here is the operator's raw token, and a + // caller enumerating kinds must not count it as one. + { recognized: false }, + ); + } + + // Fork 2 (#6345): a kind with NO local default, selected with no URL. Every + // such selection used to be answered by a guess, differently on each side: + // postgres/mysql got `config.url === undefined` and the `pg`/`mysql2` client + // then connected to ITS own localhost; mongodb got an invented + // `mongodb://localhost:27017/objectstack`. Both connect an operator to a + // database they never named, which is the #3276 class. `turso` already had + // this refusal; the maintainer's ruling generalizes it rather than leaving + // one kind honest and three guessing. + if (kind && !(databaseUrl ?? '').trim() && !driverHasLocalDefault(kind)) { + throw new UnsupportedDriverError(kind, missingUrlMessage(kind)); + } + + if (kind === 'mongodb') { + const url = databaseUrl!; return { driverId: 'mongodb', config: { url }, @@ -241,7 +366,7 @@ export function resolveStorageDefinition( }; } - if (driverType === 'sqlite' || driverType === 'sql') { + if (kind === 'sqlite') { const filePath = (databaseUrl ?? ':memory:') .replace(/^file:/, '') .replace(/^sqlite:/, '') @@ -259,7 +384,7 @@ export function resolveStorageDefinition( }; } - if (driverType === 'sqlite-wasm' || driverType === 'wasm-sqlite' || driverType === 'wasm') { + if (kind === 'sqlite-wasm') { const filePath = (databaseUrl ?? ':memory:') .replace(/^file:/, '') .replace(/^wasm-sqlite:\/\//, '') @@ -275,7 +400,7 @@ export function resolveStorageDefinition( }; } - if (driverType === 'postgres' || driverType === 'postgresql' || driverType === 'pg') { + if (kind === 'postgres') { return { driverId: 'postgres', config: { url: databaseUrl, ...autoMigrate }, @@ -285,7 +410,7 @@ export function resolveStorageDefinition( }; } - if (driverType === 'mysql' || driverType === 'mysql2') { + if (kind === 'mysql') { return { driverId: 'mysql', config: { url: databaseUrl, ...autoMigrate }, @@ -307,19 +432,9 @@ export function resolveStorageDefinition( // `TursoDriverConfig` declares no such key, and handing it one would be a config // the driver silently ignores. No `sqliteFilePath` either — the telemetry sibling // is provisioned next to an on-disk SQLite primary, which a libSQL endpoint is not. - if (TURSO_DRIVER_KINDS.has(driverType)) { - const url = (databaseUrl ?? '').trim(); - if (!url) { - throw new UnsupportedDriverError( - 'turso', - 'The `turso`/libSQL driver was selected (OS_DATABASE_DRIVER / --database-driver) ' - + 'but no database URL was given, and libSQL has no default to fall back on. ' - + 'Set OS_DATABASE_URL (or --database) to your libSQL endpoint — e.g. ' - + 'libsql://my-db.turso.io with OS_DATABASE_AUTH_TOKEN / --database-auth-token, ' - + 'or file:./data/objectstack.db for a local libSQL file. Booting on the SQLite ' - + 'default instead would silently ignore the driver you asked for.', - ); - } + if (kind === 'turso') { + // The no-URL refusal is the shared one above; by here a URL is present. + const url = databaseUrl!.trim(); return { driverId: 'turso', config: { url, ...(authToken ? { authToken } : {}) }, @@ -332,7 +447,7 @@ export function resolveStorageDefinition( // #3276: explicit in-memory (mingo) driver. Honored in dev AND production — an // operator asking for `memory` gets the mingo InMemoryDriver (ephemeral, not // real SQL), never the SQLite `:memory:` default. - if (driverType === 'memory' || driverType === 'mingo' || driverType === 'in-memory') { + if (kind === 'memory') { return { driverId: 'memory', config: {}, @@ -361,9 +476,15 @@ export function resolveStorageDefinition( return null; } -/** True for the driver ids {@link loadTursoDriverFactory}'s factory builds. */ +/** + * True for the driver ids {@link loadTursoDriverFactory}'s factory builds. + * + * Resolved through the shared table since #6345 rather than a local `Set`, so + * "which spellings mean libSQL" has one answer across the CLI, the standalone + * stack and the metadata gate. + */ export function isTursoDriverId(driverId: string): boolean { - return TURSO_DRIVER_KINDS.has(driverId.trim().toLowerCase()); + return resolveDatabaseDriverId(driverId) === 'turso'; } /** The exact command an operator runs to install the optional libSQL driver. */ diff --git a/packages/runtime/src/resolve-project-database.ts b/packages/runtime/src/resolve-project-database.ts index e052b45771..8613c5d9f6 100644 --- a/packages/runtime/src/resolve-project-database.ts +++ b/packages/runtime/src/resolve-project-database.ts @@ -44,7 +44,7 @@ import { resolve as resolvePath, isAbsolute } from 'node:path'; import { existsSync, readFileSync } from 'node:fs'; import { homedir } from 'node:os'; -import { resolveDriverId } from '@objectstack/spec/data'; +import { resolveDatabaseDriverId, resolveDriverId } from '@objectstack/spec/data'; /** The unified default database filename (`/data/objectstack.db`). */ export const UNIFIED_DEFAULT_DB_FILENAME = 'objectstack.db'; @@ -178,8 +178,8 @@ function resolveDatabaseStateDir(opts: { * underivable connection all yield `undefined` — resolution then falls * through to the unified default, which is what those projects got before * this tier existed. (Driver-id spellings resolve through the spec's ONE - * alias table, `resolveDriverId`; `turso`/`libsql` are recognized here - * additionally because they are not builtin factory ids.) + * alias table, `resolveDriverId` — including `turso`/`libsql`, which became + * rows in it in #6345 and no longer need a local special-case here.) */ function readConfigDeclaredDefault(opts: { artifactPath?: string; @@ -221,8 +221,10 @@ function readConfigDeclaredDefault(opts: { /** Express a declared datasource's connection as a database URL, or `undefined`. */ function datasourceUrlOf(ds: { driver?: unknown; config?: unknown }, projectRoot?: string): string | undefined { const config = (ds.config ?? {}) as { filename?: unknown; url?: unknown }; - const rawDriver = typeof ds.driver === 'string' ? ds.driver.trim().toLowerCase() : ''; - const canonical = resolveDriverId(ds.driver) ?? (rawDriver === 'turso' || rawDriver === 'libsql' ? 'turso' : undefined); + // Since #6345 `turso`/`libsql` are rows in the shared table like every other + // builtin, so the local special-case they needed while turso had no config + // contract is gone — one lookup answers for all of them. + const canonical = resolveDriverId(ds.driver); switch (canonical) { case 'sqlite': case 'sqlite-wasm': { @@ -238,7 +240,7 @@ function datasourceUrlOf(ds: { driver?: unknown; config?: unknown }, projectRoot return 'memory://'; case 'postgres': case 'mysql': - case 'mongo': + case 'mongodb': case 'turso': return typeof config.url === 'string' && config.url.trim() ? config.url.trim() : undefined; default: @@ -271,7 +273,11 @@ export function resolveProjectDatabaseUrl( // An explicitly in-memory boot gets no file default imposed on it. Only // `memory` is judged here; unknown driver values are refused downstream // (`resolveExplicitDriver`) with the full legal-values list. - const driver = (opts.explicitDriver ?? env.OS_DATABASE_DRIVER)?.trim().toLowerCase(); + // Resolved through the shared table (#6345): `OS_DATABASE_DRIVER=mingo` is an + // accepted spelling of `memory` on both hosts, so it must reach this rung + // too — a raw string compare would have imposed the unified default FILE on + // a boot that explicitly asked for the in-memory engine. + const driver = resolveDatabaseDriverId(opts.explicitDriver ?? env.OS_DATABASE_DRIVER); if (driver === 'memory') return { url: 'memory://', source: 'memory-driver' }; const fromConfig = readConfigDeclaredDefault(opts); diff --git a/packages/runtime/src/standalone-stack.ts b/packages/runtime/src/standalone-stack.ts index 78322168c7..47909f29ff 100644 --- a/packages/runtime/src/standalone-stack.ts +++ b/packages/runtime/src/standalone-stack.ts @@ -57,6 +57,12 @@ import { mkdirSync } from 'node:fs'; import { homedir } from 'node:os'; import { z } from 'zod'; import { stampSearchPinyinEnabled } from '@objectstack/types'; +import { + BUILTIN_DRIVER_IDS, + DATABASE_DRIVER_SELECTION_ALIASES, + driverHasLocalDefault, + resolveDatabaseDriverId, +} from '@objectstack/spec/data'; import type { IDatasourceDriverFactory } from '@objectstack/service-datasource'; import { loadArtifactBundle, isHttpUrl } from './load-artifact-bundle.js'; import { loadTursoDriverFactory } from './turso-driver-factory.js'; @@ -89,21 +95,63 @@ export function resolveObjectStackHome(): string { /** * The driver kinds a standalone boot can dispatch — the ONE list, and the only - * one (#6265). + * one (#6265), now shared with the CLI rather than merely singular here (#6345). * * Three consumers read it and every one of them used to carry its own answer: * the `databaseDriver` config key (a zod enum that rejected loudly), the * `OS_DATABASE_DRIVER` env var (a bare `as` cast that validated nothing, so an * unknown value fell through the dispatch chain's trailing `else` into SQLite), - * and the `ResolvedDriverKind` union (a hand-written third copy). They are now - * one declaration: the union is `z.infer`red from it, the env value is parsed - * through it, and the refusal message enumerates `.options` rather than - * repeating them — a kind added here cannot leave a stale legal-values list - * behind. + * and the `ResolvedDriverKind` union (a hand-written third copy). #6265 made + * them one declaration. + * + * What #6265 could not fix from inside this file is that the CLI had a FOURTH + * answer. This enum listed canonical spellings only, while + * `packages/cli/src/utils/storage-driver.ts` accepted `pg`, `mysql2`, `mongo`, + * `libsql`, `wasm`, `sql`, `mingo`, … — measured on `main`, **10 of 21 spellings + * disagreed**, so `OS_DATABASE_DRIVER=pg` booted under `os start` and was + * refused here. The enum's VALUES are therefore no longer written here either: + * they are `BUILTIN_DRIVER_IDS` from `@objectstack/spec`, the one driver + * vocabulary both hosts read, and the accepted spellings are that table's + * aliases via {@link resolveExplicitDriver}. A driver added to the spec table + * appears on both hosts at once, which is the only shape in which this fork + * cannot re-open. + */ +export const StandaloneDatabaseDriverSchema = z.enum(BUILTIN_DRIVER_IDS); + +/** + * The `databaseDriver` CONFIG key's schema — an alias-accepting front door onto + * {@link StandaloneDatabaseDriverSchema} (#6345). + * + * `databaseDriver` and `OS_DATABASE_DRIVER` are two spellings of one decision, + * so accepting `pg` from the environment and refusing it from a programmatic + * config would just relocate the fork this card closes to inside a single host. + * Both doors now resolve through the spec table's selection face and both + * produce a CANONICAL id, so everything downstream still branches on one value + * per driver. */ -export const StandaloneDatabaseDriverSchema = z.enum([ - 'sqlite', 'sqlite-wasm', 'memory', 'postgres', 'mysql', 'mongodb', 'turso', -]); +const DatabaseDriverSelectionSchema = z.string().transform((raw, ctx) => { + const id = resolveDatabaseDriverId(raw); + if (!id) { + ctx.addIssue({ code: 'custom', message: unsupportedDriverMessage(raw, 'databaseDriver') }); + return z.NEVER; + } + return id; +}); + +/** + * The refusal for a driver selection no builtin claims — one sentence, both + * doors, enumerating the spellings that actually work rather than a list + * maintained beside them. + */ +function unsupportedDriverMessage(raw: string, source: 'OS_DATABASE_DRIVER' | 'databaseDriver'): string { + return ( + `[StandaloneStack] Unsupported ${source} value: "${raw}". ` + + `Supported drivers: ${DATABASE_DRIVER_SELECTION_ALIASES.join(', ')}. ` + + `Booting on the SQLite default instead would silently ignore the driver you asked for ` + + `and write into a local database (#3276). Fix the value, or unset it ` + + `to let the OS_DATABASE_URL scheme select the driver.` + ); +} export const StandaloneStackConfigSchema = z.object({ databaseUrl: z.string().optional(), @@ -114,7 +162,7 @@ export const StandaloneStackConfigSchema = z.object({ * reads, and the same pair `--database-auth-token` forwards into). */ databaseAuthToken: z.string().optional(), - databaseDriver: StandaloneDatabaseDriverSchema.optional(), + databaseDriver: DatabaseDriverSelectionSchema.optional(), environmentId: z.string().optional(), artifactPath: z.string().optional(), /** @@ -264,14 +312,47 @@ function resolveExplicitDriver( if (cfg.databaseDriver) return cfg.databaseDriver; const raw = process.env.OS_DATABASE_DRIVER?.trim(); if (!raw) return undefined; - const parsed = StandaloneDatabaseDriverSchema.safeParse(raw.toLowerCase()); - if (parsed.success) return parsed.data; + // #6345: the ACCEPTED SPELLINGS are the spec table's selection aliases, not + // this file's canonical list. Lower-casing stays for the reason #6265 gave — + // the CLI's reader of this same variable lower-cases — and is now redundant + // with `resolveDatabaseDriverId`'s own normalization rather than the only + // normalization there is. + const id = resolveDatabaseDriverId(raw); + if (id) return id; + throw new Error(unsupportedDriverMessage(raw, 'OS_DATABASE_DRIVER')); +} + +/** + * Refuse a driver whose database lives somewhere this process cannot guess when + * nothing named where that is (#6345 fork 2). + * + * The URL ladder always produces SOMETHING — its last rung is the unified + * default file — so before this check a `postgres`/`mysql`/`mongodb`/`turso` + * selection with no URL anywhere was handed `file:/data/objectstack.db` + * and failed inside the driver, two layers from the cause, with a message about + * a file for an operator who asked for a server. (The CLI's mirror of this bug + * guessed differently — `url: undefined` into `pg`, an invented + * `mongodb://localhost:27017/objectstack` — which is why the ruling makes both + * sides refuse instead of making the two guesses agree.) + * + * Only the FALLBACK rungs are refused. A URL that came from `--database`, + * `OS_DATABASE_URL`/`DATABASE_URL`/`TURSO_DATABASE_URL`, or the project's own + * declared default datasource is a statement about where the database is, and a + * `file:` DSN handed to postgres by an operator who typed it is their business, + * not a guess of ours. + */ +function assertUrlNamedForRemoteDriver( + driver: ResolvedDriverKind, + source: ProjectDatabaseUrlSource, +): void { + if (driverHasLocalDefault(driver)) return; + if (source !== 'unified-default' && source !== 'legacy-file') return; throw new Error( - `[StandaloneStack] Unsupported OS_DATABASE_DRIVER value: "${raw}". ` + - `Supported drivers: ${StandaloneDatabaseDriverSchema.options.join(', ')}. ` + - `Booting on the SQLite default instead would silently ignore the driver you asked for ` + - `and write into a local database (#3276). Fix the value, or unset OS_DATABASE_DRIVER ` + - `to let the OS_DATABASE_URL scheme select the driver.` + `[StandaloneStack] The \`${driver}\` driver was selected but no database URL was given, ` + + `and ${driver} has no local default to fall back on — its database lives on a server or ` + + `endpoint this process cannot guess. Set OS_DATABASE_URL (or --database) to it. ` + + `Falling back to the local SQLite file instead would connect you to a database you never ` + + `named, and every write would land in the wrong place (#3276).` ); } @@ -368,6 +449,9 @@ export function resolveStandaloneDatabase(config?: StandaloneStackConfig): Resol const url = resolution.url; const explicitDriver = resolveExplicitDriver(cfg); const driver: ResolvedDriverKind = explicitDriver || detectDriverFromUrl(url); + // Fork 2 (#6345) — refuse before deriving a sqlite filename from a URL the + // selected driver was never going to open. + assertUrlNamedForRemoteDriver(driver, resolution.source); const isSqlite = driver === 'sqlite' || driver === 'sqlite-wasm'; const filename = isSqlite ? sqliteFilenameFromUrl(url, driver) : null; return { diff --git a/packages/services/service-datasource/src/__tests__/datasource-pool-support.test.ts b/packages/services/service-datasource/src/__tests__/datasource-pool-support.test.ts index 719292aef9..6e4c3fc769 100644 --- a/packages/services/service-datasource/src/__tests__/datasource-pool-support.test.ts +++ b/packages/services/service-datasource/src/__tests__/datasource-pool-support.test.ts @@ -136,6 +136,10 @@ describe('#5714 — which driver arms read a declared `pool`', () => { // The two sqlite arms' text is UNCHANGED by #5931 — pinned whole, against the // literal as it stood on `origin/main` before this change, because "we only // added an arm" is a claim about bytes. + // Byte-for-byte as #5714 wrote it, with ONE word changed: the closing clause + // names the pooled drivers, and #6345 renamed the canonical mongo id to + // `mongodb`. Naming the retired canon in an instruction the author is meant to + // act on would send them to a spelling the catalog no longer publishes. it('leaves the sqlite arms\' message byte-for-byte as #5714 wrote it', () => { const expected = "Datasource 'crm_primary' declares a `pool` block, but the 'sqlite' driver does not read " + @@ -144,7 +148,7 @@ describe('#5714 — which driver arms read a declared `pool`', () => { "empty database. Sizing it here would therefore split one datasource's data across " + 'several stores, so the block is rejected instead of dropped. Remove `pool` from this ' + 'datasource declaration; it stays meaningful on the pooled drivers ' + - '(postgres / mysql / mongo).'; + '(postgres / mysql / mongodb).'; expect(unsupportedPoolMessage('sqlite', 'crm_primary')).toBe(expected); expect(unsupportedPoolMessage('sqlite-wasm', 'crm_primary')) .toBe(expected.replace("the 'sqlite' driver", "the 'sqlite-wasm' driver")); diff --git a/packages/services/service-datasource/src/__tests__/driver-catalog.test.ts b/packages/services/service-datasource/src/__tests__/driver-catalog.test.ts index 4452074515..576d0b6920 100644 --- a/packages/services/service-datasource/src/__tests__/driver-catalog.test.ts +++ b/packages/services/service-datasource/src/__tests__/driver-catalog.test.ts @@ -46,7 +46,12 @@ describe('DRIVER_CATALOG', () => { expect(entry.description, entry.id).toBeTruthy(); expect(entry.icon, entry.id).toBeTruthy(); } - expect(DRIVER_CATALOG.map((d) => d.id)).toEqual(['memory', 'sqlite', 'postgres', 'mysql', 'mongo']); + // `mongodb`, not `mongo`, since #6345 renamed the canonical driver id. This + // list is the PUBLISHED contract Studio writes into `datasource.driver`, so + // the assertion is the one that has to move with the rename — stored rows + // carrying `mongo` are converged by the ADR-0087 conversion + // `datasource-driver-mongo-to-mongodb`. + expect(DRIVER_CATALOG.map((d) => d.id)).toEqual(['memory', 'sqlite', 'postgres', 'mysql', 'mongodb']); }); /** diff --git a/packages/services/service-datasource/src/datasource-pool-support.ts b/packages/services/service-datasource/src/datasource-pool-support.ts index a4ee778f84..18bd3e2a41 100644 --- a/packages/services/service-datasource/src/datasource-pool-support.ts +++ b/packages/services/service-datasource/src/datasource-pool-support.ts @@ -83,9 +83,20 @@ export type PoolUnsupportedDriverId = (typeof POOL_UNSUPPORTED_DRIVER_IDS)[numbe /** * Does this driver id read a declared `datasource.pool`? * - * `true` for the pooled built-ins (`postgres` / `mysql` / `mongo`) **and** for + * `true` for the pooled built-ins (`postgres` / `mysql` / `mongodb`) **and** for * every id outside the built-in table — an unknown id is not ours to judge, so * it is left alone rather than rejected against a contract we do not ship. + * + * `turso` answers `true` as well, and did so before #6345 made it a builtin + * (then via the unknown-id branch, now via "not in the rejected set") — so this + * function's verdict for it is unchanged. Whether that verdict is RIGHT is a + * separate, pre-existing question this card deliberately does not answer: + * `TursoDriverConfig` has no `min`/`max`, only `concurrency`, and in local mode + * the driver is a better-sqlite3 `SqlDriver` — the very engine + * {@link POOL_UNSUPPORTED_DRIVER_IDS} rejects a `pool` block for. A declared + * `pool` on a turso datasource is therefore dropped in silence today. Changing + * that is a new rejection on an authoring surface and needs its own ruling; see + * the #6345 PR's follow-ups. */ export function driverReadsDeclaredPool(driver: unknown): boolean { const id = resolveDriverId(driver); @@ -168,7 +179,7 @@ export function unsupportedPoolMessage(driver: string, datasourceName?: string): return ( `${subject} declares a \`pool\` block, but the '${driver}' driver does not read it: ${reason} ` + `Remove \`pool\` from this datasource declaration; it stays meaningful on the pooled drivers ` + - `(postgres / mysql / mongo).` + `(postgres / mysql / mongodb).` ); } diff --git a/packages/services/service-datasource/src/default-datasource-driver-factory.ts b/packages/services/service-datasource/src/default-datasource-driver-factory.ts index 5f2fdbdc69..3aa3995be8 100644 --- a/packages/services/service-datasource/src/default-datasource-driver-factory.ts +++ b/packages/services/service-datasource/src/default-datasource-driver-factory.ts @@ -15,7 +15,8 @@ * - `sqlite` / `sqlite3` → `@objectstack/driver-sql` (better-sqlite3) * - `sqlite-wasm` / `wasm-sqlite` → `@objectstack/driver-sqlite-wasm` (pure-JS) * - `mysql` / `mysql2` → `@objectstack/driver-sql` (client `mysql2`) - * - `mongo` / `mongodb` → `@objectstack/driver-mongodb` (peer dep) + * - `mongodb` / `mongo` → `@objectstack/driver-mongodb` (peer dep) + * - `turso` / `libsql` → `@objectstack/driver-turso` (peer dep) * - `memory` / `inmemory` → `@objectstack/driver-memory` (ephemeral, * per-datasource — see {@link buildMemoryConfig}) * @@ -30,6 +31,16 @@ * Anything else returns `supports() === false`, so the admin service degrades * gracefully (testConnection → `{ ok: false }`, create skips hot pool reg). * + * `turso` joined in #6345, and it HAD to: `supports()` is + * `resolveKind() !== undefined`, so the moment turso became a builtin id this + * factory started claiming it. Without an arm the claim would have been answered + * by the trailing `memory` fall-through — a libSQL datasource silently built as + * an ephemeral in-process store, which is the #3276 class with a new spelling. + * The arm is the same shape `mongodb` and `sqlite-wasm` already use, since all + * three ride in optional packages. The trailing fall-through is gone too: the + * last arm is now an explicit `memory` case with an exhaustiveness throw after + * it, so the NEXT builtin cannot inherit the same trap. + * * SECURITY: the cleartext `spec.secret` is used only to open the connection and * is never persisted or logged here. */ @@ -432,7 +443,7 @@ export function createDefaultDatasourceDriverFactory( return toHandle(driver); } - if (kind === 'mongo') { + if (kind === 'mongodb') { let MongoDBDriver: any; try { ({ MongoDBDriver } = await import('@objectstack/driver-mongodb' as any)); @@ -455,19 +466,96 @@ export function createDefaultDatasourceDriverFactory( return toHandle(driver); } - // memory — ephemeral per datasource unless the author opts into - // persistence, and then into a destination of its own (#4083). - // - // `spec.pool` is not read here and never was: `InMemoryDriver` opens no - // connection, so there is nothing for one to size. It used to be dropped - // in silence; since #5931 the guard above rejects it, which is why this - // arm needs no pool handling of its own rather than merely having none. - const { InMemoryDriver } = await import('@objectstack/driver-memory'); - return toHandle(new InMemoryDriver(buildMemoryConfig(spec))); + if (kind === 'turso') { + // libSQL/Turso (#6345). Lazy + caught exactly like `mongodb` and + // `sqlite-wasm` above: all three ship in optional packages, and a driver + // being an optional INSTALL has never meant it lacks a contract. + // + // This arm exists because `supports()` is `resolveKind() !== undefined`. + // Giving turso a config contract made it a `BuiltinDriverId`, so the + // factory began claiming it; before this arm that claim was answered by + // the trailing `memory` fall-through, i.e. a libSQL datasource built as + // an ephemeral in-process store that reports success and loses every + // write (#3276). The CLI and standalone stack still INJECT their own + // turso factory for the `default` datasource (#5602's host-factory + // seam), which wins over this one; this arm is what serves every OTHER + // door — a runtime datasource created in Setup, `testConnection`, a + // declared non-default datasource. + let TursoDriver: any; + try { + ({ TursoDriver } = await import('@objectstack/driver-turso' as any)); + } catch (err: any) { + throw new Error( + `turso driver requested but @objectstack/driver-turso is not installed (${err?.message ?? err}).`, + ); + } + const url = typeof cfg.url === 'string' ? cfg.url.trim() : ''; + if (!url) { + // `TursoConfigSchema.url` is required, so the authoring and wizard + // gates already refuse this. A stored row written before #6345 had no + // gate at all, and refusing here is the difference between a named + // failure and `@libsql/client` opening something unexpected. + throw new Error( + `datasource '${spec.name ?? 'default'}': the turso driver needs a libSQL url in its ` + + 'config (e.g. libsql://my-db.turso.io or file:./data/objectstack.db).', + ); + } + const driver = new TursoDriver({ + url, + ...(typeof cfg.authToken === 'string' && cfg.authToken ? { authToken: cfg.authToken } : {}), + ...(typeof cfg.encryptionKey === 'string' && cfg.encryptionKey + ? { encryptionKey: cfg.encryptionKey } + : {}), + ...(typeof cfg.concurrency === 'number' ? { concurrency: cfg.concurrency } : {}), + ...(typeof cfg.syncUrl === 'string' && cfg.syncUrl ? { syncUrl: cfg.syncUrl } : {}), + ...(cfg.sync && typeof cfg.sync === 'object' ? { sync: cfg.sync } : {}), + ...(typeof cfg.timeout === 'number' ? { timeout: cfg.timeout } : {}), + ...(typeof cfg.mode === 'string' ? { mode: cfg.mode } : {}), + ...(schemaMode ? { schemaMode } : {}), + }); + return toHandle(driver, () => sqlServerVersion(driver, 'sqlite')); + } + + if (kind === 'memory') { + // memory — ephemeral per datasource unless the author opts into + // persistence, and then into a destination of its own (#4083). + // + // `spec.pool` is not read here and never was: `InMemoryDriver` opens no + // connection, so there is nothing for one to size. It used to be dropped + // in silence; since #5931 the guard above rejects it, which is why this + // arm needs no pool handling of its own rather than merely having none. + const { InMemoryDriver } = await import('@objectstack/driver-memory'); + return toHandle(new InMemoryDriver(buildMemoryConfig(spec))); + } + + // Every `BuiltinDriverId` must have an arm above (#6345). Until then this + // was `memory`'s implicit position: an id the spec table knew and this + // switch did not silently became an in-process store that accepted writes + // and lost them. `kind` is `never` here, so adding a builtin without an + // arm is a TYPE error at build time and a named refusal at run time — + // never a different engine. + return assertEveryBuiltinDriverHasAnArm(kind); }, }; } +/** + * The exhaustiveness stop for {@link createDefaultDatasourceDriverFactory}'s + * dispatch — see the comment at its only call site. + * + * Takes `never`, so it cannot be reached while every builtin has an arm; it + * still throws rather than returning, because the type guarantee is erased at + * run time and a stale published `@objectstack/spec` beside a newer consumer is + * exactly the case that would reach it. + */ +function assertEveryBuiltinDriverHasAnArm(kind: never): never { + throw new Error( + `Driver id '${String(kind)}' is a built-in with a config contract but has no construction arm ` + + 'in the shared datasource driver factory. This is a platform bug — refusing rather than ' + + 'falling back, because falling back would build a different engine than the one requested.', + ); +} + /** Best-effort server version via a raw query; swallows everything. */ async function sqlServerVersion(driver: any, client: 'pg' | 'sqlite'): Promise { if (typeof driver?.execute !== 'function') return undefined; diff --git a/packages/services/service-datasource/src/driver-catalog.ts b/packages/services/service-datasource/src/driver-catalog.ts index ba0ba7f359..920545a548 100644 --- a/packages/services/service-datasource/src/driver-catalog.ts +++ b/packages/services/service-datasource/src/driver-catalog.ts @@ -27,6 +27,9 @@ * label/description/icon. `sqlite-wasm` is deliberately absent: it is * constructible and has a config contract, but it exists for CI and * no-native-build environments rather than as something an admin picks here. + * `turso` is absent for the same reason since #6345 gave it a contract: it is a + * full builtin now, but it additionally needs an optional package installed next + * to the server, which is not a thing a dropdown can arrange. */ import { getDriverConfigJsonSchemaById, type BuiltinDriverId } from '@objectstack/spec/data'; @@ -76,7 +79,13 @@ const CURATED: ReadonlyArray<{ icon: 'database', }, { - id: 'mongo', + // `mongodb` since #6345 — the canonical driver id was renamed to the + // spelling both boot hosts and `@objectstack/driver-mongodb` already used. + // This `id` is what Studio writes into `datasource.driver`, so rows written + // before the rename carry `mongo`; the ADR-0087 conversion + // `datasource-driver-mongo-to-mongodb` converges them, and `mongo` remains + // an accepted alias so a deployment that skipped it still connects. + id: 'mongodb', label: 'MongoDB', description: 'MongoDB connection via a connection URI.', icon: 'database', diff --git a/packages/spec/api-surface/data.json b/packages/spec/api-surface/data.json index a3293b4608..7c4cd0c8de 100644 --- a/packages/spec/api-surface/data.json +++ b/packages/spec/api-surface/data.json @@ -88,6 +88,7 @@ "CurrencyValueSchema (const)", "CustomPersistenceConfig (type)", "CustomPersistenceConfigSchema (const)", + "DATABASE_DRIVER_SELECTION_ALIASES (const)", "DATA_ACTION_TO_API_OPERATION (const)", "DATE_MACRO_ALIAS_TOKENS (const)", "DATE_MACRO_DESCRIPTIONS (const)", @@ -184,6 +185,7 @@ "DriverSslToggle (type)", "DriverSslToggleSchema (const)", "DriverType (type)", + "DriverVocabularyEntry (interface)", "DroppedFieldsEvent (type)", "DroppedFieldsEventSchema (const)", "ESignatureConfig (type)", @@ -566,6 +568,12 @@ "TimeUpdateInterval (type)", "TitleEligibleFieldDef (interface)", "TransformType (type)", + "TursoConfig (type)", + "TursoConfigParsed (type)", + "TursoConfigSchema (const)", + "TursoDriverSpec (const)", + "TursoTransportMode (type)", + "TursoTransportModeSchema (const)", "UniqueScope (type)", "UniqueScopeSchema (const)", "UnknownAuthoringKeyFinding (interface)", @@ -591,6 +599,7 @@ "deriveRecordFlowSurface (function)", "deriveRecordSurface (function)", "driverConfigJsonSchema (function)", + "driverHasLocalDefault (function)", "effectiveOperationsArray (function)", "emptyGroupValueFor (function)", "fieldForm (const)", @@ -604,6 +613,7 @@ "getPostgresConfigJsonSchema (const)", "getSqliteConfigJsonSchema (const)", "getSqliteWasmConfigJsonSchema (const)", + "getTursoConfigJsonSchema (const)", "hasDynamicTokens (function)", "hookForm (const)", "isApiOperationAllowed (function)", @@ -644,6 +654,7 @@ "renderAutonumber (function)", "resolveBulkPerRowHookBudget (function)", "resolveCrudAffordances (function)", + "resolveDatabaseDriverId (function)", "resolveDisplayField (function)", "resolveDriverId (function)", "resolveEffectiveApiMethods (function)", diff --git a/packages/spec/authorable-surface/data.json b/packages/spec/authorable-surface/data.json index 98317abc7a..8b6659be4d 100644 --- a/packages/spec/authorable-surface/data.json +++ b/packages/spec/authorable-surface/data.json @@ -856,6 +856,14 @@ "data/StringOperator:$notContains", "data/StringOperator:$startsWith", "data/TenancyConfig:enabled", - "data/TenancyConfig:tenantField" + "data/TenancyConfig:tenantField", + "data/TursoConfig:authToken", + "data/TursoConfig:concurrency", + "data/TursoConfig:encryptionKey", + "data/TursoConfig:mode", + "data/TursoConfig:sync", + "data/TursoConfig:syncUrl", + "data/TursoConfig:timeout", + "data/TursoConfig:url" ] } diff --git a/packages/spec/json-schema.manifest/data.json b/packages/spec/json-schema.manifest/data.json index abc8c7484e..f5b63933bf 100644 --- a/packages/spec/json-schema.manifest/data.json +++ b/packages/spec/json-schema.manifest/data.json @@ -164,6 +164,8 @@ "data/TenancyConfig", "data/TimeUpdateInterval", "data/TransformType", + "data/TursoConfig", + "data/TursoTransportMode", "data/UniqueScope", "data/ValidationRule" ] diff --git a/packages/spec/liveness/datasource.json b/packages/spec/liveness/datasource.json index bd527b5ed2..0ddb6f6b56 100644 --- a/packages/spec/liveness/datasource.json +++ b/packages/spec/liveness/datasource.json @@ -13,8 +13,8 @@ }, "driver": { "status": "live", - "evidence": "packages/services/service-datasource/src/default-datasource-driver-factory.ts:334", - "note": "factory dispatch; `resolveDriverId` normalizes aliases before the switch." + "evidence": "packages/services/service-datasource/src/default-datasource-driver-factory.ts:345", + "note": "factory dispatch; `resolveDriverId` normalizes aliases before the switch. Since #6345 that table is also what both boot hosts read for `OS_DATABASE_DRIVER` / `--database-driver`, and the canonical mongo id is `mongodb` (stored `mongo` converges via the ADR-0087 conversion `datasource-driver-mongo-to-mongodb`; the alias stays accepted). Every builtin id now has an explicit construction arm — the trailing `memory` fall-through is gone." }, "config": { "status": "live", diff --git a/packages/spec/spec-changes.json b/packages/spec/spec-changes.json index 5db3efca85..8b0ff571ff 100644 --- a/packages/spec/spec-changes.json +++ b/packages/spec/spec-changes.json @@ -278,6 +278,12 @@ "conversionId": "datasource-config-driver-key-aliases", "toMajor": 17 }, + { + "surface": "datasource.driver", + "to": "datasource driver id 'mongo' → 'mongodb' — the canonical id both boot hosts, the driver package and the published DRIVER_CATALOG already used (#6345)", + "conversionId": "datasource-driver-mongo-to-mongodb", + "toMajor": 17 + }, { "surface": "flow.node.script.config.actionType / flow.node.script.config.template / flow.node.script.config.recipients / flow.node.script.config.variables / flow.node.script.config.script", "to": "script flow-node config keys 'actionType' (→ 'function' when it was shorthand for one; otherwise removed — 'email'/'slack' were logger-backed stubs that delivered nothing), plus 'template' / 'recipients' / 'variables' (fed those stubs) and 'script' (inline JS the runtime never executed) (#4343)", @@ -1139,6 +1145,12 @@ "conversionId": "datasource-config-driver-key-aliases", "toMajor": 17 }, + { + "surface": "datasource.driver", + "to": "datasource driver id 'mongo' → 'mongodb' — the canonical id both boot hosts, the driver package and the published DRIVER_CATALOG already used (#6345)", + "conversionId": "datasource-driver-mongo-to-mongodb", + "toMajor": 17 + }, { "surface": "flow.node.script.config.actionType / flow.node.script.config.template / flow.node.script.config.recipients / flow.node.script.config.variables / flow.node.script.config.script", "to": "script flow-node config keys 'actionType' (→ 'function' when it was shorthand for one; otherwise removed — 'email'/'slack' were logger-backed stubs that delivered nothing), plus 'template' / 'recipients' / 'variables' (fed those stubs) and 'script' (inline JS the runtime never executed) (#4343)", diff --git a/packages/spec/src/conversions/conversions.test.ts b/packages/spec/src/conversions/conversions.test.ts index a0de955157..fe2bf3af33 100644 --- a/packages/spec/src/conversions/conversions.test.ts +++ b/packages/spec/src/conversions/conversions.test.ts @@ -5,6 +5,12 @@ import { describe, expect, it } from 'vitest'; import { CreateRecordConfigSchema } from '../automation/builtin-node-config.zod.js'; import { FlowSchema } from '../automation/flow.zod.js'; import { ScriptConfigSchema } from '../automation/schemaless-node-config.zod.js'; +import { DatasourceSchema } from '../data/datasource.zod.js'; +import { + getDriverConfigSchema, + resolveDriverId, + validateDriverConfig, +} from '../data/driver/config-registry.zod.js'; import { normalizeStackInput } from '../shared/metadata-collection.zod.js'; import { ElementButtonPropsSchema, PageHeaderProps, PageTabsProps } from '../ui/component.zod.js'; import { PageSchema } from '../ui/page.zod.js'; @@ -665,6 +671,61 @@ describe('conversion layer (ADR-0087 D2)', () => { }); }); + // #6345 — the `mongo` → `mongodb` canonical-id rename. Two claims have to hold + // together, and only together: the stored value CONVERGES, and a deployment + // that never runs the conversion is NOT broken. Either alone would be the + // wrong shape — a rename that breaks old rows, or a rename that leaves one + // deployment holding two spellings of one driver forever. + describe('datasource-driver-mongo-to-mongodb (#6345)', () => { + const convert = (datasources: unknown[]) => + collectConversionNotices({ datasources }, { includeRetired: true }); + + it('converts a stored `driver: "mongo"` row to `mongodb`', () => { + const row = { name: 'events', driver: 'mongo', config: { url: 'mongodb://db/x' }, origin: 'runtime' }; + const out = applyConversionsToStoredItem('datasource', row) as { driver: string }; + expect(out.driver).toBe('mongodb'); + }); + + it('converts case- and whitespace-insensitively, exactly as the resolver reads it', () => { + const { stack } = convert([ + { name: 'a', driver: 'Mongo', config: {} }, + { name: 'b', driver: ' mongo ', config: {} }, + ]); + for (const ds of stack.datasources as Array<{ driver: string }>) expect(ds.driver).toBe('mongodb'); + }); + + it('leaves an already-canonical row, and a merely mongo-LIKE id, alone', () => { + const before = { + datasources: [ + { name: 'a', driver: 'mongodb', config: { url: 'mongodb://db/x' } }, + { name: 'b', driver: 'com.vendor.mongolike', config: { url: 'x://y' } }, + ], + }; + const { stack, notices } = collectConversionNotices(structuredClone(before), { includeRetired: true }); + expect(stack).toEqual(before); + expect(notices.filter((n) => n.conversionId === 'datasource-driver-mongo-to-mongodb')).toHaveLength(0); + }); + + // THE other half of the migration proof. A deployment that upgrades without + // replaying the chain still holds `driver: 'mongo'` rows, and they must keep + // working: `mongo` stays an accepted alias on purpose, so it resolves the + // same config contract and builds the same driver as before the rename. + // This is why the rename is a `minor` on `@objectstack/spec` and not a + // boot-breaking change — the conversion converges a spelling, it does not + // rescue one. + it('an UNCONVERTED `mongo` row is not left broken — same contract, same driver', () => { + expect(resolveDriverId('mongo')).toBe('mongodb'); + expect(getDriverConfigSchema('mongo')).toBe(getDriverConfigSchema('mongodb')); + expect(validateDriverConfig('mongo', { url: 'mongodb://db/x' })).toEqual({ known: true, issues: [] }); + // And it still parses as a datasource — the authoring gate never stopped + // accepting the alias, which is the whole reason nothing breaks. + const parsed = DatasourceSchema.safeParse({ + name: 'events', driver: 'mongo', config: { url: 'mongodb://db/x' }, + }); + expect(parsed.success, JSON.stringify(parsed.error?.issues)).toBe(true); + }); + }); + // #4456 — the driver-factory `??` fallback graduation. The mappings are // driver-scoped by construction; these pin the two edges the flat fixture // pair cannot express as sharply: the same key converting under one driver @@ -688,6 +749,22 @@ describe('conversion layer (ADR-0087 D2)', () => { expect(notices.filter((n) => n.conversionId === 'datasource-config-driver-key-aliases')).toHaveLength(1); }); + it('still lands for a row whose driver id is ITSELF being renamed (#6345)', () => { + // The pairs are keyed by CANONICAL driver id, and #6345 renamed mongo's. + // A stored `driver: 'mongo'` must therefore still find the mongo pairs + // (through the alias) even as the sibling conversion rewrites its id — + // otherwise the rename would quietly un-convert every legacy mongo config. + const { stack, notices } = convert([ + { name: 'events', driver: 'mongo', config: { uri: 'mongodb://db/x', user: 'svc' } }, + ]); + const [events] = stack.datasources as Array<{ driver: string; config: Record }>; + expect(events!.config).toEqual({ url: 'mongodb://db/x', username: 'svc' }); + expect(events!.driver).toBe('mongodb'); + // Two key renames (`uri` → `url`, `user` → `username`) plus the id rename. + expect(notices.filter((n) => n.conversionId === 'datasource-config-driver-key-aliases')).toHaveLength(2); + expect(notices.filter((n) => n.conversionId === 'datasource-driver-mongo-to-mongodb')).toHaveLength(1); + }); + it('does not touch a plugin-contributed driver id — no contract, no rewrite', () => { const before = { datasources: [{ name: 'x', driver: 'com.vendor.snowflake', config: { user: 'svc' } }] }; const { stack, notices } = collectConversionNotices(structuredClone(before), { includeRetired: true }); diff --git a/packages/spec/src/conversions/registry.ts b/packages/spec/src/conversions/registry.ts index 2bc1bce250..fdb40faf11 100644 --- a/packages/spec/src/conversions/registry.ts +++ b/packages/spec/src/conversions/registry.ts @@ -3173,7 +3173,11 @@ const DATASOURCE_CONFIG_KEY_ALIASES: Readonly< 'sqlite-wasm': [['file', 'filename'], ['database', 'filename']], postgres: [['connectionString', 'url'], ['user', 'username']], mysql: [['connectionString', 'url'], ['user', 'username']], - mongo: [['uri', 'url'], ['user', 'username']], + // `mongodb` since #6345 — the canonical id was renamed from `mongo` so the + // contract canon matches what both boot hosts, the driver package and every + // URL scheme already said. Keyed by the CANONICAL id `resolveDriverId` + // returns, so a stored `driver: 'mongo'` still lands here through the alias. + mongodb: [['uri', 'url'], ['user', 'username']], }; /** @@ -3272,6 +3276,85 @@ const datasourceConfigDriverKeyAliases: MetadataConversion = { }, }; +/** + * `datasource.driver: 'mongo'` → `'mongodb'` (protocol 17, #6345). + * + * ## Why a stored value has to move at all + * + * `mongo` and `mongodb` have both been accepted spellings since #4410, and both + * still are — this conversion does NOT rescue a broken boot, and a deployment + * that never runs it keeps connecting exactly as before. What moved is the + * CANONICAL id: #6345's ruling renamed it to `mongodb`, the spelling both boot + * hosts, the driver package (`@objectstack/driver-mongodb`) and every URL scheme + * already used, so that the id which selects a driver and the id which selects + * its config contract are one string with no mapping layer between them. + * + * That rename is visible in data because the canonical id is PUBLISHED as + * `DRIVER_CATALOG.id` (`@objectstack/service-datasource`), documented as "used + * as `datasource.driver`" — it is literally what Studio's connection form writes + * into a datasource row. After the rename the form emits `mongodb`, while every + * row written before it carries `mongo`. Left alone, one deployment's datasource + * list holds two spellings of one driver, and any surface that matches a stored + * `driver` against the published catalog id (a form pre-selecting the current + * driver, a grouped list, an equality filter) silently fails to match the older + * rows. So the stored value converges here rather than each reader learning to + * accept both. + * + * ## Why D2 and not D3 + * + * There is a concrete stored value with a lossless, behaviour-preserving + * rewrite, which is the D2 test exactly. `mongo` and `mongodb` resolve to the + * same contract and build the same driver, before and after, so replaying this + * cannot change where any data lives — contrast + * {@link datasourceConfigDriverKeyAliases}, whose scope guard exists because + * rewriting a sqlite `path:` WOULD have moved a database. + * + * ## Why it stays on the LIVE load path + * + * Unlike the key-alias conversion above, `mongo` is not a spelling the authoring + * gate rejects — it is still a legal alias, deliberately, so that nothing breaks + * for a deployment that skipped the migration. There is therefore no loud + * rejection for a live-window entry to pre-empt, and every rehydration seam + * converging on one spelling is the whole point. + */ +const datasourceDriverMongoToMongodb: MetadataConversion = { + id: 'datasource-driver-mongo-to-mongodb', + toMajor: 17, + surface: 'datasource.driver', + summary: + "datasource driver id 'mongo' → 'mongodb' — the canonical id both boot hosts, the driver " + + 'package and the published DRIVER_CATALOG already used (#6345)', + apply(stack, emit) { + return mapDatasources(stack, (ds, path) => { + // Only the exact legacy canon, trimmed and lower-cased the same way + // `resolveDriverId` reads it. `mongodb` is already canonical, and any other + // spelling (a plugin driver, a typo) is not this conversion's business. + if (typeof ds.driver !== 'string' || ds.driver.trim().toLowerCase() !== 'mongo') return ds; + emit({ from: 'mongo', to: 'mongodb', path: `${path}.driver` }); + return { ...ds, driver: 'mongodb' }; + }); + }, + fixture: { + before: { + datasources: [ + { name: 'events', driver: 'mongo', config: { url: 'mongodb://mongo.internal:27017/events' } }, + // Already canonical — untouched, and emits nothing. + { name: 'audit', driver: 'mongodb', config: { url: 'mongodb://mongo.internal:27017/audit' } }, + // A different driver whose id merely CONTAINS the string: never rewritten. + { name: 'cache', driver: 'com.vendor.mongolike', config: { url: 'x://y' } }, + ], + }, + after: { + datasources: [ + { name: 'events', driver: 'mongodb', config: { url: 'mongodb://mongo.internal:27017/events' } }, + { name: 'audit', driver: 'mongodb', config: { url: 'mongodb://mongo.internal:27017/audit' } }, + { name: 'cache', driver: 'com.vendor.mongolike', config: { url: 'x://y' } }, + ], + }, + expectedNotices: 1, + }, +}; + /** * `script` node config — the four retired dispatch branches (protocol 17, #4343). * @@ -5329,6 +5412,12 @@ export const CONVERSIONS_BY_MAJOR: Readonly // one spelling per key (deleting the fallbacks without this replay would // silently move a sqlite `file:` row's data to `:memory:`). describe('stored datasource rows (datasource-config-driver-key-aliases, #4456)', () => { + // The fourth column is the driver id the stored pass SERVES, which differs + // from the stored one for exactly one row: #6345 renamed the canonical mongo + // id to `mongodb`, and `datasource-driver-mongo-to-mongodb` converges the + // stored spelling in the same replay. Both conversions run over one row here, + // which is the case worth pinning — the config-key rename is keyed by + // canonical driver id, so it has to keep landing for a row whose id is itself + // being renamed. it.each([ - ['sqlite', { file: './data/app.db' }, { filename: './data/app.db' }], - ['sqlite', { database: './data/app.db' }, { filename: './data/app.db' }], - ['postgres', { connectionString: 'postgresql://db/x', user: 'svc' }, { url: 'postgresql://db/x', username: 'svc' }], - ['mysql', { host: 'db', database: 'orders', user: 'svc' }, { host: 'db', database: 'orders', username: 'svc' }], - ['mongo', { uri: 'mongodb://db/x', user: 'svc' }, { url: 'mongodb://db/x', username: 'svc' }], - ])('serves a stored %s row with legacy config keys canonical', (driver, config, expected) => { + ['sqlite', { file: './data/app.db' }, { filename: './data/app.db' }, 'sqlite'], + ['sqlite', { database: './data/app.db' }, { filename: './data/app.db' }, 'sqlite'], + ['postgres', { connectionString: 'postgresql://db/x', user: 'svc' }, { url: 'postgresql://db/x', username: 'svc' }, 'postgres'], + ['mysql', { host: 'db', database: 'orders', user: 'svc' }, { host: 'db', database: 'orders', username: 'svc' }, 'mysql'], + ['mongo', { uri: 'mongodb://db/x', user: 'svc' }, { url: 'mongodb://db/x', username: 'svc' }, 'mongodb'], + ['mongodb', { uri: 'mongodb://db/x', user: 'svc' }, { url: 'mongodb://db/x', username: 'svc' }, 'mongodb'], + ])('serves a stored %s row with legacy config keys canonical', (driver, config, expected, servedDriver) => { const row = { name: 'legacy_ds', driver, config, origin: 'runtime' }; const out = applyConversionsToStoredItem('datasource', row) as { config: Record }; expect(out.config).toEqual(expected); - expect(out).toMatchObject({ name: 'legacy_ds', driver, origin: 'runtime' }); + expect(out).toMatchObject({ name: 'legacy_ds', driver: servedDriver, origin: 'runtime' }); }); it('leaves `database` alone for the drivers where it is canonical', () => { diff --git a/packages/spec/src/data/driver/config-registry.test.ts b/packages/spec/src/data/driver/config-registry.test.ts index 25698350b3..8beee07094 100644 --- a/packages/spec/src/data/driver/config-registry.test.ts +++ b/packages/spec/src/data/driver/config-registry.test.ts @@ -36,7 +36,9 @@ describe('driver config registry', () => { it('resolves case- and whitespace-insensitively', () => { expect(resolveDriverId(' PostgreSQL ')).toBe('postgres'); - expect(resolveDriverId('MongoDB')).toBe('mongo'); + // `mongodb`, not `mongo`, since #6345 renamed the canonical id. + expect(resolveDriverId('MongoDB')).toBe('mongodb'); + expect(resolveDriverId(' Mongo ')).toBe('mongodb'); }); /** diff --git a/packages/spec/src/data/driver/config-registry.zod.ts b/packages/spec/src/data/driver/config-registry.zod.ts index 86f9ee3fbf..f6e1f9f060 100644 --- a/packages/spec/src/data/driver/config-registry.zod.ts +++ b/packages/spec/src/data/driver/config-registry.zod.ts @@ -12,6 +12,7 @@ import { SqliteConfigSchema, SqliteWasmConfigSchema, } from './sqlite.zod'; +import { getTursoConfigJsonSchema, TursoConfigSchema } from './turso.zod'; /** * The driver-id → `datasource.config` shape registry (#4410). @@ -54,19 +55,145 @@ import { * and the id that selects its config schema could disagree — validating a `pg` * datasource against nothing while building it as postgres — so the factory now * imports {@link resolveDriverId} instead of keeping a second list. + * + * ## The HOSTS read it too, since #6345 — and that is what closed the last fork + * + * #4410 unified the two tables *inside* the metadata path. It did not reach the + * two BOOT HOSTS, which kept answering the same question differently about the + * same `OS_DATABASE_DRIVER`: the CLI (`packages/cli/src/utils/storage-driver.ts`) + * hand-wrote its spellings into `if` arms, and the standalone stack + * (`packages/runtime/src/standalone-stack.ts`) hand-wrote a zod enum of canonical + * spellings only. Measured on `main` before #6345: **10 of 21 spellings disagreed** + * — `OS_DATABASE_DRIVER=pg` booted under `os start` and was refused by + * `os migrate`, and `libsql` was accepted by the CLI alone. Both hosts now resolve + * through {@link resolveDatabaseDriverId}, so the vocabulary is this table and + * nothing else. + * + * ## Two faces, one table (the part a flat `Record` could not express) + * + * A driver id answers three separate questions, and #6345's measurement is that + * they are NOT the same set: + * + * 1. **Selection** — may an operator write this spelling as + * `OS_DATABASE_DRIVER` / `--database-driver` / `datasource.driver`? + * {@link DriverVocabularyEntry.aliases}. + * 2. **Config contract** — which schema validates that datasource's `config`? + * Every alias, selection or not, resolves one: {@link resolveDriverId}. + * 3. **Local default** — does selecting this driver with no URL have anything + * to fall back on? {@link DriverVocabularyEntry.hasLocalDefault}. `false` + * earns a typed refusal on both hosts instead of each side inventing a + * different wrong default (postgres got `url: undefined` and connected to + * the `pg` client's own localhost; mongodb got an invented + * `mongodb://localhost:27017/objectstack`; the standalone side handed all + * of them a `file:` DSN). + * + * The maintainer's #6345 ruling fixes the selection face as **the union of what + * the two hosts accepted the day the ruling was written**. `sql` and `wasm` are + * in it because the CLI accepted them; `sqlite3`, `better-sqlite3`, `mariadb` + * and `inmemory` are NOT, because neither host did — they are + * {@link DriverVocabularyEntry.contractOnlyAliases}, which keeps + * {@link resolveDriverId}'s answers for them byte-identical to what they were + * before this table existed while refusing to widen a boot flag nobody asked to + * widen. + * + * ## `mongo` → `mongodb`, and turso becoming a real builtin (#6345) + * + * The old canon was `mongo` while both hosts, the npm package + * (`@objectstack/driver-mongodb`) and every URL scheme said `mongodb`. The + * ruling renamed the canon rather than adding a mapping layer, so `id` here is + * simultaneously the selection canon and the contract canon — there is no + * `contractId` column, because after the rename it would have been `undefined` + * on every row, and a column that is always empty is the inert declaration this + * repo removes rather than ships. Stored `datasource.driver: 'mongo'` is + * converged by the ADR-0087 conversion `datasource-driver-mongo-to-mongodb`; + * `mongo` also stays an alias, so nothing that skipped the conversion breaks. + * + * `turso`/libSQL was the mirror image: both hosts dispatched it, and spec shipped + * no contract, so it resolved to `undefined` and its `config` was the one + * connection block on the platform with no gate. It now has + * {@link TursoConfigSchema} and is a full row. + */ + +/** + * One driver's whole vocabulary — the single row the three exported faces below + * are derived from, so a driver cannot be added to one and forgotten in another. + */ +export interface DriverVocabularyEntry { + /** + * The canonical id, for BOTH selection and config contract. `mongodb`, not + * `mongo` (#6345). + */ + readonly id: string; + /** + * Spellings an operator or author may SELECT this driver by, matched + * case-insensitively. Includes {@link id}. This is the face both boot hosts + * accept, and the ruling fixes it as the union of what they accepted before + * #6345 — never widened by accident. + */ + readonly aliases: readonly string[]; + /** + * Spellings that resolve this driver's CONFIG CONTRACT but are not offered as + * a selection, because no host has ever accepted them as one. Dropping them + * would silently un-validate a stored `driver: 'sqlite3'` datasource's config; + * promoting them to selections would widen a boot flag on no ruling. So they + * are kept, and kept apart. + */ + readonly contractOnlyAliases?: readonly string[]; + /** + * Can this driver be selected with no database URL at all? + * + * `true` for the three local engines (memory has no target to name; the two + * sqlite kinds fall back to `:memory:` / the unified default file). `false` + * for every kind whose target is a server or an endpoint — there is nothing + * truthful to guess, so both hosts refuse with a typed error naming what to + * set (#6345 fork 2). + */ + readonly hasLocalDefault: boolean; +} + +/** + * THE table. Everything else in this module is a projection of it. + * + * Row order is the order {@link BUILTIN_DRIVER_IDS} publishes, which is the + * order the pre-#6345 tuple used, plus `turso` appended. */ +const DRIVER_VOCABULARY = [ + { id: 'memory', aliases: ['memory', 'mingo', 'in-memory'], contractOnlyAliases: ['inmemory'], hasLocalDefault: true }, + { id: 'sqlite', aliases: ['sqlite', 'sql'], contractOnlyAliases: ['sqlite3', 'better-sqlite3'], hasLocalDefault: true }, + { id: 'sqlite-wasm', aliases: ['sqlite-wasm', 'wasm-sqlite', 'wasm'], hasLocalDefault: true }, + { id: 'postgres', aliases: ['postgres', 'postgresql', 'pg'], hasLocalDefault: false }, + { id: 'mysql', aliases: ['mysql', 'mysql2'], contractOnlyAliases: ['mariadb'], hasLocalDefault: false }, + { id: 'mongodb', aliases: ['mongodb', 'mongo'], hasLocalDefault: false }, + { id: 'turso', aliases: ['turso', 'libsql'], hasLocalDefault: false }, +] as const satisfies readonly DriverVocabularyEntry[]; -/** Canonical driver ids the platform ships a config contract for. */ -export const BUILTIN_DRIVER_IDS = [ - 'memory', - 'sqlite', - 'sqlite-wasm', - 'postgres', - 'mysql', - 'mongo', -] as const; +/** + * The same rows widened to the declared interface — what the runtime + * derivations below iterate. Kept separate from the `as const` literal because + * that literal is what carries the exact id union into {@link BuiltinDriverId}, + * while a widened view is what lets an optional column be READ uniformly across + * rows that do and do not declare it. + */ +const VOCABULARY_ROWS: readonly DriverVocabularyEntry[] = DRIVER_VOCABULARY; + +/** Tuple-preserving projection of a vocabulary table onto its ids. */ +type VocabularyIds = { -readonly [K in keyof T]: T[K]['id'] }; + +/** + * Canonical driver ids the platform ships a config contract for — and, since + * #6345, exactly the ids both boot hosts dispatch. + * + * Projected through {@link VocabularyIds} rather than a plain `.map()` so the + * published shape stays the same TUPLE it was before the table existed: a + * `readonly BuiltinDriverId[]` would have churned the api-surface baseline for + * no reason, and this file's whole contract with the rest of the repo is that + * only the intended ids moved. + */ +export const BUILTIN_DRIVER_IDS = DRIVER_VOCABULARY.map((entry) => entry.id) as VocabularyIds< + typeof DRIVER_VOCABULARY +>; -export type BuiltinDriverId = (typeof BUILTIN_DRIVER_IDS)[number]; +export type BuiltinDriverId = (typeof DRIVER_VOCABULARY)[number]['id']; /** * Accepted spellings of each canonical driver id, matched case-insensitively. @@ -75,26 +202,30 @@ export type BuiltinDriverId = (typeof BUILTIN_DRIVER_IDS)[number]; * `driver: 'postgres'` build the same driver, so they must resolve to the same * config contract. (Unknown-key tolerance inside `config` is a different * question, and the answer there is a rejection with a rename hint.) + * + * Covers BOTH faces — selection aliases and contract-only ones — because its job + * is "which contract judges this datasource's config", and a stored + * `driver: 'sqlite3'` has a sqlite config whether or not a boot flag would + * accept that spelling today. */ -export const DRIVER_ID_ALIASES: Readonly> = { - memory: 'memory', - inmemory: 'memory', - 'in-memory': 'memory', - mingo: 'memory', - sqlite: 'sqlite', - sqlite3: 'sqlite', - 'better-sqlite3': 'sqlite', - 'sqlite-wasm': 'sqlite-wasm', - 'wasm-sqlite': 'sqlite-wasm', - postgres: 'postgres', - postgresql: 'postgres', - pg: 'postgres', - mysql: 'mysql', - mysql2: 'mysql', - mariadb: 'mysql', - mongo: 'mongo', - mongodb: 'mongo', -}; +export const DRIVER_ID_ALIASES: Readonly> = Object.freeze( + Object.fromEntries( + VOCABULARY_ROWS.flatMap((entry) => + [...entry.aliases, ...(entry.contractOnlyAliases ?? [])].map((alias) => [alias, entry.id] as const), + ), + ) as Record, +); + +/** + * The spellings a BOOT HOST accepts as a driver selection — every selection + * alias of every builtin, sorted for a stable refusal message. + * + * Both hosts enumerate this in their "unsupported driver" errors, so the legal + * values an operator is shown cannot drift from the values that actually work. + */ +export const DATABASE_DRIVER_SELECTION_ALIASES: readonly string[] = Object.freeze( + VOCABULARY_ROWS.flatMap((entry) => [...entry.aliases]), +); /** * Resolve an authored `datasource.driver` onto its canonical id, or `undefined` @@ -105,6 +236,49 @@ export function resolveDriverId(driver: unknown): BuiltinDriverId | undefined { return DRIVER_ID_ALIASES[driver.trim().toLowerCase()]; } +/** Selection-face lookup, built once so {@link resolveDatabaseDriverId} is a hash hit. */ +const DATABASE_DRIVER_ALIASES: Readonly> = Object.freeze( + Object.fromEntries( + VOCABULARY_ROWS.flatMap((entry) => entry.aliases.map((alias) => [alias, entry.id] as const)), + ) as Record, +); + +/** + * Resolve an operator's DRIVER SELECTION (`OS_DATABASE_DRIVER`, + * `--database-driver`, `StandaloneStackConfig.databaseDriver`) onto its + * canonical id, or `undefined` when no builtin claims that spelling. + * + * Deliberately narrower than {@link resolveDriverId}: it refuses the + * contract-only aliases ({@link DriverVocabularyEntry.contractOnlyAliases}), + * because neither host accepted `OS_DATABASE_DRIVER=sqlite3` before #6345 and + * converging the two hosts is not a licence to widen the flag for both. + */ +export function resolveDatabaseDriverId(driver: unknown): BuiltinDriverId | undefined { + if (typeof driver !== 'string') return undefined; + return DATABASE_DRIVER_ALIASES[driver.trim().toLowerCase()]; +} + +/** Canonical id → whether it can be selected with no database URL at all. */ +const DRIVER_LOCAL_DEFAULT: Readonly> = Object.freeze( + Object.fromEntries( + VOCABULARY_ROWS.map((entry) => [entry.id, entry.hasLocalDefault] as const), + ) as Record, +); + +/** + * Does selecting this driver with no database URL have anything to fall back on? + * + * `false` means "refuse, do not guess" — the single fact both hosts consult so + * fork 2's typed refusal covers the same four kinds on both sides. An id this + * table does not know answers `true`: a plugin-contributed driver's defaults are + * not ours to judge, and refusing one on our own authority would be the mirror + * of the bug this exists to fix. + */ +export function driverHasLocalDefault(driver: unknown): boolean { + const id = resolveDriverId(driver); + return id ? DRIVER_LOCAL_DEFAULT[id] : true; +} + /** Canonical driver id → the schema its `datasource.config` must satisfy. */ export const DRIVER_CONFIG_SCHEMAS: Readonly> = { memory: MemoryConfigSchema, @@ -112,7 +286,8 @@ export const DRIVER_CONFIG_SCHEMAS: Readonly> 'sqlite-wasm': SqliteWasmConfigSchema, postgres: PostgresConfigSchema, mysql: MysqlConfigSchema, - mongo: MongoConfigSchema, + mongodb: MongoConfigSchema, + turso: TursoConfigSchema, }; /** @@ -132,7 +307,8 @@ const DRIVER_CONFIG_JSON_SCHEMAS: Readonly Record< 'sqlite-wasm': getSqliteWasmConfigJsonSchema, postgres: getPostgresConfigJsonSchema, mysql: getMysqlConfigJsonSchema, - mongo: getMongoConfigJsonSchema, + mongodb: getMongoConfigJsonSchema, + turso: getTursoConfigJsonSchema, }; /** diff --git a/packages/spec/src/data/driver/index.ts b/packages/spec/src/data/driver/index.ts index 16a8b904d0..eca601d1fb 100644 --- a/packages/spec/src/data/driver/index.ts +++ b/packages/spec/src/data/driver/index.ts @@ -18,3 +18,4 @@ export * from './mongo.zod'; export * from './mysql.zod'; export * from './postgres.zod'; export * from './sqlite.zod'; +export * from './turso.zod'; diff --git a/packages/spec/src/data/driver/mongo.test.ts b/packages/spec/src/data/driver/mongo.test.ts index e45bccc82b..9acee1d3e5 100644 --- a/packages/spec/src/data/driver/mongo.test.ts +++ b/packages/spec/src/data/driver/mongo.test.ts @@ -95,8 +95,12 @@ describe('MongoConfigSchema', () => { }); describe('MongoDriverSpec', () => { + // `mongodb` since #6345: the canonical driver id was renamed to the spelling + // both boot hosts and `@objectstack/driver-mongodb` already used, so driver + // selection and config-contract selection are one string. `mongo` stays an + // accepted ALIAS — pinned in `config-registry.test.ts`. it('should have correct id', () => { - expect(MongoDriverSpec.id).toBe('mongo'); + expect(MongoDriverSpec.id).toBe('mongodb'); }); it('should have correct label', () => { diff --git a/packages/spec/src/data/driver/mongo.zod.ts b/packages/spec/src/data/driver/mongo.zod.ts index 312596f33a..52230b8d2d 100644 --- a/packages/spec/src/data/driver/mongo.zod.ts +++ b/packages/spec/src/data/driver/mongo.zod.ts @@ -145,7 +145,11 @@ export const getMongoConfigJsonSchema = driverConfigJsonSchema(MongoConfigSchema * described. */ export const MongoDriverSpec = { - id: 'mongo', + // `mongodb`, not `mongo`, since #6345: the canonical driver id was renamed to + // the spelling both boot hosts, the `@objectstack/driver-mongodb` package and + // every URL scheme already used, so driver selection and config-contract + // selection are one string. `mongo` remains an accepted alias. + id: 'mongodb', label: 'MongoDB', description: 'Official MongoDB Driver for ObjectStack. Supports rich queries, aggregation, and atomic updates.', icon: 'database', diff --git a/packages/spec/src/data/driver/turso.test.ts b/packages/spec/src/data/driver/turso.test.ts new file mode 100644 index 0000000000..dc46e42ffc --- /dev/null +++ b/packages/spec/src/data/driver/turso.test.ts @@ -0,0 +1,98 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The turso/libSQL config contract (#6345). + * + * These assertions are what "`validateDriverConfig('turso')` flipped from + * `{ known: false }` to `{ known: true }`" MEANS in practice: before this file + * every one of the rejections below was an acceptance, because the platform had + * no shape to judge a libSQL `config` against. + */ + +import { describe, expect, it } from 'vitest'; + +import { DatasourceSchema } from '../datasource.zod'; +import { validateDriverConfig } from './config-registry.zod'; +import { TursoConfigSchema, TursoDriverSpec } from './turso.zod'; + +describe('TursoConfigSchema', () => { + it('accepts the shapes the driver actually connects with', () => { + for (const config of [ + { url: 'libsql://my-db.turso.io', authToken: 'jwt' }, + { url: 'file:./data/objectstack.db' }, + { url: ':memory:' }, + { url: 'file:./local.db', syncUrl: 'libsql://my-db.turso.io', sync: { intervalSeconds: 60 } }, + { url: 'libsql://x.turso.io', concurrency: 10, timeout: 5000, mode: 'remote' }, + ]) { + const result = TursoConfigSchema.safeParse(config); + expect(result.success, JSON.stringify(result.error?.issues)).toBe(true); + } + }); + + // `url` is the fact that makes `hasLocalDefault: false` true for turso, and + // the reason both boot hosts refuse a turso selection with no URL. + it('REQUIRES url — there is no libSQL endpoint to guess', () => { + expect(TursoConfigSchema.safeParse({}).success).toBe(false); + expect(TursoConfigSchema.safeParse({ authToken: 'jwt' }).success).toBe(false); + }); + + // The exact failure this contract was written for: `token` is the plausible + // spelling, `authToken` is the real one, and before #6345 the misspelling was + // accepted in silence and the connection attempted unauthenticated. + it('rejects `token` with a rename hint pointing at `authToken`', () => { + const result = TursoConfigSchema.safeParse({ url: 'libsql://x.turso.io', token: 'jwt' }); + expect(result.success).toBe(false); + expect(JSON.stringify(result.error?.issues)).toContain('authToken'); + }); + + it('rejects `sync` without `syncUrl` — on its own it configures nothing', () => { + const result = TursoConfigSchema.safeParse({ + url: 'file:./local.db', + sync: { intervalSeconds: 60 }, + }); + expect(result.success).toBe(false); + expect(JSON.stringify(result.error?.issues)).toContain('syncUrl'); + }); + + it('points a sqlite-style `filename` at `url` rather than accepting it', () => { + const result = TursoConfigSchema.safeParse({ filename: './data/objectstack.db' }); + expect(result.success).toBe(false); + expect(JSON.stringify(result.error?.issues)).toContain('url'); + }); +}); + +describe('turso is a known driver to the config registry now (#6345)', () => { + it('validateDriverConfig answers `known: true` for both spellings', () => { + expect(validateDriverConfig('turso', { url: 'libsql://x.turso.io' })) + .toEqual({ known: true, issues: [] }); + expect(validateDriverConfig('libsql', { url: 'libsql://x.turso.io' })) + .toEqual({ known: true, issues: [] }); + }); + + it('a bad turso config now produces ISSUES instead of `{ known: false }`', () => { + const result = validateDriverConfig('turso', { token: 'jwt' }); + expect(result.known).toBe(true); + expect(result.known && result.issues.length).toBeGreaterThan(0); + }); + + // The consumer that matters most: `DatasourceSchema` replays the driver-config + // parse onto its own issue list, so the flip reaches authored metadata. + it('DatasourceSchema now judges a turso datasource config', () => { + expect(DatasourceSchema.safeParse({ + name: 'edge', driver: 'turso', config: { url: 'libsql://x.turso.io', authToken: 'jwt' }, + }).success).toBe(true); + expect(DatasourceSchema.safeParse({ + name: 'edge', driver: 'turso', config: { token: 'jwt' }, + }).success).toBe(false); + }); +}); + +describe('TursoDriverSpec', () => { + it('publishes the canonical id and a projected config schema', () => { + expect(TursoDriverSpec.id).toBe('turso'); + expect(TursoDriverSpec.label).toBe('Turso / libSQL'); + const json = TursoDriverSpec.configSchema as { type?: string; properties?: Record }; + expect(json.type).toBe('object'); + expect(Object.keys(json.properties ?? {})).toContain('url'); + }); +}); diff --git a/packages/spec/src/data/driver/turso.zod.ts b/packages/spec/src/data/driver/turso.zod.ts new file mode 100644 index 0000000000..f444c152ba --- /dev/null +++ b/packages/spec/src/data/driver/turso.zod.ts @@ -0,0 +1,232 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { z } from 'zod'; + +import { lazySchema } from '../../shared/lazy-schema'; +import { strictObject } from '../../shared/strict-object'; +import type { DriverDefinition } from '../datasource.zod'; +import { + driverConfigJsonSchema, + READ_ONLY_BELONGS_ON_DATASOURCE, + SCHEMA_MODE_BELONGS_ON_DATASOURCE, +} from './common.zod'; + +/** + * Turso / libSQL Driver Protocol (#6345). + * + * ## Why this arrives late, and what it closes + * + * `turso` was the one connection block on the platform with NO gate. #4410 gave + * every built-in driver's `datasource.config` a contract and made + * `DatasourceSchema` parse against it, but turso was not a builtin: its driver + * ships in an OPTIONAL package (`@objectstack/driver-turso`, #5602), so + * `resolveDriverId('turso')` returned `undefined` and `validateDriverConfig` + * answered `{ known: false }` — "nothing to check against". Meanwhile both boot + * hosts dispatched `turso` for real. So a libSQL datasource could carry + * `{ token: … }` (the wrong key — it is `authToken`) and be accepted in silence, + * then connect unauthenticated, which is precisely the failure #4410 exists to + * end, surviving in the one driver #4410 could not see. + * + * The maintainer's #6345 ruling closes it by making turso a complete builtin + * rather than a permanent exception. Optionality of the PACKAGE is orthogonal to + * existence of the CONTRACT — `mongodb` and `sqlite-wasm` are optional installs + * too, and both have had a contract since #4410. + * + * ## What is declared here, and what is deliberately not + * + * The keys below are exactly the `TursoDriverConfig` fields the driver reads and + * that an author can express as data. Three are deliberately absent: + * + * - `client` (a pre-constructed `@libsql/client` instance) — a live object, not + * authorable metadata; declaring it would promise a JSON slot that can never + * be filled from a `sys_metadata` row. + * - `pool` — connection pooling is the datasource's own block, not driver + * config, exactly as on postgres/mysql/mongo. + * - `schemaMode` / `readOnly` — datasource-level, same as every other driver. + * + * ADR-0049 (enforce-or-remove) is why the list is drawn from what the driver + * READS rather than from what libSQL supports: a key declared here that no + * driver consults would be a new inert slot, and this file exists to close one. + */ + +// ========================================================================== +// 1. Connection Configuration +// ========================================================================== + +/** Transport mode, when an author pins it instead of letting the URL decide. */ +export const TursoTransportModeSchema = z.enum(['local', 'replica', 'remote']) + .describe('Force a transport mode instead of inferring it from `url`'); + +/** + * Author-facing shape of {@link TursoTransportModeSchema} (ADR-0122: the bare + * name is the AUTHOR state). + * + * No `TursoTransportModeParsed` beside it, deliberately: this is a plain + * `z.enum` with no `.default()`, no `.transform()` and no coercion, so + * `z.input` and `z.infer` are the same three literals. A second alias for an + * identical type would be a declaration that distinguishes nothing — the same + * call as leaving `contractId` off the driver vocabulary table. The two sibling + * enums in this directory settle it the same way: `SqliteWasmPersistMode` and + * `DriverSslToggle` are both bare `z.input` with no parsed twin. + */ +export type TursoTransportMode = z.input; + +export const TursoConfigSchema = lazySchema(() => strictObject( + { + surface: "this turso datasource's config", + // Semantic near-misses only — the spellings edit distance cannot reach. + // Case and underscore variants of a DECLARED key (`auth_token`, + // `encryption_key`, `sync_url`) are deliberately absent: the unknown-key + // probe already normalizes those onto the declared name, so entries for + // them would be alias rows that never fire, and two of them collided with + // each other on one probe (`auth_token`/`authtoken`, + // `sync_interval`/`syncinterval`) — caught by `alias-integrity.test.ts`. + aliases: { + uri: 'url', + connectionstring: 'url', + dsn: 'url', + database: 'url', + databaseurl: 'url', + token: 'authToken', + jwt: 'authToken', + syncinterval: 'sync', + }, + guidance: { + pool: + '`pool` is not driver config — libSQL sizes remote concurrency with `concurrency`, and ' + + "every driver's pooling block lives next to `driver` on the datasource itself.", + schemaMode: SCHEMA_MODE_BELONGS_ON_DATASOURCE, + readOnly: READ_ONLY_BELONGS_ON_DATASOURCE, + filename: + '`filename` is the sqlite spelling. A libSQL local database is still named by `url` — ' + + 'use `url: "file:./data/objectstack.db"`.', + }, + history: + 'Until #6345 a turso `config` was validated against nothing at all: the driver ships in an ' + + 'optional package, so it was not a builtin and `validateDriverConfig` answered ' + + '"{ known: false }" for it. A misspelled `token:` was therefore accepted in silence and ' + + 'the connection was attempted unauthenticated.', + }, + { + /** + * The libSQL endpoint or local file. REQUIRED — there is no default: this + * is the single fact that makes `hasLocalDefault: false` true for turso, + * and the reason both boot hosts refuse a driver selection with no URL + * rather than guessing one (#6345 fork 2). + */ + // The description names the SHAPES in words rather than pasting URL + // prefixes, matching how the postgres/mysql/mongo `url` keys describe + // themselves. Not only house style: a `.describe()` is rendered verbatim + // into `content/docs/references/`, and a scheme prefix pasted there ahead + // of an ellipsis puts a literal U+2026 where a host belongs — which the + // docs link checker resolves as an internationalised domain name, and + // fails on (caught by CI on this very key). Concrete example URLs belong + // in the TSDoc above the key, which the reference tables do not inline. + url: z.string().min(1) + .describe('libSQL endpoint or local file: a remote libsql/https Turso URL, a file path, or :memory:') + .meta({ title: 'Database URL' }), + + /** + * JWT for a remote database. Prefer `external.credentialsRef` — a + * datasource secret always wins over an inline value, exactly as on the + * SQL drivers' `password`. + */ + authToken: z.string().optional() + .describe('JWT auth token for a remote libSQL database (prefer external.credentialsRef)') + .meta({ title: 'Auth token', format: 'password' }), + + /** AES-256 key for the local database file; local/replica modes only. */ + encryptionKey: z.string().optional() + .describe('AES-256 encryption key for the local database file (local/replica modes)') + .meta({ title: 'Encryption key', format: 'password' }), + + /** Max concurrent requests to the remote database (replica/remote modes). */ + concurrency: z.number().int().positive().optional() + .describe('Maximum concurrent requests to the remote database') + .meta({ title: 'Concurrency' }), + + /** Remote sync endpoint that turns a local file into an embedded replica. */ + syncUrl: z.string().optional() + .describe('Remote sync URL for embedded-replica mode: a libsql or https Turso endpoint') + .meta({ title: 'Sync URL' }), + + /** + * Embedded-replica sync policy. Only meaningful beside {@link syncUrl}. + * + * `z.strictObject`, not a bare `z.object`: a nested block left at zod's + * default STRIP posture would silently drop `sync: { interval: 60 }` — the + * plausible misspelling of `intervalSeconds` — and the datasource would then + * sync on the 60-second default while the author believed they had set it. + * That is the exact silent acceptance this whole file exists to end, and it + * would have been a new strip site in the #4001 ledger rather than a + * closed one. + */ + sync: z.strictObject({ + intervalSeconds: z.number().int().nonnegative().optional() + .describe('Periodic sync interval in seconds (0 = manual only)'), + onConnect: z.boolean().optional().describe('Sync immediately on connect'), + }).optional().describe('Embedded-replica sync configuration (requires `syncUrl`)'), + + /** Operation timeout in ms for remote operations (replica/remote modes). */ + timeout: z.number().int().positive().optional() + .describe('Operation timeout in milliseconds for remote operations') + .meta({ title: 'Timeout (ms)' }), + + /** Pin the transport instead of inferring it from `url`. */ + mode: TursoTransportModeSchema.optional().meta({ title: 'Transport mode' }), + }) + .describe('Turso / libSQL Connection Configuration') + .superRefine((cfg, ctx) => { + // `sync` configures a replica that only exists when there is something to + // replicate FROM. Accepting it alone would be a declared key that changes + // nothing — the exact shape ADR-0049 asks us not to ship. + if (cfg.sync && !cfg.syncUrl) { + ctx.addIssue({ + code: 'custom', + path: ['sync'], + message: + '`sync` configures embedded-replica syncing, which only runs when `syncUrl` names the ' + + 'remote to replicate from. Set `syncUrl`, or remove `sync` — on its own it configures ' + + 'nothing.', + }); + } + })); + +/** + * JSON-Schema projection of {@link TursoConfigSchema}, memoized — what + * {@link TursoDriverSpec} publishes as its `configSchema`. + */ +export const getTursoConfigJsonSchema = driverConfigJsonSchema(TursoConfigSchema); + +// ========================================================================== +// 2. Driver Definition (Metadata) +// ========================================================================== + +/** + * The static definition of the Turso driver's default metadata, satisfying the + * `DriverDefinitionSchema` contract (proved by `turso.test.ts`). + * + * Not in `service-datasource`'s `DRIVER_CATALOG`: that list is CURATION — which + * drivers the Studio connection form offers — and turso stays out of it for the + * same reason `sqlite-wasm` does. Both are constructible and both have a + * contract; neither is something an admin picks from a dropdown, since turso + * additionally needs an optional package installed next to the server. + */ +export const TursoDriverSpec = { + id: 'turso', + label: 'Turso / libSQL', + description: + 'libSQL driver for ObjectStack — remote Turso databases, local files, and embedded replicas. ' + + 'Ships in the optional @objectstack/driver-turso package.', + icon: 'database', + get configSchema() { + return getTursoConfigJsonSchema(); + }, +} satisfies DriverDefinition; + +/** + * Derived Types + */ +export type TursoConfig = z.input; +/** Post-parse shape of {@link TursoConfig} — defaults applied, transforms run (ADR-0122). */ +export type TursoConfigParsed = z.infer; diff --git a/packages/spec/src/kernel/manifest.zod.ts b/packages/spec/src/kernel/manifest.zod.ts index 0444e4e4fb..30358b46d5 100644 --- a/packages/spec/src/kernel/manifest.zod.ts +++ b/packages/spec/src/kernel/manifest.zod.ts @@ -380,7 +380,7 @@ export const ManifestSchema = z.object({ * Enables connecting to new types of datasources. */ drivers: z.array(z.object({ - id: z.string().describe('Driver unique identifier (e.g. "postgres", "mongo")'), + id: z.string().describe('Driver unique identifier (e.g. "postgres", "mongodb")'), label: z.string().describe('Human readable name'), description: z.string().optional(), })).optional().describe('Driver contributions'), diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index c7ee59c170..b8097f46cf 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -570,6 +570,22 @@ const step17: MigrationStep = { + 'driver it is a canonical key and is untouched. Retired from the load path not for lying ' + 'but because the authoring gate already rejects the spellings loudly; the chain and the ' + 'stored-row replay are the seams that accept them.\n\n' + + 'Finishing the same datasource surface, the canonical driver id `mongo` is renamed to ' + + '`mongodb` (#6345). The two spellings have both been accepted since #4410 and both still ' + + 'are, so no boot breaks and no data moves — what changed is which one is CANONICAL, and ' + + 'that string is published as `DRIVER_CATALOG.id` and is what the Studio connection form ' + + 'writes into `datasource.driver`. Every row written before the rename therefore carries ' + + '`mongo` while the form now emits `mongodb`, leaving one deployment with two spellings of ' + + 'one driver and any reader that matches a stored driver against the published catalog id ' + + 'silently missing the older rows. The `datasource-driver-mongo-to-mongodb` conversion ' + + 'converges the stored value at every rehydration seam; it stays on the LIVE load path ' + + '(unlike the config-key aliases beside it) precisely because `mongo` is still legal — ' + + 'there is no loud rejection for it to pre-empt, and nothing to lose by converging early. ' + + 'The rename is what let the driver-selection id and the config-contract id become one ' + + 'string: `packages/spec`\'s driver vocabulary is now a single table both boot hosts read, ' + + 'which closed the last fork where `OS_DATABASE_DRIVER=pg` booted under `os start` and was ' + + 'refused by `os migrate`. `turso`/libSQL joins the same table with a real config contract, ' + + 'so a libSQL `config` is validated instead of waved through.\n\n' + 'The `script` flow node converges on its one real path (#4343). It had four ways to name ' + 'what it ran and only one of them ran anything: `config.actionType: \'email\' | \'slack\'` ' + 'were logger-backed stubs that wrote a line, reported success and delivered nothing under ' @@ -1204,6 +1220,7 @@ const step17: MigrationStep = { 'flow-node-wait-timeout-keys-removed', 'datasource-read-replicas-removed', 'datasource-config-driver-key-aliases', + 'datasource-driver-mongo-to-mongodb', 'flow-node-script-branch-keys-removed', 'object-managed-by-system-to-system-data', 'retry-policy-converged', diff --git a/packages/spec/src/type-alias-convention.pin.test.ts b/packages/spec/src/type-alias-convention.pin.test.ts index e1fb9948ef..1f947aeae6 100644 --- a/packages/spec/src/type-alias-convention.pin.test.ts +++ b/packages/spec/src/type-alias-convention.pin.test.ts @@ -138,6 +138,7 @@ import type * as M60 from './data/driver.zod.js'; import type * as M61 from './data/driver/common.zod.js'; import type * as M62 from './data/driver/memory.zod.js'; import type * as M63 from './data/driver/sqlite.zod.js'; +import type * as M181 from './data/driver/turso.zod.js'; import type * as M64 from './data/external-lookup.zod.js'; import type * as M65 from './data/feed.zod.js'; import type * as M66 from './data/field.zod.js'; @@ -264,7 +265,7 @@ import type * as M167 from './ui/view.zod.js'; import type * as M170 from './ui/component.zod.js'; // --------------------------------------------------------------------------- -// 823 isomorphic aliases: `z.input` === `z.infer`, so no `XParsed` is declared. +// 824 isomorphic aliases: `z.input` === `z.infer`, so no `XParsed` is declared. // // That number is machine-checked, not hand-kept. The runtime companion at the // bottom of this file recomputes the pin count from the source and asserts that @@ -737,6 +738,9 @@ export type Iso333 = Assert, z.infer< typeof M63.SqliteWasmPersistModeSchema > >>; +// data/driver/turso.zod.ts +export type Iso834 = Assert, z.infer< typeof M181.TursoTransportModeSchema > >>; + // data/external-lookup.zod.ts export type Iso335 = Assert, z.infer< typeof M64.ExternalDataSourceSchema > >>; @@ -1617,7 +1621,7 @@ describe('ADR-0122 type-alias convention', () => { // this title and the section header above the pin list — are now asserted // against the recomputed count below, so neither can go stale without a red // test naming it. - it('still declares all 823 isomorphic pins', () => { + it('still declares all 824 isomorphic pins', () => { // The truth of each pin is proved by tsc, not here — an `Assert>` // that stops holding is a compile error with the alias named. What tsc // cannot notice is a pin that was DELETED: removing the assertion removes @@ -1755,7 +1759,7 @@ describe('ADR-0122 type-alias convention', () => { // first. The file, not the history, is the operand. const self = readFileSync(fileURLToPath(import.meta.url), 'utf8'); const pins = self.match(/^export type Iso\d+ = Assert