Repository navigation
Commit 0eb2d21
<!-- claude-projects-attribution: {"github_login":"os-project-manager"}
-->
_Requested by **Leehom** · [project
thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1XU1LK3XtFTgExsmvSjfSEw7)_
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 (#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 #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 #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` (#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.
<details><summary>Red first, on base 40264be (before the fix)</summary>
- 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.
</details>
<details><summary>Green, and the ablations</summary>
- 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 #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.
</details>
<details><summary>Gates (derived by dispatch-gates --commands on the
real diff)</summary>
- 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.
</details>
<details><summary>Dogfood battery</summary>
- Not owed: no `!` in the title and no `(narrowing)` arm on `Clause-②`.
</details>
Session: `session_01FEkQ5mKGG79zhwLb1obtFs`
---
_Generated by [Claude
Code](https://claude.ai/code/session_01FEkQ5mKGG79zhwLb1obtFs)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
1 parent 5ad6f22 commit 0eb2d21
5 files changed
Lines changed: 475 additions & 7 deletions
File tree
- .changeset
- packages
- objectql/src
- services/service-automation/src/builtin
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
199 | 199 | | |
200 | 200 | | |
201 | 201 | | |
| 202 | + | |
202 | 203 | | |
203 | 204 | | |
204 | 205 | | |
| |||
12402 | 12403 | | |
12403 | 12404 | | |
12404 | 12405 | | |
| 12406 | + | |
| 12407 | + | |
| 12408 | + | |
| 12409 | + | |
| 12410 | + | |
| 12411 | + | |
| 12412 | + | |
| 12413 | + | |
12405 | 12414 | | |
12406 | 12415 | | |
12407 | 12416 | | |
| |||
16735 | 16744 | | |
16736 | 16745 | | |
16737 | 16746 | | |
| 16747 | + | |
| 16748 | + | |
| 16749 | + | |
| 16750 | + | |
| 16751 | + | |
16738 | 16752 | | |
16739 | 16753 | | |
16740 | 16754 | | |
| |||
17504 | 17518 | | |
17505 | 17519 | | |
17506 | 17520 | | |
| 17521 | + | |
| 17522 | + | |
| 17523 | + | |
| 17524 | + | |
| 17525 | + | |
| 17526 | + | |
| 17527 | + | |
| 17528 | + | |
| 17529 | + | |
| 17530 | + | |
| 17531 | + | |
| 17532 | + | |
| 17533 | + | |
| 17534 | + | |
| 17535 | + | |
| 17536 | + | |
| 17537 | + | |
17507 | 17538 | | |
17508 | 17539 | | |
17509 | 17540 | | |
| |||
17591 | 17622 | | |
17592 | 17623 | | |
17593 | 17624 | | |
17594 | | - | |
| 17625 | + | |
| 17626 | + | |
| 17627 | + | |
| 17628 | + | |
17595 | 17629 | | |
17596 | 17630 | | |
17597 | 17631 | | |
| |||
19607 | 19641 | | |
19608 | 19642 | | |
19609 | 19643 | | |
| 19644 | + | |
| 19645 | + | |
| 19646 | + | |
| 19647 | + | |
| 19648 | + | |
| 19649 | + | |
19610 | 19650 | | |
19611 | 19651 | | |
19612 | 19652 | | |
| |||
19746 | 19786 | | |
19747 | 19787 | | |
19748 | 19788 | | |
| 19789 | + | |
19749 | 19790 | | |
19750 | 19791 | | |
19751 | 19792 | | |
| |||
19841 | 19882 | | |
19842 | 19883 | | |
19843 | 19884 | | |
| 19885 | + | |
| 19886 | + | |
| 19887 | + | |
| 19888 | + | |
| 19889 | + | |
| 19890 | + | |
| 19891 | + | |
| 19892 | + | |
| 19893 | + | |
| 19894 | + | |
| 19895 | + | |
19844 | 19896 | | |
19845 | 19897 | | |
| 19898 | + | |
| 19899 | + | |
| 19900 | + | |
| 19901 | + | |
19846 | 19902 | | |
19847 | 19903 | | |
19848 | 19904 | | |
| |||
19857 | 19913 | | |
19858 | 19914 | | |
19859 | 19915 | | |
19860 | | - | |
| 19916 | + | |
19861 | 19917 | | |
| 19918 | + | |
19862 | 19919 | | |
19863 | 19920 | | |
19864 | 19921 | | |
| |||
19926 | 19983 | | |
19927 | 19984 | | |
19928 | 19985 | | |
19929 | | - | |
19930 | | - | |
19931 | | - | |
| 19986 | + | |
| 19987 | + | |
| 19988 | + | |
| 19989 | + | |
| 19990 | + | |
19932 | 19991 | | |
19933 | 19992 | | |
19934 | 19993 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
120 | 120 | | |
121 | 121 | | |
122 | 122 | | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
| 147 | + | |
| 148 | + | |
| 149 | + | |
| 150 | + | |
| 151 | + | |
| 152 | + | |
| 153 | + | |
| 154 | + | |
| 155 | + | |
| 156 | + | |
| 157 | + | |
0 commit comments