diff --git a/vibn-frontend/lib/db-introspect.ts b/vibn-frontend/lib/db-introspect.ts index 8413e8a7..ca42f274 100644 --- a/vibn-frontend/lib/db-introspect.ts +++ b/vibn-frontend/lib/db-introspect.ts @@ -43,10 +43,12 @@ 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 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.`); + throw new Error( + `No container found for database ${dbUuid}. Database may be stopped.`, + ); } return target.name; } @@ -85,7 +87,9 @@ async function pgListTables(container: string): Promise { AND n.nspname NOT LIKE 'pg_%' ORDER BY n.nspname, c.relname LIMIT ${MAX_TABLES + 1}; - `.replace(/\s+/g, " ").trim(); + ` + .replace(/\s+/g, " ") + .trim(); const all: IntrospectedTable[] = []; const multi = dbs.length > 1; @@ -93,7 +97,9 @@ async function pgListTables(container: string): Promise { 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)}`); + 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). @@ -104,11 +110,17 @@ async function pgListTables(container: string): Promise { if (!trimmed) continue; const [schema, name, rowsStr] = trimmed.split("|"); if (!schema || !name) continue; - const approxRows = Number(rowsStr); + 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, + approxRows: + Number.isFinite(approxRows) && approxRows >= 0 + ? approxRows + : undefined, }); if (all.length >= MAX_TABLES) break; } @@ -127,12 +139,14 @@ async function pgListDatabases(container: string): Promise { 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)"}`); + 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)); + .map((l) => l.trim()) + .filter((name) => name && /^[A-Za-z0-9_]+$/.test(name)); } /** @@ -173,14 +187,21 @@ async function pgPreviewTable( const cmd = `docker exec ${sq(container)} bash -c ` + - sq(`psql -U "$POSTGRES_USER" -d ${sqInner(database)} -tA -c ${sqInner(sql)}`); + 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)"}`); + 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 lines = res.stdout + .split("\n") + .map((l) => l.trim()) + .filter(Boolean); const truncated = lines.length > MAX_ROWS; const rowJson = lines.slice(0, MAX_ROWS); @@ -189,7 +210,7 @@ async function pgPreviewTable( for (const line of rowJson) { try { const obj = JSON.parse(line) as Record; - Object.keys(obj).forEach(k => columnSet.add(k)); + Object.keys(obj).forEach((k) => columnSet.add(k)); parsed.push(obj); } catch { // Skip non-JSON lines (shouldn't happen with -tA, but be safe). @@ -199,10 +220,10 @@ async function pgPreviewTable( // 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 extras = [...columnSet].filter((k) => !firstRowKeys.includes(k)); const columns = [...firstRowKeys, ...extras]; - const rows = parsed.map(row => { + const rows = parsed.map((row) => { const out: Record = {}; for (const col of columns) { out[col] = formatCell(row[col]); @@ -228,7 +249,9 @@ function formatCell(v: unknown): string { } 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; + return json.length > MAX_CELL_CHARS + ? json.slice(0, MAX_CELL_CHARS) + "…" + : json; } // ──────────────────────────────────────────────────