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

fix(services): markAllRead 清空整个收件箱,不再只清列表窗口的 200 条 (#6436)#6449
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(findOneupdate/insert + unique 冲突收敛)与「尚无收据行」的插入面改成谓词式:未读消息里既有已存在 delivered 收据的(可被 update 谓词覆盖),也有根本没有收据行的(writeDeliveredReceipt 在事件 id 缺失时跳过,以及最小栈上收据写入尽力而为地失败过),后者只能靠插入面解决 —— 那正是分诊写死的升级条件。按「A/B 按成本选是实施方权限、重设计并发语义不是」,此处选择不进入 BmarkRead 三个面(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_idsys_inbox_message 行永远无法被标记已读 —— 读态的键在事件 id 上 #6448(观察类:唯一 ingress emit() 必带事件 id,出厂管线不产生这种行)。

测试

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

  • 核心钉:未读 350(issue 的形状)→ readCount: 350,随后 unreadCount0,且 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/allGET /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.tsmarkAllRead 改读未读集合;新增私有 unreadNotificationIds;把 listInbox 里的收据脊读取原样抽成 readReceiptStates 供两处共用(纯提取,行为不变,读态语义只此一处)
  • packages/services/service-messaging/src/messaging-service.test.tspackages/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 一行未动(MarkAllNotificationsReadResponseSchemareadCount 的声明「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:495notifications.reduce(...))—— 这半分诊没测错。
  • 但「全部已读」按钮走的就是本 PR 这条路由AppHeader.tsx:526markAllReadpostMarkRead('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

@hotlong
hotlong marked this pull request as ready for review August 7, 2026 21:15
@hotlong
hotlong enabled auto-merge August 7, 2026 21:16
@hotlong
hotlong added this pull request to the merge queue Aug 7, 2026
Merged via the queue into main with commit f1850d8 Aug 7, 2026
25 checks passed
@hotlong
hotlong deleted the claude/issue-6436-mark-all-read-full-sweep branch August 7, 2026 21:27
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