dsh-d365-kit 0.34.1 → 0.34.3

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.
Files changed (75) hide show
  1. package/CHANGELOG.md +318 -308
  2. package/CHANGELOG.zh-CN.md +317 -307
  3. package/INSTALL.md +94 -94
  4. package/INSTALL.zh-CN.md +93 -93
  5. package/LICENSE +21 -21
  6. package/README.md +336 -327
  7. package/README.zh-CN.md +336 -327
  8. package/client.js +1818 -1818
  9. package/cordis.patch.yml +44 -44
  10. package/docs/en/architecture.md +172 -172
  11. package/docs/en/checks.md +184 -184
  12. package/docs/en/installation.md +151 -151
  13. package/docs/en/permissions.md +161 -161
  14. package/docs/en/scenarios.md +202 -202
  15. package/docs/en/tools.md +665 -665
  16. package/docs/en/troubleshooting.md +131 -131
  17. package/docs/zh/architecture.md +172 -172
  18. package/docs/zh/checks.md +184 -184
  19. package/docs/zh/installation.md +151 -151
  20. package/docs/zh/permissions.md +161 -161
  21. package/docs/zh/scenarios.md +202 -202
  22. package/docs/zh/tools.md +665 -665
  23. package/docs/zh/troubleshooting.md +131 -131
  24. package/examples/prompts.en.md +206 -206
  25. package/examples/prompts.zh-CN.md +206 -206
  26. package/examples/waivers.example.json +20 -20
  27. package/icon.svg +6 -6
  28. package/index.js +139 -139
  29. package/lib/auth.js +181 -181
  30. package/lib/client.js +496 -496
  31. package/lib/code-check.js +549 -549
  32. package/lib/config.js +323 -323
  33. package/lib/contract-history.js +49 -49
  34. package/lib/contract.js +583 -583
  35. package/lib/csharp-check.js +493 -493
  36. package/lib/data-migrate.js +1318 -1318
  37. package/lib/data-policy.js +78 -78
  38. package/lib/error-codes.js +80 -80
  39. package/lib/gitlab.js +161 -161
  40. package/lib/health.js +205 -205
  41. package/lib/migrate.js +732 -732
  42. package/lib/preflight.js +116 -116
  43. package/lib/refs.js +191 -191
  44. package/lib/resources.js +320 -320
  45. package/lib/retention-defaults.js +17 -17
  46. package/lib/retention.js +384 -384
  47. package/lib/runtime.js +232 -232
  48. package/lib/settings-http.js +512 -512
  49. package/lib/settings-store.js +371 -371
  50. package/lib/snapshot.js +149 -149
  51. package/lib/tool-code-check.js +133 -133
  52. package/lib/tool-configure.js +169 -169
  53. package/lib/tool-contract.js +376 -376
  54. package/lib/tool-data-sync.js +559 -559
  55. package/lib/tool-data-write.js +203 -203
  56. package/lib/tool-data.js +282 -282
  57. package/lib/tool-diff.js +484 -484
  58. package/lib/tool-environments.js +106 -106
  59. package/lib/tool-flows.js +540 -540
  60. package/lib/tool-health.js +127 -127
  61. package/lib/tool-layers.js +497 -497
  62. package/lib/tool-metadata.js +417 -417
  63. package/lib/tool-migrate.js +102 -102
  64. package/lib/tool-refs.js +206 -206
  65. package/lib/tool-reports.js +299 -299
  66. package/lib/tool-sync.js +430 -430
  67. package/lib/tool-write.js +563 -563
  68. package/lib/waivers.js +83 -83
  69. package/lib/write-actions.js +1374 -1374
  70. package/lib/write-policy.js +199 -199
  71. package/lib/write-store.js +167 -167
  72. package/locale/en.json +68 -68
  73. package/locale/zh.json +68 -68
  74. package/package.json +2 -2
  75. package/skills/d365-troubleshooting/SKILL.md +115 -115
package/lib/resources.js CHANGED
@@ -1,320 +1,320 @@
1
- /**
2
- * The metadata resource catalogue.
3
- *
4
- * Each entry turns model-facing arguments into one Web API collection request.
5
- * `$select` lists are deliberately minimal-but-useful: a wrong property name
6
- * fails the whole query with 400, and the client has a fallback that retries
7
- * without `$select` rather than losing the call entirely.
8
- */
9
-
10
- /** Escape a value for an OData string literal (double the single quote). */
11
- export const odataString = (value) => String(value).replace(/'/g, "''")
12
-
13
- /** Entity-scoped resources need `entity`; the tool reports this before calling. */
14
- const entityFilter = {
15
- forms: (entity) => `objecttypecode eq '${odataString(entity)}'`,
16
- views: (entity) => `returnedtypecode eq '${odataString(entity)}'`,
17
- workflows: (entity) => `primaryentity eq '${odataString(entity)}'`,
18
- business_rules: (entity) => `primaryentity eq '${odataString(entity)}'`,
19
- attributes: null,
20
- keys: null,
21
- one_to_many_relationships: null,
22
- many_to_many_relationships: null,
23
- }
24
-
25
- export const RESOURCES = {
26
- organization: {
27
- summary: '组织信息:返回该环境实际暴露的组织属性(各环境属性集不同),版本号另走 RetrieveVersion',
28
- path: () => 'organizations',
29
- // No $select on purpose: deployments differ in which organization
30
- // properties exist, and naming a missing one fails the whole query.
31
- },
32
-
33
- entities: {
34
- summary: '全部实体(表)定义:逻辑名、架构名、集合名、是否自定义',
35
- path: () => 'EntityDefinitions',
36
- select: 'LogicalName,SchemaName,EntitySetName,ObjectTypeCode,IsCustomEntity,IsManaged,PrimaryIdAttribute,PrimaryNameAttribute',
37
- // No $orderby: the metadata endpoint rejects it (`$orderby` returns
38
- // 0x80060888 "The query parameter ... is not supported").
39
- },
40
-
41
- attributes: {
42
- summary: '某个实体的全部字段(属性)定义',
43
- entityScoped: true,
44
- path: (args) => `EntityDefinitions(LogicalName='${odataString(args.entity)}')/Attributes`,
45
- select: 'LogicalName,SchemaName,AttributeType,AttributeTypeName,IsCustomAttribute,RequiredLevel,IsPrimaryId,IsValidForCreate,IsValidForUpdate,IsValidForRead',
46
- // Typed columns (DefaultValue, OptionSet, Targets, Format ...) only exist on
47
- // the derived metadata type, so reading one of them means casting the keyed
48
- // attribute to e.g. Microsoft.Dynamics.CRM.BooleanAttributeMetadata.
49
- keyedByAttribute: (args) => `EntityDefinitions(LogicalName='${odataString(args.entity)}')/Attributes(LogicalName='${odataString(args.attribute)}')${args.cast ? `/Microsoft.Dynamics.CRM.${args.cast}` : ''}`,
50
- defaultTop: 1000,
51
- },
52
-
53
- keys: {
54
- summary: '某个实体的备用键定义',
55
- entityScoped: true,
56
- path: (args) => `EntityDefinitions(LogicalName='${odataString(args.entity)}')/Keys`,
57
- },
58
-
59
- one_to_many_relationships: {
60
- summary: '某个实体的一对多关系',
61
- entityScoped: true,
62
- path: (args) => `EntityDefinitions(LogicalName='${odataString(args.entity)}')/OneToManyRelationships`,
63
- },
64
-
65
- many_to_many_relationships: {
66
- summary: '某个实体的多对多关系',
67
- entityScoped: true,
68
- path: (args) => `EntityDefinitions(LogicalName='${odataString(args.entity)}')/ManyToManyRelationships`,
69
- },
70
-
71
- forms: {
72
- summary: '系统表单定义(不含 formxml 正文,需要时用 select 显式取)',
73
- entityFilter: entityFilter.forms,
74
- path: () => 'systemforms',
75
- select: 'formid,name,type,objecttypecode,description,ismanaged,modifiedon',
76
- orderby: 'name asc',
77
- },
78
-
79
- views: {
80
- summary: '视图(savedquery):名称、类型、是否默认',
81
- entityFilter: entityFilter.views,
82
- path: () => 'savedqueries',
83
- select: 'savedqueryid,name,querytype,returnedtypecode,isdefault,ismanaged,modifiedon',
84
- orderby: 'name asc',
85
- },
86
-
87
- workflows: {
88
- summary: '工作流 / 流程(含业务规则;按 category 区分)',
89
- entityFilter: entityFilter.workflows,
90
- path: () => 'workflows',
91
- // Dataverse stores every process twice: a definition row (type 1) and an
92
- // activation row (type 2) with its own workflowid and the same name.
93
- // Listing both makes each process look duplicated and doubles the count,
94
- // so the definition row is the default. Pass `filter` to see activation
95
- // rows instead (e.g. "type eq 2") — an explicit filter replaces this one.
96
- defaultFilter: 'type eq 1',
97
- select: 'workflowid,name,category,type,statecode,statuscode,primaryentity,createdon,modifiedon',
98
- orderby: 'name asc',
99
- },
100
-
101
- business_rules: {
102
- summary: '业务规则(workflows 中 category = 2 的子集)',
103
- entityFilter: entityFilter.business_rules,
104
- path: () => 'workflows',
105
- filter: 'category eq 2',
106
- defaultFilter: 'type eq 1',
107
- select: 'workflowid,name,category,type,statecode,statuscode,primaryentity,modifiedon',
108
- orderby: 'name asc',
109
- },
110
-
111
- plugin_assemblies: {
112
- summary: '插件程序集:名称、版本、部署方式',
113
- path: () => 'pluginassemblies',
114
- select: 'pluginassemblyid,name,version,culture,publickeytoken,isolationmode,sourcetype,ismanaged',
115
- orderby: 'name asc',
116
- },
117
-
118
- plugin_types: {
119
- summary: '插件类型(类):完整类型名与所属程序集',
120
- path: () => 'plugintypes',
121
- select: 'plugintypeid,typename,pluginassemblyid,isworkflowactivity,friendlyname',
122
- defaultTop: 1000,
123
- orderby: 'typename asc',
124
- },
125
-
126
- plugin_steps: {
127
- summary: '插件步骤(SDK 消息处理步骤):消息、阶段、模式、过滤属性',
128
- path: () => 'sdkmessageprocessingsteps',
129
- select: 'sdkmessageprocessingstepid,name,stage,mode,rank,statecode,filteringattributes,configuration,plugintypeid,sdkmessageid',
130
- // 5000+ steps is normal on a mature organization, and the default of 200
131
- // sorted by name means the step being looked for is usually not in the page.
132
- defaultTop: 1000,
133
- expand: 'plugintypeid($select=typename),sdkmessageid($select=name)',
134
- orderby: 'name asc',
135
- },
136
-
137
- step_images: {
138
- summary: '插件步骤注册的镜像(Step Image):镜像类型、别名与参与镜像的字段',
139
- path: () => 'sdkmessageprocessingstepimages',
140
- select: 'sdkmessageprocessingstepimageid,name,imagetype,messagepropertyname,entityalias,attributes,ismanaged,sdkmessageprocessingstepid',
141
- defaultTop: 1000,
142
- orderby: 'name asc',
143
- },
144
-
145
- web_resources: {
146
- summary: 'Web 资源清单(默认不含 content 正文)',
147
- path: () => 'webresourceset',
148
- select: 'webresourceid,name,displayname,webresourcetype,ismanaged,modifiedon',
149
- orderby: 'name asc',
150
- },
151
-
152
- solutions: {
153
- summary: '解决方案清单:唯一名、版本、是否托管、安装时间',
154
- path: () => 'solutions',
155
- select: 'solutionid,uniquename,friendlyname,version,ismanaged,installedon,isvisible,publisherid',
156
- orderby: 'uniquename asc',
157
- },
158
-
159
- solution_components: {
160
- summary: '解决方案分量:哪个组件属于哪个解决方案(判断是否纳管的依据)',
161
- path: () => 'solutioncomponents',
162
- // Lookup columns come back as `_solutionid_value`, not `solutionid`.
163
- select: 'solutioncomponentid,componenttype,objectid,_solutionid_value,rootcomponentbehavior',
164
- },
165
-
166
- custom_apis: {
167
- summary: '自定义 API 定义',
168
- path: () => 'customapis',
169
- select: 'customapiid,uniquename,name,displayname,boundentitylogicalname,isfunction,isprivate',
170
- orderby: 'uniquename asc',
171
- },
172
-
173
- sitemaps: {
174
- summary: '站点地图(默认不含 sitemapxml 正文)',
175
- path: () => 'sitemaps',
176
- select: 'sitemapid,sitemapname,componentstate,ismanaged',
177
- },
178
-
179
- option_sets: {
180
- summary: '全局选项集定义',
181
- path: () => 'GlobalOptionSetDefinitions',
182
- select: 'Name,DisplayName,IsCustomOptionSet,OptionSetType,IsGlobal',
183
- // The option-set endpoint is a metadata collection: it rejects $filter
184
- // outright (HTTP 405, "The query parameter $filter is not supported") and
185
- // has no record id to page through, so a name is resolved with a keyed read
186
- // instead — one request instead of pulling all 987 definitions.
187
- filterable: false,
188
- keyedByName: (name) => `GlobalOptionSetDefinitions(Name='${odataString(name)}')`,
189
- },
190
- }
191
-
192
- /** Model-facing enum of resource keys. */
193
- export const RESOURCE_KEYS = Object.keys(RESOURCES)
194
-
195
- /**
196
- * Property that identifies a row of each resource. Used to align rows across
197
- * environments and to give a stable, human-recognisable label in summaries.
198
- */
199
- export const KEY_FIELDS = {
200
- organization: 'organizationid',
201
- entities: 'LogicalName',
202
- attributes: 'LogicalName',
203
- keys: 'SchemaName',
204
- one_to_many_relationships: 'SchemaName',
205
- many_to_many_relationships: 'SchemaName',
206
- forms: 'name',
207
- views: 'name',
208
- workflows: 'name',
209
- business_rules: 'name',
210
- plugin_assemblies: 'name',
211
- plugin_types: 'typename',
212
- plugin_steps: 'name',
213
- step_images: 'name',
214
- web_resources: 'name',
215
- solutions: 'uniquename',
216
- solution_components: ['_solutionid_value', 'componenttype', 'objectid'],
217
- custom_apis: 'uniquename',
218
- sitemaps: 'sitemapname',
219
- option_sets: 'Name',
220
- }
221
-
222
- /**
223
- * Properties worth showing in a cross-environment comparison, on top of
224
- * presence. Empty means "presence is the whole story".
225
- */
226
- export const COMPARE_FIELDS = {
227
- solutions: ['version', 'ismanaged'],
228
- plugin_assemblies: ['version'],
229
- entities: ['IsCustomEntity', 'EntitySetName'],
230
- attributes: ['AttributeType', 'IsCustomAttribute'],
231
- }
232
-
233
- /** Best-effort identity for a row without knowing the resource. */
234
- export function rowKey(resource, row, fields) {
235
- const spec = fields ?? KEY_FIELDS[resource]
236
- const list = Array.isArray(spec) ? spec : spec ? [spec] : []
237
- if (list.length) {
238
- const values = list.map((field) => {
239
- if (row?.[field] !== undefined && row?.[field] !== null && row[field] !== '') return row[field]
240
- // Lookup columns arrive as `_solutionid_value`; older snapshots stored the
241
- // bare name, so accept either spelling for the same key.
242
- const annotated = field.startsWith('_') ? field.replace(/^_/, '').replace(/_value$/, '') : `_${field}_value`
243
- return row?.[annotated]
244
- })
245
- if (values.every((value) => value !== undefined && value !== null && value !== '')) {
246
- return values.join('|')
247
- }
248
- }
249
- return row?.name ?? row?.Name ?? row?.logicalname ?? row?.['@odata.id'] ?? JSON.stringify(row).slice(0, 60)
250
- }
251
-
252
- /**
253
- * Identity fields used when diffing snapshots. Attributes are only unique
254
- * within their entity, and a solution component is identified by the triple
255
- * (solution, type, object), not by `objectid` alone.
256
- */
257
- export const DIFF_KEY_FIELDS = {
258
- attributes: ['entity', 'LogicalName'],
259
- solution_components: ['_solutionid_value', 'componenttype', 'objectid'],
260
- }
261
-
262
- /** Fields whose change is worth calling out first in a diff. */
263
- export const HIGHLIGHT_FIELDS = {
264
- entities: ['EntitySetName', 'IsCustomEntity', 'IsManaged'],
265
- attributes: ['AttributeType', 'IsCustomAttribute', 'RequiredLevel'],
266
- forms: ['type', 'ismanaged'],
267
- views: ['querytype', 'isdefault'],
268
- workflows: ['category', 'statecode', 'statuscode'],
269
- plugin_assemblies: ['version'],
270
- plugin_steps: ['stage', 'mode', 'filteringattributes'],
271
- step_images: ['imagetype', 'attributes'],
272
- solutions: ['version', 'ismanaged'],
273
- web_resources: ['webresourcetype'],
274
- organization: ['version', 'organizationstructure'],
275
- }
276
-
277
- /**
278
- * Compose the final `$filter` from the resource's entity filter, its default
279
- * filter, and the caller's own expression.
280
- *
281
- * `defaultFilter` is a de-duplication guard (workflows are stored twice), so an
282
- * explicit `args.filter` replaces it rather than ANDing with it — otherwise
283
- * asking for "type eq 2" would compose into `type eq 1 and type eq 2`, i.e. an
284
- * empty result with no explanation.
285
- * @returns the filter string, or undefined.
286
- */
287
- export function composeFilter(definition, args) {
288
- const parts = []
289
- if (definition.filter) parts.push(`(${definition.filter})`)
290
- if (definition.defaultFilter && !args.filter) parts.push(`(${definition.defaultFilter})`)
291
- if (definition.entityFilter && args.entity) parts.push(`(${definition.entityFilter(args.entity)})`)
292
- if (args.filter) parts.push(`(${args.filter})`)
293
- return parts.length ? parts.join(' and ') : undefined
294
- }
295
-
296
- /**
297
- * Collapse a Dataverse Label object into a plain string, preferring zh-CN then
298
- * the user's label then the first available. Recurses through arrays/objects
299
- * and drops `@odata.*` annotations.
300
- * @param value - any decoded JSON value.
301
- */
302
- export function flattenLabels(value) {
303
- if (Array.isArray(value)) return value.map((item) => flattenLabels(item))
304
- if (value && typeof value === 'object') {
305
- if (Array.isArray(value.LocalizedLabels)) {
306
- const labels = value.LocalizedLabels
307
- const preferred = labels.find((item) => item?.LanguageCode === 2052)
308
- ?? labels.find((item) => item?.IsDefaultLanguage)
309
- ?? labels[0]
310
- return preferred?.Label ?? ''
311
- }
312
- const result = {}
313
- for (const [key, item] of Object.entries(value)) {
314
- if (key.startsWith('@odata.')) continue
315
- result[key] = flattenLabels(item)
316
- }
317
- return result
318
- }
319
- return value
320
- }
1
+ /**
2
+ * The metadata resource catalogue.
3
+ *
4
+ * Each entry turns model-facing arguments into one Web API collection request.
5
+ * `$select` lists are deliberately minimal-but-useful: a wrong property name
6
+ * fails the whole query with 400, and the client has a fallback that retries
7
+ * without `$select` rather than losing the call entirely.
8
+ */
9
+
10
+ /** Escape a value for an OData string literal (double the single quote). */
11
+ export const odataString = (value) => String(value).replace(/'/g, "''")
12
+
13
+ /** Entity-scoped resources need `entity`; the tool reports this before calling. */
14
+ const entityFilter = {
15
+ forms: (entity) => `objecttypecode eq '${odataString(entity)}'`,
16
+ views: (entity) => `returnedtypecode eq '${odataString(entity)}'`,
17
+ workflows: (entity) => `primaryentity eq '${odataString(entity)}'`,
18
+ business_rules: (entity) => `primaryentity eq '${odataString(entity)}'`,
19
+ attributes: null,
20
+ keys: null,
21
+ one_to_many_relationships: null,
22
+ many_to_many_relationships: null,
23
+ }
24
+
25
+ export const RESOURCES = {
26
+ organization: {
27
+ summary: '组织信息:返回该环境实际暴露的组织属性(各环境属性集不同),版本号另走 RetrieveVersion',
28
+ path: () => 'organizations',
29
+ // No $select on purpose: deployments differ in which organization
30
+ // properties exist, and naming a missing one fails the whole query.
31
+ },
32
+
33
+ entities: {
34
+ summary: '全部实体(表)定义:逻辑名、架构名、集合名、是否自定义',
35
+ path: () => 'EntityDefinitions',
36
+ select: 'LogicalName,SchemaName,EntitySetName,ObjectTypeCode,IsCustomEntity,IsManaged,PrimaryIdAttribute,PrimaryNameAttribute',
37
+ // No $orderby: the metadata endpoint rejects it (`$orderby` returns
38
+ // 0x80060888 "The query parameter ... is not supported").
39
+ },
40
+
41
+ attributes: {
42
+ summary: '某个实体的全部字段(属性)定义',
43
+ entityScoped: true,
44
+ path: (args) => `EntityDefinitions(LogicalName='${odataString(args.entity)}')/Attributes`,
45
+ select: 'LogicalName,SchemaName,AttributeType,AttributeTypeName,IsCustomAttribute,RequiredLevel,IsPrimaryId,IsValidForCreate,IsValidForUpdate,IsValidForRead',
46
+ // Typed columns (DefaultValue, OptionSet, Targets, Format ...) only exist on
47
+ // the derived metadata type, so reading one of them means casting the keyed
48
+ // attribute to e.g. Microsoft.Dynamics.CRM.BooleanAttributeMetadata.
49
+ keyedByAttribute: (args) => `EntityDefinitions(LogicalName='${odataString(args.entity)}')/Attributes(LogicalName='${odataString(args.attribute)}')${args.cast ? `/Microsoft.Dynamics.CRM.${args.cast}` : ''}`,
50
+ defaultTop: 1000,
51
+ },
52
+
53
+ keys: {
54
+ summary: '某个实体的备用键定义',
55
+ entityScoped: true,
56
+ path: (args) => `EntityDefinitions(LogicalName='${odataString(args.entity)}')/Keys`,
57
+ },
58
+
59
+ one_to_many_relationships: {
60
+ summary: '某个实体的一对多关系',
61
+ entityScoped: true,
62
+ path: (args) => `EntityDefinitions(LogicalName='${odataString(args.entity)}')/OneToManyRelationships`,
63
+ },
64
+
65
+ many_to_many_relationships: {
66
+ summary: '某个实体的多对多关系',
67
+ entityScoped: true,
68
+ path: (args) => `EntityDefinitions(LogicalName='${odataString(args.entity)}')/ManyToManyRelationships`,
69
+ },
70
+
71
+ forms: {
72
+ summary: '系统表单定义(不含 formxml 正文,需要时用 select 显式取)',
73
+ entityFilter: entityFilter.forms,
74
+ path: () => 'systemforms',
75
+ select: 'formid,name,type,objecttypecode,description,ismanaged,modifiedon',
76
+ orderby: 'name asc',
77
+ },
78
+
79
+ views: {
80
+ summary: '视图(savedquery):名称、类型、是否默认',
81
+ entityFilter: entityFilter.views,
82
+ path: () => 'savedqueries',
83
+ select: 'savedqueryid,name,querytype,returnedtypecode,isdefault,ismanaged,modifiedon',
84
+ orderby: 'name asc',
85
+ },
86
+
87
+ workflows: {
88
+ summary: '工作流 / 流程(含业务规则;按 category 区分)',
89
+ entityFilter: entityFilter.workflows,
90
+ path: () => 'workflows',
91
+ // Dataverse stores every process twice: a definition row (type 1) and an
92
+ // activation row (type 2) with its own workflowid and the same name.
93
+ // Listing both makes each process look duplicated and doubles the count,
94
+ // so the definition row is the default. Pass `filter` to see activation
95
+ // rows instead (e.g. "type eq 2") — an explicit filter replaces this one.
96
+ defaultFilter: 'type eq 1',
97
+ select: 'workflowid,name,category,type,statecode,statuscode,primaryentity,createdon,modifiedon',
98
+ orderby: 'name asc',
99
+ },
100
+
101
+ business_rules: {
102
+ summary: '业务规则(workflows 中 category = 2 的子集)',
103
+ entityFilter: entityFilter.business_rules,
104
+ path: () => 'workflows',
105
+ filter: 'category eq 2',
106
+ defaultFilter: 'type eq 1',
107
+ select: 'workflowid,name,category,type,statecode,statuscode,primaryentity,modifiedon',
108
+ orderby: 'name asc',
109
+ },
110
+
111
+ plugin_assemblies: {
112
+ summary: '插件程序集:名称、版本、部署方式',
113
+ path: () => 'pluginassemblies',
114
+ select: 'pluginassemblyid,name,version,culture,publickeytoken,isolationmode,sourcetype,ismanaged',
115
+ orderby: 'name asc',
116
+ },
117
+
118
+ plugin_types: {
119
+ summary: '插件类型(类):完整类型名与所属程序集',
120
+ path: () => 'plugintypes',
121
+ select: 'plugintypeid,typename,pluginassemblyid,isworkflowactivity,friendlyname',
122
+ defaultTop: 1000,
123
+ orderby: 'typename asc',
124
+ },
125
+
126
+ plugin_steps: {
127
+ summary: '插件步骤(SDK 消息处理步骤):消息、阶段、模式、过滤属性',
128
+ path: () => 'sdkmessageprocessingsteps',
129
+ select: 'sdkmessageprocessingstepid,name,stage,mode,rank,statecode,filteringattributes,configuration,plugintypeid,sdkmessageid',
130
+ // 5000+ steps is normal on a mature organization, and the default of 200
131
+ // sorted by name means the step being looked for is usually not in the page.
132
+ defaultTop: 1000,
133
+ expand: 'plugintypeid($select=typename),sdkmessageid($select=name)',
134
+ orderby: 'name asc',
135
+ },
136
+
137
+ step_images: {
138
+ summary: '插件步骤注册的镜像(Step Image):镜像类型、别名与参与镜像的字段',
139
+ path: () => 'sdkmessageprocessingstepimages',
140
+ select: 'sdkmessageprocessingstepimageid,name,imagetype,messagepropertyname,entityalias,attributes,ismanaged,sdkmessageprocessingstepid',
141
+ defaultTop: 1000,
142
+ orderby: 'name asc',
143
+ },
144
+
145
+ web_resources: {
146
+ summary: 'Web 资源清单(默认不含 content 正文)',
147
+ path: () => 'webresourceset',
148
+ select: 'webresourceid,name,displayname,webresourcetype,ismanaged,modifiedon',
149
+ orderby: 'name asc',
150
+ },
151
+
152
+ solutions: {
153
+ summary: '解决方案清单:唯一名、版本、是否托管、安装时间',
154
+ path: () => 'solutions',
155
+ select: 'solutionid,uniquename,friendlyname,version,ismanaged,installedon,isvisible,publisherid',
156
+ orderby: 'uniquename asc',
157
+ },
158
+
159
+ solution_components: {
160
+ summary: '解决方案分量:哪个组件属于哪个解决方案(判断是否纳管的依据)',
161
+ path: () => 'solutioncomponents',
162
+ // Lookup columns come back as `_solutionid_value`, not `solutionid`.
163
+ select: 'solutioncomponentid,componenttype,objectid,_solutionid_value,rootcomponentbehavior',
164
+ },
165
+
166
+ custom_apis: {
167
+ summary: '自定义 API 定义',
168
+ path: () => 'customapis',
169
+ select: 'customapiid,uniquename,name,displayname,boundentitylogicalname,isfunction,isprivate',
170
+ orderby: 'uniquename asc',
171
+ },
172
+
173
+ sitemaps: {
174
+ summary: '站点地图(默认不含 sitemapxml 正文)',
175
+ path: () => 'sitemaps',
176
+ select: 'sitemapid,sitemapname,componentstate,ismanaged',
177
+ },
178
+
179
+ option_sets: {
180
+ summary: '全局选项集定义',
181
+ path: () => 'GlobalOptionSetDefinitions',
182
+ select: 'Name,DisplayName,IsCustomOptionSet,OptionSetType,IsGlobal',
183
+ // The option-set endpoint is a metadata collection: it rejects $filter
184
+ // outright (HTTP 405, "The query parameter $filter is not supported") and
185
+ // has no record id to page through, so a name is resolved with a keyed read
186
+ // instead — one request instead of pulling all 987 definitions.
187
+ filterable: false,
188
+ keyedByName: (name) => `GlobalOptionSetDefinitions(Name='${odataString(name)}')`,
189
+ },
190
+ }
191
+
192
+ /** Model-facing enum of resource keys. */
193
+ export const RESOURCE_KEYS = Object.keys(RESOURCES)
194
+
195
+ /**
196
+ * Property that identifies a row of each resource. Used to align rows across
197
+ * environments and to give a stable, human-recognisable label in summaries.
198
+ */
199
+ export const KEY_FIELDS = {
200
+ organization: 'organizationid',
201
+ entities: 'LogicalName',
202
+ attributes: 'LogicalName',
203
+ keys: 'SchemaName',
204
+ one_to_many_relationships: 'SchemaName',
205
+ many_to_many_relationships: 'SchemaName',
206
+ forms: 'name',
207
+ views: 'name',
208
+ workflows: 'name',
209
+ business_rules: 'name',
210
+ plugin_assemblies: 'name',
211
+ plugin_types: 'typename',
212
+ plugin_steps: 'name',
213
+ step_images: 'name',
214
+ web_resources: 'name',
215
+ solutions: 'uniquename',
216
+ solution_components: ['_solutionid_value', 'componenttype', 'objectid'],
217
+ custom_apis: 'uniquename',
218
+ sitemaps: 'sitemapname',
219
+ option_sets: 'Name',
220
+ }
221
+
222
+ /**
223
+ * Properties worth showing in a cross-environment comparison, on top of
224
+ * presence. Empty means "presence is the whole story".
225
+ */
226
+ export const COMPARE_FIELDS = {
227
+ solutions: ['version', 'ismanaged'],
228
+ plugin_assemblies: ['version'],
229
+ entities: ['IsCustomEntity', 'EntitySetName'],
230
+ attributes: ['AttributeType', 'IsCustomAttribute'],
231
+ }
232
+
233
+ /** Best-effort identity for a row without knowing the resource. */
234
+ export function rowKey(resource, row, fields) {
235
+ const spec = fields ?? KEY_FIELDS[resource]
236
+ const list = Array.isArray(spec) ? spec : spec ? [spec] : []
237
+ if (list.length) {
238
+ const values = list.map((field) => {
239
+ if (row?.[field] !== undefined && row?.[field] !== null && row[field] !== '') return row[field]
240
+ // Lookup columns arrive as `_solutionid_value`; older snapshots stored the
241
+ // bare name, so accept either spelling for the same key.
242
+ const annotated = field.startsWith('_') ? field.replace(/^_/, '').replace(/_value$/, '') : `_${field}_value`
243
+ return row?.[annotated]
244
+ })
245
+ if (values.every((value) => value !== undefined && value !== null && value !== '')) {
246
+ return values.join('|')
247
+ }
248
+ }
249
+ return row?.name ?? row?.Name ?? row?.logicalname ?? row?.['@odata.id'] ?? JSON.stringify(row).slice(0, 60)
250
+ }
251
+
252
+ /**
253
+ * Identity fields used when diffing snapshots. Attributes are only unique
254
+ * within their entity, and a solution component is identified by the triple
255
+ * (solution, type, object), not by `objectid` alone.
256
+ */
257
+ export const DIFF_KEY_FIELDS = {
258
+ attributes: ['entity', 'LogicalName'],
259
+ solution_components: ['_solutionid_value', 'componenttype', 'objectid'],
260
+ }
261
+
262
+ /** Fields whose change is worth calling out first in a diff. */
263
+ export const HIGHLIGHT_FIELDS = {
264
+ entities: ['EntitySetName', 'IsCustomEntity', 'IsManaged'],
265
+ attributes: ['AttributeType', 'IsCustomAttribute', 'RequiredLevel'],
266
+ forms: ['type', 'ismanaged'],
267
+ views: ['querytype', 'isdefault'],
268
+ workflows: ['category', 'statecode', 'statuscode'],
269
+ plugin_assemblies: ['version'],
270
+ plugin_steps: ['stage', 'mode', 'filteringattributes'],
271
+ step_images: ['imagetype', 'attributes'],
272
+ solutions: ['version', 'ismanaged'],
273
+ web_resources: ['webresourcetype'],
274
+ organization: ['version', 'organizationstructure'],
275
+ }
276
+
277
+ /**
278
+ * Compose the final `$filter` from the resource's entity filter, its default
279
+ * filter, and the caller's own expression.
280
+ *
281
+ * `defaultFilter` is a de-duplication guard (workflows are stored twice), so an
282
+ * explicit `args.filter` replaces it rather than ANDing with it — otherwise
283
+ * asking for "type eq 2" would compose into `type eq 1 and type eq 2`, i.e. an
284
+ * empty result with no explanation.
285
+ * @returns the filter string, or undefined.
286
+ */
287
+ export function composeFilter(definition, args) {
288
+ const parts = []
289
+ if (definition.filter) parts.push(`(${definition.filter})`)
290
+ if (definition.defaultFilter && !args.filter) parts.push(`(${definition.defaultFilter})`)
291
+ if (definition.entityFilter && args.entity) parts.push(`(${definition.entityFilter(args.entity)})`)
292
+ if (args.filter) parts.push(`(${args.filter})`)
293
+ return parts.length ? parts.join(' and ') : undefined
294
+ }
295
+
296
+ /**
297
+ * Collapse a Dataverse Label object into a plain string, preferring zh-CN then
298
+ * the user's label then the first available. Recurses through arrays/objects
299
+ * and drops `@odata.*` annotations.
300
+ * @param value - any decoded JSON value.
301
+ */
302
+ export function flattenLabels(value) {
303
+ if (Array.isArray(value)) return value.map((item) => flattenLabels(item))
304
+ if (value && typeof value === 'object') {
305
+ if (Array.isArray(value.LocalizedLabels)) {
306
+ const labels = value.LocalizedLabels
307
+ const preferred = labels.find((item) => item?.LanguageCode === 2052)
308
+ ?? labels.find((item) => item?.IsDefaultLanguage)
309
+ ?? labels[0]
310
+ return preferred?.Label ?? ''
311
+ }
312
+ const result = {}
313
+ for (const [key, item] of Object.entries(value)) {
314
+ if (key.startsWith('@odata.')) continue
315
+ result[key] = flattenLabels(item)
316
+ }
317
+ return result
318
+ }
319
+ return value
320
+ }