UNPKG

@kyvrixon/json-db

Version:

A simple, feature rich JSON database solution. Designed for Bun

302 lines (301 loc) 10 kB
import { z } from 'zod'; import * as fs from 'fs/promises'; import * as path from 'path'; export default class Database { basePath; options; lockedFiles = new Set(); constructor(basePath, options = {}) { this.basePath = path.resolve(basePath); this.options = { createDirectory: true, validateOnRead: false, ...options }; } /** * Ensures a directory exists, creating it if necessary */ async ensureDirectoryExists(dirPath) { if (!this.options.createDirectory) return; try { await fs.access(dirPath); } catch { await fs.mkdir(dirPath, { recursive: true }); } } /** * Acquires a memory-only lock for file operations */ async acquireLock(file) { let attempts = 0; const maxAttempts = 100; const lockDelay = 10; while (attempts < maxAttempts) { if (!this.lockedFiles.has(file)) { this.lockedFiles.add(file); return; } attempts++; await new Promise(resolve => setTimeout(resolve, lockDelay)); } throw new Error('Could not acquire memory lock for file: ' + file); } /** * Releases a memory-only lock for file operations */ releaseLock(file) { this.lockedFiles.delete(file); } /** * Writes data to a JSON file atomically with memory lock */ async writeJSONFile(filePath, data) { await this.acquireLock(filePath); try { const tempPath = `${filePath}.tmp`; await fs.writeFile(tempPath, JSON.stringify(data, null, 2), 'utf-8'); await fs.rename(tempPath, filePath); } finally { this.releaseLock(filePath); } } /** * Reads and parses a JSON file safely */ async readJSONFile(filePath, schema) { try { await fs.access(filePath); } catch { return null; } const content = await fs.readFile(filePath, 'utf-8'); const data = JSON.parse(content); if (schema && this.options.validateOnRead) { return schema.parse(data); } return data; } /** * Validates data against a Zod schema */ validate(data, schema) { return schema.parse(data); } /** * Writes a document to a path-based location * Example: await db.write("users/123", myData) creates this.basePath/users/123.json */ async write(filePath, data, schema) { // Validate data if schema is provided if (schema) { schema.parse(data); } // Parse the path to separate directory and filename const fullPath = path.join(this.basePath, filePath); const dirPath = path.dirname(fullPath); const fileName = path.basename(fullPath); // Ensure directory exists await this.ensureDirectoryExists(dirPath); // Create the final file path with .json extension const jsonFilePath = path.join(dirPath, `${fileName}.json`); // Write the file with lock protection await this.writeJSONFile(jsonFilePath, data); } /** * Reads a document from a path-based location */ async read(filePath, schema) { const fullPath = path.join(this.basePath, filePath); const dirPath = path.dirname(fullPath); const fileName = path.basename(fullPath); const jsonFilePath = path.join(dirPath, `${fileName}.json`); return await this.readJSONFile(jsonFilePath, schema); } /** * Reads all documents from a directory */ async readAll(dirPath, schema) { const fullDirPath = path.join(this.basePath, dirPath); try { await fs.access(fullDirPath); } catch { return new Map(); } const files = (await fs.readdir(fullDirPath)).filter(f => f.endsWith('.json')); const result = new Map(); for (const file of files) { const id = path.basename(file, '.json'); const data = await this.readJSONFile(path.join(fullDirPath, file), schema); if (data !== null) { result.set(id, data); } } return result; } /** * Finds documents matching a filter in a directory */ async find(dirPath, filter = {}, schema) { const allData = await this.readAll(dirPath, schema); if (!filter || Object.keys(filter).length === 0) { return allData; } const result = new Map(); for (const [id, document] of allData.entries()) { if (this.matchesDocument(document, filter)) { result.set(id, document); } } return result; } /** * Finds the first document matching a filter */ async findOne(dirPath, filter = {}, schema) { const allData = await this.readAll(dirPath, schema); for (const [id, document] of allData.entries()) { if (this.matchesDocument(document, filter)) { return { id, data: document }; } } return null; } /** * Checks if a value matches a filter condition */ matchesFilter(value, filter) { // Check each operator - ALL must pass for the filter to match if (filter.$equals !== undefined && value !== filter.$equals) return false; if (filter.$notEquals !== undefined && value === filter.$notEquals) return false; if (filter.$greaterThan !== undefined && filter.$greaterThan !== null && !(value > filter.$greaterThan)) return false; if (filter.$greaterThanOrEqual !== undefined && filter.$greaterThanOrEqual !== null && !(value >= filter.$greaterThanOrEqual)) return false; if (filter.$lessThan !== undefined && filter.$lessThan !== null && !(value < filter.$lessThan)) return false; if (filter.$lessThanOrEqual !== undefined && filter.$lessThanOrEqual !== null && !(value <= filter.$lessThanOrEqual)) return false; if (filter.$in !== undefined) { if (Array.isArray(value)) { if (!value.some(item => filter.$in.includes(item))) return false; } else { if (!filter.$in.includes(value)) return false; } } if (filter.$notIn !== undefined) { if (Array.isArray(value)) { if (value.some(item => filter.$notIn.includes(item))) return false; } else { if (filter.$notIn.includes(value)) return false; } } if (filter.$exists !== undefined && (value !== undefined && value !== null) !== filter.$exists) return false; if (filter.$regex !== undefined && typeof value === 'string' && !filter.$regex.test(value)) return false; if (filter.$arraySize !== undefined) { if (!Array.isArray(value)) return false; // Must be an array to match arraySize if (value.length !== filter.$arraySize) return false; } // If we reach here, all specified operators passed return true; } /** * Checks if a document matches a database filter */ matchesDocument(document, filter) { // Handle logical operators if (filter.$and) { return filter.$and.every((f) => this.matchesDocument(document, f)); } if (filter.$or) { return filter.$or.some((f) => this.matchesDocument(document, f)); } if (filter.$not) { return !this.matchesDocument(document, filter.$not); } // Handle field filters for (const [key, value] of Object.entries(filter)) { if (key.startsWith('$')) continue; // Skip logical operators const documentValue = document[key]; if (typeof value === 'object' && value !== null && !Array.isArray(value)) { if (!this.matchesFilter(documentValue, value)) { return false; } } else { if (documentValue !== value) { return false; } } } return true; } /** * Deletes a document from a path-based location */ async delete(filePath) { const fullPath = path.join(this.basePath, filePath); const dirPath = path.dirname(fullPath); const fileName = path.basename(fullPath); const jsonFilePath = path.join(dirPath, `${fileName}.json`); try { await fs.access(jsonFilePath); } catch { return false; } await this.acquireLock(jsonFilePath); try { await fs.unlink(jsonFilePath); return true; } finally { this.releaseLock(jsonFilePath); } } /** * Deletes a whole directory and all its contents */ async drop(dirPath) { const fullDirPath = path.join(this.basePath, dirPath); try { await fs.access(fullDirPath); } catch { return false; } await this.acquireLock(fullDirPath); try { await fs.rm(fullDirPath, { recursive: true, force: true }); return true; } finally { this.releaseLock(fullDirPath); } } /** * Creates an empty directory (and parents if needed) */ async create(dirPath) { const fullDirPath = path.join(this.basePath, dirPath); await this.ensureDirectoryExists(fullDirPath); } }