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,615 @@
1
+ /**
2
+ * The connection registry.
3
+ *
4
+ * The server used to hold exactly one adapter. It now holds several, opened
5
+ * lazily: naming a connection costs nothing until you look at it, which
6
+ * matters when a config file lists eight databases and you only wanted one.
7
+ *
8
+ * Two rules shape everything here.
9
+ *
10
+ * A connection string is a credential. It is read from disk or the command
11
+ * line, kept in this module, and never sent to the browser — the client sees
12
+ * an id, a name and a dialect. Anything that reaches the page has been
13
+ * through `publicView`, and there is a test asserting a password does not
14
+ * survive the trip.
15
+ *
16
+ * Introspection is per connection and cached, because it is the expensive
17
+ * part of connecting and the schema does not change while you browse. It is
18
+ * refreshable on request, since sometimes it does.
19
+ */
20
+ import { readFile, writeFile, mkdir } from 'node:fs/promises';
21
+ import { dirname } from 'node:path';
22
+ import { homedir } from 'node:os';
23
+ import { join } from 'node:path';
24
+ import { connect } from '../adapters/connect.js';
25
+ import { shapeOnly } from './shapeonly.js';
26
+ import { resolveConnection, keychainAvailable, keychainSet, writeCredential } from './credentials.js';
27
+ export class Registry {
28
+ entries = new Map();
29
+ activeId = null;
30
+ add(config) {
31
+ const id = config.id ?? slug(config.name);
32
+ /* Ids have to be unique because the client addresses connections by them;
33
+ a duplicate name gets a suffix rather than silently replacing. */
34
+ let unique = id;
35
+ let n = 2;
36
+ while (this.entries.has(unique))
37
+ unique = `${id}-${n++}`;
38
+ const full = { ...config, id: unique };
39
+ this.entries.set(unique, { config: full, busy: 0, idle: [] });
40
+ if (!this.activeId)
41
+ this.activeId = unique;
42
+ return full;
43
+ }
44
+ has(id) {
45
+ return this.entries.has(id);
46
+ }
47
+ /** The stored configuration, for the manager to edit and to write back. */
48
+ configOf(id) {
49
+ return this.entries.get(id)?.config;
50
+ }
51
+ /** Every stored configuration, in order, including its secrets as written. */
52
+ configs() {
53
+ return [...this.entries.values()].map((e) => e.config);
54
+ }
55
+ /**
56
+ * Change a connection's name, target or schema list.
57
+ *
58
+ * The open adapter is discarded whenever the target changes, because an
59
+ * entry whose URL says one database while its schema describes another is
60
+ * the worst of both: it looks edited and behaves as it did before. The name
61
+ * alone can change without reconnecting.
62
+ */
63
+ async update(id, patch) {
64
+ const entry = this.entries.get(id);
65
+ if (!entry)
66
+ throw new Error(`No connection called "${id}".`);
67
+ const retarget = (patch.url !== undefined && patch.url !== entry.config.url) ||
68
+ (patch.schemas !== undefined &&
69
+ JSON.stringify(patch.schemas) !== JSON.stringify(entry.config.schemas));
70
+ entry.config = { ...entry.config, ...patch, id };
71
+ if (retarget) {
72
+ await this.drain(id);
73
+ await entry.adapter?.close().catch(() => { });
74
+ entry.adapter = undefined;
75
+ entry.schema = undefined;
76
+ entry.error = undefined;
77
+ }
78
+ return entry.config;
79
+ }
80
+ /**
81
+ * Forget a connection.
82
+ *
83
+ * This used to refuse the last one, and the reason it gave was honest: a
84
+ * browser with no database to browse was a page that could not do anything,
85
+ * and the way out of that state was not obvious. Both halves of that have
86
+ * stopped being true — the server starts with nothing now, and the page it
87
+ * serves in that state exists to do exactly one thing, which is get you out
88
+ * of it. A rule that guards against a state the product supports is just a
89
+ * connection you cannot remove.
90
+ *
91
+ * The active connection moves to whatever remains, or to nothing.
92
+ */
93
+ async remove(id) {
94
+ const entry = this.entries.get(id);
95
+ if (!entry)
96
+ throw new Error(`No connection called "${id}".`);
97
+ await this.drain(id);
98
+ await entry.adapter?.close().catch(() => { });
99
+ this.entries.delete(id);
100
+ if (this.activeId === id)
101
+ this.activeId = [...this.entries.keys()][0] ?? null;
102
+ }
103
+ get active() {
104
+ return this.activeId;
105
+ }
106
+ /**
107
+ * Choose the default connection, or `null` for none.
108
+ *
109
+ * Null is reachable now that the server starts with nothing, and it is what
110
+ * an add has to fall back to when the connection it just made turns out not
111
+ * to open: `add` makes the first one active before anything has tried to
112
+ * dial it, so a failed first add left the whole application pointed at a
113
+ * connection that had never worked.
114
+ */
115
+ setActive(id) {
116
+ if (id !== null && !this.entries.has(id))
117
+ throw new Error(`No connection called "${id}".`);
118
+ this.activeId = id;
119
+ }
120
+ list() {
121
+ return [...this.entries.values()].map((e) => publicView(e));
122
+ }
123
+ /**
124
+ * Open a connection if it is not already open, and return it with its
125
+ * schema. A failure is recorded on the entry rather than thrown away, so
126
+ * the sidebar can show which connection is broken and why.
127
+ */
128
+ async open(id) {
129
+ const entry = this.entries.get(id);
130
+ if (!entry)
131
+ throw new Error(`No connection called "${id}".`);
132
+ if (entry.adapter && entry.schema)
133
+ return { adapter: entry.adapter, schema: entry.schema };
134
+ /* Already dialling: wait for that one rather than starting a second.
135
+ Two tabs selecting the same unopened connection at the same moment used
136
+ to open two real connections and leak whichever finished first. */
137
+ if (entry.opening)
138
+ return entry.opening;
139
+ entry.opening = this.dial(entry);
140
+ try {
141
+ return await entry.opening;
142
+ }
143
+ finally {
144
+ entry.opening = undefined;
145
+ }
146
+ }
147
+ async dial(entry) {
148
+ /* Declared out here so the catch can sweep it. It holds the secret in its
149
+ expanded form, which is exactly what a driver quotes back on failure. */
150
+ let resolved;
151
+ try {
152
+ /* The stored URL may be a reference rather than a secret: an env var,
153
+ a keychain entry, or a name in the credentials file. It is resolved
154
+ here, at the last possible moment, and the resolved form is handed
155
+ straight to the adapter without being stored anywhere. */
156
+ resolved = await resolveConnection(entry.config.name, entry.config.url);
157
+ entry.secret = resolved.source;
158
+ entry.warning = resolved.warning;
159
+ const adapter = connect(resolved.url, {
160
+ schemas: entry.config.schemas ?? [],
161
+ label: entry.config.name,
162
+ });
163
+ const schema = await adapter.introspect();
164
+ /* Wrapped before it is stored, so nothing can reach the unwrapped one:
165
+ the boundary is a property of the connection, not a rule each caller
166
+ remembers. Introspection happens first because the catalog is the
167
+ part a shape-only connection exists to serve. */
168
+ entry.adapter = entry.config.rows === false ? shapeOnly(adapter, entry.config.name) : adapter;
169
+ entry.schema = schema;
170
+ entry.error = undefined;
171
+ /* The stored one, which is the wrapped one where it matters. Returning
172
+ the raw adapter here would hand the first caller a way past the
173
+ boundary the line above just put up. */
174
+ return { adapter: entry.adapter, schema };
175
+ }
176
+ catch (err) {
177
+ /* Scrubbed here, at the one place a failure becomes a stored string.
178
+
179
+ `resolved` is the URL with the secret expanded into it, and it is the
180
+ thing a driver quotes back. It may not exist yet — `resolveConnection`
181
+ can be what failed — so the configured URL is swept as well, since an
182
+ inline password lives in that one. */
183
+ entry.error = scrubSecrets(err.message, resolved?.url, entry.config.url);
184
+ /* A missing password, said as one.
185
+
186
+ Moving a secret to the keychain gives this tool a failure mode it did
187
+ not have before: the entry can be removed, or the keychain locked, or
188
+ the file copied to a machine that has neither. What the driver says
189
+ then is its own internal complaint — Postgres answers "SASL:
190
+ SCRAM-SERVER-FIRST-MESSAGE: client password must be a string", which
191
+ names nothing a reader can act on and does not even contain the word
192
+ "password" in a useful position.
193
+
194
+ So when the URL names a user, no password was found for it, and the
195
+ connection failed, the reason is stated where the reader is already
196
+ looking. Only then: guessing at any other failure would be worse than
197
+ the driver's own words, which are usually right. */
198
+ if (resolved?.source === 'none' && namesUserWithoutPassword(resolved.url)) {
199
+ entry.error = `no password found for "${entry.config.name}". `
200
+ + 'Looked in the environment, the keychain, and the credentials file. '
201
+ + 'If it was moved to the keychain and has since been removed, add the connection again '
202
+ + `with its password. (The database said: ${entry.error})`;
203
+ }
204
+ // Rethrown with the connection named, because "ECONNREFUSED" on its own
205
+ // does not say which of eight databases is down.
206
+ throw new Error(`${entry.config.name}: ${entry.error}`);
207
+ }
208
+ }
209
+ /**
210
+ * Hold a connection open for the length of one request.
211
+ *
212
+ * Closing an adapter is not an operation the thing using it finds out
213
+ * about. A slow `/api/run` in one tab and a `refresh` in another meant the
214
+ * pg client was ended mid-query — the read came back as a driver-level
215
+ * "connection closed" 500, and on SQLite as a use-after-close. Nothing
216
+ * counted who was using what.
217
+ *
218
+ * The lease is taken when a request resolves its connection and released
219
+ * when the response ends, whichever way it ends.
220
+ */
221
+ lease(id) {
222
+ const entry = this.entries.get(id);
223
+ if (!entry)
224
+ return () => { };
225
+ entry.busy += 1;
226
+ let released = false;
227
+ return () => {
228
+ if (released)
229
+ return;
230
+ released = true;
231
+ entry.busy -= 1;
232
+ if (entry.busy <= 0) {
233
+ entry.busy = 0;
234
+ for (const wake of entry.idle.splice(0))
235
+ wake();
236
+ }
237
+ };
238
+ }
239
+ /**
240
+ * Wait for the requests using a connection to finish.
241
+ *
242
+ * Bounded, because a query can be stuck for as long as the statement
243
+ * timeout allows and "the close hangs" is a worse failure than "the close
244
+ * was slightly rude". After the wait it closes anyway — which is the old
245
+ * behaviour, now as a last resort rather than as the only one.
246
+ */
247
+ async drain(id, ms = 5_000) {
248
+ const entry = this.entries.get(id);
249
+ if (!entry || entry.busy <= 0)
250
+ return;
251
+ await new Promise((resolve) => {
252
+ const timer = setTimeout(resolve, ms);
253
+ entry.idle.push(() => {
254
+ clearTimeout(timer);
255
+ resolve();
256
+ });
257
+ });
258
+ }
259
+ /** Drop the cached schema so the next open re-reads the catalog. */
260
+ async refresh(id) {
261
+ const entry = this.entries.get(id);
262
+ if (!entry)
263
+ throw new Error(`No connection called "${id}".`);
264
+ await this.drain(id);
265
+ if (entry.adapter)
266
+ await entry.adapter.close();
267
+ entry.adapter = undefined;
268
+ entry.schema = undefined;
269
+ return this.open(id);
270
+ }
271
+ async closeAll() {
272
+ await Promise.allSettled([...this.entries.values()].map((e) => e.adapter?.close()));
273
+ }
274
+ }
275
+ function publicView(entry) {
276
+ return {
277
+ id: entry.config.id,
278
+ name: entry.config.name,
279
+ dialect: dialectOf(entry.config.url),
280
+ detail: redact(entry.config.url),
281
+ source: entry.config.source,
282
+ connected: Boolean(entry.schema),
283
+ tables: entry.schema?.tables.length,
284
+ error: entry.error,
285
+ secret: entry.secret,
286
+ warning: entry.warning,
287
+ /* Safe to send, unlike the URL: a schema list is a set of names the user
288
+ chose to browse, and the manager needs them to prefill its editor. */
289
+ schemas: entry.config.schemas,
290
+ /* Sent so the interface can be honest about it: a write toggle offered on
291
+ a connection the server will refuse to write is a button that lies. */
292
+ writable: Boolean(entry.config.writable),
293
+ /* Same reason as `writable`: an interface that offers a query bar on a
294
+ connection the server will not read rows from is an interface that
295
+ lies. */
296
+ ...(entry.config.rows === false ? { rows: false } : {}),
297
+ };
298
+ }
299
+ /* Read off the scheme, with SQLite as the fallback because a SQLite target is
300
+ a bare file path and has no scheme to read.
301
+
302
+ This listed only Postgres, so every MySQL connection was labelled "sqlite" in
303
+ the picker and the connection manager — a wrong answer to the one question
304
+ those labels exist to answer. Adding an adapter means adding its scheme here;
305
+ `connect.ts` is the other half of the same list. */
306
+ function dialectOf(url) {
307
+ if (/^postgres(ql)?:\/\//i.test(url))
308
+ return 'postgres';
309
+ if (/^mysql:\/\//i.test(url))
310
+ return 'mysql';
311
+ if (/^mariadb:\/\//i.test(url))
312
+ return 'mariadb';
313
+ return 'sqlite';
314
+ }
315
+ /**
316
+ * A displayable form of a target with no credential in it.
317
+ *
318
+ * `URL` parses out the password for us; the fallback matters because a
319
+ * malformed URL must not fall through to printing the original string.
320
+ */
321
+ export function redact(url) {
322
+ if (!/^[a-z][\w+.-]*:\/\//i.test(url)) {
323
+ // A bare file path. Show the file name, not the whole tree.
324
+ return url.split('/').pop() || url;
325
+ }
326
+ try {
327
+ const parsed = new URL(url);
328
+ const user = parsed.username ? `${parsed.username}@` : '';
329
+ const port = parsed.port ? `:${parsed.port}` : '';
330
+ return `${user}${parsed.hostname}${port}${parsed.pathname}`;
331
+ }
332
+ catch {
333
+ return '(connection)';
334
+ }
335
+ }
336
+ /**
337
+ * Take the credentials out of anything on its way to the browser.
338
+ *
339
+ * `redact` produces the public form of a URL we chose to show. This is for
340
+ * the other direction: text we did not write, going somewhere we did not
341
+ * intend. A driver's failure message quotes what it was handed, and what it
342
+ * was handed is the *resolved* connection string — the one with the password
343
+ * expanded out of the keychain and into it. That message was stored on the
344
+ * entry and served by every `GET /api/connections` from then on.
345
+ *
346
+ * Two passes, because there are two shapes of leak:
347
+ *
348
+ * 1. The whole DSN, echoed. Any `scheme://…` in the text is replaced with
349
+ * its redacted form, so the message keeps saying which host it could not
350
+ * reach — which is the useful half — without the credentials.
351
+ * 2. The password alone, quoted without its URL. Rarer, and caught by
352
+ * sweeping for the literal value.
353
+ *
354
+ * A password shorter than four characters is swept too. It mangles a message
355
+ * that happens to contain those letters, and that is the right trade: a
356
+ * garbled sentence is a nuisance, and a password on screen is not.
357
+ */
358
+ export function scrubSecrets(text, ...urls) {
359
+ let out = text.replace(/\b[a-z][\w+.-]*:\/\/[^\s'"`,)\]]+/gi, (match) => redact(match));
360
+ for (const url of urls) {
361
+ if (!url)
362
+ continue;
363
+ for (const secret of passwordsIn(url)) {
364
+ if (!secret)
365
+ continue;
366
+ out = out.split(secret).join('\u2022\u2022\u2022\u2022');
367
+ }
368
+ }
369
+ return out;
370
+ }
371
+ /**
372
+ * The password in a URL, in both the spellings it can appear in.
373
+ *
374
+ * `new URL` gives it percent-decoded, and a driver may quote either that or
375
+ * the raw form it was handed. Both are swept.
376
+ */
377
+ function passwordsIn(url) {
378
+ const found = new Set();
379
+ try {
380
+ const parsed = new URL(url);
381
+ if (parsed.password) {
382
+ found.add(parsed.password);
383
+ found.add(decodeURIComponent(parsed.password));
384
+ }
385
+ }
386
+ catch {
387
+ /* Not a URL — a file path, or a string with a `${VAR}` still in it. There
388
+ is no password to find, and failing to parse is not an error here. */
389
+ }
390
+ return [...found];
391
+ }
392
+ function slug(name) {
393
+ const s = name
394
+ .toLowerCase()
395
+ .replace(/[^a-z0-9]+/g, '-')
396
+ .replace(/^-+|-+$/g, '');
397
+ return s || 'connection';
398
+ }
399
+ /**
400
+ * Where a config file may live, in order.
401
+ *
402
+ * Project-local first, because a repo that ships a `tablewalk.json` should win
403
+ * for anyone working in it; the user's own file is the fallback.
404
+ */
405
+ export function configLocations(explicit) {
406
+ if (explicit)
407
+ return [explicit];
408
+ const xdg = process.env.XDG_CONFIG_HOME || join(homedir(), '.config');
409
+ return [
410
+ join(process.cwd(), 'tablewalk.json'),
411
+ join(process.cwd(), '.tablewalk.json'),
412
+ join(xdg, 'tablewalk', 'connections.json'),
413
+ /* The former name, still read. A config file is something a person wrote
414
+ by hand, and a rename that silently stops reading it looks exactly like
415
+ the tool losing their connections. */
416
+ join(process.cwd(), 'datawalk.json'),
417
+ join(process.cwd(), '.datawalk.json'),
418
+ join(xdg, 'datawalk', 'connections.json'),
419
+ ];
420
+ }
421
+ /**
422
+ * Read the first config file that exists.
423
+ *
424
+ * A missing file is not an error — most people will never write one. A file
425
+ * that exists but is malformed *is* an error, and is reported rather than
426
+ * skipped: silently ignoring a config someone wrote is worse than refusing.
427
+ */
428
+ export async function loadConfig(explicit) {
429
+ for (const path of configLocations(explicit)) {
430
+ let text;
431
+ try {
432
+ text = await readFile(path, 'utf8');
433
+ }
434
+ catch {
435
+ if (explicit)
436
+ throw new Error(`No config file at ${path}`);
437
+ continue;
438
+ }
439
+ let parsed;
440
+ try {
441
+ parsed = JSON.parse(text);
442
+ }
443
+ catch (err) {
444
+ throw new Error(`${path} is not valid JSON: ${err.message}`);
445
+ }
446
+ const connections = parsed?.connections;
447
+ if (!Array.isArray(connections)) {
448
+ throw new Error(`${path} needs a "connections" array.`);
449
+ }
450
+ connections.forEach((c, i) => {
451
+ if (!c || typeof c.url !== 'string' || !c.url) {
452
+ throw new Error(`${path}: connection ${i + 1} has no "url".`);
453
+ }
454
+ if (typeof c.name !== 'string' || !c.name) {
455
+ throw new Error(`${path}: connection ${i + 1} has no "name".`);
456
+ }
457
+ /* Refused rather than coerced. `"writable": "false"` is a string, and
458
+ every non-empty string is truthy — quietly reading that as permission
459
+ to write to production is the one mistake in this file worth being
460
+ loud about. */
461
+ if (c.writable !== undefined && typeof c.writable !== 'boolean') {
462
+ throw new Error(`${path}: connection "${c.name}" has "writable": ${JSON.stringify(c.writable)}. ` +
463
+ `It must be true or false, without quotes.`);
464
+ }
465
+ /* Same reasoning one boundary further out: `"rows": "false"` is a
466
+ truthy string, and reading it as permission would open exactly the
467
+ connection someone wrote it to close. */
468
+ if (c.rows !== undefined && typeof c.rows !== 'boolean') {
469
+ throw new Error(`${path}: connection "${c.name}" has "rows": ${JSON.stringify(c.rows)}. ` +
470
+ `It must be true or false, without quotes.`);
471
+ }
472
+ if (c.rows === false && c.writable) {
473
+ /* Writing is how rows get in, and an insert answers with the stored
474
+ row. Allowing both would be a boundary with a door in it. */
475
+ throw new Error(`${path}: connection "${c.name}" is both "writable" and "rows": false. ` +
476
+ `A shape-only connection cannot be written to — drop one of them.`);
477
+ }
478
+ });
479
+ return { path, connections };
480
+ }
481
+ return undefined;
482
+ }
483
+ /**
484
+ * Split a connection string into a URL worth persisting and a secret worth
485
+ * protecting, then store the secret where the platform can protect it.
486
+ *
487
+ * The URL that gets written to disk never contains the password. That is the
488
+ * property worth having: a config file that leaks is embarrassing, not a
489
+ * breach, and a connection list can be shared or committed without thinking
490
+ * about it.
491
+ */
492
+ /**
493
+ * A URL that names a user and carries no password.
494
+ *
495
+ * The shape that means "a password was expected from somewhere else" — which
496
+ * is exactly the shape `storeSecret` leaves behind, and so the shape whose
497
+ * failure deserves an explanation rather than the driver's own words.
498
+ */
499
+ function namesUserWithoutPassword(url) {
500
+ try {
501
+ const parsed = new URL(url);
502
+ return Boolean(parsed.username) && !parsed.password;
503
+ }
504
+ catch {
505
+ return false;
506
+ }
507
+ }
508
+ export async function storeSecret(name, url) {
509
+ let parsed;
510
+ try {
511
+ parsed = new URL(url);
512
+ }
513
+ catch {
514
+ return undefined; // A file path has no secret to store.
515
+ }
516
+ if (!parsed.password)
517
+ return undefined;
518
+ const secret = decodeURIComponent(parsed.password);
519
+ /* An environment reference is not a secret to move — it is the *absence* of
520
+ one, and the arrangement this file recommends first.
521
+ `postgres://app:${PROD_PASSWORD}@host/db` stores nothing anywhere; the
522
+ value arrives from the environment at open time. Moving it would put the
523
+ literal characters `${PROD_PASSWORD}` into the keychain and strip the
524
+ reference out of the URL, breaking the workflow in the course of trying
525
+ to secure it.
526
+
527
+ Checked on the raw password rather than with `hasInlinePassword`, which
528
+ is only asked *after* expansion — by then a reference has already become
529
+ the value it names, and the distinction this needs is gone. */
530
+ if (/\$\{?[A-Za-z_]/.test(secret))
531
+ return undefined;
532
+ parsed.password = '';
533
+ const withoutSecret = parsed.toString();
534
+ if (keychainAvailable() && (await keychainSet(name, secret))) {
535
+ return { stored: 'keychain', url: withoutSecret };
536
+ }
537
+ await writeCredential(name, secret);
538
+ return { stored: 'file', url: withoutSecret };
539
+ }
540
+ /**
541
+ * Write connections back to a config file.
542
+ *
543
+ * The rule this respects: **a page never edits a file on disk without being
544
+ * told to.** Adding or editing a connection changes the running session only;
545
+ * this is the separate, explicit act of making that permanent, and the UI has
546
+ * to name the file it is about to write.
547
+ *
548
+ * URLs are stored exactly as they were typed, which is what makes
549
+ * `postgres://app:${PROD_PASSWORD}@db/app` survive a round trip as a
550
+ * reference rather than being resolved into the secret it points at. The
551
+ * corollary is that a password someone typed inline gets written inline —
552
+ * true of any config file, and the reason the UI warns about it before this
553
+ * is ever called.
554
+ *
555
+ * Only the `connections` key is rewritten. The registry knows about
556
+ * connections and nothing else, so building the file out of it alone deleted
557
+ * the `views` and `queries` a team had committed — and reported success. A
558
+ * file with a section this function does not understand is a file it has no
559
+ * business rewriting from scratch.
560
+ */
561
+ export async function saveConfig(path, connections) {
562
+ const body = {
563
+ ...(await existingConfig(path)),
564
+ connections: connections
565
+ // Session connections are included: making them permanent is the whole
566
+ // point of pressing the button.
567
+ .map((c) => ({
568
+ name: c.name,
569
+ url: c.url,
570
+ ...(c.schemas?.length ? { schemas: c.schemas } : {}),
571
+ // Written only when true. Absent means read-only, and a file full of
572
+ // `"writable": false` would suggest the flag is doing something.
573
+ ...(c.writable ? { writable: true } : {}),
574
+ // And the same rule inverted: only written when it is doing something.
575
+ ...(c.rows === false ? { rows: false } : {}),
576
+ })),
577
+ };
578
+ await mkdir(dirname(path), { recursive: true });
579
+ await writeFile(path, `${JSON.stringify(body, null, 2)}\n`);
580
+ }
581
+ /**
582
+ * What is already in the file, so that writing connections back keeps it.
583
+ *
584
+ * A file that does not exist yet is simply an empty object — the first save
585
+ * writes a file with one key in it. A file that exists and cannot be parsed
586
+ * is the case worth stopping on: overwriting it would replace something
587
+ * hand-edited with a machine-generated stub, and the person who typed the
588
+ * stray comma would find their views gone rather than their typo reported.
589
+ */
590
+ async function existingConfig(path) {
591
+ let text;
592
+ try {
593
+ text = await readFile(path, 'utf8');
594
+ }
595
+ catch {
596
+ return {};
597
+ }
598
+ if (!text.trim())
599
+ return {};
600
+ let parsed;
601
+ try {
602
+ parsed = JSON.parse(text);
603
+ }
604
+ catch (err) {
605
+ throw new Error(`${path} is not valid JSON (${err.message}), so its other settings cannot be `
606
+ + 'preserved. Fix the file, or save to a different path.');
607
+ }
608
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
609
+ return {};
610
+ /* `connections` is dropped because it is about to be written from the
611
+ registry; everything else — views, queries, comments, keys a later
612
+ version adds — is carried through untouched. */
613
+ const { connections: _drop, ...rest } = parsed;
614
+ return rest;
615
+ }
@@ -0,0 +1,62 @@
1
+ import { findTable, referencesFrom } from '../shared/schema.js';
2
+ /** Every engine's way of saying a foreign key was not satisfied. */
3
+ const FOREIGN_KEY = /foreign key|violates foreign key constraint|a foreign key constraint fails/i;
4
+ /**
5
+ * The referenced row that is missing, if the message says one is.
6
+ *
7
+ * Returns the original message unchanged when this is not a foreign-key
8
+ * failure, when the write named no reference column, or when every reference
9
+ * it did name turns out to be satisfied — that last case matters, because a
10
+ * concurrent delete or a deferred constraint can produce a failure this
11
+ * cannot explain, and inventing an explanation for it would be worse than
12
+ * the bare message.
13
+ */
14
+ export async function explainWriteFailure(adapter, schema, tableId, values, message) {
15
+ if (!FOREIGN_KEY.test(message))
16
+ return message;
17
+ const table = findTable(schema, tableId);
18
+ if (!table)
19
+ return message;
20
+ for (const fk of referencesFrom(schema, tableId)) {
21
+ const sent = fk.from.columns.map((c) => values[c]);
22
+ /* Only references this write actually set. A column left out is the
23
+ database's own default or null, and either way not what the caller
24
+ typed — pointing at it would send them to the wrong line. */
25
+ if (sent.some((v) => v === undefined || v === null))
26
+ continue;
27
+ const target = findTable(schema, fk.to.table);
28
+ if (!target)
29
+ continue;
30
+ let exists;
31
+ try {
32
+ const found = await adapter.query({
33
+ table: fk.to.table,
34
+ columns: fk.to.columns,
35
+ filter: {
36
+ groups: [fk.to.columns.map((column, i) => ({
37
+ column,
38
+ op: '=',
39
+ value: sent[i],
40
+ }))],
41
+ },
42
+ limit: 1,
43
+ offset: 0,
44
+ });
45
+ exists = found.rows.length > 0;
46
+ }
47
+ catch {
48
+ /* A lookup that itself fails proves nothing about the write. */
49
+ continue;
50
+ }
51
+ if (exists)
52
+ continue;
53
+ const pairs = fk.from.columns.map((c, i) => `${c} = ${format(sent[i])}`).join(', ');
54
+ const targetName = fk.to.columns.map((c) => `${target.name}.${c}`).join(', ');
55
+ return `${message} — ${pairs} has no matching ${targetName}. `
56
+ + `Insert that ${target.name} first, or point at one that exists.`;
57
+ }
58
+ return message;
59
+ }
60
+ function format(value) {
61
+ return typeof value === 'number' || typeof value === 'boolean' ? String(value) : `"${String(value)}"`;
62
+ }