@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.
Files changed (76) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +294 -2
  3. package/dist/app.js +39 -0
  4. package/dist/cli.js +263 -0
  5. package/dist/commands/configgen.js +128 -0
  6. package/dist/commands/explore.js +306 -0
  7. package/dist/commands/terminal-prompt.js +68 -0
  8. package/dist/config/load.js +386 -0
  9. package/dist/config/resolve-environment.js +164 -0
  10. package/dist/config/resolvers/env.js +12 -0
  11. package/dist/config/resolvers/file.js +24 -0
  12. package/dist/config/resolvers/groups.js +1 -0
  13. package/dist/config/resolvers/index.js +49 -0
  14. package/dist/config/resolvers/literal.js +7 -0
  15. package/dist/config/resolvers/prompt.js +159 -0
  16. package/dist/config/resolvers/stored.js +13 -0
  17. package/dist/config/resolvers/types.js +18 -0
  18. package/dist/config/resolvers/vault.js +365 -0
  19. package/dist/config/schema.js +24 -0
  20. package/dist/config/source.js +105 -0
  21. package/dist/db/connect.js +55 -0
  22. package/dist/db/identifiers.js +6 -0
  23. package/dist/db/paths.js +14 -0
  24. package/dist/db/store.js +266 -0
  25. package/dist/db/tester.js +190 -0
  26. package/dist/db/validate.js +99 -0
  27. package/dist/environments.js +137 -0
  28. package/dist/explorer/catalog.js +383 -0
  29. package/dist/explorer/connect-entry.js +31 -0
  30. package/dist/explorer/explain.js +13 -0
  31. package/dist/explorer/format.js +134 -0
  32. package/dist/explorer/rows.js +50 -0
  33. package/dist/explorer/session.js +105 -0
  34. package/dist/prompts.js +52 -0
  35. package/dist/screens/environments.js +226 -0
  36. package/dist/screens/explorer.js +148 -0
  37. package/dist/screens/header.js +8 -0
  38. package/dist/screens/index.js +13 -0
  39. package/dist/screens/new-connection.js +178 -0
  40. package/dist/screens/panes/common.js +35 -0
  41. package/dist/screens/panes/history.js +36 -0
  42. package/dist/screens/panes/info.js +61 -0
  43. package/dist/screens/panes/query.js +113 -0
  44. package/dist/screens/panes/table.js +178 -0
  45. package/dist/screens/panes/tables.js +142 -0
  46. package/dist/screens/prompt-modal.js +18 -0
  47. package/dist/screens/record-view.js +42 -0
  48. package/dist/screens/result-grid.js +126 -0
  49. package/dist/screens/scroll.js +12 -0
  50. package/dist/screens/splash.js +76 -0
  51. package/dist/screens/terminal.js +25 -0
  52. package/dist/screens/types.js +1 -0
  53. package/dist/services.js +1 -0
  54. package/dist/startup.js +84 -0
  55. package/dist/web/server.js +355 -0
  56. package/package.json +67 -5
  57. package/web/dist/assets/anton-latin-400-normal-AUNGEG_V.woff +0 -0
  58. package/web/dist/assets/anton-latin-400-normal-Byf51wtH.woff2 +0 -0
  59. package/web/dist/assets/anton-latin-ext-400-normal-BMODBQc6.woff +0 -0
  60. package/web/dist/assets/anton-latin-ext-400-normal-SyiqE2Jt.woff2 +0 -0
  61. package/web/dist/assets/anton-vietnamese-400-normal-2FfR1wHA.woff +0 -0
  62. package/web/dist/assets/anton-vietnamese-400-normal-CkBxLiRJ.woff2 +0 -0
  63. package/web/dist/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
  64. package/web/dist/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
  65. package/web/dist/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
  66. package/web/dist/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
  67. package/web/dist/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
  68. package/web/dist/assets/index-BMLFCtFY.js +17 -0
  69. package/web/dist/assets/index-CMXJRL9O.css +2 -0
  70. package/web/dist/assets/orbitron-latin-700-normal-4jsRXGGJ.woff2 +0 -0
  71. package/web/dist/assets/orbitron-latin-700-normal-DmYOh-W9.woff +0 -0
  72. package/web/dist/assets/orbitron-latin-900-normal-BbJatbhp.woff +0 -0
  73. package/web/dist/assets/orbitron-latin-900-normal-DrIi7unX.woff2 +0 -0
  74. package/web/dist/assets/share-tech-mono-latin-400-normal-15MgdvEM.woff +0 -0
  75. package/web/dist/assets/share-tech-mono-latin-400-normal-CCqdbj5z.woff2 +0 -0
  76. 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
+ }