gitoriaLog in with ident

mpackdb

All repositories: gitoria

ReadmeCodePull requestsReleasesTicketsSettings
Commitc4cdb9b6c4cdb9b6node: import prefixes (Deno compat) + pre-existing index-state WIPcaramboleyoc4cdb9b6/README.md

15.7 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. - **📦 Zero Dependencies** - Only requires `msgpackr`
  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` for read-only / secondary instances)
  65. - `indexPersistInterval` (number): Auto-persist interval in ms (default: `60000`, `0` to disable)
  66. - `indexPersistThreshold` (number): Auto-persist after N changes (default: `1000`)
  67. **Examples:**
  68. functional style:
  69. ```javascript
  70. import MPackDB from '@worldapi.org/mpackdb';
  71. const db = new MPackDB('data/products', {
  72. primaryKey: '*id',
  73. indexes: ['category', '*price', '@sku'],
  74. indexPersistThreshold: 100
  75. });
  76. ```
  77. OOP style:
  78. ```javascript
  79. import { MPackDB, Model, PrimaryKeyType } from 'mpackdb';
  80. export class Product extends Model {
  81. id = 0;
  82. name = '';
  83. price = 0;
  84. category = '';
  85. sku = '';
  86. }
  87. export class Products extends MPackDB {
  88. _classToUse = Product;
  89. _primaryKey = 'id';
  90. _primaryKeyType = PrimaryKeyType.UUID;
  91. _indexes = ['category', '*price', '@sku'];
  92. }
  93. export default products = new Products('data/products');
  94. ```
  95. ### insert(record, options)
  96. Insert a new record into the database.
  97. ```javascript
  98. await db.insert({ name: 'Alice', age: 30 });
  99. // Returns: { id: 0, name: 'Alice', age: 30 }
  100. ```
  101. **Parameters:**
  102. - `record` (object): The record to insert
  103. - `options` (object):
  104. - `skipPrimaryKey` (boolean): Don't auto-generate primary key
  105. **Returns:** Promise<Object> - The inserted record with primary key
  106. ### find(query, options)
  107. Find records in the database. Returns an async iterable Cursor.
  108. The Cursor is both an async iterable (for streaming with `for await`) and a thenable (awaiting it returns an array).
  109. ```javascript
  110. // Find all (streaming)
  111. for await (const record of db.find()) {
  112. console.log(record);
  113. }
  114. // Find all (array)
  115. const all = await db.find();
  116. // Find by primary key
  117. const users = await db.find(0);
  118. // Find with filter function (stream)
  119. for await (const user of db.find(r => r.age > 30)) {
  120. console.log(user.name);
  121. }
  122. // Find with single index — only reads records from the index range
  123. for await (const user of db.find(r => r.age <= 50, {
  124. index: { field: 'age', from: 30, to: 50 }
  125. })) {
  126. console.log(user.name);
  127. }
  128. // Find with index (descending)
  129. for await (const user of db.find(null, {
  130. index: { field: 'age', from: 50, direction: 'desc' }
  131. })) {
  132. if (user.age < 18) break;
  133. console.log(user.name);
  134. }
  135. // Find with index intersection — intersects offset sets, then streams only matches
  136. const results = await db.find(null, {
  137. index: [
  138. { field: 'x', from: 0, to: 100 },
  139. { field: 'status', value: 'active' }
  140. ]
  141. });
  142. ```
  143. **Parameters:**
  144. - `query` (undefined|string|number|Function):
  145. - `undefined/null` - Returns all records
  146. - `string/number` - Primary key value
  147. - `Function` - Filter function `(record) => boolean`
  148. - `options` (object):
  149. - `mode` (string): Return mode - `'record'`, `'raw'`, or `'mixed'`
  150. - `index` (object|array): Index hint(s) to narrow disk reads:
  151. - `field` (string): Indexed field name
  152. - `value` (any): Exact match lookup
  153. - `from` (any): Start key (inclusive) for range scan
  154. - `to` (any): End key (inclusive) for range scan
  155. - `direction` ('asc'|'desc'): Scan direction (default: 'asc')
  156. When `index` is an array, offset sets from each index are intersected before streaming records from disk.
  157. **Returns:** Cursor (async iterable + thenable)
  158. ### update(query, callback, options)
  159. 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.
  160. ```javascript
  161. // Update by primary key — modify and return
  162. await db.update(0, record => {
  163. record.age = 32;
  164. delete record.address;
  165. return record; // RETURN IS IMPORTANT OR YOUR RECORD WILL BE undefined
  166. });
  167. // Update with query function
  168. await db.update(r => r.age > 30, record => {
  169. record.status = 'senior';
  170. return record;
  171. });
  172. // Spread for partial updates
  173. await db.update(0, record => ({ ...record, age: 32 }));
  174. ```
  175. **Parameters:**
  176. - `query` (string|number|Function): Primary key or query function
  177. - `callback` (Function): Receives old record, must return new record
  178. - `options` (object):
  179. - `upsert` (boolean): Insert if no records match
  180. - `index` (object|array): Index hint(s), same as `find()`
  181. **Returns:** Promise<Object[]> - Array of updated records
  182. ### upsert(query, callback, options)
  183. Update records or insert if not found. The callback receives `{}` when inserting.
  184. ```javascript
  185. await db.upsert(r => r.email === '[email protected]', record => ({
  186. ...record,
  187. name: 'Alice',
  188. email: '[email protected]',
  189. age: 30
  190. }));
  191. ```
  192. **Parameters:**
  193. - `query` (string|number|Function): Primary key or query function
  194. - `callback` (Function): Receives old record (or `{}` if inserting), must return new record
  195. - `options` (object):
  196. - `index` (object|array): Index hint(s), same as `find()`
  197. ### delete(query, options)
  198. Delete records matching a query.
  199. ```javascript
  200. // Delete by primary key
  201. await db.delete(0);
  202. // Delete with query function
  203. await db.delete(r => r.age < 18);
  204. // Delete with index hint (avoids full scan)
  205. await db.delete(r => r.status === 'inactive', {
  206. index: { field: 'status', value: 'inactive' }
  207. });
  208. ```
  209. **Parameters:**
  210. - `query` (string|Function): Primary key or query function
  211. - `options` (object):
  212. - `index` (object|array): Index hint(s), same as `find()`
  213. **Returns:** Promise<Object[]> - Array of deleted records
  214. ### compact()
  215. Compact the database by removing deleted records. This rewrites the data file without tombstones.
  216. ```javascript
  217. await db.compact();
  218. ```
  219. **Note:** Compaction happens automatically on database initialization.
  220. ### boundingBox(corners, filter)
  221. 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.
  222. ```javascript
  223. const db = new MPackDB('data/sectors', {
  224. primaryKey: '*id',
  225. indexes: ['*x', '*y']
  226. });
  227. // Find all sectors in a bounding box
  228. const sectors = await db.boundingBox([
  229. { x: -5, y: 5 },
  230. { x: 5, y: 5 },
  231. { x: 5, y: -5 },
  232. { x: -5, y: -5 }
  233. ]);
  234. // Streaming
  235. for await (const sector of db.boundingBox([
  236. { x: 0, y: 0 }, { x: 100, y: 0 },
  237. { x: 100, y: 100 }, { x: 0, y: 100 }
  238. ])) {
  239. console.log(sector);
  240. }
  241. // With additional filter
  242. const active = await db.boundingBox([
  243. { x: -5, y: 5 }, { x: 5, y: 5 },
  244. { x: 5, y: -5 }, { x: -5, y: -5 }
  245. ], s => s.status === 'active');
  246. ```
  247. **Parameters:**
  248. - `corners` (Array<{x: number, y: number}>): 4 corner coordinates
  249. - `filter` (Function): Optional additional filter function
  250. **Returns:** Cursor (async iterable + thenable)
  251. ### withLock(callback)
  252. 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.
  253. Use this for compound operations that must be atomic, e.g. find-then-insert.
  254. ```javascript
  255. const user = await db.withLock(async () => {
  256. const [existing] = await db.find(u => u.email === email);
  257. if (existing) return existing;
  258. return db.insert({ email, name });
  259. });
  260. ```
  261. **Parameters:**
  262. - `callback` (Function): Async function to execute under lock
  263. **Returns:** Promise<any> - The return value of the callback
  264. ### close()
  265. Close the database and persist all pending changes. Should be called before process exit.
  266. ```javascript
  267. await db.close();
  268. ```
  269. ## Primary Key Types
  270. MPackDB supports three primary key types:
  271. ### Numeric (Auto-increment)
  272. ```javascript
  273. const db = new MPackDB('data/users', {
  274. primaryKey: '*id' // * prefix
  275. });
  276. await db.insert({ name: 'Alice' });
  277. // { id: 0, name: 'Alice' }
  278. ```
  279. ### UUID
  280. ```javascript
  281. const db = new MPackDB('data/sessions', {
  282. primaryKey: '@sessionId' // @ prefix
  283. });
  284. await db.insert({ data: 'session data' });
  285. // { sessionId: 'lz7gdcwh9x4r', data: 'session data' }
  286. ```
  287. ### String
  288. ```javascript
  289. const db = new MPackDB('data/users', {
  290. primaryKey: 'username' // No prefix
  291. });
  292. await db.insert({ username: 'alice', name: 'Alice' });
  293. // { username: 'alice', name: 'Alice' }
  294. ```
  295. ## Indexes
  296. Indexes dramatically improve query performance for large datasets.
  297. ### Index Types
  298. - **Lexical** (default): String sorting, good for text fields
  299. - **Numeric**: Number sorting, good for integers/floats
  300. - **UUID**: Treated as lexical (string)
  301. - **Unique**: Rejects duplicate values on insert (any type)
  302. ### Creating Indexes
  303. ```javascript
  304. const db = new MPackDB('data/products', {
  305. primaryKey: '*id',
  306. indexes: [
  307. 'category', // Lexical index
  308. '*price', // Numeric index
  309. '*stock', // Numeric index
  310. '@sku', // UUID index (lexical)
  311. '!email' // Unique lexical index
  312. ]
  313. });
  314. ```
  315. ### Unique Indexes
  316. Unique indexes prevent duplicate values. Primary keys are always unique by default. Use the `!` prefix to make secondary indexes unique:
  317. ```javascript
  318. const db = new MPackDB('data/users', {
  319. primaryKey: '*id',
  320. indexes: ['!email', '!username', '*age']
  321. });
  322. await db.insert({ email: '[email protected]', username: 'alice', age: 30 }); // ok
  323. await db.insert({ email: '[email protected]', username: 'bob', age: 25 });
  324. // throws: Error { code: 'DUPLICATE_KEY', field: 'email', value: '[email protected]' }
  325. ```
  326. Combine `!` with type prefixes: `!*field` for unique numeric, `!@field` for unique UUID.
  327. Uniqueness is enforced atomically under the write lock, so concurrent inserts cannot create duplicates.
  328. ### Index Persistence
  329. Indexes are automatically persisted based on:
  330. - **Threshold**: After N changes (default: 1000)
  331. - **Interval**: Every N milliseconds (default: 60000)
  332. - **On close**: When `db.close()` is called
  333. ```javascript
  334. const db = new MPackDB('data/users', {
  335. primaryKey: '*id',
  336. indexes: ['email'],
  337. indexPersistThreshold: 100, // Persist after 100 changes
  338. indexPersistInterval: 30000 // Persist every 30 seconds
  339. });
  340. ```
  341. ## File Structure
  342. MPackDB creates the following files:
  343. ```
  344. data/
  345. users.mpack # Main data file (MessagePack binary)
  346. users.meta.json # Metadata (nextId, deleted offsets)
  347. users.id.txt # Primary key index
  348. users.email.txt # Email field index
  349. users.age.txt # Age field index
  350. users.lock # Lock file (temporary)
  351. ```
  352. ## Concurrency
  353. MPackDB uses file-based locking to ensure safe concurrent access:
  354. ```javascript
  355. // Process 1
  356. const db1 = new MPackDB('data/users', { primaryKey: '*id' });
  357. await db1.insert({ name: 'Alice' });
  358. // Process 2 (waits for lock)
  359. const db2 = new MPackDB('data/users', { primaryKey: '*id' });
  360. await db2.insert({ name: 'Bob' });
  361. ```
  362. ### Multi-process reads
  363. MPackDB automatically re-reads `meta.json` from disk before every read operation (`refresh()`). This means a second process (e.g. an admin UI) opening the same database files will always see the latest state, including records deleted by the main application. Without this, the in-memory `_meta.deleted` array would go stale and deleted records would reappear in query results.
  364. You can also call `db.refresh()` manually if needed.
  365. **Important:** Secondary/read-only instances should disable compaction with `compact: false`. Compaction replaces the data file via `rename()`, which invalidates the write stream of any other instance pointing at the old file inode.
  366. ```javascript
  367. // Main app — owns the data, compacts on startup (default)
  368. const db = new MPackDB('data/users', { primaryKey: '*id' });
  369. // Admin UI or secondary reader — no compaction
  370. const admin = new MPackDB('data/users', { primaryKey: '*id', compact: false });
  371. ```
  372. ## Performance Tips
  373. 1. **Use indexes** for frequently queried fields
  374. 2. **Adjust persist thresholds** based on your write patterns
  375. 3. **Call `compact()`** periodically if you have many deletes
  376. 4. **Use numeric indexes** for number fields
  377. 5. **Batch operations** when possible
  378. ## Examples
  379. ### User Management System
  380. ```javascript
  381. import MPackDB from '@worldapi.org/mpackdb';
  382. const users = new MPackDB('data/users', {
  383. primaryKey: '*id',
  384. indexes: ['email', '*age', 'role']
  385. });
  386. // Register user
  387. await users.insert({
  388. email: '[email protected]',
  389. name: 'Alice',
  390. age: 30,
  391. role: 'admin'
  392. });
  393. // Find by email
  394. for await (const user of users.find(u => u.email === '[email protected]')) {
  395. console.log('Found user:', user.name);
  396. }
  397. // Get all admins
  398. for await (const admin of users.find(u => u.role === 'admin')) {
  399. console.log('Admin:', admin.name);
  400. }
  401. // Update age
  402. await users.update(u => u.email === '[email protected]', r => { r.age = 31; return r; });
  403. // Delete inactive users
  404. await users.delete(u => u.lastLogin < Date.now() - 90 * 24 * 60 * 60 * 1000);
  405. await users.close();
  406. ```
  407. ### Product Catalog
  408. ```javascript
  409. const products = new MPackDB('data/products', {
  410. primaryKey: '*id',
  411. indexes: ['category', '*price', '@sku']
  412. });
  413. // Add products
  414. await products.insert({ sku: 'ABC-123', name: 'Laptop', category: 'Electronics', price: 999 });
  415. await products.insert({ sku: 'DEF-456', name: 'Mouse', category: 'Electronics', price: 29 });
  416. // Find by category
  417. for await (const product of products.find(p => p.category === 'Electronics')) {
  418. console.log(product.name, product.price);
  419. }
  420. // Find products under $50
  421. for await (const product of products.find(p => p.price < 50)) {
  422. console.log('Affordable:', product.name);
  423. }
  424. // Update price
  425. await products.update(p => p.sku === 'ABC-123', r => { r.price = 899; return r; });
  426. await products.close();
  427. ```
  428. ## License
  429. MIT
  430. ## Contributing
  431. Contributions are welcome! Please open an issue or submit a pull request.
  432. ## Related Projects
  433. - [**BsonDB**](https://bsondb.gitoria.worldapi.org) - Similar database using BSON serialization

Branches

Latest commits

  • 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