/** * Database introspection helpers — list tables and preview rows. * * Coolify-managed databases run in their own docker network, unreachable * from vibn-frontend's container. We route through the Coolify host via * SSH and `docker exec` into the database container itself, where the * native client (psql / redis-cli / mongosh) is already installed and * pre-authenticated via the container's own POSTGRES_USER / etc envs. * * v1 supports Postgres; Redis + MongoDB are stubbed and return helpful * "not yet supported" errors so the UI can render a useful empty state. * * Hard limits, by design: * - 50 tables max returned per call * - 50 rows max per table preview * - 200 chars truncation per cell value * - 8s wall-clock SSH timeout * - Only SELECT-style queries; never any mutation */ import { runOnCoolifyHost } from "./coolify-ssh"; import { listContainersForApp } from "./coolify-containers"; export interface IntrospectedTable { schema: string; name: string; /** Approximate row count from pg_class.reltuples — fast, not exact. */ approxRows?: number; } export interface PreviewedTable { columns: string[]; rows: Array>; truncated: boolean; totalRowsApprox?: number; } const MAX_TABLES = 50; const MAX_ROWS = 50; const MAX_CELL_CHARS = 200; const SSH_TIMEOUT_MS = 8_000; /** Find the running container for a Coolify database uuid. */ async function resolveDbContainer(dbUuid: string): Promise { const containers = await listContainersForApp(dbUuid); const running = containers.find((c) => /up /i.test(c.status)); const target = running ?? containers[0]; if (!target) { throw new Error( `No container found for database ${dbUuid}. Database may be stopped.`, ); } return target.name; } /** Single-quote escape a token for bash. */ function sq(s: string): string { return `'${s.replace(/'/g, `'\\''`)}'`; } // ────────────────────────────────────────────────── // Postgres // ────────────────────────────────────────────────── /** * List tables across every non-system schema in every non-template database. * * Coolify reports a single `postgres_db` per database resource, but the * cluster usually has more than one db inside (e.g. Twenty CRM connects * to `default`, not `postgres`). We enumerate every non-template db with * `psql -l`-style query, then union table listings across all of them. * * When there's more than one db, the IntrospectedTable.schema field is * stamped as `.` so the UI can disambiguate; with a single * db we keep the bare schema name so `public` flattens like before. */ async function pgListTables(container: string): Promise { const dbs = await pgListDatabases(container); if (dbs.length === 0) return []; const tablesSql = ` SELECT n.nspname || '|' || c.relname || '|' || COALESCE(c.reltuples::bigint, 0) FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace WHERE c.relkind IN ('r', 'p') AND n.nspname NOT IN ('pg_catalog', 'information_schema') AND n.nspname NOT LIKE 'pg_%' ORDER BY n.nspname, c.relname LIMIT ${MAX_TABLES + 1}; ` .replace(/\s+/g, " ") .trim(); const all: IntrospectedTable[] = []; const multi = dbs.length > 1; for (const db of dbs) { if (all.length >= MAX_TABLES) break; const cmd = `docker exec ${sq(container)} bash -c ` + sq( `psql -U "$POSTGRES_USER" -d ${sqInner(db)} -tAF '|' -c ${sqInner(tablesSql)}`, ); const res = await runOnCoolifyHost(cmd, { timeoutMs: SSH_TIMEOUT_MS }); if (res.code !== 0) { // Skip dbs the role can't connect to (e.g. owned by another user). continue; } for (const line of res.stdout.split("\n")) { const trimmed = line.trim(); if (!trimmed) continue; const [schema, name, rowsStr] = trimmed.split("|"); if (!schema || !name) continue; let approxRows = Number(rowsStr); // Postgres 14+ sets reltuples to -1 if the table has never been analyzed if (approxRows === -1) approxRows = 0; all.push({ schema: multi ? `${db}.${schema}` : schema, name, approxRows: Number.isFinite(approxRows) && approxRows >= 0 ? approxRows : undefined, }); if (all.length >= MAX_TABLES) break; } // ── Exact-count fallback for unanalyzed tables ── // If Postgres reports 0 rows (usually because the table is new and hasn't // been analyzed yet), we run a fast exact COUNT(*) just for those tables // so the UI doesn't falsely report '0' when there is real data. const suspectTables = all.filter( (t) => t.approxRows === 0 && (multi ? t.schema.startsWith(`${db}.`) : true), ); if (suspectTables.length > 0) { const unionQueries = suspectTables.map((t) => { const rawSchema = multi ? t.schema.split(".")[1] : t.schema; return `SELECT '${rawSchema}|${t.name}|' || count(*) FROM "${rawSchema}"."${t.name}"`; }); const exactQuery = unionQueries.join(" UNION ALL "); const exactCmd = `docker exec ${sq(container)} bash -c ` + sq( `psql -U "$POSTGRES_USER" -d ${sqInner(db)} -tAF '|' -c ${sqInner(exactQuery)}`, ); try { const exactRes = await runOnCoolifyHost(exactCmd, { timeoutMs: SSH_TIMEOUT_MS, }); if (exactRes.code === 0) { const exactMap = new Map(); for (const line of exactRes.stdout.split("\n")) { const [sch, nm, cnt] = line.trim().split("|"); if (sch && nm && cnt) exactMap.set(`${sch}.${nm}`, Number(cnt)); } for (const t of suspectTables) { const rawSchema = multi ? t.schema.split(".")[1] : t.schema; const exactCount = exactMap.get(`${rawSchema}.${t.name}`); if (exactCount !== undefined) { t.approxRows = exactCount; } } } } catch (e) { // Fall back to 0 if the exact count fails for any reason console.warn("[db-introspect] Failed to fetch exact counts:", e); } } } return all.slice(0, MAX_TABLES); } /** Enumerate non-template, connectable databases in the cluster. */ async function pgListDatabases(container: string): Promise { const sql = `SELECT datname FROM pg_database ` + `WHERE datistemplate = false AND datallowconn = true ` + `ORDER BY (datname = 'postgres'), datname;`; // user dbs first const cmd = `docker exec ${sq(container)} bash -c ` + sq(`psql -U "$POSTGRES_USER" -d postgres -tA -c ${sqInner(sql)}`); const res = await runOnCoolifyHost(cmd, { timeoutMs: SSH_TIMEOUT_MS }); if (res.code !== 0) { throw new Error( `psql -l exited ${res.code}: ${res.stderr.trim() || "(no stderr)"}`, ); } return res.stdout .split("\n") .map((l) => l.trim()) .filter((name) => name && /^[A-Za-z0-9_]+$/.test(name)); } /** * Preview the first MAX_ROWS rows of a single table. Identifiers are * locked to /^[A-Za-z0-9_]+$/ so we can safely splice them into SQL — * Postgres identifiers can technically be wider, but anything outside * that range gets rejected up-front to keep the surface small. */ async function pgPreviewTable( container: string, schema: string, table: string, ): Promise { // Schema can be either bare ("public") or db-qualified ("default.public") // when the cluster has more than one database. let database = "postgres"; let bareSchema = schema; if (schema.includes(".")) { const [db, rest] = schema.split(".", 2); database = db; bareSchema = rest; } if ( !/^[A-Za-z0-9_]+$/.test(bareSchema) || !/^[A-Za-z0-9_]+$/.test(table) || !/^[A-Za-z0-9_]+$/.test(database) ) { throw new Error("Invalid identifier"); } // Build `SELECT row_to_json(t)::text FROM (SELECT * FROM s.t LIMIT N) t` // so each output line is a self-contained JSON object that's resilient // to embedded pipes / newlines. const sql = `SELECT row_to_json(t)::text ` + `FROM (SELECT * FROM "${bareSchema}"."${table}" LIMIT ${MAX_ROWS + 1}) t;`; const cmd = `docker exec ${sq(container)} bash -c ` + sq( `psql -U "$POSTGRES_USER" -d ${sqInner(database)} -tA -c ${sqInner(sql)}`, ); const res = await runOnCoolifyHost(cmd, { timeoutMs: SSH_TIMEOUT_MS }); if (res.code !== 0) { throw new Error( `psql exited ${res.code}: ${res.stderr.trim() || "(no stderr)"}`, ); } const lines = res.stdout .split("\n") .map((l) => l.trim()) .filter(Boolean); const truncated = lines.length > MAX_ROWS; const rowJson = lines.slice(0, MAX_ROWS); const columnSet = new Set(); const parsed: Record[] = []; for (const line of rowJson) { try { const obj = JSON.parse(line) as Record; Object.keys(obj).forEach((k) => columnSet.add(k)); parsed.push(obj); } catch { // Skip non-JSON lines (shouldn't happen with -tA, but be safe). } } // Stable column order: order they appeared in the first row, then // any extras at the end. const firstRowKeys = parsed[0] ? Object.keys(parsed[0]) : []; const extras = [...columnSet].filter((k) => !firstRowKeys.includes(k)); const columns = [...firstRowKeys, ...extras]; const rows = parsed.map((row) => { const out: Record = {}; for (const col of columns) { out[col] = formatCell(row[col]); } return out; }); return { columns, rows, truncated }; } /** Inner sq for the bash -c '…' wrapper: we need to single-quote the * SQL itself, but we're already inside one layer of single quotes * for `bash -c '…'` — switch to double quotes for the inner level * and rely on the fact that psql's -c argument tolerates them. */ function sqInner(s: string): string { return `"${s.replace(/"/g, '\\"').replace(/\$/g, "\\$")}"`; } function formatCell(v: unknown): string { if (v == null) return "—"; if (typeof v === "string") { return v.length > MAX_CELL_CHARS ? v.slice(0, MAX_CELL_CHARS) + "…" : v; } if (typeof v === "number" || typeof v === "boolean") return String(v); const json = JSON.stringify(v); return json.length > MAX_CELL_CHARS ? json.slice(0, MAX_CELL_CHARS) + "…" : json; } // ────────────────────────────────────────────────── // Public API // ────────────────────────────────────────────────── export class IntrospectionUnsupportedError extends Error { constructor(public engine: string) { super(`Introspection not yet supported for ${engine}`); } } export async function listTables( dbUuid: string, engine: string, ): Promise { if (engine !== "postgresql") throw new IntrospectionUnsupportedError(engine); const container = await resolveDbContainer(dbUuid); return pgListTables(container); } export async function previewTable( dbUuid: string, engine: string, schema: string, table: string, ): Promise { if (engine !== "postgresql") throw new IntrospectionUnsupportedError(engine); const container = await resolveDbContainer(dbUuid); return pgPreviewTable(container, schema, table); }