Skip to main content

Core Model

OIMDB's current app-level model is:

  • a shared OIMEventQueue
  • a collection model created with createOIMCollectionKit
  • indexes that live next to the collection
  • selectors for reactive reads

Collections remain the source of truth for entities. Indexes and ordered lists keep canonical collection slots, but they are not stored inside the collection.

Collection Model

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

type User = {
id: string;
name: string;
teamId: string;
};

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

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

The model facade contains:

type TOIMCollectionKit<TEntity, TPk> = {
queue: OIMEventQueue;
collection: OIMReactiveCollection<TEntity, TPk>;
indexFactory: OIMCollectionIndexFactory<TEntity, TPk>;
select: OIMCollectionSelectors<TEntity, TPk>;
};

Slot-Returning Writes

Collection writes return canonical slots. Updating an entity keeps the same slot object and updates slot.item.

const firstSlot = users.collection.upsertOne({
id: 'u1',
name: 'Alice',
teamId: 'team1',
});

const updatedSlot = users.collection.upsertOneByPk('u1', {
name: 'Alicia',
});

console.log(firstSlot === updatedSlot); // true

This is what lets collection-bound indexes store stable slot references while still exposing PK projections such as getPksByKey.

Event Semantics

Batching & coalescing — multiple writes to the same key coalesce into a single notification per queue.flush(). Delivery is driven by updated key sets, not by every write.

Reentrancy — writing to a store from a subscription callback during queue.flush() throws (updates during queue.flush() are not allowed). Effects and computeds run at AFTER_FLUSH, when the queue is no longer flushing; writes made there are allowed and batched into the next flush.

Single-pass flushqueue.flush() runs each currently-pending task once (buffer swap, no re-drain loop). Tasks enqueued during a flush land in a fresh buffer and run on the next flush (scheduled tick or next manual flush()), not within the same call.

Key-scoped subscriptions — there is no "subscribe to everything". Delivery cost is proportional to the subscriber sets for changed keys only.

Missing entities: the holes policy

A pk can point at nothing — an entity was never upserted, or was removed while some index or caller still holds its pk. OIMDB has one rule for that across the whole library:

Reads are length-aligned and surface the gap as undefined. Dropping it is always a separate, explicitly named call.

A missing entity is a real state of the store, not noise. Hiding it turns a torn state into a list that is quietly one item short — the kind of bug that shows up far from its cause.

collection.getManyByPks(['u1', 'gone', 'u2']);
// → [User, undefined, User] length-aligned, the gap is visible

collection.getManyByPksCompact(['u1', 'gone', 'u2']);
// → [User, User] filtered, SHORTER than the input — deliberate

The same contract holds everywhere entities come back as a list:

ReadShape
collection.getManyByPks(pks)(TEntity | undefined)[]
collection.getManyByPksCompact(pks)TEntity[], may be shorter
index.getEntitiesByKey(key)(TEntity | undefined)[]
globalIndex.getEntities()(TEntity | undefined)[]
every selector returning a listreadonly (TEntity | undefined)[]
stream.getEntitiesByKey(key)(TEntity | undefined)[]

When holes cannot happen

A derived index is dense by construction: every pk in it was put there by an entity that exists, and removal maintains it. Reading through one, the | undefined is spurious — but it is still the honest type, because the store cannot know where your pks came from.

A manual index (arrayBasedIndex, composite*Index, anything written with setPks) holds pks you wrote by hand. There a gap is entirely possible, and compacting it away is how a torn state becomes invisible.

So: compact when the pks are dense and you know it; otherwise keep the alignment and handle the gap. @oimdb/exodra mirrors this exactly — the default index read keeps holes, .compact(key) filters, and .unsafeDense(key) only changes the type while checking nothing, named so the assertion is visibly yours.

Composite primary key

A collection's PK is usually a primitive (string/number). It can instead be a composite key path — an arbitrary-length tuple of primitive segments, e.g. [userId, projectId]. Pass a trie-backed store:

import { OIMReactiveCollection, OIMCollectionStoreTrieDriven } from '@oimdb/core';

const memberships = new OIMReactiveCollection<Membership, readonly (string | number)[]>(
queue,
{
selectPk: (m) => [m.userId, m.projectId],
store: new OIMCollectionStoreTrieDriven<Membership>(),
}
);

memberships.getOneByPk([1, 10]); // matched by content — a fresh array is fine
memberships.subscribeOnKey([1, 10], render);
memberships.removeOneByPk([1, 10]);

Key paths are matched by content (a freshly built [1, 10] resolves to the same entity as one stored earlier); the store interns each logical PK to one canonical slot.pk reference. Primitive-PK collections keep the native-Map store (OIMCollectionStoreMapDriven) untouched — no cost.

Indexes work over a composite-PK collection too — indexFactory.setBasedIndex() indexes composite PKs, matching them by content in setPks/addPks/removePks.

Serialization (Redux, persist, snapshots) keys by a string, so a composite PK there needs an IOIMPkCodec (OIMPkCodecKeyPath is the ready one). @oimdb/persist and @oimdb/snapshot-manager store the PK as a value and need no codec; @oimdb/redux-adapter keys state by string and takes the codec (see its docs).