Initial commit: Inventory Barcode System
This commit is contained in:
275
utils/backup.js
Normal file
275
utils/backup.js
Normal file
@ -0,0 +1,275 @@
|
||||
/**
|
||||
* Database Backup and Recovery Utilities
|
||||
* Provides automated backup and recovery functionality for SQLite database
|
||||
*/
|
||||
|
||||
const fs = require('fs').promises;
|
||||
const path = require('path');
|
||||
const { createReadStream, createWriteStream } = require('fs');
|
||||
const { pipeline } = require('stream/promises');
|
||||
const logger = require('./logger');
|
||||
const config = require('../config/production');
|
||||
|
||||
class BackupManager {
|
||||
constructor() {
|
||||
this.dbPath = config.database.path;
|
||||
this.backupDir = config.database.backupPath;
|
||||
this.retentionDays = config.backup.retentionDays;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize backup directory
|
||||
*/
|
||||
async initialize() {
|
||||
try {
|
||||
await fs.mkdir(this.backupDir, { recursive: true });
|
||||
logger.info('Backup directory initialized', { path: this.backupDir });
|
||||
} catch (error) {
|
||||
logger.error('Failed to initialize backup directory', {
|
||||
error: error.message,
|
||||
path: this.backupDir
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a backup of the database
|
||||
*/
|
||||
async createBackup() {
|
||||
try {
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const backupFileName = `inventory-backup-${timestamp}.db`;
|
||||
const backupPath = path.join(this.backupDir, backupFileName);
|
||||
|
||||
// Check if source database exists
|
||||
try {
|
||||
await fs.access(this.dbPath);
|
||||
} catch (error) {
|
||||
throw new Error(`Source database not found: ${this.dbPath}`);
|
||||
}
|
||||
|
||||
// Create backup using file copy
|
||||
await pipeline(
|
||||
createReadStream(this.dbPath),
|
||||
createWriteStream(backupPath)
|
||||
);
|
||||
|
||||
// Verify backup file
|
||||
const stats = await fs.stat(backupPath);
|
||||
if (stats.size === 0) {
|
||||
throw new Error('Backup file is empty');
|
||||
}
|
||||
|
||||
logger.info('Database backup created successfully', {
|
||||
backupPath,
|
||||
size: stats.size,
|
||||
timestamp
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
backupPath,
|
||||
size: stats.size,
|
||||
timestamp
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Database backup failed', { error: error.message });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore database from backup
|
||||
*/
|
||||
async restoreBackup(backupPath) {
|
||||
try {
|
||||
// Verify backup file exists
|
||||
try {
|
||||
await fs.access(backupPath);
|
||||
} catch (error) {
|
||||
throw new Error(`Backup file not found: ${backupPath}`);
|
||||
}
|
||||
|
||||
// Create backup of current database before restore
|
||||
const currentBackupPath = `${this.dbPath}.pre-restore-${Date.now()}.bak`;
|
||||
try {
|
||||
await pipeline(
|
||||
createReadStream(this.dbPath),
|
||||
createWriteStream(currentBackupPath)
|
||||
);
|
||||
logger.info('Current database backed up before restore', {
|
||||
path: currentBackupPath
|
||||
});
|
||||
} catch (error) {
|
||||
logger.warn('Could not backup current database before restore', {
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
|
||||
// Restore from backup
|
||||
await pipeline(
|
||||
createReadStream(backupPath),
|
||||
createWriteStream(this.dbPath)
|
||||
);
|
||||
|
||||
// Verify restored database
|
||||
const stats = await fs.stat(this.dbPath);
|
||||
if (stats.size === 0) {
|
||||
throw new Error('Restored database is empty');
|
||||
}
|
||||
|
||||
logger.info('Database restored successfully', {
|
||||
backupPath,
|
||||
restoredSize: stats.size
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
restoredFrom: backupPath,
|
||||
size: stats.size
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Database restore failed', {
|
||||
error: error.message,
|
||||
backupPath
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List available backups
|
||||
*/
|
||||
async listBackups() {
|
||||
try {
|
||||
const files = await fs.readdir(this.backupDir);
|
||||
const backups = [];
|
||||
|
||||
for (const file of files) {
|
||||
if (file.endsWith('.db')) {
|
||||
const filePath = path.join(this.backupDir, file);
|
||||
const stats = await fs.stat(filePath);
|
||||
|
||||
backups.push({
|
||||
filename: file,
|
||||
path: filePath,
|
||||
size: stats.size,
|
||||
created: stats.birthtime,
|
||||
modified: stats.mtime
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by creation date (newest first)
|
||||
backups.sort((a, b) => b.created - a.created);
|
||||
|
||||
return backups;
|
||||
} catch (error) {
|
||||
logger.error('Failed to list backups', { error: error.message });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up old backups based on retention policy
|
||||
*/
|
||||
async cleanupOldBackups() {
|
||||
try {
|
||||
const backups = await this.listBackups();
|
||||
const cutoffDate = new Date();
|
||||
cutoffDate.setDate(cutoffDate.getDate() - this.retentionDays);
|
||||
|
||||
let deletedCount = 0;
|
||||
let deletedSize = 0;
|
||||
|
||||
for (const backup of backups) {
|
||||
if (backup.created < cutoffDate) {
|
||||
try {
|
||||
await fs.unlink(backup.path);
|
||||
deletedCount++;
|
||||
deletedSize += backup.size;
|
||||
logger.info('Old backup deleted', {
|
||||
filename: backup.filename,
|
||||
age: Math.floor((Date.now() - backup.created) / (1000 * 60 * 60 * 24))
|
||||
});
|
||||
} catch (error) {
|
||||
logger.warn('Failed to delete old backup', {
|
||||
filename: backup.filename,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.info('Backup cleanup completed', {
|
||||
deletedCount,
|
||||
deletedSize,
|
||||
retentionDays: this.retentionDays
|
||||
});
|
||||
|
||||
return { deletedCount, deletedSize };
|
||||
} catch (error) {
|
||||
logger.error('Backup cleanup failed', { error: error.message });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule automatic backups
|
||||
*/
|
||||
scheduleBackups() {
|
||||
const interval = config.database.backupInterval;
|
||||
|
||||
if (!config.backup.enabled) {
|
||||
logger.info('Automatic backups disabled');
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info('Scheduling automatic backups', {
|
||||
intervalMs: interval,
|
||||
intervalHours: interval / (1000 * 60 * 60)
|
||||
});
|
||||
|
||||
setInterval(async () => {
|
||||
try {
|
||||
await this.createBackup();
|
||||
await this.cleanupOldBackups();
|
||||
} catch (error) {
|
||||
logger.error('Scheduled backup failed', { error: error.message });
|
||||
}
|
||||
}, interval);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get backup statistics
|
||||
*/
|
||||
async getBackupStats() {
|
||||
try {
|
||||
const backups = await this.listBackups();
|
||||
const totalSize = backups.reduce((sum, backup) => sum + backup.size, 0);
|
||||
const oldestBackup = backups.length > 0 ? backups[backups.length - 1] : null;
|
||||
const newestBackup = backups.length > 0 ? backups[0] : null;
|
||||
|
||||
return {
|
||||
count: backups.length,
|
||||
totalSize,
|
||||
oldestBackup: oldestBackup ? {
|
||||
filename: oldestBackup.filename,
|
||||
created: oldestBackup.created,
|
||||
size: oldestBackup.size
|
||||
} : null,
|
||||
newestBackup: newestBackup ? {
|
||||
filename: newestBackup.filename,
|
||||
created: newestBackup.created,
|
||||
size: newestBackup.size
|
||||
} : null
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Failed to get backup statistics', { error: error.message });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = BackupManager;
|
||||
166
utils/logger.js
Normal file
166
utils/logger.js
Normal file
@ -0,0 +1,166 @@
|
||||
const winston = require('winston');
|
||||
const DailyRotateFile = require('winston-daily-rotate-file');
|
||||
const path = require('path');
|
||||
|
||||
// Create logs directory if it doesn't exist
|
||||
const logsDir = path.join(__dirname, '..', 'logs');
|
||||
|
||||
// Define log levels and colors
|
||||
const logLevels = {
|
||||
error: 0,
|
||||
warn: 1,
|
||||
info: 2,
|
||||
http: 3,
|
||||
debug: 4
|
||||
};
|
||||
|
||||
const logColors = {
|
||||
error: 'red',
|
||||
warn: 'yellow',
|
||||
info: 'green',
|
||||
http: 'magenta',
|
||||
debug: 'blue'
|
||||
};
|
||||
|
||||
winston.addColors(logColors);
|
||||
|
||||
// Custom format for structured logging
|
||||
const logFormat = winston.format.combine(
|
||||
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
|
||||
winston.format.errors({ stack: true }),
|
||||
winston.format.json(),
|
||||
winston.format.printf(({ timestamp, level, message, stack, ...meta }) => {
|
||||
let logMessage = `${timestamp} [${level.toUpperCase()}]: ${message}`;
|
||||
|
||||
// Add stack trace for errors
|
||||
if (stack) {
|
||||
logMessage += `\nStack: ${stack}`;
|
||||
}
|
||||
|
||||
// Add metadata if present
|
||||
if (Object.keys(meta).length > 0) {
|
||||
logMessage += `\nMeta: ${JSON.stringify(meta, null, 2)}`;
|
||||
}
|
||||
|
||||
return logMessage;
|
||||
})
|
||||
);
|
||||
|
||||
// Console format for development
|
||||
const consoleFormat = winston.format.combine(
|
||||
winston.format.colorize({ all: true }),
|
||||
winston.format.timestamp({ format: 'HH:mm:ss' }),
|
||||
winston.format.printf(({ timestamp, level, message, stack }) => {
|
||||
let logMessage = `${timestamp} ${level}: ${message}`;
|
||||
if (stack) {
|
||||
logMessage += `\n${stack}`;
|
||||
}
|
||||
return logMessage;
|
||||
})
|
||||
);
|
||||
|
||||
// Create transports
|
||||
const transports = [
|
||||
// Console transport for development
|
||||
new winston.transports.Console({
|
||||
level: process.env.NODE_ENV === 'production' ? 'info' : 'debug',
|
||||
format: consoleFormat,
|
||||
handleExceptions: true,
|
||||
handleRejections: true
|
||||
}),
|
||||
|
||||
// File transport for all logs
|
||||
new DailyRotateFile({
|
||||
filename: path.join(logsDir, 'application-%DATE%.log'),
|
||||
datePattern: 'YYYY-MM-DD',
|
||||
maxSize: '20m',
|
||||
maxFiles: '14d',
|
||||
level: 'debug',
|
||||
format: logFormat,
|
||||
handleExceptions: true,
|
||||
handleRejections: true
|
||||
}),
|
||||
|
||||
// Separate file for errors
|
||||
new DailyRotateFile({
|
||||
filename: path.join(logsDir, 'error-%DATE%.log'),
|
||||
datePattern: 'YYYY-MM-DD',
|
||||
maxSize: '20m',
|
||||
maxFiles: '30d',
|
||||
level: 'error',
|
||||
format: logFormat,
|
||||
handleExceptions: true,
|
||||
handleRejections: true
|
||||
}),
|
||||
|
||||
// HTTP requests log
|
||||
new DailyRotateFile({
|
||||
filename: path.join(logsDir, 'http-%DATE%.log'),
|
||||
datePattern: 'YYYY-MM-DD',
|
||||
maxSize: '20m',
|
||||
maxFiles: '7d',
|
||||
level: 'http',
|
||||
format: logFormat
|
||||
})
|
||||
];
|
||||
|
||||
// Create logger instance
|
||||
const logger = winston.createLogger({
|
||||
levels: logLevels,
|
||||
transports,
|
||||
exitOnError: false
|
||||
});
|
||||
|
||||
// Add request logging helper
|
||||
logger.logRequest = (req, res, responseTime) => {
|
||||
const logData = {
|
||||
method: req.method,
|
||||
url: req.originalUrl,
|
||||
ip: req.ip || req.connection.remoteAddress,
|
||||
userAgent: req.get('User-Agent'),
|
||||
statusCode: res.statusCode,
|
||||
responseTime: `${responseTime}ms`,
|
||||
contentLength: res.get('Content-Length') || 0
|
||||
};
|
||||
|
||||
// Log based on status code
|
||||
if (res.statusCode >= 500) {
|
||||
logger.error('HTTP Request Error', logData);
|
||||
} else if (res.statusCode >= 400) {
|
||||
logger.warn('HTTP Request Warning', logData);
|
||||
} else {
|
||||
logger.http('HTTP Request', logData);
|
||||
}
|
||||
};
|
||||
|
||||
// Add database operation logging helper
|
||||
logger.logDbOperation = (operation, table, data = {}, duration = null) => {
|
||||
const logData = {
|
||||
operation,
|
||||
table,
|
||||
duration: duration ? `${duration}ms` : null,
|
||||
...data
|
||||
};
|
||||
|
||||
logger.debug('Database Operation', logData);
|
||||
};
|
||||
|
||||
// Add error context helper
|
||||
logger.logError = (error, context = {}) => {
|
||||
const errorData = {
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
name: error.name,
|
||||
code: error.code,
|
||||
...context
|
||||
};
|
||||
|
||||
logger.error('Application Error', errorData);
|
||||
};
|
||||
|
||||
// Add business logic logging helper
|
||||
logger.logBusinessEvent = (event, data = {}) => {
|
||||
logger.info(`Business Event: ${event}`, data);
|
||||
};
|
||||
|
||||
module.exports = logger;
|
||||
262
utils/retry.js
Normal file
262
utils/retry.js
Normal file
@ -0,0 +1,262 @@
|
||||
const logger = require('./logger');
|
||||
|
||||
/**
|
||||
* Retry configuration options
|
||||
*/
|
||||
const DEFAULT_RETRY_OPTIONS = {
|
||||
maxAttempts: 3,
|
||||
baseDelay: 1000, // 1 second
|
||||
maxDelay: 10000, // 10 seconds
|
||||
backoffFactor: 2,
|
||||
jitter: true,
|
||||
retryCondition: (error) => {
|
||||
// Default retry condition - retry on network errors and 5xx responses
|
||||
const retryableCodes = ['ECONNRESET', 'ETIMEDOUT', 'ENOTFOUND', 'ECONNREFUSED'];
|
||||
const retryableStatusCodes = [500, 502, 503, 504];
|
||||
|
||||
return retryableCodes.includes(error.code) ||
|
||||
retryableStatusCodes.includes(error.statusCode) ||
|
||||
(error.status && retryableStatusCodes.includes(error.status));
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculate delay with exponential backoff and optional jitter
|
||||
*/
|
||||
const calculateDelay = (attempt, options) => {
|
||||
const { baseDelay, maxDelay, backoffFactor, jitter } = options;
|
||||
|
||||
let delay = baseDelay * Math.pow(backoffFactor, attempt - 1);
|
||||
|
||||
// Apply maximum delay limit
|
||||
delay = Math.min(delay, maxDelay);
|
||||
|
||||
// Add jitter to prevent thundering herd
|
||||
if (jitter) {
|
||||
delay = delay * (0.5 + Math.random() * 0.5);
|
||||
}
|
||||
|
||||
return Math.floor(delay);
|
||||
};
|
||||
|
||||
/**
|
||||
* Sleep utility function
|
||||
*/
|
||||
const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
|
||||
|
||||
/**
|
||||
* Retry wrapper for async operations
|
||||
*/
|
||||
const withRetry = async (operation, options = {}) => {
|
||||
const config = { ...DEFAULT_RETRY_OPTIONS, ...options };
|
||||
const { maxAttempts, retryCondition } = config;
|
||||
|
||||
let lastError;
|
||||
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||
try {
|
||||
logger.debug('Retry Attempt', {
|
||||
operation: operation.name || 'anonymous',
|
||||
attempt,
|
||||
maxAttempts
|
||||
});
|
||||
|
||||
const result = await operation();
|
||||
|
||||
if (attempt > 1) {
|
||||
logger.info('Retry Successful', {
|
||||
operation: operation.name || 'anonymous',
|
||||
attempt,
|
||||
totalAttempts: attempt
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
|
||||
logger.warn('Retry Attempt Failed', {
|
||||
operation: operation.name || 'anonymous',
|
||||
attempt,
|
||||
maxAttempts,
|
||||
error: error.message,
|
||||
errorCode: error.code,
|
||||
statusCode: error.statusCode
|
||||
});
|
||||
|
||||
// Check if we should retry
|
||||
if (attempt === maxAttempts || !retryCondition(error)) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Calculate and apply delay
|
||||
const delay = calculateDelay(attempt, config);
|
||||
|
||||
logger.debug('Retry Delay', {
|
||||
operation: operation.name || 'anonymous',
|
||||
attempt,
|
||||
delay: `${delay}ms`
|
||||
});
|
||||
|
||||
await sleep(delay);
|
||||
}
|
||||
}
|
||||
|
||||
// All attempts failed
|
||||
logger.error('Retry Exhausted', {
|
||||
operation: operation.name || 'anonymous',
|
||||
totalAttempts: maxAttempts,
|
||||
finalError: lastError.message
|
||||
});
|
||||
|
||||
throw lastError;
|
||||
};
|
||||
|
||||
/**
|
||||
* Retry wrapper for database operations
|
||||
*/
|
||||
const withDatabaseRetry = async (operation, options = {}) => {
|
||||
const dbRetryOptions = {
|
||||
maxAttempts: 3,
|
||||
baseDelay: 500,
|
||||
maxDelay: 5000,
|
||||
retryCondition: (error) => {
|
||||
// Retry on database connection errors and lock timeouts
|
||||
const retryableCodes = [
|
||||
'SQLITE_BUSY',
|
||||
'SQLITE_LOCKED',
|
||||
'SQLITE_PROTOCOL',
|
||||
'ECONNRESET',
|
||||
'ETIMEDOUT'
|
||||
];
|
||||
|
||||
return retryableCodes.includes(error.code) ||
|
||||
error.message.includes('database is locked') ||
|
||||
error.message.includes('connection') ||
|
||||
error.message.includes('timeout');
|
||||
},
|
||||
...options
|
||||
};
|
||||
|
||||
return withRetry(operation, dbRetryOptions);
|
||||
};
|
||||
|
||||
/**
|
||||
* Retry wrapper for file operations
|
||||
*/
|
||||
const withFileRetry = async (operation, options = {}) => {
|
||||
const fileRetryOptions = {
|
||||
maxAttempts: 2,
|
||||
baseDelay: 100,
|
||||
maxDelay: 1000,
|
||||
retryCondition: (error) => {
|
||||
// Retry on temporary file system errors
|
||||
const retryableCodes = [
|
||||
'EBUSY',
|
||||
'EMFILE',
|
||||
'ENFILE',
|
||||
'ENOENT'
|
||||
];
|
||||
|
||||
return retryableCodes.includes(error.code);
|
||||
},
|
||||
...options
|
||||
};
|
||||
|
||||
return withRetry(operation, fileRetryOptions);
|
||||
};
|
||||
|
||||
/**
|
||||
* Circuit breaker pattern implementation
|
||||
*/
|
||||
class CircuitBreaker {
|
||||
constructor(options = {}) {
|
||||
this.failureThreshold = options.failureThreshold || 5;
|
||||
this.resetTimeout = options.resetTimeout || 60000; // 1 minute
|
||||
this.monitoringPeriod = options.monitoringPeriod || 10000; // 10 seconds
|
||||
|
||||
this.state = 'CLOSED'; // CLOSED, OPEN, HALF_OPEN
|
||||
this.failureCount = 0;
|
||||
this.lastFailureTime = null;
|
||||
this.successCount = 0;
|
||||
}
|
||||
|
||||
async execute(operation) {
|
||||
if (this.state === 'OPEN') {
|
||||
if (Date.now() - this.lastFailureTime >= this.resetTimeout) {
|
||||
this.state = 'HALF_OPEN';
|
||||
this.successCount = 0;
|
||||
logger.info('Circuit Breaker Half-Open', {
|
||||
operation: operation.name || 'anonymous'
|
||||
});
|
||||
} else {
|
||||
const error = new Error('Circuit breaker is OPEN');
|
||||
error.code = 'CIRCUIT_BREAKER_OPEN';
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await operation();
|
||||
|
||||
if (this.state === 'HALF_OPEN') {
|
||||
this.successCount++;
|
||||
if (this.successCount >= 3) {
|
||||
this.reset();
|
||||
logger.info('Circuit Breaker Closed', {
|
||||
operation: operation.name || 'anonymous'
|
||||
});
|
||||
}
|
||||
} else {
|
||||
this.reset();
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
this.recordFailure();
|
||||
|
||||
if (this.failureCount >= this.failureThreshold) {
|
||||
this.state = 'OPEN';
|
||||
this.lastFailureTime = Date.now();
|
||||
|
||||
logger.error('Circuit Breaker Opened', {
|
||||
operation: operation.name || 'anonymous',
|
||||
failureCount: this.failureCount,
|
||||
threshold: this.failureThreshold
|
||||
});
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.state = 'CLOSED';
|
||||
this.failureCount = 0;
|
||||
this.lastFailureTime = null;
|
||||
this.successCount = 0;
|
||||
}
|
||||
|
||||
recordFailure() {
|
||||
this.failureCount++;
|
||||
}
|
||||
|
||||
getState() {
|
||||
return {
|
||||
state: this.state,
|
||||
failureCount: this.failureCount,
|
||||
lastFailureTime: this.lastFailureTime,
|
||||
successCount: this.successCount
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
withRetry,
|
||||
withDatabaseRetry,
|
||||
withFileRetry,
|
||||
CircuitBreaker,
|
||||
calculateDelay,
|
||||
sleep,
|
||||
DEFAULT_RETRY_OPTIONS
|
||||
};
|
||||
Reference in New Issue
Block a user