Delete jellyfin, install it again at a newer commit, connect a server — and no accounts were created,
no mapping rows written, and no error anywhere. Every step reported success.
The new checkout writes manifest.ts at exactly the path the old one occupied, and `await import(path)`
returns the module it already has: ESM caches by resolved specifier, and the specifier did not change.
So the platform kept obeying the manifest of a plugin that was no longer on disk. That manifest
predates `userProvisioning`, so the plugin was judged not entitled to the account list, the sidecar
was sent nothing, and provisioning skipped with "the platform has not sent the account list yet" —
a report that goes into the response body nobody reads.
It began working four minutes later for a reason that had nothing to do with the plugin: officer was
restarted for an unrelated deploy, which emptied the module registry.
`importFresh` keys the import on the file's mtime, so a file that changed is evaluated again and one
that did not is still a cache hit — `discoverPlugins` runs on every sidecar registration, every mount
and every plugins page load, so a Date.now() key would leak a module instance each time.
Used for the manifest, the api router and the db schema. For manifests it is complete: every manifest
imports one thing, `import type { PluginManifest }`, which is erased before it runs, so they are leaf
modules. For the other two it narrows the window rather than closing it — only the named file is
re-read, and its own static imports come back from the cache. A restart is still the only thing that
guarantees a plugin's whole module graph is the one on disk, and that is written down in the file
rather than left to be rediscovered.
The test pins the failure and not the mechanism: one case proves a plain import keeps serving a
manifest that is no longer on disk, the next proves importFresh sees the replacement, and a third
proves an unchanged file is evaluated once. mtimes are set explicitly, because two writes inside one
millisecond would otherwise decide the result.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
69 lines
3.5 KiB
TypeScript
69 lines
3.5 KiB
TypeScript
import { getTableName, is, sql } from 'drizzle-orm';
|
|
import { PgTable } from 'drizzle-orm/pg-core';
|
|
import { db } from 'officerdb/db';
|
|
import type { DiscoveredPlugin } from './manifest';
|
|
import { importFresh } from './import-fresh';
|
|
|
|
// 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: [] };
|
|
|
|
// Deleting a plugin reads this to know which tables it owns. A stale copy would name the wrong ones,
|
|
// which for a delete-with-data means dropping the wrong tables or missing some.
|
|
const module = await importFresh<Record<string, unknown>>(plugin.schema);
|
|
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`));
|
|
}
|
|
}
|