@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.
- package/LICENSE.md +21 -0
- package/README.md +848 -2
- package/dist/index.js +684 -0
- package/dist/src/catalog/collect.js +220 -0
- package/dist/src/catalog/docs.js +328 -0
- package/dist/src/catalog/index.js +685 -0
- package/dist/src/catalog/render.js +177 -0
- package/dist/src/catalog/store.js +617 -0
- package/dist/src/catalog/types.js +59 -0
- package/dist/src/catalog/usage.js +164 -0
- package/dist/src/config/index.js +244 -0
- package/dist/src/db/diagnose.js +189 -0
- package/dist/src/db/index.js +461 -0
- package/dist/src/db/permissions.js +26 -0
- package/dist/src/db/utils.js +264 -0
- package/dist/src/ssh/config.js +137 -0
- package/dist/src/ssh/tunnel.js +334 -0
- package/dist/src/types/index.js +1 -0
- package/dist/src/utils/index.js +11 -0
- package/package.json +50 -3
|
@@ -0,0 +1,617 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import { CATALOG_VERSION, emptyCatalog, emptyTable, normalizeName, pruneJoins } from './types.js';
|
|
4
|
+
const FLUSH_DELAY_MS = 2_000;
|
|
5
|
+
const LOCK_RETRY_MS = 25;
|
|
6
|
+
const LOCK_WAIT_MS = 15_000;
|
|
7
|
+
const LOCK_CLOSE_WAIT_MS = 2_000;
|
|
8
|
+
const LOCK_STALE_MS = 30_000;
|
|
9
|
+
const CLOSE_FLUSH_ATTEMPTS = 2;
|
|
10
|
+
class CatalogLockTimeoutError extends Error {
|
|
11
|
+
}
|
|
12
|
+
function errorCode(error) {
|
|
13
|
+
return error && typeof error === 'object' && 'code' in error ? String(error.code) : null;
|
|
14
|
+
}
|
|
15
|
+
function processIsAlive(pid) {
|
|
16
|
+
try {
|
|
17
|
+
process.kill(pid, 0);
|
|
18
|
+
return true;
|
|
19
|
+
}
|
|
20
|
+
catch (error) {
|
|
21
|
+
return errorCode(error) !== 'ESRCH';
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
function delay(ms) {
|
|
25
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
26
|
+
}
|
|
27
|
+
async function acquireCatalogLock(filePath, waitMs) {
|
|
28
|
+
const lockPath = `${filePath}.lock`;
|
|
29
|
+
const deadline = Date.now() + waitMs;
|
|
30
|
+
const record = {
|
|
31
|
+
pid: process.pid,
|
|
32
|
+
token: `${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
|
33
|
+
createdAt: Date.now(),
|
|
34
|
+
};
|
|
35
|
+
while (true) {
|
|
36
|
+
try {
|
|
37
|
+
const handle = await fs.promises.open(lockPath, 'wx', 0o600);
|
|
38
|
+
try {
|
|
39
|
+
await handle.writeFile(JSON.stringify(record), 'utf8');
|
|
40
|
+
await handle.sync();
|
|
41
|
+
}
|
|
42
|
+
catch (error) {
|
|
43
|
+
await handle.close().catch(() => undefined);
|
|
44
|
+
await fs.promises.unlink(lockPath).catch(() => undefined);
|
|
45
|
+
throw error;
|
|
46
|
+
}
|
|
47
|
+
let released = false;
|
|
48
|
+
return async () => {
|
|
49
|
+
if (released) {
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
released = true;
|
|
53
|
+
await handle.close();
|
|
54
|
+
try {
|
|
55
|
+
const current = JSON.parse(await fs.promises.readFile(lockPath, 'utf8'));
|
|
56
|
+
if (current.token === record.token) {
|
|
57
|
+
await fs.promises.unlink(lockPath);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
catch (error) {
|
|
61
|
+
if (errorCode(error) !== 'ENOENT') {
|
|
62
|
+
throw error;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
catch (error) {
|
|
68
|
+
if (errorCode(error) !== 'EEXIST') {
|
|
69
|
+
throw error;
|
|
70
|
+
}
|
|
71
|
+
let lockText = null;
|
|
72
|
+
let stale = false;
|
|
73
|
+
try {
|
|
74
|
+
const [text, stat] = await Promise.all([
|
|
75
|
+
fs.promises.readFile(lockPath, 'utf8'),
|
|
76
|
+
fs.promises.stat(lockPath),
|
|
77
|
+
]);
|
|
78
|
+
lockText = text;
|
|
79
|
+
let owner = {};
|
|
80
|
+
try {
|
|
81
|
+
owner = JSON.parse(text);
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
}
|
|
85
|
+
stale =
|
|
86
|
+
typeof owner.pid === 'number'
|
|
87
|
+
? !processIsAlive(owner.pid)
|
|
88
|
+
: Date.now() - stat.mtimeMs >= LOCK_STALE_MS;
|
|
89
|
+
}
|
|
90
|
+
catch (readError) {
|
|
91
|
+
if (errorCode(readError) === 'ENOENT') {
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
throw readError;
|
|
95
|
+
}
|
|
96
|
+
if (stale && lockText !== null) {
|
|
97
|
+
try {
|
|
98
|
+
if ((await fs.promises.readFile(lockPath, 'utf8')) === lockText) {
|
|
99
|
+
await fs.promises.unlink(lockPath);
|
|
100
|
+
console.error(`[catalog] removed stale lock ${lockPath}`);
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
catch (cleanupError) {
|
|
105
|
+
if (errorCode(cleanupError) === 'ENOENT') {
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
throw cleanupError;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
if (Date.now() >= deadline) {
|
|
112
|
+
throw new CatalogLockTimeoutError(`timed out after ${waitMs}ms waiting for catalog lock ${lockPath}`);
|
|
113
|
+
}
|
|
114
|
+
await delay(LOCK_RETRY_MS + Math.floor(Math.random() * LOCK_RETRY_MS));
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
const EPOCH = new Date(0).toISOString();
|
|
119
|
+
function timestamp(value) {
|
|
120
|
+
if (!value) {
|
|
121
|
+
return 0;
|
|
122
|
+
}
|
|
123
|
+
const parsed = Date.parse(value);
|
|
124
|
+
return Number.isFinite(parsed) ? parsed : 0;
|
|
125
|
+
}
|
|
126
|
+
function mergeEditedSet(external, local, base) {
|
|
127
|
+
const merged = new Set(external);
|
|
128
|
+
for (const previous of base) {
|
|
129
|
+
if (!local.includes(previous)) {
|
|
130
|
+
merged.delete(previous);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
for (const current of local) {
|
|
134
|
+
if (!base.includes(current)) {
|
|
135
|
+
merged.add(current);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return [...merged];
|
|
139
|
+
}
|
|
140
|
+
function mergeCurated(external, local, base) {
|
|
141
|
+
const baseDoc = Object.prototype.hasOwnProperty.call(base ?? {}, 'doc') ? JSON.stringify(base?.doc) : '__missing__';
|
|
142
|
+
const localDoc = Object.prototype.hasOwnProperty.call(local, 'doc') ? JSON.stringify(local.doc) : '__missing__';
|
|
143
|
+
const chosen = localDoc !== baseDoc ? local : external;
|
|
144
|
+
return {
|
|
145
|
+
notes: mergeEditedSet(external.notes ?? [], local.notes ?? [], base?.notes ?? []),
|
|
146
|
+
aliases: mergeEditedSet(external.aliases ?? [], local.aliases ?? [], base?.aliases ?? []),
|
|
147
|
+
...(Object.prototype.hasOwnProperty.call(chosen, 'doc') ? { doc: chosen.doc } : {}),
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
function mergedCounter(external, local, base) {
|
|
151
|
+
if (local < base) {
|
|
152
|
+
return local + Math.max(0, external - base);
|
|
153
|
+
}
|
|
154
|
+
return external + Math.max(0, local - base);
|
|
155
|
+
}
|
|
156
|
+
function newerUsageTimestamp(externalValue, localValue, externalCount, localCount, baseCount) {
|
|
157
|
+
if (localCount < baseCount && externalCount <= baseCount) {
|
|
158
|
+
return localValue ?? null;
|
|
159
|
+
}
|
|
160
|
+
if (externalCount < baseCount && localCount <= baseCount) {
|
|
161
|
+
return externalValue ?? null;
|
|
162
|
+
}
|
|
163
|
+
return timestamp(localValue) >= timestamp(externalValue) ? (localValue ?? null) : (externalValue ?? null);
|
|
164
|
+
}
|
|
165
|
+
function mergeTable(external, local, base) {
|
|
166
|
+
const baseDetailAt = timestamp(base?.detailScannedAt);
|
|
167
|
+
const localInvalidated = local.detailStale === true && (base?.detailStale !== true || local.detailScannedAt === null);
|
|
168
|
+
const externalInvalidated = external.detailStale === true && (base?.detailStale !== true || external.detailScannedAt === null);
|
|
169
|
+
const externalRefreshed = timestamp(external.detailScannedAt) > baseDetailAt;
|
|
170
|
+
const localRefreshed = timestamp(local.detailScannedAt) > baseDetailAt;
|
|
171
|
+
const newerDetail = localInvalidated && !externalRefreshed
|
|
172
|
+
? local
|
|
173
|
+
: externalInvalidated && !localRefreshed
|
|
174
|
+
? external
|
|
175
|
+
: timestamp(local.detailScannedAt) >= timestamp(external.detailScannedAt)
|
|
176
|
+
? local
|
|
177
|
+
: external;
|
|
178
|
+
const externalUsageCount = external.usage?.count ?? 0;
|
|
179
|
+
const localUsageCount = local.usage?.count ?? 0;
|
|
180
|
+
const baseUsageCount = base?.usage?.count ?? 0;
|
|
181
|
+
const columns = [
|
|
182
|
+
...new Set([...Object.keys(external.usage?.columns ?? {}), ...Object.keys(local.usage?.columns ?? {})]),
|
|
183
|
+
]
|
|
184
|
+
.map((column) => [
|
|
185
|
+
column,
|
|
186
|
+
mergedCounter(external.usage?.columns?.[column] ?? 0, local.usage?.columns?.[column] ?? 0, base?.usage?.columns?.[column] ?? 0),
|
|
187
|
+
])
|
|
188
|
+
.filter(([, count]) => count > 0);
|
|
189
|
+
return {
|
|
190
|
+
...newerDetail,
|
|
191
|
+
usage: {
|
|
192
|
+
count: mergedCounter(externalUsageCount, localUsageCount, baseUsageCount),
|
|
193
|
+
successCount: mergedCounter(external.usage?.successCount ?? 0, local.usage?.successCount ?? 0, base?.usage?.successCount ?? 0),
|
|
194
|
+
failureCount: mergedCounter(external.usage?.failureCount ?? 0, local.usage?.failureCount ?? 0, base?.usage?.failureCount ?? 0),
|
|
195
|
+
lastUsedAt: newerUsageTimestamp(external.usage?.lastUsedAt, local.usage?.lastUsedAt, externalUsageCount, localUsageCount, baseUsageCount),
|
|
196
|
+
columns: Object.fromEntries(columns),
|
|
197
|
+
},
|
|
198
|
+
curated: mergeCurated(external.curated ?? { notes: [], aliases: [] }, local.curated ?? { notes: [], aliases: [] }, base?.curated),
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
function mergeSchema(external, local, base) {
|
|
202
|
+
const newerInventory = timestamp(local.scannedAt) >= timestamp(external.scannedAt) ? local : external;
|
|
203
|
+
const olderInventory = newerInventory === local ? external : local;
|
|
204
|
+
const tables = {};
|
|
205
|
+
for (const [name, table] of Object.entries(newerInventory.tables)) {
|
|
206
|
+
tables[name] = olderInventory.tables[name]
|
|
207
|
+
? mergeTable(newerInventory === local ? olderInventory.tables[name] : table, newerInventory === local ? table : olderInventory.tables[name], base?.tables[name])
|
|
208
|
+
: table;
|
|
209
|
+
}
|
|
210
|
+
return { ...newerInventory, tables };
|
|
211
|
+
}
|
|
212
|
+
function mergeCatalog(external, local, base) {
|
|
213
|
+
const schemas = {};
|
|
214
|
+
for (const name of new Set([...Object.keys(external.schemas), ...Object.keys(local.schemas)])) {
|
|
215
|
+
if (external.schemas[name] && local.schemas[name]) {
|
|
216
|
+
schemas[name] = mergeSchema(external.schemas[name], local.schemas[name], base.schemas[name]);
|
|
217
|
+
}
|
|
218
|
+
else {
|
|
219
|
+
schemas[name] = external.schemas[name] ?? local.schemas[name];
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
const joins = new Map();
|
|
223
|
+
const externalJoins = new Map(external.joins.map((edge) => [`${edge.a}\u0000${edge.b}`, edge]));
|
|
224
|
+
const localJoins = new Map(local.joins.map((edge) => [`${edge.a}\u0000${edge.b}`, edge]));
|
|
225
|
+
const baseJoins = new Map(base.joins.map((edge) => [`${edge.a}\u0000${edge.b}`, edge]));
|
|
226
|
+
for (const key of new Set([...externalJoins.keys(), ...localJoins.keys()])) {
|
|
227
|
+
const outside = externalJoins.get(key);
|
|
228
|
+
const inside = localJoins.get(key);
|
|
229
|
+
const ancestor = baseJoins.get(key);
|
|
230
|
+
const edge = outside ?? inside;
|
|
231
|
+
if (!edge) {
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
const count = mergedCounter(outside?.count ?? 0, inside?.count ?? 0, ancestor?.count ?? 0);
|
|
235
|
+
if (count <= 0) {
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
joins.set(key, {
|
|
239
|
+
a: edge.a,
|
|
240
|
+
b: edge.b,
|
|
241
|
+
count,
|
|
242
|
+
lastUsedAt: newerUsageTimestamp(outside?.lastUsedAt, inside?.lastUsedAt, outside?.count ?? 0, inside?.count ?? 0, ancestor?.count ?? 0) ?? EPOCH,
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
const localDocsChanged = local.docs.refCommit !== base.docs.refCommit;
|
|
246
|
+
const externalDocsChanged = external.docs.refCommit !== base.docs.refCommit;
|
|
247
|
+
const chosenDocs = localDocsChanged && externalDocsChanged
|
|
248
|
+
? timestamp(local.docs.refUpdatedAt) >= timestamp(external.docs.refUpdatedAt)
|
|
249
|
+
? local.docs
|
|
250
|
+
: external.docs
|
|
251
|
+
: localDocsChanged
|
|
252
|
+
? local.docs
|
|
253
|
+
: external.docs;
|
|
254
|
+
const merged = {
|
|
255
|
+
...local,
|
|
256
|
+
docs: {
|
|
257
|
+
...chosenDocs,
|
|
258
|
+
repo: local.docs.repo,
|
|
259
|
+
ref: local.docs.ref,
|
|
260
|
+
paths: chosenDocs.paths ?? [],
|
|
261
|
+
unlinked: [],
|
|
262
|
+
},
|
|
263
|
+
schemas,
|
|
264
|
+
joins: pruneJoins([...joins.values()]),
|
|
265
|
+
};
|
|
266
|
+
const linked = new Set();
|
|
267
|
+
for (const schema of Object.values(merged.schemas)) {
|
|
268
|
+
for (const table of Object.values(schema.tables)) {
|
|
269
|
+
if (table.curated.doc && !merged.docs.paths.includes(table.curated.doc.path)) {
|
|
270
|
+
delete table.curated.doc;
|
|
271
|
+
}
|
|
272
|
+
if (table.curated.doc) {
|
|
273
|
+
linked.add(table.curated.doc.path);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
merged.docs.unlinked = merged.docs.paths.filter((path) => !linked.has(path));
|
|
278
|
+
return merged;
|
|
279
|
+
}
|
|
280
|
+
export class CatalogStore {
|
|
281
|
+
options;
|
|
282
|
+
enabled;
|
|
283
|
+
data;
|
|
284
|
+
baseData;
|
|
285
|
+
flushTimer = null;
|
|
286
|
+
flushPromise = null;
|
|
287
|
+
revision = 0;
|
|
288
|
+
flushedRevision = 0;
|
|
289
|
+
closing = false;
|
|
290
|
+
nameIndex = null;
|
|
291
|
+
constructor(options) {
|
|
292
|
+
this.options = options;
|
|
293
|
+
this.enabled = options.enabled;
|
|
294
|
+
this.data = emptyCatalog(options);
|
|
295
|
+
this.baseData = structuredClone(this.data);
|
|
296
|
+
if (this.enabled) {
|
|
297
|
+
this.initialize();
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
initialize() {
|
|
301
|
+
try {
|
|
302
|
+
fs.mkdirSync(path.dirname(this.options.filePath), {
|
|
303
|
+
recursive: true,
|
|
304
|
+
mode: 0o700,
|
|
305
|
+
});
|
|
306
|
+
this.loadFromDisk();
|
|
307
|
+
}
|
|
308
|
+
catch (error) {
|
|
309
|
+
this.disable(`cannot initialize ${this.options.filePath}: ${error instanceof Error ? error.message : String(error)}`);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
loadFromDisk() {
|
|
313
|
+
if (!fs.existsSync(this.options.filePath)) {
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
let parsed;
|
|
317
|
+
try {
|
|
318
|
+
parsed = JSON.parse(fs.readFileSync(this.options.filePath, 'utf8'));
|
|
319
|
+
}
|
|
320
|
+
catch (error) {
|
|
321
|
+
console.error(`[catalog] ignoring unreadable ${this.options.filePath}; it will be replaced: ${error instanceof Error ? error.message : String(error)}`);
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
if (parsed.version !== CATALOG_VERSION ||
|
|
325
|
+
parsed.profile !== this.options.profile ||
|
|
326
|
+
parsed.fingerprint !== this.options.fingerprint) {
|
|
327
|
+
console.error(`[catalog] ignoring incompatible cache at ${this.options.filePath}; it will be replaced.`);
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
this.data = this.sanitizeLoaded(parsed);
|
|
331
|
+
this.invalidateNameIndex();
|
|
332
|
+
this.baseData = structuredClone(this.data);
|
|
333
|
+
this.revision += 1;
|
|
334
|
+
this.scheduleFlush();
|
|
335
|
+
console.error(`[catalog] loaded ${this.options.filePath}`);
|
|
336
|
+
}
|
|
337
|
+
sanitizeLoaded(loaded) {
|
|
338
|
+
const fresh = emptyCatalog(this.options);
|
|
339
|
+
const docsCompatible = loaded.docs?.repo === this.options.docsRepo && loaded.docs?.ref === this.options.docsRef;
|
|
340
|
+
for (const [schemaName, schema] of Object.entries(fresh.schemas)) {
|
|
341
|
+
const cached = loaded.schemas?.[schemaName];
|
|
342
|
+
if (!cached) {
|
|
343
|
+
continue;
|
|
344
|
+
}
|
|
345
|
+
schema.scannedAt = cached.scannedAt ?? null;
|
|
346
|
+
for (const [tableName, raw] of Object.entries(cached.tables ?? {})) {
|
|
347
|
+
const base = emptyTable();
|
|
348
|
+
const table = {
|
|
349
|
+
...base,
|
|
350
|
+
...raw,
|
|
351
|
+
columns: raw.columns ?? [],
|
|
352
|
+
pk: raw.pk ?? [],
|
|
353
|
+
indexes: raw.indexes ?? [],
|
|
354
|
+
fks: raw.fks ?? [],
|
|
355
|
+
usage: {
|
|
356
|
+
count: raw.usage?.count ?? 0,
|
|
357
|
+
successCount: raw.usage?.successCount ?? raw.usage?.count ?? 0,
|
|
358
|
+
failureCount: raw.usage?.failureCount ?? 0,
|
|
359
|
+
lastUsedAt: raw.usage?.lastUsedAt ?? null,
|
|
360
|
+
columns: raw.usage?.columns ?? {},
|
|
361
|
+
},
|
|
362
|
+
curated: {
|
|
363
|
+
notes: raw.curated?.notes ?? [],
|
|
364
|
+
aliases: raw.curated?.aliases ?? [],
|
|
365
|
+
...(docsCompatible && Object.prototype.hasOwnProperty.call(raw.curated ?? {}, 'doc')
|
|
366
|
+
? { doc: raw.curated.doc }
|
|
367
|
+
: {}),
|
|
368
|
+
},
|
|
369
|
+
};
|
|
370
|
+
schema.tables[tableName] = table;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
if (docsCompatible) {
|
|
374
|
+
fresh.docs = {
|
|
375
|
+
...fresh.docs,
|
|
376
|
+
refCommit: loaded.docs.refCommit ?? null,
|
|
377
|
+
refUpdatedAt: loaded.docs.refUpdatedAt ?? null,
|
|
378
|
+
paths: loaded.docs.paths ?? [],
|
|
379
|
+
unlinked: loaded.docs.unlinked ?? [],
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
fresh.joins = pruneJoins((loaded.joins ?? [])
|
|
383
|
+
.map((edge) => ({ ...edge, lastUsedAt: edge.lastUsedAt ?? EPOCH })));
|
|
384
|
+
return fresh;
|
|
385
|
+
}
|
|
386
|
+
disable(reason) {
|
|
387
|
+
this.enabled = false;
|
|
388
|
+
if (this.flushTimer) {
|
|
389
|
+
clearTimeout(this.flushTimer);
|
|
390
|
+
}
|
|
391
|
+
this.flushTimer = null;
|
|
392
|
+
console.error(`[catalog] disabled: ${reason}`);
|
|
393
|
+
}
|
|
394
|
+
isEnabled() {
|
|
395
|
+
return this.enabled;
|
|
396
|
+
}
|
|
397
|
+
invalidateNameIndex() {
|
|
398
|
+
this.nameIndex = null;
|
|
399
|
+
}
|
|
400
|
+
ensureNameIndex() {
|
|
401
|
+
if (this.nameIndex) {
|
|
402
|
+
return this.nameIndex;
|
|
403
|
+
}
|
|
404
|
+
const index = new Map();
|
|
405
|
+
for (const [schemaName, schema] of Object.entries(this.data.schemas)) {
|
|
406
|
+
const tables = new Map();
|
|
407
|
+
for (const tableName of Object.keys(schema.tables)) {
|
|
408
|
+
tables.set(normalizeName(tableName), tableName);
|
|
409
|
+
}
|
|
410
|
+
index.set(normalizeName(schemaName), { schema: schemaName, tables });
|
|
411
|
+
}
|
|
412
|
+
this.nameIndex = index;
|
|
413
|
+
return index;
|
|
414
|
+
}
|
|
415
|
+
snapshot() {
|
|
416
|
+
return structuredClone(this.data);
|
|
417
|
+
}
|
|
418
|
+
docsView() {
|
|
419
|
+
return structuredClone(this.data.docs);
|
|
420
|
+
}
|
|
421
|
+
lookup(schemaHint, table) {
|
|
422
|
+
const index = this.ensureNameIndex();
|
|
423
|
+
const wanted = normalizeName(table);
|
|
424
|
+
const scopes = schemaHint ? [index.get(normalizeName(schemaHint))] : [...index.values()];
|
|
425
|
+
const matches = [];
|
|
426
|
+
for (const scope of scopes) {
|
|
427
|
+
if (!scope) {
|
|
428
|
+
continue;
|
|
429
|
+
}
|
|
430
|
+
const canonical = scope.tables.get(wanted);
|
|
431
|
+
if (canonical) {
|
|
432
|
+
matches.push({ schema: scope.schema, table: canonical });
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
return matches;
|
|
436
|
+
}
|
|
437
|
+
schemaState(schema) {
|
|
438
|
+
const entry = this.data.schemas[schema];
|
|
439
|
+
return entry ? { scannedAt: entry.scannedAt } : null;
|
|
440
|
+
}
|
|
441
|
+
tableMeta(schema, table) {
|
|
442
|
+
const entry = this.data.schemas[schema]?.tables[table];
|
|
443
|
+
if (!entry) {
|
|
444
|
+
return null;
|
|
445
|
+
}
|
|
446
|
+
return {
|
|
447
|
+
detailScannedAt: entry.detailScannedAt,
|
|
448
|
+
detailStale: entry.detailStale === true,
|
|
449
|
+
docReviewed: Object.prototype.hasOwnProperty.call(entry.curated, 'doc'),
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
tableIndexes(schema, table) {
|
|
453
|
+
const entry = this.data.schemas[schema]?.tables[table];
|
|
454
|
+
if (!entry) {
|
|
455
|
+
return null;
|
|
456
|
+
}
|
|
457
|
+
return {
|
|
458
|
+
schema,
|
|
459
|
+
table,
|
|
460
|
+
indexes: entry.indexes.map((index) => ({
|
|
461
|
+
...index,
|
|
462
|
+
columns: [...index.columns],
|
|
463
|
+
})),
|
|
464
|
+
pk: [...entry.pk],
|
|
465
|
+
rowsEstimate: entry.rowsEstimate,
|
|
466
|
+
detailScannedAt: entry.detailScannedAt,
|
|
467
|
+
detailStale: entry.detailStale === true,
|
|
468
|
+
};
|
|
469
|
+
}
|
|
470
|
+
readTable(schema, table) {
|
|
471
|
+
const entry = this.data.schemas[schema]?.tables[table];
|
|
472
|
+
return entry ? structuredClone(entry) : null;
|
|
473
|
+
}
|
|
474
|
+
hasSuccessfulUse() {
|
|
475
|
+
for (const schema of Object.values(this.data.schemas)) {
|
|
476
|
+
for (const table of Object.values(schema.tables)) {
|
|
477
|
+
if (table.usage.successCount > 0) {
|
|
478
|
+
return true;
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
return false;
|
|
483
|
+
}
|
|
484
|
+
readJoins(schema, table) {
|
|
485
|
+
const prefix = `${schema}.${table}.`.toLowerCase();
|
|
486
|
+
return this.data.joins
|
|
487
|
+
.filter((edge) => edge.a.toLowerCase().startsWith(prefix) || edge.b.toLowerCase().startsWith(prefix))
|
|
488
|
+
.sort((a, b) => b.count - a.count || a.a.localeCompare(b.a))
|
|
489
|
+
.slice(0, 20)
|
|
490
|
+
.map((edge) => ({ ...edge }));
|
|
491
|
+
}
|
|
492
|
+
update(mutator) {
|
|
493
|
+
if (!this.enabled) {
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
mutator(this.data);
|
|
497
|
+
this.invalidateNameIndex();
|
|
498
|
+
this.revision += 1;
|
|
499
|
+
this.scheduleFlush();
|
|
500
|
+
}
|
|
501
|
+
scheduleFlush() {
|
|
502
|
+
if (!this.enabled || this.closing || this.flushTimer) {
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
this.flushTimer = setTimeout(() => {
|
|
506
|
+
this.flushTimer = null;
|
|
507
|
+
void this.flush();
|
|
508
|
+
}, FLUSH_DELAY_MS);
|
|
509
|
+
this.flushTimer.unref();
|
|
510
|
+
}
|
|
511
|
+
async flush() {
|
|
512
|
+
if (!this.enabled) {
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
if (this.flushPromise) {
|
|
516
|
+
return this.flushPromise;
|
|
517
|
+
}
|
|
518
|
+
if (this.revision === this.flushedRevision) {
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
this.flushPromise = this.writeAtomically().finally(() => {
|
|
522
|
+
this.flushPromise = null;
|
|
523
|
+
});
|
|
524
|
+
return this.flushPromise;
|
|
525
|
+
}
|
|
526
|
+
async writeAtomically() {
|
|
527
|
+
let tempPath = null;
|
|
528
|
+
let releaseLock = null;
|
|
529
|
+
try {
|
|
530
|
+
releaseLock = await acquireCatalogLock(this.options.filePath, this.closing ? LOCK_CLOSE_WAIT_MS : LOCK_WAIT_MS);
|
|
531
|
+
tempPath = `${this.options.filePath}.${process.pid}.${Date.now()}.tmp`;
|
|
532
|
+
const writingRevision = this.revision;
|
|
533
|
+
let toWrite = this.sanitizeLoaded(structuredClone(this.data));
|
|
534
|
+
const external = this.readExternal();
|
|
535
|
+
if (external &&
|
|
536
|
+
external.version === CATALOG_VERSION &&
|
|
537
|
+
external.profile === this.options.profile &&
|
|
538
|
+
external.fingerprint === this.options.fingerprint) {
|
|
539
|
+
toWrite = mergeCatalog(this.sanitizeLoaded(external), toWrite, this.baseData);
|
|
540
|
+
this.data = toWrite;
|
|
541
|
+
this.invalidateNameIndex();
|
|
542
|
+
}
|
|
543
|
+
const payload = `${JSON.stringify(toWrite, null, 2)}\n`;
|
|
544
|
+
const persisted = structuredClone(toWrite);
|
|
545
|
+
await fs.promises.writeFile(tempPath, payload, {
|
|
546
|
+
encoding: 'utf8',
|
|
547
|
+
mode: 0o600,
|
|
548
|
+
flag: 'wx',
|
|
549
|
+
});
|
|
550
|
+
await fs.promises.rename(tempPath, this.options.filePath);
|
|
551
|
+
await fs.promises.chmod(this.options.filePath, 0o600);
|
|
552
|
+
this.baseData = persisted;
|
|
553
|
+
this.flushedRevision = writingRevision;
|
|
554
|
+
if (this.revision !== this.flushedRevision) {
|
|
555
|
+
this.scheduleFlush();
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
catch (error) {
|
|
559
|
+
if (tempPath) {
|
|
560
|
+
try {
|
|
561
|
+
await fs.promises.unlink(tempPath);
|
|
562
|
+
}
|
|
563
|
+
catch {
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
if (error instanceof CatalogLockTimeoutError) {
|
|
567
|
+
console.error(`[catalog] ${error.message}; will retry`);
|
|
568
|
+
this.scheduleFlush();
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
571
|
+
this.disable(`cannot write ${this.options.filePath}: ${error instanceof Error ? error.message : String(error)}`);
|
|
572
|
+
}
|
|
573
|
+
finally {
|
|
574
|
+
if (releaseLock) {
|
|
575
|
+
try {
|
|
576
|
+
await releaseLock();
|
|
577
|
+
}
|
|
578
|
+
catch (error) {
|
|
579
|
+
this.disable(`cannot release catalog lock: ${error instanceof Error ? error.message : String(error)}`);
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
readExternal() {
|
|
585
|
+
if (!fs.existsSync(this.options.filePath)) {
|
|
586
|
+
return null;
|
|
587
|
+
}
|
|
588
|
+
try {
|
|
589
|
+
return JSON.parse(fs.readFileSync(this.options.filePath, 'utf8'));
|
|
590
|
+
}
|
|
591
|
+
catch (error) {
|
|
592
|
+
console.error(`[catalog] ignoring unreadable ${this.options.filePath}; it will be replaced: ${error instanceof Error ? error.message : String(error)}`);
|
|
593
|
+
return null;
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
async close() {
|
|
597
|
+
this.closing = true;
|
|
598
|
+
if (this.flushTimer) {
|
|
599
|
+
clearTimeout(this.flushTimer);
|
|
600
|
+
this.flushTimer = null;
|
|
601
|
+
}
|
|
602
|
+
for (let attempt = 0; attempt < CLOSE_FLUSH_ATTEMPTS; attempt += 1) {
|
|
603
|
+
if (!this.enabled || this.revision === this.flushedRevision) {
|
|
604
|
+
return;
|
|
605
|
+
}
|
|
606
|
+
if (this.flushPromise) {
|
|
607
|
+
await this.flushPromise;
|
|
608
|
+
}
|
|
609
|
+
else {
|
|
610
|
+
await this.flush();
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
if (this.enabled && this.revision !== this.flushedRevision) {
|
|
614
|
+
console.error(`[catalog] gave up persisting the last updates to ${this.options.filePath} after ${CLOSE_FLUSH_ATTEMPTS} attempts`);
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
export const CATALOG_VERSION = 2;
|
|
2
|
+
export const JOIN_EDGE_LIMIT = 500;
|
|
3
|
+
export function pruneJoins(joins) {
|
|
4
|
+
if (joins.length <= JOIN_EDGE_LIMIT) {
|
|
5
|
+
return joins;
|
|
6
|
+
}
|
|
7
|
+
return [...joins].sort((a, b) => b.lastUsedAt.localeCompare(a.lastUsedAt)).slice(0, JOIN_EDGE_LIMIT);
|
|
8
|
+
}
|
|
9
|
+
export function normalizeName(value) {
|
|
10
|
+
return value.trim().toLowerCase();
|
|
11
|
+
}
|
|
12
|
+
export function emptyTable() {
|
|
13
|
+
return {
|
|
14
|
+
comment: '',
|
|
15
|
+
rowsEstimate: null,
|
|
16
|
+
detailScannedAt: null,
|
|
17
|
+
columns: [],
|
|
18
|
+
pk: [],
|
|
19
|
+
indexes: [],
|
|
20
|
+
fks: [],
|
|
21
|
+
usage: emptyUsage(),
|
|
22
|
+
curated: { notes: [], aliases: [] },
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
export function emptyUsage() {
|
|
26
|
+
return {
|
|
27
|
+
count: 0,
|
|
28
|
+
successCount: 0,
|
|
29
|
+
failureCount: 0,
|
|
30
|
+
lastUsedAt: null,
|
|
31
|
+
columns: {},
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
export function emptyCatalog(options) {
|
|
35
|
+
const schemas = {};
|
|
36
|
+
for (const entry of options.appSchemas) {
|
|
37
|
+
schemas[entry.schema] = {
|
|
38
|
+
app: entry.app,
|
|
39
|
+
...(entry.description ? { description: entry.description } : {}),
|
|
40
|
+
scannedAt: null,
|
|
41
|
+
tables: {},
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
return {
|
|
45
|
+
version: CATALOG_VERSION,
|
|
46
|
+
profile: options.profile,
|
|
47
|
+
fingerprint: options.fingerprint,
|
|
48
|
+
docs: {
|
|
49
|
+
repo: options.docsRepo,
|
|
50
|
+
ref: options.docsRef,
|
|
51
|
+
refCommit: null,
|
|
52
|
+
refUpdatedAt: null,
|
|
53
|
+
paths: [],
|
|
54
|
+
unlinked: [],
|
|
55
|
+
},
|
|
56
|
+
schemas,
|
|
57
|
+
joins: [],
|
|
58
|
+
};
|
|
59
|
+
}
|