@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.
- package/dist/bundle/index.js +6 -6
- package/dist/bundle/index.js.map +4 -4
- package/dist/esm/__tests__/entities.test.js +296 -1
- package/dist/esm/__tests__/entities.test.js.map +1 -1
- package/dist/esm/client/Client.js +4 -5
- package/dist/esm/client/Client.js.map +1 -1
- package/dist/esm/entities/Entity.d.ts +6 -2
- package/dist/esm/entities/Entity.js +96 -39
- 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 +6 -0
- package/dist/esm/entities/EntityMetadata.js +52 -4
- package/dist/esm/entities/EntityMetadata.js.map +1 -1
- package/dist/esm/entities/EntityStore.js +4 -1
- package/dist/esm/entities/EntityStore.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 +311 -0
- package/src/client/Client.ts +12 -4
- package/src/entities/Entity.test.ts +8 -7
- package/src/entities/Entity.ts +117 -38
- package/src/entities/EntityCache.ts +20 -10
- package/src/entities/EntityMetadata.ts +64 -5
- package/src/entities/EntityStore.ts +5 -1
- package/src/files/EntityFile.ts +16 -3
- package/src/files/FileManager.ts +7 -3
- 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
|
|
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
|
}
|
|
@@ -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
|
-
|
|
97
|
-
|
|
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
|
-
|
|
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
|
|
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,
|
package/src/files/EntityFile.ts
CHANGED
|
@@ -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.
|
|
85
|
+
this.emitChange();
|
|
73
86
|
};
|
|
74
87
|
|
|
75
88
|
[MARK_FAILED] = () => {
|
|
76
89
|
this._failed = true;
|
|
77
90
|
this._loading = false;
|
|
78
|
-
this.
|
|
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.
|
|
99
|
+
this.emitChange();
|
|
87
100
|
};
|
|
88
101
|
|
|
89
102
|
get url(): string | null {
|
package/src/files/FileManager.ts
CHANGED
|
@@ -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 = (
|
|
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
|
}
|