Full codebase including API server (32 modules + routes), dashboard frontend, DashCA certificate distribution, installer script, and deployment skills.
238 lines
6.7 KiB
JavaScript
238 lines
6.7 KiB
JavaScript
/**
|
|
* 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<any>} 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<void>}
|
|
* @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<any>} 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<boolean>} 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<void>}
|
|
*/
|
|
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<any>} 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<any>} 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<any>} 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<any|null>} Found item or null
|
|
*/
|
|
async findItem(id) {
|
|
const items = await this.read();
|
|
return items.find(item => item.id === id) || null;
|
|
}
|
|
}
|
|
|
|
module.exports = StateManager;
|