@verdant-web/store 4.3.0 → 4.4.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.
Files changed (37) hide show
  1. package/dist/bundle/index.js +6 -6
  2. package/dist/bundle/index.js.map +4 -4
  3. package/dist/esm/__tests__/entities.test.js +296 -1
  4. package/dist/esm/__tests__/entities.test.js.map +1 -1
  5. package/dist/esm/client/Client.js +4 -5
  6. package/dist/esm/client/Client.js.map +1 -1
  7. package/dist/esm/entities/Entity.d.ts +6 -2
  8. package/dist/esm/entities/Entity.js +96 -39
  9. package/dist/esm/entities/Entity.js.map +1 -1
  10. package/dist/esm/entities/Entity.test.js +4 -3
  11. package/dist/esm/entities/Entity.test.js.map +1 -1
  12. package/dist/esm/entities/EntityCache.d.ts +6 -4
  13. package/dist/esm/entities/EntityCache.js +18 -7
  14. package/dist/esm/entities/EntityCache.js.map +1 -1
  15. package/dist/esm/entities/EntityMetadata.d.ts +6 -0
  16. package/dist/esm/entities/EntityMetadata.js +52 -4
  17. package/dist/esm/entities/EntityMetadata.js.map +1 -1
  18. package/dist/esm/entities/EntityStore.js +4 -1
  19. package/dist/esm/entities/EntityStore.js.map +1 -1
  20. package/dist/esm/files/EntityFile.d.ts +6 -1
  21. package/dist/esm/files/EntityFile.js +11 -4
  22. package/dist/esm/files/EntityFile.js.map +1 -1
  23. package/dist/esm/files/FileManager.d.ts +3 -1
  24. package/dist/esm/files/FileManager.js +2 -2
  25. package/dist/esm/files/FileManager.js.map +1 -1
  26. package/dist/esm/utils/versions.js +1 -1
  27. package/package.json +2 -2
  28. package/src/__tests__/entities.test.ts +311 -0
  29. package/src/client/Client.ts +12 -4
  30. package/src/entities/Entity.test.ts +8 -7
  31. package/src/entities/Entity.ts +117 -38
  32. package/src/entities/EntityCache.ts +20 -10
  33. package/src/entities/EntityMetadata.ts +64 -5
  34. package/src/entities/EntityStore.ts +5 -1
  35. package/src/files/EntityFile.ts +16 -3
  36. package/src/files/FileManager.ts +7 -3
  37. package/src/utils/versions.ts +1 -1
@@ -2,6 +2,7 @@ import {
2
2
  assert,
3
3
  createRef,
4
4
  EventSubscriber,
5
+ FixedTimestampProvider,
5
6
  NaiveTimestampProvider,
6
7
  PatchCreator,
7
8
  schema,
@@ -10,6 +11,7 @@ import { describe, expect, it, MockedFunction, vi, vitest } from 'vitest';
10
11
  import { WeakEvent } from 'weak-event';
11
12
  import { Time } from '../context/Time.js';
12
13
  import { EntityFamilyMetadata } from '../entities/EntityMetadata.js';
14
+ import { MARK_FAILED } from '../files/EntityFile.js';
13
15
  import { Entity, EntityFile } from '../index.js';
14
16
  import { createTestStorage } from './fixtures/testStorage.js';
15
17
 
@@ -734,6 +736,7 @@ describe('entities', () => {
734
736
  files: {
735
737
  add: vi.fn(),
736
738
  },
739
+ weakRef: (v: any) => new WeakRef(v),
737
740
  } as any;
738
741
  const metadataFamily = new EntityFamilyMetadata({
739
742
  ctx: testCtx,
@@ -860,4 +863,312 @@ describe('entities', () => {
860
863
  expect(entity.getSnapshot()).toEqual(entity.getUnprunedSnapshot());
861
864
  expect(entity.deepInvalid).toBe(false);
862
865
  });
866
+
867
+ it('should only fire deep change once for a file change even if you get it multiple times', async () => {
868
+ // addressing a specific bug / lazy thing I did that caused a listener leak
869
+ const storage = await createTestStorage();
870
+ const weird = await storage.weirds.put({});
871
+ const fileList = weird.get('fileList');
872
+ const file = new window.File(['d(⌐□_□)b'], 'test.txt', {
873
+ type: 'text/plain',
874
+ });
875
+ fileList.push(file);
876
+ const deepListener = vi.fn();
877
+ fileList.subscribe('changeDeep', deepListener);
878
+ fileList.get(0);
879
+ fileList.get(0);
880
+ fileList.get(0);
881
+ // easy way to trigger change
882
+ fileList.get(0)[MARK_FAILED]();
883
+ expect(deepListener).toHaveBeenCalledTimes(1);
884
+ });
885
+
886
+ it('should correctly prune deep nested objects', () => {
887
+ // manually constructing an entity for this test is easiest,
888
+ // kind of hard to force invalid data otherwise
889
+ const onPendingOperations = vi.fn();
890
+ const time = new Time(new FixedTimestampProvider(), 1);
891
+ // too much junk in here, have to manually pick and choose
892
+ let subId = 100; // avoid false-flag matching of other ids by choosing an arbitrary start point
893
+ const testCtx = {
894
+ globalEvents: new EventSubscriber(),
895
+ time,
896
+ log: console.log,
897
+ patchCreator: new PatchCreator(
898
+ () => time.now,
899
+ () => `${subId++}`,
900
+ ),
901
+ files: {
902
+ add: vi.fn(),
903
+ },
904
+ weakRef: (v: any) => new WeakRef(v),
905
+ } as any;
906
+ const metadataFamily = new EntityFamilyMetadata({
907
+ ctx: testCtx,
908
+ onPendingOperations,
909
+ rootOid: 'foos/a',
910
+ });
911
+ const entity = new Entity({
912
+ oid: 'foos/a',
913
+ schema: {
914
+ type: 'object',
915
+ properties: {
916
+ id: schema.fields.id(),
917
+ // should prune to an empty list entity
918
+ listTest: schema.fields.array({
919
+ items: schema.fields.object({
920
+ fields: {
921
+ required: schema.fields.string(),
922
+ },
923
+ }),
924
+ }),
925
+ objectTest: schema.fields.object({
926
+ default: () => ({
927
+ // @ts-ignore - excessive nesting
928
+ nested: {
929
+ required: 'foo',
930
+ },
931
+ }),
932
+ fields: {
933
+ nested: schema.fields.object({
934
+ fields: {
935
+ required: schema.fields.string(),
936
+ },
937
+ }),
938
+ },
939
+ }),
940
+ // this one will not be present at all.
941
+ missingTest: schema.fields.object({
942
+ default: () => ({
943
+ value: 1,
944
+ }),
945
+ fields: {
946
+ value: schema.fields.number(),
947
+ },
948
+ }),
949
+ },
950
+ },
951
+ ctx: testCtx,
952
+ deleteSelf: vi.fn(),
953
+ files: {
954
+ add: vi.fn(),
955
+ } as any,
956
+ metadataFamily,
957
+ storeEvents: {
958
+ add: new WeakEvent(),
959
+ replace: new WeakEvent(),
960
+ resetAll: new WeakEvent(),
961
+ },
962
+ readonlyKeys: ['id'],
963
+ });
964
+ metadataFamily.addConfirmedData({
965
+ baselines: [
966
+ {
967
+ // an invalid list item
968
+ oid: 'foos/a:1',
969
+ snapshot: {},
970
+ timestamp: time.now,
971
+ },
972
+ {
973
+ oid: 'foos/a:2',
974
+ snapshot: [createRef('foos/a:1')],
975
+ timestamp: time.now,
976
+ },
977
+ {
978
+ // an invalid nested object
979
+ oid: 'foos/a:3',
980
+ snapshot: {},
981
+ timestamp: time.now,
982
+ },
983
+ {
984
+ oid: 'foos/a:4',
985
+ snapshot: { nested: createRef('foos/a:3') },
986
+ timestamp: time.now,
987
+ },
988
+ {
989
+ oid: 'foos/a',
990
+ snapshot: {
991
+ id: 'a',
992
+ listTest: createRef('foos/a:2'),
993
+ objectTest: createRef('foos/a:4'),
994
+ },
995
+ timestamp: time.now,
996
+ },
997
+ ],
998
+ });
999
+
1000
+ expect(entity.deepInvalid).toBe(true);
1001
+ expect(entity.getSnapshot()).toEqual({
1002
+ id: 'a',
1003
+ listTest: [],
1004
+ // gets the default, pruned
1005
+ objectTest: {
1006
+ nested: {
1007
+ required: 'foo',
1008
+ },
1009
+ },
1010
+ missingTest: {
1011
+ value: 1,
1012
+ },
1013
+ });
1014
+
1015
+ // the kicker...
1016
+ const list = entity.get('listTest');
1017
+ expect(list).toBeInstanceOf(Entity);
1018
+ const obj = entity.get('objectTest');
1019
+ expect(obj).toBeInstanceOf(Entity);
1020
+ const missing = entity.get('missingTest');
1021
+ expect(missing).toBeInstanceOf(Entity);
1022
+
1023
+ // we should be able to make edits to the pruned versions
1024
+ // and they get committed.
1025
+ list.push({ required: 'foo' });
1026
+ expect(entity.getSnapshot()).toEqual({
1027
+ id: 'a',
1028
+ listTest: [{ required: 'foo' }],
1029
+ objectTest: {
1030
+ nested: {
1031
+ required: 'foo',
1032
+ },
1033
+ },
1034
+ missingTest: {
1035
+ value: 1,
1036
+ },
1037
+ });
1038
+ obj.get('nested').set('required', 'bar');
1039
+ expect(entity.getSnapshot()).toEqual({
1040
+ id: 'a',
1041
+ listTest: [{ required: 'foo' }],
1042
+ objectTest: {
1043
+ nested: {
1044
+ required: 'bar',
1045
+ },
1046
+ },
1047
+ missingTest: {
1048
+ value: 1,
1049
+ },
1050
+ });
1051
+ missing.set('value', 2);
1052
+ expect(entity.getSnapshot()).toEqual({
1053
+ id: 'a',
1054
+ listTest: [{ required: 'foo' }],
1055
+ objectTest: {
1056
+ nested: {
1057
+ required: 'bar',
1058
+ },
1059
+ // gets the default, pruned
1060
+ },
1061
+ missingTest: {
1062
+ value: 2,
1063
+ },
1064
+ });
1065
+
1066
+ expect(onPendingOperations).toHaveBeenCalledTimes(5);
1067
+ // when doing those changes above, we see pending operations
1068
+ // added to construct the pruned versions with applied changes
1069
+ function removeExtra(obj: any) {
1070
+ delete obj.timestamp;
1071
+ delete obj.authz;
1072
+ return obj;
1073
+ }
1074
+ // being kind of precious about assertions here since
1075
+ // ordering is not quite as important and some ops create new
1076
+ // oids.
1077
+ const firstCall = onPendingOperations.mock.calls[0][0].map(removeExtra);
1078
+
1079
+ // the required field was set to the default
1080
+ expect(firstCall).toContainEqual({
1081
+ data: {
1082
+ name: 'required',
1083
+ op: 'set',
1084
+ value: 'foo',
1085
+ },
1086
+ oid: 'foos/a:3',
1087
+ });
1088
+
1089
+ // missingTest is created wholesale
1090
+ expect(firstCall).toContainEqual({
1091
+ data: {
1092
+ op: 'set',
1093
+ name: 'missingTest',
1094
+ value: {
1095
+ '@@type': 'ref',
1096
+ id: expect.stringMatching(/^foos\/a:/),
1097
+ },
1098
+ },
1099
+ oid: 'foos/a',
1100
+ });
1101
+ expect(firstCall).toContainEqual({
1102
+ data: {
1103
+ op: 'initialize',
1104
+ value: {
1105
+ value: 1,
1106
+ },
1107
+ },
1108
+ oid: expect.stringMatching(/^foos\/a:/),
1109
+ });
1110
+
1111
+ // we asserted all the contents, now also assert the length
1112
+ expect(firstCall).toHaveLength(3);
1113
+
1114
+ const secondCall = onPendingOperations.mock.calls[1][0].map(removeExtra);
1115
+ // the invalid item was removed from the array
1116
+ expect(secondCall).toContainEqual({
1117
+ data: {
1118
+ op: 'delete',
1119
+ },
1120
+ oid: 'foos/a:1',
1121
+ });
1122
+ expect(secondCall).toContainEqual({
1123
+ data: {
1124
+ op: 'list-remove',
1125
+ only: 'last',
1126
+ value: {
1127
+ '@@type': 'ref',
1128
+ id: 'foos/a:1',
1129
+ },
1130
+ },
1131
+ oid: 'foos/a:2',
1132
+ });
1133
+
1134
+ const thirdCall = onPendingOperations.mock.calls[2][0].map(removeExtra);
1135
+ // we can be more lax here
1136
+ expect(thirdCall).toMatchInlineSnapshot(`
1137
+ [
1138
+ {
1139
+ "data": {
1140
+ "op": "initialize",
1141
+ "value": {
1142
+ "required": "foo",
1143
+ },
1144
+ },
1145
+ "oid": "foos/a:100",
1146
+ },
1147
+ {
1148
+ "data": {
1149
+ "op": "list-push",
1150
+ "value": {
1151
+ "@@type": "ref",
1152
+ "id": "foos/a:100",
1153
+ },
1154
+ },
1155
+ "oid": "foos/a:2",
1156
+ },
1157
+ ]
1158
+ `);
1159
+
1160
+ const fourthCall = onPendingOperations.mock.calls[3][0].map(removeExtra);
1161
+ expect(fourthCall).toMatchInlineSnapshot(`
1162
+ [
1163
+ {
1164
+ "data": {
1165
+ "name": "required",
1166
+ "op": "set",
1167
+ "value": "bar",
1168
+ },
1169
+ "oid": "foos/a:3",
1170
+ },
1171
+ ]
1172
+ `);
1173
+ });
863
1174
  });
@@ -4,6 +4,7 @@ import {
4
4
  EventSubscriber,
5
5
  FileData,
6
6
  Operation,
7
+ VerdantError,
7
8
  } from '@verdant-web/common';
8
9
  import { Context } from '../context/context.js';
9
10
  import { DocumentManager } from '../entities/DocumentManager.js';
@@ -205,13 +206,20 @@ export class Client<Presence = any, Profile = any> extends EventSubscriber<{
205
206
  return await this._entities.addData(data);
206
207
  }
207
208
  } catch (err) {
208
- this.context.log('critical', 'Sync failed', err);
209
+ this.context.log(
210
+ 'critical',
211
+ 'Sync failed. To avoid data corruption, the client will now shut down.',
212
+ err,
213
+ );
209
214
  this.emit(
210
215
  'developerError',
211
- new Error('Sync failed, see logs or cause', {
212
- cause: err,
213
- }),
216
+ new VerdantError(
217
+ VerdantError.Code.Unexpected,
218
+ err as any,
219
+ 'Sync failed. To avoid data corruption, the client will now shut down.',
220
+ ),
214
221
  );
222
+ await this.close();
215
223
  throw err;
216
224
  }
217
225
  };
@@ -1,16 +1,16 @@
1
- import { describe, expect, it, vi } from 'vitest';
2
- import { Entity } from './Entity.js';
3
- import { EntityFamilyMetadata } from './EntityMetadata.js';
4
- import { Context } from '../context/context.js';
5
- import { EntityStoreEvents } from './EntityStore.js';
6
- import { WeakEvent } from 'weak-event';
7
- import { FileManager } from '../files/FileManager.js';
8
1
  import {
9
2
  NaiveTimestampProvider,
10
3
  PatchCreator,
11
4
  groupPatchesByOid,
12
5
  } from '@verdant-web/common';
6
+ import { describe, expect, it, vi } from 'vitest';
7
+ import { WeakEvent } from 'weak-event';
8
+ import { Context } from '../context/context.js';
13
9
  import { Time } from '../context/Time.js';
10
+ import { FileManager } from '../files/FileManager.js';
11
+ import { Entity } from './Entity.js';
12
+ import { EntityFamilyMetadata } from './EntityMetadata.js';
13
+ import { EntityStoreEvents } from './EntityStore.js';
14
14
 
15
15
  describe('Entity', () => {
16
16
  const schema = {
@@ -65,6 +65,7 @@ describe('Entity', () => {
65
65
  log: vi.fn(),
66
66
  time: new Time(time, 1),
67
67
  patchCreator,
68
+ weakRef: (v: any) => new WeakRef(v),
68
69
  };
69
70
  const entity = new Entity({
70
71
  oid: 'test/1',
@@ -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,
@@ -18,6 +19,7 @@ import {
18
19
  isNullable,
19
20
  isObject,
20
21
  isObjectRef,
22
+ isPrimitive,
21
23
  isRef,
22
24
  maybeGetOid,
23
25
  memoByKeys,
@@ -25,6 +27,7 @@ import {
25
27
  validateEntityField,
26
28
  } from '@verdant-web/common';
27
29
  import { Context } from '../context/context.js';
30
+ import { CHILD_FILE_CHANGED } from '../files/EntityFile.js';
28
31
  import { FileManager } from '../files/FileManager.js';
29
32
  import { processValueFiles } from '../files/utils.js';
30
33
  import { ClientWithCollections, EntityFile } from '../index.js';
@@ -112,6 +115,7 @@ export class Entity<
112
115
  files,
113
116
  storeEvents,
114
117
  deleteSelf,
118
+ fieldPath,
115
119
  }: EntityInit) {
116
120
  super();
117
121
 
@@ -122,10 +126,12 @@ export class Entity<
122
126
  this.ctx = ctx;
123
127
  this.files = files;
124
128
  this.schema = schema;
129
+ this.fieldPath = fieldPath || [];
125
130
  this.entityFamily =
126
131
  childCache ||
127
132
  new EntityCache({
128
133
  initial: [this],
134
+ ctx,
129
135
  });
130
136
  this.metadataFamily = metadataFamily;
131
137
  this.storeEvents = storeEvents;
@@ -213,8 +219,8 @@ export class Entity<
213
219
  return null as any;
214
220
  }
215
221
 
216
- this.cachedView = this.isList ? [] : {};
217
- assignOid(this.cachedView, this.oid);
222
+ let newView: any = this.isList ? [] : {};
223
+ assignOid(newView, this.oid);
218
224
 
219
225
  if (Array.isArray(rawView)) {
220
226
  const schema = getChildFieldSchema(this.schema, 0);
@@ -243,7 +249,7 @@ export class Entity<
243
249
 
244
250
  // this item will be pruned.
245
251
  } else {
246
- this.cachedView.push(child);
252
+ newView.push(child);
247
253
  }
248
254
  }
249
255
  }
@@ -271,10 +277,10 @@ export class Entity<
271
277
  );
272
278
  if (this.schema.type === 'map') {
273
279
  // it's valid to prune here if it's a map
274
- this.cachedView = {};
280
+ newView = {};
275
281
  } else {
276
282
  // otherwise prune moves upward
277
- this.cachedView = null;
283
+ newView = null;
278
284
  }
279
285
  break;
280
286
  }
@@ -287,28 +293,29 @@ export class Entity<
287
293
  'key:',
288
294
  key,
289
295
  );
290
- /**
291
- * PRUNE - this is a prune point. we can't continue
292
- * to render this data. If this is a map, we can ignore
293
- * this value. Otherwise we must prune upward.
294
- * This exits the loop.
295
- */
296
296
  if (this.schema.type !== 'map') {
297
- this.cachedView = null;
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;
298
304
  break;
299
305
  }
300
306
  } else {
301
307
  // special case - rewrite undefined fields to null
302
308
  if (isNullable(schema) && child === undefined) {
303
- this.cachedView[key] = null;
309
+ newView[key] = null;
304
310
  } else {
305
- this.cachedView[key] = child;
311
+ newView[key] = child;
306
312
  }
307
313
  }
308
314
  }
309
315
  }
310
316
 
311
- return this.cachedView;
317
+ this.cachedView = newView;
318
+ return newView;
312
319
  }
313
320
 
314
321
  private childIsNull = (child: any) => {
@@ -597,19 +604,16 @@ export class Entity<
597
604
  }
598
605
  };
599
606
 
600
- private canonizePrunedVersion = () => {
607
+ private getPruneDiff = () => {
601
608
  const snapshot = this.getSnapshot();
602
609
  const unprunedSnapshot = this.getUnprunedSnapshot();
603
- const operations = this.patchCreator.createDiff(
604
- unprunedSnapshot,
605
- snapshot,
606
- {
607
- authz: this.access,
608
- merge: false,
609
- },
610
- );
611
-
612
- this.applyPendingOperations(operations);
610
+ return this.patchCreator.createDiff(unprunedSnapshot, snapshot, {
611
+ authz: this.access,
612
+ merge: false,
613
+ });
614
+ };
615
+ private canonizePrunedVersion = () => {
616
+ this.applyPendingOperations(this.getPruneDiff());
613
617
  };
614
618
 
615
619
  private addConfirmedData = (data: EntityStoreEventData) => {
@@ -700,6 +704,10 @@ export class Entity<
700
704
  this.emit('changeDeep', target, ev);
701
705
  this.parent?.deepChange(target, ev);
702
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
+ };
703
711
 
704
712
  private getChild = (key: any, oid: ObjectIdentifier) => {
705
713
  const schema = getChildFieldSchema(this.schema, key);
@@ -767,16 +775,44 @@ export class Entity<
767
775
  const file = this.files.get(child.id, {
768
776
  downloadRemote: !!fieldSchema.downloadRemote,
769
777
  ctx: this.ctx,
770
- });
771
-
772
- // FIXME: this seems bad and inconsistent
773
- file.subscribe('change', () => {
774
- this.deepChange(this, { isLocal: false, oid: this.oid });
778
+ parent: this,
775
779
  });
776
780
 
777
781
  return file as KeyValue[Key];
778
782
  } else {
779
- return this.getChild(key, child.id) as KeyValue[Key];
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];
780
816
  }
781
817
  } else {
782
818
  // if this is a Map type, a missing child is
@@ -794,16 +830,59 @@ export class Entity<
794
830
  requireDefaults: true,
795
831
  })
796
832
  ) {
797
- // FIXME: this returns []/{} for arrays and objects, but the contract
798
- // of this method should return an Entity for such object fields.
799
- // I want to write a test case for this one before attempting to fix
800
- // just to be sure the fix works.
801
- return getFieldDefault(fieldSchema);
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
+ }
802
863
  }
803
864
  return child as KeyValue[Key];
804
865
  }
805
866
  };
806
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
+
807
886
  /**
808
887
  * Gets a value on this entity. If the value is not
809
888
  * present, it will be set to the provided default
@@ -865,7 +944,7 @@ export class Entity<
865
944
  });
866
945
  }
867
946
  }
868
- return processValueFiles(value, this.files.add);
947
+ return processValueFiles(value, (file) => this.files.add(file, this));
869
948
  };
870
949
 
871
950
  private getDeleteMode = (key: any) => {