diff --git a/lib/db-introspect.ts b/lib/db-introspect.ts index dce422eb..8413e8a7 100644 --- a/lib/db-introspect.ts +++ b/lib/db-introspect.ts @@ -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 `.` 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 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 { 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 { + 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 { - 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) {