@milaboratories/pl-tree 1.14.3 → 1.15.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.
@@ -0,0 +1,221 @@
1
+ import type { PlTransaction, SignedResourceId } from "@milaboratories/pl-client";
2
+ import { isNotNullSignedResourceId } from "@milaboratories/pl-client";
3
+ import type { ExtendedResourceData } from "./state";
4
+ import { TreeStateUpdateError } from "./state";
5
+ import type { TreeLoadingRequest, TreeLoadingStat } from "./sync";
6
+ import { collectStatsForResource } from "./sync";
7
+
8
+ /** Emit everything at or below this depth from a resolution seed, whatever its change token
9
+ * says. It bounds descent as well as emission: under a token the walk otherwise ends at the
10
+ * first unchanged resource (api.proto, changed_since_token), so without this a round returns
11
+ * the seeds alone and a newly attached subtree costs one sequential round trip per level.
12
+ *
13
+ * Deep enough to clear a subtree in one round, which is the trade: real server-side walk and
14
+ * downlink for those levels, against a round trip each at ~1.4s on a slow link. Not free -
15
+ * lower it if resolution rounds ever dominate a poll. */
16
+ const RESOLUTION_DEPTH = 32;
17
+
18
+ /** Every id a body points at. Exactly what `updateFromResourceData` refcounts, and it throws
19
+ * `orphan resource` for any that resolves to nothing. */
20
+ function referencesOf(resource: ExtendedResourceData): SignedResourceId[] {
21
+ const refs: SignedResourceId[] = [];
22
+ if (isNotNullSignedResourceId(resource.error)) refs.push(resource.error);
23
+ for (const field of resource.fields) {
24
+ if (isNotNullSignedResourceId(field.value)) refs.push(field.value);
25
+ if (isNotNullSignedResourceId(field.error)) refs.push(field.error);
26
+ }
27
+ return refs;
28
+ }
29
+
30
+ /**
31
+ * One delta poll: hand the backend this transaction's change token, take only what is newer.
32
+ *
33
+ * The caller commits the token, and only after the whole batch applies - advancing it past a
34
+ * partial apply loses the dropped resources for good.
35
+ *
36
+ * Removals arrive as a rewrite of the referring resource, never as absence, and the refcount
37
+ * GC does the rest. There is deliberately no periodic full reconcile; the blind spot is a
38
+ * removal under a resource the mirror marked final, which streaming and BFS share.
39
+ */
40
+ export async function loadDeltaTreeState(
41
+ tx: PlTransaction,
42
+ loadingRequest: TreeLoadingRequest,
43
+ stats?: TreeLoadingStat,
44
+ logger?: { warn: (msg: string) => void },
45
+ ): Promise<ExtendedResourceData[]> {
46
+ const {
47
+ seedResources,
48
+ finalResources,
49
+ roots,
50
+ knownResources,
51
+ pruningFunction,
52
+ fieldFilter,
53
+ traverseStopRules,
54
+ changedSinceToken,
55
+ } = loadingRequest;
56
+
57
+ // Stop rules are never sent. The backend ignores them under a usable token anyway, but a
58
+ // token-less poll then walks unpruned where streaming would have pruned. Sending them only
59
+ // when the token is absent would change the request shape between polls, which a token
60
+ // holder must not do - so the cost is warned about instead.
61
+ if (traverseStopRules !== undefined && changedSinceToken === undefined) {
62
+ logger?.warn(
63
+ "delta poll: traverseStopRules supplied but not sent, and this poll carries no token," +
64
+ " so the backend walk is unpruned where the streaming path would have pruned it",
65
+ );
66
+ }
67
+
68
+ // Seed at every non-final resource, not the roots. Required, not an optimisation: the
69
+ // backend ends a walk at the first unchanged resource, so a root-seeded poll never reaches a
70
+ // change under a quiet parent - measured at 0 of 3 changes delivered. algorithm_equivalence
71
+ // fails on its KV step if this is ever narrowed back to the roots.
72
+ //
73
+ // The seed set changes every poll and the token is deliberately NOT discarded for it, though
74
+ // api.proto counts it as a shape input. Safe because every seed is a resource the mirror
75
+ // already holds, so a change can only widen the result, and widening hides nothing.
76
+ // Discarding per poll would make every poll a full read and defeat the mechanism.
77
+ //
78
+ // The roots stand in when the frontier is empty: resourceTree needs at least one seed.
79
+ const seeds = seedResources.length > 0 ? seedResources : [...roots];
80
+ if (seeds.length === 0) return [];
81
+
82
+ const collected = new Map<SignedResourceId, ExtendedResourceData>();
83
+ // References neither the mirror nor this batch can satisfy; applying with one outstanding
84
+ // invalidates the whole tree. Maintained incrementally as frames arrive: rescanning
85
+ // everything collected once per round would be quadratic in a poll that needs several.
86
+ const missing = new Set<SignedResourceId>();
87
+ // Same ids, in arrival order, so a round consumes only what is new rather than walking the
88
+ // whole set. An id enters both exactly once: `collected` is checked before the add and never
89
+ // cleared, so nothing re-enters after it resolves.
90
+ const pending: SignedResourceId[] = [];
91
+ const collect = (resource: ExtendedResourceData) => {
92
+ collected.set(resource.id, resource);
93
+ missing.delete(resource.id);
94
+ for (const ref of referencesOf(resource))
95
+ if (!collected.has(ref) && !knownResources.has(ref) && !missing.has(ref)) {
96
+ missing.add(ref);
97
+ pending.push(ref);
98
+ }
99
+ };
100
+
101
+ let warnedStopMarker = false;
102
+ const consume = async (walkSeeds: SignedResourceId[], unconditionalDepth?: number) => {
103
+ if (stats) {
104
+ stats.roundTrips++;
105
+ stats.streamRounds++;
106
+ stats.deltaSeedsSent += walkSeeds.length;
107
+ }
108
+
109
+ for await (const frame of tx.resourceTree(walkSeeds, {
110
+ includeKv: true,
111
+ fieldFilter,
112
+ changedSinceToken,
113
+ unconditionalDepth,
114
+ })) {
115
+ // A body-less frame. The client reads that as a stop marker, and a stop marker can only
116
+ // follow traverse_stop_rules, which this path never sends. Nothing here needs it, so it
117
+ // is skipped - but not silently: reaching this means the backend put a meaning on a
118
+ // body-less frame that this client does not know, and a quiet skip would leave no trace
119
+ // of it. Warn once per poll rather than per frame, since a contract change would emit
120
+ // one of these for every resource visited.
121
+ if (frame.frameKind === "stopMarker") {
122
+ if (!warnedStopMarker) {
123
+ warnedStopMarker = true;
124
+ logger?.warn(
125
+ `delta poll: ignoring a body-less frame for ${frame.id}; this poll sent no ` +
126
+ `traverse stop rules, so the backend is using body-less frames for something ` +
127
+ `this client does not interpret`,
128
+ );
129
+ }
130
+ continue;
131
+ }
132
+
133
+ if (stats) stats.resourceFrames++;
134
+
135
+ // updateFromResourceData throws on any body for a held final resource and invalidates
136
+ // the tree. A token-less poll walks unpruned and emits exactly those, which is what the
137
+ // first poll after a restored snapshot does. Both sibling algorithms skip the same way.
138
+ // Safe for references: finalResources is a subset of knownResources.
139
+ if (finalResources.has(frame.id)) {
140
+ if (stats) stats.finalResourcesSkipped++;
141
+ continue;
142
+ }
143
+
144
+ // An explicit literal rather than two rest-spreads of the frame. Measured ~15% off the
145
+ // per-frame cost: a spread copies every property twice and gives the object a hidden
146
+ // class of its own, where this is the shape the streaming path already produces.
147
+ const resource: ExtendedResourceData = {
148
+ id: frame.id,
149
+ type: frame.type,
150
+ kind: frame.kind,
151
+ data: frame.data,
152
+ resourceReady: frame.resourceReady,
153
+ error: frame.error,
154
+ originalResourceId: frame.originalResourceId,
155
+ final: frame.final,
156
+ inputsLocked: frame.inputsLocked,
157
+ outputsLocked: frame.outputsLocked,
158
+ fields:
159
+ pruningFunction !== undefined
160
+ ? pruningFunction(frame as unknown as ExtendedResourceData)
161
+ : frame.fields,
162
+ kv: frame.kv,
163
+ };
164
+ if (stats) stats.prunedFields += frame.fields.length - resource.fields.length;
165
+
166
+ collect(resource);
167
+ collectStatsForResource(resource, stats);
168
+ }
169
+ };
170
+
171
+ await consume(seeds);
172
+
173
+ // Captured before the resolution rounds. They fetch unconditionally by design, so counting
174
+ // afterwards lets a modest unheld subtree inflate the total past a small mirror and report a
175
+ // refused token that was never refused.
176
+ const collectedFromPoll = collected.size;
177
+
178
+ // A body may reference a resource the response did not carry - a field repointed at one we
179
+ // never held. Ids already asked for are never re-requested, so the loop terminates: the tree
180
+ // is finite and `fetched` only grows.
181
+ //
182
+ // Deliberately unbounded on rounds. A round-count cap would turn a legal tree shape into a
183
+ // desktop error, since how deep the references chain is a property of what the backend
184
+ // allowed rather than of anything wrong here, and the streaming path's stop-marker follow-up
185
+ // loop has always run on the same assumption.
186
+ const fetched = new Set<SignedResourceId>();
187
+ while (missing.size > 0) {
188
+ const round = pending.splice(0).filter((id) => !fetched.has(id) && missing.has(id));
189
+ if (round.length === 0) {
190
+ // Asked for all of these and none arrived; a soft-deleted referent gets here, since
191
+ // those frames are dropped client-side. TreeStateUpdateError specifically: the poll loop
192
+ // rebuilds and discards the token for that class, where a plain Error is only logged and
193
+ // the identical request fails forever.
194
+ throw new TreeStateUpdateError(
195
+ `delta poll: ${missing.size} referenced resource(s) could not be resolved, first ${missing.values().next().value}`,
196
+ );
197
+ }
198
+ for (const id of round) fetched.add(id);
199
+
200
+ if (stats) stats.deltaResolutionRounds++;
201
+ await consume(round, RESOLUTION_DEPTH);
202
+ }
203
+
204
+ // A refused token is answered with the full tree and no error, so this is the only tell.
205
+ // Reported, not acted on: the response is a correct superset, and the token stored after
206
+ // this poll comes from the current instance, so it self-heals next poll.
207
+ if (
208
+ changedSinceToken !== undefined &&
209
+ knownResources.size > 0 &&
210
+ collectedFromPoll >= knownResources.size
211
+ ) {
212
+ if (stats) stats.deltaSuspectedFullAnswers++;
213
+ logger?.warn(
214
+ `delta poll: sent a token but received ${collectedFromPoll} resources against a mirror of ` +
215
+ `${knownResources.size}; the backend may have refused the token (instance change or ` +
216
+ `rewound numbering), in which case this poll cost a full tree read`,
217
+ );
218
+ }
219
+
220
+ return [...collected.values()];
221
+ }
@@ -0,0 +1,259 @@
1
+ import { test, expect } from "vitest";
2
+ import {
3
+ asSignedResourceId,
4
+ field,
5
+ hasCapability,
6
+ parseSignedResourceId,
7
+ TestHelpers,
8
+ } from "@milaboratories/pl-client";
9
+ import type { PlClient } from "@milaboratories/pl-client";
10
+ import type { ExtendedResourceData } from "./state";
11
+ import { TestStructuralResourceType1 } from "./test_utils";
12
+ import { SynchronizedTreeState } from "./synchronized_tree";
13
+ import { ConsoleLoggerAdapter } from "@milaboratories/ts-helpers";
14
+ import type { TraversalMode } from "./sync";
15
+ import tp from "timers/promises";
16
+
17
+ /**
18
+ * Delta polling against a real backend, for correctness only. Cost (bytes and round trips per
19
+ * poll) is the benchmark's job, since stats are loop-internal with no public accessor.
20
+ *
21
+ * Everything here needs `treeChangedSince:v1`, which
22
+ * only a backend built from pl PR #2163 advertises, so each test skips rather than fails on
23
+ * one without it. The unit coverage in `delta_sync.test.ts` pins the client's own logic; what
24
+ * cannot be faked, and is what these are for, is the backend's own emission rule.
25
+ */
26
+ /** Marks the test SKIPPED, not passed, when the backend cannot serve delta.
27
+ *
28
+ * These previously returned early, so on a backend without the capability the whole delta
29
+ * suite reported four green ticks over zero delta code. Skipped and passed are different
30
+ * signals and a run summary has to be able to tell them apart. */
31
+ function skipUnlessDelta(pl: PlClient, ctx: { skip: (note?: string) => void }): boolean {
32
+ if (hasCapability(pl.serverInfo.capabilities ?? [], "treeChangedSince:v1")) return true;
33
+ ctx.skip("backend does not advertise treeChangedSince:v1 (needs pl PR #2163)");
34
+ return false;
35
+ }
36
+
37
+ const logger = new ConsoleLoggerAdapter(console);
38
+
39
+ /** Whole-mirror comparison, so a retained-but-unreferenced resource or a dropped property
40
+ * shows up rather than only a differing count. */
41
+ function canonicalShape(resources: ExtendedResourceData[]): string {
42
+ return resources
43
+ .map(
44
+ (r) =>
45
+ `${r.id}|${r.kind}|${r.final}|${r.inputsLocked}|${r.outputsLocked}|` +
46
+ `${r.fields
47
+ .map((f) => `${f.name}=${f.value}`)
48
+ .sort()
49
+ .join(",")}|kv:${r.kv
50
+ .map((kv) => kv.key)
51
+ .sort()
52
+ .join(",")}`,
53
+ )
54
+ .sort()
55
+ .join("\n");
56
+ }
57
+
58
+ /** A root with one child holding data, which is enough of a tree to poll. */
59
+ async function seedTree(pl: PlClient) {
60
+ return await pl.withWriteTx(
61
+ "DeltaSeed",
62
+ async (tx) => {
63
+ const root = tx.createStruct(TestStructuralResourceType1);
64
+ const rootField = field(tx.clientRoot, "deltaRoot");
65
+ tx.createField(rootField, "Dynamic");
66
+ tx.setField(rootField, root);
67
+
68
+ const child = tx.createStruct(TestStructuralResourceType1);
69
+ const childField = field(root, "child");
70
+ tx.createField(childField, "Dynamic");
71
+ tx.setField(childField, child);
72
+
73
+ await tx.commit();
74
+ return { root: await root.globalId, child: await child.globalId };
75
+ },
76
+ { sync: true },
77
+ );
78
+ }
79
+
80
+ async function openTree(
81
+ pl: PlClient,
82
+ root: string,
83
+ traversalMode: TraversalMode,
84
+ extra: Record<string, unknown> = {},
85
+ ) {
86
+ return await SynchronizedTreeState.init(
87
+ pl,
88
+ root as never,
89
+ { stopPollingDelay: 50, pollingInterval: 10, traversalMode, ...extra } as never,
90
+ logger,
91
+ );
92
+ }
93
+
94
+ test("a delta poll delivers a change on a resource the mirror already holds", async (ctx) => {
95
+ await TestHelpers.withTempRoot(async (pl) => {
96
+ if (!skipUnlessDelta(pl, ctx)) return;
97
+
98
+ const { root, child } = await seedTree(pl);
99
+ const tree = await openTree(pl, root, "backend-delta");
100
+ try {
101
+ await tree.refreshState();
102
+
103
+ // Attach a new resource under the child, so the child's own state moves.
104
+ await pl.withWriteTx(
105
+ "DeltaMutate",
106
+ async (tx) => {
107
+ const grandchild = tx.createStruct(TestStructuralResourceType1);
108
+ const f = field(child as never, "grandchild");
109
+ tx.createField(f, "Dynamic");
110
+ tx.setField(f, grandchild);
111
+ await tx.commit();
112
+ },
113
+ { sync: true },
114
+ );
115
+
116
+ await tree.refreshState();
117
+
118
+ // The grandchild is a resource the mirror never held, referenced by a body the delta
119
+ // did carry: exactly the case the resolution round exists for.
120
+ const dump = tree.dumpState();
121
+ expect(dump.some((r) => r.fields.some((f) => f.name === "grandchild"))).toBe(true);
122
+ } finally {
123
+ await tree.terminate();
124
+ }
125
+ });
126
+ });
127
+
128
+ test("delta and streaming converge on the same mirror", async (ctx) => {
129
+ await TestHelpers.withTempRoot(async (pl) => {
130
+ if (!skipUnlessDelta(pl, ctx)) return;
131
+
132
+ const { root, child } = await seedTree(pl);
133
+
134
+ const delta = await openTree(pl, root, "backend-delta");
135
+ const streaming = await openTree(pl, root, "backend-streaming");
136
+ try {
137
+ await Promise.all([delta.refreshState(), streaming.refreshState()]);
138
+
139
+ await pl.withWriteTx(
140
+ "DeltaConverge",
141
+ async (tx) => {
142
+ const extra = tx.createStruct(TestStructuralResourceType1);
143
+ const f = field(child as never, "extra");
144
+ tx.createField(f, "Dynamic");
145
+ tx.setField(f, extra);
146
+ await tx.commit();
147
+ },
148
+ { sync: true },
149
+ );
150
+
151
+ // Poll both twice: delta needs the second to see the change settle, and streaming is
152
+ // idempotent, so this cannot favour either.
153
+ await Promise.all([delta.refreshState(), streaming.refreshState()]);
154
+ await Promise.all([delta.refreshState(), streaming.refreshState()]);
155
+
156
+ const shape = (t: SynchronizedTreeState) =>
157
+ t
158
+ .dumpState()
159
+ .map(
160
+ (r) =>
161
+ `${r.id}|${r.fields
162
+ .map((f) => `${f.name}=${f.value}`)
163
+ .sort()
164
+ .join(",")}`,
165
+ )
166
+ .sort();
167
+
168
+ expect(shape(delta)).toEqual(shape(streaming));
169
+ } finally {
170
+ await Promise.all([delta.terminate(), streaming.terminate()]);
171
+ }
172
+ });
173
+ });
174
+
175
+ test("a quiet parent: a change under an unchanged resource still arrives", async (ctx) => {
176
+ await TestHelpers.withTempRoot(async (pl) => {
177
+ if (!skipUnlessDelta(pl, ctx)) return;
178
+
179
+ const { root, child } = await seedTree(pl);
180
+ const tree = await openTree(pl, root, "backend-delta");
181
+ try {
182
+ await tree.refreshState();
183
+
184
+ // A KV write on the child does not rewrite the root, so the root stays quiet. With
185
+ // the child is its own seed, so the walk reaches it anyway. This is
186
+ // the assertion that justifies seeding the frontier rather than the roots.
187
+ await pl.withWriteTx(
188
+ "DeltaQuietParent",
189
+ async (tx) => {
190
+ tx.setKValue(child as never, "quiet", Buffer.from("value"));
191
+ await tx.commit();
192
+ },
193
+ { sync: true },
194
+ );
195
+
196
+ // Give the poll loop a couple of cycles: the change has to be reached, not just sent.
197
+ for (let i = 0; i < 3; i++) {
198
+ await tree.refreshState();
199
+ await tp.setTimeout(20);
200
+ }
201
+
202
+ const dump = tree.dumpState();
203
+ const childState = dump.find((r) => r.id === child);
204
+ expect(childState?.kv.some((kv) => kv.key === "quiet")).toBe(true);
205
+ } finally {
206
+ await tree.terminate();
207
+ }
208
+ });
209
+ });
210
+
211
+ test("a delta tree restored from a snapshot converges with streaming", async (ctx) => {
212
+ await TestHelpers.withTempRoot(async (pl) => {
213
+ if (!skipUnlessDelta(pl, ctx)) return;
214
+
215
+ const { root } = await seedTree(pl);
216
+
217
+ // Load through streaming, then hand that mirror to a delta tree via restoreFrom. This is
218
+ // the ordinary upgrade path and the one shape a cold open cannot produce: a POPULATED
219
+ // mirror whose first delta poll carries no token, so it is a full walk that also sends no
220
+ // stop rules. Bodies for resources the mirror holds as final must be skipped, or the apply
221
+ // throws; resources below them must not be retained unreferenced.
222
+ //
223
+ // The earlier version of this test opened a fresh delta tree after terminating the
224
+ // streaming one, which gave an EMPTY mirror and asserted nothing.
225
+ // The fixture must actually contain a final resource or the skip branch is never reached:
226
+ // DefaultFinalResourceDataPredicate marks none of the test resource types final, which is
227
+ // why an earlier version of this test passed with the skip disabled. Everything except the
228
+ // root goes final, so the root stays a seed and the tree can still poll.
229
+ const finalPredicateOverride = (r: { id: unknown }) => r.id !== root;
230
+
231
+ const warm = await openTree(pl, root, "backend-streaming", { finalPredicateOverride });
232
+ await warm.refreshState();
233
+ const witness = parseSignedResourceId(asSignedResourceId(root)).signature;
234
+ const snapshot = warm.capture(witness);
235
+ const streamingShape = canonicalShape(warm.dumpState());
236
+ await warm.terminate();
237
+
238
+ const delta = await SynchronizedTreeState.init(
239
+ pl,
240
+ root as never,
241
+ {
242
+ stopPollingDelay: 50,
243
+ pollingInterval: 10,
244
+ traversalMode: "backend-delta",
245
+ restoreFrom: snapshot,
246
+ finalPredicateOverride,
247
+ },
248
+ logger,
249
+ );
250
+ try {
251
+ expect(delta.wasRestoredFromSnapshot).toBe(true);
252
+ await delta.refreshState();
253
+ await delta.refreshState();
254
+ expect(canonicalShape(delta.dumpState())).toBe(streamingShape);
255
+ } finally {
256
+ await delta.terminate();
257
+ }
258
+ });
259
+ }, 300_000);
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";
@@ -541,3 +546,71 @@ test("orphan-invariant-preserved: error referent streamed alongside stop marker"
541
546
  expect(ids).toEqual(["NG:0x1", "NG:0xE"]);
542
547
  // No throw means the invariant held throughout loadTreeState
543
548
  });
549
+
550
+ //
551
+ // Algorithm selection. These pin the contract that a capable backend is polled with a token
552
+ // by default, and that an unavailable preference degrades rather than throwing.
553
+ //
554
+
555
+ test("auto prefers delta, then streaming, then BFS", () => {
556
+ expect(resolveTreeLoadingAlgorithm("auto", ["treeChangedSince:v1", "treeFilter:v2"])).toBe(
557
+ "backend-delta",
558
+ );
559
+ // Delta needs no treeFilter:v2 of its own.
560
+ expect(resolveTreeLoadingAlgorithm("auto", ["treeChangedSince:v1"])).toBe("backend-delta");
561
+ expect(resolveTreeLoadingAlgorithm("auto", ["treeFilter:v2"])).toBe("backend-streaming");
562
+ expect(resolveTreeLoadingAlgorithm("auto", [])).toBe("client-bfs");
563
+ });
564
+
565
+ test("an explicit mode is honoured over what auto would pick", () => {
566
+ const capable = ["treeChangedSince:v1", "treeFilter:v2"];
567
+ expect(resolveTreeLoadingAlgorithm("client-bfs", capable)).toBe("client-bfs");
568
+ expect(resolveTreeLoadingAlgorithm("backend-streaming", capable)).toBe("backend-streaming");
569
+ expect(resolveTreeLoadingAlgorithm("backend-delta", capable)).toBe("backend-delta");
570
+ });
571
+
572
+ test("backend-delta degrades to the best available path, with a warning, never a throw", () => {
573
+ const warnings: string[] = [];
574
+ const logger = { warn: (m: string) => warnings.push(m) };
575
+
576
+ expect(resolveTreeLoadingAlgorithm("backend-delta", ["treeFilter:v2"], logger)).toBe(
577
+ "backend-streaming",
578
+ );
579
+ expect(resolveTreeLoadingAlgorithm("backend-delta", [], logger)).toBe("client-bfs");
580
+
581
+ expect(warnings).toHaveLength(2);
582
+ for (const w of warnings) expect(w).toContain("treeChangedSince:v1");
583
+ });
584
+
585
+ test("loadTreeState routes into the delta path and passes the token through", async () => {
586
+ const received: { seeds?: string[]; token?: Uint8Array; stopRules?: unknown } = {};
587
+ const tx = {
588
+ resourceTree: (seeds: string[], opts: Record<string, unknown>) => {
589
+ received.seeds = seeds;
590
+ received.token = opts.changedSinceToken as Uint8Array;
591
+ received.stopRules = opts.traverseStopRules;
592
+ return (async function* () {})();
593
+ },
594
+ } as unknown as Parameters<typeof loadTreeState>[0];
595
+
596
+ const request = {
597
+ seedResources: ["NG:0x1"],
598
+ finalResources: new Set<string>(),
599
+ roots: ["NG:0x1"],
600
+ knownResources: new Set<string>(["NG:0x1"]),
601
+ changedSinceToken: new Uint8Array([9]),
602
+ } as unknown as Parameters<typeof loadTreeState>[1];
603
+
604
+ const stat = initialTreeLoadingStat();
605
+ // Via the mode, not by calling loadDeltaTreeState directly: this is the only test that
606
+ // proves the dispatch in loadTreeState reaches delta at all.
607
+ await loadTreeState(tx, request, stat, ["treeChangedSince:v1"], "auto", { warn: () => {} });
608
+
609
+ expect(received.seeds).toEqual(["NG:0x1"]);
610
+ expect(received.token).toEqual(new Uint8Array([9]));
611
+ expect(received.stopRules).toBeUndefined();
612
+ // True for delta as well as streaming: it marks a backend path, which is what state.ts
613
+ // reads to decide the BFS-only wasted-fetch attribution.
614
+ expect(stat.usedStreaming).toBe(true);
615
+ expect(stat.deltaSeedsSent).toBe(1);
616
+ });