UNPKG

leaf-db

Version:

Small file-based database for node.js

241 lines (233 loc) 6.64 kB
'use strict'; var crypto = require('crypto'); var rambda = require('rambda'); var deepEqual = require('fast-deep-equal'); var fs = require('fs'); var path = require('path'); const isObject = (x) => x !== null && !Array.isArray(x) && typeof x === "object"; const isTag = (x) => x[0] === "$"; const hasTag = ([key, value]) => isTag(key) && value !== void 0; const hasTagDeep = ([key, value]) => value !== null && typeof value === "object" ? Object.entries(value).some(hasTagDeep) : hasTag([key, value]); const isDraft = (x) => isObject(x) && !Object.entries(x).some(hasTagDeep); const isDoc = (x) => isDraft(x) && typeof x._id === "string" && x._id.length > 0; const isQueryMatch = (doc, query) => { const isMatchNumber = { $gt: (x, y) => x > y, $gte: (x, y) => x >= y, $lt: (x, y) => x < y, $lte: (x, y) => x <= y }; return Object.entries(query).every(([key, y]) => { if (isObject(doc) && !(key in doc) && !isTag(key)) return false; const x = isObject(doc) ? doc[key] : doc; if (key in isMatchNumber) { if (typeof x !== "number" || typeof y !== "number") return false; return isMatchNumber[key](x, y); } if (key === "$text") { if (typeof x !== "string" || typeof y !== "string") return false; return x.toLocaleLowerCase().includes(y.toLocaleLowerCase()); } if (key === "$regex") { if (typeof x !== "string" || !(y instanceof RegExp)) return false; return y.test(x); } if (key === "$has") { if (!Array.isArray(x)) return false; return x.some((value) => deepEqual(y, value)); } if (key === "$size") { if (!Array.isArray(x) || typeof y !== "number") return false; return x.length === y; } if (key === "$not") return x !== y; if (Array.isArray(y)) { if (!Array.isArray(x) || x.length !== y.length) return false; return deepEqual(x, y); } if (!isObject(y)) return x === y; return isQueryMatch(x, y); }); }; const MEMORY_MODE = (fn) => `Tried to call '${fn}()' in memory mode`; const DUPLICATE_DOC = (doc) => `Doc already exists with _id: ${doc._id}`; const MISSING_FD = (fn) => `Cannot call '${fn}()' if file is not opened`; const INVALID = (param) => (value) => `Invalid ${param}: ${JSON.stringify(value)}`; const INVALID_DOC = INVALID("doc"); class Memory { constructor() { this._docs = new Map(); } get(_id) { return this._docs.get(_id) || null; } set(doc) { this._docs.set(doc._id, doc); return doc; } has(_id) { if (!_id || !this._docs.has(_id)) return false; return !this._docs.get(_id)?.__deleted; } delete(_id) { return this._docs.delete(_id); } docs() { return this._docs.values(); } flush() { this._docs.clear(); } } class Storage { constructor(options) { this._file = path.format({ dir: options.root, name: options.name, ext: ".txt" }); } open() { let data = []; if (fs.existsSync(this._file)) { data = fs.readFileSync(this._file, { encoding: "utf-8" }).split("\n"); } else { fs.mkdirSync(path.parse(this._file).dir, { recursive: true }); } this._fd = fs.openSync(this._file, "a"); return data; } close() { if (typeof this._fd !== "number") throw new Error(MISSING_FD("close")); fs.closeSync(this._fd); delete this._fd; } append(raw) { if (typeof this._fd !== "number") throw new Error(MISSING_FD("append")); fs.appendFileSync(this._fd, `${raw} `); } flush() { if (typeof this._fd !== "number") throw new Error(MISSING_FD("flush")); fs.closeSync(this._fd); fs.rmSync(this._file); this._fd = fs.openSync(this._file, "a"); } } class LeafDB { static id() { return [ Date.now().toString(16), crypto.randomBytes(4).toString("hex") ].join(""); } _set(doc) { this._memory.set(doc); this._storage?.append(JSON.stringify(doc)); return doc; } _delete(_id) { this._memory.delete(_id); this._storage?.append(JSON.stringify({ _id, __deleted: true })); } constructor(options) { this._memory = new Memory(); const root = typeof options?.storage === "string" ? options?.storage : options?.storage?.root; if (root) { const name = typeof options?.storage !== "string" ? options?.storage?.name ?? "leaf-db" : "leaf-db"; this._storage = new Storage({ root, name }); } } open() { if (!this._storage) throw new Error(MEMORY_MODE("open")); const corrupted = []; const docs = []; this._storage.open().forEach((raw) => { try { if (raw.length > 0) { const doc = JSON.parse(raw); if (!isDoc(doc)) throw new Error(INVALID_DOC(doc)); docs.push(doc); } } catch (err) { corrupted.push({ raw, err }); } }); docs.filter((x) => x.__deleted).map((x) => x._id).forEach((doc) => { const i = docs.findIndex((x) => x._id === doc); if (i >= 0) docs.splice(i, 1); }); this._storage.flush(); docs.filter((doc) => !doc.__deleted).forEach((doc) => this._set(doc)); return corrupted; } close() { if (!this._storage) throw new Error(MEMORY_MODE("close")); return this._storage?.close(); } insert(drafts) { const docs = []; drafts.forEach((draft) => { if (isDoc(draft)) { if (this._memory.has(draft._id) || docs.some((doc) => doc._id === draft._id)) throw new Error(DUPLICATE_DOC(draft)); docs.push(draft); } else { docs.push({ _id: LeafDB.id(), ...draft }); } }); return docs.map((doc) => this._set(doc)); } select(...queries) { const docs = []; for (const doc of this._memory.docs()) { if (!doc.__deleted && queries.some((query) => isQueryMatch(doc, query))) docs.push(doc); } return docs; } selectById(...ids) { return ids.reduce((acc, cur) => { const doc = this._memory.get(cur); if (doc) acc.push(doc); return acc; }, []); } update(update, ...queries) { if ("_id" in update) throw new Error("Invalid update, cannot contain key `_id`"); return this.select(...queries).map((doc) => { this._delete(doc._id); return this._set(rambda.merge(doc, update)); }); } delete(...queries) { return this.select(...queries).reduce((acc, cur) => { this._delete(cur._id); return acc + 1; }, 0); } drop() { this._memory.flush(); this._storage?.flush(); } } module.exports = LeafDB;