`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
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