@abraca/dabra 2.0.5 → 2.0.7

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abraca/dabra",
3
- "version": "2.0.5",
3
+ "version": "2.0.7",
4
4
  "description": "abracadabra provider",
5
5
  "keywords": [
6
6
  "abracadabra",
@@ -123,8 +123,29 @@ export class AbracadabraProvider extends AbracadabraBaseProvider {
123
123
  private childAccessTimes = new Map<string, number>();
124
124
  /** Pinned children that must not be evicted (e.g. actively viewed docs) */
125
125
  private pinnedChildren = new Set<string>();
126
- /** Default cap on simultaneously cached child providers; configurable per-instance via `maxChildren`. */
127
- private static readonly DEFAULT_MAX_CHILDREN = 20;
126
+ /**
127
+ * Children explicitly marked as transient (e.g. by a background indexer
128
+ * that scans the whole tree). They:
129
+ * - never count toward the LRU `maxChildren` budget
130
+ * - never cause eviction of OTHER children when they're added
131
+ * - are eligible for LRU eviction themselves only after they get
132
+ * unmarked or explicitly unloaded
133
+ *
134
+ * The motivating case: `useSearchIndex` / `useFileIndex` walk every doc
135
+ * in the tree calling `loadChild`. With a default cap of 20 and a tree
136
+ * of 76+ subdocs, the LRU silently evicted whichever subdoc the UI was
137
+ * actually showing — a CLOSE storm and dead awareness for the user.
138
+ * Transient loads sidestep that pool entirely.
139
+ */
140
+ private transientChildren = new Set<string>();
141
+ /**
142
+ * Default cap on simultaneously cached child providers (excluding
143
+ * transient ones — see `transientChildren`). Configurable per-instance
144
+ * via `maxChildren`. Bumped from 20 → 64 because real apps routinely
145
+ * have trees with dozens of subdocs being touched and 20 caused silent
146
+ * subdoc eviction (with awareness loss) on perfectly normal load.
147
+ */
148
+ private static readonly DEFAULT_MAX_CHILDREN = 64;
128
149
  private readonly maxChildren: number;
129
150
 
130
151
  private abracadabraConfig: AbracadabraProviderConfiguration;
@@ -473,10 +494,22 @@ export class AbracadabraProvider extends AbracadabraBaseProvider {
473
494
 
474
495
  /**
475
496
  * Create (or return cached) a child AbracadabraProvider for a given
476
- * child document id. Each child opens its own WebSocket connection because
477
- * the server is document-scoped (one WebSocket ↔ one document).
497
+ * child document id. Each child shares the parent's WebSocket via
498
+ * multiplexing.
499
+ *
500
+ * `evictable` (default `true`) controls whether the child enters the
501
+ * LRU pool. Pass `evictable: false` for transient loads — typically
502
+ * batch indexers (search, file extraction) that touch every doc in
503
+ * the tree and would otherwise blow out the cache, silently evicting
504
+ * subdocs the UI is actively using. Transient children are excluded
505
+ * from the LRU budget AND don't trigger eviction of other children.
506
+ * Callers MUST pair `loadChild(id, { evictable: false })` with an
507
+ * explicit `unloadChild(id)` once they're done.
478
508
  */
479
- loadChild(childId: string): Promise<AbracadabraProvider> {
509
+ loadChild(
510
+ childId: string,
511
+ options: { evictable?: boolean } = {},
512
+ ): Promise<AbracadabraProvider> {
480
513
  if (!isValidDocId(childId)) {
481
514
  return Promise.reject(
482
515
  new Error(
@@ -486,8 +519,15 @@ export class AbracadabraProvider extends AbracadabraBaseProvider {
486
519
  );
487
520
  }
488
521
 
522
+ const evictable = options.evictable !== false;
523
+
489
524
  if (this.childProviders.has(childId)) {
490
525
  this.childAccessTimes.set(childId, Date.now());
526
+ // A second load call promotes a child *out* of transient mode —
527
+ // once a UI surface wants it, it's no longer "just for indexing"
528
+ // and should be governed by the normal LRU budget. We never go
529
+ // the other way (UI-loaded → transient) implicitly.
530
+ if (evictable) this.transientChildren.delete(childId);
491
531
  return Promise.resolve(this.childProviders.get(childId)!);
492
532
  }
493
533
 
@@ -497,13 +537,16 @@ export class AbracadabraProvider extends AbracadabraBaseProvider {
497
537
  return this.pendingLoads.get(childId)!;
498
538
  }
499
539
 
500
- const load = this._doLoadChild(childId);
540
+ const load = this._doLoadChild(childId, evictable);
501
541
  this.pendingLoads.set(childId, load);
502
542
  load.finally(() => this.pendingLoads.delete(childId));
503
543
  return load;
504
544
  }
505
545
 
506
- private async _doLoadChild(childId: string): Promise<AbracadabraProvider> {
546
+ private async _doLoadChild(
547
+ childId: string,
548
+ evictable: boolean,
549
+ ): Promise<AbracadabraProvider> {
507
550
  const childDoc = new Y.Doc({ guid: childId });
508
551
 
509
552
  // Fire-and-forget: tell the server this child belongs to the parent.
@@ -535,9 +578,13 @@ export class AbracadabraProvider extends AbracadabraBaseProvider {
535
578
 
536
579
  this.childProviders.set(childId, childProvider);
537
580
  this.childAccessTimes.set(childId, Date.now());
581
+ if (!evictable) this.transientChildren.add(childId);
538
582
 
539
- // Evict least-recently-used children if over capacity
540
- this.evictLRU();
583
+ // Evict least-recently-used children if over capacity. A transient
584
+ // load doesn't count toward the budget AND shouldn't kick others
585
+ // out — it's an indexer scanning the tree, not the UI subscribing
586
+ // to a new doc.
587
+ if (evictable) this.evictLRU();
541
588
 
542
589
  this.emit("subdocLoaded", { childId, provider: childProvider });
543
590
 
@@ -551,6 +598,7 @@ export class AbracadabraProvider extends AbracadabraBaseProvider {
551
598
  this.childProviders.delete(childId);
552
599
  this.childAccessTimes.delete(childId);
553
600
  this.pinnedChildren.delete(childId);
601
+ this.transientChildren.delete(childId);
554
602
  }
555
603
  }
556
604
 
@@ -572,20 +620,31 @@ export class AbracadabraProvider extends AbracadabraBaseProvider {
572
620
  }
573
621
 
574
622
  /**
575
- * Evict least-recently-used unpinned child providers until the cache is
576
- * at or below MAX_CHILDREN.
623
+ * Evict least-recently-used unpinned, non-transient child providers
624
+ * until the cache is at or below MAX_CHILDREN. Transient children
625
+ * (loaded with `{ evictable: false }`) are excluded from both the
626
+ * budget *and* the eviction list — they're invisible to the LRU and
627
+ * only go away when their loader explicitly calls `unloadChild`.
577
628
  */
578
629
  private evictLRU() {
579
- if (this.childProviders.size <= this.maxChildren) return;
630
+ // Count only non-transient children toward the budget. A search
631
+ // indexer that pinned 100 transient children must NOT cause the
632
+ // LRU to evict the 1 doc the user's editor is actively viewing.
633
+ let nonTransientCount = 0;
634
+ for (const id of this.childProviders.keys()) {
635
+ if (!this.transientChildren.has(id)) nonTransientCount++;
636
+ }
637
+ if (nonTransientCount <= this.maxChildren) return;
580
638
 
581
639
  const evictable: Array<{ id: string; accessTime: number }> = [];
582
640
  for (const [id] of this.childProviders) {
583
641
  if (this.pinnedChildren.has(id)) continue;
642
+ if (this.transientChildren.has(id)) continue;
584
643
  evictable.push({ id, accessTime: this.childAccessTimes.get(id) ?? 0 });
585
644
  }
586
645
  evictable.sort((a, b) => a.accessTime - b.accessTime);
587
646
 
588
- let toEvict = this.childProviders.size - this.maxChildren;
647
+ let toEvict = nonTransientCount - this.maxChildren;
589
648
  for (const entry of evictable) {
590
649
  if (toEvict <= 0) break;
591
650
  this.unloadChild(entry.id);