Skip to content

fix(services): markAllRead 清空整个收件箱,不再只清列表窗口的 200 条 (#6436) - #6449

Merged
hotlong merged 1 commit into
mainfrom
claude/issue-6436-mark-all-read-full-sweep
Aug 7, 2026
Merged

hotlong merged 1 commit into
mainfrom
claude/issue-6436-mark-all-read-full-sweep

Conversation

@hotlong

@hotlong hotlong commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Fixes #6436

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

在 origin/main(含 #6363 的 PR #6439,merge 提交 17d09541)上逐条复核,issue 的前提成立且低估了:

低估的部分 —— 也是本 PR 的路线依据。 那个窗口是对全部行按 created_at desc 取的,read 过滤在截断之后于内存中施加(listInbox 末行 all.filter(...))。于是最新 200 条恰好都已读的信箱,交给清扫的是一个空 id 列表:无论后面压着多少更旧的未读,markAllRead 一条都不标,返回 readCount: 0。已钉成回归(改前实测 expected +0 to be 150)。

这直接否掉了 issue 里的路线 A(循环翻页直到取空):它不是「代价高但正确」,而是不正确 —— 循环恰好在那空的第一页退出。即便信箱全是未读,第二轮取回的仍是刚被标为已读的同一批最新 200 行,同样退出。

路线取舍

路线 A(循环翻页) 路线 B(谓词式批量写) 本 PR(读未读集合)
正确性 ❌ 见上,空首页即退出 ✅ ✅
读次数 无上界的多轮 0 固定 2 次,与信箱大小无关
写次数 每条未读 1 次 1 次批量 每条未读 1 次
触碰 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」重新变成谎话,正是维护者裁决反对的那种读法。真正需要回答的两半分开看:

约束这半的不是上限,而是幂等且可续:markRead 逐条捕获失败、记日志、跳过,readCount 只报真正落库的条数,下一次清扫接着做(已钉「第二次清扫写 0 条、报 0」)。

实测

真实栈(sqlite-wasm + ObjectQL + service-messaging + hono + dispatcher),单用户 260 条未读,同一条 HTTP 路径:

请求 修复前(实测) 修复后(实测)
POST /api/v1/notifications/read/all readCount: 200 readCount: 260
紧接着 GET /api/v1/notifications unreadCount: 60 unreadCount: 0
同上,notifications[] 长度 50 50(列表窗口不变)

修复前那两个数字是把实现临时还原后跑出来的(反向验证,方向事前预测为红,结果与预测一致),不是推算。

readCount 现在报什么

本次调用真正翻成 read 的去重通知条数。 此前报的是「最新 200 行里未读的那些」。两个推论,都已钉:

  1. 一条通知被多条收件箱行物化时只计一次 —— 收据键是 (notification_id, user_id, channel),本就只有一行。
  2. 没有 notification_id 的收件箱行被跳过而不是计入。读态以事件 id 为键,旧代码为它们写的那条(以收件箱行 id 为键的)收据,连接永远读不回来 —— 既没让行变成已读,又把自己计进了 readCount。该行恒为未读的结构性缺口不属于本 PR,另行记为 notification: 没有 notification_id 的 sys_inbox_message 行永远无法被标记已读 —— 读态的键在事件 id 上 #6448(观察类:唯一 ingress emit() 必带事件 id,出厂管线不产生这种行)。

测试

packages/services/service-messaging/src/messaging-service.test.ts 新增 [#6436] 一组 11 条,全部先在未改动的实现上跑出红(7 条真红 + 4 条按设计本就绿的「不变」钉),再转绿:

  • 核心钉:未读 350(issue 的形状)→ readCount: 350,随后 unreadCount 为 0,且 350 条收据落库全为 read(改前:readCount: 200)
  • 最新 200 条已读 + 更旧 150 条未读 → 标 150(改前:0)
  • 小信箱(10 条,2 条已读)行为不变:只翻 8 条,两条已存收据未被重新盖章
  • 成本钉:信箱 10 与 350 都恰好 2 次 find,且形状为 where: {user_id} / fields: ['notification_id'] / 无 limit / 无 orderBy
  • 只动被点名的用户;幂等(第二次 0 条);同一通知多行只计一次;收据脊不可用时与 listInbox 同向降级;无 data engine / 无 user id 仍是 no-op

packages/runtime/src/notifications.hono.integration.test.ts 新增线上钉一条:真实 HTTP 栈上 260 条未读,POST /read/all → GET /notifications,把 issue 里那对矛盾响应钉成回归。

未新增任何 fake engine(复用既有 inboxEngine),故 assertEngineDeleteDispatch 不适用。

service-messaging  test  16 files / 195 tests passed     typecheck  clean
runtime            test 110 files / 1604 tests passed    typecheck  clean
check:nul-bytes / check:empty-changeset / check:route-envelope /
check:service-providers / check:query-options-erasure /
check:engine-double-contract                              OK
check:type-check-debt   全量 pnpm build 后 OK(新 worktree 首跑的 8 条上飘
                        全在本 PR 未触碰的包,建完即消失;runtime 反而 -7)

改动面

  • 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-messaging patch

不变:列表窗口(默认 50、上限 200、最新在前)、unreadCount(#6363)、markRead 的三个面、packages/spec 一行未动(MarkAllNotificationsReadResponseSchema 对 readCount 的声明「Number of notifications marked as read」本就与新语义一致 —— 是实现向声明靠拢,不是反过来)。


Generated by Claude Code

`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
@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 9:08pm

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

hotlong commented Aug 7, 2026

Copy link
Copy Markdown
Contributor Author

消费半径的一处更正(分诊测错了一半,方向对本单有利)

#6436 的分诊评论写的是「objectui 的铃铛根本不调这条路由 —— 它直接读 sys_inbox_message」。实测(objectui 7894432f5):前半对,后半错。

  • 列表确实直接读 sys_inbox_message,角标也是客户端自己数(AppHeader.tsx:495:notifications.reduce(...))—— 这半分诊没测错。
  • 但「全部已读」按钮走的就是本 PR 这条路由:AppHeader.tsx:526 的 markAllRead → postMarkRead('read/all') → POST /api/v1/notifications/read/all(:508)。

所以本缺陷在出厂 Console 里是有真实消费者的,而且那里的表现比裸 REST 更难看:markAllRead 先把本地所有行乐观地翻成已读(setNotifications(prev => prev.map(n => ({ ...n, is_read: true })))),服务端却只落 200 条 —— 下一次轮询把剩下的行又变回未读。用户看到的是「点了全部已读、列表当场清空、过一会儿自己长回来」。

objectui 侧无需改动,本 PR 修完即收敛:

  • 响应形状未变({ success, readCount }),且 objectui 不读返回值(await postMarkRead('read/all') 直接丢弃),所以 readCount 语义的变化对它是无感的;
  • 乐观 UI 与服务端状态现在一致 —— 服务端把该翻的都翻了,轮询不再把行拉回未读。

这条更正只提高 #6436 target:v17 上板理由的分量(分诊按「已发布契约说谎」的标准 ② 上的板;实际还叠了一个出厂 Console 的可见回弹),不改变本 PR 的任何实现取舍。


Generated by Claude Code

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: markAllRead 只清窗口内的 200 条 —— 未读超过 200 的用户按「全部已读」清不掉角标

2 participants