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>
This commit is contained in:
419
axhub-make/vite-plugins/lowdbService.ts
Normal file
419
axhub-make/vite-plugins/lowdbService.ts
Normal file
@@ -0,0 +1,419 @@
|
||||
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();
|
||||
Reference in New Issue
Block a user