OIMDB: Reactive In-Memory Database for JavaScript
OIMDB (Object In-Memory Database) is a reactive in-memory database library that provides normalized entity storage, intelligent indexing, and automatic change notifications. Unlike traditional state managers that copy entire state trees, OIMDB uses O(1) Map-based lookups and efficient event coalescing to deliver high-performance state management.
Why OIMDB?
Traditional state management approaches like Redux or MobX have limitations:
- Tree copying overhead: Immutable updates require copying large state trees, causing GC pressure
- No built-in indexing: Querying related data requires manual filtering or memoization
- Fragile batching: UI updates are batched inconsistently across frameworks
- Complex coordination: Multiple reducers need ad-hoc messaging to coordinate updates
OIMDB solves these problems with:
- Normalized storage: Entities stored by primary key in Maps (O(1) lookups)
- Reactive indexes: Manual indexes for efficient queries (e.g., "all posts by author")
- Event coalescing: Multiple rapid updates to the same entity trigger only one notification
- Configurable scheduling: Choose when events fire (microtask, animationFrame, timeout, immediate)
Installation
npm install @oimdb/core
Core Concepts
Collections: Normalized Entity Storage
Collections store entities by primary key, providing O(1) lookups:
import {
OIMReactiveCollection,
OIMEventQueue,
OIMEventQueueSchedulerFactory
} from '@oimdb/core';
interface User {
id: string;
name: string;
email: string;
}
// Create event queue with microtask scheduler (most common)
const queue = new OIMEventQueue({
scheduler: OIMEventQueueSchedulerFactory.createMicrotask()
});
// Create reactive collection
const users = new OIMReactiveCollection<User, string>(queue, {
selectPk: (user) => user.id
});
// CRUD operations
users.upsertOne({ id: 'user1', name: 'John Doe', email: 'john@example.com' });
users.upsertMany([
{ id: 'user2', name: 'Jane Smith', email: 'jane@example.com' },
{ id: 'user3', name: 'Bob Wilson', email: 'bob@example.com' }
]);
// O(1) lookups
const user = users.getOneByPk('user1');
const multipleUsers = users.getManyByPks(['user1', 'user2']);
Reactive Updates: Key-Specific Subscriptions
Subscribe to changes for specific entities:
// Subscribe to changes for a specific user
users.updateEventEmitter.subscribeOnKey('user1', () => {
console.log('User1 changed!');
});
// Subscribe to changes for multiple users
users.updateEventEmitter.subscribeOnKeys(['user1', 'user2'], () => {
console.log('Users changed!');
});
// Updates trigger notifications
users.upsertOne({ id: 'user1', name: 'John Updated' });
// Notification fires in next microtask
Indexes: Efficient Queries
OIMDB provides two index types optimized for different use cases:
SetBased Indexes: For Incremental Updates
Use when you frequently add/remove individual items:
import { OIMReactiveIndexManualSetBased } from '@oimdb/core';
// Create Set-based index for user roles
const userRoleIndex = new OIMReactiveIndexManualSetBased<string, string>(queue);
// Build the index
userRoleIndex.setPks('admin', ['user1']);
userRoleIndex.setPks('user', ['user2', 'user3']);
// Efficient incremental updates
userRoleIndex.addPks('admin', ['user2']); // O(1)
userRoleIndex.removePks('admin', ['user1']); // O(1)
// Query returns Set<TPk>
const adminUsers = userRoleIndex.index.getPksByKey('admin'); // Set(['user1', 'user2'])
ArrayBased Indexes: For Full Replacements
Use when you typically replace entire arrays (e.g., ordered lists):
import { OIMReactiveIndexManualArrayBased } from '@oimdb/core';
// Create Array-based index for deck cards
const cardsByDeckIndex = new OIMReactiveIndexManualArrayBased<string, string>(queue);
// Set full array (O(1) - direct assignment, no diff computation)
cardsByDeckIndex.setPks('deck1', ['card1', 'card2', 'card3']);
// Query returns TPk[]
const deckCards = cardsByDeckIndex.index.getPksByKey('deck1'); // ['card1', 'card2', 'card3']
// For ArrayBased, prefer setPks for updates
cardsByDeckIndex.setPks('deck1', ['card1', 'card2', 'card4']); // Recommended
// addPks/removePks work but are O(n) - less efficient than SetBased
When to use which:
- SetBased: Frequent add/remove operations, order doesn't matter
- ArrayBased: Full array replacements, need to preserve order/sorting
Event Coalescing: Performance Optimization
Multiple rapid updates to the same entity are automatically coalesced into a single notification:
// These three updates...
users.upsertOne({ id: 'user1', name: 'John' });
users.upsertOne({ id: 'user1', email: 'john@test.com' });
users.upsertOne({ id: 'user1', role: 'admin' });
// ...result in only one notification with the final state
// This prevents unnecessary re-renders and improves performance
Event Queue and Schedulers
Control when events fire with different schedulers:
// Microtask (most common) - executes before next browser render
const microtaskQueue = new OIMEventQueue({
scheduler: OIMEventQueueSchedulerFactory.createMicrotask()
});
// AnimationFrame - syncs with browser rendering (60fps)
const animationFrameQueue = new OIMEventQueue({
scheduler: OIMEventQueueSchedulerFactory.createAnimationFrame()
});
// Timeout - configurable delay for custom batching
const timeoutQueue = new OIMEventQueue({
scheduler: OIMEventQueueSchedulerFactory.createTimeout(100)
});
// Immediate - fastest execution
const immediateQueue = new OIMEventQueue({
scheduler: OIMEventQueueSchedulerFactory.createImmediate()
});
// Manual queue (no scheduler)
const manualQueue = new OIMEventQueue();
manualQueue.enqueue(() => console.log('Task 1'));
manualQueue.flush(); // Execute when ready
Advanced Patterns
Collections with Indexes
Use OIMRICollection to combine collections with indexes:
import {
OIMRICollection,
OIMReactiveIndexManualSetBased,
OIMReactiveIndexManualArrayBased
} from '@oimdb/core';
interface User {
id: string;
name: string;
teamId: string;
role: 'admin' | 'user';
}
// Create indexes
const teamIndex = new OIMReactiveIndexManualSetBased<string, string>(queue);
const roleIndex = new OIMReactiveIndexManualArrayBased<string, string>(queue);
// Create collection with indexes
const users = new OIMRICollection(queue, {
collectionOpts: {
selectPk: (user: User) => user.id
},
indexes: {
byTeam: teamIndex,
byRole: roleIndex
}
});
// Subscribe to index changes
users.indexes.byTeam.updateEventEmitter.subscribeOnKey('engineering', (pks) => {
console.log('Engineering team changed:', pks);
});
// Update indexes manually
users.indexes.byTeam.setPks('engineering', ['u1', 'u2']);
Custom Entity Updaters
Customize how entities are merged on update:
import { TOIMEntityUpdater } from '@oimdb/core';
// Deep merge updater
const deepMergeUpdater: TOIMEntityUpdater<User> = (newEntity, oldEntity) => {
const result = { ...oldEntity };
for (const [key, value] of Object.entries(newEntity)) {
if (value !== undefined) {
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
result[key] = deepMergeUpdater(value, result[key] || {});
} else {
result[key] = value;
}
}
}
return result;
};
// Use custom updater
const users = new OIMReactiveCollection<User, string>(queue, {
selectPk: (user) => user.id,
updateEntity: deepMergeUpdater
});
// Updates merge with existing
users.upsertOne({ id: 'user1', name: 'John' });
users.upsertOne({ id: 'user1', email: 'john@example.com' }); // Merges with existing
Integration with CNStra
CNStra provides orchestration for OIMDB, replacing reducers, slices, thunks, and sagas with a typed neuron graph. Together, they deliver deterministic state management with high performance.
Why CNStra + OIMDB?
The Problem with Flux:
- Multiple reducers need to coordinate ordering and cross-updates
- Immutable tree copies cause extra allocations and GC pressure
- No built-in "after everything settles" phase for batching
Our Approach:
- A controlling neuron orchestrates the sequence of updates across models
- OIMDB stores normalized data with reactive indexes (no tree copies)
- After all model updates in a run, we flush the OIMDB event queue once, so the UI updates efficiently in batches
Minimal Setup
import { CNS, neuron, collateral } from '@cnstra/core';
import { OIMEventQueue, OIMEventQueueSchedulerFactory, OIMRICollection, OIMReactiveIndexManualSetBased } from '@oimdb/core';
const dbEventQueue = new OIMEventQueue({ scheduler: OIMEventQueueSchedulerFactory.createMicrotask() });
export const users = new OIMRICollection(dbEventQueue, {
indexes: { byId: new OIMReactiveIndexManualSetBased<string, string>(dbEventQueue) },
collectionOpts: { selectPk: (u: { id: string }) => u.id }
});
// Define UI/update collateral
const userUpdated = collateral<{ id: string; name: string }>('user:updated');
// Controlling neuron updates models and returns nothing (end of branch)
export const usersNeuron = neuron('users', {}).dendrite({
collateral: userUpdated,
response: (payload) => {
users.collection.upsertOne({ id: payload.id, name: payload.name });
// OIMDB event queue will flush after the run completes
return undefined;
},
});
const cns = new CNS([usersNeuron]);
React Usage
import { useSelectEntityByPk } from '@oimdb/react';
function UserName({ id }: { id: string }) {
const user = useSelectEntityByPk(users, id) || null;
return <span>{user?.name ?? ''}</span>;
}
Updating Multiple Collections
Best practice: each model is updated by its own domain neuron. The controller emits one controller-owned signal with both payloads; each domain neuron listens and updates its model.
import { collateral, neuron } from '@cnstra/core';
import { OIMEventQueue, OIMEventQueueSchedulerFactory, OIMRICollection, OIMReactiveIndexManualSetBased } from '@oimdb/core';
const dbEventQueue = new OIMEventQueue({ scheduler: OIMEventQueueSchedulerFactory.createMicrotask() });
export const users = new OIMRICollection(dbEventQueue, {
indexes: { byId: new OIMReactiveIndexManualSetBased<string, string>(dbEventQueue) },
collectionOpts: { selectPk: (u: { id: string }) => u.id },
});
export const posts = new OIMRICollection(dbEventQueue, {
indexes: { byAuthor: new OIMReactiveIndexManualSetBased<string, string>(dbEventQueue) },
collectionOpts: { selectPk: (p: { id: string }) => p.id },
});
// Single incoming signal with both payloads
const userAndPostUpdated = collateral<{
user: { id: string; name: string };
post: { id: string; title: string; authorId: string };
}>('user+post:updated');
// Controller-owned single update signal
const controllerUpdated = collateral<{
user: { id: string; name: string };
post: { id: string; title: string; authorId: string };
}>('controller:updated');
// Controller receives inbound and emits one outbound
export const controller = neuron('controller', { controllerUpdated })
.dendrite({
collateral: userAndPostUpdated,
response: (payload, axon) => axon.controllerUpdated.createSignal(payload),
});
// Domain neurons update their own collections
export const userModel = neuron('user-model', {}).dendrite({
collateral: controllerUpdated,
response: (p) => {
users.collection.upsertOne(p.user);
return undefined;
},
});
export const postModel = neuron('post-model', {}).dendrite({
collateral: controllerUpdated,
response: (p) => {
posts.collection.upsertOne(p.post);
return undefined;
},
});
React selectors will observe a single batched change after the run completes, not N re-renders during the sequence.
import { useSelectEntityByPk, useSelectEntitiesByIndexKey } from '@oimdb/react';
function AuthorWithPosts({ authorId }: { authorId: string }) {
const user = useSelectEntityByPk(users, authorId) || null;
const postsByAuthor = useSelectEntitiesByIndexKey(posts, 'byAuthor', authorId) || [];
return (
<section>
<h4>{user?.name}</h4>
<ul>{postsByAuthor.map(p => <li key={p.id}>{p.title}</li>)}</ul>
</section>
);
}
Example: Create Deck then Card
Goal: on UI click, create a deck first (to obtain deckId), then create a card that needs that deckId. We orchestrate this with a controlling neuron; OIMDB persists models; the event queue flushes once after the run.
import { CNS, collateral, neuron } from '@cnstra/core';
import {
OIMEventQueue,
OIMEventQueueSchedulerFactory,
OIMRICollection,
OIMReactiveIndexManualSetBased,
} from '@oimdb/core';
// OIMDB setup
const dbEventQueue = new OIMEventQueue({ scheduler: OIMEventQueueSchedulerFactory.createMicrotask() });
export const decks = new OIMRICollection(dbEventQueue, {
indexes: { byId: new OIMReactiveIndexManualSetBased<string, string>(dbEventQueue) },
collectionOpts: { selectPk: (d: { id: string }) => d.id },
});
export const cards = new OIMRICollection(dbEventQueue, {
indexes: { byDeck: new OIMReactiveIndexManualSetBased<string, string>(dbEventQueue) },
collectionOpts: { selectPk: (c: { id: string }) => c.id },
});
// Collaterals
const uiCreateCardClick = collateral<{ deckTitle: string; cardTitle: string }>('ui:createCardClick');
const controllerCreateDeckForCard = collateral<{ title: string; cardTitle: string }>('controller:deck:createForCard');
const controllerCreateCard = collateral<{ deckId: string; cardId: string; title: string }>('controller:card:create');
const deckCreatedForCard = collateral<{ deckId: string; title: string; cardTitle: string }>('deck:createdForCard');
// Services (mocked)
const generateDeckId = (title: string) => 'deck-' + Math.random().toString(36).slice(2);
const generateCardId = () => 'card-' + Math.random().toString(36).slice(2);
// Deck neuron: listens controller:deck:createForCard, emits deck:createdForCard, upserts OIMDB
export const deckNeuron = neuron('deck', { deckCreatedForCard }).dendrite({
collateral: controllerCreateDeckForCard,
response: async (payload, axon) => {
const deckId = generateDeckId();
decks.collection.upsertOne({ id: deckId, title: payload.title });
return axon.deckCreatedForCard.createSignal({ deckId, title: payload.title, cardTitle: payload.cardTitle });
},
});
// Card neuron: listens controller:card:create, upserts OIMDB
export const cardNeuron = neuron('card', {}).dendrite({
collateral: controllerCreateCard,
response: async (payload) => {
const cardId = generateCardId();
cards.collection.upsertOne({ id: cardId, deckId: payload.deckId, title: payload.title });
return {
card
};
},
});
// Controller neuron: emits only its own collaterals (controller:*)
// Pass cardTitle through signal payloads, not context
export const controller = neuron('controller', { controllerCreateDeckForCard, controllerCreateCard })
.dendrite({
collateral: uiCreateCardClick,
response: (payload, axon) => {
// Pass cardTitle along with deck creation through payload
return axon.controllerCreateDeckForCard.createSignal({
title: payload.deckTitle,
cardTitle: payload.cardTitle
});
},
})
.dendrite({
collateral: deckCreatedForCard,
response: (payload, axon) => {
return axon.controllerCreateCard.createSignal({ deckId: payload.deckId, title: payload.cardTitle });
},
});
// CNS
const cns = new CNS([controller, deckNeuron, cardNeuron]);
// UI click starts the run; OIMDB event queue flushes once after both upserts
await cns.stimulate(uiCreateCardClick.createSignal({ deckTitle: 'Inbox', cardTitle: 'First task' }));
Simplified Example: Direct Neuron Communication (No Controller)
For simpler flows, you can skip the controller and have neurons communicate directly:
import { CNS, collateral, neuron } from '@cnstra/core';
import {
OIMEventQueue,
OIMEventQueueSchedulerFactory,
OIMRICollection,
OIMReactiveIndexManualSetBased,
} from '@oimdb/core';
// OIMDB setup
const dbEventQueue = new OIMEventQueue({ scheduler: OIMEventQueueSchedulerFactory.createMicrotask() });
export const decks = new OIMRICollection(dbEventQueue, {
indexes: { byId: new OIMReactiveIndexManualSetBased<string, string>(dbEventQueue) },
collectionOpts: { selectPk: (d: { id: string }) => d.id },
});
export const cards = new OIMRICollection(dbEventQueue, {
indexes: { byDeck: new OIMReactiveIndexManualSetBased<string, string>(dbEventQueue) },
collectionOpts: { selectPk: (c: { id: string }) => c.id },
});
// Collaterals
const uiCreateCardClick = collateral<{ deckTitle: string; cardTitle: string }>('ui:createCardClick');
const deckCreatedForCard = collateral<{ deckId: string; cardTitle: string }>('deck:createdForCard');
// Services (mocked)
const generateDeckId = (title: string) => 'deck-' + Math.random().toString(36).slice(2);
const generateCardId = () => 'card-' + Math.random().toString(36).slice(2);
// Deck neuron: listens to UI click, creates deck, emits deckCreatedForCard
export const deckNeuron = neuron('deck', { deckCreatedForCard }).dendrite({
collateral: uiCreateCardClick,
response: async (payload, axon) => {
const deckId = generateDeckId(payload.deckTitle);
decks.collection.upsertOne({ id: deckId, title: payload.deckTitle });
return axon.deckCreatedForCard.createSignal({ deckId, cardTitle: payload.cardTitle });
},
});
// Card neuron: listens to deckCreatedForCard, creates card
export const cardNeuron = neuron('card', {}).dendrite({
collateral: deckCreatedForCard,
response: async (payload) => {
const cardId = generateCardId();
cards.collection.upsertOne({ id: cardId, deckId: payload.deckId, title: payload.cardTitle });
return undefined;
},
});
// CNS (no controller needed)
const cns = new CNS([deckNeuron, cardNeuron]);
// UI click starts the run; OIMDB event queue flushes once after both upserts
await cns.stimulate(uiCreateCardClick.createSignal({ deckTitle: 'Inbox', cardTitle: 'First task' }));
Rule of ownership:
- A neuron emits only collaterals from its own axon
- Other neurons subscribe to those collaterals via dendrites
- In this example, controller emits
controller:*requests; deck emitsdeck:createdForCard; card writes data oncontroller:card:create
Performance Characteristics
- Collections: O(1) primary key lookups using Map-based storage
- Reactive Collections: O(1) lookups + efficient event coalescing
- Indices: O(1) index lookups with lazy evaluation
- Event System: Smart coalescing prevents redundant notifications
- Memory: Efficient key-based subscriptions, no global listeners
- Schedulers: Configurable timing for optimal batching:
- Microtask: ~1-5ms delay, ideal for UI updates
- Immediate: <1ms, fastest execution
- Timeout: Custom delay for batching strategies
- AnimationFrame: 16ms, synced with 60fps rendering
Index Performance
SetBased Indexes (OIMReactiveIndexManualSetBased):
- Returns
Set<TPk>for efficient membership checks - O(1) add/remove operations
- Best for frequent incremental updates
ArrayBased Indexes (OIMReactiveIndexManualArrayBased):
- Returns
TPk[]for direct array access - O(1)
setPksoperation (direct assignment, no diff computation) - Best for full array replacements
- Note:
addPks/removePksare O(n) - prefersetPksfor better performance
Integration with Other Libraries
React (@oimdb/react)
The core library integrates seamlessly with React through dedicated hooks:
import { useSelectEntitiesByPks, selectEntityByPk } from '@oimdb/react';
// React hooks automatically subscribe to reactive collections
const user = selectEntityByPk(users, 'user1');
const teamUsers = useSelectEntitiesByPks(users, userIds);
Redux (@oimdb/redux-adapter)
⚠️ Experimental: The Redux adapter is experimental. Functionality is not guaranteed and the API may change.
Migrate from Redux to OIMDB gradually or use both systems side-by-side with automatic two-way synchronization. See Redux Migration Guide for details.
API Reference
Core Classes
OIMReactiveCollection<TEntity, TPk>
Reactive collection with automatic change notifications and event coalescing.
Constructor:
new OIMReactiveCollection(queue: OIMEventQueue, opts?: TOIMCollectionOptions<TEntity, TPk>)
Key Methods:
upsertOne(entity: TEntity): void- Insert or update single entityupsertMany(entities: TEntity[]): void- Insert or update multiple entitiesgetOneByPk(pk: TPk): TEntity | undefined- Get entity by primary keygetManyByPks(pks: readonly TPk[]): Map<TPk, TEntity | undefined>- Get multiple entities
Properties:
updateEventEmitter: OIMUpdateEventEmitter<TPk>- Key-specific subscriptionscoalescer: OIMUpdateEventCoalescerCollection<TPk>- Event coalescing
OIMRICollection<TEntity, TPk, ...>
Reactive collection with integrated indexing capabilities.
Constructor:
new OIMRICollection(queue: OIMEventQueue, opts: {
collectionOpts?: TOIMCollectionOptions<TEntity, TPk>;
indexes: TReactiveIndexMap;
})
Properties:
indexes: TReactiveIndexMap- Named reactive indexes
OIMReactiveIndexManualSetBased<TKey, TPk>
Reactive Set-based index. Returns Set<TPk> for efficient membership checks.
Methods:
setPks(key: TKey, pks: readonly TPk[]): void- Set primary keys (replaces entire Set)addPks(key: TKey, pks: readonly TPk[]): void- Add primary keys (O(1))removePks(key: TKey, pks: readonly TPk[]): void- Remove primary keys (O(1))index.getPksByKey(key: TKey): Set<TPk>- Query index
OIMReactiveIndexManualArrayBased<TKey, TPk>
Reactive Array-based index. Returns TPk[] for direct array access.
Methods:
setPks(key: TKey, pks: readonly TPk[]): void- Set primary keys (O(1), recommended)addPks(key: TKey, pks: readonly TPk[]): void- Add primary keys (O(n))removePks(key: TKey, pks: readonly TPk[]): void- Remove primary keys (O(n))index.getPksByKey(key: TKey): TPk[]- Query index
OIMEventQueue
Event processing queue with configurable scheduling.
Constructor:
new OIMEventQueue(options?: TOIMEventQueueOptions)
Methods:
enqueue(fn: () => void): void- Add function to queueflush(): void- Execute all queued functionsclear(): void- Clear queue without executing
Schedulers
OIMEventQueueSchedulerFactory
Factory for creating different scheduler types:
// Available types: 'immediate' | 'microtask' | 'timeout' | 'animationFrame'
OIMEventQueueSchedulerFactory.createMicrotask()
OIMEventQueueSchedulerFactory.createAnimationFrame()
OIMEventQueueSchedulerFactory.createTimeout(delay?: number)
OIMEventQueueSchedulerFactory.createImmediate()
License
MIT License