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:
@@ -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}`,
|
||||
|
||||
@@ -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<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,
|
||||
}));
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user