IKHSAN
ENID Baca dalam Bahasa Indonesia

homeio

Public

Forked from doctor-io/homeio

Branch
main
Latest commit
958d74616 October 2026

stacks-repository.ts

Open raw
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<string, string> {
  if (!value || typeof value !== "object" || Array.isArray(value)) {
    return {};
  }
  const record = value as Record<string, unknown>;
  const out: Record<string, string> = {};
  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<InstalledStackConfig[]> {
  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<InstalledStackConfig | null> {
  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<InstalledStackConfig | null> {
  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<string, string>;
  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<string, unknown>;
  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<StoreOperation | null> {
  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<string, unknown>;
  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<StoreOperation | null> {
  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;
}
bytes
11,734
lines
423
Type
ts
Latest commit
16 October 2026