diff --git a/.changeset/migrate-apply-refuses-before-ddl.md b/.changeset/migrate-apply-refuses-before-ddl.md new file mode 100644 index 0000000000..1e7efbbcab --- /dev/null +++ b/.changeset/migrate-apply-refuses-before-ddl.md @@ -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. diff --git a/packages/cli/src/commands/migrate/apply.ts b/packages/cli/src/commands/migrate/apply.ts index ed8eeb8b15..407fa0a670 100644 --- a/packages/cli/src/commands/migrate/apply.ts +++ b/packages/cli/src/commands/migrate/apply.ts @@ -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'; @@ -56,6 +57,14 @@ async function confirm(question: string): Promise { * 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 = @@ -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); } @@ -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; } diff --git a/packages/cli/src/utils/schema-migration-plugins.test.ts b/packages/cli/src/utils/schema-migration-plugins.test.ts index 0760a34aef..df567cafe9 100644 --- a/packages/cli/src/utils/schema-migration-plugins.test.ts +++ b/packages/cli/src/utils/schema-migration-plugins.test.ts @@ -10,6 +10,7 @@ import { buildSchemaMigrationPlugins, measureComposedCoverage, describeUnloadableHostConfig, + NO_DDL_EXECUTED_NOTICE, type SchemaMigrationComposition, } from './schema-migration-plugins.js'; @@ -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 { + 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. * diff --git a/packages/cli/src/utils/schema-migration-plugins.ts b/packages/cli/src/utils/schema-migration-plugins.ts index 02c2d131cc..242e3fe2b0 100644 --- a/packages/cli/src/utils/schema-migration-plugins.ts +++ b/packages/cli/src/utils/schema-migration-plugins.ts @@ -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'; @@ -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.' ); @@ -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}`); diff --git a/packages/cli/test/migrate-apply-refuses-before-ddl.e2e.test.ts b/packages/cli/test/migrate-apply-refuses-before-ddl.e2e.test.ts new file mode 100644 index 0000000000..2f9d37e910 --- /dev/null +++ b/packages/cli/test/migrate-apply-refuses-before-ddl.e2e.test.ts @@ -0,0 +1,292 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #13118 — `os migrate apply` REFUSES BEFORE WRITING ANY DDL when a host + * `objectstack.config.{ts,js,mjs}` exists but could not be loaded. + * + * ## What this inverts, measured on this fixture before the change + * + * #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 here on `origin/main` 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. + * + * Maintainer ruling 2026-08-29, verbatim 「同意」, option 2: refuse first, write + * no DDL, exit non-zero. Option 3 (a flag that lets the mutation through) was + * refused in the same ruling. + * + * ## Why the assertion is a SCHEMA READ and not an exit code + * + * The exit code on this path is already non-zero on `origin/main` — #13113 + * shipped it. A test that only checked the status would pass identically + * before and after this change and would prove nothing new. What changed is + * whether the database was touched, so that is what is read: `sqlite_master`, + * through a connection of this test's own, after the command has exited. + * + * ⚠️ Deliberately not a hash of the database FILE — SQLite rewrites header + * bytes on any read-write open, so a file hash reports a difference after a + * run that only opened the database (`duplicates.integration.test.ts` carries + * that measurement). What must not change is the SCHEMA. + * + * ## The positive control is built in, and it is directions 2 and 3 + * + * "Zero tables" is worthless as a reading unless the same probe, run the same + * way, can see tables when tables exist. Directions 2 and 3 are exactly that + * control: config ABSENT and config LOADABLE both still create the platform + * floor, through the same command, read by the same helper, in the same run. + * If `readSchema()` were blind — wrong path, wrong file, a probe that silently + * connected to an empty in-memory database — those two go red and direction 1 + * stays green. That pairing is the reason all three directions live in ONE + * file rather than in a file per direction. + * + * And they are also the ruling's own scope pins: `absent ⇒ 不变`, + * `present+loadable ⇒ 不变`. Here "unchanged" now means unchanged in BOTH + * halves — status and mutation — which is the half #12953 could not state. + * + * ## Why a real child process + * + * Same reason as `migrate-unloadable-host-config-exit.e2e.test.ts`: the claim + * is about a command an operator runs, and `process.exitCode` set inside a + * vitest worker is not an exit status. Spawned through `bin/run-dev.js` + tsx, + * so the suite does not depend on `packages/cli/dist` having been built. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { execFile } from 'node:child_process'; +import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { CLI, TSX, childEnv } from './helpers/serve-process.js'; +import { NO_DDL_EXECUTED_NOTICE } from '../src/utils/schema-migration-plugins.js'; + +/** + * The environment variable the unloadable fixture demands. + * + * Namespaced to this test and explicitly unset in the child, so the fixture + * fails for a reason the runner cannot accidentally satisfy — an inherited + * value would turn direction 1 into direction 3 while every assertion in it + * kept reading as a pass. + */ +const REQUIRED_VAR = 'OS_E2E_13118_SECRET'; + +/** Generous: a cold tsx compile of the command tree dominates a ~1 s apply. */ +const RUN_BUDGET_MS = 120_000; + +interface Run { + code: number; + stdout: string; + stderr: string; +} + +function runCli(args: string[], cwd: string): Promise { + return new Promise((resolvePromise) => { + execFile( + TSX, + [CLI, ...args], + { + cwd, + maxBuffer: 16 * 1024 * 1024, + env: childEnv({ NO_COLOR: '1', [REQUIRED_VAR]: undefined }), + }, + (err, stdout, stderr) => { + resolvePromise({ + // `err.code` is the real exit status. `null`/undefined means the + // child was SIGNALLED — a different failure, never reported as 0. + code: err + ? (typeof (err as { code?: unknown }).code === 'number' + ? (err as unknown as { code: number }).code + : 1) + : 0, + stdout: String(stdout), + stderr: String(stderr), + }); + }, + ); + }); +} + +/** + * The tables a project's database actually holds, read with a connection of + * our own after the command has exited. + * + * A MISSING database file answers `[]` without connecting — connecting would + * CREATE it, which would make this probe a writer and its "no tables" reading + * a self-fulfilling one. + */ +async function readTables(projectDir: string): Promise { + const dbFile = join(projectDir, '.objectstack', 'data', 'objectstack.db'); + if (!existsSync(dbFile)) return []; + const probe = new SqlDriver({ + client: 'better-sqlite3', + connection: { filename: dbFile }, + useNullAsDefault: true, + }); + try { + // The driver's own knex handle, typed structurally to exactly the two calls + // made here. `duplicates.integration.test.ts` reaches it as `(probe as any)`; + // spelling the shape instead keeps this file free of `any` and keeps the + // package's TEST_DEBT ratchet where it was. + type CatalogRow = { type: string; name: string }; + type KnexLike = (table: string) => { + select: (...columns: string[]) => Promise; + }; + const knex = (probe as unknown as { knex: KnexLike }).knex; + const rows = await knex('sqlite_master').select('type', 'name'); + return rows + .filter((r) => r.type === 'table' && !r.name.startsWith('sqlite_')) + .map((r) => r.name) + .sort(); + } finally { + await probe.disconnect(); + } +} + +/** A config that is PRESENT and throws while loading — direction 1. */ +const UNLOADABLE_CONFIG = [ + `const secret = process.env.${REQUIRED_VAR};`, + 'if (!secret) {', + ` throw new Error('Missing required environment variable ${REQUIRED_VAR}');`, + '}', + '', + "export default { name: 'unloadable_13118', label: 'Unloadable 13118', objects: [] };", + '', +].join('\n'); + +/** A config that is PRESENT and loads, and declares a table of its own — direction 3. */ +const LOADABLE_CONFIG = [ + 'export default {', + " name: 'loadable_13118',", + " label: 'Loadable 13118',", + ' objects: [{', + " name: 'lo_ticket',", + " label: 'Ticket',", + " fields: { title: { type: 'text', label: 'Title' } },", + ' }],', + '};', + '', +].join('\n'); + +const dirs: string[] = []; +function project(config: string | null): string { + const dir = mkdtempSync(join(tmpdir(), 'os-13118-e2e-')); + dirs.push(dir); + if (config !== null) writeFileSync(join(dir, 'objectstack.config.ts'), config); + return dir; +} + +describe('os migrate apply refuses BEFORE any DDL on an unloadable host config (#13118)', () => { + let unloadableApply: Run; + let unloadableTables: string[]; + let absentApply: Run; + let absentTables: string[]; + let loadableApply: Run; + let loadableTables: string[]; + + beforeAll(async () => { + const unloadable = project(UNLOADABLE_CONFIG); + const absent = project(null); + const loadable = project(LOADABLE_CONFIG); + + // Sequential on purpose: each run boots a kernel, and this suite shares a + // box with whatever else CI is running. + unloadableApply = await runCli(['migrate', 'apply', '--yes', '--json'], unloadable); + unloadableTables = await readTables(unloadable); + absentApply = await runCli(['migrate', 'apply', '--yes', '--json'], absent); + absentTables = await readTables(absent); + loadableApply = await runCli(['migrate', 'apply', '--yes', '--json'], loadable); + loadableTables = await readTables(loadable); + }, RUN_BUDGET_MS * 3); + + afterAll(() => { + while (dirs.length > 0) { + const dir = dirs.pop()!; + try { rmSync(dir, { recursive: true, force: true }); } catch { /* best-effort */ } + } + }); + + describe('direction 1 — config PRESENT and unloadable: non-zero AND zero DDL', () => { + it('exits non-zero', () => { + // #13113's half, re-read here only so the next assertion is about a run + // that did refuse rather than one that quietly succeeded. + expect(unloadableApply.code).not.toBe(0); + }); + + it('⭐ wrote NO DDL — the database holds no tables at all', () => { + // This is the whole card. On `origin/main` the same run left 9 tables + // here. Directions 2 and 3 below prove this probe can see tables. + expect(unloadableTables).toEqual([]); + }); + + it('says so in the refusal, on stderr, on top of the #12953 wording', () => { + // The ruling: reuse the ruled wording AND state explicitly that no DDL + // ran, so an operator does not have to guess whether the database was + // touched. The notice is imported rather than re-spelled — a copy here + // would keep passing the day the sentence moves. + expect(unloadableApply.stderr).toContain('could not be loaded'); + expect(unloadableApply.stderr).toContain('UNMEASURED'); + expect(unloadableApply.stderr).toMatch(/Remedy:/); + expect(unloadableApply.stderr).toContain(NO_DDL_EXECUTED_NOTICE.trim()); + }); + + it('reports the refusal in the --json document too, with nothing applied', () => { + const payload = JSON.parse(unloadableApply.stdout) as { + message?: string; + created?: unknown[]; + applied?: unknown[]; + composition?: { hostConfig?: string; hostConfigLoaded?: boolean }; + }; + expect(payload.message).toBe('refused_unloadable_host_config'); + expect(payload.created).toEqual([]); + expect(payload.applied).toEqual([]); + // #12953 kept `hostConfigLoaded` as the machine discriminator its + // consumers read; the refusal must not take it away with it. + expect(payload.composition?.hostConfigLoaded).toBe(false); + expect(payload.composition?.hostConfig).toContain('objectstack.config.ts'); + }); + }); + + describe('direction 2 — there is NO host config: unchanged, floor still created', () => { + it('keeps exit 0', () => { + expect(absentApply.code).toBe(0); + }); + + it('still CREATES the data stack — and proves the probe can see tables', () => { + // ⚠️ The data stack, NOT the platform floor. With neither a host config + // nor a compiled artifact `buildSchemaMigrationPlugins()` composes + // nothing at all (its own header: "the five-table data stack is the + // honest answer"), so `PlatformObjectsPlugin` — and with it + // `sys_migration` — is absent on this shape by design. Written against + // what the shape actually produces rather than against the floor, which + // is direction 3's business. + expect(absentTables.length).toBeGreaterThan(0); + expect(absentTables).toContain('sys_metadata'); + }); + + it('emits no refusal', () => { + expect(absentApply.stderr).not.toContain('could not be loaded'); + expect(absentApply.stderr).not.toContain(NO_DDL_EXECUTED_NOTICE.trim()); + }); + }); + + describe("direction 3 — the host config LOADS: unchanged, the deployment's tables land", () => { + it('keeps exit 0', () => { + expect(loadableApply.code).toBe(0); + }); + + it("CREATES the config's own object table as well as the floor", () => { + expect(loadableTables).toContain('lo_ticket'); + expect(loadableTables).toContain('sys_migration'); + }); + + it('emits no refusal', () => { + expect(loadableApply.stderr).not.toContain('could not be loaded'); + expect(loadableApply.stderr).not.toContain(NO_DDL_EXECUTED_NOTICE.trim()); + }); + }); +});