diff --git a/.changeset/fix-plugin-migration-startup.md b/.changeset/fix-plugin-migration-startup.md new file mode 100644 index 0000000000..89118b525a --- /dev/null +++ b/.changeset/fix-plugin-migration-startup.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Prevent startup crashes while recovering plugins from retained SQLite data. +category: fix +dev: Runs the plugin bridge before switching PostgreSQL connections to the restricted runtime role. 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 8811fc3dd4..dc29413fb4 100644 --- a/packages/core/src/__tests__/postgres/startup-factory-integration.test.ts +++ b/packages/core/src/__tests__/postgres/startup-factory-integration.test.ts @@ -81,6 +81,41 @@ function seedLegacyRegistry(globalDir: string, projects: Array<{ id: string; pat } } +function seedLegacyPlugin(root: string): void { + const fusionDir = join(root, ".fusion"); + mkdirSync(fusionDir, { recursive: true }); + const legacy = new DatabaseSync(join(fusionDir, "fusion.db")); + try { + legacy.exec(`CREATE TABLE plugins ( + id TEXT PRIMARY KEY, name TEXT NOT NULL, version TEXT NOT NULL, + description TEXT, author TEXT, homepage TEXT, path TEXT NOT NULL, + enabled INTEGER DEFAULT 1, state TEXT NOT NULL DEFAULT 'installed', + settings TEXT DEFAULT '{}', settingsSchema TEXT, error TEXT, + dependencies TEXT DEFAULT '[]', aiScanOnLoad INTEGER NOT NULL DEFAULT 0, + lastSecurityScan TEXT, createdAt TEXT NOT NULL, updatedAt TEXT NOT NULL + )`); + legacy.prepare(`INSERT INTO plugins ( + id, name, version, path, enabled, state, settings, dependencies, + aiScanOnLoad, createdAt, updatedAt + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`) + .run( + "legacy-startup-plugin", + "Legacy startup plugin", + "1.0.0", + "/plugins/legacy-startup-plugin", + 1, + "installed", + "{}", + "[]", + 0, + "2026-01-01T00:00:00.000Z", + "2026-01-01T00:00:00.000Z", + ); + } finally { + legacy.close(); + } +} + pgDescribe("startup-factory: external PostgreSQL boot (integration)", () => { let rootDir: string; let dbName: string; @@ -143,6 +178,69 @@ pgDescribe("startup-factory: external PostgreSQL boot (integration)", () => { await second!.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". + */ + it("migrates retained plugin rows before returning the restricted runtime store", async () => { + rootDir = await mkdtemp(join(tmpdir(), "startup-factory-plugin-bridge-")); + 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}, 'complete', NULL, now()) + `; + } finally { + await admin.end(); + } + seedLegacyPlugin(rootDir); + + const second = await createTaskStoreForBackend({ + rootDir, + env: { DATABASE_URL: testUrl }, + poolMax: 1, + }); + try { + await expect(second.taskStore.getPluginStore().init()).resolves.toBeUndefined(); + const client = postgres(testUrl, { max: 1 }); + try { + const installs = await client<{ id: string }[]>` + SELECT id FROM central.plugin_installs WHERE id = 'legacy-startup-plugin' + `; + const states = await client<{ plugin_id: string }[]>` + SELECT plugin_id FROM central.project_plugin_states + WHERE project_path = ${rootDir} AND plugin_id = 'legacy-startup-plugin' + `; + expect(installs).toEqual([{ id: "legacy-startup-plugin" }]); + expect(states).toEqual([{ plugin_id: "legacy-startup-plugin" }]); + } finally { + await client.end(); + } + } finally { + await second.shutdown(); + } + }); + /* * FNXC:PostgresMigration 2026-07-10: * First-boot auto-migration (review data-loss trap): booting the PG backend diff --git a/packages/core/src/plugin-store.ts b/packages/core/src/plugin-store.ts index 893b0c1465..27259cf337 100644 --- a/packages/core/src/plugin-store.ts +++ b/packages/core/src/plugin-store.ts @@ -201,15 +201,9 @@ export class PluginStore extends EventEmitter { async init(): Promise { if (this.backendMode) { /* - FNXC:PluginLegacyMigration 2026-07-14-22:50: - PostgreSQL plugin reads use central.plugin_installs plus path-scoped project_plugin_states. The retained-SQLite bridge runs once per project behind a durable PostgreSQL marker so projects cut over before this bridge existed recover their state without making fusion.db a recurring runtime authority. + FNXC:PluginLegacyMigration 2026-07-15-02:09: + PostgreSQL plugin reads use central.plugin_installs plus path-scoped project_plugin_states. The startup factory completes the retained-SQLite bridge with its privileged migration connection before constructing the runtime layer; PluginStore.init must not attempt DDL or migration writes through the restricted project-scoped role used by dashboard, serve, desktop, and engine startup. */ - const { migrateLegacyProjectPluginRows } = await import("./postgres/sqlite-migrator.js"); - await migrateLegacyProjectPluginRows( - this.asyncLayer!.db, - join(this.rootDir, ".fusion", "fusion.db"), - this.normalizedProjectPath, - ); return; } const _ = this.localDb; diff --git a/packages/core/src/postgres/startup-factory.ts b/packages/core/src/postgres/startup-factory.ts index 21681864b0..c4109f5751 100644 --- a/packages/core/src/postgres/startup-factory.ts +++ b/packages/core/src/postgres/startup-factory.ts @@ -527,7 +527,7 @@ export async function createTaskStoreForBackend( on the empty-PG path where one-time auto-migration is considered. */ const migrationKey = `project:${migrationProjectId ?? rootDir}`; - const { migrateSqliteToPostgres, defaultMigrationSources, formatMigrationProgress, isSqliteMigrationComplete, completeSqliteMigration, recordSqliteMigrationComplete, CENTRAL_SQLITE_MIGRATION_KEY } = await import("./sqlite-migrator.js"); + const { migrateSqliteToPostgres, migrateLegacyProjectPluginRows, defaultMigrationSources, formatMigrationProgress, isSqliteMigrationComplete, completeSqliteMigration, recordSqliteMigrationComplete, CENTRAL_SQLITE_MIGRATION_KEY } = await import("./sqlite-migrator.js"); const migrationComplete = await isSqliteMigrationComplete(connections.migration, migrationKey); if (!migrationComplete && isValidSqliteDatabaseFile(legacySqlitePath)) { // The central (global-dir) source is optional: when no global dir is @@ -634,6 +634,17 @@ export async function createTaskStoreForBackend( log.log(`startup-factory: SQLite → PostgreSQL auto-migration complete (${migratedRows} row(s) across ${report.tables.length} table(s))`); } } + /* + FNXC:PluginLegacyMigration 2026-07-15-02:09: + The retained-SQLite plugin bridge requires schema-marker and central plugin writes, so steady-state startup must run it through the privileged migration connection before that connection is replaced by the project-scoped fusion_runtime role. This bridge remains independently marker-gated because projects that completed the core cutover before plugin migration existed still need their plugin state recovered; every runtime surface receives the already-migrated store and PluginStore.init stays DDL-free. + */ + if (isValidSqliteDatabaseFile(legacySqlitePath)) { + await migrateLegacyProjectPluginRows( + connections.migration, + legacySqlitePath, + rootDir, + ); + } } } catch (err) { await connections.close().catch(() => undefined);