@kazurolabs/psql-explorer 0.1.0 → 0.3.1

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 (77) hide show
  1. package/README.md +229 -18
  2. package/RELEASES.md +95 -0
  3. package/dist/cli.js +57 -13
  4. package/dist/commands/configgen.js +25 -25
  5. package/dist/commands/explore.js +86 -4
  6. package/dist/config/load.js +11 -73
  7. package/dist/config/resolve-environment.js +10 -62
  8. package/dist/config/resolvers/index.js +1 -1
  9. package/dist/config/resolvers/prompt.js +2 -2
  10. package/dist/config/resolvers/vault.js +1 -83
  11. package/dist/db/store.js +57 -0
  12. package/dist/environments.js +5 -2
  13. package/dist/explorer/alter.js +493 -0
  14. package/dist/explorer/catalog.js +31 -0
  15. package/dist/explorer/connect-entry.js +29 -18
  16. package/dist/explorer/cypher-parse.js +478 -0
  17. package/dist/explorer/cypher.js +963 -0
  18. package/dist/explorer/ddl.js +502 -0
  19. package/dist/explorer/dependents.js +222 -0
  20. package/dist/explorer/design-load.js +254 -0
  21. package/dist/explorer/design.js +45 -0
  22. package/dist/explorer/edit.js +108 -0
  23. package/dist/explorer/explain.js +44 -3
  24. package/dist/explorer/filter.js +121 -0
  25. package/dist/explorer/graph.js +679 -0
  26. package/dist/explorer/gucs.js +31 -0
  27. package/dist/explorer/profile.js +102 -0
  28. package/dist/explorer/references.js +95 -0
  29. package/dist/explorer/rows.js +100 -13
  30. package/dist/explorer/schema-diff.js +360 -0
  31. package/dist/explorer/search.js +309 -0
  32. package/dist/explorer/security.js +152 -0
  33. package/dist/explorer/seed.js +1376 -0
  34. package/dist/explorer/session.js +25 -1
  35. package/dist/explorer/stats.js +135 -0
  36. package/dist/explorer/timeout.js +13 -0
  37. package/dist/screens/environments.js +6 -3
  38. package/dist/screens/explorer.js +0 -3
  39. package/dist/screens/splash.js +4 -2
  40. package/dist/web/server.js +655 -22
  41. package/package.json +4 -1
  42. package/web/dist/assets/antonio-latin-400-normal-B7iMKlzw.woff +0 -0
  43. package/web/dist/assets/antonio-latin-400-normal-CZjzwNAN.woff2 +0 -0
  44. package/web/dist/assets/antonio-latin-700-normal-BY3mCh47.woff +0 -0
  45. package/web/dist/assets/antonio-latin-700-normal-DSH-lEDG.woff2 +0 -0
  46. package/web/dist/assets/antonio-latin-ext-400-normal-nE82dizb.woff2 +0 -0
  47. package/web/dist/assets/antonio-latin-ext-400-normal-qA9Jb92z.woff +0 -0
  48. package/web/dist/assets/antonio-latin-ext-700-normal-BdSkr58W.woff +0 -0
  49. package/web/dist/assets/antonio-latin-ext-700-normal-D-dvgqjr.woff2 +0 -0
  50. package/web/dist/assets/button-BiFx4Lyw.js +1 -0
  51. package/web/dist/assets/code-xml-CPEjNFOw.js +1 -0
  52. package/web/dist/assets/compare-view-COQ3yE76.js +2 -0
  53. package/web/dist/assets/copy-C2N9tUYJ.js +1 -0
  54. package/web/dist/assets/cypher-view-BzyL4mNU.js +13 -0
  55. package/web/dist/assets/design-view-BPm7dSTY.js +9 -0
  56. package/web/dist/assets/erd-view-DqGDws2_.js +1 -0
  57. package/web/dist/assets/format-sql-CoddYUOF.js +5 -0
  58. package/web/dist/assets/graph-force-ChCGXQUt.js +1 -0
  59. package/web/dist/assets/graph-link-C8P9s_0W.js +1 -0
  60. package/web/dist/assets/graph-view-BpoUrUCP.js +3 -0
  61. package/web/dist/assets/index-CpHnjRLv.js +10 -0
  62. package/web/dist/assets/index-OzJqnJWG.css +2 -0
  63. package/web/dist/assets/key-round-JiGCgCWS.js +1 -0
  64. package/web/dist/assets/loader-circle-BxDJOoJO.js +1 -0
  65. package/web/dist/assets/query-builder-view-CtRexMEX.js +4 -0
  66. package/web/dist/assets/rotate-ccw-CwdA-J3t.js +1 -0
  67. package/web/dist/assets/route-7eu-iAqD.js +15 -0
  68. package/web/dist/assets/seed-view-BIkBTiU6.js +7 -0
  69. package/web/dist/assets/shield-alert-keJIAND5.js +1 -0
  70. package/web/dist/assets/sliders-horizontal-DKtL_ql7.js +1 -0
  71. package/web/dist/assets/sql-tabs-DUSXdYCr.js +2 -0
  72. package/web/dist/assets/table-Drc_n1gF.js +1 -0
  73. package/web/dist/assets/triangle-alert-C5nF69rW.js +1 -0
  74. package/web/dist/assets/zone-CHDOrDBF.js +5 -0
  75. package/web/dist/index.html +9 -2
  76. package/web/dist/assets/index-BMLFCtFY.js +0 -17
  77. package/web/dist/assets/index-CMXJRL9O.css +0 -2
@@ -7,13 +7,34 @@ import { readFile } from 'node:fs/promises';
7
7
  import { extname, join, normalize, resolve, sep } from 'node:path';
8
8
  import { ResolveError } from '../config/resolvers/index.js';
9
9
  import { addressOf, findEnvironment, forgetAnswers, listEnvironments } from '../environments.js';
10
- import { connectEntry, LoginError } from '../explorer/connect-entry.js';
11
- import { describeCatalog, describeErd, describeFunction, describeRelation, describeSequence, describeType, listObjects, listSchemas, serverInfo, } from '../explorer/catalog.js';
12
- import { explainQuery } from '../explorer/explain.js';
13
- import { browseRows, runPage } from '../explorer/rows.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';
14
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';
15
32
  export const DEFAULT_WEB_PORT = 7878;
16
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;
17
38
  const contentTypes = {
18
39
  '.html': 'text/html; charset=utf-8',
19
40
  '.js': 'text/javascript; charset=utf-8',
@@ -27,9 +48,13 @@ const contentTypes = {
27
48
  /** A request that cannot be answered as sent: the status says why. */
28
49
  class HttpError extends Error {
29
50
  status;
30
- constructor(status, message) {
51
+ extra;
52
+ constructor(status, message,
53
+ // More to send along with the message, e.g. which statement failed.
54
+ extra = {}) {
31
55
  super(message);
32
56
  this.status = status;
57
+ this.extra = extra;
33
58
  }
34
59
  }
35
60
  const sendJson = (res, status, body) => {
@@ -42,13 +67,41 @@ const requiredParam = (url, key) => {
42
67
  throw new HttpError(400, `${url.pathname.split('/').pop()} needs ?${key}=`);
43
68
  return value;
44
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
+ };
45
98
  const isLoopbackHost = (host) => {
46
99
  if (!host)
47
100
  return false;
48
101
  const name = host.startsWith('[') ? host.slice(0, host.indexOf(']') + 1) : host.split(':')[0];
49
102
  return name === 'localhost' || name === '127.0.0.1' || name === '[::1]';
50
103
  };
51
- async function readJson(req) {
104
+ async function readJson(req, maxBytes = MAX_BODY_BYTES) {
52
105
  if (!req.headers['content-type']?.startsWith('application/json')) {
53
106
  throw new HttpError(415, 'send application/json');
54
107
  }
@@ -56,7 +109,7 @@ async function readJson(req) {
56
109
  let size = 0;
57
110
  for await (const chunk of req) {
58
111
  size += chunk.length;
59
- if (size > MAX_BODY_BYTES)
112
+ if (size > maxBytes)
60
113
  throw new HttpError(413, 'request body is too large');
61
114
  chunks.push(chunk);
62
115
  }
@@ -73,18 +126,20 @@ export function createWebServer(options) {
73
126
  const environmentNamed = (name) => {
74
127
  const entry = findEnvironment(services, name);
75
128
  if (!entry)
76
- throw new HttpError(404, `no environment named "${name}"`);
129
+ throw new HttpError(404, `you do not have access to "${name}": it is not in your config`);
77
130
  return entry;
78
131
  };
79
132
  // Protected environments the person has switched to read write, by entry key. In memory only, so
80
133
  // a restart is read only again.
81
134
  const writable = new Set();
82
- // One connection per request keeps the scaffold simple; pooling comes with the real UI.
83
- async function withSession(name, work, signal) {
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) {
84
139
  const entry = environmentNamed(name);
85
- let session;
140
+ let sessions;
86
141
  try {
87
- session = await connectEntry(entry, services, {
142
+ sessions = await connectEntries(entry, services, count, {
88
143
  signal,
89
144
  ...(writable.has(entry.key) ? { readOnly: false } : {}),
90
145
  });
@@ -95,18 +150,23 @@ export function createWebServer(options) {
95
150
  forgetAnswers(entry, services);
96
151
  throw error;
97
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);
98
156
  try {
99
- return await work(session);
157
+ return await work(sessions);
100
158
  }
101
159
  finally {
102
- await session.close();
103
- await services.groups?.release(entry.name);
160
+ signal?.removeEventListener('abort', stop);
161
+ await Promise.all(sessions.map((session) => session.close().catch(() => undefined)));
104
162
  }
105
163
  }
164
+ const withSession = (name, work, signal) => withSessions(name, 1, ([session]) => work(session), signal);
106
165
  async function api(req, url, signal) {
107
166
  const withSessionFor = (name, work) => withSession(name, work, signal);
167
+ const withSessionsFor = (name, count, work) => withSessions(name, count, work, signal);
108
168
  const parts = url.pathname.split('/').slice(2).map(decodeURIComponent);
109
- const [resource, name, sub] = parts;
169
+ const [resource, name, sub, id] = parts;
110
170
  if (resource === 'prompt' && !name && req.method === 'GET') {
111
171
  const pending = services.prompts?.current();
112
172
  return {
@@ -143,6 +203,8 @@ export function createWebServer(options) {
143
203
  tier: entry.tier,
144
204
  protected: entry.protected,
145
205
  readOnly: entry.readOnly,
206
+ seeder: entry.seeder,
207
+ designer: entry.designer,
146
208
  address: addressOf(entry),
147
209
  tls: entry.tlsMode,
148
210
  })),
@@ -189,9 +251,96 @@ export function createWebServer(options) {
189
251
  if (resource === 'envs' && name && sub === 'catalog' && req.method === 'GET') {
190
252
  return withSessionFor(name, (session) => describeCatalog(session));
191
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
+ }
192
274
  if (resource === 'envs' && name && sub === 'info' && req.method === 'GET') {
193
275
  return withSessionFor(name, (session) => serverInfo(session));
194
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
+ }
195
344
  if (resource === 'envs' && name && sub === 'objects' && req.method === 'GET') {
196
345
  const schema = requiredParam(url, 'schema');
197
346
  return withSessionFor(name, (session) => listObjects(session, schema));
@@ -222,7 +371,107 @@ export function createWebServer(options) {
222
371
  const limit = Math.min(Math.max(Number(url.searchParams.get('limit')) || 100, 1), 1000);
223
372
  const offset = Math.max(Number(url.searchParams.get('offset')) || 0, 0);
224
373
  try {
225
- return await withSessionFor(name, (session) => browseRows(session, { schema, name: table }, { limit, offset }));
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));
226
475
  }
227
476
  catch (error) {
228
477
  if (error instanceof HttpError)
@@ -233,32 +482,416 @@ export function createWebServer(options) {
233
482
  if (resource === 'envs' && name && sub === 'query' && req.method === 'POST') {
234
483
  const body = (await readJson(req));
235
484
  if (typeof body?.sql !== 'string' || body.sql.trim() === '') {
236
- throw new HttpError(400, 'send {"sql": "..."}');
485
+ throw new HttpError(400, 'send {"sql": "...", "asWritten"?: true}');
237
486
  }
238
487
  const sql = body.sql;
239
488
  const limit = Math.min(Math.max(Number(body.limit) || 100, 1), 1000);
240
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, asWritten: body.asWritten === true });
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;
241
777
  try {
242
- return await withSessionFor(name, (session) => runPage(session, sql, { limit, offset }));
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
+ });
243
806
  }
244
807
  catch (error) {
245
808
  if (error instanceof HttpError)
246
809
  throw error;
810
+ if (error instanceof DesignRunError) {
811
+ throw new HttpError(400, error.message, { statement: error.statement });
812
+ }
247
813
  throw new HttpError(400, describeQueryError(error));
248
814
  }
249
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
+ }
250
868
  if (resource === 'envs' && name && sub === 'explain' && req.method === 'POST') {
251
869
  const body = (await readJson(req));
252
870
  if (typeof body?.sql !== 'string' || body.sql.trim() === '') {
253
- throw new HttpError(400, 'send {"sql": "..."}');
871
+ throw new HttpError(400, 'send {"sql": "...", "analyze"?: true}');
254
872
  }
255
873
  const sql = body.sql;
256
874
  try {
257
- return { plan: await withSessionFor(name, (session) => explainQuery(session, sql)) };
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
+ };
258
889
  }
259
890
  catch (error) {
260
891
  if (error instanceof HttpError)
261
892
  throw error;
893
+ if (error instanceof GucError)
894
+ throw new HttpError(400, error.message);
262
895
  throw new HttpError(400, describeQueryError(error));
263
896
  }
264
897
  }
@@ -306,7 +939,7 @@ export function createWebServer(options) {
306
939
  }
307
940
  catch (error) {
308
941
  if (error instanceof HttpError) {
309
- sendJson(res, error.status, { error: error.message });
942
+ sendJson(res, error.status, { ...error.extra, error: error.message });
310
943
  }
311
944
  else {
312
945
  sendJson(res, 500, { error: error instanceof Error ? error.message : String(error) });