@milaboratories/pl-tree 1.10.5 → 1.11.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.
package/src/sync.test.ts CHANGED
@@ -1,11 +1,190 @@
1
1
  import { test, expect } from "vitest";
2
- import { DefaultFinalResourceDataPredicate, field, TestHelpers } from "@milaboratories/pl-client";
2
+ import {
3
+ DefaultFinalResourceDataPredicate,
4
+ field,
5
+ NullSignedResourceId,
6
+ TestHelpers,
7
+ } from "@milaboratories/pl-client";
3
8
  import { PlTreeState } from "./state";
4
- import { constructTreeLoadingRequest, loadTreeState } from "./sync";
9
+ import { constructTreeLoadingRequest, initialTreeLoadingStat, loadTreeState } from "./sync";
10
+ import type { TraversalMode } from "./sync";
5
11
  import { Computable } from "@milaboratories/computable";
6
12
  import { TestStructuralResourceType1 } from "./test_utils";
7
13
  import * as tp from "node:timers/promises";
8
14
 
15
+ test("loadTreeState uses ResourceTree path with full options", async () => {
16
+ const received: {
17
+ seeds?: string[];
18
+ includeKv?: boolean;
19
+ hasFilters?: boolean;
20
+ hasStopRules?: boolean;
21
+ } = {};
22
+
23
+ const tx = {
24
+ resourceTree: (
25
+ seeds: string[],
26
+ opts?: { includeKv?: boolean; fieldFilter?: unknown; traverseStopRules?: unknown },
27
+ ) => {
28
+ received.seeds = seeds;
29
+ received.includeKv = opts?.includeKv;
30
+ received.hasFilters = opts?.fieldFilter !== undefined;
31
+ received.hasStopRules = opts?.traverseStopRules !== undefined;
32
+
33
+ const itemA = {
34
+ id: "NG:0x1",
35
+ type: { name: "Projects", version: "1" },
36
+ kind: "Structural",
37
+ data: undefined,
38
+ resourceReady: false,
39
+ error: NullSignedResourceId,
40
+ originalResourceId: NullSignedResourceId,
41
+ final: false,
42
+ inputsLocked: false,
43
+ outputsLocked: false,
44
+ fields: [
45
+ {
46
+ name: "keep",
47
+ type: "Dynamic",
48
+ value: NullSignedResourceId,
49
+ error: NullSignedResourceId,
50
+ status: "Resolved",
51
+ valueIsFinal: false,
52
+ },
53
+ ],
54
+ kv: [],
55
+ traverseWasStopped: false,
56
+ };
57
+ const itemSkipped = { ...itemA, id: "NG:0x2" };
58
+
59
+ return (async function* () {
60
+ yield itemA;
61
+ yield itemSkipped;
62
+ })();
63
+ },
64
+ } as unknown as Parameters<typeof loadTreeState>[0];
65
+
66
+ const request = {
67
+ seedResources: ["NG:0x1", "NG:0x2"],
68
+ finalResources: new Set<string>(["NG:0x2"]),
69
+ pruningFunction: (r: any) => r.fields.filter((f: any) => f.name === "keep"),
70
+ fieldFilter: {},
71
+ traverseStopRules: {},
72
+ } as unknown as Parameters<typeof loadTreeState>[1];
73
+
74
+ const result = await loadTreeState(tx, request, undefined, ["treeFilter:v1"]);
75
+
76
+ expect(received).toEqual({
77
+ seeds: ["NG:0x1", "NG:0x2"],
78
+ includeKv: true,
79
+ hasFilters: true,
80
+ hasStopRules: true,
81
+ });
82
+ expect(result).toHaveLength(1);
83
+ expect(result[0]?.id).toBe("NG:0x1");
84
+ });
85
+
86
+ test("loadTreeState propagates ResourceTree stream failure", async () => {
87
+ const tx = {
88
+ resourceTree: () =>
89
+ (async function* () {
90
+ yield await Promise.reject(new Error("stream failed"));
91
+ })(),
92
+ } as unknown as Parameters<typeof loadTreeState>[0];
93
+
94
+ const request = {
95
+ seedResources: ["NG:0x1"],
96
+ finalResources: new Set<string>(),
97
+ } as unknown as Parameters<typeof loadTreeState>[1];
98
+
99
+ await expect(loadTreeState(tx, request, undefined, ["treeFilter:v1"])).rejects.toThrow(
100
+ "stream failed",
101
+ );
102
+ });
103
+
104
+ test("loadTreeState cancels ResourceTree iterator on pruning failure", async () => {
105
+ let returnCalled = false;
106
+
107
+ const iterable = {
108
+ [Symbol.asyncIterator]() {
109
+ let done = false;
110
+ return {
111
+ next: async () => {
112
+ if (done) return { done: true as const, value: undefined };
113
+ done = true;
114
+ return {
115
+ done: false as const,
116
+ value: {
117
+ id: "NG:0x1",
118
+ type: { name: "Projects", version: "1" },
119
+ kind: "Structural",
120
+ data: undefined,
121
+ resourceReady: false,
122
+ error: NullSignedResourceId,
123
+ originalResourceId: NullSignedResourceId,
124
+ final: false,
125
+ inputsLocked: false,
126
+ outputsLocked: false,
127
+ fields: [],
128
+ kv: [],
129
+ traverseWasStopped: false,
130
+ },
131
+ };
132
+ },
133
+ return: async () => {
134
+ returnCalled = true;
135
+ return { done: true as const, value: undefined };
136
+ },
137
+ };
138
+ },
139
+ };
140
+
141
+ const tx = {
142
+ resourceTree: () => iterable,
143
+ } as unknown as Parameters<typeof loadTreeState>[0];
144
+
145
+ const request = {
146
+ seedResources: ["NG:0x1"],
147
+ finalResources: new Set<string>(),
148
+ pruningFunction: () => {
149
+ throw new Error("pruning failed");
150
+ },
151
+ } as unknown as Parameters<typeof loadTreeState>[1];
152
+
153
+ await expect(loadTreeState(tx, request, undefined, ["treeFilter:v1"])).rejects.toThrow(
154
+ "pruning failed",
155
+ );
156
+ expect(returnCalled).toBe(true);
157
+ });
158
+
159
+ test("loadTreeState falls back to BFS when capabilities are absent", async () => {
160
+ const tx = {
161
+ getResourceDataIfExists: async (rid: string) => ({
162
+ id: rid,
163
+ type: { name: "Projects", version: "1" },
164
+ kind: "Structural",
165
+ data: undefined,
166
+ resourceReady: false,
167
+ error: NullSignedResourceId,
168
+ originalResourceId: NullSignedResourceId,
169
+ final: false,
170
+ inputsLocked: false,
171
+ outputsLocked: false,
172
+ fields: [],
173
+ }),
174
+ listKeyValuesIfResourceExists: async () => [],
175
+ } as unknown as Parameters<typeof loadTreeState>[0];
176
+
177
+ const request = {
178
+ seedResources: ["NG:0x2"],
179
+ finalResources: new Set<string>(),
180
+ } as unknown as Parameters<typeof loadTreeState>[1];
181
+
182
+ const result = await loadTreeState(tx, request, undefined, []);
183
+
184
+ expect(result).toHaveLength(1);
185
+ expect(result[0]?.id).toBe("NG:0x2");
186
+ });
187
+
9
188
  test("load resources", async () => {
10
189
  await TestHelpers.withTempRoot(async (cl) => {
11
190
  const r1 = await cl.withWriteTx(
@@ -104,3 +283,265 @@ test("load resources", async () => {
104
283
  });
105
284
  });
106
285
  });
286
+
287
+ // ─── TraversalMode tests ──────────────────────────────────────────────────────
288
+
289
+ /** Build a minimal mock tx that records whether resourceTree / getResourceDataIfExists
290
+ * was called. Both surfaces are instrumented so we can assert which path ran. */
291
+ function buildMockTx(opts: { capable: boolean }): {
292
+ tx: Parameters<typeof loadTreeState>[0];
293
+ calls: { bfs: number; streaming: number };
294
+ } {
295
+ const calls = { bfs: 0, streaming: 0 };
296
+
297
+ const bfsResource = {
298
+ id: "NG:0x1",
299
+ type: { name: "Test", version: "1" },
300
+ kind: "Structural" as const,
301
+ data: undefined,
302
+ resourceReady: false,
303
+ error: NullSignedResourceId,
304
+ originalResourceId: NullSignedResourceId,
305
+ final: false,
306
+ inputsLocked: false,
307
+ outputsLocked: false,
308
+ fields: [],
309
+ };
310
+
311
+ const streamingResource = {
312
+ ...bfsResource,
313
+ kv: [],
314
+ traverseWasStopped: false,
315
+ };
316
+
317
+ const tx = {
318
+ getResourceDataIfExists: async () => {
319
+ calls.bfs++;
320
+ return bfsResource;
321
+ },
322
+ listKeyValuesIfResourceExists: async () => [],
323
+ resourceTree: opts.capable
324
+ ? () => {
325
+ calls.streaming++;
326
+ return (async function* () {
327
+ yield streamingResource;
328
+ })();
329
+ }
330
+ : undefined,
331
+ } as unknown as Parameters<typeof loadTreeState>[0];
332
+
333
+ return { tx, calls };
334
+ }
335
+
336
+ const baseRequest = {
337
+ seedResources: ["NG:0x1"],
338
+ finalResources: new Set<string>(),
339
+ } as unknown as Parameters<typeof loadTreeState>[1];
340
+
341
+ test("traversalMode=client-bfs forces BFS on capable backend", async () => {
342
+ const { tx, calls } = buildMockTx({ capable: true });
343
+ const mode: TraversalMode = "client-bfs";
344
+
345
+ const result = await loadTreeState(tx, baseRequest, undefined, ["treeFilter:v1"], mode);
346
+
347
+ expect(result).toHaveLength(1);
348
+ expect(calls.bfs).toBeGreaterThan(0);
349
+ expect(calls.streaming).toBe(0);
350
+ });
351
+
352
+ test("traversalMode=backend-streaming falls back to BFS when capability is absent, warns once", async () => {
353
+ const { tx, calls } = buildMockTx({ capable: false });
354
+ const mode: TraversalMode = "backend-streaming";
355
+
356
+ const warnings: string[] = [];
357
+ const logger = { warn: (msg: string) => warnings.push(msg) };
358
+
359
+ const result = await loadTreeState(tx, baseRequest, undefined, [], mode, logger);
360
+
361
+ expect(result).toHaveLength(1);
362
+ expect(calls.bfs).toBeGreaterThan(0);
363
+ expect(calls.streaming).toBe(0);
364
+ expect(warnings).toHaveLength(1);
365
+ expect(warnings[0]).toMatch(/treeFilter:v1/);
366
+ });
367
+
368
+ test("traversalMode=backend-streaming uses streaming on capable backend", async () => {
369
+ const { tx, calls } = buildMockTx({ capable: true });
370
+ const mode: TraversalMode = "backend-streaming";
371
+
372
+ const result = await loadTreeState(tx, baseRequest, undefined, ["treeFilter:v1"], mode);
373
+
374
+ expect(result).toHaveLength(1);
375
+ expect(calls.streaming).toBeGreaterThan(0);
376
+ expect(calls.bfs).toBe(0);
377
+ });
378
+
379
+ test("traversalMode=auto default regression: streaming on capable, BFS on incapable", async () => {
380
+ // capable backend → streaming
381
+ const { tx: txCap, calls: callsCap } = buildMockTx({ capable: true });
382
+ await loadTreeState(txCap, baseRequest, undefined, ["treeFilter:v1"]);
383
+ expect(callsCap.streaming).toBeGreaterThan(0);
384
+ expect(callsCap.bfs).toBe(0);
385
+
386
+ // incapable backend → BFS
387
+ const { tx: txNoCap, calls: callsNoCap } = buildMockTx({ capable: false });
388
+ await loadTreeState(txNoCap, baseRequest, undefined, []);
389
+ expect(callsNoCap.bfs).toBeGreaterThan(0);
390
+ expect(callsNoCap.streaming).toBe(0);
391
+ });
392
+
393
+ // ─── Stop-marker handling tests ───────────────────────────────────────────────
394
+
395
+ /** Capabilities for backends that support streaming traversal. */
396
+ const stopMarkerCaps = ["treeFilter:v1"] as const;
397
+
398
+ /** Minimal full-resource frame suitable for stop-marker tests. */
399
+ function makeFullResource(
400
+ id: string,
401
+ overrides: Record<string, unknown> = {},
402
+ ): Record<string, unknown> {
403
+ return {
404
+ id,
405
+ type: { name: "Test", version: "1" },
406
+ kind: "Structural",
407
+ data: undefined,
408
+ resourceReady: false,
409
+ error: NullSignedResourceId,
410
+ originalResourceId: NullSignedResourceId,
411
+ final: false,
412
+ inputsLocked: false,
413
+ outputsLocked: false,
414
+ fields: [],
415
+ kv: [],
416
+ traverseWasStopped: false,
417
+ ...overrides,
418
+ };
419
+ }
420
+
421
+ /** Minimal stop-marker frame. */
422
+ function makeStopMarker(id: string): Record<string, unknown> {
423
+ return { frameKind: "stopMarker", id, traverseWasStopped: true };
424
+ }
425
+
426
+ test("stop-marker-final-locally-skipped: marker for final id is not followed up", async () => {
427
+ let callCount = 0;
428
+ const tx = {
429
+ resourceTree: () => {
430
+ callCount++;
431
+ return (async function* () {
432
+ yield makeStopMarker("NG:0x1");
433
+ })();
434
+ },
435
+ } as unknown as Parameters<typeof loadTreeState>[0];
436
+
437
+ const request = {
438
+ seedResources: ["NG:0x99"],
439
+ finalResources: new Set<string>(["NG:0x1"]),
440
+ } as unknown as Parameters<typeof loadTreeState>[1];
441
+
442
+ const stats = initialTreeLoadingStat();
443
+ const result = await loadTreeState(tx, request, stats, stopMarkerCaps);
444
+
445
+ expect(result).toHaveLength(0);
446
+ expect(stats.stopMarkersSkipped).toBe(1);
447
+ expect(callCount).toBe(1); // no follow-up call issued
448
+ });
449
+
450
+ test("stop-marker-unknown-triggers-followup: unknown marker fetched in follow-up", async () => {
451
+ let callCount = 0;
452
+ const tx = {
453
+ resourceTree: () => {
454
+ callCount++;
455
+ if (callCount === 1) {
456
+ // Round 0: emit a stop marker for an unknown id
457
+ return (async function* () {
458
+ yield makeStopMarker("NG:0x1");
459
+ })();
460
+ } else {
461
+ // Follow-up: return full resource for that id
462
+ return (async function* () {
463
+ yield makeFullResource("NG:0x1");
464
+ })();
465
+ }
466
+ },
467
+ } as unknown as Parameters<typeof loadTreeState>[0];
468
+
469
+ const request = {
470
+ seedResources: ["NG:0x99"],
471
+ finalResources: new Set<string>(),
472
+ } as unknown as Parameters<typeof loadTreeState>[1];
473
+
474
+ const stats = initialTreeLoadingStat();
475
+ const result = await loadTreeState(tx, request, stats, stopMarkerCaps);
476
+
477
+ expect(result).toHaveLength(1);
478
+ expect(result[0]?.id).toBe("NG:0x1");
479
+ expect(stats.stopMarkerFollowUpRoundTrips).toBe(1);
480
+ expect(callCount).toBe(2);
481
+ });
482
+
483
+ test("legacy-backend-compat: no stop markers in stream → no follow-up call", async () => {
484
+ // Old backends emit only full-resource frames; pendingFollowUp stays empty
485
+ // and the follow-up loop body never executes.
486
+ let callCount = 0;
487
+ const tx = {
488
+ resourceTree: () => {
489
+ callCount++;
490
+ return (async function* () {
491
+ yield makeFullResource("NG:0x1");
492
+ })();
493
+ },
494
+ } as unknown as Parameters<typeof loadTreeState>[0];
495
+
496
+ const request = {
497
+ seedResources: ["NG:0x1"],
498
+ finalResources: new Set<string>(),
499
+ } as unknown as Parameters<typeof loadTreeState>[1];
500
+
501
+ const stats = initialTreeLoadingStat();
502
+ const result = await loadTreeState(tx, request, stats, ["treeFilter:v1"]);
503
+
504
+ expect(result).toHaveLength(1);
505
+ expect(callCount).toBe(1);
506
+ expect(stats.stopMarkerFollowUpRoundTrips).toBe(0);
507
+ });
508
+
509
+ test("orphan-invariant-preserved: error referent streamed alongside stop marker", async () => {
510
+ // Server streams the error referent (errY) as a normal frame and the
511
+ // stopped node (X) as a stop marker. Follow-up returns X as a full
512
+ // resource with error pointing to errY. Both should appear in the result.
513
+ const errY = makeFullResource("NG:0xE", { final: true, inputsLocked: true, outputsLocked: true });
514
+ const stoppedX = makeFullResource("NG:0x1", {
515
+ error: "NG:0xE", // X's error field points to errY
516
+ traverseWasStopped: true,
517
+ });
518
+
519
+ let callCount = 0;
520
+ const tx = {
521
+ resourceTree: () => {
522
+ callCount++;
523
+ if (callCount === 1) {
524
+ return (async function* () {
525
+ yield errY; // error referent — normal frame
526
+ yield makeStopMarker("NG:0x1"); // stopped node — marker frame
527
+ })();
528
+ } else {
529
+ return (async function* () {
530
+ yield stoppedX; // follow-up returns full resource
531
+ })();
532
+ }
533
+ },
534
+ } as unknown as Parameters<typeof loadTreeState>[0];
535
+
536
+ const request = {
537
+ seedResources: ["NG:0x1"],
538
+ finalResources: new Set<string>(),
539
+ } as unknown as Parameters<typeof loadTreeState>[1];
540
+
541
+ const result = await loadTreeState(tx, request, undefined, stopMarkerCaps);
542
+
543
+ // Both the error referent and the stopped resource must be present
544
+ const ids = result.map((r) => r.id).sort();
545
+ expect(ids).toEqual(["NG:0x1", "NG:0xE"]);
546
+ // No throw means the invariant held throughout loadTreeState
547
+ });