Skip to main content

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: Set- and array-based 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

The canonical entry point is createOIMCollectionKit. It wires a collection to an event queue and returns a kit with everything you need — collection (reads/writes/subscriptions), indexFactory (build indexes), and select (selectors). Entities are stored by primary key, providing O(1) lookups:

import {
OIMEventQueue,
OIMEventQueueSchedulerFactory,
createOIMCollectionKit,
} 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 a collection kit
const users = createOIMCollectionKit<User, string>(queue, {
selectPk: (user) => user.id
});

// CRUD operations go through users.collection
users.collection.upsertOne({ id: 'user1', name: 'John Doe', email: 'john@example.com' });
users.collection.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.collection.getOneByPk('user1');
const multipleUsers = users.collection.getManyByPks(['user1', 'user2']);

Reactive Updates: Key-Specific Subscriptions

Subscribe directly on the collection to changes for specific entities. Handlers receive no payload — read the current value from the collection when they fire:

// Subscribe to changes for a specific user
users.collection.subscribeOnKey('user1', () => {
console.log('User1 changed!', users.collection.getOneByPk('user1'));
});

// Subscribe to changes for multiple users
users.collection.subscribeOnKeys(['user1', 'user2'], () => {
console.log('Users changed!');
});

// Updates trigger notifications
users.collection.upsertOne({ id: 'user1', name: 'John Updated' });
// Notification fires in next microtask

Indexes: Efficient Queries

Indexes are created through the kit's indexFactory. OIMDB provides two manual index types optimized for different use cases, plus derived indexes that maintain themselves from an entity field.

SetBased Indexes: For Incremental Updates

Use when you frequently add/remove individual items:

// Create a manual Set-based index for user roles
const userRoleIndex = users.indexFactory.setBasedIndex<string>();

// 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.getPksByKey('admin'); // Set(['user2'])

// Subscribe to a specific index key
userRoleIndex.subscribeOnKey('admin', () => {
console.log('Admins changed:', userRoleIndex.getPksByKey('admin'));
});

ArrayBased Indexes: For Full Replacements

Use when you typically replace entire arrays (e.g., ordered lists):

// Create a manual Array-based index for deck cards
const cardsByDeckIndex = cards.indexFactory.arrayBasedIndex<string>();

// Set full array (O(1) - direct assignment, no diff computation)
cardsByDeckIndex.setPks('deck1', ['card1', 'card2', 'card3']);

// Query returns TPk[]
const deckCards = cardsByDeckIndex.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

Derived Indexes: Maintained Automatically

When the index key is a field of the entity, use a derived index — it updates itself on every upsert/remove, so you never call setPks manually:

// Automatically groups users by their `teamId` field
const usersByTeam = users.indexFactory.derivedSetIndex((user) => user.teamId);

// Query returns Set<TPk>, always in sync with the collection
const engineering = usersByTeam.getPksByKey('engineering');

Event Coalescing: Performance Optimization

Multiple rapid updates to the same entity are automatically coalesced into a single notification:

// These three updates...
users.collection.upsertOne({ id: 'user1', name: 'John' });
users.collection.upsertOne({ id: 'user1', email: 'john@test.com' });
users.collection.upsertOne({ id: 'user1', name: 'John Doe' });

// ...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

A kit already exposes indexFactory — create as many indexes as you need and keep references to them:

import {
OIMEventQueue,
OIMEventQueueSchedulerFactory,
createOIMCollectionKit,
} from '@oimdb/core';

interface User {
id: string;
name: string;
teamId: string;
role: 'admin' | 'user';
}

const queue = new OIMEventQueue({ scheduler: OIMEventQueueSchedulerFactory.createMicrotask() });

// Create the collection kit
const users = createOIMCollectionKit<User, string>(queue, {
selectPk: (user) => user.id,
});

// Derived index: auto-maintained from the `teamId` field
const usersByTeam = users.indexFactory.derivedSetIndex((user) => user.teamId);
// Manual index: you control membership explicitly
const usersByRole = users.indexFactory.setBasedIndex<string>();

// Subscribe to index changes
usersByTeam.subscribeOnKey('engineering', () => {
console.log('Engineering team changed:', usersByTeam.getPksByKey('engineering'));
});

// Writing entities keeps the derived index in sync automatically
users.collection.upsertMany([
{ id: 'u1', name: 'Ann', teamId: 'engineering', role: 'admin' },
{ id: 'u2', name: 'Bob', teamId: 'engineering', role: 'user' },
]);

// Manual index is updated by hand
usersByRole.setPks('admin', ['u1']);

Custom Entity Updaters

By default an upsert replaces the stored entity. To merge instead, pass an updateEntity updater. OIMDB ships factories for the common cases, and you can supply your own (draft, prevEntity) => entity:

import { createOIMCollectionKit, createMergeEntityUpdater } from '@oimdb/core';

// Built-in shallow merge: partial upserts merge into the existing entity
const users = createOIMCollectionKit<User, string>(queue, {
selectPk: (user) => user.id,
updateEntity: createMergeEntityUpdater<User>(),
});

// Updates merge with existing
users.collection.upsertOne({ id: 'user1', name: 'John' });
users.collection.upsertOneByPk('user1', { email: 'john@example.com' }); // merges

// Or a custom updater, e.g. a deep merge
const deepMerge: (draft: Partial<User>, prev: User) => User = (draft, prev) => {
const result = { ...prev };
for (const [key, value] of Object.entries(draft)) {
if (value !== undefined) {
(result as any)[key] = value;
}
}
return result;
};

const usersDeep = createOIMCollectionKit<User, string>(queue, {
selectPk: (user) => user.id,
updateEntity: deepMerge,
});

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, createOIMCollectionKit } from '@oimdb/core';

const dbEventQueue = new OIMEventQueue({ scheduler: OIMEventQueueSchedulerFactory.createMicrotask() });
export const users = createOIMCollectionKit<{ id: string; name: string }, string>(dbEventQueue, {
selectPk: (u) => u.id,
});

// Define UI/update collateral
const userUpdated = collateral<{ id: string; name: string }>();

// Controlling neuron updates models and returns nothing (end of branch)
export const usersNeuron = neuron({}).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

React hooks take the reactive collection (users.collection), not the kit:

import { useSelectEntityByPk } from '@oimdb/react';

function UserName({ id }: { id: string }) {
const user = useSelectEntityByPk(users.collection, 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, createOIMCollectionKit } from '@oimdb/core';

type UserEntity = { id: string; name: string };
type PostEntity = { id: string; title: string; authorId: string };

const dbEventQueue = new OIMEventQueue({ scheduler: OIMEventQueueSchedulerFactory.createMicrotask() });
export const users = createOIMCollectionKit<UserEntity, string>(dbEventQueue, {
selectPk: (u) => u.id,
});
export const posts = createOIMCollectionKit<PostEntity, string>(dbEventQueue, {
selectPk: (p) => p.id,
});
// Derived index: posts grouped by author, kept in sync automatically
export const postsByAuthor = posts.indexFactory.derivedSetIndex((p) => p.authorId);

// Single incoming signal with both payloads
const userAndPostUpdated = collateral<{
user: UserEntity;
post: PostEntity;
}>();

// Controller-owned single update signal
const controllerUpdated = collateral<{
user: UserEntity;
post: PostEntity;
}>();

// Controller receives inbound and emits one outbound
export const controller = neuron({ controllerUpdated })
.dendrite({
collateral: userAndPostUpdated,
response: (payload, axon) => axon.controllerUpdated.createSignal(payload),
});

// Domain neurons update their own collections
export const userModel = neuron({}).dendrite({
collateral: controllerUpdated,
response: (p) => {
users.collection.upsertOne(p.user);
return undefined;
},
});

export const postModel = neuron({}).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. Index hooks take the index object (not a string name):

import { useSelectEntityByPk, useSelectEntitiesByIndexKeySetBased } from '@oimdb/react';

function AuthorWithPosts({ authorId }: { authorId: string }) {
const user = useSelectEntityByPk(users.collection, authorId) || null;
const postsByAuthorList =
useSelectEntitiesByIndexKeySetBased(posts.collection, postsByAuthor, authorId) ?? [];
return (
<section>
<h4>{user?.name}</h4>
<ul>{postsByAuthorList.map(p => 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,
createOIMCollectionKit,
} from '@oimdb/core';

type DeckEntity = { id: string; title: string };
type CardEntity = { id: string; deckId: string; title: string };

// OIMDB setup
const dbEventQueue = new OIMEventQueue({ scheduler: OIMEventQueueSchedulerFactory.createMicrotask() });
export const decks = createOIMCollectionKit<DeckEntity, string>(dbEventQueue, {
selectPk: (d) => d.id,
});
export const cards = createOIMCollectionKit<CardEntity, string>(dbEventQueue, {
selectPk: (c) => c.id,
});
// Cards grouped by deck, maintained automatically
export const cardsByDeck = cards.indexFactory.derivedSetIndex((c) => c.deckId);

// Collaterals
const uiCreateCardClick = collateral<{ deckTitle: string; cardTitle: string }>();
const controllerCreateDeckForCard = collateral<{ title: string; cardTitle: string }>();
const controllerCreateCard = collateral<{ deckId: string; title: string }>();
const deckCreatedForCard = collateral<{ deckId: string; title: string; cardTitle: string }>();

// Services (mocked)
const generateDeckId = () => '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({ 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({}).dendrite({
collateral: controllerCreateCard,
response: async (payload) => {
const cardId = generateCardId();
cards.collection.upsertOne({ id: cardId, deckId: payload.deckId, title: payload.title });
return undefined;
},
});

// Controller neuron: emits only its own collaterals (controller:*)
// Pass cardTitle through signal payloads, not context
export const controller = neuron({ 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,
createOIMCollectionKit,
} from '@oimdb/core';

type DeckEntity = { id: string; title: string };
type CardEntity = { id: string; deckId: string; title: string };

// OIMDB setup
const dbEventQueue = new OIMEventQueue({ scheduler: OIMEventQueueSchedulerFactory.createMicrotask() });
export const decks = createOIMCollectionKit<DeckEntity, string>(dbEventQueue, {
selectPk: (d) => d.id,
});
export const cards = createOIMCollectionKit<CardEntity, string>(dbEventQueue, {
selectPk: (c) => c.id,
});

// Collaterals
const uiCreateCardClick = collateral<{ deckTitle: string; cardTitle: string }>();
const deckCreatedForCard = collateral<{ deckId: string; cardTitle: string }>();

// Services (mocked)
const generateDeckId = () => '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({ deckCreatedForCard }).dendrite({
collateral: uiCreateCardClick,
response: async (payload, axon) => {
const deckId = generateDeckId();
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({}).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 emits deck:createdForCard; card writes data on controller: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 (indexFactory.setBasedIndex() / derivedSetIndex()):

  • Returns Set<TPk> for efficient membership checks
  • O(1) add/remove operations
  • Best for frequent incremental updates

ArrayBased Indexes (indexFactory.arrayBasedIndex() / derivedArrayIndex()):

  • Returns TPk[] for direct array access
  • O(1) setPks operation (direct assignment, no diff computation)
  • Best for full array replacements
  • Note: addPks/removePks are O(n) - prefer setPks for better performance

Integration with Other Libraries

React (@oimdb/react)

The core library integrates seamlessly with React through dedicated hooks. All hooks take the reactive collection (kit.collection) and, for index queries, the index object:

import { useSelectEntityByPk, useSelectEntitiesByPks } from '@oimdb/react';

// Single entity by primary key
const user = useSelectEntityByPk(users.collection, 'user1');
// Many entities by primary keys
const teamUsers = useSelectEntitiesByPks(users.collection, 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

Collection Kit

createOIMCollectionKit<TEntity, TPk>(queue, opts?)

Creates a reactive collection and returns a kit:

const kit = createOIMCollectionKit<TEntity, TPk>(queue: OIMEventQueue, opts?: {
selectPk?: (entity: TEntity) => TPk;
updateEntity?: (draft: Partial<TEntity>, prevEntity: TEntity) => TEntity;
});
// kit = { queue, collection, indexFactory, select }

kit.collectionOIMReactiveCollection<TEntity, TPk>

Reactive collection with automatic change notifications and event coalescing.

Key Methods:

  • upsertOne(entity): TOIMEntitySlot - Insert or update a single entity
  • upsertOneByPk(pk, partial): TOIMEntitySlot - Insert or update by primary key
  • upsertMany(entities): TOIMEntitySlot[] - Insert or update multiple entities
  • removeOneByPk(pk): void / removeManyByPks(pks): void - Remove entities
  • getOneByPk(pk): TEntity | undefined - Get entity by primary key
  • getManyByPks(pks): TEntity[] - Get multiple entities
  • getAll(): TEntity[] - Get all entities
  • subscribeOnKey(pk, handler): () => void - Subscribe to a single key (handler takes no args)
  • subscribeOnKeys(pks, handler): () => void - Subscribe to multiple keys

kit.indexFactoryOIMCollectionIndexFactory<TEntity, TPk>

Builds indexes bound to the collection:

  • setBasedIndex<TKey>(): OIMReactiveCollectionIndexManualSetBased - Manual Set-based index
  • arrayBasedIndex<TKey>(): OIMReactiveCollectionIndexManualArrayBased - Manual Array-based index
  • derivedSetIndex<TKey>(selectKey): OIMDerivedCollectionIndexSetBased - Auto-maintained Set index
  • derivedArrayIndex<TKey>(selectKey): OIMDerivedCollectionIndexArrayBased - Auto-maintained Array index

Index methods (on the returned index):

  • getPksByKey(key): Set<TPk> | TPk[] - Query the index
  • subscribeOnKey(key, handler): () => void - Subscribe to a key
  • Manual indexes only: setPks(key, pks), addPks(key, pks), removePks(key, pks), clear(key?)

kit.selectOIMCollectionSelectors<TEntity, TPk>

Composable selectors (each has a .watch(cb) method):

  • byPk(pk) / byPks(pks)
  • entitiesBySetIndexKey(index, key) / entitiesByArrayIndexKey(index, key)

OIMEventQueue

Event processing queue with configurable scheduling.

Constructor:

new OIMEventQueue(options?: TOIMEventQueueOptions)

Methods:

  • enqueue(fn: () => void): void - Add function to queue
  • flush(): void - Execute all queued functions
  • clear(): void - Clear queue without executing

Schedulers

OIMEventQueueSchedulerFactory

Factory for creating different scheduler types:

OIMEventQueueSchedulerFactory.createMicrotask()
OIMEventQueueSchedulerFactory.createAnimationFrame()
OIMEventQueueSchedulerFactory.createTimeout(delay?: number)
OIMEventQueueSchedulerFactory.createImmediate()

License

MIT License