@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,988 @@
1
+ // The HTTP server behind `psql-explorer web`: a small JSON API over the same services the TUI and
2
+ // the commands use, plus the built web UI (web/dist). It only listens on the loopback interface
3
+ // and refuses requests whose Host is not loopback, so a web page cannot reach it by DNS rebinding.
4
+ import { spawn } from 'node:child_process';
5
+ import { createServer } from 'node:http';
6
+ import { readFile } from 'node:fs/promises';
7
+ import { extname, join, normalize, resolve, sep } from 'node:path';
8
+ import { ResolveError } from '../config/resolvers/index.js';
9
+ import { addressOf, findEnvironment, forgetAnswers, listEnvironments } from '../environments.js';
10
+ import { connectEntries, LoginError } from '../explorer/connect-entry.js';
11
+ import { describeCatalog, describeFunctionCatalog, describeErd, describeFunction, describeRelation, describeSequence, describeType, listColumns, listObjects, listSchemas, serverInfo, } from '../explorer/catalog.js';
12
+ import { applyEdit, buildEdit, EditError } from '../explorer/edit.js';
13
+ import { listDependents } from '../explorer/dependents.js';
14
+ import { describeFunctionSecurity, describeSecurity, listRoles } from '../explorer/security.js';
15
+ import { explainAnalyze, explainQuery } from '../explorer/explain.js';
16
+ import { browseRows, browseSql, canExport, EXPORT_MAX_ROWS, exportRows, runPage, } from '../explorer/rows.js';
17
+ import { buildWhere, FilterError, parseFilters } from '../explorer/filter.js';
18
+ import { buildGraph, expandNode, GraphError, parseExpandRequest, parseGraphRequest, } from '../explorer/graph.js';
19
+ import { CypherError, parseCypherRequest, runCypher } from '../explorer/cypher.js';
20
+ import { profileColumn } from '../explorer/profile.js';
21
+ import { countReferences, listForeignKeys, listSchemaForeignKeys } from '../explorer/references.js';
22
+ import { buildSearchIndex, searchValues } from '../explorer/search.js';
23
+ import { describeSeedColumns, generators, MAX_SEED_ROWS, parseSeedRequest, previewSeed, runSeed, SeedError, seedLocales, } from '../explorer/seed.js';
24
+ import { compareSchemas } from '../explorer/schema-diff.js';
25
+ import { AlterError, diffDesigns, dropTableStatement, parseAlterOptions, splitPlan, } from '../explorer/alter.js';
26
+ import { buildDdl, DdlError, parseTableDesign } from '../explorer/ddl.js';
27
+ import { DesignRunError, runPlan, runStatements } from '../explorer/design.js';
28
+ import { loadDesign } from '../explorer/design-load.js';
29
+ import { describeQueryError } from '../explorer/session.js';
30
+ import { applyGucs, GucError, parseGucs } from '../explorer/gucs.js';
31
+ import { databaseStats, listIndexStats, listTableStats } from '../explorer/stats.js';
32
+ export const DEFAULT_WEB_PORT = 7878;
33
+ const MAX_BODY_BYTES = 1_000_000;
34
+ // Editing a row can carry a dropped file: up to 20 MB of it, written as hex.
35
+ const MAX_EDIT_BODY_BYTES = 48_000_000;
36
+ // Connections a value search reads tables on at once.
37
+ const SEARCH_CONNECTIONS = 4;
38
+ const contentTypes = {
39
+ '.html': 'text/html; charset=utf-8',
40
+ '.js': 'text/javascript; charset=utf-8',
41
+ '.css': 'text/css; charset=utf-8',
42
+ '.json': 'application/json; charset=utf-8',
43
+ '.svg': 'image/svg+xml',
44
+ '.png': 'image/png',
45
+ '.ico': 'image/x-icon',
46
+ '.woff2': 'font/woff2',
47
+ };
48
+ /** A request that cannot be answered as sent: the status says why. */
49
+ class HttpError extends Error {
50
+ status;
51
+ extra;
52
+ constructor(status, message,
53
+ // More to send along with the message, e.g. which statement failed.
54
+ extra = {}) {
55
+ super(message);
56
+ this.status = status;
57
+ this.extra = extra;
58
+ }
59
+ }
60
+ const sendJson = (res, status, body) => {
61
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
62
+ res.end(JSON.stringify(body));
63
+ };
64
+ const requiredParam = (url, key) => {
65
+ const value = url.searchParams.get(key);
66
+ if (!value)
67
+ throw new HttpError(400, `${url.pathname.split('/').pop()} needs ?${key}=`);
68
+ return value;
69
+ };
70
+ /** A query parameter holding JSON, e.g. the filters of the row grid; undefined when absent. */
71
+ const jsonParam = (url, key) => {
72
+ const text = url.searchParams.get(key);
73
+ if (!text)
74
+ return undefined;
75
+ try {
76
+ return JSON.parse(text);
77
+ }
78
+ catch {
79
+ throw new HttpError(400, `${key} is not valid JSON`);
80
+ }
81
+ };
82
+ const parseSort = (raw) => {
83
+ if (raw === undefined)
84
+ return [];
85
+ if (!Array.isArray(raw))
86
+ throw new HttpError(400, 'sort is a list of {column, descending}');
87
+ return raw.slice(0, 10).map((item) => {
88
+ if (typeof item?.column !== 'string')
89
+ throw new HttpError(400, 'sort is a list of {column, descending}');
90
+ return { column: item.column, descending: item.descending === true };
91
+ });
92
+ };
93
+ /** A saved query's name: what was typed, or else the start of the statement. */
94
+ const savedName = (given, sql) => {
95
+ const text = typeof given === 'string' ? given.trim() : '';
96
+ return (text || sql.trim().replace(/\s+/g, ' ')).slice(0, 80);
97
+ };
98
+ const isLoopbackHost = (host) => {
99
+ if (!host)
100
+ return false;
101
+ const name = host.startsWith('[') ? host.slice(0, host.indexOf(']') + 1) : host.split(':')[0];
102
+ return name === 'localhost' || name === '127.0.0.1' || name === '[::1]';
103
+ };
104
+ async function readJson(req, maxBytes = MAX_BODY_BYTES) {
105
+ if (!req.headers['content-type']?.startsWith('application/json')) {
106
+ throw new HttpError(415, 'send application/json');
107
+ }
108
+ const chunks = [];
109
+ let size = 0;
110
+ for await (const chunk of req) {
111
+ size += chunk.length;
112
+ if (size > maxBytes)
113
+ throw new HttpError(413, 'request body is too large');
114
+ chunks.push(chunk);
115
+ }
116
+ try {
117
+ return JSON.parse(Buffer.concat(chunks).toString('utf8'));
118
+ }
119
+ catch {
120
+ throw new HttpError(400, 'the body is not valid JSON');
121
+ }
122
+ }
123
+ export function createWebServer(options) {
124
+ const { services } = options;
125
+ const staticDir = options.staticDir ? resolve(options.staticDir) : undefined;
126
+ const environmentNamed = (name) => {
127
+ const entry = findEnvironment(services, name);
128
+ if (!entry)
129
+ throw new HttpError(404, `you do not have access to "${name}": it is not in your config`);
130
+ return entry;
131
+ };
132
+ // Protected environments the person has switched to read write, by entry key. In memory only, so
133
+ // a restart is read only again.
134
+ const writable = new Set();
135
+ // Opens up to `count` connections with one lookup of the credentials (one Vault read, one
136
+ // prompt), so queries can run side by side. Nothing is kept between requests: the next request
137
+ // looks everything up again, so a changed password is never held onto.
138
+ async function withSessions(name, count, work, signal) {
139
+ const entry = environmentNamed(name);
140
+ let sessions;
141
+ try {
142
+ sessions = await connectEntries(entry, services, count, {
143
+ signal,
144
+ ...(writable.has(entry.key) ? { readOnly: false } : {}),
145
+ });
146
+ }
147
+ catch (error) {
148
+ // What was typed was refused, so ask again next time instead of remembering it.
149
+ if (error instanceof LoginError)
150
+ forgetAnswers(entry, services);
151
+ throw error;
152
+ }
153
+ // A request the browser gave up on (Cancel, or a closed page) stops its statements on the server.
154
+ const stop = () => void Promise.all(sessions.map((session) => session.cancel()));
155
+ signal?.addEventListener('abort', stop);
156
+ try {
157
+ return await work(sessions);
158
+ }
159
+ finally {
160
+ signal?.removeEventListener('abort', stop);
161
+ await Promise.all(sessions.map((session) => session.close().catch(() => undefined)));
162
+ }
163
+ }
164
+ const withSession = (name, work, signal) => withSessions(name, 1, ([session]) => work(session), signal);
165
+ async function api(req, url, signal) {
166
+ const withSessionFor = (name, work) => withSession(name, work, signal);
167
+ const withSessionsFor = (name, count, work) => withSessions(name, count, work, signal);
168
+ const parts = url.pathname.split('/').slice(2).map(decodeURIComponent);
169
+ const [resource, name, sub, id] = parts;
170
+ if (resource === 'prompt' && !name && req.method === 'GET') {
171
+ const pending = services.prompts?.current();
172
+ return {
173
+ prompt: pending
174
+ ? {
175
+ envName: pending.envName,
176
+ field: pending.field,
177
+ masked: pending.masked,
178
+ ...(pending.challenge === undefined ? {} : { challenge: pending.challenge }),
179
+ }
180
+ : null,
181
+ };
182
+ }
183
+ if (resource === 'prompt' && !name && req.method === 'POST') {
184
+ const body = (await readJson(req));
185
+ const pending = services.prompts?.current();
186
+ // The question may have been answered elsewhere since the page last looked.
187
+ if (!pending || pending.envName !== body?.envName || pending.field !== body?.field) {
188
+ throw new HttpError(409, 'that question is no longer waiting for an answer');
189
+ }
190
+ if (body.value === null)
191
+ pending.cancel();
192
+ else if (typeof body.value === 'string' && body.value !== '')
193
+ pending.submit(body.value);
194
+ else
195
+ throw new HttpError(400, 'send {"value": "..."} or {"value": null} to cancel');
196
+ return {};
197
+ }
198
+ if (resource === 'envs' && !name && req.method === 'GET') {
199
+ return {
200
+ environments: listEnvironments(services).map((entry) => ({
201
+ name: entry.name,
202
+ source: entry.source,
203
+ tier: entry.tier,
204
+ protected: entry.protected,
205
+ readOnly: entry.readOnly,
206
+ seeder: entry.seeder,
207
+ designer: entry.designer,
208
+ address: addressOf(entry),
209
+ tls: entry.tlsMode,
210
+ })),
211
+ error: services.configError?.message,
212
+ };
213
+ }
214
+ if (resource === 'envs' && name && sub === 'write' && (req.method === 'GET' || req.method === 'POST')) {
215
+ const entry = environmentNamed(name);
216
+ // Only a protected environment that is read only has anything to switch.
217
+ const available = entry.protected && entry.readOnly;
218
+ if (req.method === 'POST') {
219
+ const body = (await readJson(req));
220
+ if (typeof body?.enabled !== 'boolean')
221
+ throw new HttpError(400, 'send {"enabled": true|false}');
222
+ if (!available)
223
+ throw new HttpError(400, `${entry.name} is not a protected read only environment`);
224
+ if (!body.enabled)
225
+ writable.delete(entry.key);
226
+ else if (!writable.has(entry.key)) {
227
+ // Always asked, whatever `confirm` says: this is the step that allows changing data.
228
+ const prompt = services.resolvers.get('prompt');
229
+ if (!prompt)
230
+ throw new HttpError(400, 'there is no way to ask for confirmation');
231
+ try {
232
+ await prompt.confirm({ envName: entry.name, interactive: true, signal, purpose: 'write' });
233
+ }
234
+ catch (error) {
235
+ if (error instanceof ResolveError)
236
+ throw new HttpError(400, error.message);
237
+ throw error;
238
+ }
239
+ writable.add(entry.key);
240
+ }
241
+ }
242
+ return { available, enabled: writable.has(entry.key) };
243
+ }
244
+ if (resource === 'envs' && name && sub === 'schemas' && req.method === 'GET') {
245
+ return withSessionFor(name, (session) => listSchemas(session));
246
+ }
247
+ if (resource === 'envs' && name && sub === 'erd' && req.method === 'GET') {
248
+ const schema = requiredParam(url, 'schema');
249
+ return withSessionFor(name, (session) => describeErd(session, schema));
250
+ }
251
+ if (resource === 'envs' && name && sub === 'catalog' && req.method === 'GET') {
252
+ return withSessionFor(name, (session) => describeCatalog(session));
253
+ }
254
+ if (resource === 'envs' && name && sub === 'functions' && req.method === 'GET') {
255
+ return withSessionFor(name, (session) => describeFunctionCatalog(session));
256
+ }
257
+ if (resource === 'envs' && name && sub === 'search-index' && req.method === 'GET') {
258
+ return withSessionFor(name, (session) => buildSearchIndex(session));
259
+ }
260
+ if (resource === 'envs' && name && sub === 'find' && req.method === 'GET') {
261
+ const text = requiredParam(url, 'q').trim();
262
+ if (!text)
263
+ throw new HttpError(400, 'find needs ?q=');
264
+ const schema = url.searchParams.get('schema') || undefined;
265
+ try {
266
+ return await withSessionsFor(name, SEARCH_CONNECTIONS, (sessions) => searchValues(sessions, { text, ...(schema ? { schema } : {}) }));
267
+ }
268
+ catch (error) {
269
+ if (error instanceof HttpError)
270
+ throw error;
271
+ throw new HttpError(400, describeQueryError(error));
272
+ }
273
+ }
274
+ if (resource === 'envs' && name && sub === 'info' && req.method === 'GET') {
275
+ return withSessionFor(name, (session) => serverInfo(session));
276
+ }
277
+ if (resource === 'envs' && name && sub === 'stats' && req.method === 'GET') {
278
+ const schema = url.searchParams.get('schema');
279
+ const table = url.searchParams.get('table');
280
+ if (!schema || !table)
281
+ return withSessionFor(name, (session) => databaseStats(session));
282
+ const ref = { schema, name: table };
283
+ const found = await withSessionFor(name, async (session) => {
284
+ const [stats] = await listTableStats(session, ref).catch(() => []);
285
+ return stats && { table: stats, indexes: await listIndexStats(session, ref) };
286
+ });
287
+ if (!found)
288
+ throw new HttpError(404, `no table ${schema}.${table}`);
289
+ return found;
290
+ }
291
+ if (resource === 'envs' && name && sub === 'schema-diff' && req.method === 'GET') {
292
+ const other = requiredParam(url, 'with');
293
+ const schema = url.searchParams.get('schema') || undefined;
294
+ const withSchema = url.searchParams.get('withSchema') || undefined;
295
+ environmentNamed(other);
296
+ try {
297
+ return await withSessionFor(name, (session) => withSession(other, (otherSession) => compareSchemas(session, otherSession, { schema, withSchema }), signal));
298
+ }
299
+ catch (error) {
300
+ if (error instanceof HttpError)
301
+ throw error;
302
+ throw new HttpError(400, describeQueryError(error));
303
+ }
304
+ }
305
+ if (resource === 'envs' && name && sub === 'dependents' && req.method === 'GET') {
306
+ const kind = requiredParam(url, 'kind');
307
+ const schema = requiredParam(url, 'schema');
308
+ const object = requiredParam(url, 'name');
309
+ let target;
310
+ if (kind === 'function') {
311
+ target = { kind, schema, name: object, args: url.searchParams.get('args') ?? '' };
312
+ }
313
+ else if (kind === 'table' || kind === 'sequence' || kind === 'type') {
314
+ target = { kind, schema, name: object };
315
+ }
316
+ else if (kind === 'column' || kind === 'constraint') {
317
+ target = { kind, schema, table: requiredParam(url, 'table'), name: object };
318
+ }
319
+ else if (kind === 'index') {
320
+ target = { kind, schema, name: object };
321
+ }
322
+ else
323
+ throw new HttpError(400, `unknown kind "${kind}"`);
324
+ const found = await withSessionFor(name, (session) => listDependents(session, target));
325
+ if (!found)
326
+ throw new HttpError(404, `no ${kind} ${schema}.${object}`);
327
+ return found;
328
+ }
329
+ if (resource === 'envs' && name && sub === 'security' && req.method === 'GET') {
330
+ const schema = requiredParam(url, 'schema');
331
+ const table = requiredParam(url, 'name');
332
+ const args = url.searchParams.get('args');
333
+ const found = await withSessionFor(name, (session) => args === null
334
+ ? describeSecurity(session, { schema, name: table })
335
+ : describeFunctionSecurity(session, { schema, name: table, args }));
336
+ if (!found)
337
+ throw new HttpError(404, `no ${args === null ? 'table' : 'function'} ${schema}.${table}`);
338
+ return found;
339
+ }
340
+ if (resource === 'envs' && name && sub === 'roles' && req.method === 'GET') {
341
+ // Not every role may read the catalog of roles; the page then just has none to show.
342
+ return withSessionFor(name, (session) => listRoles(session).then((roles) => ({ roles, available: true }), () => ({ roles: [], available: false })));
343
+ }
344
+ if (resource === 'envs' && name && sub === 'objects' && req.method === 'GET') {
345
+ const schema = requiredParam(url, 'schema');
346
+ return withSessionFor(name, (session) => listObjects(session, schema));
347
+ }
348
+ if (resource === 'envs' && name && sub === 'object' && req.method === 'GET') {
349
+ const schema = requiredParam(url, 'schema');
350
+ const object = requiredParam(url, 'name');
351
+ const kind = requiredParam(url, 'kind');
352
+ const found = await withSessionFor(name, (session) => {
353
+ if (kind === 'table')
354
+ return describeRelation(session, { schema, name: object });
355
+ if (kind === 'function') {
356
+ return describeFunction(session, { schema, name: object, args: url.searchParams.get('args') ?? '' });
357
+ }
358
+ if (kind === 'sequence')
359
+ return describeSequence(session, { schema, name: object });
360
+ if (kind === 'type')
361
+ return describeType(session, { schema, name: object });
362
+ throw new HttpError(400, `unknown kind "${kind}"`);
363
+ });
364
+ if (!found)
365
+ throw new HttpError(404, `no ${kind} ${schema}.${object}`);
366
+ return found;
367
+ }
368
+ if (resource === 'envs' && name && sub === 'rows' && req.method === 'GET') {
369
+ const schema = requiredParam(url, 'schema');
370
+ const table = requiredParam(url, 'table');
371
+ const limit = Math.min(Math.max(Number(url.searchParams.get('limit')) || 100, 1), 1000);
372
+ const offset = Math.max(Number(url.searchParams.get('offset')) || 0, 0);
373
+ try {
374
+ const filters = parseFilters(jsonParam(url, 'filters'));
375
+ const sort = parseSort(jsonParam(url, 'sort'));
376
+ return await withSessionFor(name, async (session) => {
377
+ const ref = { schema, name: table };
378
+ const columns = await listColumns(session, ref);
379
+ for (const { column } of sort) {
380
+ if (!columns.some((known) => known.name === column)) {
381
+ throw new FilterError(`no column "${column}"`);
382
+ }
383
+ }
384
+ const where = buildWhere(columns, filters);
385
+ const result = await browseRows(session, ref, { limit, offset, orderBy: sort, where });
386
+ // The same statement with its values written in, for the SQL box.
387
+ const shown = browseSql(ref, {
388
+ orderBy: sort,
389
+ where: buildWhere(columns, filters, { inline: true }),
390
+ })
391
+ .replace(/LIMIT \$\d+ OFFSET \$\d+$/, `LIMIT ${limit} OFFSET ${offset}`)
392
+ .replace(/ OFFSET 0$/, '');
393
+ return { ...result, sql: shown };
394
+ });
395
+ }
396
+ catch (error) {
397
+ if (error instanceof HttpError)
398
+ throw error;
399
+ if (error instanceof FilterError || error instanceof GucError) {
400
+ throw new HttpError(400, error.message);
401
+ }
402
+ throw new HttpError(400, describeQueryError(error));
403
+ }
404
+ }
405
+ if (resource === 'envs' &&
406
+ name &&
407
+ sub === 'graph' &&
408
+ (!id || id === 'expand') &&
409
+ req.method === 'POST') {
410
+ try {
411
+ const body = (await readJson(req));
412
+ if (id === 'expand') {
413
+ const request = parseExpandRequest(body);
414
+ return await withSessionFor(name, (session) => expandNode(session, request));
415
+ }
416
+ const request = parseGraphRequest(body, parseFilters(body?.filters));
417
+ return await withSessionFor(name, (session) => buildGraph(session, request));
418
+ }
419
+ catch (error) {
420
+ if (error instanceof HttpError)
421
+ throw error;
422
+ if (error instanceof GraphError || error instanceof FilterError) {
423
+ throw new HttpError(400, error.message);
424
+ }
425
+ throw new HttpError(400, describeQueryError(error));
426
+ }
427
+ }
428
+ if (resource === 'envs' && name && sub === 'cypher' && req.method === 'POST') {
429
+ try {
430
+ const request = parseCypherRequest(await readJson(req));
431
+ return await withSessionFor(name, (session) => runCypher(session, request));
432
+ }
433
+ catch (error) {
434
+ if (error instanceof HttpError)
435
+ throw error;
436
+ if (error instanceof CypherError)
437
+ throw new HttpError(400, error.message);
438
+ throw new HttpError(400, describeQueryError(error));
439
+ }
440
+ }
441
+ if (resource === 'envs' && name && sub === 'profile' && req.method === 'GET') {
442
+ const schema = requiredParam(url, 'schema');
443
+ const table = requiredParam(url, 'table');
444
+ const column = requiredParam(url, 'column');
445
+ try {
446
+ return await withSessionFor(name, (session) => profileColumn(session, { schema, name: table }, column));
447
+ }
448
+ catch (error) {
449
+ if (error instanceof HttpError)
450
+ throw error;
451
+ throw new HttpError(400, describeQueryError(error));
452
+ }
453
+ }
454
+ if (resource === 'envs' && name && sub === 'foreign-keys' && req.method === 'GET') {
455
+ const schema = requiredParam(url, 'schema');
456
+ const table = requiredParam(url, 'table');
457
+ return withSessionFor(name, (session) => listForeignKeys(session, { schema, name: table }));
458
+ }
459
+ if (resource === 'envs' && name && sub === 'schema-foreign-keys' && req.method === 'GET') {
460
+ const schema = requiredParam(url, 'schema');
461
+ return withSessionFor(name, (session) => listSchemaForeignKeys(session, schema));
462
+ }
463
+ if (resource === 'envs' && name && sub === 'references' && req.method === 'POST') {
464
+ const body = (await readJson(req));
465
+ if (typeof body?.schema !== 'string' ||
466
+ typeof body.table !== 'string' ||
467
+ typeof body.values !== 'object' ||
468
+ body.values === null) {
469
+ throw new HttpError(400, 'send {"schema", "table", "values": {column: value}}');
470
+ }
471
+ const { schema, table } = body;
472
+ const values = body.values;
473
+ try {
474
+ return await withSessionFor(name, (session) => countReferences(session, { schema, name: table }, values));
475
+ }
476
+ catch (error) {
477
+ if (error instanceof HttpError)
478
+ throw error;
479
+ throw new HttpError(400, describeQueryError(error));
480
+ }
481
+ }
482
+ if (resource === 'envs' && name && sub === 'query' && req.method === 'POST') {
483
+ const body = (await readJson(req));
484
+ if (typeof body?.sql !== 'string' || body.sql.trim() === '') {
485
+ throw new HttpError(400, 'send {"sql": "..."}');
486
+ }
487
+ const sql = body.sql;
488
+ const limit = Math.min(Math.max(Number(body.limit) || 100, 1), 1000);
489
+ const offset = Math.max(Math.floor(Number(body.offset)) || 0, 0);
490
+ const envName = environmentNamed(name).name;
491
+ const started = performance.now();
492
+ try {
493
+ const gucs = parseGucs(body.settings);
494
+ const result = await withSessionFor(name, async (session) => {
495
+ await applyGucs(session, gucs);
496
+ return runPage(session, sql, { limit, offset });
497
+ });
498
+ // Later pages of a result are the same statement again, not a new one to remember.
499
+ if (offset === 0) {
500
+ services.store.recordQuery({
501
+ envName,
502
+ sql,
503
+ durationMs: result.durationMs,
504
+ rowCount: result.rowCount ?? result.rows.length,
505
+ });
506
+ }
507
+ return result;
508
+ }
509
+ catch (error) {
510
+ if (error instanceof HttpError)
511
+ throw error;
512
+ const message = error instanceof GucError ? error.message : describeQueryError(error);
513
+ if (offset === 0) {
514
+ services.store.recordQuery({
515
+ envName,
516
+ sql,
517
+ durationMs: performance.now() - started,
518
+ error: message,
519
+ });
520
+ }
521
+ throw new HttpError(400, message);
522
+ }
523
+ }
524
+ if (resource === 'envs' && name && sub === 'export' && req.method === 'POST') {
525
+ const body = (await readJson(req));
526
+ try {
527
+ if (typeof body?.table === 'string' && typeof body.schema === 'string') {
528
+ const ref = { schema: body.schema, name: body.table };
529
+ const filters = parseFilters(body.filters);
530
+ const sort = parseSort(body.sort);
531
+ return await withSessionFor(name, async (session) => {
532
+ const columns = await listColumns(session, ref);
533
+ for (const { column } of sort) {
534
+ if (!columns.some((known) => known.name === column)) {
535
+ throw new FilterError(`no column "${column}"`);
536
+ }
537
+ }
538
+ const where = buildWhere(columns, filters);
539
+ const result = await browseRows(session, ref, {
540
+ limit: EXPORT_MAX_ROWS,
541
+ orderBy: sort,
542
+ where,
543
+ });
544
+ return { columns: result.columns, rows: result.rows, truncated: result.truncated };
545
+ });
546
+ }
547
+ if (typeof body?.sql !== 'string' || !canExport(body.sql)) {
548
+ throw new HttpError(400, 'send {"sql": "SELECT ..."} or {"schema", "table"}: only queries that return rows can be exported');
549
+ }
550
+ const sql = body.sql;
551
+ const gucs = parseGucs(body.settings);
552
+ const result = await withSessionFor(name, async (session) => {
553
+ await applyGucs(session, gucs);
554
+ return exportRows(session, sql);
555
+ });
556
+ return { columns: result.columns, rows: result.rows, truncated: result.truncated };
557
+ }
558
+ catch (error) {
559
+ if (error instanceof HttpError)
560
+ throw error;
561
+ if (error instanceof FilterError)
562
+ throw new HttpError(400, error.message);
563
+ throw new HttpError(400, describeQueryError(error));
564
+ }
565
+ }
566
+ if (resource === 'envs' && name && sub === 'edit' && req.method === 'POST') {
567
+ const body = (await readJson(req, MAX_EDIT_BODY_BYTES));
568
+ if (typeof body?.schema !== 'string' || typeof body.table !== 'string') {
569
+ throw new HttpError(400, 'send {"schema", "table", "action", "key"?, "values"?, "apply"?}');
570
+ }
571
+ const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
572
+ const cells = (value) => {
573
+ if (value === undefined)
574
+ return {};
575
+ if (!isRecord(value))
576
+ throw new HttpError(400, 'values is {column: text or null}');
577
+ for (const cell of Object.values(value)) {
578
+ if (cell !== null && typeof cell !== 'string') {
579
+ throw new HttpError(400, 'values is {column: text or null}');
580
+ }
581
+ }
582
+ return value;
583
+ };
584
+ const keyed = (value) => {
585
+ const key = cells(value);
586
+ if (Object.values(key).some((cell) => cell === null)) {
587
+ throw new HttpError(400, 'a key has no NULL values');
588
+ }
589
+ return key;
590
+ };
591
+ let request;
592
+ if (body.action === 'insert')
593
+ request = { action: 'insert', values: cells(body.values) };
594
+ else if (body.action === 'update') {
595
+ request = { action: 'update', key: keyed(body.key), values: cells(body.values) };
596
+ }
597
+ else if (body.action === 'delete')
598
+ request = { action: 'delete', key: keyed(body.key) };
599
+ else
600
+ throw new HttpError(400, 'action is insert, update or delete');
601
+ const ref = { schema: body.schema, name: body.table };
602
+ try {
603
+ return await withSessionFor(name, async (session) => {
604
+ const edit = buildEdit(ref, await listColumns(session, ref), request);
605
+ if (body.apply !== true)
606
+ return { sql: edit.display, applied: false };
607
+ if (session.readOnly) {
608
+ throw new HttpError(400, 'this environment is read only: turn on write mode first');
609
+ }
610
+ const result = await applyEdit(session, edit);
611
+ const entry = environmentNamed(name);
612
+ services.store.recordQuery({
613
+ envName: entry.name,
614
+ sql: edit.display,
615
+ durationMs: result.durationMs,
616
+ rowCount: 1,
617
+ });
618
+ return { sql: edit.display, applied: true };
619
+ });
620
+ }
621
+ catch (error) {
622
+ if (error instanceof HttpError)
623
+ throw error;
624
+ if (error instanceof EditError)
625
+ throw new HttpError(400, error.message);
626
+ throw new HttpError(400, describeQueryError(error));
627
+ }
628
+ }
629
+ if (resource === 'envs' && name && sub === 'seed') {
630
+ const entry = environmentNamed(name);
631
+ if (!entry.seeder) {
632
+ throw new HttpError(403, `the seeder is turned off for ${entry.name}: set seeder: true in its explorer.yaml to use it`);
633
+ }
634
+ if (req.method === 'GET') {
635
+ const schema = requiredParam(url, 'schema');
636
+ const table = requiredParam(url, 'table');
637
+ try {
638
+ const ref = { schema, name: table };
639
+ const [columns, foreignKeys] = await withSessionFor(name, async (session) => [
640
+ await describeSeedColumns(session, ref),
641
+ (await listForeignKeys(session, ref)).outgoing,
642
+ ]);
643
+ return {
644
+ columns,
645
+ foreignKeys,
646
+ generators,
647
+ locales: seedLocales,
648
+ maxRows: MAX_SEED_ROWS,
649
+ };
650
+ }
651
+ catch (error) {
652
+ throw new HttpError(400, describeQueryError(error));
653
+ }
654
+ }
655
+ if (req.method === 'POST') {
656
+ const body = (await readJson(req));
657
+ if (typeof body?.schema !== 'string' || typeof body.table !== 'string') {
658
+ throw new HttpError(400, 'send {"schema", "table", "count", "columns", "apply"?}');
659
+ }
660
+ const ref = { schema: body.schema, name: body.table };
661
+ try {
662
+ const request = parseSeedRequest(body);
663
+ return await withSessionFor(name, async (session) => {
664
+ if (body.apply !== true)
665
+ return { preview: await previewSeed(session, ref, request) };
666
+ if (session.readOnly) {
667
+ throw new HttpError(400, 'this environment is read only: turn on write mode first');
668
+ }
669
+ const result = await runSeed(session, ref, request);
670
+ services.store.recordQuery({
671
+ envName: entry.name,
672
+ sql: `-- seeder: ${result.inserted} generated rows\n${result.sql}`,
673
+ durationMs: result.durationMs,
674
+ rowCount: result.inserted,
675
+ });
676
+ return { result };
677
+ });
678
+ }
679
+ catch (error) {
680
+ if (error instanceof HttpError)
681
+ throw error;
682
+ if (error instanceof SeedError)
683
+ throw new HttpError(400, error.message);
684
+ throw new HttpError(400, describeQueryError(error));
685
+ }
686
+ }
687
+ }
688
+ if (resource === 'envs' && name && sub === 'design' && req.method === 'GET') {
689
+ environmentNamed(name);
690
+ const schema = requiredParam(url, 'schema');
691
+ const table = requiredParam(url, 'name');
692
+ const loaded = await withSessionFor(name, (session) => loadDesign(session, { schema, name: table }));
693
+ if (!loaded)
694
+ throw new HttpError(404, `no table ${schema}.${table}`);
695
+ return loaded;
696
+ }
697
+ if (resource === 'envs' && name && (sub === 'alter' || sub === 'drop-table') && req.method === 'POST') {
698
+ const entry = environmentNamed(name);
699
+ const body = (await readJson(req));
700
+ const lockTimeoutMs = typeof body?.lockTimeoutMs === 'number' ? body.lockTimeoutMs : 5000;
701
+ let statements;
702
+ let estimatedRows = null;
703
+ try {
704
+ if (sub === 'drop-table') {
705
+ const schema = body?.schema;
706
+ const table = body?.name;
707
+ if (typeof schema !== 'string' || typeof table !== 'string') {
708
+ throw new AlterError('send {"schema", "name", "cascade"?, "apply"?}');
709
+ }
710
+ statements = [dropTableStatement({ schema, name: table }, body?.cascade === true)];
711
+ }
712
+ else {
713
+ const before = parseTableDesign(body?.original);
714
+ const after = parseTableDesign(body?.design);
715
+ const options = parseAlterOptions(body?.options);
716
+ const loaded = await withSessionFor(name, (session) => loadDesign(session, before));
717
+ if (!loaded)
718
+ throw new HttpError(404, `no table ${before.schema}.${before.name}`);
719
+ estimatedRows = loaded.estimatedRows;
720
+ statements = diffDesigns(before, after, {
721
+ ...options,
722
+ estimatedRows,
723
+ serverMajor: loaded.serverMajor,
724
+ });
725
+ }
726
+ const plan = splitPlan(statements, lockTimeoutMs);
727
+ if (body?.apply !== true) {
728
+ return { statements, lockTimeout: plan.transactional[0]?.startsWith('SET LOCAL') ? plan.transactional[0] : null, estimatedRows };
729
+ }
730
+ if (!entry.designer) {
731
+ throw new HttpError(403, `the designer is turned off for ${entry.name}: set designer: true in its explorer.yaml to change tables`);
732
+ }
733
+ if (statements.length === 0)
734
+ throw new HttpError(400, 'there is nothing to change');
735
+ const session = await withSessionFor(name, (opened) => Promise.resolve(opened.readOnly));
736
+ if (session)
737
+ throw new HttpError(400, 'this environment is read only: turn on write mode first');
738
+ if (statements.some((statement) => statement.drop)) {
739
+ // Asked every time, like switching on write mode: it drops something that cannot come back.
740
+ const prompt = services.resolvers.get('prompt');
741
+ if (!prompt)
742
+ throw new HttpError(400, 'there is no way to ask for confirmation');
743
+ try {
744
+ await prompt.confirm({ envName: entry.name, interactive: true, signal, purpose: 'drop' });
745
+ }
746
+ catch (error) {
747
+ if (error instanceof ResolveError)
748
+ throw new HttpError(400, error.message);
749
+ throw error;
750
+ }
751
+ }
752
+ return await withSessionFor(name, async (opened) => {
753
+ const result = await runPlan(opened, plan, describeQueryError);
754
+ services.store.recordQuery({
755
+ envName: entry.name,
756
+ sql: [...plan.transactional, ...plan.concurrent].map((statement) => `${statement};`).join('\n'),
757
+ durationMs: result.durationMs,
758
+ });
759
+ return { statements, applied: true };
760
+ });
761
+ }
762
+ catch (error) {
763
+ if (error instanceof HttpError)
764
+ throw error;
765
+ if (error instanceof DdlError)
766
+ throw new HttpError(400, error.message);
767
+ if (error instanceof DesignRunError) {
768
+ throw new HttpError(400, error.message, { statement: error.statement });
769
+ }
770
+ throw new HttpError(400, describeQueryError(error));
771
+ }
772
+ }
773
+ if (resource === 'envs' && name && sub === 'design' && req.method === 'POST') {
774
+ const entry = environmentNamed(name);
775
+ const body = (await readJson(req));
776
+ let statements;
777
+ try {
778
+ const design = parseTableDesign(body?.design);
779
+ // The statements are built from the design alone; the server's own version only matters when
780
+ // they run, where it refuses what it lacks with its own message.
781
+ statements = buildDdl(design);
782
+ }
783
+ catch (error) {
784
+ if (error instanceof DdlError)
785
+ throw new HttpError(400, error.message);
786
+ throw error;
787
+ }
788
+ if (body?.apply !== true)
789
+ return { statements };
790
+ if (!entry.designer) {
791
+ throw new HttpError(403, `the designer is turned off for ${entry.name}: set designer: true in its explorer.yaml to create tables`);
792
+ }
793
+ try {
794
+ return await withSessionFor(name, async (session) => {
795
+ if (session.readOnly) {
796
+ throw new HttpError(400, 'this environment is read only: turn on write mode first');
797
+ }
798
+ const result = await runStatements(session, statements, describeQueryError);
799
+ services.store.recordQuery({
800
+ envName: entry.name,
801
+ sql: statements.map((statement) => `${statement};`).join('\n'),
802
+ durationMs: result.durationMs,
803
+ });
804
+ return { statements, applied: true };
805
+ });
806
+ }
807
+ catch (error) {
808
+ if (error instanceof HttpError)
809
+ throw error;
810
+ if (error instanceof DesignRunError) {
811
+ throw new HttpError(400, error.message, { statement: error.statement });
812
+ }
813
+ throw new HttpError(400, describeQueryError(error));
814
+ }
815
+ }
816
+ if (resource === 'envs' && name && sub === 'history' && req.method === 'GET') {
817
+ const entry = environmentNamed(name);
818
+ const text = (url.searchParams.get('q') ?? '').trim().toLowerCase();
819
+ const limit = Math.min(Math.max(Number(url.searchParams.get('limit')) || 100, 1), 500);
820
+ // Newest first; the search runs over all that is kept, not only what is shown.
821
+ return services.store
822
+ .historyFor(entry.name, 5000)
823
+ .filter((record) => !text || record.sql.toLowerCase().includes(text))
824
+ .slice(0, limit);
825
+ }
826
+ if (resource === 'envs' && name && sub === 'saved' && !id && req.method === 'GET') {
827
+ return services.store.savedQueries(environmentNamed(name).name);
828
+ }
829
+ if (resource === 'envs' && name && sub === 'saved' && !id && req.method === 'POST') {
830
+ const entry = environmentNamed(name);
831
+ const body = (await readJson(req));
832
+ if (typeof body?.sql !== 'string' || body.sql.trim() === '') {
833
+ throw new HttpError(400, 'send {"name", "sql", "folder"?, "pinned"?}');
834
+ }
835
+ return services.store.saveQuery({
836
+ envName: entry.name,
837
+ name: savedName(body.name, body.sql),
838
+ folder: typeof body.folder === 'string' ? body.folder.trim().slice(0, 60) : '',
839
+ sql: body.sql,
840
+ pinned: body.pinned === true,
841
+ });
842
+ }
843
+ if (resource === 'envs' &&
844
+ name &&
845
+ sub === 'saved' &&
846
+ id &&
847
+ (req.method === 'POST' || req.method === 'DELETE')) {
848
+ const entry = environmentNamed(name);
849
+ const savedId = Number(id);
850
+ // Only a query saved for this environment can be changed through it.
851
+ const found = services.store.savedQueries(entry.name).find((saved) => saved.id === savedId);
852
+ if (!found)
853
+ throw new HttpError(404, 'no such saved query');
854
+ if (req.method === 'DELETE') {
855
+ services.store.deleteSavedQuery(savedId);
856
+ return {};
857
+ }
858
+ const body = (await readJson(req));
859
+ return services.store.updateSavedQuery(savedId, {
860
+ ...(typeof body?.name === 'string' && body.name.trim()
861
+ ? { name: savedName(body.name, found.sql) }
862
+ : {}),
863
+ ...(typeof body?.folder === 'string' ? { folder: body.folder.trim().slice(0, 60) } : {}),
864
+ ...(typeof body?.sql === 'string' && body.sql.trim() ? { sql: body.sql } : {}),
865
+ ...(typeof body?.pinned === 'boolean' ? { pinned: body.pinned } : {}),
866
+ });
867
+ }
868
+ if (resource === 'envs' && name && sub === 'explain' && req.method === 'POST') {
869
+ const body = (await readJson(req));
870
+ if (typeof body?.sql !== 'string' || body.sql.trim() === '') {
871
+ throw new HttpError(400, 'send {"sql": "...", "analyze"?: true}');
872
+ }
873
+ const sql = body.sql;
874
+ try {
875
+ const gucs = parseGucs(body.settings);
876
+ // Analyzing runs the statement (and rolls it back) to measure each step.
877
+ if (body.analyze === true) {
878
+ return await withSessionFor(name, async (session) => {
879
+ await applyGucs(session, gucs);
880
+ return explainAnalyze(session, sql);
881
+ });
882
+ }
883
+ return {
884
+ plan: await withSessionFor(name, async (session) => {
885
+ await applyGucs(session, gucs);
886
+ return explainQuery(session, sql);
887
+ }),
888
+ };
889
+ }
890
+ catch (error) {
891
+ if (error instanceof HttpError)
892
+ throw error;
893
+ if (error instanceof GucError)
894
+ throw new HttpError(400, error.message);
895
+ throw new HttpError(400, describeQueryError(error));
896
+ }
897
+ }
898
+ throw new HttpError(404, 'not found');
899
+ }
900
+ async function serveStatic(res, pathname) {
901
+ if (!staticDir)
902
+ throw new HttpError(404, 'the web UI is not built; run npm run build:web');
903
+ const file = normalize(join(staticDir, pathname === '/' ? 'index.html' : pathname));
904
+ if (file !== staticDir && !file.startsWith(staticDir + sep))
905
+ throw new HttpError(403, 'forbidden');
906
+ let data;
907
+ let type = contentTypes[extname(file)] ?? 'application/octet-stream';
908
+ try {
909
+ data = await readFile(file);
910
+ }
911
+ catch {
912
+ // Not a file: the single page app handles the route itself.
913
+ if (extname(pathname))
914
+ throw new HttpError(404, 'not found');
915
+ data = await readFile(join(staticDir, 'index.html')).catch(() => {
916
+ throw new HttpError(404, 'the web UI is not built; run npm run build:web');
917
+ });
918
+ type = contentTypes['.html'];
919
+ }
920
+ res.writeHead(200, { 'content-type': type });
921
+ res.end(data);
922
+ }
923
+ return createServer((req, res) => {
924
+ void (async () => {
925
+ try {
926
+ if (!isLoopbackHost(req.headers.host))
927
+ throw new HttpError(403, 'forbidden host');
928
+ const url = new URL(req.url ?? '/', 'http://localhost');
929
+ if (url.pathname.startsWith('/api/')) {
930
+ // A request the browser gave up on stops waiting for answers to its questions.
931
+ const abort = new AbortController();
932
+ res.on('close', () => abort.abort());
933
+ sendJson(res, 200, await api(req, url, abort.signal));
934
+ }
935
+ else if (req.method === 'GET' || req.method === 'HEAD')
936
+ await serveStatic(res, url.pathname);
937
+ else
938
+ throw new HttpError(405, 'method not allowed');
939
+ }
940
+ catch (error) {
941
+ if (error instanceof HttpError) {
942
+ sendJson(res, error.status, { ...error.extra, error: error.message });
943
+ }
944
+ else {
945
+ sendJson(res, 500, { error: error instanceof Error ? error.message : String(error) });
946
+ }
947
+ }
948
+ })();
949
+ });
950
+ }
951
+ const PORT_ATTEMPTS = 100;
952
+ /**
953
+ * Starts listening on the loopback interface; resolves with the address once it is up. When the
954
+ * port is taken the next free one is used, so the caller compares the result with what it asked for.
955
+ */
956
+ export async function listen(server, port) {
957
+ for (let candidate = port; candidate < port + PORT_ATTEMPTS && candidate <= 65535; candidate++) {
958
+ try {
959
+ await listenOn(server, candidate);
960
+ return `http://127.0.0.1:${candidate}`;
961
+ }
962
+ catch (error) {
963
+ if (error.code !== 'EADDRINUSE')
964
+ throw error;
965
+ }
966
+ }
967
+ throw new Error(`no free port found from ${port} to ${port + PORT_ATTEMPTS - 1}`);
968
+ }
969
+ function listenOn(server, port) {
970
+ return new Promise((resolveListen, reject) => {
971
+ server.once('error', reject);
972
+ server.listen(port, '127.0.0.1', () => {
973
+ server.off('error', reject);
974
+ resolveListen();
975
+ });
976
+ });
977
+ }
978
+ /** Opens the address in the default browser; failing to is not an error, the address is printed. */
979
+ export function openBrowser(address) {
980
+ const [command, args] = process.platform === 'darwin'
981
+ ? ['open', [address]]
982
+ : process.platform === 'win32'
983
+ ? ['cmd', ['/c', 'start', '', address]]
984
+ : ['xdg-open', [address]];
985
+ const child = spawn(command, args, { stdio: 'ignore', detached: true });
986
+ child.on('error', () => { });
987
+ child.unref();
988
+ }