mcp-for-i 0.1.13 → 0.2.0
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/README.md +17 -0
- package/dist/config/store.js +229 -109
- package/dist/controlplane/auth.js +128 -0
- package/dist/controlplane/clientIntegrationService.js +83 -4
- package/dist/controlplane/commands.js +14 -9
- package/dist/controlplane/connectionService.js +110 -5
- package/dist/controlplane/connectionTest.js +329 -0
- package/dist/controlplane/errors.js +12 -0
- package/dist/controlplane/runtimeService.js +98 -26
- package/dist/controlplane/server.js +122 -11
- package/dist/controlplane/serverShared.js +25 -6
- package/dist/controlplane/serviceManager.js +113 -100
- package/dist/controlplane/ui.js +1742 -920
- package/dist/ibmi/CompileTools.js +29 -1
- package/dist/ibmi/IBMiContent.js +435 -63
- package/dist/ibmi/IbmDocs.js +685 -0
- package/dist/ibmi/LocalLanguageActions.js +129 -3
- package/dist/ibmi/Search.js +158 -52
- package/dist/ibmi/Tools.js +7 -10
- package/dist/ibmi/client.js +220 -31
- package/dist/ibmi/evfevent.js +256 -0
- package/dist/ibmi/knownHosts.js +190 -0
- package/dist/ibmi/libraryList.js +93 -0
- package/dist/ibmi/sourceInspect.js +815 -0
- package/dist/ibmi/sqlJob.js +888 -0
- package/dist/ibmi/sqlParse.js +317 -0
- package/dist/ibmi/tn5250/certTrust.js +59 -0
- package/dist/ibmi/tn5250/client.js +310 -0
- package/dist/ibmi/tn5250/datastream.js +109 -0
- package/dist/ibmi/tn5250/ebcdic.js +74 -0
- package/dist/ibmi/tn5250/index.js +6 -0
- package/dist/ibmi/tn5250/screen.js +827 -0
- package/dist/ibmi/tn5250/telnet.js +186 -0
- package/dist/ibmi/variables.js +18 -7
- package/dist/index.js +43 -22
- package/dist/mcp/annotations.js +155 -0
- package/dist/mcp/audit.js +490 -133
- package/dist/mcp/confirm.js +48 -0
- package/dist/mcp/context.js +43 -5
- package/dist/mcp/deployMethods.js +192 -0
- package/dist/mcp/logger.js +5 -10
- package/dist/mcp/modules/bulkMembers.js +476 -0
- package/dist/mcp/modules/changedLines.js +189 -0
- package/dist/mcp/modules/compare.js +198 -0
- package/dist/mcp/modules/compile.js +515 -0
- package/dist/mcp/modules/ifsOps.js +123 -0
- package/dist/mcp/modules/index.js +38 -0
- package/dist/mcp/modules/inspect.js +811 -0
- package/dist/mcp/modules/libl.js +246 -0
- package/dist/mcp/modules/members.js +171 -0
- package/dist/mcp/modules/objects.js +339 -0
- package/dist/mcp/modules/system.js +266 -0
- package/dist/mcp/modules/tn5250.js +515 -0
- package/dist/mcp/policy.js +412 -0
- package/dist/mcp/tools.js +1700 -698
- package/dist/security/credentialStore.js +62 -13
- package/dist/security/localPaths.js +138 -0
- package/dist/security/redact.js +79 -0
- package/dist/util/atomicWrite.js +101 -0
- package/dist/util/fileLock.js +206 -0
- package/docs/capabilities.md +50 -7
- package/docs/configuration.md +189 -60
- package/docs/examples.md +109 -73
- package/docs/overview.md +15 -15
- package/docs/security.md +101 -16
- package/docs/tools.md +363 -188
- package/package.json +56 -53
package/dist/ibmi/IBMiContent.js
CHANGED
|
@@ -1,41 +1,25 @@
|
|
|
1
1
|
import fs from "fs/promises";
|
|
2
2
|
import path from "path";
|
|
3
3
|
import os from "os";
|
|
4
|
+
import crypto from "crypto";
|
|
4
5
|
import { Tools } from "./Tools.js";
|
|
5
6
|
import { IBMiClient } from "./client.js";
|
|
6
7
|
import { SourceDateStore, makeAliasName } from "./sourceDates.js";
|
|
7
8
|
export class IBMiContent {
|
|
8
9
|
ibmi;
|
|
9
10
|
sourceDates = new SourceDateStore();
|
|
11
|
+
snapshotCache = new Map();
|
|
12
|
+
snapshotInflight = new Map();
|
|
13
|
+
snapshotLatestKey = new Map();
|
|
14
|
+
snapshotGeneration = new Map();
|
|
15
|
+
snapshotCacheTtlMs = 120_000;
|
|
16
|
+
snapshotCacheMax = 32;
|
|
10
17
|
constructor(ibmi) {
|
|
11
18
|
this.ibmi = ibmi;
|
|
12
19
|
}
|
|
13
20
|
get config() {
|
|
14
21
|
return this.ibmi.getConfig();
|
|
15
22
|
}
|
|
16
|
-
async getTempLibrary() {
|
|
17
|
-
const desired = this.ibmi.upperCaseName(this.config.tempLibrary || "ILEDITOR");
|
|
18
|
-
if (desired === "QTEMP")
|
|
19
|
-
return "QTEMP";
|
|
20
|
-
try {
|
|
21
|
-
const rows = await this.ibmi.runSQL(`select SYSTEM_SCHEMA_NAME from QSYS2.SYSSCHEMAS where SYSTEM_SCHEMA_NAME=${Tools.sqlString(desired)}`);
|
|
22
|
-
if (rows.length > 0)
|
|
23
|
-
return desired;
|
|
24
|
-
}
|
|
25
|
-
catch {
|
|
26
|
-
// ignore and fall back to QTEMP
|
|
27
|
-
}
|
|
28
|
-
if (!this.config.readOnlyMode) {
|
|
29
|
-
try {
|
|
30
|
-
await this.createLibrary(desired);
|
|
31
|
-
return desired;
|
|
32
|
-
}
|
|
33
|
-
catch {
|
|
34
|
-
// ignore
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
return undefined;
|
|
38
|
-
}
|
|
39
23
|
async downloadStreamfileRaw(remotePath) {
|
|
40
24
|
const tmpFile = await this.createTempFile();
|
|
41
25
|
await this.ibmi.client.getFile(tmpFile, remotePath);
|
|
@@ -81,11 +65,33 @@ export class IBMiContent {
|
|
|
81
65
|
return files;
|
|
82
66
|
}
|
|
83
67
|
async downloadMemberContent(library, sourceFile, member) {
|
|
68
|
+
return (await this.downloadMemberContentDetailed(library, sourceFile, member)).content;
|
|
69
|
+
}
|
|
70
|
+
async downloadMemberContentDetailed(library, sourceFile, member) {
|
|
84
71
|
if (this.config.enableSourceDates) {
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
72
|
+
try {
|
|
73
|
+
const lib = this.requireQsysName(library, "library");
|
|
74
|
+
const file = this.requireQsysName(sourceFile, "sourceFile");
|
|
75
|
+
const mbr = this.requireQsysName(member, "member");
|
|
76
|
+
const snapshot = await this.loadMemberSnapshotViaSql(lib, file, mbr);
|
|
77
|
+
return { content: snapshot.content, transport: "sql-member", warnings: [] };
|
|
78
|
+
}
|
|
79
|
+
catch (error) {
|
|
80
|
+
const fallback = await this.downloadMemberContentViaStreamFile(library, sourceFile, member);
|
|
81
|
+
return {
|
|
82
|
+
content: fallback,
|
|
83
|
+
transport: "cpytostmf",
|
|
84
|
+
warnings: [`Source-date SQL read failed; used complete CPYTOSTMF fallback: ${error?.message || String(error)}`]
|
|
85
|
+
};
|
|
86
|
+
}
|
|
88
87
|
}
|
|
88
|
+
return {
|
|
89
|
+
content: await this.downloadMemberContentViaStreamFile(library, sourceFile, member),
|
|
90
|
+
transport: "cpytostmf",
|
|
91
|
+
warnings: []
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
async downloadMemberContentViaStreamFile(library, sourceFile, member) {
|
|
89
95
|
const lib = this.requireQsysName(library, "library");
|
|
90
96
|
const file = this.requireQsysName(sourceFile, "sourceFile");
|
|
91
97
|
const mbr = this.requireQsysName(member, "member");
|
|
@@ -93,17 +99,22 @@ export class IBMiContent {
|
|
|
93
99
|
const ccsid = this.normalizeSourceFileCcsid(this.config.sourceFileCCSID);
|
|
94
100
|
const cmd = `QSYS/CPYTOSTMF FROMMBR('${Tools.qualifyPath(lib, file, mbr, undefined, true)}') ` +
|
|
95
101
|
`TOSTMF('${tempRemote}') STMFOPT(*REPLACE) STMFCCSID(1208) DBFCCSID(${ccsid})`;
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
+
try {
|
|
103
|
+
const result = await this.ibmi.sendQsh({ command: `system \"${IBMiClient.escapeForShell(cmd)}\"` });
|
|
104
|
+
if (result.code !== 0)
|
|
105
|
+
throw new Error(result.stderr || "CPYTOSTMF failed");
|
|
106
|
+
const buffer = await this.downloadStreamfileRaw(tempRemote);
|
|
107
|
+
return Buffer.from(buffer).toString("utf8");
|
|
108
|
+
}
|
|
109
|
+
finally {
|
|
110
|
+
await this.ibmi.sendCommand({ command: `rm -f ${Tools.escapePath(tempRemote)}` }).catch(() => { });
|
|
111
|
+
}
|
|
102
112
|
}
|
|
103
113
|
async uploadMemberContent(library, sourceFile, member, content) {
|
|
104
114
|
if (this.config.enableSourceDates) {
|
|
105
115
|
try {
|
|
106
116
|
await this.uploadMemberContentWithDates(library, sourceFile, member, content.toString());
|
|
117
|
+
this.invalidateMemberSnapshot(library, sourceFile, member);
|
|
107
118
|
return;
|
|
108
119
|
}
|
|
109
120
|
catch {
|
|
@@ -111,6 +122,7 @@ export class IBMiContent {
|
|
|
111
122
|
}
|
|
112
123
|
}
|
|
113
124
|
await this.uploadMemberContentRaw(library, sourceFile, member, content);
|
|
125
|
+
this.invalidateMemberSnapshot(library, sourceFile, member);
|
|
114
126
|
}
|
|
115
127
|
async uploadMemberContentRaw(library, sourceFile, member, content) {
|
|
116
128
|
this.assertWritable();
|
|
@@ -129,8 +141,24 @@ export class IBMiContent {
|
|
|
129
141
|
await this.ibmi.sendCommand({ command: `rm -f ${Tools.escapePath(tempRemote)}` });
|
|
130
142
|
}
|
|
131
143
|
async getLibraries(filter = "*") {
|
|
132
|
-
const
|
|
133
|
-
|
|
144
|
+
const normalized = String(filter || "*").trim().toUpperCase() || "*";
|
|
145
|
+
let sql;
|
|
146
|
+
if (normalized === "*" || normalized === "*ALL") {
|
|
147
|
+
sql = `select OBJNAME, OBJTEXT from table(QSYS2.OBJECT_STATISTICS('*ALL', 'LIB', '*ALLSIMPLE'))`;
|
|
148
|
+
}
|
|
149
|
+
else if (normalized.includes("*")) {
|
|
150
|
+
// Generic filter such as DEV*: libraries are *LIB objects in QSYS, so list
|
|
151
|
+
// those and match the name with LIKE.
|
|
152
|
+
if (!/^[A-Z0-9#@$_.*]{1,11}$/.test(normalized))
|
|
153
|
+
throw new Error(`Invalid library filter: ${filter}`);
|
|
154
|
+
const pattern = normalized.replace(/[!%_]/g, c => `!${c}`).replace(/\*/g, "%");
|
|
155
|
+
sql = `select OBJNAME, OBJTEXT from table(QSYS2.OBJECT_STATISTICS('QSYS', '*LIB', '*ALLSIMPLE')) ` +
|
|
156
|
+
`where OBJNAME like ${Tools.sqlString(pattern)} escape '!' order by OBJNAME`;
|
|
157
|
+
}
|
|
158
|
+
else {
|
|
159
|
+
sql = `select OBJNAME, OBJTEXT from table(QSYS2.OBJECT_STATISTICS(${Tools.sqlString(normalized)}, 'LIB', '*ALLSIMPLE'))`;
|
|
160
|
+
}
|
|
161
|
+
const rows = await this.ibmi.runSQL(sql);
|
|
134
162
|
return rows.map(r => ({
|
|
135
163
|
library: String(r.OBJNAME),
|
|
136
164
|
name: String(r.OBJNAME),
|
|
@@ -208,6 +236,55 @@ export class IBMiContent {
|
|
|
208
236
|
text: String(r.PARTITION_TEXT || "")
|
|
209
237
|
}));
|
|
210
238
|
}
|
|
239
|
+
async getMemberListPage(options) {
|
|
240
|
+
const lib = this.requireQsysName(options.library, "library");
|
|
241
|
+
const file = this.requireQsysName(options.sourceFile, "sourceFile");
|
|
242
|
+
const limit = Math.max(1, Math.min(500, Math.floor(options.limit || 50)));
|
|
243
|
+
const conditions = [
|
|
244
|
+
`P.SYSTEM_TABLE_SCHEMA=${Tools.sqlString(lib)}`,
|
|
245
|
+
`P.SYSTEM_TABLE_NAME=${Tools.sqlString(file)}`,
|
|
246
|
+
`P.SYSTEM_TABLE_MEMBER <> ''`
|
|
247
|
+
];
|
|
248
|
+
if (options.member) {
|
|
249
|
+
conditions.push(`P.SYSTEM_TABLE_MEMBER=${Tools.sqlString(this.requireQsysName(options.member, "member"))}`);
|
|
250
|
+
}
|
|
251
|
+
if (options.memberPrefix) {
|
|
252
|
+
conditions.push(`P.SYSTEM_TABLE_MEMBER LIKE ${Tools.sqlString(`${this.normalizeSearchValue(options.memberPrefix)}%`)}`);
|
|
253
|
+
}
|
|
254
|
+
if (options.memberContains) {
|
|
255
|
+
conditions.push(`P.SYSTEM_TABLE_MEMBER LIKE ${Tools.sqlString(`%${this.normalizeSearchValue(options.memberContains)}%`)}`);
|
|
256
|
+
}
|
|
257
|
+
if (options.sourceTypes?.length) {
|
|
258
|
+
const sourceTypes = options.sourceTypes.map(value => this.normalizeSourceType(value));
|
|
259
|
+
conditions.push(`P.SOURCE_TYPE IN (${sourceTypes.map(Tools.sqlString).join(", ")})`);
|
|
260
|
+
}
|
|
261
|
+
if (options.changedSince) {
|
|
262
|
+
const changedSince = new Date(options.changedSince);
|
|
263
|
+
if (Number.isNaN(changedSince.getTime()))
|
|
264
|
+
throw new Error(`Invalid changedSince timestamp: ${options.changedSince}`);
|
|
265
|
+
const sqlTimestamp = changedSince.toISOString().slice(0, 23).replace("T", " ");
|
|
266
|
+
conditions.push(`P.LAST_SOURCE_UPDATE_TIMESTAMP >= timestamp(${Tools.sqlString(sqlTimestamp)})`);
|
|
267
|
+
}
|
|
268
|
+
if (options.cursor) {
|
|
269
|
+
const cursor = this.decodeMemberCursor(options.cursor);
|
|
270
|
+
conditions.push(`P.SYSTEM_TABLE_MEMBER > ${Tools.sqlString(cursor)}`);
|
|
271
|
+
}
|
|
272
|
+
const rows = await this.ibmi.runSQL(`select P.SYSTEM_TABLE_MEMBER, P.SOURCE_TYPE, P.PARTITION_TEXT, P.NUMBER_ROWS, ` +
|
|
273
|
+
`P.LAST_SOURCE_UPDATE_TIMESTAMP, T.ROW_LENGTH from QSYS2.SYSPARTITIONSTAT P ` +
|
|
274
|
+
`left join QSYS2.SYSTABLES T on T.SYSTEM_TABLE_SCHEMA=P.SYSTEM_TABLE_SCHEMA and T.SYSTEM_TABLE_NAME=P.SYSTEM_TABLE_NAME ` +
|
|
275
|
+
`where ${conditions.join(" and ")} order by P.SYSTEM_TABLE_MEMBER fetch first ${limit + 1} rows only`);
|
|
276
|
+
const hasMore = rows.length > limit;
|
|
277
|
+
const selected = rows.slice(0, limit);
|
|
278
|
+
const items = selected.map(row => this.memberMetadataFromRow(lib, file, row));
|
|
279
|
+
const last = items.at(-1);
|
|
280
|
+
return {
|
|
281
|
+
items,
|
|
282
|
+
returned: items.length,
|
|
283
|
+
hasMore,
|
|
284
|
+
...(hasMore && last ? { nextCursor: this.encodeMemberCursor(last.name) } : {}),
|
|
285
|
+
observedAt: new Date().toISOString()
|
|
286
|
+
};
|
|
287
|
+
}
|
|
211
288
|
async getMemberInfo(library, sourceFile, member) {
|
|
212
289
|
const lib = this.requireQsysName(library, "library");
|
|
213
290
|
const file = this.requireQsysName(sourceFile, "sourceFile");
|
|
@@ -224,23 +301,163 @@ export class IBMiContent {
|
|
|
224
301
|
text: String(row.PARTITION_TEXT || "")
|
|
225
302
|
};
|
|
226
303
|
}
|
|
227
|
-
async
|
|
304
|
+
async getMemberMetadata(library, sourceFile, member) {
|
|
228
305
|
const lib = this.requireQsysName(library, "library");
|
|
229
306
|
const file = this.requireQsysName(sourceFile, "sourceFile");
|
|
230
307
|
const mbr = this.requireQsysName(member, "member");
|
|
231
|
-
const
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
308
|
+
const rows = await this.ibmi.runSQL(`select P.SYSTEM_TABLE_MEMBER, P.SOURCE_TYPE, P.PARTITION_TEXT, P.NUMBER_ROWS, ` +
|
|
309
|
+
`P.LAST_SOURCE_UPDATE_TIMESTAMP, T.ROW_LENGTH from QSYS2.SYSPARTITIONSTAT P ` +
|
|
310
|
+
`left join QSYS2.SYSTABLES T on T.SYSTEM_TABLE_SCHEMA=P.SYSTEM_TABLE_SCHEMA and T.SYSTEM_TABLE_NAME=P.SYSTEM_TABLE_NAME ` +
|
|
311
|
+
`where P.SYSTEM_TABLE_SCHEMA=${Tools.sqlString(lib)} and P.SYSTEM_TABLE_NAME=${Tools.sqlString(file)} ` +
|
|
312
|
+
`and P.SYSTEM_TABLE_MEMBER=${Tools.sqlString(mbr)} fetch first 1 row only`);
|
|
313
|
+
return rows[0] ? this.memberMetadataFromRow(lib, file, rows[0]) : undefined;
|
|
314
|
+
}
|
|
315
|
+
async resolveMembers(options) {
|
|
316
|
+
const lib = this.requireQsysName(options.library, "library");
|
|
317
|
+
const member = this.requireQsysName(options.member, "member");
|
|
318
|
+
const limit = Math.max(1, Math.min(50, Math.floor(options.limit || 20)));
|
|
319
|
+
const conditions = [
|
|
320
|
+
`P.SYSTEM_TABLE_SCHEMA=${Tools.sqlString(lib)}`,
|
|
321
|
+
`P.SYSTEM_TABLE_MEMBER=${Tools.sqlString(member)}`
|
|
322
|
+
];
|
|
323
|
+
if (options.candidateSourceFiles?.length) {
|
|
324
|
+
const files = options.candidateSourceFiles.map(value => this.requireQsysName(value, "candidateSourceFile"));
|
|
325
|
+
conditions.push(`P.SYSTEM_TABLE_NAME IN (${files.map(Tools.sqlString).join(", ")})`);
|
|
326
|
+
}
|
|
327
|
+
if (options.sourceTypes?.length) {
|
|
328
|
+
const types = options.sourceTypes.map(value => this.normalizeSourceType(value));
|
|
329
|
+
conditions.push(`P.SOURCE_TYPE IN (${types.map(Tools.sqlString).join(", ")})`);
|
|
330
|
+
}
|
|
331
|
+
const rows = await this.ibmi.runSQL(`select P.SYSTEM_TABLE_NAME, P.SYSTEM_TABLE_MEMBER, P.SOURCE_TYPE, P.PARTITION_TEXT, P.NUMBER_ROWS, ` +
|
|
332
|
+
`P.LAST_SOURCE_UPDATE_TIMESTAMP, T.ROW_LENGTH from QSYS2.SYSPARTITIONSTAT P ` +
|
|
333
|
+
`left join QSYS2.SYSTABLES T on T.SYSTEM_TABLE_SCHEMA=P.SYSTEM_TABLE_SCHEMA and T.SYSTEM_TABLE_NAME=P.SYSTEM_TABLE_NAME ` +
|
|
334
|
+
`where ${conditions.join(" and ")} order by P.SYSTEM_TABLE_NAME fetch first ${limit + 1} rows only`);
|
|
335
|
+
return rows.slice(0, limit).map(row => this.memberMetadataFromRow(lib, String(row.SYSTEM_TABLE_NAME || ""), row));
|
|
336
|
+
}
|
|
337
|
+
async getMemberSnapshot(library, sourceFile, member, forceFresh = false) {
|
|
338
|
+
const lib = this.requireQsysName(library, "library");
|
|
339
|
+
const file = this.requireQsysName(sourceFile, "sourceFile");
|
|
340
|
+
const mbr = this.requireQsysName(member, "member");
|
|
341
|
+
const identityKey = `${lib}/${file}/${mbr}`;
|
|
342
|
+
if (!forceFresh) {
|
|
343
|
+
const cached = this.peekCachedSnapshot(identityKey);
|
|
344
|
+
if (cached) {
|
|
345
|
+
// Cache contract: a hit is only served after the cheap change marker
|
|
346
|
+
// still matches, so edits made outside this server are seen at once.
|
|
347
|
+
const current = await this.getMemberMetadata(lib, file, mbr);
|
|
348
|
+
if (!current) {
|
|
349
|
+
this.invalidateMemberSnapshot(lib, file, mbr);
|
|
350
|
+
throw new Error(`${mbr} in ${lib}/${file} not found`);
|
|
351
|
+
}
|
|
352
|
+
if (sameChangeMarker(cached.metadata, current)) {
|
|
353
|
+
return { ...cached, metadata: current, validatedAt: new Date().toISOString(), cacheStatus: "hit" };
|
|
354
|
+
}
|
|
355
|
+
this.invalidateMemberSnapshot(lib, file, mbr);
|
|
356
|
+
}
|
|
357
|
+
const inflight = this.snapshotInflight.get(identityKey);
|
|
358
|
+
if (inflight)
|
|
359
|
+
return { ...(await inflight), cacheStatus: "coalesced" };
|
|
360
|
+
}
|
|
361
|
+
// An invalidation (write, rename, delete) while this load is in flight
|
|
362
|
+
// bumps the generation, and the possibly stale result is then not cached.
|
|
363
|
+
const generation = this.snapshotGeneration.get(identityKey) || 0;
|
|
364
|
+
const loading = this.loadMemberSnapshot(lib, file, mbr);
|
|
365
|
+
this.snapshotInflight.set(identityKey, loading);
|
|
236
366
|
try {
|
|
237
|
-
|
|
367
|
+
const snapshot = await loading;
|
|
368
|
+
if ((this.snapshotGeneration.get(identityKey) || 0) === generation) {
|
|
369
|
+
this.cacheSnapshot(identityKey, snapshot);
|
|
370
|
+
}
|
|
371
|
+
return snapshot;
|
|
238
372
|
}
|
|
239
|
-
|
|
240
|
-
|
|
373
|
+
finally {
|
|
374
|
+
if (this.snapshotInflight.get(identityKey) === loading)
|
|
375
|
+
this.snapshotInflight.delete(identityKey);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
/**
|
|
379
|
+
* Cheap version information for metadata-only reads. Returns the member's
|
|
380
|
+
* catalog metadata and, when a cached snapshot is still valid for the
|
|
381
|
+
* current change marker, that snapshot. Never downloads member content.
|
|
382
|
+
*/
|
|
383
|
+
async getMemberVersionInfo(library, sourceFile, member) {
|
|
384
|
+
const lib = this.requireQsysName(library, "library");
|
|
385
|
+
const file = this.requireQsysName(sourceFile, "sourceFile");
|
|
386
|
+
const mbr = this.requireQsysName(member, "member");
|
|
387
|
+
const identityKey = `${lib}/${file}/${mbr}`;
|
|
388
|
+
const metadata = await this.getMemberMetadata(lib, file, mbr);
|
|
389
|
+
const observedAt = new Date().toISOString();
|
|
390
|
+
if (!metadata) {
|
|
391
|
+
this.invalidateMemberSnapshot(lib, file, mbr);
|
|
392
|
+
throw new Error(`${mbr} in ${lib}/${file} not found`);
|
|
393
|
+
}
|
|
394
|
+
const cached = this.peekCachedSnapshot(identityKey);
|
|
395
|
+
if (cached && sameChangeMarker(cached.metadata, metadata)) {
|
|
396
|
+
return { metadata, observedAt, snapshot: { ...cached, metadata, validatedAt: observedAt, cacheStatus: "hit" } };
|
|
397
|
+
}
|
|
398
|
+
return { metadata, observedAt };
|
|
399
|
+
}
|
|
400
|
+
invalidateMemberSnapshot(library, sourceFile, member) {
|
|
401
|
+
const identityKey = `${String(library).trim().toUpperCase()}/${String(sourceFile).trim().toUpperCase()}/${String(member).trim().toUpperCase()}`;
|
|
402
|
+
this.snapshotGeneration.set(identityKey, (this.snapshotGeneration.get(identityKey) || 0) + 1);
|
|
403
|
+
for (const [key, entry] of this.snapshotCache) {
|
|
404
|
+
if (entry.identityKey === identityKey)
|
|
405
|
+
this.snapshotCache.delete(key);
|
|
241
406
|
}
|
|
242
|
-
|
|
243
|
-
|
|
407
|
+
this.snapshotLatestKey.delete(identityKey);
|
|
408
|
+
// Later readers must not coalesce onto a load that started before the change.
|
|
409
|
+
this.snapshotInflight.delete(identityKey);
|
|
410
|
+
}
|
|
411
|
+
peekCachedSnapshot(identityKey) {
|
|
412
|
+
const latestKey = this.snapshotLatestKey.get(identityKey);
|
|
413
|
+
if (!latestKey)
|
|
414
|
+
return undefined;
|
|
415
|
+
const entry = this.snapshotCache.get(latestKey);
|
|
416
|
+
if (!entry || entry.expiresAt <= Date.now()) {
|
|
417
|
+
this.snapshotCache.delete(latestKey);
|
|
418
|
+
this.snapshotLatestKey.delete(identityKey);
|
|
419
|
+
return undefined;
|
|
420
|
+
}
|
|
421
|
+
// Refresh LRU order.
|
|
422
|
+
this.snapshotCache.delete(latestKey);
|
|
423
|
+
this.snapshotCache.set(latestKey, entry);
|
|
424
|
+
return entry.snapshot;
|
|
425
|
+
}
|
|
426
|
+
cacheSnapshot(identityKey, snapshot) {
|
|
427
|
+
// Without an authoritative change marker a hit could never be validated.
|
|
428
|
+
if (!snapshot.metadata.lastChanged)
|
|
429
|
+
return;
|
|
430
|
+
for (const [key, entry] of this.snapshotCache) {
|
|
431
|
+
if (entry.identityKey === identityKey)
|
|
432
|
+
this.snapshotCache.delete(key);
|
|
433
|
+
}
|
|
434
|
+
const key = `${identityKey}/${snapshot.metadata.lastChanged}`;
|
|
435
|
+
this.snapshotCache.set(key, { snapshot, identityKey, expiresAt: Date.now() + this.snapshotCacheTtlMs });
|
|
436
|
+
this.snapshotLatestKey.set(identityKey, key);
|
|
437
|
+
while (this.snapshotCache.size > this.snapshotCacheMax) {
|
|
438
|
+
const oldest = this.snapshotCache.keys().next().value;
|
|
439
|
+
if (!oldest)
|
|
440
|
+
break;
|
|
441
|
+
const evicted = this.snapshotCache.get(oldest);
|
|
442
|
+
this.snapshotCache.delete(oldest);
|
|
443
|
+
if (evicted && this.snapshotLatestKey.get(evicted.identityKey) === oldest) {
|
|
444
|
+
this.snapshotLatestKey.delete(evicted.identityKey);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
async downloadMemberContentWithDates(library, sourceFile, member) {
|
|
449
|
+
const lib = this.requireQsysName(library, "library");
|
|
450
|
+
const file = this.requireQsysName(sourceFile, "sourceFile");
|
|
451
|
+
const mbr = this.requireQsysName(member, "member");
|
|
452
|
+
// Db2 for i requires an alias to address a non-default member. Keep the
|
|
453
|
+
// alias in QTEMP and create/read it in the same QZDFMDB2 job so no
|
|
454
|
+
// persistent ILEDITOR object or cross-job alias lookup is involved.
|
|
455
|
+
const alias = makeAliasName(`${lib}/${file}/${mbr}`);
|
|
456
|
+
const aliasPath = `QTEMP.\"${alias}\"`;
|
|
457
|
+
const recordLength = await this.getRecordLength(undefined, lib, file);
|
|
458
|
+
const rows = await this.ibmi.runSQL(`create or replace alias ${aliasPath} for \"${lib}\".\"${file}\"(\"${mbr}\");\n` +
|
|
459
|
+
`select case when locate('40',hex(srcdat)) > 0 then 0 else srcdat end as SRCDAT, srcseq, srcdta ` +
|
|
460
|
+
`from ${aliasPath} as M order by rrn(M)`);
|
|
244
461
|
if (!rows || rows.length === 0) {
|
|
245
462
|
return "";
|
|
246
463
|
}
|
|
@@ -254,18 +471,14 @@ export class IBMiContent {
|
|
|
254
471
|
const lib = this.requireQsysName(library, "library");
|
|
255
472
|
const file = this.requireQsysName(sourceFile, "sourceFile");
|
|
256
473
|
const mbr = this.requireQsysName(member, "member");
|
|
257
|
-
const tempLib = await this.getTempLibrary();
|
|
258
|
-
if (!tempLib) {
|
|
259
|
-
throw new Error(`Temp library is not available for source-date updates.`);
|
|
260
|
-
}
|
|
261
474
|
const alias = makeAliasName(`${lib}/${file}/${mbr}`);
|
|
262
|
-
const aliasPath =
|
|
475
|
+
const aliasPath = `QTEMP.\"${alias}\"`;
|
|
263
476
|
const baseKey = `${lib}/${file}/${mbr}`;
|
|
264
477
|
if (!this.sourceDates.getBaseSource(baseKey)) {
|
|
265
|
-
await this.
|
|
478
|
+
await this.loadMemberSnapshotViaSql(lib, file, mbr);
|
|
266
479
|
}
|
|
267
480
|
const sourceDates = this.sourceDates.calcNewSourceDates(baseKey, body);
|
|
268
|
-
const recordLength = this.sourceDates.getRecordLength(baseKey) || await this.getRecordLength(
|
|
481
|
+
const recordLength = this.sourceDates.getRecordLength(baseKey) || await this.getRecordLength(undefined, lib, file);
|
|
269
482
|
const sourceData = body.split(`\n`);
|
|
270
483
|
const decimalSequence = sourceData.length >= 10000;
|
|
271
484
|
const rows = [];
|
|
@@ -280,6 +493,7 @@ export class IBMiContent {
|
|
|
280
493
|
}
|
|
281
494
|
const tempTable = `QTEMP.NEWMEMBER`;
|
|
282
495
|
const statements = [
|
|
496
|
+
`CREATE OR REPLACE ALIAS ${aliasPath} FOR \"${lib}\".\"${file}\"(\"${mbr}\");`,
|
|
283
497
|
`CREATE TABLE ${tempTable} LIKE \"${lib}\".\"${file}\";`
|
|
284
498
|
];
|
|
285
499
|
const rowLength = recordLength + 55;
|
|
@@ -303,14 +517,16 @@ export class IBMiContent {
|
|
|
303
517
|
}
|
|
304
518
|
async getRecordLength(aliasPath, lib, file) {
|
|
305
519
|
let recordLength = 80;
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
520
|
+
if (aliasPath) {
|
|
521
|
+
try {
|
|
522
|
+
const res = await this.ibmi.runSQL(`select length(SRCDTA) as LENGTH from ${aliasPath} fetch first 1 rows only`);
|
|
523
|
+
if (res.length > 0 && res[0].LENGTH) {
|
|
524
|
+
recordLength = Number(res[0].LENGTH);
|
|
525
|
+
return recordLength;
|
|
526
|
+
}
|
|
311
527
|
}
|
|
528
|
+
catch { }
|
|
312
529
|
}
|
|
313
|
-
catch { }
|
|
314
530
|
try {
|
|
315
531
|
const res = await this.ibmi.runSQL(`select row_length-12 as LENGTH from QSYS2.SYSTABLES where SYSTEM_TABLE_SCHEMA = ${Tools.sqlString(lib)} and SYSTEM_TABLE_NAME = ${Tools.sqlString(file)} fetch first 1 rows only`);
|
|
316
532
|
if (res.length > 0 && res[0].LENGTH) {
|
|
@@ -320,16 +536,121 @@ export class IBMiContent {
|
|
|
320
536
|
catch { }
|
|
321
537
|
return recordLength;
|
|
322
538
|
}
|
|
539
|
+
async loadMemberSnapshot(lib, file, mbr) {
|
|
540
|
+
if (this.config.enableSourceDates) {
|
|
541
|
+
try {
|
|
542
|
+
return await this.loadMemberSnapshotViaSql(lib, file, mbr);
|
|
543
|
+
}
|
|
544
|
+
catch (error) {
|
|
545
|
+
return this.loadMemberSnapshotViaStreamFile(lib, file, mbr, error);
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
return this.loadMemberSnapshotViaStreamFile(lib, file, mbr);
|
|
549
|
+
}
|
|
550
|
+
async loadMemberSnapshotViaSql(lib, file, mbr) {
|
|
551
|
+
const alias = makeAliasName(`${lib}/${file}/${mbr}`);
|
|
552
|
+
const aliasPath = `QTEMP.\"${alias}\"`;
|
|
553
|
+
const rows = await this.ibmi.runSQL(`create or replace alias ${aliasPath} for \"${lib}\".\"${file}\"(\"${mbr}\");\n` +
|
|
554
|
+
`select case when locate('40',hex(M.srcdat)) > 0 then 0 else M.srcdat end as SRCDAT, M.srcseq, M.srcdta, ` +
|
|
555
|
+
`P.SYSTEM_TABLE_MEMBER, P.SOURCE_TYPE, P.PARTITION_TEXT, P.NUMBER_ROWS, P.LAST_SOURCE_UPDATE_TIMESTAMP, T.ROW_LENGTH ` +
|
|
556
|
+
`from ${aliasPath} as M cross join QSYS2.SYSPARTITIONSTAT P ` +
|
|
557
|
+
`left join QSYS2.SYSTABLES T on T.SYSTEM_TABLE_SCHEMA=P.SYSTEM_TABLE_SCHEMA and T.SYSTEM_TABLE_NAME=P.SYSTEM_TABLE_NAME ` +
|
|
558
|
+
`where P.SYSTEM_TABLE_SCHEMA=${Tools.sqlString(lib)} and P.SYSTEM_TABLE_NAME=${Tools.sqlString(file)} ` +
|
|
559
|
+
`and P.SYSTEM_TABLE_MEMBER=${Tools.sqlString(mbr)} order by rrn(M)`);
|
|
560
|
+
let metadata;
|
|
561
|
+
if (rows.length === 0) {
|
|
562
|
+
const emptyMetadata = await this.getMemberMetadata(lib, file, mbr);
|
|
563
|
+
if (!emptyMetadata)
|
|
564
|
+
throw new Error(`${mbr} in ${lib}/${file} not found`);
|
|
565
|
+
metadata = emptyMetadata;
|
|
566
|
+
}
|
|
567
|
+
else {
|
|
568
|
+
metadata = this.memberMetadataFromRow(lib, file, rows[0]);
|
|
569
|
+
}
|
|
570
|
+
const sourceDates = rows.map(row => String(row.SRCDAT).padStart(6, "0"));
|
|
571
|
+
const content = rows.map(row => String(row.SRCDTA ?? "")).join("\n");
|
|
572
|
+
const snapshot = this.makeMemberSnapshot(lib, file, mbr, content, metadata, "sql-member", []);
|
|
573
|
+
// The diff base must match what callers see and send back (trimmed lines),
|
|
574
|
+
// otherwise every padded line would look changed and lose its date.
|
|
575
|
+
this.sourceDates.setBase(`${lib}/${file}/${mbr}`, snapshot.content, sourceDates, metadata.recordLength || 80);
|
|
576
|
+
return snapshot;
|
|
577
|
+
}
|
|
578
|
+
async loadMemberSnapshotViaStreamFile(lib, file, mbr, sqlError) {
|
|
579
|
+
const metadataBefore = await this.getMemberMetadata(lib, file, mbr);
|
|
580
|
+
if (!metadataBefore)
|
|
581
|
+
throw new Error(`${mbr} in ${lib}/${file} not found`);
|
|
582
|
+
const content = await this.downloadMemberContentViaStreamFile(lib, file, mbr);
|
|
583
|
+
const metadataAfter = await this.getMemberMetadata(lib, file, mbr);
|
|
584
|
+
if (!metadataAfter)
|
|
585
|
+
throw new Error(`${mbr} in ${lib}/${file} was removed during read`);
|
|
586
|
+
if (metadataBefore.lastChanged && metadataAfter.lastChanged && metadataBefore.lastChanged !== metadataAfter.lastChanged) {
|
|
587
|
+
throw new Error(`Source changed during read: ${lib}/${file}(${mbr})`);
|
|
588
|
+
}
|
|
589
|
+
return this.makeMemberSnapshot(lib, file, mbr, content, metadataAfter, "cpytostmf", sqlError ? [`Source-date SQL read failed; used complete CPYTOSTMF fallback: ${sqlError?.message || String(sqlError)}`] : []);
|
|
590
|
+
}
|
|
591
|
+
makeMemberSnapshot(lib, file, mbr, rawContent, metadata, transport, warnings) {
|
|
592
|
+
const { content, lines } = normalizeMemberContent(rawContent, transport);
|
|
593
|
+
const sha256 = crypto.createHash("sha256").update(content, "utf8").digest("hex");
|
|
594
|
+
const marker = metadata.lastChanged || "unknown";
|
|
595
|
+
return {
|
|
596
|
+
identity: { library: lib, sourceFile: file, member: mbr },
|
|
597
|
+
content,
|
|
598
|
+
lines,
|
|
599
|
+
metadata,
|
|
600
|
+
sha256,
|
|
601
|
+
version: `${marker}:${sha256.slice(0, 16)}`,
|
|
602
|
+
observedAt: new Date().toISOString(),
|
|
603
|
+
complete: true,
|
|
604
|
+
transport,
|
|
605
|
+
warnings,
|
|
606
|
+
cacheStatus: "miss"
|
|
607
|
+
};
|
|
608
|
+
}
|
|
609
|
+
memberMetadataFromRow(lib, file, row) {
|
|
610
|
+
const rowLength = Number(row.ROW_LENGTH || 0);
|
|
611
|
+
return {
|
|
612
|
+
library: lib,
|
|
613
|
+
file: this.ibmi.upperCaseName(file),
|
|
614
|
+
name: String(row.SYSTEM_TABLE_MEMBER || ""),
|
|
615
|
+
extension: String(row.SOURCE_TYPE || ""),
|
|
616
|
+
text: String(row.PARTITION_TEXT || ""),
|
|
617
|
+
lines: Number(row.NUMBER_ROWS || 0),
|
|
618
|
+
rowCount: Number(row.NUMBER_ROWS || 0),
|
|
619
|
+
...(row.LAST_SOURCE_UPDATE_TIMESTAMP ? { lastChanged: String(row.LAST_SOURCE_UPDATE_TIMESTAMP) } : {}),
|
|
620
|
+
...(rowLength > 0 ? { recordLength: Math.max(0, rowLength - 12) } : {})
|
|
621
|
+
};
|
|
622
|
+
}
|
|
623
|
+
normalizeSearchValue(value) {
|
|
624
|
+
return String(value || "").trim().toUpperCase().replace(/[%_]/g, "");
|
|
625
|
+
}
|
|
626
|
+
encodeMemberCursor(member) {
|
|
627
|
+
return Buffer.from(JSON.stringify({ v: 1, member }), "utf8").toString("base64url");
|
|
628
|
+
}
|
|
629
|
+
decodeMemberCursor(cursor) {
|
|
630
|
+
try {
|
|
631
|
+
const parsed = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8"));
|
|
632
|
+
if (parsed?.v !== 1 || typeof parsed.member !== "string")
|
|
633
|
+
throw new Error("bad cursor");
|
|
634
|
+
return this.requireQsysName(parsed.member, "cursor member");
|
|
635
|
+
}
|
|
636
|
+
catch {
|
|
637
|
+
throw new Error("Invalid member-list cursor");
|
|
638
|
+
}
|
|
639
|
+
}
|
|
323
640
|
async createLibrary(library) {
|
|
324
641
|
this.assertWritable();
|
|
325
642
|
const lib = this.requireQsysName(library, "library");
|
|
326
|
-
return this.
|
|
643
|
+
return this.runClViaQsh(`CRTLIB LIB(${lib})`, "CRTLIB");
|
|
327
644
|
}
|
|
328
645
|
async createSourceFile(library, file, rcdlen = 112) {
|
|
329
646
|
this.assertWritable();
|
|
330
647
|
const lib = this.requireQsysName(library, "library");
|
|
331
648
|
const src = this.requireQsysName(file, "sourceFile");
|
|
332
|
-
|
|
649
|
+
const length = Number(rcdlen);
|
|
650
|
+
if (!Number.isInteger(length) || length < 13 || length > 32766) {
|
|
651
|
+
throw new Error(`Invalid rcdlen: ${rcdlen}`);
|
|
652
|
+
}
|
|
653
|
+
return this.runClViaQsh(`CRTSRCPF FILE(${lib}/${src}) RCDLEN(${length})`, "CRTSRCPF");
|
|
333
654
|
}
|
|
334
655
|
async createMember(library, file, member, srctype) {
|
|
335
656
|
this.assertWritable();
|
|
@@ -337,7 +658,12 @@ export class IBMiContent {
|
|
|
337
658
|
const src = this.requireQsysName(file, "sourceFile");
|
|
338
659
|
const mbr = this.requireQsysName(member, "member");
|
|
339
660
|
const type = this.normalizeSourceType(srctype);
|
|
340
|
-
|
|
661
|
+
try {
|
|
662
|
+
return await this.runClViaQsh(`ADDPFM FILE(${lib}/${src}) MBR(${mbr}) SRCTYPE(${type})`, "ADDPFM");
|
|
663
|
+
}
|
|
664
|
+
finally {
|
|
665
|
+
this.invalidateMemberSnapshot(lib, src, mbr);
|
|
666
|
+
}
|
|
341
667
|
}
|
|
342
668
|
async renameMember(library, file, member, newMember) {
|
|
343
669
|
this.assertWritable();
|
|
@@ -345,14 +671,37 @@ export class IBMiContent {
|
|
|
345
671
|
const src = this.requireQsysName(file, "sourceFile");
|
|
346
672
|
const mbr = this.requireQsysName(member, "member");
|
|
347
673
|
const newMbr = this.requireQsysName(newMember, "newMember");
|
|
348
|
-
|
|
674
|
+
try {
|
|
675
|
+
return await this.runClViaQsh(`RNMM FILE(${lib}/${src}) MBR(${mbr}) NEWMBR(${newMbr})`, "RNMM");
|
|
676
|
+
}
|
|
677
|
+
finally {
|
|
678
|
+
this.invalidateMemberSnapshot(lib, src, mbr);
|
|
679
|
+
this.invalidateMemberSnapshot(lib, src, newMbr);
|
|
680
|
+
}
|
|
349
681
|
}
|
|
350
682
|
async deleteMember(library, file, member) {
|
|
351
683
|
this.assertWritable();
|
|
352
684
|
const lib = this.requireQsysName(library, "library");
|
|
353
685
|
const src = this.requireQsysName(file, "sourceFile");
|
|
354
686
|
const mbr = this.requireQsysName(member, "member");
|
|
355
|
-
|
|
687
|
+
try {
|
|
688
|
+
return await this.runClViaQsh(`RMVM FILE(${lib}/${src}) MBR(${mbr})`, "RMVM");
|
|
689
|
+
}
|
|
690
|
+
finally {
|
|
691
|
+
this.invalidateMemberSnapshot(lib, src, mbr);
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
// Runs a CL command through qsh's `system` utility and throws when it fails.
|
|
695
|
+
// `system` exits non-zero when the command ends with an escape message.
|
|
696
|
+
// '$' is escaped because names such as $ABC would otherwise be expanded by
|
|
697
|
+
// the shell inside the double-quoted argument.
|
|
698
|
+
async runClViaQsh(command, label) {
|
|
699
|
+
const result = await this.ibmi.sendQsh({ command: `system \"${IBMiClient.escapeForShell(command)}\"` });
|
|
700
|
+
if (!result || result.code !== 0) {
|
|
701
|
+
const detail = String(result?.stderr || result?.stdout || "").trim();
|
|
702
|
+
throw new Error(`${label} failed (exit ${result?.code ?? "unknown"})${detail ? `: ${detail}` : ""}`);
|
|
703
|
+
}
|
|
704
|
+
return result;
|
|
356
705
|
}
|
|
357
706
|
async createTempFile() {
|
|
358
707
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "mcpfori-"));
|
|
@@ -386,6 +735,29 @@ export class IBMiContent {
|
|
|
386
735
|
}
|
|
387
736
|
}
|
|
388
737
|
}
|
|
738
|
+
/**
|
|
739
|
+
* Normalizes member text so both read transports produce the same content,
|
|
740
|
+
* SHA-256 and version: line endings become LF, trailing blanks are removed
|
|
741
|
+
* from every line (the SQL path returns CHAR-padded SRCDTA, CPYTOSTMF output
|
|
742
|
+
* is trimmed) and the record terminator CPYTOSTMF writes after the last
|
|
743
|
+
* record is dropped.
|
|
744
|
+
*/
|
|
745
|
+
export function normalizeMemberContent(rawContent, transport) {
|
|
746
|
+
let text = String(rawContent ?? "").replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
747
|
+
if (transport === "cpytostmf" && text.endsWith("\n"))
|
|
748
|
+
text = text.slice(0, -1);
|
|
749
|
+
const lines = text === "" ? [] : text.split("\n").map(line => line.trimEnd());
|
|
750
|
+
return { content: lines.join("\n"), lines };
|
|
751
|
+
}
|
|
752
|
+
function sameChangeMarker(cached, current) {
|
|
753
|
+
if (!cached.lastChanged || !current.lastChanged)
|
|
754
|
+
return false;
|
|
755
|
+
if (cached.lastChanged !== current.lastChanged)
|
|
756
|
+
return false;
|
|
757
|
+
if (typeof cached.rowCount === "number" && typeof current.rowCount === "number" && cached.rowCount !== current.rowCount)
|
|
758
|
+
return false;
|
|
759
|
+
return true;
|
|
760
|
+
}
|
|
389
761
|
function escapeString(val) {
|
|
390
762
|
return val.replace(/[\0\n\r\b\t'\x1a]/g, function (s) {
|
|
391
763
|
switch (s) {
|