@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.
- package/LICENSE.md +9 -0
- package/README.md +31 -0
- package/dist/blob-api.d.ts +92 -0
- package/dist/blob-api.d.ts.map +1 -0
- package/dist/blob-store/index.d.ts +163 -0
- package/dist/blob-store/index.d.ts.map +1 -0
- package/dist/blob-store/live-download.d.ts +107 -0
- package/dist/blob-store/live-download.d.ts.map +1 -0
- package/dist/config-import.d.ts +74 -0
- package/dist/config-import.d.ts.map +1 -0
- package/dist/constants.d.ts +14 -0
- package/dist/constants.d.ts.map +1 -0
- package/dist/core-manager/bitfield-rle.d.ts +25 -0
- package/dist/core-manager/bitfield-rle.d.ts.map +1 -0
- package/dist/core-manager/core-index.d.ts +56 -0
- package/dist/core-manager/core-index.d.ts.map +1 -0
- package/dist/core-manager/index.d.ts +125 -0
- package/dist/core-manager/index.d.ts.map +1 -0
- package/dist/core-manager/random-access-file-pool.d.ts +17 -0
- package/dist/core-manager/random-access-file-pool.d.ts.map +1 -0
- package/dist/core-manager/remote-bitfield.d.ts +146 -0
- package/dist/core-manager/remote-bitfield.d.ts.map +1 -0
- package/dist/core-ownership.d.ts +112 -0
- package/dist/core-ownership.d.ts.map +1 -0
- package/dist/datastore/index.d.ts +91 -0
- package/dist/datastore/index.d.ts.map +1 -0
- package/dist/datatype/index.d.ts +108 -0
- package/dist/discovery/local-discovery.d.ts +64 -0
- package/dist/discovery/local-discovery.d.ts.map +1 -0
- package/dist/errors.d.ts +4 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/fastify-controller.d.ts +27 -0
- package/dist/fastify-controller.d.ts.map +1 -0
- package/dist/fastify-plugins/blobs.d.ts +6 -0
- package/dist/fastify-plugins/blobs.d.ts.map +1 -0
- package/dist/fastify-plugins/constants.d.ts +3 -0
- package/dist/fastify-plugins/constants.d.ts.map +1 -0
- package/dist/fastify-plugins/icons.d.ts +6 -0
- package/dist/fastify-plugins/icons.d.ts.map +1 -0
- package/dist/fastify-plugins/maps/index.d.ts +11 -0
- package/dist/fastify-plugins/maps/index.d.ts.map +1 -0
- package/dist/fastify-plugins/maps/offline-fallback-map.d.ts +12 -0
- package/dist/fastify-plugins/maps/offline-fallback-map.d.ts.map +1 -0
- package/dist/fastify-plugins/maps/static-maps.d.ts +11 -0
- package/dist/fastify-plugins/maps/static-maps.d.ts.map +1 -0
- package/dist/fastify-plugins/utils.d.ts +23 -0
- package/dist/fastify-plugins/utils.d.ts.map +1 -0
- package/dist/generated/extensions.d.ts +44 -0
- package/dist/generated/extensions.d.ts.map +1 -0
- package/dist/generated/keys.d.ts +36 -0
- package/dist/generated/keys.d.ts.map +1 -0
- package/dist/generated/rpc.d.ts +87 -0
- package/dist/generated/rpc.d.ts.map +1 -0
- package/dist/icon-api.d.ts +109 -0
- package/dist/icon-api.d.ts.map +1 -0
- package/dist/index-writer/index.d.ts +51 -0
- package/dist/index-writer/index.d.ts.map +1 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/invite-api.d.ts +70 -0
- package/dist/invite-api.d.ts.map +1 -0
- package/dist/lib/hashmap.d.ts +62 -0
- package/dist/lib/hashmap.d.ts.map +1 -0
- package/dist/lib/hypercore-helpers.d.ts +6 -0
- package/dist/lib/hypercore-helpers.d.ts.map +1 -0
- package/dist/lib/noise-secret-stream-helpers.d.ts +45 -0
- package/dist/lib/noise-secret-stream-helpers.d.ts.map +1 -0
- package/dist/lib/ponyfills.d.ts +10 -0
- package/dist/lib/ponyfills.d.ts.map +1 -0
- package/dist/lib/string.d.ts +2 -0
- package/dist/lib/string.d.ts.map +1 -0
- package/dist/lib/timing-safe-equal.d.ts +15 -0
- package/dist/lib/timing-safe-equal.d.ts.map +1 -0
- package/dist/local-peers.d.ts +151 -0
- package/dist/local-peers.d.ts.map +1 -0
- package/dist/logger.d.ts +32 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/mapeo-manager.d.ts +178 -0
- package/dist/mapeo-manager.d.ts.map +1 -0
- package/dist/mapeo-project.d.ts +3233 -0
- package/dist/mapeo-project.d.ts.map +1 -0
- package/dist/member-api.d.ts +114 -0
- package/dist/member-api.d.ts.map +1 -0
- package/dist/roles.d.ts +157 -0
- package/dist/roles.d.ts.map +1 -0
- package/dist/schema/client.d.ts +284 -0
- package/dist/schema/client.d.ts.map +1 -0
- package/dist/schema/project.d.ts +1812 -0
- package/dist/schema/project.d.ts.map +1 -0
- package/dist/schema/schema-to-drizzle.d.ts +20 -0
- package/dist/schema/schema-to-drizzle.d.ts.map +1 -0
- package/dist/schema/types.d.ts +98 -0
- package/dist/schema/types.d.ts.map +1 -0
- package/dist/schema/utils.d.ts +55 -0
- package/dist/schema/utils.d.ts.map +1 -0
- package/dist/sync/core-sync-state.d.ts +252 -0
- package/dist/sync/core-sync-state.d.ts.map +1 -0
- package/dist/sync/namespace-sync-state.d.ts +47 -0
- package/dist/sync/namespace-sync-state.d.ts.map +1 -0
- package/dist/sync/peer-sync-controller.d.ts +44 -0
- package/dist/sync/peer-sync-controller.d.ts.map +1 -0
- package/dist/sync/sync-api.d.ts +158 -0
- package/dist/sync/sync-api.d.ts.map +1 -0
- package/dist/sync/sync-state.d.ts +40 -0
- package/dist/sync/sync-state.d.ts.map +1 -0
- package/dist/translation-api.d.ts +288 -0
- package/dist/translation-api.d.ts.map +1 -0
- package/dist/types.d.ts +115 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/utils.d.ts +115 -0
- package/dist/utils.d.ts.map +1 -0
- package/dist/utils_types.d.ts +14 -0
- package/drizzle/client/0000_bumpy_carnage.sql +33 -0
- package/drizzle/client/meta/0000_snapshot.json +199 -0
- package/drizzle/client/meta/_journal.json +13 -0
- package/drizzle/project/0000_spooky_lady_ursula.sql +192 -0
- package/drizzle/project/meta/0000_snapshot.json +1137 -0
- package/drizzle/project/meta/_journal.json +13 -0
- package/package.json +202 -0
- package/src/blob-api.js +139 -0
- package/src/blob-store/index.js +325 -0
- package/src/blob-store/live-download.js +373 -0
- package/src/config-import.js +604 -0
- package/src/constants.js +34 -0
- package/src/core-manager/bitfield-rle.js +235 -0
- package/src/core-manager/core-index.js +87 -0
- package/src/core-manager/index.js +504 -0
- package/src/core-manager/random-access-file-pool.js +30 -0
- package/src/core-manager/remote-bitfield.js +416 -0
- package/src/core-ownership.js +235 -0
- package/src/datastore/README.md +46 -0
- package/src/datastore/index.js +234 -0
- package/src/datatype/README.md +33 -0
- package/src/datatype/index.d.ts +108 -0
- package/src/datatype/index.js +358 -0
- package/src/discovery/local-discovery.js +303 -0
- package/src/errors.js +5 -0
- package/src/fastify-controller.js +84 -0
- package/src/fastify-plugins/blobs.js +139 -0
- package/src/fastify-plugins/constants.js +5 -0
- package/src/fastify-plugins/icons.js +158 -0
- package/src/fastify-plugins/maps/index.js +173 -0
- package/src/fastify-plugins/maps/offline-fallback-map.js +114 -0
- package/src/fastify-plugins/maps/static-maps.js +271 -0
- package/src/fastify-plugins/utils.js +52 -0
- package/src/generated/README.md +3 -0
- package/src/generated/extensions.d.ts +44 -0
- package/src/generated/extensions.js +196 -0
- package/src/generated/extensions.ts +237 -0
- package/src/generated/keys.d.ts +36 -0
- package/src/generated/keys.js +148 -0
- package/src/generated/keys.ts +185 -0
- package/src/generated/rpc.d.ts +87 -0
- package/src/generated/rpc.js +389 -0
- package/src/generated/rpc.ts +463 -0
- package/src/icon-api.js +282 -0
- package/src/index-writer/README.md +38 -0
- package/src/index-writer/index.js +124 -0
- package/src/index.js +16 -0
- package/src/invite-api.js +450 -0
- package/src/lib/hashmap.js +91 -0
- package/src/lib/hypercore-helpers.js +18 -0
- package/src/lib/noise-secret-stream-helpers.js +37 -0
- package/src/lib/ponyfills.js +25 -0
- package/src/lib/string.js +7 -0
- package/src/lib/timing-safe-equal.js +34 -0
- package/src/local-peers.js +737 -0
- package/src/logger.js +99 -0
- package/src/mapeo-manager.js +914 -0
- package/src/mapeo-project.js +980 -0
- package/src/member-api.js +319 -0
- package/src/roles.js +412 -0
- package/src/schema/client.js +55 -0
- package/src/schema/project.js +44 -0
- package/src/schema/schema-to-drizzle.js +118 -0
- package/src/schema/types.ts +153 -0
- package/src/schema/utils.js +51 -0
- package/src/sync/core-sync-state.js +440 -0
- package/src/sync/namespace-sync-state.js +193 -0
- package/src/sync/peer-sync-controller.js +332 -0
- package/src/sync/sync-api.js +588 -0
- package/src/sync/sync-state.js +63 -0
- package/src/translation-api.js +141 -0
- package/src/types.ts +149 -0
- package/src/utils.js +210 -0
- 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
|
+
}
|