UNPKG

@funztic/db

Version:

TypeScript기반 파일 데이터베이스, zod를 지원합니다.

284 lines (283 loc) 9.03 kB
import fs from 'fs/promises'; import path from 'path'; import { EventEmitter } from 'events'; import { Worker } from 'worker_threads'; import { workerPath } from '../worker/db-saver.js'; export class TypedEventEmitter { constructor() { this.emitter = new EventEmitter(); } on(event, listener) { this.emitter.on(event, listener); return this; } once(event, listener) { this.emitter.once(event, listener); return this; } emit(event, ...args) { return this.emitter.emit(event, ...args); } removeAll(event) { if (event !== undefined) { this.emitter.removeAllListeners(event); } else { this.emitter.removeAllListeners(); } return this; } eventNames() { return this.emitter.eventNames(); } } export class Collection extends TypedEventEmitter { constructor(collectionName, options) { super(); this.data = {}; this.saveTimeout = null; this.worker = null; this.isSaving = false; this.pendingChanges = false; this.isInitialized = false; const { dir, autoSave = true, saveDelay = 500 } = options; this.filePath = path.join(dir, `${collectionName}.json`); this.autoSave = autoSave; this.saveDelay = saveDelay; this.initWorker(); this.initPromise = this.init(); } initWorker() { try { if (!this.worker) { this.worker = new Worker(workerPath); this.worker.on('message', (response) => { this.isSaving = false; if (response.success) { this.emit('saved'); if (this.pendingChanges) { this.pendingChanges = false; this.triggerSave(); } } else { this.emit('error', new Error(response.error || 'Save failed (Worker)')); } }); this.worker.on('error', (err) => { this.isSaving = false; this.emit('error', err); this.worker = null; if (this.pendingChanges) { this.pendingChanges = false; this.save(true); } }); this.worker.on('exit', (code) => { if (code !== 0) { this.emit('error', new Error(`Worker stopped unexpectedly with code ${code}`)); this.worker = null; } if (this.pendingChanges) { this.pendingChanges = false; this.save(true); } this.isSaving = false; }); } } catch (err) { this.emit('error', err instanceof Error ? err : new Error(String(err))); this.worker = null; } } async init() { try { const dir = path.dirname(this.filePath); await fs.mkdir(dir, { recursive: true }); let rawData = {}; let loadedCount = 0; try { const fileContent = await fs.readFile(this.filePath, 'utf-8'); rawData = JSON.parse(fileContent); loadedCount = Object.keys(rawData).length; } catch (err) { if (err.code === 'ENOENT') { this.data = {}; await this.save(true); this.emit('loaded', 0); this.isInitialized = true; return; } else { this.emit('error', err instanceof Error ? err : new Error(String(err))); throw err; } } this.data = rawData; this.emit('loaded', loadedCount); this.isInitialized = true; } catch (err) { this.isInitialized = false; const error = err instanceof Error ? err : new Error(String(err)); this.emit('error', error); throw error; } } async ensureInitialized() { if (!this.initPromise) { throw new Error("Collection not initialized."); } await this.initPromise; if (!this.isInitialized) { throw new Error("Collection initialization failed."); } } async performSave() { const dataToSave = JSON.stringify(this.data, null, 2); if (this.worker) { if (this.isSaving) { this.pendingChanges = true; return; } this.isSaving = true; this.pendingChanges = false; const message = { action: 'save', filePath: this.filePath, data: dataToSave }; try { this.worker.postMessage(message); } catch (err) { this.isSaving = false; this.emit('error', new Error(`Worker postMessage failed: ${err instanceof Error ? err.message : String(err)}`)); await this.saveToDisk(dataToSave); } } else { await this.saveToDisk(dataToSave); } } async saveToDisk(dataString) { try { await fs.writeFile(this.filePath, dataString, 'utf-8'); this.emit('saved'); } catch (err) { this.emit('error', err instanceof Error ? err : new Error(String(err))); } } async save(force = false) { if (!force) { await this.ensureInitialized(); } await this.performSave(); } triggerSave() { if (!this.autoSave) return; if (this.saveTimeout) clearTimeout(this.saveTimeout); this.saveTimeout = setTimeout(() => { this.save(); this.saveTimeout = null; }, this.saveDelay); } async create(itemData) { await this.ensureInitialized(); if (!itemData.id) throw new Error('ID는 필수 항목입니다.'); const id = itemData.id; this.data[id] = itemData; this.triggerSave(); this.emit('created', this.data[id]); return this.data[id]; } async get(id) { await this.ensureInitialized(); return this.data[id]; } async getAll() { await this.ensureInitialized(); return Object.values(this.data); } async find(filterFn) { await this.ensureInitialized(); return Object.values(this.data).filter(filterFn); } async update(id, updates) { await this.ensureInitialized(); const item = this.data[id]; if (!item) return undefined; const updatedItem = { ...item, ...updates }; this.data[id] = updatedItem; this.triggerSave(); this.emit('updated', this.data[id]); return this.data[id]; } async delete(id) { await this.ensureInitialized(); if (!this.data[id]) return false; const deletedItem = this.data[id]; delete this.data[id]; this.triggerSave(); this.emit('deleted', deletedItem); return true; } async clear() { await this.ensureInitialized(); this.data = {}; this.triggerSave(); this.emit('cleared'); } async query(queryFn) { await this.ensureInitialized(); return queryFn(this.data); } async close() { if (this.saveTimeout) { clearTimeout(this.saveTimeout); this.saveTimeout = null; } if (this.worker) { const checkSave = () => { if (!this.isSaving && !this.pendingChanges) { this.worker?.terminate(); this.worker = null; } else { setTimeout(checkSave, 100); } }; checkSave(); } } } export class Database { constructor(dbDir, options = {}) { this.collections = {}; this.dbDir = dbDir; this.options = options; } collection(name, schema) { if (!this.collections[name]) { this.collections[name] = new Collection(name, { dir: this.dbDir, ...this.options }); } return this.collections[name]; } async syncAll() { await Promise.all(Object.values(this.collections).map(collection => collection.save(true))); } async close() { await Promise.all(Object.values(this.collections).map(collection => collection.close())); } }