a plugin creates its own tables, and uninstalling never drops them

the last unwired step. install now generates a drizzle barrel of plugin schemas
and runs db:push, so a plugin with db/schema.ts brings its tables with it.

the barrel follows the plugin DIRECTORIES on disk, not the install table, and
that difference is the entire safety property. push drops what it cannot see, so
a barrel tracking installs would delete a plugin's tables the moment it was
uninstalled — turning "stop running this" into "delete my data", which is the
one thing the install model refuses to do. following the directory means:

  directory present, not installed   in the barrel, tables exist unused
  installed                          in the barrel, tables in use
  uninstalled                        STILL in the barrel, every row survives
  directory deleted                  out of the barrel, a push may drop them

so reinstall is a restore, and losing data requires deliberately deleting a
plugin's source.

proved end to end rather than argued. with offscale uninstalled and its entry
removed from the barrel, db:push DROPPED headscale_servers. installing it
recreated the table in 1882ms — columns, both unique indexes including the
partial one that enforces a single active server, and the fk. a canary row then
survived an uninstall AND a subsequent manual db:push, which reported "No
changes detected".

it shells out to the same `bun db:push` a human runs rather than driving
drizzle-kit in-process: one definition of applying the schema instead of two
that can disagree, and an owner can reproduce exactly what an install did.
--force because the barrel only ever gains entries unless source is deleted, and
a prompt with no terminal would hang an install rather than fail it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-15 00:21:11 +00:00
co-authored by Claude Opus 5
parent e13128846b
commit 8587ae20b7
4 changed files with 103 additions and 6 deletions
+18 -6
View File
@@ -2,7 +2,9 @@ import { recordPluginInstall, removePluginInstall, setPluginEnabled } from 'offi
import { PLATFORM_DIR } from '../data-path';
import { deleteProcess, processStatus, startProcess, stopProcess } from '../app-store/pm2';
import { addPluginToEcosystem, pluginProcessName, removePluginFromEcosystem } from './ecosystem';
import { discoverPlugins } from './discover';
import { refreshPluginMounts, snapshotPlugins } from './mount';
import { generatePluginSchemas, pushSchema } from './schema';
import type { DiscoveredPlugin } from './manifest';
// The install runner: the four verbs, each as a short ordered list of effects.
@@ -26,11 +28,10 @@ import type { DiscoveredPlugin } from './manifest';
// Nothing here drops a table, ever. Uninstall means "stop running this", and for a plugin holding a
// user's data the two are unrecoverably different — see `plugin_installs` schema.
//
// `[open]` The schema push. A plugin with `db/schema.ts` still needs its tables created, which means
// regenerating the drizzle barrel and running `db:push`. Deliberately not done in the same pass as this:
// push DROPS tables absent from the schema it is given, so an uninstall that regenerated the barrel would
// delete a plugin's data as a side effect of stopping it — exactly the thing this file refuses to do.
// Offscale does not need it yet (`headscale_servers` already ships in the platform schema).
// The schema push happens on install and NEVER on uninstall. `drizzle-kit push` drops what it cannot
// see, so the generated barrel follows the plugin DIRECTORIES rather than the install table — uninstall
// leaves both the barrel entry and every row alone, and only deleting a plugin's source can lose data.
// See plugins/schema.ts.
/**
* Reported as each step completes, for the streaming endpoint.
@@ -93,7 +94,18 @@ export async function installPlugin(appName: string, onStep?: OnStep): Promise<P
const steps: string[] = [];
try {
if (plugin.schema) await step(steps, onStep, 'schema: skipped — not wired yet (see install.ts)');
if (plugin.schema) {
// The barrel first, then the push. It is generated from the DIRECTORIES on disk rather than from
// what is installed — see schema.ts for why that difference is the whole safety property.
const { plugins } = await discoverPlugins();
generatePluginSchemas(plugins);
const pushed = await pushSchema();
if (!pushed.ok) {
return { ok: false, appName, steps, error: `schema push failed: ${pushed.output}` };
}
await step(steps, onStep, `schema: applied in ${pushed.ms}ms`);
}
await recordPluginInstall(appName, plugin.manifest.version);
await step(steps, onStep, `recorded at ${plugin.manifest.version}`);