@funztic/db
Version:
TypeScript기반 파일 데이터베이스, zod를 지원합니다.
284 lines (283 loc) • 9.03 kB
JavaScript
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()));
}
}