@ffschrattenecker/tm1-mcp-server 7.0.2 → 8.1.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/CHANGELOG.md +101 -1
- package/README.md +9 -6
- package/dist/config.d.ts +10 -1
- package/dist/config.js +40 -22
- package/dist/connections.d.ts +30 -2
- package/dist/connections.js +94 -21
- package/dist/http-transport.js +51 -4
- package/dist/index.js +6 -0
- package/dist/lib/callgraph/referenceIndex.js +2 -1
- package/dist/lib/callgraph/rulesLinter.d.ts +0 -19
- package/dist/lib/callgraph/rulesLinter.js +0 -590
- package/dist/lib/callgraph/tiParser.js +10 -5
- package/dist/lib/callgraph/tm1-adapter.d.ts +5 -1
- package/dist/lib/callgraph/tm1-adapter.js +41 -9
- package/dist/lib/callgraph/variableEnv.js +3 -2
- package/dist/lib/cell-address.d.ts +1 -1
- package/dist/lib/cell-address.js +2 -2
- package/dist/lib/complexity/antipatterns.js +3 -1
- package/dist/lib/complexity/comment-classifier.js +2 -1
- package/dist/lib/feeders/element-type-cache.js +4 -6
- package/dist/lib/naming/odata-filter.js +2 -1
- package/dist/lib/pro-parser.js +13 -4
- package/dist/lib/safe-regex.d.ts +0 -13
- package/dist/lib/safe-regex.js +40 -13
- package/dist/lib/sample-cells.js +1 -1
- package/dist/lib/ti-identifier.d.ts +11 -0
- package/dist/lib/ti-identifier.js +11 -0
- package/dist/lib/tm1-name.d.ts +9 -0
- package/dist/lib/tm1-name.js +9 -0
- package/dist/lib/v12-compat/deprecated-ti.js +2 -2
- package/dist/secrets-cli.d.ts +10 -0
- package/dist/secrets-cli.js +218 -0
- package/dist/secrets.d.ts +39 -0
- package/dist/secrets.js +128 -0
- package/dist/session-manager.d.ts +1 -0
- package/dist/session-manager.js +24 -0
- package/dist/tm1-client/connection/profile.js +2 -4
- package/dist/tm1-client/dispatcher.d.ts +1 -1
- package/dist/tm1-client/dispatcher.js +18 -6
- package/dist/tm1-client/http.d.ts +21 -0
- package/dist/tm1-client/http.js +39 -30
- package/dist/tm1-client/services/cell-service.d.ts +9 -7
- package/dist/tm1-client/services/cell-service.js +51 -25
- package/dist/tm1-client/services/chore-service.js +8 -9
- package/dist/tm1-client/services/cube-service.d.ts +14 -18
- package/dist/tm1-client/services/cube-service.js +51 -45
- package/dist/tm1-client/services/dimension-order.d.ts +13 -0
- package/dist/tm1-client/services/dimension-order.js +42 -0
- package/dist/tm1-client/services/dimension-service.js +4 -6
- package/dist/tm1-client/services/element-service.d.ts +35 -11
- package/dist/tm1-client/services/element-service.js +124 -60
- package/dist/tm1-client/services/file-service.d.ts +42 -6
- package/dist/tm1-client/services/file-service.js +225 -28
- package/dist/tm1-client/services/hierarchy-service.d.ts +19 -9
- package/dist/tm1-client/services/hierarchy-service.js +118 -12
- package/dist/tm1-client/services/monitoring-service.js +2 -5
- package/dist/tm1-client/services/odata-page.d.ts +6 -0
- package/dist/tm1-client/services/odata-page.js +8 -0
- package/dist/tm1-client/services/process-service.d.ts +1 -1
- package/dist/tm1-client/services/process-service.js +26 -26
- package/dist/tm1-client/services/security-service.js +8 -9
- package/dist/tm1-client/services/server-service.js +14 -15
- package/dist/tm1-client/services/subset-service.d.ts +23 -10
- package/dist/tm1-client/services/subset-service.js +82 -26
- package/dist/tm1-client/services/view-service.js +12 -13
- package/dist/tm1-client.js +5 -2
- package/dist/tools/analysis/check-v12-readiness.js +3 -3
- package/dist/tools/celldata/check-feeders.js +6 -6
- package/dist/tools/celldata/check-writable-coords.js +33 -22
- package/dist/tools/celldata/execute-mdx.js +10 -9
- package/dist/tools/celldata/get-view.js +10 -9
- package/dist/tools/celldata/member-ref.d.ts +15 -0
- package/dist/tools/celldata/member-ref.js +67 -0
- package/dist/tools/celldata/trace-cell-calculation.js +6 -6
- package/dist/tools/celldata/trace-feeders.js +6 -6
- package/dist/tools/celldata/write-cells.js +62 -12
- package/dist/tools/dimension-management/create-element-attribute.js +1 -1
- package/dist/tools/dimension-management/delete-hierarchy.js +3 -4
- package/dist/tools/dimension-management/get-element-attribute-values.js +7 -2
- package/dist/tools/dimension-management/list-element-attributes.js +1 -1
- package/dist/tools/dimension-management/update-element-attribute-value.js +8 -3
- package/dist/tools/dimension-management/update-element.js +14 -7
- package/dist/tools/fileops/container.d.ts +8 -0
- package/dist/tools/fileops/container.js +11 -0
- package/dist/tools/fileops/delete-file.js +4 -2
- package/dist/tools/fileops/get-file-content.js +45 -7
- package/dist/tools/fileops/list-files.js +5 -2
- package/dist/tools/fileops/search-files.js +4 -1
- package/dist/tools/fileops/upload-file.js +5 -2
- package/dist/tools/index.js +0 -2
- package/dist/tools/model-building/check-cube-rule.js +3 -2
- package/dist/tools/model-building/clear-cube.js +16 -27
- package/dist/tools/model-building/set-cube-rules.js +13 -20
- package/dist/tools/model-building/unload-cube.js +1 -1
- package/dist/tools/operations/get-cube-stats.js +1 -1
- package/dist/tools/operations/get-transaction-log.js +1 -1
- package/dist/tools/operations/list-error-logs.js +27 -2
- package/dist/tools/schemas/items-fileops.d.ts +4 -0
- package/dist/tools/schemas/items-fileops.js +1 -0
- package/dist/tools/security/list-clients.js +5 -5
- package/dist/tools/security/list-groups.js +1 -1
- package/dist/tools/subsets/create-subset.js +9 -5
- package/dist/tools/subsets/delete-subset.js +8 -3
- package/dist/tools/subsets/update-subset.js +9 -9
- package/dist/tools/ti-development/diff-process-with-file.js +4 -29
- package/dist/tools/ti-development/diff-processes.js +9 -0
- package/dist/tools/ti-development/export-process-to-git.js +9 -7
- package/dist/tools/ti-development/export-process-to-pro.js +15 -8
- package/dist/tools/ti-development/import-pro-file.js +10 -21
- package/dist/tools/ti-development/import-process-from-git.js +8 -18
- package/dist/tools/ti-development/install-pro-bundle.js +2 -2
- package/dist/tools/ti-development/upsert-process.js +26 -5
- package/npm-shrinkwrap.json +245 -9
- package/package.json +10 -5
- package/dist/tools/dimension-management/move-element.d.ts +0 -2
- 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
|
-
|
|
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('${
|
|
120
|
+
let url = `/api/v1/Contents('${odataKey(root)}')`;
|
|
35
121
|
for (const seg of segments) {
|
|
36
|
-
url += `/Contents('${
|
|
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('${
|
|
156
|
+
let url = `/api/v1/Contents('${odataKey(root)}')`;
|
|
60
157
|
for (const p of parts) {
|
|
61
|
-
url += `/Contents('${
|
|
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.
|
|
164
|
+
return await this.http.requestRawBytes("GET", buildUrl("Files"));
|
|
68
165
|
}
|
|
69
166
|
catch (e) {
|
|
70
167
|
rethrowIfSystemic(e);
|
|
71
|
-
return await this.http.
|
|
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('${
|
|
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('${
|
|
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('${
|
|
238
|
+
.map((s) => `/Contents('${odataKey(s)}')`)
|
|
130
239
|
.join("");
|
|
131
240
|
const tryRoot = async (root) => {
|
|
132
|
-
const parentUrl = `/api/v1/Contents('${
|
|
133
|
-
const contentUrl = `/api/v1/Contents('${
|
|
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
|
-
|
|
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('${
|
|
168
|
-
return `/api/v1/Contents('${
|
|
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
|
-
|
|
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('${
|
|
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('${
|
|
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('${
|
|
358
|
+
let url = `/api/v1/Contents('${odataKey(root)}')`;
|
|
202
359
|
for (const seg of segments)
|
|
203
|
-
url += `/Contents('${
|
|
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
|
-
|
|
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
|
-
//
|
|
9
|
-
|
|
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, '${
|
|
35
|
+
filters.push(`contains(Name, '${escapeOdataLiteral(opts.nameContains)}')`);
|
|
36
36
|
if (opts?.nameStartsWith)
|
|
37
|
-
filters.push(`startswith(Name, '${
|
|
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('${
|
|
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('${
|
|
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('${
|
|
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
|
-
|
|
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('${
|
|
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('${
|
|
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
|
-
|
|
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('${
|
|
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
|
/**
|