mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-22 00:30:19 +02:00
Fix plugin database namespace purge
This commit is contained in:
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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
Reference in new issue
Block a user