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,932 @@
1
+ /**
2
+ * Pages: a record layout with a front door.
3
+ *
4
+ * A record template lays out a row you have already found. A page starts one
5
+ * step earlier — it asks *which* customer, and then shows everything that
6
+ * customer is: their own fields, how many orders they have placed, what those
7
+ * orders were, and the lines inside those orders. One screen assembled from
8
+ * one root, instead of a walk you have to remember the way back through.
9
+ *
10
+ * The content model is deliberately the record template's, extended rather
11
+ * than replaced. Two systems that both lay out sections keyed by foreign
12
+ * key would drift within a month, and the second one would be the one that
13
+ * forgot composite keys. What pages add is:
14
+ *
15
+ * - **metric** sections — one number, from an aggregate over a relationship
16
+ * - **lists that reach further than one hop**, via a path rather than a
17
+ * single constraint, so "the lines of this customer's orders" is a
18
+ * section rather than two clicks
19
+ * - **a filter on a list**, so "recent open orders" is a section and not
20
+ * the whole of them
21
+ * - **a width**, so three counts sit in a row instead of stacked
22
+ *
23
+ * Everything here is pure: parsing, validating, and turning a page plus a
24
+ * root key into the requests its sections need. Nothing fetches. That is what
25
+ * lets the hard part — path resolution against a real schema, which is where
26
+ * the mistakes live — be tested without a browser or a database.
27
+ */
28
+ import { findTable, labelColumn, primaryKey, referencesTo, } from './schema.js';
29
+ import { MAX_PATH_DEPTH, parseViewQuery } from './view.js';
30
+ import { quoteValue } from './query.js';
31
+ export const WIDTHS = ['full', 'half', 'third'];
32
+ export const OFFSETS = ['quarter', 'third', 'half', 'two-thirds'];
33
+ export const FIELD_COLUMNS = [1, 2, 3];
34
+ /** Rows a list section shows before it needs a page of its own. */
35
+ export const DEFAULT_PAGE_LIST_LIMIT = 10;
36
+ /** Sections a page may hold. A page is a screen, not a report. */
37
+ export const MAX_SECTIONS = 24;
38
+ /* How a suggested page divides the relationships pointing at its root: the
39
+ first few as lists, the next few as counts, the rest not shown. A page that
40
+ listed all twelve things pointing at `customer` would be a report. */
41
+ const LISTED = 2;
42
+ const COUNTED = 3;
43
+ /**
44
+ * Follow `a.b.c` from a table, hop by hop, and say where it lands.
45
+ *
46
+ * Each segment names a foreign key *on the current table* — by constraint
47
+ * name when one is given, otherwise by its column. Constraint name first,
48
+ * because a table can point at the same parent twice and `parent_id` and
49
+ * `supersedes_id` are two different paths that a column-only lookup would
50
+ * happily confuse.
51
+ */
52
+ export function resolvePath(schema, from, path) {
53
+ const segments = path.split('.').map((s) => s.trim()).filter(Boolean);
54
+ if (!segments.length)
55
+ return { error: 'A path cannot be empty.' };
56
+ if (segments.length > MAX_PATH_DEPTH) {
57
+ return { error: `"${path}" follows more than ${MAX_PATH_DEPTH} references.` };
58
+ }
59
+ let current = from;
60
+ const hops = [];
61
+ for (const segment of segments) {
62
+ const table = findTable(schema, current);
63
+ if (!table)
64
+ return { error: `"${current}" is not a table on this connection.` };
65
+ const outgoing = schema.foreignKeys.filter((fk) => fk.from.table === table.id);
66
+ const hop = outgoing.find((fk) => fk.name === segment)
67
+ ?? outgoing.find((fk) => fk.from.columns.length === 1 && fk.from.columns[0] === segment)
68
+ ?? outgoing.find((fk) => fk.from.columns.join(',') === segment);
69
+ if (!hop) {
70
+ return {
71
+ error: `"${table.name}" has no reference called "${segment}".`
72
+ + (outgoing.length
73
+ ? ` It points at: ${outgoing.map((fk) => fk.from.columns.join('+')).join(', ')}.`
74
+ : ' It points at nothing.'),
75
+ };
76
+ }
77
+ hops.push(hop);
78
+ current = hop.to.table;
79
+ }
80
+ return { path: { hops, endsAt: current } };
81
+ }
82
+ /**
83
+ * The filter that ties a section to one root record.
84
+ *
85
+ * Built as a structured `ViewFilter` rather than as text, for the reason the
86
+ * whole tool is built that way: a key value that happens to contain a quote
87
+ * or the word `and` is a value, and text would make it grammar.
88
+ *
89
+ * The path is written into the column, so the compiler does the joining —
90
+ * `invoice_id.customer_id` is a path it already knows how to walk, and the
91
+ * one place that knows how to join is better than a second one here.
92
+ */
93
+ export function rootFilter(path, key, resolved) {
94
+ const last = resolved.hops[resolved.hops.length - 1];
95
+ const prefix = path.split('.').slice(0, -1).join('.');
96
+ /* Every column of the root's key, compared through the same path. A
97
+ composite key compared on one column matches every row that shares it,
98
+ which on `invoice_line` is most of the table. */
99
+ const conditions = last.to.columns.map((rootColumn, i) => {
100
+ const childColumn = last.from.columns[i] ?? last.from.columns[0];
101
+ return {
102
+ path: prefix ? `${prefix}.${childColumn}` : childColumn,
103
+ op: '=',
104
+ value: key[rootColumn],
105
+ };
106
+ });
107
+ return { groups: [conditions] };
108
+ }
109
+ /**
110
+ * Resolve a section's `of` — the forward reference naming which record the
111
+ * section is about.
112
+ *
113
+ * One hop, deliberately. `of: 'site_id.address_id'` is expressible in the
114
+ * type and refused here: each extra hop is another row the planner must read
115
+ * before any section can run, and nobody has yet asked for a section about
116
+ * the address of the site of the record they are looking at. The error says
117
+ * the limit rather than pretending the path is malformed.
118
+ */
119
+ export function resolveOf(schema, baseId, of) {
120
+ const resolved = resolvePath(schema, baseId, of);
121
+ if (resolved.error)
122
+ return { error: resolved.error };
123
+ if (resolved.path.hops.length !== 1) {
124
+ return { error: `"of" reaches one reference from ${baseId}; "${of}" is ${resolved.path.hops.length} hops.` };
125
+ }
126
+ const fk = resolved.path.hops[0];
127
+ const table = findTable(schema, resolved.path.endsAt);
128
+ if (!table)
129
+ return { error: `"${of}" leads to a table this connection does not have.` };
130
+ return { fk, table };
131
+ }
132
+ /**
133
+ * The section's own narrowing, ANDed onto the root's.
134
+ *
135
+ * `filter` has been in the model and in the config parser since the start and
136
+ * was never applied: a page saying `"filter": "status = open"` listed every
137
+ * order, silently. Documented, accepted, ignored — the worst shape a bug can
138
+ * take, because the file says the behaviour is there.
139
+ *
140
+ * Written in the query language and parsed against the *child*, which is what
141
+ * "recent open orders" means: the condition is about the order, not about the
142
+ * customer the page is on. Errors come back rather than throwing, so a
143
+ * mistyped filter names its section instead of blanking the page.
144
+ */
145
+ function withSectionFilter(schema, from, root, filter, now) {
146
+ if (!filter)
147
+ return { filter: root };
148
+ const parsed = parseViewQuery(schema, from, filter, now);
149
+ if (parsed.errors.length)
150
+ return { error: parsed.errors[0].message };
151
+ const extra = parsed.filter.groups;
152
+ if (!extra.length)
153
+ return { filter: root };
154
+ /* ANDed into every root group rather than appended as another group: groups
155
+ are OR-ed, so appending would have widened the result instead of
156
+ narrowing it — a filter that added rows. With one root group, which is
157
+ what `rootFilter` always builds, this is the ordinary case of "and also".
158
+
159
+ A filter with its own OR becomes a product, which is what AND-of-ORs
160
+ means when both sides have alternatives. */
161
+ const groups = [];
162
+ for (const rootGroup of root.groups) {
163
+ for (const extraGroup of extra)
164
+ groups.push([...rootGroup, ...extraGroup]);
165
+ }
166
+ return { filter: { groups } };
167
+ }
168
+ /**
169
+ * What a list section needs to run, as a view the existing compiler can take.
170
+ *
171
+ * A page adds no query machinery of its own. Every section is a view — that
172
+ * is the point of having built one — so a page is an arrangement of views
173
+ * around a shared root rather than a second way to ask a database questions.
174
+ */
175
+ export function listView(schema, page, section, key, index, now, ofKeys) {
176
+ const root = sectionRoot(schema, page, section, key, ofKeys);
177
+ if (root.error)
178
+ return { error: root.error };
179
+ if (root.empty)
180
+ return { empty: true };
181
+ const resolved = resolvePath(schema, section.from, section.path);
182
+ if (resolved.error)
183
+ return { error: resolved.error };
184
+ if (resolved.path.endsAt !== root.table) {
185
+ return {
186
+ error: `"${section.path}" leads from ${section.from} to `
187
+ + `${resolved.path.endsAt}, not to ${root.table}.`,
188
+ };
189
+ }
190
+ const table = findTable(schema, section.from);
191
+ if (!table)
192
+ return { error: `"${section.from}" is not a table on this connection.` };
193
+ const columns = (section.columns?.length
194
+ ? section.columns
195
+ : defaultColumns(table, section.path, resolved.path)).map((p) => ({ path: p }));
196
+ const narrowed = withSectionFilter(schema, table.id, rootFilter(section.path, root.key, resolved.path), section.filter, now);
197
+ if (narrowed.error)
198
+ return { error: narrowed.error };
199
+ return {
200
+ view: {
201
+ id: `page:${page.id}:${index}`,
202
+ name: section.title ?? table.name,
203
+ base: table.id,
204
+ columns,
205
+ filter: narrowed.filter,
206
+ orderBy: section.orderBy?.map((o) => ({ path: o.column, direction: o.direction })),
207
+ limit: section.limit ?? DEFAULT_PAGE_LIST_LIMIT,
208
+ },
209
+ };
210
+ }
211
+ /**
212
+ * The record a section is actually about: the root, or — through `of` — a
213
+ * record the root points at, whose key the planner has already read.
214
+ */
215
+ function sectionRoot(schema, page, section, key, ofKeys) {
216
+ if (!section.of)
217
+ return { table: page.base, key };
218
+ const of = resolveOf(schema, page.base, section.of);
219
+ if (of.error)
220
+ return { error: of.error };
221
+ const referenced = ofKeys?.[section.of];
222
+ if (referenced === undefined) {
223
+ /* The planner read the root row before building sections, so a missing
224
+ entry is the caller's bug, not the page's. Said as what it is. */
225
+ return { error: `The key for "${section.of}" was not resolved before planning.` };
226
+ }
227
+ if (referenced === null)
228
+ return { empty: true };
229
+ return { table: of.table.id, key: referenced };
230
+ }
231
+ /**
232
+ * What a metric section needs to run.
233
+ *
234
+ * Expressed as a one-row view over the *root* table with a single aggregate,
235
+ * rather than as a count over the child: the aggregate machinery already
236
+ * guarantees that two counts over different children cannot inflate each
237
+ * other, and doing it any other way here would give up that guarantee for a
238
+ * number nobody would think to check.
239
+ */
240
+ /**
241
+ * How the query bar would say what a section shows — when it can say it.
242
+ *
243
+ * A count on a page is a number, and a number you cannot get behind is a
244
+ * number you have to take on trust. Clicking one should open the rows it
245
+ * counted, narrowed the same way — and for a long time it opened the child
246
+ * table with no filter at all, because the plan carried no query and the
247
+ * client fell back to the table's own name. The count said 3 and the grid
248
+ * said 20.
249
+ *
250
+ * The query language has no dotted paths: `invoice_line invoice_id.customer_id
251
+ * = 4` is not a thing it can parse, and a page two hops out produces exactly
252
+ * that. So this answers `undefined` rather than something that will not run —
253
+ * the caller opens the composer on the view itself in that case, which can
254
+ * say it. The distinction is deliberate: it is better to be told the language
255
+ * cannot express this than to be shown a filter that quietly is not the one.
256
+ *
257
+ * `is empty` for a null test, because that is the grammar's spelling; `in` is
258
+ * left out, since a list needs bracket syntax the parser writes differently
259
+ * from how it reads it.
260
+ */
261
+ const QUERY_OPS = {
262
+ '=': '=',
263
+ '!=': '!=',
264
+ '<': '<',
265
+ '<=': '<=',
266
+ '>': '>',
267
+ '>=': '>=',
268
+ contains: 'contains',
269
+ startsWith: 'startswith',
270
+ endsWith: 'endswith',
271
+ };
272
+ export function sectionQuery(view) {
273
+ if (!view?.base)
274
+ return undefined;
275
+ const groups = view.filter?.groups?.filter((g) => g.length) ?? [];
276
+ if (!groups.length)
277
+ return view.base;
278
+ const parts = [];
279
+ for (const group of groups) {
280
+ const conditions = [];
281
+ for (const c of group) {
282
+ /* A path, not a column: the language filters the table in front of it
283
+ and nothing further out. */
284
+ if (!c.path || c.path.includes('.'))
285
+ return undefined;
286
+ if (c.op === 'isNull' || c.op === 'isNotNull') {
287
+ conditions.push(`${c.path} is ${c.op === 'isNull' ? '' : 'not '}empty`);
288
+ continue;
289
+ }
290
+ const op = QUERY_OPS[c.op];
291
+ if (!op || c.value === undefined || c.value === null || Array.isArray(c.value))
292
+ return undefined;
293
+ conditions.push(`${c.path} ${op} ${quoteValue(c.value)}`);
294
+ }
295
+ parts.push(conditions.join(' and '));
296
+ }
297
+ return `${view.base} ${parts.join(' or ')}`;
298
+ }
299
+ export function metricView(schema, page, section, key, index, now, ofKeys) {
300
+ const root = sectionRoot(schema, page, section, key, ofKeys);
301
+ if (root.error)
302
+ return { error: root.error };
303
+ if (root.empty)
304
+ return { empty: true, counts: section.fn === 'count' };
305
+ const resolved = resolvePath(schema, section.from, section.path);
306
+ if (resolved.error)
307
+ return { error: resolved.error };
308
+ if (resolved.path.endsAt !== root.table) {
309
+ return {
310
+ error: `"${section.path}" leads from ${section.from} to `
311
+ + `${resolved.path.endsAt}, not to ${root.table}.`,
312
+ };
313
+ }
314
+ if (section.fn !== 'count' && !section.column) {
315
+ return { error: `"${section.fn}" needs a column to ${section.fn}.` };
316
+ }
317
+ const table = findTable(schema, section.from);
318
+ if (!table)
319
+ return { error: `"${section.from}" is not a table on this connection.` };
320
+ if (section.column && !table.columns.some((c) => c.name === section.column)) {
321
+ return { error: `"${table.name}" has no column called "${section.column}".` };
322
+ }
323
+ /* The record this section is about — the page's base, or the table `of`
324
+ reaches. The aggregate view below anchors on it, so a sum "of its site"
325
+ is a one-row view over site exactly as a sum over the root is one over
326
+ the root. */
327
+ const base = findTable(schema, root.table);
328
+ if (!base)
329
+ return { error: `"${root.table}" is not a table on this connection.` };
330
+ /**
331
+ * A count is a filtered count of the child, not an aggregate on the root.
332
+ *
333
+ * It used to be an aggregate, with `via` taken from the *first* hop — which
334
+ * is the right constraint only when there is one. For anything further out
335
+ * the first hop names a key that does not point at the root at all: on a
336
+ * customer page, `invoice_line via invoice_id.customer_id` handed the
337
+ * aggregate `line_invoice_fk`, which joins lines to invoices and knows
338
+ * nothing about customers. The number that came back was not the number
339
+ * asked for, and looked like one.
340
+ *
341
+ * Counting the child directly is correct at any depth, and it is what the
342
+ * list beside it already does — so a count and the list it summarises can
343
+ * no longer disagree. The view returns one row and its `total`, which is
344
+ * exact: a view only ever joins to-one, so counting it is counting the
345
+ * table it started from.
346
+ */
347
+ if (section.fn === 'count') {
348
+ const narrowed = withSectionFilter(schema, table.id, rootFilter(section.path, root.key, resolved.path), section.filter, now);
349
+ if (narrowed.error)
350
+ return { error: narrowed.error };
351
+ return {
352
+ counts: true,
353
+ view: {
354
+ id: `page:${page.id}:${index}`,
355
+ name: section.title ?? table.name,
356
+ base: table.id,
357
+ columns: (primaryKey(table)[0] ? [primaryKey(table)[0]] : [table.columns[0].name])
358
+ .map((path) => ({ path })),
359
+ filter: narrowed.filter,
360
+ limit: 1,
361
+ },
362
+ };
363
+ }
364
+ /* `sum`, `avg`, `min` and `max` still go through the aggregate compiler,
365
+ which is the only thing that can produce them — and which reaches exactly
366
+ one hop. Refused rather than answered wrongly: a sum over a relationship
367
+ it cannot express would be a number with no way to tell it was the wrong
368
+ one. */
369
+ if (resolved.path.hops.length > 1) {
370
+ return {
371
+ error: `"${section.fn}" only works on something that points straight at `
372
+ + `${base.name}. ${table.name} is ${resolved.path.hops.length} references away — `
373
+ + 'count it instead, or add the column to the table in between.',
374
+ };
375
+ }
376
+ if (section.filter) {
377
+ /* The aggregate compiler takes no filter of its own, and quietly dropping
378
+ one would be the bug this whole change is about. */
379
+ return {
380
+ error: `A "${section.fn}" cannot be narrowed by a filter. `
381
+ + 'Count it instead, or make a list and read the total.',
382
+ };
383
+ }
384
+ return {
385
+ view: {
386
+ id: `page:${page.id}:${index}`,
387
+ name: section.title ?? `${section.fn} of ${table.name}`,
388
+ base: base.id,
389
+ columns: primaryKey(base).map((c) => ({ path: c })),
390
+ aggregates: [{
391
+ fn: section.fn,
392
+ via: resolved.path.hops[0].name,
393
+ column: section.column,
394
+ alias: 'value',
395
+ }],
396
+ filter: { groups: [primaryKey(base).map((c) => ({ path: c, op: '=', value: root.key[c] }))] },
397
+ limit: 1,
398
+ },
399
+ };
400
+ }
401
+ /**
402
+ * Columns worth showing in a list nobody has configured.
403
+ *
404
+ * The path back to the root is dropped: every row in the section has the same
405
+ * value for it, so a column of forty identical numbers is the one column that
406
+ * cannot tell two rows apart. The key and the label come first, because they
407
+ * are what a row is called.
408
+ */
409
+ export function defaultColumns(table, path, resolved) {
410
+ /* The columns the first hop actually uses, not the segment that named it.
411
+
412
+ A path segment can be a constraint name — which is how a suggested page
413
+ writes it, and the only way to name a composite hop — so splitting the
414
+ path for a column name found `address_country_fk`, matched nothing, and
415
+ left `country_code` in the table: a column reading NZ on every row of a
416
+ section that is *about* NZ. The one column that cannot tell two rows
417
+ apart, kept in the one place it is guaranteed useless. */
418
+ const pathHead = resolved
419
+ ? resolved.hops[0].from.columns
420
+ : [path.split('.')[0]];
421
+ const key = primaryKey(table);
422
+ const label = labelColumn(table);
423
+ const ordered = [
424
+ ...key,
425
+ ...(label && !key.includes(label) ? [label] : []),
426
+ ...table.columns.map((c) => c.name).filter((n) => !key.includes(n) && n !== label),
427
+ ];
428
+ return ordered.filter((name) => !pathHead.includes(name)).slice(0, 6);
429
+ }
430
+ /**
431
+ * Check a page against a real schema, and say what is wrong with it.
432
+ *
433
+ * Returns the page with an `error` rather than throwing, because a page that
434
+ * names a table this connection does not have is still a page — it belongs
435
+ * in the list, greyed, with its reason. A config file shared by a team will
436
+ * always describe some connection someone is not on.
437
+ */
438
+ export function validatePage(schema, page) {
439
+ const base = findTable(schema, page.base);
440
+ if (!base)
441
+ return { ...page, error: `"${page.base}" is not a table on this connection.` };
442
+ if (!primaryKey(base).length) {
443
+ return {
444
+ ...page,
445
+ error: `"${base.name}" has no primary key, so a single record cannot be identified.`,
446
+ };
447
+ }
448
+ for (const [i, section] of page.sections.entries()) {
449
+ const at = section.title ? `"${section.title}"` : `section ${i + 1}`;
450
+ if (section.kind === 'fields')
451
+ continue;
452
+ let rootTable = page.base;
453
+ if (section.of) {
454
+ const of = resolveOf(schema, page.base, section.of);
455
+ if (of.error)
456
+ return { ...page, error: `${at}: ${of.error}` };
457
+ rootTable = of.table.id;
458
+ }
459
+ const resolved = resolvePath(schema, section.from, section.path);
460
+ if (resolved.error)
461
+ return { ...page, error: `${at}: ${resolved.error}` };
462
+ if (resolved.path.endsAt !== rootTable) {
463
+ return {
464
+ ...page,
465
+ error: `${at}: "${section.path}" leads from ${section.from} to `
466
+ + `${resolved.path.endsAt}, not to ${rootTable}.`,
467
+ };
468
+ }
469
+ if (section.kind === 'metric' && section.fn !== 'count' && !section.column) {
470
+ return { ...page, error: `${at}: "${section.fn}" needs a column to ${section.fn}.` };
471
+ }
472
+ }
473
+ return { ...page, error: undefined };
474
+ }
475
+ /**
476
+ * A page for a table nobody has written one for.
477
+ *
478
+ * Not a placeholder: it is the page most people would have built by hand —
479
+ * the record, a count of each thing that points at it, and the two biggest of
480
+ * those as lists. Offering it means a page exists for every table on the
481
+ * first run, which is the difference between a feature people try and a
482
+ * feature people configure first.
483
+ */
484
+ export function suggestPage(schema, baseId) {
485
+ const base = findTable(schema, baseId);
486
+ if (!base || !primaryKey(base).length)
487
+ return null;
488
+ const incoming = referencesTo(schema, base.id);
489
+ const sections = [{ kind: 'fields', width: 'full' }];
490
+ /* Lists for the first few, counts for the rest — never both for the same
491
+ relationship.
492
+
493
+ A count above a list of the rows it counted says nothing the list does
494
+ not: `ROOM 2` over a table of two rooms is a number and its own proof,
495
+ taking a card to do it. The count belongs *on* the list, where it says
496
+ how many there are beyond the ones shown; a card earns its place only for
497
+ a relationship you cannot see, which is what the ones past the listed
498
+ few are. Show the big ones, count the rest. */
499
+ const listed = incoming.slice(0, LISTED);
500
+ const counted = incoming.slice(LISTED, LISTED + COUNTED);
501
+ for (const fk of counted) {
502
+ const child = findTable(schema, fk.from.table);
503
+ if (!child)
504
+ continue;
505
+ sections.push({
506
+ kind: 'metric',
507
+ fn: 'count',
508
+ from: child.id,
509
+ path: fk.name,
510
+ title: metricTitle(counted, fk, child.name, base.id),
511
+ width: 'third',
512
+ });
513
+ }
514
+ for (const fk of listed) {
515
+ const child = findTable(schema, fk.from.table);
516
+ if (!child)
517
+ continue;
518
+ sections.push({
519
+ kind: 'list',
520
+ from: child.id,
521
+ path: fk.name,
522
+ title: metricTitle(listed, fk, child.name, base.id),
523
+ limit: DEFAULT_PAGE_LIST_LIMIT,
524
+ width: listed.length > 1 ? 'half' : 'full',
525
+ });
526
+ }
527
+ return {
528
+ id: `suggested:${base.id}`,
529
+ name: base.name,
530
+ base: base.id,
531
+ sections,
532
+ source: 'suggested',
533
+ };
534
+ }
535
+ /**
536
+ * What to call a count.
537
+ *
538
+ * The child's name, which is what someone reading a summary expects — until
539
+ * that is not enough to tell two counts apart. A table pointing at the same
540
+ * parent twice gives two counts both labelled `work_order`, and a
541
+ * self-reference gives a count labelled with the table you are already on:
542
+ * `employee` on an employee page, which is the count of their reports and
543
+ * says nothing of the sort. Both cases are named by their foreign key
544
+ * instead, which is the thing that actually differs.
545
+ */
546
+ function metricTitle(chosen, fk, childName, baseId) {
547
+ const ambiguous = chosen.filter((other) => other.from.table === fk.from.table).length > 1;
548
+ const selfReference = fk.from.table === baseId;
549
+ return ambiguous || selfReference ? `${childName} via ${fk.from.columns.join(', ')}` : childName;
550
+ }
551
+ /**
552
+ * Every way a table could reach this root, within a couple of hops.
553
+ *
554
+ * The one field nobody guesses when writing a page by hand is the path —
555
+ * `invoice_id.customer_id` is obvious only once you already know it. So it is
556
+ * not typed: the schema is asked what actually points here, directly and one
557
+ * table further out, and each answer comes back with the path already
558
+ * written.
559
+ *
560
+ * Two hops rather than the full depth limit. Three is a real relationship and
561
+ * a list nobody can read: on a schema of this size it runs to hundreds of
562
+ * entries, most of them routes no one would choose, and a menu that long is a
563
+ * menu people close.
564
+ */
565
+ export function relationsTo(schema, baseId, maxHops = 2) {
566
+ const base = findTable(schema, baseId);
567
+ if (!base)
568
+ return [];
569
+ const found = [];
570
+ const seen = new Set();
571
+ /** Every spelling of each path already built, so deeper hops can extend them. */
572
+ const aliasesOf = new Map([['', ['']]]);
573
+ const walk = (targetId, path, via, hops) => {
574
+ if (hops > maxHops)
575
+ return;
576
+ for (const fk of schema.foreignKeys) {
577
+ if (fk.to.table !== targetId)
578
+ continue;
579
+ const child = findTable(schema, fk.from.table);
580
+ if (!child)
581
+ continue;
582
+ /* Named by the constraint, always. A column name is shorter and works
583
+ until a table points at the same parent twice, or points at it with
584
+ two columns — and both are common enough that the readable spelling
585
+ is the one that breaks. */
586
+ const next = [fk.name, ...path];
587
+ const route = next.join('.');
588
+ const id = `${child.id}|${route}`;
589
+ if (seen.has(id))
590
+ continue;
591
+ seen.add(id);
592
+ const target = findTable(schema, targetId);
593
+ /* Both spellings of this hop, crossed with every spelling of the rest.
594
+ Bounded: two names per hop and two hops is four, and the depth cap
595
+ keeps it there. A composite key has only its constraint name, since
596
+ no single column identifies it. */
597
+ const hopNames = [fk.name];
598
+ if (fk.from.columns.length === 1)
599
+ hopNames.push(fk.from.columns[0]);
600
+ const tailAliases = aliasesOf.get(path.join('.')) ?? [path.join('.')];
601
+ const aliases = hopNames.flatMap((name) => tailAliases.map((tail) => (tail ? `${name}.${tail}` : name)));
602
+ aliasesOf.set(route, aliases);
603
+ found.push({
604
+ from: child.id,
605
+ path: route,
606
+ aliases,
607
+ label: child.name,
608
+ hops: hops + 1,
609
+ /* The route in the words on the screen: table names and the columns
610
+ that join them. A constraint name is the only reliable *identifier*
611
+ for a hop, which is why the path is written in them — and it is the
612
+ one thing nobody recognises, which is why it is not what is shown.
613
+ Innermost first, so it reads outward from the root. */
614
+ via: [`${child.name}.${fk.from.columns.join(', ')} \u2192 ${target?.name ?? targetId}`, ...via],
615
+ });
616
+ /* Not through the root, and not through itself. A self-referencing key
617
+ is a real relationship at one hop and an infinite regress past it. */
618
+ if (child.id !== baseId && child.id !== targetId) {
619
+ walk(child.id, next, [`${child.name}.${fk.from.columns.join(', ')} \u2192 ${findTable(schema, targetId)?.name ?? targetId}`, ...via], hops + 1);
620
+ }
621
+ }
622
+ };
623
+ walk(base.id, [], [], 0);
624
+ /* Disambiguated after the fact rather than while walking, because whether
625
+ `invoice` needs qualifying depends on whether a *second* invoice route
626
+ turned up later.
627
+
628
+ Qualified by the columns, not by the path: `invoice via sold_by` is a
629
+ sentence someone can check against the schema in their head, where
630
+ `invoice via invoice_fk_2` is a string only the database has ever seen. */
631
+ /* Qualified only as far as it takes, and as far as it takes.
632
+
633
+ One pass was not enough. `work_log through ticket` was three entries on
634
+ the employee page — work_log reaches employee through ticket by
635
+ `assigned_to`, by `raised_by` and by `closed_by` — and all three were
636
+ rendered with the same words and the same detail line underneath. Three
637
+ identical rows in a menu is a menu you cannot use: whichever is picked,
638
+ nobody can tell which was picked.
639
+
640
+ So each name is refined until it is unique, one step at a time, and only
641
+ the ones that collide take the next step. `invoice` stays `invoice` where
642
+ nothing else is called that. */
643
+ const steps = new Map(found.map((r) => [r, 0]));
644
+ for (let pass = 0; pass < 4; pass += 1) {
645
+ const groups = new Map();
646
+ for (const r of found) {
647
+ const label = qualify(r, steps.get(r) ?? 0);
648
+ const group = groups.get(label);
649
+ if (group)
650
+ group.push(r);
651
+ else
652
+ groups.set(label, [r]);
653
+ }
654
+ let refined = false;
655
+ for (const group of groups.values()) {
656
+ if (group.length < 2)
657
+ continue;
658
+ for (const r of group) {
659
+ steps.set(r, (steps.get(r) ?? 0) + 1);
660
+ refined = true;
661
+ }
662
+ }
663
+ if (!refined)
664
+ break;
665
+ }
666
+ for (const r of found)
667
+ r.label = qualify(r, steps.get(r) ?? 0);
668
+ return found.sort((a, b) => a.hops - b.hops || a.label.localeCompare(b.label));
669
+ }
670
+ /**
671
+ * The columns of one hop, out of the sentence it is written as.
672
+ *
673
+ * A `via` entry reads `work_log.ticket_id \u2192 ticket`, which is the form the
674
+ * screen wants; this is the one place that takes it apart again.
675
+ */
676
+ const hopColumns = (via) => via?.split(' \u2192 ')[0]?.split('.').slice(1).join('.') ?? '';
677
+ const hopTarget = (via) => via?.split('\u2192 ')[1] ?? '\u2026';
678
+ /**
679
+ * A relationship's name at a given degree of qualification.
680
+ *
681
+ * Step 0 is the child table's name, which is right whenever nothing else on
682
+ * the page is called that. Each step after it adds the next thing that could
683
+ * tell two of them apart, in the order a reader would ask: which column, then
684
+ * which table in the middle, then which column *that* one reaches the root
685
+ * by. The last step falls back to the path — unreadable, and unique by
686
+ * construction, which is what a last step is for.
687
+ */
688
+ function qualify(r, step) {
689
+ const name = r.label.split(' via ')[0].split(' through ')[0];
690
+ if (step <= 0)
691
+ return name;
692
+ if (r.hops === 1) {
693
+ return step === 1 ? `${name} via ${hopColumns(r.via[0])}` : `${name} (${r.path})`;
694
+ }
695
+ /* Two hops are told apart by the table in the middle — `contract_line
696
+ through contract` and `contract_line through asset` are two genuinely
697
+ different lists. Naming the root instead would label both of them
698
+ "through customer", which is the thing they have in common. */
699
+ if (step === 1)
700
+ return `${name} through ${hopTarget(r.via[0])}`;
701
+ /* And where the middle table is the same, by how *it* reaches the root:
702
+ `work_log through ticket.assigned_to` against `\u2026 through
703
+ ticket.raised_by`. `via` runs innermost first, so the hop that lands on
704
+ the root is the last one. */
705
+ if (step === 2 && r.hops === 2) {
706
+ /* Only at exactly two hops, where the table in the middle is also the one
707
+ holding the column that reaches the root. Three hops out they are
708
+ different tables, and `work_log through ticket.lead_employee_id` would
709
+ name a column that is on `team` — a sentence that reads as fact and is
710
+ not one. Those go straight to the full spelling below. */
711
+ return `${name} through ${hopTarget(r.via[0])}.${hopColumns(r.via[r.via.length - 1])}`;
712
+ }
713
+ if (step === 2 || step === 3) {
714
+ return `${name} through ${r.via.map((v) => hopColumns(v)).join(' \u2192 ')}`;
715
+ }
716
+ return `${name} (${r.path})`;
717
+ }
718
+ /** Columns of a table that a metric could summarise. `count` needs none. */
719
+ export function metricColumns(schema, tableId) {
720
+ const table = findTable(schema, tableId);
721
+ if (!table)
722
+ return [];
723
+ /* Only the numeric ones. `sum` of a name is not a question, and offering it
724
+ produces a page that fails at run time for a reason the builder knew at
725
+ build time. */
726
+ return table.columns
727
+ .filter((c) => /int|numeric|decimal|real|double|float|money|serial/i.test(c.type))
728
+ .map((c) => c.name);
729
+ }
730
+ /* ---------- config ---------- */
731
+ const slug = (name) => name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'page';
732
+ /**
733
+ * Read the `pages` section of a tablewalk.json.
734
+ *
735
+ * Throws on a malformed file rather than skipping the bad entry: a page that
736
+ * silently did not appear is a bug report that starts "I definitely wrote
737
+ * it". Everything a config file can get wrong is named with the page it is
738
+ * in and, where there is one, the fix.
739
+ */
740
+ export function parsePagesConfig(raw, where) {
741
+ const pages = raw?.pages;
742
+ if (pages === undefined)
743
+ return [];
744
+ if (!Array.isArray(pages))
745
+ throw new Error(`${where}: "pages" must be an array.`);
746
+ const seen = new Set();
747
+ return pages.map((entry, i) => {
748
+ const at = `${where}: page ${i + 1}`;
749
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
750
+ throw new Error(`${at} is not an object.`);
751
+ }
752
+ const p = entry;
753
+ if (typeof p.name !== 'string' || !p.name.trim())
754
+ throw new Error(`${at} has no "name".`);
755
+ const name = p.name.trim();
756
+ if (typeof p.base !== 'string' || !p.base.trim()) {
757
+ throw new Error(`${at} ("${name}") has no "base" table.`);
758
+ }
759
+ const id = p.id === undefined ? `config-${slug(name)}` : String(p.id);
760
+ if (p.id !== undefined && (typeof p.id !== 'string' || !p.id.trim())) {
761
+ throw new Error(`${at} ("${name}") has an "id" that is not a non-empty string.`);
762
+ }
763
+ if (seen.has(id))
764
+ throw new Error(`${at} ("${name}") repeats the id "${id}".`);
765
+ seen.add(id);
766
+ if (p.connection !== undefined && typeof p.connection !== 'string') {
767
+ throw new Error(`${at} ("${name}") has a "connection" that is not a string.`);
768
+ }
769
+ /* Refused rather than coerced, like every other boolean in this file:
770
+ `"listTabs": "false"` is a truthy string. */
771
+ if (p.listTabs !== undefined && typeof p.listTabs !== 'boolean') {
772
+ throw new Error(`${at} ("${name}") has "listTabs": ${JSON.stringify(p.listTabs)}. It must be true or false, without quotes.`);
773
+ }
774
+ return {
775
+ id,
776
+ name,
777
+ base: p.base.trim(),
778
+ sections: parseSections(p.sections, at, name),
779
+ connection: p.connection,
780
+ /* Stored only when it is `false`, since `true` is the default and a
781
+ file full of `"listTabs": true` suggests the flag is doing
782
+ something. */
783
+ ...(p.listTabs === false ? { listTabs: false } : {}),
784
+ source: 'config',
785
+ };
786
+ });
787
+ }
788
+ function parseSections(raw, at, name) {
789
+ if (raw === undefined)
790
+ return [{ kind: 'fields', width: 'full' }];
791
+ if (!Array.isArray(raw))
792
+ throw new Error(`${at} ("${name}") has "sections" that are not an array.`);
793
+ if (raw.length > MAX_SECTIONS) {
794
+ throw new Error(`${at} ("${name}") has more than ${MAX_SECTIONS} sections.`);
795
+ }
796
+ return raw.map((entry, i) => parseSection(entry, `${at} ("${name}") section ${i + 1}`));
797
+ }
798
+ /**
799
+ * A column count, refused rather than clamped.
800
+ *
801
+ * Clamping `"columns": 7` to 3 would render a layout nobody asked for and say
802
+ * nothing about it; the file is the thing being edited, so the file is where
803
+ * the mistake belongs.
804
+ */
805
+ function parseFieldColumns(value, at) {
806
+ if (value === undefined || value === null)
807
+ return undefined;
808
+ const n = Number(value);
809
+ if (!FIELD_COLUMNS.includes(n)) {
810
+ throw new Error(`${at} has "columns" of ${JSON.stringify(value)}. Use 1, 2 or 3.`);
811
+ }
812
+ return n;
813
+ }
814
+ function parseSection(entry, at) {
815
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
816
+ throw new Error(`${at} is not an object.`);
817
+ }
818
+ const s = entry;
819
+ const kind = s.kind === undefined ? 'fields' : String(s.kind);
820
+ const width = parseWidth(s.width, at);
821
+ const offsetLeft = parseOffset(s.offsetLeft, at, 'offsetLeft');
822
+ const offsetRight = parseOffset(s.offsetRight, at, 'offsetRight');
823
+ const title = s.title === undefined ? undefined : String(s.title);
824
+ const hidden = s.hidden === undefined ? undefined : Boolean(s.hidden) || undefined;
825
+ if (kind === 'fields') {
826
+ if (s.paths !== undefined && !Array.isArray(s.paths)) {
827
+ throw new Error(`${at} has "paths" that are not an array.`);
828
+ }
829
+ return {
830
+ kind: 'fields',
831
+ title,
832
+ width,
833
+ offsetLeft,
834
+ offsetRight,
835
+ hidden,
836
+ paths: s.paths?.map(String),
837
+ columns: parseFieldColumns(s.columns, at),
838
+ collapsed: s.collapsed === undefined ? undefined : Boolean(s.collapsed) || undefined,
839
+ };
840
+ }
841
+ if (kind !== 'metric' && kind !== 'list') {
842
+ throw new Error(`${at} has an unknown kind "${kind}". Use "fields", "metric" or "list".`);
843
+ }
844
+ const from = typeof s.from === 'string' ? s.from.trim() : '';
845
+ if (!from)
846
+ throw new Error(`${at} has no "from" table.`);
847
+ const path = typeof s.path === 'string' ? s.path.trim() : '';
848
+ if (!path) {
849
+ throw new Error(`${at} has no "path" saying how ${from} reaches the page's record — `
850
+ + 'e.g. "customer_id", or "invoice_id.customer_id" for something two references away.');
851
+ }
852
+ const filter = s.filter === undefined ? undefined : String(s.filter).trim() || undefined;
853
+ if (kind === 'metric') {
854
+ const fn = String(s.fn ?? 'count');
855
+ if (!['count', 'sum', 'min', 'max', 'avg'].includes(fn)) {
856
+ throw new Error(`${at} has an unknown "fn" of "${fn}".`);
857
+ }
858
+ if (fn !== 'count' && (typeof s.column !== 'string' || !s.column.trim())) {
859
+ throw new Error(`${at} is a "${fn}" with no "column" to ${fn}.`);
860
+ }
861
+ return {
862
+ kind: 'metric',
863
+ title,
864
+ width,
865
+ offsetLeft,
866
+ offsetRight,
867
+ hidden,
868
+ fn: fn,
869
+ from,
870
+ path,
871
+ column: s.column === undefined ? undefined : String(s.column).trim(),
872
+ filter,
873
+ };
874
+ }
875
+ if (s.limit !== undefined && (typeof s.limit !== 'number' || !Number.isInteger(s.limit) || s.limit < 1)) {
876
+ throw new Error(`${at} has a "limit" that is not a positive whole number.`);
877
+ }
878
+ if (s.columns !== undefined && !Array.isArray(s.columns)) {
879
+ throw new Error(`${at} has "columns" that are not an array.`);
880
+ }
881
+ return {
882
+ kind: 'list',
883
+ title,
884
+ width,
885
+ offsetLeft,
886
+ offsetRight,
887
+ hidden,
888
+ from,
889
+ path,
890
+ columns: s.columns?.map(String),
891
+ filter,
892
+ limit: s.limit,
893
+ orderBy: parseOrderBy(s.orderBy, at),
894
+ };
895
+ }
896
+ function parseWidth(raw, at) {
897
+ if (raw === undefined)
898
+ return undefined;
899
+ const width = String(raw);
900
+ if (!WIDTHS.includes(width)) {
901
+ throw new Error(`${at} has a "width" of "${width}". Use ${WIDTHS.join(', ')}.`);
902
+ }
903
+ return width;
904
+ }
905
+ function parseOffset(raw, at, side) {
906
+ if (raw === undefined)
907
+ return undefined;
908
+ const offset = String(raw);
909
+ if (!OFFSETS.includes(offset)) {
910
+ throw new Error(`${at} has an "${side}" of "${offset}". Use ${OFFSETS.join(', ')}.`);
911
+ }
912
+ return offset;
913
+ }
914
+ function parseOrderBy(raw, at) {
915
+ if (raw === undefined)
916
+ return undefined;
917
+ if (!Array.isArray(raw))
918
+ throw new Error(`${at} has an "orderBy" that is not an array.`);
919
+ return raw.map((entry) => {
920
+ if (!entry || typeof entry !== 'object')
921
+ throw new Error(`${at} has an "orderBy" entry that is not an object.`);
922
+ const o = entry;
923
+ if (typeof o.column !== 'string' || !o.column.trim()) {
924
+ throw new Error(`${at} has an "orderBy" entry with no "column".`);
925
+ }
926
+ const direction = o.direction === undefined ? 'asc' : String(o.direction).toLowerCase();
927
+ if (direction !== 'asc' && direction !== 'desc') {
928
+ throw new Error(`${at} has an "orderBy" direction of "${direction}". Use asc or desc.`);
929
+ }
930
+ return { column: o.column.trim(), direction: direction };
931
+ });
932
+ }