@oimdb/core 1.4.0 → 1.5.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
@@ -21,24 +21,32 @@ This package exports all the core classes, interfaces, and types needed to build
21
21
  - **OIMCollection**: Base collection with CRUD operations and event emission
22
22
 
23
23
  ### Event System
24
- - **OIMUpdateEventEmitter**: Key-specific event subscriptions with coalescing
25
- - **OIMUpdateEventCoalescer**: Intelligent event batching and deduplication
24
+ - **OIMUpdateEventEmitter**: Key-based subscriptions with batching/deduplication (no buffering if there are no subscribers)
26
25
  - **OIMEventEmitter**: Generic type-safe event emitter
27
26
  - **Schedulers**: Multiple event processing strategies (microtask, timeout, animationFrame, immediate)
28
27
 
28
+ ### Reactive Primitives
29
+ - **OIMEffect**: Reactive effects that run when dependencies change
30
+ - **OIMComputed**: Derived values that recompute when dependencies change
31
+ - **OIMSelector**: Value watchers that deliver updates only when values actually change
32
+ - `OIMCollectionByPkSelector`: Watch single entity from collection
33
+ - `OIMCollectionByPksSelector`: Watch multiple entities from collection
34
+ - `OIMObjectValueByKeySelector`: Watch single key from reactive object
35
+ - `OIMEntitiesByIndexKey*Selector`: Watch entities by index key
36
+
29
37
  ### Storage & Indexing
30
38
  - **OIMCollectionStoreMapDriven**: Map-based storage backend
31
- - **OIMIndexManualSetBased**: Set-based manual index (returns `Set<TPk>`)
32
- - **OIMIndexManualArrayBased**: Array-based manual index (returns `TPk[]`)
33
- - **OIMIndexStoreMapDrivenSetBased**: Set-based index storage backend
34
- - **OIMIndexStoreMapDrivenArrayBased**: Array-based index storage backend
39
+ - **OIMIndexManualSetBased**: Set-based manual index (stores slots, projects to `Set<TPk>`)
40
+ - **OIMIndexManualArrayBased**: Array-based manual index (stores slots, projects to `TPk[]`)
41
+ - **OIMIndexStoreMapDrivenSetBased**: Set-based slot index storage backend
42
+ - **OIMIndexStoreMapDrivenArrayBased**: Array-based slot index storage backend
35
43
  - **OIMMap2Keys**: Two-key mapping utilities for complex indexing
36
44
 
37
45
  ### Abstract Classes & Interfaces
38
46
  - **OIMCollectionStore**: Storage backend interface
39
47
  - **OIMEventQueueScheduler**: Event processing scheduler interface
40
- - **OIMIndexSetBased**: Base Set-based index interface (returns `Set<TPk>`)
41
- - **OIMIndexArrayBased**: Base Array-based index interface (returns `TPk[]`)
48
+ - **OIMIndexSetBased**: Base Set-based index interface (slot-backed, projects to `Set<TPk>`)
49
+ - **OIMIndexArrayBased**: Base Array-based index interface (slot-backed, projects to `TPk[]`)
42
50
  - **OIMReactiveIndexSetBased**: Reactive Set-based index interface
43
51
  - **OIMReactiveIndexArrayBased**: Reactive Array-based index interface
44
52
 
@@ -100,6 +108,8 @@ const multipleUsers = users.getManyByPks(['user1', 'user2']);
100
108
 
101
109
  OIMDB provides two types of indexes optimized for different use cases:
102
110
 
111
+ Indexes are slot-backed internally. Collections own canonical `{ pk, item }` slots, while indexes store references to those slots. `getPksByKey` remains available as a PK projection (`Set<TPk>` for SetBased, `TPk[]` for ArrayBased), and entity-by-index selectors/hooks read `slot.item` directly for faster reads.
112
+
103
113
  #### SetBased Indexes (for incremental updates)
104
114
 
105
115
  ```typescript
@@ -113,8 +123,8 @@ const queue = new OIMEventQueue({
113
123
  const userRoleIndex = new OIMReactiveIndexManualSetBased<string, string>(queue);
114
124
 
115
125
  // Subscribe to specific index key changes
116
- userRoleIndex.updateEventEmitter.subscribeOnKey('admin', (pks) => {
117
- console.log('Admin users changed:', pks); // pks is Set<string>
126
+ userRoleIndex.updateEventEmitter.subscribeOnKey('admin', () => {
127
+ console.log('Admin users changed:', userRoleIndex.getPksByKey('admin')); // Set<string>
118
128
  });
119
129
 
120
130
  // Build the index manually
@@ -145,8 +155,8 @@ const queue = new OIMEventQueue({
145
155
  const cardsByDeckIndex = new OIMReactiveIndexManualArrayBased<string, string>(queue);
146
156
 
147
157
  // Subscribe to specific index key changes
148
- cardsByDeckIndex.updateEventEmitter.subscribeOnKey('deck1', (pks) => {
149
- console.log('Deck cards changed:', pks); // pks is string[]
158
+ cardsByDeckIndex.updateEventEmitter.subscribeOnKey('deck1', () => {
159
+ console.log('Deck cards changed:', cardsByDeckIndex.getPksByKey('deck1')); // string[]
150
160
  });
151
161
 
152
162
  // Build the index manually - set full array
@@ -326,10 +336,7 @@ users.upsertOne({ id: 'user1', email: 'john@example.com' });
326
336
 
327
337
  // Only one notification will fire (in next microtask)
328
338
 
329
- // Access the underlying coalescer directly
330
- users.coalescer.emitter.on('upsert', (coalescedUpdates) => {
331
- console.log('Raw coalesced updates:', coalescedUpdates);
332
- });
339
+ // (No separate "coalescer" object exists: batching/deduplication is handled inside OIMUpdateEventEmitter.)
333
340
  ```
334
341
 
335
342
  ## 🔄 Reactive Architecture
@@ -340,7 +347,7 @@ OIMDB core uses a reactive architecture where changes automatically trigger noti
340
347
 
341
348
  ```typescript
342
349
  // Collection updates trigger events through the event queue
343
- collection.upsertOne(entity) → coalescer → event queue → subscribers
350
+ collection.upsertOne(entity) → updateEventEmitter → event queue → subscribers
344
351
 
345
352
  // Key-specific subscriptions only notify when relevant data changes
346
353
  updateEventEmitter.subscribeOnKey('user1', callback) // Only fires for user1 changes
@@ -360,31 +367,22 @@ users.upsertOne({ id: 'user1', role: 'admin' });
360
367
  // This prevents unnecessary re-renders and improves performance
361
368
  ```
362
369
 
363
- ### Effects, Computed, and the Event Lifecycle (PRE vs HANDLERS)
364
-
365
- OIMDB uses a **two-phase** update model inside a queue flush:
366
-
367
- - **PRE phase**: derived logic (e.g. `OIMComputed`) and **pure** effects run here
368
- - **HANDLERS phase**: regular subscribers run here (UI updates, integrations, etc.)
369
-
370
- **Important rules / guarantees:**
370
+ ### Effects, Computed, and the Event Lifecycle
371
371
 
372
- - **PRE should be pure**: effects in the PRE phase should **not write** back into collections/objects/indexes. This keeps the update model predictable and avoids re-entrant update loops.
373
- - **Computed values are consistent after a single flush**: after `queue.flush()`, reading `computed.get()` returns an up-to-date value.
374
- - **Notifications may be delivered in the next flush**: subscribers attached via `updateEventEmitter` are delivered through the queue and follow the queue snapshot rules. That means:
375
- - computed may recompute in flush #1 (PRE)
376
- - subscribers may run in flush #2 (HANDLERS, next queue drain) if the subscription itself is scheduled from within flush #1
372
+ OIMDB uses a single-pass flush boundary: `queue.flush()` executes the current batch of pending work.
377
373
 
378
- If you're using an auto-scheduled queue (microtask/raf/timeout), you typically don't call `flush()` manually; the above becomes “next tick/microtask” semantics instead of “second flush call”.
374
+ Effects and computed values are scheduled through `OIMComputativeRuntime`, which is backed by the same queue. This keeps the public API simple and avoids a multi-phase flush model.
379
375
 
380
376
  #### What is an Effect?
381
377
 
382
378
  `OIMEffect` is the base reactive primitive: it subscribes to dependencies and calls `run()` when those dependencies change. It coalesces multiple invalidations during the same flush into a single run.
383
379
 
380
+ **Basic example with reactive object:**
381
+
384
382
  ```typescript
385
383
  import {
386
384
  OIMEffect,
387
- EOIMEffectPhase,
385
+ OIMComputativeRuntime,
388
386
  OIMEventQueue,
389
387
  OIMReactiveObject,
390
388
  OIMEffectDependencyKeyedObject,
@@ -393,10 +391,10 @@ import {
393
391
  type TKey = 'a';
394
392
 
395
393
  const queue = new OIMEventQueue();
394
+ const runtime = new OIMComputativeRuntime(queue);
396
395
  const obj = new OIMReactiveObject<TKey, number>(queue);
397
396
 
398
- const effect = new OIMEffect(queue, {
399
- phase: EOIMEffectPhase.HANDLERS,
397
+ const effect = new OIMEffect(runtime, {
400
398
  deps: [new OIMEffectDependencyKeyedObject(obj, 'a')],
401
399
  run: () => {
402
400
  console.log('obj.a changed');
@@ -411,40 +409,154 @@ obj.destroy();
411
409
  queue.destroy();
412
410
  ```
413
411
 
412
+ **Effect with collection dependency:**
413
+
414
+ ```typescript
415
+ import {
416
+ OIMEffect,
417
+ OIMComputativeRuntime,
418
+ OIMEventQueue,
419
+ OIMReactiveCollection,
420
+ OIMEffectDependencyKeyedCollection,
421
+ } from '@oimdb/core';
422
+
423
+ interface User {
424
+ id: string;
425
+ name: string;
426
+ }
427
+
428
+ const queue = new OIMEventQueue();
429
+ const runtime = new OIMComputativeRuntime(queue);
430
+ const users = new OIMReactiveCollection<User, string>(queue, {
431
+ selectPk: (u) => u.id,
432
+ });
433
+
434
+ const effect = new OIMEffect(runtime, {
435
+ deps: [new OIMEffectDependencyKeyedCollection(users, 'user1')],
436
+ run: () => {
437
+ const user = users.getOneByPk('user1');
438
+ console.log('User1 changed:', user);
439
+ },
440
+ });
441
+
442
+ users.upsertOne({ id: 'user1', name: 'John' });
443
+ queue.flush();
444
+
445
+ effect.destroy();
446
+ users.destroy();
447
+ queue.destroy();
448
+ ```
449
+
450
+ **Effect with index dependency:**
451
+
452
+ ```typescript
453
+ import {
454
+ OIMEffect,
455
+ OIMComputativeRuntime,
456
+ OIMEventQueue,
457
+ OIMReactiveIndexManualSetBased,
458
+ OIMEffectDependencyKeyedIndex,
459
+ } from '@oimdb/core';
460
+
461
+ const queue = new OIMEventQueue();
462
+ const runtime = new OIMComputativeRuntime(queue);
463
+ const roleIndex = new OIMReactiveIndexManualSetBased<string, string>(queue);
464
+
465
+ const effect = new OIMEffect(runtime, {
466
+ deps: [new OIMEffectDependencyKeyedIndex(roleIndex, 'admin')],
467
+ run: () => {
468
+ const adminPks = roleIndex.getPksByKey('admin');
469
+ console.log('Admin users changed:', Array.from(adminPks));
470
+ },
471
+ });
472
+
473
+ roleIndex.setPks('admin', ['user1', 'user2']);
474
+ queue.flush();
475
+
476
+ effect.destroy();
477
+ roleIndex.destroy();
478
+ queue.destroy();
479
+ ```
480
+
481
+ **Effect with multiple dependencies:**
482
+
483
+ ```typescript
484
+ import {
485
+ OIMEffect,
486
+ OIMComputativeRuntime,
487
+ OIMEventQueue,
488
+ OIMReactiveObject,
489
+ OIMReactiveCollection,
490
+ OIMEffectDependencyKeyedObject,
491
+ OIMEffectDependencyKeyedCollection,
492
+ } from '@oimdb/core';
493
+
494
+ const queue = new OIMEventQueue();
495
+ const runtime = new OIMComputativeRuntime(queue);
496
+ const settings = new OIMReactiveObject<'theme' | 'lang', string>(queue);
497
+ const users = new OIMReactiveCollection<User, string>(queue, {
498
+ selectPk: (u) => u.id,
499
+ });
500
+
501
+ const effect = new OIMEffect(runtime, {
502
+ deps: [
503
+ new OIMEffectDependencyKeyedObject(settings, ['theme', 'lang']),
504
+ new OIMEffectDependencyKeyedCollection(users, 'currentUser'),
505
+ ],
506
+ run: () => {
507
+ const theme = settings.get('theme');
508
+ const user = users.getOneByPk('currentUser');
509
+ console.log('Settings or user changed:', { theme, user });
510
+ },
511
+ });
512
+
513
+ settings.setProperty('theme', 'dark');
514
+ users.upsertOne({ id: 'currentUser', name: 'John' });
515
+ queue.flush(); // Effect runs once, even though multiple deps changed
516
+
517
+ effect.destroy();
518
+ settings.destroy();
519
+ users.destroy();
520
+ queue.destroy();
521
+ ```
522
+
414
523
  #### What is a Computed?
415
524
 
416
- `OIMComputed<T>` is built on top of `OIMEffect` (PRE phase): it recomputes a derived value and emits `update` when the value changes.
525
+ `OIMComputed<T>` is built on top of `OIMEffect`: it recomputes a derived value and emits `update` when the value changes.
526
+
527
+ **Basic example:**
417
528
 
418
529
  ```typescript
419
530
  import {
420
531
  OIMComputed,
421
532
  OIMEventQueue,
533
+ OIMComputativeRuntime,
422
534
  OIMReactiveObject,
423
535
  OIMEffectDependencyKeyedObject,
424
536
  } from '@oimdb/core';
425
537
 
426
538
  type TKey = 'a';
427
539
  const queue = new OIMEventQueue();
540
+ const runtime = new OIMComputativeRuntime(queue);
428
541
  const obj = new OIMReactiveObject<TKey, number>(queue);
429
542
 
430
- const doubled = new OIMComputed<number>(queue, {
543
+ const doubled = new OIMComputed<number>(runtime, {
431
544
  compute: () => (obj.get('a') ?? 0) * 2,
432
545
  deps: [new OIMEffectDependencyKeyedObject(obj, 'a')],
433
546
  });
434
547
 
435
548
  obj.setProperty('a', 10);
436
- queue.flush(); // recompute happens during PRE
549
+ queue.flush(); // run scheduled work
437
550
 
438
551
  console.log(doubled.get()); // 20
439
552
 
440
- // If you also subscribe to computed updates, delivery can be in the next flush:
553
+ // If you also subscribe to computed updates, delivery happens in the same drain flush:
441
554
  let calls = 0;
442
555
  doubled.updateEventEmitter.subscribeOnKey('value', () => {
443
556
  calls++;
444
557
  });
445
558
  obj.setProperty('a', 11);
446
- queue.flush(); // recompute (PRE)
447
- queue.flush(); // subscriber delivery (HANDLERS via queue snapshot)
559
+ queue.flush(); // run scheduled work
448
560
  console.log(calls); // 1
449
561
 
450
562
  doubled.destroy();
@@ -452,18 +564,81 @@ obj.destroy();
452
564
  queue.destroy();
453
565
  ```
454
566
 
567
+ **Computed with collection and index dependencies:**
568
+
569
+ ```typescript
570
+ import {
571
+ OIMComputed,
572
+ OIMEventQueue,
573
+ OIMComputativeRuntime,
574
+ OIMReactiveCollection,
575
+ OIMReactiveIndexManualSetBased,
576
+ OIMEffectDependencyKeyedCollection,
577
+ OIMEffectDependencyKeyedIndex,
578
+ } from '@oimdb/core';
579
+
580
+ interface User {
581
+ id: string;
582
+ name: string;
583
+ role: string;
584
+ }
585
+
586
+ const queue = new OIMEventQueue();
587
+ const runtime = new OIMComputativeRuntime(queue);
588
+ const users = new OIMReactiveCollection<User, string>(queue, {
589
+ selectPk: (u) => u.id,
590
+ });
591
+ const roleIndex = new OIMReactiveIndexManualSetBased<string, string>(queue);
592
+
593
+ // Computed that counts admin users
594
+ const adminCount = new OIMComputed<number>(runtime, {
595
+ compute: () => {
596
+ const adminPks = roleIndex.getPksByKey('admin');
597
+ return adminPks.size;
598
+ },
599
+ deps: [new OIMEffectDependencyKeyedIndex(roleIndex, 'admin')],
600
+ });
601
+
602
+ // Computed that gets admin user names
603
+ const adminNames = new OIMComputed<string[]>(runtime, {
604
+ compute: () => {
605
+ const adminPks = Array.from(roleIndex.getPksByKey('admin'));
606
+ return adminPks
607
+ .map((pk) => users.getOneByPk(pk)?.name)
608
+ .filter((name): name is string => name !== undefined);
609
+ },
610
+ deps: [
611
+ new OIMEffectDependencyKeyedIndex(roleIndex, 'admin'),
612
+ new OIMEffectDependencyKeyedCollection(users, Array.from(roleIndex.getPksByKey('admin'))),
613
+ ],
614
+ });
615
+
616
+ users.upsertMany([
617
+ { id: 'u1', name: 'Alice', role: 'admin' },
618
+ { id: 'u2', name: 'Bob', role: 'user' },
619
+ ]);
620
+ roleIndex.setPks('admin', ['u1']);
621
+ queue.flush();
622
+
623
+ console.log(adminCount.get()); // 1
624
+ console.log(adminNames.get()); // ['Alice']
625
+
626
+ adminNames.destroy();
627
+ adminCount.destroy();
628
+ roleIndex.destroy();
629
+ users.destroy();
630
+ queue.destroy();
631
+ ```
632
+
455
633
  #### Computed-to-Computed dependencies
456
634
 
457
635
  For computed-to-computed dependencies you can use `OIMEffectDependencyComputed`.
458
636
 
459
- - In **PRE** it subscribes directly to the computed's internal `emitter` to invalidate downstream derivations immediately during the pre-drain.
460
- - In **HANDLERS** it subscribes via `computed.updateEventEmitter` (delivered through the queue).
461
-
462
637
  ```typescript
463
638
  import {
464
639
  OIMComputed,
465
640
  OIMEffect,
466
- EOIMEffectPhase,
641
+ OIMComputativeRuntime,
467
642
  OIMEffectDependencyComputed,
468
643
  OIMEventQueue,
469
644
  OIMReactiveObject,
@@ -472,27 +647,26 @@ import {
472
647
 
473
648
  type TKey = 'a';
474
649
  const queue = new OIMEventQueue();
650
+ const runtime = new OIMComputativeRuntime(queue);
475
651
  const obj = new OIMReactiveObject<TKey, number>(queue);
476
652
 
477
- const A = new OIMComputed<number>(queue, {
653
+ const A = new OIMComputed<number>(runtime, {
478
654
  compute: () => (obj.get('a') ?? 0) + 1,
479
655
  deps: [new OIMEffectDependencyKeyedObject(obj, 'a')],
480
656
  });
481
657
 
482
- const B = new OIMComputed<number>(queue, {
658
+ const B = new OIMComputed<number>(runtime, {
483
659
  compute: () => A.get() * 2,
484
660
  deps: [new OIMEffectDependencyComputed({ emitter: A.emitter, updateEventEmitter: A.updateEventEmitter })],
485
661
  });
486
662
 
487
- const effect = new OIMEffect(queue, {
488
- phase: EOIMEffectPhase.HANDLERS,
663
+ const effect = new OIMEffect(runtime, {
489
664
  deps: [new OIMEffectDependencyComputed({ emitter: B.emitter, updateEventEmitter: B.updateEventEmitter })],
490
665
  run: () => console.log('B changed'),
491
666
  });
492
667
 
493
668
  obj.setProperty('a', 1);
494
- queue.flush(); // computed recompute (PRE)
495
- queue.flush(); // handlers delivered via the queue snapshot
669
+ queue.flush(); // run scheduled work
496
670
 
497
671
  effect.destroy();
498
672
  B.destroy();
@@ -501,12 +675,153 @@ obj.destroy();
501
675
  queue.destroy();
502
676
  ```
503
677
 
678
+ #### What are Selectors?
679
+
680
+ Selectors provide a convenient way to watch and react to changes in collections, objects, and indexes. They automatically handle subscription management and deliver updates only when values actually change.
681
+
682
+ **Collection selector:**
683
+
684
+ ```typescript
685
+ import {
686
+ OIMCollectionByPkSelector,
687
+ OIMCollectionByPksSelector,
688
+ OIMComputativeRuntime,
689
+ OIMEventQueue,
690
+ OIMReactiveCollection,
691
+ } from '@oimdb/core';
692
+
693
+ interface User {
694
+ id: string;
695
+ name: string;
696
+ }
697
+
698
+ const queue = new OIMEventQueue();
699
+ const runtime = new OIMComputativeRuntime(queue);
700
+ const users = new OIMReactiveCollection<User, string>(queue, {
701
+ selectPk: (u) => u.id,
702
+ });
703
+
704
+ // Watch a single user
705
+ const userSelector = new OIMCollectionByPkSelector(runtime, users, 'user1');
706
+ const unwatch = userSelector.watch((user) => {
707
+ console.log('User1 changed:', user);
708
+ });
709
+
710
+ users.upsertOne({ id: 'user1', name: 'John' });
711
+ queue.flush(); // Callback fires with { id: 'user1', name: 'John' }
712
+
713
+ // Watch multiple users
714
+ const usersSelector = new OIMCollectionByPksSelector(runtime, users, ['user1', 'user2']);
715
+ usersSelector.watch((users) => {
716
+ console.log('Users changed:', users);
717
+ });
718
+
719
+ users.upsertMany([
720
+ { id: 'user1', name: 'John Doe' },
721
+ { id: 'user2', name: 'Jane Smith' },
722
+ ]);
723
+ queue.flush(); // Callback fires with array of users
724
+
725
+ unwatch(); // Stop watching
726
+ usersSelector.watch(() => {}); // Get unsubscribe function
727
+ users.destroy();
728
+ queue.destroy();
729
+ ```
730
+
731
+ **Selector with index (entities by index key):**
732
+
733
+ ```typescript
734
+ import {
735
+ OIMEntitiesByIndexKeySetBasedSelector,
736
+ OIMComputativeRuntime,
737
+ OIMEventQueue,
738
+ OIMReactiveCollection,
739
+ OIMReactiveIndexManualSetBased,
740
+ } from '@oimdb/core';
741
+
742
+ const queue = new OIMEventQueue();
743
+ const runtime = new OIMComputativeRuntime(queue);
744
+ const users = new OIMReactiveCollection<User, string>(queue, {
745
+ selectPk: (u) => u.id,
746
+ });
747
+ const roleIndex = new OIMReactiveIndexManualSetBased<string, string>(queue);
748
+
749
+ // Watch all admin users
750
+ const adminUsersSelector = new OIMEntitiesByIndexKeySetBasedSelector(
751
+ runtime,
752
+ users,
753
+ roleIndex,
754
+ 'admin'
755
+ );
756
+
757
+ adminUsersSelector.watch((adminUsers) => {
758
+ console.log('Admin users:', adminUsers.map((u) => u?.name));
759
+ });
760
+
761
+ users.upsertMany([
762
+ { id: 'u1', name: 'Alice', role: 'admin' },
763
+ { id: 'u2', name: 'Bob', role: 'admin' },
764
+ ]);
765
+ roleIndex.setPks('admin', ['u1', 'u2']);
766
+ queue.flush(); // Callback fires with [Alice, Bob]
767
+
768
+ // When index changes, selector automatically resubscribes to new entities
769
+ roleIndex.setPks('admin', ['u1']);
770
+ queue.flush(); // Callback fires with [Alice]
771
+
772
+ adminUsersSelector.watch(() => {}); // Get unsubscribe function
773
+ roleIndex.destroy();
774
+ users.destroy();
775
+ queue.destroy();
776
+ ```
777
+
778
+ **Object selector:**
779
+
780
+ ```typescript
781
+ import {
782
+ OIMObjectValueByKeySelector,
783
+ OIMObjectValuesByKeysSelector,
784
+ OIMComputativeRuntime,
785
+ OIMEventQueue,
786
+ OIMReactiveObject,
787
+ } from '@oimdb/core';
788
+
789
+ const queue = new OIMEventQueue();
790
+ const runtime = new OIMComputativeRuntime(queue);
791
+ const settings = new OIMReactiveObject<'theme' | 'lang', string>(queue);
792
+
793
+ // Watch single key
794
+ const themeSelector = new OIMObjectValueByKeySelector(runtime, settings, 'theme');
795
+ themeSelector.watch((theme) => {
796
+ console.log('Theme changed:', theme);
797
+ });
798
+
799
+ // Watch multiple keys
800
+ const settingsSelector = new OIMObjectValuesByKeysSelector(runtime, settings, ['theme', 'lang']);
801
+ settingsSelector.watch((values) => {
802
+ console.log('Settings changed:', values); // [theme, lang]
803
+ });
804
+
805
+ settings.setProperty('theme', 'dark');
806
+ queue.flush();
807
+
808
+ settings.destroy();
809
+ queue.destroy();
810
+ ```
811
+
812
+ **Key differences: Effects vs Selectors:**
813
+
814
+ - **Effects** (`OIMEffect`): Run side effects when dependencies change. Use for logging, API calls, UI updates.
815
+ - **Selectors** (`OIMSelector`): Watch and deliver values only when they actually change. Use for reactive data access with automatic change detection.
816
+ - **Computed** (`OIMComputed`): Derive values from dependencies. Use for calculated/transformed data.
817
+
504
818
  #### Gotchas (read this once)
505
819
 
506
820
  - **Avoid cycles**: if A depends on B and B depends on A (directly or indirectly), you can get endless invalidation/recompute. Keep your dependency graph acyclic.
507
821
  - **Keep `compute()` pure**: treat `compute()` as a pure function over current state. Doing writes inside `compute()` will create hard-to-debug re-entrancy.
508
- - **Put side-effects in HANDLERS**: if you need to write to stores or trigger IO, do it from `OIMEffect` with `phase: EOIMEffectPhase.HANDLERS`.
509
- - **Always `destroy()`**: effects/computed subscribe to dependencies; if you create them dynamically, call `destroy()` to unsubscribe and free memory.
822
+ - **Keep effects safe**: if you need to write to stores or trigger IO, do it from `OIMEffect`, but avoid creating endless update loops.
823
+ - **Always `destroy()`**: effects/computed/selectors subscribe to dependencies; if you create them dynamically, call `destroy()` or use the unsubscribe function to unsubscribe and free memory.
824
+ - **Selectors deliver only on change**: Selectors use equality checks (`areEqual`) to avoid delivering the same value multiple times. Override `areEqual` in custom selectors if needed.
510
825
 
511
826
  ### Scheduler Types
512
827
 
@@ -521,7 +836,7 @@ Choose the right scheduler for your use case:
521
836
 
522
837
  ```
523
838
  OIMCollection (base)
524
- ├── OIMReactiveCollection (adds event emitter + coalescing)
839
+ ├── OIMReactiveCollection (adds updateEventEmitter wired to the queue)
525
840
  └── OIMRICollection (reactive collection + indexes)
526
841
 
527
842
  OIMIndexSetBased (base for Set-based)
@@ -549,13 +864,17 @@ OIMIndexArrayBased (base for Array-based)
549
864
  ### Index Performance
550
865
 
551
866
  **SetBased Indexes** (`OIMReactiveIndexManualSetBased`):
552
- - **Returns**: `Set<TPk>` for efficient membership checks
867
+ - **Storage**: Set of canonical entity slots
868
+ - **PK projection**: `getPksByKey` returns `Set<TPk>` for efficient membership checks
869
+ - **Entity reads**: selectors/hooks read `slot.item` directly, avoiding per-item collection lookups
553
870
  - **Best for**: Frequent incremental updates using `addPks`/`removePks`
554
871
  - **Performance**: O(1) add/remove operations, O(n) for `setPks` (requires Set creation)
555
872
  - **Use case**: When you need to frequently add/remove individual items
556
873
 
557
874
  **ArrayBased Indexes** (`OIMReactiveIndexManualArrayBased`):
558
- - **Returns**: `TPk[]` for direct array access
875
+ - **Storage**: Array of canonical entity slots
876
+ - **PK projection**: `getPksByKey` returns `TPk[]` for direct array access
877
+ - **Entity reads**: selectors/hooks read `slot.item` directly, preserving array order
559
878
  - **Best for**: Full array replacements using `setPks`
560
879
  - **Performance**: O(1) `setPks` operation (direct assignment, no diff computation)
561
880
  - **Use case**: When you typically replace the entire array (e.g., deck cards, ordered lists) or when you need to preserve element order/sorting
@@ -646,7 +965,7 @@ new OIMReactiveCollection(queue: OIMEventQueue, opts?: TOIMCollectionOptions<TEn
646
965
  **Properties:**
647
966
  - `collection: OIMCollection<TEntity, TPk>` - Underlying collection
648
967
  - `updateEventEmitter: OIMUpdateEventEmitter<TPk>` - Key-specific subscriptions
649
- - `coalescer: OIMUpdateEventCoalescerCollection<TPk>` - Event coalescing
968
+ - Event batching/deduplication is handled internally by `OIMUpdateEventEmitter`
650
969
 
651
970
  **Methods:**
652
971
  - `upsertOne(entity: TEntity): void` - Insert or update single entity
@@ -654,7 +973,9 @@ new OIMReactiveCollection(queue: OIMEventQueue, opts?: TOIMCollectionOptions<TEn
654
973
  - `removeOne(entity: TEntity): void` - Remove single entity
655
974
  - `removeMany(entities: TEntity[]): void` - Remove multiple entities
656
975
  - `getOneByPk(pk: TPk): TEntity | undefined` - Get entity by primary key
657
- - `getManyByPks(pks: readonly TPk[]): Map<TPk, TEntity | undefined>` - Get multiple entities
976
+ - `getManyByPks(pks: readonly TPk[]): TEntity[]` - Get existing entities for primary keys
977
+ - `getSlotByPk(pk: TPk): OIMEntitySlot<TEntity, TPk> | undefined` - Get the canonical slot for a primary key
978
+ - `getSlotsByPks(pks: readonly TPk[]): OIMEntitySlot<TEntity, TPk>[]` - Get existing canonical slots for primary keys
658
979
 
659
980
  #### `OIMRICollection<TEntity, TPk, TIndexName, TIndexKey, TIndex, TReactiveIndex, TReactiveIndexMap>`
660
981
  Reactive collection with integrated indexing capabilities.
@@ -672,12 +993,16 @@ new OIMRICollection(queue: OIMEventQueue, opts: {
672
993
  - *(inherits all OIMReactiveCollection properties)*
673
994
 
674
995
  #### `OIMReactiveIndexManualSetBased<TKey, TPk>`
675
- Reactive Set-based index with manual key-to-entity mapping and change notifications. Returns `Set<TPk>` for efficient membership checks.
996
+ Reactive Set-based index with manual key-to-entity mapping and change notifications. Stores canonical slots internally and projects to `Set<TPk>` for PK reads.
676
997
 
677
998
  **Constructor:**
678
999
  ```typescript
679
1000
  new OIMReactiveIndexManualSetBased(queue: OIMEventQueue, opts?: {
680
- index?: OIMIndexManualSetBased<TKey, TPk>
1001
+ indexOptions?: {
1002
+ comparePks?: TOIMIndexComparator<TPk>;
1003
+ store?: OIMIndexStoreSetBased<TKey, TPk>;
1004
+ resolveSlot?: TOIMEntitySlotResolver<TPk>;
1005
+ }
681
1006
  })
682
1007
  ```
683
1008
 
@@ -689,18 +1014,24 @@ new OIMReactiveIndexManualSetBased(queue: OIMEventQueue, opts?: {
689
1014
  - `setPks(key: TKey, pks: readonly TPk[]): void` - Set primary keys for index key (replaces entire Set)
690
1015
  - `addPks(key: TKey, pks: readonly TPk[]): void` - Add primary keys to index key (efficient)
691
1016
  - `removePks(key: TKey, pks: readonly TPk[]): void` - Remove primary keys from index key (efficient)
1017
+ - `setSlots(key: TKey, slots: Iterable<TOIMAnyEntitySlot<TPk>>): void` - Set canonical slots directly
692
1018
  - `clear(key?: TKey): void` - Clear all keys or specific key
693
1019
 
694
1020
  **Query:**
695
- - `index.getPksByKey(key: TKey): Set<TPk>` - Returns Set of primary keys
1021
+ - `index.getPksByKey(key: TKey): Set<TPk>` - Returns Set projection of primary keys
1022
+ - `index.getSlotsByKey(key: TKey): ReadonlySet<TOIMAnyEntitySlot<TPk>>` - Returns stored slots for fast entity reads
696
1023
 
697
1024
  #### `OIMReactiveIndexManualArrayBased<TKey, TPk>`
698
- Reactive Array-based index with manual key-to-entity mapping and change notifications. Returns `TPk[]` for direct array access.
1025
+ Reactive Array-based index with manual key-to-entity mapping and change notifications. Stores canonical slots internally and projects to `TPk[]` for PK reads.
699
1026
 
700
1027
  **Constructor:**
701
1028
  ```typescript
702
1029
  new OIMReactiveIndexManualArrayBased(queue: OIMEventQueue, opts?: {
703
- index?: OIMIndexManualArrayBased<TKey, TPk>
1030
+ indexOptions?: {
1031
+ comparePks?: TOIMIndexComparator<TPk>;
1032
+ store?: OIMIndexStoreArrayBased<TKey, TPk>;
1033
+ resolveSlot?: TOIMEntitySlotResolver<TPk>;
1034
+ }
704
1035
  })
705
1036
  ```
706
1037
 
@@ -712,10 +1043,12 @@ new OIMReactiveIndexManualArrayBased(queue: OIMEventQueue, opts?: {
712
1043
  - `setPks(key: TKey, pks: readonly TPk[]): void` - Set primary keys for index key (direct assignment, no diff) - **Recommended for ArrayBased**
713
1044
  - `addPks(key: TKey, pks: readonly TPk[]): void` - Add primary keys to index key (O(n) operation, less efficient than SetBased)
714
1045
  - `removePks(key: TKey, pks: readonly TPk[]): void` - Remove primary keys from index key (O(n) operation, less efficient than SetBased)
1046
+ - `setSlots(key: TKey, slots: TOIMAnyEntitySlot<TPk>[]): void` - Set canonical slots directly
715
1047
  - `clear(key?: TKey): void` - Clear all keys or specific key
716
1048
 
717
1049
  **Query:**
718
- - `index.getPksByKey(key: TKey): TPk[]` - Returns Array of primary keys
1050
+ - `index.getPksByKey(key: TKey): TPk[]` - Returns Array projection of primary keys
1051
+ - `index.getSlotsByKey(key: TKey): readonly TOIMAnyEntitySlot<TPk>[]` - Returns stored slots for fast entity reads
719
1052
 
720
1053
  **Note**: While `addPks`/`removePks` are available, they require array operations (Set creation, filtering) making them O(n) compared to O(1) for SetBased indexes. For ArrayBased indexes, prefer `setPks` for better performance when replacing the entire array.
721
1054