@oimdb/core 3.7.0 → 3.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/llms.txt CHANGED
@@ -13,6 +13,7 @@ OIMDB is a TypeScript in-memory reactive database. It stores entities in typed c
13
13
  | `@oimdb/core` | Collections, indexes, event queue, reactive primitives |
14
14
  | `@oimdb/persist` | Storage-agnostic persist/hydrate engine (source adapters, resources, versioned codec). Backends ship separately: `@oimdb/persist-memory`, `-localstorage`, `-idb`, `-async-kv`, `-json` |
15
15
  | `@oimdb/react` | React hooks for collections and selectors |
16
+ | `@oimdb/exodra` | [Exodra](https://exodra.org) adapter: OIMDB selectors/computeds → read-only `TExoBindable` (no React) |
16
17
  | `@oimdb/async` | Async-aware reactive collection and index variants |
17
18
  | `@oimdb/devtools` | Debug registry: name collections/indexes/computeds, inspect runtime state |
18
19
  | `@oimdb/snapshot-manager` | Snapshot/restore collection state |
@@ -114,6 +115,35 @@ const active = indexFactory.derivedSetGlobalIndex({ filter: (u) => !u.archived }
114
115
  - Selectors: `select.entitiesByArrayGlobalIndex(list)` / `select.entitiesBySetGlobalIndex(set)` → `(TEntity | undefined)[]` (no key).
115
116
  - React: `useSelectEntitiesByGlobalIndex{Array,Set}Based(collection, index)`, `useSelectPksByGlobalIndex{Array,Set}Based(index)`, plus `…Signal` variants — all drop the `key` argument.
116
117
 
118
+ ### Composite (key-path) indexes
119
+
120
+ When the key is a **combination of values** (e.g. `[userId, projectId, role]`) rather than one primitive, use a **composite** set-based index instead of concatenating a string key. Create it with `indexFactory.compositeSetIndex()`. The key is a `TOIMKeyPath` — an ordered, **arbitrary-length** tuple of primitive segments (`readonly (string | number)[]`). Every method that takes a `key` takes a path array instead; everything else (pk writes, slot resolution, reactivity, `getEntitiesByKey` holes) is identical to `setBasedIndex`.
121
+
122
+ ```ts
123
+ const membersByScope = indexFactory.compositeSetIndex();
124
+ membersByScope.setPks(['u1', 'p1', 'admin'], ['m1', 'm2']); // also addPks / removePks
125
+ membersByScope.getPksByKey(['u1', 'p1', 'admin']); // Set { 'm1', 'm2' }
126
+ membersByScope.getEntitiesByKey(['u1', 'p1', 'admin']); // (Member | undefined)[]
127
+ membersByScope.subscribeOnKey(['u1', 'p1', 'admin'], () => {/* … */});
128
+ queue.flush();
129
+ ```
130
+
131
+ - **Matched by content, not reference / string.** A freshly built `['u1','p1','admin']` resolves to the same bucket as the one written earlier — no need to reuse the array instance. Segments keep their own type (`1` ≠ `'1'`) and no separator can collide (`['a|b','c']` ≠ `['a','b|c']`), unlike a `"a|b|c"` string key.
132
+ - **Full path is the lookup unit.** O(arity) — effectively O(1) for a fixed arity — a lookup walks one native-`Map` level per segment. No partial/prefix queries (subscribe/lookup are by the whole path). Paths of different lengths coexist (`['a','b']` and `['a','b','c']` never collide).
133
+ - **No perf cost to primitive indexes.** It is a distinct opt-in index: `setBasedIndex` and all others keep their native-`Map` fast path untouched. The trie cost is paid only where you use a composite index.
134
+ - **Ordered variant:** `indexFactory.compositeArrayIndex()` — same key path, but buckets are ordered slot arrays (`getPksByKey` returns an array in write order). Use it when order matters per composite key (e.g. `[channelId, threadId]` messages).
135
+ - **Selectors:** `select.entitiesByCompositeSetIndexKey(index, path)` / `select.entitiesByCompositeArrayIndexKey(index, path)` → `(TEntity | undefined)[]`, tracking both index and entity changes.
136
+ - **React:** `useSelectEntitiesByCompositeIndexKey{Set,Array}Based(collection, index, path)` and `useSelectPksByCompositeIndexKey{Set,Array}Based(index, path)`. Unlike the primitive hooks (which stabilize the key by `===`), these stabilize the path **by content**, so a freshly built `['p1','admin']` on every render does not re-subscribe.
137
+ - New exports: `OIMReactiveCollectionIndexCompositeTrie{Set,Array}Based`, `OIMIndexStoreTrieDriven{Set,Array}Based` (inject via `indexOptions.store` for a custom key path store), `OIMKeyPathCarrierProvider`, `OIMTrieMap` (the generic N-level trie util), types `TOIMKeyPath` / `TOIMKey`. The keyed-emitter / index generics accept `TKey extends TOIMKey` (= `TOIMPk | TOIMKeyPath`) — a pure widening; primitive keys are unaffected.
138
+
139
+ ### Composite PRIMARY key (collection keyed by a tuple)
140
+
141
+ Beyond composite index *keys*, a **collection's PK itself** can be a composite path. Pass a trie store: `new OIMReactiveCollection(queue, { selectPk: e => [e.userId, e.projectId], store: new OIMCollectionStoreTrieDriven() })`. Then `getOneByPk([1,10])`, `subscribeOnKey([1,10])`, `removeOneByPk([1,10])` all match by content; the store interns each logical PK to one canonical `slot.pk` reference so downstream native `Set<pk>` stays correct. Primitive-PK collections keep `OIMCollectionStoreMapDriven` (native `Map`) untouched — no regression.
142
+
143
+ - **Indexes work over a composite-PK collection.** `indexFactory.setBasedIndex()` (or `compositeSetIndex`) on such a collection indexes composite PKs: `byRole.setPks('admin', [[1,10],[2,10]])`, `getPksByKey`, `addPks`, `removePks([[2,10]])` all match PKs by content (the per-bucket pk→slot membership uses the collection's trie key domain).
144
+ - **Serialization boundaries.** `@oimdb/persist` (stores entities as an array, re-derives the PK via `selectPk` on hydrate) and `@oimdb/snapshot-manager` (stores the PK as a value) round-trip a composite PK with **no codec** — just pass the composite-PK collection. **`@oimdb/redux-adapter`** keys Redux state by string (`Record<pk, entity>`), so a composite PK needs an `IOIMPkCodec`: pass it to `createCollectionReducer(collection, child?, mapper?, pkCodec)` — one codec drives both the default mapper (encode: `entities` keyed by JSON-encoded PK, `ids` keep the raw tuple) AND child write-back / linked-index sync (decode: Redux string key → raw PK). `OIMPkCodecKeyPath` is the ready codec. (Or pass `mapper: createDefaultCollectionMapper(codec)` directly for a read-only projection.)
145
+ - New exports: `OIMCollectionStoreTrieDriven`, `OIMKeyDomainTrie` / `OIMKeyDomainNative` (`IOIMKeyDomain`), `OIMTrieSet`, `IOIMPkCodec` / `OIMPkCodecKeyPath`. The collection/index PK generics accept `TPk extends TOIMKey`.
146
+
117
147
  ### Ordered list command stream
118
148
 
119
149
  For an ordered per-key list that emits **position-addressed commands** (insert / remove / move / set / reset) instead of a new array to diff — for imperative renderers that apply edits in place. `OIMCollectionOrderedListCommandStream` (pk writes, via `indexFactory.orderedList()`) or `OIMOrderedListCommandStream` (slot writes). Commands are not in the event payload — subscribe, then pull synchronously.
@@ -227,6 +257,8 @@ const logEffect = new OIMEffect(runtime, {
227
257
  - `OIMEffectDependencyKeyedIndex(index, key)` — watch a key in an index
228
258
  - `OIMEffectDependencyComputed(computed)` — depend on another computed
229
259
 
260
+ Each keyed dependency watches exactly **one** key — the key argument is the whole key, so a composite key/PK path `[a, b]` is one key (never split into `a`, `b`). For a composite-PK collection or composite index, pass the tuple: `new OIMEffectDependencyKeyedCollection(reqs, ['t1', 'open'])`. To depend on several keys, add several dependencies to the effect's `deps` array.
261
+
230
262
  ### Objects
231
263
 
232
264
  Key-value stores for singleton data (settings, flags, etc.).
@@ -317,6 +349,42 @@ Signal hooks: `useSelectEntityByPkSignal(collection, pk)`, `useSelectPksByIndexK
317
349
 
318
350
  Trade-offs (use only when every reader is subscription-driven): NOT Concurrent-safe; the entity reference is stable across changes, so `React.memo` on entities, prev/next diffing, time-travel, and the default uSES hooks on the same collection won't see updates. Default to the standard immutable hooks; reach for this only when a profile shows the data layer is the bottleneck.
319
351
 
352
+ ## Exodra (`@oimdb/exodra`)
353
+
354
+ ```bash
355
+ npm install @oimdb/exodra @exodra/reactivity @oimdb/core
356
+ ```
357
+
358
+ The [Exodra](https://exodra.org) counterpart of `@oimdb/react`: instead of a hook + re-render, every OIMDB source becomes a read-only Exodra bindable (`TExoBindable<T> = { getValue(): T; subscribe(update): () => void }`). No React. Built on `@oimdb/core`'s `OIMSelector` (`{ getValue, watch }`) — the fine-grained subscription already lives there, so this is a thin adapter. Bindables are read-only, lazy (subscribe upstream only while they have a downstream subscriber → cost O(visible)) and SSR-safe (`getValue()` reads fresh, valid without an active subscription).
359
+
360
+ ```ts
361
+ import { fromSelector, bindSelectors, fromComputed, combine, entityRows } from '@oimdb/exodra';
362
+ import { bindable, derive } from '@exodra/reactivity';
363
+
364
+ // wrap any OIMSelector
365
+ const alice = fromSelector(kit.select.byPk('u1')); // TExoBindable<User | undefined>
366
+
367
+ // mirror OIMCollectionSelectors → bindables; keys accept a bindable (reactive key re-points the sub)
368
+ const bound = bindSelectors(kit.select);
369
+ const selectedTeam = bindable('team-1');
370
+ const members = bound.entitiesBySetIndexKey(usersByTeam, selectedTeam);
371
+ // <ul bindable={{ children: entityRows(memberPks, pk => bound.byPk(pk), (u, pk) => renderRow(u)) }} />
372
+ ```
373
+
374
+ | Export | Purpose |
375
+ |---|---|
376
+ | `fromSelector(selector, opts?)` | primary adapter: `OIMSelector<T>` → `TExoBindable<T>` |
377
+ | `fromOimdb(read, subscribe, opts?)` | escape hatch for a raw read/subscribe pair |
378
+ | `fromSelectorFactory(key, makeSelector, opts?)` | reactive key (`TKey \| bindable`) → rebuilds the selector on change |
379
+ | `fromComputed(computed, opts?)` | `OIMComputed<T>` → `TExoBindable<T>` |
380
+ | `combine(sources, fn)` | multi-source derive (Exodra's own `derive` is single-source) |
381
+ | `bindSelectors(selectors, defaults?)` | mirror of `OIMCollectionSelectors`, each method returning a bindable |
382
+ | `keyedChildren(source, { key, render })` / `entityRows(order, bindEntity, render)` | identity-stable list children (field edit = reconcile no-op, focus-safe) |
383
+ | `listFromCommandStream(stream, key, render)` | O(delta) path: OIMDB ordered command stream → Exodra `bindableList` (commands map 1:1 to `TExoListOp`) |
384
+ | `read*/subscribe*` (6 fns) | low-level pairs mirroring an inline app bridge 1:1 (find-replace migration) |
385
+
386
+ Options `{ equals?, alwaysNotify? }`: `equals` defaults to `Object.is`; `alwaysNotify: true` for in-place entity updaters (stable ref, where `Object.is` would swallow the update). `bindSelectors` takes `kit.select` (not a bare collection) because the collection's queue is private and the selectors own a queue-bound compute runtime. Exodra bucket bindings subscribe at *build* (not `onExoMount`); the low-level `subscribe*` fns cover `onExoMount`-scoped needs. Write-side is intentionally omitted (apps write via orchestration, e.g. cnstra, not from views).
387
+
320
388
  ## Persist (`@oimdb/persist`)
321
389
 
322
390
  ```ts
@@ -462,6 +530,8 @@ unsubscribe();
462
530
 
463
531
  ### Destroy lifecycle (always call destroy in reverse order)
464
532
 
533
+ Teardown is LIFO: dispose dependents before what they depend on — indexes / streams / selectors before the collection, the collection before the queue. By hand:
534
+
465
535
  ```ts
466
536
  logEffect.destroy();
467
537
  openCount.destroy();
@@ -470,6 +540,18 @@ tasks.destroy();
470
540
  queue.destroy();
471
541
  ```
472
542
 
543
+ **`OIMDisposeScope`** does this for you — register as you build, tear everything down in one LIFO call. It accepts both `{ destroy(): void }` objects AND bare `() => void` unsubscribe functions (selectors' `watch`, per-key `subscribeOnKey`, `emitter.on`, scheduler tasks expose only the function form). `add(x)` returns `x` for inline capture; idempotent; every item is disposed even if one throws (first error rethrown after); `child()` nests a sub-scope. `createOIMDisposeScope()` factory. New exports: `OIMDisposeScope`, `createOIMDisposeScope`, `IOIMDisposable` (`{ destroy(): void }`), `TOIMDisposable` (`IOIMDisposable | (() => void)`).
544
+
545
+ ```ts
546
+ const scope = new OIMDisposeScope();
547
+ const queue = scope.add(new OIMEventQueue());
548
+ const tasks = scope.add(new OIMReactiveCollection(queue, { selectPk: t => t.id }));
549
+ const byStatus = scope.add(tasks.indexFactory.setBasedIndex());
550
+ scope.add(select.entitiesBySetIndexKey(byStatus, 'open').watch(render));
551
+ // …later — one call, correct LIFO order, no manual bookkeeping:
552
+ scope.destroy();
553
+ ```
554
+
473
555
  ## DevTools UI (`@oimdb/mcp devtools`)
474
556
 
475
557
  For human developers — runs a local web UI at `http://localhost:7433`:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oimdb/core",
3
- "version": "3.7.0",
3
+ "version": "3.9.0",
4
4
  "description": "Core in-memory data library with type-safe indices, reactive subscriptions, and event processing",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",
@@ -29,6 +29,7 @@
29
29
  "build": "tsup src/index.ts --dts --format esm,cjs",
30
30
  "test": "jest",
31
31
  "type-check": "tsc --noEmit",
32
+ "bench": "tsx scripts/run-bench.ts",
32
33
  "clean": "rimraf dist",
33
34
  "prepack": "node -e \"require('fs').copyFileSync('../../llms.txt', 'llms.txt')\"",
34
35
  "prepublishOnly": "npm run type-check && npm test && npm run build"