@milaboratories/pl-tree 1.14.4 → 1.15.1

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/src/state.ts CHANGED
@@ -513,6 +513,53 @@ export class PlTreeState {
513
513
  const incrementRefs: SignedResourceId[] = [];
514
514
  const decrementRefs: SignedResourceId[] = [];
515
515
 
516
+ // `resourcesAdded` is notified once, after the loop: markChanged detaches every watcher on
517
+ // its first call and no watcher can attach in the middle of this synchronous loop, so a
518
+ // single notification covers every resource added here. It also builds the marker string
519
+ // just once, which is worth doing because resourceIdToString on a signed id parses a
520
+ // BigInt and hex-decodes a Buffer.
521
+ let addedCount = 0;
522
+ let firstAdded: SignedResourceId | undefined;
523
+
524
+ // Diagnostic context for unexpectedTransitionError, refreshed once per resource. It lives
525
+ // out here so the loop body allocates neither a closure nor a snapshot per resource on the
526
+ // path where nothing goes wrong. Only the mutable half of BasicResourceData needs
527
+ // capturing: id, kind, type and data are readonly on PlTreeResource, so they are read back
528
+ // from the resource when the message is built.
529
+ let errRd: ExtendedResourceData;
530
+ let errRes: PlTreeResource | undefined;
531
+ let errOriginalResourceId: OptionalSignedResourceId = NullSignedResourceId;
532
+ let errError: OptionalSignedResourceId = NullSignedResourceId;
533
+ let errInputsLocked = false;
534
+ let errOutputsLocked = false;
535
+ let errResourceReady = false;
536
+ let errFinal = false;
537
+ const unexpectedTransitionError = (reason: string): never => {
538
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
539
+ const { fields, ...rdWithoutFields } = errRd;
540
+ const statBeforeMutation: BasicResourceData | undefined =
541
+ errRes === undefined
542
+ ? undefined
543
+ : {
544
+ id: errRes.id,
545
+ kind: errRes.kind,
546
+ type: errRes.type,
547
+ data: errRes.data,
548
+ resourceReady: errResourceReady,
549
+ inputsLocked: errInputsLocked,
550
+ outputsLocked: errOutputsLocked,
551
+ error: errError,
552
+ originalResourceId: errOriginalResourceId,
553
+ final: errFinal,
554
+ };
555
+ this.invalidateTree();
556
+ throw new TreeStateUpdateError(
557
+ `Unexpected resource state transition (${reason}): ${stringifyWithResourceId(
558
+ rdWithoutFields,
559
+ )} -> ${stringifyWithResourceId(statBeforeMutation)}`,
560
+ );
561
+ };
562
+
516
563
  // patching / creating resources
517
564
  for (const rd of resourceData) {
518
565
  let resource = this.resources.get(rd.id);
@@ -522,20 +569,17 @@ export class PlTreeState {
522
569
  // they never change; this flag isolates value/flag-only changes from real metadata churn.
523
570
  let metadataChanged = false;
524
571
 
525
- const statBeforeMutation = resource?.basicState;
526
- const unexpectedTransitionError = (reason: string): never => {
527
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
528
- const { fields, ...rdWithoutFields } = rd;
529
- this.invalidateTree();
530
- throw new TreeStateUpdateError(
531
- `Unexpected resource state transition (${reason}): ${stringifyWithResourceId(
532
- rdWithoutFields,
533
- )} -> ${stringifyWithResourceId(statBeforeMutation)}`,
534
- );
535
- };
572
+ errRd = rd;
573
+ errRes = resource;
536
574
 
537
575
  if (resource !== undefined) {
538
576
  // updating existing resource
577
+ errOriginalResourceId = resource.originalResourceId;
578
+ errError = resource.error;
579
+ errInputsLocked = resource.inputsLocked;
580
+ errOutputsLocked = resource.outputsLocked;
581
+ errResourceReady = resource.resourceReady;
582
+ errFinal = resource.finalFlag;
539
583
 
540
584
  if (resource.finalState)
541
585
  unexpectedTransitionError("resource state can\t be updated after it is marked as final");
@@ -569,9 +613,27 @@ export class PlTreeState {
569
613
  if (stat) stat.errorsAttached++;
570
614
  }
571
615
 
572
- // updating fields
616
+ // updating fields.
617
+ //
618
+ // Fields normally arrive in the order they are already stored, so the stored ones are
619
+ // walked in lockstep with the incoming ones and matched by direct name comparison
620
+ // rather than through a fieldsMap lookup: every field name in a poll response is
621
+ // freshly decoded from protobuf, so V8 has no cached hash for any of them.
622
+ //
623
+ // Any divergence (reordering, an insertion, a removal) abandons the walk, and the rest
624
+ // of the resource resolves through fieldsMap.get, which is always correct. The walk is
625
+ // abandoned before any fieldsMap.set, so it never observes an insertion made through
626
+ // the live iterator.
627
+ let walk: MapIterator<PlTreeField> | undefined =
628
+ rd.fields.length > 0 ? resource.fieldsMap.values() : undefined;
573
629
  for (const fd of rd.fields) {
574
- let field = resource.fieldsMap.get(fd.name);
630
+ let field: PlTreeField | undefined;
631
+ if (walk !== undefined) {
632
+ const next = walk.next();
633
+ if (next.done === true || next.value.name !== fd.name) walk = undefined;
634
+ else field = next.value;
635
+ }
636
+ if (field === undefined) field = resource.fieldsMap.get(fd.name);
575
637
 
576
638
  if (!field) {
577
639
  // new field
@@ -755,11 +817,23 @@ export class PlTreeState {
755
817
  if (stat) stat.readyFlips++;
756
818
  }
757
819
 
758
- // syncing kv
820
+ // syncing kv. Same lockstep walk as the fields above, for the same reason: kv keys
821
+ // arrive in a stable order and are freshly decoded strings. The walk is only set up
822
+ // when there is something to compare against, so a resource with no kv allocates no
823
+ // iterator.
824
+ let kvWalk: MapIterator<[string, Uint8Array]> | undefined =
825
+ rd.kv.length > 0 ? resource.kv.entries() : undefined;
759
826
  for (const kv of rd.kv) {
760
- const current = resource.kv.get(kv.key);
827
+ let current: Uint8Array | undefined;
828
+ if (kvWalk !== undefined) {
829
+ const next = kvWalk.next();
830
+ if (next.done === true || next.value[0] !== kv.key) kvWalk = undefined;
831
+ else current = next.value[1];
832
+ }
833
+ if (current === undefined) current = resource.kv.get(kv.key);
761
834
  if (current === undefined) {
762
835
  resource.kv.set(kv.key, kv.value);
836
+ kvWalk = undefined;
763
837
  notEmpty(resource.kvChangedPerKey).markChanged(
764
838
  kv.key,
765
839
  `kv added for ${resourceIdToString(resource.id)}: ${kv.key}`,
@@ -832,7 +906,8 @@ export class PlTreeState {
832
906
 
833
907
  // adding the resource to the heap
834
908
  this.resources.set(resource.id, resource);
835
- this.resourcesAdded.markChanged(`new resource ${resourceIdToString(resource.id)} added`);
909
+ if (addedCount === 0) firstAdded = resource.id;
910
+ addedCount++;
836
911
  }
837
912
 
838
913
  if (stat) {
@@ -849,6 +924,13 @@ export class PlTreeState {
849
924
  }
850
925
  }
851
926
 
927
+ if (firstAdded !== undefined)
928
+ this.resourcesAdded.markChanged(
929
+ addedCount === 1
930
+ ? `new resource ${resourceIdToString(firstAdded)} added`
931
+ : `${addedCount} new resources added, first ${resourceIdToString(firstAdded)}`,
932
+ );
933
+
852
934
  // applying refCount increments
853
935
  for (const rid of incrementRefs) {
854
936
  const res = this.resources.get(rid);
package/src/sync.test.ts CHANGED
@@ -6,7 +6,12 @@ import {
6
6
  TestHelpers,
7
7
  } from "@milaboratories/pl-client";
8
8
  import { PlTreeState } from "./state";
9
- import { constructTreeLoadingRequest, initialTreeLoadingStat, loadTreeState } from "./sync";
9
+ import {
10
+ constructTreeLoadingRequest,
11
+ initialTreeLoadingStat,
12
+ loadTreeState,
13
+ resolveTreeLoadingAlgorithm,
14
+ } from "./sync";
10
15
  import type { TraversalMode } from "./sync";
11
16
  import { Computable } from "@milaboratories/computable";
12
17
  import { TestStructuralResourceType1 } from "./test_utils";
@@ -476,6 +481,37 @@ test("stop-marker-unknown-triggers-followup: unknown marker fetched in follow-up
476
481
  expect(callCount).toBe(2);
477
482
  });
478
483
 
484
+ test("stop-marker-followup-large: a very large follow-up round does not overflow the stack", async () => {
485
+ // Regression: result.push(...followUpResult) threw RangeError once the follow-up round
486
+ // exceeded the argument limit (~200k elements on Node 26).
487
+ const followUpSize = 300_000;
488
+ let callCount = 0;
489
+ const tx = {
490
+ resourceTree: () => {
491
+ callCount++;
492
+ if (callCount === 1) {
493
+ return (async function* () {
494
+ yield makeStopMarker("NG:0x1");
495
+ })();
496
+ } else {
497
+ return (async function* () {
498
+ for (let i = 0; i < followUpSize; i++) yield makeFullResource(`NG:0x${i.toString(16)}`);
499
+ })();
500
+ }
501
+ },
502
+ } as unknown as Parameters<typeof loadTreeState>[0];
503
+
504
+ const request = {
505
+ seedResources: ["NG:0x99"],
506
+ finalResources: new Set<string>(),
507
+ } as unknown as Parameters<typeof loadTreeState>[1];
508
+
509
+ const result = await loadTreeState(tx, request, undefined, stopMarkerCaps);
510
+
511
+ expect(result).toHaveLength(followUpSize);
512
+ expect(callCount).toBe(2);
513
+ });
514
+
479
515
  test("legacy-backend-compat: no stop markers in stream → no follow-up call", async () => {
480
516
  // Old backends emit only full-resource frames; pendingFollowUp stays empty
481
517
  // and the follow-up loop body never executes.
@@ -541,3 +577,71 @@ test("orphan-invariant-preserved: error referent streamed alongside stop marker"
541
577
  expect(ids).toEqual(["NG:0x1", "NG:0xE"]);
542
578
  // No throw means the invariant held throughout loadTreeState
543
579
  });
580
+
581
+ //
582
+ // Algorithm selection. These pin the contract that a capable backend is polled with a token
583
+ // by default, and that an unavailable preference degrades rather than throwing.
584
+ //
585
+
586
+ test("auto prefers delta, then streaming, then BFS", () => {
587
+ expect(resolveTreeLoadingAlgorithm("auto", ["treeChangedSince:v1", "treeFilter:v2"])).toBe(
588
+ "backend-delta",
589
+ );
590
+ // Delta needs no treeFilter:v2 of its own.
591
+ expect(resolveTreeLoadingAlgorithm("auto", ["treeChangedSince:v1"])).toBe("backend-delta");
592
+ expect(resolveTreeLoadingAlgorithm("auto", ["treeFilter:v2"])).toBe("backend-streaming");
593
+ expect(resolveTreeLoadingAlgorithm("auto", [])).toBe("client-bfs");
594
+ });
595
+
596
+ test("an explicit mode is honoured over what auto would pick", () => {
597
+ const capable = ["treeChangedSince:v1", "treeFilter:v2"];
598
+ expect(resolveTreeLoadingAlgorithm("client-bfs", capable)).toBe("client-bfs");
599
+ expect(resolveTreeLoadingAlgorithm("backend-streaming", capable)).toBe("backend-streaming");
600
+ expect(resolveTreeLoadingAlgorithm("backend-delta", capable)).toBe("backend-delta");
601
+ });
602
+
603
+ test("backend-delta degrades to the best available path, with a warning, never a throw", () => {
604
+ const warnings: string[] = [];
605
+ const logger = { warn: (m: string) => warnings.push(m) };
606
+
607
+ expect(resolveTreeLoadingAlgorithm("backend-delta", ["treeFilter:v2"], logger)).toBe(
608
+ "backend-streaming",
609
+ );
610
+ expect(resolveTreeLoadingAlgorithm("backend-delta", [], logger)).toBe("client-bfs");
611
+
612
+ expect(warnings).toHaveLength(2);
613
+ for (const w of warnings) expect(w).toContain("treeChangedSince:v1");
614
+ });
615
+
616
+ test("loadTreeState routes into the delta path and passes the token through", async () => {
617
+ const received: { seeds?: string[]; token?: Uint8Array; stopRules?: unknown } = {};
618
+ const tx = {
619
+ resourceTree: (seeds: string[], opts: Record<string, unknown>) => {
620
+ received.seeds = seeds;
621
+ received.token = opts.changedSinceToken as Uint8Array;
622
+ received.stopRules = opts.traverseStopRules;
623
+ return (async function* () {})();
624
+ },
625
+ } as unknown as Parameters<typeof loadTreeState>[0];
626
+
627
+ const request = {
628
+ seedResources: ["NG:0x1"],
629
+ finalResources: new Set<string>(),
630
+ roots: ["NG:0x1"],
631
+ knownResources: new Set<string>(["NG:0x1"]),
632
+ changedSinceToken: new Uint8Array([9]),
633
+ } as unknown as Parameters<typeof loadTreeState>[1];
634
+
635
+ const stat = initialTreeLoadingStat();
636
+ // Via the mode, not by calling loadDeltaTreeState directly: this is the only test that
637
+ // proves the dispatch in loadTreeState reaches delta at all.
638
+ await loadTreeState(tx, request, stat, ["treeChangedSince:v1"], "auto", { warn: () => {} });
639
+
640
+ expect(received.seeds).toEqual(["NG:0x1"]);
641
+ expect(received.token).toEqual(new Uint8Array([9]));
642
+ expect(received.stopRules).toBeUndefined();
643
+ // True for delta as well as streaming: it marks a backend path, which is what state.ts
644
+ // reads to decide the BFS-only wasted-fetch attribution.
645
+ expect(stat.usedStreaming).toBe(true);
646
+ expect(stat.deltaSeedsSent).toBe(1);
647
+ });
package/src/sync.ts CHANGED
@@ -10,6 +10,7 @@ import Denque from "denque";
10
10
  import { hasCapability, isNullSignedResourceId } from "@milaboratories/pl-client";
11
11
  import type { ExtendedResourceData, PlTreeState, ResourceUpdateStat } from "./state";
12
12
  import { ConcurrencyLimitingExecutor, msToHumanReadable } from "@milaboratories/ts-helpers";
13
+ import { loadDeltaTreeState } from "./delta_sync";
13
14
 
14
15
  /** Applied to list of fields in resource data. */
15
16
  export type PruningFunction = (resource: ExtendedResourceData) => FieldData[];
@@ -31,24 +32,81 @@ export interface TreeLoadingRequest {
31
32
  /** ResourceTree field filter passed to the backend when supported. */
32
33
  readonly fieldFilter?: Filter;
33
34
 
34
- /** ResourceTree traversal stop rules passed to the backend when supported. */
35
+ /** ResourceTree traversal stop rules passed to the backend when supported.
36
+ * @deprecated prune with {@link changedSinceToken} instead; ignored by the backend under one. */
35
37
  readonly traverseStopRules?: Filter;
38
+
39
+ /** The tree's roots. Delta seeds at these only when the non-final frontier is empty;
40
+ * resourceTree requires at least one seed. */
41
+ readonly roots: readonly SignedResourceId[];
42
+
43
+ /** Every id the mirror currently holds, final or not. Delta uses it to tell a reference it
44
+ * must resolve from one already satisfied locally. */
45
+ readonly knownResources: ReadonlySet<SignedResourceId>;
46
+
47
+ /** Change token from the transaction this request will run in, for a delta walk. Absent
48
+ * means "send the full tree", which is also what a token the server cannot use gets. */
49
+ readonly changedSinceToken?: Uint8Array;
36
50
  }
37
51
 
38
52
  /** Controls which tree-loading path is used.
39
- * - `"auto"` (default): use backend streaming when the backend advertises `treeFilter:v2`,
40
- * fall back to client-side BFS otherwise.
53
+ * - `"auto"` (default): use delta polling when the backend advertises `treeChangedSince:v1`,
54
+ * else backend streaming when it advertises `treeFilter:v2`, else client-side BFS.
41
55
  * - `"client-bfs"`: always use client-side BFS, even on capable backends.
42
56
  * - `"backend-streaming"`: always prefer backend streaming; if the capability is absent,
43
57
  * logs a warning and falls back to BFS (never throws).
58
+ * - `"backend-delta"`: always prefer delta polling, which hands the backend the transaction's
59
+ * change token and takes only what changed since it; if `treeChangedSince:v1` is absent,
60
+ * logs a warning and falls back to the best available path (never throws).
44
61
  */
45
- export type TraversalMode = "auto" | "client-bfs" | "backend-streaming";
62
+ export type TraversalMode = "auto" | "client-bfs" | "backend-streaming" | "backend-delta";
63
+
64
+ /** A concrete loading algorithm: a {@link TraversalMode} with `"auto"` and any unsupported
65
+ * preference already resolved against the server's capabilities. */
66
+ export type TreeLoadingAlgorithmName = "client-bfs" | "backend-streaming" | "backend-delta";
67
+
68
+ /** Resolves a traversal mode into the algorithm a tree will run. A tree calls this once, when
69
+ * it is made, and keeps the answer for its whole life, so the choice (and the fallback warning
70
+ * for a preference the backend cannot serve) happens once rather than on every poll. */
71
+ export function resolveTreeLoadingAlgorithm(
72
+ mode: TraversalMode,
73
+ capabilities: readonly string[] = [],
74
+ logger?: { warn: (msg: string) => void },
75
+ ): TreeLoadingAlgorithmName {
76
+ const streaming = supportsResourceTreeTraversal(capabilities);
77
+ const delta = supportsTreeDelta(capabilities);
78
+ switch (mode) {
79
+ case "client-bfs":
80
+ return "client-bfs";
81
+ case "backend-delta":
82
+ if (delta) return "backend-delta";
83
+ (logger ?? console).warn(
84
+ "traversalMode=backend-delta but backend lacks treeChangedSince:v1 capability; falling back to " +
85
+ (streaming ? "backend-streaming" : "client-bfs"),
86
+ );
87
+ return streaming ? "backend-streaming" : "client-bfs";
88
+ case "backend-streaming":
89
+ if (streaming) return "backend-streaming";
90
+ (logger ?? console).warn(
91
+ "traversalMode=backend-streaming but backend lacks treeFilter:v2 capability; falling back to BFS",
92
+ );
93
+ return "client-bfs";
94
+ case "auto":
95
+ // Delta first: its cost tracks what changed rather than what the tree holds. Streaming
96
+ // is the fallback for a backend that can shape a walk but not date one.
97
+ if (delta) return "backend-delta";
98
+ return streaming ? "backend-streaming" : "client-bfs";
99
+ }
100
+ }
46
101
 
47
102
  /** Given the current tree state, build the request object to pass to
48
103
  * {@link loadTreeState} to load updated state. */
49
104
  export function constructTreeLoadingRequest(
50
105
  tree: PlTreeState,
51
- options: Pick<TreeLoadingRequest, "pruningFunction" | "fieldFilter" | "traverseStopRules"> = {},
106
+ options: Pick<
107
+ TreeLoadingRequest,
108
+ "pruningFunction" | "fieldFilter" | "traverseStopRules" | "changedSinceToken"
109
+ > = {},
52
110
  ): TreeLoadingRequest {
53
111
  const seedResources: SignedResourceId[] = [];
54
112
  const finalResources = new Set<SignedResourceId>();
@@ -64,9 +122,12 @@ export function constructTreeLoadingRequest(
64
122
  return {
65
123
  seedResources,
66
124
  finalResources,
125
+ roots: [...tree.roots],
126
+ knownResources: materialized,
67
127
  pruningFunction: options.pruningFunction,
68
128
  fieldFilter: options.fieldFilter,
69
129
  traverseStopRules: options.traverseStopRules,
130
+ changedSinceToken: options.changedSinceToken,
70
131
  };
71
132
  }
72
133
 
@@ -85,9 +146,10 @@ export type TreeLoadingStat = ResourceUpdateStat & {
85
146
  stopMarkersSkipped: number;
86
147
  /** Number of follow-up resourceTree() calls issued to resolve unknown stop markers. */
87
148
  stopMarkerFollowUpRoundTrips: number;
88
- /** Streaming (resourceTree) path: resourceTree() streams consumed (1, or 2 with a follow-up). */
149
+ /** Backend paths: resourceTree() streams consumed. Streaming spends 1, or 2 with a
150
+ * follow-up; delta spends 1 plus one per resolution round. */
89
151
  streamRounds: number;
90
- /** Streaming path: resource frames received. */
152
+ /** Backend paths: resource frames received. */
91
153
  resourceFrames: number;
92
154
  /** Streaming path: stopMarker frames received (both skipped and follow-up). */
93
155
  stopMarkerFrames: number;
@@ -99,6 +161,16 @@ export type TreeLoadingStat = ResourceUpdateStat & {
99
161
  bfsResourcesRequested: number;
100
162
  /** BFS path: requested resources that no longer exist (undefined reply). */
101
163
  bfsResourcesNotFound: number;
164
+ /** Delta path: seed ids handed to the backend, summed over every round of the poll.
165
+ * This is what seeding the whole frontier costs on the uplink. */
166
+ deltaSeedsSent: number;
167
+ /** Delta path: extra rounds spent resolving references a delta body pointed at but the
168
+ * response did not carry. */
169
+ deltaResolutionRounds: number;
170
+ /** Delta path: polls that sent a token and got back a response the size of the whole
171
+ * mirror, which is what a refused token looks like from here - rejection is silent, so this
172
+ * is the only tell. Heuristic: a genuinely large change set trips it too. */
173
+ deltaSuspectedFullAnswers: number;
102
174
  };
103
175
 
104
176
  export function initialTreeLoadingStat(): TreeLoadingStat {
@@ -122,6 +194,9 @@ export function initialTreeLoadingStat(): TreeLoadingStat {
122
194
  traverseWasStoppedCount: 0,
123
195
  bfsResourcesRequested: 0,
124
196
  bfsResourcesNotFound: 0,
197
+ deltaSeedsSent: 0,
198
+ deltaResolutionRounds: 0,
199
+ deltaSuspectedFullAnswers: 0,
125
200
  resourcesNew: 0,
126
201
  resourcesChanged: 0,
127
202
  resourcesUnchanged: 0,
@@ -161,15 +236,20 @@ Unchanged bytes (wasted downlink): ${stat.bytesUnchanged}
161
236
  Changed with stable metadata: ${stat.metadataStableChanged}
162
237
  BFS fetches wasted on unchanged: ${stat.bfsRequestsWasted}
163
238
  Used streaming: ${stat.usedStreaming}
164
- [streaming] rounds: ${stat.streamRounds}, resource frames: ${stat.resourceFrames}, stop-marker frames: ${stat.stopMarkerFrames}, stop->follow-up: ${stat.stopMarkersFollowUp}, traverse-stopped: ${stat.traverseWasStoppedCount}
165
- [bfs] resources requested: ${stat.bfsResourcesRequested}, not found: ${stat.bfsResourcesNotFound}`;
239
+ [backend] rounds: ${stat.streamRounds}, resource frames: ${stat.resourceFrames}, stop-marker frames: ${stat.stopMarkerFrames}, stop->follow-up: ${stat.stopMarkersFollowUp}, traverse-stopped: ${stat.traverseWasStoppedCount}
240
+ [bfs] resources requested: ${stat.bfsResourcesRequested}, not found: ${stat.bfsResourcesNotFound}
241
+ [delta] seeds sent: ${stat.deltaSeedsSent}, resolution rounds: ${stat.deltaResolutionRounds}, suspected full answers: ${stat.deltaSuspectedFullAnswers}`;
166
242
  }
167
243
 
168
- function supportsResourceTreeTraversal(capabilities: readonly string[] = []): boolean {
244
+ export function supportsResourceTreeTraversal(capabilities: readonly string[] = []): boolean {
169
245
  return hasCapability(capabilities, "treeFilter:v2");
170
246
  }
171
247
 
172
- function collectStatsForResource(resource: ExtendedResourceData, stats?: TreeLoadingStat) {
248
+ function supportsTreeDelta(capabilities: readonly string[] = []): boolean {
249
+ return hasCapability(capabilities, "treeChangedSince:v1");
250
+ }
251
+
252
+ export function collectStatsForResource(resource: ExtendedResourceData, stats?: TreeLoadingStat) {
173
253
  if (!stats) return;
174
254
  stats.retrievedResources++;
175
255
  stats.retrievedFields += resource.fields.length;
@@ -366,7 +446,7 @@ async function loadTreeStateViaResourceTree(
366
446
  traverseStopRules,
367
447
  });
368
448
 
369
- const { result, followUpSeeds } = await processResourceTreeStream(
449
+ let { result, followUpSeeds } = await processResourceTreeStream(
370
450
  treeItems,
371
451
  finalResources,
372
452
  pruningFunction,
@@ -401,7 +481,8 @@ async function loadTreeStateViaResourceTree(
401
481
  pruningFunction,
402
482
  stats,
403
483
  );
404
- result.push(...followUpResult);
484
+ // spreading fails due to exceeding stack argument size
485
+ result = result.concat(followUpResult);
405
486
  if (stats) {
406
487
  logger?.info?.(
407
488
  `loadTreeStateViaResourceTree: follow-up request for ${roundSeeds.length} stop-marker seeds: ${JSON.stringify(roundSeeds)}`,
@@ -431,23 +512,23 @@ export async function loadTreeState(
431
512
  if (stats) stats.requests++;
432
513
 
433
514
  try {
434
- const wantsStreaming =
435
- mode === "backend-streaming" ||
436
- (mode === "auto" && supportsResourceTreeTraversal(capabilities));
437
-
438
- if (stats) stats.usedStreaming = wantsStreaming && supportsResourceTreeTraversal(capabilities);
439
-
440
- if (wantsStreaming && !supportsResourceTreeTraversal(capabilities)) {
441
- const msg =
442
- "traversalMode=backend-streaming but backend lacks treeFilter:v2 capability; falling back to BFS";
443
- if (logger) logger.warn(msg);
444
- else console.warn(msg);
445
- return await loadTreeStateViaBfs(tx, loadingRequest, stats);
515
+ // A tree passes its pinned algorithm here, which resolves to itself. The mode form is
516
+ // kept for callers that run a single load.
517
+ const algorithm = resolveTreeLoadingAlgorithm(mode, capabilities, logger);
518
+ // Both backend paths, not just streaming: state.ts reads this to attribute a BFS-only
519
+ // wasted-fetch counter, and leaving it false for delta reports phantom waste.
520
+ if (stats) stats.usedStreaming = algorithm !== "client-bfs";
521
+
522
+ switch (algorithm) {
523
+ case "backend-delta":
524
+ return await loadDeltaTreeState(tx, loadingRequest, stats, logger);
525
+ case "backend-streaming":
526
+ return await loadTreeStateViaResourceTree(tx, loadingRequest, stats, logger);
527
+ case "client-bfs":
528
+ return await loadTreeStateViaBfs(tx, loadingRequest, stats);
529
+ default:
530
+ throw new Error(`unknown tree loading algorithm: ${algorithm as string}`);
446
531
  }
447
-
448
- return wantsStreaming
449
- ? await loadTreeStateViaResourceTree(tx, loadingRequest, stats, logger)
450
- : await loadTreeStateViaBfs(tx, loadingRequest, stats);
451
532
  } finally {
452
533
  if (stats) stats.millisSpent += Date.now() - startTimestamp;
453
534
  }