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 flush — queue.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).