@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,121 @@
1
+ // Filters on the rows of a table, as the row grid of the web page sends them.
2
+ import { quoteIdentifier } from '../db/identifiers.js';
3
+ import { escapeLike } from './search.js';
4
+ export const FILTER_OPS = [
5
+ 'eq',
6
+ 'ne',
7
+ 'contains',
8
+ 'gt',
9
+ 'gte',
10
+ 'lt',
11
+ 'lte',
12
+ 'null',
13
+ 'notnull',
14
+ 'in',
15
+ 'between',
16
+ ];
17
+ export const MAX_IN_VALUES = 100;
18
+ export class FilterError extends Error {
19
+ }
20
+ const quoteLiteral = (text) => `'${text.replaceAll("'", "''")}'`;
21
+ /** The values of an `in` list: commas separate them, a backslash escapes a comma or a backslash. */
22
+ export function splitList(text) {
23
+ const values = [];
24
+ let current = '';
25
+ for (let i = 0; i < text.length; i++) {
26
+ const char = text[i];
27
+ if (char === '\\' && i + 1 < text.length)
28
+ current += text[++i];
29
+ else if (char === ',') {
30
+ values.push(current.trim());
31
+ current = '';
32
+ }
33
+ else
34
+ current += char;
35
+ }
36
+ values.push(current.trim());
37
+ return values.filter((value) => value !== '');
38
+ }
39
+ const comparisons = {
40
+ eq: '=',
41
+ ne: '<>',
42
+ gt: '>',
43
+ gte: '>=',
44
+ lt: '<',
45
+ lte: '<=',
46
+ };
47
+ /**
48
+ * The WHERE clause for the filters. Columns must be ones of the table, and values are bound as
49
+ * parameters (or written as literals when `inline` is set, for showing the statement); a value is
50
+ * compared with the column's own type, so Postgres reads `2024-01-01` as a date for a date column.
51
+ */
52
+ export function buildWhere(columns, filters, { inline = false } = {}) {
53
+ const params = [];
54
+ const bind = (value) => {
55
+ if (inline)
56
+ return quoteLiteral(value);
57
+ params.push(value);
58
+ return `$${params.length}`;
59
+ };
60
+ const known = new Set(columns.map((column) => column.name));
61
+ const conditions = filters.map((filter) => {
62
+ if (!known.has(filter.column))
63
+ throw new FilterError(`no column "${filter.column}"`);
64
+ if (!FILTER_OPS.includes(filter.op))
65
+ throw new FilterError(`unknown operator "${filter.op}"`);
66
+ const name = quoteIdentifier(filter.column);
67
+ const { op } = filter;
68
+ if (op === 'null')
69
+ return `${name} IS NULL`;
70
+ if (op === 'notnull')
71
+ return `${name} IS NOT NULL`;
72
+ const value = filter.value ?? '';
73
+ if (op === 'in') {
74
+ const values = splitList(value);
75
+ if (values.length === 0)
76
+ throw new FilterError(`"${filter.column}" in needs a value`);
77
+ if (values.length > MAX_IN_VALUES) {
78
+ throw new FilterError(`in takes at most ${MAX_IN_VALUES} values`);
79
+ }
80
+ return `${name} IN (${values.map(bind).join(', ')})`;
81
+ }
82
+ if (op === 'between') {
83
+ if (value === '' || (filter.value2 ?? '') === '') {
84
+ throw new FilterError(`"${filter.column}" between needs two values`);
85
+ }
86
+ const low = bind(value);
87
+ return `${name} BETWEEN ${low} AND ${bind(filter.value2)}`;
88
+ }
89
+ if (op === 'contains')
90
+ return `${name}::text ILIKE ${bind(`%${escapeLike(value)}%`)}`;
91
+ return `${name} ${comparisons[op]} ${bind(value)}`;
92
+ });
93
+ return {
94
+ sql: conditions.length > 0 ? ` WHERE ${conditions.join(' AND ')}` : '',
95
+ params,
96
+ };
97
+ }
98
+ /** Reads the filters of a request, which are untrusted, as `Filter`s. */
99
+ export function parseFilters(raw) {
100
+ if (raw === undefined || raw === null)
101
+ return [];
102
+ if (!Array.isArray(raw))
103
+ throw new FilterError('filters must be a list');
104
+ if (raw.length > 50)
105
+ throw new FilterError('too many filters');
106
+ return raw.map((item) => {
107
+ const entry = item;
108
+ if (typeof entry?.column !== 'string' || typeof entry.op !== 'string') {
109
+ throw new FilterError('a filter is {column, op, value}');
110
+ }
111
+ const text = (field) => (typeof field === 'string' ? field : undefined);
112
+ const value = text(entry.value);
113
+ const value2 = text(entry.value2);
114
+ return {
115
+ column: entry.column,
116
+ op: entry.op,
117
+ ...(value === undefined ? {} : { value }),
118
+ ...(value2 === undefined ? {} : { value2 }),
119
+ };
120
+ });
121
+ }
@@ -0,0 +1,134 @@
1
+ // How results are written out: on one line in a grid cell, as an aligned table like psql's, as CSV
2
+ // and as JSON. Values arrive as the text Postgres sent (see Session).
3
+ import stringWidth from 'string-width';
4
+ export const outputFormats = ['table', 'csv', 'json'];
5
+ export const nullText = 'NULL';
6
+ /** A value on one line: line breaks and tabs become visible marks, so a row stays one row. */
7
+ export const cellText = (value) => value === null
8
+ ? nullText
9
+ : value
10
+ .replaceAll('\r\n', '↵')
11
+ .replace(/[\n\r]/g, '↵')
12
+ .replaceAll('\t', ' ');
13
+ /** Text cut to `width` columns, ending in … when it was longer. */
14
+ export function truncate(text, width) {
15
+ if (width <= 0)
16
+ return '';
17
+ if (stringWidth(text) <= width)
18
+ return text;
19
+ let out = '';
20
+ for (const char of text) {
21
+ if (stringWidth(out + char) > width - 1)
22
+ break;
23
+ out += char;
24
+ }
25
+ return `${out}…`;
26
+ }
27
+ export const pad = (text, width, right = false) => {
28
+ const gap = ' '.repeat(Math.max(0, width - stringWidth(text)));
29
+ return right ? gap + text : text + gap;
30
+ };
31
+ const numericTypes = new Set([
32
+ 'smallint',
33
+ 'integer',
34
+ 'bigint',
35
+ 'numeric',
36
+ 'real',
37
+ 'double precision',
38
+ 'oid',
39
+ 'money',
40
+ ]);
41
+ /** Whether a column holds numbers, which read best aligned to the right. */
42
+ export const isNumeric = (column) => numericTypes.has(column.type) || column.type.startsWith('numeric');
43
+ export const widthOf = stringWidth;
44
+ /** An aligned table with a header row and a "(n rows)" footer, like psql prints. */
45
+ export function textTable(result, { maxColumnWidth = 60 } = {}) {
46
+ const { columns, rows } = result;
47
+ const texts = rows.map((row) => row.map((value) => truncate(cellText(value), maxColumnWidth)));
48
+ const widths = columns.map((column, index) => Math.max(stringWidth(truncate(column.name, maxColumnWidth)), ...texts.map((row) => stringWidth(row[index] ?? ''))));
49
+ const line = (cells, right) => cells
50
+ .map((cell, index) => pad(cell, widths[index] ?? 0, right(index)))
51
+ .join(' | ')
52
+ .trimEnd();
53
+ const header = line(columns.map((column) => truncate(column.name, maxColumnWidth)), () => false);
54
+ const rule = widths.map((width) => '-'.repeat(width)).join('-+-');
55
+ const body = texts.map((row) => line(row, (index) => isNumeric(columns[index])));
56
+ const count = `(${rows.length} ${rows.length === 1 ? 'row' : 'rows'})`;
57
+ return [header, rule, ...body, count].join('\n') + '\n';
58
+ }
59
+ const csvField = (value) => /[",\r\n]/.test(value) ? `"${value.replaceAll('"', '""')}"` : value;
60
+ /** RFC 4180 CSV with a header row; NULL is an empty field. */
61
+ export function csv(result) {
62
+ const lines = [
63
+ result.columns.map((column) => csvField(column.name)).join(','),
64
+ ...result.rows.map((row) => row.map((value) => (value === null ? '' : csvField(value))).join(',')),
65
+ ];
66
+ return lines.map((line) => `${line}\r\n`).join('');
67
+ }
68
+ const jsonNumberTypes = new Set(['smallint', 'integer', 'real', 'double precision', 'oid']);
69
+ /**
70
+ * A value as JSON: numbers that fit a JS number, booleans and json columns as themselves, and
71
+ * everything else (bigint and numeric included, to keep every digit) as its text.
72
+ */
73
+ export function jsonValue(value, column) {
74
+ if (value === null)
75
+ return null;
76
+ if (jsonNumberTypes.has(column.type)) {
77
+ const number = Number(value);
78
+ return Number.isFinite(number) ? number : value;
79
+ }
80
+ if (column.type === 'boolean')
81
+ return value === 't';
82
+ if (column.type === 'json' || column.type === 'jsonb') {
83
+ try {
84
+ return JSON.parse(value);
85
+ }
86
+ catch {
87
+ return value;
88
+ }
89
+ }
90
+ return value;
91
+ }
92
+ /** The rows as an array of objects keyed by column name (a repeated name keeps its last value). */
93
+ export function jsonRows(result) {
94
+ return result.rows.map((row) => Object.fromEntries(result.columns.map((column, index) => [column.name, jsonValue(row[index] ?? null, column)])));
95
+ }
96
+ export function formatResult(result, format) {
97
+ if (format === 'csv')
98
+ return csv(result);
99
+ if (format === 'json')
100
+ return `${JSON.stringify(jsonRows(result), null, 2)}\n`;
101
+ return textTable(result);
102
+ }
103
+ /** "SELECT 3", "UPDATE 1": what a statement did, in psql's words. */
104
+ export function commandSummary(result) {
105
+ if (result.truncated)
106
+ return `${result.command} (first ${result.rows.length} rows)`;
107
+ return result.rowCount === null ? result.command : `${result.command} ${result.rowCount}`;
108
+ }
109
+ const units = ['B', 'kB', 'MB', 'GB', 'TB', 'PB'];
110
+ /** 1536 → "1.5 kB", as pg_size_pretty but with one decimal. */
111
+ export function formatBytes(bytes) {
112
+ if (bytes === null)
113
+ return '';
114
+ let value = bytes;
115
+ let unit = 0;
116
+ while (value >= 1024 && unit < units.length - 1) {
117
+ value /= 1024;
118
+ unit++;
119
+ }
120
+ return unit === 0 ? `${value} B` : `${value.toFixed(1)} ${units[unit]}`;
121
+ }
122
+ /** 1234567 → "1.2M": a row estimate is only ever approximate. */
123
+ export function formatEstimate(rows) {
124
+ if (rows === null)
125
+ return '';
126
+ if (rows < 1000)
127
+ return `~${rows}`;
128
+ if (rows < 1_000_000)
129
+ return `~${(rows / 1000).toFixed(1)}k`;
130
+ if (rows < 1_000_000_000)
131
+ return `~${(rows / 1_000_000).toFixed(1)}M`;
132
+ return `~${(rows / 1_000_000_000).toFixed(1)}B`;
133
+ }
134
+ export const formatDuration = (ms) => ms < 1000 ? `${Math.round(ms)} ms` : `${(ms / 1000).toFixed(2)} s`;