@abraca/dabra 2.0.5 → 2.0.8

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/dist/index.d.ts CHANGED
@@ -1089,7 +1089,28 @@ declare class AbracadabraProvider extends AbracadabraBaseProvider {
1089
1089
  private childAccessTimes;
1090
1090
  /** Pinned children that must not be evicted (e.g. actively viewed docs) */
1091
1091
  private pinnedChildren;
1092
- /** Default cap on simultaneously cached child providers; configurable per-instance via `maxChildren`. */
1092
+ /**
1093
+ * Children explicitly marked as transient (e.g. by a background indexer
1094
+ * that scans the whole tree). They:
1095
+ * - never count toward the LRU `maxChildren` budget
1096
+ * - never cause eviction of OTHER children when they're added
1097
+ * - are eligible for LRU eviction themselves only after they get
1098
+ * unmarked or explicitly unloaded
1099
+ *
1100
+ * The motivating case: `useSearchIndex` / `useFileIndex` walk every doc
1101
+ * in the tree calling `loadChild`. With a default cap of 20 and a tree
1102
+ * of 76+ subdocs, the LRU silently evicted whichever subdoc the UI was
1103
+ * actually showing — a CLOSE storm and dead awareness for the user.
1104
+ * Transient loads sidestep that pool entirely.
1105
+ */
1106
+ private transientChildren;
1107
+ /**
1108
+ * Default cap on simultaneously cached child providers (excluding
1109
+ * transient ones — see `transientChildren`). Configurable per-instance
1110
+ * via `maxChildren`. Bumped from 20 → 64 because real apps routinely
1111
+ * have trees with dozens of subdocs being touched and 20 caused silent
1112
+ * subdoc eviction (with awareness loss) on perfectly normal load.
1113
+ */
1093
1114
  private static readonly DEFAULT_MAX_CHILDREN;
1094
1115
  private readonly maxChildren;
1095
1116
  private abracadabraConfig;
@@ -1166,10 +1187,21 @@ declare class AbracadabraProvider extends AbracadabraBaseProvider {
1166
1187
  hasChild(childId: string): boolean;
1167
1188
  /**
1168
1189
  * Create (or return cached) a child AbracadabraProvider for a given
1169
- * child document id. Each child opens its own WebSocket connection because
1170
- * the server is document-scoped (one WebSocket ↔ one document).
1171
- */
1172
- loadChild(childId: string): Promise<AbracadabraProvider>;
1190
+ * child document id. Each child shares the parent's WebSocket via
1191
+ * multiplexing.
1192
+ *
1193
+ * `evictable` (default `true`) controls whether the child enters the
1194
+ * LRU pool. Pass `evictable: false` for transient loads — typically
1195
+ * batch indexers (search, file extraction) that touch every doc in
1196
+ * the tree and would otherwise blow out the cache, silently evicting
1197
+ * subdocs the UI is actively using. Transient children are excluded
1198
+ * from the LRU budget AND don't trigger eviction of other children.
1199
+ * Callers MUST pair `loadChild(id, { evictable: false })` with an
1200
+ * explicit `unloadChild(id)` once they're done.
1201
+ */
1202
+ loadChild(childId: string, options?: {
1203
+ evictable?: boolean;
1204
+ }): Promise<AbracadabraProvider>;
1173
1205
  private _doLoadChild;
1174
1206
  unloadChild(childId: string): void;
1175
1207
  /**
@@ -1182,8 +1214,11 @@ declare class AbracadabraProvider extends AbracadabraBaseProvider {
1182
1214
  */
1183
1215
  unpinChild(childId: string): void;
1184
1216
  /**
1185
- * Evict least-recently-used unpinned child providers until the cache is
1186
- * at or below MAX_CHILDREN.
1217
+ * Evict least-recently-used unpinned, non-transient child providers
1218
+ * until the cache is at or below MAX_CHILDREN. Transient children
1219
+ * (loaded with `{ evictable: false }`) are excluded from both the
1220
+ * budget *and* the eviction list — they're invisible to the LRU and
1221
+ * only go away when their loader explicitly calls `unloadChild`.
1187
1222
  */
1188
1223
  private evictLRU;
1189
1224
  /** Return all currently-loaded child providers. */
@@ -2280,6 +2315,15 @@ declare class AbracadabraBaseProvider extends EventEmitter {
2280
2315
  isSynced: boolean;
2281
2316
  unsyncedChanges: number;
2282
2317
  isAuthenticated: boolean;
2318
+ /**
2319
+ * True once this provider has received at least one Authenticated frame
2320
+ * on the current socket lifetime. Used by `permissionDeniedHandler` to
2321
+ * tell apart "the entry doc is wrong / unreachable" (first frame is a
2322
+ * denial → config error, give up) from "a mid-session write was denied"
2323
+ * (server keeps the read subscription alive → don't kill the socket).
2324
+ * Reset on close.
2325
+ */
2326
+ private _hasEverAuthenticated;
2283
2327
  /** Current WebSocket connection status. */
2284
2328
  get connectionStatus(): WebSocketStatus;
2285
2329
  authorizedScope: AuthorizedScope | undefined;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abraca/dabra",
3
- "version": "2.0.5",
3
+ "version": "2.0.8",
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,32 @@ export class AbracadabraProvider extends AbracadabraBaseProvider {
486
519
  );
487
520
  }
488
521
 
522
+ // Self-load: caller asked for the doc this provider already owns.
523
+ // Return `this` directly. Without this guard we'd build a sibling
524
+ // AbracadabraProvider with the same `name` and call attach() on the
525
+ // shared AbracadabraWS. The wsp's providerMap is keyed by name, so
526
+ // the sibling silently REPLACES the root in the routing table — its
527
+ // outgoing messages still reach the wire (send doesn't read the
528
+ // map), but inbound frames addressed to that doc go to the sibling.
529
+ // When the borrow ends and the sibling detaches, providerMap is
530
+ // wiped of that name and the root is left orphaned: the UI still
531
+ // reports `connected` + `synced`, but no awareness/sync messages
532
+ // are received. Returning `this` is intentionally not added to
533
+ // `childProviders` so LRU eviction never destroys the root, and
534
+ // `unloadChild(self)` becomes a safe no-op (see below).
535
+ if (childId === this.configuration.name) {
536
+ return Promise.resolve(this);
537
+ }
538
+
539
+ const evictable = options.evictable !== false;
540
+
489
541
  if (this.childProviders.has(childId)) {
490
542
  this.childAccessTimes.set(childId, Date.now());
543
+ // A second load call promotes a child *out* of transient mode —
544
+ // once a UI surface wants it, it's no longer "just for indexing"
545
+ // and should be governed by the normal LRU budget. We never go
546
+ // the other way (UI-loaded → transient) implicitly.
547
+ if (evictable) this.transientChildren.delete(childId);
491
548
  return Promise.resolve(this.childProviders.get(childId)!);
492
549
  }
493
550
 
@@ -497,13 +554,16 @@ export class AbracadabraProvider extends AbracadabraBaseProvider {
497
554
  return this.pendingLoads.get(childId)!;
498
555
  }
499
556
 
500
- const load = this._doLoadChild(childId);
557
+ const load = this._doLoadChild(childId, evictable);
501
558
  this.pendingLoads.set(childId, load);
502
559
  load.finally(() => this.pendingLoads.delete(childId));
503
560
  return load;
504
561
  }
505
562
 
506
- private async _doLoadChild(childId: string): Promise<AbracadabraProvider> {
563
+ private async _doLoadChild(
564
+ childId: string,
565
+ evictable: boolean,
566
+ ): Promise<AbracadabraProvider> {
507
567
  const childDoc = new Y.Doc({ guid: childId });
508
568
 
509
569
  // Fire-and-forget: tell the server this child belongs to the parent.
@@ -535,9 +595,13 @@ export class AbracadabraProvider extends AbracadabraBaseProvider {
535
595
 
536
596
  this.childProviders.set(childId, childProvider);
537
597
  this.childAccessTimes.set(childId, Date.now());
598
+ if (!evictable) this.transientChildren.add(childId);
538
599
 
539
- // Evict least-recently-used children if over capacity
540
- this.evictLRU();
600
+ // Evict least-recently-used children if over capacity. A transient
601
+ // load doesn't count toward the budget AND shouldn't kick others
602
+ // out — it's an indexer scanning the tree, not the UI subscribing
603
+ // to a new doc.
604
+ if (evictable) this.evictLRU();
541
605
 
542
606
  this.emit("subdocLoaded", { childId, provider: childProvider });
543
607
 
@@ -551,6 +615,7 @@ export class AbracadabraProvider extends AbracadabraBaseProvider {
551
615
  this.childProviders.delete(childId);
552
616
  this.childAccessTimes.delete(childId);
553
617
  this.pinnedChildren.delete(childId);
618
+ this.transientChildren.delete(childId);
554
619
  }
555
620
  }
556
621
 
@@ -572,20 +637,31 @@ export class AbracadabraProvider extends AbracadabraBaseProvider {
572
637
  }
573
638
 
574
639
  /**
575
- * Evict least-recently-used unpinned child providers until the cache is
576
- * at or below MAX_CHILDREN.
640
+ * Evict least-recently-used unpinned, non-transient child providers
641
+ * until the cache is at or below MAX_CHILDREN. Transient children
642
+ * (loaded with `{ evictable: false }`) are excluded from both the
643
+ * budget *and* the eviction list — they're invisible to the LRU and
644
+ * only go away when their loader explicitly calls `unloadChild`.
577
645
  */
578
646
  private evictLRU() {
579
- if (this.childProviders.size <= this.maxChildren) return;
647
+ // Count only non-transient children toward the budget. A search
648
+ // indexer that pinned 100 transient children must NOT cause the
649
+ // LRU to evict the 1 doc the user's editor is actively viewing.
650
+ let nonTransientCount = 0;
651
+ for (const id of this.childProviders.keys()) {
652
+ if (!this.transientChildren.has(id)) nonTransientCount++;
653
+ }
654
+ if (nonTransientCount <= this.maxChildren) return;
580
655
 
581
656
  const evictable: Array<{ id: string; accessTime: number }> = [];
582
657
  for (const [id] of this.childProviders) {
583
658
  if (this.pinnedChildren.has(id)) continue;
659
+ if (this.transientChildren.has(id)) continue;
584
660
  evictable.push({ id, accessTime: this.childAccessTimes.get(id) ?? 0 });
585
661
  }
586
662
  evictable.sort((a, b) => a.accessTime - b.accessTime);
587
663
 
588
- let toEvict = this.childProviders.size - this.maxChildren;
664
+ let toEvict = nonTransientCount - this.maxChildren;
589
665
  for (const entry of evictable) {
590
666
  if (toEvict <= 0) break;
591
667
  this.unloadChild(entry.id);