import { getTableName, is, sql } from 'drizzle-orm'; import { PgTable } from 'drizzle-orm/pg-core'; import { db } from 'officerdb/db'; import type { DiscoveredPlugin } from './manifest'; import { importFresh } from './import-fresh'; // Which tables a plugin owns, and whether they are actually there. // // ── Why this is asked of the SCHEMA rather than of Postgres ── // // The obvious implementation is `drop table where name like '_%'`. It is also the one that // eventually drops somebody else's table: the `_` prefix is a CONVENTION and nothing validates // it, so a plugin called `music` and a plugin called `music-videos` share a prefix, and a table named by // hand can match a plugin that never created it. // // So the list comes from the plugin's own `db/schema.ts` — the same file `db:push` reads, which makes // "what this plugin created" and "what this deletes" the same question with the same answer. // // The prefix is then used as a GUARD rather than as the query. A schema module is arbitrary TypeScript // and could export `users`; refusing anything not prefixed means the worst a wrong or hostile plugin can // do here is fail to clean up after itself. /** A plugin's tables, by name, as its schema declares them. Empty for a plugin with no `db/`. */ export async function declaredTables(plugin: DiscoveredPlugin): Promise<{ tables: string[]; refused: string[] }> { if (!plugin.schema) return { tables: [], refused: [] }; // Deleting a plugin reads this to know which tables it owns. A stale copy would name the wrong ones, // which for a delete-with-data means dropping the wrong tables or missing some. const module = await importFresh>(plugin.schema); const prefix = `${plugin.appName.replace(/-/g, '_')}_`; const tables: string[] = []; const refused: string[] = []; for (const value of Object.values(module)) { if (!is(value, PgTable)) continue; const name = getTableName(value); // Also accept the literal appName as a table, for a plugin whose single table IS its name. if (name === plugin.appName || name.startsWith(prefix) || name.startsWith(`${plugin.appName}_`)) tables.push(name); else refused.push(name); } return { tables, refused }; } /** Which of these exist in Postgres right now. */ export async function existingTables(names: string[]): Promise { if (names.length === 0) return []; const rows = await db.execute<{ table_name: string }>( sql`select table_name from information_schema.tables where table_schema = 'public' and table_name in ${sql.raw( `(${names.map((n) => `'${n.replace(/'/g, "''")}'`).join(', ')})`, )}`, ); // drizzle's postgres-js driver returns the rows array directly; the pg driver wraps them in `.rows`. const list = Array.isArray(rows) ? rows : ((rows as { rows?: { table_name: string }[] }).rows ?? []); return list.map((r) => r.table_name); } /** * Drop them. `cascade`, because a plugin's tables reference each other and dropping them in dependency * order is a problem this does not need to solve — everything being dropped belongs to the same plugin. * * Names come from `declaredTables`, which has already refused anything outside the plugin's prefix, and * are quoted here as identifiers. Nothing from a URL or a request body reaches this. */ export async function dropTables(names: string[]): Promise { for (const name of names) { await db.execute(sql.raw(`drop table if exists "${name.replace(/"/g, '""')}" cascade`)); } }