From e0d3f59b9bbc5380de1dedd5e8a260ad48260b2f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Sun, 16 Aug 2026 10:33:25 +0000 Subject: [PATCH] name the tables nothing on this machine owns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/servers/api/plugins/router.ts | 10 +++ src/servers/plugins/orphans.ts | 89 +++++++++++++++++++ .../src/apps/Plugins/PluginsList.tsx | 56 +++++++++++- .../officerdev/src/apps/Plugins/usePlugins.ts | 17 +++- 4 files changed, 168 insertions(+), 4 deletions(-) create mode 100644 src/servers/plugins/orphans.ts diff --git a/src/servers/api/plugins/router.ts b/src/servers/api/plugins/router.ts index 98344ddc..04b12fe1 100644 --- a/src/servers/api/plugins/router.ts +++ b/src/servers/api/plugins/router.ts @@ -5,6 +5,7 @@ import { mountPrefix } from '../../plugins/manifest'; import { snapshotPlugins } from '../../plugins/mount'; import { manualInstallHint, reportDependencies } from '../../plugins/os-deps'; import { fetchCatalogue, fetchReadme } from '../../plugins/marketplace'; +import { findOrphanTables } from '../../plugins/orphans'; import { iconUrl } from '../../app-store/assets'; import { installPlugin, @@ -83,7 +84,16 @@ pluginsRouter.get('/', async (ctx) => { const onDisk = new Set(states.map((s) => s.plugin.appName)); const catalogue = (await fetchCatalogue().catch(() => [])).filter((e) => !onDisk.has(e.appName)); + // Tables nothing on this machine claims. Cheap — one pg_class query and one import per plugin — and + // computed here rather than behind its own route so the screen cannot show a plugin list without it. + // Never fatal: a database that will not answer this must not take the plugins page down with it. + const orphans = await findOrphanTables(catalogue.map((e) => e.appName)).catch((err) => { + console.error('[plugins] orphan scan failed —', err instanceof Error ? err.message : err); + return []; + }); + return ctx.json({ + orphans, available: catalogue.map((e) => ({ appName: e.appName, prefix: e.publisher === 'officerdev' ? `/${e.appName}` : `/p/${e.publisher}/${e.appName}`, diff --git a/src/servers/plugins/orphans.ts b/src/servers/plugins/orphans.ts new file mode 100644 index 00000000..99f79201 --- /dev/null +++ b/src/servers/plugins/orphans.ts @@ -0,0 +1,89 @@ +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, + })); +} diff --git a/src/workspaces/officerdev/src/apps/Plugins/PluginsList.tsx b/src/workspaces/officerdev/src/apps/Plugins/PluginsList.tsx index f21b9f90..9da7ce61 100644 --- a/src/workspaces/officerdev/src/apps/Plugins/PluginsList.tsx +++ b/src/workspaces/officerdev/src/apps/Plugins/PluginsList.tsx @@ -1,6 +1,6 @@ import { Link, useSearchParams } from 'react-router'; -import { Puzzle, AlertTriangle } from 'lucide-react'; -import { usePlugins, type PluginItem } from './usePlugins'; +import { Puzzle, AlertTriangle, Database } from 'lucide-react'; +import { usePlugins, type OrphanTable, type PluginItem } from './usePlugins'; import { resolveIcon } from '../../utils/resolve-icon'; // The left panel: every plugin in the tree, installed or not. @@ -17,7 +17,7 @@ const Status = ({ plugin }: { plugin: PluginItem }) => { }; export const PluginsList = () => { - const { plugins, available, broken, isLoading } = usePlugins(); + const { plugins, available, broken, orphans, isLoading } = usePlugins(); const [params] = useSearchParams(); const selected = params.get('selected'); @@ -104,6 +104,56 @@ export const PluginsList = () => { ))} + + + + ); +}; + +/** + * Tables in the database that no code on this machine claims. + * + * Deliberately at the BOTTOM and deliberately quiet. It is not an error — a plugin's tables outlive it on + * purpose, so an uninstalled plugin that is still on disk owns its own — and most machines will never show + * this at all. + * + * It exists because the last time these were present nothing said so. `bun db:push` saw tables the schema + * did not declare, asked a question no non-TTY caller could answer, applied nothing, exited 0, and the + * install reported success while the plugin was unusable. The owner found the cause by reading `\dt` by + * hand. This is that query, shown before it costs anybody an evening. + * + * No delete button. 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. + */ +const Orphans = ({ orphans }: { orphans: OrphanTable[] }) => { + if (orphans.length === 0) return null; + + return ( +
+
+ +
+
+ {orphans.length} table{orphans.length === 1 ? '' : 's'} with no plugin on this machine +
+
    + {orphans.map((table) => ( +
  • + {table.name} + + ~{table.rows.toLocaleString()} row{table.rows === 1 ? '' : 's'} + {table.likelyOwner ? ` · looks like ${table.likelyOwner}, which is not installed here` : ''} + +
  • + ))} +
+

+ Kept, not deleted — uninstalling a plugin never drops its data. Install the plugin to adopt them, or drop + them yourself if they are from something gone for good. +

+
+
); }; diff --git a/src/workspaces/officerdev/src/apps/Plugins/usePlugins.ts b/src/workspaces/officerdev/src/apps/Plugins/usePlugins.ts index aa43df6d..4eb2d31b 100644 --- a/src/workspaces/officerdev/src/apps/Plugins/usePlugins.ts +++ b/src/workspaces/officerdev/src/apps/Plugins/usePlugins.ts @@ -86,6 +86,15 @@ export type PluginVerb = 'install' | 'uninstall' | 'enable' | 'disable' | 'purge */ export type ActionEntry = { kind: 'step' | 'log'; text: string }; +/** + * A table in the database that no code on this machine claims. + * + * Not an error — a plugin's tables outlive it deliberately, so an uninstalled-but-present plugin still + * owns its own. These are the ones with nothing left to explain them, and they are worth showing because + * the last time some existed they silently broke an install and were found by reading `\dt` by hand. + */ +export type OrphanTable = { name: string; rows: number; likelyOwner: string | null }; + export type BrokenPlugin = { appName: string; error: string }; /** @@ -126,7 +135,12 @@ export function usePlugins() { const { data, isLoading, error } = useQuery({ queryKey: PLUGINS_KEY, queryFn: () => - client.get<{ plugins: PluginItem[]; available: AvailablePlugin[]; broken: BrokenPlugin[] }>('/plugins'), + client.get<{ + plugins: PluginItem[]; + available: AvailablePlugin[]; + broken: BrokenPlugin[]; + orphans: OrphanTable[]; + }>('/plugins'), }); // Every verb invalidates three caches, because a dock tile is assembled from all three: the plugin list, @@ -223,6 +237,7 @@ export function usePlugins() { /** Offered by the marketplace, absent from this machine. Empty when the catalogue cannot be reached. */ available: data?.available ?? [], broken: data?.broken ?? [], + orphans: data?.orphans ?? [], isLoading, error, /** Steps as they arrive. Cleared when the next verb starts. */