@comapeo/core 1.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 (186) hide show
  1. package/LICENSE.md +9 -0
  2. package/README.md +31 -0
  3. package/dist/blob-api.d.ts +92 -0
  4. package/dist/blob-api.d.ts.map +1 -0
  5. package/dist/blob-store/index.d.ts +163 -0
  6. package/dist/blob-store/index.d.ts.map +1 -0
  7. package/dist/blob-store/live-download.d.ts +107 -0
  8. package/dist/blob-store/live-download.d.ts.map +1 -0
  9. package/dist/config-import.d.ts +74 -0
  10. package/dist/config-import.d.ts.map +1 -0
  11. package/dist/constants.d.ts +14 -0
  12. package/dist/constants.d.ts.map +1 -0
  13. package/dist/core-manager/bitfield-rle.d.ts +25 -0
  14. package/dist/core-manager/bitfield-rle.d.ts.map +1 -0
  15. package/dist/core-manager/core-index.d.ts +56 -0
  16. package/dist/core-manager/core-index.d.ts.map +1 -0
  17. package/dist/core-manager/index.d.ts +125 -0
  18. package/dist/core-manager/index.d.ts.map +1 -0
  19. package/dist/core-manager/random-access-file-pool.d.ts +17 -0
  20. package/dist/core-manager/random-access-file-pool.d.ts.map +1 -0
  21. package/dist/core-manager/remote-bitfield.d.ts +146 -0
  22. package/dist/core-manager/remote-bitfield.d.ts.map +1 -0
  23. package/dist/core-ownership.d.ts +112 -0
  24. package/dist/core-ownership.d.ts.map +1 -0
  25. package/dist/datastore/index.d.ts +91 -0
  26. package/dist/datastore/index.d.ts.map +1 -0
  27. package/dist/datatype/index.d.ts +108 -0
  28. package/dist/discovery/local-discovery.d.ts +64 -0
  29. package/dist/discovery/local-discovery.d.ts.map +1 -0
  30. package/dist/errors.d.ts +4 -0
  31. package/dist/errors.d.ts.map +1 -0
  32. package/dist/fastify-controller.d.ts +27 -0
  33. package/dist/fastify-controller.d.ts.map +1 -0
  34. package/dist/fastify-plugins/blobs.d.ts +6 -0
  35. package/dist/fastify-plugins/blobs.d.ts.map +1 -0
  36. package/dist/fastify-plugins/constants.d.ts +3 -0
  37. package/dist/fastify-plugins/constants.d.ts.map +1 -0
  38. package/dist/fastify-plugins/icons.d.ts +6 -0
  39. package/dist/fastify-plugins/icons.d.ts.map +1 -0
  40. package/dist/fastify-plugins/maps/index.d.ts +11 -0
  41. package/dist/fastify-plugins/maps/index.d.ts.map +1 -0
  42. package/dist/fastify-plugins/maps/offline-fallback-map.d.ts +12 -0
  43. package/dist/fastify-plugins/maps/offline-fallback-map.d.ts.map +1 -0
  44. package/dist/fastify-plugins/maps/static-maps.d.ts +11 -0
  45. package/dist/fastify-plugins/maps/static-maps.d.ts.map +1 -0
  46. package/dist/fastify-plugins/utils.d.ts +23 -0
  47. package/dist/fastify-plugins/utils.d.ts.map +1 -0
  48. package/dist/generated/extensions.d.ts +44 -0
  49. package/dist/generated/extensions.d.ts.map +1 -0
  50. package/dist/generated/keys.d.ts +36 -0
  51. package/dist/generated/keys.d.ts.map +1 -0
  52. package/dist/generated/rpc.d.ts +87 -0
  53. package/dist/generated/rpc.d.ts.map +1 -0
  54. package/dist/icon-api.d.ts +109 -0
  55. package/dist/icon-api.d.ts.map +1 -0
  56. package/dist/index-writer/index.d.ts +51 -0
  57. package/dist/index-writer/index.d.ts.map +1 -0
  58. package/dist/index.d.ts +14 -0
  59. package/dist/index.d.ts.map +1 -0
  60. package/dist/invite-api.d.ts +70 -0
  61. package/dist/invite-api.d.ts.map +1 -0
  62. package/dist/lib/hashmap.d.ts +62 -0
  63. package/dist/lib/hashmap.d.ts.map +1 -0
  64. package/dist/lib/hypercore-helpers.d.ts +6 -0
  65. package/dist/lib/hypercore-helpers.d.ts.map +1 -0
  66. package/dist/lib/noise-secret-stream-helpers.d.ts +45 -0
  67. package/dist/lib/noise-secret-stream-helpers.d.ts.map +1 -0
  68. package/dist/lib/ponyfills.d.ts +10 -0
  69. package/dist/lib/ponyfills.d.ts.map +1 -0
  70. package/dist/lib/string.d.ts +2 -0
  71. package/dist/lib/string.d.ts.map +1 -0
  72. package/dist/lib/timing-safe-equal.d.ts +15 -0
  73. package/dist/lib/timing-safe-equal.d.ts.map +1 -0
  74. package/dist/local-peers.d.ts +151 -0
  75. package/dist/local-peers.d.ts.map +1 -0
  76. package/dist/logger.d.ts +32 -0
  77. package/dist/logger.d.ts.map +1 -0
  78. package/dist/mapeo-manager.d.ts +178 -0
  79. package/dist/mapeo-manager.d.ts.map +1 -0
  80. package/dist/mapeo-project.d.ts +3233 -0
  81. package/dist/mapeo-project.d.ts.map +1 -0
  82. package/dist/member-api.d.ts +114 -0
  83. package/dist/member-api.d.ts.map +1 -0
  84. package/dist/roles.d.ts +157 -0
  85. package/dist/roles.d.ts.map +1 -0
  86. package/dist/schema/client.d.ts +284 -0
  87. package/dist/schema/client.d.ts.map +1 -0
  88. package/dist/schema/project.d.ts +1812 -0
  89. package/dist/schema/project.d.ts.map +1 -0
  90. package/dist/schema/schema-to-drizzle.d.ts +20 -0
  91. package/dist/schema/schema-to-drizzle.d.ts.map +1 -0
  92. package/dist/schema/types.d.ts +98 -0
  93. package/dist/schema/types.d.ts.map +1 -0
  94. package/dist/schema/utils.d.ts +55 -0
  95. package/dist/schema/utils.d.ts.map +1 -0
  96. package/dist/sync/core-sync-state.d.ts +252 -0
  97. package/dist/sync/core-sync-state.d.ts.map +1 -0
  98. package/dist/sync/namespace-sync-state.d.ts +47 -0
  99. package/dist/sync/namespace-sync-state.d.ts.map +1 -0
  100. package/dist/sync/peer-sync-controller.d.ts +44 -0
  101. package/dist/sync/peer-sync-controller.d.ts.map +1 -0
  102. package/dist/sync/sync-api.d.ts +158 -0
  103. package/dist/sync/sync-api.d.ts.map +1 -0
  104. package/dist/sync/sync-state.d.ts +40 -0
  105. package/dist/sync/sync-state.d.ts.map +1 -0
  106. package/dist/translation-api.d.ts +288 -0
  107. package/dist/translation-api.d.ts.map +1 -0
  108. package/dist/types.d.ts +115 -0
  109. package/dist/types.d.ts.map +1 -0
  110. package/dist/utils.d.ts +115 -0
  111. package/dist/utils.d.ts.map +1 -0
  112. package/dist/utils_types.d.ts +14 -0
  113. package/drizzle/client/0000_bumpy_carnage.sql +33 -0
  114. package/drizzle/client/meta/0000_snapshot.json +199 -0
  115. package/drizzle/client/meta/_journal.json +13 -0
  116. package/drizzle/project/0000_spooky_lady_ursula.sql +192 -0
  117. package/drizzle/project/meta/0000_snapshot.json +1137 -0
  118. package/drizzle/project/meta/_journal.json +13 -0
  119. package/package.json +202 -0
  120. package/src/blob-api.js +139 -0
  121. package/src/blob-store/index.js +325 -0
  122. package/src/blob-store/live-download.js +373 -0
  123. package/src/config-import.js +604 -0
  124. package/src/constants.js +34 -0
  125. package/src/core-manager/bitfield-rle.js +235 -0
  126. package/src/core-manager/core-index.js +87 -0
  127. package/src/core-manager/index.js +504 -0
  128. package/src/core-manager/random-access-file-pool.js +30 -0
  129. package/src/core-manager/remote-bitfield.js +416 -0
  130. package/src/core-ownership.js +235 -0
  131. package/src/datastore/README.md +46 -0
  132. package/src/datastore/index.js +234 -0
  133. package/src/datatype/README.md +33 -0
  134. package/src/datatype/index.d.ts +108 -0
  135. package/src/datatype/index.js +358 -0
  136. package/src/discovery/local-discovery.js +303 -0
  137. package/src/errors.js +5 -0
  138. package/src/fastify-controller.js +84 -0
  139. package/src/fastify-plugins/blobs.js +139 -0
  140. package/src/fastify-plugins/constants.js +5 -0
  141. package/src/fastify-plugins/icons.js +158 -0
  142. package/src/fastify-plugins/maps/index.js +173 -0
  143. package/src/fastify-plugins/maps/offline-fallback-map.js +114 -0
  144. package/src/fastify-plugins/maps/static-maps.js +271 -0
  145. package/src/fastify-plugins/utils.js +52 -0
  146. package/src/generated/README.md +3 -0
  147. package/src/generated/extensions.d.ts +44 -0
  148. package/src/generated/extensions.js +196 -0
  149. package/src/generated/extensions.ts +237 -0
  150. package/src/generated/keys.d.ts +36 -0
  151. package/src/generated/keys.js +148 -0
  152. package/src/generated/keys.ts +185 -0
  153. package/src/generated/rpc.d.ts +87 -0
  154. package/src/generated/rpc.js +389 -0
  155. package/src/generated/rpc.ts +463 -0
  156. package/src/icon-api.js +282 -0
  157. package/src/index-writer/README.md +38 -0
  158. package/src/index-writer/index.js +124 -0
  159. package/src/index.js +16 -0
  160. package/src/invite-api.js +450 -0
  161. package/src/lib/hashmap.js +91 -0
  162. package/src/lib/hypercore-helpers.js +18 -0
  163. package/src/lib/noise-secret-stream-helpers.js +37 -0
  164. package/src/lib/ponyfills.js +25 -0
  165. package/src/lib/string.js +7 -0
  166. package/src/lib/timing-safe-equal.js +34 -0
  167. package/src/local-peers.js +737 -0
  168. package/src/logger.js +99 -0
  169. package/src/mapeo-manager.js +914 -0
  170. package/src/mapeo-project.js +980 -0
  171. package/src/member-api.js +319 -0
  172. package/src/roles.js +412 -0
  173. package/src/schema/client.js +55 -0
  174. package/src/schema/project.js +44 -0
  175. package/src/schema/schema-to-drizzle.js +118 -0
  176. package/src/schema/types.ts +153 -0
  177. package/src/schema/utils.js +51 -0
  178. package/src/sync/core-sync-state.js +440 -0
  179. package/src/sync/namespace-sync-state.js +193 -0
  180. package/src/sync/peer-sync-controller.js +332 -0
  181. package/src/sync/sync-api.js +588 -0
  182. package/src/sync/sync-state.js +63 -0
  183. package/src/translation-api.js +141 -0
  184. package/src/types.ts +149 -0
  185. package/src/utils.js +210 -0
  186. package/src/utils_types.d.ts +14 -0
@@ -0,0 +1,588 @@
1
+ import { TypedEmitter } from 'tiny-typed-emitter'
2
+ import { SyncState } from './sync-state.js'
3
+ import { PeerSyncController } from './peer-sync-controller.js'
4
+ import { Logger } from '../logger.js'
5
+ import {
6
+ DATA_NAMESPACES,
7
+ NAMESPACES,
8
+ PRESYNC_NAMESPACES,
9
+ } from '../constants.js'
10
+ import { ExhaustivenessError, assert, keyToId, noop } from '../utils.js'
11
+ import { NO_ROLE_ID } from '../roles.js'
12
+ /** @import { CoreOwnership as CoreOwnershipDoc } from '@comapeo/schema' */
13
+ /** @import { CoreOwnership } from '../core-ownership.js' */
14
+ /** @import { OpenedNoiseStream } from '../lib/noise-secret-stream-helpers.js' */
15
+
16
+ export const kHandleDiscoveryKey = Symbol('handle discovery key')
17
+ export const kSyncState = Symbol('sync state')
18
+ export const kRequestFullStop = Symbol('background')
19
+ export const kRescindFullStopRequest = Symbol('foreground')
20
+
21
+ /**
22
+ * @typedef {'initial' | 'full'} SyncType
23
+ */
24
+
25
+ /**
26
+ * @typedef {'none' | 'presync' | 'all'} SyncEnabledState
27
+ */
28
+
29
+ /**
30
+ * @internal
31
+ * @typedef {object} RemoteDeviceNamespaceGroupSyncState
32
+ * @property {boolean} isSyncEnabled do we want to sync this namespace group?
33
+ * @property {number} want number of blocks this device wants from us
34
+ * @property {number} wanted number of blocks we want from this device
35
+ */
36
+
37
+ /**
38
+ * @internal
39
+ * @typedef {object} RemoteDeviceSyncState state of sync for a remote peer
40
+ * @property {RemoteDeviceNamespaceGroupSyncState} initial state of initial namespaces (auth, config, and blob index)
41
+ * @property {RemoteDeviceNamespaceGroupSyncState} data state of data namespaces (data and blob)
42
+ */
43
+
44
+ /**
45
+ * @typedef {object} State
46
+ * @property {{ isSyncEnabled: boolean }} initial state of initial namespace syncing (auth, config, and blob index) for local device
47
+ * @property {{ isSyncEnabled: boolean }} data state of data namespace syncing (data and blob) for local device
48
+ * @property {Record<string, RemoteDeviceSyncState>} remoteDeviceSyncState sync states for remote peers
49
+ */
50
+
51
+ /**
52
+ * @typedef {object} SyncEvents
53
+ * @property {(syncState: State) => void} sync-state
54
+ */
55
+
56
+ /**
57
+ * @extends {TypedEmitter<SyncEvents>}
58
+ */
59
+ export class SyncApi extends TypedEmitter {
60
+ #coreManager
61
+ #coreOwnership
62
+ #roles
63
+ /** @type {Map<import('protomux'), PeerSyncController>} */
64
+ #peerSyncControllers = new Map()
65
+ /** @type {Map<string, PeerSyncController>} */
66
+ #pscByPeerId = new Map()
67
+ #wantsToSyncData = false
68
+ #hasRequestedFullStop = false
69
+ /** @type {SyncEnabledState} */
70
+ #previousSyncEnabledState = 'none'
71
+ /** @type {null | number} */
72
+ #previousDataHave = null
73
+ /** @type {null | number} */
74
+ #autostopDataSyncAfter = null
75
+ /** @type {null | ReturnType<typeof setTimeout>} */
76
+ #autostopDataSyncTimeout = null
77
+ /** @type {Map<import('protomux'), Set<Buffer>>} */
78
+ #pendingDiscoveryKeys = new Map()
79
+ #l
80
+
81
+ /**
82
+ *
83
+ * @param {object} opts
84
+ * @param {import('../core-manager/index.js').CoreManager} opts.coreManager
85
+ * @param {CoreOwnership} opts.coreOwnership
86
+ * @param {import('../roles.js').Roles} opts.roles
87
+ * @param {number} [opts.throttleMs]
88
+ * @param {Logger} [opts.logger]
89
+ */
90
+ constructor({ coreManager, throttleMs = 200, roles, logger, coreOwnership }) {
91
+ super()
92
+ this.#l = Logger.create('syncApi', logger)
93
+ this.#coreManager = coreManager
94
+ this.#coreOwnership = coreOwnership
95
+ this.#roles = roles
96
+ this[kSyncState] = new SyncState({
97
+ coreManager,
98
+ throttleMs,
99
+ peerSyncControllers: this.#pscByPeerId,
100
+ })
101
+ this[kSyncState].setMaxListeners(0)
102
+ this[kSyncState].on('state', (namespaceSyncState) => {
103
+ this.#updateState(namespaceSyncState)
104
+ })
105
+
106
+ this.#coreManager.creatorCore.on('peer-add', this.#handlePeerAdd)
107
+ this.#coreManager.creatorCore.on('peer-remove', this.#handlePeerRemove)
108
+
109
+ roles.on('update', this.#handleRoleUpdate)
110
+ coreOwnership.on('update', this.#handleCoreOwnershipUpdate)
111
+
112
+ this.#coreOwnership
113
+ .getAll()
114
+ .then((coreOwnerships) =>
115
+ Promise.allSettled(
116
+ coreOwnerships.map(async (coreOwnership) => {
117
+ if (coreOwnership.docId === this.#coreManager.deviceId) return
118
+ await this.#validateRoleAndAddCoresForPeer(coreOwnership)
119
+ })
120
+ )
121
+ )
122
+ .catch(noop)
123
+ }
124
+
125
+ /** @type {import('../local-peers.js').LocalPeersEvents['discovery-key']} */
126
+ [kHandleDiscoveryKey](discoveryKey, protomux) {
127
+ const peerSyncController = this.#peerSyncControllers.get(protomux)
128
+ if (peerSyncController) {
129
+ peerSyncController.handleDiscoveryKey(discoveryKey)
130
+ return
131
+ }
132
+ // We will reach here if we are not part of the project, so we can ignore
133
+ // these keys. However it's also possible to reach here when we are part of
134
+ // a project, but the creator core `peer-add` event has not yet fired, so we
135
+ // queue this to be handled in `#handlePeerAdd`
136
+ const peerQueue = this.#pendingDiscoveryKeys.get(protomux) || new Set()
137
+ peerQueue.add(discoveryKey)
138
+ this.#pendingDiscoveryKeys.set(protomux, peerQueue)
139
+
140
+ // If we _are_ part of the project, the `peer-add` should happen very soon
141
+ // after we get a discovery-key event, so we cleanup our queue to avoid
142
+ // memory leaks for any discovery keys that have not been handled.
143
+ setTimeout(() => {
144
+ const peerQueue = this.#pendingDiscoveryKeys.get(protomux)
145
+ if (!peerQueue) return
146
+ peerQueue.delete(discoveryKey)
147
+ if (peerQueue.size === 0) {
148
+ this.#pendingDiscoveryKeys.delete(protomux)
149
+ }
150
+ }, 500)
151
+ }
152
+
153
+ /**
154
+ * Get the current sync state (initial and full). Also emitted via the 'sync-state' event
155
+ * @returns {State}
156
+ */
157
+ getState() {
158
+ return this.#getState(this[kSyncState].getState())
159
+ }
160
+
161
+ /**
162
+ * @param {import('./sync-state.js').State} namespaceSyncState
163
+ * @returns {State}
164
+ */
165
+ #getState(namespaceSyncState) {
166
+ const remoteDeviceSyncState = getRemoteDevicesSyncState(
167
+ namespaceSyncState,
168
+ this.#peerSyncControllers.values()
169
+ )
170
+
171
+ switch (this.#previousSyncEnabledState) {
172
+ case 'none':
173
+ return {
174
+ initial: { isSyncEnabled: false },
175
+ data: { isSyncEnabled: false },
176
+ remoteDeviceSyncState,
177
+ }
178
+ case 'presync':
179
+ return {
180
+ initial: { isSyncEnabled: true },
181
+ data: { isSyncEnabled: false },
182
+ remoteDeviceSyncState,
183
+ }
184
+ case 'all':
185
+ return {
186
+ initial: { isSyncEnabled: true },
187
+ data: { isSyncEnabled: true },
188
+ remoteDeviceSyncState,
189
+ }
190
+ default:
191
+ throw new ExhaustivenessError(this.#previousSyncEnabledState)
192
+ }
193
+ }
194
+
195
+ /**
196
+ * Update which namespaces are synced and the autostop timeout.
197
+ *
198
+ * The following table describes the expected behavior based on inputs.
199
+ *
200
+ * | Want to sync data? | Full stop requested? | Synced? | Enabled | Timeout |
201
+ * |--------------------|----------------------|---------|---------|---------|
202
+ * | no | no | no | presync | off |
203
+ * | no | no | yes | presync | off |
204
+ * | no | yes | no | presync | off |
205
+ * | no | yes | yes | none | off |
206
+ * | yes | no | no | all | off |
207
+ * | yes | no | yes | all | on |
208
+ * | yes | yes | no | all | off |
209
+ * | yes | yes | yes | none | off |
210
+ */
211
+ #updateState(namespaceSyncState = this[kSyncState].getState()) {
212
+ const dataHave = DATA_NAMESPACES.reduce(
213
+ (total, namespace) =>
214
+ total + namespaceSyncState[namespace].localState.have,
215
+ 0
216
+ )
217
+ const hasReceivedNewData = dataHave !== this.#previousDataHave
218
+ if (hasReceivedNewData) {
219
+ this.#clearAutostopDataSyncTimeoutIfExists()
220
+ }
221
+ this.#previousDataHave = dataHave
222
+
223
+ /** @type {SyncEnabledState} */ let syncEnabledState
224
+ if (this.#hasRequestedFullStop) {
225
+ this.#clearAutostopDataSyncTimeoutIfExists()
226
+ if (this.#previousSyncEnabledState === 'none') {
227
+ syncEnabledState = 'none'
228
+ } else if (
229
+ isSynced(
230
+ namespaceSyncState,
231
+ this.#wantsToSyncData ? 'full' : 'initial',
232
+ this.#peerSyncControllers
233
+ )
234
+ ) {
235
+ syncEnabledState = 'none'
236
+ } else if (this.#wantsToSyncData) {
237
+ syncEnabledState = 'all'
238
+ } else {
239
+ syncEnabledState = 'presync'
240
+ }
241
+ } else if (this.#wantsToSyncData) {
242
+ if (
243
+ isSynced(
244
+ namespaceSyncState,
245
+ this.#wantsToSyncData ? 'full' : 'initial',
246
+ this.#peerSyncControllers
247
+ )
248
+ ) {
249
+ if (typeof this.#autostopDataSyncAfter === 'number') {
250
+ this.#autostopDataSyncTimeout ??= setTimeout(() => {
251
+ this.#wantsToSyncData = false
252
+ this.#updateState()
253
+ }, this.#autostopDataSyncAfter)
254
+ }
255
+ } else {
256
+ this.#clearAutostopDataSyncTimeoutIfExists()
257
+ }
258
+ syncEnabledState = 'all'
259
+ } else {
260
+ this.#clearAutostopDataSyncTimeoutIfExists()
261
+ syncEnabledState = 'presync'
262
+ }
263
+
264
+ this.#l.log(`Setting sync enabled state to "${syncEnabledState}"`)
265
+ for (const peerSyncController of this.#peerSyncControllers.values()) {
266
+ peerSyncController.setSyncEnabledState(syncEnabledState)
267
+ }
268
+
269
+ this.#previousSyncEnabledState = syncEnabledState
270
+
271
+ this.emit('sync-state', this.#getState(namespaceSyncState))
272
+ }
273
+
274
+ /**
275
+ * Start syncing data cores.
276
+ *
277
+ * If the app is backgrounded and sync has already completed, this will do
278
+ * nothing until the app is foregrounded.
279
+ *
280
+ * @param {object} [options]
281
+ * @param {null | number} [options.autostopDataSyncAfter] If no data sync
282
+ * happens after this duration in milliseconds, sync will be automatically
283
+ * stopped as if {@link stop} was called.
284
+ */
285
+ start({ autostopDataSyncAfter } = {}) {
286
+ this.#wantsToSyncData = true
287
+ if (autostopDataSyncAfter === undefined) {
288
+ this.#updateState()
289
+ } else {
290
+ this.setAutostopDataSyncTimeout(autostopDataSyncAfter)
291
+ }
292
+ }
293
+
294
+ /**
295
+ * Stop syncing data cores.
296
+ *
297
+ * Pre-sync cores will continue syncing unless the app is backgrounded.
298
+ */
299
+ stop() {
300
+ this.#wantsToSyncData = false
301
+ this.#updateState()
302
+ }
303
+
304
+ /**
305
+ * Request a graceful stop to all sync.
306
+ */
307
+ [kRequestFullStop]() {
308
+ this.#hasRequestedFullStop = true
309
+ this.#updateState()
310
+ }
311
+
312
+ /**
313
+ * Rescind any requests for a full stop.
314
+ */
315
+ [kRescindFullStopRequest]() {
316
+ this.#hasRequestedFullStop = false
317
+ this.#updateState()
318
+ }
319
+
320
+ /**
321
+ * @param {null | number} autostopDataSyncAfter
322
+ * @returns {void}
323
+ */
324
+ setAutostopDataSyncTimeout(autostopDataSyncAfter) {
325
+ assertAutostopDataSyncAfterIsValid(autostopDataSyncAfter)
326
+ this.#clearAutostopDataSyncTimeoutIfExists()
327
+ this.#autostopDataSyncAfter = autostopDataSyncAfter
328
+ this.#updateState()
329
+ }
330
+
331
+ /**
332
+ * @param {SyncType} type
333
+ * @returns {Promise<void>}
334
+ */
335
+ async waitForSync(type) {
336
+ const state = this[kSyncState].getState()
337
+ if (isSynced(state, type, this.#peerSyncControllers)) return
338
+ return new Promise((res) => {
339
+ const _this = this
340
+ this[kSyncState].on('state', function onState(state) {
341
+ if (!isSynced(state, type, _this.#peerSyncControllers)) return
342
+ _this[kSyncState].off('state', onState)
343
+ res()
344
+ })
345
+ })
346
+ }
347
+
348
+ #clearAutostopDataSyncTimeoutIfExists() {
349
+ if (this.#autostopDataSyncTimeout) {
350
+ clearTimeout(this.#autostopDataSyncTimeout)
351
+ this.#autostopDataSyncTimeout = null
352
+ }
353
+ }
354
+
355
+ /**
356
+ * Bound to `this`
357
+ *
358
+ * This will be called whenever a peer is successfully added to the creator
359
+ * core, which means that the peer has the project key. The PeerSyncController
360
+ * will then handle validation of role records to ensure that the peer is
361
+ * actually still part of the project.
362
+ *
363
+ * @param {{ protomux: import('protomux')<OpenedNoiseStream> }} peer
364
+ */
365
+ #handlePeerAdd = (peer) => {
366
+ const { protomux } = peer
367
+ if (this.#peerSyncControllers.has(protomux)) {
368
+ this.#l.log(
369
+ 'Unexpected existing peer sync controller for peer %h',
370
+ protomux.stream.remotePublicKey
371
+ )
372
+ return
373
+ }
374
+ const peerSyncController = new PeerSyncController({
375
+ protomux,
376
+ coreManager: this.#coreManager,
377
+ syncState: this[kSyncState],
378
+ roles: this.#roles,
379
+ logger: this.#l,
380
+ })
381
+ this.#peerSyncControllers.set(protomux, peerSyncController)
382
+ this.#pscByPeerId.set(peerSyncController.peerId, peerSyncController)
383
+
384
+ // Add peer to all core states (via namespace sync states)
385
+ this[kSyncState].addPeer(peerSyncController.peerId)
386
+
387
+ this.#updateState()
388
+
389
+ const peerQueue = this.#pendingDiscoveryKeys.get(protomux)
390
+ if (peerQueue) {
391
+ for (const discoveryKey of peerQueue) {
392
+ peerSyncController.handleDiscoveryKey(discoveryKey)
393
+ }
394
+ this.#pendingDiscoveryKeys.delete(protomux)
395
+ }
396
+ }
397
+
398
+ /**
399
+ * Bound to `this`
400
+ *
401
+ * Called when a peer is removed from the creator core, e.g. when the
402
+ * connection is terminated.
403
+ *
404
+ * @param {{ protomux: import('protomux')<import('@hyperswarm/secret-stream')>, remotePublicKey: Buffer }} peer
405
+ */
406
+ #handlePeerRemove = (peer) => {
407
+ const { protomux } = peer
408
+ if (!this.#peerSyncControllers.has(protomux)) {
409
+ this.#l.log(
410
+ 'Unexpected no existing peer sync controller for peer %h',
411
+ protomux.stream.remotePublicKey
412
+ )
413
+ return
414
+ }
415
+ this.#peerSyncControllers.delete(protomux)
416
+ const peerId = keyToId(peer.remotePublicKey)
417
+ this.#pscByPeerId.delete(peerId)
418
+ this.#pendingDiscoveryKeys.delete(protomux)
419
+ }
420
+
421
+ /**
422
+ * @param {Set<string>} roleDocIds
423
+ * @returns {Promise<void>}
424
+ */
425
+ #handleRoleUpdate = async (roleDocIds) => {
426
+ /** @type {Promise<CoreOwnershipDoc>[]} */ const coreOwnershipPromises = []
427
+ for (const roleDocId of roleDocIds) {
428
+ // Ignore docs about ourselves
429
+ if (roleDocId === this.#coreManager.deviceId) continue
430
+ coreOwnershipPromises.push(this.#coreOwnership.get(roleDocId))
431
+ }
432
+
433
+ const ownershipResults = await Promise.allSettled(coreOwnershipPromises)
434
+
435
+ for (const result of ownershipResults) {
436
+ if (result.status === 'rejected') continue
437
+ await this.#validateRoleAndAddCoresForPeer(result.value)
438
+ this.#l.log('Added cores for device %S', result.value.docId)
439
+ }
440
+ }
441
+
442
+ /**
443
+ * @param {Set<string>} coreOwnershipDocIds
444
+ * @returns {Promise<void>}
445
+ */
446
+ #handleCoreOwnershipUpdate = async (coreOwnershipDocIds) => {
447
+ /** @type {Promise<void>[]} */ const promises = []
448
+
449
+ for (const coreOwnershipDocId of coreOwnershipDocIds) {
450
+ // Ignore our own ownership doc - we don't need to add cores for ourselves
451
+ if (coreOwnershipDocId === this.#coreManager.deviceId) continue
452
+
453
+ promises.push(
454
+ (async () => {
455
+ try {
456
+ const coreOwnershipDoc = await this.#coreOwnership.get(
457
+ coreOwnershipDocId
458
+ )
459
+ await this.#validateRoleAndAddCoresForPeer(coreOwnershipDoc)
460
+ this.#l.log('Added cores for device %S', coreOwnershipDocId)
461
+ } catch (_) {
462
+ // Ignore, we'll add these when the role is added
463
+ this.#l.log('No role for device %S', coreOwnershipDocId)
464
+ }
465
+ })()
466
+ )
467
+ }
468
+
469
+ await Promise.all(promises)
470
+ }
471
+
472
+ /**
473
+ * @param {CoreOwnershipDoc} coreOwnership
474
+ * @returns {Promise<void>}
475
+ */
476
+ async #validateRoleAndAddCoresForPeer(coreOwnership) {
477
+ const peerDeviceId = coreOwnership.docId
478
+ const role = await this.#roles.getRole(peerDeviceId)
479
+ // We only add cores for peers that have been explicitly written into the
480
+ // project. If in the future we allow syncing from blocked peers, we can
481
+ // drop the role check here, and just add cores.
482
+ if (role.roleId === NO_ROLE_ID) return
483
+ for (const ns of NAMESPACES) {
484
+ if (ns === 'auth') continue
485
+ const coreKey = Buffer.from(coreOwnership[`${ns}CoreId`], 'hex')
486
+ this.#coreManager.addCore(coreKey, ns)
487
+ }
488
+ }
489
+ }
490
+
491
+ /**
492
+ * @param {null | number} ms
493
+ * @returns {void}
494
+ */
495
+ function assertAutostopDataSyncAfterIsValid(ms) {
496
+ if (ms === null) return
497
+ assert(
498
+ ms > 0 && ms <= 2 ** 31 - 1 && Number.isSafeInteger(ms),
499
+ 'auto-stop timeout must be Infinity or a positive integer between 0 and the largest 32-bit signed integer'
500
+ )
501
+ }
502
+
503
+ /**
504
+ * Is the sync state "synced", e.g. is there nothing left to sync
505
+ *
506
+ * @param {import('./sync-state.js').State} state
507
+ * @param {SyncType} type
508
+ * @param {Map<import('protomux'), PeerSyncController>} peerSyncControllers
509
+ */
510
+ function isSynced(state, type, peerSyncControllers) {
511
+ const namespaces = type === 'initial' ? PRESYNC_NAMESPACES : NAMESPACES
512
+ for (const ns of namespaces) {
513
+ if (state[ns].dataToSync) return false
514
+ for (const psc of peerSyncControllers.values()) {
515
+ const { peerId } = psc
516
+ if (psc.syncCapability[ns] === 'blocked') continue
517
+ if (!(peerId in state[ns].remoteStates)) return false
518
+ if (state[ns].remoteStates[peerId].status === 'starting') return false
519
+ }
520
+ }
521
+ return true
522
+ }
523
+
524
+ /**
525
+ * @param {import('./sync-state.js').State} namespaceSyncState
526
+ * @param {Iterable<PeerSyncController>} peerSyncControllers
527
+ * @returns {State['remoteDeviceSyncState']}
528
+ */
529
+ function getRemoteDevicesSyncState(namespaceSyncState, peerSyncControllers) {
530
+ /** @type {State['remoteDeviceSyncState']} */ const result = {}
531
+
532
+ for (const psc of peerSyncControllers) {
533
+ const { peerId } = psc
534
+
535
+ /** @type {undefined | boolean} */ let isInitialEnabled
536
+ /** @type {undefined | boolean} */ let isDataEnabled
537
+
538
+ for (const namespace of NAMESPACES) {
539
+ const isBlocked = psc.syncCapability[namespace] === 'blocked'
540
+ if (isBlocked) continue
541
+
542
+ const peerNamespaceState =
543
+ namespaceSyncState[namespace].remoteStates[peerId]
544
+ if (!peerNamespaceState) continue
545
+
546
+ /** @type {boolean} */
547
+ let isSyncEnabled
548
+ switch (peerNamespaceState.status) {
549
+ case 'stopped':
550
+ case 'starting':
551
+ isSyncEnabled = false
552
+ break
553
+ case 'started':
554
+ isSyncEnabled = true
555
+ break
556
+ default:
557
+ throw new ExhaustivenessError(peerNamespaceState.status)
558
+ }
559
+
560
+ if (!Object.hasOwn(result, peerId)) {
561
+ result[peerId] = {
562
+ initial: { isSyncEnabled: false, want: 0, wanted: 0 },
563
+ data: { isSyncEnabled: false, want: 0, wanted: 0 },
564
+ }
565
+ }
566
+
567
+ /** @type {'initial' | 'data'} */ let namespaceGroup
568
+ const isPresyncNamespace = PRESYNC_NAMESPACES.includes(namespace)
569
+ if (isPresyncNamespace) {
570
+ namespaceGroup = 'initial'
571
+ isInitialEnabled = (isInitialEnabled ?? true) && isSyncEnabled
572
+ } else {
573
+ namespaceGroup = 'data'
574
+ isDataEnabled = (isDataEnabled ?? true) && isSyncEnabled
575
+ }
576
+
577
+ result[peerId][namespaceGroup].want += peerNamespaceState.want
578
+ result[peerId][namespaceGroup].wanted += peerNamespaceState.wanted
579
+ }
580
+
581
+ if (Object.hasOwn(result, peerId)) {
582
+ result[peerId].initial.isSyncEnabled = Boolean(isInitialEnabled)
583
+ result[peerId].data.isSyncEnabled = Boolean(isDataEnabled)
584
+ }
585
+ }
586
+
587
+ return result
588
+ }
@@ -0,0 +1,63 @@
1
+ import { TypedEmitter } from 'tiny-typed-emitter'
2
+ import { NAMESPACES } from '../constants.js'
3
+ import { NamespaceSyncState } from './namespace-sync-state.js'
4
+ import { throttle } from 'throttle-debounce'
5
+ import mapObject from 'map-obj'
6
+ /** @import { Namespace } from '../types.js' */
7
+
8
+ /**
9
+ * @typedef {Record<
10
+ * Namespace,
11
+ * import('./namespace-sync-state.js').SyncState
12
+ * >} State
13
+ */
14
+
15
+ /**
16
+ * Emit sync state when it changes
17
+ * @extends {TypedEmitter<{ state: (state: State) => void}>}
18
+ */
19
+ export class SyncState extends TypedEmitter {
20
+ #syncStates = /** @type {Record<Namespace, NamespaceSyncState> } */ ({})
21
+ /**
22
+ *
23
+ * @param {object} opts
24
+ * @param {import('../core-manager/index.js').CoreManager} opts.coreManager
25
+ * @param {Map<string, import('./peer-sync-controller.js').PeerSyncController>} opts.peerSyncControllers
26
+ * @param {number} [opts.throttleMs]
27
+ */
28
+ constructor({ coreManager, peerSyncControllers, throttleMs = 200 }) {
29
+ super()
30
+ const throttledHandleUpdate = throttle(throttleMs, this.#handleUpdate)
31
+ for (const namespace of NAMESPACES) {
32
+ this.#syncStates[namespace] = new NamespaceSyncState({
33
+ namespace,
34
+ coreManager,
35
+ onUpdate: throttledHandleUpdate,
36
+ peerSyncControllers,
37
+ })
38
+ }
39
+ }
40
+
41
+ /**
42
+ * @param {string} peerId
43
+ */
44
+ addPeer(peerId) {
45
+ for (const nss of Object.values(this.#syncStates)) {
46
+ nss.addPeer(peerId)
47
+ }
48
+ }
49
+
50
+ /**
51
+ * @returns {State}
52
+ */
53
+ getState() {
54
+ return mapObject(this.#syncStates, (namespace, nss) => [
55
+ namespace,
56
+ nss.getState(),
57
+ ])
58
+ }
59
+
60
+ #handleUpdate = () => {
61
+ this.emit('state', this.getState())
62
+ }
63
+ }