import fs from 'fs/promises'; import { EventEmitter } from 'events'; import { v4 as uuidv4 } from 'uuid'; class GreenBean extends EventEmitter { constructor(dbPath = 'data.json', { autoSave = false } = {}) { super(); this.dbPath = dbPath; this.data = null; this.initialized = false; this.frozen = false; this.autoSave = autoSave; this._initPromise = null; this._transactionLock = false; this._relationCache = new WeakMap(); } async dispense(type, props = {}) { await this.init(); const bean = { id: uuidv4(), __type: type, __meta: { created: new Date().toISOString(), modified: new Date().toISOString() }, ...props }; return this.createProxy(bean); } async load(type, id) { await this.init(); const item = this.data[type]?.find(i => i.id === id); if (!item) return null; return this.createProxy({...item}); } async store(bean) { if (this.frozen) throw new Error('Frozen database'); await this.init(); const type = bean.__type; bean.__meta.modified = new Date().toISOString(); if (!this.data[type]) this.data[type] = []; const index = this.data[type].findIndex(i => i.id === bean.id); if (index >= 0) { this.data[type][index] = {...bean}; } else { this.data[type].push({...bean}); } await this.autoSave && this.save(); return bean; } async trash(bean) { if (this.frozen) throw new Error('Frozen database'); await this.init(); const type = bean.__type; if (!this.data[type]) return; this.data[type] = this.data[type].filter(i => i.id !== bean.id); await this.handleCascadeDelete(bean); await this.autoSave && this.save(); } async find(type, criteria = {}) { await this.init(); if (!this.data[type]) return []; return this.data[type] .filter(item => this.matchCriteria(item, criteria)) .map(item => this.createProxy({...item})); } async findOne(type, criteria) { const results = await this.find(type, criteria); return results[0] || null; } async transaction(callback) { if (this._transactionLock) throw new Error('Transaction in progress'); this._transactionLock = true; const snapshot = JSON.stringify(this.data); try { const result = await callback(); await this.save(); return result; } catch (error) { this.data = JSON.parse(snapshot); throw error; } finally { this._transactionLock = false; } } createProxy(bean) { const handler = { get: (target, prop) => { if (prop === 'then') return; // Для async/await if (prop in target) return target[prop]; return new Proxy({}, { get: async (_, propName) => { const relations = await this.resolveRelations(target); return relations[prop]; }, apply: async () => { return this.handleRelationAccess(target, prop); } }); }, set: async (target, prop, value) => { if (prop === '__type' || prop === '__meta') { throw new Error(`Cannot modify ${prop}`); } const relationType = await this.detectRelationship(target.__type, prop); if (relationType) { await this.handleRelationUpdate(target, prop, value, relationType); return true; } if (prop.endsWith('List') && Array.isArray(value)) { target[prop] = value.map(i => ({ id: i.id, __type: i.__type || this.guessType(i) })); } else { target[prop] = value; } target.__meta.modified = new Date().toISOString(); await this.autoSave && this.store(target); return true; } }; return new Proxy(bean, handler); } // ========== Relationship Handling ========== async resolveRelations(target) { if (!this._relationCache.has(target)) { this._relationCache.set(target, {}); } const cache = this._relationCache.get(target); return new Proxy({}, { get: (_, prop) => { if (!cache[prop]) { cache[prop] = this.handleRelationAccess(target, prop); } return cache[prop]; } }); } async handleRelationAccess(target, prop) { const relation = await this.detectRelationship(target.__type, prop); switch (relation.type) { case 'manyToMany': return this.getShared(target, prop); case 'oneToMany': return this.getOwn(target, prop); case 'oneToOne': return this.getXOwn(target, prop); default: return undefined; } } async handleRelationUpdate(target, prop, value, relation) { switch (relation.type) { case 'manyToMany': await this.shared(target, prop, value); break; case 'oneToMany': await this.own(target, prop, value); break; case 'oneToOne': await this.xownOne(target, prop, value); break; } this._relationCache.delete(target); } async detectRelationship(parentType, property) { // Many-to-Many detection const linkTable = `${parentType}_${property}`; if (this.data[linkTable]) { return { type: 'manyToMany', linkTable }; } // One-to-Many detection const childType = property; if (this.data[childType]?.some(i => i[`${parentType}_id`])) { return { type: 'oneToMany', childType }; } // One-to-One detection if (this.data[property]?.some(i => i[`${parentType}_id`] && this.isFieldUnique(property, `${parentType}_id`) )) { return { type: 'oneToOne', childType: property }; } return { type: 'none' }; } async getShared(target, prop) { const linkTable = `${target.__type}_${prop}`; const links = await this.find(linkTable, { [`${target.__type}_id`]: target.id }); return Promise.all( links.map(link => this.load(link[`${prop}_type`], link[`${prop}_id`]) ) ); } async getOwn(target, prop) { return this.find(prop, { [`${target.__type}_id`]: target.id }); } async getXOwn(target, prop) { return this.findOne(prop, { [`${target.__type}_id`]: target.id }); } async shared(target, prop, values) { const linkTable = `${target.__type}_${prop}`; await this.trashRelations(linkTable, target.id); for (const value of [values].flat()) { const link = await this.dispense(linkTable, { [`${target.__type}_id`]: target.id, [`${prop}_id`]: value.id, [`${prop}_type`]: value.__type }); await this.store(link); } } async own(target, prop, values) { const children = [values].flat(); for (const child of children) { child[`${target.__type}_id`] = target.id; await this.store(child); } } async xownOne(target, prop, value) { const current = await this.getXOwn(target, prop); if (current) { delete current[`${target.__type}_id`]; await this.store(current); } if (value) { value[`${target.__type}_id`] = target.id; await this.store(value); } } // ========== Helpers ========== async init() { if (this._initPromise) return this._initPromise; this._initPromise = (async () => { try { const exists = await fs.access(this.dbPath).then(() => true).catch(() => false); this.data = exists ? JSON.parse(await fs.readFile(this.dbPath, 'utf-8')) : {}; this.initialized = true; } catch (error) { throw new Error(`Init failed: ${error.message}`); } })(); return this._initPromise; } async save() { await fs.writeFile(this.dbPath, JSON.stringify(this.data, null, 2)); this.emit('save'); } async handleCascadeDelete(bean) { const relations = await this.detectAllRelationships(bean.__type); for (const rel of relations) { switch (rel.type) { case 'oneToMany': const children = await this.find(rel.childType, { [`${bean.__type}_id`]: bean.id }); for (const child of children) { await this.trash(child); } break; case 'oneToOne': const linked = await this.findOne(rel.childType, { [`${bean.__type}_id`]: bean.id }); if (linked) await this.trash(linked); break; case 'manyToMany': await this.trashRelations(rel.linkTable, bean.id); break; } } } async trashRelations(linkTable, sourceId) { if (!this.data[linkTable]) return; this.data[linkTable] = this.data[linkTable].filter( i => i[`${linkTable.split('_')[0]}_id`] !== sourceId ); } matchCriteria(item, criteria) { return Object.entries(criteria).every(([key, condition]) => { if (typeof condition === 'object' && condition !== null) { return Object.entries(condition).every(([op, value]) => { switch(op) { case 'gt': return item[key] > value; case 'lt': return item[key] < value; case 'like': return new RegExp(value).test(item[key]); default: return item[key] === condition; } }); } return item[key] === condition; }); } isFieldUnique(type, field) { const values = this.data[type]?.map(i => i[field]); return new Set(values).size === values?.length; } guessType(obj) { return obj.__type || Object.getPrototypeOf(obj)?.constructor?.name.toLowerCase(); } freeze(frozen = true) { this.frozen = frozen; } async nuke() { if (this.frozen) throw new Error('Cannot nuke frozen DB'); this.data = {}; await this.save(); } } export default GreenBean;