@oimdb/core 3.1.0 → 3.3.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
@@ -546,6 +546,31 @@ users.upsertOne({ id: 'user1', name: 'John' });
546
546
  users.upsertOne({ id: 'user1', email: 'john@example.com' }); // Merges with existing
547
547
  ```
548
548
 
549
+ #### Built-in updaters
550
+
551
+ Two updater strategies ship as factories — pass the result as `updateEntity`:
552
+
553
+ ```typescript
554
+ import { createMergeEntityUpdater, createInPlaceEntityUpdater } from '@oimdb/core';
555
+
556
+ // Default: immutable shallow merge — `{ ...prev, ...draft }`. Each update produces
557
+ // a NEW entity object (required for React's Object.is / useSyncExternalStore).
558
+ const a = new OIMReactiveCollection<User, string>(queue, {
559
+ selectPk: (u) => u.id,
560
+ updateEntity: createMergeEntityUpdater(),
561
+ });
562
+
563
+ // In-place: `Object.assign(prev, draft)` — mutates the existing object, no
564
+ // per-update allocation. The entity reference is STABLE across changes, so it
565
+ // only works with subscription-driven readers (e.g. the `*Signal` hooks in
566
+ // @oimdb/react), not with Object.is/React.memo diffing. Fastest on the data
567
+ // layer for update-heavy workloads.
568
+ const b = new OIMReactiveCollection<User, string>(queue, {
569
+ selectPk: (u) => u.id,
570
+ updateEntity: createInPlaceEntityUpdater(),
571
+ });
572
+ ```
573
+
549
574
  ### Event Coalescing and Update Subscriptions
550
575
 
551
576
  ```typescript
@@ -1509,7 +1534,7 @@ new OIMIndexManualOrderedArrayBased<TKey, TPk>()
1509
1534
  **Query:**
1510
1535
  - `getPksByKey(key: TKey): TPk[]` - Returns the ordered primary-key array
1511
1536
  - `getSlotsByKey(key: TKey): readonly TOIMAnyEntitySlot<TPk>[]` - Returns the stored ordered slots
1512
- - `getEntitiesByKey<TEntity>(key: TKey): TEntity[]` - Returns existing entities from stored slots
1537
+ - `getEntitiesByKey<TEntity>(key: TKey): (TEntity | undefined)[]` - Returns entities from stored slots, aligned 1:1 with the pks (holes are `undefined`)
1513
1538
 
1514
1539
  #### `OIMCollectionIndexManualOrderedArrayBased<TKey, TPk, TEntity>`
1515
1540
  Collection-bound ordered Array-based index. Use this when ordered PK writes should resolve to canonical slots from a collection.
@@ -1518,7 +1543,7 @@ Collection-bound ordered Array-based index. Use this when ordered PK writes shou
1518
1543
  - `push(key: TKey, pk: TPk): number` - Append a PK as its canonical slot
1519
1544
  - `insertAt(key: TKey, index: number, pk: TPk): number` - Insert a PK as its canonical slot
1520
1545
  - `reset(key: TKey, pks: readonly TPk[]): void` - Replace the whole ordered list from PKs
1521
- - `getEntitiesByKey(key: TKey): TEntity[]` - Returns entities from canonical slots
1546
+ - `getEntitiesByKey(key: TKey): (TEntity | undefined)[]` - Returns entities from canonical slots (holes are `undefined`)
1522
1547
 
1523
1548
  #### `OIMOrderedListCommandStream<TKey, TPk, TEntity>`
1524
1549
  Slot-first ordered per-key list with a command stream for imperative consumers. It stores data in an `OIMIndexManualOrderedArrayBased` and emits `TOIMOrderedListCommand` batches through `commandsEventEmitter`.
@@ -1545,7 +1570,7 @@ new OIMOrderedListCommandStream(
1545
1570
  - `getBufferedCommands(key: TKey): readonly TOIMOrderedListCommand<TPk, TEntity>[]` - Peek at buffered commands without clearing them
1546
1571
  - `getPksByKey(key: TKey): readonly TPk[]` - Read the current ordered list
1547
1572
  - `getSlotsByKey(key: TKey): readonly TOIMEntitySlot<TEntity, TPk>[]` - Read current ordered slots
1548
- - `getEntitiesByKey(key: TKey): TEntity[]` - Read current ordered entities
1573
+ - `getEntitiesByKey(key: TKey): (TEntity | undefined)[]` - Read current ordered entities (holes are `undefined`)
1549
1574
  - `clear(key?: TKey): void` - Clear all keys or a specific key
1550
1575
  - `destroy(): void` - Dispose subscriptions and clear state
1551
1576
 
package/dist/index.cjs CHANGED
@@ -537,7 +537,6 @@ var OIMCarrierKeyedEmitter = class {
537
537
  this.isFlushEnqueued = false;
538
538
  this.onFlush = () => {
539
539
  this.isFlushEnqueued = false;
540
- this.dequeueFlush = void 0;
541
540
  if (this.dirtyCarriers.length === 0) return;
542
541
  const flushing = this.dirtyCarriers;
543
542
  this.dirtyCarriers = [];
@@ -647,9 +646,9 @@ var OIMCarrierKeyedEmitter = class {
647
646
  };
648
647
  }
649
648
  destroy() {
650
- if (this.dequeueFlush) {
651
- this.dequeueFlush();
652
- this.dequeueFlush = void 0;
649
+ if (this.isFlushEnqueued) {
650
+ this.queue.cancel(this.onFlush);
651
+ this.isFlushEnqueued = false;
653
652
  }
654
653
  this.subscribedCarriers.forEach((carrier) => {
655
654
  carrier.subscribers?.clear();
@@ -672,7 +671,7 @@ var OIMCarrierKeyedEmitter = class {
672
671
  scheduleFlush() {
673
672
  if (this.isFlushEnqueued) return;
674
673
  this.isFlushEnqueued = true;
675
- this.dequeueFlush = this.queue.enqueue(this.onFlush);
674
+ this.queue.enqueue(this.onFlush);
676
675
  }
677
676
  notify(carrier) {
678
677
  const subscribers = carrier.subscribers;
@@ -1098,7 +1097,6 @@ var OIMReactiveCollection = class extends OIMCollection {
1098
1097
  this.runAnyUpdate = () => {
1099
1098
  if (!this.isAnyUpdateScheduled) return;
1100
1099
  this.isAnyUpdateScheduled = false;
1101
- this.dequeueAnyUpdate = void 0;
1102
1100
  const pks = this.isAnyUpdateClearPending ? [] : Array.from(this.pendingAnyUpdatePks);
1103
1101
  this.pendingAnyUpdatePks.clear();
1104
1102
  this.isAnyUpdateClearPending = false;
@@ -1238,13 +1236,13 @@ var OIMReactiveCollection = class extends OIMCollection {
1238
1236
  ensureAnyUpdateEnqueued() {
1239
1237
  if (this.isAnyUpdateScheduled) return;
1240
1238
  this.isAnyUpdateScheduled = true;
1241
- this.dequeueAnyUpdate = this.queue.enqueue(this.runAnyUpdate);
1239
+ this.queue.enqueue(this.runAnyUpdate);
1242
1240
  }
1243
1241
  destroy() {
1244
1242
  this.updateEmitter.destroy();
1245
- if (this.dequeueAnyUpdate) {
1246
- this.dequeueAnyUpdate();
1247
- this.dequeueAnyUpdate = void 0;
1243
+ if (this.isAnyUpdateScheduled) {
1244
+ this.queue.cancel(this.runAnyUpdate);
1245
+ this.isAnyUpdateScheduled = false;
1248
1246
  }
1249
1247
  this.anyUpdateHandlers.clear();
1250
1248
  this.pendingAnyUpdatePks.clear();
@@ -1386,7 +1384,6 @@ var OIMUpdateEventEmitter = class {
1386
1384
  // Stable handler reference to avoid allocating a new closure on every HAS_CHANGES.
1387
1385
  this.onFlush = () => {
1388
1386
  this.isFlushEnqueued = false;
1389
- this.dequeueFlush = void 0;
1390
1387
  if (this.keyHandlers.size === 0) {
1391
1388
  this.updatedKeys.clear();
1392
1389
  return;
@@ -1439,7 +1436,7 @@ var OIMUpdateEventEmitter = class {
1439
1436
  }
1440
1437
  if (this.isFlushEnqueued) return;
1441
1438
  this.isFlushEnqueued = true;
1442
- this.dequeueFlush = this.queue.enqueue(this.onFlush);
1439
+ this.queue.enqueue(this.onFlush);
1443
1440
  }
1444
1441
  markUpdatedKey(key) {
1445
1442
  if (this.deliveryMode === "queue" && this.queue.isInFlush) {
@@ -1461,7 +1458,7 @@ var OIMUpdateEventEmitter = class {
1461
1458
  }
1462
1459
  if (this.isFlushEnqueued) return;
1463
1460
  this.isFlushEnqueued = true;
1464
- this.dequeueFlush = this.queue.enqueue(this.onFlush);
1461
+ this.queue.enqueue(this.onFlush);
1465
1462
  }
1466
1463
  markAllUpdated() {
1467
1464
  if (this.deliveryMode === "queue" && this.queue.isInFlush) {
@@ -1483,7 +1480,7 @@ var OIMUpdateEventEmitter = class {
1483
1480
  }
1484
1481
  if (this.isFlushEnqueued) return;
1485
1482
  this.isFlushEnqueued = true;
1486
- this.dequeueFlush = this.queue.enqueue(this.onFlush);
1483
+ this.queue.enqueue(this.onFlush);
1487
1484
  }
1488
1485
  deliverImmediate() {
1489
1486
  this.runSinglePass();
@@ -1531,9 +1528,9 @@ var OIMUpdateEventEmitter = class {
1531
1528
  }
1532
1529
  }
1533
1530
  destroy() {
1534
- if (this.dequeueFlush) {
1535
- this.dequeueFlush();
1536
- this.dequeueFlush = void 0;
1531
+ if (this.isFlushEnqueued) {
1532
+ this.queue.cancel(this.onFlush);
1533
+ this.isFlushEnqueued = false;
1537
1534
  }
1538
1535
  if (this.unsubscribeAfterFlushDelivery) {
1539
1536
  this.unsubscribeAfterFlushDelivery();
@@ -1591,9 +1588,8 @@ var OIMUpdateEventEmitter = class {
1591
1588
  }
1592
1589
  if (this.keyHandlers.size === 0) {
1593
1590
  this.updatedKeys.clear();
1594
- if (this.dequeueFlush) {
1595
- this.dequeueFlush();
1596
- this.dequeueFlush = void 0;
1591
+ if (this.isFlushEnqueued) {
1592
+ this.queue.cancel(this.onFlush);
1597
1593
  }
1598
1594
  this.isFlushEnqueued = false;
1599
1595
  }
@@ -2551,17 +2547,15 @@ var OIMObject = class {
2551
2547
  }
2552
2548
  setProperty(key, value) {
2553
2549
  this.store.setProperty(key, value);
2554
- this.emitter.emit(0 /* UPDATE */, { keys: [key] });
2550
+ this.onUpdatedKey(key);
2555
2551
  }
2556
2552
  merge(draft) {
2557
2553
  this.store.merge(draft);
2558
- this.emitter.emit(0 /* UPDATE */, {
2559
- keys: Object.keys(draft)
2560
- });
2554
+ this.onUpdatedKeys(Object.keys(draft));
2561
2555
  }
2562
2556
  delete(key) {
2563
2557
  this.store.delete(key);
2564
- this.emitter.emit(0 /* UPDATE */, { keys: [key] });
2558
+ this.onUpdatedKey(key);
2565
2559
  }
2566
2560
  get(key) {
2567
2561
  return this.store.get(key);
@@ -2571,7 +2565,24 @@ var OIMObject = class {
2571
2565
  }
2572
2566
  clear() {
2573
2567
  this.store.clear();
2574
- this.emitter.emit(0 /* UPDATE */, { keys: [] });
2568
+ this.onUpdatedKeys([]);
2569
+ }
2570
+ /**
2571
+ * Write fan-out hooks. Split single-key vs multi-key so a reactive subclass
2572
+ * can mark its keyed emitter on the hot single-property path without
2573
+ * allocating a `{ keys: [key] }` payload. The base only fires the general
2574
+ * UPDATE event, and only when something is actually subscribed to it — so an
2575
+ * unobserved write skips the payload allocation entirely.
2576
+ */
2577
+ onUpdatedKey(key) {
2578
+ if (this.emitter.hasHandlers(0 /* UPDATE */)) {
2579
+ this.emitter.emit(0 /* UPDATE */, { keys: [key] });
2580
+ }
2581
+ }
2582
+ onUpdatedKeys(keys) {
2583
+ if (this.emitter.hasHandlers(0 /* UPDATE */)) {
2584
+ this.emitter.emit(0 /* UPDATE */, { keys });
2585
+ }
2575
2586
  }
2576
2587
  count() {
2577
2588
  return this.store.count();
@@ -2595,10 +2606,20 @@ var OIMObject = class {
2595
2606
  var OIMReactiveObject = class extends OIMObject {
2596
2607
  constructor(queue, opts) {
2597
2608
  super(opts);
2598
- this.updateEmitter = new OIMUpdateEventEmitter(queue, "queue");
2599
- this.emitter.on(0 /* UPDATE */, (payload) => {
2600
- this.updateEmitter.markUpdatedKeys(payload.keys);
2601
- });
2609
+ this.updateEmitter = new OIMCarrierKeyedEmitter(queue, new OIMKeyedCarrierResolver());
2610
+ }
2611
+ /**
2612
+ * Mark the written key(s) on the keyed emitter directly — no general-event
2613
+ * bridge, no `{ keys }` payload on the single-key path. `super` still fires
2614
+ * the general UPDATE event for any low-level `emitter` subscribers.
2615
+ */
2616
+ onUpdatedKey(key) {
2617
+ this.updateEmitter.markUpdatedKey(key);
2618
+ super.onUpdatedKey(key);
2619
+ }
2620
+ onUpdatedKeys(keys) {
2621
+ this.updateEmitter.markUpdatedKeys(keys);
2622
+ super.onUpdatedKeys(keys);
2602
2623
  }
2603
2624
  subscribeOnKey(key, handler) {
2604
2625
  return this.updateEmitter.subscribeOnKey(key, handler);
@@ -2702,6 +2723,9 @@ var OIMEventQueue = class {
2702
2723
  constructor(options = {}) {
2703
2724
  this.emitter = new OIMEventEmitter();
2704
2725
  this.tasks = /* @__PURE__ */ new Set();
2726
+ // Reused buffer swapped in during flush so the pending set is never copied
2727
+ // (no per-flush array snapshot) and new enqueues land in a fresh set.
2728
+ this.tasksSpare = /* @__PURE__ */ new Set();
2705
2729
  this.isFlushing = false;
2706
2730
  this.scheduler = options.scheduler;
2707
2731
  if (this.scheduler) {
@@ -2713,32 +2737,23 @@ var OIMEventQueue = class {
2713
2737
  }
2714
2738
  }
2715
2739
  /**
2716
- * Enqueue a one-shot task to be executed on the next flush.
2717
- * Returns a dequeue function that can be called to cancel the task before it runs.
2740
+ * Enqueue a one-shot task to run on the next flush. The function is stored
2741
+ * directly (no wrapper, no returned closure) and cancelled via {@link cancel}
2742
+ * by passing the same reference. Enqueuing the same reference twice is
2743
+ * idempotent — it is Set-deduped and runs once per flush.
2718
2744
  */
2719
2745
  enqueue(fn) {
2720
- let isActive = true;
2721
- const task = () => {
2722
- if (!isActive) return;
2723
- fn();
2724
- };
2725
- this.tasks.add(task);
2746
+ this.tasks.add(fn);
2726
2747
  this.ensureScheduled();
2727
- return () => {
2728
- if (!isActive) return;
2729
- isActive = false;
2730
- this.tasks.delete(task);
2731
- };
2748
+ }
2749
+ /** Cancel a previously enqueued task by its reference; safe to call mid-flush. */
2750
+ cancel(fn) {
2751
+ this.tasks.delete(fn);
2752
+ this.flushing?.delete(fn);
2732
2753
  }
2733
2754
  get isInFlush() {
2734
2755
  return this.isFlushing;
2735
2756
  }
2736
- /**
2737
- * Dequeue (cancel) a previously enqueued task by its dequeue function.
2738
- */
2739
- dequeue(dequeueFn) {
2740
- dequeueFn();
2741
- }
2742
2757
  /**
2743
2758
  * Execute all pending tasks.
2744
2759
  *
@@ -2749,11 +2764,13 @@ var OIMEventQueue = class {
2749
2764
  this.scheduler?.cancel();
2750
2765
  this.isFlushing = true;
2751
2766
  this.emitter.emit(1 /* BEFORE_FLUSH */, void 0);
2752
- const snapshot = Array.from(this.tasks);
2753
- this.tasks.clear();
2754
- for (let i = 0; i < snapshot.length; i++) {
2755
- snapshot[i]();
2756
- }
2767
+ const flushing = this.tasks;
2768
+ this.tasks = this.tasksSpare;
2769
+ this.flushing = flushing;
2770
+ for (const task of flushing) task();
2771
+ flushing.clear();
2772
+ this.tasksSpare = flushing;
2773
+ this.flushing = void 0;
2757
2774
  this.isFlushing = false;
2758
2775
  this.emitter.emit(0 /* AFTER_FLUSH */, void 0);
2759
2776
  }
package/dist/index.d.cts CHANGED
@@ -247,20 +247,22 @@ declare enum EOIMEventQueueEventType {
247
247
  declare class OIMEventQueue {
248
248
  readonly emitter: OIMEventEmitter<Record<EOIMEventQueueEventType, void>>;
249
249
  protected tasks: Set<() => void>;
250
+ protected tasksSpare: Set<() => void>;
251
+ protected flushing?: Set<() => void>;
250
252
  protected readonly scheduler?: OIMEventQueueScheduler;
251
253
  protected flushBound?: () => void;
252
254
  protected isFlushing: boolean;
253
255
  constructor(options?: TOIMEventQueueOptions);
254
256
  /**
255
- * Enqueue a one-shot task to be executed on the next flush.
256
- * Returns a dequeue function that can be called to cancel the task before it runs.
257
+ * Enqueue a one-shot task to run on the next flush. The function is stored
258
+ * directly (no wrapper, no returned closure) and cancelled via {@link cancel}
259
+ * by passing the same reference. Enqueuing the same reference twice is
260
+ * idempotent — it is Set-deduped and runs once per flush.
257
261
  */
258
- enqueue(fn: () => void): () => void;
262
+ enqueue(fn: () => void): void;
263
+ /** Cancel a previously enqueued task by its reference; safe to call mid-flush. */
264
+ cancel(fn: () => void): void;
259
265
  get isInFlush(): boolean;
260
- /**
261
- * Dequeue (cancel) a previously enqueued task by its dequeue function.
262
- */
263
- dequeue(dequeueFn: () => void): void;
264
266
  /**
265
267
  * Execute all pending tasks.
266
268
  *
@@ -454,7 +456,6 @@ declare class OIMCarrierKeyedEmitter<TKey extends TOIMPk, TCarrier extends IOIMS
454
456
  private readonly subscribedCarriers;
455
457
  private dirtyCarriers;
456
458
  private isFlushEnqueued;
457
- private dequeueFlush?;
458
459
  constructor(queue: OIMEventQueue, resolver: IOIMCarrierResolver<TKey, TCarrier>);
459
460
  subscribeOnKey(key: TKey, handler: TOIMEventHandler<void>): () => void;
460
461
  subscribeOnKeys(keys: readonly TKey[], handler: TOIMEventHandler<void>): () => void;
@@ -489,7 +490,6 @@ declare class OIMReactiveCollection<TEntity extends object, TPk extends TOIMPk>
489
490
  private pendingAnyUpdatePks;
490
491
  private isAnyUpdateClearPending;
491
492
  private isAnyUpdateScheduled;
492
- private dequeueAnyUpdate?;
493
493
  constructor(queue: OIMEventQueue, opts?: TOIMCollectionOptions<TEntity, TPk>);
494
494
  subscribeOnKey(pk: TPk, handler: TOIMEventHandler<void>): () => void;
495
495
  subscribeOnKeys(pks: readonly TPk[], handler: TOIMEventHandler<void>): () => void;
@@ -661,7 +661,6 @@ declare class OIMUpdateEventEmitter<TKey extends TOIMPk> {
661
661
  protected queue: OIMEventQueue;
662
662
  private readonly deliveryMode;
663
663
  private isFlushEnqueued;
664
- private dequeueFlush?;
665
664
  private updatedKeys;
666
665
  private isAfterFlushDeliveryScheduled;
667
666
  private unsubscribeAfterFlushDelivery?;
@@ -999,6 +998,15 @@ declare class OIMObject<TKey extends string, TValue> {
999
998
  get(key: TKey): TValue | undefined;
1000
999
  getAll(): Record<TKey, TValue>;
1001
1000
  clear(): void;
1001
+ /**
1002
+ * Write fan-out hooks. Split single-key vs multi-key so a reactive subclass
1003
+ * can mark its keyed emitter on the hot single-property path without
1004
+ * allocating a `{ keys: [key] }` payload. The base only fires the general
1005
+ * UPDATE event, and only when something is actually subscribed to it — so an
1006
+ * unobserved write skips the payload allocation entirely.
1007
+ */
1008
+ protected onUpdatedKey(key: TKey): void;
1009
+ protected onUpdatedKeys(keys: TKey[]): void;
1002
1010
  count(): number;
1003
1011
  keys(): TKey[];
1004
1012
  values(): TValue[];
@@ -1007,8 +1015,15 @@ declare class OIMObject<TKey extends string, TValue> {
1007
1015
  }
1008
1016
 
1009
1017
  declare class OIMReactiveObject<TKey extends string, TValue> extends OIMObject<TKey, TValue> implements IOIMKeyedSubscription<TKey> {
1010
- protected readonly updateEmitter: OIMUpdateEventEmitter<TKey>;
1018
+ protected readonly updateEmitter: IOIMKeyedUpdateEmitter<TKey>;
1011
1019
  constructor(queue: OIMEventQueue, opts?: TOIMObjectOptions<TKey, TValue>);
1020
+ /**
1021
+ * Mark the written key(s) on the keyed emitter directly — no general-event
1022
+ * bridge, no `{ keys }` payload on the single-key path. `super` still fires
1023
+ * the general UPDATE event for any low-level `emitter` subscribers.
1024
+ */
1025
+ protected onUpdatedKey(key: TKey): void;
1026
+ protected onUpdatedKeys(keys: TKey[]): void;
1012
1027
  subscribeOnKey(key: TKey, handler: TOIMEventHandler<void>): () => void;
1013
1028
  subscribeOnKeys(keys: readonly TKey[], handler: TOIMEventHandler<void>): () => void;
1014
1029
  unsubscribeFromKey(key: TKey, handler: TOIMEventHandler<void>): void;
package/dist/index.d.ts CHANGED
@@ -247,20 +247,22 @@ declare enum EOIMEventQueueEventType {
247
247
  declare class OIMEventQueue {
248
248
  readonly emitter: OIMEventEmitter<Record<EOIMEventQueueEventType, void>>;
249
249
  protected tasks: Set<() => void>;
250
+ protected tasksSpare: Set<() => void>;
251
+ protected flushing?: Set<() => void>;
250
252
  protected readonly scheduler?: OIMEventQueueScheduler;
251
253
  protected flushBound?: () => void;
252
254
  protected isFlushing: boolean;
253
255
  constructor(options?: TOIMEventQueueOptions);
254
256
  /**
255
- * Enqueue a one-shot task to be executed on the next flush.
256
- * Returns a dequeue function that can be called to cancel the task before it runs.
257
+ * Enqueue a one-shot task to run on the next flush. The function is stored
258
+ * directly (no wrapper, no returned closure) and cancelled via {@link cancel}
259
+ * by passing the same reference. Enqueuing the same reference twice is
260
+ * idempotent — it is Set-deduped and runs once per flush.
257
261
  */
258
- enqueue(fn: () => void): () => void;
262
+ enqueue(fn: () => void): void;
263
+ /** Cancel a previously enqueued task by its reference; safe to call mid-flush. */
264
+ cancel(fn: () => void): void;
259
265
  get isInFlush(): boolean;
260
- /**
261
- * Dequeue (cancel) a previously enqueued task by its dequeue function.
262
- */
263
- dequeue(dequeueFn: () => void): void;
264
266
  /**
265
267
  * Execute all pending tasks.
266
268
  *
@@ -454,7 +456,6 @@ declare class OIMCarrierKeyedEmitter<TKey extends TOIMPk, TCarrier extends IOIMS
454
456
  private readonly subscribedCarriers;
455
457
  private dirtyCarriers;
456
458
  private isFlushEnqueued;
457
- private dequeueFlush?;
458
459
  constructor(queue: OIMEventQueue, resolver: IOIMCarrierResolver<TKey, TCarrier>);
459
460
  subscribeOnKey(key: TKey, handler: TOIMEventHandler<void>): () => void;
460
461
  subscribeOnKeys(keys: readonly TKey[], handler: TOIMEventHandler<void>): () => void;
@@ -489,7 +490,6 @@ declare class OIMReactiveCollection<TEntity extends object, TPk extends TOIMPk>
489
490
  private pendingAnyUpdatePks;
490
491
  private isAnyUpdateClearPending;
491
492
  private isAnyUpdateScheduled;
492
- private dequeueAnyUpdate?;
493
493
  constructor(queue: OIMEventQueue, opts?: TOIMCollectionOptions<TEntity, TPk>);
494
494
  subscribeOnKey(pk: TPk, handler: TOIMEventHandler<void>): () => void;
495
495
  subscribeOnKeys(pks: readonly TPk[], handler: TOIMEventHandler<void>): () => void;
@@ -661,7 +661,6 @@ declare class OIMUpdateEventEmitter<TKey extends TOIMPk> {
661
661
  protected queue: OIMEventQueue;
662
662
  private readonly deliveryMode;
663
663
  private isFlushEnqueued;
664
- private dequeueFlush?;
665
664
  private updatedKeys;
666
665
  private isAfterFlushDeliveryScheduled;
667
666
  private unsubscribeAfterFlushDelivery?;
@@ -999,6 +998,15 @@ declare class OIMObject<TKey extends string, TValue> {
999
998
  get(key: TKey): TValue | undefined;
1000
999
  getAll(): Record<TKey, TValue>;
1001
1000
  clear(): void;
1001
+ /**
1002
+ * Write fan-out hooks. Split single-key vs multi-key so a reactive subclass
1003
+ * can mark its keyed emitter on the hot single-property path without
1004
+ * allocating a `{ keys: [key] }` payload. The base only fires the general
1005
+ * UPDATE event, and only when something is actually subscribed to it — so an
1006
+ * unobserved write skips the payload allocation entirely.
1007
+ */
1008
+ protected onUpdatedKey(key: TKey): void;
1009
+ protected onUpdatedKeys(keys: TKey[]): void;
1002
1010
  count(): number;
1003
1011
  keys(): TKey[];
1004
1012
  values(): TValue[];
@@ -1007,8 +1015,15 @@ declare class OIMObject<TKey extends string, TValue> {
1007
1015
  }
1008
1016
 
1009
1017
  declare class OIMReactiveObject<TKey extends string, TValue> extends OIMObject<TKey, TValue> implements IOIMKeyedSubscription<TKey> {
1010
- protected readonly updateEmitter: OIMUpdateEventEmitter<TKey>;
1018
+ protected readonly updateEmitter: IOIMKeyedUpdateEmitter<TKey>;
1011
1019
  constructor(queue: OIMEventQueue, opts?: TOIMObjectOptions<TKey, TValue>);
1020
+ /**
1021
+ * Mark the written key(s) on the keyed emitter directly — no general-event
1022
+ * bridge, no `{ keys }` payload on the single-key path. `super` still fires
1023
+ * the general UPDATE event for any low-level `emitter` subscribers.
1024
+ */
1025
+ protected onUpdatedKey(key: TKey): void;
1026
+ protected onUpdatedKeys(keys: TKey[]): void;
1012
1027
  subscribeOnKey(key: TKey, handler: TOIMEventHandler<void>): () => void;
1013
1028
  subscribeOnKeys(keys: readonly TKey[], handler: TOIMEventHandler<void>): () => void;
1014
1029
  unsubscribeFromKey(key: TKey, handler: TOIMEventHandler<void>): void;
package/dist/index.js CHANGED
@@ -435,7 +435,6 @@ var OIMCarrierKeyedEmitter = class {
435
435
  this.isFlushEnqueued = false;
436
436
  this.onFlush = () => {
437
437
  this.isFlushEnqueued = false;
438
- this.dequeueFlush = void 0;
439
438
  if (this.dirtyCarriers.length === 0) return;
440
439
  const flushing = this.dirtyCarriers;
441
440
  this.dirtyCarriers = [];
@@ -545,9 +544,9 @@ var OIMCarrierKeyedEmitter = class {
545
544
  };
546
545
  }
547
546
  destroy() {
548
- if (this.dequeueFlush) {
549
- this.dequeueFlush();
550
- this.dequeueFlush = void 0;
547
+ if (this.isFlushEnqueued) {
548
+ this.queue.cancel(this.onFlush);
549
+ this.isFlushEnqueued = false;
551
550
  }
552
551
  this.subscribedCarriers.forEach((carrier) => {
553
552
  carrier.subscribers?.clear();
@@ -570,7 +569,7 @@ var OIMCarrierKeyedEmitter = class {
570
569
  scheduleFlush() {
571
570
  if (this.isFlushEnqueued) return;
572
571
  this.isFlushEnqueued = true;
573
- this.dequeueFlush = this.queue.enqueue(this.onFlush);
572
+ this.queue.enqueue(this.onFlush);
574
573
  }
575
574
  notify(carrier) {
576
575
  const subscribers = carrier.subscribers;
@@ -996,7 +995,6 @@ var OIMReactiveCollection = class extends OIMCollection {
996
995
  this.runAnyUpdate = () => {
997
996
  if (!this.isAnyUpdateScheduled) return;
998
997
  this.isAnyUpdateScheduled = false;
999
- this.dequeueAnyUpdate = void 0;
1000
998
  const pks = this.isAnyUpdateClearPending ? [] : Array.from(this.pendingAnyUpdatePks);
1001
999
  this.pendingAnyUpdatePks.clear();
1002
1000
  this.isAnyUpdateClearPending = false;
@@ -1136,13 +1134,13 @@ var OIMReactiveCollection = class extends OIMCollection {
1136
1134
  ensureAnyUpdateEnqueued() {
1137
1135
  if (this.isAnyUpdateScheduled) return;
1138
1136
  this.isAnyUpdateScheduled = true;
1139
- this.dequeueAnyUpdate = this.queue.enqueue(this.runAnyUpdate);
1137
+ this.queue.enqueue(this.runAnyUpdate);
1140
1138
  }
1141
1139
  destroy() {
1142
1140
  this.updateEmitter.destroy();
1143
- if (this.dequeueAnyUpdate) {
1144
- this.dequeueAnyUpdate();
1145
- this.dequeueAnyUpdate = void 0;
1141
+ if (this.isAnyUpdateScheduled) {
1142
+ this.queue.cancel(this.runAnyUpdate);
1143
+ this.isAnyUpdateScheduled = false;
1146
1144
  }
1147
1145
  this.anyUpdateHandlers.clear();
1148
1146
  this.pendingAnyUpdatePks.clear();
@@ -1284,7 +1282,6 @@ var OIMUpdateEventEmitter = class {
1284
1282
  // Stable handler reference to avoid allocating a new closure on every HAS_CHANGES.
1285
1283
  this.onFlush = () => {
1286
1284
  this.isFlushEnqueued = false;
1287
- this.dequeueFlush = void 0;
1288
1285
  if (this.keyHandlers.size === 0) {
1289
1286
  this.updatedKeys.clear();
1290
1287
  return;
@@ -1337,7 +1334,7 @@ var OIMUpdateEventEmitter = class {
1337
1334
  }
1338
1335
  if (this.isFlushEnqueued) return;
1339
1336
  this.isFlushEnqueued = true;
1340
- this.dequeueFlush = this.queue.enqueue(this.onFlush);
1337
+ this.queue.enqueue(this.onFlush);
1341
1338
  }
1342
1339
  markUpdatedKey(key) {
1343
1340
  if (this.deliveryMode === "queue" && this.queue.isInFlush) {
@@ -1359,7 +1356,7 @@ var OIMUpdateEventEmitter = class {
1359
1356
  }
1360
1357
  if (this.isFlushEnqueued) return;
1361
1358
  this.isFlushEnqueued = true;
1362
- this.dequeueFlush = this.queue.enqueue(this.onFlush);
1359
+ this.queue.enqueue(this.onFlush);
1363
1360
  }
1364
1361
  markAllUpdated() {
1365
1362
  if (this.deliveryMode === "queue" && this.queue.isInFlush) {
@@ -1381,7 +1378,7 @@ var OIMUpdateEventEmitter = class {
1381
1378
  }
1382
1379
  if (this.isFlushEnqueued) return;
1383
1380
  this.isFlushEnqueued = true;
1384
- this.dequeueFlush = this.queue.enqueue(this.onFlush);
1381
+ this.queue.enqueue(this.onFlush);
1385
1382
  }
1386
1383
  deliverImmediate() {
1387
1384
  this.runSinglePass();
@@ -1429,9 +1426,9 @@ var OIMUpdateEventEmitter = class {
1429
1426
  }
1430
1427
  }
1431
1428
  destroy() {
1432
- if (this.dequeueFlush) {
1433
- this.dequeueFlush();
1434
- this.dequeueFlush = void 0;
1429
+ if (this.isFlushEnqueued) {
1430
+ this.queue.cancel(this.onFlush);
1431
+ this.isFlushEnqueued = false;
1435
1432
  }
1436
1433
  if (this.unsubscribeAfterFlushDelivery) {
1437
1434
  this.unsubscribeAfterFlushDelivery();
@@ -1489,9 +1486,8 @@ var OIMUpdateEventEmitter = class {
1489
1486
  }
1490
1487
  if (this.keyHandlers.size === 0) {
1491
1488
  this.updatedKeys.clear();
1492
- if (this.dequeueFlush) {
1493
- this.dequeueFlush();
1494
- this.dequeueFlush = void 0;
1489
+ if (this.isFlushEnqueued) {
1490
+ this.queue.cancel(this.onFlush);
1495
1491
  }
1496
1492
  this.isFlushEnqueued = false;
1497
1493
  }
@@ -2449,17 +2445,15 @@ var OIMObject = class {
2449
2445
  }
2450
2446
  setProperty(key, value) {
2451
2447
  this.store.setProperty(key, value);
2452
- this.emitter.emit(0 /* UPDATE */, { keys: [key] });
2448
+ this.onUpdatedKey(key);
2453
2449
  }
2454
2450
  merge(draft) {
2455
2451
  this.store.merge(draft);
2456
- this.emitter.emit(0 /* UPDATE */, {
2457
- keys: Object.keys(draft)
2458
- });
2452
+ this.onUpdatedKeys(Object.keys(draft));
2459
2453
  }
2460
2454
  delete(key) {
2461
2455
  this.store.delete(key);
2462
- this.emitter.emit(0 /* UPDATE */, { keys: [key] });
2456
+ this.onUpdatedKey(key);
2463
2457
  }
2464
2458
  get(key) {
2465
2459
  return this.store.get(key);
@@ -2469,7 +2463,24 @@ var OIMObject = class {
2469
2463
  }
2470
2464
  clear() {
2471
2465
  this.store.clear();
2472
- this.emitter.emit(0 /* UPDATE */, { keys: [] });
2466
+ this.onUpdatedKeys([]);
2467
+ }
2468
+ /**
2469
+ * Write fan-out hooks. Split single-key vs multi-key so a reactive subclass
2470
+ * can mark its keyed emitter on the hot single-property path without
2471
+ * allocating a `{ keys: [key] }` payload. The base only fires the general
2472
+ * UPDATE event, and only when something is actually subscribed to it — so an
2473
+ * unobserved write skips the payload allocation entirely.
2474
+ */
2475
+ onUpdatedKey(key) {
2476
+ if (this.emitter.hasHandlers(0 /* UPDATE */)) {
2477
+ this.emitter.emit(0 /* UPDATE */, { keys: [key] });
2478
+ }
2479
+ }
2480
+ onUpdatedKeys(keys) {
2481
+ if (this.emitter.hasHandlers(0 /* UPDATE */)) {
2482
+ this.emitter.emit(0 /* UPDATE */, { keys });
2483
+ }
2473
2484
  }
2474
2485
  count() {
2475
2486
  return this.store.count();
@@ -2493,10 +2504,20 @@ var OIMObject = class {
2493
2504
  var OIMReactiveObject = class extends OIMObject {
2494
2505
  constructor(queue, opts) {
2495
2506
  super(opts);
2496
- this.updateEmitter = new OIMUpdateEventEmitter(queue, "queue");
2497
- this.emitter.on(0 /* UPDATE */, (payload) => {
2498
- this.updateEmitter.markUpdatedKeys(payload.keys);
2499
- });
2507
+ this.updateEmitter = new OIMCarrierKeyedEmitter(queue, new OIMKeyedCarrierResolver());
2508
+ }
2509
+ /**
2510
+ * Mark the written key(s) on the keyed emitter directly — no general-event
2511
+ * bridge, no `{ keys }` payload on the single-key path. `super` still fires
2512
+ * the general UPDATE event for any low-level `emitter` subscribers.
2513
+ */
2514
+ onUpdatedKey(key) {
2515
+ this.updateEmitter.markUpdatedKey(key);
2516
+ super.onUpdatedKey(key);
2517
+ }
2518
+ onUpdatedKeys(keys) {
2519
+ this.updateEmitter.markUpdatedKeys(keys);
2520
+ super.onUpdatedKeys(keys);
2500
2521
  }
2501
2522
  subscribeOnKey(key, handler) {
2502
2523
  return this.updateEmitter.subscribeOnKey(key, handler);
@@ -2600,6 +2621,9 @@ var OIMEventQueue = class {
2600
2621
  constructor(options = {}) {
2601
2622
  this.emitter = new OIMEventEmitter();
2602
2623
  this.tasks = /* @__PURE__ */ new Set();
2624
+ // Reused buffer swapped in during flush so the pending set is never copied
2625
+ // (no per-flush array snapshot) and new enqueues land in a fresh set.
2626
+ this.tasksSpare = /* @__PURE__ */ new Set();
2603
2627
  this.isFlushing = false;
2604
2628
  this.scheduler = options.scheduler;
2605
2629
  if (this.scheduler) {
@@ -2611,32 +2635,23 @@ var OIMEventQueue = class {
2611
2635
  }
2612
2636
  }
2613
2637
  /**
2614
- * Enqueue a one-shot task to be executed on the next flush.
2615
- * Returns a dequeue function that can be called to cancel the task before it runs.
2638
+ * Enqueue a one-shot task to run on the next flush. The function is stored
2639
+ * directly (no wrapper, no returned closure) and cancelled via {@link cancel}
2640
+ * by passing the same reference. Enqueuing the same reference twice is
2641
+ * idempotent — it is Set-deduped and runs once per flush.
2616
2642
  */
2617
2643
  enqueue(fn) {
2618
- let isActive = true;
2619
- const task = () => {
2620
- if (!isActive) return;
2621
- fn();
2622
- };
2623
- this.tasks.add(task);
2644
+ this.tasks.add(fn);
2624
2645
  this.ensureScheduled();
2625
- return () => {
2626
- if (!isActive) return;
2627
- isActive = false;
2628
- this.tasks.delete(task);
2629
- };
2646
+ }
2647
+ /** Cancel a previously enqueued task by its reference; safe to call mid-flush. */
2648
+ cancel(fn) {
2649
+ this.tasks.delete(fn);
2650
+ this.flushing?.delete(fn);
2630
2651
  }
2631
2652
  get isInFlush() {
2632
2653
  return this.isFlushing;
2633
2654
  }
2634
- /**
2635
- * Dequeue (cancel) a previously enqueued task by its dequeue function.
2636
- */
2637
- dequeue(dequeueFn) {
2638
- dequeueFn();
2639
- }
2640
2655
  /**
2641
2656
  * Execute all pending tasks.
2642
2657
  *
@@ -2647,11 +2662,13 @@ var OIMEventQueue = class {
2647
2662
  this.scheduler?.cancel();
2648
2663
  this.isFlushing = true;
2649
2664
  this.emitter.emit(1 /* BEFORE_FLUSH */, void 0);
2650
- const snapshot = Array.from(this.tasks);
2651
- this.tasks.clear();
2652
- for (let i = 0; i < snapshot.length; i++) {
2653
- snapshot[i]();
2654
- }
2665
+ const flushing = this.tasks;
2666
+ this.tasks = this.tasksSpare;
2667
+ this.flushing = flushing;
2668
+ for (const task of flushing) task();
2669
+ flushing.clear();
2670
+ this.tasksSpare = flushing;
2671
+ this.flushing = void 0;
2655
2672
  this.isFlushing = false;
2656
2673
  this.emitter.emit(0 /* AFTER_FLUSH */, void 0);
2657
2674
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oimdb/core",
3
- "version": "3.1.0",
3
+ "version": "3.3.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",