@oimdb/core 3.11.1 → 3.12.1

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/README.md CHANGED
@@ -1331,6 +1331,24 @@ const unsubscribe = users.updateEventEmitter.subscribeOnKey('user1', () => {
1331
1331
  unsubscribe();
1332
1332
  ```
1333
1333
 
1334
+ ## 🕳️ Missing entities: the holes policy
1335
+
1336
+ A pk can point at nothing — never upserted, or removed while an index still holds it. One rule
1337
+ applies everywhere:
1338
+
1339
+ > **Reads are length-aligned and surface the gap as `undefined`. Dropping it is always a separate,
1340
+ > explicitly named call.**
1341
+
1342
+ ```ts
1343
+ collection.getManyByPks(['u1', 'gone', 'u2']); // [User, undefined, User]
1344
+ collection.getManyByPksCompact(['u1', 'gone', 'u2']); // [User, User] — SHORTER, deliberate
1345
+ ```
1346
+
1347
+ `index.getEntitiesByKey`, `globalIndex.getEntities`, `stream.getEntitiesByKey` and every
1348
+ list-returning selector follow the same shape. A **derived** index is dense by construction, so the
1349
+ `| undefined` there is spurious; a **manual** index (`setPks`, composite) can hold a dangling pk, and
1350
+ compacting it away is how a torn state becomes invisible.
1351
+
1334
1352
  ## 📚 API Reference
1335
1353
 
1336
1354
  ### DX Factories
package/dist/index.cjs CHANGED
@@ -1415,6 +1415,13 @@ var OIMCollectionStoreMapDriven = class extends OIMCollectionStore {
1415
1415
  return this.slots.get(pk)?.item;
1416
1416
  }
1417
1417
  getManyByPks(pks) {
1418
+ const result = new Array(pks.length);
1419
+ for (let i = 0; i < pks.length; i++) {
1420
+ result[i] = this.getOneByPk(pks[i]);
1421
+ }
1422
+ return result;
1423
+ }
1424
+ getManyByPksCompact(pks) {
1418
1425
  const result = [];
1419
1426
  result.length = pks.length;
1420
1427
  let writeIndex = 0;
@@ -1495,9 +1502,14 @@ var OIMCollection = class {
1495
1502
  getOneByPk(pk) {
1496
1503
  return this.store.getOneByPk(pk);
1497
1504
  }
1505
+ /** Length-aligned with `pks`; `undefined` wherever a pk has no entity. */
1498
1506
  getManyByPks(pks) {
1499
1507
  return this.store.getManyByPks(pks);
1500
1508
  }
1509
+ /** Holes filtered out — may be SHORTER than `pks`. Deliberate; see the store's docs. */
1510
+ getManyByPksCompact(pks) {
1511
+ return this.store.getManyByPksCompact(pks);
1512
+ }
1501
1513
  getSlotByPk(pk) {
1502
1514
  return this.store.getSlotByPk(pk);
1503
1515
  }
@@ -4375,6 +4387,13 @@ var OIMCollectionStoreTrieDriven = class extends OIMCollectionStore {
4375
4387
  return this.slots.get(pk)?.item;
4376
4388
  }
4377
4389
  getManyByPks(pks) {
4390
+ const result = new Array(pks.length);
4391
+ for (let i = 0; i < pks.length; i++) {
4392
+ result[i] = this.getOneByPk(pks[i]);
4393
+ }
4394
+ return result;
4395
+ }
4396
+ getManyByPksCompact(pks) {
4378
4397
  const result = [];
4379
4398
  result.length = pks.length;
4380
4399
  let writeIndex = 0;
package/dist/index.d.cts CHANGED
@@ -62,7 +62,18 @@ declare abstract class OIMCollectionStore<TEntity extends object, TPk extends TO
62
62
  abstract removeOneByPk(pk: TPk): void;
63
63
  abstract removeManyByPks(pks: readonly TPk[]): void;
64
64
  abstract getOneByPk(pk: TPk): TEntity | undefined;
65
- abstract getManyByPks(pks: readonly TPk[]): TEntity[];
65
+ /**
66
+ * Length-aligned with `pks`: `undefined` wherever a pk has no entity. A missing entity is a real
67
+ * state of the store, so the read surfaces it instead of deciding for the caller — the same
68
+ * contract as {@link OIMIndex.getEntitiesByKey} and every selector.
69
+ */
70
+ abstract getManyByPks(pks: readonly TPk[]): (TEntity | undefined)[];
71
+ /**
72
+ * The same read with holes filtered out, so the result may be SHORTER than `pks`. Deliberate and
73
+ * separately named: silently swallowing a missing entity is exactly what makes torn state hard to
74
+ * find. Safe when the pks come from a derived index (dense by construction).
75
+ */
76
+ abstract getManyByPksCompact(pks: readonly TPk[]): TEntity[];
66
77
  abstract getAll(): TEntity[];
67
78
  abstract getAllPks(): TPk[];
68
79
  abstract countAll(): number;
@@ -785,7 +796,10 @@ declare class OIMCollection<TEntity extends object, TPk extends TOIMKey> {
785
796
  protected readonly updateEntity: TOIMEntityUpdater<TEntity>;
786
797
  constructor(opts?: TOIMCollectionOptions<TEntity, TPk>);
787
798
  getOneByPk(pk: TPk): TEntity | undefined;
788
- getManyByPks(pks: readonly TPk[]): TEntity[];
799
+ /** Length-aligned with `pks`; `undefined` wherever a pk has no entity. */
800
+ getManyByPks(pks: readonly TPk[]): (TEntity | undefined)[];
801
+ /** Holes filtered out — may be SHORTER than `pks`. Deliberate; see the store's docs. */
802
+ getManyByPksCompact(pks: readonly TPk[]): TEntity[];
789
803
  getSlotByPk(pk: TPk): TOIMEntitySlot<TEntity, TPk> | undefined;
790
804
  getOrReserveSlotByPk(pk: TPk): TOIMEntitySlot<TEntity, TPk>;
791
805
  /**
@@ -1796,7 +1810,12 @@ type TOIMDerivedCollectionOptions<TDerived extends object, TPk extends TOIMKey>
1796
1810
  * listens to the source and keeps itself in sync — add / update / remove.
1797
1811
  *
1798
1812
  * It is a first-class `OIMReactiveCollection`, so it can be indexed, selected,
1799
- * and derived-from again (join → join). Maintenance runs on the source's
1813
+ * and derived-from again — a CHAIN of one-to-one projections, not a join. There
1814
+ * is deliberately no multi-source variant: a joined row would need a primary key
1815
+ * nobody references, and an identity nobody uses is not an identity. Combine
1816
+ * across collections with one `OIMComputed`, or, if something really does
1817
+ * reference the combined thing, give it its own collection and write to it.
1818
+ * Maintenance runs on the source's
1800
1819
  * synchronous UPDATE channel, so a chain of derivations stays consistent within
1801
1820
  * the same flush; delivery to keyed subscribers is batched on flush as usual.
1802
1821
  *
@@ -1900,7 +1919,8 @@ declare class OIMCollectionStoreMapDriven<TEntity extends object, TPk extends TO
1900
1919
  removeOneByPk(pk: TPk): void;
1901
1920
  removeManyByPks(pks: readonly TPk[]): void;
1902
1921
  getOneByPk(pk: TPk): TEntity | undefined;
1903
- getManyByPks(pks: readonly TPk[]): TEntity[];
1922
+ getManyByPks(pks: readonly TPk[]): (TEntity | undefined)[];
1923
+ getManyByPksCompact(pks: readonly TPk[]): TEntity[];
1904
1924
  getAll(): TEntity[];
1905
1925
  countAll(): number;
1906
1926
  clear(): void;
@@ -1951,7 +1971,8 @@ declare class OIMCollectionStoreTrieDriven<TEntity extends object> extends OIMCo
1951
1971
  removeOneByPk(pk: TOIMKeyPath): void;
1952
1972
  removeManyByPks(pks: readonly TOIMKeyPath[]): void;
1953
1973
  getOneByPk(pk: TOIMKeyPath): TEntity | undefined;
1954
- getManyByPks(pks: readonly TOIMKeyPath[]): TEntity[];
1974
+ getManyByPks(pks: readonly TOIMKeyPath[]): (TEntity | undefined)[];
1975
+ getManyByPksCompact(pks: readonly TOIMKeyPath[]): TEntity[];
1955
1976
  getAll(): TEntity[];
1956
1977
  countAll(): number;
1957
1978
  clear(): void;
package/dist/index.d.ts CHANGED
@@ -62,7 +62,18 @@ declare abstract class OIMCollectionStore<TEntity extends object, TPk extends TO
62
62
  abstract removeOneByPk(pk: TPk): void;
63
63
  abstract removeManyByPks(pks: readonly TPk[]): void;
64
64
  abstract getOneByPk(pk: TPk): TEntity | undefined;
65
- abstract getManyByPks(pks: readonly TPk[]): TEntity[];
65
+ /**
66
+ * Length-aligned with `pks`: `undefined` wherever a pk has no entity. A missing entity is a real
67
+ * state of the store, so the read surfaces it instead of deciding for the caller — the same
68
+ * contract as {@link OIMIndex.getEntitiesByKey} and every selector.
69
+ */
70
+ abstract getManyByPks(pks: readonly TPk[]): (TEntity | undefined)[];
71
+ /**
72
+ * The same read with holes filtered out, so the result may be SHORTER than `pks`. Deliberate and
73
+ * separately named: silently swallowing a missing entity is exactly what makes torn state hard to
74
+ * find. Safe when the pks come from a derived index (dense by construction).
75
+ */
76
+ abstract getManyByPksCompact(pks: readonly TPk[]): TEntity[];
66
77
  abstract getAll(): TEntity[];
67
78
  abstract getAllPks(): TPk[];
68
79
  abstract countAll(): number;
@@ -785,7 +796,10 @@ declare class OIMCollection<TEntity extends object, TPk extends TOIMKey> {
785
796
  protected readonly updateEntity: TOIMEntityUpdater<TEntity>;
786
797
  constructor(opts?: TOIMCollectionOptions<TEntity, TPk>);
787
798
  getOneByPk(pk: TPk): TEntity | undefined;
788
- getManyByPks(pks: readonly TPk[]): TEntity[];
799
+ /** Length-aligned with `pks`; `undefined` wherever a pk has no entity. */
800
+ getManyByPks(pks: readonly TPk[]): (TEntity | undefined)[];
801
+ /** Holes filtered out — may be SHORTER than `pks`. Deliberate; see the store's docs. */
802
+ getManyByPksCompact(pks: readonly TPk[]): TEntity[];
789
803
  getSlotByPk(pk: TPk): TOIMEntitySlot<TEntity, TPk> | undefined;
790
804
  getOrReserveSlotByPk(pk: TPk): TOIMEntitySlot<TEntity, TPk>;
791
805
  /**
@@ -1796,7 +1810,12 @@ type TOIMDerivedCollectionOptions<TDerived extends object, TPk extends TOIMKey>
1796
1810
  * listens to the source and keeps itself in sync — add / update / remove.
1797
1811
  *
1798
1812
  * It is a first-class `OIMReactiveCollection`, so it can be indexed, selected,
1799
- * and derived-from again (join → join). Maintenance runs on the source's
1813
+ * and derived-from again — a CHAIN of one-to-one projections, not a join. There
1814
+ * is deliberately no multi-source variant: a joined row would need a primary key
1815
+ * nobody references, and an identity nobody uses is not an identity. Combine
1816
+ * across collections with one `OIMComputed`, or, if something really does
1817
+ * reference the combined thing, give it its own collection and write to it.
1818
+ * Maintenance runs on the source's
1800
1819
  * synchronous UPDATE channel, so a chain of derivations stays consistent within
1801
1820
  * the same flush; delivery to keyed subscribers is batched on flush as usual.
1802
1821
  *
@@ -1900,7 +1919,8 @@ declare class OIMCollectionStoreMapDriven<TEntity extends object, TPk extends TO
1900
1919
  removeOneByPk(pk: TPk): void;
1901
1920
  removeManyByPks(pks: readonly TPk[]): void;
1902
1921
  getOneByPk(pk: TPk): TEntity | undefined;
1903
- getManyByPks(pks: readonly TPk[]): TEntity[];
1922
+ getManyByPks(pks: readonly TPk[]): (TEntity | undefined)[];
1923
+ getManyByPksCompact(pks: readonly TPk[]): TEntity[];
1904
1924
  getAll(): TEntity[];
1905
1925
  countAll(): number;
1906
1926
  clear(): void;
@@ -1951,7 +1971,8 @@ declare class OIMCollectionStoreTrieDriven<TEntity extends object> extends OIMCo
1951
1971
  removeOneByPk(pk: TOIMKeyPath): void;
1952
1972
  removeManyByPks(pks: readonly TOIMKeyPath[]): void;
1953
1973
  getOneByPk(pk: TOIMKeyPath): TEntity | undefined;
1954
- getManyByPks(pks: readonly TOIMKeyPath[]): TEntity[];
1974
+ getManyByPks(pks: readonly TOIMKeyPath[]): (TEntity | undefined)[];
1975
+ getManyByPksCompact(pks: readonly TOIMKeyPath[]): TEntity[];
1955
1976
  getAll(): TEntity[];
1956
1977
  countAll(): number;
1957
1978
  clear(): void;
package/dist/index.js CHANGED
@@ -1265,6 +1265,13 @@ var OIMCollectionStoreMapDriven = class extends OIMCollectionStore {
1265
1265
  return this.slots.get(pk)?.item;
1266
1266
  }
1267
1267
  getManyByPks(pks) {
1268
+ const result = new Array(pks.length);
1269
+ for (let i = 0; i < pks.length; i++) {
1270
+ result[i] = this.getOneByPk(pks[i]);
1271
+ }
1272
+ return result;
1273
+ }
1274
+ getManyByPksCompact(pks) {
1268
1275
  const result = [];
1269
1276
  result.length = pks.length;
1270
1277
  let writeIndex = 0;
@@ -1345,9 +1352,14 @@ var OIMCollection = class {
1345
1352
  getOneByPk(pk) {
1346
1353
  return this.store.getOneByPk(pk);
1347
1354
  }
1355
+ /** Length-aligned with `pks`; `undefined` wherever a pk has no entity. */
1348
1356
  getManyByPks(pks) {
1349
1357
  return this.store.getManyByPks(pks);
1350
1358
  }
1359
+ /** Holes filtered out — may be SHORTER than `pks`. Deliberate; see the store's docs. */
1360
+ getManyByPksCompact(pks) {
1361
+ return this.store.getManyByPksCompact(pks);
1362
+ }
1351
1363
  getSlotByPk(pk) {
1352
1364
  return this.store.getSlotByPk(pk);
1353
1365
  }
@@ -4225,6 +4237,13 @@ var OIMCollectionStoreTrieDriven = class extends OIMCollectionStore {
4225
4237
  return this.slots.get(pk)?.item;
4226
4238
  }
4227
4239
  getManyByPks(pks) {
4240
+ const result = new Array(pks.length);
4241
+ for (let i = 0; i < pks.length; i++) {
4242
+ result[i] = this.getOneByPk(pks[i]);
4243
+ }
4244
+ return result;
4245
+ }
4246
+ getManyByPksCompact(pks) {
4228
4247
  const result = [];
4229
4248
  result.length = pks.length;
4230
4249
  let writeIndex = 0;
package/llms.txt CHANGED
@@ -203,7 +203,7 @@ rows.subscribeOnKey('team1', () => { for (const row of rows.getByKey('team1')) {
203
203
 
204
204
  ### Derived collections
205
205
 
206
- `OIMDerivedCollection(queue, source, derive, opts?)` is a reactive collection whose entities are derived **one-to-one** from a source collection — `derived[pk] = derive(source[pk], pk)`, keyed by the SOURCE pk — kept in sync (add / update / remove) automatically. It is itself a first-class `OIMReactiveCollection`, so it can be indexed, selected, and derived-from again (join → join); maintenance runs on the source's **synchronous** update channel, so a chain of derivations stays consistent within a flush (delivery to keyed subscribers is batched on flush as usual). It parallels `OIMDerivedCollectionIndex*` and needs no compute runtime.
206
+ `OIMDerivedCollection(queue, source, derive, opts?)` is a reactive collection whose entities are derived **one-to-one** from a source collection — `derived[pk] = derive(source[pk], pk)`, keyed by the SOURCE pk — kept in sync (add / update / remove) automatically. It is itself a first-class `OIMReactiveCollection`, so it can be indexed, selected, and derived-from again — a CHAIN of one-to-one projections, NOT a join. There is deliberately no multi-source derived collection: a joined row would need a pk nobody references, and an identity nobody uses is not an identity; combine across collections with one `OIMComputed`, or give the combined thing its own collection if something genuinely references it; maintenance runs on the source's **synchronous** update channel, so a chain of derivations stays consistent within a flush (delivery to keyed subscribers is batched on flush as usual). It parallels `OIMDerivedCollectionIndex*` and needs no compute runtime.
207
207
 
208
208
  ```ts
209
209
  const nameView = new OIMDerivedCollection(queue, users,
@@ -395,41 +395,55 @@ Signal hooks: `useSelectEntityByPkSignal(collection, pk)`, `useSelectPksByIndexK
395
395
 
396
396
  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.
397
397
 
398
+ ### Missing entities: the holes policy
399
+
400
+ A pk can point at nothing (never upserted, or removed while an index still holds it). ONE rule across the library: **reads are length-aligned and surface the gap as `undefined`; dropping it is always a separate, explicitly named call.** `collection.getManyByPks(pks)` → `(TEntity | undefined)[]`, aligned. `collection.getManyByPksCompact(pks)` → `TEntity[]`, filtered and possibly SHORTER. Same shape for `index.getEntitiesByKey`, `globalIndex.getEntities`, `stream.getEntitiesByKey` and every list-returning selector. A DERIVED index is dense by construction, so there the `| undefined` is spurious — but a MANUAL index (`setPks`, composite) can genuinely hold a dangling pk, and compacting it away is how torn state becomes invisible. `@oimdb/exodra` mirrors this: default index read keeps holes, `.compact(key)` filters, `.unsafeDense(key)` only retypes and checks nothing.
401
+
398
402
  ## Exodra (`@oimdb/exodra`)
399
403
 
400
404
  ```bash
401
405
  npm install @oimdb/exodra @oimdb/core
402
406
  ```
403
407
 
404
- Depends only on the type-only `@exodra/reactivity-types` (never the reactivity runtime); peer dep `@oimdb/core`. Your Exodra app already provides `@exodra/reactivity` for the runtime (`bindable`, `derive`, `h`). 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).
408
+ The Exodra counterpart of `@oimdb/react`: instead of a hook + re-render, every OIMDB read 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. Depends only on the type-only `@exodra/reactivity-types` (never the reactivity runtime); peer dep `@oimdb/core`. Everything produced is read-only, lazy + ref-counted (upstream subscribed only while a downstream subscriber exists → O(visible)) and SSR-safe (`getValue()` reads through, valid with no subscription — a reactive key resolves on every read, never stale).
405
409
 
406
410
  ```ts
407
- import { fromSelector, bindSelectors, fromComputed, combine, entityRows } from '@oimdb/exodra';
408
- import { bindable, derive } from '@exodra/reactivity';
411
+ import { exoDb } from '@oimdb/exodra';
412
+ import { bindable, derive, h } from '@exodra/reactivity';
409
413
 
410
- // wrap any OIMSelector
411
- const alice = fromSelector(kit.select.byPk('u1')); // TExoBindable<User | undefined>
414
+ const byTeam = usersKit.indexFactory.derivedSetIndex<string>(u => u.teamId);
415
+ const byTeamOrdered = usersKit.indexFactory.derivedArrayIndex<string>(u => u.teamId, { orderBy: u => u.name });
416
+ const online = usersKit.indexFactory.derivedSetGlobalIndex({ filter: u => u.online });
417
+
418
+ const db = exoDb({
419
+ users: { kit: usersKit, indexes: { byTeam, byTeamOrdered, online } },
420
+ tags: { kit: tagsKit }, // no indexes → byPk / byPks only
421
+ });
412
422
 
413
- // mirror OIMCollectionSelectors → bindables; keys accept a bindable (reactive key re-points the sub)
414
- const bound = bindSelectors(kit.select);
415
- const selectedTeam = bindable('team-1');
416
- const members = bound.entitiesBySetIndexKey(usersByTeam, selectedTeam);
417
- // <ul bindable={{ children: entityRows(memberPks, pk => bound.byPk(pk), (u, pk) => renderRow(u)) }} />
423
+ const team = bindable('t1');
424
+ db.users.byPk('u1'); // TExoBindable<User | undefined>
425
+ db.users.byTeam(team); // set index — the kind is inferred from the index
426
+ db.users.online(); // global index — no key, and none is asked for
427
+ db.users.byTeamOrdered.rows(team, render); // → bindables.children (identity-stable)
428
+ db.users.byTeamOrdered.list('t1', render); // → bindableLists.children (O(delta), fixed key)
418
429
  ```
419
430
 
420
431
  | Export | Purpose |
421
432
  |---|---|
422
- | `fromSelector(selector, opts?)` | primary adapter: `OIMSelector<T>` → `TExoBindable<T>` |
423
- | `fromOimdb(read, subscribe, opts?)` | escape hatch for a raw read/subscribe pair |
424
- | `fromSelectorFactory(key, makeSelector, opts?)` | reactive key (`TKey \| bindable`) → rebuilds the selector on change |
425
- | `fromComputed(computed, opts?)` | `OIMComputed<T>` → `TExoBindable<T>` |
426
- | `combine(sources, fn)` | multi-source derive (Exodra's own `derive` is single-source) |
427
- | `bindSelectors(selectors, defaults?)` | mirror of `OIMCollectionSelectors`, each method returning a bindable |
428
- | `keyedChildren(source, { key, render })` / `entityRows(order, bindEntity, render)` | identity-stable list children (field edit = reconcile no-op, focus-safe) |
429
- | `listFromCommandStream(stream, key, render)` | O(delta) path: OIMDB ordered command stream → Exodra `bindableList` (commands map 1:1 to `TExoListOp`) |
430
- | `read*/subscribe*` (6 fns) | low-level pairs mirroring an inline app bridge 1:1 (find-replace migration) |
431
-
432
- 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).
433
+ | `exoDb(spec)` | whole system at once: `{ name: { kit, indexes? } }` → record of typed views |
434
+ | `exoCollection(kit, indexes?)` | one collection's Exodra view; `kit` stays reachable for writes |
435
+ | `<collection>.all()` | every entity, reactively — non-stale `<option>` lists, and the change signal for `exoCombine` when a row key depends on another collection |
436
+ | `exoSource(read, subscribe)` | base primitive: a raw pair → a lazy, ref-counted bindable |
437
+ | `exoSelector(selector)` / `exoComputed(computed)` | an `OIMSelector` / `OIMComputed` → a bindable |
438
+ | `exoKeyed(keyBindable, makeSelector)` | a MOVING key: repoints the selector as the key changes |
439
+ | `exoCombine(sources, fn)` | multi-source derive (Exodra's own `derive` is single-source) |
440
+ | `exoRows(keys, render)` | identity-stable children for any items |
441
+ | `exoList(stream, key, render)` | OIMDB ordered command stream → Exodra `bindableList` (commands map 1:1 to `TExoListOp`) |
442
+
443
+ Each named index becomes a CALLABLE member of that name carrying `pks`, `rows`, `subscribe` (and `list` on ordered ones), so the caller never picks a method to match the index kind and never passes the index object again. Any key accepts `TKey | TOIMExodraReadable<TKey>`. Dispatch is structural: array-based `getPksByKey` returns `TPk[]`, set-based returns `Set<TPk>`, keyless ones have none; composite indexes are the same classes with an array key, resolved per call via `Array.isArray(key)`. **`list` exists only on array-based indexes** — a stream is derived by diffing an order and a set has none; enforced in the types, and it builds/memoizes the stream itself. `indexes` is a second argument because a kit does not own its indexes (`kit.indexFactory` creates them, the app keeps them); if core ever lets a kit hold named indexes this becomes optional without a break.
444
+
445
+ **No function in this package takes an equality option, and there is no options type.** Equality belongs to whoever owns the value: `OIMSelector.areEqual` for selectors (override it), `OIMComputed`'s `compare` for computeds (pass at construction), your own `fn` for `exoCombine` (return a stable reference when content is unchanged), your own `subscribe` callback for `exoSource(read, subscribe)` (compare there and don't call `onChange` — that callback IS the extension point). Those owners run BELOW the bridge, so a filter here could only reject what they passed, never resurrect what they dropped; in-place entity updaters need no configuration anywhere. `exoCombine` drops recomputes landing on the same value, collapsing the N notifications N sources produce for one logical change — not glitch-free though, so put real fan-in in ONE `OIMComputed` forwarded via `exoComputed`. `exoRows` reads are idempotent (same array reference back, no render, no eviction, until the key sequence changes) and require UNIQUE keys — a duplicate throws rather than placing one schema object twice. `exoList` memoizes `render` per item, so repeated `snapshot()` calls return the same row objects (identity is what Exodra reconciles on); the memo is pruned against the live order on snapshot and amortized past 2x the live row count. Reads are length-aligned WITH HOLES by default (`readonly (T | undefined)[]`, matching `@oimdb/react`) — a missing entity is a real state of the store and hiding it is not the bridge's call. Dropping holes is explicit and never a flag: `<index>.compact(key)` filters (may be SHORTER — on MANUAL `setPks` pks that silently swallows a torn state), `<index>.unsafeDense(key)` filters and checks NOTHING and just retypes to `readonly T[]` — correct for a DERIVED index, which is dense by construction, and `unsafe` because the guarantee is the caller's. Pure aliases of a single core call (`getOneByPk`, `subscribeOnKey`, `getPksByKey`) are NOT exported — call core directly. Pair `exoDb` with Exodra's own DI: `createContextKey<typeof db>('db')`, created by the app since this package pulls types only. Exodra bucket bindings subscribe at *build* (not `onExoMount`); `<index>.subscribe(key, onChange)` covers `onExoMount`-scoped needs. No function takes more than one shape of argument: a fixed key and a moving key are different members (`<index>(key)` vs `<index>.for(keyBindable)`), never one member that inspects what it was given. `exoCombine`'s FIRST emit after subscribing always propagates — OIMDB sources fire synchronously on subscribe and a binding is built from `getValue()` then wired a moment later, so suppressing it leaves the region frozen at its build-time value. Sorting and filtering belong in the index (`derivedArrayIndex({ orderBy })`, or emit no key for excluded entities), not in `rows` — there they are maintained incrementally instead of recomputed per read. The PRIMITIVES are the load-bearing API: screens that sort/filter beyond an index, key rows off related data, or fan in across collections are built from `exoRows` over an `OIMComputed` forwarded by `exoComputed`, and that is the intended path, not a fallback. Patterns: ordered column → order in the index (`derivedArrayIndex({ orderBy })`), never a per-render sort; filtered list → emit no key for excluded entities; a row that follows another collection → bind INSIDE the row (`derive(exoSelector(other.select.byPk(id)), …)`) so only that node updates; a key is IDENTITY, never invalidation — `key: t => t.id` and nothing else; a composite key re-derives the whole key sequence per read and rebuilds rows whose markup did not change (2000 rows, one added comment: 2.44ms + 1 wasted rebuild vs 0.73ms + zero when the count is bound inside the row), so bind what varies WITHIN a row via `derive(db.comments.byTask.pks(pk), c => c.length)`; `<select>` options → `all()`, never a one-off `getAll()` at build; aggregate → ONE `OIMComputed` + `exoComputed`; moving selection → `<index>.for(keyBindable)`; `onExoMount` scope → `<index>.subscribe(key, onChange)`. Write-side is intentionally omitted (apps write via orchestration, e.g. cnstra, not from views).
446
+
433
447
 
434
448
  ## Persist (`@oimdb/persist`)
435
449
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oimdb/core",
3
- "version": "3.11.1",
3
+ "version": "3.12.1",
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",