The factory loudly rejects every REMOVED pre-8.0 path key, but an unknown
nested `config` object (e.g. `storage: { config: { baseDir } }` — a shape
that was never supported) fell through SILENTLY to the zero-config default
directory. Every instance constructed with such a shape wrote to ONE shared
on-disk root while its caller believed each had its own — found live when
two integration tests' brains shared a store across an entire
single-process CI run and a health probe refused on the foreign edges it
sampled. A nested `config` carrying any path-shaped key now throws the same
loud migration error, naming the canonical `path` rename. The two tests are
repaired to the supported shape (and now actually test isolated stores, for
the first time since 8.0).
325 lines
10 KiB
TypeScript
325 lines
10 KiB
TypeScript
/**
|
|
* Read-After-Write Consistency Integration Tests (v5.7.2)
|
|
*
|
|
* These tests verify the fix for the critical v5.7.x bug where:
|
|
* - brain.add() would create an entity
|
|
* - brain.relate() would immediately try to verify the entity exists
|
|
* - brain.get() would return null (entity not yet queryable)
|
|
* - Error thrown: "Source entity 19f3f6dd-2102-4b60-956c-bfc1d8213838 not found"
|
|
*
|
|
* The write-through cache in BaseStorage now guarantees that entities are
|
|
* immediately queryable after add() returns, fixing this race condition.
|
|
*
|
|
* Test Coverage:
|
|
* - brain.add() → brain.get() (basic read-after-write)
|
|
* - brain.add() → brain.relate() (the actual bug scenario)
|
|
* - Rapid entity creation + relationship creation (import workflow)
|
|
* - Update then read
|
|
* - Delete then verify deleted
|
|
*/
|
|
|
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
import { Brainy } from '../../src/brainy.js'
|
|
import { NounType, VerbType } from '../../src/types/graphTypes.js'
|
|
import { tmpdir } from 'os'
|
|
import { join } from 'path'
|
|
import { rmSync } from 'fs'
|
|
|
|
describe('Read-After-Write Consistency (v5.7.2 Bug Fix)', () => {
|
|
let brain: Brainy
|
|
let testDir: string
|
|
|
|
beforeEach(async () => {
|
|
// Create unique test directory for each test
|
|
testDir = join(tmpdir(), `brainy-consistency-${Date.now()}-${Math.random().toString(36).substring(7)}`)
|
|
|
|
brain = new Brainy({ requireSubtype: false,
|
|
storage: { type: 'filesystem', path: testDir },
|
|
dimensions: 384
|
|
})
|
|
|
|
await brain.init()
|
|
})
|
|
|
|
afterEach(async () => {
|
|
// Cleanup
|
|
try {
|
|
await brain.close()
|
|
} catch (err) {
|
|
// Ignore close errors
|
|
}
|
|
|
|
try {
|
|
rmSync(testDir, { recursive: true, force: true })
|
|
} catch (err) {
|
|
// Ignore cleanup errors
|
|
}
|
|
})
|
|
|
|
it('should allow brain.add() followed by immediate brain.get()', async () => {
|
|
// This tests the core read-after-write guarantee
|
|
const entityId = await brain.add({
|
|
data: 'Test Entity for Immediate Read',
|
|
type: NounType.Thing
|
|
})
|
|
|
|
// Immediately query - should NOT return null
|
|
const entity = await brain.get(entityId)
|
|
|
|
expect(entity).not.toBeNull()
|
|
expect(entity?.id).toBe(entityId)
|
|
expect(entity?.data).toBe('Test Entity for Immediate Read')
|
|
expect(entity?.type).toBe(NounType.Thing)
|
|
})
|
|
|
|
it('should allow brain.add() followed by immediate brain.relate() - THE BUG FIX', async () => {
|
|
// This is the EXACT bug scenario from the v5.7.x report
|
|
// ImportCoordinator would:
|
|
// 1. Create an entity with brain.add()
|
|
// 2. Immediately create a provenance link with brain.relate()
|
|
// 3. brain.relate() calls brain.get() to verify entities exist
|
|
// 4. brain.get() would return null → "Source entity not found" error
|
|
|
|
const sourceId = await brain.add({
|
|
data: 'Source Document',
|
|
type: NounType.Document,
|
|
metadata: { filename: 'test.pdf' }
|
|
})
|
|
|
|
const targetId = await brain.add({
|
|
data: 'Extracted Entity',
|
|
type: NounType.Thing,
|
|
metadata: { extractedFrom: 'test.pdf' }
|
|
})
|
|
|
|
// This should NOT throw "Source entity not found" or "Target entity not found"
|
|
// The write-through cache ensures both entities are immediately queryable
|
|
const relationId = await brain.relate({
|
|
from: sourceId,
|
|
to: targetId,
|
|
type: VerbType.Contains,
|
|
metadata: {
|
|
relationshipType: 'provenance',
|
|
evidence: 'Extracted from test.pdf'
|
|
}
|
|
})
|
|
|
|
expect(relationId).toBeTruthy()
|
|
expect(typeof relationId).toBe('string')
|
|
|
|
// Verify relationship exists and is correct
|
|
const relations = await brain.related(sourceId)
|
|
expect(relations).toHaveLength(1)
|
|
expect(relations[0].to).toBe(targetId)
|
|
expect(relations[0].type).toBe(VerbType.Contains)
|
|
})
|
|
|
|
it('should handle rapid entity creation + relationship creation (import scenario)', async () => {
|
|
// Simulate the import workflow from the bug report:
|
|
// - Extract 372 entities from PDF
|
|
// - Create entities rapidly
|
|
// - Create provenance relationships immediately after
|
|
// - v5.7.x would fail with "Source entity not found"
|
|
|
|
const documentId = await brain.add({
|
|
data: 'Test PDF Document',
|
|
type: NounType.Document,
|
|
metadata: { filename: 'large-import.pdf', pages: 50 }
|
|
})
|
|
|
|
const entityIds: string[] = []
|
|
const startTime = Date.now()
|
|
|
|
// Create 100 entities rapidly (simulates extraction phase)
|
|
for (let i = 0; i < 100; i++) {
|
|
const entityId = await brain.add({
|
|
data: `Entity ${i} extracted from PDF`,
|
|
type: NounType.Thing,
|
|
metadata: {
|
|
entityIndex: i,
|
|
extractedFrom: 'large-import.pdf',
|
|
page: Math.floor(i / 2) + 1
|
|
}
|
|
})
|
|
entityIds.push(entityId)
|
|
}
|
|
|
|
console.log(`Created 100 entities in ${Date.now() - startTime}ms`)
|
|
|
|
// Create provenance relationships immediately after entity creation
|
|
// This is where v5.7.x would fail
|
|
const relStartTime = Date.now()
|
|
for (let i = 0; i < entityIds.length; i++) {
|
|
await brain.relate({
|
|
from: documentId,
|
|
to: entityIds[i],
|
|
type: VerbType.Contains,
|
|
metadata: {
|
|
relationshipType: 'provenance',
|
|
extractionOrder: i
|
|
}
|
|
})
|
|
}
|
|
|
|
console.log(`Created 100 relationships in ${Date.now() - relStartTime}ms`)
|
|
|
|
// Verify all relationships created successfully
|
|
const relations = await brain.related(documentId)
|
|
expect(relations.length).toBe(100)
|
|
|
|
// Verify each entity is still queryable
|
|
for (const id of entityIds) {
|
|
const entity = await brain.get(id)
|
|
expect(entity).not.toBeNull()
|
|
expect(entity?.id).toBe(id)
|
|
}
|
|
})
|
|
|
|
it('should support read-after-update', async () => {
|
|
// Create entity
|
|
const entityId = await brain.add({
|
|
data: 'Original Data',
|
|
type: NounType.Thing,
|
|
metadata: { version: 1 }
|
|
})
|
|
|
|
// Update immediately
|
|
await brain.update({
|
|
id: entityId,
|
|
metadata: { version: 2, updated: true }
|
|
})
|
|
|
|
// Read immediately - should see updated data
|
|
const entity = await brain.get(entityId)
|
|
expect(entity).not.toBeNull()
|
|
expect(entity?.metadata?.version).toBe(2)
|
|
expect(entity?.metadata?.updated).toBe(true)
|
|
})
|
|
|
|
it('should verify entity is deleted after brain.remove()', async () => {
|
|
// Create entity
|
|
const entityId = await brain.add({
|
|
data: 'Entity to be deleted',
|
|
type: NounType.Thing
|
|
})
|
|
|
|
// Verify it exists
|
|
const beforeDelete = await brain.get(entityId)
|
|
expect(beforeDelete).not.toBeNull()
|
|
|
|
// Delete
|
|
await brain.remove(entityId)
|
|
|
|
// Verify it's deleted (should return null)
|
|
const afterDelete = await brain.get(entityId)
|
|
expect(afterDelete).toBeNull()
|
|
})
|
|
|
|
it('should handle complex relationship graph creation', async () => {
|
|
// Create a complex graph:
|
|
// Document → contains → Concept1
|
|
// → Concept2
|
|
// → Concept3
|
|
// Concept1 → relatedTo → Concept2
|
|
// Concept2 → relatedTo → Concept3
|
|
// All created in rapid succession
|
|
|
|
const documentId = await brain.add({
|
|
data: 'Complex Document',
|
|
type: NounType.Document
|
|
})
|
|
|
|
const concept1Id = await brain.add({
|
|
data: 'Concept 1',
|
|
type: NounType.Concept
|
|
})
|
|
|
|
const concept2Id = await brain.add({
|
|
data: 'Concept 2',
|
|
type: NounType.Concept
|
|
})
|
|
|
|
const concept3Id = await brain.add({
|
|
data: 'Concept 3',
|
|
type: NounType.Concept
|
|
})
|
|
|
|
// Create containment relationships
|
|
await brain.relate({ from: documentId, to: concept1Id, type: VerbType.Contains })
|
|
await brain.relate({ from: documentId, to: concept2Id, type: VerbType.Contains })
|
|
await brain.relate({ from: documentId, to: concept3Id, type: VerbType.Contains })
|
|
|
|
// Create concept relationships
|
|
await brain.relate({ from: concept1Id, to: concept2Id, type: VerbType.RelatedTo })
|
|
await brain.relate({ from: concept2Id, to: concept3Id, type: VerbType.RelatedTo })
|
|
|
|
// Verify all relationships exist
|
|
const docRelations = await brain.related(documentId)
|
|
expect(docRelations.length).toBe(3)
|
|
|
|
const concept1Relations = await brain.related(concept1Id)
|
|
expect(concept1Relations.length).toBeGreaterThan(0)
|
|
|
|
const concept2Relations = await brain.related(concept2Id)
|
|
expect(concept2Relations.length).toBeGreaterThan(0)
|
|
})
|
|
|
|
it('should handle the exact v5.7.x bug scenario with UUID from report', async () => {
|
|
// The bug report mentioned entity ID: 19f3f6dd-2102-4b60-956c-bfc1d8213838
|
|
// Let's use our own UUIDs but replicate the exact sequence
|
|
|
|
// Step 1: PDF import creates document entity
|
|
const documentId = await brain.add({
|
|
data: 'TfT~Sapient Species.pdf',
|
|
type: NounType.Document,
|
|
metadata: {
|
|
filename: 'TfT~Sapient Species.pdf',
|
|
size: 2036.8 * 1024,
|
|
format: 'pdf'
|
|
}
|
|
})
|
|
|
|
// Step 2: Extract 372 entities (we'll do 10 for speed)
|
|
const extractedIds: string[] = []
|
|
for (let i = 0; i < 10; i++) {
|
|
const id = await brain.add({
|
|
data: `Species ${i}`,
|
|
type: NounType.Thing,
|
|
metadata: {
|
|
extractedFrom: 'TfT~Sapient Species.pdf',
|
|
entityIndex: i
|
|
}
|
|
})
|
|
extractedIds.push(id)
|
|
}
|
|
|
|
// Step 3: VFSStructureGenerator creates provenance links
|
|
// This is where "Source entity not found" would occur
|
|
for (const entityId of extractedIds) {
|
|
const relationId = await brain.relate({
|
|
from: documentId,
|
|
to: entityId,
|
|
type: VerbType.Contains,
|
|
metadata: {
|
|
relationshipType: 'provenance',
|
|
evidence: 'Extracted from PDF'
|
|
}
|
|
})
|
|
expect(relationId).toBeTruthy()
|
|
}
|
|
|
|
// Step 4: Verify all entities are queryable
|
|
const doc = await brain.get(documentId)
|
|
expect(doc).not.toBeNull()
|
|
|
|
for (const id of extractedIds) {
|
|
const entity = await brain.get(id)
|
|
expect(entity).not.toBeNull()
|
|
expect(entity?.id).toBe(id)
|
|
}
|
|
|
|
// Step 5: Verify relationships exist
|
|
const relations = await brain.related(documentId)
|
|
expect(relations.length).toBe(10)
|
|
})
|
|
})
|