# minidb A **pure-Node.js** (zero native addons) embedded key-value database that mixes ideas from **Redis** (in-memory KV speed, data structures, AOF-style rewrite) and **SQLite** (durable single-file persistence, WAL, indexed queries). Written in **TypeScript**, with strict types and zero runtime dependencies. > Built by studying the real source code of Redis, SQLite, NeDB, Bitcask, and a > tiny SQLite clone — see [`DESIGN_NOTES.md`](./DESIGN_NOTES.md) for what each > one taught us. ## Features - **O(1) GET/SET** from an in-memory index (`Map`), values as bytes. - **Optional larger-than-RAM values**: `valueMode: 'disk'` keeps only value pointers in RAM and reads value bulk from the snapshot/WAL with synchronous positioned reads. - **Durable**: append-only **WAL** with group commit + three fsync policies (`always` / `everysec` / `no`), exactly like Redis AOF. - **Crash-safe recovery**: CRC-framed records; a torn tail from a crash is detected and truncated automatically. - **Snapshots + compaction** (WAL rewrite) bound disk growth and speed up restart. - **TTL**: lazy + active expiration (Redis-style, heap-backed). - **Secondary indexes**: equality and **range** indexes over JSON documents, backed by a Redis-style **skip list** with O(log N) rank/range; unique and sparse supported; array fields indexed per element. - **RESP server** (optional): talk to it with `redis-cli` / `ioredis`. - **Value codecs**: `buffer`, `string`, or `json`. - **Zero dependencies**, pure `node:*` built-ins. ## Install / layout Written in **TypeScript**, zero runtime dependencies. Source is in `src/`; build output goes to `dist/` (generated by `npm run build`). ``` src/ index.ts public API (MiniDb) + types codec.ts binary frame encode/decode + CRC parser + resync crc32.ts table-based CRC-32 wal.ts buffered append + group commit + fsync policies store.ts in-memory index + TTL (lazy + active) + value refs value-reader.ts synchronous positioned reads for disk-backed values snapshot.ts chunked snapshot writer recovery.ts load snapshot + replay WAL + resync/truncate compaction.ts WAL rewrite / rotation skiplist.ts comparator-driven skip list with span index-manager.ts secondary indexes (equality + range) dt-index.ts datetime-column indexes query.ts jq-like value query engine text-index.ts full-text index (in-RAM dictionary + on-disk postings) text-postings.ts on-disk postings file for the full-text index lockfile.ts exclusive file lock + read-only server.ts optional RESP TCP server ``` ```bash npm run build # compile src/ -> dist/ (JS + .d.ts) npm run typecheck # tsc --noEmit (strict) ``` ## Quick start (embedded) ```ts import { MiniDb } from 'minidb'; const db = await MiniDb.open({ dir: './data', valueCodec: 'string', // 'buffer' | 'string' | 'json' fsyncPolicy: 'everysec', // 'always' | 'everysec' | 'no' }); await db.set('hello', 'world'); db.get('hello'); // 'world' await db.set('temp', 'x', { ttl: 5000 }); // expires in 5s db.ttl('temp'); // remaining ms (-1 none, -2 missing) await db.del('hello'); await db.close(); // flush + fsync + close ``` > Within this repo, import from `./src/index.ts` (run with `tsx`). As an installed > package, import from `'minidb'` (the built `dist/` output). Re-open the same `dir` and your data is recovered from the snapshot + WAL. ### Typed usage `MiniDb` is generic over the value type: ```ts interface User { name: string; age: number; city: string } const db = await MiniDb.open({ dir: './data', valueCodec: 'json' }); await db.set('u1', { name: 'Ann', age: 30, city: 'Paris' }); const u = db.get('u1'); // User | undefined ``` ### JSON + secondary indexes ```js const db = await MiniDb.open({ dir: './data', valueCodec: 'json' }); await db.createIndex('byCity', { field: 'city' }); // equality await db.createIndex('byAge', { field: 'age', type: 'range' }); // range (skip list) await db.createIndex('byMail', { field: 'email', unique: true }); // unique await db.set('u1', { name: 'Ann', city: 'Paris', age: 30, email: 'a@x.com' }); await db.set('u2', { name: 'Bob', city: 'Paris', age: 41, email: 'b@x.com' }); db.findEq('byCity', 'Paris'); // [{ key:'u1', value:{...} }, { key:'u2', ... }] db.findRange('byAge', { min: 30, max: 40, count: 10 }); ``` Index definitions are persisted and rebuilt from the store on startup. ## Document model & querying (MongoDB-like subset) A record is: ```js { key: 'user:1234', value: { ...any JSON... }, dt1..dtN: } ``` - **key** — string ≤ 128 chars, unique, **ordered** (range / prefix / ordered scan). - **value** — any JSON object (jq-like filter + projection + full-text). - **dt1..dtN** — any number of top-level **datetime columns**, each indexed for O(log N) range queries. Pass them as `{ dt: { created: Date.now() } }` (ISO strings or epoch ms). ### Key scans (ordered) ```js db.scan({ gte: 'user:1', lte: 'user:9', limit: 100 }); // range in key order db.prefix('user:'); // prefix scan ``` ### Datetime column queries ```js await db.set('a', { n: 1 }, { dt: { created: '2024-01-01' } }); db.dtColumns(); // ['created'] db.dtRange('created', { gte: t0, lte: t1 }); // O(log N), [{ key, value, dt, dtValue }] ``` ### Unified query `db.query(q)` composes key range, dt range, full-text, and a value filter: ```js db.query({ key: { prefix: 'post:' }, dt: { created: { gte: t0, lte: t1 } }, text: { index: 'body', q: '北京', op: 'AND' }, filter: { age: { $gte: 18 }, $or: [{ city: 'Paris' }, { city: 'London' }] }, sort: { age: -1 }, project: ['name', 'age'], skip: 0, limit: 20, }); ``` Filter operators: `$eq $ne $gt $gte $lt $lte $in $nin $regex $exists $contains $type`, plus `$and $or $nor $not`. Paths use dot/bracket notation (`"address.city"`, `"tags[0]"`). > Indexed dimensions (key / dt / text / a `createIndex`'d field) are fast; > `db.query()` automatically uses matching equality/range value indexes for simple > top-level predicates (including `$and`), while a value `filter` with no matching > index falls back to a full collection scan, exactly like MongoDB without an index. ### Full-text search ```js await db.createTextIndex('body', { fields: ['bio'] }); // fields optional (default: all strings) db.search('body', 'hello 世界', { op: 'AND' }); // [{ key, value, score }] ``` Latin words + CJK unigram/bigram tokenization (no dictionary, zero deps), with TF-IDF ranking. ## API | Method | Description | |---|---| | `MiniDb.open({ dir, valueCodec?, valueMode?, fsyncPolicy?, compactThresholdBytes?, autoCompact?, activeExpireIntervalMs?, recovery?, readOnly?, onLockFail?, maxMemoryBytes?, maxMemoryPolicy? })` | Open / create a database | | `MiniDb.restore(srcDir, destDir, opts?)` | Restore a `db.backup()` directory and open it | | `MiniDb.openOrRebuild(opts, { onRebuild? })` | Open; on corruption, discard + reopen empty (cache use). Never deletes a live-locked db | | `get(key)` | Decoded value or `undefined` | | `set(key, value, { ttl? })` | Set; `ttl` in ms. Resolves per fsync policy | | `del(key)` | Delete; returns `true` if it existed | | `has(key)`, `size` | Membership / live key count | | `mget([keys])`, `mset([[k,v],...])` | Batch read / write | | `batch([{op:'set'|'del', key, value?, ttl?, dt?}])` | Atomically commit multiple ops (all-or-nothing) | | `expire(key, ttlMs)`, `ttl(key)` | Set / read TTL | | `createIndex(name, { field, type?, unique?, sparse? })` | Secondary index (json codec) | | `dropIndex(name)`, `listIndexes()` | Manage indexes | | `findEq(name, value)`, `findRange(name, opts)` | Query value indexes | | `createCompoundIndex(name, { groupBy, orderBy, orderType? })` | Compound index (group + order, e.g. workspace + updatedAt) | | `compoundRange(name, groupValue, opts)` | Ordered range within a group, O(log N + limit), no full sort | | `dropCompoundIndex(name)`, `listCompoundIndexes()` | Manage compound indexes | | `scan({ gte, gt, lte, lt, limit, reverse })` | Ordered key range scan | | `prefix(p, limit?)` | Key prefix scan | | `dtColumns()`, `dtRange(col, opts)` | Datetime column range query | | `query({ key, dt, text, filter, project, sort, skip, limit })` | Unified Mongo-like query | | `createTextIndex(name, { fields? })`, `dropTextIndex(name)` | Full-text index | | `search(name, q, { op?, limit? })` | Full-text search | | `compact()` | Force a snapshot + WAL rewrite now | | `backup(destDir, { compact? })` | Write a consistent online backup directory | | `close()` | Flush, fsync, close | ### Durability (`fsyncPolicy`) | Policy | Guarantees | Relative speed | |---|---|---| | `always` | fsync after every flush | slowest (safest) | | `everysec` | fsync once per second (default) | fast, ≤1s loss window | | `no` | OS decides when to flush | fastest, may lose seconds on power loss | ### Value storage mode (`valueMode`) `valueMode` controls where value bulk lives: ```js const db = await MiniDb.open({ dir: './data', valueCodec: 'json', valueMode: 'memory', // 'memory' | 'disk' | 'auto' }); ``` - `memory` (default): values are kept in RAM, Redis-style. Reads are memory-bound. - `disk`: values stay inline in the durable snapshot/WAL, while RAM keeps only keys, metadata, indexes, and small `{ file, off, len }` value pointers. Reads use synchronous positioned reads, so the public API stays synchronous. This allows value bulk to exceed RAM, at the cost of disk reads on cold `get()` paths. - `auto`: at startup, compare the current `db.snapshot` + `db.wal` size with `maxMemoryBytes`. If the persisted files exceed the budget, open as `disk`; otherwise open as `memory`. If `maxMemoryBytes` is not set, `auto` falls back to `memory`. ### Memory limits With `valueMode: 'memory'` the budget bounds keys plus values. With `valueMode: 'disk'` it bounds the in-RAM key/metadata/index footprint, not the value bulk. You can bound writes with an approximate memory budget: ```js const db = await MiniDb.open({ dir: './data', valueCodec: 'json', maxMemoryBytes: 512 * 1024 * 1024, maxMemoryPolicy: 'reject', // or 'evict-lru' }); ``` `reject` throws when a write would exceed the budget; `evict-lru` durably deletes least-recently-used keys first. In `valueMode: 'disk'`, `evict-lru` trims keys to bound the metadata/index footprint; it does not delete value bytes from disk because values are already stored outside RAM. `db.stats.evictions` and `db.stats.maxMemoryRejections` track the behavior. ### Online backup / restore ```js await db.backup('./backup'); const restored = await MiniDb.restore('./backup', './restored', { valueCodec: 'json' }); ``` `backup()` flushes the WAL, optionally compacts, and copies the snapshot, WAL, index definitions, and text postings while writers are briefly parked, producing a consistent directory that `restore()` can reopen. ### Write throughput & SSD endurance MiniDb's write path is append-only and sequential, which is already SSD-friendly. A few choices have an outsized effect on write amplification and flash wear: - **Use batch writes.** `set()` resolves after its own flush, so `for (const x of xs) await db.set(...)` issues one flush (and, under `fsyncPolicy: 'always'`, one fsync) per key. Coalesce writes with `db.batch([...])` / `db.mset([[k, v], ...])` (single atomic WAL frame) or `await Promise.all(xs.map(x => db.set(x)))` (group commit collapses the tick into one `writev` + one fsync). - **Keep the default `fsyncPolicy: 'everysec'`.** It bounds fsyncs to ~1/s. `always` is the most durable but the hardest on flash (one fsync per flush); reserve it for data that must survive any crash. - **Tune `compactThresholdBytes` for large datasets.** Compaction rewrites all live data, so steady-state write amplification is roughly `1 + liveDataBytes / compactThresholdBytes`. Raising the threshold (e.g. 256 MiB–1 GiB) trades a larger WAL / longer recovery for less rewrite. - **Observe it via `db.stats`.** `walBytesWritten`, `walFsyncs`, `snapshotBytesWritten`, `compactions`, `evictions`, `maxMemoryRejections`, and `queryIndexHits` let you measure real write volume, fsync rate, memory pressure, and index usage under your workload. ## RESP server (redis-cli compatible) ```bash npm run server -- --dir ./data --port 6379 # or: node --import tsx src/server.ts --dir ./data --port 6379 ``` Then in another shell: ```bash redis-cli -p 6379 SET foo bar redis-cli -p 6379 GET foo ``` Supported commands: `PING ECHO GET SET DEL EXISTS MGET MSET TTL DBSIZE COMPACT INFO QUIT`. ## Benchmarks Measured on Node v24, 100-byte values (`npm run bench`, `N=100000`): | Operation | Throughput | |---|---| | Raw `Map` set (baseline) | ~8.6 M ops/s | | Raw `Map` get (baseline) | ~20 M ops/s | | **DB get** (in-memory) | **~8.0 M ops/s** | | **DB set, fsync=everysec** (concurrent, group commit) | **~725 k ops/s** | | **DB set, fsync=no** (concurrent, group commit) | **~396 k ops/s** | | DB set, fsync=always (sequential, 1 fsync/op) | ~328 ops/s | | Compact snapshot of 100k keys | ~69 ms (12 MiB) | Reads are memory-bound and track the raw `Map` closely. Writes are disk-bound; **group commit** makes concurrent writes very fast, while `synchronous` (`always`) writes pay the fsync cost one would expect from any database. Numbers vary by machine/disk. Query benchmarks (N=50k docs, 200 iters each, `node bench/query.js`): | Query | Throughput | |---|---| | dt range (small result) | ~183 k ops/s | | key prefix scan (~100 rows) | ~23 k ops/s | | full-text search (latin / CJK) | ~350–490 ops/s | | value filter, **no index** (full scan of 50k) | ~23 ops/s | Indexed dimensions (key / dt / text) are fast; an unindexed value filter scans the whole collection — create a secondary index (`createIndex`) for hot value predicates. For **ordered pagination within a group** (e.g. "sessions in a workspace ordered by updatedAt"), use a **compound index** instead of fetching the whole group and sorting in memory. On 10k sessions in one workspace, paginating with `compoundRange` is ~20–40× faster than "fetch-all + sort", and stays sub-millisecond at any offset. ## Testing Three layers of tests, run with the built-in `node:test` runner (no deps): ```bash npm test # unit tests (fast) npm run test:e2e # end-to-end stability suite npm run test:all # both ``` The **unit tests** (`test/*.test.js`) cover each module: frame codec/CRC, WAL group commit, store TTL, snapshot/compaction, recovery truncation, skip list, secondary/full-text indexes, and the RESP server. The **E2E stability suite** (`test/e2e/*.test.js`) covers crash-safety and long-run behavior: | File | What it verifies | |---|---| | `fuzz-model.test.js` | thousands of random ops match a reference model (seeded, reproducible) | | `crash-recovery.test.js` | `kill -9` mid-write and mid-compaction → recovery is always consistent | | `index-consistency.test.js` | key/dt/secondary/full-text indexes never drift from the store | | `compaction-race.test.js` | heavy concurrent writes during compaction lose nothing | | `recovery-matrix.test.js` | WAL corruption at head/mid/tail under `resync` vs `strict` | | `durability.test.js` | `always`/`everysec`/`no` close-durability + many open/close cycles | | `boundary.test.js` | key-length limits, large values, many keys, empty db | | `soak.test.js` | sustained ops + heap stability (opt-in: `SOAK=30 npm run test:e2e`) | ## Design in one paragraph Log-structured engine: all writes append to a CRC-framed **WAL** (group committed, configurable fsync); all reads hit an in-memory `Map`. When the WAL grows past a threshold, a **snapshot** of the live keys is written and the WAL is rotated — **non-blocking**: writers keep appending to the WAL (which doubles as a Redis-style rewrite buffer) while the snapshot is written, and pause only for a brief final rotation. Recovery loads the latest snapshot then replays the WAL, truncating any torn tail. Secondary indexes use a Redis-style skip list for range queries. See [`DESIGN_NOTES.md`](./DESIGN_NOTES.md) for the full rationale and the source-code study behind each choice. ## Concurrency & multi-process minidb is **single-writer**. Opening a directory for writing acquires an exclusive lock file (`db.lock`); a second writer is rejected with a `LockError`. A lock is taken over only when its owner PID is dead (stale-lock recovery), never merely because it is old. ```js // second process: throws LockError await MiniDb.open({ dir: './data' }); // degrade to read-only instead of throwing const ro = await MiniDb.open({ dir: './data', onLockFail: 'readonly' }); ro.get('k'); // ok (point-in-time view) await ro.set('k'); // throws "read-only mode" // open read-only alongside a writer const r = await MiniDb.open({ dir: './data', readOnly: true }); ``` For many clients, run the **RESP server** (single minidb process, many TCP clients) — that is the intended concurrent-access model, like Redis. For a **rebuildable cache**, use `openOrRebuild`: a corrupt cache is discarded and reopened empty, while a live-locked db is never destroyed: ```js const db = await MiniDb.openOrRebuild( { dir: cacheDir, valueCodec: 'json' }, { onRebuild: (err) => log.warn('cache rebuilt:', err.message) }, ); ``` ## Caveats / roadmap - In the default `valueMode: 'memory'`, the dataset must fit in RAM Redis-style. Use `valueMode: 'disk'` for larger-than-RAM value bulk; cold reads then perform synchronous positioned reads against the snapshot/WAL. - Full-text index postings are stored on disk (larger-than-RAM); only the term dictionary and per-doc metadata stay in memory. Postings are rebuilt from the store on open and on compaction. Search reads postings synchronously, so a cold, very large postings list can briefly block the event loop. - Compaction is **non-blocking for writes**: the WAL itself acts as a `BGREWRITEAOF`-style rewrite buffer, so the (slow) snapshot is written while writers keep appending. Writes pause only for a brief final rotation (a flush, a small tail copy, and two atomic renames). - Snapshot encoding runs on the main thread (chunked + yielding); offloading to a `worker_thread` is a planned optimization. - Single process / single writer. ## Credits Design distilled from reading: Redis (`references/redis`), the SQLite WAL paper, NeDB (`references/nedb`), Bitcask (`references/bitcask`), and the cstack SQLite tutorial (`references/db_tutorial`).