dsh-d365-kit 0.34.3 → 0.34.5
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 +14 -2
- package/CHANGELOG.zh-CN.md +14 -2
- package/INSTALL.md +2 -2
- package/INSTALL.zh-CN.md +2 -2
- package/README.md +2 -2
- package/README.zh-CN.md +2 -2
- package/docs/en/installation.md +3 -3
- package/docs/en/tools.md +10 -1
- package/docs/zh/installation.md +3 -3
- package/docs/zh/tools.md +10 -1
- package/lib/client.js +78 -23
- package/lib/error-codes.js +1 -1
- package/lib/tool-data.js +60 -9
- package/lib/tool-metadata.js +19 -3
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,7 +4,18 @@
|
|
|
4
4
|
|
|
5
5
|
Versioning: bug fixes bump the patch, new tools/capabilities bump the minor, changes to the config structure or renames bump the major.
|
|
6
6
|
|
|
7
|
-
## 0.34.
|
|
7
|
+
## 0.34.5
|
|
8
|
+
|
|
9
|
+
An outside review (looking for users who hold two particular roles) turned up the same class of problem several times: when the tool was **wrong**, it did not fail — it quietly changed the question.
|
|
10
|
+
|
|
11
|
+
- A wrong attribute name in `$filter` is no longer treated as a `$select` problem. Previously any `0x80060888` triggered a walk down `$select` → `$orderby` → no `$select` at all, so you could get a 200 whose columns were not the ones you asked for, or a retry loop that ended in an error unrelated to the cause. Now a column is dropped only when that name really is in `$select` and is absent from `$filter`/`$orderby`; otherwise it fails as `unknown-property` and says whether it is casing, a lookup (`_<field>_value`) or a missing cast. The degradation ladder (`$top`/`$orderby`/`$select`) now runs only when the service actually says a query parameter is unsupported.
|
|
12
|
+
- A lookup in `select` silently came back as an empty column (`businessunitid` empty, the value only in `_businessunitid_value`). The column is now switched to `_businessunitid_value` with a note under the table; a column that genuinely does not exist is named — "`x` was not returned: that name cannot be selected (or its casing is wrong), this is not an empty field".
|
|
13
|
+
- `d365_data sample` no longer sends its own `$top` (which defeated the paging probe and made `hasMore` always false), says "(more rows exist, the sample shows only the first few)" when there are, and no longer renders `@odata.etag` as a column header.
|
|
14
|
+
- Every per-environment heading now carries "N requests / seconds", so a query slowed down by fallback retries is visible at a glance.
|
|
15
|
+
- The definition endpoints (`EntityDefinitions` / `RelationshipDefinitions` / `GlobalOptionSetDefinitions`) no longer get a `$top` they are going to reject: one wasted request per query before, now a clean metadata read is a single request. Dropped columns are named too.
|
|
16
|
+
- The `0x80060888` hint was rewritten: it used to promise that the tool would drop columns and retry, which cannot help when the name is in `$filter`.
|
|
17
|
+
|
|
18
|
+
## 0.34.4
|
|
8
19
|
|
|
9
20
|
Publishing is now part of the repository: `.github/workflows/publish.yml` publishes to npm whenever the pushed `package.json` version is not on the registry yet.
|
|
10
21
|
|
|
@@ -12,7 +23,8 @@ Publishing is now part of the repository: `.github/workflows/publish.yml` publis
|
|
|
12
23
|
- `node --check` runs before the publish, because a published npm version cannot be withdrawn or overwritten;
|
|
13
24
|
- Authentication is opt-in: npm trusted publishing (OIDC, `id-token: write`, `--provenance`) through the `NPM_TRUSTED_PUBLISHING` variable, or a granular `NPM_TOKEN` secret; with neither configured the job prints a notice instead of failing;
|
|
14
25
|
- The install docs no longer claim that pushing a tag is what publishes.
|
|
15
|
-
- `0.34.2` never shipped: an earlier manual attempt had already put that exact version in the registry's stage queue, and
|
|
26
|
+
- `0.34.2` never shipped: an earlier manual attempt had already put that exact version in the registry's stage queue, and publishing it again is refused with `E409 Cannot publish over previously staged version "0.34.2"`.
|
|
27
|
+
- `0.34.3` was published by CI but only into that same stage queue, because the trusted publisher allowed stage publishing but not direct publishing; `npm publish` exits 0 when it stages, so the run looked green. It went live later, when direct publishing was enabled. `0.34.4` is the first version published directly, with nobody approving it by hand.
|
|
16
28
|
|
|
17
29
|
## 0.34.1
|
|
18
30
|
|
package/CHANGELOG.zh-CN.md
CHANGED
|
@@ -4,7 +4,18 @@
|
|
|
4
4
|
|
|
5
5
|
版本语义:修 bug 升 patch,加工具/加能力升 minor,改配置结构或改名升 major。
|
|
6
6
|
|
|
7
|
-
## 0.34.
|
|
7
|
+
## 0.34.5
|
|
8
|
+
|
|
9
|
+
一次外部复盘(查「同时有某两个角色的用户」)暴露的问题:工具在**答错**的时候没有报错,而是悄悄换了问题。
|
|
10
|
+
|
|
11
|
+
- `$filter` 里写错属性名时不再被当成 `$select` 的问题:以前任何 `0x80060888` 都会去删 `$select` 的列、再删 `$orderby`、最后连整个 `$select` 都删掉,于是可能返回 200 但列不是你要的,或者反复重试最后报一个和原因无关的错。现在只有「这个名字确实在 `$select` 里、且没出现在 `$filter`/`$orderby` 里」才逐列剔除;否则直接报 `unknown-property`,并说明是大小写、lookup(`_<字段>_value`)还是 cast 的问题。降级阶梯(去 `$top`/`$orderby`/`$select`)只在服务端明确说「查询参数不被支持」时执行。
|
|
12
|
+
- lookup 列放进 `select` 会静默变成空列(`businessunitid` 空、`_businessunitid_value` 才有值)。现在自动改显示 `_businessunitid_value` 并写一行说明;真不存在的列会点名「`x` 没有返回:这个名字不能 select(或大小写不对),不是记录里为空」。
|
|
13
|
+
- `d365_data sample` 不再自己发 `$top`(那会让分页探测失效、`hasMore` 恒假),行数够了会写「(还有更多,样例只取前几条)」;默认列也不再拿 `@odata.etag` 当表头。
|
|
14
|
+
- 每个环境的标题现在带「N 请求 / 秒数」,一眼看出这次查询是不是被降级重试拖慢了。
|
|
15
|
+
- 定义端点(`EntityDefinitions` / `RelationshipDefinitions` / `GlobalOptionSetDefinitions`)不发 `$top`:以前每次都要先被拒一次再降级,白花一个请求(元数据查询从 2–3 个请求降到 1 个);被去掉的列也会点名。
|
|
16
|
+
- `0x80060888` 的 hint 重写:以前说「工具会逐列剔除并重试」,但 `$filter` 场景根本修不好。
|
|
17
|
+
|
|
18
|
+
## 0.34.4
|
|
8
19
|
|
|
9
20
|
发布进了仓库:`.github/workflows/publish.yml` 会在推送的 `package.json` 版本还没上架 npm 时自动发布。
|
|
10
21
|
|
|
@@ -12,7 +23,8 @@
|
|
|
12
23
|
- 发布前先跑 `node --check` —— 已经发出去的 npm 版本撤不回、也盖不掉;
|
|
13
24
|
- 认证是显式的:用 `NPM_TRUSTED_PUBLISHING` 变量开启 npm trusted publishing(OIDC、`id-token: write`、带 `--provenance`),或者配 `NPM_TOKEN` secret;两者都没有时只打一条 notice,不让 push 变红;
|
|
14
25
|
- 安装文档不再说「推 tag 就会发布」。
|
|
15
|
-
- `0.34.2`
|
|
26
|
+
- `0.34.2` 没有发出来:更早一次手动发布已经把这个版本号留在了注册表的暂存队列里,再发同一个版本会被拒(`E409 Cannot publish over previously staged version "0.34.2"`)。
|
|
27
|
+
- `0.34.3` 由 CI 发了,但只进了那个暂存队列:trusted publisher 当时只允许 stage 发布、不允许直接发布,而「进暂存」时 `npm publish` 同样退出 0,看上去像成功了。后来打开直接发布后它才上线。`0.34.4` 是第一个直接发布、不需要人批的版本。
|
|
16
28
|
|
|
17
29
|
## 0.34.1
|
|
18
30
|
|
package/INSTALL.md
CHANGED
|
@@ -19,7 +19,7 @@ For the complete steps, see [docs/en/installation.md](docs/en/installation.md).
|
|
|
19
19
|
### Option 1: Install from npm (shortest, upgradeable)
|
|
20
20
|
|
|
21
21
|
```
|
|
22
|
-
plugin_manager install_bundle target="dsh-d365-kit@0.34.
|
|
22
|
+
plugin_manager install_bundle target="dsh-d365-kit@0.34.5"
|
|
23
23
|
```
|
|
24
24
|
|
|
25
25
|
This needs the package to be published to the registry pnpm uses — public npm or an internal mirror. The name has no scope, so no scope mapping is required.
|
|
@@ -29,7 +29,7 @@ This needs the package to be published to the registry pnpm uses — public npm
|
|
|
29
29
|
### Option 2: Install from Git (no registry needed)
|
|
30
30
|
|
|
31
31
|
```
|
|
32
|
-
plugin_manager install_bundle target="git+https://<your Git>/<group>/dsh-d365-kit.git#v0.34.
|
|
32
|
+
plugin_manager install_bundle target="git+https://<your Git>/<group>/dsh-d365-kit.git#v0.34.5"
|
|
33
33
|
```
|
|
34
34
|
|
|
35
35
|
### Option 3: Install from a local directory
|
package/INSTALL.zh-CN.md
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
### 方式一:从 npm 装(最短,可升级)
|
|
19
19
|
|
|
20
20
|
```
|
|
21
|
-
plugin_manager install_bundle target="dsh-d365-kit@0.34.
|
|
21
|
+
plugin_manager install_bundle target="dsh-d365-kit@0.34.5"
|
|
22
22
|
```
|
|
23
23
|
|
|
24
24
|
前提是包已经发到 pnpm 用的那个 registry——公网 npm 或公司内网镜像都行。包名没有 scope,不需要配 scope 映射。
|
|
@@ -28,7 +28,7 @@ plugin_manager install_bundle target="dsh-d365-kit@0.34.3"
|
|
|
28
28
|
### 方式二:从 Git 装(不需要 registry)
|
|
29
29
|
|
|
30
30
|
```
|
|
31
|
-
plugin_manager install_bundle target="git+https://<你的 Git>/<group>/dsh-d365-kit.git#v0.34.
|
|
31
|
+
plugin_manager install_bundle target="git+https://<你的 Git>/<group>/dsh-d365-kit.git#v0.34.5"
|
|
32
32
|
```
|
|
33
33
|
|
|
34
34
|
### 方式三:从本地目录装
|
package/README.md
CHANGED
|
@@ -116,10 +116,10 @@ Prerequisites: DSH desktop; network access to `*.dynamics.com`; a read-only appl
|
|
|
116
116
|
|
|
117
117
|
```bash
|
|
118
118
|
# From npm (shortest, upgradeable; works once the package is published)
|
|
119
|
-
plugin_manager install_bundle target="dsh-d365-kit@0.34.
|
|
119
|
+
plugin_manager install_bundle target="dsh-d365-kit@0.34.5"
|
|
120
120
|
|
|
121
121
|
# From Git (no registry needed, pin a tag)
|
|
122
|
-
plugin_manager install_bundle target="git+https://<your-git>/<group>/dsh-d365-kit.git#v0.34.
|
|
122
|
+
plugin_manager install_bundle target="git+https://<your-git>/<group>/dsh-d365-kit.git#v0.34.5"
|
|
123
123
|
|
|
124
124
|
# From a local directory (link: code changes take effect immediately)
|
|
125
125
|
plugin_manager install_bundle target="D:\path\to\dsh-d365-kit"
|
package/README.zh-CN.md
CHANGED
|
@@ -116,10 +116,10 @@ DSH Web UI 的「设置 → D365 工具箱」,六个标签页:
|
|
|
116
116
|
|
|
117
117
|
```bash
|
|
118
118
|
# 从 npm 安装(最短,可升级;包发布到 npm 或内网 registry 后可用)
|
|
119
|
-
plugin_manager install_bundle target="dsh-d365-kit@0.34.
|
|
119
|
+
plugin_manager install_bundle target="dsh-d365-kit@0.34.5"
|
|
120
120
|
|
|
121
121
|
# 从 Git 安装(不需要 registry,可钉 tag)
|
|
122
|
-
plugin_manager install_bundle target="git+https://<your-git>/<group>/dsh-d365-kit.git#v0.34.
|
|
122
|
+
plugin_manager install_bundle target="git+https://<your-git>/<group>/dsh-d365-kit.git#v0.34.5"
|
|
123
123
|
|
|
124
124
|
# 或从本地目录安装(link,改代码立刻生效)
|
|
125
125
|
plugin_manager install_bundle target="D:\path\to\dsh-d365-kit"
|
package/docs/en/installation.md
CHANGED
|
@@ -20,7 +20,7 @@ A read-only application user is enough; for the write capabilities, messages suc
|
|
|
20
20
|
### Option 1: install from npm (shortest)
|
|
21
21
|
|
|
22
22
|
```bash
|
|
23
|
-
plugin_manager install_bundle target="dsh-d365-kit@0.34.
|
|
23
|
+
plugin_manager install_bundle target="dsh-d365-kit@0.34.5"
|
|
24
24
|
```
|
|
25
25
|
|
|
26
26
|
Works once the package is published to the registry pnpm is configured against — public npm or an internal mirror. The package has no scope, so no scope mapping is needed. To install a specific version pin it as `dsh-d365-kit@<version>`.
|
|
@@ -30,7 +30,7 @@ Works once the package is published to the registry pnpm is configured against
|
|
|
30
30
|
### Option 2: install from Git (no registry needed)
|
|
31
31
|
|
|
32
32
|
```bash
|
|
33
|
-
plugin_manager install_bundle target="git+https://<your-git>/<group>/dsh-d365-kit.git#v0.34.
|
|
33
|
+
plugin_manager install_bundle target="git+https://<your-git>/<group>/dsh-d365-kit.git#v0.34.5"
|
|
34
34
|
```
|
|
35
35
|
|
|
36
36
|
DSH pulls the package from git with pnpm. After installing, check:
|
|
@@ -128,7 +128,7 @@ If both return content, it works. The first `d365_sync` pulls the full metadata
|
|
|
128
128
|
```bash
|
|
129
129
|
# installed from Git
|
|
130
130
|
plugin_manager remove_bundle target=dsh-d365-kit
|
|
131
|
-
plugin_manager install_bundle target="git+https://<your-git>/<group>/dsh-d365-kit.git#v0.34.
|
|
131
|
+
plugin_manager install_bundle target="git+https://<your-git>/<group>/dsh-d365-kit.git#v0.34.5"
|
|
132
132
|
```
|
|
133
133
|
|
|
134
134
|
After changing the version number you must **remove first, then install**: link/pnpm returns `already-installed` when it believes the package exists, and does not replace the code.
|
package/docs/en/tools.md
CHANGED
|
@@ -108,7 +108,9 @@ Queries metadata by category. 20 resource types:
|
|
|
108
108
|
|
|
109
109
|
A few real constraints:
|
|
110
110
|
|
|
111
|
-
- The
|
|
111
|
+
- The definition endpoints (`EntityDefinitions`, `RelationshipDefinitions`, `GlobalOptionSetDefinitions`) **do not accept** `$top`; the tool simply does not send it for those paths (it used to pay one rejected request per query). `$orderby` is only dropped when the endpoint refuses it.
|
|
112
|
+
- Degradation is limited to `$select`: a column the endpoint rejects is removed one at a time and named under the table ("the endpoint does not recognise these columns, dropped: …"). A name written in `$filter` / `$orderby` is **never** rewritten — that would change the question — it fails as `unknown-property` and says whether it is a casing, lookup or cast problem.
|
|
113
|
+
- Every per-environment heading carries "N requests / seconds", so a query slowed down by fallback retries is visible at a glance.
|
|
112
114
|
- Display names are multilingual, so compare across environments by **logical name**, not display name;
|
|
113
115
|
- Entity set names can be customized; do not hard-code the plural form — use the `entities` resource or `d365_data`'s automatic resolution;
|
|
114
116
|
- `workflows` / `business_rules` return the **definition row only** by default (`type eq 1`): Dataverse stores a second, same-named activation row (`type eq 2`, different `workflowid`) for every process, which otherwise looks like a duplicate and doubles the count. An explicit `filter` (e.g. `type eq 2`) replaces the default instead of ANDing with it, so it never collapses to an empty result.
|
|
@@ -274,6 +276,13 @@ Counting prefers `$apply=aggregate($count as count)` (unaffected by the 5,000 ca
|
|
|
274
276
|
|
|
275
277
|
`query` distinguishes three outcomes in its heading: `共 N 行` (all N rows), `已列 N 条 / 共 M 条` (N of M shown), or `返回 N 行(可能还有更多)` (there may be more). **A GUID copied from another environment is a 404 when it is absent here**, and the `id` path reports `record-not-found` instead of handing back an empty table.
|
|
276
278
|
|
|
279
|
+
Two things that are easy to misread, both stated in the digest:
|
|
280
|
+
|
|
281
|
+
- **A lookup cannot be selected by its attribute name.** `select=businessunitid` comes back empty; the value lives in `_businessunitid_value`. The tool switches the column for you and prints "businessunitid is a lookup: the value is in _businessunitid_value …" under the table — read that line before concluding the field is empty.
|
|
282
|
+
- **Every per-environment heading carries "N requests / seconds".** Four requests for one query usually means entity-set resolution + fetch + count; a much larger number means fallback retries did the extra work (look for the "columns dropped" note).
|
|
283
|
+
|
|
284
|
+
`sample` says "(more rows exist, the sample shows only the first few)" so that "I looked at 5 rows" is never read as "there are 5 rows".
|
|
285
|
+
|
|
277
286
|
Use it to learn "how many records still use this value" before changing an option set, deleting a field, or narrowing a filter.
|
|
278
287
|
|
|
279
288
|
---
|
package/docs/zh/installation.md
CHANGED
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
### 方式一:从 npm 安装(最短)
|
|
21
21
|
|
|
22
22
|
```bash
|
|
23
|
-
plugin_manager install_bundle target="dsh-d365-kit@0.34.
|
|
23
|
+
plugin_manager install_bundle target="dsh-d365-kit@0.34.5"
|
|
24
24
|
```
|
|
25
25
|
|
|
26
26
|
前提是包已经发到 pnpm 配置的 registry——公网 npm 或内网镜像都行。包名没有 scope,不需要配 scope 映射。要装指定版本就写 `dsh-d365-kit@<版本>`。
|
|
@@ -30,7 +30,7 @@ plugin_manager install_bundle target="dsh-d365-kit@0.34.3"
|
|
|
30
30
|
### 方式二:从 Git 安装(不需要 registry)
|
|
31
31
|
|
|
32
32
|
```bash
|
|
33
|
-
plugin_manager install_bundle target="git+https://<your-git>/<group>/dsh-d365-kit.git#v0.34.
|
|
33
|
+
plugin_manager install_bundle target="git+https://<your-git>/<group>/dsh-d365-kit.git#v0.34.5"
|
|
34
34
|
```
|
|
35
35
|
|
|
36
36
|
DSH 用 pnpm 从 git 拉包。装完确认:
|
|
@@ -128,7 +128,7 @@ d365_metadata resource=entities top=3
|
|
|
128
128
|
```bash
|
|
129
129
|
# 从 Git 安装的
|
|
130
130
|
plugin_manager remove_bundle target=dsh-d365-kit
|
|
131
|
-
plugin_manager install_bundle target="git+https://<your-git>/<group>/dsh-d365-kit.git#v0.34.
|
|
131
|
+
plugin_manager install_bundle target="git+https://<your-git>/<group>/dsh-d365-kit.git#v0.34.5"
|
|
132
132
|
```
|
|
133
133
|
|
|
134
134
|
改版本号后必须**先 remove 再 install**:link/pnpm 认为包已存在时会返回 `already-installed`,不会替换代码。
|
package/docs/zh/tools.md
CHANGED
|
@@ -108,7 +108,9 @@
|
|
|
108
108
|
|
|
109
109
|
几个真实约束:
|
|
110
110
|
|
|
111
|
-
-
|
|
111
|
+
- 元数据定义端点(`EntityDefinitions` / `RelationshipDefinitions` / `GlobalOptionSetDefinitions`)**不接受** `$top`,工具对这类路径直接不发(以前每次都要先被拒一次,白花一个请求);`$orderby` 被拒时才降级去掉。
|
|
112
|
+
- 降级只发生在 `$select` 上:端点不认的列会被逐列去掉,并在表下点名「这些列该环境不认,已去掉:…」。名字写在 `$filter` / `$orderby` 里时**不会**被改写(那等于换一个问题)——直接报 `unknown-property`,并说明是大小写、lookup 还是 cast 的问题。
|
|
113
|
+
- 每个环境的标题带「N 请求 / 秒数」,被降级重试拖慢时一眼能看出来。
|
|
112
114
|
- 显示名有多语言,跨环境比较请用**逻辑名**,不要用显示名;
|
|
113
115
|
- 实体集名可被自定义,不要硬编码复数形式,用 `entities` 资源或 `d365_data` 的自动解析;
|
|
114
116
|
- `workflows` / `business_rules` 默认只取**定义行**(`type eq 1`):Dataverse 给每个流程另存一条同名、不同 `workflowid` 的激活行(`type eq 2`),不过滤会看起来像重复、计数也会翻倍;显式传 `filter`(如 `type eq 2`)时默认过滤自动让位,不会 AND 成空集。
|
|
@@ -274,6 +276,13 @@ manifest 是区分「环境里没有」与「没采到」的唯一依据。全
|
|
|
274
276
|
|
|
275
277
|
`query` 的标题会区分三种情况:`共 N 行`、`已列 N 条 / 共 M 条(未列全…)`、`返回 N 行(可能还有更多)`。**跨环境复制的 GUID 在目标环境不存在时是 404**,`id` 分支会明确报 `record-not-found`,而不是给一张空表。
|
|
276
278
|
|
|
279
|
+
两个容易看错的地方,摘要会直接说:
|
|
280
|
+
|
|
281
|
+
- **lookup 列不能按字段名 select**。`select=businessunitid` 拿回来是空列,值在 `_businessunitid_value`。工具会自动改显示 `_businessunitid_value`,并在表下写「businessunitid 是 lookup:值在 _businessunitid_value…」——所以看到空列时先读那行提示,不要当成「这一列没值」。
|
|
282
|
+
- **每个环境的标题都带「N 请求 / X 秒」**。一次查询要 4 个请求,通常是「实体集解析 + 取数 + 计数」;如果数字明显偏大,说明被降级重试拖慢了(看表下的「这些列该环境不认」)。
|
|
283
|
+
|
|
284
|
+
`sample` 会标「(还有更多,样例只取前几条)」,避免把「只看了 5 行」当成「一共 5 行」。
|
|
285
|
+
|
|
277
286
|
用途:改选项集、删字段、收窄筛选条件之前,先知道「这个值还有多少条记录在用」。
|
|
278
287
|
|
|
279
288
|
---
|
package/lib/client.js
CHANGED
|
@@ -435,6 +435,35 @@ export class DataverseClient {
|
|
|
435
435
|
}
|
|
436
436
|
}
|
|
437
437
|
|
|
438
|
+
/** `Could not find a property named 'x' on type '...'` — the name is the group. */
|
|
439
|
+
const MISSING_PROPERTY = /property named '([^']+)'/
|
|
440
|
+
|
|
441
|
+
/** True when `expression` names `name` as a whole word (not a substring). */
|
|
442
|
+
function mentions(expression, name) {
|
|
443
|
+
if (!expression) return false
|
|
444
|
+
const escaped = String(name).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
445
|
+
return new RegExp(`(^|[^A-Za-z0-9_])${escaped}($|[^A-Za-z0-9_])`, 'i').test(String(expression))
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* Explain a rejected property name.
|
|
450
|
+
*
|
|
451
|
+
* The old behaviour was to treat every 0x80060888 as "some `$select` column is
|
|
452
|
+
* wrong" and keep narrowing the request. When the name actually came from the
|
|
453
|
+
* caller's `$filter`, that repair can never succeed: the tool spent two extra
|
|
454
|
+
* requests and then reported the original error, and — worse — dropping the
|
|
455
|
+
* whole `$select` could return a 200 whose columns are not the ones asked for.
|
|
456
|
+
*/
|
|
457
|
+
function unknownPropertyHint(name, params) {
|
|
458
|
+
if (mentions(params?.$filter, name)) {
|
|
459
|
+
return `名字 "${name}" 出现在 $filter 里:工具不会改写 filter(那等于换一个问题),改正后重试。`
|
|
460
|
+
}
|
|
461
|
+
if (mentions(params?.$orderby, name)) {
|
|
462
|
+
return `名字 "${name}" 出现在 $orderby 里:改正后重试。`
|
|
463
|
+
}
|
|
464
|
+
return `这个端点不认 "${name}":元数据端点属性名大小写敏感;lookup 用 _<字段>_value,派生属性先 cast;也可能是这一列不在当前选择集里。`
|
|
465
|
+
}
|
|
466
|
+
|
|
438
467
|
/**
|
|
439
468
|
* GET with progressive fallbacks for query options real deployments reject.
|
|
440
469
|
*
|
|
@@ -442,8 +471,12 @@ export class DataverseClient {
|
|
|
442
471
|
* endpoint or version: a metadata endpoint answers `$orderby` with
|
|
443
472
|
* 0x80060888 "The query parameter ... is not supported", and a table may lack a
|
|
444
473
|
* property this plugin asked for ("Could not find a property named 'modifiedon'
|
|
445
|
-
* on type 'Microsoft.Dynamics.CRM.systemform'").
|
|
446
|
-
*
|
|
474
|
+
* on type 'Microsoft.Dynamics.CRM.systemform'").
|
|
475
|
+
*
|
|
476
|
+
* Narrowing is deliberately limited to the `$select` list: dropping a column
|
|
477
|
+
* the server rejects is harmless, while dropping `$orderby` or the whole
|
|
478
|
+
* `$select` changes the answer. A property that is not in `$select` — or that
|
|
479
|
+
* also appears in `$filter` — is reported as `unknown-property` immediately.
|
|
447
480
|
* @returns `{ page, selectDropped, orderbyDropped, droppedFields }`.
|
|
448
481
|
*/
|
|
449
482
|
export async function getPagedWithSelectFallback(client, path, params, limit, signal) {
|
|
@@ -457,36 +490,58 @@ export async function getPagedWithSelectFallback(client, path, params, limit, si
|
|
|
457
490
|
if (error?.status !== 400) throw error
|
|
458
491
|
const text = String(error.message ?? '')
|
|
459
492
|
|
|
460
|
-
const missing =
|
|
461
|
-
if (missing
|
|
462
|
-
const
|
|
463
|
-
const
|
|
464
|
-
|
|
493
|
+
const missing = MISSING_PROPERTY.exec(text)
|
|
494
|
+
if (missing) {
|
|
495
|
+
const name = missing[1]
|
|
496
|
+
const fields = String(current.$select ?? '').split(',').map((item) => item.trim()).filter(Boolean)
|
|
497
|
+
const selected = fields.some((field) => field.toLowerCase() === name.toLowerCase())
|
|
498
|
+
// Only a column we chose to select can be dropped. A name the caller
|
|
499
|
+
// wrote in $filter/$orderby is their question, not our vocabulary.
|
|
500
|
+
if (!selected || mentions(current.$filter, name) || mentions(current.$orderby, name)) {
|
|
501
|
+
throw new D365Error(text, {
|
|
502
|
+
code: 'unknown-property',
|
|
503
|
+
status: 400,
|
|
504
|
+
body: error.body,
|
|
505
|
+
hint: unknownPropertyHint(name, current),
|
|
506
|
+
})
|
|
507
|
+
}
|
|
508
|
+
const kept = fields.filter((field) => field.toLowerCase() !== name.toLowerCase())
|
|
509
|
+
if (kept.length) {
|
|
465
510
|
current.$select = kept.join(',')
|
|
466
|
-
dropped.droppedFields.push(
|
|
511
|
+
dropped.droppedFields.push(name)
|
|
467
512
|
continue
|
|
468
513
|
}
|
|
514
|
+
// Every selected column was rejected: returning default columns would
|
|
515
|
+
// look like success with the wrong shape.
|
|
516
|
+
throw new D365Error(text, {
|
|
517
|
+
code: 'unknown-property',
|
|
518
|
+
status: 400,
|
|
519
|
+
body: error.body,
|
|
520
|
+
hint: unknownPropertyHint(name, current),
|
|
521
|
+
})
|
|
469
522
|
}
|
|
470
523
|
|
|
471
524
|
// "The query parameter '...' is not supported": the name is often
|
|
472
525
|
// redacted before it reaches us, so narrow the request by dropping the
|
|
473
526
|
// options in turn, cheapest first. `$top` is added inside getPaged, so
|
|
474
527
|
// the check is on the marker, not on the params object.
|
|
475
|
-
if (/query parameter/i.test(text)
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
528
|
+
if (/query parameter/i.test(text)) {
|
|
529
|
+
if (!dropped.topDropped) {
|
|
530
|
+
current.__noTop = true
|
|
531
|
+
delete current.$top
|
|
532
|
+
dropped.topDropped = true
|
|
533
|
+
continue
|
|
534
|
+
}
|
|
535
|
+
if (current.$orderby) {
|
|
536
|
+
delete current.$orderby
|
|
537
|
+
dropped.orderbyDropped = true
|
|
538
|
+
continue
|
|
539
|
+
}
|
|
540
|
+
if (current.$select) {
|
|
541
|
+
delete current.$select
|
|
542
|
+
dropped.selectDropped = true
|
|
543
|
+
continue
|
|
544
|
+
}
|
|
490
545
|
}
|
|
491
546
|
throw error
|
|
492
547
|
}
|
package/lib/error-codes.js
CHANGED
|
@@ -15,7 +15,7 @@ export const ERROR_CODES = {
|
|
|
15
15
|
},
|
|
16
16
|
'0x80060888': {
|
|
17
17
|
summary: '请求里有一个这个端点不认识的属性名或查询参数',
|
|
18
|
-
hint: '
|
|
18
|
+
hint: '属性名大小写敏感:元数据端点(EntityDefinitions 等)要 LogicalName,实体表上要逻辑名小写(name、createdon);lookup 列要写 _<字段>_value;派生属性(DefaultValue、Targets、Options)要先用 cast 指到具体类型。工具只会在 $select 里剔除同名的那一列——名字写在 $filter / $orderby 里时直接报 unknown-property(改写 filter 等于换一个问题)。只有「查询参数不被支持」时,工具才会逐项去掉 $top / $orderby / $select 重试。',
|
|
19
19
|
},
|
|
20
20
|
'0x80040216': {
|
|
21
21
|
summary: '对象不存在:按这个名字或 id 找不到记录',
|
package/lib/tool-data.js
CHANGED
|
@@ -162,6 +162,47 @@ async function countRows(client, set, filter, signal) {
|
|
|
162
162
|
})
|
|
163
163
|
}
|
|
164
164
|
|
|
165
|
+
/**
|
|
166
|
+
* Keep the columns the caller asked for even when the response disagrees.
|
|
167
|
+
*
|
|
168
|
+
* Selecting a lookup by its attribute name returns nothing — Dataverse exposes
|
|
169
|
+
* the value as `_<name>_value` — and an unselectable or misspelled column
|
|
170
|
+
* silently comes back absent. Both used to render as an empty cell with no
|
|
171
|
+
* explanation, which reads as "this record has no value".
|
|
172
|
+
* @returns `{ columns, notes }` — `notes` is empty when everything resolved.
|
|
173
|
+
*/
|
|
174
|
+
function reconcileColumns(columns, rows) {
|
|
175
|
+
const notes = []
|
|
176
|
+
if (!columns?.length || !rows.length) return { columns: columns ?? [], notes }
|
|
177
|
+
const present = new Set(Object.keys(rows[0]))
|
|
178
|
+
const resolved = []
|
|
179
|
+
for (const name of columns) {
|
|
180
|
+
if (present.has(name)) {
|
|
181
|
+
if (!resolved.includes(name)) resolved.push(name)
|
|
182
|
+
continue
|
|
183
|
+
}
|
|
184
|
+
const lookup = `_${name}_value`
|
|
185
|
+
if (present.has(lookup)) {
|
|
186
|
+
if (!resolved.includes(lookup)) resolved.push(lookup)
|
|
187
|
+
notes.push(`${name} 是 lookup:值在 ${lookup},已改显示那一列(写入时用 ${name}@odata.bind)。`)
|
|
188
|
+
continue
|
|
189
|
+
}
|
|
190
|
+
if (!resolved.includes(name)) resolved.push(name)
|
|
191
|
+
notes.push(`${name} 没有返回:这个名字不能 select(或大小写不对),不是「记录里为空」。`)
|
|
192
|
+
}
|
|
193
|
+
return { columns: resolved, notes }
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** One-line cost tag: a retry-happy call should look expensive in the summary. */
|
|
197
|
+
function costText(requests, started) {
|
|
198
|
+
const seconds = Math.max(0, Date.now() - started) / 1000
|
|
199
|
+
return `(${requests} 请求 / ${seconds.toFixed(1)}s)`
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function notesText(notes) {
|
|
203
|
+
return notes?.length ? `\n注意:\n${notes.map((note) => `- ${note}`).join('\n')}` : ''
|
|
204
|
+
}
|
|
205
|
+
|
|
165
206
|
export function register(ctx, state) {
|
|
166
207
|
return ctx.tools.register({
|
|
167
208
|
...definition,
|
|
@@ -180,6 +221,7 @@ export function register(ctx, state) {
|
|
|
180
221
|
const artifact = { action, entity: args.entity, filter: args.filter, environments: {} }
|
|
181
222
|
|
|
182
223
|
for (const env of envs) {
|
|
224
|
+
const started = Date.now()
|
|
183
225
|
const client = makeClient(env, config)
|
|
184
226
|
const { set, logical } = await resolveEntitySet(client, args.entity, exec?.signal)
|
|
185
227
|
const filter = args.filter
|
|
@@ -187,7 +229,7 @@ export function register(ctx, state) {
|
|
|
187
229
|
if (action === 'count') {
|
|
188
230
|
const count = await countRows(client, set, filter, exec?.signal)
|
|
189
231
|
artifact.environments[env.key] = { entitySet: set, count }
|
|
190
|
-
reports.push(`${env.key}:${logical} 共 ${count} 行${filter ? `(筛选:${filter})` : ''}`)
|
|
232
|
+
reports.push(`${env.key}:${logical} 共 ${count} 行${filter ? `(筛选:${filter})` : ''}${costText(client.counters.requests, started)}`)
|
|
191
233
|
continue
|
|
192
234
|
}
|
|
193
235
|
|
|
@@ -239,8 +281,10 @@ export function register(ctx, state) {
|
|
|
239
281
|
throw new D365Error(`${env.key} 没有返回 ${logical}(${key})`, { code: 'record-not-found' })
|
|
240
282
|
}
|
|
241
283
|
artifact.environments[env.key] = { entitySet: set, id: key, rows: [row] }
|
|
242
|
-
const
|
|
243
|
-
|
|
284
|
+
const requested = columns ?? Object.keys(row).filter((name) => !name.startsWith('@')).slice(0, 8)
|
|
285
|
+
const shown = reconcileColumns(requested, [row])
|
|
286
|
+
reports.push(`${env.key}:${logical}(${key})${costText(client.counters.requests, started)}\n`
|
|
287
|
+
+ `${formatTable([row], shown.columns, { width: 40 })}${notesText(shown.notes)}`)
|
|
244
288
|
continue
|
|
245
289
|
}
|
|
246
290
|
// Do not send `$top` here: `getPaged` asks for one row more than the
|
|
@@ -253,26 +297,33 @@ export function register(ctx, state) {
|
|
|
253
297
|
const page = await client.getPaged(set, params, top, exec?.signal)
|
|
254
298
|
const rows = page.records ?? []
|
|
255
299
|
const total = page.hasMore ? await countRows(client, set, filter, exec?.signal).catch(() => undefined) : rows.length
|
|
256
|
-
const
|
|
300
|
+
const requested = columns ?? Object.keys(rows[0] ?? {}).filter((name) => !name.startsWith('@')).slice(0, 8)
|
|
301
|
+
const shown = reconcileColumns(requested, rows)
|
|
257
302
|
artifact.environments[env.key] = { entitySet: set, params, total, rows }
|
|
258
303
|
const head = total === undefined
|
|
259
304
|
? `${env.key}:${logical} 返回 ${rows.length} 行(可能还有更多)`
|
|
260
305
|
: total === rows.length
|
|
261
306
|
? `${env.key}:${logical} 共 ${total} 行`
|
|
262
307
|
: `${env.key}:已列 ${rows.length} 条 / 共 ${total} 条(未列全,加 filter 或调 top)`
|
|
263
|
-
reports.push(`${head}
|
|
308
|
+
reports.push(`${head}${costText(client.counters.requests, started)}\n`
|
|
309
|
+
+ `${formatTable(rows, shown.columns, { width: 40 })}${notesText(shown.notes)}`)
|
|
264
310
|
continue
|
|
265
311
|
}
|
|
266
312
|
|
|
267
313
|
const top = Number.isFinite(args.top) ? Math.max(1, Math.min(100, args.top)) : 5
|
|
268
|
-
const params = {
|
|
314
|
+
const params = {}
|
|
269
315
|
if (filter) params.$filter = filter
|
|
270
316
|
if (args.select) params.$select = args.select
|
|
271
317
|
const page = await client.getPaged(set, params, top, exec?.signal)
|
|
272
318
|
const rows = page.records ?? []
|
|
273
|
-
const
|
|
274
|
-
|
|
275
|
-
|
|
319
|
+
const requested = args.select
|
|
320
|
+
? args.select.split(',').map((item) => item.trim()).filter(Boolean)
|
|
321
|
+
: Object.keys(rows[0] ?? {}).filter((name) => !name.startsWith('@')).slice(0, 5)
|
|
322
|
+
const shown = reconcileColumns(requested, rows)
|
|
323
|
+
artifact.environments[env.key] = { entitySet: set, rows, hasMore: page.hasMore }
|
|
324
|
+
const more = page.hasMore ? '(还有更多,样例只取前几条)' : ''
|
|
325
|
+
reports.push(`${env.key}:${logical} 样例 ${rows.length} 行${more}${costText(client.counters.requests, started)}\n`
|
|
326
|
+
+ `${formatTable(rows, shown.columns, { width: 32 })}${notesText(shown.notes)}`)
|
|
276
327
|
}
|
|
277
328
|
|
|
278
329
|
const file = await writeArtifact(config, `data-${action}`, artifact)
|
package/lib/tool-metadata.js
CHANGED
|
@@ -210,6 +210,13 @@ function buildRequest(resource, args, config) {
|
|
|
210
210
|
|
|
211
211
|
const keyed = keyedByName ?? keyedByAttribute
|
|
212
212
|
|
|
213
|
+
const path = keyed ?? def.path(args)
|
|
214
|
+
// The definition endpoints reject `$top` with 0x80060888 "The query
|
|
215
|
+
// parameter ... is not supported", and `getPaged` adds `$top` unless told not
|
|
216
|
+
// to — so without this every such query pays one wasted 400 before the
|
|
217
|
+
// fallback notices. Declaring it up front removes a whole round trip.
|
|
218
|
+
const rejectsTop = /^(entitydefinitions|relationshipdefinitions|globaloptionsetdefinitions)\b/i.test(path)
|
|
219
|
+
|
|
213
220
|
const select = [def.select, args.select]
|
|
214
221
|
.filter(Boolean)
|
|
215
222
|
.join(',')
|
|
@@ -222,7 +229,7 @@ function buildRequest(resource, args, config) {
|
|
|
222
229
|
: def.defaultTop ?? config.maxRecords
|
|
223
230
|
|
|
224
231
|
return {
|
|
225
|
-
path
|
|
232
|
+
path,
|
|
226
233
|
keyed: Boolean(keyed),
|
|
227
234
|
top,
|
|
228
235
|
params: {
|
|
@@ -232,6 +239,7 @@ function buildRequest(resource, args, config) {
|
|
|
232
239
|
$filter: keyed ? undefined : composeFilter(def, args),
|
|
233
240
|
$orderby: keyed ? undefined : (args.orderby ?? def.orderby),
|
|
234
241
|
$expand: keyed ? undefined : (args.expand ?? def.expand),
|
|
242
|
+
__noTop: !keyed && rejectsTop ? true : undefined,
|
|
235
243
|
},
|
|
236
244
|
}
|
|
237
245
|
}
|
|
@@ -279,6 +287,7 @@ async function queryEnvironment(env, config, args, signal) {
|
|
|
279
287
|
const client = makeClient(env, config)
|
|
280
288
|
const started = Date.now()
|
|
281
289
|
let selectDropped = false
|
|
290
|
+
let droppedFields = []
|
|
282
291
|
let page
|
|
283
292
|
if (request.keyed) {
|
|
284
293
|
// Keyed metadata reads answer with a bare object (no `value` wrapper) and
|
|
@@ -305,6 +314,7 @@ async function queryEnvironment(env, config, args, signal) {
|
|
|
305
314
|
const paged = await getPagedWithSelectFallback(client, request.path, request.params, request.top, signal)
|
|
306
315
|
page = paged.page
|
|
307
316
|
selectDropped = paged.selectDropped
|
|
317
|
+
droppedFields = paged.droppedFields ?? []
|
|
308
318
|
}
|
|
309
319
|
const rows = page.records.map((row) => flattenExpanded(flattenLabels(row)))
|
|
310
320
|
// The organization table has no portable `version` column; take it from the
|
|
@@ -321,6 +331,7 @@ async function queryEnvironment(env, config, args, signal) {
|
|
|
321
331
|
hasMore: page.hasMore,
|
|
322
332
|
limit: request.top,
|
|
323
333
|
selectDropped,
|
|
334
|
+
droppedFields,
|
|
324
335
|
requests: client.counters.requests,
|
|
325
336
|
elapsedMs: Date.now() - started,
|
|
326
337
|
filter: request.params.$filter,
|
|
@@ -357,7 +368,8 @@ export function register(ctx, state) {
|
|
|
357
368
|
const totalText = result.hasMore && typeof result.total === 'number'
|
|
358
369
|
? `已列 ${result.rows.length} 条 / 共 ${result.total >= 5000 ? '≥5000' : result.total} 条(未列全,加 filter 或 top 缩小)`
|
|
359
370
|
: `${result.rows.length} 行${result.hasMore ? `(已达上限 ${result.limit},还有更多;加 filter 或 top 缩小)` : ''}`
|
|
360
|
-
|
|
371
|
+
const seconds = Math.max(0, result.elapsedMs ?? 0) / 1000
|
|
372
|
+
lines.push(`### ${result.env.key} — ${totalText}(${result.requests ?? 0} 请求 / ${seconds.toFixed(1)}s)`)
|
|
361
373
|
if (!result.rows.length) {
|
|
362
374
|
lines.push(args.name
|
|
363
375
|
? `该环境没有名为 ${args.name} 的${def?.summary ?? resource}(按名称直取返回 404)。`
|
|
@@ -366,7 +378,11 @@ export function register(ctx, state) {
|
|
|
366
378
|
}
|
|
367
379
|
const columns = displayColumns(resource, result.rows, args.select)
|
|
368
380
|
lines.push(formatTable(result.rows, columns, { limit: INLINE_ROWS }))
|
|
369
|
-
if (result.
|
|
381
|
+
if (result.droppedFields?.length) {
|
|
382
|
+
lines.push(`(这些列该环境不认,已去掉:${result.droppedFields.join('、')})`)
|
|
383
|
+
} else if (result.selectDropped) {
|
|
384
|
+
lines.push('(该环境不接受部分 $select 列,已自动去掉后重试,故列数可能偏少)')
|
|
385
|
+
}
|
|
370
386
|
}
|
|
371
387
|
|
|
372
388
|
const comparison = compareEnvironments(resource, results.map((result) => ({ env: result.env, rows: result.rows })))
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-d365-kit",
|
|
3
|
-
"version": "0.34.
|
|
3
|
+
"version": "0.34.5",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Dynamics 365 CE developer and deployment toolkit for DSH: metadata, layers, snapshots, migration risk, code checks, contract drift, environment health, plug-in traces, message invocation and business-record sync.",
|
|
6
6
|
"license": "MIT",
|