@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,679 @@
1
+ // A graph of rows: some rows of a table, and the rows linked to them through foreign keys.
2
+ import { createHash } from 'node:crypto';
3
+ import { quoteIdentifier } from '../db/identifiers.js';
4
+ import { listColumns, qualified, tableLabel } from './catalog.js';
5
+ import { primaryKeyOf } from './edit.js';
6
+ import { buildWhere } from './filter.js';
7
+ import { DEFAULT_COUNT_CAP, foreignKeyId, listForeignKeys, } from './references.js';
8
+ import { withStatementTimeout } from './timeout.js';
9
+ export class GraphError extends Error {
10
+ }
11
+ export const DEFAULT_GRAPH_LIMITS = {
12
+ maxStart: 50,
13
+ maxNodes: 300,
14
+ perKey: 20,
15
+ timeoutMs: 5000,
16
+ countCap: DEFAULT_COUNT_CAP,
17
+ };
18
+ export const MAX_GRAPH_DEPTH = 3;
19
+ /** The id of a row: its schema, table and primary key values (or a hash of the row without one). */
20
+ export function nodeId(table, key) {
21
+ return JSON.stringify([table.schema, table.name, key]);
22
+ }
23
+ const isNodeId = (id) => id.startsWith('[');
24
+ /** The table and primary key values an id was made of; null when it is not one for a primary key. */
25
+ export function parseNodeId(id) {
26
+ try {
27
+ const [schema, name, key] = JSON.parse(id);
28
+ if (typeof schema !== 'string' || typeof name !== 'string')
29
+ return null;
30
+ if (!Array.isArray(key) || key.some((value) => typeof value !== 'string'))
31
+ return null;
32
+ return { table: { schema, name }, key: key };
33
+ }
34
+ catch {
35
+ return null;
36
+ }
37
+ }
38
+ /** The label of a row unless another is picked: its key's values, joined when the key is composite. */
39
+ export function keyLabel(key) {
40
+ const label = Object.values(key)
41
+ .map((value) => value ?? 'NULL')
42
+ .join(', ');
43
+ return label.length > 60 ? `${label.slice(0, 59)}…` : label;
44
+ }
45
+ const tupleKey = (values) => JSON.stringify(values);
46
+ const chunksOf = (items, size) => Array.from({ length: Math.ceil(items.length / size) }, (_, i) => items.slice(i * size, (i + 1) * size));
47
+ // Tuples are looked up this many at a time, which keeps the parameters of a statement few.
48
+ const BATCH = 200;
49
+ const edgeId = (key, from, to) => `edge:${tableLabel(key.from)}.${key.name}|${from}|${to}`;
50
+ // Hidden tables that a path may go through.
51
+ const MAX_HIDDEN = 3;
52
+ const stepEnd = (step) => (step.forward ? step.key.to : step.key.from);
53
+ const stepStart = (step) => (step.forward ? step.key.fromColumns : step.key.toColumns);
54
+ const stepTarget = (step) => (step.forward ? step.key.toColumns : step.key.fromColumns);
55
+ class Walk {
56
+ session;
57
+ limits;
58
+ direction;
59
+ nodes = new Map();
60
+ edges = new Map();
61
+ more = [];
62
+ truncated = false;
63
+ seen;
64
+ hidden;
65
+ skipped;
66
+ seenNodes;
67
+ columns = new Map();
68
+ keys = new Map();
69
+ constructor(session, limits, drawn, direction, hide = [], skip = []) {
70
+ this.session = session;
71
+ this.limits = limits;
72
+ this.direction = direction;
73
+ this.skipped = new Set(skip);
74
+ this.hidden = new Set(hide.map(tableLabel));
75
+ this.seen = new Set(drawn);
76
+ this.seenNodes = drawn.filter(isNodeId).length;
77
+ }
78
+ columnsOf(table) {
79
+ const label = tableLabel(table);
80
+ let found = this.columns.get(label);
81
+ if (!found)
82
+ this.columns.set(label, (found = listColumns(this.session, table)));
83
+ return found;
84
+ }
85
+ foreignKeysOf(table) {
86
+ const label = tableLabel(table);
87
+ let found = this.keys.get(label);
88
+ if (!found) {
89
+ // Keys that are skipped are not there as far as the walk can tell.
90
+ found = listForeignKeys(this.session, table).then(({ outgoing, incoming }) => ({
91
+ outgoing: outgoing.filter((key) => !this.skipped.has(foreignKeyId(key))),
92
+ incoming: incoming.filter((key) => !this.skipped.has(foreignKeyId(key))),
93
+ }));
94
+ this.keys.set(label, found);
95
+ }
96
+ return found;
97
+ }
98
+ get full() {
99
+ return this.seenNodes + this.nodes.size >= this.limits.maxNodes;
100
+ }
101
+ /** The node of a row. `fresh` is set when it is new to the graph; null when there is no room. */
102
+ async nodeFor(table, row, reachedBy, distance, start = false) {
103
+ const columns = await this.columnsOf(table);
104
+ const primary = primaryKeyOf(columns);
105
+ const key = {};
106
+ let id;
107
+ if (primary.length > 0) {
108
+ for (const name of primary)
109
+ key[name] = row[name] ?? null;
110
+ id = nodeId(table, primary.map((name) => row[name] ?? ''));
111
+ }
112
+ else {
113
+ // Rows cannot be told apart by key, so the whole row is part of the id.
114
+ for (const name of reachedBy)
115
+ key[name] = row[name] ?? null;
116
+ const hash = createHash('sha1').update(JSON.stringify(row)).digest('hex').slice(0, 12);
117
+ id = nodeId(table, `~${hash}`);
118
+ }
119
+ const known = this.nodes.get(id);
120
+ if (known)
121
+ return { node: known, id, fresh: false };
122
+ if (this.seen.has(id))
123
+ return { node: null, id, fresh: false };
124
+ if (this.full) {
125
+ this.truncated = true;
126
+ return { node: null, id, fresh: false };
127
+ }
128
+ const node = {
129
+ id,
130
+ table,
131
+ key,
132
+ label: keyLabel(key),
133
+ row,
134
+ distance,
135
+ start,
136
+ expandable: primary.length > 0,
137
+ };
138
+ this.nodes.set(id, node);
139
+ return { node, id, fresh: true };
140
+ }
141
+ addEdge(key, from, to) {
142
+ const id = edgeId(key, from, to);
143
+ if (this.seen.has(id) || this.edges.has(id))
144
+ return;
145
+ this.edges.set(id, {
146
+ id,
147
+ from,
148
+ to,
149
+ constraint: key.name,
150
+ fromColumns: key.fromColumns,
151
+ toColumns: key.toColumns,
152
+ });
153
+ }
154
+ /** A link between the rows at the two ends of a path through hidden tables; it has no direction. */
155
+ addViaEdge(via, a, b) {
156
+ const [from, to] = a <= b ? [a, b] : [b, a];
157
+ const name = via.map(tableLabel).join(' › ');
158
+ const id = `edge:via ${name}|${from}|${to}`;
159
+ if (this.seen.has(id) || this.edges.has(id))
160
+ return;
161
+ this.edges.set(id, {
162
+ id,
163
+ from,
164
+ to,
165
+ constraint: `via ${name}`,
166
+ fromColumns: [],
167
+ toColumns: [],
168
+ via,
169
+ });
170
+ }
171
+ isHidden(table) {
172
+ return this.hidden.has(tableLabel(table));
173
+ }
174
+ paths = new Map();
175
+ /**
176
+ * The shortest ways from a table to each table that is drawn, through one or more hidden tables, found
177
+ * by walking the foreign keys (which are only metadata). Tables that a key leads to directly are not
178
+ * reached this way. Keys that tie for the shortest way to a table are all kept.
179
+ */
180
+ pathsFrom(origin) {
181
+ const label = tableLabel(origin);
182
+ let found = this.paths.get(label);
183
+ if (!found)
184
+ this.paths.set(label, (found = this.findPaths(origin)));
185
+ return found;
186
+ }
187
+ async findPaths(origin) {
188
+ const out = [];
189
+ const shortest = new Map();
190
+ // Tables reached by one key are not also reached the long way.
191
+ const direct = new Set();
192
+ let level = [
193
+ { table: origin, steps: [], via: [] },
194
+ ];
195
+ for (let depth = 0; depth <= MAX_HIDDEN && level.length > 0; depth++) {
196
+ const next = [];
197
+ for (const { table, steps, via } of level) {
198
+ const { outgoing, incoming } = await this.foreignKeysOf(table);
199
+ const last = steps[steps.length - 1];
200
+ const options = [
201
+ ...outgoing.map((key) => ({ key, forward: true })),
202
+ ...incoming.map((key) => ({ key, forward: false })),
203
+ ];
204
+ for (const step of options) {
205
+ // The key just used leads back to where it came from.
206
+ if (last && foreignKeyId(last.key) === foreignKeyId(step.key))
207
+ continue;
208
+ const to = stepEnd(step);
209
+ if (this.isHidden(to)) {
210
+ if (via.some((t) => tableLabel(t) === tableLabel(to)) ||
211
+ tableLabel(to) === tableLabel(origin)) {
212
+ continue;
213
+ }
214
+ next.push({ table: to, steps: [...steps, step], via: [...via, to] });
215
+ continue;
216
+ }
217
+ const end = tableLabel(to);
218
+ if (steps.length === 0) {
219
+ direct.add(end);
220
+ continue;
221
+ }
222
+ const length = steps.length + 1;
223
+ if (direct.has(end) || (shortest.has(end) && shortest.get(end) < length))
224
+ continue;
225
+ shortest.set(end, length);
226
+ out.push({ steps: [...steps, step], via, end: to });
227
+ }
228
+ }
229
+ level = next;
230
+ }
231
+ return out.filter((path) => !direct.has(tableLabel(path.end)));
232
+ }
233
+ /**
234
+ * The rows at the end of a path from each node, linked to it. One statement joins the tables in
235
+ * between and gives the distinct pairs of key values at the two ends; the rows at the end are then
236
+ * fetched as they are for a direct key. At most `perKey` are linked to a node.
237
+ */
238
+ async through(path, nodes) {
239
+ const [first, last] = [path.steps[0], path.steps[path.steps.length - 1]];
240
+ const originColumns = stepStart(first);
241
+ const wanted = new Map();
242
+ for (const node of nodes) {
243
+ const values = originColumns.map((name) => node.row[name] ?? null);
244
+ if (values.some((value) => value === null))
245
+ continue;
246
+ const tuple = tupleKey(values);
247
+ const entry = wanted.get(tuple) ?? { values, nodes: [] };
248
+ entry.nodes.push(node);
249
+ wanted.set(tuple, entry);
250
+ }
251
+ // t1 .. t(n-1) are the hidden tables, joined along the keys in between. When the last key is
252
+ // followed against its direction, many rows of the end table share its values, so the end table is
253
+ // joined as well and its rows are told apart by their primary keys.
254
+ const endPrimary = primaryKeyOf(await this.columnsOf(path.end));
255
+ const joinEnd = !last.forward && endPrimary.length > 0;
256
+ const alias = (i) => `t${i}`;
257
+ const columns = (i, names) => names.map((name) => `${alias(i)}.${quoteIdentifier(name)}`);
258
+ const chain = [...path.steps.slice(0, -1).map(stepEnd), ...(joinEnd ? [path.end] : [])];
259
+ let from = `${qualified(chain[0])} ${alias(1)}`;
260
+ for (let i = 1; i < chain.length; i++) {
261
+ const step = path.steps[i];
262
+ const [here, there] = step.forward
263
+ ? [step.key.fromColumns, step.key.toColumns]
264
+ : [step.key.toColumns, step.key.fromColumns];
265
+ const on = columns(i, here).map((c, k) => `${c} = ${columns(i + 1, there)[k]}`);
266
+ from += ` JOIN ${qualified(chain[i])} ${alias(i + 1)} ON ${on.join(' AND ')}`;
267
+ }
268
+ const lookupColumns = columns(1, stepTarget(first));
269
+ const endSource = joinEnd
270
+ ? columns(chain.length, endPrimary)
271
+ : columns(chain.length, stepStart(last));
272
+ const endColumns = joinEnd ? endPrimary : stepTarget(last);
273
+ const originAlias = lookupColumns.map((c, i) => `${c} AS "__o${i}"`);
274
+ const endAlias = endSource.map((c, i) => `${c} AS "__e${i}"`);
275
+ const partition = lookupColumns.map((_, i) => `"__o${i}"`).join(', ');
276
+ const order = endSource.map((_, i) => `"__e${i}"`).join(', ');
277
+ const created = [];
278
+ for (const batch of chunksOf([...wanted.values()], BATCH)) {
279
+ const params = [];
280
+ const marks = batch.map((entry) => {
281
+ const group = entry.values.map((value) => {
282
+ params.push(value ?? '');
283
+ return `$${params.length}`;
284
+ });
285
+ return lookupColumns.length === 1 ? group[0] : `(${group.join(', ')})`;
286
+ });
287
+ const left = lookupColumns.length === 1 ? lookupColumns[0] : `(${lookupColumns.join(', ')})`;
288
+ const pairs = await this.query(`SELECT * FROM (
289
+ SELECT d.*, row_number() OVER (PARTITION BY ${partition} ORDER BY ${order}) AS "__rn",
290
+ count(*) OVER (PARTITION BY ${partition}) AS "__n"
291
+ FROM (SELECT DISTINCT ${[...originAlias, ...endAlias].join(', ')} FROM ${from}
292
+ WHERE ${left} IN (${marks.join(', ')}) AND ${endSource.map((c) => `${c} IS NOT NULL`).join(' AND ')}) d
293
+ ) found WHERE "__rn" <= ${this.limits.perKey}`, params);
294
+ const ends = new Map();
295
+ for (const pair of pairs) {
296
+ const origin = wanted.get(tupleKey(originColumns.map((_, i) => pair[`__o${i}`] ?? null)));
297
+ if (!origin)
298
+ continue;
299
+ if (Number(pair.__n) > this.limits.perKey)
300
+ this.truncated = true;
301
+ const values = endColumns.map((_, i) => pair[`__e${i}`] ?? null);
302
+ const tuple = tupleKey(values);
303
+ const entry = ends.get(tuple) ?? { values, links: [] };
304
+ entry.links.push(...origin.nodes);
305
+ ends.set(tuple, entry);
306
+ }
307
+ for (const group of chunksOf([...ends.values()], BATCH)) {
308
+ const rows = await this.fetch(path.end, endColumns, group.map((entry) => entry.values), this.limits.maxNodes);
309
+ for (const row of rows) {
310
+ const entry = ends.get(tupleKey(endColumns.map((name) => row[name] ?? null)));
311
+ if (!entry)
312
+ continue;
313
+ for (const origin of entry.links) {
314
+ const { node, id, fresh } = await this.nodeFor(path.end, row, endColumns, origin.distance + 1);
315
+ if (!node && !this.seen.has(id))
316
+ continue;
317
+ if (fresh && node)
318
+ created.push(node);
319
+ this.addViaEdge(path.via, origin.id, id);
320
+ }
321
+ }
322
+ }
323
+ }
324
+ return created;
325
+ }
326
+ /** Follows the keys of the nodes for `levels` steps, giving the rows found the next distance. */
327
+ async follow(frontier, levels) {
328
+ let current = frontier;
329
+ for (let level = 0; level < levels && current.length > 0; level++) {
330
+ const next = [];
331
+ const byTable = new Map();
332
+ for (const node of current) {
333
+ const label = tableLabel(node.table);
334
+ byTable.set(label, [...(byTable.get(label) ?? []), node]);
335
+ }
336
+ for (const nodes of byTable.values()) {
337
+ const table = nodes[0].table;
338
+ const { outgoing, incoming } = await this.foreignKeysOf(table);
339
+ // Keys that lead to a hidden table are followed through it, below.
340
+ if (this.direction !== 'in') {
341
+ for (const key of outgoing.filter((k) => !this.isHidden(k.to))) {
342
+ next.push(...(await this.outgoing(key, nodes)));
343
+ }
344
+ }
345
+ if (this.direction !== 'out') {
346
+ for (const key of incoming.filter((k) => !this.isHidden(k.from))) {
347
+ next.push(...(await this.incoming(key, nodes, 0)));
348
+ }
349
+ }
350
+ if (this.hidden.size > 0) {
351
+ for (const path of await this.pathsFrom(table)) {
352
+ const forward = path.steps[0].forward;
353
+ if ((forward && this.direction === 'in') || (!forward && this.direction === 'out'))
354
+ continue;
355
+ next.push(...(await this.through(path, nodes)));
356
+ }
357
+ }
358
+ }
359
+ current = next;
360
+ }
361
+ }
362
+ /** Fetches the row each node's key points at. */
363
+ async outgoing(key, nodes) {
364
+ const wanted = new Map();
365
+ for (const node of nodes) {
366
+ const values = key.fromColumns.map((name) => node.row[name] ?? null);
367
+ // A null never points at anything.
368
+ if (values.some((value) => value === null))
369
+ continue;
370
+ const tuple = tupleKey(values);
371
+ const entry = wanted.get(tuple) ?? { values, nodes: [] };
372
+ entry.nodes.push(node);
373
+ wanted.set(tuple, entry);
374
+ }
375
+ const created = [];
376
+ for (const batch of chunksOf([...wanted.values()], BATCH)) {
377
+ const rows = await this.fetch(key.to, key.toColumns, batch.map((entry) => entry.values), this.limits.maxNodes);
378
+ for (const row of rows) {
379
+ const parents = wanted.get(tupleKey(key.toColumns.map((name) => row[name] ?? null)));
380
+ if (!parents)
381
+ continue;
382
+ for (const parent of parents.nodes) {
383
+ const { node, id, fresh } = await this.nodeFor(key.to, row, key.toColumns, parent.distance + 1);
384
+ if (!node && !this.seen.has(id))
385
+ continue;
386
+ if (fresh && node)
387
+ created.push(node);
388
+ this.addEdge(key, parent.id, id);
389
+ }
390
+ }
391
+ }
392
+ return created;
393
+ }
394
+ /** Fetches the rows that point at each node through the key, a page of them for each. */
395
+ async incoming(key, nodes, skip) {
396
+ const wanted = new Map();
397
+ for (const node of nodes) {
398
+ const values = key.toColumns.map((name) => node.row[name] ?? null);
399
+ if (values.some((value) => value === null))
400
+ continue;
401
+ const tuple = tupleKey(values);
402
+ const entry = wanted.get(tuple) ?? { values, nodes: [] };
403
+ entry.nodes.push(node);
404
+ wanted.set(tuple, entry);
405
+ }
406
+ const fromColumns = await this.columnsOf(key.from);
407
+ const order = primaryKeyOf(fromColumns).map(quoteIdentifier).join(', ') || 'ctid';
408
+ const created = [];
409
+ const handled = new Map();
410
+ const totals = new Map();
411
+ for (const batch of chunksOf([...wanted.values()], BATCH)) {
412
+ const partition = key.fromColumns.map(quoteIdentifier).join(', ');
413
+ const { sql, params } = lookup(key.from, key.fromColumns, batch.map((entry) => entry.values), `, row_number() OVER (PARTITION BY ${partition} ORDER BY ${order}) AS "__rn",
414
+ count(*) OVER (PARTITION BY ${partition}) AS "__n"`);
415
+ const rows = await this.query(`SELECT * FROM (${sql}) AS found WHERE "__rn" > ${skip} AND "__rn" <= ${skip + this.limits.perKey}
416
+ ORDER BY "__rn"`, params);
417
+ for (const row of rows) {
418
+ const tuple = tupleKey(key.fromColumns.map((name) => row[name] ?? null));
419
+ const parents = wanted.get(tuple);
420
+ if (!parents)
421
+ continue;
422
+ totals.set(tuple, Number(row.__n));
423
+ delete row.__rn;
424
+ delete row.__n;
425
+ for (const parent of parents.nodes) {
426
+ const { node, id, fresh } = await this.nodeFor(key.from, row, key.fromColumns, parent.distance + 1);
427
+ if (!node && !this.seen.has(id))
428
+ continue;
429
+ if (fresh && node)
430
+ created.push(node);
431
+ this.addEdge(key, id, parent.id);
432
+ handled.set(tuple, (handled.get(tuple) ?? 0) + 1);
433
+ }
434
+ }
435
+ }
436
+ for (const [tuple, total] of totals) {
437
+ const shown = skip + (handled.get(tuple) ?? 0);
438
+ if (total <= shown)
439
+ continue;
440
+ for (const parent of wanted.get(tuple).nodes) {
441
+ this.more.push({
442
+ node: parent.id,
443
+ constraint: key.name,
444
+ table: key.from,
445
+ shown,
446
+ count: Math.min(total, this.limits.countCap),
447
+ capped: total > this.limits.countCap,
448
+ });
449
+ }
450
+ }
451
+ return created;
452
+ }
453
+ async query(sql, params) {
454
+ try {
455
+ const result = await this.session.run(sql, params, {
456
+ maxRows: this.limits.maxNodes * 2 + 1000,
457
+ });
458
+ return result.rows.map((cells) => {
459
+ const row = {};
460
+ result.columns.forEach((column, i) => (row[column.name] = cells[i] ?? null));
461
+ return row;
462
+ });
463
+ }
464
+ catch (error) {
465
+ if (error?.code === '57014') {
466
+ throw new GraphError(`took longer than ${this.limits.timeoutMs / 1000}s`);
467
+ }
468
+ throw error;
469
+ }
470
+ }
471
+ /** The rows of `table` whose `columns` have one of the value tuples. */
472
+ async fetch(table, columns, tuples, limit) {
473
+ const { sql, params } = lookup(table, columns, tuples);
474
+ return this.query(`${sql} LIMIT ${Math.max(Math.floor(limit), 1)}`, params);
475
+ }
476
+ }
477
+ /** `SELECT *, <extra> FROM t WHERE (a, b) IN (($1, $2), ...)`; the values are bound as text. */
478
+ function lookup(table, columns, tuples, extra = '') {
479
+ const params = [];
480
+ const names = columns.map(quoteIdentifier);
481
+ const marks = tuples.map((tuple) => {
482
+ const group = tuple.map((value) => {
483
+ params.push(value ?? '');
484
+ return `$${params.length}`;
485
+ });
486
+ return columns.length === 1 ? group[0] : `(${group.join(', ')})`;
487
+ });
488
+ const left = columns.length === 1 ? names[0] : `(${names.join(', ')})`;
489
+ return {
490
+ sql: `SELECT *${extra} FROM ${qualified(table)} WHERE ${left} IN (${marks.join(', ')})`,
491
+ params,
492
+ };
493
+ }
494
+ const finish = (walk, moreStart) => ({
495
+ nodes: [...walk.nodes.values()],
496
+ edges: [...walk.edges.values()],
497
+ more: walk.more,
498
+ moreStart,
499
+ truncated: walk.truncated,
500
+ });
501
+ /** The rows the filters find, and the rows linked to them through foreign keys, `depth` steps out. */
502
+ export async function buildGraph(session, request) {
503
+ const { table, limits } = request;
504
+ return withStatementTimeout(session, limits.timeoutMs, async () => {
505
+ // The rows the graph starts from are drawn even if their table is hidden.
506
+ const hide = (request.hide ?? []).filter((t) => tableLabel(t) !== tableLabel(table));
507
+ const walk = new Walk(session, limits, [], request.direction, hide, request.skipKeys);
508
+ const columns = await walk.columnsOf(table);
509
+ if (columns.length === 0)
510
+ throw new GraphError(`no table ${tableLabel(table)}`);
511
+ const primary = primaryKeyOf(columns);
512
+ const order = primary.map(quoteIdentifier).join(', ');
513
+ const wanted = Math.min(limits.maxStart, limits.maxNodes);
514
+ let from;
515
+ if (request.keys && request.keys.length > 0) {
516
+ if (primary.length === 0) {
517
+ throw new GraphError(`${tableLabel(table)} has no primary key to pick its rows by`);
518
+ }
519
+ if (request.keys.some((values) => values.length !== primary.length)) {
520
+ throw new GraphError(`each row is picked by ${primary.join(', ')}`);
521
+ }
522
+ from = lookup(table, primary, request.keys.map((values) => [...values]));
523
+ }
524
+ else {
525
+ const where = buildWhere(columns, request.filters);
526
+ from = { sql: `SELECT * FROM ${qualified(table)}${where.sql}`, params: where.params };
527
+ }
528
+ const rows = await walk.query(`${from.sql}${order ? ` ORDER BY ${order}` : ''} LIMIT ${wanted + 1}`, from.params);
529
+ const start = [];
530
+ for (const row of rows.slice(0, wanted)) {
531
+ const { node } = await walk.nodeFor(table, row, [], 0, true);
532
+ if (node)
533
+ start.push(node);
534
+ }
535
+ await walk.follow(start, request.depth);
536
+ return finish(walk, rows.length > wanted);
537
+ });
538
+ }
539
+ /**
540
+ * What is new when a node is opened: the rows linked to it, and the edges to them. The ids already
541
+ * drawn are left out of the result, but still count towards the node limit.
542
+ */
543
+ export async function expandNode(session, request) {
544
+ const parsed = parseNodeId(request.node);
545
+ if (!parsed)
546
+ throw new GraphError('that node cannot be expanded');
547
+ const { table } = parsed;
548
+ const { limits } = request;
549
+ return withStatementTimeout(session, limits.timeoutMs, async () => {
550
+ const walk = new Walk(session, limits, request.drawn, request.direction, request.hide, request.skipKeys);
551
+ const columns = await walk.columnsOf(table);
552
+ const primary = primaryKeyOf(columns);
553
+ if (primary.length === 0 || primary.length !== parsed.key.length) {
554
+ throw new GraphError(`${tableLabel(table)} has no primary key, so its rows cannot be expanded`);
555
+ }
556
+ const [row] = await walk.fetch(table, primary, [parsed.key], 1);
557
+ if (!row)
558
+ throw new GraphError('that row is gone');
559
+ // The node itself is drawn already; it is only the place the walk starts from.
560
+ const self = {
561
+ id: request.node,
562
+ table,
563
+ key: Object.fromEntries(primary.map((name) => [name, row[name] ?? null])),
564
+ label: '',
565
+ row,
566
+ distance: request.distance,
567
+ start: false,
568
+ expandable: true,
569
+ };
570
+ if (request.key !== undefined) {
571
+ const { incoming } = await listForeignKeys(session, table);
572
+ const key = incoming.find((candidate) => candidate.name === request.key);
573
+ if (!key)
574
+ throw new GraphError(`no key "${request.key}" points at ${tableLabel(table)}`);
575
+ await walk.incoming(key, [self], request.skip);
576
+ }
577
+ else {
578
+ await walk.follow([self], request.depth);
579
+ }
580
+ return finish(walk, false);
581
+ });
582
+ }
583
+ const integer = (value, name, min, max, fallback) => {
584
+ if (value === undefined || value === null)
585
+ return fallback;
586
+ if (typeof value !== 'number' || !Number.isInteger(value) || value < min || value > max) {
587
+ throw new GraphError(`${name} must be a whole number from ${min} to ${max}`);
588
+ }
589
+ return value;
590
+ };
591
+ const parseDirection = (value) => {
592
+ if (value === undefined || value === null)
593
+ return 'both';
594
+ if (value === 'out' || value === 'in' || value === 'both')
595
+ return value;
596
+ throw new GraphError('direction is out, in or both');
597
+ };
598
+ function parseLimits(body) {
599
+ const defaults = DEFAULT_GRAPH_LIMITS;
600
+ return {
601
+ maxStart: integer(body.maxStart, 'maxStart', 1, 200, defaults.maxStart),
602
+ maxNodes: integer(body.maxNodes, 'maxNodes', 1, 1000, defaults.maxNodes),
603
+ perKey: integer(body.perKey, 'perKey', 1, 100, defaults.perKey),
604
+ timeoutMs: defaults.timeoutMs,
605
+ countCap: defaults.countCap,
606
+ };
607
+ }
608
+ function parseSkipKeys(value) {
609
+ if (value === undefined || value === null)
610
+ return [];
611
+ if (!Array.isArray(value) ||
612
+ value.length > 5000 ||
613
+ value.some((entry) => typeof entry !== 'string')) {
614
+ throw new GraphError('skipKeys is a list of foreign key ids');
615
+ }
616
+ return value;
617
+ }
618
+ function parseHide(value) {
619
+ if (value === undefined || value === null)
620
+ return [];
621
+ if (!Array.isArray(value) ||
622
+ value.length > 500 ||
623
+ value.some((entry) => typeof entry?.schema !== 'string' ||
624
+ typeof entry.name !== 'string')) {
625
+ throw new GraphError('hide is a list of {"schema", "name"}');
626
+ }
627
+ return value.map(({ schema, name }) => ({ schema, name }));
628
+ }
629
+ /** Reads a request to start a graph; `filters` are parsed by the caller (`parseFilters`). */
630
+ export function parseGraphRequest(body, filters) {
631
+ const entry = body;
632
+ if (typeof entry?.schema !== 'string' || typeof entry.table !== 'string') {
633
+ throw new GraphError('send {"schema", "table", "filters"?, "depth"?, "direction"?}');
634
+ }
635
+ const keys = entry.keys ?? [];
636
+ if (!Array.isArray(keys) ||
637
+ keys.length > 1000 ||
638
+ keys.some((values) => !Array.isArray(values) ||
639
+ values.length === 0 ||
640
+ values.some((value) => typeof value !== 'string'))) {
641
+ throw new GraphError('keys is a list of lists of primary key values');
642
+ }
643
+ return {
644
+ table: { schema: entry.schema, name: entry.table },
645
+ filters,
646
+ ...(keys.length > 0 ? { keys: keys } : {}),
647
+ depth: integer(entry.depth, 'depth', 0, MAX_GRAPH_DEPTH, 1),
648
+ direction: parseDirection(entry.direction),
649
+ limits: parseLimits(entry),
650
+ hide: parseHide(entry.hide),
651
+ skipKeys: parseSkipKeys(entry.skipKeys),
652
+ };
653
+ }
654
+ /** Reads a request to expand a node. */
655
+ export function parseExpandRequest(body) {
656
+ const entry = body;
657
+ if (typeof entry?.node !== 'string') {
658
+ throw new GraphError('send {"node", "drawn"?, "depth"?, "direction"?, "key"?, "skip"?}');
659
+ }
660
+ const drawn = entry.drawn ?? [];
661
+ if (!Array.isArray(drawn) || drawn.some((id) => typeof id !== 'string')) {
662
+ throw new GraphError('drawn is a list of ids');
663
+ }
664
+ if (entry.key !== undefined && typeof entry.key !== 'string') {
665
+ throw new GraphError('key is the name of a foreign key');
666
+ }
667
+ return {
668
+ node: entry.node,
669
+ drawn: drawn,
670
+ depth: integer(entry.depth, 'depth', 1, MAX_GRAPH_DEPTH, 1),
671
+ direction: parseDirection(entry.direction),
672
+ distance: integer(entry.distance, 'distance', 0, 1000, 0),
673
+ ...(entry.key === undefined ? {} : { key: entry.key }),
674
+ skip: integer(entry.skip, 'skip', 0, 1_000_000, 0),
675
+ limits: parseLimits(entry),
676
+ hide: parseHide(entry.hide),
677
+ skipKeys: parseSkipKeys(entry.skipKeys),
678
+ };
679
+ }