import { Low } from 'lowdb'; import { JSONFile } from 'lowdb/node'; import { v4 as uuidv4 } from 'uuid'; import * as fs from 'fs/promises'; import * as path from 'path'; import { validateIdField, validateDataTypes, checkDuplicateId, validateRecordForInsert, validateRecordForUpdate, validateCSVData } from './validation'; /** * Data record interface - all records must have an id field * ID can be either string or number to support both UUID and integer IDs */ export interface DataRecord { id: string | number; [key: string]: any; } /** * Table metadata structure stored in JSON files */ export interface TableMetadata { tableName: string; records: DataRecord[]; } /** * LowDB Service for managing data tables */ export class LowDBService { private dbPath: string; private dbCache: Map> = new Map(); private locks: Map> = new Map(); constructor(dbPath: string = 'src/database') { this.dbPath = dbPath; } /** * Acquire a lock for a specific file to ensure atomic operations * This prevents concurrent write conflicts by serializing operations on the same file */ private async acquireLock(fileName: string): Promise<() => void> { // Wait for any existing lock to be released while (this.locks.has(fileName)) { await this.locks.get(fileName); } // Create a new lock let releaseLock: () => void; const lockPromise = new Promise((resolve) => { releaseLock = resolve; }); this.locks.set(fileName, lockPromise); // Return the release function return () => { this.locks.delete(fileName); releaseLock!(); }; } /** * Execute an operation with file locking to ensure concurrent safety */ private async withLock(fileName: string, operation: () => Promise): Promise { const release = await this.acquireLock(fileName); try { return await operation(); } finally { release(); } } /** * Initialize the database directory */ async initialize(): Promise { try { await fs.access(this.dbPath); } catch { // Directory doesn't exist, create it await fs.mkdir(this.dbPath, { recursive: true }); } } /** * Get database instance for a table */ private async getDB(fileName: string): Promise> { if (this.dbCache.has(fileName)) { const db = this.dbCache.get(fileName)!; await db.read(); return db; } const filePath = path.join(this.dbPath, `${fileName}.json`); const adapter = new JSONFile(filePath); const defaultData: TableMetadata = { tableName: fileName, records: [] }; const db = new Low(adapter, defaultData); await db.read(); this.dbCache.set(fileName, db); return db; } /** * Get all table names (file names without .json extension) */ async getAllTables(): Promise> { await this.initialize(); const files = await fs.readdir(this.dbPath); const jsonFiles = files.filter(file => file.endsWith('.json')); const tables = await Promise.all( jsonFiles.map(async (file) => { const fileName = file.replace('.json', ''); const db = await this.getDB(fileName); return { fileName, tableName: db.data.tableName }; }) ); return tables; } /** * Get all records from a table */ async getTable(fileName: string): Promise { const db = await this.getDB(fileName); return db.data.records; } /** * Get a single record by id */ async getRecord(fileName: string, id: string | number): Promise { const db = await this.getDB(fileName); // Support both string and number comparison return db.data.records.find(record => record.id == id); } /** * Create a new table with a sample record */ async createTable(fileName: string, tableName?: string): Promise { return this.withLock(fileName, async () => { const db = await this.getDB(fileName); // Create a sample record to demonstrate the format const sampleRecord: DataRecord = { id: uuidv4(), name: '示例数据', description: '这是一条示例数据,用于演示数据格式', createdAt: new Date().toISOString() }; db.data = { tableName: tableName || fileName, records: [sampleRecord] }; await db.write(); }); } /** * Update table name */ async updateTableName(fileName: string, newTableName: string): Promise { return this.withLock(fileName, async () => { const db = await this.getDB(fileName); db.data.tableName = newTableName; await db.write(); }); } /** * Delete a table */ async deleteTable(fileName: string): Promise { return this.withLock(fileName, async () => { const filePath = path.join(this.dbPath, `${fileName}.json`); await fs.unlink(filePath); this.dbCache.delete(fileName); }); } /** * Insert a new record */ async insertData(fileName: string, data: Omit): Promise { return this.withLock(fileName, async () => { const db = await this.getDB(fileName); // Validate data before insertion validateRecordForInsert(data); // Generate ID if not provided, or validate if provided let recordId: string; if ('id' in data && data.id) { // ID provided - validate and check for duplicates validateIdField(data.id); checkDuplicateId(data.id, db.data.records); recordId = data.id; } else { // No ID - generate new UUID recordId = uuidv4(); } const newRecord: DataRecord = { ...data, id: recordId }; db.data.records.push(newRecord); await db.write(); return newRecord; }); } /** * Update an existing record */ async updateData(fileName: string, id: string | number, data: Partial): Promise { return this.withLock(fileName, async () => { const db = await this.getDB(fileName); // Validate the update data validateRecordForUpdate(data); // If trying to change ID, validate and check for duplicates if (data.id && data.id != id) { validateIdField(data.id); checkDuplicateId(data.id, db.data.records, id); } const index = db.data.records.findIndex(record => record.id == id); if (index === -1) { throw new Error(`Record with id ${id} not found`); } // Merge the update data with existing record db.data.records[index] = { ...db.data.records[index], ...data, id // Ensure id cannot be changed (keep original) }; await db.write(); return db.data.records[index]; }); } /** * Delete a record */ async deleteData(fileName: string, id: string | number): Promise { return this.withLock(fileName, async () => { const db = await this.getDB(fileName); const index = db.data.records.findIndex(record => record.id == id); if (index === -1) { throw new Error(`Record with id ${id} not found`); } db.data.records.splice(index, 1); await db.write(); }); } /** * Batch insert records */ async batchInsert(fileName: string, dataList: Array>): Promise { return this.withLock(fileName, async () => { const db = await this.getDB(fileName); // Validate all records first dataList.forEach((data, index) => { try { validateRecordForInsert(data); } catch (e: any) { throw new Error(`Validation failed for record ${index}: ${e.message}`); } }); const newRecords: DataRecord[] = dataList.map((data, index) => { let recordId: string; if ('id' in data && data.id) { // ID provided - validate and check for duplicates validateIdField(data.id); // Check against existing records and previously processed records const allRecords = [...db.data.records, ...newRecords.slice(0, index)]; checkDuplicateId(data.id, allRecords); recordId = data.id; } else { // No ID - generate new UUID recordId = uuidv4(); } return { ...data, id: recordId }; }); db.data.records.push(...newRecords); await db.write(); return newRecords; }); } /** * Replace all table data (used for CSV import) */ async replaceTableData(fileName: string, records: DataRecord[]): Promise { return this.withLock(fileName, async () => { const db = await this.getDB(fileName); db.data.records = records; await db.write(); }); } /** * Import CSV data with upsert logic * - Records with id: update if exists, create if not * - Records without id: generate new UUID and create * - Replace mode: completely replace all table data with imported data */ async importCSV(fileName: string, csvData: any[]): Promise { return this.withLock(fileName, async () => { const db = await this.getDB(fileName); // Validate CSV data validateCSVData(csvData); // Track IDs to detect duplicates within the CSV const seenIds = new Set(); // Process each row from CSV const records: DataRecord[] = csvData.map((row, index) => { // Check if row has a valid ID (string or number) const hasValidId = row.id !== undefined && row.id !== null && row.id !== '' && (typeof row.id === 'string' || typeof row.id === 'number'); if (hasValidId) { // Has ID - validate it validateIdField(row.id); // Convert ID to string for comparison to avoid type issues const idStr = String(row.id); // Check for duplicate within CSV if (seenIds.has(idStr)) { throw new Error(`Duplicate id '${row.id}' found in CSV at row ${index + 1}`); } seenIds.add(idStr); return { ...row, id: row.id // Keep original type (string or number) }; } else { // No ID or empty ID - generate new UUID const { id, ...rest } = row; // Remove empty id field if exists const newId = uuidv4(); seenIds.add(newId); return { id: newId, ...rest }; } }); // Replace entire table with imported data (覆盖模式) db.data.records = records; await db.write(); return records; }); } /** * Check if a table exists */ async tableExists(fileName: string): Promise { const filePath = path.join(this.dbPath, `${fileName}.json`); try { await fs.access(filePath); return true; } catch { return false; } } } // Export a singleton instance export const lowdbService = new LowDBService();