UNPKG

s3db.js

Version:

Use AWS S3, the world's most reliable document storage, as a database with this ORM.

692 lines (599 loc) 20.8 kB
/** * Filesystem Cache Configuration Documentation * * This cache implementation stores data in the local filesystem, providing persistent storage * that survives process restarts and is suitable for single-instance applications. * It's faster than S3 cache for local operations and doesn't require network connectivity. * * @typedef {Object} FilesystemCacheConfig * @property {string} directory - The directory path to store cache files (required) * @property {string} [prefix='cache'] - Prefix for cache filenames * @property {number} [ttl=3600000] - Time to live in milliseconds (1 hour default) * @property {boolean} [enableCompression=true] - Whether to compress cache values using gzip * @property {number} [compressionThreshold=1024] - Minimum size in bytes to trigger compression * @property {boolean} [createDirectory=true] - Whether to create the directory if it doesn't exist * @property {string} [fileExtension='.cache'] - File extension for cache files * @property {boolean} [enableMetadata=true] - Whether to store metadata alongside cache data * @property {number} [maxFileSize=10485760] - Maximum file size in bytes (10MB default) * @property {boolean} [enableStats=false] - Whether to track cache statistics * @property {boolean} [enableCleanup=true] - Whether to automatically clean up expired files * @property {number} [cleanupInterval=300000] - Interval in milliseconds to run cleanup (5 minutes default) * @property {string} [encoding='utf8'] - File encoding to use * @property {number} [fileMode=0o644] - File permissions in octal notation * @property {boolean} [enableBackup=false] - Whether to create backup files before overwriting * @property {string} [backupSuffix='.bak'] - Suffix for backup files * @property {boolean} [enableLocking=false] - Whether to use file locking to prevent concurrent access * @property {number} [lockTimeout=5000] - Lock timeout in milliseconds * @property {boolean} [enableJournal=false] - Whether to maintain a journal of operations * @property {string} [journalFile='cache.journal'] - Journal filename * * @example * // Basic configuration * { * directory: './cache', * prefix: 'app-cache', * ttl: 7200000, // 2 hours * enableCompression: true * } * * @example * // Configuration with cleanup and metadata * { * directory: '/tmp/s3db-cache', * prefix: 'db-cache', * ttl: 1800000, // 30 minutes * enableCompression: true, * compressionThreshold: 512, * enableCleanup: true, * cleanupInterval: 600000, // 10 minutes * enableMetadata: true, * maxFileSize: 5242880 // 5MB * } * * @example * // Configuration with backup and locking * { * directory: './data/cache', * ttl: 86400000, // 24 hours * enableBackup: true, * enableLocking: true, * lockTimeout: 3000, * enableJournal: true * } * * @example * // Minimal configuration * { * directory: './cache' * } * * @notes * - Requires filesystem write permissions to the specified directory * - File storage is faster than S3 but limited to single instance * - Compression reduces disk usage but increases CPU overhead * - TTL is enforced by checking file modification time * - Cleanup interval helps prevent disk space issues * - File locking prevents corruption during concurrent access * - Journal provides audit trail of cache operations * - Backup files help recover from write failures * - Metadata includes creation time, compression info, and custom properties */ import fs from 'fs'; import { readFile, writeFile, unlink, readdir, stat, mkdir } from 'fs/promises'; import path from 'path'; import zlib from 'node:zlib'; import { Cache } from './cache.class.js'; import tryFn from '../../concerns/try-fn.js'; export class FilesystemCache extends Cache { constructor({ directory, prefix = 'cache', ttl = 3600000, enableCompression = true, compressionThreshold = 1024, createDirectory = true, fileExtension = '.cache', enableMetadata = true, maxFileSize = 10485760, // 10MB enableStats = false, enableCleanup = true, cleanupInterval = 300000, // 5 minutes encoding = 'utf8', fileMode = 0o644, enableBackup = false, backupSuffix = '.bak', enableLocking = false, lockTimeout = 5000, enableJournal = false, journalFile = 'cache.journal', ...config }) { super(config); if (!directory) { throw new Error('FilesystemCache: directory parameter is required'); } this.directory = path.resolve(directory); this.prefix = prefix; this.ttl = ttl; this.enableCompression = enableCompression; this.compressionThreshold = compressionThreshold; this.createDirectory = createDirectory; this.fileExtension = fileExtension; this.enableMetadata = enableMetadata; this.maxFileSize = maxFileSize; this.enableStats = enableStats; this.enableCleanup = enableCleanup; this.cleanupInterval = cleanupInterval; this.encoding = encoding; this.fileMode = fileMode; this.enableBackup = enableBackup; this.backupSuffix = backupSuffix; this.enableLocking = enableLocking; this.lockTimeout = lockTimeout; this.enableJournal = enableJournal; this.journalFile = path.join(this.directory, journalFile); this.stats = { hits: 0, misses: 0, sets: 0, deletes: 0, clears: 0, errors: 0 }; this.locks = new Map(); // For file locking this.cleanupTimer = null; this._init(); } async _init() { // Create cache directory if needed if (this.createDirectory) { await this._ensureDirectory(this.directory); } // Start cleanup timer if enabled if (this.enableCleanup && this.cleanupInterval > 0) { this.cleanupTimer = setInterval(() => { this._cleanup().catch(err => { console.warn('FilesystemCache cleanup error:', err.message); }); }, this.cleanupInterval); } } async _ensureDirectory(dir) { const [ok, err] = await tryFn(async () => { await mkdir(dir, { recursive: true }); }); if (!ok && err.code !== 'EEXIST') { throw new Error(`Failed to create cache directory: ${err.message}`); } } _getFilePath(key) { // Sanitize key for filesystem const sanitizedKey = key.replace(/[<>:"/\\|?*]/g, '_'); const filename = `${this.prefix}_${sanitizedKey}${this.fileExtension}`; return path.join(this.directory, filename); } _getMetadataPath(filePath) { return filePath + '.meta'; } async _set(key, data) { const filePath = this._getFilePath(key); try { // Prepare data let serialized = JSON.stringify(data); const originalSize = Buffer.byteLength(serialized, this.encoding); // Check size limit if (originalSize > this.maxFileSize) { throw new Error(`Cache data exceeds maximum file size: ${originalSize} > ${this.maxFileSize}`); } let compressed = false; let finalData = serialized; // Compress if enabled and over threshold if (this.enableCompression && originalSize >= this.compressionThreshold) { const compressedBuffer = zlib.gzipSync(Buffer.from(serialized, this.encoding)); finalData = compressedBuffer.toString('base64'); compressed = true; } // Create backup if enabled if (this.enableBackup && await this._fileExists(filePath)) { const backupPath = filePath + this.backupSuffix; await this._copyFile(filePath, backupPath); } // Acquire lock if enabled if (this.enableLocking) { await this._acquireLock(filePath); } try { // Write data await writeFile(filePath, finalData, { encoding: compressed ? 'utf8' : this.encoding, mode: this.fileMode }); // Write metadata if enabled if (this.enableMetadata) { const metadata = { key, timestamp: Date.now(), ttl: this.ttl, compressed, originalSize, compressedSize: compressed ? Buffer.byteLength(finalData, 'utf8') : originalSize, compressionRatio: compressed ? (Buffer.byteLength(finalData, 'utf8') / originalSize).toFixed(2) : 1.0 }; await writeFile(this._getMetadataPath(filePath), JSON.stringify(metadata), { encoding: this.encoding, mode: this.fileMode }); } // Update stats if (this.enableStats) { this.stats.sets++; } // Journal operation if (this.enableJournal) { await this._journalOperation('set', key, { size: originalSize, compressed }); } } finally { // Release lock if (this.enableLocking) { this._releaseLock(filePath); } } return data; } catch (error) { if (this.enableStats) { this.stats.errors++; } throw new Error(`Failed to set cache key '${key}': ${error.message}`); } } async _get(key) { const filePath = this._getFilePath(key); try { // Check if file exists if (!await this._fileExists(filePath)) { if (this.enableStats) { this.stats.misses++; } return null; } // Check TTL using metadata or file modification time let isExpired = false; if (this.enableMetadata) { const metadataPath = this._getMetadataPath(filePath); if (await this._fileExists(metadataPath)) { const [ok, err, metadata] = await tryFn(async () => { const metaContent = await readFile(metadataPath, this.encoding); return JSON.parse(metaContent); }); if (ok && metadata.ttl > 0) { const age = Date.now() - metadata.timestamp; isExpired = age > metadata.ttl; } } } else if (this.ttl > 0) { // Fallback to file modification time const stats = await stat(filePath); const age = Date.now() - stats.mtime.getTime(); isExpired = age > this.ttl; } // Remove expired files if (isExpired) { await this._del(key); if (this.enableStats) { this.stats.misses++; } return null; } // Acquire lock if enabled if (this.enableLocking) { await this._acquireLock(filePath); } try { // Read file content const content = await readFile(filePath, this.encoding); // Check if compressed using metadata let isCompressed = false; if (this.enableMetadata) { const metadataPath = this._getMetadataPath(filePath); if (await this._fileExists(metadataPath)) { const [ok, err, metadata] = await tryFn(async () => { const metaContent = await readFile(metadataPath, this.encoding); return JSON.parse(metaContent); }); if (ok) { isCompressed = metadata.compressed; } } } // Decompress if needed let finalContent = content; if (isCompressed || (this.enableCompression && content.match(/^[A-Za-z0-9+/=]+$/))) { try { const compressedBuffer = Buffer.from(content, 'base64'); finalContent = zlib.gunzipSync(compressedBuffer).toString(this.encoding); } catch (decompressError) { // If decompression fails, assume it's not compressed finalContent = content; } } // Parse JSON const data = JSON.parse(finalContent); // Update stats if (this.enableStats) { this.stats.hits++; } return data; } finally { // Release lock if (this.enableLocking) { this._releaseLock(filePath); } } } catch (error) { if (this.enableStats) { this.stats.errors++; } // If file is corrupted or unreadable, delete it and return null await this._del(key); return null; } } async _del(key) { const filePath = this._getFilePath(key); try { // Delete main file if (await this._fileExists(filePath)) { await unlink(filePath); } // Delete metadata file if (this.enableMetadata) { const metadataPath = this._getMetadataPath(filePath); if (await this._fileExists(metadataPath)) { await unlink(metadataPath); } } // Delete backup file if (this.enableBackup) { const backupPath = filePath + this.backupSuffix; if (await this._fileExists(backupPath)) { await unlink(backupPath); } } // Update stats if (this.enableStats) { this.stats.deletes++; } // Journal operation if (this.enableJournal) { await this._journalOperation('delete', key); } return true; } catch (error) { if (this.enableStats) { this.stats.errors++; } throw new Error(`Failed to delete cache key '${key}': ${error.message}`); } } async _clear(prefix) { try { // Check if directory exists before trying to read it if (!await this._fileExists(this.directory)) { // Directory doesn't exist, nothing to clear if (this.enableStats) { this.stats.clears++; } return true; } const files = await readdir(this.directory); const cacheFiles = files.filter(file => { if (!file.startsWith(this.prefix)) return false; if (!file.endsWith(this.fileExtension)) return false; if (prefix) { // Extract key from filename const keyPart = file.slice(this.prefix.length + 1, -this.fileExtension.length); return keyPart.startsWith(prefix); } return true; }); // Delete matching files and their metadata for (const file of cacheFiles) { const filePath = path.join(this.directory, file); // Delete main file (handle ENOENT gracefully) try { if (await this._fileExists(filePath)) { await unlink(filePath); } } catch (error) { if (error.code !== 'ENOENT') { throw error; // Re-throw non-ENOENT errors } // ENOENT means file is already gone, which is what we wanted } // Delete metadata file (handle ENOENT gracefully) if (this.enableMetadata) { try { const metadataPath = this._getMetadataPath(filePath); if (await this._fileExists(metadataPath)) { await unlink(metadataPath); } } catch (error) { if (error.code !== 'ENOENT') { throw error; // Re-throw non-ENOENT errors } // ENOENT means file is already gone, which is what we wanted } } // Delete backup file (handle ENOENT gracefully) if (this.enableBackup) { try { const backupPath = filePath + this.backupSuffix; if (await this._fileExists(backupPath)) { await unlink(backupPath); } } catch (error) { if (error.code !== 'ENOENT') { throw error; // Re-throw non-ENOENT errors } // ENOENT means file is already gone, which is what we wanted } } } // Update stats if (this.enableStats) { this.stats.clears++; } // Journal operation if (this.enableJournal) { await this._journalOperation('clear', prefix || 'all', { count: cacheFiles.length }); } return true; } catch (error) { // Handle ENOENT errors at the top level too (e.g., directory doesn't exist) if (error.code === 'ENOENT') { if (this.enableStats) { this.stats.clears++; } return true; // Already cleared! } if (this.enableStats) { this.stats.errors++; } throw new Error(`Failed to clear cache: ${error.message}`); } } async size() { const keys = await this.keys(); return keys.length; } async keys() { try { const files = await readdir(this.directory); const cacheFiles = files.filter(file => file.startsWith(this.prefix) && file.endsWith(this.fileExtension) ); // Extract keys from filenames const keys = cacheFiles.map(file => { const keyPart = file.slice(this.prefix.length + 1, -this.fileExtension.length); return keyPart; }); return keys; } catch (error) { console.warn('FilesystemCache: Failed to list keys:', error.message); return []; } } // Helper methods async _fileExists(filePath) { const [ok] = await tryFn(async () => { await stat(filePath); }); return ok; } async _copyFile(src, dest) { const [ok, err] = await tryFn(async () => { const content = await readFile(src); await writeFile(dest, content); }); if (!ok) { console.warn('FilesystemCache: Failed to create backup:', err.message); } } async _cleanup() { if (!this.ttl || this.ttl <= 0) return; try { const files = await readdir(this.directory); const now = Date.now(); for (const file of files) { if (!file.startsWith(this.prefix) || !file.endsWith(this.fileExtension)) { continue; } const filePath = path.join(this.directory, file); let shouldDelete = false; if (this.enableMetadata) { // Use metadata for TTL check const metadataPath = this._getMetadataPath(filePath); if (await this._fileExists(metadataPath)) { const [ok, err, metadata] = await tryFn(async () => { const metaContent = await readFile(metadataPath, this.encoding); return JSON.parse(metaContent); }); if (ok && metadata.ttl > 0) { const age = now - metadata.timestamp; shouldDelete = age > metadata.ttl; } } } else { // Use file modification time const [ok, err, stats] = await tryFn(async () => { return await stat(filePath); }); if (ok) { const age = now - stats.mtime.getTime(); shouldDelete = age > this.ttl; } } if (shouldDelete) { const keyPart = file.slice(this.prefix.length + 1, -this.fileExtension.length); await this._del(keyPart); } } } catch (error) { console.warn('FilesystemCache cleanup error:', error.message); } } async _acquireLock(filePath) { if (!this.enableLocking) return; const lockKey = filePath; const startTime = Date.now(); while (this.locks.has(lockKey)) { if (Date.now() - startTime > this.lockTimeout) { throw new Error(`Lock timeout for file: ${filePath}`); } await new Promise(resolve => setTimeout(resolve, 10)); } this.locks.set(lockKey, Date.now()); } _releaseLock(filePath) { if (!this.enableLocking) return; this.locks.delete(filePath); } async _journalOperation(operation, key, metadata = {}) { if (!this.enableJournal) return; const entry = { timestamp: new Date().toISOString(), operation, key, metadata }; const [ok, err] = await tryFn(async () => { const line = JSON.stringify(entry) + '\n'; await fs.promises.appendFile(this.journalFile, line, this.encoding); }); if (!ok) { console.warn('FilesystemCache journal error:', err.message); } } // Cleanup on process exit destroy() { if (this.cleanupTimer) { clearInterval(this.cleanupTimer); this.cleanupTimer = null; } } // Get cache statistics getStats() { return { ...this.stats, directory: this.directory, ttl: this.ttl, compression: this.enableCompression, metadata: this.enableMetadata, cleanup: this.enableCleanup, locking: this.enableLocking, journal: this.enableJournal }; } } export default FilesystemCache;