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,343 @@
1
+ /**
2
+ * A record template: one row, with its references pulled in around it.
3
+ *
4
+ * The view composer as built answers "give me a table of X with columns from
5
+ * the things X points at" — one row per base record, joins flattened into
6
+ * columns. That is a useful shape and the wrong one for the question tablewalk
7
+ * actually exists to answer, which is "show me *this* work order, properly".
8
+ *
9
+ * That question wants a page: the work order's own fields, the customer's
10
+ * details inline because a work order without its customer's name is a
11
+ * riddle, and its parts and notes as tables underneath because there are
12
+ * several of each and a count is not enough.
13
+ *
14
+ * The difference from a list view is not cosmetic — it is what the thing is
15
+ * bound to. A list view is a *query*: it produces rows and you read them. A
16
+ * record template is bound to a **table**, not to a row, and applies to every
17
+ * row of it. Configure `work_order` once and every work order you ever walk
18
+ * to is laid out that way. That is what makes it worth configuring at all.
19
+ *
20
+ * Two consequences fall out, and both are why this shape earns its place:
21
+ *
22
+ * - A has-many relationship is finally answerable. In a list view it either
23
+ * multiplies rows or collapses to a count; here it is simply a table on
24
+ * the page, which is what anyone asking for "the order and its lines"
25
+ * meant in the first place.
26
+ * - Views stop being a dead end. A list view is somewhere you look; a
27
+ * record template is where a walk *arrives*, so views become part of the
28
+ * navigation rather than a parallel product beside it.
29
+ */
30
+ import { findTable, labelColumn, looksLikeDate, primaryKey, referencesFrom, referencesTo, } from './schema.js';
31
+ /** Rows to show in an embedded list before it needs its own page. */
32
+ export const DEFAULT_LIST_LIMIT = 10;
33
+ /**
34
+ * Below this many columns, grouping costs more than it saves.
35
+ *
36
+ * A four-column table split into "Identity" and "Details" is two headings and
37
+ * a divider to organise four fields — more ceremony than content, and slower
38
+ * to read than the flat list it replaced.
39
+ */
40
+ export const GROUP_THRESHOLD = 12;
41
+ const AUDIT_NAME = /^(sys_|created|updated|modified|deleted|inserted)|_(at|by)$|^(version|etag|revision)$/i;
42
+ /**
43
+ * How a table's own columns divide into blocks.
44
+ *
45
+ * Derived from what the schema already says rather than from a stored layout:
46
+ * keys and the label column identify, foreign keys link, dates measure, and
47
+ * anything named like an audit column is bookkeeping. Ordering is by what
48
+ * gets looked at first, not by declaration order.
49
+ *
50
+ * Lives here rather than in the renderer because it *is* the default
51
+ * template. Having the derivation in one place and the stored layout in
52
+ * another is how the two start disagreeing about what a record looks like.
53
+ */
54
+ export function groupOwnColumns(table) {
55
+ const columns = table.columns;
56
+ if (columns.length < GROUP_THRESHOLD)
57
+ return [[null, columns]];
58
+ const label = labelColumn(table);
59
+ const groups = new Map([
60
+ ['Identity', []],
61
+ ['Bookkeeping', []],
62
+ ['References', []],
63
+ ['Details', []],
64
+ ['Dates', []],
65
+ ]);
66
+ for (const col of columns) {
67
+ if (col.primaryKey || col.name === label)
68
+ groups.get('Identity').push(col);
69
+ else if (col.references)
70
+ groups.get('References').push(col);
71
+ else if (AUDIT_NAME.test(col.name))
72
+ groups.get('Bookkeeping').push(col);
73
+ else if (looksLikeDate(col))
74
+ groups.get('Dates').push(col);
75
+ else
76
+ groups.get('Details').push(col);
77
+ }
78
+ // A section with nothing in it is a heading that means nothing.
79
+ return [...groups].filter(([, cols]) => cols.length);
80
+ }
81
+ /* ---------- deriving a starting template ---------- */
82
+ /**
83
+ * The template a table gets before anyone configures one.
84
+ *
85
+ * Derived rather than blank, because a blank record page is a worse starting
86
+ * point than the default layout the record view already produces, and because
87
+ * the fastest way to explain what a template *is* is to show someone one made
88
+ * of their own data.
89
+ *
90
+ * The rules are the ones the record view already uses, made explicit: the
91
+ * record's own fields first, then each to-one reference as its own labelled
92
+ * block, then each incoming relationship as a table.
93
+ */
94
+ export function deriveTemplate(schema, tableId) {
95
+ const table = findTable(schema, tableId);
96
+ if (!table)
97
+ return undefined;
98
+ const sections = groupOwnColumns(table).map(([title, columns]) => ({
99
+ kind: 'fields',
100
+ title: title ?? undefined,
101
+ paths: columns.map((c) => c.name),
102
+ }));
103
+ /* One block per outgoing reference, holding a few readable columns of the
104
+ row it points at. Not every column: pulling a customer's forty fields
105
+ into a work order buries the work order. */
106
+ for (const fk of referencesFrom(schema, tableId)) {
107
+ if (fk.to.table === tableId)
108
+ continue; // a self-reference adds no context
109
+ const target = findTable(schema, fk.to.table);
110
+ if (!target)
111
+ continue;
112
+ const via = fk.from.columns.join('+');
113
+ sections.push({
114
+ kind: 'fields',
115
+ title: `${target.name} · via ${fk.from.columns.join(', ')}`,
116
+ paths: summaryColumns(target).map((c) => `${via}.${c}`),
117
+ });
118
+ }
119
+ for (const fk of referencesTo(schema, tableId)) {
120
+ const child = findTable(schema, fk.from.table);
121
+ if (!child)
122
+ continue;
123
+ sections.push({
124
+ kind: 'list',
125
+ title: `${child.name} · via ${fk.from.columns.join(', ')}`,
126
+ via: fk.name,
127
+ columns: listColumns(child, fk.from.columns),
128
+ limit: DEFAULT_LIST_LIMIT,
129
+ });
130
+ }
131
+ return { id: `derived:${tableId}`, name: table.name, base: tableId, sections };
132
+ }
133
+ /**
134
+ * Every column of a child table worth showing, in declaration order.
135
+ *
136
+ * All of them, not a summary. A summary is the right shape for a reference
137
+ * pulled *into* a record — a customer's forty fields would bury the work order
138
+ * they are attached to — and the wrong shape for a table of children, which is
139
+ * the thing you opened the record to read. `work_order_part` summarised down
140
+ * to one column of quantities is a list of numbers with nothing to attach them
141
+ * to.
142
+ *
143
+ * The join columns are still dropped: every row here carries the same value
144
+ * for them, so a column of one repeated value is width spent on nothing. The
145
+ * key stays, because it is what distinguishes the rows.
146
+ *
147
+ * Anyone who wants fewer can say so in the layout builder, which is the right
148
+ * order for a default — start from everything and subtract, rather than start
149
+ * from a guess and hunt for what is missing.
150
+ */
151
+ export function listColumns(table, exclude = []) {
152
+ const skip = new Set(exclude);
153
+ const chosen = table.columns.filter((c) => !skip.has(c.name)).map((c) => c.name);
154
+ // Everything was part of the join: the key is better than an empty table.
155
+ return chosen.length ? chosen : primaryKey(table).slice(0, 1);
156
+ }
157
+ /**
158
+ * A few columns that say what a row *is*.
159
+ *
160
+ * Keys and the columns forming the join are dropped: in a table of a work
161
+ * order's parts, every row has the same `work_order_id`, so a column of it is
162
+ * a column of one repeated value. What is left is what distinguishes the rows
163
+ * from each other, which is the only thing the section is there to show.
164
+ */
165
+ export function summaryColumns(table, exclude = [], max = 5) {
166
+ const skip = new Set(exclude);
167
+ const chosen = [];
168
+ for (const column of table.columns) {
169
+ if (skip.has(column.name))
170
+ continue;
171
+ if (column.primaryKey && table.columns.some((c) => !c.primaryKey && !skip.has(c.name)))
172
+ continue;
173
+ chosen.push(column.name);
174
+ if (chosen.length >= max)
175
+ break;
176
+ }
177
+ // Everything was a key or excluded: the key is better than nothing.
178
+ return chosen.length ? chosen : primaryKey(table).slice(0, 1);
179
+ }
180
+ /**
181
+ * Match each list section to the foreign key it names.
182
+ *
183
+ * A section whose constraint has since been dropped is returned as an error
184
+ * rather than skipped: a template that quietly stops showing a table looks
185
+ * exactly like a record that has no rows in it.
186
+ */
187
+ export function resolveLists(schema, template) {
188
+ const lists = [];
189
+ const errors = [];
190
+ for (const section of template.sections) {
191
+ if (section.kind !== 'list')
192
+ continue;
193
+ const fk = referencesTo(schema, template.base).find((f) => f.name === section.via);
194
+ if (!fk) {
195
+ errors.push(`${template.name}: nothing called "${section.via}" points at ${template.base} any more. ` +
196
+ `The section was left in place so it can be removed or repointed.`);
197
+ continue;
198
+ }
199
+ const child = findTable(schema, fk.from.table);
200
+ if (!child) {
201
+ errors.push(`${template.name}: ${fk.from.table} is not in this schema.`);
202
+ continue;
203
+ }
204
+ lists.push({
205
+ section,
206
+ fk,
207
+ child,
208
+ join: fk.to.columns.map((parentColumn, i) => ({
209
+ parentColumn,
210
+ childColumn: fk.from.columns[i] ?? fk.from.columns[0],
211
+ })),
212
+ });
213
+ }
214
+ return { lists, errors };
215
+ }
216
+ /**
217
+ * Every path the base row itself has to supply.
218
+ *
219
+ * Collected across field sections so the record can be fetched in one query
220
+ * rather than one per section — a page with six reference blocks should not
221
+ * be six round trips.
222
+ */
223
+ export function fieldPaths(template) {
224
+ const seen = new Set();
225
+ for (const section of template.sections) {
226
+ if (section.kind !== 'fields')
227
+ continue;
228
+ for (const path of section.paths)
229
+ seen.add(path);
230
+ }
231
+ return [...seen];
232
+ }
233
+ /** Whether a template still says anything, after resolution. */
234
+ export function isEmpty(template) {
235
+ return !template.sections.some((s) => s.kind === 'list' ? true : s.paths.length > 0);
236
+ }
237
+ /**
238
+ * Read the `layouts` section of a tablewalk.json.
239
+ *
240
+ * Throws on anything malformed rather than skipping it, naming the entry and
241
+ * the fix — the same rule `parsePagesConfig` follows, for the same reason: a
242
+ * layout that silently did not appear is a bug report that starts "I
243
+ * definitely wrote it".
244
+ *
245
+ * Structure only. Whether `customer_id` is a real column, or `invoice` a real
246
+ * table, needs a schema this runs long before — those are answered when the
247
+ * layout is served, and reported on the layout itself.
248
+ */
249
+ export function parseLayoutsConfig(raw, where) {
250
+ const layouts = raw?.layouts;
251
+ if (layouts === undefined)
252
+ return [];
253
+ if (!Array.isArray(layouts))
254
+ throw new Error(`${where}: "layouts" must be an array.`);
255
+ const seen = new Set();
256
+ return layouts.map((entry, i) => {
257
+ const at = `${where}: layout ${i + 1}`;
258
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
259
+ throw new Error(`${at} is not an object.`);
260
+ }
261
+ const l = entry;
262
+ if (typeof l.table !== 'string' || !l.table.trim()) {
263
+ throw new Error(`${at} has no "table" — a layout lays out one table's records.`);
264
+ }
265
+ const table = l.table.trim();
266
+ if (l.connection !== undefined && typeof l.connection !== 'string') {
267
+ throw new Error(`${at} ("${table}") has a "connection" that is not a string.`);
268
+ }
269
+ if (l.name !== undefined && typeof l.name !== 'string') {
270
+ throw new Error(`${at} ("${table}") has a "name" that is not a string.`);
271
+ }
272
+ /* One per table per connection: the store this fills is keyed that way,
273
+ so a second would silently win over the first. */
274
+ const key = `${l.connection ?? ''}::${table}`;
275
+ if (seen.has(key))
276
+ throw new Error(`${at} repeats a layout for "${table}".`);
277
+ seen.add(key);
278
+ if (!Array.isArray(l.sections) || !l.sections.length) {
279
+ throw new Error(`${at} ("${table}") has no "sections".`);
280
+ }
281
+ const sections = l.sections.map((raw2, j) => {
282
+ const sat = `${at} ("${table}"), section ${j + 1}`;
283
+ if (!raw2 || typeof raw2 !== 'object' || Array.isArray(raw2)) {
284
+ throw new Error(`${sat} is not an object.`);
285
+ }
286
+ const s = raw2;
287
+ if (s.kind !== 'fields' && s.kind !== 'list') {
288
+ throw new Error(`${sat} has "kind": ${JSON.stringify(s.kind)} — it must be "fields" or "list".`);
289
+ }
290
+ if (s.title !== undefined && typeof s.title !== 'string') {
291
+ throw new Error(`${sat} has a "title" that is not a string.`);
292
+ }
293
+ if (s.kind === 'fields') {
294
+ if (!Array.isArray(s.paths) || !s.paths.every((p) => typeof p === 'string' && p.trim())) {
295
+ throw new Error(`${sat} needs "paths": a list of column names or paths like customer_id.name.`);
296
+ }
297
+ return { kind: 'fields', ...(s.title ? { title: s.title } : {}), paths: s.paths.map((p) => String(p).trim()) };
298
+ }
299
+ /* A list says which relationship it shows, one way or the other. */
300
+ const hasVia = typeof s.via === 'string' && s.via.trim();
301
+ const hasPair = typeof s.from === 'string' && s.from.trim() && typeof s.path === 'string' && s.path.trim();
302
+ if (!hasVia && !hasPair) {
303
+ throw new Error(`${sat} needs either "via" (a foreign key constraint name) or "from" and "path" `
304
+ + `— the child table and the column of it that points back here.`);
305
+ }
306
+ if (s.columns !== undefined
307
+ && (!Array.isArray(s.columns) || !s.columns.every((c) => typeof c === 'string'))) {
308
+ throw new Error(`${sat} has "columns" that is not a list of column names.`);
309
+ }
310
+ if (s.limit !== undefined && (typeof s.limit !== 'number' || !Number.isInteger(s.limit) || s.limit < 1)) {
311
+ throw new Error(`${sat} has a "limit" that is not a whole number of rows.`);
312
+ }
313
+ if (s.orderBy !== undefined) {
314
+ if (!Array.isArray(s.orderBy))
315
+ throw new Error(`${sat} has an "orderBy" that is not an array.`);
316
+ for (const o of s.orderBy) {
317
+ const entry2 = o;
318
+ if (!entry2 || typeof entry2.column !== 'string') {
319
+ throw new Error(`${sat} has an "orderBy" entry with no "column".`);
320
+ }
321
+ if (entry2.direction !== undefined && entry2.direction !== 'asc' && entry2.direction !== 'desc') {
322
+ throw new Error(`${sat} has an "orderBy" direction that is not "asc" or "desc".`);
323
+ }
324
+ }
325
+ }
326
+ return {
327
+ kind: 'list',
328
+ ...(s.title ? { title: s.title } : {}),
329
+ ...(hasVia ? { via: String(s.via).trim() } : {}),
330
+ ...(hasPair ? { from: String(s.from).trim(), path: String(s.path).trim() } : {}),
331
+ columns: Array.isArray(s.columns) ? s.columns.map((c) => String(c)) : [],
332
+ ...(s.limit !== undefined ? { limit: s.limit } : {}),
333
+ ...(s.orderBy !== undefined ? { orderBy: s.orderBy } : {}),
334
+ };
335
+ });
336
+ return {
337
+ table,
338
+ ...(l.connection ? { connection: String(l.connection) } : {}),
339
+ ...(l.name ? { name: String(l.name) } : {}),
340
+ sections,
341
+ };
342
+ });
343
+ }