diff --git a/.gitignore b/.gitignore index 60d6bb36..b33d5d65 100644 --- a/.gitignore +++ b/.gitignore @@ -69,3 +69,4 @@ ecosystem.config.cjs build/ build.next/ src/apps/officer-web/Plugins.gen.tsx +src/databases/officer_db/src/plugin-schemas.gen.ts diff --git a/src/databases/officer_db/src/schema.ts b/src/databases/officer_db/src/schema.ts index a772e587..cb9d738c 100644 --- a/src/databases/officer_db/src/schema.ts +++ b/src/databases/officer_db/src/schema.ts @@ -51,3 +51,11 @@ export * from './service-connections/schema'; // service_connections // export * from './soulseek/schema'; // soulseek_favorites, _browse_snapshots, _browse_dirs // export * from './vault/schema'; // vault_tokens, vault_unlock_keys officer-vault // 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'; diff --git a/src/servers/plugins/install.ts b/src/servers/plugins/install.ts index 671d6541..e85dfe0f 100644 --- a/src/servers/plugins/install.ts +++ b/src/servers/plugins/install.ts @@ -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
{
+ 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