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,390 @@
1
+ /**
2
+ * The parts of a query, as things you can take off.
3
+ *
4
+ * The query bar is the source of truth, which is a good design and leaves one
5
+ * gap: undoing part of what you built means editing the text by hand. Having
6
+ * clicked a column header to sort and a filter menu to narrow, going back to
7
+ * "the same thing but unsorted" meant finding `sort contract_id` in a line of
8
+ * text and deleting exactly it — the interface could write the language but
9
+ * not un-write it.
10
+ *
11
+ * So the clauses are shown as chips and removing one rewrites the text. The
12
+ * text stays authoritative; this is a second way to edit it, not a second
13
+ * place to keep it.
14
+ *
15
+ * The split is by keyword, which the grammar makes safe: a query is a table,
16
+ * then conditions, then any of `show`, `sort`/`order by` and `limit` in that
17
+ * order. Only the trailing keywords are looked for, so a *value* containing
18
+ * the word "limit" cannot be mistaken for the clause — the boundary is found
19
+ * from the right, and only outside quotes.
20
+ */
21
+
22
+ /** Trailing clause keywords, longest first so `order by` wins over `order`. */
23
+ const TAIL = [
24
+ { key: 'limit', pattern: /\blimit\b/i },
25
+ { key: 'sort', pattern: /\b(?:sort|order\s+by)\b/i },
26
+ { key: 'show', pattern: /\bshow\b/i },
27
+ ];
28
+
29
+ /**
30
+ * Positions of characters that are inside a quoted value.
31
+ *
32
+ * `status = "sort code"` contains the word `sort` and does not contain a sort
33
+ * clause. Scanning for quotes first is what keeps the two apart.
34
+ */
35
+ function quotedSpans(text) {
36
+ const spans = [];
37
+ let quote = null;
38
+ let start = 0;
39
+ for (let i = 0; i < text.length; i += 1) {
40
+ const c = text[i];
41
+ if (quote) {
42
+ if (c === '\\') { i += 1; continue; }
43
+ if (c === quote) { spans.push([start, i]); quote = null; }
44
+ } else if (c === '"' || c === "'") {
45
+ quote = c;
46
+ start = i;
47
+ }
48
+ }
49
+ if (quote) spans.push([start, text.length]);
50
+ return spans;
51
+ }
52
+
53
+ const inside = (spans, at) => spans.some(([a, b]) => at > a && at < b);
54
+
55
+ /**
56
+ * A copy of the query with the contents of quoted values replaced by spaces.
57
+ *
58
+ * Mirrors `maskQuotedValues` in src/shared/query.ts — same trick, same reason.
59
+ * Same length as the input and the quote characters left in place, so an index
60
+ * into this string is an index into the original.
61
+ *
62
+ * Anything that scans a query for keywords needs this. Scanning raw text turns
63
+ * a value into syntax: `name contains "show me"` was cut at the value's own
64
+ * word and the filter came back holding `"sort name show id, name` — a string
65
+ * assembled out of the query's own punctuation.
66
+ */
67
+ export function maskQuoted(text) {
68
+ let out = '';
69
+ let quote = null;
70
+ for (let i = 0; i < text.length; i += 1) {
71
+ const ch = text[i];
72
+ if (quote) {
73
+ if (ch === '\\' && i + 1 < text.length) {
74
+ out += ' ';
75
+ i += 1;
76
+ continue;
77
+ }
78
+ out += ch === quote ? ((quote = null), ch) : ' ';
79
+ continue;
80
+ }
81
+ if (ch === '"' || ch === "'") {
82
+ quote = ch;
83
+ out += ch;
84
+ continue;
85
+ }
86
+ out += ch;
87
+ }
88
+ return out;
89
+ }
90
+
91
+ /**
92
+ * Break a query into its table, filter and trailing clauses.
93
+ *
94
+ * @returns {{table: string, filter: string, show?: string, sort?: string, limit?: string}}
95
+ */
96
+ export function splitQuery(text) {
97
+ const query = String(text ?? '').trim();
98
+ const spans = quotedSpans(query);
99
+
100
+ const firstSpace = query.search(/\s/);
101
+ const table = firstSpace < 0 ? query : query.slice(0, firstSpace);
102
+ let rest = firstSpace < 0 ? '' : query.slice(firstSpace + 1);
103
+ let offset = firstSpace + 1;
104
+
105
+ const parts = { table, filter: '' };
106
+
107
+ /* Boundaries are found first, then sorted by *position* — not peeled off in
108
+ grammar order.
109
+
110
+ Peeling assumed the clauses arrive in the order the grammar lists them. The
111
+ parser does not require that, and `customer limit 5 sort name` is a query
112
+ someone will type. Peeling `limit` first took everything from `limit` to
113
+ the end, so the sort clause was swallowed into it: the chip read "5 sort
114
+ name" and removing the limit removed the sort with it.
115
+
116
+ The last occurrence of each keyword wins, because a clause word can appear
117
+ earlier as an unquoted value; quoted ones are excluded outright. */
118
+ const marks = [];
119
+ for (const { key, pattern } of TAIL) {
120
+ let at = -1;
121
+ let from = 0;
122
+ for (;;) {
123
+ const found = rest.slice(from).search(pattern);
124
+ if (found < 0) break;
125
+ const absolute = from + found;
126
+ if (!inside(spans, offset + absolute)) at = absolute;
127
+ from = absolute + 1;
128
+ }
129
+ if (at >= 0) marks.push({ key, at });
130
+ }
131
+ marks.sort((a, b) => a.at - b.at);
132
+
133
+ marks.forEach(({ key, at }, i) => {
134
+ const until = marks[i + 1]?.at ?? rest.length;
135
+ parts[key] = rest.slice(at, until).trim();
136
+ });
137
+ if (marks.length) rest = rest.slice(0, marks[0].at);
138
+
139
+ parts.filter = rest.trim();
140
+ return parts;
141
+ }
142
+
143
+ /**
144
+ * The row count the query itself asks for, if any. A number, or null.
145
+ *
146
+ * Read rather than parsed properly, because the client has no parser — the
147
+ * language lives on the server. Reading it *here*, off the same split as the
148
+ * chips, is what makes it right for a limit that is not the last thing typed:
149
+ * the table view matched `limit N` anchored at the end of the string, which
150
+ * found nothing at all in `customer limit 40000 sort name`, so the page size
151
+ * went out instead and forty thousand rows came back as twenty — the same lie
152
+ * that anchoring was supposed to have fixed, in the clause order nobody tried.
153
+ * It lived beside its caller in the browser, where nothing could test it.
154
+ *
155
+ * The split also settles what a *value* containing the word means, which no
156
+ * regex here could: `name = "limit 5"` is a name.
157
+ *
158
+ * `limit = 5`, on a table with a column called `limit`, is a filter — no
159
+ * number follows the keyword, so this hands back nothing and the caller falls
160
+ * back to the page size, which is what being wrong has always cost.
161
+ */
162
+ export function explicitLimit(query) {
163
+ const match = splitQuery(query).limit?.match(/^limit\s+(\d+)\b/i);
164
+ if (!match) return null;
165
+ const n = Number(match[1]);
166
+ return Number.isFinite(n) && n > 0 ? n : null;
167
+ }
168
+
169
+ /** Rebuild a query from its parts, dropping the empty ones. */
170
+ export function joinQuery(parts) {
171
+ return [parts.table, parts.filter, parts.show, parts.sort, parts.limit]
172
+ .filter((p) => p && String(p).trim())
173
+ .join(' ')
174
+ .trim();
175
+ }
176
+
177
+ /**
178
+ * The query with one clause removed.
179
+ *
180
+ * @param {string} text
181
+ * @param {'filter'|'show'|'sort'|'limit'} clause
182
+ */
183
+ export function withoutClause(text, clause) {
184
+ const parts = splitQuery(text);
185
+ delete parts[clause];
186
+ if (clause === 'filter') parts.filter = '';
187
+ return joinQuery(parts);
188
+ }
189
+
190
+ /** Clauses present in a query, as {key, label, text} — for rendering chips. */
191
+ export function activeClauses(text) {
192
+ const parts = splitQuery(text);
193
+ const out = [];
194
+ /* One chip per condition rather than one for the whole filter. A query built
195
+ from three narrowings should be undoable one narrowing at a time — the
196
+ interface wrote them separately, so it should be able to take them off
197
+ separately. */
198
+ splitConditions(parts.filter).forEach((condition, i) => {
199
+ out.push({ key: 'filter', index: i, label: i === 0 ? 'where' : 'and', text: condition });
200
+ });
201
+ if (parts.show) out.push({ key: 'show', label: 'showing', text: parts.show.replace(/^show\s+/i, '') });
202
+ if (parts.sort) out.push({ key: 'sort', label: 'sorted by', text: parts.sort.replace(/^(?:sort|order\s+by)\s+/i, '') });
203
+ if (parts.limit) out.push({ key: 'limit', label: 'limit', text: parts.limit.replace(/^limit\s+/i, '') });
204
+ return out;
205
+ }
206
+
207
+ /**
208
+ * Split a filter into the conditions joined by top-level `and`.
209
+ *
210
+ * Top-level is the important word. `active = true and (priority = 1 or
211
+ * priority = 2)` is two conditions, not three: the bracketed group is one
212
+ * thing you either want or do not, and splitting inside it would produce
213
+ * chips that cannot be removed independently without changing what the query
214
+ * means. Depth and quoting are both tracked, so `name = "a and b"` is one
215
+ * condition whose value happens to contain the word.
216
+ *
217
+ * `or` is never split on. A filter that is `a or b` comes back as a single
218
+ * condition, which is right — removing half of an `or` is not a smaller
219
+ * filter, it is a different one.
220
+ */
221
+ export function splitConditions(filter) {
222
+ const text = String(filter ?? '').trim();
223
+ if (!text) return [];
224
+
225
+ const parts = [];
226
+ let depth = 0;
227
+ let quote = null;
228
+ let start = 0;
229
+ /* Set when a `between` is open and its `and` has not been seen yet.
230
+
231
+ `between 2026-08-17 and 2026-08-27` contains an `and` that joins a range
232
+ rather than two conditions. Without this the filter bar drew it as two
233
+ chips — `WHERE hired_on between 2026-08-17` and `AND 2026-08-27` — and
234
+ dismissing either produced `hired_on between 2026-08-17`, which is not a
235
+ condition and came back as an error. One range, cut in half by the thing
236
+ that was only supposed to be listing it.
237
+
238
+ `splitTopLevel` in shared/query.ts has tracked this since `between`
239
+ shipped. This copy did not, which is the whole hazard of a mirror: each
240
+ side looks right on its own. */
241
+ let pendingBetween = false;
242
+
243
+ const wordAt = (word, i) => new RegExp(`^${word}\\b`, 'i').test(text.slice(i))
244
+ && (i === 0 || /[\s)]/.test(text[i - 1]));
245
+
246
+ for (let i = 0; i < text.length; i += 1) {
247
+ const c = text[i];
248
+ if (quote) {
249
+ if (c === '\\') { i += 1; continue; }
250
+ if (c === quote) quote = null;
251
+ continue;
252
+ }
253
+ if (c === '"' || c === "'") { quote = c; continue; }
254
+ if (c === '(') { depth += 1; continue; }
255
+ if (c === ')') { depth = Math.max(0, depth - 1); continue; }
256
+ if (depth > 0) continue;
257
+
258
+ if ((c === 'b' || c === 'B') && wordAt('between', i)) {
259
+ pendingBetween = true;
260
+ continue;
261
+ }
262
+
263
+ // A word boundary on both sides, so a column called `and_then` survives.
264
+ if ((c === 'a' || c === 'A') && wordAt('and', i)) {
265
+ // The first top-level `and` after a `between` belongs to it.
266
+ if (pendingBetween) {
267
+ pendingBetween = false;
268
+ i += 2;
269
+ continue;
270
+ }
271
+ parts.push(text.slice(start, i).trim());
272
+ i += 3;
273
+ start = i;
274
+ }
275
+ }
276
+ parts.push(text.slice(start).trim());
277
+
278
+ /* A split that yields one piece is not a split.
279
+
280
+ `last_name contains and` — where the value *is* the keyword — splits at
281
+ that `and` into `last_name contains` and nothing. Dropping the empty half
282
+ and keeping the first left a chip with its value chopped off, which is a
283
+ misdescription of the filter and a dismiss button wired to the wrong
284
+ text.
285
+
286
+ `splitTopLevel` in shared/query.ts survives this by accident of shape: it
287
+ drops empty parts, finds fewer than two left, and falls through to
288
+ treating the whole term as one condition — which is why the server reads
289
+ `LIKE '%and%'` where the browser read a truncated condition. Same rule
290
+ here, deliberately: if the keyword did not actually stand between two
291
+ conditions, it belonged to the one. */
292
+ const kept = parts.filter(Boolean);
293
+ if (kept.length > 1) return kept;
294
+ return text ? [text] : [];
295
+ }
296
+
297
+ /** The query with one condition of its filter removed, by index. */
298
+ export function withoutCondition(text, index) {
299
+ const parts = splitQuery(text);
300
+ const conditions = splitConditions(parts.filter);
301
+ conditions.splice(index, 1);
302
+ parts.filter = conditions.join(' and ');
303
+ return joinQuery(parts);
304
+ }
305
+
306
+ /**
307
+ * The sort clause's fields, split on commas, keyword already removed.
308
+ *
309
+ * `sort a, b desc` is two fields, not one field called "a, b desc". Splitting
310
+ * here rather than at each caller is what keeps the multi-field case from
311
+ * being everyone's separate problem.
312
+ */
313
+ function sortFields(query) {
314
+ const body = splitQuery(query).sort?.replace(/^(?:sort|order\s+by)\s+/i, '').trim();
315
+ if (!body) return [];
316
+ return body.split(',').map((part) => {
317
+ const [column, ...rest] = part.trim().split(/\s+/);
318
+ return { column, direction: /^desc$/i.test(rest[0] ?? '') ? 'desc' : 'asc' };
319
+ });
320
+ }
321
+
322
+ /**
323
+ * Which way a query sorts by a column, or null if it does not.
324
+ *
325
+ * Answered by splitting rather than by matching, because the caller's column
326
+ * name goes into the comparison and a reference hop contains characters a
327
+ * regex reads as instructions: `invoice_id+line_no.sku` interpolated into a
328
+ * pattern means "one or more `d`, then any character" — a question about a
329
+ * different column entirely.
330
+ */
331
+ export function sortDirection(query, column) {
332
+ return sortFields(query).find((f) => f.column === column)?.direction ?? null;
333
+ }
334
+
335
+ /**
336
+ * The query you get by clicking a column header.
337
+ *
338
+ * Cycles ascending → descending → ascending on the column already sorted by,
339
+ * and replaces the clause otherwise. An explicit `direction` skips the cycle:
340
+ * it means "write down what is already happening", which is what adopting the
341
+ * default order does, and toggling it would flip the very thing being adopted.
342
+ *
343
+ * "Already sorted by this" means this column and nothing else. A click on one
344
+ * header of a multi-field sort replaces the whole clause rather than toggling
345
+ * one field of it — reversing one column of `sort a, b` is not something a
346
+ * single click can express, and guessing would be worse than starting fresh.
347
+ */
348
+ /**
349
+ * Add a column to the sort rather than replacing it.
350
+ *
351
+ * `sort a, b desc` is expressible in the language and was reachable only by
352
+ * typing it: every header click replaced the whole clause, so a two-column
353
+ * sort could not be built by clicking. "Sort by title, then by hire date" is
354
+ * an ordinary thing to want of a table.
355
+ *
356
+ * A column already in the clause has its direction changed **in place** rather
357
+ * than being moved to the end. Sort order is precedence, and silently
358
+ * re-prioritising the column somebody was only reversing would change the
359
+ * answer in a way nothing on screen explains.
360
+ */
361
+ export function addSort(query, column, direction) {
362
+ const parts = splitQuery(query);
363
+ const fields = sortFields(query);
364
+ const desc = direction === 'desc';
365
+
366
+ const at = fields.findIndex((f) => f.column === column);
367
+ if (at === -1) fields.push({ column, direction: desc ? 'desc' : 'asc' });
368
+ else fields[at] = { column, direction: desc ? 'desc' : 'asc' };
369
+
370
+ parts.sort = `sort ${fields.map((f) => `${f.column}${f.direction === 'desc' ? ' desc' : ''}`).join(', ')}`;
371
+ return joinQuery(parts);
372
+ }
373
+
374
+ /** How many columns the query sorts by — the menu asks, to word itself. */
375
+ export function sortCount(query) {
376
+ return sortFields(query).length;
377
+ }
378
+
379
+ export function nextSort(query, column, direction) {
380
+ const parts = splitQuery(query);
381
+ const fields = sortFields(query);
382
+ const onlyThis = fields.length === 1 && fields[0].column === column;
383
+
384
+ const desc = direction
385
+ ? direction === 'desc'
386
+ : onlyThis && fields[0].direction === 'asc';
387
+
388
+ parts.sort = `sort ${column}${desc ? ' desc' : ''}`;
389
+ return joinQuery(parts);
390
+ }
@@ -0,0 +1,98 @@
1
+ /**
2
+ * How a column should be drawn.
3
+ *
4
+ * Decided per column, once per result set, from the declared type *and* the
5
+ * values actually on the page. Type alone is not enough: SQLite has no
6
+ * boolean, so a flag column arrives as INTEGER and would otherwise render as
7
+ * a column of 1s and 0s. Values alone are not enough either — a page where
8
+ * every quantity happens to be 0 or 1 is not a column of checkboxes.
9
+ *
10
+ * The rule throughout: formatting may never hide the stored value. Anything
11
+ * reformatted carries the raw value in its title, because in a database tool
12
+ * the difference between `5000` and `5,000` is sometimes the whole question.
13
+ */
14
+
15
+ import { columnKind } from './core.js';
16
+
17
+ /** Columns whose name says "flag" loudly enough to trust 0/1 as a boolean. */
18
+ const FLAG_NAME = /^(is|has|can|should|was)_|_(flag|enabled|disabled)$|^(active|enabled|disabled|deleted|archived|verified|published|visible|locked|hidden|default|primary|required|readonly)$/i;
19
+
20
+ const ISO_DATE = /^\d{4}-\d{2}-\d{2}([ T]\d{2}:\d{2}(:\d{2})?)?/;
21
+
22
+ /**
23
+ * Below this many rows, sorting any column is cheap enough to offer freely.
24
+ *
25
+ * `ESTIMATE_ABOVE` from adapters/adapter.ts, which is where this codebase
26
+ * already draws the line between "counting is free" and "counting is a scan
27
+ * worth avoiding". A sort is the same kind of work as a count, so it belongs
28
+ * on the same side of the same line rather than getting a second threshold to
29
+ * disagree with the first.
30
+ */
31
+ export const SORT_SCAN_BELOW = 50_000;
32
+
33
+ /**
34
+ * May the header offer to sort this column?
35
+ *
36
+ * A header click is a one-click action with no visible cost, and on an
37
+ * unindexed column of a large table it is a full scan and a sort of
38
+ * everything to show twenty rows. So the offer is made when an index leads on
39
+ * the column — or when the table is small enough that the scan costs nothing
40
+ * anybody would notice.
41
+ *
42
+ * That second clause is the whole point. The rule used to be the first alone,
43
+ * which is right on a table of millions and far too careful on one of a
44
+ * hundred and seventy: `employee.last_name` had no index, so there was no way
45
+ * to sort by it from the interface at all.
46
+ *
47
+ * `approxRows` unknown is not treated as small. A table nobody could count is
48
+ * not one to assume is cheap.
49
+ *
50
+ * Pure, and exported, so the rule can be tested directly rather than through a
51
+ * browser — no fixture in this repo has fifty thousand rows in it, so the
52
+ * large-table half is otherwise a branch nothing ever exercises.
53
+ */
54
+ export function sortableFromHeader(table) {
55
+ if (!table) return false;
56
+ return table.approxRows != null && table.approxRows < SORT_SCAN_BELOW;
57
+ }
58
+
59
+ export function planColumn(meta, name, rows) {
60
+ const values = rows.map((r) => r[name]).filter((v) => v !== null && v !== undefined);
61
+ const declared = columnKind(meta?.type);
62
+ const plan = { align: 'left', kind: declared, mono: false };
63
+
64
+ const allNumeric = values.length > 0 && values.every((v) => typeof v === 'number');
65
+
66
+ // Boolean: declared as one, or an integer flag column whose name says so.
67
+ if (declared === 'boolean' || (allNumeric && FLAG_NAME.test(name) && values.every((v) => v === 0 || v === 1))) {
68
+ return { ...plan, kind: 'boolean', align: 'center' };
69
+ }
70
+
71
+ if (declared === 'number' || allNumeric) {
72
+ /* Keys and references are identifiers that happen to be numeric. Grouping
73
+ them turns invoice 1001 into "1,001", which reads as a quantity and is
74
+ not what the value means. */
75
+ const identifier = Boolean(meta?.primaryKey || meta?.references);
76
+ const decimals = identifier ? 0 : Math.max(0, ...values.map(countDecimals));
77
+ const group = !identifier && values.some((v) => Math.abs(Number(v)) >= 10000);
78
+ return { ...plan, kind: 'number', align: 'right', decimals, group, identifier };
79
+ }
80
+
81
+ if (declared === 'date' || (values.length && values.every((v) => typeof v === 'string' && ISO_DATE.test(v)))) {
82
+ // A column of timestamps all at midnight is a date column wearing a
83
+ // timestamp's clothes; showing ` 00:00:00` on every row is pure noise.
84
+ const anyTime = values.some((v) => /[ T]\d{2}:\d{2}/.test(String(v)) && !/[ T]00:00:00(\.0+)?$/.test(String(v)));
85
+ return { ...plan, kind: 'date', mono: true, showTime: anyTime };
86
+ }
87
+
88
+ /* Short, uniform text — codes, statuses, currencies — reads better in a
89
+ narrow monospace column than stretched across a wide proportional one. */
90
+ const longest = Math.max(0, ...values.map((v) => String(v).length));
91
+ return { ...plan, kind: 'text', mono: longest <= 12 && values.length > 0 };
92
+ }
93
+
94
+ export function countDecimals(value) {
95
+ const text = String(value);
96
+ const dot = text.indexOf('.');
97
+ return dot === -1 ? 0 : Math.min(6, text.length - dot - 1);
98
+ }