fix(core): complete legacy SQLite cutover
This commit is contained in:
@@ -2,6 +2,6 @@
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Fix startup migration for legacy project rows whose project ID is null.
|
||||
summary: Fix first-boot SQLite migration failures while preserving all legacy project data.
|
||||
category: fix
|
||||
dev: Bound SQLite cutovers now override nullable or stale source project IDs with the resolved registry identity.
|
||||
dev: Handles stale partitions, retired tables, derived FTS indexes, seeded singletons, and cross-database checksum collation.
|
||||
|
||||
@@ -32,7 +32,10 @@ import {
|
||||
SCHEMA_BASELINE_VERSION,
|
||||
roadmapPluginSchemaInit,
|
||||
} from "../../postgres/index.js";
|
||||
import { MONITOR_APPROVAL_ISOLATION_SCHEMA_VERSION } from "../../postgres/schema-applier.js";
|
||||
import {
|
||||
LEGACY_CUTOVER_PRESERVATION_SCHEMA_VERSION,
|
||||
MONITOR_APPROVAL_ISOLATION_SCHEMA_VERSION,
|
||||
} from "../../postgres/schema-applier.js";
|
||||
|
||||
const PG_ADMIN_URL =
|
||||
process.env.FUSION_PG_TEST_ADMIN_URL ?? "postgresql://localhost:5432/postgres";
|
||||
@@ -49,6 +52,11 @@ describe("schema-applier: immutable migration identities", () => {
|
||||
expect(Number(SCHEMA_BASELINE_VERSION))
|
||||
.toBeGreaterThanOrEqual(Number(MONITOR_APPROVAL_ISOLATION_SCHEMA_VERSION));
|
||||
});
|
||||
|
||||
it("keeps legacy cutover preservation assigned to version 0004", () => {
|
||||
expect(LEGACY_CUTOVER_PRESERVATION_SCHEMA_VERSION).toBe("0004");
|
||||
expect(SCHEMA_BASELINE_VERSION).toBe(LEGACY_CUTOVER_PRESERVATION_SCHEMA_VERSION);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -322,7 +330,7 @@ pgDescribe("schema-applier: VAL-SCHEMA-001 final-schema parity (table counts)",
|
||||
ctx = null;
|
||||
});
|
||||
|
||||
it("creates all 81 project tables, 17 central tables, 1 archive table", async () => {
|
||||
it("creates all 87 project tables, 17 central tables, 1 archive table", async () => {
|
||||
ctx = await setupFreshDb();
|
||||
// FNXC:PostgresCutover 2026-07-05-15:55: apply the BASELINE only.
|
||||
// applySchemaBaseline now runs the plugin schema-init hooks by default,
|
||||
@@ -337,8 +345,8 @@ pgDescribe("schema-applier: VAL-SCHEMA-001 final-schema parity (table counts)",
|
||||
GROUP BY table_schema
|
||||
`)) as unknown as Array<{ table_schema: string; n: number }>;
|
||||
const bySchema = Object.fromEntries(rows.map((r) => [r.table_schema, r.n]));
|
||||
// Project: 81 core tables. (Plugin tables are added separately by the hook.)
|
||||
expect(bySchema.project).toBe(81);
|
||||
// Project: 87 core tables. (Plugin tables are added separately by the hook.)
|
||||
expect(bySchema.project).toBe(87);
|
||||
expect(bySchema.central).toBe(17);
|
||||
expect(bySchema.archive).toBe(1);
|
||||
});
|
||||
@@ -540,7 +548,7 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => {
|
||||
const versions = (await ctx.db.execute(sql`
|
||||
SELECT version FROM public.fusion_schema_migrations ORDER BY version
|
||||
`)) as unknown as Array<{ version: string }>;
|
||||
expect(versions.map(({ version }) => version)).toEqual(["0000", "0001", "0002", SCHEMA_BASELINE_VERSION]);
|
||||
expect(versions.map(({ version }) => version)).toEqual(["0000", "0001", "0002", "0003", SCHEMA_BASELINE_VERSION]);
|
||||
expect((await applySchemaBaseline(ctx.db, { pluginHooks: [] })).applied).toBe(false);
|
||||
});
|
||||
|
||||
@@ -564,7 +572,7 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => {
|
||||
applySchemaBaseline(ctx.db, { pluginHooks: [] }),
|
||||
]);
|
||||
expect(results.filter(({ applied }) => applied)).toHaveLength(1);
|
||||
expect(await getAppliedMigrations(ctx.db)).toEqual(["0000", "0001", "0002", SCHEMA_BASELINE_VERSION]);
|
||||
expect(await getAppliedMigrations(ctx.db)).toEqual(["0000", "0001", "0002", "0003", SCHEMA_BASELINE_VERSION]);
|
||||
});
|
||||
|
||||
it("upgrades a 0001 database by backfilling analytics ownership", async () => {
|
||||
@@ -594,7 +602,7 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => {
|
||||
))) as unknown as Array<{ project_id: string }>;
|
||||
expect(rows).toEqual([{ project_id: "project-a" }]);
|
||||
}
|
||||
expect(await getAppliedMigrations(ctx.db)).toEqual(["0000", "0001", "0002", "0003"]);
|
||||
expect(await getAppliedMigrations(ctx.db)).toEqual(["0000", "0001", "0002", "0003", "0004"]);
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -626,7 +634,45 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => {
|
||||
))) as unknown as Array<{ project_id: string }>;
|
||||
expect(rows).toEqual([{ project_id: "project-a" }]);
|
||||
}
|
||||
expect(await getAppliedMigrations(ctx.db)).toEqual(["0000", "0001", "0002", "0003"]);
|
||||
expect(await getAppliedMigrations(ctx.db)).toEqual(["0000", "0001", "0002", "0003", "0004"]);
|
||||
});
|
||||
|
||||
/*
|
||||
FNXC:PostgresMigrationCompleteness 2026-07-14-09:27:
|
||||
A target that already recorded 0000-0003 must still receive all retired-table preservation surfaces before a SQLite migration retry.
|
||||
*/
|
||||
it("upgrades a 0003 database with legacy cutover preservation tables", async () => {
|
||||
ctx = await setupFreshDb();
|
||||
await applySchemaBaseline(ctx.db, { pluginHooks: [] });
|
||||
await ctx.db.execute(sql.raw(`
|
||||
DELETE FROM public.fusion_schema_migrations WHERE version = '0004';
|
||||
DROP TABLE project.project_auth_sessions;
|
||||
DROP TABLE project.project_auth_providers;
|
||||
DROP TABLE project.project_auth_memberships;
|
||||
DROP TABLE project.project_auth_users;
|
||||
DROP TABLE project.task_reviewer_runs;
|
||||
DROP TABLE project.boards;
|
||||
`));
|
||||
|
||||
expect((await applySchemaBaseline(ctx.db, { pluginHooks: [] })).applied).toBe(true);
|
||||
const tables = (await ctx.db.execute(sql`
|
||||
SELECT table_name FROM information_schema.tables
|
||||
WHERE table_schema = 'project'
|
||||
AND table_name IN (
|
||||
'boards', 'project_auth_users', 'project_auth_memberships',
|
||||
'project_auth_providers', 'project_auth_sessions', 'task_reviewer_runs'
|
||||
)
|
||||
ORDER BY table_name
|
||||
`)) as unknown as Array<{ table_name: string }>;
|
||||
expect(tables.map(({ table_name }) => table_name)).toEqual([
|
||||
"boards",
|
||||
"project_auth_memberships",
|
||||
"project_auth_providers",
|
||||
"project_auth_sessions",
|
||||
"project_auth_users",
|
||||
"task_reviewer_runs",
|
||||
]);
|
||||
expect(await getAppliedMigrations(ctx.db)).toEqual(["0000", "0001", "0002", "0003", "0004"]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -471,6 +471,139 @@ pgDescribe("SQLite-to-PostgreSQL migrator", () => {
|
||||
}));
|
||||
});
|
||||
|
||||
/*
|
||||
FNXC:PostgresMigrationCompleteness 2026-07-14-09:27:
|
||||
The built-in task and archive FTS5 tables are derived indexes whose searchable content is regenerated from the migrated task rows by PostgreSQL generated tsvectors. Only these two named virtual tables may be skipped; arbitrary extension-owned FTS data must continue to fail closed.
|
||||
*/
|
||||
it("treats the two replaced built-in FTS indexes as verified derived data", async () => {
|
||||
const projectPath = join(ctx!.fusionDir, "fusion.db");
|
||||
const archivePath = join(ctx!.fusionDir, "archive.db");
|
||||
const project = new DatabaseSync(projectPath);
|
||||
const archive = new DatabaseSync(archivePath);
|
||||
try {
|
||||
project.exec(`CREATE VIRTUAL TABLE tasks_fts USING fts5(title, description)`);
|
||||
project.prepare(`INSERT INTO tasks_fts (title, description) VALUES (?, ?)`).run("First task", "desc");
|
||||
archive.exec(`CREATE VIRTUAL TABLE archived_tasks_fts USING fts5(title, description)`);
|
||||
archive.prepare(`INSERT INTO archived_tasks_fts (title, description) VALUES (?, ?)`).run("Archived task", "desc");
|
||||
} finally {
|
||||
project.close();
|
||||
archive.close();
|
||||
}
|
||||
|
||||
const report = await migrateTest(ctx!.db, [
|
||||
{ sqlitePath: projectPath, pgSchema: "project" as const },
|
||||
{ sqlitePath: archivePath, pgSchema: "archive" as const },
|
||||
]);
|
||||
|
||||
for (const table of ["tasks_fts", "archived_tasks_fts"]) {
|
||||
expect(report.tables).toContainEqual(expect.objectContaining({
|
||||
table,
|
||||
verified: true,
|
||||
skipped: true,
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
||||
/*
|
||||
FNXC:PostgresMigrationCompleteness 2026-07-14-09:27:
|
||||
SQLite cutover must preserve retired company-board, project-auth, and task-reviewer datasets even though current runtime code no longer reads them. These tables remain project-partitioned in shared PostgreSQL so later projects cannot collide with legacy IDs.
|
||||
*/
|
||||
it("preserves every retired project table under the resolved project partition", async () => {
|
||||
const sqlitePath = join(ctx!.fusionDir, "retired-project-data.db");
|
||||
const legacy = new DatabaseSync(sqlitePath);
|
||||
try {
|
||||
legacy.exec(`
|
||||
CREATE TABLE boards (id TEXT PRIMARY KEY, projectId TEXT NOT NULL DEFAULT '', name TEXT NOT NULL, description TEXT NOT NULL DEFAULT '', workflowId TEXT NOT NULL, ordering INTEGER NOT NULL DEFAULT 0, requirePlanApproval INTEGER NOT NULL DEFAULT 0, lfgMode INTEGER NOT NULL DEFAULT 0, createdAt TEXT NOT NULL, updatedAt TEXT NOT NULL);
|
||||
CREATE TABLE project_auth_users (id TEXT PRIMARY KEY, email TEXT NOT NULL, displayName TEXT, active INTEGER NOT NULL DEFAULT 1, createdAt TEXT NOT NULL, updatedAt TEXT NOT NULL);
|
||||
CREATE TABLE project_auth_memberships (id TEXT PRIMARY KEY, userId TEXT NOT NULL, role TEXT NOT NULL, active INTEGER NOT NULL DEFAULT 1, createdAt TEXT NOT NULL, updatedAt TEXT NOT NULL);
|
||||
CREATE TABLE project_auth_providers (id TEXT PRIMARY KEY, userId TEXT NOT NULL, provider TEXT NOT NULL, providerUserId TEXT NOT NULL, metadata TEXT, createdAt TEXT NOT NULL, updatedAt TEXT NOT NULL);
|
||||
CREATE TABLE project_auth_sessions (id TEXT PRIMARY KEY, userId TEXT NOT NULL, membershipId TEXT NOT NULL, sessionToken TEXT NOT NULL, expiresAt TEXT NOT NULL, revokedAt TEXT, createdAt TEXT NOT NULL, updatedAt TEXT NOT NULL);
|
||||
CREATE TABLE task_reviewer_runs (id TEXT PRIMARY KEY, taskId TEXT NOT NULL, boardId TEXT NOT NULL DEFAULT '', status TEXT NOT NULL DEFAULT 'pending', summary TEXT, failureReasons TEXT, reviewerAgentId TEXT, reworkRound INTEGER NOT NULL DEFAULT 0, startedAt TEXT NOT NULL, completedAt TEXT, createdAt TEXT NOT NULL, updatedAt TEXT NOT NULL, invalidatedAt TEXT);
|
||||
`);
|
||||
legacy.prepare(`INSERT INTO boards VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run("board-1", "stale", "Legacy board", "desc", "builtin:coding", 0, 0, 0, "2026-06-01", "2026-06-01");
|
||||
legacy.prepare(`INSERT INTO project_auth_users VALUES (?, ?, ?, ?, ?, ?)`).run("user-1", "operator@example.com", "Operator", 1, "2026-06-01", "2026-06-01");
|
||||
legacy.prepare(`INSERT INTO project_auth_memberships VALUES (?, ?, ?, ?, ?, ?)`).run("member-1", "user-1", "owner", 1, "2026-06-01", "2026-06-01");
|
||||
legacy.prepare(`INSERT INTO project_auth_providers VALUES (?, ?, ?, ?, ?, ?, ?)`).run("provider-1", "user-1", "local", "operator", "{}", "2026-06-01", "2026-06-01");
|
||||
legacy.prepare(`INSERT INTO project_auth_sessions VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run("session-1", "user-1", "member-1", "token", "2026-07-01", null, "2026-06-01", "2026-06-01");
|
||||
legacy.prepare(`INSERT INTO task_reviewer_runs VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run("review-1", "FN-1", "board-1", "passed", "ok", "[]", "agent-1", 0, "2026-06-01", "2026-06-01", "2026-06-01", "2026-06-01", null);
|
||||
} finally {
|
||||
legacy.close();
|
||||
}
|
||||
|
||||
const report = await migrateTest(
|
||||
ctx!.db,
|
||||
[{ sqlitePath, pgSchema: "project" as const }],
|
||||
{ projectId: "project-retired" },
|
||||
);
|
||||
const retiredTables = [
|
||||
"boards",
|
||||
"project_auth_users",
|
||||
"project_auth_memberships",
|
||||
"project_auth_providers",
|
||||
"project_auth_sessions",
|
||||
"task_reviewer_runs",
|
||||
];
|
||||
for (const table of retiredTables) {
|
||||
expect(report.tables).toContainEqual(expect.objectContaining({
|
||||
table,
|
||||
sourceRows: 1,
|
||||
targetRows: 1,
|
||||
verified: true,
|
||||
}));
|
||||
}
|
||||
const partitions = await ctx!.db.execute(sql`
|
||||
SELECT project_id FROM project.boards
|
||||
UNION ALL SELECT project_id FROM project.project_auth_users
|
||||
UNION ALL SELECT project_id FROM project.project_auth_memberships
|
||||
UNION ALL SELECT project_id FROM project.project_auth_providers
|
||||
UNION ALL SELECT project_id FROM project.project_auth_sessions
|
||||
UNION ALL SELECT project_id FROM project.task_reviewer_runs
|
||||
`) as unknown as Array<{ project_id: string }>;
|
||||
expect(partitions).toHaveLength(6);
|
||||
expect(partitions.every(({ project_id }) => project_id === "project-retired")).toBe(true);
|
||||
});
|
||||
|
||||
/*
|
||||
FNXC:PostgresMigrationCompleteness 2026-07-14-09:27:
|
||||
Central singleton values from SQLite must replace baseline seed defaults during first-boot cutover, and content verification must be independent of SQLite versus PostgreSQL text collation so mixed-case filesystem paths verify consistently.
|
||||
*/
|
||||
it("migrates seeded central singletons and verifies rows across database collations", async () => {
|
||||
const sqlitePath = join(ctx!.fusionDir, "fusion-central.db");
|
||||
const legacy = new DatabaseSync(sqlitePath);
|
||||
try {
|
||||
legacy.exec(`
|
||||
CREATE TABLE centralSettings (id INTEGER PRIMARY KEY, defaultProjectId TEXT, updatedAt TEXT NOT NULL);
|
||||
CREATE TABLE globalConcurrency (id INTEGER PRIMARY KEY, globalMaxConcurrent INTEGER, currentlyActive INTEGER, queuedCount INTEGER, updatedAt TEXT);
|
||||
CREATE TABLE plugin_installs (id TEXT PRIMARY KEY, name TEXT NOT NULL, version TEXT NOT NULL, path TEXT NOT NULL, createdAt TEXT NOT NULL, updatedAt TEXT NOT NULL);
|
||||
CREATE TABLE project_plugin_states (projectPath TEXT NOT NULL, pluginId TEXT NOT NULL, enabled INTEGER NOT NULL, state TEXT NOT NULL, error TEXT, createdAt TEXT NOT NULL, updatedAt TEXT NOT NULL, PRIMARY KEY (projectPath, pluginId));
|
||||
`);
|
||||
legacy.prepare(`INSERT INTO centralSettings VALUES (?, ?, ?)`).run(1, "project-default", "2026-06-01");
|
||||
legacy.prepare(`INSERT INTO globalConcurrency VALUES (?, ?, ?, ?, ?)`).run(1, 10, 0, 0, "2026-06-02");
|
||||
legacy.prepare(`INSERT INTO plugin_installs VALUES (?, ?, ?, ?, ?, ?)`).run("plugin-a", "A", "1.0.0", "/a", "2026-06-01", "2026-06-01");
|
||||
legacy.prepare(`INSERT INTO plugin_installs VALUES (?, ?, ?, ?, ?, ?)`).run("plugin-b", "B", "1.0.0", "/b", "2026-06-01", "2026-06-01");
|
||||
const insertState = legacy.prepare(`INSERT INTO project_plugin_states VALUES (?, ?, ?, ?, ?, ?, ?)`);
|
||||
insertState.run("/Users/operator/project", "plugin-a", 1, "started", null, "2026-06-01", "2026-06-01");
|
||||
insertState.run("/private/tmp/project", "plugin-b", 1, "stopped", null, "2026-06-01", "2026-06-01");
|
||||
} finally {
|
||||
legacy.close();
|
||||
}
|
||||
|
||||
const report = await migrateTest(ctx!.db, [
|
||||
{ sqlitePath, pgSchema: "central" as const },
|
||||
]);
|
||||
for (const table of ["central_settings", "global_concurrency", "project_plugin_states"]) {
|
||||
expect(report.tables).toContainEqual(expect.objectContaining({ table, verified: true }));
|
||||
}
|
||||
const settings = await ctx!.db.execute(sql`
|
||||
SELECT default_project_id, updated_at FROM central.central_settings WHERE id = 1
|
||||
`) as unknown as Array<{ default_project_id: string; updated_at: string }>;
|
||||
expect(settings).toEqual([{ default_project_id: "project-default", updated_at: "2026-06-01" }]);
|
||||
const concurrency = await ctx!.db.execute(sql`
|
||||
SELECT global_max_concurrent, updated_at FROM central.global_concurrency WHERE id = 1
|
||||
`) as unknown as Array<{ global_max_concurrent: number; updated_at: string }>;
|
||||
expect(concurrency).toEqual([{ global_max_concurrent: 10, updated_at: "2026-06-02" }]);
|
||||
});
|
||||
|
||||
/*
|
||||
FNXC:AutomationIsolation 2026-07-13-22:37:
|
||||
Legacy project databases do not carry project_id on automation rows. Migration must inject the resolved registry identity before verification so bound automation stores and cron runners see only their project's schedules, including when legacy automation IDs overlap.
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
/*
|
||||
FNXC:PostgresMigrationCompleteness 2026-07-14-09:27:
|
||||
Retired company-board, project-auth, and task-reviewer records remain queryable after SQLite cutover. Project-partition every key and relationship because embedded PostgreSQL is shared, and apply this as an independent version so targets that already recorded 0000 still receive the preservation tables before retrying migration.
|
||||
*/
|
||||
CREATE TABLE IF NOT EXISTS project.boards (
|
||||
project_id text NOT NULL,
|
||||
id text NOT NULL,
|
||||
name text NOT NULL,
|
||||
description text NOT NULL DEFAULT '',
|
||||
workflow_id text NOT NULL,
|
||||
ordering integer NOT NULL DEFAULT 0,
|
||||
require_plan_approval integer NOT NULL DEFAULT 0,
|
||||
lfg_mode integer NOT NULL DEFAULT 0,
|
||||
created_at text NOT NULL,
|
||||
updated_at text NOT NULL,
|
||||
PRIMARY KEY (project_id, id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS "idxLegacyBoardsProjectOrdering" ON project.boards(project_id, ordering);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS project.project_auth_users (
|
||||
project_id text NOT NULL,
|
||||
id text NOT NULL,
|
||||
email text NOT NULL,
|
||||
display_name text,
|
||||
active integer NOT NULL DEFAULT 1,
|
||||
created_at text NOT NULL,
|
||||
updated_at text NOT NULL,
|
||||
PRIMARY KEY (project_id, id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS "idxLegacyProjectAuthUsersEmail" ON project.project_auth_users(project_id, email);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS project.project_auth_memberships (
|
||||
project_id text NOT NULL,
|
||||
id text NOT NULL,
|
||||
user_id text NOT NULL,
|
||||
role text NOT NULL,
|
||||
active integer NOT NULL DEFAULT 1,
|
||||
created_at text NOT NULL,
|
||||
updated_at text NOT NULL,
|
||||
PRIMARY KEY (project_id, id),
|
||||
FOREIGN KEY (project_id, user_id) REFERENCES project.project_auth_users(project_id, id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS "idxLegacyProjectAuthMembershipsUser" ON project.project_auth_memberships(project_id, user_id);
|
||||
CREATE INDEX IF NOT EXISTS "idxLegacyProjectAuthMembershipsRole" ON project.project_auth_memberships(project_id, role);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS project.project_auth_providers (
|
||||
project_id text NOT NULL,
|
||||
id text NOT NULL,
|
||||
user_id text NOT NULL,
|
||||
provider text NOT NULL,
|
||||
provider_user_id text NOT NULL,
|
||||
metadata text,
|
||||
created_at text NOT NULL,
|
||||
updated_at text NOT NULL,
|
||||
PRIMARY KEY (project_id, id),
|
||||
FOREIGN KEY (project_id, user_id) REFERENCES project.project_auth_users(project_id, id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "idxLegacyProjectAuthProvidersIdentity" ON project.project_auth_providers(project_id, provider, provider_user_id);
|
||||
CREATE INDEX IF NOT EXISTS "idxLegacyProjectAuthProvidersUser" ON project.project_auth_providers(project_id, user_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS project.project_auth_sessions (
|
||||
project_id text NOT NULL,
|
||||
id text NOT NULL,
|
||||
user_id text NOT NULL,
|
||||
membership_id text NOT NULL,
|
||||
session_token text NOT NULL,
|
||||
expires_at text NOT NULL,
|
||||
revoked_at text,
|
||||
created_at text NOT NULL,
|
||||
updated_at text NOT NULL,
|
||||
PRIMARY KEY (project_id, id),
|
||||
FOREIGN KEY (project_id, user_id) REFERENCES project.project_auth_users(project_id, id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (project_id, membership_id) REFERENCES project.project_auth_memberships(project_id, id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "idxLegacyProjectAuthSessionsToken" ON project.project_auth_sessions(project_id, session_token);
|
||||
CREATE INDEX IF NOT EXISTS "idxLegacyProjectAuthSessionsUser" ON project.project_auth_sessions(project_id, user_id);
|
||||
CREATE INDEX IF NOT EXISTS "idxLegacyProjectAuthSessionsMembership" ON project.project_auth_sessions(project_id, membership_id);
|
||||
CREATE INDEX IF NOT EXISTS "idxLegacyProjectAuthSessionsExpiry" ON project.project_auth_sessions(project_id, expires_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS project.task_reviewer_runs (
|
||||
project_id text NOT NULL,
|
||||
id text NOT NULL,
|
||||
task_id text NOT NULL,
|
||||
board_id text NOT NULL DEFAULT '',
|
||||
status text NOT NULL DEFAULT 'pending',
|
||||
summary text,
|
||||
failure_reasons text,
|
||||
reviewer_agent_id text,
|
||||
rework_round integer NOT NULL DEFAULT 0,
|
||||
started_at text NOT NULL,
|
||||
completed_at text,
|
||||
created_at text NOT NULL,
|
||||
updated_at text NOT NULL,
|
||||
invalidated_at text,
|
||||
PRIMARY KEY (project_id, id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS "idxLegacyTaskReviewerRunsTask" ON project.task_reviewer_runs(project_id, task_id);
|
||||
CREATE INDEX IF NOT EXISTS "idxLegacyTaskReviewerRunsStatus" ON project.task_reviewer_runs(project_id, status);
|
||||
@@ -27,7 +27,7 @@ import { sql } from "drizzle-orm";
|
||||
import { runPluginSchemaInitHooks, DEFAULT_PLUGIN_SCHEMA_INIT_HOOKS, type PluginSchemaInitHook } from "./plugin-schema-hook.js";
|
||||
|
||||
/** The latest PostgreSQL schema version known to this applier. */
|
||||
export const SCHEMA_BASELINE_VERSION = "0003";
|
||||
export const SCHEMA_BASELINE_VERSION = "0004";
|
||||
const INITIAL_SCHEMA_VERSION = "0000";
|
||||
const AUTOMATION_ISOLATION_SCHEMA_VERSION = "0001";
|
||||
const ANALYTICS_ISOLATION_SCHEMA_VERSION = "0002";
|
||||
@@ -36,6 +36,7 @@ const ANALYTICS_ISOLATION_SCHEMA_VERSION = "0002";
|
||||
* Each migration keeps an immutable bookkeeping identity even as SCHEMA_BASELINE_VERSION advances to newer migrations. Upgrade checks and inserts must use this dedicated 0003 identifier so a later latest-version marker cannot make an unrecorded monitor/approval migration look applied.
|
||||
*/
|
||||
export const MONITOR_APPROVAL_ISOLATION_SCHEMA_VERSION = "0003";
|
||||
export const LEGACY_CUTOVER_PRESERVATION_SCHEMA_VERSION = "0004";
|
||||
|
||||
/** Bookkeeping table for the fresh Drizzle migration history. */
|
||||
export const MIGRATION_BOOKKEEPING_TABLE = "fusion_schema_migrations";
|
||||
@@ -57,6 +58,11 @@ const MONITOR_APPROVAL_ISOLATION_MIGRATION_PATH = join(
|
||||
"migrations",
|
||||
"0003_monitor_approval_project_isolation.sql",
|
||||
);
|
||||
const LEGACY_CUTOVER_PRESERVATION_MIGRATION_PATH = join(
|
||||
__dirname,
|
||||
"migrations",
|
||||
"0004_legacy_cutover_preservation.sql",
|
||||
);
|
||||
|
||||
/**
|
||||
* Ensure the migration bookkeeping table exists. Lives in the public schema so
|
||||
@@ -117,6 +123,7 @@ export async function applySchemaBaseline(
|
||||
const automationIsolationAlreadyApplied = applied.includes(AUTOMATION_ISOLATION_SCHEMA_VERSION);
|
||||
const analyticsIsolationAlreadyApplied = applied.includes(ANALYTICS_ISOLATION_SCHEMA_VERSION);
|
||||
const monitorApprovalIsolationAlreadyApplied = applied.includes(MONITOR_APPROVAL_ISOLATION_SCHEMA_VERSION);
|
||||
const legacyCutoverPreservationAlreadyApplied = applied.includes(LEGACY_CUTOVER_PRESERVATION_SCHEMA_VERSION);
|
||||
let schemaChanged = false;
|
||||
|
||||
if (!baselineAlreadyApplied) {
|
||||
@@ -170,6 +177,19 @@ export async function applySchemaBaseline(
|
||||
schemaChanged = true;
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:PostgresMigrationCompleteness 2026-07-14-09:27:
|
||||
Apply retired-table preservation independently of 0000 so an older partial migration target can retry without losing board, project-auth, or task-reviewer rows. The DDL is additive and idempotent.
|
||||
*/
|
||||
if (!legacyCutoverPreservationAlreadyApplied) {
|
||||
const migrationSql = await readFile(LEGACY_CUTOVER_PRESERVATION_MIGRATION_PATH, "utf8");
|
||||
await tx.execute(sql.raw(migrationSql));
|
||||
await tx.execute(
|
||||
sql`INSERT INTO public.${sql.identifier(MIGRATION_BOOKKEEPING_TABLE)} (version) VALUES (${LEGACY_CUTOVER_PRESERVATION_SCHEMA_VERSION}) ON CONFLICT (version) DO NOTHING`,
|
||||
);
|
||||
schemaChanged = true;
|
||||
}
|
||||
|
||||
// Run plugin schema-init hooks regardless of whether the baseline was just
|
||||
// applied or already present — plugin tables must exist on every connection
|
||||
// the applier touches. The hooks are themselves idempotent (CREATE TABLE IF
|
||||
|
||||
@@ -324,6 +324,123 @@ export const config = projectSchema.table("config", {
|
||||
updatedAt: text("updated_at"),
|
||||
});
|
||||
|
||||
/*
|
||||
FNXC:PostgresMigrationCompleteness 2026-07-14-09:27:
|
||||
Retired company-board, project-auth, and task-reviewer tables remain part of the cutover preservation contract even though current runtime code no longer reads them. Keep their legacy columns queryable and add project_id to every key and relationship so multiple SQLite project databases can migrate into one shared PostgreSQL schema without collisions.
|
||||
*/
|
||||
export const legacyBoards = projectSchema.table("boards", {
|
||||
projectId: text("project_id").notNull(),
|
||||
id: text("id").notNull(),
|
||||
name: text("name").notNull(),
|
||||
description: text("description").notNull().default(""),
|
||||
workflowId: text("workflow_id").notNull(),
|
||||
ordering: integer("ordering").notNull().default(0),
|
||||
requirePlanApproval: integer("require_plan_approval").notNull().default(0),
|
||||
lfgMode: integer("lfg_mode").notNull().default(0),
|
||||
createdAt: text("created_at").notNull(),
|
||||
updatedAt: text("updated_at").notNull(),
|
||||
}, (t) => [
|
||||
primaryKey({ columns: [t.projectId, t.id] }),
|
||||
index("idxLegacyBoardsProjectOrdering").on(t.projectId, t.ordering),
|
||||
]);
|
||||
|
||||
export const legacyProjectAuthUsers = projectSchema.table("project_auth_users", {
|
||||
projectId: text("project_id").notNull(),
|
||||
id: text("id").notNull(),
|
||||
email: text("email").notNull(),
|
||||
displayName: text("display_name"),
|
||||
active: integer("active").notNull().default(1),
|
||||
createdAt: text("created_at").notNull(),
|
||||
updatedAt: text("updated_at").notNull(),
|
||||
}, (t) => [
|
||||
primaryKey({ columns: [t.projectId, t.id] }),
|
||||
index("idxLegacyProjectAuthUsersEmail").on(t.projectId, t.email),
|
||||
]);
|
||||
|
||||
export const legacyProjectAuthMemberships = projectSchema.table("project_auth_memberships", {
|
||||
projectId: text("project_id").notNull(),
|
||||
id: text("id").notNull(),
|
||||
userId: text("user_id").notNull(),
|
||||
role: text("role").notNull(),
|
||||
active: integer("active").notNull().default(1),
|
||||
createdAt: text("created_at").notNull(),
|
||||
updatedAt: text("updated_at").notNull(),
|
||||
}, (t) => [
|
||||
primaryKey({ columns: [t.projectId, t.id] }),
|
||||
foreignKey({
|
||||
columns: [t.projectId, t.userId],
|
||||
foreignColumns: [legacyProjectAuthUsers.projectId, legacyProjectAuthUsers.id],
|
||||
}).onDelete("cascade"),
|
||||
index("idxLegacyProjectAuthMembershipsUser").on(t.projectId, t.userId),
|
||||
index("idxLegacyProjectAuthMembershipsRole").on(t.projectId, t.role),
|
||||
]);
|
||||
|
||||
export const legacyProjectAuthProviders = projectSchema.table("project_auth_providers", {
|
||||
projectId: text("project_id").notNull(),
|
||||
id: text("id").notNull(),
|
||||
userId: text("user_id").notNull(),
|
||||
provider: text("provider").notNull(),
|
||||
providerUserId: text("provider_user_id").notNull(),
|
||||
metadata: text("metadata"),
|
||||
createdAt: text("created_at").notNull(),
|
||||
updatedAt: text("updated_at").notNull(),
|
||||
}, (t) => [
|
||||
primaryKey({ columns: [t.projectId, t.id] }),
|
||||
foreignKey({
|
||||
columns: [t.projectId, t.userId],
|
||||
foreignColumns: [legacyProjectAuthUsers.projectId, legacyProjectAuthUsers.id],
|
||||
}).onDelete("cascade"),
|
||||
uniqueIndex("idxLegacyProjectAuthProvidersIdentity").on(t.projectId, t.provider, t.providerUserId),
|
||||
index("idxLegacyProjectAuthProvidersUser").on(t.projectId, t.userId),
|
||||
]);
|
||||
|
||||
export const legacyProjectAuthSessions = projectSchema.table("project_auth_sessions", {
|
||||
projectId: text("project_id").notNull(),
|
||||
id: text("id").notNull(),
|
||||
userId: text("user_id").notNull(),
|
||||
membershipId: text("membership_id").notNull(),
|
||||
sessionToken: text("session_token").notNull(),
|
||||
expiresAt: text("expires_at").notNull(),
|
||||
revokedAt: text("revoked_at"),
|
||||
createdAt: text("created_at").notNull(),
|
||||
updatedAt: text("updated_at").notNull(),
|
||||
}, (t) => [
|
||||
primaryKey({ columns: [t.projectId, t.id] }),
|
||||
foreignKey({
|
||||
columns: [t.projectId, t.userId],
|
||||
foreignColumns: [legacyProjectAuthUsers.projectId, legacyProjectAuthUsers.id],
|
||||
}).onDelete("cascade"),
|
||||
foreignKey({
|
||||
columns: [t.projectId, t.membershipId],
|
||||
foreignColumns: [legacyProjectAuthMemberships.projectId, legacyProjectAuthMemberships.id],
|
||||
}).onDelete("cascade"),
|
||||
uniqueIndex("idxLegacyProjectAuthSessionsToken").on(t.projectId, t.sessionToken),
|
||||
index("idxLegacyProjectAuthSessionsUser").on(t.projectId, t.userId),
|
||||
index("idxLegacyProjectAuthSessionsMembership").on(t.projectId, t.membershipId),
|
||||
index("idxLegacyProjectAuthSessionsExpiry").on(t.projectId, t.expiresAt),
|
||||
]);
|
||||
|
||||
export const legacyTaskReviewerRuns = projectSchema.table("task_reviewer_runs", {
|
||||
projectId: text("project_id").notNull(),
|
||||
id: text("id").notNull(),
|
||||
taskId: text("task_id").notNull(),
|
||||
boardId: text("board_id").notNull().default(""),
|
||||
status: text("status").notNull().default("pending"),
|
||||
summary: text("summary"),
|
||||
failureReasons: text("failure_reasons"),
|
||||
reviewerAgentId: text("reviewer_agent_id"),
|
||||
reworkRound: integer("rework_round").notNull().default(0),
|
||||
startedAt: text("started_at").notNull(),
|
||||
completedAt: text("completed_at"),
|
||||
createdAt: text("created_at").notNull(),
|
||||
updatedAt: text("updated_at").notNull(),
|
||||
invalidatedAt: text("invalidated_at"),
|
||||
}, (t) => [
|
||||
primaryKey({ columns: [t.projectId, t.id] }),
|
||||
index("idxLegacyTaskReviewerRunsTask").on(t.projectId, t.taskId),
|
||||
index("idxLegacyTaskReviewerRunsStatus").on(t.projectId, t.status),
|
||||
]);
|
||||
|
||||
// ── Distributed task ID allocator ────────────────────────────────────
|
||||
/*
|
||||
FNXC:CentralProjectIdentity 2026-07-13-22:40:
|
||||
@@ -1755,7 +1872,9 @@ export const chatRoomMessages = projectSchema.table("chat_room_messages", {
|
||||
* entry (drift signal).
|
||||
*/
|
||||
export const projectTableNames = [
|
||||
"tasks", "config", "distributed_task_id_state", "distributed_task_id_reservations",
|
||||
"tasks", "config", "boards", "project_auth_users", "project_auth_memberships",
|
||||
"project_auth_providers", "project_auth_sessions", "task_reviewer_runs",
|
||||
"distributed_task_id_state", "distributed_task_id_reservations",
|
||||
"workflow_steps", "workflows", "task_workflow_selection", "activity_log",
|
||||
"archived_tasks", "task_commit_associations", "automations", "agents",
|
||||
"agent_heartbeats", "agent_runs", "agent_task_sessions", "agent_api_keys",
|
||||
|
||||
@@ -529,6 +529,17 @@ function disposableSqliteTableReason(virtualTables: readonly string[], table: st
|
||||
return "SQLite internal bookkeeping table";
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:PostgresMigrationCompleteness 2026-07-14-09:27:
|
||||
tasks_fts and archived_tasks_fts contain derived search indexes, not the authoritative task records. PostgreSQL regenerates both surfaces from migrated task rows through generated tsvector columns, so the two canonical virtual tables and their shadows are intentional skips; extension-owned FTS tables still fail closed.
|
||||
*/
|
||||
if (
|
||||
virtualTables.includes(table) &&
|
||||
(table === "tasks_fts" || table === "archived_tasks_fts")
|
||||
) {
|
||||
return `FTS5 index replaced by PostgreSQL tsvector for ${table}`;
|
||||
}
|
||||
|
||||
for (const name of virtualTables) {
|
||||
/*
|
||||
FNXC:PostgresMigration 2026-07-13-23:02:
|
||||
@@ -1054,6 +1065,20 @@ async function insertBatch(
|
||||
return sql`(${sql.join(cells, sql`, `)})`;
|
||||
});
|
||||
|
||||
/*
|
||||
FNXC:PostgresMigrationCompleteness 2026-07-14-09:27:
|
||||
The fresh PostgreSQL baseline seeds the two central singleton rows so a database without SQLite can boot. During SQLite cutover those defaults are placeholders: the legacy singleton is authoritative and must replace them instead of being discarded by ON CONFLICT DO NOTHING. No other table receives overwrite semantics.
|
||||
*/
|
||||
const replacesCentralSeed =
|
||||
plan.pgSchema === CENTRAL_SCHEMA &&
|
||||
(plan.pgTable === "central_settings" || plan.pgTable === "global_concurrency");
|
||||
const conflictClause = replacesCentralSeed
|
||||
? sql.raw(`ON CONFLICT (${quoteIdent("id")}) DO UPDATE SET ${cols
|
||||
.filter((column) => column.pgName !== "id")
|
||||
.map((column) => `${quoteIdent(column.pgName)} = EXCLUDED.${quoteIdent(column.pgName)}`)
|
||||
.join(", ")}`)
|
||||
: sql`ON CONFLICT DO NOTHING`;
|
||||
|
||||
/*
|
||||
FNXC:PostgresMigration 2026-07-13-21:05:
|
||||
RETURNING 1 makes the inserted-row count driver-agnostic: the result carries
|
||||
@@ -1065,7 +1090,7 @@ async function insertBatch(
|
||||
*/
|
||||
const query = sql`INSERT INTO ${sql.raw(schemaQualifiedTable)} (${sql.raw(colList)})${sql.raw(overridingClause)}
|
||||
VALUES ${sql.join(valueRowsBuilt, sql`, `)}
|
||||
ON CONFLICT DO NOTHING
|
||||
${conflictClause}
|
||||
RETURNING 1`;
|
||||
|
||||
const result = (await db.execute(query)) as unknown as { length?: number };
|
||||
@@ -1243,21 +1268,24 @@ function computeSourceContentChecksum(
|
||||
): string {
|
||||
if (cols.length === 0) return "";
|
||||
const selectCols = cols.map((c) => quoteIdent(c.sqliteName)).join(", ");
|
||||
const orderCols = cols.map((c) => quoteIdent(c.sqliteName)).join(", ");
|
||||
const rows = sqlite
|
||||
.prepare(`SELECT ${selectCols} FROM ${quoteIdent(table)} ORDER BY ${orderCols}`)
|
||||
.prepare(`SELECT ${selectCols} FROM ${quoteIdent(table)}`)
|
||||
.all() as Array<Record<string, unknown>>;
|
||||
|
||||
const hash = createHash("md5");
|
||||
for (const row of rows) {
|
||||
const canonicalRows = rows.map((row) => {
|
||||
let canonical = "";
|
||||
for (const col of cols) {
|
||||
const converted = col.pgName === "project_id" && partitionProjectId
|
||||
? partitionProjectId
|
||||
: convertValue(row[col.sqliteName], col.type, col.nullJsonbFallback);
|
||||
hash.update(canonicalizeCell(converted));
|
||||
hash.update("\u0001"); // cell separator
|
||||
canonical += `${canonicalizeCell(converted)}\u0001`;
|
||||
}
|
||||
hash.update("\u0002"); // row separator
|
||||
return canonical;
|
||||
}).sort();
|
||||
const hash = createHash("md5");
|
||||
for (const row of canonicalRows) {
|
||||
hash.update(row);
|
||||
hash.update("\u0002");
|
||||
}
|
||||
return hash.digest("hex");
|
||||
}
|
||||
@@ -1283,19 +1311,26 @@ async function computeTargetContentChecksum(
|
||||
): Promise<string> {
|
||||
if (cols.length === 0) return "";
|
||||
const selectCols = cols.map((c) => quoteIdent(c.pgName)).join(", ");
|
||||
const orderCols = cols.map((c) => `${quoteIdent(c.pgName)} NULLS FIRST`).join(", ");
|
||||
const rows = (await db.execute(
|
||||
sql`SELECT ${sql.raw(selectCols)} FROM ${sql.raw(quoteIdent(pgSchema))}.${sql.raw(
|
||||
quoteIdent(table),
|
||||
)}${projectId ? sql` WHERE project_id = ${projectId}` : sql``} ORDER BY ${sql.raw(orderCols)}`,
|
||||
)}${projectId ? sql` WHERE project_id = ${projectId}` : sql``}`,
|
||||
)) as unknown as Array<Record<string, unknown>>;
|
||||
|
||||
const hash = createHash("md5");
|
||||
for (const row of rows) {
|
||||
/*
|
||||
FNXC:PostgresMigrationCompleteness 2026-07-14-09:27:
|
||||
Content verification must not depend on database collation. SQLite BINARY and PostgreSQL locale collation can order identical mixed-case paths differently, so both sides canonicalize complete rows and sort those strings in Node before hashing.
|
||||
*/
|
||||
const canonicalRows = rows.map((row) => {
|
||||
let canonical = "";
|
||||
for (const col of cols) {
|
||||
hash.update(canonicalizeCell(row[col.pgName]));
|
||||
hash.update("\u0001");
|
||||
canonical += `${canonicalizeCell(row[col.pgName])}\u0001`;
|
||||
}
|
||||
return canonical;
|
||||
}).sort();
|
||||
const hash = createHash("md5");
|
||||
for (const row of canonicalRows) {
|
||||
hash.update(row);
|
||||
hash.update("\u0002");
|
||||
}
|
||||
return hash.digest("hex");
|
||||
|
||||
Reference in New Issue
Block a user