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
64 changes: 64 additions & 0 deletions .changeset/migrate-apply-refuses-before-ddl.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
---
"@objectstack/cli": minor
---

fix(cli): `os migrate apply` refuses BEFORE writing any DDL when the host config exists but could not be loaded (#13118)

#12953 ruled the exit STATUS on that path and said nothing about the mutation.
So `apply` went on flushing its deferred schema work and applying drift over the
reduced object set, and THEN exited non-zero — one run saying both "this result
is UNMEASURED, not in sync" and "…and I changed your schema on that basis".
Measured on this change's own fixture before the fix: the refused run created
**9 tables** (`sys_metadata`, `sys_metadata_activation`, `sys_metadata_audit`,
`sys_metadata_commit`, `sys_metadata_history`, `sys_migration`,
`sys_migration_journal`, `sys_secret`, `sys_view_definition`), none of them the
deployment's, and exited 1.

Maintainer ruling 2026-08-29, verbatim 「同意」, option 2: 采**选项 2**:
`os migrate apply` 在 host config 存在但不可加载时,**先拒绝、不写任何 DDL**,退出非零。
`os migrate apply` now returns above `flushSchemaDdl()` and
`applyMigrationEntries()` on that path — the two calls in the command that
write — and the refusal on stderr reuses the ruled #12953 wording and
**additionally states that no DDL was executed**, so an operator does not have
to guess whether the database was touched. Under `--json` the document carries
`message: "refused_unloadable_host_config"` with `created: []` and
`applied: []`.

**BEHAVIOUR CHANGE to a mutating command**, shipped as `minor` for the same
reason #12953's exit-status half was: the repo's launch-window convention treats
a deliberate change to a published command's observable behaviour as `minor`
rather than `patch`, and this one additionally adds a new `--json` `message`
value that a consumer can branch on.

Scoped to exactly one shape; the ruling pinned the neighbours as hard as the
changed one, and all three are measured in
`packages/cli/test/migrate-apply-refuses-before-ddl.e2e.test.ts` on both halves
— exit status *and* what the database holds afterwards:

- host config **present and unloadable** → non-zero **and zero tables created**
(this change);
- host config **absent** → unchanged: exit 0, platform floor still created;
- host config **present and loadable** → unchanged: exit 0, the deployment's own
tables still created.

⛔ **No flag, env var or other escape hatch.** Option 3 was refused in the same
ruling — this repo does not add a published surface before the need for it is
measured.

`os migrate plan` is untouched: it never wrote to the database, its refusal
message is byte-identical to #12953's, and the no-DDL sentence is opt-in per
call site rather than deduced from the command.

**Recoverability, measured for the ruling.** A partial apply over the reduced
set DOES converge: after repairing the config, a full `apply` on the same
database produces a schema identical to one a never-degraded database gets from
a single full run (verified with a positive control — the same comparison
detects a deliberately introduced one-column difference). So this change is
contract honesty rather than data rescue; the ruling holds either way, and the
cost is simply low.

**Migration.** A CI step that runs `os migrate apply` against a project whose
config needs environment it was not given already failed (#12953); it now also
leaves the database untouched instead of reconciling it against a fraction of
the deployment. Supply that environment to the run (the error names the missing
variable), or fix the config, then re-run.
72 changes: 71 additions & 1 deletion packages/cli/src/commands/migrate/apply.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
} from '../../utils/schema-migrate.js';
import { exitOneShotCommand } from '../../utils/one-shot-exit.js';
import {
describeUnloadableHostConfig,
refuseWhenHostConfigUnloadable,
type SchemaMigrationComposition,
} from '../../utils/schema-migration-plugins.js';
Expand Down Expand Up @@ -56,6 +57,14 @@ async function confirm(question: string): Promise<boolean> {
* 2. **A database somebody else is using is not migrated by accident.** The
* SQLite target is probed for other attached connections before boot, and a
* busy database refuses without `--force`.
*
* A third, added by #13118 (maintainer ruling 2026-08-29, verbatim 「同意」):
*
* 3. **An UNMEASURED run does not write.** When the host
* `objectstack.config.{ts,js,mjs}` exists and could not be loaded, the object
* set this command can see is the data stack plus the platform floor — not
* the deployment's. #12953 made that run exit non-zero; it still applied its
* DDL first. It now refuses above every write, and says so in the refusal.
*/
export default class MigrateApply extends Command {
static override description =
Expand Down Expand Up @@ -99,7 +108,13 @@ export default class MigrateApply extends Command {
// 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);
// [#13118] `noDdlExecuted` is true because `apply()` above RETURNS on this
// path before `flushSchemaDdl()` / `applyMigrationEntries()` — see the
// refusal gate there. The two must move together: the sentence is a claim
// about this run, not a label on the command.
if (this.composition) {
refuseWhenHostConfigUnloadable(this.composition, { noDdlExecuted: true });
}
await exitOneShotCommand(typeof process.exitCode === 'number' ? process.exitCode : 0);
}

Expand Down Expand Up @@ -253,6 +268,61 @@ export default class MigrateApply extends Command {
}
}

// ── [#13118] REFUSE BEFORE ANY DDL ──────────────────────────────
//
// Maintainer ruling 2026-08-29, verbatim 「同意」, option 2: when the host
// config EXISTS and could not be loaded, `os migrate apply` refuses
// WITHOUT writing DDL, and exits non-zero. #12953 had ruled only the exit
// status, so until this gate the same run said "this result is UNMEASURED,
// not in sync" and reconciled the operator's schema against that very
// set — nine platform/data-stack tables, none of them the deployment's.
//
// Placement is the whole change, and it is exact:
//
// • BELOW the report. Everything above this line is read-only — the
// boot deferred its DDL (#3917), `detectManagedDrift()` only reads the
// catalog — and #12953 pinned that the refusal replaces the STATUS,
// not the document. The plan and the `--json` payload still reach
// their reader, `composition.hostConfigLoaded` included.
// • ABOVE the confirmation gate. An operator must not be asked to
// confirm a reconcile this command has already decided to refuse.
// • ABOVE `flushSchemaDdl()` and `applyMigrationEntries()` — the two
// calls in this file that write. That is what makes `run()`'s
// `noDdlExecuted: true` a true sentence rather than a hopeful one.
//
// ⛔ No flag, env var or escape hatch: option 3 was refused in the same
// ruling ("需求未测不加面"). The path back to a working apply is to fix the
// config — which is also the only path back to a bootable deployment, as
// `os serve` refuses the identical shape.
//
// Convergence, measured for the ruling before this gate was written: a
// partial apply over the reduced set then a repaired full apply DOES
// converge to the schema a never-degraded run produces. So this refusal
// is contract honesty, not data rescue — which is exactly the outcome the
// ruling said to implement anyway, at low cost.
if (describeUnloadableHostConfig(stack.composition) !== null) {
if (flags.json) {
// `skipped`/`pending` carry what was NOT done, in the vocabulary the
// other "we did not apply" payloads above already use. `created` and
// `applied` are empty because nothing was created or applied — a
// consumer must be able to read "no DDL" off the document too, not
// only off stderr.
await emitJson({
database: stack.dbLabel,
created: [],
applied: [],
skipped: drift,
pending,
message: 'refused_unloadable_host_config',
...compositionPayload,
}, 0, { compact: true });
return;
}
// The refusal sentence itself is printed once, on stderr, by `run()`'s
// shared choke point — not duplicated here.
return;
}

const totalIntended = intended.length + pending.length;
if (totalIntended === 0) {
if (flags.json) { await emitJson({ applied: [], skipped: deferred, created: [], message: 'nothing_safe_to_apply' }, 0, { compact: true }); return; }
Expand Down
81 changes: 81 additions & 0 deletions packages/cli/src/utils/schema-migration-plugins.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
buildSchemaMigrationPlugins,
measureComposedCoverage,
describeUnloadableHostConfig,
NO_DDL_EXECUTED_NOTICE,
type SchemaMigrationComposition,
} from './schema-migration-plugins.js';

Expand Down Expand Up @@ -288,6 +289,86 @@ describe('describeUnloadableHostConfig (#12953)', () => {
});
});

/**
* #13118 — the MUTATING command additionally says it wrote nothing.
*
* Maintainer ruling 2026-08-29, verbatim 「同意」, option 2: `os migrate apply`
* refuses on the unloadable-config path WITHOUT touching the database, and the
* refusal must say so — "an operator reading it must not have to guess whether
* the database was touched".
*
* The zero-DDL behaviour itself is pinned over a real child process and a real
* `sqlite_master` read in
* `packages/cli/test/migrate-apply-refuses-before-ddl.e2e.test.ts`; this file
* owns the WORDING, and the two things the option must not do:
*
* • it must not leak into `os migrate plan`, whose message the ruling pinned
* unchanged (`plan` 行为不变) — so the default spelling is byte-identical to
* the #12953 text;
* • it must not widen the predicate. An option that made the untouched
* populations answer non-null would turn every config-less and every
* healthy project red, and direction 1 would keep passing while it did.
*/
describe('describeUnloadableHostConfig — the no-DDL notice (#13118)', () => {
function composition(over: Partial<SchemaMigrationComposition>): SchemaMigrationComposition {
return {
plugins: [], hostConfigPath: null, hostConfigLoaded: false, hostConfigError: null,
notes: [], coverage: null, ...over,
};
}

const unloadable = composition({
hostConfigPath: '/srv/app/objectstack.config.ts',
hostConfigLoaded: false,
hostConfigError: 'Missing required environment variable AUTH_SECRET',
});

it('states that no DDL ran when the caller asserts it', () => {
const said = describeUnloadableHostConfig(unloadable, { noDdlExecuted: true });
expect(said).toContain(NO_DDL_EXECUTED_NOTICE.trim());
// ⭐ And it is an ADDITION, not a replacement: the ruling said to REUSE the
// #12953 wording and add to it, so every element that ruling required is
// still there.
expect(said).toContain('/srv/app/objectstack.config.ts');
expect(said).toContain('Missing required environment variable AUTH_SECRET');
expect(said).toContain('UNMEASURED');
expect(said).toMatch(/Remedy:/);
});

it("says nothing about DDL by default — `plan`'s message is byte-identical to #12953's", () => {
// The default spelling and the explicit-false spelling are the same
// string, and neither carries the notice. `plan` passes no options at all,
// so this is the exact text it emits.
const byDefault = describeUnloadableHostConfig(unloadable);
expect(byDefault).not.toContain('NO DDL');
expect(byDefault).toBe(describeUnloadableHostConfig(unloadable, {}));
expect(byDefault).toBe(describeUnloadableHostConfig(unloadable, { noDdlExecuted: false }));
});

it('the notice is the only difference the option makes', () => {
// Written as a subtraction rather than as a second copy of the sentence:
// a test that re-spells the message is a test that stops holding it.
const withNotice = describeUnloadableHostConfig(unloadable, { noDdlExecuted: true })!;
expect(withNotice.replace(NO_DDL_EXECUTED_NOTICE, '')).toBe(
describeUnloadableHostConfig(unloadable),
);
});

it('⛔ does not widen the predicate — the untouched populations stay null', () => {
// Both with the option set: an option that could turn "no config" or
// "config loads fine" into a refusal is the failure #12953 already named,
// and direction 1 above passes identically while it happens.
expect(describeUnloadableHostConfig(
composition({ hostConfigPath: null, hostConfigLoaded: false }),
{ noDdlExecuted: true },
)).toBeNull();
expect(describeUnloadableHostConfig(
composition({ hostConfigPath: '/srv/app/objectstack.config.ts', hostConfigLoaded: true }),
{ noDdlExecuted: true },
)).toBeNull();
});
});

/**
* #13028 — the plan reports its own BOUNDARY.
*
Expand Down
53 changes: 52 additions & 1 deletion packages/cli/src/utils/schema-migration-plugins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -392,9 +392,56 @@ export async function buildSchemaMigrationPlugins(opts: {
*
* The message names the three things the ruling requires of it: the config
* file, the underlying failure, and the remedy.
*
* ## The mutation half (#13118), and why it is a caller's claim
*
* #12953 ruled the exit STATUS and said nothing about the mutation, so `apply`
* shipped writing its DDL over the reduced object set and THEN exiting
* non-zero — the same run saying "this result is UNMEASURED" and "…and I
* changed your schema on that basis". Maintainer ruling 2026-08-29, verbatim
* 「同意」, option 2: `os migrate apply` refuses on this path **without touching
* the database**, and the refusal must say so explicitly, so an operator
* reading it does not have to guess.
*
* That extra sentence is opt-in ({@link UnloadableHostConfigRefusalOptions})
* rather than automatic. It is a claim about what a particular run did, and the
* only site that can honestly make it is one that returned before its own
* mutating work.
*/
export interface UnloadableHostConfigRefusalOptions {
/**
* Say, in the refusal itself, that this run performed **no DDL** (#13118).
*
* ⛔ Not a default, and deliberately not deduced from the command name. The
* sentence is a claim about what THIS run did to the operator's database,
* and only a call site that has actually returned before its mutating work
* can make it. `os migrate apply` passes `true` because #13118 moved its
* refusal above `flushSchemaDdl()` / `applyMigrationEntries()`; a future
* caller that refuses AFTER writing must say nothing here and get the
* #12953 wording unchanged, rather than inherit a false all-clear by
* omission.
*
* `os migrate plan` never passes it: `plan` writes nothing on ANY path, so
* the sentence would be noise there — and its message is pinned unchanged by
* the ruling's "plan 行为不变".
*/
noDdlExecuted?: boolean;
}

/**
* The sentence #13118 requires of the MUTATING command's refusal, verbatim.
*
* Exported so the pin and the message have one source: an operator reading the
* refusal "must not have to guess whether the database was touched", and a
* test that re-spells the sentence stops holding it the day the wording moves.
*/
export const NO_DDL_EXECUTED_NOTICE =
'NO DDL WAS EXECUTED: this run refused before touching the database, so the physical '
+ 'schema is exactly as it was before the command ran. ';

export function describeUnloadableHostConfig(
composition: SchemaMigrationComposition,
options: UnloadableHostConfigRefusalOptions = {},
): string | null {
if (composition.hostConfigPath === null || composition.hostConfigLoaded) return null;
const cause = composition.hostConfigError ?? 'the load threw without a message';
Expand All @@ -403,6 +450,7 @@ export function describeUnloadableHostConfig(
+ 'This run therefore covered ONLY the objects the data stack registered — a fraction of '
+ 'what this deployment serves — so its result is UNMEASURED, not "in sync", and it is '
+ 'reported as a FAILURE rather than as success. '
+ (options.noDdlExecuted === true ? NO_DDL_EXECUTED_NOTICE : '')
+ 'Remedy: supply the environment this config needs (the failure named above says which), '
+ 'or fix the config, then re-run.'
);
Expand All @@ -426,12 +474,15 @@ export function describeUnloadableHostConfig(
* time this runs and must survive. `migrate/plan.ts`'s `run()` wrapper reads
* `process.exitCode` and hands it to `exitOneShotCommand`.
*
* @param options see {@link UnloadableHostConfigRefusalOptions} — `apply`
* passes `noDdlExecuted: true` (#13118); `plan` passes nothing.
* @returns `true` when this run was the refused shape.
*/
export function refuseWhenHostConfigUnloadable(
composition: SchemaMigrationComposition,
options: UnloadableHostConfigRefusalOptions = {},
): boolean {
const line = describeUnloadableHostConfig(composition);
const line = describeUnloadableHostConfig(composition, options);
if (line === null) return false;
// eslint-disable-next-line no-console
console.error(`[migrate] ✗ ${line}`);
Expand Down
Loading
Loading