gitoriaLog in with ident

mpackdb

All repositories: gitoria

ReadmeCodePull requestsReleasesTicketsSettings
Commit8788872587888725release 1.0.7caramboleyo87888725/README.md

18.1 KB

  1. # MPackDB
  2. A fast, local, append-only JSON database with [MessagePack](https://msgpack.org/) serialization for Node.js.
  3. ## Features
  4. - **🚀 High Performance** - Append-only writes with MessagePack binary serialization
  5. - **📇 Flexible Indexing** - Optional numeric and lexical indexes for fast queries
  6. - **🔒 Concurrent Access** - File-based locking for safe multi-process access
  7. - **💾 Auto-Persistence** - Configurable automatic index persistence
  8. - **🗜️ Auto-Compaction** - Removes deleted records on startup
  9. - **🎯 Simple API** - Intuitive CRUD operations with async/await
  10. - **📦 Source-First Package** - Ships readable ESM source with one direct runtime dependency (`msgpackr`); consumers choose how to bundle or minify it
  11. ## Installation
  12. ```bash
  13. npm install mpackdb
  14. ```
  15. ## Quick Start
  16. ```javascript
  17. import MPackDB from 'mpackdb';
  18. // Create a database with auto-increment numeric primary key
  19. const db = new MPackDB('data/users', {
  20. primaryKey: '*id', // * prefix = numeric auto-increment
  21. indexes: ['email', '*age'] // Index email (lexical) and age (numeric)
  22. });
  23. // Insert records
  24. await db.insert({ name: 'Alice', email: '[email protected]', age: 30 });
  25. await db.insert({ name: 'Bob', email: '[email protected]', age: 25 });
  26. // Find all records
  27. for await (const user of db.find()) {
  28. console.log(user);
  29. }
  30. // Find by primary key
  31. const users = await db.find(0); // Returns array with Alice
  32. // Query with function
  33. for await (const user of db.find(r => r.age > 28)) {
  34. console.log(user.name); // Alice
  35. }
  36. // Update records
  37. await db.update(0, r => { r.age = 31; return r; });
  38. await db.update(r => r.age < 26, r => { r.status = 'junior'; return r; });
  39. // Delete records
  40. await db.delete(r => r.age < 18);
  41. // Close database (persists all changes)
  42. await db.close();
  43. ```
  44. ## API Reference
  45. ### Constructor
  46. ```javascript
  47. new MPackDB(dbFile, options)
  48. ```
  49. **Parameters:**
  50. - `dbFile` (string): Path to database file (without extension)
  51. - `options` (object):
  52. - `primaryKey` (string): Primary key field name with optional prefix:
  53. - `*field` - Numeric auto-increment (e.g., `*id`)
  54. - `@field` - UUID (e.g., `@uuid`)
  55. - `field` - String (e.g., `username`)
  56. - `primaryKeyType` (PrimaryKeyType): Explicit type override
  57. - `indexes` (string[]): Fields to index. Prefix options:
  58. - `*field` - Numeric index
  59. - `@field` - UUID index (lexical)
  60. - `!field` - Unique index (rejects duplicates)
  61. - `!*field` - Unique numeric index
  62. - `field` - Lexical index (default)
  63. - `debug` (boolean): Enable debug logging (default: `false`)
  64. - `compact` (boolean): Run compaction on init (default: `true`, set `false` whenever another process may have the same files open)
  65. - `indexPersistInterval` (number): Auto-persist interval in ms (default: `60000`, `0` to disable)
  66. - `indexPersistThreshold` (number): Auto-persist after N changes (default: `1000`)
  67. - `staleLockTimeout` (number): Take over lock files older than this many ms — a crashed holder never removes its lock (default: `30000`, `0` to disable)
  68. **Examples:**
  69. functional style:
  70. ```javascript
  71. import MPackDB from 'mpackdb';
  72. const db = new MPackDB('data/products', {
  73. primaryKey: '*id',
  74. indexes: ['category', '*price', '@sku'],
  75. indexPersistThreshold: 100
  76. });
  77. ```
  78. OOP style:
  79. ```javascript
  80. import { MPackDB, Model, PrimaryKeyType } from 'mpackdb';
  81. export class Product extends Model {
  82. id = 0;
  83. name = '';
  84. price = 0;
  85. category = '';
  86. sku = '';
  87. }
  88. export class Products extends MPackDB {
  89. _classToUse = Product;
  90. _primaryKey = 'id';
  91. _primaryKeyType = PrimaryKeyType.UUID;
  92. _indexes = ['category', '*price', '@sku'];
  93. }
  94. export default products = new Products('data/products');
  95. ```
  96. ### insert(record, options)
  97. Insert a new record into the database.
  98. ```javascript
  99. const id = await db.insert({ name: 'Alice', age: 30 });
  100. // Returns: 0 (the generated primary-key value)
  101. ```
  102. **Parameters:**
  103. - `record` (object): The record to insert
  104. - `options` (object):
  105. - `skipPrimaryKey` (boolean): Don't auto-generate primary key
  106. **Returns:** Promise<string|number|Object> - The primary-key value when a primary key is configured; otherwise the inserted record
  107. ### find(query, options)
  108. Find records in the database. Returns an async iterable Cursor.
  109. The Cursor is both an async iterable (for streaming with `for await`) and a thenable (awaiting it returns an array).
  110. ```javascript
  111. // Find all (streaming)
  112. for await (const record of db.find()) {
  113. console.log(record);
  114. }
  115. // Find all (array)
  116. const all = await db.find();
  117. // Find by primary key
  118. const users = await db.find(0);
  119. // Find with filter function (stream)
  120. for await (const user of db.find(r => r.age > 30)) {
  121. console.log(user.name);
  122. }
  123. // Find with single index — only reads records from the index range
  124. for await (const user of db.find(r => r.age <= 50, {
  125. index: { field: 'age', from: 30, to: 50 }
  126. })) {
  127. console.log(user.name);
  128. }
  129. // Find with index (descending)
  130. for await (const user of db.find(null, {
  131. index: { field: 'age', from: 50, direction: 'desc' }
  132. })) {
  133. if (user.age < 18) break;
  134. console.log(user.name);
  135. }
  136. // Find with index intersection — intersects offset sets, then streams only matches
  137. const results = await db.find(null, {
  138. index: [
  139. { field: 'x', from: 0, to: 100 },
  140. { field: 'status', value: 'active' }
  141. ]
  142. });
  143. ```
  144. **Parameters:**
  145. - `query` (undefined|string|number|Function):
  146. - `undefined/null` - Returns all records
  147. - `string/number` - Primary key value
  148. - `Function` - Filter function `(record) => boolean`
  149. - `options` (object):
  150. - `mode` (string): Return mode - `'record'`, `'raw'`, or `'mixed'`
  151. - `index` (object|array): Index hint(s) to narrow disk reads:
  152. - `field` (string): Configured indexed field name. Unknown fields reject with `error.code === 'INDEX_NOT_FOUND'`
  153. - `value` (any): Exact match lookup
  154. - `from` (any): Start key (inclusive) for range scan
  155. - `to` (any): End key (inclusive) for range scan
  156. - `direction` ('asc'|'desc'): Scan direction (default: 'asc')
  157. When `index` is an array, offset sets from each index are intersected before streaming records from disk.
  158. **Returns:** Cursor (async iterable + thenable)
  159. ### update(query, callback, options)
  160. 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.
  161. ```javascript
  162. // Update by primary key — modify and return
  163. await db.update(0, record => {
  164. record.age = 32;
  165. delete record.address;
  166. return record; // RETURN IS IMPORTANT OR YOUR RECORD WILL BE undefined
  167. });
  168. // Update with query function
  169. await db.update(r => r.age > 30, record => {
  170. record.status = 'senior';
  171. return record;
  172. });
  173. // Spread for partial updates
  174. await db.update(0, record => ({ ...record, age: 32 }));
  175. ```
  176. **Parameters:**
  177. - `query` (string|number|Function): Primary key or query function
  178. - `callback` (Function): Receives old record, must return new record
  179. - `options` (object):
  180. - `upsert` (boolean): Insert if no records match
  181. - `index` (object|array): Index hint(s), same as `find()`
  182. **Returns:** Promise<Object[]> - Array of updated records
  183. ### upsert(query, callback, options)
  184. Update records or insert if not found. The callback receives `{}` when inserting.
  185. ```javascript
  186. await db.upsert(r => r.email === '[email protected]', record => ({
  187. ...record,
  188. name: 'Alice',
  189. email: '[email protected]',
  190. age: 30
  191. }));
  192. ```
  193. **Parameters:**
  194. - `query` (string|number|Function): Primary key or query function
  195. - `callback` (Function): Receives old record (or `{}` if inserting), must return new record
  196. - `options` (object):
  197. - `index` (object|array): Index hint(s), same as `find()`
  198. ### delete(query, options)
  199. Delete records matching a query.
  200. ```javascript
  201. // Delete by primary key
  202. await db.delete(0);
  203. // Delete with query function
  204. await db.delete(r => r.age < 18);
  205. // Delete with index hint (avoids full scan)
  206. await db.delete(r => r.status === 'inactive', {
  207. index: { field: 'status', value: 'inactive' }
  208. });
  209. ```
  210. **Parameters:**
  211. - `query` (string|Function): Primary key or query function
  212. - `options` (object):
  213. - `index` (object|array): Index hint(s), same as `find()`
  214. **Returns:** Promise<Object[]> - Array of deleted records
  215. ### compact()
  216. Compact the database by removing deleted records. This rewrites the data file without tombstones.
  217. ```javascript
  218. await db.compact();
  219. ```
  220. **Note:** Compaction happens automatically on database initialization.
  221. ### boundingBox(corners, filter)
  222. 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.
  223. ```javascript
  224. const db = new MPackDB('data/sectors', {
  225. primaryKey: '*id',
  226. indexes: ['*x', '*y']
  227. });
  228. // Find all sectors in a bounding box
  229. const sectors = await db.boundingBox([
  230. { x: -5, y: 5 },
  231. { x: 5, y: 5 },
  232. { x: 5, y: -5 },
  233. { x: -5, y: -5 }
  234. ]);
  235. // Streaming
  236. for await (const sector of db.boundingBox([
  237. { x: 0, y: 0 }, { x: 100, y: 0 },
  238. { x: 100, y: 100 }, { x: 0, y: 100 }
  239. ])) {
  240. console.log(sector);
  241. }
  242. // With additional filter
  243. const active = await db.boundingBox([
  244. { x: -5, y: 5 }, { x: 5, y: 5 },
  245. { x: 5, y: -5 }, { x: -5, y: -5 }
  246. ], s => s.status === 'active');
  247. ```
  248. **Parameters:**
  249. - `corners` (Array<{x: number, y: number}>): 4 corner coordinates
  250. - `filter` (Function): Optional additional filter function
  251. **Returns:** Cursor (async iterable + thenable)
  252. ### withLock(callback)
  253. 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.
  254. Use this for compound operations that must be atomic, e.g. find-then-insert.
  255. ```javascript
  256. const user = await db.withLock(async () => {
  257. const [existing] = await db.find(u => u.email === email);
  258. if (existing) return existing;
  259. return db.insert({ email, name });
  260. });
  261. ```
  262. **Parameters:**
  263. - `callback` (Function): Async function to execute under lock
  264. **Returns:** Promise<any> - The return value of the callback
  265. ### close()
  266. Close the database and persist all pending changes. Should be called before process exit.
  267. ```javascript
  268. await db.close();
  269. ```
  270. ## Primary Key Types
  271. MPackDB supports three primary key types:
  272. ### Numeric (Auto-increment)
  273. ```javascript
  274. const db = new MPackDB('data/users', {
  275. primaryKey: '*id' // * prefix
  276. });
  277. await db.insert({ name: 'Alice' });
  278. // { id: 0, name: 'Alice' }
  279. ```
  280. ### UUID
  281. ```javascript
  282. const db = new MPackDB('data/sessions', {
  283. primaryKey: '@sessionId' // @ prefix
  284. });
  285. await db.insert({ data: 'session data' });
  286. // { sessionId: 'lz7gdcwh9x4r', data: 'session data' }
  287. ```
  288. ### String
  289. ```javascript
  290. const db = new MPackDB('data/users', {
  291. primaryKey: 'username' // No prefix
  292. });
  293. await db.insert({ username: 'alice', name: 'Alice' });
  294. // { username: 'alice', name: 'Alice' }
  295. ```
  296. ## Indexes
  297. Indexes dramatically improve query performance for large datasets.
  298. ### Index Types
  299. - **Lexical** (default): String sorting, good for text fields
  300. - **Numeric**: Number sorting, good for integers/floats
  301. - **UUID**: Treated as lexical (string)
  302. - **Unique**: Rejects duplicate values on insert (any type)
  303. ### Creating Indexes
  304. ```javascript
  305. const db = new MPackDB('data/products', {
  306. primaryKey: '*id',
  307. indexes: [
  308. 'category', // Lexical index
  309. '*price', // Numeric index
  310. '*stock', // Numeric index
  311. '@sku', // UUID index (lexical)
  312. '!email' // Unique lexical index
  313. ]
  314. });
  315. ```
  316. ### Unique Indexes
  317. Unique indexes prevent duplicate values. Primary keys are always unique by default. Use the `!` prefix to make secondary indexes unique:
  318. ```javascript
  319. const db = new MPackDB('data/users', {
  320. primaryKey: '*id',
  321. indexes: ['!email', '!username', '*age']
  322. });
  323. await db.insert({ email: '[email protected]', username: 'alice', age: 30 }); // ok
  324. await db.insert({ email: '[email protected]', username: 'bob', age: 25 });
  325. // throws: Error { code: 'DUPLICATE_KEY', field: 'email', value: '[email protected]' }
  326. ```
  327. Combine `!` with type prefixes: `!*field` for unique numeric, `!@field` for unique UUID.
  328. Uniqueness is enforced atomically under the write lock, so concurrent inserts cannot create duplicates.
  329. ### Index Persistence
  330. Indexes are automatically persisted based on:
  331. - **Threshold**: After N changes (default: 1000)
  332. - **Interval**: Every N milliseconds (default: 60000)
  333. - **On close**: When `db.close()` is called
  334. ```javascript
  335. const db = new MPackDB('data/users', {
  336. primaryKey: '*id',
  337. indexes: ['email'],
  338. indexPersistThreshold: 100, // Persist after 100 changes
  339. indexPersistInterval: 30000 // Persist every 30 seconds
  340. });
  341. ```
  342. ## File Structure
  343. MPackDB creates the following files:
  344. ```
  345. data/
  346. users.mpack # Main data file (MessagePack binary)
  347. users.meta.json # Metadata (version, nextId, deleted offsets, schema)
  348. users.id.txt # Primary key index
  349. users.email.txt # Email field index
  350. users.age.txt # Age field index
  351. users.idxstate.json # Index coverage state (how far into the data file the indexes reach)
  352. users.lock # Lock file (temporary)
  353. ```
  354. ## Concurrency
  355. MPackDB supports concurrent reads and writes **within one process** (multiple async flows on the same instance) and **across processes** (multiple processes opening the same files).
  356. ### In-process
  357. Writes 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.
  358. ```javascript
  359. // Safe: concurrent lanes writing to the same store
  360. await Promise.all([
  361. db.delete(spentId),
  362. db.insert(newUtxo),
  363. (async () => { for await (const r of db.find(q)) { ... } })(),
  364. ]);
  365. ```
  366. For compound read-then-write operations that must be atomic, use `withLock` (see above).
  367. ### Multi-process
  368. Cross-process exclusion uses a lock file; visibility uses a version counter in `meta.json` plus incremental index catch-up:
  369. - `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.
  370. - 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**.
  371. - `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.
  372. - 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).
  373. ```javascript
  374. // Process 1 — the app
  375. const db1 = new MPackDB('data/users', { primaryKey: '*id', compact: false });
  376. await db1.insert({ name: 'Alice' });
  377. // Process 2 — admin UI, migration script, ... (waits for lock, sees Alice)
  378. const db2 = new MPackDB('data/users', { primaryKey: '*id', compact: false });
  379. await db2.insert({ name: 'Bob' });
  380. ```
  381. **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.
  382. ```javascript
  383. // Exclusive owner — may compact on startup (default)
  384. const db = new MPackDB('data/users', { primaryKey: '*id' });
  385. // Anyone sharing the files with another live process — no compaction
  386. const admin = new MPackDB('data/users', { primaryKey: '*id', compact: false });
  387. ```
  388. ### Upgrading from <= 1.0.6
  389. Old 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.
  390. ## Performance Tips
  391. 1. **Use indexes** for frequently queried fields
  392. 2. **Adjust persist thresholds** based on your write patterns
  393. 3. **Call `compact()`** periodically if you have many deletes
  394. 4. **Use numeric indexes** for number fields
  395. 5. **Batch operations** when possible
  396. ## Examples
  397. ### User Management System
  398. ```javascript
  399. import MPackDB from 'mpackdb';
  400. const users = new MPackDB('data/users', {
  401. primaryKey: '*id',
  402. indexes: ['email', '*age', 'role']
  403. });
  404. // Register user
  405. await users.insert({
  406. email: '[email protected]',
  407. name: 'Alice',
  408. age: 30,
  409. role: 'admin'
  410. });
  411. // Find by email
  412. for await (const user of users.find(u => u.email === '[email protected]')) {
  413. console.log('Found user:', user.name);
  414. }
  415. // Get all admins
  416. for await (const admin of users.find(u => u.role === 'admin')) {
  417. console.log('Admin:', admin.name);
  418. }
  419. // Update age
  420. await users.update(u => u.email === '[email protected]', r => { r.age = 31; return r; });
  421. // Delete inactive users
  422. await users.delete(u => u.lastLogin < Date.now() - 90 * 24 * 60 * 60 * 1000);
  423. await users.close();
  424. ```
  425. ### Product Catalog
  426. ```javascript
  427. const products = new MPackDB('data/products', {
  428. primaryKey: '*id',
  429. indexes: ['category', '*price', '@sku']
  430. });
  431. // Add products
  432. await products.insert({ sku: 'ABC-123', name: 'Laptop', category: 'Electronics', price: 999 });
  433. await products.insert({ sku: 'DEF-456', name: 'Mouse', category: 'Electronics', price: 29 });
  434. // Find by category
  435. for await (const product of products.find(p => p.category === 'Electronics')) {
  436. console.log(product.name, product.price);
  437. }
  438. // Find products under $50
  439. for await (const product of products.find(p => p.price < 50)) {
  440. console.log('Affordable:', product.name);
  441. }
  442. // Update price
  443. await products.update(p => p.sku === 'ABC-123', r => { r.price = 899; return r; });
  444. await products.close();
  445. ```
  446. ## License
  447. MIT
  448. ## Contributing
  449. Contributions are welcome! Please open an issue or submit a pull request.
  450. ## Related Projects
  451. - [**BsonDB**](https://bsondb.gitoria.worldapi.org) - Similar database using BSON serialization

Branches

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