From b9b94eabdd2f4c30aa9ea8730fc4b0762ac5836b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 01:45:08 +0000 Subject: [PATCH 1/7] wip(spec): retire datasource driver 'mongo' as a value Claude-Session: https://claude.ai/code/session_01LFqdRsuM5nkqoJdHd5ztVk Co-authored-by: Claude --- .../spec/src/conversions/conversions.test.ts | 67 +++++++---- packages/spec/src/conversions/registry.ts | 50 ++++---- packages/spec/src/data/datasource.test.ts | 8 +- packages/spec/src/data/datasource.zod.ts | 17 ++- .../src/data/driver/config-registry.test.ts | 72 +++++++++++- .../src/data/driver/config-registry.zod.ts | 109 ++++++++++++++++-- packages/spec/src/data/driver/mongo.test.ts | 5 +- packages/spec/src/data/driver/mongo.zod.ts | 3 +- ...8.datasource-driver-mongo-alias-retired.ts | 42 +++++++ .../retired-key-removal-version.pin.test.ts | 10 ++ .../spec/src/stack-authored-input.test.ts | 53 ++++++--- 11 files changed, 360 insertions(+), 76 deletions(-) create mode 100644 packages/spec/src/migrations/entries/semantic/18.datasource-driver-mongo-alias-retired.ts diff --git a/packages/spec/src/conversions/conversions.test.ts b/packages/spec/src/conversions/conversions.test.ts index 09b5da4a4be..c650057178d 100644 --- a/packages/spec/src/conversions/conversions.test.ts +++ b/packages/spec/src/conversions/conversions.test.ts @@ -400,7 +400,6 @@ describe('conversion layer (ADR-0087 D2)', () => { it('every entry whose window ran out before 18 is retired or kept by name', () => { const kept = ALL_CONVERSIONS.filter((c) => c.toMajor < 18 && !c.retiredFromLoadPath).map((c) => c.id).sort(); expect(kept).toEqual([ - 'datasource-driver-mongo-to-mongodb', 'flow-node-connector-config-lift', 'flow-node-http-callout-rename', 'flow-node-wait-event-config-lift', @@ -417,6 +416,8 @@ describe('conversion layer (ADR-0087 D2)', () => { 'flow-node-subflow-flow-alias', 'flow-node-script-config-aliases', 'sharing-rule-access-level-full-to-edit', + // #23543 F1: retired once `mongo` itself was retired as a value. + 'datasource-driver-mongo-to-mongodb', ]) { expect(ALL_CONVERSIONS.find((c) => c.id === id), id).toMatchObject({ retiredFromLoadPath: true, retiredAfter: '17.7.0' }); } @@ -962,11 +963,12 @@ describe('conversion layer (ADR-0087 D2)', () => { }); }); - // Commit e2798fab7 — 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. + // Commit e2798fab7 renamed the canonical id `mongo` → `mongodb`; #23543 F1 + // retired `mongo` as a value at 18. Three claims hold together: the stored + // value still CONVERGES (the data-at-rest seams replay the retired entry), an + // AUTHORED `mongo` meets the prescription instead of a silent rewrite, and a + // stored row the chain has not reached keeps its contract — the half that + // keeps its credential redaction (`DriverVocabularyEntry.retiredAliases`). describe('datasource-driver-mongo-to-mongodb', () => { const convert = (datasources: unknown[]) => collectConversionNotices({ datasources }, { includeRetired: true }); @@ -975,6 +977,8 @@ describe('conversion layer (ADR-0087 D2)', () => { 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'); + // …and the converted row parses: the refusal below is never what a stored row meets. + expect(DatasourceSchema.safeParse(out).success).toBe(true); }); it('converts case- and whitespace-insensitively, exactly as the resolver reads it', () => { @@ -997,23 +1001,46 @@ describe('conversion layer (ADR-0087 D2)', () => { 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', () => { + it('is retired from the load path — the authoring funnel no longer rewrites an authored `mongo`', () => { + const before = { datasources: [{ name: 'events', driver: 'mongo', config: { url: 'mongodb://db/x' } }] }; + const { stack, notices } = collectConversionNotices(structuredClone(before)); + expect(stack).toEqual(before); + expect(notices).toHaveLength(0); + }); + + it('an authored `mongo` reaches defineStack as written and is refused naming `mongodb` and the release', () => { + let refusal: unknown; + try { + defineStack({ + datasources: [{ name: 'events', label: 'Events', driver: 'mongo', config: { url: 'mongodb://db/x' } }], + } as never); + } catch (error) { + refusal = error; + } + expect(refusal).toMatchObject({ code: 'STACK_SCHEMA_INVALID', status: 422 }); + const issues = (refusal as { issues: { path: PropertyKey[]; message: string }[] }).issues; + // The driver is the ONE issue: the config is a valid mongodb config, so + // the refusal is about the spelling alone. + expect(issues.map((i) => i.path.join('.'))).toEqual(['datasources.0.driver']); + expect(issues[0]!.message).toMatch(/^Datasource `driver: 'mongo'` was removed in @objectstack\/spec 18 \(ADR-0087 D2\)/); + expect(issues[0]!.message).toContain("Use `driver: 'mongodb'`"); + // Case and whitespace do not slip past it, and the canonical id is untouched. + for (const driver of ['Mongo', ' MONGO ']) { + const parsed = DatasourceSchema.safeParse({ name: 'events', driver, config: { url: 'mongodb://db/x' } }); + expect(parsed.success, driver).toBe(false); + expect(parsed.error!.issues.map((i) => i.path.join('.')), driver).toEqual(['driver']); + } + expect(DatasourceSchema.safeParse({ name: 'events', driver: 'mongodb', config: { url: 'mongodb://db/x' } }).success).toBe(true); + }); + + // The other half of the migration proof. A stored row the chain has not + // reached (a raw body served by the read-path redactor, say) still resolves + // the mongodb contract: deleting the spelling outright was measured to + // serve every mongodb passthrough secret of such a row in cleartext. + it('an UNCONVERTED stored `mongo` row keeps its contract — same schema, same validation', () => { 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); }); }); diff --git a/packages/spec/src/conversions/registry.ts b/packages/spec/src/conversions/registry.ts index dfc823a26a1..9b39d41b1f8 100644 --- a/packages/spec/src/conversions/registry.ts +++ b/packages/spec/src/conversions/registry.ts @@ -3601,13 +3601,14 @@ const datasourceConfigDriverKeyAliases: MetadataConversion = { }; /** - * `datasource.driver: 'mongo'` → `'mongodb'` (protocol 17, commit e2798fab7). + * `datasource.driver: 'mongo'` → `'mongodb'` (protocol 17, commit e2798fab7; + * retired from the load path at 18, #23543 F1). * * ## 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 + * `mongo` and `mongodb` were both accepted spellings from #4410 through 17 — + * this conversion never rescued a broken boot, and a stored row it has not + * reached still resolves the same contract and builds the same driver. What moved is the * CANONICAL id: commit e2798fab7 landed the ruling that 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 @@ -3635,26 +3636,35 @@ const datasourceConfigDriverKeyAliases: MetadataConversion = { * * Losslessness decides only that the data repair is D2. It does not decide * whether the family ALSO owes a D3 entry — every retirement family does - * (`SemanticMigration`, `migrations/types.ts`). This one converts into - * protocol 17, whose step shipped before that rule and was not back-filled, - * so it has none. - * - * ## 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. - * - * **A recorded verdict at 18 (#23487):** it stays. Retiring it needs a decision - * first: retire `'mongo'` as a value, refused with a prescription naming - * `mongodb`, or record this entry as permanent. While the value is legal there - * is no refusal for a retirement to hand the author to. + * (`SemanticMigration`, `migrations/types.ts`). The rename into protocol 17 + * shipped before that rule and was not back-filled; the value's retirement at + * 18 is a family of its own, and its D3 entry is + * `datasource-driver-mongo-alias-retired`. + * + * ## Retired from the load path at 18 (#23543, ruling F1 A) + * + * Through 17 it stayed live because `mongo` was still a legal alias, so there + * was no refusal for a retirement to hand the author to (the verdict #23487 + * recorded). The ruling retired the value: `mongo` left the selection face, and + * `DriverType` refuses an authored `datasource.driver: 'mongo'` by name with + * the prescription naming `mongodb` (`DriverVocabularyEntry.retiredAliases`, + * `data/driver/config-registry.zod.ts`). So the authoring funnel no longer + * replays this entry, and an author is taught the canonical id instead of + * being rewritten. Stored rows are the `includeRetired` seams: stored-row + * rehydration (`applyConversionsToStoredItem`) and the artifact door still + * rewrite them, and `os migrate meta --from 16` lists the edit for existing + * sources, as step 17's mechanical member. The spelling keeps resolving the + * mongodb CONTRACT, which is what lets + * {@link datasourceConfigDriverKeyAliases} — ordered ahead of this entry — + * still reach a stored `driver: 'mongo'` row's legacy config keys. That D3 + * entry names what the chain cannot reach: an environment variable, a REST + * caller, a script. */ const datasourceDriverMongoToMongodb: MetadataConversion = { id: 'datasource-driver-mongo-to-mongodb', toMajor: 17, + retiredFromLoadPath: true, + retiredAfter: '17.7.0', surface: 'datasource.driver', summary: "datasource driver id 'mongo' → 'mongodb' — the canonical id both boot hosts, the driver " diff --git a/packages/spec/src/data/datasource.test.ts b/packages/spec/src/data/datasource.test.ts index f8c6c4ced1b..8be05f3cff4 100644 --- a/packages/spec/src/data/datasource.test.ts +++ b/packages/spec/src/data/datasource.test.ts @@ -235,16 +235,18 @@ describe('DatasourceSchema', () => { expect(datasource.driver).toBe('mysql'); }); + // `mongodb`: the retired `mongo` spelling is refused by name since 18 + // (`config-registry.test.ts`, the retired `mongo` describe). it('should accept MongoDB datasource', () => { const datasource = DatasourceSchema.parse({ name: 'mongo_db', - driver: 'mongo', + driver: 'mongodb', config: { url: 'mongodb://localhost:27017/mydb', }, }); - expect(datasource.driver).toBe('mongo'); + expect(datasource.driver).toBe('mongodb'); }); // This fixture used to spell the URI `connectionString`, a key the mongo @@ -254,7 +256,7 @@ describe('DatasourceSchema', () => { it('rejects a mongo config that spells the URI `connectionString`', () => { const result = DatasourceSchema.safeParse({ name: 'mongo_db', - driver: 'mongo', + driver: 'mongodb', config: { connectionString: 'mongodb://localhost:27017/mydb' }, }); diff --git a/packages/spec/src/data/datasource.zod.ts b/packages/spec/src/data/datasource.zod.ts index b959a3ec1b2..0e3dfb313c9 100644 --- a/packages/spec/src/data/datasource.zod.ts +++ b/packages/spec/src/data/datasource.zod.ts @@ -5,7 +5,7 @@ import { lazySchema } from '../shared/lazy-schema'; import { strictObject } from '../shared/strict-object'; import { MetadataProtectionFields } from '../kernel/metadata-protection.zod'; import { urlUserinfoUsername } from './driver/common.zod'; -import { resolveDriverId, validateDriverConfig } from './driver/config-registry.zod'; +import { resolveDriverId, retiredDriverIdGuidance, validateDriverConfig } from './driver/config-registry.zod'; /* * ── Unknown-key strictness (#4001 data step, closed out by #4410) ─────────── @@ -171,8 +171,21 @@ const RETIRED_READ_REPLICAS = /** * Driver Identifier * Can be a built-in driver or a plugin-contributed driver (e.g., "com.vendor.snowflake"). + * + * An open namespace, so a `z.string()` — with one closed exception: a spelling + * the driver vocabulary RETIRED (`mongo`, at 18) is refused by name, with the + * prescription naming its canonical id + * (`DriverVocabularyEntry.retiredAliases`, `driver/config-registry.zod.ts`). + * Data at rest never reaches this refusal: every stored-row rehydration seam + * replays `datasource-driver-mongo-to-mongodb` before it parses. */ -export const DriverType = z.string().describe('Underlying driver identifier'); +export const DriverType = z + .string() + .superRefine((driver, ctx) => { + const guidance = retiredDriverIdGuidance(driver); + if (guidance !== undefined) ctx.addIssue({ code: 'custom', message: guidance }); + }) + .describe('Underlying driver identifier'); export type DriverType = z.input; /** diff --git a/packages/spec/src/data/driver/config-registry.test.ts b/packages/spec/src/data/driver/config-registry.test.ts index 41cd6fd8662..5f53d6f4be8 100644 --- a/packages/spec/src/data/driver/config-registry.test.ts +++ b/packages/spec/src/data/driver/config-registry.test.ts @@ -15,6 +15,7 @@ import { getDriverConfigSchema, resolveDatabaseDriverId, resolveDriverId, + retiredDriverIdGuidance, validateDriverConfig, } from './config-registry.zod'; @@ -41,7 +42,8 @@ describe('driver config registry', () => { it('resolves case- and whitespace-insensitively', () => { expect(resolveDriverId(' PostgreSQL ')).toBe('postgres'); - // `mongodb`, not `mongo`, since commit e2798fab7 renamed the canonical id. + // `mongodb`, not `mongo`, since commit e2798fab7 renamed the canonical id; + // `mongo` is retired at 18 but still resolves the CONTRACT (see the row's describe). expect(resolveDriverId('MongoDB')).toBe('mongodb'); expect(resolveDriverId(' Mongo ')).toBe('mongodb'); }); @@ -259,12 +261,14 @@ describe('the `memory` row: contract face kept, selection face withdrawn', () => }); it('moved no other row: the selection face is exactly the six other drivers\' spellings', () => { + // `mongo` left this face at 18 for the `mongodb` row's retired column + // (pinned in its own describe below); no other spelling moved. expect([...DATABASE_DRIVER_SELECTION_ALIASES]).toEqual([ 'sqlite', 'sql', 'sqlite-wasm', 'wasm-sqlite', 'wasm', 'postgres', 'postgresql', 'pg', 'mysql', 'mysql2', - 'mongodb', 'mongo', + 'mongodb', 'turso', 'libsql', ]); expect([...DATABASE_DRIVER_SELECTION_IDS]).toEqual(['sqlite', 'sqlite-wasm', 'postgres', 'mysql', 'mongodb', 'turso']); @@ -272,6 +276,70 @@ describe('the `memory` row: contract face kept, selection face withdrawn', () => }); }); +/** + * The `mongodb` row's retired spelling `mongo` (#23543, ruling F1 A): refused + * at authoring by name, offered by no boot selection, and kept on the CONTRACT + * face — the half a raw stored legacy row's credential redaction rests on + * (`datasource-credential-redaction.test.ts`). + */ +describe('the retired `mongo` spelling: authoring refused, selection withdrawn, contract kept', () => { + const VALID_CONFIG = { url: 'mongodb://db.internal:27017/events' }; + + it('is refused at authoring with the prescription naming `mongodb`, as the only issue', () => { + for (const driver of ['mongo', 'Mongo', ' MONGO ']) { + const result = DatasourceSchema.safeParse({ name: 'events', driver, config: VALID_CONFIG }); + expect(result.success, driver).toBe(false); + expect(result.error!.issues, driver).toHaveLength(1); + const [issue] = result.error!.issues; + expect(issue!.code, driver).toBe('custom'); + expect(issue!.path, driver).toEqual(['driver']); + expect(issue!.message, driver).toMatch( + /^Datasource `driver: 'mongo'` was removed in @objectstack\/spec 18 \(ADR-0087 D2\)/, + ); + expect(issue!.message, driver).toContain("Use `driver: 'mongodb'`"); + } + }); + + it('still judges the config of a refused `mongo` datasource — the driver refusal hides nothing', () => { + const result = DatasourceSchema.safeParse({ name: 'events', driver: 'mongo', config: { hostname: 'db.internal' } }); + expect(result.success).toBe(false); + const paths = result.error!.issues.map((i) => i.path.join('.')); + expect(paths).toContain('driver'); + expect(paths).toContain('config.hostname'); + }); + + it('is offered by no boot selection', () => { + expect(DATABASE_DRIVER_SELECTION_ALIASES).not.toContain('mongo'); + expect(resolveDatabaseDriverId('mongo')).toBeUndefined(); + expect(resolveDatabaseDriverId('mongodb')).toBe('mongodb'); + }); + + it('keeps the contract face, so a stored legacy row is judged, redacted and converged as `mongodb`', () => { + expect(resolveDriverId('mongo')).toBe('mongodb'); + expect(getDriverConfigSchema('mongo')).toBe(DRIVER_CONFIG_SCHEMAS.mongodb); + expect(validateDriverConfig('mongo', VALID_CONFIG)).toEqual({ known: true, issues: [] }); + }); + + it('answers a prescription for the retired spelling only — never a live id, a plugin id or a prototype word', () => { + expect(retiredDriverIdGuidance(' Mongo ')).toBe(retiredDriverIdGuidance('mongo')); + for (const value of ['mongodb', 'pg', 'memory', 'com.vendor.mongolike', 'constructor', '__proto__', '', 42, null, undefined]) { + expect(retiredDriverIdGuidance(value), String(value)).toBeUndefined(); + } + }); + + it('a retired spelling is no live spelling of any row — the refusal could never fire on a legal value', () => { + const live = new Set([...DATABASE_DRIVER_SELECTION_ALIASES, ...BUILTIN_DRIVER_IDS]); + for (const alias of Object.keys(DRIVER_ID_ALIASES)) { + if (retiredDriverIdGuidance(alias) === undefined) continue; + expect(live.has(alias), alias).toBe(false); + // …and every retired spelling still resolves a contract (the kept half). + expect(resolveDriverId(alias), alias).toBeDefined(); + } + // Anti-vacuity: the loop above judged at least the one retirement this row carries. + expect(Object.keys(DRIVER_ID_ALIASES).filter((a) => retiredDriverIdGuidance(a) !== undefined)).toEqual(['mongo']); + }); +}); + /** * The OFF-VOCABULARY population — the one the pins above cannot reach (#16903). * diff --git a/packages/spec/src/data/driver/config-registry.zod.ts b/packages/spec/src/data/driver/config-registry.zod.ts index 0641a2c8ff6..6ea8515f2db 100644 --- a/packages/spec/src/data/driver/config-registry.zod.ts +++ b/packages/spec/src/data/driver/config-registry.zod.ts @@ -2,6 +2,7 @@ import type { z } from 'zod'; +import type { RetiredValueGuidance } from '../../shared/retired-key'; import { getMemoryConfigJsonSchema, MemoryConfigSchema } from './memory.zod'; import { getMongoConfigJsonSchema, MongoConfigSchema } from './mongo.zod'; import { getMysqlConfigJsonSchema, MysqlConfigSchema } from './mysql.zod'; @@ -115,8 +116,26 @@ import { getTursoConfigJsonSchema, TursoConfigSchema } from './turso.zod'; * `contractId` column, because after the rename it would have been `undefined` * on every row, and a column that is always empty is the inert declaration this * repo removes rather than ships. Stored `datasource.driver: 'mongo'` is - * converged by the ADR-0087 conversion `datasource-driver-mongo-to-mongodb`; - * `mongo` also stays an alias, so nothing that skipped the conversion breaks. + * converged by the ADR-0087 conversion `datasource-driver-mongo-to-mongodb`. + * + * ## `mongo` retired as a value at 18 (#23543, ruling F1 A) + * + * Through 17, `mongo` stayed a selection alias and the loader rewrote an + * authored one with a notice. At 18 it is retired: it left the selection face + * (no boot host accepts `OS_DATABASE_DRIVER=mongo`), and an authored + * `datasource.driver: 'mongo'` is refused at parse with the prescription naming + * `mongodb` ({@link retiredDriverIdGuidance}, read by `DriverType`). It lives on + * as a {@link DriverVocabularyEntry.retiredAliases} key, which keeps it on the + * CONTRACT face only — and that half is load-bearing, not a second spelling. + * Data at rest never meets the refusal: a stored row is replayed through the + * whole conversion chain before anything parses it. But some readers judge a + * stored body RAW, by design: the read-path credential redactor runs on the + * stored `metadata` column as written (`redactStoredMetadataBody`), and it + * keys its mongodb passthrough-secret table by canonical id. Measured with the + * alias deleted outright: a stored legacy `driver: 'mongo'` row served all ten + * of those secrets (`options.proxyPassword`, `options.key`, the KMS provider + * secrets, …) in cleartext through that exit. So the spelling still resolves + * the mongodb contract, and nothing an author or an operator writes accepts it. * * `turso`/libSQL was the mirror image: both hosts dispatched it, and spec shipped * no contract, so it resolved to `undefined` and its `config` was the one @@ -141,7 +160,8 @@ export interface DriverVocabularyEntry { * engine — has none, and keeps its spellings on * {@link contractOnlyAliases}. This is the face both boot hosts accept, and * the ruling fixes it as the union of what they accepted before commit - * e2798fab7 — never widened by accident. + * e2798fab7 — never widened by accident, and narrowed only by a ruling: + * `mongo` left it at 18 for {@link retiredAliases}. */ readonly aliases: readonly string[]; /** @@ -155,6 +175,29 @@ export interface DriverVocabularyEntry { * and kept apart. */ readonly contractOnlyAliases?: readonly string[]; + /** + * Spellings RETIRED as authored values, each mapped to the prescription an + * author meets instead (`mongo` on the `mongodb` row, retired at 18). + * + * Three faces, each a deliberate answer: + * + * - **authoring** — refused by name: `DriverType` (`datasource.zod.ts`) + * raises the prescription for an authored `datasource.driver`, through + * {@link retiredDriverIdGuidance}. `driver` is an open namespace (a plugin + * ships `com.vendor.snowflake`), so the field is `z.string()` and + * `enumWithRetiredValues` cannot carry this; the prescription type and + * its writing conventions are that helper's ({@link RetiredValueGuidance}). + * - **selection** — absent: no boot host accepts it, because it is not in + * {@link aliases}. + * - **contract** — kept: {@link resolveDriverId} still answers this row's + * id, so a stored legacy row is judged, redacted and converged exactly as + * a canonical one. The module docblock records what deleting it measured. + * + * A key here must be no row's {@link id} and appear in no row's + * {@link aliases} — a refusal of a legal selection could never be obeyed + * (`config-registry.test.ts`). + */ + readonly retiredAliases?: RetiredValueGuidance; /** * Can this driver be selected with no database URL at all? * @@ -168,6 +211,21 @@ export interface DriverVocabularyEntry { readonly hasLocalDefault: boolean; } +/** + * The prescription for an authored `datasource.driver: 'mongo'` — the + * `mongodb` row's one {@link DriverVocabularyEntry.retiredAliases} entry. + * Written to `enumWithRetiredValues`' conventions: the value first, the release + * and ADR that removed it, why, the one-line fix, and the house `os migrate + * meta` sentence (`datasource-driver-mongo-to-mongodb` is a `toMajor: 17` + * entry, so its step is reached by `--from 16`). + */ +const MONGO_DRIVER_ID_RETIRED = + "Datasource `driver: 'mongo'` was removed in @objectstack/spec 18 (ADR-0087 D2) — the MongoDB driver's " + + 'id has been `mongodb` since 17, the spelling both boot hosts, the `@objectstack/driver-mongodb` package ' + + "and every connection URL scheme use, and the loader rewrote `mongo` to it until this release. Use `driver: 'mongodb'`. " + + 'Change the value; the driver it builds and the `config` contract it is judged against are unchanged. ' + + 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.'; + /** * THE table. Everything else in this module is a projection of it. * @@ -180,7 +238,7 @@ const DRIVER_VOCABULARY = [ { id: 'sqlite-wasm', aliases: ['sqlite-wasm', 'wasm-sqlite', 'wasm'], hasLocalDefault: true }, { id: 'postgres', aliases: ['postgres', 'postgresql', 'pg'], hasLocalDefault: false }, { id: 'mysql', aliases: ['mysql', 'mysql2'], contractOnlyAliases: ['mariadb'], hasLocalDefault: false }, - { id: 'mongodb', aliases: ['mongodb', 'mongo'], hasLocalDefault: false }, + { id: 'mongodb', aliases: ['mongodb'], retiredAliases: { mongo: MONGO_DRIVER_ID_RETIRED }, hasLocalDefault: false }, { id: 'turso', aliases: ['turso', 'libsql'], hasLocalDefault: false }, ] as const satisfies readonly DriverVocabularyEntry[]; @@ -220,19 +278,50 @@ export type BuiltinDriverId = (typeof DRIVER_VOCABULARY)[number]['id']; * 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. + * Covers EVERY face — selection aliases, contract-only ones and retired 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. A retired spelling (`mongo`) is here for + * the same reason: the authoring parse refuses it by name, but a stored legacy + * row keeps its contract, its credential redaction and its conversions. */ 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), + [ + ...entry.aliases, + ...(entry.contractOnlyAliases ?? []), + ...Object.keys(entry.retiredAliases ?? {}), + ].map((alias) => [alias, entry.id] as const), ), ) as Record, ); +/** + * Retired spelling → its prescription, across every row — the lookup table + * behind {@link retiredDriverIdGuidance}. + */ +const RETIRED_DRIVER_ID_GUIDANCE: Readonly> = Object.freeze( + Object.fromEntries(VOCABULARY_ROWS.flatMap((entry) => Object.entries(entry.retiredAliases ?? {}))), +); + +/** + * The prescription for a RETIRED driver spelling, or `undefined` for every + * other value — a live alias, a plugin's driver id, a typo, a non-string. + * + * Read the way {@link resolveDriverId} reads a spelling (trimmed, + * case-insensitive, own keys only), so `' Mongo '` meets the same refusal the + * resolver would have resolved. What `DriverType` refuses an authored + * `datasource.driver` with; see {@link DriverVocabularyEntry.retiredAliases} + * for why the spelling still resolves the contract. + */ +export function retiredDriverIdGuidance(driver: unknown): string | undefined { + if (typeof driver !== 'string') return undefined; + const spelling = driver.trim().toLowerCase(); + if (!Object.prototype.hasOwnProperty.call(RETIRED_DRIVER_ID_GUIDANCE, spelling)) return undefined; + return RETIRED_DRIVER_ID_GUIDANCE[spelling]; +} + /** * The spellings a BOOT HOST accepts as a driver selection — every selection * alias of every builtin, sorted for a stable refusal message. @@ -355,7 +444,7 @@ export function resolveDatabaseDriverId(driver: unknown): BuiltinDriverId | unde * * Built by filtering {@link DATABASE_DRIVER_SELECTION_ALIASES} — the * {@link DriverVocabularyEntry.aliases} face — down to the spellings that resolve - * to THEMSELVES. Eight of its fourteen entries are dropped by that filter (`pg`, + * to THEMSELVES. Seven of its thirteen entries are dropped by that filter (`pg`, * `sql`, `wasm`, `libsql`, …): they select a driver, but under another driver's * canonical name, and an allowlist that offered both would be advertising one * driver twice. diff --git a/packages/spec/src/data/driver/mongo.test.ts b/packages/spec/src/data/driver/mongo.test.ts index bdc32e1b4b5..bb9036558fe 100644 --- a/packages/spec/src/data/driver/mongo.test.ts +++ b/packages/spec/src/data/driver/mongo.test.ts @@ -100,8 +100,9 @@ describe('MongoConfigSchema', () => { describe('MongoDriverSpec', () => { // `mongodb` since commit e2798fab7: 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`. + // selection and config-contract selection are one string. `mongo` was retired + // as a value at 18 (refused when authored, kept on the contract face) — + // pinned in `config-registry.test.ts`. it('should have correct id', () => { expect(MongoDriverSpec.id).toBe('mongodb'); }); diff --git a/packages/spec/src/data/driver/mongo.zod.ts b/packages/spec/src/data/driver/mongo.zod.ts index 25b9c689165..330f5b8b81c 100644 --- a/packages/spec/src/data/driver/mongo.zod.ts +++ b/packages/spec/src/data/driver/mongo.zod.ts @@ -184,7 +184,8 @@ export const MongoDriverSpec = { // `mongodb`, not `mongo`, since commit e2798fab7: 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. + // selection are one string. `mongo` was retired as a value at 18: refused + // when authored, it still resolves this contract for a stored legacy row. id: 'mongodb', label: 'MongoDB', description: 'Official MongoDB Driver for ObjectStack. Supports rich queries, aggregation, and atomic updates.', diff --git a/packages/spec/src/migrations/entries/semantic/18.datasource-driver-mongo-alias-retired.ts b/packages/spec/src/migrations/entries/semantic/18.datasource-driver-mongo-alias-retired.ts new file mode 100644 index 00000000000..92f5d149ac9 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.datasource-driver-mongo-alias-retired.ts @@ -0,0 +1,42 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// #23543 F1 — the value retirement's one D3 entry. The D2 half is +// `datasource-driver-mongo-to-mongodb` (a step-17 conversion), which rewrites +// every `driver: 'mongo'` the chain can reach and is now retired from the load +// path; this entry carries what the chain cannot reach — the spellings that +// live outside metadata sources. +export const entry: SemanticMigration = { + id: 'datasource-driver-mongo-alias-retired', + // No backticks and no pipes in `surface` — build-upgrade-guide.ts renders it + // inside a code span AND a table cell. + surface: + "datasource.driver authored as 'mongo' (DriverType, data/datasource.zod.ts), and the same spelling " + + 'as a boot driver selection (OS_DATABASE_DRIVER, the standalone stack databaseDriver option)', + replacement: + "`driver: 'mongodb'` — the MongoDB driver's canonical id since 17, and `OS_DATABASE_DRIVER=mongodb` " + + 'for a boot selection', + reason: + "A maintainer ruling retired `mongo` as a value at 18. Through 17 it was a second accepted spelling of " + + "`mongodb`: the loader rewrote an authored `datasource.driver: 'mongo'` with a notice, and both boot " + + 'hosts accepted it as a driver selection. At 18 the authoring parse refuses it by name with the ' + + 'prescription naming `mongodb`, and it is no longer a selection spelling, so a boot that sets ' + + '`OS_DATABASE_DRIVER=mongo` is refused with the list of supported drivers. The D2 conversion ' + + '`datasource-driver-mongo-to-mongodb` makes the mechanical edit wherever the chain reaches: ' + + "`os migrate meta` lists every `datasources[].driver: 'mongo'` in your metadata sources, and stored " + + 'datasource rows keep loading because every rehydration seam replays it. What the chain cannot ' + + 'reach is everything outside metadata sources: an environment variable or deployment manifest ' + + 'setting `OS_DATABASE_DRIVER=mongo`, a program that passes `databaseDriver: \'mongo\'` to the ' + + 'standalone stack, and a script or integration that creates or updates a datasource through the ' + + 'metadata or datasource REST API with `driver: \'mongo\'`. Each of those is refused at 18 and needs ' + + 'the same one-word change by hand. Nothing about the connection changes: `mongo` and `mongodb` ' + + 'always built the same driver against the same config contract.', + acceptanceCriteria: + "`os validate` reports no refusal at a `datasources[].driver` path; no deployment environment, " + + "manifest or launch script sets `OS_DATABASE_DRIVER=mongo`, and the process boots on MongoDB with " + + "`OS_DATABASE_DRIVER=mongodb` (or with the driver chosen by a `mongodb://` URL scheme and no " + + 'override); every script or integration that writes a datasource sends `driver: \'mongodb\'` and ' + + 'gets a success answer from the API.', + conversionIds: ['datasource-driver-mongo-to-mongodb'], +}; diff --git a/packages/spec/src/shared/retired-key-removal-version.pin.test.ts b/packages/spec/src/shared/retired-key-removal-version.pin.test.ts index 5a3062c01af..47a96d122e4 100644 --- a/packages/spec/src/shared/retired-key-removal-version.pin.test.ts +++ b/packages/spec/src/shared/retired-key-removal-version.pin.test.ts @@ -87,6 +87,7 @@ import { import { NotifyConfigSchema } from '../automation/io-node-config.zod'; import { ScriptConfigSchema, SubflowConfigSchema } from '../automation/schemaless-node-config.zod'; import { SharingLevel } from '../security/sharing.zod'; +import { DatasourceSchema } from '../data/datasource.zod'; import { PROTOCOL_MAJOR } from '../kernel/protocol-version'; import { EmailTemplateDefinitionSchema } from '../system/email-template.zod'; import { @@ -295,6 +296,15 @@ const JOIN: Readonly> = { 'sharing-rule-access-level-full-to-edit': [ refusal("sharing-rule accessLevel 'full'", () => SharingLevel.safeParse('full')), ], + // #23543 F1: an open-namespace value retired by name (`DriverType`); the + // config is a valid mongodb config, so `driver` is the only issue. + 'datasource-driver-mongo-to-mongodb': [ + refusal( + "datasource driver 'mongo'", + () => DatasourceSchema.safeParse({ name: 'events', driver: 'mongo', config: { url: 'mongodb://db/x' } }), + 'driver', + ), + ], // Shipped by a 17.x minor; `toMajor: 18` like the rows above. 'page-header-breadcrumb-removed': [tombstone(PageHeaderProps, 'page:header', 'breadcrumb')], 'object-grid-default-sort-removed': [tombstone(ObjectGridPropsSchema, 'object-grid', 'defaultSort')], diff --git a/packages/spec/src/stack-authored-input.test.ts b/packages/spec/src/stack-authored-input.test.ts index 4cd9215a847..b918a9329c5 100644 --- a/packages/spec/src/stack-authored-input.test.ts +++ b/packages/spec/src/stack-authored-input.test.ts @@ -36,17 +36,27 @@ afterEach(() => { /** Spelled here, by its registry name, exactly as the CLI spells it: the key is not exported. */ const AUTHORED_INPUT = Symbol.for('objectstack.stack.authoredInput'); -const MONGO = 'datasource-driver-mongo-to-mongodb'; +const RETRY = 'retry-policy-converged'; /** - * A stack the strict parse accepts, whose one non-canonical spelling is a - * datasource driver id the load still converts. + * A stack the strict parse accepts, whose one non-canonical spelling is a key + * the load still converts: a flow-level retry policy's `retryDelayMs`, renamed + * `backoffMs` by `retry-policy-converged` while that entry is on the load path. + * (It was `datasources[].driver: 'mongo'` until that spelling was retired as a + * value at 18 and its conversion left the load path, #23543 F1.) */ const service = () => ({ manifest: { id: 'com.probe.svc', name: 'Probe Service', namespace: 'probe', version: '1.0.0', type: 'module' as const }, objects: [{ name: 'probe_slot', label: 'Slot', fields: { name: { type: 'text' as const, label: 'Name' } } }], - datasources: [ - { name: 'docs', label: 'Docs', driver: 'mongo', config: { url: 'mongodb://mongo.internal:27017/docs' } }, + flows: [ + { + name: 'probe_sync', + label: 'Sync', + type: 'autolaunched' as const, + errorHandling: { strategy: 'retry' as const, maxRetries: 3, retryDelayMs: 2000 }, + nodes: [{ id: 'start', type: 'start', label: 'Start' }, { id: 'end', type: 'end', label: 'End' }], + edges: [{ id: 'e1', source: 'start', target: 'end' }], + }, ], }); @@ -57,26 +67,37 @@ const app = () => ({ const quiet = () => vi.spyOn(console, 'warn').mockImplementation(() => {}); const ids = (value: unknown) => stackConversionsOf(value).map((n) => n.conversionId); -const driverOf = (stack: unknown) => (stack as { datasources: Array<{ driver: string }> }).datasources[0]!.driver; +const errorHandlingOf = (stack: unknown) => + (stack as { flows: Array<{ errorHandling: Record }> }).flows[0]!.errorHandling; +/** The authored spelling: `retryDelayMs` kept, no `backoffMs` invented. */ +const asWritten = (stack: unknown) => { + expect(errorHandlingOf(stack)).toMatchObject({ retryDelayMs: 2000 }); + expect(errorHandlingOf(stack)).not.toHaveProperty('backoffMs'); +}; +/** The converted spelling: `backoffMs` carries the value, `retryDelayMs` is gone. */ +const converted = (stack: unknown) => { + expect(errorHandlingOf(stack)).toMatchObject({ backoffMs: 2000 }); + expect(errorHandlingOf(stack)).not.toHaveProperty('retryDelayMs'); +}; describe('a non-strict call carrying the key returns its input as authored', () => { it('keeps the authored spelling, is marked, and records nothing; without the key it converts', () => { quiet(); const authored = defineStack(service() as never, { strict: false, [AUTHORED_INPUT]: true } as never); - expect(driverOf(authored)).toBe('mongo'); + asWritten(authored); expect(hasStackProvenance(authored)).toBe(true); expect(stackConversionsOf(authored)).toEqual([]); - const converted = defineStack(service() as never, { strict: false }); - expect(driverOf(converted)).toBe('mongodb'); - expect(ids(converted)).toEqual([MONGO]); + const plain = defineStack(service() as never, { strict: false }); + converted(plain); + expect(ids(plain)).toEqual([RETRY]); }); it('a strict call ignores the key: the pass runs and is recorded', () => { quiet(); const strict = defineStack(service() as never, { [AUTHORED_INPUT]: true } as never); - expect(driverOf(strict)).toBe('mongodb'); - expect(ids(strict)).toEqual([MONGO]); + converted(strict); + expect(ids(strict)).toEqual([RETRY]); }); }); @@ -92,14 +113,14 @@ describe('composeStacks over inputs produced as authored', () => { { manifest: 'preserve' }, ); expect(hasStackProvenance(authored)).toBe(true); - expect(driverOf(body(authored, 0))).toBe('mongo'); + asWritten(body(authored, 0)); expect(stackConversionsOf(authored)).toEqual([]); - const converted = composeStacks( + const plain = composeStacks( [defineStack(service() as never, { strict: false }), defineStack(app() as never, { strict: false })], { manifest: 'preserve' }, ); - expect(driverOf(body(converted, 0))).toBe('mongodb'); - expect(ids(converted)).toEqual([MONGO]); + converted(body(plain, 0)); + expect(ids(plain)).toEqual([RETRY]); }); }); From f03ee5f7c329771852be4c64bd5cf37163573a13 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 01:54:02 +0000 Subject: [PATCH 2/7] wip(spec): fix config-gate path expectation Claude-Session: https://claude.ai/code/session_01LFqdRsuM5nkqoJdHd5ztVk Co-authored-by: Claude --- packages/spec/src/data/driver/config-registry.test.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/spec/src/data/driver/config-registry.test.ts b/packages/spec/src/data/driver/config-registry.test.ts index 5f53d6f4be8..96f285d3290 100644 --- a/packages/spec/src/data/driver/config-registry.test.ts +++ b/packages/spec/src/data/driver/config-registry.test.ts @@ -303,9 +303,10 @@ describe('the retired `mongo` spelling: authoring refused, selection withdrawn, it('still judges the config of a refused `mongo` datasource — the driver refusal hides nothing', () => { const result = DatasourceSchema.safeParse({ name: 'events', driver: 'mongo', config: { hostname: 'db.internal' } }); expect(result.success).toBe(false); - const paths = result.error!.issues.map((i) => i.path.join('.')); - expect(paths).toContain('driver'); - expect(paths).toContain('config.hostname'); + // The config gate still runs against the mongodb contract and reports the + // misspelled key (unknown keys are reported at the record they sit in). + expect(result.error!.issues.map((i) => i.path.join('.'))).toEqual(['driver', 'config']); + expect(result.error!.issues[1]!.message).toContain('hostname'); }); it('is offered by no boot selection', () => { From 7f04e1269b9fed9978ccf5b36438c478d48b52d4 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 02:05:48 +0000 Subject: [PATCH 3/7] wip(spec): refuse retired driver spellings with a projectable pattern Claude-Session: https://claude.ai/code/session_01LFqdRsuM5nkqoJdHd5ztVk Co-authored-by: Claude --- packages/spec/src/data/datasource.zod.ts | 31 +++++++++----- .../src/data/driver/config-registry.test.ts | 42 +++++++++++++------ .../src/data/driver/config-registry.zod.ts | 34 ++++++--------- 3 files changed, 61 insertions(+), 46 deletions(-) diff --git a/packages/spec/src/data/datasource.zod.ts b/packages/spec/src/data/datasource.zod.ts index 0e3dfb313c9..3ae04c6f21b 100644 --- a/packages/spec/src/data/datasource.zod.ts +++ b/packages/spec/src/data/datasource.zod.ts @@ -5,7 +5,7 @@ import { lazySchema } from '../shared/lazy-schema'; import { strictObject } from '../shared/strict-object'; import { MetadataProtectionFields } from '../kernel/metadata-protection.zod'; import { urlUserinfoUsername } from './driver/common.zod'; -import { resolveDriverId, retiredDriverIdGuidance, validateDriverConfig } from './driver/config-registry.zod'; +import { RETIRED_DRIVER_ID_GUIDANCE, resolveDriverId, validateDriverConfig } from './driver/config-registry.zod'; /* * ── Unknown-key strictness (#4001 data step, closed out by #4410) ─────────── @@ -168,23 +168,32 @@ const RETIRED_READ_REPLICAS = + '`config` at that endpoint, which is the only read-scaling path that works today. ' + 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.'; +/** + * The refusal of one retired driver spelling as a regular expression: + * `^(?!\s*\s*$)` with every letter case-folded, so it reads a value the + * way `resolveDriverId` does (surrounding whitespace ignored, case-insensitive) + * — and, unlike a refinement, it survives the JSON Schema projection as a + * `pattern`. A flag (`/i`) would not: JSON Schema patterns carry none. + */ +function refusingSpelling(spelling: string): RegExp { + const escaped = spelling.replace(/[.*+?^${}()|[\]\\-]/g, '\\$&'); + const folded = escaped.replace(/[a-z]/g, (ch) => `[${ch}${ch.toUpperCase()}]`); + return new RegExp(`^(?!\\s*${folded}\\s*$)`); +} + /** * Driver Identifier * Can be a built-in driver or a plugin-contributed driver (e.g., "com.vendor.snowflake"). * * An open namespace, so a `z.string()` — with one closed exception: a spelling * the driver vocabulary RETIRED (`mongo`, at 18) is refused by name, with the - * prescription naming its canonical id - * (`DriverVocabularyEntry.retiredAliases`, `driver/config-registry.zod.ts`). - * Data at rest never reaches this refusal: every stored-row rehydration seam - * replays `datasource-driver-mongo-to-mongodb` before it parses. + * prescription naming its canonical id (`DriverVocabularyEntry.retiredAliases`, + * `driver/config-registry.zod.ts`). Data at rest never reaches this refusal: + * every stored-row rehydration seam replays `datasource-driver-mongo-to-mongodb` + * before it parses. */ -export const DriverType = z - .string() - .superRefine((driver, ctx) => { - const guidance = retiredDriverIdGuidance(driver); - if (guidance !== undefined) ctx.addIssue({ code: 'custom', message: guidance }); - }) +export const DriverType = Object.entries(RETIRED_DRIVER_ID_GUIDANCE) + .reduce((schema, [spelling, guidance]) => schema.regex(refusingSpelling(spelling), guidance), z.string()) .describe('Underlying driver identifier'); export type DriverType = z.input; diff --git a/packages/spec/src/data/driver/config-registry.test.ts b/packages/spec/src/data/driver/config-registry.test.ts index 96f285d3290..5dd80a3314d 100644 --- a/packages/spec/src/data/driver/config-registry.test.ts +++ b/packages/spec/src/data/driver/config-registry.test.ts @@ -1,8 +1,9 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. import { describe, it, expect } from 'vitest'; +import { z } from 'zod'; -import { DatasourceSchema } from '../datasource.zod'; +import { DatasourceSchema, DriverType } from '../datasource.zod'; import { BUILTIN_DRIVER_IDS, type BuiltinDriverId, @@ -15,7 +16,7 @@ import { getDriverConfigSchema, resolveDatabaseDriverId, resolveDriverId, - retiredDriverIdGuidance, + RETIRED_DRIVER_ID_GUIDANCE, validateDriverConfig, } from './config-registry.zod'; @@ -291,7 +292,9 @@ describe('the retired `mongo` spelling: authoring refused, selection withdrawn, expect(result.success, driver).toBe(false); expect(result.error!.issues, driver).toHaveLength(1); const [issue] = result.error!.issues; - expect(issue!.code, driver).toBe('custom'); + // A `pattern`-shaped refusal, not a refinement: it survives the JSON + // Schema projection (pinned below), so zod reports it as a format issue. + expect(issue!.code, driver).toBe('invalid_format'); expect(issue!.path, driver).toEqual(['driver']); expect(issue!.message, driver).toMatch( /^Datasource `driver: 'mongo'` was removed in @objectstack\/spec 18 \(ADR-0087 D2\)/, @@ -321,23 +324,36 @@ describe('the retired `mongo` spelling: authoring refused, selection withdrawn, expect(validateDriverConfig('mongo', VALID_CONFIG)).toEqual({ known: true, issues: [] }); }); - it('answers a prescription for the retired spelling only — never a live id, a plugin id or a prototype word', () => { - expect(retiredDriverIdGuidance(' Mongo ')).toBe(retiredDriverIdGuidance('mongo')); - for (const value of ['mongodb', 'pg', 'memory', 'com.vendor.mongolike', 'constructor', '__proto__', '', 42, null, undefined]) { - expect(retiredDriverIdGuidance(value), String(value)).toBeUndefined(); + it('refuses the retired spelling only — never a live id, a plugin id that merely contains it, or a prototype word', () => { + for (const value of ['mongodb', 'pg', 'memory', 'com.vendor.mongolike', 'mongo.v2', 'xmongo', 'constructor', '__proto__', '']) { + expect(DriverType.safeParse(value).success, value).toBe(true); } + for (const value of ['mongo', 'MONGO', '\tmongo\n']) { + expect(DriverType.safeParse(value).success, JSON.stringify(value)).toBe(false); + } + }); + + it('publishes the refusal in the JSON Schema projection — a validator of the published file refuses it too', () => { + const json = z.toJSONSchema(DriverType) as { type?: string; pattern?: string }; + expect(json.type).toBe('string'); + const pattern = new RegExp(json.pattern!); + expect(pattern.test('mongo')).toBe(false); + expect(pattern.test(' Mongo ')).toBe(false); + expect(pattern.test('mongodb')).toBe(true); + expect(pattern.test('com.vendor.snowflake')).toBe(true); }); it('a retired spelling is no live spelling of any row — the refusal could never fire on a legal value', () => { const live = new Set([...DATABASE_DRIVER_SELECTION_ALIASES, ...BUILTIN_DRIVER_IDS]); - for (const alias of Object.keys(DRIVER_ID_ALIASES)) { - if (retiredDriverIdGuidance(alias) === undefined) continue; - expect(live.has(alias), alias).toBe(false); + const retired = Object.keys(RETIRED_DRIVER_ID_GUIDANCE); + for (const spelling of retired) { + expect(spelling, 'stored the way resolveDriverId reads a spelling').toBe(spelling.trim().toLowerCase()); + expect(live.has(spelling), spelling).toBe(false); // …and every retired spelling still resolves a contract (the kept half). - expect(resolveDriverId(alias), alias).toBeDefined(); + expect(resolveDriverId(spelling), spelling).toBeDefined(); } - // Anti-vacuity: the loop above judged at least the one retirement this row carries. - expect(Object.keys(DRIVER_ID_ALIASES).filter((a) => retiredDriverIdGuidance(a) !== undefined)).toEqual(['mongo']); + // Anti-vacuity: the loop above judged the one retirement the table carries. + expect(retired).toEqual(['mongo']); }); }); diff --git a/packages/spec/src/data/driver/config-registry.zod.ts b/packages/spec/src/data/driver/config-registry.zod.ts index 6ea8515f2db..50487c22574 100644 --- a/packages/spec/src/data/driver/config-registry.zod.ts +++ b/packages/spec/src/data/driver/config-registry.zod.ts @@ -124,7 +124,7 @@ import { getTursoConfigJsonSchema, TursoConfigSchema } from './turso.zod'; * authored one with a notice. At 18 it is retired: it left the selection face * (no boot host accepts `OS_DATABASE_DRIVER=mongo`), and an authored * `datasource.driver: 'mongo'` is refused at parse with the prescription naming - * `mongodb` ({@link retiredDriverIdGuidance}, read by `DriverType`). It lives on + * `mongodb` ({@link RETIRED_DRIVER_ID_GUIDANCE}, read by `DriverType`). It lives on * as a {@link DriverVocabularyEntry.retiredAliases} key, which keeps it on the * CONTRACT face only — and that half is load-bearing, not a second spelling. * Data at rest never meets the refusal: a stored row is replayed through the @@ -183,7 +183,7 @@ export interface DriverVocabularyEntry { * * - **authoring** — refused by name: `DriverType` (`datasource.zod.ts`) * raises the prescription for an authored `datasource.driver`, through - * {@link retiredDriverIdGuidance}. `driver` is an open namespace (a plugin + * {@link RETIRED_DRIVER_ID_GUIDANCE}. `driver` is an open namespace (a plugin * ships `com.vendor.snowflake`), so the field is `z.string()` and * `enumWithRetiredValues` cannot carry this; the prescription type and * its writing conventions are that helper's ({@link RetiredValueGuidance}). @@ -298,30 +298,20 @@ export const DRIVER_ID_ALIASES: Readonly> = Obje ); /** - * Retired spelling → its prescription, across every row — the lookup table - * behind {@link retiredDriverIdGuidance}. + * Retired spelling → its prescription, across every row's + * {@link DriverVocabularyEntry.retiredAliases}. Keys are lower-case, the way + * {@link resolveDriverId} reads a spelling. + * + * Exported for `DriverType` (`datasource.zod.ts`), which refuses each key in a + * form the JSON Schema projection keeps (a `pattern`), so a document validated + * against the published schema meets the refusal too. Iterate it by own + * entries; a bare index by an arbitrary spelling reaches `Object.prototype` + * (see {@link lookupDriverId}). */ -const RETIRED_DRIVER_ID_GUIDANCE: Readonly> = Object.freeze( +export const RETIRED_DRIVER_ID_GUIDANCE: Readonly> = Object.freeze( Object.fromEntries(VOCABULARY_ROWS.flatMap((entry) => Object.entries(entry.retiredAliases ?? {}))), ); -/** - * The prescription for a RETIRED driver spelling, or `undefined` for every - * other value — a live alias, a plugin's driver id, a typo, a non-string. - * - * Read the way {@link resolveDriverId} reads a spelling (trimmed, - * case-insensitive, own keys only), so `' Mongo '` meets the same refusal the - * resolver would have resolved. What `DriverType` refuses an authored - * `datasource.driver` with; see {@link DriverVocabularyEntry.retiredAliases} - * for why the spelling still resolves the contract. - */ -export function retiredDriverIdGuidance(driver: unknown): string | undefined { - if (typeof driver !== 'string') return undefined; - const spelling = driver.trim().toLowerCase(); - if (!Object.prototype.hasOwnProperty.call(RETIRED_DRIVER_ID_GUIDANCE, spelling)) return undefined; - return RETIRED_DRIVER_ID_GUIDANCE[spelling]; -} - /** * The spellings a BOOT HOST accepts as a driver selection — every selection * alias of every builtin, sorted for a stable refusal message. From 01368f1ce4ada15390c7a3de0a5b2069e19355a1 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 02:12:17 +0000 Subject: [PATCH 4/7] wip(spec): regenerate api-surface and export-origins for RETIRED_DRIVER_ID_GUIDANCE Claude-Session: https://claude.ai/code/session_01LFqdRsuM5nkqoJdHd5ztVk Co-authored-by: Claude --- packages/spec/api-surface/data.json | 1 + packages/spec/export-origins/data.json | 1 + 2 files changed, 2 insertions(+) diff --git a/packages/spec/api-surface/data.json b/packages/spec/api-surface/data.json index ef162bcc324..784b17525b7 100644 --- a/packages/spec/api-surface/data.json +++ b/packages/spec/api-surface/data.json @@ -610,6 +610,7 @@ "RECORD_CONTEXT_TOKENS (const)", "RECORD_SURFACE_PAGE_THRESHOLD (const)", "REFERENCE_VALUE_TYPES (const)", + "RETIRED_DRIVER_ID_GUIDANCE (const)", "RETIRED_FILTER_OPERATORS (const)", "RETIRED_SUB_DAY_INTERVALS (const)", "RLS_DENY_FILTER (const)", diff --git a/packages/spec/export-origins/data.json b/packages/spec/export-origins/data.json index ae9691822c3..81db50bcb9a 100644 --- a/packages/spec/export-origins/data.json +++ b/packages/spec/export-origins/data.json @@ -599,6 +599,7 @@ "RECORD_CONTEXT_TOKENS": "src/data/context-tokens.zod.ts#RECORD_CONTEXT_TOKENS (const)", "RECORD_SURFACE_PAGE_THRESHOLD": "src/data/record-surface.ts#RECORD_SURFACE_PAGE_THRESHOLD (const)", "REFERENCE_VALUE_TYPES": "src/data/field-value.zod.ts#REFERENCE_VALUE_TYPES (const)", + "RETIRED_DRIVER_ID_GUIDANCE": "src/data/driver/config-registry.zod.ts#RETIRED_DRIVER_ID_GUIDANCE (const)", "RETIRED_FILTER_OPERATORS": "src/data/filter.zod.ts#RETIRED_FILTER_OPERATORS (const)", "RETIRED_SUB_DAY_INTERVALS": "src/data/analytics.zod.ts#RETIRED_SUB_DAY_INTERVALS (const)", "RLS_DENY_FILTER": "src/data/readable-ids.ts#RLS_DENY_FILTER (const)", From b27c88a4d9094abe9140a11fb27d6f149640e327 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 02:13:02 +0000 Subject: [PATCH 5/7] wip(cli): flip the mongo selection parity pin; correct the docblock examples Claude-Session: https://claude.ai/code/session_01LFqdRsuM5nkqoJdHd5ztVk Co-authored-by: Claude --- packages/cli/src/utils/config.ts | 8 ++++--- .../utils/driver-vocabulary-parity.test.ts | 24 +++++++++++++++---- 2 files changed, 25 insertions(+), 7 deletions(-) diff --git a/packages/cli/src/utils/config.ts b/packages/cli/src/utils/config.ts index 281e043f30e..3c669569443 100644 --- a/packages/cli/src/utils/config.ts +++ b/packages/cli/src/utils/config.ts @@ -617,8 +617,9 @@ const AUTHORED_SOURCE_PRELUDE: readonly string[] = [ * A call the current schema ACCEPTS has already run the producer's load-time * ADR-0087 D2 conversion pass when it returns. That pass runs in both of its * modes, and no option skips it. So its result is canonical already. Handed - * to the chain as it was, a conversion the load still applies - * (`driver: 'mongo'`) had already happened: `applied` came back empty, + * to the chain as it was, a conversion the load still applies (it was + * measured on `driver: 'mongo'`, a spelling since retired from the load path + * at 18) had already happened: `applied` came back empty, * `--write` wrote nothing, and every later load still printed the notice * that sends the author to this command. * @@ -651,7 +652,8 @@ const AUTHORED_SOURCE_PRELUDE: readonly string[] = [ * what the author wrote. It asks in the producer's `strict: false` mode, with * the producer's internal parameter that skips the load-time ADR-0087 D2 * conversion pass ({@link SPEC_AUTHORED_INPUT_OPTION}, #22256). Without that - * parameter a conversion the load still applies (`driver: 'mongo'`) was + * parameter a conversion the load still applies (measured on + * `driver: 'mongo'`, since retired from the load path at 18) was * applied while the input was produced again, so the chain never listed it * and `--write` never wrote it — the one-package defect, inside every package * body. With it, composition runs its own rule over the authored inputs, and diff --git a/packages/cli/src/utils/driver-vocabulary-parity.test.ts b/packages/cli/src/utils/driver-vocabulary-parity.test.ts index d66a2170936..73b1321eda9 100644 --- a/packages/cli/src/utils/driver-vocabulary-parity.test.ts +++ b/packages/cli/src/utils/driver-vocabulary-parity.test.ts @@ -266,12 +266,28 @@ describe('driver vocabulary parity: `os start` and `os migrate` answer alike (#6 expect(resolveStandaloneDatabase({ artifactPath: '/nonexistent/objectstack.json' }).driver).toBe('sqlite'); }); - it('`mongo` and `mongodb` both select the renamed canonical id on both hosts', () => { + // `mongo` was retired as a value at 18 (#23543 F1): it left the selection + // face, so both hosts refuse it alike, each enumerating `mongodb` among the + // spellings that work. It stays on the CONTRACT face only, so a stored legacy + // `datasource.driver: 'mongo'` row keeps its contract and its redaction. + it('`mongodb` selects the canonical id on both hosts; the retired `mongo` is refused by both, naming `mongodb`', () => { 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(cliVerdict('mongodb', url)).toEqual({ accepted: true, driverId: 'mongodb' }); + expect(standaloneVerdict('mongodb', url)).toEqual({ accepted: true, driverId: 'mongodb' }); + + expect(cliVerdict('mongo', url)).toEqual({ accepted: false }); + expect(standaloneVerdict('mongo', url)).toEqual({ accepted: false }); + const cli = refusalMessage(() => resolveStorageDefinition(resolveDriverType('mongo', url), { databaseUrl: url, isDev: false })); + process.env.OS_DATABASE_DRIVER = 'mongo'; + process.env.OS_DATABASE_URL = url; + const standalone = refusalMessage(() => resolveStandaloneDatabase({ artifactPath: '/nonexistent/objectstack.json' })); + for (const [host, message] of [['os start', cli], ['os migrate', standalone]] as const) { + expect(message, host).toContain('"mongo"'); + expect(message, host).toMatch(/Supported drivers: [^.]*\bmongodb\b/); + expect(message, host).not.toMatch(/Supported drivers: [^.]*\bmongo\b(?!db)/); } + + expect(resolveDatabaseDriverId('mongo')).toBeUndefined(); expect(resolveDriverId('mongo')).toBe('mongodb'); }); }); From 6e89078a32ac378935aec7bbe9d2a0e2f9384a67 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 02:23:09 +0000 Subject: [PATCH 6/7] wip(cli): re-point the load-path migrate-meta suites onto a live conversion Claude-Session: https://claude.ai/code/session_01LFqdRsuM5nkqoJdHd5ztVk Co-authored-by: Claude --- ...ate-meta-composed-load-conversions.test.ts | 71 ++++++++++++------- .../cli/test/migrate-meta-composed.test.ts | 37 ++++++---- .../migrate-meta-load-conversions.test.ts | 70 +++++++++++------- 3 files changed, 111 insertions(+), 67 deletions(-) diff --git a/packages/cli/test/migrate-meta-composed-load-conversions.test.ts b/packages/cli/test/migrate-meta-composed-load-conversions.test.ts index 23f2d23a874..2ddafb292a5 100644 --- a/packages/cli/test/migrate-meta-composed-load-conversions.test.ts +++ b/packages/cli/test/migrate-meta-composed-load-conversions.test.ts @@ -3,8 +3,11 @@ /** * `os migrate meta` on a COMPOSED project — * `composeStacks([defineStack(…), …], { manifest: 'preserve' })` — whose - * package body carries a spelling the load still converts - * (`datasources[].driver: 'mongo'`) (#22256, the composed half). + * package body carries a spelling the load still converts (a flow-level retry + * policy's `retryDelayMs`, renamed `backoffMs` by `retry-policy-converged`) + * (#22256, the composed half). It was measured on `datasources[].driver: + * 'mongo'`, until that spelling was retired as a value at 18 and its + * conversion left the load path (#23543 F1). * * ## The defect * @@ -153,7 +156,7 @@ const json = (run: Run): any => JSON.parse(run.stdout); const sites = (list: Array<{ conversionId: string; path: string; from?: string; to?: string }>) => list.map(({ conversionId, path, from, to }) => ({ conversionId, path, from, to })); const conversionIds = (list: readonly { conversionId: string }[]) => list.map((c) => c.conversionId); -const bodyDriver = (config: any): string => config.packages[0].manifest.datasources[0].driver; +const bodyErrorHandling = (config: any): Record => config.packages[0].manifest.flows[0].errorHandling; beforeAll(() => { root = mkdtempSync(join(tmpdir(), 'os-migrate-meta-composed-load-conversions-')); @@ -171,9 +174,10 @@ afterAll(() => { /** * The service package: a stack the current schema accepts, whose one - * non-canonical spelling is a datasource driver id the load still converts - * (`retiredFromLoadPath: false`); the `url` is what makes a mongo datasource - * valid, so the call is accepted. + * non-canonical spelling is a key the load still converts (`retiredFromLoadPath` + * unset): the flow-level retry policy's `retryDelayMs`, which + * `retry-policy-converged` renames `backoffMs` before the parse, so the call is + * accepted. */ const SERVICE_STACK = `import { defineStack } from '@objectstack/spec'; @@ -188,7 +192,16 @@ export const ServiceStack = defineStack({ }, }, ], - datasources: [{ name: 'docs', label: 'Docs', driver: 'mongo', config: { url: 'mongodb://mongo.internal:27017/docs' } }], + flows: [ + { + name: 'probe_sync', + label: 'Sync', + type: 'autolaunched', + errorHandling: { strategy: 'retry', maxRetries: 3, retryDelayMs: 2000 }, + nodes: [{ id: 'start', type: 'start', label: 'Start' }, { id: 'end', type: 'end', label: 'End' }], + edges: [{ id: 'e1', source: 'start', target: 'end' }], + }, + ], }); `; @@ -213,13 +226,15 @@ const composed = (service: string): Record => ({ 'src/app.stack.ts': APP_STACK, }); -const MONGO_CONVERSION = 'datasource-driver-mongo-to-mongodb'; -const MONGO_BODY_SITE = { - conversionId: MONGO_CONVERSION, - path: 'packages[0].manifest.datasources[0].driver', - from: 'mongo', - to: 'mongodb', +const RETRY_CONVERSION = 'retry-policy-converged'; +const RETRY_BODY_SITE = { + conversionId: RETRY_CONVERSION, + path: 'packages[0].manifest.flows[0].errorHandling.backoffMs', + from: 'retryDelayMs', + to: 'backoffMs', }; +const AUTHORED_DELAY = 'retryDelayMs: 2000'; +const CANONICAL_DELAY = 'backoffMs: 2000'; const TIME_BODY_SITE = { conversionId: 'time-default-utc-suffix-dropped', path: 'packages[0].manifest.objects[0].fields.starts_at.defaultValue', @@ -235,16 +250,16 @@ describe('an accepted input: the chain reads the package body as the author wrot // The control for the last reading below: today the strict load converts it. const strictBefore = await quietLoad(dir); expect(strictBefore.stackProvenance).toBe(true); - expect(conversionIds(strictBefore.stackConversions)).toEqual([MONGO_CONVERSION]); + expect(conversionIds(strictBefore.stackConversions)).toEqual([RETRY_CONVERSION]); const dry = json(await runMeta(dir, ['--from', '16', '--json'])); - expect(sites(dry.applied)).toEqual([MONGO_BODY_SITE]); + expect(sites(dry.applied)).toEqual([RETRY_BODY_SITE]); expect(dry.schemaValid).toBe(true); const run = await runMeta(dir, ['--from', '16', '--write', '--json']); expect(run.exitCode, run.stdout + run.stderr).toBeUndefined(); const out = json(run); - expect(sites(out.applied)).toEqual([MONGO_BODY_SITE]); + expect(sites(out.applied)).toEqual([RETRY_BODY_SITE]); expect(out.write.status).toBe('written'); expect(out.write.files).toEqual([{ file: 'src/service.stack.ts', sites: 1 }]); expect(out.write.manual).toEqual([]); @@ -253,7 +268,7 @@ describe('an accepted input: the chain reads the package body as the author wrot // The site and nothing else: every other file keeps its bytes. expect(snapshot(dir)).toEqual({ ...before, - 'src/service.stack.ts': edit(before['src/service.stack.ts']!, "driver: 'mongo'", "driver: 'mongodb'"), + 'src/service.stack.ts': edit(before['src/service.stack.ts']!, AUTHORED_DELAY, CANONICAL_DELAY), }); const again = json(await runMeta(dir, ['--from', '16', '--json'])); @@ -270,11 +285,13 @@ describe('an accepted input: the chain reads the package body as the author wrot const dir = writeProject(composed(SERVICE_STACK)); const authored = await quietLoad(dir, true); - expect(bodyDriver(authored.config)).toBe('mongo'); + expect(bodyErrorHandling(authored.config)).toMatchObject({ retryDelayMs: 2000 }); + expect(bodyErrorHandling(authored.config)).not.toHaveProperty('backoffMs'); expect(authored.stackProvenance).toBe(true); const strict = await quietLoad(dir); - expect(bodyDriver(strict.config)).toBe('mongodb'); + expect(bodyErrorHandling(strict.config)).toMatchObject({ backoffMs: 2000 }); + expect(bodyErrorHandling(strict.config)).not.toHaveProperty('retryDelayMs'); }, RUN_TIMEOUT); it('an input defined twice starts from the innermost argument, and is listed once', async () => { @@ -284,7 +301,7 @@ describe('an accepted input: the chain reads the package body as the author wrot '\n}));\n', ))); const out = json(await runMeta(dir, ['--from', '16', '--json'])); - expect(sites(out.applied)).toEqual([MONGO_BODY_SITE]); + expect(sites(out.applied)).toEqual([RETRY_BODY_SITE]); expect(out.schemaValid).toBe(true); }, RUN_TIMEOUT); }); @@ -292,8 +309,8 @@ describe('an accepted input: the chain reads the package body as the author wrot describe('a refused input the shim produces again, carrying a load-path spelling too', () => { // The `time` default with the UTC suffix is refused by the current schema, // so the shim hands the call's argument through and produces it again in - // `strict: false` mode for the composer; the driver id is the input's one - // load-path conversion. + // `strict: false` mode for the composer; the flow's retry delay key is the + // input's one load-path conversion. const MIXED = edit( SERVICE_STACK, " name: { type: 'text', label: 'Name' },\n", @@ -305,20 +322,20 @@ describe('a refused input the shim produces again, carrying a load-path spelling const before = snapshot(dir); const dry = json(await runMeta(dir, ['--from', '16', '--json'])); - expect(sites(dry.applied)).toEqual([MONGO_BODY_SITE, TIME_BODY_SITE]); + expect(sites(dry.applied)).toEqual([RETRY_BODY_SITE, TIME_BODY_SITE]); const run = await runMeta(dir, ['--from', '16', '--write', '--json']); expect(run.exitCode, run.stdout + run.stderr).toBeUndefined(); const out = json(run); - expect(sites(out.applied)).toEqual([MONGO_BODY_SITE, TIME_BODY_SITE]); + expect(sites(out.applied)).toEqual([RETRY_BODY_SITE, TIME_BODY_SITE]); expect(out.write.status).toBe('written'); expect(out.write.files).toEqual([{ file: 'src/service.stack.ts', sites: 2 }]); expect(snapshot(dir)).toEqual({ ...before, 'src/service.stack.ts': edit( edit(MIXED, "defaultValue: '10:00Z'", "defaultValue: '10:00'"), - "driver: 'mongo'", - "driver: 'mongodb'", + AUTHORED_DELAY, + CANONICAL_DELAY, ), }); @@ -332,7 +349,7 @@ describe('a refused input the shim produces again, carrying a load-path spelling }); describe('control: an already-canonical composed project', () => { - const CANONICAL = edit(SERVICE_STACK, "driver: 'mongo'", "driver: 'mongodb'"); + const CANONICAL = edit(SERVICE_STACK, AUTHORED_DELAY, CANONICAL_DELAY); it('writes nothing, and its --json summary is the one the built composition gives', async () => { const dir = writeProject(composed(CANONICAL)); diff --git a/packages/cli/test/migrate-meta-composed.test.ts b/packages/cli/test/migrate-meta-composed.test.ts index cea4e48850e..fda2ddea394 100644 --- a/packages/cli/test/migrate-meta-composed.test.ts +++ b/packages/cli/test/migrate-meta-composed.test.ts @@ -21,10 +21,12 @@ * input is not written as a literal is listed with its reason and left * alone, never guessed at. * 3. The control: a one-package project's `applied` and `write` outcome are - * what they were, including a conversion the load still applies - * (`driver: 'mongo'`), which the shim's raw hand-back keeps visible to the - * chain. The expected values are the ones measured on `origin/main` - * `4e4111ca0` before this change. + * what they were, including a conversion the load still applies, which + * the shim's raw hand-back keeps visible to the chain. The expected values + * are the ones measured on `origin/main` `4e4111ca0` before this change, + * on `driver: 'mongo'`; that spelling was retired as a value at 18 + * (#23543 F1), so the load-path case now carries a flow-level + * `retryDelayMs` instead. * 4. The boundary: an input the author never wrapped is still refused with * `STACK_PROVENANCE_MISSING` — the shim produces again only what it handed * through itself. @@ -266,12 +268,19 @@ export default defineStack({ ], }); `; - // The same stack with a datasource spelled the old way: a conversion the - // load still applies. The raw hand-back keeps it visible to the chain. + // The same stack with a flow whose retry policy spells its delay the old + // way: a conversion the load still applies. The raw hand-back keeps it + // visible to the chain. (It was a datasource `driver: 'mongo'` until that + // spelling was retired as a value at 18, #23543 F1.) const SINGLE_WITH_LOAD_PATH_CONVERSION = edit( SINGLE, ' ],\n});\n', - " ],\n datasources: [{ name: 'docs', label: 'Docs', driver: 'mongo', config: {} }],\n});\n", + ' ],\n' + + " flows: [{ name: 'probe_sync', label: 'Sync', type: 'autolaunched', " + + "errorHandling: { strategy: 'retry', maxRetries: 3, retryDelayMs: 2000 }, " + + "nodes: [{ id: 'start', type: 'start', label: 'Start' }, { id: 'end', type: 'end', label: 'End' }], " + + "edges: [{ id: 'e1', source: 'start', target: 'end' }] }],\n" + + '});\n', ); const TIME_SITE = { conversionId: 'time-default-utc-suffix-dropped', @@ -279,16 +288,16 @@ export default defineStack({ from: '"10:00Z"', to: '"10:00"', }; - const MONGO_SITE = { - conversionId: 'datasource-driver-mongo-to-mongodb', - path: 'datasources[0].driver', - from: 'mongo', - to: 'mongodb', + const RETRY_SITE = { + conversionId: 'retry-policy-converged', + path: 'flows[0].errorHandling.backoffMs', + from: 'retryDelayMs', + to: 'backoffMs', }; it.each([ { name: 'a refused stack', source: SINGLE, applied: [TIME_SITE], writtenSites: 1 }, - { name: 'a refused stack carrying a load-path conversion', source: SINGLE_WITH_LOAD_PATH_CONVERSION, applied: [MONGO_SITE, TIME_SITE], writtenSites: 2 }, + { name: 'a refused stack carrying a load-path conversion', source: SINGLE_WITH_LOAD_PATH_CONVERSION, applied: [RETRY_SITE, TIME_SITE], writtenSites: 2 }, ])('$name: `applied` and `write.files` as measured before the change', async ({ source, applied, writtenSites }) => { const dir = writeProject({ 'objectstack.config.ts': source }); const dry = json(await runMeta(dir, ['--from', '16', '--json'])); @@ -300,7 +309,7 @@ export default defineStack({ expect(out.write.status).toBe('written'); expect(out.write.files).toEqual([{ file: 'objectstack.config.ts', sites: writtenSites }]); let expected = edit(source, "defaultValue: '10:00Z'", "defaultValue: '10:00'"); - if (writtenSites === 2) expected = edit(expected, "driver: 'mongo'", "driver: 'mongodb'"); + if (writtenSites === 2) expected = edit(expected, 'retryDelayMs: 2000', 'backoffMs: 2000'); expect(readFileSync(join(dir, 'objectstack.config.ts'), 'utf8')).toBe(expected); }, RUN_TIMEOUT); }); diff --git a/packages/cli/test/migrate-meta-load-conversions.test.ts b/packages/cli/test/migrate-meta-load-conversions.test.ts index 6843d7ee08c..33dbdc22a34 100644 --- a/packages/cli/test/migrate-meta-load-conversions.test.ts +++ b/packages/cli/test/migrate-meta-load-conversions.test.ts @@ -2,7 +2,11 @@ /** * `os migrate meta` on a stack whose `defineStack` call the current schema - * ACCEPTS, carrying a spelling the load still converts (`driver: 'mongo'`). + * ACCEPTS, carrying a spelling the load still converts (a flow-level retry + * policy's `retryDelayMs`, renamed `backoffMs` by `retry-policy-converged`). + * It was measured on `driver: 'mongo'`, until that spelling was retired as a + * value at 18 and its conversion left the load path (#23543 F1): an authored + * `mongo` is now refused, so it no longer exercises the accepted arm. * * ## The defect * @@ -163,11 +167,12 @@ afterAll(() => { }); /** - * A stack the current schema accepts. Its one non-canonical spelling is a - * datasource driver id the load still converts (`retiredFromLoadPath: false`); - * the `url` is what makes a mongo datasource valid, so the call is accepted. + * A stack the current schema accepts. Its one non-canonical spelling is a key + * the load still converts (`retiredFromLoadPath` unset): the flow-level retry + * policy's `retryDelayMs`, which `retry-policy-converged` renames `backoffMs` + * before the parse, so the call is accepted. */ -const MONGO_SOURCE = `import { defineStack } from '@objectstack/spec'; +const RETRY_SOURCE = `import { defineStack } from '@objectstack/spec'; export default defineStack({ manifest: { id: 'com.probe.svc', name: 'Probe Service', namespace: 'probe', version: '1.0.0', type: 'module' }, @@ -180,12 +185,23 @@ export default defineStack({ }, }, ], - datasources: [{ name: 'docs', label: 'Docs', driver: 'mongo', config: { url: 'mongodb://mongo.internal:27017/docs' } }], + flows: [ + { + name: 'probe_sync', + label: 'Sync', + type: 'autolaunched', + errorHandling: { strategy: 'retry', maxRetries: 3, retryDelayMs: 2000 }, + nodes: [{ id: 'start', type: 'start', label: 'Start' }, { id: 'end', type: 'end', label: 'End' }], + edges: [{ id: 'e1', source: 'start', target: 'end' }], + }, + ], }); `; -const MONGO_CONVERSION = 'datasource-driver-mongo-to-mongodb'; -const MONGO_SITE = { conversionId: MONGO_CONVERSION, path: 'datasources[0].driver', from: 'mongo', to: 'mongodb' }; +const RETRY_CONVERSION = 'retry-policy-converged'; +const RETRY_SITE = { conversionId: RETRY_CONVERSION, path: 'flows[0].errorHandling.backoffMs', from: 'retryDelayMs', to: 'backoffMs' }; +const AUTHORED_DELAY = 'retryDelayMs: 2000'; +const CANONICAL_DELAY = 'backoffMs: 2000'; const TIME_SITE = { conversionId: 'time-default-utc-suffix-dropped', path: 'objects[0].fields.starts_at.defaultValue', @@ -195,27 +211,27 @@ const TIME_SITE = { describe('an accepted stack: the chain starts from what the author wrote', () => { it('lists a conversion the load already applied; --write writes it, and the load stops converting', async () => { - const dir = writeProject({ 'objectstack.config.ts': MONGO_SOURCE }); + const dir = writeProject({ 'objectstack.config.ts': RETRY_SOURCE }); // The control for the last reading below: today the strict load converts it. const before = await quietLoad(dir); expect(before.stackProvenance).toBe(true); - expect(conversionIds(before.stackConversions)).toEqual([MONGO_CONVERSION]); + expect(conversionIds(before.stackConversions)).toEqual([RETRY_CONVERSION]); const dry = json(await runMeta(dir, ['--from', '16', '--json'])); - expect(sites(dry.applied)).toEqual([MONGO_SITE]); + expect(sites(dry.applied)).toEqual([RETRY_SITE]); expect(dry.schemaValid).toBe(true); const run = await runMeta(dir, ['--from', '16', '--write', '--json']); expect(run.exitCode, run.stdout + run.stderr).toBeUndefined(); const out = json(run); - expect(sites(out.applied)).toEqual([MONGO_SITE]); + expect(sites(out.applied)).toEqual([RETRY_SITE]); expect(out.write.status).toBe('written'); expect(out.write.files).toEqual([{ file: 'objectstack.config.ts', sites: 1 }]); expect(out.write.manual).toEqual([]); expect(out.write.unexplained).toEqual([]); expect(readFileSync(join(dir, 'objectstack.config.ts'), 'utf8')).toBe( - edit(MONGO_SOURCE, "driver: 'mongo'", "driver: 'mongodb'"), + edit(RETRY_SOURCE, AUTHORED_DELAY, CANONICAL_DELAY), ); const again = json(await runMeta(dir, ['--from', '16', '--json'])); @@ -230,38 +246,40 @@ describe('an accepted stack: the chain starts from what the author wrote', () => it('the authored-source load reads the argument off the default export, before the named exports are merged', async () => { const dir = writeProject({ - 'objectstack.config.ts': `${MONGO_SOURCE}\nexport const onEnable = async () => {};\n`, + 'objectstack.config.ts': `${RETRY_SOURCE}\nexport const onEnable = async () => {};\n`, }); const authored = await quietLoad(dir, true); - expect(authored.config.datasources[0].driver).toBe('mongo'); + expect(authored.config.flows[0].errorHandling).toMatchObject({ retryDelayMs: 2000 }); + expect(authored.config.flows[0].errorHandling).not.toHaveProperty('backoffMs'); expect(authored.namedExports).toEqual(['onEnable']); expect(typeof authored.config.onEnable).toBe('function'); // The producer's mark and record are still read off the stack it built. expect(authored.stackProvenance).toBe(true); - expect(conversionIds(authored.stackConversions)).toEqual([MONGO_CONVERSION]); + expect(conversionIds(authored.stackConversions)).toEqual([RETRY_CONVERSION]); // Every other command still reads the stack `defineStack` built. const strict = await quietLoad(dir); - expect(strict.config.datasources[0].driver).toBe('mongodb'); + expect(strict.config.flows[0].errorHandling).toMatchObject({ backoffMs: 2000 }); + expect(strict.config.flows[0].errorHandling).not.toHaveProperty('retryDelayMs'); }, RUN_TIMEOUT); it('a stack defined twice starts from the innermost argument, and converts it once', async () => { const dir = writeProject({ 'objectstack.config.ts': edit( - edit(MONGO_SOURCE, 'export default defineStack({', 'export default defineStack(defineStack({'), + edit(RETRY_SOURCE, 'export default defineStack({', 'export default defineStack(defineStack({'), '\n});\n', '\n}));\n', ), }); const out = json(await runMeta(dir, ['--from', '16', '--json'])); - expect(sites(out.applied)).toEqual([MONGO_SITE]); + expect(sites(out.applied)).toEqual([RETRY_SITE]); expect(out.schemaValid).toBe(true); }, RUN_TIMEOUT); }); describe('control: an already-canonical source', () => { - const CANONICAL = edit(MONGO_SOURCE, "driver: 'mongo'", "driver: 'mongodb'"); + const CANONICAL = edit(RETRY_SOURCE, AUTHORED_DELAY, CANONICAL_DELAY); it('writes nothing, and its --json summary is the one the built stack gives', async () => { const dir = writeProject({ 'objectstack.config.ts': CANONICAL }); @@ -288,10 +306,10 @@ describe('control: an already-canonical source', () => { describe('a stack with one refused spelling and one load-path spelling', () => { // The `time` default with the UTC suffix is refused by the current schema, - // so this call hands its argument through; the datasource is valid, so the - // driver id is the only load-path conversion in it. + // so this call hands its argument through; the flow is valid, so its retry + // delay key is the only load-path conversion in it. const MIXED = edit( - MONGO_SOURCE, + RETRY_SOURCE, " name: { type: 'text', label: 'Name' },\n", " name: { type: 'text', label: 'Name' },\n starts_at: { type: 'time', label: 'Starts', defaultValue: '10:00Z' },\n", ); @@ -300,16 +318,16 @@ describe('a stack with one refused spelling and one load-path spelling', () => { const dir = writeProject({ 'objectstack.config.ts': MIXED }); const dry = json(await runMeta(dir, ['--from', '16', '--json'])); - expect(sites(dry.applied)).toEqual([MONGO_SITE, TIME_SITE]); + expect(sites(dry.applied)).toEqual([RETRY_SITE, TIME_SITE]); const run = await runMeta(dir, ['--from', '16', '--write', '--json']); expect(run.exitCode, run.stdout + run.stderr).toBeUndefined(); const out = json(run); - expect(sites(out.applied)).toEqual([MONGO_SITE, TIME_SITE]); + expect(sites(out.applied)).toEqual([RETRY_SITE, TIME_SITE]); expect(out.write.status).toBe('written'); expect(out.write.files).toEqual([{ file: 'objectstack.config.ts', sites: 2 }]); expect(readFileSync(join(dir, 'objectstack.config.ts'), 'utf8')).toBe( - edit(edit(MIXED, "defaultValue: '10:00Z'", "defaultValue: '10:00'"), "driver: 'mongo'", "driver: 'mongodb'"), + edit(edit(MIXED, "defaultValue: '10:00Z'", "defaultValue: '10:00'"), AUTHORED_DELAY, CANONICAL_DELAY), ); const again = json(await runMeta(dir, ['--from', '16', '--json'])); From 81eb862a4d0dc54b15adf95d7280729bda3f240c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 02:29:35 +0000 Subject: [PATCH 7/7] wip: changeset, docs and comments that named mongo as accepted Claude-Session: https://claude.ai/code/session_01LFqdRsuM5nkqoJdHd5ztVk Co-authored-by: Claude --- .../23543-f1-retire-mongo-driver-alias.md | 24 +++++++++++++++++++ content/docs/data-modeling/drivers.mdx | 19 +++++++++------ packages/drivers/driver-mongodb/README.md | 3 ++- .../service-datasource/src/driver-catalog.ts | 5 ++-- packages/spec/liveness/datasource.json | 2 +- 5 files changed, 42 insertions(+), 11 deletions(-) create mode 100644 .changeset/23543-f1-retire-mongo-driver-alias.md diff --git a/.changeset/23543-f1-retire-mongo-driver-alias.md b/.changeset/23543-f1-retire-mongo-driver-alias.md new file mode 100644 index 00000000000..383d6479840 --- /dev/null +++ b/.changeset/23543-f1-retire-mongo-driver-alias.md @@ -0,0 +1,24 @@ +--- +'@objectstack/spec': minor +'@objectstack/driver-mongodb': patch +--- + +feat(spec)!: the datasource driver spelling `mongo` is retired — an authored `driver: 'mongo'` is refused with the prescription naming `mongodb`, `OS_DATABASE_DRIVER=mongo` is no longer a driver selection, and `datasource-driver-mongo-to-mongodb` leaves the load path + +Clause-②: yes (narrowing) + +**BREAKING (authoring):** `mongo` was a second accepted spelling of the MongoDB driver id `mongodb` through 17: the loader rewrote an authored `datasource.driver: 'mongo'` to `mongodb` with a notice, and both boot hosts accepted `OS_DATABASE_DRIVER=mongo`. At 18 it is retired. `DriverType` refuses it by name at every door that parses a datasource (`defineStack`, `os validate`, `os build`, `os lint`, the metadata and datasource APIs): "Datasource `driver: 'mongo'` was removed in @objectstack/spec 18 (ADR-0087 D2) … Use `driver: 'mongodb'`." The refusal is case- and whitespace-insensitive, and it is published in the JSON Schema too (a `pattern` on `driver`), so a document validated against `json-schema/**` is refused as well. Both boot hosts now refuse `OS_DATABASE_DRIVER=mongo` (and a standalone stack's `databaseDriver: 'mongo'`) with their "Supported drivers" list, which names `mongodb`. The ADR-0087 D2 conversion `datasource-driver-mongo-to-mongodb` is retired from the load path (`retiredAfter: '17.7.0'`), so the authoring funnel no longer rewrites the spelling. + +Migration: + +- `datasource.driver: 'mongo'` → `driver: 'mongodb'`. Change the value; the driver it builds and the `config` contract it is judged against are unchanged. +- `OS_DATABASE_DRIVER=mongo` → `OS_DATABASE_DRIVER=mongodb`, or unset it and let a `mongodb://` or `mongodb+srv://` database URL select the driver. The same for `databaseDriver: 'mongo'` passed to the standalone stack. +- A script or integration that creates or updates a datasource through the metadata or datasource REST API with `driver: 'mongo'` → send `driver: 'mongodb'`. + +Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. The D3 entry `datasource-driver-mongo-alias-retired` lists what the chain cannot reach: environment variables, launch scripts and API callers. + +Stored metadata needs no edit. Every stored-row rehydration seam replays the retired conversion, so a `sys_metadata` datasource row written as `mongo` (the Setup → Datasources form wrote that until the canonical id was renamed to `mongodb`) keeps loading as `mongodb`. The spelling also stays on the spec's driver CONTRACT face (`resolveDriverId('mongo')` still answers `mongodb`): a reader that judges a stored body as written, such as the read-path credential redactor, keeps judging a legacy row as mongodb. Measured with the spelling deleted outright, that redactor served all ten mongodb passthrough secrets of such a row in cleartext. + +`@objectstack/spec` also exports `RETIRED_DRIVER_ID_GUIDANCE` (retired spelling → its prescription), the table the refusal is derived from. `@objectstack/driver-mongodb`'s README no longer lists `mongo` as a recognised `OS_DATABASE_DRIVER` value. + + diff --git a/content/docs/data-modeling/drivers.mdx b/content/docs/data-modeling/drivers.mdx index 3021dfe3ccd..78084c8a2a1 100644 --- a/content/docs/data-modeling/drivers.mdx +++ b/content/docs/data-modeling/drivers.mdx @@ -77,7 +77,7 @@ libSQL data stayed untouched. Pass the token with `--database-auth-token` | **MySQL** | `@objectstack/driver-sql` (peer: `mysql2`) | `SqlDriver` | `mysql` \| `mysql2` (supported, with three dialect caveats — see [below](#mysql-dialect-caveats)) | | **SQLite** | `@objectstack/driver-sql` (peer: `better-sqlite3`) | `SqlDriver` | `sqlite` \| `sql` | | **SQLite (WASM)** | `@objectstack/driver-sqlite-wasm` | `SqliteWasmDriver` | `sqlite-wasm` \| `wasm-sqlite` \| `wasm` | -| **MongoDB** | `@objectstack/driver-mongodb` | `MongoDBDriver` | `mongodb` \| `mongo` (single-tenant only — see [below](#multi-tenancy-not-supported)) | +| **MongoDB** | `@objectstack/driver-mongodb` | `MongoDBDriver` | `mongodb` (single-tenant only — see [below](#multi-tenancy-not-supported)) | | **Turso / libSQL** | `@objectstack/driver-turso` (optional install — see the callout above) | `TursoDriver` | `turso` \| `libsql` | | **Memory** | `@objectstack/driver-memory` | `InMemoryDriver` | none — not a boot store (see [Memory Driver](#memory-driver)) | @@ -100,9 +100,14 @@ driver ships that contract as a zod schema, exported from `@objectstack/spec/dat | `mysql` \| `mysql2` \| `mariadb` | `MysqlConfigSchema` | `url`, `host`, `port`, `database`, `username`, `password`, `ssl`, `autoMigrate` | | `sqlite` \| `sqlite3` | `SqliteConfigSchema` | `filename`, `autoMigrate` | | `sqlite-wasm` \| `wasm-sqlite` | `SqliteWasmConfigSchema` | `filename`, `persist` | -| `mongo` \| `mongodb` | `MongoConfigSchema` | `url`, `host`, `port`, `database`, `username`, `password`, `authSource`, `options` | +| `mongodb` | `MongoConfigSchema` | `url`, `host`, `port`, `database`, `username`, `password`, `authSource`, `options` | | `memory` \| `in-memory` \| `mingo` | `MemoryConfigSchema` | `initialData`, `strictMode`, `persistence` | +`mongo` was a second spelling of `mongodb` until 18. It is retired: an authored +`driver: 'mongo'` is refused with the prescription naming `mongodb`, and +`OS_DATABASE_DRIVER=mongo` is no longer a driver selection. `os migrate meta --from 16` +lists the edit for existing sources, and stored datasource rows keep loading. + An unrecognised key is rejected with its correction, at authoring time and in the Setup → Datasources wizard alike: @@ -185,11 +190,11 @@ ObjectStack — is what makes them secrets: | Driver | Key | What it carries | | :--- | :--- | :--- | -| `mongo` \| `mongodb` | `options.proxyPassword` | SOCKS5 proxy password | -| `mongo` \| `mongodb` | `options.tlsCertificateKeyFilePassword` | TLS key-file passphrase | -| `mongo` \| `mongodb` | `options.key` | TLS private key material (PEM) | -| `mongo` \| `mongodb` | `options.passphrase` | TLS key passphrase | -| `mongo` \| `mongodb` | `options.authMechanismProperties.AWS_SESSION_TOKEN` | AWS STS session token (`MONGODB-AWS` auth) | +| `mongodb` | `options.proxyPassword` | SOCKS5 proxy password | +| `mongodb` | `options.tlsCertificateKeyFilePassword` | TLS key-file passphrase | +| `mongodb` | `options.key` | TLS private key material (PEM) | +| `mongodb` | `options.passphrase` | TLS key passphrase | +| `mongodb` | `options.authMechanismProperties.AWS_SESSION_TOKEN` | AWS STS session token (`MONGODB-AWS` auth) | | `turso` \| `libsql` | `encryptionKey` | AES-256 key for the local database file | Unlike `password` / `authToken` (typed `never` in their schemas — the parse diff --git a/packages/drivers/driver-mongodb/README.md b/packages/drivers/driver-mongodb/README.md index 01e506778aa..71e8de45791 100644 --- a/packages/drivers/driver-mongodb/README.md +++ b/packages/drivers/driver-mongodb/README.md @@ -30,7 +30,8 @@ pnpm dev The ObjectStack CLI auto-registers this driver when `OS_DATABASE_URL` starts with `mongodb://` or `mongodb+srv://`. You do **not** need to set `OS_DATABASE_DRIVER` separately — the URL scheme is enough. Set it only as an -explicit override (recognised values: `mongodb`, `mongo`). +explicit override (recognised value: `mongodb`; the old `mongo` spelling was retired in +`@objectstack/spec` 18 and is refused). ### Option B — Programmatic via `defineStack` diff --git a/packages/services/service-datasource/src/driver-catalog.ts b/packages/services/service-datasource/src/driver-catalog.ts index 69d1a3247d6..e4b51448c34 100644 --- a/packages/services/service-datasource/src/driver-catalog.ts +++ b/packages/services/service-datasource/src/driver-catalog.ts @@ -83,8 +83,9 @@ const CURATED: ReadonlyArray<{ // 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. + // `datasource-driver-mongo-to-mongodb` converges them at rehydration. Since + // 18 an authored `mongo` is refused; the spelling stays on the spec's + // contract face only, so a stored row keeps its contract and its redaction. id: 'mongodb', label: 'MongoDB', description: 'MongoDB connection via a connection URI.', diff --git a/packages/spec/liveness/datasource.json b/packages/spec/liveness/datasource.json index 360ed5a1011..be6e63358fc 100644 --- a/packages/spec/liveness/datasource.json +++ b/packages/spec/liveness/datasource.json @@ -20,7 +20,7 @@ "status": "live", "verifiedAt": "2026-08-28", "evidence": "packages/services/service-datasource/src/default-datasource-driver-factory.ts#resolveKind (`return resolveDriverId(driverId)` — the alias normalization); packages/services/service-datasource/src/default-datasource-driver-factory.ts#createDefaultDatasourceDriverFactory (`const kind = resolveKind(spec.driver)`, then the per-driver construction arms; an id that normalizes to nothing is refused by name)", - "note": "factory dispatch; `resolveDriverId` normalizes aliases before the switch. 2026-08-28: RE-ANCHORED (commit 9ee2dcfbd) and REPOINTED — `:345` had rotted in range onto a docblock line about `datasource.ssl` in this 1314-line file; the dispatch is at ~:1037. Since commit e2798fab7 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. Re-closed by hand against c459da6bc; DATED for the first time (this file's rows were all undated, which the review that accepted the re-anchoring sorts as oldest)." + "note": "factory dispatch; `resolveDriverId` normalizes aliases before the switch. 2026-08-28: RE-ANCHORED (commit 9ee2dcfbd) and REPOINTED — `:345` had rotted in range onto a docblock line about `datasource.ssl` in this 1314-line file; the dispatch is at ~:1037. Since commit e2798fab7 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`; since 18 an authored `mongo` is refused and the spelling stays on the contract face only). Every builtin id now has an explicit construction arm — the trailing `memory` fall-through is gone. Re-closed by hand against c459da6bc; DATED for the first time (this file's rows were all undated, which the review that accepted the re-anchoring sorts as oldest)." }, "config": { "status": "live",