@incoqnito.io/bajadab
v1.3.0
Published
a lightweight json-based database for small-scale single-machine setups
Keywords
Readme
bajadab
A lightweight JSON-file-backed database for small, single-machine setups. No server, no native bindings — just collections of values persisted as JSON under a directory you choose.
Features
- Collections stored as plain JSON files, one directory per database.
MAP(keyed by id) orARRAY(ordered list) in-memory storage per collection.- Configurable behavior for duplicate ids, missing ids, array deletion, auto-flush, and unloading dirty data.
- Custom id extraction/assignment via a pluggable function registry.
- Optional secondary indexes for fast lookup by a derived key.
- Atomic writes (write-to-temp, then rename) and, on POSIX systems, owner-only file permissions (
0600files,0700directories). - In-process concurrency safety via one lock per database — deadlock-free, even when handlers touch other collections.
db.snapshot(targetDir, options?)to copy the whole database into a new timestamped version folder, optionally pruning older ones.db.transaction(collectionNames, fn)to apply reads and writes across several collections as one unit — committed together, or not applied at all.
Install
npm install @incoqnito.io/bajadabRequires Node.js >= 24.
Quick start
import { getDatabase, EBMemStorageStrategy } from "@incoqnito.io/bajadab";
const db = await getDatabase("./data");
const notes = await db.getCollection<{ id?: string; text: string }>("notes", {
...db.config.defaultCollectionConfig,
memStorageStrategy: EBMemStorageStrategy.ARRAY,
});
await notes.add({ text: "hello" });
const all = await notes.get();
const some = await notes.get(n => n.text.startsWith("h"));
await notes.delete(n => n.text === "hello");
await db.dropCollection("notes");getDatabase(basePath) opens the database at basePath, creating it if it doesn't exist. db.getCollection(name, config?) returns the named collection, creating it with config (or the database's default) if it doesn't exist yet; db.createCollection(name, config?) instead rejects if one already exists. Collection names are matched case- and whitespace-insensitively ("Notes" and " notes " refer to the same collection).
Configuring a collection
Every collection has an IBCollectionConfig. db.config.defaultCollectionConfig holds the database's defaults — spread it and override what you need, as above.
| Option | Values | Meaning |
| --- | --- | --- |
| memStorageStrategy | MAP (default) / ARRAY | Keyed lookup by id, or an ordered list. |
| autoIDStrategy | AUTO_ID_UUID (default) / NO_AUTO_ID | Generate a uuidv7 for values with no id, or reject them. |
| duplicateIDStrategy | OVERWRITE (default) / ERROR / IGNORE | What add() does when a value's id already exists. |
| arrayDeletionStrategy | IN_PLACE (default) / NEW_ARRAY | How delete() removes matches from an ARRAY collection. |
| autoFlushStrategy | ALWAYS_AUTO_FLUSH (default) / NO_AUTO_FLUSH | Whether every mutation is written to disk immediately, or only on an explicit flush(). |
| dirtyUnloadStrategy | FLUSH (default) / ERROR / IGNORE | What unload() does with unsaved changes. |
| getID / setID | registry key (optional) | Custom id accessors — see below. Omit to use the default, which reads/writes a plain .id property. |
| indexes | { [indexKey: string]: registry key } (optional) | Secondary indexes — see below. Omit for no indexes. |
Custom ids
By default, a value's id is its .id property. To use something else, implement IBFunctionRegistry and register getID/setID functions under whatever keys you configure:
import { getDatabase, IBFunctionRegistry } from "@incoqnito.io/bajadab";
class Registry implements IBFunctionRegistry {
private fns = new Map<string, Function>([
["userGetID", async (v: any) => v.userId],
["userSetID", async (v: any, id: string) => { v.userId = id; }],
]);
fetch(key?: string) {
return key ? this.fns.get(key) : undefined;
}
}
const db = await getDatabase("./data", new Registry());
const users = await db.createCollection("users", {
...db.config.defaultCollectionConfig,
getID: "userGetID",
setID: "userSetID",
});A getID/setID key that isn't found in the registry causes add() to reject, rather than silently falling back to the default.
Indexes
A collection can declare secondary indexes for fast lookup by a derived key. Like getID/setID, the extractor function lives in the registry — only its key is part of the (persisted) collection config:
class Registry implements IBFunctionRegistry {
private fns = new Map<string, Function>([
["byEmail", async (v: any) => v.email],
]);
fetch(key?: string) {
return key ? this.fns.get(key) : undefined;
}
}
const db = await getDatabase("./data", new Registry());
const users = await db.createCollection("users", {
...db.config.defaultCollectionConfig,
indexes: { email: "byEmail" },
});
await users.add({ email: "[email protected]", name: "A" });
const matches = await users.getByIndex("email", "[email protected]");An extractor returns a string, a string[] for a multi-valued index, or undefined to leave a value out of the index. getByIndex() rejects if indexKey isn't configured. A configured index whose registry key isn't found rejects on the collection's first use after it's (re)loaded — get(), add(), whatever comes first — not just the mutations that actually need indexing; that's a wider blast radius than getID/setID, which only ever fail inside add().
The index is maintained incrementally: add()/update()/delete() update only the affected buckets, not the whole index. A full rebuild only happens once, right after reload() loads fresh data from disk. Each value remembers the keys it was indexed under, so re-adding a value that was mutated in place moves it out of its old buckets correctly.
Values are stored by reference
bajadab keeps the objects you hand it; it doesn't copy them. get()/getByIndex() return a new array, but the values in it are the stored objects themselves, and update()'s updater receives the stored object too. Change values only by passing a new object to update() (async v => ({ ...v, value: 5 })) or to add() with an existing id:
- Mutating a value you got from
get()changes the in-memory state without marking the collection dirty, updating indexes, or firing handlers. - An updater that mutates in place and returns the same object still keeps indexes consistent, but the
UPDATEhandler receives that same object as bothpreviousandupdated. - Inside a transaction, in-place mutations are outside transactional safety: they aren't reverted on rollback. A value mutated directly fires nothing on commit; an in-place updater passed to
update()firesUPDATEwith the same object as bothpreviousandupdated, same as outside a transaction.
update() is all-or-nothing: if the updater throws or returns undefined for any matching item, no item is replaced.
Ids are immutable. Once a value is stored, never change its id — neither in place nor through the object update()'s updater returns. bajadab doesn't check this; a changed id leaves the collection inconsistent (a MAP entry stored under its old key, a duplicate id in an ARRAY). To "rename" a value, delete() it and add() it under the new id.
Persistence and file layout
Each database directory contains:
dbmeta.json— the database config and a name → id map for its collections.<id>.json— per-collection metadata (name, config, id).<id>_data/data.json— the collection's actual values.
Collections are addressed by a stable id, not by name, so a handle to a since-renamed-or-recreated collection can't end up reading or writing the wrong data. Every write goes through a temp-file-then-rename step to avoid partial writes. A rename that fails with EPERM/EBUSY/EACCES (typically a virus scanner or indexer briefly holding the file on Windows) is retried a few times before giving up; a failed write never leaves its temp file behind. On POSIX systems, created files get mode 0600 and directories 0700; this has no effect on Windows, which has no equivalent permission bits.
Concurrency
All operations on a database — on any of its collections as well as create/get/has/dropCollection(), snapshot() and transaction() — are serialized within one process by a single lock per database. The lock is reentrant: code that runs while an operation holds it (handlers, predicates, updaters, a transaction's fn) can call any other operation on the same database without deadlocking. The flip side is that such code blocks the whole database while it runs, so keep EBHandlerMode.SYNC handlers and transaction callbacks short, and don't await anything slow or external inside them. There is no cross-process locking — multiple processes pointed at the same directory can still race on the underlying files.
Snapshots
db.snapshot(targetDir, options?) flushes every loaded collection, then copies the whole database directory into a new version folder under targetDir (created if it doesn't exist yet). The version folder is named after a sortable timestamp (e.g. 20260904T153012345Z) and is itself a complete, independent database — open it directly with getDatabase(versionPath).
const versionPath = await db.snapshot("./backups");
// later, e.g. after a bad deploy:
const restored = await getDatabase(versionPath);targetDir is a pool that can hold several versions; each call adds one more. Options:
| Option | Values | Meaning |
| --- | --- | --- |
| label | string (optional) | Appended to the version folder's timestamp, e.g. ..._before-deploy. Letters, digits, - and _ only. |
| keepLast | number (optional) | Older version folders under targetDir to keep besides the one just created. 0 keeps only the new one. Omit to keep everything. Negative values reject. |
Pruning only ever deletes folders matching bajadab's own naming scheme — anything else you keep in targetDir is left alone.
Version folder names have millisecond resolution. Two snapshot() calls into the same targetDir within the same millisecond (and with the same or no label) resolve to the same folder name: the second copy is merged into the first, and if that copy fails, the cleanup removes the earlier snapshot along with it. Don't take snapshots into the same targetDir in such quick succession.
snapshot() blocks every other operation on the database for as long as the copy takes, to guarantee a consistent point-in-time copy. There's no separate restoreSnapshot() — restoring means copying a version folder back over the original basePath (while nothing has it open), or just pointing getDatabase() at the version folder directly.
Transactions
db.transaction(collectionNames, fn) runs fn against isolated, in-memory working copies of the named collections. Reads and writes inside fn only see the transaction's own state until it resolves:
await db.transaction(["accounts", "orders"], async (tx) => {
const accounts = await tx.getCollection<Account>("accounts");
const orders = await tx.getCollection<Order>("orders");
await accounts.update(a => a.id === "u1", async a => ({ ...a, balance: a.balance - 10 }));
await orders.add({ userId: "u1", total: 10 });
});Every name passed to transaction() must be an existing collection; otherwise it rejects before fn runs, and nothing is created.
If fn resolves, every declared collection's working copy is first written to a temp file. If any of these writes fails, all temp files are discarded and nothing is applied. Otherwise the temp files replace the collections' files, every live state is replaced by its working copy, and only then do the ADD/UPDATE/DELETE handlers registered with on() fire — so a handler that reads or writes another declared collection already sees its committed state. They fire once per id the transaction touched, typed by the last operation on it, the same way as outside a transaction: ADD if the value is new or was last stored via add() (including an id-based overwrite), UPDATE (pre-transaction value → final value) if it was last changed via update(), DELETE if it's gone. A value added and later deleted within the same transaction fires nothing, and a value updated twice fires one UPDATE. If fn throws, nothing is applied, nothing is flushed, and no handler fires — every declared collection stays exactly as it was. This only covers changes made through the working copies' add()/update()/delete(); values mutated in place are shared with the live collection and aren't isolated (see "Values are stored by reference").
tx.getCollection(name) only accepts names passed to transaction(); anything else throws. It mirrors IBCollection's read/write surface (get/has/count/getByIndex/add/update/delete) but not flush/reload/unload/on — those don't make sense against a working copy that might still be rolled back.
Inside fn, add()/update()/delete() through a declared collection's regular handle (the one from db.getCollection()) reject — those writes would otherwise be overwritten by the commit. Reading through it is fine and shows the pre-transaction state; collections not declared to the transaction stay fully usable, but writes to them aren't part of the transaction. Calling transaction() again from inside fn rejects as well, whichever collections it names.
transaction() holds the database lock for its whole duration, so every other operation on the database — on any collection — waits until it resolves. Across collections, the final renames at commit still happen one file at a time — a crash, or a rename that keeps failing, between them can leave a transaction partially applied. If a rename fails, the collections renamed so far are applied (on disk and in memory, and their handlers fire), the rest are discarded, and transaction() rejects. There's no cross-process locking here either, same as everywhere else in bajadab.
License
See LICENSE.
