fix(8.0): restore() reloads a native entity-id mapper before graphIndex.rebuild()

A logical snapshot restore could silently lose graph edges on a brain backed by
a native provider: the graph adjacency keys on the shared id-mapper's interned
ints (sourceInt/targetInt), which are derived + never persisted, and restore()
never reloaded the mapper — so a native graph rebuild resolved verb endpoints
against a stale/empty mapper and dropped edges.

restore() now calls entityIdMapper.rebuild() — a new OPTIONAL method on the
EntityIdMapperProvider contract — BEFORE rebuilding the indexes, so a native
mapper reloads its int<->uuid from the restored binary KV first and the graph
resolves endpoints correctly. The JS mapper deliberately has NO rebuild() and
needs none: MetadataIndex.rebuild() re-derives it from the restored entities via
append-only getOrAssign, consistently with the bitmaps it builds — forcing a
reload there would blank a still-referenced mapping and break find() after a
same-instance restore.

Contract: graphIndex.rebuild() resolves sourceId/targetId -> ints through the
shared mapper itself (brainy does not re-feed resolved endpoints); brainy's only
job is to ensure the mapper is reloaded first.

Test: relationships survive a snapshot round-trip (db-mvcc.test.ts). 84
generation/temporal/visibility tests green; tsc clean.
This commit is contained in:
David Snelling 2026-06-23 12:02:17 -07:00
parent 3783e61b30
commit 4d0b64f455
4 changed files with 72 additions and 2 deletions

View file

@ -1096,6 +1096,38 @@ describe('8.0 Db API — generational MVCC', () => {
await after.release()
})
it('restore() rebuilds graph adjacency — relationships survive a snapshot round-trip (id-mapper reloaded before graph)', async () => {
const { brain } = await openFsBrain()
const a = uid('rr-a')
const b = uid('rr-b')
const c = uid('rr-c')
await brain.transact([
{ op: 'add', id: a, type: NounType.Person, data: 'a', vector: vec(1), metadata: {} },
{ op: 'add', id: b, type: NounType.Person, data: 'b', vector: vec(2), metadata: {} },
{ op: 'add', id: c, type: NounType.Document, data: 'c', vector: vec(3), metadata: {} },
{ op: 'relate', from: a, to: b, type: VerbType.RelatedTo },
{ op: 'relate', from: a, to: c, type: VerbType.References }
])
expect((await brain.related(a)).map((r) => r.to).sort()).toEqual([b, c].sort())
const snapDir = path.join(makeTempDir(), 'rel-snapshot')
const db = brain.now()
await db.persist(snapDir)
await db.release()
// Drop a + its edges live so the restore must genuinely RECONSTRUCT them.
await brain.remove(a)
expect(await brain.related(a)).toEqual([])
await brain.restore(snapDir, { confirm: true })
// a's relationships are back: the graph adjacency rebuilt by resolving each
// verb's endpoints (sourceId/targetId → ints) through the id-mapper that
// restore() reloaded from the snapshot BEFORE graphIndex.rebuild(). Without
// that reload, a native adjacency keyed on those ints loses the edges.
expect((await brain.related(a)).map((r) => r.to).sort()).toEqual([b, c].sort())
})
it('Db values are first-class: Brainy.open() + chained with() overlays compose', async () => {
const brain = await Brainy.open({ requireSubtype: false, storage: { type: 'memory' } })
brains.push(brain)