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:
@@ -69,3 +69,4 @@ ecosystem.config.cjs
|
|||||||
build/
|
build/
|
||||||
build.next/
|
build.next/
|
||||||
src/apps/officer-web/Plugins.gen.tsx
|
src/apps/officer-web/Plugins.gen.tsx
|
||||||
|
src/databases/officer_db/src/plugin-schemas.gen.ts
|
||||||
|
|||||||
@@ -51,3 +51,11 @@ export * from './service-connections/schema'; // service_connections
|
|||||||
// export * from './soulseek/schema'; // soulseek_favorites, _browse_snapshots, _browse_dirs
|
// export * from './soulseek/schema'; // soulseek_favorites, _browse_snapshots, _browse_dirs
|
||||||
// export * from './vault/schema'; // vault_tokens, vault_unlock_keys officer-vault
|
// export * from './vault/schema'; // vault_tokens, vault_unlock_keys officer-vault
|
||||||
// export * from './wallet/schema'; // wallet_wallets, _labels, _frozen_utxos, _chain_cache
|
// export * from './wallet/schema'; // wallet_wallets, _labels, _frozen_utxos, _chain_cache
|
||||||
|
|
||||||
|
// ── Plugin tables ────────────────────────────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Generated from the plugin DIRECTORIES on disk (servers/plugins/schema.ts), not from what is installed.
|
||||||
|
// push drops what it cannot see, so following the install table would delete a plugin's tables the moment
|
||||||
|
// it was uninstalled. Following the directory means uninstall keeps every row and only deleting a
|
||||||
|
// plugin's source can lose data.
|
||||||
|
export * from './plugin-schemas.gen';
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ import { recordPluginInstall, removePluginInstall, setPluginEnabled } from 'offi
|
|||||||
import { PLATFORM_DIR } from '../data-path';
|
import { PLATFORM_DIR } from '../data-path';
|
||||||
import { deleteProcess, processStatus, startProcess, stopProcess } from '../app-store/pm2';
|
import { deleteProcess, processStatus, startProcess, stopProcess } from '../app-store/pm2';
|
||||||
import { addPluginToEcosystem, pluginProcessName, removePluginFromEcosystem } from './ecosystem';
|
import { addPluginToEcosystem, pluginProcessName, removePluginFromEcosystem } from './ecosystem';
|
||||||
|
import { discoverPlugins } from './discover';
|
||||||
import { refreshPluginMounts, snapshotPlugins } from './mount';
|
import { refreshPluginMounts, snapshotPlugins } from './mount';
|
||||||
|
import { generatePluginSchemas, pushSchema } from './schema';
|
||||||
import type { DiscoveredPlugin } from './manifest';
|
import type { DiscoveredPlugin } from './manifest';
|
||||||
|
|
||||||
// The install runner: the four verbs, each as a short ordered list of effects.
|
// 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
|
// 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.
|
// 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
|
// The schema push happens on install and NEVER on uninstall. `drizzle-kit push` drops what it cannot
|
||||||
// regenerating the drizzle barrel and running `db:push`. Deliberately not done in the same pass as this:
|
// see, so the generated barrel follows the plugin DIRECTORIES rather than the install table — uninstall
|
||||||
// push DROPS tables absent from the schema it is given, so an uninstall that regenerated the barrel would
|
// leaves both the barrel entry and every row alone, and only deleting a plugin's source can lose data.
|
||||||
// delete a plugin's data as a side effect of stopping it — exactly the thing this file refuses to do.
|
// See plugins/schema.ts.
|
||||||
// Offscale does not need it yet (`headscale_servers` already ships in the platform schema).
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reported as each step completes, for the streaming endpoint.
|
* 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[] = [];
|
const steps: string[] = [];
|
||||||
try {
|
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 recordPluginInstall(appName, plugin.manifest.version);
|
||||||
await step(steps, onStep, `recorded at ${plugin.manifest.version}`);
|
await step(steps, onStep, `recorded at ${plugin.manifest.version}`);
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
||||||
|
import { dirname, join, relative } from 'node:path';
|
||||||
|
import { PLATFORM_DIR } from '../data-path';
|
||||||
|
import type { DiscoveredPlugin } from './manifest';
|
||||||
|
|
||||||
|
// Creating a plugin's tables.
|
||||||
|
//
|
||||||
|
// ── The trap this is shaped around ──
|
||||||
|
//
|
||||||
|
// `drizzle-kit push` DROPS anything absent from the schema it is given. So a generated barrel that
|
||||||
|
// tracked what is INSTALLED 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 whole install model refuses to do.
|
||||||
|
//
|
||||||
|
// So the barrel tracks the DIRECTORY, not the install row:
|
||||||
|
//
|
||||||
|
// directory present, not installed in the barrel. tables exist, unused. costs nothing.
|
||||||
|
// installed in the barrel. tables exist and are used.
|
||||||
|
// uninstalled STILL in the barrel. tables and every row survive.
|
||||||
|
// directory deleted out of the barrel — the code is gone, and a push may drop them.
|
||||||
|
//
|
||||||
|
// Which makes reinstall a restore rather than a fresh start, and makes the destructive case require
|
||||||
|
// deliberately deleting a plugin's source. `bun db:push` by hand is then safe at any moment, which it
|
||||||
|
// would not be if this followed the install table.
|
||||||
|
|
||||||
|
const GENERATED = join(PLATFORM_DIR, 'src/databases/officer_db/src/plugin-schemas.gen.ts');
|
||||||
|
|
||||||
|
const HEADER = `// GENERATED by servers/plugins/schema.ts. Do not edit, and do not commit.
|
||||||
|
//
|
||||||
|
// One re-export per plugin DIRECTORY that has a db/schema.ts — not per installed plugin, deliberately.
|
||||||
|
// drizzle-kit push drops what it cannot see, so following the install table would delete a plugin's
|
||||||
|
// tables on uninstall. Following the directory means data survives uninstall and only a deleted plugin
|
||||||
|
// can lose it.
|
||||||
|
`;
|
||||||
|
|
||||||
|
/** Import specifier from the generated file to a plugin's schema, POSIX-style. */
|
||||||
|
const specifier = (target: string): string => {
|
||||||
|
const rel = relative(dirname(GENERATED), target).split('\\').join('/');
|
||||||
|
return (rel.startsWith('.') ? rel : `./${rel}`).replace(/\.ts$/, '');
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Write the barrel. Always written, even empty — `schema.ts` imports it unconditionally. */
|
||||||
|
export function generatePluginSchemas(plugins: DiscoveredPlugin[]): void {
|
||||||
|
const withSchema = plugins.filter((p) => p.schema);
|
||||||
|
const lines = withSchema.map((p) => `export * from '${specifier(p.schema!)}'; // ${p.appName}`);
|
||||||
|
const body = `${HEADER}\n${lines.join('\n')}\n`;
|
||||||
|
|
||||||
|
const previous = existsSync(GENERATED) ? readFileSync(GENERATED, 'utf-8') : '';
|
||||||
|
if (previous !== body) writeFileSync(GENERATED, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PushResult = { ok: boolean; ms: number; output: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply the schema to Postgres.
|
||||||
|
*
|
||||||
|
* Shells out to the same `bun db:push` a human runs, rather than driving drizzle-kit in-process: it is
|
||||||
|
* one definition of "apply the schema" instead of two that can disagree, and an owner can reproduce
|
||||||
|
* exactly what an install did with a command they already know.
|
||||||
|
*
|
||||||
|
* `--force` because there is nothing to prompt about by construction — the barrel only ever gains entries
|
||||||
|
* unless a plugin's source is deleted — and a prompt with no terminal attached would hang the install
|
||||||
|
* forever rather than fail it.
|
||||||
|
*/
|
||||||
|
export async function pushSchema(): Promise<PushResult> {
|
||||||
|
const started = Date.now();
|
||||||
|
const proc = Bun.spawn(['bun', 'run', 'db:push', '--force'], {
|
||||||
|
cwd: PLATFORM_DIR,
|
||||||
|
stdout: 'pipe',
|
||||||
|
stderr: 'pipe',
|
||||||
|
});
|
||||||
|
const [out, err] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]);
|
||||||
|
const code = await proc.exited;
|
||||||
|
// drizzle writes its progress to stderr, so a non-empty stderr is not a failure — only the exit code is.
|
||||||
|
const output = `${out}${err}`.replace(/\u001b\[[0-9;]*[A-Za-z]/g, '').trim();
|
||||||
|
return { ok: code === 0, ms: Date.now() - started, output: output.slice(-600) };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user