@comapeo/core 2.0.0 → 2.1.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/dist/blob-store/index.d.ts +23 -49
- package/dist/blob-store/index.d.ts.map +1 -1
- package/dist/constants.d.ts +2 -1
- package/dist/constants.d.ts.map +1 -1
- package/dist/core-manager/index.d.ts +10 -0
- package/dist/core-manager/index.d.ts.map +1 -1
- package/dist/core-ownership.d.ts.map +1 -1
- package/dist/datastore/index.d.ts +5 -4
- package/dist/datastore/index.d.ts.map +1 -1
- package/dist/generated/extensions.d.ts +31 -0
- package/dist/generated/extensions.d.ts.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/lib/drizzle-helpers.d.ts +6 -0
- package/dist/lib/drizzle-helpers.d.ts.map +1 -0
- package/dist/lib/error.d.ts +37 -0
- package/dist/lib/error.d.ts.map +1 -0
- package/dist/lib/get-own.d.ts +9 -0
- package/dist/lib/get-own.d.ts.map +1 -0
- package/dist/lib/is-hostname-ip-address.d.ts +17 -0
- package/dist/lib/is-hostname-ip-address.d.ts.map +1 -0
- package/dist/lib/omit.d.ts +17 -0
- package/dist/lib/omit.d.ts.map +1 -0
- package/dist/lib/ws-core-replicator.d.ts +11 -0
- package/dist/lib/ws-core-replicator.d.ts.map +1 -0
- package/dist/mapeo-manager.d.ts +18 -22
- package/dist/mapeo-manager.d.ts.map +1 -1
- package/dist/mapeo-project.d.ts +454 -37
- package/dist/mapeo-project.d.ts.map +1 -1
- package/dist/member-api.d.ts +40 -1
- package/dist/member-api.d.ts.map +1 -1
- package/dist/schema/client.d.ts +17 -5
- package/dist/schema/client.d.ts.map +1 -1
- package/dist/schema/project.d.ts +211 -1
- package/dist/schema/project.d.ts.map +1 -1
- package/dist/sync/peer-sync-controller.d.ts.map +1 -1
- package/dist/sync/sync-api.d.ts +28 -2
- package/dist/sync/sync-api.d.ts.map +1 -1
- package/dist/translation-api.d.ts.map +1 -1
- package/dist/types.d.ts +3 -2
- package/dist/types.d.ts.map +1 -1
- package/dist/utils.d.ts.map +1 -1
- package/drizzle/client/0001_chubby_cargill.sql +12 -0
- package/drizzle/client/meta/0001_snapshot.json +208 -0
- package/drizzle/client/meta/_journal.json +7 -0
- package/drizzle/project/0001_medical_wendell_rand.sql +22 -0
- package/drizzle/project/meta/0001_snapshot.json +1267 -0
- package/drizzle/project/meta/_journal.json +7 -0
- package/package.json +10 -6
- package/src/blob-store/index.js +20 -4
- package/src/config-import.js +0 -1
- package/src/constants.js +4 -1
- package/src/core-manager/index.js +58 -2
- package/src/core-ownership.js +5 -2
- package/src/datastore/README.md +1 -2
- package/src/datastore/index.js +4 -5
- package/src/fastify-plugins/blobs.js +1 -0
- package/src/fastify-plugins/maps.js +11 -3
- package/src/generated/extensions.d.ts +31 -0
- package/src/generated/extensions.js +150 -0
- package/src/generated/extensions.ts +181 -0
- package/src/index.js +10 -0
- package/src/invite-api.js +1 -1
- package/src/lib/drizzle-helpers.js +79 -0
- package/src/lib/error.js +47 -0
- package/src/lib/get-own.js +10 -0
- package/src/lib/is-hostname-ip-address.js +26 -0
- package/src/lib/omit.js +28 -0
- package/src/lib/ws-core-replicator.js +47 -0
- package/src/mapeo-manager.js +76 -53
- package/src/mapeo-project.js +155 -46
- package/src/member-api.js +253 -2
- package/src/schema/client.js +4 -3
- package/src/schema/project.js +7 -0
- package/src/sync/peer-sync-controller.js +1 -0
- package/src/sync/sync-api.js +171 -3
- package/src/translation-api.js +2 -2
- package/src/types.ts +4 -3
- package/src/utils.js +11 -14
- package/dist/lib/timing-safe-equal.d.ts +0 -15
- package/dist/lib/timing-safe-equal.d.ts.map +0 -1
- package/src/lib/timing-safe-equal.js +0 -34
package/src/member-api.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import * as b4a from 'b4a'
|
|
1
2
|
import * as crypto from 'node:crypto'
|
|
3
|
+
import WebSocket from 'ws'
|
|
2
4
|
import { TypedEmitter } from 'tiny-typed-emitter'
|
|
3
5
|
import { pEvent } from 'p-event'
|
|
4
6
|
import { InviteResponse_Decision } from './generated/rpc.js'
|
|
@@ -8,11 +10,15 @@ import {
|
|
|
8
10
|
ExhaustivenessError,
|
|
9
11
|
projectKeyToId,
|
|
10
12
|
projectKeyToProjectInviteId,
|
|
13
|
+
projectKeyToPublicId,
|
|
11
14
|
} from './utils.js'
|
|
12
15
|
import { keyBy } from './lib/key-by.js'
|
|
13
16
|
import { abortSignalAny } from './lib/ponyfills.js'
|
|
14
|
-
import timingSafeEqual from '
|
|
15
|
-
import {
|
|
17
|
+
import timingSafeEqual from 'string-timing-safe-equal'
|
|
18
|
+
import { isHostnameIpAddress } from './lib/is-hostname-ip-address.js'
|
|
19
|
+
import { ErrorWithCode, getErrorMessage } from './lib/error.js'
|
|
20
|
+
import { wsCoreReplicator } from './lib/ws-core-replicator.js'
|
|
21
|
+
import { MEMBER_ROLE_ID, ROLES, isRoleIdForNewInvite } from './roles.js'
|
|
16
22
|
/**
|
|
17
23
|
* @import {
|
|
18
24
|
* DeviceInfo,
|
|
@@ -21,11 +27,13 @@ import { ROLES, isRoleIdForNewInvite } from './roles.js'
|
|
|
21
27
|
* ProjectSettingsValue
|
|
22
28
|
* } from '@comapeo/schema'
|
|
23
29
|
*/
|
|
30
|
+
/** @import { Promisable } from 'type-fest' */
|
|
24
31
|
/** @import { Invite, InviteResponse } from './generated/rpc.js' */
|
|
25
32
|
/** @import { DataType } from './datatype/index.js' */
|
|
26
33
|
/** @import { DataStore } from './datastore/index.js' */
|
|
27
34
|
/** @import { deviceInfoTable } from './schema/project.js' */
|
|
28
35
|
/** @import { projectSettingsTable } from './schema/client.js' */
|
|
36
|
+
/** @import { ReplicationStream } from './types.js' */
|
|
29
37
|
|
|
30
38
|
/** @typedef {DataType<DataStore<'config'>, typeof deviceInfoTable, "deviceInfo", DeviceInfo, DeviceInfoValue>} DeviceInfoDataType */
|
|
31
39
|
/** @typedef {DataType<DataStore<'config'>, typeof projectSettingsTable, "projectSettings", ProjectSettings, ProjectSettingsValue>} ProjectDataType */
|
|
@@ -36,6 +44,8 @@ import { ROLES, isRoleIdForNewInvite } from './roles.js'
|
|
|
36
44
|
* @prop {DeviceInfo['name']} [name]
|
|
37
45
|
* @prop {DeviceInfo['deviceType']} [deviceType]
|
|
38
46
|
* @prop {DeviceInfo['createdAt']} [joinedAt]
|
|
47
|
+
* @prop {object} [selfHostedServerDetails]
|
|
48
|
+
* @prop {string} selfHostedServerDetails.baseUrl
|
|
39
49
|
*/
|
|
40
50
|
|
|
41
51
|
export class MemberApi extends TypedEmitter {
|
|
@@ -43,8 +53,11 @@ export class MemberApi extends TypedEmitter {
|
|
|
43
53
|
#roles
|
|
44
54
|
#coreOwnership
|
|
45
55
|
#encryptionKeys
|
|
56
|
+
#getProjectName
|
|
46
57
|
#projectKey
|
|
47
58
|
#rpc
|
|
59
|
+
#getReplicationStream
|
|
60
|
+
#waitForInitialSyncWithPeer
|
|
48
61
|
#dataTypes
|
|
49
62
|
|
|
50
63
|
/** @type {Map<string, { abortController: AbortController }>} */
|
|
@@ -56,8 +69,11 @@ export class MemberApi extends TypedEmitter {
|
|
|
56
69
|
* @param {import('./roles.js').Roles} opts.roles
|
|
57
70
|
* @param {import('./core-ownership.js').CoreOwnership} opts.coreOwnership
|
|
58
71
|
* @param {import('./generated/keys.js').EncryptionKeys} opts.encryptionKeys
|
|
72
|
+
* @param {() => Promisable<undefined | string>} opts.getProjectName
|
|
59
73
|
* @param {Buffer} opts.projectKey
|
|
60
74
|
* @param {import('./local-peers.js').LocalPeers} opts.rpc
|
|
75
|
+
* @param {() => ReplicationStream} opts.getReplicationStream
|
|
76
|
+
* @param {(deviceId: string, abortSignal: AbortSignal) => Promise<void>} opts.waitForInitialSyncWithPeer
|
|
61
77
|
* @param {Object} opts.dataTypes
|
|
62
78
|
* @param {Pick<DeviceInfoDataType, 'getByDocId' | 'getMany'>} opts.dataTypes.deviceInfo
|
|
63
79
|
* @param {Pick<ProjectDataType, 'getByDocId'>} opts.dataTypes.project
|
|
@@ -67,8 +83,11 @@ export class MemberApi extends TypedEmitter {
|
|
|
67
83
|
roles,
|
|
68
84
|
coreOwnership,
|
|
69
85
|
encryptionKeys,
|
|
86
|
+
getProjectName,
|
|
70
87
|
projectKey,
|
|
71
88
|
rpc,
|
|
89
|
+
getReplicationStream,
|
|
90
|
+
waitForInitialSyncWithPeer,
|
|
72
91
|
dataTypes,
|
|
73
92
|
}) {
|
|
74
93
|
super()
|
|
@@ -76,8 +95,11 @@ export class MemberApi extends TypedEmitter {
|
|
|
76
95
|
this.#roles = roles
|
|
77
96
|
this.#coreOwnership = coreOwnership
|
|
78
97
|
this.#encryptionKeys = encryptionKeys
|
|
98
|
+
this.#getProjectName = getProjectName
|
|
79
99
|
this.#projectKey = projectKey
|
|
80
100
|
this.#rpc = rpc
|
|
101
|
+
this.#getReplicationStream = getReplicationStream
|
|
102
|
+
this.#waitForInitialSyncWithPeer = waitForInitialSyncWithPeer
|
|
81
103
|
this.#dataTypes = dataTypes
|
|
82
104
|
}
|
|
83
105
|
|
|
@@ -245,6 +267,181 @@ export class MemberApi extends TypedEmitter {
|
|
|
245
267
|
this.#outboundInvitesByDevice.get(deviceId)?.abortController.abort()
|
|
246
268
|
}
|
|
247
269
|
|
|
270
|
+
/**
|
|
271
|
+
* Add a server peer.
|
|
272
|
+
*
|
|
273
|
+
* Can reject with any of the following error codes (accessed via `err.code`):
|
|
274
|
+
*
|
|
275
|
+
* - `INVALID_URL`: the base URL is invalid, likely due to user error.
|
|
276
|
+
* - `MISSING_DATA`: some required data is missing in order to add the server
|
|
277
|
+
* peer. For example, the project must have a name.
|
|
278
|
+
* - `NETWORK_ERROR`: there was an issue connecting to the server. Is the
|
|
279
|
+
* device online? Is the server online?
|
|
280
|
+
* - `INVALID_SERVER_RESPONSE`: we connected to the server but it returned
|
|
281
|
+
* an unexpected response. Is the server running a compatible version of
|
|
282
|
+
* CoMapeo Cloud?
|
|
283
|
+
*
|
|
284
|
+
* If `err.code` is not specified, that indicates a bug in this module.
|
|
285
|
+
*
|
|
286
|
+
* @param {string} baseUrl
|
|
287
|
+
* @param {object} [options]
|
|
288
|
+
* @param {boolean} [options.dangerouslyAllowInsecureConnections] Allow insecure network connections. Should only be used in tests.
|
|
289
|
+
* @returns {Promise<void>}
|
|
290
|
+
*/
|
|
291
|
+
async addServerPeer(
|
|
292
|
+
baseUrl,
|
|
293
|
+
{ dangerouslyAllowInsecureConnections = false } = {}
|
|
294
|
+
) {
|
|
295
|
+
if (
|
|
296
|
+
!isValidServerBaseUrl(baseUrl, { dangerouslyAllowInsecureConnections })
|
|
297
|
+
) {
|
|
298
|
+
throw new ErrorWithCode('INVALID_URL', 'Server base URL is invalid')
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const { serverDeviceId } = await this.#addServerToProject(baseUrl)
|
|
302
|
+
|
|
303
|
+
await this.#roles.assignRole(serverDeviceId, MEMBER_ROLE_ID)
|
|
304
|
+
|
|
305
|
+
await this.#waitForInitialSyncWithServer({
|
|
306
|
+
baseUrl,
|
|
307
|
+
serverDeviceId,
|
|
308
|
+
dangerouslyAllowInsecureConnections,
|
|
309
|
+
})
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* @param {string} baseUrl Server base URL. Should already be validated.
|
|
314
|
+
* @returns {Promise<{ serverDeviceId: string }>}
|
|
315
|
+
*/
|
|
316
|
+
async #addServerToProject(baseUrl) {
|
|
317
|
+
const projectName = await this.#getProjectName()
|
|
318
|
+
if (!projectName) {
|
|
319
|
+
throw new ErrorWithCode(
|
|
320
|
+
'MISSING_DATA',
|
|
321
|
+
'Project must have name to add server peer'
|
|
322
|
+
)
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const requestUrl = new URL('projects', baseUrl)
|
|
326
|
+
const requestBody = {
|
|
327
|
+
projectName,
|
|
328
|
+
projectKey: encodeBufferForServer(this.#projectKey),
|
|
329
|
+
encryptionKeys: {
|
|
330
|
+
auth: encodeBufferForServer(this.#encryptionKeys.auth),
|
|
331
|
+
data: encodeBufferForServer(this.#encryptionKeys.data),
|
|
332
|
+
config: encodeBufferForServer(this.#encryptionKeys.config),
|
|
333
|
+
blobIndex: encodeBufferForServer(this.#encryptionKeys.blobIndex),
|
|
334
|
+
blob: encodeBufferForServer(this.#encryptionKeys.blob),
|
|
335
|
+
},
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** @type {Response} */ let response
|
|
339
|
+
try {
|
|
340
|
+
response = await fetch(requestUrl, {
|
|
341
|
+
method: 'PUT',
|
|
342
|
+
body: JSON.stringify(requestBody),
|
|
343
|
+
headers: { 'Content-Type': 'application/json' },
|
|
344
|
+
})
|
|
345
|
+
} catch (err) {
|
|
346
|
+
throw new ErrorWithCode(
|
|
347
|
+
'NETWORK_ERROR',
|
|
348
|
+
`Failed to add server peer due to network error: ${getErrorMessage(
|
|
349
|
+
err
|
|
350
|
+
)}`
|
|
351
|
+
)
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
if (response.status !== 200 && response.status !== 201) {
|
|
355
|
+
throw new ErrorWithCode(
|
|
356
|
+
'INVALID_SERVER_RESPONSE',
|
|
357
|
+
`Failed to add server peer due to HTTP status code ${response.status}`
|
|
358
|
+
)
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
try {
|
|
362
|
+
const responseBody = await response.json()
|
|
363
|
+
assert(
|
|
364
|
+
responseBody &&
|
|
365
|
+
typeof responseBody === 'object' &&
|
|
366
|
+
'data' in responseBody &&
|
|
367
|
+
responseBody.data &&
|
|
368
|
+
typeof responseBody.data === 'object' &&
|
|
369
|
+
'deviceId' in responseBody.data &&
|
|
370
|
+
typeof responseBody.data.deviceId === 'string',
|
|
371
|
+
'Response body is valid'
|
|
372
|
+
)
|
|
373
|
+
return { serverDeviceId: responseBody.data.deviceId }
|
|
374
|
+
} catch (err) {
|
|
375
|
+
throw new ErrorWithCode(
|
|
376
|
+
'INVALID_SERVER_RESPONSE',
|
|
377
|
+
"Failed to add server peer because we couldn't parse the response"
|
|
378
|
+
)
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* @param {object} options
|
|
384
|
+
* @param {string} options.baseUrl
|
|
385
|
+
* @param {string} options.serverDeviceId
|
|
386
|
+
* @param {boolean} options.dangerouslyAllowInsecureConnections
|
|
387
|
+
* @returns {Promise<void>}
|
|
388
|
+
*/
|
|
389
|
+
async #waitForInitialSyncWithServer({
|
|
390
|
+
baseUrl,
|
|
391
|
+
serverDeviceId,
|
|
392
|
+
dangerouslyAllowInsecureConnections,
|
|
393
|
+
}) {
|
|
394
|
+
const projectPublicId = projectKeyToPublicId(this.#projectKey)
|
|
395
|
+
const websocketUrl = new URL('sync/' + projectPublicId, baseUrl)
|
|
396
|
+
websocketUrl.protocol =
|
|
397
|
+
dangerouslyAllowInsecureConnections && websocketUrl.protocol === 'http:'
|
|
398
|
+
? 'ws:'
|
|
399
|
+
: 'wss:'
|
|
400
|
+
|
|
401
|
+
const websocket = new WebSocket(websocketUrl)
|
|
402
|
+
|
|
403
|
+
try {
|
|
404
|
+
await pEvent(websocket, 'open', { rejectionEvents: ['error'] })
|
|
405
|
+
} catch (rejectionEvent) {
|
|
406
|
+
throw new ErrorWithCode(
|
|
407
|
+
// It's difficult for us to reliably disambiguate between "network error"
|
|
408
|
+
// and "invalid response from server" here, so we just say it was an
|
|
409
|
+
// invalid server response.
|
|
410
|
+
'INVALID_SERVER_RESPONSE',
|
|
411
|
+
'Failed to open the socket',
|
|
412
|
+
rejectionEvent &&
|
|
413
|
+
typeof rejectionEvent === 'object' &&
|
|
414
|
+
'error' in rejectionEvent
|
|
415
|
+
? { cause: rejectionEvent.error }
|
|
416
|
+
: { cause: rejectionEvent }
|
|
417
|
+
)
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
const onErrorPromise = pEvent(websocket, 'error')
|
|
421
|
+
|
|
422
|
+
const replicationStream = this.#getReplicationStream()
|
|
423
|
+
wsCoreReplicator(websocket, replicationStream)
|
|
424
|
+
|
|
425
|
+
const syncAbortController = new AbortController()
|
|
426
|
+
const syncPromise = this.#waitForInitialSyncWithPeer(
|
|
427
|
+
serverDeviceId,
|
|
428
|
+
syncAbortController.signal
|
|
429
|
+
)
|
|
430
|
+
|
|
431
|
+
const errorEvent = await Promise.race([onErrorPromise, syncPromise])
|
|
432
|
+
|
|
433
|
+
if (errorEvent) {
|
|
434
|
+
syncAbortController.abort()
|
|
435
|
+
websocket.close()
|
|
436
|
+
throw errorEvent.error
|
|
437
|
+
} else {
|
|
438
|
+
const onClosePromise = pEvent(websocket, 'close')
|
|
439
|
+
onErrorPromise.cancel()
|
|
440
|
+
websocket.close()
|
|
441
|
+
await onClosePromise
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
248
445
|
/**
|
|
249
446
|
* @param {string} deviceId
|
|
250
447
|
* @returns {Promise<MemberInfo>}
|
|
@@ -304,6 +501,8 @@ export class MemberApi extends TypedEmitter {
|
|
|
304
501
|
memberInfo.name = deviceInfo?.name
|
|
305
502
|
memberInfo.deviceType = deviceInfo?.deviceType
|
|
306
503
|
memberInfo.joinedAt = deviceInfo?.createdAt
|
|
504
|
+
memberInfo.selfHostedServerDetails =
|
|
505
|
+
deviceInfo?.selfHostedServerDetails
|
|
307
506
|
} catch (err) {
|
|
308
507
|
// Attempting to get someone else may throw because sync hasn't occurred or completed
|
|
309
508
|
// Only throw if attempting to get themself since the relevant information should be available
|
|
@@ -324,3 +523,55 @@ export class MemberApi extends TypedEmitter {
|
|
|
324
523
|
return this.#roles.assignRole(deviceId, roleId)
|
|
325
524
|
}
|
|
326
525
|
}
|
|
526
|
+
|
|
527
|
+
/**
|
|
528
|
+
* @param {string} baseUrl
|
|
529
|
+
* @param {object} options
|
|
530
|
+
* @param {boolean} options.dangerouslyAllowInsecureConnections
|
|
531
|
+
* @returns {boolean}
|
|
532
|
+
*/
|
|
533
|
+
function isValidServerBaseUrl(
|
|
534
|
+
baseUrl,
|
|
535
|
+
{ dangerouslyAllowInsecureConnections }
|
|
536
|
+
) {
|
|
537
|
+
if (baseUrl.length > 2000) return false
|
|
538
|
+
|
|
539
|
+
/** @type {URL} */ let url
|
|
540
|
+
try {
|
|
541
|
+
url = new URL(baseUrl)
|
|
542
|
+
} catch (_err) {
|
|
543
|
+
return false
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
const isProtocolValid =
|
|
547
|
+
url.protocol === 'https:' ||
|
|
548
|
+
(dangerouslyAllowInsecureConnections && url.protocol === 'http:')
|
|
549
|
+
if (!isProtocolValid) return false
|
|
550
|
+
|
|
551
|
+
if (url.username) return false
|
|
552
|
+
if (url.password) return false
|
|
553
|
+
if (url.search) return false
|
|
554
|
+
if (url.hash) return false
|
|
555
|
+
|
|
556
|
+
// We may want to support this someday. See <https://github.com/digidem/comapeo-core/issues/908>.
|
|
557
|
+
if (url.pathname !== '/') return false
|
|
558
|
+
|
|
559
|
+
if (
|
|
560
|
+
!isHostnameIpAddress(url.hostname) &&
|
|
561
|
+
!dangerouslyAllowInsecureConnections
|
|
562
|
+
) {
|
|
563
|
+
const parts = url.hostname.split('.')
|
|
564
|
+
const isDomainValid = parts.length >= 2 && parts.every(Boolean)
|
|
565
|
+
if (!isDomainValid) return false
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
return true
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/**
|
|
572
|
+
* @param {undefined | Uint8Array} buffer
|
|
573
|
+
* @returns {undefined | string}
|
|
574
|
+
*/
|
|
575
|
+
function encodeBufferForServer(buffer) {
|
|
576
|
+
return buffer ? b4a.toString(buffer, 'hex') : undefined
|
|
577
|
+
}
|
package/src/schema/client.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// These schemas are all in a "client" database. There is only one client
|
|
2
2
|
// database and it contains information that is shared across all projects on a
|
|
3
3
|
// device
|
|
4
|
-
import { blob, sqliteTable, text } from 'drizzle-orm/sqlite-core'
|
|
4
|
+
import { blob, sqliteTable, text, int } from 'drizzle-orm/sqlite-core'
|
|
5
5
|
import { dereferencedDocSchemas as schemas } from '@comapeo/schema'
|
|
6
6
|
import { jsonSchemaToDrizzleColumns as toColumns } from './schema-to-drizzle.js'
|
|
7
7
|
import { backlinkTable, customJson } from './utils.js'
|
|
@@ -49,7 +49,8 @@ const deviceInfoColumn =
|
|
|
49
49
|
)
|
|
50
50
|
|
|
51
51
|
// This table only ever has one row in it.
|
|
52
|
-
export const
|
|
52
|
+
export const deviceSettingsTable = sqliteTable('deviceSettings', {
|
|
53
53
|
deviceId: text('deviceId').notNull().unique(),
|
|
54
|
-
deviceInfo: deviceInfoColumn('deviceInfo')
|
|
54
|
+
deviceInfo: deviceInfoColumn('deviceInfo'),
|
|
55
|
+
isArchiveDevice: int('isArchiveDevice', { mode: 'boolean' }),
|
|
55
56
|
})
|
package/src/schema/project.js
CHANGED
|
@@ -15,6 +15,10 @@ export const observationTable = sqliteTable(
|
|
|
15
15
|
toColumns(schemas.observation)
|
|
16
16
|
)
|
|
17
17
|
export const trackTable = sqliteTable('track', toColumns(schemas.track))
|
|
18
|
+
export const remoteDetectionAlertTable = sqliteTable(
|
|
19
|
+
'remoteDetectionAlert',
|
|
20
|
+
toColumns(schemas.remoteDetectionAlert)
|
|
21
|
+
)
|
|
18
22
|
export const presetTable = sqliteTable('preset', toColumns(schemas.preset))
|
|
19
23
|
export const fieldTable = sqliteTable('field', toColumns(schemas.field))
|
|
20
24
|
export const coreOwnershipTable = sqliteTable(
|
|
@@ -31,6 +35,9 @@ export const iconTable = sqliteTable('icon', toColumns(schemas.icon))
|
|
|
31
35
|
export const translationBacklinkTable = backlinkTable(translationTable)
|
|
32
36
|
export const observationBacklinkTable = backlinkTable(observationTable)
|
|
33
37
|
export const trackBacklinkTable = backlinkTable(trackTable)
|
|
38
|
+
export const remoteDetectionAlertBacklinkTable = backlinkTable(
|
|
39
|
+
remoteDetectionAlertTable
|
|
40
|
+
)
|
|
34
41
|
export const presetBacklinkTable = backlinkTable(presetTable)
|
|
35
42
|
export const fieldBacklinkTable = backlinkTable(fieldTable)
|
|
36
43
|
export const coreOwnershipBacklinkTable = backlinkTable(coreOwnershipTable)
|
package/src/sync/sync-api.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { TypedEmitter } from 'tiny-typed-emitter'
|
|
2
|
+
import WebSocket from 'ws'
|
|
2
3
|
import { SyncState } from './sync-state.js'
|
|
3
4
|
import { PeerSyncController } from './peer-sync-controller.js'
|
|
4
5
|
import { Logger } from '../logger.js'
|
|
@@ -8,15 +9,23 @@ import {
|
|
|
8
9
|
PRESYNC_NAMESPACES,
|
|
9
10
|
} from '../constants.js'
|
|
10
11
|
import { ExhaustivenessError, assert, keyToId, noop } from '../utils.js'
|
|
12
|
+
import { getOwn } from '../lib/get-own.js'
|
|
13
|
+
import { wsCoreReplicator } from '../lib/ws-core-replicator.js'
|
|
11
14
|
import { NO_ROLE_ID } from '../roles.js'
|
|
12
15
|
/** @import { CoreOwnership as CoreOwnershipDoc } from '@comapeo/schema' */
|
|
16
|
+
/** @import * as http from 'node:http' */
|
|
13
17
|
/** @import { CoreOwnership } from '../core-ownership.js' */
|
|
14
18
|
/** @import { OpenedNoiseStream } from '../lib/noise-secret-stream-helpers.js' */
|
|
19
|
+
/** @import { ReplicationStream } from '../types.js' */
|
|
15
20
|
|
|
16
21
|
export const kHandleDiscoveryKey = Symbol('handle discovery key')
|
|
17
22
|
export const kSyncState = Symbol('sync state')
|
|
18
23
|
export const kRequestFullStop = Symbol('background')
|
|
19
24
|
export const kRescindFullStopRequest = Symbol('foreground')
|
|
25
|
+
export const kWaitForInitialSyncWithPeer = Symbol(
|
|
26
|
+
'wait for initial sync with peer'
|
|
27
|
+
)
|
|
28
|
+
export const kSetBlobDownloadFilter = Symbol('set isArchiveDevice')
|
|
20
29
|
|
|
21
30
|
/**
|
|
22
31
|
* @typedef {'initial' | 'full'} SyncType
|
|
@@ -65,6 +74,7 @@ export class SyncApi extends TypedEmitter {
|
|
|
65
74
|
/** @type {Map<string, PeerSyncController>} */
|
|
66
75
|
#pscByPeerId = new Map()
|
|
67
76
|
#wantsToSyncData = false
|
|
77
|
+
#wantsToConnectToServers = false
|
|
68
78
|
#hasRequestedFullStop = false
|
|
69
79
|
/** @type {SyncEnabledState} */
|
|
70
80
|
#previousSyncEnabledState = 'none'
|
|
@@ -77,22 +87,41 @@ export class SyncApi extends TypedEmitter {
|
|
|
77
87
|
/** @type {Map<import('protomux'), Set<Buffer>>} */
|
|
78
88
|
#pendingDiscoveryKeys = new Map()
|
|
79
89
|
#l
|
|
90
|
+
#getServerWebsocketUrls
|
|
91
|
+
#getReplicationStream
|
|
92
|
+
/** @type {Map<string, WebSocket>} */
|
|
93
|
+
#serverWebsockets = new Map()
|
|
94
|
+
#blobDownloadFilter
|
|
80
95
|
|
|
81
96
|
/**
|
|
82
|
-
*
|
|
83
97
|
* @param {object} opts
|
|
84
98
|
* @param {import('../core-manager/index.js').CoreManager} opts.coreManager
|
|
85
99
|
* @param {CoreOwnership} opts.coreOwnership
|
|
86
100
|
* @param {import('../roles.js').Roles} opts.roles
|
|
101
|
+
* @param {() => Promise<Iterable<string>>} opts.getServerWebsocketUrls
|
|
102
|
+
* @param {() => ReplicationStream} opts.getReplicationStream
|
|
103
|
+
* @param {import('../types.js').BlobFilter | null} opts.blobDownloadFilter
|
|
87
104
|
* @param {number} [opts.throttleMs]
|
|
88
105
|
* @param {Logger} [opts.logger]
|
|
89
106
|
*/
|
|
90
|
-
constructor({
|
|
107
|
+
constructor({
|
|
108
|
+
coreManager,
|
|
109
|
+
throttleMs = 200,
|
|
110
|
+
roles,
|
|
111
|
+
getServerWebsocketUrls,
|
|
112
|
+
getReplicationStream,
|
|
113
|
+
logger,
|
|
114
|
+
coreOwnership,
|
|
115
|
+
blobDownloadFilter,
|
|
116
|
+
}) {
|
|
91
117
|
super()
|
|
92
118
|
this.#l = Logger.create('syncApi', logger)
|
|
119
|
+
this.#blobDownloadFilter = blobDownloadFilter
|
|
93
120
|
this.#coreManager = coreManager
|
|
94
121
|
this.#coreOwnership = coreOwnership
|
|
95
122
|
this.#roles = roles
|
|
123
|
+
this.#getServerWebsocketUrls = getServerWebsocketUrls
|
|
124
|
+
this.#getReplicationStream = getReplicationStream
|
|
96
125
|
this[kSyncState] = new SyncState({
|
|
97
126
|
coreManager,
|
|
98
127
|
throttleMs,
|
|
@@ -123,6 +152,15 @@ export class SyncApi extends TypedEmitter {
|
|
|
123
152
|
.catch(noop)
|
|
124
153
|
}
|
|
125
154
|
|
|
155
|
+
/** @param {import('../types.js').BlobFilter | null} blobDownloadFilter */
|
|
156
|
+
[kSetBlobDownloadFilter](blobDownloadFilter) {
|
|
157
|
+
this.#blobDownloadFilter = blobDownloadFilter
|
|
158
|
+
if (!blobDownloadFilter) return // No download intents = intend to download everything
|
|
159
|
+
for (const peer of this.#coreManager.creatorCore.peers) {
|
|
160
|
+
this.#coreManager.sendDownloadIntents(blobDownloadFilter, peer)
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
126
164
|
/** @type {import('../local-peers.js').LocalPeersEvents['discovery-key']} */
|
|
127
165
|
[kHandleDiscoveryKey](discoveryKey, protomux) {
|
|
128
166
|
const peerSyncController = this.#peerSyncControllers.get(protomux)
|
|
@@ -274,6 +312,74 @@ export class SyncApi extends TypedEmitter {
|
|
|
274
312
|
this.emit('sync-state', this.#getState(namespaceSyncState))
|
|
275
313
|
}
|
|
276
314
|
|
|
315
|
+
/**
|
|
316
|
+
* @returns {void}
|
|
317
|
+
*/
|
|
318
|
+
connectServers() {
|
|
319
|
+
this.#wantsToConnectToServers = true
|
|
320
|
+
|
|
321
|
+
this.#getServerWebsocketUrls()
|
|
322
|
+
.then((urls) => {
|
|
323
|
+
const hasDisconnectedSinceWebsocketUrlsRequestFinished =
|
|
324
|
+
!this.#wantsToConnectToServers
|
|
325
|
+
if (hasDisconnectedSinceWebsocketUrlsRequestFinished) return
|
|
326
|
+
|
|
327
|
+
for (const url of urls) {
|
|
328
|
+
const existingWebsocket = this.#serverWebsockets.get(url)
|
|
329
|
+
if (
|
|
330
|
+
existingWebsocket &&
|
|
331
|
+
(existingWebsocket.readyState === WebSocket.OPEN ||
|
|
332
|
+
existingWebsocket.readyState === WebSocket.CONNECTING)
|
|
333
|
+
) {
|
|
334
|
+
continue
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const websocket = new WebSocket(url)
|
|
338
|
+
|
|
339
|
+
/** @param {Error} err */
|
|
340
|
+
const onWebsocketError = (err) => {
|
|
341
|
+
this.#l.log('Ignoring WebSocket error to %s: %o', url, err)
|
|
342
|
+
}
|
|
343
|
+
websocket.on('error', onWebsocketError)
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* @param {unknown} _req
|
|
347
|
+
* @param {http.IncomingMessage} res
|
|
348
|
+
*/
|
|
349
|
+
const onWebsocketUnexpectedResponse = (_req, res) => {
|
|
350
|
+
this.#l.log(
|
|
351
|
+
'Ignoring unexpected %d WebSocket response to %s',
|
|
352
|
+
res.statusCode,
|
|
353
|
+
url
|
|
354
|
+
)
|
|
355
|
+
}
|
|
356
|
+
websocket.on('unexpected-response', onWebsocketUnexpectedResponse)
|
|
357
|
+
|
|
358
|
+
const replicationStream = this.#getReplicationStream()
|
|
359
|
+
wsCoreReplicator(websocket, replicationStream)
|
|
360
|
+
|
|
361
|
+
this.#serverWebsockets.set(url, websocket)
|
|
362
|
+
websocket.once('close', () => {
|
|
363
|
+
websocket.off('error', onWebsocketError)
|
|
364
|
+
websocket.off('unexpected-response', onWebsocketUnexpectedResponse)
|
|
365
|
+
this.#serverWebsockets.delete(url)
|
|
366
|
+
})
|
|
367
|
+
}
|
|
368
|
+
})
|
|
369
|
+
.catch(noop)
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* @returns {void}
|
|
374
|
+
*/
|
|
375
|
+
disconnectServers() {
|
|
376
|
+
for (const websocket of this.#serverWebsockets.values()) {
|
|
377
|
+
websocket.close()
|
|
378
|
+
}
|
|
379
|
+
this.#serverWebsockets.clear()
|
|
380
|
+
this.#wantsToConnectToServers = false
|
|
381
|
+
}
|
|
382
|
+
|
|
277
383
|
/**
|
|
278
384
|
* Start syncing data cores.
|
|
279
385
|
*
|
|
@@ -348,6 +454,40 @@ export class SyncApi extends TypedEmitter {
|
|
|
348
454
|
})
|
|
349
455
|
}
|
|
350
456
|
|
|
457
|
+
/**
|
|
458
|
+
* @param {string} deviceId
|
|
459
|
+
* @param {AbortSignal} abortSignal
|
|
460
|
+
* @returns {Promise<void>}
|
|
461
|
+
*/
|
|
462
|
+
async [kWaitForInitialSyncWithPeer](deviceId, abortSignal) {
|
|
463
|
+
abortSignal.throwIfAborted()
|
|
464
|
+
|
|
465
|
+
const state = this[kSyncState].getState()
|
|
466
|
+
if (isInitiallySyncedWithPeer(state, deviceId)) return
|
|
467
|
+
|
|
468
|
+
return new Promise((resolve, reject) => {
|
|
469
|
+
/** @param {import('./sync-state.js').State} state */
|
|
470
|
+
const onState = (state) => {
|
|
471
|
+
if (isInitiallySyncedWithPeer(state, deviceId)) {
|
|
472
|
+
cleanup()
|
|
473
|
+
resolve()
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
const onAbort = () => {
|
|
477
|
+
cleanup()
|
|
478
|
+
reject(abortSignal.reason)
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
const cleanup = () => {
|
|
482
|
+
this[kSyncState].off('state', onState)
|
|
483
|
+
abortSignal.removeEventListener('abort', onAbort)
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
this[kSyncState].on('state', onState)
|
|
487
|
+
abortSignal.addEventListener('abort', onAbort)
|
|
488
|
+
})
|
|
489
|
+
}
|
|
490
|
+
|
|
351
491
|
#clearAutostopDataSyncTimeoutIfExists() {
|
|
352
492
|
if (this.#autostopDataSyncTimeout) {
|
|
353
493
|
clearTimeout(this.#autostopDataSyncTimeout)
|
|
@@ -363,7 +503,7 @@ export class SyncApi extends TypedEmitter {
|
|
|
363
503
|
* will then handle validation of role records to ensure that the peer is
|
|
364
504
|
* actually still part of the project.
|
|
365
505
|
*
|
|
366
|
-
* @param {{ protomux: import('protomux')<OpenedNoiseStream> }} peer
|
|
506
|
+
* @param {import('../types.js').HypercorePeer & { protomux: import('protomux')<OpenedNoiseStream> }} peer
|
|
367
507
|
*/
|
|
368
508
|
#handlePeerAdd = (peer) => {
|
|
369
509
|
const { protomux } = peer
|
|
@@ -374,6 +514,9 @@ export class SyncApi extends TypedEmitter {
|
|
|
374
514
|
)
|
|
375
515
|
return
|
|
376
516
|
}
|
|
517
|
+
if (this.#blobDownloadFilter) {
|
|
518
|
+
this.#coreManager.sendDownloadIntents(this.#blobDownloadFilter, peer)
|
|
519
|
+
}
|
|
377
520
|
const peerSyncController = new PeerSyncController({
|
|
378
521
|
protomux,
|
|
379
522
|
coreManager: this.#coreManager,
|
|
@@ -524,6 +667,31 @@ function isSynced(state, type, peerSyncControllers) {
|
|
|
524
667
|
return true
|
|
525
668
|
}
|
|
526
669
|
|
|
670
|
+
/**
|
|
671
|
+
* @param {import('./sync-state.js').State} state
|
|
672
|
+
* @param {string} peerId
|
|
673
|
+
*/
|
|
674
|
+
function isInitiallySyncedWithPeer(state, peerId) {
|
|
675
|
+
for (const ns of PRESYNC_NAMESPACES) {
|
|
676
|
+
const remoteDeviceSyncState = getOwn(state[ns].remoteStates, peerId)
|
|
677
|
+
if (!remoteDeviceSyncState) return false
|
|
678
|
+
|
|
679
|
+
switch (remoteDeviceSyncState.status) {
|
|
680
|
+
case 'starting':
|
|
681
|
+
return false
|
|
682
|
+
case 'started':
|
|
683
|
+
case 'stopped': {
|
|
684
|
+
const { want, wanted } = remoteDeviceSyncState
|
|
685
|
+
if (want || wanted) return false
|
|
686
|
+
break
|
|
687
|
+
}
|
|
688
|
+
default:
|
|
689
|
+
throw new ExhaustivenessError(remoteDeviceSyncState.status)
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
return true
|
|
693
|
+
}
|
|
694
|
+
|
|
527
695
|
/**
|
|
528
696
|
* @param {import('./sync-state.js').State} namespaceSyncState
|
|
529
697
|
* @param {Iterable<PeerSyncController>} peerSyncControllers
|
package/src/translation-api.js
CHANGED
|
@@ -2,6 +2,7 @@ import { and, sql } from 'drizzle-orm'
|
|
|
2
2
|
import { kCreateWithDocId, kSelect } from './datatype/index.js'
|
|
3
3
|
import { hashObject } from './utils.js'
|
|
4
4
|
import { NotFoundError } from './errors.js'
|
|
5
|
+
import { omit } from './lib/omit.js'
|
|
5
6
|
/** @import { Translation, TranslationValue } from '@comapeo/schema' */
|
|
6
7
|
/** @import { SetOptional } from 'type-fest' */
|
|
7
8
|
|
|
@@ -47,8 +48,7 @@ export default class TranslationApi {
|
|
|
47
48
|
* @param {TranslationValue} value
|
|
48
49
|
*/
|
|
49
50
|
async put(value) {
|
|
50
|
-
|
|
51
|
-
const { message, ...identifiers } = value
|
|
51
|
+
const identifiers = omit(value, ['message'])
|
|
52
52
|
const docId = hashObject(identifiers)
|
|
53
53
|
try {
|
|
54
54
|
const doc = await this.#dataType.getByDocId(docId)
|
package/src/types.ts
CHANGED
|
@@ -41,12 +41,13 @@ export type BlobId = Simplify<
|
|
|
41
41
|
}>
|
|
42
42
|
>
|
|
43
43
|
|
|
44
|
-
type ArrayAtLeastOne<T> = [T, ...T[]]
|
|
45
|
-
|
|
46
44
|
export type BlobFilter = RequireAtLeastOne<{
|
|
47
|
-
[KeyType in BlobType]:
|
|
45
|
+
[KeyType in BlobType]: Array<BlobVariant<KeyType>>
|
|
48
46
|
}>
|
|
49
47
|
|
|
48
|
+
/** Map of blob types to array of blob variants */
|
|
49
|
+
export type GenericBlobFilter = Record<string, string[]>
|
|
50
|
+
|
|
50
51
|
export type MapeoDocMap = {
|
|
51
52
|
[K in MapeoDoc['schemaName']]: Extract<MapeoDoc, { schemaName: K }>
|
|
52
53
|
}
|