@abraca/dabra 2.0.4 → 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.4",
3
+ "version": "2.0.7",
4
4
  "description": "abracadabra provider",
5
5
  "keywords": [
6
6
  "abracadabra",
@@ -142,6 +142,16 @@ export class AbracadabraBaseProvider extends EventEmitter {
142
142
 
143
143
  isAuthenticated = false;
144
144
 
145
+ /**
146
+ * True once this provider has received at least one Authenticated frame
147
+ * on the current socket lifetime. Used by `permissionDeniedHandler` to
148
+ * tell apart "the entry doc is wrong / unreachable" (first frame is a
149
+ * denial → config error, give up) from "a mid-session write was denied"
150
+ * (server keeps the read subscription alive → don't kill the socket).
151
+ * Reset on close.
152
+ */
153
+ private _hasEverAuthenticated = false;
154
+
145
155
  /** Current WebSocket connection status. */
146
156
  get connectionStatus(): WebSocketStatus {
147
157
  return this.configuration.websocketProvider.status;
@@ -504,6 +514,10 @@ export class AbracadabraBaseProvider extends EventEmitter {
504
514
  onClose() {
505
515
  this.isAuthenticated = false;
506
516
  this.synced = false;
517
+ // Reset per-socket-lifetime auth flag so the next connection's
518
+ // permissionDeniedHandler can correctly identify a fresh-connection
519
+ // denial as a config error.
520
+ this._hasEverAuthenticated = false;
507
521
 
508
522
  // update awareness (all users except local left)
509
523
  if (this.awareness) {
@@ -616,15 +630,26 @@ export class AbracadabraBaseProvider extends EventEmitter {
616
630
  // closes after every server-side rejection, AbracadabraWS schedules a
617
631
  // retry, the new connection sends the same Subscribe → server denies
618
632
  // again, ad infinitum (CPU/log/network spam, page looks "broken" to
619
- // the user). The dominant cases:
633
+ // the user). The dominant cases here:
620
634
  // * "document not found" — entry-doc-id pinned to something that
621
635
  // doesn't exist on this server
622
- // * "permission denied" — operator never granted the user access
623
- // Both are config issues; retrying won't fix them. We only stop the
624
- // socket when this provider OWNS it (`manageSocket`); shared sockets
625
- // (e.g. multiplexed child providers) leave the parent's lifecycle
626
- // alone and just give up on this doc.
627
- if (this.manageSocket) {
636
+ // * initial "permission denied" — operator never granted access
637
+ // Both are config issues; retrying won't fix them.
638
+ //
639
+ // BUT once we've successfully authenticated at least once on this
640
+ // socket, a permission_denied frame means a *mid-session* operation
641
+ // was rejected (e.g. a write attempt on a doc the user only has read
642
+ // on, or a Forbidden surfaced from defense-in-depth). The server
643
+ // keeps the read subscription alive in that case (see ws.rs handling
644
+ // of Error::Forbidden), so killing the socket would be a regression:
645
+ // it tears down every other doc's sync just because one write was
646
+ // denied. Skip the disconnect here and let the affected operation
647
+ // surface its own error.
648
+ //
649
+ // We only stop the socket when this provider OWNS it (`manageSocket`);
650
+ // shared sockets (e.g. multiplexed child providers) leave the parent's
651
+ // lifecycle alone and just give up on this doc.
652
+ if (this.manageSocket && !this._hasEverAuthenticated) {
628
653
  try {
629
654
  this.configuration.websocketProvider.disconnect();
630
655
  }
@@ -634,6 +659,7 @@ export class AbracadabraBaseProvider extends EventEmitter {
634
659
 
635
660
  authenticatedHandler(scope: string) {
636
661
  this.isAuthenticated = true;
662
+ this._hasEverAuthenticated = true;
637
663
  this.authorizedScope = scope as AuthorizedScope;
638
664
 
639
665
  this.emit("authenticated", { scope });
@@ -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);