@automerge/automerge-repo 2.0.0-collectionsync-alpha.1 → 2.0.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.
Files changed (203) hide show
  1. package/README.md +8 -8
  2. package/dist/AutomergeUrl.d.ts +17 -5
  3. package/dist/AutomergeUrl.d.ts.map +1 -1
  4. package/dist/AutomergeUrl.js +71 -24
  5. package/dist/DocHandle.d.ts +33 -41
  6. package/dist/DocHandle.d.ts.map +1 -1
  7. package/dist/DocHandle.js +105 -66
  8. package/dist/FindProgress.d.ts +30 -0
  9. package/dist/FindProgress.d.ts.map +1 -0
  10. package/dist/FindProgress.js +1 -0
  11. package/dist/RemoteHeadsSubscriptions.d.ts +4 -5
  12. package/dist/RemoteHeadsSubscriptions.d.ts.map +1 -1
  13. package/dist/RemoteHeadsSubscriptions.js +4 -1
  14. package/dist/Repo.d.ts +24 -5
  15. package/dist/Repo.d.ts.map +1 -1
  16. package/dist/Repo.js +355 -169
  17. package/dist/helpers/abortable.d.ts +36 -0
  18. package/dist/helpers/abortable.d.ts.map +1 -0
  19. package/dist/helpers/abortable.js +47 -0
  20. package/dist/helpers/arraysAreEqual.d.ts.map +1 -1
  21. package/dist/helpers/bufferFromHex.d.ts +3 -0
  22. package/dist/helpers/bufferFromHex.d.ts.map +1 -0
  23. package/dist/helpers/bufferFromHex.js +13 -0
  24. package/dist/helpers/debounce.d.ts.map +1 -1
  25. package/dist/helpers/eventPromise.d.ts.map +1 -1
  26. package/dist/helpers/headsAreSame.d.ts +2 -2
  27. package/dist/helpers/headsAreSame.d.ts.map +1 -1
  28. package/dist/helpers/mergeArrays.d.ts +1 -1
  29. package/dist/helpers/mergeArrays.d.ts.map +1 -1
  30. package/dist/helpers/pause.d.ts.map +1 -1
  31. package/dist/helpers/tests/network-adapter-tests.d.ts.map +1 -1
  32. package/dist/helpers/tests/network-adapter-tests.js +13 -13
  33. package/dist/helpers/tests/storage-adapter-tests.d.ts.map +1 -1
  34. package/dist/helpers/tests/storage-adapter-tests.js +6 -9
  35. package/dist/helpers/throttle.d.ts.map +1 -1
  36. package/dist/helpers/withTimeout.d.ts.map +1 -1
  37. package/dist/index.d.ts +35 -7
  38. package/dist/index.d.ts.map +1 -1
  39. package/dist/index.js +37 -6
  40. package/dist/network/NetworkSubsystem.d.ts +0 -1
  41. package/dist/network/NetworkSubsystem.d.ts.map +1 -1
  42. package/dist/network/NetworkSubsystem.js +0 -3
  43. package/dist/network/messages.d.ts +1 -7
  44. package/dist/network/messages.d.ts.map +1 -1
  45. package/dist/network/messages.js +1 -2
  46. package/dist/storage/StorageAdapter.d.ts +0 -9
  47. package/dist/storage/StorageAdapter.d.ts.map +1 -1
  48. package/dist/storage/StorageAdapter.js +0 -33
  49. package/dist/storage/StorageSubsystem.d.ts +6 -2
  50. package/dist/storage/StorageSubsystem.d.ts.map +1 -1
  51. package/dist/storage/StorageSubsystem.js +131 -37
  52. package/dist/storage/keyHash.d.ts +1 -1
  53. package/dist/storage/keyHash.d.ts.map +1 -1
  54. package/dist/synchronizer/CollectionSynchronizer.d.ts +3 -4
  55. package/dist/synchronizer/CollectionSynchronizer.d.ts.map +1 -1
  56. package/dist/synchronizer/CollectionSynchronizer.js +32 -26
  57. package/dist/synchronizer/DocSynchronizer.d.ts +8 -8
  58. package/dist/synchronizer/DocSynchronizer.d.ts.map +1 -1
  59. package/dist/synchronizer/DocSynchronizer.js +205 -79
  60. package/dist/types.d.ts +4 -1
  61. package/dist/types.d.ts.map +1 -1
  62. package/fuzz/fuzz.ts +3 -3
  63. package/package.json +4 -5
  64. package/src/AutomergeUrl.ts +101 -26
  65. package/src/DocHandle.ts +158 -77
  66. package/src/FindProgress.ts +48 -0
  67. package/src/RemoteHeadsSubscriptions.ts +11 -9
  68. package/src/Repo.ts +465 -180
  69. package/src/helpers/abortable.ts +62 -0
  70. package/src/helpers/bufferFromHex.ts +14 -0
  71. package/src/helpers/headsAreSame.ts +2 -2
  72. package/src/helpers/tests/network-adapter-tests.ts +14 -13
  73. package/src/helpers/tests/storage-adapter-tests.ts +13 -24
  74. package/src/index.ts +57 -38
  75. package/src/network/NetworkSubsystem.ts +0 -4
  76. package/src/network/messages.ts +2 -11
  77. package/src/storage/StorageAdapter.ts +0 -42
  78. package/src/storage/StorageSubsystem.ts +155 -45
  79. package/src/storage/keyHash.ts +1 -1
  80. package/src/synchronizer/CollectionSynchronizer.ts +42 -29
  81. package/src/synchronizer/DocSynchronizer.ts +263 -89
  82. package/src/types.ts +4 -1
  83. package/test/AutomergeUrl.test.ts +130 -0
  84. package/test/CollectionSynchronizer.test.ts +6 -8
  85. package/test/DocHandle.test.ts +161 -77
  86. package/test/DocSynchronizer.test.ts +11 -9
  87. package/test/RemoteHeadsSubscriptions.test.ts +1 -1
  88. package/test/Repo.test.ts +406 -341
  89. package/test/StorageSubsystem.test.ts +95 -20
  90. package/test/remoteHeads.test.ts +28 -13
  91. package/dist/CollectionHandle.d.ts +0 -14
  92. package/dist/CollectionHandle.d.ts.map +0 -1
  93. package/dist/CollectionHandle.js +0 -37
  94. package/dist/DocUrl.d.ts +0 -47
  95. package/dist/DocUrl.d.ts.map +0 -1
  96. package/dist/DocUrl.js +0 -72
  97. package/dist/EphemeralData.d.ts +0 -20
  98. package/dist/EphemeralData.d.ts.map +0 -1
  99. package/dist/EphemeralData.js +0 -1
  100. package/dist/ferigan.d.ts +0 -51
  101. package/dist/ferigan.d.ts.map +0 -1
  102. package/dist/ferigan.js +0 -98
  103. package/dist/src/DocHandle.d.ts +0 -182
  104. package/dist/src/DocHandle.d.ts.map +0 -1
  105. package/dist/src/DocHandle.js +0 -405
  106. package/dist/src/DocUrl.d.ts +0 -49
  107. package/dist/src/DocUrl.d.ts.map +0 -1
  108. package/dist/src/DocUrl.js +0 -72
  109. package/dist/src/EphemeralData.d.ts +0 -19
  110. package/dist/src/EphemeralData.d.ts.map +0 -1
  111. package/dist/src/EphemeralData.js +0 -1
  112. package/dist/src/Repo.d.ts +0 -74
  113. package/dist/src/Repo.d.ts.map +0 -1
  114. package/dist/src/Repo.js +0 -208
  115. package/dist/src/helpers/arraysAreEqual.d.ts +0 -2
  116. package/dist/src/helpers/arraysAreEqual.d.ts.map +0 -1
  117. package/dist/src/helpers/arraysAreEqual.js +0 -2
  118. package/dist/src/helpers/cbor.d.ts +0 -4
  119. package/dist/src/helpers/cbor.d.ts.map +0 -1
  120. package/dist/src/helpers/cbor.js +0 -8
  121. package/dist/src/helpers/eventPromise.d.ts +0 -11
  122. package/dist/src/helpers/eventPromise.d.ts.map +0 -1
  123. package/dist/src/helpers/eventPromise.js +0 -7
  124. package/dist/src/helpers/headsAreSame.d.ts +0 -2
  125. package/dist/src/helpers/headsAreSame.d.ts.map +0 -1
  126. package/dist/src/helpers/headsAreSame.js +0 -4
  127. package/dist/src/helpers/mergeArrays.d.ts +0 -2
  128. package/dist/src/helpers/mergeArrays.d.ts.map +0 -1
  129. package/dist/src/helpers/mergeArrays.js +0 -15
  130. package/dist/src/helpers/pause.d.ts +0 -6
  131. package/dist/src/helpers/pause.d.ts.map +0 -1
  132. package/dist/src/helpers/pause.js +0 -10
  133. package/dist/src/helpers/tests/network-adapter-tests.d.ts +0 -21
  134. package/dist/src/helpers/tests/network-adapter-tests.d.ts.map +0 -1
  135. package/dist/src/helpers/tests/network-adapter-tests.js +0 -122
  136. package/dist/src/helpers/withTimeout.d.ts +0 -12
  137. package/dist/src/helpers/withTimeout.d.ts.map +0 -1
  138. package/dist/src/helpers/withTimeout.js +0 -24
  139. package/dist/src/index.d.ts +0 -53
  140. package/dist/src/index.d.ts.map +0 -1
  141. package/dist/src/index.js +0 -40
  142. package/dist/src/network/NetworkAdapter.d.ts +0 -26
  143. package/dist/src/network/NetworkAdapter.d.ts.map +0 -1
  144. package/dist/src/network/NetworkAdapter.js +0 -4
  145. package/dist/src/network/NetworkSubsystem.d.ts +0 -23
  146. package/dist/src/network/NetworkSubsystem.d.ts.map +0 -1
  147. package/dist/src/network/NetworkSubsystem.js +0 -120
  148. package/dist/src/network/messages.d.ts +0 -85
  149. package/dist/src/network/messages.d.ts.map +0 -1
  150. package/dist/src/network/messages.js +0 -23
  151. package/dist/src/storage/StorageAdapter.d.ts +0 -14
  152. package/dist/src/storage/StorageAdapter.d.ts.map +0 -1
  153. package/dist/src/storage/StorageAdapter.js +0 -1
  154. package/dist/src/storage/StorageSubsystem.d.ts +0 -12
  155. package/dist/src/storage/StorageSubsystem.d.ts.map +0 -1
  156. package/dist/src/storage/StorageSubsystem.js +0 -145
  157. package/dist/src/synchronizer/CollectionSynchronizer.d.ts +0 -25
  158. package/dist/src/synchronizer/CollectionSynchronizer.d.ts.map +0 -1
  159. package/dist/src/synchronizer/CollectionSynchronizer.js +0 -106
  160. package/dist/src/synchronizer/DocSynchronizer.d.ts +0 -29
  161. package/dist/src/synchronizer/DocSynchronizer.d.ts.map +0 -1
  162. package/dist/src/synchronizer/DocSynchronizer.js +0 -263
  163. package/dist/src/synchronizer/Synchronizer.d.ts +0 -9
  164. package/dist/src/synchronizer/Synchronizer.d.ts.map +0 -1
  165. package/dist/src/synchronizer/Synchronizer.js +0 -2
  166. package/dist/src/types.d.ts +0 -16
  167. package/dist/src/types.d.ts.map +0 -1
  168. package/dist/src/types.js +0 -1
  169. package/dist/test/CollectionSynchronizer.test.d.ts +0 -2
  170. package/dist/test/CollectionSynchronizer.test.d.ts.map +0 -1
  171. package/dist/test/CollectionSynchronizer.test.js +0 -57
  172. package/dist/test/DocHandle.test.d.ts +0 -2
  173. package/dist/test/DocHandle.test.d.ts.map +0 -1
  174. package/dist/test/DocHandle.test.js +0 -238
  175. package/dist/test/DocSynchronizer.test.d.ts +0 -2
  176. package/dist/test/DocSynchronizer.test.d.ts.map +0 -1
  177. package/dist/test/DocSynchronizer.test.js +0 -111
  178. package/dist/test/Network.test.d.ts +0 -2
  179. package/dist/test/Network.test.d.ts.map +0 -1
  180. package/dist/test/Network.test.js +0 -11
  181. package/dist/test/Repo.test.d.ts +0 -2
  182. package/dist/test/Repo.test.d.ts.map +0 -1
  183. package/dist/test/Repo.test.js +0 -568
  184. package/dist/test/StorageSubsystem.test.d.ts +0 -2
  185. package/dist/test/StorageSubsystem.test.d.ts.map +0 -1
  186. package/dist/test/StorageSubsystem.test.js +0 -56
  187. package/dist/test/helpers/DummyNetworkAdapter.d.ts +0 -9
  188. package/dist/test/helpers/DummyNetworkAdapter.d.ts.map +0 -1
  189. package/dist/test/helpers/DummyNetworkAdapter.js +0 -15
  190. package/dist/test/helpers/DummyStorageAdapter.d.ts +0 -16
  191. package/dist/test/helpers/DummyStorageAdapter.d.ts.map +0 -1
  192. package/dist/test/helpers/DummyStorageAdapter.js +0 -33
  193. package/dist/test/helpers/generate-large-object.d.ts +0 -5
  194. package/dist/test/helpers/generate-large-object.d.ts.map +0 -1
  195. package/dist/test/helpers/generate-large-object.js +0 -9
  196. package/dist/test/helpers/getRandomItem.d.ts +0 -2
  197. package/dist/test/helpers/getRandomItem.d.ts.map +0 -1
  198. package/dist/test/helpers/getRandomItem.js +0 -4
  199. package/dist/test/types.d.ts +0 -4
  200. package/dist/test/types.d.ts.map +0 -1
  201. package/dist/test/types.js +0 -1
  202. package/src/CollectionHandle.ts +0 -54
  203. package/src/ferigan.ts +0 -184
@@ -1,4 +1,4 @@
1
- import * as A from "@automerge/automerge/slim/next"
1
+ import { next as A } from "@automerge/automerge/slim"
2
2
  import { decode } from "cbor-x"
3
3
  import debug from "debug"
4
4
  import {
@@ -17,10 +17,9 @@ import {
17
17
  SyncMessage,
18
18
  isRequestMessage,
19
19
  } from "../network/messages.js"
20
- import { AutomergeUrl, DocumentId, PeerId } from "../types.js"
20
+ import { PeerId } from "../types.js"
21
21
  import { Synchronizer } from "./Synchronizer.js"
22
22
  import { throttle } from "../helpers/throttle.js"
23
- import { parseAutomergeUrl } from "../AutomergeUrl.js"
24
23
 
25
24
  type PeerDocumentStatus = "unknown" | "has" | "unavailable" | "wants"
26
25
 
@@ -31,7 +30,8 @@ type PendingMessage = {
31
30
 
32
31
  interface DocSynchronizerConfig {
33
32
  handle: DocHandle<unknown>
34
- beelay: A.beelay.Beelay
33
+ peerId: PeerId
34
+ onLoadSyncState?: (peerId: PeerId) => Promise<A.SyncState | undefined>
35
35
  }
36
36
 
37
37
  /**
@@ -45,56 +45,71 @@ export class DocSynchronizer extends Synchronizer {
45
45
  /** Active peers */
46
46
  #peers: PeerId[] = []
47
47
 
48
+ #pendingSyncStateCallbacks: Record<
49
+ PeerId,
50
+ ((syncState: A.SyncState) => void)[]
51
+ > = {}
52
+
48
53
  #peerDocumentStatuses: Record<PeerId, PeerDocumentStatus> = {}
49
- #lastSaveOffset: string | null = null
54
+
55
+ /** Sync state for each peer we've communicated with (including inactive peers) */
56
+ #syncStates: Record<PeerId, A.SyncState> = {}
57
+
58
+ #pendingSyncMessages: Array<PendingMessage> = []
59
+
60
+ // We keep this around at least in part for debugging.
61
+ // eslint-disable-next-line no-unused-private-class-members
62
+ #peerId: PeerId
50
63
  #syncStarted = false
51
- #beelay: A.beelay.Beelay
52
64
 
53
65
  #handle: DocHandle<unknown>
54
- #docId: DocumentId
66
+ #onLoadSyncState: (peerId: PeerId) => Promise<A.SyncState | undefined>
55
67
 
56
- constructor({ handle, beelay }: DocSynchronizerConfig) {
68
+ constructor({ handle, peerId, onLoadSyncState }: DocSynchronizerConfig) {
57
69
  super()
70
+ this.#peerId = peerId
58
71
  this.#handle = handle
59
- this.#beelay = beelay
60
- this.#docId = this.#handle.documentId
72
+ this.#onLoadSyncState =
73
+ onLoadSyncState ?? (() => Promise.resolve(undefined))
74
+
75
+ const docId = handle.documentId.slice(0, 5)
76
+ this.#log = debug(`automerge-repo:docsync:${docId}`)
61
77
 
62
- this.#log = debug(`automerge-repo:docsync:${this.#handle.documentId}`)
78
+ handle.on(
79
+ "change",
80
+ throttle(() => this.#syncWithPeers(), this.syncDebounceRate)
81
+ )
63
82
 
64
83
  handle.on("ephemeral-message-outbound", payload =>
65
84
  this.#broadcastToPeers(payload)
66
85
  )
67
86
 
68
- handle.on("change", changeInfo => {
69
- const newLinks = changeInfo.patches
70
- .map(patch => {
71
- if (patch.action === "put") {
72
- if (patch.value instanceof A.Link) {
73
- return patch.value
74
- }
75
- }
76
- return null
77
- })
78
- .filter(v => v != null)
79
- for (const link of newLinks) {
80
- const { documentId: target } = parseAutomergeUrl(
81
- link.target as AutomergeUrl
82
- )
83
- this.#beelay.addLink({ from: this.#handle.documentId, to: target })
84
- }
85
- })
87
+ // Process pending sync messages immediately after the handle becomes ready.
88
+ void (async () => {
89
+ this.#processAllPendingSyncMessages()
90
+ })()
86
91
  }
87
92
 
88
93
  get peerStates() {
89
94
  return this.#peerDocumentStatuses
90
95
  }
91
96
 
92
- get documentId(): DocumentId {
93
- return this.#docId
97
+ get documentId() {
98
+ return this.#handle.documentId
94
99
  }
95
100
 
96
101
  /// PRIVATE
97
102
 
103
+ async #syncWithPeers() {
104
+ try {
105
+ await this.#handle.whenReady()
106
+ const doc = this.#handle.doc() // XXX THIS ONE IS WEIRD
107
+ this.#peers.forEach(peerId => this.#sendSyncMessage(peerId, doc))
108
+ } catch (e) {
109
+ console.log("sync with peers threw an exception")
110
+ }
111
+ }
112
+
98
113
  async #broadcastToPeers({
99
114
  data,
100
115
  }: DocHandleOutboundEphemeralMessagePayload<unknown>) {
@@ -108,85 +123,188 @@ export class DocSynchronizer extends Synchronizer {
108
123
  const message: MessageContents<EphemeralMessage> = {
109
124
  type: "ephemeral",
110
125
  targetId: peerId,
111
- documentId: this.documentId,
126
+ documentId: this.#handle.documentId,
112
127
  data,
113
128
  }
114
129
  this.emit("message", message)
115
130
  }
116
131
 
132
+ #withSyncState(peerId: PeerId, callback: (syncState: A.SyncState) => void) {
133
+ this.#addPeer(peerId)
134
+
135
+ if (!(peerId in this.#peerDocumentStatuses)) {
136
+ this.#peerDocumentStatuses[peerId] = "unknown"
137
+ }
138
+
139
+ const syncState = this.#syncStates[peerId]
140
+ if (syncState) {
141
+ callback(syncState)
142
+ return
143
+ }
144
+
145
+ let pendingCallbacks = this.#pendingSyncStateCallbacks[peerId]
146
+ if (!pendingCallbacks) {
147
+ this.#onLoadSyncState(peerId)
148
+ .then(syncState => {
149
+ this.#initSyncState(peerId, syncState ?? A.initSyncState())
150
+ })
151
+ .catch(err => {
152
+ this.#log(`Error loading sync state for ${peerId}: ${err}`)
153
+ })
154
+ pendingCallbacks = this.#pendingSyncStateCallbacks[peerId] = []
155
+ }
156
+
157
+ pendingCallbacks.push(callback)
158
+ }
159
+
160
+ #addPeer(peerId: PeerId) {
161
+ if (!this.#peers.includes(peerId)) {
162
+ this.#peers.push(peerId)
163
+ this.emit("open-doc", { documentId: this.documentId, peerId })
164
+ }
165
+ }
166
+
167
+ #initSyncState(peerId: PeerId, syncState: A.SyncState) {
168
+ const pendingCallbacks = this.#pendingSyncStateCallbacks[peerId]
169
+ if (pendingCallbacks) {
170
+ for (const callback of pendingCallbacks) {
171
+ callback(syncState)
172
+ }
173
+ }
174
+
175
+ delete this.#pendingSyncStateCallbacks[peerId]
176
+
177
+ this.#syncStates[peerId] = syncState
178
+ }
179
+
180
+ #setSyncState(peerId: PeerId, syncState: A.SyncState) {
181
+ this.#syncStates[peerId] = syncState
182
+
183
+ this.emit("sync-state", {
184
+ peerId,
185
+ syncState,
186
+ documentId: this.#handle.documentId,
187
+ })
188
+ }
189
+
190
+ #sendSyncMessage(peerId: PeerId, doc: A.Doc<unknown>) {
191
+ this.#log(`sendSyncMessage ->${peerId}`)
192
+
193
+ this.#withSyncState(peerId, syncState => {
194
+ const [newSyncState, message] = A.generateSyncMessage(doc, syncState)
195
+ if (message) {
196
+ this.#setSyncState(peerId, newSyncState)
197
+ const isNew = A.getHeads(doc).length === 0
198
+
199
+ if (
200
+ !this.#handle.isReady() &&
201
+ isNew &&
202
+ newSyncState.sharedHeads.length === 0 &&
203
+ !Object.values(this.#peerDocumentStatuses).includes("has") &&
204
+ this.#peerDocumentStatuses[peerId] === "unknown"
205
+ ) {
206
+ // we don't have the document (or access to it), so we request it
207
+ this.emit("message", {
208
+ type: "request",
209
+ targetId: peerId,
210
+ documentId: this.#handle.documentId,
211
+ data: message,
212
+ } as RequestMessage)
213
+ } else {
214
+ this.emit("message", {
215
+ type: "sync",
216
+ targetId: peerId,
217
+ data: message,
218
+ documentId: this.#handle.documentId,
219
+ } as SyncMessage)
220
+ }
221
+
222
+ // if we have sent heads, then the peer now has or will have the document
223
+ if (!isNew) {
224
+ this.#peerDocumentStatuses[peerId] = "has"
225
+ }
226
+ }
227
+ })
228
+ }
229
+
117
230
  /// PUBLIC
118
231
 
119
232
  hasPeer(peerId: PeerId) {
120
233
  return this.#peers.includes(peerId)
121
234
  }
122
235
 
123
- beginSync(peerIds: PeerId[]) {
124
- this.#log(`beginSync: ${peerIds.join(", ")}`)
125
-
126
- const docPromise = this.#handle
236
+ async beginSync(peerIds: PeerId[]) {
237
+ void this.#handle
127
238
  .whenReady([READY, REQUESTING, UNAVAILABLE])
128
- .then(doc => {
239
+ .then(() => {
240
+ this.#syncStarted = true
241
+ this.#checkDocUnavailable()
242
+ })
243
+ .catch(e => {
244
+ console.log("caught whenready", e)
129
245
  this.#syncStarted = true
130
246
  this.#checkDocUnavailable()
131
247
  })
132
- // TODO: handle this error
133
- .catch(() => {})
134
248
 
135
- peerIds.forEach(peerId => {
136
- if (!this.#peers.includes(peerId)) {
137
- this.#peers.push(peerId)
138
- } else {
139
- return
140
- }
141
- this.#peerDocumentStatuses[peerId] = "unknown"
249
+ const peersWithDocument = this.#peers.some(peerId => {
250
+ return this.#peerDocumentStatuses[peerId] == "has"
251
+ })
142
252
 
143
- docPromise.then(() => {
144
- this.#syncStarted = true
145
- this.#log(`beginning sync with ${peerId} for doc: ${this.documentId}`)
146
- this.#beelay
147
- .syncDoc(this.documentId, peerId)
148
- .then(({ snapshot, found }) => {
149
- this.#peerDocumentStatuses[peerId] = found ? "has" : "unavailable"
150
- // this.#log("synced snapshot: ", snapshot)
151
- if (found) {
152
- this.#beelay.loadDocument(this.#docId).then(commitOrBundles => {
153
- if (commitOrBundles != null) {
154
- this.#handle?.update(d => {
155
- let doc = d
156
- for (const commitOrBundle of commitOrBundles) {
157
- doc = A.loadIncremental(doc, commitOrBundle.contents)
158
- }
159
- return doc
160
- })
161
- this.#checkDocUnavailable()
162
- }
163
- })
164
- } else {
165
- this.#checkDocUnavailable()
253
+ if (peersWithDocument) {
254
+ await this.#handle.whenReady()
255
+ }
256
+
257
+ peerIds.forEach(peerId => {
258
+ this.#withSyncState(peerId, syncState => {
259
+ // HACK: if we have a sync state already, we round-trip it through the encoding system to make
260
+ // sure state is preserved. This prevents an infinite loop caused by failed attempts to send
261
+ // messages during disconnection.
262
+ // TODO: cover that case with a test and remove this hack
263
+ const reparsedSyncState = A.decodeSyncState(
264
+ A.encodeSyncState(syncState)
265
+ )
266
+ this.#setSyncState(peerId, reparsedSyncState)
267
+
268
+ // At this point if we don't have anything in our storage, we need to use an empty doc to sync
269
+ // with; but we don't want to surface that state to the front end
270
+ this.#handle
271
+ .whenReady([READY, REQUESTING, UNAVAILABLE])
272
+ .then(() => {
273
+ const doc = this.#handle.isReady()
274
+ ? this.#handle.doc()
275
+ : A.init<unknown>()
276
+
277
+ const noPeersWithDocument = peerIds.every(
278
+ peerId =>
279
+ this.#peerDocumentStatuses[peerId] in ["unavailable", "wants"]
280
+ )
281
+
282
+ const wasUnavailable = doc === undefined
283
+ if (wasUnavailable && noPeersWithDocument) {
284
+ return
166
285
  }
167
- this.#beelay.listen(peerId, snapshot)
286
+
287
+ // If the doc is unavailable we still need a blank document to generate
288
+ // the sync message from
289
+ this.#sendSyncMessage(peerId, doc ?? A.init<unknown>())
290
+ })
291
+ .catch(err => {
292
+ this.#log(`Error loading doc for ${peerId}: ${err}`)
168
293
  })
169
294
  })
170
295
  })
171
296
  }
172
297
 
173
- peerWantsDocument(peerId: PeerId) {
174
- this.#peerDocumentStatuses[peerId] = "wants"
175
- if (!this.#peers.includes(peerId)) {
176
- this.beginSync([peerId as PeerId])
177
- }
178
- }
179
-
180
298
  endSync(peerId: PeerId) {
181
299
  this.#log(`removing peer ${peerId}`)
182
300
  this.#peers = this.#peers.filter(p => p !== peerId)
183
- this.#beelay.cancelListens(peerId)
184
301
  }
185
302
 
186
303
  receiveMessage(message: RepoMessage) {
187
304
  switch (message.type) {
188
305
  case "sync":
189
306
  case "request":
307
+ this.receiveSyncMessage(message)
190
308
  break
191
309
  case "ephemeral":
192
310
  this.receiveEphemeralMessage(message)
@@ -201,7 +319,7 @@ export class DocSynchronizer extends Synchronizer {
201
319
  }
202
320
 
203
321
  receiveEphemeralMessage(message: EphemeralMessage) {
204
- if (message.documentId !== this.documentId)
322
+ if (message.documentId !== this.#handle.documentId)
205
323
  throw new Error(`channelId doesn't match documentId`)
206
324
 
207
325
  const { senderId, data } = message
@@ -213,6 +331,7 @@ export class DocSynchronizer extends Synchronizer {
213
331
  senderId,
214
332
  message: contents,
215
333
  })
334
+
216
335
  this.#peers.forEach(peerId => {
217
336
  if (peerId === senderId) return
218
337
  this.emit("message", {
@@ -222,7 +341,59 @@ export class DocSynchronizer extends Synchronizer {
222
341
  })
223
342
  }
224
343
 
225
- receiveSyncMessage(message: SyncMessage | RequestMessage) {}
344
+ receiveSyncMessage(message: SyncMessage | RequestMessage) {
345
+ if (message.documentId !== this.#handle.documentId)
346
+ throw new Error(`channelId doesn't match documentId`)
347
+
348
+ // We need to block receiving the syncMessages until we've checked local storage
349
+ if (!this.#handle.inState([READY, REQUESTING, UNAVAILABLE])) {
350
+ this.#pendingSyncMessages.push({ message, received: new Date() })
351
+ return
352
+ }
353
+
354
+ this.#processAllPendingSyncMessages()
355
+ this.#processSyncMessage(message)
356
+ }
357
+
358
+ #processSyncMessage(message: SyncMessage | RequestMessage) {
359
+ if (isRequestMessage(message)) {
360
+ this.#peerDocumentStatuses[message.senderId] = "wants"
361
+ }
362
+
363
+ this.#checkDocUnavailable()
364
+
365
+ // if the message has heads, then the peer has the document
366
+ if (A.decodeSyncMessage(message.data).heads.length > 0) {
367
+ this.#peerDocumentStatuses[message.senderId] = "has"
368
+ }
369
+
370
+ this.#withSyncState(message.senderId, syncState => {
371
+ this.#handle.update(doc => {
372
+ const start = performance.now()
373
+
374
+ const [newDoc, newSyncState] = A.receiveSyncMessage(
375
+ doc,
376
+ syncState,
377
+ message.data
378
+ )
379
+ const end = performance.now()
380
+ this.emit("metrics", {
381
+ type: "receive-sync-message",
382
+ documentId: this.#handle.documentId,
383
+ durationMillis: end - start,
384
+ ...A.stats(doc),
385
+ })
386
+
387
+ this.#setSyncState(message.senderId, newSyncState)
388
+
389
+ // respond to just this peer (as required)
390
+ this.#sendSyncMessage(message.senderId, doc)
391
+ return newDoc
392
+ })
393
+
394
+ this.#checkDocUnavailable()
395
+ })
396
+ }
226
397
 
227
398
  #checkDocUnavailable() {
228
399
  // if we know none of the peers have the document, tell all our peers that we don't either
@@ -240,25 +411,28 @@ export class DocSynchronizer extends Synchronizer {
240
411
  .forEach(peerId => {
241
412
  const message: MessageContents<DocumentUnavailableMessage> = {
242
413
  type: "doc-unavailable",
243
- documentId: this.documentId,
414
+ documentId: this.#handle.documentId,
244
415
  targetId: peerId,
245
416
  }
246
417
  this.emit("message", message)
247
418
  })
248
419
 
249
- if (this.#handle) {
250
- this.#handle.unavailable()
251
- }
420
+ this.#handle.unavailable()
252
421
  }
253
422
  }
254
423
 
255
- metrics(): {
256
- peers: PeerId[]
257
- size: { numOps: number; numChanges: number } | undefined
258
- } {
424
+ #processAllPendingSyncMessages() {
425
+ for (const message of this.#pendingSyncMessages) {
426
+ this.#processSyncMessage(message.message)
427
+ }
428
+
429
+ this.#pendingSyncMessages = []
430
+ }
431
+
432
+ metrics(): { peers: PeerId[]; size: { numOps: number; numChanges: number } } {
259
433
  return {
260
434
  peers: this.#peers,
261
- size: this.#handle?.metrics(),
435
+ size: this.#handle.metrics(),
262
436
  }
263
437
  }
264
438
  }
package/src/types.ts CHANGED
@@ -27,8 +27,11 @@ export type AnyDocumentId =
27
27
  | BinaryDocumentId
28
28
  | LegacyDocumentId
29
29
 
30
+ // We need to define our own version of heads because the AutomergeHeads type is not bs58check encoded
31
+ export type UrlHeads = string[] & { __automergeUrlHeads: unknown }
32
+
30
33
  /** A branded type for peer IDs */
31
34
  export type PeerId = string & { __peerId: true }
32
35
 
33
36
  /** A randomly generated string created when the {@link Repo} starts up */
34
- export type SessionId = string & { __SessionId: true }
37
+ export type SessionId = string & { __sessionId: true }
@@ -3,9 +3,11 @@ import bs58check from "bs58check"
3
3
  import { describe, it } from "vitest"
4
4
  import {
5
5
  generateAutomergeUrl,
6
+ getHeadsFromUrl,
6
7
  isValidAutomergeUrl,
7
8
  parseAutomergeUrl,
8
9
  stringifyAutomergeUrl,
10
+ UrlHeads,
9
11
  } from "../src/AutomergeUrl.js"
10
12
  import type {
11
13
  AutomergeUrl,
@@ -102,3 +104,131 @@ describe("AutomergeUrl", () => {
102
104
  })
103
105
  })
104
106
  })
107
+
108
+ describe("AutomergeUrl with heads", () => {
109
+ // Create some sample encoded heads for testing
110
+ const head1 = bs58check.encode(new Uint8Array([1, 2, 3, 4])) as string
111
+ const head2 = bs58check.encode(new Uint8Array([5, 6, 7, 8])) as string
112
+ const goodHeads = [head1, head2] as UrlHeads
113
+ const urlWithHeads = `${goodUrl}#${head1}|${head2}` as AutomergeUrl
114
+ const invalidHead = "not-base58-encoded"
115
+ const invalidHeads = [invalidHead] as UrlHeads
116
+
117
+ describe("stringifyAutomergeUrl", () => {
118
+ it("should stringify a url with heads", () => {
119
+ const url = stringifyAutomergeUrl({
120
+ documentId: goodDocumentId,
121
+ heads: goodHeads,
122
+ })
123
+ assert.strictEqual(url, urlWithHeads)
124
+ })
125
+
126
+ it("should throw if heads are not valid base58check", () => {
127
+ assert.throws(() =>
128
+ stringifyAutomergeUrl({
129
+ documentId: goodDocumentId,
130
+ heads: invalidHeads,
131
+ })
132
+ )
133
+ })
134
+ })
135
+
136
+ describe("parseAutomergeUrl", () => {
137
+ it("should parse a url with heads", () => {
138
+ const { documentId, heads } = parseAutomergeUrl(urlWithHeads)
139
+ assert.equal(documentId, goodDocumentId)
140
+ assert.deepEqual(heads, [head1, head2])
141
+ })
142
+
143
+ it("should parse a url without heads", () => {
144
+ const { documentId, heads } = parseAutomergeUrl(goodUrl)
145
+ assert.equal(documentId, goodDocumentId)
146
+ assert.equal(heads, undefined)
147
+ })
148
+
149
+ it("should throw on url with invalid heads encoding", () => {
150
+ const badUrl = `${goodUrl}#${invalidHead}` as AutomergeUrl
151
+ assert.throws(() => parseAutomergeUrl(badUrl))
152
+ })
153
+ })
154
+
155
+ describe("isValidAutomergeUrl", () => {
156
+ it("should return true for a valid url with heads", () => {
157
+ assert(isValidAutomergeUrl(urlWithHeads) === true)
158
+ })
159
+
160
+ it("should return false for a url with invalid heads", () => {
161
+ const badUrl = `${goodUrl}#${invalidHead}` as AutomergeUrl
162
+ assert(isValidAutomergeUrl(badUrl) === false)
163
+ })
164
+ })
165
+
166
+ describe("getHeadsFromUrl", () => {
167
+ it("should return heads from a valid url", () => {
168
+ const heads = getHeadsFromUrl(urlWithHeads)
169
+ assert.deepEqual(heads, [head1, head2])
170
+ })
171
+
172
+ it("should return undefined for url without heads", () => {
173
+ const heads = getHeadsFromUrl(goodUrl)
174
+ assert.equal(heads, undefined)
175
+ })
176
+ })
177
+ it("should handle a single head correctly", () => {
178
+ const urlWithOneHead = `${goodUrl}#${head1}` as AutomergeUrl
179
+ const { heads } = parseAutomergeUrl(urlWithOneHead)
180
+ assert.deepEqual(heads, [head1])
181
+ })
182
+
183
+ it("should round-trip urls with heads", () => {
184
+ const originalUrl = urlWithHeads
185
+ const parsed = parseAutomergeUrl(originalUrl)
186
+ const roundTripped = stringifyAutomergeUrl({
187
+ documentId: parsed.documentId,
188
+ heads: parsed.heads,
189
+ })
190
+ assert.equal(roundTripped, originalUrl)
191
+ })
192
+
193
+ describe("should reject malformed urls", () => {
194
+ it("should reject urls with trailing delimiter", () => {
195
+ assert(!isValidAutomergeUrl(`${goodUrl}#${head1}:` as AutomergeUrl))
196
+ })
197
+
198
+ it("should reject urls with empty head", () => {
199
+ assert(!isValidAutomergeUrl(`${goodUrl}#|${head1}` as AutomergeUrl))
200
+ })
201
+
202
+ it("should reject urls with multiple hash characters", () => {
203
+ assert(
204
+ !isValidAutomergeUrl(`${goodUrl}#${head1}#${head2}` as AutomergeUrl)
205
+ )
206
+ })
207
+ })
208
+ })
209
+
210
+ describe("empty heads section", () => {
211
+ it("should treat bare # as empty heads array", () => {
212
+ const urlWithEmptyHeads = `${goodUrl}#` as AutomergeUrl
213
+ const { heads } = parseAutomergeUrl(urlWithEmptyHeads)
214
+ assert.deepEqual(heads, [])
215
+ })
216
+
217
+ it("should round-trip empty heads array", () => {
218
+ const original = `${goodUrl}#` as AutomergeUrl
219
+ const parsed = parseAutomergeUrl(original)
220
+ const roundTripped = stringifyAutomergeUrl({
221
+ documentId: parsed.documentId,
222
+ heads: parsed.heads,
223
+ })
224
+ assert.equal(roundTripped, original)
225
+ })
226
+
227
+ it("should distinguish between no heads and empty heads", () => {
228
+ const noHeads = parseAutomergeUrl(goodUrl)
229
+ const emptyHeads = parseAutomergeUrl(`${goodUrl}#` as AutomergeUrl)
230
+
231
+ assert.equal(noHeads.heads, undefined)
232
+ assert.deepEqual(emptyHeads.heads, [])
233
+ })
234
+ })
@@ -2,16 +2,14 @@ import assert from "assert"
2
2
  import { beforeEach, describe, it } from "vitest"
3
3
  import { PeerId, Repo, SyncMessage } from "../src/index.js"
4
4
  import { CollectionSynchronizer } from "../src/synchronizer/CollectionSynchronizer.js"
5
- import { next as Automerge } from "@automerge/automerge"
6
5
 
7
- describe.skip("CollectionSynchronizer", () => {
6
+ describe("CollectionSynchronizer", () => {
8
7
  let repo: Repo
9
8
  let synchronizer: CollectionSynchronizer
10
- let beelay: Automerge.beelay.Beelay
11
9
 
12
10
  beforeEach(() => {
13
11
  repo = new Repo()
14
- synchronizer = new CollectionSynchronizer(beelay, repo)
12
+ synchronizer = new CollectionSynchronizer(repo)
15
13
  })
16
14
 
17
15
  it("is not null", async () => {
@@ -30,13 +28,13 @@ describe.skip("CollectionSynchronizer", () => {
30
28
  done()
31
29
  })
32
30
 
33
- synchronizer.addDocument(handle.documentId)
31
+ synchronizer.addDocument(handle)
34
32
  }))
35
33
 
36
34
  it("starts synchronizing existing documents when a peer is added", () =>
37
35
  new Promise<void>(done => {
38
36
  const handle = repo.create()
39
- synchronizer.addDocument(handle.documentId)
37
+ synchronizer.addDocument(handle)
40
38
  synchronizer.once("message", event => {
41
39
  const { targetId, documentId } = event as SyncMessage
42
40
  assert(targetId === "peer1")
@@ -52,7 +50,7 @@ describe.skip("CollectionSynchronizer", () => {
52
50
 
53
51
  repo.sharePolicy = async (peerId: PeerId) => peerId !== "peer1"
54
52
 
55
- synchronizer.addDocument(handle.documentId)
53
+ synchronizer.addDocument(handle)
56
54
  synchronizer.once("message", () => {
57
55
  reject(new Error("Should not have sent a message"))
58
56
  })
@@ -73,7 +71,7 @@ describe.skip("CollectionSynchronizer", () => {
73
71
  reject(new Error("Should not have sent a message"))
74
72
  })
75
73
 
76
- synchronizer.addDocument(handle.documentId)
74
+ synchronizer.addDocument(handle)
77
75
 
78
76
  setTimeout(done)
79
77
  }))