tablewalk 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +553 -0
- package/dist/adapters/adapter.js +372 -0
- package/dist/adapters/connect.js +33 -0
- package/dist/adapters/mysql.js +951 -0
- package/dist/adapters/postgres.js +1000 -0
- package/dist/adapters/sqlite.js +781 -0
- package/dist/client/agent.js +262 -0
- package/dist/client/app.js +973 -0
- package/dist/client/arrange.js +254 -0
- package/dist/client/ask.js +133 -0
- package/dist/client/breakdown.js +317 -0
- package/dist/client/clauses.js +390 -0
- package/dist/client/columns.js +98 -0
- package/dist/client/complete.js +437 -0
- package/dist/client/compose.js +166 -0
- package/dist/client/composer.css +495 -0
- package/dist/client/composer.js +1972 -0
- package/dist/client/connections.js +234 -0
- package/dist/client/connmanager.js +962 -0
- package/dist/client/connurl.js +188 -0
- package/dist/client/core.js +893 -0
- package/dist/client/deeplink.js +270 -0
- package/dist/client/delete.js +144 -0
- package/dist/client/diagram.js +885 -0
- package/dist/client/dropdown.js +279 -0
- package/dist/client/export.js +456 -0
- package/dist/client/features.css +524 -0
- package/dist/client/findvalue.js +169 -0
- package/dist/client/grid.js +205 -0
- package/dist/client/handoff.js +153 -0
- package/dist/client/help.css +145 -0
- package/dist/client/help.js +881 -0
- package/dist/client/history.js +222 -0
- package/dist/client/index.html +116 -0
- package/dist/client/insert.js +151 -0
- package/dist/client/menu.js +160 -0
- package/dist/client/nested.js +255 -0
- package/dist/client/page.css +713 -0
- package/dist/client/page.js +1345 -0
- package/dist/client/pagebuilder.js +1222 -0
- package/dist/client/pagemarks.js +95 -0
- package/dist/client/palette.js +374 -0
- package/dist/client/peek.js +254 -0
- package/dist/client/picker.js +139 -0
- package/dist/client/pins.js +140 -0
- package/dist/client/prompt.js +129 -0
- package/dist/client/record.js +707 -0
- package/dist/client/schemaexport.js +242 -0
- package/dist/client/schematext.js +125 -0
- package/dist/client/shape.js +178 -0
- package/dist/client/shapecheck.js +129 -0
- package/dist/client/skeleton.js +139 -0
- package/dist/client/sql.css +126 -0
- package/dist/client/sql.js +398 -0
- package/dist/client/sqlcomplete.js +163 -0
- package/dist/client/sqlsaved.js +107 -0
- package/dist/client/style.css +2711 -0
- package/dist/client/summary.js +259 -0
- package/dist/client/table.js +1035 -0
- package/dist/client/template.js +539 -0
- package/dist/client/theme.js +74 -0
- package/dist/client/tour.js +324 -0
- package/dist/client/undo.js +105 -0
- package/dist/client/url.js +166 -0
- package/dist/client/value.js +223 -0
- package/dist/client/views.js +215 -0
- package/dist/client/virtual.js +176 -0
- package/dist/client/welcome.js +170 -0
- package/dist/client/write.js +414 -0
- package/dist/server/changeimpact.js +195 -0
- package/dist/server/connections.js +615 -0
- package/dist/server/constraints.js +62 -0
- package/dist/server/credentials.js +230 -0
- package/dist/server/fixture.js +199 -0
- package/dist/server/graph.js +194 -0
- package/dist/server/impact.js +48 -0
- package/dist/server/index.js +2204 -0
- package/dist/server/journal.js +173 -0
- package/dist/server/layouts.js +128 -0
- package/dist/server/mcp.js +2840 -0
- package/dist/server/shapeonly.js +91 -0
- package/dist/shared/breakdown.js +231 -0
- package/dist/shared/breakdowntext.js +257 -0
- package/dist/shared/diff.js +130 -0
- package/dist/shared/like.js +29 -0
- package/dist/shared/lint.js +149 -0
- package/dist/shared/order.js +133 -0
- package/dist/shared/page.js +932 -0
- package/dist/shared/query.js +831 -0
- package/dist/shared/recordview.js +343 -0
- package/dist/shared/schema.js +377 -0
- package/dist/shared/sqlsaved.js +67 -0
- package/dist/shared/view.js +981 -0
- package/dist/shared/viewtext.js +273 -0
- package/dist/shared/vocabulary.js +164 -0
- package/package.json +57 -0
|
@@ -0,0 +1,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
|
+
}
|