tm1npm 1.5.3 → 2.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 (78) hide show
  1. package/CHANGELOG.md +89 -0
  2. package/lib/index.d.ts +1 -1
  3. package/lib/index.d.ts.map +1 -1
  4. package/lib/services/ApplicationService.d.ts +19 -3
  5. package/lib/services/ApplicationService.d.ts.map +1 -1
  6. package/lib/services/ApplicationService.js +232 -6
  7. package/lib/services/AsyncOperationService.d.ts +8 -1
  8. package/lib/services/AsyncOperationService.d.ts.map +1 -1
  9. package/lib/services/AsyncOperationService.js +69 -26
  10. package/lib/services/ElementService.d.ts +67 -1
  11. package/lib/services/ElementService.d.ts.map +1 -1
  12. package/lib/services/ElementService.js +214 -0
  13. package/lib/services/FileService.d.ts.map +1 -1
  14. package/lib/services/HierarchyService.d.ts +26 -0
  15. package/lib/services/HierarchyService.d.ts.map +1 -1
  16. package/lib/services/HierarchyService.js +306 -0
  17. package/lib/services/ProcessService.d.ts +40 -22
  18. package/lib/services/ProcessService.d.ts.map +1 -1
  19. package/lib/services/ProcessService.js +118 -111
  20. package/lib/services/RestService.d.ts +213 -25
  21. package/lib/services/RestService.d.ts.map +1 -1
  22. package/lib/services/RestService.js +841 -263
  23. package/lib/services/SubsetService.d.ts +2 -0
  24. package/lib/services/SubsetService.d.ts.map +1 -1
  25. package/lib/services/SubsetService.js +33 -0
  26. package/lib/services/TM1Service.d.ts +44 -1
  27. package/lib/services/TM1Service.d.ts.map +1 -1
  28. package/lib/services/TM1Service.js +96 -4
  29. package/lib/services/index.d.ts +1 -1
  30. package/lib/services/index.d.ts.map +1 -1
  31. package/lib/tests/100PercentParityCheck.test.js +23 -6
  32. package/lib/tests/applicationService.issue38.test.d.ts +5 -0
  33. package/lib/tests/applicationService.issue38.test.d.ts.map +1 -0
  34. package/lib/tests/applicationService.issue38.test.js +237 -0
  35. package/lib/tests/asyncOperationService.test.js +51 -45
  36. package/lib/tests/bugfix28.test.js +12 -4
  37. package/lib/tests/elementService.issue37.test.d.ts +5 -0
  38. package/lib/tests/elementService.issue37.test.d.ts.map +1 -0
  39. package/lib/tests/elementService.issue37.test.js +413 -0
  40. package/lib/tests/elementService.issue38.test.d.ts +5 -0
  41. package/lib/tests/elementService.issue38.test.d.ts.map +1 -0
  42. package/lib/tests/elementService.issue38.test.js +79 -0
  43. package/lib/tests/hierarchyService.issue38.test.d.ts +5 -0
  44. package/lib/tests/hierarchyService.issue38.test.d.ts.map +1 -0
  45. package/lib/tests/hierarchyService.issue38.test.js +460 -0
  46. package/lib/tests/processService.comprehensive.test.js +9 -9
  47. package/lib/tests/processService.test.js +234 -0
  48. package/lib/tests/restService.test.d.ts +0 -4
  49. package/lib/tests/restService.test.d.ts.map +1 -1
  50. package/lib/tests/restService.test.js +1558 -143
  51. package/lib/tests/subsetService.issue38.test.d.ts +5 -0
  52. package/lib/tests/subsetService.issue38.test.d.ts.map +1 -0
  53. package/lib/tests/subsetService.issue38.test.js +113 -0
  54. package/lib/tests/tm1Service.test.js +80 -8
  55. package/package.json +1 -1
  56. package/src/index.ts +1 -1
  57. package/src/services/ApplicationService.ts +282 -10
  58. package/src/services/AsyncOperationService.ts +76 -29
  59. package/src/services/ElementService.ts +322 -1
  60. package/src/services/FileService.ts +3 -3
  61. package/src/services/HierarchyService.ts +419 -1
  62. package/src/services/ProcessService.ts +185 -142
  63. package/src/services/RestService.ts +1021 -267
  64. package/src/services/SubsetService.ts +48 -0
  65. package/src/services/TM1Service.ts +127 -6
  66. package/src/services/index.ts +1 -1
  67. package/src/tests/100PercentParityCheck.test.ts +29 -8
  68. package/src/tests/applicationService.issue38.test.ts +293 -0
  69. package/src/tests/asyncOperationService.test.ts +52 -48
  70. package/src/tests/bugfix28.test.ts +12 -4
  71. package/src/tests/elementService.issue37.test.ts +571 -0
  72. package/src/tests/elementService.issue38.test.ts +103 -0
  73. package/src/tests/hierarchyService.issue38.test.ts +599 -0
  74. package/src/tests/processService.comprehensive.test.ts +10 -10
  75. package/src/tests/processService.test.ts +295 -3
  76. package/src/tests/restService.test.ts +1844 -139
  77. package/src/tests/subsetService.issue38.test.ts +182 -0
  78. package/src/tests/tm1Service.test.ts +95 -11
@@ -0,0 +1,5 @@
1
+ /**
2
+ * SubsetService Tests — Issue #38 new methods
3
+ */
4
+ export {};
5
+ //# sourceMappingURL=subsetService.issue38.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"subsetService.issue38.test.d.ts","sourceRoot":"","sources":["../../src/tests/subsetService.issue38.test.ts"],"names":[],"mappings":"AAAA;;GAEG"}
@@ -0,0 +1,113 @@
1
+ "use strict";
2
+ /**
3
+ * SubsetService Tests — Issue #38 new methods
4
+ */
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ const SubsetService_1 = require("../services/SubsetService");
7
+ const Subset_1 = require("../objects/Subset");
8
+ const Element_1 = require("../objects/Element");
9
+ const createMockResponse = (data, status = 200) => ({
10
+ data,
11
+ status,
12
+ statusText: status === 200 ? 'OK' : status === 201 ? 'Created' : status === 204 ? 'No Content' : 'Error',
13
+ headers: {},
14
+ config: {}
15
+ });
16
+ describe('SubsetService — Issue #38 new methods', () => {
17
+ let subsetService;
18
+ let mockRestService;
19
+ beforeEach(() => {
20
+ mockRestService = {
21
+ get: jest.fn(),
22
+ post: jest.fn(),
23
+ patch: jest.fn(),
24
+ delete: jest.fn(),
25
+ put: jest.fn(),
26
+ config: {},
27
+ rest: {},
28
+ buildBaseUrl: jest.fn(),
29
+ extractErrorMessage: jest.fn()
30
+ };
31
+ subsetService = new SubsetService_1.SubsetService(mockRestService);
32
+ });
33
+ // ===== updateStaticElements =====
34
+ describe('updateStaticElements', () => {
35
+ test('should PUT to Elements/$ref with correct @odata.id body', async () => {
36
+ mockRestService.put.mockResolvedValue(createMockResponse({}, 200));
37
+ await subsetService.updateStaticElements('MySubset', 'TestDimension', 'TestHierarchy', false, ['ElemA', 'ElemB']);
38
+ expect(mockRestService.put).toHaveBeenCalledTimes(1);
39
+ const [url, body] = mockRestService.put.mock.calls[0];
40
+ expect(url).toContain("Dimensions('TestDimension')/Hierarchies('TestHierarchy')");
41
+ expect(url).toContain("Subsets('MySubset')/Elements/$ref");
42
+ const parsed = JSON.parse(body);
43
+ expect(Array.isArray(parsed.value)).toBe(true);
44
+ expect(parsed.value).toHaveLength(2);
45
+ expect(parsed.value[0]['@odata.id']).toContain("Elements('ElemA')");
46
+ expect(parsed.value[1]['@odata.id']).toContain("Elements('ElemB')");
47
+ });
48
+ test('should accept a Subset object and use its elements', async () => {
49
+ mockRestService.put.mockResolvedValue(createMockResponse({}, 200));
50
+ const subset = new Subset_1.Subset('MySubset', 'TestDimension', 'TestHierarchy', undefined, undefined, ['X', 'Y', 'Z']);
51
+ await subsetService.updateStaticElements(subset);
52
+ expect(mockRestService.put).toHaveBeenCalledTimes(1);
53
+ const [, body] = mockRestService.put.mock.calls[0];
54
+ const parsed = JSON.parse(body);
55
+ expect(parsed.value).toHaveLength(3);
56
+ expect(parsed.value[0]['@odata.id']).toContain("Elements('X')");
57
+ expect(parsed.value[2]['@odata.id']).toContain("Elements('Z')");
58
+ });
59
+ test('should accept a Subset object with overriding elements array', async () => {
60
+ mockRestService.put.mockResolvedValue(createMockResponse({}, 200));
61
+ const subset = new Subset_1.Subset('MySubset', 'TestDimension', 'TestHierarchy', undefined, undefined, ['X', 'Y']);
62
+ // Override elements explicitly
63
+ await subsetService.updateStaticElements(subset, undefined, undefined, false, ['Override1']);
64
+ const [, body] = mockRestService.put.mock.calls[0];
65
+ const parsed = JSON.parse(body);
66
+ expect(parsed.value).toHaveLength(1);
67
+ expect(parsed.value[0]['@odata.id']).toContain("Elements('Override1')");
68
+ });
69
+ test('should accept Element objects (not just strings)', async () => {
70
+ mockRestService.put.mockResolvedValue(createMockResponse({}, 200));
71
+ const elem1 = new Element_1.Element('Alpha', Element_1.ElementType.NUMERIC);
72
+ const elem2 = new Element_1.Element('Beta', Element_1.ElementType.NUMERIC);
73
+ await subsetService.updateStaticElements('MySubset', 'TestDimension', 'TestHierarchy', false, [elem1, elem2]);
74
+ const [, body] = mockRestService.put.mock.calls[0];
75
+ const parsed = JSON.parse(body);
76
+ expect(parsed.value[0]['@odata.id']).toContain("Elements('Alpha')");
77
+ expect(parsed.value[1]['@odata.id']).toContain("Elements('Beta')");
78
+ });
79
+ test('should use PrivateSubsets collection when isPrivate=true', async () => {
80
+ mockRestService.put.mockResolvedValue(createMockResponse({}, 200));
81
+ await subsetService.updateStaticElements('PrivateSubset', 'TestDimension', 'TestHierarchy', true, ['E1']);
82
+ const [url] = mockRestService.put.mock.calls[0];
83
+ expect(url).toContain('PrivateSubsets');
84
+ // PrivateSubsets is the only subsets segment — there must be no plain '/Subsets(' prefix
85
+ expect(url).not.toMatch(/\/Subsets\(/);
86
+ });
87
+ test('should use public Subsets collection when isPrivate=false', async () => {
88
+ mockRestService.put.mockResolvedValue(createMockResponse({}, 200));
89
+ await subsetService.updateStaticElements('PublicSubset', 'TestDimension', 'TestHierarchy', false, ['E1']);
90
+ const [url] = mockRestService.put.mock.calls[0];
91
+ expect(url).toContain("Subsets('PublicSubset')");
92
+ expect(url).not.toContain('PrivateSubsets');
93
+ });
94
+ test('should throw when dimensionName is missing for string argument', async () => {
95
+ await expect(subsetService.updateStaticElements('MySubset', undefined, 'TestHierarchy', false, ['E1'])).rejects.toThrow('dimensionName is required when passing subset name as string');
96
+ });
97
+ test('should default hierarchyName to dimensionName for string argument', async () => {
98
+ mockRestService.put.mockResolvedValue(createMockResponse({}, 200));
99
+ await subsetService.updateStaticElements('MySubset', 'TestDimension', undefined, // no hierarchyName — should default to dimensionName
100
+ false, ['E1']);
101
+ const [url] = mockRestService.put.mock.calls[0];
102
+ expect(url).toContain("Hierarchies('TestDimension')");
103
+ });
104
+ test('should send empty value array when no elements provided', async () => {
105
+ mockRestService.put.mockResolvedValue(createMockResponse({}, 200));
106
+ await subsetService.updateStaticElements('EmptySubset', 'TestDimension', 'TestHierarchy', false, []);
107
+ const [, body] = mockRestService.put.mock.calls[0];
108
+ const parsed = JSON.parse(body);
109
+ expect(parsed.value).toEqual([]);
110
+ });
111
+ });
112
+ });
113
+ //# sourceMappingURL=subsetService.issue38.test.js.map
@@ -7,6 +7,7 @@
7
7
  Object.defineProperty(exports, "__esModule", { value: true });
8
8
  const TM1Service_1 = require("../services/TM1Service");
9
9
  const RestService_1 = require("../services/RestService");
10
+ const User_1 = require("../objects/User");
10
11
  // Mock all service dependencies
11
12
  jest.mock('../services/RestService');
12
13
  jest.mock('../services/DimensionService');
@@ -22,6 +23,19 @@ jest.mock('../services/FileService');
22
23
  jest.mock('../services/SessionService');
23
24
  jest.mock('../services/ServerService');
24
25
  jest.mock('../services/MonitoringService');
26
+ jest.mock('../services/AnnotationService');
27
+ jest.mock('../services/ChoreService');
28
+ jest.mock('../services/GitService');
29
+ jest.mock('../services/ApplicationService');
30
+ jest.mock('../services/SandboxService');
31
+ jest.mock('../services/JobService');
32
+ jest.mock('../services/UserService');
33
+ jest.mock('../services/ThreadService');
34
+ jest.mock('../services/TransactionLogService');
35
+ jest.mock('../services/MessageLogService');
36
+ jest.mock('../services/ConfigurationService');
37
+ jest.mock('../services/AuditLogService');
38
+ jest.mock('../services/LoggerService');
25
39
  describe('TM1Service', () => {
26
40
  let tm1Service;
27
41
  let mockRestService;
@@ -56,6 +70,8 @@ describe('TM1Service', () => {
56
70
  setSandbox: jest.fn(),
57
71
  getSandbox: jest.fn().mockReturnValue('test-sandbox'),
58
72
  isLoggedIn: jest.fn().mockReturnValue(true),
73
+ getVersion: jest.fn().mockResolvedValue('12.0.0'),
74
+ version: undefined,
59
75
  };
60
76
  // Mock RestService constructor
61
77
  RestService_1.RestService.mockImplementation(() => mockRestService);
@@ -75,6 +91,7 @@ describe('TM1Service', () => {
75
91
  expect(tm1Service.security).toBeDefined();
76
92
  expect(tm1Service.files).toBeDefined();
77
93
  expect(tm1Service.sessions).toBeDefined();
94
+ expect(tm1Service.applications).toBeDefined();
78
95
  });
79
96
  test('should create RestService with provided config', () => {
80
97
  expect(RestService_1.RestService).toHaveBeenCalledWith(mockConfig);
@@ -123,14 +140,16 @@ describe('TM1Service', () => {
123
140
  });
124
141
  });
125
142
  describe('User and Authentication', () => {
126
- test('should get current user with whoami', async () => {
127
- // Mock security service getCurrentUser method
143
+ test('should get current user as User object with whoami', async () => {
144
+ const expectedUser = new User_1.User('test-user', ['ADMIN'], 'Test User', undefined, User_1.UserType.Admin, true);
128
145
  const mockSecurityService = {
129
- getCurrentUser: jest.fn().mockResolvedValue({ name: 'test-user' })
146
+ getCurrentUser: jest.fn().mockResolvedValue(expectedUser)
130
147
  };
131
148
  tm1Service.security = mockSecurityService;
132
149
  const result = await tm1Service.whoami();
133
- expect(result).toBe('test-user');
150
+ expect(result).toBeInstanceOf(User_1.User);
151
+ expect(result.name).toBe('test-user');
152
+ expect(result).toBe(expectedUser);
134
153
  expect(mockSecurityService.getCurrentUser).toHaveBeenCalledTimes(1);
135
154
  });
136
155
  test('should check if user is logged in', () => {
@@ -157,11 +176,11 @@ describe('TM1Service', () => {
157
176
  expect(result).toEqual({ metadata: 'test-metadata' });
158
177
  expect(mockRestService.get).toHaveBeenCalledWith('/$metadata');
159
178
  });
160
- test('should get TM1 version', async () => {
161
- mockRestService.get.mockResolvedValueOnce(mockResponse({ value: '12.0.0' }));
179
+ test('should get TM1 version via cached RestService.getVersion', async () => {
180
+ mockRestService.getVersion.mockResolvedValueOnce('12.0.0');
162
181
  const result = await tm1Service.getVersion();
163
182
  expect(result).toBe('12.0.0');
164
- expect(mockRestService.get).toHaveBeenCalledWith('/Configuration/ProductVersion');
183
+ expect(mockRestService.getVersion).toHaveBeenCalledTimes(1);
165
184
  });
166
185
  test('should handle metadata retrieval errors', async () => {
167
186
  const metadataError = new Error('Metadata not available');
@@ -170,7 +189,7 @@ describe('TM1Service', () => {
170
189
  });
171
190
  test('should handle version retrieval errors', async () => {
172
191
  const versionError = new Error('Version not available');
173
- mockRestService.get.mockRejectedValueOnce(versionError);
192
+ mockRestService.getVersion.mockRejectedValueOnce(versionError);
174
193
  await expect(tm1Service.getVersion()).rejects.toThrow('Version not available');
175
194
  });
176
195
  });
@@ -286,5 +305,58 @@ describe('TM1Service', () => {
286
305
  expect(monitoring1).toBe(monitoring2);
287
306
  });
288
307
  });
308
+ describe('Lazy Services (Issue #82)', () => {
309
+ const lazyServiceNames = [
310
+ 'annotations',
311
+ 'chores',
312
+ 'git',
313
+ 'sandboxes',
314
+ 'jobs',
315
+ 'users',
316
+ 'threads',
317
+ 'transactionLogs',
318
+ 'messageLogs',
319
+ 'configuration',
320
+ 'auditLogs',
321
+ 'loggers',
322
+ ];
323
+ test.each(lazyServiceNames)('should lazy-initialize %s service', (serviceName) => {
324
+ const instance = tm1Service[serviceName];
325
+ expect(instance).toBeDefined();
326
+ });
327
+ test.each(lazyServiceNames)('should cache %s service instance across accesses', (serviceName) => {
328
+ const first = tm1Service[serviceName];
329
+ const second = tm1Service[serviceName];
330
+ expect(second).toBe(first);
331
+ });
332
+ });
333
+ describe('Version Getter (Issue #82)', () => {
334
+ test('should expose cached version via sync getter', () => {
335
+ Object.defineProperty(mockRestService, 'version', {
336
+ get: () => '11.8.0',
337
+ configurable: true,
338
+ });
339
+ expect(tm1Service.version).toBe('11.8.0');
340
+ });
341
+ test('should return undefined when version has not been fetched yet', () => {
342
+ Object.defineProperty(mockRestService, 'version', {
343
+ get: () => undefined,
344
+ configurable: true,
345
+ });
346
+ expect(tm1Service.version).toBeUndefined();
347
+ });
348
+ });
349
+ describe('reConnect (Issue #82)', () => {
350
+ test('should call connect without disconnecting (tm1py parity)', async () => {
351
+ await tm1Service.reConnect();
352
+ expect(mockRestService.connect).toHaveBeenCalledTimes(1);
353
+ expect(mockRestService.disconnect).not.toHaveBeenCalled();
354
+ });
355
+ test('should propagate errors from connect', async () => {
356
+ mockRestService.connect.mockRejectedValueOnce(new Error('Connect failed'));
357
+ await expect(tm1Service.reConnect()).rejects.toThrow('Connect failed');
358
+ expect(mockRestService.connect).toHaveBeenCalledTimes(1);
359
+ });
360
+ });
289
361
  });
290
362
  //# sourceMappingURL=tm1Service.test.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tm1npm",
3
- "version": "1.5.3",
3
+ "version": "2.0.0",
4
4
  "description": "A Node.js module for TM1",
5
5
  "main": "lib/index.js",
6
6
  "types": "lib/index.d.ts",
package/src/index.ts CHANGED
@@ -11,7 +11,7 @@ export {
11
11
  CubeService,
12
12
  ElementService,
13
13
  CellService,
14
- ProcessService,
14
+ ProcessService, CompileSyntaxError,
15
15
  ViewService,
16
16
  SecurityService,
17
17
  FileService,
@@ -1,4 +1,4 @@
1
- import { AxiosRequestConfig, AxiosResponse } from 'axios';
1
+ import { AxiosResponse } from 'axios';
2
2
  import { promises as fs } from 'fs';
3
3
  import { RestService } from './RestService';
4
4
  import { ObjectService } from './ObjectService';
@@ -17,12 +17,19 @@ import {
17
17
  getApplicationMetadata
18
18
  } from '../objects/Application';
19
19
  import { formatUrl, verifyVersion } from '../utils/Utils';
20
+ import { TM1RestException } from '../exceptions/TM1Exception';
20
21
 
21
22
  export class ApplicationService extends ObjectService {
23
+ private pathCache: Map<string, number> = new Map();
24
+
22
25
  constructor(rest: RestService) {
23
26
  super(rest);
24
27
  }
25
28
 
29
+ public clearPathCache(): void {
30
+ this.pathCache.clear();
31
+ }
32
+
26
33
  public async getAllPublicRootNames(): Promise<string[]> {
27
34
  const url = "/Contents('Applications')/Contents";
28
35
  const response = await this.rest.get(url);
@@ -35,7 +42,14 @@ export class ApplicationService extends ObjectService {
35
42
  return response.data.value.map((application: any) => application.Name);
36
43
  }
37
44
 
38
- public async getNames(path: string, isPrivate: boolean = false): Promise<string[]> {
45
+ public async getNames(path: string, isPrivate: boolean = false, useCache: boolean = false): Promise<string[]> {
46
+ if (isPrivate) {
47
+ const resolved = await this._resolvePath(path, true, useCache);
48
+ // Leaf collection is always PrivateContents when isPrivate — even if parent path is public
49
+ const url = resolved.baseUrl + '/PrivateContents';
50
+ const response = await this.rest.get(url);
51
+ return response.data.value.map((application: any) => application.Name);
52
+ }
39
53
  const contents = this.getContentsCollection(isPrivate);
40
54
  const mid = this.buildPathSegments(path);
41
55
  const baseUrl = "/Contents('Applications')" + mid + "/" + contents;
@@ -47,7 +61,8 @@ export class ApplicationService extends ObjectService {
47
61
  path: string,
48
62
  applicationType: string | ApplicationTypes,
49
63
  name: string,
50
- isPrivate: boolean = false
64
+ isPrivate: boolean = false,
65
+ useCache: boolean = false
51
66
  ): Promise<Application> {
52
67
  const appType = this.parseApplicationType(applicationType);
53
68
 
@@ -56,7 +71,13 @@ export class ApplicationService extends ObjectService {
56
71
  }
57
72
 
58
73
  const requestName = this.withLegacySuffix(name, appType);
59
- const baseUrl = this.buildApplicationUrl(path, isPrivate, requestName);
74
+ let baseUrl: string;
75
+ if (isPrivate) {
76
+ const resolved = await this._resolvePath(path, true, useCache);
77
+ baseUrl = formatUrl(resolved.baseUrl + "/PrivateContents('{}')", requestName);
78
+ } else {
79
+ baseUrl = this.buildApplicationUrl(path, isPrivate, requestName);
80
+ }
60
81
 
61
82
  switch (appType) {
62
83
  case ApplicationTypes.CUBE: {
@@ -79,7 +100,6 @@ export class ApplicationService extends ObjectService {
79
100
  return new FolderApplication(path, name);
80
101
  }
81
102
  case ApplicationTypes.LINK: {
82
- await this.rest.get(baseUrl);
83
103
  const response = await this.rest.get(baseUrl + "?$expand=*");
84
104
  return new LinkApplication(path, response.data?.Name || name, response.data?.URL || '');
85
105
  }
@@ -132,7 +152,7 @@ export class ApplicationService extends ObjectService {
132
152
  requestName
133
153
  );
134
154
 
135
- const arrayBufferResponse = await this.rest.get(contentUrl, { responseType: 'arraybuffer' } as AxiosRequestConfig);
155
+ const arrayBufferResponse = await this.rest.get(contentUrl, { responseType: 'arraybuffer' });
136
156
  const metadataResponse = await this.rest.get(metadataUrl);
137
157
 
138
158
  const buffer = Buffer.from(arrayBufferResponse.data);
@@ -163,7 +183,7 @@ export class ApplicationService extends ObjectService {
163
183
  );
164
184
  await this.rest.put(contentUrl, application.content, {
165
185
  headers: this.binaryHttpHeader
166
- } as AxiosRequestConfig);
186
+ });
167
187
  }
168
188
 
169
189
  return response;
@@ -184,7 +204,7 @@ export class ApplicationService extends ObjectService {
184
204
  );
185
205
  return await this.rest.post(url, application.content, {
186
206
  headers: this.binaryHttpHeader
187
- } as AxiosRequestConfig);
207
+ });
188
208
  }
189
209
 
190
210
  const url = formatUrl(
@@ -232,11 +252,28 @@ export class ApplicationService extends ObjectService {
232
252
  path: string,
233
253
  applicationType: ApplicationTypes,
234
254
  name: string,
235
- isPrivate: boolean = false
255
+ isPrivate: boolean = false,
256
+ useCache: boolean = false
236
257
  ): Promise<boolean> {
258
+ const requestName = this.withLegacySuffix(name, applicationType);
259
+ if (isPrivate) {
260
+ try {
261
+ const resolved = await this._resolvePath(path, true, useCache);
262
+ const url = formatUrl(resolved.baseUrl + "/PrivateContents('{}')", requestName);
263
+ return await this._exists(url);
264
+ } catch (error: any) {
265
+ // Path-not-found from _resolvePath means item doesn't exist
266
+ if (error instanceof Error && error.message.includes('not found')) {
267
+ return false;
268
+ }
269
+ if (error instanceof TM1RestException && error.statusCode === 404) {
270
+ return false;
271
+ }
272
+ throw error;
273
+ }
274
+ }
237
275
  const contents = this.getContentsCollection(isPrivate);
238
276
  const mid = this.buildPathSegments(path);
239
- const requestName = this.withLegacySuffix(name, applicationType);
240
277
  const url = formatUrl(
241
278
  "/Contents('Applications')" + mid + "/" + contents + "('{}')",
242
279
  requestName
@@ -287,6 +324,230 @@ export class ApplicationService extends ObjectService {
287
324
  return await this.update(document, isPrivate);
288
325
  }
289
326
 
327
+ // NOTE: `flat` parameter is accepted for tm1py signature parity but output is always flat.
328
+ // Nested (tree) mode is not yet implemented.
329
+ public async discover(
330
+ path: string = '',
331
+ includePrivate: boolean = false,
332
+ recursive: boolean = false,
333
+ _flat: boolean = false
334
+ ): Promise<Array<{ type: string; name: string; path: string; is_private: boolean }>> {
335
+ return this._discoverAtPath(path, includePrivate, recursive, false);
336
+ }
337
+
338
+ private async _discoverAtPath(
339
+ path: string,
340
+ includePrivate: boolean,
341
+ recursive: boolean,
342
+ inPrivateContext: boolean
343
+ ): Promise<Array<{ type: string; name: string; path: string; is_private: boolean }>> {
344
+ const results: Array<{ type: string; name: string; path: string; is_private: boolean }> = [];
345
+
346
+ if (!inPrivateContext) {
347
+ const publicItems = await this._getContentsRaw(path, false, false);
348
+ const processed = await this._processItems(
349
+ publicItems, path, false, includePrivate, recursive, false
350
+ );
351
+ results.push(...processed);
352
+ }
353
+
354
+ if (includePrivate || inPrivateContext) {
355
+ const privateItems = await this._getContentsRaw(path, true, inPrivateContext);
356
+ const processed = await this._processItems(
357
+ privateItems, path, true, includePrivate, recursive, true
358
+ );
359
+ results.push(...processed);
360
+ }
361
+
362
+ return results;
363
+ }
364
+
365
+ private async _getContentsRaw(path: string, isPrivate: boolean, inPrivateContext: boolean): Promise<any[]> {
366
+ try {
367
+ // Path segments: use PrivateContents only if we're already inside a private folder tree.
368
+ // Leaf collection: PrivateContents if isPrivate or inPrivateContext, else Contents.
369
+ const mid = inPrivateContext
370
+ ? this.buildPrivatePathSegments(path)
371
+ : this.buildPathSegments(path);
372
+ const collection = (inPrivateContext || isPrivate) ? 'PrivateContents' : 'Contents';
373
+ const url = "/Contents('Applications')" + mid + "/" + collection;
374
+ const response = await this.rest.get(url);
375
+ return response.data?.value || [];
376
+ } catch (error: any) {
377
+ if (error instanceof TM1RestException && error.statusCode === 404) {
378
+ return [];
379
+ }
380
+ throw error;
381
+ }
382
+ }
383
+
384
+ private async _processItems(
385
+ items: any[],
386
+ path: string,
387
+ isPrivate: boolean,
388
+ includePrivate: boolean,
389
+ recursive: boolean,
390
+ inPrivateContext: boolean
391
+ ): Promise<Array<{ type: string; name: string; path: string; is_private: boolean }>> {
392
+ const results: Array<{ type: string; name: string; path: string; is_private: boolean }> = [];
393
+
394
+ const folderPromises: Array<Promise<Array<{ type: string; name: string; path: string; is_private: boolean }>>> = [];
395
+
396
+ for (const item of items) {
397
+ const odataType = item['@odata.type'] || '';
398
+ const typeName = this._extractTypeFromOdata(odataType);
399
+ const itemName = item.Name || '';
400
+ const itemPath = path ? `${path}/${itemName}` : itemName;
401
+
402
+ results.push({
403
+ type: typeName,
404
+ name: itemName,
405
+ path: itemPath,
406
+ is_private: isPrivate || inPrivateContext
407
+ });
408
+
409
+ if (recursive && typeName === 'Folder') {
410
+ folderPromises.push(
411
+ this._discoverAtPath(
412
+ itemPath, includePrivate, recursive, inPrivateContext || isPrivate
413
+ )
414
+ );
415
+ }
416
+ }
417
+
418
+ const folderResults = await Promise.all(folderPromises);
419
+ for (const subItems of folderResults) {
420
+ results.push(...subItems);
421
+ }
422
+
423
+ return results;
424
+ }
425
+
426
+ private _extractTypeFromOdata(odataType: string): string {
427
+ if (!odataType) return 'Unknown';
428
+ const parts = odataType.split('.');
429
+ const raw = parts[parts.length - 1] || 'Unknown';
430
+ // TM1 returns types like 'FolderApplication', 'CubeApplication' — strip suffix
431
+ // so folder check works consistently
432
+ return raw.replace(/Application$/, '') || raw;
433
+ }
434
+
435
+ private async _findPrivateBoundary(segments: string[]): Promise<number> {
436
+ let publicUrl = "/Contents('Applications')";
437
+ for (let i = 0; i < segments.length; i++) {
438
+ const testUrl = publicUrl + formatUrl("/Contents('{}')", segments[i]) + "?$top=0";
439
+ try {
440
+ await this.rest.get(testUrl);
441
+ publicUrl += formatUrl("/Contents('{}')", segments[i]);
442
+ } catch (error: any) {
443
+ if (error instanceof TM1RestException && error.statusCode === 404) {
444
+ const privateTestUrl = publicUrl + formatUrl("/PrivateContents('{}')", segments[i]) + "?$top=0";
445
+ try {
446
+ await this.rest.get(privateTestUrl);
447
+ return i;
448
+ } catch (innerError: any) {
449
+ if (innerError instanceof TM1RestException && innerError.statusCode === 404) {
450
+ return -1;
451
+ }
452
+ throw innerError;
453
+ }
454
+ }
455
+ throw error;
456
+ }
457
+ }
458
+ return segments.length;
459
+ }
460
+
461
+ private _buildPathUrl(segments: string[], privateBoundary?: number): string {
462
+ if (!segments.length) return '';
463
+
464
+ let url = '';
465
+ const boundary = privateBoundary ?? segments.length;
466
+
467
+ for (let i = 0; i < segments.length; i++) {
468
+ if (i < boundary) {
469
+ url += formatUrl("/Contents('{}')", segments[i]);
470
+ } else {
471
+ url += formatUrl("/PrivateContents('{}')", segments[i]);
472
+ }
473
+ }
474
+ return url;
475
+ }
476
+
477
+ private async _resolvePath(
478
+ path: string,
479
+ isPrivate: boolean = false,
480
+ useCache: boolean = false
481
+ ): Promise<{ baseUrl: string; inPrivateContext: boolean }> {
482
+ if (!path || !path.trim()) {
483
+ return { baseUrl: "/Contents('Applications')", inPrivateContext: isPrivate };
484
+ }
485
+
486
+ const segments = path.split('/').filter(s => s.trim().length > 0);
487
+
488
+ if (!isPrivate) {
489
+ const mid = segments.map(s => formatUrl("/Contents('{}')", s)).join('');
490
+ return { baseUrl: "/Contents('Applications')" + mid, inPrivateContext: false };
491
+ }
492
+
493
+ const cacheKey = path;
494
+ if (useCache && this.pathCache.has(cacheKey)) {
495
+ const boundary = this.pathCache.get(cacheKey)!;
496
+ const mid = this._buildPathUrl(segments, boundary);
497
+ return {
498
+ baseUrl: "/Contents('Applications')" + mid,
499
+ inPrivateContext: boundary < segments.length
500
+ };
501
+ }
502
+
503
+ // Try all-public first (optimistic)
504
+ try {
505
+ const publicUrl = "/Contents('Applications')" +
506
+ segments.map(s => formatUrl("/Contents('{}')", s)).join('') + "?$top=0";
507
+ await this.rest.get(publicUrl);
508
+ if (useCache) this.pathCache.set(cacheKey, segments.length);
509
+ return {
510
+ baseUrl: "/Contents('Applications')" +
511
+ segments.map(s => formatUrl("/Contents('{}')", s)).join(''),
512
+ inPrivateContext: false
513
+ };
514
+ } catch (error: any) {
515
+ if (!(error instanceof TM1RestException && error.statusCode === 404)) {
516
+ throw error;
517
+ }
518
+ }
519
+
520
+ // Try all-private
521
+ try {
522
+ const privateUrl = "/Contents('Applications')" +
523
+ segments.map(s => formatUrl("/PrivateContents('{}')", s)).join('') + "?$top=0";
524
+ await this.rest.get(privateUrl);
525
+ if (useCache) this.pathCache.set(cacheKey, 0);
526
+ return {
527
+ baseUrl: "/Contents('Applications')" +
528
+ segments.map(s => formatUrl("/PrivateContents('{}')", s)).join(''),
529
+ inPrivateContext: true
530
+ };
531
+ } catch (error: any) {
532
+ if (!(error instanceof TM1RestException && error.statusCode === 404)) {
533
+ throw error;
534
+ }
535
+ }
536
+
537
+ // Iterative boundary search
538
+ const boundary = await this._findPrivateBoundary(segments);
539
+ if (boundary === -1) {
540
+ throw new Error(`Application path not found: ${path}`);
541
+ }
542
+
543
+ if (useCache) this.pathCache.set(cacheKey, boundary);
544
+ const mid = this._buildPathUrl(segments, boundary);
545
+ return {
546
+ baseUrl: "/Contents('Applications')" + mid,
547
+ inPrivateContext: boundary < segments.length
548
+ };
549
+ }
550
+
290
551
  private parseApplicationType(applicationType: string | ApplicationTypes): ApplicationTypes {
291
552
  if (typeof applicationType === 'string') {
292
553
  const upper = applicationType.toUpperCase();
@@ -309,6 +570,17 @@ export class ApplicationService extends ObjectService {
309
570
  .join('');
310
571
  }
311
572
 
573
+ private buildPrivatePathSegments(path: string): string {
574
+ if (!path || !path.trim()) {
575
+ return '';
576
+ }
577
+ return path
578
+ .split('/')
579
+ .filter(segment => segment.trim().length > 0)
580
+ .map(segment => formatUrl("/PrivateContents('{}')", segment))
581
+ .join('');
582
+ }
583
+
312
584
  private getContentsCollection(isPrivate: boolean): string {
313
585
  return isPrivate ? 'PrivateContents' : 'Contents';
314
586
  }