feat(8.0): portable graph export()/import() (BackupData v1) — Db.export + polymorphic import
Re-adds the portable graph round-trip on 8.0 (deleted with DataAPI), now on the
immutable Db so it composes with the generational model.
- src/db/backup.ts: BackupData v1 engine (identical wire format to the 7.32.0 line).
exportGraph() reads through a Db at its pinned generation; importGraph() applies the
whole backup as ONE atomic transact() (a single generation).
- Db.export(selector?, options?) — read at this view's generation, so
brain.asOf(g).export() is a time-travel export and brain.now().with(ops).export() a
what-if export.
- brain.export(selector?, options?) — sugar for now().export().
- brain.import() is polymorphic: a BackupData document -> graph round-trip; a
file/buffer -> existing foreign-file ingestion (dispatched on the 'brainy-backup'
tag; zero migration for ingestion callers).
- Selectors (ids/collection/connected/vfsPath/predicate/whole) + edge policy
(induced/incident/none) + includeVectors/includeContent/includeSystem; system
entities excluded by visibility. onConflict merge/replace/skip, reembed auto/never,
remapIds. DbHost gains storage (VFS blob bytes).
- Types exported from the package root: BackupData/BackupEntity/BackupRelation/
ExportSelector/ExportOptions/ImportOptions/ImportResult + isBackupData.
Tests: tests/unit/db/db-backup.test.ts (10, green) — round-trip, selectors, edges,
vectors+re-embed, merge, asOf/with composition, validation. Full unit suite green.
Follow-ups: docs (8.0 guide + RELEASES + api/README), completeness adds
(since(prior).export() delta, exportStream/importStream, validate()), includeContent
filesystem test, ids-at-asOf get() refinement, @soulcraft/formats Zod schema mirror.
2026-06-16 16:38:18 -07:00
/ * *
* @module db / backup
* @description Portable graph backup / restore engine for Brainy 8.0 — the
* ` BackupData v1 ` format plus the ` export ` / ` import ` engines that power
* ` db.export() ` / ` brain.export() ` ( read , at a pinned generation ) and
* ` brain.import(backup) ` ( write , applied as one atomic transaction ) .
*
* The wire format is identical to the 7 . x line ( ` formatVersion: 1 ` ) , so a
* document written by 7 . x imports cleanly here and vice - versa . This is the
* * * portable * * round - trip ( human - readable , partial - or - whole , cross - version ) ,
* distinct from ` db.persist() ` / ` Brainy.load() ` ( the native whole - brain snapshot ,
* which preserves generation history ) and from ` brain.import(file) ` ( foreign - file
* ingestion ) .
*
* * * Reserved - field split : * * each ` BackupEntity ` carries Brainy ' s standard fields
* ( ` subtype ` , ` visibility ` , ` data ` , ` confidence ` , ` weight ` , ` service ` , ` createdBy ` ,
* ` createdAt ` ) at the TOP LEVEL and ` metadata ` holds ONLY custom user fields —
* mirroring the in - memory ` Entity ` , so import maps each to its dedicated ` add ` /
* ` relate ` parameter rather than the metadata bag .
* /
import { Entity , Relation , Result } from '../types/brainy.types.js'
import { NounType , VerbType } from '../types/graphTypes.js'
import { StorageAdapter } from '../coreTypes.js'
import { getBrainyVersion } from '../utils/version.js'
import { TxOperation } from './types.js'
/** Fixed entity id of the VFS root collection (a `system` entity; excluded unless `includeSystem`). */
const VFS_ROOT_ID = '00000000-0000-0000-0000-000000000000'
/** Magic string identifying a Brainy portable backup document. */
export const BACKUP_FORMAT = 'brainy-backup'
/** Current portable-format version (import gates on this for cross-version migration). */
export const BACKUP_FORMAT_VERSION = 1
/** Default embedding model label (informational; `dimensions` is the real compat gate). */
const DEFAULT_EMBED_MODEL = 'all-MiniLM-L6-v2'
/** Page size for find-based enumeration (whole-brain / predicate selectors). */
const ENUM_PAGE = 1000
/** Per-node relation fetch ceiling (source/target-filtered, so no unfiltered-scan warning). */
const RELATION_FETCH_LIMIT = 100 _000
// ============================================================================
// Public types — identical wire shape to the 7.x line
// ============================================================================
/ * *
* @description Selects WHICH part of the graph to export . Reuses ` find() ` ' s grammar
* plus export - specific selectors . Omit ( or pass ` {} ` ) to export the whole brain .
* Structural selectors ( ` ids ` / ` collection ` / ` connected ` / ` vfsPath ` ) and predicate
* selectors ( ` type ` / ` subtype ` / ` where ` / ` service ` / ` visibility ` ) * * compose * * .
* /
export interface ExportSelector {
/** Exactly these entity ids. */
ids? : string [ ]
/** A collection id → the collection + its transitive `Contains` members. */
collection? : string
/** Alias for `collection`. */
memberOf? : string
/** An entity + its N-hop neighbourhood. */
connected ? : {
from : string
depth? : number
verbs? : VerbType [ ]
direction ? : 'out' | 'in' | 'both'
}
/** A VFS path → the directory/file + (for a directory) its `Contains` subtree. */
vfsPath? : string
/** For `vfsPath` directories: include the whole subtree (default: true). */
recursive? : boolean
/** For `collection`/`vfsPath`: cap traversal depth (default: unbounded). */
depth? : number
/** Predicate: entity type(s). */
type ? : NounType | NounType [ ]
/** Predicate: entity subtype(s). */
subtype? : string | string [ ]
/** Predicate: exact-match metadata fields. */
where? : Record < string , any >
/** Predicate: multi-tenancy service id. */
service? : string
/** Predicate: visibility (`'public'` matches entities with no explicit visibility). */
visibility? : string
}
/** Controls HOW the selected graph is serialized. */
export interface ExportOptions {
/** Include embedding vectors verbatim (default: false → `import()` re-embeds from `data`). */
includeVectors? : boolean
/** Include VFS file bytes in `blobs` so files round-trip byte-identically (default: false). */
includeContent? : boolean
/** Include `visibility:'system'` entities (e.g. the VFS root) (default: false). */
includeSystem? : boolean
/** Which edges to include (default: `'induced'`). */
edges ? : 'induced' | 'incident' | 'none'
}
/** Controls how a `BackupData` is applied on `import()`. */
export interface ImportOptions {
/** Conflict policy for an existing id (default: `'merge'` — dedup-by-id). */
onConflict ? : 'merge' | 'replace' | 'skip'
/** Vector policy (default: `'auto'` — re-embed from `data` when no vector is carried). */
reembed ? : 'auto' | 'never'
/** Rewrite every id on the way in (e.g. to clone a template subgraph under fresh ids). */
remapIds ? : ( id : string ) = > string
/** Transaction metadata, recorded in the tx-log alongside the new generation. */
meta? : Record < string , unknown >
}
/** One entity in a `BackupData`. Standard fields top-level; `metadata` is custom-only. */
export interface BackupEntity {
id : string
type : string
subtype? : string
visibility? : string
data? : any
confidence? : number
weight? : number
service? : string
createdBy? : any
createdAt? : number
vector? : number [ ]
metadata? : any
}
/** One relation (edge) in a `BackupData`. */
export interface BackupRelation {
id : string
from : string
to : string
type : string
subtype? : string
visibility? : string
weight? : number
confidence? : number
metadata? : any
}
/** A self-describing, versioned, portable graph document (identical on 7.x and 8.0). */
export interface BackupData {
format : typeof BACKUP_FORMAT
formatVersion : number
brainyVersion : string
createdAt : string
embedding : { model : string ; dimensions : number }
selector? : ExportSelector
entities : BackupEntity [ ]
relations : BackupRelation [ ]
blobs? : Record < string , string >
danglingIds? : string [ ]
stats : { entityCount : number ; relationCount : number ; blobCount : number ; vectorDimensions? : number }
}
/** Outcome of an `import()`. */
export interface ImportResult {
imported : number
merged : number
skipped : number
reembedded : number
blobsWritten : number
errors : Array < { id : string ; error : string } >
}
/** The minimal read surface the export engine needs — satisfied by `Db` and `Brainy`. */
export interface BackupReader < T = any > {
get ( id : string , options ? : { includeVectors? : boolean } ) : Promise < Entity < T > | null >
find ( query : any ) : Promise < Result < T > [ ] >
related ( paramsOrId? : any ) : Promise < Relation < T > [ ] >
}
/** The minimal write surface the import engine needs — satisfied by `Brainy`. */
export interface BackupWriter < T = any > {
get ( id : string , options ? : { includeVectors? : boolean } ) : Promise < Entity < T > | null >
transact ( ops : TxOperation < T > [ ] , options ? : { meta? : Record < string , unknown > } ) : Promise < unknown >
}
/** Type guard: is this value a Brainy `BackupData` document (vs a file/foreign object)? */
export function isBackupData ( value : unknown ) : value is BackupData {
return (
typeof value === 'object' &&
value !== null &&
( value as any ) . format === BACKUP_FORMAT &&
Array . isArray ( ( value as any ) . entities )
)
}
2026-06-17 11:44:29 -07:00
/** 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 }
}
feat(8.0): portable graph export()/import() (BackupData v1) — Db.export + polymorphic import
Re-adds the portable graph round-trip on 8.0 (deleted with DataAPI), now on the
immutable Db so it composes with the generational model.
- src/db/backup.ts: BackupData v1 engine (identical wire format to the 7.32.0 line).
exportGraph() reads through a Db at its pinned generation; importGraph() applies the
whole backup as ONE atomic transact() (a single generation).
- Db.export(selector?, options?) — read at this view's generation, so
brain.asOf(g).export() is a time-travel export and brain.now().with(ops).export() a
what-if export.
- brain.export(selector?, options?) — sugar for now().export().
- brain.import() is polymorphic: a BackupData document -> graph round-trip; a
file/buffer -> existing foreign-file ingestion (dispatched on the 'brainy-backup'
tag; zero migration for ingestion callers).
- Selectors (ids/collection/connected/vfsPath/predicate/whole) + edge policy
(induced/incident/none) + includeVectors/includeContent/includeSystem; system
entities excluded by visibility. onConflict merge/replace/skip, reembed auto/never,
remapIds. DbHost gains storage (VFS blob bytes).
- Types exported from the package root: BackupData/BackupEntity/BackupRelation/
ExportSelector/ExportOptions/ImportOptions/ImportResult + isBackupData.
Tests: tests/unit/db/db-backup.test.ts (10, green) — round-trip, selectors, edges,
vectors+re-embed, merge, asOf/with composition, validation. Full unit suite green.
Follow-ups: docs (8.0 guide + RELEASES + api/README), completeness adds
(since(prior).export() delta, exportStream/importStream, validate()), includeContent
filesystem test, ids-at-asOf get() refinement, @soulcraft/formats Zod schema mirror.
2026-06-16 16:38:18 -07:00
// ============================================================================
// EXPORT
// ============================================================================
/ * *
* @description Serialize part or all of a graph ( read through ` reader ` at its pinned
* generation ) into a portable ` BackupData ` document .
* @param reader - Generation - correct read surface ( ` Db ` or ` Brainy ` ) .
* @param storage - Storage adapter ( used only for VFS blob bytes when ` includeContent ` ) .
* @param selector - WHAT to export ( omit for the whole brain ) .
* @param options - HOW to export ( vectors / file bytes / edge policy ) .
* @param dimensions - Embedding dimensionality for the manifest .
* /
export async function exportGraph < T = any > (
reader : BackupReader < T > ,
storage : StorageAdapter | undefined ,
selector : ExportSelector ,
options : ExportOptions
) : Promise < BackupData > {
const {
includeVectors = false ,
includeContent = false ,
includeSystem = false ,
edges = 'induced'
} = options
// 1. Resolve the node-id set.
const idSet = await resolveSelector ( reader , selector , includeSystem )
// 2. Read canonical entities (reserved fields top-level), applying any predicate filter.
const usePredicate = hasPredicate ( selector )
const entityMap = new Map < string , Entity < T > > ( )
const entities : BackupEntity [ ] = [ ]
for ( const id of idSet ) {
const e = await reader . get ( id , { includeVectors } )
if ( ! e ) continue
if ( ! includeSystem && ( e as any ) . visibility === 'system' ) continue
if ( usePredicate && ! matchesPredicate ( e , selector ) ) continue
entityMap . set ( id , e )
entities . push ( toBackupEntity ( e , includeVectors ) )
}
const keptIds = new Set ( entityMap . keys ( ) )
// 3. Edges per policy.
const { relations , danglingIds } = await collectEdges ( reader , keptIds , edges )
// 4. VFS blob bytes (only when requested).
let blobs : Record < string , string > | undefined
if ( includeContent ) blobs = await collectBlobs ( storage , entityMap )
const blobCount = blobs ? Object . keys ( blobs ) . length : 0
// Embedding dimensionality for the manifest: detect from a carried vector,
// else the default model's 384 (informational — `dimensions` is the compat gate).
const dimensions = entities . find ( ( e ) = > e . vector && e . vector . length ) ? . vector ? . length ? ? 384
return {
format : BACKUP_FORMAT ,
formatVersion : BACKUP_FORMAT_VERSION ,
brainyVersion : getBrainyVersion ( ) ,
createdAt : new Date ( ) . toISOString ( ) ,
embedding : { model : DEFAULT_EMBED_MODEL , dimensions } ,
selector ,
entities ,
relations ,
. . . ( blobs && blobCount > 0 ? { blobs } : { } ) ,
. . . ( danglingIds && danglingIds . length > 0 ? { danglingIds } : { } ) ,
stats : {
entityCount : entities.length ,
relationCount : relations.length ,
blobCount ,
vectorDimensions : dimensions
}
}
}
// ============================================================================
// IMPORT
// ============================================================================
/ * *
* @description Apply a ` BackupData ` to the brain as ONE atomic transaction ( a single
* new generation ) . Dedup - by - id merge by default ; re - embeds from ` data ` when no vector
* is carried . VFS blob bytes are written first ( content - addressed , idempotent ) .
* @param writer - The brain ( ` get ` + ` transact ` ) .
* @param storage - Storage adapter ( for VFS blob bytes ) .
* @param data - A ` BackupData ` document .
* @param options - Conflict / vector / id - remap policy .
* @throws If ` data ` is not a ` BackupData ` , or its ` formatVersion ` is newer than supported .
* /
export async function importGraph < T = any > (
writer : BackupWriter < T > ,
storage : StorageAdapter | undefined ,
data : BackupData ,
options : ImportOptions = { }
) : Promise < ImportResult > {
if ( ! isBackupData ( data ) ) {
throw new Error (
` import() expects a BackupData document (format:' ${ BACKUP_FORMAT } '). ` +
` For file ingestion (CSV/PDF/Excel/JSON), pass a file/buffer instead. `
)
}
if ( typeof data . formatVersion === 'number' && data . formatVersion > BACKUP_FORMAT_VERSION ) {
throw new Error (
` Backup formatVersion ${ data . formatVersion } is newer than this Brainy supports ` +
` (max ${ BACKUP_FORMAT_VERSION } ). Upgrade Brainy to import this document. `
)
}
const { onConflict = 'merge' , reembed = 'auto' , remapIds , meta } = options
const result : ImportResult = {
imported : 0 ,
merged : 0 ,
skipped : 0 ,
reembedded : 0 ,
blobsWritten : 0 ,
errors : [ ]
}
const mapId = ( id : string ) = > ( remapIds ? remapIds ( id ) : id )
// 1. Blob bytes first (content-addressed → idempotent), so file entities resolve content.
if ( data . blobs && Object . keys ( data . blobs ) . length > 0 ) {
const blobStorage = ( storage as any ) ? . blobStorage
if ( blobStorage ? . write ) {
for ( const [ hash , b64 ] of Object . entries ( data . blobs ) ) {
try {
await blobStorage . write ( Buffer . from ( b64 , 'base64' ) )
result . blobsWritten ++
} catch ( e ) {
result . errors . push ( { id : hash , error : ` blob: ${ ( e as Error ) . message } ` } )
}
}
} else {
for ( const hash of Object . keys ( data . blobs ) ) {
result . errors . push ( { id : hash , error : 'blob: storage does not support binary blobs' } )
}
}
}
// 2. Build the operation batch (one atomic transaction → one generation).
const ops : TxOperation < T > [ ] = [ ]
for ( const be of data . entities || [ ] ) {
const id = mapId ( be . id )
try {
const exists = ( await writer . get ( id ) ) !== null
if ( exists ) {
if ( onConflict === 'skip' ) {
result . skipped ++
continue
}
if ( onConflict === 'merge' ) {
ops . push ( { op : 'update' , id , . . . entityUpdateFields ( be ) , merge : true } as TxOperation < T > )
result . merged ++
continue
}
ops . push ( { op : 'remove' , id } as TxOperation < T > ) // 'replace'
}
const useVector = Array . isArray ( be . vector ) && be . vector . length > 0
if ( ! useVector && reembed === 'never' ) {
result . errors . push ( { id , error : 'no vector carried and reembed:never' } )
continue
}
ops . push ( {
op : 'add' ,
id ,
. . . entityAddFields ( be ) ,
. . . ( useVector ? { vector : be.vector } : { } )
} as TxOperation < T > )
result . imported ++
if ( ! useVector ) result . reembedded ++
} catch ( e ) {
result . errors . push ( { id , error : ( e as Error ) . message } )
}
}
for ( const br of data . relations || [ ] ) {
ops . push ( {
op : 'relate' ,
from : mapId ( br . from ) ,
to : mapId ( br . to ) ,
type : br . type as VerbType ,
. . . ( br . subtype !== undefined ? { subtype : br.subtype } : { } ) ,
. . . ( br . weight !== undefined ? { weight : br.weight } : { } ) ,
. . . ( br . confidence !== undefined ? { confidence : br.confidence } : { } ) ,
. . . ( br . metadata !== undefined ? { metadata : br.metadata } : { } )
} as TxOperation < T > )
}
// 3. Apply atomically — one generation for the whole backup, or none.
if ( ops . length > 0 ) {
await writer . transact ( ops , meta ? { meta } : undefined )
}
return result
}
// ============================================================================
// Selector resolution (private)
// ============================================================================
function hasStructural ( s : ExportSelector ) : boolean {
return ! ! ( s . ids || s . collection || s . memberOf || s . connected || s . vfsPath )
}
function hasPredicate ( s : ExportSelector ) : boolean {
return (
s . type !== undefined ||
s . subtype !== undefined ||
s . where !== undefined ||
s . service !== undefined ||
s . visibility !== undefined
)
}
async function resolveSelector < T > (
reader : BackupReader < T > ,
s : ExportSelector ,
includeSystem : boolean
) : Promise < Set < string > > {
let idSet : Set < string >
if ( s . ids && s . ids . length ) {
idSet = new Set ( s . ids )
} else if ( s . collection ? ? s . memberOf ) {
idSet = await resolveCollectionSubtree ( reader , ( s . collection ? ? s . memberOf ) ! , s . depth )
} else if ( s . connected ) {
idSet = await resolveConnected ( reader , s . connected )
} else if ( s . vfsPath ) {
idSet = await resolveVfsPath ( reader , s . vfsPath , s . recursive ? ? true , s . depth )
} else {
idSet = await enumerateAll ( reader , s )
}
if ( ! includeSystem ) idSet . delete ( VFS_ROOT_ID )
return idSet
}
/** Whole-brain / predicate enumeration via generation-correct paginated `find()`. */
async function enumerateAll < T > ( reader : BackupReader < T > , s : ExportSelector ) : Promise < Set < string > > {
const params : any = { }
if ( s . type !== undefined ) params . type = s . type
if ( s . subtype !== undefined ) params . subtype = s . subtype
if ( s . where !== undefined ) params . where = s . where
if ( s . service !== undefined ) params . service = s . service
const ids = new Set < string > ( )
let offset = 0
// eslint-disable-next-line no-constant-condition
while ( true ) {
const batch = await reader . find ( { . . . params , limit : ENUM_PAGE , offset } )
for ( const r of batch ) ids . add ( r . id )
if ( batch . length < ENUM_PAGE ) break
offset += ENUM_PAGE
}
return ids
}
async function resolveCollectionSubtree < T > (
reader : BackupReader < T > ,
rootId : string ,
depth? : number
) : Promise < Set < string > > {
const set = new Set < string > ( [ rootId ] )
const maxDepth = depth ? ? Infinity
let frontier = [ rootId ]
let d = 0
while ( frontier . length && d < maxDepth ) {
const next : string [ ] = [ ]
for ( const id of frontier ) {
const rels = await reader . related ( { from : id , type : VerbType . Contains , limit : RELATION_FETCH_LIMIT } )
for ( const r of rels ) {
if ( ! set . has ( r . to ) ) {
set . add ( r . to )
next . push ( r . to )
}
}
}
frontier = next
d ++
}
return set
}
async function resolveConnected < T > (
reader : BackupReader < T > ,
c : NonNullable < ExportSelector [ 'connected' ] >
) : Promise < Set < string > > {
const { from , depth = 1 , verbs , direction = 'out' } = c
const set = new Set < string > ( [ from ] )
let frontier = [ from ]
for ( let d = 0 ; d < depth ; d ++ ) {
const next : string [ ] = [ ]
for ( const id of frontier ) {
const neighbours = await neighboursOf ( reader , id , direction , verbs )
for ( const n of neighbours ) {
if ( ! set . has ( n ) ) {
set . add ( n )
next . push ( n )
}
}
}
frontier = next
if ( ! next . length ) break
}
return set
}
async function neighboursOf < T > (
reader : BackupReader < T > ,
id : string ,
direction : 'out' | 'in' | 'both' ,
verbs? : VerbType [ ]
) : Promise < string [ ] > {
const out : string [ ] = [ ]
if ( direction === 'out' || direction === 'both' ) {
const rels = await reader . related ( { from : id , limit : RELATION_FETCH_LIMIT } )
for ( const r of rels ) if ( ! verbs || verbs . includes ( r . type ) ) out . push ( r . to )
}
if ( direction === 'in' || direction === 'both' ) {
const rels = await reader . related ( { to : id , limit : RELATION_FETCH_LIMIT } )
for ( const r of rels ) if ( ! verbs || verbs . includes ( r . type ) ) out . push ( r . from )
}
return out
}
async function resolveVfsPath < T > (
reader : BackupReader < T > ,
path : string ,
recursive : boolean ,
depth? : number
) : Promise < Set < string > > {
const dirId = await resolveVfsPathToId ( reader , path )
if ( ! dirId ) return new Set ( )
if ( ! recursive ) {
const set = new Set < string > ( [ dirId ] )
const rels = await reader . related ( { from : dirId , type : VerbType . Contains , limit : RELATION_FETCH_LIMIT } )
for ( const r of rels ) set . add ( r . to )
return set
}
return resolveCollectionSubtree ( reader , dirId , depth )
}
async function resolveVfsPathToId < T > ( reader : BackupReader < T > , path : string ) : Promise < string | null > {
const segments = path . split ( '/' ) . filter ( Boolean )
let currentId = VFS_ROOT_ID
for ( const seg of segments ) {
const rels = await reader . related ( { from : currentId , type : VerbType . Contains , limit : RELATION_FETCH_LIMIT } )
let found : string | null = null
for ( const r of rels ) {
const child = await reader . get ( r . to )
if ( ( child ? . metadata as any ) ? . name === seg ) {
found = r . to
break
}
}
if ( ! found ) return null
currentId = found
}
return currentId
}
function matchesPredicate < T > ( e : Entity < T > , s : ExportSelector ) : boolean {
if ( s . type !== undefined ) {
const types = Array . isArray ( s . type ) ? s . type : [ s . type ]
if ( ! types . includes ( e . type ) ) return false
}
if ( s . subtype !== undefined ) {
const subs = Array . isArray ( s . subtype ) ? s . subtype : [ s . subtype ]
if ( e . subtype === undefined || ! subs . includes ( e . subtype ) ) return false
}
if ( s . service !== undefined && e . service !== s . service ) return false
if ( s . visibility !== undefined ) {
const vis = ( e as any ) . visibility ? ? 'public'
if ( vis !== s . visibility ) return false
}
if ( s . where && ! matchesWhere ( e . metadata , s . where ) ) return false
return true
}
function matchesWhere ( metadata : any , where : Record < string , any > ) : boolean {
if ( ! metadata ) return false
for ( const [ key , value ] of Object . entries ( where ) ) {
if ( metadata [ key ] !== value ) return false
}
return true
}
// ============================================================================
// Serialization helpers (private)
// ============================================================================
function toBackupEntity < T > ( e : Entity < T > , includeVectors : boolean ) : BackupEntity {
const be : BackupEntity = { id : e.id , type : e . type as string }
if ( e . subtype !== undefined ) be . subtype = e . subtype
const vis = ( e as any ) . visibility
if ( vis !== undefined && vis !== 'public' ) be . visibility = vis
if ( e . data !== undefined ) be . data = e . data
if ( e . confidence !== undefined ) be . confidence = e . confidence
if ( e . weight !== undefined ) be . weight = e . weight
if ( e . service !== undefined ) be . service = e . service
if ( ( e as any ) . createdBy !== undefined ) be . createdBy = ( e as any ) . createdBy
if ( e . createdAt !== undefined ) be . createdAt = e . createdAt
if ( includeVectors && e . vector && e . vector . length ) be . vector = e . vector
if ( e . metadata && Object . keys ( e . metadata as any ) . length ) be . metadata = e . metadata
return be
}
function toBackupRelation < T > ( r : Relation < T > ) : BackupRelation {
const br : BackupRelation = { id : r.id , from : r . from , to : r.to , type : r . type as string }
if ( r . subtype !== undefined ) br . subtype = r . subtype
const vis = ( r as any ) . visibility
if ( vis !== undefined && vis !== 'public' ) br . visibility = vis
if ( r . weight !== undefined ) br . weight = r . weight
if ( r . confidence !== undefined ) br . confidence = r . confidence
if ( r . metadata && Object . keys ( r . metadata as any ) . length ) br . metadata = r . metadata
return br
}
async function collectEdges < T > (
reader : BackupReader < T > ,
idSet : Set < string > ,
edges : 'induced' | 'incident' | 'none'
) : Promise < { relations : BackupRelation [ ] ; danglingIds? : string [ ] } > {
if ( edges === 'none' ) return { relations : [ ] }
const relations : BackupRelation [ ] = [ ]
const dangling = new Set < string > ( )
const seen = new Set < string > ( )
for ( const id of idSet ) {
const rels = await reader . related ( { from : id , limit : RELATION_FETCH_LIMIT } )
for ( const r of rels ) {
if ( seen . has ( r . id ) ) continue
const toIn = idSet . has ( r . to )
if ( edges === 'induced' && ! toIn ) continue
if ( ! toIn ) dangling . add ( r . to )
seen . add ( r . id )
relations . push ( toBackupRelation ( r ) )
}
}
if ( edges === 'incident' ) {
for ( const id of idSet ) {
const rels = await reader . related ( { to : id , limit : RELATION_FETCH_LIMIT } )
for ( const r of rels ) {
if ( seen . has ( r . id ) ) continue
if ( ! idSet . has ( r . from ) ) {
dangling . add ( r . from )
seen . add ( r . id )
relations . push ( toBackupRelation ( r ) )
}
}
}
}
return dangling . size > 0 ? { relations , danglingIds : Array.from ( dangling ) } : { relations }
}
async function collectBlobs < T > (
storage : StorageAdapter | undefined ,
entityMap : Map < string , Entity < T > >
) : Promise < Record < string , string > > {
const blobs : Record < string , string > = { }
const blobStorage = ( storage as any ) ? . blobStorage
if ( ! blobStorage ? . read ) return blobs
for ( const e of entityMap . values ( ) ) {
const storageMeta = ( e . metadata as any ) ? . storage
const hash = storageMeta ? . hash
if ( storageMeta ? . type === 'blob' && hash && ! blobs [ hash ] ) {
try {
const buf = await blobStorage . read ( hash )
blobs [ hash ] = Buffer . from ( buf ) . toString ( 'base64' )
} catch {
// Referenced blob bytes unreadable (storage drift): skip — file structure still
// travels, and stats.blobCount reflects what was actually captured.
}
}
}
return blobs
}
// ============================================================================
// add()/update() param mapping (private)
// ============================================================================
function entityAddFields ( be : BackupEntity ) : Record < string , any > {
const fields : Record < string , any > = { data : be.data , type : be . type as NounType }
if ( be . subtype !== undefined ) fields . subtype = be . subtype
if ( be . visibility !== undefined ) fields . visibility = be . visibility
if ( be . service !== undefined ) fields . service = be . service
if ( be . confidence !== undefined ) fields . confidence = be . confidence
if ( be . weight !== undefined ) fields . weight = be . weight
if ( be . metadata !== undefined ) fields . metadata = be . metadata
return fields
}
function entityUpdateFields ( be : BackupEntity ) : Record < string , any > {
const fields : Record < string , any > = { }
if ( be . data !== undefined ) fields . data = be . data
if ( be . type !== undefined ) fields . type = be . type as NounType
if ( be . subtype !== undefined ) fields . subtype = be . subtype
if ( be . visibility !== undefined ) fields . visibility = be . visibility
if ( be . confidence !== undefined ) fields . confidence = be . confidence
if ( be . weight !== undefined ) fields . weight = be . weight
if ( be . metadata !== undefined ) fields . metadata = be . metadata
if ( Array . isArray ( be . vector ) && be . vector . length ) fields . vector = be . vector
return fields
}