@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@milaboratories/pl-tree",
3
- "version": "1.15.0",
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.1",
35
- "@milaboratories/ts-configs": "1.4.0",
36
- "@milaboratories/ts-builder": "1.7.2"
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
- 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
@@ -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
- const { result, followUpSeeds } = await processResourceTreeStream(
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
- result.push(...followUpResult);
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)}`,