UNPKG

@toast-studios/asset-manager

Version:

A React Native asset management library with intelligent caching and loading strategies

938 lines (937 loc) 36 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.StorageManager = void 0; const react_native_fs_1 = __importDefault(require("react-native-fs")); const crypto_1 = require("crypto"); /** * Storage management utility for asset files with hash-based deduplication */ class StorageManager { constructor(basePath, enableLogging = false) { this.assetRegistry = {}; this.hashPathMapping = {}; this.assetRegistryLoaded = false; this.hashPathMappingLoaded = false; // Batching optimization fields this.pendingAssetUpdates = new Set(); this.pendingHashUpdates = new Set(); this.saveDebounceMs = 1000; // 1 second batching window this.isDestroyed = false; this.basePath = basePath; this.enableLogging = enableLogging; this.assetRegistryPath = `${this.basePath}/asset-registry.json`; this.hashPathMappingPath = `${this.basePath}/hash-paths.json`; } /** * Schedule a batched save operation with debouncing */ scheduleSave() { if (this.isDestroyed) { return; } // Clear existing timer if (this.saveDebounceTimer) { clearTimeout(this.saveDebounceTimer); } // Schedule batched save after debounce period this.saveDebounceTimer = setTimeout(async () => { await this.flushPendingSaves(); }, this.saveDebounceMs); } /** * Immediately flush all pending saves to disk */ async flushPendingSaves() { if (this.isDestroyed) { return; } const promises = []; // Save asset registry if there are pending updates if (this.pendingAssetUpdates.size > 0) { promises.push(this.saveAssetRegistryToDisk()); this.pendingAssetUpdates.clear(); } // Save hash path mapping if there are pending updates if (this.pendingHashUpdates.size > 0) { promises.push(this.saveHashPathMappingToDisk()); this.pendingHashUpdates.clear(); } // Clear the timer if (this.saveDebounceTimer) { clearTimeout(this.saveDebounceTimer); this.saveDebounceTimer = undefined; } // Execute all saves in parallel await Promise.all(promises); } /** * Initialize storage manager */ async initialize() { try { // Ensure base directory exists const exists = await react_native_fs_1.default.exists(this.basePath); if (!exists) { await react_native_fs_1.default.mkdir(this.basePath); } // Load existing registries await this.loadAssetRegistry(); await this.loadHashPathMapping(); this.log('StorageManager initialized'); } catch (error) { throw new Error(`Failed to initialize StorageManager: ${error}`); } } /** * Load asset registry from disk */ async loadAssetRegistry() { try { const exists = await react_native_fs_1.default.exists(this.assetRegistryPath); if (exists) { const registryData = await react_native_fs_1.default.readFile(this.assetRegistryPath, 'utf8'); this.assetRegistry = JSON.parse(registryData); this.log(`Loaded asset registry with ${Object.keys(this.assetRegistry).length} entries`); } else { this.assetRegistry = {}; this.log('Asset registry not found, starting with empty registry'); } this.assetRegistryLoaded = true; } catch (error) { this.log(`Error loading asset registry: ${error}`); this.assetRegistry = {}; this.assetRegistryLoaded = true; } } /** * Save asset registry to disk immediately (used by batching system) */ async saveAssetRegistryToDisk() { try { await react_native_fs_1.default.writeFile(this.assetRegistryPath, JSON.stringify(this.assetRegistry, null, 2), 'utf8'); this.log(`Saved asset registry with ${Object.keys(this.assetRegistry).length} entries`); } catch (error) { this.log(`Error saving asset registry: ${error}`); } } /** * Save asset registry with batching optimization */ async saveAssetRegistry() { // For immediate saves (e.g., critical operations), flush immediately if (this.pendingAssetUpdates.size === 0) { return; } await this.flushPendingSaves(); } /** * Load hash path mapping from disk */ async loadHashPathMapping() { try { const exists = await react_native_fs_1.default.exists(this.hashPathMappingPath); if (exists) { const mappingData = await react_native_fs_1.default.readFile(this.hashPathMappingPath, 'utf8'); this.hashPathMapping = JSON.parse(mappingData); this.log(`Loaded hash path mapping with ${Object.keys(this.hashPathMapping).length} entries`); } else { this.hashPathMapping = {}; this.log('Hash path mapping not found, starting with empty mapping'); } this.hashPathMappingLoaded = true; } catch (error) { this.log(`Error loading hash path mapping: ${error}`); this.hashPathMapping = {}; this.hashPathMappingLoaded = true; } } /** * Save hash path mapping to disk immediately (used by batching system) */ async saveHashPathMappingToDisk() { try { await react_native_fs_1.default.writeFile(this.hashPathMappingPath, JSON.stringify(this.hashPathMapping, null, 2), 'utf8'); this.log(`Saved hash path mapping with ${Object.keys(this.hashPathMapping).length} entries`); } catch (error) { this.log(`Error saving hash path mapping: ${error}`); } } /** * Save hash path mapping with batching optimization */ async saveHashPathMapping() { // For immediate saves (e.g., critical operations), flush immediately if (this.pendingHashUpdates.size === 0) { return; } await this.flushPendingSaves(); } /** * Get comprehensive storage information */ async getStorageInfo() { if (!this.assetRegistryLoaded) { await this.loadAssetRegistry(); } if (!this.hashPathMappingLoaded) { await this.loadHashPathMapping(); } const totalAssets = Object.keys(this.assetRegistry).length; const uniqueFiles = Object.keys(this.hashPathMapping).length; let totalOriginalSize = 0; let actualStorageUsed = 0; // Calculate total original size (sum of all asset sizes) for (const assetEntry of Object.values(this.assetRegistry)) { totalOriginalSize += assetEntry.size; } // Calculate actual storage used by getting file sizes from unique files for (const path of Object.values(this.hashPathMapping)) { try { const fullPath = this.getFullPath(path); const exists = await react_native_fs_1.default.exists(fullPath); if (exists) { const stat = await react_native_fs_1.default.stat(fullPath); actualStorageUsed += stat.size; } } catch (error) { this.log(`Error getting file size for ${path}: ${error}`); } } const spaceSaved = totalOriginalSize - actualStorageUsed; const deduplicationRatio = totalOriginalSize > 0 ? (spaceSaved / totalOriginalSize) * 100 : 0; return { totalAssets, uniqueFiles, totalOriginalSize, actualStorageUsed, spaceSaved, deduplicationRatio, }; } /** * Check if asset exists locally with optional ETag validation */ async assetExists(assetPath, etag) { try { const fullPath = this.getFullPath(assetPath); const exists = await react_native_fs_1.default.exists(fullPath); if (!exists) return false; // If ETag is provided, validate it if (etag) { const assetEntry = this.assetRegistry[`path:${assetPath}`]; if (!assetEntry) return false; return assetEntry.etag === etag; } return true; } catch (error) { this.log(`Error checking asset existence: ${error}`); return false; } } /** * Check if asset exists locally with optional ETag validation using asset ID */ async assetExistsById(assetId, assetPath, etag) { try { const fullPath = this.getFullPath(assetPath); const exists = await react_native_fs_1.default.exists(fullPath); if (!exists) return false; // If ETag is provided, validate it from registry if (etag) { // Ensure asset registry is loaded if (!this.assetRegistryLoaded) { await this.loadAssetRegistry(); } const assetEntry = this.assetRegistry[assetId]; if (!assetEntry || !assetEntry.etag) return false; return assetEntry.etag === etag; } return true; } catch (error) { this.log(`Error checking asset existence for ${assetId}: ${error}`); return false; } } /** * Check if an asset with the given hash already exists */ async hasAssetWithHash(hash) { if (!this.hashPathMappingLoaded) { await this.loadHashPathMapping(); } const path = this.hashPathMapping[hash]; if (path) { return { exists: true, path: path, }; } return { exists: false }; } /** * Save asset data to storage with hash-based deduplication */ async saveAsset(data, path, etag, assetId, expectedHash) { try { // Calculate hash if not provided const hash = expectedHash || (0, crypto_1.createHash)('sha256').update(data).digest('hex'); // Check if asset with this hash already exists const existingAsset = await this.hasAssetWithHash(hash); if (existingAsset.exists && existingAsset.path) { // Asset already exists - create reference instead of duplicating this.log(`Asset with hash ${hash.substring(0, 8)}... already exists at ${existingAsset.path}, creating reference for ${path}`); await this.createAssetReference(hash, path, assetId, etag, data.length); return; } // Asset doesn't exist - save normally const fullPath = this.getFullPath(path); // Ensure directory exists const dir = fullPath.substring(0, fullPath.lastIndexOf('/')); const dirExists = await react_native_fs_1.default.exists(dir); if (!dirExists) { await react_native_fs_1.default.mkdir(dir); } // Write asset data const base64Data = data.toString('base64'); await react_native_fs_1.default.writeFile(fullPath, base64Data, 'base64'); const stat = await react_native_fs_1.default.stat(fullPath); // Register in hash path mapping (batched) await this.registerAssetHash(hash, path, assetId, data.length, stat.mtime.toString()); // Save in asset registry if asset ID provided (batched) if (assetId) { this.assetRegistry[assetId] = { hash: hash, etag: etag, size: data.length, mtime: stat.mtime.toString(), calculatedAt: Date.now(), }; // Mark for batched save instead of immediate write this.pendingAssetUpdates.add(assetId); this.scheduleSave(); } this.log(`Saved new asset: ${path} (${data.length} bytes, hash: ${hash.substring(0, 8)}...)`); } catch (error) { throw new Error(`Failed to save asset ${path}: ${error}`); } } /** * Create a reference to an existing asset instead of duplicating it */ async createAssetReference(hash, newPath, assetId, _etag, _size) { if (!this.hashPathMappingLoaded) { await this.loadHashPathMapping(); } const path = this.hashPathMapping[hash]; if (!path) { throw new Error(`Path not found for hash ${hash}`); } // Create asset registry entry pointing to the same hash if (assetId) { // Get file stats to populate size and mtime correctly const fullPath = this.getFullPath(path); const stat = await react_native_fs_1.default.stat(fullPath); this.assetRegistry[assetId] = { hash: hash, etag: _etag, size: stat.size, mtime: stat.mtime.toString(), calculatedAt: Date.now(), }; // Mark for batched save instead of immediate write this.pendingAssetUpdates.add(assetId); this.scheduleSave(); } this.log(`Created reference for ${newPath} to existing asset with hash ${hash.substring(0, 8)}...`); } /** * Register a new asset hash in the hash path mapping (batched) */ async registerAssetHash(hash, path, _assetId, _size, _mtime) { if (!this.hashPathMappingLoaded) { await this.loadHashPathMapping(); } this.hashPathMapping[hash] = path; // Mark for batched save instead of immediate write this.pendingHashUpdates.add(hash); this.scheduleSave(); } /** * Get the actual file path for an asset, resolving hash-based references */ async getAssetPath(assetId) { if (!this.assetRegistryLoaded) { await this.loadAssetRegistry(); } const assetEntry = this.assetRegistry[assetId]; if (!assetEntry || !assetEntry.hash) { return null; } if (!this.hashPathMappingLoaded) { await this.loadHashPathMapping(); } const path = this.hashPathMapping[assetEntry.hash]; if (!path) { return null; } return path; } /** * Validate asset integrity using hash comparison with caching */ async validateAsset(path, expectedHash) { try { const fullPath = this.getFullPath(path); const exists = await react_native_fs_1.default.exists(fullPath); if (!exists) { return { isValid: false, error: 'Asset file does not exist', }; } const stat = await react_native_fs_1.default.stat(fullPath); const result = { isValid: true, size: stat.size, }; // Calculate and verify hash if expected hash is provided if (expectedHash) { try { // For backward compatibility, use path as assetId when not provided const assetId = `path:${path}`; const actualHash = await this.getCachedOrCalculateHashById(assetId, fullPath, stat.size, stat.mtime.toString()); result.hash = actualHash; result.isValid = actualHash === expectedHash; if (!result.isValid) { result.error = `Hash mismatch: expected ${expectedHash}, got ${actualHash}`; } } catch (error) { result.isValid = false; result.error = `Failed to calculate hash: ${error}`; } } return result; } catch (error) { return { isValid: false, error: `Validation failed: ${error}`, }; } } /** * Validate asset integrity by asset ID using cached hash registry */ async validateAssetById(assetId, path, expectedHash) { try { const fullPath = this.getFullPath(path); const exists = await react_native_fs_1.default.exists(fullPath); if (!exists) { return { isValid: false, error: 'Asset file does not exist', }; } const stat = await react_native_fs_1.default.stat(fullPath); const result = { isValid: true, size: stat.size, }; // Calculate and verify hash if expected hash is provided if (expectedHash) { try { const actualHash = await this.getCachedOrCalculateHashById(assetId, fullPath, stat.size, stat.mtime.toString()); result.hash = actualHash; result.isValid = actualHash === expectedHash; if (!result.isValid) { result.error = `Hash mismatch: expected ${expectedHash}, got ${actualHash}`; } } catch (error) { result.isValid = false; result.error = `Failed to calculate hash: ${error}`; } } return result; } catch (error) { return { isValid: false, error: `Validation failed: ${error}`, }; } } /** * Save ETag for an asset in the unified registry */ async saveETag(assetId, filePath, etag) { try { // Ensure asset registry is loaded if (!this.assetRegistryLoaded) { await this.loadAssetRegistry(); } const stat = await react_native_fs_1.default.stat(filePath); // Update or create registry entry if (this.assetRegistry[assetId]) { this.assetRegistry[assetId].etag = etag; } else { this.assetRegistry[assetId] = { hash: '', etag: etag, size: stat.size, mtime: stat.mtime.toString(), calculatedAt: Date.now(), }; } // Mark for batched save instead of immediate write this.pendingAssetUpdates.add(assetId); this.scheduleSave(); this.log(`Saved ETag for asset: ${assetId}`); } catch (error) { this.log(`Error saving ETag for asset ${assetId}: ${error}`); } } /** * Get path for temporary downloads */ getTempPath(filename) { return `${this.basePath}/temp/${filename}`; } /** * Get path for extracted assets */ getExtractedPath(subPath) { return `${this.basePath}/extracted/${subPath}`; } /** * Get path for bundle cache */ getBundlePath(bundleId) { return `${this.basePath}/bundles/${bundleId}`; } /** * Move file from one location to another */ async moveFile(fromPath, toPath) { try { // Ensure destination directory exists const dir = toPath.substring(0, toPath.lastIndexOf('/')); const dirExists = await react_native_fs_1.default.exists(dir); if (!dirExists) { await react_native_fs_1.default.mkdir(dir); } await react_native_fs_1.default.moveFile(fromPath, toPath); this.log(`Moved file: ${fromPath} -> ${toPath}`); } catch (error) { throw new Error(`Failed to move file from ${fromPath} to ${toPath}: ${error}`); } } /** * Get full path from relative path */ getFullPath(relativePath) { // Remove leading slash if present const cleanPath = relativePath.startsWith('/') ? relativePath.slice(1) : relativePath; return `${this.basePath}/extracted/${cleanPath}`; } /** * Calculate total size of a directory recursively */ async calculateDirectorySize(dirPath) { try { const exists = await react_native_fs_1.default.exists(dirPath); if (!exists) return 0; const items = await react_native_fs_1.default.readDir(dirPath); let totalSize = 0; for (const item of items) { if (item.isDirectory()) { totalSize += await this.calculateDirectorySize(item.path); } else { totalSize += item.size; } } return totalSize; } catch (error) { this.log(`Error calculating directory size for ${dirPath}: ${error}`); return 0; } } /** * Get all files in a directory recursively */ async getAllFiles(dirPath) { try { const exists = await react_native_fs_1.default.exists(dirPath); if (!exists) return []; const items = await react_native_fs_1.default.readDir(dirPath); const files = []; for (const item of items) { if (item.isDirectory()) { const subFiles = await this.getAllFiles(item.path); files.push(...subFiles); } else { files.push(item.path); } } return files; } catch (error) { this.log(`Error getting files from ${dirPath}: ${error}`); return []; } } /** * Get cached hash or calculate new one if file has changed (using centralized registry) */ async getCachedOrCalculateHashById(assetId, fullPath, size, fileModTime) { // Ensure asset registry is loaded if (!this.assetRegistryLoaded) { await this.loadAssetRegistry(); } try { // Check if hash exists in registry and file hasn't changed const cachedEntry = this.assetRegistry[assetId]; if (cachedEntry && cachedEntry.mtime === fileModTime && cachedEntry.size === size) { this.log(`Using cached hash for asset: ${assetId}`); return cachedEntry.hash; } // Hash doesn't exist or file has been modified - calculate new hash this.log(`Calculating hash for asset: ${assetId}`); const fileContent = await react_native_fs_1.default.readFile(fullPath, 'base64'); const buffer = Buffer.from(fileContent, 'base64'); const actualHash = (0, crypto_1.createHash)('sha256').update(buffer).digest('hex'); // Update registry with new hash this.assetRegistry[assetId] = { hash: actualHash, size: size, mtime: fileModTime, calculatedAt: Date.now(), }; // Mark for batched save instead of immediate write this.pendingAssetUpdates.add(assetId); this.scheduleSave(); return actualHash; } catch (error) { this.log(`Error in hash caching for asset ${assetId}: ${error}`); // Fallback to direct calculation if caching fails const fileContent = await react_native_fs_1.default.readFile(fullPath, 'base64'); const buffer = Buffer.from(fileContent, 'base64'); return (0, crypto_1.createHash)('sha256').update(buffer).digest('hex'); } } /** * Helper method to count how many assets reference a specific hash */ countHashReferences(hash) { let count = 0; for (const entry of Object.values(this.assetRegistry)) { if (entry.hash === hash) { count++; } } return count; } /** * Delete asset by ID */ async deleteAssetById(assetId, _path) { try { if (!this.assetRegistryLoaded) { await this.loadAssetRegistry(); } const assetEntry = this.assetRegistry[assetId]; if (!assetEntry) { this.log(`Asset ${assetId} not found in registry`); return; } const hash = assetEntry.hash; // Remove from asset registry first delete this.assetRegistry[assetId]; // Mark for batched save instead of immediate write this.pendingAssetUpdates.add(assetId); // Track that this asset was modified (deleted) this.scheduleSave(); if (!hash) { this.log(`No hash found for asset ${assetId}, skipping hash registry cleanup`); return; } // Check if any other assets still reference this hash const referenceCount = this.countHashReferences(hash); if (referenceCount === 0) { // No more references - delete the actual file and remove from hash mapping if (!this.hashPathMappingLoaded) { await this.loadHashPathMapping(); } const path = this.hashPathMapping[hash]; if (path) { const fullPath = this.getFullPath(path); const exists = await react_native_fs_1.default.exists(fullPath); if (exists) { await react_native_fs_1.default.unlink(fullPath); this.log(`Deleted file: ${path} (no more references)`); } // Remove from hash path mapping delete this.hashPathMapping[hash]; this.log(`Removed hash entry: ${hash.substring(0, 8)}... (no more references)`); // Mark for batched save instead of immediate write this.pendingHashUpdates.add(hash); this.scheduleSave(); } } else { this.log(`Hash ${hash.substring(0, 8)}... still has ${referenceCount} references, keeping file`); } this.log(`Deleted asset: ${assetId}`); } catch (error) { throw new Error(`Failed to delete asset ${assetId}: ${error}`); } } /** * Cleanup old/unused assets based on age and size constraints */ async cleanup(options = {}) { try { const { maxAge = 7 * 24 * 60 * 60 * 1000, maxSize } = options; // Default 7 days let deletedCount = 0; // Get all files in the asset directory const files = await this.getAllFiles(this.basePath); const now = Date.now(); // Sort by last modified time (oldest first) const filesWithStats = await Promise.all(files.map(async (file) => { try { const stat = await react_native_fs_1.default.stat(file); return { path: file, mtime: new Date(stat.mtime).getTime(), size: stat.size, }; } catch (error) { return null; } })); const validFiles = filesWithStats .filter((file) => file !== null) .sort((a, b) => a.mtime - b.mtime); // Delete files based on age for (const file of validFiles) { if (now - file.mtime > maxAge) { try { await react_native_fs_1.default.unlink(file.path); deletedCount++; this.log(`Deleted old file: ${file.path}`); } catch (error) { this.log(`Failed to delete old file ${file.path}: ${error}`); } } } // Delete files to stay under size limit if (maxSize) { let totalSize = validFiles.reduce((sum, file) => sum + file.size, 0); for (const file of validFiles) { if (totalSize <= maxSize) break; try { await react_native_fs_1.default.unlink(file.path); totalSize -= file.size; deletedCount++; this.log(`Deleted file for size limit: ${file.path}`); } catch (error) { this.log(`Failed to delete file for size limit ${file.path}: ${error}`); } } } this.log(`Cleanup completed: ${deletedCount} files deleted`); // Clean up orphaned registry entries await this.cleanupOrphanedRegistryEntries(); return deletedCount; } catch (error) { this.log(`Cleanup failed: ${error}`); return 0; } } /** * Clean up orphaned asset registry entries (entries for assets that no longer exist) * Note: This is a simplified cleanup that only removes backward-compatibility path-based entries. * In practice, asset IDs in the registry should be managed by the asset manifest system. */ async cleanupOrphanedRegistryEntries() { if (!this.assetRegistryLoaded) { await this.loadAssetRegistry(); } let removedCount = 0; const assetIds = Object.keys(this.assetRegistry); for (const assetId of assetIds) { // Only clean up backward-compatibility path-based entries if (assetId.startsWith('path:')) { const path = assetId.substring(5); // Remove 'path:' prefix const fullPath = this.getFullPath(path); try { const exists = await react_native_fs_1.default.exists(fullPath); if (!exists) { delete this.assetRegistry[assetId]; removedCount++; this.log(`Removed orphaned path-based registry entry: ${assetId}`); } } catch (error) { // If we can't check existence, remove the entry to be safe delete this.assetRegistry[assetId]; removedCount++; this.log(`Removed invalid path-based registry entry: ${assetId}`); } } // Note: Real asset IDs should be managed by the manifest system, // so we don't auto-cleanup those here to avoid data loss } if (removedCount > 0) { // Mark for batched save instead of immediate write this.pendingAssetUpdates.add('cleanup-batch'); // Use a special key to track bulk cleanup this.scheduleSave(); this.log(`Cleaned up ${removedCount} orphaned path-based registry entries`); } } /** * Clean up legacy .hash, .hash.meta, and .etag files from the old system */ async cleanupLegacyFiles() { try { const files = await this.getAllFiles(this.basePath); let cleanupCount = 0; for (const filePath of files) { if (filePath.endsWith('.hash') || filePath.endsWith('.hash.meta') || filePath.endsWith('.etag')) { try { await react_native_fs_1.default.unlink(filePath); cleanupCount++; } catch (error) { this.log(`Failed to cleanup legacy file ${filePath}: ${error}`); } } } if (cleanupCount > 0) { this.log(`Cleaned up ${cleanupCount} legacy metadata files`); } } catch (error) { this.log(`Error during legacy file cleanup: ${error}`); } } /** * Get deduplication statistics */ async getDeduplicationStats() { if (!this.assetRegistryLoaded) { await this.loadAssetRegistry(); } if (!this.hashPathMappingLoaded) { await this.loadHashPathMapping(); } const totalAssets = Object.keys(this.assetRegistry).length; const uniqueFiles = Object.keys(this.hashPathMapping).length; let totalOriginalSize = 0; let actualStorageUsed = 0; for (const assetEntry of Object.values(this.assetRegistry)) { totalOriginalSize += assetEntry.size; } // Calculate actual storage used by getting file sizes from unique files for (const path of Object.values(this.hashPathMapping)) { try { const fullPath = this.getFullPath(path); const exists = await react_native_fs_1.default.exists(fullPath); if (exists) { const stat = await react_native_fs_1.default.stat(fullPath); actualStorageUsed += stat.size; } } catch (error) { this.log(`Error getting file size for ${path}: ${error}`); } } const savedSpace = totalOriginalSize - actualStorageUsed; const dedupRatio = totalAssets > 0 ? (savedSpace / totalOriginalSize) * 100 : 0; return { totalAssets, uniqueFiles, savedSpace, dedupRatio: Math.round(dedupRatio * 100) / 100, // Round to 2 decimals }; } /** * Log message if logging is enabled */ log(message) { if (this.enableLogging) { console.log(`[StorageManager] ${message}`); } } /** * Cleanup resources and flush pending saves */ async destroy() { this.isDestroyed = true; // Clear any pending timer if (this.saveDebounceTimer) { clearTimeout(this.saveDebounceTimer); this.saveDebounceTimer = undefined; } // Flush any pending saves immediately await this.flushPendingSaves(); this.log('StorageManager destroyed and pending saves flushed'); } /** * Manually flush all pending saves to disk immediately * Useful for critical operations or before app shutdown */ async flush() { await this.flushPendingSaves(); } /** * Get statistics about batching performance */ getBatchingStats() { return { pendingAssetUpdates: this.pendingAssetUpdates.size, pendingHashUpdates: this.pendingHashUpdates.size, hasPendingTimer: this.saveDebounceTimer !== undefined, }; } } exports.StorageManager = StorageManager;