feat: add v5.8.0 features - transactions, pagination, and comprehensive docs
**Transaction System (TIER 1.2)** - Atomic operations with automatic rollback - 36 unit tests + 35 integration tests passing - Full documentation in docs/transactions.md **Duplicate Check Optimization (TIER 1.4)** - Optimized from O(n) to O(log n) using GraphAdjacencyIndex - Uses LSM-tree for efficient lookups - Tests verify performance improvements **GraphIndex Pagination (TIER 1.5)** - Production-scale pagination for high-degree nodes - Backward compatible API - 18 pagination tests passing **Comprehensive Filter Documentation (TIER 1.6)** - Complete operator reference (15 operators) - Compound filters (anyOf, allOf, nested logic) - Common query patterns and troubleshooting guide - 642 lines of new documentation **README Updates** - Added Filter & Query Syntax Guide to Essential Reading - Added Transactions to Core Concepts section All changes tested and production-ready for v5.8.0 release. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
52e961760d
commit
e40fee39d8
21 changed files with 5657 additions and 182 deletions
220
tests/unit/brainy/relate-duplicate-optimization.test.ts
Normal file
220
tests/unit/brainy/relate-duplicate-optimization.test.ts
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
/**
|
||||
* Duplicate Relationship Check Optimization Tests
|
||||
*
|
||||
* Tests for v5.8.0 optimization that uses GraphAdjacencyIndex
|
||||
* for O(log n) duplicate detection instead of O(n) storage scan.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||
import { Brainy } from '../../../src/brainy.js'
|
||||
import { NounType, VerbType } from '../../../src/types/graphTypes.js'
|
||||
|
||||
describe('Duplicate Check Optimization', () => {
|
||||
let brain: Brainy
|
||||
|
||||
beforeEach(async () => {
|
||||
brain = new Brainy()
|
||||
await brain.init()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
// Cleanup is automatic with memory storage
|
||||
})
|
||||
|
||||
it('should detect duplicate relationships using GraphAdjacencyIndex', async () => {
|
||||
// Create two entities
|
||||
const personId = await brain.add({
|
||||
data: { name: 'Alice' },
|
||||
type: NounType.Person
|
||||
})
|
||||
|
||||
const orgId = await brain.add({
|
||||
data: { name: 'Acme Corp' },
|
||||
type: NounType.Organization
|
||||
})
|
||||
|
||||
// Create first relationship
|
||||
const relationId1 = await brain.relate({
|
||||
from: personId,
|
||||
to: orgId,
|
||||
type: VerbType.ParticipatesIn
|
||||
})
|
||||
|
||||
// Attempt to create duplicate relationship
|
||||
const relationId2 = await brain.relate({
|
||||
from: personId,
|
||||
to: orgId,
|
||||
type: VerbType.ParticipatesIn
|
||||
})
|
||||
|
||||
// Should return the same ID (duplicate detected)
|
||||
expect(relationId2).toBe(relationId1)
|
||||
|
||||
// Verify only one relationship exists
|
||||
const relations = await brain.getRelations({ from: personId })
|
||||
expect(relations).toHaveLength(1)
|
||||
expect(relations[0].id).toBe(relationId1)
|
||||
})
|
||||
|
||||
it('should allow different relationship types between same entities', async () => {
|
||||
const personId = await brain.add({
|
||||
data: { name: 'Bob' },
|
||||
type: NounType.Person
|
||||
})
|
||||
|
||||
const projectId = await brain.add({
|
||||
data: { name: 'Project X' },
|
||||
type: NounType.Thing
|
||||
})
|
||||
|
||||
// Create first relationship
|
||||
const relationId1 = await brain.relate({
|
||||
from: personId,
|
||||
to: projectId,
|
||||
type: VerbType.Creates
|
||||
})
|
||||
|
||||
// Create second relationship with different type (not a duplicate)
|
||||
const relationId2 = await brain.relate({
|
||||
from: personId,
|
||||
to: projectId,
|
||||
type: VerbType.Modifies
|
||||
})
|
||||
|
||||
// Should be different IDs (different verb types)
|
||||
expect(relationId2).not.toBe(relationId1)
|
||||
|
||||
// Verify both relationships exist
|
||||
const relations = await brain.getRelations({ from: personId })
|
||||
expect(relations).toHaveLength(2)
|
||||
// Both relations should exist with different IDs
|
||||
const relationIds = relations.map(r => r.id)
|
||||
expect(relationIds).toContain(relationId1)
|
||||
expect(relationIds).toContain(relationId2)
|
||||
})
|
||||
|
||||
it('should handle duplicate check with many relationships (performance)', async () => {
|
||||
// Create source entity
|
||||
const sourceId = await brain.add({
|
||||
data: { name: 'Hub Entity' },
|
||||
type: NounType.Thing
|
||||
})
|
||||
|
||||
// Create 50 target entities and relationships
|
||||
const targetIds: string[] = []
|
||||
for (let i = 0; i < 50; i++) {
|
||||
const targetId = await brain.add({
|
||||
data: { name: `Target ${i}` },
|
||||
type: NounType.Thing
|
||||
})
|
||||
targetIds.push(targetId)
|
||||
|
||||
await brain.relate({
|
||||
from: sourceId,
|
||||
to: targetId,
|
||||
type: VerbType.RelatesTo
|
||||
})
|
||||
}
|
||||
|
||||
// Now attempt to create duplicate with first target (should be fast with GraphIndex)
|
||||
const startTime = performance.now()
|
||||
const duplicateId = await brain.relate({
|
||||
from: sourceId,
|
||||
to: targetIds[0],
|
||||
type: VerbType.RelatesTo
|
||||
})
|
||||
const elapsed = performance.now() - startTime
|
||||
|
||||
// Should be fast with O(log n) GraphIndex lookup (< 10ms even with 50 relationships)
|
||||
expect(elapsed).toBeLessThan(10)
|
||||
|
||||
// Verify duplicate was detected
|
||||
const relations = await brain.getRelations({ from: sourceId })
|
||||
expect(relations).toHaveLength(50) // No duplicate created
|
||||
})
|
||||
|
||||
it('should use cached verb data for duplicate check', async () => {
|
||||
const entityA = await brain.add({
|
||||
data: { name: 'Entity A' },
|
||||
type: NounType.Thing
|
||||
})
|
||||
|
||||
const entityB = await brain.add({
|
||||
data: { name: 'Entity B' },
|
||||
type: NounType.Thing
|
||||
})
|
||||
|
||||
// Create relationship
|
||||
const relationId1 = await brain.relate({
|
||||
from: entityA,
|
||||
to: entityB,
|
||||
type: VerbType.RelatesTo
|
||||
})
|
||||
|
||||
// Access GraphIndex to ensure verb is cached
|
||||
const verbIds = await (brain as any).graphIndex.getVerbIdsBySource(entityA)
|
||||
expect(verbIds).toContain(relationId1)
|
||||
|
||||
// Attempt duplicate (should use cached verb)
|
||||
const startTime = performance.now()
|
||||
const relationId2 = await brain.relate({
|
||||
from: entityA,
|
||||
to: entityB,
|
||||
type: VerbType.RelatesTo
|
||||
})
|
||||
const elapsed = performance.now() - startTime
|
||||
|
||||
// Should be very fast with cached verb (< 5ms)
|
||||
expect(elapsed).toBeLessThan(5)
|
||||
expect(relationId2).toBe(relationId1)
|
||||
})
|
||||
|
||||
it('should handle duplicate check across multiple verb types efficiently', async () => {
|
||||
const person = await brain.add({
|
||||
data: { name: 'Charlie' },
|
||||
type: NounType.Person
|
||||
})
|
||||
|
||||
const org = await brain.add({
|
||||
data: { name: 'BigCorp' },
|
||||
type: NounType.Organization
|
||||
})
|
||||
|
||||
// Create different relationship types
|
||||
const rel1 = await brain.relate({
|
||||
from: person,
|
||||
to: org,
|
||||
type: VerbType.Affects
|
||||
})
|
||||
|
||||
const rel2 = await brain.relate({
|
||||
from: person,
|
||||
to: org,
|
||||
type: VerbType.Owns
|
||||
})
|
||||
|
||||
// Verify both relationships exist
|
||||
let relations = await brain.getRelations({ from: person })
|
||||
expect(relations).toHaveLength(2)
|
||||
const relationIds = relations.map(r => r.id)
|
||||
expect(relationIds).toContain(rel1)
|
||||
expect(relationIds).toContain(rel2)
|
||||
|
||||
// Attempt duplicate of first relationship type
|
||||
const startTime = performance.now()
|
||||
const duplicate = await brain.relate({
|
||||
from: person,
|
||||
to: org,
|
||||
type: VerbType.Affects
|
||||
})
|
||||
const elapsed = performance.now() - startTime
|
||||
|
||||
// Should detect duplicate efficiently (< 20ms)
|
||||
expect(elapsed).toBeLessThan(20)
|
||||
expect(duplicate).toBe(rel1)
|
||||
|
||||
// Verify still same number of relationships (no duplicate added)
|
||||
const finalRelations = await brain.getRelations({ from: person })
|
||||
expect(finalRelations.length).toBe(relations.length)
|
||||
})
|
||||
})
|
||||
343
tests/unit/graph/graphIndex-pagination.test.ts
Normal file
343
tests/unit/graph/graphIndex-pagination.test.ts
Normal file
|
|
@ -0,0 +1,343 @@
|
|||
/**
|
||||
* GraphAdjacencyIndex Pagination Tests (v5.8.0)
|
||||
*
|
||||
* Tests for pagination support in GraphIndex methods:
|
||||
* - getNeighbors() with limit/offset
|
||||
* - getVerbIdsBySource() with limit/offset
|
||||
* - getVerbIdsByTarget() with limit/offset
|
||||
*
|
||||
* Verifies backward compatibility and new pagination features
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { Brainy } from '../../../src/brainy.js'
|
||||
import { NounType, VerbType } from '../../../src/types/graphTypes.js'
|
||||
|
||||
describe('GraphAdjacencyIndex Pagination', () => {
|
||||
let brain: Brainy
|
||||
let centralId: string
|
||||
let neighborIds: string[]
|
||||
|
||||
beforeEach(async () => {
|
||||
brain = new Brainy()
|
||||
await brain.init()
|
||||
|
||||
// Create central entity
|
||||
centralId = await brain.add({
|
||||
data: { name: 'Central Hub' },
|
||||
type: NounType.Thing
|
||||
})
|
||||
|
||||
// Create 50 neighbor entities with relationships
|
||||
neighborIds = []
|
||||
for (let i = 0; i < 50; i++) {
|
||||
const neighborId = await brain.add({
|
||||
data: { name: `Neighbor ${i}`, index: i },
|
||||
type: NounType.Thing
|
||||
})
|
||||
neighborIds.push(neighborId)
|
||||
|
||||
// Create outgoing relationship from central to neighbor
|
||||
await brain.relate({
|
||||
from: centralId,
|
||||
to: neighborId,
|
||||
type: VerbType.RelatesTo
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
describe('getNeighbors() Pagination', () => {
|
||||
it('should return all neighbors without pagination (backward compatible)', async () => {
|
||||
const graphIndex = (brain as any).graphIndex
|
||||
|
||||
const neighbors = await graphIndex.getNeighbors(centralId)
|
||||
|
||||
// Should return all 50 neighbors
|
||||
expect(neighbors).toHaveLength(50)
|
||||
expect(neighbors.every((id: string) => neighborIds.includes(id))).toBe(true)
|
||||
})
|
||||
|
||||
it('should return all outgoing neighbors with direction parameter (backward compatible)', async () => {
|
||||
const graphIndex = (brain as any).graphIndex
|
||||
|
||||
// Old API: direction as string
|
||||
const neighbors = await graphIndex.getNeighbors(centralId, 'out')
|
||||
|
||||
expect(neighbors).toHaveLength(50)
|
||||
})
|
||||
|
||||
it('should paginate with limit only', async () => {
|
||||
const graphIndex = (brain as any).graphIndex
|
||||
|
||||
const page1 = await graphIndex.getNeighbors(centralId, { limit: 10 })
|
||||
|
||||
expect(page1).toHaveLength(10)
|
||||
expect(page1.every((id: string) => neighborIds.includes(id))).toBe(true)
|
||||
})
|
||||
|
||||
it('should paginate with offset only', async () => {
|
||||
const graphIndex = (brain as any).graphIndex
|
||||
|
||||
const all = await graphIndex.getNeighbors(centralId)
|
||||
const offsetResults = await graphIndex.getNeighbors(centralId, { offset: 10 })
|
||||
|
||||
// Offset 10 should return all after first 10
|
||||
expect(offsetResults).toHaveLength(all.length - 10)
|
||||
})
|
||||
|
||||
it('should paginate with both limit and offset', async () => {
|
||||
const graphIndex = (brain as any).graphIndex
|
||||
|
||||
const page1 = await graphIndex.getNeighbors(centralId, { limit: 10, offset: 0 })
|
||||
const page2 = await graphIndex.getNeighbors(centralId, { limit: 10, offset: 10 })
|
||||
const page3 = await graphIndex.getNeighbors(centralId, { limit: 10, offset: 20 })
|
||||
|
||||
// Each page should have 10 results
|
||||
expect(page1).toHaveLength(10)
|
||||
expect(page2).toHaveLength(10)
|
||||
expect(page3).toHaveLength(10)
|
||||
|
||||
// Pages should not overlap
|
||||
const page1Set = new Set(page1)
|
||||
const page2Set = new Set(page2)
|
||||
const page3Set = new Set(page3)
|
||||
|
||||
const overlap12 = page1.filter(id => page2Set.has(id))
|
||||
const overlap23 = page2.filter(id => page3Set.has(id))
|
||||
|
||||
expect(overlap12).toHaveLength(0)
|
||||
expect(overlap23).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('should handle offset beyond total count', async () => {
|
||||
const graphIndex = (brain as any).graphIndex
|
||||
|
||||
const results = await graphIndex.getNeighbors(centralId, { offset: 100 })
|
||||
|
||||
// Offset 100 > 50 total → empty array
|
||||
expect(results).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('should handle limit = 0', async () => {
|
||||
const graphIndex = (brain as any).graphIndex
|
||||
|
||||
const results = await graphIndex.getNeighbors(centralId, { limit: 0 })
|
||||
|
||||
expect(results).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('should paginate with direction filter', async () => {
|
||||
const graphIndex = (brain as any).graphIndex
|
||||
|
||||
// Get first 10 outgoing neighbors
|
||||
const page1 = await graphIndex.getNeighbors(centralId, {
|
||||
direction: 'out',
|
||||
limit: 10,
|
||||
offset: 0
|
||||
})
|
||||
|
||||
expect(page1).toHaveLength(10)
|
||||
})
|
||||
|
||||
it('should handle pagination with incoming direction', async () => {
|
||||
const graphIndex = (brain as any).graphIndex
|
||||
|
||||
// Create some incoming relationships
|
||||
const sourceId = await brain.add({
|
||||
data: { name: 'Source' },
|
||||
type: NounType.Thing
|
||||
})
|
||||
|
||||
await brain.relate({
|
||||
from: sourceId,
|
||||
to: centralId,
|
||||
type: VerbType.RelatesTo
|
||||
})
|
||||
|
||||
// Get incoming neighbors with pagination
|
||||
const incoming = await graphIndex.getNeighbors(centralId, {
|
||||
direction: 'in',
|
||||
limit: 10
|
||||
})
|
||||
|
||||
expect(incoming.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getVerbIdsBySource() Pagination', () => {
|
||||
it('should return all verb IDs without pagination (backward compatible)', async () => {
|
||||
const graphIndex = (brain as any).graphIndex
|
||||
|
||||
const verbIds = await graphIndex.getVerbIdsBySource(centralId)
|
||||
|
||||
// Should return all 50 verb IDs
|
||||
expect(verbIds).toHaveLength(50)
|
||||
})
|
||||
|
||||
it('should paginate verb IDs with limit', async () => {
|
||||
const graphIndex = (brain as any).graphIndex
|
||||
|
||||
const page1 = await graphIndex.getVerbIdsBySource(centralId, { limit: 10 })
|
||||
|
||||
expect(page1).toHaveLength(10)
|
||||
})
|
||||
|
||||
it('should paginate verb IDs with offset', async () => {
|
||||
const graphIndex = (brain as any).graphIndex
|
||||
|
||||
const all = await graphIndex.getVerbIdsBySource(centralId)
|
||||
const offsetResults = await graphIndex.getVerbIdsBySource(centralId, { offset: 10 })
|
||||
|
||||
expect(offsetResults).toHaveLength(all.length - 10)
|
||||
})
|
||||
|
||||
it('should paginate verb IDs with limit and offset', async () => {
|
||||
const graphIndex = (brain as any).graphIndex
|
||||
|
||||
const page1 = await graphIndex.getVerbIdsBySource(centralId, { limit: 10, offset: 0 })
|
||||
const page2 = await graphIndex.getVerbIdsBySource(centralId, { limit: 10, offset: 10 })
|
||||
const page3 = await graphIndex.getVerbIdsBySource(centralId, { limit: 10, offset: 20 })
|
||||
|
||||
// Each page should have 10 results
|
||||
expect(page1).toHaveLength(10)
|
||||
expect(page2).toHaveLength(10)
|
||||
expect(page3).toHaveLength(10)
|
||||
|
||||
// Pages should not overlap
|
||||
const combined = [...page1, ...page2, ...page3]
|
||||
const unique = new Set(combined)
|
||||
expect(unique.size).toBe(30) // All unique
|
||||
})
|
||||
|
||||
it('should handle pagination edge cases for verb IDs', async () => {
|
||||
const graphIndex = (brain as any).graphIndex
|
||||
|
||||
// Offset beyond total
|
||||
const beyondTotal = await graphIndex.getVerbIdsBySource(centralId, { offset: 100 })
|
||||
expect(beyondTotal).toHaveLength(0)
|
||||
|
||||
// Limit = 0
|
||||
const zeroLimit = await graphIndex.getVerbIdsBySource(centralId, { limit: 0 })
|
||||
expect(zeroLimit).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getVerbIdsByTarget() Pagination', () => {
|
||||
it('should return all verb IDs targeting an entity (backward compatible)', async () => {
|
||||
const graphIndex = (brain as any).graphIndex
|
||||
|
||||
// Pick a neighbor that's a target of relationships
|
||||
const targetId = neighborIds[0]
|
||||
const verbIds = await graphIndex.getVerbIdsByTarget(targetId)
|
||||
|
||||
// Should have at least 1 (the relationship from central)
|
||||
expect(verbIds.length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('should paginate verb IDs by target with limit', async () => {
|
||||
const graphIndex = (brain as any).graphIndex
|
||||
|
||||
// Create entity with many incoming relationships
|
||||
const popularTarget = await brain.add({
|
||||
data: { name: 'Popular Target' },
|
||||
type: NounType.Thing
|
||||
})
|
||||
|
||||
// Create 30 relationships pointing to it
|
||||
for (let i = 0; i < 30; i++) {
|
||||
const sourceId = await brain.add({
|
||||
data: { name: `Source ${i}` },
|
||||
type: NounType.Thing
|
||||
})
|
||||
await brain.relate({
|
||||
from: sourceId,
|
||||
to: popularTarget,
|
||||
type: VerbType.RelatesTo
|
||||
})
|
||||
}
|
||||
|
||||
// Paginate
|
||||
const page1 = await graphIndex.getVerbIdsByTarget(popularTarget, { limit: 10 })
|
||||
const page2 = await graphIndex.getVerbIdsByTarget(popularTarget, { limit: 10, offset: 10 })
|
||||
|
||||
expect(page1).toHaveLength(10)
|
||||
expect(page2).toHaveLength(10)
|
||||
|
||||
// No overlap
|
||||
const overlap = page1.filter((id: string) => page2.includes(id))
|
||||
expect(overlap).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Performance with Pagination', () => {
|
||||
it('should maintain sub-5ms performance with pagination', async () => {
|
||||
const graphIndex = (brain as any).graphIndex
|
||||
|
||||
// Multiple paginated queries should be fast
|
||||
const startTime = performance.now()
|
||||
|
||||
await graphIndex.getNeighbors(centralId, { limit: 10, offset: 0 })
|
||||
await graphIndex.getNeighbors(centralId, { limit: 10, offset: 10 })
|
||||
await graphIndex.getNeighbors(centralId, { limit: 10, offset: 20 })
|
||||
|
||||
const elapsed = performance.now() - startTime
|
||||
|
||||
// 3 paginated queries should complete in < 15ms (< 5ms each)
|
||||
expect(elapsed).toBeLessThan(15)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Real-World Use Cases', () => {
|
||||
it('should efficiently paginate through high-degree node', async () => {
|
||||
// Simulate popular entity with 100+ relationships
|
||||
const hub = await brain.add({
|
||||
data: { name: 'Popular Hub' },
|
||||
type: NounType.Thing
|
||||
})
|
||||
|
||||
// Create 100 relationships
|
||||
const targetIds: string[] = []
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const targetId = await brain.add({
|
||||
data: { name: `Target ${i}` },
|
||||
type: NounType.Thing
|
||||
})
|
||||
targetIds.push(targetId)
|
||||
await brain.relate({
|
||||
from: hub,
|
||||
to: targetId,
|
||||
type: VerbType.RelatesTo
|
||||
})
|
||||
}
|
||||
|
||||
const graphIndex = (brain as any).graphIndex
|
||||
|
||||
// Paginate in chunks of 25
|
||||
const chunks: string[][] = []
|
||||
for (let offset = 0; offset < 100; offset += 25) {
|
||||
const chunk = await graphIndex.getNeighbors(hub, {
|
||||
direction: 'out',
|
||||
limit: 25,
|
||||
offset
|
||||
})
|
||||
chunks.push(chunk)
|
||||
}
|
||||
|
||||
// Should have 4 chunks
|
||||
expect(chunks).toHaveLength(4)
|
||||
|
||||
// Each chunk should have 25 items
|
||||
chunks.forEach(chunk => {
|
||||
expect(chunk).toHaveLength(25)
|
||||
})
|
||||
|
||||
// All chunks combined should equal total
|
||||
const allNeighbors = chunks.flat()
|
||||
expect(allNeighbors).toHaveLength(100)
|
||||
|
||||
// No duplicates
|
||||
const uniqueNeighbors = new Set(allNeighbors)
|
||||
expect(uniqueNeighbors.size).toBe(100)
|
||||
})
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue