From a4a37dbfde9759e9cd2ed390d2d5b6e5a20aa776 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 19 Aug 2026 11:36:30 +0000 Subject: [PATCH 1/2] fix(objectql): judge the required multi-value cascade escalation per row Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019yDEhPBC3tcGkW9bkce1HM --- .../src/engine-cascade-delete.test.ts | 201 +++++++++++++++--- packages/objectql/src/engine.ts | 134 +++++++++--- 2 files changed, 276 insertions(+), 59 deletions(-) diff --git a/packages/objectql/src/engine-cascade-delete.test.ts b/packages/objectql/src/engine-cascade-delete.test.ts index 9843c32b8d..eb17aa08f1 100644 --- a/packages/objectql/src/engine-cascade-delete.test.ts +++ b/packages/objectql/src/engine-cascade-delete.test.ts @@ -25,15 +25,33 @@ * a defaulted `set_null` (escalates) and an explicit `cascade` (honored) and * nothing between them, so the docs sentence claiming an explicit `set_null` is * "always honored as written" contradicted the engine with every gate green. - * Two more shapes are pinned alongside it for the same reason — a required - * `multiple: true` lookup is refused even when member removal would leave the - * set non-empty, and a `master_detail` declaring an explicit `set_null` is - * silently resolved to `cascade`. + * Two more shapes are pinned alongside it for the same reason — the required + * `multiple: true` case (see below) and a `master_detail` declaring an explicit + * `set_null`, which is silently resolved to `cascade`. * - * These pin CURRENT behaviour. Whether the multi-value refusal should judge - * emptiness instead of presence, and whether the spec should reject `set_null` - * on a `master_detail` at publish time rather than dropping it at delete time, - * are open questions carded separately — not decided by this suite. + * Whether the spec should reject `set_null` on a `master_detail` at publish + * time rather than dropping it at delete time is still an open question, + * carded separately — not decided by this suite. + * + * ## [#9688] The multi-value refusal now judges EMPTINESS, per row + * + * #9625 pinned the required `multiple: true` lookup as refused whenever any + * row referenced the parent, even when member removal would have left that + * row's set non-empty. That pin is UPDATED here rather than kept: the + * maintainer ruling on #9688 (2026-08-19) narrowed the escalation to the rows + * it is actually about. The escalation exists because a cleared required FK + * trips the child's validator — and on a `multiple: true` field the `set_null` + * limb removes the deleted MEMBER (#9438), so that only happens when the + * removal EMPTIES the set: `[]` violates `required` under the #9447 ruling and + * is rejected by the record validator since #9476. + * + * So both sides are pinned below, and the second is the one that makes the + * first safe: + * - remainder non-empty → the member is removed and the parent delete goes + * through (what the #9625 pin used to forbid), + * - remainder EMPTY (the deleted member was the last) → `DELETE_RESTRICTED` + * stands, and `dependentCount` counts ONLY the rows that would be emptied. + * An authored `deleteBehavior: 'restrict'` is untouched by any of it. */ import { describe, it, expect, beforeEach } from 'vitest'; @@ -93,9 +111,10 @@ const quoteExplicitSetNull = { }, }, }; -// [#9625] Required + `multiple: true`: the escalation runs BEFORE the -// member-removal branch and keys on `required` alone, so the refusal lands -// even when removal would leave the set non-empty. +// [#9625/#9688] Required + `multiple: true`. #9625 pinned this shape refused +// whenever anything referenced the parent; #9688 narrowed the escalation to +// the rows member removal would EMPTY, so this fixture now carries both +// outcomes depending on how many members the row holds. const rosterRequiredMulti = { name: 'roster', label: 'Roster', @@ -107,6 +126,34 @@ const rosterRequiredMulti = { }, }, }; +// [#9688] The same shape with the `set_null` DEFAULTED rather than written +// out. The escalation reads the RESOLVED behavior (#9625), so the per-row +// judgement has to reach this spelling identically. +const squadRequiredMultiDefault = { + name: 'squad', + label: 'Squad', + fields: { + id: { name: 'id', type: 'text' as const, primaryKey: true }, + accounts: { + name: 'accounts', type: 'lookup' as const, reference: 'acct', + required: true, multiple: true, + }, + }, +}; +// [#9688] Required + `multiple: true` + an AUTHORED `restrict`. The control +// that keeps the narrowing inside the escalation: an authored refusal is not +// an escalated one and is never judged on emptiness. +const vaultRequiredMultiRestrict = { + name: 'vault', + label: 'Vault', + fields: { + id: { name: 'id', type: 'text' as const, primaryKey: true }, + accounts: { + name: 'accounts', type: 'lookup' as const, reference: 'acct', + required: true, multiple: true, deleteBehavior: 'restrict', + }, + }, +}; // [#9625] The control for the pair above — same shape, `required` dropped. // Without it, a suite that only asserted the refusal could not tell // "refused because required" from "refused because multi-value". @@ -200,7 +247,8 @@ describe('cascadeDeleteRelations — required FK escalates set_null → restrict await engine.init(); for (const o of [ acct, oppRequired, noteOptional, taskCascade, - quoteExplicitSetNull, rosterRequiredMulti, watchlistOptionalMulti, lineExplicitSetNull, + quoteExplicitSetNull, rosterRequiredMulti, squadRequiredMultiDefault, + vaultRequiredMultiRestrict, watchlistOptionalMulti, lineExplicitSetNull, ]) engine.registry.registerObject(o); }); @@ -279,29 +327,128 @@ describe('cascadeDeleteRelations — required FK escalates set_null → restrict expect((await engine.findOne('quote', { where: { id: q.id } }) as any).account).toBe(a.id); }); - it('[#9625] refuses a required MULTI-VALUE lookup even when member removal would leave the set non-empty', async () => { - // The escalation runs before the multi-value branch and keys on - // `required` alone, so the other live member does not save the delete. - // Pinned as CURRENT behaviour, deliberately not changed here: the - // refusal lands before the member-removal write runs, so what reaches - // the caller is `DELETE_RESTRICTED` about the `acct` it asked to - // delete, not a `required` error naming a field on `roster`. This - // comment used to add that `[]` still satisfied `required` in the - // record validator, making the refusal the only thing stopping an - // emptied required set from landing silently — #9476 has landed and - // `[]` is rejected there now, so that clause is gone. The assertions - // below never rested on it: they pin the 409 envelope and the - // untouched set. + it('[#9625→#9688] a required MULTI-VALUE lookup now REMOVES the member when the remainder stays non-empty', async () => { + // ⚠️ This assertion is the inverse of what #9625 pinned here, changed + // deliberately under the #9688 ruling (2026-08-19) — not repaired to + // green. #9625 measured the refusal and pinned it so that changing it + // would have to be a decision; this is that decision landing. + // + // Why the refusal was too broad: the escalation exists because + // clearing a required FK trips the child's validator with a + // " is required" 400. On a `multiple: true` field the + // `set_null` limb does not clear the slot — since #9438 it removes the + // deleted MEMBER — so with `beta` still in the set the write is + // `['beta']`, a NON-EMPTY required set that no validator objects to. + // The delete was refused citing a failure that could not happen. + // + // The last-member case, where the write really would be `[]`, keeps + // the refusal — pinned in the very next test, which is what makes this + // narrowing safe rather than a hole. const a = await engine.insert('acct', { name: 'Acme' }); const b = await engine.insert('acct', { name: 'Beta' }); const r = await engine.insert('roster', { accounts: [a.id, b.id] }); + await engine.delete('acct', { where: { id: a.id } } as any); + + expect(await engine.findOne('acct', { where: { id: a.id } })).toBeNull(); + // The member is gone, the sibling reference survives — #9438 semantics, + // reached now that the escalation no longer pre-empts them. + expect((await engine.findOne('roster', { where: { id: r.id } }) as any).accounts).toEqual([b.id]); + }); + + it('[#9688] still REFUSES when the deleted member is the LAST one — the write would be `[]`', async () => { + // ⭐ The pin that makes the narrowing above safe. `[]` on a required + // multi-value field is empty under the #9447 ruling (2026-08-18) and + // is rejected by the record validator since #9476 — so this row's + // member removal has nowhere legal to land, and the escalation is + // still exactly right for it. + // + // ADR-0112 envelope — `code` AND `status`, never a bare toThrow(): an + // engine that narrowed this case too would fail here by throwing the + // child validator's own `required` 400 (a different code and status, + // naming a field that is not on `acct` at all), which a bare + // toThrow() would happily accept. + const a = await engine.insert('acct', { name: 'Acme' }); + const r = await engine.insert('roster', { accounts: [a.id] }); + const err = await engine.delete('acct', { where: { id: a.id } } as any).catch((e) => e); expect(err).toMatchObject({ code: 'DELETE_RESTRICTED', status: 409, dependentObject: 'roster', dependentCount: 1, }); - // The set is untouched — no member removal ran. - expect((await engine.findOne('roster', { where: { id: r.id } }) as any).accounts).toEqual([a.id, b.id]); + // Attributed to `required`, the same sentence the single-valued + // escalation produces — the refusal did not change its story, only its + // reach. + expect(err.developerMessage).toContain('accounts is required, so it cannot be cleared'); + // Nothing moved: no member removal ran and the parent survives. + expect((await engine.findOne('roster', { where: { id: r.id } }) as any).accounts).toEqual([a.id]); + expect(await engine.findOne('acct', { where: { id: a.id } })).toBeTruthy(); + }); + + it('[#9688] `dependentCount` counts ONLY the rows that would be emptied, and the whole delete is refused', async () => { + // Two referencing rows, one of each kind. The delete is refused — + // a delete either happens or it does not — but the count reports the + // rows it is refused OVER. Counting the removable row too would name a + // row this delete no longer objects to, which the card called out as + // its own small defect. + const a = await engine.insert('acct', { name: 'Acme' }); + const b = await engine.insert('acct', { name: 'Beta' }); + const keeps = await engine.insert('roster', { accounts: [a.id, b.id] }); // remainder ['beta'] + const emptied = await engine.insert('roster', { accounts: [a.id] }); // remainder [] + + const err = await engine.delete('acct', { where: { id: a.id } } as any).catch((e) => e); + expect(err).toMatchObject({ + code: 'DELETE_RESTRICTED', status: 409, dependentObject: 'roster', dependentCount: 1, + }); + // 1, not 2 — the localized copy counts the same rows the structured + // field does, so the operator and the developer are told one number. + expect(err.message).toContain('1 Roster'); + // And nothing was written: the removable row keeps BOTH members, + // because the refusal lands before any member-removal write runs. + expect((await engine.findOne('roster', { where: { id: keeps.id } }) as any).accounts).toEqual([a.id, b.id]); + expect((await engine.findOne('roster', { where: { id: emptied.id } }) as any).accounts).toEqual([a.id]); + expect(await engine.findOne('acct', { where: { id: a.id } })).toBeTruthy(); + }); + + it('[#9688] a DEFAULTED set_null on a required multi-value lookup is judged the same way, in both directions', async () => { + // The escalation reads the RESOLVED behavior (#9625), so the defaulted + // spelling must land on exactly the same per-row judgement as the + // explicit one. Both directions on ONE row: removing a member while + // another remains succeeds, and then removing that last member is + // refused. + const a = await engine.insert('acct', { name: 'Acme' }); + const b = await engine.insert('acct', { name: 'Beta' }); + const s = await engine.insert('squad', { accounts: [a.id, b.id] }); + + await engine.delete('acct', { where: { id: a.id } } as any); + expect((await engine.findOne('squad', { where: { id: s.id } }) as any).accounts).toEqual([b.id]); + + // `b` is now the last member — the same field, the same row. + const err = await engine.delete('acct', { where: { id: b.id } } as any).catch((e) => e); + expect(err).toMatchObject({ + code: 'DELETE_RESTRICTED', status: 409, dependentObject: 'squad', dependentCount: 1, + }); + expect((await engine.findOne('squad', { where: { id: s.id } }) as any).accounts).toEqual([b.id]); + expect(await engine.findOne('acct', { where: { id: b.id } })).toBeTruthy(); + }); + + it('[#9688] an AUTHORED restrict on a required multi-value lookup is not narrowed at all', async () => { + // The narrowing is scoped to the ESCALATION — a `set_null` that + // `required` turned into a refusal. A `deleteBehavior: 'restrict'` the + // author wrote means "refuse while anything references me", and + // emptiness has nothing to do with it. Without this control an + // implementation that judged emptiness for every multi-value field + // would sit green while quietly overriding an authored refusal. + const a = await engine.insert('acct', { name: 'Acme' }); + const b = await engine.insert('acct', { name: 'Beta' }); + const v = await engine.insert('vault', { accounts: [a.id, b.id] }); + + const err = await engine.delete('acct', { where: { id: a.id } } as any).catch((e) => e); + expect(err).toMatchObject({ + code: 'DELETE_RESTRICTED', status: 409, dependentObject: 'vault', dependentCount: 1, + }); + // Authored `restrict`, so the refusal is NOT attributed to `required`. + expect(err.developerMessage).not.toContain('is required, so it cannot be cleared'); + expect((await engine.findOne('vault', { where: { id: v.id } }) as any).accounts).toEqual([a.id, b.id]); expect(await engine.findOne('acct', { where: { id: a.id } })).toBeTruthy(); }); diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index dc26e1e1c9..8c93f9d62a 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -10391,6 +10391,30 @@ export class ObjectQL implements IObjectQLEngine { return false; } + /** + * [#9688] What a `multiple: true` reference slot holds once the deleted + * record's membership is removed — the ONE computation behind both the + * per-row `required` judgement in {@link ObjectQL.cascadeDeleteRelations} + * and the `set_null` write that judgement gates. + * + * One function rather than two readings that happen to match, because the + * judgement decides whether the write may run at all: a predicate computing + * the remainder differently could clear a delete and then have the write + * land the empty required set the judgement exists to refuse — or refuse a + * delete whose write would have produced a perfectly legal non-empty set. + * + * `String(v) !== String(id)` is the same reading + * {@link ObjectQL.storedReferenceIncludes} applies when narrowing + * `dependents`, so the member removed is exactly the member that made the + * row a dependent. A non-array value is normalized to the array spelling + * rather than dismissed, for the same reason the narrowing compares it: an + * off-shape bare scalar in a `multiple: true` slot is still a reference. + */ + private static remainderAfterMemberRemoval(stored: unknown, id: string | number): unknown[] { + const current: unknown[] = Array.isArray(stored) ? stored : [stored]; + return current.filter((v) => String(v) !== String(id)); + } + /** * Apply referential delete behavior for relations pointing AT this record, * before it is removed. For every registered object with a `master_detail` @@ -10408,8 +10432,12 @@ export class ObjectQL implements IObjectQLEngine { * lifecycle); `lookup` defaults to `set_null` — except a `set_null` default * on a REQUIRED lookup escalates to `restrict` (you can't null a NOT NULL * FK; restricting with a clear dependent-count message beats a misleading - * " is required" 400 from the child). Only runs for single-id - * deletes — multi/predicate deletes skip cascade (logged). + * " is required" 400 from the child) — and on a `multiple: true` + * required lookup that escalation is decided per ROW after the dependents + * probe (#9688): a row that keeps another member takes the member removal, + * 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). */ private async cascadeDeleteRelations( object: string, @@ -10490,30 +10518,43 @@ export class ObjectQL implements IObjectQLEngine { // escalation is a property of the RESOLVED behavior plus `required`, // not of what the author typed. // - // It also runs BEFORE the `multiValued` branch below and keys on - // `required` alone, so a `multiple: true` required lookup is refused - // even when the child's set holds other members and member removal - // would leave it non-empty — a state the #9447 ruling accepts. - // Measured, pinned as current behaviour, and carded separately rather - // than changed here. What justifies refusing is the paragraph above, - // not the validator's tolerance: the escalation refuses THIS relation - // before its own set_null write runs, so the caller is told + // [#9688] The rationale above is also what BOUNDS the escalation, and + // on a `multiple: true` field it does not reach every row. The + // set_null limb there does not clear the slot: since #9438 it removes + // the deleted MEMBER and writes the remainder. So "a cleared required + // FK trips the child's validator" is true of exactly one case — the + // row whose set the removal would EMPTY, since `[]` is what the #9447 + // ruling (maintainer, 2026-08-18) says violates `required` on a + // multi-value field, and what the record validator has rejected since + // #9476. A row that still holds another live member is written a + // NON-EMPTY set, which `required` accepts and no validator objects + // to; refusing its parent's delete was broader than the contract, and + // refused it citing a failure that could not have happened. + // + // Emptiness is a property of a ROW, not of the field — and no row has + // been read at this point. So the multi-value half of the escalation + // is DEFERRED to just after the dependents probe (search + // `requiredSetNull` below), where the rows are known and exactly + // narrowed. The single-valued half stays here and is unchanged: + // clearing a scalar FK always writes `null`, so its premise needs no + // row to hold. + // + // The deferral changes WHEN the decision is made, never who it is + // reported to: a row that does keep the refusal still refuses THIS + // relation before its own set_null write runs, so the caller is told // `DELETE_RESTRICTED` about the record it asked to delete, instead of - // the child's own `required` 400 — which names a field that is not on - // that record's object at all. The predecessor of this comment rested - // it on `[]` still satisfying `required` in the record validator, - // which made this refusal the only thing between an emptied required - // set and a silent write; #9476 landed and `[]` is rejected there now - // too, so the refusal is no longer that last guard. It is the one that - // fires early, against the right record. - if (behavior === 'set_null' && fdef.required === true) { + // the child's own `required` 400 naming a field that is not on that + // record's object at all. + // + // [#9362] `multiValued` is declared here rather than at the probe + // because the probe's filter spelling, the set_null write below and + // — since #9688 — this escalation all turn on it. + const multiValued = fdef.multiple === true; + const requiredSetNull = behavior === 'set_null' && fdef.required === true; + if (requiredSetNull && !multiValued) { behavior = 'restrict'; } - // [#9362] Declared here rather than at the probe because BOTH the - // set_null write below and the probe's filter spelling turn on it. - const multiValued = fdef.multiple === true; - let dependents: any[]; try { dependents = await this.find( @@ -10570,6 +10611,36 @@ export class ObjectQL implements IObjectQLEngine { } if (!dependents || 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 + // one member. + // + // The emptiness question is asked through + // `remainderAfterMemberRemoval`, which is the SAME computation the + // set_null write below performs. One function, two call sites, so the + // judgement cannot predict a shape the write would not produce: a + // predicate that computed the remainder even slightly differently + // could clear the delete and then let the write land the very `[]` + // this judgement exists to prevent. + // + // ANY row that would be emptied refuses the WHOLE delete — a delete + // either happens or it does not, and a partial cascade is not on + // offer. `dependents` is reduced to those rows first, because every + // number the refusal reports reads it: `dependentCount`, the + // localized message's `count`, and the developerMessage. Counting the + // rows this delete no longer refuses over is the second defect the + // card names, and it is fixed by the same statement. + if (requiredSetNull && multiValued) { + const emptied = dependents.filter( + (row) => ObjectQL.remainderAfterMemberRemoval(row?.[fieldName], id).length === 0, + ); + if (emptied.length > 0) { + behavior = 'restrict'; + dependents = emptied; + } + } + if (behavior === 'restrict') { // [#7307] TWO messages, two audiences — because this error has two // and they were sharing one string. @@ -10657,16 +10728,15 @@ export class ObjectQL implements IObjectQLEngine { // written as `[]`, which `Array.prototype.filter` already // yields, never `null`. // - // `String(v) !== String(id)` is the same reading - // `storedReferenceIncludes` applies when narrowing `dependents` - // above, so the member removed here is exactly the member that - // made this row a dependent. An off-shape bare scalar in a - // `multiple: true` slot is normalized to the array spelling by - // this write, for the same reason the narrowing compares it - // rather than dismissing it. - const stored = dep?.[fieldName]; - const current: unknown[] = Array.isArray(stored) ? stored : [stored]; - const next = current.filter((v) => String(v) !== String(id)); + // [#9688] The remainder is computed by + // `remainderAfterMemberRemoval`, which the per-row required + // judgement above calls on the same row: this write is only + // reached for a row that judgement measured as keeping at least + // one other member, and the two must agree by construction. + // The reading it applies — `String(v) !== String(id)`, and an + // off-shape bare scalar normalized to the array spelling — is + // documented there. + const next = ObjectQL.remainderAfterMemberRemoval(dep?.[fieldName], id); await this.update(childName, { id: depId, [fieldName]: next }, { context: referentialCtx } as any); } else { await this.update(childName, { id: depId, [fieldName]: null }, { context: referentialCtx } as any); From bb1cbf65bbddd8e53655d95c98f4a0d317555245 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 19 Aug 2026 11:49:08 +0000 Subject: [PATCH 2/2] docs(engine): the required multi-value cascade refusal is judged per row Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019yDEhPBC3tcGkW9bkce1HM --- .../cascade-required-multivalue-per-row.md | 45 +++++++++++++++++++ content/docs/api/data-api.mdx | 4 ++ content/docs/data-modeling/field-types.mdx | 2 +- content/docs/deployment/troubleshooting.mdx | 2 +- content/docs/protocol/objectql/types.mdx | 15 +++++-- 5 files changed, 62 insertions(+), 6 deletions(-) create mode 100644 .changeset/cascade-required-multivalue-per-row.md diff --git a/.changeset/cascade-required-multivalue-per-row.md b/.changeset/cascade-required-multivalue-per-row.md new file mode 100644 index 0000000000..cd792386ff --- /dev/null +++ b/.changeset/cascade-required-multivalue-per-row.md @@ -0,0 +1,45 @@ +--- +"@objectstack/objectql": patch +--- + +fix(engine): the required-FK escalation on a `multiple: true` lookup is judged per ROW — a parent delete is refused only over the rows member removal would EMPTY (#9688) + +`cascadeDeleteRelations` escalated `set_null` → `restrict` on `fdef.required === true` +before the multi-value branch and before the dependents probe had run, so a delete was +refused for every row that referenced the record, whatever else that row's set held. +Measured with a real engine + stub driver: a child holding `accounts: [acct_a, acct_b]` +on a `required: true, multiple: true` lookup refused `DELETE acct_a` with +`DELETE_RESTRICTED` / 409 / `dependentCount: 1`, leaving the set untouched. + +**The escalation's own rationale is what bounds it.** It exists because clearing a +required foreign key issues an UPDATE the child's validator rejects with a misleading +`" is required"` 400. On a `multiple: true` field the `set_null` limb does not +clear the slot — since #9438 it removes the deleted MEMBER and writes the remainder — so +that failure is only reachable for a row the removal would EMPTY. Removing `acct_a` +above writes `[acct_b]`, a non-empty required set no validator objects to; the delete was +refused citing a failure that could not have happened. + +**Now decided per row, after the dependents probe and the exact multi-value narrowing:** + +- remainder non-empty → the member is removed and the delete proceeds (#9438 semantics, + which the #9447 ruling accepts); +- remainder empty (the deleted member was the last) → `DELETE_RESTRICTED` stands, because + `[]` violates `required` on a multi-value field under #9447 and is rejected by the + record validator since #9476; +- when both kinds of row reference the record the whole delete is refused, and + `dependentCount` now counts **only the rows that would be emptied** — previously it + counted every referencing row, naming rows the delete no longer objects to. + +The judgement and the write share one function (`remainderAfterMemberRemoval`), so the +predicate that clears the write can never predict a shape the write would not produce. + +**Unchanged:** single-valued `set_null` on a required lookup still escalates (clearing a +scalar FK always writes `null`), an authored `deleteBehavior: 'restrict'` still refuses +regardless of emptiness, `cascade` is untouched, and a non-required multi-value lookup +keeps removing the member as before. + +The #9625 fixture pinning the previous, broader refusal is updated deliberately rather +than repaired — that is what it was pinned for — and the last-member refusal is pinned +beside it, since that pin is what makes the narrowing safe. Also pinned: the defaulted +`set_null` spelling reaches the same per-row judgement as the explicit one, an authored +`restrict` is not narrowed, and `dependentCount` reports the refused rows only. diff --git a/content/docs/api/data-api.mdx b/content/docs/api/data-api.mdx index e5fb6d80ae..cc6698d0ed 100644 --- a/content/docs/api/data-api.mdx +++ b/content/docs/api/data-api.mdx @@ -228,6 +228,10 @@ Every relation pointing at the deleted record honours its own `deleteBehavior` refused with `409 DELETE_RESTRICTED`. That happens whether the `set_null` was defaulted or written out explicitly (see [Required foreign keys](/docs/protocol/objectql/types#lookup)). On a +`multiple: true` required lookup the substitution is judged per referencing +ROW: the delete is refused only over rows whose set the member removal would +empty, and `dependentCount` counts just those rows — a row that keeps another +member is updated and does not block the delete. On a `multiple: true` reference where `set_null` does run, it removes just the deleted id from the array and keeps the rest, and a reference set emptied that way reads back as `[]` — never `null`, so a client that branches on diff --git a/content/docs/data-modeling/field-types.mdx b/content/docs/data-modeling/field-types.mdx index 9c61cbfa88..a3da1c1714 100644 --- a/content/docs/data-modeling/field-types.mdx +++ b/content/docs/data-modeling/field-types.mdx @@ -315,7 +315,7 @@ Reference to a record in another object (foreign key). |:---|:---|:---|:---| | `reference` | `string` | **required** | Target object name (snake_case) | | `referenceFilters` | `string[]` | — | **Removed** (#2377, ADR-0049) — no longer a recognized field property (unknown keys are stripped by the schema). Use structured `lookupFilters` + `dependsOn` instead; see [Relationships](/docs/data-modeling/relationships) | -| `deleteBehavior` | `'restrict' \| 'cascade' \| 'set_null'` | `'set_null'` | Behavior when referenced record is deleted. On a *required* lookup `set_null` is escalated to `restrict`, since a NOT NULL foreign key cannot be cleared — **whether the `set_null` was defaulted or written out explicitly**, and on a `multiple: true` required lookup too. `cascade` and `restrict` are the values honored as written. Where `set_null` does run, a `multiple: true` lookup loses only the deleted **member** — the other members are kept, and a set emptied that way is stored as `[]`, never `null` | +| `deleteBehavior` | `'restrict' \| 'cascade' \| 'set_null'` | `'set_null'` | Behavior when referenced record is deleted. On a *required* lookup `set_null` is escalated to `restrict`, since a NOT NULL foreign key cannot be cleared — **whether the `set_null` was defaulted or written out explicitly**. On a `multiple: true` required lookup the escalation is judged per referencing row: only a row the member removal would leave EMPTY is refused. `cascade` and `restrict` are the values honored as written. Where `set_null` does run, a `multiple: true` lookup loses only the deleted **member** — the other members are kept, and a set emptied that way is stored as `[]`, never `null` | ```typescript { name: 'company', label: 'Company', type: 'lookup', reference: 'account' } diff --git a/content/docs/deployment/troubleshooting.mdx b/content/docs/deployment/troubleshooting.mdx index f8f0871452..7bf626f44e 100644 --- a/content/docs/deployment/troubleshooting.mdx +++ b/content/docs/deployment/troubleshooting.mdx @@ -207,7 +207,7 @@ client.data.find('project_task', { /* query */ }); **Cause:** The record has dependent child records via a `lookup` or `master_detail` field, and that field resolves to `restrict`. Two routes get there: 1. The field declares `deleteBehavior: 'restrict'`. -2. The field is a `required: true` lookup whose behavior is `set_null`. A required foreign key cannot be cleared, so `set_null` is escalated to `restrict` — including when `set_null` is written out explicitly, and including a `required: true` lookup with `multiple: true`. Check the refusal's `developerMessage`: the escalated route says `( is required, so it cannot be cleared)`. +2. The field is a `required: true` lookup whose behavior is `set_null`. A required foreign key cannot be cleared, so `set_null` is escalated to `restrict` — including when `set_null` is written out explicitly. On a `multiple: true` required lookup the escalation is judged per referencing row, because `set_null` removes only the deleted member there: rows that keep another member are updated, and the delete is refused only over the rows the removal would leave EMPTY (`dependentCount` counts just those). Check the refusal's `developerMessage`: the escalated route says `( is required, so it cannot be cleared)`. **Fix:** 1. Delete or reassign the dependent records first diff --git a/content/docs/protocol/objectql/types.mdx b/content/docs/protocol/objectql/types.mdx index 55d3f68a45..fd8dd0b4b3 100644 --- a/content/docs/protocol/objectql/types.mdx +++ b/content/docs/protocol/objectql/types.mdx @@ -648,10 +648,17 @@ const opportunities = await engine.find('opportunity', { > tests the *resolved* behavior, so it cannot tell the two apart: writing > `set_null` explicitly on a required lookup does not opt out of the refusal, > and it does not change the outcome in any way. `cascade` and `restrict` are -> the two values that are honored as written. On a `multiple: true` required -> lookup the refusal comes first as well, before the member-removal rule below -> applies — so the parent delete is refused even when the child's set holds -> other members. +> the two values that are honored as written. +> +> On a `multiple: true` required lookup the escalation is judged **per row**, +> after the referencing rows are known — because `set_null` there removes only +> the deleted **member** (see the member-removal rule above), and a required +> set is only violated when nothing is left. A row that still holds another +> member is written its remainder and the parent delete goes through; a row the +> removal would EMPTY keeps the refusal, since `[]` does not satisfy `required` +> on a multi-value field. When both kinds of row reference the record, the +> delete is refused and `dependentCount` counts only the rows that would have +> been emptied. > > On `master_detail` the same reading applies from the other side: `restrict` > is the only value that deviates from `cascade`, so an explicit