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,1035 @@
1
+ /**
2
+ * The table view: tabs, results, sorting and paging.
3
+ *
4
+ * Sorting works by editing the query text rather than holding UI state, so
5
+ * the sort is visible in the language and survives being walked back to.
6
+ */
7
+ import {
8
+ $, PAGE_SIZES, pageSize, setPageSize, api, currentEntry, el, findTable, go, keyOf,
9
+ looksLikeDateColumn, pageView, primaryKey, quote, render, rowLabel, rowView, setExplain,
10
+ showErrors, state, tableView, whenRemoved,
11
+ } from './core.js';
12
+ import { addSort, explicitLimit, nextSort, sortCount, sortDirection } from './clauses.js';
13
+ import { SORT_SCAN_BELOW, planColumn, sortableFromHeader } from './columns.js';
14
+ import { composeFilter } from './compose.js';
15
+ import { promptFor, promptForRange } from './prompt.js';
16
+ import { dropdown } from './dropdown.js';
17
+ import { VIRTUAL_THRESHOLD, virtualise } from './virtual.js';
18
+ import { diagram } from './diagram.js';
19
+ import { exportControl } from './export.js';
20
+ import { newRowButton } from './insert.js';
21
+ import { recordQuery } from './history.js';
22
+ import { applyPins, pinnedFor, togglePin } from './pins.js';
23
+ import { initGrid } from './grid.js';
24
+ import { peekable } from './peek.js';
25
+ import { pagesFor } from './page.js';
26
+ import { expandAllControl, expander, hasChildren, resetNested } from './nested.js';
27
+ import { gridSkeleton, placeholder } from './skeleton.js';
28
+
29
+ function tabs(entry) {
30
+ const bar = el('div', { class: 'tabs', role: 'tablist' });
31
+ for (const [id, label] of [['rows', 'Rows'], ['ddl', 'Definition'], ['diagram', 'Diagram']]) {
32
+ bar.append(el('button', {
33
+ type: 'button',
34
+ role: 'tab',
35
+ class: 'tab',
36
+ 'aria-selected': String((entry.tab ?? 'rows') === id),
37
+ text: label,
38
+ onclick: () => go({ ...entry, tab: id }, 'replace'),
39
+ }));
40
+ }
41
+
42
+ /* The way into a page from a table.
43
+
44
+ Not a tab of this table — a page is its own kind of entry, with its own
45
+ root — but this is where someone looks for it, and a feature reachable
46
+ only by editing a config file is a feature most people never meet. Every
47
+ table has a page: the one someone wrote, or the one built from its
48
+ schema. */
49
+ bar.append(el('button', {
50
+ type: 'button',
51
+ class: 'tab tab-page',
52
+ title: 'A page about one record of this table',
53
+ text: 'Page \u2192',
54
+ onclick: () => void openPageFor(entry.table),
55
+ }));
56
+ return bar;
57
+ }
58
+
59
+ /** Open this table's page: the configured one if there is one, else the suggested. */
60
+ async function openPageFor(tableId) {
61
+ let pageId = `suggested:${tableId}`;
62
+ try {
63
+ const [configured] = await pagesFor(tableId);
64
+ if (configured && !configured.error) pageId = configured.id;
65
+ } catch {
66
+ // No config pages to read is not a reason to refuse the suggested one.
67
+ }
68
+ go(pageView(pageId, tableId), 'push');
69
+ }
70
+
71
+ /** The active virtualiser, torn down before another view replaces it. */
72
+ let teardownVirtual = null;
73
+
74
+ /** Bumped per run, so a reply that outlived its request paints nothing. */
75
+ let runToken = 0;
76
+
77
+ export async function renderTable(entry, content) {
78
+ // Any previous grid is gone the moment a new view renders; its scroll
79
+ // listener must go with it rather than outliving the DOM it watched.
80
+ teardownVirtual?.();
81
+ teardownVirtual = null;
82
+ $('query').value = entry.query;
83
+ const body = el('div');
84
+ content.append(tabs(entry), body);
85
+
86
+ if (entry.tab === 'diagram') {
87
+ body.replaceChildren(el('div', { class: 'panel-pad' }, diagram(entry.table)));
88
+ return;
89
+ }
90
+ if (entry.tab === 'ddl') {
91
+ const reading = placeholder(body, () => gridSkeleton(2, 6));
92
+ try {
93
+ const { sql, exact } = await api(`/api/ddl?table=${encodeURIComponent(entry.table)}`);
94
+ reading();
95
+ body.replaceChildren(
96
+ el('p', { class: 'ddl-note' }, [
97
+ el('strong', { text: exact ? 'Stored definition. ' : 'Reconstructed. ' }),
98
+ document.createTextNode(
99
+ exact
100
+ ? 'This is the statement the database itself keeps, verbatim.'
101
+ : 'Rebuilt from the catalog. Indexes, checks and triggers are not shown.',
102
+ ),
103
+ ]),
104
+ el('pre', { class: 'ddl', text: sql }),
105
+ );
106
+ } catch (err) {
107
+ reading();
108
+ body.replaceChildren(el('p', { class: 'note', text: err.message }));
109
+ }
110
+ return;
111
+ }
112
+
113
+ /* Shaped like the grid that is about to replace it, and only if the query
114
+ takes long enough to be worth acknowledging. */
115
+ const table = findTable(entry.table);
116
+ /* Claimed before the request goes out, checked when it comes back.
117
+
118
+ Without this the *last response* won rather than the last request. Skim
119
+ the sidebar with the arrow keys and runs for two tables overlap; if the
120
+ slower one lands second it repaints the rows — but it also resets the
121
+ nested cache, replaces the error banner, rewrites the SQL pane and adds a
122
+ history entry, all belonging to a table you are no longer on. The grid
123
+ showed B's data under A's SQL and A's errors.
124
+
125
+ Same shape as `runToken` in composer.js, which solved this for the
126
+ preview first. */
127
+ const mine = ++runToken;
128
+ const done = placeholder(body, () =>
129
+ gridSkeleton(table?.columns.length ?? 5, explicitLimit(entry.query) ?? pageSize()));
130
+
131
+ let data;
132
+ try {
133
+ /* An explicit `limit` in the query is a deliberate statement and beats
134
+ the page size. Without this the client's default silently overrode it,
135
+ so `limit 40000` returned twenty rows and the language quietly lied. */
136
+ const asked = explicitLimit(entry.query);
137
+ data = await api('/api/run', {
138
+ q: entry.query,
139
+ limit: asked ?? pageSize(),
140
+ offset: asked ? 0 : state.page * pageSize(),
141
+ // Set only by "count them", so the expensive path is asked for rather
142
+ // than arrived at.
143
+ exactCount: entry.exactCount === true,
144
+ });
145
+ } catch (err) {
146
+ if (mine !== runToken) return;
147
+ done();
148
+ body.replaceChildren(el('p', { class: 'note', text: err.message }));
149
+ return;
150
+ }
151
+ /* Nothing below this line touches only the body — `resetNested`,
152
+ `showErrors`, `setExplain` and `recordQuery` are all global — so a stale
153
+ reply has to stop here, before any of them. */
154
+ if (mine !== runToken) return;
155
+ done();
156
+ /* A new result means new rows; answers cached against the old ones are
157
+ answers to a question nobody is asking any more. */
158
+ resetNested();
159
+ showErrors(data.errors ?? []);
160
+ setExplain(data.explain);
161
+ if (!data.columns) {
162
+ body.replaceChildren(el('p', { class: 'note', text: 'Fix the query above to see results.' }));
163
+ return;
164
+ }
165
+
166
+ /* Recorded here rather than at submit: this is the point where the query is
167
+ known to have compiled and run, and a history full of typos you never got
168
+ an answer from is a history nobody opens. */
169
+ recordQuery(entry.query);
170
+
171
+ const shown = state.page * pageSize() + data.rows.length;
172
+ const head = el('div', { class: 'result-head' }, [
173
+ el('h2', { text: data.table }),
174
+ /* An estimate says so, and offers the real number.
175
+
176
+ `COUNT(*)` is a scan, so on a large table the header stat — which nobody
177
+ asked for — was the most expensive thing on the page. Above a threshold
178
+ it now comes from the catalog's own statistics instead, and the
179
+ interface has to be honest about which it is showing: "2,400,113" when
180
+ the truth is 2,411,908 is worse than "~2.4M", because one of them is
181
+ wrong and the other is not pretending. */
182
+ data.exactTotal === false
183
+ ? el('button', {
184
+ type: 'button',
185
+ class: 'stat stat-estimate',
186
+ title: 'Roughly, from the database’s own statistics — no rows were counted. Click to count them.',
187
+ onclick: () => void countExactly(entry, body),
188
+ text: data.total > shown
189
+ ? `${shown.toLocaleString()} of ~${approx(data.total)} rows`
190
+ : `~${approx(data.total)} rows`,
191
+ })
192
+ : el('span', {
193
+ class: 'stat',
194
+ text: data.total > shown
195
+ ? `${shown.toLocaleString()} of ${data.total.toLocaleString()} rows`
196
+ : `${data.total.toLocaleString()} row${data.total === 1 ? '' : 's'}`,
197
+ }),
198
+ el('span', { class: 'stat', text: `${data.ms} ms` }),
199
+ /* Named in the header as well as marked in the grid, because the column
200
+ it sorted by may not be one of the columns on screen — a `show name`
201
+ projection still gets ordered by the key. Clicking writes it into the
202
+ query, which turns a default into a decision. */
203
+ data.order?.implicit && data.order.order.length
204
+ ? el('button', {
205
+ type: 'button',
206
+ class: 'stat stat-order',
207
+ title: 'Rows have to come back in some order. Click to make this one explicit.',
208
+ onclick: () => sortBy(data.order.order[0].column, data.order.order[0].direction),
209
+ text: `sorted by ${data.order.order[0].column}${data.order.order[0].direction === 'desc' ? ' ↓' : ' ↑'}`,
210
+ })
211
+ : null,
212
+ /* Beside Export rather than among the stats: both are actions on the
213
+ table itself, and the gate (writable, write mode) already decided
214
+ whether the button exists at all. */
215
+ newRowButton(findTable(data.table)),
216
+ exportControl(entry, data),
217
+ ]);
218
+
219
+ body.replaceChildren(head, resultTable(findTable(data.table), data), pager(entry, data, shown));
220
+ }
221
+
222
+ /**
223
+ * A rounded figure, because an estimate written out in full reads as a count.
224
+ *
225
+ * "2,411,908" claims a precision the number does not have. "2.4M" says about
226
+ * as much as is known, which is the point.
227
+ */
228
+ function approx(n) {
229
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(n < 10_000_000 ? 1 : 0)}M`;
230
+ if (n >= 1_000) return `${(n / 1_000).toFixed(n < 10_000 ? 1 : 0)}k`;
231
+ return String(n);
232
+ }
233
+
234
+ /** Re-run the query asking for the true count this time. */
235
+ async function countExactly(entry, body) {
236
+ const stat = body.querySelector('.stat-estimate');
237
+ if (stat) stat.textContent = 'counting…';
238
+ /* The whole query again rather than a count on its own: the rows are already
239
+ cached by the browser and this keeps one code path, where a second one
240
+ would be a second place for the count and the page to disagree. */
241
+ go({ ...entry, exactCount: true }, 'replace');
242
+ }
243
+
244
+ /**
245
+ * A result grid, for anything that has rows and columns.
246
+ *
247
+ * Exported so the record page's embedded tables render exactly like the main
248
+ * grid — same type-aware cells, same alignment, same null and boolean
249
+ * treatment. Two renderers would drift, and a work order's parts showing
250
+ * `1`/`0` where the table above shows ✓/✗ is the kind of small inconsistency
251
+ * that makes a tool feel assembled rather than built.
252
+ */
253
+ export function renderGrid(table, data, { onRowClick, compact = false, emptyText } = {}) {
254
+ const wrap = resultTable(table, data, { onRowClick, virtual: false, emptyText });
255
+ if (compact) wrap.classList.add('grid-compact');
256
+ return wrap;
257
+ }
258
+
259
+ function resultTable(table, data, options = {}) {
260
+ const wrap = el('div', { class: 'table-wrap' });
261
+ /* A caller that knows what it was asking for gets the shape of the answer
262
+ even when the answer is nothing: the headers say which columns had
263
+ nothing in them, which is the same argument the empty query result has
264
+ always made, and an empty related list is where it matters most — the
265
+ sentence alone leaves you unable to tell an empty list from a broken
266
+ one. */
267
+ if (!data.rows.length && !(options.emptyText && data.columns?.length)) {
268
+ /* "No rows match" claims a filter did it. On `customer` with no
269
+ conditions, nothing was excluded — the table is empty, which is a fact
270
+ about the database and a different thing to be told. Saying the first
271
+ when the second is true sends someone looking for a filter that is not
272
+ there. */
273
+ const entry = currentEntry();
274
+ const filtered = !entry || entry.kind !== 'table'
275
+ || entry.query.trim() !== entry.table
276
+ || Boolean(data.errors?.length);
277
+ wrap.append(el('p', {
278
+ class: 'note',
279
+ text: filtered ? 'No rows match.' : 'This table is empty.',
280
+ }));
281
+ return wrap;
282
+ }
283
+ const byName = new Map((table?.columns ?? []).map((c) => [c.name, c]));
284
+ const plans = new Map(data.columns.map((name) => [name, planColumn(byName.get(name), name, data.rows)]));
285
+ const empty = !data.rows.length;
286
+
287
+ const interactive = options.virtual !== false;
288
+ /* Only where the header controls apply: an embedded child table is a handful
289
+ of columns in a panel, and freezing one of them buys nothing. */
290
+ const pinned = interactive ? pinnedFor(table, data.columns) : [];
291
+
292
+ /* Expanders are offered only on a page the virtualiser is not managing.
293
+
294
+ An opened detail row is many times the height of an ordinary one, and the
295
+ virtualiser's whole model is that every row is the same height — it
296
+ computes each offset as `index * rowHeight`. Mixing the two would make the
297
+ scrollbar disagree with the content by a screenful. The default page is
298
+ twenty rows and is never virtualised, so the affordance is there when it is
299
+ wanted and absent on the forty-thousand-row result where it could not
300
+ work. */
301
+ // Built before the header, which holds a control that watches it.
302
+ const tbody = el('tbody');
303
+
304
+ /* Decided here rather than beside the loop that uses it, because the header
305
+ has to know: expanding every row is meaningless on a table whose rows are
306
+ a moving window over the result. */
307
+ const virtual = options.virtual !== false && data.rows.length > VIRTUAL_THRESHOLD;
308
+ const expandable = interactive
309
+ && data.rows.length <= VIRTUAL_THRESHOLD
310
+ && hasChildren(table);
311
+
312
+ /**
313
+ * Whether the header's controls have anything to act on.
314
+ *
315
+ * Sorting and filtering here work by rewriting the query text — that is the
316
+ * design, and it is why a filter built by clicking can be edited by typing.
317
+ * It also means both are meaningless for a result that did not come from a
318
+ * tablewalk query: a view fall-through, or a SQL statement. `table` being
319
+ * undefined is exactly that case.
320
+ *
321
+ * Left alone, the degradation was silent and wrong in three ways at once.
322
+ * `!table?.indexed` is `true` when there is no table, so *every* column
323
+ * became sortable and the unindexed-sort guard disappeared; clicking one
324
+ * rewrote text that was not a query; and the filter menu fabricated
325
+ * `${table} show ${column}` and re-ran it, which for a SQL result would
326
+ * destroy the statement.
327
+ */
328
+ const language = interactive && Boolean(table);
329
+
330
+ /* Whether a row click can go anywhere. A uniform property of the result, not
331
+ of each row: either the key columns are present or they are not. */
332
+ const navigable = Boolean(options.onRowClick)
333
+ || Boolean(table && primaryKey(table).length
334
+ && primaryKey(table).every((c) => data.columns.includes(c)));
335
+ const headRow = el('tr');
336
+ // An empty header over the expander column, so the cells below line up.
337
+ /* The one control that belongs above the expander column, and costs no row
338
+ of its own. Not offered on a virtualised table: half those rows do not
339
+ exist, and the ones that do are replaced as you scroll. */
340
+ if (expandable) {
341
+ headRow.append(el('th', { class: 'expander-cell' },
342
+ virtual ? null : expandAllControl(tbody)));
343
+ }
344
+ for (const name of data.columns) {
345
+ const meta = byName.get(name);
346
+ const plan = plans.get(name);
347
+ // An embedded table has no query behind it to sort or filter, so its
348
+ // header is a label rather than a set of controls that would do nothing.
349
+ /* Three states, and the difference between the last two matters:
350
+ sorted because the query says so, sorted because nothing did and the
351
+ rows had to come back in *some* order, and not sorted at all. The
352
+ implicit one is drawn faintly and says so on hover — it is a fact about
353
+ the result, not a choice the reader made. */
354
+ /* Sorting from the header is offered only on columns the database can
355
+ sort cheaply.
356
+
357
+ A header click is a one-click action with no visible cost, and on an
358
+ unindexed column of a large table it is a full scan and a sort of
359
+ everything to show twenty rows. The language still allows it — typing
360
+ `sort status` is an explicit request, made by someone who has said what
361
+ they want — but the interface does not hand it to you as though it were
362
+ free. */
363
+ /* Indexed, or small enough that it does not matter. `approxRows` unknown
364
+ is not treated as small: a table nobody could count is not one to
365
+ assume is cheap. */
366
+ const sortable = table
367
+ ? !table.indexed || table.indexed.includes(name) || sortableFromHeader(table)
368
+ : false;
369
+ const explicit = language ? sortStateFor(name) : null;
370
+ const implied = !explicit && data.order?.implicit
371
+ ? data.order.order.find((o) => o.column === name)?.direction ?? null
372
+ : null;
373
+ const sorted = explicit ?? implied;
374
+ headRow.append(
375
+ el(
376
+ 'th',
377
+ {
378
+ // Headers take the column's alignment, so the label sits over its
379
+ // own values instead of drifting away from a right-aligned number.
380
+ class: `align-${plan.align}${explicit ? ' sorted' : ''}${implied ? ' sorted-default' : ''}`,
381
+ title: implied
382
+ ? `${meta ? `${meta.type} · ` : ''}default order — rows have to come back in some order, and this is the one tablewalk chose`
383
+ /* A view's computed column has no declared type, and now says so
384
+ rather than claiming BLOB — so there may be nothing to put here,
385
+ and an empty tooltip is worse than the column's own name. */
386
+ : meta?.type ? `${meta.type}${meta.nullable ? '' : ' NOT NULL'}` : name,
387
+ },
388
+ [
389
+ el('button', {
390
+ class: `th-sort${language && !sortable ? ' not-sortable' : ''}`,
391
+ type: 'button',
392
+ /* `aria-disabled`, not `disabled`.
393
+
394
+ A disabled button is not focusable, so the sentence explaining
395
+ *why* a column cannot be sorted — the most useful thing on this
396
+ header — was reachable by hovering and by nothing else. Keyboard
397
+ and screen-reader users got a control that was simply absent,
398
+ with no reason given. Marked unavailable instead, and the click
399
+ does nothing. */
400
+ 'aria-disabled': String(!language || !sortable),
401
+ title: !language
402
+ ? `${name} — sorting a result like this one means editing the statement that produced it`
403
+ : sortable
404
+ ? `Sort by ${name}`
405
+ : `${name} is not indexed — sorting it would scan the whole table. Type "sort ${name}" in the query bar if you mean it.`,
406
+ // Sorting edits the query text, so the sort is visible in the
407
+ // language rather than hidden in UI state.
408
+ /* Shift-click adds a level rather than replacing the clause, so
409
+ a two-column sort can be built by clicking. Without a modifier
410
+ it still replaces: reversing one column of `sort a, b` is not
411
+ something a plain click can express. */
412
+ onclick: (e) => {
413
+ if (!language || !sortable) return;
414
+ if (e.shiftKey) sortBy(name, sortDirection(entry.query, name) === 'asc' ? 'desc' : 'asc', true);
415
+ else sortBy(name);
416
+ },
417
+ }, [
418
+ document.createTextNode(name),
419
+ meta?.primaryKey ? el('span', { class: 'pk', text: ' key' }) : null,
420
+ meta?.references ? el('span', { class: 'fk', text: ' →' }) : null,
421
+ sorted ? el('span', { class: 'th-arrow', text: sorted === 'desc' ? ' ↓' : ' ↑' }) : null,
422
+ ]),
423
+ language ? el('button', {
424
+ class: 'th-filter',
425
+ type: 'button',
426
+ title: `Filter on ${name}`,
427
+ 'aria-label': `Filter on ${name}`,
428
+ text: '⌄',
429
+ onclick: (e) => { e.stopPropagation(); openFilterMenu(e.currentTarget, meta, name, data.rows, plan); },
430
+ }) : null,
431
+ ].filter(Boolean),
432
+ ),
433
+ );
434
+ }
435
+
436
+ const openRow = options.onRowClick
437
+ ?? ((row) => {
438
+ const key = table && keyOf(table, row);
439
+ // A projection that leaves out the key produces rows with nothing to
440
+ // navigate by. Better to do nothing than to open a row that is not there.
441
+ if (!key) return;
442
+ go(rowView(table.id, key, rowLabel(table, row)));
443
+ });
444
+ const buildRow = (row, index) => {
445
+ /* No handler at all when there is nowhere to go, so the row is inert in the
446
+ DOM as well as in effect. It used to keep its `onclick` and its pointer
447
+ cursor and simply do nothing, which is the worst of both: it advertises
448
+ an action it does not have. */
449
+ const tr = navigable
450
+ ? el('tr', { class: 'clickable', onclick: () => openRow(row) })
451
+ : el('tr');
452
+ /* The row's place in the result, not in the DOM. Above the threshold the
453
+ rows in `tbody` are a moving window over `data.rows`, so `tr` position
454
+ and row number are different numbers; the keyboard cursor navigates by
455
+ this one. */
456
+ if (index !== undefined) tr.dataset.row = String(index);
457
+ if (expandable) tr.append(expander(table, row, tr) ?? el('td', { class: 'expander-cell' }));
458
+ for (const name of data.columns) tr.append(cell(byName.get(name), plans.get(name), row[name]));
459
+ return tr;
460
+ };
461
+
462
+ /* Beyond a few hundred rows the DOM, not the database, is the bottleneck:
463
+ forty-two thousand rows arrive in about 100ms and take seconds to put on
464
+ the page, after which every scroll and hover is slow. Below the threshold
465
+ windowing costs more than it saves. */
466
+ if (!virtual) {
467
+ data.rows.forEach((row, i) => tbody.append(buildRow(row, i)));
468
+ }
469
+ wrap.append(el('table', {}, [el('thead', {}, headRow), tbody]));
470
+
471
+ if (virtual) {
472
+ /* Started after the table is in the document, because the window is
473
+ measured from a real rendered row and from the scroller's actual
474
+ height — neither of which exists until it is attached. */
475
+ queueMicrotask(() => {
476
+ const scroller = wrap.closest('.content') ?? document.scrollingElement;
477
+ if (!wrap.isConnected) return;
478
+ teardownVirtual?.();
479
+ teardownVirtual = virtualise({
480
+ scroller,
481
+ tbody,
482
+ rows: data.rows,
483
+ renderRow: buildRow,
484
+ columns: data.columns.length,
485
+ });
486
+ });
487
+ wrap.append(el('p', {
488
+ class: 'note virtual-note',
489
+ text: `${data.rows.length.toLocaleString()} rows, drawn as you scroll.`,
490
+ }));
491
+ }
492
+
493
+ if (pinned.length) {
494
+ /* After layout, because the offsets are the widths of the columns to the
495
+ left and those are not known until the browser has measured them. The
496
+ observer re-measures on resize *and* after a virtualised repaint swaps
497
+ the rows out from under the ones already frozen. */
498
+ const freeze = () => applyPins(wrap, data.columns, pinned);
499
+ requestAnimationFrame(freeze);
500
+ const size = new ResizeObserver(freeze);
501
+ size.observe(wrap);
502
+ const rows = new MutationObserver(freeze);
503
+ rows.observe(tbody, { childList: true });
504
+
505
+ /* Stopped when the table they watch leaves the document. Without this,
506
+ two observers were created on every render and never stopped — each one
507
+ re-measuring pinned columns on a table nobody can see. */
508
+ whenRemoved(wrap, () => {
509
+ size.disconnect();
510
+ rows.disconnect();
511
+ });
512
+ }
513
+
514
+ /* After the table exists and after virtualisation is set up, so the cursor
515
+ is measuring rows that are really there. */
516
+ initGrid(wrap, {
517
+ tbody,
518
+ rowCount: data.rows.length,
519
+ columnCount: data.columns.length,
520
+ offset: expandable ? 1 : 0,
521
+ onOpen: navigable ? (index) => openRow(data.rows[index]) : undefined,
522
+ /* So a screen reader hears "of 63,010" rather than "of 20". An estimate is
523
+ not passed: announcing a guess as the count is worse than announcing
524
+ nothing, because nothing at least sounds like nothing. */
525
+ totalRows: data.exactTotal === false ? undefined : data.total,
526
+ firstRow: explicitLimit(currentEntry()?.query ?? '') ? 0 : state.page * pageSize(),
527
+ });
528
+
529
+ /* The reason there are no rows, under the headers that say what would have
530
+ been in them. */
531
+ if (empty) wrap.append(el('p', { class: 'note grid-empty', text: options.emptyText }));
532
+
533
+ return wrap;
534
+ }
535
+
536
+ /**
537
+ * One cell, rendered by what it holds.
538
+ *
539
+ * A reference is marked on the way out rather than in each branch: a foreign
540
+ * key can be a number, a string or a code, so the branch that draws it is
541
+ * decided by its *type* while whether it points somewhere is decided by the
542
+ * schema. Marking it in one place is what stopped numeric keys — the most
543
+ * common kind there is — from being the one sort that could not be peeked at.
544
+ */
545
+ function cell(meta, plan, value) {
546
+ return peekable(paint(meta, plan, value), meta?.references, value);
547
+ }
548
+
549
+ /**
550
+ * Thousands separators on a numeric string too big to be a JS number.
551
+ *
552
+ * `toLocaleString` is the usual way to group digits and it cannot be used
553
+ * here: it takes a number, and turning this back into one is the rounding
554
+ * being avoided. So the grouping is done on the digits directly, which is all
555
+ * `useGrouping` was ever doing to the integer part anyway.
556
+ */
557
+ function groupDigits(text) {
558
+ const neg = text.startsWith('-');
559
+ const digits = neg ? text.slice(1) : text;
560
+ const grouped = digits.replace(/\B(?=(\d{3})+(?!\d))/g, ',');
561
+ return neg ? `-${grouped}` : grouped;
562
+ }
563
+
564
+ function paint(meta, plan, value) {
565
+ if (value === null || value === undefined) {
566
+ return el('td', { class: `null align-${plan.align}`, text: 'null' });
567
+ }
568
+ const raw = String(value);
569
+ const classes = [`align-${plan.align}`];
570
+ if (plan.mono || plan.kind === 'number') classes.push('mono');
571
+
572
+ if (plan.kind === 'boolean') {
573
+ const on = value === true || value === 1 || value === '1' || value === 't';
574
+ return el('td', { class: `${classes.join(' ')} bool`, title: raw },
575
+ el('span', { class: on ? 'yes' : 'no', text: on ? '✓' : '✗' }));
576
+ }
577
+
578
+ if (plan.kind === 'number') {
579
+ const n = Number(value);
580
+ /* A value the server sent as a string because it is past 2^53 must not be
581
+ run back through Number() here — that is the exact rounding the server
582
+ fix avoided, undone one layer later. `9223372036854775807` came over the
583
+ wire intact and `Number(...).toLocaleString()` renders it
584
+ `9223372036854776000`: a different number, off by 193, that looks
585
+ perfectly valid. So a raw string that is not a safe integer is shown as
586
+ it arrived, grouped by hand rather than by a lossy round trip. */
587
+ const overflow = typeof value === 'string' && /^-?\d+$/.test(value)
588
+ && !Number.isSafeInteger(n);
589
+ const shown = overflow
590
+ ? groupDigits(value)
591
+ : Number.isFinite(n)
592
+ ? n.toLocaleString(undefined, {
593
+ minimumFractionDigits: plan.decimals,
594
+ maximumFractionDigits: plan.decimals,
595
+ useGrouping: plan.group,
596
+ })
597
+ : raw;
598
+ const td = el('td', { class: `${classes.join(' ')} num`, text: shown, title: shown === raw ? undefined : raw });
599
+ // A numeric foreign key is still a link into another table.
600
+ if (meta?.references) td.classList.add('reflink');
601
+ return td;
602
+ }
603
+
604
+ if (plan.kind === 'date') {
605
+ const shown = plan.showTime ? raw.replace('T', ' ') : raw.slice(0, 10);
606
+ return el('td', { class: classes.join(' '), text: shown, title: shown === raw ? undefined : raw });
607
+ }
608
+
609
+ if (meta?.references) {
610
+ return el('td', { class: classes.join(' ') },
611
+ el('span', { class: 'ref', text: raw, title: `→ ${meta.references.table}` }));
612
+ }
613
+ /* A grid cell shows one line, clipped at 30rem with an ellipsis, so nothing
614
+ past a couple of hundred characters is ever visible in it — and a `TEXT`
615
+ column can hold a whole document. Putting the raw value into the DOM and
616
+ into the `title` regardless meant a page of a hundred rows carried
617
+ megabytes nobody could read, and a native tooltip that spanned the
618
+ screen. The cell shows a bounded prefix and says how much more there is;
619
+ the record page, which is where a long value is actually read, still gets
620
+ it whole. */
621
+ const cell = clampCell(raw);
622
+ return el('td', { class: classes.join(' '), title: cell.title, text: cell.text });
623
+ }
624
+
625
+ /** The most a single grid cell will put in the DOM. Well past what 30rem shows. */
626
+ const CELL_MAX = 300;
627
+
628
+ function clampCell(raw) {
629
+ if (raw.length <= CELL_MAX) {
630
+ return { text: raw, title: raw.length > 40 ? raw : undefined };
631
+ }
632
+ /* A count rather than a bare ellipsis, because "there is more" and "there
633
+ are forty thousand more characters" are different facts about a row, and
634
+ the second is the one worth knowing before you open it. */
635
+ const more = raw.length - CELL_MAX;
636
+ return {
637
+ text: `${raw.slice(0, CELL_MAX)}…`,
638
+ title: `${more.toLocaleString()} more character${more === 1 ? '' : 's'} — open the row to read it`,
639
+ };
640
+ }
641
+
642
+ /** 'asc' | 'desc' when the current query sorts by this column, else null. */
643
+ function sortStateFor(column) {
644
+ const entry = currentEntry();
645
+ if (!entry || entry.kind !== 'table') return null;
646
+ return sortDirection(entry.query, column);
647
+ }
648
+
649
+ /**
650
+ * The per-column filter menu.
651
+ *
652
+ * Every option writes a condition into the query bar rather than holding
653
+ * hidden state. That keeps one source of truth — the text — so a filter built
654
+ * by clicking can be edited by typing, saved as a view, shared as a link and
655
+ * read back later. A filter you cannot see is a filter you cannot debug.
656
+ */
657
+ async function openFilterMenu(anchor, meta, name, rows, plan) {
658
+ document.querySelector('.th-menu')?.remove();
659
+
660
+ /* Values are sampled from the *unfiltered* table, not the visible page.
661
+ Once you have filtered to status = scheduled the page contains only
662
+ scheduled rows, so a menu built from it offers exactly the filter you
663
+ already applied — useless precisely when you want to widen. */
664
+ const entryNow = currentEntry();
665
+ let sample = rows;
666
+ if (entryNow && entryNow.kind === 'table' && entryNow.query.trim() !== entryNow.table) {
667
+ try {
668
+ const unfiltered = await api('/api/run', {
669
+ q: `${entryNow.table} show ${name}`,
670
+ limit: 400,
671
+ });
672
+ if (unfiltered.rows?.length) sample = unfiltered.rows;
673
+ } catch {
674
+ // Sampling is a nicety; the rest of the menu still works without it.
675
+ }
676
+ }
677
+ rows = sample;
678
+
679
+ /* The header this menu belongs to may be gone.
680
+
681
+ The sample takes a round trip, and navigating during it leaves the
682
+ anchor detached — `getBoundingClientRect` on a node that is no longer in
683
+ the document is all zeroes, so the menu opened in the top-left corner of
684
+ the window, attached to nothing, listing values from the table you just
685
+ left. */
686
+ if (!anchor.isConnected) return;
687
+
688
+ const add = (condition) => {
689
+ const entry = currentEntry();
690
+ if (!entry || entry.kind !== 'table') return;
691
+ go(tableView(entry.table, composeFilter(entry.query, entry.table, name, condition)), 'replace');
692
+ menu.remove();
693
+ };
694
+
695
+ const options = [];
696
+ const kind = plan.kind;
697
+
698
+ if (kind === 'boolean') {
699
+ options.push(['is true', `${name} = true`], ['is false', `${name} = false`]);
700
+ } else if (kind === 'date' || dateish(meta, name)) {
701
+ /* Dates get ranges, not equality. `= 2026-08-22` on a timestamp matches
702
+ an exact instant and almost never what was meant. */
703
+ for (const phrase of ['today', 'last 7 days', 'last 30 days', 'this month', 'last month', 'this year']) {
704
+ options.push([phrase, `${name} = ${phrase}`]);
705
+ }
706
+ } else if (kind === 'number') {
707
+ const values = rows.map((r) => Number(r[name])).filter((n) => Number.isFinite(n));
708
+ if (values.length) {
709
+ const max = Math.max(...values);
710
+ const min = Math.min(...values);
711
+ const mid = Math.round((min + max) / 2);
712
+ options.push([`greater than ${mid.toLocaleString()}`, `${name} > ${mid}`]);
713
+ options.push([`less than ${mid.toLocaleString()}`, `${name} < ${mid}`]);
714
+ }
715
+ }
716
+
717
+ /* Values present on this page, most frequent first. Only offered when the
718
+ column repeats — a column where every value is distinct is a key, and a
719
+ list of forty unique values is a worse way to pick one than typing it. */
720
+ const counts = new Map();
721
+ for (const row of rows) {
722
+ const value = row[name];
723
+ if (value === null || value === undefined) continue;
724
+ counts.set(value, (counts.get(value) ?? 0) + 1);
725
+ }
726
+ const distinct = [...counts.entries()].sort((a, b) => b[1] - a[1]);
727
+ if (distinct.length && distinct.length <= Math.max(3, rows.length / 2)) {
728
+ for (const [value, count] of distinct.slice(0, 8)) {
729
+ const text = String(value);
730
+ const quoted = /^[\w.-]+$/.test(text) ? text : `"${text.replace(/"/g, '')}"`;
731
+ options.push([`${text} · ${count}`, `${name} = ${quoted}`]);
732
+ }
733
+ }
734
+
735
+ options.push(['is empty', `${name} is empty`], ['is not empty', `${name} is not empty`]);
736
+
737
+ /* Typed conditions, always offered.
738
+
739
+ Sampled values only help on a column that repeats. On a key or a
740
+ high-cardinality reference — invoice_id across forty thousand rows — the
741
+ sample is a list of arbitrary values that happen to be near the top, and
742
+ the thing you actually want is to name the one you are looking for. */
743
+ /* Typed because the checker otherwise reads the tuples as a union and
744
+ decides `build` might be a string. Each entry is a label, the function
745
+ that turns a typed value into a condition, and the placeholder. */
746
+ /* A timestamp gets a time in its picker and a date does not. Read off the
747
+ declared type rather than the values: the answer has to be the same
748
+ before and after the table has any rows in it. */
749
+ const hasTime = /time|timestamp/i.test(meta?.type ?? '');
750
+ const dateInput = hasTime ? 'datetime-local' : 'date';
751
+ /* `2026-01-01T09:30` is what the control hands back and not what any of
752
+ these databases write. The separator is the only difference. */
753
+ const asStamp = (v) => String(v).replace('T', ' ');
754
+
755
+ /** @type {Array<[string, (value: string) => string, string, string]>} */
756
+ const typed = [];
757
+ if (kind === 'number') {
758
+ typed.push(
759
+ ['equals a value…', (v) => `${name} = ${v}`, 'Value', 'text'],
760
+ ['greater than…', (v) => `${name} > ${v}`, 'Minimum', 'text'],
761
+ ['less than…', (v) => `${name} < ${v}`, 'Maximum', 'text'],
762
+ );
763
+ } else if (kind === 'date') {
764
+ typed.push(
765
+ ['on or after…', (v) => `${name} >= ${quoteValue(asStamp(v))}`, 'Date', dateInput],
766
+ ['before…', (v) => `${name} < ${quoteValue(asStamp(v))}`, 'Date', dateInput],
767
+ );
768
+ } else {
769
+ typed.push(
770
+ ['equals a value…', (v) => `${name} = ${quoteValue(v)}`, 'Value', 'text'],
771
+ ['contains…', (v) => `${name} contains ${quoteValue(v)}`, 'Text to look for', 'text'],
772
+ ['starts with…', (v) => `${name} startswith ${quoteValue(v)}`, 'Prefix', 'text'],
773
+ /* The escape hatch from the three above, which all escape `%` so that
774
+ searching for `50%` finds a percent sign. Here the wildcards are the
775
+ question. */
776
+ ['matches a pattern…', (v) => `${name} like ${quoteValue(v)}`, 'Pattern, e.g. Wh% or _a%', 'text'],
777
+ );
778
+ }
779
+
780
+ /* `between` exists in the language and was reachable only by typing it.
781
+ Offered on the two kinds of column a range means anything on, and asked
782
+ as one question with two fields rather than as two prompts — a range with
783
+ one end filled in is a different filter from the one intended. */
784
+ /** @type {Array<[string, string, string]>} */
785
+ const ranges = [];
786
+ if (kind === 'date') ranges.push(['between…', dateInput, 'Date']);
787
+ else if (kind === 'number') ranges.push(['between…', 'text', 'Value']);
788
+
789
+ const table = findTable(currentEntry()?.table ?? '');
790
+ const columns = [...document.querySelectorAll('.table-wrap thead th')].map(
791
+ (th) => th.querySelector('.th-sort')?.firstChild?.textContent ?? '',
792
+ );
793
+ const pinned = pinnedFor(table, columns);
794
+
795
+ /* Sorting, always offered.
796
+
797
+ It was reachable only by clicking the header, and only on columns an index
798
+ leads on — so on an unindexed column there was no way to sort from the
799
+ interface at all, and nothing said why. The menu is where someone looks
800
+ for what they can do to a column, so both directions live here whatever
801
+ the index says, and the cost is stated rather than the action withheld:
802
+ a scan on a large table is a real cost, and it is the reader's to spend. */
803
+ const indexLeads = !table?.indexed || table.indexed.includes(name);
804
+ const bigTable = table?.approxRows != null && table.approxRows >= SORT_SCAN_BELOW;
805
+ const sortItem = (label, direction, add = false) => (entryNow && entryNow.kind === 'table'
806
+ ? el('button', {
807
+ type: 'button', class: 'th-menu-item', role: 'menuitem',
808
+ onclick: () => {
809
+ const rewrite = add ? addSort : nextSort;
810
+ go(tableView(entryNow.table, rewrite(entryNow.query, name, direction)), 'replace');
811
+ menu.remove();
812
+ },
813
+ }, [
814
+ el('span', { class: 'th-menu-label', text: label }),
815
+ el('span', {
816
+ class: 'th-menu-cond',
817
+ text: indexLeads || !bigTable ? `sort ${name}` : 'no index — a full scan',
818
+ }),
819
+ ])
820
+ : null);
821
+
822
+ /* "Then by" appears only once there is something to be *after*. Offering it
823
+ on an unsorted table would be offering a second step before the first. */
824
+ const sortsAlready = entryNow && entryNow.kind === 'table'
825
+ && sortCount(entryNow.query) > 0
826
+ && sortDirection(entryNow.query, name) === null;
827
+
828
+ const menu = el('div', { class: 'th-menu', role: 'menu' }, [
829
+ /* First, because it is the one item here that is about *reading* the table
830
+ rather than narrowing it — and on the wide tables where the menu is most
831
+ used it is the thing most often wanted. */
832
+ table
833
+ ? el('button', {
834
+ type: 'button', class: 'th-menu-item', role: 'menuitem',
835
+ onclick: () => { togglePin(table, columns, name); menu.remove(); render(); },
836
+ }, [
837
+ el('span', { class: 'th-menu-label', text: pinned.includes(name) ? 'Unpin this column' : 'Pin this column' }),
838
+ el('span', { class: 'th-menu-cond', text: pinned.includes(name) ? 'let it scroll away' : 'keep it in view' }),
839
+ ])
840
+ : null,
841
+ sortItem('Sort ascending', 'asc'),
842
+ sortItem('Sort descending', 'desc'),
843
+ sortsAlready ? sortItem('Then by, ascending', 'asc', true) : null,
844
+ sortsAlready ? sortItem('Then by, descending', 'desc', true) : null,
845
+ table ? el('div', { class: 'th-menu-sep', role: 'separator' }) : null,
846
+ ...typed.map(([label, build, placeholder, inputType]) =>
847
+ el('button', {
848
+ type: 'button', class: 'th-menu-item th-menu-typed', role: 'menuitem',
849
+ onclick: async () => {
850
+ menu.remove();
851
+ const value = await promptFor({
852
+ title: `Filter ${name}`,
853
+ label,
854
+ placeholder,
855
+ type: inputType,
856
+ confirmLabel: 'Apply',
857
+ hint: 'Adds a condition to the query, which you can edit afterwards.',
858
+ });
859
+ if (value) add(build(value));
860
+ },
861
+ }, [el('span', { class: 'th-menu-label', text: label })])),
862
+ ...ranges.map(([label, inputType, fieldLabel]) =>
863
+ el('button', {
864
+ type: 'button', class: 'th-menu-item th-menu-typed', role: 'menuitem',
865
+ onclick: async () => {
866
+ menu.remove();
867
+ const range = await promptForRange({
868
+ title: `Filter ${name}`,
869
+ fromLabel: `${fieldLabel} from`,
870
+ toLabel: `${fieldLabel} to`,
871
+ type: inputType,
872
+ hint: 'Both ends are included. Adds a condition you can edit afterwards.',
873
+ });
874
+ if (range) {
875
+ add(`${name} between ${quoteValue(asStamp(range.from))} and ${quoteValue(asStamp(range.to))}`);
876
+ }
877
+ },
878
+ }, [el('span', { class: 'th-menu-label', text: label })])),
879
+ typed.length ? el('div', { class: 'th-menu-sep', role: 'separator' }) : null,
880
+ ...options.map(([label, condition]) =>
881
+ el('button', { type: 'button', class: 'th-menu-item', role: 'menuitem', onclick: () => add(condition) }, [
882
+ el('span', { class: 'th-menu-label', text: label }),
883
+ el('span', { class: 'th-menu-cond', text: condition }),
884
+ ])),
885
+ ].filter(Boolean));
886
+
887
+ document.body.append(menu);
888
+ const box = anchor.getBoundingClientRect();
889
+ // Flipped to the left when it would run off the right edge, which happens
890
+ // constantly on a wide table scrolled to the end.
891
+ const width = menu.offsetWidth;
892
+ menu.style.top = `${box.bottom + 4}px`;
893
+ menu.style.left = `${Math.min(box.left, window.innerWidth - width - 12)}px`;
894
+
895
+ const dismiss = (e) => {
896
+ if (menu.contains(e.target)) return;
897
+ menu.remove();
898
+ document.removeEventListener('pointerdown', dismiss, true);
899
+ document.removeEventListener('keydown', onEsc, true);
900
+ };
901
+ const onEsc = (e) => { if (e.key === 'Escape') dismiss(e); };
902
+ document.addEventListener('pointerdown', dismiss, true);
903
+ document.addEventListener('keydown', onEsc, true);
904
+ }
905
+
906
+ /** Quote a typed value only when the grammar needs it. */
907
+ /* Trims, then hands the value to the one quoter — this used to *delete* an
908
+ embedded quote, so `Harbour "HQ"` silently became `Harbour HQ` and the
909
+ filter matched a different row, or none. */
910
+ function quoteValue(text) {
911
+ return quote(String(text).trim());
912
+ }
913
+
914
+ /**
915
+ * The date test from core.js, applied to the name the *result set* used.
916
+ *
917
+ * A joined column arrives under its path — `customer_id.opened_on` — and the
918
+ * column meta beside it still calls itself `opened_on`, so the name has to be
919
+ * passed in rather than read off the meta.
920
+ *
921
+ * The copy that used to be written out here had lost `timestamp`, `datetime`
922
+ * and `deleted` from the name pattern, so the filter menu offered `=` on an
923
+ * `event_timestamp TEXT` column and hid the relative-date phrases that are
924
+ * the only sensible thing to say about a date.
925
+ */
926
+ const dateish = (meta, name) => Boolean(meta) && looksLikeDateColumn({ type: meta.type, name });
927
+
928
+ /**
929
+ * `append` adds a level to the sort instead of replacing it — shift-click,
930
+ * which is the gesture every grid uses for exactly this.
931
+ */
932
+ function sortBy(column, direction, append = false) {
933
+ const entry = state.stack[state.stack.length - 1];
934
+ if (!entry || entry.kind !== 'table') return;
935
+ /* `nextSort` must keep receiving `undefined` when no direction was named:
936
+ an explicit direction means "write down what is already happening" and
937
+ deliberately skips the ascending → descending cycle. Defaulting it here
938
+ cost the header its second click. `addSort` has no cycle to skip, so it
939
+ takes a direction. */
940
+ go(tableView(entry.table, append
941
+ ? addSort(entry.query, column, direction ?? 'asc')
942
+ : nextSort(entry.query, column, direction)), 'replace');
943
+ }
944
+
945
+ /**
946
+ * Paging, for a table somebody might actually reach the end of.
947
+ *
948
+ * This was one `Next →` at twenty rows a page. On the demo's invoice lines —
949
+ * sixty-three thousand of them — that is three thousand clicks to the last
950
+ * page, which means the table was browsable at the front and nowhere else.
951
+ * Every part below exists because that number is absurd.
952
+ *
953
+ * The size control sits on the left whether or not there is a second page: it
954
+ * is a property of how you are reading the table, not of how much is left.
955
+ * "Last" and the page box appear only when the total is a real count — an
956
+ * estimate cannot say which page is last, and a control that jumps to a page
957
+ * that may not exist is worse than no control.
958
+ */
959
+ function pager(entry, data, shown) {
960
+ const bar = el('div', { class: 'pager' });
961
+ // A query that names its own limit defines the whole result; paging through
962
+ // it would contradict what the user asked for.
963
+ if (explicitLimit(entry.query)) return bar;
964
+
965
+ const size = pageSize();
966
+ /* An estimated total cannot decide whether there is a next page.
967
+
968
+ `total` is a guess above ESTIMATE_ABOVE rows — read from the catalog
969
+ rather than counted — and a guess that comes in low hid `Next` on a table
970
+ that had more, with nothing on screen to say browsing had been cut short.
971
+ A full page returned is the honest signal: it means the database had at
972
+ least that many, whatever the estimate thinks. */
973
+ const full = (data.rows?.length ?? 0) >= size;
974
+ const counted = data.exactTotal !== false && typeof data.total === 'number';
975
+ const more = counted ? data.total > shown : full;
976
+ const lastPage = counted ? Math.max(0, Math.ceil(data.total / size) - 1) : null;
977
+
978
+ const goTo = (page) => {
979
+ const wanted = Math.max(0, lastPage === null ? page : Math.min(page, lastPage));
980
+ if (wanted === state.page) return;
981
+ state.page = wanted;
982
+ render();
983
+ };
984
+
985
+ /* The app's own dropdown, not a native `<select>`. Everywhere else that
986
+ picks between a handful of values — the connection picker most visibly —
987
+ uses this control, and a system popup opening in the middle of a page
988
+ that has its own look reads as something the page did not draw. */
989
+ bar.append(el('div', { class: 'pager-size' }, [
990
+ el('span', { text: 'Rows' }),
991
+ dropdown({
992
+ ariaLabel: 'Rows per page',
993
+ value: size,
994
+ items: PAGE_SIZES.map((n) => ({ value: n, label: String(n) })),
995
+ onChange: (n) => { setPageSize(n); render(); },
996
+ }),
997
+ ]));
998
+
999
+ const step = (text, label, to, enabled) => el('button', {
1000
+ class: 'ghost', type: 'button', text, 'aria-label': label,
1001
+ disabled: enabled ? undefined : true,
1002
+ onclick: () => goTo(to),
1003
+ });
1004
+
1005
+ bar.append(el('div', { class: 'pager-steps' }, [
1006
+ step('« First', 'First page', 0, state.page > 0),
1007
+ step('← Previous', 'Previous page', state.page - 1, state.page > 0),
1008
+ /* The page number is an input, not a label. Reaching page 400 by clicking
1009
+ Next four hundred times is the thing this whole function is fixing. */
1010
+ counted
1011
+ ? el('span', { class: 'page-label' }, [
1012
+ el('span', { text: 'Page' }),
1013
+ el('input', {
1014
+ class: 'page-input',
1015
+ type: 'number',
1016
+ min: '1',
1017
+ max: String(lastPage + 1),
1018
+ value: String(state.page + 1),
1019
+ 'aria-label': 'Page number',
1020
+ onchange: (e) => {
1021
+ const asked = Number(e.target.value);
1022
+ if (!Number.isFinite(asked)) { e.target.value = String(state.page + 1); return; }
1023
+ goTo(Math.round(asked) - 1);
1024
+ },
1025
+ }),
1026
+ el('span', { text: `of ${(lastPage + 1).toLocaleString()}` }),
1027
+ ])
1028
+ : el('span', { class: 'page-label', text: `Page ${state.page + 1}` }),
1029
+ step('Next →', 'Next page', state.page + 1, more),
1030
+ /* Absent on an estimate: the last page of a guess is a guess. */
1031
+ counted ? step('Last »', 'Last page', lastPage, state.page < lastPage) : null,
1032
+ ].filter(Boolean)));
1033
+
1034
+ return bar;
1035
+ }