@milaboratories/pl-tree 1.15.0 → 1.15.2
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/state.cjs +55 -9
- package/dist/state.cjs.map +1 -1
- package/dist/state.d.ts.map +1 -1
- package/dist/state.js +55 -9
- package/dist/state.js.map +1 -1
- package/dist/sync.cjs +2 -2
- package/dist/sync.cjs.map +1 -1
- package/dist/sync.d.ts.map +1 -1
- package/dist/sync.js +2 -2
- package/dist/sync.js.map +1 -1
- package/package.json +6 -6
- package/src/state.test.ts +157 -0
- package/src/state.ts +98 -16
- package/src/sync.test.ts +31 -0
- package/src/sync.ts +3 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@milaboratories/pl-tree",
|
|
3
|
-
"version": "1.15.
|
|
3
|
+
"version": "1.15.2",
|
|
4
4
|
"description": "Reactive pl tree state",
|
|
5
5
|
"files": [
|
|
6
6
|
"./dist/**/*",
|
|
@@ -21,9 +21,9 @@
|
|
|
21
21
|
"denque": "^2.1.0",
|
|
22
22
|
"utility-types": "^3.11.0",
|
|
23
23
|
"zod": "~3.25.76",
|
|
24
|
-
"@milaboratories/pl-errors": "1.4.41",
|
|
25
|
-
"@milaboratories/pl-client": "3.17.0",
|
|
26
24
|
"@milaboratories/computable": "2.9.8",
|
|
25
|
+
"@milaboratories/pl-client": "3.17.1",
|
|
26
|
+
"@milaboratories/pl-errors": "1.4.42",
|
|
27
27
|
"@milaboratories/ts-helpers": "1.8.6"
|
|
28
28
|
},
|
|
29
29
|
"devDependencies": {
|
|
@@ -31,9 +31,9 @@
|
|
|
31
31
|
"@vitest/coverage-istanbul": "^4.1.3",
|
|
32
32
|
"typescript": "7.0.2",
|
|
33
33
|
"vitest": "^4.1.3",
|
|
34
|
-
"@milaboratories/build-configs": "2.0.
|
|
35
|
-
"@milaboratories/ts-
|
|
36
|
-
"@milaboratories/ts-
|
|
34
|
+
"@milaboratories/build-configs": "2.0.2",
|
|
35
|
+
"@milaboratories/ts-builder": "1.7.2",
|
|
36
|
+
"@milaboratories/ts-configs": "1.4.0"
|
|
37
37
|
},
|
|
38
38
|
"engines": {
|
|
39
39
|
"node": ">=22.19.0"
|
package/src/state.test.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { expect, test } from "vitest";
|
|
2
2
|
import { Computable } from "@milaboratories/computable";
|
|
3
|
+
import type { Watcher } from "@milaboratories/computable";
|
|
4
|
+
import type { FieldData } from "@milaboratories/pl-client";
|
|
3
5
|
import {
|
|
4
6
|
createSignedResourceId,
|
|
5
7
|
DefaultFinalResourceDataPredicate,
|
|
@@ -20,6 +22,15 @@ import {
|
|
|
20
22
|
|
|
21
23
|
const rid = createSignedResourceId;
|
|
22
24
|
|
|
25
|
+
/** Minimal Watcher for tests that read tree state directly, outside a Computable. */
|
|
26
|
+
class NoopWatcher implements Watcher {
|
|
27
|
+
isChanged = false;
|
|
28
|
+
markChanged(): void {
|
|
29
|
+
this.isChanged = true;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
const w = () => new NoopWatcher();
|
|
33
|
+
|
|
23
34
|
test("simple tree test 1", async () => {
|
|
24
35
|
const tree = new PlTreeState(TestDynamicRootId1, DefaultFinalResourceDataPredicate);
|
|
25
36
|
const entry = tree.entry();
|
|
@@ -317,3 +328,149 @@ test("exception - ready without locks 2", () => {
|
|
|
317
328
|
]),
|
|
318
329
|
).toThrow(/ready without input or output lock/);
|
|
319
330
|
});
|
|
331
|
+
|
|
332
|
+
// The field and kv update loops walk the stored entries in lockstep with the incoming ones
|
|
333
|
+
// and only fall back to a hash lookup once the two orders diverge. These cover the
|
|
334
|
+
// divergence shapes: reordering, an insertion in the middle, a removal. A reorder that
|
|
335
|
+
// changes nothing must invalidate nothing and must not disturb refCounts, which is what
|
|
336
|
+
// pins the fallback: mistaking a reordered field for a new one still leaves the right field
|
|
337
|
+
// set behind, but double-counts the reference and fires spurious change notifications.
|
|
338
|
+
|
|
339
|
+
const rootRes = (fields: FieldData[]) => [{ ...TestDynamicRootState1, fields }];
|
|
340
|
+
|
|
341
|
+
test("a pure field reorder changes nothing and invalidates nothing", () => {
|
|
342
|
+
const tree = new PlTreeState(TestDynamicRootId1, DefaultFinalResourceDataPredicate);
|
|
343
|
+
tree.updateFromResourceData(rootRes([dField("a"), dField("b"), dField("c")]));
|
|
344
|
+
|
|
345
|
+
const watcher = w();
|
|
346
|
+
const root = tree.get(watcher, TestDynamicRootId1);
|
|
347
|
+
root.listDynamicFields(watcher);
|
|
348
|
+
for (const name of ["a", "b", "c"]) root.getField(watcher, name, () => {});
|
|
349
|
+
expect(watcher.isChanged).toStrictEqual(false);
|
|
350
|
+
|
|
351
|
+
tree.updateFromResourceData(rootRes([dField("c"), dField("b"), dField("a")]));
|
|
352
|
+
|
|
353
|
+
expect(watcher.isChanged).toStrictEqual(false);
|
|
354
|
+
expect(
|
|
355
|
+
tree
|
|
356
|
+
.get(w(), TestDynamicRootId1)
|
|
357
|
+
.fields.map((f) => f.name)
|
|
358
|
+
.sort(),
|
|
359
|
+
).toStrictEqual(["a", "b", "c"]);
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
test("field inserted mid-order, then removed, is tracked", () => {
|
|
363
|
+
const tree = new PlTreeState(TestDynamicRootId1, DefaultFinalResourceDataPredicate);
|
|
364
|
+
const names = () =>
|
|
365
|
+
tree
|
|
366
|
+
.get(w(), TestDynamicRootId1)
|
|
367
|
+
.fields.map((f) => f.name)
|
|
368
|
+
.sort();
|
|
369
|
+
|
|
370
|
+
tree.updateFromResourceData(rootRes([dField("a"), dField("c")]));
|
|
371
|
+
expect(names()).toStrictEqual(["a", "c"]);
|
|
372
|
+
|
|
373
|
+
// "b" appears between two fields that are already stored: the walk diverges at "b"
|
|
374
|
+
tree.updateFromResourceData(rootRes([dField("a"), dField("b"), dField("c")]));
|
|
375
|
+
expect(names()).toStrictEqual(["a", "b", "c"]);
|
|
376
|
+
|
|
377
|
+
// and disappears again, in a shuffled order
|
|
378
|
+
tree.updateFromResourceData(rootRes([dField("c"), dField("a")]));
|
|
379
|
+
expect(names()).toStrictEqual(["a", "c"]);
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
test("reordering fields does not inflate refCounts", () => {
|
|
383
|
+
const tree = new PlTreeState(TestDynamicRootId1, DefaultFinalResourceDataPredicate);
|
|
384
|
+
const leaf = (n: bigint) => ({
|
|
385
|
+
...TestValueResourceState1,
|
|
386
|
+
id: rid(n),
|
|
387
|
+
data: new TextEncoder().encode(`v${n}`),
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
tree.updateFromResourceData([
|
|
391
|
+
{
|
|
392
|
+
...TestDynamicRootState1,
|
|
393
|
+
fields: [dField("a", rid(1n)), dField("b", rid(2n))],
|
|
394
|
+
},
|
|
395
|
+
leaf(1n),
|
|
396
|
+
leaf(2n),
|
|
397
|
+
]);
|
|
398
|
+
|
|
399
|
+
// reorder and repoint in one update: the two fields swap targets
|
|
400
|
+
tree.updateFromResourceData([
|
|
401
|
+
{
|
|
402
|
+
...TestDynamicRootState1,
|
|
403
|
+
fields: [dField("b", rid(1n)), dField("a", rid(2n))],
|
|
404
|
+
},
|
|
405
|
+
leaf(1n),
|
|
406
|
+
leaf(2n),
|
|
407
|
+
]);
|
|
408
|
+
|
|
409
|
+
const byName = new Map(tree.get(w(), TestDynamicRootId1).fields.map((f) => [f.name, f.value]));
|
|
410
|
+
expect(byName.get("a")).toStrictEqual(rid(2n));
|
|
411
|
+
expect(byName.get("b")).toStrictEqual(rid(1n));
|
|
412
|
+
|
|
413
|
+
// dropping "a" must collect leaf 2: its refCount has to be exactly 1, so a reorder that
|
|
414
|
+
// was mistaken for an insertion (and double-incremented) would leave it alive here
|
|
415
|
+
tree.updateFromResourceData(rootRes([dField("b", rid(1n))]));
|
|
416
|
+
expect(tree.get(w(), rid(1n)).getDataAsString()).toStrictEqual("v1");
|
|
417
|
+
expect(() => tree.get(w(), rid(2n))).toThrow(/not found/);
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
test("kv reordering neither invalidates watchers nor loses entries", () => {
|
|
421
|
+
const tree = new PlTreeState(TestDynamicRootId1, DefaultFinalResourceDataPredicate);
|
|
422
|
+
const kvOf = (entries: [string, string][]) => [
|
|
423
|
+
{
|
|
424
|
+
...TestDynamicRootState1,
|
|
425
|
+
fields: [],
|
|
426
|
+
kv: entries.map(([key, value]) => ({ key, value: Buffer.from(value) })),
|
|
427
|
+
},
|
|
428
|
+
];
|
|
429
|
+
const read = (key: string) => tree.get(w(), TestDynamicRootId1).getKeyValueString(w(), key);
|
|
430
|
+
|
|
431
|
+
tree.updateFromResourceData(
|
|
432
|
+
kvOf([
|
|
433
|
+
["k1", "one"],
|
|
434
|
+
["k2", "two"],
|
|
435
|
+
]),
|
|
436
|
+
);
|
|
437
|
+
expect([read("k1"), read("k2")]).toStrictEqual(["one", "two"]);
|
|
438
|
+
|
|
439
|
+
// reversed order, same values: no watcher may fire
|
|
440
|
+
const watcher = w();
|
|
441
|
+
const root = tree.get(watcher, TestDynamicRootId1);
|
|
442
|
+
root.getKeyValue(watcher, "k1");
|
|
443
|
+
root.getKeyValue(watcher, "k2");
|
|
444
|
+
tree.updateFromResourceData(
|
|
445
|
+
kvOf([
|
|
446
|
+
["k2", "two"],
|
|
447
|
+
["k1", "one"],
|
|
448
|
+
]),
|
|
449
|
+
);
|
|
450
|
+
expect(watcher.isChanged).toStrictEqual(false);
|
|
451
|
+
|
|
452
|
+
// a key inserted before the stored ones, so the walk diverges immediately
|
|
453
|
+
tree.updateFromResourceData(
|
|
454
|
+
kvOf([
|
|
455
|
+
["k0", "zero"],
|
|
456
|
+
["k2", "two"],
|
|
457
|
+
["k1", "ONE"],
|
|
458
|
+
]),
|
|
459
|
+
);
|
|
460
|
+
expect([read("k0"), read("k1"), read("k2")]).toStrictEqual(["zero", "ONE", "two"]);
|
|
461
|
+
|
|
462
|
+
// and a deletion
|
|
463
|
+
tree.updateFromResourceData(kvOf([["k1", "ONE"]]));
|
|
464
|
+
expect([read("k0"), read("k1"), read("k2")]).toStrictEqual([undefined, "ONE", undefined]);
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
test("removal of a typed field still throws after a reorder", () => {
|
|
468
|
+
const tree = new PlTreeState(TestDynamicRootId1, DefaultFinalResourceDataPredicate);
|
|
469
|
+
|
|
470
|
+
tree.updateFromResourceData(rootRes([iField("a"), iField("b"), dField("c")]));
|
|
471
|
+
|
|
472
|
+
// reordered, and the input field "b" is gone: the removal scan must still see it
|
|
473
|
+
expect(() => tree.updateFromResourceData(rootRes([dField("c"), iField("a")]))).toThrow(
|
|
474
|
+
/removal of Input field b/,
|
|
475
|
+
);
|
|
476
|
+
});
|
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
|
-
|
|
526
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
@@ -481,6 +481,37 @@ test("stop-marker-unknown-triggers-followup: unknown marker fetched in follow-up
|
|
|
481
481
|
expect(callCount).toBe(2);
|
|
482
482
|
});
|
|
483
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
|
+
|
|
484
515
|
test("legacy-backend-compat: no stop markers in stream → no follow-up call", async () => {
|
|
485
516
|
// Old backends emit only full-resource frames; pendingFollowUp stays empty
|
|
486
517
|
// and the follow-up loop body never executes.
|
package/src/sync.ts
CHANGED
|
@@ -446,7 +446,7 @@ async function loadTreeStateViaResourceTree(
|
|
|
446
446
|
traverseStopRules,
|
|
447
447
|
});
|
|
448
448
|
|
|
449
|
-
|
|
449
|
+
let { result, followUpSeeds } = await processResourceTreeStream(
|
|
450
450
|
treeItems,
|
|
451
451
|
finalResources,
|
|
452
452
|
pruningFunction,
|
|
@@ -481,7 +481,8 @@ async function loadTreeStateViaResourceTree(
|
|
|
481
481
|
pruningFunction,
|
|
482
482
|
stats,
|
|
483
483
|
);
|
|
484
|
-
|
|
484
|
+
// spreading fails due to exceeding stack argument size
|
|
485
|
+
result = result.concat(followUpResult);
|
|
485
486
|
if (stats) {
|
|
486
487
|
logger?.info?.(
|
|
487
488
|
`loadTreeStateViaResourceTree: follow-up request for ${roundSeeds.length} stop-marker seeds: ${JSON.stringify(roundSeeds)}`,
|