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.
Files changed (75) hide show
  1. package/CHANGELOG.md +319 -308
  2. package/CHANGELOG.zh-CN.md +318 -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/preflight.js CHANGED
@@ -1,116 +1,116 @@
1
- /**
2
- * Is the target environment in the middle of a solution import or publish?
3
- *
4
- * Microsoft ships first-party updates as solution imports, and while one is
5
- * running Dataverse holds the customization lock: our metadata writes either
6
- * fail with 0x80071151 ("another operation is in progress") or hang until the
7
- * HTTP client gives up. `msdyn_solutionhistories` records every import,
8
- * uninstall, export and publish with a start time; a row whose `msdyn_endtime`
9
- * is still null means that operation has not finished.
10
- */
11
- import { D365Error } from './errors.js'
12
-
13
- /** Operations older than this are treated as zombie rows, not as active work. */
14
- export const DEFAULT_BUSY_WINDOW_MINUTES = 120
15
-
16
- const OPERATION_LABELS = {
17
- 0: '导入',
18
- 1: '卸载',
19
- 2: '导出',
20
- 3: '发布',
21
- 7: '元数据生成',
22
- }
23
-
24
- const SELECT_FIELDS = 'msdyn_name,msdyn_starttime,msdyn_operation,msdyn_ismanaged,msdyn_publishername'
25
-
26
- export function describeBlocker(blocker) {
27
- const parts = [blocker.name]
28
- parts.push(blocker.operation)
29
- if (blocker.ageMinutes !== null) parts.push(`${blocker.ageMinutes} 分钟前开始`)
30
- if (blocker.publisher) parts.push(`发行者 ${blocker.publisher}`)
31
- return `${parts[0]}(${parts.slice(1).join(',')})`
32
- }
33
-
34
- /**
35
- * @returns {Promise<{
36
- * available: boolean, checkedAt: string, windowMinutes: number, reason?: string,
37
- * blockers: object[], active: object[], stale: object[], busy: boolean,
38
- * }>}
39
- */
40
- export async function checkEnvironmentIdle(client, { windowMinutes = DEFAULT_BUSY_WINDOW_MINUTES, signal } = {}) {
41
- const checkedAt = new Date().toISOString()
42
- const url = client.buildUrl('msdyn_solutionhistories', {
43
- $select: SELECT_FIELDS,
44
- $filter: 'msdyn_endtime eq null',
45
- $orderby: 'msdyn_starttime desc',
46
- $top: 25,
47
- })
48
-
49
- let rows
50
- try {
51
- const page = await client.requestJson(url, { signal })
52
- rows = Array.isArray(page?.value) ? page.value : []
53
- } catch (error) {
54
- // No privilege on the table, or the environment does not expose it: the
55
- // check is a safety net, not a gate we can enforce without data.
56
- return {
57
- available: false,
58
- reason: error instanceof Error ? error.message : String(error),
59
- checkedAt,
60
- windowMinutes,
61
- blockers: [],
62
- active: [],
63
- stale: [],
64
- busy: false,
65
- }
66
- }
67
-
68
- const now = Date.now()
69
- const blockers = rows.map((row) => {
70
- const startedAt = row.msdyn_starttime ?? null
71
- const started = startedAt ? Date.parse(startedAt) : Number.NaN
72
- return {
73
- name: row.msdyn_name ?? '(未命名)',
74
- operation: OPERATION_LABELS[row.msdyn_operation] ?? `操作 ${row.msdyn_operation}`,
75
- managed: row.msdyn_ismanaged === true,
76
- publisher: row.msdyn_publishername ?? '',
77
- startedAt,
78
- ageMinutes: Number.isFinite(started) ? Math.max(0, Math.round((now - started) / 60000)) : null,
79
- }
80
- })
81
-
82
- const active = blockers.filter((blocker) => blocker.ageMinutes !== null && blocker.ageMinutes <= windowMinutes)
83
- const stale = blockers.filter((blocker) => !active.includes(blocker))
84
-
85
- return {
86
- available: true,
87
- checkedAt,
88
- windowMinutes,
89
- blockers,
90
- active,
91
- stale,
92
- busy: active.length > 0,
93
- }
94
- }
95
-
96
- export function busyError(envKey, state) {
97
- // One solution import shows up as many rows (per patch step), and a publish
98
- // shows up repeatedly: collapse to one line per name, newest first.
99
- const seen = new Map()
100
- for (const blocker of state.active) {
101
- const previous = seen.get(blocker.name)
102
- if (!previous || (blocker.ageMinutes ?? Infinity) < (previous.ageMinutes ?? Infinity)) {
103
- seen.set(blocker.name, blocker)
104
- }
105
- }
106
- const collapsed = [...seen.values()].sort((a, b) => (a.ageMinutes ?? 0) - (b.ageMinutes ?? 0))
107
- const shown = collapsed.slice(0, 4).map(describeBlocker).join(';')
108
- const more = collapsed.length > 4 ? `;等 ${collapsed.length} 个操作` : ''
109
- const staleNote = state.stale.length
110
- ? ` 另有 ${state.stale.length} 条超过 ${state.windowMinutes} 分钟仍未结束的旧记录,可能是残留。`
111
- : ''
112
- return new D365Error(`环境 ${envKey} 正在导入或发布解决方案,此时写入会失败或长时间卡住`, {
113
- code: 'environment-busy',
114
- hint: `进行中:${shown}${more}。${staleNote} 等它结束后重试;确认要跳过检查可以传 force: true。`,
115
- })
116
- }
1
+ /**
2
+ * Is the target environment in the middle of a solution import or publish?
3
+ *
4
+ * Microsoft ships first-party updates as solution imports, and while one is
5
+ * running Dataverse holds the customization lock: our metadata writes either
6
+ * fail with 0x80071151 ("another operation is in progress") or hang until the
7
+ * HTTP client gives up. `msdyn_solutionhistories` records every import,
8
+ * uninstall, export and publish with a start time; a row whose `msdyn_endtime`
9
+ * is still null means that operation has not finished.
10
+ */
11
+ import { D365Error } from './errors.js'
12
+
13
+ /** Operations older than this are treated as zombie rows, not as active work. */
14
+ export const DEFAULT_BUSY_WINDOW_MINUTES = 120
15
+
16
+ const OPERATION_LABELS = {
17
+ 0: '导入',
18
+ 1: '卸载',
19
+ 2: '导出',
20
+ 3: '发布',
21
+ 7: '元数据生成',
22
+ }
23
+
24
+ const SELECT_FIELDS = 'msdyn_name,msdyn_starttime,msdyn_operation,msdyn_ismanaged,msdyn_publishername'
25
+
26
+ export function describeBlocker(blocker) {
27
+ const parts = [blocker.name]
28
+ parts.push(blocker.operation)
29
+ if (blocker.ageMinutes !== null) parts.push(`${blocker.ageMinutes} 分钟前开始`)
30
+ if (blocker.publisher) parts.push(`发行者 ${blocker.publisher}`)
31
+ return `${parts[0]}(${parts.slice(1).join(',')})`
32
+ }
33
+
34
+ /**
35
+ * @returns {Promise<{
36
+ * available: boolean, checkedAt: string, windowMinutes: number, reason?: string,
37
+ * blockers: object[], active: object[], stale: object[], busy: boolean,
38
+ * }>}
39
+ */
40
+ export async function checkEnvironmentIdle(client, { windowMinutes = DEFAULT_BUSY_WINDOW_MINUTES, signal } = {}) {
41
+ const checkedAt = new Date().toISOString()
42
+ const url = client.buildUrl('msdyn_solutionhistories', {
43
+ $select: SELECT_FIELDS,
44
+ $filter: 'msdyn_endtime eq null',
45
+ $orderby: 'msdyn_starttime desc',
46
+ $top: 25,
47
+ })
48
+
49
+ let rows
50
+ try {
51
+ const page = await client.requestJson(url, { signal })
52
+ rows = Array.isArray(page?.value) ? page.value : []
53
+ } catch (error) {
54
+ // No privilege on the table, or the environment does not expose it: the
55
+ // check is a safety net, not a gate we can enforce without data.
56
+ return {
57
+ available: false,
58
+ reason: error instanceof Error ? error.message : String(error),
59
+ checkedAt,
60
+ windowMinutes,
61
+ blockers: [],
62
+ active: [],
63
+ stale: [],
64
+ busy: false,
65
+ }
66
+ }
67
+
68
+ const now = Date.now()
69
+ const blockers = rows.map((row) => {
70
+ const startedAt = row.msdyn_starttime ?? null
71
+ const started = startedAt ? Date.parse(startedAt) : Number.NaN
72
+ return {
73
+ name: row.msdyn_name ?? '(未命名)',
74
+ operation: OPERATION_LABELS[row.msdyn_operation] ?? `操作 ${row.msdyn_operation}`,
75
+ managed: row.msdyn_ismanaged === true,
76
+ publisher: row.msdyn_publishername ?? '',
77
+ startedAt,
78
+ ageMinutes: Number.isFinite(started) ? Math.max(0, Math.round((now - started) / 60000)) : null,
79
+ }
80
+ })
81
+
82
+ const active = blockers.filter((blocker) => blocker.ageMinutes !== null && blocker.ageMinutes <= windowMinutes)
83
+ const stale = blockers.filter((blocker) => !active.includes(blocker))
84
+
85
+ return {
86
+ available: true,
87
+ checkedAt,
88
+ windowMinutes,
89
+ blockers,
90
+ active,
91
+ stale,
92
+ busy: active.length > 0,
93
+ }
94
+ }
95
+
96
+ export function busyError(envKey, state) {
97
+ // One solution import shows up as many rows (per patch step), and a publish
98
+ // shows up repeatedly: collapse to one line per name, newest first.
99
+ const seen = new Map()
100
+ for (const blocker of state.active) {
101
+ const previous = seen.get(blocker.name)
102
+ if (!previous || (blocker.ageMinutes ?? Infinity) < (previous.ageMinutes ?? Infinity)) {
103
+ seen.set(blocker.name, blocker)
104
+ }
105
+ }
106
+ const collapsed = [...seen.values()].sort((a, b) => (a.ageMinutes ?? 0) - (b.ageMinutes ?? 0))
107
+ const shown = collapsed.slice(0, 4).map(describeBlocker).join(';')
108
+ const more = collapsed.length > 4 ? `;等 ${collapsed.length} 个操作` : ''
109
+ const staleNote = state.stale.length
110
+ ? ` 另有 ${state.stale.length} 条超过 ${state.windowMinutes} 分钟仍未结束的旧记录,可能是残留。`
111
+ : ''
112
+ return new D365Error(`环境 ${envKey} 正在导入或发布解决方案,此时写入会失败或长时间卡住`, {
113
+ code: 'environment-busy',
114
+ hint: `进行中:${shown}${more}。${staleNote} 等它结束后重试;确认要跳过检查可以传 force: true。`,
115
+ })
116
+ }
package/lib/refs.js CHANGED
@@ -1,191 +1,191 @@
1
- /**
2
- * Reverse index: who references this thing?
3
- *
4
- * Metadata tells you what exists; it does not tell you what would break if you
5
- * remove or rename it. The references live in text blobs that Dataverse stores
6
- * alongside the definitions — formxml, layoutxml/fetchxml, workflow xaml — so
7
- * this module reads those blobs and asks "does the exact reference appear?".
8
- *
9
- * Only exact, quoted matches count. A substring search for `name` would match
10
- * `fullname`, so every pattern ends at a quote or a tag boundary.
11
- */
12
- import { D365Error } from './errors.js'
13
- import { odataString } from './resources.js'
14
-
15
- /** Form types, for labelling what a form is. */
16
- const FORM_TYPES = { 2: '主表单', 5: '快速查看', 6: '快速创建', 7: '卡片' }
17
-
18
- /** Workflow categories. */
19
- const WORKFLOW_CATEGORIES = { 0: '工作流', 1: '对话', 2: '业务规则', 3: '动作', 4: '业务流程', 5: '现代流', 6: '桌面流' }
20
-
21
- /** Query a collection, tolerating servers that reject `$select` on the path. */
22
- export async function readAll(client, path, params, limit, signal) {
23
- const page = await client.getPaged(path, params, limit, signal)
24
- return page.records ?? []
25
- }
26
-
27
- /**
28
- * Find forms that touch a field.
29
- * @param entity - entity logical name.
30
- * @param attribute - attribute logical name.
31
- * @returns forms with the attribute on them.
32
- */
33
- export async function findFieldForms(client, entity, attribute, signal) {
34
- const needle = `datafieldname="${attribute}"`
35
- const filter = `objecttypecode eq '${entity}' and contains(formxml,'${odataString(needle)}')`
36
- const rows = await readAll(client, 'systemforms', {
37
- $select: 'name,type,objecttypecode,formxml,ismanaged',
38
- $filter: filter,
39
- }, 200, signal)
40
- return rows.map((row) => {
41
- // Each control carries its own `visible` attribute, so the control tag — not
42
- // the whole form — is the unit that decides whether the field shows.
43
- const controls = String(row.formxml ?? '').match(/<control\b[^>]*>/gi) ?? []
44
- const own = controls.filter((tag) => tag.includes(`datafieldname="${attribute}"`))
45
- return {
46
- name: row.name,
47
- type: row.type,
48
- typeLabel: FORM_TYPES[row.type] ?? `类型 ${row.type}`,
49
- managed: row.ismanaged,
50
- visible: own.some((tag) => !/\bvisible="false"/i.test(tag)),
51
- }
52
- })
53
- }
54
-
55
- /**
56
- * Find views whose layout or filter mentions a field.
57
- * @returns views that would break (layout) or change behaviour (filter).
58
- */
59
- export async function findFieldViews(client, entity, attribute, signal) {
60
- const inLayout = `contains(layoutxml,'${odataString(`name="${attribute}"`)}')`
61
- const inFilter = `contains(fetchxml,'${odataString(`attribute="${attribute}"`)}')`
62
- const rows = await readAll(client, 'savedqueries', {
63
- $select: 'name,querytype,returnedtypecode,isdefault,layoutxml,fetchxml',
64
- $filter: `returnedtypecode eq '${entity}' and (${inLayout} or ${inFilter})`,
65
- }, 500, signal)
66
- return rows.map((row) => ({
67
- name: row.name,
68
- queryType: row.querytype,
69
- default: row.isdefault,
70
- inLayout: String(row.layoutxml ?? '').includes(`name="${attribute}"`),
71
- inFilter: String(row.fetchxml ?? '').includes(`attribute="${attribute}"`),
72
- }))
73
- }
74
-
75
- /**
76
- * Find workflows and business rules whose definition mentions a field.
77
- *
78
- * The definition lives in `clientdata` (a large string). Asking the server to
79
- * filter on it is not portable, so this pulls the entity's automations and
80
- * scans locally; that is bounded by the number of automations on one entity.
81
- * @returns automations that mention the field.
82
- */
83
- export async function findFieldAutomations(client, entity, attribute, signal) {
84
- const rows = await readAll(client, 'workflows', {
85
- $select: 'name,category,statecode,primaryentity,clientdata',
86
- $filter: `primaryentity eq '${entity}'`,
87
- }, 1000, signal)
88
- const quoted = `["']${attribute}["']`
89
- const patterns = [
90
- new RegExp(`<AttributeName>${attribute}</AttributeName>`, 'i'),
91
- new RegExp(`"attributeName"\\s*:\\s*"${attribute}"`, 'i'),
92
- new RegExp(`getAttribute\\(\\s*${quoted}`, 'i'),
93
- // Business rules compile to `formContext.data.entity.attributes.get('x')`.
94
- new RegExp(`attributes\\.get\\(\\s*${quoted}`, 'i'),
95
- new RegExp(`attributes\\[\\s*${quoted}\\s*\\]`, 'i'),
96
- ]
97
- return rows
98
- .filter((row) => patterns.some((pattern) => pattern.test(String(row.clientdata ?? ''))))
99
- .map((row) => ({
100
- name: row.name,
101
- category: WORKFLOW_CATEGORIES[row.category] ?? `类别 ${row.category}`,
102
- state: row.statecode === 1 ? '已启用' : '草稿/停用',
103
- }))
104
- }
105
-
106
- /**
107
- * Find forms whose scripts reference a web resource.
108
- * @param resourceName - web resource name as written in formxml `libraryName`.
109
- */
110
- export async function findResourceForms(client, resourceName, signal) {
111
- const rows = await readAll(client, 'systemforms', {
112
- $select: 'name,type,objecttypecode,formxml',
113
- $filter: `contains(formxml,'${odataString(`libraryName="${resourceName}"`)}')`,
114
- }, 500, signal)
115
- return rows.map((row) => {
116
- // The event name lives on the enclosing <event> node; the <Handler> only
117
- // names the function. Pair them so the report says "onload → fn" instead of
118
- // "? → fn".
119
- const handlers = []
120
- for (const block of String(row.formxml ?? '').matchAll(/<event\b[^>]*>([\s\S]*?)<\/event>/gi)) {
121
- const eventTag = block[0].slice(0, block[0].indexOf('>') + 1)
122
- const eventName = /\bname="([^"]*)"/i.exec(eventTag)?.[1] ?? ''
123
- for (const match of block[1].matchAll(/<Handler\b[^>]*>/gi)) {
124
- const tag = match[0]
125
- if (!tag.includes(`libraryName="${resourceName}"`)) continue
126
- const fn = /functionName="([^"]*)"/i.exec(tag)
127
- handlers.push({ functionName: fn?.[1] ?? '', event: eventName })
128
- }
129
- }
130
- return {
131
- name: row.name,
132
- type: row.type,
133
- typeLabel: FORM_TYPES[row.type] ?? `类型 ${row.type}`,
134
- entity: row.objecttypecode,
135
- handlers,
136
- }
137
- })
138
- }
139
-
140
- /**
141
- * Resolve a plugin assembly name to its types and message steps.
142
- * @returns `{ assembly, types: [{ typename, steps: [...] }] }`
143
- */
144
- export async function findPluginUsage(client, assemblyName, signal) {
145
- const assemblies = await readAll(client, 'pluginassemblies', {
146
- $select: 'pluginassemblyid,name,version,isolationmode,sourcetype',
147
- $filter: `name eq '${odataString(assemblyName)}'`,
148
- }, 5, signal)
149
- const assembly = assemblies[0]
150
- if (!assembly) {
151
- throw new D365Error(`没有找到插件程序集:${assemblyName}`, {
152
- code: 'unknown-assembly',
153
- hint: '先用 d365_metadata resource=plugin_assemblies 看名字(通常是程序集名,不带 .dll)。',
154
- })
155
- }
156
-
157
- const types = await readAll(client, 'plugintypes', {
158
- $select: 'plugintypeid,typename,friendlyname,_pluginassemblyid_value',
159
- $filter: `_pluginassemblyid_value eq ${assembly.pluginassemblyid}`,
160
- }, 200, signal)
161
-
162
- const detail = []
163
- for (const type of types) {
164
- const steps = await readAll(client, 'sdkmessageprocessingsteps', {
165
- $select: 'name,stage,mode,rank,filteringattributes,statecode,configuration',
166
- $filter: `_eventhandler_value eq ${type.plugintypeid}`,
167
- }, 100, signal)
168
- detail.push({
169
- typename: type.typename,
170
- friendlyName: type.friendlyname,
171
- steps: steps.map((step) => ({
172
- name: step.name,
173
- stage: step.stage,
174
- mode: step.mode,
175
- rank: step.rank,
176
- filteringAttributes: step.filteringattributes,
177
- enabled: step.statecode === 0,
178
- })),
179
- })
180
- }
181
-
182
- return {
183
- assembly: {
184
- name: assembly.name,
185
- version: assembly.version,
186
- isolation: assembly.isolationmode,
187
- source: assembly.sourcetype,
188
- },
189
- types: detail,
190
- }
191
- }
1
+ /**
2
+ * Reverse index: who references this thing?
3
+ *
4
+ * Metadata tells you what exists; it does not tell you what would break if you
5
+ * remove or rename it. The references live in text blobs that Dataverse stores
6
+ * alongside the definitions — formxml, layoutxml/fetchxml, workflow xaml — so
7
+ * this module reads those blobs and asks "does the exact reference appear?".
8
+ *
9
+ * Only exact, quoted matches count. A substring search for `name` would match
10
+ * `fullname`, so every pattern ends at a quote or a tag boundary.
11
+ */
12
+ import { D365Error } from './errors.js'
13
+ import { odataString } from './resources.js'
14
+
15
+ /** Form types, for labelling what a form is. */
16
+ const FORM_TYPES = { 2: '主表单', 5: '快速查看', 6: '快速创建', 7: '卡片' }
17
+
18
+ /** Workflow categories. */
19
+ const WORKFLOW_CATEGORIES = { 0: '工作流', 1: '对话', 2: '业务规则', 3: '动作', 4: '业务流程', 5: '现代流', 6: '桌面流' }
20
+
21
+ /** Query a collection, tolerating servers that reject `$select` on the path. */
22
+ export async function readAll(client, path, params, limit, signal) {
23
+ const page = await client.getPaged(path, params, limit, signal)
24
+ return page.records ?? []
25
+ }
26
+
27
+ /**
28
+ * Find forms that touch a field.
29
+ * @param entity - entity logical name.
30
+ * @param attribute - attribute logical name.
31
+ * @returns forms with the attribute on them.
32
+ */
33
+ export async function findFieldForms(client, entity, attribute, signal) {
34
+ const needle = `datafieldname="${attribute}"`
35
+ const filter = `objecttypecode eq '${entity}' and contains(formxml,'${odataString(needle)}')`
36
+ const rows = await readAll(client, 'systemforms', {
37
+ $select: 'name,type,objecttypecode,formxml,ismanaged',
38
+ $filter: filter,
39
+ }, 200, signal)
40
+ return rows.map((row) => {
41
+ // Each control carries its own `visible` attribute, so the control tag — not
42
+ // the whole form — is the unit that decides whether the field shows.
43
+ const controls = String(row.formxml ?? '').match(/<control\b[^>]*>/gi) ?? []
44
+ const own = controls.filter((tag) => tag.includes(`datafieldname="${attribute}"`))
45
+ return {
46
+ name: row.name,
47
+ type: row.type,
48
+ typeLabel: FORM_TYPES[row.type] ?? `类型 ${row.type}`,
49
+ managed: row.ismanaged,
50
+ visible: own.some((tag) => !/\bvisible="false"/i.test(tag)),
51
+ }
52
+ })
53
+ }
54
+
55
+ /**
56
+ * Find views whose layout or filter mentions a field.
57
+ * @returns views that would break (layout) or change behaviour (filter).
58
+ */
59
+ export async function findFieldViews(client, entity, attribute, signal) {
60
+ const inLayout = `contains(layoutxml,'${odataString(`name="${attribute}"`)}')`
61
+ const inFilter = `contains(fetchxml,'${odataString(`attribute="${attribute}"`)}')`
62
+ const rows = await readAll(client, 'savedqueries', {
63
+ $select: 'name,querytype,returnedtypecode,isdefault,layoutxml,fetchxml',
64
+ $filter: `returnedtypecode eq '${entity}' and (${inLayout} or ${inFilter})`,
65
+ }, 500, signal)
66
+ return rows.map((row) => ({
67
+ name: row.name,
68
+ queryType: row.querytype,
69
+ default: row.isdefault,
70
+ inLayout: String(row.layoutxml ?? '').includes(`name="${attribute}"`),
71
+ inFilter: String(row.fetchxml ?? '').includes(`attribute="${attribute}"`),
72
+ }))
73
+ }
74
+
75
+ /**
76
+ * Find workflows and business rules whose definition mentions a field.
77
+ *
78
+ * The definition lives in `clientdata` (a large string). Asking the server to
79
+ * filter on it is not portable, so this pulls the entity's automations and
80
+ * scans locally; that is bounded by the number of automations on one entity.
81
+ * @returns automations that mention the field.
82
+ */
83
+ export async function findFieldAutomations(client, entity, attribute, signal) {
84
+ const rows = await readAll(client, 'workflows', {
85
+ $select: 'name,category,statecode,primaryentity,clientdata',
86
+ $filter: `primaryentity eq '${entity}'`,
87
+ }, 1000, signal)
88
+ const quoted = `["']${attribute}["']`
89
+ const patterns = [
90
+ new RegExp(`<AttributeName>${attribute}</AttributeName>`, 'i'),
91
+ new RegExp(`"attributeName"\\s*:\\s*"${attribute}"`, 'i'),
92
+ new RegExp(`getAttribute\\(\\s*${quoted}`, 'i'),
93
+ // Business rules compile to `formContext.data.entity.attributes.get('x')`.
94
+ new RegExp(`attributes\\.get\\(\\s*${quoted}`, 'i'),
95
+ new RegExp(`attributes\\[\\s*${quoted}\\s*\\]`, 'i'),
96
+ ]
97
+ return rows
98
+ .filter((row) => patterns.some((pattern) => pattern.test(String(row.clientdata ?? ''))))
99
+ .map((row) => ({
100
+ name: row.name,
101
+ category: WORKFLOW_CATEGORIES[row.category] ?? `类别 ${row.category}`,
102
+ state: row.statecode === 1 ? '已启用' : '草稿/停用',
103
+ }))
104
+ }
105
+
106
+ /**
107
+ * Find forms whose scripts reference a web resource.
108
+ * @param resourceName - web resource name as written in formxml `libraryName`.
109
+ */
110
+ export async function findResourceForms(client, resourceName, signal) {
111
+ const rows = await readAll(client, 'systemforms', {
112
+ $select: 'name,type,objecttypecode,formxml',
113
+ $filter: `contains(formxml,'${odataString(`libraryName="${resourceName}"`)}')`,
114
+ }, 500, signal)
115
+ return rows.map((row) => {
116
+ // The event name lives on the enclosing <event> node; the <Handler> only
117
+ // names the function. Pair them so the report says "onload → fn" instead of
118
+ // "? → fn".
119
+ const handlers = []
120
+ for (const block of String(row.formxml ?? '').matchAll(/<event\b[^>]*>([\s\S]*?)<\/event>/gi)) {
121
+ const eventTag = block[0].slice(0, block[0].indexOf('>') + 1)
122
+ const eventName = /\bname="([^"]*)"/i.exec(eventTag)?.[1] ?? ''
123
+ for (const match of block[1].matchAll(/<Handler\b[^>]*>/gi)) {
124
+ const tag = match[0]
125
+ if (!tag.includes(`libraryName="${resourceName}"`)) continue
126
+ const fn = /functionName="([^"]*)"/i.exec(tag)
127
+ handlers.push({ functionName: fn?.[1] ?? '', event: eventName })
128
+ }
129
+ }
130
+ return {
131
+ name: row.name,
132
+ type: row.type,
133
+ typeLabel: FORM_TYPES[row.type] ?? `类型 ${row.type}`,
134
+ entity: row.objecttypecode,
135
+ handlers,
136
+ }
137
+ })
138
+ }
139
+
140
+ /**
141
+ * Resolve a plugin assembly name to its types and message steps.
142
+ * @returns `{ assembly, types: [{ typename, steps: [...] }] }`
143
+ */
144
+ export async function findPluginUsage(client, assemblyName, signal) {
145
+ const assemblies = await readAll(client, 'pluginassemblies', {
146
+ $select: 'pluginassemblyid,name,version,isolationmode,sourcetype',
147
+ $filter: `name eq '${odataString(assemblyName)}'`,
148
+ }, 5, signal)
149
+ const assembly = assemblies[0]
150
+ if (!assembly) {
151
+ throw new D365Error(`没有找到插件程序集:${assemblyName}`, {
152
+ code: 'unknown-assembly',
153
+ hint: '先用 d365_metadata resource=plugin_assemblies 看名字(通常是程序集名,不带 .dll)。',
154
+ })
155
+ }
156
+
157
+ const types = await readAll(client, 'plugintypes', {
158
+ $select: 'plugintypeid,typename,friendlyname,_pluginassemblyid_value',
159
+ $filter: `_pluginassemblyid_value eq ${assembly.pluginassemblyid}`,
160
+ }, 200, signal)
161
+
162
+ const detail = []
163
+ for (const type of types) {
164
+ const steps = await readAll(client, 'sdkmessageprocessingsteps', {
165
+ $select: 'name,stage,mode,rank,filteringattributes,statecode,configuration',
166
+ $filter: `_eventhandler_value eq ${type.plugintypeid}`,
167
+ }, 100, signal)
168
+ detail.push({
169
+ typename: type.typename,
170
+ friendlyName: type.friendlyname,
171
+ steps: steps.map((step) => ({
172
+ name: step.name,
173
+ stage: step.stage,
174
+ mode: step.mode,
175
+ rank: step.rank,
176
+ filteringAttributes: step.filteringattributes,
177
+ enabled: step.statecode === 0,
178
+ })),
179
+ })
180
+ }
181
+
182
+ return {
183
+ assembly: {
184
+ name: assembly.name,
185
+ version: assembly.version,
186
+ isolation: assembly.isolationmode,
187
+ source: assembly.sourcetype,
188
+ },
189
+ types: detail,
190
+ }
191
+ }