/** * State Manager - Thread-safe file operations with locking * * Prevents data corruption when multiple API requests modify state files concurrently. * Uses file-based locking with automatic retry and timeout handling. * * @module state-manager */ const lockfile = require('proper-lockfile'); const fs = require('fs').promises; const fsSync = require('fs'); const path = require('path'); class StateManager { /** * Create a StateManager instance * @param {string} filePath - Path to the state file (e.g., services.json) * @param {Object} options - Configuration options * @param {number} options.lockTimeout - Max time to wait for lock (ms) * @param {number} options.lockRetries - Number of lock acquisition retries * @param {number} options.lockRetryInterval - Time between retries (ms) */ constructor(filePath, options = {}) { this.filePath = filePath; this.lockOptions = { retries: { retries: options.lockRetries || 10, minTimeout: options.lockRetryInterval || 100, maxTimeout: (options.lockRetryInterval || 100) * 3 }, stale: options.lockTimeout || 30000 // 30 seconds }; // Ensure file exists this._ensureFileExists(); } /** * Ensure the state file exists, create with empty array if not * @private */ _ensureFileExists() { if (!fsSync.existsSync(this.filePath)) { const dir = path.dirname(this.filePath); if (!fsSync.existsSync(dir)) { fsSync.mkdirSync(dir, { recursive: true }); } fsSync.writeFileSync(this.filePath, '[]', 'utf8'); } } /** * Read the state file (no locking required for read-only operations) * @returns {Promise} Parsed JSON data * @throws {Error} If file doesn't exist or JSON is invalid */ async read() { try { const content = await fs.readFile(this.filePath, 'utf8'); return JSON.parse(content); } catch (error) { if (error.code === 'ENOENT') { // File doesn't exist — recreate without locking (no file to lock) this._ensureFileExists(); return []; } throw new Error(`Failed to read state file: ${error.message}`); } } /** * Write data to the state file (with locking) * @param {any} data - Data to write (will be JSON.stringify'd) * @returns {Promise} * @throws {Error} If lock cannot be acquired or write fails */ async write(data) { let release; try { // Acquire lock release = await lockfile.lock(this.filePath, this.lockOptions); // Write data with pretty formatting await fs.writeFile(this.filePath, JSON.stringify(data, null, 2), 'utf8'); } catch (error) { if (error.code === 'ELOCKED') { throw new Error('State file is locked by another process. Try again.'); } throw new Error(`Failed to write state file: ${error.message}`); } finally { // Always release lock if (release) { try { await release(); } catch (e) { // Lock release failure (non-critical, lock will expire via stale timeout) } } } } /** * Update the state file using a callback function (atomic operation) * This is the recommended method for most operations. * * @param {Function} updateFn - Function that receives current data and returns updated data * @returns {Promise} The updated data * @throws {Error} If lock cannot be acquired or update fails * * @example * // Add a new service * await stateManager.update(services => { * services.push({ id: 'new-service', name: 'New Service' }); * return services; * }); * * @example * // Remove a service * await stateManager.update(services => { * return services.filter(s => s.id !== 'old-service'); * }); */ async update(updateFn) { let release; try { // Acquire lock release = await lockfile.lock(this.filePath, this.lockOptions); // Read current data const content = await fs.readFile(this.filePath, 'utf8'); const currentData = JSON.parse(content); // Apply update function const updatedData = await updateFn(currentData); // Write updated data await fs.writeFile(this.filePath, JSON.stringify(updatedData, null, 2), 'utf8'); return updatedData; } catch (error) { if (error.code === 'ELOCKED') { throw new Error('State file is locked by another process. Try again.'); } throw new Error(`Failed to update state file: ${error.message}`); } finally { // Always release lock if (release) { try { await release(); } catch (e) { // Lock release failure (non-critical, lock will expire via stale timeout) } } } } /** * Check if the state file is currently locked * @returns {Promise} True if locked, false otherwise */ async isLocked() { try { return await lockfile.check(this.filePath); } catch (error) { return false; } } /** * Forcefully unlock the state file (use with caution!) * Only use this if a lock is stuck due to a crashed process. * @returns {Promise} */ async forceUnlock() { try { await lockfile.unlock(this.filePath); } catch (error) { // Ignore errors if file wasn't locked if (error.code !== 'ENOTACQUIRED') { throw error; } } } /** * Add an item to the state array (convenience method) * @param {any} item - Item to add * @returns {Promise} Updated array */ async addItem(item) { return await this.update(items => { items.push(item); return items; }); } /** * Remove an item from the state array by ID (convenience method) * @param {string} id - ID of item to remove * @returns {Promise} Updated array */ async removeItem(id) { return await this.update(items => { return items.filter(item => item.id !== id); }); } /** * Update an item in the state array by ID (convenience method) * @param {string} id - ID of item to update * @param {Object} updates - Properties to update * @returns {Promise} Updated array */ async updateItem(id, updates) { return await this.update(items => { return items.map(item => { if (item.id === id) { return { ...item, ...updates }; } return item; }); }); } /** * Find an item in the state array by ID (convenience method) * @param {string} id - ID of item to find * @returns {Promise} Found item or null */ async findItem(id) { const items = await this.read(); return items.find(item => item.id === id) || null; } } module.exports = StateManager;