Some checks are pending
CI / Node 24 (push) Waiting to run
CI / Node 22 (push) Waiting to run
CI / Integration + conformance (Node 22) (push) Waiting to run
CI / Bun (latest) (push) Waiting to run
get(), relate() and update() each carried a "very large metadata" case that
parked an array of 1000 (or 100) elements in the metadata bag and asserted it
came back. The indexable-array bound refuses that shape at the write door now
— an array field mints one posting per element, so an unbounded array is an
unbounded write — and the three cases were failing on the refusal they should
have been pinning.
Each is rewritten to the law that replaced it, in two halves:
- a large SCALAR payload still round-trips whole through the door: a
10,000-character string, 100 sibling fields, a ten-deep nest walked to the
bottom, and an array sitting exactly ON the bound, checked first element
to last;
- an array one element OVER the bound refuses with MetadataArrayTooLargeError
carrying the field, the length and the bound, on the error object AND in
the message. update()'s refusal additionally proves the row is unchanged,
and relate()'s that no relation was written — refused means not written,
not written-then-skipped.
Every length is derived from the imported MAX_INDEXED_ARRAY_LENGTH; none is
typed as a number. That is what made the old cases fragile: 100 read as "over
the bound" and 1000 as "large", and both meanings changed under them when the
constant moved. These follow the constant instead.
490 lines
No EOL
15 KiB
TypeScript
490 lines
No EOL
15 KiB
TypeScript
/**
|
|
* Unit tests for Brainy.relate() method
|
|
* Tests all aspects of creating relationships between entities
|
|
*/
|
|
|
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
import { Brainy } from '../../../src/brainy'
|
|
import { MetadataArrayTooLargeError, MAX_INDEXED_ARRAY_LENGTH } from '../../../src/errors/brainyError'
|
|
import {
|
|
createAddParams,
|
|
createTestConfig,
|
|
} from '../../helpers/test-factory'
|
|
import {
|
|
assertCompletesWithin,
|
|
} from '../../helpers/test-assertions'
|
|
|
|
describe('Brainy.relate()', () => {
|
|
let brain: Brainy
|
|
let entity1Id: string
|
|
let entity2Id: string
|
|
let entity3Id: string
|
|
|
|
beforeEach(async () => {
|
|
brain = new Brainy(createTestConfig())
|
|
await brain.init()
|
|
|
|
// Create test entities for relationships
|
|
entity1Id = await brain.add(createAddParams({
|
|
data: 'Entity 1 - Person Alice',
|
|
type: 'person',
|
|
metadata: { name: 'Alice', role: 'developer' }
|
|
}))
|
|
|
|
entity2Id = await brain.add(createAddParams({
|
|
data: 'Entity 2 - Person Bob',
|
|
type: 'person',
|
|
metadata: { name: 'Bob', role: 'manager' }
|
|
}))
|
|
|
|
entity3Id = await brain.add(createAddParams({
|
|
data: 'Entity 3 - Project X',
|
|
type: 'project',
|
|
metadata: { name: 'Project X', status: 'active' }
|
|
}))
|
|
})
|
|
|
|
afterEach(async () => {
|
|
await brain.close()
|
|
})
|
|
|
|
describe('success paths', () => {
|
|
it('should create a relationship between two entities', async () => {
|
|
// Act
|
|
const relationId = await brain.relate({
|
|
from: entity1Id,
|
|
to: entity2Id,
|
|
type: 'worksWith'
|
|
})
|
|
|
|
// Assert
|
|
expect(relationId).toBeDefined()
|
|
expect(typeof relationId).toBe('string')
|
|
|
|
// Verify relationship exists
|
|
const relations = await brain.related({ from: entity1Id })
|
|
expect(relations.length).toBeGreaterThan(0)
|
|
expect(relations.some(r => r.to === entity2Id)).toBe(true)
|
|
})
|
|
|
|
it('should create relationship with weight', async () => {
|
|
// Act
|
|
await brain.relate({
|
|
from: entity1Id,
|
|
to: entity2Id,
|
|
type: 'likes',
|
|
weight: 0.8
|
|
})
|
|
|
|
// Assert
|
|
const relations = await brain.related({ from: entity1Id })
|
|
const relation = relations.find(r => r.to === entity2Id)
|
|
expect(relation).toBeDefined()
|
|
expect(relation!.weight).toBe(0.8)
|
|
})
|
|
|
|
it('should create relationship with metadata', async () => {
|
|
// Arrange
|
|
const metadata = {
|
|
since: '2024-01-01',
|
|
strength: 'strong',
|
|
notes: 'Worked on multiple projects'
|
|
}
|
|
|
|
// Act
|
|
await brain.relate({
|
|
from: entity1Id,
|
|
to: entity2Id,
|
|
type: 'worksWith',
|
|
metadata
|
|
})
|
|
|
|
// Assert
|
|
const relations = await brain.related({ from: entity1Id })
|
|
const relation = relations.find(r => r.to === entity2Id)
|
|
expect(relation).toBeDefined()
|
|
expect(relation!.metadata || {}).toMatchObject(metadata)
|
|
})
|
|
|
|
it('should create bidirectional relationship when specified', async () => {
|
|
// Act
|
|
await brain.relate({
|
|
from: entity1Id,
|
|
to: entity2Id,
|
|
type: 'friendOf',
|
|
bidirectional: true
|
|
})
|
|
|
|
// Assert - Check both directions
|
|
const forwardRelations = await brain.related({ from: entity1Id })
|
|
const reverseRelations = await brain.related({ from: entity2Id })
|
|
|
|
expect(forwardRelations.some(r => r.to === entity2Id)).toBe(true)
|
|
expect(reverseRelations.some(r => r.to === entity1Id)).toBe(true)
|
|
})
|
|
|
|
it('should create multiple relationships from same entity', async () => {
|
|
// Act
|
|
await brain.relate({
|
|
from: entity1Id,
|
|
to: entity2Id,
|
|
type: 'worksWith'
|
|
})
|
|
|
|
await brain.relate({
|
|
from: entity1Id,
|
|
to: entity3Id,
|
|
type: 'creates'
|
|
})
|
|
|
|
// Assert
|
|
const relations = await brain.related({ from: entity1Id })
|
|
expect(relations.length).toBe(2)
|
|
expect(relations.some(r => r.to === entity2Id)).toBe(true)
|
|
expect(relations.some(r => r.to === entity3Id)).toBe(true)
|
|
})
|
|
|
|
it('should create different relationship types between same entities', async () => {
|
|
// Act
|
|
await brain.relate({
|
|
from: entity1Id,
|
|
to: entity2Id,
|
|
type: 'worksWith'
|
|
})
|
|
|
|
await brain.relate({
|
|
from: entity1Id,
|
|
to: entity2Id,
|
|
type: 'reportsTo'
|
|
})
|
|
|
|
// Assert
|
|
const relations = await brain.related({ from: entity1Id })
|
|
const toEntity2 = relations.filter(r => r.to === entity2Id)
|
|
expect(toEntity2.length).toBe(2)
|
|
expect(toEntity2.some(r => r.type === 'worksWith')).toBe(true)
|
|
expect(toEntity2.some(r => r.type === 'reportsTo')).toBe(true)
|
|
})
|
|
|
|
it('should handle self-relationships', async () => {
|
|
// Act
|
|
await brain.relate({
|
|
from: entity1Id,
|
|
to: entity1Id,
|
|
type: 'relatedTo',
|
|
metadata: { type: 'self-reference' }
|
|
})
|
|
|
|
// Assert
|
|
const relations = await brain.related({ from: entity1Id })
|
|
const selfRelation = relations.find(r => r.to === entity1Id)
|
|
expect(selfRelation).toBeDefined()
|
|
expect(selfRelation!.metadata?.type).toBe('self-reference')
|
|
})
|
|
})
|
|
|
|
describe('error paths', () => {
|
|
it('should handle relating non-existent entities', async () => {
|
|
// Arrange
|
|
const fakeId = 'non-existent-123'
|
|
|
|
// Act & Assert - Should handle gracefully or throw
|
|
await expect(brain.relate({
|
|
from: fakeId,
|
|
to: entity2Id,
|
|
type: 'relatedTo'
|
|
})).rejects.toThrow()
|
|
})
|
|
|
|
it('should handle invalid relationship type', async () => {
|
|
// Act & Assert - Invalid type should throw validation error
|
|
await expect(brain.relate({
|
|
from: entity1Id,
|
|
to: entity2Id,
|
|
type: 'invalidType' as any
|
|
})).rejects.toThrow('invalid VerbType')
|
|
})
|
|
|
|
it('should handle missing required parameters', async () => {
|
|
// Act & Assert
|
|
await expect(brain.relate({
|
|
from: '',
|
|
to: entity2Id,
|
|
type: 'relatedTo'
|
|
} as any)).rejects.toThrow()
|
|
|
|
await expect(brain.relate({
|
|
from: entity1Id,
|
|
to: '',
|
|
type: 'relatedTo'
|
|
} as any)).rejects.toThrow()
|
|
})
|
|
})
|
|
|
|
describe('edge cases', () => {
|
|
it('should handle duplicate relationships', async () => {
|
|
// Act - Create same relationship twice
|
|
const id1 = await brain.relate({
|
|
from: entity1Id,
|
|
to: entity2Id,
|
|
type: 'worksWith',
|
|
weight: 0.5
|
|
})
|
|
|
|
const id2 = await brain.relate({
|
|
from: entity1Id,
|
|
to: entity2Id,
|
|
type: 'worksWith',
|
|
weight: 0.8
|
|
})
|
|
|
|
// Assert - Should prevent duplicates (v3.43.2 bug fix)
|
|
// Second call should return existing relationship ID instead of creating duplicate
|
|
expect(id1).toBe(id2)
|
|
|
|
const relations = await brain.related({ from: entity1Id })
|
|
const matches = relations.filter(r =>
|
|
r.to === entity2Id && r.type === 'worksWith'
|
|
)
|
|
expect(matches.length).toBe(1) // Only one relationship should exist
|
|
})
|
|
|
|
// THE INDEXABLE-ARRAY BOUND, from relate()'s side. This case used to pass a
|
|
// 100-element array through relate() and assert it came back — a length
|
|
// hardcoded either side of a bound it never named, so it read green or red
|
|
// purely by where the constant happened to sit. Both halves of the law are
|
|
// pinned here instead, and every length derives from
|
|
// MAX_INDEXED_ARRAY_LENGTH so the pin follows the constant.
|
|
it('should handle a large scalar metadata payload on a relation', async () => {
|
|
// Arrange — large in every dimension EXCEPT array length: a long string,
|
|
// many fields, and an array sitting exactly ON the bound.
|
|
const largeMetadata = {
|
|
atTheBound: Array.from({ length: MAX_INDEXED_ARRAY_LENGTH }, (_, i) => `item${i}`),
|
|
bigObject: Object.fromEntries(
|
|
Array.from({ length: 50 }, (_, i) => [`key${i}`, `value${i}`])
|
|
),
|
|
longString: 'x'.repeat(10_000)
|
|
}
|
|
|
|
// Act
|
|
await brain.relate({
|
|
from: entity1Id,
|
|
to: entity2Id,
|
|
type: 'relatedTo',
|
|
metadata: largeMetadata
|
|
})
|
|
|
|
// Assert — the payload comes back whole, first element to last
|
|
const relations = await brain.related({ from: entity1Id })
|
|
const relation = relations.find(r => r.to === entity2Id)
|
|
expect(relation).toBeDefined()
|
|
expect(relation!.metadata?.atTheBound).toHaveLength(MAX_INDEXED_ARRAY_LENGTH)
|
|
expect(relation!.metadata?.atTheBound[0]).toBe('item0')
|
|
expect(relation!.metadata?.atTheBound[MAX_INDEXED_ARRAY_LENGTH - 1])
|
|
.toBe(`item${MAX_INDEXED_ARRAY_LENGTH - 1}`)
|
|
expect(Object.keys(relation!.metadata?.bigObject)).toHaveLength(50)
|
|
expect(relation!.metadata?.longString).toHaveLength(10_000)
|
|
})
|
|
|
|
it('should refuse a relation metadata array over the indexing bound, by name', async () => {
|
|
// Arrange
|
|
const overTheBound = MAX_INDEXED_ARRAY_LENGTH + 1
|
|
|
|
// Act
|
|
const err = await brain
|
|
.relate({
|
|
from: entity1Id,
|
|
to: entity3Id,
|
|
type: 'relatedTo',
|
|
metadata: { bigArray: new Array(overTheBound).fill('item') }
|
|
})
|
|
.catch((e: any) => e)
|
|
|
|
// Assert — the field, the length and the bound, on the error and in the
|
|
// message, so a handler can report or repair without parsing prose.
|
|
expect(err).toBeInstanceOf(MetadataArrayTooLargeError)
|
|
expect(err.field).toBe('bigArray')
|
|
expect(err.length).toBe(overTheBound)
|
|
expect(err.limit).toBe(MAX_INDEXED_ARRAY_LENGTH)
|
|
expect(err.message).toContain('bigArray')
|
|
expect(err.message).toContain(String(overTheBound))
|
|
expect(err.message).toContain(String(MAX_INDEXED_ARRAY_LENGTH))
|
|
|
|
// Refused means not written: no relation of this shape exists.
|
|
const relations = await brain.related({ from: entity1Id })
|
|
expect(relations.some(r => r.to === entity3Id && r.metadata?.bigArray)).toBe(false)
|
|
})
|
|
|
|
it('should handle special characters in metadata', async () => {
|
|
// Arrange
|
|
const specialMetadata = {
|
|
emoji: '🚀🎉💻',
|
|
unicode: '你好世界',
|
|
special: '!@#$%^&*()',
|
|
newlines: 'line1\nline2\nline3'
|
|
}
|
|
|
|
// Act
|
|
await brain.relate({
|
|
from: entity1Id,
|
|
to: entity2Id,
|
|
type: 'relatedTo',
|
|
metadata: specialMetadata
|
|
})
|
|
|
|
// Assert
|
|
const relations = await brain.related({ from: entity1Id })
|
|
const relation = relations.find(r => r.to === entity2Id)
|
|
expect(relation!.metadata || {}).toMatchObject(specialMetadata)
|
|
})
|
|
|
|
})
|
|
|
|
describe('performance', () => {
|
|
it('should create relationships quickly', async () => {
|
|
// Act & Assert
|
|
await assertCompletesWithin(
|
|
() => brain.relate({
|
|
from: entity1Id,
|
|
to: entity2Id,
|
|
type: 'relatedTo'
|
|
}),
|
|
50, // Should complete within 50ms
|
|
'Relate operation'
|
|
)
|
|
})
|
|
|
|
it('should handle batch relationship creation efficiently', async () => {
|
|
// Arrange - Create more entities
|
|
const entityIds: string[] = []
|
|
for (let i = 0; i < 20; i++) {
|
|
const id = await brain.add(createAddParams({
|
|
data: `Entity ${i}`,
|
|
type: 'thing'
|
|
}))
|
|
entityIds.push(id)
|
|
}
|
|
|
|
// Act - Create relationships between all pairs
|
|
const start = performance.now()
|
|
const relates: Promise<string>[] = []
|
|
for (let i = 0; i < entityIds.length - 1; i++) {
|
|
for (let j = i + 1; j < entityIds.length; j++) {
|
|
relates.push(brain.relate({
|
|
from: entityIds[i],
|
|
to: entityIds[j],
|
|
type: 'relatedTo'
|
|
}))
|
|
}
|
|
}
|
|
await Promise.all(relates)
|
|
const duration = performance.now() - start
|
|
|
|
// Assert
|
|
const relationCount = (entityIds.length * (entityIds.length - 1)) / 2
|
|
const opsPerSecond = (relationCount / duration) * 1000
|
|
expect(opsPerSecond).toBeGreaterThan(100) // At least 100 relations/second
|
|
})
|
|
})
|
|
|
|
describe('consistency', () => {
|
|
it('should maintain relationship consistency', async () => {
|
|
// Act
|
|
await brain.relate({
|
|
from: entity1Id,
|
|
to: entity2Id,
|
|
type: 'worksWith',
|
|
metadata: { department: 'Engineering' }
|
|
})
|
|
|
|
// Assert - Multiple queries should return same data
|
|
const relations1 = await brain.related({ from: entity1Id })
|
|
const relations2 = await brain.related({ from: entity1Id })
|
|
|
|
expect(relations1.length).toBe(relations2.length)
|
|
const rel1 = relations1.find(r => r.to === entity2Id)
|
|
const rel2 = relations2.find(r => r.to === entity2Id)
|
|
|
|
expect(rel1).toBeDefined()
|
|
expect(rel2).toBeDefined()
|
|
expect(rel1!.type).toBe(rel2!.type)
|
|
expect(rel1!.metadata?.department).toBe('Engineering')
|
|
expect(rel2!.metadata?.department).toBe('Engineering')
|
|
})
|
|
|
|
it('should preserve relationships after entity updates', async () => {
|
|
// Arrange
|
|
await brain.relate({
|
|
from: entity1Id,
|
|
to: entity2Id,
|
|
type: 'worksWith'
|
|
})
|
|
|
|
// Act - Update an entity
|
|
await brain.update({
|
|
id: entity1Id,
|
|
metadata: { updated: true },
|
|
merge: true
|
|
})
|
|
|
|
// Assert - Relationship should still exist
|
|
const relations = await brain.related({ from: entity1Id })
|
|
expect(relations.some(r => r.to === entity2Id)).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe('graph traversal', () => {
|
|
it('should support basic graph traversal', async () => {
|
|
// Arrange - Create a chain: entity1 -> entity2 -> entity3
|
|
await brain.relate({
|
|
from: entity1Id,
|
|
to: entity2Id,
|
|
type: 'precedes'
|
|
})
|
|
|
|
await brain.relate({
|
|
from: entity2Id,
|
|
to: entity3Id,
|
|
type: 'precedes'
|
|
})
|
|
|
|
// Act - Get relationships step by step
|
|
const step1 = await brain.related({ from: entity1Id })
|
|
const entity2Relations = await brain.related({ from: entity2Id })
|
|
|
|
// Assert
|
|
expect(step1.some(r => r.to === entity2Id)).toBe(true)
|
|
expect(entity2Relations.some(r => r.to === entity3Id)).toBe(true)
|
|
})
|
|
|
|
it('should handle circular relationships', async () => {
|
|
// Arrange - Create a cycle: entity1 -> entity2 -> entity3 -> entity1
|
|
await brain.relate({
|
|
from: entity1Id,
|
|
to: entity2Id,
|
|
type: 'relatedTo'
|
|
})
|
|
|
|
await brain.relate({
|
|
from: entity2Id,
|
|
to: entity3Id,
|
|
type: 'relatedTo'
|
|
})
|
|
|
|
await brain.relate({
|
|
from: entity3Id,
|
|
to: entity1Id,
|
|
type: 'relatedTo'
|
|
})
|
|
|
|
// Assert - All relationships should exist
|
|
const rel1 = await brain.related({ from: entity1Id })
|
|
const rel2 = await brain.related({ from: entity2Id })
|
|
const rel3 = await brain.related({ from: entity3Id })
|
|
|
|
expect(rel1.some(r => r.to === entity2Id)).toBe(true)
|
|
expect(rel2.some(r => r.to === entity3Id)).toBe(true)
|
|
expect(rel3.some(r => r.to === entity1Id)).toBe(true)
|
|
})
|
|
})
|
|
}) |