feat: add aggregation engine with incremental SUM/COUNT/AVG/MIN/MAX, GROUP BY, and time windows

Add a write-time incremental aggregation engine that maintains running
totals on every add/update/delete for O(1) read performance. Integrates
into brain.find({ aggregate }) for a unified query API.

Core features:
- AggregationIndex with defineAggregate()/removeAggregate() API
- Five aggregation operations: SUM, COUNT, AVG, MIN, MAX
- GROUP BY with multiple dimensions including time windows
- Time window bucketing: hour, day, week, month, quarter, year, custom
- Materialization of results as NounType.Measurement entities
- Debounced persistence of definitions and state to storage
- Definition change detection via FNV-1a hashing with auto-rebuild
- Infinite loop prevention for materialized entities
- 'aggregation' plugin provider key for native acceleration
- Lazy initialization (created on first defineAggregate() call)
- 73 tests (unit + integration) covering all functionality

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
David Snelling 2026-02-16 16:57:53 -08:00
parent 089a4d4141
commit f024e56ee7
19 changed files with 2986 additions and 21 deletions

View file

@ -0,0 +1,442 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { Brainy } from '../../src/brainy'
import { NounType } from '../../src/types/graphTypes'
import type { AggregateDefinition } from '../../src/types/brainy.types'
describe('Aggregation Engine Integration', () => {
let brain: Brainy<any>
beforeEach(async () => {
brain = new Brainy({ storage: { type: 'memory' }, silent: true })
await brain.init()
})
afterEach(async () => {
await brain.close()
})
// ============= Core: Define + Add + Query =============
describe('define → add → query lifecycle', () => {
it('should accumulate metrics as entities are added', async () => {
brain.defineAggregate({
name: 'spending',
source: { type: NounType.Event, where: { domain: 'financial' } },
groupBy: ['category'],
metrics: {
total: { op: 'sum', field: 'amount' },
count: { op: 'count' },
average: { op: 'avg', field: 'amount' },
highest: { op: 'max', field: 'amount' },
lowest: { op: 'min', field: 'amount' }
}
})
// Add transactions
await brain.add({
data: 'Coffee',
type: NounType.Event,
metadata: { domain: 'financial', category: 'food', amount: 5 }
})
await brain.add({
data: 'Lunch',
type: NounType.Event,
metadata: { domain: 'financial', category: 'food', amount: 15 }
})
await brain.add({
data: 'Uber',
type: NounType.Event,
metadata: { domain: 'financial', category: 'transport', amount: 25 }
})
// Query all groups
const results = await brain.find({ aggregate: 'spending' })
expect(results).toHaveLength(2)
// Check food group
const food = results.find(r => r.metadata?.category === 'food')!
expect(food).toBeDefined()
expect(food.metadata.total).toBe(20)
expect(food.metadata.count).toBe(2)
expect(food.metadata.average).toBe(10)
expect(food.metadata.highest).toBe(15)
expect(food.metadata.lowest).toBe(5)
// Check transport group
const transport = results.find(r => r.metadata?.category === 'transport')!
expect(transport).toBeDefined()
expect(transport.metadata.total).toBe(25)
expect(transport.metadata.count).toBe(1)
})
it('should filter aggregate results with where clause', async () => {
brain.defineAggregate({
name: 'by_cat',
source: { type: NounType.Event },
groupBy: ['category'],
metrics: { total: { op: 'sum', field: 'amount' } }
})
await brain.add({
data: 'Coffee', type: NounType.Event,
metadata: { category: 'food', amount: 5 }
})
await brain.add({
data: 'Uber', type: NounType.Event,
metadata: { category: 'transport', amount: 25 }
})
const results = await brain.find({
aggregate: 'by_cat',
where: { category: 'food' }
})
expect(results).toHaveLength(1)
expect(results[0].metadata.category).toBe('food')
})
it('should support sorting and pagination on aggregates', async () => {
brain.defineAggregate({
name: 'by_cat',
source: { type: NounType.Event },
groupBy: ['category'],
metrics: { total: { op: 'sum', field: 'amount' } }
})
const categories = ['food', 'transport', 'housing', 'entertainment']
for (let i = 0; i < categories.length; i++) {
await brain.add({
data: `Expense ${i}`, type: NounType.Event,
metadata: { category: categories[i], amount: (i + 1) * 100 }
})
}
// Sort descending, limit 2
const results = await brain.find({
aggregate: { name: 'by_cat', orderBy: 'total', order: 'desc', limit: 2 }
})
expect(results).toHaveLength(2)
expect(results[0].metadata.total).toBe(400)
expect(results[1].metadata.total).toBe(300)
})
})
// ============= Incremental Updates =============
describe('incremental updates', () => {
it('should update aggregates when entities are modified', async () => {
brain.defineAggregate({
name: 'totals',
source: { type: NounType.Event },
groupBy: ['category'],
metrics: {
total: { op: 'sum', field: 'amount' },
count: { op: 'count' }
}
})
const id = await brain.add({
data: 'Coffee', type: NounType.Event,
metadata: { category: 'food', amount: 5 }
})
// Verify initial state
let results = await brain.find({ aggregate: 'totals' })
expect(results[0].metadata.total).toBe(5)
// Update amount
await brain.update({
id,
metadata: { category: 'food', amount: 15 }
})
results = await brain.find({ aggregate: 'totals' })
expect(results[0].metadata.total).toBe(15)
expect(results[0].metadata.count).toBe(1)
})
it('should update aggregates when entities are deleted', async () => {
brain.defineAggregate({
name: 'totals',
source: { type: NounType.Event },
groupBy: ['category'],
metrics: {
total: { op: 'sum', field: 'amount' },
count: { op: 'count' }
}
})
const id1 = await brain.add({
data: 'Coffee', type: NounType.Event,
metadata: { category: 'food', amount: 5 }
})
await brain.add({
data: 'Lunch', type: NounType.Event,
metadata: { category: 'food', amount: 15 }
})
await brain.delete(id1)
const results = await brain.find({ aggregate: 'totals' })
expect(results).toHaveLength(1)
expect(results[0].metadata.total).toBe(15)
expect(results[0].metadata.count).toBe(1)
})
})
// ============= Time Windows =============
describe('time-windowed aggregates', () => {
it('should group by month using time window', async () => {
brain.defineAggregate({
name: 'monthly',
source: { type: NounType.Event },
groupBy: [
'category',
{ field: 'date', window: 'month' }
],
metrics: {
total: { op: 'sum', field: 'amount' },
count: { op: 'count' }
}
})
const jan = Date.UTC(2024, 0, 15)
const feb = Date.UTC(2024, 1, 15)
await brain.add({
data: 'Coffee Jan', type: NounType.Event,
metadata: { category: 'food', date: jan, amount: 100 }
})
await brain.add({
data: 'Coffee Feb', type: NounType.Event,
metadata: { category: 'food', date: feb, amount: 200 }
})
await brain.add({
data: 'Lunch Jan', type: NounType.Event,
metadata: { category: 'food', date: jan, amount: 50 }
})
const results = await brain.find({ aggregate: 'monthly' })
expect(results).toHaveLength(2)
const janGroup = results.find(r => r.metadata.date === '2024-01')!
const febGroup = results.find(r => r.metadata.date === '2024-02')!
expect(janGroup.metadata.total).toBe(150)
expect(janGroup.metadata.count).toBe(2)
expect(febGroup.metadata.total).toBe(200)
expect(febGroup.metadata.count).toBe(1)
})
})
// ============= Non-matching entities =============
describe('source filtering', () => {
it('should only aggregate entities matching the source filter', async () => {
brain.defineAggregate({
name: 'financial_only',
source: { type: NounType.Event, where: { domain: 'financial' } },
groupBy: ['category'],
metrics: { count: { op: 'count' } }
})
// Matching entity
await brain.add({
data: 'Purchase', type: NounType.Event,
metadata: { domain: 'financial', category: 'food' }
})
// Non-matching: wrong type
await brain.add({
data: 'Person', type: NounType.Person,
metadata: { domain: 'financial', category: 'food' }
})
// Non-matching: wrong domain
await brain.add({
data: 'Game event', type: NounType.Event,
metadata: { domain: 'gaming', category: 'food' }
})
const results = await brain.find({ aggregate: 'financial_only' })
expect(results).toHaveLength(1)
expect(results[0].metadata.count).toBe(1)
})
})
// ============= Multiple Aggregates =============
describe('multiple overlapping aggregates', () => {
it('should maintain independent aggregates on the same entities', async () => {
brain.defineAggregate({
name: 'by_category',
source: { type: NounType.Event },
groupBy: ['category'],
metrics: { total: { op: 'sum', field: 'amount' } }
})
brain.defineAggregate({
name: 'by_merchant',
source: { type: NounType.Event },
groupBy: ['merchant'],
metrics: { total: { op: 'sum', field: 'amount' } }
})
await brain.add({
data: 'Coffee', type: NounType.Event,
metadata: { category: 'food', merchant: 'Starbucks', amount: 5 }
})
await brain.add({
data: 'Tea', type: NounType.Event,
metadata: { category: 'food', merchant: 'Teavana', amount: 4 }
})
const catResults = await brain.find({ aggregate: 'by_category' })
const merchResults = await brain.find({ aggregate: 'by_merchant' })
expect(catResults).toHaveLength(1)
expect(catResults[0].metadata.total).toBe(9)
expect(merchResults).toHaveLength(2)
const starbucks = merchResults.find(r => r.metadata.merchant === 'Starbucks')!
const teavana = merchResults.find(r => r.metadata.merchant === 'Teavana')!
expect(starbucks.metadata.total).toBe(5)
expect(teavana.metadata.total).toBe(4)
})
})
// ============= Error Handling =============
describe('error handling', () => {
it('should throw when querying an undefined aggregate', async () => {
await expect(brain.find({ aggregate: 'nonexistent' }))
.rejects.toThrow()
})
it('should throw when defining aggregate without groupBy', () => {
expect(() => brain.defineAggregate({
name: 'bad',
source: { type: NounType.Event },
groupBy: [],
metrics: { count: { op: 'count' } }
})).toThrow()
})
})
// ============= removeAggregate =============
describe('removeAggregate', () => {
it('should remove aggregate and reject subsequent queries', async () => {
brain.defineAggregate({
name: 'temp',
source: { type: NounType.Event },
groupBy: ['category'],
metrics: { count: { op: 'count' } }
})
await brain.add({
data: 'Test', type: NounType.Event,
metadata: { category: 'food' }
})
// Aggregate exists
const results = await brain.find({ aggregate: 'temp' })
expect(results).toHaveLength(1)
// Remove it
brain.removeAggregate('temp')
// Should throw on query
await expect(brain.find({ aggregate: 'temp' })).rejects.toThrow()
})
})
// ============= Result format =============
describe('result format', () => {
it('should return Result<T>[] with Measurement type', async () => {
brain.defineAggregate({
name: 'test',
source: { type: NounType.Event },
groupBy: ['category'],
metrics: { count: { op: 'count' } }
})
await brain.add({
data: 'Test', type: NounType.Event,
metadata: { category: 'food' }
})
const results = await brain.find({ aggregate: 'test' })
expect(results).toHaveLength(1)
const result = results[0]
expect(result.id).toBeDefined()
expect(result.score).toBe(1.0)
expect(result.type).toBe(NounType.Measurement)
expect(result.entity).toBeDefined()
expect(result.entity.type).toBe(NounType.Measurement)
expect(result.entity.service).toBe('brainy:aggregation')
expect(result.metadata.__aggregate).toBe('test')
})
})
// ============= Scale test =============
describe('scale', () => {
it('should handle 100 entities with 3 aggregates', async () => {
// Note: Full scale testing (10K+ entities) is in unit tests which skip
// embedding overhead. This integration test verifies end-to-end wiring
// with real Brainy operations including embeddings.
brain.defineAggregate({
name: 'by_category', source: { type: NounType.Event },
groupBy: ['category'],
metrics: { total: { op: 'sum', field: 'amount' }, count: { op: 'count' } }
})
brain.defineAggregate({
name: 'by_merchant', source: { type: NounType.Event },
groupBy: ['merchant'],
metrics: { total: { op: 'sum', field: 'amount' } }
})
brain.defineAggregate({
name: 'monthly', source: { type: NounType.Event },
groupBy: [{ field: 'date', window: 'month' }],
metrics: { total: { op: 'sum', field: 'amount' }, count: { op: 'count' } }
})
const categories = ['food', 'transport', 'housing', 'entertainment', 'utilities']
const merchants = ['Starbucks', 'Uber', 'Amazon', 'Netflix', 'Con Edison']
const baseDate = Date.UTC(2024, 0, 1)
for (let i = 0; i < 100; i++) {
await brain.add({
data: `Transaction ${i}`,
type: NounType.Event,
metadata: {
category: categories[i % 5],
merchant: merchants[i % 5],
amount: Math.round(Math.random() * 10000) / 100,
date: baseDate + (i % 90) * 86400_000
}
})
}
// Verify category aggregate
const catResults = await brain.find({ aggregate: 'by_category' })
expect(catResults).toHaveLength(5)
const totalCount = catResults.reduce((sum, r) => sum + (r.metadata.count as number), 0)
expect(totalCount).toBe(100)
// Verify merchant aggregate
const merchResults = await brain.find({ aggregate: 'by_merchant' })
expect(merchResults).toHaveLength(5)
// Verify monthly aggregate
const monthlyResults = await brain.find({ aggregate: 'monthly' })
expect(monthlyResults.length).toBeGreaterThan(0)
const monthlyTotalCount = monthlyResults.reduce(
(sum, r) => sum + (r.metadata.count as number), 0
)
expect(monthlyTotalCount).toBe(100)
}, 60_000)
})
})

View file

@ -0,0 +1,620 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { AggregationIndex } from '../../../src/aggregation/AggregationIndex'
import { MemoryStorage } from '../../../src/storage/storageFactory'
import { NounType } from '../../../src/types/graphTypes'
import type { AggregateDefinition, AggregateGroupState } from '../../../src/types/brainy.types'
describe('AggregationIndex', () => {
let storage: MemoryStorage
let index: AggregationIndex
beforeEach(async () => {
storage = new MemoryStorage()
await storage.init()
index = new AggregationIndex(storage)
await index.init()
})
afterEach(async () => {
await index.close()
})
// ============= Definition Management =============
describe('defineAggregate', () => {
it('should register a valid aggregate definition', () => {
const def: AggregateDefinition = {
name: 'test_agg',
source: { type: NounType.Event },
groupBy: ['category'],
metrics: { count: { op: 'count' } }
}
index.defineAggregate(def)
expect(index.hasAggregate('test_agg')).toBe(true)
expect(index.getDefinitions()).toHaveLength(1)
expect(index.getDefinitions()[0].name).toBe('test_agg')
})
it('should reject definition without name', () => {
expect(() => index.defineAggregate({
name: '',
source: { type: NounType.Event },
groupBy: ['x'],
metrics: { count: { op: 'count' } }
})).toThrow('requires a name')
})
it('should reject definition without groupBy', () => {
expect(() => index.defineAggregate({
name: 'bad',
source: { type: NounType.Event },
groupBy: [],
metrics: { count: { op: 'count' } }
})).toThrow('at least one groupBy')
})
it('should reject definition without metrics', () => {
expect(() => index.defineAggregate({
name: 'bad',
source: { type: NounType.Event },
groupBy: ['x'],
metrics: {}
})).toThrow('at least one metric')
})
it('should reject sum metric without field', () => {
expect(() => index.defineAggregate({
name: 'bad',
source: { type: NounType.Event },
groupBy: ['x'],
metrics: { total: { op: 'sum' } }
})).toThrow("requires a 'field'")
})
it('should allow count metric without field', () => {
index.defineAggregate({
name: 'ok',
source: { type: NounType.Event },
groupBy: ['x'],
metrics: { count: { op: 'count' } }
})
expect(index.hasAggregate('ok')).toBe(true)
})
})
describe('removeAggregate', () => {
it('should remove an existing aggregate', () => {
index.defineAggregate({
name: 'temp',
source: { type: NounType.Event },
groupBy: ['x'],
metrics: { count: { op: 'count' } }
})
expect(index.hasAggregate('temp')).toBe(true)
index.removeAggregate('temp')
expect(index.hasAggregate('temp')).toBe(false)
})
})
// ============= Write-Time Hooks =============
describe('onEntityAdded', () => {
const spendingDef: AggregateDefinition = {
name: 'spending',
source: { type: NounType.Event, where: { domain: 'financial' } },
groupBy: ['category'],
metrics: {
total: { op: 'sum', field: 'amount' },
count: { op: 'count' },
average: { op: 'avg', field: 'amount' },
highest: { op: 'max', field: 'amount' },
lowest: { op: 'min', field: 'amount' }
}
}
beforeEach(() => {
index.defineAggregate(spendingDef)
})
it('should accumulate sum/count/avg/min/max for matching entities', () => {
index.onEntityAdded('e1', {
type: NounType.Event,
metadata: { domain: 'financial', category: 'food', amount: 10 }
})
index.onEntityAdded('e2', {
type: NounType.Event,
metadata: { domain: 'financial', category: 'food', amount: 20 }
})
index.onEntityAdded('e3', {
type: NounType.Event,
metadata: { domain: 'financial', category: 'food', amount: 30 }
})
const results = index.queryAggregate({ name: 'spending' })
expect(results).toHaveLength(1)
expect(results[0].groupKey).toEqual({ category: 'food' })
expect(results[0].metrics.total).toBe(60)
expect(results[0].metrics.count).toBe(3)
expect(results[0].metrics.average).toBe(20)
expect(results[0].metrics.highest).toBe(30)
expect(results[0].metrics.lowest).toBe(10)
expect(results[0].count).toBe(3)
})
it('should create separate groups for different keys', () => {
index.onEntityAdded('e1', {
type: NounType.Event,
metadata: { domain: 'financial', category: 'food', amount: 10 }
})
index.onEntityAdded('e2', {
type: NounType.Event,
metadata: { domain: 'financial', category: 'transport', amount: 50 }
})
const results = index.queryAggregate({ name: 'spending' })
expect(results).toHaveLength(2)
const food = results.find(r => r.groupKey.category === 'food')!
const transport = results.find(r => r.groupKey.category === 'transport')!
expect(food.metrics.total).toBe(10)
expect(transport.metrics.total).toBe(50)
})
it('should skip entities that do not match source filter', () => {
// Wrong type
index.onEntityAdded('e1', {
type: NounType.Person,
metadata: { domain: 'financial', category: 'food', amount: 100 }
})
// Missing domain
index.onEntityAdded('e2', {
type: NounType.Event,
metadata: { category: 'food', amount: 100 }
})
const results = index.queryAggregate({ name: 'spending' })
expect(results).toHaveLength(0)
})
it('should skip materialized aggregate entities (infinite loop prevention)', () => {
index.onEntityAdded('agg1', {
type: NounType.Measurement,
service: 'brainy:aggregation',
metadata: { domain: 'financial', category: 'food', amount: 999 }
})
const results = index.queryAggregate({ name: 'spending' })
expect(results).toHaveLength(0)
})
it('should skip entities with __aggregate metadata (infinite loop prevention)', () => {
index.onEntityAdded('agg2', {
type: NounType.Event,
metadata: { __aggregate: 'spending', domain: 'financial', category: 'food', amount: 999 }
})
const results = index.queryAggregate({ name: 'spending' })
expect(results).toHaveLength(0)
})
})
describe('onEntityUpdated', () => {
beforeEach(() => {
index.defineAggregate({
name: 'totals',
source: { type: NounType.Event },
groupBy: ['category'],
metrics: {
total: { op: 'sum', field: 'amount' },
count: { op: 'count' }
}
})
})
it('should reverse old contribution and apply new', () => {
const oldEntity = { type: NounType.Event, metadata: { category: 'food', amount: 10 } }
const newEntity = { type: NounType.Event, metadata: { category: 'food', amount: 25 } }
index.onEntityAdded('e1', oldEntity)
expect(index.queryAggregate({ name: 'totals' })[0].metrics.total).toBe(10)
index.onEntityUpdated('e1', newEntity, oldEntity)
const results = index.queryAggregate({ name: 'totals' })
expect(results[0].metrics.total).toBe(25)
expect(results[0].metrics.count).toBe(1)
})
it('should handle category change (different group keys)', () => {
const old = { type: NounType.Event, metadata: { category: 'food', amount: 10 } }
const updated = { type: NounType.Event, metadata: { category: 'transport', amount: 10 } }
index.onEntityAdded('e1', old)
index.onEntityUpdated('e1', updated, old)
const results = index.queryAggregate({ name: 'totals' })
// Old group should be removed (empty), new group should exist
expect(results).toHaveLength(1)
expect(results[0].groupKey.category).toBe('transport')
expect(results[0].metrics.total).toBe(10)
})
it('should handle entity no longer matching source', () => {
const old = { type: NounType.Event, metadata: { category: 'food', amount: 10 } }
const updated = { type: NounType.Person, metadata: { category: 'food', amount: 10 } }
index.onEntityAdded('e1', old)
expect(index.queryAggregate({ name: 'totals' })).toHaveLength(1)
index.onEntityUpdated('e1', updated, old)
expect(index.queryAggregate({ name: 'totals' })).toHaveLength(0)
})
it('should handle entity now matching source', () => {
const old = { type: NounType.Person, metadata: { category: 'food', amount: 10 } }
const updated = { type: NounType.Event, metadata: { category: 'food', amount: 10 } }
index.onEntityAdded('e1', old) // Won't match
expect(index.queryAggregate({ name: 'totals' })).toHaveLength(0)
index.onEntityUpdated('e1', updated, old)
expect(index.queryAggregate({ name: 'totals' })).toHaveLength(1)
expect(index.queryAggregate({ name: 'totals' })[0].metrics.total).toBe(10)
})
})
describe('onEntityDeleted', () => {
beforeEach(() => {
index.defineAggregate({
name: 'totals',
source: { type: NounType.Event },
groupBy: ['category'],
metrics: {
total: { op: 'sum', field: 'amount' },
count: { op: 'count' }
}
})
})
it('should decrement sum and count', () => {
const entity = { type: NounType.Event, metadata: { category: 'food', amount: 10 } }
index.onEntityAdded('e1', entity)
index.onEntityAdded('e2', { type: NounType.Event, metadata: { category: 'food', amount: 20 } })
index.onEntityDeleted('e1', entity)
const results = index.queryAggregate({ name: 'totals' })
expect(results).toHaveLength(1)
expect(results[0].metrics.total).toBe(20)
expect(results[0].metrics.count).toBe(1)
})
it('should remove group when last entity is deleted', () => {
const entity = { type: NounType.Event, metadata: { category: 'food', amount: 10 } }
index.onEntityAdded('e1', entity)
index.onEntityDeleted('e1', entity)
const results = index.queryAggregate({ name: 'totals' })
expect(results).toHaveLength(0)
})
})
// ============= Time Windows =============
describe('time-windowed groupBy', () => {
beforeEach(() => {
index.defineAggregate({
name: 'monthly',
source: { type: NounType.Event },
groupBy: [
'category',
{ field: 'date', window: 'month' }
],
metrics: {
total: { op: 'sum', field: 'amount' },
count: { op: 'count' }
}
})
})
it('should group by both category and time window', () => {
const jan = Date.UTC(2024, 0, 15)
const feb = Date.UTC(2024, 1, 15)
index.onEntityAdded('e1', {
type: NounType.Event,
metadata: { category: 'food', date: jan, amount: 100 }
})
index.onEntityAdded('e2', {
type: NounType.Event,
metadata: { category: 'food', date: feb, amount: 200 }
})
index.onEntityAdded('e3', {
type: NounType.Event,
metadata: { category: 'food', date: jan, amount: 50 }
})
const results = index.queryAggregate({ name: 'monthly' })
expect(results).toHaveLength(2)
const janGroup = results.find(r => r.groupKey.date === '2024-01')!
const febGroup = results.find(r => r.groupKey.date === '2024-02')!
expect(janGroup.metrics.total).toBe(150)
expect(janGroup.metrics.count).toBe(2)
expect(febGroup.metrics.total).toBe(200)
expect(febGroup.metrics.count).toBe(1)
})
})
// ============= Query =============
describe('queryAggregate', () => {
beforeEach(() => {
index.defineAggregate({
name: 'spending',
source: { type: NounType.Event },
groupBy: ['category'],
metrics: {
total: { op: 'sum', field: 'amount' },
count: { op: 'count' }
}
})
// Add several categories
for (let i = 0; i < 5; i++) {
index.onEntityAdded(`food-${i}`, {
type: NounType.Event,
metadata: { category: 'food', amount: 10 * (i + 1) }
})
}
for (let i = 0; i < 3; i++) {
index.onEntityAdded(`transport-${i}`, {
type: NounType.Event,
metadata: { category: 'transport', amount: 20 * (i + 1) }
})
}
index.onEntityAdded('housing-1', {
type: NounType.Event,
metadata: { category: 'housing', amount: 1500 }
})
})
it('should throw for unknown aggregate', () => {
expect(() => index.queryAggregate({ name: 'nonexistent' })).toThrow("not found")
})
it('should return all groups by default', () => {
const results = index.queryAggregate({ name: 'spending' })
expect(results).toHaveLength(3)
})
it('should filter by where clause', () => {
const results = index.queryAggregate({
name: 'spending',
where: { category: 'food' }
})
expect(results).toHaveLength(1)
expect(results[0].groupKey.category).toBe('food')
expect(results[0].metrics.total).toBe(150) // 10+20+30+40+50
})
it('should sort by metric ascending', () => {
const results = index.queryAggregate({
name: 'spending',
orderBy: 'total',
order: 'asc'
})
expect(results[0].metrics.total).toBeLessThanOrEqual(results[1].metrics.total)
})
it('should sort by metric descending', () => {
const results = index.queryAggregate({
name: 'spending',
orderBy: 'total',
order: 'desc'
})
expect(results[0].metrics.total).toBe(1500) // housing
})
it('should support limit', () => {
const results = index.queryAggregate({
name: 'spending',
orderBy: 'total',
order: 'desc',
limit: 2
})
expect(results).toHaveLength(2)
})
it('should support offset', () => {
const all = index.queryAggregate({
name: 'spending',
orderBy: 'total',
order: 'desc'
})
const page2 = index.queryAggregate({
name: 'spending',
orderBy: 'total',
order: 'desc',
offset: 1,
limit: 1
})
expect(page2).toHaveLength(1)
expect(page2[0].groupKey.category).toBe(all[1].groupKey.category)
})
})
// ============= Multiple Overlapping Aggregates =============
describe('multiple aggregates', () => {
it('should update multiple aggregates on a single entity add', () => {
index.defineAggregate({
name: 'by_category',
source: { type: NounType.Event },
groupBy: ['category'],
metrics: { count: { op: 'count' } }
})
index.defineAggregate({
name: 'by_merchant',
source: { type: NounType.Event },
groupBy: ['merchant'],
metrics: { total: { op: 'sum', field: 'amount' } }
})
index.onEntityAdded('e1', {
type: NounType.Event,
metadata: { category: 'food', merchant: 'Starbucks', amount: 5 }
})
const catResults = index.queryAggregate({ name: 'by_category' })
const merchResults = index.queryAggregate({ name: 'by_merchant' })
expect(catResults).toHaveLength(1)
expect(catResults[0].metrics.count).toBe(1)
expect(merchResults).toHaveLength(1)
expect(merchResults[0].metrics.total).toBe(5)
})
})
// ============= Persistence =============
describe('persistence', () => {
it('should persist definitions and state across flush/init cycles', async () => {
index.defineAggregate({
name: 'persist_test',
source: { type: NounType.Event },
groupBy: ['category'],
metrics: { total: { op: 'sum', field: 'amount' } }
})
index.onEntityAdded('e1', {
type: NounType.Event,
metadata: { category: 'food', amount: 42 }
})
// Flush to persist
await index.flush()
// Create new index from same storage
const index2 = new AggregationIndex(storage)
await index2.init()
expect(index2.hasAggregate('persist_test')).toBe(true)
const results = index2.queryAggregate({ name: 'persist_test' })
expect(results).toHaveLength(1)
expect(results[0].metrics.total).toBe(42)
await index2.close()
})
it('should rebuild state when definition hash changes', async () => {
index.defineAggregate({
name: 'hash_test',
source: { type: NounType.Event },
groupBy: ['category'],
metrics: { total: { op: 'sum', field: 'amount' } }
})
index.onEntityAdded('e1', {
type: NounType.Event,
metadata: { category: 'food', amount: 42 }
})
await index.flush()
// Create new index and register a DIFFERENT definition with same name
const index2 = new AggregationIndex(storage)
await index2.init()
// The loaded state should be present (definition unchanged)
expect(index2.queryAggregate({ name: 'hash_test' })[0].metrics.total).toBe(42)
// Now define with different metrics — should clear state
index2.defineAggregate({
name: 'hash_test',
source: { type: NounType.Event },
groupBy: ['category'],
metrics: { count: { op: 'count' } } // Changed from sum to count
})
// State should be fresh (empty — no entities added to the new definition)
const results = index2.queryAggregate({ name: 'hash_test' })
expect(results).toHaveLength(0)
await index2.close()
})
})
// ============= Service Filter =============
describe('source service filter', () => {
it('should filter by service', () => {
index.defineAggregate({
name: 'svc_test',
source: { type: NounType.Event, service: 'finance-app' },
groupBy: ['category'],
metrics: { count: { op: 'count' } }
})
index.onEntityAdded('e1', {
type: NounType.Event,
service: 'finance-app',
metadata: { category: 'food' }
})
index.onEntityAdded('e2', {
type: NounType.Event,
service: 'other-app',
metadata: { category: 'food' }
})
const results = index.queryAggregate({ name: 'svc_test' })
expect(results).toHaveLength(1)
expect(results[0].metrics.count).toBe(1)
})
})
// ============= Scale Test =============
describe('performance at scale', () => {
it('should handle 10,000 entities efficiently', () => {
index.defineAggregate({
name: 'scale_test',
source: { type: NounType.Event },
groupBy: ['category'],
metrics: {
total: { op: 'sum', field: 'amount' },
count: { op: 'count' }
}
})
const categories = ['food', 'transport', 'housing', 'entertainment', 'utilities']
const start = performance.now()
for (let i = 0; i < 10_000; i++) {
index.onEntityAdded(`e${i}`, {
type: NounType.Event,
metadata: {
category: categories[i % categories.length],
amount: Math.random() * 1000
}
})
}
const elapsed = performance.now() - start
// 10K entities should process in under 500ms (O(1) per entity)
expect(elapsed).toBeLessThan(500)
const results = index.queryAggregate({ name: 'scale_test' })
expect(results).toHaveLength(5)
// Each category should have 2000 entities
for (const r of results) {
expect(r.metrics.count).toBe(2000)
}
})
})
})

View file

@ -0,0 +1,211 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { AggregateMaterializer, MaterializerBrainAccess } from '../../../src/aggregation/materializer'
import { NounType } from '../../../src/types/graphTypes'
import type { AggregateDefinition, AggregateGroupState, MetricState } from '../../../src/types/brainy.types'
/**
* Creates a mock brain access object for testing materialization
* without a full Brainy instance.
*/
function createMockBrain(): MaterializerBrainAccess & {
adds: Array<{ data: string; type: NounType; metadata: Record<string, unknown>; id?: string; service?: string }>
updates: Array<{ id: string; data?: string; metadata?: Record<string, unknown>; merge?: boolean }>
} {
const adds: any[] = []
const updates: any[] = []
return {
adds,
updates,
async add(params) {
adds.push(params)
return `mat-${adds.length}`
},
async update(params) {
updates.push(params)
}
}
}
function createGroupState(
groupKey: Record<string, string | number>,
metrics: Record<string, MetricState>,
materializedEntityId?: string
): AggregateGroupState {
return {
groupKey,
metrics,
materializedEntityId,
lastUpdated: Date.now()
}
}
function createMetricState(sum: number, count: number, min: number, max: number): MetricState {
return { sum, count, min, max }
}
describe('AggregateMaterializer', () => {
let brain: ReturnType<typeof createMockBrain>
let materializer: AggregateMaterializer
const definition: AggregateDefinition = {
name: 'spending',
source: { type: NounType.Event },
groupBy: ['category'],
metrics: {
total: { op: 'sum', field: 'amount' },
count: { op: 'count' },
average: { op: 'avg', field: 'amount' },
highest: { op: 'max', field: 'amount' },
lowest: { op: 'min', field: 'amount' }
},
materialize: true
}
beforeEach(() => {
vi.useFakeTimers()
brain = createMockBrain()
materializer = new AggregateMaterializer(brain, 100) // 100ms debounce for tests
})
afterEach(() => {
materializer.close()
vi.useRealTimers()
})
it('should create a Measurement entity on first materialization', async () => {
const groupState = createGroupState(
{ category: 'food' },
{
total: createMetricState(150, 3, 10, 80),
count: createMetricState(3, 3, Infinity, -Infinity),
average: createMetricState(150, 3, 10, 80),
highest: createMetricState(150, 3, 10, 80),
lowest: createMetricState(150, 3, 10, 80)
}
)
materializer.scheduleMaterialize('spending', definition, { category: 'food' }, groupState)
// Should not have fired yet (debounced)
expect(brain.adds).toHaveLength(0)
// Advance timers past debounce
await vi.advanceTimersByTimeAsync(150)
expect(brain.adds).toHaveLength(1)
const added = brain.adds[0]
expect(added.type).toBe('measurement')
expect(added.service).toBe('brainy:aggregation')
expect(added.metadata.__aggregate).toBe('spending')
expect(added.metadata.category).toBe('food')
expect(added.metadata.total).toBe(150)
expect(added.metadata.count).toBe(3)
expect(added.metadata.average).toBe(50)
expect(added.metadata.highest).toBe(80)
expect(added.metadata.lowest).toBe(10)
expect(added.data).toContain('spending:')
expect(added.data).toContain('category=food')
})
it('should update existing entity when materializedEntityId is set', async () => {
const groupState = createGroupState(
{ category: 'food' },
{
total: createMetricState(200, 4, 10, 100),
count: createMetricState(4, 4, Infinity, -Infinity)
},
'existing-entity-id'
)
materializer.scheduleMaterialize('spending', definition, { category: 'food' }, groupState)
await vi.advanceTimersByTimeAsync(150)
expect(brain.adds).toHaveLength(0)
expect(brain.updates).toHaveLength(1)
expect(brain.updates[0].id).toBe('existing-entity-id')
expect(brain.updates[0].metadata!.total).toBe(200)
})
it('should debounce rapid updates', async () => {
const groupState1 = createGroupState(
{ category: 'food' },
{ total: createMetricState(100, 1, 100, 100), count: createMetricState(1, 1, Infinity, -Infinity) }
)
const groupState2 = createGroupState(
{ category: 'food' },
{ total: createMetricState(200, 2, 50, 150), count: createMetricState(2, 2, Infinity, -Infinity) }
)
materializer.scheduleMaterialize('spending', definition, { category: 'food' }, groupState1)
await vi.advanceTimersByTimeAsync(50)
materializer.scheduleMaterialize('spending', definition, { category: 'food' }, groupState2)
await vi.advanceTimersByTimeAsync(150)
// Only the second (latest) state should be materialized
expect(brain.adds).toHaveLength(1)
expect(brain.adds[0].metadata.total).toBe(200)
})
it('should not materialize when materialize=false', async () => {
const defNoMat: AggregateDefinition = {
...definition,
materialize: false
}
const groupState = createGroupState(
{ category: 'food' },
{ total: createMetricState(100, 1, 100, 100) }
)
materializer.scheduleMaterialize('spending', defNoMat, { category: 'food' }, groupState)
await vi.advanceTimersByTimeAsync(150)
expect(brain.adds).toHaveLength(0)
})
it('should not materialize when materialize=undefined', async () => {
const defNoMat: AggregateDefinition = {
...definition,
materialize: undefined
}
const groupState = createGroupState(
{ category: 'food' },
{ total: createMetricState(100, 1, 100, 100) }
)
materializer.scheduleMaterialize('spending', defNoMat, { category: 'food' }, groupState)
await vi.advanceTimersByTimeAsync(150)
expect(brain.adds).toHaveLength(0)
})
describe('flush', () => {
it('should immediately materialize all pending entries', async () => {
const groupState = createGroupState(
{ category: 'food' },
{ total: createMetricState(100, 1, 100, 100), count: createMetricState(1, 1, Infinity, -Infinity) }
)
materializer.scheduleMaterialize('spending', definition, { category: 'food' }, groupState)
expect(brain.adds).toHaveLength(0)
await materializer.flush()
expect(brain.adds).toHaveLength(1)
})
})
describe('close', () => {
it('should cancel pending timers without materializing', async () => {
const groupState = createGroupState(
{ category: 'food' },
{ total: createMetricState(100, 1, 100, 100) }
)
materializer.scheduleMaterialize('spending', definition, { category: 'food' }, groupState)
materializer.close()
await vi.advanceTimersByTimeAsync(200)
expect(brain.adds).toHaveLength(0)
})
})
})

View file

@ -0,0 +1,140 @@
import { describe, it, expect } from 'vitest'
import { bucketTimestamp, parseBucketRange } from '../../../src/aggregation/timeWindows'
describe('timeWindows', () => {
describe('bucketTimestamp', () => {
// Use a fixed timestamp: 2024-03-15 14:30:45 UTC
const ts = Date.UTC(2024, 2, 15, 14, 30, 45)
it('should bucket by hour', () => {
expect(bucketTimestamp(ts, 'hour')).toBe('2024-03-15T14')
})
it('should bucket by day', () => {
expect(bucketTimestamp(ts, 'day')).toBe('2024-03-15')
})
it('should bucket by week', () => {
// 2024-03-15 is a Friday in ISO week 11
expect(bucketTimestamp(ts, 'week')).toBe('2024-W11')
})
it('should bucket by month', () => {
expect(bucketTimestamp(ts, 'month')).toBe('2024-03')
})
it('should bucket by quarter', () => {
expect(bucketTimestamp(ts, 'quarter')).toBe('2024-Q1')
})
it('should bucket by year', () => {
expect(bucketTimestamp(ts, 'year')).toBe('2024')
})
it('should bucket by custom interval (300 seconds = 5 min)', () => {
const result = bucketTimestamp(ts, { seconds: 300 })
// Should floor to nearest 5-minute boundary
const parsed = new Date(result).getTime()
expect(parsed % (300 * 1000)).toBe(0)
expect(parsed).toBeLessThanOrEqual(ts)
expect(ts - parsed).toBeLessThan(300 * 1000)
})
it('should handle midnight correctly for day', () => {
const midnight = Date.UTC(2024, 0, 1, 0, 0, 0)
expect(bucketTimestamp(midnight, 'day')).toBe('2024-01-01')
})
it('should handle end of year for month', () => {
const dec = Date.UTC(2024, 11, 31, 23, 59, 59)
expect(bucketTimestamp(dec, 'month')).toBe('2024-12')
})
it('should handle Q4 correctly', () => {
const oct = Date.UTC(2024, 9, 1)
expect(bucketTimestamp(oct, 'quarter')).toBe('2024-Q4')
})
it('should handle Q2 correctly', () => {
const may = Date.UTC(2024, 4, 15)
expect(bucketTimestamp(may, 'quarter')).toBe('2024-Q2')
})
it('should handle week at year boundary', () => {
// Dec 31, 2024 is in ISO week 1 of 2025
const yearEnd = Date.UTC(2024, 11, 31)
const result = bucketTimestamp(yearEnd, 'week')
// Should be 2025-W01 (ISO standard: week containing the first Thursday)
expect(result).toBe('2025-W01')
})
it('should handle leap year Feb 29', () => {
const leapDay = Date.UTC(2024, 1, 29, 12, 0, 0)
expect(bucketTimestamp(leapDay, 'day')).toBe('2024-02-29')
expect(bucketTimestamp(leapDay, 'month')).toBe('2024-02')
})
})
describe('parseBucketRange', () => {
it('should parse hour range', () => {
const range = parseBucketRange('2024-03-15T14', 'hour')
expect(range.start).toBe(Date.UTC(2024, 2, 15, 14, 0, 0))
expect(range.end).toBe(Date.UTC(2024, 2, 15, 15, 0, 0))
})
it('should parse day range', () => {
const range = parseBucketRange('2024-03-15', 'day')
expect(range.start).toBe(Date.UTC(2024, 2, 15, 0, 0, 0))
expect(range.end).toBe(Date.UTC(2024, 2, 16, 0, 0, 0))
})
it('should parse week range', () => {
const range = parseBucketRange('2024-W11', 'week')
// ISO week 11 of 2024 starts on Monday March 11
expect(range.start).toBe(Date.UTC(2024, 2, 11, 0, 0, 0))
expect(range.end - range.start).toBe(7 * 86400_000) // 7 days
})
it('should parse month range', () => {
const range = parseBucketRange('2024-03', 'month')
expect(range.start).toBe(Date.UTC(2024, 2, 1, 0, 0, 0))
expect(range.end).toBe(Date.UTC(2024, 3, 1, 0, 0, 0))
})
it('should parse quarter range', () => {
const range = parseBucketRange('2024-Q1', 'quarter')
expect(range.start).toBe(Date.UTC(2024, 0, 1, 0, 0, 0))
expect(range.end).toBe(Date.UTC(2024, 3, 1, 0, 0, 0))
})
it('should parse year range', () => {
const range = parseBucketRange('2024', 'year')
expect(range.start).toBe(Date.UTC(2024, 0, 1, 0, 0, 0))
expect(range.end).toBe(Date.UTC(2025, 0, 1, 0, 0, 0))
})
it('should parse custom interval range', () => {
const ts = new Date(Date.UTC(2024, 2, 15, 14, 30, 0)).toISOString()
const range = parseBucketRange(ts, { seconds: 300 })
expect(range.end - range.start).toBe(300_000)
})
it('should handle Feb range correctly for leap year', () => {
const range = parseBucketRange('2024-02', 'month')
expect(range.start).toBe(Date.UTC(2024, 1, 1, 0, 0, 0))
expect(range.end).toBe(Date.UTC(2024, 2, 1, 0, 0, 0))
// Feb 2024 has 29 days (leap year)
expect(range.end - range.start).toBe(29 * 86400_000)
})
it('should roundtrip: bucket then parse contains original timestamp', () => {
const ts = Date.UTC(2024, 2, 15, 14, 30, 45)
for (const gran of ['hour', 'day', 'month', 'quarter', 'year'] as const) {
const bucket = bucketTimestamp(ts, gran)
const range = parseBucketRange(bucket, gran)
expect(ts).toBeGreaterThanOrEqual(range.start)
expect(ts).toBeLessThan(range.end)
}
})
})
})