Files
vibn-agent-runner/vibn-frontend/lib/db-introspect.ts

286 lines
9.7 KiB
TypeScript

/**
* 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<Record<string, string>>;
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<string> {
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 `<db>.<schema>` 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<IntrospectedTable[]> {
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;
}
}
return all.slice(0, MAX_TABLES);
}
/** Enumerate non-template, connectable databases in the cluster. */
async function pgListDatabases(container: string): Promise<string[]> {
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<PreviewedTable> {
// 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<string>();
const parsed: Record<string, unknown>[] = [];
for (const line of rowJson) {
try {
const obj = JSON.parse(line) as Record<string, unknown>;
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<string, string> = {};
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<IntrospectedTable[]> {
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<PreviewedTable> {
if (engine !== "postgresql") throw new IntrospectionUnsupportedError(engine);
const container = await resolveDbContainer(dbUuid);
return pgPreviewTable(container, schema, table);
}