@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 +18 -0
- package/dist/index.cjs +60 -18
- package/dist/index.d.cts +32 -5
- package/dist/index.d.ts +32 -5
- package/dist/index.js +60 -18
- package/llms.txt +38 -22
- package/package.json +1 -1
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
|
|
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.
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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.
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
409
|
-
|
|
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
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
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
|
-
| `
|
|
421
|
-
| `
|
|
422
|
-
| `
|
|
423
|
-
| `
|
|
424
|
-
| `
|
|
425
|
-
| `
|
|
426
|
-
| `
|
|
427
|
-
| `
|
|
428
|
-
| `
|
|
429
|
-
|
|
430
|
-
|
|
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