From ba08d9057442dfa45fa2abbdc75ab6983bf81995 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 20 Jul 2026 16:10:46 -0700 Subject: [PATCH] fix(core): allow scoped migration health reads Grant the restricted runtime role read-only access to its own SQLite cutover marker. Repair existing databases with migration 0030 and apply the same row-scoped policy when first-boot migration creates the ledger. --- .changeset/read-sqlite-migration-health.md | 7 ++ .../__tests__/postgres/schema-applier.test.ts | 12 +++ .../startup-factory-integration.test.ts | 87 +++++++++++++++++++ .../0030_sqlite_migration_runtime_read.sql | 34 ++++++++ packages/core/src/postgres/schema-applier.ts | 17 +++- packages/core/src/postgres/sqlite-migrator.ts | 28 ++++++ 6 files changed, 184 insertions(+), 1 deletion(-) create mode 100644 .changeset/read-sqlite-migration-health.md create mode 100644 packages/core/src/postgres/migrations/0030_sqlite_migration_runtime_read.sql diff --git a/.changeset/read-sqlite-migration-health.md b/.changeset/read-sqlite-migration-health.md new file mode 100644 index 0000000000..44e326f0e0 --- /dev/null +++ b/.changeset/read-sqlite-migration-health.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Stop PostgreSQL permission errors when the dashboard reads SQLite migration health. +category: fix +dev: Grants the project-bound runtime role row-scoped read access to the SQLite migration ledger. diff --git a/packages/core/src/__tests__/postgres/schema-applier.test.ts b/packages/core/src/__tests__/postgres/schema-applier.test.ts index d844865d5f..e35f5a1fb4 100644 --- a/packages/core/src/__tests__/postgres/schema-applier.test.ts +++ b/packages/core/src/__tests__/postgres/schema-applier.test.ts @@ -72,6 +72,8 @@ import { BIGINT_COUNTERS_VERSION, TASK_VERIFICATION_REQUEST_VERSION, TASK_DECLARED_SYMBOLS_VERSION, + PLANNING_ACTIVE_TIMING_VERSION, + SQLITE_MIGRATION_RUNTIME_READ_VERSION, } from "../../postgres/schema-applier.js"; import { ProjectPartitionRekeyError, rekeyFallbackProjectPartition } from "../../postgres/migration-stamping.js"; import type { PluginSchemaInitHook } from "../../postgres/plugin-schema-hook.js"; @@ -1576,6 +1578,8 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => { BIGINT_COUNTERS_VERSION, WORKFLOW_IR_PIN_AND_LEGACY_ADOPTION_VERSION, TASK_DECLARED_SYMBOLS_VERSION, + PLANNING_ACTIVE_TIMING_VERSION, + SQLITE_MIGRATION_RUNTIME_READ_VERSION, ]); expect((await applySchemaBaseline(ctx.db, { pluginHooks: [] })).applied).toBe(false); }); @@ -1630,6 +1634,8 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => { BIGINT_COUNTERS_VERSION, WORKFLOW_IR_PIN_AND_LEGACY_ADOPTION_VERSION, TASK_DECLARED_SYMBOLS_VERSION, + PLANNING_ACTIVE_TIMING_VERSION, + SQLITE_MIGRATION_RUNTIME_READ_VERSION, ]); }); @@ -1817,6 +1823,8 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => { BIGINT_COUNTERS_VERSION, WORKFLOW_IR_PIN_AND_LEGACY_ADOPTION_VERSION, TASK_DECLARED_SYMBOLS_VERSION, + PLANNING_ACTIVE_TIMING_VERSION, + SQLITE_MIGRATION_RUNTIME_READ_VERSION, ]); }); @@ -1885,6 +1893,8 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => { BIGINT_COUNTERS_VERSION, WORKFLOW_IR_PIN_AND_LEGACY_ADOPTION_VERSION, TASK_DECLARED_SYMBOLS_VERSION, + PLANNING_ACTIVE_TIMING_VERSION, + SQLITE_MIGRATION_RUNTIME_READ_VERSION, ]); }); @@ -1953,6 +1963,8 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => { BIGINT_COUNTERS_VERSION, WORKFLOW_IR_PIN_AND_LEGACY_ADOPTION_VERSION, TASK_DECLARED_SYMBOLS_VERSION, + PLANNING_ACTIVE_TIMING_VERSION, + SQLITE_MIGRATION_RUNTIME_READ_VERSION, ]); }); }); diff --git a/packages/core/src/__tests__/postgres/startup-factory-integration.test.ts b/packages/core/src/__tests__/postgres/startup-factory-integration.test.ts index dc29413fb4..e462af2672 100644 --- a/packages/core/src/__tests__/postgres/startup-factory-integration.test.ts +++ b/packages/core/src/__tests__/postgres/startup-factory-integration.test.ts @@ -18,6 +18,7 @@ import { mkdtemp, rm } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { createTaskStoreForBackend } from "../../postgres/startup-factory.js"; +import { getSqliteMigrationState } from "../../postgres/sqlite-migrator.js"; import { mkdirSync } from "node:fs"; import { DatabaseSync } from "../../sqlite-adapter.js"; import postgres from "postgres"; @@ -178,6 +179,92 @@ pgDescribe("startup-factory: external PostgreSQL boot (integration)", () => { await second!.shutdown(); }); + it("lets the restricted runtime role read an existing SQLite migration marker", async () => { + rootDir = await mkdtemp(join(tmpdir(), "startup-factory-migration-marker-role-")); + dbName = uniqueDbName(); + adminExec(`CREATE DATABASE "${dbName}"`); + const testUrl = `${PG_TEST_URL_BASE}/${dbName}`; + + const first = await createTaskStoreForBackend({ + rootDir, + env: { DATABASE_URL: testUrl }, + poolMax: 1, + }); + const projectId = first.taskStore.getAsyncLayer()!.projectId!; + await first.shutdown(); + + const admin = postgres(testUrl, { max: 1 }); + try { + await admin`CREATE TABLE public.fusion_sqlite_migrations ( + migration_key text PRIMARY KEY, + project_id text, + status text NOT NULL CHECK (status IN ('running', 'complete', 'failed')), + last_error text, + updated_at timestamptz NOT NULL DEFAULT now() + )`; + await admin` + INSERT INTO public.fusion_sqlite_migrations + (migration_key, project_id, status, last_error, updated_at) + VALUES + (${`project:${projectId}`}, ${projectId}, 'failed', 'copy failed', now()), + ('project:other-project', 'other-project', 'failed', 'other copy failed', now()) + `; + await admin`REVOKE ALL ON public.fusion_sqlite_migrations FROM fusion_runtime`; + await admin`DELETE FROM public.fusion_schema_migrations WHERE version = '0030'`; + } finally { + await admin.end(); + } + + const second = await createTaskStoreForBackend({ + rootDir, + env: { DATABASE_URL: testUrl }, + poolMax: 1, + }); + try { + await expect(getSqliteMigrationState( + second.taskStore.getAsyncLayer()!.db, + `project:${projectId}`, + )).resolves.toMatchObject({ + migrationKey: `project:${projectId}`, + projectId, + status: "failed", + }); + await expect(getSqliteMigrationState( + second.taskStore.getAsyncLayer()!.db, + "project:other-project", + )).resolves.toBeNull(); + } finally { + await second.shutdown(); + } + }); + + it("grants migration-marker reads when first-boot SQLite migration creates the table", async () => { + rootDir = await mkdtemp(join(tmpdir(), "startup-factory-new-migration-marker-role-")); + dbName = uniqueDbName(); + adminExec(`CREATE DATABASE "${dbName}"`); + const testUrl = `${PG_TEST_URL_BASE}/${dbName}`; + seedLegacyTask(rootDir, "FN-MARKER-1", "Migration marker grant"); + + const result = await createTaskStoreForBackend({ + rootDir, + env: { DATABASE_URL: testUrl }, + poolMax: 1, + }); + try { + const projectId = result.taskStore.getAsyncLayer()!.projectId!; + await expect(getSqliteMigrationState( + result.taskStore.getAsyncLayer()!.db, + `project:${projectId}`, + )).resolves.toMatchObject({ + migrationKey: `project:${projectId}`, + projectId, + status: "complete", + }); + } finally { + await result.shutdown(); + } + }); + /* FNXC:PluginLegacyMigration 2026-07-15-02:09: Steady-state startup must finish the retained-SQLite plugin bridge through the privileged migration connection before returning a project-scoped runtime store. Dashboard, serve, desktop, and engine startup all initialize PluginStore after the runtime role is active, so PluginStore.init must remain DDL-free and must not crash with "permission denied for schema public". diff --git a/packages/core/src/postgres/migrations/0030_sqlite_migration_runtime_read.sql b/packages/core/src/postgres/migrations/0030_sqlite_migration_runtime_read.sql new file mode 100644 index 0000000000..3779556b06 --- /dev/null +++ b/packages/core/src/postgres/migrations/0030_sqlite_migration_runtime_read.sql @@ -0,0 +1,34 @@ +/* +FNXC:MigrationStatusRuntimeRead 2026-07-20: +Dashboard migration health runs through the project-bound fusion_runtime role. +Grant that role read-only access to the SQLite cutover ledger while row-level +security limits each session to its own project marker. Existing databases need +this forward migration because the ledger is created outside the schema baseline. +*/ +DO $$ +BEGIN + IF to_regclass('public.fusion_sqlite_migrations') IS NULL + OR NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'fusion_runtime') THEN + RETURN; + END IF; + + ALTER TABLE public.fusion_sqlite_migrations ENABLE ROW LEVEL SECURITY; + + IF NOT EXISTS ( + SELECT 1 + FROM pg_policy + WHERE polrelid = 'public.fusion_sqlite_migrations'::regclass + AND polname = 'fusion_sqlite_migrations_project_read' + ) THEN + CREATE POLICY fusion_sqlite_migrations_project_read + ON public.fusion_sqlite_migrations + FOR SELECT + TO fusion_runtime + USING ( + current_setting('fusion.project_bypass', true) = 'on' + OR project_id = NULLIF(current_setting('fusion.project_id', true), '') + ); + END IF; + + GRANT SELECT ON public.fusion_sqlite_migrations TO fusion_runtime; +END $$; diff --git a/packages/core/src/postgres/schema-applier.ts b/packages/core/src/postgres/schema-applier.ts index 86ec1d665c..a78b1a91fc 100644 --- a/packages/core/src/postgres/schema-applier.ts +++ b/packages/core/src/postgres/schema-applier.ts @@ -36,8 +36,12 @@ Advances to 0019 for the import-translation legacy-partition backfill. Per-migra FNXC:PostgresBigintCounters 2026-07-19-12:00: SCHEMA_BASELINE_VERSION advances to 0026 for the bigint counters migration. Per-migration identities above stay fixed; only this latest-version marker moves. + +FNXC:MigrationStatusRuntimeRead 2026-07-20: +SCHEMA_BASELINE_VERSION advances to 0030 for project-scoped runtime reads of +the SQLite cutover ledger. */ -export const SCHEMA_BASELINE_VERSION = "0029"; +export const SCHEMA_BASELINE_VERSION = "0030"; /** FNXC:SymbolLock 2026-07-31-10:00: upgrades need durable task declarations before admission resolves symbols. */ export const TASK_DECLARED_SYMBOLS_VERSION = "0028"; const INITIAL_SCHEMA_VERSION = "0000"; @@ -126,6 +130,8 @@ export const SYMBOL_LOCKS_SCHEMA_VERSION = "0025"; export const BIGINT_COUNTERS_VERSION = "0026"; /** FNXC:TaskTiming 2026-08-01-10:00: existing clusters need planning-session timing columns. */ export const PLANNING_ACTIVE_TIMING_VERSION = "0029"; +/** Dashboard health needs project-scoped, read-only runtime access to the SQLite cutover ledger. */ +export const SQLITE_MIGRATION_RUNTIME_READ_VERSION = "0030"; /** * Thrown when the database was migrated by a NEWER Fusion binary than the one now @@ -317,6 +323,7 @@ const WORKFLOW_IR_PIN_AND_LEGACY_ADOPTION_MIGRATION_PATH = join( const PLANNING_ACTIVE_TIMING_MIGRATION_PATH = join(MIGRATIONS_DIR, "0029_planning_active_timing.sql"); const TASK_DECLARED_SYMBOLS_MIGRATION_PATH = join(MIGRATIONS_DIR, "0028_task_declared_symbols.sql"); +const SQLITE_MIGRATION_RUNTIME_READ_PATH = join(MIGRATIONS_DIR, "0030_sqlite_migration_runtime_read.sql"); /** * Ensure the migration bookkeeping table exists. Lives in the public schema so @@ -414,6 +421,7 @@ export async function applySchemaBaseline( const bigintCountersAlreadyApplied = applied.includes(BIGINT_COUNTERS_VERSION); const workflowIrPinAndLegacyAdoptionAlreadyApplied = applied.includes(WORKFLOW_IR_PIN_AND_LEGACY_ADOPTION_VERSION); const planningActiveTimingAlreadyApplied = applied.includes(PLANNING_ACTIVE_TIMING_VERSION); + const sqliteMigrationRuntimeReadAlreadyApplied = applied.includes(SQLITE_MIGRATION_RUNTIME_READ_VERSION); assertBinaryNotOlderThanDatabase(applied); let schemaChanged = false; @@ -856,6 +864,13 @@ export async function applySchemaBaseline( schemaChanged = true; } + if (!sqliteMigrationRuntimeReadAlreadyApplied) { + const migrationSql = await readFile(SQLITE_MIGRATION_RUNTIME_READ_PATH, "utf8"); + await tx.execute(sql.raw(migrationSql)); + await tx.execute(sql`INSERT INTO public.${sql.identifier(MIGRATION_BOOKKEEPING_TABLE)} (version) VALUES (${SQLITE_MIGRATION_RUNTIME_READ_VERSION}) ON CONFLICT (version) DO NOTHING`); + schemaChanged = true; + } + return { applied: schemaChanged, pluginHooksRun: pluginHooks.length }; }); } diff --git a/packages/core/src/postgres/sqlite-migrator.ts b/packages/core/src/postgres/sqlite-migrator.ts index 23b494bba4..671f9f9949 100644 --- a/packages/core/src/postgres/sqlite-migrator.ts +++ b/packages/core/src/postgres/sqlite-migrator.ts @@ -457,6 +457,34 @@ async function ensureMigrationStateTable(db: PostgresJsDatabase