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,317 @@
1
+ /**
2
+ * Counting things by something, on screen.
3
+ *
4
+ * The language grew `by` — `invoice count by status`, `sum total by month
5
+ * invoice_date` — and the query bar had nowhere to send it. Typing one got
6
+ * `"billed" is not a column on invoice`, an error about a column nobody
7
+ * claimed was one, with no hint that the thing being asked for exists. The
8
+ * agent tools could answer the sentence and the person looking at the same
9
+ * database could not.
10
+ *
11
+ * A breakdown is not a table view with a GROUP BY on it, and this module is
12
+ * separate from table.js for the same reason `BreakdownDef` is a sibling of
13
+ * `ViewDef` rather than a field on it: **the rows are not records.** Nothing
14
+ * that makes the grid a walking surface applies here — a group cannot be
15
+ * opened, has no primary key, has nothing pointing at it, and cannot be
16
+ * expanded, peeked at, pinned or edited. Building this on `resultTable` would
17
+ * have meant switching every one of those off and hoping none came back.
18
+ *
19
+ * What it does keep is the two things that are true of every result here: the
20
+ * keyboard cursor (`initGrid`, with no `onOpen` — the cells are readable and
21
+ * copyable, they just do not go anywhere), and sorting expressed by editing
22
+ * the query text rather than held as UI state. The second falls out of the
23
+ * grammar: `sort` on a breakdown names an output column, so a header click is
24
+ * the same text edit it is everywhere else in this tool.
25
+ *
26
+ * The parsing all happens on the server, as it does for the query bar and the
27
+ * composer's filter box. The one rule that has to exist twice is *whether a
28
+ * line is a breakdown at all*, because that decision is made before any
29
+ * request goes out — see `looksLikeBreakdown`, and the parity test in
30
+ * mirrors.test.ts that runs both copies over the same lines.
31
+ */
32
+ import {
33
+ $, pageSize, api, disclosure, el, findTable, go, render, setExplain, showErrors, state, toast,
34
+ } from './core.js';
35
+ import { EXPORT_MAX_ROWS, download, exportFilename, toCsv, toJson } from './export.js';
36
+ import { explicitLimit, nextSort, sortDirection } from './clauses.js';
37
+ import { planColumn } from './columns.js';
38
+ import { valueNode } from './value.js';
39
+ import { initGrid } from './grid.js';
40
+ import { recordQuery } from './history.js';
41
+ import { gridSkeleton, placeholder } from './skeleton.js';
42
+
43
+ /**
44
+ * Is this line asking for a breakdown at all?
45
+ *
46
+ * The mirror of `looksLikeBreakdown` in shared/breakdowntext.ts, and one of
47
+ * the handful of rules this client has a second copy of — the browser cannot
48
+ * import the TypeScript the server runs on. It is here rather than on the
49
+ * server because it decides *which endpoint to call*, which is a decision that
50
+ * has to be made before there is anything to send.
51
+ *
52
+ * The `by` keyword standing alone and not the tail of `order by`. That is the
53
+ * whole tell: no other clause in the language uses it, which is why it was
54
+ * available to mean this.
55
+ */
56
+ export function looksLikeBreakdown(text) {
57
+ return /(?<!\border\s)\bby\s+\S/i.test(String(text ?? ''));
58
+ }
59
+
60
+ /* Same shape as table.js's, and for the same reason: skim two breakdowns and
61
+ the slower reply must not repaint over the newer one, or rewrite the SQL
62
+ pane and the error banner belonging to a question nobody is asking. */
63
+ let runToken = 0;
64
+
65
+ export async function renderBreakdown(entry, content) {
66
+ $('query').value = entry.query;
67
+ const body = el('div');
68
+ content.append(body);
69
+
70
+ const mine = ++runToken;
71
+ const asked = explicitLimit(entry.query);
72
+ /* Shaped like the grid about to replace it. Two columns is the usual size of
73
+ a breakdown — one key and one measure — and a better guess than the base
74
+ table's column count, which is the shape of the thing being counted rather
75
+ than the shape of the answer. */
76
+ const done = placeholder(body, () => gridSkeleton(2, Math.min(asked ?? pageSize, pageSize())));
77
+
78
+ let data;
79
+ try {
80
+ data = await api('/api/breakdown', {
81
+ text: entry.query,
82
+ limit: asked ?? pageSize(),
83
+ offset: asked ? 0 : state.page * pageSize(),
84
+ });
85
+ } catch (err) {
86
+ if (mine !== runToken) return;
87
+ done();
88
+ body.replaceChildren(el('p', { class: 'note', text: err.message }));
89
+ return;
90
+ }
91
+ /* Everything below is global — the error banner, the SQL pane, the history —
92
+ so a stale reply stops here, before any of it. */
93
+ if (mine !== runToken) return;
94
+ done();
95
+
96
+ showErrors(data.errors ?? []);
97
+ setExplain(data.explain);
98
+
99
+ if (!data.columns) {
100
+ /* A half-typed breakdown is the normal state of an input box, so the
101
+ messages are the answer rather than a failure. They are already in the
102
+ banner; this is the space where the grid would have been. */
103
+ body.replaceChildren(el('p', { class: 'note', text: 'Fix the line above to see groups.' }));
104
+ return;
105
+ }
106
+
107
+ recordQuery(entry.query);
108
+
109
+ const shown = (asked ? 0 : state.page * pageSize()) + data.rows.length;
110
+ body.replaceChildren(
111
+ head(entry, data, shown),
112
+ grid(entry, data),
113
+ pager(entry, data, shown),
114
+ );
115
+ }
116
+
117
+ /**
118
+ * What the answer is, in numbers.
119
+ *
120
+ * "Groups" rather than "rows", because that is what they are and because the
121
+ * difference is the whole point: 12,006 invoices came back as 4 groups, and
122
+ * calling those 4 rows invites the reader to think the table is nearly empty.
123
+ */
124
+ function head(entry, data, shown) {
125
+ const base = data.breakdown?.base ?? entry.table;
126
+ const total = data.total;
127
+ return el('div', { class: 'result-head' }, [
128
+ el('h2', { text: findTable(base)?.name ?? base }),
129
+ el('span', {
130
+ class: 'stat',
131
+ text: total === undefined
132
+ ? `${shown.toLocaleString()} group${shown === 1 ? '' : 's'}`
133
+ : total > shown
134
+ ? `${shown.toLocaleString()} of ${total.toLocaleString()} groups`
135
+ : `${total.toLocaleString()} group${total === 1 ? '' : 's'}`,
136
+ }),
137
+ el('span', { class: 'stat', text: `${data.ms} ms` }),
138
+ exportGroups(entry, data),
139
+ ]);
140
+ }
141
+
142
+ /**
143
+ * Export, for groups.
144
+ *
145
+ * Not `exportControl`. That one is built around a table: it remembers a
146
+ * column selection per table, re-runs through `/api/run` with a `show`
147
+ * clause, and names the file after the table. None of it transfers. A
148
+ * breakdown has no `show` — every column is a key or a measure, and dropping
149
+ * one does not narrow the answer, it makes it a different and wrong one — so
150
+ * there is no column chooser here, deliberately.
151
+ *
152
+ * What it keeps is the part worth sharing: the two scopes, each with its
153
+ * count spelled out, because a file named after a table gives no hint that
154
+ * the other forty groups were left behind.
155
+ */
156
+ function exportGroups(entry, data) {
157
+ const rows = data.rows ?? [];
158
+ const columns = data.columns ?? [];
159
+ const total = data.total ?? rows.length;
160
+ const capped = Math.min(total, EXPORT_MAX_ROWS);
161
+ const base = data.breakdown?.base ?? entry.table;
162
+
163
+ const menu = el('div', { class: 'export-menu' });
164
+ const wrap = disclosure(el('details', { class: 'export' }, [
165
+ el('summary', { class: 'export-summary', title: 'Download these groups' }, 'Export'),
166
+ menu,
167
+ ]));
168
+
169
+ const write = (kind, all, groups) => {
170
+ const name = `${base}-by-${(data.breakdown?.by ?? []).map((k) => k.alias || k.path).join('-') || 'group'}`;
171
+ if (kind === 'csv') download(exportFilename(name, 'csv'), toCsv(columns, groups), 'text/csv');
172
+ else download(exportFilename(name, 'json'), toJson(columns, groups), 'application/json');
173
+ toast(`Exported ${groups.length.toLocaleString()} group${groups.length === 1 ? '' : 's'}.`);
174
+ };
175
+
176
+ const start = async (button, kind, all) => {
177
+ if (!all) {
178
+ wrap.open = false;
179
+ write(kind, all, rows);
180
+ return;
181
+ }
182
+ /* One bounded re-run rather than pages stitched together — the same
183
+ argument the table's export makes, and it applies harder here: a group
184
+ is an aggregate over rows that may have changed between two requests. */
185
+ const label = button.textContent;
186
+ button.disabled = true;
187
+ button.textContent = 'Fetching…';
188
+ try {
189
+ const fresh = await api('/api/breakdown', { text: entry.query, limit: capped, offset: 0 });
190
+ if (!fresh.columns) throw new Error(fresh.errors?.[0]?.message ?? 'That line no longer groups.');
191
+ wrap.open = false;
192
+ write(kind, all, fresh.rows ?? []);
193
+ } catch (err) {
194
+ toast(err.message, 'error');
195
+ } finally {
196
+ button.disabled = false;
197
+ button.textContent = label;
198
+ }
199
+ };
200
+
201
+ const pageLabel = `this page (${rows.length.toLocaleString()} group${rows.length === 1 ? '' : 's'})`;
202
+ const allLabel = `all groups (${capped.toLocaleString()})`;
203
+ for (const [kind, heading] of [['csv', 'CSV'], ['json', 'JSON']]) {
204
+ const group = el('div', { class: 'export-group' }, el('div', { class: 'export-heading', text: heading }));
205
+ for (const [label, all] of [[pageLabel, false], [allLabel, true]]) {
206
+ /* The second scope is offered only when it differs: "all groups (20)"
207
+ beside "this page (20 groups)" is two buttons for one file. */
208
+ if (all && capped <= rows.length) continue;
209
+ const button = el('button', { type: 'button', class: 'export-item', text: label });
210
+ button.addEventListener('click', () => void start(button, kind, all));
211
+ group.append(button);
212
+ }
213
+ menu.append(group);
214
+ }
215
+ return wrap;
216
+ }
217
+
218
+ /**
219
+ * The grid.
220
+ *
221
+ * Deliberately plain: a header that sorts, cells that render through the same
222
+ * `valueNode` every other surface uses, and the keyboard cursor. No row click,
223
+ * because there is no row to open — see the note at the top of this file.
224
+ */
225
+ function grid(entry, data) {
226
+ const wrap = el('div', { class: 'table-wrap' });
227
+ if (!data.rows.length) {
228
+ /* The headers would say which columns had nothing in them, but a breakdown
229
+ with no groups means the filter matched no rows at all — there is no
230
+ shape to show, only that fact. */
231
+ wrap.append(el('p', { class: 'note', text: 'Nothing to group — no rows matched.' }));
232
+ return wrap;
233
+ }
234
+
235
+ /* `resolved` names each column and says whether it is a key or a measure,
236
+ which is the only classification a breakdown has. Its `type` is the
237
+ database's own, so a summed REAL right-aligns and a text key does not. */
238
+ const byName = new Map((data.resolved ?? []).map((c) => [c.name, c]));
239
+ const plans = new Map(
240
+ data.columns.map((name) => [name, planColumn(byName.get(name), name, data.rows)]),
241
+ );
242
+
243
+ const headRow = el('tr');
244
+ for (const name of data.columns) {
245
+ const meta = byName.get(name);
246
+ const plan = plans.get(name);
247
+ const direction = sortDirection(entry.query, name);
248
+ headRow.append(el('th', {
249
+ class: `align-${plan.align}${direction ? ' sorted' : ''}`,
250
+ /* Which half of the answer this column is. A reader who did not write
251
+ the line cannot otherwise tell `count` the measure from a column
252
+ somebody happened to group by and call `count`. */
253
+ title: meta?.kind === 'measure'
254
+ ? `${meta.fn}${meta.path ? `(${meta.path})` : ''} — a measure`
255
+ : meta?.path
256
+ ? `${meta.path} — grouped by${meta.type ? ` · ${meta.type}` : ''}`
257
+ : name,
258
+ }, el('button', {
259
+ type: 'button',
260
+ class: 'th-sort',
261
+ /* Sorting a breakdown names an output column, which is exactly what this
262
+ header is. So the click is the same text edit it is on a table — the
263
+ sort stays visible in the language and survives being walked back to. */
264
+ title: `Sort by ${name}`,
265
+ onclick: () => go({ ...entry, query: nextSort(entry.query, name) }, 'replace'),
266
+ text: `${name}${direction === 'desc' ? ' ↓' : direction === 'asc' ? ' ↑' : ''}`,
267
+ })));
268
+ }
269
+
270
+ const tbody = el('tbody');
271
+ data.rows.forEach((row, i) => {
272
+ const tr = el('tr', { 'data-row': String(i) });
273
+ for (const name of data.columns) {
274
+ const value = row[name];
275
+ const plan = plans.get(name);
276
+ tr.append(el('td', { class: `align-${plan.align}` },
277
+ value === null || value === undefined
278
+ ? el('span', { class: 'null-value', text: '—' })
279
+ : valueNode({ ...(byName.get(name) ?? {}), name }, value)));
280
+ }
281
+ tbody.append(tr);
282
+ });
283
+
284
+ wrap.append(el('table', {}, [el('thead', {}, headRow), tbody]));
285
+ /* The keyboard cursor, with nowhere to open: `onOpen` is deliberately absent
286
+ rather than a no-op, so Enter does nothing instead of appearing to. */
287
+ initGrid(wrap, {
288
+ tbody,
289
+ rowCount: data.rows.length,
290
+ columnCount: data.columns.length,
291
+ totalRows: data.total,
292
+ firstRow: explicitLimit(entry.query) ? 0 : state.page * pageSize(),
293
+ });
294
+ return wrap;
295
+ }
296
+
297
+ /** The same paging the table view has, over groups instead of rows. */
298
+ function pager(entry, data, shown) {
299
+ const bar = el('div', { class: 'pager' });
300
+ // A line that names its own limit defines the whole answer — `sort billed
301
+ // desc limit 12` is a top-twelve, and a second page of it is a contradiction.
302
+ if (explicitLimit(entry.query)) return bar;
303
+ if (state.page > 0) {
304
+ bar.append(el('button', {
305
+ class: 'ghost', type: 'button', text: '← Previous',
306
+ onclick: () => { state.page--; render(); },
307
+ }));
308
+ }
309
+ if (data.total !== undefined ? data.total > shown : data.rows.length >= pageSize()) {
310
+ bar.append(el('button', {
311
+ class: 'ghost', type: 'button', text: 'Next →',
312
+ onclick: () => { state.page++; render(); },
313
+ }));
314
+ }
315
+ if (bar.childElementCount) bar.append(el('span', { class: 'page-label', text: `Page ${state.page + 1}` }));
316
+ return bar;
317
+ }