fix(services): markAllRead 清空整个收件箱,不再只清列表窗口的 200 条 (#6436) - #6449
Conversation
`POST /api/v1/notifications/read/all` 声明的是「mark **every**
currently-unread inbox message as read」,实现却扫
`listInbox(userId, { read: false, limit: 200 })` —— 列表的一页,而 200
正是那个列表的硬上限,于是一次调用最多翻 200 条收据。
真实栈实测(sqlite-wasm + ObjectQL + service-messaging + hono + dispatcher),
单用户 260 条未读:
| 请求 | 修复前 | 修复后 |
|:---|---:|---:|
| `POST /notifications/read/all` | `readCount: 200` | `readCount: 260` |
| 紧接着 `GET /notifications` | `unreadCount: 60` | `unreadCount: 0` |
**#6363 不是成因,它掀掉了掩盖的布**:`unreadCount` 还按窗口数时,截断是自洽
且隐身的(清 200、再轮询、窗口里一条不剩、角标 0);角标变成真总数之后,同一对
请求自己把矛盾说了出来,严重度也随之从「静默漏清」升为「点了全部已读、界面仍
显示未读」。
**同一缺陷更锋利的另一面,一并修掉**:那个窗口是对**全部**行按 `created_at
desc` 取的,`read` 过滤在截断**之后**于内存中施加。所以最新 200 条已读的信箱,
交给清扫的是一个**空** id 列表 —— 无论后面压着多少更旧的未读,它一条都不标。
这也正是「循环翻页直到取空」不成立的原因:它恰好在那空的第一页退出。
**现在的做法**:读未读**集合**而不是列表的一页,且不论信箱多大都是**固定两次
读** —— 与 #6363 的 `countUnreadTotal` 为角标所发的那一次单列、无窗口投影同形,
再与 `listInbox` 本就无界读取的收据脊连接。没有循环,没有需要设上界的页数;向
数据层要的东西,不超出角标轮询在每个打满的页上已经要的。写入仍是每条未读通知
一条收据 —— 那是收据模型本身(ADR-0030);`markRead` 的 check-then-act upsert、
unique 冲突收敛与「尚无收据行」的插入面**均未改动**(路线 B 的重设计条件因此未
触发)。
`readCount` 现在报的是**本次调用真正翻成 `read` 的去重通知条数**(此前报的是
「最新 200 行里未读的那些」)。两个推论:一条通知被多行收件箱行物化时只计一次;
没有 `notification_id` 的收件箱行被跳过而不是计入 —— 读态以事件 id 为键,旧代码
为它们写的那条(以收件箱**行** id 为键的)收据,连接永远读不回来。该行恒为未读的
结构性缺口另行记为 #6448(休眠:唯一 ingress `emit()` 必带事件 id)。
不变:列表窗口(默认 50、上限 200、最新在前)、`unreadCount`、`markRead`,以及
小于旧窗口的信箱 —— 它做的写入与从前完全一致,一次也不多。
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
|
📓 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:
|
消费半径的一处更正(分诊测错了一半,方向对本单有利)#6436 的分诊评论写的是「objectui 的铃铛根本不调这条路由 —— 它直接读
所以本缺陷在出厂 Console 里是有真实消费者的,而且那里的表现比裸 REST 更难看: objectui 侧无需改动,本 PR 修完即收敛:
这条更正只提高 #6436 Generated by Claude Code |
Fixes #6436
前提复核(先证伪,再动手)
在
origin/main(含 #6363 的 PR #6439,merge 提交17d09541)上逐条复核,issue 的前提成立且低估了:markAllRead形状未变,仍是listInbox(userId, { read: false, limit: 200 })→markRead(...),200正是listInbox的硬上限(clamp[1,200])。unreadCount的既有钉子全绿(service-messaging195/195、runtime1604/1604,含notification-schema-conformance.integration.test.ts里 notification 响应侧:unreadCount声明「总未读数」实测只数 limit 窗口内;响应cursor从无 producer 发出 #6363 翻转过的那条)。低估的部分 —— 也是本 PR 的路线依据。 那个窗口是对全部行按
created_at desc取的,read过滤在截断之后于内存中施加(listInbox末行all.filter(...))。于是最新 200 条恰好都已读的信箱,交给清扫的是一个空 id 列表:无论后面压着多少更旧的未读,markAllRead一条都不标,返回readCount: 0。已钉成回归(改前实测expected +0 to be 150)。这直接否掉了 issue 里的路线 A(循环翻页直到取空):它不是「代价高但正确」,而是不正确 —— 循环恰好在那空的第一页退出。即便信箱全是未读,第二轮取回的仍是刚被标为已读的同一批最新 200 行,同样退出。
路线取舍
markRead并发面路线 B 未做,也未静默改。 它要求把
markRead的 check-then-act upsert(findOne→update/insert+ unique 冲突收敛)与「尚无收据行」的插入面改成谓词式:未读消息里既有已存在delivered收据的(可被update谓词覆盖),也有根本没有收据行的(writeDeliveredReceipt在事件 id 缺失时跳过,以及最小栈上收据写入尽力而为地失败过),后者只能靠插入面解决 —— 那正是分诊写死的升级条件。按「A/B 按成本选是实施方权限、重设计并发语义不是」,此处选择不进入 B,markRead三个面(upsert、unique 冲突收敛、插入)一行未动。路线 C(把「all」改成「当前窗口」):已被维护者 2026-08-07 对 #6363 的 Option A 裁决排除 —— 让声明成真,不把谎话写进文档。
本 PR 的做法:读未读集合而不是列表的一页。所需的读恰好是 #6439 已经为角标付过的那次 ——
sys_inbox_message上单列(fields: ['notification_id'])、无orderBy、无limit的投影 —— 再与listInbox本就无界读取的收据脊连接。不论信箱多大都是固定两次读,没有循环,没有需要设上界的页数;向数据层要的东西,不超出角标轮询在每个打满的页上已经要的。关于「无上界」(issue 明确要求回答)
不加数值安全阀。 一个上限就是换了个更大数字的路线 C:超过它,「all」重新变成谎话,正是维护者裁决反对的那种读法。真正需要回答的两半分开看:
sys_notification_receipt上),唯一能把它压成 O(1) 的就是路线 B。约束这半的不是上限,而是幂等且可续:
markRead逐条捕获失败、记日志、跳过,readCount只报真正落库的条数,下一次清扫接着做(已钉「第二次清扫写 0 条、报 0」)。实测
真实栈(sqlite-wasm + ObjectQL + service-messaging + hono + dispatcher),单用户 260 条未读,同一条 HTTP 路径:
POST /api/v1/notifications/read/allreadCount: 200readCount: 260GET /api/v1/notificationsunreadCount: 60unreadCount: 0notifications[]长度修复前那两个数字是把实现临时还原后跑出来的(反向验证,方向事前预测为红,结果与预测一致),不是推算。
readCount现在报什么本次调用真正翻成
read的去重通知条数。 此前报的是「最新 200 行里未读的那些」。两个推论,都已钉:(notification_id, user_id, channel),本就只有一行。notification_id的收件箱行被跳过而不是计入。读态以事件 id 为键,旧代码为它们写的那条(以收件箱行 id 为键的)收据,连接永远读不回来 —— 既没让行变成已读,又把自己计进了readCount。该行恒为未读的结构性缺口不属于本 PR,另行记为 notification: 没有notification_id的sys_inbox_message行永远无法被标记已读 —— 读态的键在事件 id 上 #6448(观察类:唯一 ingressemit()必带事件 id,出厂管线不产生这种行)。测试
packages/services/service-messaging/src/messaging-service.test.ts新增[#6436]一组 11 条,全部先在未改动的实现上跑出红(7 条真红 + 4 条按设计本就绿的「不变」钉),再转绿:readCount: 350,随后unreadCount为 0,且 350 条收据落库全为read(改前:readCount: 200)find,且形状为where: {user_id}/fields: ['notification_id']/ 无limit/ 无orderBylistInbox同向降级;无 data engine / 无 user id 仍是 no-oppackages/runtime/src/notifications.hono.integration.test.ts新增线上钉一条:真实 HTTP 栈上 260 条未读,POST /read/all→GET /notifications,把 issue 里那对矛盾响应钉成回归。未新增任何 fake engine(复用既有
inboxEngine),故assertEngineDeleteDispatch不适用。改动面
packages/services/service-messaging/src/messaging-service.ts—markAllRead改读未读集合;新增私有unreadNotificationIds;把listInbox里的收据脊读取原样抽成readReceiptStates供两处共用(纯提取,行为不变,读态语义只此一处)packages/services/service-messaging/src/messaging-service.test.ts、packages/runtime/src/notifications.hono.integration.test.ts— 上述钉子.changeset/notification-mark-all-read-full-sweep.md—@objectstack/service-messagingpatch不变:列表窗口(默认 50、上限 200、最新在前)、
unreadCount(#6363)、markRead的三个面、packages/spec一行未动(MarkAllNotificationsReadResponseSchema对readCount的声明「Number of notifications marked as read」本就与新语义一致 —— 是实现向声明靠拢,不是反过来)。Generated by Claude Code