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.
Files changed (97) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +553 -0
  3. package/dist/adapters/adapter.js +372 -0
  4. package/dist/adapters/connect.js +33 -0
  5. package/dist/adapters/mysql.js +951 -0
  6. package/dist/adapters/postgres.js +1000 -0
  7. package/dist/adapters/sqlite.js +781 -0
  8. package/dist/client/agent.js +262 -0
  9. package/dist/client/app.js +973 -0
  10. package/dist/client/arrange.js +254 -0
  11. package/dist/client/ask.js +133 -0
  12. package/dist/client/breakdown.js +317 -0
  13. package/dist/client/clauses.js +390 -0
  14. package/dist/client/columns.js +98 -0
  15. package/dist/client/complete.js +437 -0
  16. package/dist/client/compose.js +166 -0
  17. package/dist/client/composer.css +495 -0
  18. package/dist/client/composer.js +1972 -0
  19. package/dist/client/connections.js +234 -0
  20. package/dist/client/connmanager.js +962 -0
  21. package/dist/client/connurl.js +188 -0
  22. package/dist/client/core.js +893 -0
  23. package/dist/client/deeplink.js +270 -0
  24. package/dist/client/delete.js +144 -0
  25. package/dist/client/diagram.js +885 -0
  26. package/dist/client/dropdown.js +279 -0
  27. package/dist/client/export.js +456 -0
  28. package/dist/client/features.css +524 -0
  29. package/dist/client/findvalue.js +169 -0
  30. package/dist/client/grid.js +205 -0
  31. package/dist/client/handoff.js +153 -0
  32. package/dist/client/help.css +145 -0
  33. package/dist/client/help.js +881 -0
  34. package/dist/client/history.js +222 -0
  35. package/dist/client/index.html +116 -0
  36. package/dist/client/insert.js +151 -0
  37. package/dist/client/menu.js +160 -0
  38. package/dist/client/nested.js +255 -0
  39. package/dist/client/page.css +713 -0
  40. package/dist/client/page.js +1345 -0
  41. package/dist/client/pagebuilder.js +1222 -0
  42. package/dist/client/pagemarks.js +95 -0
  43. package/dist/client/palette.js +374 -0
  44. package/dist/client/peek.js +254 -0
  45. package/dist/client/picker.js +139 -0
  46. package/dist/client/pins.js +140 -0
  47. package/dist/client/prompt.js +129 -0
  48. package/dist/client/record.js +707 -0
  49. package/dist/client/schemaexport.js +242 -0
  50. package/dist/client/schematext.js +125 -0
  51. package/dist/client/shape.js +178 -0
  52. package/dist/client/shapecheck.js +129 -0
  53. package/dist/client/skeleton.js +139 -0
  54. package/dist/client/sql.css +126 -0
  55. package/dist/client/sql.js +398 -0
  56. package/dist/client/sqlcomplete.js +163 -0
  57. package/dist/client/sqlsaved.js +107 -0
  58. package/dist/client/style.css +2711 -0
  59. package/dist/client/summary.js +259 -0
  60. package/dist/client/table.js +1035 -0
  61. package/dist/client/template.js +539 -0
  62. package/dist/client/theme.js +74 -0
  63. package/dist/client/tour.js +324 -0
  64. package/dist/client/undo.js +105 -0
  65. package/dist/client/url.js +166 -0
  66. package/dist/client/value.js +223 -0
  67. package/dist/client/views.js +215 -0
  68. package/dist/client/virtual.js +176 -0
  69. package/dist/client/welcome.js +170 -0
  70. package/dist/client/write.js +414 -0
  71. package/dist/server/changeimpact.js +195 -0
  72. package/dist/server/connections.js +615 -0
  73. package/dist/server/constraints.js +62 -0
  74. package/dist/server/credentials.js +230 -0
  75. package/dist/server/fixture.js +199 -0
  76. package/dist/server/graph.js +194 -0
  77. package/dist/server/impact.js +48 -0
  78. package/dist/server/index.js +2204 -0
  79. package/dist/server/journal.js +173 -0
  80. package/dist/server/layouts.js +128 -0
  81. package/dist/server/mcp.js +2840 -0
  82. package/dist/server/shapeonly.js +91 -0
  83. package/dist/shared/breakdown.js +231 -0
  84. package/dist/shared/breakdowntext.js +257 -0
  85. package/dist/shared/diff.js +130 -0
  86. package/dist/shared/like.js +29 -0
  87. package/dist/shared/lint.js +149 -0
  88. package/dist/shared/order.js +133 -0
  89. package/dist/shared/page.js +932 -0
  90. package/dist/shared/query.js +831 -0
  91. package/dist/shared/recordview.js +343 -0
  92. package/dist/shared/schema.js +377 -0
  93. package/dist/shared/sqlsaved.js +67 -0
  94. package/dist/shared/view.js +981 -0
  95. package/dist/shared/viewtext.js +273 -0
  96. package/dist/shared/vocabulary.js +164 -0
  97. 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
+ }