import "server-only"; import { and, asc, eq, lt, ne, not, or, sql } from "drizzle-orm"; import { db } from "@/lib/server/db/drizzle"; import { appOperations, appStacks } from "@/lib/server/db/schema"; import type { InstalledStackConfig, InstalledStackStatus, StoreOperation, StoreOperationAction, StoreOperationStatus, } from "@/lib/shared/contracts/apps"; function toIso(value: Date | null): string | null { if (!value) return null; return value instanceof Date ? value.toISOString() : value; } function toInstalledStackStatus(status: string): InstalledStackStatus { if ( status === "installed" || status === "not_installed" || status === "installing" || status === "error" || status === "updating" || status === "uninstalling" ) { return status; } return "error"; } function toOperationStatus(status: string): StoreOperationStatus { if (status === "queued" || status === "running" || status === "success" || status === "error") { return status; } return "error"; } function toOperationAction(action: string): StoreOperationAction { if ( action === "install" || action === "update" || action === "redeploy" || action === "uninstall" || action === "start" || action === "stop" || action === "restart" || action === "check-updates" ) { return action; } return "redeploy"; } function parseEnvJson(value: unknown): Record { if (!value || typeof value !== "object" || Array.isArray(value)) { return {}; } const record = value as Record; const out: Record = {}; for (const [key, raw] of Object.entries(record)) { if (typeof raw === "string") { out[key] = raw; continue; } if (typeof raw === "number" || typeof raw === "boolean") { out[key] = String(raw); } } return out; } async function hasTable(tableName: "app_stacks" | "app_operations") { const check = await db.execute<{ table_exists: string | null }>( sql`SELECT to_regclass(${"public." + tableName}) AS table_exists`, ); return Boolean(check.rows[0]?.table_exists); } function mapStackRow(row: { appId: string; templateName: string; stackName: string; composePath: string; status: string; webUiPort: number | null; webUiUrl: string | null; tunnelSubdomain: string | null; envJson: unknown; displayName: string | null; iconUrl: string | null; installedAt: Date | null; updatedAt: Date; isUpToDate: boolean | null; lastUpdateCheck: Date | null; }): InstalledStackConfig { return { appId: row.appId, templateName: row.templateName, stackName: row.stackName, composePath: row.composePath, status: toInstalledStackStatus(row.status), webUiPort: row.webUiPort, webUiUrl: row.webUiUrl ?? null, tunnelSubdomain: row.tunnelSubdomain ?? null, env: parseEnvJson(row.envJson), displayName: row.displayName ?? null, iconUrl: row.iconUrl ?? null, installedAt: toIso(row.installedAt), updatedAt: toIso(row.updatedAt) ?? new Date().toISOString(), isUpToDate: row.isUpToDate ?? true, lastUpdateCheck: toIso(row.lastUpdateCheck), }; } function mapOperationRow(row: { id: string; appId: string; action: string; status: string; progressPercent: number; currentStep: string; errorMessage: string | null; startedAt: Date | null; finishedAt: Date | null; updatedAt: Date; }): StoreOperation { return { id: row.id, appId: row.appId, action: toOperationAction(row.action), status: toOperationStatus(row.status), progressPercent: row.progressPercent, currentStep: row.currentStep, errorMessage: row.errorMessage, startedAt: toIso(row.startedAt), finishedAt: toIso(row.finishedAt), updatedAt: toIso(row.updatedAt) ?? new Date().toISOString(), }; } export async function listInstalledStacksFromDb(): Promise { if (!(await hasTable("app_stacks"))) return []; const rows = await db.select().from(appStacks).orderBy(asc(appStacks.appId)); return rows.map(mapStackRow); } export async function findInstalledStackByAppId( appId: string, ): Promise { if (!(await hasTable("app_stacks"))) return null; const rows = await db .select() .from(appStacks) .where(eq(appStacks.appId, appId)) .limit(1); const row = rows[0]; return row ? mapStackRow(row) : null; } export async function findStackByWebUiPort( port: number, options?: { excludeAppId?: string }, ): Promise { if (!(await hasTable("app_stacks"))) return null; const conditions = [ eq(appStacks.webUiPort, port), not(eq(appStacks.status, "not_installed")), ]; if (options?.excludeAppId) { conditions.push(ne(appStacks.appId, options.excludeAppId)); } const rows = await db .select() .from(appStacks) .where(and(...conditions)) .limit(1); const row = rows[0]; return row ? mapStackRow(row) : null; } export async function upsertInstalledStack(input: { appId: string; templateName: string; stackName: string; composePath: string; status: InstalledStackStatus; webUiPort: number | null; env: Record; markInstalledAt?: boolean; }) { if (!(await hasTable("app_stacks"))) return; await db .insert(appStacks) .values({ appId: input.appId, templateName: input.templateName, stackName: input.stackName, composePath: input.composePath, status: input.status, webUiPort: input.webUiPort, envJson: input.env, installedAt: input.markInstalledAt ? sql`NOW()` : null, updatedAt: sql`NOW()`, }) .onConflictDoUpdate({ target: appStacks.appId, set: { templateName: input.templateName, stackName: input.stackName, composePath: input.composePath, status: input.status, webUiPort: input.webUiPort, envJson: input.env, installedAt: input.markInstalledAt ? sql`CASE WHEN ${appStacks.installedAt} IS NULL THEN NOW() ELSE ${appStacks.installedAt} END` : sql`${appStacks.installedAt}`, updatedAt: sql`NOW()`, }, }); } export async function deleteInstalledStackByAppId(appId: string) { if (!(await hasTable("app_stacks"))) return; await db.delete(appStacks).where(eq(appStacks.appId, appId)); } export async function createStoreOperation(input: { id: string; appId: string; action: StoreOperationAction; status: StoreOperationStatus; progressPercent: number; currentStep: string; errorMessage?: string | null; startedAt?: boolean; }) { if (!(await hasTable("app_operations"))) return; await db.insert(appOperations).values({ id: input.id, appId: input.appId, action: input.action, status: input.status, progressPercent: input.progressPercent, currentStep: input.currentStep, errorMessage: input.errorMessage ?? null, startedAt: input.startedAt ? sql`NOW()` : null, finishedAt: null, updatedAt: sql`NOW()`, }); } export async function updateStoreOperation( id: string, patch: Partial< Pick< StoreOperation, "status" | "progressPercent" | "currentStep" | "errorMessage" | "finishedAt" > > & { markStarted?: boolean; markFinished?: boolean; }, ) { if (!(await hasTable("app_operations"))) return; type SetValues = Record; const set: SetValues = { updatedAt: sql`NOW()` }; if (patch.status !== undefined) set.status = patch.status; if (patch.progressPercent !== undefined) set.progressPercent = patch.progressPercent; if (patch.currentStep !== undefined) set.currentStep = patch.currentStep; if (patch.errorMessage === null) { set.errorMessage = null; } else if (patch.errorMessage !== undefined) { set.errorMessage = patch.errorMessage; } if (patch.markStarted) { set.startedAt = sql`CASE WHEN ${appOperations.startedAt} IS NULL THEN NOW() ELSE ${appOperations.startedAt} END`; } if (patch.markFinished || patch.finishedAt) { set.finishedAt = sql`NOW()`; } await db.update(appOperations).set(set).where(eq(appOperations.id, id)); } export async function findStoreOperationById(id: string): Promise { if (!(await hasTable("app_operations"))) return null; const rows = await db .select() .from(appOperations) .where(eq(appOperations.id, id)) .limit(1); const row = rows[0]; return row ? mapOperationRow(row) : null; } export async function patchInstalledStackMeta( appId: string, input: { displayName?: string; iconUrl?: string | null; webUiUrl?: string | null; tunnelSubdomain?: string | null; }, ) { if (!(await hasTable("app_stacks"))) return; if ( input.displayName === undefined && input.iconUrl === undefined && input.webUiUrl === undefined && input.tunnelSubdomain === undefined ) { return; } type SetValues = Record; const set: SetValues = { updatedAt: sql`NOW()` }; if (input.displayName !== undefined) set.displayName = input.displayName; if (input.iconUrl !== undefined) set.iconUrl = input.iconUrl ?? null; if (input.webUiUrl !== undefined) set.webUiUrl = input.webUiUrl ?? null; if (input.tunnelSubdomain !== undefined) { set.tunnelSubdomain = input.tunnelSubdomain ?? null; } await db.update(appStacks).set(set).where(eq(appStacks.appId, appId)); } export async function updateStackUpdateStatus(input: { appId: string; isUpToDate: boolean; }) { if (!(await hasTable("app_stacks"))) return; await db .update(appStacks) .set({ isUpToDate: input.isUpToDate, lastUpdateCheck: sql`NOW()`, updatedAt: sql`NOW()`, }) .where(eq(appStacks.appId, input.appId)); } /** * Delete finished (success or error) operations older than `olderThanDays` days. * Called non-blocking on each new operation start to keep the table from growing unbounded. */ export async function pruneFinishedOperations(olderThanDays = 30) { if (!(await hasTable("app_operations"))) return; const cutoff = sql`NOW() - INTERVAL '${sql.raw(String(olderThanDays))} days'`; await db .delete(appOperations) .where( and( or(eq(appOperations.status, "success"), eq(appOperations.status, "error")), lt(appOperations.updatedAt, cutoff), ), ); } /** * Mark any operations still in "queued" or "running" state as "error". * Called once at server startup to recover from a crash or hard restart that * left operations permanently stuck. */ export async function markStaleOperationsAsError() { if (!(await hasTable("app_operations"))) return; await db .update(appOperations) .set({ status: "error", errorMessage: "Operation interrupted by server restart", finishedAt: sql`NOW()`, updatedAt: sql`NOW()`, }) .where(or(eq(appOperations.status, "queued"), eq(appOperations.status, "running"))); } /** * Return the most recent non-finished operation for a given app, or null if none. * Used as a DB-level fallback to detect in-flight operations after a restart clears * the in-memory set. */ export async function findActiveStoreOperationByAppId(appId: string): Promise { if (!(await hasTable("app_operations"))) return null; const rows = await db .select() .from(appOperations) .where( and( eq(appOperations.appId, appId), or(eq(appOperations.status, "queued"), eq(appOperations.status, "running")), ), ) .orderBy(asc(appOperations.updatedAt)) .limit(1); const row = rows[0]; return row ? mapOperationRow(row) : null; }