From 16370582f7291925f009dcc1fbcd7773d5bd9806 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 15:39:56 +0000 Subject: [PATCH 1/8] fix(cli): os migrate plan/apply connect and plan the declared datasources os serve connects A datasourceMapping rule routing objects to a declared datasource (the declared default included) left them bound to no driver on the migrate boot: no datasource-connection service was published, so AppPlugin kept every declared datasource metadata-only. Publish one (managed datasources only) and plan each declared datasource the deployment's objects resolve to. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QyoWKK9RwUNxBxmevy9Neb --- packages/cli/src/commands/migrate/apply.ts | 4 + packages/cli/src/commands/migrate/plan.ts | 12 +- packages/cli/src/utils/schema-migrate.ts | 228 ++++++++++++++++++++- 3 files changed, 235 insertions(+), 9 deletions(-) diff --git a/packages/cli/src/commands/migrate/apply.ts b/packages/cli/src/commands/migrate/apply.ts index dcce2d79612..f1a9263957b 100644 --- a/packages/cli/src/commands/migrate/apply.ts +++ b/packages/cli/src/commands/migrate/apply.ts @@ -219,6 +219,10 @@ export default class MigrateApply extends Command { deferredDdlUnsupported: 'skip-schema-sync', composeHostStack: true, composeServedPlatform: true, + // [#24120] The plan's datasources too: each declared datasource a + // `datasourceMapping` rule routes objects to, connected as `os serve` + // connects it, reconciled where they live. + composeDeclaredDatasources: true, }); } catch (error: any) { if (flags.json) { await emitJson({ error: error.message, ...errorCodeFields(error) }, 0, { compact: true }); this.exit(1); } diff --git a/packages/cli/src/commands/migrate/plan.ts b/packages/cli/src/commands/migrate/plan.ts index 5f0349a7c17..c7eff151612 100644 --- a/packages/cli/src/commands/migrate/plan.ts +++ b/packages/cli/src/commands/migrate/plan.ts @@ -19,6 +19,7 @@ import { describeWithApplyLimits, describeDatabaseSource, describeTelemetryDatabase, + distinctDatabaseDrivers, renderPlan, renderPendingSchemaWork, summarize, @@ -185,6 +186,11 @@ export default class MigratePlan extends Command { // resolves — or an app declaring `requires: ['auth']` plans without // `sys_account`, and its retired `issuer` column is never a drop. composeServedPlatform: true, + // [#24120] …and the datasources the deployment declares, connected as + // `os serve` connects them: a `datasourceMapping` rule's objects are + // planned in the datasource it routes them to, the declared default's + // included. `os migrate apply` passes the same. + composeDeclaredDatasources: true, }); } catch (error: any) { if (flags.json) { await emitJson({ error: error.message, ...errorCodeFields(error) }, 0, { compact: true }); this.exit(1); } @@ -221,8 +227,10 @@ export default class MigratePlan extends Command { // indistinguishable from "never looked at". See `../../utils/unmanaged-tables.ts`. const { normalizeRows } = await import('@objectstack/metadata-protocol'); const unmanagedTables: UnmanagedTablesReport = await collectUnmanagedTables({ - // [#22580] Every database the drift above covers, the sibling included. - drivers: stack.drivers, + // [#22580] Every database the drift above covers, the sibling included — + // [#24120] each read once, though a declared default and the primary + // resolved from it are two connections to one file. + drivers: distinctDatabaseDrivers(stack.drivers), declaredObjects: stack.allObjects(), composition: stack.composition, normalize: normalizeRows, diff --git a/packages/cli/src/utils/schema-migrate.ts b/packages/cli/src/utils/schema-migrate.ts index af95394793a..f05c2789e59 100644 --- a/packages/cli/src/utils/schema-migrate.ts +++ b/packages/cli/src/utils/schema-migrate.ts @@ -31,6 +31,19 @@ * §3.6), this boot provisions that sibling through the same helper * (`provisionTelemetryDatasource`, #22579), and the plan diffs and applies each * object against the database it lives in ({@link SchemaStack.detectManagedDrift}). + * + * [#24120] …and so are the datasources the deployment DECLARES. A serving boot + * connects every managed datasource its `datasourceMapping` routes objects to + * (or an object binds) through the `datasource-connection` service, under the + * datasource's declared name — so a `{ default: true, datasource: 'main' }` + * rule puts every object, platform objects included, in `main`. `os migrate + * plan` / `apply` compose that same service ({@link DeclaredDatasourceConnectionPlugin}), + * and plan each such datasource's driver beside the primary + * ({@link SchemaStack.drivers}). Without it the boot resolved the declared + * default's LOCATION as its primary but registered no driver under its name, + * so every object the rule routed there was bound to no driver: `Examined 0 + * managed table(s)` over the database whose drift `os dev` reports, and names + * this command for. */ import path from 'node:path'; import chalk from 'chalk'; @@ -51,7 +64,7 @@ import { readDotenvFiles, type DotenvReading, } from '../commands/doctor.js'; -import { describeDriverConnection } from './connection-display.js'; +import { describeDriverConnection, describeDriverSqliteFile } from './connection-display.js'; import { reserveStdoutForJson } from './json-stdout.js'; import { buildSchemaMigrationPlugins, @@ -125,8 +138,10 @@ export interface SchemaStack { telemetryDatabase: string | null; /** * [#22579] Every SQL driver this boot plans against, in order: the primary - * ({@link driver}), then the `telemetry` sibling when there is one. An object - * whose engine-resolved driver is not here is outside the plan. + * ({@link driver}), then the `telemetry` sibling when there is one, then + * [#24120] each declared managed datasource the boot connected for the + * objects routed or bound to it, by name. An object whose engine-resolved + * driver is not here is outside the plan. */ drivers: readonly SqlDriverLike[]; /** @@ -541,6 +556,129 @@ function defaultDriverOf(ctx: { getService?: (name: string) => unknown }): unkno return null; } +/** + * [#24120] The kernel service `AppPlugin` connects a deployment's declared + * datasources through — ADR-0062 D1's one "definition → live driver" path, + * `DatasourceConnectionService`. `os serve` publishes it from + * `DatasourceAdminServicePlugin`; without a publisher, `AppPlugin` leaves every + * declared datasource metadata-only, which is how a schema-migration boot came + * to register no driver for the datasource a `datasourceMapping` rule routes + * its objects to. + */ +const DATASOURCE_CONNECTION_SERVICE = 'datasource-connection'; + +/** + * [#24120] The connect policy of a schema-migration boot: MANAGED datasources + * only. A datasource whose schema the remote owns (`schemaMode: 'external'` / + * `'validate-only'`) holds no table a migration diffs — its objects are + * federated, counted apart by the coverage pass — so a dry run opens no + * connection to it. Every managed datasource connects as on `os serve`. + */ +const MANAGED_DATASOURCES_ONLY = { + canConnect: (ds: { schemaMode?: string }): { allow: boolean; reason?: string } => + ds.schemaMode === undefined || ds.schemaMode === 'managed' + ? { allow: true } + : { + allow: false, + reason: `schemaMode '${ds.schemaMode}': the remote owns this datasource's schema, ` + + 'so a schema migration has no table to plan on it', + }, +}; + +/** + * [#24120] Publishes {@link DATASOURCE_CONNECTION_SERVICE} on a schema-migration + * boot, so `AppPlugin` connects the deployment's declared datasources by the + * code `os serve` runs, under their declared names — ahead of the first schema + * sync, which is where `AppPlugin` connects a datasource its mapping routes + * registered objects to. Each connect reaches the engine through + * `registerDriver`, so {@link DeferSchemaDdlPlugin} arms it before it syncs and + * the declaration write guard refuses its row writes, exactly as for the + * primary. + * + * Two differences from `os serve`'s publisher, both because this boot is a + * migration and not a server: the policy is {@link MANAGED_DATASOURCES_ONLY}, + * and a read-only probe's factory opens an absent SQLite file as an empty + * in-memory database (`sqliteAbsentFile`, #6743), as the primary's does. Not + * composed when the composition already brings a publisher (a host config + * listing `DatasourceAdminServicePlugin`): the kernel refuses a second. + */ +export class DeclaredDatasourceConnectionPlugin { + name = 'com.objectstack.cli.declared-datasource-connection'; + version = '1.0.0'; + /** ADR-0116: the service `init()` registers on every path. */ + providesServices = [DATASOURCE_CONNECTION_SERVICE]; + + constructor(private readonly options: { readOnlyProbe?: boolean } = {}) {} + + init = async (ctx: any) => { + // Lazy, as every driver-adjacent import in this module is (#5726). + const { DatasourceConnectionService, createDefaultDatasourceDriverFactory } = + await import('@objectstack/service-datasource'); + const factory = createDefaultDatasourceDriverFactory( + this.options.readOnlyProbe ? { sqliteAbsentFile: 'empty-in-memory' } : {}, + ); + ctx.registerService(DATASOURCE_CONNECTION_SERVICE, new DatasourceConnectionService({ + factory: () => factory, + engine: () => { + try { return ctx.getService('data'); } catch { return undefined; } + }, + policy: MANAGED_DATASOURCES_ONLY, + logger: ctx.logger, + })); + }; +} + +/** Does any of `plugins` already publish {@link DATASOURCE_CONNECTION_SERVICE}? */ +function publishesDatasourceConnection(plugins: readonly unknown[]): boolean { + return plugins.some((p) => { + const provides = (p as { providesServices?: unknown } | null)?.providesServices; + return Array.isArray(provides) && provides.includes(DATASOURCE_CONNECTION_SERVICE); + }); +} + +/** + * [#24120] The declared datasources this boot connected that the deployment's + * objects live in: each SQL driver registered under a datasource name the + * {@link DATASOURCE_CONNECTION_SERVICE} reports `available` that a registered, + * non-federated object resolves to — the engine's own routing answers which, + * never a re-read of the rules. `planned` (the primary and the sibling) is not + * repeated. Sorted by name, so the plan reads in one order. + * + * `available`, not the `connected` status: `AppPlugin` connects a mapped + * datasource before the first schema sync and asks again in `start()`, and the + * service retains the LAST verdict — `already-registered` for the same live + * driver. + */ +function findDeclaredDatasourceDrivers( + kernel: unknown, + planned: readonly SqlDriverLike[], +): Array<{ name: string; driver: SqlDriverLike }> { + const service = (name: string): any => { + try { return (kernel as { getService?: (n: string) => unknown })?.getService?.(name); } catch { return undefined; } + }; + const states: unknown = service(DATASOURCE_CONNECTION_SERVICE)?.listConnectionStates?.(); + const connected = new Set( + (Array.isArray(states) ? states : []) + .filter((s: { availability?: unknown }) => s?.availability === 'available') + .map((s: { name?: unknown }) => s.name), + ); + if (connected.size === 0) return []; + const engine = service('objectql'); + const objects: unknown = engine?.registry?.getAllObjects?.(); + if (!Array.isArray(objects) || typeof engine?.getDriverForObject !== 'function') return []; + const found = new Map(); + for (const obj of objects as Array<{ name?: unknown; external?: unknown }>) { + if (obj?.external != null || typeof obj?.name !== 'string') continue; + const driver = engine.getDriverForObject(obj.name); + const name = driver?.name; + if (typeof name !== 'string' || !connected.has(name) || found.has(name) || planned.includes(driver)) continue; + if (typeof driver.detectManagedDrift === 'function' && typeof driver.applyMigrationEntries === 'function') { + found.set(name, driver as SqlDriverLike); + } + } + return [...found].sort(([a], [b]) => a.localeCompare(b)).map(([name, driver]) => ({ name, driver })); +} + /** * [#23816] Does the transport the boot will open for `resolved` refuse * deferred schema DDL? Today exactly the Turso REMOTE transport. The @@ -579,6 +717,44 @@ function describeDb(driver: SqlDriverLike | null): string { return describeDriverConnection(cfg) ?? String(cfg.client ?? 'unknown'); } +/** + * [#24120] One planned driver per DATABASE, for a reading of what each + * database holds (`os migrate plan`'s unmanaged-table sweep): the first driver + * of each, in {@link SchemaStack.drivers} order. + * + * Two planned drivers can address one database. A `datasourceMapping` default + * the primary resolves its location from is connected again under its declared + * name, exactly as `os serve` connects it — the primary's file and the declared + * datasource's are one file — and read twice, every table counted twice. + * Identity is the database file a SQLite driver names + * ({@link describeDriverSqliteFile}, resolved as SQLite opens it), otherwise + * the connection a driver describes; a driver with neither (an in-memory + * database, each connection its own) is never folded into another. + */ +export function distinctDatabaseDrivers(drivers: readonly SqlDriverLike[]): SqlDriverLike[] { + const seen = new Set(); + const out: SqlDriverLike[] = []; + for (const driver of drivers) { + const file = describeDriverSqliteFile(driver.config); + const identity = file !== undefined + ? `file:${path.resolve(file)}` + : hasSqliteFilename(driver.config) ? undefined : describeDriverConnection(driver.config); + if (identity !== undefined && seen.has(identity)) continue; + if (identity !== undefined) seen.add(identity); + out.push(driver); + } + return out; +} + +/** Does a driver config name a SQLite target at all — a file, `:memory:`, or SQLite's private temporary database? */ +function hasSqliteFilename(config: unknown): boolean { + if (!config || typeof config !== 'object') return false; + const cfg = config as Record; + const conn = cfg.connection; + const bag = conn && typeof conn === 'object' ? conn as Record : cfg; + return typeof (bag.filename ?? cfg.filename) === 'string'; +} + // ── The project's `.env*` files (#22581) ──────────────────────────── /** @@ -953,6 +1129,21 @@ export async function bootSchemaStack( * See `buildSchemaMigrationPlugins`'s `servedPlatform`. */ composeServedPlatform?: boolean; + /** + * [#24120] Connect the deployment's declared MANAGED datasources as `os + * serve` connects them — through the `datasource-connection` service, + * under their declared names ({@link DeclaredDatasourceConnectionPlugin}) + * — and plan each one the deployment's objects live in beside the primary + * ({@link SchemaStack.drivers}). Set by `os migrate plan` and `os migrate + * apply`, so the plan an operator reads and the apply that confirms it + * cover the same datasources; by nothing else. + * + * Without it a `datasourceMapping` rule routing objects to a declared + * datasource left them bound to no driver here, the declared DEFAULT's + * included: the boot opened that datasource's database as its primary and + * examined none of its tables. + */ + composeDeclaredDatasources?: boolean; }, ): Promise { // Taken BEFORE the first line the boot can print. `createStandaloneStack` @@ -1110,6 +1301,14 @@ export async function bootSchemaStack( for (const plugin of opts.extraPlugins ?? []) { await kernel.use(plugin as any); } + // [#24120] The publisher `AppPlugin` connects declared datasources through, + // unless the composition already brought one. + if ( + opts.composeDeclaredDatasources === true + && !publishesDatasourceConnection([...stack.plugins, ...composition.plugins, ...(opts.extraPlugins ?? [])]) + ) { + await kernel.use(new DeclaredDatasourceConnectionPlugin({ readOnlyProbe: opts.readOnlyProbe === true }) as any); + } await runtime.start(); // #13332 — the kernel bootstrap is over, and with it the window the @@ -1131,11 +1330,16 @@ export async function bootSchemaStack( const driver = findSqlDriver(kernel); // [#22579] Every SQL driver this boot plans against: the primary, then the - // sibling it provisioned. Nothing else — a datasource an artifact declares - // or a mapping names stays outside the plan, reported by its coverage. + // sibling it provisioned, then [#24120] each declared datasource this boot + // connected that objects live in. Nothing else — a datasource registered + // by any other path stays outside the plan, reported by its coverage. const telemetryDriver = telemetryDatabase !== null ? findTelemetryDriver(kernel) : null; - const plannedDrivers: SqlDriverLike[] = [driver, telemetryDriver] + const servedDrivers: SqlDriverLike[] = [driver, telemetryDriver] .filter((d): d is SqlDriverLike => d !== null); + const declaredDrivers = opts.composeDeclaredDatasources === true + ? findDeclaredDatasourceDrivers(kernel, servedDrivers) + : []; + const plannedDrivers: SqlDriverLike[] = [...servedDrivers, ...declaredDrivers.map((d) => d.driver)]; // #13028 — the composed host declared its objects in `init()`; the pass that // hands them to their driver lives in `ObjectQLPlugin.start()`, which the @@ -1145,7 +1349,17 @@ export async function bootSchemaStack( // already armed, and record what it could NOT reach — so a partial plan says // so instead of reading as coverage. Runs only when this boot actually // composed a host; every other caller is untouched. - if (opts.composeHostStack === true && composition.notes.length > 0) { + const composedHost = opts.composeHostStack === true && composition.notes.length > 0; + // [#24120] Which database each declared datasource's objects are planned + // against, in both faces — `Database:` names the primary alone. + if (declaredDrivers.length > 0) { + composition.notes.push( + `Planned ${declaredDrivers.length} declared datasource(s) against the database os serve keeps their objects in — ` + + `${declaredDrivers.map((d) => `${d.name} (${describeDb(d.driver)})`).join(', ')} — connected under the ` + + 'declared name, as os serve connects them.', + ); + } + if (composedHost) { const measured = await measureComposedCoverage(kernel, plannedDrivers, defer, { ddlFree: skipSchemaSync }); composition.coverage = measured.coverage; composition.notes.push(...measured.notes); From 61ed0bdf74cce71c9980ddbcff917272292ae49b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 15:47:00 +0000 Subject: [PATCH 2/8] test(cli): pin os migrate plan's drift set to os dev's on a datasourceMapping-default datasource Also read each planned database once in the unmanaged-table sweep: the primary resolved from a declared default and the declared connection are one file, and were counted twice. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QyoWKK9RwUNxBxmevy9Neb --- ...red-default-datasource.integration.test.ts | 160 ++++++++++++++++++ 1 file changed, 160 insertions(+) create mode 100644 packages/cli/src/commands/migrate/plan.declared-default-datasource.integration.test.ts diff --git a/packages/cli/src/commands/migrate/plan.declared-default-datasource.integration.test.ts b/packages/cli/src/commands/migrate/plan.declared-default-datasource.integration.test.ts new file mode 100644 index 00000000000..b9c7eed005d --- /dev/null +++ b/packages/cli/src/commands/migrate/plan.declared-default-datasource.integration.test.ts @@ -0,0 +1,160 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { spawnSync } from 'node:child_process'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import Database from 'better-sqlite3'; +import { defineStackSource, linkSpec } from '../../../test/helpers/define-stack-fixture.js'; +import { childEnv, reservePort } from '../../../test/helpers/serve-process.js'; + +/** + * [#24120] On a project whose primary store is a DECLARED datasource that + * `datasourceMapping` routes as the default, `os migrate plan` examines the + * drift `os dev` reports — the same set, in the same database. + * + * ## The defect + * + * `datasources: [{ name: 'qa_main', … }]` with + * `datasourceMapping: [{ default: true, datasource: 'qa_main' }]`. `os dev` + * connects `qa_main` under its declared name (through the + * `datasource-connection` service `AppPlugin` connects declared datasources + * by), so every object — platform objects included — lives there, and its boot + * prints a `[schema-drift]` line per divergence, each naming `os migrate` as + * the remedy. `os migrate plan` resolved the same database FILE as its primary + * ("the project config's default datasource qa_main") but published no such + * service, so `qa_main` stayed metadata-only, every object routed to it was + * bound to no driver, and the plan printed `Examined 0 managed table(s)` and a + * PARTIAL plan with no drift at all. + * + * ## What is pinned + * + * Two drifts are staged in the declared datasource's database, one per kind of + * object the default rule routes: a column the app object no longer declares, + * and a column no platform object declares on `sys_user`. Then: + * + * - the plan's drift set (`table.column` of each `changes` entry) EQUALS the + * set of `[schema-drift]` lines `os dev` prints over the same database — and + * holds both staged drifts, so the equality is not two empty sets; + * - the plan examined every declared object (`unexaminedObjects: 0`) and + * names who chose its database (`config-datasource`, `qa_main`); + * - its unmanaged-table sweep counts that database's tables once, although + * the primary and `qa_main` are two connections to the one file. + * + * `os dev` runs as the operator runs it, compiling the config, with + * `OS_MIGRATE_AND_EXIT=1` (its `serve --dev` child boots, syncs and exits) and + * `--no-restart` (so the parent exits with it). The datasource's filename is + * RELATIVE, as an author writes it: the primary resolves it against the project + * root, the declared connection opens it from the project directory. + */ + +const HERE = dirname(fileURLToPath(import.meta.url)); +const CLI_ROOT = resolve(HERE, '..', '..', '..'); +const RUN_DEV = resolve(CLI_ROOT, 'bin', 'run-dev.js'); +const TSX = resolve(CLI_ROOT, '..', '..', 'node_modules', '.bin', 'tsx'); +const CASE_TIMEOUT_MS = 600_000; +const SPAWN_TIMEOUT_MS = 240_000; + +const APP_OBJECT = 'fx24120_thing'; +const DATABASE = join('data', 'main.db'); +/** The `[schema-drift]` line the SQL driver prints at boot: `[schema-drift] TABLE.COLUMN: …`. */ +const DRIFT_LINE = /\[schema-drift\] ([a-z0-9_]+\.[a-z0-9_]+):/g; + +let dir: string; + +function writeConfig(fields: Record): void { + writeFileSync(join(dir, 'objectstack.config.ts'), defineStackSource({ + manifest: { id: 'com.example.fx24120', name: 'fx24120', version: '0.0.0', type: 'app', engines: { protocol: '^18' } }, + datasources: [{ name: 'qa_main', driver: 'sqlite', config: { filename: `./${DATABASE}` } }], + datasourceMapping: [{ default: true, datasource: 'qa_main' }], + objects: [{ name: APP_OBJECT, sharingModel: 'private', fields }], + })); +} + +/** The child's environment: nothing inherited that names a database, a home or a telemetry sibling. */ +function env(extra: Record = {}): Record { + return childEnv({ + OS_DATABASE_URL: undefined, + DATABASE_URL: undefined, + TURSO_DATABASE_URL: undefined, + OS_DATABASE_DRIVER: undefined, + OS_HOME: undefined, + OS_ARTIFACT_PATH: undefined, + OS_TELEMETRY_DB: '0', + OS_CLOUD_URL: 'off', + OS_TELEMETRY_DISABLED: '1', + OS_AUTH_SECRET: 'os24120-declared-default-secret-at-least-32-characters', + NO_COLOR: '1', + ...extra, + }); +} + +/** + * `os dev`, booted once and exited: its output. `--compile` because a config + * newer than its artifact is otherwise served STALE (with a warning), and the + * staged edit below is exactly such a config. + */ +function devBootOnce(): string { + const r = spawnSync( + TSX, + [RUN_DEV, 'dev', '--port', String(reservePort()), '--compile', '--no-watch', '--no-restart', '--no-seed-admin'], + { cwd: dir, env: env({ OS_MIGRATE_AND_EXIT: '1' }), encoding: 'utf8', timeout: SPAWN_TIMEOUT_MS }, + ); + const output = `${r.stdout}\n${r.stderr}`; + expect(r.status, `os dev did not boot and exit:\n${output.slice(-1500)}`).toBe(0); + return output; +} + +function planJson(): any { + const r = spawnSync(TSX, [RUN_DEV, 'migrate', 'plan', '--json'], { + cwd: dir, env: env(), encoding: 'utf8', timeout: SPAWN_TIMEOUT_MS, + }); + expect(r.status, `os migrate plan failed:\n${`${r.stdout}\n${r.stderr}`.slice(-1500)}`).toBe(0); + return JSON.parse(r.stdout); +} + +function tableCount(file: string): number { + const db = new Database(file, { readonly: true }); + try { + return (db.prepare("SELECT COUNT(*) AS n FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'").get() as { n: number }).n; + } finally { + db.close(); + } +} + +beforeAll(() => { + dir = mkdtempSync(join(tmpdir(), 'os-24120-declared-default-')); + linkSpec(dir); +}); + +afterAll(() => { + if (dir) rmSync(dir, { recursive: true, force: true }); +}); + +describe('os migrate plan on a datasourceMapping-default declared datasource (#24120)', () => { + it('examines the drift os dev reports there — the same set', () => { + // Provision: the first boot creates every table in the declared datasource. + writeConfig({ title: { type: 'text' }, extra: { type: 'text' } }); + devBootOnce(); + + // Stage: the app object stops declaring `extra`, and `sys_user` (a platform + // object the same rule routes) gains a column no object declares. + writeConfig({ title: { type: 'text' } }); + const db = new Database(join(dir, DATABASE)); + try { db.exec('ALTER TABLE sys_user ADD COLUMN fx24120_legacy TEXT'); } finally { db.close(); } + + const reported = new Set([...devBootOnce().matchAll(DRIFT_LINE)].map((m) => m[1])); + const plan = planJson(); + const planned = new Set((plan.changes as Array<{ table: string; column?: string }>).map((c) => `${c.table}.${c.column}`)); + + expect([...reported].sort()).toEqual(expect.arrayContaining([`${APP_OBJECT}.extra`, 'sys_user.fx24120_legacy'])); + expect([...planned].sort()).toEqual([...reported].sort()); + expect(plan.databaseSource).toEqual({ kind: 'config-datasource', datasource: 'qa_main' }); + expect(plan.composition?.coverage?.unexaminedObjects, JSON.stringify(plan.composition?.coverage)).toBe(0); + expect(plan.managedTables).toBeGreaterThan(0); + expect(plan.unmanagedTables?.status, JSON.stringify(plan.unmanagedTables)).toBe('read'); + expect(plan.unmanagedTables.physicalTables).toBe(tableCount(join(dir, DATABASE))); + }, CASE_TIMEOUT_MS); +}); From 3df5f9f1fcf79611c4fd1fea7cd64531d4d2eeff Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 15:49:40 +0000 Subject: [PATCH 3/8] test(cli): pin the declared-datasource connection policy and one-driver-per-database sweep Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QyoWKK9RwUNxBxmevy9Neb --- ...red-default-datasource.integration.test.ts | 1 + ...chema-migrate.declared-datasources.test.ts | 90 +++++++++++++++++++ 2 files changed, 91 insertions(+) create mode 100644 packages/cli/src/utils/schema-migrate.declared-datasources.test.ts diff --git a/packages/cli/src/commands/migrate/plan.declared-default-datasource.integration.test.ts b/packages/cli/src/commands/migrate/plan.declared-default-datasource.integration.test.ts index b9c7eed005d..486c68b3beb 100644 --- a/packages/cli/src/commands/migrate/plan.declared-default-datasource.integration.test.ts +++ b/packages/cli/src/commands/migrate/plan.declared-default-datasource.integration.test.ts @@ -1,4 +1,5 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// test-budget: 70 — spawns the real producer once per single-cause fixture: three spawns of the source CLI over one staged fixture (os dev to provision the declared datasource, os dev over the staged drift, os migrate plan --json); 51.6 s, the slower of two local runs on a shared container, x 1.25, no CI reading yet import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { spawnSync } from 'node:child_process'; diff --git a/packages/cli/src/utils/schema-migrate.declared-datasources.test.ts b/packages/cli/src/utils/schema-migrate.declared-datasources.test.ts new file mode 100644 index 00000000000..a7950faed21 --- /dev/null +++ b/packages/cli/src/utils/schema-migrate.declared-datasources.test.ts @@ -0,0 +1,90 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#24120] The two pieces of `bootSchemaStack`'s `composeDeclaredDatasources` + * that decide something on their own, pinned without a database (the + * end-to-end pin — `os migrate plan`'s drift set equal to `os dev`'s on a + * `datasourceMapping`-default datasource — is + * `commands/migrate/plan.declared-default-datasource.integration.test.ts`): + * + * - `DeclaredDatasourceConnectionPlugin` publishes the `datasource-connection` + * service `AppPlugin` connects declared datasources through, and that + * service connects a MANAGED datasource and refuses one whose schema the + * remote owns — a dry run opens no connection a migration has no table on; + * - `distinctDatabaseDrivers` keeps one planned driver per database, so the + * unmanaged-table sweep reads the primary and the declared default it was + * resolved from — two connections to one file — once. + */ + +import { describe, expect, it } from 'vitest'; +import { resolve } from 'node:path'; +import { + DeclaredDatasourceConnectionPlugin, + distinctDatabaseDrivers, + type SqlDriverLike, +} from './schema-migrate.js'; + +/** The service `init()` published, from a context that records registrations. */ +async function publishedService(): Promise<{ connect: (record: unknown, opts?: unknown) => Promise<{ status: string }> }> { + const services = new Map(); + const ctx = { + registerService: (name: string, service: unknown) => { services.set(name, service); }, + // No engine: a connect the policy admits stops at "no infrastructure", which + // is the observable difference from one the policy refuses. + getService: (name: string) => { + if (!services.has(name)) throw new Error(`no ${name}`); + return services.get(name); + }, + logger: { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} }, + }; + await new DeclaredDatasourceConnectionPlugin().init(ctx); + expect([...services.keys()]).toEqual(['datasource-connection']); + return services.get('datasource-connection') as never; +} + +describe('DeclaredDatasourceConnectionPlugin', () => { + it('declares the service it publishes', () => { + expect(new DeclaredDatasourceConnectionPlugin().providesServices).toEqual(['datasource-connection']); + }); + + it.each([ + ['managed (declared)', 'managed', 'skipped-no-infra'], + ['managed (the default)', undefined, 'skipped-no-infra'], + ['external', 'external', 'skipped-policy'], + ['validate-only', 'validate-only', 'skipped-policy'], + ] as const)('a %s datasource -> %s', async (_label, schemaMode, status) => { + const service = await publishedService(); + const result = await service.connect( + { name: 'qa_main', driver: 'sqlite', config: { filename: ':memory:' }, ...(schemaMode ? { schemaMode } : {}) }, + { context: { origin: 'code', trigger: 'declared-auto' } }, + ); + expect(result.status).toBe(status); + }); +}); + +/** A planned driver with nothing but the config the identity is read from. */ +function driver(config: unknown): SqlDriverLike { + return { config } as SqlDriverLike; +} + +describe('distinctDatabaseDrivers', () => { + it('folds a SQLite file named twice — absolute and relative, flat and knex-shaped — into its first driver', () => { + const primary = driver({ client: 'better-sqlite3', connection: { filename: resolve('fx-24120', 'main.db') } }); + const declared = driver({ filename: './fx-24120/main.db' }); + const sibling = driver({ client: 'better-sqlite3', connection: { filename: resolve('fx-24120', 'telemetry.db') } }); + expect(distinctDatabaseDrivers([primary, sibling, declared])).toEqual([primary, sibling]); + }); + + it('never folds an in-memory database: each connection is its own', () => { + const a = driver({ client: 'better-sqlite3', connection: { filename: ':memory:' } }); + const b = driver({ filename: ':memory:' }); + expect(distinctDatabaseDrivers([a, b])).toEqual([a, b]); + }); + + it('folds a server database by the connection it describes, and only that', () => { + const a = driver({ client: 'pg', connection: 'postgres://app@db.example:5432/main' }); + const b = driver({ client: 'pg', connection: 'postgres://app@db.example:5432/main' }); + const c = driver({ client: 'pg', connection: 'postgres://app@db.example:5432/other' }); + expect(distinctDatabaseDrivers([a, b, c])).toEqual([a, c]); + }); +}); From 8d233b42eb978fffdb62914b393a91b3d6805d59 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 15:52:12 +0000 Subject: [PATCH 4/8] chore(changeset): os migrate plan/apply examine declared mapping-routed datasources Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QyoWKK9RwUNxBxmevy9Neb --- .../24120-migrate-plan-declared-datasources.md | 14 ++++++++++++++ .../schema-migrate.declared-datasources.test.ts | 2 +- 2 files changed, 15 insertions(+), 1 deletion(-) create mode 100644 .changeset/24120-migrate-plan-declared-datasources.md diff --git a/.changeset/24120-migrate-plan-declared-datasources.md b/.changeset/24120-migrate-plan-declared-datasources.md new file mode 100644 index 00000000000..d07ce45500c --- /dev/null +++ b/.changeset/24120-migrate-plan-declared-datasources.md @@ -0,0 +1,14 @@ +--- +'@objectstack/cli': patch +--- + +fix(cli): `os migrate plan` and `os migrate apply` examine the objects a `datasourceMapping` rule routes to a declared datasource, the declared default included, in that datasource, as `os serve` connects it (#24120) + +Clause-②: no + +A project whose primary store is a declared datasource routed as the default (`datasources: [{ name: 'qa_main', … }]` with `datasourceMapping: [{ default: true, datasource: 'qa_main' }]`) got a plan that examined nothing. The plan resolved `qa_main`'s database as its primary, but it registered no driver under the name `qa_main`, so every object the rule routes there, platform objects included, was bound to no driver. The plan printed `Examined 0 managed table(s)` and a PARTIAL plan. Meanwhile `os dev` on the same project reported drift in that database and named `os migrate` as the remedy. + +- **Now:** `plan` and `apply` connect the project's declared **managed** datasources the way `os serve` connects them: through the same `datasource-connection` service, under the declared name, for the datasources a mapping rule routes objects to, an object binds to, or that set `autoConnect`. Each datasource that objects live in is diffed and applied beside the primary. A composition note names each one and its database, in both the human output and the `--json` output. On a `datasourceMapping`-default project, the plan's drift is the drift `os dev` reports there. +- **Not connected:** a datasource whose schema the remote owns (`schemaMode: 'external'` or `'validate-only'`). Its objects are federated, and a plan has no table on it. +- **Refused now:** a routed managed datasource that cannot connect fails `plan` / `apply` with the connect error `os serve` gives for it. It is no longer skipped with a PARTIAL plan. `OS_ALLOW_DRIVER_CONNECT_FAILURE` degrades it here as it does there. +- **Unchanged:** a project that declares no datasource gets the plan it got before. `os migrate unmapped-columns`, `security-catalog-overlays` and `organization-ownership` boot as before. The unmanaged-table sweep's `physicalTables` counts each database once, although the primary and a declared default resolved from it are two connections to one file. diff --git a/packages/cli/src/utils/schema-migrate.declared-datasources.test.ts b/packages/cli/src/utils/schema-migrate.declared-datasources.test.ts index a7950faed21..8558dfbdf77 100644 --- a/packages/cli/src/utils/schema-migrate.declared-datasources.test.ts +++ b/packages/cli/src/utils/schema-migrate.declared-datasources.test.ts @@ -52,7 +52,7 @@ describe('DeclaredDatasourceConnectionPlugin', () => { ['managed (the default)', undefined, 'skipped-no-infra'], ['external', 'external', 'skipped-policy'], ['validate-only', 'validate-only', 'skipped-policy'], - ] as const)('a %s datasource -> %s', async (_label, schemaMode, status) => { + ] as const)('a %s datasource (schemaMode %s) -> %s', async (_label, schemaMode, status) => { const service = await publishedService(); const result = await service.connect( { name: 'qa_main', driver: 'sqlite', config: { filename: ':memory:' }, ...(schemaMode ? { schemaMode } : {}) }, From be919df4a6ac26df64077e0b3508fb6ac7f7a23a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 16:04:57 +0000 Subject: [PATCH 5/8] test(cli): inline the pin's fixture helpers the src program cannot import Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QyoWKK9RwUNxBxmevy9Neb --- ...red-default-datasource.integration.test.ts | 56 +++++++++++++++---- 1 file changed, 46 insertions(+), 10 deletions(-) diff --git a/packages/cli/src/commands/migrate/plan.declared-default-datasource.integration.test.ts b/packages/cli/src/commands/migrate/plan.declared-default-datasource.integration.test.ts index 486c68b3beb..1affee2642d 100644 --- a/packages/cli/src/commands/migrate/plan.declared-default-datasource.integration.test.ts +++ b/packages/cli/src/commands/migrate/plan.declared-default-datasource.integration.test.ts @@ -3,13 +3,13 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { spawnSync } from 'node:child_process'; -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { createServer } from 'node:net'; import { tmpdir } from 'node:os'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import Database from 'better-sqlite3'; -import { defineStackSource, linkSpec } from '../../../test/helpers/define-stack-fixture.js'; -import { childEnv, reservePort } from '../../../test/helpers/serve-process.js'; /** * [#24120] On a project whose primary store is a DECLARED datasource that @@ -64,19 +64,40 @@ const DATABASE = join('data', 'main.db'); const DRIFT_LINE = /\[schema-drift\] ([a-z0-9_]+\.[a-z0-9_]+):/g; let dir: string; +let port: number; +/** + * The config, in the one shape `os build` accepts (`defineStack`). The project + * resolves `@objectstack/spec` through a link to the workspace package + * (`test/helpers/define-stack-fixture.ts` does the same, out of reach of this + * `src` program's `rootDir`). + */ function writeConfig(fields: Record): void { - writeFileSync(join(dir, 'objectstack.config.ts'), defineStackSource({ + const stack = { manifest: { id: 'com.example.fx24120', name: 'fx24120', version: '0.0.0', type: 'app', engines: { protocol: '^18' } }, datasources: [{ name: 'qa_main', driver: 'sqlite', config: { filename: `./${DATABASE}` } }], datasourceMapping: [{ default: true, datasource: 'qa_main' }], objects: [{ name: APP_OBJECT, sharingModel: 'private', fields }], - })); + }; + writeFileSync( + join(dir, 'objectstack.config.ts'), + `import { defineStack } from '@objectstack/spec';\n\nexport default defineStack(${JSON.stringify(stack, null, 2)});\n`, + ); } -/** The child's environment: nothing inherited that names a database, a home or a telemetry sibling. */ +/** + * The child's environment: this process's, minus the families + * `test/helpers/serve-process.ts` `childEnv()` strips (its header says why), + * and nothing that names a database, a home, an artifact or a telemetry sibling. + */ function env(extra: Record = {}): Record { - return childEnv({ + const inherited: Record = {}; + for (const [key, value] of Object.entries(process.env)) { + if (key === 'TEST' || key === 'VITEST' || key.startsWith('VITEST_') || key === 'NODE_PATH') continue; + inherited[key] = value; + } + return { + ...inherited, OS_DATABASE_URL: undefined, DATABASE_URL: undefined, TURSO_DATABASE_URL: undefined, @@ -89,6 +110,18 @@ function env(extra: Record = {}): Record { + return new Promise((done, fail) => { + const server = createServer(); + server.once('error', fail); + server.listen(0, '127.0.0.1', () => { + const address = server.address(); + server.close(() => (address && typeof address === 'object' ? done(address.port) : fail(new Error('no port')))); + }); }); } @@ -100,7 +133,7 @@ function env(extra: Record = {}): Record { +beforeAll(async () => { dir = mkdtempSync(join(tmpdir(), 'os-24120-declared-default-')); - linkSpec(dir); + const specRoot = dirname(createRequire(import.meta.url).resolve('@objectstack/spec/package.json')); + mkdirSync(join(dir, 'node_modules', '@objectstack'), { recursive: true }); + symlinkSync(specRoot, join(dir, 'node_modules', '@objectstack', 'spec'), 'dir'); + port = await freePort(); }); afterAll(() => { From eae4a5bbd477ac65b50a928c8dfb8cdcaa02391c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 16:30:43 +0000 Subject: [PATCH 6/8] fix(cli): os migrate unmapped-columns composes the plan's declared datasources It filters the plan's own findings; on a datasourceMapping-default project its refusal ("not in the set os migrate plan diffs: bound to no driver") became false once the plan composed the declared datasource. SchemaStack names each planned driver's database (databaseOf). Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QyoWKK9RwUNxBxmevy9Neb --- ...24120-migrate-plan-declared-datasources.md | 3 ++- ...red-default-datasource.integration.test.ts | 19 ++++++++++++++----- .../src/commands/migrate/unmapped-columns.ts | 15 ++++++++++----- packages/cli/src/utils/schema-migrate.ts | 15 ++++++++++++++- 4 files changed, 40 insertions(+), 12 deletions(-) diff --git a/.changeset/24120-migrate-plan-declared-datasources.md b/.changeset/24120-migrate-plan-declared-datasources.md index d07ce45500c..926a671e431 100644 --- a/.changeset/24120-migrate-plan-declared-datasources.md +++ b/.changeset/24120-migrate-plan-declared-datasources.md @@ -11,4 +11,5 @@ A project whose primary store is a declared datasource routed as the default (`d - **Now:** `plan` and `apply` connect the project's declared **managed** datasources the way `os serve` connects them: through the same `datasource-connection` service, under the declared name, for the datasources a mapping rule routes objects to, an object binds to, or that set `autoConnect`. Each datasource that objects live in is diffed and applied beside the primary. A composition note names each one and its database, in both the human output and the `--json` output. On a `datasourceMapping`-default project, the plan's drift is the drift `os dev` reports there. - **Not connected:** a datasource whose schema the remote owns (`schemaMode: 'external'` or `'validate-only'`). Its objects are federated, and a plan has no table on it. - **Refused now:** a routed managed datasource that cannot connect fails `plan` / `apply` with the connect error `os serve` gives for it. It is no longer skipped with a PARTIAL plan. `OS_ALLOW_DRIVER_CONNECT_FAILURE` degrades it here as it does there. -- **Unchanged:** a project that declares no datasource gets the plan it got before. `os migrate unmapped-columns`, `security-catalog-overlays` and `organization-ownership` boot as before. The unmanaged-table sweep's `physicalTables` counts each database once, although the primary and a declared default resolved from it are two connections to one file. +- **`os migrate unmapped-columns` follows:** it filters the plan's own findings, so it composes the same datasources. On such a project it reads an object's unmapped columns from the declared datasource. Before, it refused with "not in the set os migrate plan diffs: it is bound to no driver". +- **Unchanged:** a project that declares no datasource gets the plan it got before. `os migrate security-catalog-overlays` and `organization-ownership` boot as before. The unmanaged-table sweep's `physicalTables` counts each database once, although the primary and a declared default resolved from it are two connections to one file. diff --git a/packages/cli/src/commands/migrate/plan.declared-default-datasource.integration.test.ts b/packages/cli/src/commands/migrate/plan.declared-default-datasource.integration.test.ts index 1affee2642d..8886927acc0 100644 --- a/packages/cli/src/commands/migrate/plan.declared-default-datasource.integration.test.ts +++ b/packages/cli/src/commands/migrate/plan.declared-default-datasource.integration.test.ts @@ -42,7 +42,9 @@ import Database from 'better-sqlite3'; * - the plan examined every declared object (`unexaminedObjects: 0`) and * names who chose its database (`config-datasource`, `qa_main`); * - its unmanaged-table sweep counts that database's tables once, although - * the primary and `qa_main` are two connections to the one file. + * the primary and `qa_main` are two connections to the one file; + * - `os migrate unmapped-columns`, a filter over the plan's own findings, + * reads the platform object's unmapped column from the same datasource. * * `os dev` runs as the operator runs it, compiling the config, with * `OS_MIGRATE_AND_EXIT=1` (its `serve --dev` child boots, syncs and exits) and @@ -141,11 +143,12 @@ function devBootOnce(): string { return output; } -function planJson(): any { - const r = spawnSync(TSX, [RUN_DEV, 'migrate', 'plan', '--json'], { +/** One read-only `os migrate` door's `--json` document. */ +function migrateJson(args: string[]): any { + const r = spawnSync(TSX, [RUN_DEV, 'migrate', ...args, '--json'], { cwd: dir, env: env(), encoding: 'utf8', timeout: SPAWN_TIMEOUT_MS, }); - expect(r.status, `os migrate plan failed:\n${`${r.stdout}\n${r.stderr}`.slice(-1500)}`).toBe(0); + expect(r.status, `os migrate ${args.join(' ')} failed:\n${`${r.stdout}\n${r.stderr}`.slice(-1500)}`).toBe(0); return JSON.parse(r.stdout); } @@ -183,7 +186,7 @@ describe('os migrate plan on a datasourceMapping-default declared datasource (#2 try { db.exec('ALTER TABLE sys_user ADD COLUMN fx24120_legacy TEXT'); } finally { db.close(); } const reported = new Set([...devBootOnce().matchAll(DRIFT_LINE)].map((m) => m[1])); - const plan = planJson(); + const plan = migrateJson(['plan']); const planned = new Set((plan.changes as Array<{ table: string; column?: string }>).map((c) => `${c.table}.${c.column}`)); expect([...reported].sort()).toEqual(expect.arrayContaining([`${APP_OBJECT}.extra`, 'sys_user.fx24120_legacy'])); @@ -193,5 +196,11 @@ describe('os migrate plan on a datasourceMapping-default declared datasource (#2 expect(plan.managedTables).toBeGreaterThan(0); expect(plan.unmanagedTables?.status, JSON.stringify(plan.unmanagedTables)).toBe('read'); expect(plan.unmanagedTables.physicalTables).toBe(tableCount(join(dir, DATABASE))); + + // The door an unmapped column's finding leads to filters the plan's own + // findings over the same set: the platform object's column is read there, + // not refused as "not in the set os migrate plan diffs". + const unmapped = migrateJson(['unmapped-columns', '--object', 'sys_user']); + expect((unmapped.columns as Array<{ column: string }>).map((c) => c.column)).toEqual(['fx24120_legacy']); }, CASE_TIMEOUT_MS); }); diff --git a/packages/cli/src/commands/migrate/unmapped-columns.ts b/packages/cli/src/commands/migrate/unmapped-columns.ts index 74955ab08cc..9ac994c2bc6 100644 --- a/packages/cli/src/commands/migrate/unmapped-columns.ts +++ b/packages/cli/src/commands/migrate/unmapped-columns.ts @@ -297,6 +297,10 @@ export default class MigrateUnmappedColumns extends Command { readOnlyProbe: true, composeHostStack: true, composeServedPlatform: true, + // [#24120] …and the declared datasources the plan connects, so an + // object a `datasourceMapping` rule routes to one — the declared + // default's platform objects included — is in this set as in the plan's. + composeDeclaredDatasources: true, }); } catch (error: any) { if (flags.json) { @@ -340,9 +344,10 @@ export default class MigrateUnmappedColumns extends Command { 'objectql', ) as { getDriverForObject?: (name: string) => unknown } | undefined; const bound = engine?.getDriverForObject?.(object); - // [#22579] Any driver the plan diffs: the primary, or the `telemetry` + // [#22579] Any driver the plan diffs: the primary, the `telemetry` // sibling a lifecycle-classed object lives in when the serving boot - // keeps one. The read goes to that driver, and the document names it. + // keeps one, or [#24120] the declared datasource a mapping rule routes + // it to. The read goes to that driver, and the document names it. const objectDriver = stack.drivers.find((d) => d === bound) ?? null; const outside = declared.external != null @@ -358,9 +363,9 @@ export default class MigrateUnmappedColumns extends Command { 'for it, and that is unmeasured, not clean. Refusing rather than answering empty work.', ); } - // The sibling is the one other planned driver, and it is planned only - // with its file: a driver that is not the primary has a database named. - const database = objectDriver === stack.driver ? stack.dbLabel : stack.telemetryDatabase!; + // Every planned driver has a database named (`SchemaStack.databaseOf`), + // and `objectDriver` is one of them by the lookup above. + const database = stack.databaseOf(objectDriver)!; const reader = objectDriver as unknown as Partial; if (typeof reader.find !== 'function') { diff --git a/packages/cli/src/utils/schema-migrate.ts b/packages/cli/src/utils/schema-migrate.ts index f05c2789e59..12b249f0d10 100644 --- a/packages/cli/src/utils/schema-migrate.ts +++ b/packages/cli/src/utils/schema-migrate.ts @@ -144,6 +144,13 @@ export interface SchemaStack { * driver is not here is outside the plan. */ drivers: readonly SqlDriverLike[]; + /** + * [#24120] The database a planned driver ({@link drivers}) addresses, as an + * operator reads it: {@link dbLabel} for the primary, {@link telemetryDatabase} + * for the sibling, the connection a declared datasource's driver describes — + * or `null` for a driver this stack does not plan. + */ + databaseOf: (driver: SqlDriverLike) => string | null; /** * [#22579] Managed drift over every datasource this boot plans against: the * primary, then the `telemetry` sibling when there is one. Each entry stays @@ -1136,7 +1143,8 @@ export async function bootSchemaStack( * — and plan each one the deployment's objects live in beside the primary * ({@link SchemaStack.drivers}). Set by `os migrate plan` and `os migrate * apply`, so the plan an operator reads and the apply that confirms it - * cover the same datasources; by nothing else. + * cover the same datasources, and by `os migrate unmapped-columns`, a + * filter over that plan's own findings; by nothing else. * * Without it a `datasourceMapping` rule routing objects to a declared * datasource left them bound to no driver here, the declared DEFAULT's @@ -1396,6 +1404,11 @@ export async function bootSchemaStack( schemaSync: defer ? 'deferred' : skipSchemaSync ? 'skipped' : 'performed', telemetryDatabase: telemetryDriver ? telemetryDatabase : null, drivers: plannedDrivers, + databaseOf: (d: SqlDriverLike): string | null => { + if (d === driver) return describeDb(driver); + if (d === telemetryDriver) return telemetryDatabase; + return declaredDrivers.some((x) => x.driver === d) ? describeDb(d) : null; + }, detectManagedDrift: async (): Promise => { const drift: ManagedDriftEntry[] = []; for (const d of plannedDrivers) { From 61188fd4ed9b0d2617a773117c49486421adb5f9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 16:33:05 +0000 Subject: [PATCH 7/8] chore(changeset): unmapped-columns also refuses on a routed datasource that cannot connect Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QyoWKK9RwUNxBxmevy9Neb --- .changeset/24120-migrate-plan-declared-datasources.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/24120-migrate-plan-declared-datasources.md b/.changeset/24120-migrate-plan-declared-datasources.md index 926a671e431..b6dc5b57e69 100644 --- a/.changeset/24120-migrate-plan-declared-datasources.md +++ b/.changeset/24120-migrate-plan-declared-datasources.md @@ -10,6 +10,6 @@ A project whose primary store is a declared datasource routed as the default (`d - **Now:** `plan` and `apply` connect the project's declared **managed** datasources the way `os serve` connects them: through the same `datasource-connection` service, under the declared name, for the datasources a mapping rule routes objects to, an object binds to, or that set `autoConnect`. Each datasource that objects live in is diffed and applied beside the primary. A composition note names each one and its database, in both the human output and the `--json` output. On a `datasourceMapping`-default project, the plan's drift is the drift `os dev` reports there. - **Not connected:** a datasource whose schema the remote owns (`schemaMode: 'external'` or `'validate-only'`). Its objects are federated, and a plan has no table on it. -- **Refused now:** a routed managed datasource that cannot connect fails `plan` / `apply` with the connect error `os serve` gives for it. It is no longer skipped with a PARTIAL plan. `OS_ALLOW_DRIVER_CONNECT_FAILURE` degrades it here as it does there. +- **Refused now:** a routed managed datasource that cannot connect fails `plan`, `apply` and `unmapped-columns` with the connect error `os serve` gives for it. It is no longer skipped with a PARTIAL plan. `OS_ALLOW_DRIVER_CONNECT_FAILURE` degrades it here as it does there. - **`os migrate unmapped-columns` follows:** it filters the plan's own findings, so it composes the same datasources. On such a project it reads an object's unmapped columns from the declared datasource. Before, it refused with "not in the set os migrate plan diffs: it is bound to no driver". - **Unchanged:** a project that declares no datasource gets the plan it got before. `os migrate security-catalog-overlays` and `organization-ownership` boot as before. The unmanaged-table sweep's `physicalTables` counts each database once, although the primary and a declared default resolved from it are two connections to one file. From 0b5ed65b4e090d9f073ca805d4e83f10a2fe3c7e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 16:40:58 +0000 Subject: [PATCH 8/8] test(cli): re-measure the pin's budget with its unmapped-columns leg Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QyoWKK9RwUNxBxmevy9Neb --- .../plan.declared-default-datasource.integration.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/src/commands/migrate/plan.declared-default-datasource.integration.test.ts b/packages/cli/src/commands/migrate/plan.declared-default-datasource.integration.test.ts index 8886927acc0..6dabed3f68d 100644 --- a/packages/cli/src/commands/migrate/plan.declared-default-datasource.integration.test.ts +++ b/packages/cli/src/commands/migrate/plan.declared-default-datasource.integration.test.ts @@ -1,5 +1,5 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. -// test-budget: 70 — spawns the real producer once per single-cause fixture: three spawns of the source CLI over one staged fixture (os dev to provision the declared datasource, os dev over the staged drift, os migrate plan --json); 51.6 s, the slower of two local runs on a shared container, x 1.25, no CI reading yet +// test-budget: 80 — spawns the real producer once per single-cause fixture: four spawns of the source CLI over one staged fixture (os dev to provision the declared datasource, os dev over the staged drift, os migrate plan --json, os migrate unmapped-columns --json); 56.1 s, the slower of two local runs on a shared container, x 1.25, no CI reading yet import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { spawnSync } from 'node:child_process';