@xrmforge/typegen 0.3.0 → 0.4.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 = [];
@@ -401,6 +404,28 @@ var DataverseHttpClient = class {
401
404
  }
402
405
  return allResults;
403
406
  }
407
+ /**
408
+ * Execute a POST request that is semantically a read operation.
409
+ * Used for Dataverse actions like RetrieveMetadataChanges that require POST
410
+ * but do not modify data. Allowed even in read-only mode.
411
+ *
412
+ * @param path - API path (relative to apiUrl)
413
+ * @param body - JSON body to send
414
+ * @param signal - Optional AbortSignal to cancel the request
415
+ */
416
+ async postReadOnly(path2, body, signal) {
417
+ const ALLOWED_PATHS = ["/RetrieveMetadataChanges"];
418
+ const normalizedPath = path2.startsWith("/") ? path2 : `/${path2}`;
419
+ if (!ALLOWED_PATHS.some((p) => normalizedPath.startsWith(p))) {
420
+ throw new ApiRequestError(
421
+ "API_2001" /* API_REQUEST_FAILED */,
422
+ `postReadOnly is only allowed for safe read operations: ${ALLOWED_PATHS.join(", ")}. Got: "${normalizedPath}"`,
423
+ { path: normalizedPath }
424
+ );
425
+ }
426
+ const url = this.resolveUrl(path2);
427
+ return this.executeWithConcurrency(url, signal, "POST", body);
428
+ }
404
429
  // ─── Read-Only Enforcement ─────────────────────────────────────────────
405
430
  /**
406
431
  * Returns true if this client is in read-only mode (the safe default).
@@ -471,6 +496,18 @@ var DataverseHttpClient = class {
471
496
  if (this.cachedToken && this.cachedToken.expiresAt - Date.now() > TOKEN_BUFFER_MS) {
472
497
  return this.cachedToken.token;
473
498
  }
499
+ if (this.pendingTokenRefresh) {
500
+ return this.pendingTokenRefresh;
501
+ }
502
+ this.pendingTokenRefresh = this.refreshToken();
503
+ try {
504
+ return await this.pendingTokenRefresh;
505
+ } finally {
506
+ this.pendingTokenRefresh = null;
507
+ }
508
+ }
509
+ /** Internal: actually acquire a new token from the credential provider. */
510
+ async refreshToken() {
474
511
  log2.debug("Requesting new access token");
475
512
  const scope = `${this.baseUrl}/.default`;
476
513
  let tokenResponse;
@@ -508,10 +545,10 @@ var DataverseHttpClient = class {
508
545
  * The semaphore is acquired ONCE per logical request. Retries happen
509
546
  * INSIDE the semaphore to avoid the recursive slot exhaustion bug.
510
547
  */
511
- async executeWithConcurrency(url, signal) {
548
+ async executeWithConcurrency(url, signal, method = "GET", requestBody) {
512
549
  await this.acquireSlot();
513
550
  try {
514
- return await this.executeWithRetry(url, 1, 0, signal);
551
+ return await this.executeWithRetry(url, 1, 0, signal, method, requestBody);
515
552
  } finally {
516
553
  this.releaseSlot();
517
554
  }
@@ -521,10 +558,10 @@ var DataverseHttpClient = class {
521
558
  this.activeConcurrentRequests++;
522
559
  return Promise.resolve();
523
560
  }
524
- return new Promise((resolve) => {
561
+ return new Promise((resolve2) => {
525
562
  this.waitQueue.push(() => {
526
563
  this.activeConcurrentRequests++;
527
- resolve();
564
+ resolve2();
528
565
  });
529
566
  });
530
567
  }
@@ -534,7 +571,7 @@ var DataverseHttpClient = class {
534
571
  if (next) next();
535
572
  }
536
573
  // ─── Retry Logic (runs INSIDE a single concurrency slot) ─────────────────
537
- async executeWithRetry(url, attempt, rateLimitRetries = 0, signal) {
574
+ async executeWithRetry(url, attempt, rateLimitRetries = 0, signal, method = "GET", requestBody) {
538
575
  if (signal?.aborted) {
539
576
  throw new ApiRequestError(
540
577
  "API_2001" /* API_REQUEST_FAILED */,
@@ -549,17 +586,21 @@ var DataverseHttpClient = class {
549
586
  signal?.addEventListener("abort", onUserAbort, { once: true });
550
587
  let response;
551
588
  try {
552
- log2.debug(`GET ${url}`, { attempt });
589
+ log2.debug(`${method} ${url}`, { attempt });
590
+ const headers = {
591
+ Authorization: `Bearer ${token}`,
592
+ "OData-MaxVersion": "4.0",
593
+ "OData-Version": "4.0",
594
+ Accept: "application/json",
595
+ Prefer: 'odata.include-annotations="*"'
596
+ };
597
+ if (method === "POST") {
598
+ headers["Content-Type"] = "application/json";
599
+ }
553
600
  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
- },
601
+ method,
602
+ headers,
603
+ body: requestBody !== void 0 ? JSON.stringify(requestBody) : void 0,
563
604
  signal: controller.signal
564
605
  });
565
606
  } catch (fetchError) {
@@ -579,7 +620,7 @@ var DataverseHttpClient = class {
579
620
  url
580
621
  });
581
622
  await this.sleep(delay);
582
- return this.executeWithRetry(url, attempt + 1, rateLimitRetries, signal);
623
+ return this.executeWithRetry(url, attempt + 1, rateLimitRetries, signal, method, requestBody);
583
624
  }
584
625
  throw new ApiRequestError(
585
626
  "API_2005" /* API_TIMEOUT */,
@@ -594,7 +635,7 @@ var DataverseHttpClient = class {
594
635
  error: fetchError instanceof Error ? fetchError.message : String(fetchError)
595
636
  });
596
637
  await this.sleep(delay);
597
- return this.executeWithRetry(url, attempt + 1, rateLimitRetries, signal);
638
+ return this.executeWithRetry(url, attempt + 1, rateLimitRetries, signal, method, requestBody);
598
639
  }
599
640
  throw new ApiRequestError(
600
641
  "API_2001" /* API_REQUEST_FAILED */,
@@ -609,12 +650,12 @@ var DataverseHttpClient = class {
609
650
  signal?.removeEventListener("abort", onUserAbort);
610
651
  }
611
652
  if (!response.ok) {
612
- return this.handleHttpError(response, url, attempt, rateLimitRetries, signal);
653
+ return this.handleHttpError(response, url, attempt, rateLimitRetries, signal, method, requestBody);
613
654
  }
614
- log2.debug(`GET ${url} -> ${response.status}`, { attempt });
655
+ log2.debug(`${method} ${url} -> ${response.status}`, { attempt });
615
656
  return response.json();
616
657
  }
617
- async handleHttpError(response, url, attempt, rateLimitRetries, signal) {
658
+ async handleHttpError(response, url, attempt, rateLimitRetries, signal, method = "GET", requestBody) {
618
659
  const body = await response.text();
619
660
  if (response.status === 429) {
620
661
  if (rateLimitRetries >= this.maxRateLimitRetries) {
@@ -631,12 +672,12 @@ var DataverseHttpClient = class {
631
672
  retryAfterHeader
632
673
  });
633
674
  await this.sleep(retryAfterMs);
634
- return this.executeWithRetry(url, attempt, rateLimitRetries + 1, signal);
675
+ return this.executeWithRetry(url, attempt, rateLimitRetries + 1, signal, method, requestBody);
635
676
  }
636
677
  if (response.status === 401 && attempt === 1) {
637
678
  log2.warn("HTTP 401 received, clearing token cache and retrying");
638
679
  this.cachedToken = null;
639
- return this.executeWithRetry(url, attempt + 1, 0, signal);
680
+ return this.executeWithRetry(url, attempt + 1, 0, signal, method, requestBody);
640
681
  }
641
682
  if (response.status >= 500 && attempt <= this.maxRetries) {
642
683
  const delay = this.calculateBackoff(attempt);
@@ -644,7 +685,7 @@ var DataverseHttpClient = class {
644
685
  `Server error ${response.status}, retrying in ${delay}ms (${attempt}/${this.maxRetries})`
645
686
  );
646
687
  await this.sleep(delay);
647
- return this.executeWithRetry(url, attempt + 1, rateLimitRetries, signal);
688
+ return this.executeWithRetry(url, attempt + 1, rateLimitRetries, signal, method, requestBody);
648
689
  }
649
690
  const errorCode = response.status === 401 ? "API_2004" /* API_UNAUTHORIZED */ : response.status === 404 ? "API_2003" /* API_NOT_FOUND */ : "API_2001" /* API_REQUEST_FAILED */;
650
691
  throw new ApiRequestError(
@@ -667,7 +708,7 @@ var DataverseHttpClient = class {
667
708
  return Math.min(exponential + jitter, MAX_BACKOFF_MS);
668
709
  }
669
710
  sleep(ms) {
670
- return new Promise((resolve) => setTimeout(resolve, ms));
711
+ return new Promise((resolve2) => setTimeout(resolve2, ms));
671
712
  }
672
713
  };
673
714
 
@@ -1243,10 +1284,12 @@ var MetadataCache = class {
1243
1284
  cacheDir;
1244
1285
  cacheFilePath;
1245
1286
  /**
1246
- * @param projectRoot - Root directory of the project (where .xrmforge/ will be created)
1287
+ * @param cacheDir - Directory where cache files are stored.
1288
+ * Can be an absolute path or relative to cwd.
1289
+ * Defaults to ".xrmforge/cache" when constructed without argument.
1247
1290
  */
1248
- constructor(projectRoot) {
1249
- this.cacheDir = path.join(projectRoot, CACHE_DIR);
1291
+ constructor(cacheDir = CACHE_DIR) {
1292
+ this.cacheDir = path.resolve(cacheDir);
1250
1293
  this.cacheFilePath = path.join(this.cacheDir, CACHE_FILE);
1251
1294
  }
1252
1295
  /**
@@ -1362,6 +1405,105 @@ var MetadataCache = class {
1362
1405
  }
1363
1406
  };
1364
1407
 
1408
+ // src/metadata/change-detector.ts
1409
+ var log6 = createLogger("change-detector");
1410
+ var EXPIRED_VERSION_STAMP_ERROR = "0x80044352";
1411
+ var ChangeDetector = class {
1412
+ constructor(http) {
1413
+ this.http = http;
1414
+ }
1415
+ /**
1416
+ * Detect which entities have changed since the given version stamp.
1417
+ *
1418
+ * @param clientVersionStamp - The ServerVersionStamp from the last run (from cache)
1419
+ * @returns Changed entity names, deleted entity names, and new version stamp
1420
+ * @throws {MetadataError} with META_VERSION_STAMP_EXPIRED if stamp is too old (>90 days)
1421
+ */
1422
+ async detectChanges(clientVersionStamp) {
1423
+ log6.info("Detecting metadata changes since last run");
1424
+ const requestBody = {
1425
+ Query: {
1426
+ Criteria: {
1427
+ FilterOperator: "And",
1428
+ Conditions: []
1429
+ },
1430
+ Properties: {
1431
+ AllProperties: false,
1432
+ PropertyNames: ["LogicalName"]
1433
+ }
1434
+ },
1435
+ ClientVersionStamp: clientVersionStamp,
1436
+ DeletedMetadataFilters: "Entity"
1437
+ };
1438
+ let response;
1439
+ try {
1440
+ response = await this.http.postReadOnly(
1441
+ "/RetrieveMetadataChanges",
1442
+ requestBody
1443
+ );
1444
+ } catch (error) {
1445
+ if (this.isExpiredVersionStampError(error)) {
1446
+ throw new MetadataError(
1447
+ "META_3005" /* META_VERSION_STAMP_EXPIRED */,
1448
+ "Cached version stamp has expired (>90 days). A full metadata refresh is required.",
1449
+ { clientVersionStamp }
1450
+ );
1451
+ }
1452
+ throw error;
1453
+ }
1454
+ const changedEntityNames = (response.EntityMetadata ?? []).filter((e) => e.HasChanged !== false).map((e) => e.LogicalName).filter(Boolean);
1455
+ const deletedEntityNames = [];
1456
+ if (response.DeletedMetadata?.Keys) {
1457
+ log6.info(`${response.DeletedMetadata.Keys.length} entity metadata IDs were deleted`);
1458
+ }
1459
+ const newVersionStamp = response.ServerVersionStamp;
1460
+ log6.info(`Change detection complete: ${changedEntityNames.length} changed, ${deletedEntityNames.length} deleted`, {
1461
+ changedEntityNames: changedEntityNames.length <= 10 ? changedEntityNames : `${changedEntityNames.length} entities`,
1462
+ newVersionStamp: newVersionStamp.substring(0, 20) + "..."
1463
+ });
1464
+ return {
1465
+ changedEntityNames,
1466
+ deletedEntityNames,
1467
+ newVersionStamp
1468
+ };
1469
+ }
1470
+ /**
1471
+ * Perform an initial metadata query to get the first ServerVersionStamp.
1472
+ * This is used on the very first run (no cache exists).
1473
+ *
1474
+ * @returns The initial server version stamp
1475
+ */
1476
+ async getInitialVersionStamp() {
1477
+ log6.info("Fetching initial server version stamp");
1478
+ const requestBody = {
1479
+ Query: {
1480
+ Criteria: {
1481
+ FilterOperator: "And",
1482
+ Conditions: []
1483
+ },
1484
+ Properties: {
1485
+ AllProperties: false,
1486
+ PropertyNames: ["LogicalName"]
1487
+ }
1488
+ }
1489
+ };
1490
+ const response = await this.http.postReadOnly(
1491
+ "/RetrieveMetadataChanges",
1492
+ requestBody
1493
+ );
1494
+ log6.info("Initial version stamp acquired");
1495
+ return response.ServerVersionStamp;
1496
+ }
1497
+ /** Check if an error is the expired version stamp error (0x80044352) */
1498
+ isExpiredVersionStampError(error) {
1499
+ const msg = error instanceof Error ? error.message : String(error);
1500
+ const errorRecord = error;
1501
+ const contextBody = errorRecord?.context ? String(errorRecord.context["responseBody"] ?? "") : "";
1502
+ const combined = msg + contextBody;
1503
+ return combined.includes(EXPIRED_VERSION_STAMP_ERROR) || combined.includes("ExpiredVersionStamp");
1504
+ }
1505
+ };
1506
+
1365
1507
  // src/metadata/labels.ts
1366
1508
  var DEFAULT_LABEL_CONFIG = {
1367
1509
  primaryLanguage: 1033
@@ -1455,10 +1597,12 @@ function getLabelLanguagesParam(config) {
1455
1597
  }
1456
1598
 
1457
1599
  // src/generators/type-mapping.ts
1600
+ var log7 = createLogger("type-mapping");
1458
1601
  function getEntityPropertyType(attributeType, isLookup = false) {
1459
1602
  if (isLookup) return "string";
1460
1603
  const mapping = ENTITY_TYPE_MAP[attributeType];
1461
1604
  if (mapping) return mapping;
1605
+ log7.warn(`Unmapped AttributeType "${attributeType}" falling back to "unknown"`);
1462
1606
  return "unknown";
1463
1607
  }
1464
1608
  var ENTITY_TYPE_MAP = {
@@ -1497,6 +1641,7 @@ var ENTITY_TYPE_MAP = {
1497
1641
  function getFormAttributeType(attributeType) {
1498
1642
  const mapping = FORM_ATTRIBUTE_TYPE_MAP[attributeType];
1499
1643
  if (mapping) return mapping;
1644
+ log7.warn(`Unmapped form AttributeType "${attributeType}" falling back to generic Attribute`);
1500
1645
  return "Xrm.Attributes.Attribute";
1501
1646
  }
1502
1647
  var FORM_ATTRIBUTE_TYPE_MAP = {
@@ -2380,6 +2525,7 @@ function createBoundAction(operationName, entityLogicalName, paramMeta) {
2380
2525
  }
2381
2526
  function createUnboundAction(operationName, paramMeta) {
2382
2527
  return {
2528
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- return type varies (Response or parsed JSON)
2383
2529
  async execute(params) {
2384
2530
  const req = buildUnboundRequest(
2385
2531
  operationName,
@@ -2664,7 +2810,7 @@ function groupCustomApis(apis) {
2664
2810
  }
2665
2811
 
2666
2812
  // src/orchestrator/file-writer.ts
2667
- import { mkdir, writeFile, readFile } from "fs/promises";
2813
+ import { mkdir, writeFile, readFile, unlink } from "fs/promises";
2668
2814
  import { join as join2, dirname } from "path";
2669
2815
  async function writeGeneratedFile(outputDir, file) {
2670
2816
  const absolutePath = join2(outputDir, file.relativePath);
@@ -2696,6 +2842,24 @@ async function writeAllFiles(outputDir, files) {
2696
2842
  }
2697
2843
  return result;
2698
2844
  }
2845
+ async function deleteOrphanedFiles(outputDir, deletedEntityNames) {
2846
+ let deleted = 0;
2847
+ const subdirs = ["entities", "optionsets", "forms"];
2848
+ for (const entityName of deletedEntityNames) {
2849
+ for (const subdir of subdirs) {
2850
+ const filePath = join2(outputDir, subdir, `${entityName}.d.ts`);
2851
+ try {
2852
+ await unlink(filePath);
2853
+ deleted++;
2854
+ } catch (error) {
2855
+ if (error.code !== "ENOENT") {
2856
+ throw error;
2857
+ }
2858
+ }
2859
+ }
2860
+ }
2861
+ return deleted;
2862
+ }
2699
2863
  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
2864
  // This file was generated by @xrmforge/typegen. Do not edit manually.
2701
2865
  // Re-run 'xrmforge generate' to update.
@@ -2742,11 +2906,6 @@ var TypeGenerationOrchestrator = class {
2742
2906
  constructor(credential, config, logger) {
2743
2907
  this.credential = credential;
2744
2908
  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
2909
  this.config = {
2751
2910
  environmentUrl: config.environmentUrl,
2752
2911
  entities: [...config.entities],
@@ -2781,7 +2940,8 @@ var TypeGenerationOrchestrator = class {
2781
2940
  }
2782
2941
  this.logger.info("Starting type generation", {
2783
2942
  entities: this.config.entities,
2784
- outputDir: this.config.outputDir
2943
+ outputDir: this.config.outputDir,
2944
+ useCache: this.config.useCache
2785
2945
  });
2786
2946
  const httpClient = new DataverseHttpClient({
2787
2947
  environmentUrl: this.config.environmentUrl,
@@ -2804,80 +2964,71 @@ var TypeGenerationOrchestrator = class {
2804
2964
  durationMs: Date.now() - startTime
2805
2965
  };
2806
2966
  }
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"));
2967
+ let cacheStats;
2968
+ const entitiesToFetch = new Set(this.config.entities);
2969
+ const cachedEntityInfos = {};
2970
+ let cache;
2971
+ let newVersionStamp = null;
2972
+ const deletedEntityNames = [];
2973
+ if (this.config.useCache) {
2974
+ const cacheResult = await this.resolveCache(httpClient, entitiesToFetch);
2975
+ cache = cacheResult.cache;
2976
+ newVersionStamp = cacheResult.newVersionStamp;
2977
+ cacheStats = cacheResult.stats;
2978
+ for (const [name, info] of Object.entries(cacheResult.cachedEntities)) {
2979
+ cachedEntityInfos[name] = info;
2980
+ entitiesToFetch.delete(name);
2981
+ }
2982
+ deletedEntityNames.push(...cacheResult.deletedEntityNames);
2983
+ }
2984
+ const fetchList = [...entitiesToFetch];
2985
+ const failedEntities = /* @__PURE__ */ new Map();
2986
+ if (fetchList.length > 0) {
2987
+ this.logger.info(`Fetching ${fetchList.length} entities from Dataverse`);
2988
+ const settled = await Promise.allSettled(
2989
+ fetchList.map((entityName) => {
2990
+ if (signal?.aborted) {
2991
+ return Promise.reject(new Error("Generation aborted"));
2992
+ }
2993
+ return metadataClient.getEntityTypeInfo(entityName).then((info) => {
2994
+ this.logger.info(`Fetched entity: ${entityName}`);
2995
+ return { entityName, info };
2996
+ });
2997
+ })
2998
+ );
2999
+ for (let i = 0; i < settled.length; i++) {
3000
+ const outcome = settled[i];
3001
+ const entityName = fetchList[i];
3002
+ if (outcome.status === "fulfilled") {
3003
+ cachedEntityInfos[outcome.value.entityName] = outcome.value.info;
3004
+ } else {
3005
+ const errorMsg = outcome.reason instanceof Error ? outcome.reason.message : String(outcome.reason);
3006
+ this.logger.error(`Failed to fetch entity: ${entityName}`, { error: outcome.reason });
3007
+ failedEntities.set(entityName, errorMsg);
2812
3008
  }
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 });
3009
+ }
3010
+ }
3011
+ this.logger.info(`Generating types for ${this.config.entities.length - failedEntities.size} entities`);
3012
+ for (const entityName of this.config.entities) {
3013
+ if (signal?.aborted) break;
3014
+ if (failedEntities.has(entityName)) {
2828
3015
  entityResults.push({
2829
3016
  entityLogicalName: entityName,
2830
3017
  files: [],
2831
- warnings: [`Failed to process: ${errorMsg}`]
3018
+ warnings: [`Failed to process: ${failedEntities.get(entityName)}`]
2832
3019
  });
3020
+ continue;
2833
3021
  }
3022
+ const entityInfo = cachedEntityInfos[entityName];
3023
+ if (!entityInfo) continue;
3024
+ const result = this.generateEntityFiles(entityName, entityInfo);
3025
+ this.logger.info(`Generated entity: ${entityName} (${result.files.length} files)`);
3026
+ entityResults.push(result);
3027
+ allFiles.push(...result.files);
2834
3028
  }
2835
3029
  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
- }
3030
+ const actionFiles = await this.generateActions(metadataClient);
3031
+ allFiles.push(...actionFiles);
2881
3032
  }
2882
3033
  if (this.config.entities.length > 0) {
2883
3034
  const entityNamesContent = generateEntityNamesEnum(this.config.entities, {
@@ -2899,6 +3050,15 @@ var TypeGenerationOrchestrator = class {
2899
3050
  allFiles.push(indexFile);
2900
3051
  }
2901
3052
  const writeResult = await writeAllFiles(this.config.outputDir, allFiles);
3053
+ if (deletedEntityNames.length > 0) {
3054
+ const deleted = await deleteOrphanedFiles(this.config.outputDir, deletedEntityNames);
3055
+ if (deleted > 0) {
3056
+ this.logger.info(`Deleted ${deleted} orphaned files for removed entities`);
3057
+ }
3058
+ }
3059
+ if (this.config.useCache && cache) {
3060
+ await this.updateCache(cache, cachedEntityInfos, deletedEntityNames, newVersionStamp);
3061
+ }
2902
3062
  const durationMs = Date.now() - startTime;
2903
3063
  const entityWarnings = entityResults.reduce((sum, r) => sum + r.warnings.length, 0);
2904
3064
  const totalWarnings = entityWarnings + writeResult.warnings.length;
@@ -2911,22 +3071,129 @@ var TypeGenerationOrchestrator = class {
2911
3071
  filesUnchanged: writeResult.unchanged,
2912
3072
  totalFiles: allFiles.length,
2913
3073
  totalWarnings,
2914
- durationMs
3074
+ durationMs,
3075
+ cacheUsed: cacheStats?.cacheUsed ?? false
2915
3076
  });
2916
3077
  return {
2917
3078
  entities: entityResults,
2918
3079
  totalFiles: allFiles.length,
2919
3080
  totalWarnings,
2920
- durationMs
3081
+ durationMs,
3082
+ cacheStats
2921
3083
  };
2922
3084
  }
2923
3085
  /**
2924
- * Process a single entity: fetch metadata, generate all output files.
3086
+ * Resolve the cache: load existing cache, detect changes, determine which
3087
+ * entities need to be fetched vs. can be served from cache.
3088
+ *
3089
+ * On any failure (corrupt cache, expired stamp), falls back to full refresh.
2925
3090
  */
2926
- async processEntity(entityName, metadataClient) {
3091
+ async resolveCache(httpClient, requestedEntities) {
3092
+ const cache = new MetadataCache(this.config.cacheDir);
3093
+ const changeDetector = new ChangeDetector(httpClient);
3094
+ let cacheData;
3095
+ try {
3096
+ cacheData = await cache.load(this.config.environmentUrl);
3097
+ } catch {
3098
+ this.logger.warn("Failed to load metadata cache, performing full refresh");
3099
+ cacheData = null;
3100
+ }
3101
+ if (!cacheData || !cacheData.manifest.serverVersionStamp) {
3102
+ this.logger.info("No valid cache found, performing full metadata refresh");
3103
+ const stamp = await changeDetector.getInitialVersionStamp();
3104
+ return {
3105
+ cache,
3106
+ cachedEntities: {},
3107
+ deletedEntityNames: [],
3108
+ newVersionStamp: stamp,
3109
+ stats: {
3110
+ cacheUsed: true,
3111
+ fullRefresh: true,
3112
+ entitiesFromCache: 0,
3113
+ entitiesFetched: requestedEntities.size,
3114
+ entitiesDeleted: 0
3115
+ }
3116
+ };
3117
+ }
3118
+ let changeResult;
3119
+ try {
3120
+ changeResult = await changeDetector.detectChanges(cacheData.manifest.serverVersionStamp);
3121
+ } catch (error) {
3122
+ const isExpired = error instanceof Error && error.message.includes("META_3005" /* META_VERSION_STAMP_EXPIRED */);
3123
+ if (isExpired) {
3124
+ this.logger.warn("Cache version stamp expired (>90 days), performing full refresh");
3125
+ } else {
3126
+ this.logger.warn("Change detection failed, performing full refresh", {
3127
+ error: error instanceof Error ? error.message : String(error)
3128
+ });
3129
+ }
3130
+ const stamp = await changeDetector.getInitialVersionStamp();
3131
+ return {
3132
+ cache,
3133
+ cachedEntities: {},
3134
+ deletedEntityNames: [],
3135
+ newVersionStamp: stamp,
3136
+ stats: {
3137
+ cacheUsed: true,
3138
+ fullRefresh: true,
3139
+ entitiesFromCache: 0,
3140
+ entitiesFetched: requestedEntities.size,
3141
+ entitiesDeleted: 0
3142
+ }
3143
+ };
3144
+ }
3145
+ const changedSet = new Set(changeResult.changedEntityNames);
3146
+ const cachedEntities = {};
3147
+ let entitiesFromCache = 0;
3148
+ let entitiesFetched = 0;
3149
+ for (const entityName of requestedEntities) {
3150
+ if (changedSet.has(entityName) || !cacheData.entityTypeInfos[entityName]) {
3151
+ entitiesFetched++;
3152
+ } else {
3153
+ cachedEntities[entityName] = cacheData.entityTypeInfos[entityName];
3154
+ entitiesFromCache++;
3155
+ }
3156
+ }
3157
+ const deletedEntityNames = changeResult.deletedEntityNames.filter(
3158
+ (name) => requestedEntities.has(name)
3159
+ );
3160
+ this.logger.info(`Cache delta: ${entitiesFromCache} from cache, ${entitiesFetched} to fetch, ${deletedEntityNames.length} deleted`);
3161
+ return {
3162
+ cache,
3163
+ cachedEntities,
3164
+ deletedEntityNames,
3165
+ newVersionStamp: changeResult.newVersionStamp,
3166
+ stats: {
3167
+ cacheUsed: true,
3168
+ fullRefresh: false,
3169
+ entitiesFromCache,
3170
+ entitiesFetched,
3171
+ entitiesDeleted: deletedEntityNames.length
3172
+ }
3173
+ };
3174
+ }
3175
+ /**
3176
+ * Update the metadata cache after a successful generation run.
3177
+ */
3178
+ async updateCache(cache, entityTypeInfos, deletedEntityNames, newVersionStamp) {
3179
+ try {
3180
+ if (deletedEntityNames.length > 0) {
3181
+ await cache.removeEntities(this.config.environmentUrl, deletedEntityNames, newVersionStamp);
3182
+ }
3183
+ await cache.save(this.config.environmentUrl, entityTypeInfos, newVersionStamp);
3184
+ this.logger.info("Metadata cache updated");
3185
+ } catch (error) {
3186
+ this.logger.warn("Failed to update metadata cache", {
3187
+ error: error instanceof Error ? error.message : String(error)
3188
+ });
3189
+ }
3190
+ }
3191
+ /**
3192
+ * Generate all output files for a single entity from its metadata.
3193
+ */
3194
+ generateEntityFiles(entityName, entityInfo) {
2927
3195
  const warnings = [];
2928
3196
  const files = [];
2929
- const entityInfo = await metadataClient.getEntityTypeInfo(entityName);
2930
3197
  if (this.config.generateEntities) {
2931
3198
  const entityContent = generateEntityInterface(entityInfo, {
2932
3199
  labelConfig: this.config.labelConfig,
@@ -2982,6 +3249,58 @@ var TypeGenerationOrchestrator = class {
2982
3249
  }
2983
3250
  return { entityLogicalName: entityName, files, warnings };
2984
3251
  }
3252
+ /**
3253
+ * Generate Custom API Action/Function executor files.
3254
+ */
3255
+ async generateActions(metadataClient) {
3256
+ const files = [];
3257
+ this.logger.info("Fetching Custom APIs...");
3258
+ let customApis = await metadataClient.getCustomApis();
3259
+ if (this.config.actionsFilter) {
3260
+ const prefix = this.config.actionsFilter.toLowerCase();
3261
+ const before = customApis.length;
3262
+ customApis = customApis.filter((api) => api.api.uniquename.toLowerCase().startsWith(prefix));
3263
+ this.logger.info(`Filtered Custom APIs by prefix "${this.config.actionsFilter}": ${before} -> ${customApis.length}`);
3264
+ }
3265
+ if (customApis.length > 0) {
3266
+ const importPath = "@xrmforge/typegen";
3267
+ const grouped = groupCustomApis(customApis);
3268
+ for (const [key, apis] of grouped.actions) {
3269
+ const entityName = key === "global" ? void 0 : key;
3270
+ const declarations = generateActionDeclarations(apis, false, entityName, { importPath });
3271
+ const module = generateActionModule(apis, false, { importPath });
3272
+ files.push({
3273
+ relativePath: `actions/${key}.d.ts`,
3274
+ content: addGeneratedHeader(declarations),
3275
+ type: "action"
3276
+ });
3277
+ files.push({
3278
+ relativePath: `actions/${key}.ts`,
3279
+ content: addGeneratedHeader(module),
3280
+ type: "action"
3281
+ });
3282
+ }
3283
+ for (const [key, apis] of grouped.functions) {
3284
+ const entityName = key === "global" ? void 0 : key;
3285
+ const declarations = generateActionDeclarations(apis, true, entityName, { importPath });
3286
+ const module = generateActionModule(apis, true, { importPath });
3287
+ files.push({
3288
+ relativePath: `functions/${key}.d.ts`,
3289
+ content: addGeneratedHeader(declarations),
3290
+ type: "action"
3291
+ });
3292
+ files.push({
3293
+ relativePath: `functions/${key}.ts`,
3294
+ content: addGeneratedHeader(module),
3295
+ type: "action"
3296
+ });
3297
+ }
3298
+ this.logger.info(`Generated ${grouped.actions.size} action groups, ${grouped.functions.size} function groups`);
3299
+ } else {
3300
+ this.logger.info("No Custom APIs found");
3301
+ }
3302
+ return files;
3303
+ }
2985
3304
  /**
2986
3305
  * Extract picklist attributes with their OptionSet metadata.
2987
3306
  * Maps the raw EntityTypeInfo data to the format expected by the OptionSet generator.
@@ -3016,6 +3335,7 @@ export {
3016
3335
  ApiRequestError,
3017
3336
  AuthenticationError,
3018
3337
  BindingType,
3338
+ ChangeDetector,
3019
3339
  ClientState,
3020
3340
  ClientType,
3021
3341
  ConfigError,