@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.
Files changed (46) hide show
  1. package/dist/bundle/index.js +9 -7
  2. package/dist/bundle/index.js.map +4 -4
  3. package/dist/esm/__tests__/entities.test.js +438 -5
  4. package/dist/esm/__tests__/entities.test.js.map +1 -1
  5. package/dist/esm/__tests__/fixtures/testStorage.d.ts +1 -0
  6. package/dist/esm/__tests__/fixtures/testStorage.js +1 -1
  7. package/dist/esm/__tests__/fixtures/testStorage.js.map +1 -1
  8. package/dist/esm/client/Client.js +2 -1
  9. package/dist/esm/client/Client.js.map +1 -1
  10. package/dist/esm/entities/Entity.d.ts +22 -3
  11. package/dist/esm/entities/Entity.js +210 -38
  12. package/dist/esm/entities/Entity.js.map +1 -1
  13. package/dist/esm/entities/Entity.test.js +4 -3
  14. package/dist/esm/entities/Entity.test.js.map +1 -1
  15. package/dist/esm/entities/EntityCache.d.ts +6 -4
  16. package/dist/esm/entities/EntityCache.js +18 -7
  17. package/dist/esm/entities/EntityCache.js.map +1 -1
  18. package/dist/esm/entities/EntityMetadata.d.ts +8 -2
  19. package/dist/esm/entities/EntityMetadata.js +52 -4
  20. package/dist/esm/entities/EntityMetadata.js.map +1 -1
  21. package/dist/esm/entities/EntityStore.d.ts +4 -4
  22. package/dist/esm/entities/EntityStore.js +8 -10
  23. package/dist/esm/entities/EntityStore.js.map +1 -1
  24. package/dist/esm/entities/OperationBatcher.d.ts +1 -1
  25. package/dist/esm/entities/OperationBatcher.js +2 -0
  26. package/dist/esm/entities/OperationBatcher.js.map +1 -1
  27. package/dist/esm/files/EntityFile.d.ts +6 -1
  28. package/dist/esm/files/EntityFile.js +11 -4
  29. package/dist/esm/files/EntityFile.js.map +1 -1
  30. package/dist/esm/files/FileManager.d.ts +3 -1
  31. package/dist/esm/files/FileManager.js +2 -2
  32. package/dist/esm/files/FileManager.js.map +1 -1
  33. package/dist/esm/utils/versions.js +1 -1
  34. package/package.json +2 -2
  35. package/src/__tests__/entities.test.ts +471 -5
  36. package/src/__tests__/fixtures/testStorage.ts +1 -1
  37. package/src/client/Client.ts +6 -1
  38. package/src/entities/Entity.test.ts +8 -7
  39. package/src/entities/Entity.ts +239 -29
  40. package/src/entities/EntityCache.ts +20 -10
  41. package/src/entities/EntityMetadata.ts +69 -9
  42. package/src/entities/EntityStore.ts +15 -12
  43. package/src/entities/OperationBatcher.ts +16 -2
  44. package/src/files/EntityFile.ts +16 -3
  45. package/src/files/FileManager.ts +7 -3
  46. package/src/utils/versions.ts +1 -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,
@@ -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
- this.cachedView = this.isList ? [] : {};
216
- assignOid(this.cachedView, this.oid);
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
- this.cachedView.push(child);
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
- this.cachedView = {};
280
+ newView = {};
274
281
  } else {
275
282
  // otherwise prune moves upward
276
- this.cachedView = null;
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
- 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;
297
304
  break;
298
305
  }
299
306
  } else {
300
- this.cachedView[key] = child;
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
- return this.cachedView;
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
- depth: 1,
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('debug', 'Entity: adding pending operations', this.oid);
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
- 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];
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
- // FIXME: this returns []/{} for arrays and objects, but the contract
667
- // of this method should return an Entity for such object fields.
668
- // I want to write a test case for this one before attempting to fix
669
- // just to be sure the fix works.
670
- 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
+ }
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
- defaultUndefined: merge,
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 cache = new Map<string, Entity | EntityFile>();
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
- if (this.cache.has(init.oid)) {
18
- return this.cache.get(init.oid)! as Entity;
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
- return this.cache.get(oid);
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?: string;
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
- confirmedResult.view,
96
- confirmedResult.deleted,
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
- confirmedResult.latestTimestamp,
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?: string;
255
+ authz?: AuthorizationKey;
225
256
  } => {
226
257
  let futureSeen: string | undefined = undefined;
227
- let authz: string | undefined = undefined;
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 { Entity } from './Entity.js';
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 processed = processValueFiles(initial, this.files.add);
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(processed, oid);
351
- if (access) {
352
- operations.forEach((op) => {
353
- op.authz = access;
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,