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,139 @@
1
+ /**
2
+ * Placeholders shaped like the thing that is coming.
3
+ *
4
+ * "Running…" tells you the tool is alive and nothing else. A placeholder that
5
+ * matches the layout about to replace it does more: the page stops jumping
6
+ * when the data lands, because the space was already the right size and shape,
7
+ * and the eye has somewhere to be while it waits.
8
+ *
9
+ * Two rules keep this from being decoration:
10
+ *
11
+ * - **Nothing appears immediately.** Most queries here finish in single-digit
12
+ * milliseconds, and a skeleton that flashes for 40ms is worse than no
13
+ * skeleton — it reads as a glitch. Placeholders are mounted only if the
14
+ * work is still going after a delay long enough that the wait was real.
15
+ * - **The shape is derived, not invented.** The grid placeholder uses the
16
+ * table's own column count and the page size; the record placeholder uses
17
+ * the record's own layout. A generic three-bar shimmer would be a lie about
18
+ * what is loading, and would still make the page jump when it resolved.
19
+ */
20
+ import { el } from './core.js';
21
+
22
+ /**
23
+ * How long a wait has to last before it is worth showing anything.
24
+ *
25
+ * Under this, the result lands before a reader would register the placeholder,
26
+ * and showing one turns a fast response into a visible flicker.
27
+ */
28
+ const DELAY = 140;
29
+
30
+ /**
31
+ * Mount a placeholder into `host`, unless the work finishes first.
32
+ *
33
+ * Returns a function to call when it does. Calling it before the delay
34
+ * elapses means nothing is ever inserted; after, it removes what was.
35
+ *
36
+ * @param {Element} host
37
+ * @param {() => Element} build called lazily, so a placeholder that is never
38
+ * shown costs nothing to construct
39
+ */
40
+ export function placeholder(host, build) {
41
+ let node = null;
42
+ const timer = setTimeout(() => {
43
+ node = build();
44
+ host.append(node);
45
+ }, DELAY);
46
+
47
+ return () => {
48
+ clearTimeout(timer);
49
+ node?.remove();
50
+ };
51
+ }
52
+
53
+ /** One shimmering block. Width is a percentage so rows look uneven, as text is. */
54
+ function bar(width, { height = 12 } = {}) {
55
+ return el('span', {
56
+ class: 'sk-bar',
57
+ style: `width:${width}%; height:${height}px`,
58
+ 'aria-hidden': 'true',
59
+ });
60
+ }
61
+
62
+ /* Deterministic per position rather than random, so a placeholder does not
63
+ reshuffle itself if it is ever rebuilt, and two adjacent rows never come out
64
+ identical — which is what makes a block of bars read as text rather than as
65
+ a bar chart. */
66
+ const widths = [82, 54, 71, 45, 63, 88, 38, 76, 59, 67];
67
+ const widthAt = (i) => widths[i % widths.length];
68
+
69
+ /**
70
+ * A grid, before its rows arrive.
71
+ *
72
+ * @param {number} columns how many columns the table has
73
+ * @param {number} rows how many rows the page will hold
74
+ */
75
+ export function gridSkeleton(columns, rows) {
76
+ // Bounded: a forty-column table does not need forty placeholder bars to
77
+ // communicate "a table is coming", and the real grid scrolls sideways anyway.
78
+ const across = Math.min(Math.max(columns, 3), 8);
79
+ const down = Math.min(Math.max(rows, 3), 12);
80
+
81
+ const grid = el('div', {
82
+ class: 'sk-grid',
83
+ style: `--sk-cols:${across}`,
84
+ role: 'status',
85
+ 'aria-label': 'Loading rows',
86
+ });
87
+
88
+ grid.append(el('div', { class: 'sk-row sk-head' },
89
+ Array.from({ length: across }, (_, i) => bar(40 + ((i * 17) % 30), { height: 8 }))));
90
+
91
+ for (let r = 0; r < down; r += 1) {
92
+ grid.append(el('div', { class: 'sk-row' },
93
+ Array.from({ length: across }, (_, c) => bar(widthAt(r * across + c)))));
94
+ }
95
+ return grid;
96
+ }
97
+
98
+ /**
99
+ * A record, before its fields arrive.
100
+ *
101
+ * Takes the section shapes from the layout that is about to be rendered, so
102
+ * the headings land where the headings will be and the two-column field grid
103
+ * is already the right height.
104
+ *
105
+ * @param {Array<{title?: string, count: number}>} sections
106
+ */
107
+ export function recordSkeleton(sections) {
108
+ const wrap = el('div', { class: 'sk-record', role: 'status', 'aria-label': 'Loading record' });
109
+
110
+ for (const section of sections) {
111
+ const block = el('section', { class: 'sk-section' });
112
+ if (section.title) block.append(bar(14, { height: 9 }));
113
+ const list = el('div', { class: 'sk-fields' });
114
+ for (let i = 0; i < section.count; i += 1) {
115
+ list.append(el('div', { class: 'sk-field' }, [
116
+ bar(38, { height: 10 }),
117
+ bar(widthAt(i), { height: 12 }),
118
+ ]));
119
+ }
120
+ block.append(list);
121
+ wrap.append(block);
122
+ }
123
+ return wrap;
124
+ }
125
+
126
+ /** The Related list, before its counts arrive. */
127
+ export function relatedSkeleton(count) {
128
+ const wrap = el('div', { class: 'sk-related', role: 'status', 'aria-label': 'Counting' });
129
+ for (let i = 0; i < Math.min(Math.max(count, 1), 8); i += 1) {
130
+ wrap.append(el('div', { class: 'sk-related-item' }, [
131
+ bar(100, { height: 14 }),
132
+ el('span', { class: 'sk-related-names' }, [
133
+ bar(widthAt(i), { height: 11 }),
134
+ bar(widthAt(i + 3) * 0.7, { height: 8 }),
135
+ ]),
136
+ ]));
137
+ }
138
+ return wrap;
139
+ }
@@ -0,0 +1,126 @@
1
+ /* The SQL editor.
2
+
3
+ A panel rather than a page: you write a statement against something you were
4
+ already looking at, and losing that context to a full-screen editor makes
5
+ every trip to SQL a trip away from the walk. It sits over the bottom of the
6
+ window, tall enough to read a result in and short enough that the page it
7
+ came from is still there behind it. */
8
+
9
+ .sql-panel {
10
+ position: fixed; left: 0; right: 0; bottom: 0; z-index: 75;
11
+ display: flex; flex-direction: column;
12
+ max-height: 78vh;
13
+ background: var(--surface);
14
+ border-top: 1px solid var(--border-strong);
15
+ box-shadow: 0 -12px 40px rgb(0 0 0 / 0.16);
16
+ }
17
+
18
+ .sql-head {
19
+ display: flex; align-items: center; gap: 14px;
20
+ padding: 10px 16px;
21
+ border-bottom: 1px solid var(--border);
22
+ flex: none;
23
+ }
24
+ .sql-title { display: flex; align-items: baseline; gap: 10px; }
25
+ .sql-eyebrow {
26
+ font: 600 12px var(--mono); color: var(--accent);
27
+ text-transform: uppercase; letter-spacing: 0.08em;
28
+ }
29
+ .sql-target { font: 11px var(--mono); color: var(--text-faint); }
30
+ .sql-head-actions { margin-left: auto; display: flex; gap: 8px; }
31
+ .sql-write { color: var(--danger); }
32
+ .sql-write:hover { border-color: var(--danger); background: color-mix(in srgb, var(--danger) 10%, transparent); }
33
+
34
+ .sql-editor { padding: 12px 16px; flex: none; position: relative; }
35
+ .sql-input {
36
+ display: block; width: 100%; resize: none;
37
+ padding: 10px 12px;
38
+ border: 1px solid var(--border); border-radius: 7px;
39
+ background: var(--bg); color: var(--text);
40
+ font: 13px/1.55 var(--mono);
41
+ tab-size: 2;
42
+ }
43
+ .sql-input:focus { outline: none; border-color: var(--accent); }
44
+ .sql-input::placeholder { color: var(--text-faint); }
45
+
46
+ .sql-results { overflow: auto; flex: 1; min-height: 0; padding-bottom: 12px; }
47
+
48
+ .sql-stats {
49
+ display: flex; gap: 14px; align-items: baseline;
50
+ padding: 2px 16px 8px;
51
+ font: 11px var(--mono); color: var(--text-faint);
52
+ font-variant-numeric: tabular-nums;
53
+ }
54
+ /* Says which kind of "there is more" this is: a page of a bounded read, or the
55
+ front of an answer the database computed in full. */
56
+ .sql-truncated { color: var(--key); }
57
+
58
+ .sql-error {
59
+ margin: 0 16px; padding: 10px 12px;
60
+ border-radius: 6px;
61
+ background: color-mix(in srgb, var(--danger) 10%, transparent);
62
+ border: 1px solid color-mix(in srgb, var(--danger) 40%, transparent);
63
+ color: var(--danger);
64
+ font: 12px/1.5 var(--mono);
65
+ white-space: pre-wrap;
66
+ }
67
+
68
+ /* Said before the grid, because a row that opens a record should say so before
69
+ it is clicked rather than after. */
70
+ .sql-walk {
71
+ margin: 0 16px 6px; font: 11px var(--mono); color: var(--accent);
72
+ }
73
+
74
+ /* Anchored to the caret rather than the field — see placeAtCaret. */
75
+ .ac-mirror {
76
+ position: absolute; visibility: hidden; pointer-events: none;
77
+ top: 0; left: -9999px;
78
+ overflow-wrap: break-word;
79
+ }
80
+
81
+
82
+ /* The statements you have kept. One row, config and personal together — to
83
+ whoever is looking for one they are the same thing, and the difference is
84
+ only whether it can be deleted. */
85
+ .sql-saved {
86
+ display: flex; gap: 6px; flex-wrap: wrap;
87
+ padding: 0 16px 4px;
88
+ flex: none;
89
+ }
90
+ .sql-chip {
91
+ display: inline-flex; align-items: center;
92
+ border: 1px solid var(--border); border-radius: 999px;
93
+ background: var(--bg);
94
+ overflow: hidden;
95
+ }
96
+ .sql-chip:hover { border-color: var(--accent); }
97
+ .sql-chip-open {
98
+ padding: 3px 10px;
99
+ background: none; border: none;
100
+ color: var(--text-dim); cursor: pointer;
101
+ font: 11.5px var(--mono);
102
+ }
103
+ .sql-chip:hover .sql-chip-open { color: var(--accent); }
104
+ .sql-chip-remove {
105
+ padding: 3px 8px 3px 2px;
106
+ background: none; border: none;
107
+ color: var(--text-faint); cursor: pointer; font-size: 12px; line-height: 1;
108
+ }
109
+ .sql-chip-remove:hover { color: var(--danger); }
110
+ /* Not a button: a config statement is not this browser's to delete, and an ×
111
+ that refuses is worse than no ×. */
112
+ .sql-chip-tag {
113
+ padding: 2px 8px 2px 2px;
114
+ color: var(--text-faint);
115
+ font: 9px var(--mono); text-transform: uppercase; letter-spacing: 0.05em;
116
+ }
117
+
118
+ /* An empty result knows its shape, and saying so is the difference between
119
+ "your statement returned nothing" and "something went wrong". */
120
+ .sql-empty {
121
+ margin: 0 16px; padding: 10px 12px;
122
+ border: 1px solid var(--border); border-radius: 6px;
123
+ background: var(--surface-2);
124
+ font: 12px/1.6 var(--mono); color: var(--text-dim);
125
+ }
126
+ .sql-empty strong { color: var(--text); }
@@ -0,0 +1,398 @@
1
+ /**
2
+ * The SQL editor.
3
+ *
4
+ * tablewalk's argument against a SQL box is in `src/shared/query.ts`: a tool
5
+ * that lets people type SQL "would have to either trust them or write a SQL
6
+ * parser to untrust them". That is a good argument for why the *query language*
7
+ * exists and a bad reason to have no SQL at all — every language has a last
8
+ * mile, and for this one it is window functions, recursive CTEs and `EXPLAIN`.
9
+ * Without a SQL box that last mile means leaving for `psql`, and once you have
10
+ * left you do not come back for the walk.
11
+ *
12
+ * The dilemma is dissolved rather than answered: the statement is not trusted
13
+ * *or* parsed, it is handed to a connection that cannot write. See `runSql` in
14
+ * adapters/adapter.ts for what that costs and what it buys.
15
+ *
16
+ * Two ways in, on purpose. A statement typed from scratch is what people expect
17
+ * from a database browser. The other is the ramp: run something in the query
18
+ * language, open the SQL pane, and edit what it compiled to. That turns the
19
+ * pane from a thing you read into a thing you start from, and it is the only
20
+ * place in the tool that teaches the SQL behind the language.
21
+ */
22
+ import { $, api, el, explainedSql, go, render, state, toast, whenConnectionChanges } from './core.js';
23
+ import { attachComplete } from './complete.js';
24
+ import { sqlSuggestions, tokenAt } from './sqlcomplete.js';
25
+ import { renderGrid } from './table.js';
26
+ import { addMenuItem } from './menu.js';
27
+ import { promptFor } from './prompt.js';
28
+ import { removeStatement, saveStatement, savedStatements } from './sqlsaved.js';
29
+
30
+ /** The statement being edited, per connection, so switching back keeps it. */
31
+ const drafts = new Map();
32
+
33
+ const connectionKey = () => state.activeConnection ?? 'default';
34
+
35
+ let panel = null;
36
+
37
+ export const sqlOpen = () => Boolean(panel);
38
+
39
+ export function openSql(initial) {
40
+ if (panel) {
41
+ if (initial !== undefined) setText(initial);
42
+ panel.querySelector('.sql-input')?.focus();
43
+ return;
44
+ }
45
+ if (initial !== undefined) drafts.set(connectionKey(), initial);
46
+ paint();
47
+ }
48
+
49
+ function setText(text) {
50
+ drafts.set(connectionKey(), text);
51
+ const input = panel?.querySelector('.sql-input');
52
+ if (input) {
53
+ input.value = text;
54
+ autoGrow(input);
55
+ }
56
+ }
57
+
58
+ function close() {
59
+ panel?.remove();
60
+ panel = null;
61
+ document.removeEventListener('keydown', onKey, true);
62
+ }
63
+
64
+ function onKey(e) {
65
+ if (e.key !== 'Escape') return;
66
+ // The completion list eats Escape first; this only fires when it is closed.
67
+ if (document.querySelector('.ac:not([hidden])')) return;
68
+ const tag = document.activeElement?.tagName;
69
+ if (tag === 'TEXTAREA' || tag === 'INPUT') { document.activeElement.blur(); return; }
70
+ e.preventDefault();
71
+ close();
72
+ }
73
+
74
+ /* A textarea that grows with its content, to a point. A fixed-height box makes
75
+ a six-line statement a two-line window onto a six-line statement; an
76
+ unbounded one pushes the results off the screen. */
77
+ function autoGrow(input) {
78
+ input.style.height = 'auto';
79
+ input.style.height = `${Math.min(Math.max(input.scrollHeight, 84), 340)}px`;
80
+ }
81
+
82
+ function paint() {
83
+ const existing = panel;
84
+ panel = el('div', { class: 'sql-panel', role: 'dialog', 'aria-label': 'SQL' });
85
+
86
+ const writable = state.connections?.find((c) => c.id === state.activeConnection)?.writable;
87
+
88
+ const input = el('textarea', {
89
+ class: 'sql-input',
90
+ spellcheck: 'false',
91
+ autocapitalize: 'off',
92
+ autocomplete: 'off',
93
+ 'aria-label': 'SQL statement',
94
+ placeholder: 'select * from customer where credit_limit > 5000',
95
+ });
96
+ input.value = drafts.get(connectionKey()) ?? '';
97
+
98
+ const results = el('div', { class: 'sql-results' });
99
+
100
+ const run = (write = false) => void execute(input.value, results, { write });
101
+
102
+ input.addEventListener('input', () => {
103
+ drafts.set(connectionKey(), input.value);
104
+ autoGrow(input);
105
+ });
106
+ input.addEventListener('keydown', (e) => {
107
+ // ⌘↵ runs. Plain Enter is a newline: this is a text editor, and a box where
108
+ // Enter submits is a box you cannot write two lines in.
109
+ if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
110
+ e.preventDefault();
111
+ run();
112
+ }
113
+ });
114
+
115
+ const head = el('div', { class: 'sql-head' }, [
116
+ el('div', { class: 'sql-title' }, [
117
+ el('span', { class: 'sql-eyebrow', text: 'SQL' }),
118
+ el('span', {
119
+ class: 'sql-target',
120
+ text: `${state.schema?.label ?? ''} · ${state.schema?.dialect ?? ''}`,
121
+ }),
122
+ ]),
123
+ el('div', { class: 'sql-head-actions' }, [
124
+ /* Offered only where the server would allow it. A button that exists to
125
+ be refused is a button that lies. */
126
+ writable
127
+ ? el('button', {
128
+ type: 'button', class: 'ghost sql-write',
129
+ title: 'Run this statement on the write connection',
130
+ text: 'Run as write…',
131
+ onclick: () => void confirmWrite(input.value, results),
132
+ })
133
+ : null,
134
+ el('button', {
135
+ type: 'button', class: 'ghost', text: 'Save…',
136
+ title: 'Keep this statement under a name',
137
+ onclick: () => void save(input.value),
138
+ }),
139
+ el('button', { type: 'button', text: 'Run ⌘↵', onclick: () => run() }),
140
+ el('button', { type: 'button', class: 'ghost', text: 'Done', onclick: close }),
141
+ ].filter(Boolean)),
142
+ ]);
143
+
144
+ panel.append(head, savedBar(), el('div', { class: 'sql-editor' }, input), results);
145
+ document.body.append(panel);
146
+ existing?.remove();
147
+
148
+ attachComplete(input, sqlSuggestions, { anchor: 'caret', split: tokenAt });
149
+ document.addEventListener('keydown', onKey, true);
150
+
151
+ autoGrow(input);
152
+ input.focus();
153
+ input.setSelectionRange(input.value.length, input.value.length);
154
+ }
155
+
156
+ /**
157
+ * The statements you have kept, as one row above the editor.
158
+ *
159
+ * Config-defined and browser-saved together, because to whoever is looking for
160
+ * one they are the same thing — the difference is only whether it can be
161
+ * deleted, which is why only one of them offers an ×.
162
+ */
163
+ function savedBar() {
164
+ const all = savedStatements();
165
+ if (!all.length) return el('div', { hidden: true });
166
+
167
+ const bar = el('div', { class: 'sql-saved' });
168
+ for (const item of all) {
169
+ bar.append(el('span', { class: `sql-chip source-${item.source}` }, [
170
+ el('button', {
171
+ type: 'button',
172
+ class: 'sql-chip-open',
173
+ title: item.description ?? item.sql,
174
+ text: item.name,
175
+ onclick: () => setText(item.sql),
176
+ }),
177
+ item.source === 'saved'
178
+ ? el('button', {
179
+ type: 'button', class: 'sql-chip-remove',
180
+ title: `Forget "${item.name}"`,
181
+ 'aria-label': `Forget ${item.name}`,
182
+ text: '×',
183
+ onclick: () => { removeStatement(item.name); paint(); },
184
+ })
185
+ : el('span', { class: 'sql-chip-tag', title: 'Defined in tablewalk.json', text: 'config' }),
186
+ ]));
187
+ }
188
+ return bar;
189
+ }
190
+
191
+ async function save(sql) {
192
+ const statement = sql.trim();
193
+ if (!statement) return;
194
+ const name = await promptFor({
195
+ title: 'Name this statement',
196
+ label: 'Name',
197
+ hint: statement.length > 120 ? `${statement.slice(0, 120)}…` : statement,
198
+ placeholder: 'e.g. Customers over their limit',
199
+ });
200
+ if (!name) return;
201
+ if (saveStatement(name, statement)) {
202
+ toast(`Saved "${name}".`, 'ok');
203
+ paint();
204
+ }
205
+ }
206
+
207
+ /**
208
+ * Run a statement and render whatever comes back.
209
+ *
210
+ * Errors land beside the editor rather than as a toast that disappears: a SQL
211
+ * error is something you read while fixing the statement it came from, and the
212
+ * database's own wording is kept because any paraphrase says less.
213
+ */
214
+ async function execute(text, results, { write = false } = {}) {
215
+ const statement = text.trim();
216
+ if (!statement) return;
217
+
218
+ results.replaceChildren(el('p', { class: 'loading', text: 'Running…' }));
219
+ let data;
220
+ try {
221
+ data = await api('/api/sql', { text: statement, limit: 500, write });
222
+ } catch (err) {
223
+ results.replaceChildren(el('p', { class: 'sql-error', text: err.message }));
224
+ return;
225
+ }
226
+
227
+ if (data.errors?.length) {
228
+ results.replaceChildren(el('p', { class: 'sql-error', text: data.errors[0].message }));
229
+ return;
230
+ }
231
+
232
+ if (write) {
233
+ toast(`${data.affected} row${data.affected === 1 ? '' : 's'} changed.`, 'ok');
234
+ results.replaceChildren(
235
+ el('p', { class: 'note', text: `${data.affected} row${data.affected === 1 ? '' : 's'} changed.` }),
236
+ );
237
+ // The schema and every open page may now be stale.
238
+ render();
239
+ return;
240
+ }
241
+
242
+ results.replaceChildren(...[
243
+ el('div', { class: 'sql-stats' }, [
244
+ el('span', { text: `${data.rows.length} row${data.rows.length === 1 ? '' : 's'}` }),
245
+ el('span', { text: `${data.ms} ms` }),
246
+ /* Said rather than implied. `bounded: false` means the database ran the
247
+ whole statement and we are showing the front of the answer — which is
248
+ a different fact from "there are more rows", and the reader should not
249
+ have to infer which one they are looking at. */
250
+ data.truncated
251
+ ? el('span', {
252
+ class: 'sql-truncated',
253
+ text: data.bounded
254
+ ? `first ${data.rows.length} — add a LIMIT to see a different page`
255
+ : `first ${data.rows.length} of what the database returned`,
256
+ })
257
+ : null,
258
+ ].filter(Boolean)),
259
+ grid(data),
260
+ ]);
261
+ }
262
+
263
+ /**
264
+ * Rows as a grid, walkable when the columns can identify a row.
265
+ *
266
+ * A result whose columns include every part of some table's primary key can be
267
+ * walked, which is what stops SQL being a dead end: the answer feeds back into
268
+ * the thing the tool is for. When it cannot, the rows are inert *and look it* —
269
+ * `renderGrid` is given no click handler, so nothing offers a click that does
270
+ * nothing.
271
+ */
272
+ function grid(data) {
273
+ const target = walkableTable(data.columns);
274
+ /* `renderGrid` reads rows as objects keyed by column name; the wire format is
275
+ positional so duplicate names survive the trip. Rebuilt here with the
276
+ deduplicated names, which is the last point both are in hand. */
277
+ const rows = data.rows.map((row) =>
278
+ Object.fromEntries(data.columns.map((name, i) => [name, row[i]])),
279
+ );
280
+
281
+ const wrap = el('div');
282
+
283
+ /* An empty result still knows its shape, and saying so is the difference
284
+ between "your statement returned nothing" and "something went wrong".
285
+ The adapters go out of their way to report columns for a result with no
286
+ rows; the shared grid returns "No rows match." before it looks at them,
287
+ which is right for a table view and throws away the useful half here. */
288
+ if (!data.rows.length) {
289
+ wrap.append(
290
+ el('p', { class: 'sql-empty' }, [
291
+ el('strong', { text: 'No rows.' }),
292
+ el('span', {
293
+ text: data.columns.length
294
+ ? ` The statement returned these columns: ${data.columns.join(', ')}.`
295
+ : ' The statement returned no columns either.',
296
+ }),
297
+ ]),
298
+ );
299
+ return wrap;
300
+ }
301
+
302
+ if (target) {
303
+ wrap.append(el('p', { class: 'sql-walk', text: `Rows open as ${target.name} records.` }));
304
+ }
305
+ wrap.append(
306
+ renderGrid(target, { columns: data.columns, rows }, {
307
+ onRowClick: target ? (row) => openRecord(target, row) : undefined,
308
+ }),
309
+ );
310
+ return wrap;
311
+ }
312
+
313
+ /** The table whose rows these are, if the columns can say. */
314
+ function walkableTable(columns) {
315
+ const present = new Set(columns);
316
+ const candidates = (state.schema?.tables ?? []).filter((t) => {
317
+ const key = t.columns.filter((c) => c.primaryKey).map((c) => c.name);
318
+ return key.length > 0 && key.every((c) => present.has(c));
319
+ });
320
+ if (!candidates.length) return null;
321
+ /* Several tables can share a key shape — `id` is not distinctive. The one
322
+ whose other columns also appear is the one the statement is about. */
323
+ return candidates.sort((a, b) => overlap(b, present) - overlap(a, present))[0];
324
+ }
325
+
326
+ const overlap = (table, present) => table.columns.filter((c) => present.has(c.name)).length;
327
+
328
+ function openRecord(table, row) {
329
+ const key = {};
330
+ for (const column of table.columns.filter((c) => c.primaryKey)) key[column.name] = row[column.name];
331
+ close();
332
+ go({ kind: 'row', table: table.id, key, label: String(Object.values(key).join('/')) }, 'reset');
333
+ }
334
+
335
+ /**
336
+ * A write, with the statement in front of you.
337
+ *
338
+ * The confirm shows the exact text rather than asking "are you sure": the
339
+ * question worth answering is not whether you meant to press the button, it is
340
+ * whether the statement says what you think it says.
341
+ */
342
+ async function confirmWrite(text, results) {
343
+ const statement = text.trim();
344
+ if (!statement) return;
345
+
346
+ const dialog = el('dialog', { class: 'confirm' }, [
347
+ el('h2', { text: 'Run this as a write?' }),
348
+ el('p', { text: 'This runs on the write connection. It is not undoable from here.' }),
349
+ el('pre', { class: 'confirm-params', text: statement }),
350
+ el('div', { class: 'confirm-actions' }, [
351
+ el('button', { type: 'button', class: 'ghost', text: 'Cancel', onclick: () => dialog.close() }),
352
+ el('button', {
353
+ type: 'button', class: 'danger', text: 'Run as write',
354
+ onclick: () => { dialog.close(); void execute(statement, results, { write: true }); },
355
+ }),
356
+ ]),
357
+ ]);
358
+ document.body.append(dialog);
359
+ dialog.addEventListener('close', () => dialog.remove());
360
+ dialog.showModal();
361
+ }
362
+
363
+ export function initSql() {
364
+ /* The ramp.
365
+
366
+ The SQL pane has always shown what the query language compiled to, and it
367
+ has always been a `<pre>` — something to read. Making it a starting point
368
+ costs one button and changes what the pane is for: you can now build a
369
+ query the easy way, see the statement, and take it over at the point the
370
+ language runs out. That is the shortest path from knowing the language to
371
+ knowing the database, and nothing else in the tool offers it. */
372
+ $('explain-edit')?.addEventListener('click', () => openSql(explainedSql()));
373
+
374
+ /* An open editor follows the database it is switched to.
375
+
376
+ The panel is built entirely from state — which connection it names, which
377
+ dialect, whether "Run as write…" is offered, and which draft is in the
378
+ box — and it was built once. Left open across a switch it kept the head
379
+ it started with: it went on naming the previous database while the
380
+ statement it ran went to the new one, and it kept offering a write on a
381
+ connection the server would refuse. `paint()` already replaces an
382
+ existing panel, so following the switch is one call. */
383
+ whenConnectionChanges(() => {
384
+ if (panel) paint();
385
+ });
386
+
387
+ addMenuItem({
388
+ label: 'SQL…',
389
+ detail: 'write a statement (⌘⇧↵)',
390
+ onSelect: () => openSql(),
391
+ });
392
+
393
+ document.addEventListener('keydown', (e) => {
394
+ if (e.key !== 'Enter' || !(e.metaKey || e.ctrlKey) || !e.shiftKey) return;
395
+ e.preventDefault();
396
+ openSql();
397
+ }, true);
398
+ }