tablewalk 0.0.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/LICENSE +21 -0
- package/README.md +553 -0
- package/dist/adapters/adapter.js +372 -0
- package/dist/adapters/connect.js +33 -0
- package/dist/adapters/mysql.js +951 -0
- package/dist/adapters/postgres.js +1000 -0
- package/dist/adapters/sqlite.js +781 -0
- package/dist/client/agent.js +262 -0
- package/dist/client/app.js +973 -0
- package/dist/client/arrange.js +254 -0
- package/dist/client/ask.js +133 -0
- package/dist/client/breakdown.js +317 -0
- package/dist/client/clauses.js +390 -0
- package/dist/client/columns.js +98 -0
- package/dist/client/complete.js +437 -0
- package/dist/client/compose.js +166 -0
- package/dist/client/composer.css +495 -0
- package/dist/client/composer.js +1972 -0
- package/dist/client/connections.js +234 -0
- package/dist/client/connmanager.js +962 -0
- package/dist/client/connurl.js +188 -0
- package/dist/client/core.js +893 -0
- package/dist/client/deeplink.js +270 -0
- package/dist/client/delete.js +144 -0
- package/dist/client/diagram.js +885 -0
- package/dist/client/dropdown.js +279 -0
- package/dist/client/export.js +456 -0
- package/dist/client/features.css +524 -0
- package/dist/client/findvalue.js +169 -0
- package/dist/client/grid.js +205 -0
- package/dist/client/handoff.js +153 -0
- package/dist/client/help.css +145 -0
- package/dist/client/help.js +881 -0
- package/dist/client/history.js +222 -0
- package/dist/client/index.html +116 -0
- package/dist/client/insert.js +151 -0
- package/dist/client/menu.js +160 -0
- package/dist/client/nested.js +255 -0
- package/dist/client/page.css +713 -0
- package/dist/client/page.js +1345 -0
- package/dist/client/pagebuilder.js +1222 -0
- package/dist/client/pagemarks.js +95 -0
- package/dist/client/palette.js +374 -0
- package/dist/client/peek.js +254 -0
- package/dist/client/picker.js +139 -0
- package/dist/client/pins.js +140 -0
- package/dist/client/prompt.js +129 -0
- package/dist/client/record.js +707 -0
- package/dist/client/schemaexport.js +242 -0
- package/dist/client/schematext.js +125 -0
- package/dist/client/shape.js +178 -0
- package/dist/client/shapecheck.js +129 -0
- package/dist/client/skeleton.js +139 -0
- package/dist/client/sql.css +126 -0
- package/dist/client/sql.js +398 -0
- package/dist/client/sqlcomplete.js +163 -0
- package/dist/client/sqlsaved.js +107 -0
- package/dist/client/style.css +2711 -0
- package/dist/client/summary.js +259 -0
- package/dist/client/table.js +1035 -0
- package/dist/client/template.js +539 -0
- package/dist/client/theme.js +74 -0
- package/dist/client/tour.js +324 -0
- package/dist/client/undo.js +105 -0
- package/dist/client/url.js +166 -0
- package/dist/client/value.js +223 -0
- package/dist/client/views.js +215 -0
- package/dist/client/virtual.js +176 -0
- package/dist/client/welcome.js +170 -0
- package/dist/client/write.js +414 -0
- package/dist/server/changeimpact.js +195 -0
- package/dist/server/connections.js +615 -0
- package/dist/server/constraints.js +62 -0
- package/dist/server/credentials.js +230 -0
- package/dist/server/fixture.js +199 -0
- package/dist/server/graph.js +194 -0
- package/dist/server/impact.js +48 -0
- package/dist/server/index.js +2204 -0
- package/dist/server/journal.js +173 -0
- package/dist/server/layouts.js +128 -0
- package/dist/server/mcp.js +2840 -0
- package/dist/server/shapeonly.js +91 -0
- package/dist/shared/breakdown.js +231 -0
- package/dist/shared/breakdowntext.js +257 -0
- package/dist/shared/diff.js +130 -0
- package/dist/shared/like.js +29 -0
- package/dist/shared/lint.js +149 -0
- package/dist/shared/order.js +133 -0
- package/dist/shared/page.js +932 -0
- package/dist/shared/query.js +831 -0
- package/dist/shared/recordview.js +343 -0
- package/dist/shared/schema.js +377 -0
- package/dist/shared/sqlsaved.js +67 -0
- package/dist/shared/view.js +981 -0
- package/dist/shared/viewtext.js +273 -0
- package/dist/shared/vocabulary.js +164 -0
- package/package.json +57 -0
|
@@ -0,0 +1,2204 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* The server: one connection, a small read-only JSON API, and the client.
|
|
4
|
+
*
|
|
5
|
+
* It is deliberately not a general database proxy. Every endpoint maps to one
|
|
6
|
+
* thing the browser needs, takes a structured request rather than SQL, and
|
|
7
|
+
* hands the adapter a shape that cannot express a write. That constraint is
|
|
8
|
+
* what makes it safe to point at a database you care about.
|
|
9
|
+
*/
|
|
10
|
+
import { createServer, } from 'node:http';
|
|
11
|
+
import { readFile } from 'node:fs/promises';
|
|
12
|
+
import { extname, join, normalize } from 'node:path';
|
|
13
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
14
|
+
import { realpathSync } from 'node:fs';
|
|
15
|
+
import { ANSI_STYLE, ESTIMATE_ABOVE, MYSQL_STYLE, POSTGRES_STYLE, clampLimit, clampOffset, } from '../adapters/adapter.js';
|
|
16
|
+
import { Registry, loadConfig, redact, configLocations, saveConfig } from './connections.js';
|
|
17
|
+
import { Refusal } from '../adapters/adapter.js';
|
|
18
|
+
import { defaultColumns, listView, metricColumns, metricView, parsePagesConfig, relationsTo, resolveOf, resolvePath, sectionQuery, suggestPage, validatePage, } from '../shared/page.js';
|
|
19
|
+
import { layoutsFor, pagesPath, writeLayout } from './layouts.js';
|
|
20
|
+
import { scrubSecrets, storeSecret } from './connections.js';
|
|
21
|
+
import { connect } from '../adapters/connect.js';
|
|
22
|
+
import { resolveConnection } from './credentials.js';
|
|
23
|
+
import { parseLayoutsConfig } from '../shared/recordview.js';
|
|
24
|
+
import { history, lastRevertible, recordWrite, revert, summarise } from './journal.js';
|
|
25
|
+
import { agentTools, runTool, serveMcp, setAdvertiseOutputSchema, setToolProfile } from './mcp.js';
|
|
26
|
+
import { deleteImpact, inBatches } from './impact.js';
|
|
27
|
+
import { explainWriteFailure } from './constraints.js';
|
|
28
|
+
import { referencesFrom, referencesTo, findTable, labelColumn, primaryKey, renderDDL, resolveOrder, tableNamed, } from '../shared/schema.js';
|
|
29
|
+
import { parseQuery, explain } from '../shared/query.js';
|
|
30
|
+
import { PathError, compileView, explainView, parseViewQuery, parseViewsConfig, validateView, } from '../shared/view.js';
|
|
31
|
+
import { textToView, viewToTextWithSchema } from '../shared/viewtext.js';
|
|
32
|
+
import { breakdownToText, textToBreakdown } from '../shared/breakdowntext.js';
|
|
33
|
+
import { explainBreakdown } from '../shared/breakdown.js';
|
|
34
|
+
import { parseQueriesConfig, queriesFor } from '../shared/sqlsaved.js';
|
|
35
|
+
const HERE = fileURLToPath(new URL('.', import.meta.url));
|
|
36
|
+
const CLIENT_DIR = join(HERE, '..', 'client');
|
|
37
|
+
function parseArgs(argv) {
|
|
38
|
+
/* Loopback by default. A database browser listening on every interface is
|
|
39
|
+
one shared wifi away from being someone else's database browser, and the
|
|
40
|
+
old `listen(port)` did exactly that. Binding elsewhere is possible but
|
|
41
|
+
has to be asked for. */
|
|
42
|
+
const opts = {
|
|
43
|
+
target: '', port: 4111, host: '127.0.0.1', schemas: [], open: false, noConfig: false,
|
|
44
|
+
allowedHosts: [], mcp: false, outputSchema: false, tools: '',
|
|
45
|
+
help: false, version: false,
|
|
46
|
+
};
|
|
47
|
+
for (let i = 0; i < argv.length; i++) {
|
|
48
|
+
const arg = argv[i];
|
|
49
|
+
if (arg === '--db' || arg === '-d')
|
|
50
|
+
opts.target = argv[++i] ?? '';
|
|
51
|
+
else if (arg === '--port' || arg === '-p')
|
|
52
|
+
opts.port = Number(argv[++i] ?? 4111);
|
|
53
|
+
else if (arg === '--host')
|
|
54
|
+
opts.host = argv[++i] ?? '127.0.0.1';
|
|
55
|
+
else if (arg === '--allowed-host')
|
|
56
|
+
opts.allowedHosts.push(argv[++i] ?? '');
|
|
57
|
+
else if (arg === '--schema')
|
|
58
|
+
opts.schemas.push(argv[++i] ?? '');
|
|
59
|
+
else if (arg === '--config')
|
|
60
|
+
opts.config = argv[++i] ?? '';
|
|
61
|
+
else if (arg === '--no-config')
|
|
62
|
+
opts.noConfig = true;
|
|
63
|
+
else if (arg === '--open')
|
|
64
|
+
opts.open = true;
|
|
65
|
+
else if (arg === '--mcp')
|
|
66
|
+
opts.mcp = true;
|
|
67
|
+
else if (arg === '--output-schema')
|
|
68
|
+
opts.outputSchema = true;
|
|
69
|
+
else if (arg === '--tools')
|
|
70
|
+
opts.tools = argv[++i] ?? '';
|
|
71
|
+
else if (arg === '--export')
|
|
72
|
+
opts.export = argv[++i] ?? '';
|
|
73
|
+
/* `--lint` takes no argument by default, so `tablewalk --lint --db x`
|
|
74
|
+
must not eat the next flag as a format. */
|
|
75
|
+
else if (arg === '--lint') {
|
|
76
|
+
opts.lint = argv[i + 1] && !argv[i + 1].startsWith('-') ? argv[++i] : 'text';
|
|
77
|
+
}
|
|
78
|
+
else if (arg === '--fail-on')
|
|
79
|
+
opts.failOn = argv[++i] ?? '';
|
|
80
|
+
else if (arg === '--diff')
|
|
81
|
+
opts.diff = argv[++i] ?? '';
|
|
82
|
+
else if (arg === '--help' || arg === '-h')
|
|
83
|
+
opts.help = true;
|
|
84
|
+
else if (arg === '--version' || arg === '-v' || arg === '-V')
|
|
85
|
+
opts.version = true;
|
|
86
|
+
else if (!arg.startsWith('-') && !opts.target)
|
|
87
|
+
opts.target = arg;
|
|
88
|
+
/* An unknown flag is a mistake, and saying so is cheap.
|
|
89
|
+
|
|
90
|
+
It used to be ignored. That was survivable only by accident: with no
|
|
91
|
+
target, the "nothing to open" gate printed the usage and exited, so
|
|
92
|
+
`tablewalk --prot 4111` looked like it had told you something. Now that
|
|
93
|
+
a server starts with nothing, the same typo silently starts one on the
|
|
94
|
+
default port and waits — the least useful thing it could do with a
|
|
95
|
+
command it did not understand. */
|
|
96
|
+
else if (arg.startsWith('-') && !opts.unknown)
|
|
97
|
+
opts.unknown = arg;
|
|
98
|
+
}
|
|
99
|
+
if (!opts.target)
|
|
100
|
+
opts.target = process.env.DATABASE_URL ?? '';
|
|
101
|
+
/* The env var exists for containers, where the name someone reaches tablewalk
|
|
102
|
+
by is a property of the deployment rather than of the command line — a
|
|
103
|
+
compose file sets it once, and the image's own CMD stays untouched. */
|
|
104
|
+
opts.allowedHosts.push(...parseHostList(process.env.TABLEWALK_ALLOWED_HOSTS));
|
|
105
|
+
opts.allowedHosts = opts.allowedHosts.filter(Boolean);
|
|
106
|
+
return opts;
|
|
107
|
+
}
|
|
108
|
+
/** Split a `TABLEWALK_ALLOWED_HOSTS` value. Commas or spaces, either way. */
|
|
109
|
+
export function parseHostList(value) {
|
|
110
|
+
return (value ?? '').split(/[,\s]+/).map((s) => s.trim()).filter(Boolean);
|
|
111
|
+
}
|
|
112
|
+
const USAGE = `
|
|
113
|
+
tablewalk — browse a relational database as a graph you walk.
|
|
114
|
+
|
|
115
|
+
tablewalk start with nothing; the page asks for a connection
|
|
116
|
+
tablewalk <sqlite-file>
|
|
117
|
+
tablewalk postgres://user:pass@host/db
|
|
118
|
+
tablewalk --db postgres://… --schema public --port 4111
|
|
119
|
+
|
|
120
|
+
Options:
|
|
121
|
+
-d, --db <target> SQLite file path or postgres:// URL (or set DATABASE_URL)
|
|
122
|
+
-p, --port <n> Port to listen on (default 4111)
|
|
123
|
+
--host <addr> Interface to bind (default 127.0.0.1, loopback only)
|
|
124
|
+
--allowed-host <name>
|
|
125
|
+
Also answer for this Host header; repeatable.
|
|
126
|
+
Needed behind a proxy or in a container reached by name.
|
|
127
|
+
Or set TABLEWALK_ALLOWED_HOSTS=a.example,b.example
|
|
128
|
+
--schema <name> Postgres only; repeatable. Defaults to all user schemas.
|
|
129
|
+
--config <path> Read connections from this file
|
|
130
|
+
--no-config Ignore any config file
|
|
131
|
+
--open Open a browser once the server is up
|
|
132
|
+
--mcp Speak MCP on stdio for coding agents, instead of HTTP
|
|
133
|
+
--tools <name> Offer one job's worth of tools instead of all of them:
|
|
134
|
+
explore, migrate, seed, or full (the default).
|
|
135
|
+
--output-schema Advertise each tool's output shape in tools/list. Off by
|
|
136
|
+
default: it is half the listing's size, and most agents
|
|
137
|
+
never read it. Answers carry the same fields either way.
|
|
138
|
+
--export <fmt> Print the schema as md, mermaid or json, then exit.
|
|
139
|
+
--lint [fmt] Report what the shape will cost — missing keys,
|
|
140
|
+
unindexed references, naive timestamps — as text or
|
|
141
|
+
json, then exit.
|
|
142
|
+
--fail-on <sev> With --lint, exit non-zero when a finding is at least
|
|
143
|
+
this severe: high, medium or low. For CI.
|
|
144
|
+
--diff <conn> Compare the active connection's shape with this one,
|
|
145
|
+
print the differences, then exit. Exit code 1 when they
|
|
146
|
+
differ, the way diff(1) does.
|
|
147
|
+
|
|
148
|
+
Connections can also come from a tablewalk.json:
|
|
149
|
+
|
|
150
|
+
{ "connections": [ { "name": "app", "url": "postgres://localhost/app" } ] }
|
|
151
|
+
|
|
152
|
+
Looked for in ./tablewalk.json, ./.tablewalk.json, then
|
|
153
|
+
$XDG_CONFIG_HOME/tablewalk/connections.json.
|
|
154
|
+
|
|
155
|
+
Browsing is read-only. Editing is off by default and enabled in the UI.
|
|
156
|
+
`.trim();
|
|
157
|
+
/** What the package says it is, best effort. */
|
|
158
|
+
async function packageVersion() {
|
|
159
|
+
try {
|
|
160
|
+
const path = join(fileURLToPath(new URL('.', import.meta.url)), '..', '..', 'package.json');
|
|
161
|
+
return JSON.parse(await readFile(path, 'utf8')).version ?? '0.0.0';
|
|
162
|
+
}
|
|
163
|
+
catch {
|
|
164
|
+
return '0.0.0';
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
async function main() {
|
|
168
|
+
const opts = parseArgs(process.argv.slice(2));
|
|
169
|
+
/* Before anything else, and before any of it can start a server.
|
|
170
|
+
|
|
171
|
+
None of the three was handled. `--help` and `--version` were ignored and
|
|
172
|
+
then rescued by the "nothing to open" gate, which printed the usage and
|
|
173
|
+
exited 1 — the right text, the wrong exit code, and only ever by
|
|
174
|
+
accident. Now that a server starts with no connection, the accident
|
|
175
|
+
became `tablewalk --help` sitting on port 4111 waiting for a browser. */
|
|
176
|
+
if (opts.help) {
|
|
177
|
+
console.log(USAGE);
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
if (opts.version) {
|
|
181
|
+
console.log(await packageVersion());
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
if (opts.unknown) {
|
|
185
|
+
console.error(`tablewalk: unknown option "${opts.unknown}".\n`);
|
|
186
|
+
console.error(USAGE);
|
|
187
|
+
process.exit(1);
|
|
188
|
+
}
|
|
189
|
+
/* In MCP mode stdout is the protocol channel; in export mode it is the
|
|
190
|
+
document — `--export md >> CLAUDE.md` must append a brief, not a brief
|
|
191
|
+
with a startup banner stapled to its forehead. Every startup line goes
|
|
192
|
+
to stderr in both, redirected here, once, rather than guarded at each
|
|
193
|
+
of the nine call sites. */
|
|
194
|
+
if (opts.mcp || opts.export || opts.lint || opts.diff) {
|
|
195
|
+
console.log = (...args) => console.error(...args);
|
|
196
|
+
}
|
|
197
|
+
const registry = new Registry();
|
|
198
|
+
/** The file this session's connections were read from, and written back to. */
|
|
199
|
+
let configPath;
|
|
200
|
+
/** Views predefined in the config file. Read once, at startup, like connections. */
|
|
201
|
+
let configViews = [];
|
|
202
|
+
/** SQL statements predefined in the config file, read from the same place. */
|
|
203
|
+
let configQueries = [];
|
|
204
|
+
let configPages = [];
|
|
205
|
+
let configLayouts = [];
|
|
206
|
+
/* Config first, then the command line, so an explicitly named database is
|
|
207
|
+
the one you land on even when a config file lists others. */
|
|
208
|
+
if (!opts.noConfig) {
|
|
209
|
+
try {
|
|
210
|
+
const config = await loadConfig(opts.config);
|
|
211
|
+
if (config) {
|
|
212
|
+
// Remembered so "Save" writes back to the file this session was
|
|
213
|
+
// started from, rather than to whichever path happens to sort first.
|
|
214
|
+
configPath = config.path;
|
|
215
|
+
for (const c of config.connections) {
|
|
216
|
+
registry.add({
|
|
217
|
+
name: c.name,
|
|
218
|
+
url: c.url,
|
|
219
|
+
schemas: c.schemas,
|
|
220
|
+
// Opt in, per connection. Absent means read-only, which is the
|
|
221
|
+
// right default for a file someone may have copied from a
|
|
222
|
+
// colleague without reading every line of.
|
|
223
|
+
writable: c.writable === true,
|
|
224
|
+
/* Opt *out*, per connection: the default is a normal connection,
|
|
225
|
+
and `"rows": false` is someone deciding this database's rows
|
|
226
|
+
must not leave the process. */
|
|
227
|
+
rows: c.rows === false ? false : undefined,
|
|
228
|
+
source: 'config',
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
console.log(`tablewalk: ${config.connections.length} connection(s) from ${config.path}`);
|
|
232
|
+
/* Re-read rather than widening `loadConfig`'s return: connections are
|
|
233
|
+
the file's reason to exist and views are a second, independent
|
|
234
|
+
section of it. One extra read of a small file at startup buys the
|
|
235
|
+
connection loader staying exactly as it was. */
|
|
236
|
+
configViews = parseViewsConfig(JSON.parse(await readFile(config.path, 'utf8')), config.path);
|
|
237
|
+
if (configViews.length) {
|
|
238
|
+
console.log(`tablewalk: ${configViews.length} view(s) from ${config.path}`);
|
|
239
|
+
}
|
|
240
|
+
configQueries = parseQueriesConfig(JSON.parse(await readFile(config.path, 'utf8')), config.path);
|
|
241
|
+
if (configQueries.length) {
|
|
242
|
+
console.log(`tablewalk: ${configQueries.length} saved SQL statement(s) from ${config.path}`);
|
|
243
|
+
}
|
|
244
|
+
configPages = parsePagesConfig(JSON.parse(await readFile(config.path, 'utf8')), config.path);
|
|
245
|
+
if (configPages.length) {
|
|
246
|
+
console.log(`tablewalk: ${configPages.length} page(s) from ${config.path}`);
|
|
247
|
+
}
|
|
248
|
+
configLayouts = parseLayoutsConfig(JSON.parse(await readFile(config.path, 'utf8')), config.path);
|
|
249
|
+
if (configLayouts.length) {
|
|
250
|
+
console.log(`tablewalk: ${configLayouts.length} record layout(s) from ${config.path}`);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
catch (err) {
|
|
255
|
+
// A config that exists but is wrong is reported, never skipped:
|
|
256
|
+
// silently ignoring a file someone wrote is worse than refusing.
|
|
257
|
+
console.error(`Config error: ${err.message}`);
|
|
258
|
+
process.exit(1);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
if (opts.target) {
|
|
262
|
+
const added = registry.add({
|
|
263
|
+
name: redact(opts.target),
|
|
264
|
+
url: opts.target,
|
|
265
|
+
schemas: opts.schemas,
|
|
266
|
+
source: 'argument',
|
|
267
|
+
});
|
|
268
|
+
registry.setActive(added.id);
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* Nothing named a database.
|
|
272
|
+
*
|
|
273
|
+
* Every mode below except the server needs one before it can do anything at
|
|
274
|
+
* all: `--export` prints a schema, `--lint` reads one, `--diff` compares
|
|
275
|
+
* two, and every MCP tool is a question about a database. There is nothing
|
|
276
|
+
* for them to start without, and an agent on stdio has no way to supply one
|
|
277
|
+
* — so those still stop here, with the usage and the list of places a
|
|
278
|
+
* config was looked for.
|
|
279
|
+
*
|
|
280
|
+
* The server does not, and used to. Someone running `tablewalk` for the
|
|
281
|
+
* first time, having installed it to find out what it is, got a wall of
|
|
282
|
+
* flags and no program — while the thing that would have answered their
|
|
283
|
+
* question, a dialog that takes a connection and tests it before keeping
|
|
284
|
+
* it, was already built and sitting one successful startup away. The
|
|
285
|
+
* browser can ask. So it starts, and asks.
|
|
286
|
+
*/
|
|
287
|
+
const nothingToOpen = !registry.list().length;
|
|
288
|
+
if (nothingToOpen && (opts.mcp || opts.export || opts.lint || opts.diff)) {
|
|
289
|
+
console.log(USAGE);
|
|
290
|
+
console.log(`\nLooked for a config file in:\n ${configLocations(opts.config).join('\n ')}`);
|
|
291
|
+
process.exit(1);
|
|
292
|
+
}
|
|
293
|
+
/* The active connection is opened eagerly so a bad target is a startup
|
|
294
|
+
error with a clear message rather than a broken page. The rest stay
|
|
295
|
+
closed until someone selects them — naming eight databases should not
|
|
296
|
+
mean dialling eight databases. */
|
|
297
|
+
if (!nothingToOpen) {
|
|
298
|
+
const activeId = registry.active;
|
|
299
|
+
try {
|
|
300
|
+
const { schema } = await registry.open(activeId);
|
|
301
|
+
console.log(`tablewalk: ${schema.label} (${schema.dialect}) — ${schema.tables.length} tables, ${schema.foreignKeys.length} foreign keys`);
|
|
302
|
+
}
|
|
303
|
+
catch (err) {
|
|
304
|
+
console.error(`Could not open the active connection:`);
|
|
305
|
+
console.error(` ${err.message}`);
|
|
306
|
+
process.exit(1);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
if (opts.export) {
|
|
310
|
+
/* Print and exit: the same text the browser's Export menu downloads,
|
|
311
|
+
importable here because the converters are pure — a schema in, text
|
|
312
|
+
out. `tablewalk --export md >> CLAUDE.md` is the whole workflow this
|
|
313
|
+
exists for: an agent's next session starts oriented for zero calls. */
|
|
314
|
+
const { toMarkdown, toMermaid, toSchemaJson } = await import('../client/schematext.js');
|
|
315
|
+
const { schema } = await registry.open(registry.active);
|
|
316
|
+
const format = opts.export.toLowerCase();
|
|
317
|
+
const text = format === 'md' || format === 'markdown' ? toMarkdown(schema)
|
|
318
|
+
: format === 'mermaid' ? toMermaid(schema)
|
|
319
|
+
: format === 'json' ? JSON.stringify(toSchemaJson(schema), null, 2)
|
|
320
|
+
: null;
|
|
321
|
+
if (text === null) {
|
|
322
|
+
console.error(`--export takes md, mermaid or json — not "${opts.export}".`);
|
|
323
|
+
process.exit(1);
|
|
324
|
+
}
|
|
325
|
+
process.stdout.write(`${text}\n`);
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
if (opts.diff) {
|
|
329
|
+
const { diffSchemas, diffReport } = await import('../shared/diff.js');
|
|
330
|
+
const mine = await registry.open(registry.active);
|
|
331
|
+
let theirs;
|
|
332
|
+
try {
|
|
333
|
+
theirs = await registry.open(opts.diff);
|
|
334
|
+
}
|
|
335
|
+
catch (err) {
|
|
336
|
+
console.error(err.message);
|
|
337
|
+
process.exit(1);
|
|
338
|
+
}
|
|
339
|
+
const diff = diffSchemas(mine.schema, theirs.schema);
|
|
340
|
+
process.stdout.write(`${diffReport(diff)}\n`);
|
|
341
|
+
/* diff(1)'s convention, and the one a CI step wants: zero means the same,
|
|
342
|
+
one means different. Anything else here would need a flag to mean what
|
|
343
|
+
every other diff already means without one. */
|
|
344
|
+
if (!diff.same)
|
|
345
|
+
process.exit(1);
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
if (opts.lint) {
|
|
349
|
+
/* Print and exit, like --export: this is a build step, and a build step
|
|
350
|
+
that starts a web server is one nobody wires into CI. */
|
|
351
|
+
const { lintSchema, lintReport, lintCounts } = await import('../shared/lint.js');
|
|
352
|
+
const { schema } = await registry.open(registry.active);
|
|
353
|
+
const findings = lintSchema(schema);
|
|
354
|
+
const format = opts.lint.toLowerCase();
|
|
355
|
+
if (format !== 'text' && format !== 'json') {
|
|
356
|
+
console.error(`--lint takes text or json — not "${opts.lint}".`);
|
|
357
|
+
process.exit(1);
|
|
358
|
+
}
|
|
359
|
+
process.stdout.write(format === 'json'
|
|
360
|
+
? `${JSON.stringify({ connection: registry.active, counts: lintCounts(findings), findings }, null, 2)}\n`
|
|
361
|
+
: `${lintReport(findings)}\n`);
|
|
362
|
+
if (opts.failOn) {
|
|
363
|
+
const levels = ['high', 'medium', 'low'];
|
|
364
|
+
const at = levels.indexOf(opts.failOn.toLowerCase());
|
|
365
|
+
if (at === -1) {
|
|
366
|
+
console.error(`--fail-on takes high, medium or low — not "${opts.failOn}".`);
|
|
367
|
+
process.exit(1);
|
|
368
|
+
}
|
|
369
|
+
/* At least this severe: `--fail-on medium` fails on medium *and* high,
|
|
370
|
+
which is the reading that makes a threshold useful. */
|
|
371
|
+
const worst = levels.slice(0, at + 1);
|
|
372
|
+
if (findings.some((f) => worst.includes(f.severity)))
|
|
373
|
+
process.exit(1);
|
|
374
|
+
}
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
if (opts.mcp) {
|
|
378
|
+
/* No HTTP server at all: the process is spawned by the agent's client,
|
|
379
|
+
speaks on stdio, and exits when stdin closes. The version is what the
|
|
380
|
+
package says it is, best-effort — serverInfo is a courtesy field. */
|
|
381
|
+
let version = '0.0.0';
|
|
382
|
+
try {
|
|
383
|
+
const packagePath = join(fileURLToPath(new URL('.', import.meta.url)), '..', '..', 'package.json');
|
|
384
|
+
version = JSON.parse(await readFile(packagePath, 'utf8')).version ?? version;
|
|
385
|
+
}
|
|
386
|
+
catch { /* The default says enough. */ }
|
|
387
|
+
/* Before the server starts answering, and fatal if it does not name a
|
|
388
|
+
profile: a typo that silently fell back to every tool would be a
|
|
389
|
+
surprise measured in tokens rather than in errors. */
|
|
390
|
+
try {
|
|
391
|
+
setToolProfile(opts.tools);
|
|
392
|
+
}
|
|
393
|
+
catch (err) {
|
|
394
|
+
console.error(`tablewalk: ${err.message}`);
|
|
395
|
+
process.exit(1);
|
|
396
|
+
}
|
|
397
|
+
setAdvertiseOutputSchema(opts.outputSchema);
|
|
398
|
+
serveMcp(registry, version, {
|
|
399
|
+
/* The same config the browser reads, so the shelf an agent sees is the
|
|
400
|
+
shelf the team keeps — not a second list that drifts. */
|
|
401
|
+
queries: configQueries,
|
|
402
|
+
pages: configPages,
|
|
403
|
+
savedPages: (connection) => layoutsFor(connection, pagesPath()),
|
|
404
|
+
});
|
|
405
|
+
console.log('tablewalk: speaking MCP on stdio');
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
const server = createApp({
|
|
409
|
+
registry,
|
|
410
|
+
views: configViews,
|
|
411
|
+
configPath,
|
|
412
|
+
queries: configQueries,
|
|
413
|
+
pages: configPages,
|
|
414
|
+
layouts: configLayouts,
|
|
415
|
+
bound: { host: opts.host, port: opts.port, allowedHosts: opts.allowedHosts },
|
|
416
|
+
});
|
|
417
|
+
server.listen(opts.port, opts.host, () => {
|
|
418
|
+
const shown = opts.host === '127.0.0.1' ? 'localhost' : opts.host;
|
|
419
|
+
console.log(`Listening on http://${shown}:${opts.port}`);
|
|
420
|
+
if (nothingToOpen) {
|
|
421
|
+
/* Said here rather than left for the browser to explain, because
|
|
422
|
+
somebody who meant to pass a database and mistyped the flag is
|
|
423
|
+
looking at this terminal, not at the page. The config paths go with
|
|
424
|
+
it for the same reason they go with the usage: "no connection" and
|
|
425
|
+
"your config file is not where I looked" are the same sentence from
|
|
426
|
+
the reader's side. */
|
|
427
|
+
console.log('tablewalk: no connection yet — open the page and it will ask for one.');
|
|
428
|
+
console.log(` A config file would have been read from:\n ${configLocations(opts.config).join('\n ')}`);
|
|
429
|
+
}
|
|
430
|
+
if (opts.host !== '127.0.0.1') {
|
|
431
|
+
console.log('Warning: bound beyond loopback. Anyone who can reach this port can read the database.');
|
|
432
|
+
}
|
|
433
|
+
if (opts.allowedHosts.length) {
|
|
434
|
+
// Printed because it relaxes a security check, and a relaxed check that
|
|
435
|
+
// nobody can see in the log is one nobody remembers turning on.
|
|
436
|
+
console.log(`tablewalk: also answering writes for host ${opts.allowedHosts.join(', ')}`);
|
|
437
|
+
}
|
|
438
|
+
if (opts.open)
|
|
439
|
+
void openBrowser(`http://${shown}:${opts.port}`);
|
|
440
|
+
});
|
|
441
|
+
const shutdown = async () => {
|
|
442
|
+
server.close();
|
|
443
|
+
await registry.closeAll();
|
|
444
|
+
process.exit(0);
|
|
445
|
+
};
|
|
446
|
+
process.on('SIGINT', shutdown);
|
|
447
|
+
process.on('SIGTERM', shutdown);
|
|
448
|
+
}
|
|
449
|
+
/**
|
|
450
|
+
* The server, wired but not listening.
|
|
451
|
+
*
|
|
452
|
+
* This used to be four lines inside `main`, which meant the only way to reach
|
|
453
|
+
* an endpoint was to run the process — so nothing tested a status code, and
|
|
454
|
+
* the two error vocabularies (`{error}` for infrastructure, `{errors}` for a
|
|
455
|
+
* mistake in the language) were only ever agreed to by convention. A test that
|
|
456
|
+
* rebuilt this wiring for itself would prove the wiring it wrote rather than
|
|
457
|
+
* the wiring that ships, so it is pulled out whole and `main` calls it.
|
|
458
|
+
*
|
|
459
|
+
* `bound` is read on every request instead of copied, so a caller listening on
|
|
460
|
+
* port 0 can fill the port in once the kernel has chosen one. That matters
|
|
461
|
+
* because `crossOriginRefusal` compares the `Host` header against it, and a
|
|
462
|
+
* bound record that disagrees with the socket refuses every write.
|
|
463
|
+
*/
|
|
464
|
+
export function createApp(options) {
|
|
465
|
+
return createServer((req, res) => {
|
|
466
|
+
handle(req, res, options.registry, options.views ?? [], options.configPath, options.bound, options.queries ?? [], options.pages ?? [], options.layouts ?? []).catch((err) => {
|
|
467
|
+
/* A path that does not resolve is the caller's mistake, not the
|
|
468
|
+
server's, and a 500 would have the client report it as an outage
|
|
469
|
+
rather than as the typo it is. Same for anything that named its own
|
|
470
|
+
status on the way up. */
|
|
471
|
+
const status = err instanceof HttpError ? err.status
|
|
472
|
+
/* A path that does not resolve and an adapter that will not carry out
|
|
473
|
+
the request are both the caller's mistake, and a 500 tells them to
|
|
474
|
+
go and look at the server. */
|
|
475
|
+
: err instanceof PathError || err instanceof Refusal ? 400
|
|
476
|
+
: 500;
|
|
477
|
+
send(res, status, { error: err.message });
|
|
478
|
+
});
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
/**
|
|
482
|
+
* Refuse a request that another site made on the user's behalf.
|
|
483
|
+
*
|
|
484
|
+
* Binding to loopback keeps tablewalk off the network. It does **not** keep it
|
|
485
|
+
* away from the browser: any page the user has open can `fetch` a localhost
|
|
486
|
+
* port, and while the same-origin policy stops it reading the reply, the
|
|
487
|
+
* request still runs. That was enough to matter here — a page could POST to
|
|
488
|
+
* /api/update and the write would land, on a connection the user never put
|
|
489
|
+
* into write mode, because write mode is a decision the client makes and the
|
|
490
|
+
* server was taking on trust.
|
|
491
|
+
*
|
|
492
|
+
* Three checks, each cheap and each closing a different door:
|
|
493
|
+
*
|
|
494
|
+
* - **Origin.** A cross-origin POST always carries one. Ours never differs
|
|
495
|
+
* from the host we are serving on. A request with no Origin at all is a
|
|
496
|
+
* tool like curl, which is not a confused-deputy risk and is allowed.
|
|
497
|
+
* - **Content-Type.** Requiring JSON makes a cross-origin request
|
|
498
|
+
* non-"simple", so the browser must preflight it — and we answer no CORS
|
|
499
|
+
* headers at all, so the preflight fails before the real request is sent.
|
|
500
|
+
* This is the check that holds even if a browser stops sending Origin.
|
|
501
|
+
* - **Host.** Only loopback names, or the interface actually bound. This is
|
|
502
|
+
* the DNS-rebinding case: an attacker's domain resolving to 127.0.0.1
|
|
503
|
+
* makes their page same-origin with us, and the Origin check would pass.
|
|
504
|
+
* It is the one of the three that runs on reads as well — see
|
|
505
|
+
* `hostRefusal` — because same-origin is exactly what makes a *read*
|
|
506
|
+
* worth doing, and the schema, the DDL and the saved SQL are all reads.
|
|
507
|
+
*
|
|
508
|
+
* No token, no session, no cookie. The browser already tells us everything we
|
|
509
|
+
* need, and inventing a ceremony on top would be more code guarding the same
|
|
510
|
+
* door.
|
|
511
|
+
*
|
|
512
|
+
* The Host check has one deliberate escape hatch, `allowedHosts`. In a
|
|
513
|
+
* container the server binds 0.0.0.0 while the browser sends whatever name the
|
|
514
|
+
* user typed — `tablewalk.internal`, or a reverse proxy's public name — and
|
|
515
|
+
* neither is loopback nor the bound interface, so nothing would be answered at
|
|
516
|
+
* all — a message about a host nobody set, on the page itself. Naming those
|
|
517
|
+
* hosts explicitly is the fix. It is opt-in and empty by default because
|
|
518
|
+
* each entry is a name whose DNS you are now trusting: if an attacker can make
|
|
519
|
+
* a name on this list resolve to your loopback, the rebinding defence is gone
|
|
520
|
+
* for that name. That is a fair trade for a name you chose and a bad one for a
|
|
521
|
+
* wildcard, which is why there is no wildcard.
|
|
522
|
+
*/
|
|
523
|
+
const LOOPBACK = /^(localhost|127\.0\.0\.1|\[::1\]|::1)(:\d+)?$/i;
|
|
524
|
+
/**
|
|
525
|
+
* True when a `Host` header matches one of the extra names we were told to
|
|
526
|
+
* trust.
|
|
527
|
+
*
|
|
528
|
+
* An entry without a port matches the name on any port, because a request
|
|
529
|
+
* through a proxy on 80 or 443 arrives with no port at all and the person
|
|
530
|
+
* writing `--allowed-host tablewalk.internal` means the machine, not one
|
|
531
|
+
* listener on it. An entry *with* a port is taken literally.
|
|
532
|
+
*/
|
|
533
|
+
function hostAllowed(host, allowed) {
|
|
534
|
+
const lower = host.toLowerCase();
|
|
535
|
+
const withoutPort = lower.replace(/:\d+$/, '');
|
|
536
|
+
return allowed.some((entry) => {
|
|
537
|
+
const want = entry.trim().toLowerCase();
|
|
538
|
+
if (!want)
|
|
539
|
+
return false;
|
|
540
|
+
return /:\d+$/.test(want) ? lower === want : withoutPort === want;
|
|
541
|
+
});
|
|
542
|
+
}
|
|
543
|
+
/**
|
|
544
|
+
* The `Host` half, on its own, because it has to run on reads too.
|
|
545
|
+
*
|
|
546
|
+
* This was inside `crossOriginRefusal`, which only ever ran for methods that
|
|
547
|
+
* change something. The reasoning was that a read is harmless — and it is
|
|
548
|
+
* wrong, because the attack this check exists to stop is DNS rebinding, and
|
|
549
|
+
* rebinding's whole trick is to make the request *same-origin* so the
|
|
550
|
+
* response can be read. A page on a name the attacker controls, pointed at
|
|
551
|
+
* 127.0.0.1, could `fetch('/api/schema')` and read the answer: every table,
|
|
552
|
+
* every column, every foreign key. `/api/ddl` handed over `CREATE TABLE`.
|
|
553
|
+
* `/api/connections` named the hosts, users and databases. `/api/queries`
|
|
554
|
+
* gave up the saved SQL. All of it, from any page, on any machine where
|
|
555
|
+
* tablewalk happened to be running.
|
|
556
|
+
*
|
|
557
|
+
* Row data was never reachable — reads that take arguments are POSTs, and
|
|
558
|
+
* those were checked. The schema is not nothing.
|
|
559
|
+
*
|
|
560
|
+
* It runs on the static files too, so a rebinding attempt fails at the page
|
|
561
|
+
* rather than one request later. Reaching tablewalk by any name other than
|
|
562
|
+
* loopback has always needed `--allowed-host` for writes to work; this makes
|
|
563
|
+
* that one rule instead of two.
|
|
564
|
+
*/
|
|
565
|
+
export function hostRefusal(req, bound) {
|
|
566
|
+
const host = req.headers.host ?? '';
|
|
567
|
+
const expected = `${bound.host}:${bound.port}`;
|
|
568
|
+
const allowed = bound.allowedHosts ?? [];
|
|
569
|
+
if (!LOOPBACK.test(host) && host !== expected && !hostAllowed(host, allowed)) {
|
|
570
|
+
return `Refused: this request arrived for host "${host}". tablewalk only answers on ${expected}.` +
|
|
571
|
+
' Add --allowed-host, or set TABLEWALK_ALLOWED_HOSTS, to trust another name.';
|
|
572
|
+
}
|
|
573
|
+
return null;
|
|
574
|
+
}
|
|
575
|
+
export function crossOriginRefusal(req, bound) {
|
|
576
|
+
const host = req.headers.host ?? '';
|
|
577
|
+
const wrongHost = hostRefusal(req, bound);
|
|
578
|
+
if (wrongHost)
|
|
579
|
+
return wrongHost;
|
|
580
|
+
const origin = req.headers.origin;
|
|
581
|
+
if (origin) {
|
|
582
|
+
let originHost;
|
|
583
|
+
try {
|
|
584
|
+
originHost = new URL(origin).host;
|
|
585
|
+
}
|
|
586
|
+
catch {
|
|
587
|
+
return `Refused: "${origin}" is not a valid origin.`;
|
|
588
|
+
}
|
|
589
|
+
if (originHost !== host) {
|
|
590
|
+
return `Refused: a page at ${origin} tried to make this request. tablewalk only accepts requests from its own page.`;
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
const type = (req.headers['content-type'] ?? '').split(';')[0].trim().toLowerCase();
|
|
594
|
+
if (type !== 'application/json') {
|
|
595
|
+
return 'Refused: this endpoint takes application/json.';
|
|
596
|
+
}
|
|
597
|
+
return null;
|
|
598
|
+
}
|
|
599
|
+
async function handle(req, res, registry, configViews = [],
|
|
600
|
+
/** The file this session read its connections from, and writes them back to. */
|
|
601
|
+
configPath, bound = { host: '127.0.0.1', port: 4111 }, configQueries = [], configPages = [], configLayouts = []) {
|
|
602
|
+
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
603
|
+
const path = url.pathname;
|
|
604
|
+
/* Every request, read or write, page or endpoint. The name tablewalk was
|
|
605
|
+
reached by is the one thing a rebinding attack cannot fake, so it is
|
|
606
|
+
checked before anything is served — including index.html, so the attempt
|
|
607
|
+
fails at the door rather than at the first fetch. */
|
|
608
|
+
const wrongHost = hostRefusal(req, bound);
|
|
609
|
+
if (wrongHost) {
|
|
610
|
+
if (path.startsWith('/api/'))
|
|
611
|
+
return send(res, 403, { error: wrongHost });
|
|
612
|
+
res.writeHead(403, { 'content-type': 'text/plain; charset=utf-8' });
|
|
613
|
+
res.end(`${wrongHost}\n`);
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
/* Every request that can change something is checked before it is read, so
|
|
617
|
+
a refusal costs nothing and happens before any work. */
|
|
618
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
619
|
+
const refusal = crossOriginRefusal(req, bound);
|
|
620
|
+
if (refusal)
|
|
621
|
+
return send(res, 403, { error: refusal });
|
|
622
|
+
}
|
|
623
|
+
/* ---------- connection management ---------- */
|
|
624
|
+
if (path === '/api/connections' && req.method !== 'POST') {
|
|
625
|
+
return send(res, 200, { connections: registry.list(), active: registry.active });
|
|
626
|
+
}
|
|
627
|
+
/**
|
|
628
|
+
* Try a connection without keeping it.
|
|
629
|
+
*
|
|
630
|
+
* Adding was the only way to find out whether a target worked, and a failed
|
|
631
|
+
* add leaves an entry behind — on purpose, so a mistyped password is a
|
|
632
|
+
* correction rather than a retype. That is right for adding and wrong for
|
|
633
|
+
* checking: someone who is not sure yet should not have to tidy up after
|
|
634
|
+
* asking.
|
|
635
|
+
*
|
|
636
|
+
* It opens and introspects, because a URL that connects and cannot be read
|
|
637
|
+
* is not a working connection, then closes. Nothing is registered, nothing
|
|
638
|
+
* is written, and the secret is scrubbed out of any failure the same way
|
|
639
|
+
* the registry scrubs its own.
|
|
640
|
+
*/
|
|
641
|
+
if (path === '/api/connections/test' && req.method === 'POST') {
|
|
642
|
+
const body = await readJson(req);
|
|
643
|
+
const target = String(body.url ?? '').trim();
|
|
644
|
+
if (!target)
|
|
645
|
+
return send(res, 400, { error: 'A url is required.' });
|
|
646
|
+
let adapter;
|
|
647
|
+
try {
|
|
648
|
+
const resolved = await resolveConnection(String(body.name ?? '').trim() || 'test', target);
|
|
649
|
+
adapter = connect(resolved.url, {
|
|
650
|
+
schemas: Array.isArray(body.schemas) ? body.schemas : [],
|
|
651
|
+
label: 'test',
|
|
652
|
+
});
|
|
653
|
+
const schema = await adapter.introspect();
|
|
654
|
+
return send(res, 200, {
|
|
655
|
+
ok: true,
|
|
656
|
+
dialect: schema.dialect,
|
|
657
|
+
tables: schema.tables.length,
|
|
658
|
+
/* Where the password came from, since the answer to "will this work"
|
|
659
|
+
includes "and where did the credential come from" — a connection
|
|
660
|
+
that works because a password is sitting in the URL is a different
|
|
661
|
+
answer from one that works from the keychain. */
|
|
662
|
+
secret: resolved.source,
|
|
663
|
+
warning: resolved.warning,
|
|
664
|
+
});
|
|
665
|
+
}
|
|
666
|
+
catch (err) {
|
|
667
|
+
return send(res, 200, { ok: false, error: scrubSecrets(err.message, target) });
|
|
668
|
+
}
|
|
669
|
+
finally {
|
|
670
|
+
await adapter?.close().catch(() => { });
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
if (path === '/api/connections' && req.method === 'POST') {
|
|
674
|
+
/* Adding a connection at runtime is session-only — it is deliberately
|
|
675
|
+
not written back to the config file, because a browser page silently
|
|
676
|
+
editing a file on disk is a surprise nobody asked for. */
|
|
677
|
+
const body = await readJson(req);
|
|
678
|
+
const target = String(body.url ?? '').trim();
|
|
679
|
+
if (!target)
|
|
680
|
+
return send(res, 400, { error: 'A url is required.' });
|
|
681
|
+
/* Kept so a failed add can put it back. `add` makes the first connection
|
|
682
|
+
active before anything has tried to dial it, so an add that then failed
|
|
683
|
+
to open left the application pointed at a connection that had never
|
|
684
|
+
worked — and every later request answering with its error, including
|
|
685
|
+
the ones for the connection that *did* work. */
|
|
686
|
+
const previous = registry.active;
|
|
687
|
+
const added = registry.add({
|
|
688
|
+
name: String(body.name ?? '').trim() || redact(target),
|
|
689
|
+
url: target,
|
|
690
|
+
schemas: Array.isArray(body.schemas) ? body.schemas : undefined,
|
|
691
|
+
source: 'session',
|
|
692
|
+
});
|
|
693
|
+
try {
|
|
694
|
+
await registry.open(added.id);
|
|
695
|
+
}
|
|
696
|
+
catch (err) {
|
|
697
|
+
registry.setActive(previous);
|
|
698
|
+
/* The entry stays, listed with its error.
|
|
699
|
+
|
|
700
|
+
"The add failed" and "nothing was added" are not the same thing, and
|
|
701
|
+
here the first is the useful one: a mistyped password should leave the
|
|
702
|
+
connection on screen to be corrected, not make someone retype the URL
|
|
703
|
+
they just got nearly right. It is also what already happens to a
|
|
704
|
+
connection in the config file that will not open — listed, with the
|
|
705
|
+
reason, rather than dropped.
|
|
706
|
+
|
|
707
|
+
The list goes back with the error so the client can show that without
|
|
708
|
+
a second request, which is what the id alone used to make it do. */
|
|
709
|
+
return send(res, 400, {
|
|
710
|
+
error: err.message,
|
|
711
|
+
id: added.id,
|
|
712
|
+
added: added.id,
|
|
713
|
+
connections: registry.list(),
|
|
714
|
+
active: registry.active,
|
|
715
|
+
});
|
|
716
|
+
}
|
|
717
|
+
return send(res, 200, { connections: registry.list(), active: registry.active, added: added.id });
|
|
718
|
+
}
|
|
719
|
+
/**
|
|
720
|
+
* Edit a connection.
|
|
721
|
+
*
|
|
722
|
+
* In the running session only. Making it permanent is a separate, explicit
|
|
723
|
+
* act — see /api/connections/save — because a browser page quietly editing
|
|
724
|
+
* a file on disk is a surprise nobody asked for.
|
|
725
|
+
*/
|
|
726
|
+
if (path === '/api/connections/update' && req.method === 'POST') {
|
|
727
|
+
const body = await readJson(req);
|
|
728
|
+
const id = String(body.id ?? '');
|
|
729
|
+
if (!registry.has(id))
|
|
730
|
+
return send(res, 404, { error: `No connection called "${id}".` });
|
|
731
|
+
const patch = {};
|
|
732
|
+
if (typeof body.name === 'string' && body.name.trim())
|
|
733
|
+
patch.name = body.name.trim();
|
|
734
|
+
if (typeof body.url === 'string' && body.url.trim())
|
|
735
|
+
patch.url = body.url.trim();
|
|
736
|
+
if (Array.isArray(body.schemas))
|
|
737
|
+
patch.schemas = body.schemas;
|
|
738
|
+
try {
|
|
739
|
+
await registry.update(id, patch);
|
|
740
|
+
/* Opened straight away when the target changed, so a typo fails here
|
|
741
|
+
rather than the next time someone clicks the connection. */
|
|
742
|
+
if (patch.url || patch.schemas)
|
|
743
|
+
await registry.open(id);
|
|
744
|
+
}
|
|
745
|
+
catch (err) {
|
|
746
|
+
return send(res, 400, { error: err.message, connections: registry.list() });
|
|
747
|
+
}
|
|
748
|
+
return send(res, 200, { connections: registry.list(), active: registry.active });
|
|
749
|
+
}
|
|
750
|
+
if (path === '/api/connections/remove' && req.method === 'POST') {
|
|
751
|
+
const body = await readJson(req);
|
|
752
|
+
const id = String(body.id ?? '');
|
|
753
|
+
if (!registry.has(id))
|
|
754
|
+
return send(res, 404, { error: `No connection called "${id}".` });
|
|
755
|
+
try {
|
|
756
|
+
await registry.remove(id);
|
|
757
|
+
}
|
|
758
|
+
catch (err) {
|
|
759
|
+
return send(res, 400, { error: err.message });
|
|
760
|
+
}
|
|
761
|
+
return send(res, 200, { connections: registry.list(), active: registry.active });
|
|
762
|
+
}
|
|
763
|
+
/**
|
|
764
|
+
* Write the current connections to a config file.
|
|
765
|
+
*
|
|
766
|
+
* The one place a page is allowed to change a file on disk, and only because
|
|
767
|
+
* someone pressed a button that named the file it was about to write. The
|
|
768
|
+
* path is echoed back so the UI can say what happened rather than implying
|
|
769
|
+
* it.
|
|
770
|
+
*/
|
|
771
|
+
if (path === '/api/connections/save' && req.method === 'POST') {
|
|
772
|
+
const body = await readJson(req);
|
|
773
|
+
const target = typeof body.path === 'string' && body.path.trim()
|
|
774
|
+
? body.path.trim()
|
|
775
|
+
: configPath ?? configLocations()[0];
|
|
776
|
+
/* Passwords come out of the URL before the file is written.
|
|
777
|
+
|
|
778
|
+
`storeSecret` was written for exactly this and nothing called it, so
|
|
779
|
+
the guarantee in its own comment — "the URL that gets written to disk
|
|
780
|
+
never contains the password" — was not in force: a connection added
|
|
781
|
+
with `postgres://user:hunter2@host/db` put `hunter2` in the config
|
|
782
|
+
file, in plaintext, for anyone the file is later shared or committed
|
|
783
|
+
with.
|
|
784
|
+
|
|
785
|
+
The secret goes where the OS keeps secrets (the keychain) or into a
|
|
786
|
+
credentials file at 0600, and the URL keeps its user and loses its
|
|
787
|
+
password. `resolveConnection` puts it back by connection name on the
|
|
788
|
+
next open, which is the mechanism that already existed and had nothing
|
|
789
|
+
feeding it. */
|
|
790
|
+
const moved = [];
|
|
791
|
+
for (const config of registry.configs()) {
|
|
792
|
+
const result = await storeSecret(config.name, config.url).catch(() => undefined);
|
|
793
|
+
if (!result)
|
|
794
|
+
continue;
|
|
795
|
+
/* The live connection is already open and stays open; this changes what
|
|
796
|
+
is remembered about it, so the write below and the next start both
|
|
797
|
+
see the URL without the secret. */
|
|
798
|
+
await registry.update(config.id, { url: result.url }).catch(() => { });
|
|
799
|
+
moved.push({ name: config.name, stored: result.stored });
|
|
800
|
+
}
|
|
801
|
+
try {
|
|
802
|
+
await saveConfig(target, registry.configs());
|
|
803
|
+
}
|
|
804
|
+
catch (err) {
|
|
805
|
+
return send(res, 500, { error: `Could not write ${target}: ${err.message}` });
|
|
806
|
+
}
|
|
807
|
+
return send(res, 200, {
|
|
808
|
+
path: target,
|
|
809
|
+
count: registry.configs().length,
|
|
810
|
+
/* Said rather than done silently: moving somebody's password is a
|
|
811
|
+
helpful thing to do and a surprising thing to discover. */
|
|
812
|
+
secrets: moved,
|
|
813
|
+
});
|
|
814
|
+
}
|
|
815
|
+
if (path === '/api/connections/select' && req.method === 'POST') {
|
|
816
|
+
const body = await readJson(req);
|
|
817
|
+
const id = String(body.id ?? '');
|
|
818
|
+
if (!registry.has(id))
|
|
819
|
+
return send(res, 404, { error: `No connection called "${id}".` });
|
|
820
|
+
const { schema } = await registry.open(id);
|
|
821
|
+
registry.setActive(id);
|
|
822
|
+
return send(res, 200, { active: id, schema });
|
|
823
|
+
}
|
|
824
|
+
if (path === '/api/connections/refresh' && req.method === 'POST') {
|
|
825
|
+
const body = await readJson(req);
|
|
826
|
+
const id = String(body.id ?? registry.active ?? '');
|
|
827
|
+
const { schema } = await registry.refresh(id);
|
|
828
|
+
return send(res, 200, { schema });
|
|
829
|
+
}
|
|
830
|
+
/* Every remaining endpoint runs against one connection: the one named in
|
|
831
|
+
the request, or the active one. Resolving it here means no handler has
|
|
832
|
+
to remember to. */
|
|
833
|
+
const requested = url.searchParams.get('conn') ??
|
|
834
|
+
(req.method === 'POST' ? undefined : registry.active) ??
|
|
835
|
+
registry.active;
|
|
836
|
+
let adapter;
|
|
837
|
+
let schema;
|
|
838
|
+
let connectionId;
|
|
839
|
+
const resolve = async (id) => {
|
|
840
|
+
/* Named but unknown is a 404, not a fallback. Falling back ran the
|
|
841
|
+
request against whatever happened to be active — the right answer to
|
|
842
|
+
the wrong question, and with raw SQL on the same path, the wrong
|
|
843
|
+
database. */
|
|
844
|
+
if (id && !registry.has(id))
|
|
845
|
+
throw new HttpError(404, `No connection called "${id}".`);
|
|
846
|
+
const target = id ?? registry.active;
|
|
847
|
+
if (!target)
|
|
848
|
+
throw new HttpError(409, 'No connection is open.');
|
|
849
|
+
const opened = await registry.open(target);
|
|
850
|
+
adapter = opened.adapter;
|
|
851
|
+
schema = opened.schema;
|
|
852
|
+
connectionId = target;
|
|
853
|
+
/* Held for the length of the response, so a `refresh` or a `remove` in
|
|
854
|
+
another tab cannot close this adapter out from under the query that is
|
|
855
|
+
already using it. `close` fires whether the response ended or the
|
|
856
|
+
client hung up, which is the point — an abandoned request must not
|
|
857
|
+
hold the connection open for ever. */
|
|
858
|
+
const release = registry.lease(target);
|
|
859
|
+
res.once('close', release);
|
|
860
|
+
/* Every answer says which database gave it.
|
|
861
|
+
|
|
862
|
+
Set here rather than added to a dozen `send` calls: this is the one
|
|
863
|
+
place that knows, and a header covers the replies that are arrays and
|
|
864
|
+
the ones that are errors as well as the ones that are objects.
|
|
865
|
+
|
|
866
|
+
It exists to make the guarantee checkable. "The reply came from the
|
|
867
|
+
connection the request named" is the whole of the multi-tab fix, and
|
|
868
|
+
without this it is a claim rather than something a test or a reader can
|
|
869
|
+
verify. */
|
|
870
|
+
res.setHeader('x-tablewalk-connection', target);
|
|
871
|
+
};
|
|
872
|
+
if (req.method === 'POST' && path.startsWith('/api/')) {
|
|
873
|
+
// POST bodies can name a connection too, and the body is read once and
|
|
874
|
+
// reused by the handler below.
|
|
875
|
+
const body = await readJson(req);
|
|
876
|
+
const inBody = typeof body.conn === 'string' ? body.conn : undefined;
|
|
877
|
+
const inQuery = url.searchParams.get('conn');
|
|
878
|
+
/* Both, and disagreeing, is refused rather than resolved by precedence.
|
|
879
|
+
Either answer runs somebody's statement against a database they did not
|
|
880
|
+
mean, and the request has already told us it is confused about which. */
|
|
881
|
+
if (inBody && inQuery && inBody !== inQuery) {
|
|
882
|
+
return send(res, 400, {
|
|
883
|
+
error: `This request names two connections: "${inQuery}" in the URL and "${inBody}" in the body.`,
|
|
884
|
+
});
|
|
885
|
+
}
|
|
886
|
+
await resolve(inBody ?? inQuery ?? registry.active);
|
|
887
|
+
return handlePost(res, path, body, adapter, schema, registry, connectionId, configPages);
|
|
888
|
+
}
|
|
889
|
+
/* Only the API needs a database. Resolving before the static handler at the
|
|
890
|
+
bottom meant `GET /` — the page itself — answered 409 "No connection is
|
|
891
|
+
open" when there was nothing to open, so the one screen that could have
|
|
892
|
+
asked for a connection was the one screen behind the requirement. */
|
|
893
|
+
if (!path.startsWith('/api/'))
|
|
894
|
+
return serveStatic(res, path);
|
|
895
|
+
await resolve(requested);
|
|
896
|
+
/**
|
|
897
|
+
* Views defined in the config file, for the connection in hand.
|
|
898
|
+
*
|
|
899
|
+
* Saved views live in the browser, per connection — they are one person's
|
|
900
|
+
* working set, and writing them into a file the server owns would make a
|
|
901
|
+
* page silently edit something on disk. Config views are the shared half:
|
|
902
|
+
* checked into a repo, the same for everyone, and read-only from here.
|
|
903
|
+
*
|
|
904
|
+
* A view whose base table does not exist on this connection is returned
|
|
905
|
+
* *with its reason* rather than dropped. Dropping it shows a shorter list
|
|
906
|
+
* and no explanation, which is the failure this whole tool argues against.
|
|
907
|
+
*/
|
|
908
|
+
if (path === '/api/views') {
|
|
909
|
+
const name = registry.list().find((c) => c.id === connectionId)?.name;
|
|
910
|
+
const mine = configViews.filter((v) => !v.connection || v.connection === connectionId || v.connection === name);
|
|
911
|
+
return send(res, 200, {
|
|
912
|
+
connection: connectionId,
|
|
913
|
+
views: mine.map((v) => materialise(schema, v)),
|
|
914
|
+
});
|
|
915
|
+
}
|
|
916
|
+
if (path === '/api/schema') {
|
|
917
|
+
return send(res, 200, schema);
|
|
918
|
+
}
|
|
919
|
+
/**
|
|
920
|
+
* Pages the config file names, for the connection in hand.
|
|
921
|
+
*
|
|
922
|
+
* Checked against this connection's schema here rather than in the browser,
|
|
923
|
+
* because the check needs the schema and the browser would have to be told
|
|
924
|
+
* the same rules a second time — which is how the two ended up disagreeing
|
|
925
|
+
* about what a valid path was in every other feature that tried it.
|
|
926
|
+
*
|
|
927
|
+
* A page that does not fit this connection comes back *with its reason*
|
|
928
|
+
* rather than missing. A config file shared by a team always describes some
|
|
929
|
+
* connection someone is not on, and "the page I wrote is not in the list"
|
|
930
|
+
* is a bug report nobody can act on.
|
|
931
|
+
*/
|
|
932
|
+
if (path === '/api/pages') {
|
|
933
|
+
const name = registry.list().find((c) => c.id === connectionId)?.name;
|
|
934
|
+
const mine = configPages.filter((p) => !p.connection || p.connection === connectionId || p.connection === name);
|
|
935
|
+
return send(res, 200, {
|
|
936
|
+
connection: connectionId,
|
|
937
|
+
pages: mine.map((p) => validatePage(schema, p)),
|
|
938
|
+
});
|
|
939
|
+
}
|
|
940
|
+
/**
|
|
941
|
+
* The page for a table nobody has written one for.
|
|
942
|
+
*
|
|
943
|
+
* Asked for by table rather than shipped with the list, because it is
|
|
944
|
+
* derived from the schema and the client has the schema — but the rule that
|
|
945
|
+
* derives it lives in shared/page.ts, and a second copy in the browser is
|
|
946
|
+
* the drift this codebase has paid for repeatedly.
|
|
947
|
+
*/
|
|
948
|
+
/**
|
|
949
|
+
* What a page about this table could show.
|
|
950
|
+
*
|
|
951
|
+
* The path is the one field nobody guesses when writing a page by hand, so
|
|
952
|
+
* the builder does not ask for it — it asks the schema what points here and
|
|
953
|
+
* offers the answers with their paths already written. Computed on the
|
|
954
|
+
* server because the rules that resolve a path live in shared/page.ts, and
|
|
955
|
+
* a second copy of them in the browser is the drift this codebase has paid
|
|
956
|
+
* for in every feature that tried it.
|
|
957
|
+
*/
|
|
958
|
+
if (path === '/api/pages/relations') {
|
|
959
|
+
const table = String(url.searchParams.get('table') ?? '');
|
|
960
|
+
if (!findTable(schema, table)) {
|
|
961
|
+
return send(res, 404, { error: `Unknown table "${table}"` });
|
|
962
|
+
}
|
|
963
|
+
const relations = relationsTo(schema, table);
|
|
964
|
+
const describe = (r) => ({
|
|
965
|
+
...r,
|
|
966
|
+
/* Numeric columns, for what a `sum` could be about. */
|
|
967
|
+
columns: metricColumns(schema, r.from),
|
|
968
|
+
defaults: defaultColumns(findTable(schema, r.from), r.path, resolvePath(schema, r.from, r.path).path),
|
|
969
|
+
all: findTable(schema, r.from).columns.map((c) => c.name),
|
|
970
|
+
});
|
|
971
|
+
/* What points at each record this table points at, one reference out.
|
|
972
|
+
|
|
973
|
+
This is how a work-order page offers "tickets at its site": the site is
|
|
974
|
+
not the root, but the root names it, and the schema knows what points
|
|
975
|
+
at a site. Direct relations only — the two-hop tail on a foreign
|
|
976
|
+
record is a menu nobody has asked for yet. */
|
|
977
|
+
const references = referencesFrom(schema, table)
|
|
978
|
+
.filter((fk) => fk.to.table !== table && findTable(schema, fk.to.table))
|
|
979
|
+
.map((fk) => {
|
|
980
|
+
const target = findTable(schema, fk.to.table);
|
|
981
|
+
return {
|
|
982
|
+
of: fk.from.columns.length === 1 ? fk.from.columns[0] : fk.name,
|
|
983
|
+
target: target.id,
|
|
984
|
+
targetName: target.name,
|
|
985
|
+
via: fk.from.columns.join(', '),
|
|
986
|
+
relations: relationsTo(schema, target.id, 1).map(describe),
|
|
987
|
+
};
|
|
988
|
+
})
|
|
989
|
+
.filter((ref) => ref.relations.length);
|
|
990
|
+
return send(res, 200, {
|
|
991
|
+
connection: connectionId,
|
|
992
|
+
references,
|
|
993
|
+
relations: relations.map((r) => ({
|
|
994
|
+
...r,
|
|
995
|
+
/* Numeric columns, for what a `sum` could be about. */
|
|
996
|
+
columns: metricColumns(schema, r.from),
|
|
997
|
+
/* And what a list of this relation shows when nobody has said —
|
|
998
|
+
computed here, with the resolved path, because the rule that drops
|
|
999
|
+
the constant column is the same one the planner uses. A builder
|
|
1000
|
+
that guessed at it would be a copy, and the copy would be the one
|
|
1001
|
+
that forgot composite keys. */
|
|
1002
|
+
defaults: defaultColumns(findTable(schema, r.from), r.path, resolvePath(schema, r.from, r.path).path),
|
|
1003
|
+
/* Every column, so the picker can offer the ones the default leaves
|
|
1004
|
+
out without a second request. */
|
|
1005
|
+
all: findTable(schema, r.from).columns.map((c) => c.name),
|
|
1006
|
+
})),
|
|
1007
|
+
});
|
|
1008
|
+
}
|
|
1009
|
+
if (path === '/api/pages/suggested') {
|
|
1010
|
+
const table = String(url.searchParams.get('table') ?? '');
|
|
1011
|
+
/* Two different failures, and they had one message between them: a table
|
|
1012
|
+
this connection does not have was reported as a table without a primary
|
|
1013
|
+
key, which sends the reader looking for a key on something that is not
|
|
1014
|
+
there. */
|
|
1015
|
+
if (!findTable(schema, table)) {
|
|
1016
|
+
return send(res, 404, { error: `Unknown table "${table}"` });
|
|
1017
|
+
}
|
|
1018
|
+
const suggested = suggestPage(schema, table);
|
|
1019
|
+
if (!suggested) {
|
|
1020
|
+
return send(res, 404, {
|
|
1021
|
+
error: `No page can be built for "${table}" — it needs a primary key to identify a record by.`,
|
|
1022
|
+
});
|
|
1023
|
+
}
|
|
1024
|
+
return send(res, 200, { connection: connectionId, page: suggested });
|
|
1025
|
+
}
|
|
1026
|
+
/**
|
|
1027
|
+
* SQL statements the config file names, for the connection in hand.
|
|
1028
|
+
*
|
|
1029
|
+
* The committable half of saved SQL. The other half lives in the browser,
|
|
1030
|
+
* and the client merges them — a statement someone checked in and one you
|
|
1031
|
+
* saved this morning are the same kind of thing to whoever is looking for
|
|
1032
|
+
* one, and only differ in whether you may delete it.
|
|
1033
|
+
*/
|
|
1034
|
+
if (path === '/api/queries') {
|
|
1035
|
+
const name = registry.list().find((c) => c.id === connectionId)?.name;
|
|
1036
|
+
return send(res, 200, {
|
|
1037
|
+
connection: connectionId,
|
|
1038
|
+
queries: queriesFor(configQueries, connectionId, name),
|
|
1039
|
+
});
|
|
1040
|
+
}
|
|
1041
|
+
/**
|
|
1042
|
+
* Every stored record layout for this connection.
|
|
1043
|
+
*
|
|
1044
|
+
* Sent as a set rather than one at a time: the client needs to know which
|
|
1045
|
+
* tables have a layout to render the "Layout" affordance honestly, and a
|
|
1046
|
+
* request per table would be one round trip per row you walk to.
|
|
1047
|
+
*/
|
|
1048
|
+
if (path === '/api/layouts') {
|
|
1049
|
+
const saved = await layoutsFor(connectionId);
|
|
1050
|
+
/* Config layouts fill in for tables nobody has laid out here, and never
|
|
1051
|
+
over one somebody has. A committed layout is the default a team starts
|
|
1052
|
+
from; the copy in this browser is what its reader chose afterwards, and
|
|
1053
|
+
silently replacing that would make the config file feel like it was
|
|
1054
|
+
fighting them. */
|
|
1055
|
+
const merged = { ...saved };
|
|
1056
|
+
for (const layout of configLayouts) {
|
|
1057
|
+
if (layout.connection && layout.connection !== connectionId)
|
|
1058
|
+
continue;
|
|
1059
|
+
const table = tableNamed(schema, layout.table);
|
|
1060
|
+
if (!table)
|
|
1061
|
+
continue;
|
|
1062
|
+
if (merged[table.id])
|
|
1063
|
+
continue;
|
|
1064
|
+
merged[table.id] = resolveConfigLayout(schema, table.id, layout);
|
|
1065
|
+
}
|
|
1066
|
+
return send(res, 200, { connection: connectionId, layouts: merged });
|
|
1067
|
+
}
|
|
1068
|
+
/**
|
|
1069
|
+
* The MCP tools, for the agent console. The same list, from the same
|
|
1070
|
+
* function, that tools/list answers over stdio — including the rule that
|
|
1071
|
+
* write tools exist only where some connection's config allows any.
|
|
1072
|
+
*/
|
|
1073
|
+
if (path === '/api/agent/tools') {
|
|
1074
|
+
return send(res, 200, { tools: agentTools(registry) });
|
|
1075
|
+
}
|
|
1076
|
+
/** Every page saved for this connection, from the same kind of file. */
|
|
1077
|
+
if (path === '/api/pages/saved') {
|
|
1078
|
+
return send(res, 200, { connection: connectionId, pages: await layoutsFor(connectionId, pagesPath()) });
|
|
1079
|
+
}
|
|
1080
|
+
/**
|
|
1081
|
+
* A table's definition. `exact` tells the client whether it is looking at
|
|
1082
|
+
* the database's own stored DDL or a reconstruction from the catalog, so
|
|
1083
|
+
* the page can label it rather than implying an authority it lacks.
|
|
1084
|
+
*/
|
|
1085
|
+
if (path === '/api/ddl') {
|
|
1086
|
+
const tableId = url.searchParams.get('table') ?? '';
|
|
1087
|
+
if (!findTable(schema, tableId))
|
|
1088
|
+
return send(res, 404, { error: `Unknown table "${tableId}"` });
|
|
1089
|
+
const stored = adapter.ddl ? await adapter.ddl(tableId) : undefined;
|
|
1090
|
+
return send(res, 200, {
|
|
1091
|
+
table: tableId,
|
|
1092
|
+
sql: stored ?? renderDDL(schema, tableId),
|
|
1093
|
+
exact: Boolean(stored),
|
|
1094
|
+
});
|
|
1095
|
+
}
|
|
1096
|
+
if (path.startsWith('/api/')) {
|
|
1097
|
+
return send(res, 404, { error: `No such endpoint: ${path}` });
|
|
1098
|
+
}
|
|
1099
|
+
return serveStatic(res, path);
|
|
1100
|
+
}
|
|
1101
|
+
/**
|
|
1102
|
+
* Everything that takes a body.
|
|
1103
|
+
*
|
|
1104
|
+
* Split out because the connection is resolved from the body, which means the
|
|
1105
|
+
* body has to be read before dispatch — and a stream can only be read once.
|
|
1106
|
+
* Handlers get it already parsed rather than each reaching for the request.
|
|
1107
|
+
*/
|
|
1108
|
+
/**
|
|
1109
|
+
* How many rows match, and whether that number was counted or guessed.
|
|
1110
|
+
*
|
|
1111
|
+
* `COUNT(*)` is a scan, and it runs on every query to fill in a header stat
|
|
1112
|
+
* nobody asked for. On a table of a few thousand rows that is free; on one of
|
|
1113
|
+
* fifty million it is seconds of work for a number that is stale before it
|
|
1114
|
+
* renders.
|
|
1115
|
+
*
|
|
1116
|
+
* So: an unfiltered read of a table the catalog says is large gets the
|
|
1117
|
+
* catalog's estimate, and everything else gets the true count. Filtered reads
|
|
1118
|
+
* cannot be estimated at all — the catalog knows how big a table is and nothing
|
|
1119
|
+
* about how many rows match a `WHERE` — and are counted as before.
|
|
1120
|
+
*
|
|
1121
|
+
* `exact` travels with the number rather than being inferred from its size,
|
|
1122
|
+
* because the interface has to say which it is showing. "2,400,113" when the
|
|
1123
|
+
* real figure is 2,411,908 is worse than "~2.4M": one of them is wrong and the
|
|
1124
|
+
* other is honest.
|
|
1125
|
+
*/
|
|
1126
|
+
async function countFor(adapter, table, filter, { force = false } = {}) {
|
|
1127
|
+
const filtered = Boolean(filter?.groups?.length);
|
|
1128
|
+
if (force || filtered || !adapter.estimateCount) {
|
|
1129
|
+
return { count: await adapter.count(table, filter), exact: true };
|
|
1130
|
+
}
|
|
1131
|
+
const estimate = await adapter.estimateCount(table).catch(() => undefined);
|
|
1132
|
+
if (estimate !== undefined && estimate >= ESTIMATE_ABOVE) {
|
|
1133
|
+
return { count: estimate, exact: false };
|
|
1134
|
+
}
|
|
1135
|
+
return { count: await adapter.count(table, filter), exact: true };
|
|
1136
|
+
}
|
|
1137
|
+
async function handlePost(res, path, body, adapter, schema, registry, connection, configPages = []) {
|
|
1138
|
+
/**
|
|
1139
|
+
* Store a record layout, or remove one.
|
|
1140
|
+
*
|
|
1141
|
+
* Keyed by connection so a layout built against one database does not
|
|
1142
|
+
* quietly apply to another that happens to have a table of the same name.
|
|
1143
|
+
*/
|
|
1144
|
+
if (path === '/api/layouts') {
|
|
1145
|
+
const table = String(body.table ?? '');
|
|
1146
|
+
if (!table)
|
|
1147
|
+
return send(res, 400, { error: 'A layout needs a table.' });
|
|
1148
|
+
const layout = body.layout === null ? null : body.layout;
|
|
1149
|
+
if (layout !== null && (!layout || typeof layout !== 'object')) {
|
|
1150
|
+
return send(res, 400, { error: 'A layout must be an object, or null to remove it.' });
|
|
1151
|
+
}
|
|
1152
|
+
try {
|
|
1153
|
+
await writeLayout(connection, table, layout);
|
|
1154
|
+
}
|
|
1155
|
+
catch (err) {
|
|
1156
|
+
/* The client keeps its own copy, so a failure here degrades to
|
|
1157
|
+
browser-only persistence rather than losing the layout. Said plainly
|
|
1158
|
+
so the difference is visible rather than assumed. */
|
|
1159
|
+
return send(res, 500, {
|
|
1160
|
+
error: `Saved in this browser only — the layout file could not be written: ${err.message}`,
|
|
1161
|
+
});
|
|
1162
|
+
}
|
|
1163
|
+
return send(res, 200, { ok: true });
|
|
1164
|
+
}
|
|
1165
|
+
/**
|
|
1166
|
+
* Store a saved page, or remove one. The layout endpoint's twin: keyed by
|
|
1167
|
+
* connection, written through the same one-writer chain, degrading to
|
|
1168
|
+
* browser-only persistence with the failure said plainly.
|
|
1169
|
+
*/
|
|
1170
|
+
if (path === '/api/pages/save') {
|
|
1171
|
+
const id = String(body.id ?? '');
|
|
1172
|
+
if (!id)
|
|
1173
|
+
return send(res, 400, { error: 'A page needs an id.' });
|
|
1174
|
+
const page = body.page === null ? null : body.page;
|
|
1175
|
+
if (page !== null && (!page || typeof page !== 'object')) {
|
|
1176
|
+
return send(res, 400, { error: 'A page must be an object, or null to remove it.' });
|
|
1177
|
+
}
|
|
1178
|
+
try {
|
|
1179
|
+
await writeLayout(connection, id, page, pagesPath());
|
|
1180
|
+
}
|
|
1181
|
+
catch (err) {
|
|
1182
|
+
return send(res, 500, {
|
|
1183
|
+
error: `Saved in this browser only — the pages file could not be written: ${err.message}`,
|
|
1184
|
+
});
|
|
1185
|
+
}
|
|
1186
|
+
return send(res, 200, { ok: true });
|
|
1187
|
+
}
|
|
1188
|
+
/**
|
|
1189
|
+
* Run one MCP tool over HTTP, for the agent console.
|
|
1190
|
+
*
|
|
1191
|
+
* Deliberately the same runTool the stdio loop uses, so the console's
|
|
1192
|
+
* whole claim — "this is what the agent sees" — is structural rather than
|
|
1193
|
+
* aspirational. Tool errors are results here as there; the connection is
|
|
1194
|
+
* whatever the arguments name, defaulting to the active one, exactly as
|
|
1195
|
+
* an agent's call would.
|
|
1196
|
+
*/
|
|
1197
|
+
if (path === '/api/agent/call') {
|
|
1198
|
+
const name = String(body.name ?? '');
|
|
1199
|
+
const args = (body.arguments ?? {});
|
|
1200
|
+
return send(res, 200, await runTool(registry, name, args));
|
|
1201
|
+
}
|
|
1202
|
+
if (path === '/api/query') {
|
|
1203
|
+
const tableId = String(body.table ?? '');
|
|
1204
|
+
const table = findTable(schema, tableId);
|
|
1205
|
+
if (!table)
|
|
1206
|
+
return send(res, 404, { error: `Unknown table "${tableId}"` });
|
|
1207
|
+
/* Ordered even when nobody asked. An embedded child list pages too, and
|
|
1208
|
+
a page of parts that quietly differs from the last one is the same bug
|
|
1209
|
+
wearing a smaller hat. */
|
|
1210
|
+
const sorted = resolveOrder(table, body.orderBy);
|
|
1211
|
+
const result = await adapter.query({
|
|
1212
|
+
table: tableId,
|
|
1213
|
+
filter: body.filter,
|
|
1214
|
+
columns: Array.isArray(body.columns) ? body.columns : undefined,
|
|
1215
|
+
orderBy: sorted.order,
|
|
1216
|
+
limit: clampLimit(body.limit),
|
|
1217
|
+
offset: clampOffset(body.offset),
|
|
1218
|
+
});
|
|
1219
|
+
return send(res, 200, { ...result, order: sorted });
|
|
1220
|
+
}
|
|
1221
|
+
/**
|
|
1222
|
+
* Parse-and-run, for the query bar.
|
|
1223
|
+
*
|
|
1224
|
+
* The language lives on the server and only on the server. The client could
|
|
1225
|
+
* parse it too, but then there would be two implementations to keep in step
|
|
1226
|
+
* and a build step to ship the shared one to the browser. Sending the text
|
|
1227
|
+
* and getting back rows, the compiled SQL and any errors costs one round
|
|
1228
|
+
* trip and keeps the client a static file.
|
|
1229
|
+
*/
|
|
1230
|
+
if (path === '/api/run') {
|
|
1231
|
+
const text = String(body.q ?? '');
|
|
1232
|
+
const parsed = parseQuery(text, { schema });
|
|
1233
|
+
/* A view is a superset of a query, so the bar can take one.
|
|
1234
|
+
`invoice customer_id.name contains Harbour` is not a legal single-table
|
|
1235
|
+
query — `customer_id.name` is not a column on invoice — but it is a
|
|
1236
|
+
perfectly good view, and refusing it because of which endpoint the text
|
|
1237
|
+
arrived at would be an implementation detail leaking into the language.
|
|
1238
|
+
|
|
1239
|
+
The fall-through is deliberately one-way and last. Anything that parses
|
|
1240
|
+
cleanly as a query still runs as a query, so no text that works today
|
|
1241
|
+
can change meaning; only text that would otherwise have been an error
|
|
1242
|
+
gets a second reading. */
|
|
1243
|
+
if ((!parsed.query || parsed.errors.length) && adapter.runView) {
|
|
1244
|
+
const asView = textToView(schema, text, { id: 'bar', name: '' });
|
|
1245
|
+
if (asView.view && !asView.errors.length) {
|
|
1246
|
+
return runViewForBar(res, adapter, schema, asView.view, body);
|
|
1247
|
+
}
|
|
1248
|
+
}
|
|
1249
|
+
if (!parsed.query)
|
|
1250
|
+
return send(res, 200, { errors: parsed.errors });
|
|
1251
|
+
// Errors that would change which rows come back must not be papered over
|
|
1252
|
+
// with a partial result — a filter clause that failed to compile would
|
|
1253
|
+
// otherwise silently return more rows than asked for.
|
|
1254
|
+
if (parsed.errors.length) {
|
|
1255
|
+
return send(res, 200, { errors: parsed.errors, explain: explain(parsed.query) });
|
|
1256
|
+
}
|
|
1257
|
+
const q = parsed.query;
|
|
1258
|
+
/* An explicit `columns` overrides the query's own `show` clause, matching
|
|
1259
|
+
/api/query. Without it the two endpoints disagreed: one honoured the
|
|
1260
|
+
field and the other ignored it silently, which is the worst of the
|
|
1261
|
+
three options — a caller asking for three columns got all of them and
|
|
1262
|
+
no indication why. */
|
|
1263
|
+
if (Array.isArray(body.columns) && body.columns.length) {
|
|
1264
|
+
const known = new Set(findTable(schema, q.table)?.columns.map((c) => c.name) ?? []);
|
|
1265
|
+
const requested = body.columns.filter((c) => known.has(c));
|
|
1266
|
+
const unknown = body.columns.filter((c) => !known.has(c));
|
|
1267
|
+
if (unknown.length) {
|
|
1268
|
+
return refuse(res, 400, [`No column called "${unknown[0]}" on ${q.table}.`]);
|
|
1269
|
+
}
|
|
1270
|
+
q.columns = requested;
|
|
1271
|
+
}
|
|
1272
|
+
const limit = clampLimit(body.limit ?? q.limit);
|
|
1273
|
+
const offset = clampOffset(body.offset);
|
|
1274
|
+
/* Resolved here rather than in the adapter, because this is where the
|
|
1275
|
+
schema is. The adapters take a concrete order and do not guess. */
|
|
1276
|
+
const sorted = resolveOrder(findTable(schema, q.table), q.orderBy);
|
|
1277
|
+
const [result, total] = await Promise.all([
|
|
1278
|
+
adapter.query({ ...q, orderBy: sorted.order, limit, offset }),
|
|
1279
|
+
countFor(adapter, q.table, q.filter, { force: body.exactCount === true }),
|
|
1280
|
+
]);
|
|
1281
|
+
return send(res, 200, {
|
|
1282
|
+
table: q.table,
|
|
1283
|
+
...result,
|
|
1284
|
+
total: total.count,
|
|
1285
|
+
exactTotal: total.exact,
|
|
1286
|
+
limit,
|
|
1287
|
+
offset,
|
|
1288
|
+
order: sorted,
|
|
1289
|
+
explain: explain(q),
|
|
1290
|
+
errors: [],
|
|
1291
|
+
});
|
|
1292
|
+
}
|
|
1293
|
+
/**
|
|
1294
|
+
* Run a multi-table view: the composer's whole back end.
|
|
1295
|
+
*
|
|
1296
|
+
* The request carries a `ViewDef` — base table, paths, filter — and
|
|
1297
|
+
* optionally `query`, the composer's filter box as text. The text is parsed
|
|
1298
|
+
* here for the same reason the query bar's is: the language lives on the
|
|
1299
|
+
* server and only on the server, so there is one implementation of it and
|
|
1300
|
+
* the client stays a static file.
|
|
1301
|
+
*
|
|
1302
|
+
* As with `/api/run`, errors that would change which rows come back are
|
|
1303
|
+
* fatal rather than papered over. A filter clause that failed to compile
|
|
1304
|
+
* would otherwise return *more* rows than asked for, which reads as a
|
|
1305
|
+
* working view showing the wrong answer.
|
|
1306
|
+
*/
|
|
1307
|
+
/**
|
|
1308
|
+
* Run a breakdown: counts and sums, grouped by something.
|
|
1309
|
+
*
|
|
1310
|
+
* Text in, groups out. The language lives on the server and only on the
|
|
1311
|
+
* server — the same rule the query bar and the view composer follow — so
|
|
1312
|
+
* the client sends the line somebody typed and gets back the rows, what the
|
|
1313
|
+
* columns are, and how many groups there are in total.
|
|
1314
|
+
*
|
|
1315
|
+
* Errors are a 200 with `errors`, not a 4xx: a half-typed breakdown is the
|
|
1316
|
+
* normal state of an input box, and an HTTP error code for it would have
|
|
1317
|
+
* the client rendering "request failed" over a message that says which
|
|
1318
|
+
* column was misspelled.
|
|
1319
|
+
*/
|
|
1320
|
+
if (path === '/api/breakdown') {
|
|
1321
|
+
if (!adapter.runBreakdown) {
|
|
1322
|
+
return send(res, 501, { error: `${schema.dialect} connections cannot group in this build.` });
|
|
1323
|
+
}
|
|
1324
|
+
const text = String(body.text ?? '');
|
|
1325
|
+
const parsed = textToBreakdown(schema, text);
|
|
1326
|
+
if (!parsed.breakdown)
|
|
1327
|
+
return send(res, 200, { errors: parsed.errors });
|
|
1328
|
+
const def = {
|
|
1329
|
+
...parsed.breakdown,
|
|
1330
|
+
limit: clampLimit(body.limit ?? parsed.breakdown.limit),
|
|
1331
|
+
};
|
|
1332
|
+
const offset = clampOffset(body.offset);
|
|
1333
|
+
const [result, total] = await Promise.all([
|
|
1334
|
+
adapter.runBreakdown(def, offset),
|
|
1335
|
+
adapter.countBreakdown ? adapter.countBreakdown(def) : Promise.resolve(undefined),
|
|
1336
|
+
]);
|
|
1337
|
+
return send(res, 200, {
|
|
1338
|
+
breakdown: def,
|
|
1339
|
+
text: breakdownToText(schema, def),
|
|
1340
|
+
columns: result.columns,
|
|
1341
|
+
resolved: result.resolved,
|
|
1342
|
+
rows: result.rows,
|
|
1343
|
+
total,
|
|
1344
|
+
offset,
|
|
1345
|
+
ms: result.ms,
|
|
1346
|
+
sql: result.sql,
|
|
1347
|
+
/* The same statement with its values written in, for the SQL pane.
|
|
1348
|
+
`sql` above keeps the parameterised form, which is what actually ran
|
|
1349
|
+
and what anything machine-readable should be reading. */
|
|
1350
|
+
explain: explainBreakdown(schema, def, styleFor(schema), offset),
|
|
1351
|
+
});
|
|
1352
|
+
}
|
|
1353
|
+
if (path === '/api/view/run') {
|
|
1354
|
+
if (!adapter.runView) {
|
|
1355
|
+
return send(res, 501, { error: `${schema.dialect} connections cannot run views in this build.` });
|
|
1356
|
+
}
|
|
1357
|
+
let view = normaliseView(body.view);
|
|
1358
|
+
let merged = view;
|
|
1359
|
+
const errors = [];
|
|
1360
|
+
if (!view.base && typeof body.text !== 'string') {
|
|
1361
|
+
return refuse(res, 400, ['A view needs a base table.']);
|
|
1362
|
+
}
|
|
1363
|
+
/* A view may arrive as text instead of a structure — `work_order show
|
|
1364
|
+
reference, customer_id.name as customer count …`. It is the same
|
|
1365
|
+
language and the same parser; only the shape of what the client sent
|
|
1366
|
+
differs, so it is normalised here and everything below is unchanged. */
|
|
1367
|
+
if (typeof body.text === 'string' && body.text.trim()) {
|
|
1368
|
+
const parsed = textToView(schema, body.text, { id: view.id, name: view.name });
|
|
1369
|
+
if (parsed.errors.length || !parsed.view)
|
|
1370
|
+
return send(res, 200, { errors: parsed.errors });
|
|
1371
|
+
view = parsed.view;
|
|
1372
|
+
merged = parsed.view;
|
|
1373
|
+
}
|
|
1374
|
+
if (typeof body.query === 'string' && body.query.trim()) {
|
|
1375
|
+
const parsed = parseViewQuery(schema, view.base, body.query);
|
|
1376
|
+
errors.push(...parsed.errors);
|
|
1377
|
+
merged = {
|
|
1378
|
+
...view,
|
|
1379
|
+
filter: parsed.filter.groups.length ? parsed.filter : view.filter,
|
|
1380
|
+
/* A `show` clause in the box overrides the checklist, matching how
|
|
1381
|
+
`/api/run` lets an explicit column list win. Silently honouring one
|
|
1382
|
+
and ignoring the other is the worst of the three options. */
|
|
1383
|
+
columns: parsed.columns.length ? parsed.columns.map((p) => ({ path: p })) : view.columns,
|
|
1384
|
+
orderBy: parsed.orderBy.length ? parsed.orderBy : view.orderBy,
|
|
1385
|
+
limit: parsed.limit ?? view.limit,
|
|
1386
|
+
};
|
|
1387
|
+
}
|
|
1388
|
+
if (errors.length)
|
|
1389
|
+
return send(res, 200, { errors });
|
|
1390
|
+
const limit = clampLimit(body.limit ?? merged.limit);
|
|
1391
|
+
const offset = clampOffset(body.offset);
|
|
1392
|
+
try {
|
|
1393
|
+
/* One row past the page, then dropped. It answers "is there more"
|
|
1394
|
+
without a second COUNT over the whole join, and the join is the
|
|
1395
|
+
expensive half of a view. */
|
|
1396
|
+
const [result, total] = await Promise.all([
|
|
1397
|
+
adapter.runView({ ...merged, limit }, offset),
|
|
1398
|
+
/* An exact count, which is only affordable — and only *correct* —
|
|
1399
|
+
because every join in a view is to-one. A view never multiplies, so
|
|
1400
|
+
counting it is counting the table it started from. */
|
|
1401
|
+
adapter.countView ? adapter.countView(merged) : Promise.resolve(undefined),
|
|
1402
|
+
]);
|
|
1403
|
+
const more = total === undefined
|
|
1404
|
+
? result.rows.length >= limit
|
|
1405
|
+
: offset + result.rows.length < total;
|
|
1406
|
+
return send(res, 200, {
|
|
1407
|
+
view: merged,
|
|
1408
|
+
text: viewToTextWithSchema(schema, merged),
|
|
1409
|
+
total,
|
|
1410
|
+
columns: result.columns,
|
|
1411
|
+
resolved: result.resolved,
|
|
1412
|
+
joins: result.joins,
|
|
1413
|
+
rows: result.rows,
|
|
1414
|
+
ms: result.ms,
|
|
1415
|
+
sql: result.sql,
|
|
1416
|
+
explain: explainView(schema, { ...merged, limit }, styleFor(schema)),
|
|
1417
|
+
more,
|
|
1418
|
+
limit,
|
|
1419
|
+
offset,
|
|
1420
|
+
errors: [],
|
|
1421
|
+
});
|
|
1422
|
+
}
|
|
1423
|
+
catch (err) {
|
|
1424
|
+
if (err instanceof PathError) {
|
|
1425
|
+
return refuse(res, 400, [err.message]);
|
|
1426
|
+
}
|
|
1427
|
+
throw err;
|
|
1428
|
+
}
|
|
1429
|
+
}
|
|
1430
|
+
/**
|
|
1431
|
+
* Compile without running: the SQL panel while a view is still being built.
|
|
1432
|
+
*
|
|
1433
|
+
* Separate from running it because a view that is half-finished — three
|
|
1434
|
+
* fields chosen, no filter typed yet — is still worth showing the statement
|
|
1435
|
+
* for, and because compiling is free while running is not.
|
|
1436
|
+
*/
|
|
1437
|
+
if (path === '/api/view/compile') {
|
|
1438
|
+
const view = normaliseView(body.view);
|
|
1439
|
+
try {
|
|
1440
|
+
const compiled = compileView(schema, view, styleFor(schema));
|
|
1441
|
+
return send(res, 200, {
|
|
1442
|
+
sql: { text: compiled.text, params: compiled.params },
|
|
1443
|
+
explain: explainView(schema, view, styleFor(schema)),
|
|
1444
|
+
columns: compiled.columns,
|
|
1445
|
+
joins: compiled.joins,
|
|
1446
|
+
errors: [],
|
|
1447
|
+
});
|
|
1448
|
+
}
|
|
1449
|
+
catch (err) {
|
|
1450
|
+
if (err instanceof PathError)
|
|
1451
|
+
return refuse(res, 400, [err.message]);
|
|
1452
|
+
throw err;
|
|
1453
|
+
}
|
|
1454
|
+
}
|
|
1455
|
+
/**
|
|
1456
|
+
* The only endpoint that writes.
|
|
1457
|
+
*
|
|
1458
|
+
* Note what is *not* checked here: whether the client thinks it is in write
|
|
1459
|
+
* mode. That toggle is a UI affordance, and a guard that lives in the
|
|
1460
|
+
* browser is not a guard. The adapter independently verifies the key is the
|
|
1461
|
+
* whole primary key, counts the matching rows, and refuses anything other
|
|
1462
|
+
* than exactly one.
|
|
1463
|
+
*/
|
|
1464
|
+
if (path === '/api/update') {
|
|
1465
|
+
/* The gate the server can actually enforce.
|
|
1466
|
+
|
|
1467
|
+
Write mode in the browser is a deliberate act before typing, and it is
|
|
1468
|
+
not a permission — the client decides whether to *offer* editing, and a
|
|
1469
|
+
guard in the browser is not a guard. Until a connection says it is
|
|
1470
|
+
writable, anything that could reach this endpoint could write to it.
|
|
1471
|
+
|
|
1472
|
+
The message names the file and the field, because the person who hits
|
|
1473
|
+
this is usually the person who can change it, and "forbidden" would send
|
|
1474
|
+
them to the source to find out why. */
|
|
1475
|
+
if (!registry.configOf(connection)?.writable) {
|
|
1476
|
+
return send(res, 403, {
|
|
1477
|
+
error: `"${registry.configOf(connection)?.name ?? connection}" is read-only. ` +
|
|
1478
|
+
`Add "writable": true to its entry in your tablewalk.json to allow edits.`,
|
|
1479
|
+
});
|
|
1480
|
+
}
|
|
1481
|
+
if (!adapter.update) {
|
|
1482
|
+
return send(res, 501, { error: `${schema.dialect} connections are read-only in this build.` });
|
|
1483
|
+
}
|
|
1484
|
+
/* `values` is the real shape; `column`/`value` is accepted as the
|
|
1485
|
+
one-field form so a single edit does not have to build an object. */
|
|
1486
|
+
const values = (body.values ?? (body.column
|
|
1487
|
+
? { [String(body.column)]: (body.value ?? null) }
|
|
1488
|
+
: {}));
|
|
1489
|
+
try {
|
|
1490
|
+
const table = String(body.table ?? '');
|
|
1491
|
+
const key = (body.key ?? {});
|
|
1492
|
+
/* Read before writing, so the write can be undone — the same read the
|
|
1493
|
+
MCP door does, because the journal is one record of what happened to
|
|
1494
|
+
this database and not one per doorway. */
|
|
1495
|
+
const beforeRow = await rowBeforeWrite(adapter, table, key);
|
|
1496
|
+
const result = await adapter.update({ table, key, values });
|
|
1497
|
+
if (beforeRow) {
|
|
1498
|
+
const touched = Object.keys(result.applied ?? values);
|
|
1499
|
+
const steps = [{
|
|
1500
|
+
table,
|
|
1501
|
+
key,
|
|
1502
|
+
before: Object.fromEntries(touched.map((c) => [c, beforeRow[c] ?? null])),
|
|
1503
|
+
after: { ...(result.applied ?? values) },
|
|
1504
|
+
}];
|
|
1505
|
+
recordWrite(connection, { kind: 'update', summary: summarise('update', steps), revertible: true, steps });
|
|
1506
|
+
}
|
|
1507
|
+
return send(res, 200, result);
|
|
1508
|
+
}
|
|
1509
|
+
catch (err) {
|
|
1510
|
+
if (err instanceof Refusal)
|
|
1511
|
+
throw err;
|
|
1512
|
+
/* Pointing an existing row at a row that is not there is the same
|
|
1513
|
+
mistake as inserting one, and gets the same answer. */
|
|
1514
|
+
throw new Error(await explainWriteFailure(adapter, schema, String(body.table ?? ''), values, err.message));
|
|
1515
|
+
}
|
|
1516
|
+
}
|
|
1517
|
+
/**
|
|
1518
|
+
* Add a row. The same gate as /api/update, because it is the same act one
|
|
1519
|
+
* tense earlier. Refusals — a column that does not exist, null into NOT
|
|
1520
|
+
* NULL — are 400s the adapter words; a constraint the *database* enforces
|
|
1521
|
+
* comes back as 200 with its own message, because a UNIQUE violation is
|
|
1522
|
+
* the user's to fix and the engine names the constraint better than a
|
|
1523
|
+
* paraphrase would.
|
|
1524
|
+
*/
|
|
1525
|
+
if (path === '/api/insert') {
|
|
1526
|
+
if (!registry.configOf(connection)?.writable) {
|
|
1527
|
+
return send(res, 403, {
|
|
1528
|
+
error: `"${registry.configOf(connection)?.name ?? connection}" is read-only. ` +
|
|
1529
|
+
`Add "writable": true to its entry in your tablewalk.json to allow edits.`,
|
|
1530
|
+
});
|
|
1531
|
+
}
|
|
1532
|
+
if (!adapter.insert) {
|
|
1533
|
+
return send(res, 501, { error: `${schema.dialect} connections are read-only in this build.` });
|
|
1534
|
+
}
|
|
1535
|
+
const values = (body.values ?? {});
|
|
1536
|
+
try {
|
|
1537
|
+
const result = await adapter.insert({ table: String(body.table ?? ''), values });
|
|
1538
|
+
const steps = [{ table: String(body.table ?? ''), key: result.key, after: { ...result.row } }];
|
|
1539
|
+
recordWrite(connection, {
|
|
1540
|
+
kind: 'insert',
|
|
1541
|
+
summary: summarise('insert', steps),
|
|
1542
|
+
revertible: Object.keys(result.key ?? {}).length > 0,
|
|
1543
|
+
...(Object.keys(result.key ?? {}).length ? {} : { reason: 'That table has no primary key, so the row cannot be found again.' }),
|
|
1544
|
+
steps,
|
|
1545
|
+
});
|
|
1546
|
+
return send(res, 200, result);
|
|
1547
|
+
}
|
|
1548
|
+
catch (err) {
|
|
1549
|
+
if (err instanceof Refusal)
|
|
1550
|
+
throw err;
|
|
1551
|
+
/* "FOREIGN KEY constraint failed" names nothing on SQLite. The graph
|
|
1552
|
+
is right here, and one lookup on the failure path turns it into the
|
|
1553
|
+
column, the value and the row that is missing. */
|
|
1554
|
+
const message = await explainWriteFailure(adapter, schema, String(body.table ?? ''), values, err.message);
|
|
1555
|
+
return send(res, 200, { errors: [{ message }] });
|
|
1556
|
+
}
|
|
1557
|
+
}
|
|
1558
|
+
/**
|
|
1559
|
+
* Run a statement the user wrote.
|
|
1560
|
+
*
|
|
1561
|
+
* The safety argument is made in the adapter, not here — see `runSql` in
|
|
1562
|
+
* adapter.ts. What this adds is the permission question, which is the same
|
|
1563
|
+
* one /api/update asks and gets the same answer: writing needs a connection
|
|
1564
|
+
* whose config says so.
|
|
1565
|
+
*/
|
|
1566
|
+
if (path === '/api/sql') {
|
|
1567
|
+
if (!adapter.runSql) {
|
|
1568
|
+
return send(res, 501, {
|
|
1569
|
+
error: `${schema.dialect} connections cannot run SQL in this build.`,
|
|
1570
|
+
});
|
|
1571
|
+
}
|
|
1572
|
+
const text = String(body.text ?? '').trim();
|
|
1573
|
+
if (!text)
|
|
1574
|
+
return send(res, 400, { error: 'Nothing to run.' });
|
|
1575
|
+
const write = body.write === true;
|
|
1576
|
+
if (write && !registry.configOf(connection)?.writable) {
|
|
1577
|
+
return send(res, 403, {
|
|
1578
|
+
error: `"${registry.configOf(connection)?.name ?? connection}" is read-only. ` +
|
|
1579
|
+
`Add "writable": true to its entry in your tablewalk.json to allow edits.`,
|
|
1580
|
+
});
|
|
1581
|
+
}
|
|
1582
|
+
try {
|
|
1583
|
+
const result = await adapter.runSql({ text, limit: clampLimit(body.limit), write });
|
|
1584
|
+
return send(res, 200, result);
|
|
1585
|
+
}
|
|
1586
|
+
catch (err) {
|
|
1587
|
+
/* The database's own message, not ours. "cannot execute DELETE in a
|
|
1588
|
+
read-only transaction" says exactly what happened and why, and any
|
|
1589
|
+
paraphrase would say less. Answered as 200 with `errors` because a
|
|
1590
|
+
statement that does not run is the user's to fix, and the client
|
|
1591
|
+
renders it beside the editor rather than as a failed request. */
|
|
1592
|
+
return send(res, 200, { errors: [{ message: err.message }] });
|
|
1593
|
+
}
|
|
1594
|
+
}
|
|
1595
|
+
/**
|
|
1596
|
+
* Remove a row, having first said what removing it would do.
|
|
1597
|
+
*
|
|
1598
|
+
* This is the endpoint tablewalk can do better than most clients, and the
|
|
1599
|
+
* reason is structural rather than clever: it already counts what points at a
|
|
1600
|
+
* row, so it can answer "what breaks" *before* the delete rather than
|
|
1601
|
+
* reporting a constraint violation afterwards.
|
|
1602
|
+
*
|
|
1603
|
+
* The count alone is not the answer. Three rows pointing at this one means
|
|
1604
|
+
* three rows deleted under `cascade`, three rows left with a hole under `set
|
|
1605
|
+
* null`, and a refused delete under `restrict` — three different outcomes
|
|
1606
|
+
* behind one number. So the impact report carries the rule beside the count,
|
|
1607
|
+
* and a rule the adapter could not determine is reported as unknown rather
|
|
1608
|
+
* than assumed harmless.
|
|
1609
|
+
*
|
|
1610
|
+
* Two calls, deliberately. Without `confirm` this only reports; with it, it
|
|
1611
|
+
* deletes. A destructive endpoint that also happens to be the way you ask
|
|
1612
|
+
* about the damage is one typo away from doing it.
|
|
1613
|
+
*/
|
|
1614
|
+
/**
|
|
1615
|
+
* Undo, one door out from the browser's own.
|
|
1616
|
+
*
|
|
1617
|
+
* The browser used to keep its own stack of what it had written and put the
|
|
1618
|
+
* old values back itself. That covered edits made in this tab and nothing
|
|
1619
|
+
* else — not an insert, not a delete, and not a single thing an agent did
|
|
1620
|
+
* over MCP. The journal is the one record of what happened to this
|
|
1621
|
+
* database, so this is where undo lives now and the browser asks.
|
|
1622
|
+
*/
|
|
1623
|
+
if (path === '/api/revert') {
|
|
1624
|
+
if (!registry.configOf(connection)?.writable) {
|
|
1625
|
+
return send(res, 403, {
|
|
1626
|
+
error: `"${registry.configOf(connection)?.name ?? connection}" is read-only, so there is nothing to undo.`,
|
|
1627
|
+
});
|
|
1628
|
+
}
|
|
1629
|
+
const next = lastRevertible(connection);
|
|
1630
|
+
if (body.confirm !== true) {
|
|
1631
|
+
return send(res, 200, {
|
|
1632
|
+
reverted: false,
|
|
1633
|
+
writes: history(connection, typeof body.limit === 'number' ? body.limit : 10)
|
|
1634
|
+
.map(({ steps: _steps, connection: _c, ...rest }) => rest),
|
|
1635
|
+
next: next ? { id: next.id, kind: next.kind, at: next.at, summary: next.summary } : undefined,
|
|
1636
|
+
});
|
|
1637
|
+
}
|
|
1638
|
+
if (!next)
|
|
1639
|
+
return send(res, 200, { reverted: false, errors: [{ message: 'Nothing in this session can be undone.' }] });
|
|
1640
|
+
const result = await revert(adapter, connection, next);
|
|
1641
|
+
return send(res, 200, {
|
|
1642
|
+
reverted: result.reverted,
|
|
1643
|
+
undone: { id: next.id, kind: next.kind, summary: next.summary },
|
|
1644
|
+
steps: result.steps,
|
|
1645
|
+
errors: result.errors?.map((message) => ({ message })),
|
|
1646
|
+
});
|
|
1647
|
+
}
|
|
1648
|
+
if (path === '/api/delete') {
|
|
1649
|
+
if (!adapter.remove) {
|
|
1650
|
+
return send(res, 501, { error: `${schema.dialect} connections cannot delete in this build.` });
|
|
1651
|
+
}
|
|
1652
|
+
if (!registry.configOf(connection)?.writable) {
|
|
1653
|
+
return send(res, 403, {
|
|
1654
|
+
error: `"${registry.configOf(connection)?.name ?? connection}" is read-only. ` +
|
|
1655
|
+
`Add "writable": true to its entry in your tablewalk.json to allow edits.`,
|
|
1656
|
+
});
|
|
1657
|
+
}
|
|
1658
|
+
const tableId = String(body.table ?? '');
|
|
1659
|
+
const key = (body.key ?? {});
|
|
1660
|
+
const table = findTable(schema, tableId);
|
|
1661
|
+
if (!table)
|
|
1662
|
+
return send(res, 404, { error: `Unknown table "${tableId}"` });
|
|
1663
|
+
const impact = await deleteImpact(adapter, schema, tableId, key);
|
|
1664
|
+
if (body.confirm !== true)
|
|
1665
|
+
return send(res, 200, { impact, deleted: false });
|
|
1666
|
+
/* `restrict` and `no action` are left to the database rather than refused
|
|
1667
|
+
here. The engine enforces them and its message names the constraint;
|
|
1668
|
+
a second implementation of the same rule is how the two come to
|
|
1669
|
+
disagree, and tablewalk would be the one that was wrong. */
|
|
1670
|
+
try {
|
|
1671
|
+
const goneRow = await rowBeforeWrite(adapter, tableId, key);
|
|
1672
|
+
const result = await adapter.remove({ table: tableId, key });
|
|
1673
|
+
if (goneRow) {
|
|
1674
|
+
const steps = [{ table: tableId, key, before: { ...goneRow } }];
|
|
1675
|
+
recordWrite(connection, { kind: 'delete', summary: summarise('delete', steps), revertible: true, steps });
|
|
1676
|
+
}
|
|
1677
|
+
return send(res, 200, { impact, deleted: true, ...result });
|
|
1678
|
+
}
|
|
1679
|
+
catch (err) {
|
|
1680
|
+
return send(res, 200, { impact, deleted: false, errors: [{ message: err.message }] });
|
|
1681
|
+
}
|
|
1682
|
+
}
|
|
1683
|
+
/**
|
|
1684
|
+
* What each section of a page needs to run, for one root record.
|
|
1685
|
+
*
|
|
1686
|
+
* One call for the whole page rather than one per section, and it returns
|
|
1687
|
+
* *plans* rather than results: the client runs each view itself, so a count
|
|
1688
|
+
* over a large child table does not hold up the record's own name. Waiting
|
|
1689
|
+
* for all of them would make the slowest section the speed of the page.
|
|
1690
|
+
*
|
|
1691
|
+
* The views are built here because building them is path resolution against
|
|
1692
|
+
* a schema, and a second copy of those rules in the browser is the drift
|
|
1693
|
+
* this codebase has paid for in every feature that tried it.
|
|
1694
|
+
*
|
|
1695
|
+
* A section that cannot be built comes back with its reason, in place. One
|
|
1696
|
+
* bad path must not cost the other five sections — a page is most useful on
|
|
1697
|
+
* exactly the day something is misconfigured.
|
|
1698
|
+
*/
|
|
1699
|
+
if (path === '/api/pages/plan') {
|
|
1700
|
+
const key = (body.key ?? {});
|
|
1701
|
+
const name = registry.list().find((c) => c.id === connection)?.name;
|
|
1702
|
+
/* A page arrives either as a name or as itself.
|
|
1703
|
+
|
|
1704
|
+
Pages come from three places: the config file, the schema, and the
|
|
1705
|
+
builder — and the third lives in the browser's own storage, which the
|
|
1706
|
+
server has never seen. Planning by id alone meant a page someone had
|
|
1707
|
+
just built could be listed, opened and titled, and then rendered as
|
|
1708
|
+
"there is no page called that", because the only part that needed the
|
|
1709
|
+
schema was the part that had never heard of it.
|
|
1710
|
+
|
|
1711
|
+
Handing the definition over is safe for the same reason a filter is: it
|
|
1712
|
+
is structured, it is validated against this connection before anything
|
|
1713
|
+
runs, and every value in it ends up bound rather than written into
|
|
1714
|
+
SQL. */
|
|
1715
|
+
const asked = body.page;
|
|
1716
|
+
const found = typeof asked === 'object' && asked !== null
|
|
1717
|
+
? asked
|
|
1718
|
+
: String(asked ?? '').startsWith('suggested:')
|
|
1719
|
+
? suggestPage(schema, String(asked).slice('suggested:'.length))
|
|
1720
|
+
: configPages.find((p) => p.id === String(asked ?? '')
|
|
1721
|
+
&& (!p.connection || p.connection === connection || p.connection === name));
|
|
1722
|
+
if (!found)
|
|
1723
|
+
return send(res, 404, { error: `There is no page called "${String(asked ?? '')}".` });
|
|
1724
|
+
if (typeof found.base !== 'string' || !Array.isArray(found.sections)) {
|
|
1725
|
+
return send(res, 400, { error: 'That is not a page: it needs a "base" table and a list of sections.' });
|
|
1726
|
+
}
|
|
1727
|
+
const page = validatePage(schema, found);
|
|
1728
|
+
if (page.error)
|
|
1729
|
+
return send(res, 400, { error: page.error });
|
|
1730
|
+
const base = findTable(schema, page.base);
|
|
1731
|
+
const missing = primaryKey(base).filter((c) => key[c] === undefined);
|
|
1732
|
+
if (missing.length) {
|
|
1733
|
+
return send(res, 400, {
|
|
1734
|
+
error: `This page is about one ${base.name}, and the key is missing ${missing.join(', ')}.`,
|
|
1735
|
+
});
|
|
1736
|
+
}
|
|
1737
|
+
/* Sections about a record the root points at need that record's key,
|
|
1738
|
+
which only the root row knows. One read, of just the reference
|
|
1739
|
+
columns, shared by every such section — and a reference that is null
|
|
1740
|
+
resolves to `null`, which the section renders as its empty state
|
|
1741
|
+
rather than as a filter that can never match. */
|
|
1742
|
+
const ofs = [...new Set(page.sections.flatMap((s) => s.kind !== 'fields' && s.of ? [s.of] : []))];
|
|
1743
|
+
const ofKeys = {};
|
|
1744
|
+
if (ofs.length) {
|
|
1745
|
+
const resolvedOfs = ofs
|
|
1746
|
+
.map((of) => ({ of, ...resolveOf(schema, page.base, of) }))
|
|
1747
|
+
.filter((r) => r.fk);
|
|
1748
|
+
const wanted = [...new Set(resolvedOfs.flatMap((r) => r.fk.from.columns))];
|
|
1749
|
+
if (wanted.length) {
|
|
1750
|
+
const row = (await adapter.query({
|
|
1751
|
+
table: page.base,
|
|
1752
|
+
filter: { groups: [primaryKey(base).map((c) => ({ column: c, op: '=', value: key[c] }))] },
|
|
1753
|
+
columns: wanted,
|
|
1754
|
+
limit: 1,
|
|
1755
|
+
offset: 0,
|
|
1756
|
+
})).rows[0];
|
|
1757
|
+
for (const r of resolvedOfs) {
|
|
1758
|
+
const values = r.fk.from.columns.map((c) => row?.[c]);
|
|
1759
|
+
ofKeys[r.of] = values.every((v) => v !== null && v !== undefined)
|
|
1760
|
+
? Object.fromEntries(r.fk.to.columns.map((c, i) => [c, values[i]]))
|
|
1761
|
+
: null;
|
|
1762
|
+
}
|
|
1763
|
+
}
|
|
1764
|
+
}
|
|
1765
|
+
const plans = page.sections.map((section, index) => {
|
|
1766
|
+
if (section.kind === 'fields')
|
|
1767
|
+
return { index, kind: section.kind };
|
|
1768
|
+
const built = section.kind === 'metric'
|
|
1769
|
+
? metricView(schema, page, section, key, index, undefined, ofKeys)
|
|
1770
|
+
: listView(schema, page, section, key, index, undefined, ofKeys);
|
|
1771
|
+
/* `counts` tells the client to read the view's total rather than a
|
|
1772
|
+
value out of its row — see `metricView`. Without it the client would
|
|
1773
|
+
have to know which metrics are counts, which is the schema knowledge
|
|
1774
|
+
this endpoint exists to keep on this side. */
|
|
1775
|
+
return {
|
|
1776
|
+
index,
|
|
1777
|
+
kind: section.kind,
|
|
1778
|
+
view: built.view,
|
|
1779
|
+
empty: 'empty' in built ? built.empty : undefined,
|
|
1780
|
+
counts: 'counts' in built ? built.counts : undefined,
|
|
1781
|
+
/* Two ways to open what a section shows, and which one is offered
|
|
1782
|
+
depends on what the section is. A one-hop filter is expressible in
|
|
1783
|
+
the query language, so the number opens the rows it counted in the
|
|
1784
|
+
ordinary grid. Anything further out is not — the language has no
|
|
1785
|
+
dotted paths — and the honest destination is the composer, on the
|
|
1786
|
+
view itself. Both are computed here because both are grammar, and
|
|
1787
|
+
the grammar lives on this side. */
|
|
1788
|
+
query: built.view ? sectionQuery(built.view) : undefined,
|
|
1789
|
+
viewText: built.view ? viewToTextWithSchema(schema, built.view) : undefined,
|
|
1790
|
+
error: built.error,
|
|
1791
|
+
};
|
|
1792
|
+
});
|
|
1793
|
+
return send(res, 200, { page, plans });
|
|
1794
|
+
}
|
|
1795
|
+
if (path === '/api/count') {
|
|
1796
|
+
/* Looked up here rather than left to the adapter. Without this an unknown
|
|
1797
|
+
table went straight to the database and came back as whatever it says
|
|
1798
|
+
about a missing relation, with a 500 — while `/api/query` answered the
|
|
1799
|
+
same mistake with a clean 404. One mistake, two vocabularies. */
|
|
1800
|
+
const countTable = String(body.table ?? '');
|
|
1801
|
+
if (!findTable(schema, countTable)) {
|
|
1802
|
+
return send(res, 404, { error: `Unknown table "${countTable}"` });
|
|
1803
|
+
}
|
|
1804
|
+
const result = await countFor(adapter, String(body.table ?? ''), body.filter, { force: body.exactCount === true });
|
|
1805
|
+
return send(res, 200, { count: result.count, exact: result.exact });
|
|
1806
|
+
}
|
|
1807
|
+
/**
|
|
1808
|
+
* The endpoint the whole tool exists for: given one row, how many rows in
|
|
1809
|
+
* every other table point at it, and what would you click to see them.
|
|
1810
|
+
*
|
|
1811
|
+
* Counting is done server-side and in parallel because a row in a
|
|
1812
|
+
* well-normalised schema can be referenced from a dozen places, and a dozen
|
|
1813
|
+
* sequential round trips is the difference between instant and sluggish.
|
|
1814
|
+
*/
|
|
1815
|
+
if (path === '/api/references') {
|
|
1816
|
+
const tableId = String(body.table ?? '');
|
|
1817
|
+
const key = (body.key ?? {});
|
|
1818
|
+
const table = findTable(schema, tableId);
|
|
1819
|
+
if (!table)
|
|
1820
|
+
return send(res, 404, { error: `Unknown table "${tableId}"` });
|
|
1821
|
+
const incoming = referencesTo(schema, tableId);
|
|
1822
|
+
/* Counted a few at a time rather than all at once.
|
|
1823
|
+
|
|
1824
|
+
Every one of these is a filtered `COUNT(*)`, which cannot be estimated —
|
|
1825
|
+
the catalog knows how big a table is and nothing about how many of its
|
|
1826
|
+
rows point at *this* one. On a hub table in a wide schema, opening a
|
|
1827
|
+
record fired one of them per incoming constraint simultaneously: on the
|
|
1828
|
+
66-table demo that is a dozen scans in parallel, each able to run to the
|
|
1829
|
+
30-second statement timeout, on a connection also serving the page they
|
|
1830
|
+
are for.
|
|
1831
|
+
|
|
1832
|
+
A small window keeps the panel filling in steadily instead of arriving
|
|
1833
|
+
all at once or not at all, and leaves the connection able to answer the
|
|
1834
|
+
record itself. */
|
|
1835
|
+
const results = await inBatches(incoming, 4, async (fk) => {
|
|
1836
|
+
/* Every column of the key has to match, which is what makes this
|
|
1837
|
+
correct for composite keys and not just single-column ones. */
|
|
1838
|
+
const conditions = fk.to.columns.map((toCol, i) => ({
|
|
1839
|
+
column: fk.from.columns[i] ?? fk.from.columns[0],
|
|
1840
|
+
op: '=',
|
|
1841
|
+
value: key[toCol],
|
|
1842
|
+
}));
|
|
1843
|
+
const usable = conditions.every((c) => c.value !== undefined && c.value !== null);
|
|
1844
|
+
if (!usable)
|
|
1845
|
+
return null;
|
|
1846
|
+
const filter = { groups: [conditions] };
|
|
1847
|
+
try {
|
|
1848
|
+
const count = await adapter.count(fk.from.table, filter);
|
|
1849
|
+
return {
|
|
1850
|
+
table: fk.from.table,
|
|
1851
|
+
columns: fk.from.columns,
|
|
1852
|
+
// The matching key columns on this row's side, so the client can
|
|
1853
|
+
// pair them by position rather than guessing from key order.
|
|
1854
|
+
toColumns: fk.to.columns,
|
|
1855
|
+
constraint: fk.name,
|
|
1856
|
+
count,
|
|
1857
|
+
filter,
|
|
1858
|
+
label: labelColumn(findTable(schema, fk.from.table)),
|
|
1859
|
+
};
|
|
1860
|
+
}
|
|
1861
|
+
catch (err) {
|
|
1862
|
+
// One unreadable table (permissions, a dropped view) should not
|
|
1863
|
+
// blank the whole panel.
|
|
1864
|
+
return {
|
|
1865
|
+
table: fk.from.table,
|
|
1866
|
+
columns: fk.from.columns,
|
|
1867
|
+
toColumns: fk.to.columns,
|
|
1868
|
+
constraint: fk.name,
|
|
1869
|
+
count: -1,
|
|
1870
|
+
filter,
|
|
1871
|
+
error: err.message,
|
|
1872
|
+
};
|
|
1873
|
+
}
|
|
1874
|
+
});
|
|
1875
|
+
return send(res, 200, {
|
|
1876
|
+
table: tableId,
|
|
1877
|
+
primaryKey: primaryKey(table),
|
|
1878
|
+
references: results.filter(Boolean),
|
|
1879
|
+
});
|
|
1880
|
+
}
|
|
1881
|
+
return send(res, 404, { error: `No such endpoint: ${path}` });
|
|
1882
|
+
}
|
|
1883
|
+
/**
|
|
1884
|
+
* Expand a config view against a live schema, and say what is wrong with it.
|
|
1885
|
+
*
|
|
1886
|
+
* A view written as text cannot be expanded when the config file is read —
|
|
1887
|
+
* there is no connection open yet, and the text names tables. So it is
|
|
1888
|
+
* carried through and resolved here, where a schema exists. Both halves are
|
|
1889
|
+
* returned: the structure, so the composer can edit it by clicking, and the
|
|
1890
|
+
* text, so it can be read and edited as a statement.
|
|
1891
|
+
*
|
|
1892
|
+
* A view that does not resolve comes back *with its reason*, never dropped.
|
|
1893
|
+
* Dropping it gives someone a shorter list and no explanation, which is the
|
|
1894
|
+
* failure this whole tool argues against.
|
|
1895
|
+
*/
|
|
1896
|
+
function materialise(schema, view) {
|
|
1897
|
+
if (!view.text) {
|
|
1898
|
+
const error = validateView(schema, view);
|
|
1899
|
+
return { ...view, error, text: error ? undefined : viewToTextWithSchema(schema, view) };
|
|
1900
|
+
}
|
|
1901
|
+
const parsed = textToView(schema, view.text, { id: view.id, name: view.name });
|
|
1902
|
+
if (!parsed.view || parsed.errors.length) {
|
|
1903
|
+
return { ...view, error: parsed.errors[0]?.message ?? `"${view.name}" could not be read.` };
|
|
1904
|
+
}
|
|
1905
|
+
const expanded = { ...parsed.view, source: view.source, connection: view.connection, text: view.text };
|
|
1906
|
+
return { ...expanded, error: validateView(schema, expanded) };
|
|
1907
|
+
}
|
|
1908
|
+
/**
|
|
1909
|
+
* Run a view for the query bar, answering in the shape the grid expects.
|
|
1910
|
+
*
|
|
1911
|
+
* Same keys as `/api/run`'s ordinary answer — `table`, `columns`, `rows`,
|
|
1912
|
+
* `total`, `explain` — so the renderer needs no branch. What it gains is
|
|
1913
|
+
* `resolved`, which says what each output column actually is; a client that
|
|
1914
|
+
* ignores it renders path-named columns as plain text, which is a degradation
|
|
1915
|
+
* rather than a break.
|
|
1916
|
+
*/
|
|
1917
|
+
async function runViewForBar(res, adapter, schema, view, body) {
|
|
1918
|
+
const limit = clampLimit(body.limit ?? view.limit);
|
|
1919
|
+
const offset = clampOffset(body.offset);
|
|
1920
|
+
try {
|
|
1921
|
+
const [result, total] = await Promise.all([
|
|
1922
|
+
adapter.runView({ ...view, limit }, offset),
|
|
1923
|
+
adapter.countView ? adapter.countView(view) : Promise.resolve(0),
|
|
1924
|
+
]);
|
|
1925
|
+
return send(res, 200, {
|
|
1926
|
+
table: view.base,
|
|
1927
|
+
view,
|
|
1928
|
+
text: viewToTextWithSchema(schema, view),
|
|
1929
|
+
columns: result.columns,
|
|
1930
|
+
resolved: result.resolved,
|
|
1931
|
+
joins: result.joins,
|
|
1932
|
+
rows: result.rows,
|
|
1933
|
+
ms: result.ms,
|
|
1934
|
+
total,
|
|
1935
|
+
limit,
|
|
1936
|
+
offset,
|
|
1937
|
+
explain: explainView(schema, { ...view, limit }, styleFor(schema)),
|
|
1938
|
+
errors: [],
|
|
1939
|
+
});
|
|
1940
|
+
}
|
|
1941
|
+
catch (err) {
|
|
1942
|
+
if (err instanceof PathError)
|
|
1943
|
+
return refuse(res, 400, [err.message]);
|
|
1944
|
+
throw err;
|
|
1945
|
+
}
|
|
1946
|
+
}
|
|
1947
|
+
/**
|
|
1948
|
+
* Which dialect writes the SQL shown in the explain panel.
|
|
1949
|
+
*
|
|
1950
|
+
* The adapter picks its own style when it runs the statement; this is the
|
|
1951
|
+
* copy the user reads, and it has to agree with what actually ran or the
|
|
1952
|
+
* panel is teaching the wrong thing.
|
|
1953
|
+
*/
|
|
1954
|
+
function styleFor(schema) {
|
|
1955
|
+
if (schema.dialect === 'postgres')
|
|
1956
|
+
return POSTGRES_STYLE;
|
|
1957
|
+
if (schema.dialect === 'mysql')
|
|
1958
|
+
return MYSQL_STYLE;
|
|
1959
|
+
return ANSI_STYLE;
|
|
1960
|
+
}
|
|
1961
|
+
/**
|
|
1962
|
+
* Coerce a JSON body into a `ViewDef`.
|
|
1963
|
+
*
|
|
1964
|
+
* Shape only. Whether the paths mean anything is `compileView`'s job, and it
|
|
1965
|
+
* refuses what it cannot resolve — so this does not need to know the schema,
|
|
1966
|
+
* and there is no second, weaker validator to disagree with the first.
|
|
1967
|
+
*/
|
|
1968
|
+
function normaliseView(raw) {
|
|
1969
|
+
const v = (raw ?? {});
|
|
1970
|
+
const columns = Array.isArray(v.columns) ? v.columns : [];
|
|
1971
|
+
const orderBy = Array.isArray(v.orderBy) ? v.orderBy : [];
|
|
1972
|
+
return {
|
|
1973
|
+
id: String(v.id ?? ''),
|
|
1974
|
+
name: String(v.name ?? ''),
|
|
1975
|
+
base: String(v.base ?? '').trim(),
|
|
1976
|
+
columns: columns
|
|
1977
|
+
.map((c) => typeof c === 'string'
|
|
1978
|
+
? { path: c }
|
|
1979
|
+
: { path: String(c?.path ?? ''), alias: c?.alias })
|
|
1980
|
+
.filter((c) => c.path),
|
|
1981
|
+
aggregates: Array.isArray(v.aggregates)
|
|
1982
|
+
? v.aggregates
|
|
1983
|
+
: undefined,
|
|
1984
|
+
filter: (v.filter ?? undefined),
|
|
1985
|
+
orderBy: orderBy
|
|
1986
|
+
.map((o) => {
|
|
1987
|
+
const entry = o;
|
|
1988
|
+
return {
|
|
1989
|
+
path: String(entry?.path ?? ''),
|
|
1990
|
+
direction: entry?.direction === 'desc' ? 'desc' : 'asc',
|
|
1991
|
+
};
|
|
1992
|
+
})
|
|
1993
|
+
.filter((o) => o.path),
|
|
1994
|
+
limit: typeof v.limit === 'number' ? v.limit : undefined,
|
|
1995
|
+
};
|
|
1996
|
+
}
|
|
1997
|
+
const MIME = {
|
|
1998
|
+
'.html': 'text/html; charset=utf-8',
|
|
1999
|
+
'.js': 'text/javascript; charset=utf-8',
|
|
2000
|
+
'.css': 'text/css; charset=utf-8',
|
|
2001
|
+
'.svg': 'image/svg+xml',
|
|
2002
|
+
'.json': 'application/json',
|
|
2003
|
+
};
|
|
2004
|
+
async function serveStatic(res, path) {
|
|
2005
|
+
// Normalise before joining so `..` cannot walk out of the client directory.
|
|
2006
|
+
const rel = normalize(path === '/' ? '/index.html' : path).replace(/^(\.\.[/\\])+/, '');
|
|
2007
|
+
const file = join(CLIENT_DIR, rel);
|
|
2008
|
+
if (!file.startsWith(CLIENT_DIR)) {
|
|
2009
|
+
res.writeHead(403).end('Forbidden');
|
|
2010
|
+
return;
|
|
2011
|
+
}
|
|
2012
|
+
try {
|
|
2013
|
+
const data = await readFile(file);
|
|
2014
|
+
res.writeHead(200, {
|
|
2015
|
+
'content-type': MIME[extname(file)] ?? 'application/octet-stream',
|
|
2016
|
+
/* The fallback above is the reason: a file whose extension we do not
|
|
2017
|
+
know is served as a byte stream, and without this a browser is free
|
|
2018
|
+
to sniff the bytes and decide it is HTML after all. The client
|
|
2019
|
+
directory only ever holds what shipped, so this guards a mistake
|
|
2020
|
+
rather than an attack — which is what it costs nothing to do. */
|
|
2021
|
+
'x-content-type-options': 'nosniff',
|
|
2022
|
+
/* The client ships as unversioned files, so without this a browser
|
|
2023
|
+
applies its own heuristic freshness and keeps serving the old app
|
|
2024
|
+
after an upgrade — silently, and looking like the fix did not work.
|
|
2025
|
+
`no-cache` still allows a 304, it just requires asking first. */
|
|
2026
|
+
'cache-control': 'no-cache',
|
|
2027
|
+
});
|
|
2028
|
+
res.end(data);
|
|
2029
|
+
}
|
|
2030
|
+
catch {
|
|
2031
|
+
// Single-page app: unknown paths are routes, not missing files.
|
|
2032
|
+
try {
|
|
2033
|
+
const html = await readFile(join(CLIENT_DIR, 'index.html'));
|
|
2034
|
+
res.writeHead(200, {
|
|
2035
|
+
'content-type': MIME['.html'], 'cache-control': 'no-cache',
|
|
2036
|
+
'x-content-type-options': 'nosniff',
|
|
2037
|
+
});
|
|
2038
|
+
res.end(html);
|
|
2039
|
+
}
|
|
2040
|
+
catch {
|
|
2041
|
+
res.writeHead(404).end('Not found');
|
|
2042
|
+
}
|
|
2043
|
+
}
|
|
2044
|
+
}
|
|
2045
|
+
function send(res, status, body) {
|
|
2046
|
+
const json = JSON.stringify(body);
|
|
2047
|
+
res.writeHead(status, {
|
|
2048
|
+
'content-type': 'application/json; charset=utf-8',
|
|
2049
|
+
'content-length': Buffer.byteLength(json),
|
|
2050
|
+
'x-content-type-options': 'nosniff',
|
|
2051
|
+
});
|
|
2052
|
+
res.end(json);
|
|
2053
|
+
}
|
|
2054
|
+
/** Bounded, because an unbounded request body is a denial of service. */
|
|
2055
|
+
const MAX_BODY = 1024 * 256;
|
|
2056
|
+
/**
|
|
2057
|
+
* An error that already knows what status it deserves.
|
|
2058
|
+
*
|
|
2059
|
+
* Without this everything that was not a `PathError` fell out of `createApp`'s
|
|
2060
|
+
* catch as a 500, so a body the caller sent malformed — or one they sent too
|
|
2061
|
+
* much of — was reported to them as an outage on this end. The status is the
|
|
2062
|
+
* only part of a failure most clients branch on; getting it wrong sends
|
|
2063
|
+
* people to look at the wrong machine.
|
|
2064
|
+
*/
|
|
2065
|
+
export class HttpError extends Error {
|
|
2066
|
+
status;
|
|
2067
|
+
constructor(status, message) {
|
|
2068
|
+
super(message);
|
|
2069
|
+
this.status = status;
|
|
2070
|
+
this.name = 'HttpError';
|
|
2071
|
+
}
|
|
2072
|
+
}
|
|
2073
|
+
/**
|
|
2074
|
+
* A refusal that answers to both names.
|
|
2075
|
+
*
|
|
2076
|
+
* The view endpoints report their failures as `errors: [{message}]`, because
|
|
2077
|
+
* a half-built view can be wrong in several places at once; every other
|
|
2078
|
+
* endpoint reports `error`. A client that branches on the status and then
|
|
2079
|
+
* reads `.error` showed a blank message on exactly the endpoints where the
|
|
2080
|
+
* message mattered most. Both are sent — the list stays the shape the view
|
|
2081
|
+
* editor reads, and `error` is the first of them.
|
|
2082
|
+
*/
|
|
2083
|
+
function refuse(res, status, messages) {
|
|
2084
|
+
send(res, status, {
|
|
2085
|
+
error: messages[0],
|
|
2086
|
+
errors: messages.map((message) => ({ message })),
|
|
2087
|
+
});
|
|
2088
|
+
}
|
|
2089
|
+
async function readJson(req) {
|
|
2090
|
+
const chunks = [];
|
|
2091
|
+
let size = 0;
|
|
2092
|
+
for await (const chunk of req) {
|
|
2093
|
+
size += chunk.length;
|
|
2094
|
+
if (size > MAX_BODY)
|
|
2095
|
+
throw new HttpError(413, 'Request body too large.');
|
|
2096
|
+
chunks.push(chunk);
|
|
2097
|
+
}
|
|
2098
|
+
if (!chunks.length)
|
|
2099
|
+
return {};
|
|
2100
|
+
try {
|
|
2101
|
+
/* `__proto__` in a JSON body is not a key anyone means. Parsed into an
|
|
2102
|
+
object and then spread or assigned onto another, it stops being data and
|
|
2103
|
+
starts being the prototype — which is how a request body turns into a
|
|
2104
|
+
change in how unrelated objects behave. Dropped at the parse rather than
|
|
2105
|
+
guarded at each of the dozen places a body is read. */
|
|
2106
|
+
return JSON.parse(Buffer.concat(chunks).toString('utf8'), (key, value) => key === '__proto__' || key === 'constructor' || key === 'prototype'
|
|
2107
|
+
? undefined
|
|
2108
|
+
: value);
|
|
2109
|
+
}
|
|
2110
|
+
catch {
|
|
2111
|
+
throw new HttpError(400, 'Request body was not valid JSON.');
|
|
2112
|
+
}
|
|
2113
|
+
}
|
|
2114
|
+
async function openBrowser(url) {
|
|
2115
|
+
const { spawn } = await import('node:child_process');
|
|
2116
|
+
const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
|
|
2117
|
+
spawn(cmd, [url], { detached: true, stdio: 'ignore' }).unref();
|
|
2118
|
+
}
|
|
2119
|
+
/* Started only when run, not when imported.
|
|
2120
|
+
|
|
2121
|
+
The tests import this module for `crossOriginRefusal` and for `createApp`,
|
|
2122
|
+
which they stand up on an ephemeral port; a module that starts a server as a
|
|
2123
|
+
side effect of being read cannot be tested — it printed its usage and
|
|
2124
|
+
listened on 4111 in the middle of the suite. */
|
|
2125
|
+
/* Matched by identity, not by name. The old test — does argv[1] *look like*
|
|
2126
|
+
server/index — held until npm ran this through a bin shim, which is a
|
|
2127
|
+
symlink called `tablewalk`: the module loaded, matched nothing, and
|
|
2128
|
+
`npx tablewalk` exited 0 having done exactly that. Resolving the symlink
|
|
2129
|
+
and comparing against our own URL is the question actually being asked —
|
|
2130
|
+
"am I the script that was invoked" — and it keeps the property the guard
|
|
2131
|
+
exists for: a test that imports this module boots nothing. */
|
|
2132
|
+
const invoked = (() => {
|
|
2133
|
+
const arg = process.argv[1];
|
|
2134
|
+
if (!arg)
|
|
2135
|
+
return false;
|
|
2136
|
+
try {
|
|
2137
|
+
return pathToFileURL(realpathSync(arg)).href === import.meta.url;
|
|
2138
|
+
}
|
|
2139
|
+
catch {
|
|
2140
|
+
return false;
|
|
2141
|
+
}
|
|
2142
|
+
})();
|
|
2143
|
+
if (invoked) {
|
|
2144
|
+
void main();
|
|
2145
|
+
}
|
|
2146
|
+
/**
|
|
2147
|
+
* One row by its whole primary key, or undefined — the before-image a revert
|
|
2148
|
+
* needs. Failures are swallowed to undefined: a write must not be refused
|
|
2149
|
+
* because the journal could not read what it was about to change.
|
|
2150
|
+
*/
|
|
2151
|
+
async function rowBeforeWrite(adapter, table, key) {
|
|
2152
|
+
try {
|
|
2153
|
+
const groups = [Object.entries(key).map(([column, value]) => ({ column, op: '=', value }))];
|
|
2154
|
+
const result = await adapter.query({ table, filter: { groups }, limit: 1, offset: 0 });
|
|
2155
|
+
return result.rows[0];
|
|
2156
|
+
}
|
|
2157
|
+
catch {
|
|
2158
|
+
return undefined;
|
|
2159
|
+
}
|
|
2160
|
+
}
|
|
2161
|
+
/**
|
|
2162
|
+
* A config layout, with its list sections keyed the way the client expects.
|
|
2163
|
+
*
|
|
2164
|
+
* A list is stored against the constraint name of the foreign key pointing
|
|
2165
|
+
* back at the record — the right key, because a child can point at a parent
|
|
2166
|
+
* twice and the column alone would not say which — and the wrong thing to ask
|
|
2167
|
+
* anyone to type into a file. So a config may name the pair instead
|
|
2168
|
+
* (`"from": "invoice", "path": "customer_id"`) and it is turned into the
|
|
2169
|
+
* constraint here, where there is a schema to turn it with.
|
|
2170
|
+
*
|
|
2171
|
+
* A pair that names no real relationship keeps its section and gains an
|
|
2172
|
+
* `error`, rather than disappearing: the same choice `/api/views` makes for a
|
|
2173
|
+
* view that will not compile. A layout that silently lost a list is a layout
|
|
2174
|
+
* nobody can debug.
|
|
2175
|
+
*/
|
|
2176
|
+
function resolveConfigLayout(schema, tableId, layout) {
|
|
2177
|
+
const incoming = referencesTo(schema, tableId);
|
|
2178
|
+
const sections = layout.sections.map((section) => {
|
|
2179
|
+
if (section.kind !== 'list' || section.via)
|
|
2180
|
+
return section;
|
|
2181
|
+
const from = String(section.from ?? '');
|
|
2182
|
+
const column = String(section.path ?? '');
|
|
2183
|
+
const child = tableNamed(schema, from);
|
|
2184
|
+
const match = child && incoming.find((fk) => fk.from.table === child.id
|
|
2185
|
+
&& fk.from.columns.some((c) => c.toLowerCase() === column.toLowerCase()));
|
|
2186
|
+
const { from: _from, path: _path, ...rest } = section;
|
|
2187
|
+
if (!match) {
|
|
2188
|
+
return {
|
|
2189
|
+
...rest,
|
|
2190
|
+
via: '',
|
|
2191
|
+
error: `No foreign key from ${from}.${column} back to ${tableId}`
|
|
2192
|
+
+ `${child ? '' : ` — and no table called "${from}"`}.`,
|
|
2193
|
+
};
|
|
2194
|
+
}
|
|
2195
|
+
return { ...rest, via: match.name };
|
|
2196
|
+
});
|
|
2197
|
+
return {
|
|
2198
|
+
id: `config-${layout.table}`,
|
|
2199
|
+
name: layout.name ?? 'From config',
|
|
2200
|
+
base: tableId,
|
|
2201
|
+
sections,
|
|
2202
|
+
source: 'config',
|
|
2203
|
+
};
|
|
2204
|
+
}
|