Skip to content

Commit fc8a339

Browse files
hotlongclaude
andauthored
fix(cli): os migrate plan/apply compose the deployment's own object set (#12952)
* fix(cli): os migrate plan/apply compose the deployment's own object set Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(cli): pin the composed migrate object set and its write-free plan Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(cli): make the host-composition cases order-independent; add changeset Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent b1a987e commit fc8a339

7 files changed

Lines changed: 951 additions & 4 deletions

File tree

.changeset/warm-pumas-repeat.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
"@objectstack/cli": patch
3+
---
4+
5+
`os migrate plan` / `os migrate apply` now diff the object set the deployment actually serves
6+
7+
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`.
8+
9+
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".
10+
11+
`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.
12+
13+
Nothing about what counts as drift changed. A plan that now reports findings it used to hide is the fix working.
14+
15+
Two behaviours worth knowing:
16+
17+
- **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.
18+
- **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.
19+
20+
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.

packages/cli/src/commands/migrate/apply.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,16 @@ export default class MigrateApply extends Command {
126126
try {
127127
// `deferSchemaDdl` is what makes the prompt below meaningful: without it
128128
// the boot has already created tables and added columns by this point.
129-
stack = await bootSchemaStack({ jsonOutput: flags.json, databaseUrl: flags['database-url'], deferSchemaDdl: true });
129+
// `composeHostStack` (#12938): reconcile the object set this deployment
130+
// actually serves. It must be the SAME set `os migrate plan` diffed —
131+
// the plan the operator just read is the thing being confirmed — so the
132+
// two commands pass it identically.
133+
stack = await bootSchemaStack({
134+
jsonOutput: flags.json,
135+
databaseUrl: flags['database-url'],
136+
deferSchemaDdl: true,
137+
composeHostStack: true,
138+
});
130139
} catch (error: any) {
131140
if (flags.json) { await emitJson({ error: error.message }, 0, { compact: true }); this.exit(1); }
132141
printError(error.message || String(error));
@@ -141,6 +150,16 @@ export default class MigrateApply extends Command {
141150
return;
142151
}
143152

153+
// What the object set was composed from (#12938) — printed BEFORE the
154+
// in-sync early return below, not with the plan. "Already in sync" over a
155+
// set that is a fraction of the target's tables is precisely the reading
156+
// this card exists to stop, so the account of what was composed has to
157+
// reach the operator on that path too.
158+
if (!flags.json) {
159+
for (const note of stack.composition.notes) console.log(chalk.dim(` ${note}`));
160+
if (stack.composition.notes.length > 0) console.log('');
161+
}
162+
144163
const drift = await stack.driver.detectManagedDrift();
145164
const grouped = groupByCategory(drift);
146165
// Additive work the boot sync was held back from doing. Not drift — it

packages/cli/src/commands/migrate/plan.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,12 @@ export default class MigratePlan extends Command {
9999
// this — it flushes the deferred DDL after confirmation and needs a
100100
// real file to flush into.
101101
readOnlyProbe: true,
102+
// #12938 — diff the object set this deployment actually serves. Without
103+
// it the plan covers the five-table data stack alone: on a control plane
104+
// carrying ~80 `sys_*` tables that printed "in sync" while the driver's
105+
// own boot detector reported ten findings on the same database, and the
106+
// command those findings name is this one.
107+
composeHostStack: true,
102108
});
103109
} catch (error: any) {
104110
if (flags.json) { await emitJson({ error: error.message }, 0, { compact: true }); this.exit(1); }
@@ -150,6 +156,22 @@ export default class MigratePlan extends Command {
150156
},
151157
}
152158
: {}),
159+
// [#12938] What the diffed object set was composed from — present
160+
// only when there WAS a deployment to compose, so a project with
161+
// neither a config nor a compiled artifact emits the same document it
162+
// always did. A consumer asserting coverage needs `hostConfigLoaded`
163+
// and not just `managedTables`: a config that fails to load also
164+
// raises the count (the platform floor still lands), and a count alone
165+
// cannot tell that apart from a deployment that is genuinely small.
166+
...(stack.composition.notes.length > 0
167+
? {
168+
composition: {
169+
hostConfig: stack.composition.hostConfigPath,
170+
hostConfigLoaded: stack.composition.hostConfigLoaded,
171+
notes: stack.composition.notes,
172+
},
173+
}
174+
: {}),
153175
...(occupancy.status === 'busy'
154176
? { occupancy: { status: 'busy', signal: occupancy.signal, detail: occupancy.detail } }
155177
: {}),
@@ -172,6 +194,10 @@ export default class MigratePlan extends Command {
172194

173195
printInfo(`Database: ${chalk.white(stack.dbLabel)}`);
174196
printInfo(`Examined ${chalk.white(String(stack.managedTableCount))} managed table(s).`);
197+
// What the object set was composed from (#12938) — never silent about a
198+
// host config it could not load, and empty (so this block prints nothing)
199+
// when there was no deployment to compose.
200+
for (const note of stack.composition.notes) console.log(chalk.dim(` ${note}`));
175201
console.log('');
176202

177203
if (drift.length === 0 && pending.length === 0) {

0 commit comments

Comments
 (0)