@xrmforge/typegen 0.4.0 → 0.5.1

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/MIGRATION.md ADDED
@@ -0,0 +1,154 @@
1
+ # XrmForge Migration Guide
2
+
3
+ How to convert legacy Dynamics 365 JavaScript to type-safe TypeScript with XrmForge.
4
+
5
+ ## Step 1: Initialize Project
6
+
7
+ ```bash
8
+ npx @xrmforge/cli init my-project --prefix contoso
9
+ cd my-project
10
+ npm install
11
+ ```
12
+
13
+ ## Step 2: Generate Types from Dataverse
14
+
15
+ ```bash
16
+ npx xrmforge generate \
17
+ --url https://YOUR-ORG.crm4.dynamics.com \
18
+ --auth interactive \
19
+ --tenant-id YOUR-TENANT-ID \
20
+ --client-id YOUR-CLIENT-ID \
21
+ --entities account,contact,opportunity \
22
+ --output ./typings
23
+ ```
24
+
25
+ This generates:
26
+ - `typings/entities/*.d.ts` - Entity interfaces with typed attributes
27
+ - `typings/forms/*.d.ts` - Form interfaces with Fields enum, Tabs enum, Subgrid enum
28
+ - `typings/optionsets/*.d.ts` - OptionSet const enums with labels
29
+ - `typings/entity-names.d.ts` - EntityNames const enum
30
+
31
+ ## Step 3: Convert Form Scripts
32
+
33
+ ### Before (legacy JavaScript):
34
+
35
+ ```javascript
36
+ // account.js - global functions, raw strings, no type safety
37
+ var LM = LM || {};
38
+ LM.Account = {
39
+ onLoad: function(executionContext) {
40
+ var formContext = executionContext.getFormContext();
41
+ var name = formContext.getAttribute("name"); // generic Attribute
42
+ var status = formContext.getAttribute("statuscode");
43
+ if (status.getValue() === 1) { // magic number!
44
+ formContext.getControl("revenue").setVisible(true);
45
+ }
46
+ }
47
+ };
48
+ ```
49
+
50
+ ### After (XrmForge TypeScript):
51
+
52
+ ```typescript
53
+ // account-form.ts - typed, safe, autocomplete everywhere
54
+ import { AccountMainFormFieldsEnum as Fields } from '../../typings/forms/account';
55
+
56
+ export function onLoad(executionContext: Xrm.Events.EventContext): void {
57
+ const form = executionContext.getFormContext() as XrmForge.Forms.Account.AccountMainForm;
58
+
59
+ // Fields enum: compile error on typos, autocomplete in IDE
60
+ const name = form.getAttribute(Fields.AccountName); // StringAttribute, not generic
61
+ const status = form.getAttribute(Fields.StatusCode); // OptionSetAttribute
62
+
63
+ // OptionSet enum: no magic numbers
64
+ if (status.getValue() === XrmForge.OptionSets.Account.StatusCode.Active) {
65
+ form.getControl(Fields.Revenue).setVisible(true);
66
+ }
67
+ }
68
+ ```
69
+
70
+ ### Key Differences:
71
+
72
+ | Legacy | XrmForge |
73
+ |--------|----------|
74
+ | `getAttribute("name")` | `getAttribute(Fields.AccountName)` |
75
+ | `getValue() === 1` | `getValue() === OptionSets.StatusCode.Active` |
76
+ | `formContext` (untyped) | `form as AccountMainForm` (typed) |
77
+ | `getControl("revenue")` | `getControl(Fields.Revenue)` |
78
+ | No compile-time checks | Typos are compile errors |
79
+
80
+ ## Step 4: Replace Common Patterns
81
+
82
+ ### Lookup Values
83
+
84
+ ```typescript
85
+ // Before:
86
+ var value = formContext.getAttribute("primarycontactid").getValue();
87
+ var id = value[0].id.replace("{","").replace("}","");
88
+
89
+ // After: use parseLookup from @xrmforge/typegen
90
+ import { parseLookup } from '@xrmforge/typegen';
91
+ const contact = parseLookup(form.getAttribute(Fields.PrimaryContactId));
92
+ if (contact) {
93
+ console.log(contact.id); // already clean GUID
94
+ }
95
+ ```
96
+
97
+ ### Web API Queries
98
+
99
+ ```typescript
100
+ // Before:
101
+ Xrm.WebApi.retrieveMultipleRecords("account",
102
+ "?$select=name,revenue&$filter=statecode eq 0");
103
+
104
+ // After: use Fields enum for $select
105
+ import { select } from '@xrmforge/typegen';
106
+ import { AccountFields } from '../../typings/entities/account';
107
+ Xrm.WebApi.retrieveMultipleRecords("account",
108
+ `?$select=${select(AccountFields.Name, AccountFields.Revenue)}&$filter=statecode eq 0`);
109
+ ```
110
+
111
+ ### Form Testing
112
+
113
+ ```typescript
114
+ // Before: no tests, or complex manual mocks
115
+
116
+ // After: @xrmforge/testing
117
+ import { createFormMock, fireOnChange } from '@xrmforge/testing';
118
+ import type { AccountMainForm, AccountMainFormMockValues } from '../../typings/forms/account';
119
+
120
+ const mock = createFormMock<AccountMainForm, AccountMainFormMockValues>({
121
+ name: 'Contoso Ltd',
122
+ revenue: 1000000,
123
+ statuscode: 1,
124
+ });
125
+
126
+ onLoad(mock.executionContext);
127
+ expect(mock.formContext.getControl('revenue').getVisible()).toBe(true);
128
+ ```
129
+
130
+ ## Step 5: Build
131
+
132
+ ```bash
133
+ npx xrmforge build # IIFE bundles for D365
134
+ npx xrmforge build --watch # Watch mode
135
+ ```
136
+
137
+ ## Step 6: Replace Magic Numbers
138
+
139
+ Search your code for patterns like:
140
+ - `getValue() === 123` or `getValue() !== 456`
141
+ - `setValue("statuscode", 1)`
142
+ - Raw OptionSet values in if/switch statements
143
+
144
+ Replace with generated const enums from `typings/optionsets/`.
145
+
146
+ ## Checklist
147
+
148
+ - [ ] All `getAttribute("string")` calls use Fields enum
149
+ - [ ] All OptionSet comparisons use const enums (no magic numbers)
150
+ - [ ] All `Xrm.Page` calls replaced with `formContext`
151
+ - [ ] Form scripts export functions (not global namespace objects)
152
+ - [ ] Each form script has tests using `@xrmforge/testing`
153
+ - [ ] `xrmforge build` produces IIFE bundles
154
+ - [ ] `tsc --noEmit` passes with zero errors
package/dist/index.d.ts CHANGED
@@ -330,16 +330,6 @@ declare class DataverseHttpClient {
330
330
  * @param signal - Optional AbortSignal to cancel the request
331
331
  */
332
332
  getAll<T>(path: string, signal?: AbortSignal): Promise<T[]>;
333
- /**
334
- * Execute a POST request that is semantically a read operation.
335
- * Used for Dataverse actions like RetrieveMetadataChanges that require POST
336
- * but do not modify data. Allowed even in read-only mode.
337
- *
338
- * @param path - API path (relative to apiUrl)
339
- * @param body - JSON body to send
340
- * @param signal - Optional AbortSignal to cancel the request
341
- */
342
- postReadOnly<T>(path: string, body: unknown, signal?: AbortSignal): Promise<T>;
343
333
  /**
344
334
  * Returns true if this client is in read-only mode (the safe default).
345
335
  */
package/dist/index.js CHANGED
@@ -404,28 +404,6 @@ var DataverseHttpClient = class {
404
404
  }
405
405
  return allResults;
406
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
- }
429
407
  // ─── Read-Only Enforcement ─────────────────────────────────────────────
430
408
  /**
431
409
  * Returns true if this client is in read-only mode (the safe default).
@@ -514,12 +492,14 @@ var DataverseHttpClient = class {
514
492
  try {
515
493
  tokenResponse = await this.credential.getToken(scope);
516
494
  } catch (error) {
495
+ const cause = error instanceof Error ? error.message : String(error);
517
496
  throw new AuthenticationError(
518
497
  "AUTH_1003" /* AUTH_TOKEN_FAILED */,
519
- `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}`,
520
500
  {
521
501
  environmentUrl: this.baseUrl,
522
- originalError: error instanceof Error ? error.message : String(error)
502
+ originalError: cause
523
503
  }
524
504
  );
525
505
  }
@@ -1421,26 +1401,22 @@ var ChangeDetector = class {
1421
1401
  */
1422
1402
  async detectChanges(clientVersionStamp) {
1423
1403
  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
- }
1404
+ const query = {
1405
+ Criteria: {
1406
+ FilterOperator: "And",
1407
+ Conditions: []
1434
1408
  },
1435
- ClientVersionStamp: clientVersionStamp,
1436
- DeletedMetadataFilters: "Entity"
1409
+ Properties: {
1410
+ AllProperties: false,
1411
+ PropertyNames: ["LogicalName"]
1412
+ }
1437
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}`;
1438
1417
  let response;
1439
1418
  try {
1440
- response = await this.http.postReadOnly(
1441
- "/RetrieveMetadataChanges",
1442
- requestBody
1443
- );
1419
+ response = await this.http.get(path2);
1444
1420
  } catch (error) {
1445
1421
  if (this.isExpiredVersionStampError(error)) {
1446
1422
  throw new MetadataError(
@@ -1475,22 +1451,19 @@ var ChangeDetector = class {
1475
1451
  */
1476
1452
  async getInitialVersionStamp() {
1477
1453
  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
- }
1454
+ const query = {
1455
+ Criteria: {
1456
+ FilterOperator: "And",
1457
+ Conditions: []
1458
+ },
1459
+ Properties: {
1460
+ AllProperties: false,
1461
+ PropertyNames: ["LogicalName"]
1488
1462
  }
1489
1463
  };
1490
- const response = await this.http.postReadOnly(
1491
- "/RetrieveMetadataChanges",
1492
- requestBody
1493
- );
1464
+ const queryParam = encodeURIComponent(JSON.stringify(query));
1465
+ const path2 = `/RetrieveMetadataChanges(Query=@q)?@q=${queryParam}`;
1466
+ const response = await this.http.get(path2);
1494
1467
  log6.info("Initial version stamp acquired");
1495
1468
  return response.ServerVersionStamp;
1496
1469
  }
@@ -2135,6 +2108,50 @@ function generateFormInterface(form, entityLogicalName, attributeMap, options =
2135
2108
  lines.push("");
2136
2109
  }
2137
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
+ }
2138
2155
  lines.push(` /** ${form.name} */`);
2139
2156
  lines.push(` interface ${interfaceName} extends Omit<Xrm.FormContext, 'getAttribute' | 'getControl'> {`);
2140
2157
  lines.push(` /** Typisierter Feldzugriff: nur Felder die auf diesem Formular existieren */`);
@@ -2144,7 +2161,6 @@ function generateFormInterface(form, entityLogicalName, attributeMap, options =
2144
2161
  lines.push("");
2145
2162
  lines.push(` /** Typisierter Control-Zugriff: nur Controls die auf diesem Formular existieren */`);
2146
2163
  lines.push(` getControl<K extends ${fieldsTypeName}>(name: K): ${ctrlMapName}[K];`);
2147
- const specialControls = form.allSpecialControls || [];
2148
2164
  for (const sc of specialControls) {
2149
2165
  const xrmType = specialControlToXrmType(sc.controlType);
2150
2166
  if (xrmType) {