fix(FN-8141): add forward migration for tasks.bulk_completion_refusal_at

PR #2260 added project.tasks.bulk_completion_refusal_at to the Drizzle model
and the 0000 baseline but shipped no forward migration. Databases created
before #2260 already carry the 0000 marker, so the applier skips the baseline
and they never gained the column — every such cluster crashed on the first
TaskStore SELECT ("column bulk_completion_refusal_at does not exist"), taking
down dashboard/app boot.

Adds forward migration 0018 (wired via BULK_COMPLETION_REFUSAL_AT_VERSION;
SCHEMA_BASELINE_VERSION -> "0018") so existing clusters heal on next startup.

Prevention:
- Per-column upgrade regression test reproducing the exact existing-DB failure.
- Migration-wiring-integrity guard (no PostgreSQL): SCHEMA_BASELINE_VERSION must
  equal the highest migration file, and every .sql must be registered in the
  applier so none silently never runs.
- Repairs 6 pre-existing schema-applier tests left stale by the 0017 addition
  (baseline-marker identity + version-list enumerations).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-07-16 22:45:00 -07:00
parent da7c68c1dc
commit d1e9b563f7
4 changed files with 134 additions and 2 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Fix a dashboard/app boot crash on databases created before the bulk-completion-refusal change.
category: fix
dev: PR #2260 added project.tasks.bulk_completion_refusal_at to the Drizzle model and the 0000 baseline but shipped no forward migration, so any pre-existing PostgreSQL database (already carrying the 0000 marker) never gained the column and crashed on the first TaskStore SELECT. Adds forward migration 0018 (wired via BULK_COMPLETION_REFUSAL_AT_VERSION, SCHEMA_BASELINE_VERSION → "0018"), a per-column upgrade regression test, and a migration-wiring-integrity guard (baseline marker must equal the highest migration file; every .sql must be registered).

View File

@@ -26,6 +26,8 @@ import postgres from "postgres";
import { drizzle } from "drizzle-orm/postgres-js";
import { sql } from "drizzle-orm";
import { execSync } from "node:child_process";
import { readdirSync, readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import {
applySchemaBaseline,
getAppliedMigrations,
@@ -53,6 +55,8 @@ import {
EXECUTOR_TOOL_FAILURE_RETRY_VERSION,
EXECUTOR_ESCALATION_ATTEMPT_VERSION,
GLOBAL_ROUTINES_SCHEMA_VERSION,
TASK_MERGER_MODEL_LANE_VERSION,
BULK_COMPLETION_REFUSAL_AT_VERSION,
PROJECT_OWNERSHIP_SCHEMA_VERSION,
SESSION_ADVISOR_ENABLED_SCHEMA_VERSION,
SQLITE_SCHEMA_PARITY_VERSION,
@@ -135,7 +139,55 @@ describe("schema-applier: immutable migration identities", () => {
it("keeps the import translation scope fix assigned to version 0016", () => {
expect(IMPORT_TRANSLATION_CACHE_SCOPE_FIX_VERSION).toBe("0016");
expect(SCHEMA_BASELINE_VERSION).toBe(IMPORT_TRANSLATION_CACHE_SCOPE_FIX_VERSION);
// FNXC:PostgresMigrationIdentity 2026-07-16-22:40: the baseline marker advanced
// past 0016 (0017 merger lane, 0018 bulk-completion-refusal). 0016 keeps its
// immutable identity and remains applied at-or-before the latest marker.
expect(Number(SCHEMA_BASELINE_VERSION)).toBeGreaterThanOrEqual(Number(IMPORT_TRANSLATION_CACHE_SCOPE_FIX_VERSION));
});
it("keeps the per-task merger model lane assigned to version 0017", () => {
expect(TASK_MERGER_MODEL_LANE_VERSION).toBe("0017");
expect(Number(SCHEMA_BASELINE_VERSION)).toBeGreaterThanOrEqual(Number(TASK_MERGER_MODEL_LANE_VERSION));
});
it("keeps the bulk-completion-refusal marker assigned to version 0018", () => {
expect(BULK_COMPLETION_REFUSAL_AT_VERSION).toBe("0018");
expect(Number(SCHEMA_BASELINE_VERSION)).toBeGreaterThanOrEqual(Number(BULK_COMPLETION_REFUSAL_AT_VERSION));
});
});
/*
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.
*/
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([]);
});
});
@@ -558,6 +610,36 @@ pgDescribe("schema-applier: VAL-SCHEMA-001 final-schema parity (table counts)",
expect(await getAppliedMigrations(ctx.db)).toContain(SESSION_ADVISOR_ENABLED_SCHEMA_VERSION);
});
/*
FNXC:Lifecycle 2026-07-16-22:40:
Regression for the FN-8141 crash: PR #2260 added project.tasks.bulk_completion_refusal_at
to the Drizzle model + 0000 baseline but shipped NO forward migration, so every
database created before #2260 (already carrying the 0000 marker, thus skipping the
baseline) never gained the column and crashed on the first TaskStore SELECT
("column bulk_completion_refusal_at does not exist"). Migration 0018 lands it on
existing clusters. Simulate that exact existing-DB shape: drop the column + its
0018 marker, then prove re-applying restores it.
*/
it("upgrades an existing DB missing bulk_completion_refusal_at (0018)", async () => {
ctx = await setupFreshDb();
await applySchemaBaseline(ctx.db, { pluginHooks: [] });
await ctx.db.execute(sql.raw(`
DELETE FROM public.fusion_schema_migrations WHERE version = '0018';
ALTER TABLE project.tasks DROP COLUMN bulk_completion_refusal_at;
`));
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 = 'bulk_completion_refusal_at'
`)) as unknown as Array<{ column_name: string }>;
expect(columns).toEqual([{ column_name: "bulk_completion_refusal_at" }]);
expect(await getAppliedMigrations(ctx.db)).toContain(BULK_COMPLETION_REFUSAL_AT_VERSION);
});
/*
FNXC:ProjectDataIsolation 2026-07-14-12:10:
@@ -1051,6 +1133,8 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => {
EXECUTOR_ESCALATION_ATTEMPT_VERSION,
GLOBAL_ROUTINES_SCHEMA_VERSION,
IMPORT_TRANSLATION_CACHE_SCOPE_FIX_VERSION,
TASK_MERGER_MODEL_LANE_VERSION,
BULK_COMPLETION_REFUSAL_AT_VERSION,
]);
expect((await applySchemaBaseline(ctx.db, { pluginHooks: [] })).applied).toBe(false);
});
@@ -1093,6 +1177,8 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => {
EXECUTOR_ESCALATION_ATTEMPT_VERSION,
GLOBAL_ROUTINES_SCHEMA_VERSION,
IMPORT_TRANSLATION_CACHE_SCOPE_FIX_VERSION,
TASK_MERGER_MODEL_LANE_VERSION,
BULK_COMPLETION_REFUSAL_AT_VERSION,
]);
});
@@ -1188,6 +1274,8 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => {
EXECUTOR_ESCALATION_ATTEMPT_VERSION,
GLOBAL_ROUTINES_SCHEMA_VERSION,
IMPORT_TRANSLATION_CACHE_SCOPE_FIX_VERSION,
TASK_MERGER_MODEL_LANE_VERSION,
BULK_COMPLETION_REFUSAL_AT_VERSION,
]);
});
@@ -1244,6 +1332,8 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => {
EXECUTOR_ESCALATION_ATTEMPT_VERSION,
GLOBAL_ROUTINES_SCHEMA_VERSION,
IMPORT_TRANSLATION_CACHE_SCOPE_FIX_VERSION,
TASK_MERGER_MODEL_LANE_VERSION,
BULK_COMPLETION_REFUSAL_AT_VERSION,
]);
});
@@ -1300,6 +1390,8 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => {
EXECUTOR_ESCALATION_ATTEMPT_VERSION,
GLOBAL_ROUTINES_SCHEMA_VERSION,
IMPORT_TRANSLATION_CACHE_SCOPE_FIX_VERSION,
TASK_MERGER_MODEL_LANE_VERSION,
BULK_COMPLETION_REFUSAL_AT_VERSION,
]);
});
});

View File

@@ -0,0 +1,8 @@
-- FNXC:Lifecycle 2026-07-16-22:35: FN-8141 skip-bypass taint marker (nullable ISO timestamp).
-- PR #2260 added project.tasks.bulk_completion_refusal_at to the Drizzle model
-- and the 0000 baseline but shipped NO forward migration, so every database
-- created before #2260 (already carrying the 0000 marker) never received the
-- column and crashed on the first TaskStore SELECT ("column
-- bulk_completion_refusal_at does not exist"). This forward migration lands the
-- column on existing clusters. Idempotent via IF NOT EXISTS.
ALTER TABLE project.tasks ADD COLUMN IF NOT EXISTS bulk_completion_refusal_at text;

View File

@@ -31,7 +31,7 @@ import { runPluginSchemaInitHooks, DEFAULT_PLUGIN_SCHEMA_INIT_HOOKS, type Plugin
FNXC:MultiProjectIsolation 2026-07-15-23:40:
Advances to 0012 after the owner_project_id domain/partition split and chat pin timestamp. Per-migration identities above stay fixed; only this latest-version marker moves.
*/
export const SCHEMA_BASELINE_VERSION = "0017";
export const SCHEMA_BASELINE_VERSION = "0018";
const INITIAL_SCHEMA_VERSION = "0000";
const AUTOMATION_ISOLATION_SCHEMA_VERSION = "0001";
const ANALYTICS_ISOLATION_SCHEMA_VERSION = "0002";
@@ -86,6 +86,14 @@ export const EXECUTOR_ESCALATION_ATTEMPT_VERSION = "0014";
export const GLOBAL_ROUTINES_SCHEMA_VERSION = "0015";
/** FNXC:Settings-MergerModel 2026-07-16-12:00: per-task merger lane is an additive upgrade. */
export const TASK_MERGER_MODEL_LANE_VERSION = "0017";
/**
* FNXC:Lifecycle 2026-07-16-22:35:
* Version 0018 lands project.tasks.bulk_completion_refusal_at (FN-8141) on
* existing clusters. PR #2260 added the column to the model + 0000 baseline but
* forgot the forward migration, so every pre-#2260 database crashed on its first
* TaskStore SELECT. Keep this identity fixed when SCHEMA_BASELINE_VERSION advances.
*/
export const BULK_COMPLETION_REFUSAL_AT_VERSION = "0018";
/** Bookkeeping table for the fresh Drizzle migration history. */
export const MIGRATION_BOOKKEEPING_TABLE = "fusion_schema_migrations";
@@ -173,6 +181,7 @@ const GLOBAL_ROUTINES_MIGRATION_PATH = join(
"0015_global_routines.sql",
);
const TASK_MERGER_MODEL_LANE_MIGRATION_PATH = join(__dirname, "migrations", "0017_task_merger_model_lane.sql");
const BULK_COMPLETION_REFUSAL_AT_MIGRATION_PATH = join(__dirname, "migrations", "0018_bulk_completion_refusal_at.sql");
/**
* Ensure the migration bookkeeping table exists. Lives in the public schema so
@@ -259,6 +268,7 @@ export async function applySchemaBaseline(
const executorEscalationAttemptAlreadyApplied = applied.includes(EXECUTOR_ESCALATION_ATTEMPT_VERSION);
const globalRoutinesAlreadyApplied = applied.includes(GLOBAL_ROUTINES_SCHEMA_VERSION);
const taskMergerModelLaneAlreadyApplied = applied.includes(TASK_MERGER_MODEL_LANE_VERSION);
const bulkCompletionRefusalAtAlreadyApplied = applied.includes(BULK_COMPLETION_REFUSAL_AT_VERSION);
let schemaChanged = false;
if (!baselineAlreadyApplied) {
@@ -559,6 +569,21 @@ export async function applySchemaBaseline(
);
schemaChanged = true;
}
/*
FNXC:Lifecycle 2026-07-16-22:35:
FN-8141 bulk-completion-refusal taint marker. PR #2260 added the column to
the model + 0000 baseline but no forward migration, so existing clusters
(baseline marker already present) never gained it and crashed on the first
TaskStore SELECT. Apply it as a forward migration so those clusters recover.
*/
if (!bulkCompletionRefusalAtAlreadyApplied) {
const migrationSql = await readFile(BULK_COMPLETION_REFUSAL_AT_MIGRATION_PATH, "utf8");
await tx.execute(sql.raw(migrationSql));
await tx.execute(
sql`INSERT INTO public.${sql.identifier(MIGRATION_BOOKKEEPING_TABLE)} (version) VALUES (${BULK_COMPLETION_REFUSAL_AT_VERSION}) ON CONFLICT (version) DO NOTHING`,
);
schemaChanged = true;
}
/*
FNXC:GitHubImportTranslate 2026-07-16-23:30: