@xrmforge/typegen 0.3.0 → 0.5.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.js CHANGED
@@ -13,6 +13,7 @@ var ErrorCode = /* @__PURE__ */ ((ErrorCode2) => {
13
13
  ErrorCode2["META_SOLUTION_NOT_FOUND"] = "META_3002";
14
14
  ErrorCode2["META_FORM_PARSE_FAILED"] = "META_3003";
15
15
  ErrorCode2["META_ATTRIBUTE_UNKNOWN_TYPE"] = "META_3004";
16
+ ErrorCode2["META_VERSION_STAMP_EXPIRED"] = "META_3005";
16
17
  ErrorCode2["GEN_OUTPUT_WRITE_FAILED"] = "GEN_4001";
17
18
  ErrorCode2["GEN_TEMPLATE_FAILED"] = "GEN_4002";
18
19
  ErrorCode2["GEN_INVALID_IDENTIFIER"] = "GEN_4003";
@@ -331,6 +332,8 @@ var DataverseHttpClient = class {
331
332
  maxRateLimitRetries;
332
333
  readOnly;
333
334
  cachedToken = null;
335
+ /** Pending token refresh promise (prevents concurrent token requests) */
336
+ pendingTokenRefresh = null;
334
337
  // Semaphore for concurrency control (non-recursive)
335
338
  activeConcurrentRequests = 0;
336
339
  waitQueue = [];
@@ -471,18 +474,32 @@ var DataverseHttpClient = class {
471
474
  if (this.cachedToken && this.cachedToken.expiresAt - Date.now() > TOKEN_BUFFER_MS) {
472
475
  return this.cachedToken.token;
473
476
  }
477
+ if (this.pendingTokenRefresh) {
478
+ return this.pendingTokenRefresh;
479
+ }
480
+ this.pendingTokenRefresh = this.refreshToken();
481
+ try {
482
+ return await this.pendingTokenRefresh;
483
+ } finally {
484
+ this.pendingTokenRefresh = null;
485
+ }
486
+ }
487
+ /** Internal: actually acquire a new token from the credential provider. */
488
+ async refreshToken() {
474
489
  log2.debug("Requesting new access token");
475
490
  const scope = `${this.baseUrl}/.default`;
476
491
  let tokenResponse;
477
492
  try {
478
493
  tokenResponse = await this.credential.getToken(scope);
479
494
  } catch (error) {
495
+ const cause = error instanceof Error ? error.message : String(error);
480
496
  throw new AuthenticationError(
481
497
  "AUTH_1003" /* AUTH_TOKEN_FAILED */,
482
- `Failed to acquire access token for ${this.baseUrl}. Verify your authentication configuration.`,
498
+ `Failed to acquire access token for ${this.baseUrl}. Verify your authentication configuration.
499
+ Cause: ${cause}`,
483
500
  {
484
501
  environmentUrl: this.baseUrl,
485
- originalError: error instanceof Error ? error.message : String(error)
502
+ originalError: cause
486
503
  }
487
504
  );
488
505
  }
@@ -508,10 +525,10 @@ var DataverseHttpClient = class {
508
525
  * The semaphore is acquired ONCE per logical request. Retries happen
509
526
  * INSIDE the semaphore to avoid the recursive slot exhaustion bug.
510
527
  */
511
- async executeWithConcurrency(url, signal) {
528
+ async executeWithConcurrency(url, signal, method = "GET", requestBody) {
512
529
  await this.acquireSlot();
513
530
  try {
514
- return await this.executeWithRetry(url, 1, 0, signal);
531
+ return await this.executeWithRetry(url, 1, 0, signal, method, requestBody);
515
532
  } finally {
516
533
  this.releaseSlot();
517
534
  }
@@ -521,10 +538,10 @@ var DataverseHttpClient = class {
521
538
  this.activeConcurrentRequests++;
522
539
  return Promise.resolve();
523
540
  }
524
- return new Promise((resolve) => {
541
+ return new Promise((resolve2) => {
525
542
  this.waitQueue.push(() => {
526
543
  this.activeConcurrentRequests++;
527
- resolve();
544
+ resolve2();
528
545
  });
529
546
  });
530
547
  }
@@ -534,7 +551,7 @@ var DataverseHttpClient = class {
534
551
  if (next) next();
535
552
  }
536
553
  // ─── Retry Logic (runs INSIDE a single concurrency slot) ─────────────────
537
- async executeWithRetry(url, attempt, rateLimitRetries = 0, signal) {
554
+ async executeWithRetry(url, attempt, rateLimitRetries = 0, signal, method = "GET", requestBody) {
538
555
  if (signal?.aborted) {
539
556
  throw new ApiRequestError(
540
557
  "API_2001" /* API_REQUEST_FAILED */,
@@ -549,17 +566,21 @@ var DataverseHttpClient = class {
549
566
  signal?.addEventListener("abort", onUserAbort, { once: true });
550
567
  let response;
551
568
  try {
552
- log2.debug(`GET ${url}`, { attempt });
569
+ log2.debug(`${method} ${url}`, { attempt });
570
+ const headers = {
571
+ Authorization: `Bearer ${token}`,
572
+ "OData-MaxVersion": "4.0",
573
+ "OData-Version": "4.0",
574
+ Accept: "application/json",
575
+ Prefer: 'odata.include-annotations="*"'
576
+ };
577
+ if (method === "POST") {
578
+ headers["Content-Type"] = "application/json";
579
+ }
553
580
  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
- },
581
+ method,
582
+ headers,
583
+ body: requestBody !== void 0 ? JSON.stringify(requestBody) : void 0,
563
584
  signal: controller.signal
564
585
  });
565
586
  } catch (fetchError) {
@@ -579,7 +600,7 @@ var DataverseHttpClient = class {
579
600
  url
580
601
  });
581
602
  await this.sleep(delay);
582
- return this.executeWithRetry(url, attempt + 1, rateLimitRetries, signal);
603
+ return this.executeWithRetry(url, attempt + 1, rateLimitRetries, signal, method, requestBody);
583
604
  }
584
605
  throw new ApiRequestError(
585
606
  "API_2005" /* API_TIMEOUT */,
@@ -594,7 +615,7 @@ var DataverseHttpClient = class {
594
615
  error: fetchError instanceof Error ? fetchError.message : String(fetchError)
595
616
  });
596
617
  await this.sleep(delay);
597
- return this.executeWithRetry(url, attempt + 1, rateLimitRetries, signal);
618
+ return this.executeWithRetry(url, attempt + 1, rateLimitRetries, signal, method, requestBody);
598
619
  }
599
620
  throw new ApiRequestError(
600
621
  "API_2001" /* API_REQUEST_FAILED */,
@@ -609,12 +630,12 @@ var DataverseHttpClient = class {
609
630
  signal?.removeEventListener("abort", onUserAbort);
610
631
  }
611
632
  if (!response.ok) {
612
- return this.handleHttpError(response, url, attempt, rateLimitRetries, signal);
633
+ return this.handleHttpError(response, url, attempt, rateLimitRetries, signal, method, requestBody);
613
634
  }
614
- log2.debug(`GET ${url} -> ${response.status}`, { attempt });
635
+ log2.debug(`${method} ${url} -> ${response.status}`, { attempt });
615
636
  return response.json();
616
637
  }
617
- async handleHttpError(response, url, attempt, rateLimitRetries, signal) {
638
+ async handleHttpError(response, url, attempt, rateLimitRetries, signal, method = "GET", requestBody) {
618
639
  const body = await response.text();
619
640
  if (response.status === 429) {
620
641
  if (rateLimitRetries >= this.maxRateLimitRetries) {
@@ -631,12 +652,12 @@ var DataverseHttpClient = class {
631
652
  retryAfterHeader
632
653
  });
633
654
  await this.sleep(retryAfterMs);
634
- return this.executeWithRetry(url, attempt, rateLimitRetries + 1, signal);
655
+ return this.executeWithRetry(url, attempt, rateLimitRetries + 1, signal, method, requestBody);
635
656
  }
636
657
  if (response.status === 401 && attempt === 1) {
637
658
  log2.warn("HTTP 401 received, clearing token cache and retrying");
638
659
  this.cachedToken = null;
639
- return this.executeWithRetry(url, attempt + 1, 0, signal);
660
+ return this.executeWithRetry(url, attempt + 1, 0, signal, method, requestBody);
640
661
  }
641
662
  if (response.status >= 500 && attempt <= this.maxRetries) {
642
663
  const delay = this.calculateBackoff(attempt);
@@ -644,7 +665,7 @@ var DataverseHttpClient = class {
644
665
  `Server error ${response.status}, retrying in ${delay}ms (${attempt}/${this.maxRetries})`
645
666
  );
646
667
  await this.sleep(delay);
647
- return this.executeWithRetry(url, attempt + 1, rateLimitRetries, signal);
668
+ return this.executeWithRetry(url, attempt + 1, rateLimitRetries, signal, method, requestBody);
648
669
  }
649
670
  const errorCode = response.status === 401 ? "API_2004" /* API_UNAUTHORIZED */ : response.status === 404 ? "API_2003" /* API_NOT_FOUND */ : "API_2001" /* API_REQUEST_FAILED */;
650
671
  throw new ApiRequestError(
@@ -667,7 +688,7 @@ var DataverseHttpClient = class {
667
688
  return Math.min(exponential + jitter, MAX_BACKOFF_MS);
668
689
  }
669
690
  sleep(ms) {
670
- return new Promise((resolve) => setTimeout(resolve, ms));
691
+ return new Promise((resolve2) => setTimeout(resolve2, ms));
671
692
  }
672
693
  };
673
694
 
@@ -1243,10 +1264,12 @@ var MetadataCache = class {
1243
1264
  cacheDir;
1244
1265
  cacheFilePath;
1245
1266
  /**
1246
- * @param projectRoot - Root directory of the project (where .xrmforge/ will be created)
1267
+ * @param cacheDir - Directory where cache files are stored.
1268
+ * Can be an absolute path or relative to cwd.
1269
+ * Defaults to ".xrmforge/cache" when constructed without argument.
1247
1270
  */
1248
- constructor(projectRoot) {
1249
- this.cacheDir = path.join(projectRoot, CACHE_DIR);
1271
+ constructor(cacheDir = CACHE_DIR) {
1272
+ this.cacheDir = path.resolve(cacheDir);
1250
1273
  this.cacheFilePath = path.join(this.cacheDir, CACHE_FILE);
1251
1274
  }
1252
1275
  /**
@@ -1362,6 +1385,98 @@ var MetadataCache = class {
1362
1385
  }
1363
1386
  };
1364
1387
 
1388
+ // src/metadata/change-detector.ts
1389
+ var log6 = createLogger("change-detector");
1390
+ var EXPIRED_VERSION_STAMP_ERROR = "0x80044352";
1391
+ var ChangeDetector = class {
1392
+ constructor(http) {
1393
+ this.http = http;
1394
+ }
1395
+ /**
1396
+ * Detect which entities have changed since the given version stamp.
1397
+ *
1398
+ * @param clientVersionStamp - The ServerVersionStamp from the last run (from cache)
1399
+ * @returns Changed entity names, deleted entity names, and new version stamp
1400
+ * @throws {MetadataError} with META_VERSION_STAMP_EXPIRED if stamp is too old (>90 days)
1401
+ */
1402
+ async detectChanges(clientVersionStamp) {
1403
+ log6.info("Detecting metadata changes since last run");
1404
+ const query = {
1405
+ Criteria: {
1406
+ FilterOperator: "And",
1407
+ Conditions: []
1408
+ },
1409
+ Properties: {
1410
+ AllProperties: false,
1411
+ PropertyNames: ["LogicalName"]
1412
+ }
1413
+ };
1414
+ const queryJson = encodeURIComponent(JSON.stringify(query));
1415
+ const deletedFilter = `Microsoft.Dynamics.CRM.DeletedMetadataFilters'Default'`;
1416
+ const path2 = `/RetrieveMetadataChanges(Query=@q,ClientVersionStamp=@s,DeletedMetadataFilters=@d)?@q=${queryJson}&@s='${clientVersionStamp}'&@d=${deletedFilter}`;
1417
+ let response;
1418
+ try {
1419
+ response = await this.http.get(path2);
1420
+ } catch (error) {
1421
+ if (this.isExpiredVersionStampError(error)) {
1422
+ throw new MetadataError(
1423
+ "META_3005" /* META_VERSION_STAMP_EXPIRED */,
1424
+ "Cached version stamp has expired (>90 days). A full metadata refresh is required.",
1425
+ { clientVersionStamp }
1426
+ );
1427
+ }
1428
+ throw error;
1429
+ }
1430
+ const changedEntityNames = (response.EntityMetadata ?? []).filter((e) => e.HasChanged !== false).map((e) => e.LogicalName).filter(Boolean);
1431
+ const deletedEntityNames = [];
1432
+ if (response.DeletedMetadata?.Keys) {
1433
+ log6.info(`${response.DeletedMetadata.Keys.length} entity metadata IDs were deleted`);
1434
+ }
1435
+ const newVersionStamp = response.ServerVersionStamp;
1436
+ log6.info(`Change detection complete: ${changedEntityNames.length} changed, ${deletedEntityNames.length} deleted`, {
1437
+ changedEntityNames: changedEntityNames.length <= 10 ? changedEntityNames : `${changedEntityNames.length} entities`,
1438
+ newVersionStamp: newVersionStamp.substring(0, 20) + "..."
1439
+ });
1440
+ return {
1441
+ changedEntityNames,
1442
+ deletedEntityNames,
1443
+ newVersionStamp
1444
+ };
1445
+ }
1446
+ /**
1447
+ * Perform an initial metadata query to get the first ServerVersionStamp.
1448
+ * This is used on the very first run (no cache exists).
1449
+ *
1450
+ * @returns The initial server version stamp
1451
+ */
1452
+ async getInitialVersionStamp() {
1453
+ log6.info("Fetching initial server version stamp");
1454
+ const query = {
1455
+ Criteria: {
1456
+ FilterOperator: "And",
1457
+ Conditions: []
1458
+ },
1459
+ Properties: {
1460
+ AllProperties: false,
1461
+ PropertyNames: ["LogicalName"]
1462
+ }
1463
+ };
1464
+ const queryParam = encodeURIComponent(JSON.stringify(query));
1465
+ const path2 = `/RetrieveMetadataChanges(Query=@q)?@q=${queryParam}`;
1466
+ const response = await this.http.get(path2);
1467
+ log6.info("Initial version stamp acquired");
1468
+ return response.ServerVersionStamp;
1469
+ }
1470
+ /** Check if an error is the expired version stamp error (0x80044352) */
1471
+ isExpiredVersionStampError(error) {
1472
+ const msg = error instanceof Error ? error.message : String(error);
1473
+ const errorRecord = error;
1474
+ const contextBody = errorRecord?.context ? String(errorRecord.context["responseBody"] ?? "") : "";
1475
+ const combined = msg + contextBody;
1476
+ return combined.includes(EXPIRED_VERSION_STAMP_ERROR) || combined.includes("ExpiredVersionStamp");
1477
+ }
1478
+ };
1479
+
1365
1480
  // src/metadata/labels.ts
1366
1481
  var DEFAULT_LABEL_CONFIG = {
1367
1482
  primaryLanguage: 1033
@@ -1455,10 +1570,12 @@ function getLabelLanguagesParam(config) {
1455
1570
  }
1456
1571
 
1457
1572
  // src/generators/type-mapping.ts
1573
+ var log7 = createLogger("type-mapping");
1458
1574
  function getEntityPropertyType(attributeType, isLookup = false) {
1459
1575
  if (isLookup) return "string";
1460
1576
  const mapping = ENTITY_TYPE_MAP[attributeType];
1461
1577
  if (mapping) return mapping;
1578
+ log7.warn(`Unmapped AttributeType "${attributeType}" falling back to "unknown"`);
1462
1579
  return "unknown";
1463
1580
  }
1464
1581
  var ENTITY_TYPE_MAP = {
@@ -1497,6 +1614,7 @@ var ENTITY_TYPE_MAP = {
1497
1614
  function getFormAttributeType(attributeType) {
1498
1615
  const mapping = FORM_ATTRIBUTE_TYPE_MAP[attributeType];
1499
1616
  if (mapping) return mapping;
1617
+ log7.warn(`Unmapped form AttributeType "${attributeType}" falling back to generic Attribute`);
1500
1618
  return "Xrm.Attributes.Attribute";
1501
1619
  }
1502
1620
  var FORM_ATTRIBUTE_TYPE_MAP = {
@@ -1990,6 +2108,50 @@ function generateFormInterface(form, entityLogicalName, attributeMap, options =
1990
2108
  lines.push("");
1991
2109
  }
1992
2110
  }
2111
+ const specialControls = form.allSpecialControls || [];
2112
+ const subgrids = specialControls.filter((sc) => sc.controlType === "subgrid" || sc.controlType === "editablegrid");
2113
+ const quickViews = specialControls.filter((sc) => sc.controlType === "quickview");
2114
+ if (subgrids.length > 0) {
2115
+ const subgridsEnumName = `${baseName}FormSubgrids`;
2116
+ lines.push(` /** Subgrid constants for "${form.name}" (compile-time only, zero runtime) */`);
2117
+ lines.push(` const enum ${subgridsEnumName} {`);
2118
+ const usedMembers = /* @__PURE__ */ new Set();
2119
+ for (const sg of subgrids) {
2120
+ let member = toSafeFormName(sg.id) || toPascalCase(sg.id);
2121
+ const original = member;
2122
+ let counter = 2;
2123
+ while (usedMembers.has(member)) {
2124
+ member = `${original}${counter}`;
2125
+ counter++;
2126
+ }
2127
+ usedMembers.add(member);
2128
+ const label = sg.targetEntityType ? `Subgrid: ${sg.targetEntityType}` : `Subgrid`;
2129
+ lines.push(` /** ${label} */`);
2130
+ lines.push(` ${member} = '${sg.id}',`);
2131
+ }
2132
+ lines.push(" }");
2133
+ lines.push("");
2134
+ }
2135
+ if (quickViews.length > 0) {
2136
+ const qvEnumName = `${baseName}FormQuickViews`;
2137
+ lines.push(` /** Quick View constants for "${form.name}" (compile-time only, zero runtime) */`);
2138
+ lines.push(` const enum ${qvEnumName} {`);
2139
+ const usedMembers = /* @__PURE__ */ new Set();
2140
+ for (const qv of quickViews) {
2141
+ let member = toSafeFormName(qv.id) || toPascalCase(qv.id);
2142
+ const original = member;
2143
+ let counter = 2;
2144
+ while (usedMembers.has(member)) {
2145
+ member = `${original}${counter}`;
2146
+ counter++;
2147
+ }
2148
+ usedMembers.add(member);
2149
+ lines.push(` /** Quick View */`);
2150
+ lines.push(` ${member} = '${qv.id}',`);
2151
+ }
2152
+ lines.push(" }");
2153
+ lines.push("");
2154
+ }
1993
2155
  lines.push(` /** ${form.name} */`);
1994
2156
  lines.push(` interface ${interfaceName} extends Omit<Xrm.FormContext, 'getAttribute' | 'getControl'> {`);
1995
2157
  lines.push(` /** Typisierter Feldzugriff: nur Felder die auf diesem Formular existieren */`);
@@ -1999,7 +2161,6 @@ function generateFormInterface(form, entityLogicalName, attributeMap, options =
1999
2161
  lines.push("");
2000
2162
  lines.push(` /** Typisierter Control-Zugriff: nur Controls die auf diesem Formular existieren */`);
2001
2163
  lines.push(` getControl<K extends ${fieldsTypeName}>(name: K): ${ctrlMapName}[K];`);
2002
- const specialControls = form.allSpecialControls || [];
2003
2164
  for (const sc of specialControls) {
2004
2165
  const xrmType = specialControlToXrmType(sc.controlType);
2005
2166
  if (xrmType) {
@@ -2380,6 +2541,7 @@ function createBoundAction(operationName, entityLogicalName, paramMeta) {
2380
2541
  }
2381
2542
  function createUnboundAction(operationName, paramMeta) {
2382
2543
  return {
2544
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- return type varies (Response or parsed JSON)
2383
2545
  async execute(params) {
2384
2546
  const req = buildUnboundRequest(
2385
2547
  operationName,
@@ -2664,7 +2826,7 @@ function groupCustomApis(apis) {
2664
2826
  }
2665
2827
 
2666
2828
  // src/orchestrator/file-writer.ts
2667
- import { mkdir, writeFile, readFile } from "fs/promises";
2829
+ import { mkdir, writeFile, readFile, unlink } from "fs/promises";
2668
2830
  import { join as join2, dirname } from "path";
2669
2831
  async function writeGeneratedFile(outputDir, file) {
2670
2832
  const absolutePath = join2(outputDir, file.relativePath);
@@ -2696,6 +2858,24 @@ async function writeAllFiles(outputDir, files) {
2696
2858
  }
2697
2859
  return result;
2698
2860
  }
2861
+ async function deleteOrphanedFiles(outputDir, deletedEntityNames) {
2862
+ let deleted = 0;
2863
+ const subdirs = ["entities", "optionsets", "forms"];
2864
+ for (const entityName of deletedEntityNames) {
2865
+ for (const subdir of subdirs) {
2866
+ const filePath = join2(outputDir, subdir, `${entityName}.d.ts`);
2867
+ try {
2868
+ await unlink(filePath);
2869
+ deleted++;
2870
+ } catch (error) {
2871
+ if (error.code !== "ENOENT") {
2872
+ throw error;
2873
+ }
2874
+ }
2875
+ }
2876
+ }
2877
+ return deleted;
2878
+ }
2699
2879
  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
2700
2880
  // This file was generated by @xrmforge/typegen. Do not edit manually.
2701
2881
  // Re-run 'xrmforge generate' to update.
@@ -2742,11 +2922,6 @@ var TypeGenerationOrchestrator = class {
2742
2922
  constructor(credential, config, logger) {
2743
2923
  this.credential = credential;
2744
2924
  this.logger = logger ?? createLogger("orchestrator");
2745
- if (config.useCache) {
2746
- throw new Error(
2747
- "Metadata caching is not yet implemented (planned for v0.2.0). Remove the useCache option or set it to false."
2748
- );
2749
- }
2750
2925
  this.config = {
2751
2926
  environmentUrl: config.environmentUrl,
2752
2927
  entities: [...config.entities],
@@ -2781,7 +2956,8 @@ var TypeGenerationOrchestrator = class {
2781
2956
  }
2782
2957
  this.logger.info("Starting type generation", {
2783
2958
  entities: this.config.entities,
2784
- outputDir: this.config.outputDir
2959
+ outputDir: this.config.outputDir,
2960
+ useCache: this.config.useCache
2785
2961
  });
2786
2962
  const httpClient = new DataverseHttpClient({
2787
2963
  environmentUrl: this.config.environmentUrl,
@@ -2804,80 +2980,71 @@ var TypeGenerationOrchestrator = class {
2804
2980
  durationMs: Date.now() - startTime
2805
2981
  };
2806
2982
  }
2807
- this.logger.info(`Processing ${this.config.entities.length} entities in parallel`);
2808
- const settled = await Promise.allSettled(
2809
- this.config.entities.map((entityName) => {
2810
- if (signal?.aborted) {
2811
- return Promise.reject(new Error("Generation aborted"));
2983
+ let cacheStats;
2984
+ const entitiesToFetch = new Set(this.config.entities);
2985
+ const cachedEntityInfos = {};
2986
+ let cache;
2987
+ let newVersionStamp = null;
2988
+ const deletedEntityNames = [];
2989
+ if (this.config.useCache) {
2990
+ const cacheResult = await this.resolveCache(httpClient, entitiesToFetch);
2991
+ cache = cacheResult.cache;
2992
+ newVersionStamp = cacheResult.newVersionStamp;
2993
+ cacheStats = cacheResult.stats;
2994
+ for (const [name, info] of Object.entries(cacheResult.cachedEntities)) {
2995
+ cachedEntityInfos[name] = info;
2996
+ entitiesToFetch.delete(name);
2997
+ }
2998
+ deletedEntityNames.push(...cacheResult.deletedEntityNames);
2999
+ }
3000
+ const fetchList = [...entitiesToFetch];
3001
+ const failedEntities = /* @__PURE__ */ new Map();
3002
+ if (fetchList.length > 0) {
3003
+ this.logger.info(`Fetching ${fetchList.length} entities from Dataverse`);
3004
+ const settled = await Promise.allSettled(
3005
+ fetchList.map((entityName) => {
3006
+ if (signal?.aborted) {
3007
+ return Promise.reject(new Error("Generation aborted"));
3008
+ }
3009
+ return metadataClient.getEntityTypeInfo(entityName).then((info) => {
3010
+ this.logger.info(`Fetched entity: ${entityName}`);
3011
+ return { entityName, info };
3012
+ });
3013
+ })
3014
+ );
3015
+ for (let i = 0; i < settled.length; i++) {
3016
+ const outcome = settled[i];
3017
+ const entityName = fetchList[i];
3018
+ if (outcome.status === "fulfilled") {
3019
+ cachedEntityInfos[outcome.value.entityName] = outcome.value.info;
3020
+ } else {
3021
+ const errorMsg = outcome.reason instanceof Error ? outcome.reason.message : String(outcome.reason);
3022
+ this.logger.error(`Failed to fetch entity: ${entityName}`, { error: outcome.reason });
3023
+ failedEntities.set(entityName, errorMsg);
2812
3024
  }
2813
- return this.processEntity(entityName, metadataClient).then((result) => {
2814
- this.logger.info(`Completed entity: ${entityName} (${result.files.length} files)`);
2815
- return result;
2816
- });
2817
- })
2818
- );
2819
- for (let i = 0; i < settled.length; i++) {
2820
- const outcome = settled[i];
2821
- const entityName = this.config.entities[i];
2822
- if (outcome.status === "fulfilled") {
2823
- entityResults.push(outcome.value);
2824
- allFiles.push(...outcome.value.files);
2825
- } else {
2826
- const errorMsg = outcome.reason instanceof Error ? outcome.reason.message : String(outcome.reason);
2827
- this.logger.error(`Failed to process entity: ${entityName}`, { error: outcome.reason });
3025
+ }
3026
+ }
3027
+ this.logger.info(`Generating types for ${this.config.entities.length - failedEntities.size} entities`);
3028
+ for (const entityName of this.config.entities) {
3029
+ if (signal?.aborted) break;
3030
+ if (failedEntities.has(entityName)) {
2828
3031
  entityResults.push({
2829
3032
  entityLogicalName: entityName,
2830
3033
  files: [],
2831
- warnings: [`Failed to process: ${errorMsg}`]
3034
+ warnings: [`Failed to process: ${failedEntities.get(entityName)}`]
2832
3035
  });
3036
+ continue;
2833
3037
  }
3038
+ const entityInfo = cachedEntityInfos[entityName];
3039
+ if (!entityInfo) continue;
3040
+ const result = this.generateEntityFiles(entityName, entityInfo);
3041
+ this.logger.info(`Generated entity: ${entityName} (${result.files.length} files)`);
3042
+ entityResults.push(result);
3043
+ allFiles.push(...result.files);
2834
3044
  }
2835
3045
  if (this.config.generateActions && !signal?.aborted) {
2836
- this.logger.info("Fetching Custom APIs...");
2837
- let customApis = await metadataClient.getCustomApis();
2838
- if (this.config.actionsFilter) {
2839
- const prefix = this.config.actionsFilter.toLowerCase();
2840
- const before = customApis.length;
2841
- customApis = customApis.filter((api) => api.api.uniquename.toLowerCase().startsWith(prefix));
2842
- this.logger.info(`Filtered Custom APIs by prefix "${this.config.actionsFilter}": ${before} -> ${customApis.length}`);
2843
- }
2844
- if (customApis.length > 0) {
2845
- const importPath = "@xrmforge/typegen";
2846
- const grouped = groupCustomApis(customApis);
2847
- for (const [key, apis] of grouped.actions) {
2848
- const entityName = key === "global" ? void 0 : key;
2849
- const declarations = generateActionDeclarations(apis, false, entityName, { importPath });
2850
- const module = generateActionModule(apis, false, { importPath });
2851
- allFiles.push({
2852
- relativePath: `actions/${key}.d.ts`,
2853
- content: addGeneratedHeader(declarations),
2854
- type: "action"
2855
- });
2856
- allFiles.push({
2857
- relativePath: `actions/${key}.ts`,
2858
- content: addGeneratedHeader(module),
2859
- type: "action"
2860
- });
2861
- }
2862
- for (const [key, apis] of grouped.functions) {
2863
- const entityName = key === "global" ? void 0 : key;
2864
- const declarations = generateActionDeclarations(apis, true, entityName, { importPath });
2865
- const module = generateActionModule(apis, true, { importPath });
2866
- allFiles.push({
2867
- relativePath: `functions/${key}.d.ts`,
2868
- content: addGeneratedHeader(declarations),
2869
- type: "action"
2870
- });
2871
- allFiles.push({
2872
- relativePath: `functions/${key}.ts`,
2873
- content: addGeneratedHeader(module),
2874
- type: "action"
2875
- });
2876
- }
2877
- this.logger.info(`Generated ${grouped.actions.size} action groups, ${grouped.functions.size} function groups`);
2878
- } else {
2879
- this.logger.info("No Custom APIs found");
2880
- }
3046
+ const actionFiles = await this.generateActions(metadataClient);
3047
+ allFiles.push(...actionFiles);
2881
3048
  }
2882
3049
  if (this.config.entities.length > 0) {
2883
3050
  const entityNamesContent = generateEntityNamesEnum(this.config.entities, {
@@ -2899,6 +3066,15 @@ var TypeGenerationOrchestrator = class {
2899
3066
  allFiles.push(indexFile);
2900
3067
  }
2901
3068
  const writeResult = await writeAllFiles(this.config.outputDir, allFiles);
3069
+ if (deletedEntityNames.length > 0) {
3070
+ const deleted = await deleteOrphanedFiles(this.config.outputDir, deletedEntityNames);
3071
+ if (deleted > 0) {
3072
+ this.logger.info(`Deleted ${deleted} orphaned files for removed entities`);
3073
+ }
3074
+ }
3075
+ if (this.config.useCache && cache) {
3076
+ await this.updateCache(cache, cachedEntityInfos, deletedEntityNames, newVersionStamp);
3077
+ }
2902
3078
  const durationMs = Date.now() - startTime;
2903
3079
  const entityWarnings = entityResults.reduce((sum, r) => sum + r.warnings.length, 0);
2904
3080
  const totalWarnings = entityWarnings + writeResult.warnings.length;
@@ -2911,22 +3087,129 @@ var TypeGenerationOrchestrator = class {
2911
3087
  filesUnchanged: writeResult.unchanged,
2912
3088
  totalFiles: allFiles.length,
2913
3089
  totalWarnings,
2914
- durationMs
3090
+ durationMs,
3091
+ cacheUsed: cacheStats?.cacheUsed ?? false
2915
3092
  });
2916
3093
  return {
2917
3094
  entities: entityResults,
2918
3095
  totalFiles: allFiles.length,
2919
3096
  totalWarnings,
2920
- durationMs
3097
+ durationMs,
3098
+ cacheStats
3099
+ };
3100
+ }
3101
+ /**
3102
+ * Resolve the cache: load existing cache, detect changes, determine which
3103
+ * entities need to be fetched vs. can be served from cache.
3104
+ *
3105
+ * On any failure (corrupt cache, expired stamp), falls back to full refresh.
3106
+ */
3107
+ async resolveCache(httpClient, requestedEntities) {
3108
+ const cache = new MetadataCache(this.config.cacheDir);
3109
+ const changeDetector = new ChangeDetector(httpClient);
3110
+ let cacheData;
3111
+ try {
3112
+ cacheData = await cache.load(this.config.environmentUrl);
3113
+ } catch {
3114
+ this.logger.warn("Failed to load metadata cache, performing full refresh");
3115
+ cacheData = null;
3116
+ }
3117
+ if (!cacheData || !cacheData.manifest.serverVersionStamp) {
3118
+ this.logger.info("No valid cache found, performing full metadata refresh");
3119
+ const stamp = await changeDetector.getInitialVersionStamp();
3120
+ return {
3121
+ cache,
3122
+ cachedEntities: {},
3123
+ deletedEntityNames: [],
3124
+ newVersionStamp: stamp,
3125
+ stats: {
3126
+ cacheUsed: true,
3127
+ fullRefresh: true,
3128
+ entitiesFromCache: 0,
3129
+ entitiesFetched: requestedEntities.size,
3130
+ entitiesDeleted: 0
3131
+ }
3132
+ };
3133
+ }
3134
+ let changeResult;
3135
+ try {
3136
+ changeResult = await changeDetector.detectChanges(cacheData.manifest.serverVersionStamp);
3137
+ } catch (error) {
3138
+ const isExpired = error instanceof Error && error.message.includes("META_3005" /* META_VERSION_STAMP_EXPIRED */);
3139
+ if (isExpired) {
3140
+ this.logger.warn("Cache version stamp expired (>90 days), performing full refresh");
3141
+ } else {
3142
+ this.logger.warn("Change detection failed, performing full refresh", {
3143
+ error: error instanceof Error ? error.message : String(error)
3144
+ });
3145
+ }
3146
+ const stamp = await changeDetector.getInitialVersionStamp();
3147
+ return {
3148
+ cache,
3149
+ cachedEntities: {},
3150
+ deletedEntityNames: [],
3151
+ newVersionStamp: stamp,
3152
+ stats: {
3153
+ cacheUsed: true,
3154
+ fullRefresh: true,
3155
+ entitiesFromCache: 0,
3156
+ entitiesFetched: requestedEntities.size,
3157
+ entitiesDeleted: 0
3158
+ }
3159
+ };
3160
+ }
3161
+ const changedSet = new Set(changeResult.changedEntityNames);
3162
+ const cachedEntities = {};
3163
+ let entitiesFromCache = 0;
3164
+ let entitiesFetched = 0;
3165
+ for (const entityName of requestedEntities) {
3166
+ if (changedSet.has(entityName) || !cacheData.entityTypeInfos[entityName]) {
3167
+ entitiesFetched++;
3168
+ } else {
3169
+ cachedEntities[entityName] = cacheData.entityTypeInfos[entityName];
3170
+ entitiesFromCache++;
3171
+ }
3172
+ }
3173
+ const deletedEntityNames = changeResult.deletedEntityNames.filter(
3174
+ (name) => requestedEntities.has(name)
3175
+ );
3176
+ this.logger.info(`Cache delta: ${entitiesFromCache} from cache, ${entitiesFetched} to fetch, ${deletedEntityNames.length} deleted`);
3177
+ return {
3178
+ cache,
3179
+ cachedEntities,
3180
+ deletedEntityNames,
3181
+ newVersionStamp: changeResult.newVersionStamp,
3182
+ stats: {
3183
+ cacheUsed: true,
3184
+ fullRefresh: false,
3185
+ entitiesFromCache,
3186
+ entitiesFetched,
3187
+ entitiesDeleted: deletedEntityNames.length
3188
+ }
2921
3189
  };
2922
3190
  }
2923
3191
  /**
2924
- * Process a single entity: fetch metadata, generate all output files.
3192
+ * Update the metadata cache after a successful generation run.
2925
3193
  */
2926
- async processEntity(entityName, metadataClient) {
3194
+ async updateCache(cache, entityTypeInfos, deletedEntityNames, newVersionStamp) {
3195
+ try {
3196
+ if (deletedEntityNames.length > 0) {
3197
+ await cache.removeEntities(this.config.environmentUrl, deletedEntityNames, newVersionStamp);
3198
+ }
3199
+ await cache.save(this.config.environmentUrl, entityTypeInfos, newVersionStamp);
3200
+ this.logger.info("Metadata cache updated");
3201
+ } catch (error) {
3202
+ this.logger.warn("Failed to update metadata cache", {
3203
+ error: error instanceof Error ? error.message : String(error)
3204
+ });
3205
+ }
3206
+ }
3207
+ /**
3208
+ * Generate all output files for a single entity from its metadata.
3209
+ */
3210
+ generateEntityFiles(entityName, entityInfo) {
2927
3211
  const warnings = [];
2928
3212
  const files = [];
2929
- const entityInfo = await metadataClient.getEntityTypeInfo(entityName);
2930
3213
  if (this.config.generateEntities) {
2931
3214
  const entityContent = generateEntityInterface(entityInfo, {
2932
3215
  labelConfig: this.config.labelConfig,
@@ -2982,6 +3265,58 @@ var TypeGenerationOrchestrator = class {
2982
3265
  }
2983
3266
  return { entityLogicalName: entityName, files, warnings };
2984
3267
  }
3268
+ /**
3269
+ * Generate Custom API Action/Function executor files.
3270
+ */
3271
+ async generateActions(metadataClient) {
3272
+ const files = [];
3273
+ this.logger.info("Fetching Custom APIs...");
3274
+ let customApis = await metadataClient.getCustomApis();
3275
+ if (this.config.actionsFilter) {
3276
+ const prefix = this.config.actionsFilter.toLowerCase();
3277
+ const before = customApis.length;
3278
+ customApis = customApis.filter((api) => api.api.uniquename.toLowerCase().startsWith(prefix));
3279
+ this.logger.info(`Filtered Custom APIs by prefix "${this.config.actionsFilter}": ${before} -> ${customApis.length}`);
3280
+ }
3281
+ if (customApis.length > 0) {
3282
+ const importPath = "@xrmforge/typegen";
3283
+ const grouped = groupCustomApis(customApis);
3284
+ for (const [key, apis] of grouped.actions) {
3285
+ const entityName = key === "global" ? void 0 : key;
3286
+ const declarations = generateActionDeclarations(apis, false, entityName, { importPath });
3287
+ const module = generateActionModule(apis, false, { importPath });
3288
+ files.push({
3289
+ relativePath: `actions/${key}.d.ts`,
3290
+ content: addGeneratedHeader(declarations),
3291
+ type: "action"
3292
+ });
3293
+ files.push({
3294
+ relativePath: `actions/${key}.ts`,
3295
+ content: addGeneratedHeader(module),
3296
+ type: "action"
3297
+ });
3298
+ }
3299
+ for (const [key, apis] of grouped.functions) {
3300
+ const entityName = key === "global" ? void 0 : key;
3301
+ const declarations = generateActionDeclarations(apis, true, entityName, { importPath });
3302
+ const module = generateActionModule(apis, true, { importPath });
3303
+ files.push({
3304
+ relativePath: `functions/${key}.d.ts`,
3305
+ content: addGeneratedHeader(declarations),
3306
+ type: "action"
3307
+ });
3308
+ files.push({
3309
+ relativePath: `functions/${key}.ts`,
3310
+ content: addGeneratedHeader(module),
3311
+ type: "action"
3312
+ });
3313
+ }
3314
+ this.logger.info(`Generated ${grouped.actions.size} action groups, ${grouped.functions.size} function groups`);
3315
+ } else {
3316
+ this.logger.info("No Custom APIs found");
3317
+ }
3318
+ return files;
3319
+ }
2985
3320
  /**
2986
3321
  * Extract picklist attributes with their OptionSet metadata.
2987
3322
  * Maps the raw EntityTypeInfo data to the format expected by the OptionSet generator.
@@ -3016,6 +3351,7 @@ export {
3016
3351
  ApiRequestError,
3017
3352
  AuthenticationError,
3018
3353
  BindingType,
3354
+ ChangeDetector,
3019
3355
  ClientState,
3020
3356
  ClientType,
3021
3357
  ConfigError,