Files
platform/src/servers/plugins/orphans.ts
T
pastilhasandClaude Opus 5 e0d3f59b9b name the tables nothing on this machine owns
The marketplace screen now lists tables in the database that no code here claims,
with a row estimate and, where the prefix says so, which absent plugin they look
like they belong to.

This is the information that cost an evening. `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 — and the install reported success while the plugin
was unusable. The cause was two tables from before an extraction, found by reading
`\dt` by hand. Nothing in the platform would ever have mentioned them.

── What counts as an owner ──

The core schema, or a plugin DIRECTORY — not a plugin INSTALL. A plugin that is
present but uninstalled still owns its tables, because that is the promise
uninstall makes: keep every row so a reinstall puts you back where you were. So
this reports only what no code on this machine describes at all, which on a healthy
install is nothing, and the block does not render.

A plugin whose schema will not import has its prefix treated as claimed rather than
having every one of its tables reported as unowned — a broken manifest should not
read as data with no home.

── No delete button, deliberately ──

Dropping a table is not something to offer beside a list of plugins, and the right
action differs per row: `music_favorites` wants the music plugin installed,
`headscale_servers` wants dropping. Naming them is the part that was missing.

Verified by planting `headscale_servers` and `music_playlists_old` and reading them
back through the same call the route makes: the first attributed to nothing, the
second to music, which is not on this machine. Row counts come from
`pg_class.reltuples` — an exact count on a large table is not worth blocking a page
render for a number that only answers "is there anything in here".

tsgo clean, frontend builds, 873 pass / 7 fail unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 10:33:25 +00:00

90 lines
3.9 KiB
TypeScript

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<OrphanTable[]> {
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,
}));
}