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)", () => {
|
pgDescribe("startup-factory: external PostgreSQL boot (integration)", () => {
|
||||||
let rootDir: string;
|
let rootDir: string;
|
||||||
let dbName: string;
|
let dbName: string;
|
||||||
@@ -143,6 +178,69 @@ pgDescribe("startup-factory: external PostgreSQL boot (integration)", () => {
|
|||||||
await second!.shutdown();
|
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:
|
* FNXC:PostgresMigration 2026-07-10:
|
||||||
* First-boot auto-migration (review data-loss trap): booting the PG backend
|
* 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> {
|
async init(): Promise<void> {
|
||||||
if (this.backendMode) {
|
if (this.backendMode) {
|
||||||
/*
|
/*
|
||||||
FNXC:PluginLegacyMigration 2026-07-14-22:50:
|
FNXC:PluginLegacyMigration 2026-07-15-02:09:
|
||||||
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.
|
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;
|
return;
|
||||||
}
|
}
|
||||||
const _ = this.localDb;
|
const _ = this.localDb;
|
||||||
|
|||||||
@@ -527,7 +527,7 @@ export async function createTaskStoreForBackend(
|
|||||||
on the empty-PG path where one-time auto-migration is considered.
|
on the empty-PG path where one-time auto-migration is considered.
|
||||||
*/
|
*/
|
||||||
const migrationKey = `project:${migrationProjectId ?? rootDir}`;
|
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);
|
const migrationComplete = await isSqliteMigrationComplete(connections.migration, migrationKey);
|
||||||
if (!migrationComplete && isValidSqliteDatabaseFile(legacySqlitePath)) {
|
if (!migrationComplete && isValidSqliteDatabaseFile(legacySqlitePath)) {
|
||||||
// The central (global-dir) source is optional: when no global dir is
|
// 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))`);
|
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) {
|
} catch (err) {
|
||||||
await connections.close().catch(() => undefined);
|
await connections.close().catch(() => undefined);
|
||||||
|
|||||||
Reference in New Issue
Block a user