feat(8.0): validateBackup() dry-run + includeContent blob round-trip test + clone test

- validateBackup(data) → { valid, errors, warnings }: structural check, supported
  formatVersion, entity-id uniqueness + required fields, relation-endpoint coverage.
  Lets consumers (e.g. a serializer checking a .wbench graph) validate before import()
  without mutating the brain. Exported from the package root.
- Tests: validateBackup cases, remapIds clone (copy-not-move), and the includeContent
  VFS-blob filesystem round-trip (export captures bytes → import writes them).

Completes the #196 export/import surface for the showcase bar (deferred to 8.0.x:
since(prior).export() delta convenience, exportStream/importStream NDJSON).
This commit is contained in:
David Snelling 2026-06-17 11:44:29 -07:00
parent c2b73d4564
commit 7aad80395e
3 changed files with 195 additions and 2 deletions

View file

@ -10,9 +10,14 @@
*/
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)', () => {
@ -177,4 +182,114 @@ describe('8.0 portable graph export/import (BackupData v1)', () => {
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<string, string>([
[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 })
}
})
})