mpackdb
All repositories: gitoria
18.1 KB
# MPackDBA fast, local, append-only JSON database with [MessagePack](https://msgpack.org/) serialization for Node.js.## Features- **🚀 High Performance** - Append-only writes with MessagePack binary serialization- **📇 Flexible Indexing** - Optional numeric and lexical indexes for fast queries- **🔒 Concurrent Access** - File-based locking for safe multi-process access- **💾 Auto-Persistence** - Configurable automatic index persistence- **🗜️ Auto-Compaction** - Removes deleted records on startup- **🎯 Simple API** - Intuitive CRUD operations with async/await- **📦 Source-First Package** - Ships readable ESM source with one direct runtime dependency (`msgpackr`); consumers choose how to bundle or minify it## Installation```bashnpm install mpackdb```## Quick Start```javascriptimport MPackDB from 'mpackdb';// Create a database with auto-increment numeric primary keyconst db = new MPackDB('data/users', {primaryKey: '*id', // * prefix = numeric auto-incrementindexes: ['email', '*age'] // Index email (lexical) and age (numeric)});// Insert recordsawait db.insert({ name: 'Alice', email: '[email protected]', age: 30 });await db.insert({ name: 'Bob', email: '[email protected]', age: 25 });// Find all recordsfor await (const user of db.find()) {console.log(user);}// Find by primary keyconst users = await db.find(0); // Returns array with Alice// Query with functionfor await (const user of db.find(r => r.age > 28)) {console.log(user.name); // Alice}// Update recordsawait db.update(0, r => { r.age = 31; return r; });await db.update(r => r.age < 26, r => { r.status = 'junior'; return r; });// Delete recordsawait db.delete(r => r.age < 18);// Close database (persists all changes)await db.close();```## API Reference### Constructor```javascriptnew MPackDB(dbFile, options)```**Parameters:**- `dbFile` (string): Path to database file (without extension)- `options` (object):- `primaryKey` (string): Primary key field name with optional prefix:- `*field` - Numeric auto-increment (e.g., `*id`)- `@field` - UUID (e.g., `@uuid`)- `field` - String (e.g., `username`)- `primaryKeyType` (PrimaryKeyType): Explicit type override- `indexes` (string[]): Fields to index. Prefix options:- `*field` - Numeric index- `@field` - UUID index (lexical)- `!field` - Unique index (rejects duplicates)- `!*field` - Unique numeric index- `field` - Lexical index (default)- `debug` (boolean): Enable debug logging (default: `false`)- `compact` (boolean): Run compaction on init (default: `true`, set `false` whenever another process may have the same files open)- `indexPersistInterval` (number): Auto-persist interval in ms (default: `60000`, `0` to disable)- `indexPersistThreshold` (number): Auto-persist after N changes (default: `1000`)- `staleLockTimeout` (number): Take over lock files older than this many ms — a crashed holder never removes its lock (default: `30000`, `0` to disable)**Examples:**functional style:```javascriptimport MPackDB from 'mpackdb';const db = new MPackDB('data/products', {primaryKey: '*id',indexes: ['category', '*price', '@sku'],indexPersistThreshold: 100});```OOP style:```javascriptimport { MPackDB, Model, PrimaryKeyType } from 'mpackdb';export class Product extends Model {id = 0;name = '';price = 0;category = '';sku = '';}export class Products extends MPackDB {_classToUse = Product;_primaryKey = 'id';_primaryKeyType = PrimaryKeyType.UUID;_indexes = ['category', '*price', '@sku'];}export default products = new Products('data/products');```### insert(record, options)Insert a new record into the database.```javascriptconst id = await db.insert({ name: 'Alice', age: 30 });// Returns: 0 (the generated primary-key value)```**Parameters:**- `record` (object): The record to insert- `options` (object):- `skipPrimaryKey` (boolean): Don't auto-generate primary key**Returns:** Promise<string|number|Object> - The primary-key value when a primary key is configured; otherwise the inserted record### find(query, options)Find records in the database. Returns an async iterable Cursor.The Cursor is both an async iterable (for streaming with `for await`) and a thenable (awaiting it returns an array).```javascript// Find all (streaming)for await (const record of db.find()) {console.log(record);}// Find all (array)const all = await db.find();// Find by primary keyconst users = await db.find(0);// Find with filter function (stream)for await (const user of db.find(r => r.age > 30)) {console.log(user.name);}// Find with single index — only reads records from the index rangefor await (const user of db.find(r => r.age <= 50, {index: { field: 'age', from: 30, to: 50 }})) {console.log(user.name);}// Find with index (descending)for await (const user of db.find(null, {index: { field: 'age', from: 50, direction: 'desc' }})) {if (user.age < 18) break;console.log(user.name);}// Find with index intersection — intersects offset sets, then streams only matchesconst results = await db.find(null, {index: [{ field: 'x', from: 0, to: 100 },{ field: 'status', value: 'active' }]});```**Parameters:**- `query` (undefined|string|number|Function):- `undefined/null` - Returns all records- `string/number` - Primary key value- `Function` - Filter function `(record) => boolean`- `options` (object):- `mode` (string): Return mode - `'record'`, `'raw'`, or `'mixed'`- `index` (object|array): Index hint(s) to narrow disk reads:- `field` (string): Configured indexed field name. Unknown fields reject with `error.code === 'INDEX_NOT_FOUND'`- `value` (any): Exact match lookup- `from` (any): Start key (inclusive) for range scan- `to` (any): End key (inclusive) for range scan- `direction` ('asc'|'desc'): Scan direction (default: 'asc')When `index` is an array, offset sets from each index are intersected before streaming records from disk.**Returns:** Cursor (async iterable + thenable)### update(query, callback, options)Update records matching a query. The callback receives the old record and must return the new record. There is no automatic merging — the callback has full control.```javascript// Update by primary key — modify and returnawait db.update(0, record => {record.age = 32;delete record.address;return record; // RETURN IS IMPORTANT OR YOUR RECORD WILL BE undefined});// Update with query functionawait db.update(r => r.age > 30, record => {record.status = 'senior';return record;});// Spread for partial updatesawait db.update(0, record => ({ ...record, age: 32 }));```**Parameters:**- `query` (string|number|Function): Primary key or query function- `callback` (Function): Receives old record, must return new record- `options` (object):- `upsert` (boolean): Insert if no records match- `index` (object|array): Index hint(s), same as `find()`**Returns:** Promise<Object[]> - Array of updated records### upsert(query, callback, options)Update records or insert if not found. The callback receives `{}` when inserting.```javascriptawait db.upsert(r => r.email === '[email protected]', record => ({...record,name: 'Alice',email: '[email protected]',age: 30}));```**Parameters:**- `query` (string|number|Function): Primary key or query function- `callback` (Function): Receives old record (or `{}` if inserting), must return new record- `options` (object):- `index` (object|array): Index hint(s), same as `find()`### delete(query, options)Delete records matching a query.```javascript// Delete by primary keyawait db.delete(0);// Delete with query functionawait db.delete(r => r.age < 18);// Delete with index hint (avoids full scan)await db.delete(r => r.status === 'inactive', {index: { field: 'status', value: 'inactive' }});```**Parameters:**- `query` (string|Function): Primary key or query function- `options` (object):- `index` (object|array): Index hint(s), same as `find()`**Returns:** Promise<Object[]> - Array of deleted records### compact()Compact the database by removing deleted records. This rewrites the data file without tombstones.```javascriptawait db.compact();```**Note:** Compaction happens automatically on database initialization.### boundingBox(corners, filter)Find records within a bounding box defined by 4 corner coordinates. Requires numeric indexes on `x` and `y` fields. Corners can be in any order — min/max are extracted automatically.```javascriptconst db = new MPackDB('data/sectors', {primaryKey: '*id',indexes: ['*x', '*y']});// Find all sectors in a bounding boxconst sectors = await db.boundingBox([{ x: -5, y: 5 },{ x: 5, y: 5 },{ x: 5, y: -5 },{ x: -5, y: -5 }]);// Streamingfor await (const sector of db.boundingBox([{ x: 0, y: 0 }, { x: 100, y: 0 },{ x: 100, y: 100 }, { x: 0, y: 100 }])) {console.log(sector);}// With additional filterconst active = await db.boundingBox([{ x: -5, y: 5 }, { x: 5, y: 5 },{ x: 5, y: -5 }, { x: -5, y: -5 }], s => s.status === 'active');```**Parameters:**- `corners` (Array<{x: number, y: number}>): 4 corner coordinates- `filter` (Function): Optional additional filter function**Returns:** Cursor (async iterable + thenable)### withLock(callback)Execute a callback while holding the database lock. The lock is re-entrant: `find`, `insert`, `delete`, `update` called inside the callback reuse the same lock instead of deadlocking.Use this for compound operations that must be atomic, e.g. find-then-insert.```javascriptconst user = await db.withLock(async () => {const [existing] = await db.find(u => u.email === email);if (existing) return existing;return db.insert({ email, name });});```**Parameters:**- `callback` (Function): Async function to execute under lock**Returns:** Promise<any> - The return value of the callback### close()Close the database and persist all pending changes. Should be called before process exit.```javascriptawait db.close();```## Primary Key TypesMPackDB supports three primary key types:### Numeric (Auto-increment)```javascriptconst db = new MPackDB('data/users', {primaryKey: '*id' // * prefix});await db.insert({ name: 'Alice' });// { id: 0, name: 'Alice' }```### UUID```javascriptconst db = new MPackDB('data/sessions', {primaryKey: '@sessionId' // @ prefix});await db.insert({ data: 'session data' });// { sessionId: 'lz7gdcwh9x4r', data: 'session data' }```### String```javascriptconst db = new MPackDB('data/users', {primaryKey: 'username' // No prefix});await db.insert({ username: 'alice', name: 'Alice' });// { username: 'alice', name: 'Alice' }```## IndexesIndexes dramatically improve query performance for large datasets.### Index Types- **Lexical** (default): String sorting, good for text fields- **Numeric**: Number sorting, good for integers/floats- **UUID**: Treated as lexical (string)- **Unique**: Rejects duplicate values on insert (any type)### Creating Indexes```javascriptconst db = new MPackDB('data/products', {primaryKey: '*id',indexes: ['category', // Lexical index'*price', // Numeric index'*stock', // Numeric index'@sku', // UUID index (lexical)'!email' // Unique lexical index]});```### Unique IndexesUnique indexes prevent duplicate values. Primary keys are always unique by default. Use the `!` prefix to make secondary indexes unique:```javascriptconst db = new MPackDB('data/users', {primaryKey: '*id',indexes: ['!email', '!username', '*age']});await db.insert({ email: '[email protected]', username: 'alice', age: 30 }); // okawait db.insert({ email: '[email protected]', username: 'bob', age: 25 });// throws: Error { code: 'DUPLICATE_KEY', field: 'email', value: '[email protected]' }```Combine `!` with type prefixes: `!*field` for unique numeric, `!@field` for unique UUID.Uniqueness is enforced atomically under the write lock, so concurrent inserts cannot create duplicates.### Index PersistenceIndexes are automatically persisted based on:- **Threshold**: After N changes (default: 1000)- **Interval**: Every N milliseconds (default: 60000)- **On close**: When `db.close()` is called```javascriptconst db = new MPackDB('data/users', {primaryKey: '*id',indexes: ['email'],indexPersistThreshold: 100, // Persist after 100 changesindexPersistInterval: 30000 // Persist every 30 seconds});```## File StructureMPackDB creates the following files:```data/users.mpack # Main data file (MessagePack binary)users.meta.json # Metadata (version, nextId, deleted offsets, schema)users.id.txt # Primary key indexusers.email.txt # Email field indexusers.age.txt # Age field indexusers.idxstate.json # Index coverage state (how far into the data file the indexes reach)users.lock # Lock file (temporary)```## ConcurrencyMPackDB supports concurrent reads and writes **within one process** (multiple async flows on the same instance) and **across processes** (multiple processes opening the same files).### In-processWrites on an instance are serialized through an in-process FIFO queue. The lock is *causal*: operations called inside a lock-holding call chain (`find` inside `delete`, `insert` inside `withLock`) re-enter, while unrelated concurrent operations wait their turn. Reads (`find`) are lock-free and always see a consistent snapshot — a concurrent write can never roll back another write's in-flight state.```javascript// Safe: concurrent lanes writing to the same storeawait Promise.all([db.delete(spentId),db.insert(newUtxo),(async () => { for await (const r of db.find(q)) { ... } })(),]);```For compound read-then-write operations that must be atomic, use `withLock` (see above).### Multi-processCross-process exclusion uses a lock file; visibility uses a version counter in `meta.json` plus incremental index catch-up:- `meta.json` carries a monotonic `version`. `refresh()` (run before every read and after acquiring the write lock) only reloads when another process persisted a newer version — the local in-memory state stays authoritative otherwise.- New records appended by other processes are picked up by an incremental tail scan of the data file (`<name>.idxstate.json` tracks index coverage), so **indexed queries see other processes' inserts even before they persist their index files**.- `meta.json` also stores the collection's **schema** (primary key + indexes), so tools like [mpackdb-admin](https://github.com/caramboleyo/mpackdb-admin) can open and safely write to any collection without knowing its configuration.- Stale locks from crashed processes are taken over after `staleLockTimeout` (default 30s, `0` disables). Raise it if you run operations holding the lock longer than that (e.g. compacting huge files).```javascript// Process 1 — the appconst db1 = new MPackDB('data/users', { primaryKey: '*id', compact: false });await db1.insert({ name: 'Alice' });// Process 2 — admin UI, migration script, ... (waits for lock, sees Alice)const db2 = new MPackDB('data/users', { primaryKey: '*id', compact: false });await db2.insert({ name: 'Bob' });```**Compaction rule:** when multiple processes have the same files open, run with `compact: false` and only compact when you have exclusive access. Compaction replaces the data file via `rename()`; other attached instances detect the inode change on their next `refresh()` and reopen/rebase themselves, but the window is not transactional.```javascript// Exclusive owner — may compact on startup (default)const db = new MPackDB('data/users', { primaryKey: '*id' });// Anyone sharing the files with another live process — no compactionconst admin = new MPackDB('data/users', { primaryKey: '*id', compact: false });```### Upgrading from <= 1.0.6Old databases lack the meta `version`, the persisted schema and `<name>.idxstate.json`; all three are created automatically on first use. The missing idxstate triggers a **one-time full index rebuild** on the first open — expect a longer first start on large collections.## Performance Tips1. **Use indexes** for frequently queried fields2. **Adjust persist thresholds** based on your write patterns3. **Call `compact()`** periodically if you have many deletes4. **Use numeric indexes** for number fields5. **Batch operations** when possible## Examples### User Management System```javascriptimport MPackDB from 'mpackdb';const users = new MPackDB('data/users', {primaryKey: '*id',indexes: ['email', '*age', 'role']});// Register userawait users.insert({email: '[email protected]',name: 'Alice',age: 30,role: 'admin'});// Find by emailfor await (const user of users.find(u => u.email === '[email protected]')) {console.log('Found user:', user.name);}// Get all adminsfor await (const admin of users.find(u => u.role === 'admin')) {console.log('Admin:', admin.name);}// Update ageawait users.update(u => u.email === '[email protected]', r => { r.age = 31; return r; });// Delete inactive usersawait users.delete(u => u.lastLogin < Date.now() - 90 * 24 * 60 * 60 * 1000);await users.close();```### Product Catalog```javascriptconst products = new MPackDB('data/products', {primaryKey: '*id',indexes: ['category', '*price', '@sku']});// Add productsawait products.insert({ sku: 'ABC-123', name: 'Laptop', category: 'Electronics', price: 999 });await products.insert({ sku: 'DEF-456', name: 'Mouse', category: 'Electronics', price: 29 });// Find by categoryfor await (const product of products.find(p => p.category === 'Electronics')) {console.log(product.name, product.price);}// Find products under $50for await (const product of products.find(p => p.price < 50)) {console.log('Affordable:', product.name);}// Update priceawait products.update(p => p.sku === 'ABC-123', r => { r.price = 899; return r; });await products.close();```## LicenseMIT## ContributingContributions are welcome! Please open an issue or submit a pull request.## Related Projects- [**BsonDB**](https://bsondb.gitoria.worldapi.org) - Similar database using BSON serialization
Branches
- mastermain branch
Latest commits
- 87888725release 1.0.7caramboleyo
- c4cdb9b6node: import prefixes (Deno compat) + pre-existing index-state WIPcaramboleyo
- 0afb8f4bupdate now must be a callbackcaramboleyo
- cde73eb4release 1.0.6caramboleyo
- d01dda02add index hints, intersection, boundingBox; remove findByIndexcaramboleyo
- b8ffc1a0release 1.0.5caramboleyo
- d47876a1reimplemented lost features like indexed find and more testscaramboleyo
- 7f08da9afixed insert ignoring model definitioncaramboleyo
- 705774a9added flush before findcaramboleyo
- b4db6391initial commitcaramboleyo