fix(services): listInbox 的 unreadCount 数总未读,不再只数取回窗口 (#6363) - #6439
Merged
Conversation
…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
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
Contributor
📓 Docs Drift CheckThis PR changes 1 package(s): 4 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
…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
hotlong
marked this pull request as ready for review
August 7, 2026 18:43
hotlong
enabled auto-merge
August 7, 2026 18:43
This was referenced Aug 7, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #6363
按维护者 2026-08-07 17:00Z 的裁决实施 Option A:让声明成真 ——
unreadCount数总未读,而不是取回窗口内的;列表本身保持既有窗口行为。前提复核(先证伪,再动手)
三条都对着
origin/main复核过,全部成立:protocol.zod.ts:924声明'Total number of unread notifications'messaging-service.ts的rows.map(...)里,而rows已被limit截断(clamp[1,200],默认 50):316-331;行号因本 PR 前的上游改动略有平移,位置与形状一致)?limit=10得 10另有一条不在 issue 正文里、但影响设计的复核:
find无limit时不会被隐式截断(driver-sql/src/sql-driver.ts:2743是if (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 不同步还会长期漂移。type过滤与空notification_id两种情况下都对。反连接的代价是被刻意夹住的:
rows.length < limit说明这一页就是全集,窗口计数已经是总数,第二次读只会把第一次读的东西再读一遍。所以常见信箱(消息数少于页大小)的读路径开销与本 PR 之前逐字节相同 —— 有一条钉子专门量这件事(a window that came back SHORT costs no second read,断言sys_inbox_message上恰好 1 次find)。where,fields: ['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 次读,本就该绿)。实测与预测逐条一致:expected 50 to be 60就是 issue 实测那一行:60 条未读、无 limit,旧实现答 50。测试
新增 10 条钉子(
messaging-service.test.ts),fixture 照 issue 实测形状造(60 条未读 /limit=10):unreadCount60;?limit=10→ 列表 10 条、unreadCount60。limit=10的窗口之外 —— 窗口计数看不见它们)⇒ 40。notification_id为空的行恒未读;只数被寻址的那个用户;type过滤两边都收窄;read过滤只收窄列表、从不动角标。翻转了 #5792 在真栈上留下的那条测量钉(
packages/runtime/src/notification-schema-conformance.integration.test.ts)—— 它当初就写明「无论 #6361 / #6363 怎么裁,这两条断言都是必须翻的那两条」。现在它在真 socket + 真 SQL driver 上钉的是修好后的行为:窗口变小,角标不变。check:engine-double-contract特别说明:没有新增 engine double —— 测试里的recordFinds是包住既有inboxEngine记录调用,不是另起一个替身,门保持80 pinned / 133 DEBT / 4 exempt不变。范围(红线)
cursor半边不在本 PR。裁决明确「分页能力不半删」,响应侧cursor随 GET /api/v1/notifications 从不解析它声明的请求 schema ——cursor被静默丢弃(SDK 分页永远第一页),limit默认 20 声明 vs 50 实现 #6361 同向处理,其移除落packages/spec,归 spec 座位。上面那条 integration 里的cursor钉子原样保留为已测量事实。.describe()(Option A 是让声明成真),也没有改packages/spec的任何一行。顺带发现(已另立单,未在本 PR 修)
markAllRead只清窗口内的 200 条 —— 未读超过 200 的用户按「全部已读」清不掉角标 #6436 ——markAllRead只清窗口内的 200 条。这不是本 PR 引入的,但本 PR 把掩盖它的布揭掉了:以前清完 200 条后窗口内确实空了、角标显示 0;现在同一条路径会自己暴露readCount: 200而unreadCount: 150。InboxListResult.unreadCount的 JSDoc 仍写「over the returned window」—— 与 #6363 落地后的实现和同族.describe()相反 #6438 ——packages/spec/src/contracts/notification-service.ts里InboxListResult.unreadCount的 JSDoc 仍写着Unread count over the returned window,与本 PR 落地后的实现、以及同包protocol.zod.ts:924的.describe()都相反。按本单红线(不改packages/spec)留给 spec 座位,与 GET /api/v1/notifications 从不解析它声明的请求 schema ——cursor被静默丢弃(SDK 分页永远第一页),limit默认 20 声明 vs 50 实现 #6361 一并处理。Generated by Claude Code