@verdant-web/store 5.4.4 → 5.6.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 +8 -8
- package/dist/bundle/index.js.map +4 -4
- package/dist/esm/__tests__/entities.test.js +3 -3
- package/dist/esm/__tests__/entities.test.js.map +1 -1
- package/dist/esm/entities/Entity.d.ts +2 -3
- package/dist/esm/entities/Entity.js +37 -17
- package/dist/esm/entities/Entity.js.map +1 -1
- package/dist/esm/entities/EntityStore.js +1 -1
- package/dist/esm/entities/EntityStore.js.map +1 -1
- package/dist/esm/files/EntityFile.d.ts +22 -7
- package/dist/esm/files/EntityFile.js +30 -6
- package/dist/esm/files/EntityFile.js.map +1 -1
- package/dist/esm/files/FileManager.d.ts +5 -4
- package/dist/esm/files/FileManager.js +35 -16
- package/dist/esm/files/FileManager.js.map +1 -1
- package/dist/esm/sync/PresenceManager.d.ts +1 -0
- package/dist/esm/sync/PresenceManager.js +29 -8
- package/dist/esm/sync/PresenceManager.js.map +1 -1
- package/dist/esm/sync/Sync.js +8 -5
- package/dist/esm/sync/Sync.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/entities.test.ts +3 -3
- package/src/entities/Entity.ts +30 -8
- package/src/entities/EntityStore.ts +1 -1
- package/src/files/EntityFile.ts +52 -10
- package/src/files/FileManager.ts +45 -20
- package/src/sync/PresenceManager.ts +34 -8
- package/src/sync/Sync.ts +10 -6
package/src/files/EntityFile.ts
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
EventSubscriber,
|
|
3
|
+
FileData,
|
|
4
|
+
FileRef,
|
|
5
|
+
ObjectIdentifier,
|
|
6
|
+
PropertyName,
|
|
7
|
+
} from '@verdant-web/common';
|
|
2
8
|
import { Context } from '../context/context.js';
|
|
3
|
-
import { Entity } from '../entities/Entity.js';
|
|
4
9
|
|
|
5
10
|
export type EntityFileEvents = {
|
|
6
11
|
change: () => void;
|
|
@@ -9,12 +14,23 @@ export type EntityFileEvents = {
|
|
|
9
14
|
export const UPDATE = Symbol('entity-file-update');
|
|
10
15
|
export const MARK_FAILED = Symbol('entity-file-mark-failed');
|
|
11
16
|
|
|
12
|
-
|
|
13
|
-
|
|
17
|
+
export interface EntityFileContext {
|
|
18
|
+
readonly oid: ObjectIdentifier;
|
|
19
|
+
readonly key: PropertyName;
|
|
20
|
+
getFileRef(): FileRef | null;
|
|
21
|
+
applyFileRef(file: FileRef): void;
|
|
22
|
+
subscribe(callback: () => void): () => void;
|
|
23
|
+
onChange(): void;
|
|
24
|
+
}
|
|
14
25
|
|
|
15
26
|
export type EntityFileSnapshot = {
|
|
16
27
|
id: string;
|
|
17
28
|
url?: string | null;
|
|
29
|
+
name: string;
|
|
30
|
+
remote: boolean;
|
|
31
|
+
type: string;
|
|
32
|
+
file?: Blob | null;
|
|
33
|
+
alt: string | null;
|
|
18
34
|
};
|
|
19
35
|
|
|
20
36
|
/**
|
|
@@ -30,26 +46,29 @@ export class EntityFile extends EventSubscriber<EntityFileEvents> {
|
|
|
30
46
|
private _failedReason: string | undefined;
|
|
31
47
|
private _downloadRemote = false;
|
|
32
48
|
private _uploaded = false;
|
|
49
|
+
private _alt: string | null = null;
|
|
33
50
|
private ctx: Context;
|
|
34
51
|
private unsubscribes: (() => void)[] = [];
|
|
35
|
-
private
|
|
52
|
+
private fileContext: EntityFileContext;
|
|
36
53
|
|
|
37
54
|
constructor(
|
|
38
55
|
public readonly id: string,
|
|
39
56
|
{
|
|
40
57
|
downloadRemote = false,
|
|
41
58
|
ctx,
|
|
42
|
-
|
|
59
|
+
fileContext,
|
|
43
60
|
}: {
|
|
44
61
|
downloadRemote?: boolean;
|
|
45
62
|
ctx: Context;
|
|
46
|
-
|
|
63
|
+
fileContext: EntityFileContext;
|
|
47
64
|
},
|
|
48
65
|
) {
|
|
49
66
|
super();
|
|
50
67
|
this.ctx = ctx;
|
|
51
|
-
this.
|
|
68
|
+
this.fileContext = fileContext;
|
|
52
69
|
this._downloadRemote = downloadRemote;
|
|
70
|
+
this._alt = fileContext.getFileRef()?.alt ?? null;
|
|
71
|
+
this.unsubscribes.push(fileContext.subscribe(this.onContextChange));
|
|
53
72
|
|
|
54
73
|
this.unsubscribes.push(
|
|
55
74
|
this.ctx.internalEvents.subscribe(`fileUploaded:${id}`, this.onUploaded),
|
|
@@ -68,9 +87,31 @@ export class EntityFile extends EventSubscriber<EntityFileEvents> {
|
|
|
68
87
|
get error() {
|
|
69
88
|
return this._failedReason || null;
|
|
70
89
|
}
|
|
90
|
+
get alt() {
|
|
91
|
+
return this._alt;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
setAlt = (alt: string | null) => {
|
|
95
|
+
const file = this.fileContext.getFileRef();
|
|
96
|
+
if (!file || file.id !== this.id) {
|
|
97
|
+
throw new Error(
|
|
98
|
+
'Cannot set alt text on a file which is no longer present',
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
if ((file.alt ?? null) === alt) return;
|
|
102
|
+
this.fileContext.applyFileRef({ ...file, alt });
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
private onContextChange = () => {
|
|
106
|
+
const nextAlt = this.fileContext.getFileRef()?.alt ?? null;
|
|
107
|
+
if (nextAlt !== this._alt) {
|
|
108
|
+
this._alt = nextAlt;
|
|
109
|
+
this.emitChange();
|
|
110
|
+
}
|
|
111
|
+
};
|
|
71
112
|
|
|
72
113
|
private emitChange() {
|
|
73
|
-
this.
|
|
114
|
+
this.fileContext.onChange();
|
|
74
115
|
this.emit('change');
|
|
75
116
|
}
|
|
76
117
|
|
|
@@ -135,7 +176,7 @@ export class EntityFile extends EventSubscriber<EntityFileEvents> {
|
|
|
135
176
|
this.dispose();
|
|
136
177
|
};
|
|
137
178
|
|
|
138
|
-
getSnapshot():
|
|
179
|
+
getSnapshot(): EntityFileSnapshot {
|
|
139
180
|
return {
|
|
140
181
|
id: this.id,
|
|
141
182
|
url: this._fileData?.url ?? this._objectUrl ?? undefined,
|
|
@@ -143,6 +184,7 @@ export class EntityFile extends EventSubscriber<EntityFileEvents> {
|
|
|
143
184
|
remote: this._fileData?.remote ?? false,
|
|
144
185
|
type: this.type ?? '',
|
|
145
186
|
file: this._fileData?.file,
|
|
187
|
+
alt: this.alt,
|
|
146
188
|
};
|
|
147
189
|
}
|
|
148
190
|
}
|
package/src/files/FileManager.ts
CHANGED
|
@@ -1,15 +1,20 @@
|
|
|
1
1
|
import { FileData } from '@verdant-web/common';
|
|
2
2
|
import { Context } from '../context/context.js';
|
|
3
|
-
import { Entity } from '../entities/Entity.js';
|
|
4
3
|
import { Disposable } from '../internal.js';
|
|
5
4
|
import { Sync } from '../sync/Sync.js';
|
|
6
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
EntityFile,
|
|
7
|
+
EntityFileContext,
|
|
8
|
+
MARK_FAILED,
|
|
9
|
+
UPDATE,
|
|
10
|
+
} from './EntityFile.js';
|
|
7
11
|
|
|
8
12
|
export class FileManager extends Disposable {
|
|
9
13
|
private sync;
|
|
10
14
|
private context;
|
|
11
15
|
|
|
12
|
-
private cache = new Map<string, EntityFile
|
|
16
|
+
private cache = new Map<string, WeakRef<EntityFile>>();
|
|
17
|
+
private fileData = new Map<string, WeakRef<FileData>>();
|
|
13
18
|
|
|
14
19
|
constructor({ sync, context }: { sync: Sync; context: Context }) {
|
|
15
20
|
super();
|
|
@@ -23,24 +28,17 @@ export class FileManager extends Disposable {
|
|
|
23
28
|
);
|
|
24
29
|
}
|
|
25
30
|
|
|
26
|
-
add = async (file: FileData
|
|
27
|
-
// immediately cache the file
|
|
28
|
-
let entityFile = this.cache.get(file.id);
|
|
29
|
-
if (!entityFile) {
|
|
30
|
-
entityFile = new EntityFile(file.id, { ctx: this.context, parent });
|
|
31
|
-
this.cache.set(file.id, entityFile);
|
|
32
|
-
}
|
|
33
|
-
|
|
31
|
+
add = async (file: FileData) => {
|
|
34
32
|
if (!file.remote) {
|
|
35
33
|
// immediately update local files.
|
|
36
|
-
|
|
34
|
+
this.updateFileData(file);
|
|
37
35
|
}
|
|
38
36
|
// this will download any original remote file and trigger a re-upload to the
|
|
39
37
|
// new file's identity, in addition to storing it on disk
|
|
40
38
|
const processedFile = await (
|
|
41
39
|
await this.context.files
|
|
42
40
|
).add(file, { cloneRemote: true });
|
|
43
|
-
|
|
41
|
+
this.updateFileData(processedFile);
|
|
44
42
|
};
|
|
45
43
|
|
|
46
44
|
/**
|
|
@@ -49,22 +47,49 @@ export class FileManager extends Disposable {
|
|
|
49
47
|
*/
|
|
50
48
|
get = (
|
|
51
49
|
id: string,
|
|
52
|
-
options: {
|
|
50
|
+
options: {
|
|
51
|
+
downloadRemote?: boolean;
|
|
52
|
+
ctx: Context;
|
|
53
|
+
fileContext: EntityFileContext;
|
|
54
|
+
},
|
|
53
55
|
) => {
|
|
54
|
-
|
|
55
|
-
|
|
56
|
+
const cacheKey = `${options.fileContext.oid}:${String(options.fileContext.key)}:${id}`;
|
|
57
|
+
const cachedFile = this.cache.get(cacheKey)?.deref();
|
|
58
|
+
if (cachedFile) {
|
|
59
|
+
return cachedFile;
|
|
56
60
|
}
|
|
61
|
+
this.cache.delete(cacheKey);
|
|
57
62
|
const file = new EntityFile(id, options);
|
|
58
|
-
this.cache.set(
|
|
59
|
-
this.
|
|
63
|
+
this.cache.set(cacheKey, this.context.weakRef(file));
|
|
64
|
+
const data = this.fileData.get(id)?.deref();
|
|
65
|
+
if (data) {
|
|
66
|
+
file[UPDATE](data);
|
|
67
|
+
} else {
|
|
68
|
+
this.fileData.delete(id);
|
|
69
|
+
this.load(file);
|
|
70
|
+
}
|
|
60
71
|
return file;
|
|
61
72
|
};
|
|
62
73
|
|
|
74
|
+
private updateFileData = (data: FileData) => {
|
|
75
|
+
this.fileData.set(data.id, this.context.weakRef(data));
|
|
76
|
+
for (const [key, fileRef] of this.cache) {
|
|
77
|
+
const file = fileRef.deref();
|
|
78
|
+
if (!file) {
|
|
79
|
+
this.cache.delete(key);
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
if (file.id === data.id) {
|
|
83
|
+
file[UPDATE](data);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
|
|
63
88
|
private load = async (file: EntityFile) => {
|
|
64
89
|
const fileData = await (await this.context.files).get(file.id);
|
|
65
90
|
// immediately apply any file data we have
|
|
66
91
|
if (fileData) {
|
|
67
|
-
|
|
92
|
+
this.updateFileData(fileData);
|
|
68
93
|
}
|
|
69
94
|
|
|
70
95
|
if (!fileData?.url && (!fileData || fileData.remote)) {
|
|
@@ -96,7 +121,7 @@ export class FileManager extends Disposable {
|
|
|
96
121
|
url: result.data.url,
|
|
97
122
|
};
|
|
98
123
|
await (await this.context.files).update(copyWithUrl);
|
|
99
|
-
|
|
124
|
+
this.updateFileData(result.data);
|
|
100
125
|
} else {
|
|
101
126
|
this.context.log('error', 'Failed to load file', result);
|
|
102
127
|
if (!fileData) {
|
|
@@ -134,29 +134,32 @@ export class PresenceManager<
|
|
|
134
134
|
let selfChanged = false;
|
|
135
135
|
const peerIdsSet = new Set<string>(this.peerIds);
|
|
136
136
|
|
|
137
|
+
const peerSnapshot =
|
|
138
|
+
message.type === 'sync-resp' ||
|
|
139
|
+
(message.type === 'server-ack' && message.peerPresence)
|
|
140
|
+
? message.peerPresence
|
|
141
|
+
: undefined;
|
|
142
|
+
|
|
137
143
|
if (message.type === 'presence-changed') {
|
|
138
144
|
if (this.isSelf(localReplicaInfo, message.userInfo)) {
|
|
139
|
-
this.
|
|
140
|
-
this._selfReplicaIds.add(message.userInfo.replicaId);
|
|
145
|
+
this.updateSelf(localReplicaInfo, message.userInfo);
|
|
141
146
|
selfChanged = true;
|
|
142
|
-
this.emit('selfChanged', message.userInfo);
|
|
143
147
|
} else {
|
|
144
148
|
peerIdsSet.add(message.userInfo.id);
|
|
145
149
|
this._peers[message.userInfo.id] = message.userInfo;
|
|
146
150
|
peersChanged = true;
|
|
147
151
|
this.emit('peerChanged', message.userInfo.id, message.userInfo);
|
|
148
152
|
}
|
|
149
|
-
} else if (
|
|
153
|
+
} else if (peerSnapshot) {
|
|
150
154
|
// reset to provided presence data, which includes all peers.
|
|
155
|
+
const previousPeerIds = this._peerIds;
|
|
151
156
|
this._peers = {};
|
|
152
157
|
peerIdsSet.clear();
|
|
153
158
|
|
|
154
|
-
for (const [id, userInfo] of Object.entries(
|
|
159
|
+
for (const [id, userInfo] of Object.entries(peerSnapshot)) {
|
|
155
160
|
if (this.isSelf(localReplicaInfo, userInfo)) {
|
|
156
|
-
this.
|
|
157
|
-
this._selfReplicaIds.add(userInfo.replicaId);
|
|
161
|
+
this.updateSelf(localReplicaInfo, userInfo);
|
|
158
162
|
selfChanged = true;
|
|
159
|
-
this.emit('selfChanged', userInfo);
|
|
160
163
|
} else {
|
|
161
164
|
peersChanged = true;
|
|
162
165
|
peerIdsSet.add(id);
|
|
@@ -164,6 +167,10 @@ export class PresenceManager<
|
|
|
164
167
|
this.emit('peerChanged', id, userInfo);
|
|
165
168
|
}
|
|
166
169
|
}
|
|
170
|
+
// also notify when peers have all left
|
|
171
|
+
if (previousPeerIds.length !== peerIdsSet.size) {
|
|
172
|
+
peersChanged = true;
|
|
173
|
+
}
|
|
167
174
|
} else if (message.type === 'presence-offline') {
|
|
168
175
|
peerIdsSet.delete(message.userId);
|
|
169
176
|
const lastPresence = this._peers[message.userId];
|
|
@@ -182,6 +189,25 @@ export class PresenceManager<
|
|
|
182
189
|
}
|
|
183
190
|
};
|
|
184
191
|
|
|
192
|
+
private updateSelf = (
|
|
193
|
+
localReplicaInfo: LocalReplicaInfo,
|
|
194
|
+
userInfo: UserInfo<Profile, Presence>,
|
|
195
|
+
) => {
|
|
196
|
+
this._self = userInfo;
|
|
197
|
+
if (userInfo.replicaIds) {
|
|
198
|
+
// the server reports the full set of this user's active replicas,
|
|
199
|
+
// so replace rather than accumulate; that way replicas which go
|
|
200
|
+
// away are forgotten.
|
|
201
|
+
this._selfReplicaIds = new Set([
|
|
202
|
+
localReplicaInfo.id,
|
|
203
|
+
...userInfo.replicaIds,
|
|
204
|
+
]);
|
|
205
|
+
} else {
|
|
206
|
+
this._selfReplicaIds.add(userInfo.replicaId);
|
|
207
|
+
}
|
|
208
|
+
this.emit('selfChanged', userInfo);
|
|
209
|
+
};
|
|
210
|
+
|
|
185
211
|
update = async (presence: Partial<Presence>) => {
|
|
186
212
|
this._updateBatch.update({
|
|
187
213
|
items: [{ presence }],
|
package/src/sync/Sync.ts
CHANGED
|
@@ -306,21 +306,25 @@ export class ServerSync<Presence = any, Profile = any>
|
|
|
306
306
|
// automatically shift between transport modes depending
|
|
307
307
|
// on whether any peers are present (matching view if
|
|
308
308
|
// applicable)
|
|
309
|
+
const shouldUseRealtime = () => {
|
|
310
|
+
const hasPeers = this.presence.getViewPeers().length > 0;
|
|
311
|
+
return (
|
|
312
|
+
hasPeers ||
|
|
313
|
+
(automaticTransportSelection !== 'peers-only' &&
|
|
314
|
+
this.presence.selfReplicaIds.size > 1)
|
|
315
|
+
);
|
|
316
|
+
};
|
|
309
317
|
const decideIfUpgrade = () => {
|
|
310
318
|
if (switchoverTimeout) {
|
|
311
319
|
clearTimeout(switchoverTimeout);
|
|
312
320
|
}
|
|
313
|
-
const
|
|
314
|
-
const shouldUpgrade =
|
|
315
|
-
hasPeers ||
|
|
316
|
-
(automaticTransportSelection !== 'peers-only' &&
|
|
317
|
-
this.presence.selfReplicaIds.size > 1);
|
|
321
|
+
const shouldUpgrade = shouldUseRealtime();
|
|
318
322
|
if (shouldUpgrade && this.mode === 'pull') {
|
|
319
323
|
this.setMode('realtime');
|
|
320
324
|
} else if (!shouldUpgrade && this.mode === 'realtime') {
|
|
321
325
|
// wait 1 second then switch to pull mode if still empty
|
|
322
326
|
switchoverTimeout = setTimeout(() => {
|
|
323
|
-
if (
|
|
327
|
+
if (!shouldUseRealtime() && this.mode === 'realtime') {
|
|
324
328
|
this.setMode('pull');
|
|
325
329
|
}
|
|
326
330
|
}, 1000);
|