import fs from "node:fs"; import path from "node:path"; import { getDbClient } from "@hivegrid/db/client.server"; import type { DeployMetadata, DeployMigrationInput, DeploySnapshotInput, MigrationState } from "@hivegrid/view-models"; import { createScopedLogger } from "~/utils/logger.server"; const deployLogger = createScopedLogger("admin-deploys"); type DbMigrationRow = { readonly migration_name: string; readonly finished_at: Date | string | null; readonly rolled_back_at: Date | string | null; readonly logs: string | null; }; export type DeploySnapshotDeps = { readonly env?: NodeJS.ProcessEnv; readonly listMigrationNames?: () => readonly string[]; readonly listAppliedMigrations?: () => Promise; }; export async function getDeployAdminSnapshot(deps: DeploySnapshotDeps = {}): Promise { const env = deps.env ?? process.env; const listMigrationNames = deps.listMigrationNames ?? defaultListMigrationNames; const listAppliedMigrations = deps.listAppliedMigrations ?? defaultListAppliedMigrations; const migrationNames = [...listMigrationNames()].sort(); let migrationDbReachable = true; let appliedRows: readonly DbMigrationRow[] = []; try { appliedRows = await listAppliedMigrations(); } catch (error) { migrationDbReachable = false; // The UI can only say "not readable"; without this log the actual cause // stays invisible. The historical cause was a missing grant on // `_prisma_migrations` for the non-superuser runtime role, which looks // exactly like an unreachable database from here. deployLogger.error( { event: "admin_deploys.migration_history_unreadable", errorName: error instanceof Error ? error.name : "UnknownError", errorMessage: error instanceof Error ? error.message : undefined, }, "Applied migration history could not be read; operator view falls back to unknown state" ); } return { metadata: buildDeployMetadata(env), migrationDbReachable, migrations: buildMigrationRows({ migrationNames, appliedRows, migrationDbReachable }).slice(-20).reverse(), }; } export function buildDeployMetadata(env: NodeJS.ProcessEnv): DeployMetadata { const revision = firstPresent(env, ["APP_REVISION", "SENTRY_RELEASE", "GIT_SHA", "COMMIT_SHA", "CI_COMMIT_SHA"]) ?? "unknown"; return { environment: firstPresent(env, ["SENTRY_ENVIRONMENT", "DEPLOY_ENVIRONMENT", "APP_ENV"]) ?? env.NODE_ENV ?? "development", nodeEnv: env.NODE_ENV ?? "development", release: firstPresent(env, ["SENTRY_RELEASE", "APP_REVISION", "GIT_SHA", "COMMIT_SHA", "CI_COMMIT_SHA"]) ?? "local", revision, branch: firstPresent(env, ["GIT_BRANCH", "BRANCH_NAME", "CI_COMMIT_BRANCH", "GITEA_REF_NAME", "COOLIFY_BRANCH"]) ?? "unknown", image: firstPresent(env, ["DOCKER_IMAGE", "CONTAINER_IMAGE", "IMAGE_TAG", "DOCKER_IMAGE_TAG", "COOLIFY_IMAGE"]) ?? "unknown", buildTime: firstPresent(env, ["BUILD_TIME", "BUILD_DATE", "CI_BUILD_CREATED_AT", "SOURCE_DATE_EPOCH"]) ?? "unknown", }; } function buildMigrationRows(input: { readonly migrationNames: readonly string[]; readonly appliedRows: readonly DbMigrationRow[]; readonly migrationDbReachable: boolean; }): DeployMigrationInput[] { if (!input.migrationDbReachable) { return input.migrationNames.map((name) => ({ name, state: "unknown" })); } const appliedByName = new Map(input.appliedRows.map((row) => [row.migration_name, row])); return input.migrationNames.map((name) => { const row = appliedByName.get(name); if (!row) { return { name, state: "pending" }; } return { name, state: migrationStateFromDb(row), appliedAt: row.finished_at ? new Date(row.finished_at).toISOString() : null, }; }); } function migrationStateFromDb(row: DbMigrationRow): MigrationState { if (row.rolled_back_at) return "rolledBack"; if (!row.finished_at) return "failed"; if (row.logs?.trim()) return "failed"; return "applied"; } function defaultListMigrationNames(): readonly string[] { const migrationsRoot = path.resolve(process.cwd(), "prisma/migrations"); if (!fs.existsSync(migrationsRoot)) { return []; } return fs .readdirSync(migrationsRoot, { withFileTypes: true }) .filter((entry) => entry.isDirectory()) .map((entry) => entry.name); } async function defaultListAppliedMigrations(): Promise { const db = getDbClient(); return db.$queryRaw` SELECT migration_name, finished_at, rolled_back_at, logs FROM "_prisma_migrations" ORDER BY migration_name ASC `; } function firstPresent(env: NodeJS.ProcessEnv, names: readonly string[]): string | undefined { return names.map((name) => env[name]?.trim()).find((value): value is string => Boolean(value)); }