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,951 @@
1
+ /**
2
+ * MySQL adapter, on `mysql2`.
3
+ *
4
+ * The catalog is `information_schema`, so introspection is four queries no
5
+ * matter how many tables exist — the same trade the Postgres adapter makes,
6
+ * and for the same reason: a schema with four hundred tables should cost one
7
+ * round trip per *kind* of fact, not one per table.
8
+ *
9
+ * Two things about MySQL shape everything below.
10
+ *
11
+ * The first is that a database *is* a schema. There is no second level of
12
+ * namespacing, so `TABLE_SCHEMA` in the catalog is what `USE` selects and what
13
+ * a connection URL's path names. That makes the Postgres mapping exact rather
14
+ * than approximate: a table's `schema` is its database and its `id` is
15
+ * `database.table`, qualified in every statement as `` `shop`.`invoice` ``.
16
+ * Naming the database in the statement is also what keeps a browsing session
17
+ * honest when the connection's default database is not the one being read —
18
+ * `USE` is a mutable, per-connection setting and nothing here relies on it.
19
+ *
20
+ * The second is that a MySQL connection has no read-only mode of its own, the
21
+ * way a SQLite handle does. `SET SESSION TRANSACTION READ ONLY` is the closest
22
+ * equivalent and it is a real one: with autocommit on, every statement is its
23
+ * own transaction and inherits the session's access mode, so INSERT, UPDATE,
24
+ * DELETE, TRUNCATE, CREATE and DROP all come back as error 1792 rather than
25
+ * running. See `open` for what that does and does not cover.
26
+ */
27
+ import { MYSQL_STYLE, buildWhere, clampLimit, boundStatement, dedupe, looksWrappable, refuseStacked, trimStatement, clampOffset, Refusal, Serial, } from './adapter.js';
28
+ import { compileView, compileViewCount } from '../shared/view.js';
29
+ import { compileBreakdown, compileBreakdownCount } from '../shared/breakdown.js';
30
+ import { typeVocabulary } from '../shared/vocabulary.js';
31
+ import { coerceForColumn, deleteRuleOf, } from '../shared/schema.js';
32
+ export class MysqlAdapter {
33
+ connectionString;
34
+ label;
35
+ extraDatabases;
36
+ dialect = 'mysql';
37
+ client = null;
38
+ /** Opened lazily, only when an edit is actually applied. */
39
+ writeClient = null;
40
+ /* Every write on that connection, one at a time. See `Serial`. */
41
+ writes = new Serial();
42
+ /** Filled in on connect: the database the URL names, plus any extras. */
43
+ databases = [];
44
+ constructor(connectionString, label = redact(connectionString),
45
+ /**
46
+ * Further databases to browse alongside the one the URL selects.
47
+ *
48
+ * Empty — the normal case — means just that one. A MySQL server hosts
49
+ * every tenant's database side by side in one `information_schema`, so
50
+ * introspecting the lot by default would hand someone browsing `shop`
51
+ * four hundred tables belonging to people who are not them.
52
+ */
53
+ extraDatabases = []) {
54
+ this.connectionString = connectionString;
55
+ this.label = label;
56
+ this.extraDatabases = extraDatabases;
57
+ }
58
+ async conn() {
59
+ if (this.client)
60
+ return this.client;
61
+ const client = await this.open(true);
62
+ /* Asked of the server rather than parsed out of the URL, so a socket
63
+ connection, a `?database=` parameter and a plain path all resolve the
64
+ same way — and so the answer is the database the server actually put us
65
+ in rather than the one we hoped for. */
66
+ const [rows] = await client.query('SELECT DATABASE() AS db');
67
+ const current = rows[0]?.db ?? '';
68
+ if (!current && !this.extraDatabases.length) {
69
+ await client.end();
70
+ throw new Error('A MySQL URL has to name a database: mysql://user:password@host:3306/dbname');
71
+ }
72
+ this.databases = [...new Set([current, ...this.extraDatabases].filter(Boolean))];
73
+ this.client = client;
74
+ return client;
75
+ }
76
+ /**
77
+ * Open a connection, read-only unless this is the write handle.
78
+ *
79
+ * The read-only part is enforced by the server, not by convention here:
80
+ * `SET SESSION TRANSACTION READ ONLY` makes every subsequent statement on
81
+ * this connection run in a read-only transaction, and MySQL answers a write
82
+ * with ER_CANT_EXECUTE_IN_READ_ONLY_TRANSACTION (1792). Verified against
83
+ * MySQL 8.4 for INSERT, UPDATE, DELETE, TRUNCATE, CREATE TABLE, CREATE
84
+ * TEMPORARY TABLE and DROP TABLE.
85
+ *
86
+ * What it does not cover: a user with SUPER could turn it off again by
87
+ * running `SET SESSION TRANSACTION READ WRITE`, and nothing stops a stored
88
+ * procedure called from a SELECT from writing. Neither is reachable through
89
+ * this adapter — it only ever sends the statements it builds — but the
90
+ * guarantee is "this connection refuses writes", not "this connection could
91
+ * not write under any circumstances", and it is worth being exact about
92
+ * which of those is true.
93
+ */
94
+ async open(readOnly) {
95
+ const createConnection = await driver();
96
+ const client = await createConnection({
97
+ uri: this.connectionString,
98
+ /* A stacked `; DROP TABLE customer` has to be a syntax error rather than
99
+ a second statement. It is mysql2's default; set explicitly because the
100
+ entire safety argument here rests on one statement per call. */
101
+ multipleStatements: false,
102
+ /* DATETIME and TIMESTAMP arrive as the text the server stores. A JS Date
103
+ would be read in the process's local zone and written back out as UTC,
104
+ so `2025-03-14 00:00:00` would be shown as the 13th to anyone east of
105
+ Greenwich — a browsing tool inventing an offset the column does not
106
+ have. The query language's date windows are the same text format, so
107
+ filters and displayed values also stay comparable. */
108
+ dateStrings: true,
109
+ /* A BIGINT past 2^53 becomes a string rather than a silently wrong
110
+ number. Losing the last digits of an id is the kind of error that
111
+ looks fine on screen and matches nothing when clicked. */
112
+ supportBigNumbers: true,
113
+ connectTimeout: 15_000,
114
+ });
115
+ if (!readOnly)
116
+ return client;
117
+ await client.query('SET SESSION TRANSACTION READ ONLY');
118
+ try {
119
+ await client.query('SET SESSION max_execution_time = 30000');
120
+ }
121
+ catch {
122
+ /* MariaDB spells this `max_statement_time` and measures it in seconds.
123
+ A missing statement timeout is worth a slow query, not a refusal to
124
+ open the connection at all. */
125
+ }
126
+ return client;
127
+ }
128
+ async introspect() {
129
+ const db = await this.conn();
130
+ const holes = this.databases.map(() => '?').join(', ');
131
+ const tableRows = await this.read(db, `SELECT TABLE_SCHEMA, TABLE_NAME, TABLE_TYPE, TABLE_ROWS, TABLE_COMMENT
132
+ FROM information_schema.TABLES
133
+ WHERE TABLE_SCHEMA IN (${holes})
134
+ ORDER BY TABLE_SCHEMA, TABLE_NAME`, this.databases);
135
+ const wanted = new Set(tableRows.rows.map((r) => `${r.TABLE_SCHEMA}.${r.TABLE_NAME}`));
136
+ const columnRows = await this.read(db, `SELECT TABLE_SCHEMA, TABLE_NAME, COLUMN_NAME, COLUMN_TYPE, IS_NULLABLE,
137
+ COLUMN_DEFAULT, COLUMN_KEY, COLUMN_COMMENT
138
+ FROM information_schema.COLUMNS
139
+ WHERE TABLE_SCHEMA IN (${holes})
140
+ ORDER BY TABLE_SCHEMA, TABLE_NAME, ORDINAL_POSITION`, this.databases);
141
+ /* Only the leading column of each index, which is the only one an ORDER BY
142
+ can be satisfied by: an index on (customer_id, opened_at) makes
143
+ `ORDER BY customer_id` free and `ORDER BY opened_at` a full sort. The
144
+ primary key is in here too, because MySQL reports it as an index named
145
+ PRIMARY and it is the default order's last resort.
146
+
147
+ A functional index (8.0.13 and later) has a NULL COLUMN_NAME and an
148
+ EXPRESSION instead; it is dropped, as it should be, since there is no
149
+ bare column for a sort to lean on. */
150
+ const indexRows = await this.read(db, `SELECT TABLE_SCHEMA, TABLE_NAME, COLUMN_NAME
151
+ FROM information_schema.STATISTICS
152
+ WHERE TABLE_SCHEMA IN (${holes})
153
+ AND SEQ_IN_INDEX = 1
154
+ AND COLUMN_NAME IS NOT NULL`, this.databases);
155
+ /* KEY_COLUMN_USAGE carries one row per column of a key, so a two-column
156
+ foreign key is two rows to be collapsed by ORDINAL_POSITION — the same
157
+ shape SQLite's PRAGMA returns. The join to REFERENTIAL_CONSTRAINTS is
158
+ what makes these foreign keys specifically: KEY_COLUMN_USAGE also lists
159
+ primary and unique keys, which have no referenced table and would
160
+ otherwise have to be filtered by a NULL check standing in for a fact the
161
+ catalog states directly. */
162
+ const keyRows = await this.read(db, `SELECT k.CONSTRAINT_NAME, k.TABLE_SCHEMA, k.TABLE_NAME, k.COLUMN_NAME,
163
+ k.REFERENCED_TABLE_SCHEMA, k.REFERENCED_TABLE_NAME, k.REFERENCED_COLUMN_NAME,
164
+ r.DELETE_RULE
165
+ FROM information_schema.KEY_COLUMN_USAGE k
166
+ JOIN information_schema.REFERENTIAL_CONSTRAINTS r
167
+ ON r.CONSTRAINT_SCHEMA = k.CONSTRAINT_SCHEMA
168
+ AND r.CONSTRAINT_NAME = k.CONSTRAINT_NAME
169
+ AND r.TABLE_NAME = k.TABLE_NAME
170
+ WHERE k.TABLE_SCHEMA IN (${holes})
171
+ ORDER BY k.TABLE_SCHEMA, k.TABLE_NAME, k.CONSTRAINT_NAME, k.ORDINAL_POSITION`, this.databases);
172
+ const foreignKeys = [];
173
+ /* A column-level reference map, so the table view can show "this column
174
+ points there" without searching the whole key list per cell. */
175
+ const refByColumn = new Map();
176
+ const byConstraint = new Map();
177
+ for (const row of keyRows.rows) {
178
+ /* Keyed by table as well as constraint name: MySQL only requires a
179
+ foreign key name to be unique within its database, so two tables may
180
+ each have an `fk_customer` and grouping by name alone would weld them
181
+ into one four-column key pointing at the wrong thing. */
182
+ const id = `${row.TABLE_SCHEMA}.${row.TABLE_NAME}.${row.CONSTRAINT_NAME}`;
183
+ const list = byConstraint.get(id) ?? [];
184
+ list.push(row);
185
+ byConstraint.set(id, list);
186
+ }
187
+ for (const group of byConstraint.values()) {
188
+ const from = `${group[0].TABLE_SCHEMA}.${group[0].TABLE_NAME}`;
189
+ const to = `${group[0].REFERENCED_TABLE_SCHEMA}.${group[0].REFERENCED_TABLE_NAME}`;
190
+ // A key can point at a database this session is not browsing. Those
191
+ // edges lead nowhere the user can click, so they are dropped rather
192
+ // than drawn broken.
193
+ if (!wanted.has(from) || !wanted.has(to))
194
+ continue;
195
+ foreignKeys.push({
196
+ name: group[0].CONSTRAINT_NAME,
197
+ from: { table: from, columns: group.map((r) => r.COLUMN_NAME) },
198
+ to: { table: to, columns: group.map((r) => r.REFERENCED_COLUMN_NAME) },
199
+ onDelete: deleteRuleOf(group[0].DELETE_RULE),
200
+ });
201
+ for (const row of group) {
202
+ refByColumn.set(`${from}.${row.COLUMN_NAME}`, {
203
+ table: to,
204
+ column: row.REFERENCED_COLUMN_NAME,
205
+ });
206
+ }
207
+ }
208
+ const indexedByTable = new Map();
209
+ for (const row of indexRows.rows) {
210
+ const id = `${row.TABLE_SCHEMA}.${row.TABLE_NAME}`;
211
+ const set = indexedByTable.get(id) ?? new Set();
212
+ set.add(row.COLUMN_NAME);
213
+ indexedByTable.set(id, set);
214
+ }
215
+ const columnsByTable = new Map();
216
+ for (const row of columnRows.rows) {
217
+ const id = `${row.TABLE_SCHEMA}.${row.TABLE_NAME}`;
218
+ if (!wanted.has(id))
219
+ continue;
220
+ const list = columnsByTable.get(id) ?? [];
221
+ list.push(row);
222
+ columnsByTable.set(id, list);
223
+ }
224
+ const tables = tableRows.rows.map((t) => {
225
+ const id = `${t.TABLE_SCHEMA}.${t.TABLE_NAME}`;
226
+ const isView = t.TABLE_TYPE !== 'BASE TABLE';
227
+ const columns = (columnsByTable.get(id) ?? []).map((c) => ({
228
+ name: c.COLUMN_NAME,
229
+ /* The declared type verbatim, `tinyint(1)` included. MySQL has no
230
+ boolean: BOOL and BOOLEAN are spellings of TINYINT(1), and the
231
+ values really are 0 and 1. Rewriting the type to "boolean" would
232
+ make `active = true` coerce to a JavaScript true and read as a
233
+ promise the column does not make; left as declared, the same filter
234
+ coerces to 1, which is what is stored. */
235
+ type: c.COLUMN_TYPE,
236
+ nullable: c.IS_NULLABLE === 'YES',
237
+ primaryKey: c.COLUMN_KEY === 'PRI',
238
+ references: refByColumn.get(`${id}.${c.COLUMN_NAME}`),
239
+ default: c.COLUMN_DEFAULT ?? undefined,
240
+ comment: c.COLUMN_COMMENT || undefined,
241
+ /* MySQL writes the vocabulary into the type itself — `enum('draft',
242
+ 'open')` — so the declared type already carries it. */
243
+ allowed: typeVocabulary(c.COLUMN_TYPE),
244
+ }));
245
+ return {
246
+ name: t.TABLE_NAME,
247
+ schema: t.TABLE_SCHEMA,
248
+ id,
249
+ columns,
250
+ /* InnoDB's TABLE_ROWS comes from a handful of random index dives, not
251
+ a count: it is routinely out by a factor of two and can be out by an
252
+ order of magnitude on a table with a long update history. Fine for
253
+ "roughly how big is this" in a sidebar, wrong for anything a user
254
+ would act on — which is why `count()` exists and this never feeds
255
+ it. A view reports NULL, and stays undefined rather than becoming a
256
+ confident zero. */
257
+ approxRows: t.TABLE_ROWS === null ? undefined : Math.max(0, Number(t.TABLE_ROWS) || 0),
258
+ /* MySQL puts the literal word VIEW in a view's TABLE_COMMENT rather
259
+ than leaving it empty, so passing it through would caption every
260
+ view in the sidebar with its own kind. */
261
+ comment: isView && t.TABLE_COMMENT === 'VIEW' ? undefined : t.TABLE_COMMENT || undefined,
262
+ isView,
263
+ // Always an array, because this always looked. Absent would mean the
264
+ // adapter did not, and the default order reads the difference.
265
+ indexed: [...(indexedByTable.get(id) ?? [])],
266
+ };
267
+ });
268
+ return {
269
+ label: this.label,
270
+ dialect: this.dialect,
271
+ tables,
272
+ foreignKeys,
273
+ readAt: new Date().toISOString(),
274
+ };
275
+ }
276
+ /*
277
+ * Why there is no `ddl()` here.
278
+ *
279
+ * MySQL has SHOW CREATE TABLE, and it is tempting: it includes indexes,
280
+ * foreign keys, the engine and the charset, all of which the catalog
281
+ * reconstruction leaves out. But it is the server re-rendering the data
282
+ * dictionary, not the statement its author wrote — comments are gone,
283
+ * type spellings are normalised, defaults are re-quoted — and the interface
284
+ * labels an adapter-supplied definition "the statement the database itself
285
+ * keeps, verbatim". Offering a faithful reconstruction under a promise of
286
+ * verbatim text would be a worse lie than the honest "Reconstructed" label
287
+ * the fallback already carries.
288
+ */
289
+ async query(request) {
290
+ const db = await this.conn();
291
+ const table = await this.assertTable(request.table);
292
+ const where = buildWhere(request.filter, MYSQL_STYLE);
293
+ const cols = request.columns?.length
294
+ ? request.columns.map((c) => MYSQL_STYLE.quote(c)).join(', ')
295
+ : '*';
296
+ const order = request.orderBy?.length
297
+ ? ' ORDER BY ' +
298
+ request.orderBy
299
+ .map((o) => `${MYSQL_STYLE.quote(o.column)} ${o.direction === 'desc' ? 'DESC' : 'ASC'}`)
300
+ .join(', ')
301
+ : '';
302
+ /* Every read is bounded. There is no code path that reaches the database
303
+ without a LIMIT, including one that forgot to ask for one. */
304
+ const limit = clampLimit(request.limit);
305
+ const text = `SELECT ${cols} FROM ${qualify(table)}${where.text}${order} LIMIT ? OFFSET ?`;
306
+ const params = [...where.params, rowCount(limit), rowCount(clampOffset(request.offset))];
307
+ const started = performance.now();
308
+ const result = await this.read(db, text, params);
309
+ const ms = Math.round(performance.now() - started);
310
+ return {
311
+ // From the field list rather than the first row, so an empty result
312
+ // still renders its headers instead of collapsing to nothing.
313
+ columns: result.fields.map((f) => f.name),
314
+ rows: result.rows.map(toJsonSafe),
315
+ ms,
316
+ sql: { text, params },
317
+ };
318
+ }
319
+ /**
320
+ * Run a compiled view.
321
+ *
322
+ * As in the other adapters, everything hard happened in `compileView`:
323
+ * paths resolved against the catalog, joins planned, values reduced to bound
324
+ * parameters. Nothing here needs to check that the tables exist, because the
325
+ * only identifiers that reach the quoting step are ones the catalog
326
+ * produced.
327
+ */
328
+ async runView(view, offset = 0) {
329
+ const db = await this.conn();
330
+ const schema = await this.schema();
331
+ /* Every read is bounded, views included: `compileView` runs the limit
332
+ through `clampLimit` and emits LIMIT unconditionally. */
333
+ const compiled = compileView(schema, view, MYSQL_STYLE, offset);
334
+ const params = bindRowCounts(compiled.text, compiled.params);
335
+ const started = performance.now();
336
+ const result = await this.read(db, compiled.text, params);
337
+ const ms = Math.round(performance.now() - started);
338
+ return {
339
+ columns: compiled.columns.map((c) => c.name),
340
+ rows: result.rows.map(toJsonSafe),
341
+ ms,
342
+ sql: { text: compiled.text, params },
343
+ resolved: compiled.columns,
344
+ joins: compiled.joins,
345
+ };
346
+ }
347
+ async runBreakdown(breakdown, offset = 0) {
348
+ const db = await this.conn();
349
+ const compiled = compileBreakdown(await this.schema(), breakdown, MYSQL_STYLE, offset);
350
+ /* The same treatment a view's LIMIT gets: mysql2 binds a number as a
351
+ string over the prepared protocol, and `LIMIT '20'` is a syntax error. */
352
+ const params = bindRowCounts(compiled.text, compiled.params);
353
+ const started = performance.now();
354
+ const result = await this.read(db, compiled.text, params);
355
+ return {
356
+ columns: compiled.columns.map((c) => c.name),
357
+ rows: result.rows.map(toJsonSafe),
358
+ ms: Math.round(performance.now() - started),
359
+ sql: { text: compiled.text, params },
360
+ resolved: compiled.columns,
361
+ };
362
+ }
363
+ async countBreakdown(breakdown) {
364
+ const db = await this.conn();
365
+ const compiled = compileBreakdownCount(await this.schema(), breakdown, MYSQL_STYLE);
366
+ const result = await this.read(db, compiled.text, compiled.params);
367
+ return Number(result.rows[0]?.n ?? 0);
368
+ }
369
+ async countView(view) {
370
+ const db = await this.conn();
371
+ const compiled = compileViewCount(await this.schema(), view, MYSQL_STYLE);
372
+ const result = await this.read(db, compiled.text, compiled.params);
373
+ return Number(result.rows[0]?.n ?? 0);
374
+ }
375
+ /* Introspection is four catalog queries; a view preview runs on every
376
+ keystroke. The registry refreshes by discarding the adapter, so a stale
377
+ cache cannot outlive an explicit reload. */
378
+ cachedSchema = null;
379
+ async schema() {
380
+ if (!this.cachedSchema)
381
+ this.cachedSchema = await this.introspect();
382
+ return this.cachedSchema;
383
+ }
384
+ /**
385
+ * `TABLE_ROWS` from `information_schema`.
386
+ *
387
+ * InnoDB computes this from a handful of index dives, so it can be out by a
388
+ * large factor — the manual says up to 50%, and in practice worse on a table
389
+ * with skewed key distribution. That is tolerable for "is this table big",
390
+ * which is the only question being asked of it, and it is why the number is
391
+ * labelled an estimate wherever it appears.
392
+ *
393
+ * NULL for a view, where it means "not applicable" rather than zero.
394
+ */
395
+ async estimateCount(table) {
396
+ const db = await this.conn();
397
+ const { schema, name } = await this.assertTable(table);
398
+ const { rows } = await this.read(db, `SELECT TABLE_ROWS FROM information_schema.TABLES
399
+ WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ?`, [schema, name]);
400
+ const n = rows[0]?.TABLE_ROWS;
401
+ return n === null || n === undefined ? undefined : Math.max(0, Number(n));
402
+ }
403
+ async count(table, filter) {
404
+ const db = await this.conn();
405
+ const found = await this.assertTable(table);
406
+ const where = buildWhere(filter, MYSQL_STYLE);
407
+ const text = `SELECT COUNT(*) AS n FROM ${qualify(found)}${where.text}`;
408
+ const result = await this.read(db, text, where.params);
409
+ return Number(result.rows[0]?.n ?? 0);
410
+ }
411
+ /**
412
+ * The write path, on its own handle.
413
+ *
414
+ * `this.client` refuses writes for the life of the process, so browsing can
415
+ * never write no matter what the UI believes. A second connection — without
416
+ * the read-only session setting — is opened the first time an edit is
417
+ * actually applied.
418
+ *
419
+ * The guards are the SQLite adapter's, one for one, because an edit refused
420
+ * on SQLite and allowed on MySQL would mean the safety of a cell editor
421
+ * depended on which database it was pointed at.
422
+ */
423
+ async update(request) {
424
+ return this.writes.run(() => this.updateNow(request));
425
+ }
426
+ async updateNow(request) {
427
+ const db = await this.conn();
428
+ const table = await this.assertTable(request.table);
429
+ const id = `${table.schema}.${table.name}`;
430
+ /* MySQL will happily update through a simple view, and the update lands on
431
+ the table underneath. Refused here: what the editor showed and what
432
+ changed are then two different objects, and `isView` already tells the
433
+ interface this is read-only. */
434
+ if (table.isView) {
435
+ throw new Refusal(`${id} is a view, so no single row can be identified.`);
436
+ }
437
+ const meta = await this.read(db, `SELECT COLUMN_NAME, COLUMN_TYPE, IS_NULLABLE, COLUMN_KEY
438
+ FROM information_schema.COLUMNS
439
+ WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ?
440
+ ORDER BY ORDINAL_POSITION`, [table.schema, table.name]);
441
+ /* COLUMN_KEY is 'PRI' for primary key columns only — a unique index reads
442
+ 'UNI' and a non-unique one 'MUL' — so this is the declared primary key
443
+ and not merely something unique-looking. An InnoDB table without one has
444
+ a hidden clustered key that cannot be named in a WHERE clause, which is
445
+ exactly the case this refuses. */
446
+ const pk = meta.rows.filter((c) => c.COLUMN_KEY === 'PRI').map((c) => c.COLUMN_NAME);
447
+ if (!pk.length)
448
+ throw new Refusal(`${id} has no primary key, so no single row can be identified.`);
449
+ const keyColumns = Object.keys(request.key);
450
+ /* The key must be the whole primary key and nothing else. Accepting a
451
+ partial key would let a two-column key match a whole group of rows,
452
+ and accepting extra columns would let the caller widen the match. */
453
+ const same = keyColumns.length === pk.length && pk.every((c) => keyColumns.includes(c));
454
+ if (!same) {
455
+ throw new Refusal(`Updating ${id} needs exactly its primary key (${pk.join(', ')}).`);
456
+ }
457
+ const entries = Object.entries(request.values ?? {});
458
+ if (!entries.length)
459
+ throw new Refusal('Nothing to update.');
460
+ const applied = {};
461
+ for (const [column, raw] of entries) {
462
+ const declared = meta.rows.find((c) => c.COLUMN_NAME === column);
463
+ if (!declared)
464
+ throw new Refusal(`${id} has no column called "${column}".`);
465
+ if (pk.includes(column)) {
466
+ // Editing a key in place silently repoints every row that referenced
467
+ // it, or fails on the constraint. Neither belongs behind an editor.
468
+ throw new Refusal(`"${column}" is part of the primary key and cannot be edited here.`);
469
+ }
470
+ /* Coerced against the declared type, because everything typed into a
471
+ text box arrives as a string — including "17901" for a DECIMAL. */
472
+ applied[column] = coerceForColumn(declared.COLUMN_TYPE || '', raw);
473
+ if (applied[column] === null && declared.IS_NULLABLE === 'NO') {
474
+ throw new Refusal(`"${column}" is NOT NULL and cannot be emptied.`);
475
+ }
476
+ }
477
+ const filter = {
478
+ groups: [pk.map((column) => ({ column, op: '=', value: request.key[column] }))],
479
+ };
480
+ /* Pre-flight. The count runs on the read-only handle, so the check cannot
481
+ itself have side effects, and a filter matching anything other than one
482
+ row stops here rather than at the UPDATE. */
483
+ const matches = await this.count(id, filter);
484
+ if (matches !== 1) {
485
+ throw new Refusal(`Refused: that key matches ${matches} rows, not 1. An update is only allowed when it is certain which row it changes.`);
486
+ }
487
+ const where = buildWhere(filter, MYSQL_STYLE);
488
+ const assignments = Object.keys(applied).map((c) => `${MYSQL_STYLE.quote(c)} = ?`).join(', ');
489
+ const text = `UPDATE ${qualify(table)} SET ${assignments}${where.text}`;
490
+ const params = [...Object.values(applied), ...where.params];
491
+ const write = await this.writeHandle();
492
+ const [result] = await write.execute(text, params);
493
+ /* mysql2 connects with CLIENT_FOUND_ROWS, so `affectedRows` counts the
494
+ rows matched rather than the rows whose bytes changed. Without it,
495
+ saving a cell without changing its value would report zero rows and the
496
+ editor would tell the user the write failed. */
497
+ return {
498
+ affected: Number(result?.affectedRows ?? 0),
499
+ applied,
500
+ sql: { text, params },
501
+ };
502
+ }
503
+ /**
504
+ * Add one row.
505
+ *
506
+ * The guards are `update`'s where they apply, with the key rules inverted:
507
+ * a primary key may be supplied or left for AUTO_INCREMENT to assign.
508
+ * MySQL 8 has no RETURNING, so the stored row is read back by its key —
509
+ * `insertId` for an auto-assigned one, the supplied values otherwise. A
510
+ * table where neither identifies the row (a composite key only partly
511
+ * supplied) still inserts; it just cannot be read back, and the result
512
+ * says so by carrying no row rather than guessing at one.
513
+ */
514
+ async insert(request) {
515
+ return this.writes.run(() => this.insertNow(request));
516
+ }
517
+ async insertNow(request) {
518
+ const db = await this.conn();
519
+ const table = await this.assertTable(request.table);
520
+ const id = `${table.schema}.${table.name}`;
521
+ if (table.isView) {
522
+ throw new Refusal(`${id} is a view. Insert into the table underneath it.`);
523
+ }
524
+ const meta = await this.read(db, `SELECT COLUMN_NAME, COLUMN_TYPE, IS_NULLABLE, COLUMN_KEY
525
+ FROM information_schema.COLUMNS
526
+ WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ?
527
+ ORDER BY ORDINAL_POSITION`, [table.schema, table.name]);
528
+ const entries = Object.entries(request.values ?? {});
529
+ if (!entries.length)
530
+ throw new Refusal('Nothing to insert. Name at least one column.');
531
+ const applied = {};
532
+ for (const [column, raw] of entries) {
533
+ const declared = meta.rows.find((c) => c.COLUMN_NAME === column);
534
+ if (!declared)
535
+ throw new Refusal(`${id} has no column called "${column}".`);
536
+ applied[column] = coerceForColumn(declared.COLUMN_TYPE || '', raw);
537
+ if (applied[column] === null && declared.IS_NULLABLE === 'NO') {
538
+ throw new Refusal(`"${column}" is NOT NULL and cannot be null. Leave it out to use its default, if it has one.`);
539
+ }
540
+ }
541
+ const columns = Object.keys(applied);
542
+ const text = `INSERT INTO ${qualify(table)} (${columns.map((c) => MYSQL_STYLE.quote(c)).join(', ')})`
543
+ + ` VALUES (${columns.map(() => '?').join(', ')})`;
544
+ const params = Object.values(applied);
545
+ const write = await this.writeHandle();
546
+ const [result] = await write.execute(text, params);
547
+ const pk = meta.rows.filter((c) => c.COLUMN_KEY === 'PRI').map((c) => c.COLUMN_NAME);
548
+ const assigned = Number(result?.insertId ?? 0);
549
+ const key = {};
550
+ if (pk.every((c) => applied[c] !== undefined && applied[c] !== null)) {
551
+ for (const c of pk)
552
+ key[c] = applied[c];
553
+ }
554
+ else if (pk.length === 1 && assigned > 0) {
555
+ key[pk[0]] = assigned;
556
+ }
557
+ let row;
558
+ if (pk.length && pk.every((c) => key[c] !== undefined)) {
559
+ const where = buildWhere({ groups: [pk.map((column) => ({ column, op: '=', value: key[column] }))] }, MYSQL_STYLE);
560
+ const back = await this.read(db, `SELECT * FROM ${qualify(table)}${where.text}`, where.params);
561
+ row = back.rows[0];
562
+ }
563
+ return { key, row, applied, sql: { text, params } };
564
+ }
565
+ /**
566
+ * EXPLAIN, tabular, one line per row.
567
+ *
568
+ * Plain EXPLAIN rather than FORMAT=TREE, because MariaDB does not speak
569
+ * TREE and this adapter serves both. Each row becomes its columns said as
570
+ * `name=value` pairs with the empties dropped — not as pretty as the tree,
571
+ * and the same words on every engine this connects to. EXPLAIN plans and
572
+ * never executes, on the ordinary read connection.
573
+ */
574
+ async explainSql(request) {
575
+ const stacked = refuseStacked(request.text);
576
+ if (stacked)
577
+ throw new Error(stacked);
578
+ const db = await this.conn();
579
+ const started = performance.now();
580
+ const { rows } = await this.read(db, `EXPLAIN ${trimStatement(request.text)}`, (request.params ?? []));
581
+ const plan = rows.map((r) => Object.entries(r)
582
+ .filter(([, v]) => v !== null && v !== undefined && v !== '')
583
+ .map(([k, v]) => `${k}=${v}`)
584
+ .join(' '));
585
+ return { plan, ms: Math.round(performance.now() - started) };
586
+ }
587
+ /**
588
+ * What the columns hold, by looking.
589
+ *
590
+ * MySQL keeps index cardinality, not null shares — histograms exist but
591
+ * only where someone ran ANALYZE ... UPDATE HISTOGRAM — so this reads the
592
+ * table the way the SQLite adapter does, with the same escape hatch:
593
+ * above `scanAtMost` rows it samples every k-th row of a single integer
594
+ * key, or the first N where the key does not allow the arithmetic, and
595
+ * `source` says which happened.
596
+ */
597
+ async profile(table, limits) {
598
+ const db = await this.conn();
599
+ const found = await this.assertTable(table);
600
+ const id = `${found.schema}.${found.name}`;
601
+ const schema = await this.schema();
602
+ const meta = schema.tables.find((t) => t.id === id);
603
+ if (!meta)
604
+ throw new Error(`No table or view called "${id}" on this connection.`);
605
+ const q = (c) => MYSQL_STYLE.quote(c);
606
+ const scanAtMost = limits?.scanAtMost ?? 200_000;
607
+ const sampleSize = limits?.sampleSize ?? 100_000;
608
+ const started = performance.now();
609
+ const counted = await this.read(db, `SELECT COUNT(*) AS n FROM ${qualify(found)}`, []);
610
+ const total = Number(counted.rows[0]?.n ?? 0);
611
+ let from = qualify(found);
612
+ let source = 'scan';
613
+ if (total > scanAtMost) {
614
+ source = 'sample';
615
+ const k = Math.max(2, Math.ceil(total / sampleSize));
616
+ const pk = meta.columns.filter((c) => c.primaryKey);
617
+ // Hashed before the stride, for the reason the SQLite adapter gives:
618
+ // periodic data and a bare modulo sample each other.
619
+ from = pk.length === 1 && /int/i.test(pk[0].type)
620
+ ? `(SELECT * FROM ${qualify(found)} WHERE ((${q(pk[0].name)} * 2654435761) & 0x7fffffff) % ${k} = 0) AS sampled`
621
+ : `(SELECT * FROM ${qualify(found)} LIMIT ${sampleSize}) AS sampled`;
622
+ }
623
+ const aggs = meta.columns.map((c, i) => `COUNT(${q(c.name)}) AS n${i}, COUNT(DISTINCT ${q(c.name)}) AS d${i}, `
624
+ + `MIN(${q(c.name)}) AS mn${i}, MAX(${q(c.name)}) AS mx${i}`);
625
+ const row = (await this.read(db, `SELECT COUNT(*) AS total${aggs.length ? `, ${aggs.join(', ')}` : ''} FROM ${from}`, [])).rows[0] ?? {};
626
+ const basis = Number(row.total ?? 0);
627
+ const columns = [];
628
+ for (const [i, c] of meta.columns.entries()) {
629
+ const nonNull = Number(row[`n${i}`] ?? 0);
630
+ const distinct = Number(row[`d${i}`] ?? 0);
631
+ const profiled = {
632
+ column: c.name,
633
+ type: c.type,
634
+ nullFrac: basis ? (basis - nonNull) / basis : 0,
635
+ distinct,
636
+ min: row[`mn${i}`] ?? undefined,
637
+ max: row[`mx${i}`] ?? undefined,
638
+ };
639
+ if (nonNull > 0 && distinct < nonNull) {
640
+ const top = await this.read(db, `SELECT ${q(c.name)} AS v, COUNT(*) AS n FROM ${from} `
641
+ + `WHERE ${q(c.name)} IS NOT NULL GROUP BY ${q(c.name)} ORDER BY n DESC, v LIMIT 5`, []);
642
+ profiled.common = top.rows.map((t) => ({ value: t.v, frac: Number(t.n) / basis }));
643
+ }
644
+ columns.push(profiled);
645
+ }
646
+ return {
647
+ table: id,
648
+ rows: total,
649
+ columns,
650
+ source,
651
+ ...(source === 'sample' ? { sampled: basis } : {}),
652
+ ms: Math.round(performance.now() - started),
653
+ };
654
+ }
655
+ /**
656
+ * Run a statement the user wrote.
657
+ *
658
+ * On a **connection of its own, thrown away afterwards** — which is a
659
+ * heavier answer than the other two adapters need, and MySQL earns it.
660
+ *
661
+ * The obvious approach was `START TRANSACTION READ ONLY`, the way the
662
+ * Postgres adapter does it. Testing it against MySQL 8.4 showed it does not
663
+ * hold, in two steps neither of which is exotic:
664
+ *
665
+ * 1. `SET SESSION TRANSACTION READ WRITE` succeeds and **persists**. MySQL
666
+ * session variables are not transactional, so the `ROLLBACK` that
667
+ * follows does not undo it — unlike Postgres, where a plain `SET` inside
668
+ * a transaction is rolled back with everything else.
669
+ * 2. `DROP TABLE` then performs an *implicit commit*, ending the read-only
670
+ * transaction before the statement runs, so the DDL executes under the
671
+ * session mode the first step had just flipped.
672
+ *
673
+ * Two statements, two calls, and a dropped table — observed, not theorised.
674
+ *
675
+ * A connection per statement closes it without needing to know that story.
676
+ * Session state cannot outlive the run that set it, so no statement can
677
+ * arrange the conditions for the next one; and with autocommit and the
678
+ * session opened read-only, DDL is refused by the session mode rather than by
679
+ * a transaction that DDL can commit its way out of. The cost is a connection
680
+ * per run, which for a statement someone typed and is waiting on is nothing.
681
+ *
682
+ * Writes are the other path and keep the persistent write handle: it exists
683
+ * to write, and there is nothing to isolate it from.
684
+ */
685
+ async runSql(request) {
686
+ const stacked = refuseStacked(request.text);
687
+ if (stacked)
688
+ throw new Error(stacked);
689
+ const limit = clampLimit(request.limit);
690
+ const bounded = !request.write && looksWrappable(request.text);
691
+ const text = bounded ? boundStatement(request.text, limit) : trimStatement(request.text);
692
+ const started = performance.now();
693
+ if (request.write) {
694
+ const db = await this.writeHandle();
695
+ const [rows] = (await db.query({ sql: text }));
696
+ return {
697
+ columns: [], rows: [], ms: Math.round(performance.now() - started),
698
+ truncated: false, bounded: false,
699
+ affected: Number(rows?.affectedRows ?? 0),
700
+ sql: { text, params: [] },
701
+ };
702
+ }
703
+ const db = await this.open(true);
704
+ try {
705
+ /* `rowsAsArray` so two output columns of the same name keep both values;
706
+ the default object rows would let the second overwrite the first. */
707
+ let wrapped = bounded;
708
+ let [rows, fields] = (await db
709
+ .query({ sql: text, rowsAsArray: true })
710
+ .catch(async (err) => {
711
+ /* Not every legal statement survives being made a derived table.
712
+ MySQL refuses one whose columns share a name — `select id, id` is
713
+ fine on its own and not fine inside `FROM (…)` — and there are
714
+ others. Rather than enumerate them, the wrap is treated as an
715
+ optimisation that can fail: run the statement as written and say
716
+ the limit was not applied.
717
+
718
+ Safe to run twice because this connection cannot write, and the
719
+ second error is the better one to report anyway: it is about the
720
+ statement someone typed rather than about our wrapper. */
721
+ if (!bounded)
722
+ throw err;
723
+ wrapped = false;
724
+ return db.query({ sql: trimStatement(request.text), rowsAsArray: true });
725
+ }));
726
+ const all = Array.isArray(rows) ? rows : [];
727
+ const truncated = all.length > limit;
728
+ return {
729
+ columns: dedupe((fields ?? []).map((f) => f.name)),
730
+ rows: (truncated ? all.slice(0, limit) : all).map((row) => (Array.isArray(row) ? row : [row]).map(valueSafe)),
731
+ ms: Math.round(performance.now() - started),
732
+ truncated,
733
+ bounded: wrapped,
734
+ sql: { text: wrapped ? text : trimStatement(request.text), params: [] },
735
+ };
736
+ }
737
+ finally {
738
+ // Whatever the statement did to this session dies here.
739
+ await db.end().catch(() => { });
740
+ }
741
+ }
742
+ /**
743
+ * Delete one row. The guards are the SQLite adapter's, one for one.
744
+ *
745
+ * An edit refused on SQLite and allowed on MySQL would mean the safety of an
746
+ * editor depended on which database it was pointed at. The extra check here
747
+ * is the same one `update` carries: MySQL will delete *through* a simple view
748
+ * onto the table underneath, which is not what someone looking at a view
749
+ * expects to happen.
750
+ */
751
+ async remove(request) {
752
+ return this.writes.run(() => this.removeNow(request));
753
+ }
754
+ async removeNow(request) {
755
+ const found = await this.assertTable(request.table);
756
+ const { schema, name, isView } = found;
757
+ const id = `${schema}.${name}`;
758
+ if (isView)
759
+ throw new Refusal(`${id} is a view, so no single row can be identified.`);
760
+ const db = await this.conn();
761
+ const { rows: cols } = await this.read(db, `SELECT COLUMN_NAME, COLUMN_KEY FROM information_schema.COLUMNS
762
+ WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ?`, [schema, name]);
763
+ const pk = cols.filter((c) => c.COLUMN_KEY === 'PRI').map((c) => c.COLUMN_NAME);
764
+ if (!pk.length)
765
+ throw new Refusal(`${id} has no primary key, so no single row can be identified.`);
766
+ const keyColumns = Object.keys(request.key);
767
+ const same = keyColumns.length === pk.length && pk.every((c) => keyColumns.includes(c));
768
+ if (!same) {
769
+ throw new Refusal(`Deleting from ${id} needs exactly its primary key (${pk.join(', ')}).`);
770
+ }
771
+ const filter = {
772
+ groups: [pk.map((column) => ({ column, op: '=', value: request.key[column] }))],
773
+ };
774
+ const matches = await this.count(request.table, filter);
775
+ if (matches !== 1) {
776
+ throw new Refusal(`Refused: that key matches ${matches} rows, not 1. A delete is only allowed when it is certain which row it removes.`);
777
+ }
778
+ const where = buildWhere(filter, MYSQL_STYLE);
779
+ const text = `DELETE FROM ${qualify(found)}${where.text}`;
780
+ const write = await this.writeHandle();
781
+ const [result] = (await write.execute(text, where.params));
782
+ return { affected: Number(result?.affectedRows ?? 0), sql: { text, params: where.params } };
783
+ }
784
+ async writeHandle() {
785
+ if (!this.writeClient)
786
+ this.writeClient = await this.open(false);
787
+ return this.writeClient;
788
+ }
789
+ /**
790
+ * Several writes as one, or none of them.
791
+ *
792
+ * `BEGIN` / `COMMIT` on the write connection, through `query` rather than
793
+ * `execute`: MySQL's prepared-statement protocol will not take a
794
+ * transaction-control statement, and these carry no parameters to prepare.
795
+ *
796
+ * Worth naming what this cannot promise on this engine: MySQL commits DDL
797
+ * implicitly, so a `CREATE TABLE` reached through raw SQL inside one of
798
+ * these ends the transaction where it stands. Nothing here issues DDL, and
799
+ * a caller who does through `sql` has left the guarantee behind.
800
+ */
801
+ async transaction(run) {
802
+ return this.writes.run(async () => {
803
+ const write = await this.writeHandle();
804
+ await write.query({ sql: 'BEGIN' });
805
+ try {
806
+ const out = await run({
807
+ insert: (r) => this.insertNow(r),
808
+ remove: (r) => this.removeNow(r),
809
+ update: (r) => this.updateNow(r),
810
+ });
811
+ await write.query({ sql: 'COMMIT' });
812
+ return out;
813
+ }
814
+ catch (err) {
815
+ await write.query({ sql: 'ROLLBACK' }).catch(() => { });
816
+ throw err;
817
+ }
818
+ });
819
+ }
820
+ async close() {
821
+ const open = [this.client, this.writeClient].filter((c) => Boolean(c));
822
+ this.client = null;
823
+ this.writeClient = null;
824
+ await Promise.allSettled(open.map((c) => c.end()));
825
+ }
826
+ /**
827
+ * Identifiers cannot be bound, so a table name is checked against the
828
+ * catalog before it is quoted into a statement. The check is a parameterised
829
+ * query, which means the untrusted string never reaches SQL as syntax.
830
+ *
831
+ * An unqualified name means the database the connection is in, which is what
832
+ * `USE shop; SELECT * FROM invoice` means to anyone who has used MySQL.
833
+ */
834
+ async assertTable(id) {
835
+ const db = await this.conn();
836
+ const dot = id.indexOf('.');
837
+ const schema = dot === -1 ? (this.databases[0] ?? '') : id.slice(0, dot);
838
+ const name = dot === -1 ? id : id.slice(dot + 1);
839
+ const found = await this.read(db, `SELECT TABLE_TYPE FROM information_schema.TABLES
840
+ WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ?`, [schema, name]);
841
+ /* Restricted to the browsed set as well as to what exists: a server hosts
842
+ every database it has in one catalog, and a name typed into the URL bar
843
+ must not become a way to read one this session never opened. */
844
+ if (!found.rows.length || !this.databases.includes(schema)) {
845
+ throw new Error(`No table or view called "${id}" in this database.`);
846
+ }
847
+ return { schema, name, isView: found.rows[0].TABLE_TYPE !== 'BASE TABLE' };
848
+ }
849
+ /** One place that unwraps mysql2's `[rows, fields]` pair. */
850
+ async read(db, text, params = []) {
851
+ const [rows, fields] = await db.execute(text, params);
852
+ return {
853
+ rows: (Array.isArray(rows) ? rows : []),
854
+ fields: (Array.isArray(fields) ? fields : []),
855
+ };
856
+ }
857
+ }
858
+ /** `` `shop`.`invoice` `` — two identifiers, never one containing a dot. */
859
+ function qualify(table) {
860
+ return `${MYSQL_STYLE.quote(table.schema)}.${MYSQL_STYLE.quote(table.name)}`;
861
+ }
862
+ /**
863
+ * A row count on its way into a placeholder, as digits rather than a number.
864
+ *
865
+ * mysql2 encodes every JavaScript number in the prepared-statement protocol as
866
+ * a DOUBLE, and MySQL refuses a DOUBLE where it wants a row count: `LIMIT ?`
867
+ * bound to 20 fails with ER_WRONG_ARGUMENTS (1210) instead of returning twenty
868
+ * rows. A decimal string is sent as VAR_STRING, which the server converts, and
869
+ * the alternative — writing the number into the statement text — would put a
870
+ * value in the SQL for the sake of a driver quirk.
871
+ *
872
+ * Safe by construction rather than by escaping: the only callers pass the
873
+ * output of `clampLimit` or `clampOffset`, which are integers this process
874
+ * computed and never anything a user typed.
875
+ */
876
+ function rowCount(n) {
877
+ return String(Math.floor(n));
878
+ }
879
+ /**
880
+ * The same fix, applied to a statement `compileView` built.
881
+ *
882
+ * The view compiler ends every statement it produces with `LIMIT ? OFFSET ?`
883
+ * and pushes the two clamped integers last. Matching on the text rather than
884
+ * trusting that is what makes this exact: if the compiler ever stops emitting
885
+ * that tail, the parameters are left alone and the failure is a loud one from
886
+ * the server, not a filter value quietly changing type.
887
+ */
888
+ function bindRowCounts(text, params) {
889
+ if (params.length < 2 || !/LIMIT \?\s+OFFSET \?\s*$/.test(text))
890
+ return params;
891
+ const out = [...params];
892
+ const tail = out.length - 2;
893
+ if (typeof out[tail] !== 'number' || typeof out[tail + 1] !== 'number')
894
+ return params;
895
+ out[tail] = rowCount(out[tail]);
896
+ out[tail + 1] = rowCount(out[tail + 1]);
897
+ return out;
898
+ }
899
+ /**
900
+ * Load `mysql2` on demand.
901
+ *
902
+ * Imported lazily, and through a variable, so the driver is only required when
903
+ * someone actually opens a MySQL URL. The indirection also stops the type
904
+ * checker demanding the package be installed to compile this file.
905
+ */
906
+ async function driver() {
907
+ const specifier = 'mysql2/promise';
908
+ const mysql = (await import(specifier).catch(() => {
909
+ throw new Error('MySQL support needs the "mysql2" package. Install it with: npm install mysql2');
910
+ }));
911
+ const createConnection = mysql.createConnection ?? mysql.default?.createConnection;
912
+ if (!createConnection) {
913
+ throw new Error('Could not find a createConnection export in the "mysql2" package.');
914
+ }
915
+ return createConnection;
916
+ }
917
+ /** Keep the password out of the label, the logs and the UI. */
918
+ function redact(connectionString) {
919
+ try {
920
+ const url = new URL(connectionString);
921
+ return `${url.hostname}${url.pathname}`;
922
+ }
923
+ catch {
924
+ return 'mysql';
925
+ }
926
+ }
927
+ /** BigInt and Buffer are common in drivers and neither survives JSON. */
928
+ /** One value, made safe to send as JSON. See `toJsonSafe` for the row form. */
929
+ function valueSafe(v) {
930
+ if (typeof v === 'bigint')
931
+ return v.toString();
932
+ if (v instanceof Uint8Array)
933
+ return `<${v.byteLength} bytes>`;
934
+ if (v instanceof Date)
935
+ return v.toISOString();
936
+ return v;
937
+ }
938
+ function toJsonSafe(row) {
939
+ const out = {};
940
+ for (const [k, v] of Object.entries(row)) {
941
+ if (typeof v === 'bigint')
942
+ out[k] = v.toString();
943
+ else if (v instanceof Uint8Array)
944
+ out[k] = `<${v.byteLength} bytes>`;
945
+ else if (v instanceof Date)
946
+ out[k] = v.toISOString();
947
+ else
948
+ out[k] = v;
949
+ }
950
+ return out;
951
+ }