@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.
Files changed (82) hide show
  1. package/dist/blob-store/index.d.ts +23 -49
  2. package/dist/blob-store/index.d.ts.map +1 -1
  3. package/dist/constants.d.ts +2 -1
  4. package/dist/constants.d.ts.map +1 -1
  5. package/dist/core-manager/index.d.ts +10 -0
  6. package/dist/core-manager/index.d.ts.map +1 -1
  7. package/dist/core-ownership.d.ts.map +1 -1
  8. package/dist/datastore/index.d.ts +5 -4
  9. package/dist/datastore/index.d.ts.map +1 -1
  10. package/dist/generated/extensions.d.ts +31 -0
  11. package/dist/generated/extensions.d.ts.map +1 -1
  12. package/dist/index.d.ts +2 -0
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/lib/drizzle-helpers.d.ts +6 -0
  15. package/dist/lib/drizzle-helpers.d.ts.map +1 -0
  16. package/dist/lib/error.d.ts +37 -0
  17. package/dist/lib/error.d.ts.map +1 -0
  18. package/dist/lib/get-own.d.ts +9 -0
  19. package/dist/lib/get-own.d.ts.map +1 -0
  20. package/dist/lib/is-hostname-ip-address.d.ts +17 -0
  21. package/dist/lib/is-hostname-ip-address.d.ts.map +1 -0
  22. package/dist/lib/omit.d.ts +17 -0
  23. package/dist/lib/omit.d.ts.map +1 -0
  24. package/dist/lib/ws-core-replicator.d.ts +11 -0
  25. package/dist/lib/ws-core-replicator.d.ts.map +1 -0
  26. package/dist/mapeo-manager.d.ts +18 -22
  27. package/dist/mapeo-manager.d.ts.map +1 -1
  28. package/dist/mapeo-project.d.ts +454 -37
  29. package/dist/mapeo-project.d.ts.map +1 -1
  30. package/dist/member-api.d.ts +40 -1
  31. package/dist/member-api.d.ts.map +1 -1
  32. package/dist/schema/client.d.ts +17 -5
  33. package/dist/schema/client.d.ts.map +1 -1
  34. package/dist/schema/project.d.ts +211 -1
  35. package/dist/schema/project.d.ts.map +1 -1
  36. package/dist/sync/peer-sync-controller.d.ts.map +1 -1
  37. package/dist/sync/sync-api.d.ts +28 -2
  38. package/dist/sync/sync-api.d.ts.map +1 -1
  39. package/dist/translation-api.d.ts.map +1 -1
  40. package/dist/types.d.ts +3 -2
  41. package/dist/types.d.ts.map +1 -1
  42. package/dist/utils.d.ts.map +1 -1
  43. package/drizzle/client/0001_chubby_cargill.sql +12 -0
  44. package/drizzle/client/meta/0001_snapshot.json +208 -0
  45. package/drizzle/client/meta/_journal.json +7 -0
  46. package/drizzle/project/0001_medical_wendell_rand.sql +22 -0
  47. package/drizzle/project/meta/0001_snapshot.json +1267 -0
  48. package/drizzle/project/meta/_journal.json +7 -0
  49. package/package.json +10 -6
  50. package/src/blob-store/index.js +20 -4
  51. package/src/config-import.js +0 -1
  52. package/src/constants.js +4 -1
  53. package/src/core-manager/index.js +58 -2
  54. package/src/core-ownership.js +5 -2
  55. package/src/datastore/README.md +1 -2
  56. package/src/datastore/index.js +4 -5
  57. package/src/fastify-plugins/blobs.js +1 -0
  58. package/src/fastify-plugins/maps.js +11 -3
  59. package/src/generated/extensions.d.ts +31 -0
  60. package/src/generated/extensions.js +150 -0
  61. package/src/generated/extensions.ts +181 -0
  62. package/src/index.js +10 -0
  63. package/src/invite-api.js +1 -1
  64. package/src/lib/drizzle-helpers.js +79 -0
  65. package/src/lib/error.js +47 -0
  66. package/src/lib/get-own.js +10 -0
  67. package/src/lib/is-hostname-ip-address.js +26 -0
  68. package/src/lib/omit.js +28 -0
  69. package/src/lib/ws-core-replicator.js +47 -0
  70. package/src/mapeo-manager.js +76 -53
  71. package/src/mapeo-project.js +155 -46
  72. package/src/member-api.js +253 -2
  73. package/src/schema/client.js +4 -3
  74. package/src/schema/project.js +7 -0
  75. package/src/sync/peer-sync-controller.js +1 -0
  76. package/src/sync/sync-api.js +171 -3
  77. package/src/translation-api.js +2 -2
  78. package/src/types.ts +4 -3
  79. package/src/utils.js +11 -14
  80. package/dist/lib/timing-safe-equal.d.ts +0 -15
  81. package/dist/lib/timing-safe-equal.d.ts.map +0 -1
  82. 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 './lib/timing-safe-equal.js'
15
- import { ROLES, isRoleIdForNewInvite } from './roles.js'
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
+ }
@@ -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 localDeviceInfoTable = sqliteTable('localDeviceInfo', {
52
+ export const deviceSettingsTable = sqliteTable('deviceSettings', {
53
53
  deviceId: text('deviceId').notNull().unique(),
54
- deviceInfo: deviceInfoColumn('deviceInfo').notNull(),
54
+ deviceInfo: deviceInfoColumn('deviceInfo'),
55
+ isArchiveDevice: int('isArchiveDevice', { mode: 'boolean' }),
55
56
  })
@@ -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)
@@ -152,6 +152,7 @@ export class PeerSyncController {
152
152
 
153
153
  if (didUpdate.auth) {
154
154
  try {
155
+ this.#log('reading role for %h', this.peerId)
155
156
  const cap = await this.#roles.getRole(this.peerId)
156
157
  this.#syncCapability = cap.sync
157
158
  } catch (e) {
@@ -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({ coreManager, throttleMs = 200, roles, logger, coreOwnership }) {
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
@@ -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
- /* eslint-disable no-unused-vars */
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]: ArrayAtLeastOne<BlobVariant<KeyType>>
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
  }