@kyvrixon/json-db
Version:
A simple, feature rich JSON database solution. Designed for Bun
302 lines (301 loc) • 10 kB
JavaScript
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);
}
}