fix(core): prevent plugin migration startup crash

Run retained SQLite plugin recovery through the privileged startup connection before handing stores to the restricted PostgreSQL runtime role.
This commit is contained in:
gsxdsm
2026-07-15 02:16:58 -07:00
parent c63637ff44
commit 78ef3075f6
4 changed files with 119 additions and 9 deletions

View File

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

View File

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

View File

@@ -201,15 +201,9 @@ export class PluginStore extends EventEmitter<PluginStoreEvents> {
async init(): Promise<void> {
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;

View File

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