@kazurolabs/psql-explorer 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +294 -2
- package/dist/app.js +39 -0
- package/dist/cli.js +263 -0
- package/dist/commands/configgen.js +128 -0
- package/dist/commands/explore.js +306 -0
- package/dist/commands/terminal-prompt.js +68 -0
- package/dist/config/load.js +386 -0
- package/dist/config/resolve-environment.js +164 -0
- package/dist/config/resolvers/env.js +12 -0
- package/dist/config/resolvers/file.js +24 -0
- package/dist/config/resolvers/groups.js +1 -0
- package/dist/config/resolvers/index.js +49 -0
- package/dist/config/resolvers/literal.js +7 -0
- package/dist/config/resolvers/prompt.js +159 -0
- package/dist/config/resolvers/stored.js +13 -0
- package/dist/config/resolvers/types.js +18 -0
- package/dist/config/resolvers/vault.js +365 -0
- package/dist/config/schema.js +24 -0
- package/dist/config/source.js +105 -0
- package/dist/db/connect.js +55 -0
- package/dist/db/identifiers.js +6 -0
- package/dist/db/paths.js +14 -0
- package/dist/db/store.js +266 -0
- package/dist/db/tester.js +190 -0
- package/dist/db/validate.js +99 -0
- package/dist/environments.js +137 -0
- package/dist/explorer/catalog.js +383 -0
- package/dist/explorer/connect-entry.js +31 -0
- package/dist/explorer/explain.js +13 -0
- package/dist/explorer/format.js +134 -0
- package/dist/explorer/rows.js +50 -0
- package/dist/explorer/session.js +105 -0
- package/dist/prompts.js +52 -0
- package/dist/screens/environments.js +226 -0
- package/dist/screens/explorer.js +148 -0
- package/dist/screens/header.js +8 -0
- package/dist/screens/index.js +13 -0
- package/dist/screens/new-connection.js +178 -0
- package/dist/screens/panes/common.js +35 -0
- package/dist/screens/panes/history.js +36 -0
- package/dist/screens/panes/info.js +61 -0
- package/dist/screens/panes/query.js +113 -0
- package/dist/screens/panes/table.js +178 -0
- package/dist/screens/panes/tables.js +142 -0
- package/dist/screens/prompt-modal.js +18 -0
- package/dist/screens/record-view.js +42 -0
- package/dist/screens/result-grid.js +126 -0
- package/dist/screens/scroll.js +12 -0
- package/dist/screens/splash.js +76 -0
- package/dist/screens/terminal.js +25 -0
- package/dist/screens/types.js +1 -0
- package/dist/services.js +1 -0
- package/dist/startup.js +84 -0
- package/dist/web/server.js +355 -0
- package/package.json +67 -5
- package/web/dist/assets/anton-latin-400-normal-AUNGEG_V.woff +0 -0
- package/web/dist/assets/anton-latin-400-normal-Byf51wtH.woff2 +0 -0
- package/web/dist/assets/anton-latin-ext-400-normal-BMODBQc6.woff +0 -0
- package/web/dist/assets/anton-latin-ext-400-normal-SyiqE2Jt.woff2 +0 -0
- package/web/dist/assets/anton-vietnamese-400-normal-2FfR1wHA.woff +0 -0
- package/web/dist/assets/anton-vietnamese-400-normal-CkBxLiRJ.woff2 +0 -0
- package/web/dist/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
- package/web/dist/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
- package/web/dist/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
- package/web/dist/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
- package/web/dist/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
- package/web/dist/assets/index-BMLFCtFY.js +17 -0
- package/web/dist/assets/index-CMXJRL9O.css +2 -0
- package/web/dist/assets/orbitron-latin-700-normal-4jsRXGGJ.woff2 +0 -0
- package/web/dist/assets/orbitron-latin-700-normal-DmYOh-W9.woff +0 -0
- package/web/dist/assets/orbitron-latin-900-normal-BbJatbhp.woff +0 -0
- package/web/dist/assets/orbitron-latin-900-normal-DrIi7unX.woff2 +0 -0
- package/web/dist/assets/share-tech-mono-latin-400-normal-15MgdvEM.woff +0 -0
- package/web/dist/assets/share-tech-mono-latin-400-normal-CCqdbj5z.woff2 +0 -0
- package/web/dist/index.html +19 -0
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// The environments a user can pick: those from the shared config file plus their local
|
|
2
|
+
// connections, as one list. No Ink in here; screens reach it through Services.
|
|
3
|
+
import { resolveEnvironment } from './config/resolve-environment.js';
|
|
4
|
+
import { resolveValue, ResolveError } from './config/resolvers/index.js';
|
|
5
|
+
import { refKinds, } from './config/schema.js';
|
|
6
|
+
// A literal is shown as written; anything looked up elsewhere only by where it comes from.
|
|
7
|
+
const display = (value) => {
|
|
8
|
+
if (typeof value !== 'object')
|
|
9
|
+
return String(value);
|
|
10
|
+
const kind = refKinds.find((k) => k in value);
|
|
11
|
+
return `‹${kind ?? '?'}›`;
|
|
12
|
+
};
|
|
13
|
+
export const sharedKey = (name) => `shared:${name}`;
|
|
14
|
+
export const localKey = (id) => `local:${id}`;
|
|
15
|
+
/** The red mark beside an environment's name, if it has one. */
|
|
16
|
+
export const badgeOf = (entry) => entry.protected ? 'PROTECTED' : undefined;
|
|
17
|
+
/** The colour a tier is shown in, so the riskier ones stand out. */
|
|
18
|
+
export const tierColor = {
|
|
19
|
+
production: 'red',
|
|
20
|
+
reference: 'yellow',
|
|
21
|
+
demo: 'magenta',
|
|
22
|
+
development: 'green',
|
|
23
|
+
local: 'gray',
|
|
24
|
+
};
|
|
25
|
+
/** Marks a protected environment. */
|
|
26
|
+
export const shield = '⛨';
|
|
27
|
+
export const addressOf = (entry) => `${entry.user}@${entry.host}:${entry.port}/${entry.database}`;
|
|
28
|
+
/** Shared environments by name, then local connections by name. May throw if the store fails. */
|
|
29
|
+
export function listEnvironments(services) {
|
|
30
|
+
const shared = (services.config?.environments ?? []).map((config) => ({
|
|
31
|
+
key: sharedKey(config.name),
|
|
32
|
+
source: 'shared',
|
|
33
|
+
name: config.name,
|
|
34
|
+
tier: config.tier,
|
|
35
|
+
protected: config.protected,
|
|
36
|
+
readOnly: config.readOnly,
|
|
37
|
+
tlsMode: config.tls.mode,
|
|
38
|
+
user: config.username === undefined ? '‹vault›' : display(config.username),
|
|
39
|
+
host: display(config.host),
|
|
40
|
+
port: display(config.port),
|
|
41
|
+
database: display(config.database),
|
|
42
|
+
config,
|
|
43
|
+
}));
|
|
44
|
+
const local = services.store.list().map((connection) => ({
|
|
45
|
+
key: localKey(connection.id),
|
|
46
|
+
source: 'local',
|
|
47
|
+
name: connection.name,
|
|
48
|
+
// Local connections are the user's own, so they never get production guards.
|
|
49
|
+
tier: 'local',
|
|
50
|
+
protected: false,
|
|
51
|
+
readOnly: false,
|
|
52
|
+
tlsMode: connection.ssl ? 'require' : 'disable',
|
|
53
|
+
user: connection.username,
|
|
54
|
+
host: connection.host,
|
|
55
|
+
port: String(connection.port),
|
|
56
|
+
database: connection.database,
|
|
57
|
+
connection,
|
|
58
|
+
}));
|
|
59
|
+
return [...shared, ...local];
|
|
60
|
+
}
|
|
61
|
+
/** Looks up and validates everything needed to connect; may prompt, and may throw a ResolveError. */
|
|
62
|
+
export async function resolveEntry(entry, services,
|
|
63
|
+
// `interactive: false` fails instead of asking, unless the answer is already known.
|
|
64
|
+
options = {}) {
|
|
65
|
+
const { config, connection } = entry;
|
|
66
|
+
if (config) {
|
|
67
|
+
if (!services.config)
|
|
68
|
+
throw new Error('No shared config is loaded');
|
|
69
|
+
return resolveEnvironment(config, {
|
|
70
|
+
interactive: options.interactive ?? true,
|
|
71
|
+
registry: services.resolvers,
|
|
72
|
+
groups: services.groups,
|
|
73
|
+
signal: options.signal,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
if (!connection)
|
|
77
|
+
throw new Error(`Environment ${entry.name} has no connection details`);
|
|
78
|
+
return {
|
|
79
|
+
name: connection.name,
|
|
80
|
+
tier: 'local',
|
|
81
|
+
protected: false,
|
|
82
|
+
readOnly: false,
|
|
83
|
+
host: connection.host,
|
|
84
|
+
port: connection.port,
|
|
85
|
+
database: connection.database,
|
|
86
|
+
username: connection.username,
|
|
87
|
+
password: connection.password,
|
|
88
|
+
schema: connection.schema,
|
|
89
|
+
tls: { mode: connection.ssl ? 'require' : 'disable' },
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* The environment a command names: a shared one of that name, else a local connection of that name
|
|
94
|
+
* in any letter case. A shared environment wins when both have it.
|
|
95
|
+
*/
|
|
96
|
+
export function findEnvironment(services, name) {
|
|
97
|
+
const entries = listEnvironments(services);
|
|
98
|
+
return (entries.find((entry) => entry.source === 'shared' && entry.name === name) ??
|
|
99
|
+
entries.find((entry) => entry.source === 'local' && entry.name.toLowerCase() === name.toLowerCase()));
|
|
100
|
+
}
|
|
101
|
+
/** Drops the answers typed for an environment, so the next attempt asks again (after a wrong password, say). */
|
|
102
|
+
export function forgetAnswers(entry, services) {
|
|
103
|
+
services.resolvers.get('prompt')?.forget(entry.name);
|
|
104
|
+
}
|
|
105
|
+
const vaultValues = (config) => [
|
|
106
|
+
['host', config.host],
|
|
107
|
+
['port', config.port],
|
|
108
|
+
['database', config.database],
|
|
109
|
+
['schema', config.schema],
|
|
110
|
+
['username', config.username],
|
|
111
|
+
['password', config.password],
|
|
112
|
+
['tls.ca', config.tls.ca],
|
|
113
|
+
['tls.cert', config.tls.cert],
|
|
114
|
+
['tls.key', config.tls.key],
|
|
115
|
+
['tls.keyPassphrase', config.tls.keyPassphrase],
|
|
116
|
+
].filter((pair) => typeof pair[1] === 'object' && 'vault' in pair[1]);
|
|
117
|
+
/**
|
|
118
|
+
* Whether Vault lets this user read the secrets an environment refers to. Only `source: vault` values
|
|
119
|
+
* are read (a read changes nothing in Vault); issuing credentials or a certificate is left to the
|
|
120
|
+
* moment of connecting. Any other failure, such as Vault being unreachable, is not a refusal and
|
|
121
|
+
* counts as readable: connecting reports it.
|
|
122
|
+
*/
|
|
123
|
+
export async function credentialsReadable(entry, services, signal) {
|
|
124
|
+
const { config } = entry;
|
|
125
|
+
if (!config || !services.config)
|
|
126
|
+
return true;
|
|
127
|
+
for (const [field, value] of vaultValues(config)) {
|
|
128
|
+
try {
|
|
129
|
+
await resolveValue(value, { envName: config.name, field, configDir: config.dir, interactive: false, signal }, services.resolvers);
|
|
130
|
+
}
|
|
131
|
+
catch (error) {
|
|
132
|
+
if (error instanceof ResolveError && error.denied)
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return true;
|
|
137
|
+
}
|
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
// What is in a database, read from the system catalogs: schemas, their tables, and each table's
|
|
2
|
+
// columns, indexes and constraints. Every name from outside is passed as a parameter or quoted.
|
|
3
|
+
import { quoteIdentifier } from '../db/identifiers.js';
|
|
4
|
+
const kinds = {
|
|
5
|
+
r: 'table',
|
|
6
|
+
p: 'partitioned',
|
|
7
|
+
v: 'view',
|
|
8
|
+
m: 'materialized',
|
|
9
|
+
f: 'foreign',
|
|
10
|
+
};
|
|
11
|
+
export const qualified = (table) => `${quoteIdentifier(table.schema)}.${quoteIdentifier(table.name)}`;
|
|
12
|
+
export const tableLabel = (table) => `${table.schema}.${table.name}`;
|
|
13
|
+
/** "users" → in the default schema; "app.users" → in app. */
|
|
14
|
+
export function parseTableRef(text, defaultSchema) {
|
|
15
|
+
const at = text.indexOf('.');
|
|
16
|
+
if (at <= 0 || at === text.length - 1)
|
|
17
|
+
return { schema: defaultSchema, name: text };
|
|
18
|
+
return { schema: text.slice(0, at), name: text.slice(at + 1) };
|
|
19
|
+
}
|
|
20
|
+
const constraintKinds = {
|
|
21
|
+
p: 'primary key',
|
|
22
|
+
f: 'foreign key',
|
|
23
|
+
u: 'unique',
|
|
24
|
+
c: 'check',
|
|
25
|
+
x: 'exclusion',
|
|
26
|
+
n: 'not null',
|
|
27
|
+
t: 'trigger',
|
|
28
|
+
};
|
|
29
|
+
const functionKinds = {
|
|
30
|
+
f: 'function',
|
|
31
|
+
p: 'procedure',
|
|
32
|
+
a: 'aggregate',
|
|
33
|
+
w: 'window',
|
|
34
|
+
};
|
|
35
|
+
const typeKinds = {
|
|
36
|
+
e: 'enum',
|
|
37
|
+
c: 'composite',
|
|
38
|
+
d: 'domain',
|
|
39
|
+
r: 'range',
|
|
40
|
+
m: 'multirange',
|
|
41
|
+
};
|
|
42
|
+
const count = (value) => (value === null ? null : Number(value));
|
|
43
|
+
const flag = (value) => value === 't';
|
|
44
|
+
/** The schemas a person works with: not pg_catalog, information_schema or pg_toast and the like. */
|
|
45
|
+
export async function listSchemas(session) {
|
|
46
|
+
const rows = await session.rows(`SELECT n.nspname AS name, pg_catalog.pg_get_userbyid(n.nspowner) AS owner,
|
|
47
|
+
(SELECT count(*) FROM pg_catalog.pg_class c
|
|
48
|
+
WHERE c.relnamespace = n.oid AND c.relkind IN ('r', 'p', 'v', 'm', 'f')) AS tables
|
|
49
|
+
FROM pg_catalog.pg_namespace n
|
|
50
|
+
WHERE n.nspname !~ '^pg_' AND n.nspname <> 'information_schema'
|
|
51
|
+
ORDER BY n.nspname`);
|
|
52
|
+
return rows.map((row) => ({
|
|
53
|
+
name: row.name ?? '',
|
|
54
|
+
owner: row.owner ?? '',
|
|
55
|
+
tables: Number(row.tables),
|
|
56
|
+
}));
|
|
57
|
+
}
|
|
58
|
+
/** Tables, views, materialized views and foreign tables of a schema, by name (or just `name`). */
|
|
59
|
+
export async function listTables(session, schema, name) {
|
|
60
|
+
const rows = await session.rows(`SELECT c.relname AS name, c.relkind AS kind,
|
|
61
|
+
CASE WHEN c.reltuples < 0 OR c.relkind IN ('v', 'f') THEN NULL
|
|
62
|
+
ELSE c.reltuples::int8 END AS estimated_rows,
|
|
63
|
+
CASE WHEN c.relkind IN ('r', 'p', 'm') THEN pg_catalog.pg_total_relation_size(c.oid)
|
|
64
|
+
END AS total_bytes,
|
|
65
|
+
pg_catalog.obj_description(c.oid, 'pg_class') AS comment
|
|
66
|
+
FROM pg_catalog.pg_class c
|
|
67
|
+
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
|
|
68
|
+
WHERE n.nspname = $1 AND c.relkind IN ('r', 'p', 'v', 'm', 'f')
|
|
69
|
+
AND ($2::text IS NULL OR c.relname = $2)
|
|
70
|
+
ORDER BY c.relname`, [schema, name ?? null]);
|
|
71
|
+
return rows.map((row) => ({
|
|
72
|
+
schema,
|
|
73
|
+
name: row.name ?? '',
|
|
74
|
+
kind: kinds[row.kind ?? ''] ?? 'table',
|
|
75
|
+
estimatedRows: count(row.estimated_rows),
|
|
76
|
+
totalBytes: count(row.total_bytes),
|
|
77
|
+
comment: row.comment,
|
|
78
|
+
}));
|
|
79
|
+
}
|
|
80
|
+
export async function listColumns(session, table) {
|
|
81
|
+
const rows = await session.rows(`SELECT a.attname AS name, pg_catalog.format_type(a.atttypid, a.atttypmod) AS type,
|
|
82
|
+
NOT a.attnotnull AS nullable,
|
|
83
|
+
pg_catalog.pg_get_expr(d.adbin, d.adrelid) AS default,
|
|
84
|
+
EXISTS (SELECT 1 FROM pg_catalog.pg_index i
|
|
85
|
+
WHERE i.indrelid = a.attrelid AND i.indisprimary AND a.attnum = ANY (i.indkey)) AS primary_key,
|
|
86
|
+
EXISTS (SELECT 1 FROM pg_catalog.pg_index i
|
|
87
|
+
WHERE i.indrelid = a.attrelid AND i.indisunique AND i.indnkeyatts = 1
|
|
88
|
+
AND i.indpred IS NULL AND i.indkey[0] = a.attnum) AS is_unique,
|
|
89
|
+
fk.ref_schema, fk.ref_table, fk.ref_column,
|
|
90
|
+
pg_catalog.col_description(a.attrelid, a.attnum) AS comment
|
|
91
|
+
FROM pg_catalog.pg_attribute a
|
|
92
|
+
LEFT JOIN pg_catalog.pg_attrdef d ON d.adrelid = a.attrelid AND d.adnum = a.attnum
|
|
93
|
+
LEFT JOIN LATERAL (
|
|
94
|
+
SELECT rn.nspname AS ref_schema, rc.relname AS ref_table, ra.attname AS ref_column
|
|
95
|
+
FROM pg_catalog.pg_constraint c
|
|
96
|
+
CROSS JOIN LATERAL unnest(c.conkey, c.confkey) AS k(attnum, refattnum)
|
|
97
|
+
JOIN pg_catalog.pg_class rc ON rc.oid = c.confrelid
|
|
98
|
+
JOIN pg_catalog.pg_namespace rn ON rn.oid = rc.relnamespace
|
|
99
|
+
JOIN pg_catalog.pg_attribute ra ON ra.attrelid = c.confrelid AND ra.attnum = k.refattnum
|
|
100
|
+
WHERE c.conrelid = a.attrelid AND c.contype = 'f' AND k.attnum = a.attnum
|
|
101
|
+
ORDER BY c.conname
|
|
102
|
+
LIMIT 1
|
|
103
|
+
) fk ON true
|
|
104
|
+
WHERE a.attrelid = $1::regclass AND a.attnum > 0 AND NOT a.attisdropped
|
|
105
|
+
ORDER BY a.attnum`, [qualified(table)]);
|
|
106
|
+
return rows.map((row) => ({
|
|
107
|
+
name: row.name ?? '',
|
|
108
|
+
type: row.type ?? '',
|
|
109
|
+
nullable: flag(row.nullable),
|
|
110
|
+
default: row.default,
|
|
111
|
+
primaryKey: flag(row.primary_key),
|
|
112
|
+
unique: flag(row.is_unique),
|
|
113
|
+
references: row.ref_table === null
|
|
114
|
+
? null
|
|
115
|
+
: { schema: row.ref_schema ?? '', table: row.ref_table ?? '', column: row.ref_column ?? '' },
|
|
116
|
+
comment: row.comment,
|
|
117
|
+
}));
|
|
118
|
+
}
|
|
119
|
+
export async function listIndexes(session, table) {
|
|
120
|
+
const rows = await session.rows(`SELECT i.relname AS name, pg_catalog.pg_get_indexdef(ix.indexrelid) AS definition,
|
|
121
|
+
ix.indisprimary AS primary, ix.indisunique AS unique,
|
|
122
|
+
pg_catalog.pg_relation_size(ix.indexrelid) AS bytes
|
|
123
|
+
FROM pg_catalog.pg_index ix
|
|
124
|
+
JOIN pg_catalog.pg_class i ON i.oid = ix.indexrelid
|
|
125
|
+
WHERE ix.indrelid = $1::regclass
|
|
126
|
+
ORDER BY ix.indisprimary DESC, i.relname`, [qualified(table)]);
|
|
127
|
+
return rows.map((row) => ({
|
|
128
|
+
name: row.name ?? '',
|
|
129
|
+
definition: row.definition ?? '',
|
|
130
|
+
primary: flag(row.primary),
|
|
131
|
+
unique: flag(row.unique),
|
|
132
|
+
bytes: Number(row.bytes),
|
|
133
|
+
}));
|
|
134
|
+
}
|
|
135
|
+
export async function listConstraints(session, table) {
|
|
136
|
+
const rows = await session.rows(`SELECT conname AS name, contype AS kind, pg_catalog.pg_get_constraintdef(oid, true) AS definition
|
|
137
|
+
FROM pg_catalog.pg_constraint
|
|
138
|
+
WHERE conrelid = $1::regclass
|
|
139
|
+
ORDER BY array_position(ARRAY['p', 'u', 'f', 'c', 'x', 'n', 't']::"char"[], contype), conname`, [qualified(table)]);
|
|
140
|
+
return rows.map((row) => ({
|
|
141
|
+
name: row.name ?? '',
|
|
142
|
+
kind: constraintKinds[row.kind ?? ''] ?? 'check',
|
|
143
|
+
definition: row.definition ?? '',
|
|
144
|
+
}));
|
|
145
|
+
}
|
|
146
|
+
/** Columns, indexes and constraints of one table. Throws when there is no such table. */
|
|
147
|
+
export async function describeTable(session, table) {
|
|
148
|
+
const columns = await listColumns(session, table);
|
|
149
|
+
const indexes = await listIndexes(session, table);
|
|
150
|
+
const constraints = await listConstraints(session, table);
|
|
151
|
+
return { table, columns, indexes, constraints };
|
|
152
|
+
}
|
|
153
|
+
export async function listFunctions(session, schema) {
|
|
154
|
+
const rows = await session.rows(`SELECT p.proname AS name, pg_catalog.pg_get_function_identity_arguments(p.oid) AS args,
|
|
155
|
+
p.prokind AS kind,
|
|
156
|
+
CASE WHEN p.prokind = 'p' THEN NULL ELSE pg_catalog.pg_get_function_result(p.oid) END AS returns,
|
|
157
|
+
l.lanname AS language, pg_catalog.obj_description(p.oid, 'pg_proc') AS comment
|
|
158
|
+
FROM pg_catalog.pg_proc p
|
|
159
|
+
JOIN pg_catalog.pg_namespace n ON n.oid = p.pronamespace
|
|
160
|
+
JOIN pg_catalog.pg_language l ON l.oid = p.prolang
|
|
161
|
+
WHERE n.nspname = $1
|
|
162
|
+
ORDER BY p.proname, args`, [schema]);
|
|
163
|
+
return rows.map((row) => ({
|
|
164
|
+
schema,
|
|
165
|
+
name: row.name ?? '',
|
|
166
|
+
args: row.args ?? '',
|
|
167
|
+
kind: functionKinds[row.kind ?? ''] ?? 'function',
|
|
168
|
+
returns: row.returns,
|
|
169
|
+
language: row.language ?? '',
|
|
170
|
+
comment: row.comment,
|
|
171
|
+
}));
|
|
172
|
+
}
|
|
173
|
+
export async function listSequences(session, schema) {
|
|
174
|
+
const rows = await session.rows(`SELECT s.sequencename AS name, s.data_type::text AS data_type,
|
|
175
|
+
(SELECT r.relname || '.' || a.attname
|
|
176
|
+
FROM pg_catalog.pg_depend d
|
|
177
|
+
JOIN pg_catalog.pg_class r ON r.oid = d.refobjid
|
|
178
|
+
JOIN pg_catalog.pg_attribute a ON a.attrelid = d.refobjid AND a.attnum = d.refobjsubid
|
|
179
|
+
WHERE d.objid = c.oid AND d.classid = 'pg_catalog.pg_class'::regclass
|
|
180
|
+
AND d.refclassid = 'pg_catalog.pg_class'::regclass AND d.deptype IN ('a', 'i')
|
|
181
|
+
LIMIT 1) AS owned_by
|
|
182
|
+
FROM pg_catalog.pg_sequences s
|
|
183
|
+
JOIN pg_catalog.pg_namespace n ON n.nspname = s.schemaname
|
|
184
|
+
JOIN pg_catalog.pg_class c ON c.relname = s.sequencename AND c.relnamespace = n.oid
|
|
185
|
+
WHERE s.schemaname = $1
|
|
186
|
+
ORDER BY s.sequencename`, [schema]);
|
|
187
|
+
return rows.map((row) => ({
|
|
188
|
+
schema,
|
|
189
|
+
name: row.name ?? '',
|
|
190
|
+
dataType: row.data_type ?? '',
|
|
191
|
+
ownedBy: row.owned_by,
|
|
192
|
+
}));
|
|
193
|
+
}
|
|
194
|
+
/** Enums, composite types, domains and ranges; not the row types that tables and views bring. */
|
|
195
|
+
export async function listTypes(session, schema) {
|
|
196
|
+
const rows = await session.rows(`SELECT t.typname AS name, t.typtype AS kind, pg_catalog.obj_description(t.oid, 'pg_type') AS comment
|
|
197
|
+
FROM pg_catalog.pg_type t
|
|
198
|
+
JOIN pg_catalog.pg_namespace n ON n.oid = t.typnamespace
|
|
199
|
+
WHERE n.nspname = $1 AND t.typtype IN ('e', 'c', 'd', 'r', 'm')
|
|
200
|
+
AND (t.typtype <> 'c' OR EXISTS (
|
|
201
|
+
SELECT 1 FROM pg_catalog.pg_class c WHERE c.oid = t.typrelid AND c.relkind = 'c'))
|
|
202
|
+
ORDER BY t.typname`, [schema]);
|
|
203
|
+
return rows.map((row) => ({
|
|
204
|
+
schema,
|
|
205
|
+
name: row.name ?? '',
|
|
206
|
+
kind: typeKinds[row.kind ?? ''] ?? 'composite',
|
|
207
|
+
comment: row.comment,
|
|
208
|
+
}));
|
|
209
|
+
}
|
|
210
|
+
/** Tables, functions, sequences and types of one schema. */
|
|
211
|
+
export async function listObjects(session, schema) {
|
|
212
|
+
const tables = await listTables(session, schema);
|
|
213
|
+
const functions = await listFunctions(session, schema);
|
|
214
|
+
const sequences = await listSequences(session, schema);
|
|
215
|
+
const types = await listTypes(session, schema);
|
|
216
|
+
return { tables, functions, sequences, types };
|
|
217
|
+
}
|
|
218
|
+
/** The tables of a schema with their columns, whose foreign keys are the relations of an ERD. */
|
|
219
|
+
export async function describeErd(session, schema) {
|
|
220
|
+
const tables = (await listTables(session, schema)).filter((table) => table.kind === 'table' || table.kind === 'partitioned');
|
|
221
|
+
const erd = [];
|
|
222
|
+
for (const table of tables)
|
|
223
|
+
erd.push({ ...table, columns: await listColumns(session, table) });
|
|
224
|
+
return erd;
|
|
225
|
+
}
|
|
226
|
+
/** Every table, view, ... of every schema with its columns, for completing and explaining SQL. */
|
|
227
|
+
export async function describeCatalog(session) {
|
|
228
|
+
const catalog = [];
|
|
229
|
+
for (const schema of await listSchemas(session)) {
|
|
230
|
+
for (const table of await listTables(session, schema.name)) {
|
|
231
|
+
catalog.push({ ...table, columns: await listColumns(session, table) });
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
return catalog;
|
|
235
|
+
}
|
|
236
|
+
/** A table, view, ... with its columns, indexes and constraints. Null when there is no such one. */
|
|
237
|
+
export async function describeRelation(session, table) {
|
|
238
|
+
const [info] = await listTables(session, table.schema, table.name);
|
|
239
|
+
if (!info)
|
|
240
|
+
return null;
|
|
241
|
+
const details = await describeTable(session, table);
|
|
242
|
+
let definition = null;
|
|
243
|
+
if (info.kind === 'view' || info.kind === 'materialized') {
|
|
244
|
+
const [row] = await session.rows('SELECT pg_catalog.pg_get_viewdef($1::regclass, true) AS definition', [qualified(table)]);
|
|
245
|
+
definition = row?.definition ?? null;
|
|
246
|
+
}
|
|
247
|
+
return { ...info, ...details, definition };
|
|
248
|
+
}
|
|
249
|
+
/** Null when there is no function of that name with those argument types. */
|
|
250
|
+
export async function describeFunction(session, ref) {
|
|
251
|
+
const [row] = await session.rows(`SELECT p.prokind AS kind,
|
|
252
|
+
CASE WHEN p.prokind = 'p' THEN NULL ELSE pg_catalog.pg_get_function_result(p.oid) END AS returns,
|
|
253
|
+
l.lanname AS language, pg_catalog.obj_description(p.oid, 'pg_proc') AS comment,
|
|
254
|
+
pg_catalog.pg_get_userbyid(p.proowner) AS owner, p.provolatile AS volatility,
|
|
255
|
+
p.proisstrict AS strict, p.prosecdef AS security_definer,
|
|
256
|
+
CASE WHEN p.prokind = 'a' THEN NULL ELSE pg_catalog.pg_get_functiondef(p.oid) END AS definition
|
|
257
|
+
FROM pg_catalog.pg_proc p
|
|
258
|
+
JOIN pg_catalog.pg_namespace n ON n.oid = p.pronamespace
|
|
259
|
+
JOIN pg_catalog.pg_language l ON l.oid = p.prolang
|
|
260
|
+
WHERE n.nspname = $1 AND p.proname = $2
|
|
261
|
+
AND pg_catalog.pg_get_function_identity_arguments(p.oid) = $3`, [ref.schema, ref.name, ref.args]);
|
|
262
|
+
if (!row)
|
|
263
|
+
return null;
|
|
264
|
+
return {
|
|
265
|
+
...ref,
|
|
266
|
+
kind: functionKinds[row.kind ?? ''] ?? 'function',
|
|
267
|
+
returns: row.returns,
|
|
268
|
+
language: row.language ?? '',
|
|
269
|
+
comment: row.comment,
|
|
270
|
+
owner: row.owner ?? '',
|
|
271
|
+
volatility: row.volatility === 'i' ? 'immutable' : row.volatility === 's' ? 'stable' : 'volatile',
|
|
272
|
+
strict: flag(row.strict),
|
|
273
|
+
securityDefiner: flag(row.security_definer),
|
|
274
|
+
definition: row.definition,
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
export async function describeSequence(session, ref) {
|
|
278
|
+
const [info] = (await listSequences(session, ref.schema)).filter((s) => s.name === ref.name);
|
|
279
|
+
if (!info)
|
|
280
|
+
return null;
|
|
281
|
+
const [row] = await session.rows(`SELECT sequenceowner AS owner, start_value::text AS start, min_value::text AS min,
|
|
282
|
+
max_value::text AS max, increment_by::text AS increment, cache_size::text AS cache,
|
|
283
|
+
cycle, last_value::text AS last_value
|
|
284
|
+
FROM pg_catalog.pg_sequences WHERE schemaname = $1 AND sequencename = $2`, [ref.schema, ref.name]);
|
|
285
|
+
return {
|
|
286
|
+
...info,
|
|
287
|
+
owner: row?.owner ?? '',
|
|
288
|
+
start: row?.start ?? '',
|
|
289
|
+
min: row?.min ?? '',
|
|
290
|
+
max: row?.max ?? '',
|
|
291
|
+
increment: row?.increment ?? '',
|
|
292
|
+
cache: row?.cache ?? '',
|
|
293
|
+
cycle: flag(row?.cycle ?? null),
|
|
294
|
+
lastValue: row?.last_value ?? null,
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
export async function describeType(session, ref) {
|
|
298
|
+
const [row] = await session.rows(`SELECT t.oid::text AS oid, t.typtype AS kind, pg_catalog.obj_description(t.oid, 'pg_type') AS comment,
|
|
299
|
+
pg_catalog.pg_get_userbyid(t.typowner) AS owner,
|
|
300
|
+
CASE t.typtype
|
|
301
|
+
WHEN 'd' THEN pg_catalog.format_type(t.typbasetype, t.typtypmod)
|
|
302
|
+
WHEN 'r' THEN (SELECT pg_catalog.format_type(r.rngsubtype, NULL)
|
|
303
|
+
FROM pg_catalog.pg_range r WHERE r.rngtypid = t.oid)
|
|
304
|
+
END AS base_type,
|
|
305
|
+
t.typdefault AS default, t.typnotnull AS not_null
|
|
306
|
+
FROM pg_catalog.pg_type t
|
|
307
|
+
JOIN pg_catalog.pg_namespace n ON n.oid = t.typnamespace
|
|
308
|
+
WHERE n.nspname = $1 AND t.typname = $2 AND t.typtype IN ('e', 'c', 'd', 'r', 'm')
|
|
309
|
+
AND (t.typtype <> 'c' OR EXISTS (
|
|
310
|
+
SELECT 1 FROM pg_catalog.pg_class c WHERE c.oid = t.typrelid AND c.relkind = 'c'))`, [ref.schema, ref.name]);
|
|
311
|
+
if (!row)
|
|
312
|
+
return null;
|
|
313
|
+
const oid = row.oid ?? '0';
|
|
314
|
+
const kind = typeKinds[row.kind ?? ''] ?? 'composite';
|
|
315
|
+
const labels = kind === 'enum'
|
|
316
|
+
? (await session.rows('SELECT enumlabel AS label FROM pg_catalog.pg_enum WHERE enumtypid = $1::oid ORDER BY enumsortorder', [oid])).map((r) => r.label ?? '')
|
|
317
|
+
: [];
|
|
318
|
+
const attributes = kind === 'composite'
|
|
319
|
+
? (await session.rows(`SELECT a.attname AS name, pg_catalog.format_type(a.atttypid, a.atttypmod) AS type
|
|
320
|
+
FROM pg_catalog.pg_type t
|
|
321
|
+
JOIN pg_catalog.pg_attribute a ON a.attrelid = t.typrelid
|
|
322
|
+
WHERE t.oid = $1::oid AND a.attnum > 0 AND NOT a.attisdropped
|
|
323
|
+
ORDER BY a.attnum`, [oid])).map((r) => ({ name: r.name ?? '', type: r.type ?? '' }))
|
|
324
|
+
: [];
|
|
325
|
+
const constraints = kind === 'domain'
|
|
326
|
+
? (await session.rows(`SELECT conname AS name, pg_catalog.pg_get_constraintdef(oid, true) AS definition
|
|
327
|
+
FROM pg_catalog.pg_constraint WHERE contypid = $1::oid ORDER BY conname`, [oid])).map((r) => ({
|
|
328
|
+
name: r.name ?? '',
|
|
329
|
+
kind: 'check',
|
|
330
|
+
definition: r.definition ?? '',
|
|
331
|
+
}))
|
|
332
|
+
: [];
|
|
333
|
+
return {
|
|
334
|
+
...ref,
|
|
335
|
+
kind,
|
|
336
|
+
comment: row.comment,
|
|
337
|
+
owner: row.owner ?? '',
|
|
338
|
+
labels,
|
|
339
|
+
attributes,
|
|
340
|
+
baseType: row.base_type,
|
|
341
|
+
default: row.default,
|
|
342
|
+
notNull: flag(row.not_null),
|
|
343
|
+
constraints,
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
export async function serverInfo(session) {
|
|
347
|
+
const [row] = await session.rows(`SELECT pg_catalog.version() AS version, current_database() AS database, current_user AS user,
|
|
348
|
+
pg_catalog.pg_database_size(current_database()) AS "databaseBytes",
|
|
349
|
+
pg_catalog.pg_postmaster_start_time()::text AS "startedAt",
|
|
350
|
+
current_setting('default_transaction_read_only') AS "readOnly",
|
|
351
|
+
current_setting('TimeZone') AS "timeZone",
|
|
352
|
+
current_setting('search_path') AS "searchPath",
|
|
353
|
+
current_setting('server_encoding') AS encoding`);
|
|
354
|
+
return {
|
|
355
|
+
version: row?.version ?? '',
|
|
356
|
+
database: row?.database ?? '',
|
|
357
|
+
user: row?.user ?? '',
|
|
358
|
+
databaseBytes: Number(row?.databaseBytes ?? 0),
|
|
359
|
+
startedAt: row?.startedAt ?? '',
|
|
360
|
+
readOnly: row?.readOnly === 'on',
|
|
361
|
+
timeZone: row?.timeZone ?? '',
|
|
362
|
+
searchPath: row?.searchPath ?? '',
|
|
363
|
+
encoding: row?.encoding ?? '',
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
/** The other connections to this database, longest running first. */
|
|
367
|
+
export async function listActivity(session) {
|
|
368
|
+
const rows = await session.rows(`SELECT pid, usename AS user, application_name AS application,
|
|
369
|
+
host(client_addr) AS client, state,
|
|
370
|
+
(now() - query_start)::text AS duration, left(query, 500) AS query
|
|
371
|
+
FROM pg_catalog.pg_stat_activity
|
|
372
|
+
WHERE datname = current_database() AND pid <> pg_backend_pid()
|
|
373
|
+
ORDER BY query_start NULLS LAST`);
|
|
374
|
+
return rows.map((row) => ({
|
|
375
|
+
pid: Number(row.pid),
|
|
376
|
+
user: row.user,
|
|
377
|
+
application: row.application,
|
|
378
|
+
client: row.client,
|
|
379
|
+
state: row.state,
|
|
380
|
+
duration: row.duration,
|
|
381
|
+
query: row.query,
|
|
382
|
+
}));
|
|
383
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { DEFAULT_TIMEOUT_MS } from '../db/connect.js';
|
|
2
|
+
import { describeError } from '../db/tester.js';
|
|
3
|
+
import { resolveEntry } from '../environments.js';
|
|
4
|
+
/** The driver refused the login, so what was typed for it was probably wrong. */
|
|
5
|
+
export class LoginError extends Error {
|
|
6
|
+
name = 'LoginError';
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Looks up everything an environment needs (from Vault, a prompt, ...) and connects, read only when
|
|
10
|
+
* the environment says so. A connection failure is an Error with a plain-English message.
|
|
11
|
+
*/
|
|
12
|
+
export async function connectEntry(entry, services, options = {}) {
|
|
13
|
+
const resolved = await resolveEntry(entry, services, { signal: options.signal });
|
|
14
|
+
try {
|
|
15
|
+
return await services.openSession(resolved, {
|
|
16
|
+
readOnly: options.readOnly ?? resolved.readOnly,
|
|
17
|
+
...(options.statementTimeoutMs === undefined
|
|
18
|
+
? {}
|
|
19
|
+
: { statementTimeoutMs: options.statementTimeoutMs }),
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
catch (error) {
|
|
23
|
+
const failure = describeError(error, resolved, DEFAULT_TIMEOUT_MS);
|
|
24
|
+
if (failure.ok)
|
|
25
|
+
throw error;
|
|
26
|
+
const message = failure.detail && failure.detail !== failure.reason
|
|
27
|
+
? `${failure.reason} (${failure.detail})`
|
|
28
|
+
: failure.reason;
|
|
29
|
+
throw failure.kind === 'auth' ? new LoginError(message) : new Error(message);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The plan for a statement, with schemas and qualified column names so that advice can name them.
|
|
3
|
+
* Without ANALYZE the statement is planned but never run, so explaining
|
|
4
|
+
* an UPDATE or DELETE changes nothing.
|
|
5
|
+
*/
|
|
6
|
+
export async function explainQuery(session, sql) {
|
|
7
|
+
const statement = sql.replace(/[\s;]+$/, '');
|
|
8
|
+
const [row] = await session.rows(`EXPLAIN (VERBOSE, FORMAT JSON) ${statement}`);
|
|
9
|
+
const plan = JSON.parse(row?.['QUERY PLAN'] ?? '[]');
|
|
10
|
+
if (!plan[0]?.Plan)
|
|
11
|
+
throw new Error('Postgres did not return a plan');
|
|
12
|
+
return plan[0].Plan;
|
|
13
|
+
}
|