Uh oh!
There was an error while loading. Please reload this page.
fix(cli): os migrate plan/apply exit non-zero when the host config exists but could not be loaded - #13113
Conversation
… be loaded
`os migrate plan` and `os migrate apply` exited 0 when a host
`objectstack.config.{ts,js,mjs}` was present and threw while loading -- a
missing environment variable being the ordinary cause. The metadata set they
then diffed was the data stack plus the platform floor: nine tables, none of
them the deployment's, and zero drift over them printed "Physical schema is in
sync with metadata".
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. Both
commands now exit non-zero on that path, with an error on stderr naming the
config file, the underlying failure and the remedy.
Scope is exactly that one shape. A config that is ABSENT, and a config that
LOADS, keep today's behaviour -- both measured byte-identical, stdout and
stderr, human and --json, for both commands. The refusal keys on
`hostConfigPath !== null && !hostConfigLoaded`, not on the flag alone, because
`hostConfigLoaded` is false on the config-absent shape too.
Everything the previous behaviour emitted is kept: the loud stderr warning and
the `composition.hostConfigLoaded` discriminator consumer coverage gates read.
The refusal changes the exit STATUS, not the document -- the whole report is
written first, and the unloadable path's JSON payload is byte-identical to the
one it emitted before.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UjujZN219uFzBhSYfMykCd…grate-plan-unloadable-config
📓 Docs Drift CheckThis PR changes 1 package(s): 8 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 23 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 9c9882db66f4d71435000e1d751c4966c861354a && git checkout 9c9882db66f4d71435000e1d751c4966c861354a
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin d48929efe794b467c169825ecb68a2d015f09d8d 542756251498dc89c7bc823c67fc238af0c4c298 && git checkout -B drift-repro d48929efe794b467c169825ecb68a2d015f09d8d && git merge --no-ff 542756251498dc89c7bc823c67fc238af0c4c298
node scripts/docs-audit/affected-docs.mjs --json d48929efe794b467c169825ecb68a2d015f09d8d
|
os-litant
commented
Aug 29, 2026
PM 复核 — ACCEPT。待裁问题:A,按已交付
⭐ 判定式那一处,是本轮最值钱的我在派发单里说过「方向 2、3 是本卡最容易出事的地方 —— 一个"顺手统一错误处理"的改动很容易把它们一起转红」。你把它变成了一个结构性的观察,而不是靠小心: if(composition.hostConfigPath===null||composition.hostConfigLoaded)returnnull;我核过 ⇒ 朴素的 核过的其余
阳性对照做得对:「 待裁问题:A —— 维持现状, |
Uh oh!
There was an error while loading. Please reload this page.
Fixes#12953
Executes the maintainer ruling of 2026-08-29 (verbatim 「同意」, option A) on
issue #12953: when a host
objectstack.config.{ts,js,mjs}exists but cannotbe loaded,
os migrate planandos migrate applynow exit non-zero,with an error naming the config file, the underlying failure, and the remedy.
What the change is
The composition already knew this shape (
hostConfigLoaded: falsewith anon-null
hostConfigPath, #12938); it just drew no verdict from it. It nowcarries the underlying failure structurally (
hostConfigError), and one sharedchoke point —
refuseWhenHostConfigUnloadable()— renders the refusal and setsprocess.exitCode. Both commands call it from theirrun()wrapper, afterthe body has returned, so the whole report is written before the process exits
non-zero.
Two things are deliberately preserved, because the ruling pinned them:
composition.hostConfigLoadedin the--jsonpayload — the discriminatorconsumer coverage gates read (objectstack-ai/cloud#1705). A table count
cannot replace it: the platform floor lands either way, so the count rises
either way.
The refusal changes the exit status, not the document. On the unloadable
path the JSON payload is byte-identical to the one emitted before this PR.
Measured, by driving the real commands
Not read off the source. Three fixture projects, the real CLI
(
bin/run-dev.jsthrough tsx), four invocations each —migrate plan,migrate plan --json,migrate apply --yes,migrate apply --yes --json—captured before and after, with the temp path and the wall-clock duration
masked as per-run noise.
0×41×40×40×40×40×4The unloadable fixture is the issue's own shape: a config whose module body
throws when an environment variable is missing, the way ObjectStack Cloud's
control-plane config throws without
AUTH_SECRET.The new stderr line, as emitted (the project path is written
PROJECT/here —an angle-bracket placeholder is eaten by GitHub's body sanitizer, backticks and
code fences included):
The trap this PR is written against
hostConfigLoadedisfalseon the config-absent shape too — nothingloaded, because there was nothing to load. A refusal keyed on that flag alone
turns every config-less project red, and every assertion about the changed
direction still passes while it does. So the predicate keys on
hostConfigPath !== null && !hostConfigLoaded, and the untouched directionscarry their own pins (
describeUnloadableHostConfigdirection-2 unit case;direction 2 — there is NO host configin the e2e file) which are what fails ifanyone rewrites it.
Tests
packages/cli/test/migrate-unloadable-host-config-exit.e2e.test.ts(new) —the exit status, over a real child process, in all three directions. A
process.exitCodeset inside a vitest worker is not an exit status, and theshell is the entire audience of this ruling.
packages/cli/src/utils/schema-migration-plugins.test.ts— unit pins on thepredicate, all three directions plus the message-less-throw shape.
Ablation. Putting the non-zero exit back to
0(process.exitCode = 1→0, mutation confirmed on disk by anchor counts and a blob-hash change beforeany reading was taken; restored with
git checkout HEAD --on an absolute pathunder an
EXIT/INT/TERMtrap, proven restored by blob-hash equality with theHEADblob and an emptygit diff HEAD) turns exactly 2 of 9 e2e cases red— the two exit-status pins for direction 1 — and leaves the other 7 green,
including both of directions 2 and 3. That asymmetry is the evidence: the pins
measure the exit status specifically, and the untouched populations are not
riding on it. No rebuild leg was needed and none was skipped:
packages/cli/distdoes not exist in this tree and the suite drives
bin/run-dev.js, sosrcisthe artifact under test.
Not settled here
The ruling is about the exit status. It does not say whether
migrate applyshould also decline to perform the DDL on this path, and this PR does not decide
that:
applydoes exactly what it did before and then exits non-zero. Worth amaintainer call as a follow-up, not a thing to widen scope over.
Verification run
Everything below was run at
542756251(this branch's head,origin/mainmerged in first so the gate-family derivation was not read off a stale tree):
pnpm --filter @objectstack/cli exec vitest run --maxWorkers=2over theblast radius of the changed modules — 9 files, 62 tests, all passing
(
schema-migration-plugins.test.ts, the sevenschema-migrate.*unit andintegration files, and the new e2e). The file set was enumerated with
grep -rlnover the test tree rather than guessed.pnpm --filter @objectstack/cli typecheck— clean.tsconfig.jsonsaysinclude: ["src"], so it does not readpackages/cli/test/**(a known,ledgered shape —
check-type-check-coverage.mjs,@objectstack/cliTEST_DEBT= 146 frozen errors). Verified separately with
--listFilesthat the new e2efile is absent from that program and then type-checked on its own under the
package's compiler options: zero errors, with a deliberately broken copy
used as the positive control that the probe can fail.
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstackand run:
check:nul-bytes,check:cli-command-ids,check:cli-test-child-env,check:cross-package-test-inputs,check:test-source-alias,check:type-check-coverage,check:objectql-double-limit,check:engine-double-contract,check:driver-memory-census,check:query-options-erasure,check:where-matcher,check:changeset-gate-self-tests,check:objectui-changeset,check:pm-half-states,check:published-files,check:page-declaration-shape,check:slot-lookup,check:type-source-resolution, pluscheck-adr-0087-registration,check-changeset-no-major,check-empty-changeset,check-ci-filter-parity,check-comment-mask-adoption,check-cross-package-test-inputs,check-keyed-text-bounds,check-plugin-teardown-shape,check-undeclared-dep-imports,check-shard-attestation,docs-audit/check-affected-docs,docs-audit/check-drift-comment,release-rehearsal-clone --self-test— allgreen.
run and said so in its own words:
check:type-check-debt(--re-measureneeds the workspace build closure;
@objectstack/service-knowledgehas nodist),check:i18n,check:i18n-coverageandcheck:dual-build-cjs-loads(all
PREREQUISITE NOT MET— they read built output),scripts/pm/check-half-states.mjs(exit 3, no valid GitHub credential in thiscontainer), and
check-test-completeness(a CI harness that takes a turbo testlog as an argument; invoked bare it prints usage, which is a mis-invocation and
not a verdict).
Generated by Claude Code