Skip to content

fix(services): listInbox 的 unreadCount 数总未读,不再只数取回窗口 (#6363) - #6439

Merged
hotlong merged 2 commits into
mainfrom
claude/issue-6363-unread-count-true-total
Aug 7, 2026
Merged

fix(services): listInbox 的 unreadCount 数总未读,不再只数取回窗口 (#6363)#6439
hotlong merged 2 commits into
mainfrom
claude/issue-6363-unread-count-true-total

Conversation

@hotlong

@hotlong hotlong commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Fixes #6363

按维护者 2026-08-07 17:00Z 的裁决实施 Option A:让声明成真 —— unreadCount未读,而不是取回窗口内的;列表本身保持既有窗口行为。

前提复核(先证伪,再动手)

三条都对着 origin/main 复核过,全部成立:

issue 的断言 复核结果
protocol.zod.ts:924 声明 'Total number of unread notifications' ✅ 在
计数发生在 messaging-service.tsrows.map(...) 里,而 rows 已被 limit 截断(clamp [1,200],默认 50) ✅ 在(:316-331;行号因本 PR 前的上游改动略有平移,位置与形状一致)
实测 60 条未读 → 无 limit 得 50、?limit=10 得 10 ✅ 可复现(见下方「反向验证」的真实输出)

另有一条不在 issue 正文里、但影响设计的复核:findlimit不会被隐式截断(driver-sql/src/sql-driver.ts:2743if (query.limit !== undefined)),所以既有的收据全量读是真·无界,本 PR 的反连接读同理。

改了什么

MessagingService.listInbox 里两个界故意分开

  • notifications[] —— 仍是取回窗口:limit 行,默认 50,硬上限 200,最新在前。一行没动
  • unreadCount —— 整个匹配信箱的总未读,即 ListNotificationsResponseSchema 发布到 API 参考里的那句话。

取舍与成本(裁决点名的「真实设计工作」)

读态在 sys_notification_receipt、不在收件箱行上(ADR-0030),所以总未读的谓词跨两个对象,没有任何单条 count() 能回答。三条候选:

方案 判定
维护计数列 快,但要处理并发写与历史回填,且给一个每次读都能精确算出的量引入一份可漂移的副本。本单范围内不值。
count(消息) - count(已读收据) 相减 一次查询最省,但不正确:收据不带 topic,所以 type 过滤下直接错;notification_id 为空的行(从未被收据键住 ⇒ 恒未读)也算不进去;收据与消息若 TTL 不同步还会长期漂移。
反连接(本 PR 采用) 精确,且在 type 过滤与空 notification_id 两种情况下都对。

反连接的代价是被刻意夹住的:

  1. 只在窗口被打满时才发生。 rows.length < limit 说明这一页就是全集,窗口计数已经是总数,第二次读只会把第一次读的东西再读一遍。所以常见信箱(消息数少于页大小)的读路径开销与本 PR 之前逐字节相同 —— 有一条钉子专门量这件事(a window that came back SHORT costs no second read,断言 sys_inbox_message 上恰好 1 次 find)。
  2. 打满时,多的那次读是一列投影:同一个 wherefields: ['notification_id'],无 orderBy、无 limit。收据那半本来就已经全量在内存里(listInbox 一直无界地读用户的全部收件箱收据来做 join),所以补上的是消息那半 —— 与该方法本来就无条件付出的收据扫描同一量级,宽度一列。同样有钉子把这个查询形状钉死。

没有做成 best-effort,这是刻意的。 收据那次读之所以 catch 后降级,是因为收据是另一个对象、极简栈里可能压根没注册;而这次读重读的是刚刚 find 成功的同一个对象、同一个 where。它失败的世界里调用方手上那份列表也不可信 —— 而吞掉异常等于把这个方法刚刚不再讲的那个「窗口大小的谎」悄悄讲回去。有一条钉子断言异常向上抛。

反向验证(方向是事先定的:红)

unreadCount 改回 windowUnread 单行还原,预测新钉子里 9 条转红、1 条保持绿a window that came back SHORT costs no second read 钉的是「没有回归」而不是「修好了」,旧实现同样只发 1 次读,本就该绿)。实测与预测逐条一致:

× counts every unread message when the window truncates the inbox
× subtracts read-state across the whole inbox, not just inside the window
× counts rows carrying no notification_id — never receipted, so never read
× answers the `type` filter it was asked, over the whole inbox
× counts only the addressed user, at any window size
× the `read` filter narrows the list and never the badge
✓ a window that came back SHORT costs no second read      ← 预测即为绿
× the total read is one narrow projection, unwindowed, over the same predicate
× a failing total read is NOT swallowed into a window-sized answer
× the list keeps its window: default 50, hard cap 200, floor 1, newest first

AssertionError: expected 50 to be 60 // Object.is equality

expected 50 to be 60 就是 issue 实测那一行:60 条未读、无 limit,旧实现答 50。

测试

新增 10 条钉子(messaging-service.test.ts),fixture 照 issue 实测形状造(60 条未读 / limit=10):

  • 核心:60 条未读时,无 limit → 列表 50 条、unreadCount 60?limit=10 → 列表 10 条、unreadCount 60
  • 已读态在整个信箱上相减(20 条最旧的已读落在 limit=10 的窗口之外 —— 窗口计数看不见它们)⇒ 40。
  • notification_id 为空的行恒未读;只数被寻址的那个用户;type 过滤两边都收窄;read 过滤只收窄列表、从不动角标。
  • 成本与查询形状各一条(见上)。
  • 窗口行为不变的回归钉:默认 50 / 上限 200 / 下限 1 / 最新在前。

翻转了 #5792 在真栈上留下的那条测量钉(packages/runtime/src/notification-schema-conformance.integration.test.ts)—— 它当初就写明「无论 #6361 / #6363 怎么裁,这两条断言都是必须翻的那两条」。现在它在真 socket + 真 SQL driver 上钉的是修好后的行为:窗口变小,角标不变。

pnpm --filter @objectstack/service-messaging test        →  Test Files 16 passed (16) / Tests 185 passed (185)
pnpm --filter @objectstack/service-messaging typecheck   →  clean
pnpm --filter @objectstack/runtime typecheck             →  clean
runtime 通知三件套(含真栈 integration)                    →  Test Files 3 passed (3) / Tests 28 passed (28)
runtime http-dispatcher + domain-handler-registry         →  Test Files 2 passed (2) / Tests 285 passed (285)
消费半径 SDK: pnpm --filter @objectstack/client test      →  Test Files 20 passed (20) / Tests 244 passed (244)
根门: check:nul-bytes / check:engine-double-contract / check:empty-changeset /
      check:query-options-erasure / check:route-envelope / check:service-providers /
      check:doc-authoring                                 →  全绿
eslint(三个改动文件)                                      →  clean

check:engine-double-contract 特别说明:没有新增 engine double —— 测试里的 recordFinds包住既有 inboxEngine 记录调用,不是另起一个替身,门保持 80 pinned / 133 DEBT / 4 exempt 不变。

范围(红线)

顺带发现(已另立单,未在本 PR 修)


Generated by Claude Code

…6363)

`ListNotificationsResponseSchema.unreadCount` is published into the API
reference as "Total number of unread notifications", but the count happened
inside `rows.map(...)` — over rows `limit` had already truncated — so the badge
saturated at the window size forever. Measured on a real stack with 60 unread:
no `limit` answered 50, `?limit=10` answered 10.

Maintainer ruling (2026-08-07, Option A): make the declaration true. Read-state
lives on `sys_notification_receipt` (ADR-0030), so the total is a reverse join;
it runs only when the window came back saturated (a short window already IS the
whole matching set), and then reads one projected column under the same `where`
with no `orderBy` and no `limit` — the same order as the receipt scan
`listInbox` already performs unconditionally.

`notifications[]` keeps its window unchanged (default 50, cap 200, newest
first), and both bounds are now pinned separately.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015a5qkLzpGXhLL2F5gvJ7dD
@vercel

vercel Bot commented Aug 7, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Aug 7, 2026 6:39pm

Request Review

@github-actions github-actions Bot added the size/m label Aug 7, 2026
@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/service-messaging.

4 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/automation/webhooks.mdx (via @objectstack/service-messaging)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/service-messaging)
  • content/docs/plugins/packages.mdx (via @objectstack/service-messaging)
  • content/docs/releases/implementation-status.mdx (via @objectstack/service-messaging)

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Aug 7, 2026
…uality

`unreadCount === all.unreadCount` alone would go vacuous if a future fixture
change flattened both sides. The property the route now guarantees is that the
badge can exceed the window it arrived in, so assert that directly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015a5qkLzpGXhLL2F5gvJ7dD
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

notification 响应侧:unreadCount 声明「总未读数」实测只数 limit 窗口内;响应 cursor 从无 producer 发出

2 participants