Unified key-value storage for browser and server. Same API everywhere.
- Browser / Service Worker → IndexedDB
- Node.js → LevelDB (via classic-level)
- Database-per-table — each table is an isolated database
- Auto-create — tables are created on first
put - Binary keys & values — everything is
Uint8Array - Batch operations — multiple reads/writes coalesced into single transactions
- Chunked scan — iterate all records without loading everything into memory
- Export / Import — binary backup format with streaming support
- Idle close — unused tables close automatically after 15 seconds
- Callback-first — no promises, no async/await
- Zero dependencies —
classic-levelrequired on server (optional dependency)
npm install uni-indexeddb
On Node.js, also install the LevelDB binding:
npm install classic-level
var db = require('uni-indexeddb')({ dir: './data' });<script src="dist/uni-indexeddb.js"></script>
<script>
var db = uniIndexedDB();
</script>var db = require('uni-indexeddb')();import createDB from 'uni-indexeddb';
var db = createDB({ dir: './data' });Creates a database instance.
| Option | Default | Description |
|---|---|---|
dir |
'./data' |
Server only. Directory for LevelDB files |
Returns an object with put, get, scan, stat, count, tables, drop, export, import, close.
Batch write. Creates the table if it doesn't exist.
table— table name (string)data— array of[key, value]pairs. Keys are strings or numbers. Values areUint8Arrayornull(delete)callback(err, count)— count of operations
db.put('users', [
['user:1', new Uint8Array([1, 2, 3])],
['user:2', new Uint8Array([4, 5, 6])],
['user:3', null] // delete
], function(err, count) {
console.log(count); // 3
});Batch read.
table— table namekeys— array of keys to fetchcallback(err, rows)— rows is an object{ key: Uint8Array|null }
db.get('users', ['user:1', 'user:2'], function(err, rows) {
rows['user:1']; // Uint8Array([1, 2, 3])
rows['user:2']; // Uint8Array([4, 5, 6])
});Iterate all records in sort order, chunked by ~64KB or by limit.
table— table namecursor—nullto start, a key string to continue, or an options objectcallback(err, rows, nextCursor, done)— rows is[[key, value], ...]
// Full scan
db.scan('users', null, function(err, rows, cursor, done) {
// rows: [[key, Uint8Array], ...]
if (!done) db.scan('users', cursor, cb); // continue
});
// Scan with limit
db.scan('users', { limit: 100 }, function(err, rows, cursor, done) {
// at most 100 records per call
if (!done) db.scan('users', { limit: 100, after: cursor }, cb);
});Cursor options:
| Option | Description |
|---|---|
after |
Key to start after (same as passing a string cursor) |
limit |
Max records per chunk. 0 or omitted = byte-based chunking (~64KB) |
A table that doesn't exist returns (null, [], null, true) — same as an empty table. This is by design.
Count records in a table.
callback(err, count)— number of records
In IndexedDB this is O(1) (native store.count()). In LevelDB this is O(n) (iterator scan).
db.count('users', function(err, n) {
console.log(n); // 1500
});Table statistics.
callback(err, info)—{ count, size, status }
size is bytes on disk (LevelDB only, 0 in browser). status is 'open', 'closed', or 'error'.
List all existing tables.
callback(err, tables)— sorted array of table names
In LevelDB this reads the data directory. In IndexedDB this uses indexedDB.databases().
db.tables(function(err, list) {
console.log(list); // ['sessions', 'users']
});Delete a table and all its data.
In LevelDB this removes the directory. In IndexedDB this calls deleteDatabase().
db.drop('sessions', function(err) {
// table gone
});Streaming export to binary format. Callback is called once per scan chunk (~64KB).
callback(err, chunk, done)— chunk isUint8Array, done is boolean
// Node.js — export to file
var fs = require('fs');
var out = fs.createWriteStream('users.udb');
db.export('users', function(err, chunk, done) {
if (err) return console.error(err);
if (chunk) out.write(chunk);
if (done) out.end();
});
// Browser — export to download
var chunks = [];
db.export('users', function(err, chunk, done) {
if (chunk) chunks.push(chunk);
if (done) {
var blob = new Blob(chunks);
var a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'users.udb';
a.click();
}
});The export is best-effort — not an atomic snapshot. If writes happen during export, the result may contain a mix of old and new data. Stop writes before export if you need consistency.
Import from binary format. Two modes: one-shot and streaming.
One-shot — entire buffer in memory:
db.import('users', buf, function(err, count) {
console.log('imported', count, 'records');
});
// With replace — delete keys not in the backup after import
db.import('users', buf, { replace: true }, function(err, count) {});Streaming — for large files:
var w = db.import('users', { replace: true }, function(err, count) {
console.log('imported', count, 'records');
});
w.write(chunk1);
w.write(chunk2);
w.end();
// Node.js — import from file
var w = db.import('users', function(err, count) {});
var stream = fs.createReadStream('users.udb');
stream.on('data', function(chunk) { w.write(chunk); });
stream.on('end', function() { w.end(); });Import options:
Without replace, import is a merge — existing keys not in the backup are kept, existing keys in the backup are overwritten.
With replace: true, the table will contain exactly what was in the backup. There is no "empty window" — all records are written first, then extras are deleted.
For a clean slate (drop + import), call db.drop then db.import explicitly.
Flush pending writes and close all tables.
Export/import uses a compact binary format:
Header: 0x55 0x44 0x42 0x01 ("UDB" + version 1)
Record: [varint key_len] [key UTF-8] [varint val_len] [val bytes]
...records repeat until end of file
Varint is unsigned LEB128 (same as protobuf). The format is streaming-friendly — chunks can be concatenated to form a valid file.
Keys are encoded to Uint8Array for binary sorting:
- Numbers (and numeric strings like
"123") → 8-byte big-endian. Sorts correctly as binary - Strings → UTF-8 encoded bytes
- Uint8Array → passed through as-is
This ensures identical sort order on both IndexedDB and LevelDB.
uni-indexeddb/
├── index.js ← entry point, auto-detects environment
│ adds export/import on top of engine
└── src/
├── engine-leveldb.js ← Node.js backend (classic-level)
├── engine-indexeddb.js ← Browser/SW backend (IndexedDB)
├── keys.js ← shared key encoding
└── binary.js ← export/import binary format (varint + encode/decode)
Each table maps to:
- Server: a separate LevelDB directory under
opts.dir - Browser: a separate IndexedDB database
MIT
