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.
This commit is contained in:
gsxdsm
2026-07-20 16:10:46 -07:00
parent 9ad97317cb
commit ba08d90574
6 changed files with 184 additions and 1 deletions

View File

@@ -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.

View File

@@ -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,
]);
});
});

View File

@@ -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".

View File

@@ -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 $$;

View File

@@ -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 };
});
}

View File

@@ -457,6 +457,34 @@ async function ensureMigrationStateTable(db: PostgresJsDatabase<Record<string, n
last_error text,
updated_at timestamptz NOT NULL DEFAULT now()
)`));
await db.execute(sql.raw(`
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'fusion_runtime') THEN
RETURN;
END IF;
ALTER TABLE public.${SQLITE_MIGRATION_STATE_TABLE} ENABLE ROW LEVEL SECURITY;
IF NOT EXISTS (
SELECT 1
FROM pg_policy
WHERE polrelid = 'public.${SQLITE_MIGRATION_STATE_TABLE}'::regclass
AND polname = 'fusion_sqlite_migrations_project_read'
) THEN
CREATE POLICY fusion_sqlite_migrations_project_read
ON public.${SQLITE_MIGRATION_STATE_TABLE}
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.${SQLITE_MIGRATION_STATE_TABLE} TO fusion_runtime;
END $$;
`));
}
export interface SqliteMigrationState {