fix(objectql): 新建父行时把 count/sum 型 summary 汇总初始化为 0 (#5749) - #6013
Conversation
`recomputeSummaries()` only ever visits parents named by a CHILD write (`recs`/`prevs` -> `desc.fkField`), so a parent that has never had a child is never visited and its summary column keeps insert's `null`. Delete the last child and the parent IS visited (via `previous`) and lands on 0 — one logical state, two values. The consequence is not cosmetic: `= 0` / `< 1` filters compare in the database and silently DROP every parent that never had a child; sorting, GROUP BY and formula fields reading it inherit the same null. Fixed at the producer. `buildSummaryIndex` now publishes the identical descriptors under a second, parent-side view, and `insert` seeds the count/sum summaries a new row OWNS with the empty-collection value right after `applyFieldDefaults`. The empty-set function list is extracted to `summaryEmptySetValue` so the insert seed and the recompute fallback read ONE list — min/max/avg have no empty-set value and stay `null`, unchanged. Boundaries: author-supplied values are never overwritten (same `!= null` rule as `applyFieldDefaults`, #2706) and `beforeInsert` still has the final say; a roll-up whose relationship cannot be resolved is not seeded, so "seeded" and "maintained by recompute" stay the same set; existing rows are untouched — this is create-time only and backfill is a separate decision. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019Q7oc7ASjh8yxyS3Yz78We
…mary-count-null-zero
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 1 package(s): 14 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
…mary-count-null-zero
合并
|
|
队列管家:拦截(⛔ 未重投)—— 零签名踢出,本座位记录的第 2 例 事实(全部为 REST 读数):
判定:无签名可认。 四分支判例法(已知 flaky / 已修签名再现 / 基缺已合修复 / 新签名)全部以「有一个签名可认」为前提;本例连一条失败 job 都不存在,是 #5810 第 18 轮提请 ① 所提「零派发踢出」的第二例(首例 #6034)。按试点判据 2(未裁定签名一律不重投),本座位 ⛔ 不重投,仅留档。 初步判读(本轮新读数,来自队列配置本身) —— REST
建议动作(⛔ 本座位无授权面执行,交 已核让行(SKILL「双向让行」):处置前读本 PR 最近 30 分钟评论,无车道 PM 动作(最近一条为 ⛔ 本座位未合并、未切 ready/draft、未撤队、未重跑、未改代码、未动认领。 Generated by Claude Code |
⛔ merge queue 构建失败 — 先分诊,再决定要不要重排队列构建 31134755269 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集), 失败的 job(日志抽取,best effort):
历史信号:
分诊清单:
Generated by Claude Code · merge-queue-triage workflow (#4859) |
|
队列管家:已认签名,⛔ 暂不重投(本 PR 当前仍在队列内) 本 PR 的队列构建 CI 31134755269 于 00:40Z 判红(job 级 failure 2 条: 完整签名(取完整 job 归档判读,⛔ 未看 tail —— SKILL notes 7): 台账依据:#5810 正文 objectstack 表新增行(维护者 2026-08-07 授权升级)——「
台账行的四个条件逐条核过:①签名逐字吻合;②仅队列全量构建命中;③本 PR 自身 CI 23 个 check 全绿;④改动面仅 下一步(本座位):若队列据此把本 PR 踢出,即按台账原样重投并追加审计评论;若队列未踢(该红不在 required 集内),本 PR 照常前进,本评论仅作留档。⛔ 无论哪种,本 PR 代码侧无需改动 —— 失败用例不在本 PR 的改动面内。 已核让行:本 PR 最近 30 分钟无车道 PM 动作。 Generated by Claude Code |
合并 main 到 dca5bd3 后再全量重测,余量在一小时内被兑付了两笔,记录如下: - `@objectstack/objectql` 实测 339 -> **345**(+6 全是 TS2554,全在 `src/summary-rollup.test.ts`,由飞行途中落地的 #5749 / PR #6013 扩写)。 记档 349 把它静默吸收了 —— 若按精确值 339 记账,这就是同一场赛跑的第 6 次红。 按裁决「实测 +10」把记录抬到 **355**,恢复满额余量。 - `@objectstack/service-storage` 42 -> 41 -> **42**:`IStorageService.list(prefix)` 的退休被拆成两个 PR,spec 半边(#5540 / PR #5983)减 1、适配器半边 (#5541 / PR #6061)删旧测试(-1 TS7006)又新增 `storage-adapter-list-retirement.test.ts`(+2 TS2835),净 +1。上一轮我按实测 下调到 41,一小时后就被咬红 —— 正是派发令说的「非余量条目被基漂移咬住」, 按同一记档规则给这条加 +10,记 **52**,不开精确校准 lap。 一个值得写进文档块的新形状:**拆成两个 PR 的退休会让计数先降后升**,在两半之间 记下的精确值,推上去之前就已经过期。 `rest` / `lint` 两条实测未动(153 / 32),余量原样,note 补记「一小时后在 77c7c88 复测仍是该值」。 重测输出:四条记档余量各打印一行 ℹ(各 -10),无一条上漂,exit 0。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014wsZeReNTqiceBfLb5Pyf5
Fixes #5749
前提复核(先于实现)
issue 正文的两段定位在
origin/main上仍然成立,只是行号漂了(engine.ts 今天已合多个 PR),按内容定位:packages/objectql/src/engine.ts的recomputeSummaries()内:if (value == null) value = (desc.fn === 'count' || desc.fn === 'sum') ? 0 : null;—— 是对的,C 态(删光子记录)拿到 0 就是靠它。recomputeSummaries()里的for (const r of recs) ... for (const p of prevs) ...两行:待重算的parentId只从本次写入的子记录(以及被删子记录的previous)里取。getSummaryDescriptors()确实只按子对象索引,父对象自己 insert 时拿不到自己的汇总字段。所以「坏的是父行选取、不是兜底」这个判断成立,方案 1(父行 insert 时落初值)是对的方向。
改了什么
生产端修,三处:
buildSummaryIndex()现在一次扫描产出两个视图(byChild/byParent),存放的是同一批 descriptor 对象。子对象索引的语义一个字没动 ——getSummaryDescriptors(childObject)行为完全不变,只是把「有没有过期」的判断抽成了ensureSummaryIndexes(),让父侧视图共用同一条过期规则(cloud#970 那条运行时发布的过期规则,父侧同样需要,已加测试)。initializeSummaryFields(object, record):按父对象取出该行自己拥有的汇总字段,把count/sum落成空集合的值0。insert()在applyFieldDefaults之后、beforeInsert钩子之前调用它 —— 与 defaultValue 完全同一个挂点、同一套规则。另外把空集函数清单提取成模块级的
summaryEmptySetValue(fn),recomputeSummaries的兜底改为调用它。这不是改兜底逻辑:表达式逐字等价,只是让「插入初值」和「重算兜底」读同一份清单 —— 两个地方各写一份fn === 'count' || fn === 'sum'正是「A 态和 C 态读出两个值」这类 bug 的温床。min/max/avg在空集上没有定义,两边都仍然是null。边界(逐条对应 issue 的取舍)
!= null,与applyFieldDefaults完全同口径([objectql] 字段 defaultValue 语义:显式 null 不回填、解析晚于 hook、表单不预填 current_user #2706:insert 时undefined与显式null都算「未提供」)。beforeInsert钩子在其后运行,仍有最终决定权(两条都有测试)。buildSummaryIndex里解析不出子->父 FK 的 descriptor 会被continue跳过,它不进任何一个索引,所以也不会被落初值。否则就会出现一个「没人维护的 0」—— 那比null更像谎言。null的老父行仍然是null,直到某次子记录写入把它重算。实测确认这不是方案 1 的强依赖:方案 1 对新数据一次性全对,存量回填是独立取舍,建议另行立单。recomputeSummaries的父行选取逻辑。实现过程中未发现「必须同时改选取才正确」的情形:父行选取的职责是「谁被写了就重算谁」,它对「从未被写过的行」结构上就无话可说 —— 补的应该是初始化,不是把选取扩成全表扫描。测试
packages/objectql/src/summary-rollup.test.ts新增 8 个用例,复用文件里已有的 memory driver(没有引入新的 fake engine,check:engine-double-contract绿):A === C;total_estimate(sum)同款。["task_count","=",0]与["task_count","<",1]两个筛选:A 态行进结果集,有子记录的行仍被排除。null(口径 pin)。task_count: 7不被覆盖;批量 insert 每行都落初值、已提供的那行不动;beforeInsert钩子仍能覆盖。反向验证(方向先预测、后运行)
预测:去掉 insert 里的初始化调用 -> 恰好 4 个用例转红(A/C 一致性、
=0/<1筛选、批量、运行时发布的父对象);另外 4 个断言的是「不该发生的事」(不覆盖作者值、avg 仍为 null、无法解析不落初值、钩子优先),它们是护栏而不是本次修复的 pin,应当保持绿。实测与预测逐条一致:
第二条的失败信息就是 issue 描述的现象本身:同一个查询只返回了 ROLLUP PROBE,Legacy Sunset 整行消失,无任何报错。
命令与结果
packages/runtime那一轮是特意跑的:bulk-write-real-driver.integration.test.ts用的是真实 SqlDriver/better-sqlite3,验证了初值 0 能正常写进真实建表出来的列(汇总列本来就是物理列 —— 重算就是靠update写它的)。changeset
@objectstack/objectqlpatch。行为变化:新建父行的 count/sum 汇总从null变 0;changeset 里写明了存量数据不受本 PR 影响、回填另行处理。Generated by Claude Code