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.
Files changed (67) hide show
  1. package/README.md +17 -0
  2. package/dist/config/store.js +229 -109
  3. package/dist/controlplane/auth.js +128 -0
  4. package/dist/controlplane/clientIntegrationService.js +83 -4
  5. package/dist/controlplane/commands.js +14 -9
  6. package/dist/controlplane/connectionService.js +110 -5
  7. package/dist/controlplane/connectionTest.js +329 -0
  8. package/dist/controlplane/errors.js +12 -0
  9. package/dist/controlplane/runtimeService.js +98 -26
  10. package/dist/controlplane/server.js +122 -11
  11. package/dist/controlplane/serverShared.js +25 -6
  12. package/dist/controlplane/serviceManager.js +113 -100
  13. package/dist/controlplane/ui.js +1742 -920
  14. package/dist/ibmi/CompileTools.js +29 -1
  15. package/dist/ibmi/IBMiContent.js +435 -63
  16. package/dist/ibmi/IbmDocs.js +685 -0
  17. package/dist/ibmi/LocalLanguageActions.js +129 -3
  18. package/dist/ibmi/Search.js +158 -52
  19. package/dist/ibmi/Tools.js +7 -10
  20. package/dist/ibmi/client.js +220 -31
  21. package/dist/ibmi/evfevent.js +256 -0
  22. package/dist/ibmi/knownHosts.js +190 -0
  23. package/dist/ibmi/libraryList.js +93 -0
  24. package/dist/ibmi/sourceInspect.js +815 -0
  25. package/dist/ibmi/sqlJob.js +888 -0
  26. package/dist/ibmi/sqlParse.js +317 -0
  27. package/dist/ibmi/tn5250/certTrust.js +59 -0
  28. package/dist/ibmi/tn5250/client.js +310 -0
  29. package/dist/ibmi/tn5250/datastream.js +109 -0
  30. package/dist/ibmi/tn5250/ebcdic.js +74 -0
  31. package/dist/ibmi/tn5250/index.js +6 -0
  32. package/dist/ibmi/tn5250/screen.js +827 -0
  33. package/dist/ibmi/tn5250/telnet.js +186 -0
  34. package/dist/ibmi/variables.js +18 -7
  35. package/dist/index.js +43 -22
  36. package/dist/mcp/annotations.js +155 -0
  37. package/dist/mcp/audit.js +490 -133
  38. package/dist/mcp/confirm.js +48 -0
  39. package/dist/mcp/context.js +43 -5
  40. package/dist/mcp/deployMethods.js +192 -0
  41. package/dist/mcp/logger.js +5 -10
  42. package/dist/mcp/modules/bulkMembers.js +476 -0
  43. package/dist/mcp/modules/changedLines.js +189 -0
  44. package/dist/mcp/modules/compare.js +198 -0
  45. package/dist/mcp/modules/compile.js +515 -0
  46. package/dist/mcp/modules/ifsOps.js +123 -0
  47. package/dist/mcp/modules/index.js +38 -0
  48. package/dist/mcp/modules/inspect.js +811 -0
  49. package/dist/mcp/modules/libl.js +246 -0
  50. package/dist/mcp/modules/members.js +171 -0
  51. package/dist/mcp/modules/objects.js +339 -0
  52. package/dist/mcp/modules/system.js +266 -0
  53. package/dist/mcp/modules/tn5250.js +515 -0
  54. package/dist/mcp/policy.js +412 -0
  55. package/dist/mcp/tools.js +1700 -698
  56. package/dist/security/credentialStore.js +62 -13
  57. package/dist/security/localPaths.js +138 -0
  58. package/dist/security/redact.js +79 -0
  59. package/dist/util/atomicWrite.js +101 -0
  60. package/dist/util/fileLock.js +206 -0
  61. package/docs/capabilities.md +50 -7
  62. package/docs/configuration.md +189 -60
  63. package/docs/examples.md +109 -73
  64. package/docs/overview.md +15 -15
  65. package/docs/security.md +101 -16
  66. package/docs/tools.md +363 -188
  67. package/package.json +56 -53
@@ -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
- const withDates = await this.downloadMemberContentWithDates(library, sourceFile, member);
86
- if (withDates !== undefined)
87
- return withDates;
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
- const result = await this.ibmi.sendQsh({ command: `system \"${IBMiClient.escapeForShell(cmd)}\"` });
97
- if (result.code !== 0)
98
- throw new Error(result.stderr || "CPYTOSTMF failed");
99
- const buffer = await this.downloadStreamfileRaw(tempRemote);
100
- await this.ibmi.sendCommand({ command: `rm -f ${Tools.escapePath(tempRemote)}` });
101
- return Buffer.from(buffer).toString("utf8");
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 libFilter = filter === "*" ? "*ALL" : filter;
133
- const rows = await this.ibmi.runSQL(`select OBJNAME, OBJTEXT from table(QSYS2.OBJECT_STATISTICS(${Tools.sqlString(libFilter)}, 'LIB', '*ALLSIMPLE'))`);
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 downloadMemberContentWithDates(library, sourceFile, member) {
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 tempLib = await this.getTempLibrary();
232
- if (!tempLib)
233
- return undefined;
234
- const alias = makeAliasName(`${lib}/${file}/${mbr}`);
235
- const aliasPath = `${tempLib}.${alias}`;
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
- await this.ibmi.runSQL(`CREATE OR REPLACE ALIAS ${aliasPath} for \"${lib}\".\"${file}\"(\"${mbr}\")`);
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
- catch {
240
- // ignore, alias might already exist
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
- const recordLength = await this.getRecordLength(aliasPath, lib, file);
243
- const rows = await this.ibmi.runSQL(`select case when locate('40',hex(srcdat)) > 0 then 0 else srcdat end as SRCDAT, srcseq, srcdta from ${aliasPath}`);
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 = `${tempLib}.${alias}`;
475
+ const aliasPath = `QTEMP.\"${alias}\"`;
263
476
  const baseKey = `${lib}/${file}/${mbr}`;
264
477
  if (!this.sourceDates.getBaseSource(baseKey)) {
265
- await this.downloadMemberContentWithDates(lib, file, mbr);
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(aliasPath, lib, file);
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
- try {
307
- const res = await this.ibmi.runSQL(`select length(SRCDTA) as LENGTH from ${aliasPath} fetch first 1 rows only`);
308
- if (res.length > 0 && res[0].LENGTH) {
309
- recordLength = Number(res[0].LENGTH);
310
- return recordLength;
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.ibmi.sendQsh({ command: `system \"CRTLIB LIB(${lib})\"` });
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
- return this.ibmi.sendQsh({ command: `system \"CRTSRCPF FILE(${lib}/${src}) RCDLEN(${rcdlen})\"` });
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
- return this.ibmi.sendQsh({ command: `system \"ADDPFM FILE(${lib}/${src}) MBR(${mbr}) SRCTYPE(${type})\"` });
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
- return this.ibmi.sendQsh({ command: `system \"RNMM FILE(${lib}/${src}) MBR(${mbr}) NEWMBR(${newMbr})\"` });
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
- return this.ibmi.sendQsh({ command: `system \"RMVM FILE(${lib}/${src}) MBR(${mbr})\"` });
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) {