@automerge/automerge-repo 1.0.19 → 1.1.0-alpha.13

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 (68) hide show
  1. package/README.md +12 -7
  2. package/dist/AutomergeUrl.js +2 -2
  3. package/dist/DocHandle.d.ts +6 -5
  4. package/dist/DocHandle.d.ts.map +1 -1
  5. package/dist/DocHandle.js +7 -7
  6. package/dist/RemoteHeadsSubscriptions.d.ts +42 -0
  7. package/dist/RemoteHeadsSubscriptions.d.ts.map +1 -0
  8. package/dist/RemoteHeadsSubscriptions.js +284 -0
  9. package/dist/Repo.d.ts +29 -2
  10. package/dist/Repo.d.ts.map +1 -1
  11. package/dist/Repo.js +168 -9
  12. package/dist/helpers/debounce.js +1 -1
  13. package/dist/helpers/pause.d.ts.map +1 -1
  14. package/dist/helpers/pause.js +2 -0
  15. package/dist/helpers/throttle.js +1 -1
  16. package/dist/helpers/withTimeout.d.ts.map +1 -1
  17. package/dist/helpers/withTimeout.js +2 -0
  18. package/dist/index.d.ts +3 -3
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +1 -1
  21. package/dist/network/NetworkAdapter.d.ts +15 -1
  22. package/dist/network/NetworkAdapter.d.ts.map +1 -1
  23. package/dist/network/NetworkAdapter.js +3 -1
  24. package/dist/network/NetworkSubsystem.d.ts +4 -2
  25. package/dist/network/NetworkSubsystem.d.ts.map +1 -1
  26. package/dist/network/NetworkSubsystem.js +13 -7
  27. package/dist/network/messages.d.ts +68 -35
  28. package/dist/network/messages.d.ts.map +1 -1
  29. package/dist/network/messages.js +9 -7
  30. package/dist/storage/StorageSubsystem.d.ts +5 -3
  31. package/dist/storage/StorageSubsystem.d.ts.map +1 -1
  32. package/dist/storage/StorageSubsystem.js +23 -5
  33. package/dist/storage/keyHash.d.ts.map +1 -1
  34. package/dist/storage/types.d.ts +4 -0
  35. package/dist/storage/types.d.ts.map +1 -1
  36. package/dist/synchronizer/CollectionSynchronizer.d.ts +2 -2
  37. package/dist/synchronizer/CollectionSynchronizer.d.ts.map +1 -1
  38. package/dist/synchronizer/CollectionSynchronizer.js +9 -3
  39. package/dist/synchronizer/DocSynchronizer.d.ts.map +1 -1
  40. package/dist/synchronizer/DocSynchronizer.js +20 -17
  41. package/dist/synchronizer/Synchronizer.d.ts +12 -3
  42. package/dist/synchronizer/Synchronizer.d.ts.map +1 -1
  43. package/package.json +6 -6
  44. package/src/AutomergeUrl.ts +2 -2
  45. package/src/DocHandle.ts +10 -9
  46. package/src/RemoteHeadsSubscriptions.ts +375 -0
  47. package/src/Repo.ts +241 -16
  48. package/src/helpers/debounce.ts +1 -1
  49. package/src/helpers/pause.ts +4 -0
  50. package/src/helpers/throttle.ts +1 -1
  51. package/src/helpers/withTimeout.ts +2 -0
  52. package/src/index.ts +3 -1
  53. package/src/network/NetworkAdapter.ts +19 -2
  54. package/src/network/NetworkSubsystem.ts +21 -9
  55. package/src/network/messages.ts +88 -50
  56. package/src/storage/StorageSubsystem.ts +30 -7
  57. package/src/storage/keyHash.ts +2 -0
  58. package/src/storage/types.ts +3 -0
  59. package/src/synchronizer/CollectionSynchronizer.ts +13 -5
  60. package/src/synchronizer/DocSynchronizer.ts +27 -27
  61. package/src/synchronizer/Synchronizer.ts +13 -3
  62. package/test/DocHandle.test.ts +0 -17
  63. package/test/RemoteHeadsSubscriptions.test.ts +353 -0
  64. package/test/Repo.test.ts +108 -17
  65. package/test/StorageSubsystem.test.ts +29 -7
  66. package/test/helpers/waitForMessages.ts +22 -0
  67. package/test/remoteHeads.test.ts +260 -0
  68. package/.eslintrc +0 -28
@@ -0,0 +1,375 @@
1
+ import { next as A } from "@automerge/automerge"
2
+ import { EventEmitter } from "eventemitter3"
3
+ import { DocumentId, PeerId } from "./types.js"
4
+ import {
5
+ RemoteHeadsChanged,
6
+ RemoteSubscriptionControlMessage,
7
+ } from "./network/messages.js"
8
+ import { StorageId } from "./index.js"
9
+ import debug from "debug"
10
+
11
+ // Notify a DocHandle that remote heads have changed
12
+ export type RemoteHeadsSubscriptionEventPayload = {
13
+ documentId: DocumentId
14
+ storageId: StorageId
15
+ remoteHeads: A.Heads
16
+ timestamp: number
17
+ }
18
+
19
+ // Send a message to the given peer notifying them of new heads
20
+ export type NotifyRemoteHeadsPayload = {
21
+ targetId: PeerId
22
+ documentId: DocumentId
23
+ storageId: StorageId
24
+ heads: A.Heads
25
+ timestamp: number
26
+ }
27
+
28
+ type RemoteHeadsSubscriptionEvents = {
29
+ "remote-heads-changed": (payload: RemoteHeadsSubscriptionEventPayload) => void
30
+ "change-remote-subs": (payload: {
31
+ peers: PeerId[]
32
+ add?: StorageId[]
33
+ remove?: StorageId[]
34
+ }) => void
35
+ "notify-remote-heads": (payload: NotifyRemoteHeadsPayload) => void
36
+ }
37
+
38
+ export class RemoteHeadsSubscriptions extends EventEmitter<RemoteHeadsSubscriptionEvents> {
39
+ // Storage IDs we have received remote heads from
40
+ #knownHeads: Map<DocumentId, Map<StorageId, LastHeads>> = new Map()
41
+ // Storage IDs we have subscribed to via Repo.subscribeToRemoteHeads
42
+ #ourSubscriptions: Set<StorageId> = new Set()
43
+ // Storage IDs other peers have subscribed to by sending us a control message
44
+ #theirSubscriptions: Map<StorageId, Set<PeerId>> = new Map()
45
+ // Peers we will always share remote heads with even if they are not subscribed
46
+ #generousPeers: Set<PeerId> = new Set()
47
+ // Documents each peer has open, we need this information so we only send remote heads of documents that the peer knows
48
+ #subscribedDocsByPeer: Map<PeerId, Set<DocumentId>> = new Map()
49
+
50
+ #log = debug("automerge-repo:remote-heads-subscriptions")
51
+
52
+ subscribeToRemotes(remotes: StorageId[]) {
53
+ this.#log("subscribeToRemotes", remotes)
54
+ const remotesToAdd = []
55
+ for (const remote of remotes) {
56
+ if (!this.#ourSubscriptions.has(remote)) {
57
+ this.#ourSubscriptions.add(remote)
58
+ remotesToAdd.push(remote)
59
+ }
60
+ }
61
+
62
+ if (remotesToAdd.length > 0) {
63
+ this.emit("change-remote-subs", {
64
+ add: remotesToAdd,
65
+ peers: Array.from(this.#generousPeers),
66
+ })
67
+ }
68
+ }
69
+
70
+ unsubscribeFromRemotes(remotes: StorageId[]) {
71
+ this.#log("subscribeToRemotes", remotes)
72
+ const remotesToRemove = []
73
+
74
+ for (const remote of remotes) {
75
+ if (this.#ourSubscriptions.has(remote)) {
76
+ this.#ourSubscriptions.delete(remote)
77
+
78
+ if (!this.#theirSubscriptions.has(remote)) {
79
+ remotesToRemove.push(remote)
80
+ }
81
+ }
82
+ }
83
+
84
+ if (remotesToRemove.length > 0) {
85
+ this.emit("change-remote-subs", {
86
+ remove: remotesToRemove,
87
+ peers: Array.from(this.#generousPeers),
88
+ })
89
+ }
90
+ }
91
+
92
+ handleControlMessage(control: RemoteSubscriptionControlMessage) {
93
+ const remotesToAdd: StorageId[] = []
94
+ const remotesToRemove: StorageId[] = []
95
+ const addedRemotesWeKnow: StorageId[] = []
96
+
97
+ this.#log("handleControlMessage", control)
98
+ if (control.add) {
99
+ for (const remote of control.add) {
100
+ let theirSubs = this.#theirSubscriptions.get(remote)
101
+
102
+ if (this.#ourSubscriptions.has(remote) || theirSubs) {
103
+ addedRemotesWeKnow.push(remote)
104
+ }
105
+
106
+ if (!theirSubs) {
107
+ theirSubs = new Set()
108
+ this.#theirSubscriptions.set(remote, theirSubs)
109
+
110
+ if (!this.#ourSubscriptions.has(remote)) {
111
+ remotesToAdd.push(remote)
112
+ }
113
+ }
114
+
115
+ theirSubs.add(control.senderId)
116
+ }
117
+ }
118
+
119
+ if (control.remove) {
120
+ for (const remote of control.remove) {
121
+ const theirSubs = this.#theirSubscriptions.get(remote)
122
+ if (theirSubs) {
123
+ theirSubs.delete(control.senderId)
124
+
125
+ // if no one is subscribed anymore remove remote
126
+ if (theirSubs.size == 0 && !this.#ourSubscriptions.has(remote)) {
127
+ remotesToRemove.push(remote)
128
+ }
129
+ }
130
+ }
131
+ }
132
+
133
+ if (remotesToAdd.length > 0 || remotesToRemove.length > 0) {
134
+ this.emit("change-remote-subs", {
135
+ peers: Array.from(this.#generousPeers),
136
+ add: remotesToAdd,
137
+ remove: remotesToRemove,
138
+ })
139
+ }
140
+
141
+ // send all our stored heads of documents the peer knows for the remotes they've added
142
+ for (const remote of addedRemotesWeKnow) {
143
+ const subscribedDocs = this.#subscribedDocsByPeer.get(control.senderId)
144
+ if (subscribedDocs) {
145
+ for (const documentId of subscribedDocs) {
146
+ const knownHeads = this.#knownHeads.get(documentId)
147
+ if (!knownHeads) {
148
+ continue
149
+ }
150
+
151
+ const lastHeads = knownHeads.get(remote)
152
+ if (lastHeads) {
153
+ this.emit("notify-remote-heads", {
154
+ targetId: control.senderId,
155
+ documentId,
156
+ heads: lastHeads.heads,
157
+ timestamp: lastHeads.timestamp,
158
+ storageId: remote,
159
+ })
160
+ }
161
+ }
162
+ }
163
+ }
164
+ }
165
+
166
+ /** A peer we are not directly connected to has changed their heads */
167
+ handleRemoteHeads(msg: RemoteHeadsChanged) {
168
+ this.#log("handleRemoteHeads", msg)
169
+ const changedHeads = this.#changedHeads(msg)
170
+
171
+ // Emit a remote-heads-changed event to update local dochandles
172
+ for (const event of changedHeads) {
173
+ if (this.#ourSubscriptions.has(event.storageId)) {
174
+ this.emit("remote-heads-changed", event)
175
+ }
176
+ }
177
+
178
+ // Notify generous peers of these changes regardless of if they are subscribed to us
179
+ for (const event of changedHeads) {
180
+ for (const peer of this.#generousPeers) {
181
+ // don't emit event to sender if sender is a generous peer
182
+ if (peer === msg.senderId) {
183
+ continue
184
+ }
185
+
186
+ this.emit("notify-remote-heads", {
187
+ targetId: peer,
188
+ documentId: event.documentId,
189
+ heads: event.remoteHeads,
190
+ timestamp: event.timestamp,
191
+ storageId: event.storageId,
192
+ })
193
+ }
194
+ }
195
+
196
+ // Notify subscribers of these changes
197
+ for (const event of changedHeads) {
198
+ const theirSubs = this.#theirSubscriptions.get(event.storageId)
199
+ if (theirSubs) {
200
+ for (const peerId of theirSubs) {
201
+ if (this.#isPeerSubscribedToDoc(peerId, event.documentId)) {
202
+ this.emit("notify-remote-heads", {
203
+ targetId: peerId,
204
+ documentId: event.documentId,
205
+ heads: event.remoteHeads,
206
+ timestamp: event.timestamp,
207
+ storageId: event.storageId,
208
+ })
209
+ }
210
+ }
211
+ }
212
+ }
213
+ }
214
+
215
+ /** A peer we are directly connected to has updated their heads */
216
+ handleImmediateRemoteHeadsChanged(
217
+ documentId: DocumentId,
218
+ storageId: StorageId,
219
+ heads: A.Heads
220
+ ) {
221
+ this.#log("handleLocalHeadsChanged", documentId, storageId, heads)
222
+ const remote = this.#knownHeads.get(documentId)
223
+ const timestamp = Date.now()
224
+ if (!remote) {
225
+ this.#knownHeads.set(
226
+ documentId,
227
+ new Map([[storageId, { heads, timestamp }]])
228
+ )
229
+ } else {
230
+ const docRemote = remote.get(storageId)
231
+ if (!docRemote || docRemote.timestamp < Date.now()) {
232
+ remote.set(storageId, { heads, timestamp: Date.now() })
233
+ }
234
+ }
235
+ const theirSubs = this.#theirSubscriptions.get(storageId)
236
+ if (theirSubs) {
237
+ for (const peerId of theirSubs) {
238
+ if (this.#isPeerSubscribedToDoc(peerId, documentId)) {
239
+ this.emit("notify-remote-heads", {
240
+ targetId: peerId,
241
+ documentId: documentId,
242
+ heads: heads,
243
+ timestamp: timestamp,
244
+ storageId: storageId,
245
+ })
246
+ }
247
+ }
248
+ }
249
+ }
250
+
251
+ addGenerousPeer(peerId: PeerId) {
252
+ this.#log("addGenerousPeer", peerId)
253
+ this.#generousPeers.add(peerId)
254
+
255
+ if (this.#ourSubscriptions.size > 0) {
256
+ this.emit("change-remote-subs", {
257
+ add: Array.from(this.#ourSubscriptions),
258
+ peers: [peerId],
259
+ })
260
+ }
261
+
262
+ for (const [documentId, remote] of this.#knownHeads) {
263
+ for (const [storageId, { heads, timestamp }] of remote) {
264
+ this.emit("notify-remote-heads", {
265
+ targetId: peerId,
266
+ documentId: documentId,
267
+ heads: heads,
268
+ timestamp: timestamp,
269
+ storageId: storageId,
270
+ })
271
+ }
272
+ }
273
+ }
274
+
275
+ removePeer(peerId: PeerId) {
276
+ this.#log("removePeer", peerId)
277
+
278
+ const remotesToRemove = []
279
+
280
+ this.#generousPeers.delete(peerId)
281
+ this.#subscribedDocsByPeer.delete(peerId)
282
+
283
+ for (const [storageId, peerIds] of this.#theirSubscriptions) {
284
+ if (peerIds.has(peerId)) {
285
+ peerIds.delete(peerId)
286
+
287
+ if (peerIds.size == 0) {
288
+ remotesToRemove.push(storageId)
289
+ this.#theirSubscriptions.delete(storageId)
290
+ }
291
+ }
292
+ }
293
+
294
+ if (remotesToRemove.length > 0) {
295
+ this.emit("change-remote-subs", {
296
+ remove: remotesToRemove,
297
+ peers: Array.from(this.#generousPeers),
298
+ })
299
+ }
300
+ }
301
+
302
+ subscribePeerToDoc(peerId: PeerId, documentId: DocumentId) {
303
+ let subscribedDocs = this.#subscribedDocsByPeer.get(peerId)
304
+ if (!subscribedDocs) {
305
+ subscribedDocs = new Set()
306
+ this.#subscribedDocsByPeer.set(peerId, subscribedDocs)
307
+ }
308
+
309
+ subscribedDocs.add(documentId)
310
+
311
+ const remoteHeads = this.#knownHeads.get(documentId)
312
+ if (remoteHeads) {
313
+ for (const [storageId, lastHeads] of remoteHeads) {
314
+ const subscribedPeers = this.#theirSubscriptions.get(storageId)
315
+ if (subscribedPeers && subscribedPeers.has(peerId)) {
316
+ this.emit("notify-remote-heads", {
317
+ targetId: peerId,
318
+ documentId,
319
+ heads: lastHeads.heads,
320
+ timestamp: lastHeads.timestamp,
321
+ storageId,
322
+ })
323
+ }
324
+ }
325
+ }
326
+ }
327
+
328
+ #isPeerSubscribedToDoc(peerId: PeerId, documentId: DocumentId) {
329
+ const subscribedDocs = this.#subscribedDocsByPeer.get(peerId)
330
+ return subscribedDocs && subscribedDocs.has(documentId)
331
+ }
332
+
333
+ /** Returns the (document, storageId) pairs which have changed after processing msg */
334
+ #changedHeads(msg: RemoteHeadsChanged): {
335
+ documentId: DocumentId
336
+ storageId: StorageId
337
+ remoteHeads: A.Heads
338
+ timestamp: number
339
+ }[] {
340
+ const changedHeads = []
341
+ const { documentId, newHeads } = msg
342
+ for (const [storageId, { heads, timestamp }] of Object.entries(newHeads)) {
343
+ if (
344
+ !this.#ourSubscriptions.has(storageId as StorageId) &&
345
+ !this.#theirSubscriptions.has(storageId as StorageId)
346
+ ) {
347
+ continue
348
+ }
349
+ let remote = this.#knownHeads.get(documentId)
350
+ if (!remote) {
351
+ remote = new Map()
352
+ this.#knownHeads.set(documentId, remote)
353
+ }
354
+
355
+ const docRemote = remote.get(storageId as StorageId)
356
+ if (docRemote && docRemote.timestamp >= timestamp) {
357
+ continue
358
+ } else {
359
+ remote.set(storageId as StorageId, { timestamp, heads })
360
+ changedHeads.push({
361
+ documentId,
362
+ storageId: storageId as StorageId,
363
+ remoteHeads: heads,
364
+ timestamp,
365
+ })
366
+ }
367
+ }
368
+ return changedHeads
369
+ }
370
+ }
371
+
372
+ type LastHeads = {
373
+ timestamp: number
374
+ heads: A.Heads
375
+ }
package/src/Repo.ts CHANGED
@@ -7,14 +7,18 @@ import {
7
7
  parseAutomergeUrl,
8
8
  } from "./AutomergeUrl.js"
9
9
  import { DocHandle, DocHandleEncodedChangePayload } from "./DocHandle.js"
10
+ import { RemoteHeadsSubscriptions } from "./RemoteHeadsSubscriptions.js"
11
+ import { headsAreSame } from "./helpers/headsAreSame.js"
10
12
  import { throttle } from "./helpers/throttle.js"
11
- import { NetworkAdapter } from "./network/NetworkAdapter.js"
13
+ import { NetworkAdapter, type PeerMetadata } from "./network/NetworkAdapter.js"
12
14
  import { NetworkSubsystem } from "./network/NetworkSubsystem.js"
15
+ import { RepoMessage } from "./network/messages.js"
13
16
  import { StorageAdapter } from "./storage/StorageAdapter.js"
14
17
  import { StorageSubsystem } from "./storage/StorageSubsystem.js"
18
+ import { StorageId } from "./storage/types.js"
15
19
  import { CollectionSynchronizer } from "./synchronizer/CollectionSynchronizer.js"
20
+ import { SyncStatePayload } from "./synchronizer/Synchronizer.js"
16
21
  import type { AnyDocumentId, DocumentId, PeerId } from "./types.js"
17
- import { SyncStateMessage } from "./network/messages.js"
18
22
 
19
23
  /** A Repo is a collection of documents with networking, syncing, and storage capabilities. */
20
24
  /** The `Repo` is the main entry point of this library
@@ -44,8 +48,23 @@ export class Repo extends EventEmitter<RepoEvents> {
44
48
  /** @hidden */
45
49
  sharePolicy: SharePolicy = async () => true
46
50
 
47
- constructor({ storage, network, peerId, sharePolicy }: RepoConfig) {
51
+ /** maps peer id to to persistence information (storageId, isEphemeral), access by collection synchronizer */
52
+ /** @hidden */
53
+ peerMetadataByPeerId: Record<PeerId, PeerMetadata> = {}
54
+
55
+ #remoteHeadsSubscriptions = new RemoteHeadsSubscriptions()
56
+ #remoteHeadsGossipingEnabled = false
57
+
58
+ constructor({
59
+ storage,
60
+ network,
61
+ peerId,
62
+ sharePolicy,
63
+ isEphemeral = storage === undefined,
64
+ enableRemoteHeadsGossiping = false,
65
+ }: RepoConfig) {
48
66
  super()
67
+ this.#remoteHeadsGossipingEnabled = enableRemoteHeadsGossiping
49
68
  this.#log = debug(`automerge-repo:repo`)
50
69
  this.sharePolicy = sharePolicy ?? this.sharePolicy
51
70
 
@@ -62,10 +81,7 @@ export class Repo extends EventEmitter<RepoEvents> {
62
81
  }: DocHandleEncodedChangePayload<any>) => {
63
82
  void storageSubsystem.saveDoc(handle.documentId, doc)
64
83
  }
65
- const debouncedSaveFn = handle.on(
66
- "heads-changed",
67
- throttle(saveFn, this.saveDebounceRate)
68
- )
84
+ handle.on("heads-changed", throttle(saveFn, this.saveDebounceRate))
69
85
 
70
86
  if (isNew) {
71
87
  // this is a new document, immediately save it
@@ -125,6 +141,12 @@ export class Repo extends EventEmitter<RepoEvents> {
125
141
  networkSubsystem.send(message)
126
142
  })
127
143
 
144
+ if (this.#remoteHeadsGossipingEnabled) {
145
+ this.#synchronizer.on("open-doc", ({ peerId, documentId }) => {
146
+ this.#remoteHeadsSubscriptions.subscribePeerToDoc(peerId, documentId)
147
+ })
148
+ }
149
+
128
150
  // STORAGE
129
151
  // The storage subsystem has access to some form of persistence, and deals with save and loading documents.
130
152
  const storageSubsystem = storage ? new StorageSubsystem(storage) : undefined
@@ -132,33 +154,172 @@ export class Repo extends EventEmitter<RepoEvents> {
132
154
 
133
155
  // NETWORK
134
156
  // The network subsystem deals with sending and receiving messages to and from peers.
135
- const networkSubsystem = new NetworkSubsystem(network, peerId)
157
+
158
+ const myPeerMetadata: Promise<PeerMetadata> = new Promise(
159
+ // eslint-disable-next-line no-async-promise-executor -- TODO: fix
160
+ async resolve =>
161
+ resolve({
162
+ storageId: await storageSubsystem?.id(),
163
+ isEphemeral,
164
+ } as PeerMetadata)
165
+ )
166
+
167
+ const networkSubsystem = new NetworkSubsystem(
168
+ network,
169
+ peerId,
170
+ myPeerMetadata
171
+ )
136
172
  this.networkSubsystem = networkSubsystem
137
173
 
138
174
  // When we get a new peer, register it with the synchronizer
139
- networkSubsystem.on("peer", async ({ peerId }) => {
175
+ networkSubsystem.on("peer", async ({ peerId, peerMetadata }) => {
140
176
  this.#log("peer connected", { peerId })
177
+
178
+ if (peerMetadata) {
179
+ this.peerMetadataByPeerId[peerId] = { ...peerMetadata }
180
+ }
181
+
182
+ this.sharePolicy(peerId)
183
+ .then(shouldShare => {
184
+ if (shouldShare && this.#remoteHeadsGossipingEnabled) {
185
+ this.#remoteHeadsSubscriptions.addGenerousPeer(peerId)
186
+ }
187
+ })
188
+ .catch(err => {
189
+ console.log("error in share policy", { err })
190
+ })
191
+
141
192
  this.#synchronizer.addPeer(peerId)
142
193
  })
143
194
 
144
195
  // When a peer disconnects, remove it from the synchronizer
145
196
  networkSubsystem.on("peer-disconnected", ({ peerId }) => {
146
197
  this.#synchronizer.removePeer(peerId)
198
+ this.#remoteHeadsSubscriptions.removePeer(peerId)
147
199
  })
148
200
 
149
201
  // Handle incoming messages
150
202
  networkSubsystem.on("message", async msg => {
151
- await this.#synchronizer.receiveMessage(msg)
203
+ this.#receiveMessage(msg)
152
204
  })
153
205
 
154
- if (storageSubsystem) {
155
- const debouncedSaveSyncState: (syncState: SyncStateMessage) => void =
156
- throttle(({ documentId, peerId, syncState }: SyncStateMessage) => {
157
- storageSubsystem.saveSyncState(documentId, peerId, syncState)
158
- }, this.saveDebounceRate)
206
+ this.#synchronizer.on("sync-state", message => {
207
+ this.#saveSyncState(message)
208
+
209
+ const handle = this.#handleCache[message.documentId]
210
+
211
+ const { storageId } = this.peerMetadataByPeerId[message.peerId] || {}
212
+ if (!storageId) {
213
+ return
214
+ }
215
+
216
+ const heads = handle.getRemoteHeads(storageId)
217
+ const haveHeadsChanged =
218
+ message.syncState.theirHeads &&
219
+ (!heads || !headsAreSame(heads, message.syncState.theirHeads))
220
+
221
+ if (haveHeadsChanged) {
222
+ handle.setRemoteHeads(storageId, message.syncState.theirHeads)
223
+
224
+ if (storageId && this.#remoteHeadsGossipingEnabled) {
225
+ this.#remoteHeadsSubscriptions.handleImmediateRemoteHeadsChanged(
226
+ message.documentId,
227
+ storageId,
228
+ message.syncState.theirHeads
229
+ )
230
+ }
231
+ }
232
+ })
233
+
234
+ if (this.#remoteHeadsGossipingEnabled) {
235
+ this.#remoteHeadsSubscriptions.on("notify-remote-heads", message => {
236
+ this.networkSubsystem.send({
237
+ type: "remote-heads-changed",
238
+ targetId: message.targetId,
239
+ documentId: message.documentId,
240
+ newHeads: {
241
+ [message.storageId]: {
242
+ heads: message.heads,
243
+ timestamp: message.timestamp,
244
+ },
245
+ },
246
+ })
247
+ })
248
+
249
+ this.#remoteHeadsSubscriptions.on("change-remote-subs", message => {
250
+ this.#log("change-remote-subs", message)
251
+ for (const peer of message.peers) {
252
+ this.networkSubsystem.send({
253
+ type: "remote-subscription-change",
254
+ targetId: peer,
255
+ add: message.add,
256
+ remove: message.remove,
257
+ })
258
+ }
259
+ })
260
+
261
+ this.#remoteHeadsSubscriptions.on("remote-heads-changed", message => {
262
+ const handle = this.#handleCache[message.documentId]
263
+ handle.setRemoteHeads(message.storageId, message.remoteHeads)
264
+ })
265
+ }
266
+ }
267
+
268
+ #receiveMessage(message: RepoMessage) {
269
+ switch (message.type) {
270
+ case "remote-subscription-change":
271
+ if (this.#remoteHeadsGossipingEnabled) {
272
+ this.#remoteHeadsSubscriptions.handleControlMessage(message)
273
+ }
274
+ break
275
+ case "remote-heads-changed":
276
+ if (this.#remoteHeadsGossipingEnabled) {
277
+ this.#remoteHeadsSubscriptions.handleRemoteHeads(message)
278
+ }
279
+ break
280
+ case "sync":
281
+ case "request":
282
+ case "ephemeral":
283
+ case "doc-unavailable":
284
+ this.#synchronizer.receiveMessage(message).catch(err => {
285
+ console.log("error receiving message", { err })
286
+ })
287
+ }
288
+ }
289
+
290
+ #throttledSaveSyncStateHandlers: Record<
291
+ StorageId,
292
+ (payload: SyncStatePayload) => void
293
+ > = {}
159
294
 
160
- this.#synchronizer.on("sync-state", debouncedSaveSyncState)
295
+ /** saves sync state throttled per storage id, if a peer doesn't have a storage id it's sync state is not persisted */
296
+ #saveSyncState(payload: SyncStatePayload) {
297
+ if (!this.storageSubsystem) {
298
+ return
161
299
  }
300
+
301
+ const { storageId, isEphemeral } =
302
+ this.peerMetadataByPeerId[payload.peerId] || {}
303
+
304
+ if (!storageId || isEphemeral) {
305
+ return
306
+ }
307
+
308
+ let handler = this.#throttledSaveSyncStateHandlers[storageId]
309
+ if (!handler) {
310
+ handler = this.#throttledSaveSyncStateHandlers[storageId] = throttle(
311
+ ({ documentId, syncState }: SyncStatePayload) => {
312
+ void this.storageSubsystem!.saveSyncState(
313
+ documentId,
314
+ storageId,
315
+ syncState
316
+ )
317
+ },
318
+ this.saveDebounceRate
319
+ )
320
+ }
321
+
322
+ handler(payload)
162
323
  }
163
324
 
164
325
  /** Returns an existing handle if we have it; creates one otherwise. */
@@ -189,6 +350,10 @@ export class Repo extends EventEmitter<RepoEvents> {
189
350
  return this.#synchronizer.peers
190
351
  }
191
352
 
353
+ getStorageIdOfPeer(peerId: PeerId): StorageId | undefined {
354
+ return this.peerMetadataByPeerId[peerId]?.storageId
355
+ }
356
+
192
357
  /**
193
358
  * Creates a new document and returns a handle to it. The initial value of the document is
194
359
  * an empty object `{}`. Its documentId is generated by the system. we emit a `document` event
@@ -298,12 +463,67 @@ export class Repo extends EventEmitter<RepoEvents> {
298
463
  delete this.#handleCache[documentId]
299
464
  this.emit("delete-document", { documentId })
300
465
  }
466
+
467
+ /**
468
+ * Exports a document to a binary format.
469
+ * @param id - The url or documentId of the handle to export
470
+ *
471
+ * @returns Promise<Uint8Array | undefined> - A Promise containing the binary document,
472
+ * or undefined if the document is unavailable.
473
+ */
474
+ async export(id: AnyDocumentId): Promise<Uint8Array | undefined> {
475
+ const documentId = interpretAsDocumentId(id)
476
+
477
+ const handle = this.#getHandle(documentId, false)
478
+ const doc = await handle.doc()
479
+ if (!doc) return undefined
480
+ return Automerge.save(doc)
481
+ }
482
+
483
+ /**
484
+ * Imports document binary into the repo.
485
+ * @param binary - The binary to import
486
+ */
487
+ import<T>(binary: Uint8Array) {
488
+ const doc = Automerge.load<T>(binary)
489
+
490
+ const handle = this.create<T>()
491
+
492
+ handle.update(() => {
493
+ return Automerge.clone(doc)
494
+ })
495
+
496
+ return handle
497
+ }
498
+
499
+ subscribeToRemotes = (remotes: StorageId[]) => {
500
+ if (this.#remoteHeadsGossipingEnabled) {
501
+ this.#log("subscribeToRemotes", { remotes })
502
+ this.#remoteHeadsSubscriptions.subscribeToRemotes(remotes)
503
+ } else {
504
+ this.#log(
505
+ "WARN: subscribeToRemotes called but remote heads gossiping is not enabled"
506
+ )
507
+ }
508
+ }
509
+
510
+ storageId = async (): Promise<StorageId | undefined> => {
511
+ if (!this.storageSubsystem) {
512
+ return undefined
513
+ } else {
514
+ return this.storageSubsystem.id()
515
+ }
516
+ }
301
517
  }
302
518
 
303
519
  export interface RepoConfig {
304
520
  /** Our unique identifier */
305
521
  peerId?: PeerId
306
522
 
523
+ /** Indicates whether other peers should persist the sync state of this peer.
524
+ * Sync state is only persisted for non-ephemeral peers */
525
+ isEphemeral?: boolean
526
+
307
527
  /** A storage adapter can be provided, or not */
308
528
  storage?: StorageAdapter
309
529
 
@@ -315,6 +535,11 @@ export interface RepoConfig {
315
535
  * all peers). A server only syncs documents that a peer explicitly requests by ID.
316
536
  */
317
537
  sharePolicy?: SharePolicy
538
+
539
+ /**
540
+ * Whether to enable the experimental remote heads gossiping feature
541
+ */
542
+ enableRemoteHeadsGossiping?: boolean
318
543
  }
319
544
 
320
545
  /** A function that determines whether we should share a document with a peer