|
| 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 | +}); |
0 commit comments