@automerge/automerge-repo 2.5.0 → 2.5.2-alpha.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.
@@ -0,0 +1,722 @@
1
+ import { DocHandle, DocHandleEphemeralMessagePayload } from "./DocHandle.js"
2
+ import { PeerId } from "./types.js"
3
+ import { EventEmitter } from "eventemitter3"
4
+
5
+ export type UserId = unknown
6
+ export type DeviceId = unknown
7
+
8
+ export const PRESENCE_MESSAGE_MARKER = "__presence"
9
+
10
+ export type PeerState<State> = {
11
+ peerId: PeerId
12
+ deviceId?: DeviceId
13
+ userId?: UserId
14
+ value: State
15
+ }
16
+
17
+ type PresenceMessageBase = {
18
+ deviceId?: DeviceId
19
+ userId?: UserId
20
+ }
21
+
22
+ type PresenceMessageUpdate = PresenceMessageBase & {
23
+ type: "update"
24
+ channel: string
25
+ value: any
26
+ }
27
+
28
+ type PresenceMessageSnapshot = PresenceMessageBase & {
29
+ type: "snapshot"
30
+ state: any
31
+ }
32
+
33
+ type PresenceMessageHeartbeat = PresenceMessageBase & {
34
+ type: "heartbeat"
35
+ }
36
+
37
+ type PresenceMessageGoodbye = PresenceMessageBase & {
38
+ type: "goodbye"
39
+ }
40
+
41
+ type PresenceMessage = {
42
+ [PRESENCE_MESSAGE_MARKER]:
43
+ | PresenceMessageUpdate
44
+ | PresenceMessageSnapshot
45
+ | PresenceMessageHeartbeat
46
+ | PresenceMessageGoodbye
47
+ }
48
+
49
+ type PresenceMessageType =
50
+ PresenceMessage[typeof PRESENCE_MESSAGE_MARKER]["type"]
51
+
52
+ type WithPeerId = { peerId: PeerId }
53
+
54
+ export type PresenceEventUpdate = PresenceMessageUpdate & WithPeerId
55
+ export type PresenceEventSnapshot = PresenceMessageSnapshot & WithPeerId
56
+ export type PresenceEventHeartbeat = PresenceMessageHeartbeat & WithPeerId
57
+ export type PresenceEventGoodbye = PresenceMessageGoodbye & WithPeerId
58
+
59
+ /**
60
+ * Events emitted by Presence when ephemeral messages are received from peers.
61
+ */
62
+ export type PresenceEvents = {
63
+ /**
64
+ * Handle a state update broadcast by a peer.
65
+ */
66
+ update: (msg: PresenceEventUpdate) => void
67
+ /**
68
+ * Handle a full state snapshot broadcast by a peer.
69
+ */
70
+ snapshot: (msg: PresenceEventSnapshot) => void
71
+ /**
72
+ * Handle a heartbeat broadcast by a peer.
73
+ */
74
+ heartbeat: (msg: PresenceEventHeartbeat) => void
75
+ /**
76
+ * Handle a disconnection broadcast by a peer.
77
+ */
78
+ goodbye: (msg: PresenceEventGoodbye) => void
79
+ }
80
+
81
+ export const DEFAULT_HEARTBEAT_INTERVAL_MS = 15_000
82
+ export const DEFAULT_PEER_TTL_MS = 3 * DEFAULT_HEARTBEAT_INTERVAL_MS
83
+
84
+ export type PresenceConfig<State> = {
85
+ /** The full initial state to broadcast to peers */
86
+ initialState: State
87
+ /** How frequently to send heartbeats (default {@link DEFAULT_HEARTBEAT_INTERVAL_MS}) */
88
+ heartbeatMs?: number
89
+ /** How long to wait until forgetting peers with no activity (default {@link DEFAULT_PEER_TTL_MS}) */
90
+ peerTtlMs?: number
91
+ }
92
+
93
+ /**
94
+ * Presence encapsulates ephemeral state communication for a specific doc
95
+ * handle. It tracks caller-provided local state and broadcasts that state to
96
+ * all peers. It sends periodic heartbeats when there are no state updates.
97
+ *
98
+ * It also tracks ephemeral state broadcast by peers and emits events when peers
99
+ * send ephemeral state updates (see {@link PresenceEvents}).
100
+ *
101
+ * Presence starts out in an inactive state. Call {@link start} and {@link stop}
102
+ * to activate and deactivate it.
103
+ */
104
+ export class Presence<
105
+ State extends Record<string, any>,
106
+ DocType = any
107
+ > extends EventEmitter<PresenceEvents> {
108
+ #handle: DocHandle<DocType>
109
+ readonly deviceId?: DeviceId
110
+ readonly userId?: UserId
111
+ #peers: PeerPresenceInfo<State>
112
+ #localState?: State
113
+ #heartbeatMs?: number
114
+
115
+ #handleEphemeralMessage:
116
+ | ((e: DocHandleEphemeralMessagePayload<DocType>) => void)
117
+ | undefined
118
+
119
+ #heartbeatInterval: ReturnType<typeof setInterval> | undefined
120
+ #pruningInterval: ReturnType<typeof setInterval> | undefined
121
+ #hellos: ReturnType<typeof setTimeout>[] = []
122
+
123
+ #running = false
124
+
125
+ /**
126
+ * Create a new Presence to share ephemeral state with peers.
127
+ *
128
+ * @param config see {@link PresenceConfig}
129
+ * @returns
130
+ */
131
+ constructor({
132
+ handle,
133
+ deviceId,
134
+ userId,
135
+ }: {
136
+ handle: DocHandle<DocType>
137
+ /** Our device id (like userId, this is unverified; peers can send anything) */
138
+ deviceId?: DeviceId
139
+ /** Our user id (this is unverified; peers can send anything) */
140
+ userId?: UserId
141
+ }) {
142
+ super()
143
+ this.#handle = handle
144
+ this.#peers = new PeerPresenceInfo(DEFAULT_PEER_TTL_MS)
145
+ this.userId = userId
146
+ this.deviceId = deviceId
147
+ }
148
+
149
+ /**
150
+ * Start listening to ephemeral messages on the handle, broadcast initial
151
+ * state to peers, and start sending heartbeats.
152
+ */
153
+ start({ initialState, heartbeatMs, peerTtlMs }: PresenceConfig<State>) {
154
+ if (this.#running) {
155
+ return
156
+ }
157
+ this.#running = true
158
+
159
+ this.#heartbeatMs = heartbeatMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS
160
+ this.#peers = new PeerPresenceInfo(peerTtlMs ?? DEFAULT_PEER_TTL_MS)
161
+ this.#localState = initialState
162
+
163
+ // N.B.: We can't use a regular member function here since member functions
164
+ // of two distinct objects are identical, and we need to be able to stop
165
+ // listening to the handle for just this Presence instance in stop()
166
+ this.#handleEphemeralMessage = (
167
+ e: DocHandleEphemeralMessagePayload<DocType>
168
+ ) => {
169
+ const peerId = e.senderId
170
+ const envelope = e.message as PresenceMessage
171
+
172
+ if (!(PRESENCE_MESSAGE_MARKER in envelope)) {
173
+ return
174
+ }
175
+
176
+ const message = envelope[PRESENCE_MESSAGE_MARKER]
177
+ const { deviceId, userId } = message
178
+
179
+ if (!this.#peers.view.has(peerId)) {
180
+ this.announce()
181
+ }
182
+
183
+ switch (message.type) {
184
+ case "heartbeat":
185
+ this.#peers.markSeen(peerId, deviceId, userId)
186
+ this.emit("heartbeat", {
187
+ type: "heartbeat",
188
+ peerId,
189
+ deviceId,
190
+ userId,
191
+ })
192
+ break
193
+ case "goodbye":
194
+ this.#peers.delete(peerId)
195
+ this.emit("goodbye", {
196
+ type: "goodbye",
197
+ peerId,
198
+ deviceId,
199
+ userId,
200
+ })
201
+ break
202
+ case "update":
203
+ this.#peers.update({
204
+ peerId,
205
+ deviceId,
206
+ userId,
207
+ channel: message.channel as keyof State,
208
+ value: message.value,
209
+ })
210
+ this.emit("update", {
211
+ type: "update",
212
+ peerId,
213
+ deviceId,
214
+ userId,
215
+ channel: message.channel,
216
+ value: message.value,
217
+ })
218
+ break
219
+ case "snapshot":
220
+ Object.entries(message.state as State).forEach(([channel, value]) => {
221
+ this.#peers.update({
222
+ peerId,
223
+ deviceId,
224
+ userId,
225
+ channel: channel as keyof State,
226
+ value,
227
+ })
228
+ })
229
+ this.emit("snapshot", {
230
+ type: "snapshot",
231
+ peerId,
232
+ deviceId,
233
+ userId,
234
+ state: message.state,
235
+ })
236
+ break
237
+ }
238
+ }
239
+ this.#handle.on("ephemeral-message", this.#handleEphemeralMessage)
240
+
241
+ this.broadcastLocalState() // also starts heartbeats
242
+ this.startPruningPeers()
243
+ }
244
+
245
+ /**
246
+ * Return a view of current peer states.
247
+ */
248
+ getPeerStates() {
249
+ return this.#peers.view
250
+ }
251
+
252
+ /**
253
+ * Return a view of current local state.
254
+ */
255
+ getLocalState() {
256
+ return this.#localState
257
+ }
258
+
259
+ /**
260
+ * Update state for the specific channel, and broadcast new state to all
261
+ * peers.
262
+ *
263
+ * @param channel
264
+ * @param value
265
+ */
266
+ broadcast<Channel extends keyof State>(
267
+ channel: Channel,
268
+ value: State[Channel]
269
+ ) {
270
+ this.#localState = Object.assign({}, this.#localState, {
271
+ [channel]: value,
272
+ })
273
+ this.broadcastChannelState(channel, value)
274
+ }
275
+
276
+ /**
277
+ * Whether this Presence is currently active. See
278
+ * {@link start} and {@link stop}.
279
+ */
280
+ get running() {
281
+ return this.#running
282
+ }
283
+
284
+ /**
285
+ * Stop this Presence: broadcast a "goodbye" message (when received, other
286
+ * peers will immediately forget the sender), stop sending heartbeats, and
287
+ * stop listening to ephemeral-messages broadcast from peers.
288
+ *
289
+ * This can be used with browser events like
290
+ * {@link https://developer.mozilla.org/en-US/docs/Web/API/Window/pagehide_event | "pagehide"}
291
+ * or
292
+ * {@link https://developer.mozilla.org/en-US/docs/Web/API/Document/visibilitychange_event | "visibilitychange"}
293
+ * to stop sending and receiving updates when not active.
294
+ */
295
+ stop() {
296
+ if (!this.#running) {
297
+ return
298
+ }
299
+ this.#hellos.forEach(timeoutId => {
300
+ clearTimeout(timeoutId)
301
+ })
302
+ this.#hellos = []
303
+ this.#handle.off("ephemeral-message", this.#handleEphemeralMessage)
304
+ this.stopHeartbeats()
305
+ this.stopPruningPeers()
306
+ this.doBroadcast("goodbye")
307
+ this.#running = false
308
+ }
309
+
310
+ private announce() {
311
+ // Broadcast our current state whenever we see new peers
312
+ // TODO: We currently need to wait for the peer to be ready, but waiting
313
+ // some arbitrary amount of time is brittle
314
+ const helloId = setTimeout(() => {
315
+ this.broadcastLocalState()
316
+ this.#hellos = this.#hellos.filter(id => id !== helloId)
317
+ }, 500)
318
+ this.#hellos.push(helloId)
319
+ }
320
+
321
+ private broadcastLocalState() {
322
+ this.doBroadcast("snapshot", { state: this.#localState })
323
+ this.resetHeartbeats()
324
+ }
325
+
326
+ private broadcastChannelState<Channel extends keyof State>(
327
+ channel: Channel,
328
+ value: State[Channel]
329
+ ) {
330
+ this.doBroadcast("update", { channel, value })
331
+ this.resetHeartbeats()
332
+ }
333
+
334
+ private resetHeartbeats() {
335
+ // Reset heartbeats every time we broadcast a message to avoid sending
336
+ // unnecessary heartbeats when there is plenty of actual update activity
337
+ // happening.
338
+ this.stopHeartbeats()
339
+ this.startHeartbeats()
340
+ }
341
+
342
+ private sendHeartbeat() {
343
+ this.doBroadcast("heartbeat")
344
+ }
345
+
346
+ private doBroadcast(
347
+ type: PresenceMessageType,
348
+ extra?: Record<string, unknown>
349
+ ) {
350
+ if (!this.#running) {
351
+ return
352
+ }
353
+ this.#handle.broadcast({
354
+ [PRESENCE_MESSAGE_MARKER]: {
355
+ userId: this.userId,
356
+ deviceId: this.deviceId,
357
+ type,
358
+ ...extra,
359
+ },
360
+ })
361
+ }
362
+
363
+ private startHeartbeats() {
364
+ if (this.#heartbeatInterval !== undefined) {
365
+ return
366
+ }
367
+ this.#heartbeatInterval = setInterval(() => {
368
+ this.sendHeartbeat()
369
+ }, this.#heartbeatMs)
370
+ }
371
+
372
+ private stopHeartbeats() {
373
+ if (this.#heartbeatInterval === undefined) {
374
+ return
375
+ }
376
+ clearInterval(this.#heartbeatInterval)
377
+ this.#heartbeatInterval = undefined
378
+ }
379
+
380
+ private startPruningPeers() {
381
+ if (this.#pruningInterval !== undefined) {
382
+ return
383
+ }
384
+ // Pruning happens at the heartbeat frequency, not on a peer ttl frequency,
385
+ // to minimize variance between peer expiration, since the heartbeat frequency
386
+ // is expected to be several times higher.
387
+ this.#pruningInterval = setInterval(() => {
388
+ this.#peers.prune()
389
+ }, this.#heartbeatMs)
390
+ }
391
+
392
+ private stopPruningPeers() {
393
+ if (this.#pruningInterval === undefined) {
394
+ return
395
+ }
396
+ clearInterval(this.#pruningInterval)
397
+ this.#pruningInterval = undefined
398
+ }
399
+ }
400
+
401
+ /**
402
+ * A summary of the latest Presence information for the set of peers who have
403
+ * reported a Presence status to us.
404
+ */
405
+ export class PeerPresenceView<State> {
406
+ #peersLastSeen = new Map<PeerId, number>()
407
+ #peerStates = new Map<PeerId, PeerState<State>>()
408
+ #userPeers = new Map<UserId, Set<PeerId>>()
409
+ #devicePeers = new Map<DeviceId, Set<PeerId>>()
410
+
411
+ /** @hidden */
412
+ constructor(
413
+ peersLastSeen: Map<PeerId, number>,
414
+ peerStates: Map<PeerId, PeerState<State>>,
415
+ userPeers: Map<UserId, Set<PeerId>>,
416
+ devicePeers: Map<DeviceId, Set<PeerId>>
417
+ ) {
418
+ this.#peersLastSeen = peersLastSeen
419
+ this.#peerStates = peerStates
420
+ this.#userPeers = userPeers
421
+ this.#devicePeers = devicePeers
422
+ }
423
+
424
+ /**
425
+ * Check if peer is currently present.
426
+ *
427
+ * @param peerId
428
+ * @returns true if the peer has been seen recently
429
+ */
430
+ has(peerId: PeerId) {
431
+ return this.#peerStates.has(peerId)
432
+ }
433
+
434
+ /**
435
+ * Check when the peer was last seen.
436
+ *
437
+ * @param peerId
438
+ * @returns last seen UNIX timestamp, or undefined for unknown peers
439
+ */
440
+ getLastSeen(peerId: PeerId) {
441
+ return this.#peersLastSeen.get(peerId)
442
+ }
443
+
444
+ /**
445
+ * Get all recently-seen peers.
446
+ *
447
+ * @returns Array of peer ids
448
+ */
449
+ getPeers() {
450
+ return Array.from(this.#peerStates.keys())
451
+ }
452
+
453
+ /**
454
+ * Get all recently-seen users.
455
+ *
456
+ * @returns Array of user ids
457
+ */
458
+ getUsers() {
459
+ return Array.from(this.#userPeers.keys())
460
+ }
461
+
462
+ /**
463
+ * Get all recently-seen devices.
464
+ *
465
+ * @returns Array of device ids
466
+ */
467
+ getDevices() {
468
+ return Array.from(this.#devicePeers.keys())
469
+ }
470
+
471
+ /**
472
+ * Get all recently-seen peers for this user.
473
+ *
474
+ * @param userId
475
+ * @returns Array of peer ids for this user
476
+ */
477
+ getUserPeers(userId: UserId) {
478
+ const peers = this.#userPeers.get(userId)
479
+ if (!peers) {
480
+ return
481
+ }
482
+ return Array.from(peers)
483
+ }
484
+
485
+ /**
486
+ * Get all recently-seen peers for this device.
487
+ *
488
+ * @param deviceId
489
+ * @returns Array of peer ids for this device
490
+ */
491
+ getDevicePeers(deviceId: DeviceId) {
492
+ const peers = this.#devicePeers.get(deviceId)
493
+ if (!peers) {
494
+ return
495
+ }
496
+ return Array.from(peers)
497
+ }
498
+
499
+ /**
500
+ * Get most-recently-seen peer from this group.
501
+ *
502
+ * @param peers
503
+ * @returns id of most recently seen peer
504
+ */
505
+ getFreshestPeer(peers: Set<PeerId>) {
506
+ let freshestLastSeen: number
507
+ return Array.from(peers).reduce((freshest: PeerId | undefined, curr) => {
508
+ const lastSeen = this.#peersLastSeen.get(curr)
509
+ if (!lastSeen) {
510
+ return freshest
511
+ }
512
+
513
+ if (!freshest || lastSeen > freshestLastSeen) {
514
+ freshestLastSeen = lastSeen
515
+ return curr
516
+ }
517
+
518
+ return freshest
519
+ }, undefined)
520
+ }
521
+
522
+ /**
523
+ * Get current @type PeerState for given peer.
524
+ *
525
+ * @param peerId
526
+ * @returns details for the peer
527
+ */
528
+ getPeerInfo(peerId: PeerId) {
529
+ return this.#peerStates.get(peerId)
530
+ }
531
+
532
+ /**
533
+ * Get current ephemeral state value for this peer. If a channel is specified,
534
+ * only returns the ephemeral state for that specific channel. Otherwise,
535
+ * returns the full ephemeral state.
536
+ *
537
+ * @param peerId
538
+ * @param channel
539
+ * @returns latest ephemeral state received
540
+ */
541
+ getPeerState<Channel extends keyof State>(peerId: PeerId, channel?: Channel) {
542
+ const fullState = this.#peerStates.get(peerId)?.value
543
+ if (!channel) {
544
+ return fullState
545
+ }
546
+
547
+ return fullState?.[channel]
548
+ }
549
+
550
+ /**
551
+ * Get current ephemeral state value for this user's most-recently-active
552
+ * peer. See {@link getPeerState}.
553
+ *
554
+ * @param userId
555
+ * @param channel
556
+ * @returns
557
+ */
558
+ getUserState<Channel extends keyof State>(userId: UserId, channel?: Channel) {
559
+ const peers = this.#userPeers.get(userId)
560
+ if (!peers) {
561
+ return undefined
562
+ }
563
+ const peer = this.getFreshestPeer(peers)
564
+ if (!peer) {
565
+ return undefined
566
+ }
567
+
568
+ return this.getPeerState(peer, channel)
569
+ }
570
+
571
+ /**
572
+ * Get current ephemeral state value for this device's most-recently-active
573
+ * peer. See {@link getPeerState}.
574
+ *
575
+ * @param userId
576
+ * @param channel
577
+ * @returns
578
+ */
579
+ getDeviceState<Channel extends keyof State>(
580
+ deviceId: UserId,
581
+ channel?: Channel
582
+ ) {
583
+ const peers = this.#devicePeers.get(deviceId)
584
+ if (!peers) {
585
+ return undefined
586
+ }
587
+ const peer = this.getFreshestPeer(peers)
588
+ if (!peer) {
589
+ return undefined
590
+ }
591
+
592
+ return this.getPeerState(peer, channel)
593
+ }
594
+ }
595
+
596
+ class PeerPresenceInfo<State> extends EventEmitter<PresenceEvents> {
597
+ #peersLastSeen = new Map<PeerId, number>()
598
+ #peerStates = new Map<PeerId, PeerState<State>>()
599
+ #userPeers = new Map<UserId, Set<PeerId>>()
600
+ #devicePeers = new Map<DeviceId, Set<PeerId>>()
601
+
602
+ readonly view: PeerPresenceView<State>
603
+
604
+ /**
605
+ * Build a new peer presence state.
606
+ *
607
+ * @param ttl in milliseconds - peers with no activity within this timeframe
608
+ * are forgotten when {@link prune} is called.
609
+ */
610
+ constructor(readonly ttl: number) {
611
+ super()
612
+ this.view = new PeerPresenceView(
613
+ this.#peersLastSeen,
614
+ this.#peerStates,
615
+ this.#userPeers,
616
+ this.#devicePeers
617
+ )
618
+ }
619
+
620
+ /**
621
+ * Record that we've seen the given peer recently.
622
+ *
623
+ * @param peerId
624
+ * @param deviceId
625
+ * @param userId
626
+ */
627
+ markSeen(peerId: PeerId, deviceId?: DeviceId, userId?: UserId) {
628
+ const devicePeers = this.#devicePeers.get(deviceId) ?? new Set<PeerId>()
629
+ devicePeers.add(peerId)
630
+ this.#devicePeers.set(deviceId, devicePeers)
631
+
632
+ const userPeers = this.#userPeers.get(userId) ?? new Set<PeerId>()
633
+ userPeers.add(peerId)
634
+ this.#userPeers.set(userId, userPeers)
635
+
636
+ this.#peersLastSeen.set(peerId, Date.now())
637
+ }
638
+
639
+ /**
640
+ * Record a state update for the given peer. It is also automatically updated with {@link markSeen}.
641
+ *
642
+ * @param peerId
643
+ * @param deviceId
644
+ * @param userId
645
+ * @param value
646
+ */
647
+ update<Channel extends keyof State>({
648
+ peerId,
649
+ deviceId,
650
+ userId,
651
+ channel,
652
+ value,
653
+ }: {
654
+ peerId: PeerId
655
+ deviceId?: DeviceId
656
+ userId?: UserId
657
+ channel: Channel
658
+ value: State[Channel]
659
+ }) {
660
+ this.markSeen(peerId, deviceId, userId)
661
+
662
+ const peerState = this.#peerStates.get(peerId)
663
+ const existingState = peerState?.value ?? ({} as State)
664
+ this.#peerStates.set(peerId, {
665
+ peerId,
666
+ deviceId,
667
+ userId,
668
+ value: {
669
+ ...existingState,
670
+ [channel]: value,
671
+ },
672
+ })
673
+ }
674
+
675
+ /**
676
+ * Forget the given peer.
677
+ *
678
+ * @param peerId
679
+ */
680
+ delete(peerId: PeerId) {
681
+ this.#peersLastSeen.delete(peerId)
682
+ this.#peerStates.delete(peerId)
683
+
684
+ Array.from(this.#devicePeers.entries()).forEach(([deviceId, peerIds]) => {
685
+ if (peerIds.has(peerId)) {
686
+ peerIds.delete(peerId)
687
+ }
688
+ if (peerIds.size === 0) {
689
+ this.#devicePeers.delete(deviceId)
690
+ }
691
+ })
692
+ Array.from(this.#userPeers.entries()).forEach(([userId, peerIds]) => {
693
+ if (peerIds.has(peerId)) {
694
+ peerIds.delete(peerId)
695
+ }
696
+ if (peerIds.size === 0) {
697
+ this.#userPeers.delete(userId)
698
+ }
699
+ })
700
+ }
701
+
702
+ /**
703
+ * Prune all peers that have not been seen since the configured ttl has
704
+ * elapsed.
705
+ */
706
+ prune() {
707
+ const threshold = Date.now() - this.ttl
708
+ const stalePeers = new Set(
709
+ Array.from(this.#peersLastSeen.entries())
710
+ .filter(([, lastSeen]) => {
711
+ return lastSeen < threshold
712
+ })
713
+ .map(([peerId]) => peerId)
714
+ )
715
+ if (stalePeers.size === 0) {
716
+ return
717
+ }
718
+ stalePeers.forEach(stalePeer => {
719
+ this.delete(stalePeer)
720
+ })
721
+ }
722
+ }
package/src/Repo.ts CHANGED
@@ -977,6 +977,10 @@ export class Repo extends EventEmitter<RepoEvents> {
977
977
  metrics(): { documents: { [key: string]: any } } {
978
978
  return { documents: this.synchronizer.metrics() }
979
979
  }
980
+
981
+ shareConfigChanged() {
982
+ void this.synchronizer.reevaluateDocumentShare()
983
+ }
980
984
  }
981
985
 
982
986
  export interface RepoConfig {