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,893 @@
1
+ /**
2
+ * The parts every other client module needs: the API, the navigation stack,
3
+ * the schema mirrors, and DOM building.
4
+ *
5
+ * This file deliberately depends on nothing else in the client. Everything
6
+ * else depends on it and not on each other, which is what keeps the module
7
+ * graph a fan-out rather than a web — and lets a feature live in its own file
8
+ * without reaching into the renderer.
9
+ */
10
+
11
+ export const $ = (id) => document.getElementById(id);
12
+
13
+ /**
14
+ * @type {{schema: any, stack: any[], page: number, canWrite: boolean}}
15
+ *
16
+ * `stack` is a navigation stack, and each entry is somewhere you have been —
17
+ * a table with a filter, or one row. The breadcrumb is that stack rendered,
18
+ * so walking back restores the filter you walked away from.
19
+ */
20
+ export const state = { schema: null, stack: [], page: 0, canWrite: false, navMode: 'replace' };
21
+
22
+ /* Matches DEFAULT_LIMIT on the server. Small on purpose: the first page of a
23
+ browse should be instant, and paging is one click. */
24
+ export const PAGE_SIZE = 20;
25
+
26
+ /**
27
+ * How many rows a page actually holds, which the reader chooses.
28
+ *
29
+ * `PAGE_SIZE` stays the default and stays equal to the server's
30
+ * `DEFAULT_LIMIT` — twenty is right for a first look, and the first look is
31
+ * what most browsing is. It was also the *only* size, which turned a
32
+ * sixty-thousand-row table into three thousand clicks: a table you could read
33
+ * the front of and nothing else.
34
+ *
35
+ * Capped at 250 rather than offering "all". The grid renders every row it is
36
+ * given, so the honest ceiling is the one where the DOM is still the reader's
37
+ * friend; past that the answer is a filter, a sort, or Export.
38
+ */
39
+ export const PAGE_SIZES = [20, 50, 100, 250];
40
+ const PAGE_SIZE_KEY = 'tablewalk.page-size';
41
+ let chosenPageSize = null;
42
+
43
+ export function pageSize() {
44
+ if (chosenPageSize === null) {
45
+ const stored = Number(loadJson(PAGE_SIZE_KEY, PAGE_SIZE));
46
+ /* Validated rather than trusted: the value comes from localStorage, where
47
+ a stale build or a hand edit can leave anything at all, and a page size
48
+ of `null` asks the server for `LIMIT NaN`. */
49
+ chosenPageSize = PAGE_SIZES.includes(stored) ? stored : PAGE_SIZE;
50
+ }
51
+ return chosenPageSize;
52
+ }
53
+
54
+ export function setPageSize(size) {
55
+ chosenPageSize = PAGE_SIZES.includes(Number(size)) ? Number(size) : PAGE_SIZE;
56
+ saveJson(PAGE_SIZE_KEY, chosenPageSize);
57
+ /* A page *number* means nothing once the size changes — page 40 of twenty
58
+ is not page 40 of a hundred — so the only honest place to land is the
59
+ front. */
60
+ state.page = 0;
61
+ }
62
+
63
+ /* Above this many rows the table is being fetched to be processed rather
64
+ than read, and the DOM is the bottleneck rather than the database. Until
65
+ the result grid is virtualised, render this many and say what was held
66
+ back — a silent truncation would read as "that is all there is". */
67
+ export const RENDER_CAP = 2000;
68
+
69
+ /* ---------- API ---------- */
70
+
71
+ /**
72
+ * Endpoints that are *about* connections rather than answered by one.
73
+ *
74
+ * Naming a connection on these is at best redundant and at worst wrong:
75
+ * `/api/connections/remove` is about an id in its body, and resolving a second
76
+ * one from the same request would refuse it the moment the two disagreed.
77
+ */
78
+ const CONNECTION_ENDPOINTS = /^\/api\/connections(\/|$)/;
79
+
80
+ /**
81
+ * Every request says which database it is about.
82
+ *
83
+ * The server holds one "active" connection for the whole process, which is
84
+ * fine for one tab and wrong for two: switching database in one tab changed
85
+ * what every other tab's next request ran against, while those tabs went on
86
+ * rendering the schema they still had. The same query, the same screen,
87
+ * different data, and nothing to say so.
88
+ *
89
+ * So the connection stops being server state that the client nudges and
90
+ * becomes part of the question. The server's active connection remains as the
91
+ * answer to "no one said" — the first request of a session, before the client
92
+ * knows what it is connected to.
93
+ */
94
+ export async function api(path, body) {
95
+ const conn = state.activeConnection;
96
+ const scoped = conn && !CONNECTION_ENDPOINTS.test(path.split('?')[0]);
97
+
98
+ let url = path;
99
+ if (scoped && !body) {
100
+ /* Appended rather than assumed to be the only parameter — `/api/ddl` and
101
+ `/api/pages/suggested` already carry one. */
102
+ url += `${path.includes('?') ? '&' : '?'}conn=${encodeURIComponent(conn)}`;
103
+ }
104
+ /* A caller that named one wins. `/api/connections/select` is followed by
105
+ reads that must go to the connection being switched *to*, which is not yet
106
+ the active one. */
107
+ const payload = scoped && body && body.conn === undefined ? { ...body, conn } : body;
108
+
109
+ let res;
110
+ try {
111
+ res = await fetch(url, {
112
+ method: payload ? 'POST' : 'GET',
113
+ headers: payload ? { 'content-type': 'application/json' } : undefined,
114
+ body: payload ? JSON.stringify(payload) : undefined,
115
+ });
116
+ } catch {
117
+ /* `fetch` rejects with "Failed to fetch" or "Load failed" depending on the
118
+ browser, and neither says the one thing that is true and useful: the
119
+ server this page came from is not answering. It is a local process, so
120
+ that nearly always means it was stopped — and "Failed to fetch" sends
121
+ people to look at their network. */
122
+ throw new Error(
123
+ 'tablewalk is not answering. The server it was started with may have stopped — '
124
+ + 'restart it and reload this page.',
125
+ );
126
+ }
127
+
128
+ let data;
129
+ try {
130
+ data = await res.json();
131
+ } catch {
132
+ /* An answer that is not JSON is a proxy, a login page, or a crash — none
133
+ of which the caller can do anything with, and all of which read better
134
+ as what they are than as "Unexpected token < in JSON". */
135
+ throw new Error(
136
+ `The server answered ${res.status} with something that is not JSON. `
137
+ + 'Something between this page and tablewalk may be intercepting the request.',
138
+ );
139
+ }
140
+
141
+ if (!res.ok) {
142
+ /* The status travels with the message. Callers have always had to guess
143
+ the kind of failure from the text of it, which is how "No connection is
144
+ open." — a 409 with a specific meaning and a specific screen to show
145
+ for it — could only be recognised by matching a sentence. */
146
+ const failure = new Error(data.error ?? `Request failed (${res.status})`);
147
+ failure.status = res.status;
148
+ throw failure;
149
+ }
150
+ return data;
151
+ }
152
+
153
+ /**
154
+ * Run something when a node leaves the document.
155
+ *
156
+ * Observers outlive the thing they watch unless someone stops them, and these
157
+ * views are rebuilt constantly — browsing thirty tables left dozens of them
158
+ * holding detached DOM, each still re-measuring a table nobody can see on
159
+ * every resize, for as long as the tab was open.
160
+ *
161
+ * Watching the node's own parent does not work, and that is the whole reason
162
+ * this is a registry rather than one observer each: a view swap replaces the
163
+ * children of `#content`, which detaches the entire subtree in one operation.
164
+ * No `childList` record is delivered for the intermediate parents — they were
165
+ * not modified, they were carried off — so a watcher on the immediate parent
166
+ * of a nested table never hears anything.
167
+ *
168
+ * So one observer watches the place the swapping actually happens, and
169
+ * everything registered is swept for `isConnected` when it fires. One
170
+ * observer for the app rather than one per element is also the cheaper end of
171
+ * the trade.
172
+ */
173
+ /**
174
+ * Make a `<details>` announce itself as the control it looks like.
175
+ *
176
+ * A `<summary>` styled as a button is read by Chromium as `group: + Count` —
177
+ * no role, no state, nothing to say it can be opened or that it currently is.
178
+ * The reader sees a button; the screen reader is told about a grouping box.
179
+ * Every disclosure in this app is a button in appearance and in intent, so
180
+ * each one says so, and keeps `aria-expanded` in step with the element's own
181
+ * `open` — through the `toggle` event, because a `<details>` can be opened by
182
+ * the keyboard, by a click on the marker, or by `open` being set in code, and
183
+ * only the event sees all three.
184
+ */
185
+ export function disclosure(details) {
186
+ const summary = details.querySelector(':scope > summary');
187
+ if (!summary) return details;
188
+ summary.setAttribute('role', 'button');
189
+ summary.setAttribute('aria-expanded', String(details.open));
190
+ details.addEventListener('toggle', () => {
191
+ summary.setAttribute('aria-expanded', String(details.open));
192
+ });
193
+ return details;
194
+ }
195
+
196
+ /**
197
+ * Anything that has to be rebuilt when the database underneath changes.
198
+ *
199
+ * A connection switch is not a render: the trail is cleared, the schema is
200
+ * replaced and every per-connection thing — layouts, saved statements, write
201
+ * mode — is reloaded. Long-lived surfaces that are *not* inside `#content`
202
+ * miss all of it. The SQL editor was the case that showed it: painted once
203
+ * from the state it opened with, it went on naming the previous database
204
+ * while the statement it ran went to the new one, and it kept offering "Run
205
+ * as write…" after a switch to a connection the server would refuse.
206
+ *
207
+ * A set rather than the single `state.onSwitched` slot beside it, because the
208
+ * second thing that needed to know would have silently replaced the first.
209
+ */
210
+ const switchWatchers = new Set();
211
+
212
+ /** Register a rebuild. Returns the way to stop listening. */
213
+ export function whenConnectionChanges(run) {
214
+ switchWatchers.add(run);
215
+ return () => switchWatchers.delete(run);
216
+ }
217
+
218
+ /**
219
+ * Say that the connection changed. Called by the two places that switch —
220
+ * the picker and a deep link into another database — after the new schema
221
+ * and everything keyed to it have landed, so a watcher reads the new state
222
+ * rather than the half-swapped one.
223
+ */
224
+ export function connectionChanged() {
225
+ /* A copy, because a watcher may well unregister itself as its last act. */
226
+ for (const run of [...switchWatchers]) {
227
+ try {
228
+ run();
229
+ } catch (err) {
230
+ /* One surface failing to rebuild must not stop the next one. It is
231
+ already the wrong database on screen; making it the wrong database
232
+ everywhere is not an improvement. */
233
+ console.error(err);
234
+ }
235
+ }
236
+ }
237
+
238
+ const removals = new Set();
239
+ let sweeper = null;
240
+
241
+ export function whenRemoved(node, run) {
242
+ const entry = { node, run };
243
+ removals.add(entry);
244
+
245
+ if (!sweeper) {
246
+ const root = $('content') ?? document.body;
247
+ sweeper = new MutationObserver(() => {
248
+ for (const item of [...removals]) {
249
+ if (item.node.isConnected) continue;
250
+ removals.delete(item);
251
+ item.run();
252
+ }
253
+ });
254
+ sweeper.observe(root, { childList: true, subtree: true });
255
+ }
256
+ return () => removals.delete(entry);
257
+ }
258
+
259
+ /* ---------- schema helpers (mirrors of src/shared/schema.ts) ---------- */
260
+
261
+ export const findTable = (id) => state.schema.tables.find((t) => t.id === id);
262
+ export const refsTo = (id) => state.schema.foreignKeys.filter((fk) => fk.to.table === id);
263
+ export const refsFrom = (id) => state.schema.foreignKeys.filter((fk) => fk.from.table === id);
264
+ export const primaryKey = (table) => table.columns.filter((c) => c.primaryKey).map((c) => c.name);
265
+
266
+ const LABEL_CANDIDATES = ['name', 'full_name', 'display_name', 'first_name', 'username', 'title', 'label', 'subject', 'number', 'email', 'slug', 'code'];
267
+
268
+ /**
269
+ * Mirrors columnKind in shared/schema.ts.
270
+ *
271
+ * Here rather than beside the renderer that formats a column, because the
272
+ * date test below is defined in terms of it and every copy of that test the
273
+ * client grew — one per module that needed it — drifted from this one in a
274
+ * different direction.
275
+ */
276
+ export function columnKind(type) {
277
+ const t = (type ?? '').toLowerCase();
278
+ if (/bool/.test(t)) return 'boolean';
279
+ if (/int|serial|numeric|decimal|real|double|float|money/.test(t)) return 'number';
280
+ if (/date|time/.test(t)) return 'date';
281
+ if (/char|text|uuid|json|enum|clob/.test(t)) return 'text';
282
+ return 'other';
283
+ }
284
+
285
+ /** Mirrors looksLikeDate in shared/schema.ts. */
286
+ const DATE_NAME = /(^|_)(date|time|datetime|timestamp)(_|$)|_(at|on)$|^(created|updated|modified|deleted)(_|$)/i;
287
+
288
+ /**
289
+ * Whether a column should be treated as a date for presentation.
290
+ *
291
+ * Exported, and the only copy: this rule had been rewritten by hand in five
292
+ * client modules and no two of them said the same thing. `event_timestamp
293
+ * TEXT` got relative-date filters in one place and not another; a `uuid`
294
+ * column named `created_at` was a date to the server and plain text here,
295
+ * because the type test was a `char|text|varchar` regex rather than the
296
+ * classifier the shared copy uses.
297
+ */
298
+ export function looksLikeDateColumn(col) {
299
+ const kind = columnKind(col?.type);
300
+ if (kind === 'date') return true;
301
+ // Only text columns get the name-based benefit of the doubt; a number named
302
+ // `updated_at` is an epoch and comparing it to a timestamp string is wrong.
303
+ return kind === 'text' && DATE_NAME.test(col?.name ?? '');
304
+ }
305
+
306
+ export function labelColumn(table) {
307
+ const hit = LABEL_CANDIDATES.find((c) => table.columns.some((col) => col.name === c));
308
+ if (hit) return hit;
309
+ /* A date is never a good label. Two rows created the same day are
310
+ indistinguishable, and "2025-03-14" says nothing about *which* invoice you
311
+ are looking at — the key at least identifies it.
312
+
313
+ This guard exists in shared/schema.ts and had been dropped from this copy,
314
+ so the server and the browser disagreed about what a row is called: a
315
+ table whose first text column is a date got breadcrumbs and record
316
+ headings reading "2025-03-14" while every server-rendered label used the
317
+ key. */
318
+ /* Nor is a column the database gave a vocabulary: `status` reads like a
319
+ name and identifies nothing, since every sixth row shares it. */
320
+ const text = table.columns.find(
321
+ (c) => !c.primaryKey && /char|text|varchar|string/i.test(c.type)
322
+ && !looksLikeDateColumn(c) && !(c.allowed && c.allowed.length),
323
+ );
324
+ return text ? text.name : primaryKey(table)[0];
325
+ }
326
+
327
+ /** Mirrors IDENTITY_TABLE in shared/schema.ts. */
328
+ const IDENTITY_TABLE = /(^|_)(party|parties|person|persons|people|user|users|profile|profiles|identity|identities)$/i;
329
+
330
+ /**
331
+ * Mirrors labelPath in shared/schema.ts: where a row's name lives, as a view
332
+ * path. A table whose best label is its own key asks one forward hop further
333
+ * — employee has no name, `party_id.display_name` does — before settling for
334
+ * the number. Only across a plausibly one-to-one link: a reference that is
335
+ * the whole primary key, or a reference into an identity table. The invoice
336
+ * must not be named after its customer.
337
+ */
338
+ export function labelPath(table) {
339
+ const own = labelColumn(table);
340
+ const key = primaryKey(table);
341
+ if (own && !key.includes(own)) return own;
342
+ for (const column of table.columns) {
343
+ const ref = column.references;
344
+ if (!ref || ref.table === table.id) continue;
345
+ const oneToOne = key.length === 1 && key[0] === column.name;
346
+ const target = findTable(ref.table);
347
+ if (!target) continue;
348
+ if (!oneToOne && !IDENTITY_TABLE.test(target.name)) continue;
349
+ const theirs = labelColumn(target);
350
+ if (!theirs || primaryKey(target).includes(theirs)) continue;
351
+ return `${column.name}.${theirs}`;
352
+ }
353
+ return own;
354
+ }
355
+
356
+ /* ---------- paths (mirrors of src/shared/view.ts) ----------
357
+
358
+ Every hop is derivable from the schema the page already holds, so proposing
359
+ the next segment of a path is local and instant; asking the server per
360
+ keystroke would make the one interaction that has to feel immediate the one
361
+ that does not. The server still resolves every path for real before
362
+ anything runs, so a mirror here can cost a suggestion and never a result.
363
+
364
+ Here rather than in the composer that reads them most, because the layout
365
+ builder walked the same prefixes with its own copy — one that matched a hop
366
+ only by its columns, so a section whose path names the *constraint* instead
367
+ resolved to nothing and the builder reported a reference that was still
368
+ perfectly present. */
369
+
370
+ /** Mirrors hopName in shared/view.ts. Composite keys join their columns with +. */
371
+ export function hopName(fk) {
372
+ return fk.from.columns.length === 1 ? fk.from.columns[0] : fk.from.columns.join('+');
373
+ }
374
+
375
+ /** Mirrors hopsFrom in shared/view.ts: every forward hop out of a table. */
376
+ export function hopsFrom(tableId) {
377
+ const table = findTable(tableId);
378
+ return refsFrom(tableId).map((fk) => ({
379
+ name: hopName(fk),
380
+ fk,
381
+ target: fk.to.table,
382
+ optional: fk.from.columns.some(
383
+ (c) => (table ? table.columns.find((col) => col.name === c) : null)?.nullable !== false,
384
+ ),
385
+ }));
386
+ }
387
+
388
+ /** Mirrors tableAtPrefix in shared/view.ts. An empty prefix is the base. */
389
+ export function tableAtPrefix(baseId, prefix) {
390
+ let table = findTable(baseId);
391
+ if (!prefix) return table;
392
+ for (const segment of prefix.split('.')) {
393
+ if (!table) return null;
394
+ const hop = hopsFrom(table.id).find((h) => h.name === segment || h.fk.name === segment);
395
+ if (!hop) return null;
396
+ table = findTable(hop.target);
397
+ }
398
+ return table;
399
+ }
400
+
401
+ /** How a row is named in a breadcrumb: the readable column, else the key. */
402
+ export function rowLabel(table, row) {
403
+ /* The walked-to name first. A row fetched as a view carries the joined
404
+ label under its path — `party_id.display_name` — while a plain query row
405
+ simply lacks that key and falls through to the table's own label, then
406
+ the primary key. One function for both, so a caller never has to know
407
+ which kind of row it is holding. */
408
+ for (const name of [labelPath(table), labelColumn(table)]) {
409
+ const value = name != null ? row[name] : undefined;
410
+ if (value !== null && value !== undefined && String(value) !== '') return String(value);
411
+ }
412
+ const pk = primaryKey(table).map((c) => row[c]).filter((v) => v != null);
413
+ return pk.length ? pk.join('/') : '(row)';
414
+ }
415
+
416
+ /**
417
+ * The primary key of a row, or null when this row cannot supply one.
418
+ *
419
+ * Null rather than a key with `undefined` in it. A projected query — `show
420
+ * quantity`, or a child section showing four summary columns — returns rows
421
+ * that genuinely do not carry the key, and building `{invoice_id: undefined}`
422
+ * out of one produced a link to `~invoice_line:invoice_id=undefined` that
423
+ * landed on "That row is no longer there." Nothing had gone missing; the
424
+ * navigation was built from a row that never had the key to begin with.
425
+ *
426
+ * Callers use the null to decide whether a row is clickable at all, so a row
427
+ * that cannot be opened does not look like one that can.
428
+ */
429
+ export function keyOf(table, row) {
430
+ const columns = primaryKey(table);
431
+ if (!columns.length) return null;
432
+ const key = {};
433
+ for (const col of columns) {
434
+ if (row[col] === undefined) return null;
435
+ key[col] = row[col];
436
+ }
437
+ return key;
438
+ }
439
+
440
+ /** A filter matching exactly one row by its primary key. */
441
+ export function keyFilter(key) {
442
+ return { groups: [Object.entries(key).map(([column, value]) => ({ column, op: '=', value }))] };
443
+ }
444
+
445
+ /* ---------- DOM ---------- */
446
+
447
+ export function el(tag, attrs = {}, children = []) {
448
+ const node = document.createElement(tag);
449
+ for (const [k, v] of Object.entries(attrs)) {
450
+ if (v === undefined || v === false || v === null) continue;
451
+ if (k === 'class') node.className = v;
452
+ else if (k === 'text') node.textContent = v;
453
+ else if (k.startsWith('on')) node.addEventListener(k.slice(2), v);
454
+ else node.setAttribute(k, v === true ? '' : String(v));
455
+ }
456
+ for (const child of [].concat(children)) {
457
+ if (child == null || child === false) continue;
458
+ node.append(child);
459
+ }
460
+ return node;
461
+ }
462
+
463
+ export function svgEl(tag, attrs = {}, children = []) {
464
+ const node = document.createElementNS('http://www.w3.org/2000/svg', tag);
465
+ for (const [k, v] of Object.entries(attrs)) {
466
+ if (v === undefined || v === null) continue;
467
+ if (k.startsWith('on')) node.addEventListener(k.slice(2), v);
468
+ else node.setAttribute(k, String(v));
469
+ }
470
+ for (const child of [].concat(children)) if (child) node.append(child);
471
+ return node;
472
+ }
473
+
474
+ /**
475
+ * Keep the keyboard inside a panel, and give it back on the way out.
476
+ *
477
+ * Eight surfaces here declare `role="dialog"` and `aria-modal="true"` on a
478
+ * plain `<div>`. The attribute is a promise: it tells a screen reader that
479
+ * everything behind this is inert and not to be reached. Nothing was
480
+ * enforcing it, so Tab walked straight out of the panel and into the table
481
+ * underneath — the page reading as sealed to one user and open to another,
482
+ * which is worse than not claiming it at all. The `<dialog>` elements
483
+ * elsewhere get this from the platform; these had to be told.
484
+ *
485
+ * It also puts focus back where it came from. Closing a panel used to drop
486
+ * focus on `<body>`, so the next Tab started again from the top of the
487
+ * document — a keyboard user who opened the connection manager from the
488
+ * toolbar and pressed Escape had to walk the whole page to get back.
489
+ *
490
+ * Returns the release function. Call it when the panel goes.
491
+ */
492
+ export function trapFocus(panel) {
493
+ const cameFrom = document.activeElement;
494
+
495
+ /* Queried per keystroke rather than cached: these panels rebuild their own
496
+ contents constantly — a filter that repaints a list, a tour that swaps
497
+ the card — and a list captured on open goes stale immediately. */
498
+ const focusable = () => [...panel.querySelectorAll(
499
+ 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]),'
500
+ + ' textarea:not([disabled]), [tabindex]:not([tabindex="-1"])',
501
+ )].filter((node) => node.offsetWidth > 0 || node.offsetHeight > 0 || node === document.activeElement);
502
+
503
+ const onKey = (e) => {
504
+ if (e.key !== 'Tab') return;
505
+ const stops = focusable();
506
+ /* Nothing to move to yet — a panel painted but not filled. Swallowing the
507
+ key is the honest answer: the alternative is Tab escaping into the page
508
+ this panel has told a screen reader is inert. Escape still closes. */
509
+ if (!stops.length) { e.preventDefault(); return; }
510
+ const first = stops[0];
511
+ const last = stops[stops.length - 1];
512
+ /* Focus outside the panel entirely — it can happen, because a panel may
513
+ be opened while something behind it is focused. Pull it back rather
514
+ than letting the first Tab escape. */
515
+ if (!panel.contains(document.activeElement)) {
516
+ e.preventDefault();
517
+ (e.shiftKey ? last : first).focus();
518
+ return;
519
+ }
520
+ if (e.shiftKey && document.activeElement === first) {
521
+ e.preventDefault();
522
+ last.focus();
523
+ } else if (!e.shiftKey && document.activeElement === last) {
524
+ e.preventDefault();
525
+ first.focus();
526
+ }
527
+ };
528
+
529
+ /**
530
+ * Focus that ends up outside, brought back.
531
+ *
532
+ * Tab is not the only way out. These panels repaint themselves — the
533
+ * connection list arrives and replaces the loading state, a filter redraws
534
+ * the rows — and replacing the node that had focus drops it on `<body>`,
535
+ * outside the thing that claims to be modal. A click on the page behind
536
+ * does the same.
537
+ *
538
+ * A `<dialog>` on top is the exception, and it has to be: the connections
539
+ * panel opens one to add a connection, and pulling focus back to the panel
540
+ * underneath would make that dialog impossible to type into. The platform
541
+ * is already trapping focus there, so this stands aside.
542
+ */
543
+ const onFocusIn = (e) => {
544
+ if (!panel.isConnected) return;
545
+ if (panel.contains(e.target)) return;
546
+ if (document.querySelector('dialog[open]')) return;
547
+ const stops = focusable();
548
+ if (stops.length) stops[0].focus();
549
+ };
550
+
551
+ /**
552
+ * The panel rebuilding itself out from under the keyboard.
553
+ *
554
+ * This is the one that actually bit. These panels paint twice — a loading
555
+ * state, then the answer — and `replaceChildren` destroys whatever had
556
+ * focus. Removing a focused element does not reliably fire `blur` or
557
+ * `focusout` in Chrome, so nothing above hears it; focus simply becomes
558
+ * `<body>` and the next Tab starts from the top of the document, outside
559
+ * the panel that claims to be modal.
560
+ *
561
+ * Watching the subtree catches it whatever caused it — a repaint, a row
562
+ * that removed itself, a list that filtered. If the panel put focus
563
+ * somewhere itself, as the connection filter does, this sees focus already
564
+ * inside and stands aside.
565
+ */
566
+ const watcher = new MutationObserver(() => {
567
+ if (!panel.isConnected) return;
568
+ if (panel.contains(document.activeElement)) return;
569
+ if (document.querySelector('dialog[open]')) return;
570
+ const stops = focusable();
571
+ if (stops.length) stops[0].focus();
572
+ });
573
+ watcher.observe(panel, { childList: true, subtree: true });
574
+
575
+ document.addEventListener('keydown', onKey, true);
576
+ document.addEventListener('focusin', onFocusIn);
577
+ return () => {
578
+ watcher.disconnect();
579
+ document.removeEventListener('keydown', onKey, true);
580
+ document.removeEventListener('focusin', onFocusIn);
581
+ /* Only if the panel still holds it. Something that closed itself by
582
+ sending you somewhere — a connection row that opens that database —
583
+ has already put focus where it belongs. */
584
+ if (cameFrom?.isConnected && !document.activeElement?.closest?.('dialog')) {
585
+ if (!panel.isConnected || panel.contains(document.activeElement)) cameFrom.focus?.();
586
+ }
587
+ };
588
+ }
589
+
590
+ /* ---------- navigation ---------- */
591
+
592
+ /* The renderer is registered rather than imported, so this module stays at
593
+ the bottom of the dependency graph and every view module can call `go`
594
+ without a cycle. */
595
+ let renderer = () => {};
596
+ export function setRenderer(fn) {
597
+ renderer = fn;
598
+ }
599
+ export function render() {
600
+ renderer();
601
+ }
602
+
603
+ /* Redrawing the trail alone, without re-running the view.
604
+ A row restored from a link knows only its key, so its crumb starts as the
605
+ key and is corrected once the row arrives. Calling render() for that would
606
+ re-fetch the row that just resolved it — and loop. */
607
+ let crumbRenderer = () => {};
608
+ export function setCrumbRenderer(fn) {
609
+ crumbRenderer = fn;
610
+ }
611
+ export function renderCrumbs() {
612
+ crumbRenderer();
613
+ }
614
+
615
+ /**
616
+ * Move to a view and render.
617
+ *
618
+ * push — a step along a reference; the trail grows
619
+ * replace — the same view, re-run (a sort, an edited filter, a tab)
620
+ * reset — a fresh start; the trail is discarded
621
+ *
622
+ * The distinction is the whole meaning of the breadcrumb. A trail is a walk:
623
+ * every crumb should be reachable from the one before it by following a key.
624
+ * Jumping to an unrelated table is not a step along that walk, so it starts a
625
+ * new one rather than appending browsing history.
626
+ */
627
+ export function go(entry, mode = 'push') {
628
+ if (mode === 'reset') state.stack = [entry];
629
+ else if (mode === 'replace') state.stack[state.stack.length - 1] = entry;
630
+ else state.stack.push(entry);
631
+ if (mode !== 'replace') state.page = 0;
632
+ /* How the address bar should record this. A step in the walk earns a
633
+ history entry so the browser's Back button undoes it; a refinement of the
634
+ view you are already on does not, or Back would spend three presses
635
+ undoing sort toggles. */
636
+ state.navMode = mode === 'replace' ? 'replace' : 'push';
637
+ render();
638
+ }
639
+
640
+ export function backTo(index) {
641
+ state.stack.length = index + 1;
642
+ state.page = 0;
643
+ // Clicking a crumb is a navigation, so Back returns you to where you were.
644
+ state.navMode = 'push';
645
+ render();
646
+ }
647
+
648
+ /** A table view, optionally filtered. The query text is what the bar shows. */
649
+ export function tableView(tableId, query, tab) {
650
+ return { kind: 'table', table: tableId, query: query ?? tableId, tab: tab ?? 'rows' };
651
+ }
652
+
653
+ /**
654
+ * A breakdown: counts and sums, grouped by something.
655
+ *
656
+ * `table` is the base being counted, so the sidebar highlights it and the
657
+ * crumb reads like a table's. It is deliberately not a `tab` of the table
658
+ * view: the rows of a breakdown are groups rather than records, and a tab
659
+ * would promise they behave like the rows beside them.
660
+ */
661
+ export function breakdownView(tableId, query) {
662
+ return { kind: 'breakdown', table: tableId, query };
663
+ }
664
+
665
+ /** A single row. The key is enough to re-fetch it, so nothing goes stale. */
666
+ export function rowView(tableId, key, label) {
667
+ return { kind: 'row', table: tableId, key, label };
668
+ }
669
+
670
+ /**
671
+ * A page, with or without a root yet.
672
+ *
673
+ * `table` is the page's base rather than a table being browsed, so that the
674
+ * sidebar highlights the right thing and a page can find its way back to a
675
+ * suggested layout when the id names no configured page.
676
+ */
677
+ export function pageView(pageId, tableId, key, label) {
678
+ return { kind: 'page', pageId, table: tableId, key, label };
679
+ }
680
+
681
+ export const currentEntry = () => state.stack[state.stack.length - 1];
682
+
683
+ /* How a record opens, when the walk is already looking at pages.
684
+ *
685
+ * A page is a way of reading a record, not a place: someone who chose the
686
+ * page view for a customer wants the invoice they clicked to open the same
687
+ * way, and dropping them into the plain record view — and making them press
688
+ * "As a page" again — is the walk changing shape underneath them.
689
+ *
690
+ * The resolver is registered rather than imported because core must not know
691
+ * about pages: `page.js` already imports this file, and the preference for a
692
+ * saved or configured page over the suggested one lives there with the rest
693
+ * of the page machinery. Nothing registered means nothing changes.
694
+ */
695
+ let pageResolver = null;
696
+
697
+ export function setPageResolver(resolve) {
698
+ pageResolver = resolve;
699
+ }
700
+
701
+ /**
702
+ * Open one record, in whichever view the walk is already in.
703
+ *
704
+ * Every route into a record goes through here, so the mode is sticky by
705
+ * default and a call site that wants the record view specifically still says
706
+ * `go(rowView(...))` — which is what the table grid and the record view's own
707
+ * related lists do, since neither is in page view to begin with.
708
+ */
709
+ export async function openRecord(tableId, key, label, how = 'push') {
710
+ if (currentEntry()?.kind === 'page' && pageResolver) {
711
+ try {
712
+ const pageId = await pageResolver(tableId);
713
+ if (pageId) {
714
+ go(pageView(pageId, tableId, key, label), how);
715
+ return;
716
+ }
717
+ } catch {
718
+ /* A page that cannot be resolved is not a reason to go nowhere. */
719
+ }
720
+ }
721
+ go(rowView(tableId, key, label), how);
722
+ }
723
+
724
+ /* ---------- chrome shared between views ---------- */
725
+
726
+ /* Whether the compiled SQL is on show. Held here rather than read off a
727
+ button, because the control that sets it moved into a menu and the pane
728
+ should not care which widget last toggled it. */
729
+ let explaining = false;
730
+
731
+ export const explainVisible = () => explaining;
732
+
733
+ export function setExplaining(on) {
734
+ explaining = on;
735
+ showExplain();
736
+ }
737
+
738
+ export function setExplain(sql) {
739
+ $('explain').textContent = sql ?? '';
740
+ lastExplained = sql ?? '';
741
+ showExplain();
742
+ }
743
+
744
+ /* The pane is hidden as a whole rather than the <pre> alone, so the control
745
+ that edits the statement cannot outlive the statement it edits. */
746
+ function showExplain() {
747
+ const pane = $('explain-pane');
748
+ if (pane) pane.hidden = !explaining || !$('explain').textContent;
749
+ }
750
+
751
+ /** The statement the current view compiled to, for anything that wants to run it. */
752
+ let lastExplained = '';
753
+ export const explainedSql = () => lastExplained;
754
+
755
+
756
+ export function showErrors(errors) {
757
+ const box = $('errors');
758
+ box.replaceChildren(...errors.map((e) => el('p', { text: e.message })));
759
+ box.hidden = !errors.length;
760
+
761
+ /* A query that did not run must not be saveable as a filter.
762
+
763
+ The banner said `"quantitee" is not a column on invoice_line`, correctly
764
+ and helpfully — and beside it "+ Save this filter" stayed live, so a typo
765
+ could be given a name and kept. The clause chips stayed ordinary too,
766
+ which is the deeper misdescription: when the query fails *none* of them
767
+ are in effect, so a row of chips reading as the current filter is
768
+ describing something that is not happening.
769
+
770
+ Done here rather than where the bar is built because this is the only
771
+ moment the app knows: the bar is drawn from the query text, before the
772
+ answer comes back. */
773
+ const failed = errors.length > 0;
774
+ for (const save of document.querySelectorAll('.view-save')) {
775
+ save.disabled = failed;
776
+ save.title = failed ? 'Fix the query before saving it as a filter.' : '';
777
+ }
778
+ document.querySelector('.views')?.classList.toggle('not-in-effect', failed);
779
+ }
780
+
781
+ /**
782
+ * Quote a value for the query bar, only when it needs it.
783
+ *
784
+ * The one place the client writes a value into the grammar. There used to be
785
+ * three, disagreeing: two escaped an embedded `"` as `\"` and one deleted it
786
+ * outright, so a customer called `Harbour "HQ"` produced a different filter
787
+ * depending on which button you pressed — and neither matched, because the
788
+ * server stripped the quotes without unescaping. The server's `unquoteValue`
789
+ * now undoes exactly what this does; the pair is asserted as a round trip in
790
+ * mirrors.test.ts.
791
+ *
792
+ * The backslash is escaped before the quote, or a value ending in one would
793
+ * escape its own closing quote and swallow the rest of the query.
794
+ */
795
+ export function quote(value) {
796
+ const text = String(value);
797
+ if (/^[\w.-]+$/.test(text)) return text;
798
+ return `"${text.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
799
+ }
800
+
801
+ /** A short-lived message at the top of the content area. */
802
+ /**
803
+ * A passing message, optionally with one thing to do about it.
804
+ *
805
+ * The action exists for the case where the message *is* the moment to act —
806
+ * "Updated 2 columns" is exactly when someone realises it was the wrong row,
807
+ * and sending them to a menu to find Undo wastes the seconds in which they
808
+ * still remember what they did. Anything that needs more than one verb belongs
809
+ * in a dialog rather than here.
810
+ */
811
+ export function toast(message, tone = 'info', action) {
812
+ const box = $('toast');
813
+ box.className = `toast toast-${tone}`;
814
+ box.replaceChildren(el('span', { text: message }));
815
+ if (action) {
816
+ box.append(el('button', {
817
+ type: 'button',
818
+ class: 'toast-action',
819
+ text: action.label,
820
+ onclick: () => { box.hidden = true; action.run(); },
821
+ }));
822
+ }
823
+ box.hidden = false;
824
+ clearTimeout(toast.timer);
825
+ toast.timer = setTimeout(() => {
826
+ box.hidden = true;
827
+ // Longer when there is something to do: four seconds is enough to read a
828
+ // message and not enough to decide to act on one.
829
+ }, action ? 9000 : 4000);
830
+ }
831
+
832
+ /* ---------- persistence ----------
833
+
834
+ Every localStorage call is wrapped. It throws in Safari's private mode, it
835
+ throws when the quota is exceeded, and it is absent entirely in some
836
+ embedded webviews. A browser that cannot remember your saved filters should
837
+ still let you browse the database — losing a preference is not a reason to
838
+ lose the tool. */
839
+
840
+ export function loadJson(key, fallback) {
841
+ try {
842
+ migrateNamespace();
843
+ const raw = window.localStorage?.getItem(key);
844
+ if (!raw) return fallback;
845
+ const parsed = JSON.parse(raw);
846
+ return parsed ?? fallback;
847
+ } catch {
848
+ // Corrupt or unreadable: start fresh rather than breaking the page.
849
+ return fallback;
850
+ }
851
+ }
852
+
853
+ /**
854
+ * Carry everything over from the previous name's namespace, once.
855
+ *
856
+ * The project was tablewalk, then datawalk, and is tablewalk again. The
857
+ * first rename copied lazily and left the old keys in place — so a lazy copy
858
+ * back would find those ancient `tablewalk.*` values first and quietly
859
+ * resurrect them over the `datawalk.*` ones that received every write since.
860
+ * One sweep instead: every `datawalk.*` key overwrites its `tablewalk.*`
861
+ * counterpart, newest namespace wins, and a marker stops it running twice.
862
+ * The old keys are left alone — a migration that also deletes has no way
863
+ * back if it turns out to be wrong.
864
+ */
865
+ const MIGRATION_MARK = 'tablewalk.migrated-from-datawalk';
866
+ function migrateNamespace() {
867
+ try {
868
+ const store = window.localStorage;
869
+ if (!store || store.getItem(MIGRATION_MARK)) return;
870
+ const carried = [];
871
+ for (let i = 0; i < store.length; i += 1) {
872
+ const key = store.key(i);
873
+ if (key?.startsWith('datawalk.')) carried.push(key);
874
+ }
875
+ // Collected before writing: setItem during iteration reorders the store.
876
+ for (const key of carried) {
877
+ const value = store.getItem(key);
878
+ if (value !== null) store.setItem(`tablewalk.${key.slice('datawalk.'.length)}`, value);
879
+ }
880
+ store.setItem(MIGRATION_MARK, '1');
881
+ } catch {
882
+ // Storage unavailable; nothing to migrate from either.
883
+ }
884
+ }
885
+
886
+ export function saveJson(key, value) {
887
+ try {
888
+ window.localStorage?.setItem(key, JSON.stringify(value));
889
+ return true;
890
+ } catch {
891
+ return false;
892
+ }
893
+ }