dbx/apps/desktop/src/lib/database/postgresProcessList.ts

147 lines
5.8 KiB
TypeScript

import type { DatabaseType, QueryResult } from "@/types/database";
/**
* PostgreSQL "current activity / process list" helpers. Pure and framework-free
* so they can be unit-tested in isolation; the generic panel component wires them
* to the SQL bridge and the production-safety guard via the driver registry.
*
* The MySQL family lives in `./mysqlProcessList`; the generic, engine-agnostic
* bits (coordinator, interval clamping, session counting) are shared from there.
*/
const PG_PROCESS_LIST_DB_TYPES = new Set<DatabaseType>(["postgres"]);
/**
* One row per server-side backend. `now() - query_start` gives the age of the
* currently running (or last) statement; we fall back to the transaction and
* backend start so idle sessions still report a sensible age. Own session is
* kept in the result set so the panel can dim it rather than hide it.
*/
export const PG_PROCESS_LIST_SQL = `SELECT pid,
usename AS "user",
datname AS db,
coalesce(host(client_addr), client_hostname, 'local') AS client,
application_name AS app,
state,
coalesce(nullif(wait_event_type, '') || ':' || wait_event, wait_event_type, '') AS wait,
floor(extract(epoch FROM (now() - coalesce(query_start, xact_start, backend_start))))::bigint AS time,
query
FROM pg_stat_activity
ORDER BY time DESC NULLS LAST`;
/** PostgreSQL 9.2-9.5 expose `waiting` instead of wait-event detail columns. */
export const PG_PROCESS_LIST_LEGACY_SQL = `SELECT pid,
usename AS "user",
datname AS db,
coalesce(host(client_addr), client_hostname, 'local') AS client,
application_name AS app,
state,
CASE WHEN waiting THEN 'Lock' ELSE '' END AS wait,
floor(extract(epoch FROM (now() - coalesce(query_start, xact_start, backend_start))))::bigint AS time,
query
FROM pg_stat_activity
ORDER BY time DESC NULLS LAST`;
/** Scalar query that returns the viewer's own backend pid. */
export const PG_OWN_SESSION_SQL = "SELECT pg_backend_pid()";
export interface PgProcessRow {
id: number;
user: string;
db: string | null;
client: string;
app: string | null;
state: string | null;
wait: string | null;
time: number;
query: string | null;
}
function columnIndex(columns: string[], name: string): number {
const target = name.toLowerCase();
return columns.findIndex((column) => column.toLowerCase() === target);
}
function asString(value: unknown): string {
if (value === null || value === undefined) return "";
return String(value);
}
function asNullableString(value: unknown): string | null {
if (value === null || value === undefined) return null;
const text = String(value);
return text.length === 0 ? null : text;
}
function asNumber(value: unknown): number {
if (typeof value === "number") return value;
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : 0;
}
/**
* Map a `pg_stat_activity` result into typed rows. Column names are matched
* case-insensitively and any missing column degrades to an empty value rather
* than throwing, so forks that rename or drop a column still render.
*/
export function mapPgProcessRows(result: QueryResult | null | undefined): PgProcessRow[] {
if (!result || !Array.isArray(result.columns) || !Array.isArray(result.rows)) return [];
const columns = result.columns;
const pidIdx = columnIndex(columns, "pid");
const userIdx = columnIndex(columns, "user");
const dbIdx = columnIndex(columns, "db");
const clientIdx = columnIndex(columns, "client");
const appIdx = columnIndex(columns, "app");
const stateIdx = columnIndex(columns, "state");
const waitIdx = columnIndex(columns, "wait");
const timeIdx = columnIndex(columns, "time");
const queryIdx = columnIndex(columns, "query");
const cell = (row: (string | number | boolean | null)[], idx: number) => (idx >= 0 ? row[idx] : null);
return result.rows.map((row) => ({
id: asNumber(cell(row, pidIdx)),
user: asString(cell(row, userIdx)),
db: asNullableString(cell(row, dbIdx)),
client: asString(cell(row, clientIdx)),
app: asNullableString(cell(row, appIdx)),
state: asNullableString(cell(row, stateIdx)),
wait: asNullableString(cell(row, waitIdx)),
time: asNumber(cell(row, timeIdx)),
query: asNullableString(cell(row, queryIdx)),
}));
}
/**
* Build a `SELECT pg_terminate_backend(<pid>)` statement. `pid` is validated as a
* finite positive integer (never interpolated as free text) so there is no
* injection path.
*/
export function buildPgKillSql(pid: number): string {
if (!Number.isInteger(pid) || pid <= 0) {
throw new Error(`Invalid backend pid: ${pid}`);
}
return `SELECT pg_terminate_backend(${pid})`;
}
/** Return an error when PostgreSQL declines to terminate the target backend. */
export function pgKillResultError(results: QueryResult[]): string | null {
const result = results.find((item) => item.execution_error !== true);
const value = result?.rows?.[0]?.[0];
if (value === true || value === 1 || String(value).toLowerCase() === "t" || String(value).toLowerCase() === "true") return null;
return "pg_terminate_backend did not terminate the backend";
}
/** Detect the undefined-column failure produced by pre-9.6 pg_stat_activity. */
export function isPgProcessListCompatibilityError(error: unknown): boolean {
const code = typeof error === "object" && error !== null && "code" in error ? String((error as { code?: unknown }).code ?? "") : "";
if (code === "42703") return true;
const message = error instanceof Error ? error.message : String(error);
return /(?:wait_event_type|wait_event).*(?:does not exist|42703)|(?:does not exist|42703).*(?:wait_event_type|wait_event)/i.test(message);
}
/** Whether the given database type exposes the Postgres process-list viewer. */
export function supportsPgProcessList(dbType: DatabaseType | undefined): boolean {
return !!dbType && PG_PROCESS_LIST_DB_TYPES.has(dbType);
}