Skip to content

fix(objectql): a predicate update or delete recomputes the parent roll-up summaries its matched rows name (#26950) - #27000

Merged
objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/project-thread-q7o13v
Oct 28, 2026
Merged

objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/project-thread-q7o13v

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

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 写子记录才被纠正。流程批量删除或改挂订单行,订单合计静默错误。
  • 风险与回滚:没有汇总字段指向的对象完全不变,仍是一次批量驱动调用、不多读一行。有汇总时在写之前多读一次匹配行的外键列(已有读取时复用,不重复读)。重算失败与按 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() and ObjectQL.delete() (multi: true, no scalar where.id) now hand the existing recomputeSummaries the matched rows' foreign keys. Seat ruling: no second recompute routine, no new option on engine.update / engine.delete, no refusal of multi: true.

  • Which parents. A delete hands the matched rows as previous (the old parents). An update hands them as previous and, merged with the payload the driver receives, as records (the new parents), so a reparent recomputes both, as the by-id update does with priorRecord.
  • Cheap when there is nothing to recompute. A new private 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 single driver.updateMany / driver.deleteMany.
  • One read, before the write, through the write's own composed AST and driver options. Only the FK columns are read (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.
  • Failures and atomicity are the by-id path's. The recompute runs where the by-id arm runs it (after the after* dispatch, with the write's context and its transaction handle when there is one), and a failure surfaces as the same SummaryRecomputeError. No new error code.
  • A self roll-up's deleted parent is not asked about. A roll-up over its own object can name, from one matched row, a parent that the same predicate delete removes. Measured during this work: handing that row unfiltered made the delete throw SummaryRecomputeError (the parent update answers not-found) after the rows were deleted. withoutDeletedSelfParents (in summary-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; real ObjectKernel + ObjectQLPlugin + SqlDriver better-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 a count and a sum roll-up over master_detail lines, 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 a set_null lookup points at (so it takes the referential walk's arm) recomputes the parent and still clears the lookup; a flow delete_record with multi: true; a flow update_record with multi: true that reparents.
  • packages/objectql/src/summary-rollup.test.ts, new section [#26950] (6 cases, this file's own fake driver, which learned updateMany / deleteMany, an ordered read/write log and copying find answers). The control: an object no roll-up aggregates makes exactly one updateMany and one deleteMany and 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's where, and land the right totals; with per-row afterUpdate / afterDelete hooks 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

  • A predicate write can now end in 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.
  • Cost. An object with a roll-up over it pays one FK-only read per predicate write (none when the write already reads its matched rows) and one aggregate plus one parent update per distinct parent per roll-up, as N by-id writes would, deduplicated per parent. No ceiling is added (none was asked). An object no roll-up aggregates pays nothing new.
  • The self roll-up filter runs on the walk arm only. A roll-up over its own object reaches its parent through a reference to that object, which makes planCascadeAtomicity answer other than 'none', so its predicate delete always takes the walk arm where the filter sits.
  • No body the [maintainer] refactor(objectql): split src/engine.ts (23,903 lines, +7,501 in 30 days) along its ObjectQL seams, one move-only slice per PR #25522 split series moved is edited. update(), delete(), recomputeSummaries and getSummaryDescriptors all still live in engine.ts. At start and before opening this PR, no open PR touched packages/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 touches engine-execution-plumbing.ts only).

Verification

All local readings below are on head e3a38f12 (the merge of main at 49b50591 into the branch) unless a line names another, worktree objectstack-issue-26950, shared container.

Red first, on base 40264be (before the fix)
  • The flow and engine file, objectql dist built from the base: 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, and the old parent: expected { count: 2, total: 42 } … for the reparent.
  • src/summary-rollup.test.ts with engine.ts restored to the base blob fb30f0f7 (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: blob 31029717 == HEAD, git diff HEAD empty, objectql rebuilt.
Green, and the ablations
  • objectql full suite pnpm --filter @objectstack/objectql test on f6f89395: Test Files 475 passed (475), Tests 8883 passed (8883). service-automation full suite on f6f89395: Test Files 215 passed (215), Tests 2752 passed (2752).
  • pnpm --filter @objectstack/objectql typecheck and pnpm --filter @objectstack/service-automation typecheck: exit 0 (on f6f89395; objectql re-run exit 0 after the last source commit, a type-only change of two casts).
  • On e3a38f12, after a full build: summary-rollup, engine-cascade-delete-atomic, engine-nested-relation-lowering 55 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's delete-record-predicate-referential-walk 8 passed (2 files).
  • eslint --no-inline-config packages/objectql packages/services/service-automation: exit 0, no output.
  • Ablations, each through scripts/ablation-replace.mjs (anchor 1 to 0, blob moved, restored to HEAD blob, git diff HEAD empty), objectql rebuilt and ablation-dist-preflight finding the marker in dist where the flow file was read:
    1. The update hands no new FKs (next: previous): flow file 2 failed (both reparent cases), objectql 2 failed.
    2. The walk arm keeps no FK rows: flow file 1 failed (the walk case), objectql 1 failed.
    3. No reuse of the per-row hook read: objectql 1 failed (the reuse case); the flow file stays green, as it should, since an extra read changes no total.
    4. The self roll-up filter removed: objectql 1 failed, the delete throwing SummaryRecomputeError.
      The restore leg rebuilt objectql and ablation-dist-preflight --absent was clean for every marker.
Gates (derived by dispatch-gates --commands on the real diff)
  • On e3a38f12, after a full turbo 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.
  • The first sweep (on f6f89395) caught two things: check:query-options-erasure counted the two new driver reads' as any option casts (8 to 10 in engine.ts), now typed as the third parameter of IDataDriver['find'], as the by-id precondition read is; check-plugin-teardown-shape --self-test needed its pinned fixture commit fetched.
  • Everything else (the check:* farm, Test Core, Test UI, the rest of the required nine) is CI's: see this PR's check-runs.
Dogfood battery
  • Not owed: no ! in the title and no (narrowing) arm on Clause-②.

Session: session_01FEkQ5mKGG79zhwLb1obtFs


Generated by Claude Code

@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Oct 28, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

8 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
  • 1 anchor(s) matched too much of the corpus to be a work list: ObjectQL (symbol, 72 pages)
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 55 of 207 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 19 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json b8f596cfdac42361d6ef4a77f8868f9c8e59efe3 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 534417d0e069185892bf0fc5020de85cc1a1b98a — the merge of head e3a38f12a5f9955d3a3c224211a925ab57f787de into base b8f596cfdac42361d6ef4a77f8868f9c8e59efe3, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对

ACCEPT · domain:server seat 2 · os-project-manager · session_0186vfjHHMsRXC9EUT9hTFMo · 2026-10-28T01:11Z · head e3a38f12a5 · #26950

The maintainer's standing authorisation for landing reviewed PRs, verbatim: 「按照 pm-dispatch 规则复核过的都授权」.

  1. Shape:
    • A draft against main, assignee os-project-manager. The body carries Fixes #26950, with Clause-②: no and Metadata-protocol: none line-initial; the changeset .changeset/26950-predicate-write-summary-recompute.md (objectql patch) repeats them.
    • check-governed-merges --pr 27000 reads NOT governed: 0 of 5 paths hit the register, 482 changed lines.
    • The claim (6455764827) names this PR's branch and the same Clause-② reading.
  2. Against the order (order-26950):
    • Both predicate arms feed the existing recomputeSummaries: a delete hands the matched rows as previous, an update hands them as previous and, merged with the payload the driver receives, as the new records, so a reparent recomputes both parents. No second recompute routine, no new option on engine.update / engine.delete, no new error code.
    • summaryForeignKeyColumns(object) comes from getSummaryDescriptors; when it is empty nothing is read and the arm keeps its single updateMany / deleteMany. A control case pins that.
    • Where the write already reads its matched rows (the readPriorRows memo, the per-row hook read, the walk's id read from fix(objectql)!: a predicate delete honours deleteBehavior for every matched row: restrict refuses, master_detail cascades, set_null clears (#26871) #26930, now ['id', …fk]), that read serves. Cases pin each reuse.
  3. The self roll-up fix the dev measured: a predicate delete that removed a parent and its own child threw SummaryRecomputeError after deleting. withoutDeletedSelfParents clears, on the walk arm's rows, only self-referencing FK values that name a row this delete removed. It is pinned and ablated (objectql red with it removed).
  4. File surface: the helper sits in packages/objectql/src/summary-aggregate.ts, beside aggregateSummaryValue. The claim admits "a moved module only where the split series put a body they reach"; that file is the split-series home of aggregateSummaryValue, which recomputeSummaries reaches, and the helper filters the recompute's input rather than recomputing. The seat reads it as inside the surface; no amendment.
  5. Red first and ablation (the dev's):
    • On base 40264bee, the real-driver flow and engine file failed 6 of 6, each parent keeping its pre-write roll-up. summary-rollup.test.ts with engine.ts at the base blob failed 4 of 22, the read-discipline cases, with the no-roll-up control green.
    • Four ablations, each red (update hands no new FKs; walk arm keeps no FK rows; no reuse of the hook read; self filter removed), each restored to the HEAD blob with ablation-dist-preflight --absent clean.
  6. Tests: objectql 8,883 of 8,883 and service-automation 2,752 of 2,752 on f6f89395; on e3a38f12 after a full build, the three objectql files 55 passed and the flow files 8 passed. Both type-checks and eslint exit 0.
  7. Dogfood: not owed; no ! and no narrowing arm.
  8. Gates:
    • Locally 65 of 65 derived families exit 0 on e3a38f12, with 0 NOT-MEASURED.
    • At this stamp every check-run on the head reads success or skipped: 34 success, 12 skipped, none failed or in progress. GitHub reads the PR clean.
  9. Overlap: git merge-tree against main e6c4409000 reports no conflict. No other open PR touches packages/objectql/src/engine.ts or summary-aggregate.ts.
  10. Residual, not blocking: the self filter runs on the walk arm only. The PR argues a self roll-up always plans other than 'none', which holds when the FK is a lookup / master_detail / tree, as relationshipField is documented to be. A relationshipField explicitly naming a non-relation column on the same object would plan 'none' and could still meet the error; that is off the field's documented contract, so the seat does not hold this PR for it.

Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

2 participants