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

+22 -69
View File
@@ -741,6 +741,16 @@ async function initializeCompleteDatabase(): Promise<void> {
FOREIGN KEY (plugin_id) REFERENCES plugins (id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS plugin_migrations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
plugin_id TEXT NOT NULL,
migration_id TEXT NOT NULL,
checksum TEXT NOT NULL,
applied_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE (plugin_id, migration_id),
FOREIGN KEY (plugin_id) REFERENCES plugins (id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS api_keys (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
@@ -2440,29 +2450,20 @@ const migrateSchema = () => {
// --- proxmox-node-history end ---
// --- alerts begin ---
try {
sqlite.prepare("SELECT id FROM alert_rules LIMIT 1").get();
} catch {
// alert_rules, alert_rule_channels and alert_firings were only ever created
// here, never declared in schema.ts, so they never existed on Postgres or
// MySQL at all. Automations replaced them; drop the SQLite leftovers.
for (const table of [
"alert_firings",
"alert_rule_channels",
"alert_rules",
]) {
try {
sqlite.exec(`
CREATE TABLE IF NOT EXISTS alert_rules (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
host_id INTEGER REFERENCES ssh_data(id) ON DELETE CASCADE,
name TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
trigger_type TEXT NOT NULL,
threshold_value REAL,
threshold_duration_seconds INTEGER,
cooldown_minutes INTEGER NOT NULL DEFAULT 15,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
`);
} catch (createError) {
databaseLogger.warn("Failed to create alert_rules table", {
sqlite.exec(`DROP TABLE IF EXISTS ${table};`);
} catch (dropError) {
databaseLogger.warn(`Failed to drop legacy ${table} table`, {
operation: "schema_migration",
error: createError,
error: dropError,
});
}
}
@@ -2489,54 +2490,6 @@ const migrateSchema = () => {
});
}
}
try {
sqlite.prepare("SELECT id FROM alert_rule_channels LIMIT 1").get();
} catch {
try {
sqlite.exec(`
CREATE TABLE IF NOT EXISTS alert_rule_channels (
id INTEGER PRIMARY KEY AUTOINCREMENT,
rule_id INTEGER NOT NULL REFERENCES alert_rules(id) ON DELETE CASCADE,
channel_id INTEGER NOT NULL REFERENCES notification_channels(id) ON DELETE CASCADE
);
`);
} catch (createError) {
databaseLogger.warn("Failed to create alert_rule_channels table", {
operation: "schema_migration",
error: createError,
});
}
}
try {
sqlite.prepare("SELECT id FROM alert_firings LIMIT 1").get();
} catch {
try {
sqlite.exec(`
CREATE TABLE IF NOT EXISTS alert_firings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
rule_id INTEGER NOT NULL REFERENCES alert_rules(id) ON DELETE CASCADE,
host_id INTEGER NOT NULL,
host_name TEXT NOT NULL,
fired_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
resolved_at TEXT,
value REAL,
message TEXT NOT NULL,
severity TEXT NOT NULL DEFAULT 'warning',
acknowledged INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_alert_firings_user_fired
ON alert_firings (user_id, fired_at DESC);
`);
} catch (createError) {
databaseLogger.warn("Failed to create alert_firings table", {
operation: "schema_migration",
error: createError,
});
}
}
// --- alerts end ---
// --- automations begin ---
+29
View File
@@ -2252,4 +2252,33 @@ export const pluginStorage = mysqlTable(
],
);
/**
* Which of a plugin's migrations have been applied.
*
* The checksum is what makes an already-applied migration immutable: editing
* one that has run blocks that plugin rather than silently leaving two
* databases with different shapes. Cascades with the plugin so removing its
* data leaves no ledger behind.
*/
export const pluginMigrations = mysqlTable(
"plugin_migrations",
{
id: int("id").autoincrement().primaryKey(),
pluginId: varchar("plugin_id", { length: 255 })
.notNull()
.references(() => plugins.id, { onDelete: "cascade" }),
migrationId: varchar("migration_id", { length: 255 }).notNull(),
checksum: text("checksum").notNull(),
appliedAt: text("applied_at")
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
},
(table) => [
uniqueIndex("idx_plugin_migrations_plugin_migration").on(
table.pluginId,
table.migrationId,
),
],
);
// --- plugins end ---
+29
View File
@@ -2253,4 +2253,33 @@ export const pluginStorage = pgTable(
],
);
/**
* Which of a plugin's migrations have been applied.
*
* The checksum is what makes an already-applied migration immutable: editing
* one that has run blocks that plugin rather than silently leaving two
* databases with different shapes. Cascades with the plugin so removing its
* data leaves no ledger behind.
*/
export const pluginMigrations = pgTable(
"plugin_migrations",
{
id: serial("id").primaryKey(),
pluginId: varchar("plugin_id", { length: 255 })
.notNull()
.references(() => plugins.id, { onDelete: "cascade" }),
migrationId: varchar("migration_id", { length: 255 }).notNull(),
checksum: text("checksum").notNull(),
appliedAt: text("applied_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
},
(table) => [
uniqueIndex("idx_plugin_migrations_plugin_migration").on(
table.pluginId,
table.migrationId,
),
],
);
// --- plugins end ---
+29
View File
@@ -2251,4 +2251,33 @@ export const pluginStorage = sqliteTable(
],
);
/**
* Which of a plugin's migrations have been applied.
*
* The checksum is what makes an already-applied migration immutable: editing
* one that has run blocks that plugin rather than silently leaving two
* databases with different shapes. Cascades with the plugin so removing its
* data leaves no ledger behind.
*/
export const pluginMigrations = sqliteTable(
"plugin_migrations",
{
id: integer("id").primaryKey({ autoIncrement: true }),
pluginId: text("plugin_id")
.notNull()
.references(() => plugins.id, { onDelete: "cascade" }),
migrationId: text("migration_id").notNull(),
checksum: text("checksum").notNull(),
appliedAt: text("applied_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
},
(table) => [
uniqueIndex("idx_plugin_migrations_plugin_migration").on(
table.pluginId,
table.migrationId,
),
],
);
// --- plugins end ---
@@ -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(
+67
View File
@@ -461,4 +461,71 @@ router.delete(
},
);
/**
* @openapi
* /plugins/{id}/data:
* delete:
* summary: Delete everything a plugin stores
* description: Drops the plugin's own tables and clears its key/value state, capability grants and migration ledger. Disabling a plugin never touches its data; this is the explicit uninstall path, and it cannot be undone. The plugin is deactivated first so nothing is writing while its tables go.
* tags:
* - Plugins
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: string
* responses:
* 200:
* description: What was removed.
* 404:
* description: No such plugin.
* 500:
* description: Failed to remove the plugin's data.
*/
router.delete(
"/:id/data",
authenticateJWT,
requireManagePlugins,
async (req: Request, res: Response) => {
const pluginId = String(req.params.id);
const userId = (req as AuthenticatedRequest).userId;
try {
const record = await createCurrentPluginRepository().findById(pluginId);
if (!record) {
res.status(404).json({ error: "No such plugin" });
return;
}
// Stopped first: dropping a table out from under a running plugin turns
// its next query into an error rather than a clean shutdown.
const { loader } = getPluginRuntime();
if (loader.get(pluginId)?.state === "active") {
await loader.deactivate(pluginId);
}
const { removePluginData } = await import("../../plugins/data.js");
const removed = await removePluginData(pluginId);
databaseLogger.warn(`Removed all data for plugin ${pluginId}`, {
operation: "plugin_remove_data",
pluginId,
userId,
tables: removed.tables.length,
kvKeys: removed.kvKeys,
});
res.json({ id: pluginId, removed });
} catch (error) {
databaseLogger.error(
`Failed to remove data for plugin ${pluginId}`,
error instanceof Error ? error : new Error(String(error)),
{ operation: "plugin_remove_data" },
);
res.status(500).json({ error: "Failed to remove the plugin's data" });
}
},
);
export default router;
@@ -0,0 +1,179 @@
/**
* Core's own sync entities, registered the same way a plugin registers one.
*
* These were a hardcoded ENTITY_CONFIG in sync.ts, a SyncEntityType union in
* the tombstone repository, a REFERENCES map in sync-references.ts and a
* frozen array in electron/. Four lists that had to agree and had no test
* saying they did - which is how networkTopology came to be missing from one
* of them. Now there is one registration per entity and the rest is derived.
*
* The order values reproduce the dependency order the Electron array encoded:
* anything referenced by another entity syncs first, and networkTopology
* syncs last because it holds host ids inside a JSON blob.
*/
import {
hosts,
sshCredentials,
sshFolders,
snippets,
snippetFolders,
vaultProfiles,
dashboardServiceLinks,
homepageItems,
userPreferences,
networkTopology,
} from "../db/schema.js";
import {
CORE_OWNER,
registerEntity,
listEntities,
} from "../../plugins/sync-registry.js";
import { mapTopologyHostIds } from "./sync-references.js";
const CREDENTIAL_REFERENCE = {
field: "credentialId",
syncField: "credentialSyncId",
entityType: "sshCredentials",
} as const;
const HOST_REFERENCES = [
CREDENTIAL_REFERENCE,
{
field: "rdpCredentialId",
syncField: "rdpCredentialSyncId",
entityType: "sshCredentials",
},
{
field: "vncCredentialId",
syncField: "vncCredentialSyncId",
entityType: "sshCredentials",
},
{
field: "telnetCredentialId",
syncField: "telnetCredentialSyncId",
entityType: "sshCredentials",
},
{
field: "vaultProfileId",
syncField: "vaultProfileSyncId",
entityType: "vaultProfiles",
},
{
field: "parentHostId",
syncField: "parentHostSyncId",
entityType: "hosts",
},
] as const;
let registered = false;
/**
* Registers core's entities. Idempotent, because the sync routes and the
* Electron entity endpoint both need the registry primed and neither can
* assume it ran first.
*/
export function registerCoreSyncEntities(): void {
if (registered && listEntities().length > 0) return;
registered = true;
// 10, 20, 30... so a plugin can slot an entity between two core ones
// without every order value having to be renumbered.
registerEntity(CORE_OWNER, {
type: "sshCredentials",
table: sshCredentials,
order: 10,
});
registerEntity(CORE_OWNER, {
type: "vaultProfiles",
table: vaultProfiles,
order: 20,
});
registerEntity(CORE_OWNER, {
type: "sshFolders",
table: sshFolders,
order: 30,
references: [CREDENTIAL_REFERENCE],
});
registerEntity(CORE_OWNER, {
type: "snippetFolders",
table: snippetFolders,
order: 40,
});
registerEntity(CORE_OWNER, {
type: "hosts",
table: hosts,
order: 50,
// connectionOrigin says how this device reached the host, which is never
// true of the other side.
readOnlyFields: ["connectionOrigin"],
references: HOST_REFERENCES,
encryptedFields: ["ssh_data"],
});
registerEntity(CORE_OWNER, {
type: "snippets",
table: snippets,
order: 60,
});
registerEntity(CORE_OWNER, {
type: "dashboardServiceLinks",
table: dashboardServiceLinks,
order: 70,
});
registerEntity(CORE_OWNER, {
type: "homepageItems",
table: homepageItems,
order: 80,
});
registerEntity(CORE_OWNER, {
type: "userPreferences",
table: userPreferences,
order: 90,
singleton: true,
readOnlyFields: ["storageMode"],
});
registerEntity(CORE_OWNER, {
type: "networkTopology",
table: networkTopology,
order: 100,
singleton: true,
// Host ids live inside the topology JSON rather than in a column, so the
// generic reference machinery cannot reach them.
serialize: async (row, resolveSyncId) => ({
...row,
topology: await mapTopologyHostIds(
row.topology as string | null | undefined,
async (id) => {
const numericId = Number(id);
if (!Number.isInteger(numericId)) return null;
return resolveSyncId("hosts", numericId);
},
),
}),
deserialize: async (row, resolveId) => ({
...row,
topology: await mapTopologyHostIds(
row.topology as string | null | undefined,
async (syncId) => {
const id = await resolveId("hosts", syncId);
return id === null ? null : String(id);
},
),
}),
});
}
/** The tables a reference may point at, derived from the registrations. */
export const ENCRYPTED_ENTITY_TABLES: Readonly<Record<string, string>> = {
hosts: "ssh_data",
sshCredentials: "ssh_credentials",
};
+28 -66
View File
@@ -1,6 +1,16 @@
import type { SyncEntityType } from "../repositories/sync-tombstone-repository.js";
import { getEntity } from "../../plugins/sync-registry.js";
export type SyncReferenceEntity = "hosts" | "sshCredentials" | "vaultProfiles";
/**
* An entity another entity's rows may point at.
*
* A plain string rather than a union: the set is whatever is registered, and a
* plugin can both register an entity and reference one. sync.ts resolves the
* name through the registry, so an unknown one is an error there rather than a
* silent fallthrough - which is what the old three-way if/else did, routing
* anything that was not hosts or sshCredentials to vaultProfiles.
*/
export type SyncReferenceEntity = string;
interface TopologyNode {
data?: { id?: string; [key: string]: unknown };
@@ -25,7 +35,7 @@ interface Topology {
* leave group ids untouched. Nodes/edges that fail to resolve (host deleted,
* or not synced to this side yet) are dropped rather than left dangling.
*/
async function mapTopologyHostIds(
export async function mapTopologyHostIds(
topologyJson: string | null | undefined,
mapId: (id: string) => Promise<string | null>,
): Promise<string | null> {
@@ -81,46 +91,16 @@ interface SyncReference {
entityType: SyncReferenceEntity;
}
const CREDENTIAL_REFERENCE: SyncReference = {
field: "credentialId",
syncField: "credentialSyncId",
entityType: "sshCredentials",
};
const HOST_REFERENCES: SyncReference[] = [
CREDENTIAL_REFERENCE,
{
field: "rdpCredentialId",
syncField: "rdpCredentialSyncId",
entityType: "sshCredentials",
},
{
field: "vncCredentialId",
syncField: "vncCredentialSyncId",
entityType: "sshCredentials",
},
{
field: "telnetCredentialId",
syncField: "telnetCredentialSyncId",
entityType: "sshCredentials",
},
{
field: "vaultProfileId",
syncField: "vaultProfileSyncId",
entityType: "vaultProfiles",
},
{
field: "parentHostId",
syncField: "parentHostSyncId",
entityType: "hosts",
},
];
export function orderSyncRows(
entityType: SyncEntityType,
rows: Record<string, unknown>[],
): Record<string, unknown>[] {
if (entityType !== "hosts") return rows;
// Only an entity that references itself needs its rows ordered among
// themselves. Today that is hosts, through parentHostId.
const selfReference = referencesFor(entityType).find(
(reference) => reference.entityType === entityType,
);
if (!selfReference) return rows;
const bySyncId = new Map(
rows
@@ -136,7 +116,7 @@ export function orderSyncRows(
if (visiting.has(row)) return;
visiting.add(row);
const parentSyncId = row.parentHostSyncId;
const parentSyncId = row[selfReference.syncField];
if (typeof parentSyncId === "string") {
const parent = bySyncId.get(parentSyncId);
if (parent) visit(parent);
@@ -151,10 +131,9 @@ export function orderSyncRows(
return ordered;
}
const REFERENCES: Partial<Record<SyncEntityType, SyncReference[]>> = {
hosts: HOST_REFERENCES,
sshFolders: [CREDENTIAL_REFERENCE],
};
function referencesFor(entityType: SyncEntityType): readonly SyncReference[] {
return (getEntity(entityType)?.references ?? []) as readonly SyncReference[];
}
export async function serializeSyncReferences(
entityType: SyncEntityType,
@@ -166,19 +145,10 @@ export async function serializeSyncReferences(
): Promise<Record<string, unknown>> {
const serialized = { ...row };
if (entityType === "networkTopology") {
serialized.topology = await mapTopologyHostIds(
serialized.topology as string | null | undefined,
async (id) => {
const numericId = Number(id);
if (!Number.isInteger(numericId)) return null;
return resolveSyncId("hosts", numericId);
},
);
return serialized;
}
const custom = getEntity(entityType)?.serialize;
if (custom) return custom(serialized, resolveSyncId);
for (const reference of REFERENCES[entityType] ?? []) {
for (const reference of referencesFor(entityType)) {
const id = serialized[reference.field];
serialized[reference.syncField] =
typeof id === "number"
@@ -199,18 +169,10 @@ export async function deserializeSyncReferences(
): Promise<Record<string, unknown>> {
const deserialized = { ...row };
if (entityType === "networkTopology") {
deserialized.topology = await mapTopologyHostIds(
deserialized.topology as string | null | undefined,
async (syncId) => {
const id = await resolveId("hosts", syncId);
return id === null ? null : String(id);
},
);
return deserialized;
}
const custom = getEntity(entityType)?.deserialize;
if (custom) return custom(deserialized, resolveId);
for (const reference of REFERENCES[entityType] ?? []) {
for (const reference of referencesFor(entityType)) {
const syncId = deserialized[reference.syncField];
delete deserialized[reference.syncField];
delete deserialized[reference.field];
+95 -116
View File
@@ -1,17 +1,6 @@
import express, { type Request, type Response } from "express";
import { and, eq, type SQL } from "drizzle-orm";
import {
hosts,
sshCredentials,
sshFolders,
snippets,
snippetFolders,
vaultProfiles,
dashboardServiceLinks,
homepageItems,
userPreferences,
networkTopology,
} from "../db/schema.js";
import { hosts } from "../db/schema.js";
import { AuthManager } from "../../utils/auth-manager.js";
import { DataCrypto } from "../../utils/data-crypto.js";
import { databaseLogger } from "../../utils/logger.js";
@@ -22,6 +11,15 @@ import {
createCurrentSyncTombstoneRepository,
} from "../repositories/factory.js";
import type { SyncEntityType } from "../repositories/sync-tombstone-repository.js";
import {
getEntity,
hasEntity,
listEntities,
} from "../../plugins/sync-registry.js";
import {
ENCRYPTED_ENTITY_TABLES,
registerCoreSyncEntities,
} from "./sync-entities.js";
import {
deserializeSyncReferences,
orderSyncRows,
@@ -31,66 +29,44 @@ import {
import { timestampAtOrAfter } from "../sync-timestamp.js";
import { validateParentHostId } from "./host-parent-validation.js";
// Primed at import so every consumer of this module - the routes below, the
// reference resolvers, the Electron entity-types endpoint - sees the same
// registry regardless of which one runs first.
registerCoreSyncEntities();
const router = express.Router();
const authManager = AuthManager.getInstance();
const authenticateJWT = authManager.createAuthMiddleware();
// Encrypted tables need DataCrypto to translate between the wire payload
// (plaintext) and the stored row (encrypted). Everything else is stored
// and synced as-is.
const ENCRYPTED_ENTITY_TABLES: Partial<Record<SyncEntityType, string>> = {
hosts: "ssh_data",
sshCredentials: "ssh_credentials",
};
/**
* What a sync entity looks like once it is registered.
*
* The table was a hand-maintained union of ten concrete drizzle types, which a
* plugin-provided table could never join. It is the same deliberate
* approximation repositories/database-context.ts documents: the query-builder
* surface used here is identical across the engines and across tables, and
* every use already casts to reach .syncId and .updatedAt.
*/
type SyncTable = typeof hosts;
interface EntityConfig {
table:
| typeof hosts
| typeof sshCredentials
| typeof sshFolders
| typeof snippets
| typeof snippetFolders
| typeof vaultProfiles
| typeof dashboardServiceLinks
| typeof homepageItems
| typeof userPreferences
| typeof networkTopology;
// Fields that only make sense on the device that created the row, or
// that are managed elsewhere and must never be overwritten by a sync
// payload from the other side.
readOnlyFields: string[];
singleton?: boolean;
function entityConfig(entityType: SyncEntityType) {
const entity = getEntity(entityType);
if (!entity) {
throw new Error(`Unknown sync entity "${entityType}"`);
}
return {
table: entity.table as SyncTable,
readOnlyFields: entity.readOnlyFields,
singleton: entity.singleton === true,
};
}
const ENTITY_CONFIG: Record<SyncEntityType, EntityConfig> = {
hosts: {
table: hosts,
readOnlyFields: ["connectionOrigin"],
},
sshCredentials: { table: sshCredentials, readOnlyFields: [] },
sshFolders: { table: sshFolders, readOnlyFields: [] },
snippets: { table: snippets, readOnlyFields: [] },
snippetFolders: { table: snippetFolders, readOnlyFields: [] },
vaultProfiles: { table: vaultProfiles, readOnlyFields: [] },
dashboardServiceLinks: { table: dashboardServiceLinks, readOnlyFields: [] },
homepageItems: { table: homepageItems, readOnlyFields: [] },
userPreferences: {
table: userPreferences,
readOnlyFields: ["storageMode"],
singleton: true,
},
networkTopology: {
table: networkTopology,
readOnlyFields: [],
singleton: true,
},
};
const VALID_ENTITY_TYPES = new Set(Object.keys(ENTITY_CONFIG));
type RepositoryContext = ReturnType<typeof createCurrentRepositoryContext>;
export function isValidEntityType(value: unknown): value is SyncEntityType {
return typeof value === "string" && VALID_ENTITY_TYPES.has(value);
if (typeof value !== "string") return false;
registerCoreSyncEntities();
return hasEntity(value);
}
/**
@@ -106,7 +82,7 @@ export function locateSyncRow(
userId: string,
syncId: string,
): SQL {
const { table, singleton } = ENTITY_CONFIG[entityType];
const { table, singleton } = entityConfig(entityType);
if (singleton) {
return eq(table.userId, userId);
@@ -118,34 +94,25 @@ export function locateSyncRow(
)!;
}
/**
* The syncId of a referenced row.
*
* Generic over the registry rather than an if/else over three known entities.
* The old chain ended in an unguarded else that returned vaultProfiles, so a
* fourth reference target would have silently resolved against the wrong
* table; an unregistered entity is an error here instead.
*/
async function findReferenceSyncId(
context: RepositoryContext,
entityType: SyncReferenceEntity,
id: number,
userId: string,
): Promise<string | null> {
if (entityType === "hosts") {
const [row] = await context.drizzle
.select({ syncId: hosts.syncId })
.from(hosts)
.where(and(eq(hosts.id, id), eq(hosts.userId, userId)))
.limit(1);
return row?.syncId ?? null;
}
if (entityType === "sshCredentials") {
const [row] = await context.drizzle
.select({ syncId: sshCredentials.syncId })
.from(sshCredentials)
.where(and(eq(sshCredentials.id, id), eq(sshCredentials.userId, userId)))
.limit(1);
return row?.syncId ?? null;
}
const { table } = entityConfig(entityType as SyncEntityType);
const [row] = await context.drizzle
.select({ syncId: vaultProfiles.syncId })
.from(vaultProfiles)
.where(and(eq(vaultProfiles.id, id), eq(vaultProfiles.userId, userId)))
.select({ syncId: table.syncId })
.from(table)
.where(and(eq(table.id, id), eq(table.userId, userId)))
.limit(1);
return row?.syncId ?? null;
}
@@ -156,35 +123,11 @@ async function findReferenceId(
syncId: string,
userId: string,
): Promise<number | null> {
if (entityType === "hosts") {
const [row] = await context.drizzle
.select({ id: hosts.id })
.from(hosts)
.where(and(eq(hosts.syncId, syncId), eq(hosts.userId, userId)))
.limit(1);
return row?.id ?? null;
}
if (entityType === "sshCredentials") {
const [row] = await context.drizzle
.select({ id: sshCredentials.id })
.from(sshCredentials)
.where(
and(
eq(sshCredentials.syncId, syncId),
eq(sshCredentials.userId, userId),
),
)
.limit(1);
return row?.id ?? null;
}
const { table } = entityConfig(entityType as SyncEntityType);
const [row] = await context.drizzle
.select({ id: vaultProfiles.id })
.from(vaultProfiles)
.where(
and(eq(vaultProfiles.syncId, syncId), eq(vaultProfiles.userId, userId)),
)
.select({ id: table.id })
.from(table)
.where(and(eq(table.syncId, syncId), eq(table.userId, userId)))
.limit(1);
return row?.id ?? null;
}
@@ -230,7 +173,7 @@ export function stripWritePayload(
entityType: SyncEntityType,
payload: Record<string, unknown>,
): Record<string, unknown> {
const { readOnlyFields } = ENTITY_CONFIG[entityType];
const { readOnlyFields } = entityConfig(entityType);
const clean = { ...payload };
delete clean.id;
delete clean.userId;
@@ -239,6 +182,42 @@ export function stripWritePayload(
return clean;
}
/**
* @openapi
* /sync/entity-types:
* get:
* summary: List the entity types this server syncs, in dependency order
* description: The desktop app syncs the entities in the order returned here, because an entity referenced by another has to exist on the far side first. A server that predates this endpoint returns 404 and the client falls back to its built-in list.
* tags:
* - Sync
* responses:
* 200:
* description: The ordered entity types.
* 500:
* description: Failed to list entity types.
*/
router.get(
"/entity-types",
authenticateJWT,
async (_req: Request, res: Response) => {
try {
registerCoreSyncEntities();
return res.json({
entityTypes: listEntities().map((entity) => ({
type: entity.type,
order: entity.order,
singleton: entity.singleton === true,
})),
});
} catch (error) {
databaseLogger.error("Failed to list sync entity types", error, {
operation: "sync_entity_types",
});
return res.status(500).json({ error: "Failed to list entity types" });
}
},
);
/**
* @openapi
* /sync/{entityType}:
@@ -280,7 +259,7 @@ router.get(
: null;
try {
const { table, singleton } = ENTITY_CONFIG[entityType];
const { table, singleton } = entityConfig(entityType);
const context = createCurrentRepositoryContext();
const conditions = [eq(table.userId, userId)];
if (since && "updatedAt" in table) {
@@ -352,7 +331,7 @@ router.post(
}
try {
const { table } = ENTITY_CONFIG[entityType];
const { table } = entityConfig(entityType);
const context = createCurrentRepositoryContext();
await context.drizzle
@@ -418,7 +397,7 @@ router.post(
try {
// singleton is still needed below: those tables have no sync_id column
// for the insert to populate.
const { table, singleton } = ENTITY_CONFIG[entityType];
const { table, singleton } = entityConfig(entityType);
const context = createCurrentRepositoryContext();
const locateRow = locateSyncRow(entityType, userId, syncId);