@ffschrattenecker/tm1-mcp-server 7.0.1 → 8.0.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 (116) hide show
  1. package/CHANGELOG.md +102 -1
  2. package/README.md +5 -5
  3. package/dist/config.js +34 -17
  4. package/dist/connections.d.ts +18 -1
  5. package/dist/connections.js +34 -18
  6. package/dist/http-transport.js +51 -4
  7. package/dist/lib/callgraph/referenceIndex.js +2 -1
  8. package/dist/lib/callgraph/rulesLinter.d.ts +0 -19
  9. package/dist/lib/callgraph/rulesLinter.js +0 -590
  10. package/dist/lib/callgraph/tiParser.js +10 -5
  11. package/dist/lib/callgraph/tm1-adapter.d.ts +5 -1
  12. package/dist/lib/callgraph/tm1-adapter.js +41 -9
  13. package/dist/lib/callgraph/variableEnv.js +3 -2
  14. package/dist/lib/cell-address.d.ts +11 -1
  15. package/dist/lib/cell-address.js +22 -3
  16. package/dist/lib/complexity/antipatterns.js +3 -1
  17. package/dist/lib/complexity/comment-classifier.js +2 -1
  18. package/dist/lib/coordinate-error.js +5 -1
  19. package/dist/lib/feeders/element-type-cache.js +4 -6
  20. package/dist/lib/naming/odata-filter.js +2 -1
  21. package/dist/lib/pro-parser.js +13 -4
  22. package/dist/lib/safe-regex.d.ts +0 -13
  23. package/dist/lib/safe-regex.js +40 -13
  24. package/dist/lib/sample-cells.d.ts +6 -0
  25. package/dist/lib/sample-cells.js +20 -2
  26. package/dist/lib/ti-identifier.d.ts +11 -0
  27. package/dist/lib/ti-identifier.js +11 -0
  28. package/dist/lib/tm1-name.d.ts +9 -0
  29. package/dist/lib/tm1-name.js +9 -0
  30. package/dist/lib/v12-compat/deprecated-ti.js +2 -2
  31. package/dist/session-manager.d.ts +1 -0
  32. package/dist/session-manager.js +24 -0
  33. package/dist/tm1-client/connection/profile.js +2 -4
  34. package/dist/tm1-client/dispatcher.d.ts +1 -1
  35. package/dist/tm1-client/dispatcher.js +18 -6
  36. package/dist/tm1-client/http.d.ts +21 -0
  37. package/dist/tm1-client/http.js +39 -30
  38. package/dist/tm1-client/services/cell-service.d.ts +9 -7
  39. package/dist/tm1-client/services/cell-service.js +54 -25
  40. package/dist/tm1-client/services/chore-service.js +8 -9
  41. package/dist/tm1-client/services/cube-service.d.ts +14 -18
  42. package/dist/tm1-client/services/cube-service.js +51 -45
  43. package/dist/tm1-client/services/dimension-order.d.ts +13 -0
  44. package/dist/tm1-client/services/dimension-order.js +42 -0
  45. package/dist/tm1-client/services/dimension-service.js +4 -6
  46. package/dist/tm1-client/services/element-service.d.ts +35 -11
  47. package/dist/tm1-client/services/element-service.js +124 -60
  48. package/dist/tm1-client/services/file-service.d.ts +42 -6
  49. package/dist/tm1-client/services/file-service.js +225 -28
  50. package/dist/tm1-client/services/hierarchy-service.d.ts +19 -9
  51. package/dist/tm1-client/services/hierarchy-service.js +118 -12
  52. package/dist/tm1-client/services/monitoring-service.js +2 -5
  53. package/dist/tm1-client/services/odata-page.d.ts +6 -0
  54. package/dist/tm1-client/services/odata-page.js +8 -0
  55. package/dist/tm1-client/services/process-service.d.ts +1 -1
  56. package/dist/tm1-client/services/process-service.js +26 -26
  57. package/dist/tm1-client/services/security-service.js +8 -9
  58. package/dist/tm1-client/services/server-service.js +14 -15
  59. package/dist/tm1-client/services/subset-service.d.ts +23 -10
  60. package/dist/tm1-client/services/subset-service.js +82 -26
  61. package/dist/tm1-client/services/view-service.js +12 -13
  62. package/dist/tm1-client.js +5 -2
  63. package/dist/tools/analysis/check-v12-readiness.js +3 -3
  64. package/dist/tools/celldata/check-feeders.js +6 -6
  65. package/dist/tools/celldata/check-writable-coords.js +43 -25
  66. package/dist/tools/celldata/execute-mdx.js +19 -23
  67. package/dist/tools/celldata/get-cell-value.js +1 -1
  68. package/dist/tools/celldata/get-view.js +10 -9
  69. package/dist/tools/celldata/member-ref.d.ts +15 -0
  70. package/dist/tools/celldata/member-ref.js +67 -0
  71. package/dist/tools/celldata/sample-cells.js +3 -0
  72. package/dist/tools/celldata/trace-cell-calculation.js +6 -6
  73. package/dist/tools/celldata/trace-feeders.js +6 -6
  74. package/dist/tools/celldata/write-cells.js +62 -12
  75. package/dist/tools/dimension-management/create-element-attribute.js +1 -1
  76. package/dist/tools/dimension-management/delete-hierarchy.js +3 -4
  77. package/dist/tools/dimension-management/get-element-attribute-values.js +7 -2
  78. package/dist/tools/dimension-management/list-element-attributes.js +1 -1
  79. package/dist/tools/dimension-management/update-element-attribute-value.js +8 -3
  80. package/dist/tools/dimension-management/update-element.js +14 -7
  81. package/dist/tools/fileops/container.d.ts +8 -0
  82. package/dist/tools/fileops/container.js +11 -0
  83. package/dist/tools/fileops/delete-file.js +4 -2
  84. package/dist/tools/fileops/get-file-content.js +45 -7
  85. package/dist/tools/fileops/list-files.js +5 -2
  86. package/dist/tools/fileops/search-files.js +4 -1
  87. package/dist/tools/fileops/upload-file.js +5 -2
  88. package/dist/tools/format.d.ts +1 -0
  89. package/dist/tools/format.js +7 -2
  90. package/dist/tools/index.js +0 -2
  91. package/dist/tools/model-building/check-cube-rule.js +3 -2
  92. package/dist/tools/model-building/clear-cube.js +35 -26
  93. package/dist/tools/model-building/set-cube-rules.js +13 -20
  94. package/dist/tools/model-building/unload-cube.js +1 -1
  95. package/dist/tools/operations/get-cube-stats.js +1 -1
  96. package/dist/tools/operations/get-transaction-log.js +1 -1
  97. package/dist/tools/operations/list-error-logs.js +27 -2
  98. package/dist/tools/schemas/items-fileops.d.ts +4 -0
  99. package/dist/tools/schemas/items-fileops.js +1 -0
  100. package/dist/tools/security/list-clients.js +5 -5
  101. package/dist/tools/security/list-groups.js +1 -1
  102. package/dist/tools/subsets/create-subset.js +9 -5
  103. package/dist/tools/subsets/delete-subset.js +8 -3
  104. package/dist/tools/subsets/update-subset.js +9 -9
  105. package/dist/tools/ti-development/diff-process-with-file.js +4 -29
  106. package/dist/tools/ti-development/diff-processes.js +9 -0
  107. package/dist/tools/ti-development/export-process-to-git.js +9 -7
  108. package/dist/tools/ti-development/export-process-to-pro.js +15 -8
  109. package/dist/tools/ti-development/import-pro-file.js +10 -21
  110. package/dist/tools/ti-development/import-process-from-git.js +8 -18
  111. package/dist/tools/ti-development/install-pro-bundle.js +2 -2
  112. package/dist/tools/ti-development/upsert-process.js +26 -5
  113. package/npm-shrinkwrap.json +465 -500
  114. package/package.json +14 -10
  115. package/dist/tools/dimension-management/move-element.d.ts +0 -2
  116. package/dist/tools/dimension-management/move-element.js +0 -28
@@ -1,9 +1,6 @@
1
1
  import { TM1Error, TM1ErrorCode } from "../../types.js";
2
2
  import { rethrowIfSystemic } from "./fallback.js";
3
- // OData entity-key encoder: double single quotes per OData literal rules, then
4
- // percent-encode. Without the doubling a name containing ' breaks the key and
5
- // makes the object unreachable.
6
- const enc = (s) => encodeURIComponent(String(s).replace(/'/g, "''"));
3
+ import { escapeOdataLiteral, odataKey } from "./odata-page.js";
7
4
  // Split a user-supplied file path into segments, rejecting "." / ".." so a
8
5
  // crafted name cannot traverse outside the Contents root.
9
6
  function splitPath(raw) {
@@ -17,23 +14,112 @@ function splitPath(raw) {
17
14
  }
18
15
  return parts;
19
16
  }
17
+ const APPS_ROOT = "/api/v1/Contents('Applications')";
18
+ const DOCUMENT_REFERENCE = "ibm.tm1.api.v1.DocumentReference";
20
19
  export class FileService {
21
20
  http;
22
21
  constructor(http) {
23
22
  this.http = http;
24
23
  }
24
+ /**
25
+ * Entries directly under an Applications URL.
26
+ *
27
+ * Follows `@odata.nextLink` if the server sends one. Whether these builds
28
+ * page this endpoint at all was not observed; a missed page here would not
29
+ * just shorten a listing, it would make name resolution answer NOT_FOUND for
30
+ * an entry that exists, so the loop runs either way.
31
+ */
32
+ async appsChildren(url) {
33
+ const entries = [];
34
+ // v12 leaves `ID` out of this listing unless it is selected (v11 always
35
+ // sends it); without it every entry resolved to Contents('undefined').
36
+ let next = `${url}/Contents?$select=ID,Name`;
37
+ while (next) {
38
+ const r = await this.http.request("GET", next);
39
+ for (const e of r.value) {
40
+ entries.push({
41
+ id: e.ID,
42
+ name: e.Name,
43
+ kind: e["@odata.type"].split(".").pop() ?? "",
44
+ });
45
+ }
46
+ const link = r["@odata.nextLink"];
47
+ // A nextLink may be absolute. Reduce it to a path: `request` prepends the
48
+ // base URL. Its v12 rerooting only rewrites a leading `/api/v1`, which a
49
+ // server-built link no longer carries, so it passes through untouched.
50
+ next = link?.startsWith("http")
51
+ ? new URL(link).pathname + new URL(link).search
52
+ : link;
53
+ }
54
+ return entries;
55
+ }
56
+ /**
57
+ * Walk name segments down the Applications tree, one listing per level.
58
+ *
59
+ * Costs a request per segment, which the depth of this tree makes cheap, and
60
+ * buys two things a derived key cannot: the entry's real ID whatever the
61
+ * server's naming rule is, and its type — so a caller asking for the bytes of
62
+ * a ViewReference gets told what it actually hit.
63
+ */
64
+ async appsResolve(segments) {
65
+ let url = APPS_ROOT;
66
+ let entry;
67
+ for (const seg of segments) {
68
+ const children = await this.appsChildren(url);
69
+ const lower = seg.toLowerCase();
70
+ const hit = children.find((c) => c.name.toLowerCase() === lower) ??
71
+ // A listing hands documents back under their name, but an ID pasted
72
+ // straight from a previous response has to keep working too.
73
+ children.find((c) => c.id.toLowerCase() === lower);
74
+ if (!hit) {
75
+ throw new TM1Error({
76
+ code: TM1ErrorCode.NOT_FOUND,
77
+ message: `'${seg}' not found in the Applications tree`,
78
+ endpoint: url,
79
+ });
80
+ }
81
+ url += `/Contents('${odataKey(hit.id)}')`;
82
+ entry = hit;
83
+ }
84
+ return { url, entry };
85
+ }
86
+ /** URL of the bytes behind a DocumentReference, or a typed refusal. */
87
+ appsContentUrl(url, entry) {
88
+ if (entry === undefined) {
89
+ throw new TM1Error({
90
+ code: TM1ErrorCode.VALIDATION_ERROR,
91
+ message: "The Applications root is not a file",
92
+ endpoint: url,
93
+ });
94
+ }
95
+ if (entry.kind !== "DocumentReference") {
96
+ throw new TM1Error({
97
+ code: TM1ErrorCode.UNSUPPORTED_OPERATION,
98
+ message: `'${entry.name}' is a ${entry.kind}, which carries no file content`,
99
+ hint: entry.kind === "Folder"
100
+ ? "List it instead — tm1_list_files with container='applications' and this path."
101
+ : "Only documents hold bytes. A ViewReference points at a cube view; read it with tm1_get_view.",
102
+ endpoint: url,
103
+ });
104
+ }
105
+ return `${url}/${DOCUMENT_REFERENCE}/Document/Content`;
106
+ }
25
107
  /**
26
108
  * List files in TM1 server's blob/file storage.
27
109
  * v12: GET /api/v1/Contents('Files')[/Contents('subdir')...]/Contents?$select=Name
28
110
  * v11: same with 'Blobs' instead of 'Files'.
29
111
  * Tries v12 'Files' first, falls back to v11 'Blobs'.
30
112
  */
31
- async list(path) {
113
+ async list(path, container = "files") {
32
114
  const segments = path ? splitPath(path) : [];
115
+ if (container === "applications") {
116
+ const { url } = await this.appsResolve(segments);
117
+ return (await this.appsChildren(url)).map((e) => e.name);
118
+ }
33
119
  const buildUrl = (root) => {
34
- let url = `/api/v1/Contents('${enc(root)}')`;
120
+ let url = `/api/v1/Contents('${odataKey(root)}')`;
35
121
  for (const seg of segments) {
36
- url += `/Contents('${enc(seg)}')`;
122
+ url += `/Contents('${odataKey(seg)}')`;
37
123
  }
38
124
  url += "/Contents?$select=Name";
39
125
  return url;
@@ -53,22 +139,33 @@ export class FileService {
53
139
  * Returns raw text (CSV/TXT/etc).
54
140
  * Tries v12 'Files' first, falls back to v11 'Blobs'.
55
141
  */
56
- async getContent(fileName) {
142
+ async getContent(fileName, container = "files") {
143
+ return (await this.getContentBytes(fileName, container)).toString("utf8");
144
+ }
145
+ /**
146
+ * The same read, byte-for-byte. The Applications tree holds spreadsheets and
147
+ * other binaries, which a UTF-8 decode would quietly destroy.
148
+ */
149
+ async getContentBytes(fileName, container = "files") {
57
150
  const parts = splitPath(fileName);
151
+ if (container === "applications") {
152
+ const { url, entry } = await this.appsResolve(parts);
153
+ return this.http.requestRawBytes("GET", this.appsContentUrl(url, entry));
154
+ }
58
155
  const buildUrl = (root) => {
59
- let url = `/api/v1/Contents('${enc(root)}')`;
156
+ let url = `/api/v1/Contents('${odataKey(root)}')`;
60
157
  for (const p of parts) {
61
- url += `/Contents('${enc(p)}')`;
158
+ url += `/Contents('${odataKey(p)}')`;
62
159
  }
63
160
  url += "/Content";
64
161
  return url;
65
162
  };
66
163
  try {
67
- return await this.http.requestRaw("GET", buildUrl("Files"));
164
+ return await this.http.requestRawBytes("GET", buildUrl("Files"));
68
165
  }
69
166
  catch (e) {
70
167
  rethrowIfSystemic(e);
71
- return await this.http.requestRaw("GET", buildUrl("Blobs"));
168
+ return await this.http.requestRawBytes("GET", buildUrl("Blobs"));
72
169
  }
73
170
  }
74
171
  /**
@@ -76,18 +173,28 @@ export class FileService {
76
173
  * Implemented as a cheap GET on the entity ($select=Name) — TM1 REST does
77
174
  * not expose HEAD on these. 404 → false; other errors propagate.
78
175
  */
79
- async exists(fileName) {
176
+ async exists(fileName, container = "files") {
80
177
  const parts = splitPath(fileName);
81
178
  if (parts.length === 0)
82
179
  return false;
180
+ if (container === "applications") {
181
+ try {
182
+ return (await this.appsResolve(parts)).entry !== undefined;
183
+ }
184
+ catch (e) {
185
+ if (e.code === "NOT_FOUND")
186
+ return false;
187
+ throw e;
188
+ }
189
+ }
83
190
  const buildUrl = (root) => {
84
191
  const segs = parts
85
192
  .slice(0, -1)
86
- .map((s) => `/Contents('${enc(s)}')`)
193
+ .map((s) => `/Contents('${odataKey(s)}')`)
87
194
  .join("");
88
195
  // parts.length > 0 is guarded above
89
196
  const last = parts[parts.length - 1];
90
- return `/api/v1/Contents('${enc(root)}')${segs}/Contents('${enc(last)}')?$select=Name`;
197
+ return `/api/v1/Contents('${odataKey(root)}')${segs}/Contents('${odataKey(last)}')?$select=Name`;
91
198
  };
92
199
  const probe = async (url) => {
93
200
  try {
@@ -114,7 +221,7 @@ export class FileService {
114
221
  * Subfolders only supported on TM1 v12. Caller must ensure parent folders
115
222
  * exist (folder-create not yet exposed).
116
223
  */
117
- async upload(fileName, content) {
224
+ async upload(fileName, content, container = "files") {
118
225
  const parts = splitPath(fileName);
119
226
  if (parts.length === 0) {
120
227
  throw new TM1Error({
@@ -122,15 +229,17 @@ export class FileService {
122
229
  message: "upload: empty file name",
123
230
  });
124
231
  }
232
+ if (container === "applications")
233
+ return this.uploadToApplications(parts, content);
125
234
  // parts.length > 0 is guarded above
126
235
  const leaf = parts[parts.length - 1];
127
236
  const parentSegs = parts
128
237
  .slice(0, -1)
129
- .map((s) => `/Contents('${enc(s)}')`)
238
+ .map((s) => `/Contents('${odataKey(s)}')`)
130
239
  .join("");
131
240
  const tryRoot = async (root) => {
132
- const parentUrl = `/api/v1/Contents('${enc(root)}')${parentSegs}/Contents`;
133
- const contentUrl = `/api/v1/Contents('${enc(root)}')${parentSegs}/Contents('${enc(leaf)}')/Content`;
241
+ const parentUrl = `/api/v1/Contents('${odataKey(root)}')${parentSegs}/Contents`;
242
+ const contentUrl = `/api/v1/Contents('${odataKey(root)}')${parentSegs}/Contents('${odataKey(leaf)}')/Content`;
134
243
  const existed = await this.exists(fileName).catch(() => false);
135
244
  if (!existed) {
136
245
  await this.http.request("POST", parentUrl, {
@@ -139,7 +248,19 @@ export class FileService {
139
248
  Name: leaf,
140
249
  });
141
250
  }
142
- await this.http.requestBinary("PUT", contentUrl, content);
251
+ try {
252
+ await this.http.requestBinary("PUT", contentUrl, content);
253
+ }
254
+ catch (e) {
255
+ // Same two-request split as the Applications path: an entry this call
256
+ // created and could not fill is removed again, so a failed upload does
257
+ // not leave an empty file under the name.
258
+ if (!existed) {
259
+ const url = `/api/v1/Contents('${odataKey(root)}')${parentSegs}/Contents('${odataKey(leaf)}')`;
260
+ await this.http.request("DELETE", url).catch(() => undefined);
261
+ }
262
+ throw e;
263
+ }
143
264
  return { created: !existed, root };
144
265
  };
145
266
  try {
@@ -155,7 +276,7 @@ export class FileService {
155
276
  /**
156
277
  * Delete a file from blob/file storage. Tries 'Files' first, then 'Blobs'.
157
278
  */
158
- async delete(fileName) {
279
+ async delete(fileName, container = "files") {
159
280
  const parts = splitPath(fileName);
160
281
  if (parts.length === 0) {
161
282
  throw new TM1Error({
@@ -163,9 +284,28 @@ export class FileService {
163
284
  message: "delete: empty file name",
164
285
  });
165
286
  }
287
+ if (container === "applications") {
288
+ const { url, entry } = await this.appsResolve(parts);
289
+ // DELETE on a folder takes everything under it with it (see the header
290
+ // note). This is the single-file contract, so anything that is not a
291
+ // document is refused here rather than at the server, where it would
292
+ // already be gone.
293
+ if (entry !== undefined && entry.kind !== "DocumentReference") {
294
+ throw new TM1Error({
295
+ code: TM1ErrorCode.UNSUPPORTED_OPERATION,
296
+ message: `'${entry.name}' is a ${entry.kind}, not a file — refusing to delete it`,
297
+ hint: entry.kind === "Folder"
298
+ ? "Deleting a folder would delete everything inside it, which this tool does not do. Delete the entries individually, or remove the folder in Architect/PAW."
299
+ : "Only documents can be deleted here. A ViewReference points at a cube view; remove it with tm1_delete_view.",
300
+ endpoint: url,
301
+ });
302
+ }
303
+ await this.http.request("DELETE", url);
304
+ return;
305
+ }
166
306
  const buildUrl = (root) => {
167
- const segs = parts.map((s) => `/Contents('${enc(s)}')`).join("");
168
- return `/api/v1/Contents('${enc(root)}')${segs}`;
307
+ const segs = parts.map((s) => `/Contents('${odataKey(s)}')`).join("");
308
+ return `/api/v1/Contents('${odataKey(root)}')${segs}`;
169
309
  };
170
310
  try {
171
311
  await this.http.request("DELETE", buildUrl("Files"));
@@ -185,22 +325,39 @@ export class FileService {
185
325
  async search(opts) {
186
326
  const operator = opts.operator ?? "and";
187
327
  const segments = opts.path ? splitPath(opts.path) : [];
188
- const escape = (s) => s.replace(/'/g, "''");
328
+ if (opts.container === "applications") {
329
+ // Filtered client-side: the $filter push-down is measured for Blobs, not
330
+ // for this tree, and an Applications folder holds tens of entries, not
331
+ // thousands. Same matching rules as the server-side clauses below.
332
+ const names = await this.list(opts.path, "applications");
333
+ const starts = opts.startswith?.toLowerCase();
334
+ const subs = (opts.contains ?? []).map((c) => c.toLowerCase());
335
+ return names.filter((n) => {
336
+ const low = n.toLowerCase();
337
+ if (starts !== undefined && !low.startsWith(starts))
338
+ return false;
339
+ if (subs.length === 0)
340
+ return true;
341
+ return operator === "or"
342
+ ? subs.some((c) => low.includes(c))
343
+ : subs.every((c) => low.includes(c));
344
+ });
345
+ }
189
346
  const filters = [];
190
347
  if (opts.startswith) {
191
- filters.push(`startswith(tolower(Name),tolower('${escape(opts.startswith)}'))`);
348
+ filters.push(`startswith(tolower(Name),tolower('${escapeOdataLiteral(opts.startswith)}'))`);
192
349
  }
193
350
  if (opts.contains && opts.contains.length > 0) {
194
- const subs = opts.contains.map((s) => `contains(tolower(Name),tolower('${escape(s)}'))`);
351
+ const subs = opts.contains.map((s) => `contains(tolower(Name),tolower('${escapeOdataLiteral(s)}'))`);
195
352
  filters.push(`(${subs.join(` ${operator} `)})`);
196
353
  }
197
354
  const filter = filters.length > 0
198
355
  ? `&$filter=${encodeURIComponent(filters.join(" and "))}`
199
356
  : "";
200
357
  const buildUrl = (root) => {
201
- let url = `/api/v1/Contents('${enc(root)}')`;
358
+ let url = `/api/v1/Contents('${odataKey(root)}')`;
202
359
  for (const seg of segments)
203
- url += `/Contents('${enc(seg)}')`;
360
+ url += `/Contents('${odataKey(seg)}')`;
204
361
  url += `/Contents?$select=Name${filter}`;
205
362
  return url;
206
363
  };
@@ -214,5 +371,45 @@ export class FileService {
214
371
  return r.value.map((f) => f.Name);
215
372
  }
216
373
  }
374
+ /**
375
+ * Create-or-update a document in the Applications tree.
376
+ *
377
+ * Three steps, each one measured: POST the entity WITHOUT Content (inlining
378
+ * it is refused — the property is a stream), re-resolve to learn the ID the
379
+ * server assigned, then PUT the bytes behind the cast.
380
+ */
381
+ async uploadToApplications(parts, content) {
382
+ const leaf = parts[parts.length - 1];
383
+ const parentParts = parts.slice(0, -1);
384
+ const { url: parentUrl } = await this.appsResolve(parentParts);
385
+ const existing = (await this.appsChildren(parentUrl)).find((e) => e.name.toLowerCase() === leaf.toLowerCase());
386
+ if (existing !== undefined && existing.kind !== "DocumentReference") {
387
+ throw new TM1Error({
388
+ code: TM1ErrorCode.UNSUPPORTED_OPERATION,
389
+ message: `'${leaf}' already exists as a ${existing.kind} and is not a document`,
390
+ endpoint: parentUrl,
391
+ });
392
+ }
393
+ if (existing === undefined) {
394
+ await this.http.request("POST", `${parentUrl}/Contents`, {
395
+ "@odata.type": "#ibm.tm1.api.v1.Document",
396
+ Name: leaf,
397
+ });
398
+ }
399
+ const { url, entry } = await this.appsResolve([...parentParts, leaf]);
400
+ try {
401
+ await this.http.requestBinary("PUT", this.appsContentUrl(url, entry), content);
402
+ }
403
+ catch (e) {
404
+ // Create and write are two requests. If the write fails on an entry this
405
+ // call created, take it back out — leaving an empty document behind would
406
+ // report a failed upload while the name now exists.
407
+ if (existing === undefined) {
408
+ await this.http.request("DELETE", url).catch(() => undefined);
409
+ }
410
+ throw e;
411
+ }
412
+ return { created: existing === undefined, root: "Applications" };
413
+ }
217
414
  }
218
415
  //# sourceMappingURL=file-service.js.map
@@ -1,5 +1,14 @@
1
1
  import type { Hierarchy, HierarchyElement } from "../../types.js";
2
2
  import type { TM1HttpClient } from "../http.js";
3
+ interface DescendantsResult {
4
+ element: string;
5
+ descendants: Array<{
6
+ name: string;
7
+ type: HierarchyElement["type"];
8
+ level: number;
9
+ depth: number;
10
+ }>;
11
+ }
3
12
  /**
4
13
  * A hierarchy plus the size of the element set the request selected, so
5
14
  * callers can page without guessing. `totalElements` counts elements that
@@ -93,15 +102,8 @@ export declare class HierarchyService {
93
102
  getDescendants(dimensionName: string, hierarchyName: string, element: string, opts?: {
94
103
  depth?: number;
95
104
  leavesOnly?: boolean;
96
- }): Promise<{
97
- element: string;
98
- descendants: Array<{
99
- name: string;
100
- type: HierarchyElement["type"];
101
- level: number;
102
- depth: number;
103
- }>;
104
- }>;
105
+ }): Promise<DescendantsResult>;
106
+ private getDescendantsFromFull;
105
107
  /**
106
108
  * Resolve ancestors of an element via parent-walk. Handles multi-parent
107
109
  * hierarchies — returns the unique flat ancestor set AND every distinct
@@ -115,6 +117,13 @@ export declare class HierarchyService {
115
117
  }>;
116
118
  paths: string[][];
117
119
  }>;
120
+ /**
121
+ * One element with a navigation property expanded `levels` deep. Nodes on
122
+ * the last level come back without that property, which is how callers see
123
+ * where the request stopped.
124
+ */
125
+ private getNested;
126
+ private getAncestorsFromFull;
118
127
  /**
119
128
  * Create a new hierarchy inside an existing dimension.
120
129
  * POST /api/v1/Dimensions('{d}')/Hierarchies
@@ -126,4 +135,5 @@ export declare class HierarchyService {
126
135
  */
127
136
  delete(dimensionName: string, hierarchyName: string): Promise<void>;
128
137
  }
138
+ export {};
129
139
  //# sourceMappingURL=hierarchy-service.d.ts.map
@@ -4,9 +4,10 @@
4
4
  // client-side. See docs/ARCHITECTURE.md for the layering.
5
5
  import { TM1Error, TM1ErrorCode } from "../../types.js";
6
6
  import { compileUserRegex } from "../../lib/safe-regex.js";
7
- import { pageClauseList, readNestedCount } from "./odata-page.js";
8
- // OData key encoder: double ' per OData literal rules, then percent-encode.
9
- const enc = (s) => encodeURIComponent(String(s).replace(/'/g, "''"));
7
+ import { escapeOdataLiteral, odataKey, pageClauseList, readNestedCount, } from "./odata-page.js";
8
+ // How many levels one nested $expand reaches. TM1 answered 20 on 11.8 and
9
+ // 12.5; a hierarchy deeper than that falls back to the full load.
10
+ const NEST_LEVELS = 20;
10
11
  // elementType pushes down as the ORDINAL, not the name: `Type eq
11
12
  // 'Consolidated'` is accepted and matches nothing — silently, which is
12
13
  // worse than an error and is why this filter used to run client-side.
@@ -30,11 +31,10 @@ function elementFilters(opts) {
30
31
  filters.push(`Level eq ${opts.level}`);
31
32
  if (opts?.levelMax !== undefined)
32
33
  filters.push(`Level le ${opts.levelMax}`);
33
- const escapeOdata = (s) => s.replace(/'/g, "''");
34
34
  if (opts?.nameContains)
35
- filters.push(`contains(Name, '${escapeOdata(opts.nameContains)}')`);
35
+ filters.push(`contains(Name, '${escapeOdataLiteral(opts.nameContains)}')`);
36
36
  if (opts?.nameStartsWith)
37
- filters.push(`startswith(Name, '${escapeOdata(opts.nameStartsWith)}')`);
37
+ filters.push(`startswith(Name, '${escapeOdataLiteral(opts.nameStartsWith)}')`);
38
38
  const typeOrdinal = opts?.elementType && opts.elementType !== "All"
39
39
  ? TYPE_ORDINAL[opts.elementType]
40
40
  : undefined;
@@ -87,7 +87,7 @@ export class HierarchyService {
87
87
  const pushDown = topN !== undefined && !needsClientPostFilter;
88
88
  if (pushDown)
89
89
  elementClauses.push(...pageClauseList({ top: topN, skip }));
90
- const path = `/api/v1/Dimensions('${enc(dimensionName)}')/Hierarchies('${enc(hierarchyName)}')?$expand=Elements(${elementClauses.join(";")})`;
90
+ const path = `/api/v1/Dimensions('${odataKey(dimensionName)}')/Hierarchies('${odataKey(hierarchyName)}')?$expand=Elements(${elementClauses.join(";")})`;
91
91
  const rawResponse = await this.http.request("GET", path);
92
92
  let filteredElements = rawResponse.Elements;
93
93
  if (regex !== undefined)
@@ -175,7 +175,7 @@ export class HierarchyService {
175
175
  const clauses = [regex ? "$select=Name,Type,Level" : "$select=Type,Level"];
176
176
  if (filters.length > 0)
177
177
  clauses.push(`$filter=${filters.join(" and ")}`);
178
- const path = `/api/v1/Dimensions('${enc(dimensionName)}')/Hierarchies('${enc(hierarchyName)}')` +
178
+ const path = `/api/v1/Dimensions('${odataKey(dimensionName)}')/Hierarchies('${odataKey(hierarchyName)}')` +
179
179
  `/Elements?${clauses.join("&")}`;
180
180
  const response = await this.http.request("GET", path);
181
181
  let rows = response.value ?? [];
@@ -210,7 +210,7 @@ export class HierarchyService {
210
210
  * GET /api/v1/Dimensions('{d}')/Hierarchies('{h}')/Elements?$select=Name,Type
211
211
  */
212
212
  async getElementTypes(dimensionName, hierarchyName) {
213
- const path = `/api/v1/Dimensions('${enc(dimensionName)}')/Hierarchies('${enc(hierarchyName)}')` +
213
+ const path = `/api/v1/Dimensions('${odataKey(dimensionName)}')/Hierarchies('${odataKey(hierarchyName)}')` +
214
214
  `/Elements?$select=Name,Type`;
215
215
  const response = await this.http.request("GET", path);
216
216
  return (response.value ?? []).map((e) => ({
@@ -225,6 +225,46 @@ export class HierarchyService {
225
225
  * focused subtree, not the whole dimension.
226
226
  */
227
227
  async getDescendants(dimensionName, hierarchyName, element, opts) {
228
+ // Fetch only the subtree: Components expanded level by level from the
229
+ // start element. Measured on a 11,111-element, 5-level dimension (11.8):
230
+ // 14 KB for a mid-level subtree and 1.4 MB from the top, against 3 MB for
231
+ // the whole hierarchy with Parents+Edges on every call. TM1 served 20
232
+ // nested levels on 11.8 and 12.5. Deeper hierarchies fall back below.
233
+ if ((opts?.depth ?? 0) > NEST_LEVELS) {
234
+ return this.getDescendantsFromFull(dimensionName, hierarchyName, element, opts);
235
+ }
236
+ const levels = opts?.depth ?? NEST_LEVELS;
237
+ const root = await this.getNested(dimensionName, hierarchyName, element, "Components", "Name,Type,Level", levels);
238
+ const out = [];
239
+ const seen = new Set([element]);
240
+ let frontier = [root];
241
+ for (let depth = 1; depth <= levels && frontier.length > 0; depth++) {
242
+ const next = [];
243
+ for (const node of frontier) {
244
+ for (const child of node.Components ?? []) {
245
+ if (seen.has(child.Name))
246
+ continue;
247
+ seen.add(child.Name);
248
+ const type = child.Type;
249
+ // Only a consolidation has children, so type alone decides; an
250
+ // empty consolidation is not a leaf either (nothing can be written).
251
+ if (!opts?.leavesOnly || type !== "Consolidated") {
252
+ out.push({ name: child.Name, type, level: child.Level, depth });
253
+ }
254
+ next.push(child);
255
+ }
256
+ }
257
+ frontier = next;
258
+ }
259
+ // The last expanded level carries no Components. A consolidation there
260
+ // means the tree goes deeper than the request reached.
261
+ if (opts?.depth === undefined &&
262
+ frontier.some((n) => n.Type === "Consolidated")) {
263
+ return this.getDescendantsFromFull(dimensionName, hierarchyName, element, opts);
264
+ }
265
+ return { element, descendants: out };
266
+ }
267
+ async getDescendantsFromFull(dimensionName, hierarchyName, element, opts) {
228
268
  const hierarchy = await this.get(dimensionName, hierarchyName);
229
269
  const byName = new Map();
230
270
  for (const e of hierarchy.elements)
@@ -255,7 +295,10 @@ export class HierarchyService {
255
295
  const childNode = byName.get(child.name);
256
296
  if (!childNode)
257
297
  continue;
258
- const isLeaf = childNode.children.length === 0;
298
+ // Type, not just shape: an empty consolidation has no children but is
299
+ // not a leaf in the sense callers ask for — nothing can be written to
300
+ // it and it carries no value of its own.
301
+ const isLeaf = childNode.children.length === 0 && childNode.type !== "Consolidated";
259
302
  if (!opts?.leavesOnly || isLeaf) {
260
303
  out.push({
261
304
  name: childNode.name,
@@ -275,6 +318,69 @@ export class HierarchyService {
275
318
  * root-to-element path so consumers can see consolidation alternatives.
276
319
  */
277
320
  async getAncestors(dimensionName, hierarchyName, element) {
321
+ // Parents expanded upward from the element: 1 KB on the 11,111-element
322
+ // test dimension where the full load was 3 MB. A path that reaches the
323
+ // nesting limit without ending at a root falls back to the full load.
324
+ const root = await this.getNested(dimensionName, hierarchyName, element, "Parents", "Name,Level", NEST_LEVELS);
325
+ const ancestorMap = new Map();
326
+ const paths = [];
327
+ let truncated = false;
328
+ const walk = (node, path) => {
329
+ const parents = node.Parents;
330
+ if (parents === undefined) {
331
+ truncated = true;
332
+ return;
333
+ }
334
+ if (parents.length === 0) {
335
+ paths.push(path);
336
+ return;
337
+ }
338
+ for (const p of parents) {
339
+ if (path.includes(p.Name))
340
+ continue;
341
+ ancestorMap.set(p.Name, p.Level);
342
+ walk(p, [...path, p.Name]);
343
+ }
344
+ };
345
+ walk(root, [element]);
346
+ if (truncated) {
347
+ return this.getAncestorsFromFull(dimensionName, hierarchyName, element);
348
+ }
349
+ const ancestors = [...ancestorMap.entries()]
350
+ .map(([name, level]) => ({ name, level }))
351
+ .sort((a, b) => a.level - b.level || a.name.localeCompare(b.name));
352
+ return { element, ancestors, paths };
353
+ }
354
+ /**
355
+ * One element with a navigation property expanded `levels` deep. Nodes on
356
+ * the last level come back without that property, which is how callers see
357
+ * where the request stopped.
358
+ */
359
+ async getNested(dimensionName, hierarchyName, element, nav, select, levels) {
360
+ let expand = `${nav}($select=${select})`;
361
+ for (let i = 1; i < levels; i++) {
362
+ expand = `${nav}($select=${select};$expand=${expand})`;
363
+ }
364
+ const path = `/api/v1/Dimensions('${odataKey(dimensionName)}')/Hierarchies('${odataKey(hierarchyName)}')` +
365
+ `/Elements('${odataKey(element)}')?$select=${select}` +
366
+ (levels > 0 ? `&$expand=${expand}` : "");
367
+ try {
368
+ return await this.http.request("GET", path);
369
+ }
370
+ catch (e) {
371
+ if (e instanceof TM1Error && e.code === TM1ErrorCode.NOT_FOUND) {
372
+ throw new TM1Error({
373
+ code: TM1ErrorCode.NOT_FOUND,
374
+ message: `Element '${element}' not found in ${dimensionName}.${hierarchyName}`,
375
+ httpStatus: e.httpStatus,
376
+ // The nested $expand runs to kilobytes; the path alone locates it.
377
+ endpoint: e.endpoint?.split("?")[0],
378
+ });
379
+ }
380
+ throw e;
381
+ }
382
+ }
383
+ async getAncestorsFromFull(dimensionName, hierarchyName, element) {
278
384
  const hierarchy = await this.get(dimensionName, hierarchyName);
279
385
  const byName = new Map();
280
386
  for (const e of hierarchy.elements)
@@ -319,14 +425,14 @@ export class HierarchyService {
319
425
  * POST /api/v1/Dimensions('{d}')/Hierarchies
320
426
  */
321
427
  async create(dimensionName, hierarchyName) {
322
- await this.http.request("POST", `/api/v1/Dimensions('${enc(dimensionName)}')/Hierarchies`, { Name: hierarchyName });
428
+ await this.http.request("POST", `/api/v1/Dimensions('${odataKey(dimensionName)}')/Hierarchies`, { Name: hierarchyName });
323
429
  }
324
430
  /**
325
431
  * Delete a hierarchy from a dimension.
326
432
  * DELETE /api/v1/Dimensions('{d}')/Hierarchies('{h}')
327
433
  */
328
434
  async delete(dimensionName, hierarchyName) {
329
- await this.http.request("DELETE", `/api/v1/Dimensions('${enc(dimensionName)}')/Hierarchies('${enc(hierarchyName)}')`);
435
+ await this.http.request("DELETE", `/api/v1/Dimensions('${odataKey(dimensionName)}')/Hierarchies('${odataKey(hierarchyName)}')`);
330
436
  }
331
437
  }
332
438
  //# sourceMappingURL=hierarchy-service.js.map
@@ -1,7 +1,4 @@
1
- function encKey(s) {
2
- // Double single-quotes for OData escaping, then percent-encode all URL-unsafe chars
3
- return encodeURIComponent(String(s).replace(/'/g, "''")).replace(/'/g, "%27");
4
- }
1
+ import { odataKey } from "./odata-page.js";
5
2
  export class MonitoringService {
6
3
  http;
7
4
  constructor(http) {
@@ -117,7 +114,7 @@ export class MonitoringService {
117
114
  * POST /api/v1/Jobs('{id}')/tm1.Cancel
118
115
  */
119
116
  async cancelJob(jobId) {
120
- await this.http.request("POST", `/api/v1/Jobs('${encKey(jobId)}')/tm1.Cancel`, {});
117
+ await this.http.request("POST", `/api/v1/Jobs('${odataKey(jobId)}')/tm1.Cancel`, {});
121
118
  }
122
119
  }
123
120
  //# sourceMappingURL=monitoring-service.js.map
@@ -36,6 +36,12 @@ export declare function readCount(response: {
36
36
  export declare function readNestedCount(response: Record<string, unknown> | null | undefined, navigationProperty: string): number | undefined;
37
37
  /** Double `'` per OData literal rules so a caller string cannot break out of a literal. */
38
38
  export declare function escapeOdataLiteral(value: string): string;
39
+ /**
40
+ * Entity-key segment for a URL path: `Cubes('${odataKey(name)}')`. Doubles `'`,
41
+ * then percent-encodes, so `#`, `?`, `%`, `&` and `/` in a TM1 name stay inside
42
+ * the key. The one encoder for every service — see scripts/check-no-local-enc.mjs.
43
+ */
44
+ export declare function odataKey(value: string): string;
39
45
  /** Join filter predicates with `and` into a `&$filter=…` fragment. Empty string when none. */
40
46
  export declare function filterClause(predicates: readonly string[]): string;
41
47
  /**