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,242 @@
1
+ /**
2
+ * The schema, taken out of tablewalk.
3
+ *
4
+ * A diagram you can only look at inside the tool is a diagram that never
5
+ * reaches the design review, the pull request, or the person who asked what
6
+ * this database looks like. The whole point of drawing it is to be able to
7
+ * hand it to someone.
8
+ *
9
+ * Four formats, because they answer four different questions:
10
+ *
11
+ * - **SVG** for the picture, at any size, still readable when someone zooms
12
+ * into the corner of it.
13
+ * - **PNG** for the places that will not take an SVG — a chat message, a
14
+ * slide, an issue comment.
15
+ * - **Mermaid** for a repository. GitHub renders it inline, so an ER diagram
16
+ * in a README stays a diagram and, more importantly, diffs as text when
17
+ * the schema changes.
18
+ * - **Markdown** and **JSON** for reading and for feeding to something else.
19
+ *
20
+ * All of them export *what is selected*. The diagram's focus filter already
21
+ * narrows sixty-six tables to the eight someone cares about, and an export
22
+ * that ignored that would hand back the hairball they had just finished
23
+ * escaping.
24
+ */
25
+ import { download } from './export.js';
26
+ import { selection, toMarkdown, toMermaid, toSchemaJson } from './schematext.js';
27
+
28
+ export { selection, toMarkdown, toMermaid, toSchemaJson };
29
+ import { disclosure, el, state, toast } from './core.js';
30
+
31
+
32
+ /* ---------- the picture ---------- */
33
+
34
+ /* Properties worth carrying into a standalone file. Everything the diagram's
35
+ appearance depends on and nothing else — a full computed-style dump would
36
+ inline several hundred declarations per node and produce a file too large to
37
+ open. */
38
+ const PAINTED = [
39
+ 'fill-opacity', 'stroke-width', 'stroke-dasharray',
40
+ 'font-family', 'font-size', 'font-weight', 'letter-spacing',
41
+ 'opacity', 'text-anchor', 'text-transform',
42
+ ];
43
+
44
+ /* Written even when the value is `none`, which the rest are not.
45
+ `fill: none` on an edge is not the absence of a fill — SVG's initial fill is
46
+ black, so dropping the declaration as a default turned every curve into a
47
+ solid black wedge and the diagram into an inkblot. */
48
+ const ALWAYS = ['fill', 'stroke'];
49
+
50
+ /**
51
+ * A copy of the drawing that stands on its own.
52
+ *
53
+ * The diagram on screen gets its colours from the page's stylesheet, so
54
+ * serialising the live SVG produces a file of black-on-transparent shapes —
55
+ * technically the same drawing and unrecognisable as it. Every painted
56
+ * property is read off the rendered element and written onto the clone, which
57
+ * is the only way to get a file that looks like what was on screen.
58
+ *
59
+ * The pan-and-zoom transform is dropped and the viewBox set to the drawing's
60
+ * own extent, so the export is the whole diagram rather than the part of it
61
+ * that happened to be in the window.
62
+ */
63
+ export function standaloneSvg(svg, size) {
64
+ const clone = svg.cloneNode(true);
65
+ const from = svg.querySelectorAll('*');
66
+ const to = clone.querySelectorAll('*');
67
+
68
+ for (let i = 0; i < from.length; i += 1) {
69
+ const computed = getComputedStyle(from[i]);
70
+ const declarations = [...ALWAYS, ...PAINTED]
71
+ .map((property) => {
72
+ const value = computed.getPropertyValue(property);
73
+ if (!value) return '';
74
+ if (ALWAYS.includes(property)) return `${property}:${value}`;
75
+ return value !== 'none' && value !== 'normal' ? `${property}:${value}` : '';
76
+ })
77
+ .filter(Boolean)
78
+ .join(';');
79
+ if (declarations) to[i].setAttribute('style', declarations);
80
+ }
81
+
82
+ const content = clone.querySelector('g');
83
+ if (content) content.removeAttribute('transform');
84
+ clone.setAttribute('viewBox', `0 0 ${Math.ceil(size.width)} ${Math.ceil(size.height)}`);
85
+ clone.setAttribute('width', String(Math.ceil(size.width)));
86
+ clone.setAttribute('height', String(Math.ceil(size.height)));
87
+ clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
88
+ clone.style.removeProperty('width');
89
+ clone.style.removeProperty('height');
90
+
91
+ /* An explicit background. SVG has none, so a diagram exported from a light
92
+ page and opened on a dark one is invisible ink. */
93
+ const page = getComputedStyle(document.body).backgroundColor;
94
+ const backing = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
95
+ backing.setAttribute('width', '100%');
96
+ backing.setAttribute('height', '100%');
97
+ backing.setAttribute('fill', page || '#ffffff');
98
+ clone.insertBefore(backing, clone.firstChild);
99
+
100
+ return new XMLSerializer().serializeToString(clone);
101
+ }
102
+
103
+ /**
104
+ * The same drawing, rasterised.
105
+ *
106
+ * At twice the size, because a PNG of a diagram is nearly always looked at on
107
+ * a display with more pixels than CSS says, and one rendered at 1× is the
108
+ * blurry screenshot this feature exists to replace.
109
+ */
110
+ export async function svgToPng(markup, size, scale = 2) {
111
+ const blob = new Blob([markup], { type: 'image/svg+xml;charset=utf-8' });
112
+ const url = URL.createObjectURL(blob);
113
+ try {
114
+ const image = await new Promise((resolve, reject) => {
115
+ const img = new Image();
116
+ img.onload = () => resolve(img);
117
+ img.onerror = () => reject(new Error('The diagram could not be rendered as an image.'));
118
+ img.src = url;
119
+ });
120
+ const canvas = document.createElement('canvas');
121
+ canvas.width = Math.ceil(size.width * scale);
122
+ canvas.height = Math.ceil(size.height * scale);
123
+ const context = canvas.getContext('2d');
124
+ context.drawImage(image, 0, 0, canvas.width, canvas.height);
125
+ return await new Promise((resolve) => canvas.toBlob(resolve, 'image/png'));
126
+ } finally {
127
+ URL.revokeObjectURL(url);
128
+ }
129
+ }
130
+
131
+ /* ---------- saving ---------- */
132
+
133
+ const stamp = () => new Date().toISOString().slice(0, 10);
134
+
135
+ function base(schema, only) {
136
+ const slug = String(schema.label ?? 'schema').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
137
+ /* The filename says it is a subset. A file called `erp-schema.md` holding
138
+ eight of sixty-five tables misleads whoever opens it next week, and they
139
+ have no way to tell. */
140
+ const part = only?.size ? `-${only.size}-tables` : '';
141
+ return `${slug || 'schema'}${part}-${stamp()}`;
142
+ }
143
+
144
+ export async function exportSchema(kind, { schema = state.schema, only, svg, size } = {}) {
145
+ const name = base(schema, only);
146
+ switch (kind) {
147
+ case 'markdown':
148
+ download(`${name}.md`, toMarkdown(schema, only), 'text/markdown');
149
+ return;
150
+ case 'json':
151
+ download(`${name}.json`, toSchemaJson(schema, only), 'application/json');
152
+ return;
153
+ case 'mermaid':
154
+ download(`${name}.mmd`, toMermaid(schema, only), 'text/plain');
155
+ return;
156
+ case 'svg': {
157
+ if (!svg) throw new Error('There is no diagram to export.');
158
+ download(`${name}.svg`, standaloneSvg(svg, size), 'image/svg+xml');
159
+ return;
160
+ }
161
+ case 'png': {
162
+ if (!svg) throw new Error('There is no diagram to export.');
163
+ const blob = await svgToPng(standaloneSvg(svg, size), size);
164
+ const url = URL.createObjectURL(blob);
165
+ const link = document.createElement('a');
166
+ link.href = url;
167
+ link.download = `${name}.png`;
168
+ link.click();
169
+ setTimeout(() => URL.revokeObjectURL(url), 0);
170
+ return;
171
+ }
172
+ default:
173
+ throw new Error(`Unknown export format: ${kind}`);
174
+ }
175
+ }
176
+
177
+ /* ---------- the control ---------- */
178
+
179
+ /* Deliberately the same `<details class="export">` chrome the result-table
180
+ Export uses. Two menus that do the same kind of thing in the same product
181
+ should not have to be learned twice. */
182
+
183
+ const FORMATS = [
184
+ ['svg', 'SVG', 'the drawing, any size'],
185
+ ['png', 'PNG', 'for chat and slides'],
186
+ ['mermaid', 'Mermaid', 'renders in a README'],
187
+ ['markdown', 'Markdown', 'columns and keys'],
188
+ ['json', 'JSON', 'the structured schema'],
189
+ ];
190
+
191
+ /**
192
+ * Export the diagram.
193
+ *
194
+ * `picture()` is called at click time rather than captured, because the
195
+ * diagram is rebuilt on every focus change and a reference taken when the
196
+ * control was built would export the schema as it looked two edits ago.
197
+ */
198
+ export function schemaExportControl({ schema = state.schema, only, picture } = {}) {
199
+ const menu = el('div', { class: 'export-menu' });
200
+ const wrap = disclosure(el('details', { class: 'export schema-export' }, [
201
+ el('summary', { class: 'export-summary', title: 'Export this schema' }, 'Export'),
202
+ menu,
203
+ ]));
204
+
205
+ const run = async (button, kind) => {
206
+ const was = button.textContent;
207
+ button.disabled = true;
208
+ button.textContent = 'Exporting…';
209
+ try {
210
+ await exportSchema(kind, { schema, only, ...(picture?.() ?? {}) });
211
+ wrap.open = false;
212
+ toast(`Exported ${only?.size ? `${only.size} tables` : 'the schema'} as ${kind.toUpperCase()}.`);
213
+ } catch (error) {
214
+ toast(error.message ?? 'The export failed.', 'error');
215
+ } finally {
216
+ button.disabled = false;
217
+ button.textContent = was;
218
+ }
219
+ };
220
+
221
+ /* Said once, at the top, rather than repeated on five buttons: whichever
222
+ format you pick, you get what is on screen. Someone who has narrowed
223
+ sixty-six tables to eight needs to know that before choosing, not after
224
+ opening the file. */
225
+ const scope = only?.size
226
+ ? `${only.size} table${only.size === 1 ? '' : 's'} — what the diagram is showing`
227
+ : `all ${schema.tables.length} tables`;
228
+ menu.append(el('p', { class: 'export-note export-scope', text: scope }));
229
+
230
+ for (const [kind, name, detail] of FORMATS) {
231
+ menu.append(el('button', {
232
+ type: 'button',
233
+ class: 'export-item',
234
+ onclick: (e) => void run(e.currentTarget, kind),
235
+ }, [
236
+ el('span', { class: 'export-kind', text: name }),
237
+ el('span', { class: 'export-count', text: detail }),
238
+ ]));
239
+ }
240
+
241
+ return wrap;
242
+ }
@@ -0,0 +1,125 @@
1
+ /**
2
+ * The schema as text — Mermaid, Markdown, JSON — with no DOM and no state.
3
+ *
4
+ * Split from schemaexport.js so the server can speak these formats too:
5
+ * `tablewalk --export md` prints the same brief the browser downloads, and a
6
+ * committed CLAUDE.md schema section costs one command. Pure on purpose, the
7
+ * way shape.js is pure — a schema in, text out, importable from anywhere.
8
+ */
9
+
10
+ /** The tables to export, and the keys wholly between them. */
11
+ export function selection(schema, only) {
12
+ const wanted = only?.size
13
+ ? schema.tables.filter((t) => only.has(t.id))
14
+ : schema.tables;
15
+ const ids = new Set(wanted.map((t) => t.id));
16
+ /* Only keys with both ends in the selection. A reference to a table that is
17
+ not in the export is an edge pointing at nothing, which renders as a
18
+ dangling arrow and imports as a broken constraint. */
19
+ const keys = schema.foreignKeys.filter((fk) => ids.has(fk.from.table) && ids.has(fk.to.table));
20
+ return { tables: wanted, keys };
21
+ }
22
+
23
+ /* ---------- text formats ---------- */
24
+
25
+ /**
26
+ * Mermaid `erDiagram`.
27
+ *
28
+ * Cardinality is stated as far as the catalog knows it, which is not very far:
29
+ * a foreign key says the child *may* point at one parent, and says nothing
30
+ * about whether a parent must have children. So every relationship is written
31
+ * `}o--||` — many-optional to exactly-one — which is what a nullable foreign
32
+ * key actually guarantees. Claiming more precision would be inventing it.
33
+ *
34
+ * Mermaid identifiers cannot contain a dot, so `app.invoice` becomes
35
+ * `app_invoice`. Said in a comment at the top of the output rather than
36
+ * silently, because someone comparing the diagram to their database will
37
+ * notice.
38
+ */
39
+ export function toMermaid(schema, only) {
40
+ const { tables, keys } = selection(schema, only);
41
+ const safe = (id) => id.replace(/[^\w]/g, '_');
42
+ const lines = ['erDiagram'];
43
+
44
+ for (const table of tables) {
45
+ lines.push(` ${safe(table.id)} {`);
46
+ for (const column of table.columns) {
47
+ const type = (column.type || 'unknown').replace(/[^\w]/g, '_') || 'unknown';
48
+ const marks = [column.primaryKey ? 'PK' : '', column.references ? 'FK' : '']
49
+ .filter(Boolean).join(',');
50
+ lines.push(` ${type} ${column.name}${marks ? ` ${marks}` : ''}`);
51
+ }
52
+ lines.push(' }');
53
+ }
54
+
55
+ for (const fk of keys) {
56
+ const label = fk.from.columns.join(', ');
57
+ lines.push(` ${safe(fk.to.table)} ||--o{ ${safe(fk.from.table)} : "${label}"`);
58
+ }
59
+
60
+ const renamed = tables.some((t) => safe(t.id) !== t.id);
61
+ return (renamed
62
+ ? '%% Names containing a dot are written with an underscore: Mermaid ids cannot hold one.\n'
63
+ : '') + lines.join('\n') + '\n';
64
+ }
65
+
66
+ /** A readable reference document, of the kind that belongs in a repository. */
67
+ export function toMarkdown(schema, only) {
68
+ const { tables, keys } = selection(schema, only);
69
+ const out = [`# ${schema.label}`, ''];
70
+ out.push(
71
+ `${tables.length} table${tables.length === 1 ? '' : 's'}, `
72
+ + `${keys.length} foreign key${keys.length === 1 ? '' : 's'}.`,
73
+ '',
74
+ );
75
+
76
+ for (const table of tables) {
77
+ out.push(`## ${table.id}${table.isView ? ' *(view)*' : ''}`, '');
78
+ if (table.comment) out.push(table.comment, '');
79
+
80
+ out.push('| Column | Type | | References |', '| --- | --- | --- | --- |');
81
+ for (const c of table.columns) {
82
+ const marks = [
83
+ c.primaryKey ? '**key**' : '',
84
+ c.nullable ? '' : 'required',
85
+ ].filter(Boolean).join(', ');
86
+ const ref = c.references ? `→ \`${c.references.table}.${c.references.column}\`` : '';
87
+ out.push(`| \`${c.name}\` | ${c.type} | ${marks} | ${ref} |`);
88
+ }
89
+ out.push('');
90
+
91
+ /* What points *at* this table, which is the half a schema document
92
+ usually leaves out and the half tablewalk exists to show. */
93
+ const incoming = keys.filter((fk) => fk.to.table === table.id);
94
+ if (incoming.length) {
95
+ out.push('Referenced by:', '');
96
+ for (const fk of incoming) {
97
+ out.push(`- \`${fk.from.table}\` via \`${fk.from.columns.join(', ')}\``
98
+ + (fk.onDelete ? ` — on delete ${fk.onDelete}` : ''));
99
+ }
100
+ out.push('');
101
+ }
102
+ }
103
+ return out.join('\n');
104
+ }
105
+
106
+ /**
107
+ * The structured form.
108
+ *
109
+ * Deliberately the shape tablewalk itself uses rather than a format invented
110
+ * for export — anything that reads it is reading the same model the tool
111
+ * reasons about, and there is no second definition to keep in step.
112
+ */
113
+ export function toSchemaJson(schema, only) {
114
+ const { tables, keys } = selection(schema, only);
115
+ return JSON.stringify({
116
+ label: schema.label,
117
+ dialect: schema.dialect,
118
+ readAt: schema.readAt,
119
+ exportedAt: new Date().toISOString(),
120
+ partial: Boolean(only?.size) && tables.length < schema.tables.length,
121
+ tables,
122
+ foreignKeys: keys,
123
+ }, null, 2);
124
+ }
125
+
@@ -0,0 +1,178 @@
1
+ /**
2
+ * What is in this database, answered from the catalog alone.
3
+ *
4
+ * The first minute with an unfamiliar schema is spent on questions the
5
+ * structure already answers: where does this thing centre, what are the tables
6
+ * I will actually query, how deep does it go, and what is that lump over there
7
+ * connected to nothing. The diagram shows all of it at once, which is a
8
+ * different thing from saying it — a picture of sixty-six tables answers "what
9
+ * is in here" only for someone who already knows.
10
+ *
11
+ * This is the non-model version of the feature the backlog files under LLM
12
+ * assistance, and it is the honest first version: everything below is derived
13
+ * from foreign keys that were introspected on connect, so it costs no network,
14
+ * no key, and cannot be wrong about a table it hallucinated.
15
+ *
16
+ * Pure on purpose — no DOM, no state, no imports. It takes a schema and returns
17
+ * a description, which is what makes it testable without a browser.
18
+ */
19
+
20
+ /** A table has to be pointed at by this many others before it is a hub. */
21
+ const HUB_MINIMUM = 2;
22
+
23
+ /** How much of a table has to be foreign keys before it is a join table. */
24
+ const JOIN_RATIO = 0.6;
25
+
26
+ /**
27
+ * Describe a schema's shape.
28
+ *
29
+ * @param {{tables: Array, foreignKeys: Array}} schema
30
+ * @returns {{
31
+ * counts: {tables: number, views: number, keys: number},
32
+ * hubs: Array<{table: object, incoming: number, from: string[]}>,
33
+ * leaves: object[],
34
+ * joins: Array<{table: object, connects: string[]}>,
35
+ * chain: object[],
36
+ * islands: object[],
37
+ * start: object | null,
38
+ * }}
39
+ */
40
+ export function describeSchema(schema) {
41
+ const tables = schema?.tables ?? [];
42
+ const keys = (schema?.foreignKeys ?? []).filter((fk) => fk.from.table !== fk.to.table);
43
+ const byId = new Map(tables.map((t) => [t.id, t]));
44
+
45
+ /* Counted as *distinct tables*, not constraints. `work_order` pointing at
46
+ `employee` three times — raised_by, assigned_to, approved_by — says one
47
+ thing about the shape, not three, and counting constraints would rank a
48
+ table by how many roles one neighbour plays in it. */
49
+ const incoming = new Map(tables.map((t) => [t.id, new Set()]));
50
+ const outgoing = new Map(tables.map((t) => [t.id, new Set()]));
51
+ for (const fk of keys) {
52
+ incoming.get(fk.to.table)?.add(fk.from.table);
53
+ outgoing.get(fk.from.table)?.add(fk.to.table);
54
+ }
55
+
56
+ const inCount = (t) => incoming.get(t.id)?.size ?? 0;
57
+ const outCount = (t) => outgoing.get(t.id)?.size ?? 0;
58
+
59
+ const real = tables.filter((t) => !t.isView);
60
+
61
+ const hubs = real
62
+ .filter((t) => inCount(t) >= HUB_MINIMUM)
63
+ .sort((a, b) => inCount(b) - inCount(a) || a.name.localeCompare(b.name))
64
+ .slice(0, 4)
65
+ .map((table) => ({
66
+ table,
67
+ incoming: inCount(table),
68
+ from: [...(incoming.get(table.id) ?? [])].map((id) => byId.get(id)?.name ?? id).sort(),
69
+ }));
70
+
71
+ /* Leaves point outwards and are pointed at by nothing, which is what a
72
+ transaction or an event table looks like: the thing that happened, hanging
73
+ off the things it happened to. They are usually what someone actually
74
+ wants to query, and they are the least visible in a diagram because they
75
+ sit at the edges. */
76
+ const leaves = real
77
+ .filter((t) => outCount(t) > 0 && inCount(t) === 0)
78
+ .sort((a, b) => outCount(b) - outCount(a) || a.name.localeCompare(b.name));
79
+
80
+ /* A join table is mostly foreign keys, points at two or more things, and —
81
+ the part that separates it from a lookup table — is pointed at by almost
82
+ nothing itself. A join table *connects* things; things do not connect to
83
+ it.
84
+
85
+ Without that last clause `country` qualifies: three of its five columns are
86
+ references, to currency, language and region. But half the schema points at
87
+ `country`, which makes it a dimension everyone shares rather than a bridge
88
+ between two tables, and calling it a join table would mislead about the
89
+ one thing this list exists to say. */
90
+ const joins = real
91
+ .filter((t) => {
92
+ const fkColumns = t.columns.filter((c) => c.references).length;
93
+ return outCount(t) >= 2 && inCount(t) <= 1 && t.columns.length > 0
94
+ && fkColumns / t.columns.length >= JOIN_RATIO;
95
+ })
96
+ .map((table) => ({
97
+ table,
98
+ connects: [...(outgoing.get(table.id) ?? [])].map((id) => byId.get(id)?.name ?? id).sort(),
99
+ }));
100
+
101
+ return {
102
+ counts: {
103
+ tables: real.length,
104
+ views: tables.length - real.length,
105
+ keys: schema?.foreignKeys?.length ?? 0,
106
+ },
107
+ hubs,
108
+ leaves,
109
+ joins,
110
+ chain: longestChain(tables, outgoing).map((id) => byId.get(id)).filter(Boolean),
111
+ islands: real.filter((t) => inCount(t) === 0 && outCount(t) === 0),
112
+ start: suggestStart(real, inCount, outCount),
113
+ };
114
+ }
115
+
116
+ /**
117
+ * The longest path of references, as the tables along it.
118
+ *
119
+ * The diagram computes the same *depth* to lay itself out, but a number is not
120
+ * the interesting part — "five levels deep" says less than "region → country →
121
+ * address → site → building → room", which is the shape of the domain written
122
+ * out. Recomputed here rather than shared because the diagram throws the path
123
+ * away and keeps only the count.
124
+ *
125
+ * A cycle is broken at the first table already on the path. A circular schema
126
+ * has no longest chain, and the alternative to stopping is not stopping.
127
+ */
128
+ function longestChain(tables, outgoing) {
129
+ const best = new Map();
130
+
131
+ const walk = (id, seen) => {
132
+ if (seen.has(id)) return [];
133
+ if (best.has(id)) return best.get(id);
134
+
135
+ seen.add(id);
136
+ let longest = [];
137
+ for (const next of outgoing.get(id) ?? []) {
138
+ const path = walk(next, seen);
139
+ if (path.length > longest.length) longest = path;
140
+ }
141
+ seen.delete(id);
142
+
143
+ const result = [id, ...longest];
144
+ /* Only memoised when the walk was not truncated by the cycle guard — a
145
+ path cut short by `seen` is an answer about this route, not about this
146
+ table, and caching it would poison every later route through it. */
147
+ if (!longest.some((t) => seen.has(t))) best.set(id, result);
148
+ return result;
149
+ };
150
+
151
+ let chain = [];
152
+ for (const t of tables) {
153
+ const path = walk(t.id, new Set());
154
+ if (path.length > chain.length) chain = path;
155
+ }
156
+ return chain.length > 1 ? chain : [];
157
+ }
158
+
159
+ /**
160
+ * Where to start reading.
161
+ *
162
+ * The table that connects to the most, with a readable name to recognise rows
163
+ * by. Both halves matter: a join table is highly connected and tells you
164
+ * nothing on its own, and a lookup table is readable and leads nowhere.
165
+ */
166
+ function suggestStart(tables, inCount, outCount) {
167
+ const scored = tables
168
+ .filter((t) => t.columns.some((c) => !c.primaryKey && !c.references))
169
+ .map((t) => ({
170
+ table: t,
171
+ // Outgoing weighted higher: a table you can walk *from* opens more of the
172
+ // schema than one that is merely popular.
173
+ score: outCount(t) * 2 + inCount(t),
174
+ }))
175
+ .filter((s) => s.score > 0)
176
+ .sort((a, b) => b.score - a.score || a.table.name.localeCompare(b.table.name));
177
+ return scored[0]?.table ?? null;
178
+ }
@@ -0,0 +1,129 @@
1
+ /**
2
+ * What this schema will cost you.
3
+ *
4
+ * The `lint` tool answers it for an agent and `--lint` answers it for CI, and
5
+ * the person actually looking at the database — the one who can *fix* the
6
+ * unindexed foreign key — had no way to ask. Every finding is a fact this
7
+ * tool already has on screen somewhere: the keys are in the diagram, the
8
+ * types are in the table view. What the check adds is the consequence, which
9
+ * is the part nobody reads a catalog to work out.
10
+ *
11
+ * It runs the same tool through the same endpoint an agent uses, for the same
12
+ * reason the value search does: two implementations of "what is wrong with
13
+ * this shape" would eventually disagree, and then one of them is lying.
14
+ */
15
+ import { api, el, findTable, go, tableView, trapFocus } from './core.js';
16
+
17
+ let panel = null;
18
+ /** Undoes the focus trap, and hands the keyboard back where it came from. */
19
+ let release = null;
20
+
21
+ const TONE = { high: 'high', medium: 'medium', low: 'low' };
22
+
23
+ export function openShapeCheck() {
24
+ if (panel) return;
25
+ panel = el('div', {
26
+ class: 'conn-manager shape-check',
27
+ role: 'dialog',
28
+ 'aria-modal': 'true',
29
+ 'aria-label': 'Check the shape',
30
+ });
31
+ document.body.append(panel);
32
+ release = trapFocus(panel);
33
+ document.addEventListener('keydown', onKey, true);
34
+ panel.replaceChildren(el('div', { class: 'conn-inner' }, [
35
+ el('p', { class: 'loading', text: 'Reading the catalog…' }),
36
+ ]));
37
+ void run();
38
+ }
39
+
40
+ /** Findings of one table, gathered under the rule they share. */
41
+ function byRule(list) {
42
+ const groups = new Map();
43
+ for (const finding of list) {
44
+ const found = groups.get(finding.rule) ?? [];
45
+ found.push(finding);
46
+ groups.set(finding.rule, found);
47
+ }
48
+ return [...groups];
49
+ }
50
+
51
+ function close() {
52
+ release?.();
53
+ release = null;
54
+ panel?.remove();
55
+ panel = null;
56
+ document.removeEventListener('keydown', onKey, true);
57
+ }
58
+
59
+ function onKey(e) {
60
+ if (e.key !== 'Escape' || !panel) return;
61
+ e.preventDefault();
62
+ close();
63
+ }
64
+
65
+ async function run() {
66
+ let answer;
67
+ try {
68
+ /* `all: true` because this is the panel, not an agent's first orientation.
69
+ The tool samples five findings per rule by default — right when the
70
+ reader is an agent spending context to find out what kind of trouble a
71
+ schema is in, wrong when it is a person who opened a list to read it. */
72
+ answer = await api('/api/agent/call', { name: 'lint', arguments: { all: true } });
73
+ } catch (err) {
74
+ if (panel) panel.replaceChildren(el('div', { class: 'conn-inner' }, el('p', { class: 'note', text: err.message })));
75
+ return;
76
+ }
77
+ if (!panel) return;
78
+ const result = answer.structuredContent ?? {};
79
+ const findings = result.findings ?? [];
80
+ /* What a rule costs is sent once now, keyed by rule, rather than repeated on
81
+ every finding — which is what this panel was already doing with it. */
82
+ const rules = result.rules ?? {};
83
+ const counts = result.counts ?? { high: 0, medium: 0, low: 0 };
84
+
85
+ const byTable = new Map();
86
+ for (const finding of findings) {
87
+ const list = byTable.get(finding.table) ?? [];
88
+ list.push(finding);
89
+ byTable.set(finding.table, list);
90
+ }
91
+
92
+ panel.replaceChildren(el('div', { class: 'conn-inner' }, [
93
+ el('div', { class: 'conn-head' }, [
94
+ el('h2', { text: 'Check the shape' }),
95
+ el('button', { type: 'button', class: 'ghost', text: '×', 'aria-label': 'Close', onclick: close }),
96
+ ]),
97
+ el('p', { class: 'find-note' }, [
98
+ findings.length
99
+ ? el('span', { text: `${counts.high} high, ${counts.medium} medium, ${counts.low} low — read from the catalog alone: no queries, no guesses about intent.` })
100
+ : el('span', { text: 'Nothing to report: no missing keys, unindexed references or naive timestamps.' }),
101
+ ]),
102
+ ...[...byTable].map(([table, list]) => el('div', { class: 'shape-check-group' }, [
103
+ el('button', {
104
+ type: 'button',
105
+ class: 'shape-check-table',
106
+ title: `Open ${table}`,
107
+ /* Every finding is about a table, and the useful next move is
108
+ looking at it — so the heading is the way there rather than a
109
+ label beside a link. */
110
+ onclick: () => { close(); go(tableView(findTable(table)?.id ?? table), 'reset'); },
111
+ }, [
112
+ el('span', { text: findTable(table)?.name ?? table }),
113
+ el('span', { class: 'shape-check-count', text: `${list.length}` }),
114
+ ]),
115
+ /* Grouped by rule, with the cost said once. Four findings of the same
116
+ rule are four different columns and one consequence, and printing
117
+ the consequence four times is how a list stops being read. */
118
+ ...byRule(list).map(([rule, found]) => el('div', {
119
+ class: `shape-check-rule-group tone-${TONE[found[0].severity] ?? 'low'}`,
120
+ }, [
121
+ el('p', { class: 'shape-check-why' }, [
122
+ el('span', { class: 'shape-check-rule', text: rule }),
123
+ el('span', { text: rules[rule]?.why ?? found[0].why ?? '' }),
124
+ ]),
125
+ ...found.map((finding) => el('p', { class: 'shape-check-item', text: finding.message })),
126
+ ])),
127
+ ])),
128
+ ]));
129
+ }