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:
@@ -0,0 +1,65 @@
|
||||
import { getTableName, is, sql } from 'drizzle-orm';
|
||||
import { PgTable } from 'drizzle-orm/pg-core';
|
||||
import { db } from 'officerdb/db';
|
||||
import type { DiscoveredPlugin } from './manifest';
|
||||
|
||||
// Which tables a plugin owns, and whether they are actually there.
|
||||
//
|
||||
// ── Why this is asked of the SCHEMA rather than of Postgres ──
|
||||
//
|
||||
// The obvious implementation is `drop table where name like '<appName>_%'`. It is also the one that
|
||||
// eventually drops somebody else's table: the `<appName>_` prefix is a CONVENTION and nothing validates
|
||||
// it, so a plugin called `music` and a plugin called `music-videos` share a prefix, and a table named by
|
||||
// hand can match a plugin that never created it.
|
||||
//
|
||||
// So the list comes from the plugin's own `db/schema.ts` — the same file `db:push` reads, which makes
|
||||
// "what this plugin created" and "what this deletes" the same question with the same answer.
|
||||
//
|
||||
// The prefix is then used as a GUARD rather than as the query. A schema module is arbitrary TypeScript
|
||||
// and could export `users`; refusing anything not prefixed means the worst a wrong or hostile plugin can
|
||||
// do here is fail to clean up after itself.
|
||||
|
||||
/** A plugin's tables, by name, as its schema declares them. Empty for a plugin with no `db/`. */
|
||||
export async function declaredTables(plugin: DiscoveredPlugin): Promise<{ tables: string[]; refused: string[] }> {
|
||||
if (!plugin.schema) return { tables: [], refused: [] };
|
||||
|
||||
const module = (await import(plugin.schema)) as Record<string, unknown>;
|
||||
const prefix = `${plugin.appName.replace(/-/g, '_')}_`;
|
||||
|
||||
const tables: string[] = [];
|
||||
const refused: string[] = [];
|
||||
for (const value of Object.values(module)) {
|
||||
if (!is(value, PgTable)) continue;
|
||||
const name = getTableName(value);
|
||||
// Also accept the literal appName as a table, for a plugin whose single table IS its name.
|
||||
if (name === plugin.appName || name.startsWith(prefix) || name.startsWith(`${plugin.appName}_`)) tables.push(name);
|
||||
else refused.push(name);
|
||||
}
|
||||
return { tables, refused };
|
||||
}
|
||||
|
||||
/** Which of these exist in Postgres right now. */
|
||||
export async function existingTables(names: string[]): Promise<string[]> {
|
||||
if (names.length === 0) return [];
|
||||
const rows = await db.execute<{ table_name: string }>(
|
||||
sql`select table_name from information_schema.tables where table_schema = 'public' and table_name in ${sql.raw(
|
||||
`(${names.map((n) => `'${n.replace(/'/g, "''")}'`).join(', ')})`,
|
||||
)}`,
|
||||
);
|
||||
// drizzle's postgres-js driver returns the rows array directly; the pg driver wraps them in `.rows`.
|
||||
const list = Array.isArray(rows) ? rows : ((rows as { rows?: { table_name: string }[] }).rows ?? []);
|
||||
return list.map((r) => r.table_name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop them. `cascade`, because a plugin's tables reference each other and dropping them in dependency
|
||||
* order is a problem this does not need to solve — everything being dropped belongs to the same plugin.
|
||||
*
|
||||
* Names come from `declaredTables`, which has already refused anything outside the plugin's prefix, and
|
||||
* are quoted here as identifiers. Nothing from a URL or a request body reaches this.
|
||||
*/
|
||||
export async function dropTables(names: string[]): Promise<void> {
|
||||
for (const name of names) {
|
||||
await db.execute(sql.raw(`drop table if exists "${name.replace(/"/g, '""')}" cascade`));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user