dsh-d365-kit 0.34.1 → 0.34.4
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/CHANGELOG.md +319 -308
- package/CHANGELOG.zh-CN.md +318 -307
- package/INSTALL.md +94 -94
- package/INSTALL.zh-CN.md +93 -93
- package/LICENSE +21 -21
- package/README.md +336 -327
- package/README.zh-CN.md +336 -327
- package/client.js +1818 -1818
- package/cordis.patch.yml +44 -44
- package/docs/en/architecture.md +172 -172
- package/docs/en/checks.md +184 -184
- package/docs/en/installation.md +151 -151
- package/docs/en/permissions.md +161 -161
- package/docs/en/scenarios.md +202 -202
- package/docs/en/tools.md +665 -665
- package/docs/en/troubleshooting.md +131 -131
- package/docs/zh/architecture.md +172 -172
- package/docs/zh/checks.md +184 -184
- package/docs/zh/installation.md +151 -151
- package/docs/zh/permissions.md +161 -161
- package/docs/zh/scenarios.md +202 -202
- package/docs/zh/tools.md +665 -665
- package/docs/zh/troubleshooting.md +131 -131
- package/examples/prompts.en.md +206 -206
- package/examples/prompts.zh-CN.md +206 -206
- package/examples/waivers.example.json +20 -20
- package/icon.svg +6 -6
- package/index.js +139 -139
- package/lib/auth.js +181 -181
- package/lib/client.js +496 -496
- package/lib/code-check.js +549 -549
- package/lib/config.js +323 -323
- package/lib/contract-history.js +49 -49
- package/lib/contract.js +583 -583
- package/lib/csharp-check.js +493 -493
- package/lib/data-migrate.js +1318 -1318
- package/lib/data-policy.js +78 -78
- package/lib/error-codes.js +80 -80
- package/lib/gitlab.js +161 -161
- package/lib/health.js +205 -205
- package/lib/migrate.js +732 -732
- package/lib/preflight.js +116 -116
- package/lib/refs.js +191 -191
- package/lib/resources.js +320 -320
- package/lib/retention-defaults.js +17 -17
- package/lib/retention.js +384 -384
- package/lib/runtime.js +232 -232
- package/lib/settings-http.js +512 -512
- package/lib/settings-store.js +371 -371
- package/lib/snapshot.js +149 -149
- package/lib/tool-code-check.js +133 -133
- package/lib/tool-configure.js +169 -169
- package/lib/tool-contract.js +376 -376
- package/lib/tool-data-sync.js +559 -559
- package/lib/tool-data-write.js +203 -203
- package/lib/tool-data.js +282 -282
- package/lib/tool-diff.js +484 -484
- package/lib/tool-environments.js +106 -106
- package/lib/tool-flows.js +540 -540
- package/lib/tool-health.js +127 -127
- package/lib/tool-layers.js +497 -497
- package/lib/tool-metadata.js +417 -417
- package/lib/tool-migrate.js +102 -102
- package/lib/tool-refs.js +206 -206
- package/lib/tool-reports.js +299 -299
- package/lib/tool-sync.js +430 -430
- package/lib/tool-write.js +563 -563
- package/lib/waivers.js +83 -83
- package/lib/write-actions.js +1374 -1374
- package/lib/write-policy.js +199 -199
- package/lib/write-store.js +167 -167
- package/locale/en.json +68 -68
- package/locale/zh.json +68 -68
- package/package.json +2 -2
- 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
|
+
}
|