@kazurolabs/psql-explorer 0.0.0-stage → 0.3.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 (127) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +505 -2
  3. package/RELEASES.md +87 -0
  4. package/dist/app.js +39 -0
  5. package/dist/cli.js +307 -0
  6. package/dist/commands/configgen.js +128 -0
  7. package/dist/commands/explore.js +388 -0
  8. package/dist/commands/terminal-prompt.js +68 -0
  9. package/dist/config/load.js +324 -0
  10. package/dist/config/resolve-environment.js +112 -0
  11. package/dist/config/resolvers/env.js +12 -0
  12. package/dist/config/resolvers/file.js +24 -0
  13. package/dist/config/resolvers/groups.js +1 -0
  14. package/dist/config/resolvers/index.js +49 -0
  15. package/dist/config/resolvers/literal.js +7 -0
  16. package/dist/config/resolvers/prompt.js +159 -0
  17. package/dist/config/resolvers/stored.js +13 -0
  18. package/dist/config/resolvers/types.js +18 -0
  19. package/dist/config/resolvers/vault.js +283 -0
  20. package/dist/config/schema.js +24 -0
  21. package/dist/config/source.js +105 -0
  22. package/dist/db/connect.js +55 -0
  23. package/dist/db/identifiers.js +6 -0
  24. package/dist/db/paths.js +14 -0
  25. package/dist/db/store.js +323 -0
  26. package/dist/db/tester.js +190 -0
  27. package/dist/db/validate.js +99 -0
  28. package/dist/environments.js +140 -0
  29. package/dist/explorer/alter.js +493 -0
  30. package/dist/explorer/catalog.js +414 -0
  31. package/dist/explorer/connect-entry.js +42 -0
  32. package/dist/explorer/cypher-parse.js +478 -0
  33. package/dist/explorer/cypher.js +963 -0
  34. package/dist/explorer/ddl.js +502 -0
  35. package/dist/explorer/dependents.js +222 -0
  36. package/dist/explorer/design-load.js +254 -0
  37. package/dist/explorer/design.js +45 -0
  38. package/dist/explorer/edit.js +108 -0
  39. package/dist/explorer/explain.js +37 -0
  40. package/dist/explorer/filter.js +121 -0
  41. package/dist/explorer/format.js +134 -0
  42. package/dist/explorer/graph.js +679 -0
  43. package/dist/explorer/gucs.js +31 -0
  44. package/dist/explorer/profile.js +102 -0
  45. package/dist/explorer/references.js +95 -0
  46. package/dist/explorer/rows.js +64 -0
  47. package/dist/explorer/schema-diff.js +360 -0
  48. package/dist/explorer/search.js +309 -0
  49. package/dist/explorer/security.js +152 -0
  50. package/dist/explorer/seed.js +1376 -0
  51. package/dist/explorer/session.js +122 -0
  52. package/dist/explorer/stats.js +135 -0
  53. package/dist/explorer/timeout.js +13 -0
  54. package/dist/prompts.js +52 -0
  55. package/dist/screens/environments.js +229 -0
  56. package/dist/screens/explorer.js +145 -0
  57. package/dist/screens/header.js +8 -0
  58. package/dist/screens/index.js +13 -0
  59. package/dist/screens/new-connection.js +178 -0
  60. package/dist/screens/panes/common.js +35 -0
  61. package/dist/screens/panes/history.js +36 -0
  62. package/dist/screens/panes/info.js +61 -0
  63. package/dist/screens/panes/query.js +113 -0
  64. package/dist/screens/panes/table.js +178 -0
  65. package/dist/screens/panes/tables.js +142 -0
  66. package/dist/screens/prompt-modal.js +18 -0
  67. package/dist/screens/record-view.js +42 -0
  68. package/dist/screens/result-grid.js +126 -0
  69. package/dist/screens/scroll.js +12 -0
  70. package/dist/screens/splash.js +78 -0
  71. package/dist/screens/terminal.js +25 -0
  72. package/dist/screens/types.js +1 -0
  73. package/dist/services.js +1 -0
  74. package/dist/startup.js +84 -0
  75. package/dist/web/server.js +988 -0
  76. package/package.json +69 -5
  77. package/web/dist/assets/anton-latin-400-normal-AUNGEG_V.woff +0 -0
  78. package/web/dist/assets/anton-latin-400-normal-Byf51wtH.woff2 +0 -0
  79. package/web/dist/assets/anton-latin-ext-400-normal-BMODBQc6.woff +0 -0
  80. package/web/dist/assets/anton-latin-ext-400-normal-SyiqE2Jt.woff2 +0 -0
  81. package/web/dist/assets/anton-vietnamese-400-normal-2FfR1wHA.woff +0 -0
  82. package/web/dist/assets/anton-vietnamese-400-normal-CkBxLiRJ.woff2 +0 -0
  83. package/web/dist/assets/antonio-latin-400-normal-B7iMKlzw.woff +0 -0
  84. package/web/dist/assets/antonio-latin-400-normal-CZjzwNAN.woff2 +0 -0
  85. package/web/dist/assets/antonio-latin-700-normal-BY3mCh47.woff +0 -0
  86. package/web/dist/assets/antonio-latin-700-normal-DSH-lEDG.woff2 +0 -0
  87. package/web/dist/assets/antonio-latin-ext-400-normal-nE82dizb.woff2 +0 -0
  88. package/web/dist/assets/antonio-latin-ext-400-normal-qA9Jb92z.woff +0 -0
  89. package/web/dist/assets/antonio-latin-ext-700-normal-BdSkr58W.woff +0 -0
  90. package/web/dist/assets/antonio-latin-ext-700-normal-D-dvgqjr.woff2 +0 -0
  91. package/web/dist/assets/button-ClA70PLR.js +1 -0
  92. package/web/dist/assets/code-xml-CXkpHeU9.js +1 -0
  93. package/web/dist/assets/compare-view-DKl5l_Jw.js +2 -0
  94. package/web/dist/assets/copy-CCXzeW-s.js +1 -0
  95. package/web/dist/assets/cypher-view-Pd0WFH8F.js +13 -0
  96. package/web/dist/assets/design-view-CIHSmH4u.js +9 -0
  97. package/web/dist/assets/erd-view-CV7mFGAN.js +1 -0
  98. package/web/dist/assets/format-sql-BLj5_RDZ.js +5 -0
  99. package/web/dist/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
  100. package/web/dist/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
  101. package/web/dist/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
  102. package/web/dist/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
  103. package/web/dist/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
  104. package/web/dist/assets/graph-force-yBLyErpS.js +1 -0
  105. package/web/dist/assets/graph-link-OCo_s0zO.js +1 -0
  106. package/web/dist/assets/graph-view-sM1OlByf.js +3 -0
  107. package/web/dist/assets/index-CDcnUacl.js +10 -0
  108. package/web/dist/assets/index-OzJqnJWG.css +2 -0
  109. package/web/dist/assets/key-round-Cv_7QeED.js +1 -0
  110. package/web/dist/assets/loader-circle-BK_GMdvN.js +1 -0
  111. package/web/dist/assets/orbitron-latin-700-normal-4jsRXGGJ.woff2 +0 -0
  112. package/web/dist/assets/orbitron-latin-700-normal-DmYOh-W9.woff +0 -0
  113. package/web/dist/assets/orbitron-latin-900-normal-BbJatbhp.woff +0 -0
  114. package/web/dist/assets/orbitron-latin-900-normal-DrIi7unX.woff2 +0 -0
  115. package/web/dist/assets/query-builder-view-ChNFb51g.js +4 -0
  116. package/web/dist/assets/rotate-ccw-CdCaf7Q6.js +1 -0
  117. package/web/dist/assets/route-DO6IZ7of.js +15 -0
  118. package/web/dist/assets/seed-view-DFNB8WHE.js +7 -0
  119. package/web/dist/assets/share-tech-mono-latin-400-normal-15MgdvEM.woff +0 -0
  120. package/web/dist/assets/share-tech-mono-latin-400-normal-CCqdbj5z.woff2 +0 -0
  121. package/web/dist/assets/shield-alert-CMS-3j4U.js +1 -0
  122. package/web/dist/assets/sliders-horizontal-Dg51yc-C.js +1 -0
  123. package/web/dist/assets/sql-tabs-B4jt6bQj.js +2 -0
  124. package/web/dist/assets/table-DfMz2f8M.js +1 -0
  125. package/web/dist/assets/triangle-alert-COGnHCD1.js +1 -0
  126. package/web/dist/assets/zone-D9dXk8tn.js +5 -0
  127. package/web/dist/index.html +26 -0
@@ -0,0 +1,122 @@
1
+ import Cursor from 'pg-cursor';
2
+ import { createClient } from '../db/connect.js';
3
+ import { quoteIdentifier } from '../db/identifiers.js';
4
+ import { shortVersion } from '../db/tester.js';
5
+ export const DEFAULT_MAX_ROWS = 1000;
6
+ /** The server refused to change anything because the session is read only (SQLSTATE 25006). */
7
+ export const isReadOnlyError = (error) => error?.code === '25006';
8
+ // The extended protocol takes one statement at a time (SQLSTATE 42601 with this message).
9
+ const isMultipleCommands = (error) => error instanceof Error && error.message.includes('cannot insert multiple commands');
10
+ /** Why a statement failed, in a form ready to show. */
11
+ export function describeQueryError(error) {
12
+ if (isMultipleCommands(error))
13
+ return 'Run one statement at a time';
14
+ if (isReadOnlyError(error)) {
15
+ return `${error instanceof Error ? error.message : String(error)} (this environment is read only)`;
16
+ }
17
+ const pgError = error;
18
+ const message = error instanceof Error ? error.message : String(error);
19
+ const extra = [
20
+ pgError?.detail ? `Detail: ${pgError.detail}` : undefined,
21
+ pgError?.hint ? `Hint: ${pgError.hint}` : undefined,
22
+ ].filter(Boolean);
23
+ return extra.length > 0 ? `${message}. ${extra.join('. ')}` : message;
24
+ }
25
+ const readCursor = (cursor, count) => new Promise((resolve, reject) => {
26
+ cursor.read(count, (error, rows, result) => {
27
+ if (error)
28
+ reject(error);
29
+ else
30
+ resolve({ rows, result });
31
+ });
32
+ });
33
+ /** Connects to the database and returns a session on it. Throws what the driver throws. */
34
+ export const openSession = async (params, options = {}) => {
35
+ const client = createClient(params, options);
36
+ await client.connect();
37
+ let serverVersion;
38
+ try {
39
+ const result = await client.query('SHOW server_version');
40
+ serverVersion = shortVersion(result.rows[0]?.server_version ?? '');
41
+ // Tables of the environment's schema can be named without it, as in the Tables tab.
42
+ if (params.schema !== 'public') {
43
+ await client.query("SELECT pg_catalog.set_config('search_path', $1, false)", [
44
+ `${quoteIdentifier(params.schema)}, public`,
45
+ ]);
46
+ }
47
+ }
48
+ catch (error) {
49
+ await client.end().catch(() => undefined);
50
+ throw error;
51
+ }
52
+ const typeNames = new Map();
53
+ const namesOf = async (fields) => {
54
+ const missing = [...new Set(fields.map((field) => field.dataTypeID))].filter((oid) => !typeNames.has(oid));
55
+ if (missing.length > 0) {
56
+ const { rows } = await client.query('SELECT oid::int8 AS oid, pg_catalog.format_type(oid, NULL) AS name FROM pg_catalog.pg_type WHERE oid = ANY($1::oid[])', [missing]);
57
+ for (const row of rows)
58
+ typeNames.set(Number(row.oid), row.name);
59
+ }
60
+ return fields.map((field) => ({
61
+ name: field.name,
62
+ type: typeNames.get(field.dataTypeID) ?? String(field.dataTypeID),
63
+ }));
64
+ };
65
+ // One statement at a time: a cursor holds the connection until it is closed.
66
+ let queue = Promise.resolve();
67
+ const serial = (work) => {
68
+ const next = queue.then(work, work);
69
+ queue = next.catch(() => undefined);
70
+ return next;
71
+ };
72
+ return {
73
+ params,
74
+ readOnly: options.readOnly ?? false,
75
+ serverVersion,
76
+ run: (sql, values = [], { maxRows = DEFAULT_MAX_ROWS } = {}) => serial(async () => {
77
+ const started = performance.now();
78
+ const cursor = client.query(new Cursor(sql, [...values], { rowMode: 'array' }));
79
+ let read;
80
+ try {
81
+ // One more than wanted says whether there are more.
82
+ read = await readCursor(cursor, maxRows + 1);
83
+ }
84
+ finally {
85
+ // A cursor stopped before its end keeps the connection busy: close it before
86
+ // asking anything else, such as the names of the column types.
87
+ await cursor.close().catch(() => undefined);
88
+ }
89
+ const { rows, result } = read;
90
+ const durationMs = performance.now() - started;
91
+ const truncated = rows.length > maxRows;
92
+ return {
93
+ command: result.command ?? '',
94
+ columns: await namesOf(result.fields ?? []),
95
+ rows: truncated ? rows.slice(0, maxRows) : rows,
96
+ // A cursor that was not read to the end has no command tag yet.
97
+ rowCount: truncated ? null : (result.rowCount ?? rows.length),
98
+ truncated,
99
+ durationMs,
100
+ };
101
+ }),
102
+ rows: (sql, values = []) => serial(async () => (await client.query(sql, [...values])).rows),
103
+ // From a connection of its own, since this one is busy with the statement to stop.
104
+ cancel: async () => {
105
+ const pid = client.processID;
106
+ if (!pid)
107
+ return;
108
+ const other = createClient(params, { ...options, statementTimeoutMs: 5000, readOnly: true });
109
+ try {
110
+ await other.connect();
111
+ await other.query('SELECT pg_catalog.pg_cancel_backend($1)', [pid]);
112
+ }
113
+ catch {
114
+ // The statement may have finished meanwhile, or the server cannot be reached.
115
+ }
116
+ finally {
117
+ await other.end().catch(() => undefined);
118
+ }
119
+ },
120
+ close: () => serial(() => client.end().catch(() => undefined)),
121
+ };
122
+ };
@@ -0,0 +1,135 @@
1
+ import { qualified } from './catalog.js';
2
+ const utc = (column) => `to_char(${column} AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"')`;
3
+ const kinds = {
4
+ r: 'table',
5
+ p: 'partitioned',
6
+ m: 'materialized',
7
+ };
8
+ const count = (value) => (value === null ? null : Number(value));
9
+ const flag = (value) => value === 't';
10
+ /**
11
+ * Tables and materialized views with their sizes and upkeep, biggest first. The schemas of the
12
+ * system are left out; `table` narrows it to one.
13
+ */
14
+ export async function listTableStats(session, table) {
15
+ const rows = await session.rows(`WITH rel AS (
16
+ SELECT c.oid, n.nspname AS schema, c.relname AS name, c.relkind AS kind, c.reltuples,
17
+ c.relpages,
18
+ pg_catalog.pg_relation_size(c.oid) AS heap_bytes,
19
+ pg_catalog.pg_table_size(c.oid) AS table_and_toast_bytes,
20
+ pg_catalog.pg_indexes_size(c.oid) AS index_bytes,
21
+ CASE WHEN c.reltoastrelid = 0 THEN 0
22
+ ELSE pg_catalog.pg_total_relation_size(c.reltoastrelid) END AS toast_bytes,
23
+ pg_catalog.pg_total_relation_size(c.oid) AS total_bytes,
24
+ -- The bytes a row needs: its header, its pointer in the page, and its columns.
25
+ (SELECT 24 + 4 + sum(s.avg_width) FROM pg_catalog.pg_stats s
26
+ WHERE s.schemaname = n.nspname AND s.tablename = c.relname
27
+ AND NOT s.inherited) AS row_bytes,
28
+ coalesce((SELECT option_value::numeric
29
+ FROM pg_catalog.pg_options_to_table(c.reloptions)
30
+ WHERE option_name = 'fillfactor'), 100) AS fillfactor
31
+ FROM pg_catalog.pg_class c
32
+ JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
33
+ WHERE c.relkind IN ('r', 'p', 'm')
34
+ AND n.nspname !~ '^pg_' AND n.nspname <> 'information_schema'
35
+ AND ($1::text IS NULL OR c.oid = $1::regclass)
36
+ )
37
+ SELECT r.schema, r.name, r.kind,
38
+ CASE WHEN r.reltuples < 0 THEN NULL ELSE r.reltuples::int8 END AS estimated_rows,
39
+ r.table_and_toast_bytes - r.toast_bytes AS table_bytes,
40
+ r.index_bytes, r.toast_bytes, r.total_bytes,
41
+ CASE WHEN r.row_bytes IS NULL OR r.reltuples < 0 OR r.kind = 'p' THEN NULL
42
+ ELSE greatest(r.heap_bytes - ceil(r.reltuples * r.row_bytes
43
+ / ((current_setting('block_size')::int - 24) * r.fillfactor / 100))
44
+ * current_setting('block_size')::int, 0)::int8
45
+ END AS bloat_bytes,
46
+ s.n_live_tup AS live_rows, s.n_dead_tup AS dead_rows,
47
+ s.seq_scan AS seq_scans, s.idx_scan AS index_scans,
48
+ ${utc('s.last_vacuum')} AS last_vacuum, ${utc('s.last_autovacuum')} AS last_autovacuum,
49
+ ${utc('s.last_analyze')} AS last_analyze, ${utc('s.last_autoanalyze')} AS last_autoanalyze
50
+ FROM rel r
51
+ LEFT JOIN pg_catalog.pg_stat_all_tables s ON s.relid = r.oid
52
+ ORDER BY r.total_bytes DESC, r.schema, r.name`, [table ? qualified(table) : null]);
53
+ return rows.map((row) => ({
54
+ schema: row.schema ?? '',
55
+ name: row.name ?? '',
56
+ kind: kinds[row.kind ?? ''] ?? 'table',
57
+ estimatedRows: count(row.estimated_rows),
58
+ tableBytes: Number(row.table_bytes),
59
+ indexBytes: Number(row.index_bytes),
60
+ toastBytes: Number(row.toast_bytes),
61
+ totalBytes: Number(row.total_bytes),
62
+ bloatBytes: count(row.bloat_bytes),
63
+ liveRows: count(row.live_rows),
64
+ deadRows: count(row.dead_rows),
65
+ seqScans: count(row.seq_scans),
66
+ indexScans: count(row.index_scans),
67
+ lastVacuum: row.last_vacuum,
68
+ lastAutovacuum: row.last_autovacuum,
69
+ lastAnalyze: row.last_analyze,
70
+ lastAutoanalyze: row.last_autoanalyze,
71
+ }));
72
+ }
73
+ /**
74
+ * Every index of the user's tables (or of one table) with its size, how often it was used, and the
75
+ * other indexes it duplicates: the same table, columns, operator classes, expressions and
76
+ * condition. Biggest first.
77
+ */
78
+ export async function listIndexStats(session, table) {
79
+ const rows = await session.rows(`SELECT n.nspname AS schema, t.relname AS "table", i.relname AS name,
80
+ pg_catalog.pg_get_indexdef(x.indexrelid) AS definition,
81
+ pg_catalog.pg_relation_size(x.indexrelid) AS bytes,
82
+ x.indisprimary AS "primary", x.indisunique AS "unique", x.indisvalid AS valid,
83
+ EXISTS (SELECT 1 FROM pg_catalog.pg_constraint k WHERE k.conindid = x.indexrelid
84
+ AND k.conrelid = x.indrelid AND k.contype IN ('p', 'u', 'x')) AS "constraint",
85
+ coalesce(s.idx_scan, 0) AS scans,
86
+ array_to_json(ARRAY(SELECT o.relname FROM pg_catalog.pg_index y
87
+ JOIN pg_catalog.pg_class o ON o.oid = y.indexrelid
88
+ WHERE y.indrelid = x.indrelid AND y.indexrelid <> x.indexrelid
89
+ AND y.indkey::text = x.indkey::text AND y.indclass::text = x.indclass::text
90
+ AND y.indcollation::text = x.indcollation::text
91
+ AND o.relam = i.relam
92
+ AND coalesce(pg_catalog.pg_get_expr(y.indexprs, y.indrelid), '')
93
+ = coalesce(pg_catalog.pg_get_expr(x.indexprs, x.indrelid), '')
94
+ AND coalesce(pg_catalog.pg_get_expr(y.indpred, y.indrelid), '')
95
+ = coalesce(pg_catalog.pg_get_expr(x.indpred, x.indrelid), '')
96
+ ORDER BY o.relname))::text AS duplicate_of
97
+ FROM pg_catalog.pg_index x
98
+ JOIN pg_catalog.pg_class i ON i.oid = x.indexrelid
99
+ JOIN pg_catalog.pg_class t ON t.oid = x.indrelid
100
+ JOIN pg_catalog.pg_namespace n ON n.oid = t.relnamespace
101
+ LEFT JOIN pg_catalog.pg_stat_all_indexes s ON s.indexrelid = x.indexrelid
102
+ WHERE t.relkind IN ('r', 'p', 'm')
103
+ AND n.nspname !~ '^pg_' AND n.nspname <> 'information_schema'
104
+ AND ($1::text IS NULL OR x.indrelid = $1::regclass)
105
+ ORDER BY bytes DESC, n.nspname, t.relname, i.relname`, [table ? qualified(table) : null]);
106
+ return rows.map((row) => ({
107
+ schema: row.schema ?? '',
108
+ table: row.table ?? '',
109
+ name: row.name ?? '',
110
+ definition: row.definition ?? '',
111
+ bytes: Number(row.bytes),
112
+ primary: flag(row.primary),
113
+ unique: flag(row.unique),
114
+ constraint: flag(row.constraint),
115
+ valid: flag(row.valid),
116
+ scans: Number(row.scans),
117
+ duplicateOf: JSON.parse(row.duplicate_of ?? '[]'),
118
+ }));
119
+ }
120
+ /** The sizes and upkeep of every table and index of the database, biggest first. */
121
+ export async function databaseStats(session) {
122
+ const [row] = await session.rows(`SELECT current_database() AS database,
123
+ pg_catalog.pg_database_size(current_database()) AS bytes,
124
+ (SELECT ${utc('stats_reset')} FROM pg_catalog.pg_stat_database
125
+ WHERE datname = current_database()) AS reset`);
126
+ const tables = await listTableStats(session);
127
+ const indexes = await listIndexStats(session);
128
+ return {
129
+ database: row?.database ?? '',
130
+ databaseBytes: Number(row?.bytes ?? 0),
131
+ statsReset: row?.reset ?? null,
132
+ tables,
133
+ indexes,
134
+ };
135
+ }
@@ -0,0 +1,13 @@
1
+ /** Runs `work` with the session's statement timeout set to `ms`, and puts the old one back. */
2
+ export async function withStatementTimeout(session, ms, work) {
3
+ const [before] = await session.rows("SELECT current_setting('statement_timeout') AS timeout");
4
+ await session.rows("SELECT pg_catalog.set_config('statement_timeout', $1, false)", [String(ms)]);
5
+ try {
6
+ return await work();
7
+ }
8
+ finally {
9
+ await session.rows("SELECT pg_catalog.set_config('statement_timeout', $1, false)", [
10
+ before?.timeout ?? '0',
11
+ ]);
12
+ }
13
+ }
@@ -0,0 +1,52 @@
1
+ import { ResolveError } from './config/resolvers/types.js';
2
+ export function createPromptQueue() {
3
+ const queue = [];
4
+ const listeners = new Set();
5
+ const notify = () => {
6
+ for (const listener of listeners)
7
+ listener();
8
+ };
9
+ return {
10
+ ask(request) {
11
+ const { envName, field, masked, challenge, signal } = request;
12
+ return new Promise((resolve, reject) => {
13
+ const remove = () => {
14
+ const index = queue.indexOf(pending);
15
+ if (index >= 0)
16
+ queue.splice(index, 1);
17
+ signal?.removeEventListener('abort', cancel);
18
+ notify();
19
+ };
20
+ const cancel = () => {
21
+ remove();
22
+ reject(new ResolveError(envName, field, 'cancelled'));
23
+ };
24
+ const pending = {
25
+ envName,
26
+ field,
27
+ masked,
28
+ ...(challenge === undefined ? {} : { challenge }),
29
+ submit(value) {
30
+ remove();
31
+ resolve(value);
32
+ },
33
+ cancel,
34
+ };
35
+ if (signal?.aborted) {
36
+ reject(new ResolveError(envName, field, 'cancelled'));
37
+ return;
38
+ }
39
+ signal?.addEventListener('abort', cancel);
40
+ queue.push(pending);
41
+ notify();
42
+ });
43
+ },
44
+ current: () => queue[0],
45
+ subscribe(listener) {
46
+ listeners.add(listener);
47
+ return () => {
48
+ listeners.delete(listener);
49
+ };
50
+ },
51
+ };
52
+ }
@@ -0,0 +1,229 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { useEffect, useRef, useState } from 'react';
3
+ import { Box, Text, useInput } from 'ink';
4
+ import Spinner from 'ink-spinner';
5
+ import { describeProblem } from '../config/schema.js';
6
+ import { addressOf, credentialsReadable, listEnvironments, resolveEntry, shield, tierColor, } from '../environments.js';
7
+ import Header from './header.js';
8
+ import { clampTop, moveTo } from './scroll.js';
9
+ import { useTerminalSize } from './terminal.js';
10
+ // Rows the screen uses around the list: top padding, header (3), gap, column titles, the two "more" markers,
11
+ // gap + result line, gap, key help, and one spare so the frame never reaches the bottom row (Ink
12
+ // would then clear and redraw the whole screen on every update).
13
+ const chromeRows = 13;
14
+ const statusWidth = 10;
15
+ const tierWidth = 12;
16
+ const protectedWidth = 3;
17
+ const sourceWidth = 7;
18
+ const tlsWidth = 12;
19
+ const maxNameWidth = 28;
20
+ // Optional columns appear only when the row is wide enough to keep the address readable.
21
+ const tierMinWidth = 50;
22
+ const sourceMinWidth = 60;
23
+ const tlsMinWidth = 72;
24
+ const accessWidth = 11;
25
+ const accessMinWidth = 110;
26
+ const maxProblems = 5;
27
+ const describeFailure = (result) => result.detail && result.detail !== result.reason
28
+ ? `${result.reason} (${result.detail})`
29
+ : result.reason;
30
+ function Status({ entry, outcome }) {
31
+ if (outcome?.state === 'running') {
32
+ return (_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }));
33
+ }
34
+ const status = outcome?.state ?? entry.connection?.lastTest?.status;
35
+ if (status === 'ok')
36
+ return _jsx(Text, { color: "green", children: "\u2713" });
37
+ if (status === 'failed')
38
+ return _jsx(Text, { color: "red", children: "\u2717" });
39
+ return _jsx(Text, { dimColor: true, children: "untested" });
40
+ }
41
+ function WebNote({ url }) {
42
+ if (!url)
43
+ return null;
44
+ return (_jsx(Box, { marginTop: 1, paddingX: 1, children: _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["Web UI is running on ", _jsx(Text, { color: "cyan", children: url })] }) }));
45
+ }
46
+ function Banner({ services }) {
47
+ const error = services.configError;
48
+ if (!error)
49
+ return null;
50
+ const shown = error.problems.slice(0, maxProblems);
51
+ const more = error.problems.length - shown.length;
52
+ return (_jsxs(Box, { flexDirection: "column", marginTop: 1, paddingX: 1, children: [_jsxs(Text, { color: "red", wrap: "truncate-middle", children: ["\u2717 Invalid config ", error.file] }), shown.map((problem) => (_jsxs(Text, { color: "red", wrap: "truncate-end", children: [' ', describeProblem(problem)] }, describeProblem(problem)))), more > 0 ? _jsx(Text, { color: "red", children: ` … and ${more} more` }) : null] }));
53
+ }
54
+ const readEntries = (services) => {
55
+ try {
56
+ return { entries: listEnvironments(services) };
57
+ }
58
+ catch (error) {
59
+ return { error: error instanceof Error ? error.message : String(error) };
60
+ }
61
+ };
62
+ export default function Environments({ version, navigate, params, services, isInputActive, }) {
63
+ const { columns, rows } = useTerminalSize();
64
+ const [loaded, setLoaded] = useState(() => readEntries(services));
65
+ const entries = 'entries' in loaded ? loaded.entries : [];
66
+ const [position, setPosition] = useState(() => ({
67
+ selected: Math.max(0, entries.findIndex((entry) => entry.key === params?.selectedKey)),
68
+ top: 0,
69
+ }));
70
+ const { selected } = position;
71
+ const [outcomes, setOutcomes] = useState(new Map());
72
+ const running = useRef(new Set());
73
+ // Shared environments whose Vault secrets this user may not read.
74
+ const [locked, setLocked] = useState(new Set());
75
+ // A local connection waiting to be confirmed for deletion, and why something cannot be done.
76
+ const [deleting, setDeleting] = useState();
77
+ const [note, setNote] = useState();
78
+ useEffect(() => {
79
+ const controller = new AbortController();
80
+ for (const entry of entries) {
81
+ if (!entry.config)
82
+ continue;
83
+ void credentialsReadable(entry, services, controller.signal).then((readable) => {
84
+ if (!readable && !controller.signal.aborted) {
85
+ setLocked((previous) => new Set(previous).add(entry.key));
86
+ }
87
+ });
88
+ }
89
+ return () => {
90
+ controller.abort();
91
+ };
92
+ // Looked at once, when the screen opens.
93
+ // eslint-disable-next-line react-hooks/exhaustive-deps
94
+ }, []);
95
+ const bannerRows = services.configError
96
+ ? 2 +
97
+ Math.min(services.configError.problems.length, maxProblems) +
98
+ (services.configError.problems.length > maxProblems ? 1 : 0)
99
+ : 0;
100
+ const visible = Math.max(1, rows - chromeRows - bannerRows);
101
+ const first = clampTop(position.top, selected, visible, entries.length);
102
+ const setOutcome = (key, outcome) => {
103
+ setOutcomes((previous) => new Map(previous).set(key, outcome));
104
+ };
105
+ const test = async (entry) => {
106
+ if (running.current.has(entry.key))
107
+ return;
108
+ running.current.add(entry.key);
109
+ setOutcome(entry.key, { state: 'running' });
110
+ let outcome;
111
+ try {
112
+ const resolved = await resolveEntry(entry, services);
113
+ const result = await services.tester(resolved);
114
+ outcome = result.ok
115
+ ? { state: 'ok', message: `Connected · PostgreSQL ${result.serverVersion}` }
116
+ : { state: 'failed', message: describeFailure(result) };
117
+ if (entry.connection) {
118
+ services.store.recordTest(entry.connection.id, {
119
+ status: result.ok ? 'ok' : 'failed',
120
+ message: result.ok ? `PostgreSQL ${result.serverVersion}` : result.reason,
121
+ });
122
+ }
123
+ }
124
+ catch (error) {
125
+ outcome = { state: 'failed', message: error instanceof Error ? error.message : String(error) };
126
+ }
127
+ finally {
128
+ running.current.delete(entry.key);
129
+ }
130
+ setOutcome(entry.key, outcome);
131
+ };
132
+ // Shared environments come from the config (a directory or Vault), which is managed outside the
133
+ // app: only local connections can be changed here.
134
+ const fromConfig = (entry, what) => {
135
+ setNote(`${entry.name} is managed in the shared config: ${what} it there${services.config ? ` (${services.config.base})` : ''}`);
136
+ };
137
+ const deleteConnection = (entry) => {
138
+ if (!entry.connection)
139
+ return;
140
+ services.store.delete(entry.connection.id);
141
+ const next = readEntries(services);
142
+ setLoaded(next);
143
+ const remaining = 'entries' in next ? next.entries.length : 0;
144
+ setPosition((previous) => moveTo(previous, (at) => at, visible, remaining));
145
+ setOutcomes((previous) => {
146
+ const rest = new Map(previous);
147
+ rest.delete(entry.key);
148
+ return rest;
149
+ });
150
+ setNote(`Deleted ${entry.name}`);
151
+ };
152
+ useInput((input, key) => {
153
+ if (deleting) {
154
+ const entry = deleting;
155
+ setDeleting(undefined);
156
+ if (input === 'y')
157
+ deleteConnection(entry);
158
+ return;
159
+ }
160
+ setNote(undefined);
161
+ if (input === 'n') {
162
+ navigate('newConnection');
163
+ return;
164
+ }
165
+ const entry = entries[selected];
166
+ if (!entry)
167
+ return;
168
+ if (key.return) {
169
+ navigate('explorer', { envKey: entry.key });
170
+ return;
171
+ }
172
+ if (input === 't') {
173
+ void test(entry);
174
+ return;
175
+ }
176
+ if (input === 'e') {
177
+ if (entry.connection)
178
+ navigate('newConnection', { connectionId: entry.connection.id });
179
+ else
180
+ fromConfig(entry, 'change');
181
+ return;
182
+ }
183
+ if (input === 'd') {
184
+ if (entry.connection)
185
+ setDeleting(entry);
186
+ else
187
+ fromConfig(entry, 'remove');
188
+ return;
189
+ }
190
+ const move = key.upArrow ? -1 : key.downArrow ? 1 : 0;
191
+ if (move === 0)
192
+ return;
193
+ setPosition((previous) => moveTo(previous, (at) => at + move, visible, entries.length));
194
+ }, { isActive: isInputActive });
195
+ const width = Math.max(20, columns - 4);
196
+ // The list sits inside paddingX={1}, so rows get two columns less than the header.
197
+ const rowWidth = width - 2;
198
+ const showTier = rowWidth >= tierMinWidth;
199
+ const showSource = rowWidth >= sourceMinWidth;
200
+ const showTls = rowWidth >= tlsMinWidth;
201
+ const showAccess = rowWidth >= accessMinWidth;
202
+ // What the name and address share: the row minus marker (2), the gap after the name (2), the
203
+ // badge, the status and any optional columns. The name takes at most half, so the address
204
+ // always keeps some room.
205
+ const textWidth = rowWidth -
206
+ 2 -
207
+ 2 -
208
+ (showTier ? tierWidth : 0) -
209
+ protectedWidth -
210
+ statusWidth -
211
+ (showSource ? sourceWidth : 0) -
212
+ (showTls ? tlsWidth : 0) -
213
+ (showAccess ? accessWidth : 0);
214
+ const nameWidth = Math.min(maxNameWidth, Math.max(4, Math.floor(textWidth / 2)), Math.max(4, ...entries.map((entry) => entry.name.length)));
215
+ const hints = [
216
+ ...(entries.length > 0 ? ['↑↓ move', 'Enter explore', 'e edit', 'd delete', 't test'] : []),
217
+ 'n new',
218
+ 'q quit',
219
+ ];
220
+ const shown = entries.slice(first, first + visible);
221
+ const above = first;
222
+ const below = entries.length - first - shown.length;
223
+ const current = entries[selected];
224
+ const currentOutcome = current ? outcomes.get(current.key) : undefined;
225
+ return (_jsxs(Box, { flexDirection: "column", paddingX: 2, paddingTop: 1, flexGrow: 1, children: [_jsx(Header, { version: version, title: "Environments", width: width }), _jsx(Banner, { services: services }), _jsx(WebNote, { url: services.webUrl }), _jsx(Box, { flexDirection: "column", marginTop: 1, paddingX: 1, children: 'error' in loaded ? (_jsxs(Text, { color: "red", children: ["\u2717 Could not read connections: ", loaded.error] })) : entries.length === 0 ? (_jsxs(Text, { children: ["No environments yet. Press ", _jsx(Text, { color: "cyan", children: "n" }), " to create one."] })) : (_jsxs(_Fragment, { children: [_jsxs(Box, { width: rowWidth, children: [_jsx(Box, { width: 2, flexShrink: 0 }), _jsx(Box, { width: nameWidth, marginRight: 2, flexShrink: 0, children: _jsx(Text, { bold: true, dimColor: true, children: "NAME" }) }), showTier ? (_jsx(Box, { width: tierWidth, flexShrink: 0, children: _jsx(Text, { bold: true, dimColor: true, children: "TIER" }) })) : null, _jsx(Box, { width: protectedWidth, flexShrink: 0 }), showSource ? (_jsx(Box, { width: sourceWidth, flexShrink: 0, children: _jsx(Text, { bold: true, dimColor: true, children: "SOURCE" }) })) : null, _jsx(Box, { flexGrow: 1, flexShrink: 1, flexBasis: 0, minWidth: 0, children: _jsx(Text, { bold: true, dimColor: true, wrap: "truncate-end", children: "ADDRESS" }) }), showAccess ? (_jsx(Box, { width: accessWidth, flexShrink: 0, children: _jsx(Text, { bold: true, dimColor: true, children: "ACCESS" }) })) : null, showTls ? (_jsx(Box, { width: tlsWidth, flexShrink: 0, children: _jsx(Text, { bold: true, dimColor: true, children: "TLS" }) })) : null, _jsx(Box, { width: statusWidth, flexShrink: 0, justifyContent: "flex-end", children: _jsx(Text, { bold: true, dimColor: true, children: "CONNECTION" }) })] }), _jsx(Text, { dimColor: true, children: above > 0 ? ` ↑ ${above} more` : ' ' }), shown.map((entry, index) => {
226
+ const isSelected = first + index === selected;
227
+ return (_jsxs(Box, { width: rowWidth, children: [_jsx(Box, { width: 2, flexShrink: 0, children: _jsx(Text, { color: "cyan", children: isSelected ? '›' : ' ' }) }), _jsx(Box, { width: nameWidth, marginRight: 2, flexShrink: 0, children: _jsx(Text, { bold: isSelected, color: locked.has(entry.key) ? 'red' : isSelected ? 'cyan' : undefined, wrap: "truncate-end", children: entry.name }) }), showTier ? (_jsx(Box, { width: tierWidth, flexShrink: 0, children: _jsx(Text, { color: tierColor[entry.tier], children: entry.tier }) })) : null, _jsx(Box, { width: protectedWidth, flexShrink: 0, children: entry.protected ? _jsx(Text, { color: "red", children: shield }) : null }), showSource ? (_jsx(Box, { width: sourceWidth, flexShrink: 0, children: _jsx(Text, { dimColor: true, children: entry.source }) })) : null, _jsx(Box, { flexGrow: 1, flexShrink: 1, flexBasis: 0, minWidth: 0, children: _jsx(Text, { dimColor: !isSelected, wrap: "truncate-end", children: addressOf(entry) }) }), locked.has(entry.key) ? (_jsx(Box, { flexShrink: 0, marginRight: 2, children: _jsx(Text, { color: "red", children: "credentials can't be retrieved" }) })) : null, showAccess ? (_jsx(Box, { width: accessWidth, flexShrink: 0, children: entry.readOnly ? (_jsx(Text, { color: "yellow", children: "read only" })) : (_jsx(Text, { dimColor: true, children: "read write" })) })) : null, showTls ? (_jsx(Box, { width: tlsWidth, flexShrink: 0, children: _jsx(Text, { dimColor: true, children: entry.tlsMode }) })) : null, _jsx(Box, { width: statusWidth, flexShrink: 0, justifyContent: "flex-end", children: _jsx(Status, { entry: entry, outcome: outcomes.get(entry.key) }) })] }, entry.key));
228
+ }), _jsx(Text, { dimColor: true, children: below > 0 ? ` ↓ ${below} more` : ' ' })] })) }), _jsx(Box, { marginTop: 1, paddingX: 1, width: width, children: deleting ? (_jsxs(Text, { color: "red", wrap: "truncate-end", children: ["Delete ", deleting.name, "? y to delete \u00B7 any other key cancels"] })) : note ? (_jsx(Text, { color: "yellow", wrap: "truncate-end", children: note })) : currentOutcome?.state === 'running' ? (_jsxs(Text, { dimColor: true, children: ["Testing ", current?.name, "\u2026"] })) : currentOutcome ? (_jsxs(Text, { color: currentOutcome.state === 'ok' ? 'green' : 'red', wrap: "truncate-end", children: [currentOutcome.state === 'ok' ? '✓' : '✗', " ", currentOutcome.message] })) : (_jsx(Text, { children: " " })) }), _jsx(Box, { flexGrow: 1 }), _jsx(Box, { marginTop: 1, paddingX: 1, width: width, flexWrap: "wrap", children: hints.map((hint, index) => (_jsxs(Text, { color: "gray", wrap: "truncate-end", children: [hint, index < hints.length - 1 ? ' · ' : ''] }, hint))) })] }));
229
+ }