2025-11-12 12:13:35 -08:00
import { describe , it , expect , beforeEach , afterEach } from 'vitest'
import { Brainy } from '../../src/brainy.js'
import { NounType , VerbType } from '../../src/types/graphTypes.js'
import * as fs from 'node:fs'
import * as path from 'node:path'
describe ( 'Batch Import with Immediate Relations (v5.7.3 Fix)' , ( ) = > {
let brain : Brainy
let testDir : string
beforeEach ( async ( ) = > {
// Create test directory
testDir = path . join ( process . cwd ( ) , 'test-batch-relations-' + Date . now ( ) )
fs . mkdirSync ( testDir , { recursive : true } )
// Initialize brain
feat(8.0)!: flip requireSubtype default to true (BRAINY-8.0-SUBTYPE-CONTRACT § C-1)
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)
2026-06-09 14:58:25 -07:00
brain = new Brainy ( { requireSubtype : false ,
2026-08-25 10:47:51 -07:00
storage : { type : 'filesystem' , path : testDir } ,
2025-11-12 12:13:35 -08:00
dimensions : 384
} )
await brain . init ( )
} )
afterEach ( async ( ) = > {
// Cleanup
try {
await brain . close ( )
} catch ( err ) {
// Ignore close errors
}
try {
fs . rmSync ( testDir , { recursive : true , force : true } )
} catch ( err ) {
// Ignore cleanup errors
}
} )
it ( 'should create 372 entities and immediately query them for relationships (exact bug scenario)' , async ( ) = > {
// Simulate the exact PDF import scenario from bug report
// 1. Create document entity (source)
const documentId = await brain . add ( {
data : 'TfT~Sapient Species.pdf' ,
type : NounType . Document ,
metadata : {
filename : 'TfT~Sapient Species.pdf' ,
format : 'pdf'
}
} )
expect ( documentId ) . toBeTruthy ( )
// 2. Batch create 372 extracted entities (simulating entity extraction)
const entityParams = Array ( 372 ) . fill ( null ) . map ( ( _ , i ) = > ( {
data : ` Extracted Entity ${ i } ` ,
type : NounType . Thing ,
feat(8.0): reserved-field enforcement — reservedFieldPolicy defaults to throw
An untyped (JS) caller that smuggles a Brainy-reserved field (confidence,
weight, subtype, visibility, service, createdBy, noun/verb, data, createdAt,
updatedAt, _rev) inside a write-path metadata bag previously got a silent
remap-or-drop — a class of bug where confidence-evolution writes no-oped for
weeks before being caught on read-back. 8.0 closes this with no silent failures.
- New BrainyConfig.reservedFieldPolicy: 'throw' | 'warn' | 'remap' (default 'throw').
'throw' rejects the write naming every offending key + its correct write path;
'warn' remaps with a one-shot per-key warning; 'remap' is the legacy silent path.
- Central enforceReservedPolicy gate wired into all four remap methods (add,
update, relate, updateRelation) so live calls AND their transact()/with()
mirrors honor it. Single-source reservedWritePath guidance shared by throw+warn.
- 'warn' now warns for EVERY reserved key (closes the gap where only
system-managed fields warned). Dead warnDropped* helpers removed.
- Import pipeline migrated to route reserved values (confidence/weight/subtype)
through dedicated params and strip reserved keys from extractor/customMetadata
bags via the canonical split*MetadataRecord helpers — imports no longer trip
the default throw.
- Tests: new reservedFieldPolicy matrix (throw/warn/remap across every write
path + transact); remap-correctness suite reframed as opt-in 'remap'; shared
test-factory no longer emits reserved keys in custom metadata.
2026-06-20 15:37:21 -07:00
// `confidence` is a reserved field — pass it as the dedicated param, not
// inside the metadata bag (8.0 reservedFieldPolicy defaults to 'throw').
confidence : 0.9 ,
2025-11-12 12:13:35 -08:00
metadata : {
extractedFrom : 'TfT~Sapient Species.pdf' ,
feat(8.0): reserved-field enforcement — reservedFieldPolicy defaults to throw
An untyped (JS) caller that smuggles a Brainy-reserved field (confidence,
weight, subtype, visibility, service, createdBy, noun/verb, data, createdAt,
updatedAt, _rev) inside a write-path metadata bag previously got a silent
remap-or-drop — a class of bug where confidence-evolution writes no-oped for
weeks before being caught on read-back. 8.0 closes this with no silent failures.
- New BrainyConfig.reservedFieldPolicy: 'throw' | 'warn' | 'remap' (default 'throw').
'throw' rejects the write naming every offending key + its correct write path;
'warn' remaps with a one-shot per-key warning; 'remap' is the legacy silent path.
- Central enforceReservedPolicy gate wired into all four remap methods (add,
update, relate, updateRelation) so live calls AND their transact()/with()
mirrors honor it. Single-source reservedWritePath guidance shared by throw+warn.
- 'warn' now warns for EVERY reserved key (closes the gap where only
system-managed fields warned). Dead warnDropped* helpers removed.
- Import pipeline migrated to route reserved values (confidence/weight/subtype)
through dedicated params and strip reserved keys from extractor/customMetadata
bags via the canonical split*MetadataRecord helpers — imports no longer trip
the default throw.
- Tests: new reservedFieldPolicy matrix (throw/warn/remap across every write
path + transact); remap-correctness suite reframed as opt-in 'remap'; shared
test-factory no longer emits reserved keys in custom metadata.
2026-06-20 15:37:21 -07:00
entityNumber : i
2025-11-12 12:13:35 -08:00
}
} ) )
const addResult = await brain . addMany ( {
items : entityParams ,
continueOnError : true
} )
expect ( addResult . successful . length ) . toBe ( 372 )
expect ( addResult . failed . length ) . toBe ( 0 )
// 3. IMMEDIATELY create provenance links (this is where v5.7.0/v5.7.1/v5.7.2 failed)
// brain.relateMany() → brain.relate() → brain.get() must succeed
const provenanceParams = addResult . successful . map ( entityId = > ( {
from : documentId ,
to : entityId ,
type : VerbType . Contains ,
metadata : {
relationshipType : 'provenance' ,
evidence : 'Extracted from PDF'
}
} ) )
// THIS SHOULD NOT THROW "Source entity not found" error
const relationIds = await brain . relateMany ( {
items : provenanceParams ,
continueOnError : true
} )
expect ( relationIds . length ) . toBe ( 372 )
// 4. Verify all relationships exist (use higher limit to get all 372)
2026-06-11 14:51:00 -07:00
const relationships = await brain . related ( { from : documentId , limit : 500 } )
2025-11-12 12:13:35 -08:00
expect ( relationships . length ) . toBe ( 372 )
} )
it ( 'should handle batch create + immediate single relate (simplified scenario)' , async ( ) = > {
// Create 100 entities in batch
const entities = Array ( 100 ) . fill ( null ) . map ( ( _ , i ) = > ( {
data : ` Entity ${ i } ` ,
type : NounType . Person ,
metadata : { index : i }
} ) )
const addResult = await brain . addMany ( {
items : entities ,
continueOnError : true
} )
expect ( addResult . successful . length ) . toBe ( 100 )
// IMMEDIATELY try to relate first two entities (common pattern)
const relationId = await brain . relate ( {
from : addResult . successful [ 0 ] ,
to : addResult.successful [ 1 ] ,
type : VerbType . FriendOf
} )
expect ( relationId ) . toBeTruthy ( )
// Verify relationship exists
2026-06-11 14:51:00 -07:00
const relations = await brain . related ( addResult . successful [ 0 ] )
2025-11-12 12:13:35 -08:00
expect ( relations . length ) . toBe ( 1 )
expect ( relations [ 0 ] . to ) . toBe ( addResult . successful [ 1 ] )
} )
it ( 'should handle concurrent batch operations with cross-batch relationships' , async ( ) = > {
// Create three batches in parallel
const batch1 = Array ( 50 ) . fill ( null ) . map ( ( _ , i ) = > ( {
data : ` Batch1-Entity ${ i } ` ,
type : NounType . Document
} ) )
const batch2 = Array ( 50 ) . fill ( null ) . map ( ( _ , i ) = > ( {
data : ` Batch2-Entity ${ i } ` ,
type : NounType . Organization
} ) )
const batch3 = Array ( 50 ) . fill ( null ) . map ( ( _ , i ) = > ( {
data : ` Batch3-Entity ${ i } ` ,
type : NounType . Location
} ) )
// Create all batches concurrently
const [ result1 , result2 , result3 ] = await Promise . all ( [
brain . addMany ( { items : batch1 , continueOnError : true } ) ,
brain . addMany ( { items : batch2 , continueOnError : true } ) ,
brain . addMany ( { items : batch3 , continueOnError : true } )
] )
expect ( result1 . successful . length ) . toBe ( 50 )
expect ( result2 . successful . length ) . toBe ( 50 )
expect ( result3 . successful . length ) . toBe ( 50 )
// Immediately create cross-batch relationships
const crossBatchRelations = [ ]
for ( let i = 0 ; i < 10 ; i ++ ) {
crossBatchRelations . push ( {
from : result1 . successful [ i ] ,
to : result2.successful [ i ] ,
type : VerbType . RelatedTo
} )
crossBatchRelations . push ( {
from : result2 . successful [ i ] ,
to : result3.successful [ i ] ,
type : VerbType . RelatedTo
} )
}
// THIS SHOULD NOT FAIL with "Source entity not found"
const relationIds = await brain . relateMany ( {
items : crossBatchRelations ,
continueOnError : true
} )
expect ( relationIds . length ) . toBe ( 20 )
} )
it ( 'should ensure type cache is populated after addMany' , async ( ) = > {
// Create entities
const entities = Array ( 100 ) . fill ( null ) . map ( ( _ , i ) = > ( {
data : ` Test ${ i } ` ,
type : NounType . Concept
} ) )
const addResult = await brain . addMany ( {
items : entities ,
continueOnError : true
} )
// Immediately query all entities (tests type cache population)
for ( const id of addResult . successful ) {
const entity = await brain . get ( id )
expect ( entity ) . not . toBeNull ( )
expect ( entity ? . type ) . toBe ( NounType . Concept )
}
} )
it ( 'should handle flush after batch operations' , async ( ) = > {
// Create entities
const entities = Array ( 50 ) . fill ( null ) . map ( ( _ , i ) = > ( {
data : ` FlushTest ${ i } ` ,
type : NounType . Thing
} ) )
const addResult = await brain . addMany ( {
items : entities ,
continueOnError : true
} )
// Explicit flush (this is what ImportCoordinator does)
await brain . flush ( )
// After flush, write-through cache should be cleared but entities still queryable
for ( const id of addResult . successful ) {
const entity = await brain . get ( id )
expect ( entity ) . not . toBeNull ( )
}
} )
it ( 'should handle VFS-style structure generation after batch import' , async ( ) = > {
// Simulate VFS structure generation workflow:
// 1. Create root collection
const rootId = await brain . add ( {
data : 'Import Collection' ,
type : NounType . Collection ,
metadata : { isRoot : true }
} )
// 2. Batch create child entities
const children = Array ( 100 ) . fill ( null ) . map ( ( _ , i ) = > ( {
data : ` Child ${ i } ` ,
type : NounType . Thing ,
metadata : { parentCollection : rootId }
} ) )
const addResult = await brain . addMany ( {
items : children ,
continueOnError : true
} )
// 3. IMMEDIATELY create hierarchical relationships (VFS tree structure)
const hierarchyRelations = addResult . successful . map ( childId = > ( {
from : rootId ,
to : childId ,
type : VerbType . Contains ,
metadata : { hierarchyType : 'vfs-structure' }
} ) )
// THIS IS WHERE v5.7.0/v5.7.1/v5.7.2 FAILED
const relationIds = await brain . relateMany ( {
items : hierarchyRelations ,
continueOnError : true
} )
expect ( relationIds . length ) . toBe ( 100 )
// 4. Verify structure
2026-06-11 14:51:00 -07:00
const childrenRelations = await brain . related ( rootId )
2025-11-12 12:13:35 -08:00
expect ( childrenRelations . length ) . toBe ( 100 )
} )
it ( 'should handle the exact error case from bug report: brain.relate after brain.addMany' , async ( ) = > {
// This test replicates the EXACT code path that failed in v5.7.2
// ImportCoordinator: addMany() → relateMany() → relate() → get() → "Source entity not found"
// 1. Create source entity
const sourceId = await brain . add ( {
data : 'Source Document' ,
type : NounType . Document
} )
// 2. Batch create targets
const targets = Array ( 10 ) . fill ( null ) . map ( ( _ , i ) = > ( {
data : ` Target ${ i } ` ,
type : NounType . Thing
} ) )
const addResult = await brain . addMany ( {
items : targets ,
continueOnError : true
} )
expect ( addResult . successful . length ) . toBe ( 10 )
// 3. IMMEDIATELY call brain.relate (not relateMany, to test the exact path)
for ( const targetId of addResult . successful ) {
// This is the exact call that fails in v5.7.2:
// brain.relate() → brain.get(from) → "Source entity not found"
const relationId = await brain . relate ( {
from : sourceId ,
to : targetId ,
type : VerbType . Contains
} )
expect ( relationId ) . toBeTruthy ( )
}
} )
} )