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,270 @@
1
+ /**
2
+ * Deep links: the pure half.
3
+ *
4
+ * No DOM and no state, so it can be tested directly. The stateful half —
5
+ * reading `window.location`, deciding when to write it — lives in url.js.
6
+ *
7
+ * The design decision that matters: **the trail goes in the URL, not just the
8
+ * current position.** A link to a row you reached by walking should arrive
9
+ * with the walk intact, so whoever you send it to can go back the way you
10
+ * came. That is the tool's whole idea, and a link that dropped it would hand
11
+ * them a dead end.
12
+ *
13
+ * The hash is used rather than the path because tablewalk serves a static
14
+ * client from a small server with no router. A path-based URL would need the
15
+ * server to know every route; a hash never reaches it.
16
+ *
17
+ * Compactness matters, because these get pasted into chat. A table view is
18
+ * its query text, already short and human-readable; a row is its key:
19
+ *
20
+ * /demo/customer/~4/invoice%20customer_id%20=%204/~1004
21
+ */
22
+
23
+ /** A table view. Mirrors core.js — kept here so this module imports nothing. */
24
+ /* The tabs a table view can be on — see the tab strip in table.js. A segment
25
+ ends in one of these or it does not end in a tab at all. */
26
+ const TABS = new Set(['rows', 'ddl', 'diagram']);
27
+
28
+ /**
29
+ * A query, safe to put after a `!` separator.
30
+ *
31
+ * `encodeURIComponent` leaves `!` alone — it is an unreserved character — so
32
+ * a query containing `!=` wrote a second separator into its own segment.
33
+ */
34
+ function encodeQuery(query) {
35
+ return encodeURIComponent(query).replace(/!/g, '%21');
36
+ }
37
+
38
+ /**
39
+ * A key, as it appears in a link.
40
+ *
41
+ * Single-column keys — nearly all of them — are just the value, so the common
42
+ * case stays readable; composite keys name their columns, because position
43
+ * alone would not survive a column being added.
44
+ *
45
+ * Shared by rows and pages so the two cannot spell the same key differently.
46
+ */
47
+ function keyBody(key) {
48
+ const columns = Object.keys(key);
49
+ return columns.length === 1
50
+ ? String(key[columns[0]])
51
+ : columns.map((c) => `${c}=${key[c]}`).join(',');
52
+ }
53
+
54
+ function tableView(tableId, query, tab) {
55
+ return { kind: 'table', table: tableId, query: query ?? tableId, tab: tab ?? 'rows' };
56
+ }
57
+
58
+ /**
59
+ * Serialise a connection id and a stack into a hash.
60
+ *
61
+ * `pageToken` turns a page's id into the text a link should carry — its name,
62
+ * where that names it unambiguously. Passed in rather than imported: this
63
+ * module deliberately depends on nothing, which is what lets the link format
64
+ * be tested without a browser, and the page list it would need to consult
65
+ * lives three modules away. The default writes the id, which is what every
66
+ * link did before and still resolves.
67
+ */
68
+ export function hashFor(connectionId, stack, { pageToken = (id) => id } = {}) {
69
+ const parts = [encodeURIComponent(connectionId ?? '')];
70
+ stack.forEach((entry, index) => {
71
+ if (entry.kind === 'page') {
72
+ /* `@page-id` for the page, and the root — when one has been chosen —
73
+ written exactly as a row's key is, so the two agree about composite
74
+ keys instead of inventing a second spelling for them. A page with no
75
+ root is a link to the front door, which is a real place to send
76
+ someone: "open the customer page and pick one". */
77
+ /* `rootBody` is the unresolved form a link arrives with, and it is
78
+ written back when the key has not been resolved yet. Boot renders —
79
+ and stamps the hash — before the page has looked up which column a
80
+ bare key belongs to, so without this the first paint dropped the root
81
+ out of the address bar and the reader lost it by reloading. */
82
+ const body = entry.key ? keyBody(entry.key) : (entry.rootBody ?? '');
83
+ /* The open tab rides with the page rather than after the key: a key is
84
+ arbitrary text and may contain a `!`, while a page token is a slug or
85
+ an id and never does. "Look at the parts tab" is a thing people send
86
+ each other, and a link that always lands on the first tab loses the
87
+ point of sending it. */
88
+ const tab = entry.tab ? `!${encodeURIComponent(entry.tab)}` : '';
89
+ parts.push(`@${encodeURIComponent(pageToken(entry.pageId))}${tab}${body ? `~${encodeURIComponent(body)}` : ''}`);
90
+ return;
91
+ }
92
+ if (entry.kind === 'table' || entry.kind === 'breakdown') {
93
+ /* A breakdown carries no tab — it has none — and is otherwise the same
94
+ segment as a table query, because it is the same language. Which kind
95
+ it comes back as is decided by `isBreakdown` in `fromHash`. */
96
+ const tab = entry.kind === 'table' && entry.tab && entry.tab !== 'rows' ? `!${entry.tab}` : '';
97
+ parts.push(encodeQuery(entry.query) + tab);
98
+ } else {
99
+ /* A row is `~` plus its key. Single-column keys — nearly all of them —
100
+ are just the value, so the common case stays readable; composite keys
101
+ name their columns because position alone would not survive a schema
102
+ change.
103
+
104
+ The table is named whenever it differs from the entry before, which
105
+ is exactly what following a foreign key from one row to another does.
106
+ Without it `work_order/~8/~636` claims 636 is a work_order when it is
107
+ a customer, and the link restores the wrong row — or no row. */
108
+ const body = keyBody(entry.key);
109
+ const previous = stack[index - 1];
110
+ const prefix = previous && previous.table === entry.table ? '' : `${entry.table}:`;
111
+ /* And which related table is open, if one is — the record view's rail
112
+ is a tablist, and "look at its notes" is as worth linking to on a
113
+ record as it is on a page. `!` is escaped inside the key for the
114
+ same reason the query encoder escapes it: it is an unreserved
115
+ character that `encodeURIComponent` leaves alone, so a value
116
+ containing one would otherwise write a second separator. */
117
+ const tab = entry.tab ? `!${encodeURIComponent(entry.tab)}` : '';
118
+ parts.push(`~${encodeQuery(prefix + body)}${tab}`);
119
+ }
120
+ });
121
+ return `/${parts.join('/')}`;
122
+ }
123
+
124
+ /**
125
+ * The connection a hash names, without parsing the rest of it.
126
+ *
127
+ * Needed because parsing the trail requires the *right* schema: a link into
128
+ * another database mentions tables the current one has never heard of, so
129
+ * every row segment fails to resolve and is dropped. The connection has to be
130
+ * switched before the rest can be read.
131
+ */
132
+ /**
133
+ * Take the leading `/` off a link — or the leading `#/` off one written
134
+ * before these were paths.
135
+ *
136
+ * Both spellings are read for as long as it costs one regex to do so. A link
137
+ * is the thing people paste into a ticket and come back to a year later, and
138
+ * the day the old ones stop resolving should not be the day this changed.
139
+ */
140
+ function stripPrefix(link) {
141
+ return String(link ?? '').replace(/^#/, '').replace(/^\//, '');
142
+ }
143
+
144
+ export function connectionFromHash(hash) {
145
+ const raw = stripPrefix(hash);
146
+ if (!raw) return null;
147
+ const first = raw.split('/').filter(Boolean)[0];
148
+ return first ? decodeURIComponent(first) : null;
149
+ }
150
+
151
+ /**
152
+ * Parse a hash into a stack. Returns null when there is nothing to restore.
153
+ *
154
+ * Rows need the table they belong to, which is the entry before them — so a
155
+ * row segment that opens the trail is dropped rather than guessed at.
156
+ */
157
+ export function fromHash(hash, findTableById, options = {}) {
158
+ /* Read off `options` rather than destructured with a default in the
159
+ signature: `check-client-imports` cannot see a parameter list once a
160
+ default inside it has parentheses of its own, and reads every name in it
161
+ as undefined. The tool is right that this file must import nothing, so it
162
+ is the signature that gives way. */
163
+ const isBreakdown = options.isBreakdown ?? (() => false);
164
+ const raw = stripPrefix(hash);
165
+ if (!raw) return null;
166
+ const segments = raw.split('/').filter(Boolean);
167
+ if (!segments.length) return null;
168
+
169
+ const connection = decodeURIComponent(segments[0]);
170
+ const stack = [];
171
+
172
+ for (const segment of segments.slice(1)) {
173
+ if (segment.startsWith('@')) {
174
+ /* A page, and its root if the link has one. The base table is not in
175
+ the link: a page names it, and putting it in the URL as well would be
176
+ a second copy to disagree with the config file the day someone
177
+ re-points a page at another table. It is filled in when the page
178
+ loads. */
179
+ const [head, keyPart] = segment.slice(1).split('~');
180
+ /* Page first, then the tab: split on the first `!`, which a page token
181
+ cannot contain and a tab name is encoded past. */
182
+ const bang = head.indexOf('!');
183
+ const pageId = decodeURIComponent(bang === -1 ? head : head.slice(0, bang));
184
+ const tab = bang === -1 ? undefined : decodeURIComponent(head.slice(bang + 1)) || undefined;
185
+ if (!pageId) continue;
186
+ const body = keyPart === undefined ? undefined : decodeURIComponent(keyPart);
187
+ /* Carried as text, not resolved here. A single-column key is written as
188
+ a bare value, and turning `1181` back into `{ id: 1181 }` needs the
189
+ page's base table — which the link deliberately does not name, since
190
+ the page already does and two copies would disagree the day someone
191
+ re-points it. The page resolves it when it knows its own base. */
192
+ stack.push({ kind: 'page', pageId, table: undefined, rootBody: body, label: body, tab });
193
+ continue;
194
+ }
195
+ if (segment.startsWith('~')) {
196
+ const rest = segment.slice(1);
197
+ const bang = rest.indexOf('!');
198
+ const tab = bang === -1 ? undefined : decodeURIComponent(rest.slice(bang + 1)) || undefined;
199
+ let body = decodeURIComponent(bang === -1 ? rest : rest.slice(0, bang));
200
+ /* An explicit `table:` prefix wins. It is only written when the table
201
+ changed, so its absence means "same as the entry before". The colon
202
+ is found from the left and only counts before any `=`, so a composite
203
+ key like `a=1,b=2` is never mistaken for a table prefix. */
204
+ let tableId = null;
205
+ const colon = body.indexOf(':');
206
+ if (colon > 0 && (body.indexOf('=') === -1 || colon < body.indexOf('='))) {
207
+ /* Only if it actually names a table. A key value can contain a colon
208
+ — a time like `12:30`, a URN, a namespaced id — and treating that
209
+ as a table prefix would drop the row entirely. Checking against the
210
+ schema makes the ambiguity decidable instead of guessed. */
211
+ const candidate = body.slice(0, colon);
212
+ if (findTableById(candidate)) {
213
+ tableId = candidate;
214
+ body = body.slice(colon + 1);
215
+ }
216
+ }
217
+ const previous = stack[stack.length - 1];
218
+ if (!tableId && !previous) continue; // a row with no table means nothing
219
+ const resolvedId = tableId ?? previous.table;
220
+ const table = findTableById(resolvedId);
221
+ if (!table) continue;
222
+ const key = {};
223
+ if (body.includes('=')) {
224
+ for (const pair of body.split(',')) {
225
+ const [column, ...rest] = pair.split('=');
226
+ key[column] = rest.join('=');
227
+ }
228
+ } else {
229
+ const pk = table.columns.filter((c) => c.primaryKey).map((c) => c.name);
230
+ if (!pk.length) continue;
231
+ key[pk[0]] = body;
232
+ }
233
+ stack.push({ kind: 'row', table: table.id, key, label: labelFor(body), tab });
234
+ continue;
235
+ }
236
+ /* Only a real tab name ends a segment.
237
+
238
+ `lastIndexOf('!')` alone read the `!` of `!=` as the separator, so
239
+ `invoice total != 0` came back as `invoice total ` with a tab of
240
+ `%3D 0` — the condition silently gone, and a shared "unpaid invoices"
241
+ link showing every invoice. Encoding fixes it going forward; checking
242
+ the suffix against the tabs that exist also rescues every link already
243
+ written and every one this app did not write. */
244
+ const bang = segment.lastIndexOf('!');
245
+ const suffix = bang > 0 ? segment.slice(bang + 1) : '';
246
+ const hasTab = TABS.has(suffix);
247
+ const tab = hasTab ? suffix : 'rows';
248
+ const query = decodeURIComponent(hasTab ? segment.slice(0, bang) : segment);
249
+ const name = query.trim().split(/\s+/)[0];
250
+ const table = findTableById(name);
251
+ /* A breakdown is written into the link exactly as a table query is — it is
252
+ the same language and the same text — so which kind it is on the way back
253
+ is read off the text itself. The rule is supplied rather than imported:
254
+ this module deliberately depends on nothing, which is what lets the link
255
+ format be tested without a browser, and it is the same arrangement
256
+ `pageToken` uses on the way out. */
257
+ stack.push(isBreakdown(query)
258
+ ? { kind: 'breakdown', table: table ? table.id : name, query }
259
+ : tableView(table ? table.id : name, query, tab));
260
+ }
261
+
262
+ return { connection, stack };
263
+ }
264
+
265
+ function labelFor(body) {
266
+ // The real label needs the row, which has not been fetched yet. The key is
267
+ // a truthful placeholder and gets replaced by resolveLabels below.
268
+ return body.length > 24 ? `${body.slice(0, 23)}…` : body;
269
+ }
270
+
@@ -0,0 +1,144 @@
1
+ /**
2
+ * Deleting a row, having first said what that would do.
3
+ *
4
+ * This is the one destructive action in tablewalk, and it is also the one place
5
+ * the tool has a genuine advantage over every other database client — not
6
+ * through cleverness, but because of what it already knows. It counts what
7
+ * points at a row in order to draw the Related panel; the same counts answer
8
+ * "what breaks if this goes" *before* the delete rather than as a constraint
9
+ * violation afterwards.
10
+ *
11
+ * The count alone is not the answer, which is the part most tools would get
12
+ * wrong if they tried. Three rows pointing at this one means three rows
13
+ * deleted under `cascade`, three rows left holding a null under `set null`, and
14
+ * a delete that simply fails under `restrict`. Three outcomes behind one
15
+ * number. So the dialog leads with the *rule*, and a rule the adapter could not
16
+ * determine says so rather than being quietly rendered as harmless.
17
+ */
18
+ import { api, el, go, state, toast } from './core.js';
19
+
20
+ /** How a rule reads to someone deciding whether to press the button. */
21
+ const CONSEQUENCE = {
22
+ cascade: { tone: 'bad', says: (n, t) => `${n} in ${t} will be deleted too` },
23
+ 'set null': { tone: 'warn', says: (n, t) => `${n} in ${t} will be kept, with the reference emptied` },
24
+ 'set default': { tone: 'warn', says: (n, t) => `${n} in ${t} will be kept, with the reference reset` },
25
+ restrict: { tone: 'stop', says: (n, t) => `${n} in ${t} will block this — the database will refuse it` },
26
+ 'no action': { tone: 'stop', says: (n, t) => `${n} in ${t} will block this — the database will refuse it` },
27
+ };
28
+
29
+ const rows = (n) => `${n.toLocaleString()} row${n === 1 ? '' : 's'}`;
30
+
31
+ /**
32
+ * Whether this connection will accept a delete at all.
33
+ *
34
+ * Asked of the server's own answer rather than inferred: `writable` is on the
35
+ * connection list precisely so the interface does not offer a button the server
36
+ * would refuse.
37
+ */
38
+ export function canDelete(table) {
39
+ if (!state.canWrite || !table || table.isView) return false;
40
+ return Boolean(state.connections?.find((c) => c.id === state.activeConnection)?.writable);
41
+ }
42
+
43
+ export function deleteButton(table, key, label) {
44
+ if (!canDelete(table)) return null;
45
+ return el('button', {
46
+ type: 'button',
47
+ class: 'ghost record-delete',
48
+ title: `Delete this ${table.name} row`,
49
+ text: 'Delete…',
50
+ onclick: () => void confirmDelete(table, key, label),
51
+ });
52
+ }
53
+
54
+ async function confirmDelete(table, key, label) {
55
+ let report;
56
+ try {
57
+ report = await api('/api/delete', { table: table.id, key });
58
+ } catch (err) {
59
+ toast(err.message, 'error');
60
+ return;
61
+ }
62
+
63
+ const impact = report.impact ?? [];
64
+ const blocked = impact.some((i) => i.onDelete === 'restrict' || i.onDelete === 'no action');
65
+
66
+ const dialog = el('dialog', { class: 'confirm delete-confirm' }, [
67
+ el('h2', { text: `Delete ${table.name} ${label}?` }),
68
+ impact.length
69
+ ? el('div', {}, [
70
+ el('p', { text: 'This row is referenced. Here is what the database will do:' }),
71
+ el('ul', { class: 'delete-impact' }, impact.map((i) => {
72
+ const rule = CONSEQUENCE[i.onDelete];
73
+ return el('li', { class: `impact-${rule?.tone ?? 'unknown'}` }, [
74
+ el('span', { class: 'impact-what' }, [
75
+ document.createTextNode(
76
+ i.count < 0
77
+ ? `${i.table} could not be counted`
78
+ : rule
79
+ ? rule.says(rows(i.count), i.table)
80
+ : `${rows(i.count)} in ${i.table} — this constraint's rule is unknown`,
81
+ ),
82
+ ]),
83
+ el('span', { class: 'impact-rule', text: i.onDelete ? `on delete ${i.onDelete}` : 'rule unknown' }),
84
+ ]);
85
+ })),
86
+ /* Said plainly rather than left to be inferred from the list. A
87
+ cascade that itself cascades is not counted here, and implying a
88
+ total that stopped one level down would be worse than saying so. */
89
+ impact.some((i) => i.onDelete === 'cascade')
90
+ ? el('p', {
91
+ class: 'delete-note',
92
+ text: 'Cascades are counted one level deep. Rows deleted by a cascade may take their own children with them.',
93
+ })
94
+ : null,
95
+ ].filter(Boolean))
96
+ : el('p', { text: 'Nothing points at this row, so nothing else is affected.' }),
97
+
98
+ blocked
99
+ ? el('p', {
100
+ class: 'delete-blocked',
101
+ text: 'The database will refuse this while those references exist. You can try anyway — its answer is the authority, not ours.',
102
+ })
103
+ : null,
104
+
105
+ el('div', { class: 'confirm-actions' }, [
106
+ el('button', { type: 'button', class: 'ghost', text: 'Cancel', onclick: () => dialog.close() }),
107
+ el('button', {
108
+ type: 'button',
109
+ class: 'danger',
110
+ text: blocked ? 'Try anyway' : 'Delete',
111
+ onclick: () => { dialog.close(); void doDelete(table, key, label); },
112
+ }),
113
+ ]),
114
+ ].filter(Boolean));
115
+
116
+ document.body.append(dialog);
117
+ dialog.addEventListener('close', () => dialog.remove());
118
+ dialog.showModal();
119
+ }
120
+
121
+ async function doDelete(table, key, label) {
122
+ let result;
123
+ try {
124
+ result = await api('/api/delete', { table: table.id, key, confirm: true });
125
+ } catch (err) {
126
+ toast(err.message, 'error');
127
+ return;
128
+ }
129
+
130
+ if (!result.deleted) {
131
+ /* The database's own words. A constraint violation names the constraint,
132
+ which is more use than any paraphrase — and re-deriving the judgement
133
+ here would be a second implementation of a rule the database already
134
+ enforces. */
135
+ toast(result.errors?.[0]?.message ?? 'The row was not deleted.', 'error');
136
+ return;
137
+ }
138
+
139
+ toast(`Deleted ${table.name} ${label}.`, 'ok');
140
+ /* Back to the table, not to the record that no longer exists. Staying would
141
+ re-fetch a row that is gone and report it as missing, which reads as a
142
+ failure rather than as the thing that was just asked for. */
143
+ go({ kind: 'table', table: table.id, query: table.id, tab: 'rows' }, 'reset');
144
+ }