From 41caa24390282eef425b751f20efd19f591305a4 Mon Sep 17 00:00:00 2001 From: Dotta Date: Tue, 21 Apr 2026 01:18:28 -0500 Subject: [PATCH] Fix plugin database namespace purge --- server/src/__tests__/plugin-database.test.ts | 38 ++++++++++++++++++++ server/src/services/plugin-database.ts | 36 +++++++++++++++++++ server/src/services/plugin-lifecycle.ts | 4 +++ server/src/services/plugin-loader.ts | 9 +++++ 4 files changed, 87 insertions(+) diff --git a/server/src/__tests__/plugin-database.test.ts b/server/src/__tests__/plugin-database.test.ts index 9aaca2f15e1..d06537d1cf2 100644 --- a/server/src/__tests__/plugin-database.test.ts +++ b/server/src/__tests__/plugin-database.test.ts @@ -266,4 +266,42 @@ describeEmbeddedPostgres("plugin database namespaces", () => { await expect(pluginDb.applyMigrations(pluginId, pluginManifest, packageRoot)) .rejects.toThrow(/checksum mismatch/i); }); + + it("purges retained namespace tables before a hard-delete reinstall", async () => { + const pluginManifest = manifest(); + const namespace = derivePluginDatabaseNamespace(pluginManifest.id); + const packageRoot = await createPluginPackage( + pluginManifest, + `CREATE TABLE ${namespace}.mission_rows (id uuid PRIMARY KEY);`, + ); + const pluginDb = pluginDatabaseService(db); + const firstPluginId = await installPluginRecord(pluginManifest); + + await pluginDb.applyMigrations(firstPluginId, pluginManifest, packageRoot); + let tableExists = await db.execute(sql` + SELECT to_regclass(${`${namespace}.mission_rows`}) AS table_name + `); + expect(Array.from(tableExists as Iterable<{ table_name: string | null }>)[0]?.table_name) + .toBe(`${namespace}.mission_rows`); + + const purged = await pluginDb.purgeNamespace(firstPluginId, pluginManifest); + expect(purged).toContain(namespace); + await db.delete(plugins).where(eq(plugins.id, firstPluginId)); + + tableExists = await db.execute(sql` + SELECT to_regclass(${`${namespace}.mission_rows`}) AS table_name + `); + expect(Array.from(tableExists as Iterable<{ table_name: string | null }>)[0]?.table_name) + .toBeNull(); + + const secondPluginId = await installPluginRecord(pluginManifest); + await expect(pluginDb.applyMigrations(secondPluginId, pluginManifest, packageRoot)) + .resolves.toMatchObject({ namespaceName: namespace }); + + const migrations = await db + .select() + .from(pluginMigrations) + .where(and(eq(pluginMigrations.pluginId, secondPluginId), eq(pluginMigrations.status, "applied"))); + expect(migrations).toHaveLength(1); + }); }); diff --git a/server/src/services/plugin-database.ts b/server/src/services/plugin-database.ts index e1822e45cd2..5c66d602863 100644 --- a/server/src/services/plugin-database.ts +++ b/server/src/services/plugin-database.ts @@ -51,6 +51,14 @@ function quoteIdentifier(value: string): string { return `"${assertIdentifier(value).replaceAll("\"", "\"\"")}"`; } +function assertPluginNamespaceName(value: string): string { + assertIdentifier(value, "plugin namespace"); + if (!value.startsWith("plugin_")) { + throw new Error(`Refusing to purge non-plugin database namespace: ${value}`); + } + return value; +} + function splitSqlStatements(input: string): string[] { const statements: string[] = []; let start = 0; @@ -400,6 +408,34 @@ export function pluginDatabaseService(db: Db) { return { ensureNamespace, + async purgeNamespace(pluginId: string, manifest: PaperclipPluginManifestV1): Promise { + if (!manifest.database) return []; + + const expectedNamespace = derivePluginDatabaseNamespace( + manifest.id, + manifest.database.namespaceSlug, + ); + const rows = await db + .select() + .from(pluginDatabaseNamespaces) + .where(eq(pluginDatabaseNamespaces.pluginId, pluginId)); + const namespaceNames = new Set([ + expectedNamespace, + ...rows.map((row) => row.namespaceName), + ]); + + const purged: string[] = []; + for (const namespaceName of namespaceNames) { + const safeNamespace = assertPluginNamespaceName(namespaceName); + await db.execute(sql.raw(`DROP SCHEMA IF EXISTS ${quoteIdentifier(safeNamespace)} CASCADE`)); + purged.push(safeNamespace); + } + + await db.delete(pluginMigrations).where(eq(pluginMigrations.pluginId, pluginId)); + await db.delete(pluginDatabaseNamespaces).where(eq(pluginDatabaseNamespaces.pluginId, pluginId)); + return purged; + }, + async applyMigrations(pluginId: string, manifest: PaperclipPluginManifestV1, packageRoot: string) { if (!manifest.database) return null; const namespace = await ensureNamespace(pluginId, manifest); diff --git a/server/src/services/plugin-lifecycle.ts b/server/src/services/plugin-lifecycle.ts index e9f9b02b125..715d0b42727 100644 --- a/server/src/services/plugin-lifecycle.ts +++ b/server/src/services/plugin-lifecycle.ts @@ -541,6 +541,7 @@ export function pluginLifecycleManager( // If already uninstalled and removeData, hard-delete if (plugin.status === "uninstalled") { if (removeData) { + await pluginLoaderInstance.cleanupDatabaseNamespace(plugin); await pluginLoaderInstance.cleanupInstallArtifacts(plugin); const deleted = await registry.uninstall(pluginId, true); log.info( @@ -561,6 +562,9 @@ export function pluginLifecycleManager( } await deactivatePluginRuntime(pluginId, plugin.pluginKey); + if (removeData) { + await pluginLoaderInstance.cleanupDatabaseNamespace(plugin); + } await pluginLoaderInstance.cleanupInstallArtifacts(plugin); // Perform the uninstall via registry (handles soft/hard delete) diff --git a/server/src/services/plugin-loader.ts b/server/src/services/plugin-loader.ts index 95801180153..55e0c86bb88 100644 --- a/server/src/services/plugin-loader.ts +++ b/server/src/services/plugin-loader.ts @@ -412,6 +412,11 @@ export interface PluginLoader { */ cleanupInstallArtifacts(plugin: PluginRecord): Promise; + /** + * Drop the host-managed restricted database namespace for a plugin purge. + */ + cleanupDatabaseNamespace(plugin: PluginRecord): Promise; + /** * Get the local plugin directory this loader is configured to use. */ @@ -1427,6 +1432,10 @@ export function pluginLoader( } }, + async cleanupDatabaseNamespace(plugin: PluginRecord): Promise { + return pluginDatabaseService(migrationDb).purgeNamespace(plugin.id, plugin.manifestJson); + }, + // ----------------------------------------------------------------------- // getLocalPluginDir // -----------------------------------------------------------------------