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,255 @@
1
+ /**
2
+ * A row's children, opened in place.
3
+ *
4
+ * The view composer answers "how many" with an aggregate — count the invoice
5
+ * lines, sum their value — and deliberately refuses to LEFT JOIN a has-many,
6
+ * because that multiplies the base row and makes every other number on it a
7
+ * lie. That refusal is correct and it leaves a question unanswered: *which
8
+ * ones*. Today the only way to see them is to open the record, which loses the
9
+ * list you were reading them in.
10
+ *
11
+ * Expanding a row is the answer that does not multiply it. The children live in
12
+ * their own row underneath, one query per relationship keyed on that row, so
13
+ * the base row remains one row and its numbers remain its own.
14
+ *
15
+ * Fetched when opened rather than with the page. A twenty-row page with four
16
+ * incoming relationships would be eighty queries to fill in something nobody
17
+ * has asked to see; opening one is one query, and the answer is kept so that
18
+ * closing and reopening is free.
19
+ */
20
+ import { api, el, findTable, go, keyOf, quote, refsTo, rowView, tableView, whenRemoved } from './core.js';
21
+ import { renderGrid } from './table.js';
22
+
23
+ /** Rows of a child table shown inline before it needs its own page. */
24
+ const INLINE = 5;
25
+
26
+ /** Answers already fetched, by row and relationship. Cleared with the grid. */
27
+ let cache = new Map();
28
+
29
+ export function resetNested() {
30
+ cache = new Map();
31
+ }
32
+
33
+ /** Whether expanding would show anything at all. */
34
+ export const hasChildren = (table) => Boolean(table) && refsTo(table.id).length > 0;
35
+
36
+ /**
37
+ * The little triangle, and the row it opens.
38
+ *
39
+ * The detail lives in a sibling `<tr>` rather than inside the row's own cell,
40
+ * because a table row cannot contain another table without the columns of the
41
+ * inner one being laid out by the outer one — every child grid would inherit
42
+ * the parent's column widths, which are about entirely different data.
43
+ */
44
+ export function expander(table, row, tr) {
45
+ const key = keyOf(table, row);
46
+ if (!key) return null;
47
+
48
+ const cell = el('td', { class: 'expander-cell' });
49
+ let open = false;
50
+ let detail = null;
51
+
52
+ const button = el('button', {
53
+ type: 'button',
54
+ class: 'expander',
55
+ 'aria-expanded': 'false',
56
+ 'aria-label': 'Show what points at this row',
57
+ title: 'Show what points at this row',
58
+ text: '▸',
59
+ onclick: (e) => {
60
+ /* The row itself navigates. Opening the detail is a different intent and
61
+ must not also walk away from the table you are opening it in. */
62
+ e.stopPropagation();
63
+ open = !open;
64
+ button.setAttribute('aria-expanded', String(open));
65
+ button.textContent = open ? '▾' : '▸';
66
+ tr.classList.toggle('expanded', open);
67
+
68
+ if (!open) {
69
+ detail?.remove();
70
+ detail = null;
71
+ return;
72
+ }
73
+ detail = el('tr', { class: 'detail-row' }, el('td', {
74
+ class: 'detail-cell',
75
+ colspan: tr.cells.length,
76
+ }, el('p', { class: 'loading', text: 'Looking…' })));
77
+ tr.after(detail);
78
+ void fill(table, key, detail.firstChild);
79
+ },
80
+ });
81
+
82
+ cell.append(button);
83
+ return cell;
84
+ }
85
+
86
+ async function fill(table, key, host) {
87
+ const cacheKey = `${table.id}:${JSON.stringify(key)}`;
88
+ let sections = cache.get(cacheKey);
89
+
90
+ if (!sections) {
91
+ sections = await Promise.all(refsTo(table.id).map(async (fk) => {
92
+ const child = findTable(fk.from.table);
93
+ if (!child) return null;
94
+
95
+ /* Every column of the key has to match, which is what makes this correct
96
+ for a composite key and not only for a single-column one. */
97
+ const conditions = fk.to.columns.map((parentColumn, i) => ({
98
+ column: fk.from.columns[i] ?? fk.from.columns[0],
99
+ op: '=',
100
+ value: key[parentColumn],
101
+ }));
102
+ if (conditions.some((c) => c.value === undefined || c.value === null)) return null;
103
+
104
+ const filter = { groups: [conditions] };
105
+ try {
106
+ const [data, total] = await Promise.all([
107
+ api('/api/query', { table: child.id, filter, limit: INLINE, offset: 0 }),
108
+ api('/api/count', { table: child.id, filter }),
109
+ ]);
110
+ return { fk, child, data, total: total.count, exact: total.exact };
111
+ } catch (err) {
112
+ return { fk, child, error: err.message };
113
+ }
114
+ }));
115
+ sections = sections.filter(Boolean).filter((s) => s.error || s.data.rows.length);
116
+ cache.set(cacheKey, sections);
117
+ }
118
+
119
+ if (!host.isConnected) return;
120
+
121
+ if (!sections.length) {
122
+ host.replaceChildren(el('p', { class: 'note', text: 'Nothing points at this row.' }));
123
+ return;
124
+ }
125
+
126
+ host.replaceChildren(...sections.map((section) => {
127
+ if (section.error) {
128
+ return el('div', { class: 'detail-section' }, [
129
+ el('h4', { text: section.child.name }),
130
+ el('p', { class: 'sql-error', text: section.error }),
131
+ ]);
132
+ }
133
+ const { fk, child, data, total, exact } = section;
134
+ const more = total > data.rows.length;
135
+
136
+ return el('div', { class: 'detail-section' }, [
137
+ el('div', { class: 'detail-head' }, [
138
+ el('h4', { text: child.name }),
139
+ el('span', { class: 'detail-via', text: `via ${fk.from.columns.join(', ')}` }),
140
+ el('span', {
141
+ class: 'detail-count',
142
+ text: exact === false ? `~${total.toLocaleString()}` : total.toLocaleString(),
143
+ }),
144
+ more
145
+ ? el('button', {
146
+ type: 'button', class: 'ghost detail-all',
147
+ text: 'See all →',
148
+ onclick: () => {
149
+ const conditions = fk.from.columns
150
+ .map((col, i) => `${col} = ${quote(key[fk.to.columns[i]])}`)
151
+ .join(' and ');
152
+ go(tableView(child.id, `${child.id} ${conditions}`), 'push');
153
+ },
154
+ })
155
+ : null,
156
+ ].filter(Boolean)),
157
+ renderGrid(child, data, {
158
+ onRowClick: (childRow) => {
159
+ const childKey = keyOf(child, childRow);
160
+ if (childKey) go(rowView(child.id, childKey, String(Object.values(childKey).join('/'))));
161
+ },
162
+ }),
163
+ ]);
164
+ }));
165
+ }
166
+
167
+ /* ---------- all of them at once ---------- */
168
+
169
+ /** Rows started per batch, and the pause between batches. */
170
+ const BATCH = 4;
171
+ const BREATH_MS = 60;
172
+
173
+ /**
174
+ * Open or close every row on the page.
175
+ *
176
+ * Expanding one row is a handful of counted queries; expanding twenty at once
177
+ * on a schema where the table has a dozen things pointing at it is two
178
+ * hundred and forty, fired simultaneously at the same connection that is
179
+ * serving the page. So rows are started a few at a time — the top of the
180
+ * table fills in immediately, the rest follows, and the database is never
181
+ * asked for everything at once.
182
+ *
183
+ * Collapsing has no such cost and is done in one pass.
184
+ *
185
+ * It works by clicking the row's own expander rather than reimplementing what
186
+ * that does. Two code paths for opening a row is exactly how the open one
187
+ * ends up fetching something the other does not.
188
+ */
189
+ export function expandAllControl(tbody) {
190
+ let busy = false;
191
+
192
+ const buttons = () => [...tbody.querySelectorAll('button.expander')];
193
+ const anyClosed = () => buttons().some((b) => b.getAttribute('aria-expanded') !== 'true');
194
+
195
+ const button = el('button', {
196
+ type: 'button',
197
+ class: 'expander expand-all',
198
+ title: 'Expand every row',
199
+ 'aria-label': 'Expand every row',
200
+ text: '\u25b8',
201
+ onclick: async (e) => {
202
+ e.stopPropagation();
203
+ if (busy) return;
204
+ const opening = anyClosed();
205
+
206
+ if (!opening) {
207
+ for (const b of buttons()) {
208
+ if (b.getAttribute('aria-expanded') === 'true') b.click();
209
+ }
210
+ sync();
211
+ return;
212
+ }
213
+
214
+ busy = true;
215
+ button.classList.add('working');
216
+ try {
217
+ const closed = buttons().filter((b) => b.getAttribute('aria-expanded') !== 'true');
218
+ for (let i = 0; i < closed.length; i += BATCH) {
219
+ /* Re-checked rather than trusted: a row the reader collapsed while
220
+ this was running should stay collapsed, not be reopened by a
221
+ batch that was queued before they touched it. */
222
+ for (const b of closed.slice(i, i + BATCH)) {
223
+ if (b.isConnected && b.getAttribute('aria-expanded') !== 'true') b.click();
224
+ }
225
+ if (i + BATCH < closed.length) {
226
+ await new Promise((resolve) => setTimeout(resolve, BREATH_MS));
227
+ }
228
+ }
229
+ } finally {
230
+ busy = false;
231
+ button.classList.remove('working');
232
+ sync();
233
+ }
234
+ },
235
+ });
236
+
237
+ function sync() {
238
+ const closed = anyClosed();
239
+ button.textContent = closed ? '\u25b8' : '\u25be';
240
+ button.title = closed ? 'Expand every row' : 'Collapse every row';
241
+ button.setAttribute('aria-label', button.title);
242
+ button.setAttribute('aria-expanded', String(!closed));
243
+ }
244
+
245
+ /* A row expanded or collapsed on its own changes what this control should
246
+ say next, and the rows do not report to it. Watching the body is cheaper
247
+ than threading a callback through every expander. */
248
+ const watch = new MutationObserver(sync);
249
+ watch.observe(tbody, { childList: true });
250
+ // Stopped with the table: one of these was left running per render.
251
+ whenRemoved(tbody, () => watch.disconnect());
252
+
253
+ sync();
254
+ return button;
255
+ }