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,170 @@
1
+ /**
2
+ * The screen for someone who has nothing connected yet.
3
+ *
4
+ * Until now `tablewalk` with no database printed its usage and exited(1). The
5
+ * flags were all correct and it answered the wrong question: somebody who has
6
+ * just installed a thing called tablewalk to find out what it is gets a wall
7
+ * of options and no program. Worse, the tool already owned the answer — a
8
+ * dialog that takes a connection string or its parts, tests it before keeping
9
+ * it, and resolves the password from the environment or the keychain — and it
10
+ * was one successful startup out of reach.
11
+ *
12
+ * So the server starts with nothing and this asks. Three things it is careful
13
+ * about:
14
+ *
15
+ * - It does not pretend to be the app. The query bar, the table list and
16
+ * the write toggle are all controls over a database, and leaving them on
17
+ * screen greyed or live-but-broken is an interface saying "try me" about
18
+ * things that cannot work yet. They are hidden until there is something
19
+ * for them to act on.
20
+ *
21
+ * - It says what the tool is before asking for credentials. "Paste your
22
+ * production connection string" is a large request from a program that
23
+ * has not yet said what it does with it, so the read-only default and the
24
+ * fact that nothing is written to disk are on the screen making the ask,
25
+ * not in a README.
26
+ *
27
+ * - It reloads rather than booting a second way. Once a connection exists,
28
+ * the ordinary start path is the one that has every other feature wired
29
+ * into it; assembling half of it again here is how the two would drift.
30
+ * A reload of a local page is imperceptible and cannot disagree with
31
+ * itself.
32
+ */
33
+ import { $, api, el } from './core.js';
34
+ import { addConnection, openConnections } from './connmanager.js';
35
+
36
+ /** The shapes a target can take, kept in the order they are likely to be met. */
37
+ const EXAMPLES = [
38
+ { label: 'PostgreSQL', text: 'postgres://user@localhost:5432/appdb' },
39
+ { label: 'MySQL or MariaDB', text: 'mysql://user@localhost:3306/appdb' },
40
+ { label: 'SQLite', text: './data/app.db' },
41
+ ];
42
+
43
+ /**
44
+ * What the server has, for the screen that runs when none of it opened.
45
+ *
46
+ * Returns the list, not a yes/no. "Is there anything?" was the wrong
47
+ * question: a first connection that fails to open is listed and does not
48
+ * open, so the answer was yes and the welcome screen was skipped — leaving
49
+ * "Could not read the schema: No connection is open." on screen with nothing
50
+ * to click. The right question is what is there, so the screen can say.
51
+ */
52
+ export async function listConnections() {
53
+ try {
54
+ const data = await api('/api/connections');
55
+ return data.connections ?? [];
56
+ } catch {
57
+ return [];
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Draw it.
63
+ *
64
+ * Nothing is initialised beyond this point on purpose — no palette, no
65
+ * shortcuts, no undo. Every one of them reads `state.schema`, and there
66
+ * isn't one.
67
+ */
68
+ export function showWelcome(listed = []) {
69
+ document.body.classList.add('first-run');
70
+ document.title = 'tablewalk';
71
+ $('db-label').textContent = 'No database';
72
+
73
+ const button = el('button', {
74
+ type: 'button',
75
+ class: 'welcome-go',
76
+ text: 'Connect a database',
77
+ onclick: () => addConnection(() => window.location.reload()),
78
+ });
79
+
80
+ /* Connections that exist and would not open.
81
+
82
+ Without this the screen said "nothing connected" over a list of things
83
+ that were connected-ish and broken, and offered adding another as the
84
+ only move — when the useful move is almost always to fix or remove the
85
+ one that failed. It is the whole reason this screen is reachable with a
86
+ non-empty list at all. */
87
+ const broken = listed.filter((c) => c.error);
88
+ const trouble = broken.length ? el('div', { class: 'welcome-trouble' }, [
89
+ el('p', { class: 'welcome-trouble-head' }, [
90
+ el('strong', {
91
+ text: broken.length === 1
92
+ ? 'One connection is configured, and it did not open.'
93
+ : `${broken.length} connections are configured, and none of them opened.`,
94
+ }),
95
+ ]),
96
+ el('ul', {}, broken.map((c) => el('li', {}, [
97
+ el('span', { class: 'welcome-trouble-name', text: c.name }),
98
+ el('span', { class: 'welcome-trouble-why', text: c.error }),
99
+ ]))),
100
+ el('button', {
101
+ type: 'button',
102
+ class: 'ghost',
103
+ text: 'Open connections',
104
+ onclick: () => void openConnections(),
105
+ }),
106
+ ]) : null;
107
+
108
+ $('content').replaceChildren(el('div', { class: 'welcome' }, [
109
+ /* The promise, not a status report.
110
+
111
+ This read "Nothing connected yet." — accurate, and the software's point
112
+ of view rather than the reader's: the first sentence somebody meets
113
+ named an absence at the moment they wanted to know what they had found.
114
+ The absence is already on the screen twice over, in the top bar and in
115
+ the one button, so the heading does not have to carry it.
116
+
117
+ It is the README's own line, and deliberately. The wordmark is in the
118
+ top bar directly above, which makes this the same pairing the front
119
+ page opens with — a product whose first screen and first paragraph say
120
+ different things about what it is for has two pitches, and one of them
121
+ is wrong. */
122
+ el('h1', { text: 'Follow the data.' }),
123
+ el('p', { class: 'welcome-lede' }, [
124
+ /* Two plain sentences with an example in them.
125
+
126
+ This was "A database is a graph, and this walks it: open a row and
127
+ you get every row elsewhere that points at it, without writing a join
128
+ to reach them. Give it something to read." — an abstract reframe, a
129
+ colon, a clause defending against an objection nobody had raised
130
+ yet, and a closing instruction for a button that is four inches
131
+ below and already says the same thing. Naming two real rows does more
132
+ than any of that. */
133
+ document.createTextNode('Open any row and you see what points at it. '
134
+ + 'The orders on a customer, the customer on an order.'),
135
+ ]),
136
+ button,
137
+ trouble,
138
+ el('p', { class: 'welcome-assure' }, [
139
+ /* The two facts most likely to be someone's actual hesitation, said
140
+ before the box that asks for a password rather than after. */
141
+ el('strong', { text: 'Read only.' }),
142
+ document.createTextNode(' You turn editing on yourself, per connection. '
143
+ + 'Connections you add here are gone when you quit, unless you save them to a file.'),
144
+ ]),
145
+ el('div', { class: 'welcome-examples' }, [
146
+ el('h2', { text: 'What to point it at' }),
147
+ el('ul', {}, EXAMPLES.map((e) => el('li', {}, [
148
+ el('span', { class: 'welcome-ex-label', text: e.label }),
149
+ el('code', { text: e.text }),
150
+ ]))),
151
+ el('p', { class: 'note' }, [
152
+ document.createTextNode('The password does not have to be in the string. Write '),
153
+ el('code', { text: '${DB_PASSWORD}' }),
154
+ document.createTextNode(' and it comes from the environment, or leave it out and keep it in '
155
+ + 'the keychain. There is a Test button either way.'),
156
+ ]),
157
+ el('p', { class: 'note' }, [
158
+ document.createTextNode('To skip this next time, put them in a '),
159
+ el('code', { text: 'tablewalk.json' }),
160
+ document.createTextNode(' where you start tablewalk:'),
161
+ ]),
162
+ el('pre', {
163
+ class: 'welcome-config',
164
+ text: '{\n "connections": [\n { "name": "app", "url": "postgres://localhost/appdb" }\n ]\n}',
165
+ }),
166
+ ]),
167
+ ]));
168
+
169
+ button.focus();
170
+ }
@@ -0,0 +1,414 @@
1
+ /**
2
+ * Write mode.
3
+ *
4
+ * Read is the default and stays the default: the connection the browsing path
5
+ * uses is opened read-only and never changes, so nothing you do while looking
6
+ * around can write, even after the toggle is on. Editing goes through a
7
+ * separate connection that the server opens lazily, the first time it is
8
+ * actually asked to write.
9
+ *
10
+ * The toggle is deliberately not sticky. It lives in memory for the session
11
+ * and resets on reload, because a mode that silently persists is a mode you
12
+ * forget you are in — and the whole risk of an editor is a change you did not
13
+ * realise you were making.
14
+ *
15
+ * The real guard is on the server, not here. This file makes the state
16
+ * obvious and asks for confirmation; the server independently refuses any
17
+ * update whose WHERE clause does not match exactly one row.
18
+ */
19
+ import { $, api, el, loadJson, render, rowLabel, saveJson, state, toast } from './core.js';
20
+ import { planColumn } from './columns.js';
21
+ import { refreshUndo, undoLast } from './undo.js';
22
+
23
+ /* Write mode is remembered per connection.
24
+ Per connection, not globally: switching from the demo to production must
25
+ not silently inherit permission to edit it. Each database is opted into
26
+ separately, and the choice sticks until it is turned off. */
27
+ const WRITE_KEY = 'tablewalk.write.v1';
28
+
29
+ const connectionKey = () => state.activeConnection ?? 'default';
30
+
31
+ function rememberedWrite() {
32
+ return loadJson(WRITE_KEY, {})[connectionKey()] === true;
33
+ }
34
+
35
+ function rememberWrite(on) {
36
+ const all = loadJson(WRITE_KEY, {});
37
+ if (on) all[connectionKey()] = true;
38
+ else delete all[connectionKey()];
39
+ saveJson(WRITE_KEY, all);
40
+ }
41
+
42
+ export function initWriteMode() {
43
+ const toggle = $('write-toggle');
44
+ toggle.addEventListener('click', () => {
45
+ if (state.canWrite) {
46
+ setWriteMode(false);
47
+ return;
48
+ }
49
+ confirmWrite().then((ok) => ok && setWriteMode(true));
50
+ });
51
+ // Restored without asking again: the confirmation was given for this
52
+ // database and has not been withdrawn.
53
+ setWriteMode(rememberedWrite(), { remember: false });
54
+ }
55
+
56
+ /** Re-evaluate on connection switch, since the setting is per database. */
57
+ export function syncWriteMode() {
58
+ setWriteMode(rememberedWrite(), { remember: false });
59
+ }
60
+
61
+ function setWriteMode(on, { remember = true } = {}) {
62
+ state.canWrite = on;
63
+ if (remember) rememberWrite(on);
64
+ const toggle = $('write-toggle');
65
+ toggle.setAttribute('aria-pressed', String(on));
66
+ /* An icon, but not a quiet one.
67
+
68
+ Write mode is the one state in this header worth noticing without being
69
+ looked for, so the two states are drawn as opposites rather than as two
70
+ similar glyphs: a closed padlock, dim and unfilled, against an open one
71
+ that is filled and coloured. The label goes to assistive technology
72
+ rather than disappearing — an icon-only control still has to say what it
73
+ is. */
74
+ toggle.textContent = on ? '🔓' : '🔒';
75
+ toggle.setAttribute('aria-label', on ? 'Write mode — click to return to read only' : 'Read only — click to enable editing');
76
+ toggle.title = on
77
+ ? 'Editing is enabled. Click to return to read only.'
78
+ : 'Browsing only. Click to enable editing.';
79
+ /* The indicator is on <body> rather than the button, so it can colour the
80
+ window frame. Being in write mode should be visible from across the room,
81
+ not something you have to go looking for. */
82
+ document.body.classList.toggle('write-mode', on);
83
+ // Leaving write mode drops anything staged: pending edits you can no longer
84
+ // see or apply are worse than no pending edits.
85
+ if (!on) discardStaged();
86
+ render();
87
+ }
88
+
89
+ /**
90
+ * A real confirmation, naming the database. "Are you sure?" teaches nothing;
91
+ * the name of the thing you are about to be able to change does.
92
+ */
93
+ function confirmWrite() {
94
+ return new Promise((resolve) => {
95
+ const dialog = el('dialog', { class: 'confirm' }, [
96
+ el('h2', { text: 'Enable write mode?' }),
97
+ el('p', {}, [
98
+ document.createTextNode('You will be able to edit rows in '),
99
+ el('strong', { text: state.schema.label }),
100
+ document.createTextNode('. Changes go straight to the database and cannot be undone from here.'),
101
+ ]),
102
+ el('ul', { class: 'confirm-terms' }, [
103
+ el('li', { text: 'Only rows with a primary key can be edited — one row, one column at a time.' }),
104
+ el('li', { text: 'Every change is shown as SQL before it runs.' }),
105
+ el('li', { text: 'An update that would touch more than one row is refused.' }),
106
+ el('li', { text: 'Write mode stays on for this database until you turn it off, including after a reload.' }),
107
+ el('li', { text: 'It applies to this database only — switching connections does not carry it over.' }),
108
+ ]),
109
+ el('div', { class: 'confirm-actions' }, [
110
+ el('button', { type: 'button', class: 'ghost', text: 'Stay read only', onclick: () => { dialog.close(); resolve(false); } }),
111
+ el('button', { type: 'button', class: 'danger', text: 'Enable write mode', onclick: () => { dialog.close(); resolve(true); } }),
112
+ ]),
113
+ ]);
114
+ dialog.addEventListener('close', () => dialog.remove());
115
+ dialog.addEventListener('cancel', () => resolve(false));
116
+ document.body.append(dialog);
117
+ dialog.showModal();
118
+ });
119
+ }
120
+
121
+ /* ---------- staged edits ----------
122
+
123
+ An edit does not go to the database when you leave the field. It is held
124
+ until you apply, so several fields become one statement — three edits as
125
+ three statements means three chances to fail half way, leaving a row in a
126
+ state nobody asked for.
127
+
128
+ Staging is keyed by table and row, so walking away and back does not
129
+ silently carry pending edits onto a different row. */
130
+
131
+ let staged = { connection: null, table: null, key: null, values: {}, originals: {}, inputs: {} };
132
+
133
+ export function pendingCount() {
134
+ return Object.keys(staged.values).length;
135
+ }
136
+
137
+ /**
138
+ * Whether what is staged belongs to the row on screen.
139
+ *
140
+ * The connection is part of the answer. Keyed by table and key alone, an edit
141
+ * staged against staging's `customer:4` showed as pending on production's
142
+ * `customer:4` — the same table name and the same id, a different database,
143
+ * and one click from being written to the wrong one.
144
+ */
145
+ function sameRow(table, key) {
146
+ return staged.connection === state.activeConnection
147
+ && staged.table === table
148
+ && JSON.stringify(staged.key) === JSON.stringify(key);
149
+ }
150
+
151
+ export function discardStaged() {
152
+ staged = { connection: null, table: null, key: null, values: {}, originals: {}, inputs: {} };
153
+ }
154
+
155
+ function stage(table, key, column, value, original) {
156
+ if (!sameRow(table.id, key)) {
157
+ staged = {
158
+ connection: state.activeConnection, table: table.id, key,
159
+ values: {}, originals: {}, inputs: {},
160
+ };
161
+ }
162
+ // Typing a value and then typing the original back is not a change. Keeping
163
+ // it staged would show "1 change" for an edit that changes nothing.
164
+ if (String(value) === String(original) || (value === null && original === null)) {
165
+ delete staged.values[column.name];
166
+ delete staged.originals[column.name];
167
+ } else {
168
+ staged.values[column.name] = value;
169
+ staged.originals[column.name] = original;
170
+ }
171
+ }
172
+
173
+ /**
174
+ * The two values a boolean column actually holds, or null if it is not one.
175
+ *
176
+ * MySQL has no boolean — BOOL is a spelling of TINYINT(1) — and SQLite keeps
177
+ * 0 and 1 in an INTEGER, so the pair depends on the column rather than on a
178
+ * type name. Written back exactly as it was read, which is what stops an
179
+ * edit to a *different* field of the same row from rewriting `1` to `true`
180
+ * on the way past.
181
+ */
182
+ function booleanChoices(column, row) {
183
+ /* The grid's own rule, not a second one: a column drawn as ✓/✗ has to edit
184
+ as yes/no, or the same value is a tick in one view and a `0` to type in
185
+ the other. That rule is more than the declared type — MySQL has no
186
+ boolean and SQLite keeps flags in INTEGER, so a name like `is_urgent`
187
+ holding only 0 and 1 counts. */
188
+ const plan = planColumn(column, column?.name ?? '', row ? [row] : []);
189
+ if (plan.kind !== 'boolean') return null;
190
+ return /bool/i.test(column?.type ?? '') ? { yes: 'true', no: 'false' } : { yes: '1', no: '0' };
191
+ }
192
+
193
+ /**
194
+ * Turn a field into an editor. Called from the record view, and only when
195
+ * write mode is on and the row has a usable key.
196
+ */
197
+ export function editable(table, row, column, key, onSaved) {
198
+ const value = row[column.name];
199
+ const original = value === null || value === undefined ? null : String(value);
200
+ const stagedValue = sameRow(table.id, key) ? staged.values[column.name] : undefined;
201
+ const shown = stagedValue !== undefined ? stagedValue : original;
202
+
203
+ /* A box you type `0` or `1` into is not an editor for a column that holds
204
+ two values. The choices are the values the database stores — `1` and `0`
205
+ where the column is an integer flag, `true` and `false` where it is a
206
+ real boolean — so what is written is what was already there, and the
207
+ empty option is NULL rather than a third state. */
208
+ const choices = booleanChoices(column, row);
209
+ const input = choices
210
+ ? el('select', {
211
+ class: `cell-edit${stagedValue !== undefined ? ' dirty' : ''}`,
212
+ 'aria-label': `${column.name} (${column.type})`,
213
+ }, [
214
+ ...(column.nullable ? [el('option', { value: '', text: 'null' })] : []),
215
+ el('option', { value: choices.yes, text: 'yes' }),
216
+ el('option', { value: choices.no, text: 'no' }),
217
+ ])
218
+ : el('input', {
219
+ class: `cell-edit${stagedValue !== undefined ? ' dirty' : ''}`,
220
+ /* Named by its column. A record page is a grid of unlabelled text boxes
221
+ to anything that cannot see the `dt` beside each one, and "edit field"
222
+ repeated forty times is no better than nothing. */
223
+ 'aria-label': `${column.name} (${column.type})`,
224
+ type: 'text',
225
+ value: shown === null ? '' : String(shown),
226
+ 'data-null': String(shown === null),
227
+ 'aria-label': `${column.name} (${column.type})`,
228
+ /* Where the database pins the vocabulary, say it — but still a text box.
229
+ A column that gained a CHECK after the rows were written holds values
230
+ the check would refuse, and a select would silently rewrite one of
231
+ them the moment someone edited a different field of that row. */
232
+ title: column.allowed?.length ? `one of: ${column.allowed.join(', ')}` : undefined,
233
+ });
234
+
235
+ if (choices) input.value = shown === null ? '' : String(shown);
236
+
237
+ const onEdit = () => {
238
+ // An empty box on a nullable column means NULL, not the empty string.
239
+ // They are different values and conflating them loses data silently.
240
+ const next = input.value === '' && column.nullable ? null : input.value;
241
+ stage(table, key, column, next, original);
242
+ input.classList.toggle('dirty', staged.values[column.name] !== undefined);
243
+ onSaved?.();
244
+ };
245
+
246
+ // Kept so a successful write can confirm in place rather than re-render.
247
+ if (!sameRow(table.id, key)) staged = { table: table.id, key, values: {}, originals: {}, inputs: {} };
248
+ staged.inputs[column.name] = input;
249
+
250
+ /* `input` only, deliberately: a `change` listener fires on blur, which is
251
+ the moment someone clicks "Review & apply" — the re-render it triggered
252
+ detached the button mid-click and the confirmation never opened. Selects
253
+ fire `input` too, so nothing is lost. */
254
+ input.addEventListener('input', onEdit);
255
+ input.addEventListener('keydown', (e) => {
256
+ if (e.key === 'Enter') {
257
+ e.preventDefault();
258
+ if (pendingCount()) void applyStaged(table, key, onSaved);
259
+ }
260
+ if (e.key === 'Escape') {
261
+ input.value = original === null ? '' : original;
262
+ onEdit();
263
+ }
264
+ });
265
+ return input;
266
+ }
267
+
268
+ /**
269
+ * The pending-changes bar. Rendered by the record view above the fields, so
270
+ * the fact that edits are held rather than saved is impossible to miss.
271
+ */
272
+ export function pendingBar(table, key, onDone) {
273
+ if (!sameRow(table.id, key) || !pendingCount()) return null;
274
+ const n = pendingCount();
275
+ return el('div', { class: 'pending' }, [
276
+ el('span', { class: 'pending-count', text: `${n} unsaved change${n === 1 ? '' : 's'}` }),
277
+ el('span', { class: 'pending-cols', text: Object.keys(staged.values).join(', ') }),
278
+ el('button', {
279
+ type: 'button', class: 'ghost', text: 'Discard',
280
+ onclick: () => { discardStaged(); onDone?.(); },
281
+ }),
282
+ el('button', {
283
+ type: 'button', class: 'danger', text: `Review & apply`,
284
+ onclick: () => void applyStaged(table, key, onDone),
285
+ }),
286
+ ]);
287
+ }
288
+
289
+ async function applyStaged(table, key, onDone) {
290
+ if (!pendingCount()) return;
291
+ const values = { ...staged.values };
292
+ const originals = { ...staged.originals };
293
+ const ok = await confirmUpdate(table, key, values, originals);
294
+ if (!ok) return;
295
+ try {
296
+ const result = await api('/api/update', { table: table.id, key, values });
297
+ const n = Object.keys(result.applied).length;
298
+
299
+ /* Recorded before the toast, so the offer to undo cannot appear for a
300
+ write the history does not have. */
301
+ /* The server journalled the write as it landed; this just re-reads what
302
+ there is to undo, so the menu and ⌘Z are right immediately. */
303
+ void refreshUndo();
304
+ toast(`Updated ${n} column${n === 1 ? '' : 's'} on ${table.name}.`, 'ok', {
305
+ /* Beside the confirmation, because this is the moment someone realises
306
+ it was the wrong row — and sending them to a menu to find Undo wastes
307
+ the seconds in which they still remember what they changed. */
308
+ label: 'Undo',
309
+ run: () => void undoLast().then(() => render()),
310
+ });
311
+
312
+ /* Confirmed in place rather than by re-rendering. A re-render would throw
313
+ away the row you are looking at and rebuild it, which reads as a flicker
314
+ and loses the caret; worse, it left the fields showing their unsaved
315
+ styling because the DOM they styled was gone before the class was.
316
+ Showing the saved value, in green, on the field you just edited is both
317
+ calmer and more specific — it says *which* fields landed. */
318
+ const inputs = staged.inputs;
319
+ confirmSaved(inputs, result.applied);
320
+ discardStaged();
321
+ // Only the pending bar is refreshed, so the green fields survive.
322
+ onDone?.({ inPlace: true });
323
+ } catch (err) {
324
+ // Nothing is discarded on failure: the edits stay staged so they can be
325
+ // corrected rather than retyped.
326
+ toast(err.message, 'error');
327
+ }
328
+ }
329
+
330
+ /** How long the green confirmation stays before fading. */
331
+ const SAVED_MS = 2200;
332
+
333
+ function confirmSaved(inputs, applied) {
334
+ for (const [column, value] of Object.entries(applied)) {
335
+ const input = inputs[column];
336
+ if (!input || !input.isConnected) continue;
337
+ // The value the database actually stored, which may differ from what was
338
+ // typed — "17901" comes back as the number 17901.
339
+ input.value = value === null ? '' : String(value);
340
+ input.dataset.null = String(value === null);
341
+ input.classList.remove('dirty');
342
+ input.classList.add('saved');
343
+ setTimeout(() => input.classList.remove('saved'), SAVED_MS);
344
+ }
345
+ }
346
+
347
+ /** Show the statement before it runs. This is the last stop before a write. */
348
+ function confirmUpdate(table, key, values, originals) {
349
+ return new Promise((resolve) => {
350
+ const q = (id) => `"${id}"`;
351
+ const sets = Object.keys(values).map((c) => `${q(c)} = ?`).join(',\n ');
352
+ const where = Object.keys(key).map((c) => `${q(c)} = ?`).join(' AND ');
353
+ const sql = `UPDATE ${q(table.id)}\n SET ${sets}\n WHERE ${where};`;
354
+
355
+ /* The parameters shown are the raw staged values. The server coerces them
356
+ against the declared column types and returns what it applied, so a
357
+ number typed as text is corrected there rather than guessed at here. */
358
+ const params = JSON.stringify([...Object.values(values), ...Object.values(key)]);
359
+
360
+ const rows = Object.entries(values).map(([column, to]) => {
361
+ const from = originals[column];
362
+ const type = table.columns.find((c) => c.name === column)?.type ?? '';
363
+ return el('tr', {}, [
364
+ el('td', {}, [
365
+ el('span', { class: 'diff-col', text: column }),
366
+ el('span', { class: 'diff-type', text: type.toLowerCase() }),
367
+ ]),
368
+ el('td', { class: `diff-from${from === null ? ' null' : ''}`, text: from === null ? 'null' : String(from) }),
369
+ el('td', { class: 'diff-arrow', text: '→' }),
370
+ el('td', { class: `diff-to${to === null ? ' null' : ''}`, text: to === null ? 'null' : String(to) }),
371
+ ]);
372
+ });
373
+
374
+ const dialog = el('dialog', { class: 'confirm confirm-wide' }, [
375
+ el('h2', { text: rows.length === 1 ? 'Apply this change?' : `Apply ${rows.length} changes?` }),
376
+ el('p', {}, [
377
+ document.createTextNode('One row in '),
378
+ el('strong', { text: table.id }),
379
+ document.createTextNode(', identified by '),
380
+ el('strong', { text: Object.entries(key).map(([k, v]) => `${k} = ${v}`).join(', ') }),
381
+ document.createTextNode('. Applied as a single statement.'),
382
+ ]),
383
+ el('table', { class: 'confirm-diff' }, el('tbody', {}, rows)),
384
+ el('pre', { class: 'ddl', text: sql }),
385
+ el('p', { class: 'confirm-params', text: `parameters ${params}` }),
386
+ el('div', { class: 'confirm-actions' }, [
387
+ el('button', { type: 'button', class: 'ghost', text: 'Cancel', onclick: () => { dialog.close(); resolve(false); } }),
388
+ el('button', { type: 'button', class: 'danger', text: 'Apply', onclick: () => { dialog.close(); resolve(true); } }),
389
+ ]),
390
+ ]);
391
+ dialog.addEventListener('close', () => dialog.remove());
392
+ dialog.addEventListener('cancel', () => resolve(false));
393
+ document.body.append(dialog);
394
+ dialog.showModal();
395
+ });
396
+ }
397
+
398
+ /** True when this row can be edited at all: write mode on, and a full key. */
399
+ export function canEditRow(table, key) {
400
+ if (!state.canWrite || !table || table.isView) return false;
401
+ const pk = table.columns.filter((c) => c.primaryKey).map((c) => c.name);
402
+ return pk.length > 0 && pk.every((c) => key[c] !== undefined && key[c] !== null);
403
+ }
404
+
405
+ /** Why a row is not editable, for the record view to explain rather than hide. */
406
+ export function whyNotEditable(table) {
407
+ if (!state.canWrite) return null; // not in write mode: say nothing
408
+ if (!table) return 'Unknown table.';
409
+ if (table.isView) return 'This is a view. Views are read-only.';
410
+ if (!table.columns.some((c) => c.primaryKey)) {
411
+ return 'This table has no primary key, so there is no way to identify one row safely.';
412
+ }
413
+ return null;
414
+ }