- remove unused imports and variables - add void to floating promises - drop redundant return-await - bind unbound method references - replace == null with strict null/undefined checks - add explicit sort comparators and Array.from helpers - rename background_tasks capability to tasks - migrate event sink to event bus in tests |
||
|---|---|---|
| .. | ||
| bench | ||
| src | ||
| test | ||
| .gitignore | ||
| DESIGN_NOTES.md | ||
| package.json | ||
| README.md | ||
| tsconfig.json | ||
| tsdown.config.ts | ||
| vitest.config.ts | ||
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.mdfor 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, orjson. - 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
npm run build # compile src/ -> dist/ (JS + .d.ts)
npm run typecheck # tsc --noEmit (strict)
Quick start (embedded)
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 withtsx). As an installed package, import from'minidb'(the builtdist/output).
Re-open the same dir and your data is recovered from the snapshot + WAL.
Typed usage
MiniDb is generic over the value type:
interface User { name: string; age: number; city: string }
const db = await MiniDb.open<User>({ dir: './data', valueCodec: 'json' });
await db.set('u1', { name: 'Ann', age: 30, city: 'Paris' });
const u = db.get('u1'); // User | undefined
JSON + secondary indexes
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:
{ key: 'user:1234', value: { ...any JSON... }, dt1..dtN: <datetime columns> }
- 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)
db.scan({ gte: 'user:1', lte: 'user:9', limit: 100 }); // range in key order
db.prefix('user:'); // prefix scan
Datetime column queries
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:
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 valuefilterwith no matching index falls back to a full collection scan, exactly like MongoDB without an index.
Full-text search
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?}])` |
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:
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 coldget()paths.auto: at startup, compare the currentdb.snapshot+db.walsize withmaxMemoryBytes. If the persisted files exceed the budget, open asdisk; otherwise open asmemory. IfmaxMemoryBytesis not set,autofalls back tomemory.
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:
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
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, sofor (const x of xs) await db.set(...)issues one flush (and, underfsyncPolicy: 'always', one fsync) per key. Coalesce writes withdb.batch([...])/db.mset([[k, v], ...])(single atomic WAL frame) orawait Promise.all(xs.map(x => db.set(x)))(group commit collapses the tick into onewritev+ one fsync). - Keep the default
fsyncPolicy: 'everysec'. It bounds fsyncs to ~1/s.alwaysis the most durable but the hardest on flash (one fsync per flush); reserve it for data that must survive any crash. - Tune
compactThresholdBytesfor large datasets. Compaction rewrites all live data, so steady-state write amplification is roughly1 + 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, andqueryIndexHitslet you measure real write volume, fsync rate, memory pressure, and index usage under your workload.
RESP server (redis-cli compatible)
npm run server -- --dir ./data --port 6379
# or: node --import tsx src/server.ts --dir ./data --port 6379
Then in another shell:
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):
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 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.
// 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:
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. UsevalueMode: '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_threadis 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).