@xrmforge/typegen 0.10.1 → 0.11.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 CHANGED
@@ -765,6 +765,10 @@ declare class MetadataClient {
765
765
  * Queries the customapi, customapirequestparameter, and customapiresponseproperty
766
766
  * tables and joins them into CustomApiTypeInfo objects.
767
767
  *
768
+ * APIs, request parameters, and response properties are sorted alphabetically
769
+ * by uniquename (ordinal) so the result is deterministic regardless of
770
+ * server row order.
771
+ *
768
772
  * @param solutionFilter - Optional: filter by solution unique name
769
773
  * @returns Array of complete Custom API definitions
770
774
  */
@@ -1543,6 +1547,19 @@ interface GenerateConfig {
1543
1547
  cacheDir?: string;
1544
1548
  /** XrmForge namespace prefix (default: "XrmForge") */
1545
1549
  namespacePrefix?: string;
1550
+ /**
1551
+ * Check mode (drift detection): generate in-memory and compare the result
1552
+ * byte-by-byte against the files in outputDir WITHOUT any write access
1553
+ * (no output files, no cache reads or updates, no orphan deletion).
1554
+ * The comparison result is returned in GenerationResult.checkResult.
1555
+ * Intended as a CI step that fails when the checked-in generated files
1556
+ * no longer match the live environment.
1557
+ * When enabled, useCache is ignored: the check must run against live
1558
+ * metadata, otherwise a stale local cache would mask exactly the drift
1559
+ * it is supposed to find.
1560
+ * @defaultValue false
1561
+ */
1562
+ checkOnly?: boolean;
1546
1563
  }
1547
1564
  /** Result of generating types for a single entity */
1548
1565
  interface EntityGenerationResult {
@@ -1575,6 +1592,30 @@ interface CacheStats {
1575
1592
  /** Number of entities removed (deleted in Dataverse) */
1576
1593
  entitiesDeleted: number;
1577
1594
  }
1595
+ /** A single drift finding from check mode */
1596
+ interface CheckFinding {
1597
+ /** Relative path from outputDir (e.g. "actions/global.ts") */
1598
+ relativePath: string;
1599
+ /** Category of the file (matches GeneratedFile.type) */
1600
+ type: GeneratedFile['type'];
1601
+ /**
1602
+ * Drift class:
1603
+ * - "changed": file exists on disk but differs from freshly generated content
1604
+ * - "missing": generator produces this file but it does not exist on disk
1605
+ * - "orphaned": file exists on disk but the generator no longer produces it
1606
+ * (e.g. the entity or Custom API was deleted in Dataverse)
1607
+ */
1608
+ status: 'changed' | 'missing' | 'orphaned';
1609
+ }
1610
+ /** Result of a drift check (GenerateConfig.checkOnly) */
1611
+ interface CheckResult {
1612
+ /** True if at least one finding exists (generated output drifted) */
1613
+ drift: boolean;
1614
+ /** Number of files that are byte-identical on disk */
1615
+ unchanged: number;
1616
+ /** All drift findings (changed, missing, orphaned files) */
1617
+ findings: CheckFinding[];
1618
+ }
1578
1619
  /** Overall result of the generation process */
1579
1620
  interface GenerationResult {
1580
1621
  /** Per-entity results */
@@ -1587,6 +1628,12 @@ interface GenerationResult {
1587
1628
  durationMs: number;
1588
1629
  /** Cache statistics (present when useCache was enabled) */
1589
1630
  cacheStats?: CacheStats;
1631
+ /**
1632
+ * Drift check result (present when checkOnly was enabled and all entities
1633
+ * were fetched successfully; absent on fetch failures or abort, because a
1634
+ * partial generation would produce misleading missing/orphaned findings).
1635
+ */
1636
+ checkResult?: CheckResult;
1590
1637
  }
1591
1638
 
1592
1639
  /**
@@ -1663,4 +1710,4 @@ declare class TypeGenerationOrchestrator {
1663
1710
  private generateFormMapping;
1664
1711
  }
1665
1712
 
1666
- export { type ActionGeneratorOptions, ApiRequestError, type AttributeMetadata, type AuthConfig, type AuthMethod, AuthenticationError, type CacheStats, type ChangeDetectionResult, ChangeDetector, type ClientCredentialsAuth, ConfigError, ConsoleLogSink, type CustomApiTypeInfo, DEFAULT_LABEL_CONFIG, DataverseHttpClient, type DateTimeAttributeMetadata, type DecimalAttributeMetadata, type DeviceCodeAuth, type EntityFieldsGeneratorOptions, type EntityGenerationResult, type EntityGeneratorOptions, type EntityMetadata, type EntityNamesGeneratorOptions, type EntityTypeInfo, ErrorCode, FastXmlParser, type FormControl, type FormGeneratorOptions, type FormSection, type FormTab, type GenerateConfig, type GeneratedFile, GenerationError, type GenerationResult, type GroupedCustomApis, type HttpClientOptions, type IntegerAttributeMetadata, type InteractiveAuth, JsonLogSink, type Label, type LabelConfig, type LocalizedLabel, type LogEntry, LogLevel, type LogSink, Logger, type LookupAttributeMetadata, type ManyToManyRelationshipMetadata, MetadataCache, MetadataClient, MetadataError, type MoneyAttributeMetadata, type OneToManyRelationshipMetadata, type OptionMetadata, type OptionSetGeneratorOptions, type OptionSetMetadata, type ParsedForm, type PicklistAttributeMetadata, SilentLogSink, type SolutionComponent, type StateAttributeMetadata, type StatusAttributeMetadata, type StringAttributeMetadata, type SystemFormMetadata, TypeGenerationOrchestrator, type XmlElement, type XmlParser, XrmForgeError, configureLogging, createCredential, createLogger, defaultXmlParser, disambiguateEnumMembers, extractControlFields, getJSDocLabel as formatDualLabel, generateActionDeclarations, generateActionModule, generateActivityPartyInterface, generateEntityFieldsEnum, generateEntityForms, generateEntityInterface, generateEntityNamesEnum, generateEntityNavigationProperties, generateEntityOptionSets, generateEnumMembers, generateFormInterface, generateOptionSetEnum, getEntityPropertyType, getFormAttributeType, getFormControlType, getFormMockValueType, getJSDocLabel, getLabelLanguagesParam, getPrimaryLabel, getSecondaryLabel, groupCustomApis, isLookupType, isPartyListType, isRateLimitError, isXrmForgeError, labelToIdentifier, parseForm, shouldIncludeInEntityInterface, toLookupValueProperty, toPascalCase, toSafeIdentifier };
1713
+ export { type ActionGeneratorOptions, ApiRequestError, type AttributeMetadata, type AuthConfig, type AuthMethod, AuthenticationError, type CacheStats, type ChangeDetectionResult, ChangeDetector, type CheckFinding, type CheckResult, type ClientCredentialsAuth, ConfigError, ConsoleLogSink, type CustomApiTypeInfo, DEFAULT_LABEL_CONFIG, DataverseHttpClient, type DateTimeAttributeMetadata, type DecimalAttributeMetadata, type DeviceCodeAuth, type EntityFieldsGeneratorOptions, type EntityGenerationResult, type EntityGeneratorOptions, type EntityMetadata, type EntityNamesGeneratorOptions, type EntityTypeInfo, ErrorCode, FastXmlParser, type FormControl, type FormGeneratorOptions, type FormSection, type FormTab, type GenerateConfig, type GeneratedFile, GenerationError, type GenerationResult, type GroupedCustomApis, type HttpClientOptions, type IntegerAttributeMetadata, type InteractiveAuth, JsonLogSink, type Label, type LabelConfig, type LocalizedLabel, type LogEntry, LogLevel, type LogSink, Logger, type LookupAttributeMetadata, type ManyToManyRelationshipMetadata, MetadataCache, MetadataClient, MetadataError, type MoneyAttributeMetadata, type OneToManyRelationshipMetadata, type OptionMetadata, type OptionSetGeneratorOptions, type OptionSetMetadata, type ParsedForm, type PicklistAttributeMetadata, SilentLogSink, type SolutionComponent, type StateAttributeMetadata, type StatusAttributeMetadata, type StringAttributeMetadata, type SystemFormMetadata, TypeGenerationOrchestrator, type XmlElement, type XmlParser, XrmForgeError, configureLogging, createCredential, createLogger, defaultXmlParser, disambiguateEnumMembers, extractControlFields, getJSDocLabel as formatDualLabel, generateActionDeclarations, generateActionModule, generateActivityPartyInterface, generateEntityFieldsEnum, generateEntityForms, generateEntityInterface, generateEntityNamesEnum, generateEntityNavigationProperties, generateEntityOptionSets, generateEnumMembers, generateFormInterface, generateOptionSetEnum, getEntityPropertyType, getFormAttributeType, getFormControlType, getFormMockValueType, getJSDocLabel, getLabelLanguagesParam, getPrimaryLabel, getSecondaryLabel, groupCustomApis, isLookupType, isPartyListType, isRateLimitError, isXrmForgeError, labelToIdentifier, parseForm, shouldIncludeInEntityInterface, toLookupValueProperty, toPascalCase, toSafeIdentifier };
package/dist/index.js CHANGED
@@ -919,6 +919,9 @@ function extractParameter(controlElement, paramName) {
919
919
  var log4 = createLogger("metadata");
920
920
  var FORM_TYPE_MAIN = 2;
921
921
  var COMPONENT_TYPE_ENTITY = 1;
922
+ function byUniqueName(a, b) {
923
+ return a.uniquename < b.uniquename ? -1 : a.uniquename > b.uniquename ? 1 : 0;
924
+ }
922
925
  var ENTITY_SELECT = "LogicalName,SchemaName,EntitySetName,DisplayName,PrimaryIdAttribute,PrimaryNameAttribute,OwnershipType,IsCustomEntity,LogicalCollectionName,MetadataId";
923
926
  var ATTRIBUTE_SELECT = "LogicalName,SchemaName,AttributeType,AttributeTypeName,DisplayName,IsPrimaryId,IsPrimaryName,RequiredLevel,IsValidForRead,IsValidForCreate,IsValidForUpdate,MetadataId";
924
927
  var FORM_SELECT = "name,formid,formxml,description,isdefault";
@@ -1183,6 +1186,10 @@ var MetadataClient = class {
1183
1186
  * Queries the customapi, customapirequestparameter, and customapiresponseproperty
1184
1187
  * tables and joins them into CustomApiTypeInfo objects.
1185
1188
  *
1189
+ * APIs, request parameters, and response properties are sorted alphabetically
1190
+ * by uniquename (ordinal) so the result is deterministic regardless of
1191
+ * server row order.
1192
+ *
1186
1193
  * @param solutionFilter - Optional: filter by solution unique name
1187
1194
  * @returns Array of complete Custom API definitions
1188
1195
  */
@@ -1236,10 +1243,11 @@ var MetadataClient = class {
1236
1243
  displayname: api.displayname,
1237
1244
  description: api.description
1238
1245
  },
1239
- requestParameters: paramsByApi.get(api.customapiid) ?? [],
1240
- responseProperties: propsByApi.get(api.customapiid) ?? []
1246
+ requestParameters: (paramsByApi.get(api.customapiid) ?? []).sort(byUniqueName),
1247
+ responseProperties: (propsByApi.get(api.customapiid) ?? []).sort(byUniqueName)
1241
1248
  });
1242
1249
  }
1250
+ result.sort((a, b) => byUniqueName(a.api, b.api));
1243
1251
  log4.info(`Loaded ${result.length} Custom APIs with parameters and response properties`);
1244
1252
  return result;
1245
1253
  }
@@ -2559,7 +2567,7 @@ function groupCustomApis(apis) {
2559
2567
  }
2560
2568
 
2561
2569
  // src/orchestrator/file-writer.ts
2562
- import { mkdir, writeFile, readFile, unlink } from "fs/promises";
2570
+ import { mkdir, writeFile, readFile, unlink, readdir, access } from "fs/promises";
2563
2571
  import { join as join2, dirname } from "path";
2564
2572
  async function writeGeneratedFile(outputDir, file) {
2565
2573
  const absolutePath = join2(outputDir, file.relativePath);
@@ -2609,6 +2617,73 @@ async function deleteOrphanedFiles(outputDir, deletedEntityNames) {
2609
2617
  }
2610
2618
  return deleted;
2611
2619
  }
2620
+ var GENERATED_SUBDIRS = ["entities", "optionsets", "forms", "fields", "actions", "functions"];
2621
+ var GENERATED_ROOT_FILES = ["entity-names.ts", "form-mapping.json", "index.ts"];
2622
+ function typeFromRelativePath(relativePath) {
2623
+ if (relativePath.startsWith("optionsets/")) return "optionset";
2624
+ if (relativePath.startsWith("forms/")) return "form";
2625
+ if (relativePath.startsWith("fields/")) return "fields";
2626
+ if (relativePath.startsWith("actions/") || relativePath.startsWith("functions/")) return "action";
2627
+ return "entity";
2628
+ }
2629
+ async function checkGeneratedFile(outputDir, file) {
2630
+ const absolutePath = join2(outputDir, file.relativePath);
2631
+ try {
2632
+ const existing = await readFile(absolutePath, "utf-8");
2633
+ return existing === file.content ? "unchanged" : "changed";
2634
+ } catch (error) {
2635
+ if (error.code === "ENOENT") {
2636
+ return "missing";
2637
+ }
2638
+ throw error;
2639
+ }
2640
+ }
2641
+ async function findOrphanedFiles(outputDir, expectedPaths) {
2642
+ const orphans = [];
2643
+ for (const subdir of GENERATED_SUBDIRS) {
2644
+ let entries;
2645
+ try {
2646
+ entries = await readdir(join2(outputDir, subdir), { withFileTypes: true });
2647
+ } catch (error) {
2648
+ if (error.code === "ENOENT") continue;
2649
+ throw error;
2650
+ }
2651
+ for (const entry of entries) {
2652
+ if (!entry.isFile() || !entry.name.endsWith(".ts")) continue;
2653
+ const relativePath = `${subdir}/${entry.name}`;
2654
+ if (!expectedPaths.has(relativePath)) {
2655
+ orphans.push(relativePath);
2656
+ }
2657
+ }
2658
+ }
2659
+ for (const rootFile of GENERATED_ROOT_FILES) {
2660
+ if (expectedPaths.has(rootFile)) continue;
2661
+ try {
2662
+ await access(join2(outputDir, rootFile));
2663
+ orphans.push(rootFile);
2664
+ } catch {
2665
+ }
2666
+ }
2667
+ return orphans.sort();
2668
+ }
2669
+ async function checkAllFiles(outputDir, files) {
2670
+ const findings = [];
2671
+ let unchanged = 0;
2672
+ for (const file of files) {
2673
+ const status = await checkGeneratedFile(outputDir, file);
2674
+ if (status === "unchanged") {
2675
+ unchanged++;
2676
+ } else {
2677
+ findings.push({ relativePath: file.relativePath, type: file.type, status });
2678
+ }
2679
+ }
2680
+ const expectedPaths = new Set(files.map((f) => f.relativePath));
2681
+ const orphans = await findOrphanedFiles(outputDir, expectedPaths);
2682
+ for (const relativePath of orphans) {
2683
+ findings.push({ relativePath, type: typeFromRelativePath(relativePath), status: "orphaned" });
2684
+ }
2685
+ return { drift: findings.length > 0, unchanged, findings };
2686
+ }
2612
2687
  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
2613
2688
  // This file was generated by @xrmforge/typegen. Do not edit manually.
2614
2689
  // Re-run 'xrmforge generate' to update.
@@ -2688,7 +2763,8 @@ var TypeGenerationOrchestrator = class {
2688
2763
  actionsFilter: config.actionsFilter ?? "",
2689
2764
  useCache: config.useCache ?? false,
2690
2765
  cacheDir: config.cacheDir ?? ".xrmforge/cache",
2691
- namespacePrefix: config.namespacePrefix ?? "XrmForge"
2766
+ namespacePrefix: config.namespacePrefix ?? "XrmForge",
2767
+ checkOnly: config.checkOnly ?? false
2692
2768
  };
2693
2769
  }
2694
2770
  /**
@@ -2707,10 +2783,15 @@ var TypeGenerationOrchestrator = class {
2707
2783
  if (signal?.aborted) {
2708
2784
  return { entities: [], totalFiles: 0, totalWarnings: 0, durationMs: 0 };
2709
2785
  }
2710
- this.logger.info("Starting type generation", {
2786
+ const checkOnly = this.config.checkOnly;
2787
+ const useCache = this.config.useCache && !checkOnly;
2788
+ if (this.config.useCache && checkOnly) {
2789
+ this.logger.warn("Check mode ignores the metadata cache (drift check must run against live metadata)");
2790
+ }
2791
+ this.logger.info(checkOnly ? "Starting drift check (read-only)" : "Starting type generation", {
2711
2792
  entities: this.config.entities,
2712
2793
  outputDir: this.config.outputDir,
2713
- useCache: this.config.useCache
2794
+ useCache
2714
2795
  });
2715
2796
  const httpClient = new DataverseHttpClient({
2716
2797
  environmentUrl: this.config.environmentUrl,
@@ -2739,7 +2820,7 @@ var TypeGenerationOrchestrator = class {
2739
2820
  let cache;
2740
2821
  let newVersionStamp = null;
2741
2822
  const deletedEntityNames = [];
2742
- if (this.config.useCache) {
2823
+ if (useCache) {
2743
2824
  const cacheResult = await this.resolveCache(httpClient, entitiesToFetch);
2744
2825
  cache = cacheResult.cache;
2745
2826
  newVersionStamp = cacheResult.newVersionStamp;
@@ -2838,15 +2919,28 @@ var TypeGenerationOrchestrator = class {
2838
2919
  };
2839
2920
  allFiles.push(indexFile);
2840
2921
  }
2841
- const writeResult = await writeAllFiles(this.config.outputDir, allFiles);
2842
- if (deletedEntityNames.length > 0) {
2843
- const deleted = await deleteOrphanedFiles(this.config.outputDir, deletedEntityNames);
2844
- if (deleted > 0) {
2845
- this.logger.info(`Deleted ${deleted} orphaned files for removed entities`);
2922
+ let checkResult;
2923
+ let writeResult = { written: 0, unchanged: 0, warnings: [] };
2924
+ if (checkOnly) {
2925
+ if (failedEntities.size === 0 && !signal?.aborted) {
2926
+ checkResult = await checkAllFiles(this.config.outputDir, allFiles);
2927
+ this.logger.info(
2928
+ checkResult.drift ? `Drift detected: ${checkResult.findings.length} finding(s), ${checkResult.unchanged} files unchanged` : `No drift: ${checkResult.unchanged} files unchanged`
2929
+ );
2930
+ } else {
2931
+ this.logger.warn("Drift check skipped: generation incomplete (fetch failures or abort)");
2932
+ }
2933
+ } else {
2934
+ writeResult = await writeAllFiles(this.config.outputDir, allFiles);
2935
+ if (deletedEntityNames.length > 0) {
2936
+ const deleted = await deleteOrphanedFiles(this.config.outputDir, deletedEntityNames);
2937
+ if (deleted > 0) {
2938
+ this.logger.info(`Deleted ${deleted} orphaned files for removed entities`);
2939
+ }
2940
+ }
2941
+ if (useCache && cache) {
2942
+ await this.updateCache(cache, cachedEntityInfos, deletedEntityNames, newVersionStamp);
2846
2943
  }
2847
- }
2848
- if (this.config.useCache && cache) {
2849
- await this.updateCache(cache, cachedEntityInfos, deletedEntityNames, newVersionStamp);
2850
2944
  }
2851
2945
  const durationMs = Date.now() - startTime;
2852
2946
  const entityWarnings = entityResults.reduce((sum, r) => sum + r.warnings.length, 0);
@@ -2854,7 +2948,7 @@ var TypeGenerationOrchestrator = class {
2854
2948
  for (const w of writeResult.warnings) {
2855
2949
  this.logger.warn(w);
2856
2950
  }
2857
- this.logger.info("Type generation complete", {
2951
+ this.logger.info(checkOnly ? "Drift check complete" : "Type generation complete", {
2858
2952
  entities: entityResults.length,
2859
2953
  filesWritten: writeResult.written,
2860
2954
  filesUnchanged: writeResult.unchanged,
@@ -2868,7 +2962,8 @@ var TypeGenerationOrchestrator = class {
2868
2962
  totalFiles: allFiles.length,
2869
2963
  totalWarnings,
2870
2964
  durationMs,
2871
- cacheStats
2965
+ cacheStats,
2966
+ checkResult
2872
2967
  };
2873
2968
  }
2874
2969
  /**