From 8dc35abab2165696c65a8319bb708fd3198e9e30 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 13:09:02 +0000 Subject: [PATCH 1/3] docs(changeset): state what the set-rooted delete walk changed The pending objectql release note for the predicate delete walk listed a by-id delete's probe filters as unchanged, left out the count and hook change for a matched row another matched row cascades into and the change in which refusal is reported, and gave an example the old walk did not show. Correct those sentences; the level and both declaration lines stay. Claude-Session: https://claude.ai/code/session_01Utp6CXc5hequf76uJfzrdK Co-authored-by: Claude --- .changeset/27004-predicate-delete-walk-as-set.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/.changeset/27004-predicate-delete-walk-as-set.md b/.changeset/27004-predicate-delete-walk-as-set.md index 2b8dfcd530e..bee19c0aac8 100644 --- a/.changeset/27004-predicate-delete-walk-as-set.md +++ b/.changeset/27004-predicate-delete-walk-as-set.md @@ -4,10 +4,13 @@ fix(objectql): a predicate delete walks the rows it matches as one set: each relation pointing at the object is asked once per 500 matched rows, not once per row (#27004) -Clause-②: yes (widening) — a predicate delete (`engine.delete(object, { where, multi: true })`, a flow's `delete_record` with `multi: true`) whose matched rows are held only by OTHER matched rows (a report and its manager through a `restrict` self-lookup, both matched) now deletes them; the row-by-row walk refused it, or not, depending on which row it met first. Nothing is narrowed. +Clause-②: yes (widening) — a predicate delete (`engine.delete(object, { where, multi: true })`, a flow's `delete_record` with `multi: true`) whose matched rows are held only by OTHER matched rows (a report and its manager through a `restrict` self-lookup, both matched) now deletes them; the row-by-row walk refused it, whichever of the two rows it met first. Nothing is narrowed. Metadata-protocol: none - **Before.** The predicate delete ran the by-id referential walk once per matched row, so its cost was matched rows times referencing relations: 1,001 rows of an object three relations point at took 4,004 dependents probes, about 1.7 s on SQLite. -- **Now.** One walk, rooted at the whole matched set (a by-id delete is a set of one). Each referencing relation is probed once per 500 matched rows with `$in` (a multiple lookup ORs each id's `$contains`), and each row is then judged and written as before: `restrict` refuses, `cascade` deletes (each child through its own delete, hooks and all), `set_null` clears, a multiple lookup loses only the deleted members and an emptied set is written as `[]`. The same 1,001-row delete takes 12 probes. -- **Unchanged.** A refusal's code and wording: it still names one record and counts the rows that refuse over that record (for a required multiple lookup, the rows the removal would empty). The atomic and split plans, the 500-id `$in` delete batches, the roll-up recompute after the delete, and the filter of every probe a by-id delete sends. +- **Now.** One walk, rooted at the whole matched set (a by-id delete is a set of one). Each referencing relation is probed once per 500 matched rows with `$in` (a multiple lookup ORs each id's `$contains`), and each row is then judged and written as before: `restrict` refuses, `cascade` deletes (each child the predicate did not match through its own delete, hooks and all), `set_null` clears, a multiple lookup loses only the deleted members and an emptied set is written as `[]`. The same 1,001-row delete takes 12 probes. +- **A by-id delete's probes, too.** Its own record is still probed with that record's filter, and each cascaded child's delete still probes its one record. The reads made before the first write changed: collecting the rows the cascade removes now probes the records found at one depth together (per object and relation, from the root's children down), and the check for an authored `restrict` that the delete's own cascade would override probes together the records that ask one relation. Two or more records are asked with one `$in` (on a multiple lookup, the OR of each id's `$contains`) where each used to get its own probe; a lone record keeps its own filter. Deleting a parent with two cascading children therefore reads the grandchildren with one `$in`, where it used to send two equality probes. A driver answering these probes now receives `$in`; a batch with a single-valued row that names none of its ids by `String()` is asked again one id at a time. +- **A matched row another matched row's cascade reaches** (an object's `cascade` reference to itself, with parent and child both matched). The cascade no longer deletes it through its own delete; the predicate delete removes it with the other matched rows. The count `engine.delete` returns, which a flow's `delete_record` hands on as its `result`, now includes that row, where it used to leave it out, and the row's `beforeDelete` and `afterDelete` hooks fire once, as a matched row, where they used to fire a second time from the cascade's delete. +- **Which refusal is reported when more than one applies.** The row-by-row walk finished one matched row's relations before it started the next row, so it reported the first matched row that refused. The set walk asks each relation about every matched row before it moves to the next relation, so it reports the first relation that refuses, naming the first matched row that relation refuses for; and it checks every matched row for an authored `restrict` its own cascade would override before it walks the first relation. +- **Unchanged.** A refusal's code and wording: it still names one record and counts the rows that refuse over that record (for a required multiple lookup, the rows the removal would empty). The atomic and split plans, the 500-id `$in` delete batches, and the roll-up recompute after the delete. - **Who sees it.** A bulk delete that removes rows holding each other: a matched row that another matched row holds is in the delete's own set, so it neither refuses nor is cleared first (the rule a cascade already applies to the rows it deletes). A holder outside the matched rows still refuses. From 00ca77b430d6434641e909d391c515ff0a439a3e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 13:41:00 +0000 Subject: [PATCH 2/3] test(objectql): pin the count and hooks of a matched row another matched row cascades into A predicate delete walks its matched rows as one set, so a matched row that another matched row's cascade reaches is removed by the predicate's own id-set delete: the count includes it and its delete hooks fire once. The pending release note states this; the test holds it. Claude-Session: https://claude.ai/code/session_01Utp6CXc5hequf76uJfzrdK Co-authored-by: Claude --- .../src/engine-cascade-delete-atomic.test.ts | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/packages/objectql/src/engine-cascade-delete-atomic.test.ts b/packages/objectql/src/engine-cascade-delete-atomic.test.ts index 6f25bee0348..90ed383135a 100644 --- a/packages/objectql/src/engine-cascade-delete-atomic.test.ts +++ b/packages/objectql/src/engine-cascade-delete-atomic.test.ts @@ -572,6 +572,38 @@ describe('[#26871] a predicate delete runs the referential walk for every matche expect(driver.writes).toEqual([]); expect(driver.rowsOf('detail')).toHaveLength(1); }); + + it('counts a matched row another matched row cascades into, and fires its delete hooks once (#27004)', async () => { + // One walk over the matched set: the child is a member like its parent, + // so the cascade leaves it to the predicate's own id-set delete instead of + // deleting it through a nested delete of its own. + const crew = { + name: 'crew', + label: 'Crew', + fields: { + id: { name: 'id', type: 'text' as const, primaryKey: true }, + name: { name: 'name', type: 'text' as const }, + lead: { name: 'lead', type: 'lookup' as const, reference: 'crew', deleteBehavior: 'cascade' }, + }, + }; + const { engine, driver } = await makeEngine([crew]); + const lead = await engine.insert('crew', { name: 'doomed', lead: null }); + const member = await engine.insert('crew', { name: 'doomed', lead: lead.id }); + const kept = await engine.insert('crew', { name: 'kept', lead: null }); + const events: string[] = []; + for (const event of ['beforeDelete', 'afterDelete'] as const) { + engine.registerHook(event, async (ctx: any) => void events.push(`${event}:${ctx.input?.id}`), { object: 'crew' }); + } + + const removed = await engine.delete('crew', { where: { name: 'doomed' }, multi: true }); + + expect(removed).toBe(2); + expect(driver.rowsOf('crew').map((r: any) => r.id)).toEqual([kept.id]); + expect([...events].sort()).toEqual([ + `afterDelete:${lead.id}`, `afterDelete:${member.id}`, + `beforeDelete:${lead.id}`, `beforeDelete:${member.id}`, + ].sort()); + }); }); // --------------------------------------------------------------------------- From cbc268d7dfb64f33707e2b03743baec47c48ddd7 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 14:11:51 +0000 Subject: [PATCH 3/3] docs(changeset): say a by-id delete's batched probes per 500 records, and name the nested delete Claude-Session: https://claude.ai/code/session_01Utp6CXc5hequf76uJfzrdK Co-authored-by: Claude --- .changeset/27004-predicate-delete-walk-as-set.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.changeset/27004-predicate-delete-walk-as-set.md b/.changeset/27004-predicate-delete-walk-as-set.md index bee19c0aac8..44b4de13073 100644 --- a/.changeset/27004-predicate-delete-walk-as-set.md +++ b/.changeset/27004-predicate-delete-walk-as-set.md @@ -9,8 +9,8 @@ Metadata-protocol: none - **Before.** The predicate delete ran the by-id referential walk once per matched row, so its cost was matched rows times referencing relations: 1,001 rows of an object three relations point at took 4,004 dependents probes, about 1.7 s on SQLite. - **Now.** One walk, rooted at the whole matched set (a by-id delete is a set of one). Each referencing relation is probed once per 500 matched rows with `$in` (a multiple lookup ORs each id's `$contains`), and each row is then judged and written as before: `restrict` refuses, `cascade` deletes (each child the predicate did not match through its own delete, hooks and all), `set_null` clears, a multiple lookup loses only the deleted members and an emptied set is written as `[]`. The same 1,001-row delete takes 12 probes. -- **A by-id delete's probes, too.** Its own record is still probed with that record's filter, and each cascaded child's delete still probes its one record. The reads made before the first write changed: collecting the rows the cascade removes now probes the records found at one depth together (per object and relation, from the root's children down), and the check for an authored `restrict` that the delete's own cascade would override probes together the records that ask one relation. Two or more records are asked with one `$in` (on a multiple lookup, the OR of each id's `$contains`) where each used to get its own probe; a lone record keeps its own filter. Deleting a parent with two cascading children therefore reads the grandchildren with one `$in`, where it used to send two equality probes. A driver answering these probes now receives `$in`; a batch with a single-valued row that names none of its ids by `String()` is asked again one id at a time. -- **A matched row another matched row's cascade reaches** (an object's `cascade` reference to itself, with parent and child both matched). The cascade no longer deletes it through its own delete; the predicate delete removes it with the other matched rows. The count `engine.delete` returns, which a flow's `delete_record` hands on as its `result`, now includes that row, where it used to leave it out, and the row's `beforeDelete` and `afterDelete` hooks fire once, as a matched row, where they used to fire a second time from the cascade's delete. +- **A by-id delete's probes, too.** Its own record is still probed with that record's filter, and each cascaded child's delete still probes its one record. The reads made before the first write changed: collecting the rows the cascade removes now probes the records found at one depth together (per object and relation, from the root's children down), and the check for an authored `restrict` that the delete's own cascade would override probes together the records that ask one relation. Two or more records are asked together with `$in` (on a multiple lookup, the OR of each id's `$contains`) where each used to get its own probe; a lone record keeps its own filter. Deleting a parent with two children on one cascading relation therefore probes each of the children's own cascading relations once with `$in`, where it used to send one probe per child. A driver answering these probes now receives `$in`; a batch with a single-valued row that names none of its ids by `String()` is asked again one id at a time. +- **A matched row another matched row's cascade reaches** (an object's `cascade` reference to itself, with parent and child both matched). The cascade no longer deletes it through a nested delete of its own; the predicate delete removes it with the other matched rows. The count `engine.delete` returns, which a flow's `delete_record` hands on as its `result`, now includes that row, where it used to leave it out, and the row's `beforeDelete` and `afterDelete` hooks fire once, as a matched row, where they used to fire a second time from the cascade's delete. - **Which refusal is reported when more than one applies.** The row-by-row walk finished one matched row's relations before it started the next row, so it reported the first matched row that refused. The set walk asks each relation about every matched row before it moves to the next relation, so it reports the first relation that refuses, naming the first matched row that relation refuses for; and it checks every matched row for an authored `restrict` its own cascade would override before it walks the first relation. - **Unchanged.** A refusal's code and wording: it still names one record and counts the rows that refuse over that record (for a required multiple lookup, the rows the removal would empty). The atomic and split plans, the 500-id `$in` delete batches, and the roll-up recompute after the delete. - **Who sees it.** A bulk delete that removes rows holding each other: a matched row that another matched row holds is in the delete's own set, so it neither refuses nor is cleared first (the rule a cascade already applies to the rows it deletes). A holder outside the matched rows still refuses.