diff --git a/.changeset/warm-pumas-repeat.md b/.changeset/warm-pumas-repeat.md new file mode 100644 index 0000000000..f6a5bcbedf --- /dev/null +++ b/.changeset/warm-pumas-repeat.md @@ -0,0 +1,20 @@ +--- +"@objectstack/cli": patch +--- + +`os migrate plan` / `os migrate apply` now diff the object set the deployment actually serves + +Both commands booted `createStandaloneStack` and nothing else, so on any real deployment they examined a five-table subset — `sys_metadata`, `sys_metadata_audit`, `sys_metadata_commit`, `sys_metadata_history`, `sys_view_definition` — and reported `0` drift over it. The host `objectstack.config.ts` was never loaded (the standalone stack says so itself) and no platform plugin was composed either: only the DATA subcommands reached `PlatformObjectsPlugin`, through `buildDataMigrationPlugins`. + +That failed in the direction that reads as success. With nothing registered there is no drift, so `plan` printed *"Physical schema is in sync with metadata — nothing to migrate."* — while the driver's own boot-time detector, running with the full registered object set, reported findings on the same database whose message ends `run "os migrate apply"`. Measured against a control plane carrying roughly eighty `sys_*` tables: ten boot-time findings, five tables examined, "in sync". + +`plan` and `apply` now compose what `os serve` composes: the host config's plugins (plus `AppPlugin(config)` when the config carries top-level metadata and brings no app plugin of its own), and `PlatformObjectsPlugin` — the one plugin `serve` injects unconditionally. Both commands compose identically, so the plan an operator reads and the set `apply` reconciles are the same. + +Nothing about what counts as drift changed. A plan that now reports findings it used to hide is the fix working. + +Two behaviours worth knowing: + +- **Host plugins are composed for their DECLARATIONS only** — `init()` runs, `start()` does not. `os migrate plan` is a declared dry run, and host plugins are arbitrary code: composed fully, `SecurityPlugin` alone attempted fourteen inserts into `sys_permission_set` during a plan, from its `start()` bootstrap. The kernel contract puts object declarations in `init()`, which is all a schema command needs. The residue: a plugin that registers its objects in `start()` instead of `init()` stays outside the plan. +- **A project with neither an `objectstack.config.*` nor a compiled artifact is unchanged** — five tables, same output, same `--json` document. There is no deployment there to mirror. + +A host config that exists but fails to load (a missing environment variable is the common case) is reported loudly on stderr and does not fail the command; `os migrate plan --json` then carries `composition.hostConfigLoaded: false`, because the table count alone cannot tell that apart from a deployment that is genuinely small. diff --git a/packages/cli/src/commands/migrate/apply.ts b/packages/cli/src/commands/migrate/apply.ts index 220e98e78a..50f0e419da 100644 --- a/packages/cli/src/commands/migrate/apply.ts +++ b/packages/cli/src/commands/migrate/apply.ts @@ -126,7 +126,16 @@ export default class MigrateApply extends Command { try { // `deferSchemaDdl` is what makes the prompt below meaningful: without it // the boot has already created tables and added columns by this point. - stack = await bootSchemaStack({ jsonOutput: flags.json, databaseUrl: flags['database-url'], deferSchemaDdl: true }); + // `composeHostStack` (#12938): reconcile the object set this deployment + // actually serves. It must be the SAME set `os migrate plan` diffed — + // the plan the operator just read is the thing being confirmed — so the + // two commands pass it identically. + stack = await bootSchemaStack({ + jsonOutput: flags.json, + databaseUrl: flags['database-url'], + deferSchemaDdl: true, + composeHostStack: true, + }); } catch (error: any) { if (flags.json) { await emitJson({ error: error.message }, 0, { compact: true }); this.exit(1); } printError(error.message || String(error)); @@ -141,6 +150,16 @@ export default class MigrateApply extends Command { return; } + // What the object set was composed from (#12938) — printed BEFORE the + // in-sync early return below, not with the plan. "Already in sync" over a + // set that is a fraction of the target's tables is precisely the reading + // this card exists to stop, so the account of what was composed has to + // reach the operator on that path too. + if (!flags.json) { + for (const note of stack.composition.notes) console.log(chalk.dim(` ${note}`)); + if (stack.composition.notes.length > 0) console.log(''); + } + const drift = await stack.driver.detectManagedDrift(); const grouped = groupByCategory(drift); // Additive work the boot sync was held back from doing. Not drift — it diff --git a/packages/cli/src/commands/migrate/plan.ts b/packages/cli/src/commands/migrate/plan.ts index 7c916cc10a..26ab06e01e 100644 --- a/packages/cli/src/commands/migrate/plan.ts +++ b/packages/cli/src/commands/migrate/plan.ts @@ -99,6 +99,12 @@ export default class MigratePlan extends Command { // this — it flushes the deferred DDL after confirmation and needs a // real file to flush into. readOnlyProbe: true, + // #12938 — diff the object set this deployment actually serves. Without + // it the plan covers the five-table data stack alone: on a control plane + // carrying ~80 `sys_*` tables that printed "in sync" while the driver's + // own boot detector reported ten findings on the same database, and the + // command those findings name is this one. + composeHostStack: true, }); } catch (error: any) { if (flags.json) { await emitJson({ error: error.message }, 0, { compact: true }); this.exit(1); } @@ -150,6 +156,22 @@ export default class MigratePlan extends Command { }, } : {}), + // [#12938] What the diffed object set was composed from — present + // only when there WAS a deployment to compose, so a project with + // neither a config nor a compiled artifact emits the same document it + // always did. A consumer asserting coverage needs `hostConfigLoaded` + // and not just `managedTables`: a config that fails to load also + // raises the count (the platform floor still lands), and a count alone + // cannot tell that apart from a deployment that is genuinely small. + ...(stack.composition.notes.length > 0 + ? { + composition: { + hostConfig: stack.composition.hostConfigPath, + hostConfigLoaded: stack.composition.hostConfigLoaded, + notes: stack.composition.notes, + }, + } + : {}), ...(occupancy.status === 'busy' ? { occupancy: { status: 'busy', signal: occupancy.signal, detail: occupancy.detail } } : {}), @@ -172,6 +194,10 @@ export default class MigratePlan extends Command { printInfo(`Database: ${chalk.white(stack.dbLabel)}`); printInfo(`Examined ${chalk.white(String(stack.managedTableCount))} managed table(s).`); + // What the object set was composed from (#12938) — never silent about a + // host config it could not load, and empty (so this block prints nothing) + // when there was no deployment to compose. + for (const note of stack.composition.notes) console.log(chalk.dim(` ${note}`)); console.log(''); if (drift.length === 0 && pending.length === 0) { diff --git a/packages/cli/src/utils/schema-migrate.host-composition.integration.test.ts b/packages/cli/src/utils/schema-migrate.host-composition.integration.test.ts new file mode 100644 index 0000000000..3daa8c389f --- /dev/null +++ b/packages/cli/src/utils/schema-migrate.host-composition.integration.test.ts @@ -0,0 +1,342 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { mkdtempSync, mkdirSync, writeFileSync, symlinkSync, rmSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { bootSchemaStack } from './schema-migrate.js'; + +/** + * #12938 — the measurement, inverted into a regression pin. + * + * Before this card, `os migrate plan` / `apply` booted `createStandaloneStack` + * and nothing else: five managed tables (`sys_metadata`, its three history / + * audit / commit siblings, `sys_view_definition`), `0` drift, and the sentence + * "Physical schema is in sync with metadata — nothing to migrate." On a control + * plane carrying ~80 `sys_*` tables that reads as a pass while the driver's own + * boot detector reports ten findings on the same database — and the command + * those findings NAME ("run `os migrate apply`") is this one. + * + * The fixture is cloud#1695's shape, built by the platform rather than by hand: + * a host `objectstack.config.ts` composing `SecurityPlugin`, the tables created + * through the migrate path itself, and then `sys_position`'s per-organization + * composite unique swapped back to the pre-#8323 global spelling + * (`uniq_sys_position_name`) — the exact index a deployed control DB was still + * carrying. + * + * Four things are pinned, and the fourth is an ABSENCE. Composing a host + * config means composing arbitrary code, and `SecurityPlugin` seeds its + * built-in permission sets from `start()`: measured, that produced 14 insert + * attempts against `sys_permission_set` during a run documented as writing + * nothing. `sys_permission_set` staying EMPTY across a plan is what proves the + * declaration-phase composition actually holds. + */ + +const require_ = createRequire(import.meta.url); + +/** + * The installed `@objectstack/plugin-security` package root. + * + * Resolved through this package's own dependency graph rather than written as a + * path that climbs into a sibling package: the entry point is a `node_modules` + * read, which is what a dependency IS, and it keeps this test's inputs equal to + * its declared ones. + */ +function securityPackageRoot(): string { + // `/dist/index.js` → ``. + return resolve(dirname(require_.resolve('@objectstack/plugin-security')), '..'); +} + +/** The five tables the data stack alone registers — the pre-fix baseline. */ +const ARTIFACTLESS_BASELINE_TABLES = [ + 'sys_metadata', + 'sys_metadata_audit', + 'sys_metadata_commit', + 'sys_metadata_history', + 'sys_view_definition', +]; + +describe('os migrate plan/apply compose the deployment\'s own object set (#12938)', () => { + let dir: string; + let dbFile: string; + const savedEnv: Record = {}; + + beforeAll(async () => { + dir = mkdtempSync(join(tmpdir(), 'os-12938-')); + dbFile = join(dir, 'control.db'); + + // A host config whose whole object set comes from a plugin — the shape + // ObjectStack Cloud's control plane has (`createCloudStack()` returns the + // plugins; the app has no compiled artifact at all). + mkdirSync(join(dir, 'node_modules', '@objectstack'), { recursive: true }); + symlinkSync( + securityPackageRoot(), + join(dir, 'node_modules', '@objectstack', 'plugin-security'), + 'dir', + ); + writeFileSync( + join(dir, 'objectstack.config.ts'), + [ + "import { SecurityPlugin } from '@objectstack/plugin-security';", + '', + 'export default { plugins: [new SecurityPlugin()] };', + '', + ].join('\n'), + ); + + savedEnv.NODE_ENV = process.env.NODE_ENV; + savedEnv.OS_ARTIFACT_PATH = process.env.OS_ARTIFACT_PATH; + // No auto-reconcile at boot, so whatever `plan` reports is exactly what an + // operator would see before any DDL touches their database. + process.env.NODE_ENV = 'production'; + // The fixture is deliberately artifact-LESS: the config is the only host. + process.env.OS_ARTIFACT_PATH = join(dir, 'dist', 'objectstack.json'); + + // Materialize the deployment's tables the way `os migrate apply` does — + // boot deferred, then flush — so the drift cases below start from a + // database that exists and each one can arrange its own state. + const boot = await bootSchemaStack({ + jsonOutput: false, + databaseUrl: `file:${dbFile}`, + deferSchemaDdl: true, + composeHostStack: true, + projectRoot: dir, + }); + try { + await boot.flushSchemaDdl(); + } finally { + await boot.shutdown(); + } + }, 60_000); + + afterAll(() => { + if (savedEnv.NODE_ENV === undefined) delete process.env.NODE_ENV; + else process.env.NODE_ENV = savedEnv.NODE_ENV; + if (savedEnv.OS_ARTIFACT_PATH === undefined) delete process.env.OS_ARTIFACT_PATH; + else process.env.OS_ARTIFACT_PATH = savedEnv.OS_ARTIFACT_PATH; + try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ } + }); + + /** Boot the way `os migrate plan` / `apply` do. */ + const bootLikeMigrate = () => bootSchemaStack({ + jsonOutput: false, + databaseUrl: `file:${dbFile}`, + deferSchemaDdl: true, + composeHostStack: true, + projectRoot: dir, + }); + + const indexNames = async (driver: any, table: string): Promise => { + const rows: any = await driver.knex.raw( + "select name from sqlite_master where type = 'index' and tbl_name = ?", + [table], + ); + return (Array.isArray(rows) ? rows : (rows?.rows ?? [])).map((r: any) => r.name).sort(); + }; + + const countRows = async (driver: any, table: string): Promise => { + const rows: any = await driver.knex(table).count({ c: '*' }); + return Number((Array.isArray(rows) ? rows[0] : rows)?.c ?? -1); + }; + + /** + * Put `sys_position` back into the pre-respelling physical shape — the + * per-organization composite retired, the global single-column unique + * present. This is cloud#1695's index, verbatim. + * + * Idempotent, and called by every test that needs it, so the cases below do + * not depend on each other's order. An integration file whose third test only + * passes because the second ran is a suite that reports the wrong thing the + * first time one of them is skipped. + */ + const degradeToLegacyUnique = async (): Promise => { + const stack = await bootLikeMigrate(); + try { + const k = (stack.driver as any).knex; + const present = await indexNames(stack.driver, 'sys_position'); + if (present.includes('uniq_sys_position_organization_id_name')) { + await k.raw('DROP INDEX uniq_sys_position_organization_id_name'); + } + if (!present.includes('uniq_sys_position_name')) { + await k.raw('CREATE UNIQUE INDEX uniq_sys_position_name ON sys_position (name)'); + } + if (await countRows(stack.driver, 'sys_position') === 0) { + await k('sys_position').insert({ + id: 'pos_1', + name: 'org_admin', + label: 'Org Admin', + organization_id: 'org_jia', + }); + } + } finally { + await stack.shutdown(); + } + }; + + it('registers the host config\'s objects — well above the five-table baseline', async () => { + // A database of its own: this case is about what a FRESH target reports as + // pending, which is only observable before anything created the tables. + const stack = await bootSchemaStack({ + jsonOutput: false, + databaseUrl: `file:${join(dir, 'fresh.db')}`, + deferSchemaDdl: true, + composeHostStack: true, + projectRoot: dir, + }); + try { + expect(stack.driver).toBeTruthy(); + expect(stack.composition.hostConfigLoaded).toBe(true); + expect(stack.composition.hostConfigPath).toBe(join(dir, 'objectstack.config.ts')); + + // The number the consumer-side coverage gate reads. Strictly greater than + // the baseline, and by more than one: the security plugin alone brings six + // RBAC tables and the platform floor another four. + expect(stack.managedTableCount).toBeGreaterThan(ARTIFACTLESS_BASELINE_TABLES.length); + + const pending = stack.pendingSchemaWork.map((p) => p.table); + // The two tables the issue names by hand, neither of which was reachable + // from a migrate boot at all. + expect(pending).toContain('sys_position'); + expect(pending).toContain('sys_permission_set'); + // …and the platform floor `serve` composes unconditionally. + expect(pending).toContain('sys_migration'); + for (const table of ARTIFACTLESS_BASELINE_TABLES) expect(pending).toContain(table); + + // And they really are creatable — the work `os migrate apply` flushes + // after confirmation. + const created = await stack.flushSchemaDdl(); + expect(created.length).toBe(stack.pendingSchemaWork.length); + } finally { + await stack.shutdown(); + } + }, 60_000); + + it('SEES the legacy platform-wide unique its own boot message prescribes this command for', async () => { + await degradeToLegacyUnique(); + + const stack = await bootLikeMigrate(); + try { + const drift = await stack.driver!.detectManagedDrift(); + const entry = drift.find( + (d) => d.table === 'sys_position' && d.op.type === 'replace_unique_index', + ); + expect(entry, 'the legacy platform-wide unique must be planned, not invisible').toBeDefined(); + expect(entry!.category).toBe('safe'); + expect(entry!.op).toMatchObject({ + table: 'sys_position', + dropIndexNames: ['uniq_sys_position_name'], + createIndexName: 'uniq_sys_position_organization_id_name', + createColumns: ['organization_id', 'name'], + }); + // The prescription that made this a contract rather than an incomplete report. + expect(entry!.message).toContain('os migrate apply'); + } finally { + await stack.shutdown(); + } + }, 60_000); + + it('writes NOTHING while planning — no host seeder runs', async () => { + await degradeToLegacyUnique(); + + const stack = await bootLikeMigrate(); + try { + await stack.driver!.detectManagedDrift(); + + // The absence that matters. `SecurityPlugin.start()` bootstraps + // `admin_full_access`, `organization_admin`, `member_default` and the rest; + // fully started, it attempted 14 inserts into this table during a plan. + expect(await countRows(stack.driver, 'sys_permission_set')).toBe(0); + expect(await countRows(stack.driver, 'sys_position')).toBe(1); + // The legacy index is still there — a plan proposes, it does not apply. + expect(await indexNames(stack.driver, 'sys_position')).toContain('uniq_sys_position_name'); + } finally { + await stack.shutdown(); + } + }, 60_000); + + it('applies the replacement without --allow-destructive, keeps the row, and converges', async () => { + await degradeToLegacyUnique(); + { + const stack = await bootLikeMigrate(); + try { + const drift = await stack.driver!.detectManagedDrift(); + const { applied, skipped } = await stack.driver!.applyMigrationEntries( + drift, + { allowDestructive: false }, + ); + expect(applied.some((d) => d.op.type === 'replace_unique_index')).toBe(true); + expect(skipped).toHaveLength(0); + + const after = await indexNames(stack.driver, 'sys_position'); + expect(after).toContain('uniq_sys_position_organization_id_name'); + expect(after).not.toContain('uniq_sys_position_name'); + expect(await countRows(stack.driver, 'sys_position')).toBe(1); + } finally { + await stack.shutdown(); + } + } + + // A re-plan over the SAME composed set is clean — the plan converges rather + // than proposing the same relaxation forever. + const replan = await bootLikeMigrate(); + try { + expect(await replan.driver!.detectManagedDrift()).toHaveLength(0); + expect(replan.pendingSchemaWork).toHaveLength(0); + expect(replan.managedTableCount).toBeGreaterThan(ARTIFACTLESS_BASELINE_TABLES.length); + } finally { + await replan.shutdown(); + } + }, 60_000); +}); + +describe('an artifact-less, config-less project is unchanged (#12938 baseline pin)', () => { + let dir: string; + const savedEnv: Record = {}; + + beforeAll(() => { + dir = mkdtempSync(join(tmpdir(), 'os-12938-bare-')); + savedEnv.NODE_ENV = process.env.NODE_ENV; + savedEnv.OS_ARTIFACT_PATH = process.env.OS_ARTIFACT_PATH; + process.env.NODE_ENV = 'production'; + process.env.OS_ARTIFACT_PATH = join(dir, 'dist', 'objectstack.json'); + }); + + afterAll(() => { + if (savedEnv.NODE_ENV === undefined) delete process.env.NODE_ENV; + else process.env.NODE_ENV = savedEnv.NODE_ENV; + if (savedEnv.OS_ARTIFACT_PATH === undefined) delete process.env.OS_ARTIFACT_PATH; + else process.env.OS_ARTIFACT_PATH = savedEnv.OS_ARTIFACT_PATH; + try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ } + }); + + it('still examines exactly the five data-stack tables and composes nothing', async () => { + // The five-table baseline is LEGITIMATE here — there is no deployment to + // mirror — and the fix must not move it. It is also the number the + // consumer-side coverage gate is calibrated against, so it is pinned by + // value and by membership rather than by "greater than". + const stack = await bootSchemaStack({ + jsonOutput: false, + databaseUrl: `file:${join(dir, 'bare.db')}`, + deferSchemaDdl: true, + composeHostStack: true, + projectRoot: dir, + }); + try { + expect(stack.managedTableCount).toBe(ARTIFACTLESS_BASELINE_TABLES.length); + expect(stack.pendingSchemaWork.map((p) => p.table).sort()) + .toEqual([...ARTIFACTLESS_BASELINE_TABLES].sort()); + expect(await stack.driver!.detectManagedDrift()).toHaveLength(0); + + // Empty notes are what make `os migrate plan --json` emit the SAME + // document it always did: the `composition` key is spread in only when + // there is something to report. + expect(stack.composition.notes).toEqual([]); + expect(stack.composition.plugins).toEqual([]); + expect(stack.composition.hostConfigPath).toBeNull(); + expect(stack.composition.hostConfigLoaded).toBe(false); + } finally { + await stack.shutdown(); + } + }, 60_000); +}); diff --git a/packages/cli/src/utils/schema-migrate.ts b/packages/cli/src/utils/schema-migrate.ts index 18a38d7c79..878ac9202b 100644 --- a/packages/cli/src/utils/schema-migrate.ts +++ b/packages/cli/src/utils/schema-migrate.ts @@ -8,15 +8,24 @@ * sync, and hands back the live SQL driver so the command can call * `detectManagedDrift()` / `applyMigrationEntries()`. * - * Migration only sees the objects present in the loaded metadata (compiled - * artifact). Run `os build` first so your objects are visible; tables/columns - * not in the loaded metadata are never examined or altered. + * Migration only ever sees the objects this boot REGISTERED; tables/columns + * outside that set are never examined or altered. The two SCHEMA commands + * (`plan`/`apply`) therefore pass `composeHostStack` so the set is the one the + * deployment's own `os serve` boot registers — its `objectstack.config.ts` plus + * the platform floor `serve` composes unconditionally (#12938). The DATA + * subcommands keep their own narrower set (`./data-migration-plugins.ts`). + * A project with neither a config nor a compiled artifact still diffs the data + * stack alone — run `os build` first so its objects are visible. */ import chalk from 'chalk'; import type { ManagedDriftEntry, DriftCategory, PendingSchemaWork } from '@objectstack/driver-sql'; import type { IObjectQLEngine } from '@objectstack/spec/contracts'; import { describeDriverConnection } from './connection-display.js'; import { reserveStdoutForJson } from './json-stdout.js'; +import { + buildSchemaMigrationPlugins, + type SchemaMigrationComposition, +} from './schema-migration-plugins.js'; export type { PendingSchemaWork }; @@ -58,6 +67,15 @@ export interface SchemaStack { * plan. Returns the work it actually ran (`[]` when nothing was deferred). */ flushSchemaDdl: () => Promise; + /** + * What the host composition added, and anything it could not (#12938) — the + * host config it composed, whether that config actually loaded, the platform + * floor it added. `notes` is always `[]` unless the boot asked for + * `composeHostStack`, and `[]` even then when there was nothing to compose, + * so a project with neither a config nor a compiled artifact renders + * byte-identically to before this existed. + */ + composition: SchemaMigrationComposition; shutdown: () => Promise; } @@ -187,6 +205,24 @@ export async function bootSchemaStack( * adapter are present. Plain schema commands pass nothing. */ extraPlugins?: unknown[]; + /** + * Compose the SAME object set this deployment's `os serve` boot registers — + * its `objectstack.config.ts` and the platform floor `serve` composes + * unconditionally (#12938). Set by the two SCHEMA commands, `os migrate + * plan` and `os migrate apply`, and by nothing else. + * + * Off by default and opted into at the call site rather than deduced here: + * the DATA subcommands declare their own, narrower set through + * `buildDataMigrationPlugins`, and a capability that appears because of a + * default nobody wrote down is invisible at every call site (AGENTS.md → + * Route & surface ownership §2). + * + * What it composes, why exactly that, and the Phase-2 suppression that keeps + * a `plan` from writing are all in `./schema-migration-plugins.ts`'s header. + * With neither a host config nor a compiled artifact present it composes + * NOTHING, so an artifact-less run is unchanged. + */ + composeHostStack?: boolean; /** * Boot WITHOUT touching the target database (#3917). * @@ -291,6 +327,20 @@ export async function bootSchemaStack( if (defer) { await kernel.use(new DeferSchemaDdlPlugin() as any); } + // #12938 — the deployment's own object set, when this command asked for it. + // Registered here, after the data stack, for the same reason `extraPlugins` + // is: the presence tests it performs read what `createStandaloneStack` + // produced, and the DDL deferral above must already be armed. + const composition = opts.composeHostStack === true + ? await buildSchemaMigrationPlugins({ + basePlugins: stack.plugins, + cwd: opts.projectRoot ?? process.cwd(), + skipSeedData: defer, + }) + : { plugins: [], hostConfigPath: null, hostConfigLoaded: false, notes: [] } satisfies SchemaMigrationComposition; + for (const plugin of composition.plugins) { + await kernel.use(plugin as any); + } for (const plugin of opts.extraPlugins ?? []) { await kernel.use(plugin as any); } @@ -334,6 +384,7 @@ export async function bootSchemaStack( flushSchemaDdl: async () => (defer && driver?.flushDeferredSchemaDdl ? await driver.flushDeferredSchemaDdl() : []), + composition, /** * Tear the one-shot stack down through the kernel's own teardown — the * same `kernel.shutdown()` `os serve` runs on SIGTERM, so a one-shot diff --git a/packages/cli/src/utils/schema-migration-plugins.test.ts b/packages/cli/src/utils/schema-migration-plugins.test.ts new file mode 100644 index 0000000000..c02e2824cc --- /dev/null +++ b/packages/cli/src/utils/schema-migration-plugins.test.ts @@ -0,0 +1,197 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect, afterEach } from 'vitest'; +import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { + findHostConfig, + composeForDeclarations, + buildSchemaMigrationPlugins, +} from './schema-migration-plugins.js'; + +/** + * Unit half of #12938 — the composition's own decisions, without a database. + * + * The integration half (`schema-migrate.host-composition.integration.test.ts`) + * proves the composed set reaches the driver and that a `plan` over it writes + * nothing. This file pins the three decisions that file cannot isolate: which + * config spellings count, what a declaration-phase composition preserves and + * suppresses, and what is composed for each of the three shapes a project can + * be in (no host at all / a host that loads / a host that does not). + */ + +const dirs: string[] = []; +function tempProject(): string { + const dir = mkdtempSync(join(tmpdir(), 'os-12938-unit-')); + dirs.push(dir); + return dir; +} + +afterEach(() => { + while (dirs.length > 0) { + const dir = dirs.pop()!; + try { rmSync(dir, { recursive: true, force: true }); } catch { /* best-effort */ } + } +}); + +describe('findHostConfig', () => { + it('returns null when the directory holds no config — the shape that must stay unchanged', () => { + expect(findHostConfig(tempProject())).toBeNull(); + }); + + it.each(['objectstack.config.ts', 'objectstack.config.js', 'objectstack.config.mjs'])( + 'finds %s', + (name) => { + const dir = tempProject(); + writeFileSync(join(dir, name), 'export default {};\n'); + expect(findHostConfig(dir)).toBe(join(dir, name)); + }, + ); + + it('prefers .ts over .js, the same order resolveConfigPath auto-detects in', () => { + const dir = tempProject(); + writeFileSync(join(dir, 'objectstack.config.js'), 'export default {};\n'); + writeFileSync(join(dir, 'objectstack.config.ts'), 'export default {};\n'); + expect(findHostConfig(dir)).toBe(join(dir, 'objectstack.config.ts')); + }); +}); + +describe('composeForDeclarations', () => { + class FakePlugin { + name = 'com.example.fake'; + version = '2.1.0'; + type = 'standard'; + dependencies = ['com.objectstack.engine.objectql']; + optionalDependencies = ['com.example.optional']; + requiresServices = ['manifest']; + providesServices = ['example.thing']; + calls: string[] = []; + async init(): Promise { this.calls.push('init'); } + async start(): Promise { this.calls.push('start'); } + async destroy(): Promise { this.calls.push('destroy'); } + } + + it('runs init, does NOT run start, and still forwards destroy', async () => { + const inner = new FakePlugin(); + const wrapped = composeForDeclarations(inner); + + await wrapped.init(); + await wrapped.start(); + await wrapped.destroy(); + + // `start` is the phase every measured seeder writes from; `init` is where + // the kernel contract puts the object declarations this command needs. + expect(inner.calls).toEqual(['init', 'destroy']); + }); + + it('preserves every member the kernel orders and de-dups on', () => { + const inner = new FakePlugin(); + const wrapped = composeForDeclarations(inner); + + expect(wrapped.name).toBe('com.example.fake'); + expect(wrapped.version).toBe('2.1.0'); + expect(wrapped.type).toBe('standard'); + expect(wrapped.dependencies).toEqual(['com.objectstack.engine.objectql']); + expect(wrapped.optionalDependencies).toEqual(['com.example.optional']); + expect(wrapped.requiresServices).toEqual(['manifest']); + expect(wrapped.providesServices).toEqual(['example.thing']); + }); + + it('leaves constructor.name readable — `serve`\'s presence tests read it', () => { + // A bound function reports `'bound FakePlugin'`, which would defeat every + // `p?.constructor?.name === 'X'` check in the CLI, this module's own + // PlatformObjectsPlugin de-dup included. + expect(composeForDeclarations(new FakePlugin()).constructor.name).toBe('FakePlugin'); + }); + + it('keeps `this` pointed at the target, so private state still resolves', async () => { + class Private { + #secret = 'kept'; + name = 'com.example.private'; + async init(): Promise { /* nothing */ } + read(): string { return this.#secret; } + } + expect(composeForDeclarations(new Private()).read()).toBe('kept'); + }); +}); + +describe('buildSchemaMigrationPlugins', () => { + it('composes NOTHING with neither a host config nor an artifact app', async () => { + // The five-table baseline is the honest answer where there is no deployment + // to mirror, and this early return is what keeps that run byte-identical. + const out = await buildSchemaMigrationPlugins({ basePlugins: [], cwd: tempProject() }); + expect(out.plugins).toEqual([]); + expect(out.notes).toEqual([]); + expect(out.hostConfigPath).toBeNull(); + expect(out.hostConfigLoaded).toBe(false); + }); + + it('composes the platform floor once an artifact app is present, and not twice', async () => { + const artifactApp = { type: 'app', name: 'plugin.app.demo' }; + const out = await buildSchemaMigrationPlugins({ + basePlugins: [artifactApp], + cwd: tempProject(), + }); + expect(out.plugins).toHaveLength(1); + expect((out.plugins[0] as any)?.name).toBe('com.objectstack.platform-objects'); + + // A host that already brought one gets nothing added — `serve` 5c's rule. + const already = await buildSchemaMigrationPlugins({ + basePlugins: [artifactApp, { name: 'com.objectstack.platform-objects' }], + cwd: tempProject(), + }); + expect(already.plugins).toEqual([]); + }); + + it('composes a host config\'s plugins for their declarations only', async () => { + const dir = tempProject(); + writeFileSync( + join(dir, 'objectstack.config.ts'), + [ + 'class DemoPlugin {', + " name = 'com.example.demo';", + ' async init() {}', + ' async start() { throw new Error(\'start must not run under os migrate\'); }', + '}', + 'export default { plugins: [new DemoPlugin()] };', + '', + ].join('\n'), + ); + + const out = await buildSchemaMigrationPlugins({ basePlugins: [], cwd: dir }); + expect(out.hostConfigPath).toBe(join(dir, 'objectstack.config.ts')); + expect(out.hostConfigLoaded).toBe(true); + // The host plugin, plus the platform floor. + expect(out.plugins).toHaveLength(2); + + const demo = out.plugins.find((p: any) => p?.name === 'com.example.demo') as any; + expect(demo, 'the host plugin must be composed').toBeDefined(); + await expect(demo.init()).resolves.toBeUndefined(); + // The suppression is the whole point: the config's own `start` throws, and + // reaching it would be the measured defect (a dry run that writes). + await expect(demo.start()).resolves.toBeUndefined(); + + expect(out.notes.join(' ')).toContain('declarations only'); + }); + + it('reports a config it could not load rather than pretending it composed one', async () => { + const dir = tempProject(); + writeFileSync( + join(dir, 'objectstack.config.ts'), + "throw new Error('fixture: OS_SOME_SECRET is required');\n", + ); + + const out = await buildSchemaMigrationPlugins({ basePlugins: [], cwd: dir }); + + // Not fatal — this command worked before without ever reading the config, + // and a plan that stops working is a worse regression than a reduced one. + expect(out.hostConfigPath).toBe(join(dir, 'objectstack.config.ts')); + // …but it must be DISTINGUISHABLE. `managedTables` alone cannot say this: + // the platform floor still lands, so the count rises either way. + expect(out.hostConfigLoaded).toBe(false); + const said = out.notes.join(' '); + expect(said).toContain('could not be loaded'); + expect(said).toContain('UNMEASURED'); + }); +}); diff --git a/packages/cli/src/utils/schema-migration-plugins.ts b/packages/cli/src/utils/schema-migration-plugins.ts new file mode 100644 index 0000000000..5ab0525900 --- /dev/null +++ b/packages/cli/src/utils/schema-migration-plugins.ts @@ -0,0 +1,292 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import path from 'node:path'; +import fs from 'node:fs'; +import { isAppPluginLike } from './graft-runtime-hooks.js'; + +/** + * The object set a SCHEMA migration is planned against (#12938). + * + * ## What was wrong + * + * `os migrate plan` / `os migrate apply` boot through `createStandaloneStack`, + * whose plugin list is the data stack (`DefaultDatasourcePlugin`, + * `MetadataPlugin`, `ObjectQLPlugin`) plus `AppPlugin` when a compiled artifact + * is found — and, in that file's own words, it "never loads + * `objectstack.config.ts`". No platform plugin was composed either: the sibling + * DATA subcommands reach `PlatformObjectsPlugin` through + * {@link ./data-migration-plugins.js}, and `plan`/`apply` passed no + * `extraPlugins` at all. + * + * `detectManagedDrift()` iterates the driver's `managedObjectFields`, which only + * ever holds what got REGISTERED. So on a real deployment the pair diffed a + * five-table subset — `sys_metadata`, `sys_metadata_audit`, + * `sys_metadata_commit`, `sys_metadata_history`, `sys_view_definition` — and + * reported `0` drift over it. That failure is silent in the direction that reads + * as success: with nothing registered there is no drift, so the command printed + * "Physical schema is in sync with metadata — nothing to migrate." + * + * And the driver's OWN drift message prescribes this command. The + * `replace_unique_index` warning `reconcileAndWarnDrift` emits at boot — with + * the FULL registered object set, so it names tables like `sys_position` + * correctly — ends `run "os migrate apply"`. Measured on a control plane + * carrying ~80 `sys_*` tables: the boot detector reported ten findings while + * `os migrate plan` against the same database examined five tables and answered + * "in sync". + * + * ## What this composes, and why exactly this + * + * The set is derived from `serve`'s own assembly rather than hand-picked: + * + * - **The host config's plugins**, when an `objectstack.config.{ts,js,mjs}` is + * present — this is `serve`'s `plugins = config.plugins` step. On a + * deployment whose whole object set comes from its config (ObjectStack + * Cloud's control plane is the measured one: `createCloudStack()` returns the + * plugins, and the app has no compiled artifact at all) this is the ONLY half + * that matters. + * - **`AppPlugin(config)`**, when the config carries top-level metadata and + * brings no `AppPlugin` of its own — `serve` step 3, same presence test + * ({@link isAppPluginLike}), so top-level `objects`/`flows` reach the + * registry here exactly as they do there. + * - **`PlatformObjectsPlugin`**, when absent — `serve` step 5c. It is the one + * plugin `serve` composes UNCONDITIONALLY; everything else it injects + * (the auth family, i18n, observability, the HTTP server) sits behind a tier, + * an env var or a capability, and the auth family additionally behind "the + * config brought no `AuthPlugin`". Composing a tier-gated plugin here would + * be inventing an object set no boot of this deployment has. + * + * ## Phase 1 only for host plugins — and why that is the contract, not a dodge + * + * `os migrate plan` is a declared dry run: the boot defers schema DDL, + * suppresses the artifact seed and (since #9380) passes + * `runPlatformMigrations: false` for exactly this reason — "a repair that fires + * under them destroys the very evidence they were run to collect". + * + * Host plugins are arbitrary code, and the shipped ones write. Measured + * 2026-08-28, `SecurityPlugin` composed into a deferred `plan` boot: **14 + * `Insert operation failed` records against `sys_permission_set`** — its + * built-in permission-set / position bootstrap firing from `start()` and its + * `kernel:ready` hooks. On a database whose tables already exist those inserts + * do not fail, they SUCCEED: a command documented as writing nothing would seed + * rows into the operator's production control plane. + * + * What a schema command needs from a plugin is its DECLARATIONS, and the kernel + * contract puts those in `init()` — "register services, schemas, routes" — + * while `start()` is "begin work that needs every service up" (AGENTS.md → + * Patterns → Plugin). `SecurityPlugin` is the reference: `init()` hands + * `securityObjects` to the `manifest` service; every seeding path is registered + * inside `start()`. So a host plugin is composed for Phase 1 and its Phase 2 is + * SUPPRESSED ({@link composeForDeclarations}). Same measurement with the + * suppression in place: the same 16 managed tables, and **0** insert attempts. + * + * ⚠️ **The residue, stated rather than hidden:** a host plugin that registers + * its objects in `start()` instead of `init()` is invisible to this + * composition — its tables stay out of the plan. That is the same class of + * defect one notch narrower, and it is a real one; it is accepted here because + * the alternative measured worse (a dry run that writes). The composition says + * out loud what it did, so a missing table is diagnosable instead of being + * indistinguishable from "in sync". + * + * `PlatformObjectsPlugin` is deliberately NOT suppressed: it is platform + * infrastructure this CLI already boots fully under the sibling DATA + * subcommands ({@link ./data-migration-plugins.js}), which are dry-run-by-default + * too. The line is between plugins this repo owns and has measured, and host + * code it cannot know. + */ + +/** + * The config spellings `resolveConfigPath()` auto-detects, in its order. + * + * Deliberately not `resolveConfigPath()` itself: that helper `process.exit(1)`s + * when no config is found, which is right for `os build` and wrong here — a + * project with no config is a legitimate `os migrate` target and must keep + * behaving exactly as it does today. + */ +const HOST_CONFIG_CANDIDATES = [ + 'objectstack.config.ts', + 'objectstack.config.js', + 'objectstack.config.mjs', +] as const; + +/** The host config this boot would compose, or `null` when there is none. */ +export function findHostConfig(cwd: string = process.cwd()): string | null { + for (const candidate of HOST_CONFIG_CANDIDATES) { + const abs = path.resolve(cwd, candidate); + if (fs.existsSync(abs)) return abs; + } + return null; +} + +/** The `start()` a declaration-phase composition substitutes. */ +async function suppressedStart(): Promise { + /* Phase 2 is not run for host plugins — see this module's header. */ +} + +/** + * A host plugin composed for its DECLARATIONS: `init()` runs, `start()` does + * not. + * + * A Proxy rather than a hand-copied field list on purpose. The kernel reads + * several identity/ordering members off a plugin instance — `name`, `version`, + * `type`, `dependencies`, `optionalDependencies`, `requiresServices`, + * `providesServices`, and `constructor.name` at more than one presence test — + * and a copy that misses one does not fail, it silently mis-orders the boot or + * defeats a de-dup check. Forwarding everything and overriding exactly one + * member is the only shape in which that cannot happen. + * + * Two details the trap gets right deliberately: + * + * - methods are bound to the TARGET, so a plugin using real `#private` fields + * keeps working (a Proxy receiver would throw on those); + * - `constructor` is returned UNBOUND, because `fn.bind(x).name` is + * `'bound X'` — binding it would break every `p?.constructor?.name === 'X'` + * presence test, including the two this module performs. + * + * `destroy()` is forwarded: it is the symmetric teardown of `init()`, and a + * plugin that connected something during Phase 1 must still be able to close it. + */ +export function composeForDeclarations(plugin: T): T { + return new Proxy(plugin, { + get(target, prop) { + if (prop === 'start') return suppressedStart; + // Read through the target so getters see the right `this`. + const value = (target as Record)[prop]; + if (typeof value === 'function' && prop !== 'constructor') { + return (value as (...args: unknown[]) => unknown).bind(target); + } + return value; + }, + }) as T; +} + +/** Whether `plugins` already carries a `PlatformObjectsPlugin` — `serve` 5c's test. */ +function hasPlatformObjects(plugins: readonly unknown[]): boolean { + return plugins.some( + (p: any) => p?.name === 'com.objectstack.platform-objects' + || p?.constructor?.name === 'PlatformObjectsPlugin', + ); +} + +export interface SchemaMigrationComposition { + /** Plugins to register after the data stack, in order. */ + plugins: unknown[]; + /** The host config that was composed, or `null` when none was found. */ + hostConfigPath: string | null; + /** + * Did that config actually load? `false` means a config EXISTS and this boot + * could not read it — the one state in which the object set is smaller than + * the deployment's for a reason that is nobody's intent. Kept apart from + * `hostConfigPath === null` (no config at all, a legitimate shape) because a + * consumer asserting coverage cannot tell those two apart from a table count: + * both raise it above the artifact-less baseline, only one of them means the + * plan covers the deployment. + */ + hostConfigLoaded: boolean; + /** + * One line per thing this composition did or could not do, for the command to + * print. Empty when nothing was composed, so a project with neither a config + * nor an artifact produces byte-identical output to before this existed. + */ + notes: string[]; +} + +const NOTHING_COMPOSED: SchemaMigrationComposition = Object.freeze({ + plugins: [], + hostConfigPath: null, + hostConfigLoaded: false, + notes: [], +}) as SchemaMigrationComposition; + +/** + * The plugins `os migrate plan` / `apply` compose on top of the standalone data + * stack — see this module's header for what and why. + * + * @param basePlugins what `createStandaloneStack` already produced, read for the + * presence tests (an artifact-derived `AppPlugin`, a `PlatformObjectsPlugin` a + * host already brought). + * @param skipSeedData mirrors the boot's own setting onto a config-derived + * `AppPlugin`, so the config path and the artifact path suppress the inline + * seed identically. + */ +export async function buildSchemaMigrationPlugins(opts: { + basePlugins: readonly unknown[]; + cwd?: string; + skipSeedData?: boolean; +}): Promise { + const cwd = opts.cwd ?? process.cwd(); + const hostConfigPath = findHostConfig(cwd); + const hasArtifactApp = opts.basePlugins.some(isAppPluginLike); + + // Neither a host config nor a compiled artifact: there is no deployment here + // to mirror, and the five-table data stack is the honest answer. Returning + // early — rather than composing the platform floor anyway — is what keeps an + // artifact-less, config-less run byte-identical to the one before this card. + if (!hostConfigPath && !hasArtifactApp) return NOTHING_COMPOSED; + + const plugins: unknown[] = []; + const notes: string[] = []; + let hostConfigLoaded = false; + + if (hostConfigPath) { + try { + // Imported here rather than at module scope: `loadConfig` pulls in + // `bundle-require`/esbuild, and oclif `import()`s every command module on + // every CLI invocation (see `schema-migrate.ts`'s lazy-import note). + const { loadConfig } = await import('./config.js'); + const { config } = await loadConfig(hostConfigPath); + + const hostPlugins: unknown[] = Array.isArray(config?.plugins) ? config.plugins : []; + for (const plugin of hostPlugins) { + if (plugin && typeof plugin === 'object') plugins.push(composeForDeclarations(plugin)); + } + + // `serve` step 3, same predicate: a host config that ALSO carries + // top-level metadata needs the wrap, or its `objects` never reach the + // registry and this composition would report a set smaller than the one + // the deployment serves. + const configHasMetadata = !!( + config?.objects || config?.manifest || config?.apps || config?.flows || config?.apis + ); + const appAlready = hasArtifactApp || hostPlugins.some(isAppPluginLike); + if (configHasMetadata && !appAlready) { + const { AppPlugin } = await import('@objectstack/runtime'); + plugins.push(new AppPlugin(config, undefined, { skipSeedData: opts.skipSeedData ?? false })); + } + + hostConfigLoaded = true; + notes.push( + `Composed the host stack from ${path.relative(cwd, hostConfigPath) || hostConfigPath}: ` + + `${hostPlugins.length} plugin(s), registered for their declarations only ` + + '(init runs, start does not — a plan writes nothing).', + ); + } catch (error: any) { + // Loud, and on stderr in both modes (`--json` reserves stdout, and the + // reservation is already installed by the time this runs). NOT fatal: a + // config that cannot load — a missing env var is the common one — used to + // be irrelevant to this command, and turning that into a failed `plan` + // would break a command that works today. Say what was lost instead. + const message = error?.message ?? String(error); + const line = + `Host config ${hostConfigPath} could not be loaded: ${message}. ` + + 'The plan below covers ONLY the objects the data stack registered — it does NOT ' + + "cover this deployment's own objects, so an empty plan here is UNMEASURED, not " + + '"in sync". Fix the config (or its environment) and re-run.'; + // eslint-disable-next-line no-console + console.warn(`[migrate] ⚠ ${line}`); + notes.push(line); + } + } + + // `serve` 5c. Platform infrastructure every served kernel gets — the + // `sys_migration` ledger, `sys_secret`, the platform metadata tables — so it + // belongs in any plan that claims to describe a served deployment. Composed + // fully (not declaration-only): the sibling DATA subcommands already boot it + // this way, and it is this repo's own plugin rather than host code. + if (!hasPlatformObjects([...opts.basePlugins, ...plugins])) { + const { PlatformObjectsPlugin } = await import('@objectstack/platform-objects/plugin'); + plugins.push(new PlatformObjectsPlugin()); + notes.push('Composed PlatformObjectsPlugin (the platform floor `os serve` composes unconditionally).'); + } + + return { plugins, hostConfigPath, hostConfigLoaded, notes }; +}