darkdb
Version:
DarkDB V3.3 - Lightweight JSON-based database for Node.js with advanced features.
540 lines (481 loc) • 15.6 kB
JavaScript
const fs = require("fs");
const fsp = require("fs/promises");
const path = require("path");
const { EventEmitter } = require("events");
const JsonDriver = require("./drivers/JsonDriver");
const YamlDriver = require("./drivers/YamlDriver");
const BinaryDriver = require("./drivers/BinaryDriver"); // ✅ binary driver
const TomlDriver = require("./drivers/TomlDriver"); // ✅ binary driver
const IndexManager = require("./manager/indexManager");
class Mutex {
constructor() {
this.queue = [];
this.locked = false;
}
async run(fn) {
return new Promise((resolve, reject) => {
const task = async () => {
try {
const res = await fn();
resolve(res);
} catch (e) {
reject(e);
} finally {
this._next();
}
};
this.queue.push(task);
if (!this.locked) this._next();
});
}
_next() {
const t = this.queue.shift();
if (!t) {
this.locked = false;
return;
}
this.locked = true;
t();
}
}
class DarkDB {
constructor(options = {}) {
this.name = options.name || "darkdb";
this.dir = options.dir || process.cwd();
this.format = options.format || "json";
if (this.format === "yaml") {
this.fileExtension = "yaml";
} else if (this.format === "binary") {
this.fileExtension = "bin";
} else {
this.fileExtension = "json";
}
this.file = path.resolve(this.dir, `${this.name}.${this.fileExtension}`);
this.metaFile = path.resolve(this.dir, `${this.name}.meta.json`);
this.separator = options.separator ?? ".";
this.autoFile = options.autoFile ?? true;
this.debounceMs = options.debounceMs ?? 25;
this.atomic = options.atomic ?? true;
this.shard = options.shard ?? false;
if (this.format === "json") {
this.driver = new JsonDriver({ jsonSpaces: options.jsonSpaces });
this.fileExtension = "json";
} else if (this.format === "yaml") {
this.driver = new YamlDriver();
this.fileExtension = "yaml";
} else if (this.format === "toml") {
this.driver = new TomlDriver();
this.fileExtension = "toml";
} else if (this.format === "binary") {
this.driver = new BinaryDriver();
this.fileExtension = "bin";
} else {
throw new Error(`Unsupported file format: ${this.format}`);
}
this._data = {};
this._expires = {};
this._saveTimer = null;
this._emitter = new EventEmitter();
this._mutex = new Mutex();
this._indexManager = new IndexManager();
if (this.autoFile) {
this._loadDataSync();
}
}
_loadDataSync() {
try {
if (fs.existsSync(this.file)) {
const result = this.driver.read(this.file);
if (result instanceof Promise) {
throw new Error("Binary driver read() cannot be sync. Use autoFile:false and load async.");
}
this._data = result;
}
if (fs.existsSync(this.metaFile)) {
this._expires = JSON.parse(fs.readFileSync(this.metaFile, "utf8"));
}
} catch (e) {
console.error("DarkDB load error:", e?.message || e);
this._data = {};
this._expires = {};
}
this._rebuildIndex();
this._cleanupExpiredSync();
}
_rebuildIndex() {
this._indexManager.clear();
const indexRecursive = (obj, currentKey) => {
if (typeof obj === "object" && obj !== null) {
this._indexManager.update(currentKey, obj);
for (const key in obj) {
if (Object.prototype.hasOwnProperty.call(obj, key)) {
const newKey = currentKey ? `${currentKey}${this.separator}${key}` : key;
indexRecursive(obj[key], newKey);
}
}
}
};
indexRecursive(this._data, "");
}
_resolvePath(key) {
if (typeof key !== "string" || !key.length)
throw new Error("Key cannot be an empty string.");
return key.split(this.separator).filter(Boolean);
}
_getRef(keys, create = false) {
let obj = this._data;
for (let i = 0; i < keys.length - 1; i++) {
const k = keys[i];
if (obj[k] == null) {
if (create) obj[k] = {};
else return [null, null];
}
if (typeof obj[k] !== "object") {
if (create) obj[k] = {};
else return [null, null];
}
obj = obj[k];
}
return [obj, keys[keys.length - 1]];
}
_scheduleSave() {
if (!this.autoFile) return;
if (this._saveTimer) clearTimeout(this._saveTimer);
this._saveTimer = setTimeout(() => {
this._mutex.run(() => this._saveNow());
}, this.debounceMs);
}
async _saveNow() {
const metaBody = JSON.stringify(this._expires, null, 2);
await fsp.mkdir(this.dir, { recursive: true });
if (this.atomic) {
const tmpFile = this.file + ".tmp";
const tmpMetaFile = this.metaFile + ".tmp";
await this.driver.write(tmpFile, this._data);
await fsp.writeFile(tmpMetaFile, metaBody, "utf8");
await fsp.rename(tmpFile, this.file);
await fsp.rename(tmpMetaFile, this.metaFile);
} else {
await this.driver.write(this.file, this._data);
await fsp.writeFile(this.metaFile, metaBody, "utf8");
}
}
_emit(type, payload) {
this._emitter.emit(type, payload);
this._emitter.emit("change", { type, ...payload });
}
_isExpired(keyPath) {
const exp = this._expires[keyPath];
return typeof exp === "number" && Date.now() >= exp;
}
_cleanupExpiredSync() {
let changed = false;
for (const [k, exp] of Object.entries(this._expires)) {
if (Date.now() >= exp) {
this._deleteSync(k);
delete this._expires[k];
changed = true;
}
}
if (changed) this._scheduleSave();
}
_getSync(key) {
const keys = this._resolvePath(key);
let obj = this._data;
for (const k of keys) {
if (obj == null || typeof obj !== "object" || !(k in obj))
return undefined;
obj = obj[k];
}
return obj;
}
_setSync(key, value) {
const keys = this._resolvePath(key);
const [ref, last] = this._getRef(keys, true);
const oldValue = ref[last];
ref[last] = value;
if (JSON.stringify(oldValue) !== JSON.stringify(value)) {
this._indexManager.update(key, value);
}
this._emit("set", { key, value });
this._scheduleSave();
return value;
}
_deleteSync(key) {
const keys = this._resolvePath(key);
const [ref, last] = this._getRef(keys, false);
if (!ref || !(last in ref)) return false;
delete ref[last];
this._indexManager.delete(key);
this._emit("delete", { key });
this._scheduleSave();
return true;
}
async set(key, value, opts = {}) {
return this._mutex.run(async () => {
const v = this._setSync(key, value);
if (opts && typeof opts.ttlMs === "number" && opts.ttlMs > 0) {
this._expires[key] = Date.now() + opts.ttlMs;
} else {
delete this._expires[key];
}
return v;
});
}
async get(key) {
return this._mutex.run(async () => {
this._cleanupExpiredSync();
if (this._isExpired(key)) {
this._deleteSync(key);
delete this._expires[key];
return undefined;
}
return this._getSync(key);
});
}
async has(key) {
return (await this.get(key)) !== undefined;
}
async delete(key) {
return this._mutex.run(async () => {
const ok = this._deleteSync(key);
delete this._expires[key];
return ok;
});
}
async all() {
return this._mutex.run(async () => {
this._cleanupExpiredSync();
return JSON.parse(JSON.stringify(this._data));
});
}
async deleteAll() {
return this._mutex.run(async () => {
this._data = {};
this._expires = {};
this._indexManager.clear();
this._emit("reset", {});
this._scheduleSave();
return true;
});
}
async push(key, value) {
return this._mutex.run(async () => {
const cur = this._getSync(key);
const arr = Array.isArray(cur) ? cur : [];
arr.push(value);
this._setSync(key, arr);
return arr;
});
}
async unpush(key, value) {
return this._mutex.run(async () => {
const cur = this._getSync(key);
if (!Array.isArray(cur)) return [];
const arr = cur.filter((v) => v !== value);
this._setSync(key, arr);
return arr;
});
}
async add(key, n) {
return this._numOp(key, +n || 0, (a, b) => a + b);
}
async remove(key, n) {
return this._numOp(key, +n || 0, (a, b) => a - b);
}
async incr(key) {
return this._numOp(key, 1, (a, b) => a + b);
}
async decr(key) {
return this._numOp(key, 1, (a, b) => a - b);
}
async _numOp(key, n, op) {
return this._mutex.run(async () => {
const cur = this._getSync(key);
const base = typeof cur === "number" ? cur : 0;
const val = op(base, n);
this._setSync(key, val);
return val;
});
}
async keys(keyPrefix = "") {
return this._mutex.run(async () => {
const root = keyPrefix ? this._getSync(keyPrefix) : this._data;
if (root && typeof root === "object") return Object.keys(root);
return [];
});
}
async values(keyPrefix = "") {
return this._mutex.run(async () => {
const root = keyPrefix ? this._getSync(keyPrefix) : this._data;
if (root && typeof root === "object") return Object.values(root);
return [];
});
}
async entries(keyPrefix = "") {
return this._mutex.run(async () => {
const root = keyPrefix ? this._getSync(keyPrefix) : this._data;
if (root && typeof root === "object") return Object.entries(root);
return [];
});
}
async find(keyPrefix, predicate) {
return this._mutex.run(async () => {
const root = keyPrefix ? this._getSync(keyPrefix) : this._data;
if (!root || typeof root !== "object") return [];
const out = [];
for (const [k, v] of Object.entries(root))
if (predicate(v, k)) out.push([k, v]);
return out;
});
}
async search(query) {
return this._mutex.run(async () => {
const matchingKeys = this._indexManager.search(query);
const results = [];
for (const key of matchingKeys) {
const value = this._getSync(key);
if (value !== undefined) {
results.push({ key, value });
}
}
return results;
});
}
async expire(key, ttlMs) {
return this._mutex.run(async () => {
if (typeof ttlMs === "number" && ttlMs > 0) {
this._expires[key] = Date.now() + ttlMs;
} else delete this._expires[key];
this._scheduleSave();
return true;
});
}
async ttl(key) {
return this._mutex.run(async () => {
const exp = this._expires[key];
if (typeof exp !== "number") return -1;
return Math.max(0, exp - Date.now());
});
}
async backup(destPath) {
return this._mutex.run(async () => {
const out = {
data: this._data,
expires: this._expires,
version: 1,
createdAt: new Date().toISOString(),
};
await fsp.writeFile(destPath, JSON.stringify(out, null, 2), "utf8");
return destPath;
});
}
async restore(srcPath) {
return this._mutex.run(async () => {
const raw = JSON.parse(await fsp.readFile(srcPath, "utf8"));
this._data = raw.data || {};
this._expires = raw.expires || {};
this._rebuildIndex();
this._emit("reset", { reason: "restore" });
this._scheduleSave();
return true;
});
}
async export() {
return this._mutex.run(async () => JSON.parse(JSON.stringify(this._data)));
}
async import(obj) {
return this._mutex.run(async () => {
if (!obj || typeof obj !== "object")
throw new Error("import() expects object");
this._data = JSON.parse(JSON.stringify(obj));
this._expires = {};
this._rebuildIndex();
this._emit("reset", { reason: "import" });
this._scheduleSave();
return true;
});
}
on(event, listener) {
this._emitter.on(event, listener);
return () => this._emitter.off(event, listener);
}
off(event, listener) {
this._emitter.off(event, listener);
}
async transaction(fn) {
return this._mutex.run(async () => {
const snapshot = JSON.parse(JSON.stringify(this._data));
const temp = new DarkDB({
autoFile: false,
separator: this.separator,
format: this.format,
});
temp._data = JSON.parse(JSON.stringify(this._data));
temp._expires = JSON.parse(JSON.stringify(this._expires));
temp._indexManager = this._indexManager;
try {
await fn(temp);
this._data = temp._data;
this._expires = temp._expires;
this._rebuildIndex();
this._scheduleSave();
this._emit("reset", { reason: "transaction" });
return true;
} catch (e) {
this._data = snapshot;
throw e;
}
});
}
async query(keyPrefix, filter) {
return this._mutex.run(async () => {
const root = keyPrefix ? this._getSync(keyPrefix) : this._data;
if (!root || typeof root !== "object") return [];
const match = (obj, cond) => {
if (!cond || typeof cond !== "object") return false;
if (cond.$and) {
if (!Array.isArray(cond.$and)) throw new Error("$and must be array");
return cond.$and.every((sub) => match(obj, sub));
}
if (cond.$or) {
if (!Array.isArray(cond.$or)) throw new Error("$or must be array");
return cond.$or.some((sub) => match(obj, sub));
}
if (cond.$not) {
return !match(obj, cond.$not);
}
for (const [field, rule] of Object.entries(cond)) {
const val = obj[field];
if (typeof rule === "object" && !Array.isArray(rule)) {
for (const [op, cmp] of Object.entries(rule)) {
switch (op) {
case "$eq": if (val !== cmp) return false; break;
case "$ne": if (val === cmp) return false; break;
case "$gt": if (!(val > cmp)) return false; break;
case "$gte": if (!(val >= cmp)) return false; break;
case "$lt": if (!(val < cmp)) return false; break;
case "$lte": if (!(val <= cmp)) return false; break;
case "$in": if (!cmp.includes(val)) return false; break;
case "$nin": if (cmp.includes(val)) return false; break;
case "$regex": if (!(new RegExp(cmp).test(val))) return false; break;
default: throw new Error(`Unknown operator ${op}`);
}
}
} else {
if (val !== rule) return false;
}
}
return true;
};
const out = [];
for (const [k, v] of Object.entries(root)) {
if (typeof v === "object" && match(v, filter)) {
out.push([k, v]);
}
}
return out;
});
}
}
module.exports = DarkDB;