feat(recovery): the catchup verdict is consumed; verb rows go live; the metadata rebuild goes online
Three cures on the JS metadata index, one seam:
- THE CATCHUP WIRING. The index computed its three-way watermark verdict at
open and nothing consumed it — after a crash + adopt reopen, find() served
the pre-crash index while canonical reads and counts recovered (caught by
the lifecycle lane's first run). The open path now consumes the verdict:
'adopt' is a no-op, 'catchup' folds the fact window (stamped, committed]
through the index legs — nouns and verbs, remove-then-add, one mechanism
for add and update — and 'rescan' runs the explicit rebuild, each narrated.
The lane's Ch4–6 release-blocking marker comes off: the contract holds.
Bonus root-cause: close() never stamped the projection watermarks (only
flush() did), so any close without a prior flush verdicted a needless
'rescan' on reopen — both doors now stamp.
- THE LIVE VERB PATH. Verb rows entered the metadata index only via rebuild
walks, so every rebuilt store minted phantom/stale verb postings from its
first live relate(). relate()/unrelate()/updateRelation() and remove()'s
cascade now post/retract the verb's row in the same commit as the graph
leg — transact() planners mirror identically — using the exact record
shape the rebuild walk uses, so live and rebuilt populations agree.
- THE ONLINE REBUILD. rebuild() was clear-then-walk — every metadata read
empty for the duration. rebuildMetadataIndexOnline builds a fresh manager
beside the serving one (shared identity, in-memory build, dual-write via
a shadow seam with zero call-site changes), atomically swaps the
reference, and persists exactly once post-swap. A find() polled ~200x
during a 2k-noun rebuild never dropped below its baseline.
repairIndex({ rebuild: ['metadata'] }) uses it automatically.
2026-08-25 10:01:56 -07:00
/ * *
* @module tests / integration / verb - metadata - rows
* @description THE LIVE VERB PATH pins . Before this train , verb rows entered
* the metadata index ONLY via ` MetadataIndexManager.rebuild() ` ' s canonical
* walk — every relate ( ) / unrelate ( ) / updateRelation ( ) call , and every
* remove ( ) - cascaded relationship , left the metadata index blind to verb
* writes until the next rebuild . This file pins that ` relate() ` ,
* ` unrelate() ` , ` updateRelation() ` , ` remove() ` ' s cascade , and their
* ` transact() ` mirrors now post / retract the SAME verb rows a rebuild would
* derive from canonical ( ADR - 007 A4 : one mechanism for add / update , live and
* rebuilt ) .
* /
process . env . BRAINY_DETERMINISTIC_EMBEDDINGS = 'true'
import { describe , it , expect , beforeEach , afterEach } from 'vitest'
import { Brainy } from '../../src/brainy.js'
import { NounType , VerbType } from '../../src/types/graphTypes.js'
import type { MetadataIndexManager } from '../../src/utils/metadataIndex.js'
/ * * T h e J S m e t a d a t a - i n d e x m a n a g e r b a c k i n g a m e m o r y - s t o r a g e b r a i n i n t h e s e
* tests ( feature - detected in production code via ` instanceof
* MetadataIndexManager ` ; a narrow test-only reach-in here, matching the
* existing idiom in tests / integration / find - where - zero . test . ts and
* tests / integration / level - field - shadow . test . ts ) . * /
function metadataIndexOf ( brain : Brainy < any > ) : MetadataIndexManager {
return ( brain as unknown as { metadataIndex : MetadataIndexManager } ) . metadataIndex
}
describe ( 'verb metadata rows — the live path matches the rebuild walk' , ( ) = > {
let brain : Brainy < any >
beforeEach ( async ( ) = > {
brain = new Brainy ( { requireSubtype : false , storage : { type : 'memory' } , silent : true } )
await brain . init ( )
} )
afterEach ( async ( ) = > {
await brain . close ( )
} )
async function addPerson ( label : string ) : Promise < string > {
return brain . add ( {
data : ` person ${ label } ` ,
type : NounType . Person ,
metadata : { label }
} )
}
it ( '(a) relate() posts a metadata-index-backed verb row a query can find' , async ( ) = > {
const a = await addPerson ( 'a' )
const b = await addPerson ( 'b' )
const relId = await brain . relate ( {
from : a , to : b , type : VerbType . WorksWith , metadata : { role : 'lead' }
} )
// Read it back the SAME way a rebuild-sourced row is queried — the
// manager's own posting lookup, keyed on the custom field the caller wrote.
const index = metadataIndexOf ( brain )
expect ( await index . getIds ( 'role' , 'lead' ) ) . toEqual ( [ relId ] )
} )
it ( '(b) unrelate() retracts the row' , async ( ) = > {
const a = await addPerson ( 'a' )
const b = await addPerson ( 'b' )
const relId = await brain . relate ( {
from : a , to : b , type : VerbType . WorksWith , metadata : { role : 'lead' }
} )
const index = metadataIndexOf ( brain )
expect ( await index . getIds ( 'role' , 'lead' ) ) . toEqual ( [ relId ] )
// Flush BEFORE retracting the field's only occurrence: this durably
// persists the 'role' column (a segment on disk/in the store), so the
// post-retraction query below reads "this field exists, zero live
// postings" (→ []) rather than "this field has never been written"
// (→ FIELD_NOT_INDEXED) — an orthogonal column-store characteristic
// (an unflushed field with its last live posting removed reverts to
// unknown), not a D2 behavior.
await brain . flush ( )
await brain . unrelate ( relId )
expect ( await index . getIds ( 'role' , 'lead' ) ) . toEqual ( [ ] )
} )
it ( '(c) updateRelation({ metadata }) leaves exactly the new values' , async ( ) = > {
const a = await addPerson ( 'a' )
const b = await addPerson ( 'b' )
const relId = await brain . relate ( {
from : a , to : b , type : VerbType . WorksWith , metadata : { role : 'lead' , team : 'core' }
} )
const index = metadataIndexOf ( brain )
expect ( await index . getIds ( 'role' , 'lead' ) ) . toEqual ( [ relId ] )
// Flush first — see (b)'s note: 'role'/'team' must be durably known
// fields before their only value is retracted, or the post-update
// "gone" checks below throw FIELD_NOT_INDEXED instead of returning [].
await brain . flush ( )
await brain . updateRelation ( { id : relId , metadata : { role : 'reviewer' } , merge : false } )
// Stale values gone (the old shape AND the merge:false-dropped field)…
expect ( await index . getIds ( 'role' , 'lead' ) ) . toEqual ( [ ] )
expect ( await index . getIds ( 'team' , 'core' ) ) . toEqual ( [ ] )
// …only the new value serves.
expect ( await index . getIds ( 'role' , 'reviewer' ) ) . toEqual ( [ relId ] )
} )
it ( "(d) remove(entity) cascade retracts every incident relation's metadata row" , async ( ) = > {
const a = await addPerson ( 'a' )
const b = await addPerson ( 'b' )
const c = await addPerson ( 'c' )
const rel1 = await brain . relate ( {
from : a , to : b , type : VerbType . WorksWith , metadata : { tag : 'cascade-test' }
} )
const rel2 = await brain . relate ( {
from : c , to : a , type : VerbType . WorksWith , metadata : { tag : 'cascade-test' }
} )
const index = metadataIndexOf ( brain )
expect ( ( await index . getIds ( 'tag' , 'cascade-test' ) ) . sort ( ) ) . toEqual ( [ rel1 , rel2 ] . sort ( ) )
// Flush first — see (b)'s note.
await brain . flush ( )
await brain . remove ( a ) // a is source of rel1, target of rel2 — both cascade
expect ( await index . getIds ( 'tag' , 'cascade-test' ) ) . toEqual ( [ ] )
} )
it ( '(e) a rebuild() reproduces exactly the verb-row population the live path built' , async ( ) = > {
const a = await addPerson ( 'a' )
const b = await addPerson ( 'b' )
const c = await addPerson ( 'c' )
await brain . relate ( { from : a , to : b , type : VerbType . WorksWith , metadata : { tag : 'parity' , label : 'ab' } } )
await brain . relate ( { from : b , to : c , type : VerbType . RelatedTo , metadata : { tag : 'parity' , label : 'bc' } } )
const relId3 = await brain . relate ( {
from : c , to : a , type : VerbType . WorksWith , metadata : { tag : 'parity' , label : 'ca' }
} )
await brain . unrelate ( relId3 ) // exercise retraction too — the rebuild must NOT resurrect it
const index = metadataIndexOf ( brain )
const beforeIds = ( await index . getIds ( 'tag' , 'parity' ) ) . slice ( ) . sort ( )
expect ( beforeIds . length ) . toBe ( 2 )
const beforeAb = await index . getIds ( 'label' , 'ab' )
const beforeBc = await index . getIds ( 'label' , 'bc' )
await index . rebuild ( )
const afterIds = ( await index . getIds ( 'tag' , 'parity' ) ) . slice ( ) . sort ( )
expect ( afterIds ) . toEqual ( beforeIds )
expect ( await index . getIds ( 'label' , 'ab' ) ) . toEqual ( beforeAb )
expect ( await index . getIds ( 'label' , 'bc' ) ) . toEqual ( beforeBc )
expect ( await index . getIds ( 'label' , 'ca' ) ) . toEqual ( [ ] ) // the unrelated edge stays gone
} )
it ( '(f) transact() relate/unrelate posts/retracts the same metadata-index rows as single-op' , async ( ) = > {
const a = await addPerson ( 'a' )
const b = await addPerson ( 'b' )
const c = await addPerson ( 'c' )
const d = await addPerson ( 'd' )
// Single-op baseline.
const singleOpId = await brain . relate ( {
from : a , to : b , type : VerbType . WorksWith , metadata : { tag : 'parity-f' }
} )
// transact() mirror.
const relateDb = await brain . transact ( [
{ op : 'relate' , from : c , to : d , type : VerbType . WorksWith , metadata : { tag : 'parity-f' } }
] )
const transactId = relateDb . receipt ! . ids [ 0 ]
await relateDb . release ( )
const index = metadataIndexOf ( brain )
expect ( ( await index . getIds ( 'tag' , 'parity-f' ) ) . sort ( ) ) . toEqual ( [ singleOpId , transactId ] . sort ( ) )
// Flush first — see (b)'s note: 'tag' must be durably known before its
// last live posting is retracted below.
await brain . flush ( )
// Retract both ways — single-op unrelate() and transact() unrelate.
await brain . unrelate ( singleOpId )
const unrelateDb = await brain . transact ( [ { op : 'unrelate' , id : transactId } ] )
await unrelateDb . release ( )
expect ( await index . getIds ( 'tag' , 'parity-f' ) ) . toEqual ( [ ] )
} )
2026-08-25 12:07:28 -07:00
it ( 'the metadata crossing never carries BigInt endpoint ints — a cascade delete after graph resolution survives JSON' , async ( ) = > {
// resolveVerbEndpointInts MIRRORS the resolved u64 ints onto the verb
// object as BigInt (verb.sourceInt/targetInt). A provider that JSON-
// serializes the metadata crossing dies on BigInt — found by the first
// joint pair gate. This pin drives the exact shape: relate (graph legs
// resolve ints), then remove the source entity (the cascade passes the
// SAME verb object to the retraction), through a provider shim that
// enforces the JSON-safety contract the way a native provider does.
const employee = await brain . add ( { data : 'cascade employee' , type : 'person' } )
const invoice = await brain . add ( { data : 'cascade invoice' , type : 'document' } )
await brain . relate ( { from : employee , to : invoice , type : 'relatedTo' } )
const mgr : any = ( brain as any ) . metadataIndex
const origRemove = mgr . removeFromIndex . bind ( mgr )
const seen : unknown [ ] = [ ]
mgr . removeFromIndex = async ( id : string , metadata? : unknown , generation? : bigint ) = > {
seen . push ( metadata )
JSON . stringify ( metadata ) // the contract: throws on BigInt, exactly like a native crossing
return origRemove ( id , metadata , generation )
}
try {
await brain . remove ( employee ) // cascades the relation's retraction
} finally {
mgr . removeFromIndex = origRemove
}
expect ( seen . length ) . toBeGreaterThan ( 0 )
for ( const m of seen ) {
if ( m && typeof m === 'object' ) {
for ( const [ k , v ] of Object . entries ( m as Record < string , unknown > ) ) {
expect ( typeof v , ` metadata key ${ k } must be JSON-safe ` ) . not . toBe ( 'bigint' )
}
}
}
} )
feat(recovery): the catchup verdict is consumed; verb rows go live; the metadata rebuild goes online
Three cures on the JS metadata index, one seam:
- THE CATCHUP WIRING. The index computed its three-way watermark verdict at
open and nothing consumed it — after a crash + adopt reopen, find() served
the pre-crash index while canonical reads and counts recovered (caught by
the lifecycle lane's first run). The open path now consumes the verdict:
'adopt' is a no-op, 'catchup' folds the fact window (stamped, committed]
through the index legs — nouns and verbs, remove-then-add, one mechanism
for add and update — and 'rescan' runs the explicit rebuild, each narrated.
The lane's Ch4–6 release-blocking marker comes off: the contract holds.
Bonus root-cause: close() never stamped the projection watermarks (only
flush() did), so any close without a prior flush verdicted a needless
'rescan' on reopen — both doors now stamp.
- THE LIVE VERB PATH. Verb rows entered the metadata index only via rebuild
walks, so every rebuilt store minted phantom/stale verb postings from its
first live relate(). relate()/unrelate()/updateRelation() and remove()'s
cascade now post/retract the verb's row in the same commit as the graph
leg — transact() planners mirror identically — using the exact record
shape the rebuild walk uses, so live and rebuilt populations agree.
- THE ONLINE REBUILD. rebuild() was clear-then-walk — every metadata read
empty for the duration. rebuildMetadataIndexOnline builds a fresh manager
beside the serving one (shared identity, in-memory build, dual-write via
a shadow seam with zero call-site changes), atomically swaps the
reference, and persists exactly once post-swap. A find() polled ~200x
during a 2k-noun rebuild never dropped below its baseline.
repairIndex({ rebuild: ['metadata'] }) uses it automatically.
2026-08-25 10:01:56 -07:00
} )