@xrmforge/typegen 0.10.0 → 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
  }
@@ -1859,7 +1867,8 @@ function generateEntityInterface(info, options = {}) {
1859
1867
  if (jsdocParts.length > 0) {
1860
1868
  lines.push(` /** ${jsdocParts.join(" - ")} */`);
1861
1869
  }
1862
- lines.push(` ${propertyName}: ${tsType} | null;`);
1870
+ const nullable = attr.IsPrimaryId ? "" : " | null";
1871
+ lines.push(` ${propertyName}: ${tsType}${nullable};`);
1863
1872
  }
1864
1873
  if (partyListAttrs.length > 0) {
1865
1874
  const relationship = info.oneToManyRelationships.find(
@@ -2558,7 +2567,7 @@ function groupCustomApis(apis) {
2558
2567
  }
2559
2568
 
2560
2569
  // src/orchestrator/file-writer.ts
2561
- import { mkdir, writeFile, readFile, unlink } from "fs/promises";
2570
+ import { mkdir, writeFile, readFile, unlink, readdir, access } from "fs/promises";
2562
2571
  import { join as join2, dirname } from "path";
2563
2572
  async function writeGeneratedFile(outputDir, file) {
2564
2573
  const absolutePath = join2(outputDir, file.relativePath);
@@ -2608,6 +2617,73 @@ async function deleteOrphanedFiles(outputDir, deletedEntityNames) {
2608
2617
  }
2609
2618
  return deleted;
2610
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
+ }
2611
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
2612
2688
  // This file was generated by @xrmforge/typegen. Do not edit manually.
2613
2689
  // Re-run 'xrmforge generate' to update.
@@ -2687,7 +2763,8 @@ var TypeGenerationOrchestrator = class {
2687
2763
  actionsFilter: config.actionsFilter ?? "",
2688
2764
  useCache: config.useCache ?? false,
2689
2765
  cacheDir: config.cacheDir ?? ".xrmforge/cache",
2690
- namespacePrefix: config.namespacePrefix ?? "XrmForge"
2766
+ namespacePrefix: config.namespacePrefix ?? "XrmForge",
2767
+ checkOnly: config.checkOnly ?? false
2691
2768
  };
2692
2769
  }
2693
2770
  /**
@@ -2706,10 +2783,15 @@ var TypeGenerationOrchestrator = class {
2706
2783
  if (signal?.aborted) {
2707
2784
  return { entities: [], totalFiles: 0, totalWarnings: 0, durationMs: 0 };
2708
2785
  }
2709
- 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", {
2710
2792
  entities: this.config.entities,
2711
2793
  outputDir: this.config.outputDir,
2712
- useCache: this.config.useCache
2794
+ useCache
2713
2795
  });
2714
2796
  const httpClient = new DataverseHttpClient({
2715
2797
  environmentUrl: this.config.environmentUrl,
@@ -2738,7 +2820,7 @@ var TypeGenerationOrchestrator = class {
2738
2820
  let cache;
2739
2821
  let newVersionStamp = null;
2740
2822
  const deletedEntityNames = [];
2741
- if (this.config.useCache) {
2823
+ if (useCache) {
2742
2824
  const cacheResult = await this.resolveCache(httpClient, entitiesToFetch);
2743
2825
  cache = cacheResult.cache;
2744
2826
  newVersionStamp = cacheResult.newVersionStamp;
@@ -2837,15 +2919,28 @@ var TypeGenerationOrchestrator = class {
2837
2919
  };
2838
2920
  allFiles.push(indexFile);
2839
2921
  }
2840
- const writeResult = await writeAllFiles(this.config.outputDir, allFiles);
2841
- if (deletedEntityNames.length > 0) {
2842
- const deleted = await deleteOrphanedFiles(this.config.outputDir, deletedEntityNames);
2843
- if (deleted > 0) {
2844
- 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);
2845
2943
  }
2846
- }
2847
- if (this.config.useCache && cache) {
2848
- await this.updateCache(cache, cachedEntityInfos, deletedEntityNames, newVersionStamp);
2849
2944
  }
2850
2945
  const durationMs = Date.now() - startTime;
2851
2946
  const entityWarnings = entityResults.reduce((sum, r) => sum + r.warnings.length, 0);
@@ -2853,7 +2948,7 @@ var TypeGenerationOrchestrator = class {
2853
2948
  for (const w of writeResult.warnings) {
2854
2949
  this.logger.warn(w);
2855
2950
  }
2856
- this.logger.info("Type generation complete", {
2951
+ this.logger.info(checkOnly ? "Drift check complete" : "Type generation complete", {
2857
2952
  entities: entityResults.length,
2858
2953
  filesWritten: writeResult.written,
2859
2954
  filesUnchanged: writeResult.unchanged,
@@ -2867,7 +2962,8 @@ var TypeGenerationOrchestrator = class {
2867
2962
  totalFiles: allFiles.length,
2868
2963
  totalWarnings,
2869
2964
  durationMs,
2870
- cacheStats
2965
+ cacheStats,
2966
+ checkResult
2871
2967
  };
2872
2968
  }
2873
2969
  /**