Brainy 8.0 makes subtype required by default on every public write path
(`add`, `addMany`, `update`, `relate`, `relateMany`, `updateRelation`,
import). Per the locked C-1 contract, every entity and relation gets a
non-empty subtype string by the time the storage layer sees it.
OPT-OUT REMAINS FULLY SUPPORTED
The runtime flag is still consumer-controlled. Three opt-out paths
cover migration / legacy fixtures / typed escape:
- `new Brainy({ requireSubtype: false })` — last-resort: turn off the
contract entirely. Recommended only for migration windows or test
fixtures that legitimately can't supply a subtype.
- `new Brainy({ requireSubtype: { except: [NounType.Thing, ...] } })` —
per-type allowlist: strict everywhere except the listed types.
- `brain.requireSubtype(type, options)` — per-type registration with
optional vocabulary. Composes with the brain-wide flag.
Default is now `true`. Opt-out is explicit and documented; nothing
silently degrades.
TEST SWEEP
Bulk-applied `requireSubtype: false` to every `new Brainy({...})` call
site across 120 test files. Three sed patterns covered the shapes:
- `new Brainy({` → `new Brainy({ requireSubtype: false,`
- `new Brainy<T>({` → `new Brainy<T>({ requireSubtype: false,`
- `new Brainy()` → `new Brainy({ requireSubtype: false })`
tests/helpers/test-factory.ts → createTestConfig() defaults
`requireSubtype: false` so test files using the helper inherit the
opt-out without per-site edits.
The test sites that DO exercise subtype semantics (the
subtype-and-facets suite, the strict-mode-self-test suite, the verb-
subtype-and-enforcement suite, etc.) already pass real subtypes — they
were the 7.30.x acceptance tests for this contract. Those tests
continue to pass unchanged.
CHANGES
src/brainy.ts
- normalizeConfig() — `requireSubtype` default `false` → `true`.
Comment refreshed to document the three opt-out paths.
tests/* (120 files)
- Bulk-edited brain construction sites. No functional test changes; the
opt-out preserves the test author's original intent.
tests/helpers/test-factory.ts
- createTestConfig() base config gains `requireSubtype: false`.
NO-OP for consumers who were already passing subtype on every write.
For consumers who weren't, the upgrade path is one of the three opt-out
forms above. Migration recipe documented in 8.0 release notes (next
commit).
VERIFICATION
- npx tsc --noEmit: clean
- npm test: 1408 / 1409 (same pre-existing race-condition outstanding;
no other regressions from the flip)
334 lines
12 KiB
TypeScript
334 lines
12 KiB
TypeScript
/**
|
|
* Count Synchronization Integration Tests (v4.1.2)
|
|
*
|
|
* Tests for Bug #1 and Bug #2: Count synchronization failures during add() and relate()
|
|
* This validates that counts.json is properly updated when entities and relationships are created
|
|
*
|
|
* NO MOCKS - Real integration tests with actual storage
|
|
*/
|
|
|
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
import { Brainy } from '../../src/brainy.js'
|
|
import { NounType, VerbType } from '../../src/types/graphTypes.js'
|
|
import { rebuildCounts } from '../../src/utils/rebuildCounts.js'
|
|
import * as fs from 'fs/promises'
|
|
import * as path from 'path'
|
|
|
|
describe('Count Synchronization (Bug Fix v4.1.2)', () => {
|
|
let brain: Brainy
|
|
const testPath = './test-brainy-counts-sync'
|
|
|
|
beforeEach(async () => {
|
|
// Clean up test directory
|
|
try {
|
|
await fs.rm(testPath, { recursive: true, force: true })
|
|
} catch {
|
|
// Ignore if doesn't exist
|
|
}
|
|
|
|
// Create fresh Brainy instance
|
|
brain = new Brainy({ requireSubtype: false,
|
|
storage: { type: 'filesystem', path: testPath }
|
|
})
|
|
await brain.init()
|
|
})
|
|
|
|
afterEach(async () => {
|
|
// Clean up test directory
|
|
try {
|
|
await fs.rm(testPath, { recursive: true, force: true })
|
|
} catch {
|
|
// Ignore cleanup errors
|
|
}
|
|
})
|
|
|
|
describe('Noun Count Synchronization', () => {
|
|
it('should update counts.json when adding a single entity', async () => {
|
|
// Add entity
|
|
const id = await brain.add({
|
|
data: 'Test Entity',
|
|
type: NounType.Document
|
|
})
|
|
|
|
// Flush to disk
|
|
await brain.flush()
|
|
|
|
// Read counts.json
|
|
const countsPath = path.join(testPath, '_system', 'counts.json')
|
|
const countsRaw = await fs.readFile(countsPath, 'utf8')
|
|
const counts = JSON.parse(countsRaw)
|
|
|
|
// Verify counts
|
|
expect(counts.totalNounCount).toBe(1)
|
|
expect(counts.entityCounts[NounType.Document]).toBe(1)
|
|
})
|
|
|
|
it('should update counts.json when adding multiple entities of same type', async () => {
|
|
// Add 3 entities of same type
|
|
await brain.add({ data: 'Entity 1', type: NounType.Document })
|
|
await brain.add({ data: 'Entity 2', type: NounType.Document })
|
|
await brain.add({ data: 'Entity 3', type: NounType.Document })
|
|
|
|
// Flush to disk
|
|
await brain.flush()
|
|
|
|
// Read counts.json
|
|
const countsPath = path.join(testPath, '_system', 'counts.json')
|
|
const countsRaw = await fs.readFile(countsPath, 'utf8')
|
|
const counts = JSON.parse(countsRaw)
|
|
|
|
// Verify counts
|
|
expect(counts.totalNounCount).toBe(3)
|
|
expect(counts.entityCounts[NounType.Document]).toBe(3)
|
|
})
|
|
|
|
it('should update counts.json when adding multiple entities of different types', async () => {
|
|
// Add entities of different types
|
|
await brain.add({ data: 'Document 1', type: NounType.Document })
|
|
await brain.add({ data: 'Person 1', type: NounType.Person })
|
|
await brain.add({ data: 'Document 2', type: NounType.Document })
|
|
await brain.add({ data: 'Organization 1', type: NounType.Organization })
|
|
|
|
// Flush to disk
|
|
await brain.flush()
|
|
|
|
// Read counts.json
|
|
const countsPath = path.join(testPath, '_system', 'counts.json')
|
|
const countsRaw = await fs.readFile(countsPath, 'utf8')
|
|
const counts = JSON.parse(countsRaw)
|
|
|
|
// Verify counts
|
|
expect(counts.totalNounCount).toBe(4)
|
|
expect(counts.entityCounts[NounType.Document]).toBe(2)
|
|
expect(counts.entityCounts[NounType.Person]).toBe(1)
|
|
expect(counts.entityCounts[NounType.Organization]).toBe(1)
|
|
})
|
|
|
|
it('should match find() results with counts.json', async () => {
|
|
// Add entities
|
|
await brain.add({ data: 'Entity 1', type: NounType.Document })
|
|
await brain.add({ data: 'Entity 2', type: NounType.Person })
|
|
await brain.add({ data: 'Entity 3', type: NounType.Document })
|
|
|
|
// Flush
|
|
await brain.flush()
|
|
|
|
// Get all entities using find with high limit
|
|
const entities = await brain.find({ limit: 1000 })
|
|
|
|
// Read counts
|
|
const countsPath = path.join(testPath, '_system', 'counts.json')
|
|
const countsRaw = await fs.readFile(countsPath, 'utf8')
|
|
const counts = JSON.parse(countsRaw)
|
|
|
|
// CRITICAL: Counts must match actual data
|
|
expect(counts.totalNounCount).toBe(entities.length)
|
|
expect(counts.totalNounCount).toBe(3)
|
|
})
|
|
})
|
|
|
|
describe('Verb Count Synchronization', () => {
|
|
it('should update counts.json when creating a single relationship', async () => {
|
|
// Create entities
|
|
const id1 = await brain.add({ data: 'Person 1', type: NounType.Person })
|
|
const id2 = await brain.add({ data: 'Person 2', type: NounType.Person })
|
|
|
|
// Create relationship
|
|
await brain.relate({
|
|
from: id1,
|
|
to: id2,
|
|
type: VerbType.FriendOf
|
|
})
|
|
|
|
// Flush to disk
|
|
await brain.flush()
|
|
|
|
// Read counts.json
|
|
const countsPath = path.join(testPath, '_system', 'counts.json')
|
|
const countsRaw = await fs.readFile(countsPath, 'utf8')
|
|
const counts = JSON.parse(countsRaw)
|
|
|
|
// Verify verb counts
|
|
expect(counts.totalVerbCount).toBe(1)
|
|
expect(counts.verbCounts[VerbType.FriendOf]).toBe(1)
|
|
})
|
|
|
|
it('should update counts.json when creating multiple relationships of same type', async () => {
|
|
// Create entities
|
|
const id1 = await brain.add({ data: 'Person 1', type: NounType.Person })
|
|
const id2 = await brain.add({ data: 'Person 2', type: NounType.Person })
|
|
const id3 = await brain.add({ data: 'Person 3', type: NounType.Person })
|
|
|
|
// Create relationships
|
|
await brain.relate({ from: id1, to: id2, type: VerbType.FriendOf })
|
|
await brain.relate({ from: id2, to: id3, type: VerbType.FriendOf })
|
|
|
|
// Flush to disk
|
|
await brain.flush()
|
|
|
|
// Read counts.json
|
|
const countsPath = path.join(testPath, '_system', 'counts.json')
|
|
const countsRaw = await fs.readFile(countsPath, 'utf8')
|
|
const counts = JSON.parse(countsRaw)
|
|
|
|
// Verify verb counts
|
|
expect(counts.totalVerbCount).toBe(2)
|
|
expect(counts.verbCounts[VerbType.FriendOf]).toBe(2)
|
|
})
|
|
|
|
it('should update counts.json when creating multiple relationships of different types', async () => {
|
|
// Create entities
|
|
const personId = await brain.add({ data: 'John', type: NounType.Person })
|
|
const docId = await brain.add({ data: 'Resume', type: NounType.Document })
|
|
const orgId = await brain.add({ data: 'Company', type: NounType.Organization })
|
|
|
|
// Create relationships of different types
|
|
await brain.relate({ from: personId, to: orgId, type: VerbType.WorksWith })
|
|
await brain.relate({ from: personId, to: docId, type: VerbType.Creates })
|
|
|
|
// Flush to disk
|
|
await brain.flush()
|
|
|
|
// Read counts.json
|
|
const countsPath = path.join(testPath, '_system', 'counts.json')
|
|
const countsRaw = await fs.readFile(countsPath, 'utf8')
|
|
const counts = JSON.parse(countsRaw)
|
|
|
|
// Verify verb counts
|
|
expect(counts.totalVerbCount).toBe(2)
|
|
expect(counts.verbCounts[VerbType.WorksWith]).toBe(1)
|
|
expect(counts.verbCounts[VerbType.Creates]).toBe(1)
|
|
})
|
|
})
|
|
|
|
describe('Batch Operations', () => {
|
|
it('should update counts.json during addMany()', async () => {
|
|
// Add multiple entities via batch
|
|
await brain.addMany({
|
|
items: [
|
|
{ data: 'Entity 1', type: NounType.Document },
|
|
{ data: 'Entity 2', type: NounType.Person },
|
|
{ data: 'Entity 3', type: NounType.Document }
|
|
]
|
|
})
|
|
|
|
// Flush to disk
|
|
await brain.flush()
|
|
|
|
// Read counts.json
|
|
const countsPath = path.join(testPath, '_system', 'counts.json')
|
|
const countsRaw = await fs.readFile(countsPath, 'utf8')
|
|
const counts = JSON.parse(countsRaw)
|
|
|
|
// Verify counts
|
|
expect(counts.totalNounCount).toBe(3)
|
|
expect(counts.entityCounts[NounType.Document]).toBe(2)
|
|
expect(counts.entityCounts[NounType.Person]).toBe(1)
|
|
})
|
|
|
|
it('should update counts.json during relateMany()', async () => {
|
|
// Create entities
|
|
const id1 = await brain.add({ data: 'Person 1', type: NounType.Person })
|
|
const id2 = await brain.add({ data: 'Person 2', type: NounType.Person })
|
|
const id3 = await brain.add({ data: 'Person 3', type: NounType.Person })
|
|
|
|
// Create multiple relationships via batch
|
|
await brain.relateMany({
|
|
items: [
|
|
{ from: id1, to: id2, type: VerbType.FriendOf },
|
|
{ from: id2, to: id3, type: VerbType.FriendOf }
|
|
]
|
|
})
|
|
|
|
// Flush to disk
|
|
await brain.flush()
|
|
|
|
// Read counts.json
|
|
const countsPath = path.join(testPath, '_system', 'counts.json')
|
|
const countsRaw = await fs.readFile(countsPath, 'utf8')
|
|
const counts = JSON.parse(countsRaw)
|
|
|
|
// Verify verb counts
|
|
expect(counts.totalVerbCount).toBe(2)
|
|
expect(counts.verbCounts[VerbType.FriendOf]).toBe(2)
|
|
})
|
|
})
|
|
|
|
describe('rebuildCounts Utility', () => {
|
|
it('should rebuild counts from corrupted state', async () => {
|
|
// Add entities
|
|
await brain.add({ data: 'Entity 1', type: NounType.Document })
|
|
await brain.add({ data: 'Entity 2', type: NounType.Person })
|
|
await brain.add({ data: 'Entity 3', type: NounType.Document })
|
|
|
|
// Flush to disk
|
|
await brain.flush()
|
|
|
|
// Manually corrupt counts.json (simulate the bug)
|
|
const countsPath = path.join(testPath, '_system', 'counts.json')
|
|
const corruptedCounts = {
|
|
entityCounts: {},
|
|
verbCounts: {},
|
|
totalNounCount: 0,
|
|
totalVerbCount: 0,
|
|
lastUpdated: new Date().toISOString()
|
|
}
|
|
await fs.writeFile(countsPath, JSON.stringify(corruptedCounts, null, 2))
|
|
|
|
// Force storage to reload corrupted counts by updating in-memory counts
|
|
// This simulates the scenario where counts are out of sync
|
|
;(brain.storage as any).totalNounCount = 0
|
|
;(brain.storage as any).totalVerbCount = 0
|
|
;(brain.storage as any).entityCounts = new Map()
|
|
;(brain.storage as any).verbCounts = new Map()
|
|
|
|
// Rebuild counts
|
|
const result = await rebuildCounts(brain.storage)
|
|
|
|
// Verify rebuild results
|
|
expect(result.nounCount).toBe(3)
|
|
expect(result.entityCounts.get(NounType.Document)).toBe(2)
|
|
expect(result.entityCounts.get(NounType.Person)).toBe(1)
|
|
|
|
// Verify counts.json was updated
|
|
const countsRaw = await fs.readFile(countsPath, 'utf8')
|
|
const counts = JSON.parse(countsRaw)
|
|
expect(counts.totalNounCount).toBe(3)
|
|
expect(counts.entityCounts[NounType.Document]).toBe(2)
|
|
expect(counts.entityCounts[NounType.Person]).toBe(1)
|
|
})
|
|
})
|
|
|
|
describe('Import Operations', () => {
|
|
it('should update counts.json during import', async () => {
|
|
// Create simple test data as JSON
|
|
const testData = {
|
|
entities: [
|
|
{ id: '1', name: 'Entity 1', type: 'document' },
|
|
{ id: '2', name: 'Entity 2', type: 'person' },
|
|
{ id: '3', name: 'Entity 3', type: 'document' }
|
|
]
|
|
}
|
|
|
|
// Import data
|
|
const result = await brain.import(testData, {
|
|
format: 'json',
|
|
createEntities: true,
|
|
createRelationships: false
|
|
})
|
|
|
|
// Flush to disk
|
|
await brain.flush()
|
|
|
|
// Read counts.json
|
|
const countsPath = path.join(testPath, '_system', 'counts.json')
|
|
const countsRaw = await fs.readFile(countsPath, 'utf8')
|
|
const counts = JSON.parse(countsRaw)
|
|
|
|
// Verify counts are updated (import may create additional VFS entities)
|
|
// So we check that totalNounCount >= graphNodesCreated
|
|
expect(counts.totalNounCount).toBeGreaterThanOrEqual(result.stats.graphNodesCreated)
|
|
expect(counts.totalNounCount).toBeGreaterThan(0)
|
|
})
|
|
})
|
|
})
|