@pvidaal07/heydb 0.0.0-stage → 2.0.0-beta.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 (51) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/LICENSE +21 -0
  3. package/README.md +153 -2
  4. package/extensions/commands.ts +732 -0
  5. package/extensions/context.ts +37 -0
  6. package/extensions/deps.ts +32 -0
  7. package/extensions/heydb.ts +20 -0
  8. package/extensions/secret-input.ts +120 -0
  9. package/extensions/tools.ts +402 -0
  10. package/lib/about.ts +7 -0
  11. package/lib/config/operations.ts +75 -0
  12. package/lib/config/store.ts +73 -0
  13. package/lib/config/types.ts +37 -0
  14. package/lib/config/validate.ts +136 -0
  15. package/lib/engines/index.ts +66 -0
  16. package/lib/engines/mysql/catalog.ts +371 -0
  17. package/lib/engines/mysql/introspect.ts +186 -0
  18. package/lib/engines/postgres/catalog.ts +390 -0
  19. package/lib/engines/postgres/introspect.ts +240 -0
  20. package/lib/knowledge/operations.ts +223 -0
  21. package/lib/knowledge/query.ts +129 -0
  22. package/lib/knowledge/types.ts +64 -0
  23. package/lib/knowledge/validate.ts +151 -0
  24. package/lib/model/diff.ts +218 -0
  25. package/lib/model/hash.ts +88 -0
  26. package/lib/model/names.ts +141 -0
  27. package/lib/model/order.ts +47 -0
  28. package/lib/model/references.ts +251 -0
  29. package/lib/model/snapshot.ts +128 -0
  30. package/lib/model/stats.ts +97 -0
  31. package/lib/model/validate.ts +240 -0
  32. package/lib/render/markdown.ts +508 -0
  33. package/lib/search.ts +530 -0
  34. package/lib/secrets.ts +149 -0
  35. package/lib/store/envfile.ts +124 -0
  36. package/lib/store/files.ts +57 -0
  37. package/lib/store/guards.ts +68 -0
  38. package/lib/store/knowledge.ts +139 -0
  39. package/lib/store/project.ts +28 -0
  40. package/lib/store/snapshots.ts +143 -0
  41. package/lib/usecases/author.ts +51 -0
  42. package/lib/usecases/code-scan.ts +435 -0
  43. package/lib/usecases/init.ts +111 -0
  44. package/lib/usecases/knowledge.ts +214 -0
  45. package/lib/usecases/project.ts +377 -0
  46. package/lib/usecases/resolve.ts +186 -0
  47. package/lib/usecases/sharing.ts +83 -0
  48. package/lib/usecases/status.ts +37 -0
  49. package/lib/usecases/sync.ts +33 -0
  50. package/package.json +69 -5
  51. package/skills/heydb/SKILL.md +67 -0
@@ -0,0 +1,371 @@
1
+ import { formatName } from "../../model/names.ts";
2
+ import { sortSnapshot } from "../../model/order.ts";
3
+ import type {
4
+ Column,
5
+ Database,
6
+ ForeignKey,
7
+ Index,
8
+ ReferentialAction,
9
+ Routine,
10
+ Snapshot,
11
+ Table,
12
+ Trigger,
13
+ Warning,
14
+ } from "../../model/snapshot.ts";
15
+
16
+ // Pure translation from MySQL/MariaDB catalog rows to a Snapshot. It never
17
+ // talks to a server: introspect.ts runs the queries and hands the rows here,
18
+ // so every rule below is tested with hand-written rows and no database.
19
+ //
20
+ // Row field names match the aliases used in introspect.ts queries.
21
+
22
+ export interface MysqlCatalog {
23
+ /** Result of SELECT VERSION(), e.g. "8.0.36" or "10.6.18-MariaDB". */
24
+ readonly version: string;
25
+ readonly capturedAt: string;
26
+ /** Databases the caller asked for; null means "every visible database". */
27
+ readonly requestedDatabases: readonly string[] | null;
28
+ /** Databases actually captured (visible and requested). */
29
+ readonly databases: readonly string[];
30
+ readonly tables: readonly TableRow[];
31
+ readonly columns: readonly ColumnRow[];
32
+ readonly indexColumns: readonly IndexColumnRow[];
33
+ readonly foreignKeyColumns: readonly ForeignKeyColumnRow[];
34
+ readonly triggers: readonly TriggerRow[];
35
+ readonly routines: readonly RoutineRow[];
36
+ readonly views: readonly ViewRow[];
37
+ /** The current user's own privileges, global (databaseName null) or per database. */
38
+ readonly privileges: readonly PrivilegeRow[];
39
+ }
40
+
41
+ export interface TableRow {
42
+ readonly databaseName: string;
43
+ readonly tableName: string;
44
+ /** "BASE TABLE", "VIEW", or MariaDB's "SYSTEM VERSIONED". */
45
+ readonly tableType: string;
46
+ readonly storageEngine: string | null;
47
+ readonly tableRows: number | string | null;
48
+ readonly tableComment: string | null;
49
+ }
50
+
51
+ export interface ColumnRow {
52
+ readonly databaseName: string;
53
+ readonly tableName: string;
54
+ readonly columnName: string;
55
+ readonly position: number;
56
+ readonly columnType: string;
57
+ readonly isNullable: string;
58
+ readonly columnDefault: string | null;
59
+ readonly columnComment: string | null;
60
+ readonly extra: string | null;
61
+ readonly collation: string | null;
62
+ readonly generationExpression: string | null;
63
+ }
64
+
65
+ export interface IndexColumnRow {
66
+ readonly databaseName: string;
67
+ readonly tableName: string;
68
+ readonly indexName: string;
69
+ readonly sequence: number;
70
+ /** null for a functional index part (MySQL 8.0.13+). */
71
+ readonly columnName: string | null;
72
+ readonly nonUnique: number | string;
73
+ readonly indexType: string;
74
+ }
75
+
76
+ export interface ForeignKeyColumnRow {
77
+ readonly databaseName: string;
78
+ readonly tableName: string;
79
+ readonly constraintName: string;
80
+ readonly position: number;
81
+ readonly columnName: string;
82
+ readonly referencedDatabase: string;
83
+ readonly referencedTable: string;
84
+ readonly referencedColumn: string;
85
+ readonly updateRule: string;
86
+ readonly deleteRule: string;
87
+ }
88
+
89
+ export interface TriggerRow {
90
+ readonly databaseName: string;
91
+ readonly tableName: string;
92
+ readonly triggerName: string;
93
+ readonly timing: string;
94
+ readonly event: string;
95
+ }
96
+
97
+ export interface RoutineRow {
98
+ readonly databaseName: string;
99
+ readonly routineName: string;
100
+ readonly routineType: string;
101
+ }
102
+
103
+ export interface ViewRow {
104
+ readonly databaseName: string;
105
+ readonly tableName: string;
106
+ /** Empty (or null) when the user lacks SHOW VIEW on the view. */
107
+ readonly viewDefinition: string | null;
108
+ }
109
+
110
+ export interface PrivilegeRow {
111
+ readonly databaseName: string | null;
112
+ readonly privilege: string;
113
+ }
114
+
115
+ const REFERENTIAL_ACTIONS: readonly ReferentialAction[] = ["CASCADE", "SET NULL", "SET DEFAULT", "RESTRICT", "NO ACTION"];
116
+
117
+ // INFORMATION_SCHEMA only lists the triggers and routines the user is allowed
118
+ // to see. Without these privileges the lists come back empty with no error,
119
+ // so heydb warns instead of presenting an incomplete picture as complete.
120
+ const TRIGGER_PRIVILEGES = { global: ["TRIGGER"], database: ["TRIGGER"] };
121
+ const ROUTINE_PRIVILEGES = {
122
+ global: ["SHOW_ROUTINE", "EXECUTE", "ALTER ROUTINE", "CREATE ROUTINE"],
123
+ // SHOW_ROUTINE (MySQL 8.0.20+) can only be granted globally.
124
+ database: ["EXECUTE", "ALTER ROUTINE", "CREATE ROUTINE"],
125
+ };
126
+
127
+ export function buildSnapshot(catalog: MysqlCatalog): Snapshot {
128
+ const isMariaDb = catalog.version.toLowerCase().includes("mariadb");
129
+ const captured = new Set(catalog.databases);
130
+ const inCapturedDatabase = (row: { databaseName: string }) => captured.has(row.databaseName);
131
+
132
+ // Group every kind of row by its table once, so building each table is a lookup.
133
+ const columnsByTable = groupBy(catalog.columns.filter(inCapturedDatabase), tableKey);
134
+ const indexesByTable = groupBy(catalog.indexColumns.filter(inCapturedDatabase), tableKey);
135
+ const foreignKeysByTable = groupBy(catalog.foreignKeyColumns.filter(inCapturedDatabase), tableKey);
136
+ const triggersByTable = groupBy(catalog.triggers.filter(inCapturedDatabase), tableKey);
137
+ const tablesByDatabase = groupBy(catalog.tables.filter(inCapturedDatabase), (row) => row.databaseName);
138
+ const routinesByDatabase = groupBy(catalog.routines.filter(inCapturedDatabase), (row) => row.databaseName);
139
+ const definitionsByView = new Map(catalog.views.map((row) => [tableKey(row), row.viewDefinition]));
140
+
141
+ const databases: Database[] = [...captured].sort().map((name) => ({
142
+ name,
143
+ schemas: [
144
+ {
145
+ name,
146
+ tables: (tablesByDatabase.get(name) ?? []).map((row) => {
147
+ const key = tableKey(row);
148
+ return buildTable(
149
+ row,
150
+ columnsByTable.get(key) ?? [],
151
+ indexesByTable.get(key) ?? [],
152
+ foreignKeysByTable.get(key) ?? [],
153
+ triggersByTable.get(key) ?? [],
154
+ definitionsByView.get(key) ?? null,
155
+ isMariaDb,
156
+ );
157
+ }),
158
+ routines: (routinesByDatabase.get(name) ?? []).map(buildRoutine),
159
+ },
160
+ ],
161
+ }));
162
+
163
+ // Return the canonical order so every consumer sees the same snapshot,
164
+ // whatever order the server returned the rows in.
165
+ return sortSnapshot({
166
+ formatVersion: 1,
167
+ engine: { kind: isMariaDb ? "mariadb" : "mysql", version: catalog.version },
168
+ capturedAt: catalog.capturedAt,
169
+ databases,
170
+ warnings: [
171
+ ...missingDatabaseWarnings(catalog.requestedDatabases, captured),
172
+ ...hiddenObjectWarnings([...captured], catalog.privileges),
173
+ ...hiddenViewDefinitionWarnings(databases),
174
+ ],
175
+ });
176
+ }
177
+
178
+ function buildTable(
179
+ row: TableRow,
180
+ columnRows: readonly ColumnRow[],
181
+ indexRows: readonly IndexColumnRow[],
182
+ foreignKeyRows: readonly ForeignKeyColumnRow[],
183
+ triggerRows: readonly TriggerRow[],
184
+ viewDefinition: string | null,
185
+ isMariaDb: boolean,
186
+ ): Table {
187
+ const isView = row.tableType === "VIEW";
188
+ const primaryKeyRows = indexRows.filter((r) => r.indexName === "PRIMARY");
189
+ return {
190
+ name: row.tableName,
191
+ kind: isView ? "view" : "table",
192
+ // MySQL reports the comment "VIEW" for every view: it is not a real comment.
193
+ comment: isView ? "" : (row.tableComment ?? ""),
194
+ storageEngine: row.storageEngine,
195
+ rowEstimate: isView || row.tableRows === null ? null : Number(row.tableRows),
196
+ columns: [...columnRows].sort((a, b) => a.position - b.position).map((c) => buildColumn(c, isMariaDb)),
197
+ primaryKey: [...primaryKeyRows].sort((a, b) => a.sequence - b.sequence).map(indexPartName),
198
+ indexes: buildIndexes(indexRows.filter((r) => r.indexName !== "PRIMARY")),
199
+ foreignKeys: buildForeignKeys(foreignKeyRows),
200
+ triggers: buildTriggers(triggerRows),
201
+ // An empty definition is not an empty SELECT: the server hid it.
202
+ viewDefinition: isView && viewDefinition ? viewDefinition : null,
203
+ };
204
+ }
205
+
206
+ function buildColumn(row: ColumnRow, isMariaDb: boolean): Column {
207
+ const extra = (row.extra ?? "").toUpperCase();
208
+ const generatedKind = /\b(VIRTUAL|STORED|PERSISTENT) GENERATED\b/.exec(extra)?.[1];
209
+ return {
210
+ name: row.columnName,
211
+ position: row.position,
212
+ type: row.columnType,
213
+ nullable: row.isNullable === "YES",
214
+ // MariaDB 10.2.7+ reports "no default" as the text NULL; MySQL uses a real NULL.
215
+ default: isMariaDb && row.columnDefault === "NULL" ? null : row.columnDefault,
216
+ comment: row.columnComment ?? "",
217
+ autoIncrement: extra.includes("AUTO_INCREMENT"),
218
+ generated:
219
+ generatedKind === undefined
220
+ ? null
221
+ : { expression: row.generationExpression ?? "", stored: generatedKind !== "VIRTUAL" },
222
+ collation: row.collation,
223
+ };
224
+ }
225
+
226
+ function buildIndexes(rows: readonly IndexColumnRow[]): Index[] {
227
+ return [...groupBy(rows, (r) => r.indexName)].map(([name, parts]) => {
228
+ const sorted = [...parts].sort((a, b) => a.sequence - b.sequence);
229
+ const first = sorted[0]!; // groupBy never creates empty groups
230
+ return {
231
+ name,
232
+ columns: sorted.map(indexPartName),
233
+ unique: Number(first.nonUnique) === 0,
234
+ method: first.indexType,
235
+ };
236
+ });
237
+ }
238
+
239
+ function indexPartName(row: IndexColumnRow): string {
240
+ return row.columnName ?? "(expression)";
241
+ }
242
+
243
+ function buildForeignKeys(rows: readonly ForeignKeyColumnRow[]): ForeignKey[] {
244
+ return [...groupBy(rows, (r) => r.constraintName)].map(([name, parts]) => {
245
+ const sorted = [...parts].sort((a, b) => a.position - b.position);
246
+ const first = sorted[0]!;
247
+ const where = `${first.databaseName}.${first.tableName}.${name}`;
248
+ return {
249
+ name,
250
+ columns: sorted.map((r) => r.columnName),
251
+ references: {
252
+ database: first.referencedDatabase,
253
+ schema: first.referencedDatabase,
254
+ table: first.referencedTable,
255
+ columns: sorted.map((r) => r.referencedColumn),
256
+ },
257
+ onDelete: referentialAction(first.deleteRule, where),
258
+ onUpdate: referentialAction(first.updateRule, where),
259
+ };
260
+ });
261
+ }
262
+
263
+ function referentialAction(rule: string, where: string): ReferentialAction {
264
+ const action = REFERENTIAL_ACTIONS.find((candidate) => candidate === rule.toUpperCase());
265
+ if (action === undefined) throw new Error(`unknown foreign key rule "${rule}" on ${where}`);
266
+ return action;
267
+ }
268
+
269
+ function buildTriggers(rows: readonly TriggerRow[]): Trigger[] {
270
+ // MySQL and MariaDB triggers fire on exactly one event each.
271
+ return rows.map((row) => ({
272
+ name: row.triggerName,
273
+ timing: row.timing.toUpperCase() === "AFTER" ? "AFTER" : "BEFORE",
274
+ events: [row.event.toUpperCase() as Trigger["events"][number]],
275
+ }));
276
+ }
277
+
278
+ function buildRoutine(row: RoutineRow): Routine {
279
+ return { name: row.routineName, kind: row.routineType.toUpperCase() === "FUNCTION" ? "function" : "procedure" };
280
+ }
281
+
282
+ function missingDatabaseWarnings(requested: readonly string[] | null, captured: ReadonlySet<string>): Warning[] {
283
+ return [...new Set(requested ?? [])]
284
+ .filter((name) => !captured.has(name))
285
+ .map((name) => ({
286
+ code: "database_not_visible",
287
+ message: `database "${name}" does not exist or the user has no privileges on it`,
288
+ target: name,
289
+ }));
290
+ }
291
+
292
+ function hiddenObjectWarnings(databases: readonly string[], privileges: readonly PrivilegeRow[]): Warning[] {
293
+ const has = (database: string, allowed: { global: string[]; database: string[] }) =>
294
+ privileges.some((p) => {
295
+ const privilege = p.privilege.toUpperCase();
296
+ if (p.databaseName === null) return allowed.global.includes(privilege);
297
+ return allowed.database.includes(privilege) && grantMatches(p.databaseName, database);
298
+ });
299
+
300
+ const warnings: Warning[] = [];
301
+ for (const database of databases) {
302
+ if (!has(database, TRIGGER_PRIVILEGES)) {
303
+ warnings.push({
304
+ code: "triggers_hidden",
305
+ message: `triggers in "${database}" are only listed with the TRIGGER privilege; the list may be incomplete`,
306
+ target: database,
307
+ });
308
+ }
309
+ if (!has(database, ROUTINE_PRIVILEGES)) {
310
+ warnings.push({
311
+ code: "routines_hidden",
312
+ message: `routines in "${database}" are only listed with the SHOW_ROUTINE, EXECUTE, ALTER ROUTINE or CREATE ROUTINE privilege; the list may be incomplete`,
313
+ target: database,
314
+ });
315
+ }
316
+ }
317
+ return warnings;
318
+ }
319
+
320
+ // Without SHOW VIEW on a view, INFORMATION_SCHEMA.VIEWS still lists it but with
321
+ // an empty definition, so the warning is per view and needs no privilege check.
322
+ function hiddenViewDefinitionWarnings(databases: readonly Database[]): Warning[] {
323
+ return databases.flatMap((database) =>
324
+ database.schemas.flatMap((schema) =>
325
+ schema.tables
326
+ .filter((table) => table.kind === "view" && table.viewDefinition === null)
327
+ .map((view) => ({
328
+ code: "view_definition_hidden",
329
+ message: `definition of view "${database.name}.${view.name}" is only visible with the SHOW VIEW privilege`,
330
+ target: formatName({ database: database.name, schema: schema.name, table: view.name }),
331
+ })),
332
+ ),
333
+ );
334
+ }
335
+
336
+ // Database-level grants may use LIKE wildcards: "_" is any character, "%" any
337
+ // sequence, and a backslash makes the next character literal. The grant
338
+ // shop\_% covers "shop_es" but not "shopXes".
339
+ function grantMatches(pattern: string, database: string): boolean {
340
+ let regex = "^";
341
+ for (let index = 0; index < pattern.length; index += 1) {
342
+ const char = pattern[index]!;
343
+ if (char === "\\" && index + 1 < pattern.length) {
344
+ index += 1;
345
+ regex += escapeRegExp(pattern[index]!);
346
+ } else if (char === "_") regex += ".";
347
+ else if (char === "%") regex += ".*";
348
+ else regex += escapeRegExp(char);
349
+ }
350
+ return new RegExp(`${regex}$`, "s").test(database);
351
+ }
352
+
353
+ function escapeRegExp(text: string): string {
354
+ return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
355
+ }
356
+
357
+ function tableKey(row: { databaseName: string; tableName: string }): string {
358
+ // \u0000 cannot appear in identifiers, so the key is unambiguous.
359
+ return `${row.databaseName}\u0000${row.tableName}`;
360
+ }
361
+
362
+ function groupBy<T>(items: readonly T[], keyOf: (item: T) => string): Map<string, T[]> {
363
+ const groups = new Map<string, T[]>();
364
+ for (const item of items) {
365
+ const key = keyOf(item);
366
+ const group = groups.get(key);
367
+ if (group === undefined) groups.set(key, [item]);
368
+ else group.push(item);
369
+ }
370
+ return groups;
371
+ }
@@ -0,0 +1,186 @@
1
+ import mysql, { type Connection, type RowDataPacket } from "mysql2/promise";
2
+
3
+ import type { Snapshot } from "../../model/snapshot.ts";
4
+ import {
5
+ buildSnapshot,
6
+ type ColumnRow,
7
+ type ForeignKeyColumnRow,
8
+ type IndexColumnRow,
9
+ type PrivilegeRow,
10
+ type RoutineRow,
11
+ type TableRow,
12
+ type TriggerRow,
13
+ type ViewRow,
14
+ } from "./catalog.ts";
15
+
16
+ // Reads the structure of a MySQL or MariaDB server through INFORMATION_SCHEMA.
17
+ // One query per kind of object for ALL captured databases at once (never one
18
+ // query per table), so the cost grows with the number of object kinds, not
19
+ // with the number of tables. The rows go to catalog.ts, which builds the
20
+ // snapshot without touching the server.
21
+
22
+ export interface MysqlConnectionOptions {
23
+ readonly host: string;
24
+ readonly port: number;
25
+ readonly user: string;
26
+ /** The resolved password. Callers resolve secret references before this point. */
27
+ readonly password: string;
28
+ /** Databases to capture; null or omitted captures every visible user database. */
29
+ readonly databases?: readonly string[] | null;
30
+ readonly connectTimeoutMs?: number;
31
+ }
32
+
33
+ const SYSTEM_DATABASES = ["information_schema", "mysql", "performance_schema", "sys"];
34
+
35
+ // GRANTEE looks like 'user'@'host'. An admin sees every user's privileges, so
36
+ // filter to the account this session is authenticated as. CHAR(39) is a single
37
+ // quote: writing it as "'" would break on servers running with ANSI_QUOTES,
38
+ // where double quotes delimit names, not text.
39
+ const CURRENT_GRANTEE =
40
+ "CONCAT(CHAR(39), SUBSTRING_INDEX(CURRENT_USER(), '@', 1), CHAR(39), '@', CHAR(39), SUBSTRING_INDEX(CURRENT_USER(), '@', -1), CHAR(39))";
41
+
42
+ export async function introspectMysql(options: MysqlConnectionOptions): Promise<Snapshot> {
43
+ const connection = await connectReadOnly(options);
44
+ try {
45
+ const capturedAt = new Date().toISOString();
46
+ const [versionRows] = await connection.query<RowDataPacket[]>("SELECT VERSION() AS version");
47
+ const version = String(versionRows[0]?.version ?? "");
48
+
49
+ const requested = options.databases ?? null;
50
+ const visible = await visibleDatabases(connection);
51
+ const databases = requested === null ? visible : visible.filter((name) => requested.includes(name));
52
+
53
+ const empty = {
54
+ tables: [],
55
+ columns: [],
56
+ indexColumns: [],
57
+ foreignKeyColumns: [],
58
+ triggers: [],
59
+ routines: [],
60
+ views: [],
61
+ privileges: [],
62
+ };
63
+ const rows = databases.length === 0 ? empty : await readCatalog(connection, databases);
64
+
65
+ return buildSnapshot({ version, capturedAt, requestedDatabases: requested, databases, ...rows });
66
+ } finally {
67
+ await connection.end();
68
+ }
69
+ }
70
+
71
+ /**
72
+ * Opens a connection whose session is READ ONLY: even a user with write
73
+ * privileges cannot change data or tables through it (INSERT, UPDATE, DROP,
74
+ * CALL of a writing procedure all fail). It does not block SET GLOBAL; heydb
75
+ * only ever sends fixed SELECT statements. Defense in depth on top of
76
+ * recommending a read-only database user.
77
+ */
78
+ export async function connectReadOnly(options: MysqlConnectionOptions): Promise<Connection> {
79
+ let connection: Connection;
80
+ try {
81
+ connection = await mysql.createConnection({
82
+ host: options.host,
83
+ port: options.port,
84
+ user: options.user,
85
+ password: options.password,
86
+ connectTimeout: options.connectTimeoutMs ?? 10_000,
87
+ multipleStatements: false,
88
+ supportBigNumbers: true,
89
+ });
90
+ } catch (error) {
91
+ const reason = error instanceof Error ? error.message : String(error);
92
+ throw new Error(`mysql: cannot connect to ${options.host}:${options.port} as ${options.user}: ${reason}`, {
93
+ cause: error,
94
+ });
95
+ }
96
+ try {
97
+ // A neutral sql_mode makes the output independent of how the server is
98
+ // configured: MariaDB prints generated-column expressions with " instead of `
99
+ // under ANSI_QUOTES, and NO_BACKSLASH_ESCAPES would break the driver's escaping
100
+ // of query parameters. It only affects this session.
101
+ await connection.query("SET SESSION sql_mode = ''");
102
+ await connection.query("SET SESSION TRANSACTION READ ONLY");
103
+ } catch (error) {
104
+ // Never leave a half-configured connection open: it would keep the process alive.
105
+ connection.destroy();
106
+ const reason = error instanceof Error ? error.message : String(error);
107
+ throw new Error(`mysql: cannot open a read-only session on ${options.host}:${options.port}: ${reason}`, {
108
+ cause: error,
109
+ });
110
+ }
111
+ return connection;
112
+ }
113
+
114
+ async function visibleDatabases(connection: Connection): Promise<string[]> {
115
+ const [rows] = await connection.query<RowDataPacket[]>(
116
+ "SELECT SCHEMA_NAME AS name FROM INFORMATION_SCHEMA.SCHEMATA WHERE SCHEMA_NAME NOT IN (?) ORDER BY SCHEMA_NAME",
117
+ [SYSTEM_DATABASES],
118
+ );
119
+ return rows.map((row) => String(row.name));
120
+ }
121
+
122
+ async function readCatalog(connection: Connection, databases: readonly string[]) {
123
+ // mysql2 expands an array bound to "IN (?)" into a list of escaped values.
124
+ const select = async <T>(sql: string): Promise<T[]> => {
125
+ const [rows] = await connection.query<RowDataPacket[]>(sql, [databases]);
126
+ return rows as unknown as T[];
127
+ };
128
+
129
+ // One connection runs these one after another; Promise.all just waits for all of them.
130
+ const [tables, columns, indexColumns, foreignKeyColumns, triggers, routines, views, privileges] = await Promise.all([
131
+ select<TableRow>(`
132
+ SELECT TABLE_SCHEMA AS databaseName, TABLE_NAME AS tableName, TABLE_TYPE AS tableType,
133
+ ENGINE AS storageEngine, TABLE_ROWS AS tableRows, TABLE_COMMENT AS tableComment
134
+ FROM INFORMATION_SCHEMA.TABLES
135
+ WHERE TABLE_SCHEMA IN (?)`),
136
+ select<ColumnRow>(`
137
+ SELECT TABLE_SCHEMA AS databaseName, TABLE_NAME AS tableName, COLUMN_NAME AS columnName,
138
+ ORDINAL_POSITION AS position, COLUMN_TYPE AS columnType, IS_NULLABLE AS isNullable,
139
+ COLUMN_DEFAULT AS columnDefault, COLUMN_COMMENT AS columnComment, EXTRA AS extra,
140
+ COLLATION_NAME AS collation, GENERATION_EXPRESSION AS generationExpression
141
+ FROM INFORMATION_SCHEMA.COLUMNS
142
+ WHERE TABLE_SCHEMA IN (?)`),
143
+ select<IndexColumnRow>(`
144
+ SELECT TABLE_SCHEMA AS databaseName, TABLE_NAME AS tableName, INDEX_NAME AS indexName,
145
+ SEQ_IN_INDEX AS sequence, COLUMN_NAME AS columnName, NON_UNIQUE AS nonUnique,
146
+ INDEX_TYPE AS indexType
147
+ FROM INFORMATION_SCHEMA.STATISTICS
148
+ WHERE TABLE_SCHEMA IN (?)`),
149
+ select<ForeignKeyColumnRow>(`
150
+ SELECT k.TABLE_SCHEMA AS databaseName, k.TABLE_NAME AS tableName, k.CONSTRAINT_NAME AS constraintName,
151
+ k.ORDINAL_POSITION AS position, k.COLUMN_NAME AS columnName,
152
+ k.REFERENCED_TABLE_SCHEMA AS referencedDatabase, k.REFERENCED_TABLE_NAME AS referencedTable,
153
+ k.REFERENCED_COLUMN_NAME AS referencedColumn,
154
+ r.UPDATE_RULE AS updateRule, r.DELETE_RULE AS deleteRule
155
+ FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE k
156
+ JOIN INFORMATION_SCHEMA.REFERENTIAL_CONSTRAINTS r
157
+ ON r.CONSTRAINT_SCHEMA = k.CONSTRAINT_SCHEMA
158
+ AND r.CONSTRAINT_NAME = k.CONSTRAINT_NAME
159
+ AND r.TABLE_NAME = k.TABLE_NAME
160
+ WHERE k.TABLE_SCHEMA IN (?) AND k.REFERENCED_TABLE_NAME IS NOT NULL`),
161
+ select<TriggerRow>(`
162
+ SELECT EVENT_OBJECT_SCHEMA AS databaseName, EVENT_OBJECT_TABLE AS tableName,
163
+ TRIGGER_NAME AS triggerName, ACTION_TIMING AS timing, EVENT_MANIPULATION AS event
164
+ FROM INFORMATION_SCHEMA.TRIGGERS
165
+ WHERE EVENT_OBJECT_SCHEMA IN (?)`),
166
+ select<RoutineRow>(`
167
+ SELECT ROUTINE_SCHEMA AS databaseName, ROUTINE_NAME AS routineName, ROUTINE_TYPE AS routineType
168
+ FROM INFORMATION_SCHEMA.ROUTINES
169
+ WHERE ROUTINE_SCHEMA IN (?)`),
170
+ select<ViewRow>(`
171
+ SELECT TABLE_SCHEMA AS databaseName, TABLE_NAME AS tableName, VIEW_DEFINITION AS viewDefinition
172
+ FROM INFORMATION_SCHEMA.VIEWS
173
+ WHERE TABLE_SCHEMA IN (?)`),
174
+ // For a non-admin user both views list only that user's own privileges.
175
+ select<PrivilegeRow>(`
176
+ SELECT NULL AS databaseName, PRIVILEGE_TYPE AS privilege
177
+ FROM INFORMATION_SCHEMA.USER_PRIVILEGES
178
+ WHERE GRANTEE = ${CURRENT_GRANTEE}
179
+ UNION ALL
180
+ SELECT TABLE_SCHEMA AS databaseName, PRIVILEGE_TYPE AS privilege
181
+ FROM INFORMATION_SCHEMA.SCHEMA_PRIVILEGES
182
+ WHERE GRANTEE = ${CURRENT_GRANTEE} AND TABLE_SCHEMA IN (?)`),
183
+ ]);
184
+
185
+ return { tables, columns, indexColumns, foreignKeyColumns, triggers, routines, views, privileges };
186
+ }