diff --git a/src/db/backup.ts b/src/db/backup.ts index 07fd3ee8..8057109b 100644 --- a/src/db/backup.ts +++ b/src/db/backup.ts @@ -181,6 +181,83 @@ export function isBackupData(value: unknown): value is BackupData { ) } +/** Result of {@link validateBackup}. `valid` is true iff `errors` is empty. */ +export interface BackupValidation { + valid: boolean + errors: string[] + warnings: string[] +} + +/** + * @description Dry-run check a value before `import()`, without mutating the brain: + * structural validity, a supported `formatVersion`, entity-id uniqueness + required + * fields, and relation-endpoint coverage. `import()` throws on a non-`BackupData` or a + * too-new `formatVersion`; `validateBackup()` lets a consumer (e.g. a serializer checking + * a `.wbench` graph payload) surface issues first. + * @param data - The value to validate (need not be a `BackupData`). + * @returns `{ valid, errors, warnings }`. + * @example + * const { valid, errors } = validateBackup(payload) + * if (!valid) throw new Error(`invalid backup: ${errors.join('; ')}`) + * await brain.import(payload) + */ +export function validateBackup(data: unknown): BackupValidation { + const errors: string[] = [] + const warnings: string[] = [] + + if (!isBackupData(data)) { + errors.push(`not a BackupData document (expected format: '${BACKUP_FORMAT}')`) + return { valid: false, errors, warnings } + } + if (typeof data.formatVersion !== 'number') { + errors.push('formatVersion is missing or not a number') + } else if (data.formatVersion > BACKUP_FORMAT_VERSION) { + errors.push( + `formatVersion ${data.formatVersion} is newer than supported (max ${BACKUP_FORMAT_VERSION})` + ) + } + + const ids = new Set() + for (const e of data.entities) { + if (!e || typeof e.id !== 'string' || e.id.length === 0) { + errors.push('an entity is missing an id') + continue + } + if (ids.has(e.id)) errors.push(`duplicate entity id: ${e.id}`) + ids.add(e.id) + if (e.type === undefined || e.type === null || (e.type as string) === '') { + errors.push(`entity ${e.id} is missing a type`) + } + } + + const dangling = new Set(data.danglingIds ?? []) + for (const r of data.relations) { + if (!r || typeof r.id !== 'string') { + errors.push('a relation is missing an id') + continue + } + if (r.type === undefined || r.type === null || (r.type as string) === '') { + errors.push(`relation ${r.id} is missing a type`) + } + if (!r.from || !r.to) { + errors.push(`relation ${r.id} is missing from/to`) + continue + } + if (!ids.has(r.from) && !dangling.has(r.from)) { + warnings.push(`relation ${r.id}: from-endpoint ${r.from} is not in entities`) + } + if (!ids.has(r.to) && !dangling.has(r.to)) { + warnings.push(`relation ${r.id}: to-endpoint ${r.to} is not in entities`) + } + } + + if (!data.embedding || typeof data.embedding.dimensions !== 'number') { + warnings.push('embedding manifest is missing or has no dimensions') + } + + return { valid: errors.length === 0, errors, warnings } +} + // ============================================================================ // EXPORT // ============================================================================ diff --git a/src/index.ts b/src/index.ts index 5cae505a..3f060c6d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -144,7 +144,7 @@ export { Db } from './db/db.js' // Portable graph backup/restore — db.export() / brain.export() / brain.import() // (BackupData v1; identical wire format to the 7.x line). -export { isBackupData } from './db/backup.js' +export { isBackupData, validateBackup } from './db/backup.js' export type { BackupData, BackupEntity, @@ -152,7 +152,8 @@ export type { ExportSelector, ExportOptions, ImportOptions, - ImportResult + ImportResult, + BackupValidation } from './db/backup.js' export { GenerationConflictError, diff --git a/tests/unit/db/db-backup.test.ts b/tests/unit/db/db-backup.test.ts index e5ccd7f0..03b7df6c 100644 --- a/tests/unit/db/db-backup.test.ts +++ b/tests/unit/db/db-backup.test.ts @@ -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([ + [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 }) + } + }) })