UNPKG

localgoose

Version:

A lightweight, file-based ODM Database for Node.js, inspired by Mongoose

251 lines (232 loc) 8.8 kB
const fs = require('fs-extra'); const path = require('path'); const { ObjectId } = require('bson'); const { LRUCache } = require('lru-cache'); const writeFileAtomic = require('write-file-atomic'); // === File Operations === const fileLocks = new Map(); const fileCache = new LRUCache({ max: 500, // 1 hour TTL ttl: 1000 * 60 * 60, updateAgeOnGet: true }); const flushTimers = new Map(); const pendingFlushes = new Map(); const inFlightFlushes = new Map(); async function flushDisk() { while (pendingFlushes.size > 0 || inFlightFlushes.size > 0) { if (pendingFlushes.size > 0) { const promises = Array.from(pendingFlushes.entries()).map(([key, fn]) => { if (flushTimers.has(key)) { clearTimeout(flushTimers.get(key)); flushTimers.delete(key); } pendingFlushes.delete(key); return fn(); }); await Promise.all(promises); } if (inFlightFlushes.size > 0) { await Promise.all(Array.from(inFlightFlushes.values())); } } } function _deepClone(obj) { if (obj === null || typeof obj !== 'object') return obj; if (obj instanceof Date) return new Date(obj.getTime()); if (obj instanceof ObjectId) return new ObjectId(obj.toString()); if (Array.isArray(obj)) return obj.map(_deepClone); const cloned = {}; for (const k in obj) cloned[k] = _deepClone(obj[k]); return cloned; } function clearCache(dirPath) { const absPath = path.resolve(dirPath); for (const key of fileCache.keys()) { if (key.startsWith(absPath)) { fileCache.delete(key); if (flushTimers.has(key)) { clearTimeout(flushTimers.get(key)); flushTimers.delete(key); } pendingFlushes.delete(key); } } } async function withLock(filePath, fn) { const absPath = path.resolve(filePath); const currentLock = fileLocks.get(absPath) || Promise.resolve(); const resultPromise = currentLock.then(() => fn(), () => fn()); fileLocks.set(absPath, resultPromise.catch(() => {})); return resultPromise; } async function readJSON(filePath, options = {}) { const absPath = path.resolve(filePath); return withLock(absPath, async () => { const { defaultValue = [] } = options; if (fileCache.has(absPath)) { return _deepClone(fileCache.get(absPath)); } try { const data = await fs.readFile(absPath, 'utf8'); if (!data.trim()) { fileCache.set(absPath, _deepClone(defaultValue)); await _writeJSONImpl(absPath, defaultValue, options); return defaultValue; } const parsed = JSON.parse(data, (key, value) => { if (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z$/.test(value)) { const d = new Date(value); if (!isNaN(d.getTime())) return d; } return value; }); fileCache.set(absPath, parsed); return _deepClone(parsed); } catch (error) { if (error.code === 'ENOENT') { const dirPath = path.dirname(absPath); if (dirPath) await fs.mkdir(dirPath, { recursive: true }); fileCache.set(absPath, _deepClone(defaultValue)); await _writeJSONImpl(absPath, defaultValue, options); return defaultValue; } throw error; } }); } async function _writeJSONImpl(filePath, data, options = {}) { const { spaces = 2 } = options; const jsonString = JSON.stringify(data, (key, value) => { if (value instanceof Date) return value.toISOString(); if (value instanceof Map) return Object.fromEntries(value); if (typeof value === 'bigint') return value.toString(); return value; }, spaces); const dirPath = path.dirname(filePath); if (dirPath) await fs.mkdir(dirPath, { recursive: true }); await writeFileAtomic(filePath, jsonString, 'utf8'); } function _scheduleFlush(filePath, data, options) { if (flushTimers.has(filePath)) clearTimeout(flushTimers.get(filePath)); const flushFn = async () => { const writePromise = withLock(filePath, () => _writeJSONImpl(filePath, data, options)); inFlightFlushes.set(filePath, writePromise); try { await writePromise; } finally { if (inFlightFlushes.get(filePath) === writePromise) inFlightFlushes.delete(filePath); } }; pendingFlushes.set(filePath, flushFn); const timer = setTimeout(() => { flushTimers.delete(filePath); pendingFlushes.delete(filePath); flushFn().catch(console.error); }, 50); flushTimers.set(filePath, timer); } async function writeJSON(filePath, data, options = {}) { const absPath = path.resolve(filePath); fileCache.set(absPath, _deepClone(data)); _scheduleFlush(absPath, data, options); return Promise.resolve(); } // === Type Validation === function validateType(value, type, options = {}) { const { coerce = false, nullable = false } = options; if (value === undefined || value === null) return nullable; if (coerce) { if (type === String) return String(value); if (type === Number) return Number(value); if (type === Boolean) return Boolean(value); if (type === Date) return new Date(value); if (type === ObjectId) return new ObjectId(value); if (type === Buffer) return Buffer.from(value); if (type === BigInt) return BigInt(value); } if (type === String) return typeof value === 'string'; if (type === Number) return typeof value === 'number' && !isNaN(value); if (type === Boolean) return typeof value === 'boolean'; if (type === Date) return value instanceof Date || !isNaN(new Date(value).getTime()); if (type === Array) return Array.isArray(value); if (type === Object) return typeof value === 'object' && !Array.isArray(value) && value !== null; if (type === Buffer) return Buffer.isBuffer(value); if (type === ObjectId) return value instanceof ObjectId; if (type === BigInt) return typeof value === 'bigint'; if (type === Map) return value instanceof Map; if (typeof type === 'function') return value instanceof type; return true; } // === Dot-notation field access === function getNestedValue(obj, path) { if (!path || obj === null || obj === undefined) return undefined; const parts = path.split('.'); let current = obj; for (const part of parts) { if (current === null || current === undefined) return undefined; if (Array.isArray(current)) { // Map over array for array field access (e.g. 'tags.name') current = current.map(item => (item && typeof item === 'object') ? item[part] : undefined); } else { current = current[part]; } } return current; } function setNestedValue(obj, path, value) { const parts = path.split('.'); let current = obj; for (let i = 0; i < parts.length - 1; i++) { if (current[parts[i]] === undefined || current[parts[i]] === null) { current[parts[i]] = {}; } current = current[parts[i]]; } current[parts[parts.length - 1]] = value; } // === Output Formatting === function extractCoordinates(val) { if (!val) return null; if (Array.isArray(val) && val.length >= 2) return [parseFloat(val[0]), parseFloat(val[1])]; if (val.coordinates && Array.isArray(val.coordinates)) return [parseFloat(val.coordinates[0]), parseFloat(val.coordinates[1])]; if (val.type === 'Point' && Array.isArray(val.coordinates)) return [parseFloat(val.coordinates[0]), parseFloat(val.coordinates[1])]; if (val && val.lat !== undefined && (val.lng !== undefined || val.lon !== undefined)) return [parseFloat(val.lng || val.lon), parseFloat(val.lat)]; return null; } function formatOutput(obj, options = {}) { const { seen = new WeakSet(), maxDepth = 10, currentDepth = 0 } = options; if (currentDepth > maxDepth) return '[Max Depth Reached]'; if (obj === null || typeof obj !== 'object') return obj; if (seen.has(obj)) return '[Circular]'; const newSeen = new WeakSet(seen); newSeen.add(obj); if (Array.isArray(obj)) { return obj.map(item => formatOutput(item, { seen: newSeen, maxDepth, currentDepth: currentDepth + 1 })); } const formatted = {}; for (const [key, value] of Object.entries(obj)) { if (value && typeof value === 'object') { formatted[key] = value instanceof Date ? value.toISOString() : formatOutput(value, { seen: newSeen, maxDepth, currentDepth: currentDepth + 1 }); } else { formatted[key] = value; } } return formatted; } module.exports = { readJSON, writeJSON, validateType, formatOutput, getNestedValue, setNestedValue, flushDisk, clearCache, extractCoordinates, withLock };