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:
parent
c2b73d4564
commit
7aad80395e
3 changed files with 195 additions and 2 deletions
|
|
@ -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<string>()
|
||||||
|
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
|
// EXPORT
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
|
||||||
|
|
@ -144,7 +144,7 @@ export { Db } from './db/db.js'
|
||||||
|
|
||||||
// Portable graph backup/restore — db.export() / brain.export() / brain.import()
|
// Portable graph backup/restore — db.export() / brain.export() / brain.import()
|
||||||
// (BackupData v1; identical wire format to the 7.x line).
|
// (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 {
|
export type {
|
||||||
BackupData,
|
BackupData,
|
||||||
BackupEntity,
|
BackupEntity,
|
||||||
|
|
@ -152,7 +152,8 @@ export type {
|
||||||
ExportSelector,
|
ExportSelector,
|
||||||
ExportOptions,
|
ExportOptions,
|
||||||
ImportOptions,
|
ImportOptions,
|
||||||
ImportResult
|
ImportResult,
|
||||||
|
BackupValidation
|
||||||
} from './db/backup.js'
|
} from './db/backup.js'
|
||||||
export {
|
export {
|
||||||
GenerationConflictError,
|
GenerationConflictError,
|
||||||
|
|
|
||||||
|
|
@ -10,9 +10,14 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
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 { Brainy } from '../../../src/brainy'
|
||||||
import { createTestConfig } from '../../helpers/test-factory'
|
import { createTestConfig } from '../../helpers/test-factory'
|
||||||
import { NounType, VerbType } from '../../../src/types/graphTypes'
|
import { NounType, VerbType } from '../../../src/types/graphTypes'
|
||||||
|
import { validateBackup } from '../../../src/db/backup'
|
||||||
import type { BackupData } from '../../../src/db/backup'
|
import type { BackupData } from '../../../src/db/backup'
|
||||||
|
|
||||||
describe('8.0 portable graph export/import (BackupData v1)', () => {
|
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/)
|
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 })
|
||||||
|
}
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue