Repository navigation
Commit 40e7bfc
fix(metadata-protocol): an offset with no limit reports the filtered total (#28611)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"}
-->
_Requested by **Leehom** · [project
thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1X2pz77pK1zfwZ6XMswiajh2)_
Fixes #28547
Clause-②: no
Metadata-protocol: none
## 维护者速读
- **改了什么:** 列表读取只给偏移(`$skip` / `offset`)不给条数时,`total`
现在是符合筛选条件的总行数,不再是“总行数减偏移”。
- **为什么改:** 旧逻辑只在有 limit 时才做计数,没 limit 时直接拿返回行数当总数;有偏移时就少算了偏移那么多行(14 行跳
3 行报 11,偏移超出末尾报 0)。
- **风险与回滚:** 低。只改 `findData` 分页块的一个条件:有偏移无条数的请求走已有的计数路径(多一次 COUNT
查询)。无偏移无条数、有条数、`$count=false` 三种行为不变。回滚即 revert 本 PR。
- **先看:** `packages/metadata-protocol/src/protocol.ts` 中 `if (pageLimit
!== undefined || pageOffset > 0)` 一行及 `$count=false` 分支的
`hasMore`;`protocol.offset-no-limit-total.test.ts`。
- **两分钟验证:** `pnpm --filter @objectstack/metadata-protocol exec vitest
run src/protocol.offset-no-limit-total.test.ts`,预期 11 个用例全绿。
- **需要你做的:** 无,席位落地。
## What changes
`findData` (`packages/metadata-protocol/src/protocol.ts`) treated a list
read as paged only when it carried a limit. Without one it kept `total =
records.length`, on the comment "without a limit the full result set is
returned". With an offset that is false: the read returns every match
after the offset, so `total` short-counted by exactly the offset.
The paged arm now opens on `pageLimit !== undefined || pageOffset > 0`.
An offset with no limit runs the same count a paged request runs, over
the same `where`, `search` and `searchFields` (#22790). Inside that arm:
- `$count` absent or true: one `engine.count()`, and `total` is the
filtered count. `hasMore` is true only while the offset plus the rows
returned falls short of the count, so it is false when every row after
the offset came back.
- `$count=false`: no count is issued and `total` is omitted (#10757).
`hasMore` now reads `pageLimit !== undefined && records.length ===
pageLimit`, so with no limit it is false (every row after the offset
came back). Before, the guard was not needed because this arm was
reached only with a limit.
Adding the offset back to `records.length` was measured and rejected: an
offset past the end returns no rows, so it would answer `total: 999` for
`{"offset":999}` over 14 rows. The count path gives 14.
A read with neither an offset nor a limit is unchanged: no count,
`total` is the set length. An explicit `offset: 0` is no offset
(`pageOffset` is computed as `> 0`).
## Why
The card's expected result: "`total` is the number of rows matching the
filter, whatever the offset, as it already is when a limit is present
(and as #22690 / #22588 settled for `$top=0`)." The Console always sends
`$top`, so API clients paging with `$skip` alone were the ones reading
the wrong number.
## Tests
New suite
`packages/metadata-protocol/src/protocol.offset-no-limit-total.test.ts`,
on an engine double that honours `offset` and `limit` as written and
counts over the same `where` (the shape of
`protocol.zero-limit-total.test.ts`), with 14 rows like the showcase's
`showcase_account`:
- `GET ?$skip=3&$count=true`: 11 rows, `total` 14, `hasMore` false, one
count.
- `POST {"offset":3,"count":true}`: the same.
- `{"offset":999,"count":true}`: no rows, `total` 14.
- The same offset with and without `$top=100` gives the same `total` and
rows.
- Absent `$count` with `skip=3`: `total` 14.
- CONTROL: a filter (`tier=b`, 4 rows) with `$skip=1` gives 3 rows and
`total` 4, and the count carries the `where`. A `$search` is passed to
both `find` and `count`.
- CONTROL, unchanged arms: no offset and no limit issues no count;
`offset: 0` issues no count; `offset:3, limit:5` issues one count,
`total` 14, `hasMore` true; `$count=false` with an offset (both the GET
and the POST spelling) issues no count, omits `total`, returns 11 rows,
`hasMore` false.
<details><summary>Red first: the suite against the base protocol.ts
(310cb96)</summary>
The base blob (`81ff63a0`) was written over the file and the suite run,
then restored with `git checkout HEAD -- …` (blob back to `5dac1154`,
`git diff HEAD` empty).
```
Tests 8 failed | 3 passed (11)
AssertionError: expected 11 to be 14 (GET ?$skip=3&$count=true)
AssertionError: expected 11 to be 14 (POST {"offset":3,"count":true})
AssertionError: expected +0 to be 14 ({"offset":999,"count":true})
AssertionError: expected 3 to be 4 (tier=b, $skip=1)
expected "vi.fn()" to be called 1 times, but got 0 times ($search)
{"$skip":"3","$count":"false"}: expected true to be false ($count=false still reported total)
```
The 3 that stay green are the unchanged-arm controls (no offset and no
limit, `offset: 0`, a limit).
</details>
<details><summary>Ablation: revert only the arm condition on the
committed fix</summary>
`node scripts/ablation-replace.mjs --file
packages/metadata-protocol/src/protocol.ts --anchor 'if (pageLimit !==
undefined || pageOffset > 0) {' --replacement 'if (pageLimit !==
undefined) {'`. The test imports `./protocol.js` from `src/`, so no
build is involved.
```
ablation-replace: ok mutation landed: anchor 1 -> 0, blob 5dac115 -> 161f280e49ec
anchor-new count: 0
anchor-old count: 1
Tests 8 failed | 3 passed (11)
ablation-replace: ok restored: blob == HEAD (5dac115) and `git diff HEAD` is empty
```
</details>
<details><summary>Package suite, typecheck, lint</summary>
- `pnpm --filter @objectstack/metadata-protocol build && … typecheck &&
… test` under the verify lock: `VERDICT command-exit 0`, `Test Files 290
passed | 4 skipped (294)`, `Tests 21905 passed | 26 skipped (21931)`.
The package `tsconfig.json` includes `src/**/*`, so typecheck covers the
new test file.
- Neighbouring suites `protocol.zero-limit-total.test.ts` and
`protocol.count-opt-out.test.ts` stay green (27 of 27 with the new
suite). The count-opt-out pin "`$count=false` without a limit is a
no-op" has no offset and is unchanged.
- `eslint packages/metadata-protocol --no-inline-config`: exit 0.
</details>
## Acceptance notes
Other producers of a list `total`, each with a conclusion:
- **RestServer `GET /data/:object` and `POST /data/:object/query`**
(`packages/rest/src/crud-routes.ts:164`, `:379`): both call
`p.findData`. Fixed here.
- **Dispatcher `GET /data/:object` and `POST /data/:object/query`**
(`runtime/src/domains/data.ts` → `action-execution.ts`
`callData('query')` → `protocol.findData`, `:779`): fixed here.
- **`findData`'s aggregate branch** (`groupBy` / `aggregations`): no
offset reaches `engine.aggregate`, and `total` is the length of the full
grouped set. Not affected.
- **MCP HTTP `query_records`**
(`packages/mcp/src/mcp-http-tools.ts:1127`, via
`runtime/src/domains/mcp.ts:711` `callData('query')`): always sends a
limit (`Math.min(limit ?? maxLimit, maxLimit)`), so it already took the
count arm. Already correct, and it now shares the fix if that ever
changes.
- **Out of scope, observed only (not filed):**
`packages/mcp/src/stdio-data-bridge.ts:971` (stdio MCP `query`) and the
no-protocol fallback in `runtime/src/action-execution.ts` (`total:
all.length`, reached only when the metadata-protocol service is not
registered) report `records.length` as `total` whatever the offset or
limit. That is a different defect (page size reported as total even with
a limit), read from source and not measured at a public door, so it is
not filed. Carrier: none.
The fix costs one `COUNT` per offset-only read, the same cost a limited
read already pays. A caller that wants to avoid it sends `$count=false`.
## Verification
Derived gate list: `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` at `0cd24491` (61 commands, after merging
`origin/main` once at `cd065d36` because #28583 touched `protocol.ts`),
each run with its exit code captured before any pipe, reconciled with
`--ran`.
- `--ran` reconciliation: `61 derived, 61 run, 0 NOT-MEASURED, 0 UNRUN`
(`RAN_EXIT=0`), every family exit 0, including `check:type-check-debt`
("0 raw tsc error(s) total, none above its recorded number"),
`check:dual-build-cjs-loads`, `check:lean-entry-closure`,
`check:engine-double-contract`, `check:query-options-erasure`,
`check:objectql-double-limit` and `check:where-matcher`.
- Package suite re-run on the merged head: `Test Files 291 passed | 4
skipped (295)`, `Tests 21908 passed | 26 skipped (21934)`, typecheck
exit 0, `eslint packages/metadata-protocol --no-inline-config` exit 0.
- The first pass on `d5bcd30c` (before the merge) also accounted for all
61; three families answered exit 3 (prerequisite not met in a fresh
worktree) and were green after the prerequisite was met.
CI runs the full farm (Test Core shards, lint, type-check lanes) on this
PR.
## Human Minimum
- [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic.
(No spec change.)
- [x] 2. Every name I added uses the casing for its kind.
- [x] 3. No formatter ran; `pnpm lint` passes (scoped to the touched
package locally; CI runs the full lint).
- [x] 4. One issue, one branch off an up-to-date `main`, one pull
request.
- [x] 5. A change that publishes carries a changeset
(`.changeset/28547-offset-no-limit-total.md`,
`@objectstack/metadata-protocol` patch), and both declaration lines are
filled in here and in the changeset.
- [x] 6. `packages/spec` touched: not touched.
- [x] 7. No hand edits to `content/docs/references/`,
`packages/*/CHANGELOG.md` or `content/docs/releases/`.
- [x] 8. Title, body and comments are in English (the 维护者速读 block is the
fleet's required Chinese summary).
- [x] The *Before Submitting* list in CONTRIBUTING.md is done.
Report on the card: the `os-dev-report` comment from `domain:server#3 ·
os-project-manager · session_01FEkQ5pNK9fm2KtGj8ijVqC`.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01FEkQ5pNK9fm2KtGj8ijVqC
---
_Generated by [Claude
Code](https://claude.ai/code/session_01FEkQ5pNK9fm2KtGj8ijVqC)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent de3fafc commit 40e7bfc
3 files changed
Lines changed: 219 additions & 3 deletions
File tree
- .changeset
- packages/metadata-protocol/src
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
| 147 | + | |
| 148 | + | |
| 149 | + | |
| 150 | + | |
| 151 | + | |
| 152 | + | |
| 153 | + | |
| 154 | + | |
| 155 | + | |
| 156 | + | |
| 157 | + | |
| 158 | + | |
| 159 | + | |
| 160 | + | |
| 161 | + | |
| 162 | + | |
| 163 | + | |
| 164 | + | |
| 165 | + | |
| 166 | + | |
| 167 | + | |
| 168 | + | |
| 169 | + | |
| 170 | + | |
| 171 | + | |
| 172 | + | |
| 173 | + | |
| 174 | + | |
| 175 | + | |
| 176 | + | |
| 177 | + | |
| 178 | + | |
| 179 | + | |
| 180 | + | |
| 181 | + | |
| 182 | + | |
| 183 | + | |
| 184 | + | |
| 185 | + | |
| 186 | + | |
| 187 | + | |
| 188 | + | |
| 189 | + | |
| 190 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
14064 | 14064 | | |
14065 | 14065 | | |
14066 | 14066 | | |
14067 | | - | |
| 14067 | + | |
| 14068 | + | |
| 14069 | + | |
| 14070 | + | |
| 14071 | + | |
| 14072 | + | |
| 14073 | + | |
| 14074 | + | |
| 14075 | + | |
| 14076 | + | |
| 14077 | + | |
14068 | 14078 | | |
14069 | 14079 | | |
14070 | 14080 | | |
| |||
14087 | 14097 | | |
14088 | 14098 | | |
14089 | 14099 | | |
14090 | | - | |
| 14100 | + | |
14091 | 14101 | | |
14092 | 14102 | | |
14093 | 14103 | | |
| |||
14110 | 14120 | | |
14111 | 14121 | | |
14112 | 14122 | | |
14113 | | - | |
| 14123 | + | |
| 14124 | + | |
| 14125 | + | |
14114 | 14126 | | |
14115 | 14127 | | |
14116 | 14128 | | |
| |||
0 commit comments