dsh-d365-kit 0.34.4 → 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 CHANGED
@@ -4,6 +4,17 @@
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.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
+
7
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.
@@ -13,7 +24,7 @@ Publishing is now part of the repository: `.github/workflows/publish.yml` publis
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
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"`.
16
- - `0.34.3` was published by CI but only into that same stage queue: the trusted publisher allowed stage publishing but not direct publishing, and `npm publish` exits 0 when it stages. `0.34.4` is the first version that goes live on its own — turn on "Publish directly" under the trusted publisher's allowed actions.
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.
17
28
 
18
29
  ## 0.34.1
19
30
 
@@ -4,6 +4,17 @@
4
4
 
5
5
  版本语义:修 bug 升 patch,加工具/加能力升 minor,改配置结构或改名升 major。
6
6
 
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
+
7
18
  ## 0.34.4
8
19
 
9
20
  发布进了仓库:`.github/workflows/publish.yml` 会在推送的 `package.json` 版本还没上架 npm 时自动发布。
@@ -13,7 +24,7 @@
13
24
  - 认证是显式的:用 `NPM_TRUSTED_PUBLISHING` 变量开启 npm trusted publishing(OIDC、`id-token: write`、带 `--provenance`),或者配 `NPM_TOKEN` secret;两者都没有时只打一条 notice,不让 push 变红;
14
25
  - 安装文档不再说「推 tag 就会发布」。
15
26
  - `0.34.2` 没有发出来:更早一次手动发布已经把这个版本号留在了注册表的暂存队列里,再发同一个版本会被拒(`E409 Cannot publish over previously staged version "0.34.2"`)。
16
- - `0.34.3` 由 CI 发了,但也只进了那个暂存队列:trusted publisher 只允许 stage 发布、不允许直接发布,而「进暂存」时 `npm publish` 同样是退出码 0,看上去像成功了。`0.34.4` 是第一个无需人工批准就上线的版本 —— 前提是在 trusted publisher 的 Allowed actions 里勾上「Publish directly」。
27
+ - `0.34.3` 由 CI 发了,但只进了那个暂存队列:trusted publisher 当时只允许 stage 发布、不允许直接发布,而「进暂存」时 `npm publish` 同样退出 0,看上去像成功了。后来打开直接发布后它才上线。`0.34.4` 是第一个直接发布、不需要人批的版本。
17
28
 
18
29
  ## 0.34.1
19
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.4"
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.4"
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.4"
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.4"
28
28
  ### 方式二:从 Git 装(不需要 registry)
29
29
 
30
30
  ```
31
- plugin_manager install_bundle target="git+https://<你的 Git>/<group>/dsh-d365-kit.git#v0.34.4"
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.4"
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.4"
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.4"
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.4"
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"
@@ -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.4"
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.4"
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.4"
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 metadata endpoints (`EntityDefinitions`) **do not accept** `$orderby` and `$top`; the client falls back and retries step by step;
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
  ---
@@ -20,7 +20,7 @@
20
20
  ### 方式一:从 npm 安装(最短)
21
21
 
22
22
  ```bash
23
- plugin_manager install_bundle target="dsh-d365-kit@0.34.4"
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.4"
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.4"
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.4"
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
- - 元数据端点(`EntityDefinitions`)**不接受** `$orderby` 与 `$top`,客户端会逐步降级重试;
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'"). Each rejection narrows the
446
- * request — one field at a time — instead of failing the whole resource.
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 = /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) {
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(missing[1])
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) && !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
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
  }
@@ -15,7 +15,7 @@ export const ERROR_CODES = {
15
15
  },
16
16
  '0x80060888': {
17
17
  summary: '请求里有一个这个端点不认识的属性名或查询参数',
18
- hint: '元数据端点(EntityDefinitions / GlobalOptionSetDefinitions 等)不接受 $top、$orderby、$filter 的部分组合;派生属性(如 DefaultValue、Targets、Options)要先用 cast 到具体类型才能 select。工具已经会逐列剔除并重试,仍失败说明名字本身写错了。',
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 shown = columns ?? Object.keys(row).filter((name) => !name.startsWith('@')).slice(0, 8)
243
- reports.push(`${env.key}:${logical}(${key})\n${formatTable([row], shown, { width: 40 })}`)
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 shown = columns ?? Object.keys(rows[0] ?? {}).filter((name) => !name.startsWith('@')).slice(0, 8)
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}\n${formatTable(rows, shown, { width: 40 })}`)
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 = { $top: top }
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 columns = args.select ? args.select.split(',').map((item) => item.trim()) : Object.keys(rows[0] ?? {}).slice(0, 5)
274
- artifact.environments[env.key] = { entitySet: set, rows }
275
- reports.push(`${env.key}:${logical} 样例 ${rows.length} 行\n${formatTable(rows, columns, { width: 32 })}`)
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)
@@ -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: keyed ?? def.path(args),
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
- lines.push(`### ${result.env.key} — ${totalText}`)
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.selectDropped) lines.push('(该环境不接受部分 $select 列,已自动去掉后重试,故列数可能偏少)')
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.4",
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",