brainy/tests/integration/aggregate-reserved-fields.test.ts

170 lines
5.7 KiB
TypeScript
Raw Permalink Normal View History

fix: one field-resolution law across aggregation hooks, source.where, removeMany, and find() spellings Four fixes from a consumer conformance report, one root disease — two field-resolution regimes where there must be one: - The delete/update aggregation hooks fed the engine a partial entity view (type/service/data/metadata only), so a reserved-field groupBy (subtype, visibility, ...) resolved to a nonexistent group on the way down: counts drifted upward forever after deletes, and updates moving an entity between reserved-field groups double-counted. The hooks now pass the full-fidelity view via entityForAggFromRawRecord (every reserved field top-level, mirroring the add path); the update sites pass the full get() view instead of a hand-rolled subset. - Aggregation source.where resolved fields only against the custom metadata bag, so where on a reserved field silently matched nothing. The matcher now resolves each filtered field through resolveEntityField — the same single source of truth groupBy uses. - removeMany() with no usable selector (bare array passed positionally, empty params, ids: []) resolved successfully having deleted nothing. All three now throw; the two legacy tests that pinned the silent no-op as 'graceful' now pin the refusal. - find() where keys accept both spellings: a metadata.-prefixed key falls back to its flattened spelling when the prefixed one is not indexed (metadata is flattened at index time). A literal nested custom key named metadata still wins when indexed as spelled. Five regression pins in aggregate-reserved-fields.test.ts (4 of 5 vary red on the unfixed code).
2026-07-19 10:54:36 -07:00
/**
* @module tests/integration/aggregate-reserved-fields
* @description One field-resolution law across the whole aggregation + query
* surface (SELF-AGGREGATE-DELETE-DRIFT). Laws:
* (1) aggregates grouped by a RESERVED field (subtype) decrement on delete
* the delete-side entity view carries every reserved field, so the
* decrement finds its group (counts must never drift from ground truth);
* (2) same for update: moving an entity between reserved-field groups
* decrements the old group and increments the new one (no double-count);
* (3) aggregation source.where on a reserved field (subtype) FILTERS instead
* of silently matching nothing;
* (4) removeMany refuses empty/invalid selectors loudly (bare array, empty
* object, ids: []) instead of resolving as a silent no-op;
* (5) find() accepts both where spellings: flattened (entry.title) and
* storage-shaped (metadata.entry.title) resolve to the same rows.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { Brainy } from '../../src/brainy.js'
import { NounType } from '../../src/types/graphTypes.js'
const stubEmbedding = async (text: string): Promise<number[]> => {
const hash = text.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0)
return new Array(384).fill(0).map((_, i) => Math.sin(hash + i))
}
describe('aggregation + query field-resolution law', () => {
let brain: Brainy
beforeEach(async () => {
brain = new Brainy({
requireSubtype: false,
storage: { type: 'memory' as const },
embeddingFunction: stubEmbedding
})
await brain.init()
})
afterEach(async () => {
await brain.close()
})
it('reserved-field groupBy decrements on delete (the drift bug)', async () => {
brain.defineAggregate({
name: 'by_subtype',
source: { type: NounType.Document },
groupBy: ['subtype'],
metrics: { count: { op: 'count' } }
})
const ids: string[] = []
for (let i = 0; i < 5; i++) {
ids.push(
await brain.add({
data: `doc-${i}`,
type: NounType.Document,
subtype: 'note',
metadata: { team: 'alpha' }
})
)
}
let groups = await brain.queryAggregate('by_subtype')
expect(groups).toHaveLength(1)
expect(groups[0].groupKey).toEqual({ subtype: 'note' })
expect(groups[0].metrics.count).toBe(5)
await brain.remove(ids[0])
await brain.flush()
groups = await brain.queryAggregate('by_subtype')
expect(groups[0].metrics.count).toBe(4)
const live = await brain.find({ type: NounType.Document, limit: 100 })
expect(groups[0].metrics.count).toBe(live.length)
})
it('reserved-field groupBy moves between groups on update (no double-count)', async () => {
brain.defineAggregate({
name: 'by_subtype',
source: { type: NounType.Document },
groupBy: ['subtype'],
metrics: { count: { op: 'count' } }
})
const id = await brain.add({
data: 'doc-move',
type: NounType.Document,
subtype: 'draft'
})
await brain.update({ id, subtype: 'published' })
const groups = await brain.queryAggregate('by_subtype')
const byKey = Object.fromEntries(
groups.map((g) => [String(g.groupKey.subtype), g.metrics.count])
)
expect(byKey['published']).toBe(1)
// The old group must be gone or zero — never still counting the entity.
expect(byKey['draft'] ?? 0).toBe(0)
})
it('source.where on a reserved field filters instead of matching nothing', async () => {
brain.defineAggregate({
name: 'notes_only',
source: { type: NounType.Document, where: { subtype: 'note' } },
groupBy: ['team'],
metrics: { count: { op: 'count' } }
})
await brain.add({
data: 'n1',
type: NounType.Document,
subtype: 'note',
metadata: { team: 'alpha' }
})
await brain.add({
data: 'd1',
type: NounType.Document,
subtype: 'draft',
metadata: { team: 'alpha' }
})
const groups = await brain.queryAggregate('notes_only')
expect(groups).toHaveLength(1)
expect(groups[0].metrics.count).toBe(1) // the note, never the draft
})
it('removeMany refuses empty/invalid selectors loudly', async () => {
const id = await brain.add({ data: 'keep-me', type: NounType.Document })
// Bare array passed positionally — the classic silent no-op.
await expect(
brain.removeMany([id] as unknown as Parameters<typeof brain.removeMany>[0])
).rejects.toThrow(/bare array/)
// Empty selector object.
await expect(
brain.removeMany({} as Parameters<typeof brain.removeMany>[0])
).rejects.toThrow(/requires a selector/)
// Explicit empty id list.
await expect(brain.removeMany({ ids: [] })).rejects.toThrow(/ids: \[\]/)
// Nothing was deleted by any of the refused calls.
expect(await brain.get(id)).toBeTruthy()
})
it('find() accepts both flattened and metadata.-prefixed where spellings', async () => {
await brain.add({
data: 'nested-doc',
type: NounType.Document,
metadata: { entry: { title: 'T1' }, classifier: { contextHints: { vfsPath: '/n/a.md' } } }
})
await brain.flush()
const flat = await brain.find({
type: NounType.Document,
where: { 'entry.title': 'T1' },
limit: 10
})
const prefixed = await brain.find({
type: NounType.Document,
where: { 'metadata.entry.title': 'T1' },
limit: 10
})
const deepPrefixed = await brain.find({
type: NounType.Document,
where: { 'metadata.classifier.contextHints.vfsPath': '/n/a.md' },
limit: 10
})
expect(flat).toHaveLength(1)
expect(prefixed).toHaveLength(1)
expect(prefixed[0].id).toBe(flat[0].id)
expect(deepPrefixed).toHaveLength(1)
})
})