delete a plugin and everything it owns, and stop trusting exit codes

Two halves of the same problem: a plugin installed onto a fresh machine, reported
success at every step, mounted, started its sidecar, registered — and then failed
its first query with 42P01, because `offscale_servers` had never been created.
Nothing said so, and there was no way back to a clean machine to try again.

── The install now asks Postgres ──

`pushSchema` only checked the exit code, and `db:push` is a compound command:
generate the barrel, cd, run drizzle-kit. It can exit 0 having created nothing.
So the install now reads the plugin's schema for the tables it declares and
verifies they exist, failing the install with a message naming what is missing
instead of leaving a stack trace to appear in a sidecar log minutes later.

Tables that are NOT prefixed with the app name are reported as a warning rather
than a failure — they still get created, but `purge` will not remove them and a
reinstall would find them already there.

── `purge`, next to an uninstall that deliberately keeps everything ──

Uninstall is careful on purpose: every table and row survives so a reinstall puts
you back where you were. That is what makes it safe to try, and it left no way to
get to a CLEAN machine. A second install finds the directory (so the catalogue
hides the plugin) and the tables (so push has nothing to do, and a broken schema
stays broken).

So a different verb, a different word, its own confirmation. It reads the table
list from the schema BEFORE deleting the source, uninstalls (stopping the sidecar
— nothing may be running against tables about to vanish), drops, deletes the
directory, then regenerates the barrel unconditionally, since a barrel naming a
directory that no longer exists is what stopped a server booting yesterday.

The table list comes from the plugin's own schema, not from `like '<app>_%'` —
that query eventually drops somebody else's table, because the prefix is a
convention nothing validates and `music` is a prefix of `music-videos`. The
prefix is used as a GUARD instead: a schema module could export `users`, and
anything outside the plugin's own namespace is refused rather than dropped.

── Verified as a full loop on this machine ──

purge offscale → table dropped, directory deleted, barrel regenerated → the
marketplace offers it again → install → cloned, ssh checked, "schema: applied in
2072ms (1 table verified)", dock pinned, mounted, sidecar started.

I left offscale INSTALLED here rather than restoring the half-state it was in
(no install row, table present, sidecar running).

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 09:46:16 +00:00
co-authored by Claude Opus 5
parent 8ad5a5563e
commit 3c03e61c5e
5 changed files with 250 additions and 9 deletions
+8
View File
@@ -10,6 +10,7 @@ import {
installPlugin,
pluginProcessStatus,
setPluginRunning,
purgePlugin,
uninstallPlugin,
type PluginActionResult,
} from '../../plugins/install';
@@ -170,6 +171,12 @@ pluginsRouter.post('/:appName/install', async (ctx) => {
return ctx.json(result, result.ok ? 200 : 400);
});
// Destructive, and the only route here that is. Named `purge` rather than being a flag on uninstall so
// nothing can reach it by passing a body field to the verb people already use.
pluginsRouter.post('/:appName/purge', async (ctx) => {
return ctx.json(await purgePlugin(ctx.req.param('appName')));
});
pluginsRouter.post('/:appName/uninstall', async (ctx) => {
const result = await uninstallPlugin(ctx.req.param('appName'));
return ctx.json(result, result.ok ? 200 : 400);
@@ -205,6 +212,7 @@ const VERBS = {
uninstall: (appName, onStep) => uninstallPlugin(appName, onStep),
enable: (appName, onStep) => setPluginRunning(appName, true, onStep),
disable: (appName, onStep) => setPluginRunning(appName, false, onStep),
purge: (appName, onStep) => purgePlugin(appName, onStep),
// `satisfies` rather than an annotation: an annotation would widen the keys to `string`, which turns
// `isVerb` into a guard that proves nothing and lets an unknown verb index the map.
} satisfies Record<string, VerbFn>;