fix(db-introspect): scan all non-template databases, not just $POSTGRES_DB

Coolify exposes a single `postgres_db` per database resource (usually
"postgres"), but the cluster typically holds more than one db inside.
Twenty CRM connects to `default`; our prior query connected to
`postgres` and so reported the database as empty even when Twenty had
hundreds of tables.

Fix:
  - pgListDatabases() enumerates every non-template, connectable db in
    the cluster (`SELECT datname FROM pg_database WHERE datistemplate
    = false AND datallowconn = true`).
  - pgListTables() now unions table listings across all of them.
    Schema is stamped as `<db>.<schema>` only when there's more than
    one db, so single-db clusters keep the bare `public` flatten in
    the UI.
  - pgPreviewTable() understands the dotted `db.schema` form and
    routes the preview `psql` invocation to the correct database.
    Identifier whitelist applied to all three components (db, schema,
    table) before splicing into SQL.

Hard caps unchanged (50 tables total, 8s SSH wall-clock).

Made-with: Cursor
This commit is contained in:
2026-04-29 15:36:28 -07:00
parent 7b359e399e
commit 2260f3c280

View File

@@ -61,12 +61,22 @@ function sq(s: string): string {
// ──────────────────────────────────────────────────
/**
* List tables across every non-system schema. Returns at most MAX_TABLES.
* Uses `\copy (SELECT …) TO STDOUT` style approach via psql -A -t for a
* stable, parseable output (no horizontal lines, no headers).
* 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 sql = `
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
@@ -77,25 +87,52 @@ async function pgListTables(container: string): Promise<IntrospectedTable[]> {
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;
const approxRows = Number(rowsStr);
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_DB" -tAF '|' -c ${sqInner(sql)}`);
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 exited ${res.code}: ${res.stderr.trim() || "(no stderr)"}`);
throw new Error(`psql -l exited ${res.code}: ${res.stderr.trim() || "(no stderr)"}`);
}
const tables: IntrospectedTable[] = [];
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;
const approxRows = Number(rowsStr);
tables.push({ schema, name, approxRows: Number.isFinite(approxRows) && approxRows >= 0 ? approxRows : undefined });
}
return tables.slice(0, MAX_TABLES);
return res.stdout
.split("\n")
.map(l => l.trim())
.filter(name => name && /^[A-Za-z0-9_]+$/.test(name));
}
/**
@@ -109,7 +146,21 @@ async function pgPreviewTable(
schema: string,
table: string,
): Promise<PreviewedTable> {
if (!/^[A-Za-z0-9_]+$/.test(schema) || !/^[A-Za-z0-9_]+$/.test(table)) {
// 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");
}
@@ -118,11 +169,11 @@ async function pgPreviewTable(
// to embedded pipes / newlines.
const sql =
`SELECT row_to_json(t)::text ` +
`FROM (SELECT * FROM "${schema}"."${table}" LIMIT ${MAX_ROWS + 1}) t;`;
`FROM (SELECT * FROM "${bareSchema}"."${table}" LIMIT ${MAX_ROWS + 1}) t;`;
const cmd =
`docker exec ${sq(container)} bash -c ` +
sq(`psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -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) {