Files
inventory-barcode-system/models/database.js

696 lines
22 KiB
JavaScript

const Database = require('better-sqlite3');
const path = require('path');
const logger = require('../utils/logger');
const { withDatabaseRetry, CircuitBreaker } = require('../utils/retry');
const { DatabaseError } = require('../middleware/errorHandler');
class DatabaseManager {
constructor() {
this.db = null;
this.dbPath = path.join(__dirname, '..', 'inventory.db');
this.isInitialized = false;
this.circuitBreaker = new CircuitBreaker({
failureThreshold: 5,
resetTimeout: 30000
});
}
/**
* Initialize database connection and create tables
*/
async initialize() {
if (this.isInitialized) {
logger.debug('Database already initialized');
return;
}
try {
await withDatabaseRetry(async () => {
logger.info('Initializing database connection', { dbPath: this.dbPath });
this.db = new Database(this.dbPath);
// Configure database settings
this.db.pragma('journal_mode = WAL');
this.db.pragma('synchronous = NORMAL');
this.db.pragma('cache_size = 1000');
this.db.pragma('temp_store = memory');
this.db.pragma('mmap_size = 268435456'); // 256MB
// Set busy timeout
this.db.pragma('busy_timeout = 5000');
await this.createTables();
this.isInitialized = true;
logger.info('Database initialized successfully', {
dbPath: this.dbPath,
journalMode: this.db.pragma('journal_mode', { simple: true }),
cacheSize: this.db.pragma('cache_size', { simple: true })
});
});
} catch (error) {
logger.error('Database initialization failed', {
error: error.message,
dbPath: this.dbPath,
stack: error.stack
});
throw new DatabaseError('Failed to initialize database', {
originalError: error.message,
dbPath: this.dbPath
});
}
}
/**
* Create all required tables
*/
async createTables() {
// Products table as per design document
const createProductsTable = `
CREATE TABLE IF NOT EXISTS products (
id INTEGER PRIMARY KEY AUTOINCREMENT,
product_code VARCHAR(50) UNIQUE NOT NULL,
description TEXT,
category VARCHAR(100),
unit_of_measure VARCHAR(20),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`;
// Inventory table as per design document
const createInventoryTable = `
CREATE TABLE IF NOT EXISTS inventory (
id INTEGER PRIMARY KEY AUTOINCREMENT,
product_id INTEGER NOT NULL,
current_level INTEGER NOT NULL DEFAULT 0,
minimum_level INTEGER DEFAULT 0,
maximum_level INTEGER,
last_updated DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_by VARCHAR(100),
version INTEGER DEFAULT 1,
FOREIGN KEY (product_id) REFERENCES products(id) ON DELETE CASCADE
)
`;
// Inventory history table as per design document
const createInventoryHistoryTable = `
CREATE TABLE IF NOT EXISTS inventory_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
product_id INTEGER NOT NULL,
old_level INTEGER,
new_level INTEGER NOT NULL,
change_reason VARCHAR(200),
updated_by VARCHAR(100),
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (product_id) REFERENCES products(id) ON DELETE CASCADE
)
`;
// Import sessions table as per design document
const createImportSessionsTable = `
CREATE TABLE IF NOT EXISTS import_sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
filename VARCHAR(255),
total_records INTEGER,
successful_imports INTEGER,
failed_imports INTEGER,
import_date DATETIME DEFAULT CURRENT_TIMESTAMP,
status VARCHAR(20) DEFAULT 'completed'
)
`;
// Legacy tables for backward compatibility (will be migrated)
const createItemsTable = `
CREATE TABLE IF NOT EXISTS items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
description TEXT,
category TEXT,
quantity INTEGER NOT NULL DEFAULT 0,
unit TEXT,
barcode TEXT UNIQUE,
qr_code TEXT,
location TEXT,
min_stock_level INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`;
const createTransactionsTable = `
CREATE TABLE IF NOT EXISTS transactions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
item_id INTEGER NOT NULL,
type TEXT NOT NULL CHECK (type IN ('in', 'out', 'adjustment')),
quantity INTEGER NOT NULL,
reason TEXT,
user_name TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (item_id) REFERENCES items (id) ON DELETE CASCADE
)
`;
const createIndexes = [
// New schema indexes - optimized for performance
'CREATE UNIQUE INDEX IF NOT EXISTS idx_products_product_code ON products(product_code)',
'CREATE INDEX IF NOT EXISTS idx_products_category ON products(category)',
'CREATE INDEX IF NOT EXISTS idx_products_description ON products(description)',
'CREATE INDEX IF NOT EXISTS idx_products_created_at ON products(created_at)',
// Inventory indexes - optimized for common queries
'CREATE UNIQUE INDEX IF NOT EXISTS idx_inventory_product_id ON inventory(product_id)',
'CREATE INDEX IF NOT EXISTS idx_inventory_current_level ON inventory(current_level)',
'CREATE INDEX IF NOT EXISTS idx_inventory_low_stock ON inventory(current_level, minimum_level) WHERE current_level <= minimum_level',
'CREATE INDEX IF NOT EXISTS idx_inventory_last_updated ON inventory(last_updated)',
'CREATE INDEX IF NOT EXISTS idx_inventory_updated_by ON inventory(updated_by)',
'CREATE INDEX IF NOT EXISTS idx_inventory_version ON inventory(version)', // For optimistic locking
// Inventory history indexes - optimized for audit queries
'CREATE INDEX IF NOT EXISTS idx_inventory_history_product_id ON inventory_history(product_id)',
'CREATE INDEX IF NOT EXISTS idx_inventory_history_updated_at ON inventory_history(updated_at)',
'CREATE INDEX IF NOT EXISTS idx_inventory_history_updated_by ON inventory_history(updated_by)',
'CREATE INDEX IF NOT EXISTS idx_inventory_history_product_date ON inventory_history(product_id, updated_at)',
// Import sessions indexes
'CREATE INDEX IF NOT EXISTS idx_import_sessions_date ON import_sessions(import_date)',
'CREATE INDEX IF NOT EXISTS idx_import_sessions_status ON import_sessions(status)',
// Composite indexes for complex queries
'CREATE INDEX IF NOT EXISTS idx_products_category_code ON products(category, product_code)',
'CREATE INDEX IF NOT EXISTS idx_inventory_product_level ON inventory(product_id, current_level)',
'CREATE INDEX IF NOT EXISTS idx_inventory_levels_range ON inventory(current_level, minimum_level, maximum_level)',
// Legacy schema indexes
'CREATE INDEX IF NOT EXISTS idx_items_barcode ON items(barcode)',
'CREATE INDEX IF NOT EXISTS idx_items_name ON items(name)',
'CREATE INDEX IF NOT EXISTS idx_items_category ON items(category)',
'CREATE INDEX IF NOT EXISTS idx_items_quantity ON items(quantity)',
'CREATE INDEX IF NOT EXISTS idx_transactions_item_id ON transactions(item_id)',
'CREATE INDEX IF NOT EXISTS idx_transactions_created_at ON transactions(created_at)',
'CREATE INDEX IF NOT EXISTS idx_transactions_type ON transactions(type)',
'CREATE INDEX IF NOT EXISTS idx_transactions_item_date ON transactions(item_id, created_at)'
];
try {
logger.info('Creating database tables and indexes');
const startTime = Date.now();
// Create new schema tables
this.db.exec(createProductsTable);
this.db.exec(createInventoryTable);
this.db.exec(createInventoryHistoryTable);
this.db.exec(createImportSessionsTable);
// Create legacy tables for backward compatibility
this.db.exec(createItemsTable);
this.db.exec(createTransactionsTable);
// Create indexes
createIndexes.forEach((indexQuery, index) => {
try {
this.db.exec(indexQuery);
logger.debug('Index created', { index: index + 1, total: createIndexes.length });
} catch (indexError) {
logger.warn('Index creation failed', {
query: indexQuery,
error: indexError.message
});
}
});
const duration = Date.now() - startTime;
logger.info('Database tables created successfully', { duration: `${duration}ms` });
} catch (error) {
logger.error('Error creating database tables', {
error: error.message,
stack: error.stack
});
throw new DatabaseError('Failed to create database tables', {
originalError: error.message
});
}
}
/**
* Get database instance with circuit breaker protection
*/
getDatabase() {
if (!this.db || !this.isInitialized) {
throw new DatabaseError('Database not initialized. Call initialize() first.');
}
return this.db;
}
/**
* Execute database operation with circuit breaker and retry logic
*/
async executeWithProtection(operation, operationName = 'database_operation') {
return await this.circuitBreaker.execute(async () => {
return await withDatabaseRetry(async () => {
const startTime = Date.now();
try {
const result = await operation();
const duration = Date.now() - startTime;
logger.logDbOperation(operationName, 'unknown', {}, duration);
return result;
} catch (error) {
const duration = Date.now() - startTime;
logger.error('Database operation failed', {
operation: operationName,
duration: `${duration}ms`,
error: error.message,
code: error.code
});
throw error;
}
});
});
}
/**
* Close database connection
*/
close() {
if (this.db) {
try {
logger.info('Closing database connection');
this.db.close();
this.db = null;
this.isInitialized = false;
logger.info('Database connection closed successfully');
} catch (error) {
logger.error('Error closing database connection', {
error: error.message
});
}
}
}
/**
* Execute a transaction with rollback support and error handling
*/
async executeTransaction(callback, transactionName = 'transaction') {
return await this.executeWithProtection(async () => {
const startTime = Date.now();
try {
logger.debug('Starting database transaction', { name: transactionName });
const transaction = this.db.transaction(callback);
const result = transaction();
const duration = Date.now() - startTime;
logger.debug('Database transaction completed', {
name: transactionName,
duration: `${duration}ms`
});
return result;
} catch (error) {
const duration = Date.now() - startTime;
logger.error('Database transaction failed', {
name: transactionName,
duration: `${duration}ms`,
error: error.message
});
throw new DatabaseError(`Transaction failed: ${transactionName}`, {
originalError: error.message,
transactionName
});
}
}, `transaction_${transactionName}`);
}
/**
* Health check for database connection
*/
async healthCheck() {
try {
const result = this.db.prepare('SELECT 1 as health').get();
return {
status: 'healthy',
connected: true,
result: result.health === 1
};
} catch (error) {
logger.error('Database health check failed', {
error: error.message
});
return {
status: 'unhealthy',
connected: false,
error: error.message
};
}
}
/**
* Get database statistics
*/
getStats() {
try {
const stats = {
isInitialized: this.isInitialized,
dbPath: this.dbPath,
circuitBreakerState: this.circuitBreaker.getState()
};
if (this.db) {
stats.pragmas = {
journalMode: this.db.pragma('journal_mode', { simple: true }),
synchronous: this.db.pragma('synchronous', { simple: true }),
cacheSize: this.db.pragma('cache_size', { simple: true }),
busyTimeout: this.db.pragma('busy_timeout', { simple: true })
};
// Get table statistics
stats.tables = this.getTableStats();
// Get index usage statistics
stats.indexes = this.getIndexStats();
}
return stats;
} catch (error) {
logger.error('Error getting database stats', {
error: error.message
});
return {
isInitialized: this.isInitialized,
error: error.message
};
}
}
/**
* Get table statistics for performance monitoring
*/
getTableStats() {
try {
const tables = ['products', 'inventory', 'inventory_history', 'import_sessions'];
const stats = {};
tables.forEach(table => {
try {
const countResult = this.db.prepare(`SELECT COUNT(*) as count FROM ${table}`).get();
stats[table] = {
rowCount: countResult.count
};
} catch (error) {
stats[table] = { error: error.message };
}
});
return stats;
} catch (error) {
logger.error('Error getting table stats', { error: error.message });
return {};
}
}
/**
* Get index statistics for query optimization
*/
getIndexStats() {
try {
const indexes = this.db.prepare(`
SELECT name, tbl_name, sql
FROM sqlite_master
WHERE type = 'index' AND name NOT LIKE 'sqlite_%'
ORDER BY tbl_name, name
`).all();
return indexes.map(index => ({
name: index.name,
table: index.tbl_name,
definition: index.sql
}));
} catch (error) {
logger.error('Error getting index stats', { error: error.message });
return [];
}
}
/**
* Analyze database performance and suggest optimizations
*/
analyzePerformance() {
try {
const analysis = {
timestamp: new Date().toISOString(),
recommendations: []
};
// Check for missing indexes on frequently queried columns
const tableStats = this.getTableStats();
// Analyze query patterns (this would be enhanced with actual query logging)
if (tableStats.products && tableStats.products.rowCount > 1000) {
analysis.recommendations.push({
type: 'INDEX_OPTIMIZATION',
message: 'Consider adding composite indexes for frequently filtered product queries',
priority: 'MEDIUM'
});
}
if (tableStats.inventory_history && tableStats.inventory_history.rowCount > 10000) {
analysis.recommendations.push({
type: 'ARCHIVAL',
message: 'Consider archiving old inventory history records to improve query performance',
priority: 'LOW'
});
}
// Check cache hit ratio (simplified)
const cacheSize = this.db.pragma('cache_size', { simple: true });
if (cacheSize < 2000) {
analysis.recommendations.push({
type: 'CACHE_OPTIMIZATION',
message: 'Consider increasing cache size for better performance with large datasets',
priority: 'HIGH'
});
}
return analysis;
} catch (error) {
logger.error('Error analyzing database performance', { error: error.message });
return {
timestamp: new Date().toISOString(),
error: error.message,
recommendations: []
};
}
}
/**
* Optimize database for better performance
*/
async optimize() {
return await this.executeWithProtection(async () => {
logger.info('Starting database optimization');
const startTime = Date.now();
const results = {
vacuum: false,
analyze: false,
reindex: false,
pragmaUpdates: []
};
try {
// Run VACUUM to reclaim space and defragment
this.db.exec('VACUUM');
results.vacuum = true;
logger.debug('Database VACUUM completed');
// Run ANALYZE to update query planner statistics
this.db.exec('ANALYZE');
results.analyze = true;
logger.debug('Database ANALYZE completed');
// Reindex all indexes
this.db.exec('REINDEX');
results.reindex = true;
logger.debug('Database REINDEX completed');
// Optimize pragma settings for performance
const optimizations = [
{ pragma: 'optimize', value: null }, // SQLite auto-optimization
{ pragma: 'cache_size', value: 2000 },
{ pragma: 'temp_store', value: 'memory' },
{ pragma: 'mmap_size', value: 268435456 } // 256MB
];
optimizations.forEach(opt => {
try {
if (opt.value !== null) {
this.db.pragma(`${opt.pragma} = ${opt.value}`);
} else {
this.db.pragma(opt.pragma);
}
results.pragmaUpdates.push(opt.pragma);
} catch (error) {
logger.warn(`Failed to update pragma ${opt.pragma}`, { error: error.message });
}
});
const duration = Date.now() - startTime;
logger.info('Database optimization completed', {
duration: `${duration}ms`,
results
});
return {
success: true,
duration,
results
};
} catch (error) {
const duration = Date.now() - startTime;
logger.error('Database optimization failed', {
duration: `${duration}ms`,
error: error.message,
partialResults: results
});
throw new DatabaseError('Database optimization failed', {
originalError: error.message,
partialResults: results
});
}
}, 'database_optimization');
}
/**
* Prepare optimized statements for common queries
*/
prepareOptimizedStatements() {
if (!this.db) {
throw new DatabaseError('Database not initialized');
}
try {
// Cache frequently used prepared statements
this.preparedStatements = {
// Product queries
findProductByCode: this.db.prepare(`
SELECT p.*, i.current_level, i.minimum_level, i.maximum_level
FROM products p
LEFT JOIN inventory i ON p.id = i.product_id
WHERE p.product_code = ?
`),
findProductsByCategory: this.db.prepare(`
SELECT p.*, i.current_level, i.minimum_level, i.maximum_level
FROM products p
LEFT JOIN inventory i ON p.id = i.product_id
WHERE p.category = ?
ORDER BY p.product_code
LIMIT ? OFFSET ?
`),
// Inventory queries
getInventorySummary: this.db.prepare(`
SELECT
p.id,
p.product_code,
p.description,
p.category,
i.current_level,
i.minimum_level,
i.maximum_level,
i.last_updated,
i.updated_by,
CASE
WHEN i.current_level <= i.minimum_level THEN 'low'
WHEN i.current_level >= i.maximum_level THEN 'high'
ELSE 'normal'
END as stock_status
FROM products p
INNER JOIN inventory i ON p.id = i.product_id
ORDER BY p.product_code
LIMIT ? OFFSET ?
`),
getLowStockItems: this.db.prepare(`
SELECT
p.id,
p.product_code,
p.description,
p.category,
i.current_level,
i.minimum_level,
i.last_updated
FROM products p
INNER JOIN inventory i ON p.id = i.product_id
WHERE i.current_level <= i.minimum_level
ORDER BY (i.current_level - i.minimum_level), p.product_code
`),
// History queries
getInventoryHistory: this.db.prepare(`
SELECT
ih.*,
p.product_code,
p.description
FROM inventory_history ih
INNER JOIN products p ON ih.product_id = p.id
WHERE ih.product_id = ?
ORDER BY ih.updated_at DESC
LIMIT ? OFFSET ?
`),
// Update queries with optimistic locking
updateInventoryLevel: this.db.prepare(`
UPDATE inventory
SET current_level = ?,
last_updated = datetime('now'),
updated_by = ?,
version = version + 1
WHERE product_id = ? AND version = ?
`),
insertInventoryHistory: this.db.prepare(`
INSERT INTO inventory_history (product_id, old_level, new_level, change_reason, updated_by)
VALUES (?, ?, ?, ?, ?)
`)
};
logger.info('Optimized prepared statements created');
return this.preparedStatements;
} catch (error) {
logger.error('Failed to prepare optimized statements', { error: error.message });
throw new DatabaseError('Failed to prepare optimized statements', {
originalError: error.message
});
}
}
/**
* Get prepared statement by name
*/
getPreparedStatement(name) {
if (!this.preparedStatements) {
this.prepareOptimizedStatements();
}
const statement = this.preparedStatements[name];
if (!statement) {
throw new DatabaseError(`Prepared statement '${name}' not found`);
}
return statement;
}
}
module.exports = new DatabaseManager();