@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.
@@ -1,6 +1,11 @@
1
- import { EventSubscriber, FileData } from '@verdant-web/common';
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
- // this one goes on Entity
13
- export const CHILD_FILE_CHANGED = Symbol('child-file-changed');
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 parent: Entity;
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
- parent,
59
+ fileContext,
43
60
  }: {
44
61
  downloadRemote?: boolean;
45
62
  ctx: Context;
46
- parent: Entity;
63
+ fileContext: EntityFileContext;
47
64
  },
48
65
  ) {
49
66
  super();
50
67
  this.ctx = ctx;
51
- this.parent = parent;
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.parent[CHILD_FILE_CHANGED](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(): FileData {
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
  }
@@ -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 { EntityFile, MARK_FAILED, UPDATE } from './EntityFile.js';
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, parent: Entity) => {
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
- entityFile[UPDATE](file);
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
- entityFile[UPDATE](processedFile);
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: { downloadRemote?: boolean; ctx: Context; parent: Entity },
50
+ options: {
51
+ downloadRemote?: boolean;
52
+ ctx: Context;
53
+ fileContext: EntityFileContext;
54
+ },
53
55
  ) => {
54
- if (this.cache.has(id)) {
55
- return this.cache.get(id)!;
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(id, file);
59
- this.load(file);
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
- file[UPDATE](fileData);
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
- file[UPDATE](result.data);
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._self = message.userInfo;
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 (message.type === 'sync-resp') {
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(message.peerPresence)) {
159
+ for (const [id, userInfo] of Object.entries(peerSnapshot)) {
155
160
  if (this.isSelf(localReplicaInfo, userInfo)) {
156
- this._self = userInfo;
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 hasPeers = this.presence.getViewPeers().length > 0;
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 (this.presence.getViewPeers().length === 0) {
327
+ if (!shouldUseRealtime() && this.mode === 'realtime') {
324
328
  this.setMode('pull');
325
329
  }
326
330
  }, 1000);