Files
fusion/packages/core/src/plugin-store.ts
gsxdsm 5ae6332563 refactor: collapse dead SQLite dual-path code; keep migration-only readers (#2454)
# Remove dead SQLite dual-path code; keep migration-only readers

## Summary
PostgreSQL cutover left hundreds of production dual-path branches
(`backendMode ? PG : SQLite/store.db`) whose SQLite arms only hit
throwing `Database`/`ArchiveDatabase`/`CentralDatabase` stubs. This
change mechanically collapses those unreachable arms so production
authority is AsyncDataLayer/PostgreSQL only, while preserving the six
authorized read-only migration/recovery `DatabaseSync` seams.

## Dual-path mass removed
| Metric | Before | After |
|---|---|---|
| `if (…backendMode)` (non-test) | ~328 | ~70 |
| `store.db` / `this.db` refs in core (non-test) | ~570+ | ~375 (mostly
pure legacy MissionStore/eval/insight SQLite classes + thin getters) |
| Net diff | — | **~6.7k lines removed** across 41 files |

Remaining `backendMode` checks are intentional (incomplete-PG sync
safe-defaults, settings-sync disabled-on-PG, symbol-lock PG-only gates,
“requires PostgreSQL” config versioning throws), not live SQLite
authority.

## Subsystems cleaned
- **Core TaskStore / task-store/***: collapsed if/else and early-return
dual-path across reads, moves, lifecycle, mutations, workflow, archive,
branch/PR, artifacts, comments, audit, project ops, etc. `initImpl` is
PostgreSQL-only (SQLite startup tail deleted).
- **Satellite stores**: automation, agent, routine, plugin, secrets,
approval-request, central-core dual-path arms collapsed.
- **Plugins**: reports async methods, compound-engineering pipeline +
session stores, CLI Printing Press store — SQLite fallbacks removed; PG
required.
- **Engine**: no functional dual-path change beyond whitespace
(settings-sync / peer-exchange PG-disabled behavior kept).

## Six migration-only readers retained (allowlist unchanged)
1. `packages/core/src/postgres/sqlite-migrator.ts`
2. `packages/core/src/project-identity.ts`
3. `packages/core/src/sqlite-validation.ts`
4. `packages/core/src/postgres/startup-factory.ts`
5. `packages/cli/src/commands/db.ts`
6. `scripts/lib/start-local-project.mjs`

Plus low-level `sqlite-adapter` and migrator/startup-import tests.
Inventory ratchet still requires exactly these six `new DatabaseSync(`
production sites, all `readOnly: true`.

## Not treated as SQLite
- `.fusion/project.json`, `task.json`, `agent-log.jsonl` file storage
- AsyncDataLayer / Drizzle PG paths
- Incomplete-PG sync safe-default stubs (still return empty/false/null
under backend without consulting SQLite)

## Verification
- `sqlite-production-reader-inventory.test.ts` — 15/15 pass
- `incomplete-pg-ports.pg.test.ts` — 6/6 pass
- Targeted PG tests (create-task, move, handoff, runtime-persistence,
agent, mission, insight, central-core) — green
- `tsc --noEmit` for `@fusion/core`, `@fusion/engine`,
`@fusion/dashboard` — green
- `scripts/check-no-getdatabase.mjs` — clean

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Improvements**
* Improved end-to-end consistency by making PostgreSQL/async persistence
the standard across core task/workflow, automation, agents, plugins,
routines, secrets, approvals, central operations, and session storage.
* Unified scheduling, settings, configuration revision writes,
run/workflow selection, queues/leases/transitions, and audit/lifecycle
updates around consistent async transaction behavior.
* **Bug Fixes**
* Fixed edge cases for archived/deleted reads, unarchive/recovery flows,
not-found handling, and task/artifact/document/log/comment operations,
including more reliable emissions and hydration across search/list and
lifecycle operations.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-26 23:28:42 -07:00

608 lines
21 KiB
TypeScript

/**
* SQLite-backed PluginStore for managing plugin installations.
*
* Global install metadata is persisted in central DB, while per-project
* enablement/runtime state is persisted per project path.
*/
import { EventEmitter } from "node:events";
import { join, resolve } from "node:path";
import { Database, fromJson } from "./db.js";
import { CentralDatabase } from "./central-db.js";
import type {
PluginInstallation,
PluginManifest,
PluginSecurityScanResult,
PluginSettingSchema,
PluginState,
} from "./plugin-types.js";
import { validatePluginManifest } from "./plugin-types.js";
import { assertProjectRootDir } from "./project-root-guard.js";
import type { AsyncDataLayer } from "./postgres/data-layer.js";
/*
* FNXC:SqliteFinalRemoval 2026-06-26-10:00:
* Async Drizzle helpers for backend-mode (PostgreSQL) PluginStore operations.
* These helpers target the central-schema tables via Drizzle and are the async
* equivalent of the sync centralDb/localDb.prepare() call sites below.
*/
import {
registerPlugin as registerPluginAsync,
unregisterPlugin as unregisterPluginAsync,
getPlugin as getPluginAsync,
listPlugins as listPluginsAsync,
enablePlugin as enablePluginAsync,
disablePlugin as disablePluginAsync,
updatePluginState as updatePluginStateAsync,
updatePluginSettings as updatePluginSettingsAsync,
updatePluginInstall as updatePluginInstallAsync,
} from "./async-plugin-store.js";
export interface PluginStoreEvents {
"plugin:registered": [plugin: PluginInstallation];
"plugin:unregistered": [plugin: PluginInstallation];
"plugin:enabled": [plugin: PluginInstallation];
"plugin:disabled": [plugin: PluginInstallation];
"plugin:updated": [plugin: PluginInstallation];
"plugin:stateChanged": [plugin: PluginInstallation, oldState: PluginState, newState: PluginState];
}
export interface PluginRegistrationInput {
manifest: PluginManifest;
path: string;
settings?: Record<string, unknown>;
aiScanOnLoad?: boolean;
}
export interface PluginUpdateInput {
name?: string;
version?: string;
description?: string;
author?: string;
homepage?: string;
path?: string;
dependencies?: string[];
settingsSchema?: Record<string, PluginSettingSchema> | null;
aiScanOnLoad?: boolean;
lastSecurityScan?: PluginSecurityScanResult;
}
interface LegacyPluginRow {
id: string;
name: string;
version: string;
description: string | null;
author: string | null;
homepage: string | null;
path: string;
enabled: number;
state: string;
settings: string | null;
settingsSchema: string | null;
error: string | null;
dependencies: string | null;
aiScanOnLoad?: number;
lastSecurityScan?: string | null;
createdAt: string;
updatedAt: string;
}
interface InstallRow {
id: string;
name: string;
version: string;
description: string | null;
author: string | null;
homepage: string | null;
path: string;
settings: string | null;
settingsSchema: string | null;
dependencies: string | null;
aiScanOnLoad: number;
lastSecurityScan: string | null;
createdAt: string;
updatedAt: string;
}
interface ProjectStateRow {
projectPath: string;
pluginId: string;
enabled: number;
state: string;
error: string | null;
createdAt: string;
updatedAt: string;
}
export interface PluginStoreOptions {
centralGlobalDir?: string;
/**
* FNXC:SqliteFinalRemoval 2026-06-26-10:05:
* When an AsyncDataLayer is injected, PluginStore operates in "backend mode":
* all data access delegates to PostgreSQL via Drizzle and no SQLite
* Database is constructed. When absent, the legacy SQLite path is
* byte-identical to pre-migration. This mirrors the TaskStore/AgentStore
* dual-path pattern.
*/
asyncLayer?: AsyncDataLayer;
}
export class PluginStore extends EventEmitter<PluginStoreEvents> {
private _localDb: Database | null = null;
private _centralDb: CentralDatabase | null = null;
private readonly normalizedProjectPath: string;
private readonly centralGlobalDir?: string;
/**
* FNXC:SqliteFinalRemoval 2026-06-26-10:05:
* When set, PluginStore operates in backend mode (PostgreSQL via Drizzle).
* All data access delegates to async helpers. No SQLite Database is
* constructed. This mirrors the TaskStore/AgentStore dual-path pattern.
*/
public readonly asyncLayer: AsyncDataLayer | null = null;
/** True when AsyncDataLayer was injected. Gates all SQLite construction. */
public get backendMode(): boolean {
return this.asyncLayer !== null;
}
constructor(
private rootDir: string,
options?: PluginStoreOptions,
) {
super();
assertProjectRootDir(rootDir, "PluginStore");
this.normalizedProjectPath = resolve(rootDir);
this.centralGlobalDir = options?.centralGlobalDir;
this.asyncLayer = options?.asyncLayer ?? null;
}
private get localDb(): Database {
if (this.backendMode) {
throw new Error("SQLite Database is not available in backend mode (asyncLayer injected)");
}
if (!this._localDb) {
const fusionDir = join(this.rootDir, ".fusion");
this._localDb = new Database(fusionDir);
this._localDb.init();
}
return this._localDb;
}
private get centralDb(): CentralDatabase {
if (this.backendMode) {
throw new Error("CentralDatabase is not available in backend mode (asyncLayer injected)");
}
if (!this._centralDb) {
this._centralDb = new CentralDatabase(this.centralGlobalDir);
this._centralDb.init();
}
return this._centralDb;
}
/**
* FNXC:Plugins 2026-06-25-03:31:
* Shared test harnesses clear the file-backed global settings directory between tests while reusing one TaskStore.
* Dispose both plugin database handles first so future plugin access reopens a fresh central DB instead of writing through a connection whose backing file was removed.
*/
close(): void {
this._localDb?.close();
this._localDb = null;
this._centralDb?.close();
this._centralDb = null;
}
/**
* FNXC:SqliteFinalRemoval 2026-06-26-10:10:
* In backend mode (asyncLayer injected), never construct operational SQLite
* stores. A narrowly scoped, read-only bridge may inspect retained plugin
* rows once behind a durable PostgreSQL marker; all subsequent install and
* project-state authority remains in PostgreSQL.
*/
async init(): Promise<void> {
/*
FNXC:SqliteDualPathCleanup 2026-07-26-14:06:
PluginStore.init is a no-op under PostgreSQL. The startup factory completes the retained-SQLite bridge with its privileged migration connection before constructing the runtime layer; do not open SQLite local/central DBs here.
FNXC:PluginLegacyMigration 2026-07-15-02:09:
PostgreSQL plugin reads use central.plugin_installs plus path-scoped project_plugin_states.
*/
}
private validateIdFormat(id: string): boolean {
return /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/.test(id);
}
private validateSettingsAgainstSchema(
settings: Record<string, unknown>,
schema?: Record<string, PluginSettingSchema>,
): string[] {
if (!schema) return [];
const errors: string[] = [];
for (const [key, settingSchema] of Object.entries(schema)) {
const value = settings[key];
if (settingSchema.required && !(key in settings)) {
errors.push(`Setting "${key}" is required`);
continue;
}
if (!(key in settings)) continue;
const expectedType = settingSchema.type;
if (expectedType === "string" && typeof value !== "string") {
errors.push(`Setting "${key}" must be a string`);
} else if (expectedType === "password" && typeof value !== "string") {
errors.push(`Setting "${key}" must be a string`);
} else if (expectedType === "number" && typeof value !== "number") {
errors.push(`Setting "${key}" must be a number`);
} else if (expectedType === "boolean" && typeof value !== "boolean") {
errors.push(`Setting "${key}" must be a boolean`);
} else if (expectedType === "enum") {
if (typeof value !== "string" || !settingSchema.enumValues?.includes(value)) {
errors.push(`Setting "${key}" must be one of: ${settingSchema.enumValues?.join(", ")}`);
}
} else if (expectedType === "array") {
if (!Array.isArray(value)) {
errors.push(`Setting "${key}" must be an array`);
} else {
const itemType = settingSchema.itemType;
for (const item of value) {
if (itemType === "string" && typeof item !== "string") {
errors.push(`Setting "${key}" must be an array of string`);
break;
} else if (itemType === "number" && typeof item !== "number") {
errors.push(`Setting "${key}" must be an array of number`);
break;
}
}
}
}
}
return errors;
}
private rowToPlugin(install: InstallRow, state?: ProjectStateRow): PluginInstallation {
return {
id: install.id,
name: install.name,
version: install.version,
description: install.description || undefined,
author: install.author || undefined,
homepage: install.homepage || undefined,
path: install.path,
enabled: state?.enabled === 1,
state: (state?.state ?? "installed") as PluginState,
settings: fromJson<Record<string, unknown>>(install.settings) || {},
settingsSchema: fromJson<Record<string, PluginSettingSchema>>(install.settingsSchema),
error: state?.error || undefined,
dependencies: fromJson<string[]>(install.dependencies) || [],
aiScanOnLoad: install.aiScanOnLoad === 1,
lastSecurityScan: fromJson<PluginSecurityScanResult>(install.lastSecurityScan ?? null) ?? undefined,
createdAt: install.createdAt,
updatedAt: state?.updatedAt ?? install.updatedAt,
};
}
private getProjectState(pluginId: string): ProjectStateRow | undefined {
return this.centralDb
.prepare("SELECT * FROM project_plugin_states WHERE projectPath = ? AND pluginId = ?")
.get(this.normalizedProjectPath, pluginId) as ProjectStateRow | undefined;
}
private upsertProjectState(
pluginId: string,
updates: { enabled?: boolean; state?: PluginState; error?: string | null },
): ProjectStateRow {
const existing = this.getProjectState(pluginId);
const now = new Date().toISOString();
const row: ProjectStateRow = {
projectPath: this.normalizedProjectPath,
pluginId,
enabled: updates.enabled === undefined ? (existing?.enabled ?? 0) : updates.enabled ? 1 : 0,
state: updates.state ?? existing?.state ?? "installed",
error: updates.error === undefined ? (existing?.error ?? null) : updates.error,
createdAt: existing?.createdAt ?? now,
updatedAt: now,
};
this.centralDb
.prepare(`
INSERT INTO project_plugin_states (projectPath, pluginId, enabled, state, error, createdAt, updatedAt)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(projectPath, pluginId) DO UPDATE SET
enabled = excluded.enabled,
state = excluded.state,
error = excluded.error,
updatedAt = excluded.updatedAt
`)
.run(
row.projectPath,
row.pluginId,
row.enabled,
row.state,
row.error,
row.createdAt,
row.updatedAt,
);
return row;
}
private migrateLegacyProjectRows(): void {
const marker = this.localDb
.prepare("SELECT value FROM __meta WHERE key = 'pluginCentralMigrationV1'")
.get() as { value: string } | undefined;
if (marker?.value === "done") return;
const hasPluginsTable = this.localDb
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'plugins'")
.get() as { name?: string } | undefined;
if (!hasPluginsTable?.name) {
this.localDb
.prepare("INSERT INTO __meta (key, value) VALUES ('pluginCentralMigrationV1', 'done') ON CONFLICT(key) DO UPDATE SET value = excluded.value")
.run();
return;
}
const rows = this.localDb
.prepare("SELECT * FROM plugins ORDER BY updatedAt ASC")
.all() as LegacyPluginRow[];
this.centralDb.transaction(() => {
for (const row of rows) {
const existingInstall = this.centralDb
.prepare("SELECT * FROM plugin_installs WHERE id = ?")
.get(row.id) as InstallRow | undefined;
const takeLegacy = !existingInstall || new Date(row.updatedAt).getTime() >= new Date(existingInstall.updatedAt).getTime();
if (takeLegacy) {
this.centralDb
.prepare(`
INSERT INTO plugin_installs (
id, name, version, description, author, homepage, path,
settings, settingsSchema, dependencies, aiScanOnLoad, lastSecurityScan, createdAt, updatedAt
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
name = excluded.name,
version = excluded.version,
description = excluded.description,
author = excluded.author,
homepage = excluded.homepage,
path = excluded.path,
settings = excluded.settings,
settingsSchema = excluded.settingsSchema,
dependencies = excluded.dependencies,
aiScanOnLoad = excluded.aiScanOnLoad,
lastSecurityScan = excluded.lastSecurityScan,
updatedAt = excluded.updatedAt
`)
.run(
row.id,
row.name,
row.version,
row.description,
row.author,
row.homepage,
row.path,
row.settings ?? "{}",
row.settingsSchema,
row.dependencies ?? "[]",
row.aiScanOnLoad === 1 ? 1 : 0,
row.lastSecurityScan ?? null,
existingInstall?.createdAt ?? row.createdAt,
row.updatedAt,
);
}
this.centralDb
.prepare(`
INSERT INTO project_plugin_states (projectPath, pluginId, enabled, state, error, createdAt, updatedAt)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(projectPath, pluginId) DO UPDATE SET
enabled = excluded.enabled,
state = excluded.state,
error = excluded.error,
updatedAt = excluded.updatedAt
`)
.run(
this.normalizedProjectPath,
row.id,
row.enabled === 1 ? 1 : 0,
row.state,
row.error,
row.createdAt,
row.updatedAt,
);
}
});
this.localDb
.prepare("INSERT INTO __meta (key, value) VALUES ('pluginCentralMigrationV1', 'done') ON CONFLICT(key) DO UPDATE SET value = excluded.value")
.run();
}
async registerPlugin(input: PluginRegistrationInput): Promise<PluginInstallation> {
const { manifest, path, settings = {}, aiScanOnLoad = false } = input;
const manifestValidation = validatePluginManifest(manifest);
if (!manifestValidation.valid) {
throw new Error(`Invalid plugin manifest: ${manifestValidation.errors.join(", ")}`);
}
if (!path?.trim()) {
throw new Error("Plugin path is required and cannot be empty");
}
if (!this.validateIdFormat(manifest.id)) {
throw new Error(
"Plugin id must be a valid slug (lowercase, alphanumeric, hyphens only, cannot start or end with hyphen)",
);
}
/*
* FNXC:SqliteFinalRemoval 2026-06-26-10:15:
* Backend-mode: delegate to the async Drizzle registerPlugin helper which
* inserts the install row + per-project state atomically via a transaction.
*/
const plugin = await registerPluginAsync(this.asyncLayer!, {
manifest,
path,
settings,
aiScanOnLoad,
projectPath: this.normalizedProjectPath,
});
this.emit("plugin:registered", plugin);
return plugin;
}
async unregisterPlugin(id: string): Promise<PluginInstallation> {
/*
* FNXC:SqliteFinalRemoval 2026-06-26-10:15:
* Backend-mode: delegate to the async Drizzle unregisterPlugin helper.
*/
const plugin = await unregisterPluginAsync(this.asyncLayer!.db, id, this.normalizedProjectPath);
this.emit("plugin:unregistered", plugin);
return plugin;
}
async getPlugin(id: string): Promise<PluginInstallation> {
/*
* FNXC:SqliteFinalRemoval 2026-06-26-10:15:
* Backend-mode: delegate to the async Drizzle getPlugin helper.
*/
return getPluginAsync(this.asyncLayer!.db, id, this.normalizedProjectPath);
}
async listPlugins(filter?: { enabled?: boolean; state?: PluginState }): Promise<PluginInstallation[]> {
/*
* FNXC:SqliteFinalRemoval 2026-06-26-10:15:
* Backend-mode: delegate to the async Drizzle listPlugins helper.
*/
return listPluginsAsync(this.asyncLayer!.db, this.normalizedProjectPath, filter);
}
async enablePlugin(id: string): Promise<PluginInstallation> {
/*
FNXC:SqliteDualPathCleanup 2026-07-26-14:06:
Plugin enable is PostgreSQL-only; SQLite upsertProjectState arm deleted.
*/
const updated = await enablePluginAsync(this.asyncLayer!.db, id, this.normalizedProjectPath);
this.emit("plugin:enabled", updated);
this.emit("plugin:updated", updated);
return updated;
}
async disablePlugin(id: string): Promise<PluginInstallation> {
/*
FNXC:SqliteDualPathCleanup 2026-07-26-14:06:
Plugin disable is PostgreSQL-only; SQLite upsertProjectState arm deleted.
*/
const updated = await disablePluginAsync(this.asyncLayer!.db, id, this.normalizedProjectPath);
this.emit("plugin:disabled", updated);
this.emit("plugin:updated", updated);
return updated;
}
async updatePluginState(id: string, state: PluginState, error?: string): Promise<PluginInstallation> {
const plugin = await this.getPlugin(id);
const oldState = plugin.state;
const validStates: PluginState[] = ["installed", "started", "stopped", "error"];
if (!validStates.includes(state)) {
throw new Error(`Invalid state: ${state}`);
}
if (state === oldState) {
// Same-state transitions are idempotent by design. Only emit plugin:updated
// when a provided error payload actually changes persisted plugin fields.
if (error === undefined || plugin.error === error) {
return plugin;
}
/*
FNXC:SqliteDualPathCleanup 2026-07-26-14:06:
Plugin state updates are PostgreSQL-only.
*/
const updated = await updatePluginStateAsync(
this.asyncLayer!.db,
id,
this.normalizedProjectPath,
state,
error,
);
this.emit("plugin:updated", updated);
return updated;
}
if (state !== "error") {
const validTransitions: Record<PluginState, PluginState[]> = {
installed: ["started", "stopped", "error"],
started: ["stopped", "error"],
stopped: ["started", "error"],
error: ["installed", "started", "stopped"],
};
if (!validTransitions[oldState]?.includes(state)) {
throw new Error(`Invalid state transition from "${oldState}" to "${state}"`);
}
}
/*
FNXC:SqliteDualPathCleanup 2026-07-26-14:06:
Plugin state transitions are PostgreSQL-only; SQLite upsertProjectState arm deleted.
*/
const updated = await updatePluginStateAsync(
this.asyncLayer!.db,
id,
this.normalizedProjectPath,
state,
error ?? null,
);
this.emit("plugin:stateChanged", updated, oldState, state);
this.emit("plugin:updated", updated);
return updated;
}
async updatePluginSettings(id: string, settings: Record<string, unknown>): Promise<PluginInstallation> {
const plugin = await this.getPlugin(id);
const validationErrors = this.validateSettingsAgainstSchema(settings, plugin.settingsSchema);
if (validationErrors.length > 0) {
throw new Error(`Settings validation failed: ${validationErrors.join(", ")}`);
}
const mergedSettings = { ...plugin.settings, ...settings };
/*
* FNXC:SqliteFinalRemoval 2026-06-26-10:25:
* Backend-mode: delegate settings persistence to the async helper.
*/
await updatePluginSettingsAsync(this.asyncLayer!.db, id, mergedSettings);
const updated = await this.getPlugin(id);
this.emit("plugin:updated", updated);
return updated;
}
async updatePlugin(id: string, updates: PluginUpdateInput): Promise<PluginInstallation> {
await this.getPlugin(id);
/*
* FNXC:SqliteFinalRemoval 2026-06-26-10:25:
* Backend-mode: delegate install-field persistence to the async helper.
*/
await updatePluginInstallAsync(this.asyncLayer!.db, id, {
name: updates.name,
version: updates.version,
description: updates.description,
author: updates.author,
homepage: updates.homepage,
path: updates.path,
dependencies: updates.dependencies,
aiScanOnLoad: updates.aiScanOnLoad,
lastSecurityScan: updates.lastSecurityScan,
});
const updated = await this.getPlugin(id);
this.emit("plugin:updated", updated);
return updated;
}
}