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:
7
.changeset/fix-plugin-migration-startup.md
Normal file
7
.changeset/fix-plugin-migration-startup.md
Normal 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.
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user