@milaboratories/pl-tree 1.15.6 → 1.16.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/state.ts CHANGED
@@ -31,8 +31,8 @@ export type ExtendedResourceData = ResourceData & {
31
31
  };
32
32
 
33
33
  export class TreeStateUpdateError extends Error {
34
- constructor(message: string) {
35
- super(message);
34
+ constructor(message: string, options?: ErrorOptions) {
35
+ super(message, options);
36
36
  }
37
37
  }
38
38
 
@@ -64,6 +64,17 @@ class PlTreeField implements FieldData {
64
64
 
65
65
  const InitialResourceVersion = 0;
66
66
 
67
+ /** Input and Service fields share one list and one lock: both are refused once inputs lock. */
68
+ function isInputLike(type: FieldType): boolean {
69
+ return type === "Input" || type === "Service";
70
+ }
71
+
72
+ /** The only field types the backend lets a client delete, so the only ones whose name can come
73
+ * back under another type between two polls. */
74
+ function isRecreatable(type: FieldType): boolean {
75
+ return type === "Dynamic" || type === "MTW";
76
+ }
77
+
67
78
  export type ResourceDataWithFinalState = ResourceData & {
68
79
  finalState: boolean;
69
80
  };
@@ -220,10 +231,24 @@ export class PlTreeResource implements ResourceDataWithFinalState {
220
231
 
221
232
  const field = this.fieldsMap.get(step.field);
222
233
  if (field === undefined) {
223
- if (step.errorIfFieldNotFound || step.errorIfFieldNotSet)
234
+ if (step.errorIfFieldNotFound || step.errorIfFieldNotSet) {
235
+ // Subscribed before throwing, so the error recomputes when the field appears.
236
+ if (!this.inputsLocked) this.inputAndServiceFieldListChanged?.attachWatcher(watcher);
237
+ if (!this.outputsLocked) this.outputFieldListChanged?.attachWatcher(watcher);
238
+ this.dynamicFieldListChanged?.attachWatcher(watcher);
224
239
  throw new Error(
225
240
  `Field "${step.field}" not found in resource ${resourceIdToString(this.id)}`,
226
241
  );
242
+ }
243
+
244
+ // An asserted type's absence becomes permanent when its list locks, with no field added.
245
+ if (
246
+ step.assertFieldType !== undefined &&
247
+ (isInputLike(step.assertFieldType)
248
+ ? !this.inputsLocked
249
+ : step.assertFieldType === "Output" && !this.outputsLocked)
250
+ )
251
+ this.lockedChange?.attachWatcher(watcher);
227
252
 
228
253
  if (!this.inputsLocked) this.inputAndServiceFieldListChanged?.attachWatcher(watcher);
229
254
  else if (step.assertFieldType === "Service" || step.assertFieldType === "Input") {
@@ -246,6 +271,9 @@ export class PlTreeResource implements ResourceDataWithFinalState {
246
271
 
247
272
  return undefined;
248
273
  } else {
274
+ // Subscribed before the type check: a Dynamic or MTW field can come back under another
275
+ // type, and the refused reader must then be re-run.
276
+ field.change.attachWatcher(watcher);
249
277
  if (step.assertFieldType !== undefined && field.type !== step.assertFieldType)
250
278
  throw new Error(
251
279
  `Unexpected field type: expected ${step.assertFieldType} but got ${field.type} for the field name ${step.field}`,
@@ -254,12 +282,11 @@ export class PlTreeResource implements ResourceDataWithFinalState {
254
282
  const ret = {} as ValueAndError<SignedResourceId>;
255
283
  if (isNotNullSignedResourceId(field.value)) ret.value = field.value;
256
284
  if (isNotNullSignedResourceId(field.error)) ret.error = field.error;
257
- if (ret.value === undefined && ret.error === undefined)
285
+ if (ret.value === undefined && ret.error === undefined && !this._finalState)
258
286
  // this method returns value and error of the field, thus those values are considered to be accessed;
259
287
  // any existing but not resolved field here is considered to be unstable, in the sense it is
260
- // considered to acquire some resolved value eventually
288
+ // considered to acquire some resolved value eventually; on a final resource it never will
261
289
  onUnstable("field_not_resolved:" + step.field);
262
- field.change.attachWatcher(watcher);
263
290
  return ret;
264
291
  }
265
292
  }
@@ -267,14 +294,14 @@ export class PlTreeResource implements ResourceDataWithFinalState {
267
294
  public getInputsLocked(watcher: Watcher): boolean {
268
295
  if (!this.inputsLocked)
269
296
  // reverse transition can't happen, so there is no reason to wait for value to change
270
- this.resourceStateChange?.attachWatcher(watcher);
297
+ this.lockedChange?.attachWatcher(watcher);
271
298
  return this.inputsLocked;
272
299
  }
273
300
 
274
301
  public getOutputsLocked(watcher: Watcher): boolean {
275
302
  if (!this.outputsLocked)
276
303
  // reverse transition can't happen, so there is no reason to wait for value to change
277
- this.resourceStateChange?.attachWatcher(watcher);
304
+ this.lockedChange?.attachWatcher(watcher);
278
305
  return this.outputsLocked;
279
306
  }
280
307
 
@@ -328,10 +355,12 @@ export class PlTreeResource implements ResourceDataWithFinalState {
328
355
  return ret;
329
356
  }
330
357
 
358
+ /** Fields that are neither Input, Output nor Service. Service fields are input-like (the
359
+ * backend refuses them once inputs are locked) and are listed by {@link listInputFields}. */
331
360
  public listDynamicFields(watcher: Watcher): string[] {
332
361
  const ret: string[] = [];
333
362
  this.fieldsMap.forEach((field, name) => {
334
- if (field.type !== "Input" && field.type !== "Output") ret.push(name);
363
+ if (!isInputLike(field.type) && field.type !== "Output") ret.push(name);
335
364
  });
336
365
  this.dynamicFieldListChanged?.attachWatcher(watcher);
337
366
 
@@ -397,12 +426,24 @@ export class PlTreeResource implements ResourceDataWithFinalState {
397
426
  };
398
427
  }
399
428
 
400
- /** Called when {@link FinalResourceDataPredicate} returns true for the state. */
429
+ /** Called when {@link FinalResourceDataPredicate} returns true for the state.
430
+ *
431
+ * Every change source it retires is fired first: a reader attached to one of them read a
432
+ * state that could still change, and is re-run to read it as final. */
401
433
  markFinal() {
402
434
  if (this._finalState) return;
403
435
 
404
436
  this._finalState = true;
405
437
  notEmpty(this.finalChanged).markChanged("marked final");
438
+ // Field sources are kept (a final resource's fields are still read), but a reader of an
439
+ // unresolved one read it as unstable, and must be re-run to read it as permanent.
440
+ this.fieldsMap.forEach((field) => field.change.markChanged("marked final"));
441
+ this.resourceStateChange?.markChanged("marked final");
442
+ this.lockedChange?.markChanged("marked final");
443
+ this.inputAndServiceFieldListChanged?.markChanged("marked final");
444
+ this.outputFieldListChanged?.markChanged("marked final");
445
+ this.dynamicFieldListChanged?.markChanged("marked final");
446
+ this.kvChangedPerKey?.markAllChanged("marked final");
406
447
  this.finalChanged = undefined;
407
448
  this.resourceStateChange = undefined;
408
449
  this.dynamicFieldListChanged = undefined;
@@ -502,9 +543,28 @@ export class PlTreeState {
502
543
  return res;
503
544
  }
504
545
 
546
+ /** Applies a batch of resource bodies to the mirror.
547
+ *
548
+ * Any error leaves the tree invalidated and surfaces as a {@link TreeStateUpdateError}: the
549
+ * batch is applied resource by resource, so a throw part way through has already mutated the
550
+ * mirror and fired watchers, and only a rebuild restores a state some poll confirmed. */
505
551
  updateFromResourceData(
506
552
  resourceData: ExtendedResourceData[],
507
553
  opts: { allowOrphanInputs?: boolean; stat?: ResourceUpdateStat } = {},
554
+ ) {
555
+ try {
556
+ this.applyResourceData(resourceData, opts);
557
+ } catch (e: unknown) {
558
+ const message = e instanceof Error ? e.message : String(e);
559
+ if (this._isValid) this.invalidateTree(message);
560
+ if (e instanceof TreeStateUpdateError) throw e;
561
+ throw new TreeStateUpdateError(`tree update failed: ${message}`, { cause: e });
562
+ }
563
+ }
564
+
565
+ private applyResourceData(
566
+ resourceData: ExtendedResourceData[],
567
+ opts: { allowOrphanInputs?: boolean; stat?: ResourceUpdateStat },
508
568
  ) {
509
569
  const { allowOrphanInputs = false, stat } = opts;
510
570
  this.checkValid();
@@ -565,7 +625,7 @@ export class PlTreeState {
565
625
  let resource = this.resources.get(rd.id);
566
626
  const held = resource !== undefined;
567
627
  let changed = false;
568
- // Structural/metadata change (new or removed field). type and kind are readonly, so
628
+ // Structural/metadata change (a field added, removed or retyped). type and kind are readonly, so
569
629
  // they never change; this flag isolates value/flag-only changes from real metadata churn.
570
630
  let metadataChanged = false;
571
631
 
@@ -650,7 +710,7 @@ export class PlTreeState {
650
710
  if (isNotNullSignedResourceId(fd.value)) incrementRefs.push(fd.value);
651
711
  if (isNotNullSignedResourceId(fd.error)) incrementRefs.push(fd.error);
652
712
 
653
- if (fd.type === "Input" || fd.type === "Service") {
713
+ if (isInputLike(fd.type)) {
654
714
  if (resource.inputsLocked)
655
715
  unexpectedTransitionError(
656
716
  `adding ${fd.type} (${fd.name}) field while inputs locked`,
@@ -680,36 +740,31 @@ export class PlTreeState {
680
740
  } else {
681
741
  // change of old field
682
742
 
683
- // in principle this transition is possible, see assertions below
743
+ // A Dynamic or MTW field deleted and recreated under the same name between two
744
+ // polls looks like a type change. Any other type is append-only on the backend.
684
745
  if (field.type !== fd.type) {
685
- if (field.type !== "Dynamic")
746
+ if (!isRecreatable(field.type))
686
747
  unexpectedTransitionError(`field changed type ${field.type} -> ${fd.type}`);
687
- notEmpty(resource.dynamicFieldListChanged).markChanged(
688
- `field ${fd.name} changed type from Dynamic to ${fd.type} in ${resourceIdToString(resource.id)}`,
689
- );
690
- if (field.type === "Input" || field.type === "Service") {
748
+ const reason = `field ${fd.name} changed type ${field.type} -> ${fd.type} in ${resourceIdToString(resource.id)}`;
749
+ // the old list: both recreatable types are listed as dynamic
750
+ notEmpty(resource.dynamicFieldListChanged).markChanged(reason);
751
+ if (isInputLike(fd.type)) {
691
752
  if (resource.inputsLocked)
692
753
  unexpectedTransitionError(
693
- `adding input field "${fd.name}", while corresponding list is locked`,
754
+ `adding ${fd.type} field "${fd.name}", while inputs are locked`,
694
755
  );
695
- notEmpty(resource.inputAndServiceFieldListChanged).markChanged(
696
- `field ${fd.name} changed to type ${fd.type} in ${resourceIdToString(resource.id)}`,
697
- );
698
- }
699
- if (field.type === "Output") {
756
+ notEmpty(resource.inputAndServiceFieldListChanged).markChanged(reason);
757
+ } else if (fd.type === "Output") {
700
758
  if (resource.outputsLocked)
701
759
  unexpectedTransitionError(
702
- `adding output field "${fd.name}", while corresponding list is locked`,
760
+ `adding output field "${fd.name}", while outputs are locked`,
703
761
  );
704
- notEmpty(resource.outputFieldListChanged).markChanged(
705
- `field ${fd.name} changed to type ${fd.type} in ${resourceIdToString(resource.id)}`,
706
- );
762
+ notEmpty(resource.outputFieldListChanged).markChanged(reason);
707
763
  }
708
764
  field.type = fd.type;
709
- field.change.markChanged(
710
- `field ${fd.name} type changed to ${fd.type} in ${resourceIdToString(resource.id)}`,
711
- );
765
+ field.change.markChanged(reason);
712
766
  changed = true;
767
+ metadataChanged = true;
713
768
  }
714
769
 
715
770
  // field value
@@ -766,6 +821,7 @@ export class PlTreeState {
766
821
  `dynamic field ${fieldName} removed from ${resourceIdToString(resource!.id)}`,
767
822
  );
768
823
  fields.delete(fieldName);
824
+ changed = true;
769
825
  metadataChanged = true;
770
826
  if (stat) stat.fieldsRemoved++;
771
827
 
@@ -817,6 +873,12 @@ export class PlTreeState {
817
873
  if (stat) stat.readyFlips++;
818
874
  }
819
875
 
876
+ // backend final flag: informational, no reader watches it
877
+ if (resource.finalFlag !== rd.final) {
878
+ resource.finalFlag = rd.final;
879
+ changed = true;
880
+ }
881
+
820
882
  // syncing kv. Same lockstep walk as the fields above, for the same reason: kv keys
821
883
  // arrive in a stable order and are freshly decoded strings. The walk is only set up
822
884
  // when there is something to compare against, so a resource with no kv allocates no
@@ -1023,23 +1085,50 @@ export class PlTreeState {
1023
1085
  // is enough — ordinary refcounting keeps it alive and will collect it later.
1024
1086
  if (res.refCount > 0) continue;
1025
1087
 
1026
- // collect the (now-unprotected) root itself and seed the cascade with the refs it holds
1027
- const seed: SignedResourceId[] = [];
1028
- res.fieldsMap.forEach((field) => {
1029
- if (isNotNullSignedResourceId(field.value)) seed.push(field.value);
1030
- if (isNotNullSignedResourceId(field.error)) seed.push(field.error);
1031
- field.change.markChanged(
1032
- `field ${field.name} removed after root ${resourceIdToString(res.id)} left the root set`,
1033
- );
1034
- });
1035
- if (isNotNullSignedResourceId(res.error)) seed.push(res.error);
1036
- res.resourceRemoved.markChanged(
1037
- `resource removed after leaving the root set: ${resourceIdToString(res.id)}`,
1038
- );
1039
- this.resources.delete(rid);
1088
+ this.removeUnreferenced(res, `root ${resourceIdToString(res.id)} left the root set`);
1089
+ }
1090
+ }
1040
1091
 
1041
- this.collectGarbage(seed);
1092
+ /** Roots held in the heap and not final: the ones whose existence a poll must check, since
1093
+ * a walk seeded at a deleted resource returns nothing rather than an error. A final root is
1094
+ * never re-read, so it is not checked either. */
1095
+ public nonFinalRoots(): SignedResourceId[] {
1096
+ const ret: SignedResourceId[] = [];
1097
+ for (const rid of this.roots) {
1098
+ const res = this.resources.get(rid);
1099
+ if (res !== undefined && !res.finalState) ret.push(rid);
1042
1100
  }
1101
+ return ret;
1102
+ }
1103
+
1104
+ /** Drops from the heap a root the backend has deleted, with the subtree only it held, and
1105
+ * notifies its readers. The id stays in the root set: a resource id is never reused, so the
1106
+ * root stays absent and readers see "not found". Returns false, changing nothing, if the
1107
+ * root is not held or is still referenced from elsewhere in the heap; a root is protected
1108
+ * from the refcount GC, so it is retried once its referrers are gone (the next call, or the
1109
+ * caller's next pass). */
1110
+ public dropDeletedRoot(rid: SignedResourceId): boolean {
1111
+ this.checkValid();
1112
+ const res = this.resources.get(rid);
1113
+ if (res === undefined || res.refCount > 0) return false;
1114
+ this.removeUnreferenced(res, `root ${resourceIdToString(rid)} deleted on the backend`);
1115
+ return true;
1116
+ }
1117
+
1118
+ /** Removes a resource nothing in the heap references and cascades the refcount GC into
1119
+ * what it referenced. */
1120
+ private removeUnreferenced(res: PlTreeResource, reason: string) {
1121
+ const seed: SignedResourceId[] = [];
1122
+ res.fieldsMap.forEach((field) => {
1123
+ if (isNotNullSignedResourceId(field.value)) seed.push(field.value);
1124
+ if (isNotNullSignedResourceId(field.error)) seed.push(field.error);
1125
+ field.change.markChanged(`field ${field.name} removed: ${reason}`);
1126
+ });
1127
+ if (isNotNullSignedResourceId(res.error)) seed.push(res.error);
1128
+ res.resourceRemoved.markChanged(`resource removed: ${reason}`);
1129
+ this.resources.delete(res.id);
1130
+
1131
+ this.collectGarbage(seed);
1043
1132
  }
1044
1133
 
1045
1134
  /** @deprecated use "entry" instead */
@@ -1057,6 +1146,8 @@ export class PlTreeState {
1057
1146
  this._isValid = false;
1058
1147
  this.invalidationMessage = msg;
1059
1148
  this.rootsChanged.markChanged("tree invalidated");
1149
+ // readers whose last run found no resource wait on this, and must learn the tree is gone
1150
+ this.resourcesAdded.markChanged("tree invalidated");
1060
1151
  this.resources.forEach((res) => {
1061
1152
  res.markAllChanged();
1062
1153
  });
@@ -0,0 +1,264 @@
1
+ // Notification completeness, as a property: random legal update sequences on one resource,
2
+ // with the readers defined below attached before each update. Any reader whose answer (value
3
+ // or throw, plus stability for plain field reads) differs after the update must have been
4
+ // notified.
5
+ // Sequence n is seeded with n + 1; a failure reports the seed and step of the first
6
+ // violation per reader. MODEL_RUNS sets the number of sequences (default 200; raise it when
7
+ // hunting).
8
+ import { expect, test } from "vitest";
9
+ import type { Watcher } from "@milaboratories/computable";
10
+ import type { FieldData, FieldType } from "@milaboratories/pl-client";
11
+ import {
12
+ createSignedResourceId,
13
+ DefaultFinalResourceDataPredicate,
14
+ NullSignedResourceId,
15
+ } from "@milaboratories/pl-client";
16
+ import type { ExtendedResourceData } from "./state";
17
+ import { PlTreeState } from "./state";
18
+ import {
19
+ field,
20
+ InitialStructuralResourceState,
21
+ TestDynamicRootId1,
22
+ TestDynamicRootState1,
23
+ TestValueResourceState1,
24
+ dField,
25
+ } from "./test_utils";
26
+
27
+ class W implements Watcher {
28
+ isChanged = false;
29
+ markChanged() {
30
+ this.isChanged = true;
31
+ }
32
+ }
33
+ const R = createSignedResourceId(10n);
34
+ const V = [createSignedResourceId(20n), createSignedResourceId(21n)];
35
+ const E = createSignedResourceId(30n);
36
+ const RUNS = Number(process.env.MODEL_RUNS ?? 200);
37
+ const STEPS = 25;
38
+
39
+ function rng(seed: number) {
40
+ let s = seed >>> 0 || 1;
41
+ return () => {
42
+ s ^= s << 13;
43
+ s >>>= 0;
44
+ s ^= s >> 17;
45
+ s ^= s << 5;
46
+ s >>>= 0;
47
+ return s / 4294967296;
48
+ };
49
+ }
50
+
51
+ type St = {
52
+ typeName: string;
53
+ il: boolean;
54
+ ol: boolean;
55
+ ready: boolean;
56
+ err: boolean;
57
+ final: boolean;
58
+ fields: Map<string, { type: FieldType; v: number }>;
59
+ kv: Map<string, string>;
60
+ removed: Set<string>;
61
+ };
62
+
63
+ function body(st: St): ExtendedResourceData {
64
+ const fields: FieldData[] = [...st.fields.entries()].map(([n, f]) =>
65
+ field(f.type, n, f.v < 0 ? NullSignedResourceId : V[f.v]!, NullSignedResourceId, f.v >= 0),
66
+ );
67
+ return {
68
+ ...InitialStructuralResourceState,
69
+ id: R,
70
+ type: { name: st.typeName, version: "1" },
71
+ inputsLocked: st.il,
72
+ outputsLocked: st.ol,
73
+ resourceReady: st.ready,
74
+ error: st.err ? E : NullSignedResourceId,
75
+ final: st.final,
76
+ fields,
77
+ kv: [...st.kv.entries()].map(([key, v]) => ({ key, value: Buffer.from(v) })),
78
+ };
79
+ }
80
+
81
+ const NAMES = ["a", "b", "c", "d"];
82
+ const TYPES: FieldType[] = ["Input", "Output", "Service", "Dynamic", "MTW"];
83
+
84
+ function mutate(st: St, r: () => number): void {
85
+ const pick = <T>(xs: T[]) => xs[Math.floor(r() * xs.length)]!;
86
+ const op = Math.floor(r() * 9);
87
+ const name = pick(NAMES);
88
+ const canAdd = (t: FieldType) =>
89
+ t === "Input" || t === "Service" ? !st.il : t === "Output" ? !st.ol : true;
90
+ switch (op) {
91
+ case 0: {
92
+ if (!st.fields.has(name)) {
93
+ const t = pick(TYPES);
94
+ if (canAdd(t)) st.fields.set(name, { type: t, v: -1 });
95
+ }
96
+ break;
97
+ }
98
+ case 1: {
99
+ const f = st.fields.get(name);
100
+ if (f && (f.type === "Dynamic" || f.type === "MTW")) {
101
+ st.fields.delete(name);
102
+ if (r() < 0.6) {
103
+ const t = pick(TYPES);
104
+ if (canAdd(t)) st.fields.set(name, { type: t, v: -1 });
105
+ }
106
+ }
107
+ break;
108
+ }
109
+ case 2: {
110
+ const f = st.fields.get(name);
111
+ if (f && (f.v < 0 || f.type === "Dynamic" || f.type === "MTW")) f.v = Math.floor(r() * 2);
112
+ break;
113
+ }
114
+ case 3:
115
+ st.il = true;
116
+ break;
117
+ case 4:
118
+ if (st.il) st.ol = true;
119
+ break;
120
+ case 5:
121
+ if (st.il) st.ready = true;
122
+ break;
123
+ case 6:
124
+ st.kv.set(pick(["k1", "k2"]), String(Math.floor(r() * 3)));
125
+ break;
126
+ case 7:
127
+ if (r() < 0.3) st.err = true;
128
+ break;
129
+ case 8:
130
+ if (r() < 0.3) st.final = true;
131
+ break;
132
+ }
133
+ }
134
+
135
+ type Reader = { name: string; read: (t: PlTreeState, w: W) => string };
136
+ const readers: Reader[] = [
137
+ { name: "inputsLocked", read: (t, w) => String(t.get(new W(), R).getInputsLocked(w)) },
138
+ { name: "outputsLocked", read: (t, w) => String(t.get(new W(), R).getOutputsLocked(w)) },
139
+ { name: "readyOrError", read: (t, w) => String(t.get(new W(), R).getIsReadyOrError(w)) },
140
+ { name: "error", read: (t, w) => String(t.get(new W(), R).getError(w)) },
141
+ { name: "isFinal", read: (t, w) => String(t.get(new W(), R).getIsFinal(w)) },
142
+ { name: "listInput", read: (t, w) => t.get(new W(), R).listInputFields(w).sort().join() },
143
+ { name: "listOutput", read: (t, w) => t.get(new W(), R).listOutputFields(w).sort().join() },
144
+ { name: "listDynamic", read: (t, w) => t.get(new W(), R).listDynamicFields(w).sort().join() },
145
+ ...["k1", "k2"].map(
146
+ (k): Reader => ({
147
+ name: "kv:" + k,
148
+ read: (t, w) => String(t.get(new W(), R).getKeyValueString(w, k)),
149
+ }),
150
+ ),
151
+ ...NAMES.flatMap((n): Reader[] => [
152
+ {
153
+ name: "get:" + n,
154
+ read: (t, w) => {
155
+ const u: string[] = [];
156
+ const v = t.get(new W(), R).getField(w, n, (m) => u.push(m));
157
+ return JSON.stringify(v) + (u.length ? "|unstable" : "");
158
+ },
159
+ },
160
+ {
161
+ name: "req:" + n,
162
+ read: (t, w) =>
163
+ JSON.stringify(
164
+ t.get(new W(), R).getField(w, { field: n, errorIfFieldNotFound: true }, () => {}),
165
+ ),
166
+ },
167
+ ...(["Input", "Output", "Dynamic"] as const).map(
168
+ (ty): Reader => ({
169
+ name: `assert${ty}:${n}`,
170
+ read: (t, w) =>
171
+ JSON.stringify(
172
+ t
173
+ .get(new W(), R)
174
+ .getField(
175
+ w,
176
+ { field: n, assertFieldType: ty, allowPermanentAbsence: true },
177
+ () => {},
178
+ ),
179
+ ),
180
+ }),
181
+ ),
182
+ ]),
183
+ ];
184
+ function safeRead(rd: Reader, t: PlTreeState, w: W): string {
185
+ try {
186
+ return rd.read(t, w);
187
+ } catch (e) {
188
+ return "THROW:" + (e instanceof Error ? e.message.replace(/\d+/g, "#") : String(e));
189
+ }
190
+ }
191
+
192
+ test("every modeled reader whose answer changes is notified", () => {
193
+ const violations = new Map<string, number>();
194
+ const example = new Map<string, string>();
195
+ let invalidations = 0;
196
+ for (let run = 0; run < RUNS; run++) {
197
+ const seed = run + 1;
198
+ const r = rng(seed);
199
+ const t = new PlTreeState(TestDynamicRootId1, DefaultFinalResourceDataPredicate);
200
+ const st: St = {
201
+ typeName: r() < 0.5 ? "StdMap" : "UserProject",
202
+ il: false,
203
+ ol: false,
204
+ ready: false,
205
+ err: false,
206
+ final: false,
207
+ fields: new Map(),
208
+ kv: new Map(),
209
+ removed: new Set(),
210
+ };
211
+ const rootB = {
212
+ ...TestDynamicRootState1,
213
+ fields: [dField("r", R), dField("v0", V[0]), dField("v1", V[1]), dField("e", E)],
214
+ };
215
+ const vals = [...V, E].map((id) => ({
216
+ ...TestValueResourceState1,
217
+ id,
218
+ data: Buffer.from("x"),
219
+ }));
220
+ t.updateFromResourceData([rootB, ...vals, body(st)]);
221
+ for (let step = 0; step < STEPS; step++) {
222
+ const res = t.get(new W(), R);
223
+ if (res.finalState) break;
224
+ const before = readers.map((rd) => {
225
+ const w = new W();
226
+ return { rd, w, v: safeRead(rd, t, w) };
227
+ });
228
+ const prev = JSON.stringify(body(st));
229
+ mutate(st, r);
230
+ if (JSON.stringify(body(st)) === prev) continue;
231
+ try {
232
+ t.updateFromResourceData([body(st)]);
233
+ } catch (e) {
234
+ invalidations++;
235
+ if (!example.has("INVALIDATED"))
236
+ example.set("INVALIDATED", `seed ${seed} step ${step}: ${String(e).slice(0, 200)}`);
237
+ break;
238
+ }
239
+ for (const b of before) {
240
+ const after = safeRead(b.rd, t, new W());
241
+ // Oracle exclusion, per the contract at getField's locked branch ("stable absence of
242
+ // field"): an asserted-type read (in practice Input/Output, whose lists lock) that
243
+ // answered an absence stays right when a field of another type takes the name.
244
+ const stableAbsence =
245
+ b.rd.name.startsWith("assert") &&
246
+ String(b.v) === "undefined" &&
247
+ String(after).startsWith("THROW:Unexpected field type");
248
+ if (String(after) !== String(b.v) && !b.w.isChanged && !stableAbsence) {
249
+ const key = b.rd.name.replace(/:[a-d]$/, ":*");
250
+ violations.set(key, (violations.get(key) ?? 0) + 1);
251
+ if (!example.has(key)) example.set(key, `seed ${seed} step ${step}: ${b.v} -> ${after}`);
252
+ }
253
+ }
254
+ }
255
+ }
256
+ expect(
257
+ { invalidations, violations: [...violations], example: [...example] },
258
+ `first failure per reader (seed, step): ${[...example.values()].join("; ")}`,
259
+ ).toEqual({
260
+ invalidations: 0,
261
+ violations: [],
262
+ example: [],
263
+ });
264
+ });
package/src/sync.ts CHANGED
@@ -167,6 +167,8 @@ export type TreeLoadingStat = ResourceUpdateStat & {
167
167
  /** Delta path: extra rounds spent resolving references a delta body pointed at but the
168
168
  * response did not carry. */
169
169
  deltaResolutionRounds: number;
170
+ /** Roots dropped from the mirror because they are absent from the backend. */
171
+ rootsDropped: number;
170
172
  /** Delta path: polls that sent a token and got back a response the size of the whole
171
173
  * mirror, which is what a refused token looks like from here - rejection is silent, so this
172
174
  * is the only tell. Heuristic: a genuinely large change set trips it too. */
@@ -197,6 +199,7 @@ export function initialTreeLoadingStat(): TreeLoadingStat {
197
199
  deltaSeedsSent: 0,
198
200
  deltaResolutionRounds: 0,
199
201
  deltaSuspectedFullAnswers: 0,
202
+ rootsDropped: 0,
200
203
  resourcesNew: 0,
201
204
  resourcesChanged: 0,
202
205
  resourcesUnchanged: 0,
@@ -404,9 +407,9 @@ async function processResourceTreeStream(
404
407
  resourceReady: frame.resourceReady,
405
408
  error: frame.error,
406
409
  originalResourceId: frame.originalResourceId,
407
- // traverseWasStopped: backend matched traverse stop rules — children were not streamed.
408
- // Mark as terminal; fields are resolved below.
409
- final: frame.final || frame.traverseWasStopped,
410
+ // The backend's flag as sent. A stopped traversal (children not streamed) is handled
411
+ // through the fields below, not by claiming the backend marked the resource final.
412
+ final: frame.final,
410
413
  inputsLocked: frame.inputsLocked,
411
414
  outputsLocked: frame.outputsLocked,
412
415
  fields: frame.fields,