Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .changeset/warm-pumas-repeat.md
Original file line numberDiff line numberDiff line change
@@ -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.
21 changes: 20 additions & 1 deletion packages/cli/src/commands/migrate/apply.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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));
Expand All@@ -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
Expand Down
26 changes: 26 additions & 0 deletions packages/cli/src/commands/migrate/plan.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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); }
Expand DownExpand Up@@ -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 } }
: {}),
Expand All@@ -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) {
Expand Down
Loading
Loading