/** * @module tests/integration/aggregation-delete * @description Regression for BR-AGG-DELETE-HANG (consumer-reported on 7.x): once a * brain had ever deleted an entity, raw `queryAggregate` never resolved (7.x recomputed * MIN/MAX after a delete by SCANNING entities, which fed materialized-aggregate entities * back into aggregation → infinite loop). 8.0 must (a) RESOLVE after deletes — never hang — * and (b) return the CORRECT new min/max after deleting the current extreme (recomputed * from the in-memory value multiset, no entity scan). */ import { describe, it, expect, afterEach } from 'vitest' import { Brainy } from '../../src/brainy.js' import { NounType } from '../../src/types/graphTypes.js' /** Deterministic 384-dim vectors so the embedder is never invoked. */ function vec(seed: number): number[] { return Array.from({ length: 384 }, (_, i) => ((seed * 31 + i * 7) % 100) / 100) } /** Resolve a query or fail loudly if it hangs (the 7.x symptom). */ function noHang(p: Promise, label: string, ms = 4000): Promise { return Promise.race([ p, new Promise((_, rej) => setTimeout(() => rej(new Error(`${label} HUNG (did not resolve in ${ms}ms)`)), ms) ) ]) } describe('queryAggregate across deletes — no hang + correct min/max', () => { const brains: Brainy[] = [] afterEach(async () => { for (const b of brains.splice(0)) await b.close().catch(() => {}) }) it('resolves after deletes and recomputes min/max from the multiset', async () => { const brain = new Brainy({ requireSubtype: false, storage: { type: 'memory' } }) brains.push(brain) await brain.init() brain.defineAggregate({ name: 'stats_by_cat', source: { type: NounType.Thing }, groupBy: ['category'], metrics: { total: { op: 'sum', field: 'amount' }, lo: { op: 'min', field: 'amount' }, hi: { op: 'max', field: 'amount' }, n: { op: 'count' } } }) const ids: Record = {} for (const [label, amount] of [ ['a', 10], ['b', 20], ['c', 30], ['d', 40] ] as const) { ids[label] = await brain.add({ data: `e-${label}`, type: NounType.Thing, vector: vec(amount), metadata: { category: 'x', amount } }) } const pick = (rows: any[]) => rows.find((r) => r.groupKey.category === 'x') let g = pick(await noHang(brain.queryAggregate('stats_by_cat'), 'queryAggregate (initial)')) expect(g).toBeDefined() expect(g.metrics.total).toBe(100) expect(g.metrics.lo).toBe(10) expect(g.metrics.hi).toBe(40) expect(g.metrics.n).toBe(4) // Delete the CURRENT min (10) AND the CURRENT max (40) — the case that hung in 7.x. await brain.remove(ids.a) await brain.remove(ids.d) g = pick(await noHang(brain.queryAggregate('stats_by_cat'), 'queryAggregate (after delete)')) expect(g).toBeDefined() expect(g.metrics.n).toBe(2) expect(g.metrics.total).toBe(50) // 20 + 30 expect(g.metrics.lo).toBe(20) // new min after deleting 10 expect(g.metrics.hi).toBe(30) // new max after deleting 40 // A further delete still resolves and stays correct. await brain.remove(ids.b) g = pick(await noHang(brain.queryAggregate('stats_by_cat'), 'queryAggregate (after 2nd delete)')) expect(g.metrics.n).toBe(1) expect(g.metrics.lo).toBe(30) expect(g.metrics.hi).toBe(30) }) })