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:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user