indinis
v0.1.2
Published
A storage library using LSM trees for storage and B-trees for indices with MVCC support
Maintainers
Readme
Indinis DB
Indinis is a high-performance, embedded NoSQL database for Node.js and TypeScript, featuring a fluent, Firestore-like API. It's designed to be fast, easy to use, and powerful enough for complex applications.
Install
Install Indinis via npm:
npm i indinisKey Features
- Fluent, Type-Safe API: A modern, chainable API that feels familiar and boosts productivity.
- High-Performance Caching: Optional in-memory cache to dramatically accelerate read-heavy workloads.
- Powerful Indexing: Automatic indexing for common fields and robust support for secondary and multikey indexes.
- Advanced Queries: Perform rich queries with filtering, sorting, pagination, and array operators (
arrayContains,arrayContainsAny). - Atomic Operations: Safe, concurrent-friendly atomic increments for counters.
- Composable Queries: Build clean, reusable, and maintainable query logic.
- Zero Dependencies: A lightweight, self-contained package.
Table of Contents
Installation
npm install indinisQuick Start
Here's a simple example to get you up and running in minutes.
import { Indinis } from 'indinis';
// Define a type for your data for type-safety
interface User {
id?: string;
name: string;
email: string;
createdAt: number;
}
async function main() {
// 1. Initialize the database
const db = new Indinis('./my-first-database');
const usersStore = db.store<User>('users');
// 2. Create a new user with an auto-generated ID
console.log('Creating a new user...');
const newUser = await usersStore.make({
name: 'Alice',
email: '[email protected]',
createdAt: Date.now()
}).withSnapshot(); // .withSnapshot() returns the full document
console.log('User created:', newUser);
// 3. Read the user back by their ID
console.log('\nFetching user by ID...');
const fetchedUser = await usersStore.item(newUser.id!).one();
console.log('Found:', fetchedUser);
// 4. Query for the user by their email
console.log('\nQuerying for user by email...');
const userFromQuery = await usersStore
.filter('email').equals('[email protected]')
.one();
console.log('Found via query:', userFromQuery);
}
main().catch(console.error);Core Concepts: Stores and Items
Indinis organizes data in a hierarchical structure familiar to users of document databases.
- Store: A container for documents, analogous to a collection in Firestore or a table. Store paths must have an odd number of segments (e.g.,
'users','users/user123/posts'). - Item: An individual document within a store. Item paths must have an even number of segments (e.g.,
'users/user123').
import { Indinis } from 'indinis';
const db = new Indinis('./my-database');
// A reference to the top-level 'users' store
const usersStore = db.store('users');
// A reference to a specific user item
const userItem = usersStore.item('user123');
// A reference to a nested 'posts' store for a user
const userPostsStore = db.store('users/user123/posts');Core API Guide
Creating Data
Use .make() to create or overwrite documents.
Auto-Generated IDs
Call .make() on a StoreRef to create a document with a unique, auto-generated ID.
const users = db.store('users');
// By default, make() resolves with the new document's ID
const newUserId = await users.make({
name: 'John Doe',
email: '[email protected]'
});
console.log('Created user with ID:', newUserId);
// Chain .withSnapshot() to get the full document back immediately
const newUserDoc = await users.make({
name: 'Jane Smith',
email: '[email protected]'
}).withSnapshot();
console.log('Created new user:', newUserDoc);
// { name: 'Jane Smith', ..., id: 'bH5iLr9tMq0xZaZ4wH3v' }Specified IDs
Call .make() on an ItemRef to create a document with an ID you provide. By default, this fails if the document already exists to prevent accidental overwrites.
const userItem = db.store('users').item('john-doe-123');
await userItem.make({ name: 'John Doe', email: '[email protected]' });
// To overwrite an existing document, pass `true` as the second argument.
await userItem.make({ name: 'John Doe V2', status: 'active' }, true);Reading Data
Fetch a Single Item by ID
The most direct way to get a document is by its ID using item(...).one().
const userData = await db.store('users').item('john-doe-123').one();
if (userData) {
console.log(userData.name); // 'John Doe V2'
}You can also efficiently check for existence with .exists().
const userExists = await db.store('users').item('john-doe-123').exists(); // true or falseUpdating Data
Partial Updates with modify()
Use .modify() on an ItemRef to update specific fields of a document without replacing the entire object. This method will fail if the document does not exist.
const userItem = db.store('users').item('john-doe-123');
// Update the user's status and add a new 'lastLogin' field
await userItem.modify({
status: 'active',
lastLogin: Date.now()
});Atomic Increments
For counters (likes, views, etc.), use the atomic increment() operator to prevent race conditions. Import the increment function from the indinis library.
Rules: The target field must exist and its value must be a number.
import { Indinis, increment } from 'indinis';
const postRef = db.store('posts').item('postXYZ');
// Atomically increment 'views' and update 'lastViewedBy' in one operation
await postRef.modify({
views: increment(1),
lastViewedBy: 'user-abc'
});
// You can also
await db.store('users').item('user123').remove();Querying Data
Build queries by starting with a store() or query() and chaining methods.
Filtering with .filter()
Use .filter() with a chainable operator to define your query conditions.
| Operator | Description |
| -------------------- | ----------------------------------- |
| .equals(val) | Field is equal to val |
| .greaterThan(val) | Field is greater than val |
| .greaterThanOrEqual(val) | Field is >= val |
| .lessThan(val) | Field is less than val |
| .lessThanOrEqual(val) | Field is <= val |
You can chain multiple .filter() calls. For a query to succeed, at least one of the filtered fields must be indexed.
const activeEngineers = await db.store<Employee>('employees')
.filter('department').equals('eng') // Uses an index
.filter('status').equals('active') // Post-filtered in memory
.filter('level').greaterThanOrEqual(5) // Post-filtered in memory
.take(10); // Retrieves up to 10 matching documentsQuerying Arrays
Indinis supports powerful queries on array fields, provided a multikey index exists on the field.
Creating a Multikey Index
// Create an index on the 'tags' array field for the 'products' store
await db.createIndex('products', 'idx_prod_tags', {
field: 'tags',
multikey: true
});arrayContains
Find documents where the array field contains a specific element.
// Find all products tagged as 'electronics'
const electronics = await db.store('products')
.filter('tags').arrayContains('electronics')
.take();arrayContainsAny
Find documents where the array field contains at least one of the elements from the provided list.
// Find products that are on 'sale' OR are a 'bestseller'
const featured = await db.store('products')
.filter('tags').arrayContainsAny(['sale', 'bestseller'])
.take();Sorting with .sortBy()
Use .sortBy() to order your query results. Sorting requires an index on the field you are sorting by.
const recentUsers = await db.store('users')
.sortBy('createdAt', 'desc') // Sort by creation date, newest first
.take(20);Pagination
Indinis provides efficient, cursor-based pagination. Pagination queries must include at least one .sortBy() clause.
The .get() method executes the query and returns a PaginatedQueryResult object containing the documents, cursors, and page information.
Example: Paginating through Users
const usersStore = db.store('users');
const PAGE_SIZE = 10;
// --- Fetch the First Page ---
const firstPage = await usersStore.query()
.sortBy('name', 'asc')
.limit(PAGE_SIZE)
.get();
console.log('First page users:', firstPage.docs.map(u => u.name));
console.log('Has next page?', firstPage.hasNextPage);
// --- Fetch the Next Page ---
if (firstPage.hasNextPage) {
const nextPage = await usersStore.query()
.sortBy('name', 'asc') // The query must be identical
.limit(PAGE_SIZE)
.startAfter(...firstPage.endCursor!) // Use the cursor from the previous page
.get();
console.log('Next page users:', nextPage.docs.map(u => u.name));
}Advanced Topics
In-Memory Data Caching
Indinis includes an optional in-memory cache to dramatically accelerate read-heavy workloads. The cache operates transparently using a cache-aside pattern: items are cached on first read, and automatically invalidated on any write (set, modify, remove) to guarantee consistency.
Enabling and Configuring the Cache
Enable the cache via IndinisOptions.
import { Indinis, IndinisOptions } from 'indinis';
const dbOptions: IndinisOptions = {
enableCache: true, // Simple enablement with defaults
// Advanced configuration
cacheOptions: {
maxSize: 5000, // Max items in cache (default: 1000)
policy: 'LRU', // Eviction policy: 'LRU' or 'LFU'
defaultTTLMilliseconds: 60000, // Expire entries after 1 minute (default: 0, no TTL)
enableStats: true // Set true to monitor performance
}
};
const db = new Indinis('./my-cached-db', dbOptions);Monitoring Cache Performance
Use db.getCacheStats() to check the cache's effectiveness. The most important metric is the hitRate.
const stats = await db.getCacheStats();
if (stats) {
console.log(`Cache Hit Rate: ${(stats.hitRate * 100).toFixed(2)}%`);
}Indexing
Indinis is designed for performance and will not perform slow, un-indexed collection scans.
Rule: No Index, No Query. A query with one or more .filter() conditions must be serviceable by at least one existing index. If no suitable index is found, the operation will throw an error.
Automatic name Index
For convenience, the first time a document is written to a new store, Indinis automatically creates a default secondary index on the name field. This allows you to start querying immediately on new collections.
Listing Indexes
You can see which indexes are available for a specific collection.
const userIndexes = await db.store('users').listIndexes();
console.log(userIndexes);Composable Queries with .use()
For complex applications, you can encapsulate and reuse query logic using .use(). Define reusable parts as functions that take and return a Query object.
import { IQuery } from 'indinis';
// A reusable part to find only active users
const isActive = (query: IQuery<User>) => query.filter('status').equals('active');
// A parameterized part to find users by region
const byRegion = (region: string) =>
(query: IQuery<User>) => query.filter('region').equals(region);
// Compose the parts to build a clean, readable query
const recentActiveWestCoastUsers = await db.store<User>('users')
.query() // Start a composable query
.use(isActive)
.use(byRegion('us-west'))
.sortBy('createdAt', 'desc')
.take(20);Configuration
You can customize the database behavior by passing an IndinisOptions object to the constructor.
import { Indinis, IndinisOptions } from 'indinis';
const dbOptions: IndinisOptions = {
enableCache: true,
cacheOptions: {
maxSize: 10000
},
// ... other options
};
const db = new Indinis('./my-database', dbOptions);