import { and, eq, inArray, sql } from "drizzle-orm"; import { randomUUID } from "crypto"; import { hostAccess, hosts, pluginSettings } from "../db/schema.js"; import type { DatabaseContext } from "./database-context.js"; import { DataCrypto } from "../../utils/data-crypto.js"; import { rowsAffected } from "./mutation-result.js"; import { deleteReturning, insertReturning, updateReturning, } from "./returning.js"; export type HostRecord = typeof hosts.$inferSelect; export type NewHostRecord = typeof hosts.$inferInsert; export type HostUpdate = Partial>; export interface HostBulkUpdateState { id: number; credentialId: number | null; } export class HostRepository { constructor( private readonly context: DatabaseContext, private readonly onWrite?: () => void | Promise, ) {} async create(host: NewHostRecord): Promise { const rows = await insertReturning(this.context, hosts, { syncId: randomUUID(), ...host, }); await this.afterWrite(); return rows[0]; } async createEncryptedForUser( userId: string, host: NewHostRecord | Record, ): Promise { const userDataKey = DataCrypto.validateUserAccess(userId); const tempId = host.id ?? Date.now(); const dataWithTempId = { syncId: randomUUID(), ...host, id: tempId, }; const encryptedHost = DataCrypto.encryptRecord( "ssh_data", dataWithTempId, userId, userDataKey, ); if (!host.id) { delete (encryptedHost as Partial).id; } const rows = await insertReturning( this.context, hosts, encryptedHost as NewHostRecord, ); await this.afterWrite(); return DataCrypto.decryptRecord("ssh_data", rows[0], userId, userDataKey); } async findById(id: number): Promise { const rows = await this.context.drizzle .select() .from(hosts) .where(eq(hosts.id, id)) .limit(1); return rows[0] ?? null; } async findByIdForUser( userId: string, hostId: number, ): Promise { const rows = await this.context.drizzle .select() .from(hosts) .where(and(eq(hosts.id, hostId), eq(hosts.userId, userId))) .limit(1); return rows[0] ?? null; } async findDecryptedByIdAs( userId: string, hostId: number, ): Promise { const row = await this.findById(hostId); if (!row) return null; const userDataKey = DataCrypto.getUserDataKey(userId); if (!userDataKey) return null; return DataCrypto.decryptRecord("ssh_data", row, userId, userDataKey); } async listByUserId(userId: string): Promise { return this.context.drizzle .select() .from(hosts) .where(eq(hosts.userId, userId)); } async listDecryptedByUserId(userId: string): Promise { const rows = await this.listByUserId(userId); const userDataKey = DataCrypto.getUserDataKey(userId); if (!userDataKey) return []; return DataCrypto.decryptRecords("ssh_data", rows, userId, userDataKey); } async existsForImportIdentity( userId: string, ip: string, port: number, username: string, ): Promise { const rows = await this.context.drizzle .select({ id: hosts.id }) .from(hosts) .where( and( eq(hosts.userId, userId), eq(hosts.ip, ip), eq(hosts.port, port), eq(hosts.username, username), ), ) .limit(1); return rows.length > 0; } async updateForUser( userId: string, hostId: number, update: HostUpdate, ): Promise { const rows = await updateReturning( this.context, hosts, { ...update, updatedAt: sql`CURRENT_TIMESTAMP` }, and(eq(hosts.id, hostId), eq(hosts.userId, userId)), ); await this.afterWrite(); return rows[0] ?? null; } /** Re-keys every host of one user to another; see CredentialRepository.transferAllToUser. */ async transferAllToUser( fromUserId: string, toUserId: string, ): Promise { const fromKey = DataCrypto.validateUserAccess(fromUserId); const toKey = DataCrypto.validateUserAccess(toUserId); const rows = await this.context.drizzle .select() .from(hosts) .where(eq(hosts.userId, fromUserId)); const moved: number[] = []; for (const row of rows) { const plain = DataCrypto.decryptRecord( "ssh_data", row, fromUserId, fromKey, ); const { id: _id, userId: _userId, ...fields } = plain as Record; const encrypted = DataCrypto.encryptRecord( "ssh_data", { ...fields, id: row.id }, toUserId, toKey, ) as Record; delete encrypted.id; await this.context.drizzle .update(hosts) .set({ ...(encrypted as Partial), userId: toUserId }) .where(eq(hosts.id, row.id)); moved.push(row.id); } if (moved.length) await this.afterWrite(); return moved; } async updateEncryptedForUser( userId: string, hostId: number, update: HostUpdate, ): Promise { const userDataKey = DataCrypto.validateUserAccess(userId); const encryptedUpdate = DataCrypto.encryptRecord( "ssh_data", update, userId, userDataKey, ); const rows = await updateReturning( this.context, hosts, { ...encryptedUpdate, updatedAt: sql`CURRENT_TIMESTAMP` }, and(eq(hosts.id, hostId), eq(hosts.userId, userId)), ); await this.afterWrite(); return rows[0] ? DataCrypto.decryptRecord("ssh_data", rows[0], userId, userDataKey) : null; } async listBulkUpdateState( userId: string, hostIds: number[], ): Promise { if (hostIds.length === 0) { return []; } return this.context.drizzle .select({ id: hosts.id, credentialId: hosts.credentialId, }) .from(hosts) .where(and(inArray(hosts.id, hostIds), eq(hosts.userId, userId))); } async updateManyForUser( userId: string, hostIds: number[], update: HostUpdate, ): Promise { if (hostIds.length === 0 || Object.keys(update).length === 0) { return 0; } const result = await this.context.drizzle .update(hosts) .set({ ...update, updatedAt: sql`CURRENT_TIMESTAMP` }) .where(and(inArray(hosts.id, hostIds), eq(hosts.userId, userId))); if (rowsAffected(result) > 0) { await this.afterWrite(); } return rowsAffected(result); } /** * Sets a distinct manual sortOrder per host (drag-to-reorder). Unlike * updateManyForUser, each id gets its own value, so this is one UPDATE per * row rather than a single set-for-all-matching-ids statement. */ async reorderForUser( userId: string, positions: { id: number; sortOrder: number }[], ): Promise { if (positions.length === 0) return 0; let affected: number; if (this.context.dialect === "sqlite") { affected = this.context.drizzle.transaction((tx) => { let count = 0; for (const { id, sortOrder } of positions) { const result = tx .update(hosts) .set({ sortOrder, updatedAt: sql`CURRENT_TIMESTAMP` }) .where(and(eq(hosts.id, id), eq(hosts.userId, userId))) .run(); count += rowsAffected(result); } return count; }); } else { affected = await this.context.drizzle.transaction(async (tx) => { let count = 0; for (const { id, sortOrder } of positions) { const result = await tx .update(hosts) .set({ sortOrder, updatedAt: sql`CURRENT_TIMESTAMP` }) .where(and(eq(hosts.id, id), eq(hosts.userId, userId))); count += rowsAffected(result); } return count; }); } if (affected > 0) { await this.afterWrite(); } return affected; } async deleteForUser( userId: string, hostId: number, ): Promise<{ syncId: string | null } | null> { await this.deleteAccessForHost(hostId); const rows = await deleteReturning( this.context, hosts, and(eq(hosts.id, hostId), eq(hosts.userId, userId)), ); await this.afterWrite(); return rows[0] ? { syncId: rows[0].syncId } : null; } async deleteByUserId(userId: string): Promise { // Bulk path, so it never passes through deleteAccessForHost: clear the // host-scope plugin settings for these hosts before the rows go. const owned = await this.context.drizzle .select({ id: hosts.id }) .from(hosts) .where(eq(hosts.userId, userId)); if (owned.length > 0) { await this.context.drizzle.delete(pluginSettings).where( and( eq(pluginSettings.scope, "host"), inArray( pluginSettings.scopeId, owned.map((row) => String(row.id)), ), ), ); } const result = await this.context.drizzle .delete(hosts) .where(eq(hosts.userId, userId)); if (rowsAffected(result) > 0) { await this.afterWrite(); } return rowsAffected(result); } async deleteAccessForHost(hostId: number): Promise { // plugin_settings.scope_id is polymorphic, so it has no foreign key to // ssh_data and the engine will not cascade it. Cleared alongside the // access rows, which every host delete path already goes through. await this.context.drizzle .delete(pluginSettings) .where( and( eq(pluginSettings.scope, "host"), eq(pluginSettings.scopeId, String(hostId)), ), ); const result = await this.context.drizzle .delete(hostAccess) .where(eq(hostAccess.hostId, hostId)); if (rowsAffected(result) > 0) { await this.afterWrite(); } return rowsAffected(result); } private async afterWrite(): Promise { await this.onWrite?.(); } }