Initial commit: Inventory Barcode System

This commit is contained in:
2025-07-22 20:24:51 -04:00
commit 511b01748d
63 changed files with 26932 additions and 0 deletions

2
models/.gitkeep Normal file
View File

@ -0,0 +1,2 @@
# Models directory
This directory contains data models and database schemas

434
models/Inventory.js Normal file
View File

@ -0,0 +1,434 @@
const database = require('./database');
class Inventory {
constructor(data = {}) {
this.id = data.id || null;
this.product_id = data.product_id || null;
this.current_level = data.current_level || 0;
this.minimum_level = data.minimum_level || 0;
this.maximum_level = data.maximum_level || null;
this.last_updated = data.last_updated || null;
this.updated_by = data.updated_by || 'system';
this.version = data.version || 1; // For optimistic locking
}
/**
* Validate inventory data
* @returns {Object} validation result with isValid boolean and errors array
*/
validate() {
const errors = [];
// Required field validation
if (!this.product_id || typeof this.product_id !== 'number') {
errors.push('Product ID is required and must be a number');
}
// Current level validation
if (typeof this.current_level !== 'number' || this.current_level < 0) {
errors.push('Current level must be a non-negative number');
}
// Minimum level validation
if (this.minimum_level !== null && (typeof this.minimum_level !== 'number' || this.minimum_level < 0)) {
errors.push('Minimum level must be a non-negative number');
}
// Maximum level validation
if (this.maximum_level !== null && (typeof this.maximum_level !== 'number' || this.maximum_level < 0)) {
errors.push('Maximum level must be a non-negative number');
}
// Maximum should be greater than minimum
if (this.maximum_level !== null && this.minimum_level !== null && this.maximum_level < this.minimum_level) {
errors.push('Maximum level must be greater than minimum level');
}
// Updated by validation
if (!this.updated_by || this.updated_by.trim().length === 0) {
errors.push('Updated by is required');
}
return {
isValid: errors.length === 0,
errors: errors
};
}
/**
* Save inventory record to database
* @returns {Promise<Inventory>} Saved inventory record
*/
async save() {
const validation = this.validate();
if (!validation.isValid) {
throw new Error(`Validation failed: ${validation.errors.join(', ')}`);
}
const db = database.getDatabase();
if (this.id) {
// Update existing record with optimistic locking
const updateStmt = db.prepare(`
UPDATE inventory
SET current_level = ?, minimum_level = ?, maximum_level = ?,
last_updated = CURRENT_TIMESTAMP, updated_by = ?, version = version + 1
WHERE id = ? AND version = ?
`);
const result = updateStmt.run(
this.current_level,
this.minimum_level,
this.maximum_level,
this.updated_by,
this.id,
this.version
);
if (result.changes === 0) {
throw new Error('Concurrent update detected. Please refresh and try again.');
}
this.version += 1;
this.last_updated = new Date().toISOString();
} else {
// Insert new record
const insertStmt = db.prepare(`
INSERT INTO inventory (product_id, current_level, minimum_level, maximum_level, updated_by)
VALUES (?, ?, ?, ?, ?)
`);
const result = insertStmt.run(
this.product_id,
this.current_level,
this.minimum_level,
this.maximum_level,
this.updated_by
);
this.id = result.lastInsertRowid;
this.last_updated = new Date().toISOString();
}
return this;
}
/**
* Update inventory level with audit trail and concurrent update handling
* @param {number} productId - Product ID to update
* @param {number} newLevel - New inventory level
* @param {string} changeReason - Reason for the change
* @param {string} updatedBy - User making the change
* @returns {Promise<Inventory>} Updated inventory record
*/
static async updateInventoryLevel(productId, newLevel, changeReason = '', updatedBy = 'system') {
const db = database.getDatabase();
return database.executeTransaction(() => {
// Get current inventory record with locking
const currentInventory = db.prepare(`
SELECT * FROM inventory WHERE product_id = ?
`).get(productId);
if (!currentInventory) {
throw new Error(`Inventory record for product ID ${productId} not found`);
}
const oldLevel = currentInventory.current_level;
// Validate the new level
if (newLevel < 0) {
throw new Error('Inventory level cannot be negative');
}
// Update inventory with optimistic locking
const updateStmt = db.prepare(`
UPDATE inventory
SET current_level = ?, last_updated = CURRENT_TIMESTAMP, updated_by = ?, version = version + 1
WHERE product_id = ? AND version = ?
`);
const result = updateStmt.run(newLevel, updatedBy, productId, currentInventory.version);
if (result.changes === 0) {
throw new Error('Concurrent update detected. Please refresh and try again.');
}
// Create audit trail record
const historyStmt = db.prepare(`
INSERT INTO inventory_history (product_id, old_level, new_level, change_reason, updated_by)
VALUES (?, ?, ?, ?, ?)
`);
historyStmt.run(productId, oldLevel, newLevel, changeReason, updatedBy);
// Return updated inventory record
const updatedInventory = db.prepare(`
SELECT * FROM inventory WHERE product_id = ?
`).get(productId);
return new Inventory({
id: updatedInventory.id,
product_id: updatedInventory.product_id,
current_level: updatedInventory.current_level,
minimum_level: updatedInventory.minimum_level,
maximum_level: updatedInventory.maximum_level,
last_updated: updatedInventory.last_updated,
updated_by: updatedInventory.updated_by,
version: updatedInventory.version
});
});
}
/**
* Get current inventory level for a product
* @param {number} productId - Product ID
* @returns {Promise<number>} Current inventory level
*/
static async getCurrentLevel(productId) {
const db = database.getDatabase();
const stmt = db.prepare('SELECT current_level FROM inventory WHERE product_id = ?');
const result = stmt.get(productId);
return result ? result.current_level : 0;
}
/**
* Get inventory record by product ID
* @param {number} productId - Product ID
* @returns {Promise<Inventory|null>} Inventory record or null if not found
*/
static async getByProductId(productId) {
const db = database.getDatabase();
const stmt = db.prepare('SELECT * FROM inventory WHERE product_id = ?');
const result = stmt.get(productId);
return result ? new Inventory(result) : null;
}
/**
* Get inventory history for a product
* @param {number} productId - Product ID
* @param {Object} options - Query options (limit, offset, startDate, endDate)
* @returns {Promise<Object[]>} Array of inventory history records
*/
static async getInventoryHistory(productId, options = {}) {
const db = database.getDatabase();
const { limit = 50, offset = 0, startDate, endDate } = options;
let query = `
SELECT h.*, p.product_code, p.description
FROM inventory_history h
JOIN products p ON h.product_id = p.id
WHERE h.product_id = ?
`;
const params = [productId];
if (startDate) {
query += ' AND h.updated_at >= ?';
params.push(startDate);
}
if (endDate) {
query += ' AND h.updated_at <= ?';
params.push(endDate);
}
query += ' ORDER BY h.updated_at DESC, h.id DESC LIMIT ? OFFSET ?';
params.push(limit, offset);
const stmt = db.prepare(query);
return stmt.all(...params);
}
/**
* Get inventory summary for all products
* @param {Object} filters - Optional filters (category, lowStock)
* @returns {Promise<Object[]>} Array of inventory summary objects
*/
static async getInventorySummary(filters = {}) {
const db = database.getDatabase();
let query = `
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.minimum_level * 1.5 THEN 'warning'
ELSE 'normal'
END as stock_status
FROM products p
LEFT JOIN inventory i ON p.id = i.product_id
`;
const params = [];
const conditions = [];
if (filters.category) {
conditions.push('p.category = ?');
params.push(filters.category);
}
if (filters.lowStock) {
conditions.push('i.current_level <= i.minimum_level');
}
if (conditions.length > 0) {
query += ' WHERE ' + conditions.join(' AND ');
}
query += ' ORDER BY p.product_code';
const stmt = db.prepare(query);
return stmt.all(...params);
}
/**
* Bulk update inventory levels with concurrent handling
* @param {Array} updates - Array of {productId, newLevel, changeReason, updatedBy} objects
* @returns {Promise<Inventory[]>} Array of updated inventory records
*/
static async bulkUpdateInventory(updates) {
const db = database.getDatabase();
const results = [];
return database.executeTransaction(() => {
for (const update of updates) {
const { productId, newLevel, changeReason = '', updatedBy = 'system' } = update;
// Get current inventory record
const currentInventory = db.prepare(`
SELECT * FROM inventory WHERE product_id = ?
`).get(productId);
if (!currentInventory) {
throw new Error(`Inventory record for product ID ${productId} not found`);
}
const oldLevel = currentInventory.current_level;
if (newLevel < 0) {
throw new Error(`Inventory level cannot be negative for product ${productId}`);
}
// Update inventory with optimistic locking
const updateStmt = db.prepare(`
UPDATE inventory
SET current_level = ?, last_updated = CURRENT_TIMESTAMP, updated_by = ?, version = version + 1
WHERE product_id = ? AND version = ?
`);
const result = updateStmt.run(newLevel, updatedBy, productId, currentInventory.version);
if (result.changes === 0) {
throw new Error(`Concurrent update detected for product ${productId}. Please refresh and try again.`);
}
// Create audit trail record
const historyStmt = db.prepare(`
INSERT INTO inventory_history (product_id, old_level, new_level, change_reason, updated_by)
VALUES (?, ?, ?, ?, ?)
`);
historyStmt.run(productId, oldLevel, newLevel, changeReason, updatedBy);
// Get updated record
const updatedInventory = db.prepare(`
SELECT * FROM inventory WHERE product_id = ?
`).get(productId);
results.push(new Inventory({
id: updatedInventory.id,
product_id: updatedInventory.product_id,
current_level: updatedInventory.current_level,
minimum_level: updatedInventory.minimum_level,
maximum_level: updatedInventory.maximum_level,
last_updated: updatedInventory.last_updated,
updated_by: updatedInventory.updated_by,
version: updatedInventory.version
}));
}
return results;
});
}
/**
* Get low stock items
* @returns {Promise<Object[]>} Array of products with low stock
*/
static async getLowStockItems() {
const db = database.getDatabase();
const stmt = db.prepare(`
SELECT
p.id, p.product_code, p.description, p.category,
i.current_level, i.minimum_level, i.last_updated
FROM products p
JOIN inventory i ON p.id = i.product_id
WHERE i.current_level <= i.minimum_level
ORDER BY (i.current_level - i.minimum_level) ASC
`);
return stmt.all();
}
/**
* Create inventory record for a new product
* @param {number} productId - Product ID
* @param {number} initialLevel - Initial inventory level
* @param {number} minimumLevel - Minimum stock level
* @param {number} maximumLevel - Maximum stock level (optional)
* @param {string} updatedBy - User creating the record
* @returns {Promise<Inventory>} Created inventory record
*/
static async createForProduct(productId, initialLevel = 0, minimumLevel = 0, maximumLevel = null, updatedBy = 'system') {
const inventory = new Inventory({
product_id: productId,
current_level: initialLevel,
minimum_level: minimumLevel,
maximum_level: maximumLevel,
updated_by: updatedBy
});
await inventory.save();
// Create initial history record if there's an initial level
if (initialLevel > 0) {
const db = database.getDatabase();
const historyStmt = db.prepare(`
INSERT INTO inventory_history (product_id, old_level, new_level, change_reason, updated_by)
VALUES (?, ?, ?, ?, ?)
`);
historyStmt.run(productId, 0, initialLevel, 'Initial inventory setup', updatedBy);
}
return inventory;
}
/**
* Convert inventory record to plain object
* @returns {Object} Plain object representation
*/
toJSON() {
return {
id: this.id,
product_id: this.product_id,
current_level: this.current_level,
minimum_level: this.minimum_level,
maximum_level: this.maximum_level,
last_updated: this.last_updated,
updated_by: this.updated_by,
version: this.version
};
}
}
module.exports = Inventory;

235
models/Product.js Normal file
View File

@ -0,0 +1,235 @@
const database = require('./database');
class Product {
constructor(data = {}) {
this.id = data.id || null;
this.name = data.name || '';
this.description = data.description || '';
this.category = data.category || '';
this.quantity = data.quantity || 0;
this.unit = data.unit || '';
this.barcode = data.barcode || null;
this.qr_code = data.qr_code || null;
this.location = data.location || '';
this.min_stock_level = data.min_stock_level || 0;
this.created_at = data.created_at || null;
this.updated_at = data.updated_at || null;
}
/**
* Validate product data
* @returns {Object} validation result with isValid boolean and errors array
*/
validate() {
const errors = [];
// Required field validation
if (!this.name || this.name.trim().length === 0) {
errors.push('Product name is required');
}
if (this.name && this.name.length > 255) {
errors.push('Product name must be less than 255 characters');
}
// Quantity validation
if (typeof this.quantity !== 'number' || this.quantity < 0) {
errors.push('Quantity must be a non-negative number');
}
// Min stock level validation
if (typeof this.min_stock_level !== 'number' || this.min_stock_level < 0) {
errors.push('Minimum stock level must be a non-negative number');
}
// Barcode validation (if provided)
if (this.barcode && (typeof this.barcode !== 'string' || this.barcode.trim().length === 0)) {
errors.push('Barcode must be a non-empty string if provided');
}
// Category validation
if (this.category && this.category.length > 100) {
errors.push('Category must be less than 100 characters');
}
// Unit validation
if (this.unit && this.unit.length > 20) {
errors.push('Unit must be less than 20 characters');
}
return {
isValid: errors.length === 0,
errors: errors
};
}
/**
* Save product to database
* @returns {Promise<Product>} saved product instance
*/
async save() {
const validation = this.validate();
if (!validation.isValid) {
throw new Error(`Validation failed: ${validation.errors.join(', ')}`);
}
const db = database.getDatabase();
try {
if (this.id) {
// Update existing product
const stmt = db.prepare(`
UPDATE items
SET name = ?, description = ?, category = ?, quantity = ?,
unit = ?, barcode = ?, qr_code = ?, location = ?,
min_stock_level = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`);
stmt.run(
this.name, this.description, this.category, this.quantity,
this.unit, this.barcode, this.qr_code, this.location,
this.min_stock_level, this.id
);
} else {
// Insert new product
const stmt = db.prepare(`
INSERT INTO items (name, description, category, quantity, unit, barcode, qr_code, location, min_stock_level)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
const result = stmt.run(
this.name, this.description, this.category, this.quantity,
this.unit, this.barcode, this.qr_code, this.location,
this.min_stock_level
);
this.id = result.lastInsertRowid;
}
return this;
} catch (error) {
if (error.code === 'SQLITE_CONSTRAINT_UNIQUE') {
throw new Error('A product with this barcode already exists');
}
throw error;
}
}
/**
* Find product by ID
* @param {number} id - Product ID
* @returns {Promise<Product|null>} Product instance or null if not found
*/
static async findById(id) {
const db = database.getDatabase();
const stmt = db.prepare('SELECT * FROM items WHERE id = ?');
const row = stmt.get(id);
return row ? new Product(row) : null;
}
/**
* Find product by barcode
* @param {string} barcode - Product barcode
* @returns {Promise<Product|null>} Product instance or null if not found
*/
static async findByBarcode(barcode) {
const db = database.getDatabase();
const stmt = db.prepare('SELECT * FROM items WHERE barcode = ?');
const row = stmt.get(barcode);
return row ? new Product(row) : null;
}
/**
* Get all products with optional filtering
* @param {Object} filters - Optional filters
* @returns {Promise<Product[]>} Array of Product instances
*/
static async findAll(filters = {}) {
const db = database.getDatabase();
let query = 'SELECT * FROM items';
const params = [];
const conditions = [];
if (filters.category) {
conditions.push('category = ?');
params.push(filters.category);
}
if (filters.name) {
conditions.push('name LIKE ?');
params.push(`%${filters.name}%`);
}
if (conditions.length > 0) {
query += ' WHERE ' + conditions.join(' AND ');
}
query += ' ORDER BY name';
const stmt = db.prepare(query);
const rows = stmt.all(...params);
return rows.map(row => new Product(row));
}
/**
* Delete product by ID
* @param {number} id - Product ID
* @returns {Promise<boolean>} true if deleted, false if not found
*/
static async deleteById(id) {
const db = database.getDatabase();
const stmt = db.prepare('DELETE FROM items WHERE id = ?');
const result = stmt.run(id);
return result.changes > 0;
}
/**
* Convert product to plain object
* @returns {Object} Plain object representation
*/
toJSON() {
return {
id: this.id,
name: this.name,
description: this.description,
category: this.category,
quantity: this.quantity,
unit: this.unit,
barcode: this.barcode,
qr_code: this.qr_code,
location: this.location,
min_stock_level: this.min_stock_level,
created_at: this.created_at,
updated_at: this.updated_at
};
}
/**
* Get all unique categories
* @returns {Promise<Array>} Array of unique category names
*/
static async getCategories() {
const db = database.getDatabase();
try {
const stmt = db.prepare(`
SELECT DISTINCT category
FROM products
WHERE category IS NOT NULL AND category != ''
ORDER BY category
`);
const rows = stmt.all();
return rows.map(row => row.category);
} catch (error) {
console.error('Error getting categories:', error);
throw error;
}
}
}
module.exports = Product;

696
models/database.js Normal file
View File

@ -0,0 +1,696 @@
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();