Files
ONE-OS/axhub-make/vite-plugins/lowdbService.ts
王冕 a27e3b8e43 feat: sync full workspace including web modules, docs, and configurations to Gitea
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>
2026-06-09 18:12:25 +08:00

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();