brainy/scripts/migrate-vfs-storage.ts
David Snelling 1874b77896 feat: add ImageHandler with EXIF extraction and comprehensive MIME detection (v5.2.0)
Implements Phase 1.5 (Comprehensive MIME Type Detection) and adds built-in image processing support to IntelligentImportAugmentation.

**New Features:**
- ImageHandler: Extracts image metadata (dimensions, format, color space) using sharp
- EXIF extraction: Camera data, GPS, timestamps using exifr library
- Support for JPEG, PNG, WebP, GIF, TIFF, BMP, SVG, HEIC, AVIF formats
- MimeTypeDetector: Unified MIME type detection with magic byte support
- FormatDetector: Enhanced with image format detection via MIME + magic bytes

**Architecture Fixes:**
- Fixed brain.import() augmentation pipeline integration (src/brainy.ts:3140-3154)
- Added parameter spreading for ImportSource objects to enable augmentation access
- Fixed metadata propagation through ImportCoordinator to final results
- Added augmentation data check in ImportCoordinator.extract()

**Integration:**
- ImageHandler registered as built-in handler alongside CSV, Excel, PDF
- Images import as 'media' entities with 'image' subtype
- Full metadata preserved in knowledge graph entities
- Configuration options: enableImage, extractEXIF, imageDefaults

**Test Coverage:**
- 15 integration tests (image-import.test.ts) - 100% passing
- 27 unit tests (image-handler.test.ts) - 100% passing
- Format detection tests for all supported image types
- Error handling and resilience tests

**Breaking Changes:** None - backward compatible

Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-03 14:06:17 -08:00

269 lines
8.6 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* VFS Storage Migration Script: v5.1.x → v5.2.0
*
* Converts VFS files from 3-tier storage (inline/reference/chunked)
* to unified BlobStorage (content-addressable with deduplication)
*
* Usage:
* import { migrateVFSStorage } from './scripts/migrate-vfs-storage'
* await migrateVFSStorage(brain)
*/
import { Brainy } from '../src/brainy.js'
import { VFSMetadata } from '../src/vfs/types.js'
export interface MigrationStats {
filesProcessed: number
inlineMigrated: number
referenceMigrated: number
chunkedMigrated: number
alreadyMigrated: number
errors: number
deduplicated: number
totalSizeBefore: number
totalSizeAfter: number
durationMs: number
}
export async function migrateVFSStorage(
brain: Brainy,
options: {
dryRun?: boolean
verbose?: boolean
batchSize?: number
} = {}
): Promise<MigrationStats> {
const { dryRun = false, verbose = false, batchSize = 100 } = options
const stats: MigrationStats = {
filesProcessed: 0,
inlineMigrated: 0,
referenceMigrated: 0,
chunkedMigrated: 0,
alreadyMigrated: 0,
errors: 0,
deduplicated: 0,
totalSizeBefore: 0,
totalSizeAfter: 0,
durationMs: 0
}
const startTime = Date.now()
if (verbose) {
console.log('🔄 Starting VFS storage migration (v5.1.x → v5.2.0)')
if (dryRun) console.log(' DRY RUN: No changes will be made')
}
try {
// Find all VFS file entities
const files = await brain.find({
where: { 'metadata.vfsType': 'file' },
limit: 100000 // Large limit to get all files
})
if (verbose) {
console.log(`📁 Found ${files.length} VFS files to process`)
}
// Process in batches
for (let i = 0; i < files.length; i += batchSize) {
const batch = files.slice(i, i + batchSize)
await Promise.all(batch.map(async (file) => {
try {
stats.filesProcessed++
const metadata = file.metadata as VFSMetadata
const storage = metadata.storage
// Already migrated?
if (storage?.type === 'blob') {
stats.alreadyMigrated++
if (verbose && stats.filesProcessed % 100 === 0) {
console.log(`${metadata.path} (already migrated)`)
}
return
}
let buffer: Buffer | null = null
let sizeBefore = 0
// Migrate based on old storage type
if (!storage || storage.type === 'inline') {
// Inline storage: content in metadata.rawData
if (metadata.rawData) {
buffer = Buffer.from(metadata.rawData, 'base64')
sizeBefore = buffer.length
stats.inlineMigrated++
if (verbose && stats.filesProcessed % 100 === 0) {
console.log(`${metadata.path} (inline, ${sizeBefore} bytes)`)
}
}
} else if (storage.type === 'reference') {
// Reference storage: content stored as separate entity
if (storage.key) {
const contentEntity = await brain.get(storage.key)
if (contentEntity && contentEntity.data) {
buffer = Buffer.isBuffer(contentEntity.data)
? contentEntity.data
: Buffer.from(contentEntity.data as string)
sizeBefore = buffer.length
stats.referenceMigrated++
if (verbose && stats.filesProcessed % 100 === 0) {
console.log(`${metadata.path} (reference, ${sizeBefore} bytes)`)
}
// Delete old reference entity (unless dry run)
if (!dryRun) {
await brain.delete(storage.key)
}
}
}
} else if (storage.type === 'chunked') {
// Chunked storage: content split across multiple entities
if (storage.chunks && storage.chunks.length > 0) {
const chunkBuffers = await Promise.all(
storage.chunks.map(async (chunkId) => {
const chunkEntity = await brain.get(chunkId)
if (chunkEntity && chunkEntity.data) {
return Buffer.isBuffer(chunkEntity.data)
? chunkEntity.data
: Buffer.from(chunkEntity.data as string)
}
return Buffer.alloc(0)
})
)
buffer = Buffer.concat(chunkBuffers)
sizeBefore = buffer.length
stats.chunkedMigrated++
if (verbose && stats.filesProcessed % 100 === 0) {
console.log(`${metadata.path} (chunked, ${storage.chunks.length} chunks, ${sizeBefore} bytes)`)
}
// Delete old chunk entities (unless dry run)
if (!dryRun) {
await Promise.all(storage.chunks.map(id => brain.delete(id)))
}
}
}
// Store in BlobStorage
if (buffer) {
stats.totalSizeBefore += sizeBefore
if (!dryRun) {
// Write to BlobStorage (content-addressable)
const blobHash = await brain.vfs['blobStorage'].write(buffer)
// Check if deduplicated
const blobMeta = await brain.vfs['blobStorage'].getMetadata(blobHash)
if (blobMeta && blobMeta.refCount > 1) {
stats.deduplicated++
}
// Update VFS metadata
await brain.update(file.id, {
metadata: {
...metadata,
storage: {
type: 'blob',
hash: blobHash,
size: buffer.length,
compressed: blobMeta?.compressed
}
}
})
stats.totalSizeAfter += blobMeta?.compressedSize || buffer.length
} else {
// Dry run: just count sizes
stats.totalSizeAfter += buffer.length
}
}
} catch (error) {
stats.errors++
if (verbose) {
console.error(` ✗ Error migrating ${file.metadata?.path}: ${error.message}`)
}
}
}))
if (verbose && i + batchSize < files.length) {
const progress = Math.round(((i + batchSize) / files.length) * 100)
console.log(` Progress: ${progress}% (${i + batchSize}/${files.length})`)
}
}
stats.durationMs = Date.now() - startTime
if (verbose) {
console.log('\n✅ Migration complete!\n')
console.log('📊 Statistics:')
console.log(` Files processed: ${stats.filesProcessed}`)
console.log(` Inline migrated: ${stats.inlineMigrated}`)
console.log(` Reference migrated: ${stats.referenceMigrated}`)
console.log(` Chunked migrated: ${stats.chunkedMigrated}`)
console.log(` Already migrated: ${stats.alreadyMigrated}`)
console.log(` Deduplicated: ${stats.deduplicated}`)
console.log(` Errors: ${stats.errors}`)
console.log(` Size before: ${formatBytes(stats.totalSizeBefore)}`)
console.log(` Size after: ${formatBytes(stats.totalSizeAfter)}`)
console.log(` Space saved: ${formatBytes(stats.totalSizeBefore - stats.totalSizeAfter)}`)
console.log(` Duration: ${stats.durationMs}ms`)
if (dryRun) {
console.log('\n⚠ DRY RUN: No changes were made')
}
}
return stats
} catch (error) {
if (verbose) {
console.error('❌ Migration failed:', error)
}
throw error
}
}
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 Bytes'
const k = 1024
const sizes = ['Bytes', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i]
}
/**
* Auto-detect and migrate if needed
* Called automatically on brain.init() if old format detected
*/
export async function autoMigrateIfNeeded(brain: Brainy): Promise<boolean> {
try {
// Check for old format files
const oldFormatFiles = await brain.find({
where: {
'metadata.vfsType': 'file',
'metadata.storage.type': { $in: ['inline', 'reference', 'chunked'] }
},
limit: 1
})
if (oldFormatFiles.length > 0) {
console.log('🔄 Detected v5.1.x VFS storage format. Auto-migrating to v5.2.0...')
await migrateVFSStorage(brain, { verbose: true })
return true
}
return false
} catch (error) {
console.error('❌ Auto-migration failed:', error)
return false
}
}