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.
- package/CHANGELOG.md +318 -308
- package/CHANGELOG.zh-CN.md +317 -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/tool-metadata.js
CHANGED
|
@@ -1,417 +1,417 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* `d365_metadata` — the generic, read-only metadata query.
|
|
3
|
-
*
|
|
4
|
-
* One tool over a fixed catalogue beats one tool per table: the catalogue is
|
|
5
|
-
* small enough for a model to read, the arguments are the same shape for every
|
|
6
|
-
* resource, and adding a table later is a data change, not a new tool.
|
|
7
|
-
*
|
|
8
|
-
* When several environments are named in one call, the result includes a
|
|
9
|
-
* cross-environment comparison, because "does sit have it" is the common
|
|
10
|
-
* question and running two calls by hand loses the alignment.
|
|
11
|
-
*/
|
|
12
|
-
import { loadConfig } from './config.js'
|
|
13
|
-
import {
|
|
14
|
-
INLINE_ROWS,
|
|
15
|
-
compareEnvironments,
|
|
16
|
-
formatTable,
|
|
17
|
-
makeClient,
|
|
18
|
-
pickEnvironments,
|
|
19
|
-
writeArtifact,
|
|
20
|
-
} from './runtime.js'
|
|
21
|
-
import { RESOURCES, RESOURCE_KEYS, composeFilter, flattenLabels, odataString } from './resources.js'
|
|
22
|
-
import { getPagedWithSelectFallback } from './client.js'
|
|
23
|
-
import { D365Error } from './errors.js'
|
|
24
|
-
|
|
25
|
-
/** Columns shown in the inline digest, per resource. Missing ones are skipped. */
|
|
26
|
-
const DISPLAY_FIELDS = {
|
|
27
|
-
organization: ['name', 'version', 'organizationstructure', 'url'],
|
|
28
|
-
entities: ['LogicalName', 'EntitySetName', 'IsCustomEntity', 'ObjectTypeCode'],
|
|
29
|
-
attributes: ['LogicalName', 'AttributeType', 'IsCustomAttribute', 'RequiredLevel'],
|
|
30
|
-
keys: ['SchemaName', 'LogicalName'],
|
|
31
|
-
one_to_many_relationships: ['SchemaName', 'ReferencingEntity', 'ReferencedEntity'],
|
|
32
|
-
many_to_many_relationships: ['SchemaName', 'Entity1LogicalName', 'Entity2LogicalName'],
|
|
33
|
-
forms: ['name', 'type', 'objecttypecode', 'ismanaged', 'modifiedon'],
|
|
34
|
-
views: ['name', 'querytype', 'isdefault', 'returnedtypecode'],
|
|
35
|
-
workflows: ['name', 'type', 'category', 'statecode', 'primaryentity', 'modifiedon'],
|
|
36
|
-
business_rules: ['name', 'type', 'statecode', 'primaryentity', 'modifiedon'],
|
|
37
|
-
plugin_assemblies: ['name', 'version', 'isolationmode', 'sourcetype'],
|
|
38
|
-
plugin_types: ['typename', 'friendlyname'],
|
|
39
|
-
plugin_steps: ['message', 'plugintype', 'stage', 'mode', 'rank', 'filteringattributes'],
|
|
40
|
-
step_images: ['name', 'imagetype', 'entityalias', 'attributes', 'ismanaged'],
|
|
41
|
-
web_resources: ['name', 'webresourcetype', 'ismanaged', 'modifiedon'],
|
|
42
|
-
solutions: ['uniquename', 'version', 'ismanaged', 'installedon'],
|
|
43
|
-
solution_components: ['componenttype', 'objectid', 'solutionid'],
|
|
44
|
-
custom_apis: ['uniquename', 'boundentitylogicalname', 'isfunction'],
|
|
45
|
-
sitemaps: ['sitemapname', 'componentstate'],
|
|
46
|
-
option_sets: ['Name', 'OptionSetType', 'IsCustomOptionSet'],
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
export const definition = {
|
|
50
|
-
name: 'd365_metadata',
|
|
51
|
-
description:
|
|
52
|
-
'以只读方式查询 D365 CE / Dataverse 元数据(实体、字段、表单、视图、工作流、插件、Web 资源、解决方案等)。'
|
|
53
|
-
+ '可一次指定多个环境,自动按名称对齐并给出跨环境差异。返回摘要与完整结果文件路径。',
|
|
54
|
-
parameters: {
|
|
55
|
-
type: 'object',
|
|
56
|
-
additionalProperties: false,
|
|
57
|
-
required: ['resource'],
|
|
58
|
-
properties: {
|
|
59
|
-
resource: {
|
|
60
|
-
type: 'string',
|
|
61
|
-
enum: RESOURCE_KEYS,
|
|
62
|
-
description: '要查询的元数据类别。选错时会返回完整可选列表。',
|
|
63
|
-
},
|
|
64
|
-
entity: {
|
|
65
|
-
type: 'string',
|
|
66
|
-
description: '实体逻辑名(如 account、new_order),用于 attributes/keys/relationships/forms/views/workflows 等按实体取数的资源。',
|
|
67
|
-
},
|
|
68
|
-
name: {
|
|
69
|
-
type: 'string',
|
|
70
|
-
description: '按名称精确取一条记录(目前只有 option_sets 支持:直接按全局选项集名取,一次请求,不用把 900 多个定义都拉下来)。',
|
|
71
|
-
},
|
|
72
|
-
attribute: {
|
|
73
|
-
type: 'string',
|
|
74
|
-
description: 'attributes 资源:只取一个字段(主键读取)。配合 cast 可以读到派生类型才有的列,例如 DefaultValue、OptionSet。',
|
|
75
|
-
},
|
|
76
|
-
cast: {
|
|
77
|
-
type: 'string',
|
|
78
|
-
description: 'attributes 资源:把字段 cast 到派生元数据类型,例如 BooleanAttributeMetadata、PicklistAttributeMetadata、LookupAttributeMetadata、StringAttributeMetadata。只和 attribute 一起用。',
|
|
79
|
-
},
|
|
80
|
-
expand: {
|
|
81
|
-
type: 'string',
|
|
82
|
-
description: '额外的 OData $expand,例如 "plugintypeid($select=typename)"。plugin_steps 默认已展开插件类型与消息名。',
|
|
83
|
-
},
|
|
84
|
-
filter: {
|
|
85
|
-
type: 'string',
|
|
86
|
-
description: "额外的 OData $filter 表达式,不带 '$filter=',例如 \"contains(name,'审批')\"。",
|
|
87
|
-
},
|
|
88
|
-
select: {
|
|
89
|
-
type: 'string',
|
|
90
|
-
description: '额外要返回的列,逗号分隔,例如 "formxml,description"。列名写错会先自动降级重试。',
|
|
91
|
-
},
|
|
92
|
-
orderby: {
|
|
93
|
-
type: 'string',
|
|
94
|
-
description: '覆盖默认排序,例如 "modifiedon desc"。',
|
|
95
|
-
},
|
|
96
|
-
top: {
|
|
97
|
-
type: 'integer',
|
|
98
|
-
description: '最多返回多少行。省略时清单型资源(插件步骤/类型/镜像、字段)用 1000,其它用配置的 maxRecords。',
|
|
99
|
-
},
|
|
100
|
-
environments: {
|
|
101
|
-
type: 'array',
|
|
102
|
-
items: { type: 'string' },
|
|
103
|
-
description: '环境 key 列表,例如 ["dev","sit"]。省略则用默认环境。',
|
|
104
|
-
},
|
|
105
|
-
allEnvironments: {
|
|
106
|
-
type: 'boolean',
|
|
107
|
-
description: '对全部已配置环境执行同一查询并比较。',
|
|
108
|
-
},
|
|
109
|
-
save: {
|
|
110
|
-
type: 'boolean',
|
|
111
|
-
description: '强制把完整结果写入文件(默认仅在超过 12 行时写)。',
|
|
112
|
-
},
|
|
113
|
-
},
|
|
114
|
-
},
|
|
115
|
-
output: {
|
|
116
|
-
schema: {
|
|
117
|
-
type: 'object',
|
|
118
|
-
additionalProperties: false,
|
|
119
|
-
required: ['text'],
|
|
120
|
-
properties: {
|
|
121
|
-
text: { type: 'string' },
|
|
122
|
-
},
|
|
123
|
-
},
|
|
124
|
-
render(_args, value) {
|
|
125
|
-
return [{ type: 'text', text: value?.text ?? '' }]
|
|
126
|
-
},
|
|
127
|
-
},
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
/**
|
|
131
|
-
* Choose the column list for the inline table.
|
|
132
|
-
* @param resource - resource key.
|
|
133
|
-
* @param rows - already-flattened rows.
|
|
134
|
-
*/
|
|
135
|
-
function displayColumns(resource, rows, selectArg) {
|
|
136
|
-
const requested = String(selectArg ?? '')
|
|
137
|
-
.split(',')
|
|
138
|
-
.map((item) => item.trim())
|
|
139
|
-
.filter(Boolean)
|
|
140
|
-
// An explicit `select` is the caller saying which columns matter; showing the
|
|
141
|
-
// catalogue default instead is how "I asked for DefaultValue" ends up
|
|
142
|
-
// rendered as four columns that do not include it.
|
|
143
|
-
if (requested.length) {
|
|
144
|
-
const present = requested.filter((field) => rows.some((row) => row?.[field] !== undefined))
|
|
145
|
-
if (present.length) return present.slice(0, 8)
|
|
146
|
-
}
|
|
147
|
-
const preferred = DISPLAY_FIELDS[resource] ?? []
|
|
148
|
-
const present = preferred.filter((field) => rows.some((row) => row?.[field] !== undefined))
|
|
149
|
-
if (present.length >= 2) return present.slice(0, 6)
|
|
150
|
-
const first = rows[0] ?? {}
|
|
151
|
-
return Object.keys(first).filter((key) => !key.startsWith('@') && !key.endsWith('@odata.etag')).slice(0, 6)
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
/**
|
|
155
|
-
* Build the request parameters for one resource.
|
|
156
|
-
* @returns `{ path, params }`.
|
|
157
|
-
*/
|
|
158
|
-
function buildRequest(resource, args, config) {
|
|
159
|
-
const def = RESOURCES[resource]
|
|
160
|
-
if (!def) {
|
|
161
|
-
throw new D365Error(`未知的元数据类别:${resource}`, {
|
|
162
|
-
code: 'unknown-resource',
|
|
163
|
-
hint: `可用:${RESOURCE_KEYS.join(', ')}`,
|
|
164
|
-
})
|
|
165
|
-
}
|
|
166
|
-
if (def.entityScoped && !args.entity) {
|
|
167
|
-
throw new D365Error(`查询 ${resource} 需要 entity 参数`, {
|
|
168
|
-
code: 'missing-entity',
|
|
169
|
-
hint: `传入实体逻辑名,例如 {"resource":"${resource}","entity":"account"}`,
|
|
170
|
-
})
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
// A filter on an endpoint that refuses `$filter` fails at the server with a
|
|
174
|
-
// bare 405 that says nothing about what to do instead.
|
|
175
|
-
if (def.filterable === false && args.filter) {
|
|
176
|
-
throw new D365Error(`${def.summary ?? resource} 不支持 filter`, {
|
|
177
|
-
code: 'filter-unsupported',
|
|
178
|
-
hint: 'Dataverse 的 GlobalOptionSetDefinitions 不接受 $filter(返回 HTTP 405)。'
|
|
179
|
-
+ '改成按名称取:{"resource":"option_sets","name":"contoso_taxcategoryos"};要看全部就不要传 filter。',
|
|
180
|
-
})
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
const keyedByName = def.keyedByName && args.name ? def.keyedByName(args.name) : null
|
|
184
|
-
if (args.name && !keyedByName) {
|
|
185
|
-
throw new D365Error(`${def.summary ?? resource} 不支持按名称取单条`, {
|
|
186
|
-
code: 'name-unsupported',
|
|
187
|
-
hint: 'name 目前只用于 option_sets。其它资源用 filter 或 entity 缩小范围。',
|
|
188
|
-
})
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
if (args.cast && !args.attribute) {
|
|
192
|
-
throw new D365Error('cast 只能和 attribute 一起用', {
|
|
193
|
-
code: 'cast-without-attribute',
|
|
194
|
-
hint: 'cast 是把单个字段的元数据 cast 到派生类型(例如 {"resource":"attributes","entity":"account","attribute":"donotemail","cast":"BooleanAttributeMetadata"})。',
|
|
195
|
-
})
|
|
196
|
-
}
|
|
197
|
-
if (args.cast && !/^[A-Za-z]+AttributeMetadata$/.test(args.cast)) {
|
|
198
|
-
throw new D365Error(`cast 看起来不是元数据类型名:${args.cast}`, {
|
|
199
|
-
code: 'bad-cast',
|
|
200
|
-
hint: '要写完整的派生类型名,例如 BooleanAttributeMetadata、PicklistAttributeMetadata、LookupAttributeMetadata。',
|
|
201
|
-
})
|
|
202
|
-
}
|
|
203
|
-
const keyedByAttribute = def.keyedByAttribute && args.attribute ? def.keyedByAttribute(args) : null
|
|
204
|
-
if (args.attribute && !keyedByAttribute) {
|
|
205
|
-
throw new D365Error(`${def.summary ?? resource} 不支持按字段取单条`, {
|
|
206
|
-
code: 'attribute-unsupported',
|
|
207
|
-
hint: 'attribute 目前只用于 attributes(配合 entity)。',
|
|
208
|
-
})
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
const keyed = keyedByName ?? keyedByAttribute
|
|
212
|
-
|
|
213
|
-
const select = [def.select, args.select]
|
|
214
|
-
.filter(Boolean)
|
|
215
|
-
.join(',')
|
|
216
|
-
.split(',')
|
|
217
|
-
.map((item) => item.trim())
|
|
218
|
-
.filter(Boolean)
|
|
219
|
-
|
|
220
|
-
const top = Number.isFinite(args.top)
|
|
221
|
-
? Math.max(1, Math.min(5000, Math.trunc(args.top)))
|
|
222
|
-
: def.defaultTop ?? config.maxRecords
|
|
223
|
-
|
|
224
|
-
return {
|
|
225
|
-
path: keyed ?? def.path(args),
|
|
226
|
-
keyed: Boolean(keyed),
|
|
227
|
-
top,
|
|
228
|
-
params: {
|
|
229
|
-
$select: select.length ? [...new Set(select)].join(',') : undefined,
|
|
230
|
-
// A keyed read answers with one object and rejects the collection-only
|
|
231
|
-
// parameters that would otherwise be sent.
|
|
232
|
-
$filter: keyed ? undefined : composeFilter(def, args),
|
|
233
|
-
$orderby: keyed ? undefined : (args.orderby ?? def.orderby),
|
|
234
|
-
$expand: keyed ? undefined : (args.expand ?? def.expand),
|
|
235
|
-
},
|
|
236
|
-
}
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
/**
|
|
240
|
-
* Count the rows a request would return, without pulling them.
|
|
241
|
-
*
|
|
242
|
-
* A digest that says "200 rows" next to a truncated 5000-row table reads as
|
|
243
|
-
* "there are 200"; the number that matters is how many were *not* shown.
|
|
244
|
-
* @returns the total, or undefined when the endpoint refuses `$count`.
|
|
245
|
-
*/
|
|
246
|
-
async function countMatching(client, request, signal) {
|
|
247
|
-
try {
|
|
248
|
-
const url = client.buildUrl(request.path, {
|
|
249
|
-
$filter: request.params.$filter,
|
|
250
|
-
$count: 'true',
|
|
251
|
-
$top: 1,
|
|
252
|
-
})
|
|
253
|
-
const page = await client.requestJson(url, { signal })
|
|
254
|
-
const total = page?.['@odata.count']
|
|
255
|
-
return typeof total === 'number' ? total : undefined
|
|
256
|
-
} catch {
|
|
257
|
-
return undefined
|
|
258
|
-
}
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
/** Lift the `$expand`ed lookups into flat, displayable columns. */
|
|
262
|
-
function flattenExpanded(row) {
|
|
263
|
-
if (!row || typeof row !== 'object') return row
|
|
264
|
-
const expanded = {}
|
|
265
|
-
if (row.plugintypeid && typeof row.plugintypeid === 'object') expanded.plugintype = row.plugintypeid.typename
|
|
266
|
-
if (row.sdkmessageid && typeof row.sdkmessageid === 'object') expanded.message = row.sdkmessageid.name
|
|
267
|
-
return Object.keys(expanded).length ? { ...row, ...expanded } : row
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
/** The property name from "Could not find a property named 'x' on type 'y'". */
|
|
271
|
-
function missingProperty(message) {
|
|
272
|
-
const match = /property named '([^']+)'/.exec(String(message ?? ''))
|
|
273
|
-
return match ? match[1] : undefined
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
/** Query one environment and return rows plus provenance. */
|
|
277
|
-
async function queryEnvironment(env, config, args, signal) {
|
|
278
|
-
const request = buildRequest(args.resource, args, config)
|
|
279
|
-
const client = makeClient(env, config)
|
|
280
|
-
const started = Date.now()
|
|
281
|
-
let selectDropped = false
|
|
282
|
-
let page
|
|
283
|
-
if (request.keyed) {
|
|
284
|
-
// Keyed metadata reads answer with a bare object (no `value` wrapper) and
|
|
285
|
-
// 404 when the record is simply absent here, which is a normal answer.
|
|
286
|
-
let params = request.params
|
|
287
|
-
for (let attempt = 0; ; attempt += 1) {
|
|
288
|
-
try {
|
|
289
|
-
const row = await client.requestJson(client.buildUrl(request.path, params), { signal })
|
|
290
|
-
page = { records: row ? [row] : [], hasMore: false }
|
|
291
|
-
break
|
|
292
|
-
} catch (error) {
|
|
293
|
-
if (error?.status === 404) {
|
|
294
|
-
page = { records: [], hasMore: false }
|
|
295
|
-
break
|
|
296
|
-
}
|
|
297
|
-
const missing = error?.status === 400 && missingProperty(error.message)
|
|
298
|
-
if (!missing || attempt >= 4) throw error
|
|
299
|
-
const kept = String(params.$select ?? '').split(',').filter((column) => column.trim() && column.trim() !== missing)
|
|
300
|
-
params = { ...params, $select: kept.length ? kept.join(',') : undefined }
|
|
301
|
-
selectDropped = true
|
|
302
|
-
}
|
|
303
|
-
}
|
|
304
|
-
} else {
|
|
305
|
-
const paged = await getPagedWithSelectFallback(client, request.path, request.params, request.top, signal)
|
|
306
|
-
page = paged.page
|
|
307
|
-
selectDropped = paged.selectDropped
|
|
308
|
-
}
|
|
309
|
-
const rows = page.records.map((row) => flattenExpanded(flattenLabels(row)))
|
|
310
|
-
// The organization table has no portable `version` column; take it from the
|
|
311
|
-
// supported RetrieveVersion action so the resource still reports it.
|
|
312
|
-
if (args.resource === 'organization' && rows.length) {
|
|
313
|
-
const version = await client.readVersion(signal)
|
|
314
|
-
if (version) rows[0] = { ...rows[0], version }
|
|
315
|
-
}
|
|
316
|
-
const total = page.hasMore ? await countMatching(client, request, signal) : rows.length
|
|
317
|
-
return {
|
|
318
|
-
env,
|
|
319
|
-
rows,
|
|
320
|
-
total,
|
|
321
|
-
hasMore: page.hasMore,
|
|
322
|
-
limit: request.top,
|
|
323
|
-
selectDropped,
|
|
324
|
-
requests: client.counters.requests,
|
|
325
|
-
elapsedMs: Date.now() - started,
|
|
326
|
-
filter: request.params.$filter,
|
|
327
|
-
}
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
export function register(ctx, state) {
|
|
331
|
-
return ctx.tools.register({
|
|
332
|
-
...definition,
|
|
333
|
-
async execute(args = {}, exec) {
|
|
334
|
-
const config = await loadConfig(state.rawConfig)
|
|
335
|
-
const environments = pickEnvironments(config, args)
|
|
336
|
-
const resource = args.resource
|
|
337
|
-
const def = RESOURCES[resource]
|
|
338
|
-
|
|
339
|
-
const results = []
|
|
340
|
-
for (const env of environments) {
|
|
341
|
-
results.push(await queryEnvironment(env, config, args, exec?.signal))
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
const lines = []
|
|
345
|
-
lines.push(`查询 ${def?.summary ?? resource}:${environments.map((env) => env.key).join(' / ')}`)
|
|
346
|
-
if (args.entity) lines.push(`实体:${args.entity}`)
|
|
347
|
-
if (args.name) lines.push(`按名称:${args.name}`)
|
|
348
|
-
const filterText = results.find((item) => item.filter)?.filter
|
|
349
|
-
if (filterText) lines.push(`过滤:${filterText}`)
|
|
350
|
-
if (def?.defaultFilter && !args.filter) {
|
|
351
|
-
lines.push(`说明:默认只列定义行(${def.defaultFilter})。Dataverse 给每个流程另存一条激活行(type 2,workflowid 不同、名称相同),`
|
|
352
|
-
+ '不过滤会看起来像重复、计数也会翻倍;要看激活行请显式传 filter,例如 "type eq 2"。')
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
for (const result of results) {
|
|
356
|
-
lines.push('')
|
|
357
|
-
const totalText = result.hasMore && typeof result.total === 'number'
|
|
358
|
-
? `已列 ${result.rows.length} 条 / 共 ${result.total >= 5000 ? '≥5000' : result.total} 条(未列全,加 filter 或 top 缩小)`
|
|
359
|
-
: `${result.rows.length} 行${result.hasMore ? `(已达上限 ${result.limit},还有更多;加 filter 或 top 缩小)` : ''}`
|
|
360
|
-
lines.push(`### ${result.env.key} — ${totalText}`)
|
|
361
|
-
if (!result.rows.length) {
|
|
362
|
-
lines.push(args.name
|
|
363
|
-
? `该环境没有名为 ${args.name} 的${def?.summary ?? resource}(按名称直取返回 404)。`
|
|
364
|
-
: '该环境未返回任何记录。可能确实不存在,也可能是 filter 过窄——先用不带 filter 的查询确认。')
|
|
365
|
-
continue
|
|
366
|
-
}
|
|
367
|
-
const columns = displayColumns(resource, result.rows, args.select)
|
|
368
|
-
lines.push(formatTable(result.rows, columns, { limit: INLINE_ROWS }))
|
|
369
|
-
if (result.selectDropped) lines.push('(该环境不接受部分 $select 列,已自动去掉后重试,故列数可能偏少)')
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
const comparison = compareEnvironments(resource, results.map((result) => ({ env: result.env, rows: result.rows })))
|
|
373
|
-
if (comparison) {
|
|
374
|
-
lines.push('')
|
|
375
|
-
lines.push('### 跨环境对比')
|
|
376
|
-
lines.push(...comparison.lines)
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
const totalRows = results.reduce((sum, result) => sum + result.rows.length, 0)
|
|
380
|
-
const shouldSave = args.save === true || totalRows > INLINE_ROWS
|
|
381
|
-
if (shouldSave) {
|
|
382
|
-
const path = await writeArtifact(config, `${resource}-${environments.map((env) => env.key).join('-')}`, {
|
|
383
|
-
resource,
|
|
384
|
-
args,
|
|
385
|
-
generatedAt: new Date().toISOString(),
|
|
386
|
-
environments: results.map((result) => ({
|
|
387
|
-
env: result.env.key,
|
|
388
|
-
url: result.env.url,
|
|
389
|
-
count: result.rows.length,
|
|
390
|
-
total: result.total,
|
|
391
|
-
hasMore: result.hasMore,
|
|
392
|
-
requests: result.requests,
|
|
393
|
-
elapsedMs: result.elapsedMs,
|
|
394
|
-
filter: result.filter,
|
|
395
|
-
rows: result.rows,
|
|
396
|
-
})),
|
|
397
|
-
comparison: comparison ? { changed: comparison.changed, onlyIn: [...comparison.onlyIn.values()] } : undefined,
|
|
398
|
-
})
|
|
399
|
-
lines.push('')
|
|
400
|
-
lines.push(`完整结果:${path}`)
|
|
401
|
-
}
|
|
402
|
-
|
|
403
|
-
return { text: lines.join('\n') }
|
|
404
|
-
},
|
|
405
|
-
presentCall(args = {}) {
|
|
406
|
-
const envs = args.allEnvironments
|
|
407
|
-
? '全部环境'
|
|
408
|
-
: [...(args.environments ?? []), args.environment].filter(Boolean).join('/') || '默认环境'
|
|
409
|
-
return {
|
|
410
|
-
card: 'generic',
|
|
411
|
-
title: `查询 ${args.resource}${args.entity ? `:${args.entity}` : ''}(${envs})`,
|
|
412
|
-
kind: 'other',
|
|
413
|
-
rawInput: args,
|
|
414
|
-
}
|
|
415
|
-
},
|
|
416
|
-
})
|
|
417
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* `d365_metadata` — the generic, read-only metadata query.
|
|
3
|
+
*
|
|
4
|
+
* One tool over a fixed catalogue beats one tool per table: the catalogue is
|
|
5
|
+
* small enough for a model to read, the arguments are the same shape for every
|
|
6
|
+
* resource, and adding a table later is a data change, not a new tool.
|
|
7
|
+
*
|
|
8
|
+
* When several environments are named in one call, the result includes a
|
|
9
|
+
* cross-environment comparison, because "does sit have it" is the common
|
|
10
|
+
* question and running two calls by hand loses the alignment.
|
|
11
|
+
*/
|
|
12
|
+
import { loadConfig } from './config.js'
|
|
13
|
+
import {
|
|
14
|
+
INLINE_ROWS,
|
|
15
|
+
compareEnvironments,
|
|
16
|
+
formatTable,
|
|
17
|
+
makeClient,
|
|
18
|
+
pickEnvironments,
|
|
19
|
+
writeArtifact,
|
|
20
|
+
} from './runtime.js'
|
|
21
|
+
import { RESOURCES, RESOURCE_KEYS, composeFilter, flattenLabels, odataString } from './resources.js'
|
|
22
|
+
import { getPagedWithSelectFallback } from './client.js'
|
|
23
|
+
import { D365Error } from './errors.js'
|
|
24
|
+
|
|
25
|
+
/** Columns shown in the inline digest, per resource. Missing ones are skipped. */
|
|
26
|
+
const DISPLAY_FIELDS = {
|
|
27
|
+
organization: ['name', 'version', 'organizationstructure', 'url'],
|
|
28
|
+
entities: ['LogicalName', 'EntitySetName', 'IsCustomEntity', 'ObjectTypeCode'],
|
|
29
|
+
attributes: ['LogicalName', 'AttributeType', 'IsCustomAttribute', 'RequiredLevel'],
|
|
30
|
+
keys: ['SchemaName', 'LogicalName'],
|
|
31
|
+
one_to_many_relationships: ['SchemaName', 'ReferencingEntity', 'ReferencedEntity'],
|
|
32
|
+
many_to_many_relationships: ['SchemaName', 'Entity1LogicalName', 'Entity2LogicalName'],
|
|
33
|
+
forms: ['name', 'type', 'objecttypecode', 'ismanaged', 'modifiedon'],
|
|
34
|
+
views: ['name', 'querytype', 'isdefault', 'returnedtypecode'],
|
|
35
|
+
workflows: ['name', 'type', 'category', 'statecode', 'primaryentity', 'modifiedon'],
|
|
36
|
+
business_rules: ['name', 'type', 'statecode', 'primaryentity', 'modifiedon'],
|
|
37
|
+
plugin_assemblies: ['name', 'version', 'isolationmode', 'sourcetype'],
|
|
38
|
+
plugin_types: ['typename', 'friendlyname'],
|
|
39
|
+
plugin_steps: ['message', 'plugintype', 'stage', 'mode', 'rank', 'filteringattributes'],
|
|
40
|
+
step_images: ['name', 'imagetype', 'entityalias', 'attributes', 'ismanaged'],
|
|
41
|
+
web_resources: ['name', 'webresourcetype', 'ismanaged', 'modifiedon'],
|
|
42
|
+
solutions: ['uniquename', 'version', 'ismanaged', 'installedon'],
|
|
43
|
+
solution_components: ['componenttype', 'objectid', 'solutionid'],
|
|
44
|
+
custom_apis: ['uniquename', 'boundentitylogicalname', 'isfunction'],
|
|
45
|
+
sitemaps: ['sitemapname', 'componentstate'],
|
|
46
|
+
option_sets: ['Name', 'OptionSetType', 'IsCustomOptionSet'],
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export const definition = {
|
|
50
|
+
name: 'd365_metadata',
|
|
51
|
+
description:
|
|
52
|
+
'以只读方式查询 D365 CE / Dataverse 元数据(实体、字段、表单、视图、工作流、插件、Web 资源、解决方案等)。'
|
|
53
|
+
+ '可一次指定多个环境,自动按名称对齐并给出跨环境差异。返回摘要与完整结果文件路径。',
|
|
54
|
+
parameters: {
|
|
55
|
+
type: 'object',
|
|
56
|
+
additionalProperties: false,
|
|
57
|
+
required: ['resource'],
|
|
58
|
+
properties: {
|
|
59
|
+
resource: {
|
|
60
|
+
type: 'string',
|
|
61
|
+
enum: RESOURCE_KEYS,
|
|
62
|
+
description: '要查询的元数据类别。选错时会返回完整可选列表。',
|
|
63
|
+
},
|
|
64
|
+
entity: {
|
|
65
|
+
type: 'string',
|
|
66
|
+
description: '实体逻辑名(如 account、new_order),用于 attributes/keys/relationships/forms/views/workflows 等按实体取数的资源。',
|
|
67
|
+
},
|
|
68
|
+
name: {
|
|
69
|
+
type: 'string',
|
|
70
|
+
description: '按名称精确取一条记录(目前只有 option_sets 支持:直接按全局选项集名取,一次请求,不用把 900 多个定义都拉下来)。',
|
|
71
|
+
},
|
|
72
|
+
attribute: {
|
|
73
|
+
type: 'string',
|
|
74
|
+
description: 'attributes 资源:只取一个字段(主键读取)。配合 cast 可以读到派生类型才有的列,例如 DefaultValue、OptionSet。',
|
|
75
|
+
},
|
|
76
|
+
cast: {
|
|
77
|
+
type: 'string',
|
|
78
|
+
description: 'attributes 资源:把字段 cast 到派生元数据类型,例如 BooleanAttributeMetadata、PicklistAttributeMetadata、LookupAttributeMetadata、StringAttributeMetadata。只和 attribute 一起用。',
|
|
79
|
+
},
|
|
80
|
+
expand: {
|
|
81
|
+
type: 'string',
|
|
82
|
+
description: '额外的 OData $expand,例如 "plugintypeid($select=typename)"。plugin_steps 默认已展开插件类型与消息名。',
|
|
83
|
+
},
|
|
84
|
+
filter: {
|
|
85
|
+
type: 'string',
|
|
86
|
+
description: "额外的 OData $filter 表达式,不带 '$filter=',例如 \"contains(name,'审批')\"。",
|
|
87
|
+
},
|
|
88
|
+
select: {
|
|
89
|
+
type: 'string',
|
|
90
|
+
description: '额外要返回的列,逗号分隔,例如 "formxml,description"。列名写错会先自动降级重试。',
|
|
91
|
+
},
|
|
92
|
+
orderby: {
|
|
93
|
+
type: 'string',
|
|
94
|
+
description: '覆盖默认排序,例如 "modifiedon desc"。',
|
|
95
|
+
},
|
|
96
|
+
top: {
|
|
97
|
+
type: 'integer',
|
|
98
|
+
description: '最多返回多少行。省略时清单型资源(插件步骤/类型/镜像、字段)用 1000,其它用配置的 maxRecords。',
|
|
99
|
+
},
|
|
100
|
+
environments: {
|
|
101
|
+
type: 'array',
|
|
102
|
+
items: { type: 'string' },
|
|
103
|
+
description: '环境 key 列表,例如 ["dev","sit"]。省略则用默认环境。',
|
|
104
|
+
},
|
|
105
|
+
allEnvironments: {
|
|
106
|
+
type: 'boolean',
|
|
107
|
+
description: '对全部已配置环境执行同一查询并比较。',
|
|
108
|
+
},
|
|
109
|
+
save: {
|
|
110
|
+
type: 'boolean',
|
|
111
|
+
description: '强制把完整结果写入文件(默认仅在超过 12 行时写)。',
|
|
112
|
+
},
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
output: {
|
|
116
|
+
schema: {
|
|
117
|
+
type: 'object',
|
|
118
|
+
additionalProperties: false,
|
|
119
|
+
required: ['text'],
|
|
120
|
+
properties: {
|
|
121
|
+
text: { type: 'string' },
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
render(_args, value) {
|
|
125
|
+
return [{ type: 'text', text: value?.text ?? '' }]
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Choose the column list for the inline table.
|
|
132
|
+
* @param resource - resource key.
|
|
133
|
+
* @param rows - already-flattened rows.
|
|
134
|
+
*/
|
|
135
|
+
function displayColumns(resource, rows, selectArg) {
|
|
136
|
+
const requested = String(selectArg ?? '')
|
|
137
|
+
.split(',')
|
|
138
|
+
.map((item) => item.trim())
|
|
139
|
+
.filter(Boolean)
|
|
140
|
+
// An explicit `select` is the caller saying which columns matter; showing the
|
|
141
|
+
// catalogue default instead is how "I asked for DefaultValue" ends up
|
|
142
|
+
// rendered as four columns that do not include it.
|
|
143
|
+
if (requested.length) {
|
|
144
|
+
const present = requested.filter((field) => rows.some((row) => row?.[field] !== undefined))
|
|
145
|
+
if (present.length) return present.slice(0, 8)
|
|
146
|
+
}
|
|
147
|
+
const preferred = DISPLAY_FIELDS[resource] ?? []
|
|
148
|
+
const present = preferred.filter((field) => rows.some((row) => row?.[field] !== undefined))
|
|
149
|
+
if (present.length >= 2) return present.slice(0, 6)
|
|
150
|
+
const first = rows[0] ?? {}
|
|
151
|
+
return Object.keys(first).filter((key) => !key.startsWith('@') && !key.endsWith('@odata.etag')).slice(0, 6)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Build the request parameters for one resource.
|
|
156
|
+
* @returns `{ path, params }`.
|
|
157
|
+
*/
|
|
158
|
+
function buildRequest(resource, args, config) {
|
|
159
|
+
const def = RESOURCES[resource]
|
|
160
|
+
if (!def) {
|
|
161
|
+
throw new D365Error(`未知的元数据类别:${resource}`, {
|
|
162
|
+
code: 'unknown-resource',
|
|
163
|
+
hint: `可用:${RESOURCE_KEYS.join(', ')}`,
|
|
164
|
+
})
|
|
165
|
+
}
|
|
166
|
+
if (def.entityScoped && !args.entity) {
|
|
167
|
+
throw new D365Error(`查询 ${resource} 需要 entity 参数`, {
|
|
168
|
+
code: 'missing-entity',
|
|
169
|
+
hint: `传入实体逻辑名,例如 {"resource":"${resource}","entity":"account"}`,
|
|
170
|
+
})
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// A filter on an endpoint that refuses `$filter` fails at the server with a
|
|
174
|
+
// bare 405 that says nothing about what to do instead.
|
|
175
|
+
if (def.filterable === false && args.filter) {
|
|
176
|
+
throw new D365Error(`${def.summary ?? resource} 不支持 filter`, {
|
|
177
|
+
code: 'filter-unsupported',
|
|
178
|
+
hint: 'Dataverse 的 GlobalOptionSetDefinitions 不接受 $filter(返回 HTTP 405)。'
|
|
179
|
+
+ '改成按名称取:{"resource":"option_sets","name":"contoso_taxcategoryos"};要看全部就不要传 filter。',
|
|
180
|
+
})
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const keyedByName = def.keyedByName && args.name ? def.keyedByName(args.name) : null
|
|
184
|
+
if (args.name && !keyedByName) {
|
|
185
|
+
throw new D365Error(`${def.summary ?? resource} 不支持按名称取单条`, {
|
|
186
|
+
code: 'name-unsupported',
|
|
187
|
+
hint: 'name 目前只用于 option_sets。其它资源用 filter 或 entity 缩小范围。',
|
|
188
|
+
})
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (args.cast && !args.attribute) {
|
|
192
|
+
throw new D365Error('cast 只能和 attribute 一起用', {
|
|
193
|
+
code: 'cast-without-attribute',
|
|
194
|
+
hint: 'cast 是把单个字段的元数据 cast 到派生类型(例如 {"resource":"attributes","entity":"account","attribute":"donotemail","cast":"BooleanAttributeMetadata"})。',
|
|
195
|
+
})
|
|
196
|
+
}
|
|
197
|
+
if (args.cast && !/^[A-Za-z]+AttributeMetadata$/.test(args.cast)) {
|
|
198
|
+
throw new D365Error(`cast 看起来不是元数据类型名:${args.cast}`, {
|
|
199
|
+
code: 'bad-cast',
|
|
200
|
+
hint: '要写完整的派生类型名,例如 BooleanAttributeMetadata、PicklistAttributeMetadata、LookupAttributeMetadata。',
|
|
201
|
+
})
|
|
202
|
+
}
|
|
203
|
+
const keyedByAttribute = def.keyedByAttribute && args.attribute ? def.keyedByAttribute(args) : null
|
|
204
|
+
if (args.attribute && !keyedByAttribute) {
|
|
205
|
+
throw new D365Error(`${def.summary ?? resource} 不支持按字段取单条`, {
|
|
206
|
+
code: 'attribute-unsupported',
|
|
207
|
+
hint: 'attribute 目前只用于 attributes(配合 entity)。',
|
|
208
|
+
})
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const keyed = keyedByName ?? keyedByAttribute
|
|
212
|
+
|
|
213
|
+
const select = [def.select, args.select]
|
|
214
|
+
.filter(Boolean)
|
|
215
|
+
.join(',')
|
|
216
|
+
.split(',')
|
|
217
|
+
.map((item) => item.trim())
|
|
218
|
+
.filter(Boolean)
|
|
219
|
+
|
|
220
|
+
const top = Number.isFinite(args.top)
|
|
221
|
+
? Math.max(1, Math.min(5000, Math.trunc(args.top)))
|
|
222
|
+
: def.defaultTop ?? config.maxRecords
|
|
223
|
+
|
|
224
|
+
return {
|
|
225
|
+
path: keyed ?? def.path(args),
|
|
226
|
+
keyed: Boolean(keyed),
|
|
227
|
+
top,
|
|
228
|
+
params: {
|
|
229
|
+
$select: select.length ? [...new Set(select)].join(',') : undefined,
|
|
230
|
+
// A keyed read answers with one object and rejects the collection-only
|
|
231
|
+
// parameters that would otherwise be sent.
|
|
232
|
+
$filter: keyed ? undefined : composeFilter(def, args),
|
|
233
|
+
$orderby: keyed ? undefined : (args.orderby ?? def.orderby),
|
|
234
|
+
$expand: keyed ? undefined : (args.expand ?? def.expand),
|
|
235
|
+
},
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Count the rows a request would return, without pulling them.
|
|
241
|
+
*
|
|
242
|
+
* A digest that says "200 rows" next to a truncated 5000-row table reads as
|
|
243
|
+
* "there are 200"; the number that matters is how many were *not* shown.
|
|
244
|
+
* @returns the total, or undefined when the endpoint refuses `$count`.
|
|
245
|
+
*/
|
|
246
|
+
async function countMatching(client, request, signal) {
|
|
247
|
+
try {
|
|
248
|
+
const url = client.buildUrl(request.path, {
|
|
249
|
+
$filter: request.params.$filter,
|
|
250
|
+
$count: 'true',
|
|
251
|
+
$top: 1,
|
|
252
|
+
})
|
|
253
|
+
const page = await client.requestJson(url, { signal })
|
|
254
|
+
const total = page?.['@odata.count']
|
|
255
|
+
return typeof total === 'number' ? total : undefined
|
|
256
|
+
} catch {
|
|
257
|
+
return undefined
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** Lift the `$expand`ed lookups into flat, displayable columns. */
|
|
262
|
+
function flattenExpanded(row) {
|
|
263
|
+
if (!row || typeof row !== 'object') return row
|
|
264
|
+
const expanded = {}
|
|
265
|
+
if (row.plugintypeid && typeof row.plugintypeid === 'object') expanded.plugintype = row.plugintypeid.typename
|
|
266
|
+
if (row.sdkmessageid && typeof row.sdkmessageid === 'object') expanded.message = row.sdkmessageid.name
|
|
267
|
+
return Object.keys(expanded).length ? { ...row, ...expanded } : row
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** The property name from "Could not find a property named 'x' on type 'y'". */
|
|
271
|
+
function missingProperty(message) {
|
|
272
|
+
const match = /property named '([^']+)'/.exec(String(message ?? ''))
|
|
273
|
+
return match ? match[1] : undefined
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** Query one environment and return rows plus provenance. */
|
|
277
|
+
async function queryEnvironment(env, config, args, signal) {
|
|
278
|
+
const request = buildRequest(args.resource, args, config)
|
|
279
|
+
const client = makeClient(env, config)
|
|
280
|
+
const started = Date.now()
|
|
281
|
+
let selectDropped = false
|
|
282
|
+
let page
|
|
283
|
+
if (request.keyed) {
|
|
284
|
+
// Keyed metadata reads answer with a bare object (no `value` wrapper) and
|
|
285
|
+
// 404 when the record is simply absent here, which is a normal answer.
|
|
286
|
+
let params = request.params
|
|
287
|
+
for (let attempt = 0; ; attempt += 1) {
|
|
288
|
+
try {
|
|
289
|
+
const row = await client.requestJson(client.buildUrl(request.path, params), { signal })
|
|
290
|
+
page = { records: row ? [row] : [], hasMore: false }
|
|
291
|
+
break
|
|
292
|
+
} catch (error) {
|
|
293
|
+
if (error?.status === 404) {
|
|
294
|
+
page = { records: [], hasMore: false }
|
|
295
|
+
break
|
|
296
|
+
}
|
|
297
|
+
const missing = error?.status === 400 && missingProperty(error.message)
|
|
298
|
+
if (!missing || attempt >= 4) throw error
|
|
299
|
+
const kept = String(params.$select ?? '').split(',').filter((column) => column.trim() && column.trim() !== missing)
|
|
300
|
+
params = { ...params, $select: kept.length ? kept.join(',') : undefined }
|
|
301
|
+
selectDropped = true
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
} else {
|
|
305
|
+
const paged = await getPagedWithSelectFallback(client, request.path, request.params, request.top, signal)
|
|
306
|
+
page = paged.page
|
|
307
|
+
selectDropped = paged.selectDropped
|
|
308
|
+
}
|
|
309
|
+
const rows = page.records.map((row) => flattenExpanded(flattenLabels(row)))
|
|
310
|
+
// The organization table has no portable `version` column; take it from the
|
|
311
|
+
// supported RetrieveVersion action so the resource still reports it.
|
|
312
|
+
if (args.resource === 'organization' && rows.length) {
|
|
313
|
+
const version = await client.readVersion(signal)
|
|
314
|
+
if (version) rows[0] = { ...rows[0], version }
|
|
315
|
+
}
|
|
316
|
+
const total = page.hasMore ? await countMatching(client, request, signal) : rows.length
|
|
317
|
+
return {
|
|
318
|
+
env,
|
|
319
|
+
rows,
|
|
320
|
+
total,
|
|
321
|
+
hasMore: page.hasMore,
|
|
322
|
+
limit: request.top,
|
|
323
|
+
selectDropped,
|
|
324
|
+
requests: client.counters.requests,
|
|
325
|
+
elapsedMs: Date.now() - started,
|
|
326
|
+
filter: request.params.$filter,
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
export function register(ctx, state) {
|
|
331
|
+
return ctx.tools.register({
|
|
332
|
+
...definition,
|
|
333
|
+
async execute(args = {}, exec) {
|
|
334
|
+
const config = await loadConfig(state.rawConfig)
|
|
335
|
+
const environments = pickEnvironments(config, args)
|
|
336
|
+
const resource = args.resource
|
|
337
|
+
const def = RESOURCES[resource]
|
|
338
|
+
|
|
339
|
+
const results = []
|
|
340
|
+
for (const env of environments) {
|
|
341
|
+
results.push(await queryEnvironment(env, config, args, exec?.signal))
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const lines = []
|
|
345
|
+
lines.push(`查询 ${def?.summary ?? resource}:${environments.map((env) => env.key).join(' / ')}`)
|
|
346
|
+
if (args.entity) lines.push(`实体:${args.entity}`)
|
|
347
|
+
if (args.name) lines.push(`按名称:${args.name}`)
|
|
348
|
+
const filterText = results.find((item) => item.filter)?.filter
|
|
349
|
+
if (filterText) lines.push(`过滤:${filterText}`)
|
|
350
|
+
if (def?.defaultFilter && !args.filter) {
|
|
351
|
+
lines.push(`说明:默认只列定义行(${def.defaultFilter})。Dataverse 给每个流程另存一条激活行(type 2,workflowid 不同、名称相同),`
|
|
352
|
+
+ '不过滤会看起来像重复、计数也会翻倍;要看激活行请显式传 filter,例如 "type eq 2"。')
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
for (const result of results) {
|
|
356
|
+
lines.push('')
|
|
357
|
+
const totalText = result.hasMore && typeof result.total === 'number'
|
|
358
|
+
? `已列 ${result.rows.length} 条 / 共 ${result.total >= 5000 ? '≥5000' : result.total} 条(未列全,加 filter 或 top 缩小)`
|
|
359
|
+
: `${result.rows.length} 行${result.hasMore ? `(已达上限 ${result.limit},还有更多;加 filter 或 top 缩小)` : ''}`
|
|
360
|
+
lines.push(`### ${result.env.key} — ${totalText}`)
|
|
361
|
+
if (!result.rows.length) {
|
|
362
|
+
lines.push(args.name
|
|
363
|
+
? `该环境没有名为 ${args.name} 的${def?.summary ?? resource}(按名称直取返回 404)。`
|
|
364
|
+
: '该环境未返回任何记录。可能确实不存在,也可能是 filter 过窄——先用不带 filter 的查询确认。')
|
|
365
|
+
continue
|
|
366
|
+
}
|
|
367
|
+
const columns = displayColumns(resource, result.rows, args.select)
|
|
368
|
+
lines.push(formatTable(result.rows, columns, { limit: INLINE_ROWS }))
|
|
369
|
+
if (result.selectDropped) lines.push('(该环境不接受部分 $select 列,已自动去掉后重试,故列数可能偏少)')
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const comparison = compareEnvironments(resource, results.map((result) => ({ env: result.env, rows: result.rows })))
|
|
373
|
+
if (comparison) {
|
|
374
|
+
lines.push('')
|
|
375
|
+
lines.push('### 跨环境对比')
|
|
376
|
+
lines.push(...comparison.lines)
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
const totalRows = results.reduce((sum, result) => sum + result.rows.length, 0)
|
|
380
|
+
const shouldSave = args.save === true || totalRows > INLINE_ROWS
|
|
381
|
+
if (shouldSave) {
|
|
382
|
+
const path = await writeArtifact(config, `${resource}-${environments.map((env) => env.key).join('-')}`, {
|
|
383
|
+
resource,
|
|
384
|
+
args,
|
|
385
|
+
generatedAt: new Date().toISOString(),
|
|
386
|
+
environments: results.map((result) => ({
|
|
387
|
+
env: result.env.key,
|
|
388
|
+
url: result.env.url,
|
|
389
|
+
count: result.rows.length,
|
|
390
|
+
total: result.total,
|
|
391
|
+
hasMore: result.hasMore,
|
|
392
|
+
requests: result.requests,
|
|
393
|
+
elapsedMs: result.elapsedMs,
|
|
394
|
+
filter: result.filter,
|
|
395
|
+
rows: result.rows,
|
|
396
|
+
})),
|
|
397
|
+
comparison: comparison ? { changed: comparison.changed, onlyIn: [...comparison.onlyIn.values()] } : undefined,
|
|
398
|
+
})
|
|
399
|
+
lines.push('')
|
|
400
|
+
lines.push(`完整结果:${path}`)
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
return { text: lines.join('\n') }
|
|
404
|
+
},
|
|
405
|
+
presentCall(args = {}) {
|
|
406
|
+
const envs = args.allEnvironments
|
|
407
|
+
? '全部环境'
|
|
408
|
+
: [...(args.environments ?? []), args.environment].filter(Boolean).join('/') || '默认环境'
|
|
409
|
+
return {
|
|
410
|
+
card: 'generic',
|
|
411
|
+
title: `查询 ${args.resource}${args.entity ? `:${args.entity}` : ''}(${envs})`,
|
|
412
|
+
kind: 'other',
|
|
413
|
+
rawInput: args,
|
|
414
|
+
}
|
|
415
|
+
},
|
|
416
|
+
})
|
|
417
|
+
}
|