UNPKG

nope.db

Version:

A modern, simple, and async JSON database for Node.js with zero dependencies and a data-safe queue.

238 lines (237 loc) 8.36 kB
import fs from "fs/promises"; import { DatabaseError } from "./error.js"; export class StorageManager { file; spaces; separator; queue; errors = { dataNotANumber: "Existing data for this ID is not of type 'number'.", mustBeANumber: "The provided value must be of type 'number'.", mustBeArray: "The existing data must be of type 'array'.", nonValidID: "Invalid ID. It cannot be empty, start/end with a separator, or contain repeated separators.", undefinedID: "ID is undefined.", undefinedValue: "Value is undefined.", parseError: "Failed to parse database file. Check for corrupt JSON.", clearConfirm: "Accidental clear prevented. Must pass { confirm: true } to clear()." }; constructor(settings) { this.file = settings.file; this.spaces = settings.spaces; this.separator = settings.separator; this.queue = Promise.resolve(); this._enqueue(async () => { try { await fs.access(this.file); } catch (error) { if (error?.code === 'ENOENT') { await this._write({}); return; } throw new DatabaseError(`Unable to access database file: ${error?.message ?? error}`); } }); } _enqueue(task) { const nextTask = this.queue.catch(() => { }).then(task); this.queue = nextTask; return nextTask; } async _read() { try { const data = await fs.readFile(this.file, "utf-8"); return JSON.parse(data); } catch (error) { if (error.code === 'ENOENT') { await this._write({}); return {}; } if (error instanceof SyntaxError) { throw new DatabaseError(this.errors.parseError); } throw new DatabaseError(`Unable to read database file: ${error?.message ?? error}`); } } async _write(data) { await fs.writeFile(this.file, JSON.stringify(data, null, this.spaces)); } _validateID(id) { if (typeof id !== 'string' || !id || id.startsWith(this.separator) || id.endsWith(this.separator) || id.includes(this.separator + this.separator)) { throw new DatabaseError(this.errors.nonValidID); } } _find(data, id) { this._validateID(id); const parts = id.split(this.separator); let current = data; for (const part of parts) { if (typeof current !== 'object' || current === null) { return null; } current = current[part]; } return current ?? null; } _findAndSet(data, id, value) { this._validateID(id); const parts = id.split(this.separator); let current = data; for (let i = 0; i < parts.length; i++) { const part = parts[i]; if (i === parts.length - 1) { current[part] = value; } else { if (typeof current[part] !== 'object' || current[part] === null) { current[part] = {}; } current = current[part]; } } return data; } set(id, value) { return this._enqueue(async () => { if (!id) throw new DatabaseError(this.errors.undefinedID); if (value === undefined) throw new DatabaseError(this.errors.undefinedValue); let data = await this._read(); this._findAndSet(data, id, value); await this._write(data); return value; }); } get(id) { return this._enqueue(async () => { if (!id) throw new DatabaseError(this.errors.undefinedID); const data = await this._read(); return this._find(data, id); }); } add(id, value) { return this._enqueue(async () => { if (!id) throw new DatabaseError(this.errors.undefinedID); if (value === undefined) throw new DatabaseError(this.errors.undefinedValue); if (typeof value !== "number") throw new DatabaseError(this.errors.mustBeANumber); let data = await this._read(); const currentVal = this._find(data, id); if (currentVal !== null && typeof currentVal !== "number") { throw new DatabaseError(this.errors.dataNotANumber); } const newVal = (currentVal || 0) + value; this._findAndSet(data, id, newVal); await this._write(data); return newVal; }); } subtract(id, value) { if (typeof value !== "number") throw new DatabaseError(this.errors.mustBeANumber); return this.add(id, -value); } all() { return this._enqueue(() => this._read()); } has(id) { return this._enqueue(async () => { if (!id) throw new DatabaseError(this.errors.undefinedID); const data = await this._read(); return this._find(data, id) !== null; }); } delete(id) { return this._enqueue(async () => { if (!id) throw new DatabaseError(this.errors.undefinedID); let data = await this._read(); const parts = id.split(this.separator); let current = data; for (let i = 0; i < parts.length; i++) { const part = parts[i]; if (typeof current !== 'object' || current === null) { return false; } if (i === parts.length - 1) { if (current[part] === undefined) return false; delete current[part]; } else { current = current[part]; } } await this._write(data); return true; }); } clear(options) { return this._enqueue(async () => { if (options?.confirm !== true) { throw new DatabaseError(this.errors.clearConfirm); } await this._write({}); return true; }); } push(id, value) { return this._enqueue(async () => { if (!id) throw new DatabaseError(this.errors.undefinedID); if (value === undefined) throw new DatabaseError(this.errors.undefinedValue); let data = await this._read(); let arr = this._find(data, id); if (arr === null || arr === undefined) { arr = []; } else if (!Array.isArray(arr)) { throw new DatabaseError(this.errors.mustBeArray); } arr.push(value); this._findAndSet(data, id, arr); await this._write(data); return arr; }); } backup(filePath) { return this._enqueue(async () => { if (!filePath || typeof filePath !== 'string') { throw new DatabaseError("Invalid backup file path provided."); } if (!filePath.endsWith(".json")) { throw new DatabaseError("The backup file path must end with '.json'."); } const data = await this._read(); await fs.writeFile(filePath, JSON.stringify(data, null, this.spaces)); return true; }); } loadBackup(filePath) { return this._enqueue(async () => { if (!filePath || typeof filePath !== 'string') { throw new DatabaseError("Invalid backup file path provided."); } let backupData; try { const data = await fs.readFile(filePath, "utf-8"); backupData = JSON.parse(data); } catch (error) { throw new DatabaseError(`Failed to read or parse backup file: ${filePath}`); } await this._write(backupData); return true; }); } }