@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,220 @@
|
|
|
1
|
+
import { executeQuery } from '../db/index.js';
|
|
2
|
+
import { emptyTable, normalizeName } from './types.js';
|
|
3
|
+
function chainAfter(pending, run) {
|
|
4
|
+
if (!pending) {
|
|
5
|
+
return run();
|
|
6
|
+
}
|
|
7
|
+
return pending.catch(() => undefined).then(run);
|
|
8
|
+
}
|
|
9
|
+
function isExpired(value, ttlHours) {
|
|
10
|
+
if (!value) {
|
|
11
|
+
return true;
|
|
12
|
+
}
|
|
13
|
+
const parsed = Date.parse(value);
|
|
14
|
+
if (!Number.isFinite(parsed)) {
|
|
15
|
+
return true;
|
|
16
|
+
}
|
|
17
|
+
return Date.now() - parsed >= ttlHours * 60 * 60 * 1_000;
|
|
18
|
+
}
|
|
19
|
+
export class CatalogCollector {
|
|
20
|
+
store;
|
|
21
|
+
options;
|
|
22
|
+
inventoryPromise = null;
|
|
23
|
+
detailPromises = new Map();
|
|
24
|
+
constructor(store, options) {
|
|
25
|
+
this.store = store;
|
|
26
|
+
this.options = options;
|
|
27
|
+
}
|
|
28
|
+
inventoryNeedsRefresh() {
|
|
29
|
+
return this.options.schemas.some((name) => {
|
|
30
|
+
const state = this.store.schemaState(name);
|
|
31
|
+
return !state || isExpired(state.scannedAt, this.options.ttlHours);
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
async collectInventory(force = false) {
|
|
35
|
+
if (!this.store.isEnabled() || this.options.schemas.length === 0) {
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
if (!force && !this.inventoryNeedsRefresh()) {
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
const pending = this.inventoryPromise;
|
|
42
|
+
if (pending && !force) {
|
|
43
|
+
return pending;
|
|
44
|
+
}
|
|
45
|
+
const promise = chainAfter(pending, () => this.runInventory()).finally(() => {
|
|
46
|
+
if (this.inventoryPromise === promise) {
|
|
47
|
+
this.inventoryPromise = null;
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
this.inventoryPromise = promise;
|
|
51
|
+
return promise;
|
|
52
|
+
}
|
|
53
|
+
async runInventory() {
|
|
54
|
+
const placeholders = this.options.schemas.map(() => '?').join(', ');
|
|
55
|
+
const rows = await executeQuery(`SELECT
|
|
56
|
+
table_schema AS \`table_schema\`,
|
|
57
|
+
table_name AS \`table_name\`,
|
|
58
|
+
table_comment AS \`table_comment\`,
|
|
59
|
+
table_rows AS \`table_rows\`
|
|
60
|
+
FROM information_schema.tables
|
|
61
|
+
WHERE table_schema IN (${placeholders})
|
|
62
|
+
ORDER BY table_schema, table_name`, [...this.options.schemas]);
|
|
63
|
+
const declaredBySchema = new Map(this.options.schemas.map((name) => [normalizeName(name), name]));
|
|
64
|
+
const rowsBySchema = new Map();
|
|
65
|
+
for (const row of rows) {
|
|
66
|
+
const declared = declaredBySchema.get(normalizeName(String(row.table_schema)));
|
|
67
|
+
if (!declared) {
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
const bucket = rowsBySchema.get(declared);
|
|
71
|
+
if (bucket) {
|
|
72
|
+
bucket.push(row);
|
|
73
|
+
}
|
|
74
|
+
else {
|
|
75
|
+
rowsBySchema.set(declared, [row]);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
const now = new Date().toISOString();
|
|
79
|
+
this.store.update((catalog) => {
|
|
80
|
+
for (const schemaName of this.options.schemas) {
|
|
81
|
+
const schema = catalog.schemas[schemaName];
|
|
82
|
+
if (!schema) {
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
const nextTables = Object.fromEntries((rowsBySchema.get(schemaName) ?? []).map((row) => {
|
|
86
|
+
const existing = schema.tables[row.table_name] ?? emptyTable();
|
|
87
|
+
const numericRows = Number(row.table_rows);
|
|
88
|
+
return [
|
|
89
|
+
row.table_name,
|
|
90
|
+
{
|
|
91
|
+
...existing,
|
|
92
|
+
comment: row.table_comment ?? '',
|
|
93
|
+
rowsEstimate: Number.isFinite(numericRows) ? numericRows : null,
|
|
94
|
+
},
|
|
95
|
+
];
|
|
96
|
+
}));
|
|
97
|
+
schema.tables = nextTables;
|
|
98
|
+
schema.scannedAt = now;
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
for (const schemaName of this.options.schemas) {
|
|
102
|
+
if (!rowsBySchema.has(schemaName)) {
|
|
103
|
+
console.error(`[catalog] declared schema "${schemaName}" returned no tables; check MYSQL_APP_SCHEMAS and the account's grants.`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
console.error(`[catalog] inventory scan complete: ${this.options.schemas.length} schemas, ${rows.length} tables`);
|
|
107
|
+
}
|
|
108
|
+
detailNeedsRefresh(schemaName, tableName) {
|
|
109
|
+
const meta = this.store.tableMeta(schemaName, tableName);
|
|
110
|
+
return Boolean(meta && (meta.detailStale || isExpired(meta.detailScannedAt, this.options.ttlHours)));
|
|
111
|
+
}
|
|
112
|
+
async collectTableDetail(schemaName, tableName, force = false) {
|
|
113
|
+
if (!this.store.isEnabled()) {
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
if (!force && !this.detailNeedsRefresh(schemaName, tableName)) {
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
const key = `${schemaName}.${tableName}`;
|
|
120
|
+
const pending = this.detailPromises.get(key);
|
|
121
|
+
if (pending && !force) {
|
|
122
|
+
return pending;
|
|
123
|
+
}
|
|
124
|
+
const promise = chainAfter(pending, () => this.runTableDetail(schemaName, tableName)).finally(() => {
|
|
125
|
+
if (this.detailPromises.get(key) === promise) {
|
|
126
|
+
this.detailPromises.delete(key);
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
this.detailPromises.set(key, promise);
|
|
130
|
+
return promise;
|
|
131
|
+
}
|
|
132
|
+
async runTableDetail(schemaName, tableName) {
|
|
133
|
+
const rows = await executeQuery(`SELECT
|
|
134
|
+
'column' AS kind,
|
|
135
|
+
c.column_name AS name,
|
|
136
|
+
c.data_type AS \`data_type\`,
|
|
137
|
+
c.column_type AS \`column_type\`,
|
|
138
|
+
c.is_nullable AS \`is_nullable\`,
|
|
139
|
+
CAST(c.column_default AS CHAR) AS default_value,
|
|
140
|
+
c.extra AS \`extra\`,
|
|
141
|
+
c.column_comment AS comment,
|
|
142
|
+
c.ordinal_position AS position,
|
|
143
|
+
NULL AS index_column,
|
|
144
|
+
NULL AS non_unique,
|
|
145
|
+
NULL AS index_type,
|
|
146
|
+
NULL AS fk_column,
|
|
147
|
+
NULL AS ref_schema,
|
|
148
|
+
NULL AS ref_table,
|
|
149
|
+
NULL AS ref_column
|
|
150
|
+
FROM information_schema.columns c
|
|
151
|
+
WHERE c.table_schema = ? AND c.table_name = ?
|
|
152
|
+
UNION ALL
|
|
153
|
+
SELECT
|
|
154
|
+
'index', s.index_name, NULL, NULL, NULL, NULL, NULL, NULL,
|
|
155
|
+
s.seq_in_index, s.column_name, s.non_unique, s.index_type,
|
|
156
|
+
NULL, NULL, NULL, NULL
|
|
157
|
+
FROM information_schema.statistics s
|
|
158
|
+
WHERE s.table_schema = ? AND s.table_name = ?
|
|
159
|
+
UNION ALL
|
|
160
|
+
SELECT
|
|
161
|
+
'fk', k.constraint_name, NULL, NULL, NULL, NULL, NULL, NULL,
|
|
162
|
+
k.ordinal_position, NULL, NULL, NULL,
|
|
163
|
+
k.column_name, k.referenced_table_schema,
|
|
164
|
+
k.referenced_table_name, k.referenced_column_name
|
|
165
|
+
FROM information_schema.key_column_usage k
|
|
166
|
+
WHERE k.table_schema = ? AND k.table_name = ?
|
|
167
|
+
AND k.referenced_table_name IS NOT NULL
|
|
168
|
+
ORDER BY kind, position`, [schemaName, tableName, schemaName, tableName, schemaName, tableName]);
|
|
169
|
+
const columns = rows
|
|
170
|
+
.filter((row) => row.kind === 'column')
|
|
171
|
+
.map((row) => ({
|
|
172
|
+
name: row.name,
|
|
173
|
+
dataType: row.data_type ?? '',
|
|
174
|
+
columnType: row.column_type ?? '',
|
|
175
|
+
nullable: row.is_nullable === 'YES',
|
|
176
|
+
defaultValue: row.default_value,
|
|
177
|
+
extra: row.extra ?? '',
|
|
178
|
+
comment: row.comment ?? '',
|
|
179
|
+
ordinal: Number(row.position),
|
|
180
|
+
}));
|
|
181
|
+
const indexesByName = new Map();
|
|
182
|
+
for (const row of rows) {
|
|
183
|
+
if (row.kind !== 'index' || !row.index_column) {
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
const index = indexesByName.get(row.name) ?? {
|
|
187
|
+
name: row.name,
|
|
188
|
+
unique: Number(row.non_unique) === 0,
|
|
189
|
+
type: row.index_type ?? '',
|
|
190
|
+
columns: [],
|
|
191
|
+
};
|
|
192
|
+
index.columns.push(row.index_column);
|
|
193
|
+
indexesByName.set(row.name, index);
|
|
194
|
+
}
|
|
195
|
+
const indexes = [...indexesByName.values()];
|
|
196
|
+
const pk = indexes.find((index) => index.name === 'PRIMARY')?.columns ?? [];
|
|
197
|
+
const fks = rows
|
|
198
|
+
.filter((row) => row.kind === 'fk' && Boolean(row.fk_column && row.ref_schema && row.ref_table && row.ref_column))
|
|
199
|
+
.map((row) => ({
|
|
200
|
+
name: row.name,
|
|
201
|
+
column: row.fk_column,
|
|
202
|
+
referencedSchema: row.ref_schema,
|
|
203
|
+
referencedTable: row.ref_table,
|
|
204
|
+
referencedColumn: row.ref_column,
|
|
205
|
+
}));
|
|
206
|
+
this.store.update((catalog) => {
|
|
207
|
+
const table = catalog.schemas[schemaName]?.tables[tableName];
|
|
208
|
+
if (!table) {
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
table.columns = columns;
|
|
212
|
+
table.pk = pk;
|
|
213
|
+
table.indexes = indexes;
|
|
214
|
+
table.fks = fks;
|
|
215
|
+
table.detailScannedAt = new Date().toISOString();
|
|
216
|
+
delete table.detailStale;
|
|
217
|
+
});
|
|
218
|
+
console.error(`[catalog] detail scan complete: ${schemaName}.${tableName} (${columns.length} columns)`);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
import { execFile } from 'child_process';
|
|
2
|
+
import { promisify } from 'util';
|
|
3
|
+
const execFileAsync = promisify(execFile);
|
|
4
|
+
const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1_000;
|
|
5
|
+
const GIT_TIMEOUT_MS = 10_000;
|
|
6
|
+
export function modelPaths(output) {
|
|
7
|
+
return output.split('\0').filter((path) => path.endsWith('model.md'));
|
|
8
|
+
}
|
|
9
|
+
export function parseDiff(output) {
|
|
10
|
+
const entries = [];
|
|
11
|
+
const fields = output.split('\0');
|
|
12
|
+
let index = 0;
|
|
13
|
+
while (index < fields.length) {
|
|
14
|
+
const kind = fields[index]?.[0];
|
|
15
|
+
if (kind === 'R' || kind === 'C') {
|
|
16
|
+
const oldPath = fields[index + 1];
|
|
17
|
+
const path = fields[index + 2];
|
|
18
|
+
if (kind === 'R' && oldPath && path) {
|
|
19
|
+
entries.push({ status: 'R', oldPath, path });
|
|
20
|
+
}
|
|
21
|
+
index += 3;
|
|
22
|
+
}
|
|
23
|
+
else if (kind) {
|
|
24
|
+
const path = fields[index + 1];
|
|
25
|
+
if ((kind === 'A' || kind === 'D' || kind === 'M') && path) {
|
|
26
|
+
entries.push({ status: kind, path });
|
|
27
|
+
}
|
|
28
|
+
index += 2;
|
|
29
|
+
}
|
|
30
|
+
else {
|
|
31
|
+
index += 1;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return entries;
|
|
35
|
+
}
|
|
36
|
+
function linkedDocumentPaths(catalog) {
|
|
37
|
+
const linked = new Set();
|
|
38
|
+
for (const schema of Object.values(catalog.schemas)) {
|
|
39
|
+
for (const table of Object.values(schema.tables)) {
|
|
40
|
+
if (table.curated.doc) {
|
|
41
|
+
linked.add(table.curated.doc.path);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return linked;
|
|
46
|
+
}
|
|
47
|
+
function refreshUnlinked(catalog) {
|
|
48
|
+
const linked = linkedDocumentPaths(catalog);
|
|
49
|
+
catalog.docs.unlinked = catalog.docs.paths.filter((path) => !linked.has(path));
|
|
50
|
+
}
|
|
51
|
+
function shellQuote(value) {
|
|
52
|
+
if (/^[A-Za-z0-9_./:@+-]+$/.test(value)) {
|
|
53
|
+
return value;
|
|
54
|
+
}
|
|
55
|
+
return `'${value.replace(/'/g, `'"'"'`)}'`;
|
|
56
|
+
}
|
|
57
|
+
export class CatalogDocuments {
|
|
58
|
+
store;
|
|
59
|
+
options;
|
|
60
|
+
attempted = false;
|
|
61
|
+
wakePromise = null;
|
|
62
|
+
wakeSpent = false;
|
|
63
|
+
disabledReason = null;
|
|
64
|
+
constructor(store, options) {
|
|
65
|
+
this.store = store;
|
|
66
|
+
this.options = options;
|
|
67
|
+
}
|
|
68
|
+
isConfigured() {
|
|
69
|
+
return Boolean(this.options.repo && this.options.ref);
|
|
70
|
+
}
|
|
71
|
+
isAvailable() {
|
|
72
|
+
return this.isConfigured() && this.disabledReason === null;
|
|
73
|
+
}
|
|
74
|
+
error() {
|
|
75
|
+
return this.disabledReason;
|
|
76
|
+
}
|
|
77
|
+
resetWake() {
|
|
78
|
+
this.attempted = false;
|
|
79
|
+
this.disabledReason = null;
|
|
80
|
+
this.wakeSpent = this.wakePromise !== null;
|
|
81
|
+
}
|
|
82
|
+
async git(args) {
|
|
83
|
+
if (!this.options.repo) {
|
|
84
|
+
throw new Error('MYSQL_DOCS_REPO is not configured.');
|
|
85
|
+
}
|
|
86
|
+
console.error(`[catalog] git -C ${this.options.repo} ${args.join(' ')}`);
|
|
87
|
+
const { stdout } = await execFileAsync('git', ['-C', this.options.repo, ...args], {
|
|
88
|
+
encoding: 'utf8',
|
|
89
|
+
maxBuffer: 5 * 1024 * 1024,
|
|
90
|
+
timeout: GIT_TIMEOUT_MS,
|
|
91
|
+
});
|
|
92
|
+
return stdout;
|
|
93
|
+
}
|
|
94
|
+
async ensureAwake() {
|
|
95
|
+
if (!this.isConfigured()) {
|
|
96
|
+
throw new Error('The document catalog is disabled because MYSQL_DOCS_REPO or MYSQL_CODE_BRANCH is not configured.');
|
|
97
|
+
}
|
|
98
|
+
if (this.disabledReason) {
|
|
99
|
+
throw new Error(this.disabledReason);
|
|
100
|
+
}
|
|
101
|
+
if (this.attempted && !this.wakePromise) {
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
let promise = this.wakePromise;
|
|
105
|
+
if (!promise || this.wakeSpent) {
|
|
106
|
+
this.attempted = true;
|
|
107
|
+
const previous = promise && this.wakeSpent ? promise.catch(() => undefined) : null;
|
|
108
|
+
this.wakeSpent = false;
|
|
109
|
+
promise = (previous ?? Promise.resolve())
|
|
110
|
+
.then(() => this.wake())
|
|
111
|
+
.catch((error) => {
|
|
112
|
+
this.disabledReason = `The document catalog is unavailable: ${error instanceof Error ? error.message : String(error)}`;
|
|
113
|
+
console.error(`[catalog] document axis disabled: ${this.disabledReason}`);
|
|
114
|
+
throw new Error(this.disabledReason);
|
|
115
|
+
});
|
|
116
|
+
this.wakePromise = promise;
|
|
117
|
+
}
|
|
118
|
+
try {
|
|
119
|
+
await promise;
|
|
120
|
+
}
|
|
121
|
+
finally {
|
|
122
|
+
if (this.wakePromise === promise) {
|
|
123
|
+
this.wakePromise = null;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
async wake() {
|
|
128
|
+
const ref = this.options.ref;
|
|
129
|
+
const commit = (await this.git(['rev-parse', '--verify', `${ref}^{commit}`])).trim();
|
|
130
|
+
const cached = this.store.docsView();
|
|
131
|
+
if (cached.refCommit === commit) {
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
let entries = null;
|
|
135
|
+
if (cached.refCommit) {
|
|
136
|
+
try {
|
|
137
|
+
entries = parseDiff(await this.git(['diff', '--name-status', '-z', cached.refCommit, commit, '--', '*model.md']));
|
|
138
|
+
}
|
|
139
|
+
catch (error) {
|
|
140
|
+
console.error(`[catalog] cached document commit is unavailable; rebuilding path list: ${error instanceof Error ? error.message : String(error)}`);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
if (entries === null) {
|
|
144
|
+
const paths = modelPaths(await this.git(['ls-tree', '-r', '-z', '--name-only', commit]));
|
|
145
|
+
this.store.update((catalog) => {
|
|
146
|
+
catalog.docs.paths = paths;
|
|
147
|
+
for (const schema of Object.values(catalog.schemas)) {
|
|
148
|
+
for (const table of Object.values(schema.tables)) {
|
|
149
|
+
if (table.curated.doc && !paths.includes(table.curated.doc.path)) {
|
|
150
|
+
delete table.curated.doc;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
refreshUnlinked(catalog);
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
else {
|
|
158
|
+
this.store.update((catalog) => {
|
|
159
|
+
for (const entry of entries) {
|
|
160
|
+
if (entry.status === 'M') {
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
if (entry.status === 'A') {
|
|
164
|
+
if (!catalog.docs.paths.includes(entry.path)) {
|
|
165
|
+
catalog.docs.paths.push(entry.path);
|
|
166
|
+
}
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
if (entry.status === 'D') {
|
|
170
|
+
catalog.docs.paths = catalog.docs.paths.filter((path) => path !== entry.path);
|
|
171
|
+
for (const schema of Object.values(catalog.schemas)) {
|
|
172
|
+
for (const table of Object.values(schema.tables)) {
|
|
173
|
+
if (table.curated.doc?.path === entry.path) {
|
|
174
|
+
delete table.curated.doc;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
const oldPath = entry.oldPath;
|
|
181
|
+
catalog.docs.paths = catalog.docs.paths.map((path) => (path === oldPath ? entry.path : path));
|
|
182
|
+
if (!catalog.docs.paths.includes(entry.path)) {
|
|
183
|
+
catalog.docs.paths.push(entry.path);
|
|
184
|
+
}
|
|
185
|
+
for (const schema of Object.values(catalog.schemas)) {
|
|
186
|
+
for (const table of Object.values(schema.tables)) {
|
|
187
|
+
if (table.curated.doc?.path === oldPath) {
|
|
188
|
+
table.curated.doc.path = entry.path;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
catalog.docs.paths = [...new Set(catalog.docs.paths)].sort();
|
|
194
|
+
refreshUnlinked(catalog);
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
let refUpdatedAt = null;
|
|
198
|
+
try {
|
|
199
|
+
const seconds = Number((await this.git(['show', '-s', '--format=%ct', commit])).trim());
|
|
200
|
+
if (Number.isFinite(seconds)) {
|
|
201
|
+
refUpdatedAt = new Date(seconds * 1_000).toISOString();
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
catch (error) {
|
|
205
|
+
console.error(`[catalog] could not read document ref timestamp: ${error instanceof Error ? error.message : String(error)}`);
|
|
206
|
+
}
|
|
207
|
+
this.store.update((catalog) => {
|
|
208
|
+
catalog.docs.repo = this.options.repo;
|
|
209
|
+
catalog.docs.ref = this.options.ref;
|
|
210
|
+
catalog.docs.refCommit = commit;
|
|
211
|
+
catalog.docs.refUpdatedAt = refUpdatedAt;
|
|
212
|
+
});
|
|
213
|
+
console.error(`[catalog] document paths refreshed: ${this.store.docsView().paths.length} files at ${ref}`);
|
|
214
|
+
}
|
|
215
|
+
async list(schemaName) {
|
|
216
|
+
await this.ensureAwake();
|
|
217
|
+
const mapping = this.options.appSchemas.find((entry) => entry.schema.toLowerCase() === schemaName.toLowerCase());
|
|
218
|
+
if (!mapping) {
|
|
219
|
+
throw new Error(`Schema "${schemaName}" is not declared in MYSQL_APP_SCHEMAS.`);
|
|
220
|
+
}
|
|
221
|
+
const catalog = this.store.snapshot();
|
|
222
|
+
const prefix = `apps/${mapping.app}/`;
|
|
223
|
+
const inPrefix = catalog.docs.paths.filter((path) => path.startsWith(prefix));
|
|
224
|
+
const scoped = inPrefix.length > 0;
|
|
225
|
+
const selected = scoped ? inPrefix : catalog.docs.paths;
|
|
226
|
+
const linkedTablesFor = (path) => Object.entries(catalog.schemas).flatMap(([knownSchema, schema]) => Object.entries(schema.tables)
|
|
227
|
+
.filter(([, table]) => table.curated.doc?.path === path)
|
|
228
|
+
.map(([table]) => `${knownSchema}.${table}`));
|
|
229
|
+
const paths = selected.map((path) => ({
|
|
230
|
+
path,
|
|
231
|
+
linkedTables: linkedTablesFor(path),
|
|
232
|
+
}));
|
|
233
|
+
const outsidePrefix = catalog.docs.paths.length - inPrefix.length;
|
|
234
|
+
const notice = scoped
|
|
235
|
+
? outsidePrefix > 0
|
|
236
|
+
? `${outsidePrefix}개 문서는 "${prefix}" 밖에 있어 제외했습니다. ` +
|
|
237
|
+
'map의 unlinkedDocuments에 전체 목록이 있고, docs_read는 카탈로그에 ' +
|
|
238
|
+
'있는 어떤 경로든 읽습니다.'
|
|
239
|
+
: null
|
|
240
|
+
: `"${prefix}" 로 시작하는 문서가 없어 카탈로그의 모든 문서를 반환했습니다. ` +
|
|
241
|
+
`MYSQL_APP_SCHEMAS의 앱 이름("${mapping.app}")이 저장소 디렉토리와 다를 수 있습니다.`;
|
|
242
|
+
const warning = this.staleWarning();
|
|
243
|
+
return JSON.stringify({
|
|
244
|
+
schema: schemaName,
|
|
245
|
+
app: mapping.app,
|
|
246
|
+
ref: catalog.docs.ref,
|
|
247
|
+
scopedToApp: scoped,
|
|
248
|
+
paths,
|
|
249
|
+
...(notice ? { notice } : {}),
|
|
250
|
+
...(warning ? { warning } : {}),
|
|
251
|
+
}, null, 2);
|
|
252
|
+
}
|
|
253
|
+
async read(documentPath) {
|
|
254
|
+
await this.ensureAwake();
|
|
255
|
+
const catalog = this.store.snapshot();
|
|
256
|
+
if (!catalog.docs.paths.includes(documentPath)) {
|
|
257
|
+
throw new Error(`Document "${documentPath}" is not present at ${catalog.docs.ref}. Use docs_list or map to choose a cataloged path.`);
|
|
258
|
+
}
|
|
259
|
+
const content = await this.git(['show', `${catalog.docs.ref}:${documentPath}`]);
|
|
260
|
+
const warning = this.staleWarning();
|
|
261
|
+
return warning ? `${warning}\n\n${content}` : content;
|
|
262
|
+
}
|
|
263
|
+
async link(links) {
|
|
264
|
+
await this.ensureAwake();
|
|
265
|
+
const snapshot = this.store.snapshot();
|
|
266
|
+
for (const link of links) {
|
|
267
|
+
if (!snapshot.docs.paths.includes(link.doc)) {
|
|
268
|
+
throw new Error(`Document "${link.doc}" is not present at ${snapshot.docs.ref}.`);
|
|
269
|
+
}
|
|
270
|
+
if (!snapshot.schemas[link.schema]?.tables[link.table]) {
|
|
271
|
+
throw new Error(`Unknown table "${link.schema}.${link.table}".`);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
const now = new Date().toISOString();
|
|
275
|
+
this.store.update((catalog) => {
|
|
276
|
+
for (const link of links) {
|
|
277
|
+
catalog.schemas[link.schema].tables[link.table].curated.doc = {
|
|
278
|
+
path: link.doc,
|
|
279
|
+
linkedBy: 'model',
|
|
280
|
+
linkedAt: now,
|
|
281
|
+
linkedAtCommit: catalog.docs.refCommit,
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
refreshUnlinked(catalog);
|
|
285
|
+
});
|
|
286
|
+
return JSON.stringify({
|
|
287
|
+
linked: links.map((link) => ({
|
|
288
|
+
table: `${link.schema}.${link.table}`,
|
|
289
|
+
doc: link.doc,
|
|
290
|
+
})),
|
|
291
|
+
...(this.staleWarning() ? { warning: this.staleWarning() } : {}),
|
|
292
|
+
}, null, 2);
|
|
293
|
+
}
|
|
294
|
+
unlink(schemaName, tableName) {
|
|
295
|
+
const snapshot = this.store.snapshot();
|
|
296
|
+
if (!snapshot.schemas[schemaName]?.tables[tableName]) {
|
|
297
|
+
throw new Error(`Unknown table "${schemaName}.${tableName}".`);
|
|
298
|
+
}
|
|
299
|
+
this.store.update((catalog) => {
|
|
300
|
+
catalog.schemas[schemaName].tables[tableName].curated.doc = null;
|
|
301
|
+
refreshUnlinked(catalog);
|
|
302
|
+
});
|
|
303
|
+
return JSON.stringify({
|
|
304
|
+
table: `${schemaName}.${tableName}`,
|
|
305
|
+
doc: null,
|
|
306
|
+
status: '문서 없음 또는 연결 해제를 확인했습니다.',
|
|
307
|
+
...(this.staleWarning() ? { warning: this.staleWarning() } : {}),
|
|
308
|
+
}, null, 2);
|
|
309
|
+
}
|
|
310
|
+
staleWarning() {
|
|
311
|
+
const updatedAt = this.store.docsView().refUpdatedAt;
|
|
312
|
+
if (!updatedAt) {
|
|
313
|
+
return null;
|
|
314
|
+
}
|
|
315
|
+
const age = Date.now() - Date.parse(updatedAt);
|
|
316
|
+
if (!Number.isFinite(age) || age <= THIRTY_DAYS_MS) {
|
|
317
|
+
return null;
|
|
318
|
+
}
|
|
319
|
+
return (`[카탈로그 경고] ${this.options.ref}의 마지막 커밋이 30일 이상 지났습니다. ` +
|
|
320
|
+
'필요하면 사용자가 문서 저장소에서 git fetch를 실행해야 합니다.');
|
|
321
|
+
}
|
|
322
|
+
documentCommand(documentPath) {
|
|
323
|
+
if (!this.options.repo || !this.options.ref) {
|
|
324
|
+
return null;
|
|
325
|
+
}
|
|
326
|
+
return `git -C ${shellQuote(this.options.repo)} show ${shellQuote(`${this.options.ref}:${documentPath}`)}`;
|
|
327
|
+
}
|
|
328
|
+
}
|