@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.
- package/README.md +12 -7
- package/dist/AutomergeUrl.js +2 -2
- package/dist/DocHandle.d.ts +6 -5
- package/dist/DocHandle.d.ts.map +1 -1
- package/dist/DocHandle.js +7 -7
- package/dist/RemoteHeadsSubscriptions.d.ts +42 -0
- package/dist/RemoteHeadsSubscriptions.d.ts.map +1 -0
- package/dist/RemoteHeadsSubscriptions.js +284 -0
- package/dist/Repo.d.ts +29 -2
- package/dist/Repo.d.ts.map +1 -1
- package/dist/Repo.js +168 -9
- package/dist/helpers/debounce.js +1 -1
- package/dist/helpers/pause.d.ts.map +1 -1
- package/dist/helpers/pause.js +2 -0
- package/dist/helpers/throttle.js +1 -1
- package/dist/helpers/withTimeout.d.ts.map +1 -1
- package/dist/helpers/withTimeout.js +2 -0
- package/dist/index.d.ts +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/network/NetworkAdapter.d.ts +15 -1
- package/dist/network/NetworkAdapter.d.ts.map +1 -1
- package/dist/network/NetworkAdapter.js +3 -1
- package/dist/network/NetworkSubsystem.d.ts +4 -2
- package/dist/network/NetworkSubsystem.d.ts.map +1 -1
- package/dist/network/NetworkSubsystem.js +13 -7
- package/dist/network/messages.d.ts +68 -35
- package/dist/network/messages.d.ts.map +1 -1
- package/dist/network/messages.js +9 -7
- package/dist/storage/StorageSubsystem.d.ts +5 -3
- package/dist/storage/StorageSubsystem.d.ts.map +1 -1
- package/dist/storage/StorageSubsystem.js +23 -5
- package/dist/storage/keyHash.d.ts.map +1 -1
- package/dist/storage/types.d.ts +4 -0
- package/dist/storage/types.d.ts.map +1 -1
- package/dist/synchronizer/CollectionSynchronizer.d.ts +2 -2
- package/dist/synchronizer/CollectionSynchronizer.d.ts.map +1 -1
- package/dist/synchronizer/CollectionSynchronizer.js +9 -3
- package/dist/synchronizer/DocSynchronizer.d.ts.map +1 -1
- package/dist/synchronizer/DocSynchronizer.js +20 -17
- package/dist/synchronizer/Synchronizer.d.ts +12 -3
- package/dist/synchronizer/Synchronizer.d.ts.map +1 -1
- package/package.json +6 -6
- package/src/AutomergeUrl.ts +2 -2
- package/src/DocHandle.ts +10 -9
- package/src/RemoteHeadsSubscriptions.ts +375 -0
- package/src/Repo.ts +241 -16
- package/src/helpers/debounce.ts +1 -1
- package/src/helpers/pause.ts +4 -0
- package/src/helpers/throttle.ts +1 -1
- package/src/helpers/withTimeout.ts +2 -0
- package/src/index.ts +3 -1
- package/src/network/NetworkAdapter.ts +19 -2
- package/src/network/NetworkSubsystem.ts +21 -9
- package/src/network/messages.ts +88 -50
- package/src/storage/StorageSubsystem.ts +30 -7
- package/src/storage/keyHash.ts +2 -0
- package/src/storage/types.ts +3 -0
- package/src/synchronizer/CollectionSynchronizer.ts +13 -5
- package/src/synchronizer/DocSynchronizer.ts +27 -27
- package/src/synchronizer/Synchronizer.ts +13 -3
- package/test/DocHandle.test.ts +0 -17
- package/test/RemoteHeadsSubscriptions.test.ts +353 -0
- package/test/Repo.test.ts +108 -17
- package/test/StorageSubsystem.test.ts +29 -7
- package/test/helpers/waitForMessages.ts +22 -0
- package/test/remoteHeads.test.ts +260 -0
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
203
|
+
this.#receiveMessage(msg)
|
|
152
204
|
})
|
|
153
205
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
-
|
|
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
|