Fix plugin database namespace purge

This commit is contained in:
Dotta committed 2026-04-22 11:05:49 -05:00
1 parent 8f3aa3519d
commit 41caa24390
4 files changed
+87

No files matched your search

@@ -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);
});
});
+36
View File
@@ -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<string[]> {
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);
+4
View File
@@ -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)
+9
View File
@@ -412,6 +412,11 @@ export interface PluginLoader {
*/
cleanupInstallArtifacts(plugin: PluginRecord): Promise<void>;
/**
* Drop the host-managed restricted database namespace for a plugin purge.
*/
cleanupDatabaseNamespace(plugin: PluginRecord): Promise<string[]>;
/**
* Get the local plugin directory this loader is configured to use.
*/
@@ -1427,6 +1432,10 @@ export function pluginLoader(
}
},
async cleanupDatabaseNamespace(plugin: PluginRecord): Promise<string[]> {
return pluginDatabaseService(migrationDb).purgeNamespace(plugin.id, plugin.manifestJson);
},
// -----------------------------------------------------------------------
// getLocalPluginDir
// -----------------------------------------------------------------------