2025-09-11 16:23:32 -07:00
|
|
|
/**
|
|
|
|
|
* Data Management API for Brainy 3.0
|
|
|
|
|
* Provides backup, restore, import, export, and data management
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
import { StorageAdapter, HNSWNoun, GraphVerb } from '../coreTypes.js'
|
|
|
|
|
import { Entity, Relation } from '../types/brainy.types.js'
|
|
|
|
|
import { NounType, VerbType } from '../types/graphTypes.js'
|
|
|
|
|
|
|
|
|
|
export interface BackupOptions {
|
|
|
|
|
includeVectors?: boolean
|
|
|
|
|
compress?: boolean
|
|
|
|
|
format?: 'json' | 'binary'
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface RestoreOptions {
|
|
|
|
|
merge?: boolean
|
|
|
|
|
overwrite?: boolean
|
|
|
|
|
validate?: boolean
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface ImportOptions {
|
|
|
|
|
format: 'json' | 'csv'
|
|
|
|
|
mapping?: Record<string, string>
|
|
|
|
|
batchSize?: number
|
|
|
|
|
validate?: boolean
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface ExportOptions {
|
|
|
|
|
format?: 'json' | 'csv'
|
|
|
|
|
filter?: {
|
|
|
|
|
type?: NounType | NounType[]
|
|
|
|
|
where?: Record<string, any>
|
|
|
|
|
service?: string
|
|
|
|
|
}
|
|
|
|
|
includeVectors?: boolean
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface BackupData {
|
|
|
|
|
version: string
|
|
|
|
|
timestamp: number
|
|
|
|
|
entities: Array<{
|
|
|
|
|
id: string
|
|
|
|
|
vector?: number[]
|
|
|
|
|
type: string
|
|
|
|
|
metadata: any
|
|
|
|
|
service?: string
|
|
|
|
|
}>
|
|
|
|
|
relations: Array<{
|
|
|
|
|
id: string
|
|
|
|
|
from: string
|
|
|
|
|
to: string
|
|
|
|
|
type: string
|
|
|
|
|
weight: number
|
|
|
|
|
metadata?: any
|
|
|
|
|
}>
|
|
|
|
|
config?: Record<string, any>
|
|
|
|
|
stats: {
|
|
|
|
|
entityCount: number
|
|
|
|
|
relationCount: number
|
|
|
|
|
vectorDimensions?: number
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface ImportResult {
|
|
|
|
|
successful: number
|
|
|
|
|
failed: number
|
|
|
|
|
errors: Array<{ item: any; error: string }>
|
|
|
|
|
duration: number
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export class DataAPI {
|
|
|
|
|
private brain: any // Reference to Brainy instance for neural import
|
|
|
|
|
|
|
|
|
|
constructor(
|
|
|
|
|
private storage: StorageAdapter,
|
|
|
|
|
private getEntity: (id: string) => Promise<Entity | null>,
|
|
|
|
|
private getRelation?: (id: string) => Promise<Relation | null>,
|
|
|
|
|
brain?: any
|
|
|
|
|
) {
|
|
|
|
|
this.brain = brain
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Create a backup of all data
|
|
|
|
|
*/
|
|
|
|
|
async backup(options: BackupOptions = {}): Promise<BackupData | { compressed: boolean; data: string; originalSize: number; compressedSize: number }> {
|
|
|
|
|
const {
|
|
|
|
|
includeVectors = true,
|
|
|
|
|
compress = false,
|
|
|
|
|
format = 'json'
|
|
|
|
|
} = options
|
|
|
|
|
|
|
|
|
|
const startTime = Date.now()
|
|
|
|
|
|
|
|
|
|
// Get all entities
|
|
|
|
|
const nounsResult = await this.storage.getNouns({
|
|
|
|
|
pagination: { limit: 1000000 }
|
|
|
|
|
})
|
|
|
|
|
const entities: BackupData['entities'] = []
|
|
|
|
|
|
|
|
|
|
for (const noun of nounsResult.items) {
|
|
|
|
|
const entity = {
|
|
|
|
|
id: noun.id,
|
|
|
|
|
vector: includeVectors ? noun.vector : undefined,
|
fix(storage): v4.8.0 metadata architecture refactoring - FIXES VFS bug
CRITICAL FIX: VFS bug that persisted through v4.5.1-v4.7.4 is NOW FIXED.
Root Cause:
- Storage adapters were not properly extracting standard fields from metadata
- This caused getVerbsBySource_internal() to return 0 relationships despite relationships existing
- VFS PathResolver couldn't navigate directory structure
Solution - Metadata Architecture Refactoring:
1. Move standard fields to top-level of HNSWNounWithMetadata and HNSWVerbWithMetadata
- type, createdAt, updatedAt, confidence, weight, service, data, createdBy
2. Update all 9 storage adapters to extract standard fields from metadata on load
3. Maintain backward compatibility at storage layer (metadata files unchanged)
Changes:
- src/coreTypes.ts: Update HNSWNounWithMetadata and HNSWVerbWithMetadata interfaces
- Add top-level standard fields
- Change data type from unknown to Record<string, any>
- Add confidence field to GraphVerb
- src/storage/baseStorage.ts: Add type cast pattern for standard field extraction
- src/storage/adapters/*.ts: Fix all 9 adapters (memoryStorage, fileSystemStorage, gcsStorage,
s3CompatibleStorage, r2Storage, opfsStorage, azureBlobStorage, typeAwareStorageAdapter)
- Extract standard fields from metadata on load
- Place at top-level of returned entities
- src/api/DataAPI.ts: Read fields from top-level instead of metadata
- src/graph/graphAdjacencyIndex.ts: Convert HNSWVerbWithMetadata to GraphVerb format
- src/utils/metadataIndex.ts: Fix typo (metadata → entityOrMetadata)
- src/types/brainy.types.ts: Add createdBy field to AddParams
- src/types/graphTypes.ts: Add service field to GraphVerb
Test Results:
✅ VFS bug FIXED - vfs.readdir('/') now returns files (was returning empty array)
✅ getVerbsBySource_internal() now returns relationships correctly
✅ Build succeeds with ZERO compilation errors
✅ 95.7% of tests pass (954/997)
Breaking Changes:
- None - backward compatibility maintained at storage layer
Version: 4.8.0
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-27 15:43:49 -07:00
|
|
|
type: noun.type || NounType.Thing, // v4.8.0: type at top-level
|
2025-09-11 16:23:32 -07:00
|
|
|
metadata: noun.metadata,
|
fix(storage): v4.8.0 metadata architecture refactoring - FIXES VFS bug
CRITICAL FIX: VFS bug that persisted through v4.5.1-v4.7.4 is NOW FIXED.
Root Cause:
- Storage adapters were not properly extracting standard fields from metadata
- This caused getVerbsBySource_internal() to return 0 relationships despite relationships existing
- VFS PathResolver couldn't navigate directory structure
Solution - Metadata Architecture Refactoring:
1. Move standard fields to top-level of HNSWNounWithMetadata and HNSWVerbWithMetadata
- type, createdAt, updatedAt, confidence, weight, service, data, createdBy
2. Update all 9 storage adapters to extract standard fields from metadata on load
3. Maintain backward compatibility at storage layer (metadata files unchanged)
Changes:
- src/coreTypes.ts: Update HNSWNounWithMetadata and HNSWVerbWithMetadata interfaces
- Add top-level standard fields
- Change data type from unknown to Record<string, any>
- Add confidence field to GraphVerb
- src/storage/baseStorage.ts: Add type cast pattern for standard field extraction
- src/storage/adapters/*.ts: Fix all 9 adapters (memoryStorage, fileSystemStorage, gcsStorage,
s3CompatibleStorage, r2Storage, opfsStorage, azureBlobStorage, typeAwareStorageAdapter)
- Extract standard fields from metadata on load
- Place at top-level of returned entities
- src/api/DataAPI.ts: Read fields from top-level instead of metadata
- src/graph/graphAdjacencyIndex.ts: Convert HNSWVerbWithMetadata to GraphVerb format
- src/utils/metadataIndex.ts: Fix typo (metadata → entityOrMetadata)
- src/types/brainy.types.ts: Add createdBy field to AddParams
- src/types/graphTypes.ts: Add service field to GraphVerb
Test Results:
✅ VFS bug FIXED - vfs.readdir('/') now returns files (was returning empty array)
✅ getVerbsBySource_internal() now returns relationships correctly
✅ Build succeeds with ZERO compilation errors
✅ 95.7% of tests pass (954/997)
Breaking Changes:
- None - backward compatibility maintained at storage layer
Version: 4.8.0
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-27 15:43:49 -07:00
|
|
|
service: noun.service // v4.8.0: service at top-level
|
2025-09-11 16:23:32 -07:00
|
|
|
}
|
|
|
|
|
entities.push(entity)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Get all relations
|
|
|
|
|
const verbsResult = await this.storage.getVerbs({
|
|
|
|
|
pagination: { limit: 1000000 }
|
|
|
|
|
})
|
|
|
|
|
const relations: BackupData['relations'] = []
|
|
|
|
|
|
|
|
|
|
for (const verb of verbsResult.items) {
|
|
|
|
|
relations.push({
|
|
|
|
|
id: verb.id,
|
|
|
|
|
from: verb.sourceId,
|
|
|
|
|
to: verb.targetId,
|
2025-10-17 12:29:27 -07:00
|
|
|
type: verb.verb as string,
|
fix(storage): v4.8.0 metadata architecture refactoring - FIXES VFS bug
CRITICAL FIX: VFS bug that persisted through v4.5.1-v4.7.4 is NOW FIXED.
Root Cause:
- Storage adapters were not properly extracting standard fields from metadata
- This caused getVerbsBySource_internal() to return 0 relationships despite relationships existing
- VFS PathResolver couldn't navigate directory structure
Solution - Metadata Architecture Refactoring:
1. Move standard fields to top-level of HNSWNounWithMetadata and HNSWVerbWithMetadata
- type, createdAt, updatedAt, confidence, weight, service, data, createdBy
2. Update all 9 storage adapters to extract standard fields from metadata on load
3. Maintain backward compatibility at storage layer (metadata files unchanged)
Changes:
- src/coreTypes.ts: Update HNSWNounWithMetadata and HNSWVerbWithMetadata interfaces
- Add top-level standard fields
- Change data type from unknown to Record<string, any>
- Add confidence field to GraphVerb
- src/storage/baseStorage.ts: Add type cast pattern for standard field extraction
- src/storage/adapters/*.ts: Fix all 9 adapters (memoryStorage, fileSystemStorage, gcsStorage,
s3CompatibleStorage, r2Storage, opfsStorage, azureBlobStorage, typeAwareStorageAdapter)
- Extract standard fields from metadata on load
- Place at top-level of returned entities
- src/api/DataAPI.ts: Read fields from top-level instead of metadata
- src/graph/graphAdjacencyIndex.ts: Convert HNSWVerbWithMetadata to GraphVerb format
- src/utils/metadataIndex.ts: Fix typo (metadata → entityOrMetadata)
- src/types/brainy.types.ts: Add createdBy field to AddParams
- src/types/graphTypes.ts: Add service field to GraphVerb
Test Results:
✅ VFS bug FIXED - vfs.readdir('/') now returns files (was returning empty array)
✅ getVerbsBySource_internal() now returns relationships correctly
✅ Build succeeds with ZERO compilation errors
✅ 95.7% of tests pass (954/997)
Breaking Changes:
- None - backward compatibility maintained at storage layer
Version: 4.8.0
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-27 15:43:49 -07:00
|
|
|
weight: verb.weight || 1.0, // v4.8.0: weight at top-level
|
2025-09-11 16:23:32 -07:00
|
|
|
metadata: verb.metadata
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Create backup data
|
|
|
|
|
const backupData: BackupData = {
|
|
|
|
|
version: '3.0.0',
|
|
|
|
|
timestamp: Date.now(),
|
|
|
|
|
entities,
|
|
|
|
|
relations,
|
|
|
|
|
stats: {
|
|
|
|
|
entityCount: entities.length,
|
|
|
|
|
relationCount: relations.length,
|
|
|
|
|
vectorDimensions: entities[0]?.vector?.length
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Compress if requested
|
|
|
|
|
if (compress) {
|
|
|
|
|
// Import zlib for compression
|
feat: add node: protocol to all Node.js built-in imports for bundler compatibility
- Updated all fs, path, crypto, os, url, util, events, http, https, net, child_process, stream, and zlib imports
- Changed both static imports and dynamic imports to use node: protocol
- This makes Brainy more bundler-friendly by explicitly marking Node.js built-ins
- Prevents bundlers from attempting to polyfill or bundle these modules
- Reduces bundle size for web applications using Brainy
- Improves tree-shaking and dead code elimination
Benefits for external bundlers:
- Clear distinction between Node.js built-ins and external dependencies
- No ambiguity about what needs polyfilling
- Smaller bundles for browser builds
- Better compatibility with modern bundlers (Webpack 5, Vite, Rollup, esbuild)
🤖 Generated with Claude Code
Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-17 14:20:21 -07:00
|
|
|
const { gzipSync } = await import('node:zlib')
|
2025-09-11 16:23:32 -07:00
|
|
|
const jsonString = JSON.stringify(backupData)
|
|
|
|
|
const compressed = gzipSync(Buffer.from(jsonString))
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
compressed: true,
|
|
|
|
|
data: compressed.toString('base64'),
|
|
|
|
|
originalSize: jsonString.length,
|
|
|
|
|
compressedSize: compressed.length
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return backupData
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Restore data from a backup
|
2025-10-30 09:08:14 -07:00
|
|
|
*
|
|
|
|
|
* v4.11.1: CRITICAL FIX - Now uses brain.addMany() and brain.relateMany()
|
|
|
|
|
* Previous implementation only wrote to storage cache without updating indexes,
|
|
|
|
|
* causing complete data loss on restart. This fix ensures:
|
|
|
|
|
* - All 5 indexes updated (HNSW, metadata, adjacency, sparse, type-aware)
|
|
|
|
|
* - Proper persistence to disk/cloud storage
|
|
|
|
|
* - Storage-aware batching for optimal performance
|
|
|
|
|
* - Atomic writes to prevent corruption
|
|
|
|
|
* - Data survives instance restart
|
2025-09-11 16:23:32 -07:00
|
|
|
*/
|
|
|
|
|
async restore(params: {
|
|
|
|
|
backup: BackupData
|
|
|
|
|
merge?: boolean
|
|
|
|
|
overwrite?: boolean
|
|
|
|
|
validate?: boolean
|
2025-10-30 09:08:14 -07:00
|
|
|
onProgress?: (completed: number, total: number) => void
|
|
|
|
|
}): Promise<{
|
|
|
|
|
entitiesRestored: number
|
|
|
|
|
relationshipsRestored: number
|
2025-10-30 13:19:08 -07:00
|
|
|
relationshipsSkipped: number
|
2025-10-30 09:08:14 -07:00
|
|
|
errors: Array<{ type: 'entity' | 'relation'; id: string; error: string }>
|
|
|
|
|
}> {
|
|
|
|
|
const { backup, merge = false, overwrite = false, validate = true, onProgress } = params
|
|
|
|
|
|
|
|
|
|
const result = {
|
|
|
|
|
entitiesRestored: 0,
|
|
|
|
|
relationshipsRestored: 0,
|
2025-10-30 13:19:08 -07:00
|
|
|
relationshipsSkipped: 0,
|
2025-10-30 09:08:14 -07:00
|
|
|
errors: [] as Array<{ type: 'entity' | 'relation'; id: string; error: string }>
|
|
|
|
|
}
|
2025-09-11 16:23:32 -07:00
|
|
|
|
|
|
|
|
// Validate backup format
|
|
|
|
|
if (validate) {
|
|
|
|
|
if (!backup.version || !backup.entities || !backup.relations) {
|
2025-10-30 09:08:14 -07:00
|
|
|
throw new Error('Invalid backup format: missing version, entities, or relations')
|
2025-09-11 16:23:32 -07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-30 09:08:14 -07:00
|
|
|
// Validate brain instance is available (required for v4.11.1+ restore)
|
|
|
|
|
if (!this.brain) {
|
|
|
|
|
throw new Error(
|
|
|
|
|
'Restore requires brain instance. DataAPI must be initialized with brain reference. ' +
|
|
|
|
|
'Use: await brain.data() instead of constructing DataAPI directly.'
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-11 16:23:32 -07:00
|
|
|
// Clear existing data if not merging
|
|
|
|
|
if (!merge && overwrite) {
|
|
|
|
|
await this.clear({ entities: true, relations: true })
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-30 09:08:14 -07:00
|
|
|
// ============================================
|
|
|
|
|
// Phase 1: Restore entities using addMany()
|
|
|
|
|
// v4.11.1: Uses proper persistence path through brain.addMany()
|
|
|
|
|
// ============================================
|
|
|
|
|
|
|
|
|
|
// Prepare entity parameters for addMany()
|
|
|
|
|
const entityParams = backup.entities
|
|
|
|
|
.filter(entity => {
|
|
|
|
|
// Skip existing entities when merging without overwrite
|
|
|
|
|
if (merge && !overwrite) {
|
|
|
|
|
// Note: We'll rely on addMany's internal duplicate handling
|
|
|
|
|
// rather than checking each entity individually (performance)
|
|
|
|
|
return true
|
2025-10-17 12:29:27 -07:00
|
|
|
}
|
2025-10-30 09:08:14 -07:00
|
|
|
return true
|
|
|
|
|
})
|
|
|
|
|
.map(entity => {
|
|
|
|
|
// Extract data field from metadata (backup format compatibility)
|
|
|
|
|
// Backup stores the original data in metadata.data
|
|
|
|
|
const data = entity.metadata?.data || entity.id
|
2025-10-17 12:29:27 -07:00
|
|
|
|
2025-10-30 09:08:14 -07:00
|
|
|
return {
|
|
|
|
|
id: entity.id,
|
|
|
|
|
data, // Required field for brainy.add()
|
|
|
|
|
type: entity.type,
|
|
|
|
|
metadata: entity.metadata || {},
|
|
|
|
|
vector: entity.vector, // Preserve original vectors from backup
|
2025-10-17 12:29:27 -07:00
|
|
|
service: entity.service,
|
2025-10-30 09:08:14 -07:00
|
|
|
// Preserve confidence and weight if available
|
|
|
|
|
confidence: entity.metadata?.confidence,
|
|
|
|
|
weight: entity.metadata?.weight
|
2025-09-11 16:23:32 -07:00
|
|
|
}
|
2025-10-30 09:08:14 -07:00
|
|
|
})
|
2025-09-11 16:23:32 -07:00
|
|
|
|
2025-10-30 09:08:14 -07:00
|
|
|
// Restore entities in batches using storage-aware batching (v4.11.0)
|
2025-10-30 13:19:08 -07:00
|
|
|
// v4.11.1: Track successful entity IDs to prevent orphaned relationships
|
|
|
|
|
const successfulEntityIds = new Set<string>()
|
|
|
|
|
|
2025-10-30 09:08:14 -07:00
|
|
|
if (entityParams.length > 0) {
|
|
|
|
|
try {
|
|
|
|
|
const addResult = await this.brain.addMany({
|
|
|
|
|
items: entityParams,
|
|
|
|
|
continueOnError: true,
|
|
|
|
|
onProgress: (done: number, total: number) => {
|
|
|
|
|
onProgress?.(done, backup.entities.length + backup.relations.length)
|
2025-09-11 16:23:32 -07:00
|
|
|
}
|
2025-10-30 09:08:14 -07:00
|
|
|
})
|
2025-09-11 16:23:32 -07:00
|
|
|
|
2025-10-30 09:08:14 -07:00
|
|
|
result.entitiesRestored = addResult.successful.length
|
|
|
|
|
|
2025-10-30 13:19:08 -07:00
|
|
|
// Build Set of successfully restored entity IDs (v4.11.1 Bug Fix)
|
|
|
|
|
addResult.successful.forEach((entityId: string) => {
|
|
|
|
|
successfulEntityIds.add(entityId)
|
|
|
|
|
})
|
|
|
|
|
|
2025-10-30 09:08:14 -07:00
|
|
|
// Track errors
|
|
|
|
|
addResult.failed.forEach((failure: any) => {
|
|
|
|
|
result.errors.push({
|
|
|
|
|
type: 'entity',
|
|
|
|
|
id: failure.item?.id || 'unknown',
|
|
|
|
|
error: failure.error || 'Unknown error'
|
|
|
|
|
})
|
|
|
|
|
})
|
2025-09-11 16:23:32 -07:00
|
|
|
} catch (error) {
|
2025-10-30 09:08:14 -07:00
|
|
|
throw new Error(`Failed to restore entities: ${(error as Error).message}`)
|
2025-09-11 16:23:32 -07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-30 09:08:14 -07:00
|
|
|
// ============================================
|
|
|
|
|
// Phase 2: Restore relationships using relateMany()
|
2025-10-30 13:19:08 -07:00
|
|
|
// v4.11.1: CRITICAL FIX - Filter orphaned relationships
|
2025-10-30 09:08:14 -07:00
|
|
|
// ============================================
|
2025-10-17 12:29:27 -07:00
|
|
|
|
2025-10-30 13:19:08 -07:00
|
|
|
// Prepare relationship parameters - filter out orphaned references
|
2025-10-30 09:08:14 -07:00
|
|
|
const relationParams = backup.relations
|
|
|
|
|
.filter(relation => {
|
|
|
|
|
// Skip existing relations when merging without overwrite
|
|
|
|
|
if (merge && !overwrite) {
|
|
|
|
|
// Note: We'll rely on relateMany's internal duplicate handling
|
|
|
|
|
return true
|
2025-10-17 12:29:27 -07:00
|
|
|
}
|
2025-10-30 13:19:08 -07:00
|
|
|
|
|
|
|
|
// v4.11.1 CRITICAL BUG FIX: Skip relationships where source or target entity failed to restore
|
|
|
|
|
// This prevents creating orphaned references that cause "Entity not found" errors
|
|
|
|
|
const sourceExists = successfulEntityIds.has(relation.from)
|
|
|
|
|
const targetExists = successfulEntityIds.has(relation.to)
|
|
|
|
|
|
|
|
|
|
if (!sourceExists || !targetExists) {
|
|
|
|
|
// Track skipped relationship
|
|
|
|
|
result.relationshipsSkipped++
|
|
|
|
|
|
|
|
|
|
// Optionally log for debugging (can be removed in production)
|
|
|
|
|
if (process.env.NODE_ENV !== 'production') {
|
|
|
|
|
console.debug(
|
|
|
|
|
`Skipping orphaned relationship: ${relation.from} -> ${relation.to} ` +
|
|
|
|
|
`(${!sourceExists ? 'missing source' : 'missing target'})`
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-30 09:08:14 -07:00
|
|
|
return true
|
|
|
|
|
})
|
|
|
|
|
.map(relation => ({
|
|
|
|
|
from: relation.from,
|
|
|
|
|
to: relation.to,
|
|
|
|
|
type: relation.type as VerbType,
|
|
|
|
|
metadata: relation.metadata || {},
|
|
|
|
|
weight: relation.weight || 1.0
|
|
|
|
|
// Note: relation.id is ignored - brain.relate() generates new IDs
|
|
|
|
|
// This is intentional to avoid ID conflicts
|
|
|
|
|
}))
|
2025-09-11 16:23:32 -07:00
|
|
|
|
2025-10-30 09:08:14 -07:00
|
|
|
// Restore relationships in batches using storage-aware batching (v4.11.0)
|
|
|
|
|
if (relationParams.length > 0) {
|
|
|
|
|
try {
|
|
|
|
|
const relateResult = await this.brain.relateMany({
|
|
|
|
|
items: relationParams,
|
|
|
|
|
continueOnError: true
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
result.relationshipsRestored = relateResult.successful.length
|
2025-09-11 16:23:32 -07:00
|
|
|
|
2025-10-30 09:08:14 -07:00
|
|
|
// Track errors
|
|
|
|
|
relateResult.failed.forEach((failure: any) => {
|
|
|
|
|
result.errors.push({
|
|
|
|
|
type: 'relation',
|
|
|
|
|
id: failure.item?.from + '->' + failure.item?.to || 'unknown',
|
|
|
|
|
error: failure.error || 'Unknown error'
|
|
|
|
|
})
|
|
|
|
|
})
|
2025-09-11 16:23:32 -07:00
|
|
|
} catch (error) {
|
2025-10-30 09:08:14 -07:00
|
|
|
throw new Error(`Failed to restore relationships: ${(error as Error).message}`)
|
2025-09-11 16:23:32 -07:00
|
|
|
}
|
|
|
|
|
}
|
2025-10-30 09:08:14 -07:00
|
|
|
|
|
|
|
|
// ============================================
|
|
|
|
|
// Phase 3: Verify restoration succeeded
|
|
|
|
|
// ============================================
|
|
|
|
|
|
|
|
|
|
// Sample verification: Check that first entity is actually retrievable
|
|
|
|
|
if (backup.entities.length > 0 && result.entitiesRestored > 0) {
|
|
|
|
|
const firstEntityId = backup.entities[0].id
|
|
|
|
|
const verified = await this.brain.get(firstEntityId)
|
|
|
|
|
|
|
|
|
|
if (!verified) {
|
|
|
|
|
console.warn(
|
|
|
|
|
`⚠️ Restore completed but verification failed - entity ${firstEntityId} not retrievable. ` +
|
|
|
|
|
`This may indicate a persistence issue with the storage adapter.`
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return result
|
2025-09-11 16:23:32 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Clear data
|
|
|
|
|
*/
|
|
|
|
|
async clear(params: {
|
|
|
|
|
entities?: boolean
|
|
|
|
|
relations?: boolean
|
|
|
|
|
config?: boolean
|
|
|
|
|
} = {}): Promise<void> {
|
|
|
|
|
const { entities = true, relations = true, config = false } = params
|
|
|
|
|
|
|
|
|
|
if (entities) {
|
|
|
|
|
// Clear all entities
|
|
|
|
|
const nounsResult = await this.storage.getNouns({
|
|
|
|
|
pagination: { limit: 1000000 }
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
for (const noun of nounsResult.items) {
|
|
|
|
|
await this.storage.deleteNoun(noun.id)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Also clear the HNSW index if available
|
|
|
|
|
if (this.brain?.index?.clear) {
|
|
|
|
|
this.brain.index.clear()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Clear metadata index if available
|
|
|
|
|
if (this.brain?.metadataIndex) {
|
|
|
|
|
await this.brain.metadataIndex.rebuild() // Rebuild empty index
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (relations) {
|
|
|
|
|
// Clear all relations
|
|
|
|
|
const verbsResult = await this.storage.getVerbs({
|
|
|
|
|
pagination: { limit: 1000000 }
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
for (const verb of verbsResult.items) {
|
|
|
|
|
await this.storage.deleteVerb(verb.id)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (config) {
|
|
|
|
|
// Clear configuration would be handled by ConfigAPI
|
|
|
|
|
// For now, skip this
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Import data from various formats
|
|
|
|
|
*/
|
|
|
|
|
async import(params: ImportOptions & { data: any }): Promise<ImportResult> {
|
|
|
|
|
const {
|
|
|
|
|
data,
|
|
|
|
|
format,
|
|
|
|
|
mapping = {},
|
|
|
|
|
batchSize = 100,
|
|
|
|
|
validate = true
|
|
|
|
|
} = params
|
|
|
|
|
|
|
|
|
|
const result: ImportResult = {
|
|
|
|
|
successful: 0,
|
|
|
|
|
failed: 0,
|
|
|
|
|
errors: [],
|
|
|
|
|
duration: 0
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const startTime = Date.now()
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
// ALWAYS use neural import for proper type matching
|
|
|
|
|
const { UniversalImportAPI } = await import('./UniversalImportAPI.js')
|
|
|
|
|
const universalImport = new UniversalImportAPI(this.brain)
|
|
|
|
|
await universalImport.init()
|
|
|
|
|
|
|
|
|
|
// Convert to ImportSource format
|
|
|
|
|
const neuralResult = await universalImport.import({
|
|
|
|
|
type: 'object',
|
|
|
|
|
data,
|
|
|
|
|
format: format || 'json',
|
|
|
|
|
metadata: { mapping, batchSize, validate }
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Convert neural result to ImportResult format
|
|
|
|
|
result.successful = neuralResult.stats.entitiesCreated
|
|
|
|
|
result.failed = 0 // Neural import always succeeds with best match
|
|
|
|
|
result.duration = neuralResult.stats.processingTimeMs
|
|
|
|
|
|
|
|
|
|
// Log relationships created
|
|
|
|
|
if (neuralResult.stats.relationshipsCreated > 0) {
|
|
|
|
|
console.log(`Neural import also created ${neuralResult.stats.relationshipsCreated} relationships`)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return result
|
|
|
|
|
} catch (error) {
|
|
|
|
|
// Fallback to legacy import ONLY if neural import fails to load
|
|
|
|
|
console.warn('Neural import failed, using legacy import:', error)
|
|
|
|
|
|
|
|
|
|
let items: any[] = []
|
|
|
|
|
|
|
|
|
|
// Parse data based on format
|
|
|
|
|
switch (format) {
|
|
|
|
|
case 'json':
|
|
|
|
|
items = Array.isArray(data) ? data : [data]
|
|
|
|
|
break
|
|
|
|
|
|
|
|
|
|
case 'csv':
|
|
|
|
|
// CSV parsing would go here
|
|
|
|
|
// For now, assume data is already parsed
|
|
|
|
|
items = data
|
|
|
|
|
break
|
|
|
|
|
|
|
|
|
|
// Parquet format removed - not implemented
|
|
|
|
|
|
|
|
|
|
default:
|
|
|
|
|
throw new Error(`Unsupported format: ${format}`)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Process items in batches
|
|
|
|
|
for (let i = 0; i < items.length; i += batchSize) {
|
|
|
|
|
const batch = items.slice(i, i + batchSize)
|
|
|
|
|
|
|
|
|
|
for (const item of batch) {
|
|
|
|
|
try {
|
|
|
|
|
// Apply field mapping
|
|
|
|
|
const mapped = this.applyMapping(item, mapping)
|
|
|
|
|
|
|
|
|
|
// Validate if requested
|
|
|
|
|
if (validate) {
|
|
|
|
|
this.validateImportItem(mapped)
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-17 12:29:27 -07:00
|
|
|
// v4.0.0: Save entity - separate vector and metadata
|
|
|
|
|
const id = mapped.id || this.generateId()
|
2025-09-11 16:23:32 -07:00
|
|
|
const noun: HNSWNoun = {
|
2025-10-17 12:29:27 -07:00
|
|
|
id,
|
2025-09-11 16:23:32 -07:00
|
|
|
vector: mapped.vector || new Array(384).fill(0),
|
|
|
|
|
connections: new Map(),
|
2025-10-17 12:29:27 -07:00
|
|
|
level: 0
|
2025-09-11 16:23:32 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await this.storage.saveNoun(noun)
|
2025-10-17 12:29:27 -07:00
|
|
|
await this.storage.saveNounMetadata(id, { ...mapped, createdAt: Date.now() })
|
2025-09-11 16:23:32 -07:00
|
|
|
result.successful++
|
|
|
|
|
} catch (error) {
|
|
|
|
|
result.failed++
|
|
|
|
|
result.errors.push({
|
|
|
|
|
item,
|
|
|
|
|
error: (error as Error).message
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
result.duration = Date.now() - startTime
|
|
|
|
|
return result
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Export data to various formats
|
|
|
|
|
*/
|
|
|
|
|
async export(params: ExportOptions = {}): Promise<any> {
|
|
|
|
|
const {
|
|
|
|
|
format = 'json',
|
|
|
|
|
filter = {},
|
|
|
|
|
includeVectors = false
|
|
|
|
|
} = params
|
|
|
|
|
|
|
|
|
|
// Get filtered entities
|
|
|
|
|
const nounsResult = await this.storage.getNouns({
|
|
|
|
|
pagination: { limit: 1000000 }
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
let entities = nounsResult.items
|
|
|
|
|
|
|
|
|
|
// Apply filters
|
|
|
|
|
if (filter.type) {
|
|
|
|
|
const types = Array.isArray(filter.type) ? filter.type : [filter.type]
|
|
|
|
|
entities = entities.filter(e =>
|
|
|
|
|
types.includes(e.metadata?.noun as NounType)
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (filter.service) {
|
|
|
|
|
entities = entities.filter(e =>
|
|
|
|
|
e.metadata?.service === filter.service
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (filter.where) {
|
|
|
|
|
entities = entities.filter(e =>
|
|
|
|
|
this.matchesFilter(e.metadata, filter.where!)
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Format data based on export format
|
|
|
|
|
switch (format) {
|
|
|
|
|
case 'json':
|
|
|
|
|
return entities.map(e => ({
|
|
|
|
|
id: e.id,
|
|
|
|
|
vector: includeVectors ? e.vector : undefined,
|
|
|
|
|
...e.metadata
|
|
|
|
|
}))
|
|
|
|
|
|
|
|
|
|
case 'csv':
|
|
|
|
|
// Convert to CSV format
|
|
|
|
|
// For now, return simplified format
|
|
|
|
|
return this.convertToCSV(entities)
|
|
|
|
|
|
|
|
|
|
// Parquet format removed - not implemented
|
|
|
|
|
|
|
|
|
|
default:
|
|
|
|
|
throw new Error(`Unsupported export format: ${format}`)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get storage statistics
|
|
|
|
|
*/
|
|
|
|
|
async getStats(): Promise<{
|
|
|
|
|
entities: number
|
|
|
|
|
relations: number
|
|
|
|
|
storageSize?: number
|
|
|
|
|
vectorDimensions?: number
|
|
|
|
|
}> {
|
|
|
|
|
const nounsResult = await this.storage.getNouns({
|
|
|
|
|
pagination: { limit: 1 }
|
|
|
|
|
})
|
|
|
|
|
const verbsResult = await this.storage.getVerbs({
|
|
|
|
|
pagination: { limit: 1 }
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const firstNoun = nounsResult.items[0]
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
entities: nounsResult.totalCount || nounsResult.items.length,
|
|
|
|
|
relations: verbsResult.totalCount || verbsResult.items.length,
|
|
|
|
|
vectorDimensions: firstNoun?.vector?.length
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Helper methods
|
|
|
|
|
|
|
|
|
|
private applyMapping(item: any, mapping: Record<string, string>): any {
|
|
|
|
|
const mapped: any = {}
|
|
|
|
|
|
|
|
|
|
for (const [key, value] of Object.entries(item)) {
|
|
|
|
|
const mappedKey = mapping[key] || key
|
|
|
|
|
mapped[mappedKey] = value
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return mapped
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private validateImportItem(item: any): void {
|
|
|
|
|
// Basic validation
|
|
|
|
|
if (!item || typeof item !== 'object') {
|
|
|
|
|
throw new Error('Invalid item: must be an object')
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Could add more validation here
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private matchesFilter(metadata: any, filter: Record<string, any>): boolean {
|
|
|
|
|
for (const [key, value] of Object.entries(filter)) {
|
|
|
|
|
if (metadata[key] !== value) {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-17 12:29:27 -07:00
|
|
|
private convertToCSV(entities: Array<{ id: string; metadata?: any }>): string {
|
2025-09-11 16:23:32 -07:00
|
|
|
if (entities.length === 0) return ''
|
2025-10-17 12:29:27 -07:00
|
|
|
|
2025-09-11 16:23:32 -07:00
|
|
|
// Get all unique keys from metadata
|
|
|
|
|
const keys = new Set<string>()
|
|
|
|
|
for (const entity of entities) {
|
|
|
|
|
if (entity.metadata) {
|
|
|
|
|
Object.keys(entity.metadata).forEach(k => keys.add(k))
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-10-17 12:29:27 -07:00
|
|
|
|
2025-09-11 16:23:32 -07:00
|
|
|
// Create CSV header
|
|
|
|
|
const headers = ['id', ...Array.from(keys)]
|
|
|
|
|
const rows = [headers.join(',')]
|
2025-10-17 12:29:27 -07:00
|
|
|
|
2025-09-11 16:23:32 -07:00
|
|
|
// Add data rows
|
|
|
|
|
for (const entity of entities) {
|
|
|
|
|
const row = [entity.id]
|
|
|
|
|
for (const key of keys) {
|
|
|
|
|
const value = entity.metadata?.[key] || ''
|
|
|
|
|
// Escape values that contain commas
|
2025-10-17 12:29:27 -07:00
|
|
|
const escaped = String(value).includes(',')
|
|
|
|
|
? `"${String(value).replace(/"/g, '""')}"`
|
2025-09-11 16:23:32 -07:00
|
|
|
: String(value)
|
|
|
|
|
row.push(escaped)
|
|
|
|
|
}
|
|
|
|
|
rows.push(row.join(','))
|
|
|
|
|
}
|
2025-10-17 12:29:27 -07:00
|
|
|
|
2025-09-11 16:23:32 -07:00
|
|
|
return rows.join('\n')
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private generateId(): string {
|
|
|
|
|
return `import_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
|
|
|
|
|
}
|
|
|
|
|
}
|