Skip to main content

Exodra

@oimdb/exodra is the exoskeleton over your data. OIMDB stays the body — collections, indexes, writes — and this package straps an Exodra reactive surface onto it. It is the Exodra counterpart of @oimdb/react: where the React package turns an OIMDB source into a hook + re-render, this one turns it into a read-only bindable (TExoBindable). No React, no hooks, no re-renders.

npm install @oimdb/exodra @oimdb/core

The bridge pulls only the type-only @exodra/reactivity-types, never the reactivity runtime. Your Exodra app already provides @exodra/reactivity for bindable, derive, h.

The whole system at once

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

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

const queue = new OIMEventQueue();
const usersKit = createOIMCollectionKit<User, string>(queue, { selectPk: (u) => u.id });

const byTeam = usersKit.indexFactory.derivedSetIndex<string>((u) => u.teamId);
const byTeamOrdered = usersKit.indexFactory.derivedArrayIndex<string>((u) => u.teamId, {
orderBy: (u) => u.name,
});
const online = usersKit.indexFactory.derivedSetGlobalIndex({ filter: (u) => u.online });

export const db = exoDb({
users: { kit: usersKit, indexes: { byTeam, byTeamOrdered, online } },
teams: { kit: teamsKit }, // no indexes → byPk / byPks only
});

Every index you name becomes a member of the same name, and the kind of index is inferred from the index itself — you never pick a method to match it, and never pass the index object again:

db.users.byPk('u1'); // TExoBindable<User | undefined>
db.users.byTeam('t1'); // set index → entities for a key
db.users.byTeamOrdered('t1'); // array index → same, in order
db.users.online(); // global index → no key, and none is asked for
tip

Pair it with Exodra's own DI so views never import the database directly:

export const dbKey = createContextKey<typeof db>('db'); // from @exodra/reactivity

The key is created by your app: createContextKey lives in the Exodra runtime, which this bridge deliberately does not depend on.

One name per index, everything it can do

const team = bindable('t1'); // a bindable key — the view follows a moving selection

db.users.byTeam(team); // entities
db.users.byTeam.pks(team); // just the membership
db.users.byTeam.rows(team, render); // → bindables.children (identity-stable)
db.users.byTeamOrdered.list('t1', render); // → bindableLists.children (O(delta), fixed key)
db.users.byTeam.subscribe('t1', onChange); // manual, for onExoMount scope

rows and list are exactly Exodra's two children buckets:

const rows = db.users.byTeamOrdered.rows('t1', (user, pk) =>
h('li', { bindable: { textContent: derive(user, (u) => u?.name ?? '—') } }),
);
// <ul bindable={{ children: rows }} />

Each row gets its own entity bindable, so a field edit updates that row in place: the array is unchanged, the reconcile is a no-op, focus survives. list goes further — it derives a command stream from the ordered index, so a reorder emits move and Exodra relocates the existing DOM node instead of rebuilding it.

note

list exists only on ordered (array-based) indexes, because a command stream is derived by diffing an order and a set has none. That is enforced in the types, not merely documented. It also builds and memoizes the stream itself — you never construct one by hand.

list takes a plain key only (a bindable there is a compile error): a TExoBindableList is bound once and driven by ops, so following a moving key would mean resetting the whole list on every change — which is exactly what rows already does, better. Use rows for a moving selection.

db.users.kit stays reachable, so writes and the rest of OIMDB are always one hop away.

Primitives

The facade is built from these, and they are exported for everything it does not cover:

import { exoSource, exoSelector, exoComputed, exoKeyed, exoCombine, exoRows, exoList } from '@oimdb/exodra';

exoSource(kit.select.byPk('u1')); // an OIMSelector
exoSource(myComputed); // an OIMComputed
exoSource(read, subscribe); // a raw pair — the escape hatch
exoSource(team, (k) => kit.select.byPks(k)); // a reactive key

exoCombine([a, b], () => `${a.getValue()} / ${b.getValue()}`); // derive is single-source
exoRows(tagSlugs, renderTag); // any keys, not just entity pks
exoList(stream, 'deck-1', renderCard); // any command stream

Everything is lazy and ref-counted — upstream is subscribed only while something downstream is listening, so cost is O(visible), not O(total) — and SSR-safe: getValue() reads through to the store and is valid with no subscription at all, including a reactive key, which resolves on read.

Patterns

Three situations cover essentially every screen. Find yours before inventing glue.

1. Render entities of a collection or an index

Identity already exists — it is the primary key. Feed the pks in; there is no key function anywhere.

// From an index (ordered, filtered — see below):
const rows = db.tasks.byStatus.rows('in-progress', (task, pk) =>
h('li', { bindable: { textContent: derive(task, t => t?.title ?? '') } }),
);
// <ul bindable={{ children: rows }} />

// Or straight from the primitive, over any pk sequence:
const rows = exoRows(db.tasks.byStatus.pks('in-progress'), pk => renderCard(pk));

// Whole collection rather than an index:
const rows = exoRows(db.tasks.allPks(), pk => renderCard(pk));

The row is built once per pk. A field edit updates that row's own bindables in place — the array is unchanged, the reconcile is a no-op, focus survives.

Never put state in the key. `${t.id}:${commentCount(t.id)}` turns "which row is this" into "which row and in what condition": the row is rebuilt every time the condition changes, and its focus goes with it. Measured on 2000 rows, one added comment cost 2.44 ms and a wasted rebuild that way, versus 0.73 ms and zero rebuilds with the count bound inside the row.

The canonical shape: a post, its comments, and the comments themselves

Three independent sources of change, three independent subscriptions — each reaching exactly the node that depends on it:

const postView = (postId: string) => {
const post = db.posts.byPk(postId); // 1. the post itself

const comments = db.comments.byPost.rows(postId, (comment, pk) =>
h('li', { // 3. each comment, its own bindable
bindable: { textContent: derive(comment, c => c?.body ?? '') },
}),
); // 2. membership, from the index

return h('article', {
bindable: {
textContent: derive(post, p => p?.title ?? ''),
},
}, h('ul', { bindable: { children: comments } }));
};

What happens on each change:

ChangeWhat fires
the post is editedthe post's binding only — the comment list is not touched
one comment is editedthat row's binding only — the list array comes back identical, no reconcile, no rebuild
a comment is added or removedthe list rebuilds, and only the genuinely new row is rendered; surviving rows keep their identity

No composite key, no combining, no extra invalidation wiring: the index gives membership, byPk gives each row its own value, and the post is its own binding. exo-post-comments.test.ts asserts every line of that table with counters.

Spelled out with the primitive instead of the facade, it is the same thing:

const comments = exoRows(db.comments.byPost.pks(postId), pk =>
h('li', { bindable: { textContent: derive(db.comments.byPk(pk), c => c?.body ?? '') } }),
);

A LIST of posts, each with its own comments (nested rows)

exoRows inside exoRows. The post row is built once and cached, so the comment list created inside it is cached with it — adding a post does not rebuild anybody else's comments.

const posts = exoRows(db.posts.postsByDate.pks(), postPk => {
const post = db.posts.byPk(postPk);

const comments = exoRows(db.comments.byPost.pks(postPk), commentPk =>
h('li', {
bindable: {
textContent: derive(db.comments.byPk(commentPk), c => c?.body ?? ''),
},
}),
);

return h('article', {
bindable: { textContent: derive(post, p => p?.title ?? '') },
}, h('ul', { bindable: { children: comments } }));
});
// <section bindable={{ children: posts }} />

Containment, asserted in exo-post-list.test.ts:

ChangeWhat happens
a post's titlethat post's binding only — neither list moves
a comment's bodythat comment's binding only — its list returns the identical array
a comment added to post Aonly A's comment list rebuilds, and only the new comment renders; post B and the post list are untouched
a post addedthe post list rebuilds and only the new post renders; existing rows keep their identity and their already-built comment lists
a post removedits row and nested list are dropped; nothing else re-renders

That last row is the point of caching by identity: the nested list is part of the cached row, so it survives changes to the outer list instead of being rebuilt with it.

Through the facade it collapses to a single top-level call — the inner list is just an expression inside the row's markup, the way .map() is inside JSX:

const posts = db.posts.postsByDate.rows((post, postPk) =>
h('article', {
bindable: { textContent: derive(post, p => p?.title ?? '') },
},
h('ul', {
bindable: {
children: db.comments.byPost.rows(postPk, comment =>
h('li', { bindable: { textContent: derive(comment, c => c?.body ?? '') } }),
),
},
}),
),
);

Two lists means two identity sequences and therefore two children buckets — that is the floor, not a limitation of this package. The only way to a literally single map is flattening to one key sequence (['p:p1','c:c1','p:p2',…]), which buys a flat DOM at the price of prefixed keys and, more importantly, of containment: the sequence then has to be recomputed whenever ANY post's comments change.

2. A row needs data from another table

Bind it inside the row. Do not join, do not widen the key.

const commentRow = (pk: string) =>
h('li', {
bindable: {
// the author's name follows the members collection…
textContent: derive(exoSelector(members.select.byPk(authorOf(pk))), m => m?.name ?? '?'),
// …and a count follows the comments index
title: derive(db.comments.byTask.pks(pk), c => `${c.length} comments`),
},
});

Need a genuine aggregate — a roll-up, a total, something that is one value rather than a per-row one? That is one OIMComputed (leveled at AFTER_FLUSH, coherent) forwarded with exoComputed.

There is deliberately no multi-source derived table. A joined row would need a primary key nobody references, and an identity nobody uses is not an identity. If something does reference the combined thing, it is a real entity — give it its own collection and fill it yourself.

3. Items that are not entities

Tags, groups, buckets — anything without a collection. They still have something stable to be keyed by, so pass those keys:

const rows = exoRows(tagSlugs, slug => renderTag(slug));

Ordering and filtering live in the index

// order is maintained incrementally; a per-render sort would be O(n log n) every flush
kit.indexFactory.derivedArrayIndex(t => t.statusId, { orderBy: t => t.createdAt });

// filtered: emit no key and the entity never enters the index
kit.indexFactory.derivedSetIndex(t => (t.archived ? [] : [t.statusId]));

A selection that moves

Pin the index once; nothing below takes a key again.

const live = db.tasks.byStatus.for(selectedStatus);
live.rows(renderCard);

A <select> whose options must not go stale

const options = derive(db.members.all(), ms =>
ms.map(m => h('option', { static: { value: m.id } })),
);

Reading collection.getAll() once at build is the classic staleness bug.

A subscription scoped to onExoMount

onExoMount: () => { stop = db.tasks.byStatus.subscribe('done', refresh); },
onExoUnmount: () => stop?.(),

No equality options. Anywhere.

There is no options type in this package. Whoever owns the value owns the comparison:

SourceWhere equality lives
selectorsOIMSelector.areEqual — an element compare in every collection-returning selector
computedsOIMComputed's compare, passed at construction
exoCombineyour own fn — return a stable reference when the content is unchanged
exoSource(read, subscribe)your own subscribe callback — compare there, don't call onChange

Those owners all run below the bridge, so a filter here could only reject what they passed, never resurrect what they dropped. Everything else is forwarded, which is why an in-place entity updater — whose entity reference is stable — is seen with no configuration at all.

const theme = exoSource(read, (onChange) => {
let last = read();
return settings.subscribeOnKey('theme', () => {
const next = read();
if (Object.is(next, last)) return;
last = next;
onChange();
});
});

Notes

  • Multi-entity reads are length-aligned with holes ((TEntity | undefined)[]), matching @oimdb/react.
  • Row keys must be unique; a duplicate throws rather than silently corrupting an identity-reconciled list.
  • Fan-in belongs in one OIMComputed (leveled at AFTER_FLUSH, coherent) forwarded via exoSource — Exodra has no glitch batching, so chaining derives off each other does not.
  • The write side is intentionally absent: apps write through orchestration, not from views.

See also