Optimized the root .gitignore to exclude virtual environments, node modules, and temp folders to ensure clean and lightweight version tracking. Co-authored-by: Cursor <cursoragent@cursor.com>
420 lines
11 KiB
TypeScript
420 lines
11 KiB
TypeScript
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<string, Low<TableMetadata>> = new Map();
|
|
private locks: Map<string, Promise<void>> = 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<void>((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<T>(fileName: string, operation: () => Promise<T>): Promise<T> {
|
|
const release = await this.acquireLock(fileName);
|
|
try {
|
|
return await operation();
|
|
} finally {
|
|
release();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Initialize the database directory
|
|
*/
|
|
async initialize(): Promise<void> {
|
|
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<Low<TableMetadata>> {
|
|
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<TableMetadata>(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<Array<{ fileName: string; tableName: string }>> {
|
|
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<DataRecord[]> {
|
|
const db = await this.getDB(fileName);
|
|
return db.data.records;
|
|
}
|
|
|
|
/**
|
|
* Get a single record by id
|
|
*/
|
|
async getRecord(fileName: string, id: string | number): Promise<DataRecord | undefined> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<DataRecord, 'id'>): Promise<DataRecord> {
|
|
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<DataRecord>): Promise<DataRecord> {
|
|
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<void> {
|
|
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<Omit<DataRecord, 'id'>>): Promise<DataRecord[]> {
|
|
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<void> {
|
|
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<DataRecord[]> {
|
|
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<string>();
|
|
|
|
// 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<boolean> {
|
|
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();
|