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>
This commit is contained in:
2026-08-16 10:33:25 +00:00
co-authored by Claude Opus 5
parent 8d8fbe2903
commit e0d3f59b9b
4 changed files with 168 additions and 4 deletions
@@ -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 = () => {
</div>
</div>
))}
<Orphans orphans={orphans} />
</div>
);
};
/**
* 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 (
<div className="border-t border-duck-dark/10 px-3 py-3">
<div className="flex items-start gap-2">
<Database className="mt-0.5 h-3.5 w-3.5 shrink-0 text-amber-600" />
<div className="min-w-0 flex-1">
<div className="text-xs font-medium text-duck-dark/70">
{orphans.length} table{orphans.length === 1 ? '' : 's'} with no plugin on this machine
</div>
<ul className="mt-1.5 space-y-1">
{orphans.map((table) => (
<li key={table.name} className="font-mono text-[11px] text-duck-dark/50">
{table.name}
<span className="ml-1.5 font-sans text-duck-dark/35">
~{table.rows.toLocaleString()} row{table.rows === 1 ? '' : 's'}
{table.likelyOwner ? ` · looks like ${table.likelyOwner}, which is not installed here` : ''}
</span>
</li>
))}
</ul>
<p className="mt-1.5 text-[11px] text-duck-dark/35">
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.
</p>
</div>
</div>
</div>
);
};
@@ -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. */