181 lines
8 KiB
TypeScript
181 lines
8 KiB
TypeScript
|
|
/**
|
||
|
|
* 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 { Brainy } from '../../../src/brainy'
|
||
|
|
import { createTestConfig } from '../../helpers/test-factory'
|
||
|
|
import { NounType, VerbType } from '../../../src/types/graphTypes'
|
||
|
|
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/)
|
||
|
|
})
|
||
|
|
})
|
||
|
|
})
|