@verdant-web/store 4.2.0 → 4.4.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/dist/bundle/index.js +9 -7
- package/dist/bundle/index.js.map +4 -4
- package/dist/esm/__tests__/entities.test.js +438 -5
- package/dist/esm/__tests__/entities.test.js.map +1 -1
- package/dist/esm/__tests__/fixtures/testStorage.d.ts +1 -0
- package/dist/esm/__tests__/fixtures/testStorage.js +1 -1
- package/dist/esm/__tests__/fixtures/testStorage.js.map +1 -1
- package/dist/esm/client/Client.js +2 -1
- package/dist/esm/client/Client.js.map +1 -1
- package/dist/esm/entities/Entity.d.ts +22 -3
- package/dist/esm/entities/Entity.js +210 -38
- package/dist/esm/entities/Entity.js.map +1 -1
- package/dist/esm/entities/Entity.test.js +4 -3
- package/dist/esm/entities/Entity.test.js.map +1 -1
- package/dist/esm/entities/EntityCache.d.ts +6 -4
- package/dist/esm/entities/EntityCache.js +18 -7
- package/dist/esm/entities/EntityCache.js.map +1 -1
- package/dist/esm/entities/EntityMetadata.d.ts +8 -2
- package/dist/esm/entities/EntityMetadata.js +52 -4
- package/dist/esm/entities/EntityMetadata.js.map +1 -1
- package/dist/esm/entities/EntityStore.d.ts +4 -4
- package/dist/esm/entities/EntityStore.js +8 -10
- package/dist/esm/entities/EntityStore.js.map +1 -1
- package/dist/esm/entities/OperationBatcher.d.ts +1 -1
- package/dist/esm/entities/OperationBatcher.js +2 -0
- package/dist/esm/entities/OperationBatcher.js.map +1 -1
- package/dist/esm/files/EntityFile.d.ts +6 -1
- package/dist/esm/files/EntityFile.js +11 -4
- package/dist/esm/files/EntityFile.js.map +1 -1
- package/dist/esm/files/FileManager.d.ts +3 -1
- package/dist/esm/files/FileManager.js +2 -2
- package/dist/esm/files/FileManager.js.map +1 -1
- package/dist/esm/utils/versions.js +1 -1
- package/package.json +2 -2
- package/src/__tests__/entities.test.ts +471 -5
- package/src/__tests__/fixtures/testStorage.ts +1 -1
- package/src/client/Client.ts +6 -1
- package/src/entities/Entity.test.ts +8 -7
- package/src/entities/Entity.ts +239 -29
- package/src/entities/EntityCache.ts +20 -10
- package/src/entities/EntityMetadata.ts +69 -9
- package/src/entities/EntityStore.ts +15 -12
- package/src/entities/OperationBatcher.ts +16 -2
- package/src/files/EntityFile.ts +16 -3
- package/src/files/FileManager.ts +7 -3
- package/src/utils/versions.ts +1 -1
package/src/entities/Entity.ts
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
compareRefs,
|
|
11
11
|
createFileRef,
|
|
12
12
|
createRef,
|
|
13
|
+
createSubOid,
|
|
13
14
|
getChildFieldSchema,
|
|
14
15
|
getFieldDefault,
|
|
15
16
|
hasDefault,
|
|
@@ -17,6 +18,8 @@ import {
|
|
|
17
18
|
isFileRef,
|
|
18
19
|
isNullable,
|
|
19
20
|
isObject,
|
|
21
|
+
isObjectRef,
|
|
22
|
+
isPrimitive,
|
|
20
23
|
isRef,
|
|
21
24
|
maybeGetOid,
|
|
22
25
|
memoByKeys,
|
|
@@ -24,6 +27,7 @@ import {
|
|
|
24
27
|
validateEntityField,
|
|
25
28
|
} from '@verdant-web/common';
|
|
26
29
|
import { Context } from '../context/context.js';
|
|
30
|
+
import { CHILD_FILE_CHANGED } from '../files/EntityFile.js';
|
|
27
31
|
import { FileManager } from '../files/FileManager.js';
|
|
28
32
|
import { processValueFiles } from '../files/utils.js';
|
|
29
33
|
import { ClientWithCollections, EntityFile } from '../index.js';
|
|
@@ -111,6 +115,7 @@ export class Entity<
|
|
|
111
115
|
files,
|
|
112
116
|
storeEvents,
|
|
113
117
|
deleteSelf,
|
|
118
|
+
fieldPath,
|
|
114
119
|
}: EntityInit) {
|
|
115
120
|
super();
|
|
116
121
|
|
|
@@ -121,10 +126,12 @@ export class Entity<
|
|
|
121
126
|
this.ctx = ctx;
|
|
122
127
|
this.files = files;
|
|
123
128
|
this.schema = schema;
|
|
129
|
+
this.fieldPath = fieldPath || [];
|
|
124
130
|
this.entityFamily =
|
|
125
131
|
childCache ||
|
|
126
132
|
new EntityCache({
|
|
127
133
|
initial: [this],
|
|
134
|
+
ctx,
|
|
128
135
|
});
|
|
129
136
|
this.metadataFamily = metadataFamily;
|
|
130
137
|
this.storeEvents = storeEvents;
|
|
@@ -212,8 +219,8 @@ export class Entity<
|
|
|
212
219
|
return null as any;
|
|
213
220
|
}
|
|
214
221
|
|
|
215
|
-
|
|
216
|
-
assignOid(
|
|
222
|
+
let newView: any = this.isList ? [] : {};
|
|
223
|
+
assignOid(newView, this.oid);
|
|
217
224
|
|
|
218
225
|
if (Array.isArray(rawView)) {
|
|
219
226
|
const schema = getChildFieldSchema(this.schema, 0);
|
|
@@ -242,7 +249,7 @@ export class Entity<
|
|
|
242
249
|
|
|
243
250
|
// this item will be pruned.
|
|
244
251
|
} else {
|
|
245
|
-
|
|
252
|
+
newView.push(child);
|
|
246
253
|
}
|
|
247
254
|
}
|
|
248
255
|
}
|
|
@@ -270,10 +277,10 @@ export class Entity<
|
|
|
270
277
|
);
|
|
271
278
|
if (this.schema.type === 'map') {
|
|
272
279
|
// it's valid to prune here if it's a map
|
|
273
|
-
|
|
280
|
+
newView = {};
|
|
274
281
|
} else {
|
|
275
282
|
// otherwise prune moves upward
|
|
276
|
-
|
|
283
|
+
newView = null;
|
|
277
284
|
}
|
|
278
285
|
break;
|
|
279
286
|
}
|
|
@@ -286,23 +293,29 @@ export class Entity<
|
|
|
286
293
|
'key:',
|
|
287
294
|
key,
|
|
288
295
|
);
|
|
289
|
-
/**
|
|
290
|
-
* PRUNE - this is a prune point. we can't continue
|
|
291
|
-
* to render this data. If this is a map, we can ignore
|
|
292
|
-
* this value. Otherwise we must prune upward.
|
|
293
|
-
* This exits the loop.
|
|
294
|
-
*/
|
|
295
296
|
if (this.schema.type !== 'map') {
|
|
296
|
-
|
|
297
|
+
/**
|
|
298
|
+
* PRUNE - this is a prune point. we can't continue
|
|
299
|
+
* to render this data. If this is a map, we can ignore
|
|
300
|
+
* this value. Otherwise we must prune upward.
|
|
301
|
+
* This exits the loop.
|
|
302
|
+
*/
|
|
303
|
+
newView = null;
|
|
297
304
|
break;
|
|
298
305
|
}
|
|
299
306
|
} else {
|
|
300
|
-
|
|
307
|
+
// special case - rewrite undefined fields to null
|
|
308
|
+
if (isNullable(schema) && child === undefined) {
|
|
309
|
+
newView[key] = null;
|
|
310
|
+
} else {
|
|
311
|
+
newView[key] = child;
|
|
312
|
+
}
|
|
301
313
|
}
|
|
302
314
|
}
|
|
303
315
|
}
|
|
304
316
|
|
|
305
|
-
|
|
317
|
+
this.cachedView = newView;
|
|
318
|
+
return newView;
|
|
306
319
|
}
|
|
307
320
|
|
|
308
321
|
private childIsNull = (child: any) => {
|
|
@@ -325,6 +338,33 @@ export class Entity<
|
|
|
325
338
|
return !!this.validate();
|
|
326
339
|
}
|
|
327
340
|
|
|
341
|
+
/**
|
|
342
|
+
* Returns true if this or any child is invalid (pruned)
|
|
343
|
+
*/
|
|
344
|
+
get deepInvalid(): boolean {
|
|
345
|
+
if (this.invalid) return true;
|
|
346
|
+
if (Array.isArray(this.rawView)) {
|
|
347
|
+
for (let i = 0; i < this.rawView.length; i++) {
|
|
348
|
+
if (isObjectRef(this.rawView[i])) {
|
|
349
|
+
const child = this.getChild(i, this.rawView[i].id);
|
|
350
|
+
if (child.deepInvalid) {
|
|
351
|
+
return true;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
} else if (isObject(this.rawView)) {
|
|
356
|
+
for (const key in this.rawView) {
|
|
357
|
+
if (isObjectRef(this.rawView[key])) {
|
|
358
|
+
const child = this.getChild(key, this.rawView[key].id);
|
|
359
|
+
if (child.deepInvalid) {
|
|
360
|
+
return true;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
return false;
|
|
366
|
+
}
|
|
367
|
+
|
|
328
368
|
get isList() {
|
|
329
369
|
// have to turn TS off here as our two interfaces both implement
|
|
330
370
|
// const values for this boolean.
|
|
@@ -418,7 +458,7 @@ export class Entity<
|
|
|
418
458
|
field: this.schema,
|
|
419
459
|
value: this.rawView,
|
|
420
460
|
fieldPath: this.fieldPath,
|
|
421
|
-
|
|
461
|
+
expectRefs: true,
|
|
422
462
|
}) ?? undefined;
|
|
423
463
|
return this.validationError;
|
|
424
464
|
},
|
|
@@ -456,6 +496,37 @@ export class Entity<
|
|
|
456
496
|
}
|
|
457
497
|
};
|
|
458
498
|
|
|
499
|
+
private rawViewWithMappedChildren = (
|
|
500
|
+
mapper: (child: Entity | EntityFile) => any,
|
|
501
|
+
) => {
|
|
502
|
+
const view = this.rawView;
|
|
503
|
+
if (!view) {
|
|
504
|
+
return null;
|
|
505
|
+
}
|
|
506
|
+
if (Array.isArray(view)) {
|
|
507
|
+
const mapped = view.map((value, i) => {
|
|
508
|
+
if (isRef(value)) {
|
|
509
|
+
return mapper(this.getChild(i, value.id));
|
|
510
|
+
} else {
|
|
511
|
+
return value;
|
|
512
|
+
}
|
|
513
|
+
});
|
|
514
|
+
assignOid(mapped, this.oid);
|
|
515
|
+
return mapped;
|
|
516
|
+
} else {
|
|
517
|
+
const mapped = Object.entries(view).reduce((acc, [key, value]) => {
|
|
518
|
+
if (isRef(value)) {
|
|
519
|
+
acc[key as any] = mapper(this.getChild(key, value.id));
|
|
520
|
+
} else {
|
|
521
|
+
acc[key as any] = value;
|
|
522
|
+
}
|
|
523
|
+
return acc;
|
|
524
|
+
}, {} as any);
|
|
525
|
+
assignOid(mapped, this.oid);
|
|
526
|
+
return mapped;
|
|
527
|
+
}
|
|
528
|
+
};
|
|
529
|
+
|
|
459
530
|
/**
|
|
460
531
|
* A current snapshot of this Entity's data, including nested
|
|
461
532
|
* Entities.
|
|
@@ -464,10 +535,62 @@ export class Entity<
|
|
|
464
535
|
return this.viewWithMappedChildren((child) => child.getSnapshot());
|
|
465
536
|
};
|
|
466
537
|
|
|
538
|
+
/**
|
|
539
|
+
* A snapshot of this Entity with unpruned (invalid) data. This will
|
|
540
|
+
* not conform to the entity schema and should be used carefully.
|
|
541
|
+
*
|
|
542
|
+
* Can be used to inspect or recover invalid, pruned data not
|
|
543
|
+
* otherwise accessible.
|
|
544
|
+
*/
|
|
545
|
+
getUnprunedSnapshot = (): any => {
|
|
546
|
+
return this.rawViewWithMappedChildren((child) => {
|
|
547
|
+
if (child instanceof EntityFile) return child.getSnapshot();
|
|
548
|
+
return child.getUnprunedSnapshot();
|
|
549
|
+
});
|
|
550
|
+
};
|
|
551
|
+
|
|
467
552
|
// change management methods (internal use only)
|
|
468
553
|
private addPendingOperations = (operations: Operation[]) => {
|
|
469
|
-
this.ctx.log(
|
|
554
|
+
this.ctx.log(
|
|
555
|
+
'debug',
|
|
556
|
+
'Entity: adding pending operations',
|
|
557
|
+
this.oid,
|
|
558
|
+
operations,
|
|
559
|
+
);
|
|
470
560
|
|
|
561
|
+
// special case -- if this entity is pruned, any changes we apply to it
|
|
562
|
+
// will be in relation to 'exposed' pruned data, not the 'real world'
|
|
563
|
+
// data that's backing it. That means those changes will produce unexpected
|
|
564
|
+
// or further invalid results. To avoid this, we basically stamp in the
|
|
565
|
+
// pruned version of this entity before proceeding.
|
|
566
|
+
//
|
|
567
|
+
// as an example of a failure mode without this check, consider a list:
|
|
568
|
+
// [1, 2, <pruned>, 4, 5]
|
|
569
|
+
// the user sees: [1, 2, 4, 5]
|
|
570
|
+
// when they try to replace the item at index 2 with "0" (they see "4"), they
|
|
571
|
+
// actually replace the invisible pruned item, resulting in [1, 2, 0, 4, 5]
|
|
572
|
+
// being the result when they expected [1, 2, 0, 5].
|
|
573
|
+
//
|
|
574
|
+
// To "stamp" the data before applying user changes, we diff the snapshot
|
|
575
|
+
// (which is the pruned version) with the current state of the entity.
|
|
576
|
+
if (this.deepInvalid) {
|
|
577
|
+
this.ctx.log(
|
|
578
|
+
'warn',
|
|
579
|
+
'Changes are being applied to a pruned entity. This means that the pruned version is being treated as the new baseline and any pruned invalid data is lost.',
|
|
580
|
+
this.oid,
|
|
581
|
+
);
|
|
582
|
+
this.canonizePrunedVersion();
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
this.applyPendingOperations(operations);
|
|
586
|
+
};
|
|
587
|
+
|
|
588
|
+
// naming is fuzzy here, but this method was split out from
|
|
589
|
+
// addPendingOperations since that method also conditionally canonizes
|
|
590
|
+
// the pruned snapshot, and I wanted to keep the actual insertion of
|
|
591
|
+
// the ops in one place, so leaving it as part of addPendingOperations
|
|
592
|
+
// would introduce infinite recursion when canonizing.
|
|
593
|
+
private applyPendingOperations = (operations: Operation[]) => {
|
|
471
594
|
// apply authz to all operations
|
|
472
595
|
if (this.access) {
|
|
473
596
|
for (const op of operations) {
|
|
@@ -481,6 +604,18 @@ export class Entity<
|
|
|
481
604
|
}
|
|
482
605
|
};
|
|
483
606
|
|
|
607
|
+
private getPruneDiff = () => {
|
|
608
|
+
const snapshot = this.getSnapshot();
|
|
609
|
+
const unprunedSnapshot = this.getUnprunedSnapshot();
|
|
610
|
+
return this.patchCreator.createDiff(unprunedSnapshot, snapshot, {
|
|
611
|
+
authz: this.access,
|
|
612
|
+
merge: false,
|
|
613
|
+
});
|
|
614
|
+
};
|
|
615
|
+
private canonizePrunedVersion = () => {
|
|
616
|
+
this.applyPendingOperations(this.getPruneDiff());
|
|
617
|
+
};
|
|
618
|
+
|
|
484
619
|
private addConfirmedData = (data: EntityStoreEventData) => {
|
|
485
620
|
this.ctx.log('debug', 'Entity: adding confirmed data', this.oid);
|
|
486
621
|
const changes = this.metadataFamily.addConfirmedData(data);
|
|
@@ -569,6 +704,10 @@ export class Entity<
|
|
|
569
704
|
this.emit('changeDeep', target, ev);
|
|
570
705
|
this.parent?.deepChange(target, ev);
|
|
571
706
|
};
|
|
707
|
+
[CHILD_FILE_CHANGED] = (file: EntityFile) => {
|
|
708
|
+
// consistent with prior behavior, but kind of arbitrary.
|
|
709
|
+
this.deepChange(this, { isLocal: false, oid: this.oid });
|
|
710
|
+
};
|
|
572
711
|
|
|
573
712
|
private getChild = (key: any, oid: ObjectIdentifier) => {
|
|
574
713
|
const schema = getChildFieldSchema(this.schema, key);
|
|
@@ -636,16 +775,44 @@ export class Entity<
|
|
|
636
775
|
const file = this.files.get(child.id, {
|
|
637
776
|
downloadRemote: !!fieldSchema.downloadRemote,
|
|
638
777
|
ctx: this.ctx,
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
// FIXME: this seems bad and inconsistent
|
|
642
|
-
file.subscribe('change', () => {
|
|
643
|
-
this.deepChange(this, { isLocal: false, oid: this.oid });
|
|
778
|
+
parent: this,
|
|
644
779
|
});
|
|
645
780
|
|
|
646
781
|
return file as KeyValue[Key];
|
|
647
782
|
} else {
|
|
648
|
-
|
|
783
|
+
const childEntity = this.getChild(key, child.id);
|
|
784
|
+
if (childEntity.deepInvalid) {
|
|
785
|
+
// this child is pruned. materialize a pruned version of
|
|
786
|
+
// this subtree if possible.
|
|
787
|
+
|
|
788
|
+
// special case: lists -- as long as the list itself
|
|
789
|
+
// is present and valid, it can omit invalid children
|
|
790
|
+
// selectively rather than fallback to an empty default
|
|
791
|
+
if (fieldSchema.type === 'array') {
|
|
792
|
+
return childEntity as KeyValue[Key];
|
|
793
|
+
}
|
|
794
|
+
// special case: maps -- similar to lists
|
|
795
|
+
if (fieldSchema.type === 'map') {
|
|
796
|
+
return childEntity as KeyValue[Key];
|
|
797
|
+
}
|
|
798
|
+
if (isNullable(fieldSchema)) {
|
|
799
|
+
return null as KeyValue[Key];
|
|
800
|
+
}
|
|
801
|
+
if (hasDefault(fieldSchema)) {
|
|
802
|
+
const unprunedSnapshot = childEntity.getUnprunedSnapshot();
|
|
803
|
+
const pruneDiff = this.patchCreator.createDiff(
|
|
804
|
+
unprunedSnapshot,
|
|
805
|
+
getFieldDefault(fieldSchema),
|
|
806
|
+
{
|
|
807
|
+
merge: false,
|
|
808
|
+
mergeUnknownObjects: true,
|
|
809
|
+
authz: this.access,
|
|
810
|
+
},
|
|
811
|
+
);
|
|
812
|
+
return this.processPrunedChild(key, childEntity, pruneDiff);
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
return childEntity as KeyValue[Key];
|
|
649
816
|
}
|
|
650
817
|
} else {
|
|
651
818
|
// if this is a Map type, a missing child is
|
|
@@ -663,16 +830,59 @@ export class Entity<
|
|
|
663
830
|
requireDefaults: true,
|
|
664
831
|
})
|
|
665
832
|
) {
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
833
|
+
if (hasDefault(fieldSchema)) {
|
|
834
|
+
// primitive fields with defaults are easy.
|
|
835
|
+
if (isPrimitive(fieldSchema)) {
|
|
836
|
+
return getFieldDefault(fieldSchema) as KeyValue[Key];
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
// object/list fields are hard.
|
|
840
|
+
const defaultValue = getFieldDefault(fieldSchema);
|
|
841
|
+
const prunedFieldOid = createSubOid(this.oid);
|
|
842
|
+
const pruneDiff = this.patchCreator.createInitialize(
|
|
843
|
+
defaultValue,
|
|
844
|
+
prunedFieldOid,
|
|
845
|
+
this.access,
|
|
846
|
+
);
|
|
847
|
+
pruneDiff.push(
|
|
848
|
+
...this.patchCreator.createSet(
|
|
849
|
+
this.oid,
|
|
850
|
+
key,
|
|
851
|
+
createRef(prunedFieldOid),
|
|
852
|
+
this.access,
|
|
853
|
+
),
|
|
854
|
+
);
|
|
855
|
+
const childEntity = this.getChild(key, prunedFieldOid);
|
|
856
|
+
return this.processPrunedChild(key, childEntity, pruneDiff);
|
|
857
|
+
} else {
|
|
858
|
+
// failure / hard prune: no way to represent this
|
|
859
|
+
// data in a valid way exists. the parent entity
|
|
860
|
+
// is also invalid and this should bubble up.
|
|
861
|
+
return undefined as KeyValue[Key];
|
|
862
|
+
}
|
|
671
863
|
}
|
|
672
864
|
return child as KeyValue[Key];
|
|
673
865
|
}
|
|
674
866
|
};
|
|
675
867
|
|
|
868
|
+
private processPrunedChild = (
|
|
869
|
+
key: any,
|
|
870
|
+
child: Entity,
|
|
871
|
+
pruneDiff: Operation[],
|
|
872
|
+
): any => {
|
|
873
|
+
this.ctx.log(
|
|
874
|
+
'warn',
|
|
875
|
+
'Replacing invalid child object field with ephemeral, valid data',
|
|
876
|
+
this.oid,
|
|
877
|
+
key,
|
|
878
|
+
);
|
|
879
|
+
const changes = this.metadataFamily.addEphemeralData(pruneDiff);
|
|
880
|
+
for (const change of changes) {
|
|
881
|
+
this.change(change);
|
|
882
|
+
}
|
|
883
|
+
return child as any;
|
|
884
|
+
};
|
|
885
|
+
|
|
676
886
|
/**
|
|
677
887
|
* Gets a value on this entity. If the value is not
|
|
678
888
|
* present, it will be set to the provided default
|
|
@@ -734,7 +944,7 @@ export class Entity<
|
|
|
734
944
|
});
|
|
735
945
|
}
|
|
736
946
|
}
|
|
737
|
-
return processValueFiles(value, this.files.add);
|
|
947
|
+
return processValueFiles(value, (file) => this.files.add(file, this));
|
|
738
948
|
};
|
|
739
949
|
|
|
740
950
|
private getDeleteMode = (key: any) => {
|
|
@@ -927,7 +1137,7 @@ export class Entity<
|
|
|
927
1137
|
this.addPendingOperations(
|
|
928
1138
|
this.patchCreator.createDiff(this.getSnapshot(), changes, {
|
|
929
1139
|
mergeUnknownObjects: !replaceSubObjects,
|
|
930
|
-
|
|
1140
|
+
merge,
|
|
931
1141
|
}),
|
|
932
1142
|
);
|
|
933
1143
|
};
|
|
@@ -1,24 +1,25 @@
|
|
|
1
|
-
import { Entity, EntityInit } from './Entity.js';
|
|
2
|
-
import { EntityFile } from '../files/EntityFile.js';
|
|
3
1
|
import { ObjectIdentifier } from '@verdant-web/common';
|
|
2
|
+
import { Context } from '../internal.js';
|
|
3
|
+
import { Entity, EntityInit } from './Entity.js';
|
|
4
4
|
|
|
5
5
|
export class EntityCache {
|
|
6
|
-
private
|
|
6
|
+
private ctx: Context;
|
|
7
|
+
private cache = new Map<string, WeakRef<Entity>>();
|
|
7
8
|
|
|
8
|
-
constructor({ initial }: { initial?: Entity[]
|
|
9
|
+
constructor({ initial, ctx }: { initial?: Entity[]; ctx: Context }) {
|
|
10
|
+
this.ctx = ctx;
|
|
9
11
|
if (initial) {
|
|
10
12
|
for (const entity of initial) {
|
|
11
|
-
this.cache.set(entity.oid, entity);
|
|
13
|
+
this.cache.set(entity.oid, ctx.weakRef(entity));
|
|
12
14
|
}
|
|
13
15
|
}
|
|
14
16
|
}
|
|
15
17
|
|
|
16
18
|
get = (init: EntityInit): Entity => {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
}
|
|
19
|
+
const cached = this.getCached(init.oid);
|
|
20
|
+
if (cached) return cached;
|
|
20
21
|
const entity = new Entity(init);
|
|
21
|
-
this.cache.set(init.oid, entity);
|
|
22
|
+
this.cache.set(init.oid, this.ctx.weakRef(entity));
|
|
22
23
|
return entity;
|
|
23
24
|
};
|
|
24
25
|
|
|
@@ -27,6 +28,15 @@ export class EntityCache {
|
|
|
27
28
|
};
|
|
28
29
|
|
|
29
30
|
getCached = (oid: string) => {
|
|
30
|
-
|
|
31
|
+
if (this.cache.has(oid)) {
|
|
32
|
+
const ref = this.cache.get(oid);
|
|
33
|
+
const derefed = ref?.deref();
|
|
34
|
+
if (derefed) {
|
|
35
|
+
return derefed as Entity;
|
|
36
|
+
} else {
|
|
37
|
+
this.cache.delete(oid);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return null;
|
|
31
41
|
};
|
|
32
42
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import {
|
|
2
|
+
AuthorizationKey,
|
|
2
3
|
DocumentBaseline,
|
|
3
4
|
ObjectIdentifier,
|
|
4
5
|
Operation,
|
|
@@ -20,7 +21,7 @@ export type EntityMetadataView = {
|
|
|
20
21
|
empty: boolean;
|
|
21
22
|
updatedAt: number;
|
|
22
23
|
latestTimestamp: string | null;
|
|
23
|
-
authz?:
|
|
24
|
+
authz?: AuthorizationKey;
|
|
24
25
|
};
|
|
25
26
|
|
|
26
27
|
export class EntityMetadata {
|
|
@@ -28,6 +29,14 @@ export class EntityMetadata {
|
|
|
28
29
|
private baseline: DocumentBaseline | null = null;
|
|
29
30
|
// these must be kept in timestamp order.
|
|
30
31
|
private confirmedOperations: Operation[] = [];
|
|
32
|
+
// operations applied locally, but not sent to persistence
|
|
33
|
+
// until 'committed' by pending operations. this powers the
|
|
34
|
+
// self-healing pruning system, which injects these ephemeral
|
|
35
|
+
// operations to materialize pruned 'fixed' data in place of
|
|
36
|
+
// 'real' invalid data so the user can keep using the app. as
|
|
37
|
+
// soon as the user makes modifications to the entity, this
|
|
38
|
+
// ephemeral pruned data is applied underneath it.
|
|
39
|
+
private ephemeralOperations?: Operation[] = [];
|
|
31
40
|
private pendingOperations: Operation[] = [];
|
|
32
41
|
readonly oid;
|
|
33
42
|
|
|
@@ -89,19 +98,30 @@ export class EntityMetadata {
|
|
|
89
98
|
authz = confirmedResult.authz;
|
|
90
99
|
}
|
|
91
100
|
|
|
101
|
+
const ephemeralResult =
|
|
102
|
+
!this.ephemeralOperations || omitPending
|
|
103
|
+
? confirmedResult
|
|
104
|
+
: this.applyOperations(
|
|
105
|
+
confirmedResult.view,
|
|
106
|
+
confirmedResult.deleted,
|
|
107
|
+
this.ephemeralOperations,
|
|
108
|
+
confirmedResult.latestTimestamp,
|
|
109
|
+
null,
|
|
110
|
+
);
|
|
111
|
+
|
|
92
112
|
const pendingResult = omitPending
|
|
93
113
|
? confirmedResult
|
|
94
114
|
: this.applyOperations(
|
|
95
|
-
|
|
96
|
-
|
|
115
|
+
ephemeralResult.view,
|
|
116
|
+
ephemeralResult.deleted,
|
|
97
117
|
// now we're applying pending operations
|
|
98
118
|
this.pendingOperations,
|
|
99
119
|
// keep our latest timestamp up to date
|
|
100
|
-
|
|
120
|
+
ephemeralResult.latestTimestamp,
|
|
101
121
|
// we don't use after for pending ops, they're all
|
|
102
122
|
// logically in the future
|
|
103
123
|
null,
|
|
104
|
-
|
|
124
|
+
);
|
|
105
125
|
if (pendingResult.authz) {
|
|
106
126
|
authz = pendingResult.authz;
|
|
107
127
|
}
|
|
@@ -199,11 +219,22 @@ export class EntityMetadata {
|
|
|
199
219
|
};
|
|
200
220
|
|
|
201
221
|
addPendingOperation = (operation: Operation) => {
|
|
202
|
-
// check to see if new operation supersedes the previous one
|
|
203
|
-
// we can assume pending ops are always newer
|
|
204
222
|
this.pendingOperations.push(operation);
|
|
205
223
|
};
|
|
206
224
|
|
|
225
|
+
addEphemeralOperation = (operation: Operation) => {
|
|
226
|
+
if (!this.ephemeralOperations) {
|
|
227
|
+
this.ephemeralOperations = [];
|
|
228
|
+
}
|
|
229
|
+
this.ephemeralOperations.push(operation);
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
clearEphemeralOperations = () => {
|
|
233
|
+
const old = this.ephemeralOperations;
|
|
234
|
+
this.ephemeralOperations = [];
|
|
235
|
+
return old;
|
|
236
|
+
};
|
|
237
|
+
|
|
207
238
|
discardPendingOperation = (operation: Operation) => {
|
|
208
239
|
this.pendingOperations = this.pendingOperations.filter(
|
|
209
240
|
(op) => op.timestamp !== operation.timestamp,
|
|
@@ -221,10 +252,10 @@ export class EntityMetadata {
|
|
|
221
252
|
latestTimestamp: string | null;
|
|
222
253
|
deleted: boolean;
|
|
223
254
|
futureSeen: string | undefined;
|
|
224
|
-
authz?:
|
|
255
|
+
authz?: AuthorizationKey;
|
|
225
256
|
} => {
|
|
226
257
|
let futureSeen: string | undefined = undefined;
|
|
227
|
-
let authz:
|
|
258
|
+
let authz: AuthorizationKey | undefined = undefined;
|
|
228
259
|
|
|
229
260
|
const now = this.ctx.time.now;
|
|
230
261
|
for (const op of operations) {
|
|
@@ -345,6 +376,10 @@ export class EntityFamilyMetadata {
|
|
|
345
376
|
* local changes are usually handled, as a list.
|
|
346
377
|
*/
|
|
347
378
|
addPendingData = (operations: Operation[]) => {
|
|
379
|
+
// when pending data is applied, we go ahead and
|
|
380
|
+
// write all ephemeral changes first.
|
|
381
|
+
this.flushAllEphemeral();
|
|
382
|
+
|
|
348
383
|
const changes: Record<ObjectIdentifier, EntityChange> = {};
|
|
349
384
|
for (const op of operations) {
|
|
350
385
|
this.get(op.oid).addPendingOperation(op);
|
|
@@ -354,6 +389,31 @@ export class EntityFamilyMetadata {
|
|
|
354
389
|
return Object.values(changes);
|
|
355
390
|
};
|
|
356
391
|
|
|
392
|
+
private ephemeralMemo = new Array<Operation>();
|
|
393
|
+
private flushAllEphemeral = () => {
|
|
394
|
+
for (const ent of this.entities.values()) {
|
|
395
|
+
const ops = ent.clearEphemeralOperations();
|
|
396
|
+
if (ops) {
|
|
397
|
+
this.ephemeralMemo.push(...ops);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
if (this.ephemeralMemo.length) {
|
|
401
|
+
const ephemeralCopy = this.ephemeralMemo.slice();
|
|
402
|
+
// must clear this first to avoid infinite recursion
|
|
403
|
+
this.ephemeralMemo.length = 0;
|
|
404
|
+
this.addPendingData(ephemeralCopy);
|
|
405
|
+
}
|
|
406
|
+
};
|
|
407
|
+
|
|
408
|
+
addEphemeralData = (operations: Operation[]) => {
|
|
409
|
+
const changes: Record<ObjectIdentifier, EntityChange> = {};
|
|
410
|
+
for (const op of operations) {
|
|
411
|
+
this.get(op.oid).addEphemeralOperation(op);
|
|
412
|
+
changes[op.oid] ??= { oid: op.oid, isLocal: true };
|
|
413
|
+
}
|
|
414
|
+
return Object.values(changes);
|
|
415
|
+
};
|
|
416
|
+
|
|
357
417
|
replaceAllData = ({
|
|
358
418
|
operations = {},
|
|
359
419
|
baselines = [],
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
|
+
AuthorizationKey,
|
|
2
3
|
DocumentBaseline,
|
|
4
|
+
FileData,
|
|
3
5
|
ObjectIdentifier,
|
|
4
6
|
Operation,
|
|
5
7
|
StorageObjectFieldSchema,
|
|
@@ -12,16 +14,15 @@ import {
|
|
|
12
14
|
groupPatchesByRootOid,
|
|
13
15
|
isRootOid,
|
|
14
16
|
removeOidsFromAllSubObjects,
|
|
15
|
-
AuthorizationKey,
|
|
16
17
|
} from '@verdant-web/common';
|
|
18
|
+
import { WeakEvent } from 'weak-event';
|
|
17
19
|
import { Context } from '../context/context.js';
|
|
18
|
-
import {
|
|
20
|
+
import { FileManager } from '../files/FileManager.js';
|
|
21
|
+
import { processValueFiles } from '../files/utils.js';
|
|
19
22
|
import { Disposable } from '../utils/Disposable.js';
|
|
23
|
+
import { Entity } from './Entity.js';
|
|
20
24
|
import { EntityFamilyMetadata } from './EntityMetadata.js';
|
|
21
|
-
import { FileManager } from '../files/FileManager.js';
|
|
22
25
|
import { OperationBatcher } from './OperationBatcher.js';
|
|
23
|
-
import { WeakEvent } from 'weak-event';
|
|
24
|
-
import { processValueFiles } from '../files/utils.js';
|
|
25
26
|
|
|
26
27
|
enum AbortReason {
|
|
27
28
|
Reset,
|
|
@@ -335,7 +336,8 @@ export class EntityStore extends Disposable {
|
|
|
335
336
|
// remove any OID associations from the initial data
|
|
336
337
|
removeOidsFromAllSubObjects(initial);
|
|
337
338
|
// grab files and replace them with refs
|
|
338
|
-
const
|
|
339
|
+
const fileRefs: FileData[] = [];
|
|
340
|
+
const processed = processValueFiles(initial, fileRefs.push.bind(fileRefs));
|
|
339
341
|
|
|
340
342
|
assignOid(processed, oid);
|
|
341
343
|
|
|
@@ -346,13 +348,14 @@ export class EntityStore extends Disposable {
|
|
|
346
348
|
`Could not put new document: no schema exists for collection ${collection}`,
|
|
347
349
|
);
|
|
348
350
|
}
|
|
351
|
+
// add files with entity as parent
|
|
352
|
+
fileRefs.forEach((file) => this.files.add(file, entity));
|
|
349
353
|
|
|
350
|
-
const operations = this.ctx.patchCreator.createInitialize(
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
}
|
|
354
|
+
const operations = this.ctx.patchCreator.createInitialize(
|
|
355
|
+
processed,
|
|
356
|
+
oid,
|
|
357
|
+
access,
|
|
358
|
+
);
|
|
356
359
|
await this.batcher.commitOperations(operations, {
|
|
357
360
|
undoable: !!undoable,
|
|
358
361
|
source: entity,
|