@xrmforge/typegen 0.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/dist/index.d.ts +1295 -0
- package/dist/index.js +2281 -0
- package/dist/index.js.map +1 -0
- package/package.json +63 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,2281 @@
|
|
|
1
|
+
// src/errors.ts
|
|
2
|
+
var ErrorCode = /* @__PURE__ */ ((ErrorCode2) => {
|
|
3
|
+
ErrorCode2["AUTH_MISSING_CONFIG"] = "AUTH_1001";
|
|
4
|
+
ErrorCode2["AUTH_INVALID_CREDENTIALS"] = "AUTH_1002";
|
|
5
|
+
ErrorCode2["AUTH_TOKEN_FAILED"] = "AUTH_1003";
|
|
6
|
+
ErrorCode2["AUTH_TOKEN_EXPIRED"] = "AUTH_1004";
|
|
7
|
+
ErrorCode2["API_REQUEST_FAILED"] = "API_2001";
|
|
8
|
+
ErrorCode2["API_RATE_LIMITED"] = "API_2002";
|
|
9
|
+
ErrorCode2["API_NOT_FOUND"] = "API_2003";
|
|
10
|
+
ErrorCode2["API_UNAUTHORIZED"] = "API_2004";
|
|
11
|
+
ErrorCode2["API_TIMEOUT"] = "API_2005";
|
|
12
|
+
ErrorCode2["META_ENTITY_NOT_FOUND"] = "META_3001";
|
|
13
|
+
ErrorCode2["META_SOLUTION_NOT_FOUND"] = "META_3002";
|
|
14
|
+
ErrorCode2["META_FORM_PARSE_FAILED"] = "META_3003";
|
|
15
|
+
ErrorCode2["META_ATTRIBUTE_UNKNOWN_TYPE"] = "META_3004";
|
|
16
|
+
ErrorCode2["GEN_OUTPUT_WRITE_FAILED"] = "GEN_4001";
|
|
17
|
+
ErrorCode2["GEN_TEMPLATE_FAILED"] = "GEN_4002";
|
|
18
|
+
ErrorCode2["GEN_INVALID_IDENTIFIER"] = "GEN_4003";
|
|
19
|
+
ErrorCode2["CONFIG_INVALID"] = "CONFIG_5001";
|
|
20
|
+
ErrorCode2["CONFIG_FILE_NOT_FOUND"] = "CONFIG_5002";
|
|
21
|
+
ErrorCode2["CONFIG_ENV_VAR_MISSING"] = "CONFIG_5003";
|
|
22
|
+
return ErrorCode2;
|
|
23
|
+
})(ErrorCode || {});
|
|
24
|
+
var XrmForgeError = class _XrmForgeError extends Error {
|
|
25
|
+
code;
|
|
26
|
+
context;
|
|
27
|
+
constructor(code, message, context = {}) {
|
|
28
|
+
super(`[${code}] ${message}`);
|
|
29
|
+
this.name = "XrmForgeError";
|
|
30
|
+
this.code = code;
|
|
31
|
+
this.context = context;
|
|
32
|
+
if (Error.captureStackTrace) {
|
|
33
|
+
Error.captureStackTrace(this, _XrmForgeError);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
var AuthenticationError = class extends XrmForgeError {
|
|
38
|
+
constructor(code, message, context = {}) {
|
|
39
|
+
super(code, message, context);
|
|
40
|
+
this.name = "AuthenticationError";
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
var ApiRequestError = class extends XrmForgeError {
|
|
44
|
+
statusCode;
|
|
45
|
+
responseBody;
|
|
46
|
+
constructor(code, message, context = {}) {
|
|
47
|
+
super(code, message, context);
|
|
48
|
+
this.name = "ApiRequestError";
|
|
49
|
+
this.statusCode = context.statusCode;
|
|
50
|
+
this.responseBody = context.responseBody;
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
var MetadataError = class extends XrmForgeError {
|
|
54
|
+
constructor(code, message, context = {}) {
|
|
55
|
+
super(code, message, context);
|
|
56
|
+
this.name = "MetadataError";
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
var GenerationError = class extends XrmForgeError {
|
|
60
|
+
constructor(code, message, context = {}) {
|
|
61
|
+
super(code, message, context);
|
|
62
|
+
this.name = "GenerationError";
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
var ConfigError = class extends XrmForgeError {
|
|
66
|
+
constructor(code, message, context = {}) {
|
|
67
|
+
super(code, message, context);
|
|
68
|
+
this.name = "ConfigError";
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
function isXrmForgeError(error) {
|
|
72
|
+
return error instanceof XrmForgeError;
|
|
73
|
+
}
|
|
74
|
+
function isRateLimitError(error) {
|
|
75
|
+
return error instanceof ApiRequestError && error.code === "API_2002" /* API_RATE_LIMITED */;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// src/logger.ts
|
|
79
|
+
var LogLevel = /* @__PURE__ */ ((LogLevel2) => {
|
|
80
|
+
LogLevel2[LogLevel2["DEBUG"] = 0] = "DEBUG";
|
|
81
|
+
LogLevel2[LogLevel2["INFO"] = 1] = "INFO";
|
|
82
|
+
LogLevel2[LogLevel2["WARN"] = 2] = "WARN";
|
|
83
|
+
LogLevel2[LogLevel2["ERROR"] = 3] = "ERROR";
|
|
84
|
+
LogLevel2[LogLevel2["SILENT"] = 4] = "SILENT";
|
|
85
|
+
return LogLevel2;
|
|
86
|
+
})(LogLevel || {});
|
|
87
|
+
var ConsoleLogSink = class _ConsoleLogSink {
|
|
88
|
+
static LEVEL_PREFIX = {
|
|
89
|
+
[0 /* DEBUG */]: "\x1B[90m[DBG]\x1B[0m",
|
|
90
|
+
[1 /* INFO */]: "\x1B[36m[INF]\x1B[0m",
|
|
91
|
+
[2 /* WARN */]: "\x1B[33m[WRN]\x1B[0m",
|
|
92
|
+
[3 /* ERROR */]: "\x1B[31m[ERR]\x1B[0m",
|
|
93
|
+
[4 /* SILENT */]: ""
|
|
94
|
+
};
|
|
95
|
+
write(entry) {
|
|
96
|
+
if (entry.level === 4 /* SILENT */) return;
|
|
97
|
+
const prefix = _ConsoleLogSink.LEVEL_PREFIX[entry.level];
|
|
98
|
+
const scope = `\x1B[90m[${entry.scope}]\x1B[0m`;
|
|
99
|
+
const message = `${prefix} ${scope} ${entry.message}`;
|
|
100
|
+
switch (entry.level) {
|
|
101
|
+
case 3 /* ERROR */:
|
|
102
|
+
console.error(message);
|
|
103
|
+
break;
|
|
104
|
+
case 2 /* WARN */:
|
|
105
|
+
console.warn(message);
|
|
106
|
+
break;
|
|
107
|
+
default:
|
|
108
|
+
console.log(message);
|
|
109
|
+
}
|
|
110
|
+
if (entry.context && entry.level === 0 /* DEBUG */) {
|
|
111
|
+
console.log(" Context:", JSON.stringify(entry.context, null, 2));
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
writeProgress(message) {
|
|
115
|
+
process.stdout.write(message);
|
|
116
|
+
}
|
|
117
|
+
writeProgressEnd(message) {
|
|
118
|
+
console.log(message);
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
var JsonLogSink = class {
|
|
122
|
+
write(entry) {
|
|
123
|
+
if (entry.level === 4 /* SILENT */) return;
|
|
124
|
+
const output = {
|
|
125
|
+
timestamp: entry.timestamp.toISOString(),
|
|
126
|
+
level: LogLevel[entry.level],
|
|
127
|
+
scope: entry.scope,
|
|
128
|
+
message: entry.message,
|
|
129
|
+
...entry.context ? { context: entry.context } : {}
|
|
130
|
+
};
|
|
131
|
+
console.log(JSON.stringify(output));
|
|
132
|
+
}
|
|
133
|
+
writeProgress(message) {
|
|
134
|
+
console.log(JSON.stringify({
|
|
135
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
136
|
+
level: "INFO",
|
|
137
|
+
scope: "progress",
|
|
138
|
+
message: message.trim()
|
|
139
|
+
}));
|
|
140
|
+
}
|
|
141
|
+
writeProgressEnd(message) {
|
|
142
|
+
console.log(JSON.stringify({
|
|
143
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
144
|
+
level: "INFO",
|
|
145
|
+
scope: "progress",
|
|
146
|
+
message: message.trim()
|
|
147
|
+
}));
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
var SilentLogSink = class {
|
|
151
|
+
write(_entry) {
|
|
152
|
+
}
|
|
153
|
+
writeProgress(_message) {
|
|
154
|
+
}
|
|
155
|
+
writeProgressEnd(_message) {
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
var Logger = class {
|
|
159
|
+
scope;
|
|
160
|
+
getSink;
|
|
161
|
+
getMinLevel;
|
|
162
|
+
constructor(scope, getSink, getMinLevel) {
|
|
163
|
+
this.scope = scope;
|
|
164
|
+
this.getSink = getSink;
|
|
165
|
+
this.getMinLevel = getMinLevel;
|
|
166
|
+
}
|
|
167
|
+
debug(message, context) {
|
|
168
|
+
this.log(0 /* DEBUG */, message, context);
|
|
169
|
+
}
|
|
170
|
+
info(message, context) {
|
|
171
|
+
this.log(1 /* INFO */, message, context);
|
|
172
|
+
}
|
|
173
|
+
warn(message, context) {
|
|
174
|
+
this.log(2 /* WARN */, message, context);
|
|
175
|
+
}
|
|
176
|
+
error(message, context) {
|
|
177
|
+
this.log(3 /* ERROR */, message, context);
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Write an inline progress update (no newline).
|
|
181
|
+
*/
|
|
182
|
+
progress(message) {
|
|
183
|
+
if (this.getMinLevel() > 1 /* INFO */) return;
|
|
184
|
+
this.getSink().writeProgress(message);
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Complete an inline progress line.
|
|
188
|
+
*/
|
|
189
|
+
progressEnd(message) {
|
|
190
|
+
if (this.getMinLevel() > 1 /* INFO */) return;
|
|
191
|
+
this.getSink().writeProgressEnd(message);
|
|
192
|
+
}
|
|
193
|
+
log(level, message, context) {
|
|
194
|
+
if (level < this.getMinLevel()) return;
|
|
195
|
+
this.getSink().write({
|
|
196
|
+
level,
|
|
197
|
+
scope: this.scope,
|
|
198
|
+
message,
|
|
199
|
+
context,
|
|
200
|
+
timestamp: /* @__PURE__ */ new Date()
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
};
|
|
204
|
+
var _sharedSink = new ConsoleLogSink();
|
|
205
|
+
var _sharedMinLevel = 1 /* INFO */;
|
|
206
|
+
function configureLogging(options) {
|
|
207
|
+
if (options.sink !== void 0) _sharedSink = options.sink;
|
|
208
|
+
if (options.minLevel !== void 0) _sharedMinLevel = options.minLevel;
|
|
209
|
+
}
|
|
210
|
+
function createLogger(scope) {
|
|
211
|
+
return new Logger(
|
|
212
|
+
scope,
|
|
213
|
+
() => _sharedSink,
|
|
214
|
+
() => _sharedMinLevel
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// src/auth/credential.ts
|
|
219
|
+
import {
|
|
220
|
+
ClientSecretCredential,
|
|
221
|
+
InteractiveBrowserCredential,
|
|
222
|
+
DeviceCodeCredential
|
|
223
|
+
} from "@azure/identity";
|
|
224
|
+
var log = createLogger("auth");
|
|
225
|
+
var DEFAULT_CLIENT_ID = "51f81489-12ee-4a9e-aaae-a2591f45987d";
|
|
226
|
+
function createCredential(config) {
|
|
227
|
+
switch (config.method) {
|
|
228
|
+
case "client-credentials":
|
|
229
|
+
return createClientCredential(config);
|
|
230
|
+
case "interactive":
|
|
231
|
+
return createInteractiveCredential(config);
|
|
232
|
+
case "device-code":
|
|
233
|
+
return createDeviceCodeCredential(config);
|
|
234
|
+
case "token":
|
|
235
|
+
return createStaticTokenCredential(config);
|
|
236
|
+
default: {
|
|
237
|
+
const exhaustiveCheck = config;
|
|
238
|
+
throw new AuthenticationError(
|
|
239
|
+
"AUTH_1001" /* AUTH_MISSING_CONFIG */,
|
|
240
|
+
`Unknown authentication method: "${exhaustiveCheck.method}". Supported methods: client-credentials, interactive, device-code.`
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
function createClientCredential(config) {
|
|
246
|
+
const missing = [];
|
|
247
|
+
if (!config.tenantId?.trim()) missing.push("tenantId");
|
|
248
|
+
if (!config.clientId?.trim()) missing.push("clientId");
|
|
249
|
+
if (!config.clientSecret?.trim()) missing.push("clientSecret");
|
|
250
|
+
if (missing.length > 0) {
|
|
251
|
+
throw new AuthenticationError(
|
|
252
|
+
"AUTH_1001" /* AUTH_MISSING_CONFIG */,
|
|
253
|
+
`Client credentials authentication requires: ${missing.join(", ")}. These can be set in xrmforge.config.json or via environment variables (XRMFORGE_TENANT_ID, XRMFORGE_CLIENT_ID, XRMFORGE_CLIENT_SECRET).`,
|
|
254
|
+
{ missingFields: missing }
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
log.debug("Creating client credentials (Service Principal)", {
|
|
258
|
+
tenantId: config.tenantId,
|
|
259
|
+
clientId: config.clientId
|
|
260
|
+
});
|
|
261
|
+
return new ClientSecretCredential(config.tenantId, config.clientId, config.clientSecret);
|
|
262
|
+
}
|
|
263
|
+
function createInteractiveCredential(config) {
|
|
264
|
+
const clientId = config.clientId?.trim() || DEFAULT_CLIENT_ID;
|
|
265
|
+
log.debug("Creating interactive browser credential", {
|
|
266
|
+
clientId,
|
|
267
|
+
tenantId: config.tenantId ?? "common",
|
|
268
|
+
usingDefaultClientId: !config.clientId
|
|
269
|
+
});
|
|
270
|
+
return new InteractiveBrowserCredential({
|
|
271
|
+
tenantId: config.tenantId,
|
|
272
|
+
clientId
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
function createDeviceCodeCredential(config) {
|
|
276
|
+
const clientId = config.clientId?.trim() || DEFAULT_CLIENT_ID;
|
|
277
|
+
log.debug("Creating device code credential", {
|
|
278
|
+
clientId,
|
|
279
|
+
tenantId: config.tenantId ?? "common",
|
|
280
|
+
usingDefaultClientId: !config.clientId
|
|
281
|
+
});
|
|
282
|
+
return new DeviceCodeCredential({
|
|
283
|
+
tenantId: config.tenantId,
|
|
284
|
+
clientId,
|
|
285
|
+
userPromptCallback: (info) => {
|
|
286
|
+
log.info("Device Code Authentication required:");
|
|
287
|
+
log.info(info.message);
|
|
288
|
+
}
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
function createStaticTokenCredential(config) {
|
|
292
|
+
if (!config.token?.trim()) {
|
|
293
|
+
throw new AuthenticationError(
|
|
294
|
+
"AUTH_1001" /* AUTH_MISSING_CONFIG */,
|
|
295
|
+
"Token authentication requires a non-empty token. Set XRMFORGE_TOKEN environment variable or use --token flag."
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
log.debug("Using pre-acquired token (static credential)");
|
|
299
|
+
return new StaticTokenCredential(config.token);
|
|
300
|
+
}
|
|
301
|
+
var StaticTokenCredential = class {
|
|
302
|
+
token;
|
|
303
|
+
constructor(token) {
|
|
304
|
+
this.token = token;
|
|
305
|
+
}
|
|
306
|
+
async getToken() {
|
|
307
|
+
return {
|
|
308
|
+
token: this.token,
|
|
309
|
+
expiresOnTimestamp: Date.now() + 60 * 60 * 1e3
|
|
310
|
+
// Pretend 1 hour validity
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
};
|
|
314
|
+
|
|
315
|
+
// src/http/client.ts
|
|
316
|
+
var log2 = createLogger("http");
|
|
317
|
+
var TOKEN_BUFFER_MS = 5 * 60 * 1e3;
|
|
318
|
+
var MAX_BACKOFF_MS = 6e4;
|
|
319
|
+
var MAX_RESPONSE_BODY_LENGTH = 2e3;
|
|
320
|
+
var MAX_ERROR_VALUE_LENGTH = 100;
|
|
321
|
+
var DEFAULT_MAX_RATE_LIMIT_RETRIES = 10;
|
|
322
|
+
var DataverseHttpClient = class {
|
|
323
|
+
baseUrl;
|
|
324
|
+
apiVersion;
|
|
325
|
+
credential;
|
|
326
|
+
maxRetries;
|
|
327
|
+
retryBaseDelayMs;
|
|
328
|
+
timeoutMs;
|
|
329
|
+
maxConcurrency;
|
|
330
|
+
maxPages;
|
|
331
|
+
maxRateLimitRetries;
|
|
332
|
+
readOnly;
|
|
333
|
+
cachedToken = null;
|
|
334
|
+
// Semaphore for concurrency control (non-recursive)
|
|
335
|
+
activeConcurrentRequests = 0;
|
|
336
|
+
waitQueue = [];
|
|
337
|
+
constructor(options) {
|
|
338
|
+
this.baseUrl = options.environmentUrl.replace(/\/$/, "");
|
|
339
|
+
this.apiVersion = options.apiVersion ?? "v9.2";
|
|
340
|
+
this.credential = options.credential;
|
|
341
|
+
this.maxRetries = options.maxRetries ?? 3;
|
|
342
|
+
this.retryBaseDelayMs = options.retryBaseDelayMs ?? 1e3;
|
|
343
|
+
this.timeoutMs = options.timeoutMs ?? 3e4;
|
|
344
|
+
this.maxConcurrency = options.maxConcurrency ?? 5;
|
|
345
|
+
this.maxPages = options.maxPages ?? 100;
|
|
346
|
+
this.maxRateLimitRetries = options.maxRateLimitRetries ?? DEFAULT_MAX_RATE_LIMIT_RETRIES;
|
|
347
|
+
this.readOnly = options.readOnly ?? true;
|
|
348
|
+
}
|
|
349
|
+
/**
|
|
350
|
+
* Full API base URL, e.g. "https://myorg.crm4.dynamics.com/api/data/v9.2"
|
|
351
|
+
*/
|
|
352
|
+
get apiUrl() {
|
|
353
|
+
return `${this.baseUrl}/api/data/${this.apiVersion}`;
|
|
354
|
+
}
|
|
355
|
+
// ─── Public API ──────────────────────────────────────────────────────────
|
|
356
|
+
/**
|
|
357
|
+
* Execute a GET request against the Dataverse Web API.
|
|
358
|
+
* Handles token caching, retries, rate limits, and timeout.
|
|
359
|
+
*
|
|
360
|
+
* @param path - API path (relative or absolute URL)
|
|
361
|
+
* @param signal - Optional AbortSignal to cancel the request
|
|
362
|
+
*/
|
|
363
|
+
async get(path2, signal) {
|
|
364
|
+
const url = this.resolveUrl(path2);
|
|
365
|
+
return this.executeWithConcurrency(url, signal);
|
|
366
|
+
}
|
|
367
|
+
/**
|
|
368
|
+
* Execute a GET request and automatically follow @odata.nextLink for paging.
|
|
369
|
+
* Returns all pages combined into a single array.
|
|
370
|
+
*
|
|
371
|
+
* Safety: Stops after `maxPages` iterations to prevent infinite loops.
|
|
372
|
+
*
|
|
373
|
+
* @param path - API path (relative or absolute URL)
|
|
374
|
+
* @param signal - Optional AbortSignal to cancel the request
|
|
375
|
+
*/
|
|
376
|
+
async getAll(path2, signal) {
|
|
377
|
+
const allResults = [];
|
|
378
|
+
let currentUrl = this.resolveUrl(path2);
|
|
379
|
+
let page = 0;
|
|
380
|
+
while (currentUrl) {
|
|
381
|
+
if (signal?.aborted) {
|
|
382
|
+
throw new ApiRequestError(
|
|
383
|
+
"API_2001" /* API_REQUEST_FAILED */,
|
|
384
|
+
`Request aborted after ${page} pages (${allResults.length} records retrieved)`,
|
|
385
|
+
{ url: currentUrl, pagesCompleted: page }
|
|
386
|
+
);
|
|
387
|
+
}
|
|
388
|
+
page++;
|
|
389
|
+
if (page > this.maxPages) {
|
|
390
|
+
log2.warn(
|
|
391
|
+
`Stopped paging after ${this.maxPages} pages (safety limit). ${allResults.length} records retrieved. Increase maxPages if this is expected.`
|
|
392
|
+
);
|
|
393
|
+
break;
|
|
394
|
+
}
|
|
395
|
+
const response = await this.executeWithConcurrency(currentUrl, signal);
|
|
396
|
+
allResults.push(...response.value);
|
|
397
|
+
currentUrl = response["@odata.nextLink"] ?? null;
|
|
398
|
+
if (currentUrl) {
|
|
399
|
+
log2.debug(`Following @odata.nextLink (page ${page}), ${allResults.length} records so far`);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
return allResults;
|
|
403
|
+
}
|
|
404
|
+
// ─── Read-Only Enforcement ─────────────────────────────────────────────
|
|
405
|
+
/**
|
|
406
|
+
* Returns true if this client is in read-only mode (the safe default).
|
|
407
|
+
*/
|
|
408
|
+
get isReadOnly() {
|
|
409
|
+
return this.readOnly;
|
|
410
|
+
}
|
|
411
|
+
/**
|
|
412
|
+
* Assert that a non-GET operation is allowed.
|
|
413
|
+
* Throws immediately if the client is in read-only mode.
|
|
414
|
+
*
|
|
415
|
+
* @throws {ApiRequestError} always in read-only mode
|
|
416
|
+
* @internal This method exists so that future packages (e.g. @xrmforge/webapi)
|
|
417
|
+
* can reuse the HTTP client for write operations when readOnly is explicitly false.
|
|
418
|
+
*/
|
|
419
|
+
assertWriteAllowed(operation) {
|
|
420
|
+
if (this.readOnly) {
|
|
421
|
+
throw new ApiRequestError(
|
|
422
|
+
"API_2001" /* API_REQUEST_FAILED */,
|
|
423
|
+
`BLOCKED: Write operation "${operation}" rejected. This client is in read-only mode (readOnly: true). XrmForge typegen must NEVER modify data in Dataverse. Set readOnly: false only for @xrmforge/webapi (not for typegen).`,
|
|
424
|
+
{ operation, readOnly: true }
|
|
425
|
+
);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
// ─── Input Sanitization ──────────────────────────────────────────────────
|
|
429
|
+
/**
|
|
430
|
+
* Validate that a value is a safe OData identifier (entity name, attribute name).
|
|
431
|
+
* Prevents OData injection by allowing only: starts with letter/underscore,
|
|
432
|
+
* followed by alphanumeric/underscore.
|
|
433
|
+
*
|
|
434
|
+
* @throws {ApiRequestError} if the value contains invalid characters
|
|
435
|
+
*/
|
|
436
|
+
static sanitizeIdentifier(value) {
|
|
437
|
+
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(value)) {
|
|
438
|
+
throw new ApiRequestError(
|
|
439
|
+
"API_2001" /* API_REQUEST_FAILED */,
|
|
440
|
+
`Invalid OData identifier: "${value.substring(0, MAX_ERROR_VALUE_LENGTH).replace(/[\r\n]/g, "")}". Only letters, digits, and underscores allowed; must start with a letter or underscore.`,
|
|
441
|
+
{ value: value.substring(0, MAX_ERROR_VALUE_LENGTH) }
|
|
442
|
+
);
|
|
443
|
+
}
|
|
444
|
+
return value;
|
|
445
|
+
}
|
|
446
|
+
/**
|
|
447
|
+
* Validate that a value is a properly formatted GUID.
|
|
448
|
+
*
|
|
449
|
+
* @throws {ApiRequestError} if the format is invalid
|
|
450
|
+
*/
|
|
451
|
+
static sanitizeGuid(value) {
|
|
452
|
+
const guidPattern = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
|
|
453
|
+
if (!guidPattern.test(value)) {
|
|
454
|
+
throw new ApiRequestError(
|
|
455
|
+
"API_2001" /* API_REQUEST_FAILED */,
|
|
456
|
+
`Invalid GUID format: "${value}".`,
|
|
457
|
+
{ value }
|
|
458
|
+
);
|
|
459
|
+
}
|
|
460
|
+
return value;
|
|
461
|
+
}
|
|
462
|
+
/**
|
|
463
|
+
* Escape a string for use inside OData single-quoted string literals.
|
|
464
|
+
* Doubles single quotes to prevent injection.
|
|
465
|
+
*/
|
|
466
|
+
static escapeODataString(value) {
|
|
467
|
+
return value.replace(/'/g, "''");
|
|
468
|
+
}
|
|
469
|
+
// ─── Token Management ────────────────────────────────────────────────────
|
|
470
|
+
async getToken() {
|
|
471
|
+
if (this.cachedToken && this.cachedToken.expiresAt - Date.now() > TOKEN_BUFFER_MS) {
|
|
472
|
+
return this.cachedToken.token;
|
|
473
|
+
}
|
|
474
|
+
log2.debug("Requesting new access token");
|
|
475
|
+
const scope = `${this.baseUrl}/.default`;
|
|
476
|
+
let tokenResponse;
|
|
477
|
+
try {
|
|
478
|
+
tokenResponse = await this.credential.getToken(scope);
|
|
479
|
+
} catch (error) {
|
|
480
|
+
throw new AuthenticationError(
|
|
481
|
+
"AUTH_1003" /* AUTH_TOKEN_FAILED */,
|
|
482
|
+
`Failed to acquire access token for ${this.baseUrl}. Verify your authentication configuration.`,
|
|
483
|
+
{
|
|
484
|
+
environmentUrl: this.baseUrl,
|
|
485
|
+
originalError: error instanceof Error ? error.message : String(error)
|
|
486
|
+
}
|
|
487
|
+
);
|
|
488
|
+
}
|
|
489
|
+
if (!tokenResponse) {
|
|
490
|
+
throw new AuthenticationError(
|
|
491
|
+
"AUTH_1003" /* AUTH_TOKEN_FAILED */,
|
|
492
|
+
`No access token returned for ${this.baseUrl}. This may indicate invalid credentials or insufficient permissions.`,
|
|
493
|
+
{ environmentUrl: this.baseUrl }
|
|
494
|
+
);
|
|
495
|
+
}
|
|
496
|
+
this.cachedToken = {
|
|
497
|
+
token: tokenResponse.token,
|
|
498
|
+
expiresAt: tokenResponse.expiresOnTimestamp
|
|
499
|
+
};
|
|
500
|
+
log2.debug("Access token acquired", {
|
|
501
|
+
expiresIn: `${Math.round((tokenResponse.expiresOnTimestamp - Date.now()) / 1e3)}s`
|
|
502
|
+
});
|
|
503
|
+
return tokenResponse.token;
|
|
504
|
+
}
|
|
505
|
+
// ─── Concurrency Control ─────────────────────────────────────────────────
|
|
506
|
+
/**
|
|
507
|
+
* Execute a request within the concurrency semaphore.
|
|
508
|
+
* The semaphore is acquired ONCE per logical request. Retries happen
|
|
509
|
+
* INSIDE the semaphore to avoid the recursive slot exhaustion bug.
|
|
510
|
+
*/
|
|
511
|
+
async executeWithConcurrency(url, signal) {
|
|
512
|
+
await this.acquireSlot();
|
|
513
|
+
try {
|
|
514
|
+
return await this.executeWithRetry(url, 1, 0, signal);
|
|
515
|
+
} finally {
|
|
516
|
+
this.releaseSlot();
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
acquireSlot() {
|
|
520
|
+
if (this.activeConcurrentRequests < this.maxConcurrency) {
|
|
521
|
+
this.activeConcurrentRequests++;
|
|
522
|
+
return Promise.resolve();
|
|
523
|
+
}
|
|
524
|
+
return new Promise((resolve) => {
|
|
525
|
+
this.waitQueue.push(() => {
|
|
526
|
+
this.activeConcurrentRequests++;
|
|
527
|
+
resolve();
|
|
528
|
+
});
|
|
529
|
+
});
|
|
530
|
+
}
|
|
531
|
+
releaseSlot() {
|
|
532
|
+
this.activeConcurrentRequests--;
|
|
533
|
+
const next = this.waitQueue.shift();
|
|
534
|
+
if (next) next();
|
|
535
|
+
}
|
|
536
|
+
// ─── Retry Logic (runs INSIDE a single concurrency slot) ─────────────────
|
|
537
|
+
async executeWithRetry(url, attempt, rateLimitRetries = 0, signal) {
|
|
538
|
+
if (signal?.aborted) {
|
|
539
|
+
throw new ApiRequestError(
|
|
540
|
+
"API_2001" /* API_REQUEST_FAILED */,
|
|
541
|
+
"Request aborted before execution",
|
|
542
|
+
{ url }
|
|
543
|
+
);
|
|
544
|
+
}
|
|
545
|
+
const token = await this.getToken();
|
|
546
|
+
const controller = new AbortController();
|
|
547
|
+
const timeoutId = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
548
|
+
const onUserAbort = () => controller.abort();
|
|
549
|
+
signal?.addEventListener("abort", onUserAbort, { once: true });
|
|
550
|
+
let response;
|
|
551
|
+
try {
|
|
552
|
+
log2.debug(`GET ${url}`, { attempt });
|
|
553
|
+
response = await fetch(url, {
|
|
554
|
+
method: "GET",
|
|
555
|
+
// Explicit: never rely on default
|
|
556
|
+
headers: {
|
|
557
|
+
Authorization: `Bearer ${token}`,
|
|
558
|
+
"OData-MaxVersion": "4.0",
|
|
559
|
+
"OData-Version": "4.0",
|
|
560
|
+
Accept: "application/json",
|
|
561
|
+
Prefer: 'odata.include-annotations="*"'
|
|
562
|
+
},
|
|
563
|
+
signal: controller.signal
|
|
564
|
+
});
|
|
565
|
+
} catch (fetchError) {
|
|
566
|
+
clearTimeout(timeoutId);
|
|
567
|
+
signal?.removeEventListener("abort", onUserAbort);
|
|
568
|
+
if (fetchError instanceof Error && fetchError.name === "AbortError") {
|
|
569
|
+
if (signal?.aborted) {
|
|
570
|
+
throw new ApiRequestError(
|
|
571
|
+
"API_2001" /* API_REQUEST_FAILED */,
|
|
572
|
+
"Request aborted by caller",
|
|
573
|
+
{ url, attempt }
|
|
574
|
+
);
|
|
575
|
+
}
|
|
576
|
+
if (attempt <= this.maxRetries) {
|
|
577
|
+
const delay = this.calculateBackoff(attempt);
|
|
578
|
+
log2.warn(`Request timed out, retrying in ${delay}ms (${attempt}/${this.maxRetries})`, {
|
|
579
|
+
url
|
|
580
|
+
});
|
|
581
|
+
await this.sleep(delay);
|
|
582
|
+
return this.executeWithRetry(url, attempt + 1, rateLimitRetries, signal);
|
|
583
|
+
}
|
|
584
|
+
throw new ApiRequestError(
|
|
585
|
+
"API_2005" /* API_TIMEOUT */,
|
|
586
|
+
`Request timed out after ${this.timeoutMs}ms (${this.maxRetries} retries exhausted)`,
|
|
587
|
+
{ url, timeoutMs: this.timeoutMs }
|
|
588
|
+
);
|
|
589
|
+
}
|
|
590
|
+
if (attempt <= this.maxRetries) {
|
|
591
|
+
const delay = this.calculateBackoff(attempt);
|
|
592
|
+
log2.warn(`Network error, retrying in ${delay}ms (${attempt}/${this.maxRetries})`, {
|
|
593
|
+
url,
|
|
594
|
+
error: fetchError instanceof Error ? fetchError.message : String(fetchError)
|
|
595
|
+
});
|
|
596
|
+
await this.sleep(delay);
|
|
597
|
+
return this.executeWithRetry(url, attempt + 1, rateLimitRetries, signal);
|
|
598
|
+
}
|
|
599
|
+
throw new ApiRequestError(
|
|
600
|
+
"API_2001" /* API_REQUEST_FAILED */,
|
|
601
|
+
`Network error after ${this.maxRetries} retries`,
|
|
602
|
+
{
|
|
603
|
+
url,
|
|
604
|
+
originalError: fetchError instanceof Error ? fetchError.message : String(fetchError)
|
|
605
|
+
}
|
|
606
|
+
);
|
|
607
|
+
} finally {
|
|
608
|
+
clearTimeout(timeoutId);
|
|
609
|
+
signal?.removeEventListener("abort", onUserAbort);
|
|
610
|
+
}
|
|
611
|
+
if (!response.ok) {
|
|
612
|
+
return this.handleHttpError(response, url, attempt, rateLimitRetries, signal);
|
|
613
|
+
}
|
|
614
|
+
log2.debug(`GET ${url} -> ${response.status}`, { attempt });
|
|
615
|
+
return response.json();
|
|
616
|
+
}
|
|
617
|
+
async handleHttpError(response, url, attempt, rateLimitRetries, signal) {
|
|
618
|
+
const body = await response.text();
|
|
619
|
+
if (response.status === 429) {
|
|
620
|
+
if (rateLimitRetries >= this.maxRateLimitRetries) {
|
|
621
|
+
throw new ApiRequestError(
|
|
622
|
+
"API_2002" /* API_RATE_LIMITED */,
|
|
623
|
+
`Rate limit retries exhausted (${this.maxRateLimitRetries} consecutive 429 responses)`,
|
|
624
|
+
{ url, rateLimitRetries }
|
|
625
|
+
);
|
|
626
|
+
}
|
|
627
|
+
const retryAfterHeader = response.headers.get("Retry-After");
|
|
628
|
+
const retryAfterMs = retryAfterHeader ? parseInt(retryAfterHeader, 10) * 1e3 : this.calculateBackoff(rateLimitRetries + 1);
|
|
629
|
+
log2.warn(`Rate limited (HTTP 429). Waiting ${retryAfterMs}ms (${rateLimitRetries + 1}/${this.maxRateLimitRetries}).`, {
|
|
630
|
+
url,
|
|
631
|
+
retryAfterHeader
|
|
632
|
+
});
|
|
633
|
+
await this.sleep(retryAfterMs);
|
|
634
|
+
return this.executeWithRetry(url, attempt, rateLimitRetries + 1, signal);
|
|
635
|
+
}
|
|
636
|
+
if (response.status === 401 && attempt === 1) {
|
|
637
|
+
log2.warn("HTTP 401 received, clearing token cache and retrying");
|
|
638
|
+
this.cachedToken = null;
|
|
639
|
+
return this.executeWithRetry(url, attempt + 1, 0, signal);
|
|
640
|
+
}
|
|
641
|
+
if (response.status >= 500 && attempt <= this.maxRetries) {
|
|
642
|
+
const delay = this.calculateBackoff(attempt);
|
|
643
|
+
log2.warn(
|
|
644
|
+
`Server error ${response.status}, retrying in ${delay}ms (${attempt}/${this.maxRetries})`
|
|
645
|
+
);
|
|
646
|
+
await this.sleep(delay);
|
|
647
|
+
return this.executeWithRetry(url, attempt + 1, rateLimitRetries, signal);
|
|
648
|
+
}
|
|
649
|
+
const errorCode = response.status === 401 ? "API_2004" /* API_UNAUTHORIZED */ : response.status === 404 ? "API_2003" /* API_NOT_FOUND */ : "API_2001" /* API_REQUEST_FAILED */;
|
|
650
|
+
throw new ApiRequestError(
|
|
651
|
+
errorCode,
|
|
652
|
+
`Dataverse API error: HTTP ${response.status} ${response.statusText}`,
|
|
653
|
+
{
|
|
654
|
+
url,
|
|
655
|
+
statusCode: response.status,
|
|
656
|
+
responseBody: body.substring(0, MAX_RESPONSE_BODY_LENGTH)
|
|
657
|
+
}
|
|
658
|
+
);
|
|
659
|
+
}
|
|
660
|
+
// ─── Helpers ─────────────────────────────────────────────────────────────
|
|
661
|
+
resolveUrl(path2) {
|
|
662
|
+
return path2.startsWith("http") ? path2 : `${this.apiUrl}${path2}`;
|
|
663
|
+
}
|
|
664
|
+
calculateBackoff(attempt) {
|
|
665
|
+
const exponential = this.retryBaseDelayMs * Math.pow(2, attempt - 1);
|
|
666
|
+
const jitter = Math.random() * this.retryBaseDelayMs;
|
|
667
|
+
return Math.min(exponential + jitter, MAX_BACKOFF_MS);
|
|
668
|
+
}
|
|
669
|
+
sleep(ms) {
|
|
670
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
671
|
+
}
|
|
672
|
+
};
|
|
673
|
+
|
|
674
|
+
// src/metadata/xml-parser.ts
|
|
675
|
+
import { XMLParser } from "fast-xml-parser";
|
|
676
|
+
var FastXmlParser = class {
|
|
677
|
+
parser;
|
|
678
|
+
constructor() {
|
|
679
|
+
this.parser = new XMLParser({
|
|
680
|
+
ignoreAttributes: false,
|
|
681
|
+
attributeNamePrefix: "@_",
|
|
682
|
+
allowBooleanAttributes: true,
|
|
683
|
+
preserveOrder: true,
|
|
684
|
+
trimValues: true
|
|
685
|
+
});
|
|
686
|
+
}
|
|
687
|
+
parse(xml) {
|
|
688
|
+
const result = this.parser.parse(xml);
|
|
689
|
+
return this.convertToXmlElement(result);
|
|
690
|
+
}
|
|
691
|
+
convertToXmlElement(parsed) {
|
|
692
|
+
if (!Array.isArray(parsed) || parsed.length === 0) {
|
|
693
|
+
return { tag: "root", attributes: {}, children: [] };
|
|
694
|
+
}
|
|
695
|
+
const children = parsed.map((item) => this.convertNode(item));
|
|
696
|
+
if (children.length === 1) {
|
|
697
|
+
return children[0];
|
|
698
|
+
}
|
|
699
|
+
return { tag: "root", attributes: {}, children };
|
|
700
|
+
}
|
|
701
|
+
convertNode(node) {
|
|
702
|
+
const attrs = {};
|
|
703
|
+
let tag = "";
|
|
704
|
+
let children = [];
|
|
705
|
+
let text;
|
|
706
|
+
for (const key of Object.keys(node)) {
|
|
707
|
+
if (key === ":@") {
|
|
708
|
+
const attrObj = node[key];
|
|
709
|
+
for (const [attrKey, attrVal] of Object.entries(attrObj)) {
|
|
710
|
+
const cleanKey = attrKey.startsWith("@_") ? attrKey.slice(2) : attrKey;
|
|
711
|
+
attrs[cleanKey] = String(attrVal);
|
|
712
|
+
}
|
|
713
|
+
} else if (key === "#text") {
|
|
714
|
+
text = String(node[key]);
|
|
715
|
+
} else {
|
|
716
|
+
tag = key;
|
|
717
|
+
const childContent = node[key];
|
|
718
|
+
if (Array.isArray(childContent)) {
|
|
719
|
+
children = childContent.map((child) => this.convertNode(child));
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
return { tag, attributes: attrs, children, text };
|
|
724
|
+
}
|
|
725
|
+
};
|
|
726
|
+
var defaultXmlParser = new FastXmlParser();
|
|
727
|
+
|
|
728
|
+
// src/metadata/form-parser.ts
|
|
729
|
+
var log3 = createLogger("form-parser");
|
|
730
|
+
var SPECIAL_CONTROL_CLASSIDS = {
|
|
731
|
+
// Subgrid (read-only list of related records)
|
|
732
|
+
"e7a81278-8635-4d9e-8d4d-59480b391c5b": "subgrid",
|
|
733
|
+
// Editable Grid (inline-editable list)
|
|
734
|
+
"02d4264b-47e2-4b4c-aa95-f439f3f4d458": "editablegrid",
|
|
735
|
+
// Quick View Form (embedded read-only form of related record)
|
|
736
|
+
"5c5600e0-1d6e-4205-a272-be80da87fd42": "quickview",
|
|
737
|
+
// Web Resource (custom HTML/JS content)
|
|
738
|
+
"9fdf5f91-88b1-47f4-ad53-c11efc01a01d": "webresource",
|
|
739
|
+
// Bing Map
|
|
740
|
+
"62b0df79-0464-470f-8af7-4483cfea0c7d": "map",
|
|
741
|
+
// Notes/Timeline
|
|
742
|
+
"06375649-c143-495e-a496-c962e5b4488e": "notes"
|
|
743
|
+
};
|
|
744
|
+
function parseForm(form, parser = defaultXmlParser) {
|
|
745
|
+
const tabs = parseTabs(form.formxml, form.name, parser);
|
|
746
|
+
const allControls = tabs.flatMap(
|
|
747
|
+
(tab) => tab.sections.flatMap((section) => section.controls)
|
|
748
|
+
);
|
|
749
|
+
const allSpecialControls = tabs.flatMap(
|
|
750
|
+
(tab) => tab.sections.flatMap((section) => section.specialControls)
|
|
751
|
+
);
|
|
752
|
+
return {
|
|
753
|
+
name: form.name,
|
|
754
|
+
formId: form.formid,
|
|
755
|
+
isDefault: form.isdefault,
|
|
756
|
+
tabs,
|
|
757
|
+
allControls,
|
|
758
|
+
allSpecialControls
|
|
759
|
+
};
|
|
760
|
+
}
|
|
761
|
+
function extractControlFields(formxml, parser = defaultXmlParser) {
|
|
762
|
+
if (!formxml || formxml.trim().length === 0) {
|
|
763
|
+
return [];
|
|
764
|
+
}
|
|
765
|
+
try {
|
|
766
|
+
const root = parser.parse(formxml);
|
|
767
|
+
const fields = [];
|
|
768
|
+
collectDatafieldNames(root, fields);
|
|
769
|
+
return fields;
|
|
770
|
+
} catch {
|
|
771
|
+
log3.warn("Failed to parse formxml for field extraction, returning empty list");
|
|
772
|
+
return [];
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
function parseTabs(formxml, formName, parser) {
|
|
776
|
+
if (!formxml || formxml.trim().length === 0) {
|
|
777
|
+
log3.warn(`Empty formxml for form "${formName}"`);
|
|
778
|
+
return [];
|
|
779
|
+
}
|
|
780
|
+
let root;
|
|
781
|
+
try {
|
|
782
|
+
root = parser.parse(formxml);
|
|
783
|
+
} catch (error) {
|
|
784
|
+
throw new MetadataError(
|
|
785
|
+
"META_3003" /* META_FORM_PARSE_FAILED */,
|
|
786
|
+
`Failed to parse FormXml for form "${formName}"`,
|
|
787
|
+
{
|
|
788
|
+
formName,
|
|
789
|
+
originalError: error instanceof Error ? error.message : String(error)
|
|
790
|
+
}
|
|
791
|
+
);
|
|
792
|
+
}
|
|
793
|
+
const tabs = [];
|
|
794
|
+
const tabElements = findElements(root, "tab");
|
|
795
|
+
for (const tabEl of tabElements) {
|
|
796
|
+
const tabName = tabEl.attributes["name"] ?? "";
|
|
797
|
+
const tabVisible = tabEl.attributes["visible"] !== "false";
|
|
798
|
+
const tabLabel = extractLabel(tabEl);
|
|
799
|
+
const sections = parseSections(tabEl);
|
|
800
|
+
tabs.push({ name: tabName, label: tabLabel, visible: tabVisible, sections });
|
|
801
|
+
}
|
|
802
|
+
return tabs;
|
|
803
|
+
}
|
|
804
|
+
function parseSections(tabElement) {
|
|
805
|
+
const sections = [];
|
|
806
|
+
const sectionElements = findElements(tabElement, "section");
|
|
807
|
+
for (const sectionEl of sectionElements) {
|
|
808
|
+
const sectionName = sectionEl.attributes["name"] ?? "";
|
|
809
|
+
const sectionVisible = sectionEl.attributes["visible"] !== "false";
|
|
810
|
+
const sectionLabel = extractLabel(sectionEl);
|
|
811
|
+
const controls = parseDataControls(sectionEl);
|
|
812
|
+
const specialControls = parseSpecialControls(sectionEl);
|
|
813
|
+
sections.push({
|
|
814
|
+
name: sectionName,
|
|
815
|
+
label: sectionLabel,
|
|
816
|
+
visible: sectionVisible,
|
|
817
|
+
controls,
|
|
818
|
+
specialControls
|
|
819
|
+
});
|
|
820
|
+
}
|
|
821
|
+
return sections;
|
|
822
|
+
}
|
|
823
|
+
function parseDataControls(sectionElement) {
|
|
824
|
+
const controls = [];
|
|
825
|
+
const controlElements = findElements(sectionElement, "control");
|
|
826
|
+
for (const controlEl of controlElements) {
|
|
827
|
+
const datafieldname = controlEl.attributes["datafieldname"];
|
|
828
|
+
if (!datafieldname) continue;
|
|
829
|
+
controls.push({
|
|
830
|
+
id: controlEl.attributes["id"] ?? "",
|
|
831
|
+
datafieldname,
|
|
832
|
+
classid: controlEl.attributes["classid"] ?? ""
|
|
833
|
+
});
|
|
834
|
+
}
|
|
835
|
+
return controls;
|
|
836
|
+
}
|
|
837
|
+
function parseSpecialControls(sectionElement) {
|
|
838
|
+
const controls = [];
|
|
839
|
+
const controlElements = findElements(sectionElement, "control");
|
|
840
|
+
for (const controlEl of controlElements) {
|
|
841
|
+
if (controlEl.attributes["datafieldname"]) continue;
|
|
842
|
+
const classid = (controlEl.attributes["classid"] ?? "").replace(/[{}]/g, "").toLowerCase();
|
|
843
|
+
const controlType = SPECIAL_CONTROL_CLASSIDS[classid];
|
|
844
|
+
if (!controlType) continue;
|
|
845
|
+
const special = {
|
|
846
|
+
id: controlEl.attributes["id"] ?? "",
|
|
847
|
+
classid,
|
|
848
|
+
controlType
|
|
849
|
+
};
|
|
850
|
+
if (controlType === "subgrid" || controlType === "editablegrid") {
|
|
851
|
+
special.targetEntityType = extractParameter(controlEl, "TargetEntityType");
|
|
852
|
+
special.relationshipName = extractParameter(controlEl, "RelationshipName");
|
|
853
|
+
}
|
|
854
|
+
if (controlType === "webresource") {
|
|
855
|
+
special.webResourceName = extractParameter(controlEl, "Url");
|
|
856
|
+
}
|
|
857
|
+
controls.push(special);
|
|
858
|
+
}
|
|
859
|
+
return controls;
|
|
860
|
+
}
|
|
861
|
+
function findElements(element, tagName) {
|
|
862
|
+
const results = [];
|
|
863
|
+
if (element.tag === tagName) {
|
|
864
|
+
results.push(element);
|
|
865
|
+
}
|
|
866
|
+
for (const child of element.children) {
|
|
867
|
+
results.push(...findElements(child, tagName));
|
|
868
|
+
}
|
|
869
|
+
return results;
|
|
870
|
+
}
|
|
871
|
+
function collectDatafieldNames(element, fields) {
|
|
872
|
+
if (element.tag === "control") {
|
|
873
|
+
const name = element.attributes["datafieldname"];
|
|
874
|
+
if (name && !fields.includes(name)) {
|
|
875
|
+
fields.push(name);
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
for (const child of element.children) {
|
|
879
|
+
collectDatafieldNames(child, fields);
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
function extractLabel(element) {
|
|
883
|
+
const labelsEl = element.children.find((c) => c.tag === "labels");
|
|
884
|
+
if (!labelsEl) return void 0;
|
|
885
|
+
const labelEl = labelsEl.children.find((c) => c.tag === "label");
|
|
886
|
+
if (!labelEl) return void 0;
|
|
887
|
+
return labelEl.attributes["description"] || void 0;
|
|
888
|
+
}
|
|
889
|
+
function extractParameter(controlElement, paramName) {
|
|
890
|
+
const paramsEl = controlElement.children.find((c) => c.tag === "parameters");
|
|
891
|
+
if (!paramsEl) return void 0;
|
|
892
|
+
const paramEl = paramsEl.children.find((c) => c.tag === paramName);
|
|
893
|
+
if (!paramEl) return void 0;
|
|
894
|
+
return paramEl.text || void 0;
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
// src/metadata/client.ts
|
|
898
|
+
var log4 = createLogger("metadata");
|
|
899
|
+
var FORM_TYPE_MAIN = 2;
|
|
900
|
+
var COMPONENT_TYPE_ENTITY = 1;
|
|
901
|
+
var ENTITY_SELECT = "LogicalName,SchemaName,EntitySetName,DisplayName,PrimaryIdAttribute,PrimaryNameAttribute,OwnershipType,IsCustomEntity,LogicalCollectionName,MetadataId";
|
|
902
|
+
var ATTRIBUTE_SELECT = "LogicalName,SchemaName,AttributeType,AttributeTypeName,DisplayName,IsPrimaryId,IsPrimaryName,RequiredLevel,IsValidForRead,IsValidForCreate,IsValidForUpdate,MetadataId";
|
|
903
|
+
var FORM_SELECT = "name,formid,formxml,description,isdefault";
|
|
904
|
+
var MetadataClient = class {
|
|
905
|
+
http;
|
|
906
|
+
constructor(httpClient) {
|
|
907
|
+
this.http = httpClient;
|
|
908
|
+
}
|
|
909
|
+
// ─── Entity Metadata ───────────────────────────────────────────────────
|
|
910
|
+
/**
|
|
911
|
+
* Get metadata for a single entity by LogicalName, including all attributes.
|
|
912
|
+
*
|
|
913
|
+
* @throws {MetadataError} if the entity is not found
|
|
914
|
+
*/
|
|
915
|
+
async getEntityWithAttributes(logicalName) {
|
|
916
|
+
const safeName = DataverseHttpClient.sanitizeIdentifier(logicalName);
|
|
917
|
+
log4.info(`Fetching entity metadata: ${safeName}`);
|
|
918
|
+
const entity = await this.http.get(
|
|
919
|
+
`/EntityDefinitions(LogicalName='${safeName}')?$select=${ENTITY_SELECT}&$expand=Attributes($select=${ATTRIBUTE_SELECT})`
|
|
920
|
+
);
|
|
921
|
+
log4.info(`Entity "${safeName}": ${entity.Attributes?.length ?? 0} attributes`);
|
|
922
|
+
return entity;
|
|
923
|
+
}
|
|
924
|
+
/**
|
|
925
|
+
* List all entities (without attributes) for discovery.
|
|
926
|
+
* Use `$filter` parameter to narrow results.
|
|
927
|
+
*/
|
|
928
|
+
async listEntities(filter) {
|
|
929
|
+
let path2 = `/EntityDefinitions?$select=${ENTITY_SELECT}`;
|
|
930
|
+
if (filter) {
|
|
931
|
+
path2 += `&$filter=${filter}`;
|
|
932
|
+
}
|
|
933
|
+
log4.info("Listing entities");
|
|
934
|
+
return this.http.getAll(path2);
|
|
935
|
+
}
|
|
936
|
+
// ─── Typed Attribute Queries ───────────────────────────────────────────
|
|
937
|
+
/**
|
|
938
|
+
* Get all Picklist attributes with their OptionSets for an entity.
|
|
939
|
+
* Includes both local and global OptionSets.
|
|
940
|
+
*/
|
|
941
|
+
async getPicklistAttributes(logicalName) {
|
|
942
|
+
const safeName = DataverseHttpClient.sanitizeIdentifier(logicalName);
|
|
943
|
+
log4.debug(`Fetching Picklist attributes for: ${safeName}`);
|
|
944
|
+
return this.http.getAll(
|
|
945
|
+
`/EntityDefinitions(LogicalName='${safeName}')/Attributes/Microsoft.Dynamics.CRM.PicklistAttributeMetadata?$select=LogicalName,SchemaName,MetadataId&$expand=OptionSet($select=Options,Name,IsGlobal,MetadataId),GlobalOptionSet($select=Options,Name,MetadataId)`
|
|
946
|
+
);
|
|
947
|
+
}
|
|
948
|
+
/**
|
|
949
|
+
* Get all Lookup attributes with their target entity names.
|
|
950
|
+
*/
|
|
951
|
+
async getLookupAttributes(logicalName) {
|
|
952
|
+
const safeName = DataverseHttpClient.sanitizeIdentifier(logicalName);
|
|
953
|
+
log4.debug(`Fetching Lookup attributes for: ${safeName}`);
|
|
954
|
+
return this.http.getAll(
|
|
955
|
+
`/EntityDefinitions(LogicalName='${safeName}')/Attributes/Microsoft.Dynamics.CRM.LookupAttributeMetadata?$select=LogicalName,SchemaName,Targets,MetadataId`
|
|
956
|
+
);
|
|
957
|
+
}
|
|
958
|
+
/**
|
|
959
|
+
* Get Status attributes (statuscode) with their OptionSets.
|
|
960
|
+
*/
|
|
961
|
+
async getStatusAttributes(logicalName) {
|
|
962
|
+
const safeName = DataverseHttpClient.sanitizeIdentifier(logicalName);
|
|
963
|
+
log4.debug(`Fetching Status attributes for: ${safeName}`);
|
|
964
|
+
return this.http.getAll(
|
|
965
|
+
`/EntityDefinitions(LogicalName='${safeName}')/Attributes/Microsoft.Dynamics.CRM.StatusAttributeMetadata?$select=LogicalName,SchemaName,MetadataId&$expand=OptionSet($select=Options)`
|
|
966
|
+
);
|
|
967
|
+
}
|
|
968
|
+
/**
|
|
969
|
+
* Get State attributes (statecode) with their OptionSets.
|
|
970
|
+
*/
|
|
971
|
+
async getStateAttributes(logicalName) {
|
|
972
|
+
const safeName = DataverseHttpClient.sanitizeIdentifier(logicalName);
|
|
973
|
+
log4.debug(`Fetching State attributes for: ${safeName}`);
|
|
974
|
+
return this.http.getAll(
|
|
975
|
+
`/EntityDefinitions(LogicalName='${safeName}')/Attributes/Microsoft.Dynamics.CRM.StateAttributeMetadata?$select=LogicalName,SchemaName,MetadataId&$expand=OptionSet($select=Options)`
|
|
976
|
+
);
|
|
977
|
+
}
|
|
978
|
+
// ─── Form Metadata ────────────────────────────────────────────────────
|
|
979
|
+
/**
|
|
980
|
+
* Get and parse Main forms (type=2) for an entity.
|
|
981
|
+
* Returns parsed form structures with tabs, sections, and controls.
|
|
982
|
+
*/
|
|
983
|
+
async getMainForms(logicalName) {
|
|
984
|
+
const safeName = DataverseHttpClient.sanitizeIdentifier(logicalName);
|
|
985
|
+
log4.info(`Fetching Main forms for: ${safeName}`);
|
|
986
|
+
const forms = await this.http.getAll(
|
|
987
|
+
`/systemforms?$filter=objecttypecode eq '${safeName}' and type eq ${FORM_TYPE_MAIN}&$select=${FORM_SELECT}`
|
|
988
|
+
);
|
|
989
|
+
log4.info(`Found ${forms.length} Main form(s) for "${safeName}"`);
|
|
990
|
+
return forms.map((form) => {
|
|
991
|
+
try {
|
|
992
|
+
return parseForm(form);
|
|
993
|
+
} catch (error) {
|
|
994
|
+
log4.warn(`Failed to parse form "${form.name}" (${form.formid}), skipping`, {
|
|
995
|
+
formName: form.name,
|
|
996
|
+
formId: form.formid,
|
|
997
|
+
error: error instanceof Error ? error.message : String(error)
|
|
998
|
+
});
|
|
999
|
+
return {
|
|
1000
|
+
name: form.name,
|
|
1001
|
+
formId: form.formid,
|
|
1002
|
+
isDefault: form.isdefault,
|
|
1003
|
+
tabs: [],
|
|
1004
|
+
allControls: [],
|
|
1005
|
+
allSpecialControls: []
|
|
1006
|
+
};
|
|
1007
|
+
}
|
|
1008
|
+
});
|
|
1009
|
+
}
|
|
1010
|
+
// ─── Global OptionSets ─────────────────────────────────────────────────
|
|
1011
|
+
/**
|
|
1012
|
+
* Get a global OptionSet by its exact name.
|
|
1013
|
+
*/
|
|
1014
|
+
async getGlobalOptionSet(name) {
|
|
1015
|
+
const safeName = DataverseHttpClient.sanitizeIdentifier(name);
|
|
1016
|
+
log4.debug(`Fetching GlobalOptionSet: ${safeName}`);
|
|
1017
|
+
return this.http.get(
|
|
1018
|
+
`/GlobalOptionSetDefinitions(Name='${safeName}')`
|
|
1019
|
+
);
|
|
1020
|
+
}
|
|
1021
|
+
/**
|
|
1022
|
+
* List all global OptionSets (names and types only).
|
|
1023
|
+
*/
|
|
1024
|
+
async listGlobalOptionSets() {
|
|
1025
|
+
log4.debug("Listing all GlobalOptionSets");
|
|
1026
|
+
return this.http.getAll(
|
|
1027
|
+
`/GlobalOptionSetDefinitions?$select=Name,DisplayName,OptionSetType,IsGlobal,MetadataId`
|
|
1028
|
+
);
|
|
1029
|
+
}
|
|
1030
|
+
// ─── Relationships ─────────────────────────────────────────────────────
|
|
1031
|
+
/**
|
|
1032
|
+
* Get all 1:N relationships where this entity is the referenced (parent) entity.
|
|
1033
|
+
*/
|
|
1034
|
+
async getOneToManyRelationships(logicalName) {
|
|
1035
|
+
const safeName = DataverseHttpClient.sanitizeIdentifier(logicalName);
|
|
1036
|
+
log4.debug(`Fetching 1:N relationships for: ${safeName}`);
|
|
1037
|
+
return this.http.getAll(
|
|
1038
|
+
`/EntityDefinitions(LogicalName='${safeName}')/OneToManyRelationships?$select=SchemaName,ReferencingEntity,ReferencingAttribute,ReferencedEntity,ReferencedAttribute,MetadataId`
|
|
1039
|
+
);
|
|
1040
|
+
}
|
|
1041
|
+
/**
|
|
1042
|
+
* Get all N:N relationships for an entity.
|
|
1043
|
+
*/
|
|
1044
|
+
async getManyToManyRelationships(logicalName) {
|
|
1045
|
+
const safeName = DataverseHttpClient.sanitizeIdentifier(logicalName);
|
|
1046
|
+
log4.debug(`Fetching N:N relationships for: ${safeName}`);
|
|
1047
|
+
return this.http.getAll(
|
|
1048
|
+
`/EntityDefinitions(LogicalName='${safeName}')/ManyToManyRelationships?$select=SchemaName,Entity1LogicalName,Entity2LogicalName,IntersectEntityName,MetadataId`
|
|
1049
|
+
);
|
|
1050
|
+
}
|
|
1051
|
+
// ─── Solution Filter ───────────────────────────────────────────────────
|
|
1052
|
+
/**
|
|
1053
|
+
* Get all entity LogicalNames that belong to a specific solution.
|
|
1054
|
+
* Resolves SolutionComponent MetadataIds to EntityDefinition LogicalNames.
|
|
1055
|
+
*
|
|
1056
|
+
* @param solutionUniqueName - The unique name of the solution
|
|
1057
|
+
* @returns Array of entity LogicalNames (e.g. ["account", "contact"])
|
|
1058
|
+
*/
|
|
1059
|
+
async getEntityNamesForSolution(solutionUniqueName) {
|
|
1060
|
+
const safeName = DataverseHttpClient.escapeODataString(solutionUniqueName);
|
|
1061
|
+
log4.info(`Fetching solution: ${solutionUniqueName}`);
|
|
1062
|
+
const solutions = await this.http.get(
|
|
1063
|
+
`/solutions?$filter=uniquename eq '${safeName}'&$select=solutionid,uniquename,friendlyname`
|
|
1064
|
+
);
|
|
1065
|
+
if (solutions.value.length === 0) {
|
|
1066
|
+
throw new MetadataError(
|
|
1067
|
+
"META_3002" /* META_SOLUTION_NOT_FOUND */,
|
|
1068
|
+
`Solution "${solutionUniqueName}" not found`,
|
|
1069
|
+
{ solutionUniqueName }
|
|
1070
|
+
);
|
|
1071
|
+
}
|
|
1072
|
+
const solutionId = solutions.value[0].solutionid;
|
|
1073
|
+
const solutionName = solutions.value[0].friendlyname;
|
|
1074
|
+
log4.info(`Solution "${solutionName}" (${solutionId})`);
|
|
1075
|
+
const components = await this.http.getAll(
|
|
1076
|
+
`/solutioncomponents?$filter=_solutionid_value eq ${DataverseHttpClient.sanitizeGuid(solutionId)} and componenttype eq ${COMPONENT_TYPE_ENTITY}&$select=objectid,componenttype`
|
|
1077
|
+
);
|
|
1078
|
+
log4.info(`Solution "${solutionName}" contains ${components.length} entity components`);
|
|
1079
|
+
if (components.length === 0) return [];
|
|
1080
|
+
const metadataIds = components.map((c) => c.objectid);
|
|
1081
|
+
const filterClauses = metadataIds.map((id) => `MetadataId eq ${DataverseHttpClient.sanitizeGuid(id)}`);
|
|
1082
|
+
const BATCH_SIZE = 15;
|
|
1083
|
+
const logicalNames = [];
|
|
1084
|
+
for (let i = 0; i < filterClauses.length; i += BATCH_SIZE) {
|
|
1085
|
+
const batch = filterClauses.slice(i, i + BATCH_SIZE);
|
|
1086
|
+
const filter = batch.join(" or ");
|
|
1087
|
+
const entities = await this.http.getAll(
|
|
1088
|
+
`/EntityDefinitions?$filter=${filter}&$select=LogicalName`
|
|
1089
|
+
);
|
|
1090
|
+
for (const e of entities) {
|
|
1091
|
+
logicalNames.push(e.LogicalName);
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
log4.info(`Resolved ${logicalNames.length} entity logical names from solution "${solutionName}"`);
|
|
1095
|
+
return logicalNames;
|
|
1096
|
+
}
|
|
1097
|
+
/**
|
|
1098
|
+
* Get all entity LogicalNames from multiple solutions, merged and deduplicated.
|
|
1099
|
+
*
|
|
1100
|
+
* @param solutionUniqueNames - Array of solution unique names
|
|
1101
|
+
* @returns Deduplicated array of entity LogicalNames
|
|
1102
|
+
*/
|
|
1103
|
+
async getEntityNamesForSolutions(solutionUniqueNames) {
|
|
1104
|
+
const allNames = /* @__PURE__ */ new Set();
|
|
1105
|
+
for (const name of solutionUniqueNames) {
|
|
1106
|
+
const names = await this.getEntityNamesForSolution(name);
|
|
1107
|
+
for (const n of names) {
|
|
1108
|
+
allNames.add(n);
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
const result = [...allNames].sort();
|
|
1112
|
+
log4.info(`${result.length} unique entities from ${solutionUniqueNames.length} solutions`);
|
|
1113
|
+
return result;
|
|
1114
|
+
}
|
|
1115
|
+
// ─── Aggregated Metadata ───────────────────────────────────────────────
|
|
1116
|
+
/**
|
|
1117
|
+
* Fetch complete metadata for a single entity: all attributes (typed),
|
|
1118
|
+
* forms, and relationships. This is the primary method for type generation.
|
|
1119
|
+
*
|
|
1120
|
+
* Makes 7 parallel API calls per entity for optimal performance.
|
|
1121
|
+
*/
|
|
1122
|
+
async getEntityTypeInfo(logicalName) {
|
|
1123
|
+
const safeName = DataverseHttpClient.sanitizeIdentifier(logicalName);
|
|
1124
|
+
log4.info(`Fetching complete type info for: ${safeName}`);
|
|
1125
|
+
const [entity, picklistAttributes, lookupAttributes, statusAttributes, stateAttributes, forms, relationships] = await Promise.all([
|
|
1126
|
+
this.getEntityWithAttributes(safeName),
|
|
1127
|
+
this.getPicklistAttributes(safeName),
|
|
1128
|
+
this.getLookupAttributes(safeName),
|
|
1129
|
+
this.getStatusAttributes(safeName),
|
|
1130
|
+
this.getStateAttributes(safeName),
|
|
1131
|
+
this.getMainForms(safeName),
|
|
1132
|
+
this.getRelationships(safeName)
|
|
1133
|
+
]);
|
|
1134
|
+
const result = {
|
|
1135
|
+
entity,
|
|
1136
|
+
attributes: entity.Attributes ?? [],
|
|
1137
|
+
picklistAttributes,
|
|
1138
|
+
lookupAttributes,
|
|
1139
|
+
statusAttributes,
|
|
1140
|
+
stateAttributes,
|
|
1141
|
+
forms,
|
|
1142
|
+
oneToManyRelationships: relationships.oneToMany,
|
|
1143
|
+
manyToManyRelationships: relationships.manyToMany
|
|
1144
|
+
};
|
|
1145
|
+
log4.info(
|
|
1146
|
+
`Type info for "${safeName}": ${result.attributes.length} attrs, ${picklistAttributes.length} picklists, ${lookupAttributes.length} lookups, ${stateAttributes.length} state, ${forms.length} forms, ${relationships.oneToMany.length} 1:N, ${relationships.manyToMany.length} N:N`
|
|
1147
|
+
);
|
|
1148
|
+
return result;
|
|
1149
|
+
}
|
|
1150
|
+
/**
|
|
1151
|
+
* Fetch complete metadata for multiple entities in parallel.
|
|
1152
|
+
* Respects the HTTP client's concurrency limit automatically.
|
|
1153
|
+
*/
|
|
1154
|
+
async getMultipleEntityTypeInfos(logicalNames) {
|
|
1155
|
+
log4.info(`Fetching type info for ${logicalNames.length} entities`);
|
|
1156
|
+
return Promise.all(logicalNames.map((name) => this.getEntityTypeInfo(name)));
|
|
1157
|
+
}
|
|
1158
|
+
// ─── Internal Helpers ──────────────────────────────────────────────────
|
|
1159
|
+
async getRelationships(logicalName) {
|
|
1160
|
+
const [oneToMany, manyToMany] = await Promise.all([
|
|
1161
|
+
this.getOneToManyRelationships(logicalName),
|
|
1162
|
+
this.getManyToManyRelationships(logicalName)
|
|
1163
|
+
]);
|
|
1164
|
+
return { oneToMany, manyToMany };
|
|
1165
|
+
}
|
|
1166
|
+
};
|
|
1167
|
+
|
|
1168
|
+
// src/metadata/cache.ts
|
|
1169
|
+
import { promises as fs } from "fs";
|
|
1170
|
+
import * as path from "path";
|
|
1171
|
+
var log5 = createLogger("cache");
|
|
1172
|
+
var CACHE_DIR = ".xrmforge/cache";
|
|
1173
|
+
var CACHE_FILE = "metadata.json";
|
|
1174
|
+
var CACHE_VERSION = "1";
|
|
1175
|
+
var MetadataCache = class {
|
|
1176
|
+
cacheDir;
|
|
1177
|
+
cacheFilePath;
|
|
1178
|
+
/**
|
|
1179
|
+
* @param projectRoot - Root directory of the project (where .xrmforge/ will be created)
|
|
1180
|
+
*/
|
|
1181
|
+
constructor(projectRoot) {
|
|
1182
|
+
this.cacheDir = path.join(projectRoot, CACHE_DIR);
|
|
1183
|
+
this.cacheFilePath = path.join(this.cacheDir, CACHE_FILE);
|
|
1184
|
+
}
|
|
1185
|
+
/**
|
|
1186
|
+
* Load cached metadata from disk.
|
|
1187
|
+
* Returns null if no cache exists, cache is for a different environment,
|
|
1188
|
+
* or cache format is incompatible.
|
|
1189
|
+
*/
|
|
1190
|
+
async load(environmentUrl) {
|
|
1191
|
+
try {
|
|
1192
|
+
const raw = await fs.readFile(this.cacheFilePath, "utf-8");
|
|
1193
|
+
const data = JSON.parse(raw);
|
|
1194
|
+
if (data.manifest.version !== CACHE_VERSION) {
|
|
1195
|
+
log5.info("Cache version mismatch, will do full refresh");
|
|
1196
|
+
return null;
|
|
1197
|
+
}
|
|
1198
|
+
if (data.manifest.environmentUrl !== environmentUrl) {
|
|
1199
|
+
log5.info("Cache is for a different environment, will do full refresh", {
|
|
1200
|
+
cached: data.manifest.environmentUrl,
|
|
1201
|
+
current: environmentUrl
|
|
1202
|
+
});
|
|
1203
|
+
return null;
|
|
1204
|
+
}
|
|
1205
|
+
log5.info(`Loaded metadata cache: ${data.manifest.entities.length} entities, last refreshed ${data.manifest.lastRefreshed}`);
|
|
1206
|
+
return data;
|
|
1207
|
+
} catch (error) {
|
|
1208
|
+
if (error.code === "ENOENT") {
|
|
1209
|
+
log5.info("No metadata cache found, will do full refresh");
|
|
1210
|
+
} else {
|
|
1211
|
+
log5.warn("Failed to read metadata cache, will do full refresh", {
|
|
1212
|
+
error: error instanceof Error ? error.message : String(error)
|
|
1213
|
+
});
|
|
1214
|
+
}
|
|
1215
|
+
return null;
|
|
1216
|
+
}
|
|
1217
|
+
}
|
|
1218
|
+
/**
|
|
1219
|
+
* Save metadata to the file-system cache.
|
|
1220
|
+
*/
|
|
1221
|
+
async save(environmentUrl, entityTypeInfos, serverVersionStamp) {
|
|
1222
|
+
const data = {
|
|
1223
|
+
manifest: {
|
|
1224
|
+
version: CACHE_VERSION,
|
|
1225
|
+
environmentUrl,
|
|
1226
|
+
serverVersionStamp,
|
|
1227
|
+
lastRefreshed: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1228
|
+
entities: Object.keys(entityTypeInfos).sort()
|
|
1229
|
+
},
|
|
1230
|
+
entityTypeInfos
|
|
1231
|
+
};
|
|
1232
|
+
await fs.mkdir(this.cacheDir, { recursive: true });
|
|
1233
|
+
const tmpPath = this.cacheFilePath + ".tmp";
|
|
1234
|
+
await fs.writeFile(tmpPath, JSON.stringify(data, null, 2), "utf-8");
|
|
1235
|
+
await fs.rename(tmpPath, this.cacheFilePath);
|
|
1236
|
+
log5.info(`Saved metadata cache: ${data.manifest.entities.length} entities`);
|
|
1237
|
+
}
|
|
1238
|
+
/**
|
|
1239
|
+
* Get the stored ServerVersionStamp for delta queries.
|
|
1240
|
+
* Returns null if no cache exists.
|
|
1241
|
+
*/
|
|
1242
|
+
async getVersionStamp(environmentUrl) {
|
|
1243
|
+
const cache = await this.load(environmentUrl);
|
|
1244
|
+
return cache?.manifest.serverVersionStamp ?? null;
|
|
1245
|
+
}
|
|
1246
|
+
/**
|
|
1247
|
+
* Update specific entities in the cache (delta update).
|
|
1248
|
+
* Merges new/changed entities into the existing cache.
|
|
1249
|
+
*/
|
|
1250
|
+
async updateEntities(environmentUrl, updatedEntities, newVersionStamp) {
|
|
1251
|
+
const existing = await this.load(environmentUrl);
|
|
1252
|
+
const merged = existing?.entityTypeInfos ?? {};
|
|
1253
|
+
for (const [name, info] of Object.entries(updatedEntities)) {
|
|
1254
|
+
merged[name] = info;
|
|
1255
|
+
}
|
|
1256
|
+
await this.save(environmentUrl, merged, newVersionStamp);
|
|
1257
|
+
log5.info(`Delta cache update: ${Object.keys(updatedEntities).length} entities updated`);
|
|
1258
|
+
}
|
|
1259
|
+
/**
|
|
1260
|
+
* Remove specific entities from the cache (for deleted entities).
|
|
1261
|
+
*/
|
|
1262
|
+
async removeEntities(environmentUrl, deletedEntityNames, newVersionStamp) {
|
|
1263
|
+
const existing = await this.load(environmentUrl);
|
|
1264
|
+
if (!existing) return;
|
|
1265
|
+
const merged = existing.entityTypeInfos;
|
|
1266
|
+
for (const name of deletedEntityNames) {
|
|
1267
|
+
delete merged[name];
|
|
1268
|
+
}
|
|
1269
|
+
await this.save(environmentUrl, merged, newVersionStamp);
|
|
1270
|
+
log5.info(`Removed ${deletedEntityNames.length} entities from cache`);
|
|
1271
|
+
}
|
|
1272
|
+
/**
|
|
1273
|
+
* Delete the entire cache.
|
|
1274
|
+
*/
|
|
1275
|
+
async clear() {
|
|
1276
|
+
try {
|
|
1277
|
+
await fs.unlink(this.cacheFilePath);
|
|
1278
|
+
log5.info("Metadata cache cleared");
|
|
1279
|
+
} catch (error) {
|
|
1280
|
+
if (error.code !== "ENOENT") {
|
|
1281
|
+
throw error;
|
|
1282
|
+
}
|
|
1283
|
+
}
|
|
1284
|
+
}
|
|
1285
|
+
/**
|
|
1286
|
+
* Check if a cache file exists.
|
|
1287
|
+
*/
|
|
1288
|
+
async exists() {
|
|
1289
|
+
try {
|
|
1290
|
+
await fs.access(this.cacheFilePath);
|
|
1291
|
+
return true;
|
|
1292
|
+
} catch {
|
|
1293
|
+
return false;
|
|
1294
|
+
}
|
|
1295
|
+
}
|
|
1296
|
+
};
|
|
1297
|
+
|
|
1298
|
+
// src/metadata/labels.ts
|
|
1299
|
+
var DEFAULT_LABEL_CONFIG = {
|
|
1300
|
+
primaryLanguage: 1033
|
|
1301
|
+
};
|
|
1302
|
+
function getPrimaryLabel(label, config) {
|
|
1303
|
+
if (!label) return "";
|
|
1304
|
+
return getLabelForLanguage(label, config.primaryLanguage);
|
|
1305
|
+
}
|
|
1306
|
+
function getJSDocLabel(label, config) {
|
|
1307
|
+
if (!label) return "";
|
|
1308
|
+
const primary = getLabelForLanguage(label, config.primaryLanguage);
|
|
1309
|
+
if (!primary) return "";
|
|
1310
|
+
if (!config.secondaryLanguage) return primary;
|
|
1311
|
+
const secondary = getLabelForLanguage(label, config.secondaryLanguage);
|
|
1312
|
+
if (!secondary || secondary === primary) return primary;
|
|
1313
|
+
return `${primary} | ${secondary}`;
|
|
1314
|
+
}
|
|
1315
|
+
function getLabelForLanguage(label, languageCode) {
|
|
1316
|
+
const localized = label.LocalizedLabels?.find(
|
|
1317
|
+
(l) => l.LanguageCode === languageCode
|
|
1318
|
+
);
|
|
1319
|
+
if (localized?.Label) return localized.Label;
|
|
1320
|
+
if (label.UserLocalizedLabel?.Label) return label.UserLocalizedLabel.Label;
|
|
1321
|
+
return "";
|
|
1322
|
+
}
|
|
1323
|
+
var TRANSLITERATION_MAP = {
|
|
1324
|
+
"\xE4": "ae",
|
|
1325
|
+
"\xF6": "oe",
|
|
1326
|
+
"\xFC": "ue",
|
|
1327
|
+
"\xDF": "ss",
|
|
1328
|
+
"\xC4": "Ae",
|
|
1329
|
+
"\xD6": "Oe",
|
|
1330
|
+
"\xDC": "Ue"
|
|
1331
|
+
};
|
|
1332
|
+
function transliterateUmlauts(text) {
|
|
1333
|
+
return text.replace(/[äöüßÄÖÜ]/g, (char) => TRANSLITERATION_MAP[char] ?? char);
|
|
1334
|
+
}
|
|
1335
|
+
function labelToIdentifier(label) {
|
|
1336
|
+
if (!label || label.trim().length === 0) return null;
|
|
1337
|
+
const transliterated = transliterateUmlauts(label);
|
|
1338
|
+
let cleaned = transliterated.replace(/[^a-zA-Z0-9\s_]/g, "");
|
|
1339
|
+
cleaned = cleaned.trim();
|
|
1340
|
+
if (cleaned.length === 0) return null;
|
|
1341
|
+
const parts = cleaned.split(/[\s_]+/).filter((p) => p.length > 0);
|
|
1342
|
+
const pascal = parts.map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join("");
|
|
1343
|
+
if (pascal.length === 0) return null;
|
|
1344
|
+
if (/^\d/.test(pascal)) {
|
|
1345
|
+
return `_${pascal}`;
|
|
1346
|
+
}
|
|
1347
|
+
return pascal;
|
|
1348
|
+
}
|
|
1349
|
+
function generateEnumMembers(options, config) {
|
|
1350
|
+
const members = options.map((option) => {
|
|
1351
|
+
const primaryLabel = getPrimaryLabel(option.Label, config);
|
|
1352
|
+
const identifier = labelToIdentifier(primaryLabel);
|
|
1353
|
+
const jsDocLabel = getJSDocLabel(option.Label, config);
|
|
1354
|
+
return {
|
|
1355
|
+
rawName: identifier ?? `Value_${option.Value}`,
|
|
1356
|
+
value: option.Value,
|
|
1357
|
+
jsDocLabel: jsDocLabel || `Value ${option.Value}`,
|
|
1358
|
+
fromLabel: identifier !== null
|
|
1359
|
+
};
|
|
1360
|
+
});
|
|
1361
|
+
const nameCount = /* @__PURE__ */ new Map();
|
|
1362
|
+
for (const m of members) {
|
|
1363
|
+
nameCount.set(m.rawName, (nameCount.get(m.rawName) ?? 0) + 1);
|
|
1364
|
+
}
|
|
1365
|
+
const nameUsed = /* @__PURE__ */ new Map();
|
|
1366
|
+
const result = [];
|
|
1367
|
+
for (const m of members) {
|
|
1368
|
+
const count = nameCount.get(m.rawName) ?? 1;
|
|
1369
|
+
if (count === 1) {
|
|
1370
|
+
result.push({ name: m.rawName, value: m.value, jsDocLabel: m.jsDocLabel });
|
|
1371
|
+
} else {
|
|
1372
|
+
if (!nameUsed.get(m.rawName)) {
|
|
1373
|
+
nameUsed.set(m.rawName, true);
|
|
1374
|
+
result.push({ name: m.rawName, value: m.value, jsDocLabel: m.jsDocLabel });
|
|
1375
|
+
} else {
|
|
1376
|
+
result.push({ name: `${m.rawName}_${m.value}`, value: m.value, jsDocLabel: m.jsDocLabel });
|
|
1377
|
+
}
|
|
1378
|
+
}
|
|
1379
|
+
}
|
|
1380
|
+
return result;
|
|
1381
|
+
}
|
|
1382
|
+
function getLabelLanguagesParam(config) {
|
|
1383
|
+
const languages = [config.primaryLanguage];
|
|
1384
|
+
if (config.secondaryLanguage && config.secondaryLanguage !== config.primaryLanguage) {
|
|
1385
|
+
languages.push(config.secondaryLanguage);
|
|
1386
|
+
}
|
|
1387
|
+
return `&LabelLanguages=${languages.join(",")}`;
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1390
|
+
// src/generators/type-mapping.ts
|
|
1391
|
+
function getEntityPropertyType(attributeType, isLookup = false) {
|
|
1392
|
+
if (isLookup) return "string";
|
|
1393
|
+
const mapping = ENTITY_TYPE_MAP[attributeType];
|
|
1394
|
+
if (mapping) return mapping;
|
|
1395
|
+
return "unknown";
|
|
1396
|
+
}
|
|
1397
|
+
var ENTITY_TYPE_MAP = {
|
|
1398
|
+
// String types
|
|
1399
|
+
String: "string",
|
|
1400
|
+
Memo: "string",
|
|
1401
|
+
EntityName: "string",
|
|
1402
|
+
// Numeric types
|
|
1403
|
+
Integer: "number",
|
|
1404
|
+
BigInt: "number",
|
|
1405
|
+
Decimal: "number",
|
|
1406
|
+
Double: "number",
|
|
1407
|
+
Money: "number",
|
|
1408
|
+
// Boolean
|
|
1409
|
+
Boolean: "boolean",
|
|
1410
|
+
// OptionSet types (numeric values in Web API)
|
|
1411
|
+
Picklist: "number",
|
|
1412
|
+
State: "number",
|
|
1413
|
+
Status: "number",
|
|
1414
|
+
// MultiSelectPicklist: Web API returns comma-separated string (e.g. "595300000,595300001")
|
|
1415
|
+
// Verified live on markant-dev.crm4.dynamics.com 2026-03-29
|
|
1416
|
+
MultiSelectPicklist: "string",
|
|
1417
|
+
// Date/Time (ISO 8601 strings in Web API)
|
|
1418
|
+
DateTime: "string",
|
|
1419
|
+
// Identifiers
|
|
1420
|
+
Uniqueidentifier: "string",
|
|
1421
|
+
// Lookup (handled separately via _value pattern, but base type is string)
|
|
1422
|
+
Lookup: "string",
|
|
1423
|
+
Customer: "string",
|
|
1424
|
+
Owner: "string",
|
|
1425
|
+
PartyList: "string",
|
|
1426
|
+
// Binary/Image (not typically in entity interfaces, filtered by shouldIncludeInEntityInterface)
|
|
1427
|
+
Virtual: "unknown",
|
|
1428
|
+
CalendarRules: "unknown"
|
|
1429
|
+
};
|
|
1430
|
+
function getFormAttributeType(attributeType) {
|
|
1431
|
+
const mapping = FORM_ATTRIBUTE_TYPE_MAP[attributeType];
|
|
1432
|
+
if (mapping) return mapping;
|
|
1433
|
+
return "Xrm.Attributes.Attribute";
|
|
1434
|
+
}
|
|
1435
|
+
var FORM_ATTRIBUTE_TYPE_MAP = {
|
|
1436
|
+
// String types
|
|
1437
|
+
String: "Xrm.Attributes.StringAttribute",
|
|
1438
|
+
Memo: "Xrm.Attributes.StringAttribute",
|
|
1439
|
+
// Numeric types
|
|
1440
|
+
Integer: "Xrm.Attributes.NumberAttribute",
|
|
1441
|
+
BigInt: "Xrm.Attributes.NumberAttribute",
|
|
1442
|
+
Decimal: "Xrm.Attributes.NumberAttribute",
|
|
1443
|
+
Double: "Xrm.Attributes.NumberAttribute",
|
|
1444
|
+
Money: "Xrm.Attributes.NumberAttribute",
|
|
1445
|
+
// Boolean
|
|
1446
|
+
Boolean: "Xrm.Attributes.BooleanAttribute",
|
|
1447
|
+
// OptionSet types
|
|
1448
|
+
Picklist: "Xrm.Attributes.OptionSetAttribute",
|
|
1449
|
+
State: "Xrm.Attributes.OptionSetAttribute",
|
|
1450
|
+
Status: "Xrm.Attributes.OptionSetAttribute",
|
|
1451
|
+
MultiSelectPicklist: "Xrm.Attributes.MultiSelectOptionSetAttribute",
|
|
1452
|
+
// Date/Time
|
|
1453
|
+
DateTime: "Xrm.Attributes.DateAttribute",
|
|
1454
|
+
// Lookup types
|
|
1455
|
+
Lookup: "Xrm.Attributes.LookupAttribute",
|
|
1456
|
+
Customer: "Xrm.Attributes.LookupAttribute",
|
|
1457
|
+
Owner: "Xrm.Attributes.LookupAttribute",
|
|
1458
|
+
PartyList: "Xrm.Attributes.LookupAttribute",
|
|
1459
|
+
// Entity Name (string attribute in forms)
|
|
1460
|
+
EntityName: "Xrm.Attributes.StringAttribute"
|
|
1461
|
+
};
|
|
1462
|
+
function getFormControlType(attributeType) {
|
|
1463
|
+
const mapping = FORM_CONTROL_TYPE_MAP[attributeType];
|
|
1464
|
+
if (mapping) return mapping;
|
|
1465
|
+
return "Xrm.Controls.StandardControl";
|
|
1466
|
+
}
|
|
1467
|
+
var FORM_CONTROL_TYPE_MAP = {
|
|
1468
|
+
// Standard controls
|
|
1469
|
+
String: "Xrm.Controls.StringControl",
|
|
1470
|
+
Memo: "Xrm.Controls.StringControl",
|
|
1471
|
+
Integer: "Xrm.Controls.NumberControl",
|
|
1472
|
+
BigInt: "Xrm.Controls.NumberControl",
|
|
1473
|
+
Decimal: "Xrm.Controls.NumberControl",
|
|
1474
|
+
Double: "Xrm.Controls.NumberControl",
|
|
1475
|
+
Money: "Xrm.Controls.NumberControl",
|
|
1476
|
+
Boolean: "Xrm.Controls.StandardControl",
|
|
1477
|
+
DateTime: "Xrm.Controls.DateControl",
|
|
1478
|
+
EntityName: "Xrm.Controls.StandardControl",
|
|
1479
|
+
// OptionSet controls
|
|
1480
|
+
Picklist: "Xrm.Controls.OptionSetControl",
|
|
1481
|
+
State: "Xrm.Controls.OptionSetControl",
|
|
1482
|
+
Status: "Xrm.Controls.OptionSetControl",
|
|
1483
|
+
MultiSelectPicklist: "Xrm.Controls.MultiSelectOptionSetControl",
|
|
1484
|
+
// Lookup controls
|
|
1485
|
+
Lookup: "Xrm.Controls.LookupControl",
|
|
1486
|
+
Customer: "Xrm.Controls.LookupControl",
|
|
1487
|
+
Owner: "Xrm.Controls.LookupControl",
|
|
1488
|
+
PartyList: "Xrm.Controls.LookupControl"
|
|
1489
|
+
};
|
|
1490
|
+
function toSafeIdentifier(logicalName) {
|
|
1491
|
+
if (/^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(logicalName)) {
|
|
1492
|
+
return logicalName;
|
|
1493
|
+
}
|
|
1494
|
+
let safe = logicalName.replace(/[^a-zA-Z0-9_$]/g, "_");
|
|
1495
|
+
if (/^\d/.test(safe)) {
|
|
1496
|
+
safe = `_${safe}`;
|
|
1497
|
+
}
|
|
1498
|
+
if (safe.length === 0) {
|
|
1499
|
+
return "_unnamed";
|
|
1500
|
+
}
|
|
1501
|
+
return safe;
|
|
1502
|
+
}
|
|
1503
|
+
function toPascalCase(logicalName) {
|
|
1504
|
+
return logicalName.split("_").map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join("");
|
|
1505
|
+
}
|
|
1506
|
+
function toLookupValueProperty(logicalName) {
|
|
1507
|
+
return `_${logicalName}_value`;
|
|
1508
|
+
}
|
|
1509
|
+
function isLookupType(attributeType) {
|
|
1510
|
+
return attributeType === "Lookup" || attributeType === "Customer" || attributeType === "Owner";
|
|
1511
|
+
}
|
|
1512
|
+
function isPartyListType(attributeType) {
|
|
1513
|
+
return attributeType === "PartyList";
|
|
1514
|
+
}
|
|
1515
|
+
function shouldIncludeInEntityInterface(attr) {
|
|
1516
|
+
if (attr.AttributeType === "Virtual" || attr.AttributeType === "CalendarRules") {
|
|
1517
|
+
const odataType = attr["@odata.type"];
|
|
1518
|
+
if (odataType !== "#Microsoft.Dynamics.CRM.MultiSelectPicklistAttributeMetadata") {
|
|
1519
|
+
return false;
|
|
1520
|
+
}
|
|
1521
|
+
}
|
|
1522
|
+
if (attr.AttributeType === "ManagedProperty") {
|
|
1523
|
+
return false;
|
|
1524
|
+
}
|
|
1525
|
+
if (attr.AttributeType === "EntityName") {
|
|
1526
|
+
return false;
|
|
1527
|
+
}
|
|
1528
|
+
if (attr.IsValidForRead === false) {
|
|
1529
|
+
return false;
|
|
1530
|
+
}
|
|
1531
|
+
return true;
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1534
|
+
// src/generators/label-utils.ts
|
|
1535
|
+
function getSecondaryLabel(label, config) {
|
|
1536
|
+
if (!config.secondaryLanguage) return void 0;
|
|
1537
|
+
const secondary = label.LocalizedLabels.find((l) => l.LanguageCode === config.secondaryLanguage);
|
|
1538
|
+
return secondary && secondary.Label || void 0;
|
|
1539
|
+
}
|
|
1540
|
+
function labelToEnumMember(labelText) {
|
|
1541
|
+
if (!labelText) return "";
|
|
1542
|
+
const transliterated = transliterateUmlauts(labelText);
|
|
1543
|
+
const cleaned = transliterated.replace(/[^a-zA-Z0-9\s_]/g, "");
|
|
1544
|
+
const parts = cleaned.split(/[\s_]+/).filter((p) => p.length > 0);
|
|
1545
|
+
if (parts.length === 0) return "";
|
|
1546
|
+
const pascal = parts.map((p) => p.charAt(0).toUpperCase() + p.slice(1)).join("");
|
|
1547
|
+
if (/^\d/.test(pascal)) {
|
|
1548
|
+
return `_${pascal}`;
|
|
1549
|
+
}
|
|
1550
|
+
return pascal;
|
|
1551
|
+
}
|
|
1552
|
+
function disambiguateEnumMembers(members) {
|
|
1553
|
+
const allOriginalNames = new Set(members.map((m) => m.name));
|
|
1554
|
+
const usedNames = /* @__PURE__ */ new Set();
|
|
1555
|
+
const result = [];
|
|
1556
|
+
for (const { name, value } of members) {
|
|
1557
|
+
let finalName = name;
|
|
1558
|
+
if (usedNames.has(finalName)) {
|
|
1559
|
+
finalName = `${name}_${value}`;
|
|
1560
|
+
let attempt = 2;
|
|
1561
|
+
while (usedNames.has(finalName) || allOriginalNames.has(finalName) && finalName !== name) {
|
|
1562
|
+
finalName = `${name}_${value}_v${attempt}`;
|
|
1563
|
+
attempt++;
|
|
1564
|
+
}
|
|
1565
|
+
}
|
|
1566
|
+
usedNames.add(finalName);
|
|
1567
|
+
result.push({ name: finalName, value });
|
|
1568
|
+
}
|
|
1569
|
+
return result;
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1572
|
+
// src/generators/entity-generator.ts
|
|
1573
|
+
function generateEntityInterface(info, options = {}) {
|
|
1574
|
+
const labelConfig = options.labelConfig || DEFAULT_LABEL_CONFIG;
|
|
1575
|
+
const namespace = options.namespace || "XrmForge.Entities";
|
|
1576
|
+
const entityName = toPascalCase(info.entity.LogicalName);
|
|
1577
|
+
const entityLabel = getJSDocLabel(info.entity.DisplayName, labelConfig);
|
|
1578
|
+
const lines = [];
|
|
1579
|
+
lines.push(`declare namespace ${namespace} {`);
|
|
1580
|
+
lines.push("");
|
|
1581
|
+
if (entityLabel) {
|
|
1582
|
+
lines.push(` /** ${entityLabel} */`);
|
|
1583
|
+
}
|
|
1584
|
+
lines.push(` interface ${entityName} {`);
|
|
1585
|
+
const includedAttrs = info.attributes.filter(shouldIncludeInEntityInterface).sort((a, b) => a.LogicalName.localeCompare(b.LogicalName));
|
|
1586
|
+
const lookupTargets = /* @__PURE__ */ new Map();
|
|
1587
|
+
for (const la of info.lookupAttributes) {
|
|
1588
|
+
if (la.Targets && la.Targets.length > 0) {
|
|
1589
|
+
lookupTargets.set(la.LogicalName, la.Targets);
|
|
1590
|
+
}
|
|
1591
|
+
}
|
|
1592
|
+
for (const attr of includedAttrs) {
|
|
1593
|
+
const isLookup = isLookupType(attr.AttributeType);
|
|
1594
|
+
const propertyName = isLookup ? toLookupValueProperty(attr.LogicalName) : attr.LogicalName;
|
|
1595
|
+
const tsType = getEntityPropertyType(attr.AttributeType, isLookup);
|
|
1596
|
+
const label = getJSDocLabel(attr.DisplayName, labelConfig);
|
|
1597
|
+
const jsdocParts = [];
|
|
1598
|
+
if (label) jsdocParts.push(label);
|
|
1599
|
+
if (isLookup) {
|
|
1600
|
+
const targets = lookupTargets.get(attr.LogicalName);
|
|
1601
|
+
if (targets && targets.length > 0) {
|
|
1602
|
+
jsdocParts.push(`Lookup (${targets.join(" | ")})`);
|
|
1603
|
+
}
|
|
1604
|
+
}
|
|
1605
|
+
if (!attr.IsValidForCreate && !attr.IsValidForUpdate && !attr.IsPrimaryId) {
|
|
1606
|
+
jsdocParts.push("read-only");
|
|
1607
|
+
}
|
|
1608
|
+
if (jsdocParts.length > 0) {
|
|
1609
|
+
lines.push(` /** ${jsdocParts.join(" - ")} */`);
|
|
1610
|
+
}
|
|
1611
|
+
lines.push(` ${propertyName}: ${tsType} | null;`);
|
|
1612
|
+
}
|
|
1613
|
+
const partyListAttrs = info.attributes.filter((a) => isPartyListType(a.AttributeType));
|
|
1614
|
+
if (partyListAttrs.length > 0) {
|
|
1615
|
+
const relationship = info.oneToManyRelationships.find(
|
|
1616
|
+
(r) => r.ReferencingEntity === "activityparty"
|
|
1617
|
+
);
|
|
1618
|
+
const navPropName = relationship ? relationship.SchemaName.charAt(0).toLowerCase() + relationship.SchemaName.slice(1) : `${info.entity.LogicalName}_activity_parties`;
|
|
1619
|
+
lines.push("");
|
|
1620
|
+
lines.push(` /** ActivityParty collection (${partyListAttrs.length} PartyList-Felder: ${partyListAttrs.map((a) => a.LogicalName).join(", ")}) */`);
|
|
1621
|
+
lines.push(` ${navPropName}: ActivityParty[] | null;`);
|
|
1622
|
+
}
|
|
1623
|
+
lines.push(" }");
|
|
1624
|
+
lines.push("}");
|
|
1625
|
+
lines.push("");
|
|
1626
|
+
return lines.join("\n");
|
|
1627
|
+
}
|
|
1628
|
+
|
|
1629
|
+
// src/generators/optionset-generator.ts
|
|
1630
|
+
function generateOptionSetEnum(optionSet, _entityLogicalName, attributeSchemaName, options = {}) {
|
|
1631
|
+
const labelConfig = options.labelConfig || DEFAULT_LABEL_CONFIG;
|
|
1632
|
+
const namespace = options.namespace || "XrmForge.OptionSets";
|
|
1633
|
+
const enumName = optionSet.IsGlobal ? toPascalCase(optionSet.Name) : toPascalCase(attributeSchemaName);
|
|
1634
|
+
const rawMembers = optionSet.Options.map((opt) => {
|
|
1635
|
+
const label = getPrimaryLabel(opt.Label, labelConfig);
|
|
1636
|
+
const memberName = labelToEnumMember(label);
|
|
1637
|
+
return {
|
|
1638
|
+
name: memberName || `Value_${opt.Value}`,
|
|
1639
|
+
// Fallback for empty/invalid labels
|
|
1640
|
+
value: opt.Value,
|
|
1641
|
+
option: opt
|
|
1642
|
+
};
|
|
1643
|
+
});
|
|
1644
|
+
const disambiguated = disambiguateEnumMembers(
|
|
1645
|
+
rawMembers.map((m) => ({ name: m.name, value: m.value }))
|
|
1646
|
+
);
|
|
1647
|
+
const lines = [];
|
|
1648
|
+
lines.push(`declare namespace ${namespace} {`);
|
|
1649
|
+
lines.push("");
|
|
1650
|
+
const enumLabel = getJSDocLabel(optionSet.DisplayName, labelConfig);
|
|
1651
|
+
if (enumLabel) {
|
|
1652
|
+
lines.push(` /** ${enumLabel} (${optionSet.Name}) */`);
|
|
1653
|
+
}
|
|
1654
|
+
lines.push(` const enum ${enumName} {`);
|
|
1655
|
+
for (let i = 0; i < disambiguated.length; i++) {
|
|
1656
|
+
const member = disambiguated[i];
|
|
1657
|
+
const rawMember = rawMembers[i];
|
|
1658
|
+
if (!rawMember) continue;
|
|
1659
|
+
const memberLabel = getJSDocLabel(rawMember.option.Label, labelConfig);
|
|
1660
|
+
if (memberLabel) {
|
|
1661
|
+
lines.push(` /** ${memberLabel} */`);
|
|
1662
|
+
}
|
|
1663
|
+
lines.push(` ${member.name} = ${member.value},`);
|
|
1664
|
+
}
|
|
1665
|
+
lines.push(" }");
|
|
1666
|
+
lines.push("}");
|
|
1667
|
+
lines.push("");
|
|
1668
|
+
return lines.join("\n");
|
|
1669
|
+
}
|
|
1670
|
+
function generateEntityOptionSets(picklistAttributes, entityLogicalName, options = {}) {
|
|
1671
|
+
const results = [];
|
|
1672
|
+
const generatedGlobals = /* @__PURE__ */ new Set();
|
|
1673
|
+
for (const attr of picklistAttributes) {
|
|
1674
|
+
const optionSet = attr.OptionSet || attr.GlobalOptionSet;
|
|
1675
|
+
if (!optionSet || !optionSet.Options || optionSet.Options.length === 0) continue;
|
|
1676
|
+
if (optionSet.IsGlobal && generatedGlobals.has(optionSet.Name)) continue;
|
|
1677
|
+
if (optionSet.IsGlobal) generatedGlobals.add(optionSet.Name);
|
|
1678
|
+
const enumName = optionSet.IsGlobal ? toPascalCase(optionSet.Name) : toPascalCase(attr.SchemaName);
|
|
1679
|
+
const content = generateOptionSetEnum(optionSet, entityLogicalName, attr.SchemaName, options);
|
|
1680
|
+
results.push({ enumName, content });
|
|
1681
|
+
}
|
|
1682
|
+
return results;
|
|
1683
|
+
}
|
|
1684
|
+
|
|
1685
|
+
// src/generators/form-generator.ts
|
|
1686
|
+
function specialControlToXrmType(controlType) {
|
|
1687
|
+
switch (controlType) {
|
|
1688
|
+
case "subgrid":
|
|
1689
|
+
return "Xrm.Controls.GridControl";
|
|
1690
|
+
case "editablegrid":
|
|
1691
|
+
return "Xrm.Controls.GridControl";
|
|
1692
|
+
case "quickview":
|
|
1693
|
+
return "Xrm.Controls.QuickFormControl";
|
|
1694
|
+
case "webresource":
|
|
1695
|
+
return "Xrm.Controls.IframeControl";
|
|
1696
|
+
case "iframe":
|
|
1697
|
+
return "Xrm.Controls.IframeControl";
|
|
1698
|
+
case "notes":
|
|
1699
|
+
return "Xrm.Controls.Control";
|
|
1700
|
+
case "map":
|
|
1701
|
+
return "Xrm.Controls.Control";
|
|
1702
|
+
default:
|
|
1703
|
+
return null;
|
|
1704
|
+
}
|
|
1705
|
+
}
|
|
1706
|
+
function toSafeFormName(formName) {
|
|
1707
|
+
const result = transliterateUmlauts(formName).replace(/[^a-zA-Z0-9\s]/g, "").split(/\s+/).map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join("");
|
|
1708
|
+
if (/^\d/.test(result)) return `_${result}`;
|
|
1709
|
+
if (result.length === 0) return "_Unnamed";
|
|
1710
|
+
return result;
|
|
1711
|
+
}
|
|
1712
|
+
function buildFormBaseName(entityPascal, safeFormName) {
|
|
1713
|
+
if (safeFormName.startsWith(entityPascal)) {
|
|
1714
|
+
return safeFormName;
|
|
1715
|
+
}
|
|
1716
|
+
return `${entityPascal}${safeFormName}`;
|
|
1717
|
+
}
|
|
1718
|
+
function labelToPascalMember(label) {
|
|
1719
|
+
if (!label) return "";
|
|
1720
|
+
const transliterated = transliterateUmlauts(label);
|
|
1721
|
+
const cleaned = transliterated.replace(/[^a-zA-Z0-9\s_]/g, "");
|
|
1722
|
+
const parts = cleaned.split(/[\s_]+/).filter((p) => p.length > 0);
|
|
1723
|
+
if (parts.length === 0) return "";
|
|
1724
|
+
const pascal = parts.map((p) => p.charAt(0).toUpperCase() + p.slice(1).toLowerCase()).join("");
|
|
1725
|
+
if (/^\d/.test(pascal)) return `_${pascal}`;
|
|
1726
|
+
return pascal;
|
|
1727
|
+
}
|
|
1728
|
+
function generateFormInterface(form, entityLogicalName, attributeMap, options = {}, baseNameOverride) {
|
|
1729
|
+
const labelConfig = options.labelConfig || DEFAULT_LABEL_CONFIG;
|
|
1730
|
+
const namespacePrefix = options.namespacePrefix || "XrmForge.Forms";
|
|
1731
|
+
const entityPascal = toPascalCase(entityLogicalName);
|
|
1732
|
+
const namespace = `${namespacePrefix}.${entityPascal}`;
|
|
1733
|
+
const baseName = baseNameOverride || buildFormBaseName(entityPascal, toSafeFormName(form.name));
|
|
1734
|
+
const interfaceName = `${baseName}Form`;
|
|
1735
|
+
const fieldsTypeName = `${baseName}FormFields`;
|
|
1736
|
+
const attrMapName = `${baseName}FormAttributeMap`;
|
|
1737
|
+
const ctrlMapName = `${baseName}FormControlMap`;
|
|
1738
|
+
const fieldNames = /* @__PURE__ */ new Set();
|
|
1739
|
+
for (const control of form.allControls) {
|
|
1740
|
+
if (control.datafieldname) {
|
|
1741
|
+
fieldNames.add(control.datafieldname);
|
|
1742
|
+
}
|
|
1743
|
+
}
|
|
1744
|
+
const fields = [];
|
|
1745
|
+
const usedEnumNames = /* @__PURE__ */ new Set();
|
|
1746
|
+
for (const fieldName of [...fieldNames].sort()) {
|
|
1747
|
+
const attr = attributeMap.get(fieldName);
|
|
1748
|
+
if (!attr) continue;
|
|
1749
|
+
const primaryLabel = getPrimaryLabel(attr.DisplayName, labelConfig);
|
|
1750
|
+
let enumMember = labelToPascalMember(primaryLabel);
|
|
1751
|
+
if (!enumMember) {
|
|
1752
|
+
enumMember = toPascalCase(fieldName);
|
|
1753
|
+
}
|
|
1754
|
+
const originalEnumMember = enumMember;
|
|
1755
|
+
let counter = 2;
|
|
1756
|
+
while (usedEnumNames.has(enumMember)) {
|
|
1757
|
+
enumMember = `${originalEnumMember}${counter}`;
|
|
1758
|
+
counter++;
|
|
1759
|
+
}
|
|
1760
|
+
usedEnumNames.add(enumMember);
|
|
1761
|
+
const dualLabel = getJSDocLabel(attr.DisplayName, labelConfig);
|
|
1762
|
+
fields.push({
|
|
1763
|
+
logicalName: fieldName,
|
|
1764
|
+
attributeType: attr.AttributeType,
|
|
1765
|
+
formAttributeType: getFormAttributeType(attr.AttributeType),
|
|
1766
|
+
formControlType: getFormControlType(attr.AttributeType),
|
|
1767
|
+
label: dualLabel,
|
|
1768
|
+
enumMemberName: enumMember
|
|
1769
|
+
});
|
|
1770
|
+
}
|
|
1771
|
+
const lines = [];
|
|
1772
|
+
lines.push(`declare namespace ${namespace} {`);
|
|
1773
|
+
lines.push("");
|
|
1774
|
+
lines.push(` /** Valid field names for the "${form.name}" form */`);
|
|
1775
|
+
lines.push(` type ${fieldsTypeName} =`);
|
|
1776
|
+
for (let i = 0; i < fields.length; i++) {
|
|
1777
|
+
const separator = i === fields.length - 1 ? ";" : "";
|
|
1778
|
+
lines.push(` | "${fields[i].logicalName}"${separator}`);
|
|
1779
|
+
}
|
|
1780
|
+
lines.push("");
|
|
1781
|
+
lines.push(` /** Attribute type map for "${form.name}" */`);
|
|
1782
|
+
lines.push(` type ${attrMapName} = {`);
|
|
1783
|
+
for (const field of fields) {
|
|
1784
|
+
lines.push(` ${field.logicalName}: ${field.formAttributeType};`);
|
|
1785
|
+
}
|
|
1786
|
+
lines.push(" };");
|
|
1787
|
+
lines.push("");
|
|
1788
|
+
lines.push(` /** Control type map for "${form.name}" */`);
|
|
1789
|
+
lines.push(` type ${ctrlMapName} = {`);
|
|
1790
|
+
for (const field of fields) {
|
|
1791
|
+
lines.push(` ${field.logicalName}: ${field.formControlType};`);
|
|
1792
|
+
}
|
|
1793
|
+
lines.push(" };");
|
|
1794
|
+
lines.push("");
|
|
1795
|
+
lines.push(` /** Field constants for "${form.name}" (compile-time only, zero runtime) */`);
|
|
1796
|
+
lines.push(` const enum ${fieldsTypeName}Enum {`);
|
|
1797
|
+
for (const field of fields) {
|
|
1798
|
+
if (field.label) {
|
|
1799
|
+
lines.push(` /** ${field.label} */`);
|
|
1800
|
+
}
|
|
1801
|
+
lines.push(` ${field.enumMemberName} = '${field.logicalName}',`);
|
|
1802
|
+
}
|
|
1803
|
+
lines.push(" }");
|
|
1804
|
+
lines.push("");
|
|
1805
|
+
const namedTabs = form.tabs.filter((t) => t.name);
|
|
1806
|
+
if (namedTabs.length > 0) {
|
|
1807
|
+
const tabsEnumName = `${baseName}FormTabs`;
|
|
1808
|
+
const usedTabMembers = /* @__PURE__ */ new Set();
|
|
1809
|
+
const tabMemberNames = [];
|
|
1810
|
+
for (const tab of namedTabs) {
|
|
1811
|
+
let memberName = toSafeFormName(tab.name) || toPascalCase(tab.name);
|
|
1812
|
+
const originalName = memberName;
|
|
1813
|
+
let counter = 2;
|
|
1814
|
+
while (usedTabMembers.has(memberName)) {
|
|
1815
|
+
memberName = `${originalName}${counter}`;
|
|
1816
|
+
counter++;
|
|
1817
|
+
}
|
|
1818
|
+
usedTabMembers.add(memberName);
|
|
1819
|
+
tabMemberNames.push(memberName);
|
|
1820
|
+
}
|
|
1821
|
+
lines.push(` /** Tab constants for "${form.name}" (compile-time only, zero runtime) */`);
|
|
1822
|
+
lines.push(` const enum ${tabsEnumName} {`);
|
|
1823
|
+
for (let i = 0; i < namedTabs.length; i++) {
|
|
1824
|
+
const tab = namedTabs[i];
|
|
1825
|
+
if (tab.label) {
|
|
1826
|
+
lines.push(` /** ${tab.label} */`);
|
|
1827
|
+
}
|
|
1828
|
+
lines.push(` ${tabMemberNames[i]} = '${tab.name}',`);
|
|
1829
|
+
}
|
|
1830
|
+
lines.push(" }");
|
|
1831
|
+
lines.push("");
|
|
1832
|
+
for (let i = 0; i < namedTabs.length; i++) {
|
|
1833
|
+
const tab = namedTabs[i];
|
|
1834
|
+
const namedSections = tab.sections.filter((s) => s.name);
|
|
1835
|
+
if (namedSections.length === 0) continue;
|
|
1836
|
+
const tabMemberName = tabMemberNames[i];
|
|
1837
|
+
const sectionsEnumName = `${baseName}Form${tabMemberName}Sections`;
|
|
1838
|
+
lines.push(` /** Section constants for tab "${tab.name}" (compile-time only, zero runtime) */`);
|
|
1839
|
+
lines.push(` const enum ${sectionsEnumName} {`);
|
|
1840
|
+
const usedSectionMembers = /* @__PURE__ */ new Set();
|
|
1841
|
+
for (const section of namedSections) {
|
|
1842
|
+
if (section.label) {
|
|
1843
|
+
lines.push(` /** ${section.label} */`);
|
|
1844
|
+
}
|
|
1845
|
+
let sectionMember = toSafeFormName(section.name) || toPascalCase(section.name);
|
|
1846
|
+
const originalSectionMember = sectionMember;
|
|
1847
|
+
let sCounter = 2;
|
|
1848
|
+
while (usedSectionMembers.has(sectionMember)) {
|
|
1849
|
+
sectionMember = `${originalSectionMember}${sCounter}`;
|
|
1850
|
+
sCounter++;
|
|
1851
|
+
}
|
|
1852
|
+
usedSectionMembers.add(sectionMember);
|
|
1853
|
+
lines.push(` ${sectionMember} = '${section.name}',`);
|
|
1854
|
+
}
|
|
1855
|
+
lines.push(" }");
|
|
1856
|
+
lines.push("");
|
|
1857
|
+
}
|
|
1858
|
+
}
|
|
1859
|
+
lines.push(` /** ${form.name} */`);
|
|
1860
|
+
lines.push(` interface ${interfaceName} extends Omit<Xrm.FormContext, 'getAttribute' | 'getControl'> {`);
|
|
1861
|
+
lines.push(` /** Typisierter Feldzugriff: nur Felder die auf diesem Formular existieren */`);
|
|
1862
|
+
lines.push(` getAttribute<K extends ${fieldsTypeName}>(name: K): ${attrMapName}[K];`);
|
|
1863
|
+
lines.push(" getAttribute(index: number): Xrm.Attributes.Attribute;");
|
|
1864
|
+
lines.push(" getAttribute(): Xrm.Attributes.Attribute[];");
|
|
1865
|
+
lines.push("");
|
|
1866
|
+
lines.push(` /** Typisierter Control-Zugriff: nur Controls die auf diesem Formular existieren */`);
|
|
1867
|
+
lines.push(` getControl<K extends ${fieldsTypeName}>(name: K): ${ctrlMapName}[K];`);
|
|
1868
|
+
const specialControls = form.allSpecialControls || [];
|
|
1869
|
+
for (const sc of specialControls) {
|
|
1870
|
+
const xrmType = specialControlToXrmType(sc.controlType);
|
|
1871
|
+
if (xrmType) {
|
|
1872
|
+
lines.push(` getControl(name: "${sc.id}"): ${xrmType};`);
|
|
1873
|
+
}
|
|
1874
|
+
}
|
|
1875
|
+
lines.push(" getControl(index: number): Xrm.Controls.Control;");
|
|
1876
|
+
lines.push(" getControl(): Xrm.Controls.Control[];");
|
|
1877
|
+
if (form.tabs.length > 0) {
|
|
1878
|
+
lines.push("");
|
|
1879
|
+
lines.push(" /** Typisierter Tab-Zugriff */");
|
|
1880
|
+
lines.push(" ui: {");
|
|
1881
|
+
lines.push(" tabs: {");
|
|
1882
|
+
for (const tab of form.tabs) {
|
|
1883
|
+
if (tab.name) {
|
|
1884
|
+
const sectionNames = tab.sections.filter((s) => s.name).map((s) => s.name);
|
|
1885
|
+
if (sectionNames.length > 0) {
|
|
1886
|
+
lines.push(` get(name: "${tab.name}"): Xrm.Controls.Tab & {`);
|
|
1887
|
+
lines.push(" sections: {");
|
|
1888
|
+
for (const sectionName of sectionNames) {
|
|
1889
|
+
lines.push(` get(name: "${sectionName}"): Xrm.Controls.Section;`);
|
|
1890
|
+
}
|
|
1891
|
+
lines.push(" get(name: string): Xrm.Controls.Section;");
|
|
1892
|
+
lines.push(" };");
|
|
1893
|
+
lines.push(" };");
|
|
1894
|
+
} else {
|
|
1895
|
+
lines.push(` get(name: "${tab.name}"): Xrm.Controls.Tab;`);
|
|
1896
|
+
}
|
|
1897
|
+
}
|
|
1898
|
+
}
|
|
1899
|
+
lines.push(" get(name: string): Xrm.Controls.Tab;");
|
|
1900
|
+
lines.push(" };");
|
|
1901
|
+
lines.push(" } & Xrm.Ui;");
|
|
1902
|
+
}
|
|
1903
|
+
lines.push(" }");
|
|
1904
|
+
lines.push("}");
|
|
1905
|
+
lines.push("");
|
|
1906
|
+
return lines.join("\n");
|
|
1907
|
+
}
|
|
1908
|
+
function generateEntityForms(forms, entityLogicalName, attributes, options = {}) {
|
|
1909
|
+
const attributeMap = /* @__PURE__ */ new Map();
|
|
1910
|
+
for (const attr of attributes) {
|
|
1911
|
+
attributeMap.set(attr.LogicalName, attr);
|
|
1912
|
+
}
|
|
1913
|
+
const entityPascal = toPascalCase(entityLogicalName);
|
|
1914
|
+
const validForms = forms.filter((f) => f.allControls.length > 0);
|
|
1915
|
+
const baseNames = validForms.map((form) => {
|
|
1916
|
+
const safeFormName = toSafeFormName(form.name);
|
|
1917
|
+
return buildFormBaseName(entityPascal, safeFormName);
|
|
1918
|
+
});
|
|
1919
|
+
const baseNameCounts = /* @__PURE__ */ new Map();
|
|
1920
|
+
for (const name of baseNames) {
|
|
1921
|
+
baseNameCounts.set(name, (baseNameCounts.get(name) || 0) + 1);
|
|
1922
|
+
}
|
|
1923
|
+
const baseNameCounters = /* @__PURE__ */ new Map();
|
|
1924
|
+
const results = [];
|
|
1925
|
+
for (let i = 0; i < validForms.length; i++) {
|
|
1926
|
+
const form = validForms[i];
|
|
1927
|
+
let baseName = baseNames[i];
|
|
1928
|
+
if (baseNameCounts.get(baseName) > 1) {
|
|
1929
|
+
const counter = (baseNameCounters.get(baseName) || 0) + 1;
|
|
1930
|
+
baseNameCounters.set(baseName, counter);
|
|
1931
|
+
if (counter > 1) {
|
|
1932
|
+
baseName = `${baseName}${counter}`;
|
|
1933
|
+
}
|
|
1934
|
+
}
|
|
1935
|
+
const interfaceName = `${baseName}Form`;
|
|
1936
|
+
const content = generateFormInterface(form, entityLogicalName, attributeMap, options, baseName);
|
|
1937
|
+
results.push({ formName: form.name, interfaceName, content });
|
|
1938
|
+
}
|
|
1939
|
+
return results;
|
|
1940
|
+
}
|
|
1941
|
+
|
|
1942
|
+
// src/orchestrator/file-writer.ts
|
|
1943
|
+
import { mkdir, writeFile, readFile } from "fs/promises";
|
|
1944
|
+
import { join as join2, dirname } from "path";
|
|
1945
|
+
async function writeGeneratedFile(outputDir, file) {
|
|
1946
|
+
const absolutePath = join2(outputDir, file.relativePath);
|
|
1947
|
+
try {
|
|
1948
|
+
const existing = await readFile(absolutePath, "utf-8");
|
|
1949
|
+
if (existing === file.content) {
|
|
1950
|
+
return false;
|
|
1951
|
+
}
|
|
1952
|
+
} catch {
|
|
1953
|
+
}
|
|
1954
|
+
await mkdir(dirname(absolutePath), { recursive: true });
|
|
1955
|
+
await writeFile(absolutePath, file.content, "utf-8");
|
|
1956
|
+
return true;
|
|
1957
|
+
}
|
|
1958
|
+
async function writeAllFiles(outputDir, files) {
|
|
1959
|
+
const result = { written: 0, unchanged: 0, warnings: [] };
|
|
1960
|
+
for (const file of files) {
|
|
1961
|
+
try {
|
|
1962
|
+
const changed = await writeGeneratedFile(outputDir, file);
|
|
1963
|
+
if (changed) {
|
|
1964
|
+
result.written++;
|
|
1965
|
+
} else {
|
|
1966
|
+
result.unchanged++;
|
|
1967
|
+
}
|
|
1968
|
+
} catch (err) {
|
|
1969
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1970
|
+
result.warnings.push(`Konnte ${file.relativePath} nicht schreiben: ${message}`);
|
|
1971
|
+
}
|
|
1972
|
+
}
|
|
1973
|
+
return result;
|
|
1974
|
+
}
|
|
1975
|
+
var GENERATED_HEADER = `// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
1976
|
+
// This file was generated by @xrmforge/typegen. Do not edit manually.
|
|
1977
|
+
// Re-run 'xrmforge generate' to update.
|
|
1978
|
+
// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
1979
|
+
|
|
1980
|
+
`;
|
|
1981
|
+
function addGeneratedHeader(content) {
|
|
1982
|
+
return GENERATED_HEADER + content;
|
|
1983
|
+
}
|
|
1984
|
+
function generateBarrelIndex(files) {
|
|
1985
|
+
const lines = [GENERATED_HEADER];
|
|
1986
|
+
const entities = files.filter((f) => f.type === "entity");
|
|
1987
|
+
const optionsets = files.filter((f) => f.type === "optionset");
|
|
1988
|
+
const forms = files.filter((f) => f.type === "form");
|
|
1989
|
+
if (entities.length > 0) {
|
|
1990
|
+
lines.push("// Entity Interfaces");
|
|
1991
|
+
for (const f of entities) {
|
|
1992
|
+
lines.push(`/// <reference path="${f.relativePath}" />`);
|
|
1993
|
+
}
|
|
1994
|
+
lines.push("");
|
|
1995
|
+
}
|
|
1996
|
+
if (optionsets.length > 0) {
|
|
1997
|
+
lines.push("// OptionSet Enums");
|
|
1998
|
+
for (const f of optionsets) {
|
|
1999
|
+
lines.push(`/// <reference path="${f.relativePath}" />`);
|
|
2000
|
+
}
|
|
2001
|
+
lines.push("");
|
|
2002
|
+
}
|
|
2003
|
+
if (forms.length > 0) {
|
|
2004
|
+
lines.push("// Form Interfaces");
|
|
2005
|
+
for (const f of forms) {
|
|
2006
|
+
lines.push(`/// <reference path="${f.relativePath}" />`);
|
|
2007
|
+
}
|
|
2008
|
+
lines.push("");
|
|
2009
|
+
}
|
|
2010
|
+
return lines.join("\n");
|
|
2011
|
+
}
|
|
2012
|
+
|
|
2013
|
+
// src/orchestrator/orchestrator.ts
|
|
2014
|
+
var TypeGenerationOrchestrator = class {
|
|
2015
|
+
config;
|
|
2016
|
+
credential;
|
|
2017
|
+
logger;
|
|
2018
|
+
constructor(credential, config, logger) {
|
|
2019
|
+
this.credential = credential;
|
|
2020
|
+
this.logger = logger ?? createLogger("orchestrator");
|
|
2021
|
+
if (config.useCache) {
|
|
2022
|
+
throw new Error(
|
|
2023
|
+
"Metadata caching is not yet implemented (planned for v0.2.0). Remove the useCache option or set it to false."
|
|
2024
|
+
);
|
|
2025
|
+
}
|
|
2026
|
+
this.config = {
|
|
2027
|
+
environmentUrl: config.environmentUrl,
|
|
2028
|
+
entities: [...config.entities],
|
|
2029
|
+
solutionNames: config.solutionNames ?? [],
|
|
2030
|
+
outputDir: config.outputDir,
|
|
2031
|
+
labelConfig: config.labelConfig,
|
|
2032
|
+
generateEntities: config.generateEntities ?? true,
|
|
2033
|
+
generateForms: config.generateForms ?? true,
|
|
2034
|
+
generateOptionSets: config.generateOptionSets ?? true,
|
|
2035
|
+
useCache: config.useCache ?? false,
|
|
2036
|
+
cacheDir: config.cacheDir ?? ".xrmforge/cache",
|
|
2037
|
+
namespacePrefix: config.namespacePrefix ?? "XrmForge"
|
|
2038
|
+
};
|
|
2039
|
+
}
|
|
2040
|
+
/**
|
|
2041
|
+
* Run the full type generation pipeline.
|
|
2042
|
+
*
|
|
2043
|
+
* @param options - Optional parameters
|
|
2044
|
+
* @param options.signal - AbortSignal to cancel the generation process.
|
|
2045
|
+
* When aborted, entities that have not yet started processing are skipped.
|
|
2046
|
+
* Entities already in progress may still complete or fail with an abort error.
|
|
2047
|
+
*/
|
|
2048
|
+
async generate(options) {
|
|
2049
|
+
const signal = options?.signal;
|
|
2050
|
+
const startTime = Date.now();
|
|
2051
|
+
const allFiles = [];
|
|
2052
|
+
const entityResults = [];
|
|
2053
|
+
if (signal?.aborted) {
|
|
2054
|
+
return { entities: [], totalFiles: 0, totalWarnings: 0, durationMs: 0 };
|
|
2055
|
+
}
|
|
2056
|
+
this.logger.info("Starting type generation", {
|
|
2057
|
+
entities: this.config.entities,
|
|
2058
|
+
outputDir: this.config.outputDir
|
|
2059
|
+
});
|
|
2060
|
+
const httpClient = new DataverseHttpClient({
|
|
2061
|
+
environmentUrl: this.config.environmentUrl,
|
|
2062
|
+
credential: this.credential
|
|
2063
|
+
});
|
|
2064
|
+
const metadataClient = new MetadataClient(httpClient);
|
|
2065
|
+
if (this.config.solutionNames && this.config.solutionNames.length > 0) {
|
|
2066
|
+
this.logger.info(`Resolving entities from ${this.config.solutionNames.length} solution(s): ${this.config.solutionNames.join(", ")}`);
|
|
2067
|
+
const solutionEntityNames = await metadataClient.getEntityNamesForSolutions(this.config.solutionNames);
|
|
2068
|
+
this.logger.info(`Found ${solutionEntityNames.length} entities in solution(s)`);
|
|
2069
|
+
const merged = /* @__PURE__ */ new Set([...this.config.entities, ...solutionEntityNames]);
|
|
2070
|
+
this.config.entities = [...merged].sort();
|
|
2071
|
+
}
|
|
2072
|
+
if (this.config.entities.length === 0) {
|
|
2073
|
+
this.logger.warn("No entities to process. Check --entities or --solutions.");
|
|
2074
|
+
return {
|
|
2075
|
+
entities: [],
|
|
2076
|
+
totalFiles: 0,
|
|
2077
|
+
totalWarnings: 1,
|
|
2078
|
+
durationMs: Date.now() - startTime
|
|
2079
|
+
};
|
|
2080
|
+
}
|
|
2081
|
+
this.logger.info(`Processing ${this.config.entities.length} entities in parallel`);
|
|
2082
|
+
const settled = await Promise.allSettled(
|
|
2083
|
+
this.config.entities.map((entityName) => {
|
|
2084
|
+
if (signal?.aborted) {
|
|
2085
|
+
return Promise.reject(new Error("Generation aborted"));
|
|
2086
|
+
}
|
|
2087
|
+
return this.processEntity(entityName, metadataClient).then((result) => {
|
|
2088
|
+
this.logger.info(`Completed entity: ${entityName} (${result.files.length} files)`);
|
|
2089
|
+
return result;
|
|
2090
|
+
});
|
|
2091
|
+
})
|
|
2092
|
+
);
|
|
2093
|
+
for (let i = 0; i < settled.length; i++) {
|
|
2094
|
+
const outcome = settled[i];
|
|
2095
|
+
const entityName = this.config.entities[i];
|
|
2096
|
+
if (outcome.status === "fulfilled") {
|
|
2097
|
+
entityResults.push(outcome.value);
|
|
2098
|
+
allFiles.push(...outcome.value.files);
|
|
2099
|
+
} else {
|
|
2100
|
+
const errorMsg = outcome.reason instanceof Error ? outcome.reason.message : String(outcome.reason);
|
|
2101
|
+
this.logger.error(`Failed to process entity: ${entityName}`, { error: outcome.reason });
|
|
2102
|
+
entityResults.push({
|
|
2103
|
+
entityLogicalName: entityName,
|
|
2104
|
+
files: [],
|
|
2105
|
+
warnings: [`Failed to process: ${errorMsg}`]
|
|
2106
|
+
});
|
|
2107
|
+
}
|
|
2108
|
+
}
|
|
2109
|
+
if (allFiles.length > 0) {
|
|
2110
|
+
const indexContent = generateBarrelIndex(allFiles);
|
|
2111
|
+
const indexFile = {
|
|
2112
|
+
relativePath: "index.d.ts",
|
|
2113
|
+
content: indexContent,
|
|
2114
|
+
type: "entity"
|
|
2115
|
+
};
|
|
2116
|
+
allFiles.push(indexFile);
|
|
2117
|
+
}
|
|
2118
|
+
const writeResult = await writeAllFiles(this.config.outputDir, allFiles);
|
|
2119
|
+
const durationMs = Date.now() - startTime;
|
|
2120
|
+
const entityWarnings = entityResults.reduce((sum, r) => sum + r.warnings.length, 0);
|
|
2121
|
+
const totalWarnings = entityWarnings + writeResult.warnings.length;
|
|
2122
|
+
for (const w of writeResult.warnings) {
|
|
2123
|
+
this.logger.warn(w);
|
|
2124
|
+
}
|
|
2125
|
+
this.logger.info("Type generation complete", {
|
|
2126
|
+
entities: entityResults.length,
|
|
2127
|
+
filesWritten: writeResult.written,
|
|
2128
|
+
filesUnchanged: writeResult.unchanged,
|
|
2129
|
+
totalFiles: allFiles.length,
|
|
2130
|
+
totalWarnings,
|
|
2131
|
+
durationMs
|
|
2132
|
+
});
|
|
2133
|
+
return {
|
|
2134
|
+
entities: entityResults,
|
|
2135
|
+
totalFiles: allFiles.length,
|
|
2136
|
+
totalWarnings,
|
|
2137
|
+
durationMs
|
|
2138
|
+
};
|
|
2139
|
+
}
|
|
2140
|
+
/**
|
|
2141
|
+
* Process a single entity: fetch metadata, generate all output files.
|
|
2142
|
+
*/
|
|
2143
|
+
async processEntity(entityName, metadataClient) {
|
|
2144
|
+
const warnings = [];
|
|
2145
|
+
const files = [];
|
|
2146
|
+
const entityInfo = await metadataClient.getEntityTypeInfo(entityName);
|
|
2147
|
+
if (this.config.generateEntities) {
|
|
2148
|
+
const entityContent = generateEntityInterface(entityInfo, {
|
|
2149
|
+
labelConfig: this.config.labelConfig,
|
|
2150
|
+
namespace: `${this.config.namespacePrefix}.Entities`
|
|
2151
|
+
});
|
|
2152
|
+
files.push({
|
|
2153
|
+
relativePath: `entities/${entityName}.d.ts`,
|
|
2154
|
+
content: addGeneratedHeader(entityContent),
|
|
2155
|
+
type: "entity"
|
|
2156
|
+
});
|
|
2157
|
+
}
|
|
2158
|
+
if (this.config.generateOptionSets) {
|
|
2159
|
+
const picklistAttrs = this.getPicklistAttributes(entityInfo);
|
|
2160
|
+
if (picklistAttrs.length > 0) {
|
|
2161
|
+
const optionSets = generateEntityOptionSets(picklistAttrs, entityName, {
|
|
2162
|
+
labelConfig: this.config.labelConfig,
|
|
2163
|
+
namespace: `${this.config.namespacePrefix}.OptionSets`
|
|
2164
|
+
});
|
|
2165
|
+
if (optionSets.length > 0) {
|
|
2166
|
+
const combinedContent = optionSets.map((os) => os.content).join("\n");
|
|
2167
|
+
files.push({
|
|
2168
|
+
relativePath: `optionsets/${entityName}.d.ts`,
|
|
2169
|
+
content: addGeneratedHeader(combinedContent),
|
|
2170
|
+
type: "optionset"
|
|
2171
|
+
});
|
|
2172
|
+
}
|
|
2173
|
+
} else {
|
|
2174
|
+
warnings.push(`No OptionSet attributes found for ${entityName}`);
|
|
2175
|
+
}
|
|
2176
|
+
}
|
|
2177
|
+
if (this.config.generateForms) {
|
|
2178
|
+
if (entityInfo.forms.length > 0) {
|
|
2179
|
+
const formResults = generateEntityForms(
|
|
2180
|
+
entityInfo.forms,
|
|
2181
|
+
entityName,
|
|
2182
|
+
entityInfo.attributes,
|
|
2183
|
+
{
|
|
2184
|
+
labelConfig: this.config.labelConfig,
|
|
2185
|
+
namespacePrefix: `${this.config.namespacePrefix}.Forms`
|
|
2186
|
+
}
|
|
2187
|
+
);
|
|
2188
|
+
if (formResults.length > 0) {
|
|
2189
|
+
const combinedContent = formResults.map((f) => f.content).join("\n");
|
|
2190
|
+
files.push({
|
|
2191
|
+
relativePath: `forms/${entityName}.d.ts`,
|
|
2192
|
+
content: addGeneratedHeader(combinedContent),
|
|
2193
|
+
type: "form"
|
|
2194
|
+
});
|
|
2195
|
+
}
|
|
2196
|
+
} else {
|
|
2197
|
+
warnings.push(`No forms found for ${entityName}`);
|
|
2198
|
+
}
|
|
2199
|
+
}
|
|
2200
|
+
return { entityLogicalName: entityName, files, warnings };
|
|
2201
|
+
}
|
|
2202
|
+
/**
|
|
2203
|
+
* Extract picklist attributes with their OptionSet metadata.
|
|
2204
|
+
* Maps the raw EntityTypeInfo data to the format expected by the OptionSet generator.
|
|
2205
|
+
*/
|
|
2206
|
+
getPicklistAttributes(entityInfo) {
|
|
2207
|
+
const result = [];
|
|
2208
|
+
for (const attr of entityInfo.picklistAttributes) {
|
|
2209
|
+
result.push({
|
|
2210
|
+
SchemaName: attr.SchemaName,
|
|
2211
|
+
OptionSet: attr.OptionSet ?? null,
|
|
2212
|
+
GlobalOptionSet: attr.GlobalOptionSet ?? null
|
|
2213
|
+
});
|
|
2214
|
+
}
|
|
2215
|
+
for (const attr of entityInfo.stateAttributes) {
|
|
2216
|
+
result.push({
|
|
2217
|
+
SchemaName: attr.SchemaName,
|
|
2218
|
+
OptionSet: attr.OptionSet ?? null,
|
|
2219
|
+
GlobalOptionSet: null
|
|
2220
|
+
});
|
|
2221
|
+
}
|
|
2222
|
+
for (const attr of entityInfo.statusAttributes) {
|
|
2223
|
+
result.push({
|
|
2224
|
+
SchemaName: attr.SchemaName,
|
|
2225
|
+
OptionSet: attr.OptionSet ?? null,
|
|
2226
|
+
GlobalOptionSet: null
|
|
2227
|
+
});
|
|
2228
|
+
}
|
|
2229
|
+
return result;
|
|
2230
|
+
}
|
|
2231
|
+
};
|
|
2232
|
+
export {
|
|
2233
|
+
ApiRequestError,
|
|
2234
|
+
AuthenticationError,
|
|
2235
|
+
ConfigError,
|
|
2236
|
+
ConsoleLogSink,
|
|
2237
|
+
DEFAULT_LABEL_CONFIG,
|
|
2238
|
+
DataverseHttpClient,
|
|
2239
|
+
ErrorCode,
|
|
2240
|
+
FastXmlParser,
|
|
2241
|
+
GenerationError,
|
|
2242
|
+
JsonLogSink,
|
|
2243
|
+
LogLevel,
|
|
2244
|
+
Logger,
|
|
2245
|
+
MetadataCache,
|
|
2246
|
+
MetadataClient,
|
|
2247
|
+
MetadataError,
|
|
2248
|
+
SilentLogSink,
|
|
2249
|
+
TypeGenerationOrchestrator,
|
|
2250
|
+
XrmForgeError,
|
|
2251
|
+
configureLogging,
|
|
2252
|
+
createCredential,
|
|
2253
|
+
createLogger,
|
|
2254
|
+
defaultXmlParser,
|
|
2255
|
+
disambiguateEnumMembers,
|
|
2256
|
+
extractControlFields,
|
|
2257
|
+
getJSDocLabel as formatDualLabel,
|
|
2258
|
+
generateEntityForms,
|
|
2259
|
+
generateEntityInterface,
|
|
2260
|
+
generateEntityOptionSets,
|
|
2261
|
+
generateEnumMembers,
|
|
2262
|
+
generateFormInterface,
|
|
2263
|
+
generateOptionSetEnum,
|
|
2264
|
+
getEntityPropertyType,
|
|
2265
|
+
getFormAttributeType,
|
|
2266
|
+
getFormControlType,
|
|
2267
|
+
getJSDocLabel,
|
|
2268
|
+
getLabelLanguagesParam,
|
|
2269
|
+
getPrimaryLabel,
|
|
2270
|
+
getSecondaryLabel,
|
|
2271
|
+
isLookupType,
|
|
2272
|
+
isRateLimitError,
|
|
2273
|
+
isXrmForgeError,
|
|
2274
|
+
labelToIdentifier,
|
|
2275
|
+
parseForm,
|
|
2276
|
+
shouldIncludeInEntityInterface,
|
|
2277
|
+
toLookupValueProperty,
|
|
2278
|
+
toPascalCase,
|
|
2279
|
+
toSafeIdentifier
|
|
2280
|
+
};
|
|
2281
|
+
//# sourceMappingURL=index.js.map
|