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/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
|
+
}
|