@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.
package/src/index.ts CHANGED
@@ -39,6 +39,14 @@ export {
39
39
  decodeHeads,
40
40
  } from "./AutomergeUrl.js"
41
41
  export { Repo } from "./Repo.js"
42
+ export {
43
+ Presence,
44
+ PeerPresenceView,
45
+ PeerState,
46
+ PresenceConfig,
47
+ UserId,
48
+ DeviceId,
49
+ } from "./Presence.js"
42
50
  export { NetworkAdapter } from "./network/NetworkAdapter.js"
43
51
  export type { NetworkAdapterInterface } from "./network/NetworkAdapterInterface.js"
44
52
  export { isRepoMessage } from "./network/messages.js"
@@ -22,6 +22,7 @@ export class CollectionSynchronizer extends Synchronizer {
22
22
  #docSetUp: Record<DocumentId, boolean> = {}
23
23
 
24
24
  #denylist: DocumentId[]
25
+ #hasRequested: Map<DocumentId, Set<PeerId>> = new Map()
25
26
 
26
27
  constructor(private repo: Repo, denylist: AutomergeUrl[] = []) {
27
28
  super()
@@ -108,6 +109,16 @@ export class CollectionSynchronizer extends Synchronizer {
108
109
  return
109
110
  }
110
111
 
112
+ // Record the request so that even if access is denied now, we know that the
113
+ // peer requested the document so that if the share policy changes we know
114
+ // to begin syncing with this peer
115
+ if (message.type === "request") {
116
+ if (!this.#hasRequested.has(documentId)) {
117
+ this.#hasRequested.set(documentId, new Set())
118
+ }
119
+ this.#hasRequested.get(documentId)?.add(message.senderId)
120
+ }
121
+
111
122
  const hasAccess = await this.repo.shareConfig.access(
112
123
  message.senderId,
113
124
  documentId
@@ -146,6 +157,7 @@ export class CollectionSynchronizer extends Synchronizer {
146
157
  if (this.#docSetUp[handle.documentId]) {
147
158
  return
148
159
  }
160
+ this.#docSetUp[handle.documentId] = true
149
161
  const docSynchronizer = this.#fetchDocSynchronizer(handle)
150
162
  void this.#documentGenerousPeers(handle.documentId).then(peers => {
151
163
  void docSynchronizer.beginSync(peers)
@@ -184,6 +196,9 @@ export class CollectionSynchronizer extends Synchronizer {
184
196
  removePeer(peerId: PeerId) {
185
197
  log(`removing peer ${peerId}`)
186
198
  this.#peers.delete(peerId)
199
+ for (const requested of this.#hasRequested.values()) {
200
+ requested.delete(peerId)
201
+ }
187
202
 
188
203
  for (const docSynchronizer of Object.values(this.docSynchronizers)) {
189
204
  docSynchronizer.endSync(peerId)
@@ -195,6 +210,49 @@ export class CollectionSynchronizer extends Synchronizer {
195
210
  return Array.from(this.#peers)
196
211
  }
197
212
 
213
+ /**
214
+ * Re-evaluates share policy for a document and updates sync accordingly
215
+ *
216
+ * @remarks
217
+ * This is called when the share policy for a document has changed. It re-evaluates
218
+ * which peers should have access and starts/stops synchronization as needed.
219
+ */
220
+ async reevaluateDocumentShare() {
221
+ const peers = Array.from(this.#peers)
222
+ const docPromises = []
223
+ for (const docSynchronizer of Object.values(this.docSynchronizers)) {
224
+ const documentId = docSynchronizer.documentId
225
+ docPromises.push(
226
+ (async () => {
227
+ for (const peerId of peers) {
228
+ const shouldShare = await this.#shouldShare(peerId, documentId)
229
+ const isAlreadySyncing = docSynchronizer.hasPeer(peerId)
230
+
231
+ log(
232
+ `reevaluateDocumentShare: ${peerId} for ${documentId}, shouldShare: ${shouldShare}, isAlreadySyncing: ${isAlreadySyncing}`
233
+ )
234
+ if (shouldShare && !isAlreadySyncing) {
235
+ log(
236
+ `reevaluateDocumentShare: starting sync with ${peerId} for ${documentId}`
237
+ )
238
+ void docSynchronizer.beginSync([peerId])
239
+ } else if (!shouldShare && isAlreadySyncing) {
240
+ log(
241
+ `reevaluateDocumentShare: stopping sync with ${peerId} for ${documentId}`
242
+ )
243
+ docSynchronizer.endSync(peerId)
244
+ }
245
+ }
246
+ })().catch(e => {
247
+ console.log(
248
+ `error reevaluating document share for ${documentId}: ${e}`
249
+ )
250
+ })
251
+ )
252
+ }
253
+ await Promise.allSettled(docPromises)
254
+ }
255
+
198
256
  metrics(): {
199
257
  [key: string]: {
200
258
  peers: PeerId[]
@@ -215,6 +273,8 @@ export class CollectionSynchronizer extends Synchronizer {
215
273
  this.repo.shareConfig.announce(peerId, documentId),
216
274
  this.repo.shareConfig.access(peerId, documentId),
217
275
  ])
218
- return announce && access
276
+ const hasRequested =
277
+ this.#hasRequested.get(documentId)?.has(peerId) ?? false
278
+ return announce || (access && hasRequested)
219
279
  }
220
280
  }
@@ -201,8 +201,8 @@ export class DocSynchronizer extends Synchronizer {
201
201
  forPeer: peerId,
202
202
  })
203
203
 
204
+ this.#setSyncState(peerId, newSyncState)
204
205
  if (message) {
205
- this.#setSyncState(peerId, newSyncState)
206
206
  const isNew = A.getHeads(doc).length === 0
207
207
 
208
208
  if (
@@ -0,0 +1,264 @@
1
+ import { describe, expect, it } from "vitest"
2
+
3
+ import { Presence, PresenceEventHeartbeat } from "../src/Presence.js"
4
+ import { Repo } from "../src/Repo.js"
5
+ import { PeerId } from "../src/types.js"
6
+ import { DummyNetworkAdapter } from "../src/helpers/DummyNetworkAdapter.js"
7
+ import { waitFor } from "./helpers/waitFor.js"
8
+ import { wait } from "./helpers/wait.js"
9
+
10
+ type PresenceState = { position: number }
11
+
12
+ describe("Presence", () => {
13
+ async function setup(opts?: { skipAnnounce?: boolean }) {
14
+ const alice = new Repo({ peerId: "alice" as PeerId })
15
+ const bob = new Repo({ peerId: "bob" as PeerId })
16
+ const [aliceToBob, bobToAlice] = DummyNetworkAdapter.createConnectedPair()
17
+ alice.networkSubsystem.addNetworkAdapter(aliceToBob)
18
+ bob.networkSubsystem.addNetworkAdapter(bobToAlice)
19
+ if (!opts?.skipAnnounce) {
20
+ aliceToBob.peerCandidate("bob" as PeerId)
21
+ bobToAlice.peerCandidate("alice" as PeerId)
22
+ }
23
+ await Promise.all([
24
+ alice.networkSubsystem.whenReady(),
25
+ bob.networkSubsystem.whenReady(),
26
+ ])
27
+
28
+ const aliceHandle = alice.create({
29
+ test: "doc",
30
+ })
31
+ const alicePresence = new Presence<PresenceState>({
32
+ handle: aliceHandle,
33
+ userId: "alice",
34
+ deviceId: "phone",
35
+ })
36
+
37
+ const bobHandle = await bob.find(aliceHandle.url)
38
+ const bobPresence = new Presence<PresenceState>({
39
+ handle: bobHandle,
40
+ userId: "bob",
41
+ deviceId: "phone",
42
+ })
43
+
44
+ return {
45
+ alice: {
46
+ repo: alice,
47
+ handle: aliceHandle,
48
+ presence: alicePresence,
49
+ network: aliceToBob,
50
+ },
51
+ bob: {
52
+ repo: bob,
53
+ handle: bobHandle,
54
+ presence: bobPresence,
55
+ network: bobToAlice,
56
+ },
57
+ }
58
+ }
59
+
60
+ describe("start", () => {
61
+ it("activates presence and shares initial state", async () => {
62
+ const { alice, bob } = await setup()
63
+
64
+ alice.presence.start({
65
+ initialState: {
66
+ position: 123,
67
+ },
68
+ })
69
+ expect(alice.presence.running).toBe(true)
70
+
71
+ bob.presence.start({
72
+ initialState: {
73
+ position: 456,
74
+ },
75
+ })
76
+ expect(bob.presence.running).toBe(true)
77
+
78
+ await waitFor(() => {
79
+ const bobPeerStates = bob.presence.getPeerStates()
80
+ const bobPeers = bobPeerStates.getPeers()
81
+
82
+ expect(bobPeers.length).toBe(1)
83
+ expect(bobPeers[0]).toBe(alice.repo.peerId)
84
+ expect(bobPeerStates.getPeerState(bobPeers[0], "position")).toBe(123)
85
+
86
+ const alicePeerStates = alice.presence.getPeerStates()
87
+ const alicePeers = alicePeerStates.getPeers()
88
+
89
+ expect(alicePeers.length).toBe(1)
90
+ expect(alicePeers[0]).toBe(bob.repo.peerId)
91
+ expect(alicePeerStates.getPeerState(alicePeers[0], "position")).toBe(
92
+ 456
93
+ )
94
+ })
95
+ })
96
+
97
+ it("does nothing if invoked on an already-running Presence", async () => {
98
+ const { alice } = await setup()
99
+
100
+ alice.presence.start({
101
+ initialState: {
102
+ position: 123,
103
+ },
104
+ })
105
+ expect(alice.presence.running).toBe(true)
106
+
107
+ alice.presence.start({
108
+ initialState: {
109
+ position: 789,
110
+ },
111
+ })
112
+ expect(alice.presence.running).toBe(true)
113
+ expect(alice.presence.getLocalState().position).toBe(123)
114
+ })
115
+ })
116
+
117
+ describe("stop", () => {
118
+ it("stops running presence and ignores further broadcasts", async () => {
119
+ const { alice, bob } = await setup()
120
+
121
+ alice.presence.start({
122
+ initialState: {
123
+ position: 123,
124
+ },
125
+ })
126
+ expect(alice.presence.running).toBe(true)
127
+
128
+ bob.presence.start({
129
+ initialState: {
130
+ position: 456,
131
+ },
132
+ })
133
+
134
+ await waitFor(() => {
135
+ const bobPeerStates = bob.presence.getPeerStates()
136
+ const bobPeers = bobPeerStates.getPeers()
137
+
138
+ expect(bobPeers.length).toBe(1)
139
+ expect(bobPeers[0]).toBe(alice.repo.peerId)
140
+ expect(bobPeerStates.getPeerState(bobPeers[0], "position")).toBe(123)
141
+ })
142
+
143
+ alice.presence.stop()
144
+ expect(alice.presence.running).toBe(false)
145
+
146
+ await waitFor(() => {
147
+ const bobPeerStates = bob.presence.getPeerStates()
148
+ const bobPeers = bobPeerStates.getPeers()
149
+
150
+ expect(bobPeers.length).toBe(0)
151
+ })
152
+ })
153
+
154
+ it("does nothing if invoked on a non-running Presence", async () => {
155
+ const { alice } = await setup()
156
+
157
+ expect(alice.presence.running).toBe(false)
158
+
159
+ alice.presence.stop()
160
+
161
+ expect(alice.presence.running).toBe(false)
162
+ })
163
+ })
164
+
165
+ describe("heartbeats", () => {
166
+ it("sends heartbeats on the configured interval", async () => {
167
+ const { alice, bob } = await setup()
168
+ alice.presence.start({
169
+ initialState: {
170
+ position: 123,
171
+ },
172
+ heartbeatMs: 10,
173
+ })
174
+
175
+ bob.presence.start({
176
+ initialState: {
177
+ position: 456,
178
+ },
179
+ })
180
+
181
+ let hbPeerMsg: PresenceEventHeartbeat
182
+ bob.presence.on("heartbeat", msg => {
183
+ hbPeerMsg = msg
184
+ })
185
+
186
+ await waitFor(() => {
187
+ expect(hbPeerMsg.peerId).toEqual(alice.repo.peerId)
188
+ expect(hbPeerMsg.type).toEqual("heartbeat")
189
+ expect(hbPeerMsg.userId).toEqual("alice")
190
+ })
191
+ })
192
+
193
+ it("delays heartbeats when there is a state update", async () => {
194
+ const { alice, bob } = await setup()
195
+ alice.presence.start({
196
+ initialState: {
197
+ position: 123,
198
+ },
199
+ heartbeatMs: 10,
200
+ })
201
+
202
+ bob.presence.start({
203
+ initialState: {
204
+ position: 456,
205
+ },
206
+ })
207
+
208
+ let hbPeerMsg: PresenceEventHeartbeat
209
+ bob.presence.on("heartbeat", msg => {
210
+ hbPeerMsg = msg
211
+ })
212
+
213
+ await wait(7)
214
+ alice.presence.broadcast("position", 789)
215
+ await wait(7)
216
+
217
+ expect(hbPeerMsg).toBeUndefined()
218
+
219
+ await wait(20)
220
+ expect(hbPeerMsg.peerId).toEqual(alice.repo.peerId)
221
+ expect(hbPeerMsg.type).toEqual("heartbeat")
222
+ expect(hbPeerMsg.userId).toEqual("alice")
223
+ })
224
+ })
225
+
226
+ describe("broadcast", () => {
227
+ it("sends updates to peers", async () => {
228
+ const { alice, bob } = await setup()
229
+ alice.presence.start({
230
+ initialState: {
231
+ position: 123,
232
+ },
233
+ })
234
+
235
+ bob.presence.start({
236
+ initialState: {
237
+ position: 456,
238
+ },
239
+ })
240
+
241
+ await waitFor(() => {
242
+ const bobPeerStates = bob.presence.getPeerStates()
243
+ const bobPeers = bobPeerStates.getPeers()
244
+
245
+ expect(bobPeers.length).toBe(1)
246
+ expect(bobPeerStates.getPeerState(alice.repo.peerId, "position")).toBe(
247
+ 123
248
+ )
249
+ })
250
+
251
+ alice.presence.broadcast("position", 213)
252
+
253
+ await waitFor(() => {
254
+ const bobPeerStates = bob.presence.getPeerStates()
255
+ const bobPeers = bobPeerStates.getPeers()
256
+
257
+ expect(bobPeers.length).toBe(1)
258
+ expect(bobPeerStates.getPeerState(alice.repo.peerId, "position")).toBe(
259
+ 213
260
+ )
261
+ })
262
+ })
263
+ })
264
+ })
package/test/Repo.test.ts CHANGED
@@ -35,6 +35,10 @@ import {
35
35
  LargeObject,
36
36
  generateLargeObject,
37
37
  } from "./helpers/generate-large-object.js"
38
+ import twoPeers from "./helpers/twoPeers.js"
39
+ import connectRepos from "./helpers/connectRepos.js"
40
+ import awaitState from "./helpers/awaitState.js"
41
+ import withTimeout from "./helpers/withTimeout.js"
38
42
  import { getRandomItem } from "./helpers/getRandomItem.js"
39
43
  import { TestDoc } from "./types.js"
40
44
  import { StorageId, StorageKey } from "../src/storage/types.js"
@@ -1182,6 +1186,49 @@ describe("Repo", () => {
1182
1186
  teardown()
1183
1187
  })
1184
1188
 
1189
+ it("a previously unavailable document syncs if a connected peer obtains it (but doesn't announce it)", async () => {
1190
+ const alice = new Repo({
1191
+ peerId: "alice" as PeerId,
1192
+ shareConfig: {
1193
+ announce: async peerId => peerId === "charlie",
1194
+ access: async () => true,
1195
+ },
1196
+ })
1197
+ const bob = new Repo({
1198
+ peerId: "bob" as PeerId,
1199
+ shareConfig: {
1200
+ announce: async () => true,
1201
+ access: async () => true,
1202
+ },
1203
+ })
1204
+ const charlie = new Repo({
1205
+ peerId: "charlie" as PeerId,
1206
+ shareConfig: {
1207
+ announce: async () => false,
1208
+ access: async () => true,
1209
+ },
1210
+ })
1211
+ await connectRepos(alice, bob)
1212
+
1213
+ const charlieHandle = charlie.create({ foo: "bar" })
1214
+
1215
+ // Charlie isn't connected to any peer, so we don't have the document
1216
+ await assert.rejects(bob.find(charlieHandle.url))
1217
+
1218
+ // Now, connect charlie to alice
1219
+ await connectRepos(alice, charlie)
1220
+
1221
+ // Alice should now find the document
1222
+ const aliceHandle = await withTimeout(alice.find(charlieHandle.url), 500)
1223
+ assert.deepStrictEqual(aliceHandle.doc(), { foo: "bar" })
1224
+
1225
+ await pause(150) // wait for the sync debounce rate to elapse
1226
+
1227
+ // Bob should now find the document via alice
1228
+ const bobHandle = await withTimeout(bob.find(charlieHandle.url), 500)
1229
+ assert.deepStrictEqual(bobHandle.doc(), { foo: "bar" })
1230
+ })
1231
+
1185
1232
  it("a previously unavailable document becomes available if the network adapter initially has no peers", async () => {
1186
1233
  // It is possible for a network adapter to be ready without any peer
1187
1234
  // being announced (e.g. the BroadcastChannelNetworkAdapter). In this
@@ -1716,174 +1763,6 @@ describe("Repo", () => {
1716
1763
  assert.deepEqual(openDocs, 0)
1717
1764
  })
1718
1765
  })
1719
-
1720
- describe("the sharePolicy", () => {
1721
- async function connect(left: Repo, right: Repo) {
1722
- const [leftToRight, rightToLeft] =
1723
- DummyNetworkAdapter.createConnectedPair({ latency: 0 })
1724
- left.networkSubsystem.addNetworkAdapter(leftToRight)
1725
- right.networkSubsystem.addNetworkAdapter(rightToLeft)
1726
- leftToRight.peerCandidate(right.peerId)
1727
- rightToLeft.peerCandidate(left.peerId)
1728
- await Promise.all([
1729
- left.networkSubsystem.whenReady(),
1730
- right.networkSubsystem.whenReady(),
1731
- ])
1732
- await pause(10)
1733
- }
1734
-
1735
- async function withTimeout<T>(
1736
- promise: Promise<T>,
1737
- timeout: number
1738
- ): Promise<T | undefined> {
1739
- const timeoutPromise = new Promise<T | undefined>(resolve => {
1740
- setTimeout(() => resolve(undefined), timeout)
1741
- })
1742
- return Promise.race([promise, timeoutPromise])
1743
- }
1744
-
1745
- async function awaitState(
1746
- progress: FindProgress<unknown> | FindProgressWithMethods<unknown>,
1747
- state: string
1748
- ): Promise<void> {
1749
- if (progress.state == state) {
1750
- return
1751
- }
1752
- if (!("subscribe" in progress)) {
1753
- throw new Error(
1754
- `expected progress in state ${state} but was in final state ${progress.state}`
1755
- )
1756
- }
1757
- await new Promise(resolve => {
1758
- const unsubscribe = progress.subscribe(progress => {
1759
- if (progress.state === state) {
1760
- unsubscribe()
1761
- resolve(null)
1762
- }
1763
- })
1764
- })
1765
- }
1766
-
1767
- // The parts of `RepoConfig` which are either the old sharePolicy API or the new shareConfig API
1768
- type EitherConfig = { sharePolicy?: SharePolicy; shareConfig?: ShareConfig }
1769
-
1770
- /// Create two connected peers with the given share configurations
1771
- async function twoPeers({
1772
- alice: aliceConfig,
1773
- bob: bobConfig,
1774
- }: {
1775
- alice: EitherConfig
1776
- bob: EitherConfig
1777
- }): Promise<{ alice: Repo; bob: Repo }> {
1778
- const alice = new Repo({
1779
- peerId: "alice" as PeerId,
1780
- ...aliceConfig,
1781
- })
1782
- const bob = new Repo({
1783
- peerId: "bob" as PeerId,
1784
- ...bobConfig,
1785
- })
1786
- await connect(alice, bob)
1787
- return { alice, bob }
1788
- }
1789
-
1790
- describe("the legacy API", () => {
1791
- it("should announce documents to peers for whom the sharePolicy returns true", async () => {
1792
- const { alice, bob } = await twoPeers({
1793
- alice: { sharePolicy: async () => true },
1794
- bob: { sharePolicy: async () => true },
1795
- })
1796
- const handle = alice.create({ foo: "bar" })
1797
-
1798
- // Wait for the announcement to be synced
1799
- await pause(100)
1800
-
1801
- // Disconnect and stop alice
1802
- await alice.shutdown()
1803
-
1804
- // Bob should have the handle already because it was announced to him
1805
- const bobHandle = await bob.find(handle.url)
1806
- })
1807
-
1808
- it("should not annouce documents to peers for whom the sharePolicy returns false", async () => {
1809
- const { alice, bob } = await twoPeers({
1810
- alice: { sharePolicy: async () => false },
1811
- bob: { sharePolicy: async () => true },
1812
- })
1813
- const handle = alice.create({ foo: "bar" })
1814
-
1815
- // Disconnect and stop alice
1816
- await alice.shutdown()
1817
-
1818
- // Bob should have the handle already because it was announced to him
1819
- const bobHandle = await withTimeout(bob.find(handle.url), 100)
1820
- assert.equal(bobHandle, null)
1821
- })
1822
-
1823
- it("should respond to direct requests for document where the sharePolicy returns false", async () => {
1824
- const { alice, bob } = await twoPeers({
1825
- alice: { sharePolicy: async () => false },
1826
- bob: { sharePolicy: async () => true },
1827
- })
1828
- await connect(alice, bob)
1829
-
1830
- const aliceHandle = alice.create({ foo: "bar" })
1831
- const bobHandle = await bob.find(aliceHandle.url)
1832
- })
1833
- })
1834
-
1835
- it("should respond to direct requests for document where the announce policy returns false but the access policy returns true", async () => {
1836
- const { alice, bob } = await twoPeers({
1837
- alice: {
1838
- shareConfig: {
1839
- announce: async () => false,
1840
- access: async () => true,
1841
- },
1842
- },
1843
- bob: { sharePolicy: async () => true },
1844
- })
1845
-
1846
- const aliceHandle = alice.create({ foo: "bar" })
1847
- const bobHandle = await bob.find(aliceHandle.url)
1848
- })
1849
-
1850
- it("should not respond to direct requests for a document where the access policy returns false and the announce policy return trrrue", async () => {
1851
- const { alice, bob } = await twoPeers({
1852
- alice: {
1853
- shareConfig: {
1854
- announce: async () => true,
1855
- access: async () => false,
1856
- },
1857
- },
1858
- bob: { sharePolicy: async () => true },
1859
- })
1860
- await connect(alice, bob)
1861
-
1862
- const aliceHandle = alice.create({ foo: "bar" })
1863
- withTimeout(
1864
- awaitState(bob.findWithProgress(aliceHandle.url), "unavailable"),
1865
- 500
1866
- )
1867
- })
1868
-
1869
- it("should not respond to direct requests for a document where the access policy and the announce policy return false", async () => {
1870
- const { alice, bob } = await twoPeers({
1871
- alice: {
1872
- shareConfig: {
1873
- announce: async () => false,
1874
- access: async () => false,
1875
- },
1876
- },
1877
- bob: { sharePolicy: async () => false },
1878
- })
1879
-
1880
- const aliceHandle = alice.create({ foo: "bar" })
1881
- withTimeout(
1882
- awaitState(bob.findWithProgress(aliceHandle.url), "unavailable"),
1883
- 500
1884
- )
1885
- })
1886
- })
1887
1766
  })
1888
1767
 
1889
1768
  describe("Repo heads-in-URLs functionality", () => {