现象
pnpm dev(showcase)启动后,控制台永远停不下来地循环输出同一行 WARN:
2026-08-03T03:49:37.101Z WARN Field 'updated_at' is read-only — ignoring incoming change (#2948)
实测稳定 48 行/秒,一直刷到进程退出。120 秒的 dev 日志里 1044 行输出,其中 456 行是这一条(其余是正常的启动日志);再跑久一点这一条会淹没所有真实日志。空闲、没人访问、没有浏览器连上来的 dev server 也照刷不误。
复现
pnpm install && pnpm build
pnpm dev # examples/app-showcase
启动完成约 2 秒后开始刷。
根因
告警来自 packages/objectql/src/validation/rule-validator.ts:420(stripReadonlyFields,#2948):调用方在非 system 上下文的 UPDATE 里显式带了 readonly 字段,平台把它剥掉并 warn 一次。
刷屏的调用方是 @objectstack/service-messaging 的两个 SQL outbox——堆栈(--stack-trace-limit=80 抓的)指向:
SqlNotificationOutbox.claimSqlNotificationOutbox.claimDigestSqlHttpOutbox.claim
三处 claim*() 的第一步都是无条件的 "reap stale in_flight" 谓词 UPDATE(visibility-timeout 回收),payload 里带了 updated_at: now。stripReadonlyFields 是按 payload 的字段告警的,不看命中了几行——所以哪怕 outbox 是空的、一行都没 reap 到,每次调用照样 warn 一次。
算一下正好对得上:NotificationDispatcher 每 tick 跑 claim + claimDigest,HttpDispatcher 每 tick 跑 claim,各自遍历 8 个 partition → 每 tick (2 + 1) × 8 = 24 次 warn;dispatcher 默认 intervalMs = 500 → 48 行/秒。
而 updated_at 本来就轮不到调用方写:packages/objectql/src/plugin.ts 的内建 hook sys_stamp_audit_update 在每一次 update 上无条件盖 record.updated_at = now。也就是说 outbox 传的这个值先被剥掉、再被平台盖回去——纯冗余,行为上完全是 no-op,只剩噪音。
顺带暴露的一个隐患
同模块的 audit-timestamp.ts 写得很清楚:created_at / updated_at 在 Postgres/MySQL 上是原生 TIMESTAMP 列,必须写 Date,写裸 epoch-ms 数字会被真·timestamp 列拒绝(就是当初弄坏 sys_notification_delivery 保留期清理的那个 bug)。
enqueue() 遵守了这条(const now = new Date()),但 claim() / claimDigest() / ack() / redeliver() 传的是 opts.now ?? Date.now(),epoch-ms 数字。现在没炸,只是因为它在 UPDATE 路径上被 stripReadonlyFields 剥掉了——一旦哪天这些写入改走 system 上下文(system 上下文跳过剥离),这个数字就会直接落到 Postgres 上。
所以正确的修法不是"让它别 warn",而是把这个本就不该传的字段删掉。
修复方向
从两个 SQL outbox 的所有 UPDATE payload 里删掉 updated_at,交给 sys_stamp_audit_update 盖:
packages/services/service-messaging/src/sql-outbox.ts — claim() ×2、claimDigest() ×2、ack()packages/services/service-messaging/src/sql-http-outbox.ts — claim() ×2、ack()、redeliver()
INSERT 路径(enqueue())不动:那里 created_at 是有意义的,且 insert 不走 stripReadonlyFields。
加一个回归测试:outbox 交给 engine 的 update payload 里不得出现 updated_at。
影响
纯噪音 + 上面那个隐患,没有行为变化(值本来就被剥掉又盖回去)。但它让 pnpm dev 的控制台基本不可用——真实的 warn/error 会被瞬间冲走。
现象
pnpm dev(showcase)启动后,控制台永远停不下来地循环输出同一行 WARN:实测稳定 48 行/秒,一直刷到进程退出。120 秒的 dev 日志里 1044 行输出,其中 456 行是这一条(其余是正常的启动日志);再跑久一点这一条会淹没所有真实日志。空闲、没人访问、没有浏览器连上来的 dev server 也照刷不误。
复现
启动完成约 2 秒后开始刷。
根因
告警来自
packages/objectql/src/validation/rule-validator.ts:420(stripReadonlyFields,#2948):调用方在非 system 上下文的 UPDATE 里显式带了readonly字段,平台把它剥掉并 warn 一次。刷屏的调用方是
@objectstack/service-messaging的两个 SQL outbox——堆栈(--stack-trace-limit=80抓的)指向:SqlNotificationOutbox.claimSqlNotificationOutbox.claimDigestSqlHttpOutbox.claim三处
claim*()的第一步都是无条件的 "reap stale in_flight" 谓词 UPDATE(visibility-timeout 回收),payload 里带了updated_at: now。stripReadonlyFields是按 payload 的字段告警的,不看命中了几行——所以哪怕 outbox 是空的、一行都没 reap 到,每次调用照样 warn 一次。算一下正好对得上:
NotificationDispatcher每 tick 跑claim+claimDigest,HttpDispatcher每 tick 跑claim,各自遍历 8 个 partition → 每 tick(2 + 1) × 8 = 24次 warn;dispatcher 默认intervalMs = 500→ 48 行/秒。而
updated_at本来就轮不到调用方写:packages/objectql/src/plugin.ts的内建 hooksys_stamp_audit_update在每一次 update 上无条件盖record.updated_at = now。也就是说 outbox 传的这个值先被剥掉、再被平台盖回去——纯冗余,行为上完全是 no-op,只剩噪音。顺带暴露的一个隐患
同模块的
audit-timestamp.ts写得很清楚:created_at/updated_at在 Postgres/MySQL 上是原生TIMESTAMP列,必须写Date,写裸 epoch-ms 数字会被真·timestamp 列拒绝(就是当初弄坏sys_notification_delivery保留期清理的那个 bug)。enqueue()遵守了这条(const now = new Date()),但claim()/claimDigest()/ack()/redeliver()传的是opts.now ?? Date.now(),epoch-ms 数字。现在没炸,只是因为它在 UPDATE 路径上被stripReadonlyFields剥掉了——一旦哪天这些写入改走 system 上下文(system 上下文跳过剥离),这个数字就会直接落到 Postgres 上。所以正确的修法不是"让它别 warn",而是把这个本就不该传的字段删掉。
修复方向
从两个 SQL outbox 的所有 UPDATE payload 里删掉
updated_at,交给sys_stamp_audit_update盖:packages/services/service-messaging/src/sql-outbox.ts—claim()×2、claimDigest()×2、ack()packages/services/service-messaging/src/sql-http-outbox.ts—claim()×2、ack()、redeliver()INSERT 路径(
enqueue())不动:那里created_at是有意义的,且 insert 不走stripReadonlyFields。加一个回归测试:outbox 交给 engine 的 update payload 里不得出现
updated_at。影响
纯噪音 + 上面那个隐患,没有行为变化(值本来就被剥掉又盖回去)。但它让
pnpm dev的控制台基本不可用——真实的 warn/error 会被瞬间冲走。