@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.
- package/README.md +229 -18
- package/RELEASES.md +95 -0
- package/dist/cli.js +57 -13
- package/dist/commands/configgen.js +25 -25
- package/dist/commands/explore.js +86 -4
- package/dist/config/load.js +11 -73
- package/dist/config/resolve-environment.js +10 -62
- package/dist/config/resolvers/index.js +1 -1
- package/dist/config/resolvers/prompt.js +2 -2
- package/dist/config/resolvers/vault.js +1 -83
- package/dist/db/store.js +57 -0
- package/dist/environments.js +5 -2
- package/dist/explorer/alter.js +493 -0
- package/dist/explorer/catalog.js +31 -0
- package/dist/explorer/connect-entry.js +29 -18
- package/dist/explorer/cypher-parse.js +478 -0
- package/dist/explorer/cypher.js +963 -0
- package/dist/explorer/ddl.js +502 -0
- package/dist/explorer/dependents.js +222 -0
- package/dist/explorer/design-load.js +254 -0
- package/dist/explorer/design.js +45 -0
- package/dist/explorer/edit.js +108 -0
- package/dist/explorer/explain.js +44 -3
- package/dist/explorer/filter.js +121 -0
- package/dist/explorer/graph.js +679 -0
- package/dist/explorer/gucs.js +31 -0
- package/dist/explorer/profile.js +102 -0
- package/dist/explorer/references.js +95 -0
- package/dist/explorer/rows.js +100 -13
- package/dist/explorer/schema-diff.js +360 -0
- package/dist/explorer/search.js +309 -0
- package/dist/explorer/security.js +152 -0
- package/dist/explorer/seed.js +1376 -0
- package/dist/explorer/session.js +25 -1
- package/dist/explorer/stats.js +135 -0
- package/dist/explorer/timeout.js +13 -0
- package/dist/screens/environments.js +6 -3
- package/dist/screens/explorer.js +0 -3
- package/dist/screens/splash.js +4 -2
- package/dist/web/server.js +655 -22
- package/package.json +4 -1
- package/web/dist/assets/antonio-latin-400-normal-B7iMKlzw.woff +0 -0
- package/web/dist/assets/antonio-latin-400-normal-CZjzwNAN.woff2 +0 -0
- package/web/dist/assets/antonio-latin-700-normal-BY3mCh47.woff +0 -0
- package/web/dist/assets/antonio-latin-700-normal-DSH-lEDG.woff2 +0 -0
- package/web/dist/assets/antonio-latin-ext-400-normal-nE82dizb.woff2 +0 -0
- package/web/dist/assets/antonio-latin-ext-400-normal-qA9Jb92z.woff +0 -0
- package/web/dist/assets/antonio-latin-ext-700-normal-BdSkr58W.woff +0 -0
- package/web/dist/assets/antonio-latin-ext-700-normal-D-dvgqjr.woff2 +0 -0
- package/web/dist/assets/button-BiFx4Lyw.js +1 -0
- package/web/dist/assets/code-xml-CPEjNFOw.js +1 -0
- package/web/dist/assets/compare-view-COQ3yE76.js +2 -0
- package/web/dist/assets/copy-C2N9tUYJ.js +1 -0
- package/web/dist/assets/cypher-view-BzyL4mNU.js +13 -0
- package/web/dist/assets/design-view-BPm7dSTY.js +9 -0
- package/web/dist/assets/erd-view-DqGDws2_.js +1 -0
- package/web/dist/assets/format-sql-CoddYUOF.js +5 -0
- package/web/dist/assets/graph-force-ChCGXQUt.js +1 -0
- package/web/dist/assets/graph-link-C8P9s_0W.js +1 -0
- package/web/dist/assets/graph-view-BpoUrUCP.js +3 -0
- package/web/dist/assets/index-CpHnjRLv.js +10 -0
- package/web/dist/assets/index-OzJqnJWG.css +2 -0
- package/web/dist/assets/key-round-JiGCgCWS.js +1 -0
- package/web/dist/assets/loader-circle-BxDJOoJO.js +1 -0
- package/web/dist/assets/query-builder-view-CtRexMEX.js +4 -0
- package/web/dist/assets/rotate-ccw-CwdA-J3t.js +1 -0
- package/web/dist/assets/route-7eu-iAqD.js +15 -0
- package/web/dist/assets/seed-view-BIkBTiU6.js +7 -0
- package/web/dist/assets/shield-alert-keJIAND5.js +1 -0
- package/web/dist/assets/sliders-horizontal-DKtL_ql7.js +1 -0
- package/web/dist/assets/sql-tabs-DUSXdYCr.js +2 -0
- package/web/dist/assets/table-Drc_n1gF.js +1 -0
- package/web/dist/assets/triangle-alert-C5nF69rW.js +1 -0
- package/web/dist/assets/zone-CHDOrDBF.js +5 -0
- package/web/dist/index.html +9 -2
- package/web/dist/assets/index-BMLFCtFY.js +0 -17
- package/web/dist/assets/index-CMXJRL9O.css +0 -2
package/dist/web/server.js
CHANGED
|
@@ -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 {
|
|
11
|
-
import { describeCatalog, describeErd, describeFunction, describeRelation, describeSequence, describeType, listObjects, listSchemas, serverInfo, } from '../explorer/catalog.js';
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
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
|
-
|
|
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 >
|
|
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, `
|
|
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
|
-
//
|
|
83
|
-
|
|
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
|
|
140
|
+
let sessions;
|
|
86
141
|
try {
|
|
87
|
-
|
|
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(
|
|
157
|
+
return await work(sessions);
|
|
100
158
|
}
|
|
101
159
|
finally {
|
|
102
|
-
|
|
103
|
-
await
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) });
|