@verdant-web/store 4.3.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 (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 +2 -1
  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 +6 -1
  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
@@ -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
  }
@@ -29,6 +29,14 @@ export class EntityMetadata {
29
29
  private baseline: DocumentBaseline | null = null;
30
30
  // these must be kept in timestamp order.
31
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[] = [];
32
40
  private pendingOperations: Operation[] = [];
33
41
  readonly oid;
34
42
 
@@ -90,15 +98,26 @@ export class EntityMetadata {
90
98
  authz = confirmedResult.authz;
91
99
  }
92
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
+
93
112
  const pendingResult = omitPending
94
113
  ? confirmedResult
95
114
  : this.applyOperations(
96
- confirmedResult.view,
97
- confirmedResult.deleted,
115
+ ephemeralResult.view,
116
+ ephemeralResult.deleted,
98
117
  // now we're applying pending operations
99
118
  this.pendingOperations,
100
119
  // keep our latest timestamp up to date
101
- confirmedResult.latestTimestamp,
120
+ ephemeralResult.latestTimestamp,
102
121
  // we don't use after for pending ops, they're all
103
122
  // logically in the future
104
123
  null,
@@ -200,11 +219,22 @@ export class EntityMetadata {
200
219
  };
201
220
 
202
221
  addPendingOperation = (operation: Operation) => {
203
- // check to see if new operation supersedes the previous one
204
- // we can assume pending ops are always newer
205
222
  this.pendingOperations.push(operation);
206
223
  };
207
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
+
208
238
  discardPendingOperation = (operation: Operation) => {
209
239
  this.pendingOperations = this.pendingOperations.filter(
210
240
  (op) => op.timestamp !== operation.timestamp,
@@ -346,6 +376,10 @@ export class EntityFamilyMetadata {
346
376
  * local changes are usually handled, as a list.
347
377
  */
348
378
  addPendingData = (operations: Operation[]) => {
379
+ // when pending data is applied, we go ahead and
380
+ // write all ephemeral changes first.
381
+ this.flushAllEphemeral();
382
+
349
383
  const changes: Record<ObjectIdentifier, EntityChange> = {};
350
384
  for (const op of operations) {
351
385
  this.get(op.oid).addPendingOperation(op);
@@ -355,6 +389,31 @@ export class EntityFamilyMetadata {
355
389
  return Object.values(changes);
356
390
  };
357
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
+
358
417
  replaceAllData = ({
359
418
  operations = {},
360
419
  baselines = [],
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  AuthorizationKey,
3
3
  DocumentBaseline,
4
+ FileData,
4
5
  ObjectIdentifier,
5
6
  Operation,
6
7
  StorageObjectFieldSchema,
@@ -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,6 +348,8 @@ 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
354
  const operations = this.ctx.patchCreator.createInitialize(
351
355
  processed,
@@ -1,5 +1,6 @@
1
1
  import { EventSubscriber, FileData } from '@verdant-web/common';
2
2
  import { Context } from '../context/context.js';
3
+ import { Entity } from '../entities/Entity.js';
3
4
 
4
5
  export type EntityFileEvents = {
5
6
  change: () => void;
@@ -8,6 +9,9 @@ export type EntityFileEvents = {
8
9
  export const UPDATE = Symbol('entity-file-update');
9
10
  export const MARK_FAILED = Symbol('entity-file-mark-failed');
10
11
 
12
+ // this one goes on Entity
13
+ export const CHILD_FILE_CHANGED = Symbol('child-file-changed');
14
+
11
15
  export type EntityFileSnapshot = {
12
16
  id: string;
13
17
  url?: string | null;
@@ -27,19 +31,23 @@ export class EntityFile extends EventSubscriber<EntityFileEvents> {
27
31
  private _uploaded = false;
28
32
  private ctx: Context;
29
33
  private unsubscribes: (() => void)[] = [];
34
+ private parent: Entity;
30
35
 
31
36
  constructor(
32
37
  public readonly id: string,
33
38
  {
34
39
  downloadRemote = false,
35
40
  ctx,
41
+ parent,
36
42
  }: {
37
43
  downloadRemote?: boolean;
38
44
  ctx: Context;
45
+ parent: Entity;
39
46
  },
40
47
  ) {
41
48
  super();
42
49
  this.ctx = ctx;
50
+ this.parent = parent;
43
51
  this._downloadRemote = downloadRemote;
44
52
 
45
53
  this.unsubscribes.push(
@@ -57,6 +65,11 @@ export class EntityFile extends EventSubscriber<EntityFileEvents> {
57
65
  return this._uploaded || this._fileData?.remote || false;
58
66
  }
59
67
 
68
+ private emitChange() {
69
+ this.parent[CHILD_FILE_CHANGED](this);
70
+ this.emit('change');
71
+ }
72
+
60
73
  [UPDATE] = (fileData: FileData) => {
61
74
  this.ctx.log('debug', 'EntityFile updated', this.id, fileData);
62
75
  this._loading = false;
@@ -69,13 +82,13 @@ export class EntityFile extends EventSubscriber<EntityFileEvents> {
69
82
  this.ctx.log('debug', 'Creating object URL for file', this.id);
70
83
  this._objectUrl = URL.createObjectURL(fileData.file);
71
84
  }
72
- this.emit('change');
85
+ this.emitChange();
73
86
  };
74
87
 
75
88
  [MARK_FAILED] = () => {
76
89
  this._failed = true;
77
90
  this._loading = false;
78
- this.emit('change');
91
+ this.emitChange();
79
92
  };
80
93
 
81
94
  private onUploaded = (data: FileData) => {
@@ -83,7 +96,7 @@ export class EntityFile extends EventSubscriber<EntityFileEvents> {
83
96
  this._fileData ??= data;
84
97
  this._uploaded = true;
85
98
  this.ctx.log('debug', 'File marked uploaded', this.id, this._fileData);
86
- this.emit('change');
99
+ this.emitChange();
87
100
  };
88
101
 
89
102
  get url(): string | null {
@@ -1,5 +1,6 @@
1
1
  import { FileData } from '@verdant-web/common';
2
2
  import { Context } from '../context/context.js';
3
+ import { Entity } from '../entities/Entity.js';
3
4
  import { Disposable } from '../internal.js';
4
5
  import { Sync } from '../sync/Sync.js';
5
6
  import { EntityFile, MARK_FAILED, UPDATE } from './EntityFile.js';
@@ -22,11 +23,11 @@ export class FileManager extends Disposable {
22
23
  );
23
24
  }
24
25
 
25
- add = async (file: FileData) => {
26
+ add = async (file: FileData, parent: Entity) => {
26
27
  // immediately cache the file
27
28
  let entityFile = this.cache.get(file.id);
28
29
  if (!entityFile) {
29
- entityFile = new EntityFile(file.id, { ctx: this.context });
30
+ entityFile = new EntityFile(file.id, { ctx: this.context, parent });
30
31
  this.cache.set(file.id, entityFile);
31
32
  }
32
33
 
@@ -44,7 +45,10 @@ export class FileManager extends Disposable {
44
45
  * Immediately returns an EntityFile to use, then either loads
45
46
  * the file from cache, local database, or the server.
46
47
  */
47
- get = (id: string, options: { downloadRemote?: boolean; ctx: Context }) => {
48
+ get = (
49
+ id: string,
50
+ options: { downloadRemote?: boolean; ctx: Context; parent: Entity },
51
+ ) => {
48
52
  if (this.cache.has(id)) {
49
53
  return this.cache.get(id)!;
50
54
  }
@@ -17,7 +17,7 @@ export function getLatestVersion(data: {
17
17
  return tsVersion;
18
18
  }
19
19
  return v;
20
- }, 0);
20
+ }, 1);
21
21
 
22
22
  return latestVersion;
23
23
  }