From ea661187f2d6f3cabe910323745b284468da40c8 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 20:42:11 +0000 Subject: [PATCH 1/6] test(objectql): pin the cascade-set rule for sibling restricts (red on base) Claude-Session: https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2 Co-authored-by: Claude --- ...ne-cascade-delete-sibling-restrict.test.ts | 486 ++++++++++++++++++ 1 file changed, 486 insertions(+) create mode 100644 packages/objectql/src/engine-cascade-delete-sibling-restrict.test.ts diff --git a/packages/objectql/src/engine-cascade-delete-sibling-restrict.test.ts b/packages/objectql/src/engine-cascade-delete-sibling-restrict.test.ts new file mode 100644 index 00000000000..18f72bbcfc3 --- /dev/null +++ b/packages/objectql/src/engine-cascade-delete-sibling-restrict.test.ts @@ -0,0 +1,486 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#22305] A record the cascade itself deletes never refuses a delete of + * another record the same cascade deletes. + * + * ## The defect + * + * `ObjectQL.cascadeDeleteRelations` walks the registry in registration order + * and recurses depth-first through the public `delete()`. Each recursion + * evaluates its own `restrict` refusals with no knowledge of the cascade that + * called it. In the measured shape (objectstack-ai/hotcrm's account delete), + * an account cascades to its contacts and to its contracts, and a contract + * holds a REQUIRED lookup to a contact, whose defaulted `set_null` escalates + * to `restrict`. When the contacts sort first, deleting a contact refuses with + * `DELETE_RESTRICTED` naming the contract, a record the same account delete + * was about to remove. When the contracts sort first, the identical delete + * succeeds. So the answer depended on registration order. + * + * ## The rule (triage ruling on #22305) + * + * A child that is itself in the cascade set never restricts a sibling in the + * same set. The engine now collects the set first (every record the cascade + * deletes, transitively, the root included) and judges `restrict`, and the + * `set_null` write, only against records outside it. + * + * ## What is pinned here, and the controls that keep it honest + * + * - the measured shape deletes in BOTH registration orders, and every member + * of the set is gone; + * - a restrict raised by a record OUTSIDE the set still refuses with the + * ADR-0112 envelope (`code` + `status`), names the dependent object, and the + * whole delete rolls back — escalated and authored both; + * - a `set_null` on an outside record still clears; one on a record the + * cascade deletes issues no write at all; + * - every deleted record fires `beforeDelete` and `afterDelete` exactly once; + * - the elevation ledger (#12166) files one record per referenced object per + * deleted record, as before, never two; + * - a cycle in the data terminates. + * + * The driver has REAL snapshot rollback (the `engine-cascade-delete-atomic` + * shape): a stub whose rollback is a no-op cannot tell "refused and rolled + * back" from "refused halfway", so the refusal controls would pass vacuously. + */ + +import { describe, it, expect } from 'vitest'; +import { ObjectQL } from './engine.js'; + +type Row = Record; +type Write = { object: string; op: 'create' | 'update' | 'delete'; id: string }; +type Logged = { level: 'debug' | 'info' | 'warn' | 'error'; message: string; meta: unknown }; + +function makeDriver() { + const stores = new Map>(); + const storeFor = (o: string): Map => { + let s = stores.get(o); + if (!s) { s = new Map(); stores.set(o, s); } + return s; + }; + const writes: Write[] = []; + const committed: unknown[] = []; + const rolledBack: unknown[] = []; + const snapshots = new Map>>(); + let nextId = 0; + const matches = (row: Row, where: unknown): boolean => { + if (!where || typeof where !== 'object') return true; + for (const [k, v] of Object.entries(where as Row)) { + if (k.startsWith('$')) continue; + const exp = v && typeof v === 'object' && '$eq' in (v as Row) ? (v as Row).$eq : v; + if ((row[k] ?? null) !== (exp ?? null)) return false; + } + return true; + }; + const driver: any = { + name: 'primary', version: '0.0.0', supports: {}, + writes, committed, rolledBack, + rowsOf: (o: string): Row[] => Array.from(storeFor(o).values()), + async connect() {}, async disconnect() {}, async checkHealth() { return true; }, async execute() { return null; }, + async syncSchema() {}, + async find(o: string, ast: { where?: unknown } | undefined) { + return Array.from(storeFor(o).values()).filter((r) => matches(r, ast?.where)); + }, + async findOne(o: string, ast: { where?: unknown } | undefined) { + for (const r of storeFor(o).values()) if (matches(r, ast?.where)) return r; + return null; + }, + async create(o: string, data: Row) { + nextId += 1; + const id = (data.id as string | undefined) ?? `r_${nextId}`; + const row = { ...data, id }; + writes.push({ object: o, op: 'create', id }); + storeFor(o).set(id, row); + return row; + }, + async update(o: string, id: string, data: Row) { + writes.push({ object: o, op: 'update', id: String(id) }); + const s = storeFor(o); + const cur = s.get(String(id)); + if (!cur) throw new Error(`not found ${o}/${id}`); + const up = { ...cur, ...data, id }; + s.set(String(id), up); + return up; + }, + async upsert(o: string, data: Row) { + const id = data.id as string | undefined; + return id && storeFor(o).has(id) ? this.update(o, id, data) : this.create(o, data); + }, + async delete(o: string, id: string) { + writes.push({ object: o, op: 'delete', id: String(id) }); + return storeFor(o).delete(String(id)); + }, + async count(o: string, ast: { where?: unknown } | undefined) { return (await this.find(o, ast)).length; }, + async bulkCreate(o: string, rows: Row[]) { return Promise.all(rows.map((r) => this.create(o, r))); }, + async bulkUpdate() { return []; }, + async bulkDelete() {}, + async beginTransaction() { + const handle = { __trx: snapshots.size + committed.length + rolledBack.length + 1 }; + const snap = new Map>(); + for (const [o, s] of stores) snap.set(o, new Map(Array.from(s, ([k, v]) => [k, { ...v }]))); + snapshots.set(handle, snap); + return handle; + }, + async commit(handle: unknown) { snapshots.delete(handle); committed.push(handle); }, + async rollback(handle: unknown) { + const snap = snapshots.get(handle); + if (snap) { stores.clear(); for (const [o, s] of snap) stores.set(o, s); } + snapshots.delete(handle); + rolledBack.push(handle); + }, + }; + return driver; +} + +async function makeEngine(objects: unknown[]) { + const logged: Logged[] = []; + const push = (level: Logged['level']) => (message: string, meta?: unknown) => + void logged.push({ level, message: String(message), meta }); + const engine = new ObjectQL({ + logger: { debug: push('debug'), info: push('info'), warn: push('warn'), error: push('error') }, + } as any); + const driver = makeDriver(); + engine.registerDriver(driver, true); + await engine.init(); + for (const o of objects) engine.registry.registerObject(o as any, '__test__'); + return { engine, driver, logged }; +} + +const id = { name: 'id', type: 'text' as const, primaryKey: true }; +const name = { name: 'name', type: 'text' as const }; + +// ── The measured shape (hotcrm's account / contact / contract) ────────────── + +const account = { name: 'zz_account', label: 'Account', fields: { id, name } }; +const contact = { + name: 'zz_contact', + label: 'Contact', + fields: { id, name, account: { name: 'account', type: 'master_detail' as const, reference: 'zz_account' } }, +}; +/** Cascades from the account AND holds a required lookup to a sibling (default `set_null` escalates). */ +const contract = { + name: 'zz_contract', + label: 'Contract', + fields: { + id, name, + account: { name: 'account', type: 'master_detail' as const, reference: 'zz_account' }, + primary_contact: { name: 'primary_contact', type: 'lookup' as const, reference: 'zz_contact', required: true }, + }, +}; + +/** Both registration orders. The defect answered differently for each. */ +const ORDERS: Array<[string, unknown[]]> = [ + ['contacts registered first', [account, contact, contract]], + ['contracts registered first', [account, contract, contact]], +]; + +async function seedAccount(engine: ObjectQL) { + const acc = await engine.insert('zz_account', { name: 'Acme' }); + const c1 = await engine.insert('zz_contact', { name: 'c1', account: acc.id }); + const c2 = await engine.insert('zz_contact', { name: 'c2', account: acc.id }); + const k1 = await engine.insert('zz_contract', { name: 'k1', account: acc.id, primary_contact: c1.id }); + const k2 = await engine.insert('zz_contract', { name: 'k2', account: acc.id, primary_contact: c1.id }); + const k3 = await engine.insert('zz_contract', { name: 'k3', account: acc.id, primary_contact: c2.id }); + return { acc, contacts: [c1, c2], contracts: [k1, k2, k3] }; +} + +describe('[#22305] a sibling in the cascade set never restricts another member', () => { + describe.each(ORDERS)('%s', (_label, objects) => { + it('deletes the account and every member of its cascade', async () => { + const { engine, driver } = await makeEngine(objects); + const { acc } = await seedAccount(engine); + + await engine.delete('zz_account', { where: { id: acc.id } } as any); + + expect(driver.rowsOf('zz_account')).toHaveLength(0); + expect(driver.rowsOf('zz_contact')).toHaveLength(0); + expect(driver.rowsOf('zz_contract')).toHaveLength(0); + expect(driver.committed).toHaveLength(1); + expect(driver.rolledBack).toHaveLength(0); + }); + + it('fires beforeDelete and afterDelete exactly once per deleted record', async () => { + const { engine } = await makeEngine(objects); + const { acc, contacts, contracts } = await seedAccount(engine); + const fired: string[] = []; + for (const event of ['beforeDelete', 'afterDelete'] as const) { + engine.registerHook(event, async (ctx: any) => void fired.push(`${event}:${ctx.object}:${ctx.input?.id}`)); + } + + await engine.delete('zz_account', { where: { id: acc.id } } as any); + + const expected = [ + `zz_account:${acc.id}`, + ...contacts.map((c) => `zz_contact:${c.id}`), + ...contracts.map((k) => `zz_contract:${k.id}`), + ]; + for (const event of ['beforeDelete', 'afterDelete']) { + const got = fired.filter((f) => f.startsWith(`${event}:`)).map((f) => f.slice(event.length + 1)); + expect(got.slice().sort()).toEqual(expected.slice().sort()); + } + }); + + it('files one elevation record per referenced object per deleted record, never two (#12166)', async () => { + const { engine, logged } = await makeEngine(objects); + const { acc, contacts } = await seedAccount(engine); + logged.length = 0; + + await engine.delete('zz_account', { where: { id: acc.id } } as any); + + const keys = logged + .filter((l) => l.level === 'info' && l.message.startsWith('[reference-cleanup] referential integrity check')) + .map((l) => { + const m = l.meta as { object: string; recordId: string; referencedObject: string }; + return `${m.object}/${m.recordId}/${m.referencedObject}`; + }); + // The account is referenced by both children; each contact by the + // contracts; nothing references a contract. + const expected = [ + `zz_account/${acc.id}/zz_contact`, + `zz_account/${acc.id}/zz_contract`, + ...contacts.map((c) => `zz_contact/${c.id}/zz_contract`), + ]; + expect(keys.slice().sort()).toEqual(expected.slice().sort()); + }); + }); +}); + +// ── Controls: a record OUTSIDE the cascade still refuses ──────────────────── + +/** Not related to the account at all; a REQUIRED lookup to a contact. */ +const invoice = { + name: 'zz_invoice', + label: 'Invoice', + fields: { id, bill_to: { name: 'bill_to', type: 'lookup' as const, reference: 'zz_contact', required: true } }, +}; +/** Not related to the account; an AUTHORED restrict to a contact. */ +const ticket = { + name: 'zz_ticket', + label: 'Ticket', + fields: { id, raised_by: { name: 'raised_by', type: 'lookup' as const, reference: 'zz_contact', deleteBehavior: 'restrict' } }, +}; +/** Not related to the account; an OPTIONAL lookup to a contact (`set_null`). */ +const lead = { + name: 'zz_lead', + label: 'Lead', + fields: { id, referred_by: { name: 'referred_by', type: 'lookup' as const, reference: 'zz_contact' } }, +}; + +describe('[#22305] controls: a restrict from outside the cascade set still refuses', () => { + describe.each(ORDERS)('%s', (_label, objects) => { + it('refuses on an outside REQUIRED lookup to a member, names it, and rolls the whole delete back', async () => { + const { engine, driver } = await makeEngine([...objects, invoice]); + const { acc, contacts } = await seedAccount(engine); + await engine.insert('zz_invoice', { bill_to: contacts[1].id }); + + const err = await engine.delete('zz_account', { where: { id: acc.id } } as any).catch((e: unknown) => e); + + expect(err).toMatchObject({ code: 'DELETE_RESTRICTED', status: 409, dependentObject: 'zz_invoice', dependentCount: 1 }); + expect(driver.rowsOf('zz_account')).toHaveLength(1); + expect(driver.rowsOf('zz_contact')).toHaveLength(2); + expect(driver.rowsOf('zz_contract')).toHaveLength(3); + expect(driver.rowsOf('zz_invoice')).toHaveLength(1); + expect(driver.rolledBack).toHaveLength(1); + }); + + it('refuses on an outside AUTHORED restrict to a member', async () => { + const { engine, driver } = await makeEngine([...objects, ticket]); + const { acc, contacts } = await seedAccount(engine); + await engine.insert('zz_ticket', { raised_by: contacts[0].id }); + + const err = await engine.delete('zz_account', { where: { id: acc.id } } as any).catch((e: unknown) => e); + + expect(err).toMatchObject({ code: 'DELETE_RESTRICTED', status: 409, dependentObject: 'zz_ticket' }); + expect(driver.rowsOf('zz_contact')).toHaveLength(2); + }); + + it('still clears an outside optional lookup to a member (set_null)', async () => { + const { engine, driver } = await makeEngine([...objects, lead]); + const { acc, contacts } = await seedAccount(engine); + const l = await engine.insert('zz_lead', { referred_by: contacts[0].id }); + + await engine.delete('zz_account', { where: { id: acc.id } } as any); + + expect(driver.rowsOf('zz_contact')).toHaveLength(0); + const kept = driver.rowsOf('zz_lead').find((r: Row) => r.id === l.id); + expect(kept).toBeDefined(); + expect(kept?.referred_by).toBeNull(); + }); + }); +}); + +// ── set_null and authored restrict INSIDE the set ─────────────────────────── + +/** Cascades from the account; an OPTIONAL lookup to a contact (`set_null`). */ +const memo = { + name: 'zz_memo', + label: 'Memo', + fields: { + id, + account: { name: 'account', type: 'master_detail' as const, reference: 'zz_account' }, + about: { name: 'about', type: 'lookup' as const, reference: 'zz_contact' }, + }, +}; +/** Cascades from the account; an AUTHORED restrict to a contact. */ +const quote = { + name: 'zz_quote', + label: 'Quote', + fields: { + id, + account: { name: 'account', type: 'master_detail' as const, reference: 'zz_account' }, + owner_contact: { name: 'owner_contact', type: 'lookup' as const, reference: 'zz_contact', deleteBehavior: 'restrict' }, + }, +}; +/** + * Cascades from the account, and ALSO holds a required lookup to the account + * itself, declared ahead of the master-detail field: the root is a member of + * its own cascade set. + */ +const ledger = { + name: 'zz_ledger', + label: 'Ledger', + fields: { + id, + billed_account: { name: 'billed_account', type: 'lookup' as const, reference: 'zz_account', required: true }, + account: { name: 'account', type: 'master_detail' as const, reference: 'zz_account' }, + }, +}; + +describe('[#22305] relations between members of the set', () => { + it.each([ + ['contacts registered first', [account, contact, memo]], + ['memos registered first', [account, memo, contact]], + ] as Array<[string, unknown[]]>)('issues no set_null write against a member the cascade deletes (%s)', async (_l, objects) => { + const { engine, driver } = await makeEngine(objects); + const acc = await engine.insert('zz_account', { name: 'Acme' }); + const c = await engine.insert('zz_contact', { name: 'c', account: acc.id }); + await engine.insert('zz_memo', { account: acc.id, about: c.id }); + driver.writes.length = 0; + + await engine.delete('zz_account', { where: { id: acc.id } } as any); + + expect(driver.rowsOf('zz_memo')).toHaveLength(0); + expect(driver.writes.filter((w: Write) => w.op === 'update')).toEqual([]); + }); + + it.each([ + ['contacts registered first', [account, contact, quote]], + ['quotes registered first', [account, quote, contact]], + ] as Array<[string, unknown[]]>)('an AUTHORED restrict between two members does not refuse (%s)', async (_l, objects) => { + const { engine, driver } = await makeEngine(objects); + const acc = await engine.insert('zz_account', { name: 'Acme' }); + const c = await engine.insert('zz_contact', { name: 'c', account: acc.id }); + await engine.insert('zz_quote', { account: acc.id, owner_contact: c.id }); + + await engine.delete('zz_account', { where: { id: acc.id } } as any); + + expect(driver.rowsOf('zz_contact')).toHaveLength(0); + expect(driver.rowsOf('zz_quote')).toHaveLength(0); + }); + + it('a member holding a required lookup to the ROOT does not refuse the root', async () => { + const { engine, driver } = await makeEngine([account, ledger]); + const acc = await engine.insert('zz_account', { name: 'Acme' }); + await engine.insert('zz_ledger', { account: acc.id, billed_account: acc.id }); + + await engine.delete('zz_account', { where: { id: acc.id } } as any); + + expect(driver.rowsOf('zz_account')).toHaveLength(0); + expect(driver.rowsOf('zz_ledger')).toHaveLength(0); + }); + + it('control: the same required lookup from a record OUTSIDE the set still refuses the root', async () => { + const { engine, driver } = await makeEngine([account, ledger]); + const acc = await engine.insert('zz_account', { name: 'Acme' }); + const other = await engine.insert('zz_account', { name: 'Other' }); + // Cascades from `other`, so it is outside `acc`'s set — and bills `acc`. + await engine.insert('zz_ledger', { account: other.id, billed_account: acc.id }); + + await expect(engine.delete('zz_account', { where: { id: acc.id } } as any)) + .rejects.toMatchObject({ code: 'DELETE_RESTRICTED', status: 409, dependentObject: 'zz_ledger' }); + expect(driver.rowsOf('zz_account')).toHaveLength(2); + }); +}); + +// ── Cycles ────────────────────────────────────────────────────────────────── + +/** A self-referencing cascade. */ +const node = { + name: 'zz_node', + label: 'Node', + fields: { id, name, parent: { name: 'parent', type: 'lookup' as const, reference: 'zz_node', deleteBehavior: 'cascade' } }, +}; +/** Two objects that cascade into each other. */ +const ringA = { + name: 'zz_ring_a', + label: 'Ring A', + fields: { id, b: { name: 'b', type: 'lookup' as const, reference: 'zz_ring_b', deleteBehavior: 'cascade' } }, +}; +const ringB = { + name: 'zz_ring_b', + label: 'Ring B', + fields: { id, a: { name: 'a', type: 'lookup' as const, reference: 'zz_ring_a', deleteBehavior: 'cascade' } }, +}; +/** A tree under the account: an item cascades from the account AND from its parent item. */ +const item = { + name: 'zz_item', + label: 'Item', + fields: { + id, name, + account: { name: 'account', type: 'master_detail' as const, reference: 'zz_account' }, + parent: { name: 'parent', type: 'lookup' as const, reference: 'zz_item', deleteBehavior: 'cascade' }, + }, +}; + +/** + * A runaway walk does not overflow the stack — every level awaits — so an + * unbounded cascade would hang until the test timeout. This guard turns it + * into a fast, named failure. + */ +function guardRunaway(engine: ObjectQL, limit = 50) { + let calls = 0; + engine.registerHook('beforeDelete', async () => { + calls += 1; + if (calls > limit) throw new Error(`runaway cascade: more than ${limit} beforeDelete dispatches`); + }); +} + +describe('[#22305] a cycle in the data terminates', () => { + it('a self-referencing cascade whose rows point at each other', async () => { + const { engine, driver } = await makeEngine([node]); + guardRunaway(engine); + const n1 = await engine.insert('zz_node', { name: 'n1' }); + const n2 = await engine.insert('zz_node', { name: 'n2', parent: n1.id }); + await engine.update('zz_node', { id: n1.id, parent: n2.id } as any); + + await engine.delete('zz_node', { where: { id: n1.id } } as any); + + expect(driver.rowsOf('zz_node')).toHaveLength(0); + }); + + it('two objects that cascade into each other', async () => { + const { engine, driver } = await makeEngine([ringA, ringB]); + guardRunaway(engine); + const a = await engine.insert('zz_ring_a', {}); + const b = await engine.insert('zz_ring_b', { a: a.id }); + await engine.update('zz_ring_a', { id: a.id, b: b.id } as any); + + await engine.delete('zz_ring_a', { where: { id: a.id } } as any); + + expect(driver.rowsOf('zz_ring_a')).toHaveLength(0); + expect(driver.rowsOf('zz_ring_b')).toHaveLength(0); + }); + + it('a tree reached twice — from the account and from its parent item — deletes each row once', async () => { + const { engine, driver } = await makeEngine([account, item]); + guardRunaway(engine); + const acc = await engine.insert('zz_account', { name: 'Acme' }); + const i1 = await engine.insert('zz_item', { name: 'i1', account: acc.id }); + await engine.insert('zz_item', { name: 'i2', account: acc.id, parent: i1.id }); + driver.writes.length = 0; + + await engine.delete('zz_account', { where: { id: acc.id } } as any); + + expect(driver.rowsOf('zz_item')).toHaveLength(0); + expect(driver.writes.filter((w: Write) => w.op === 'delete' && w.object === 'zz_item')).toHaveLength(2); + }); +}); From 651198e1783081196c265b245529a1075fff740b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 20:46:18 +0000 Subject: [PATCH 2/6] fix(objectql): collect the cascade set before judging restricts (WIP) Claude-Session: https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2 Co-authored-by: Claude --- packages/objectql/src/engine.ts | 575 +++++++++++++++++++++++--------- 1 file changed, 413 insertions(+), 162 deletions(-) diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index 540d18f10b3..6962c8a0b2b 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -3744,6 +3744,59 @@ export interface DatasourceDef { }; } +/** + * [#22305] The records ONE by-id cascade delete removes, collected before any + * of them is judged — see {@link ObjectQL.cascadeDeleteRelations}. + * + * Without it, every level of the depth-first walk judged its own `restrict` + * refusals with no knowledge of the cascade that called it: a record the same + * cascade was about to delete refused the delete of its sibling, and whether + * it got the chance depended on which object was registered first. The triage + * ruling on #22305 is the rule this set exists to apply: a child that is itself + * in the cascade set never restricts a sibling in the same set. + * + * Engine-internal and never part of a context: it rides + * {@link ObjectQL.cascadeDeleteSets}, an `AsyncLocalStorage`, so no request + * input can name a record into it, and a caller that passed no context still + * passes none. + */ +interface CascadeDeleteSet { + /** + * Object name → ids the cascade deletes, the root included. Collected by a + * read-only walk across every CASCADING relation before any write, so it is + * complete before the first refusal is judged. + */ + readonly members: Map>; + /** + * Object name → ids whose own referential walk has started, in progress or + * done. The walk never re-enters one, which is what makes a cycle in the + * data terminate and what keeps a row reached by two paths from being + * deleted twice. + */ + readonly entered: Map>; + /** + * The #12166 elevation records already filed, one per deleted record per + * referenced object, so the collection walk and the deleting walk, which + * read the same relations, file each record once between them. + */ + readonly elevationsFiled: Set; +} + +/** Is `id` of `object` in this index of a {@link CascadeDeleteSet}? */ +function cascadeSetHas(index: Map>, object: string, id: unknown): boolean { + return id != null && index.get(object)?.has(String(id)) === true; +} + +/** Add `id` of `object`; `true` when it was not there yet. */ +function cascadeSetAdd(index: Map>, object: string, id: unknown): boolean { + let ids = index.get(object); + if (!ids) { ids = new Set(); index.set(object, ids); } + const key = String(id); + if (ids.has(key)) return false; + ids.add(key); + return true; +} + export class ObjectQL implements IObjectQLEngine { /** * Ambient transaction store (ADR-0034). While a `transaction()` callback @@ -3774,6 +3827,16 @@ export class ObjectQL implements IObjectQLEngine { scope?: TransactionScope; }>(); + /** + * [#22305] The {@link CascadeDeleteSet} of the by-id cascade delete in + * progress, ambient for the same reason as {@link txStore}: the cascade + * recurses through the public `delete()`, and each level must judge its + * refusals against the set its root collected. A context key would be the + * other carrier, and it is not used: it would turn a caller's absent context + * into a present one on every nested delete. + */ + private readonly cascadeDeleteSets = new AsyncLocalStorage(); + private drivers = new Map(); private defaultDriver: string | null = null; private logger: Logger; @@ -16467,6 +16530,28 @@ export class ObjectQL implements IObjectQLEngine { * a row the removal would EMPTY keeps the refusal, and the refusal counts * only those rows. Only runs for single-id deletes — multi/predicate * deletes skip cascade (logged). + * + * [#22305] Two phases, so the walk knows its own set before it judges + * anything: + * 1. {@link collectCascadeDeleteSet} — read-only. Every record the cascade + * deletes, transitively, across every CASCADING relation, the root + * included. + * 2. {@link walkReferencingRelations} — the walk above, unchanged in + * order, except that a `restrict` (authored, or the escalated + * required `set_null`) and a `set_null` write consider only rows + * OUTSIDE the set. A row in the set is about to be deleted by this same + * cascade: it cannot be left dangling, so it refuses nothing, and + * clearing its reference would be a write against a row about to go. + * Before this, the outcome depended on registration order: with the + * contacts registered ahead of the contracts, deleting an account refused + * on a contract's required lookup to a contact, a contract the same delete + * was about to remove; the other order deleted. A refusal from a row outside + * the set is unchanged, and so is the unit of work: the set is collected + * inside the transaction that `delete()` opens for the cascade. + * + * A nested `delete()` the walk makes for a member joins the ambient set + * ({@link cascadeDeleteSets}); any other delete reaching here — a hook's, + * say — is the root of its own cascade, as before. */ private async cascadeDeleteRelations( object: string, @@ -16495,73 +16580,312 @@ export class ObjectQL implements IObjectQLEngine { // raised reaches the caller with its envelope intact, exactly as #8895's // probe failure does. const objects: ServiceObject[] = this._registry.getAllObjects(); - // [#12166, ruling constraint 3] Referenced objects this call has already - // filed an elevation record for. One record per referenced OBJECT, not per - // relation: two lookup fields on the same child pointing at the same parent - // are one "the platform read object X as system" fact, and filing it twice - // would make the ledger's row count a function of the child's field layout. - const elevationRecorded = new Set(); + // [#22305] A member of a cascade already in progress joins that cascade's + // set. Any other delete is the root of its own cascade, and collects the + // set first: before one refusal is judged and before one row is written. + const inherited = this.cascadeDeleteSets.getStore(); + if (inherited && cascadeSetHas(inherited.members, object, id)) { + await this.walkReferencingRelations(object, id, context, objects, inherited); + return; + } + const cascadeSet = await this.collectCascadeDeleteSet(object, id, context, objects); + await this.cascadeDeleteSets.run(cascadeSet, () => + this.walkReferencingRelations(object, id, context, objects, cascadeSet), + ); + } + + /** + * [#22305] Phase 1 of {@link cascadeDeleteRelations}: every record a by-id + * delete of `object`/`id` removes, the root included, collected READ-ONLY. + * + * Breadth-first across every relation whose resolved behaviour is + * `cascade`, read through the walk's own two readings — which relations + * point at an object ({@link cascadeRelationBehavior}) and which rows + * reference a record ({@link probeReferencingRows}: system identity, the + * same filter, the same missing-table and multi-value handling) — so the + * two phases cannot disagree about which rows a cascade reaches. A record + * already in the set is not expanded again, so a cycle in the data (a + * self-reference, two objects cascading into each other) terminates. + * + * The cost is one extra probe per CASCADING relation per member, since the + * walk probes those relations again as it deletes. `restrict` and + * `set_null` relations are not read here. Every probe here is an elevated + * read, so it files its #12166 record before it runs, through the + * once-per-record ledger the walk shares ({@link fileReferenceCheckElevation}). + */ + private async collectCascadeDeleteSet( + object: string, + id: string | number, + context: ExecutionContext | undefined, + objects: ServiceObject[], + ): Promise { + const set: CascadeDeleteSet = { members: new Map(), entered: new Map(), elevationsFiled: new Set() }; + cascadeSetAdd(set.members, object, id); + // A queue the loop appends to while it iterates: an array iterator reads + // `length` on every step, so each record pushed below is visited in turn. + const queue: Array<{ object: string; id: string | number }> = [{ object, id }]; + for (const target of queue) { + for (const child of objects) { + const childName = (child as any)?.name as string | undefined; + const fields = (child as any)?.fields as Record | undefined; + if (!childName || !fields) continue; + for (const [fieldName, fdef] of Object.entries(fields)) { + if (this.cascadeRelationBehavior(target.object, child, fieldName, fdef) !== 'cascade') continue; + this.fileReferenceCheckElevation(set, target.object, target.id, childName, fieldName, context); + const rows = await this.probeReferencingRows( + childName, fieldName, fdef, target.id, this.referenceProbeFilter(fieldName, fdef, target.id), context, + ); + for (const row of rows) { + const depId = row?.id; + if (depId != null && cascadeSetAdd(set.members, childName, depId)) { + queue.push({ object: childName, id: depId }); + } + } + } + } + } + return set; + } + + /** + * Does `fieldName` on `child` point at `object` as a relation the cascade + * walk follows, and with which behaviour? `undefined` when it does not. The + * behaviour is the RESOLVED one, before the required-`set_null` escalation, + * which also reads the field's `multiple` and, on a multi-value field, the + * rows themselves. + * + * [#22305] Lifted out of the walk so both phases of + * {@link cascadeDeleteRelations} read relations one way: the set the first + * collects is the set the second deletes. + */ + private cascadeRelationBehavior( + object: string, + child: ServiceObject, + fieldName: string, + fdef: any, + ): string | undefined { + if (!fdef || (fdef.type !== 'master_detail' && fdef.type !== 'lookup')) return undefined; + // [#18550] Same arbiter, same absence-vs-unreadability split as + // {@link ObjectQL.planCascadeAtomicity} states above — and this is the + // seam where the silence was measurable end to end: an unreadable + // carrier made the relation invisible to the cascade, so `delete()` + // removed the parent, left a `master_detail` child behind, and + // reported success. No `restrict` refusal, no `set_null`, nothing + // logged. Absence still skips the field here exactly as before. + const ref = referenceCarrierOf(fdef, 'ObjectQL.cascadeDeleteRelations'); + if (!ref) return undefined; + // Match the target object by raw or resolved name. + let resolvedRef: string | undefined; + try { resolvedRef = this.resolveObjectName(ref); } catch { resolvedRef = undefined; } + if (ref !== object && resolvedRef !== object) return undefined; + + // [#21910, #21918] A lookup the registry INJECTED into a federated + // object, and the object does not provision, is not a reference to + // anything, so it is not a relation to probe. That is the tenant + // anchor `organization_id`, the ADR-0117 D1 anchor + // `owning_business_unit_id`, and the owner and audit lookups + // `owner_id` / `created_by` / `updated_by`. On a federated object each + // exists in the registered schema and nowhere else: the probe below + // was refused by the driver (`INVALID_FILTER`, no such column), its + // catch propagated the refusal as #8895 rules for a missing column, + // and deleting the organization, business unit or user it names was + // refused on a deployment with a federated object bound. + // `buildDriverOptions` and the related-record read already refuse this + // reading of the tenant column. + // {@link isFederatedUnprovisionedInjectedColumn} reads which columns + // those are from the registry's own provenance, never from a list of + // names, and {@link ObjectQL.planCascadeAtomicity} asks it too. + // + // ⛔ The probe's catch ({@link probeReferencingRows}) is deliberately NOT + // widened to pass a missing column as benign. That would invert #8895's + // discriminate or propagate for every object, not just these injected + // columns: a lookup the author declared on a federated object, including + // an author's own `organization_id` or `owner_id`, stays in the scan, and + // its probe failure still propagates. + if (isFederatedUnprovisionedInjectedColumn(child, fieldName)) return undefined; + + // A master-detail parent owns its children: cascade by default (the + // child FK is typically required, so set_null would be invalid). Only + // an explicit `restrict` deviates. A plain lookup honors its + // configured deleteBehavior (default set_null). + // + // [#9625] "Only an explicit `restrict` deviates" is the whole of it: + // every other value a master_detail can declare — including an + // explicit `deleteBehavior: 'set_null'` — resolves to `cascade` here, + // silently. Measured and pinned (`engine-cascade-delete.test.ts`). + return fdef.type === 'master_detail' + ? (fdef.deleteBehavior === 'restrict' ? 'restrict' : 'cascade') + : (fdef.deleteBehavior || 'set_null'); + } + + /** + * [#12166, ruling constraint 3] File the elevation record for a probe of + * `childName` made while deleting `object`/`id` — once. One record per + * referenced OBJECT, not per relation: two lookup fields on the same child + * pointing at the same parent are one "the platform read object X as + * system" fact, and filing it twice would make the ledger's row count a + * function of the child's field layout. + * + * [#22305] The dedupe is the cascade's, not one call's: the collection walk + * and the deleting walk probe the same cascading relations, and between + * them file one record per deleted record per referenced object, as the + * single walk did. + */ + private fileReferenceCheckElevation( + set: CascadeDeleteSet, + object: string, + id: string | number, + childName: string, + fieldName: string, + context: ExecutionContext | undefined, + ): void { + const key = JSON.stringify([object, String(id), childName]); + if (set.elevationsFiled.has(key)) return; + set.elevationsFiled.add(key); + this.recordReferenceCheckElevation(object, id, childName, fieldName, context); + } + + /** + * The dependents probe of {@link cascadeDeleteRelations}: the rows of + * `childName` whose `fieldName` references `id`, read under SYSTEM identity + * and exactly narrowed. [#22305] Both phases read through it, so the set the + * collection walk gathers and the rows the deleting walk acts on are one + * reading of the same relation. + */ + private async probeReferencingRows( + childName: string, + fieldName: string, + fdef: any, + id: string | number, + probeWhere: Record, + context: ExecutionContext | undefined, + ): Promise { + let dependents: any[]; + try { + dependents = await this.find( + childName, + // [#12166] (maintainer ruling 2026-08-26, option A) SYSTEM identity, + // unconditionally. This probe is the platform's own referential- + // integrity read, not a query the caller asked for, and running it + // as the caller made "delete permission" silently mean "delete + + // read on EVERY referencing table": a caller with full delete rights + // on `object` but no read grant on `childName` got a blanket 403 + // from the security middleware — whether or not a reference existed, + // and with `childName` EMPTY. Measured on a real deployment across + // 17 role×object pairs, with the A/B control that granting read-only + // on the referencing object (touching NOTHING about delete rights) + // turned the identical delete into a 200. + // + // Referential-integrity actions are engine responsibility executed + // under system identity on every mainstream platform — the RDBMS FK + // baseline, Salesforce (lookup clearing / cascade delete documented + // as bypassing sharing), Dataverse, ServiceNow, Odoo. Caller + // identity here was the outlier. + // + // The elevation is `sudo()`-SHAPED (`{ ...context, isSystem: true }`), + // never a bare `{ isSystem: true }` — the same posture + // `recomputeSummaries` holds one axis over. Three reasons, and each + // one is a defect if dropped: + // - the open transaction handle, `tenantId` and `timezone` must + // survive, or this probe leaves the caller's transaction and + // stops being TENANT-scoped — a bare system context would widen + // the probe across the tenant wall, which is the opposite of + // what this card relaxes; + // - `userId` survives, and that IS the audit ledger's + // "triggered-by" half (ruling constraint 3): the record carries + // triggered-by = the deleting operator and executed-as = system. + // `read-audit.ts` states the same property of `sudo()`; + // - it is a NARROW elevation: nothing else about the delete path + // changes identity (ruling constraint 1). The `set_null` UPDATE + // and the `cascade` DELETE below still run as the caller, byte + // for byte, so this relaxes the reference CHECK and not the + // caller's own authority over the dependent rows. + // + // [Constraint 4] If the spec later declares per-relationship + // on-delete behaviour, BEHAVIOUR follows the declaration; the + // identity of this probe stays system, unconditionally. Do not make + // this line conditional on `behavior`. + { where: probeWhere, context: ObjectQL.referenceCheckContext(context) } as any, + ); + } catch (error) { + // [#8895] Discriminate by error TYPE — this probe IS the referential + // guard, so an empty answer is only truthful for the one failure that + // really means "no dependents". + // + // The bare `catch { continue }` this replaces reached `continue` on + // ANY probe failure, and every consequence of that is silent: the + // walk's `restrict` branch never fires, so a delete the integrity + // rules say must be REFUSED is allowed through; `set_null`/`cascade` + // never run, so child rows that should have been nulled or removed + // are left orphaned; nothing is logged and the caller is told the + // delete succeeded. Fail-OPEN on an integrity guard — the read did + // not happen and the answer "there are none" was invented for it + // (ADR-0110 D3: "the probe found nothing" and "the probe could not + // run" are different facts with opposite meanings here). + // + // Benign: the child object is registered but its TABLE was never + // provisioned (schema sync not run yet). It cannot hold a row that + // references anything, so zero dependents IS the truth and skipping + // the relation is correct. Asked through the shared + // `isMissingTableError` predicate (`@objectstack/metadata/errors`, + // #4825) — the same call `seedAutonumber` and `resolveFileReferences` + // make — never a hand-rolled code test. + // + // Everything else (connection drop, timeout, permission denial, a + // query error, a missing COLUMN on a provisioned table) means the + // dependents may well exist and simply were not seen. It propagates: + // the delete fails loudly and nothing is written, which is the same + // disposition the maintainer's 2026-08-15 ruling gives this family — + // unprovisioned is truthful emptiness, everything else must surface. + // A guard that could not be EVALUATED must not silently pass. + // + // No new response field and no new error code: the caller receives + // the probe's own failure, envelope intact. + if (isMissingTableError(error, childName)) return []; + throw error; + } + // [#9362] The multi-value pushdown in the probe filter can be a SUPERSET + // (the substring reading, on a backend without the declaration), so the + // exact answer is taken here, on the rows themselves. Everything the walk + // does with them — the `restrict` count in the 409 envelope, the `cascade` + // recursion, the `set_null` write — and the set the collection walk + // gathers read this answer, so narrowing anywhere later would leave one of + // them acting on a row that never referenced this record. + if (declaredMultiValued(fdef) && dependents) { + dependents = dependents.filter((row) => + ObjectQL.storedReferenceIncludes(row?.[fieldName], id), + ); + } + return dependents ?? []; + } + + /** + * [#22305] Phase 2 of {@link cascadeDeleteRelations}, for one record of + * `cascadeSet`: refuse, clear or cascade each relation that points at it. + * Unchanged from the single walk it was, but for three readings of the set: + * a `restrict` and a `set_null` consider only rows OUTSIDE it, and a row the + * walk has already entered is not cascaded into again. + */ + private async walkReferencingRelations( + object: string, + id: string | number, + context: ExecutionContext | undefined, + objects: ServiceObject[], + cascadeSet: CascadeDeleteSet, + ): Promise { + // Entered before the first nested `delete()` can come back here. + cascadeSetAdd(cascadeSet.entered, object, id); for (const child of objects) { const childName = (child as any)?.name as string | undefined; const fields = (child as any)?.fields as Record | undefined; if (!childName || !fields) continue; for (const [fieldName, fdef] of Object.entries(fields)) { - if (!fdef || (fdef.type !== 'master_detail' && fdef.type !== 'lookup')) continue; - // [#18550] Same arbiter, same absence-vs-unreadability split as - // {@link ObjectQL.planCascadeAtomicity} states above — and this is the - // seam where the silence was measurable end to end: an unreadable - // carrier made the relation invisible to the cascade, so `delete()` - // removed the parent, left a `master_detail` child behind, and - // reported success. No `restrict` refusal, no `set_null`, nothing - // logged. Absence still `continue`s here exactly as before. - const ref = referenceCarrierOf(fdef, 'ObjectQL.cascadeDeleteRelations'); - if (!ref) continue; - // Match the target object by raw or resolved name. - let resolvedRef: string | undefined; - try { resolvedRef = this.resolveObjectName(ref); } catch { resolvedRef = undefined; } - if (ref !== object && resolvedRef !== object) continue; - - // [#21910, #21918] A lookup the registry INJECTED into a federated - // object, and the object does not provision, is not a reference to - // anything, so it is not a relation to probe. That is the tenant - // anchor `organization_id`, the ADR-0117 D1 anchor - // `owning_business_unit_id`, and the owner and audit lookups - // `owner_id` / `created_by` / `updated_by`. On a federated object each - // exists in the registered schema and nowhere else: the probe below - // was refused by the driver (`INVALID_FILTER`, no such column), its - // catch propagated the refusal as #8895 rules for a missing column, - // and deleting the organization, business unit or user it names was - // refused on a deployment with a federated object bound. - // `buildDriverOptions` and the related-record read already refuse this - // reading of the tenant column. - // {@link isFederatedUnprovisionedInjectedColumn} reads which columns - // those are from the registry's own provenance, never from a list of - // names, and {@link ObjectQL.planCascadeAtomicity} asks it too. - // - // ⛔ The catch below is deliberately NOT widened to pass a missing - // column as benign. That would invert #8895's discriminate or - // propagate for every object, not just these injected columns: a - // lookup the author declared on a federated object, including an - // author's own `organization_id` or `owner_id`, stays in the scan, and - // its probe failure still propagates. - if (isFederatedUnprovisionedInjectedColumn(child, fieldName)) continue; - - // A master-detail parent owns its children: cascade by default (the - // child FK is typically required, so set_null would be invalid). Only - // an explicit `restrict` deviates. A plain lookup honors its - // configured deleteBehavior (default set_null). - // - // [#9625] "Only an explicit `restrict` deviates" is the whole of it: - // every other value a master_detail can declare — including an - // explicit `deleteBehavior: 'set_null'` — resolves to `cascade` here, - // silently. Measured and pinned (`engine-cascade-delete.test.ts`). - let behavior: string = - fdef.type === 'master_detail' - ? (fdef.deleteBehavior === 'restrict' ? 'restrict' : 'cascade') - : (fdef.deleteBehavior || 'set_null'); + const resolvedBehavior = this.cascadeRelationBehavior(object, child, fieldName, fdef); + if (resolvedBehavior === undefined) continue; + let behavior: string = resolvedBehavior; // [#9689] (maintainer ruling 2026-08-19, Q3 = B): the judgement the - // #9625 comment above deferred is now taken — `FieldSchema` REJECTS an + // #9625 comment in `cascadeRelationBehavior` deferred is now taken — `FieldSchema` REJECTS an // authored `deleteBehavior: 'set_null'` on a `master_detail` at parse // time, so the value is meaningful again: one that still reaches this // site came in around the parse seam (a raw `registerObject`, or a @@ -16601,7 +16925,7 @@ export class ObjectQL implements IObjectQLEngine { // NOT NULL FK). Authors who want the children gone set // deleteBehavior:'cascade' explicitly. // - // [#9625] The test reads the RESOLVED `behavior`, one statement above, + // [#9625] The test reads the RESOLVED `behavior` (`cascadeRelationBehavior`), // which no longer records how the value got there. So it escalates // BOTH the defaulted set_null and one the author wrote out as // `deleteBehavior: 'set_null'` — the two are indistinguishable here by @@ -16668,108 +16992,10 @@ export class ObjectQL implements IObjectQLEngine { // none, refuses the delete, or fails — and a record written only on the // success path would be missing exactly the runs an auditor goes // looking for. - if (!elevationRecorded.has(childName)) { - elevationRecorded.add(childName); - this.recordReferenceCheckElevation(object, id, childName, fieldName, context); - } + this.fileReferenceCheckElevation(cascadeSet, object, id, childName, fieldName, context); - let dependents: any[]; - try { - dependents = await this.find( - childName, - // [#12166] (maintainer ruling 2026-08-26, option A) SYSTEM identity, - // unconditionally. This probe is the platform's own referential- - // integrity read, not a query the caller asked for, and running it - // as the caller made "delete permission" silently mean "delete + - // read on EVERY referencing table": a caller with full delete rights - // on `object` but no read grant on `childName` got a blanket 403 - // from the security middleware — whether or not a reference existed, - // and with `childName` EMPTY. Measured on a real deployment across - // 17 role×object pairs, with the A/B control that granting read-only - // on the referencing object (touching NOTHING about delete rights) - // turned the identical delete into a 200. - // - // Referential-integrity actions are engine responsibility executed - // under system identity on every mainstream platform — the RDBMS FK - // baseline, Salesforce (lookup clearing / cascade delete documented - // as bypassing sharing), Dataverse, ServiceNow, Odoo. Caller - // identity here was the outlier. - // - // The elevation is `sudo()`-SHAPED (`{ ...context, isSystem: true }`), - // never a bare `{ isSystem: true }` — the same posture - // `recomputeSummaries` holds one axis over. Three reasons, and each - // one is a defect if dropped: - // - the open transaction handle, `tenantId` and `timezone` must - // survive, or this probe leaves the caller's transaction and - // stops being TENANT-scoped — a bare system context would widen - // the probe across the tenant wall, which is the opposite of - // what this card relaxes; - // - `userId` survives, and that IS the audit ledger's - // "triggered-by" half (ruling constraint 3): the record carries - // triggered-by = the deleting operator and executed-as = system. - // `read-audit.ts` states the same property of `sudo()`; - // - it is a NARROW elevation: nothing else about the delete path - // changes identity (ruling constraint 1). The `set_null` UPDATE - // and the `cascade` DELETE below still run as the caller, byte - // for byte, so this relaxes the reference CHECK and not the - // caller's own authority over the dependent rows. - // - // [Constraint 4] If the spec later declares per-relationship - // on-delete behaviour, BEHAVIOUR follows the declaration; the - // identity of this probe stays system, unconditionally. Do not make - // this line conditional on `behavior`. - { where: probeWhere, context: ObjectQL.referenceCheckContext(context) } as any, - ); - } catch (error) { - // [#8895] Discriminate by error TYPE — this probe IS the referential - // guard, so `continue` is only truthful for the one failure that - // really means "no dependents". - // - // The bare `catch { continue }` this replaces reached `continue` on - // ANY probe failure, and every consequence of that is silent: the - // `restrict` branch below never fires, so a delete the integrity - // rules say must be REFUSED is allowed through; `set_null`/`cascade` - // never run, so child rows that should have been nulled or removed - // are left orphaned; nothing is logged and the caller is told the - // delete succeeded. Fail-OPEN on an integrity guard — the read did - // not happen and the answer "there are none" was invented for it - // (ADR-0110 D3: "the probe found nothing" and "the probe could not - // run" are different facts with opposite meanings here). - // - // Benign: the child object is registered but its TABLE was never - // provisioned (schema sync not run yet). It cannot hold a row that - // references anything, so zero dependents IS the truth and skipping - // the relation is correct. Asked through the shared - // `isMissingTableError` predicate (`@objectstack/metadata/errors`, - // #4825) — the same call `seedAutonumber` and `resolveFileReferences` - // make — never a hand-rolled code test. - // - // Everything else (connection drop, timeout, permission denial, a - // query error, a missing COLUMN on a provisioned table) means the - // dependents may well exist and simply were not seen. It propagates: - // the delete fails loudly and nothing is written, which is the same - // disposition the maintainer's 2026-08-15 ruling gives this family — - // unprovisioned is truthful emptiness, everything else must surface. - // A guard that could not be EVALUATED must not silently pass. - // - // No new response field and no new error code: the caller receives - // the probe's own failure, envelope intact. - if (isMissingTableError(error, childName)) continue; - throw error; - } - // [#9362] The multi-value pushdown above can be a SUPERSET (the - // substring reading, on a backend without the declaration), so the - // exact answer is taken here, on the rows themselves. Everything below — - // the `restrict` count in the 409 envelope, the `cascade` recursion, - // the `set_null` write — reads `dependents`, so narrowing anywhere - // later would leave one of them acting on a row that never referenced - // this record. - if (multiValued && dependents) { - dependents = dependents.filter((row) => - ObjectQL.storedReferenceIncludes(row?.[fieldName], id), - ); - } - if (!dependents || dependents.length === 0) continue; + let dependents = await this.probeReferencingRows(childName, fieldName, fdef, id, probeWhere, context); + if (dependents.length === 0) continue; // [#12166] The elevated probe's row IDENTITY, captured here — after the // multi-value narrowing and BEFORE the `requiredSetNull && multiValued` @@ -16781,6 +17007,25 @@ export class ObjectQL implements IObjectQLEngine { // neither. Comparing at the later stage would leak the difference. const probedIds: readonly string[] = dependents.map((r: any) => String(r?.id)); + // [#22305] A row the cascade itself deletes refuses nothing and is + // cleared of nothing: the triage ruling on #22305 is that a child in the + // cascade set never restricts a sibling in the same set. Such a row + // cannot be left dangling, because this same unit of work removes it, + // and clearing its reference would be a write against a row about to + // go. So `restrict` (authored, or the escalation above) and `set_null` + // judge only the rows OUTSIDE the set, and a relation whose rows are + // all inside it is done. `cascade` keeps every row: its rows ARE the + // set, and the walk deletes them below. + // + // After `probedIds`, on purpose: the disclosure check still compares + // the caller's own view against every row the elevated probe read, so a + // count is disclosed only to a caller who can see the whole relation, + // as before; what it counts is the rows that actually refuse. + if (behavior !== 'cascade') { + dependents = dependents.filter((row) => !cascadeSetHas(cascadeSet.members, childName, row?.id)); + if (dependents.length === 0) continue; + } + // [#9688] The deferred half of the required escalation, decided per // ROW now that the rows are read and exactly narrowed — every row // here genuinely holds `id`, so its remainder is its set minus that @@ -16896,6 +17141,12 @@ export class ObjectQL implements IObjectQLEngine { const depId = dep?.id; if (depId == null) continue; if (behavior === 'cascade') { + // [#22305] Entered already: an ancestor of this delete, still in + // progress up the stack (a cycle in the data), or a row an earlier + // branch of this cascade has deleted (a row reached by two paths). + // Either way this cascade removes it once, and re-entering would + // loop forever on the first and answer 404 on the second. + if (cascadeSetHas(cascadeSet.entered, childName, depId)) continue; // Recurse via the public delete so the child's own cascade, // hooks and events fire. await this.delete(childName, { where: { id: depId }, context } as any); From 21fded682c61ab0a9cc24b523afc62bf6e782056 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 21:05:11 +0000 Subject: [PATCH 3/6] fix(objectql): changeset and the split-path note for the cascade set (WIP) Claude-Session: https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2 Co-authored-by: Claude --- .changeset/22305-cascade-sibling-restrict.md | 13 +++++++++++++ packages/objectql/src/engine.ts | 18 +++++++++++------- 2 files changed, 24 insertions(+), 7 deletions(-) create mode 100644 .changeset/22305-cascade-sibling-restrict.md diff --git a/.changeset/22305-cascade-sibling-restrict.md b/.changeset/22305-cascade-sibling-restrict.md new file mode 100644 index 00000000000..e04d9ae1e9c --- /dev/null +++ b/.changeset/22305-cascade-sibling-restrict.md @@ -0,0 +1,13 @@ +--- +'@objectstack/objectql': patch +--- + +Deleting a record no longer fails because of a record that the same delete removes. Before this fix, when a record's delete cascaded to two kinds of children, and one child held a required lookup to the other, the delete could answer `409 DELETE_RESTRICTED` naming the first child. Whether it did depended on which object was registered first. A measured example: an account cascades to its contacts and to its contracts, and each contract has a required lookup to a contact. With the contacts registered first, deleting the account was refused, naming a contract that the same delete was about to remove. With the contracts registered first, the same delete succeeded. + +Clause-②: no + +A by-id cascade delete now collects, before any refusal is judged, every record it will delete across its cascading relations, the root record included. A `restrict` raised by a record in that set against another record in the set is no longer a refusal. That covers an authored `deleteBehavior: 'restrict'` and a required lookup whose `set_null` cannot clear it. A `set_null` writes nothing to a record in the set, so that record's hooks and audit rows show the delete without an earlier update. A refusal from a record outside the set is unchanged. It still answers `409 DELETE_RESTRICTED` and names the dependent object, and the whole delete still rolls back. The refusal now counts only rows outside the set. + +The cascade also no longer re-enters a record it is already deleting. A cycle in the data, such as two rows that cascade to each other, now deletes both rows instead of recursing until the request fails. A row that two cascade paths reach is deleted once, instead of the second path answering `404`. + +The cost is one more read for each cascading relation of each record the cascade deletes: the set is collected by a read-only walk before the existing walk deletes. No key, export or error code changes. diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index 6962c8a0b2b..9b6abd18b81 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -17009,13 +17009,17 @@ export class ObjectQL implements IObjectQLEngine { // [#22305] A row the cascade itself deletes refuses nothing and is // cleared of nothing: the triage ruling on #22305 is that a child in the - // cascade set never restricts a sibling in the same set. Such a row - // cannot be left dangling, because this same unit of work removes it, - // and clearing its reference would be a write against a row about to - // go. So `restrict` (authored, or the escalation above) and `set_null` - // judge only the rows OUTSIDE the set, and a relation whose rows are - // all inside it is done. `cascade` keeps every row: its rows ARE the - // set, and the walk deletes them below. + // cascade set never restricts a sibling in the same set. Such a row is + // not left dangling, because this same unit of work removes it, and + // clearing its reference would be a write against a row about to go. + // (The one cascade that is not one unit of work is + // `planCascadeAtomicity`'s `'split'`: there a later refusal can strand + // such a row, the partial outcome `warnCascadeNotAtomic` already + // declares for every row that path deletes.) So `restrict` (authored, + // or either half of the required escalation) and `set_null` judge only + // the rows OUTSIDE the set, and a relation whose rows are all inside it + // is done. `cascade` keeps every row: its rows ARE the set, and the + // walk deletes them below. // // After `probedIds`, on purpose: the disclosure check still compares // the caller's own view against every row the elevated probe read, so a From 72223c937f3418abfac22d96d69855b0f7920277 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 21:56:10 +0000 Subject: [PATCH 4/6] fix(objectql): scan each object's cascading relations once while collecting the set; document the rule on the DELETE door Claude-Session: https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2 Co-authored-by: Claude --- .changeset/22305-cascade-sibling-restrict.md | 6 +-- content/docs/api/data-api.mdx | 10 +++++ packages/objectql/src/engine.ts | 46 +++++++++++++------- 3 files changed, 44 insertions(+), 18 deletions(-) diff --git a/.changeset/22305-cascade-sibling-restrict.md b/.changeset/22305-cascade-sibling-restrict.md index e04d9ae1e9c..13fac092bd4 100644 --- a/.changeset/22305-cascade-sibling-restrict.md +++ b/.changeset/22305-cascade-sibling-restrict.md @@ -2,12 +2,12 @@ '@objectstack/objectql': patch --- -Deleting a record no longer fails because of a record that the same delete removes. Before this fix, when a record's delete cascaded to two kinds of children, and one child held a required lookup to the other, the delete could answer `409 DELETE_RESTRICTED` naming the first child. Whether it did depended on which object was registered first. A measured example: an account cascades to its contacts and to its contracts, and each contract has a required lookup to a contact. With the contacts registered first, deleting the account was refused, naming a contract that the same delete was about to remove. With the contracts registered first, the same delete succeeded. +Deleting a record no longer fails because of a record that the same delete removes. Before this fix, when a record's delete cascaded to two kinds of children, and one child held a required lookup to the other, the delete could answer `409 DELETE_RESTRICTED` naming the child that holds the lookup. Whether it did depended on which object was registered first. A measured example: an account cascades to its contacts and to its contracts, and each contract has a required lookup to a contact. With the contacts registered first, deleting the account was refused, naming a contract that the same delete was about to remove. With the contracts registered first, the same delete succeeded. Clause-②: no -A by-id cascade delete now collects, before any refusal is judged, every record it will delete across its cascading relations, the root record included. A `restrict` raised by a record in that set against another record in the set is no longer a refusal. That covers an authored `deleteBehavior: 'restrict'` and a required lookup whose `set_null` cannot clear it. A `set_null` writes nothing to a record in the set, so that record's hooks and audit rows show the delete without an earlier update. A refusal from a record outside the set is unchanged. It still answers `409 DELETE_RESTRICTED` and names the dependent object, and the whole delete still rolls back. The refusal now counts only rows outside the set. +A by-id cascade delete now collects, before any refusal is judged, every record it will delete across its cascading relations, the root record included. A `restrict` raised by a record in that set against another record in the set is no longer a refusal. That covers an authored `deleteBehavior: 'restrict'` and a required lookup whose `set_null` cannot clear it. A `set_null` writes nothing to a record in the set, so that record's hooks and audit rows show the delete without an earlier update. A refusal from a record outside the set is unchanged. It still answers `409 DELETE_RESTRICTED` and names the dependent object, and on a single datasource the whole delete still rolls back. The refusal now counts only rows outside the set. -The cascade also no longer re-enters a record it is already deleting. A cycle in the data, such as two rows that cascade to each other, now deletes both rows instead of recursing until the request fails. A row that two cascade paths reach is deleted once, instead of the second path answering `404`. +The cascade also no longer re-enters a record it is already deleting. A cycle in the data, such as two rows that cascade to each other, now deletes both rows. Before, the walk re-entered the same rows without end. A row that two cascade paths reach is deleted once, instead of the second path answering `404`. The cost is one more read for each cascading relation of each record the cascade deletes: the set is collected by a read-only walk before the existing walk deletes. No key, export or error code changes. diff --git a/content/docs/api/data-api.mdx b/content/docs/api/data-api.mdx index 45316d60432..4371fe89f57 100644 --- a/content/docs/api/data-api.mdx +++ b/content/docs/api/data-api.mdx @@ -257,6 +257,16 @@ emptied that way reads back as `[]` — never `null`, so a client that branches `packages/spec/src/data/field.zod.ts`, rendered in the [Field reference](/docs/references/data/field)). +A record that the same delete removes never blocks it. Before any refusal is +judged, the delete collects every record its cascade reaches, the deleted +record included. A `restrict` held by a record in that set is not a refusal, +and neither is the `restrict` that a required `set_null` becomes. A `set_null` +writes nothing to a record in that set. So when an account cascades to its +contacts and its contracts, and a contract's required lookup points at a +contact, deleting the account removes all three, whichever object was +registered first. A `restrict` from any record outside the set still refuses +with `409 DELETE_RESTRICTED`, and `dependentCount` counts only those records. + --- ## Batch Operations diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index 9b6abd18b81..bd05e02c865 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -16621,25 +16621,41 @@ export class ObjectQL implements IObjectQLEngine { ): Promise { const set: CascadeDeleteSet = { members: new Map(), entered: new Map(), elevationsFiled: new Set() }; cascadeSetAdd(set.members, object, id); + // The cascading relations pointing at each object, scanned once per object + // rather than once per record: a wide cascade has many records of few + // objects. + const cascading = new Map>(); + const cascadingInto = (target: string) => { + let relations = cascading.get(target); + if (!relations) { + relations = []; + for (const child of objects) { + const childName = (child as any)?.name as string | undefined; + const fields = (child as any)?.fields as Record | undefined; + if (!childName || !fields) continue; + for (const [fieldName, fdef] of Object.entries(fields)) { + if (this.cascadeRelationBehavior(target, child, fieldName, fdef) === 'cascade') { + relations.push({ childName, fieldName, fdef }); + } + } + } + cascading.set(target, relations); + } + return relations; + }; // A queue the loop appends to while it iterates: an array iterator reads // `length` on every step, so each record pushed below is visited in turn. const queue: Array<{ object: string; id: string | number }> = [{ object, id }]; for (const target of queue) { - for (const child of objects) { - const childName = (child as any)?.name as string | undefined; - const fields = (child as any)?.fields as Record | undefined; - if (!childName || !fields) continue; - for (const [fieldName, fdef] of Object.entries(fields)) { - if (this.cascadeRelationBehavior(target.object, child, fieldName, fdef) !== 'cascade') continue; - this.fileReferenceCheckElevation(set, target.object, target.id, childName, fieldName, context); - const rows = await this.probeReferencingRows( - childName, fieldName, fdef, target.id, this.referenceProbeFilter(fieldName, fdef, target.id), context, - ); - for (const row of rows) { - const depId = row?.id; - if (depId != null && cascadeSetAdd(set.members, childName, depId)) { - queue.push({ object: childName, id: depId }); - } + for (const { childName, fieldName, fdef } of cascadingInto(target.object)) { + this.fileReferenceCheckElevation(set, target.object, target.id, childName, fieldName, context); + const rows = await this.probeReferencingRows( + childName, fieldName, fdef, target.id, this.referenceProbeFilter(fieldName, fdef, target.id), context, + ); + for (const row of rows) { + const depId = row?.id; + if (depId != null && cascadeSetAdd(set.members, childName, depId)) { + queue.push({ object: childName, id: depId }); } } } From 969c4073b30f5bcb24090649fdb6287f8580e4eb Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 23:02:44 +0000 Subject: [PATCH 5/6] test(objectql): the federated-reader ledger names the relation reading the cascade's two phases share Claude-Session: https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2 Co-authored-by: Claude --- .../src/federated-injected-column-readers.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/objectql/src/federated-injected-column-readers.test.ts b/packages/objectql/src/federated-injected-column-readers.test.ts index beae374ca8c..b50590dec3a 100644 --- a/packages/objectql/src/federated-injected-column-readers.test.ts +++ b/packages/objectql/src/federated-injected-column-readers.test.ts @@ -127,11 +127,11 @@ const VALIDATION_RULE = "a validation rule's own relation path, which the author const READERS: Record = { // ── The readers this family fixed, and the one decision they ask ───────── - 'engine.ts#cascadeDeleteRelations :: isFederatedUnprovisionedInjectedColumn()': { + 'engine.ts#cascadeRelationBehavior :: isFederatedUnprovisionedInjectedColumn()': { disposition: 'skips', - why: 'the dependents probe never filters a federated object on a column it does not provision', + why: 'the dependents probe never filters a federated object on a column it does not provision (both phases of the cascade read relations here)', }, - 'engine.ts#cascadeDeleteRelations :: referenceCarrierOf()': { + 'engine.ts#cascadeRelationBehavior :: referenceCarrierOf()': { disposition: 'skips', why: 'finds the relations a delete probes; the skip above follows the reference match', }, @@ -550,7 +550,7 @@ describe('[#21918] every engine reader of an injected column has a disposition t .map(([key]) => siteKeyOf(key)), ); expect([...skipping].sort()).toEqual([ - 'engine.ts#cascadeDeleteRelations', + 'engine.ts#cascadeRelationBehavior', 'engine.ts#planCascadeAtomicity', 'lifecycle/lifecycle-service.ts#tenantWindowsFor', ]); From faa4ac206aac62e12cb5259b9d7dc4c95d31d386 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 23:37:53 +0000 Subject: [PATCH 6/6] test(objectql): the sibling-restrict pins' driver double applies the caller's limit Claude-Session: https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2 Co-authored-by: Claude --- .../src/engine-cascade-delete-sibling-restrict.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/objectql/src/engine-cascade-delete-sibling-restrict.test.ts b/packages/objectql/src/engine-cascade-delete-sibling-restrict.test.ts index 18f72bbcfc3..43710f3fe2c 100644 --- a/packages/objectql/src/engine-cascade-delete-sibling-restrict.test.ts +++ b/packages/objectql/src/engine-cascade-delete-sibling-restrict.test.ts @@ -77,8 +77,9 @@ function makeDriver() { rowsOf: (o: string): Row[] => Array.from(storeFor(o).values()), async connect() {}, async disconnect() {}, async checkHealth() { return true; }, async execute() { return null; }, async syncSchema() {}, - async find(o: string, ast: { where?: unknown } | undefined) { - return Array.from(storeFor(o).values()).filter((r) => matches(r, ast?.where)); + async find(o: string, ast: { where?: unknown; limit?: unknown } | undefined) { + const rows = Array.from(storeFor(o).values()).filter((r) => matches(r, ast?.where)); + return typeof ast?.limit === 'number' ? rows.slice(0, ast.limit) : rows; }, async findOne(o: string, ast: { where?: unknown } | undefined) { for (const r of storeFor(o).values()) if (matches(r, ast?.where)) return r;