/** * Unit tests for the 8.0 portable graph backup/restore surface: * db.export() / brain.export() (read, at a pinned generation) * brain.import(backup) (write, one atomic transaction; polymorphic) * * Real round-trips against in-memory storage (no mocks of the API under test), * including the 8.0-specific compositions (asOf/with export) and the polymorphic * import() dispatch. Entities carry subtypes so the suite passes under 8.0's * subtype-required default. */ import { describe, it, expect, beforeEach, afterEach } from 'vitest' import { randomUUID } from 'node:crypto' import * as fs from 'node:fs/promises' import * as os from 'node:os' import * as path from 'node:path' import { Brainy } from '../../../src/brainy' import { createTestConfig } from '../../helpers/test-factory' import { NounType, VerbType } from '../../../src/types/graphTypes' import { validateBackup } from '../../../src/db/backup' import type { BackupData } from '../../../src/db/backup' describe('8.0 portable graph export/import (BackupData v1)', () => { let brain: Brainy beforeEach(async () => { brain = new Brainy(createTestConfig()) await brain.init() }) afterEach(async () => { await brain.close() }) describe('format + round-trip', () => { it('brain.export() produces a versioned BackupData document', async () => { const a = await brain.add({ data: 'Alice', type: NounType.Person, subtype: 'employee' }) const b = await brain.add({ data: 'Acme', type: NounType.Organization, subtype: 'vendor' }) await brain.relate({ from: a, to: b, type: VerbType.WorksWith, subtype: 'full-time' }) const backup = await brain.export() expect(backup.format).toBe('brainy-backup') expect(backup.formatVersion).toBe(1) expect(backup.entities.map((e) => e.id).sort()).toEqual([a, b].sort()) expect(backup.relations).toHaveLength(1) expect(backup.entities.find((e) => e.id === a)?.subtype).toBe('employee') expect(backup.relations[0].subtype).toBe('full-time') }) it('round-trips into a second brain via polymorphic brain.import()', async () => { const a = await brain.add({ data: 'Alice', type: NounType.Person, subtype: 'employee' }) const b = await brain.add({ data: 'Bob', type: NounType.Person, subtype: 'employee' }) await brain.relate({ from: a, to: b, type: VerbType.FriendOf, subtype: 'close' }) const backup = await brain.export({}, { includeVectors: true }) const target = new Brainy(createTestConfig()) await target.init() try { const result = await target.import(backup) expect(result.imported).toBe(2) expect(result.errors).toHaveLength(0) expect((await target.get(a))?.id).toBe(a) const rels = await target.related({ from: a }) expect(rels.some((r) => r.to === b && r.type === VerbType.FriendOf)).toBe(true) } finally { await target.close() } }) }) describe('selectors', () => { it('ids — exactly the requested entities', async () => { const a = await brain.add({ data: 'A', type: NounType.Thing, subtype: 'x' }) const b = await brain.add({ data: 'B', type: NounType.Thing, subtype: 'x' }) await brain.add({ data: 'C', type: NounType.Thing, subtype: 'x' }) const backup = await brain.export({ ids: [a, b] }) expect(backup.entities.map((e) => e.id).sort()).toEqual([a, b].sort()) }) it('collection — collection + transitive Contains members', async () => { const root = await brain.add({ data: 'Folder', type: NounType.Collection, subtype: 'dir' }) const c1 = await brain.add({ data: 'C1', type: NounType.Document, subtype: 'doc' }) const c2 = await brain.add({ data: 'C2', type: NounType.Document, subtype: 'doc' }) await brain.add({ data: 'Outside', type: NounType.Document, subtype: 'doc' }) await brain.relate({ from: root, to: c1, type: VerbType.Contains, subtype: 'has' }) await brain.relate({ from: c1, to: c2, type: VerbType.Contains, subtype: 'has' }) const backup = await brain.export({ collection: root }) expect(backup.entities.map((e) => e.id).sort()).toEqual([root, c1, c2].sort()) }) it('induced edges only by default', async () => { const a = await brain.add({ data: 'A', type: NounType.Thing, subtype: 'x' }) const b = await brain.add({ data: 'B', type: NounType.Thing, subtype: 'x' }) const c = await brain.add({ data: 'C', type: NounType.Thing, subtype: 'x' }) await brain.relate({ from: a, to: b, type: VerbType.RelatedTo, subtype: 'r' }) await brain.relate({ from: b, to: c, type: VerbType.RelatedTo, subtype: 'r' }) const backup = await brain.export({ ids: [a, b] }) expect(backup.relations).toHaveLength(1) expect(backup.relations[0].from).toBe(a) }) }) describe('vectors + conflict', () => { it('omits vectors by default and re-embeds on import', async () => { const a = await brain.add({ data: 'Re-embed me', type: NounType.Thing, subtype: 'x' }) const backup = await brain.export({ ids: [a] }) expect(backup.entities[0].vector).toBeUndefined() const target = new Brainy(createTestConfig()) await target.init() try { const result = await target.import(backup) expect(result.reembedded).toBe(1) expect((await target.get(a, { includeVectors: true }))?.vector?.length).toBeGreaterThan(0) } finally { await target.close() } }) it('onConflict:merge updates an existing entity in place', async () => { const a = await brain.add({ data: 'V1', type: NounType.Thing, subtype: 'x', metadata: { n: 1 } }) const backup = await brain.export({ ids: [a] }, { includeVectors: true }) await brain.update({ id: a, metadata: { n: 99 }, merge: false }) const result = await brain.import(backup, { onConflict: 'merge' }) expect(result.merged).toBe(1) expect((await brain.get(a))?.metadata?.n).toBe(1) }) }) describe('8.0 composition — export on the immutable Db', () => { it('asOf(generation).export() is a time-travel export', async () => { // Only transact() advances the committed generation (add() does not), so // distinct generations are created via transact for a true time-travel pin. const a = '22222222-2222-4222-8222-222222222222' const b = '33333333-3333-4333-8333-333333333333' await brain.transact([{ op: 'add', id: a, data: 'First', type: NounType.Thing, subtype: 'x' }]) const g1 = brain.generation() await brain.transact([{ op: 'add', id: b, data: 'Second', type: NounType.Thing, subtype: 'x' }]) const past = await brain.asOf(g1) try { // Whole-brain export at g1 enumerates via the generation-correct find() path. const backup = await past.export() const ids = backup.entities.map((e) => e.id) expect(ids).toContain(a) expect(ids).not.toContain(b) // b was committed in a later generation } finally { await past.release() } }) it('now().with(ops).export() is a what-if export', async () => { const a = await brain.add({ data: 'Real', type: NounType.Thing, subtype: 'x' }) const speculativeId = '11111111-1111-4111-8111-111111111111' const view = await brain.now().with([ { op: 'add', id: speculativeId, data: 'Speculative', type: NounType.Thing, subtype: 'x' } ]) try { const backup = await view.export({ ids: [a, speculativeId] }) expect(backup.entities.map((e) => e.id).sort()).toEqual([a, speculativeId].sort()) } finally { await view.release() } // The speculative entity never touched the durable brain. expect(await brain.get(speculativeId)).toBeNull() }) }) describe('import validation', () => { it('rejects a newer formatVersion', async () => { const future: BackupData = { format: 'brainy-backup', formatVersion: 999, brainyVersion: 'x', createdAt: new Date().toISOString(), embedding: { model: 'm', dimensions: 384 }, entities: [], relations: [], stats: { entityCount: 0, relationCount: 0, blobCount: 0 } } await expect(brain.import(future)).rejects.toThrow(/formatVersion/) }) }) describe('id remapping (clone)', () => { it('imports a subgraph under fresh ids (a copy, not a move)', async () => { const a = await brain.add({ data: 'A', type: NounType.Thing, subtype: 'x' }) const b = await brain.add({ data: 'B', type: NounType.Thing, subtype: 'x' }) await brain.relate({ from: a, to: b, type: VerbType.RelatedTo, subtype: 'r' }) const backup = await brain.export({ ids: [a, b] }, { includeVectors: true }) const remap = new Map([ [a, randomUUID()], [b, randomUUID()] ]) const result = await brain.import(backup, { remapIds: (id) => remap.get(id) ?? id }) expect(result.imported).toBe(2) expect((await brain.get(remap.get(a)!))?.id).toBe(remap.get(a)) expect((await brain.get(a))?.id).toBe(a) // original still present const rels = await brain.related({ from: remap.get(a)! }) expect(rels.some((r) => r.to === remap.get(b))).toBe(true) }) }) describe('validateBackup() — dry-run check', () => { const valid = (): BackupData => ({ format: 'brainy-backup', formatVersion: 1, brainyVersion: 'x', createdAt: new Date().toISOString(), embedding: { model: 'all-MiniLM-L6-v2', dimensions: 384 }, entities: [{ id: 'a', type: NounType.Thing }], relations: [], stats: { entityCount: 1, relationCount: 0, blobCount: 0, vectorDimensions: 384 } }) it('accepts a well-formed backup', () => { const r = validateBackup(valid()) expect(r.valid).toBe(true) expect(r.errors).toHaveLength(0) }) it('rejects a non-BackupData value', () => { const r = validateBackup({ entities: [] }) expect(r.valid).toBe(false) expect(r.errors[0]).toMatch(/BackupData/) }) it('rejects a newer formatVersion', () => { const r = validateBackup({ ...valid(), formatVersion: 999 }) expect(r.valid).toBe(false) expect(r.errors.some((e) => /formatVersion/.test(e))).toBe(true) }) it('flags duplicate entity ids as an error', () => { const b = valid() b.entities = [ { id: 'dup', type: NounType.Thing }, { id: 'dup', type: NounType.Thing } ] const r = validateBackup(b) expect(r.valid).toBe(false) expect(r.errors.some((e) => /duplicate/.test(e))).toBe(true) }) it('warns (not errors) on a relation endpoint missing from entities', () => { const b = valid() b.relations = [{ id: 'r1', from: 'a', to: 'missing', type: VerbType.RelatedTo }] const r = validateBackup(b) expect(r.valid).toBe(true) // a dangling endpoint is a warning, not a hard error expect(r.warnings.some((w) => /missing/.test(w))).toBe(true) }) }) }) describe('8.0 export includeContent (VFS blobs, filesystem)', () => { let dir: string let brain: Brainy beforeEach(async () => { dir = await fs.mkdtemp(path.join(os.tmpdir(), 'brainy-blob-')) brain = new Brainy({ storage: { type: 'filesystem', rootDirectory: dir } }) await brain.init() }) afterEach(async () => { await brain.close() await fs.rm(dir, { recursive: true, force: true }) }) it('captures VFS file bytes in blobs and writes them on import', async () => { await brain.vfs.writeFile('/hello.txt', 'Hello blobs') const backup = await brain.export( { vfsPath: '/hello.txt' }, { includeVectors: true, includeContent: true } ) expect(backup.stats.blobCount).toBeGreaterThanOrEqual(1) const b64 = Object.values(backup.blobs ?? {})[0] expect(Buffer.from(b64, 'base64').toString()).toBe('Hello blobs') const dir2 = await fs.mkdtemp(path.join(os.tmpdir(), 'brainy-blob2-')) const target = new Brainy({ storage: { type: 'filesystem', rootDirectory: dir2 } }) await target.init() try { const result = await target.import(backup) expect(result.blobsWritten).toBeGreaterThanOrEqual(1) expect(result.imported).toBeGreaterThanOrEqual(1) } finally { await target.close() await fs.rm(dir2, { recursive: true, force: true }) } }) })