fix(core): repair a renumbered migration whose ledger row outlived its column
Reported from a dev instance: `column "memory_focus" does not exist` on every chat-session read, so the task planner chat 500s and never opens — with a startup that reports success. A ledger row asserts "a migration with this NUMBER ran". That is not the same claim as "this COLUMN exists" once a migration has been renumbered, and this one was renumbered four times — 0059 -> 0060 -> 0061 -> 0065 -> 0066 — each time because an upstream batch claimed the sequence first. A database can therefore carry a row from one numbering while a different migration owned that number on the boot that recorded it. The applier trusts the ledger absolutely, skips the migration, and leaves a schema that does not match it. Nothing fails at startup; everything fails afterwards, because Drizzle's `select()` emits the binary's full column list and one missing column breaks every read of the table. The defence already existed one table over: `0047` task recommendations verifies its materialized column in addition to the marker and replays its idempotent SQL. The lesson had been learned and not generalized. Both migrations renumbered on this branch — 0066 memory focus and 0067 session contention wait state — now carry it, and both SQL files are `ADD COLUMN IF NOT EXISTS`, so a replay over a healthy schema costs nothing. Two PostgreSQL regression tests reproduce the drifted state exactly (marker present, column dropped) and prove the replay materializes the column and stays idempotent on a second pass. pnpm lint 0 errors, test:gate green, core typecheck clean, schema-applier 80/80 against a real PostgreSQL.
This commit is contained in:
7
.changeset/repair-renumbered-migration-drift.md
Normal file
7
.changeset/repair-renumbered-migration-drift.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Fixes chat failing with "column memory_focus does not exist" — the missing column is now repaired at startup.
|
||||
category: fix
|
||||
dev: A ledger row asserts that a migration with a given NUMBER ran, which is not the same claim as "this column exists" once a migration has been renumbered. `0066_chat_session_memory_focus.sql` was renumbered four times (0059 → 0060 → 0061 → 0065 → 0066) as upstream batches claimed each sequence, so a database can carry a row from one numbering while a different migration owned that number on the boot that recorded it. The applier then trusts the ledger, skips the migration, and reports a successful startup over a schema that does not match it; every `chat_sessions` read then fails with `column "memory_focus" does not exist`, because Drizzle's `select()` emits the binary's full column list. Both migrations renumbered on this branch (0066 memory focus, 0067 session contention wait state) now verify their materialized columns in addition to the marker and replay their idempotent `ADD COLUMN IF NOT EXISTS` when a column is absent — the same defence `0047` task recommendations already carried. Covered by two PostgreSQL regression tests that reproduce the drifted state exactly.
|
||||
@@ -781,6 +781,80 @@ pgDescribe("schema-applier: VAL-SCHEMA-001 final-schema parity (table counts)",
|
||||
await assertTaskLifecycleOutboxOwnershipContract(ctx);
|
||||
});
|
||||
|
||||
/*
|
||||
FNXC:MemoryFocus 2026-08-26-08:31:
|
||||
THE LEDGER CAN LIE ABOUT A RENUMBERED MIGRATION.
|
||||
|
||||
A ledger row asserts "a migration with this NUMBER ran". The memory-focus migration was renumbered
|
||||
four times (0059 → 0060 → 0061 → 0065 → 0066), each time because an upstream batch claimed the
|
||||
sequence first, so a database can carry a row for one numbering while a different migration owned
|
||||
that number on the boot that recorded it. The applier then trusts the ledger absolutely, skips the
|
||||
migration, and reports a successful startup over a schema that does not match it.
|
||||
|
||||
Reproduced from a real dev database: `column "memory_focus" does not exist` on every chat-session
|
||||
read — `select()` emits the binary's full column list — so every chat query 500s and the task
|
||||
planner chat never opens, with nothing wrong at startup.
|
||||
|
||||
The repair is the same one `recommendations` already carries: verify the materialized column, not
|
||||
only the marker, and replay the idempotent `ADD COLUMN IF NOT EXISTS`.
|
||||
*/
|
||||
it("repairs a database whose ledger claims memory focus but whose column is missing", async () => {
|
||||
ctx = await setupFreshDb();
|
||||
await applySchemaBaseline(ctx.db, { pluginHooks: [] });
|
||||
|
||||
// The exact drifted state: marker present, column absent.
|
||||
await ctx.db.execute(sql.raw(`ALTER TABLE project.chat_sessions DROP COLUMN memory_focus;`));
|
||||
expect(await getAppliedMigrations(ctx.db)).toContain(CHAT_SESSION_MEMORY_FOCUS_VERSION);
|
||||
|
||||
expect((await applySchemaBaseline(ctx.db, { pluginHooks: [] })).applied).toBe(true);
|
||||
|
||||
const columns = (await ctx.db.execute(sql`
|
||||
SELECT column_name
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'project' AND table_name = 'chat_sessions' AND column_name = 'memory_focus'
|
||||
`)) as unknown as Array<{ column_name: string }>;
|
||||
expect(columns, "the replay must materialize the column the ledger already claimed").toHaveLength(1);
|
||||
expect(await getAppliedMigrations(ctx.db)).toContain(CHAT_SESSION_MEMORY_FOCUS_VERSION);
|
||||
|
||||
// Idempotent: a second pass over a healthy schema changes nothing and still succeeds.
|
||||
await applySchemaBaseline(ctx.db, { pluginHooks: [] });
|
||||
const afterSecondPass = (await ctx.db.execute(sql`
|
||||
SELECT column_name
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'project' AND table_name = 'chat_sessions' AND column_name = 'memory_focus'
|
||||
`)) as unknown as Array<{ column_name: string }>;
|
||||
expect(afterSecondPass).toHaveLength(1);
|
||||
});
|
||||
|
||||
/*
|
||||
FNXC:WorkspaceContention 2026-08-26-08:31:
|
||||
The other migration renumbered on this branch (0066 → 0067, because released chat memory focus owns
|
||||
0066) carries the identical hazard and therefore the identical defence.
|
||||
*/
|
||||
it("repairs a database whose ledger claims session contention wait state but whose columns are missing", async () => {
|
||||
ctx = await setupFreshDb();
|
||||
await applySchemaBaseline(ctx.db, { pluginHooks: [] });
|
||||
|
||||
await ctx.db.execute(sql.raw(`
|
||||
ALTER TABLE project.tasks
|
||||
DROP COLUMN session_contention_hold_count,
|
||||
DROP COLUMN session_contention_wait_reason;
|
||||
`));
|
||||
expect(await getAppliedMigrations(ctx.db)).toContain(SESSION_CONTENTION_WAIT_STATE_VERSION);
|
||||
|
||||
expect((await applySchemaBaseline(ctx.db, { pluginHooks: [] })).applied).toBe(true);
|
||||
|
||||
const columns = (await ctx.db.execute(sql`
|
||||
SELECT column_name
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'project' AND table_name = 'tasks'
|
||||
AND column_name IN ('session_contention_hold_count', 'session_contention_wait_reason')
|
||||
ORDER BY column_name
|
||||
`)) as unknown as Array<{ column_name: string }>;
|
||||
expect(columns.map((row) => row.column_name))
|
||||
.toEqual(["session_contention_hold_count", "session_contention_wait_reason"]);
|
||||
});
|
||||
|
||||
/*
|
||||
FNXC:PostgresMigrationColumnCoverage 2026-07-14-13:17:
|
||||
A cluster that already recorded migrations through 0006 must receive every late SQLite column before cutover retries. This is the production failure shape: the initial copy is blocked while the target schema is otherwise fully initialized.
|
||||
|
||||
@@ -1387,13 +1387,64 @@ export async function applySchemaBaseline(
|
||||
}
|
||||
|
||||
/* FNXC:MemoryFocus 2026-08-14-10:30: register 0066 explicitly (renumbered from 0061 on 2026-08-20 — the upstream FN-066..FN-094 batch owns 0061-0064 — and from 0065 on 2026-08-23 when upstream's FN-149 claimed 0065). */
|
||||
if (!chatSessionMemoryFocusAlreadyApplied) {
|
||||
/*
|
||||
FNXC:MemoryFocus 2026-08-26-08:31:
|
||||
VERIFY THE COLUMN, NOT ONLY THE LEDGER ROW — the same defence `recommendations` already carries
|
||||
above, for the same reason, one table over.
|
||||
|
||||
A ledger row asserts "a migration with this NUMBER ran". For a migration renumbered four times
|
||||
(0059 → 0060 → 0061 → 0065 → 0066, each time because upstream claimed the sequence first) that is
|
||||
not the same claim as "this COLUMN exists": a database carrying a row from one numbering while
|
||||
another migration owned that number on the boot that recorded it converges to a ledger the applier
|
||||
trusts absolutely and a schema that does not match it.
|
||||
|
||||
Measured on a real dev database: `column "memory_focus" does not exist` on every chat-session read,
|
||||
because `select()` emits the binary's full column list. Every chat query 500s, the task planner
|
||||
chat never opens, and startup reports success — the applier had nothing left to do.
|
||||
|
||||
The SQL is `ADD COLUMN IF NOT EXISTS`, so replaying it is free when the column is already there.
|
||||
A settings-only schema has no chat_sessions relation and stays a no-op.
|
||||
*/
|
||||
const chatSessionMemoryFocusColumnState = (await tx.execute(sql`
|
||||
SELECT
|
||||
to_regclass('project.chat_sessions') IS NOT NULL AS chat_sessions_exists,
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'project'
|
||||
AND table_name = 'chat_sessions'
|
||||
AND column_name = 'memory_focus'
|
||||
) AS memory_focus_exists
|
||||
`)) as unknown as Array<{ chat_sessions_exists: boolean; memory_focus_exists: boolean }>;
|
||||
const chatSessionMemoryFocusColumnMissing = chatSessionMemoryFocusColumnState[0]?.chat_sessions_exists
|
||||
&& !chatSessionMemoryFocusColumnState[0]?.memory_focus_exists;
|
||||
if (!chatSessionMemoryFocusAlreadyApplied || chatSessionMemoryFocusColumnMissing) {
|
||||
const migrationSql = await readFile(CHAT_SESSION_MEMORY_FOCUS_MIGRATION_PATH, "utf8");
|
||||
await tx.execute(sql.raw(migrationSql));
|
||||
await tx.execute(sql`INSERT INTO public.${sql.identifier(MIGRATION_BOOKKEEPING_TABLE)} (version) VALUES (${CHAT_SESSION_MEMORY_FOCUS_VERSION}) ON CONFLICT (version) DO NOTHING`);
|
||||
schemaChanged = true;
|
||||
}
|
||||
if (!sessionContentionWaitStateAlreadyApplied) {
|
||||
/*
|
||||
FNXC:WorkspaceContention 2026-08-26-08:31:
|
||||
The OTHER renumbered migration on this branch (0066 → 0067, because released chat memory focus
|
||||
owns 0066) carries the identical ledger-versus-schema hazard, so it takes the identical defence:
|
||||
verify the materialized columns, not only the marker. Its SQL is `ADD COLUMN IF NOT EXISTS`, so a
|
||||
replay over a healthy schema is free.
|
||||
*/
|
||||
const sessionContentionColumnState = (await tx.execute(sql`
|
||||
SELECT
|
||||
to_regclass('project.tasks') IS NOT NULL AS tasks_exists,
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'project'
|
||||
AND table_name = 'tasks'
|
||||
AND column_name = 'session_contention_wait_reason'
|
||||
) AS wait_reason_exists
|
||||
`)) as unknown as Array<{ tasks_exists: boolean; wait_reason_exists: boolean }>;
|
||||
const sessionContentionColumnsMissing = sessionContentionColumnState[0]?.tasks_exists
|
||||
&& !sessionContentionColumnState[0]?.wait_reason_exists;
|
||||
if (!sessionContentionWaitStateAlreadyApplied || sessionContentionColumnsMissing) {
|
||||
const migrationSql = await readFile(SESSION_CONTENTION_WAIT_STATE_MIGRATION_PATH, "utf8");
|
||||
await tx.execute(sql.raw(migrationSql));
|
||||
await tx.execute(sql`INSERT INTO public.${sql.identifier(MIGRATION_BOOKKEEPING_TABLE)} (version) VALUES (${SESSION_CONTENTION_WAIT_STATE_VERSION}) ON CONFLICT (version) DO NOTHING`);
|
||||
|
||||
Reference in New Issue
Block a user