From 3c7f0b7a4effdeb85f4a74fd4125c4fe3241b874 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 01:47:41 +0000 Subject: [PATCH 01/16] =?UTF-8?q?wip(spec):=20one=20driver=20vocabulary=20?= =?UTF-8?q?table;=20mongo=E2=86=92mongodb;=20turso=20contract?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/spec/src/conversions/registry.ts | 91 ++++++- .../src/data/driver/config-registry.zod.ts | 238 +++++++++++++++--- packages/spec/src/data/driver/index.ts | 1 + packages/spec/src/data/driver/turso.zod.ts | 197 +++++++++++++++ packages/spec/src/migrations/registry.ts | 17 ++ 5 files changed, 512 insertions(+), 32 deletions(-) create mode 100644 packages/spec/src/data/driver/turso.zod.ts diff --git a/packages/spec/src/conversions/registry.ts b/packages/spec/src/conversions/registry.ts index fa4607ee94..398c892b08 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). * @@ -5194,6 +5277,12 @@ export const CONVERSIONS_BY_MAJOR: Readonly = { -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/turso.zod.ts b/packages/spec/src/data/driver/turso.zod.ts new file mode 100644 index 0000000000..7280c0306e --- /dev/null +++ b/packages/spec/src/data/driver/turso.zod.ts @@ -0,0 +1,197 @@ +// 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`'); + +export const TursoConfigSchema = lazySchema(() => strictObject( + { + surface: "this turso datasource's config", + aliases: { + uri: 'url', + connectionstring: 'url', + dsn: 'url', + database: 'url', + databaseurl: 'url', + token: 'authToken', + auth_token: 'authToken', + authtoken: 'authToken', + jwt: 'authToken', + encryption_key: 'encryptionKey', + sync_url: 'syncUrl', + syncinterval: 'sync', + sync_interval: '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). + */ + url: z.string().min(1).describe('libSQL endpoint or local file (libsql://…, https://…, file:…, :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 (libsql:// or https://)') + .meta({ title: 'Sync URL' }), + + /** Embedded-replica sync policy. Only meaningful beside {@link syncUrl}. */ + sync: z.object({ + 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/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 95363245d2..c64f7c6682 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 ' @@ -1174,6 +1190,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', From 6e708b799c11e5bf5e93060d3a03fac4eebb1e74 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 02:01:40 +0000 Subject: [PATCH 02/16] wip(cli,runtime): both hosts read the shared driver vocabulary; fork-2 refusals --- packages/cli/src/utils/storage-driver.ts | 151 ++++++++++++++---- .../runtime/src/resolve-project-database.ts | 20 ++- packages/runtime/src/standalone-stack.ts | 118 ++++++++++++-- packages/spec/authorable-surface/data.json | 10 +- packages/spec/json-schema.manifest/data.json | 2 + packages/spec/src/data/driver/mongo.zod.ts | 6 +- 6 files changed, 251 insertions(+), 56 deletions(-) diff --git a/packages/cli/src/utils/storage-driver.ts b/packages/cli/src/utils/storage-driver.ts index 6e58fa6dc7..5914c4cd07 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 @@ -230,8 +285,48 @@ 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.', + ); + } + + // 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 +336,7 @@ export function resolveStorageDefinition( }; } - if (driverType === 'sqlite' || driverType === 'sql') { + if (kind === 'sqlite') { const filePath = (databaseUrl ?? ':memory:') .replace(/^file:/, '') .replace(/^sqlite:/, '') @@ -259,7 +354,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 +370,7 @@ export function resolveStorageDefinition( }; } - if (driverType === 'postgres' || driverType === 'postgresql' || driverType === 'pg') { + if (kind === 'postgres') { return { driverId: 'postgres', config: { url: databaseUrl, ...autoMigrate }, @@ -285,7 +380,7 @@ export function resolveStorageDefinition( }; } - if (driverType === 'mysql' || driverType === 'mysql2') { + if (kind === 'mysql') { return { driverId: 'mysql', config: { url: databaseUrl, ...autoMigrate }, @@ -307,19 +402,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 +417,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 +446,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/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/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', From e7776118ad6579fb338d6fbeda44c4dfb4d0759c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 02:04:00 +0000 Subject: [PATCH 03/16] wip(service-datasource): mongodb rename, turso arm, exhaustive factory dispatch --- .../src/datasource-pool-support.ts | 15 ++- .../src/default-datasource-driver-factory.ts | 110 ++++++++++++++++-- .../service-datasource/src/driver-catalog.ts | 11 +- packages/spec/src/kernel/manifest.zod.ts | 2 +- 4 files changed, 123 insertions(+), 15 deletions(-) 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/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'), From b1ea158e18143920c28e7f27c5aa5b7ae8253793 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 02:06:26 +0000 Subject: [PATCH 04/16] test(cli): cross-host driver vocabulary parity pin + 8-cell fork-2 matrix --- .../utils/driver-vocabulary-parity.test.ts | 248 ++++++++++++++++++ 1 file changed, 248 insertions(+) create mode 100644 packages/cli/src/utils/driver-vocabulary-parity.test.ts 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..47deb866d3 --- /dev/null +++ b/packages/cli/src/utils/driver-vocabulary-parity.test.ts @@ -0,0 +1,248 @@ +// 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. */ +function cliVerdict(spelling: string, databaseUrl: string | undefined): Verdict { + try { + const kind = resolveDriverType(spelling, databaseUrl); + const definition = resolveStorageDefinition(kind, { databaseUrl, isDev: false }); + 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`', + (spelling) => { + expect(cliVerdict(spelling, undefined).accepted, `os start 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); + }); +}); From 35022d709edb3ec300bab2628ead138ae54ce268 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 02:07:07 +0000 Subject: [PATCH 05/16] test(cli): update the two pins fork 1/2 deliberately flip --- packages/cli/src/utils/storage-driver.test.ts | 49 +++++++++++++++++-- 1 file changed, 44 insertions(+), 5 deletions(-) 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'); }); }); From c7368c9088c803b24edb0376b988d91ebe49baf2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 02:09:38 +0000 Subject: [PATCH 06/16] =?UTF-8?q?test(spec):=20turso=20contract,=20mongo?= =?UTF-8?q?=E2=86=92mongodb=20migration=20proof,=20rename=20pins?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spec/src/conversions/conversions.test.ts | 77 +++++++++++++++ packages/spec/src/conversions/stored.test.ts | 22 +++-- .../src/data/driver/config-registry.test.ts | 4 +- packages/spec/src/data/driver/mongo.test.ts | 6 +- packages/spec/src/data/driver/turso.test.ts | 98 +++++++++++++++++++ 5 files changed, 198 insertions(+), 9 deletions(-) create mode 100644 packages/spec/src/data/driver/turso.test.ts diff --git a/packages/spec/src/conversions/conversions.test.ts b/packages/spec/src/conversions/conversions.test.ts index 721072c6ff..961bede15e 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 } 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/stored.test.ts b/packages/spec/src/conversions/stored.test.ts index 5c0a330705..1374ad2d75 100644 --- a/packages/spec/src/conversions/stored.test.ts +++ b/packages/spec/src/conversions/stored.test.ts @@ -98,17 +98,25 @@ describe('applyConversionsToStoredItem (stored sys_metadata rows, #3903)', () => // 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/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/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'); + }); +}); From 4bdafa21e80159060153525846b3b11ee62603e8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 02:29:34 +0000 Subject: [PATCH 07/16] chore(spec): regenerate api-surface, spec-changes, upgrade guide; liveness evidence --- docs/protocol-upgrade-guide.md | 3 +++ packages/spec/api-surface/data.json | 10 ++++++++++ packages/spec/liveness/datasource.json | 4 ++-- packages/spec/spec-changes.json | 12 ++++++++++++ 4 files changed, 27 insertions(+), 2 deletions(-) diff --git a/docs/protocol-upgrade-guide.md b/docs/protocol-upgrade-guide.md index 154a8e1861..3d6f82579f 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. @@ -261,6 +263,7 @@ One entry in this step is not a removal at all but a SECURE-DEFAULT FLIP, the sh | `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/spec/api-surface/data.json b/packages/spec/api-surface/data.json index a3293b4608..60232bd19a 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,11 @@ "TimeUpdateInterval (type)", "TitleEligibleFieldDef (interface)", "TransformType (type)", + "TursoConfig (type)", + "TursoConfigParsed (type)", + "TursoConfigSchema (const)", + "TursoDriverSpec (const)", + "TursoTransportModeSchema (const)", "UniqueScope (type)", "UniqueScopeSchema (const)", "UnknownAuthoringKeyFinding (interface)", @@ -591,6 +598,7 @@ "deriveRecordFlowSurface (function)", "deriveRecordSurface (function)", "driverConfigJsonSchema (function)", + "driverHasLocalDefault (function)", "effectiveOperationsArray (function)", "emptyGroupValueFor (function)", "fieldForm (const)", @@ -604,6 +612,7 @@ "getPostgresConfigJsonSchema (const)", "getSqliteConfigJsonSchema (const)", "getSqliteWasmConfigJsonSchema (const)", + "getTursoConfigJsonSchema (const)", "hasDynamicTokens (function)", "hookForm (const)", "isApiOperationAllowed (function)", @@ -644,6 +653,7 @@ "renderAutonumber (function)", "resolveBulkPerRowHookBudget (function)", "resolveCrudAffordances (function)", + "resolveDatabaseDriverId (function)", "resolveDisplayField (function)", "resolveDriverId (function)", "resolveEffectiveApiMethods (function)", 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 3bd80030b9..20a0aa170f 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)", @@ -1119,6 +1125,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)", From 970712c16aa63664b9a5d590f0a91988b426a3ce Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 02:34:53 +0000 Subject: [PATCH 08/16] chore: changesets for the driver-vocabulary convergence --- .../driver-vocabulary-single-table-hosts.md | 75 +++++++++++++++ ...abulary-single-table-service-datasource.md | 57 ++++++++++++ .../driver-vocabulary-single-table-spec.md | 92 +++++++++++++++++++ 3 files changed, 224 insertions(+) create mode 100644 .changeset/driver-vocabulary-single-table-hosts.md create mode 100644 .changeset/driver-vocabulary-single-table-service-datasource.md create mode 100644 .changeset/driver-vocabulary-single-table-spec.md diff --git a/.changeset/driver-vocabulary-single-table-hosts.md b/.changeset/driver-vocabulary-single-table-hosts.md new file mode 100644 index 0000000000..627cf67521 --- /dev/null +++ b/.changeset/driver-vocabulary-single-table-hosts.md @@ -0,0 +1,75 @@ +--- +"@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`. + + 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. + + From 9a16e2a45c119a45d3b9076cf8fce4dd3d6d98fc Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 02:35:53 +0000 Subject: [PATCH 09/16] =?UTF-8?q?chore:=20register=20the=20mongo=E2=86=92m?= =?UTF-8?q?ongodb=20conversion=20on=20the=20hosts=20changeset?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/driver-vocabulary-single-table-hosts.md | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/.changeset/driver-vocabulary-single-table-hosts.md b/.changeset/driver-vocabulary-single-table-hosts.md index 627cf67521..31116dc2d0 100644 --- a/.changeset/driver-vocabulary-single-table-hosts.md +++ b/.changeset/driver-vocabulary-single-table-hosts.md @@ -72,4 +72,14 @@ 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. + + From dfad5268cdec648c06ce572ea49c43ff821296f5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 02:45:24 +0000 Subject: [PATCH 10/16] =?UTF-8?q?test(cli):=20parity=20REFUSE=20cases=20ru?= =?UTF-8?q?n=20dev=20AND=20prod=20=E2=80=94=20reverse=20verification=20fou?= =?UTF-8?q?nd=20the=20gap?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../utils/driver-vocabulary-parity.test.ts | 24 +++++++++++++++---- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/packages/cli/src/utils/driver-vocabulary-parity.test.ts b/packages/cli/src/utils/driver-vocabulary-parity.test.ts index 47deb866d3..013872dcc4 100644 --- a/packages/cli/src/utils/driver-vocabulary-parity.test.ts +++ b/packages/cli/src/utils/driver-vocabulary-parity.test.ts @@ -61,11 +61,20 @@ const CONTRACT_ONLY_SPELLINGS = ['sqlite3', 'better-sqlite3', 'mariadb', 'inmemo type Verdict = { accepted: true; driverId: string } | { accepted: false }; -/** The `os start` verdict for one spelling — driving serve.ts's own two calls. */ -function cliVerdict(spelling: string, databaseUrl: string | undefined): Verdict { +/** + * 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: false }); + const definition = resolveStorageDefinition(kind, { databaseUrl, isDev }); return definition ? { accepted: true, driverId: definition.driverId } : { accepted: false }; } catch { return { accepted: false }; @@ -128,9 +137,14 @@ describe('driver vocabulary parity: `os start` and `os migrate` answer alike (#6 // 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`', + 'both hosts REFUSE `%s`, in dev AND in prod', (spelling) => { - expect(cliVerdict(spelling, undefined).accepted, `os start accepted '${spelling}'`).toBe(false); + 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); }, ); From e0fe76c049d9f673c245db225c9d2dc75e74df96 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 02:47:30 +0000 Subject: [PATCH 11/16] test(service-datasource): rename-consequence pins for mongodb --- .../src/__tests__/datasource-pool-support.test.ts | 6 +++++- .../src/__tests__/driver-catalog.test.ts | 7 ++++++- 2 files changed, 11 insertions(+), 2 deletions(-) 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']); }); /** From 70cbcc031df5b41092d68596ab9f39168adae13d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 03:24:19 +0000 Subject: [PATCH 12/16] chore(spec): turso schema strictness ledger row, TursoTransportMode alias, regenerated artifacts --- content/docs/references/data/driver-turso.mdx | 92 +++++++++++++++++++ content/docs/references/data/index.mdx | 1 + content/docs/references/data/meta.json | 1 + content/docs/references/index.mdx | 9 +- ...07-unknown-key-strictness-ledger.counts.md | 13 +-- .../2026-07-unknown-key-strictness-ledger.md | 1 + packages/spec/src/data/driver/turso.zod.ts | 17 +++- skills/objectstack-data/references/_index.md | 1 + .../objectstack-platform/references/_index.md | 1 + 9 files changed, 124 insertions(+), 12 deletions(-) create mode 100644 content/docs/references/data/driver-turso.mdx diff --git a/content/docs/references/data/driver-turso.mdx b/content/docs/references/data/driver-turso.mdx new file mode 100644 index 0000000000..86f899a837 --- /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 } 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 (libsql://…, https://…, file:…, :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 (libsql:// or https://) | +| **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 1a3d2244de..d0bf441709 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/packages/spec/src/data/driver/turso.zod.ts b/packages/spec/src/data/driver/turso.zod.ts index 7280c0306e..3d00e4c64a 100644 --- a/packages/spec/src/data/driver/turso.zod.ts +++ b/packages/spec/src/data/driver/turso.zod.ts @@ -57,6 +57,9 @@ import { export const TursoTransportModeSchema = z.enum(['local', 'replica', 'remote']) .describe('Force a transport mode instead of inferring it from `url`'); +/** Post-parse shape of {@link TursoTransportModeSchema}. */ +export type TursoTransportMode = z.infer; + export const TursoConfigSchema = lazySchema(() => strictObject( { surface: "this turso datasource's config", @@ -125,8 +128,18 @@ export const TursoConfigSchema = lazySchema(() => strictObject( .describe('Remote sync URL for embedded-replica mode (libsql:// or https://)') .meta({ title: 'Sync URL' }), - /** Embedded-replica sync policy. Only meaningful beside {@link syncUrl}. */ - sync: z.object({ + /** + * 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'), diff --git a/skills/objectstack-data/references/_index.md b/skills/objectstack-data/references/_index.md index 55276d0896..fb0a57be17 100644 --- a/skills/objectstack-data/references/_index.md +++ b/skills/objectstack-data/references/_index.md @@ -28,6 +28,7 @@ from `node_modules` — there is no local copy in the skill bundle. - `node_modules/@objectstack/spec/src/data/driver/mysql.zod.ts` — MySQL / MariaDB driver configuration — the `config` slot of a `datasource` - `node_modules/@objectstack/spec/src/data/driver/postgres.zod.ts` — PostgreSQL driver configuration — the `config` slot of a `datasource` whose - `node_modules/@objectstack/spec/src/data/driver/sqlite.zod.ts` — SQLite driver configuration — the `config` slot of a `datasource` whose +- `node_modules/@objectstack/spec/src/data/driver/turso.zod.ts` — Turso / libSQL Driver Protocol (#6345). - `node_modules/@objectstack/spec/src/data/filter.zod.ts` — Unified Query DSL Specification - `node_modules/@objectstack/spec/src/data/hook-body.zod.ts` — Capability tokens a script body may request. - `node_modules/@objectstack/spec/src/data/query.zod.ts` — Sort Node diff --git a/skills/objectstack-platform/references/_index.md b/skills/objectstack-platform/references/_index.md index 2547e517eb..03b7f573e1 100644 --- a/skills/objectstack-platform/references/_index.md +++ b/skills/objectstack-platform/references/_index.md @@ -28,6 +28,7 @@ from `node_modules` — there is no local copy in the skill bundle. - `node_modules/@objectstack/spec/src/data/driver/mysql.zod.ts` — MySQL / MariaDB driver configuration — the `config` slot of a `datasource` - `node_modules/@objectstack/spec/src/data/driver/postgres.zod.ts` — PostgreSQL driver configuration — the `config` slot of a `datasource` whose - `node_modules/@objectstack/spec/src/data/driver/sqlite.zod.ts` — SQLite driver configuration — the `config` slot of a `datasource` whose +- `node_modules/@objectstack/spec/src/data/driver/turso.zod.ts` — Turso / libSQL Driver Protocol (#6345). - `node_modules/@objectstack/spec/src/data/field.zod.ts` — Field Type Enum - `node_modules/@objectstack/spec/src/data/filter.zod.ts` — Unified Query DSL Specification - `node_modules/@objectstack/spec/src/data/hook-body.zod.ts` — Capability tokens a script body may request. From c0bfb659eea28f72752892c029195e38a5719f23 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 03:30:51 +0000 Subject: [PATCH 13/16] chore(spec): regenerate spec-changes/upgrade-guide from the MERGED source --- docs/protocol-upgrade-guide.md | 13 +++++++++---- packages/spec/spec-changes.json | 22 ++++++++++++++++++---- 2 files changed, 27 insertions(+), 8 deletions(-) diff --git a/docs/protocol-upgrade-guide.md b/docs/protocol-upgrade-guide.md index 3d6f82579f..7bc75c60ea 100644 --- a/docs/protocol-upgrade-guide.md +++ b/docs/protocol-upgrade-guide.md @@ -224,6 +224,8 @@ Finally it narrows the aggregation vocabulary: `array_agg` and `string_agg` leav One entry in this step is not a removal at all but a SECURE-DEFAULT FLIP, the shape protocol 12 last used for `api.requireAuth`: an omitted `ActionDescriptor.resumeAuthority` resolves to `'service'` instead of `'any'`, so a pausing node type that never states who may continue its pauses is refused on the generic resume route rather than open to it (#5561, ADR-0044's 2026-07-28 amendment). Nothing is removed and no metadata shape changes — the field has been optional since step one of the same issue — so tsc reports nothing and only the MEANING of silence moved. That is exactly why it needs a ledger entry: a third-party plugin author has no compile error to discover it with, and the one-line prescription (declare `resumeAuthority` on the descriptor) has to arrive before a user meets a run that will not continue. +The same descriptor loses a key in this step, and the pairing is the point (#6748, ADR-0049). `ActionDescriptor.isAsync` and `ActionDescriptor.supportsPause` were two spellings of one capability — "this node type can suspend the run" — and #6667 split them by evidence rather than by preference: `supportsPause` took the ENFORCE leg (the engine now refuses a suspension the descriptor never declared, at the one seam every suspension passes through), and `isAsync` takes the REMOVE leg, because a fresh three-repo measurement found zero readers and no consumer it could grow into. What makes the duplicate worse than an ordinary inert key is that five shipped descriptors WROTE it, so the platform itself modelled a declaration that decided nothing — and a plugin author copying `screen` (which declared BOTH) had no way to tell which of the two the runtime honoured. It is tombstoned rather than deleted, so the answer arrives as a rejection carrying the fix; and because a descriptor lives in executor TypeScript rather than in stored metadata, its prescription is a semantic entry below rather than a conversion `os migrate meta` could replay. + ### Mechanical (applied for you) | Conversion | Surface | Change | Load window | @@ -349,7 +351,7 @@ One entry in this step is not a removal at all but a SECURE-DEFAULT FLIP, the sh - Why not automatic: The five schemas declared the "Dynamic Loading" capability — runtime load / unload / reload of plugins without a kernel restart, with sandboxing, integrity hashes, drain strategies and dependent-cascade policy — and NOTHING implemented it. A bare-name scan of objectstack, cloud and objectui found zero references outside this package's own declaration, its unit tests and the generated artifacts: no runtime ever received a `DynamicLoadRequest`, performed a load/unload, or produced a `DynamicPluginResult`. That is the ADR-0049 false-compliance shape at its most inviting to an AI author (ADR-0033), who reads `DynamicLoadRequestSchema` in the published IDE bundle as proof the platform hot-loads plugins and constructs a request that parses clean and is received by nobody (#3950: an exported schema with no consumer is read as a capability). The #3896 follow-up removed this module's discovery/sandbox config island and left these five in place explicitly — "operation contracts, not security promises; the enforce-or-remove call on them is a design decision rather than a correction" — but that suspension lived only in a changeset paragraph with no issue carrying it. #4834 is that decision, answered REMOVE. `experimental` was considered and rejected: it is only `.describe()` prose and cannot stop an import, the weakest of the three ADR-0049 channels. None of the five is stored metadata — they are root request/result payload shapes embedded in no parent schema and parsed against no metadata document — so no `sys_metadata` row can carry one and there is no source for the D2 chain to rewrite; this entry is the D3 record. The removal also subsumes the kernel half of `plugin-activation-events-retired` (#4657): that tombstone goes with the shape that carried it. ADR-0049, #4834. - Done when: No code imports `DynamicLoadRequestSchema`, `DynamicUnloadRequestSchema`, `DynamicPluginResultSchema`, `PluginSourceSchema`, `DynamicPluginOperationSchema` or any of their type aliases (`DynamicLoadRequest`, `DynamicUnloadRequest`, `DynamicPluginResult`, `PluginSource`, `DynamicPluginOperation`, `DynamicLoadRequestInput`, `DynamicUnloadRequestInput`) from `@objectstack/spec` or `@objectstack/spec/kernel` — every one is TS2305 after upgrade, on every public entry (pinned by symbol identity in `plugin-runtime-retirement.test.ts`). Nothing regresses at runtime, because nothing called anything: a caller that believed it was hot-loading a plugin was already only building an object. Boot-time composition through `defineStack` is unchanged. - **`declarative-apis-endpoints-live`** — `stack.apis[] (every declared ApiEndpoint — REVIEW REQUIRED BEFORE UPGRADING)` → the same declarations, re-read as LIVE HTTP routes: `path` moved under `/api/v1/apps//`, and every entry that declares `authRequired: false` re-confirmed as an intentionally anonymous endpoint carrying `rateLimit: { enabled: true, … }` - - Why not automatic: This is the one protocol-17 entry that turns metadata ON rather than off, so read it as a SECURITY review item and not as a rename. Before 17 the declarative endpoint surface executed NOTHING: no route was mounted for a declared `path`, no matcher existed, and every key — `authRequired` included — parsed green and gated nothing (#4936, which refused a non-empty `apis:` outright for exactly that reason). Protocol 17 ships the executor (#5040) and narrows that refusal to a per-endpoint publish gate: an endpoint that PASSES the gate is mounted and serves real traffic as soon as the stack is published. So an `apis:` block written against an older major — or one restored from a pre-#4936 source, or authored from a doc that predates the refusal — changes meaning without changing a byte: what used to be inert documentation becomes an execution entry point into the data and automation pipelines. Nothing about that transition can be applied mechanically, because the judgment it needs is "did the author of this endpoint mean for the internet to reach it?" — and the one key where a wrong answer is unrecoverable is `authRequired`. Its schema default is `true`, so an omission is SAFE and needs no review; an EXPLICIT `authRequired: false` is the only thing that opens anonymous access, and under ADR-0121 D6 it now also requires an armed `rateLimit` (`enabled: true` — the key defaults to `false`, so a budget written without it meters nothing) or the stack refuses to publish. Grep every `apis:` entry for `authRequired: false` before you upgrade, delete the ones that were never meant to be public, and arm a budget on the ones that were. The path move is the mechanical-looking half and is still yours: ADR-0121 D1/D2 confine a declared path to your own namespace carve-out (`/api/v1/apps//…`), the namespace comes from an explicit `manifest.namespace` with no derivation fallback, and the subpath is the only part you name — rewriting it for you would silently change a URL third parties call. + - Why not automatic: This is the one protocol-17 entry that turns metadata ON rather than off, so read it as a SECURITY review item and not as a rename. Before 17 the declarative endpoint surface executed NOTHING: no route was mounted for a declared `path`, no matcher existed, and every key — `authRequired` included — parsed green and gated nothing (#4936, which refused a non-empty `apis:` outright for exactly that reason). Protocol 17 ships the executor (#5040) and narrows that refusal to a per-endpoint publish gate: an endpoint that PASSES the gate is mounted and serves real traffic as soon as the stack is published. So an `apis:` block written against an older major — or one restored from a pre-#4936 source, or authored from a doc that predates the refusal — changes meaning without changing a byte: what used to be inert documentation becomes an execution entry point into the data and automation pipelines. Nothing about that transition can be applied mechanically, because the judgment it needs is "did the author of this endpoint mean for the internet to reach it?" — and the one key where a wrong answer is unrecoverable is `authRequired`. Its schema default is `true`, so an omission is SAFE and needs no review; an EXPLICIT `authRequired: false` is the only thing that opens anonymous access, and under ADR-0121 D6 it now also requires an armed `rateLimit` (`enabled: true` — the key defaults to `false`, so a budget written without it meters nothing) or the stack refuses to publish. ⚠️ If you author endpoints in TypeScript, annotate them with `ApiEndpoint` — the AUTHOR state — so that omitting `authRequired` compiles: `const e: ApiEndpoint = { name, path, method, type, target }` is legal and is the safe shape this paragraph prescribes. `ApiEndpointParsed` is the POST-parse type (defaults materialized, ADR-0122), where `authRequired` is required — annotating a declaration with it forces you to write the key out, and being made to think about a key whose only unrecoverable value is `false` is the one thing this entry is trying to avoid (#5227). Hold a parse RESULT with `ApiEndpointParsed`; write declarations as `ApiEndpoint`. Grep every `apis:` entry for `authRequired: false` before you upgrade, delete the ones that were never meant to be public, and arm a budget on the ones that were. The path move is the mechanical-looking half and is still yours: ADR-0121 D1/D2 confine a declared path to your own namespace carve-out (`/api/v1/apps//…`), the namespace comes from an explicit `manifest.namespace` with no derivation fallback, and the subpath is the only part you name — rewriting it for you would silently change a URL third parties call. - Done when: You have READ every entry of every `apis:` block, not just the ones that fail to publish. Concretely: (1) each declared `path` is `/api/v1/apps//` and the stack declares that `manifest.namespace` explicitly; (2) every entry declaring `authRequired: false` is one you INTEND to be reachable without a session, and each carries `rateLimit: { enabled: true, windowMs, maxRequests }` — entries that were not intended to be anonymous have the key removed so the safe default (`true`) applies; (3) `objectstack validate` passes, which also proves no endpoint declares a shape 17.x cannot execute (`type: script` / `proxy`, mapping `transform`, an `object_operation` missing `objectParams`, `cacheTtl` on a non-GET method, `inputMapping` on find/get/delete, or two endpoints claiming one METHOD + path); and (4) after publishing, each endpoint answers as you expect — an anonymous request to a session-only endpoint returns 401 rather than data. - **`ui-widget-i18n-family-retired`** — `ui.widgetManifest / ui.widgetLifecycle / ui.widgetEvent / ui.widgetProperty / ui.widgetSource / ui.i18nObject / ui.pluralRule / ui.numberFormat / ui.dateFormat / ui.localeConfig (the widget-registration vocabulary of ui/widget.zod.ts, and the five doorless shapes of ui/i18n.zod.ts — 10 defs, 26 exported names)` → (removed — there is no replacement key, because there was never a key. A custom field widget is still named the same way it always was: `field.widget` is a plain string naming a component the RENDERER has registered, and objectui's registry has always carried its own runtime manifest for that (`RuntimeWidgetManifest` / `RuntimeWidgetSource` in `@object-ui/types`, objectui#3161 / #4115), which models different keys and never derived from these. For localisation: write the default-language string on `label` / `description` — the framework generates the translation key at registration time from the naming convention — and put translations in translation files, which is the LIVE `system/translation.zod.ts` surface. Widget registration and locale formatting as authorable protocol metadata return via the ENFORCE route of ADR-0049 through a new ADR — the registry / loader / formatter first, the vocabulary second) - Why not automatic: `ui/widget.zod.ts` published a complete widget-registration vocabulary — a manifest with lifecycle hooks, custom events, configurable properties and an npm/remote/inline implementation-source union — and `ui/i18n.zod.ts` published a structured-label, plural-rule and locale-formatting vocabulary. NOTHING in the protocol carried either. Three independent measurements, re-run on `origin/main` immediately before the removal with their controls passing in the SAME run: (1) no module under `packages/spec/src` imported `widget.zod` at all, and the only imports of `i18n.zod` anywhere name `I18nLabelSchema` / `AriaPropsSchema` (both KEPT), so no schema declared a carrier key — `field.widget` is a `z.string()` naming a registered component and has never referenced `WidgetManifest`; (2) a BFS over the in-memory Zod graph from all 24 metadata-type roots plus `defineStack`'s `ObjectStackSchema` reached none of them, while `PageSchema` / `ObjectListViewSchema` resolved `direct` in the same run and a synthetic carrier flipped every one of them; (3) zero `.parse()` / `.safeParse()` in objectstack, objectui or cloud outside these files' own unit tests. `NumberFormat` / `DateFormat` DID have a carrier key (`LocaleConfig.numberFormat` / `.dateFormat`) but the carrier was itself doorless, so the subtree was `no door` rather than `no gate` and goes whole — leaving the two leaves behind would strand exported schemas with no consumer (#3950). `I18nObjectSchema` was additionally superseded by its own file-neighbour: `I18nLabelSchema`'s documentation already says translation keys are generated at registration time and translations live in translation files, and the live translation surface is `system/translation.zod.ts`, which uses none of these shapes. The 2026-08-06 ruling weighed giving them a carrier (option B) and rejected it: that is a feature with a registry and a renderer behind it, not ledger clean-up. Tightening them to `strictObject` was rejected earlier and explicitly (#4001 批 16) — strictness is a property of a PARSE and there is no parse, so it would spend a breaking change to leave "a precisely validated dead slot, the more convincing lie" (#4583). With no carrier key there is nothing to tombstone and no `sys_metadata` row or source file for a D2 conversion to rewrite: this entry is the D3 record, route 3, the same shape as #4988 (the ui/ interaction config family), #4834 (kernel plugin-runtime family) and #4938 (`HttpServerConfig`). ⚠️ `WidgetManifest.performance`'s own `retiredKey()` tombstone (#3896 close-out) is SUBSUMED here, the #4657/#4834 way: it goes with the shape that carried it, which is strictly stronger than the tombstone, because there is no longer a manifest to author the key INTO. ⚠️ One of the nine widget sites is deliberately NOT retired. `FieldWidgetPropsSchema` survives: it is a REACT PROPS CONTRACT rather than authorable metadata (it never appeared in `authorable-surface/` or `json-schema.manifest/` — its `onChange` is a `z.function()`), so "zero parse" is its design and not its defect, and it acquired a live cross-repo compile-time consumer one day before 批 16 measured: objectui PR #3289 (2026-08-03) renamed `@object-ui/fields`' validation slot onto the spec's `error` with no alias, the form renderer began producing it, and `packages/fields/src/__tests__/spec-symbol-batch7.test.ts` pins the shape against `import type { FieldWidgetProps } from '@objectstack/spec/ui'` as an intentional tripwire. Re-verified on objectui `origin/main` 2026-08-07. ADR-0049, #5055. @@ -369,9 +371,9 @@ One entry in this step is not a removal at all but a SECURE-DEFAULT FLIP, the sh - **`actor-user-roles-to-positions`** — `action body / AI route: ctx.user.roles (req.user.roles)` → ctx.user.positions (an AI route handler reads `req.user.positions`) — the same array, under the one spelling ADR-0090 D3 sanctions - Why not automatic: The THIRD face of the ADR-0090 `roles` → `positions` rename, and the only one whose surface the spec never declared. `ActorUser` (`packages/runtime/src/security/actor-user.ts`) is the ONE producer of the `user` envelope handed to an action body as `ctx.user` and to an AI route handler as `req.user`; it declared `positions` and `roles` side by side and filled them from a SINGLE assignment (`roles: core.positions`), so the two keys were verbatim identical on every dispatch — a second spelling of the vocabulary ADR-0090 D3 reserves and bans, published straight into author-written code. The maintainer ruled it closed IMMEDIATELY (2026-08-06 14:49Z, #6011): no deprecation window, no dual-emit, the alias simply gone in 17 (PR #6048). ⚠️ Do not read this entry across to its neighbour above: `action-session-roles-to-positions` governs `ctx.session`, a DIFFERENT object reached through the same `ctx`, and that one KEEPS its one-window dual-emit (#5613). Same word, same dispatch, two faces, two schedules — `ctx.user.roles` is absent in 17 while `ctx.session.roles` still answers for the length of its window. What makes this entry different in KIND from both session-side siblings: `ctx.user` has no spec schema and never had one. It is a runtime TS interface, so unlike `HookContext.session.roles` (tombstoned on a deliberately non-strict `HookContextSchema`, #5050) and unlike `ActionSessionSchema` (declared contract-first at #5697 precisely so its key could be renamed), there is no schema key here to tombstone and no `retiredKey()` prescription that could reach anybody — nothing ever ran an `ActorUser` through a `.parse()`, so a prescription there would have no one to reach. The enforced channel is tsc, and it reports at the READ site inside the author's own body; for an untyped or sandboxed body there is no enforced channel at all, which is exactly why this ledger entry has to exist — `spec-changes.json` and the generated upgrade guide are the ONLY way such a reader learns of the rename. It is the `findStream` (#4484) / `IStorageService.list` (#5540) disposition — a TS/API contract, no stored source, no tombstone, tsc at the call site — applied to a surface that lives one layer further out than either: those two are at least DECLARED in `packages/spec/src/contracts`, this one only in `packages/runtime`. Why it is a D3 semantic TODO and not a D2 conversion, on the same two independent grounds as its session sibling: FIRST, there is no source to convert — an `ActorUser` is constructed per dispatch and never persisted, so no `sys_metadata` row, example or template can carry the key (the `openApi31` (#4579) / `activationEvents` (#4657) / `hook-context-session-roles-retired` (#5050) shape). SECOND, the only place the key is ever SPELLED is inside an action body or an AI route handler: author-written JS/TS, or a sandboxed script. A declarative transform cannot safely rewrite an identifier inside free-form code — the same reason the ADR-0090 wave delegated `current_user.roles` to the author at step 13 (`cel-current-user-roles-to-positions`) instead of substituting text. The removal's hard precondition was met before it landed, and the result is recorded here because the ledger is where an upgrading consumer meets it: the declaration's own comment claimed the alias was "kept for the REST/AI shapes", and that claim was DISPROVEN face by face against `origin/main` — repo-wide `user.roles` was 4 hits, all of them in the pins PR #6048 flipped; the four `ActorUser` construction sites build server-side envelopes that never enter a response body; objectui's `.roles` reads belong to two unrelated producers (the better-auth session, and the `/auth/me/permissions` payload). The `cloud` repo was NOT reachable in that session and is the one consumer face left unverified — this entry, and the changeset's FROM/TO prescription, are its disposition. ADR-0090 D3 / ADR-0049 / ADR-0087, #6011 (PR #6048). - Done when: No action body reads `ctx.user.roles` and no AI route handler reads `req.user.roles`; every such read is `.positions` and observes the SAME array — the value was `ExecutionContext.positions` on both sides, so this is a pure key rename and no value has to be re-derived. Privilege is NOT re-derived from either spelling: a read that was `roles.includes('admin')` as an access check is rewritten to ask the security service (capability grants / placements / derived posture, ADR-0095), never renamed to `positions.includes('admin')` — renaming that read migrates the defect rather than the code. Unlike `ctx.session` there is NO window to migrate inside: in 17 the key is already absent, so a typed body fails `tsc` at the read while an untyped or sandboxed one silently sees `undefined` — move the read AS you upgrade, not after it. Verify against a real dispatch rather than a fixture: invoke an action (and an AI route) as a caller holding positions, assert the body observed them under the canonical key, and assert the old key is ABSENT by key existence (`'roles' in ctx.user === false`) rather than by `undefined`, which cannot tell a removed key from one left behind holding nothing — the runtime pin `action-ctx-user-shape.test.ts` asserts both halves that way. -- **`storage-service-list-retired`** — `contracts.IStorageService.list` → no replacement — track the keys you wrote (sys_file / file-reference records, queryable through ObjectQL with real pagination) instead of enumerating the bucket +- **`storage-service-list-retired`** — `contracts.IStorageService.list` → track the keys you wrote (sys_file / file-reference records, queryable through ObjectQL with real pagination) instead of enumerating the bucket — and where no such record exists, the cursor-shaped `list(prefix, { cursor, limit })` this entry reserved, restored in #6781 - Why not automatic: `list(prefix)` was an OPTIONAL contract method documented as "List files in a directory/prefix", and the two shipped adapters answered the same call with two different semantics — both of them silently incomplete. `LocalStorageAdapter.list` was a single-level `readdir`, so a nested key `a/b/c` was invisible under `list('a')` (only `a/b` came back), and a subdirectory that `stat` succeeded on was pushed into the result as a file, yielding a `StorageFileInfo` whose `size` is a directory inode and which cannot be downloaded at all. `S3StorageAdapter.list` was RECURSIVE (`ListObjectsV2` matches the whole key) and read neither `IsTruncated` nor `ContinuationToken`, so past 1000 objects the "all files" a caller received was the first page, with no signal. One contract method, two dialects, both quietly incomplete — and the first feature that genuinely needed to enumerate a prefix (backup, orphan sweep, migration audit) would have got two different answers on two deployments without an error on either. #5172 was nearly that feature: it planned to drive attachment reclamation off `list(EMAIL_ATTACHMENT_KEY_PREFIX)`, found the local adapter could not see one level down, and switched to queue-driven deferred work instead. Nothing consumed it afterwards: the only in-repo call site was the `SwappableStorageService` pass-through (which itself rejects when the active adapter has no `list`), and REST, CLI and the storage routes never called it. Remove was chosen over align-and-tighten (maintainer ruling, 2026-08-05, #5266): aligning would grow a conformance surface nobody walks, while a prefix listing that cannot paginate is the wrong signature to inherit — when a real caller needs enumeration it returns cursor-shaped, `list(prefix, { cursor, limit })`, with adapter-conformance cases (nested keys, directory entries, >1000 objects) proving both backends agree. This is a TS/API contract surface — a storage adapter is CODE, never stack metadata — so there is no source for the chain to rewrite, and deliberately no schema tombstone: nothing ever ran an adapter through a `.parse()`, so a prescription there would reach no one. The enforced channel is tsc, and it reports at the call site. Same disposition, and the same reason, as `data-driver-find-stream-retired` (#4484). ADR-0049 / ADR-0087, #5540 (analysis #5266). - - Done when: No code calls `storage.list(...)` on the `file-storage` service or on any `IStorageService` value. Code that needed "which files are under this prefix" reads the records it wrote — `sys_file` / file-reference rows carry the storage key and page deterministically through ObjectQL — rather than asking the bucket, which is also the only form that stays correct past 1000 objects and across both adapters. An adapter that still IMPLEMENTS `list` keeps compiling (an extra method is not an error on a class) and is simply unreachable through the contract, so deleting it is cleanup that can follow. The break is on the CALLER side: `storage.list(...)` no longer type-checks, and a PROXY typed against `IStorageService` that forwards to `inner.list` is exactly such a caller — the one in `@objectstack/service-storage` goes with the adapters (#5541). + - Done when: No code calls `storage.list(...)` on the `file-storage` service or on any `IStorageService` value. Code that needed "which files are under this prefix" reads the records it wrote — `sys_file` / file-reference rows carry the storage key and page deterministically through ObjectQL — rather than asking the bucket, which is also the only form that stays correct past 1000 objects and across both adapters. An adapter that still IMPLEMENTS `list` keeps compiling (an extra method is not an error on a class) and is simply unreachable through the contract, so deleting it is cleanup that can follow. The break is on the CALLER side: `storage.list(...)` no longer type-checks, and a PROXY typed against `IStorageService` that forwards to `inner.list` is exactly such a caller — the one in `@objectstack/service-storage` goes with the adapters (#5541). ⚠️ AMENDED 2026-08-09 (#6781, maintainer ruling on cloud#1203, option B): the RESERVED route in the paragraph above was taken. `list` exists again on the contract, cursor-shaped — `list(prefix, { cursor, limit })` returning `{ items, nextCursor }` — because cloud had two first-party callers this repo could not see when the measurement said "nothing calls it" (tenant attachment reclamation, marketplace snapshot GC). This does NOT un-retire anything and the acceptance criterion above is unchanged for what it actually governs: the single-argument `list(prefix): StorageFileInfo[]` is gone for good, a call written against it still fails to compile, and the two dialects it had are now pinned against each other in `storage-adapter-list.conformance.test.ts` rather than left to diverge. What changed for an upgrader is only the destination: prefer the records you wrote, and reach for the restored member when there are none. - **`driver-aggregate-undeclared-key-aliases-removed`** — `driver aggregate() call argument — query.aggregate and aggregations[].func` → query.aggregations and aggregations[].function — the spellings QueryASTSchema and AggregationNodeSchema have always declared - Why not automatic: `SqlDriver.aggregate` and `RemoteTransport.aggregate` each read two aliases the Query Protocol has never declared: `query.aggregations || query.aggregate` and `agg.function || agg.func`. "Never declared" is measured, not assumed — `git log -S` over `data/query.zod.ts` finds no commit that ever introduced either name, there is no `retiredKey()` tombstone and no alias-table entry for them (the file's only alias table is `SortNode`'s `direction` → `order`), and neither appears in any upgrade guide or release note. So this entry does not record a declared surface being withdrawn; it records a LENIENCY being withdrawn, which is why it is here rather than behind a tombstone. The only writers in this repository were the two driver packages' own fixtures — #4984's family, where a fixture spelling the alias keeps the tolerant limb green forever and no test in existence can go red on its deletion — so ADR-0049 enforce-or-remove applies once those are re-spelt. ⚠️ Do NOT read this across to `dashboard`/`page` measures: `aggregate` IS the canonical key there and `func` IS a declared, loudly-suggesting alias (`DatasetMeasureSchema`, ui/dataset.zod.ts). That neighbouring vocabulary is untouched, and it is the most likely reason an off-repo caller ever wrote these keys on a QUERY — one habit, two surfaces, only one of which declared it. This is a driver CALL ARGUMENT — code, never stack metadata — so there is no source for the D2 chain to rewrite and deliberately no schema tombstone: nothing ever ran a query through `QueryASTSchema.parse()` on this path. The enforced channel is tsc at the call site, once the parameter is `DriverQuery` — and for an untyped JS caller there is no enforced channel at all, which is exactly why this ledger entry has to exist: the generated upgrade guide is the only way such a reader learns of the rename. Same disposition, and the same reason, as `data-driver-find-stream-retired` (#4484), `storage-service-list-retired` (#5540) and `actor-user-roles-to-positions` (#6011). ADR-0049 / ADR-0087, #6321 (PR #6404). - Done when: No caller passes `aggregate:` to a driver's `aggregate()`, and no aggregation entry spells its function `func:`; both are written `aggregations:` / `function:`. An inline literal still using either old spelling no longer type-checks (TS2353 at the call site). An untyped JS caller that keeps writing `aggregate:` silently receives no aggregate column — the grouping still happens, the measure is simply absent — and one that keeps writing `func:` receives INVALID_QUERY / 400 naming the undeclared function, identically on the local driver and the Turso remote transport. @@ -395,10 +397,13 @@ One entry in this step is not a removal at all but a SECURE-DEFAULT FLIP, the sh - Done when: No source imports `ETLPipeline`, `ETLPipelineParsed`, `ETLPipelineSchema`, `ETLPipelineRun(Schema)`, `ETLSource(Schema)`, `ETLDestination(Schema)`, `ETLTransformation(Schema)`, `ETLEndpointType(Schema)`, `ETLTransformationType(Schema)`, `ETLSyncMode(Schema)`, `ETLRunStatus(Schema)` or the `ETL` factory from `@objectstack/spec/automation`; `tsc` reports TS2724/TS2305 on any that survives. Every author who was pointed at L2 has been re-pointed by name: SYNC_ARCHITECTURE.md no longer lists an L2 row, no longer recommends `ETLPipeline` as L1's destination and no longer advertises a transformation-type table. The surviving layers still parse unchanged — a connector declaring `syncConfig` and an import declaring `mapping.transform` both behave exactly as they did in 16.x. - **`action-descriptor-resume-authority-default-flip`** — `automation.ActionDescriptor.resumeAuthority — an OMITTED value on a pausing node descriptor (supportsPause: true, or any executor whose execute() returns suspend: true)` → an explicit resumeAuthority: 'any' on the descriptor, for a pausing node whose pauses really are meant to be continued through the generic resume route (POST /automation/:name/runs/:runId/resume) — a screen-style collected-input pause, or a signal wait an external producer resumes. Declare 'service' instead if continuing is the tail of a decision your own service must authorize and record first. Either value is a one-line addition; only the silence changed meaning - Why not automatic: A SECURE-DEFAULT FLIP with no metadata shape to rewrite — the same category as protocol 12's `rest-requireauth-default-flip`, and it is registered here for the same reason: whether a given pause is genuinely open to the generic route is a trust judgment no transform can make. The #3801 resume gate keys on the SUSPENDED NODE, and `ActionDescriptor.resumeAuthority` used to default to `'any'`, so a pausing node type shipped raw-resumable unless its author remembered the field. It now resolves to `'service'` when absent: an unclaimed pause is refused on the generic route with `PERMISSION_DENIED` / 403 until its descriptor states who may continue it. #3823 is the incident that decided the direction — ADR-0044 pointed an approval's revise edge at a generic `wait`, `wait` is legitimately `'any'`, and the pause standing in a service-owned position inherited a fail-open value nobody chose; the demonstrated cost was an unaudited resubmit plus a destroyed remote run. The two possible mistakes are asymmetric, which is the whole argument: guessing `'any'` walks past a decision nothing recorded and is silent, while guessing `'service'` returns a refusal naming the missing field. ⚠️ The surface is a DESCRIPTOR FIELD set in plugin CODE, never stack metadata, so there is no source for a D2 conversion to rewrite and deliberately no schema tombstone — the disposition `data-driver-find-stream-retired` (#4484), `storage-service-list-retired` (#5540) and `actor-user-roles-to-positions` (#6011) already carry. It differs from those in one way a reader should not have to infer: nothing is REMOVED, so tsc reports nothing at all — the field was already optional after step one and an omission still compiles. The enforced channels are all run-time: a registration warning naming the node type (once per type per engine), the refusal message on the resume itself, and `check:resume-authority-declared` for executors living in this repo. For a third-party plugin the generated upgrade guide is the only channel that arrives BEFORE a user hits a run that will not continue. In-tree the flip moves nothing: all six shipped pausing types (screen, wait, subflow, map, approval, approval_revise) declare their authority explicitly. ADR-0044 amendment (2026-07-28) and its 2026-08-08 landing section, ADR-0019 #3801 addendum, #5561. - - Done when: Every action descriptor your plugin registers for a node type that can suspend declares `resumeAuthority`. Booting the stack logs no `declares supportsPause but never declares resumeAuthority` warning naming one of your types, and a run parked on each of your pausing nodes can still be continued the way you intend: a resume through the generic route succeeds for the ones you declared `'any'`, and answers 403 (`PERMISSION_DENIED`) for the ones you declared `'service'`, which continue through your own service API instead. ⚠️ `supportsPause` is a declaration nothing enforces (#5703), so an executor whose `execute()` returns `suspend: true` while leaving `supportsPause` false is warned about by NEITHER channel — check those by hand against the same rule. + - Done when: Every action descriptor your plugin registers for a node type that can suspend declares `resumeAuthority`. Booting the stack logs no `declares supportsPause but never declares resumeAuthority` warning naming one of your types, and a run parked on each of your pausing nodes can still be continued the way you intend: a resume through the generic route succeeds for the ones you declared `'any'`, and answers 403 (`PERMISSION_DENIED`) for the ones you declared `'service'`, which continue through your own service API instead. ⚠️ `supportsPause` is no longer the declaration nothing enforced (#5703, closed by #6667): an executor whose `execute()` returns `suspend: true` while leaving `supportsPause` false is still warned about by neither warning channel, but `AutomationEngine.refuseUndeclaredSuspension` now refuses that suspension at the one seam every suspension passes through — a guard-class failure no `fault` edge routes — so it needs no hand-check. The residue that does: an executor registering NO descriptor declares nothing for either warning or the refusal to read, so its pauses are still created and refused only later, on the resume route (#5561). - **`export-field-meta-constraints-retired`** — `@objectstack/rest: ExportFieldMeta.required / .system / .readonly / .hasDefault / .min / .max / .minLength / .maxLength (the map built by `buildFieldMetaMap`, reached as `PreparedImport.metaMap` from `prepareImportRequest`)` → the object schema you already hold — read `fields[name].required` / `.system` / `.readonly` / `.defaultValue` / `.min` / `.max` / `.minLength` / `.maxLength` off the same `ObjectSchema` you passed to `buildFieldMetaMap`, which is where the ENGINE reads them and therefore the only copy that cannot drift - Why not automatic: ADR-0049 enforce-or-remove. These eight were never a source of truth: `buildFieldMetaMap(schema)` DERIVED each one from the very `schema` its caller passed in, so the map carried a second copy of facts the caller already held. They existed for exactly one consumer — the import dry run's hand-copied pre-check mirror (`firstMissingRequiredField` / `firstConstraintViolation`, framework#3956) — and #4633 ruling D retired that mirror (PR #6532): the dry run now asks `DataProtocol.validateData` for the engine's verdict, which reads the object's own schema. That left all eight computed on every import and read by NOTHING, which is the declared-and-unread shape ADR-0049 exists for; a constraint vocabulary standing next to the presentation one with no enforcer behind it is precisely the thing an AI-authored consumer mistakes for a contract. Verified zero-reader before removal, per key and by type, across this repo (`packages/rest` itself, and all five in-repo dependents of `@objectstack/rest`: runtime, cli, verify, plugin-auth, plugin-dev) and the `objectui` sibling; plugin-auth's identity import forwards `prepared.metaMap` into `runImport` but reads only the presentation keys through `coerceRow`. Why this needs a ledger entry despite that sweep: it is the `findStream` (#4484) / `IStorageService.list` (#5540) / `actor-user-roles-to-positions` (#6011) disposition — a published TS surface with NO spec schema, so there is no `retiredKey()` tombstone and no parse rejection that could carry a prescription, and the ledger is the only channel that reaches an upgrader. It is if anything blinder than those three: the keys shipped in a FINAL release (`@objectstack/rest` 14.5.0) and have been published in every release since, and because they were OPTIONAL keys on an interface that itself survives, a JavaScript consumer reading `meta.required` after the upgrade gets `undefined` with no error at all — tsc reports at the read site only for a typed consumer. Why D3 semantic and not a D2 conversion: there is nothing to convert. No authored or stored metadata changes shape — `required` / `min` / `maxLength` and the rest remain fully authorable on a field definition and fully enforced by the engine, which is where they always lived. The only place these eight are ever spelled is inside a consumer's own TypeScript, so no `objectstack migrate meta` transform can reach them. ADR-0049 / ADR-0087, #6536 (the sweep PR #6532 deliberately deferred). - Done when: No code of yours reads any of the eight off a `buildFieldMetaMap` / `prepareImportRequest` result. Grep your sources for `.required` / `.hasDefault` / `.minLength` / `.maxLength` / `.min` / `.max` / `.system` / `.readonly` on an `ExportFieldMeta`-typed value; each hit moves to the object schema you already passed in. ⚠️ Prove it against a RUN, not against tsc: these were optional keys, so an untyped or `any`-typed read compiles clean and silently becomes `undefined` — assert that the constraint your code acts on is still observed on a real import, not merely that the build is green. Note `hasDefault` has no one-to-one replacement key: it was the derived predicate `defaultValue != null`, mirroring the engine's `applyFieldDefaults` gate, so read `fields[name].defaultValue` and apply that same `!= null` test yourself. +- **`action-descriptor-is-async-retired`** — `ActionDescriptor.isAsync (the descriptor an executor publishes via `registerNodeExecutor` / `defineActionDescriptor`)` → nothing to re-declare — delete the key. Suspension is `execute()` RETURNING `suspend: true`, and permission to suspend is `supportsPause: true` on the same descriptor (with the `resumeAuthority` its pauses need) + - Why not automatic: ADR-0049 enforce-or-remove. `isAsync` declared "this action suspends the flow awaiting an external reply" and NOTHING read it: a fresh three-repo measurement (#6748, re-run at pickup) found zero property reads across objectstack, objectui and cloud — every hit was the declaration itself, a generated baseline, one of five shipped descriptors WRITING it, a test fixture pinning the shape, or prose. So declaring it never made a node suspend and omitting it never stopped one, which is the silently-inert declaration ADR-0049 exists to end. It was always a second, weaker spelling of the capability `supportsPause` states, and the two diverged in exactly the way a duplicated declaration does: `screen` declared both, `map` and `wait` declared `isAsync` alongside `supportsPause`, and nothing anywhere reconciled them. The sibling took the ENFORCE leg of the same ruling in #6667 — `AutomationEngine` now refuses a suspension whose type does not declare `supportsPause: true` — so the capability this key gestured at is now a real, enforced fact under one name. This one had no consumer to grow into and takes the remove leg. Why D3 semantic and not a D2 conversion: an ActionDescriptor is published from an executor's TypeScript, never stored in stack metadata — no stack, example or template carries the key — so there is no source for the chain to rewrite and `os migrate meta` cannot reach it. The schema tombstones it via `retiredKey()` and descriptor authors delete the key themselves; that rejection (a `tsc` error at the authoring site, and a parse error inside `defineActionDescriptor`) is the channel a third-party plugin author actually meets. The `EnhancedApiError.fieldErrors` disposition, one layer down. + - Done when: No descriptor declares `isAsync` — not the five that shipped it (`screen`, `map`, `wait`, `approval`, `approval_revise`), not a plugin's. Every node type that returns `suspend: true` from `execute()` declares `supportsPause: true` on its descriptor together with a `resumeAuthority`, and its runs still pause and resume as before: the behaviour never depended on `isAsync`, so deleting the key changes no run. Authoring `isAsync` fails `tsc` at the descriptor literal and fails `defineActionDescriptor()` at runtime with the prescription, instead of parsing clean and being stripped. --- diff --git a/packages/spec/spec-changes.json b/packages/spec/spec-changes.json index 20a0aa170f..6719a751d1 100644 --- a/packages/spec/spec-changes.json +++ b/packages/spec/spec-changes.json @@ -605,7 +605,7 @@ "replacement": "the same declarations, re-read as LIVE HTTP routes: `path` moved under `/api/v1/apps//`, and every entry that declares `authRequired: false` re-confirmed as an intentionally anonymous endpoint carrying `rateLimit: { enabled: true, … }`", "migrationId": "declarative-apis-endpoints-live", "toMajor": 17, - "rationale": "This is the one protocol-17 entry that turns metadata ON rather than off, so read it as a SECURITY review item and not as a rename. Before 17 the declarative endpoint surface executed NOTHING: no route was mounted for a declared `path`, no matcher existed, and every key — `authRequired` included — parsed green and gated nothing (#4936, which refused a non-empty `apis:` outright for exactly that reason). Protocol 17 ships the executor (#5040) and narrows that refusal to a per-endpoint publish gate: an endpoint that PASSES the gate is mounted and serves real traffic as soon as the stack is published. So an `apis:` block written against an older major — or one restored from a pre-#4936 source, or authored from a doc that predates the refusal — changes meaning without changing a byte: what used to be inert documentation becomes an execution entry point into the data and automation pipelines. Nothing about that transition can be applied mechanically, because the judgment it needs is \"did the author of this endpoint mean for the internet to reach it?\" — and the one key where a wrong answer is unrecoverable is `authRequired`. Its schema default is `true`, so an omission is SAFE and needs no review; an EXPLICIT `authRequired: false` is the only thing that opens anonymous access, and under ADR-0121 D6 it now also requires an armed `rateLimit` (`enabled: true` — the key defaults to `false`, so a budget written without it meters nothing) or the stack refuses to publish. Grep every `apis:` entry for `authRequired: false` before you upgrade, delete the ones that were never meant to be public, and arm a budget on the ones that were. The path move is the mechanical-looking half and is still yours: ADR-0121 D1/D2 confine a declared path to your own namespace carve-out (`/api/v1/apps//…`), the namespace comes from an explicit `manifest.namespace` with no derivation fallback, and the subpath is the only part you name — rewriting it for you would silently change a URL third parties call." + "rationale": "This is the one protocol-17 entry that turns metadata ON rather than off, so read it as a SECURITY review item and not as a rename. Before 17 the declarative endpoint surface executed NOTHING: no route was mounted for a declared `path`, no matcher existed, and every key — `authRequired` included — parsed green and gated nothing (#4936, which refused a non-empty `apis:` outright for exactly that reason). Protocol 17 ships the executor (#5040) and narrows that refusal to a per-endpoint publish gate: an endpoint that PASSES the gate is mounted and serves real traffic as soon as the stack is published. So an `apis:` block written against an older major — or one restored from a pre-#4936 source, or authored from a doc that predates the refusal — changes meaning without changing a byte: what used to be inert documentation becomes an execution entry point into the data and automation pipelines. Nothing about that transition can be applied mechanically, because the judgment it needs is \"did the author of this endpoint mean for the internet to reach it?\" — and the one key where a wrong answer is unrecoverable is `authRequired`. Its schema default is `true`, so an omission is SAFE and needs no review; an EXPLICIT `authRequired: false` is the only thing that opens anonymous access, and under ADR-0121 D6 it now also requires an armed `rateLimit` (`enabled: true` — the key defaults to `false`, so a budget written without it meters nothing) or the stack refuses to publish. ⚠️ If you author endpoints in TypeScript, annotate them with `ApiEndpoint` — the AUTHOR state — so that omitting `authRequired` compiles: `const e: ApiEndpoint = { name, path, method, type, target }` is legal and is the safe shape this paragraph prescribes. `ApiEndpointParsed` is the POST-parse type (defaults materialized, ADR-0122), where `authRequired` is required — annotating a declaration with it forces you to write the key out, and being made to think about a key whose only unrecoverable value is `false` is the one thing this entry is trying to avoid (#5227). Hold a parse RESULT with `ApiEndpointParsed`; write declarations as `ApiEndpoint`. Grep every `apis:` entry for `authRequired: false` before you upgrade, delete the ones that were never meant to be public, and arm a budget on the ones that were. The path move is the mechanical-looking half and is still yours: ADR-0121 D1/D2 confine a declared path to your own namespace carve-out (`/api/v1/apps//…`), the namespace comes from an explicit `manifest.namespace` with no derivation fallback, and the subpath is the only part you name — rewriting it for you would silently change a URL third parties call." }, { "surface": "ui.widgetManifest / ui.widgetLifecycle / ui.widgetEvent / ui.widgetProperty / ui.widgetSource / ui.i18nObject / ui.pluralRule / ui.numberFormat / ui.dateFormat / ui.localeConfig (the widget-registration vocabulary of ui/widget.zod.ts, and the five doorless shapes of ui/i18n.zod.ts — 10 defs, 26 exported names)", @@ -651,7 +651,7 @@ }, { "surface": "contracts.IStorageService.list", - "replacement": "no replacement — track the keys you wrote (sys_file / file-reference records, queryable through ObjectQL with real pagination) instead of enumerating the bucket", + "replacement": "track the keys you wrote (sys_file / file-reference records, queryable through ObjectQL with real pagination) instead of enumerating the bucket — and where no such record exists, the cursor-shaped `list(prefix, { cursor, limit })` this entry reserved, restored in #6781", "migrationId": "storage-service-list-retired", "toMajor": 17, "rationale": "`list(prefix)` was an OPTIONAL contract method documented as \"List files in a directory/prefix\", and the two shipped adapters answered the same call with two different semantics — both of them silently incomplete. `LocalStorageAdapter.list` was a single-level `readdir`, so a nested key `a/b/c` was invisible under `list('a')` (only `a/b` came back), and a subdirectory that `stat` succeeded on was pushed into the result as a file, yielding a `StorageFileInfo` whose `size` is a directory inode and which cannot be downloaded at all. `S3StorageAdapter.list` was RECURSIVE (`ListObjectsV2` matches the whole key) and read neither `IsTruncated` nor `ContinuationToken`, so past 1000 objects the \"all files\" a caller received was the first page, with no signal. One contract method, two dialects, both quietly incomplete — and the first feature that genuinely needed to enumerate a prefix (backup, orphan sweep, migration audit) would have got two different answers on two deployments without an error on either. #5172 was nearly that feature: it planned to drive attachment reclamation off `list(EMAIL_ATTACHMENT_KEY_PREFIX)`, found the local adapter could not see one level down, and switched to queue-driven deferred work instead. Nothing consumed it afterwards: the only in-repo call site was the `SwappableStorageService` pass-through (which itself rejects when the active adapter has no `list`), and REST, CLI and the storage routes never called it. Remove was chosen over align-and-tighten (maintainer ruling, 2026-08-05, #5266): aligning would grow a conformance surface nobody walks, while a prefix listing that cannot paginate is the wrong signature to inherit — when a real caller needs enumeration it returns cursor-shaped, `list(prefix, { cursor, limit })`, with adapter-conformance cases (nested keys, directory entries, >1000 objects) proving both backends agree. This is a TS/API contract surface — a storage adapter is CODE, never stack metadata — so there is no source for the chain to rewrite, and deliberately no schema tombstone: nothing ever ran an adapter through a `.parse()`, so a prescription there would reach no one. The enforced channel is tsc, and it reports at the call site. Same disposition, and the same reason, as `data-driver-find-stream-retired` (#4484). ADR-0049 / ADR-0087, #5540 (analysis #5266)." @@ -718,6 +718,13 @@ "migrationId": "export-field-meta-constraints-retired", "toMajor": 17, "rationale": "ADR-0049 enforce-or-remove. These eight were never a source of truth: `buildFieldMetaMap(schema)` DERIVED each one from the very `schema` its caller passed in, so the map carried a second copy of facts the caller already held. They existed for exactly one consumer — the import dry run's hand-copied pre-check mirror (`firstMissingRequiredField` / `firstConstraintViolation`, framework#3956) — and #4633 ruling D retired that mirror (PR #6532): the dry run now asks `DataProtocol.validateData` for the engine's verdict, which reads the object's own schema. That left all eight computed on every import and read by NOTHING, which is the declared-and-unread shape ADR-0049 exists for; a constraint vocabulary standing next to the presentation one with no enforcer behind it is precisely the thing an AI-authored consumer mistakes for a contract. Verified zero-reader before removal, per key and by type, across this repo (`packages/rest` itself, and all five in-repo dependents of `@objectstack/rest`: runtime, cli, verify, plugin-auth, plugin-dev) and the `objectui` sibling; plugin-auth's identity import forwards `prepared.metaMap` into `runImport` but reads only the presentation keys through `coerceRow`. Why this needs a ledger entry despite that sweep: it is the `findStream` (#4484) / `IStorageService.list` (#5540) / `actor-user-roles-to-positions` (#6011) disposition — a published TS surface with NO spec schema, so there is no `retiredKey()` tombstone and no parse rejection that could carry a prescription, and the ledger is the only channel that reaches an upgrader. It is if anything blinder than those three: the keys shipped in a FINAL release (`@objectstack/rest` 14.5.0) and have been published in every release since, and because they were OPTIONAL keys on an interface that itself survives, a JavaScript consumer reading `meta.required` after the upgrade gets `undefined` with no error at all — tsc reports at the read site only for a typed consumer. Why D3 semantic and not a D2 conversion: there is nothing to convert. No authored or stored metadata changes shape — `required` / `min` / `maxLength` and the rest remain fully authorable on a field definition and fully enforced by the engine, which is where they always lived. The only place these eight are ever spelled is inside a consumer's own TypeScript, so no `objectstack migrate meta` transform can reach them. ADR-0049 / ADR-0087, #6536 (the sweep PR #6532 deliberately deferred)." + }, + { + "surface": "ActionDescriptor.isAsync (the descriptor an executor publishes via `registerNodeExecutor` / `defineActionDescriptor`)", + "replacement": "nothing to re-declare — delete the key. Suspension is `execute()` RETURNING `suspend: true`, and permission to suspend is `supportsPause: true` on the same descriptor (with the `resumeAuthority` its pauses need)", + "migrationId": "action-descriptor-is-async-retired", + "toMajor": 17, + "rationale": "ADR-0049 enforce-or-remove. `isAsync` declared \"this action suspends the flow awaiting an external reply\" and NOTHING read it: a fresh three-repo measurement (#6748, re-run at pickup) found zero property reads across objectstack, objectui and cloud — every hit was the declaration itself, a generated baseline, one of five shipped descriptors WRITING it, a test fixture pinning the shape, or prose. So declaring it never made a node suspend and omitting it never stopped one, which is the silently-inert declaration ADR-0049 exists to end. It was always a second, weaker spelling of the capability `supportsPause` states, and the two diverged in exactly the way a duplicated declaration does: `screen` declared both, `map` and `wait` declared `isAsync` alongside `supportsPause`, and nothing anywhere reconciled them. The sibling took the ENFORCE leg of the same ruling in #6667 — `AutomationEngine` now refuses a suspension whose type does not declare `supportsPause: true` — so the capability this key gestured at is now a real, enforced fact under one name. This one had no consumer to grow into and takes the remove leg. Why D3 semantic and not a D2 conversion: an ActionDescriptor is published from an executor's TypeScript, never stored in stack metadata — no stack, example or template carries the key — so there is no source for the chain to rewrite and `os migrate meta` cannot reach it. The schema tombstones it via `retiredKey()` and descriptor authors delete the key themselves; that rejection (a `tsc` error at the authoring site, and a parse error inside `defineActionDescriptor`) is the channel a third-party plugin author actually meets. The `EnhancedApiError.fieldErrors` disposition, one layer down." } ], "removed": [] @@ -1382,7 +1389,7 @@ "replacement": "the same declarations, re-read as LIVE HTTP routes: `path` moved under `/api/v1/apps//`, and every entry that declares `authRequired: false` re-confirmed as an intentionally anonymous endpoint carrying `rateLimit: { enabled: true, … }`", "migrationId": "declarative-apis-endpoints-live", "toMajor": 17, - "rationale": "This is the one protocol-17 entry that turns metadata ON rather than off, so read it as a SECURITY review item and not as a rename. Before 17 the declarative endpoint surface executed NOTHING: no route was mounted for a declared `path`, no matcher existed, and every key — `authRequired` included — parsed green and gated nothing (#4936, which refused a non-empty `apis:` outright for exactly that reason). Protocol 17 ships the executor (#5040) and narrows that refusal to a per-endpoint publish gate: an endpoint that PASSES the gate is mounted and serves real traffic as soon as the stack is published. So an `apis:` block written against an older major — or one restored from a pre-#4936 source, or authored from a doc that predates the refusal — changes meaning without changing a byte: what used to be inert documentation becomes an execution entry point into the data and automation pipelines. Nothing about that transition can be applied mechanically, because the judgment it needs is \"did the author of this endpoint mean for the internet to reach it?\" — and the one key where a wrong answer is unrecoverable is `authRequired`. Its schema default is `true`, so an omission is SAFE and needs no review; an EXPLICIT `authRequired: false` is the only thing that opens anonymous access, and under ADR-0121 D6 it now also requires an armed `rateLimit` (`enabled: true` — the key defaults to `false`, so a budget written without it meters nothing) or the stack refuses to publish. Grep every `apis:` entry for `authRequired: false` before you upgrade, delete the ones that were never meant to be public, and arm a budget on the ones that were. The path move is the mechanical-looking half and is still yours: ADR-0121 D1/D2 confine a declared path to your own namespace carve-out (`/api/v1/apps//…`), the namespace comes from an explicit `manifest.namespace` with no derivation fallback, and the subpath is the only part you name — rewriting it for you would silently change a URL third parties call." + "rationale": "This is the one protocol-17 entry that turns metadata ON rather than off, so read it as a SECURITY review item and not as a rename. Before 17 the declarative endpoint surface executed NOTHING: no route was mounted for a declared `path`, no matcher existed, and every key — `authRequired` included — parsed green and gated nothing (#4936, which refused a non-empty `apis:` outright for exactly that reason). Protocol 17 ships the executor (#5040) and narrows that refusal to a per-endpoint publish gate: an endpoint that PASSES the gate is mounted and serves real traffic as soon as the stack is published. So an `apis:` block written against an older major — or one restored from a pre-#4936 source, or authored from a doc that predates the refusal — changes meaning without changing a byte: what used to be inert documentation becomes an execution entry point into the data and automation pipelines. Nothing about that transition can be applied mechanically, because the judgment it needs is \"did the author of this endpoint mean for the internet to reach it?\" — and the one key where a wrong answer is unrecoverable is `authRequired`. Its schema default is `true`, so an omission is SAFE and needs no review; an EXPLICIT `authRequired: false` is the only thing that opens anonymous access, and under ADR-0121 D6 it now also requires an armed `rateLimit` (`enabled: true` — the key defaults to `false`, so a budget written without it meters nothing) or the stack refuses to publish. ⚠️ If you author endpoints in TypeScript, annotate them with `ApiEndpoint` — the AUTHOR state — so that omitting `authRequired` compiles: `const e: ApiEndpoint = { name, path, method, type, target }` is legal and is the safe shape this paragraph prescribes. `ApiEndpointParsed` is the POST-parse type (defaults materialized, ADR-0122), where `authRequired` is required — annotating a declaration with it forces you to write the key out, and being made to think about a key whose only unrecoverable value is `false` is the one thing this entry is trying to avoid (#5227). Hold a parse RESULT with `ApiEndpointParsed`; write declarations as `ApiEndpoint`. Grep every `apis:` entry for `authRequired: false` before you upgrade, delete the ones that were never meant to be public, and arm a budget on the ones that were. The path move is the mechanical-looking half and is still yours: ADR-0121 D1/D2 confine a declared path to your own namespace carve-out (`/api/v1/apps//…`), the namespace comes from an explicit `manifest.namespace` with no derivation fallback, and the subpath is the only part you name — rewriting it for you would silently change a URL third parties call." }, { "surface": "ui.widgetManifest / ui.widgetLifecycle / ui.widgetEvent / ui.widgetProperty / ui.widgetSource / ui.i18nObject / ui.pluralRule / ui.numberFormat / ui.dateFormat / ui.localeConfig (the widget-registration vocabulary of ui/widget.zod.ts, and the five doorless shapes of ui/i18n.zod.ts — 10 defs, 26 exported names)", @@ -1428,7 +1435,7 @@ }, { "surface": "contracts.IStorageService.list", - "replacement": "no replacement — track the keys you wrote (sys_file / file-reference records, queryable through ObjectQL with real pagination) instead of enumerating the bucket", + "replacement": "track the keys you wrote (sys_file / file-reference records, queryable through ObjectQL with real pagination) instead of enumerating the bucket — and where no such record exists, the cursor-shaped `list(prefix, { cursor, limit })` this entry reserved, restored in #6781", "migrationId": "storage-service-list-retired", "toMajor": 17, "rationale": "`list(prefix)` was an OPTIONAL contract method documented as \"List files in a directory/prefix\", and the two shipped adapters answered the same call with two different semantics — both of them silently incomplete. `LocalStorageAdapter.list` was a single-level `readdir`, so a nested key `a/b/c` was invisible under `list('a')` (only `a/b` came back), and a subdirectory that `stat` succeeded on was pushed into the result as a file, yielding a `StorageFileInfo` whose `size` is a directory inode and which cannot be downloaded at all. `S3StorageAdapter.list` was RECURSIVE (`ListObjectsV2` matches the whole key) and read neither `IsTruncated` nor `ContinuationToken`, so past 1000 objects the \"all files\" a caller received was the first page, with no signal. One contract method, two dialects, both quietly incomplete — and the first feature that genuinely needed to enumerate a prefix (backup, orphan sweep, migration audit) would have got two different answers on two deployments without an error on either. #5172 was nearly that feature: it planned to drive attachment reclamation off `list(EMAIL_ATTACHMENT_KEY_PREFIX)`, found the local adapter could not see one level down, and switched to queue-driven deferred work instead. Nothing consumed it afterwards: the only in-repo call site was the `SwappableStorageService` pass-through (which itself rejects when the active adapter has no `list`), and REST, CLI and the storage routes never called it. Remove was chosen over align-and-tighten (maintainer ruling, 2026-08-05, #5266): aligning would grow a conformance surface nobody walks, while a prefix listing that cannot paginate is the wrong signature to inherit — when a real caller needs enumeration it returns cursor-shaped, `list(prefix, { cursor, limit })`, with adapter-conformance cases (nested keys, directory entries, >1000 objects) proving both backends agree. This is a TS/API contract surface — a storage adapter is CODE, never stack metadata — so there is no source for the chain to rewrite, and deliberately no schema tombstone: nothing ever ran an adapter through a `.parse()`, so a prescription there would reach no one. The enforced channel is tsc, and it reports at the call site. Same disposition, and the same reason, as `data-driver-find-stream-retired` (#4484). ADR-0049 / ADR-0087, #5540 (analysis #5266)." @@ -1495,6 +1502,13 @@ "migrationId": "export-field-meta-constraints-retired", "toMajor": 17, "rationale": "ADR-0049 enforce-or-remove. These eight were never a source of truth: `buildFieldMetaMap(schema)` DERIVED each one from the very `schema` its caller passed in, so the map carried a second copy of facts the caller already held. They existed for exactly one consumer — the import dry run's hand-copied pre-check mirror (`firstMissingRequiredField` / `firstConstraintViolation`, framework#3956) — and #4633 ruling D retired that mirror (PR #6532): the dry run now asks `DataProtocol.validateData` for the engine's verdict, which reads the object's own schema. That left all eight computed on every import and read by NOTHING, which is the declared-and-unread shape ADR-0049 exists for; a constraint vocabulary standing next to the presentation one with no enforcer behind it is precisely the thing an AI-authored consumer mistakes for a contract. Verified zero-reader before removal, per key and by type, across this repo (`packages/rest` itself, and all five in-repo dependents of `@objectstack/rest`: runtime, cli, verify, plugin-auth, plugin-dev) and the `objectui` sibling; plugin-auth's identity import forwards `prepared.metaMap` into `runImport` but reads only the presentation keys through `coerceRow`. Why this needs a ledger entry despite that sweep: it is the `findStream` (#4484) / `IStorageService.list` (#5540) / `actor-user-roles-to-positions` (#6011) disposition — a published TS surface with NO spec schema, so there is no `retiredKey()` tombstone and no parse rejection that could carry a prescription, and the ledger is the only channel that reaches an upgrader. It is if anything blinder than those three: the keys shipped in a FINAL release (`@objectstack/rest` 14.5.0) and have been published in every release since, and because they were OPTIONAL keys on an interface that itself survives, a JavaScript consumer reading `meta.required` after the upgrade gets `undefined` with no error at all — tsc reports at the read site only for a typed consumer. Why D3 semantic and not a D2 conversion: there is nothing to convert. No authored or stored metadata changes shape — `required` / `min` / `maxLength` and the rest remain fully authorable on a field definition and fully enforced by the engine, which is where they always lived. The only place these eight are ever spelled is inside a consumer's own TypeScript, so no `objectstack migrate meta` transform can reach them. ADR-0049 / ADR-0087, #6536 (the sweep PR #6532 deliberately deferred)." + }, + { + "surface": "ActionDescriptor.isAsync (the descriptor an executor publishes via `registerNodeExecutor` / `defineActionDescriptor`)", + "replacement": "nothing to re-declare — delete the key. Suspension is `execute()` RETURNING `suspend: true`, and permission to suspend is `supportsPause: true` on the same descriptor (with the `resumeAuthority` its pauses need)", + "migrationId": "action-descriptor-is-async-retired", + "toMajor": 17, + "rationale": "ADR-0049 enforce-or-remove. `isAsync` declared \"this action suspends the flow awaiting an external reply\" and NOTHING read it: a fresh three-repo measurement (#6748, re-run at pickup) found zero property reads across objectstack, objectui and cloud — every hit was the declaration itself, a generated baseline, one of five shipped descriptors WRITING it, a test fixture pinning the shape, or prose. So declaring it never made a node suspend and omitting it never stopped one, which is the silently-inert declaration ADR-0049 exists to end. It was always a second, weaker spelling of the capability `supportsPause` states, and the two diverged in exactly the way a duplicated declaration does: `screen` declared both, `map` and `wait` declared `isAsync` alongside `supportsPause`, and nothing anywhere reconciled them. The sibling took the ENFORCE leg of the same ruling in #6667 — `AutomationEngine` now refuses a suspension whose type does not declare `supportsPause: true` — so the capability this key gestured at is now a real, enforced fact under one name. This one had no consumer to grow into and takes the remove leg. Why D3 semantic and not a D2 conversion: an ActionDescriptor is published from an executor's TypeScript, never stored in stack metadata — no stack, example or template carries the key — so there is no source for the chain to rewrite and `os migrate meta` cannot reach it. The schema tombstones it via `retiredKey()` and descriptor authors delete the key themselves; that rejection (a `tsc` error at the authoring site, and a parse error inside `defineActionDescriptor`) is the channel a third-party plugin author actually meets. The `EnhancedApiError.fieldErrors` disposition, one layer down." } ], "removed": [] From 7bb035d5698a0a4c75d204199142c682081feceb Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 03:34:39 +0000 Subject: [PATCH 14/16] chore(spec): regenerate api-surface/docs/skill-refs from the MERGED source --- content/docs/references/data/driver-turso.mdx | 2 +- packages/spec/api-surface/contracts.json | 8 +------- packages/spec/api-surface/data.json | 1 + 3 files changed, 3 insertions(+), 8 deletions(-) diff --git a/content/docs/references/data/driver-turso.mdx b/content/docs/references/data/driver-turso.mdx index 86f899a837..6d05e7246d 100644 --- a/content/docs/references/data/driver-turso.mdx +++ b/content/docs/references/data/driver-turso.mdx @@ -49,7 +49,7 @@ driver consults would be a new inert slot, and this file exists to close one. ```typescript import { TursoConfigSchema, TursoTransportModeSchema } from '@objectstack/spec/data'; -import type { TursoConfig } from '@objectstack/spec/data'; +import type { TursoConfig, TursoTransportMode } from '@objectstack/spec/data'; // Validate data const result = TursoConfigSchema.parse(data); diff --git a/packages/spec/api-surface/contracts.json b/packages/spec/api-surface/contracts.json index 205b12e561..4f51224719 100644 --- a/packages/spec/api-surface/contracts.json +++ b/packages/spec/api-surface/contracts.json @@ -61,7 +61,6 @@ "CryptoContext (interface)", "CryptoHandle (interface)", "CubeMeta (interface)", - "DEFAULT_STORAGE_LIST_LIMIT (const)", "DatasetCompareTo (interface)", "DatasetSelection (interface)", "DefineSharingRuleInput (interface)", @@ -272,8 +271,6 @@ "StartupOptions (type)", "StartupOptionsParsed (type)", "StorageFileInfo (interface)", - "StorageListOptions (interface)", - "StorageListPage (interface)", "StorageUploadOptions (interface)", "StrategyContext (interface)", "SubscribeOptions (interface)", @@ -290,9 +287,6 @@ "UploadArtifactResult (interface)", "UserModelMessage (type)", "ValidationResult (type)", - "WriteObservabilityOptions (interface)", - "decodeStorageListCursor (function)", - "encodeStorageListCursor (function)", - "resolveStorageListLimit (function)" + "WriteObservabilityOptions (interface)" ] } diff --git a/packages/spec/api-surface/data.json b/packages/spec/api-surface/data.json index 60232bd19a..7c4cd0c8de 100644 --- a/packages/spec/api-surface/data.json +++ b/packages/spec/api-surface/data.json @@ -572,6 +572,7 @@ "TursoConfigParsed (type)", "TursoConfigSchema (const)", "TursoDriverSpec (const)", + "TursoTransportMode (type)", "TursoTransportModeSchema (const)", "UniqueScope (type)", "UniqueScopeSchema (const)", From b19f1697de554385a3ab5a539c60d1ffcb512d74 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 03:47:52 +0000 Subject: [PATCH 15/16] =?UTF-8?q?fix(spec):=20three=20convention=20breaks?= =?UTF-8?q?=20in=20the=20new=20turso=20schema=20=E2=80=94=20docs-link=20el?= =?UTF-8?q?lipsis,=20ADR-0122=20alias,=20colliding=20alias=20probes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- content/docs/references/data/driver-turso.mdx | 4 +- packages/spec/api-surface/contracts.json | 8 +++- packages/spec/src/data/driver/turso.zod.ts | 40 ++++++++++++++----- .../src/type-alias-convention.pin.test.ts | 10 +++-- 4 files changed, 47 insertions(+), 15 deletions(-) diff --git a/content/docs/references/data/driver-turso.mdx b/content/docs/references/data/driver-turso.mdx index 6d05e7246d..9e793a480a 100644 --- a/content/docs/references/data/driver-turso.mdx +++ b/content/docs/references/data/driver-turso.mdx @@ -65,11 +65,11 @@ Turso / libSQL Connection Configuration | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **url** | `string` | ✅ | libSQL endpoint or local file (libsql://…, https://…, file:…, :memory:) | +| **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 (libsql:// or https://) | +| **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` | diff --git a/packages/spec/api-surface/contracts.json b/packages/spec/api-surface/contracts.json index 4f51224719..205b12e561 100644 --- a/packages/spec/api-surface/contracts.json +++ b/packages/spec/api-surface/contracts.json @@ -61,6 +61,7 @@ "CryptoContext (interface)", "CryptoHandle (interface)", "CubeMeta (interface)", + "DEFAULT_STORAGE_LIST_LIMIT (const)", "DatasetCompareTo (interface)", "DatasetSelection (interface)", "DefineSharingRuleInput (interface)", @@ -271,6 +272,8 @@ "StartupOptions (type)", "StartupOptionsParsed (type)", "StorageFileInfo (interface)", + "StorageListOptions (interface)", + "StorageListPage (interface)", "StorageUploadOptions (interface)", "StrategyContext (interface)", "SubscribeOptions (interface)", @@ -287,6 +290,9 @@ "UploadArtifactResult (interface)", "UserModelMessage (type)", "ValidationResult (type)", - "WriteObservabilityOptions (interface)" + "WriteObservabilityOptions (interface)", + "decodeStorageListCursor (function)", + "encodeStorageListCursor (function)", + "resolveStorageListLimit (function)" ] } diff --git a/packages/spec/src/data/driver/turso.zod.ts b/packages/spec/src/data/driver/turso.zod.ts index 3d00e4c64a..f444c152ba 100644 --- a/packages/spec/src/data/driver/turso.zod.ts +++ b/packages/spec/src/data/driver/turso.zod.ts @@ -57,12 +57,30 @@ import { export const TursoTransportModeSchema = z.enum(['local', 'replica', 'remote']) .describe('Force a transport mode instead of inferring it from `url`'); -/** Post-parse shape of {@link TursoTransportModeSchema}. */ -export type TursoTransportMode = z.infer; +/** + * 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', @@ -70,13 +88,8 @@ export const TursoConfigSchema = lazySchema(() => strictObject( database: 'url', databaseurl: 'url', token: 'authToken', - auth_token: 'authToken', - authtoken: 'authToken', jwt: 'authToken', - encryption_key: 'encryptionKey', - sync_url: 'syncUrl', syncinterval: 'sync', - sync_interval: 'sync', }, guidance: { pool: @@ -101,7 +114,16 @@ export const TursoConfigSchema = lazySchema(() => strictObject( * and the reason both boot hosts refuse a driver selection with no URL * rather than guessing one (#6345 fork 2). */ - url: z.string().min(1).describe('libSQL endpoint or local file (libsql://…, https://…, file:…, :memory:)') + // 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' }), /** @@ -125,7 +147,7 @@ export const TursoConfigSchema = lazySchema(() => strictObject( /** Remote sync endpoint that turns a local file into an embedded replica. */ syncUrl: z.string().optional() - .describe('Remote sync URL for embedded-replica mode (libsql:// or https://)') + .describe('Remote sync URL for embedded-replica mode: a libsql or https Turso endpoint') .meta({ title: 'Sync URL' }), /** 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 Date: Sun, 9 Aug 2026 06:05:15 +0000 Subject: [PATCH 16/16] =?UTF-8?q?fix(cli):=20keep=20the=20#6860=20allowlis?= =?UTF-8?q?t=20pin's=20oracle=20honest=20=E2=80=94=20an=20unrecognized=20s?= =?UTF-8?q?pelling=20is=20not=20a=20driver=20kind?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #6345's CLI-side refusal of an explicitly-named unknown driver reused UnsupportedDriverError with the operator's RAW TOKEN in driverType. The #6860 pin uses resolveStorageDefinition as its oracle and reads driverType out of that error, so its deliberately over-broad candidate scan started reporting every lowercase literal in storage-driver.ts ('safe', 'on-disconnect', 'factory', 'string', ...) as a driver kind. The allowlist itself was already correct: #6860 landed the canonical seven (sqlite, sqlite-wasm, turso, postgres, mysql, mongodb, memory), mongodb included. start.ts and dev.ts are therefore untouched. UnsupportedDriverError now carries 'recognized', defaulting true so the pre-#6345 turso-with-no-URL call sites keep their meaning, and the pin returns null for the unrecognized case. The assertion is unchanged: both sides still derived, still required to be equal. Also regenerates spec-changes.json / protocol-upgrade-guide.md, which the merge brought stale (os-regen guard). --- docs/protocol-upgrade-guide.md | 6 ++++ .../database-driver-allowlist.pin.test.ts | 13 +++++++- packages/cli/src/utils/storage-driver.ts | 32 ++++++++++++++++++- packages/spec/spec-changes.json | 26 +++++++++++++++ 4 files changed, 75 insertions(+), 2 deletions(-) diff --git a/docs/protocol-upgrade-guide.md b/docs/protocol-upgrade-guide.md index 7bc75c60ea..d08a140fed 100644 --- a/docs/protocol-upgrade-guide.md +++ b/docs/protocol-upgrade-guide.md @@ -220,6 +220,8 @@ The last of the #4001 enforce-or-remove batch lands on two more `ui/` files (#50 Last, it reconciles the SDUI component-props surface with the renderers that serve it (#5775). #5068 wired the first parse `ComponentPropsMap` ever had, and the corpus it landed on diverged in BOTH directions: keys objectui honours that the schema never declared, and keys the schema declared — one of them REQUIRED — that no renderer reads. The maintainer ruled direction A (2026-08-06), the #5611 rule again: the delivered and authorized shape is the contract. So the honoured keys are declared (`element:record_picker` `labelField`/`valueField`/`label`/`emptyText`, `record:path` `stages[].terminal`, `page:tabs` `items[].value`/`items[].count`, `page:card` `children`, and `children` on `page:section`/`page:footer`/`page:sidebar`, which were declared `EmptyProps` while their renderers rendered a child list), and four keys retire. Two are synonym renames: `element:record_picker.displayField` → `labelField` (the required key no renderer read, while `labelField ?? 'name'` is what actually renders the row — so an author who followed the schema got a picker listing `name` with no diagnostic, the ADR-0078 shape), and `page:card.body` → `children` (one composition key across every container; the card renderer already reads both, and the showcase authors `children`). Two are enforce-or-remove deletions: `element:record_picker.searchFields` and `.multiple` — the control is a shadcn single-select with no search input, binding ONE record id into a page variable, so `searchFields` narrowed nothing and `multiple: true` selected nothing extra while reporting success. Either returns the day the capability is implemented (#5021 / #4988). Not in scope, and deliberately: `page:card.visible` is a component-level visibility predicate written into `properties` and hoisted by the renderer — a page to rewrite onto the ADR-0089 `visibleWhen`, not a key to declare. +That count turned out to be incomplete, and #6776 finishes it: five more keys the renderers read were still undeclared. Four are plain additions with no behaviour change (`page:header` `recordChrome`/`showStar`/`showCopyId`, which select between the record-chip header and the bare heading a dashboard wants, and `page:accordion.variant`, which decides whether the accordion draws its own dividers or leaves the border to each panel). The fifth is a rename, and the only one in the family whose defect is structural rather than an oversight: the tab strip's visual style was declared as `page:tabs.type`, which collides with the page component's OWN dispatch key. objectui's `SchemaRenderer` refuses to hoist `properties.type` for exactly that reason, `sdui-parser`'s `BASE_PROPS` contains `type` and skips it before any validation runs, and in a flat or JSX carrier the node reads `{ type: 'page:tabs', … }` so the name is already taken. The key was therefore unauthorable in every carrier but the nested `properties` object, and unvalidated even there. It becomes `tabStyle` — the spelling objectui publishes and the renderer already reads first in the flat carriers — which is `displayField` → `labelField` again: converge on the spelling that works, not the one that declares well, and keep one spelling rather than two (Prime Directive #12). + Finally it narrows the aggregation vocabulary: `array_agg` and `string_agg` leave `AggregationFunction` (#6188, ADR-0049). The enum declared eight functions and the SQL family compiles five — `SqlDriver.mapAggregateFunc` and the Turso `RemoteTransport.aggregate` each lower `count`/`sum`/`avg`/`min`/`max` and route the rest to one refusal — so three were declared-but-unenforced against the backends this platform targets. What makes these two worse than an ordinary inert declaration is that another package had to carry a denylist for them: `service-analytics` subtracted `array_agg` and `string_agg` by name in `UNSUPPORTED_AGGREGATES`, because without that subtraction they reached the Cube strategy's `default` and returned `COUNT(*)` — a row count in place of the requested value, with no error and no log. The maintainer SPLIT the three rather than retiring them as a block (2026-08-07), and the split is the point: `count_distinct` STAYS and takes the enforce leg — one portable lowering (`COUNT(DISTINCT x)`), a dashboard staple, already lowered by `service-analytics` — with its SQL implementation following on its own card, so that declaration leads its implementation by decision rather than by drift. These two take the remove leg: display conveniences with no measured pull, and `string_agg` never had one shape to lower to (the delimiter is a second argument in PostgreSQL, a `SEPARATOR` clause in MySQL, a differently named function in SQL Server). This is an enum VALUE, not a key, so — as with `crypto.hash` above — there is no `retiredKey()` tombstone: the enum error map carries the prescription, keyed on the received value so only the two spellings that used to be legal are told they "were removed". Of the two authoring surfaces only one is stored metadata: the conversion rewrites `dataset.measures[].aggregate`, dropping the measure outright (a measure with neither `aggregate` nor `derived` fails the dataset's own refinement, so stripping just the key would emit an item that cannot parse) plus any derived measure the drop strands, with a notice each. Nothing is lost: `compileDataset` refused both by name already, so such a measure never produced a number. `QueryAST.aggregations[].function` is a request surface with no stored source — one semantic TODO below. The mongodb and in-memory backends that implemented these two are inside the #5499 freeze and are untouched; their code is simply no longer reachable through a spec-valid request. One entry in this step is not a removal at all but a SECURE-DEFAULT FLIP, the shape protocol 12 last used for `api.requireAuth`: an omitted `ActionDescriptor.resumeAuthority` resolves to `'service'` instead of `'any'`, so a pausing node type that never states who may continue its pauses is refused on the generic resume route rather than open to it (#5561, ADR-0044's 2026-07-28 amendment). Nothing is removed and no metadata shape changes — the field has been optional since step one of the same issue — so tsc reports nothing and only the MEANING of silence moved. That is exactly why it needs a ledger entry: a third-party plugin author has no compile error to discover it with, and the one-line prescription (declare `resumeAuthority` on the descriptor) has to arrive before a user meets a run that will not continue. @@ -281,6 +283,7 @@ The same descriptor loses a key in this step, and the pairing is the point (#674 | `record-picker-inert-keys-removed` | `page.component.element:record_picker.searchFields / page.component.element:record_picker.multiple` | record-picker component props 'searchFields'/'multiple' removed (#5775 — the control is a plain single-select with no search box; neither key had a reader) | retired — `migrate meta` only | | `page-card-body-to-children` | `page.component.page:card.body` | page:card component prop 'body' → 'children' (#5775 — one composition key across every container; the card renderer already reads both) | retired — `migrate meta` only | | `inline-action-api-params-to-body-extra` | `page.component.element:button.action.params` | inline type:'api' action prop 'params' (object form) → 'bodyExtra' (#5777 — the payload gets its own key; `params` stays the ActionParam[] definition array) | live — protocol 17 loader accepts the old shape | +| `page-tabs-type-to-tab-style` | `page.component.page:tabs.type` | page:tabs component prop 'type' → 'tabStyle' (#6776 — a props key named `type` collides with the node's dispatch key and is unauthorable in flat/JSX carriers; `tabStyle` is the spelling the renderer reads in all of them) | retired — `migrate meta` only | ### Semantic (delegated to you, with acceptance criteria) @@ -404,6 +407,9 @@ The same descriptor loses a key in this step, and the pairing is the point (#674 - **`action-descriptor-is-async-retired`** — `ActionDescriptor.isAsync (the descriptor an executor publishes via `registerNodeExecutor` / `defineActionDescriptor`)` → nothing to re-declare — delete the key. Suspension is `execute()` RETURNING `suspend: true`, and permission to suspend is `supportsPause: true` on the same descriptor (with the `resumeAuthority` its pauses need) - Why not automatic: ADR-0049 enforce-or-remove. `isAsync` declared "this action suspends the flow awaiting an external reply" and NOTHING read it: a fresh three-repo measurement (#6748, re-run at pickup) found zero property reads across objectstack, objectui and cloud — every hit was the declaration itself, a generated baseline, one of five shipped descriptors WRITING it, a test fixture pinning the shape, or prose. So declaring it never made a node suspend and omitting it never stopped one, which is the silently-inert declaration ADR-0049 exists to end. It was always a second, weaker spelling of the capability `supportsPause` states, and the two diverged in exactly the way a duplicated declaration does: `screen` declared both, `map` and `wait` declared `isAsync` alongside `supportsPause`, and nothing anywhere reconciled them. The sibling took the ENFORCE leg of the same ruling in #6667 — `AutomationEngine` now refuses a suspension whose type does not declare `supportsPause: true` — so the capability this key gestured at is now a real, enforced fact under one name. This one had no consumer to grow into and takes the remove leg. Why D3 semantic and not a D2 conversion: an ActionDescriptor is published from an executor's TypeScript, never stored in stack metadata — no stack, example or template carries the key — so there is no source for the chain to rewrite and `os migrate meta` cannot reach it. The schema tombstones it via `retiredKey()` and descriptor authors delete the key themselves; that rejection (a `tsc` error at the authoring site, and a parse error inside `defineActionDescriptor`) is the channel a third-party plugin author actually meets. The `EnhancedApiError.fieldErrors` disposition, one layer down. - Done when: No descriptor declares `isAsync` — not the five that shipped it (`screen`, `map`, `wait`, `approval`, `approval_revise`), not a plugin's. Every node type that returns `suspend: true` from `execute()` declares `supportsPause: true` on its descriptor together with a `resumeAuthority`, and its runs still pause and resume as before: the behaviour never depended on `isAsync`, so deleting the key changes no run. Authoring `isAsync` fails `tsc` at the descriptor literal and fails `defineActionDescriptor()` at runtime with the prescription, instead of parsing clean and being stripped. +- **`notification-list-cursor-retired`** — `api.listNotifications cursor — the key on BOTH halves of GET /api/v1/notifications (ListNotificationsRequestSchema and ListNotificationsResponseSchema) and the cursor argument of the client SDK call client.notifications.list(). The same entry covers the limit default: the request schema no longer declares default(20)` → a larger `limit` — the route answers the newest N notifications and has no page 2. There is no replacement for `cursor`, deliberately: nothing ever minted one, so no caller holds a value to carry over. Callers that looped on it were re-reading the first window and should read one window sized to what they display (the Console bell polls exactly this way). For the removed `limit` default, send the number you want explicitly if you were relying on 20 — omitting it takes the server window, which is 50 on the platform inbox and clamped into 1..200, and has been since before the declaration existed + - Why not automatic: One capability, both halves, never half-deleted (maintainer ruling 2026-08-07, Option A, ruled jointly with #6363). `cursor` was declared on the request and on the response and honoured on neither: the dispatcher domain reads `read` / `type` / `limit` and nothing else, and no emit site has ever written the response key. It was worse than inert because it had a shipped PRODUCER — the SDK appended it to the query string — so a caller paginating by the published contract looped on page 1 forever, with no error and no 400. Measured over a real boot with 60 unread before the removal: page2 === page1, both parsing green against the response schema, which is why no conformance gate could see it. This is `data.query.cursor` (#4286, `query-cursor-retired`) one layer up, with the same verdict for the same reason, down to deleting the SDK producer alongside the key. A first-class inbox cursor, if one is ever designed, will be a response-minted opaque token — a different API — so keeping this one preserved a wrong design rather than a roadmap. The `limit` default goes with it because the FICTION WAS THE MECHANISM, not the number: no request path parses a query string through this schema (#3899 wired the catalog's requestSchema to the real entry for BODIES only), so `.default(20)` never stamped anything onto anything, and the server has always applied its own 50. Re-spelling 20 as 50 — the other arm the ruling allowed — would have kept a declaration that does not execute and merely made it coincide with the implementation until someone moved the clamp; `.optional()` plus prose is true about both the schema and the server. No constraint (`.int()` / `.max(200)`) is declared either, because the service CLAMPS an out-of-range limit rather than refusing it, and declaring a rejection the wire does not perform is the same defect mirrored. Route 2, and the split is worth stating exactly because the two halves of the bookkeeping go different ways. There IS a tombstone: both schemas are non-strict, so a bare deletion would have made Zod SILENTLY STRIP whatever a caller kept sending — a clean parse and a parameter that never takes effect, which is this issue's own defect re-created one layer down (#3733, ADR-0104). So `cursor` is `retiredKey()` on both halves, typed `never` for tsc and raising the prescription at any parse, and both keys are registered in RETIRED_KEYS_BY_MAJOR[17]. There is NO D2 conversion: a conversion rewrites an authored source or a stored `sys_metadata` row, and these two shapes are HTTP-only — nobody authors a `ListNotificationsRequest` and nothing persists one. Request AND response shapes: two semantic TODOs for API callers, no stack conversion — the same disposition `BatchOptions.validateOnly` (#4052) and the `AnalyticsQueryRequest` envelope keys already take in this major. The `limit` default is declared separately and mechanically, in DEFAULT_CHANGES_BY_MAJOR[17] (#4666), whose `from`/`to` fingerprints are re-derived on every build. ADR-0049 / ADR-0078, #6361. + - Done when: No caller sends `cursor` to `GET /api/v1/notifications` and no SDK call site passes it: `client.notifications.list({ cursor })` is a `tsc` error (TS2353, excess property), which is the enforced channel — the removal is loud at compile time for every TypeScript consumer. Reading `response.cursor` no longer type-checks either, and always answered `undefined` before. ⚠️ Behaviour on the wire is deliberately UNCHANGED and must be verified as such: a request still carrying `?cursor=…` is IGNORED, not refused — the domain reads three named query keys and no route validates this query against a schema, so an unknown key has never produced a 400 and does not start doing so here. The declaration stopped promising what the wire never did; the wire did not change. `unreadCount` is untouched (#6363) and still reports the total across the whole matching inbox rather than the window. A caller that omitted `limit` receives the same 50 rows it always received. --- 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/storage-driver.ts b/packages/cli/src/utils/storage-driver.ts index 5914c4cd07..666fa05de4 100644 --- a/packages/cli/src/utils/storage-driver.ts +++ b/packages/cli/src/utils/storage-driver.ts @@ -139,11 +139,38 @@ function missingUrlMessage(kind: BuiltinDriverId): string { * 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; } } @@ -310,6 +337,9 @@ export function resolveStorageDefinition( + '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 }, ); } diff --git a/packages/spec/spec-changes.json b/packages/spec/spec-changes.json index 6719a751d1..8b0ff571ff 100644 --- a/packages/spec/spec-changes.json +++ b/packages/spec/spec-changes.json @@ -373,6 +373,12 @@ "to": "inline type:'api' action prop 'params' (object form) → 'bodyExtra' (#5777 — the payload gets its own key; `params` stays the ActionParam[] definition array)", "conversionId": "inline-action-api-params-to-body-extra", "toMajor": 17 + }, + { + "surface": "page.component.page:tabs.type", + "to": "page:tabs component prop 'type' → 'tabStyle' (#6776 — a props key named `type` collides with the node's dispatch key and is unauthorable in flat/JSX carriers; `tabStyle` is the spelling the renderer reads in all of them)", + "conversionId": "page-tabs-type-to-tab-style", + "toMajor": 17 } ], "migrated": [ @@ -725,6 +731,13 @@ "migrationId": "action-descriptor-is-async-retired", "toMajor": 17, "rationale": "ADR-0049 enforce-or-remove. `isAsync` declared \"this action suspends the flow awaiting an external reply\" and NOTHING read it: a fresh three-repo measurement (#6748, re-run at pickup) found zero property reads across objectstack, objectui and cloud — every hit was the declaration itself, a generated baseline, one of five shipped descriptors WRITING it, a test fixture pinning the shape, or prose. So declaring it never made a node suspend and omitting it never stopped one, which is the silently-inert declaration ADR-0049 exists to end. It was always a second, weaker spelling of the capability `supportsPause` states, and the two diverged in exactly the way a duplicated declaration does: `screen` declared both, `map` and `wait` declared `isAsync` alongside `supportsPause`, and nothing anywhere reconciled them. The sibling took the ENFORCE leg of the same ruling in #6667 — `AutomationEngine` now refuses a suspension whose type does not declare `supportsPause: true` — so the capability this key gestured at is now a real, enforced fact under one name. This one had no consumer to grow into and takes the remove leg. Why D3 semantic and not a D2 conversion: an ActionDescriptor is published from an executor's TypeScript, never stored in stack metadata — no stack, example or template carries the key — so there is no source for the chain to rewrite and `os migrate meta` cannot reach it. The schema tombstones it via `retiredKey()` and descriptor authors delete the key themselves; that rejection (a `tsc` error at the authoring site, and a parse error inside `defineActionDescriptor`) is the channel a third-party plugin author actually meets. The `EnhancedApiError.fieldErrors` disposition, one layer down." + }, + { + "surface": "api.listNotifications cursor — the key on BOTH halves of GET /api/v1/notifications (ListNotificationsRequestSchema and ListNotificationsResponseSchema) and the cursor argument of the client SDK call client.notifications.list(). The same entry covers the limit default: the request schema no longer declares default(20)", + "replacement": "a larger `limit` — the route answers the newest N notifications and has no page 2. There is no replacement for `cursor`, deliberately: nothing ever minted one, so no caller holds a value to carry over. Callers that looped on it were re-reading the first window and should read one window sized to what they display (the Console bell polls exactly this way). For the removed `limit` default, send the number you want explicitly if you were relying on 20 — omitting it takes the server window, which is 50 on the platform inbox and clamped into 1..200, and has been since before the declaration existed", + "migrationId": "notification-list-cursor-retired", + "toMajor": 17, + "rationale": "One capability, both halves, never half-deleted (maintainer ruling 2026-08-07, Option A, ruled jointly with #6363). `cursor` was declared on the request and on the response and honoured on neither: the dispatcher domain reads `read` / `type` / `limit` and nothing else, and no emit site has ever written the response key. It was worse than inert because it had a shipped PRODUCER — the SDK appended it to the query string — so a caller paginating by the published contract looped on page 1 forever, with no error and no 400. Measured over a real boot with 60 unread before the removal: page2 === page1, both parsing green against the response schema, which is why no conformance gate could see it. This is `data.query.cursor` (#4286, `query-cursor-retired`) one layer up, with the same verdict for the same reason, down to deleting the SDK producer alongside the key. A first-class inbox cursor, if one is ever designed, will be a response-minted opaque token — a different API — so keeping this one preserved a wrong design rather than a roadmap. The `limit` default goes with it because the FICTION WAS THE MECHANISM, not the number: no request path parses a query string through this schema (#3899 wired the catalog's requestSchema to the real entry for BODIES only), so `.default(20)` never stamped anything onto anything, and the server has always applied its own 50. Re-spelling 20 as 50 — the other arm the ruling allowed — would have kept a declaration that does not execute and merely made it coincide with the implementation until someone moved the clamp; `.optional()` plus prose is true about both the schema and the server. No constraint (`.int()` / `.max(200)`) is declared either, because the service CLAMPS an out-of-range limit rather than refusing it, and declaring a rejection the wire does not perform is the same defect mirrored. Route 2, and the split is worth stating exactly because the two halves of the bookkeeping go different ways. There IS a tombstone: both schemas are non-strict, so a bare deletion would have made Zod SILENTLY STRIP whatever a caller kept sending — a clean parse and a parameter that never takes effect, which is this issue's own defect re-created one layer down (#3733, ADR-0104). So `cursor` is `retiredKey()` on both halves, typed `never` for tsc and raising the prescription at any parse, and both keys are registered in RETIRED_KEYS_BY_MAJOR[17]. There is NO D2 conversion: a conversion rewrites an authored source or a stored `sys_metadata` row, and these two shapes are HTTP-only — nobody authors a `ListNotificationsRequest` and nothing persists one. Request AND response shapes: two semantic TODOs for API callers, no stack conversion — the same disposition `BatchOptions.validateOnly` (#4052) and the `AnalyticsQueryRequest` envelope keys already take in this major. The `limit` default is declared separately and mechanically, in DEFAULT_CHANGES_BY_MAJOR[17] (#4666), whose `from`/`to` fingerprints are re-derived on every build. ADR-0049 / ADR-0078, #6361." } ], "removed": [] @@ -1227,6 +1240,12 @@ "to": "inline type:'api' action prop 'params' (object form) → 'bodyExtra' (#5777 — the payload gets its own key; `params` stays the ActionParam[] definition array)", "conversionId": "inline-action-api-params-to-body-extra", "toMajor": 17 + }, + { + "surface": "page.component.page:tabs.type", + "to": "page:tabs component prop 'type' → 'tabStyle' (#6776 — a props key named `type` collides with the node's dispatch key and is unauthorable in flat/JSX carriers; `tabStyle` is the spelling the renderer reads in all of them)", + "conversionId": "page-tabs-type-to-tab-style", + "toMajor": 17 } ], "migrated": [ @@ -1509,6 +1528,13 @@ "migrationId": "action-descriptor-is-async-retired", "toMajor": 17, "rationale": "ADR-0049 enforce-or-remove. `isAsync` declared \"this action suspends the flow awaiting an external reply\" and NOTHING read it: a fresh three-repo measurement (#6748, re-run at pickup) found zero property reads across objectstack, objectui and cloud — every hit was the declaration itself, a generated baseline, one of five shipped descriptors WRITING it, a test fixture pinning the shape, or prose. So declaring it never made a node suspend and omitting it never stopped one, which is the silently-inert declaration ADR-0049 exists to end. It was always a second, weaker spelling of the capability `supportsPause` states, and the two diverged in exactly the way a duplicated declaration does: `screen` declared both, `map` and `wait` declared `isAsync` alongside `supportsPause`, and nothing anywhere reconciled them. The sibling took the ENFORCE leg of the same ruling in #6667 — `AutomationEngine` now refuses a suspension whose type does not declare `supportsPause: true` — so the capability this key gestured at is now a real, enforced fact under one name. This one had no consumer to grow into and takes the remove leg. Why D3 semantic and not a D2 conversion: an ActionDescriptor is published from an executor's TypeScript, never stored in stack metadata — no stack, example or template carries the key — so there is no source for the chain to rewrite and `os migrate meta` cannot reach it. The schema tombstones it via `retiredKey()` and descriptor authors delete the key themselves; that rejection (a `tsc` error at the authoring site, and a parse error inside `defineActionDescriptor`) is the channel a third-party plugin author actually meets. The `EnhancedApiError.fieldErrors` disposition, one layer down." + }, + { + "surface": "api.listNotifications cursor — the key on BOTH halves of GET /api/v1/notifications (ListNotificationsRequestSchema and ListNotificationsResponseSchema) and the cursor argument of the client SDK call client.notifications.list(). The same entry covers the limit default: the request schema no longer declares default(20)", + "replacement": "a larger `limit` — the route answers the newest N notifications and has no page 2. There is no replacement for `cursor`, deliberately: nothing ever minted one, so no caller holds a value to carry over. Callers that looped on it were re-reading the first window and should read one window sized to what they display (the Console bell polls exactly this way). For the removed `limit` default, send the number you want explicitly if you were relying on 20 — omitting it takes the server window, which is 50 on the platform inbox and clamped into 1..200, and has been since before the declaration existed", + "migrationId": "notification-list-cursor-retired", + "toMajor": 17, + "rationale": "One capability, both halves, never half-deleted (maintainer ruling 2026-08-07, Option A, ruled jointly with #6363). `cursor` was declared on the request and on the response and honoured on neither: the dispatcher domain reads `read` / `type` / `limit` and nothing else, and no emit site has ever written the response key. It was worse than inert because it had a shipped PRODUCER — the SDK appended it to the query string — so a caller paginating by the published contract looped on page 1 forever, with no error and no 400. Measured over a real boot with 60 unread before the removal: page2 === page1, both parsing green against the response schema, which is why no conformance gate could see it. This is `data.query.cursor` (#4286, `query-cursor-retired`) one layer up, with the same verdict for the same reason, down to deleting the SDK producer alongside the key. A first-class inbox cursor, if one is ever designed, will be a response-minted opaque token — a different API — so keeping this one preserved a wrong design rather than a roadmap. The `limit` default goes with it because the FICTION WAS THE MECHANISM, not the number: no request path parses a query string through this schema (#3899 wired the catalog's requestSchema to the real entry for BODIES only), so `.default(20)` never stamped anything onto anything, and the server has always applied its own 50. Re-spelling 20 as 50 — the other arm the ruling allowed — would have kept a declaration that does not execute and merely made it coincide with the implementation until someone moved the clamp; `.optional()` plus prose is true about both the schema and the server. No constraint (`.int()` / `.max(200)`) is declared either, because the service CLAMPS an out-of-range limit rather than refusing it, and declaring a rejection the wire does not perform is the same defect mirrored. Route 2, and the split is worth stating exactly because the two halves of the bookkeeping go different ways. There IS a tombstone: both schemas are non-strict, so a bare deletion would have made Zod SILENTLY STRIP whatever a caller kept sending — a clean parse and a parameter that never takes effect, which is this issue's own defect re-created one layer down (#3733, ADR-0104). So `cursor` is `retiredKey()` on both halves, typed `never` for tsc and raising the prescription at any parse, and both keys are registered in RETIRED_KEYS_BY_MAJOR[17]. There is NO D2 conversion: a conversion rewrites an authored source or a stored `sys_metadata` row, and these two shapes are HTTP-only — nobody authors a `ListNotificationsRequest` and nothing persists one. Request AND response shapes: two semantic TODOs for API callers, no stack conversion — the same disposition `BatchOptions.validateOnly` (#4052) and the `AnalyticsQueryRequest` envelope keys already take in this major. The `limit` default is declared separately and mechanically, in DEFAULT_CHANGES_BY_MAJOR[17] (#4666), whose `from`/`to` fingerprints are re-derived on every build. ADR-0049 / ADR-0078, #6361." } ], "removed": []