Skip to content

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

Description

@qq9340100

发现于 #5792(Stage A / #3877 notification 族响应体一致性测试)的前置测量。不在该 PR 修——两条都是
#3676(删声明)与 #3847(改实现)方向相反的判断题,按纪律另立单。

这是 #6361响应侧对照单(那单是请求侧)。两条都活在双断言看不见的盲区里,值得单说:
schema.parse() 判值、键集 ⊆ 声明键集判键——两条都绿,因为 unreadCount 无论对错都是个
number,而 cursoroptional 所以「从不发出」也是合法的。这一点本身是给 #3877 Stage D
棘轮设计的输入。

事实 1:unreadCount 的声明语义与交付语义不一致

声明(packages/spec/src/api/protocol.zod.ts:924):

unreadCount: z.number().describe('Total number of unread notifications')

.describe() 不是注释——它进 content/docs/references/,是发布出去的 API 文档原文

实现(packages/services/service-messaging/src/messaging-service.ts:284-333)自己的注释就写着相反的话:

unreadCount is computed over the fetched window (bounded by limit, like the Console bell's poll).

计数发生在 rows.map(...) 里,而 rows 已经被 limit 截断过(默认 clamp 到 50,上限 200)。

实测(真实 boot:sqlite-wasm + ObjectQL + service-messaging + hono + dispatcher,60 条未读):

无 limit 查询   →  rows: 50   unreadCount: 50      ← 真值是 60
?limit=10      →  rows: 10   unreadCount: 10      ← 真值是 60

也就是说:一个未读超过窗口的用户,拿到的角标永远等于窗口大小,而契约说这是总数。

事实 2:响应侧 cursor 没有任何 producer 发出

声明(ListNotificationsResponseSchema,protocol.zod.ts:925):

cursor: z.string().optional().describe('Next page cursor')

实测:GET /api/v1/notifications?limit=5 的响应体里 cursor 键不存在
(Object.prototype.hasOwnProperty(body.data, 'cursor') === false),listInbox 的返回类型
(InboxListResult,packages/spec/src/contracts/notification-service.ts:100-104)也只有
{ notifications, unreadCount }。与 #6361 的请求侧 cursor 是同一个未实现的分页能力的两半。

谁在消费(三轴的「业务需求」证据,实读得来)

GET /api/v1/notifications 的实测消费者只有 SDK(client.notifications.list)。
objectui 的通知铃不调这条路由:它 dataSource.find('sys_inbox_message', …),自己 join
sys_notification_receipt,自己 reduce 出未读数
(objectui/packages/app-shell/src/layout/AppHeader.tsx:354 / :391 / :496),
只用本族的两条 mark-read POST。所以今天没有界面被这个错值伤到——但它是发布出去的契约。

判断题(不猜,列证据供裁决)

关于 unreadCount

A. 改实现(#3847 方向) —— 真的数总未读:窗口外的未读也要算进去。

  • 长远合理性轴:角标就该是总数,这是「通知铃」这个 UI 元件的通用语义;声明本来就是对的。
  • 代价:读路径要多一次计数。读态存在另一个对象(sys_notification_receipt,ADR-0030),
    所以「未读总数」不是一次 count(sys_inbox_message) 能答的——要么反连接,要么维护计数。
    这是真实设计工作。
  • 防 AI 轴:强。声明与交付一致,消费者不需要知道实现细节。

B. 改声明(#3676 方向) —— 把 .describe() 改成「所返回窗口内的未读数」,并按惯例
提供「50+」式的上限表达。

  • 业务需求轴:今天没有实测消费者被伤到(见上),而按窗口计数正是 Console bell 轮询的实际用法。
  • 长远合理性轴:弱一些——把一个可以正确交付的语义降级为窗口语义,是让契约迁就实现。
  • 注意:改 .describe() 会动 content/docs/references/(生成物),按 AGENTS.md 走
    pnpm --filter @objectstack/spec gen:schema && gen:docs

建议:A(改实现),理由是三轴里长远合理性与防 AI 都指向同一边,而「没人消费」在这里
不是削减能力的理由——unreadCount 是本路由存在的主要产出之一(另一半 notifications[] 本就
可以从 sys_inbox_message 直接读到,objectui 正是这么做的),把它降级为窗口计数等于让这条路由
只剩一个更差的数据 API。但代价落在读路径设计上,请维护者定。

关于响应 cursor

#6361 的请求侧 cursor 必须同向裁决——分页是一个能力的两半,只删一半或只实现一半都是新的不一致。
建议随 #6361 一并决定。

关联

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions