发现于 #5792(Stage A / #3877 notification 族响应体一致性测试)的前置测量。不在该 PR 修——修法是 #3676(删声明)与 #3847(改实现)方向相反的判断题,按纪律另立单。
事实(实测,origin/main@4d552af3f)
DEFAULT_NOTIFICATION_ROUTES 给 GET /api/v1/notifications 声明了 ListNotificationsRequestSchema(见 packages/spec/src/api/protocol.zod.ts:914-919):
read: z.boolean().optional()
type: z.string().optional()
limit: z.number().default(20)
cursor: z.string().optional()
服务端(packages/runtime/src/domains/notifications.ts:106-112)只读三个,且不经过这个 schema:
const read = query?.read === undefined ? undefined : String(query.read) === 'true';
const limit = query?.limit ? Number(query.limit) : undefined;
const type = query?.type ? String(query.type) : undefined;
const result = await inbox.listInbox(userId, { read, type, limit });
于是两条声明落空:
| 声明 |
实际 |
后果 |
cursor |
谁都不读 |
见下面实测 |
limit 默认 20 |
未传时 forward undefined,MessagingService.listInbox 自己 clamp 到 50(messaging-service.ts:289) |
声明的默认值从来没有生效过 |
cursor 的调用方是存在的:client.notifications.list({ cursor }) 确实把它拼进 query
(packages/client/src/index.ts:3679-3688)。所以这不是「无人使用的声明」,是「有人发、服务端静默忽略」。
实测(真实 boot:sqlite-wasm + ObjectQL + service-messaging + hono + dispatcher,60 条未读):
page1 = GET /api/v1/notifications?limit=5
ids: ["YmkEfraba3S3E8GQ","N4eT1bK5MEGu-6j3","PsGcTsTjf0RqAOO-","6xLrm7tJadih3ccL","tZx2EQQ4MfeKKfWp"]
page2 = GET /api/v1/notifications?limit=5&cursor=tZx2EQQ4MfeKKfWp
ids: ["YmkEfraba3S3E8GQ","N4eT1bK5MEGu-6j3","PsGcTsTjf0RqAOO-","6xLrm7tJadih3ccL","tZx2EQQ4MfeKKfWp"]
page2 === page1 ? true
即:一个照着 SDK 声明分页的调用者会无限循环在第一页上,不报错,不 400。这正是 #3676
「request 声明了服务器不读的过滤器」那一类,只是这次调用方真的会发。
为什么此前没被发现
判断题(不猜,列证据供裁决)
A. 删声明(#3676 方向) —— 从 ListNotificationsRequestSchema 摘掉 cursor,把 limit
的默认值改成实现真正用的 50(或去掉 default,让「未传 = 由服务端决定窗口」成为显式契约),
同时摘掉 client.notifications.list 的 cursor 参数。
B. 改实现(#3847 方向) —— 真的实现 cursor 分页(listInbox 按 created_at + id 游标取下一页,
响应回填 cursor),并把 query 接到 ListNotificationsRequestSchema 上(默认值随之生效)。
- 业务需求轴:目前没有实测拉动。收件箱的深翻页在 objectui 里走的是 setup app 的
sys_inbox_message 列表视图(InboxPopover.tsx:129),已有自己的分页。
- 长远合理性轴:通知列表迟早需要分页,但「迟早」不是本轮的证据。
- 代价:
listInbox 的读路径要改(受 receipt join 与 in-memory read 过滤影响,游标语义要想清楚),
是真实设计工作,不是机械替换。
建议:A(删声明 + 把 limit 默认对齐实现),理由是三轴里业务需求轴给不出拉动,而
#4127 已在同一事实上裁过同一方向;创业期能力扩张从紧,分页词汇可以在真的有业务拉动时
连同实现一起回来。但这是公开契约变更,按纪律不猜——请维护者定。
关联
发现于 #5792(Stage A / #3877 notification 族响应体一致性测试)的前置测量。不在该 PR 修——修法是 #3676(删声明)与 #3847(改实现)方向相反的判断题,按纪律另立单。
事实(实测,
origin/main@4d552af3f)DEFAULT_NOTIFICATION_ROUTES给GET /api/v1/notifications声明了ListNotificationsRequestSchema(见packages/spec/src/api/protocol.zod.ts:914-919):服务端(
packages/runtime/src/domains/notifications.ts:106-112)只读三个,且不经过这个 schema:于是两条声明落空:
cursorlimit默认 20undefined,MessagingService.listInbox自己 clamp 到 50(messaging-service.ts:289)cursor的调用方是存在的:client.notifications.list({ cursor })确实把它拼进 query(
packages/client/src/index.ts:3679-3688)。所以这不是「无人使用的声明」,是「有人发、服务端静默忽略」。实测(真实 boot:sqlite-wasm + ObjectQL + service-messaging + hono + dispatcher,60 条未读):
即:一个照着 SDK 声明分页的调用者会无限循环在第一页上,不报错,不 400。这正是 #3676
「request 声明了服务器不读的过滤器」那一类,只是这次调用方真的会发。
为什么此前没被发现
请求体从不与声明它的 schema 对照(#3877 的请求侧对偶):7 个 schema 定义了从未启用,而 API 目录已宣称生效 #3899 把 catalog 的
requestSchema接到了真实入口,但只接 body;GET的 queryschema 不在其范围内,本路由的 body 校验(
POST /read)是接上的,query 没有。InboxQuery(packages/spec/src/contracts/notification-service.ts:69-76)当年已经就同一个事实做过一次裁决,而且方向是「删」:
也就是说内部契约面已经删过
cursor,wire schema 面没跟上。判断题(不猜,列证据供裁决)
A. 删声明(#3676 方向) —— 从
ListNotificationsRequestSchema摘掉cursor,把limit的默认值改成实现真正用的 50(或去掉 default,让「未传 = 由服务端决定窗口」成为显式契约),
同时摘掉
client.notifications.list的cursor参数。GET /notifications的实测消费者只有 SDK。objectui 的通知铃不走这条路由——它直接
dataSource.find('sys_inbox_message', …)并自己 join 回执、自己算未读(
objectui/packages/app-shell/src/layout/AppHeader.tsx:354/391/496),只用本族的两条mark-read POST。所以「分页」目前没有被任何真实界面拉动。
InboxQuery上做过的裁决同向,消除两个契约面的分歧。cursor会照着写分页循环,拿到的是无限的第一页——声明比没有声明更危险,正是 Response bodies are never checked against the schemas that declare them — staged plan, not a repo-wide sweep #3877 不排期 Stage C 的同一条理由。
且是 spec 车道。
B. 改实现(#3847 方向) —— 真的实现 cursor 分页(
listInbox按created_at+ id 游标取下一页,响应回填
cursor),并把 query 接到ListNotificationsRequestSchema上(默认值随之生效)。sys_inbox_message列表视图(InboxPopover.tsx:129),已有自己的分页。listInbox的读路径要改(受 receipt join 与 in-memoryread过滤影响,游标语义要想清楚),是真实设计工作,不是机械替换。
建议:A(删声明 + 把
limit默认对齐实现),理由是三轴里业务需求轴给不出拉动,而#4127 已在同一事实上裁过同一方向;创业期能力扩张从紧,分页词汇可以在真的有业务拉动时
连同实现一起回来。但这是公开契约变更,按纪律不猜——请维护者定。
关联
unreadCount语义 + 响应cursor)GetTranslationsRequestdeclaresnamespace/keysthat no server reads — declared ≠ enforced #3676(删声明)、GetFieldLabelsResponsedeclares rich label entries; both surfaces emit bare strings #3847(改实现)、dispatcher 多个 domain 调用契约里没有的方法 —— #4087 的同类,只是方向相反(契约缺声明,不是调用点乱编) #4127(InboxQuery已删cursor)、请求体从不与声明它的 schema 对照(#3877 的请求侧对偶):7 个 schema 定义了从未启用,而 API 目录已宣称生效 #3899(只接 body schema)