From 4c197c529f97199800bae7d419952c247083877c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 02:26:38 +0000 Subject: [PATCH] =?UTF-8?q?docs(objectql):=20RUNTIME=5FOWNED=5FFIELD=5FTYP?= =?UTF-8?q?ES=20=E7=9A=84=20summary=20=E6=8E=92=E9=99=A4=E7=90=86=E7=94=B1?= =?UTF-8?q?=E6=94=B9=E4=B8=BA=E4=B8=8E=E5=AE=9E=E7=8E=B0=E7=9B=B8=E7=AC=A6?= =?UTF-8?q?=20(#6014)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `RUNTIME_OWNED_FIELD_TYPES` 上方的 TSDoc 把 `formula` 和 `summary` 并列成 「computed on read from a plan, never stored from the write payload」。formula 那半句对(`applyFormulaPlan` 读时按 plan 求值);summary 那半句是错的:roll-up summary 是实打实的物理存储列 —— `recomputeSummaries()` 用 `update(parent, { [summaryField]: value })` 写入,#5749 / PR #6013 之后 `initializeSummaryFields` 在 insert 时也落初值,读路径直读该列(#5749 的 「筛选静默漏行」正因为它是库内列)。 危险方向具体:下一个作者按注释字面「修正代码以匹配注释」,把 summary 加进集合, 带汇总初值的历史导入/种子写入就会被静默 strip。而且 strip 站点(engine.insert :5276)跑在 seed 之后、且只认原始 caller payload,所以 caller 送了 `task_count: 42` 时,42 被 strip 掉、0 也不会补(seed 正因为 caller 供了值而跳过), 列落回 null —— 恰是 #6013 要消灭的状态,且写入仍报成功。 改注释,不改行为:formula / summary 拆成两段,写明 summary 是 persisted + runtime-maintained 但故意不 strip,真实判据是第三条「没有合法的 caller 供值」—— autonumber 满足(伪造业务标识且无人纠正),summary 不满足(派生缓存,下次子表写入 自愈,且显式写初值是 #6013 明确支持的路径)。成员判据补成 (a)/(b)/(c) 三条。 未触及 `RUNTIME_OWNED_FIELD_TYPES` 集合本身,未触及任何行为代码。 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_019Q7oc7ASjh8yxyS3Yz78We --- .../objectql/src/validation/rule-validator.ts | 42 +++++++++++++++++-- 1 file changed, 38 insertions(+), 4 deletions(-) diff --git a/packages/objectql/src/validation/rule-validator.ts b/packages/objectql/src/validation/rule-validator.ts index 11300af60a..289d917df1 100644 --- a/packages/objectql/src/validation/rule-validator.ts +++ b/packages/objectql/src/validation/rule-validator.ts @@ -565,10 +565,44 @@ export function stripReadonlyWhenFieldsMulti( * ownership here makes it enforced rather than merely asserted — the same * `declared ≠ enforced` correction as #4447 (`created_at`), one type over. * - * Deliberately NOT `formula` / `summary`: those are computed on read from a - * plan, never stored from the write payload, so there is no caller value to - * strip. Keep this set to types whose value is (a) persisted and (b) issued by - * the runtime. + * Deliberately NOT `formula`: a formula field IS computed on read from a plan + * (`applyFormulaPlan`) and never stored from the write payload, so there is no + * caller value to strip in the first place. + * + * Deliberately NOT `summary` either — but for a COMPLETELY DIFFERENT reason, + * and conflating the two is what this note exists to prevent (#6014). A roll-up + * `summary` is NOT computed on read: it is a real stored column the runtime + * maintains. `ObjectQL.recomputeSummaries` writes it with an ordinary + * `update(parent, { [summaryField]: value })` after any child write, and since + * #5749 / PR #6013 `initializeSummaryFields` also seeds it at parent INSERT. + * Reads hit that stored column directly — which is exactly why + * `["task_count","=",0]` is an in-database comparison, and why a never-seeded + * `null` silently dropped rows from it (#5749). So `summary` satisfies BOTH + * clauses a naive membership rule would use — persisted AND runtime-issued — + * and is STILL excluded. Persistence and runtime ownership do not decide it. + * + * What decides it is the third clause: a runtime-owned type must have NO + * legitimate caller-supplied value. `autonumber` qualifies — a client-chosen + * record number bypasses the sequence and forges a business identifier nothing + * later corrects. `summary` does not qualify: the value is a derived cache of + * the child aggregate, self-healing on the next child write, and supplying an + * initial value is a SUPPORTED authoring path — `initializeSummaryFields` + * deliberately keeps a caller-supplied one ("author supplied a value"), so + * historical imports and seed data may carry pre-computed totals. + * + * DO NOT "fix the code to match this comment" by adding `summary` here. The + * insert-side strip ({@link stripRuntimeOwnedFields}) keys on the RAW caller + * payload and runs in `engine.insert` AFTER the seed pass, so a plain + * (non-`isSystem`, non-`preserveAudit`) import of a parent carrying + * `task_count: 42` would lose the 42 to the strip and get no 0 from the seed + * either — the seed already skipped that field precisely BECAUSE the caller + * supplied it. The column lands `null`, the exact state #6013 was written to + * eliminate, and the write still reports success. Only `isSystem: true` (whole + * pass skipped) or `preserveAudit: true` (kept by {@link isPreservableUnderAudit}, + * since a summary field is not `system: true`) would survive it. + * + * Keep this set to types whose value is (a) persisted, (b) issued by the + * runtime, and (c) never legitimately supplied by a caller. */ const RUNTIME_OWNED_FIELD_TYPES: ReadonlySet = new Set(['autonumber']);