import { and, eq, like, or, sql } from "drizzle-orm"; import { randomUUID } from "crypto"; import type { SQLiteColumn } from "drizzle-orm/sqlite-core"; import { hosts, sshCredentials, sshFolders } from "../db/schema.js"; import type { DatabaseContext } from "./database-context.js"; import { rowsAffected } from "./mutation-result.js"; import { deleteReturning, insertReturning, updateReturning, } from "./returning.js"; export type HostFolderRecord = typeof sshFolders.$inferSelect; export type HostFolderHostRecord = typeof hosts.$inferSelect; export interface RenameFolderResult { updatedHosts: number; updatedCredentials: number; } export class HostFolderRepository { constructor( private readonly context: DatabaseContext, private readonly onWrite?: () => void | Promise, ) {} async renameFolder( userId: string, oldName: string, newName: string, now = new Date().toISOString(), ): Promise { // CAST target: every engine spells the text type differently enough to // matter here, MySQL has no `text` cast and wants `char`. const textType = this.context.dialect === "mysql" ? "char" : "text"; const oldPrefix = `${oldName} / `; const newPrefix = `${newName} / `; const childLike = `${oldPrefix}%`; // CONCAT, not `||`: MySQL reads `||` as logical OR unless the server runs // with PIPES_AS_CONCAT, so the child paths would have been rewritten to 0. // No error, just wrong folder names. CONCAT and SUBSTR mean the same thing // on all three engines. // // The prefix is inlined rather than bound: CONCAT is variadic, so Postgres // cannot infer a parameter's type from its position and rejects the // statement with 42P18 before it runs. The value is a folder name the // caller supplied, so it goes through a bound placeholder in a plain // concatenation instead of sql.raw. const renameExpr = (col: SQLiteColumn) => sql`CASE WHEN ${col} = ${oldName} THEN ${newName} ELSE CONCAT(CAST(${newPrefix} AS ${sql.raw(textType)}), SUBSTR(${col}, ${sql.raw(String(oldPrefix.length + 1))})) END`; const folderMatch = (col: SQLiteColumn) => or(eq(col, oldName), like(col, childLike)); const updatedHosts = await this.context.drizzle .update(hosts) .set({ folder: renameExpr(hosts.folder), updatedAt: now }) .where(and(eq(hosts.userId, userId), folderMatch(hosts.folder))); const updatedCredentials = await this.context.drizzle .update(sshCredentials) .set({ folder: renameExpr(sshCredentials.folder), updatedAt: now }) .where( and( eq(sshCredentials.userId, userId), folderMatch(sshCredentials.folder), ), ); await this.context.drizzle .update(sshFolders) .set({ name: renameExpr(sshFolders.name), updatedAt: now }) .where(and(eq(sshFolders.userId, userId), folderMatch(sshFolders.name))); await this.afterWrite(); return { updatedHosts: rowsAffected(updatedHosts), updatedCredentials: rowsAffected(updatedCredentials), }; } async listFolders(userId: string): Promise { return this.context.drizzle .select() .from(sshFolders) .where(eq(sshFolders.userId, userId)); } /** * Keeps a folder, its subfolders and every host in them on this device * only, or lets them sync again. */ async setLocalOnly( userId: string, folderName: string, localOnly: boolean, ): Promise { const inFolder = (col: SQLiteColumn) => or(eq(col, folderName), like(col, `${folderName} / %`)); await this.context.drizzle .update(sshFolders) .set({ localOnly }) .where(and(eq(sshFolders.userId, userId), inFolder(sshFolders.name))); await this.context.drizzle .update(hosts) .set({ localOnly, updatedAt: new Date().toISOString() }) .where(and(eq(hosts.userId, userId), inFolder(hosts.folder))); await this.afterWrite(); } async upsertMetadata( userId: string, name: string, color: string | null | undefined, icon: string | null | undefined, now = new Date().toISOString(), ): Promise<{ folder: HostFolderRecord; created: boolean }> { const existing = await this.findFolder(userId, name); if (existing) { const [updated] = await updateReturning( this.context, sshFolders, { color, icon, updatedAt: now, }, and(eq(sshFolders.userId, userId), eq(sshFolders.name, name)), ); await this.afterWrite(); return { folder: updated, created: false }; } const [created] = await insertReturning(this.context, sshFolders, { syncId: randomUUID(), userId, name, color, icon, createdAt: now, updatedAt: now, }); await this.afterWrite(); return { folder: created, created: true }; } /** * Sets a distinct manual sortOrder per sibling folder (drag-to-reorder). * Folders with no existing sshFolders row are created first (matching the * empty-folder-persists behavior elsewhere) so the order survives even for * folders that only ever existed implicitly via host paths. */ async reorderFolders( userId: string, positions: { name: string; sortOrder: number }[], now = new Date().toISOString(), ): 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 { name, sortOrder } of positions) { const result = tx .update(sshFolders) .set({ sortOrder, updatedAt: now }) .where( and(eq(sshFolders.userId, userId), eq(sshFolders.name, name)), ) .run(); if (rowsAffected(result) > 0) { count += rowsAffected(result); continue; } tx.insert(sshFolders) .values({ syncId: randomUUID(), userId, name, sortOrder, createdAt: now, updatedAt: now, }) .run(); count += 1; } return count; }); } else { affected = await this.context.drizzle.transaction(async (tx) => { let count = 0; for (const { name, sortOrder } of positions) { const result = await tx .update(sshFolders) .set({ sortOrder, updatedAt: now }) .where( and(eq(sshFolders.userId, userId), eq(sshFolders.name, name)), ); if (rowsAffected(result) > 0) { count += rowsAffected(result); continue; } await tx.insert(sshFolders).values({ syncId: randomUUID(), userId, name, sortOrder, createdAt: now, updatedAt: now, }); count += 1; } return count; }); } if (affected > 0) { await this.afterWrite(); } return affected; } async listHostsInFolder( userId: string, folderName: string, ): Promise { const folderMatch = (col: SQLiteColumn) => or(eq(col, folderName), like(col, `${folderName} / %`)); return this.context.drizzle .select() .from(hosts) .where(and(eq(hosts.userId, userId), folderMatch(hosts.folder))); } /** Drops the folder rows (and subfolders) only when no host is left in them. */ async deleteEmptyFolderRecords( userId: string, folderName: string, ): Promise { if ((await this.listHostsInFolder(userId, folderName)).length > 0) { return false; } await deleteReturning( this.context, sshFolders, and( eq(sshFolders.userId, userId), or( eq(sshFolders.name, folderName), like(sshFolders.name, `${folderName} / %`), ), ), ); await this.afterWrite(); return true; } async deleteHostsAndFolderRecords( userId: string, folderName: string, ): Promise<{ hostSyncIds: string[]; folderSyncIds: string[] }> { const folderMatch = (col: SQLiteColumn) => or(eq(col, folderName), like(col, `${folderName} / %`)); const hostsToDelete = await this.listHostsInFolder(userId, folderName); if (hostsToDelete.length > 0) { await this.context.drizzle .delete(hosts) .where(and(eq(hosts.userId, userId), folderMatch(hosts.folder))); } const deletedFolders = await deleteReturning( this.context, sshFolders, and(eq(sshFolders.userId, userId), folderMatch(sshFolders.name)), ); await this.afterWrite(); return { hostSyncIds: hostsToDelete .map((h) => h.syncId) .filter((id): id is string => !!id), folderSyncIds: deletedFolders .map((f) => f.syncId) .filter((id): id is string => !!id), }; } async deleteByUserId(userId: string): Promise { const result = await this.context.drizzle .delete(sshFolders) .where(eq(sshFolders.userId, userId)); if (rowsAffected(result) > 0) { await this.afterWrite(); } return rowsAffected(result); } private async findFolder( userId: string, name: string, ): Promise { const rows = await this.context.drizzle .select() .from(sshFolders) .where(and(eq(sshFolders.userId, userId), eq(sshFolders.name, name))) .limit(1); return rows[0] ?? null; } private async afterWrite(): Promise { await this.onWrite?.(); } }