dsh-d365-kit 0.34.1 → 0.34.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/CHANGELOG.md +318 -308
  2. package/CHANGELOG.zh-CN.md +317 -307
  3. package/INSTALL.md +94 -94
  4. package/INSTALL.zh-CN.md +93 -93
  5. package/LICENSE +21 -21
  6. package/README.md +336 -327
  7. package/README.zh-CN.md +336 -327
  8. package/client.js +1818 -1818
  9. package/cordis.patch.yml +44 -44
  10. package/docs/en/architecture.md +172 -172
  11. package/docs/en/checks.md +184 -184
  12. package/docs/en/installation.md +151 -151
  13. package/docs/en/permissions.md +161 -161
  14. package/docs/en/scenarios.md +202 -202
  15. package/docs/en/tools.md +665 -665
  16. package/docs/en/troubleshooting.md +131 -131
  17. package/docs/zh/architecture.md +172 -172
  18. package/docs/zh/checks.md +184 -184
  19. package/docs/zh/installation.md +151 -151
  20. package/docs/zh/permissions.md +161 -161
  21. package/docs/zh/scenarios.md +202 -202
  22. package/docs/zh/tools.md +665 -665
  23. package/docs/zh/troubleshooting.md +131 -131
  24. package/examples/prompts.en.md +206 -206
  25. package/examples/prompts.zh-CN.md +206 -206
  26. package/examples/waivers.example.json +20 -20
  27. package/icon.svg +6 -6
  28. package/index.js +139 -139
  29. package/lib/auth.js +181 -181
  30. package/lib/client.js +496 -496
  31. package/lib/code-check.js +549 -549
  32. package/lib/config.js +323 -323
  33. package/lib/contract-history.js +49 -49
  34. package/lib/contract.js +583 -583
  35. package/lib/csharp-check.js +493 -493
  36. package/lib/data-migrate.js +1318 -1318
  37. package/lib/data-policy.js +78 -78
  38. package/lib/error-codes.js +80 -80
  39. package/lib/gitlab.js +161 -161
  40. package/lib/health.js +205 -205
  41. package/lib/migrate.js +732 -732
  42. package/lib/preflight.js +116 -116
  43. package/lib/refs.js +191 -191
  44. package/lib/resources.js +320 -320
  45. package/lib/retention-defaults.js +17 -17
  46. package/lib/retention.js +384 -384
  47. package/lib/runtime.js +232 -232
  48. package/lib/settings-http.js +512 -512
  49. package/lib/settings-store.js +371 -371
  50. package/lib/snapshot.js +149 -149
  51. package/lib/tool-code-check.js +133 -133
  52. package/lib/tool-configure.js +169 -169
  53. package/lib/tool-contract.js +376 -376
  54. package/lib/tool-data-sync.js +559 -559
  55. package/lib/tool-data-write.js +203 -203
  56. package/lib/tool-data.js +282 -282
  57. package/lib/tool-diff.js +484 -484
  58. package/lib/tool-environments.js +106 -106
  59. package/lib/tool-flows.js +540 -540
  60. package/lib/tool-health.js +127 -127
  61. package/lib/tool-layers.js +497 -497
  62. package/lib/tool-metadata.js +417 -417
  63. package/lib/tool-migrate.js +102 -102
  64. package/lib/tool-refs.js +206 -206
  65. package/lib/tool-reports.js +299 -299
  66. package/lib/tool-sync.js +430 -430
  67. package/lib/tool-write.js +563 -563
  68. package/lib/waivers.js +83 -83
  69. package/lib/write-actions.js +1374 -1374
  70. package/lib/write-policy.js +199 -199
  71. package/lib/write-store.js +167 -167
  72. package/locale/en.json +68 -68
  73. package/locale/zh.json +68 -68
  74. package/package.json +2 -2
  75. package/skills/d365-troubleshooting/SKILL.md +115 -115
package/lib/tool-sync.js CHANGED
@@ -1,430 +1,430 @@
1
- /**
2
- * `d365_sync` — pull a fixed metadata set into a local snapshot.
3
- *
4
- * Snapshots exist so that "what changed" is answerable without keeping the
5
- * live environment as the only source of truth. Each sync is a full pull (the
6
- * Dataverse metadata API has no delta for most tables); the value is not fewer
7
- * requests but a durable, comparable record with an honest completeness report.
8
- */
9
- import { loadConfig } from './config.js'
10
- import { makeClient, pickEnvironments, readOrganization } from './runtime.js'
11
- import { RESOURCES, composeFilter, flattenLabels, odataString } from './resources.js'
12
- import { getPagedWithSelectFallback } from './client.js'
13
- import { D365Error } from './errors.js'
14
- import { ensureDataRoot, pruneSnapshots, snapshotId, writeSnapshot } from './snapshot.js'
15
- import { enforceRetention } from './retention.js'
16
-
17
- /**
18
- * The default pull set. Deliberately excludes `attributes` (one request per
19
- * entity) and skips the resources that need per-entity iteration; ask for those
20
- * explicitly with `includeAttributes`.
21
- */
22
- export const CORE_RESOURCES = [
23
- 'organization',
24
- 'solutions',
25
- 'solution_components',
26
- 'entities',
27
- 'forms',
28
- 'views',
29
- 'workflows',
30
- 'plugin_assemblies',
31
- 'plugin_types',
32
- 'plugin_steps',
33
- 'web_resources',
34
- 'custom_apis',
35
- 'sitemaps',
36
- 'option_sets',
37
- ]
38
-
39
- /** Entities whose attributes are pulled when `includeAttributes` is set. */
40
- const MAX_ATTRIBUTE_ENTITIES = 500
41
-
42
- export const definition = {
43
- name: 'd365_sync',
44
- description:
45
- '把一个或多个环境的 D365 CE 元数据拉成本地快照(写到 .d365/snapshots/<env>/<快照ID>/),并记录每个资源是完整、被行数上限截断,还是失败。'
46
- + '快照是 d365_diff 的输入;数据源是实时 Web API,元数据表大多没有增量游标,所以每次都是全量拉取。',
47
- parameters: {
48
- type: 'object',
49
- additionalProperties: false,
50
- properties: {
51
- environments: {
52
- type: 'array',
53
- items: { type: 'string' },
54
- description: '环境 key 列表,例如 ["dev","sit"]。省略则用默认环境。',
55
- },
56
- allEnvironments: {
57
- type: 'boolean',
58
- description: '同步全部已配置环境。',
59
- },
60
- resources: {
61
- type: 'array',
62
- items: { type: 'string', enum: Object.keys(RESOURCES) },
63
- description: '只同步指定资源,默认是一组核心资源(不含 attributes)。',
64
- },
65
- includeAttributes: {
66
- type: 'boolean',
67
- description: '额外拉取每个实体的字段定义。请求数约为实体数,耗时明显更长。',
68
- },
69
- entities: {
70
- type: 'array',
71
- items: { type: 'string' },
72
- description: '配合 includeAttributes:只拉这些实体的字段(如 ["account","new_order"])。',
73
- },
74
- maxRecords: {
75
- type: 'integer',
76
- description: '每个资源最多拉多少行。默认 0,表示全量分页拉完;填正数则限制行数(被截断的资源会在 manifest 里标出)。',
77
- },
78
- keep: {
79
- type: 'integer',
80
- description: '每个环境保留多少份快照,超出后删除最旧的,默认取配置 snapshotKeep(2)。',
81
- },
82
- },
83
- },
84
- output: {
85
- schema: {
86
- type: 'object',
87
- additionalProperties: false,
88
- required: ['text'],
89
- properties: {
90
- text: { type: 'string' },
91
- },
92
- },
93
- render(_args, value) {
94
- return [{ type: 'text', text: value?.text ?? '' }]
95
- },
96
- },
97
- }
98
-
99
- /** Run at most `limit` workers over `items`, keeping result order. */
100
- async function mapLimit(items, limit, worker) {
101
- const results = new Array(items.length)
102
- let cursor = 0
103
- const runners = Array.from({ length: Math.min(limit, items.length) }, async () => {
104
- for (;;) {
105
- const index = cursor
106
- cursor += 1
107
- if (index >= items.length) return
108
- results[index] = await worker(items[index], index)
109
- }
110
- })
111
- await Promise.all(runners)
112
- return results
113
- }
114
-
115
- /** Build and execute one resource request. */
116
- async function fetchResource(client, resource, options) {
117
- const def = RESOURCES[resource]
118
- const path = resource === 'attributes'
119
- ? `EntityDefinitions(LogicalName='${odataString(options.entity)}')/Attributes`
120
- : def.path({ entity: options.entity })
121
- const params = {
122
- $select: def.select,
123
- $filter: composeFilter(def, { entity: options.entity }),
124
- $orderby: def.orderby,
125
- }
126
- const { page } = await getPagedWithSelectFallback(client, path, params, options.top, options.signal)
127
- return page
128
- }
129
-
130
- /** Fetch attributes for many entities, a few at a time. */
131
- async function fetchAttributes(client, entityNames, top, signal) {
132
- const rows = []
133
- const failures = []
134
- const started = Date.now()
135
-
136
- await mapLimit(entityNames, 4, async (entity) => {
137
- try {
138
- const page = await fetchResource(client, 'attributes', { entity, top, signal })
139
- for (const row of page.records) rows.push({ entity, ...flattenLabels(row) })
140
- } catch (error) {
141
- failures.push({ entity, message: error.message })
142
- }
143
- })
144
-
145
- return { rows, failures, elapsedMs: Date.now() - started }
146
- }
147
-
148
- /** Sync one environment and return its summary. */
149
- async function syncEnvironment(env, config, args, exec) {
150
- const client = makeClient(env, config)
151
- const startedAt = new Date()
152
- const id = snapshotId(startedAt)
153
- const requestedAll = Array.isArray(args.resources) && args.resources.length
154
- ? [...new Set(args.resources)]
155
- : [...CORE_RESOURCES]
156
- const unknown = requestedAll.filter((resource) => !RESOURCES[resource])
157
- if (unknown.length) {
158
- throw new D365Error(`未知的资源:${unknown.join(', ')}`, {
159
- code: 'unknown-resource',
160
- hint: `可用:${Object.keys(RESOURCES).join(', ')}`,
161
- })
162
- }
163
-
164
- // Unlimited by default: a snapshot with rows silently missing is worse than a
165
- // slow sync, and `d365_diff` would report the missing rows as deletions.
166
- const maxRecords = args.maxRecords === undefined || args.maxRecords === 0
167
- ? Number.POSITIVE_INFINITY
168
- : Number.isFinite(args.maxRecords) && args.maxRecords > 0
169
- ? Math.min(1_000_000, Math.trunc(args.maxRecords))
170
- : Math.max(20_000, config.maxRecords)
171
-
172
- const organizationRow = flattenLabels(await readOrganization(client))
173
-
174
- const requested = requestedAll.filter((resource) => resource !== 'organization')
175
- const wantAttributes = args.includeAttributes === true
176
- const explicitEntities = Array.isArray(args.entities)
177
- ? args.entities.map((item) => String(item).trim()).filter(Boolean)
178
- : []
179
-
180
- const details = {}
181
- const files = []
182
- const issues = []
183
- let entitiesRows = null
184
- let entitiesTruncated = false
185
-
186
- // `keys` / `*_relationships` only make sense for one entity, and the loop
187
- // below has no entity argument: without this the path becomes
188
- // EntityDefinitions(LogicalName='undefined') and the resource fails.
189
- const withoutEntity = requested.filter((resource) => RESOURCES[resource]?.entityScoped && !explicitEntities.length)
190
- for (const resource of withoutEntity) {
191
- issues.push({ resource, message: '跳过:该资源需要实体(传 entities,或改用 d365_metadata 的 entity 参数)' })
192
- }
193
-
194
- if (organizationRow) {
195
- files.push({
196
- resource: 'organization',
197
- payload: {
198
- resource: 'organization',
199
- env: env.key,
200
- snapshotId: id,
201
- generatedAt: startedAt.toISOString(),
202
- count: 1,
203
- truncated: false,
204
- rows: [organizationRow],
205
- },
206
- })
207
- details.organization = { count: 1, truncated: false, requests: 1, elapsedMs: 0 }
208
- }
209
-
210
- // `entities` first: it is both a requested resource and the input to the
211
- // attribute walk, so a truncation here changes what the attribute pull covers.
212
- if (requested.includes('entities') || wantAttributes) {
213
- const started = Date.now()
214
- const before = client.counters.requests
215
- try {
216
- const page = await fetchResource(client, 'entities', { top: maxRecords, signal: exec?.signal })
217
- entitiesRows = page.records.map((row) => flattenLabels(row))
218
- entitiesTruncated = page.hasMore
219
- if (requested.includes('entities')) {
220
- files.push({
221
- resource: 'entities',
222
- payload: {
223
- resource: 'entities',
224
- env: env.key,
225
- snapshotId: id,
226
- generatedAt: new Date().toISOString(),
227
- count: entitiesRows.length,
228
- truncated: page.hasMore,
229
- rows: entitiesRows,
230
- },
231
- })
232
- }
233
- details.entities = {
234
- count: entitiesRows.length,
235
- truncated: page.hasMore,
236
- requests: client.counters.requests - before,
237
- elapsedMs: Date.now() - started,
238
- }
239
- if (page.hasMore) {
240
- issues.push({ resource: 'entities', message: `达到行数上限 ${maxRecords},实体列表不完整,依赖它的字段采集也受限` })
241
- }
242
- } catch (error) {
243
- issues.push({ resource: 'entities', message: error.message })
244
- details.entities = { count: 0, truncated: true, requests: client.counters.requests - before, elapsedMs: Date.now() - started, error: error.message }
245
- }
246
- }
247
-
248
- for (const resource of requested) {
249
- if (resource === 'entities') continue
250
- if (withoutEntity.includes(resource)) continue
251
- const started = Date.now()
252
- const before = client.counters.requests
253
- try {
254
- const page = await fetchResource(client, resource, { top: maxRecords, signal: exec?.signal })
255
- const rows = page.records.map((row) => flattenLabels(row))
256
- files.push({
257
- resource,
258
- payload: {
259
- resource,
260
- env: env.key,
261
- snapshotId: id,
262
- generatedAt: new Date().toISOString(),
263
- count: rows.length,
264
- truncated: page.hasMore,
265
- rows,
266
- },
267
- })
268
- details[resource] = {
269
- count: rows.length,
270
- truncated: page.hasMore,
271
- requests: client.counters.requests - before,
272
- elapsedMs: Date.now() - started,
273
- }
274
- if (page.hasMore) {
275
- issues.push({ resource, message: `达到行数上限 ${maxRecords},该资源不完整` })
276
- }
277
- } catch (error) {
278
- details[resource] = {
279
- count: 0,
280
- truncated: true,
281
- requests: client.counters.requests - before,
282
- elapsedMs: Date.now() - started,
283
- error: error.message,
284
- }
285
- issues.push({ resource, message: error.message })
286
- }
287
- }
288
-
289
- if (wantAttributes) {
290
- const available = entitiesRows ?? []
291
- const names = (explicitEntities.length ? explicitEntities : available.map((row) => row.LogicalName))
292
- .filter((name) => typeof name === 'string' && name.trim())
293
- const capped = names.slice(0, MAX_ATTRIBUTE_ENTITIES)
294
- if (names.length > capped.length) {
295
- issues.push({ resource: 'attributes', message: `实体数超过 ${MAX_ATTRIBUTE_ENTITIES},只采集了前 ${MAX_ATTRIBUTE_ENTITIES} 个实体的字段` })
296
- }
297
-
298
- const started = Date.now()
299
- const result = await fetchAttributes(client, capped, maxRecords, exec?.signal)
300
- files.push({
301
- resource: 'attributes',
302
- payload: {
303
- resource: 'attributes',
304
- env: env.key,
305
- snapshotId: id,
306
- generatedAt: new Date().toISOString(),
307
- count: result.rows.length,
308
- truncated: entitiesTruncated || names.length > capped.length || result.failures.length > 0,
309
- entities: capped.length,
310
- failures: result.failures,
311
- rows: result.rows,
312
- },
313
- })
314
- details.attributes = {
315
- count: result.rows.length,
316
- truncated: entitiesTruncated || names.length > capped.length || result.failures.length > 0,
317
- entities: capped.length,
318
- failedEntities: result.failures.length,
319
- elapsedMs: result.elapsedMs,
320
- }
321
- for (const failure of result.failures.slice(0, 10)) {
322
- issues.push({ resource: `attributes:${failure.entity}`, message: failure.message })
323
- }
324
- if (result.failures.length > 10) {
325
- issues.push({ resource: 'attributes', message: `另有 ${result.failures.length - 10} 个实体的字段采集失败` })
326
- }
327
- }
328
-
329
- const finishedAt = new Date()
330
- const manifest = {
331
- snapshotId: id,
332
- env: env.key,
333
- label: env.label,
334
- url: env.url,
335
- organization: organizationRow
336
- ? {
337
- organizationid: organizationRow.organizationid,
338
- name: organizationRow.name,
339
- version: organizationRow.version,
340
- organizationstructure: organizationRow.organizationstructure,
341
- }
342
- : null,
343
- startedAt: startedAt.toISOString(),
344
- finishedAt: finishedAt.toISOString(),
345
- durationMs: finishedAt - startedAt,
346
- complete: issues.length === 0,
347
- issues,
348
- resources: details,
349
- requested: ['organization', ...requested, ...(wantAttributes ? ['attributes'] : [])],
350
- }
351
-
352
- await ensureDataRoot(config)
353
- const dir = await writeSnapshot(config, env.key, id, manifest, files)
354
- const keep = Number.isFinite(args.keep) && args.keep > 0 ? Math.trunc(args.keep) : config.snapshotKeep
355
- const pruned = await pruneSnapshots(config, env.key, keep)
356
- await enforceRetention(config, { target: 'snapshots' })
357
-
358
- return { env, id, dir, manifest, pruned, organization: organizationRow }
359
- }
360
-
361
- /** Render one environment's sync summary. */
362
- function renderSummary(result, config) {
363
- const { env, manifest } = result
364
- const lines = []
365
- const orgName = manifest.organization?.name ?? '(未取到组织信息)'
366
- lines.push(`### ${env.key} — ${orgName}`)
367
- lines.push(`- 快照:${manifest.snapshotId}`)
368
- lines.push(`- 目录:${result.dir.replace(/\\/g, '/')}`)
369
- lines.push(`- 状态:${manifest.complete ? '完整' : '部分(见下方 issues)'},耗时 ${(manifest.durationMs / 1000).toFixed(1)}s`)
370
- lines.push('')
371
- lines.push('| 资源 | 行数 | 请求 | 状态 |')
372
- lines.push('| --- | --- | --- | --- |')
373
- for (const [resource, detail] of Object.entries(manifest.resources)) {
374
- const status = detail.error
375
- ? `失败:${String(detail.error).slice(0, 60)}`
376
- : detail.truncated ? '被截断' : '完整'
377
- lines.push(`| ${resource} | ${detail.count ?? 0} | ${detail.requests ?? '-'} | ${status} |`)
378
- }
379
- if (manifest.issues.length) {
380
- lines.push('')
381
- lines.push('问题:')
382
- for (const issue of manifest.issues.slice(0, 12)) lines.push(`- ${issue.resource}:${issue.message}`)
383
- if (manifest.issues.length > 12) lines.push(`- … 另有 ${manifest.issues.length - 12} 条见 manifest.json`)
384
- }
385
- if (result.pruned.length) {
386
- lines.push('', `已清理旧快照:${result.pruned.join(', ')}(保留 ${config.snapshotKeep} 份)`)
387
- }
388
- if (!manifest.resources.attributes) {
389
- lines.push('', '未采集字段定义(attributes)。需要时加 includeAttributes: true,或指定 entities 只拉部分实体。')
390
- }
391
- return lines
392
- }
393
-
394
- export function register(ctx, state) {
395
- return ctx.tools.register({
396
- ...definition,
397
- async execute(args = {}, exec) {
398
- const config = await loadConfig(state.rawConfig)
399
- const environments = pickEnvironments(config, args)
400
- const lines = [`同步快照:${environments.map((env) => env.key).join(' / ')}`]
401
- const failures = []
402
-
403
- for (const env of environments) {
404
- try {
405
- const result = await syncEnvironment(env, config, args, exec)
406
- lines.push('', ...renderSummary(result, config))
407
- } catch (error) {
408
- failures.push(`${env.key}:${error.message}`)
409
- }
410
- }
411
-
412
- if (failures.length) {
413
- lines.push('')
414
- lines.push('失败的环境:')
415
- for (const failure of failures) lines.push(`- ${failure}`)
416
- }
417
-
418
- return { text: lines.join('\n') }
419
- },
420
- presentCall(args = {}) {
421
- const envs = args.allEnvironments ? '全部环境' : (args.environments ?? []).join('/') || '默认环境'
422
- return {
423
- card: 'generic',
424
- title: `同步 D365 快照(${envs})`,
425
- kind: 'other',
426
- rawInput: args,
427
- }
428
- },
429
- })
430
- }
1
+ /**
2
+ * `d365_sync` — pull a fixed metadata set into a local snapshot.
3
+ *
4
+ * Snapshots exist so that "what changed" is answerable without keeping the
5
+ * live environment as the only source of truth. Each sync is a full pull (the
6
+ * Dataverse metadata API has no delta for most tables); the value is not fewer
7
+ * requests but a durable, comparable record with an honest completeness report.
8
+ */
9
+ import { loadConfig } from './config.js'
10
+ import { makeClient, pickEnvironments, readOrganization } from './runtime.js'
11
+ import { RESOURCES, composeFilter, flattenLabels, odataString } from './resources.js'
12
+ import { getPagedWithSelectFallback } from './client.js'
13
+ import { D365Error } from './errors.js'
14
+ import { ensureDataRoot, pruneSnapshots, snapshotId, writeSnapshot } from './snapshot.js'
15
+ import { enforceRetention } from './retention.js'
16
+
17
+ /**
18
+ * The default pull set. Deliberately excludes `attributes` (one request per
19
+ * entity) and skips the resources that need per-entity iteration; ask for those
20
+ * explicitly with `includeAttributes`.
21
+ */
22
+ export const CORE_RESOURCES = [
23
+ 'organization',
24
+ 'solutions',
25
+ 'solution_components',
26
+ 'entities',
27
+ 'forms',
28
+ 'views',
29
+ 'workflows',
30
+ 'plugin_assemblies',
31
+ 'plugin_types',
32
+ 'plugin_steps',
33
+ 'web_resources',
34
+ 'custom_apis',
35
+ 'sitemaps',
36
+ 'option_sets',
37
+ ]
38
+
39
+ /** Entities whose attributes are pulled when `includeAttributes` is set. */
40
+ const MAX_ATTRIBUTE_ENTITIES = 500
41
+
42
+ export const definition = {
43
+ name: 'd365_sync',
44
+ description:
45
+ '把一个或多个环境的 D365 CE 元数据拉成本地快照(写到 .d365/snapshots/<env>/<快照ID>/),并记录每个资源是完整、被行数上限截断,还是失败。'
46
+ + '快照是 d365_diff 的输入;数据源是实时 Web API,元数据表大多没有增量游标,所以每次都是全量拉取。',
47
+ parameters: {
48
+ type: 'object',
49
+ additionalProperties: false,
50
+ properties: {
51
+ environments: {
52
+ type: 'array',
53
+ items: { type: 'string' },
54
+ description: '环境 key 列表,例如 ["dev","sit"]。省略则用默认环境。',
55
+ },
56
+ allEnvironments: {
57
+ type: 'boolean',
58
+ description: '同步全部已配置环境。',
59
+ },
60
+ resources: {
61
+ type: 'array',
62
+ items: { type: 'string', enum: Object.keys(RESOURCES) },
63
+ description: '只同步指定资源,默认是一组核心资源(不含 attributes)。',
64
+ },
65
+ includeAttributes: {
66
+ type: 'boolean',
67
+ description: '额外拉取每个实体的字段定义。请求数约为实体数,耗时明显更长。',
68
+ },
69
+ entities: {
70
+ type: 'array',
71
+ items: { type: 'string' },
72
+ description: '配合 includeAttributes:只拉这些实体的字段(如 ["account","new_order"])。',
73
+ },
74
+ maxRecords: {
75
+ type: 'integer',
76
+ description: '每个资源最多拉多少行。默认 0,表示全量分页拉完;填正数则限制行数(被截断的资源会在 manifest 里标出)。',
77
+ },
78
+ keep: {
79
+ type: 'integer',
80
+ description: '每个环境保留多少份快照,超出后删除最旧的,默认取配置 snapshotKeep(2)。',
81
+ },
82
+ },
83
+ },
84
+ output: {
85
+ schema: {
86
+ type: 'object',
87
+ additionalProperties: false,
88
+ required: ['text'],
89
+ properties: {
90
+ text: { type: 'string' },
91
+ },
92
+ },
93
+ render(_args, value) {
94
+ return [{ type: 'text', text: value?.text ?? '' }]
95
+ },
96
+ },
97
+ }
98
+
99
+ /** Run at most `limit` workers over `items`, keeping result order. */
100
+ async function mapLimit(items, limit, worker) {
101
+ const results = new Array(items.length)
102
+ let cursor = 0
103
+ const runners = Array.from({ length: Math.min(limit, items.length) }, async () => {
104
+ for (;;) {
105
+ const index = cursor
106
+ cursor += 1
107
+ if (index >= items.length) return
108
+ results[index] = await worker(items[index], index)
109
+ }
110
+ })
111
+ await Promise.all(runners)
112
+ return results
113
+ }
114
+
115
+ /** Build and execute one resource request. */
116
+ async function fetchResource(client, resource, options) {
117
+ const def = RESOURCES[resource]
118
+ const path = resource === 'attributes'
119
+ ? `EntityDefinitions(LogicalName='${odataString(options.entity)}')/Attributes`
120
+ : def.path({ entity: options.entity })
121
+ const params = {
122
+ $select: def.select,
123
+ $filter: composeFilter(def, { entity: options.entity }),
124
+ $orderby: def.orderby,
125
+ }
126
+ const { page } = await getPagedWithSelectFallback(client, path, params, options.top, options.signal)
127
+ return page
128
+ }
129
+
130
+ /** Fetch attributes for many entities, a few at a time. */
131
+ async function fetchAttributes(client, entityNames, top, signal) {
132
+ const rows = []
133
+ const failures = []
134
+ const started = Date.now()
135
+
136
+ await mapLimit(entityNames, 4, async (entity) => {
137
+ try {
138
+ const page = await fetchResource(client, 'attributes', { entity, top, signal })
139
+ for (const row of page.records) rows.push({ entity, ...flattenLabels(row) })
140
+ } catch (error) {
141
+ failures.push({ entity, message: error.message })
142
+ }
143
+ })
144
+
145
+ return { rows, failures, elapsedMs: Date.now() - started }
146
+ }
147
+
148
+ /** Sync one environment and return its summary. */
149
+ async function syncEnvironment(env, config, args, exec) {
150
+ const client = makeClient(env, config)
151
+ const startedAt = new Date()
152
+ const id = snapshotId(startedAt)
153
+ const requestedAll = Array.isArray(args.resources) && args.resources.length
154
+ ? [...new Set(args.resources)]
155
+ : [...CORE_RESOURCES]
156
+ const unknown = requestedAll.filter((resource) => !RESOURCES[resource])
157
+ if (unknown.length) {
158
+ throw new D365Error(`未知的资源:${unknown.join(', ')}`, {
159
+ code: 'unknown-resource',
160
+ hint: `可用:${Object.keys(RESOURCES).join(', ')}`,
161
+ })
162
+ }
163
+
164
+ // Unlimited by default: a snapshot with rows silently missing is worse than a
165
+ // slow sync, and `d365_diff` would report the missing rows as deletions.
166
+ const maxRecords = args.maxRecords === undefined || args.maxRecords === 0
167
+ ? Number.POSITIVE_INFINITY
168
+ : Number.isFinite(args.maxRecords) && args.maxRecords > 0
169
+ ? Math.min(1_000_000, Math.trunc(args.maxRecords))
170
+ : Math.max(20_000, config.maxRecords)
171
+
172
+ const organizationRow = flattenLabels(await readOrganization(client))
173
+
174
+ const requested = requestedAll.filter((resource) => resource !== 'organization')
175
+ const wantAttributes = args.includeAttributes === true
176
+ const explicitEntities = Array.isArray(args.entities)
177
+ ? args.entities.map((item) => String(item).trim()).filter(Boolean)
178
+ : []
179
+
180
+ const details = {}
181
+ const files = []
182
+ const issues = []
183
+ let entitiesRows = null
184
+ let entitiesTruncated = false
185
+
186
+ // `keys` / `*_relationships` only make sense for one entity, and the loop
187
+ // below has no entity argument: without this the path becomes
188
+ // EntityDefinitions(LogicalName='undefined') and the resource fails.
189
+ const withoutEntity = requested.filter((resource) => RESOURCES[resource]?.entityScoped && !explicitEntities.length)
190
+ for (const resource of withoutEntity) {
191
+ issues.push({ resource, message: '跳过:该资源需要实体(传 entities,或改用 d365_metadata 的 entity 参数)' })
192
+ }
193
+
194
+ if (organizationRow) {
195
+ files.push({
196
+ resource: 'organization',
197
+ payload: {
198
+ resource: 'organization',
199
+ env: env.key,
200
+ snapshotId: id,
201
+ generatedAt: startedAt.toISOString(),
202
+ count: 1,
203
+ truncated: false,
204
+ rows: [organizationRow],
205
+ },
206
+ })
207
+ details.organization = { count: 1, truncated: false, requests: 1, elapsedMs: 0 }
208
+ }
209
+
210
+ // `entities` first: it is both a requested resource and the input to the
211
+ // attribute walk, so a truncation here changes what the attribute pull covers.
212
+ if (requested.includes('entities') || wantAttributes) {
213
+ const started = Date.now()
214
+ const before = client.counters.requests
215
+ try {
216
+ const page = await fetchResource(client, 'entities', { top: maxRecords, signal: exec?.signal })
217
+ entitiesRows = page.records.map((row) => flattenLabels(row))
218
+ entitiesTruncated = page.hasMore
219
+ if (requested.includes('entities')) {
220
+ files.push({
221
+ resource: 'entities',
222
+ payload: {
223
+ resource: 'entities',
224
+ env: env.key,
225
+ snapshotId: id,
226
+ generatedAt: new Date().toISOString(),
227
+ count: entitiesRows.length,
228
+ truncated: page.hasMore,
229
+ rows: entitiesRows,
230
+ },
231
+ })
232
+ }
233
+ details.entities = {
234
+ count: entitiesRows.length,
235
+ truncated: page.hasMore,
236
+ requests: client.counters.requests - before,
237
+ elapsedMs: Date.now() - started,
238
+ }
239
+ if (page.hasMore) {
240
+ issues.push({ resource: 'entities', message: `达到行数上限 ${maxRecords},实体列表不完整,依赖它的字段采集也受限` })
241
+ }
242
+ } catch (error) {
243
+ issues.push({ resource: 'entities', message: error.message })
244
+ details.entities = { count: 0, truncated: true, requests: client.counters.requests - before, elapsedMs: Date.now() - started, error: error.message }
245
+ }
246
+ }
247
+
248
+ for (const resource of requested) {
249
+ if (resource === 'entities') continue
250
+ if (withoutEntity.includes(resource)) continue
251
+ const started = Date.now()
252
+ const before = client.counters.requests
253
+ try {
254
+ const page = await fetchResource(client, resource, { top: maxRecords, signal: exec?.signal })
255
+ const rows = page.records.map((row) => flattenLabels(row))
256
+ files.push({
257
+ resource,
258
+ payload: {
259
+ resource,
260
+ env: env.key,
261
+ snapshotId: id,
262
+ generatedAt: new Date().toISOString(),
263
+ count: rows.length,
264
+ truncated: page.hasMore,
265
+ rows,
266
+ },
267
+ })
268
+ details[resource] = {
269
+ count: rows.length,
270
+ truncated: page.hasMore,
271
+ requests: client.counters.requests - before,
272
+ elapsedMs: Date.now() - started,
273
+ }
274
+ if (page.hasMore) {
275
+ issues.push({ resource, message: `达到行数上限 ${maxRecords},该资源不完整` })
276
+ }
277
+ } catch (error) {
278
+ details[resource] = {
279
+ count: 0,
280
+ truncated: true,
281
+ requests: client.counters.requests - before,
282
+ elapsedMs: Date.now() - started,
283
+ error: error.message,
284
+ }
285
+ issues.push({ resource, message: error.message })
286
+ }
287
+ }
288
+
289
+ if (wantAttributes) {
290
+ const available = entitiesRows ?? []
291
+ const names = (explicitEntities.length ? explicitEntities : available.map((row) => row.LogicalName))
292
+ .filter((name) => typeof name === 'string' && name.trim())
293
+ const capped = names.slice(0, MAX_ATTRIBUTE_ENTITIES)
294
+ if (names.length > capped.length) {
295
+ issues.push({ resource: 'attributes', message: `实体数超过 ${MAX_ATTRIBUTE_ENTITIES},只采集了前 ${MAX_ATTRIBUTE_ENTITIES} 个实体的字段` })
296
+ }
297
+
298
+ const started = Date.now()
299
+ const result = await fetchAttributes(client, capped, maxRecords, exec?.signal)
300
+ files.push({
301
+ resource: 'attributes',
302
+ payload: {
303
+ resource: 'attributes',
304
+ env: env.key,
305
+ snapshotId: id,
306
+ generatedAt: new Date().toISOString(),
307
+ count: result.rows.length,
308
+ truncated: entitiesTruncated || names.length > capped.length || result.failures.length > 0,
309
+ entities: capped.length,
310
+ failures: result.failures,
311
+ rows: result.rows,
312
+ },
313
+ })
314
+ details.attributes = {
315
+ count: result.rows.length,
316
+ truncated: entitiesTruncated || names.length > capped.length || result.failures.length > 0,
317
+ entities: capped.length,
318
+ failedEntities: result.failures.length,
319
+ elapsedMs: result.elapsedMs,
320
+ }
321
+ for (const failure of result.failures.slice(0, 10)) {
322
+ issues.push({ resource: `attributes:${failure.entity}`, message: failure.message })
323
+ }
324
+ if (result.failures.length > 10) {
325
+ issues.push({ resource: 'attributes', message: `另有 ${result.failures.length - 10} 个实体的字段采集失败` })
326
+ }
327
+ }
328
+
329
+ const finishedAt = new Date()
330
+ const manifest = {
331
+ snapshotId: id,
332
+ env: env.key,
333
+ label: env.label,
334
+ url: env.url,
335
+ organization: organizationRow
336
+ ? {
337
+ organizationid: organizationRow.organizationid,
338
+ name: organizationRow.name,
339
+ version: organizationRow.version,
340
+ organizationstructure: organizationRow.organizationstructure,
341
+ }
342
+ : null,
343
+ startedAt: startedAt.toISOString(),
344
+ finishedAt: finishedAt.toISOString(),
345
+ durationMs: finishedAt - startedAt,
346
+ complete: issues.length === 0,
347
+ issues,
348
+ resources: details,
349
+ requested: ['organization', ...requested, ...(wantAttributes ? ['attributes'] : [])],
350
+ }
351
+
352
+ await ensureDataRoot(config)
353
+ const dir = await writeSnapshot(config, env.key, id, manifest, files)
354
+ const keep = Number.isFinite(args.keep) && args.keep > 0 ? Math.trunc(args.keep) : config.snapshotKeep
355
+ const pruned = await pruneSnapshots(config, env.key, keep)
356
+ await enforceRetention(config, { target: 'snapshots' })
357
+
358
+ return { env, id, dir, manifest, pruned, organization: organizationRow }
359
+ }
360
+
361
+ /** Render one environment's sync summary. */
362
+ function renderSummary(result, config) {
363
+ const { env, manifest } = result
364
+ const lines = []
365
+ const orgName = manifest.organization?.name ?? '(未取到组织信息)'
366
+ lines.push(`### ${env.key} — ${orgName}`)
367
+ lines.push(`- 快照:${manifest.snapshotId}`)
368
+ lines.push(`- 目录:${result.dir.replace(/\\/g, '/')}`)
369
+ lines.push(`- 状态:${manifest.complete ? '完整' : '部分(见下方 issues)'},耗时 ${(manifest.durationMs / 1000).toFixed(1)}s`)
370
+ lines.push('')
371
+ lines.push('| 资源 | 行数 | 请求 | 状态 |')
372
+ lines.push('| --- | --- | --- | --- |')
373
+ for (const [resource, detail] of Object.entries(manifest.resources)) {
374
+ const status = detail.error
375
+ ? `失败:${String(detail.error).slice(0, 60)}`
376
+ : detail.truncated ? '被截断' : '完整'
377
+ lines.push(`| ${resource} | ${detail.count ?? 0} | ${detail.requests ?? '-'} | ${status} |`)
378
+ }
379
+ if (manifest.issues.length) {
380
+ lines.push('')
381
+ lines.push('问题:')
382
+ for (const issue of manifest.issues.slice(0, 12)) lines.push(`- ${issue.resource}:${issue.message}`)
383
+ if (manifest.issues.length > 12) lines.push(`- … 另有 ${manifest.issues.length - 12} 条见 manifest.json`)
384
+ }
385
+ if (result.pruned.length) {
386
+ lines.push('', `已清理旧快照:${result.pruned.join(', ')}(保留 ${config.snapshotKeep} 份)`)
387
+ }
388
+ if (!manifest.resources.attributes) {
389
+ lines.push('', '未采集字段定义(attributes)。需要时加 includeAttributes: true,或指定 entities 只拉部分实体。')
390
+ }
391
+ return lines
392
+ }
393
+
394
+ export function register(ctx, state) {
395
+ return ctx.tools.register({
396
+ ...definition,
397
+ async execute(args = {}, exec) {
398
+ const config = await loadConfig(state.rawConfig)
399
+ const environments = pickEnvironments(config, args)
400
+ const lines = [`同步快照:${environments.map((env) => env.key).join(' / ')}`]
401
+ const failures = []
402
+
403
+ for (const env of environments) {
404
+ try {
405
+ const result = await syncEnvironment(env, config, args, exec)
406
+ lines.push('', ...renderSummary(result, config))
407
+ } catch (error) {
408
+ failures.push(`${env.key}:${error.message}`)
409
+ }
410
+ }
411
+
412
+ if (failures.length) {
413
+ lines.push('')
414
+ lines.push('失败的环境:')
415
+ for (const failure of failures) lines.push(`- ${failure}`)
416
+ }
417
+
418
+ return { text: lines.join('\n') }
419
+ },
420
+ presentCall(args = {}) {
421
+ const envs = args.allEnvironments ? '全部环境' : (args.environments ?? []).join('/') || '默认环境'
422
+ return {
423
+ card: 'generic',
424
+ title: `同步 D365 快照(${envs})`,
425
+ kind: 'other',
426
+ rawInput: args,
427
+ }
428
+ },
429
+ })
430
+ }