fix(FN-149): advance schema ceiling to 0065 so Fusion stops rejecting its own database

FN-149 shipped migration 0065_fn_149_review_convergence_stage.sql and registered
REVIEW_CONVERGENCE_STAGE_VERSION but left SCHEMA_BASELINE_VERSION at "0064". The first
store open applied and recorded 0065; the next open (project store, same boot) hit
assertBinaryNotOlderThanDatabase, saw 0065 > 0064 and threw StaleBinarySchemaError, so
every startup died with "this binary only knows up to 0064" on fresh and upgraded
databases alike.

- Bump SCHEMA_BASELINE_VERSION to "0065" (marker only: applies no SQL, touches no data).
- Move the DB-free migration-wiring assertions out of the PostgreSQL integration file
  into src/__tests__/migration-wiring-integrity.test.ts and wire it into test:unit-gate,
  so the ceiling/migration drift now fails the merge gate instead of main's boot.
- Refresh the stale migration-identity expectations (0062-0065).

Symptom verification: `node scripts/dev-with-memory.mjs --isolated=<tmp> --prebuild none`
exited 1 with the guard error before; it now boots and serves the dashboard.
This commit is contained in:
Fusion Agent
2026-08-22 19:04:03 +00:00
parent c1818ea819
commit 794dae3196
5 changed files with 98 additions and 36 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Fix a startup crash where Fusion rejected the database it had just migrated.
category: fix
dev: FN-149 shipped migration `0065_fn_149_review_convergence_stage.sql` without advancing `SCHEMA_BASELINE_VERSION` (still `"0064"`), so the first store open applied and recorded 0065 and the next open threw `StaleBinarySchemaError` from `assertBinaryNotOlderThanDatabase` ("this binary only knows up to 0064"), exiting 1 on fresh and upgraded databases alike. Bumps the ceiling to `"0065"` (marker only — applies no SQL, touches no data) and moves the DB-free migration-wiring assertions to `packages/core/src/__tests__/migration-wiring-integrity.test.ts`, now wired into `test:unit-gate` so a migration landing without a ceiling bump fails the merge gate.

View File

@@ -64,7 +64,7 @@
"test": "vitest run --silent=passed-only --reporter=dot",
"test:embedded-postgres": "vitest run src/__tests__/postgres/embedded-lifecycle.test.ts --silent=passed-only --reporter=dot",
"test:pg-gate": "FUSION_PG_TEST_SETUP_PARTICIPANT=1 vitest run --config vitest.pg.config.ts src/__tests__/postgres/handoff-to-review-atomicity.pg.test.ts src/__tests__/postgres/task-lifecycle-e2e.pg.test.ts --silent=passed-only --reporter=dot",
"test:unit-gate": "vitest run src/__tests__/task-merge.test.ts src/__tests__/legacy-adoption.test.ts src/__tests__/no-hardcoded-lifecycle-columns.test.ts src/__tests__/sync-workflow-ir-callsite-allowlist.test.ts --silent=passed-only --reporter=dot"
"test:unit-gate": "vitest run src/__tests__/task-merge.test.ts src/__tests__/legacy-adoption.test.ts src/__tests__/no-hardcoded-lifecycle-columns.test.ts src/__tests__/sync-workflow-ir-callsite-allowlist.test.ts src/__tests__/migration-wiring-integrity.test.ts --silent=passed-only --reporter=dot"
},
"devDependencies": {
"@earendil-works/pi-coding-agent": "0.84.1",

View File

@@ -0,0 +1,55 @@
/*
FNXC:Lifecycle 2026-07-16-22:40:
Migration wiring integrity — the class guard for the FN-8141 crash. Migrations are
registered EXPLICITLY in schema-applier.ts (not auto-discovered), so a new .sql
file that is not wired through a version constant + bookkeeping check silently
never runs (documented hazard). PR #2260 tripped the adjacent trap: it added a
column to the model + 0000 baseline and bumped nothing, so existing DBs never got
it.
FNXC:ReviewConvergence 2026-08-22-18:58:
These assertions used to live inside src/__tests__/postgres/schema-applier.test.ts, whose comment
claimed they "run in the merge gate" — they did not: the gate runs four named files via
`test:unit-gate` plus two *.pg.test.ts files, and that PostgreSQL-integration file is in neither.
The drift it was meant to catch then landed twice (0064, then FN-149's 0065 with the ceiling left
at 0064), and the second one made every Fusion startup fail: the binary applied 0065, recorded it,
then rejected its own database through assertBinaryNotOlderThanDatabase.
They are moved here — a file with no PostgreSQL dependency, no fixtures and no timers, reading only
the migrations directory and the applier source — precisely so `test:unit-gate` can run them
deterministically in milliseconds. Gate admission evidence: a bootable `main` is the cheapest thing
this repository can verify, and this exact drift broke it. Keep this file DB-free; anything needing
a live database belongs in the PostgreSQL suite instead.
*/
import { describe, it, expect } from "vitest";
import { readdirSync, readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { SCHEMA_BASELINE_VERSION } from "../postgres/schema-applier.js";
describe("schema-applier: migration wiring integrity", () => {
const migrationsDir = fileURLToPath(new URL("../postgres/migrations", import.meta.url));
const applierSource = readFileSync(
fileURLToPath(new URL("../postgres/schema-applier.ts", import.meta.url)),
"utf8",
);
const migrationFiles = readdirSync(migrationsDir)
.filter((f) => /^\d{4}_.*\.sql$/.test(f))
.sort();
it("advances SCHEMA_BASELINE_VERSION to the highest-numbered migration file", () => {
const highest = migrationFiles[migrationFiles.length - 1]!.slice(0, 4);
// A new column that ships a migration file must also bump the baseline marker
// (else the "all markers recorded" fast-path and upgrade bookkeeping drift, and
// the stale-binary guard rejects the database this very binary just migrated).
expect(SCHEMA_BASELINE_VERSION).toBe(highest);
});
it("wires every migration .sql file into the applier so none silently never runs", () => {
// The applier references each migration by its exact basename in a path
// constant. A file present on disk but absent from the source is unwired.
const unwired = migrationFiles.filter((f) => !applierSource.includes(f));
expect(unwired).toEqual([]);
});
});

View File

@@ -25,7 +25,7 @@ import { describe, it, expect, afterEach, beforeAll } from "vitest";
import postgres from "postgres";
import { drizzle } from "drizzle-orm/postgres-js";
import { sql } from "drizzle-orm";
import { readdirSync, readFileSync } from "node:fs";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import {
applySchemaBaseline,
@@ -104,6 +104,10 @@ import {
TASK_SOURCE_AGENT_INDEX_VERSION,
WORKSPACE_COORDINATION_LEASES_SCHEMA_VERSION,
ACTIVITY_LOG_TASK_ID_INDEX_VERSION,
REMOVE_TASK_SUBTASK_SPLITTING_VERSION,
AI_MERGE_REVIEW_RECONCILIATION_VERSION,
TASK_REPOSITORY_SCOPE_VERSION,
REVIEW_CONVERGENCE_STAGE_VERSION,
} from "../../postgres/schema-applier.js";
import { ProjectPartitionRekeyError, rekeyFallbackProjectPartition } from "../../postgres/migration-stamping.js";
import type { PluginSchemaInitHook } from "../../postgres/plugin-schema-hook.js";
@@ -139,7 +143,18 @@ describe("schema-applier: immutable migration identities", () => {
expect(TASK_SOURCE_AGENT_INDEX_VERSION).toBe("0059");
expect(WORKSPACE_COORDINATION_LEASES_SCHEMA_VERSION).toBe("0060");
expect(ACTIVITY_LOG_TASK_ID_INDEX_VERSION).toBe("0061");
expect(SCHEMA_BASELINE_VERSION).toBe("0063");
/*
FNXC:ReviewConvergence 2026-08-22-18:58:
The tail of this list went stale twice in a row (it still asserted 0063 while the ceiling was
0064), so it stopped being the tripwire it exists to be. Assert every identity through the
current head and keep the ceiling equal to the newest one — FN-149 shipped 0065 with the marker
left at 0064, and that mismatch made the binary reject its own database at startup.
*/
expect(REMOVE_TASK_SUBTASK_SPLITTING_VERSION).toBe("0062");
expect(AI_MERGE_REVIEW_RECONCILIATION_VERSION).toBe("0063");
expect(TASK_REPOSITORY_SCOPE_VERSION).toBe("0064");
expect(REVIEW_CONVERGENCE_STAGE_VERSION).toBe("0065");
expect(SCHEMA_BASELINE_VERSION).toBe("0065");
});
it("keeps monitor and approval isolation assigned to version 0003", () => {
@@ -272,39 +287,13 @@ describe("schema-applier: immutable migration identities", () => {
});
/*
FNXC:Lifecycle 2026-07-16-22:40:
Migration wiring integrity — the class guard for the FN-8141 crash. Migrations are
registered EXPLICITLY in schema-applier.ts (not auto-discovered), so a new .sql
file that is not wired through a version constant + bookkeeping check silently
never runs (documented hazard). PR #2260 tripped the adjacent trap: it added a
column to the model + 0000 baseline and bumped nothing, so existing DBs never got
it. These pure (no-PostgreSQL) assertions run in the merge gate and fail fast when
the baseline marker and the on-disk migration set drift out of sync.
FNXC:ReviewConvergence 2026-08-22-18:58:
The pure "migration wiring integrity" assertions (baseline ceiling == highest migration file, and
every .sql wired into the applier) MOVED to src/__tests__/migration-wiring-integrity.test.ts. They
need no PostgreSQL, and living in this integration file kept them out of the merge gate — which is
how FN-149 shipped migration 0065 with the ceiling left at 0064 and made every startup reject its
own database. The new home is wired into `test:unit-gate`. Do not re-add them here.
*/
describe("schema-applier: migration wiring integrity", () => {
const migrationsDir = fileURLToPath(new URL("../../postgres/migrations", import.meta.url));
const applierSource = readFileSync(
fileURLToPath(new URL("../../postgres/schema-applier.ts", import.meta.url)),
"utf8",
);
const migrationFiles = readdirSync(migrationsDir)
.filter((f) => /^\d{4}_.*\.sql$/.test(f))
.sort();
it("advances SCHEMA_BASELINE_VERSION to the highest-numbered migration file", () => {
const highest = migrationFiles[migrationFiles.length - 1]!.slice(0, 4);
// A new column that ships a migration file must also bump the baseline marker
// (else the "all markers recorded" fast-path and upgrade bookkeeping drift).
expect(SCHEMA_BASELINE_VERSION).toBe(highest);
});
it("wires every migration .sql file into the applier so none silently never runs", () => {
// The applier references each migration by its exact basename in a path
// constant. A file present on disk but absent from the source is unwired.
const unwired = migrationFiles.filter((f) => !applierSource.includes(f));
expect(unwired).toEqual([]);
});
});
interface TestContext {
testUrl: string;

View File

@@ -66,7 +66,18 @@ capacity-model table drop that landed while this PR was open.
/* FNXC:TaskRecommendations 2026-08-13-22:23: upgrades must install the source-agent index before duplicate intake queries it. */
/* FNXC:WorkspaceLease 2026-08-15-12:00: the baseline ceiling must include durable coordination tables so an upgraded database is never rejected by the current binary. */
/* FNXC:ActivityLogTaskSearch 2026-08-20-04:17: advance the schema ceiling so durable central task-ID lookups receive their indexed upgrade. */
export const SCHEMA_BASELINE_VERSION = "0064";
/*
FNXC:ReviewConvergence 2026-08-22-18:58:
Advance the ceiling to 0065 for FN-149's review-convergence columns. FN-149 registered
REVIEW_CONVERGENCE_STAGE_VERSION and wired the migration but left this marker at 0064, which is a
SELF-REJECTION rather than a compatibility guard: the first open applies 0065 and records it in
fusion_schema_migrations, then the NEXT open (the project store, in the same boot) hits
assertBinaryNotOlderThanDatabase, sees 0065 > 0064, and throws StaleBinarySchemaError. Every
startup died with "this binary only knows up to 0064" on fresh and upgraded databases alike. This
marker is ONLY the binary's "highest migration I know" claim — bumping it applies no SQL and
touches no data; it must advance in the same change that ships a new migration file.
*/
export const SCHEMA_BASELINE_VERSION = "0065";
/** FNXC:SymbolLock 2026-07-20-10:00: upgrades need durable task declarations before admission resolves symbols. */
export const TASK_DECLARED_SYMBOLS_VERSION = "0028";
const INITIAL_SCHEMA_VERSION = "0000";