Skip to content

Commit 650cd3d

Browse files
claude[bot]claude
andauthored
fix(objectql): the delete-cascade path's two registry reads propagate instead of inventing 'no relations' (#9002) (#9163)
* fix(objectql): the delete-cascade path's two registry reads propagate instead of inventing 'no relations' (#9002) * test(objectql): pin both delete-cascade registry-read seams; repair the vocabulary suite's registry double; changeset (#9002) --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 4dfa369 commit 650cd3d

4 files changed

Lines changed: 417 additions & 18 deletions

File tree

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
---
2+
"@objectstack/objectql": patch
3+
---
4+
5+
fix(objectql): the delete-cascade path's two registry reads propagate instead of answering "no relations" (#9002)
6+
7+
`ObjectQL.delete()`'s by-id branch reads `registry.getAllObjects()` twice, and
8+
both reads sat behind a swallow that invented an answer for a read that never
9+
happened:
10+
11+
- `planCascadeAtomicity()``catch { return 'none' }`. `'none'` is the verdict
12+
that asserts *nothing references this object*, so #7413's cascade atomicity
13+
was silently switched off on a registry nobody could read.
14+
- `cascadeDeleteRelations()`, its first statement — `catch { return }`. That
15+
skips the cascade entirely: no `restrict` refusal, no `set_null`, no
16+
`cascade`, nothing logged, and the caller told the delete succeeded.
17+
18+
This is the #8895 shape one layer up, with a strictly larger blast radius:
19+
#8895's `catch` invented "no dependents" for one relation whose probe could not
20+
run; this one invented "no relations" for every relation at once, before the
21+
per-relation probe was ever reached.
22+
23+
#8895 ruled the family *discriminate or propagate*. Discrimination needs a
24+
benign failure class — there, an unprovisioned child table, which genuinely
25+
cannot hold a referencing row. Here there is none: an unreadable registry is
26+
never truthfully "no relations". So both `catch`es are removed and the read's
27+
own failure reaches the caller, envelope intact — no new error code, no new
28+
response field, and the second seam is decided in the same direction as the
29+
first because its own argument rested on the first one firing.
30+
31+
**No shipped behaviour changes.** `SchemaRegistry.getAllObjects()` is a walk
32+
over in-memory `Map`s (`resolveObject()` returns `undefined` on every failure
33+
branch it models) with no I/O and no `throw` on the measured path, so nothing in
34+
a running deployment can reach either seam today. This is a structural close of
35+
a fail-open shape, pinned by tests, so that the day the registry read grows a
36+
throwing path it fails loudly instead of disabling every referential guard at
37+
once.
Lines changed: 330 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,330 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#9002] The delete-cascade path's TWO registry reads must not answer a failed
5+
* read with an invented "no relations".
6+
*
7+
* `ObjectQL.delete()`'s by-id branch reads `registry.getAllObjects()` twice, and
8+
* both reads used to sit behind a swallow:
9+
*
10+
* 1. {@link ObjectQL.planCascadeAtomicity} — `catch { return 'none' }`, i.e.
11+
* "nothing references this object, so there is no multi-write unit to make
12+
* atomic" (#7413's atomicity silently switched off);
13+
* 2. `cascadeDeleteRelations()`'s first statement — `catch { return }`, i.e.
14+
* the cascade does not run at all: no `restrict` refusal, no `set_null`, no
15+
* `cascade`, nothing logged, and the caller told the delete succeeded.
16+
*
17+
* That is the #8895 shape one layer up, with a strictly larger blast radius:
18+
* #8895's swallow invented "no dependents" for ONE relation, seam 2 here
19+
* invents "no relations" for EVERY relation at once, before the per-relation
20+
* probe is ever reached. #8895 ruled the family *discriminate or propagate*;
21+
* discrimination needs a benign failure class and there is none here — an
22+
* unreadable registry is never truthfully "no relations" — so both `catch`es
23+
* are gone and the read's own failure reaches the caller.
24+
*
25+
* ⚠️ This pins a STRUCTURAL close, not a live defect. `SchemaRegistry`'s
26+
* `getAllObjects()` is a walk over in-memory `Map`s (`resolveObject` → a spread
27+
* fold; every failure branch returns `undefined`, the orphan-overlay one after
28+
* a `console.warn`) with no I/O and no `throw` on the measured path, so nothing
29+
* shipped can reach these seams today. The tests therefore inject the failure at
30+
* the registry method itself — the injection IS the statement that the seam is
31+
* unreachable from real data, and the pin is what keeps the fail-open shape from
32+
* coming back the day `getAllObjects()` grows a throwing path.
33+
*
34+
* The two seams are told apart by WHICH read fails, not by mocking one function:
35+
* `delete()` calls `planCascadeAtomicity` first and `cascadeDeleteRelations`
36+
* second, so failing read #1 exercises seam 1 and failing read #2 — a read that
37+
* fails once after succeeding, the flaky shape the card names — exercises
38+
* seam 2 with the atomicity plan already computed. Every expectation is written
39+
* against literals (the injected error object's identity, its literal `code` /
40+
* `status` / message, literal row counts), and each is paired with a positive
41+
* control in the same describe, so a harness that had stopped cascading at all
42+
* could not pass vacuously.
43+
*/
44+
45+
import { describe, it, expect, beforeEach } from 'vitest';
46+
import type { ServiceObject } from '@objectstack/spec/data';
47+
import { ObjectQL } from './engine.js';
48+
49+
/** The package id every fixture below is registered under. */
50+
const OWNER_PACKAGE = 'test-9002';
51+
52+
/*
53+
* Fixtures are typed as `ServiceObject` (and registered WITH their `packageId`)
54+
* rather than left to inference, so this file adds nothing to
55+
* `@objectstack/objectql`'s TEST_DEBT ledger — a shrink-only ratchet (#5278).
56+
*/
57+
const acct: ServiceObject = {
58+
name: 'acct',
59+
label: 'Account',
60+
fields: {
61+
id: { name: 'id', label: 'ID', type: 'text' as const },
62+
name: { name: 'name', label: 'Name', type: 'text' as const },
63+
},
64+
};
65+
// required lookup → the defaulted set_null escalates to `restrict`, so this
66+
// relation is the one whose refusal the swallow used to disable.
67+
const oppRestrict: ServiceObject = {
68+
name: 'opp',
69+
label: 'Opportunity',
70+
fields: {
71+
id: { name: 'id', label: 'ID', type: 'text' as const },
72+
name: { name: 'name', label: 'Name', type: 'text' as const },
73+
account: {
74+
name: 'account',
75+
label: 'Account',
76+
type: 'lookup' as const,
77+
reference: 'acct',
78+
required: true,
79+
},
80+
},
81+
};
82+
// explicit cascade → the seam also decides whether children are REMOVED.
83+
const taskCascade: ServiceObject = {
84+
name: 'task',
85+
label: 'Task',
86+
fields: {
87+
id: { name: 'id', label: 'ID', type: 'text' as const },
88+
title: { name: 'title', label: 'Title', type: 'text' as const },
89+
account: {
90+
name: 'account',
91+
label: 'Account',
92+
type: 'lookup' as const,
93+
reference: 'acct',
94+
required: true,
95+
deleteBehavior: 'cascade' as const,
96+
},
97+
},
98+
};
99+
100+
/** A minimal in-memory driver — no read-failure injection here, deliberately:
101+
* the failure this file is about happens in the REGISTRY, before any driver
102+
* read, so a driver that always succeeds is what makes that visible. */
103+
function makeStubDriver() {
104+
const stores = new Map<string, Map<string, Record<string, unknown>>>();
105+
const storeFor = (o: string) => {
106+
let s = stores.get(o);
107+
if (!s) { s = new Map(); stores.set(o, s); }
108+
return s;
109+
};
110+
let nextId = 0;
111+
const matches = (row: Record<string, unknown>, where: any): boolean => {
112+
if (!where || typeof where !== 'object') return true;
113+
for (const [k, v] of Object.entries(where)) {
114+
if (k.startsWith('$')) continue;
115+
const exp = (v && typeof v === 'object' && '$eq' in (v as any)) ? (v as any).$eq : v;
116+
if ((row[k] ?? null) !== (exp ?? null)) return false;
117+
}
118+
return true;
119+
};
120+
const driver: any = {
121+
name: 'memory', version: '0.0.0', supports: {},
122+
async connect() {}, async disconnect() {}, async checkHealth() { return true; }, async execute() { return null; },
123+
async find(o: string, ast: any) {
124+
return Array.from(storeFor(o).values()).filter((r) => matches(r, ast?.where));
125+
},
126+
async findOne(o: string, ast: any) {
127+
for (const r of storeFor(o).values()) if (matches(r, ast?.where)) return r;
128+
return null;
129+
},
130+
async create(o: string, data: Record<string, unknown>) {
131+
nextId += 1;
132+
const id = (data.id as string) ?? `r_${nextId}`;
133+
const row = { ...data, id };
134+
storeFor(o).set(id, row);
135+
return row;
136+
},
137+
async update(o: string, id: string, data: Record<string, unknown>) {
138+
const s = storeFor(o); const cur = s.get(id);
139+
if (!cur) throw new Error(`nf ${o}/${id}`);
140+
const up = { ...cur, ...data, id }; s.set(id, up); return up;
141+
},
142+
async upsert(o: string, data: Record<string, unknown>) {
143+
const id = data.id as string | undefined;
144+
return id && storeFor(o).has(id) ? this.update(o, id, data) : this.create(o, data);
145+
},
146+
async delete(o: string, id: string) { return storeFor(o).delete(id); },
147+
async count(o: string, ast: any) {
148+
return Array.from(storeFor(o).values()).filter((r) => matches(r, ast?.where)).length;
149+
},
150+
async bulkCreate(o: string, rows: Record<string, unknown>[]) { return Promise.all(rows.map((r) => this.create(o, r))); },
151+
async bulkUpdate() { return []; }, async bulkDelete() {},
152+
async beginTransaction() { return { commit: async () => {}, rollback: async () => {} }; },
153+
async commit() {}, async rollback() {},
154+
};
155+
return { driver, stores };
156+
}
157+
158+
/** Row count read straight out of the stub's store — never through the engine. */
159+
const rows = (stores: Map<string, Map<string, Record<string, unknown>>>, object: string) =>
160+
stores.get(object)?.size ?? 0;
161+
162+
/**
163+
* Make the engine's registry throw `error` on its Nth `getAllObjects()` call
164+
* counted FROM THIS CALL — every earlier read (setup, warm-up) has already
165+
* happened, so `nth: 1` is the delete's first read and `nth: 2` its second.
166+
*
167+
* Returns the live call counter so each test can assert HOW MANY reads the
168+
* delete actually got to make: that count is what separates "seam 1 stopped it"
169+
* from "seam 2 stopped it", and it is read from the wrapper, never re-derived
170+
* from the code under test.
171+
*/
172+
function failRegistryReadOn(
173+
engine: ObjectQL,
174+
nth: number,
175+
error: unknown,
176+
): { calls: () => number } {
177+
const registry = engine.registry as unknown as {
178+
getAllObjects: (packageId?: string) => ServiceObject[];
179+
};
180+
const real = registry.getAllObjects.bind(registry);
181+
let n = 0;
182+
registry.getAllObjects = (packageId?: string): ServiceObject[] => {
183+
n += 1;
184+
if (n === nth) throw error;
185+
return real(packageId);
186+
};
187+
return { calls: () => n };
188+
}
189+
190+
describe('[#9002] the delete-cascade path\'s registry reads must not invent "no relations"', () => {
191+
let engine: ObjectQL;
192+
let stores: Map<string, Map<string, Record<string, unknown>>>;
193+
194+
beforeEach(async () => {
195+
engine = new ObjectQL();
196+
const stub = makeStubDriver();
197+
stores = stub.stores;
198+
engine.registerDriver(stub.driver, true);
199+
await engine.init();
200+
for (const o of [acct, oppRestrict, taskCascade]) {
201+
engine.registry.registerObject(o, OWNER_PACKAGE);
202+
}
203+
});
204+
205+
// ── POSITIVE CONTROLS — the registry read SUCCEEDS, so both of the
206+
// cascade's real answers are observable in this harness. Without these,
207+
// every refusal assertion below could pass on a harness that no longer
208+
// cascades at all.
209+
210+
it('control: a readable registry still refuses a restricted delete (DELETE_RESTRICTED, 409)', async () => {
211+
const a = await engine.insert('acct', { name: 'Acme' });
212+
await engine.insert('opp', { name: 'Deal', account: a.id });
213+
214+
const err: any = await engine.delete('acct', { where: { id: a.id } } as any).catch((e) => e);
215+
expect(err.code).toBe('DELETE_RESTRICTED');
216+
expect(err.status).toBe(409);
217+
expect(err.dependentObject).toBe('opp');
218+
expect(err.dependentCount).toBe(1);
219+
expect(rows(stores, 'acct')).toBe(1);
220+
expect(rows(stores, 'opp')).toBe(1);
221+
});
222+
223+
it('control: a readable registry still cascades the children away', async () => {
224+
const a = await engine.insert('acct', { name: 'Acme' });
225+
await engine.insert('task', { title: 'Follow up', account: a.id });
226+
227+
await engine.delete('acct', { where: { id: a.id } } as any);
228+
expect(rows(stores, 'acct')).toBe(0);
229+
expect(rows(stores, 'task')).toBe(0);
230+
});
231+
232+
it('control: a readable registry still lets a dependent-free delete through', async () => {
233+
const a = await engine.insert('acct', { name: 'Empty' });
234+
await engine.delete('acct', { where: { id: a.id } } as any);
235+
expect(rows(stores, 'acct')).toBe(0);
236+
});
237+
238+
// ── SEAM 1 — `planCascadeAtomicity`, the FIRST of the delete's two reads.
239+
// It used to answer `'none'`: no transaction opened, and the pre-#7413
240+
// non-atomic path taken over a schema nobody could read.
241+
242+
it('seam 1 (planCascadeAtomicity): a failed registry read surfaces and refuses the delete', async () => {
243+
const a = await engine.insert('acct', { name: 'Acme' });
244+
await engine.insert('opp', { name: 'Deal', account: a.id });
245+
246+
const injected = Object.assign(new Error('registry unreadable: contributor fold failed'), {
247+
code: 'REGISTRY_READ_FAILED',
248+
status: 500,
249+
});
250+
const probe = failRegistryReadOn(engine, 1, injected);
251+
252+
const err: any = await engine.delete('acct', { where: { id: a.id } } as any).catch((e) => e);
253+
254+
// The caller receives the REGISTRY read's own failure — this fix mints
255+
// no new code and no new response field, so the envelope it arrived
256+
// with is the envelope it leaves with.
257+
expect(err).toBe(injected);
258+
expect(err.message).toBe('registry unreadable: contributor fold failed');
259+
expect(err.code).toBe('REGISTRY_READ_FAILED');
260+
expect(err.status).toBe(500);
261+
// Seam 1 runs BEFORE the cascade, so the delete stops at the first read.
262+
expect(probe.calls()).toBe(1);
263+
// …and emphatically NOT the pre-fix outcome: a silent, non-atomic
264+
// success that removed the parent and orphaned the child.
265+
expect(rows(stores, 'acct')).toBe(1);
266+
expect(rows(stores, 'opp')).toBe(1);
267+
});
268+
269+
it('seam 1: a failed read refuses even a delete with NO dependents — "unreadable" is not "none"', async () => {
270+
// The pre-fix `'none'` verdict was indistinguishable from this object's
271+
// genuine one, which is the whole complaint: `'none'` asserts something
272+
// positive about the schema that an unreadable registry cannot support.
273+
const a = await engine.insert('acct', { name: 'Lonely' });
274+
275+
const injected = Object.assign(new Error('registry unreadable'), { code: 'REGISTRY_READ_FAILED' });
276+
failRegistryReadOn(engine, 1, injected);
277+
278+
const err: any = await engine.delete('acct', { where: { id: a.id } } as any).catch((e) => e);
279+
expect(err).toBe(injected);
280+
expect(rows(stores, 'acct')).toBe(1);
281+
});
282+
283+
// ── SEAM 2 — `cascadeDeleteRelations`, the SECOND read. Reached only when
284+
// the first read SUCCEEDED, i.e. a read that fails once after succeeding
285+
// — the flaky shape the card names. Pre-fix this returned silently and
286+
// the parent's own `driver.delete` then ran, unguarded.
287+
288+
it('seam 2 (cascadeDeleteRelations): a read that fails on the SECOND call surfaces and deletes nothing', async () => {
289+
const a = await engine.insert('acct', { name: 'Acme' });
290+
await engine.insert('opp', { name: 'Deal', account: a.id });
291+
292+
const injected = Object.assign(new Error('registry unreadable mid-delete'), {
293+
code: 'REGISTRY_READ_FAILED',
294+
status: 500,
295+
});
296+
const probe = failRegistryReadOn(engine, 2, injected);
297+
298+
const err: any = await engine.delete('acct', { where: { id: a.id } } as any).catch((e) => e);
299+
300+
expect(err).toBe(injected);
301+
expect(err.message).toBe('registry unreadable mid-delete');
302+
expect(err.code).toBe('REGISTRY_READ_FAILED');
303+
expect(err.status).toBe(500);
304+
// Proof this is seam 2 and not seam 1: the first read SUCCEEDED (the
305+
// atomicity plan was computed) and the delete died on the second.
306+
expect(probe.calls()).toBe(2);
307+
// Pre-fix: the `restrict` relation was never consulted, the parent row
308+
// was deleted, and the caller was told it succeeded.
309+
expect(rows(stores, 'acct')).toBe(1);
310+
expect(rows(stores, 'opp')).toBe(1);
311+
});
312+
313+
it('seam 2: a CASCADE relation is not skipped either — no parent removed over unread children', async () => {
314+
const a = await engine.insert('acct', { name: 'Acme' });
315+
await engine.insert('task', { title: 'Follow up', account: a.id });
316+
317+
const injected = Object.assign(new Error('registry unreadable mid-delete'), {
318+
code: 'REGISTRY_READ_FAILED',
319+
});
320+
const probe = failRegistryReadOn(engine, 2, injected);
321+
322+
const err: any = await engine.delete('acct', { where: { id: a.id } } as any).catch((e) => e);
323+
expect(err).toBe(injected);
324+
expect(probe.calls()).toBe(2);
325+
// Pre-fix this removed the parent and left the child pointing at a row
326+
// that no longer exists.
327+
expect(rows(stores, 'acct')).toBe(1);
328+
expect(rows(stores, 'task')).toBe(1);
329+
});
330+
});

packages/objectql/src/engine-middleware-operation-vocabulary.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,18 @@ vi.mock('./registry', () => {
100100
const instance: any = {
101101
getObject: vi.fn(),
102102
resolveObject: vi.fn((n: string) => instance.getObject(n)),
103+
// [#9002] This double used to omit `getAllObjects`, and the suite passed
104+
// anyway: `delete()`'s by-id branch reads it twice (`planCascadeAtomicity`,
105+
// then `cascadeDeleteRelations`) and BOTH reads sat behind a `catch` that
106+
// answered "no relations". The swallow absorbed the `TypeError` this
107+
// omission raises just as silently as it would absorb a real read failure,
108+
// so an incomplete double read as a registry with nothing in it. With the
109+
// swallows gone the omission is a hard failure, which is the point — the
110+
// double now has to model the method the engine actually calls. Empty is
111+
// the right body here: this suite pins the middleware operation VOCABULARY
112+
// and registers no relations, so "no object references the deleted one" is
113+
// the truthful answer rather than an invented one.
114+
getAllObjects: vi.fn(() => []),
103115
registerObject: vi.fn(),
104116
getObjectOwner: vi.fn(),
105117
registerNamespace: vi.fn(),

0 commit comments

Comments
 (0)