@toast-studios/asset-manager
Version:
A React Native asset management library with intelligent caching and loading strategies
938 lines (937 loc) • 36 kB
JavaScript
"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;