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:
7
.changeset/fix-bulk-completion-refusal-migration.md
Normal file
7
.changeset/fix-bulk-completion-refusal-migration.md
Normal 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).
|
||||||
@@ -26,6 +26,8 @@ import postgres from "postgres";
|
|||||||
import { drizzle } from "drizzle-orm/postgres-js";
|
import { drizzle } from "drizzle-orm/postgres-js";
|
||||||
import { sql } from "drizzle-orm";
|
import { sql } from "drizzle-orm";
|
||||||
import { execSync } from "node:child_process";
|
import { execSync } from "node:child_process";
|
||||||
|
import { readdirSync, readFileSync } from "node:fs";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
import {
|
import {
|
||||||
applySchemaBaseline,
|
applySchemaBaseline,
|
||||||
getAppliedMigrations,
|
getAppliedMigrations,
|
||||||
@@ -53,6 +55,8 @@ import {
|
|||||||
EXECUTOR_TOOL_FAILURE_RETRY_VERSION,
|
EXECUTOR_TOOL_FAILURE_RETRY_VERSION,
|
||||||
EXECUTOR_ESCALATION_ATTEMPT_VERSION,
|
EXECUTOR_ESCALATION_ATTEMPT_VERSION,
|
||||||
GLOBAL_ROUTINES_SCHEMA_VERSION,
|
GLOBAL_ROUTINES_SCHEMA_VERSION,
|
||||||
|
TASK_MERGER_MODEL_LANE_VERSION,
|
||||||
|
BULK_COMPLETION_REFUSAL_AT_VERSION,
|
||||||
PROJECT_OWNERSHIP_SCHEMA_VERSION,
|
PROJECT_OWNERSHIP_SCHEMA_VERSION,
|
||||||
SESSION_ADVISOR_ENABLED_SCHEMA_VERSION,
|
SESSION_ADVISOR_ENABLED_SCHEMA_VERSION,
|
||||||
SQLITE_SCHEMA_PARITY_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", () => {
|
it("keeps the import translation scope fix assigned to version 0016", () => {
|
||||||
expect(IMPORT_TRANSLATION_CACHE_SCOPE_FIX_VERSION).toBe("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);
|
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:
|
FNXC:ProjectDataIsolation 2026-07-14-12:10:
|
||||||
@@ -1051,6 +1133,8 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => {
|
|||||||
EXECUTOR_ESCALATION_ATTEMPT_VERSION,
|
EXECUTOR_ESCALATION_ATTEMPT_VERSION,
|
||||||
GLOBAL_ROUTINES_SCHEMA_VERSION,
|
GLOBAL_ROUTINES_SCHEMA_VERSION,
|
||||||
IMPORT_TRANSLATION_CACHE_SCOPE_FIX_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);
|
expect((await applySchemaBaseline(ctx.db, { pluginHooks: [] })).applied).toBe(false);
|
||||||
});
|
});
|
||||||
@@ -1093,6 +1177,8 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => {
|
|||||||
EXECUTOR_ESCALATION_ATTEMPT_VERSION,
|
EXECUTOR_ESCALATION_ATTEMPT_VERSION,
|
||||||
GLOBAL_ROUTINES_SCHEMA_VERSION,
|
GLOBAL_ROUTINES_SCHEMA_VERSION,
|
||||||
IMPORT_TRANSLATION_CACHE_SCOPE_FIX_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,
|
EXECUTOR_ESCALATION_ATTEMPT_VERSION,
|
||||||
GLOBAL_ROUTINES_SCHEMA_VERSION,
|
GLOBAL_ROUTINES_SCHEMA_VERSION,
|
||||||
IMPORT_TRANSLATION_CACHE_SCOPE_FIX_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,
|
EXECUTOR_ESCALATION_ATTEMPT_VERSION,
|
||||||
GLOBAL_ROUTINES_SCHEMA_VERSION,
|
GLOBAL_ROUTINES_SCHEMA_VERSION,
|
||||||
IMPORT_TRANSLATION_CACHE_SCOPE_FIX_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,
|
EXECUTOR_ESCALATION_ATTEMPT_VERSION,
|
||||||
GLOBAL_ROUTINES_SCHEMA_VERSION,
|
GLOBAL_ROUTINES_SCHEMA_VERSION,
|
||||||
IMPORT_TRANSLATION_CACHE_SCOPE_FIX_VERSION,
|
IMPORT_TRANSLATION_CACHE_SCOPE_FIX_VERSION,
|
||||||
|
TASK_MERGER_MODEL_LANE_VERSION,
|
||||||
|
BULK_COMPLETION_REFUSAL_AT_VERSION,
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -31,7 +31,7 @@ import { runPluginSchemaInitHooks, DEFAULT_PLUGIN_SCHEMA_INIT_HOOKS, type Plugin
|
|||||||
FNXC:MultiProjectIsolation 2026-07-15-23:40:
|
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.
|
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 INITIAL_SCHEMA_VERSION = "0000";
|
||||||
const AUTOMATION_ISOLATION_SCHEMA_VERSION = "0001";
|
const AUTOMATION_ISOLATION_SCHEMA_VERSION = "0001";
|
||||||
const ANALYTICS_ISOLATION_SCHEMA_VERSION = "0002";
|
const ANALYTICS_ISOLATION_SCHEMA_VERSION = "0002";
|
||||||
@@ -86,6 +86,14 @@ export const EXECUTOR_ESCALATION_ATTEMPT_VERSION = "0014";
|
|||||||
export const GLOBAL_ROUTINES_SCHEMA_VERSION = "0015";
|
export const GLOBAL_ROUTINES_SCHEMA_VERSION = "0015";
|
||||||
/** FNXC:Settings-MergerModel 2026-07-16-12:00: per-task merger lane is an additive upgrade. */
|
/** FNXC:Settings-MergerModel 2026-07-16-12:00: per-task merger lane is an additive upgrade. */
|
||||||
export const TASK_MERGER_MODEL_LANE_VERSION = "0017";
|
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. */
|
/** Bookkeeping table for the fresh Drizzle migration history. */
|
||||||
export const MIGRATION_BOOKKEEPING_TABLE = "fusion_schema_migrations";
|
export const MIGRATION_BOOKKEEPING_TABLE = "fusion_schema_migrations";
|
||||||
@@ -173,6 +181,7 @@ const GLOBAL_ROUTINES_MIGRATION_PATH = join(
|
|||||||
"0015_global_routines.sql",
|
"0015_global_routines.sql",
|
||||||
);
|
);
|
||||||
const TASK_MERGER_MODEL_LANE_MIGRATION_PATH = join(__dirname, "migrations", "0017_task_merger_model_lane.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
|
* 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 executorEscalationAttemptAlreadyApplied = applied.includes(EXECUTOR_ESCALATION_ATTEMPT_VERSION);
|
||||||
const globalRoutinesAlreadyApplied = applied.includes(GLOBAL_ROUTINES_SCHEMA_VERSION);
|
const globalRoutinesAlreadyApplied = applied.includes(GLOBAL_ROUTINES_SCHEMA_VERSION);
|
||||||
const taskMergerModelLaneAlreadyApplied = applied.includes(TASK_MERGER_MODEL_LANE_VERSION);
|
const taskMergerModelLaneAlreadyApplied = applied.includes(TASK_MERGER_MODEL_LANE_VERSION);
|
||||||
|
const bulkCompletionRefusalAtAlreadyApplied = applied.includes(BULK_COMPLETION_REFUSAL_AT_VERSION);
|
||||||
let schemaChanged = false;
|
let schemaChanged = false;
|
||||||
|
|
||||||
if (!baselineAlreadyApplied) {
|
if (!baselineAlreadyApplied) {
|
||||||
@@ -559,6 +569,21 @@ export async function applySchemaBaseline(
|
|||||||
);
|
);
|
||||||
schemaChanged = true;
|
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:
|
FNXC:GitHubImportTranslate 2026-07-16-23:30:
|
||||||
|
|||||||
Reference in New Issue
Block a user