diff --git a/.changeset/calm-skills-postgres.md b/.changeset/calm-skills-postgres.md new file mode 100644 index 0000000000..a7aefe3abf --- /dev/null +++ b/.changeset/calm-skills-postgres.md @@ -0,0 +1,8 @@ +--- +"@runfusion/fusion": patch +"@fusion/core": patch +--- + +summary: Fix dashboard skill discovery lifecycle in PostgreSQL mode. +category: fix +dev: Reuse and close backend-aware project stores, keep request-scoped discovery loaders from mutating persistent plugin runtime state, and make cluster-wide PostgreSQL runtime-role creation race-safe. diff --git a/packages/cli/src/commands/__tests__/daemon.test.ts b/packages/cli/src/commands/__tests__/daemon.test.ts index c6f0953874..7cea39de9b 100644 --- a/packages/cli/src/commands/__tests__/daemon.test.ts +++ b/packages/cli/src/commands/__tests__/daemon.test.ts @@ -320,6 +320,8 @@ const mocks = vi.hoisted(() => { const pluginLoader = { loadPlugin: vi.fn().mockResolvedValue(undefined), loadAllPlugins: vi.fn().mockResolvedValue({ loaded: 0, errors: 0 }), + getPluginSkills: vi.fn().mockReturnValue([]), + stopAllPlugins: vi.fn().mockResolvedValue(undefined), stopPlugin: vi.fn().mockResolvedValue(undefined), reloadPlugin: vi.fn().mockResolvedValue(undefined), getPluginRoutes: vi.fn().mockReturnValue([]), @@ -349,6 +351,7 @@ const mocks = vi.hoisted(() => { }; const refreshAllCustomProviderModels = vi.fn().mockResolvedValue({ refreshed: 0, failed: 0, skipped: 0 }); + const createSkillsAdapterMock = vi.fn().mockReturnValue(undefined); const agentSemaphoreCtor = vi.fn().mockImplementation(function () { return { @@ -470,6 +473,7 @@ const mocks = vi.hoisted(() => { missionAutopilotInstances, missionExecutionLoopInstances, notifierInstances, + pluginLoaderInstances, projectEngineInstances, listenCalls, globalSettingsStoreInstance, @@ -500,6 +504,7 @@ const mocks = vi.hoisted(() => { authStorage, modelRegistry, refreshAllCustomProviderModels, + createSkillsAdapterMock, reset() { taskStores.length = 0; automationStores.length = 0; @@ -574,7 +579,7 @@ resolveCliPackageVersionInfo: vi.fn(() => ({ version: "0.0.0-test", isUnresolved GitHubClient: vi.fn().mockImplementation(function () { return {}; }), - createSkillsAdapter: vi.fn().mockReturnValue(undefined), + createSkillsAdapter: mocks.createSkillsAdapterMock, getProjectSettingsPath: vi.fn().mockReturnValue("/tmp/project/.fusion/settings.json"), loadTlsCredentialsFromEnv: vi.fn().mockReturnValue(undefined), refreshAllCustomProviderModels: mocks.refreshAllCustomProviderModels, @@ -832,6 +837,32 @@ describe("runDaemon", () => { await triggerSignal("SIGINT"); }); + /* + * FNXC:PluginSkillsPostgres 2026-07-14-17:47: + * `fn daemon` skill discovery is metadata-only. Its request-scoped loader must not persist synthetic plugin starts, stops, or errors. + */ + it("keeps request-scoped plugin skill discovery read-only", async () => { + await runDaemon({}); + const adapterOptions = mocks.createSkillsAdapterMock.mock.calls.at(-1)?.[0] as { + getPluginSkills?: (rootDir: string, resolvedProjectStore: (typeof mocks.taskStores)[number]) => Promise; + }; + const resolvedProjectStore = mocks.taskStores[0]; + resolvedProjectStore.getPluginStore().listPlugins.mockResolvedValue([ + { id: "enabled-plugin", updatedAt: "2026-07-14T00:00:00.000Z" }, + ]); + mocks.pluginLoaderCtor.mockClear(); + + await expect(adapterOptions.getPluginSkills?.("/repo-secondary", resolvedProjectStore)).resolves.toEqual([]); + expect(mocks.pluginLoaderCtor).toHaveBeenCalledWith({ + pluginStore: resolvedProjectStore.getPluginStore(), + taskStore: resolvedProjectStore, + persistRuntimeState: false, + }); + expect(mocks.pluginLoaderInstances.at(-1)?.stopAllPlugins).toHaveBeenCalledOnce(); + + await triggerSignal("SIGINT"); + }); + // FNXC:DaemonSignalExit 2026-07-10-14:00: a memory-pressure SIGTERM must exit // non-zero (128+signal) so a `Restart=on-failure` supervisor restarts the // daemon instead of treating the kill as a clean stop. Regression for the diff --git a/packages/cli/src/commands/__tests__/dashboard.test.ts b/packages/cli/src/commands/__tests__/dashboard.test.ts index 149a0555ef..ab46e3d649 100644 --- a/packages/cli/src/commands/__tests__/dashboard.test.ts +++ b/packages/cli/src/commands/__tests__/dashboard.test.ts @@ -42,6 +42,10 @@ const { mockSuperviseSpawn } = vi.hoisted(() => ({ })), })); +const { mockCreateSkillsAdapter } = vi.hoisted(() => ({ + mockCreateSkillsAdapter: vi.fn().mockReturnValue(undefined), +})); + /* FNXC:SystemPanel 2026-07-12-14:35: Fake attached child for runDashboardSupervised: the supervisor now uses a @@ -180,6 +184,7 @@ function makeMockStore() { updateTask: vi.fn().mockResolvedValue({}), getRootDir: vi.fn().mockReturnValue("/tmp/test"), getFusionDir: vi.fn().mockReturnValue("/tmp/test/.fusion"), + getAsyncLayer: vi.fn().mockReturnValue(null), getGlobalSettingsStore: vi.fn(() => ({ getSettings: mockGlobalSettingsGetSettings, updateSettings: mockGlobalSettingsUpdateSettings, @@ -264,6 +269,8 @@ vi.mock("@fusion/core", async (importOriginal) => { return { loadPlugin: vi.fn().mockResolvedValue(undefined), loadAllPlugins: vi.fn().mockResolvedValue({ loaded: 0, errors: 0 }), + getPluginSkills: vi.fn().mockReturnValue([]), + stopAllPlugins: vi.fn().mockResolvedValue(undefined), stopPlugin: vi.fn().mockResolvedValue(undefined), reloadPlugin: vi.fn().mockResolvedValue(undefined), getPluginRoutes: vi.fn().mockReturnValue([]), @@ -425,7 +432,7 @@ resolveCliPackageVersionInfo: vi.fn(() => ({ version: "0.0.0-test", isUnresolved getPrMergeStatus: mockGetPrMergeStatus, mergePr: mockMergePr, })), - createSkillsAdapter: vi.fn().mockReturnValue(undefined), + createSkillsAdapter: mockCreateSkillsAdapter, getCliPackageVersion: mockGetCliPackageVersion, getProjectSettingsPath: vi.fn().mockReturnValue("/tmp/project/.fusion/settings.json"), loadTlsCredentialsFromEnv: vi.fn().mockReturnValue(undefined), @@ -915,6 +922,54 @@ async function runDashboard(...args: Parameters): Retur // ── Tests ─────────────────────────────────────────────────────────── +describe("runDashboard — project-scoped plugin skills", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("reuses a backend-aware project store instead of constructing a SQLite PluginStore", async () => { + vi.stubEnv("FUSION_NO_EMBEDDED_PG", "1"); + try { + const dashboard = await runDashboard(0, { open: false }); + + const adapterOptions = mockCreateSkillsAdapter.mock.calls.at(-1)?.[0] as + | { getPluginSkills?: (rootDir: string, resolvedProjectStore?: ReturnType) => Promise } + | undefined; + expect(adapterOptions?.getPluginSkills).toBeTypeOf("function"); + + const { PluginLoader, PluginStore, TaskStore } = await import("@fusion/core"); + const scopedStore = makeMockStore(); + vi.mocked(scopedStore.getPluginStore().listPlugins).mockResolvedValue([ + { id: "enabled-plugin", updatedAt: "2026-07-14T00:00:00.000Z" }, + ]); + const taskStoreConstructor = vi.mocked(TaskStore); + taskStoreConstructor.mockClear(); + const pluginStoreConstructor = vi.mocked(PluginStore); + pluginStoreConstructor.mockClear(); + const pluginLoaderConstructor = vi.mocked(PluginLoader); + pluginLoaderConstructor.mockClear(); + + await expect(adapterOptions!.getPluginSkills!("/tmp/other-project", scopedStore)).resolves.toEqual([]); + expect(pluginStoreConstructor).not.toHaveBeenCalled(); + expect(taskStoreConstructor).not.toHaveBeenCalled(); + expect(pluginLoaderConstructor).toHaveBeenCalledWith({ + pluginStore: scopedStore.getPluginStore(), + taskStore: scopedStore, + persistRuntimeState: false, + }); + const scopedPluginLoader = pluginLoaderConstructor.mock.results.at(-1)?.value as { + stopAllPlugins: ReturnType; + }; + expect(scopedPluginLoader.stopAllPlugins).toHaveBeenCalledWith(); + + dashboard.dispose(); + expect(scopedStore.close).not.toHaveBeenCalled(); + } finally { + vi.unstubAllEnvs(); + } + }); +}); + describe("runDashboard — startup model sync", () => { beforeEach(() => { vi.clearAllMocks(); diff --git a/packages/cli/src/commands/__tests__/serve.test.ts b/packages/cli/src/commands/__tests__/serve.test.ts index 154e9eb5f6..51caa16294 100644 --- a/packages/cli/src/commands/__tests__/serve.test.ts +++ b/packages/cli/src/commands/__tests__/serve.test.ts @@ -352,6 +352,8 @@ const mocks = vi.hoisted(() => { const pluginLoader = { loadPlugin: vi.fn().mockResolvedValue(undefined), loadAllPlugins: vi.fn().mockResolvedValue({ loaded: 0, errors: 0 }), + getPluginSkills: vi.fn().mockReturnValue([]), + stopAllPlugins: vi.fn().mockResolvedValue(undefined), stopPlugin: vi.fn().mockResolvedValue(undefined), reloadPlugin: vi.fn().mockResolvedValue(undefined), getPluginRoutes: vi.fn().mockReturnValue([]), @@ -381,6 +383,7 @@ const mocks = vi.hoisted(() => { }; const refreshAllCustomProviderModels = vi.fn().mockResolvedValue({ refreshed: 0, failed: 0, skipped: 0 }); + const createSkillsAdapterMock = vi.fn().mockReturnValue(undefined); const agentSemaphoreCtor = vi.fn().mockImplementation(function () { return { @@ -532,6 +535,7 @@ const mocks = vi.hoisted(() => { missionAutopilotInstances, missionExecutionLoopInstances, notifierInstances, + pluginLoaderInstances, projectEngineInstances, listenCalls, taskStoreCtor, @@ -560,6 +564,7 @@ const mocks = vi.hoisted(() => { authStorage, modelRegistry, refreshAllCustomProviderModels, + createSkillsAdapterMock, globalSettingsGetSettings, reset() { taskStores.length = 0; @@ -634,7 +639,7 @@ resolveCliPackageVersionInfo: vi.fn(() => ({ version: "0.0.0-test", isUnresolved GitHubClient: vi.fn().mockImplementation(function () { return {}; }), - createSkillsAdapter: vi.fn().mockReturnValue(undefined), + createSkillsAdapter: mocks.createSkillsAdapterMock, getProjectSettingsPath: vi.fn().mockReturnValue("/tmp/project/.fusion/settings.json"), loadTlsCredentialsFromEnv: vi.fn().mockReturnValue(undefined), refreshAllCustomProviderModels: mocks.refreshAllCustomProviderModels, @@ -920,6 +925,32 @@ describe("runServe", () => { await triggerSignal("SIGINT"); }); + /* + * FNXC:PluginSkillsPostgres 2026-07-14-17:47: + * `fn serve` skill discovery is metadata-only. Its request-scoped loader must not persist synthetic plugin starts, stops, or errors. + */ + it("keeps request-scoped plugin skill discovery read-only", async () => { + await runServe(0, {}); + const adapterOptions = mocks.createSkillsAdapterMock.mock.calls.at(-1)?.[0] as { + getPluginSkills?: (rootDir: string, resolvedProjectStore: (typeof mocks.taskStores)[number]) => Promise; + }; + const resolvedProjectStore = mocks.taskStores[0]; + resolvedProjectStore.getPluginStore().listPlugins.mockResolvedValue([ + { id: "enabled-plugin", updatedAt: "2026-07-14T00:00:00.000Z" }, + ]); + mocks.pluginLoaderCtor.mockClear(); + + await expect(adapterOptions.getPluginSkills?.("/repo-secondary", resolvedProjectStore)).resolves.toEqual([]); + expect(mocks.pluginLoaderCtor).toHaveBeenCalledWith({ + pluginStore: resolvedProjectStore.getPluginStore(), + taskStore: resolvedProjectStore, + persistRuntimeState: false, + }); + expect(mocks.pluginLoaderInstances.at(-1)?.stopAllPlugins).toHaveBeenCalledOnce(); + + await triggerSignal("SIGINT"); + }); + it("passes remote-capable engine hooks into headless createServer for fn serve parity", async () => { const { createServer } = await import("@fusion/dashboard"); diff --git a/packages/cli/src/commands/daemon.ts b/packages/cli/src/commands/daemon.ts index 4178a391e5..ebbdf0df68 100644 --- a/packages/cli/src/commands/daemon.ts +++ b/packages/cli/src/commands/daemon.ts @@ -776,7 +776,15 @@ export async function runDaemon(opts: DaemonOptions = {}) { } const scopedPluginStore = targetStore.getPluginStore(); - const scopedPluginLoader = new PluginLoader({ pluginStore: scopedPluginStore, taskStore: targetStore }); + /* + * FNXC:PluginSkillsPostgres 2026-07-14-17:47: + * Request-scoped skill discovery is read-only across every CLI server surface. Loading and stopping plugins here must not rewrite durable runtime state for the target project. + */ + const scopedPluginLoader = new PluginLoader({ + pluginStore: scopedPluginStore, + taskStore: targetStore, + persistRuntimeState: false, + }); try { await scopedPluginStore.init(); const { errors } = await scopedPluginLoader.loadAllPlugins(); diff --git a/packages/cli/src/commands/dashboard.ts b/packages/cli/src/commands/dashboard.ts index 5c3a651233..977899c191 100644 --- a/packages/cli/src/commands/dashboard.ts +++ b/packages/cli/src/commands/dashboard.ts @@ -990,8 +990,11 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?: // FNXC:PostgresCutover 2026-07-05-12:00: non-cwd project stores must boot // through the PostgreSQL startup factory; bare `new TaskStore` throws in // backend mode (SQLite runtime removed under VAL-REMOVAL-005). Stores are - // cached for the TUI process lifetime; pools are released at process exit. + // cached for the dashboard process lifetime and explicitly closed during + // dashboard disposal/shutdown. const projectStores = new Map(); + const projectStoreShutdowns = new Map Promise>(); + let projectStoresClosePromise: Promise | undefined; async function getProjectStore(projectPath: string): Promise { const cached = projectStores.get(projectPath); if (cached) return cached; @@ -1003,6 +1006,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?: const boot = await createTaskStoreForBackend({ rootDir: projectPath }); if (boot) { projectStore = boot.taskStore; + projectStoreShutdowns.set(projectPath, boot.shutdown); } else { projectStore = new TaskStore(projectPath); await projectStore.init(); @@ -1011,6 +1015,23 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?: projectStores.set(projectPath, projectStore); return projectStore; } + async function closeProjectStores(): Promise { + projectStoresClosePromise ??= (async () => { + const stores = Array.from(projectStores.entries()).filter(([, projectStore]) => projectStore !== store); + projectStores.clear(); + const shutdowns = new Map(projectStoreShutdowns); + projectStoreShutdowns.clear(); + await Promise.allSettled(stores.map(async ([projectPath, projectStore]) => { + const shutdown = shutdowns.get(projectPath); + if (shutdown) { + await shutdown(); + } else { + await projectStore.close(); + } + })); + })(); + await projectStoresClosePromise; + } // ── U11: resolve per-task workflow column flags for the TUI (flag-ON only) ── // @@ -1831,6 +1852,12 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?: >(); const getProjectScopedPluginSkills = async (rootDir: string, resolvedProjectStore?: TaskStore): Promise> => { const normalizedRootDir = pathResolve(rootDir); + /* + * FNXC:PluginSkillsPostgres 2026-07-14-23:45: + * Skill discovery must use the backend-aware project store resolved by the + * dashboard route. Direct PluginStore/TaskStore construction enters the + * removed SQLite runtime under PostgreSQL (VAL-REMOVAL-005). + */ const targetStore = resolvedProjectStore ?? (normalizedRootDir === pathResolve(store.getRootDir()) ? store : undefined); if (!targetStore) return []; const stateStore = targetStore.getPluginStore(); @@ -1865,7 +1892,11 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?: } const scopedPluginStore = targetStore.getPluginStore(); - const scopedPluginLoader = new PluginLoader({ pluginStore: scopedPluginStore, taskStore: targetStore }); + const scopedPluginLoader = new PluginLoader({ + pluginStore: scopedPluginStore, + taskStore: targetStore, + persistRuntimeState: false, + }); try { await scopedPluginStore.init(); const { errors } = await scopedPluginLoader.loadAllPlugins(); @@ -1935,6 +1966,9 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?: void dashboardBackendShutdown!().catch(() => undefined); }); } + disposeCallbacks.push(() => { + void closeProjectStores(); + }); // ── createServer: deferred until engine is conditionally started ──── // @@ -2297,6 +2331,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?: closeCentralCoreBestEffort(centralCoreForEngine, `shutdown (${signal})`), ); + await timeShutdownStep("closeProjectStores", () => closeProjectStores()); store.close(); process.exit(shutdownExitCode); }; @@ -2630,6 +2665,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?: ); } + await timeShutdownStep("closeProjectStores", () => closeProjectStores()); store.close(); process.exit(shutdownExitCode); }; diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index d7e9f7d522..e42e94b544 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -886,7 +886,15 @@ export async function runServe( } const scopedPluginStore = targetStore.getPluginStore(); - const scopedPluginLoader = new PluginLoader({ pluginStore: scopedPluginStore, taskStore: targetStore }); + /* + * FNXC:PluginSkillsPostgres 2026-07-14-17:47: + * Request-scoped skill discovery is read-only across every CLI server surface. Loading and stopping plugins here must not rewrite durable runtime state for the target project. + */ + const scopedPluginLoader = new PluginLoader({ + pluginStore: scopedPluginStore, + taskStore: targetStore, + persistRuntimeState: false, + }); try { await scopedPluginStore.init(); const { errors } = await scopedPluginLoader.loadAllPlugins(); diff --git a/packages/core/src/__tests__/plugin-hot-reload.test.ts b/packages/core/src/__tests__/plugin-hot-reload.test.ts index 894af1729e..e5ff5eac76 100644 --- a/packages/core/src/__tests__/plugin-hot-reload.test.ts +++ b/packages/core/src/__tests__/plugin-hot-reload.test.ts @@ -304,6 +304,21 @@ describe("PluginLoader Hot-Reload", () => { await expect(pluginLoader.stopPlugin("nonexistent")).resolves.not.toThrow(); expect(pluginLoader.isPluginLoaded("nonexistent")).toBe(false); }); + + it("unloads request-scoped plugins without persisting a stopped runtime state", async () => { + pluginLoader = new PluginLoader({ + pluginStore: mockPluginStore, + taskStore: mockTaskStore, + persistRuntimeState: false, + }); + await pluginLoader.loadPlugin("hot-reload-test"); + expect((mockPluginStore as any)._installation.state).toBe("installed"); + + await pluginLoader.stopAllPlugins(); + + expect(pluginLoader.isPluginLoaded("hot-reload-test")).toBe(false); + expect((mockPluginStore as any)._installation.state).toBe("installed"); + }); }); describe("reloadPlugin() - hot reload", () => { diff --git a/packages/core/src/plugin-loader.ts b/packages/core/src/plugin-loader.ts index c06cc4db0c..b2595a99c9 100644 --- a/packages/core/src/plugin-loader.ts +++ b/packages/core/src/plugin-loader.ts @@ -158,6 +158,8 @@ export interface PluginLoaderOptions { pluginDirs?: string[]; /** npm prefix for resolving packages */ npmPrefix?: string; + /** Persist started/stopped/error runtime state transitions (default true). */ + persistRuntimeState?: boolean; } /** @@ -213,6 +215,15 @@ export class PluginLoader extends EventEmitter<{ super(); } + private async updatePluginState( + pluginId: string, + state: PluginInstallation["state"], + error?: string, + ): Promise { + if (this.options.persistRuntimeState === false) return; + await this.options.pluginStore.updatePluginState(pluginId, state, error); + } + private getProjectRoot(): string { return this.options.taskStore.getRootDir(); } @@ -341,7 +352,7 @@ export class PluginLoader extends EventEmitter<{ if (["blocked", "error", "unavailable"].includes(scanResult.verdict)) { const errorMessage = `Security scan ${scanResult.verdict}: ${scanResult.summary}`; - await this.options.pluginStore.updatePluginState(pluginId, "error", errorMessage); + await this.updatePluginState(pluginId, "error", errorMessage); this.emit("plugin:error", { pluginId, error: new Error(errorMessage) }); throw new Error(errorMessage); } @@ -378,7 +389,7 @@ export class PluginLoader extends EventEmitter<{ await this.resolveDependencies(plugin); // Update state to started - await this.options.pluginStore.updatePluginState(pluginId, "started"); + await this.updatePluginState(pluginId, "started"); // Update plugin state locally and store plugin.state = "started"; @@ -394,7 +405,7 @@ export class PluginLoader extends EventEmitter<{ this.plugins.delete(pluginId); this.pluginRoots.delete(pluginId); const errorMsg = loadErr instanceof Error ? loadErr.message : String(loadErr); - await this.options.pluginStore.updatePluginState( + await this.updatePluginState( pluginId, "error", `onLoad failed: ${errorMsg}`, @@ -417,7 +428,7 @@ export class PluginLoader extends EventEmitter<{ // Error isolation: set error state but don't crash const errorMsg = err instanceof Error ? err.message : String(err); - await this.options.pluginStore.updatePluginState( + await this.updatePluginState( pluginId, "error", errorMsg, @@ -645,7 +656,7 @@ export class PluginLoader extends EventEmitter<{ ); // Update store state back to started - await this.options.pluginStore.updatePluginState(pluginId, "started"); + await this.updatePluginState(pluginId, "started"); this.log.warn(`Rollback successful for ${pluginId}`); } catch (rollbackErr) { @@ -662,7 +673,7 @@ export class PluginLoader extends EventEmitter<{ const rollbackError = rollbackErr instanceof Error ? rollbackErr.message : String(rollbackErr); const combinedError = `Reload failed and rollback failed: ${originalError}; ${rollbackError}`; - await this.options.pluginStore.updatePluginState( + await this.updatePluginState( pluginId, "error", combinedError, @@ -871,8 +882,7 @@ export class PluginLoader extends EventEmitter<{ this.log.error(`Error in onUnload for ${pluginId}:`, err); } - // Update state - await this.options.pluginStore.updatePluginState(pluginId, "stopped"); + await this.updatePluginState(pluginId, "stopped"); // Remove from loaded plugins this.plugins.delete(pluginId); @@ -943,7 +953,7 @@ export class PluginLoader extends EventEmitter<{ // Update plugin state to error try { - await this.options.pluginStore.updatePluginState( + await this.updatePluginState( pluginId, "error", err instanceof Error ? err.message : String(err), diff --git a/packages/core/src/postgres/migrations/0006_project_ownership.sql b/packages/core/src/postgres/migrations/0006_project_ownership.sql index d0070b7813..f4fd0f31c0 100644 --- a/packages/core/src/postgres/migrations/0006_project_ownership.sql +++ b/packages/core/src/postgres/migrations/0006_project_ownership.sql @@ -33,8 +33,18 @@ DECLARE BEGIN SELECT rolsuper INTO current_user_is_superuser FROM pg_roles WHERE rolname = current_user; IF current_user_is_superuser THEN + /* + FNXC:ProjectDataIsolation 2026-07-14-23:45: + PostgreSQL roles are cluster-wide while Gate databases apply this migration + concurrently. Advisory locks are database-local, so make CREATE ROLE itself + race-safe across databases by accepting the concurrent winner. + */ IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'fusion_runtime') THEN - CREATE ROLE fusion_runtime NOLOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION; + BEGIN + CREATE ROLE fusion_runtime NOLOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION; + EXCEPTION + WHEN duplicate_object OR unique_violation THEN NULL; + END; END IF; EXECUTE format('GRANT fusion_runtime TO %I', current_user); END IF;