feat: give plugins their own tables, migrations and sync entities

This commit is contained in:
LukeGus committed 2026-09-22 15:49:54 -05:00
1 parent 5796186a1c
commit c496a093ca
47 files changed
+32595 -290

No files matched your search

@@ -34,6 +34,7 @@ import { OpenTabRepository } from "./open-tab-repository.js";
import { OpksshTokenRepository } from "./opkssh-token-repository.js";
import { PluginRepository } from "./plugin-repository.js";
import { PluginStorageRepository } from "./plugin-storage-repository.js";
import { PluginMigrationRepository } from "./plugin-migration-repository.js";
import { PluginPermissionGrantRepository } from "./plugin-permission-grant-repository.js";
import { PluginRegistryRepository } from "./plugin-registry-repository.js";
import { PluginInstallCountRepository } from "./plugin-install-count-repository.js";
@@ -393,6 +394,13 @@ export function createCurrentPluginStorageRepository(): PluginStorageRepository
);
}
export function createCurrentPluginMigrationRepository(): PluginMigrationRepository {
return new PluginMigrationRepository(
createCurrentRepositoryContext(),
createCurrentRepositoryWriteHook("plugin_migration_repository_write"),
);
}
export function createCurrentPluginRegistryRepository(): PluginRegistryRepository {
return new PluginRegistryRepository(
createCurrentRepositoryContext(),
@@ -0,0 +1,73 @@
import { and, eq } from "drizzle-orm";
import { pluginMigrations } from "../db/schema.js";
import { insertReturning } from "./returning.js";
import { rowsAffected } from "./mutation-result.js";
import type { DatabaseContext } from "./database-context.js";
export type PluginMigrationRecord = typeof pluginMigrations.$inferSelect;
/**
* Which of a plugin's migrations have run.
*
* The checksum is the reason this is a table rather than a file marker: it
* makes an applied migration immutable, so an edited one is caught instead of
* leaving two installs with quietly different schemas.
*/
export class PluginMigrationRepository {
constructor(
private readonly context: DatabaseContext,
private readonly onWrite?: () => void | Promise<void>,
) {}
async listByPlugin(pluginId: string): Promise<PluginMigrationRecord[]> {
return this.context.drizzle
.select()
.from(pluginMigrations)
.where(eq(pluginMigrations.pluginId, pluginId));
}
async record(
pluginId: string,
migrationId: string,
checksum: string,
now = new Date().toISOString(),
): Promise<void> {
await insertReturning(this.context, pluginMigrations, {
pluginId,
migrationId,
checksum,
appliedAt: now,
});
await this.afterWrite();
}
async deleteByPlugin(pluginId: string): Promise<number> {
const result = await this.context.drizzle
.delete(pluginMigrations)
.where(eq(pluginMigrations.pluginId, pluginId));
const affected = rowsAffected(result);
if (affected > 0) await this.afterWrite();
return affected;
}
async has(pluginId: string, migrationId: string): Promise<boolean> {
const rows = await this.context.drizzle
.select()
.from(pluginMigrations)
.where(
and(
eq(pluginMigrations.pluginId, pluginId),
eq(pluginMigrations.migrationId, migrationId),
),
)
.limit(1);
return rows.length > 0;
}
private async afterWrite(): Promise<void> {
await this.onWrite?.();
}
}
@@ -33,6 +33,16 @@ export class PluginStorageRepository {
return rows.map((row) => row.key);
}
/** How many keys this plugin holds, for the per-plugin key cap. */
async countKeys(pluginId: string): Promise<number> {
const rows = await this.context.drizzle
.select()
.from(pluginStorage)
.where(eq(pluginStorage.pluginId, pluginId));
return rows.length;
}
async set(
pluginId: string,
key: string,
@@ -5,17 +5,16 @@ import type { DatabaseContext } from "./database-context.js";
export type SyncTombstoneRecord = typeof syncTombstones.$inferSelect;
export type SyncEntityType =
| "hosts"
| "sshCredentials"
| "sshFolders"
| "snippets"
| "snippetFolders"
| "vaultProfiles"
| "dashboardServiceLinks"
| "homepageItems"
| "userPreferences"
| "networkTopology";
/**
* The wire name of a synced entity.
*
* A string rather than a union: the set is whatever core and the installed
* plugins have registered, so it cannot be known at compile time. The column
* was already free-form text with no enum constraint, so nothing in the
* database changes. sync.ts validates a name through the registry before it
* reaches a query.
*/
export type SyncEntityType = string;
export class SyncTombstoneRepository {
constructor(