@milaboratories/pl-tree 1.15.7 → 1.16.1
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/accessors.cjs +6 -2
- package/dist/accessors.cjs.map +1 -1
- package/dist/accessors.d.ts.map +1 -1
- package/dist/accessors.js +6 -2
- package/dist/accessors.js.map +1 -1
- package/dist/delta_sync.cjs +18 -8
- package/dist/delta_sync.cjs.map +1 -1
- package/dist/delta_sync.js +18 -8
- package/dist/delta_sync.js.map +1 -1
- package/dist/state.cjs +108 -30
- package/dist/state.cjs.map +1 -1
- package/dist/state.d.ts +27 -2
- package/dist/state.d.ts.map +1 -1
- package/dist/state.js +108 -30
- package/dist/state.js.map +1 -1
- package/dist/sync.cjs +2 -1
- package/dist/sync.cjs.map +1 -1
- package/dist/sync.d.ts +2 -0
- package/dist/sync.d.ts.map +1 -1
- package/dist/sync.js +2 -1
- package/dist/sync.js.map +1 -1
- package/dist/synchronized_tree.cjs +46 -9
- package/dist/synchronized_tree.cjs.map +1 -1
- package/dist/synchronized_tree.d.ts +6 -0
- package/dist/synchronized_tree.d.ts.map +1 -1
- package/dist/synchronized_tree.js +47 -10
- package/dist/synchronized_tree.js.map +1 -1
- package/package.json +4 -4
- package/src/accessors.test.ts +108 -0
- package/src/accessors.ts +8 -3
- package/src/delta_sync.test.ts +88 -3
- package/src/delta_sync.ts +19 -8
- package/src/state.test.ts +397 -2
- package/src/state.ts +137 -46
- package/src/state_notifications.model.test.ts +264 -0
- package/src/sync.ts +6 -3
- package/src/synchronized_tree.test.ts +550 -1
- package/src/synchronized_tree.ts +102 -20
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.
|
|
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.
|
|
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
|
|
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 (
|
|
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
|
|
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
|
-
//
|
|
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
|
|
746
|
+
if (!isRecreatable(field.type))
|
|
686
747
|
unexpectedTransitionError(`field changed type ${field.type} -> ${fd.type}`);
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
);
|
|
690
|
-
if (
|
|
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
|
|
754
|
+
`adding ${fd.type} field "${fd.name}", while inputs are locked`,
|
|
694
755
|
);
|
|
695
|
-
notEmpty(resource.inputAndServiceFieldListChanged).markChanged(
|
|
696
|
-
|
|
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
|
|
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
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
408
|
-
//
|
|
409
|
-
final: frame.final
|
|
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,
|