import { getTableName, is, sql } from 'drizzle-orm'; import { PgTable } from 'drizzle-orm/pg-core'; import { db } from 'officerdb/db'; import * as coreSchema from 'officerdb/schema'; import { discoverPlugins } from './discover'; import { declaredTables } from './tables'; // Tables in the database that nothing on this machine claims. // // ── Why this is worth a line on screen ── // // A plugin's tables outlive it on purpose: uninstall keeps every row so a reinstall puts you back where // you were, and only deleting a plugin's source can lose data. The cost of that promise is tables with no // code left to explain them — `headscale_servers` from before a rename, `music_*` on a machine where music // was never installed — and nothing anywhere said they were there. // // That is not cosmetic. It broke an install: `bun db:push` saw tables the schema did not declare, asked an // interactive question no non-TTY caller could answer, applied nothing, and exited 0. The owner found the // cause by reading `\\dt` by hand. The information existed; nothing surfaced it. // // ── What counts as an owner ── // // The CORE schema, or a plugin DIRECTORY on disk — not a plugin INSTALL. A plugin that is present but // uninstalled still owns its tables; that is the whole point of keeping them. So this reports only what // no code on this machine describes at all. export type OrphanTable = { name: string; /** Approximate, from `pg_class.reltuples`. An exact count on a large table is not worth blocking a page. */ rows: number; /** * The plugin whose name prefixes it, if that plugin is known but absent — "music_favorites, and music * is in the marketplace but not on this machine". Null when nothing explains it, which is the case * worth looking at by hand. */ likelyOwner: string | null; }; /** Every table the core schema declares. */ function coreTables(): string[] { // No type predicate: each export is its own precisely-typed table, and a `value is PgTable` guard is not // assignable to that union. `is()` still narrows at runtime, which is the part that matters. const names: string[] = []; for (const value of Object.values(coreSchema)) { if (is(value, PgTable)) names.push(getTableName(value)); } return names; } /** * Tables with no code behind them. * * `knownPlugins` is used only to guess at a name — the marketplace's list, so an absent plugin can be * named rather than left as a bare table. Failure to reach it is not a failure here. */ export async function findOrphanTables(knownPlugins: string[] = []): Promise { const owned = new Set(coreTables()); const { plugins } = await discoverPlugins(); for (const plugin of plugins) { // A plugin whose schema will not import is not evidence that its tables are unowned — treat its // prefix as claimed rather than reporting every table it has as an orphan. const { tables, refused } = await declaredTables(plugin).catch(() => ({ tables: [], refused: [] })); for (const name of [...tables, ...refused]) owned.add(name); } const rows = await db.execute<{ table_name: string; estimate: number }>(sql` select c.relname as table_name, greatest(c.reltuples, 0)::bigint as estimate from pg_class c join pg_namespace n on n.oid = c.relnamespace where n.nspname = 'public' and c.relkind = 'r' order by c.relname `); const list = Array.isArray(rows) ? rows : ((rows as { rows?: { table_name: string; estimate: number }[] }).rows ?? []); const prefixes = plugins.map((p) => p.appName).concat(knownPlugins); return list .filter((row) => !owned.has(row.table_name)) .map((row) => ({ name: row.table_name, rows: Number(row.estimate) || 0, // Longest match wins, so `music-videos_x` is not attributed to `music`. likelyOwner: prefixes.filter((name) => row.table_name.startsWith(`${name}_`)).sort((a, b) => b.length - a.length)[0] ?? null, })); }