Repository navigation
fix(objectql): a predicate update or delete recomputes the parent roll-up summaries its matched rows name (#26950) - #27000
Conversation
… parent roll-ups its matched rows name (red) Claude-Session: https://claude.ai/code/session_01FEkQ5mKGG79zhwLb1obtFs Co-authored-by: Claude <noreply@anthropic.com>
…l-ups its matched rows name Claude-Session: https://claude.ai/code/session_01FEkQ5mKGG79zhwLb1obtFs Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FEkQ5mKGG79zhwLb1obtFs Co-authored-by: Claude <noreply@anthropic.com>
…arent it removed Claude-Session: https://claude.ai/code/session_01FEkQ5mKGG79zhwLb1obtFs Co-authored-by: Claude <noreply@anthropic.com>
…mpute Claude-Session: https://claude.ai/code/session_01FEkQ5mKGG79zhwLb1obtFs Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FEkQ5mKGG79zhwLb1obtFs Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift Check8 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. What this run could not see
Coarse fallback — 19 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 534417d0e069185892bf0fc5020de85cc1a1b98a && git checkout 534417d0e069185892bf0fc5020de85cc1a1b98a
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin b8f596cfdac42361d6ef4a77f8868f9c8e59efe3 e3a38f12a5f9955d3a3c224211a925ab57f787de && git checkout -B drift-repro b8f596cfdac42361d6ef4a77f8868f9c8e59efe3 && git merge --no-ff e3a38f12a5f9955d3a3c224211a925ab57f787de
node scripts/docs-audit/affected-docs.mjs --json b8f596cfdac42361d6ef4a77f8868f9c8e59efe3 |
席位核对ACCEPT · The maintainer's standing authorisation for landing reviewed PRs, verbatim: 「按照 pm-dispatch 规则复核过的都授权」.
Generated by Claude Code |
Requested by Leehom · project thread
Fixes #26950
Clause-②: no
Metadata-protocol: none
维护者速读
multi: true+ filter,流程的update_record/delete_record批量节点走的就是它)现在会重算父记录上的汇总字段(count/sum/min/max/avg):删除重算被删子记录原来挂的父记录,更新同时重算旧父和新父。recomputeSummaries拿不到任何父 id,父记录汇总一直停在旧值,直到某次按 id 写子记录才被纠正。流程批量删除或改挂订单行,订单合计静默错误。SummaryRecomputeError(记录已写入)。回滚即 revert 本 PR。packages/objectql/src/engine.ts的update()谓词分支([#26950]段,updateMany之前)与delete()谓词分支(plan === 'none'与走查分支);packages/objectql/src/summary-aggregate.ts的withoutDeletedSelfParents。pnpm --filter @objectstack/service-automation exec vitest run src/builtin/predicate-write-summary-recompute.integration.test.ts,预期 6 条全绿(真 SQLite 驱动,含两条流程节点用例)。What changes
The predicate arms of
ObjectQL.update()andObjectQL.delete()(multi: true, no scalarwhere.id) now hand the existingrecomputeSummariesthe matched rows' foreign keys. Seat ruling: no second recompute routine, no new option onengine.update/engine.delete, no refusal ofmulti: true.previous(the old parents). An update hands them aspreviousand, merged with the payload the driver receives, asrecords(the new parents), so a reparent recomputes both, as the by-id update does withpriorRecord.summaryForeignKeyColumns(object)names the FK columns of the roll-up descriptors over the object (getSummaryDescriptors). Empty means nothing is read and the arm keeps its singledriver.updateMany/driver.deleteMany.fields: [fk…]). Where the write already reads its matched rows, that read serves and no second one is made: on an update, the matched-row memo (readPriorRows) when a consumer above made it (per-row hooks, per-row validation, the post-hook image check); on a delete, the referential walk's own id read (fix(objectql)!: a predicate delete honours deleteBehavior for every matched row: restrict refuses, master_detail cascades, set_null clears (#26871) #26930), which now reads['id', …fk], or, with no walk, the per-row hook read.after*dispatch, with the write's context and its transaction handle when there is one), and a failure surfaces as the sameSummaryRecomputeError. No new error code.SummaryRecomputeError(the parent update answers not-found) after the rows were deleted.withoutDeletedSelfParents(insummary-aggregate.ts) clears, on the walk arm's rows, only the self-referencing descriptors' FK values that name a row this delete removed. The by-id delete never meets this case (its children's own deletes recompute the parent while it still exists).Tests
packages/services/service-automation/src/builtin/predicate-write-summary-recompute.integration.test.ts(new; realObjectKernel+ObjectQLPlugin+SqlDriverbetter-sqlite3 +AutomationServicePlugin, the stack the objectql: a predicate delete (multi: true with a filter) skips the referential walk, so a flow's bulk delete_record bypasses restrict, master_detail cascade and set_null #26871 flow test boots). An order with acountand asumroll-up overmaster_detaillines, two orders per case, roll-ups read out of the driver. Six cases: a predicate delete of both of one order's lines brings it to 0/0 and leaves the other order alone; a predicate update moving both lines to the other order recomputes both (old 0/0, new 3/47); a predicate update of the summed field recomputes the parent it stays under; a predicate delete of a second child that aset_nulllookup points at (so it takes the referential walk's arm) recomputes the parent and still clears the lookup; a flowdelete_recordwithmulti: true; a flowupdate_recordwithmulti: truethat reparents.packages/objectql/src/summary-rollup.test.ts, new section[#26950](6 cases, this file's own fake driver, which learnedupdateMany/deleteMany, an ordered read/write log and copyingfindanswers). The control: an object no roll-up aggregates makes exactly oneupdateManyand onedeleteManyand no row read. With a roll-up: a predicate update and a predicate delete each read the matched rows ONCE before the write,fields: ['inv'], under the caller'swhere, and land the right totals; with per-rowafterUpdate/afterDeletehooks registered the hooks' read is the only one; with a lookup pointing at the child the walk's id read is the only one and carries['id', 'inv']; a self roll-up predicate delete that removes a parent and its child answers its count and recomputes only the surviving root.Acceptance notes
SummaryRecomputeError. It is the by-id write's existing answer when a parent recompute fails after retries (the rows were written, the roll-up may be stale). Before, a predicate write never asked, so it never failed this way; it left the roll-up stale instead. No write is refused, and no accepted value or shape changes:Clause-②: no.planCascadeAtomicityanswer other than'none', so its predicate delete always takes the walk arm where the filter sits.src/engine.ts(23,903 lines, +7,501 in 30 days) along itsObjectQLseams, one move-only slice per PR #25522 split series moved is edited.update(),delete(),recomputeSummariesandgetSummaryDescriptorsall still live inengine.ts. At start and before opening this PR, no open PR touchedpackages/objectql/src/engine.ts(feat(spec): a hook declares its refusal catalogue, and its body refuses by code, answered in the caller's language #26831 touchesengine-execution-plumbing.tsonly).Verification
All local readings below are on head
e3a38f12(the merge ofmainat49b50591into the branch) unless a line names another, worktreeobjectstack-issue-26950, shared container.Red first, on base 40264be (before the fix)
Tests 6 failed (6). Each parent kept its pre-write roll-up, e.g.expected { count: 2, total: 42 } to deeply equal { count: +0, total: +0 }for the predicate delete, andthe old parent: expected { count: 2, total: 42 } …for the reparent.src/summary-rollup.test.tswithengine.tsrestored to the base blobfb30f0f7(tree only):Tests 4 failed | 18 passed (22). The four read-discipline cases red; the no-roll-up control stays green, as it must. Restored: blob31029717== HEAD,git diff HEADempty, objectql rebuilt.Green, and the ablations
pnpm --filter @objectstack/objectql testonf6f89395: Test Files 475 passed (475), Tests 8883 passed (8883). service-automation full suite onf6f89395: Test Files 215 passed (215), Tests 2752 passed (2752).pnpm --filter @objectstack/objectql typecheckandpnpm --filter @objectstack/service-automation typecheck: exit 0 (onf6f89395; objectql re-run exit 0 after the last source commit, a type-only change of two casts).e3a38f12, after a full build:summary-rollup,engine-cascade-delete-atomic,engine-nested-relation-lowering55 passed (3 files); the new flow file plus objectql: a predicate delete (multi: true with a filter) skips the referential walk, so a flow's bulk delete_record bypasses restrict, master_detail cascade and set_null #26871'sdelete-record-predicate-referential-walk8 passed (2 files).eslint --no-inline-config packages/objectql packages/services/service-automation: exit 0, no output.scripts/ablation-replace.mjs(anchor 1 to 0, blob moved, restored to HEAD blob,git diff HEADempty), objectql rebuilt andablation-dist-preflightfinding the marker in dist where the flow file was read:next: previous): flow file 2 failed (both reparent cases), objectql 2 failed.SummaryRecomputeError.The restore leg rebuilt objectql and
ablation-dist-preflight --absentwas clean for every marker.Gates (derived by dispatch-gates --commands on the real diff)
e3a38f12, after a fullturbo run build: 65 families derived, each run with its exit code captured before any pipe: 65 exit 0.dispatch-gates --ran: 65 derived, 65 run, 0 NOT-MEASURED, 0 UNRUN.f6f89395) caught two things:check:query-options-erasurecounted the two new driver reads'as anyoption casts (8 to 10 inengine.ts), now typed as the third parameter ofIDataDriver['find'], as the by-id precondition read is;check-plugin-teardown-shape --self-testneeded its pinned fixture commit fetched.check:*farm,Test Core,Test UI, the rest of the required nine) is CI's: see this PR's check-runs.Dogfood battery
!in the title and no(narrowing)arm onClause-②.Session:
session_01FEkQ5mKGG79zhwLb1obtFsGenerated by Claude Code