nodedb-json
Version:
A lightweight JSON-based database for Node.js with TypeScript support, indexing, and complex query capabilities
491 lines (369 loc) • 13.5 kB
Markdown
# nodedb-json
A small JSON-file database for Node.js. It is useful for local tools, examples,
desktop apps, small services, tests, and configuration-like data where a full
database server would be too much.
It stores one JSON document on disk and gives you CRUD helpers, array operations,
indexes, batch writes, and query helpers.
## Install
```bash
npm install nodedb-json
```
## Quick Start
```js
const NodedbJson = require('nodedb-json');
const db = new NodedbJson('data/db.json', {
defaultValue: { users: [] }
});
db.createIndex('users', { field: 'id', type: 'unique' });
db.push('users', { id: 1, name: 'Bob', age: 31 });
db.update('users', user => user.id === 1, { role: 'admin' });
const user = db.findByField('users', 'id', 1);
console.log(user);
```
TypeScript can type the root document with a schema:
```ts
import NodedbJson, { DuplicateIndexError } from 'nodedb-json';
type User = {
id: number;
name: string;
age?: number;
};
type AppConfig = {
theme: string;
language?: string;
};
interface Schema {
users: User[];
config: AppConfig;
}
const db = new NodedbJson<Schema>('data/db.json', {
defaultValue: {
users: [],
config: { theme: 'dark' }
}
});
const users = db.collection('users');
try {
users.createIndex({ field: 'id', type: 'unique' });
users.insert({ id: 1, name: 'Bob' });
const user = users.findOne({ id: 1 }); // User | undefined
const config = db.get('config'); // AppConfig
} catch (error) {
if (error instanceof DuplicateIndexError) {
console.error(error.message);
}
}
```
## What It Does
- Reads and writes a JSON file.
- Creates missing parent directories automatically.
- Writes through a same-directory temp file and `rename`.
- Keeps a `.bak` backup by default and can restore from it if the JSON file is corrupt.
- Creates a `.lock` file by default to prevent two live writers from opening the same database.
- Supports dot-path keys such as `config.theme`.
- Returns deep copies from read APIs so callers cannot mutate internal state by accident.
- Supports `unique` and `multi` indexes for array fields.
- Persists index definitions to a `.meta.json` file by default.
- Supports `NodedbJson<Schema>` and typed `collection()` helpers in TypeScript.
- Includes Vitest coverage and GitHub Actions for Node 18, 20, and 22.
## Important Limits
This package is still a single-file, synchronous JSON database.
- It is best used by one process / one writer at a time.
- File locking is cooperative and based on a sibling `.lock` file.
- Call `close()` when a long-lived instance is done so the lock is released.
- If `fileLock: false` is used, two live instances can overwrite each other's in-memory snapshots.
- Every save serializes the whole JSON document.
- For many small writes, use `batch()` or `autoSave: false` plus `flush()`.
- The package publishes separate ESM and CommonJS entries through `exports`.
## Constructor Options
```ts
type DbOptions = {
autoSave?: boolean; // default: true
createIfNotExists?: boolean; // default: true
defaultValue?: Partial<TSchema>;
enableIndexing?: boolean; // default: true
autoIndex?: boolean; // default: true
indexValueMode?: 'strict' | 'coerce'; // default: 'strict'
indexes?: IndexOptions;
persistIndexes?: boolean; // default: true
indexMetaPath?: string; // default: `${dbFile}.meta.json`
atomicWrites?: boolean; // default: true
backupOnWrite?: boolean; // default: true
backupPath?: string; // default: `${dbFile}.bak`
tempPath?: string; // default: `${dbFile}.tmp`
fileLock?: boolean; // default: true
lockPath?: string; // default: `${dbFile}.lock`
staleLockMs?: number; // remove old locks only when the recorded PID is gone
};
```
Example:
```js
const db = new NodedbJson('data/db.json', {
autoSave: false,
defaultValue: {
users: [],
config: { theme: 'dark' }
},
indexes: {
users: [
{ field: 'id', type: 'unique' },
{ field: 'department', type: 'multi' }
]
}
});
```
## Basic Usage
### Values And Objects
```js
db.set('config.theme', 'dark');
db.set('config.language', 'zh-CN');
console.log(db.get('config.theme')); // dark
console.log(db.has('config.language')); // true
db.update('config', { timezone: 'Asia/Shanghai' });
db.delete('config', ['language']);
```
`get()` returns a deep copy:
```js
const config = db.get('config');
config.theme = 'light';
console.log(db.get('config.theme')); // still "dark"
```
For controlled in-place style changes, use `mutate()`:
```js
db.mutate('config', config => {
config.theme = 'light';
});
```
`getUnsafeReference()` exists for advanced cases, but mutating its return value
bypasses change tracking, auto-save, and index refreshes.
### Arrays
```js
db.push('users', { id: 1, name: 'Bob', age: 31 });
db.push('users', [
{ id: 2, name: 'Charlie', age: 35 },
{ id: 3, name: 'Dave', age: 40 }
]);
const user = db.find('users', user => user.id === 2);
const adults = db.filter('users', user => user.age >= 18);
db.update('users', user => user.id === 1, { role: 'admin' });
db.delete('users', [2, 3], 'id');
```
### Collections
`collection()` is a small typed wrapper around an array path. It keeps common
array operations readable without changing the stored JSON shape:
```ts
const users = db.collection<User>('users');
users.insert({ id: 1, name: 'Bob' });
users.updateOne({ id: 1 }, { name: 'Robert' });
users.deleteOne({ id: 1 });
const activeUsers = users.findMany(user => user.active === true);
```
When the database has a schema, the item type is inferred:
```ts
const db = new NodedbJson<Schema>('data/db.json');
const users = db.collection('users'); // Collection<User>
```
### Manual Save
`autoSave` is enabled by default. Disable it when you want several changes to be
written once:
```js
const db = new NodedbJson('data/db.json', { autoSave: false });
db.set('config.theme', 'dark');
db.push('logs', { time: new Date().toISOString(), action: 'updated config' });
db.flush();
```
### Lifecycle
```js
const db = new NodedbJson('data/db.json', {
autoSave: false,
staleLockMs: 60_000
});
db.push('users', { id: 1 });
db.flush(); // force pending changes to disk
db.reload(); // read the JSON file again and rebuild indexes
db.close(); // flush and release the lock
```
Opening a database creates `db.json.lock` by default. A second instance opening
the same file throws `FileLockError` until the first one calls `close()`.
If `close()` cannot save pending changes, the instance stays open and keeps the
lock so callers can retry `flush()` or `close()`. Use `close({ force: true })`
only when you intentionally want to discard pending in-memory changes and
release the lock.
When `staleLockMs` is set, an old lock is removed only if its recorded process
is no longer alive.
If the main JSON file is corrupt and a valid `.bak` exists, the backup is
restored automatically. If both are invalid, construction throws
`CorruptedFileError`.
### Batch
`batch()` runs allowed write operations with `autoSave` temporarily disabled. If
one operation throws, in-memory data and indexes are rolled back.
```js
db.batch([
{ method: 'set', args: ['config.theme', 'dark'] },
{ method: 'push', args: ['logs', { action: 'config updated' }] }
]);
```
Allowed batch methods are `set`, `update`, `delete`, `push`, and `mutate`.
## Indexes
Indexes are for arrays of objects.
```js
db.createIndex('users', { field: 'id', type: 'unique' });
db.createIndex('users', { field: 'department', type: 'multi' });
const bob = db.findByField('users', 'id', 1);
const devUsers = db.filterByField('users', 'department', ['dev']);
db.dropIndex('users', 'department');
console.log(db.getIndexes());
```
Index types:
- `unique`: one item per field value. Duplicate non-null values throw `DuplicateIndexError`.
- `multi`: many items can share the same field value.
Index definitions created with `createIndex()` are persisted by default. You can
also define them in options:
```js
const db = new NodedbJson('data/db.json', {
indexes: {
users: [
{ field: 'id', type: 'unique' },
{ field: 'department', type: 'multi' }
]
}
});
```
Index values are type-strict by default. A numeric `1` and a string `"1"` are
different index values, which keeps indexed lookups consistent with non-indexed
`===` lookups:
```js
db.findByField('users', 'id', 1); // matches { id: 1 }
db.findByField('users', 'id', '1'); // matches { id: '1' }
```
For compatibility with older releases, set `indexValueMode: 'coerce'` to use the
old string-coercion behavior.
## Query
Use `query()` for filtering, sorting, pagination, projection, and aggregation.
Queries run as an in-memory eager pipeline over the current array.
```js
const result = db.query('users', {
where: {
department: 'dev',
age: { $gte: 18, $lt: 60 },
status: { $in: ['active', 'pending'] }
},
sort: { field: 'age', direction: 'desc' },
pagination: { page: 1, pageSize: 10 },
select: ['id', 'name', 'age'],
aggregation: [
{ type: 'count' },
{ type: 'avg', field: 'age' }
]
});
console.log(result.data);
console.log(result.pagination);
console.log(result.aggregations);
console.log(result.stats.usedIndex);
```
Object `where` conditions can use indexes when indexed equality or `$in` fields
are present. Multiple indexed fields are intersected before the remaining
conditions are checked. Function predicates are supported too, but they scan the
collection:
```js
const result = db.query('users', {
where: user => user.age >= 18 && user.active === true
});
```
Supported object operators:
```js
db.query('users', {
where: {
age: { $gte: 18, $lt: 60 },
status: { $in: ['active', 'pending'] },
name: { $startsWith: '张' }
}
});
```
Operators: `$eq`, `$ne`, `$gt`, `$gte`, `$lt`, `$lte`, `$in`, `$nin`,
`$startsWith`, `$endsWith`, `$contains`, and `$regex`.
`skip` must be a non-negative integer. `limit`, `pagination.page`, and
`pagination.pageSize` must be positive integers. When `skip`, `limit`, and
`pagination` are used together, the order is:
```txt
where -> sort -> skip -> limit -> pagination -> select
```
Aggregations run against the filtered data before `sort`, `skip`, `limit`,
`pagination`, and `select` are applied.
In TypeScript, literal `select` fields narrow the returned item type:
```ts
const result = db.query<User>('users', {
select: ['id'] as const
});
result.data[0].id; // ok
result.data[0].name; // TypeScript error
```
Dynamic `string[]` selects and nested path selects stay conservatively typed.
Convenience helpers:
```js
db.orderBy('users', { field: 'age', direction: 'desc' }, 5);
db.paginate('users', 1, 20, { department: 'dev' });
db.count('users', { active: true });
db.aggregate('users', [{ type: 'max', field: 'age' }]);
db.distinct('users', 'department');
```
## 1.4.1 Notes
This is a correctness patch for the 1.4.x line.
Fixed:
- Collection object conditions now use the same matcher as root queries.
- Persisted index definitions no longer cause false indexed lookups when
`autoIndex: false`.
- Nested object field deletion works with dot-path keys.
- `close()` no longer closes the instance or releases the lock if saving fails.
- `staleLockMs` no longer removes a lock owned by a live process.
- Group aggregations and `distinct()` no longer expose internal references.
- `$regex` queries are isolated from stateful `g` / `y` RegExp `lastIndex`.
- `createIndex()` and `dropIndex()` roll back in-memory index state if metadata
persistence fails.
- TypeScript `select` results now reflect partial selected records.
Usage impact:
- Normal runtime usage is unchanged.
- `close({ force: true })` is available when you explicitly want to discard
pending in-memory changes after a close/save failure.
- TypeScript users may now see compile errors when accessing fields that were
not selected by a literal `select` query. The runtime already returned
partial objects; the type now matches that behavior.
## 1.4.0 Reliability Notes
The reliability work adds:
- Atomic writes, backup recovery, and parent directory creation.
- Persistent index metadata.
- File locking through a sibling `.lock` file.
- `flush()`, `reload()`, and `close()`.
- `DatabaseError`, `CorruptedFileError`, and `FileLockError`.
Remaining limits:
- The database is still synchronous and single-file.
- File locking is cooperative and local-filesystem oriented.
- There is no cross-process transaction isolation.
## 1.3.1 Notes
This release focuses on correctness and test coverage.
Fixed:
- Multi-value indexes now correctly return items stored at array position `0`.
- Indexed batch deletion no longer deletes the wrong items after array indexes shift.
- Unique indexes now reject duplicate values instead of silently overwriting entries.
- Object updates now merge plain objects as documented.
- Indexed object updates now verify all query conditions before modifying an item.
- Read APIs return deep copies by default.
- Tests now fail properly through Vitest and CI.
Also included:
- Atomic JSON writes with temp files and `rename`.
- Backup creation and corrupt JSON recovery from `.bak`.
- Parent directory creation.
- Persistent index metadata.
## Development
```bash
npm run lint
npm run typecheck
npm test
npm run build
npm pack --dry-run
```
## License
MIT
## Support
Issues: <https://github.com/zxbb1190/nodedb-json/issues>
Email: <mailto:douyaj33@gmail.com>