@milaboratories/pl-tree 1.3.6

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 ADDED
@@ -0,0 +1,681 @@
1
+ import {
2
+ BasicResourceData,
3
+ FieldType,
4
+ isNotNullResourceId,
5
+ isNullResourceId,
6
+ KeyValue,
7
+ NullResourceId,
8
+ OptionalResourceId,
9
+ ResourceData,
10
+ ResourceId,
11
+ resourceIdToString,
12
+ ResourceKind,
13
+ ResourceType,
14
+ stringifyWithResourceId
15
+ } from '@milaboratories/pl-client';
16
+ import { ChangeSource, Watcher } from '@milaboratories/computable';
17
+ import { PlTreeEntry } from './accessors';
18
+ import { ValueAndError } from './value_and_error';
19
+ import { MiLogger, notEmpty } from '@milaboratories/ts-helpers';
20
+ import { FieldTraversalStep, GetFieldStep, ResourceTraversalOps } from './traversal_ops';
21
+
22
+ export type ExtendedResourceData = ResourceData & {
23
+ kv: KeyValue[];
24
+ };
25
+
26
+ export class TreeStateUpdateError extends Error {
27
+ constructor(message: string) {
28
+ super(message);
29
+ }
30
+ }
31
+
32
+ class PlTreeField {
33
+ readonly change = new ChangeSource();
34
+
35
+ constructor(
36
+ public type: FieldType,
37
+ public value: OptionalResourceId,
38
+ public error: OptionalResourceId,
39
+ /** Last version of resource this field was observed, used to garbage collect fields in tree patching procedure */
40
+ public resourceVersion: number
41
+ ) {}
42
+ }
43
+
44
+ const InitialResourceVersion = 0;
45
+
46
+ const decoder = new TextDecoder();
47
+
48
+ /** Interface of PlTreeResource exposed to outer world, like {@link FinalPredicate}. */
49
+ export interface PlTreeResourceI extends BasicResourceData {
50
+ readonly final: boolean;
51
+ }
52
+
53
+ /** Predicate of resource state used to determine if it's state is considered to be final,
54
+ * and not expected to change in the future. */
55
+ export type FinalPredicate = (r: Omit<PlTreeResourceI, 'final'>) => boolean;
56
+
57
+ /** Never store instances of this class, always get fresh instance from {@link PlTreeState} */
58
+ export class PlTreeResource implements PlTreeResourceI {
59
+ /** Tracks number of other resources referencing this resource. Used to perform garbage collection in tree patching procedure */
60
+ refCount: number = 0;
61
+
62
+ /** Increments each time resource is checked for difference with new state */
63
+ version: number = InitialResourceVersion;
64
+ /** Set to resource version when resource state, or it's fields have changed */
65
+ dataVersion: number = InitialResourceVersion;
66
+
67
+ readonly fields: Map<string, PlTreeField> = new Map();
68
+
69
+ readonly kv = new Map<string, Uint8Array>();
70
+
71
+ readonly resourceRemoved = new ChangeSource();
72
+
73
+ // following change source are removed when resource is marked as final
74
+
75
+ finalChanged? = new ChangeSource();
76
+
77
+ resourceStateChange? = new ChangeSource();
78
+
79
+ lockedChange? = new ChangeSource();
80
+ inputAndServiceFieldListChanged? = new ChangeSource();
81
+ outputFieldListChanged? = new ChangeSource();
82
+ dynamicFieldListChanged? = new ChangeSource();
83
+
84
+ kvChanged? = new ChangeSource();
85
+
86
+ readonly id: ResourceId;
87
+ originalResourceId: OptionalResourceId;
88
+
89
+ readonly kind: ResourceKind;
90
+ readonly type: ResourceType;
91
+
92
+ readonly data?: Uint8Array;
93
+ private dataAsString?: string;
94
+ private dataAsJson?: unknown;
95
+
96
+ error: OptionalResourceId;
97
+
98
+ inputsLocked: boolean;
99
+ outputsLocked: boolean;
100
+ resourceReady: boolean;
101
+ finalFlag: boolean;
102
+
103
+ /** Set externally by the tree, using {@link FinalPredicate} */
104
+ _final: boolean = false;
105
+
106
+ private readonly logger?: MiLogger;
107
+
108
+ constructor(initialState: BasicResourceData, logger?: MiLogger) {
109
+ this.id = initialState.id;
110
+ this.originalResourceId = initialState.originalResourceId;
111
+ this.kind = initialState.kind;
112
+ this.type = initialState.type;
113
+ this.data = initialState.data;
114
+ this.error = initialState.error;
115
+ this.inputsLocked = initialState.inputsLocked;
116
+ this.outputsLocked = initialState.outputsLocked;
117
+ this.resourceReady = initialState.resourceReady;
118
+ this.finalFlag = initialState.final;
119
+ this.logger = logger;
120
+ }
121
+
122
+ // TODO add logging
123
+
124
+ private info(msg: string) {
125
+ if (this.logger !== undefined) this.logger.info(msg);
126
+ }
127
+
128
+ private warn(msg: string) {
129
+ if (this.logger !== undefined) this.logger.warn(msg);
130
+ }
131
+
132
+ get final(): boolean {
133
+ return this._final;
134
+ }
135
+
136
+ public getField(
137
+ watcher: Watcher,
138
+ _step:
139
+ | (Omit<GetFieldStep, 'errorIfFieldNotFound'> & { errorIfFieldNotFound: true })
140
+ | (Omit<GetFieldStep, 'errorIfFieldNotSet'> & { errorIfFieldNotSet: true }),
141
+ onUnstable: (marker: string) => void
142
+ ): ValueAndError<ResourceId>;
143
+ public getField(
144
+ watcher: Watcher,
145
+ _step: string | GetFieldStep,
146
+ onUnstable: (marker: string) => void
147
+ ): ValueAndError<ResourceId> | undefined;
148
+ public getField(
149
+ watcher: Watcher,
150
+ _step: string | GetFieldStep,
151
+ onUnstable: (marker: string) => void = () => {}
152
+ ): ValueAndError<ResourceId> | undefined {
153
+ const step: FieldTraversalStep = typeof _step === 'string' ? { field: _step } : _step;
154
+
155
+ const field = this.fields.get(step.field);
156
+ if (field === undefined) {
157
+ if (step.errorIfFieldNotFound || step.errorIfFieldNotSet)
158
+ throw new Error(
159
+ `Field "${step.field}" not found in resource ${resourceIdToString(this.id)}`
160
+ );
161
+
162
+ if (!this.inputsLocked) this.inputAndServiceFieldListChanged?.attachWatcher(watcher);
163
+ else if (step.assertFieldType === 'Service' || step.assertFieldType === 'Input') {
164
+ if (step.allowPermanentAbsence)
165
+ // stable absence of field
166
+ return undefined;
167
+ else throw new Error(`Service or input field not found ${step.field}.`);
168
+ }
169
+
170
+ if (!this.outputsLocked) this.outputFieldListChanged?.attachWatcher(watcher);
171
+ else if (step.assertFieldType === 'Output') {
172
+ if (step.allowPermanentAbsence)
173
+ // stable absence of field
174
+ return undefined;
175
+ else throw new Error(`Output field not found ${step.field}.`);
176
+ }
177
+
178
+ this.dynamicFieldListChanged?.attachWatcher(watcher);
179
+ if (!this._final && !step.stableIfNotFound) onUnstable('field_not_found:' + step.field);
180
+
181
+ return undefined;
182
+ } else {
183
+ if (step.assertFieldType !== undefined && field.type !== step.assertFieldType)
184
+ throw new Error(
185
+ `Unexpected field type: expected ${step.assertFieldType} but got ${field.type} for the field name ${step.field}`
186
+ );
187
+
188
+ const ret = {} as ValueAndError<ResourceId>;
189
+ if (isNotNullResourceId(field.value)) ret.value = field.value;
190
+ if (isNotNullResourceId(field.error)) ret.error = field.error;
191
+ if (ret.value === undefined && ret.error === undefined)
192
+ // this method returns value and error of the field, thus those values are considered to be accessed;
193
+ // any existing but not resolved field here is considered to be unstable, in the sence it is
194
+ // considered to acquire some resolved value eventually
195
+ onUnstable('field_not_resolved:' + step.field);
196
+ field.change.attachWatcher(watcher);
197
+ return ret;
198
+ }
199
+ }
200
+
201
+ public getInputsLocked(watcher: Watcher): boolean {
202
+ if (!this.inputsLocked)
203
+ // reverse transition can't happen, so there is no reason to wait for value to change
204
+ this.resourceStateChange?.attachWatcher(watcher);
205
+ return this.inputsLocked;
206
+ }
207
+
208
+ public getOutputsLocked(watcher: Watcher): boolean {
209
+ if (!this.outputsLocked)
210
+ // reverse transition can't happen, so there is no reason to wait for value to change
211
+ this.resourceStateChange?.attachWatcher(watcher);
212
+ return this.outputsLocked;
213
+ }
214
+
215
+ public get isReadyOrError(): boolean {
216
+ return (
217
+ this.error !== NullResourceId ||
218
+ this.resourceReady ||
219
+ this.originalResourceId !== NullResourceId
220
+ );
221
+ }
222
+
223
+ public getIsFinal(watcher: Watcher): boolean {
224
+ this.finalChanged?.attachWatcher(watcher);
225
+ return this._final;
226
+ }
227
+
228
+ public getIsReadyOrError(watcher: Watcher): boolean {
229
+ if (!this.isReadyOrError)
230
+ // reverse transition can't happen, so there is no reason to wait for value to change if it is already true
231
+ this.resourceStateChange?.attachWatcher(watcher);
232
+ return this.isReadyOrError;
233
+ }
234
+
235
+ public getError(watcher: Watcher): ResourceId | undefined {
236
+ if (isNullResourceId(this.error)) {
237
+ this.resourceStateChange?.attachWatcher(watcher);
238
+ return undefined;
239
+ } else {
240
+ // reverse transition can't happen, so there is no reason to wait for value to change, if error already set
241
+ return this.error;
242
+ }
243
+ }
244
+
245
+ public listInputFields(watcher: Watcher): string[] {
246
+ const ret: string[] = [];
247
+ this.fields.forEach((field, name) => {
248
+ if (field.type === 'Input' || field.type === 'Service') ret.push(name);
249
+ });
250
+ if (!this.inputsLocked) this.inputAndServiceFieldListChanged?.attachWatcher(watcher);
251
+
252
+ return ret;
253
+ }
254
+
255
+ public listOutputFields(watcher: Watcher): string[] {
256
+ const ret: string[] = [];
257
+ this.fields.forEach((field, name) => {
258
+ if (field.type === 'Output') ret.push(name);
259
+ });
260
+ if (!this.outputsLocked) this.outputFieldListChanged?.attachWatcher(watcher);
261
+
262
+ return ret;
263
+ }
264
+
265
+ public listDynamicFields(watcher: Watcher): string[] {
266
+ const ret: string[] = [];
267
+ this.fields.forEach((field, name) => {
268
+ if (field.type !== 'Input' && field.type !== 'Output') ret.push(name);
269
+ });
270
+ this.dynamicFieldListChanged?.attachWatcher(watcher);
271
+
272
+ return ret;
273
+ }
274
+
275
+ public getKeyValue(watcher: Watcher, key: string): Uint8Array | undefined {
276
+ this.kvChanged?.attachWatcher(watcher);
277
+ return this.kv.get(key);
278
+ }
279
+
280
+ public getKeyValueString(watcher: Watcher, key: string): string | undefined {
281
+ const bytes = this.getKeyValue(watcher, key);
282
+ if (bytes === undefined) return undefined;
283
+ return decoder.decode(bytes);
284
+ }
285
+
286
+ public getDataAsString(): string | undefined {
287
+ if (this.data === undefined) return undefined;
288
+ if (this.dataAsString === undefined) this.dataAsString = decoder.decode(this.data);
289
+ return this.dataAsString;
290
+ }
291
+
292
+ public getDataAsJson<T = unknown>(): T | undefined {
293
+ if (this.data === undefined) return undefined;
294
+ if (this.dataAsJson === undefined) this.dataAsJson = JSON.parse(this.getDataAsString()!);
295
+ return this.dataAsJson as T;
296
+ }
297
+
298
+ verifyReadyState() {
299
+ if (this.resourceReady && !this.inputsLocked)
300
+ throw new Error(`ready without input or output lock: ${stringifyWithResourceId(this.state)}`);
301
+ }
302
+
303
+ get state(): BasicResourceData {
304
+ return {
305
+ id: this.id,
306
+ kind: this.kind,
307
+ type: this.type,
308
+ data: this.data,
309
+ resourceReady: this.resourceReady,
310
+ inputsLocked: this.inputsLocked,
311
+ outputsLocked: this.outputsLocked,
312
+ error: this.error,
313
+ originalResourceId: this.originalResourceId,
314
+ final: this.finalFlag
315
+ };
316
+ }
317
+
318
+ markFinal() {
319
+ if (this._final) return;
320
+
321
+ this._final = true;
322
+ notEmpty(this.finalChanged).markChanged();
323
+ this.finalChanged = undefined;
324
+ this.resourceStateChange = undefined;
325
+ this.dynamicFieldListChanged = undefined;
326
+ this.inputAndServiceFieldListChanged = undefined;
327
+ this.outputFieldListChanged = undefined;
328
+ this.lockedChange = undefined;
329
+ }
330
+
331
+ /** Used for invalidation */
332
+ markAllChanged() {
333
+ this.fields.forEach((field) => field.change.markChanged());
334
+ this.finalChanged?.markChanged();
335
+ this.resourceStateChange?.markChanged();
336
+ this.lockedChange?.markChanged();
337
+ this.inputAndServiceFieldListChanged?.markChanged();
338
+ this.outputFieldListChanged?.markChanged();
339
+ this.dynamicFieldListChanged?.markChanged();
340
+ this.kvChanged?.markChanged();
341
+ this.resourceRemoved.markChanged();
342
+ }
343
+ }
344
+
345
+ // TODO implement invalidate tree
346
+ // TODO make invalid state permanent
347
+ // TODO invalidate on update errors
348
+ export class PlTreeState {
349
+ /** resource heap */
350
+ private resources: Map<ResourceId, PlTreeResource> = new Map();
351
+ private readonly resourcesAdded = new ChangeSource();
352
+ /** Resets to false if any invalid state transitions are registered,
353
+ * after that tree will produce errors for any read or write operations */
354
+ private _isValid: boolean = true;
355
+ private invalidationMessage?: string;
356
+
357
+ constructor(
358
+ /** This will be the only resource not deleted during GC round */
359
+ public readonly root: ResourceId,
360
+ public readonly isFinalPredicate: FinalPredicate = (r) => false
361
+ ) {}
362
+
363
+ public forEachResource(cb: (res: PlTreeResourceI) => void): void {
364
+ this.resources.forEach((v) => cb(v));
365
+ }
366
+
367
+ private checkValid() {
368
+ if (!this._isValid) throw new Error(this.invalidationMessage ?? 'tree is in invalid state');
369
+ }
370
+
371
+ public get(watcher: Watcher, rid: ResourceId): PlTreeResource {
372
+ this.checkValid();
373
+ const res = this.resources.get(rid);
374
+ if (res === undefined) {
375
+ // to make recovery from resource not found possible, considering some
376
+ // race conditions, where computable is created before tree is updated
377
+ this.resourcesAdded.attachWatcher(watcher);
378
+ throw new Error(`resource ${resourceIdToString(rid)} not found in the tree`);
379
+ }
380
+ res.resourceRemoved.attachWatcher(watcher);
381
+ return res;
382
+ }
383
+
384
+ updateFromResourceData(resourceData: ExtendedResourceData[], allowOrphanInputs: boolean = false) {
385
+ this.checkValid();
386
+
387
+ // All resources for which recount should be incremented, first are aggregated in this list
388
+ const incrementRefs: ResourceId[] = [];
389
+ const decrementRefs: ResourceId[] = [];
390
+
391
+ // patching / creating resources
392
+ for (const rd of resourceData) {
393
+ let resource = this.resources.get(rd.id);
394
+
395
+ const statBeforeMutation = resource?.state;
396
+ const unexpectedTransitionError = (reason: string): never => {
397
+ const { fields, ...rdWithoutFields } = rd;
398
+ this.invalidateTree();
399
+ throw new TreeStateUpdateError(
400
+ `Unexpected resource state transition (${reason}): ${stringifyWithResourceId(
401
+ rdWithoutFields
402
+ )} -> ${stringifyWithResourceId(statBeforeMutation)}`
403
+ );
404
+ };
405
+
406
+ if (resource !== undefined) {
407
+ // updating existing resource
408
+
409
+ if (resource.final)
410
+ unexpectedTransitionError('resource state can\t be updated after it is marked as final');
411
+
412
+ let changed = false;
413
+ // updating resource version, even if it was not changed
414
+ resource.version += 1;
415
+
416
+ // duplicate / original
417
+ if (resource.originalResourceId !== rd.originalResourceId) {
418
+ if (resource.originalResourceId !== NullResourceId)
419
+ unexpectedTransitionError("originalResourceId can't change after it is set");
420
+ resource.originalResourceId = rd.originalResourceId;
421
+ // duplicate status of the resource counts as ready for the external observer
422
+ notEmpty(resource.resourceStateChange).markChanged();
423
+ changed = true;
424
+ }
425
+
426
+ // error
427
+ if (resource.error !== rd.error) {
428
+ if (isNotNullResourceId(resource.error))
429
+ unexpectedTransitionError("resource can't change attached error after it is set");
430
+ resource.error = rd.error;
431
+ incrementRefs.push(resource.error as ResourceId);
432
+ notEmpty(resource.resourceStateChange).markChanged();
433
+ changed = true;
434
+ }
435
+
436
+ // updating fields
437
+ for (const fd of rd.fields) {
438
+ let field = resource.fields.get(fd.name);
439
+
440
+ if (!field) {
441
+ // new field
442
+
443
+ field = new PlTreeField(fd.type, fd.value, fd.error, resource.version);
444
+ if (isNotNullResourceId(fd.value)) incrementRefs.push(fd.value);
445
+ if (isNotNullResourceId(fd.error)) incrementRefs.push(fd.error);
446
+
447
+ if (fd.type === 'Input' || fd.type === 'Service') {
448
+ if (resource.inputsLocked)
449
+ unexpectedTransitionError(
450
+ `adding ${fd.type} (${fd.name}) field while inputs locked`
451
+ );
452
+ notEmpty(resource.inputAndServiceFieldListChanged).markChanged();
453
+ } else if (fd.type === 'Output') {
454
+ if (resource.outputsLocked)
455
+ unexpectedTransitionError(
456
+ `adding ${fd.type} (${fd.name}) field while outputs locked`
457
+ );
458
+ notEmpty(resource.outputFieldListChanged).markChanged();
459
+ } else {
460
+ notEmpty(resource.dynamicFieldListChanged).markChanged();
461
+ }
462
+
463
+ resource.fields.set(fd.name, field);
464
+
465
+ changed = true;
466
+ } else {
467
+ // change of old field
468
+
469
+ // in principle this transition is possible, see assertions below
470
+ if (field.type !== fd.type) {
471
+ if (field.type !== 'Dynamic')
472
+ unexpectedTransitionError(`field changed type ${field.type} -> ${fd.type}`);
473
+ notEmpty(resource.dynamicFieldListChanged).markChanged();
474
+ if (field.type === 'Input' || field.type === 'Service') {
475
+ if (resource.inputsLocked)
476
+ unexpectedTransitionError(
477
+ `adding input field "${fd.name}", while corresponding list is locked`
478
+ );
479
+ notEmpty(resource.inputAndServiceFieldListChanged).markChanged();
480
+ }
481
+ if (field.type === 'Output') {
482
+ if (resource.outputsLocked)
483
+ unexpectedTransitionError(
484
+ `adding output field "${fd.name}", while corresponding list is locked`
485
+ );
486
+ notEmpty(resource.outputFieldListChanged).markChanged();
487
+ }
488
+ field.type = fd.type;
489
+ field.change.markChanged();
490
+ changed = true;
491
+ }
492
+
493
+ // field value
494
+ if (field.value !== fd.value) {
495
+ if (isNotNullResourceId(field.value)) decrementRefs.push(field.value);
496
+ field.value = fd.value;
497
+ if (isNotNullResourceId(fd.value)) incrementRefs.push(fd.value);
498
+ field.change.markChanged();
499
+ changed = true;
500
+ }
501
+
502
+ // field error
503
+ if (field.error !== fd.error) {
504
+ if (isNotNullResourceId(field.error)) decrementRefs.push(field.error);
505
+ field.error = fd.error;
506
+ if (isNotNullResourceId(fd.error)) incrementRefs.push(fd.error);
507
+ field.change.markChanged();
508
+ changed = true;
509
+ }
510
+
511
+ field.resourceVersion = resource.version;
512
+ }
513
+ }
514
+
515
+ // detecting removed fields
516
+ resource.fields.forEach((field, fieldName, fields) => {
517
+ if (field.resourceVersion !== resource!.version) {
518
+ if (field.type === 'Input' || field.type === 'Service' || field.type === 'Output')
519
+ unexpectedTransitionError(`removal of ${field.type} field ${fieldName}`);
520
+ field.change.markChanged();
521
+ fields.delete(fieldName);
522
+
523
+ if (isNotNullResourceId(field.value)) decrementRefs.push(field.value);
524
+ if (isNotNullResourceId(field.error)) decrementRefs.push(field.error);
525
+
526
+ notEmpty(resource!.dynamicFieldListChanged).markChanged();
527
+ }
528
+ });
529
+
530
+ // inputsLocked
531
+ if (resource.inputsLocked !== rd.inputsLocked) {
532
+ if (resource.inputsLocked) unexpectedTransitionError('inputs unlocking is not permitted');
533
+ resource.inputsLocked = rd.inputsLocked;
534
+ notEmpty(resource.lockedChange).markChanged();
535
+ changed = true;
536
+ }
537
+
538
+ // outputsLocked
539
+ if (resource.outputsLocked !== rd.outputsLocked) {
540
+ if (resource.outputsLocked)
541
+ unexpectedTransitionError('outputs unlocking is not permitted');
542
+ resource.outputsLocked = rd.outputsLocked;
543
+ notEmpty(resource.lockedChange).markChanged();
544
+ changed = true;
545
+ }
546
+
547
+ // ready flag
548
+ if (resource.resourceReady !== rd.resourceReady) {
549
+ const readyStateBefore = resource.resourceReady;
550
+ resource.resourceReady = rd.resourceReady;
551
+ resource.verifyReadyState();
552
+ if (!resource.isReadyOrError)
553
+ unexpectedTransitionError(
554
+ `resource can't lose it's ready or error state (ready state before ${readyStateBefore})`
555
+ );
556
+ notEmpty(resource.resourceStateChange).markChanged();
557
+ changed = true;
558
+ }
559
+
560
+ // syncing kv
561
+ let kvChanged = false;
562
+ for (const kv of rd.kv) {
563
+ const current = resource.kv.get(kv.key);
564
+ if (current === undefined) {
565
+ resource.kv.set(kv.key, kv.value);
566
+ kvChanged = true;
567
+ } else if (Buffer.compare(current, kv.value) !== 0) {
568
+ resource.kv.set(kv.key, kv.value);
569
+ kvChanged = true;
570
+ }
571
+ }
572
+
573
+ if (resource.kv.size > rd.kv.length) {
574
+ // only it this case it makes sense to check for deletions
575
+ const newStateKeys = new Set(rd.kv.map((kv) => kv.key));
576
+
577
+ // deleting keys not present in resource anymore
578
+ resource.kv.forEach((value, key, map) => {
579
+ if (!newStateKeys.has(key)) map.delete(key);
580
+ });
581
+
582
+ kvChanged = true;
583
+ }
584
+
585
+ if (kvChanged) notEmpty(resource.kvChanged).markChanged();
586
+
587
+ if (changed) {
588
+ // if resource was changed, updating resource data version
589
+ resource.dataVersion = resource.version;
590
+ if (this.isFinalPredicate(resource)) resource.markFinal();
591
+ }
592
+ } else {
593
+ // creating new resource
594
+
595
+ resource = new PlTreeResource(rd);
596
+ resource.verifyReadyState();
597
+ if (isNotNullResourceId(resource.error)) incrementRefs.push(resource.error);
598
+ for (const fd of rd.fields) {
599
+ const field = new PlTreeField(fd.type, fd.value, fd.error, InitialResourceVersion);
600
+ if (isNotNullResourceId(fd.value)) incrementRefs.push(fd.value);
601
+ if (isNotNullResourceId(fd.error)) incrementRefs.push(fd.error);
602
+ resource.fields.set(fd.name, field);
603
+ }
604
+
605
+ // adding kv
606
+ for (const kv of rd.kv) resource.kv.set(kv.key, kv.value);
607
+
608
+ // adding the resource to the heap
609
+ this.resources.set(resource.id, resource);
610
+ this.resourcesAdded.markChanged();
611
+ }
612
+ }
613
+
614
+ // applying refCount increments
615
+ for (const rid of incrementRefs) {
616
+ const res = this.resources.get(rid);
617
+ if (!res) {
618
+ this.invalidateTree();
619
+ throw new TreeStateUpdateError(`orphan resource ${rid}`);
620
+ }
621
+ res.refCount++;
622
+ }
623
+
624
+ // recursively applying refCount decrements / doing garbage collection
625
+ let currentRefs = decrementRefs;
626
+ while (currentRefs.length > 0) {
627
+ const nextRefs: ResourceId[] = [];
628
+ for (const rid of currentRefs) {
629
+ const res = this.resources.get(rid);
630
+ if (!res) {
631
+ this.invalidateTree();
632
+ throw new TreeStateUpdateError(`orphan resource ${rid}`);
633
+ }
634
+ res.refCount--;
635
+
636
+ // garbage collection
637
+ if (res.refCount === 0 && res.id !== this.root) {
638
+ // removing fields
639
+ res.fields.forEach((field) => {
640
+ if (isNotNullResourceId(field.value)) nextRefs.push(field.value);
641
+ if (isNotNullResourceId(field.error)) nextRefs.push(field.error);
642
+ field.change.markChanged();
643
+ });
644
+ if (isNotNullResourceId(res.error)) nextRefs.push(res.error);
645
+ res.resourceRemoved.markChanged();
646
+ this.resources.delete(rid);
647
+ }
648
+ }
649
+ currentRefs = nextRefs;
650
+ }
651
+
652
+ // checking for orphans (maybe removed in the future)
653
+ if (!allowOrphanInputs) {
654
+ for (const rd of resourceData) {
655
+ if (!this.resources.has(rd.id)) {
656
+ this.invalidateTree();
657
+ throw new TreeStateUpdateError(`orphan input resource ${rd.id}`);
658
+ }
659
+ }
660
+ }
661
+ }
662
+
663
+ /** @deprecated use "entry" instead */
664
+ public accessor(rid: ResourceId = this.root): PlTreeEntry {
665
+ this.checkValid();
666
+ return this.entry(rid);
667
+ }
668
+
669
+ public entry(rid: ResourceId = this.root): PlTreeEntry {
670
+ this.checkValid();
671
+ return new PlTreeEntry({ treeProvider: () => this }, rid);
672
+ }
673
+
674
+ public invalidateTree(msg?: string) {
675
+ this._isValid = false;
676
+ this.invalidationMessage = msg;
677
+ this.resources.forEach((res) => {
678
+ res.markAllChanged();
679
+ });
680
+ }
681
+ }