From d1e9b563f7e9678638d8fe30606b779e7bccf05f Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Thu, 16 Jul 2026 22:45:00 -0700 Subject: [PATCH] fix(FN-8141): add forward migration for tasks.bulk_completion_refusal_at MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../fix-bulk-completion-refusal-migration.md | 7 ++ .../__tests__/postgres/schema-applier.test.ts | 94 ++++++++++++++++++- .../0018_bulk_completion_refusal_at.sql | 8 ++ packages/core/src/postgres/schema-applier.ts | 27 +++++- 4 files changed, 134 insertions(+), 2 deletions(-) create mode 100644 .changeset/fix-bulk-completion-refusal-migration.md create mode 100644 packages/core/src/postgres/migrations/0018_bulk_completion_refusal_at.sql diff --git a/.changeset/fix-bulk-completion-refusal-migration.md b/.changeset/fix-bulk-completion-refusal-migration.md new file mode 100644 index 0000000000..6651a62101 --- /dev/null +++ b/.changeset/fix-bulk-completion-refusal-migration.md @@ -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). diff --git a/packages/core/src/__tests__/postgres/schema-applier.test.ts b/packages/core/src/__tests__/postgres/schema-applier.test.ts index 7b0663478c..dc80377e19 100644 --- a/packages/core/src/__tests__/postgres/schema-applier.test.ts +++ b/packages/core/src/__tests__/postgres/schema-applier.test.ts @@ -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, ]); }); }); diff --git a/packages/core/src/postgres/migrations/0018_bulk_completion_refusal_at.sql b/packages/core/src/postgres/migrations/0018_bulk_completion_refusal_at.sql new file mode 100644 index 0000000000..a805816b60 --- /dev/null +++ b/packages/core/src/postgres/migrations/0018_bulk_completion_refusal_at.sql @@ -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; diff --git a/packages/core/src/postgres/schema-applier.ts b/packages/core/src/postgres/schema-applier.ts index 544bdb3384..305b9a7424 100644 --- a/packages/core/src/postgres/schema-applier.ts +++ b/packages/core/src/postgres/schema-applier.ts @@ -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: