leaf-db
Version:
Small file-based database for node.js
239 lines (232 loc) • 6.61 kB
JavaScript
import crypto from 'crypto';
import { merge } from 'rambda';
import deepEqual from 'fast-deep-equal';
import fs from 'fs';
import path from '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(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();
}
}
export { LeafDB as default };