@oimdb/core 3.11.0 → 3.12.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/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
@@ -728,19 +728,13 @@ var OIMCarrierKeyedEmitter = class {
728
728
  // doubles as membership: a carrier is in this array iff `carrier.dirty`.
729
729
  this.dirtyCarriers = [];
730
730
  this.isFlushEnqueued = false;
731
- // Number of live subscriptions whose single handler spans 2+ distinct
732
- // carriers (a `subscribeOnKeys` over distinct keys). While this is > 0 a
733
- // flush coalesces delivery so such a handler fires at most ONCE per flush,
734
- // not once per changed carrier — the callback carries no key, so extra
735
- // fires are pure waste. At 0 the hot single-subscriber path pays nothing:
736
- // no per-flush Set, no membership checks.
737
- this.multiCarrierSubscriptions = 0;
738
731
  this.onFlush = () => {
739
732
  this.isFlushEnqueued = false;
740
733
  if (this.dirtyCarriers.length === 0) return;
741
734
  const flushing = this.dirtyCarriers;
742
735
  this.dirtyCarriers = [];
743
- const delivered = this.multiCarrierSubscriptions > 0 ? this.deliveredPool ?? (this.deliveredPool = /* @__PURE__ */ new Set()) : void 0;
736
+ const multiCarrierHandlers = this.multiCarrierHandlers;
737
+ const delivered = multiCarrierHandlers !== void 0 && multiCarrierHandlers.size > 0 ? this.deliveredPool ?? (this.deliveredPool = /* @__PURE__ */ new Set()) : void 0;
744
738
  let i = 0;
745
739
  try {
746
740
  for (; i < flushing.length; i++) {
@@ -768,12 +762,14 @@ var OIMCarrierKeyedEmitter = class {
768
762
  carriers.push(carrier);
769
763
  }
770
764
  const spansMultipleCarriers = carriers.length > 1;
771
- if (spansMultipleCarriers) this.multiCarrierSubscriptions++;
765
+ if (spansMultipleCarriers) this.retainMultiCarrierHandler(handler);
772
766
  let unsubscribed = false;
773
767
  return () => {
774
768
  if (unsubscribed) return;
775
769
  unsubscribed = true;
776
- if (spansMultipleCarriers) this.multiCarrierSubscriptions--;
770
+ if (spansMultipleCarriers) {
771
+ this.releaseMultiCarrierHandler(handler);
772
+ }
777
773
  for (let i = 0; i < carriers.length; i++) {
778
774
  this.removeHandler(carriers[i], handler);
779
775
  }
@@ -784,10 +780,25 @@ var OIMCarrierKeyedEmitter = class {
784
780
  if (carrier) this.removeHandler(carrier, handler);
785
781
  }
786
782
  unsubscribeFromKeys(keys, handler) {
783
+ if (keys.length > 1) this.releaseMultiCarrierHandler(handler);
787
784
  for (let i = 0; i < keys.length; i++) {
788
785
  this.unsubscribeFromKey(keys[i], handler);
789
786
  }
790
787
  }
788
+ /** Take a reference for a handler whose subscription spans several carriers. */
789
+ retainMultiCarrierHandler(handler) {
790
+ const handlers = this.multiCarrierHandlers ?? (this.multiCarrierHandlers = /* @__PURE__ */ new Map());
791
+ handlers.set(handler, (handlers.get(handler) ?? 0) + 1);
792
+ }
793
+ /** Drop one reference; the handler leaves the set when none are left. */
794
+ releaseMultiCarrierHandler(handler) {
795
+ const handlers = this.multiCarrierHandlers;
796
+ if (handlers === void 0) return;
797
+ const count = handlers.get(handler);
798
+ if (count === void 0) return;
799
+ if (count > 1) handlers.set(handler, count - 1);
800
+ else handlers.delete(handler);
801
+ }
791
802
  addHandler(carrier, handler) {
792
803
  let subscribers = carrier.subscribers;
793
804
  if (!subscribers) {
@@ -876,6 +887,8 @@ var OIMCarrierKeyedEmitter = class {
876
887
  }
877
888
  this.dirtyCarriers = [];
878
889
  this.isFlushEnqueued = false;
890
+ this.multiCarrierHandlers?.clear();
891
+ this.deliveredPool?.clear();
879
892
  }
880
893
  assertNotInFlush() {
881
894
  if (this.queue.isInFlush) {
@@ -892,13 +905,12 @@ var OIMCarrierKeyedEmitter = class {
892
905
  notify(carrier, delivered) {
893
906
  const subscribers = carrier.subscribers;
894
907
  if (!subscribers || subscribers.size === 0) return;
908
+ const multiCarrierHandlers = delivered ? this.multiCarrierHandlers : void 0;
895
909
  if (subscribers.size === 1) {
896
910
  const only = subscribers.values().next().value;
897
911
  if (!only) return;
898
- if (delivered) {
899
- const size = delivered.size;
900
- delivered.add(only);
901
- if (delivered.size === size) return;
912
+ if (this.isAlreadyDelivered(only, delivered, multiCarrierHandlers)) {
913
+ return;
902
914
  }
903
915
  only();
904
916
  return;
@@ -908,14 +920,25 @@ var OIMCarrierKeyedEmitter = class {
908
920
  for (let i = 0; i < snapshot.length; i++) {
909
921
  const handler = snapshot[i];
910
922
  if (!subscribers.has(handler)) continue;
911
- if (delivered) {
912
- const size = delivered.size;
913
- delivered.add(handler);
914
- if (delivered.size === size) continue;
923
+ if (this.isAlreadyDelivered(handler, delivered, multiCarrierHandlers)) {
924
+ continue;
915
925
  }
916
926
  handler();
917
927
  }
918
928
  }
929
+ /**
930
+ * Whether this handler already fired in the current flush and must be
931
+ * skipped. Only multi-carrier handlers are tracked; everything else always
932
+ * delivers. Membership is recorded via the size delta of a single `add` —
933
+ * one hash probe instead of `has` + `add`.
934
+ */
935
+ isAlreadyDelivered(handler, delivered, multiCarrierHandlers) {
936
+ if (delivered === void 0) return false;
937
+ if (multiCarrierHandlers?.has(handler) !== true) return false;
938
+ const size = delivered.size;
939
+ delivered.add(handler);
940
+ return delivered.size === size;
941
+ }
919
942
  };
920
943
 
921
944
  // src/core/OIMKeyedCarrierProvider.ts
@@ -1392,6 +1415,13 @@ var OIMCollectionStoreMapDriven = class extends OIMCollectionStore {
1392
1415
  return this.slots.get(pk)?.item;
1393
1416
  }
1394
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) {
1395
1425
  const result = [];
1396
1426
  result.length = pks.length;
1397
1427
  let writeIndex = 0;
@@ -1472,9 +1502,14 @@ var OIMCollection = class {
1472
1502
  getOneByPk(pk) {
1473
1503
  return this.store.getOneByPk(pk);
1474
1504
  }
1505
+ /** Length-aligned with `pks`; `undefined` wherever a pk has no entity. */
1475
1506
  getManyByPks(pks) {
1476
1507
  return this.store.getManyByPks(pks);
1477
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
+ }
1478
1513
  getSlotByPk(pk) {
1479
1514
  return this.store.getSlotByPk(pk);
1480
1515
  }
@@ -4352,6 +4387,13 @@ var OIMCollectionStoreTrieDriven = class extends OIMCollectionStore {
4352
4387
  return this.slots.get(pk)?.item;
4353
4388
  }
4354
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) {
4355
4397
  const result = [];
4356
4398
  result.length = pks.length;
4357
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;
@@ -509,13 +520,17 @@ declare class OIMCarrierKeyedEmitter<TKey extends TOIMKey, TCarrier extends IOIM
509
520
  private readonly subscribedCarriers;
510
521
  private dirtyCarriers;
511
522
  private isFlushEnqueued;
512
- private multiCarrierSubscriptions;
523
+ private multiCarrierHandlers?;
513
524
  private deliveredPool?;
514
525
  constructor(queue: OIMEventQueue, carrierProvider: IOIMCarrierProvider<TKey, TCarrier>);
515
526
  subscribeOnKey(key: TKey, handler: TOIMEventHandler<void>): () => void;
516
527
  subscribeOnKeys(keys: readonly TKey[], handler: TOIMEventHandler<void>): () => void;
517
528
  unsubscribeFromKey(key: TKey, handler: TOIMEventHandler<void>): void;
518
529
  unsubscribeFromKeys(keys: readonly TKey[], handler: TOIMEventHandler<void>): void;
530
+ /** Take a reference for a handler whose subscription spans several carriers. */
531
+ private retainMultiCarrierHandler;
532
+ /** Drop one reference; the handler leaves the set when none are left. */
533
+ private releaseMultiCarrierHandler;
519
534
  private addHandler;
520
535
  private removeHandler;
521
536
  /** Fast path: the writer already holds the carrier. */
@@ -536,6 +551,13 @@ declare class OIMCarrierKeyedEmitter<TKey extends TOIMKey, TCarrier extends IOIM
536
551
  private scheduleFlush;
537
552
  private readonly onFlush;
538
553
  private notify;
554
+ /**
555
+ * Whether this handler already fired in the current flush and must be
556
+ * skipped. Only multi-carrier handlers are tracked; everything else always
557
+ * delivers. Membership is recorded via the size delta of a single `add` —
558
+ * one hash probe instead of `has` + `add`.
559
+ */
560
+ private isAlreadyDelivered;
539
561
  }
540
562
 
541
563
  /**
@@ -774,7 +796,10 @@ declare class OIMCollection<TEntity extends object, TPk extends TOIMKey> {
774
796
  protected readonly updateEntity: TOIMEntityUpdater<TEntity>;
775
797
  constructor(opts?: TOIMCollectionOptions<TEntity, TPk>);
776
798
  getOneByPk(pk: TPk): TEntity | undefined;
777
- 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[];
778
803
  getSlotByPk(pk: TPk): TOIMEntitySlot<TEntity, TPk> | undefined;
779
804
  getOrReserveSlotByPk(pk: TPk): TOIMEntitySlot<TEntity, TPk>;
780
805
  /**
@@ -1889,7 +1914,8 @@ declare class OIMCollectionStoreMapDriven<TEntity extends object, TPk extends TO
1889
1914
  removeOneByPk(pk: TPk): void;
1890
1915
  removeManyByPks(pks: readonly TPk[]): void;
1891
1916
  getOneByPk(pk: TPk): TEntity | undefined;
1892
- getManyByPks(pks: readonly TPk[]): TEntity[];
1917
+ getManyByPks(pks: readonly TPk[]): (TEntity | undefined)[];
1918
+ getManyByPksCompact(pks: readonly TPk[]): TEntity[];
1893
1919
  getAll(): TEntity[];
1894
1920
  countAll(): number;
1895
1921
  clear(): void;
@@ -1940,7 +1966,8 @@ declare class OIMCollectionStoreTrieDriven<TEntity extends object> extends OIMCo
1940
1966
  removeOneByPk(pk: TOIMKeyPath): void;
1941
1967
  removeManyByPks(pks: readonly TOIMKeyPath[]): void;
1942
1968
  getOneByPk(pk: TOIMKeyPath): TEntity | undefined;
1943
- getManyByPks(pks: readonly TOIMKeyPath[]): TEntity[];
1969
+ getManyByPks(pks: readonly TOIMKeyPath[]): (TEntity | undefined)[];
1970
+ getManyByPksCompact(pks: readonly TOIMKeyPath[]): TEntity[];
1944
1971
  getAll(): TEntity[];
1945
1972
  countAll(): number;
1946
1973
  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;
@@ -509,13 +520,17 @@ declare class OIMCarrierKeyedEmitter<TKey extends TOIMKey, TCarrier extends IOIM
509
520
  private readonly subscribedCarriers;
510
521
  private dirtyCarriers;
511
522
  private isFlushEnqueued;
512
- private multiCarrierSubscriptions;
523
+ private multiCarrierHandlers?;
513
524
  private deliveredPool?;
514
525
  constructor(queue: OIMEventQueue, carrierProvider: IOIMCarrierProvider<TKey, TCarrier>);
515
526
  subscribeOnKey(key: TKey, handler: TOIMEventHandler<void>): () => void;
516
527
  subscribeOnKeys(keys: readonly TKey[], handler: TOIMEventHandler<void>): () => void;
517
528
  unsubscribeFromKey(key: TKey, handler: TOIMEventHandler<void>): void;
518
529
  unsubscribeFromKeys(keys: readonly TKey[], handler: TOIMEventHandler<void>): void;
530
+ /** Take a reference for a handler whose subscription spans several carriers. */
531
+ private retainMultiCarrierHandler;
532
+ /** Drop one reference; the handler leaves the set when none are left. */
533
+ private releaseMultiCarrierHandler;
519
534
  private addHandler;
520
535
  private removeHandler;
521
536
  /** Fast path: the writer already holds the carrier. */
@@ -536,6 +551,13 @@ declare class OIMCarrierKeyedEmitter<TKey extends TOIMKey, TCarrier extends IOIM
536
551
  private scheduleFlush;
537
552
  private readonly onFlush;
538
553
  private notify;
554
+ /**
555
+ * Whether this handler already fired in the current flush and must be
556
+ * skipped. Only multi-carrier handlers are tracked; everything else always
557
+ * delivers. Membership is recorded via the size delta of a single `add` —
558
+ * one hash probe instead of `has` + `add`.
559
+ */
560
+ private isAlreadyDelivered;
539
561
  }
540
562
 
541
563
  /**
@@ -774,7 +796,10 @@ declare class OIMCollection<TEntity extends object, TPk extends TOIMKey> {
774
796
  protected readonly updateEntity: TOIMEntityUpdater<TEntity>;
775
797
  constructor(opts?: TOIMCollectionOptions<TEntity, TPk>);
776
798
  getOneByPk(pk: TPk): TEntity | undefined;
777
- 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[];
778
803
  getSlotByPk(pk: TPk): TOIMEntitySlot<TEntity, TPk> | undefined;
779
804
  getOrReserveSlotByPk(pk: TPk): TOIMEntitySlot<TEntity, TPk>;
780
805
  /**
@@ -1889,7 +1914,8 @@ declare class OIMCollectionStoreMapDriven<TEntity extends object, TPk extends TO
1889
1914
  removeOneByPk(pk: TPk): void;
1890
1915
  removeManyByPks(pks: readonly TPk[]): void;
1891
1916
  getOneByPk(pk: TPk): TEntity | undefined;
1892
- getManyByPks(pks: readonly TPk[]): TEntity[];
1917
+ getManyByPks(pks: readonly TPk[]): (TEntity | undefined)[];
1918
+ getManyByPksCompact(pks: readonly TPk[]): TEntity[];
1893
1919
  getAll(): TEntity[];
1894
1920
  countAll(): number;
1895
1921
  clear(): void;
@@ -1940,7 +1966,8 @@ declare class OIMCollectionStoreTrieDriven<TEntity extends object> extends OIMCo
1940
1966
  removeOneByPk(pk: TOIMKeyPath): void;
1941
1967
  removeManyByPks(pks: readonly TOIMKeyPath[]): void;
1942
1968
  getOneByPk(pk: TOIMKeyPath): TEntity | undefined;
1943
- getManyByPks(pks: readonly TOIMKeyPath[]): TEntity[];
1969
+ getManyByPks(pks: readonly TOIMKeyPath[]): (TEntity | undefined)[];
1970
+ getManyByPksCompact(pks: readonly TOIMKeyPath[]): TEntity[];
1944
1971
  getAll(): TEntity[];
1945
1972
  countAll(): number;
1946
1973
  clear(): void;
package/dist/index.js CHANGED
@@ -578,19 +578,13 @@ var OIMCarrierKeyedEmitter = class {
578
578
  // doubles as membership: a carrier is in this array iff `carrier.dirty`.
579
579
  this.dirtyCarriers = [];
580
580
  this.isFlushEnqueued = false;
581
- // Number of live subscriptions whose single handler spans 2+ distinct
582
- // carriers (a `subscribeOnKeys` over distinct keys). While this is > 0 a
583
- // flush coalesces delivery so such a handler fires at most ONCE per flush,
584
- // not once per changed carrier — the callback carries no key, so extra
585
- // fires are pure waste. At 0 the hot single-subscriber path pays nothing:
586
- // no per-flush Set, no membership checks.
587
- this.multiCarrierSubscriptions = 0;
588
581
  this.onFlush = () => {
589
582
  this.isFlushEnqueued = false;
590
583
  if (this.dirtyCarriers.length === 0) return;
591
584
  const flushing = this.dirtyCarriers;
592
585
  this.dirtyCarriers = [];
593
- const delivered = this.multiCarrierSubscriptions > 0 ? this.deliveredPool ?? (this.deliveredPool = /* @__PURE__ */ new Set()) : void 0;
586
+ const multiCarrierHandlers = this.multiCarrierHandlers;
587
+ const delivered = multiCarrierHandlers !== void 0 && multiCarrierHandlers.size > 0 ? this.deliveredPool ?? (this.deliveredPool = /* @__PURE__ */ new Set()) : void 0;
594
588
  let i = 0;
595
589
  try {
596
590
  for (; i < flushing.length; i++) {
@@ -618,12 +612,14 @@ var OIMCarrierKeyedEmitter = class {
618
612
  carriers.push(carrier);
619
613
  }
620
614
  const spansMultipleCarriers = carriers.length > 1;
621
- if (spansMultipleCarriers) this.multiCarrierSubscriptions++;
615
+ if (spansMultipleCarriers) this.retainMultiCarrierHandler(handler);
622
616
  let unsubscribed = false;
623
617
  return () => {
624
618
  if (unsubscribed) return;
625
619
  unsubscribed = true;
626
- if (spansMultipleCarriers) this.multiCarrierSubscriptions--;
620
+ if (spansMultipleCarriers) {
621
+ this.releaseMultiCarrierHandler(handler);
622
+ }
627
623
  for (let i = 0; i < carriers.length; i++) {
628
624
  this.removeHandler(carriers[i], handler);
629
625
  }
@@ -634,10 +630,25 @@ var OIMCarrierKeyedEmitter = class {
634
630
  if (carrier) this.removeHandler(carrier, handler);
635
631
  }
636
632
  unsubscribeFromKeys(keys, handler) {
633
+ if (keys.length > 1) this.releaseMultiCarrierHandler(handler);
637
634
  for (let i = 0; i < keys.length; i++) {
638
635
  this.unsubscribeFromKey(keys[i], handler);
639
636
  }
640
637
  }
638
+ /** Take a reference for a handler whose subscription spans several carriers. */
639
+ retainMultiCarrierHandler(handler) {
640
+ const handlers = this.multiCarrierHandlers ?? (this.multiCarrierHandlers = /* @__PURE__ */ new Map());
641
+ handlers.set(handler, (handlers.get(handler) ?? 0) + 1);
642
+ }
643
+ /** Drop one reference; the handler leaves the set when none are left. */
644
+ releaseMultiCarrierHandler(handler) {
645
+ const handlers = this.multiCarrierHandlers;
646
+ if (handlers === void 0) return;
647
+ const count = handlers.get(handler);
648
+ if (count === void 0) return;
649
+ if (count > 1) handlers.set(handler, count - 1);
650
+ else handlers.delete(handler);
651
+ }
641
652
  addHandler(carrier, handler) {
642
653
  let subscribers = carrier.subscribers;
643
654
  if (!subscribers) {
@@ -726,6 +737,8 @@ var OIMCarrierKeyedEmitter = class {
726
737
  }
727
738
  this.dirtyCarriers = [];
728
739
  this.isFlushEnqueued = false;
740
+ this.multiCarrierHandlers?.clear();
741
+ this.deliveredPool?.clear();
729
742
  }
730
743
  assertNotInFlush() {
731
744
  if (this.queue.isInFlush) {
@@ -742,13 +755,12 @@ var OIMCarrierKeyedEmitter = class {
742
755
  notify(carrier, delivered) {
743
756
  const subscribers = carrier.subscribers;
744
757
  if (!subscribers || subscribers.size === 0) return;
758
+ const multiCarrierHandlers = delivered ? this.multiCarrierHandlers : void 0;
745
759
  if (subscribers.size === 1) {
746
760
  const only = subscribers.values().next().value;
747
761
  if (!only) return;
748
- if (delivered) {
749
- const size = delivered.size;
750
- delivered.add(only);
751
- if (delivered.size === size) return;
762
+ if (this.isAlreadyDelivered(only, delivered, multiCarrierHandlers)) {
763
+ return;
752
764
  }
753
765
  only();
754
766
  return;
@@ -758,14 +770,25 @@ var OIMCarrierKeyedEmitter = class {
758
770
  for (let i = 0; i < snapshot.length; i++) {
759
771
  const handler = snapshot[i];
760
772
  if (!subscribers.has(handler)) continue;
761
- if (delivered) {
762
- const size = delivered.size;
763
- delivered.add(handler);
764
- if (delivered.size === size) continue;
773
+ if (this.isAlreadyDelivered(handler, delivered, multiCarrierHandlers)) {
774
+ continue;
765
775
  }
766
776
  handler();
767
777
  }
768
778
  }
779
+ /**
780
+ * Whether this handler already fired in the current flush and must be
781
+ * skipped. Only multi-carrier handlers are tracked; everything else always
782
+ * delivers. Membership is recorded via the size delta of a single `add` —
783
+ * one hash probe instead of `has` + `add`.
784
+ */
785
+ isAlreadyDelivered(handler, delivered, multiCarrierHandlers) {
786
+ if (delivered === void 0) return false;
787
+ if (multiCarrierHandlers?.has(handler) !== true) return false;
788
+ const size = delivered.size;
789
+ delivered.add(handler);
790
+ return delivered.size === size;
791
+ }
769
792
  };
770
793
 
771
794
  // src/core/OIMKeyedCarrierProvider.ts
@@ -1242,6 +1265,13 @@ var OIMCollectionStoreMapDriven = class extends OIMCollectionStore {
1242
1265
  return this.slots.get(pk)?.item;
1243
1266
  }
1244
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) {
1245
1275
  const result = [];
1246
1276
  result.length = pks.length;
1247
1277
  let writeIndex = 0;
@@ -1322,9 +1352,14 @@ var OIMCollection = class {
1322
1352
  getOneByPk(pk) {
1323
1353
  return this.store.getOneByPk(pk);
1324
1354
  }
1355
+ /** Length-aligned with `pks`; `undefined` wherever a pk has no entity. */
1325
1356
  getManyByPks(pks) {
1326
1357
  return this.store.getManyByPks(pks);
1327
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
+ }
1328
1363
  getSlotByPk(pk) {
1329
1364
  return this.store.getSlotByPk(pk);
1330
1365
  }
@@ -4202,6 +4237,13 @@ var OIMCollectionStoreTrieDriven = class extends OIMCollectionStore {
4202
4237
  return this.slots.get(pk)?.item;
4203
4238
  }
4204
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) {
4205
4247
  const result = [];
4206
4248
  result.length = pks.length;
4207
4249
  let writeIndex = 0;
package/llms.txt CHANGED
@@ -86,7 +86,9 @@ byRole.subscribeOnKey('admin', () => {
86
86
  queue.flush(); // delivers subscription
87
87
  ```
88
88
 
89
- **`subscribeOnKeys(keys, handler)` delivery is coalesced per flush:** the handler fires at most ONCE per flush no matter how many of the subscribed keys changed in it (the callback gets no key, so per-key fan-out would be pure waste). This holds across all reactive primitives — sync (collection / index / object, via `OIMCarrierKeyedEmitter`) and the `@oimdb/async` nodes (via `OIMUpdateEventEmitterAsync`) alike. `subscribeOnKey` (single key) is unchanged. Handler identity is preserved, so `unsubscribeFromKeys(keys, handler)` still removes a `subscribeOnKeys` subscription.
89
+ **`subscribeOnKeys(keys, handler)` delivery is coalesced per flush:** the handler fires at most ONCE per flush no matter how many of the subscribed keys changed in it (the callback gets no key, so per-key fan-out would be pure waste). This holds across all reactive primitives — sync (collection / index / object, via `OIMCarrierKeyedEmitter`) and the `@oimdb/async` nodes (via `OIMUpdateEventEmitterAsync`) alike. `subscribeOnKey` (single key) is unchanged. Handler identity is preserved, so `unsubscribeFromKeys(keys, handler)` still removes a `subscribeOnKeys` subscription — and releases the coalescing exactly like the returned unsubscribe closure does.
90
+
91
+ **Coalescing is scoped to the subscription, not to the handler.** It applies only to handlers registered through a multi-key `subscribeOnKeys`. If the same function holds several *independent* `subscribeOnKey` subscriptions, it still gets one delivery per changed key — an unrelated multi-key subscription elsewhere on the same collection/index never changes that.
90
92
 
91
93
  Membership writes are incremental (touch only the changed pks):
92
94
  - `addPks(key, pks)` / `removePks(key, pks)` — **set-based**: O(1) per pk for both. **array-based**: `addPks` O(1), `removePks` O(bucket) (ordered, must scan).
@@ -393,41 +395,55 @@ Signal hooks: `useSelectEntityByPkSignal(collection, pk)`, `useSelectPksByIndexK
393
395
 
394
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.
395
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
+
396
402
  ## Exodra (`@oimdb/exodra`)
397
403
 
398
404
  ```bash
399
405
  npm install @oimdb/exodra @oimdb/core
400
406
  ```
401
407
 
402
- 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).
403
409
 
404
410
  ```ts
405
- import { fromSelector, bindSelectors, fromComputed, combine, entityRows } from '@oimdb/exodra';
406
- import { bindable, derive } from '@exodra/reactivity';
411
+ import { exoDb } from '@oimdb/exodra';
412
+ import { bindable, derive, h } from '@exodra/reactivity';
413
+
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 });
407
417
 
408
- // wrap any OIMSelector
409
- const alice = fromSelector(kit.select.byPk('u1')); // TExoBindable<User | undefined>
418
+ const db = exoDb({
419
+ users: { kit: usersKit, indexes: { byTeam, byTeamOrdered, online } },
420
+ tags: { kit: tagsKit }, // no indexes → byPk / byPks only
421
+ });
410
422
 
411
- // mirror OIMCollectionSelectors → bindables; keys accept a bindable (reactive key re-points the sub)
412
- const bound = bindSelectors(kit.select);
413
- const selectedTeam = bindable('team-1');
414
- const members = bound.entitiesBySetIndexKey(usersByTeam, selectedTeam);
415
- // <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)
416
429
  ```
417
430
 
418
431
  | Export | Purpose |
419
432
  |---|---|
420
- | `fromSelector(selector, opts?)` | primary adapter: `OIMSelector<T>` → `TExoBindable<T>` |
421
- | `fromOimdb(read, subscribe, opts?)` | escape hatch for a raw read/subscribe pair |
422
- | `fromSelectorFactory(key, makeSelector, opts?)` | reactive key (`TKey \| bindable`) → rebuilds the selector on change |
423
- | `fromComputed(computed, opts?)` | `OIMComputed<T>` → `TExoBindable<T>` |
424
- | `combine(sources, fn)` | multi-source derive (Exodra's own `derive` is single-source) |
425
- | `bindSelectors(selectors, defaults?)` | mirror of `OIMCollectionSelectors`, each method returning a bindable |
426
- | `keyedChildren(source, { key, render })` / `entityRows(order, bindEntity, render)` | identity-stable list children (field edit = reconcile no-op, focus-safe) |
427
- | `listFromCommandStream(stream, key, render)` | O(delta) path: OIMDB ordered command stream → Exodra `bindableList` (commands map 1:1 to `TExoListOp`) |
428
- | `read*/subscribe*` (6 fns) | low-level pairs mirroring an inline app bridge 1:1 (find-replace migration) |
429
-
430
- 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
+ | `exoChildren(source, { key, 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`. `exoChildren` 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 `exoChildren` 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 row KEY derived from another collection → `exoChildren(exoCombine([a.all(), b.all()], read), { key, render })`; `<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
+
431
447
 
432
448
  ## Persist (`@oimdb/persist`)
433
449
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oimdb/core",
3
- "version": "3.11.0",
3
+ "version": "3.12.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",