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/client.js
CHANGED
|
@@ -1,496 +1,496 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Dataverse Web API client.
|
|
3
|
-
*
|
|
4
|
-
* Every request is a GET or write to one environment. Reads are split in two:
|
|
5
|
-
* metadata and definition tables need the `metadata.read` capability, business
|
|
6
|
-
* records need `data.read`. The check lives here, at the single choke point, so
|
|
7
|
-
* a new tool cannot accidentally bypass it.
|
|
8
|
-
*/
|
|
9
|
-
import { getAccessToken, forgetToken } from './auth.js'
|
|
10
|
-
import { D365Error, describeDataverseError, httpErrorHint } from './errors.js'
|
|
11
|
-
import { errorCodeFromBody } from './error-codes.js'
|
|
12
|
-
|
|
13
|
-
const API_VERSION = 'v9.2'
|
|
14
|
-
const RETRYABLE_STATUS = new Set([429, 500, 502, 503, 504])
|
|
15
|
-
/** Methods the server treats as read-only, so repeating them cannot write twice. */
|
|
16
|
-
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS'])
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* Entity sets and endpoints that only expose metadata or deployment state.
|
|
20
|
-
* Anything not listed here is treated as business data.
|
|
21
|
-
*/
|
|
22
|
-
const METADATA_READ_PREFIXES = [
|
|
23
|
-
'entitydefinitions',
|
|
24
|
-
'relationshipdefinitions',
|
|
25
|
-
'globaloptionsets',
|
|
26
|
-
'globaloptionsetdefinitions',
|
|
27
|
-
'solutions',
|
|
28
|
-
'solutioncomponents',
|
|
29
|
-
'publishers',
|
|
30
|
-
'sdkmessageprocessingsteps',
|
|
31
|
-
'sdkmessageprocessingstepimages',
|
|
32
|
-
'sdkmessages',
|
|
33
|
-
'pluginassemblies',
|
|
34
|
-
'plugintypes',
|
|
35
|
-
'webresourceset',
|
|
36
|
-
'systemforms',
|
|
37
|
-
'savedqueries',
|
|
38
|
-
'workflows',
|
|
39
|
-
'customapis',
|
|
40
|
-
'sitemaps',
|
|
41
|
-
'organizations',
|
|
42
|
-
'msdyn_componentlayers',
|
|
43
|
-
'msdyn_solutionhistories',
|
|
44
|
-
'retrieveversion',
|
|
45
|
-
'$metadata',
|
|
46
|
-
]
|
|
47
|
-
|
|
48
|
-
/**
|
|
49
|
-
* Action endpoints that change metadata or deployment state rather than
|
|
50
|
-
* business records. They are allowed through the `data.write` guard; the
|
|
51
|
-
* metadata capabilities are enforced separately by `d365_write`.
|
|
52
|
-
*/
|
|
53
|
-
const METADATA_WRITE_SEGMENTS = [
|
|
54
|
-
'publishxml',
|
|
55
|
-
'publishallxml',
|
|
56
|
-
'insertoptionvalue',
|
|
57
|
-
'updateoptionvalue',
|
|
58
|
-
'deleteoptionvalue',
|
|
59
|
-
'createattribute',
|
|
60
|
-
'updateattribute',
|
|
61
|
-
'deleteattribute',
|
|
62
|
-
'removeactivecustomizations',
|
|
63
|
-
'updateentity',
|
|
64
|
-
'createentity',
|
|
65
|
-
'deleteentity',
|
|
66
|
-
'addsolutioncomponent',
|
|
67
|
-
'removesolutioncomponent',
|
|
68
|
-
]
|
|
69
|
-
|
|
70
|
-
let dataReadResolver = async () => false
|
|
71
|
-
let dataWriteResolver = async () => false
|
|
72
|
-
let metadataReadResolver = async () => true
|
|
73
|
-
let capabilityResolver = async () => false
|
|
74
|
-
|
|
75
|
-
/**
|
|
76
|
-
* Install the generic `(environment, capability) => boolean` decision function.
|
|
77
|
-
*
|
|
78
|
-
* Only `d365_invoke` uses it, and only for the capability it names explicitly on
|
|
79
|
-
* the request: everything else keeps the three narrow resolvers above, so this
|
|
80
|
-
* does not widen any existing gate.
|
|
81
|
-
*/
|
|
82
|
-
export function setCapabilityResolver(resolver) {
|
|
83
|
-
capabilityResolver = typeof resolver === 'function' ? resolver : async () => false
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
/** Install the `data.read` decision function; index.js points it at the policy file. */
|
|
87
|
-
export function setDataReadResolver(resolver) {
|
|
88
|
-
dataReadResolver = typeof resolver === 'function' ? resolver : async () => false
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
/** Install the `data.write` decision function; index.js points it at the policy file. */
|
|
92
|
-
export function setDataWriteResolver(resolver) {
|
|
93
|
-
dataWriteResolver = typeof resolver === 'function' ? resolver : async () => false
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
/** Install the `metadata.read` decision function; index.js points it at the policy file. */
|
|
97
|
-
export function setMetadataReadResolver(resolver) {
|
|
98
|
-
metadataReadResolver = typeof resolver === 'function' ? resolver : async () => true
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
function urlSegment(url) {
|
|
102
|
-
const text = String(url?.pathname ?? url ?? '')
|
|
103
|
-
const marker = `/api/data/${API_VERSION}/`
|
|
104
|
-
const at = text.indexOf(marker)
|
|
105
|
-
const rest = at >= 0 ? text.slice(at + marker.length) : text.replace(/^\/+/, '')
|
|
106
|
-
return rest.split(/[/?(]/)[0].toLowerCase()
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
/** @returns true when the URL only touches metadata or deployment state. */
|
|
110
|
-
export function isMetadataRead(url) {
|
|
111
|
-
const segment = urlSegment(url)
|
|
112
|
-
return METADATA_READ_PREFIXES.some((prefix) => segment === prefix || segment.startsWith(`${prefix}(`))
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
/** @returns true when the URL changes metadata or deployment state, not business records. */
|
|
116
|
-
export function isMetadataWrite(url) {
|
|
117
|
-
const segment = urlSegment(url)
|
|
118
|
-
if (isMetadataRead(url)) return true
|
|
119
|
-
return METADATA_WRITE_SEGMENTS.some((name) => segment === name)
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
|
123
|
-
|
|
124
|
-
/**
|
|
125
|
-
* Run `fetch` with a timeout, while still honoring a caller-provided signal.
|
|
126
|
-
*
|
|
127
|
-
* The timer stays armed after the response headers arrive: a server that sends
|
|
128
|
-
* headers and then stalls the body would otherwise hang forever, because
|
|
129
|
-
* `response.text()` is not covered by the `fetch` promise. The caller must call
|
|
130
|
-
* `release()` once the body has been consumed (or the request abandoned).
|
|
131
|
-
* @returns `{ response, release }`.
|
|
132
|
-
*/
|
|
133
|
-
async function timedFetch(url, init, timeoutMs, externalSignal) {
|
|
134
|
-
const controller = new AbortController()
|
|
135
|
-
const timer = setTimeout(() => controller.abort(new Error(`请求超时(${timeoutMs}ms)`)), timeoutMs)
|
|
136
|
-
let listener
|
|
137
|
-
if (externalSignal) {
|
|
138
|
-
if (externalSignal.aborted) controller.abort(externalSignal.reason)
|
|
139
|
-
else {
|
|
140
|
-
listener = () => controller.abort(externalSignal.reason)
|
|
141
|
-
externalSignal.addEventListener('abort', listener, { once: true })
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
const release = () => {
|
|
145
|
-
clearTimeout(timer)
|
|
146
|
-
if (listener) externalSignal.removeEventListener('abort', listener)
|
|
147
|
-
}
|
|
148
|
-
try {
|
|
149
|
-
const response = await fetch(url, { ...init, signal: controller.signal })
|
|
150
|
-
return { response, release }
|
|
151
|
-
} catch (error) {
|
|
152
|
-
release()
|
|
153
|
-
throw error
|
|
154
|
-
}
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
/** Seconds to wait from a `Retry-After` header, falling back to exponential backoff. */
|
|
158
|
-
function retryDelay(response, attempt) {
|
|
159
|
-
const header = response.headers.get('retry-after')
|
|
160
|
-
const seconds = Number(header)
|
|
161
|
-
if (Number.isFinite(seconds) && seconds >= 0) return Math.min(seconds, 120) * 1000
|
|
162
|
-
return Math.min(1000 * 2 ** attempt, 20_000)
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
export class DataverseClient {
|
|
166
|
-
/**
|
|
167
|
-
* @param env - normalized environment.
|
|
168
|
-
* @param options - timeout, retry budget, and request counters shared by a tool call.
|
|
169
|
-
*/
|
|
170
|
-
constructor(env, options = {}) {
|
|
171
|
-
this.env = env
|
|
172
|
-
this.timeoutMs = options.timeoutMs ?? 60_000
|
|
173
|
-
this.maxRetries = options.maxRetries ?? 4
|
|
174
|
-
this.counters = options.counters ?? { requests: 0 }
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
get apiBase() {
|
|
178
|
-
return `${this.env.url}/api/data/${API_VERSION}/`
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
/**
|
|
182
|
-
* Build one absolute Web API URL.
|
|
183
|
-
* @param path - path relative to the Web API root; may contain OData segments.
|
|
184
|
-
* @param params - query options such as `$select`. Empty values are dropped.
|
|
185
|
-
*/
|
|
186
|
-
buildUrl(path, params = {}) {
|
|
187
|
-
const url = new URL(this.apiBase + path)
|
|
188
|
-
for (const [key, value] of Object.entries(params)) {
|
|
189
|
-
if (value === undefined || value === null || value === '') continue
|
|
190
|
-
url.searchParams.set(key, String(value))
|
|
191
|
-
}
|
|
192
|
-
return url
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
/**
|
|
196
|
-
* GET one URL and decode it as JSON, retrying throttling and refreshing the
|
|
197
|
-
* token once on 401.
|
|
198
|
-
* @returns the parsed JSON value.
|
|
199
|
-
*/
|
|
200
|
-
async requestJson(url, state = {}) {
|
|
201
|
-
const attempt = state.attempt ?? 0
|
|
202
|
-
const refreshed = state.refreshed ?? false
|
|
203
|
-
const method = state.method ?? 'GET'
|
|
204
|
-
|
|
205
|
-
if (method === 'GET') {
|
|
206
|
-
// Metadata and business records are gated separately: an environment can
|
|
207
|
-
// allow reading table definitions while business rows stay closed.
|
|
208
|
-
const metadata = isMetadataRead(url)
|
|
209
|
-
const allowed = metadata
|
|
210
|
-
? await metadataReadResolver(this.env.key)
|
|
211
|
-
: await dataReadResolver(this.env.key)
|
|
212
|
-
if (!allowed) {
|
|
213
|
-
throw new D365Error(
|
|
214
|
-
metadata
|
|
215
|
-
? `环境 ${this.env.key} 未授权读取元数据`
|
|
216
|
-
: `环境 ${this.env.key} 未授权读取业务数据`,
|
|
217
|
-
{
|
|
218
|
-
code: metadata ? 'metadata-read-forbidden' : 'data-read-forbidden',
|
|
219
|
-
hint: metadata
|
|
220
|
-
? `在设置页「D365 工具箱 → 权限」里给 ${this.env.key} 勾上「读取元数据」后再试。`
|
|
221
|
-
: `在设置页「D365 工具箱 → 权限」里给 ${this.env.key} 勾上「读取业务数据」后再试。`,
|
|
222
|
-
},
|
|
223
|
-
)
|
|
224
|
-
}
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
if (method !== 'GET' && !isMetadataWrite(url)) {
|
|
228
|
-
// A caller may name a different capability for one request (only
|
|
229
|
-
// `d365_invoke` does, after checking it itself). Everything else still
|
|
230
|
-
// walks the `data.write` gate.
|
|
231
|
-
const capability = state.capability === 'message.invoke' ? 'message.invoke' : 'data.write'
|
|
232
|
-
const allowed = capability === 'message.invoke'
|
|
233
|
-
? await capabilityResolver(this.env.key, 'message.invoke')
|
|
234
|
-
: await dataWriteResolver(this.env.key)
|
|
235
|
-
if (!allowed) {
|
|
236
|
-
throw new D365Error(
|
|
237
|
-
capability === 'message.invoke'
|
|
238
|
-
? `环境 ${this.env.key} 未授权调用消息`
|
|
239
|
-
: `环境 ${this.env.key} 未授权新增或修改业务数据`,
|
|
240
|
-
{
|
|
241
|
-
code: capability === 'message.invoke' ? 'message-invoke-forbidden' : 'data-write-forbidden',
|
|
242
|
-
hint: capability === 'message.invoke'
|
|
243
|
-
? `在设置页「D365 工具箱 → 权限」里给 ${this.env.key} 勾上「调用消息」后再试。`
|
|
244
|
-
: `在设置页「D365 工具箱 → 权限」里给 ${this.env.key} 勾上「新增/修改业务数据」后再试。元数据的修改走 d365_write,不受这个开关影响。`,
|
|
245
|
-
},
|
|
246
|
-
)
|
|
247
|
-
}
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
const token = await getAccessToken(this.env, { force: refreshed })
|
|
251
|
-
this.counters.requests += 1
|
|
252
|
-
|
|
253
|
-
const headers = {
|
|
254
|
-
authorization: `Bearer ${token}`,
|
|
255
|
-
accept: 'application/json',
|
|
256
|
-
'odata-maxversion': '4.0',
|
|
257
|
-
'odata-version': '4.0',
|
|
258
|
-
}
|
|
259
|
-
if (state.body !== undefined) headers['content-type'] = 'application/json; charset=utf-8'
|
|
260
|
-
if (state.headers) Object.assign(headers, state.headers)
|
|
261
|
-
|
|
262
|
-
let response
|
|
263
|
-
let release = () => {}
|
|
264
|
-
try {
|
|
265
|
-
const timed = await timedFetch(
|
|
266
|
-
url,
|
|
267
|
-
{
|
|
268
|
-
method,
|
|
269
|
-
headers,
|
|
270
|
-
body: state.body === undefined ? undefined : JSON.stringify(state.body),
|
|
271
|
-
},
|
|
272
|
-
this.timeoutMs,
|
|
273
|
-
state.signal,
|
|
274
|
-
)
|
|
275
|
-
response = timed.response
|
|
276
|
-
release = timed.release
|
|
277
|
-
} catch (error) {
|
|
278
|
-
// A failed request is only safe to repeat when the server provably did
|
|
279
|
-
// not act on it. A timeout or a dropped connection after the bytes went
|
|
280
|
-
// out tells us nothing: retrying a POST/PATCH/DELETE there duplicates the
|
|
281
|
-
// write (a second CreateAttribute, a second InsertOptionValue).
|
|
282
|
-
if (attempt < this.maxRetries && SAFE_METHODS.has(method)) {
|
|
283
|
-
await wait(500 * (attempt + 1))
|
|
284
|
-
return this.requestJson(url, { ...state, attempt: attempt + 1 })
|
|
285
|
-
}
|
|
286
|
-
throw new D365Error(`请求环境 ${this.env.key} 失败:${error.message}`, {
|
|
287
|
-
code: 'network',
|
|
288
|
-
hint: SAFE_METHODS.has(method)
|
|
289
|
-
? '检查网络与该环境 URL 是否可达。'
|
|
290
|
-
: '写入请求可能已在环境里生效,也可能没生效——先到环境里核对结果,再决定是否重试。',
|
|
291
|
-
})
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
if (response.status === 401 && !refreshed) {
|
|
295
|
-
release()
|
|
296
|
-
forgetToken(this.env.key)
|
|
297
|
-
return this.requestJson(url, { ...state, refreshed: true })
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
// 429 means the request was rejected before running, so repeating it is
|
|
301
|
-
// safe for any method. 5xx is different: the server may have half-applied a
|
|
302
|
-
// write, so only idempotent methods are retried.
|
|
303
|
-
const retryable = response.status === 429
|
|
304
|
-
|| (RETRYABLE_STATUS.has(response.status) && SAFE_METHODS.has(method))
|
|
305
|
-
if (retryable && attempt < this.maxRetries) {
|
|
306
|
-
release()
|
|
307
|
-
await wait(retryDelay(response, attempt))
|
|
308
|
-
return this.requestJson(url, { ...state, attempt: attempt + 1 })
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
let text
|
|
312
|
-
try {
|
|
313
|
-
text = await response.text()
|
|
314
|
-
} catch (error) {
|
|
315
|
-
throw new D365Error(`读取环境 ${this.env.key} 的响应失败:${error.message}`, {
|
|
316
|
-
code: 'network',
|
|
317
|
-
hint: '响应头已经到了但正文没有在超时内读完;写入是否生效以环境里的实际状态为准。',
|
|
318
|
-
})
|
|
319
|
-
} finally {
|
|
320
|
-
release()
|
|
321
|
-
}
|
|
322
|
-
if (!response.ok) {
|
|
323
|
-
let body
|
|
324
|
-
try {
|
|
325
|
-
body = JSON.parse(text)
|
|
326
|
-
} catch {
|
|
327
|
-
body = text
|
|
328
|
-
}
|
|
329
|
-
throw new D365Error(`环境 ${this.env.key} 返回 HTTP ${response.status}:${describeDataverseError(body)}`, {
|
|
330
|
-
code: 'http',
|
|
331
|
-
status: response.status,
|
|
332
|
-
errorCode: errorCodeFromBody(body),
|
|
333
|
-
// Keep the body: the message above is a summary, and when a write fails
|
|
334
|
-
// on one property the original payload text is what a person needs.
|
|
335
|
-
body: text.length > 8000 ? `${text.slice(0, 8000)}…(共 ${text.length} 字符)` : text,
|
|
336
|
-
hint: httpErrorHint({ status: response.status, body }),
|
|
337
|
-
})
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
if (!text) return null
|
|
341
|
-
try {
|
|
342
|
-
return JSON.parse(text)
|
|
343
|
-
} catch {
|
|
344
|
-
throw new D365Error(`环境 ${this.env.key} 返回的不是 JSON`, { code: 'decode' })
|
|
345
|
-
}
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
/**
|
|
349
|
-
* GET a collection, following `@odata.nextLink` until `limit` rows are
|
|
350
|
-
* collected. Returns the rows and whether more exist.
|
|
351
|
-
*
|
|
352
|
-
* `$top` is sent with the first request: without it the server picks its own
|
|
353
|
-
* page size (thousands of rows), which on a real organization made one
|
|
354
|
-
* `systemforms` page take ~28 seconds when three rows were all that was
|
|
355
|
-
* wanted.
|
|
356
|
-
* @param limit - hard cap on returned rows.
|
|
357
|
-
*/
|
|
358
|
-
async getPaged(path, params, limit, signal) {
|
|
359
|
-
const records = []
|
|
360
|
-
const first = { ...params }
|
|
361
|
-
const unlimited = !Number.isFinite(limit)
|
|
362
|
-
// `__noTop` is the fallback path's opt-out: some endpoints (the metadata
|
|
363
|
-
// ones) reject `$top` outright, and retrying must not re-add it.
|
|
364
|
-
const noTop = first.__noTop === true || unlimited
|
|
365
|
-
delete first.__noTop
|
|
366
|
-
// Ask for one row more than the cap: a server that fills `$top` exactly
|
|
367
|
-
// sends no `@odata.nextLink`, so without the probe row a capped response
|
|
368
|
-
// is indistinguishable from a complete one.
|
|
369
|
-
if (!noTop && first.$top === undefined && Number.isFinite(limit)) first.$top = limit + 1
|
|
370
|
-
let next = this.buildUrl(path, first)
|
|
371
|
-
let pages = 0
|
|
372
|
-
const maxPages = unlimited ? 20_000 : 500
|
|
373
|
-
|
|
374
|
-
while (next && (unlimited || records.length < limit) && pages < maxPages) {
|
|
375
|
-
const page = await this.requestJson(next, { signal })
|
|
376
|
-
const value = Array.isArray(page?.value) ? page.value : []
|
|
377
|
-
records.push(...value)
|
|
378
|
-
next = page?.['@odata.nextLink']
|
|
379
|
-
pages += 1
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
const overflow = !unlimited && records.length > limit
|
|
383
|
-
return {
|
|
384
|
-
records: overflow ? records.slice(0, limit) : records,
|
|
385
|
-
hasMore: Boolean(next) || overflow,
|
|
386
|
-
pages,
|
|
387
|
-
}
|
|
388
|
-
}
|
|
389
|
-
|
|
390
|
-
/** GET a single record (collection query with `$top=1`). */
|
|
391
|
-
async getOne(path, params, signal) {
|
|
392
|
-
const url = this.buildUrl(path, { ...params, $top: 1 })
|
|
393
|
-
const page = await this.requestJson(url, { signal })
|
|
394
|
-
return Array.isArray(page?.value) ? page.value[0] : page
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
/**
|
|
398
|
-
* POST a JSON body (an unbound action such as `InsertOptionValue`) and decode
|
|
399
|
-
* the response. Writes are reachable only through the audited write pipeline.
|
|
400
|
-
*/
|
|
401
|
-
async postJson(path, body, signal) {
|
|
402
|
-
return this.requestJson(this.buildUrl(path), { method: 'POST', body, signal })
|
|
403
|
-
}
|
|
404
|
-
|
|
405
|
-
/**
|
|
406
|
-
* Send one JSON body with any method and extra headers.
|
|
407
|
-
*
|
|
408
|
-
* Metadata creation and update need more than POST: creating a column is a
|
|
409
|
-
* POST to the entity's `Attributes` collection, updating it is a PUT that
|
|
410
|
-
* carries `MSCRM.MergeLabels`, and deleting it is a DELETE. None of those are
|
|
411
|
-
* actions, so the option-value helpers do not cover them.
|
|
412
|
-
* @param path - Web API path.
|
|
413
|
-
* @param options - `{ method, body, headers }`.
|
|
414
|
-
*/
|
|
415
|
-
async sendJson(path, options = {}, signal) {
|
|
416
|
-
const { method = 'POST', body, headers } = options
|
|
417
|
-
return this.requestJson(this.buildUrl(path), { method, body, headers, signal })
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
/**
|
|
421
|
-
* Read the organization's platform version.
|
|
422
|
-
*
|
|
423
|
-
* The `organization` table does not expose a `version` property in every
|
|
424
|
-
* deployment (a real one rejected `$select=version` with 0x80060888), so the
|
|
425
|
-
* supported `RetrieveVersion` action is the only portable source. It is
|
|
426
|
-
* best-effort: a missing version must not fail a connection check.
|
|
427
|
-
*/
|
|
428
|
-
async readVersion(signal) {
|
|
429
|
-
try {
|
|
430
|
-
const payload = await this.requestJson(this.buildUrl('RetrieveVersion'), { signal })
|
|
431
|
-
return payload?.Version ?? payload?.version ?? undefined
|
|
432
|
-
} catch {
|
|
433
|
-
return undefined
|
|
434
|
-
}
|
|
435
|
-
}
|
|
436
|
-
}
|
|
437
|
-
|
|
438
|
-
/**
|
|
439
|
-
* GET with progressive fallbacks for query options real deployments reject.
|
|
440
|
-
*
|
|
441
|
-
* Dataverse does not accept the same `$select`/`$orderby` vocabulary on every
|
|
442
|
-
* endpoint or version: a metadata endpoint answers `$orderby` with
|
|
443
|
-
* 0x80060888 "The query parameter ... is not supported", and a table may lack a
|
|
444
|
-
* property this plugin asked for ("Could not find a property named 'modifiedon'
|
|
445
|
-
* on type 'Microsoft.Dynamics.CRM.systemform'"). Each rejection narrows the
|
|
446
|
-
* request — one field at a time — instead of failing the whole resource.
|
|
447
|
-
* @returns `{ page, selectDropped, orderbyDropped, droppedFields }`.
|
|
448
|
-
*/
|
|
449
|
-
export async function getPagedWithSelectFallback(client, path, params, limit, signal) {
|
|
450
|
-
const dropped = { selectDropped: false, orderbyDropped: false, topDropped: false, droppedFields: [] }
|
|
451
|
-
const current = { ...params }
|
|
452
|
-
|
|
453
|
-
for (let attempt = 0; attempt < 10; attempt += 1) {
|
|
454
|
-
try {
|
|
455
|
-
return { page: await client.getPaged(path, current, limit, signal), ...dropped }
|
|
456
|
-
} catch (error) {
|
|
457
|
-
if (error?.status !== 400) throw error
|
|
458
|
-
const text = String(error.message ?? '')
|
|
459
|
-
|
|
460
|
-
const missing = /property named '([^']+)'/.exec(text)
|
|
461
|
-
if (missing && current.$select) {
|
|
462
|
-
const fields = current.$select.split(',').map((item) => item.trim()).filter(Boolean)
|
|
463
|
-
const kept = fields.filter((field) => field.toLowerCase() !== missing[1].toLowerCase())
|
|
464
|
-
if (kept.length && kept.length < fields.length) {
|
|
465
|
-
current.$select = kept.join(',')
|
|
466
|
-
dropped.droppedFields.push(missing[1])
|
|
467
|
-
continue
|
|
468
|
-
}
|
|
469
|
-
}
|
|
470
|
-
|
|
471
|
-
// "The query parameter '...' is not supported": the name is often
|
|
472
|
-
// redacted before it reaches us, so narrow the request by dropping the
|
|
473
|
-
// options in turn, cheapest first. `$top` is added inside getPaged, so
|
|
474
|
-
// the check is on the marker, not on the params object.
|
|
475
|
-
if (/query parameter/i.test(text) && !dropped.topDropped) {
|
|
476
|
-
current.__noTop = true
|
|
477
|
-
delete current.$top
|
|
478
|
-
dropped.topDropped = true
|
|
479
|
-
continue
|
|
480
|
-
}
|
|
481
|
-
if (current.$orderby) {
|
|
482
|
-
delete current.$orderby
|
|
483
|
-
dropped.orderbyDropped = true
|
|
484
|
-
continue
|
|
485
|
-
}
|
|
486
|
-
if (current.$select) {
|
|
487
|
-
delete current.$select
|
|
488
|
-
dropped.selectDropped = true
|
|
489
|
-
continue
|
|
490
|
-
}
|
|
491
|
-
throw error
|
|
492
|
-
}
|
|
493
|
-
}
|
|
494
|
-
|
|
495
|
-
return { page: await client.getPaged(path, current, limit, signal), ...dropped }
|
|
496
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Dataverse Web API client.
|
|
3
|
+
*
|
|
4
|
+
* Every request is a GET or write to one environment. Reads are split in two:
|
|
5
|
+
* metadata and definition tables need the `metadata.read` capability, business
|
|
6
|
+
* records need `data.read`. The check lives here, at the single choke point, so
|
|
7
|
+
* a new tool cannot accidentally bypass it.
|
|
8
|
+
*/
|
|
9
|
+
import { getAccessToken, forgetToken } from './auth.js'
|
|
10
|
+
import { D365Error, describeDataverseError, httpErrorHint } from './errors.js'
|
|
11
|
+
import { errorCodeFromBody } from './error-codes.js'
|
|
12
|
+
|
|
13
|
+
const API_VERSION = 'v9.2'
|
|
14
|
+
const RETRYABLE_STATUS = new Set([429, 500, 502, 503, 504])
|
|
15
|
+
/** Methods the server treats as read-only, so repeating them cannot write twice. */
|
|
16
|
+
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS'])
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Entity sets and endpoints that only expose metadata or deployment state.
|
|
20
|
+
* Anything not listed here is treated as business data.
|
|
21
|
+
*/
|
|
22
|
+
const METADATA_READ_PREFIXES = [
|
|
23
|
+
'entitydefinitions',
|
|
24
|
+
'relationshipdefinitions',
|
|
25
|
+
'globaloptionsets',
|
|
26
|
+
'globaloptionsetdefinitions',
|
|
27
|
+
'solutions',
|
|
28
|
+
'solutioncomponents',
|
|
29
|
+
'publishers',
|
|
30
|
+
'sdkmessageprocessingsteps',
|
|
31
|
+
'sdkmessageprocessingstepimages',
|
|
32
|
+
'sdkmessages',
|
|
33
|
+
'pluginassemblies',
|
|
34
|
+
'plugintypes',
|
|
35
|
+
'webresourceset',
|
|
36
|
+
'systemforms',
|
|
37
|
+
'savedqueries',
|
|
38
|
+
'workflows',
|
|
39
|
+
'customapis',
|
|
40
|
+
'sitemaps',
|
|
41
|
+
'organizations',
|
|
42
|
+
'msdyn_componentlayers',
|
|
43
|
+
'msdyn_solutionhistories',
|
|
44
|
+
'retrieveversion',
|
|
45
|
+
'$metadata',
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Action endpoints that change metadata or deployment state rather than
|
|
50
|
+
* business records. They are allowed through the `data.write` guard; the
|
|
51
|
+
* metadata capabilities are enforced separately by `d365_write`.
|
|
52
|
+
*/
|
|
53
|
+
const METADATA_WRITE_SEGMENTS = [
|
|
54
|
+
'publishxml',
|
|
55
|
+
'publishallxml',
|
|
56
|
+
'insertoptionvalue',
|
|
57
|
+
'updateoptionvalue',
|
|
58
|
+
'deleteoptionvalue',
|
|
59
|
+
'createattribute',
|
|
60
|
+
'updateattribute',
|
|
61
|
+
'deleteattribute',
|
|
62
|
+
'removeactivecustomizations',
|
|
63
|
+
'updateentity',
|
|
64
|
+
'createentity',
|
|
65
|
+
'deleteentity',
|
|
66
|
+
'addsolutioncomponent',
|
|
67
|
+
'removesolutioncomponent',
|
|
68
|
+
]
|
|
69
|
+
|
|
70
|
+
let dataReadResolver = async () => false
|
|
71
|
+
let dataWriteResolver = async () => false
|
|
72
|
+
let metadataReadResolver = async () => true
|
|
73
|
+
let capabilityResolver = async () => false
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Install the generic `(environment, capability) => boolean` decision function.
|
|
77
|
+
*
|
|
78
|
+
* Only `d365_invoke` uses it, and only for the capability it names explicitly on
|
|
79
|
+
* the request: everything else keeps the three narrow resolvers above, so this
|
|
80
|
+
* does not widen any existing gate.
|
|
81
|
+
*/
|
|
82
|
+
export function setCapabilityResolver(resolver) {
|
|
83
|
+
capabilityResolver = typeof resolver === 'function' ? resolver : async () => false
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Install the `data.read` decision function; index.js points it at the policy file. */
|
|
87
|
+
export function setDataReadResolver(resolver) {
|
|
88
|
+
dataReadResolver = typeof resolver === 'function' ? resolver : async () => false
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Install the `data.write` decision function; index.js points it at the policy file. */
|
|
92
|
+
export function setDataWriteResolver(resolver) {
|
|
93
|
+
dataWriteResolver = typeof resolver === 'function' ? resolver : async () => false
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Install the `metadata.read` decision function; index.js points it at the policy file. */
|
|
97
|
+
export function setMetadataReadResolver(resolver) {
|
|
98
|
+
metadataReadResolver = typeof resolver === 'function' ? resolver : async () => true
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function urlSegment(url) {
|
|
102
|
+
const text = String(url?.pathname ?? url ?? '')
|
|
103
|
+
const marker = `/api/data/${API_VERSION}/`
|
|
104
|
+
const at = text.indexOf(marker)
|
|
105
|
+
const rest = at >= 0 ? text.slice(at + marker.length) : text.replace(/^\/+/, '')
|
|
106
|
+
return rest.split(/[/?(]/)[0].toLowerCase()
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** @returns true when the URL only touches metadata or deployment state. */
|
|
110
|
+
export function isMetadataRead(url) {
|
|
111
|
+
const segment = urlSegment(url)
|
|
112
|
+
return METADATA_READ_PREFIXES.some((prefix) => segment === prefix || segment.startsWith(`${prefix}(`))
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** @returns true when the URL changes metadata or deployment state, not business records. */
|
|
116
|
+
export function isMetadataWrite(url) {
|
|
117
|
+
const segment = urlSegment(url)
|
|
118
|
+
if (isMetadataRead(url)) return true
|
|
119
|
+
return METADATA_WRITE_SEGMENTS.some((name) => segment === name)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Run `fetch` with a timeout, while still honoring a caller-provided signal.
|
|
126
|
+
*
|
|
127
|
+
* The timer stays armed after the response headers arrive: a server that sends
|
|
128
|
+
* headers and then stalls the body would otherwise hang forever, because
|
|
129
|
+
* `response.text()` is not covered by the `fetch` promise. The caller must call
|
|
130
|
+
* `release()` once the body has been consumed (or the request abandoned).
|
|
131
|
+
* @returns `{ response, release }`.
|
|
132
|
+
*/
|
|
133
|
+
async function timedFetch(url, init, timeoutMs, externalSignal) {
|
|
134
|
+
const controller = new AbortController()
|
|
135
|
+
const timer = setTimeout(() => controller.abort(new Error(`请求超时(${timeoutMs}ms)`)), timeoutMs)
|
|
136
|
+
let listener
|
|
137
|
+
if (externalSignal) {
|
|
138
|
+
if (externalSignal.aborted) controller.abort(externalSignal.reason)
|
|
139
|
+
else {
|
|
140
|
+
listener = () => controller.abort(externalSignal.reason)
|
|
141
|
+
externalSignal.addEventListener('abort', listener, { once: true })
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
const release = () => {
|
|
145
|
+
clearTimeout(timer)
|
|
146
|
+
if (listener) externalSignal.removeEventListener('abort', listener)
|
|
147
|
+
}
|
|
148
|
+
try {
|
|
149
|
+
const response = await fetch(url, { ...init, signal: controller.signal })
|
|
150
|
+
return { response, release }
|
|
151
|
+
} catch (error) {
|
|
152
|
+
release()
|
|
153
|
+
throw error
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Seconds to wait from a `Retry-After` header, falling back to exponential backoff. */
|
|
158
|
+
function retryDelay(response, attempt) {
|
|
159
|
+
const header = response.headers.get('retry-after')
|
|
160
|
+
const seconds = Number(header)
|
|
161
|
+
if (Number.isFinite(seconds) && seconds >= 0) return Math.min(seconds, 120) * 1000
|
|
162
|
+
return Math.min(1000 * 2 ** attempt, 20_000)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export class DataverseClient {
|
|
166
|
+
/**
|
|
167
|
+
* @param env - normalized environment.
|
|
168
|
+
* @param options - timeout, retry budget, and request counters shared by a tool call.
|
|
169
|
+
*/
|
|
170
|
+
constructor(env, options = {}) {
|
|
171
|
+
this.env = env
|
|
172
|
+
this.timeoutMs = options.timeoutMs ?? 60_000
|
|
173
|
+
this.maxRetries = options.maxRetries ?? 4
|
|
174
|
+
this.counters = options.counters ?? { requests: 0 }
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
get apiBase() {
|
|
178
|
+
return `${this.env.url}/api/data/${API_VERSION}/`
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Build one absolute Web API URL.
|
|
183
|
+
* @param path - path relative to the Web API root; may contain OData segments.
|
|
184
|
+
* @param params - query options such as `$select`. Empty values are dropped.
|
|
185
|
+
*/
|
|
186
|
+
buildUrl(path, params = {}) {
|
|
187
|
+
const url = new URL(this.apiBase + path)
|
|
188
|
+
for (const [key, value] of Object.entries(params)) {
|
|
189
|
+
if (value === undefined || value === null || value === '') continue
|
|
190
|
+
url.searchParams.set(key, String(value))
|
|
191
|
+
}
|
|
192
|
+
return url
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* GET one URL and decode it as JSON, retrying throttling and refreshing the
|
|
197
|
+
* token once on 401.
|
|
198
|
+
* @returns the parsed JSON value.
|
|
199
|
+
*/
|
|
200
|
+
async requestJson(url, state = {}) {
|
|
201
|
+
const attempt = state.attempt ?? 0
|
|
202
|
+
const refreshed = state.refreshed ?? false
|
|
203
|
+
const method = state.method ?? 'GET'
|
|
204
|
+
|
|
205
|
+
if (method === 'GET') {
|
|
206
|
+
// Metadata and business records are gated separately: an environment can
|
|
207
|
+
// allow reading table definitions while business rows stay closed.
|
|
208
|
+
const metadata = isMetadataRead(url)
|
|
209
|
+
const allowed = metadata
|
|
210
|
+
? await metadataReadResolver(this.env.key)
|
|
211
|
+
: await dataReadResolver(this.env.key)
|
|
212
|
+
if (!allowed) {
|
|
213
|
+
throw new D365Error(
|
|
214
|
+
metadata
|
|
215
|
+
? `环境 ${this.env.key} 未授权读取元数据`
|
|
216
|
+
: `环境 ${this.env.key} 未授权读取业务数据`,
|
|
217
|
+
{
|
|
218
|
+
code: metadata ? 'metadata-read-forbidden' : 'data-read-forbidden',
|
|
219
|
+
hint: metadata
|
|
220
|
+
? `在设置页「D365 工具箱 → 权限」里给 ${this.env.key} 勾上「读取元数据」后再试。`
|
|
221
|
+
: `在设置页「D365 工具箱 → 权限」里给 ${this.env.key} 勾上「读取业务数据」后再试。`,
|
|
222
|
+
},
|
|
223
|
+
)
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (method !== 'GET' && !isMetadataWrite(url)) {
|
|
228
|
+
// A caller may name a different capability for one request (only
|
|
229
|
+
// `d365_invoke` does, after checking it itself). Everything else still
|
|
230
|
+
// walks the `data.write` gate.
|
|
231
|
+
const capability = state.capability === 'message.invoke' ? 'message.invoke' : 'data.write'
|
|
232
|
+
const allowed = capability === 'message.invoke'
|
|
233
|
+
? await capabilityResolver(this.env.key, 'message.invoke')
|
|
234
|
+
: await dataWriteResolver(this.env.key)
|
|
235
|
+
if (!allowed) {
|
|
236
|
+
throw new D365Error(
|
|
237
|
+
capability === 'message.invoke'
|
|
238
|
+
? `环境 ${this.env.key} 未授权调用消息`
|
|
239
|
+
: `环境 ${this.env.key} 未授权新增或修改业务数据`,
|
|
240
|
+
{
|
|
241
|
+
code: capability === 'message.invoke' ? 'message-invoke-forbidden' : 'data-write-forbidden',
|
|
242
|
+
hint: capability === 'message.invoke'
|
|
243
|
+
? `在设置页「D365 工具箱 → 权限」里给 ${this.env.key} 勾上「调用消息」后再试。`
|
|
244
|
+
: `在设置页「D365 工具箱 → 权限」里给 ${this.env.key} 勾上「新增/修改业务数据」后再试。元数据的修改走 d365_write,不受这个开关影响。`,
|
|
245
|
+
},
|
|
246
|
+
)
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const token = await getAccessToken(this.env, { force: refreshed })
|
|
251
|
+
this.counters.requests += 1
|
|
252
|
+
|
|
253
|
+
const headers = {
|
|
254
|
+
authorization: `Bearer ${token}`,
|
|
255
|
+
accept: 'application/json',
|
|
256
|
+
'odata-maxversion': '4.0',
|
|
257
|
+
'odata-version': '4.0',
|
|
258
|
+
}
|
|
259
|
+
if (state.body !== undefined) headers['content-type'] = 'application/json; charset=utf-8'
|
|
260
|
+
if (state.headers) Object.assign(headers, state.headers)
|
|
261
|
+
|
|
262
|
+
let response
|
|
263
|
+
let release = () => {}
|
|
264
|
+
try {
|
|
265
|
+
const timed = await timedFetch(
|
|
266
|
+
url,
|
|
267
|
+
{
|
|
268
|
+
method,
|
|
269
|
+
headers,
|
|
270
|
+
body: state.body === undefined ? undefined : JSON.stringify(state.body),
|
|
271
|
+
},
|
|
272
|
+
this.timeoutMs,
|
|
273
|
+
state.signal,
|
|
274
|
+
)
|
|
275
|
+
response = timed.response
|
|
276
|
+
release = timed.release
|
|
277
|
+
} catch (error) {
|
|
278
|
+
// A failed request is only safe to repeat when the server provably did
|
|
279
|
+
// not act on it. A timeout or a dropped connection after the bytes went
|
|
280
|
+
// out tells us nothing: retrying a POST/PATCH/DELETE there duplicates the
|
|
281
|
+
// write (a second CreateAttribute, a second InsertOptionValue).
|
|
282
|
+
if (attempt < this.maxRetries && SAFE_METHODS.has(method)) {
|
|
283
|
+
await wait(500 * (attempt + 1))
|
|
284
|
+
return this.requestJson(url, { ...state, attempt: attempt + 1 })
|
|
285
|
+
}
|
|
286
|
+
throw new D365Error(`请求环境 ${this.env.key} 失败:${error.message}`, {
|
|
287
|
+
code: 'network',
|
|
288
|
+
hint: SAFE_METHODS.has(method)
|
|
289
|
+
? '检查网络与该环境 URL 是否可达。'
|
|
290
|
+
: '写入请求可能已在环境里生效,也可能没生效——先到环境里核对结果,再决定是否重试。',
|
|
291
|
+
})
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
if (response.status === 401 && !refreshed) {
|
|
295
|
+
release()
|
|
296
|
+
forgetToken(this.env.key)
|
|
297
|
+
return this.requestJson(url, { ...state, refreshed: true })
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// 429 means the request was rejected before running, so repeating it is
|
|
301
|
+
// safe for any method. 5xx is different: the server may have half-applied a
|
|
302
|
+
// write, so only idempotent methods are retried.
|
|
303
|
+
const retryable = response.status === 429
|
|
304
|
+
|| (RETRYABLE_STATUS.has(response.status) && SAFE_METHODS.has(method))
|
|
305
|
+
if (retryable && attempt < this.maxRetries) {
|
|
306
|
+
release()
|
|
307
|
+
await wait(retryDelay(response, attempt))
|
|
308
|
+
return this.requestJson(url, { ...state, attempt: attempt + 1 })
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
let text
|
|
312
|
+
try {
|
|
313
|
+
text = await response.text()
|
|
314
|
+
} catch (error) {
|
|
315
|
+
throw new D365Error(`读取环境 ${this.env.key} 的响应失败:${error.message}`, {
|
|
316
|
+
code: 'network',
|
|
317
|
+
hint: '响应头已经到了但正文没有在超时内读完;写入是否生效以环境里的实际状态为准。',
|
|
318
|
+
})
|
|
319
|
+
} finally {
|
|
320
|
+
release()
|
|
321
|
+
}
|
|
322
|
+
if (!response.ok) {
|
|
323
|
+
let body
|
|
324
|
+
try {
|
|
325
|
+
body = JSON.parse(text)
|
|
326
|
+
} catch {
|
|
327
|
+
body = text
|
|
328
|
+
}
|
|
329
|
+
throw new D365Error(`环境 ${this.env.key} 返回 HTTP ${response.status}:${describeDataverseError(body)}`, {
|
|
330
|
+
code: 'http',
|
|
331
|
+
status: response.status,
|
|
332
|
+
errorCode: errorCodeFromBody(body),
|
|
333
|
+
// Keep the body: the message above is a summary, and when a write fails
|
|
334
|
+
// on one property the original payload text is what a person needs.
|
|
335
|
+
body: text.length > 8000 ? `${text.slice(0, 8000)}…(共 ${text.length} 字符)` : text,
|
|
336
|
+
hint: httpErrorHint({ status: response.status, body }),
|
|
337
|
+
})
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
if (!text) return null
|
|
341
|
+
try {
|
|
342
|
+
return JSON.parse(text)
|
|
343
|
+
} catch {
|
|
344
|
+
throw new D365Error(`环境 ${this.env.key} 返回的不是 JSON`, { code: 'decode' })
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* GET a collection, following `@odata.nextLink` until `limit` rows are
|
|
350
|
+
* collected. Returns the rows and whether more exist.
|
|
351
|
+
*
|
|
352
|
+
* `$top` is sent with the first request: without it the server picks its own
|
|
353
|
+
* page size (thousands of rows), which on a real organization made one
|
|
354
|
+
* `systemforms` page take ~28 seconds when three rows were all that was
|
|
355
|
+
* wanted.
|
|
356
|
+
* @param limit - hard cap on returned rows.
|
|
357
|
+
*/
|
|
358
|
+
async getPaged(path, params, limit, signal) {
|
|
359
|
+
const records = []
|
|
360
|
+
const first = { ...params }
|
|
361
|
+
const unlimited = !Number.isFinite(limit)
|
|
362
|
+
// `__noTop` is the fallback path's opt-out: some endpoints (the metadata
|
|
363
|
+
// ones) reject `$top` outright, and retrying must not re-add it.
|
|
364
|
+
const noTop = first.__noTop === true || unlimited
|
|
365
|
+
delete first.__noTop
|
|
366
|
+
// Ask for one row more than the cap: a server that fills `$top` exactly
|
|
367
|
+
// sends no `@odata.nextLink`, so without the probe row a capped response
|
|
368
|
+
// is indistinguishable from a complete one.
|
|
369
|
+
if (!noTop && first.$top === undefined && Number.isFinite(limit)) first.$top = limit + 1
|
|
370
|
+
let next = this.buildUrl(path, first)
|
|
371
|
+
let pages = 0
|
|
372
|
+
const maxPages = unlimited ? 20_000 : 500
|
|
373
|
+
|
|
374
|
+
while (next && (unlimited || records.length < limit) && pages < maxPages) {
|
|
375
|
+
const page = await this.requestJson(next, { signal })
|
|
376
|
+
const value = Array.isArray(page?.value) ? page.value : []
|
|
377
|
+
records.push(...value)
|
|
378
|
+
next = page?.['@odata.nextLink']
|
|
379
|
+
pages += 1
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
const overflow = !unlimited && records.length > limit
|
|
383
|
+
return {
|
|
384
|
+
records: overflow ? records.slice(0, limit) : records,
|
|
385
|
+
hasMore: Boolean(next) || overflow,
|
|
386
|
+
pages,
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/** GET a single record (collection query with `$top=1`). */
|
|
391
|
+
async getOne(path, params, signal) {
|
|
392
|
+
const url = this.buildUrl(path, { ...params, $top: 1 })
|
|
393
|
+
const page = await this.requestJson(url, { signal })
|
|
394
|
+
return Array.isArray(page?.value) ? page.value[0] : page
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* POST a JSON body (an unbound action such as `InsertOptionValue`) and decode
|
|
399
|
+
* the response. Writes are reachable only through the audited write pipeline.
|
|
400
|
+
*/
|
|
401
|
+
async postJson(path, body, signal) {
|
|
402
|
+
return this.requestJson(this.buildUrl(path), { method: 'POST', body, signal })
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Send one JSON body with any method and extra headers.
|
|
407
|
+
*
|
|
408
|
+
* Metadata creation and update need more than POST: creating a column is a
|
|
409
|
+
* POST to the entity's `Attributes` collection, updating it is a PUT that
|
|
410
|
+
* carries `MSCRM.MergeLabels`, and deleting it is a DELETE. None of those are
|
|
411
|
+
* actions, so the option-value helpers do not cover them.
|
|
412
|
+
* @param path - Web API path.
|
|
413
|
+
* @param options - `{ method, body, headers }`.
|
|
414
|
+
*/
|
|
415
|
+
async sendJson(path, options = {}, signal) {
|
|
416
|
+
const { method = 'POST', body, headers } = options
|
|
417
|
+
return this.requestJson(this.buildUrl(path), { method, body, headers, signal })
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Read the organization's platform version.
|
|
422
|
+
*
|
|
423
|
+
* The `organization` table does not expose a `version` property in every
|
|
424
|
+
* deployment (a real one rejected `$select=version` with 0x80060888), so the
|
|
425
|
+
* supported `RetrieveVersion` action is the only portable source. It is
|
|
426
|
+
* best-effort: a missing version must not fail a connection check.
|
|
427
|
+
*/
|
|
428
|
+
async readVersion(signal) {
|
|
429
|
+
try {
|
|
430
|
+
const payload = await this.requestJson(this.buildUrl('RetrieveVersion'), { signal })
|
|
431
|
+
return payload?.Version ?? payload?.version ?? undefined
|
|
432
|
+
} catch {
|
|
433
|
+
return undefined
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* GET with progressive fallbacks for query options real deployments reject.
|
|
440
|
+
*
|
|
441
|
+
* Dataverse does not accept the same `$select`/`$orderby` vocabulary on every
|
|
442
|
+
* endpoint or version: a metadata endpoint answers `$orderby` with
|
|
443
|
+
* 0x80060888 "The query parameter ... is not supported", and a table may lack a
|
|
444
|
+
* property this plugin asked for ("Could not find a property named 'modifiedon'
|
|
445
|
+
* on type 'Microsoft.Dynamics.CRM.systemform'"). Each rejection narrows the
|
|
446
|
+
* request — one field at a time — instead of failing the whole resource.
|
|
447
|
+
* @returns `{ page, selectDropped, orderbyDropped, droppedFields }`.
|
|
448
|
+
*/
|
|
449
|
+
export async function getPagedWithSelectFallback(client, path, params, limit, signal) {
|
|
450
|
+
const dropped = { selectDropped: false, orderbyDropped: false, topDropped: false, droppedFields: [] }
|
|
451
|
+
const current = { ...params }
|
|
452
|
+
|
|
453
|
+
for (let attempt = 0; attempt < 10; attempt += 1) {
|
|
454
|
+
try {
|
|
455
|
+
return { page: await client.getPaged(path, current, limit, signal), ...dropped }
|
|
456
|
+
} catch (error) {
|
|
457
|
+
if (error?.status !== 400) throw error
|
|
458
|
+
const text = String(error.message ?? '')
|
|
459
|
+
|
|
460
|
+
const missing = /property named '([^']+)'/.exec(text)
|
|
461
|
+
if (missing && current.$select) {
|
|
462
|
+
const fields = current.$select.split(',').map((item) => item.trim()).filter(Boolean)
|
|
463
|
+
const kept = fields.filter((field) => field.toLowerCase() !== missing[1].toLowerCase())
|
|
464
|
+
if (kept.length && kept.length < fields.length) {
|
|
465
|
+
current.$select = kept.join(',')
|
|
466
|
+
dropped.droppedFields.push(missing[1])
|
|
467
|
+
continue
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// "The query parameter '...' is not supported": the name is often
|
|
472
|
+
// redacted before it reaches us, so narrow the request by dropping the
|
|
473
|
+
// options in turn, cheapest first. `$top` is added inside getPaged, so
|
|
474
|
+
// the check is on the marker, not on the params object.
|
|
475
|
+
if (/query parameter/i.test(text) && !dropped.topDropped) {
|
|
476
|
+
current.__noTop = true
|
|
477
|
+
delete current.$top
|
|
478
|
+
dropped.topDropped = true
|
|
479
|
+
continue
|
|
480
|
+
}
|
|
481
|
+
if (current.$orderby) {
|
|
482
|
+
delete current.$orderby
|
|
483
|
+
dropped.orderbyDropped = true
|
|
484
|
+
continue
|
|
485
|
+
}
|
|
486
|
+
if (current.$select) {
|
|
487
|
+
delete current.$select
|
|
488
|
+
dropped.selectDropped = true
|
|
489
|
+
continue
|
|
490
|
+
}
|
|
491
|
+
throw error
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
return { page: await client.getPaged(path, current, limit, signal), ...dropped }
|
|
496
|
+
}
|