@ecubelabs/mysql-mcp 0.0.0-stage → 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.
@@ -0,0 +1,685 @@
1
+ import { createHash } from 'crypto';
2
+ import * as os from 'os';
3
+ import * as path from 'path';
4
+ import { CatalogCollector } from './collect.js';
5
+ import { CatalogDocuments } from './docs.js';
6
+ import { renderDescribe, renderMap, renderToolDescriptionSuffix, searchCatalog } from './render.js';
7
+ import { CatalogStore } from './store.js';
8
+ import { emptyUsage, normalizeName, pruneJoins } from './types.js';
9
+ import { prepareCatalogQuery, resultColumnNames } from './usage.js';
10
+ const UNKNOWN_REFERENCE_REFRESH_COOLDOWN_MS = 60_000;
11
+ const UNKNOWN_REFERENCE_MEMORY_LIMIT = 256;
12
+ const CLOSE_DRAIN_DEADLINE_MS = 3_000;
13
+ const NOTE_MAX_LENGTH = 1_000;
14
+ const ALIAS_MAX_LENGTH = 200;
15
+ const NOTE_COUNT_LIMIT = 50;
16
+ const ALIAS_COUNT_LIMIT = 20;
17
+ function deadlineTimer(ms) {
18
+ let timer;
19
+ const expired = new Promise((resolve) => {
20
+ timer = setTimeout(resolve, ms);
21
+ });
22
+ return { expired, cancel: () => clearTimeout(timer) };
23
+ }
24
+ function shortHash(value) {
25
+ return createHash('sha256').update(value).digest('hex').slice(0, 16);
26
+ }
27
+ export function catalogIdentity(identity) {
28
+ const profile = identity.profile || 'default';
29
+ const safeProfile = profile.replace(/[^A-Za-z0-9_.-]/g, '_');
30
+ const endpointHash = shortHash(`${identity.target}\u0000${identity.user}`);
31
+ const directory = identity.customPath ?? path.join(os.homedir(), '.cache', 'mysql-mcp', 'catalog');
32
+ return {
33
+ filePath: path.join(directory, `${safeProfile}-${endpointHash}.json`),
34
+ fingerprint: endpointHash,
35
+ };
36
+ }
37
+ function sameName(a, b) {
38
+ return normalizeName(a) === normalizeName(b);
39
+ }
40
+ function tableVanished(qualified) {
41
+ return `${qualified} is no longer in the catalog; it was dropped while the request was in flight. Run mysql_catalog {action:"map"} to see what remains.`;
42
+ }
43
+ export class SchemaCatalog {
44
+ options;
45
+ store;
46
+ collector;
47
+ documents;
48
+ backgroundTasks = new Set();
49
+ unknownReferenceRefreshes = new Map();
50
+ closing = false;
51
+ closePromise = null;
52
+ toolDescriptionListener = null;
53
+ toolDescriptionAnnounced = false;
54
+ constructor(options) {
55
+ this.options = options;
56
+ this.store = new CatalogStore(options);
57
+ this.collector = new CatalogCollector(this.store, {
58
+ schemas: options.appSchemas.map((entry) => entry.schema),
59
+ ttlHours: options.ttlHours,
60
+ });
61
+ this.documents = new CatalogDocuments(this.store, {
62
+ repo: options.docsRepo,
63
+ ref: options.docsRef,
64
+ appSchemas: options.appSchemas,
65
+ });
66
+ }
67
+ isEnabled() {
68
+ return this.store.isEnabled();
69
+ }
70
+ startInventory() {
71
+ if (!this.isEnabled() || this.closing) {
72
+ return;
73
+ }
74
+ this.trackBackgroundTask(this.collector.collectInventory(false).catch((error) => {
75
+ console.error(`[catalog] inventory scan failed: ${error instanceof Error ? error.message : String(error)}`);
76
+ }));
77
+ }
78
+ toolDescriptionSuffix(budget) {
79
+ if (!this.isEnabled()) {
80
+ return '';
81
+ }
82
+ return renderToolDescriptionSuffix(this.store.snapshot(), budget);
83
+ }
84
+ onToolDescriptionFilled(listener) {
85
+ this.toolDescriptionListener = listener;
86
+ }
87
+ async announceToolDescription() {
88
+ if (this.toolDescriptionAnnounced || !this.toolDescriptionListener) {
89
+ return;
90
+ }
91
+ if (!this.store.hasSuccessfulUse()) {
92
+ return;
93
+ }
94
+ this.toolDescriptionAnnounced = true;
95
+ await this.toolDescriptionListener();
96
+ }
97
+ trackBackgroundTask(task) {
98
+ this.backgroundTasks.add(task);
99
+ void task.then(() => this.backgroundTasks.delete(task), () => this.backgroundTasks.delete(task));
100
+ }
101
+ resolveTable(input) {
102
+ const cleaned = input.replace(/`/g, '').trim();
103
+ const dot = cleaned.indexOf('.');
104
+ const requestedSchema = dot === -1 ? null : cleaned.slice(0, dot);
105
+ const requestedTable = dot === -1 ? cleaned : cleaned.slice(dot + 1);
106
+ const matches = this.store.lookup(requestedSchema, requestedTable);
107
+ if (matches.length === 1) {
108
+ return matches[0];
109
+ }
110
+ if (!requestedSchema && this.options.defaultSchema) {
111
+ return matches.find((match) => sameName(match.schema, this.options.defaultSchema)) ?? null;
112
+ }
113
+ return null;
114
+ }
115
+ documentReviewed(schema, table) {
116
+ return this.store.tableMeta(schema, table)?.docReviewed === true;
117
+ }
118
+ catalogKeyForReference(reference) {
119
+ const requestedSchema = reference.schema ?? this.options.defaultSchema;
120
+ if (!requestedSchema) {
121
+ return null;
122
+ }
123
+ const declared = this.options.appSchemas.find((entry) => sameName(entry.schema, requestedSchema));
124
+ return declared ? `${declared.schema}.${reference.table}`.toLowerCase() : null;
125
+ }
126
+ resolveReferences(references) {
127
+ return references
128
+ .map((reference) => this.resolveTable(reference.schema ? `${reference.schema}.${reference.table}` : reference.table))
129
+ .filter((value) => value !== null);
130
+ }
131
+ async ensureKnownTable(input) {
132
+ let resolved = this.resolveTable(input);
133
+ if (!resolved) {
134
+ await this.collector.collectInventory(false);
135
+ resolved = this.resolveTable(input);
136
+ }
137
+ if (!resolved) {
138
+ throw new Error(`Unknown or ambiguous table "${input}". Use a declared schema.table name.`);
139
+ }
140
+ return resolved;
141
+ }
142
+ async map() {
143
+ if (!this.isEnabled()) {
144
+ throw new Error('The schema catalog is disabled.');
145
+ }
146
+ if (this.collector.inventoryNeedsRefresh()) {
147
+ await this.collector.collectInventory(false);
148
+ }
149
+ let documentWarning = null;
150
+ if (this.documents.isConfigured()) {
151
+ try {
152
+ await this.documents.ensureAwake();
153
+ }
154
+ catch (error) {
155
+ documentWarning = error instanceof Error ? error.message : String(error);
156
+ }
157
+ }
158
+ return renderMap(this.store.snapshot(), documentWarning ?? this.documents.staleWarning());
159
+ }
160
+ async search(query, requestedLimit) {
161
+ if (!this.isEnabled()) {
162
+ throw new Error('The schema catalog is disabled.');
163
+ }
164
+ if (this.collector.inventoryNeedsRefresh()) {
165
+ await this.collector.collectInventory(false);
166
+ }
167
+ const limit = Math.min(100, Math.max(1, requestedLimit ?? 20));
168
+ return JSON.stringify(searchCatalog(this.store.snapshot(), query, limit), null, 2);
169
+ }
170
+ async describe(input) {
171
+ if (!this.isEnabled()) {
172
+ throw new Error('The schema catalog is disabled.');
173
+ }
174
+ const resolved = await this.ensureKnownTable(input);
175
+ await this.collector.collectTableDetail(resolved.schema, resolved.table);
176
+ let documentError = null;
177
+ if (this.documents.isConfigured()) {
178
+ try {
179
+ await this.documents.ensureAwake();
180
+ }
181
+ catch (error) {
182
+ documentError = error instanceof Error ? error.message : String(error);
183
+ }
184
+ }
185
+ const entry = this.store.readTable(resolved.schema, resolved.table);
186
+ if (!entry) {
187
+ throw new Error(`Table disappeared during refresh: ${input}`);
188
+ }
189
+ return renderDescribe(`${resolved.schema}.${resolved.table}`, entry, {
190
+ configured: this.documents.isConfigured(),
191
+ available: this.documents.isAvailable(),
192
+ ref: this.options.docsRef,
193
+ command: entry.curated.doc ? this.documents.documentCommand(entry.curated.doc.path) : null,
194
+ warning: documentError ?? this.documents.staleWarning(),
195
+ schema: resolved.schema,
196
+ }, this.store.readJoins(resolved.schema, resolved.table));
197
+ }
198
+ async refresh(target) {
199
+ if (!this.isEnabled()) {
200
+ throw new Error('The schema catalog is disabled.');
201
+ }
202
+ const requested = (target ?? '').replace(/`/g, '').trim();
203
+ const at = new Date().toISOString();
204
+ if (!requested) {
205
+ await this.collector.collectInventory(true);
206
+ return JSON.stringify({
207
+ refreshed: 'inventory',
208
+ ...this.inventoryReport(),
209
+ ...(this.documents.isConfigured() ? { documents: await this.refreshDocuments() } : {}),
210
+ at,
211
+ }, null, 2);
212
+ }
213
+ if (normalizeName(requested) === 'docs') {
214
+ return JSON.stringify({
215
+ refreshed: 'documents',
216
+ documents: await this.refreshDocuments(),
217
+ hint: 'A table actually named "docs" must be written as schema.docs.',
218
+ at,
219
+ }, null, 2);
220
+ }
221
+ const declared = requested.includes('.')
222
+ ? undefined
223
+ : this.options.appSchemas.find((entry) => sameName(entry.schema, requested));
224
+ if (declared) {
225
+ await this.collector.collectInventory(true);
226
+ return JSON.stringify({
227
+ refreshed: 'inventory',
228
+ requestedSchema: declared.schema,
229
+ note: '인벤토리는 선언된 모든 스키마를 쿼리 한 번으로 함께 갱신합니다.',
230
+ ...this.inventoryReport(),
231
+ at,
232
+ }, null, 2);
233
+ }
234
+ let resolved = this.resolveTable(requested);
235
+ if (!resolved && requested.includes('.')) {
236
+ await this.collector.collectInventory(true);
237
+ resolved = this.resolveTable(requested);
238
+ }
239
+ if (resolved) {
240
+ await this.collector.collectTableDetail(resolved.schema, resolved.table, true);
241
+ const entry = this.store.readTable(resolved.schema, resolved.table);
242
+ return JSON.stringify({
243
+ refreshed: 'table',
244
+ table: `${resolved.schema}.${resolved.table}`,
245
+ detailScannedAt: entry?.detailScannedAt ?? null,
246
+ columns: entry?.columns.length ?? 0,
247
+ indexes: entry?.indexes.length ?? 0,
248
+ foreignKeys: entry?.fks.length ?? 0,
249
+ at,
250
+ }, null, 2);
251
+ }
252
+ throw new Error(`Unknown refresh target "${requested}". Use schema.table for one table's ` +
253
+ 'columns, a declared schema name for the table inventory, "docs" for the ' +
254
+ 'document axis, or omit target for both.');
255
+ }
256
+ inventoryReport() {
257
+ const catalog = this.store.snapshot();
258
+ const schemas = Object.values(catalog.schemas);
259
+ return {
260
+ schemas: schemas.length,
261
+ tables: schemas.reduce((total, schema) => total + Object.keys(schema.tables).length, 0),
262
+ scannedAt: schemas[0]?.scannedAt ?? null,
263
+ };
264
+ }
265
+ async refreshDocuments() {
266
+ if (!this.documents.isConfigured()) {
267
+ return {
268
+ refreshed: false,
269
+ ref: null,
270
+ error: 'The document axis is disabled because MYSQL_DOCS_REPO or MYSQL_CODE_BRANCH is not configured.',
271
+ };
272
+ }
273
+ this.documents.resetWake();
274
+ try {
275
+ await this.documents.ensureAwake();
276
+ const docs = this.store.docsView();
277
+ return {
278
+ refreshed: true,
279
+ ref: docs.ref,
280
+ refCommit: docs.refCommit,
281
+ paths: docs.paths.length,
282
+ };
283
+ }
284
+ catch (error) {
285
+ return {
286
+ refreshed: false,
287
+ ref: this.options.docsRef,
288
+ error: error instanceof Error ? error.message : String(error),
289
+ };
290
+ }
291
+ }
292
+ async docsList(schemaName) {
293
+ if (!this.isEnabled()) {
294
+ throw new Error('The schema catalog is disabled.');
295
+ }
296
+ return this.documents.list(schemaName);
297
+ }
298
+ async docsRead(documentPath) {
299
+ if (!this.isEnabled()) {
300
+ throw new Error('The schema catalog is disabled.');
301
+ }
302
+ return this.documents.read(documentPath);
303
+ }
304
+ async link(links) {
305
+ if (!this.isEnabled()) {
306
+ throw new Error('The schema catalog is disabled.');
307
+ }
308
+ if (links.length === 0) {
309
+ throw new Error('mysql_catalog link requires a non-empty "links" array.');
310
+ }
311
+ const resolvedLinks = [];
312
+ for (const link of links) {
313
+ const table = await this.ensureKnownTable(link.table);
314
+ resolvedLinks.push({
315
+ schema: table.schema,
316
+ table: table.table,
317
+ doc: link.doc,
318
+ });
319
+ }
320
+ return this.documents.link(resolvedLinks);
321
+ }
322
+ async unlink(input) {
323
+ if (!this.isEnabled()) {
324
+ throw new Error('The schema catalog is disabled.');
325
+ }
326
+ const resolved = await this.ensureKnownTable(input);
327
+ return this.documents.unlink(resolved.schema, resolved.table);
328
+ }
329
+ async note(input, value) {
330
+ if (!this.isEnabled()) {
331
+ throw new Error('The schema catalog is disabled.');
332
+ }
333
+ const hasText = typeof value.text === 'string' && value.text.trim().length > 0;
334
+ const hasAlias = typeof value.alias === 'string' && value.alias.trim().length > 0;
335
+ if (hasText === hasAlias) {
336
+ throw new Error('mysql_catalog note requires exactly one of "text" or "alias".');
337
+ }
338
+ const resolved = await this.ensureKnownTable(input);
339
+ const kind = hasText ? 'note' : 'alias';
340
+ const content = (hasText ? value.text : value.alias)?.trim();
341
+ const limit = hasText ? NOTE_MAX_LENGTH : ALIAS_MAX_LENGTH;
342
+ if (content.length > limit) {
343
+ throw new Error(`${kind} exceeds the ${limit}-character limit.`);
344
+ }
345
+ const qualified = `${resolved.schema}.${resolved.table}`;
346
+ let full = false;
347
+ let missing = false;
348
+ this.store.update((catalog) => {
349
+ const curated = catalog.schemas[resolved.schema]?.tables[resolved.table]?.curated;
350
+ if (!curated) {
351
+ missing = true;
352
+ return;
353
+ }
354
+ if (hasText) {
355
+ if (curated.notes.includes(content)) {
356
+ return;
357
+ }
358
+ if (curated.notes.length >= NOTE_COUNT_LIMIT) {
359
+ full = true;
360
+ return;
361
+ }
362
+ curated.notes.push(content);
363
+ }
364
+ else {
365
+ if (curated.aliases.some((alias) => normalizeName(alias) === normalizeName(content))) {
366
+ return;
367
+ }
368
+ if (curated.aliases.length >= ALIAS_COUNT_LIMIT) {
369
+ full = true;
370
+ return;
371
+ }
372
+ curated.aliases.push(content);
373
+ }
374
+ });
375
+ if (missing) {
376
+ throw new Error(tableVanished(qualified));
377
+ }
378
+ if (full) {
379
+ throw new Error(`${qualified} already holds the maximum of ${hasText ? NOTE_COUNT_LIMIT : ALIAS_COUNT_LIMIT} ${kind}s. Clear them with mysql_catalog {action:"forget", target:"${qualified}", scope:"${kind}s"} first.`);
380
+ }
381
+ const table = this.store.readTable(resolved.schema, resolved.table);
382
+ return JSON.stringify({
383
+ target: qualified,
384
+ added: { [kind]: content },
385
+ notes: table?.curated.notes ?? [],
386
+ aliases: table?.curated.aliases ?? [],
387
+ }, null, 2);
388
+ }
389
+ async forget(input, scope) {
390
+ if (!this.isEnabled()) {
391
+ throw new Error('The schema catalog is disabled.');
392
+ }
393
+ const resolved = await this.ensureKnownTable(input);
394
+ const qualified = `${resolved.schema}.${resolved.table}`;
395
+ let removed = 0;
396
+ let missing = false;
397
+ this.store.update((catalog) => {
398
+ const table = catalog.schemas[resolved.schema]?.tables[resolved.table];
399
+ if (!table) {
400
+ missing = true;
401
+ return;
402
+ }
403
+ if (scope === 'notes') {
404
+ removed = table.curated.notes.length;
405
+ table.curated.notes = [];
406
+ }
407
+ else if (scope === 'aliases') {
408
+ removed = table.curated.aliases.length;
409
+ table.curated.aliases = [];
410
+ }
411
+ else if (scope === 'usage') {
412
+ removed = table.usage.count;
413
+ table.usage = emptyUsage();
414
+ }
415
+ else if (scope === 'joins') {
416
+ const prefix = `${qualified}.`.toLowerCase();
417
+ const before = catalog.joins.length;
418
+ catalog.joins = catalog.joins.filter((edge) => !edge.a.toLowerCase().startsWith(prefix) && !edge.b.toLowerCase().startsWith(prefix));
419
+ removed = before - catalog.joins.length;
420
+ }
421
+ else {
422
+ removed = table.columns.length + table.indexes.length + table.fks.length;
423
+ table.columns = [];
424
+ table.pk = [];
425
+ table.indexes = [];
426
+ table.fks = [];
427
+ table.detailScannedAt = null;
428
+ table.detailStale = true;
429
+ }
430
+ });
431
+ if (missing) {
432
+ throw new Error(tableVanished(qualified));
433
+ }
434
+ return JSON.stringify({
435
+ target: qualified,
436
+ forgotten: scope,
437
+ removed,
438
+ ...(scope === 'metadata'
439
+ ? { next: 'The next describe or successful query refreshes table metadata.' }
440
+ : {}),
441
+ }, null, 2);
442
+ }
443
+ queryDocumentGuidance(prepared) {
444
+ if (!this.isEnabled() || !this.documents.isConfigured()) {
445
+ return null;
446
+ }
447
+ const notices = [];
448
+ const documentError = this.documents.error();
449
+ if (documentError) {
450
+ notices.push(`[카탈로그 경고] ${documentError}`);
451
+ }
452
+ else {
453
+ const stale = this.documents.staleWarning();
454
+ if (stale) {
455
+ notices.push(stale);
456
+ }
457
+ }
458
+ if (!this.documents.isAvailable()) {
459
+ return notices.join('\n') || null;
460
+ }
461
+ const unresolved = new Map();
462
+ for (const reference of prepared.references) {
463
+ const resolved = this.resolveTable(reference.schema ? `${reference.schema}.${reference.table}` : reference.table);
464
+ if (resolved && !this.documentReviewed(resolved.schema, resolved.table)) {
465
+ unresolved.set(`${resolved.schema}.${resolved.table}`, resolved.schema);
466
+ }
467
+ }
468
+ notices.push(...[...unresolved.entries()].map(([table, schema]) => `[카탈로그] ${table} 에 연결된 문서가 없습니다.\n` +
469
+ `mysql_catalog {action:"docs_list", schema:"${schema}"} 로 후보를 보고\n` +
470
+ 'mysql_catalog {action:"link", links:[{table:"' +
471
+ `${table}", doc:"..."}]} 로 연결하세요.`));
472
+ return notices.join('\n\n') || null;
473
+ }
474
+ indexFacts(reference) {
475
+ if (!this.isEnabled()) {
476
+ return null;
477
+ }
478
+ const resolved = this.resolveTable(reference.schema ? `${reference.schema}.${reference.table}` : reference.table);
479
+ if (!resolved) {
480
+ return null;
481
+ }
482
+ return this.store.tableIndexes(resolved.schema, resolved.table);
483
+ }
484
+ async listTables() {
485
+ if (!this.isEnabled()) {
486
+ return [];
487
+ }
488
+ if (this.collector.inventoryNeedsRefresh()) {
489
+ await this.collector.collectInventory(false);
490
+ }
491
+ return Object.entries(this.store.snapshot().schemas).flatMap(([schemaName, schema]) => Object.entries(schema.tables).map(([tableName, table]) => ({
492
+ table_name: tableName,
493
+ name: tableName,
494
+ database: schemaName,
495
+ description: table.comment,
496
+ rowCount: table.rowsEstimate ?? undefined,
497
+ })));
498
+ }
499
+ prepareQuery(sql) {
500
+ if (!this.isEnabled()) {
501
+ return { references: [], joins: [] };
502
+ }
503
+ return prepareCatalogQuery(sql);
504
+ }
505
+ afterQuery(prepared, result, error) {
506
+ if (!this.isEnabled() || this.closing || prepared.references.length === 0) {
507
+ return;
508
+ }
509
+ const task = new Promise((resolve) => {
510
+ setImmediate(() => {
511
+ const documentWake = this.referencesNeedDocuments(prepared.references)
512
+ ? this.documents.ensureAwake().catch(() => {
513
+ })
514
+ : Promise.resolve();
515
+ const queryUpdate = this.processQuery(prepared, result, error).catch((cause) => {
516
+ console.error(`[catalog] post-query update failed: ${cause instanceof Error ? cause.message : String(cause)}`);
517
+ });
518
+ void Promise.all([documentWake, queryUpdate]).then(() => resolve());
519
+ });
520
+ });
521
+ this.trackBackgroundTask(task);
522
+ }
523
+ referencesNeedDocuments(references) {
524
+ if (!this.documents.isAvailable()) {
525
+ return false;
526
+ }
527
+ return references.some((reference) => {
528
+ const resolved = this.resolveTable(reference.schema ? `${reference.schema}.${reference.table}` : reference.table);
529
+ return Boolean(resolved && !this.documentReviewed(resolved.schema, resolved.table));
530
+ });
531
+ }
532
+ async processQuery(prepared, result, error) {
533
+ const { references } = prepared;
534
+ const code = error && typeof error === 'object' && 'code' in error ? String(error.code) : '';
535
+ const succeeded = !error && !result.isError;
536
+ const inventoryWasStale = succeeded && this.collector.inventoryNeedsRefresh();
537
+ if (inventoryWasStale) {
538
+ await this.collector.collectInventory(false);
539
+ }
540
+ let resolved = this.resolveReferences(references);
541
+ if (succeeded) {
542
+ const resolvedKeys = new Set(resolved.map((value) => `${value.schema}.${value.table}`.toLowerCase()));
543
+ const unresolvedKeys = [
544
+ ...new Set(references
545
+ .map((reference) => this.catalogKeyForReference(reference))
546
+ .filter((key) => key !== null && !resolvedKeys.has(key))),
547
+ ];
548
+ const now = Date.now();
549
+ const needsUnknownRefresh = unresolvedKeys.some((key) => now - (this.unknownReferenceRefreshes.get(key) ?? 0) >= UNKNOWN_REFERENCE_REFRESH_COOLDOWN_MS);
550
+ if (inventoryWasStale) {
551
+ for (const key of unresolvedKeys) {
552
+ this.rememberUnknownReference(key, now);
553
+ }
554
+ }
555
+ else if (needsUnknownRefresh) {
556
+ await this.collector.collectInventory(true);
557
+ for (const key of unresolvedKeys) {
558
+ this.rememberUnknownReference(key, now);
559
+ }
560
+ resolved = this.resolveReferences(references);
561
+ }
562
+ }
563
+ const unique = new Map(resolved.map((value) => [`${value.schema}.${value.table}`, value]));
564
+ if (this.documents.isAvailable() &&
565
+ [...unique.values()].some((value) => !this.documentReviewed(value.schema, value.table))) {
566
+ await this.documents.ensureAwake().catch(() => {
567
+ });
568
+ }
569
+ const columns = resultColumnNames(result.content?.[0]?.text ?? '');
570
+ const now = new Date().toISOString();
571
+ this.store.update((catalog) => {
572
+ for (const value of unique.values()) {
573
+ const table = catalog.schemas[value.schema]?.tables[value.table];
574
+ if (!table) {
575
+ continue;
576
+ }
577
+ table.usage.count += 1;
578
+ if (succeeded) {
579
+ table.usage.successCount += 1;
580
+ table.usage.lastUsedAt = now;
581
+ }
582
+ else {
583
+ table.usage.failureCount += 1;
584
+ }
585
+ if (code === 'ER_BAD_FIELD_ERROR' || code === 'ER_NO_SUCH_TABLE') {
586
+ table.detailStale = true;
587
+ if (code === 'ER_NO_SUCH_TABLE') {
588
+ catalog.schemas[value.schema].scannedAt = null;
589
+ }
590
+ }
591
+ }
592
+ if (succeeded) {
593
+ for (const join of prepared.joins) {
594
+ const left = this.resolveTable(join.a.schema ? `${join.a.schema}.${join.a.table}` : join.a.table);
595
+ const right = this.resolveTable(join.b.schema ? `${join.b.schema}.${join.b.table}` : join.b.table);
596
+ if (!left || !right) {
597
+ continue;
598
+ }
599
+ const endpoints = [
600
+ `${left.schema}.${left.table}.${join.a.column}`,
601
+ `${right.schema}.${right.table}.${join.b.column}`,
602
+ ].sort();
603
+ const existing = catalog.joins.find((edge) => edge.a === endpoints[0] && edge.b === endpoints[1]);
604
+ if (existing) {
605
+ existing.count += 1;
606
+ existing.lastUsedAt = now;
607
+ }
608
+ else {
609
+ catalog.joins.push({
610
+ a: endpoints[0],
611
+ b: endpoints[1],
612
+ count: 1,
613
+ lastUsedAt: now,
614
+ });
615
+ }
616
+ }
617
+ catalog.joins = pruneJoins(catalog.joins);
618
+ }
619
+ });
620
+ if (!succeeded) {
621
+ if (code === 'ER_BAD_FIELD_ERROR') {
622
+ await Promise.all([...unique.values()].map((value) => this.collector.collectTableDetail(value.schema, value.table, true)));
623
+ }
624
+ else if (code === 'ER_NO_SUCH_TABLE') {
625
+ await this.collector.collectInventory(true);
626
+ }
627
+ return;
628
+ }
629
+ await Promise.all([...unique.values()].map((value) => this.collector.collectTableDetail(value.schema, value.table).catch((cause) => {
630
+ console.error(`[catalog] detail scan failed for ${value.schema}.${value.table}: ${cause instanceof Error ? cause.message : String(cause)}`);
631
+ })));
632
+ this.store.update((catalog) => {
633
+ for (const column of columns) {
634
+ const owners = [...unique.values()].filter((value) => {
635
+ const table = catalog.schemas[value.schema]?.tables[value.table];
636
+ return table?.columns.some((known) => sameName(known.name, column));
637
+ });
638
+ if (owners.length !== 1) {
639
+ continue;
640
+ }
641
+ const owner = owners[0];
642
+ const table = catalog.schemas[owner.schema]?.tables[owner.table];
643
+ if (table) {
644
+ table.usage.columns[column] = (table.usage.columns[column] ?? 0) + 1;
645
+ }
646
+ }
647
+ });
648
+ await this.announceToolDescription();
649
+ }
650
+ rememberUnknownReference(key, at) {
651
+ this.unknownReferenceRefreshes.delete(key);
652
+ this.unknownReferenceRefreshes.set(key, at);
653
+ while (this.unknownReferenceRefreshes.size > UNKNOWN_REFERENCE_MEMORY_LIMIT) {
654
+ const oldest = this.unknownReferenceRefreshes.keys().next();
655
+ if (oldest.done) {
656
+ break;
657
+ }
658
+ this.unknownReferenceRefreshes.delete(oldest.value);
659
+ }
660
+ }
661
+ async close() {
662
+ if (!this.closePromise) {
663
+ this.closing = true;
664
+ this.closePromise = (async () => {
665
+ const deadline = Date.now() + CLOSE_DRAIN_DEADLINE_MS;
666
+ while (this.backgroundTasks.size > 0) {
667
+ const remaining = deadline - Date.now();
668
+ if (remaining <= 0) {
669
+ console.error(`[catalog] ${this.backgroundTasks.size} background task(s) did not finish within ${CLOSE_DRAIN_DEADLINE_MS}ms; persisting what is already recorded`);
670
+ break;
671
+ }
672
+ const deadlineReached = deadlineTimer(remaining);
673
+ try {
674
+ await Promise.race([Promise.allSettled(this.backgroundTasks), deadlineReached.expired]);
675
+ }
676
+ finally {
677
+ deadlineReached.cancel();
678
+ }
679
+ }
680
+ await this.store.close();
681
+ })();
682
+ }
683
+ await this.closePromise;
684
+ }
685
+ }