286 lines
9.7 KiB
TypeScript
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);
|
|
}
|