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
44 changes: 44 additions & 0 deletions .changeset/migrate-refuses-unloadable-host-config.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
---
"@objectstack/cli": minor
---

fix(cli): `os migrate plan` / `apply` exit non-zero when the host config exists but could not be loaded (#12953)

A host `objectstack.config.{ts,js,mjs}` that EXISTS and throws while loading — a
missing environment variable is the ordinary cause, and ObjectStack Cloud's own
control-plane config throws without `AUTH_SECRET` — used to warn loudly and then
**exit 0**. The object set the commands diffed on that path is the data stack
plus the platform floor: nine tables, none of them the deployment's, and `0`
drift over them printed "Physical schema is in sync with metadata — nothing to
migrate". Measured on the fixture this ships with, before the change: `plan`,
`plan --json`, `apply --yes` and `apply --yes --json` all returned `0`.

Maintainer ruling 2026-08-29, verbatim 「同意」: a green exit over an UNMEASURED
partial metadata set is the false-green a migration tool must never emit, and
the population this "regresses" was computing defective plans all along. Both
commands now exit **non-zero** on that path, with an error on stderr naming the
config file, the underlying failure, and the remedy.

**BEHAVIOUR CHANGE to exit status**, shipped as `minor` under the repo's
launch-window convention. It is scoped to exactly one shape, and the two
neighbouring ones were measured byte-identical before and after — stdout *and*
stderr, human and `--json`, for both commands:

- host config **present and unloadable** → non-zero (this change);
- host config **absent** → unchanged, still exit 0. `hostConfigLoaded` is
`false` on that shape too, so the refusal keys on `hostConfigPath !== null`
rather than on the flag alone;
- host config **present and loadable** → unchanged, still exit 0.

Everything the previous behaviour emitted is kept, deliberately: the loud stderr
warning, and the `composition.hostConfigLoaded` discriminator in the `--json`
payload that consumer coverage gates (objectstack-ai/cloud#1705) read — a table
count cannot replace it, because the platform floor raises the count either way.
The refusal changes the exit STATUS, not the document: the whole plan, or the
whole JSON payload, is still written before the process exits non-zero, and the
unloadable path's payload is byte-identical to the one it emitted before.

**Migration.** A CI step that runs `os migrate plan`/`apply` against a project
whose config needs environment it was not given now fails instead of reporting
success over a fraction of the deployment. Supply that environment to the run
(the error names the missing variable), or fix the config.
17 changes: 17 additions & 0 deletions packages/cli/src/commands/migrate/apply.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -22,6 +22,10 @@ import {
groupByCategory,
} from '../../utils/schema-migrate.js';
import { exitOneShotCommand } from '../../utils/one-shot-exit.js';
import {
refuseWhenHostConfigUnloadable,
type SchemaMigrationComposition,
} from '../../utils/schema-migration-plugins.js';
import { OCCUPANCY_HINT, probeMigrationTarget } from '../../utils/migrate-occupancy-gate.js';
import { describeOccupancy } from '../../utils/sqlite-occupancy.js';

Expand DownExpand Up@@ -90,9 +94,21 @@ export default class MigrateApply extends Command {
*/
async run(): Promise<void> {
await this.apply();
// [#12953] Same refusal as `migrate plan`, through the same choke point —
// the ruling (2026-08-29, verbatim 「同意」) named BOTH commands, and the
// reconcile an operator confirms has to be judged the same way as the plan
// they read. Applied after `apply()` for the same reason it is there: the
// report is already written and must survive the non-zero exit.
if (this.composition) refuseWhenHostConfigUnloadable(this.composition);
await exitOneShotCommand(typeof process.exitCode === 'number' ? process.exitCode : 0);
}

/**
* What {@link apply} composed, read by {@link run} after it returns (#12953).
* `null` until the stack has booted, and on every path where it never did.
*/
private composition: SchemaMigrationComposition | null = null;

private async apply(): Promise<void> {
const { flags } = await this.parse(MigrateApply);
const timer = createTimer();
Expand DownExpand Up@@ -154,6 +170,7 @@ export default class MigrateApply extends Command {
this.exit(1);
return;
}
this.composition = stack.composition;

try {
if (!stack.driver) {
Expand Down
22 changes: 22 additions & 0 deletions packages/cli/src/commands/migrate/plan.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -20,6 +20,10 @@ import {
summarizePendingSchemaWork,
} from '../../utils/schema-migrate.js';
import { exitOneShotCommand } from '../../utils/one-shot-exit.js';
import {
refuseWhenHostConfigUnloadable,
type SchemaMigrationComposition,
} from '../../utils/schema-migration-plugins.js';
import { probeMigrationTarget } from '../../utils/migrate-occupancy-gate.js';
import { describeOccupancy } from '../../utils/sqlite-occupancy.js';
import {
Expand DownExpand Up@@ -86,9 +90,26 @@ export default class MigratePlan extends Command {
*/
async run(): Promise<void> {
await this.plan();
// [#12953] A host config that EXISTS but could not be loaded means the plan
// above covered a fraction of this deployment — UNMEASURED, not "in sync" —
// and the maintainer ruled that green exit out (2026-08-29, verbatim
// 「同意」). Applied HERE, after `plan()`, deliberately: every one of its
// early returns (no SQL driver, in sync, the rendered plan) has already
// written its report by now, and the report — the human plan, or the JSON
// document whose `composition.hostConfigLoaded` the ruling kept as the
// consumer's discriminator — must survive the refusal, not be replaced by
// it. `this.composition` is `null` on the boot-failure path, which already
// exits non-zero through oclif.
if (this.composition) refuseWhenHostConfigUnloadable(this.composition);
await exitOneShotCommand(typeof process.exitCode === 'number' ? process.exitCode : 0);
}

/**
* What {@link plan} composed, read by {@link run} after it returns (#12953).
* `null` until the stack has booted, and on every path where it never did.
*/
private composition: SchemaMigrationComposition | null = null;

private async plan(): Promise<void> {
const { flags } = await this.parse(MigratePlan);
const timer = createTimer();
Expand DownExpand Up@@ -132,6 +153,7 @@ export default class MigratePlan extends Command {
this.exit(1);
return;
}
this.composition = stack.composition;

try {
if (!stack.driver) {
Expand Down
5 changes: 4 additions & 1 deletion packages/cli/src/utils/schema-migrate.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -338,7 +338,10 @@ export async function bootSchemaStack(
cwd: opts.projectRoot ?? process.cwd(),
skipSeedData: defer,
})
: { plugins: [], hostConfigPath: null, hostConfigLoaded: false, notes: [], coverage: null } satisfies SchemaMigrationComposition;
: {
plugins: [], hostConfigPath: null, hostConfigLoaded: false, hostConfigError: null,
notes: [], coverage: null,
} satisfies SchemaMigrationComposition;
for (const plugin of composition.plugins) {
await kernel.use(plugin as any);
}
Expand Down
97 changes: 94 additions & 3 deletions packages/cli/src/utils/schema-migration-plugins.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,8 @@ import {
composeForDeclarations,
buildSchemaMigrationPlugins,
measureComposedCoverage,
describeUnloadableHostConfig,
type SchemaMigrationComposition,
} from './schema-migration-plugins.js';

/**
Expand DownExpand Up@@ -185,15 +187,104 @@ describe('buildSchemaMigrationPlugins', () => {

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.
// The composition still COMPLETES — the reduced set is composed and
// returned. What changed with #12953 is the verdict the COMMANDS draw from
// it (a non-zero exit), not whether this function throws.
expect(out.hostConfigPath).toBe(join(dir, 'objectstack.config.ts'));
// …but it must be DISTINGUISHABLE. `managedTables` alone cannot say this:
// …and 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');
// [#12953] The underlying failure, carried structurally so the refusal can
// NAME it rather than re-parsing the prose above.
expect(out.hostConfigError).toContain('OS_SOME_SECRET is required');
});

it('leaves hostConfigError null when there is no host config at all', async () => {
const none = await buildSchemaMigrationPlugins({ basePlugins: [], cwd: tempProject() });
expect(none.hostConfigError).toBeNull();
});

it('leaves hostConfigError null when the host config LOADS', async () => {
const dir = tempProject();
writeFileSync(join(dir, 'objectstack.config.ts'), 'export default { objects: [] };\n');
const loaded = await buildSchemaMigrationPlugins({ basePlugins: [], cwd: dir });
expect(loaded.hostConfigLoaded).toBe(true);
expect(loaded.hostConfigError).toBeNull();
// Loading a real config runs `bundle-require`/esbuild — well past the 5 s
// default on a cold, shared box.
}, 60_000);
});

/**
* #12953 — the predicate behind the non-zero exit, in all three directions.
*
* Maintainer ruling 2026-08-29 (verbatim 「同意」): a host config that EXISTS
* and could not be loaded makes `os migrate plan` / `apply` exit non-zero,
* because a green exit over an UNMEASURED partial metadata set is the
* false-green a migration tool must never emit. The ruling pinned the OTHER
* two directions just as hard — config absent, and config loadable, both keep
* today's behaviour — so all three are pinned here.
*
* ⚠️ The trap this file exists to hold: `hostConfigLoaded` is `false` on the
* config-ABSENT shape too (nothing loaded, because there was nothing to load).
* A predicate written as `!hostConfigLoaded` therefore turns the untouched
* population red, and every assertion about direction 1 still passes while it
* does. The second case below is the one that fails if anyone writes it that
* way.
*
* The exit STATUS itself is pinned over a real child process in
* `packages/cli/test/migrate-unloadable-host-config-exit.e2e.test.ts` — a
* `process.exitCode` set inside a vitest worker is not an exit status.
*/
describe('describeUnloadableHostConfig (#12953)', () => {
function composition(over: Partial<SchemaMigrationComposition>): SchemaMigrationComposition {
return {
plugins: [], hostConfigPath: null, hostConfigLoaded: false, hostConfigError: null,
notes: [], coverage: null, ...over,
};
}

it('direction 1 — config PRESENT and unloadable: names the config, the cause and the remedy', () => {
const said = describeUnloadableHostConfig(composition({
hostConfigPath: '/srv/app/objectstack.config.ts',
hostConfigLoaded: false,
hostConfigError: 'Missing required environment variable AUTH_SECRET',
}));

expect(said).not.toBeNull();
// The three things the ruling requires the error to name.
expect(said).toContain('/srv/app/objectstack.config.ts');
expect(said).toContain('Missing required environment variable AUTH_SECRET');
expect(said).toMatch(/Remedy:/);
// And that it is a FAILURE, not another warning — the whole point.
expect(said).toContain('UNMEASURED');
});

it('direction 2 — config ABSENT: null, even though hostConfigLoaded is false', () => {
// `hostConfigPath === null` with `hostConfigLoaded === false` is the
// untouched population. If this ever answers non-null, every project with
// no config starts failing `os migrate plan`.
expect(describeUnloadableHostConfig(composition({
hostConfigPath: null, hostConfigLoaded: false,
}))).toBeNull();
});

it('direction 3 — config PRESENT and loadable: null', () => {
expect(describeUnloadableHostConfig(composition({
hostConfigPath: '/srv/app/objectstack.config.ts', hostConfigLoaded: true,
}))).toBeNull();
});

it('still names something when the load threw without a message', () => {
const said = describeUnloadableHostConfig(composition({
hostConfigPath: '/srv/app/objectstack.config.mjs', hostConfigLoaded: false,
hostConfigError: null,
}));
expect(said).toContain('/srv/app/objectstack.config.mjs');
expect(said).toContain('the load threw without a message');
});
});

Expand Down
Loading
Loading