@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
@@ -16,10 +16,11 @@ import {
16
16
  kBlobStore,
17
17
  kClearDataIfLeft,
18
18
  kProjectLeave,
19
+ kSetIsArchiveDevice,
19
20
  kSetOwnDeviceInfo,
20
21
  } from './mapeo-project.js'
21
22
  import {
22
- localDeviceInfoTable,
23
+ deviceSettingsTable,
23
24
  projectKeysTable,
24
25
  projectSettingsTable,
25
26
  } from './schema/client.js'
@@ -34,6 +35,7 @@ import {
34
35
  projectKeyToPublicId,
35
36
  } from './utils.js'
36
37
  import { openedNoiseSecretStream } from './lib/noise-secret-stream-helpers.js'
38
+ import { omit } from './lib/omit.js'
37
39
  import { RandomAccessFilePool } from './core-manager/random-access-file-pool.js'
38
40
  import BlobServerPlugin from './fastify-plugins/blobs.js'
39
41
  import IconServerPlugin from './fastify-plugins/icons.js'
@@ -43,7 +45,6 @@ import { LocalPeers } from './local-peers.js'
43
45
  import { InviteApi } from './invite-api.js'
44
46
  import { LocalDiscovery } from './discovery/local-discovery.js'
45
47
  import { Roles } from './roles.js'
46
- import NoiseSecretStream from '@hyperswarm/secret-stream'
47
48
  import { Logger } from './logger.js'
48
49
  import {
49
50
  kSyncState,
@@ -51,6 +52,7 @@ import {
51
52
  kRescindFullStopRequest,
52
53
  } from './sync/sync-api.js'
53
54
  /** @import { ProjectSettingsValue as ProjectValue } from '@comapeo/schema' */
55
+ /** @import NoiseSecretStream from '@hyperswarm/secret-stream' */
54
56
  /** @import { SetNonNullable } from 'type-fest' */
55
57
  /** @import { CoreStorage, Namespace } from './types.js' */
56
58
  /** @import { DeviceInfoParam } from './schema/client.js' */
@@ -78,9 +80,6 @@ export const DEFAULT_FALLBACK_MAP_FILE_PATH = require.resolve(
78
80
  export const DEFAULT_ONLINE_STYLE_URL =
79
81
  'https://demotiles.maplibre.org/style.json'
80
82
 
81
- export const kRPC = Symbol('rpc')
82
- export const kManagerReplicate = Symbol('replicate manager')
83
-
84
83
  /**
85
84
  * @typedef {Omit<import('./local-peers.js').PeerInfo, 'protomux'>} PublicPeerInfo
86
85
  */
@@ -220,33 +219,10 @@ export class MapeoManager extends TypedEmitter {
220
219
  this.#localDiscovery.on('connection', this.#replicate.bind(this))
221
220
  }
222
221
 
223
- /**
224
- * MapeoRPC instance, used for tests
225
- */
226
- get [kRPC]() {
227
- return this.#localPeers
228
- }
229
-
230
222
  get deviceId() {
231
223
  return this.#deviceId
232
224
  }
233
225
 
234
- /**
235
- * Create a Mapeo replication stream. This replication connects the Mapeo RPC
236
- * channel and allows invites. All active projects will sync automatically to
237
- * this replication stream. Only use for local (trusted) connections, because
238
- * the RPC channel key is public. To sync a specific project without
239
- * connecting RPC, use project[kProjectReplication].
240
- *
241
- * @param {boolean} isInitiator
242
- */
243
- [kManagerReplicate](isInitiator) {
244
- const noiseStream = new NoiseSecretStream(isInitiator, undefined, {
245
- keyPair: this.#keyManager.getIdentityKeypair(),
246
- })
247
- return this.#replicate(noiseStream)
248
- }
249
-
250
226
  /**
251
227
  * @param {'blobs' | 'icons' | 'maps'} mediaType
252
228
  * @returns {Promise<string>}
@@ -442,9 +418,10 @@ export class MapeoManager extends TypedEmitter {
442
418
 
443
419
  // 7. Load config, if relevant
444
420
  // TODO: see how to expose warnings to frontend
445
- /* eslint-disable no-unused-vars */
421
+ // eslint-disable-next-line no-unused-vars
446
422
  let warnings
447
423
  if (configPath) {
424
+ // eslint-disable-next-line no-unused-vars
448
425
  warnings = await project.importConfig({ configPath })
449
426
  }
450
427
 
@@ -505,6 +482,7 @@ export class MapeoManager extends TypedEmitter {
505
482
  async #createProjectInstance(projectKeys) {
506
483
  validateProjectKeys(projectKeys)
507
484
  const projectId = keyToId(projectKeys.projectKey)
485
+ const isArchiveDevice = this.getIsArchiveDevice()
508
486
  const project = new MapeoProject({
509
487
  ...this.#projectStorage(projectId),
510
488
  ...projectKeys,
@@ -515,6 +493,7 @@ export class MapeoManager extends TypedEmitter {
515
493
  localPeers: this.#localPeers,
516
494
  logger: this.#loggerBase,
517
495
  getMediaBaseUrl: this.#getMediaBaseUrl.bind(this),
496
+ isArchiveDevice,
518
497
  })
519
498
  await project[kClearDataIfLeft]()
520
499
  return project
@@ -577,7 +556,7 @@ export class MapeoManager extends TypedEmitter {
577
556
  * downloaded their proof of project membership and the project config.
578
557
  *
579
558
  * @param {Pick<import('./generated/rpc.js').ProjectJoinDetails, 'projectKey' | 'encryptionKeys'> & { projectName: string }} projectJoinDetails
580
- * @param {{ waitForSync?: boolean }} [opts] For internal use in tests, set opts.waitForSync = false to not wait for sync during addProject()
559
+ * @param {{ waitForSync?: boolean }} [opts] Set opts.waitForSync = false to not wait for sync during addProject()
581
560
  * @returns {Promise<string>}
582
561
  */
583
562
  addProject = async (
@@ -731,9 +710,7 @@ export class MapeoManager extends TypedEmitter {
731
710
  }
732
711
 
733
712
  /**
734
- * @typedef {Exclude<
735
- * import('./schema/client.js').DeviceInfoParam['deviceType'],
736
- * 'selfHostedServer'>} RPCDeviceType
713
+ * @typedef {import('./schema/client.js').DeviceInfoParam['deviceType']} RPCDeviceType
737
714
  */
738
715
 
739
716
  /**
@@ -744,10 +721,10 @@ export class MapeoManager extends TypedEmitter {
744
721
  async setDeviceInfo(deviceInfo) {
745
722
  const values = { deviceId: this.#deviceId, deviceInfo }
746
723
  this.#db
747
- .insert(localDeviceInfoTable)
724
+ .insert(deviceSettingsTable)
748
725
  .values(values)
749
726
  .onConflictDoUpdate({
750
- target: localDeviceInfoTable.deviceId,
727
+ target: deviceSettingsTable.deviceId,
751
728
  set: values,
752
729
  })
753
730
  .run()
@@ -760,13 +737,22 @@ export class MapeoManager extends TypedEmitter {
760
737
  })
761
738
  )
762
739
 
763
- await Promise.all(
764
- this.#localPeers.peers
765
- .filter(({ status }) => status === 'connected')
766
- .map((peer) =>
767
- this.#localPeers.sendDeviceInfo(peer.deviceId, deviceInfo)
768
- )
769
- )
740
+ if (deviceInfo.deviceType !== 'selfHostedServer') {
741
+ // We have to make a copy of this because TypeScript can't guarantee that
742
+ // `deviceInfo` won't be mutated by the time it gets to the
743
+ // `sendDeviceInfo` call below.
744
+ const deviceInfoToSend = {
745
+ ...deviceInfo,
746
+ deviceType: deviceInfo.deviceType,
747
+ }
748
+ await Promise.all(
749
+ this.#localPeers.peers
750
+ .filter(({ status }) => status === 'connected')
751
+ .map((peer) =>
752
+ this.#localPeers.sendDeviceInfo(peer.deviceId, deviceInfoToSend)
753
+ )
754
+ )
755
+ }
770
756
 
771
757
  this.#l.log('set device info %o', deviceInfo)
772
758
  }
@@ -782,8 +768,8 @@ export class MapeoManager extends TypedEmitter {
782
768
  getDeviceInfo() {
783
769
  const row = this.#db
784
770
  .select()
785
- .from(localDeviceInfoTable)
786
- .where(eq(localDeviceInfoTable.deviceId, this.#deviceId))
771
+ .from(deviceSettingsTable)
772
+ .where(eq(deviceSettingsTable.deviceId, this.#deviceId))
787
773
  .get()
788
774
  return {
789
775
  deviceId: this.#deviceId,
@@ -792,6 +778,50 @@ export class MapeoManager extends TypedEmitter {
792
778
  }
793
779
  }
794
780
 
781
+ /**
782
+ * Set whether this device is an archive device. Archive devices will download
783
+ * all media during sync, where-as non-archive devices will not download media
784
+ * original variants, and only download preview and thumbnail variants.
785
+ * @param {boolean} isArchiveDevice
786
+ * @returns {void}
787
+ */
788
+ setIsArchiveDevice(isArchiveDevice) {
789
+ const values = { deviceId: this.#deviceId, isArchiveDevice }
790
+ const result = this.#db
791
+ .insert(deviceSettingsTable)
792
+ .values(values)
793
+ .onConflictDoUpdate({
794
+ target: deviceSettingsTable.deviceId,
795
+ set: values,
796
+ })
797
+ .run()
798
+ if (!result || result.changes === 0) {
799
+ throw new Error('Failed to set isArchiveDevice')
800
+ }
801
+ for (const project of this.#activeProjects.values()) {
802
+ project[kSetIsArchiveDevice](isArchiveDevice)
803
+ }
804
+ }
805
+
806
+ /**
807
+ * Get whether this device is an archive device. Archive devices will download
808
+ * all media during sync, where-as non-archive devices will not download media
809
+ * original variants, and only download preview and thumbnail variants.
810
+ * @returns {boolean} isArchiveDevice
811
+ */
812
+ getIsArchiveDevice() {
813
+ const row = this.#db
814
+ .select()
815
+ .from(deviceSettingsTable)
816
+ .where(eq(deviceSettingsTable.deviceId, this.#deviceId))
817
+ .get()
818
+ if (typeof row?.isArchiveDevice === 'boolean') {
819
+ return row.isArchiveDevice
820
+ } else {
821
+ return true
822
+ }
823
+ }
824
+
795
825
  /**
796
826
  * @returns {InviteApi}
797
827
  */
@@ -917,15 +947,8 @@ export class MapeoManager extends TypedEmitter {
917
947
  * @returns {PublicPeerInfo[]}
918
948
  */
919
949
  function omitPeerProtomux(peers) {
920
- return peers.map(
921
- ({
922
- // @ts-ignore
923
- // eslint-disable-next-line no-unused-vars
924
- protomux,
925
- ...publicPeerInfo
926
- }) => {
927
- return publicPeerInfo
928
- }
950
+ return peers.map((peer) =>
951
+ 'protomux' in peer ? omit(peer, ['protomux']) : peer
929
952
  )
930
953
  }
931
954
 
@@ -2,7 +2,6 @@ import path from 'path'
2
2
  import Database from 'better-sqlite3'
3
3
  import { decodeBlockPrefix, decode, parseVersionId } from '@comapeo/schema'
4
4
  import { drizzle } from 'drizzle-orm/better-sqlite3'
5
- import { migrate } from 'drizzle-orm/better-sqlite3/migrator'
6
5
  import { discoveryKey } from 'hypercore-crypto'
7
6
  import { TypedEmitter } from 'tiny-typed-emitter'
8
7
 
@@ -24,6 +23,7 @@ import {
24
23
  roleTable,
25
24
  iconTable,
26
25
  translationTable,
26
+ remoteDetectionAlertTable,
27
27
  } from './schema/project.js'
28
28
  import {
29
29
  CoreOwnership,
@@ -38,19 +38,26 @@ import {
38
38
  } from './roles.js'
39
39
  import {
40
40
  assert,
41
+ ExhaustivenessError,
41
42
  getDeviceId,
42
43
  projectKeyToId,
43
44
  projectKeyToPublicId,
44
45
  valueOf,
45
46
  } from './utils.js'
47
+ import { migrate } from './lib/drizzle-helpers.js'
48
+ import { omit } from './lib/omit.js'
46
49
  import { MemberApi } from './member-api.js'
47
- import { SyncApi, kHandleDiscoveryKey } from './sync/sync-api.js'
50
+ import {
51
+ SyncApi,
52
+ kHandleDiscoveryKey,
53
+ kWaitForInitialSyncWithPeer,
54
+ } from './sync/sync-api.js'
48
55
  import { Logger } from './logger.js'
49
56
  import { IconApi } from './icon-api.js'
50
57
  import { readConfig } from './config-import.js'
51
58
  import TranslationApi from './translation-api.js'
52
59
  /** @import { ProjectSettingsValue } from '@comapeo/schema' */
53
- /** @import { CoreStorage, KeyPair, Namespace } from './types.js' */
60
+ /** @import { CoreStorage, KeyPair, Namespace, ReplicationStream } from './types.js' */
54
61
 
55
62
  /** @typedef {Omit<ProjectSettingsValue, 'schemaName'>} EditableProjectSettings */
56
63
  /** @typedef {ProjectSettingsValue['configMetadata']} ConfigMetadata */
@@ -65,6 +72,8 @@ export const kProjectReplicate = Symbol('replicate project')
65
72
  export const kDataTypes = Symbol('dataTypes')
66
73
  export const kProjectLeave = Symbol('leave project')
67
74
  export const kClearDataIfLeft = Symbol('clear data if left project')
75
+ export const kSetIsArchiveDevice = Symbol('set isArchiveDevice')
76
+ export const kIsArchiveDevice = Symbol('isArchiveDevice (temp - test only)')
68
77
 
69
78
  const EMPTY_PROJECT_SETTINGS = Object.freeze({})
70
79
 
@@ -72,8 +81,9 @@ const EMPTY_PROJECT_SETTINGS = Object.freeze({})
72
81
  * @extends {TypedEmitter<{ close: () => void }>}
73
82
  */
74
83
  export class MapeoProject extends TypedEmitter {
75
- #projectId
84
+ #projectKey
76
85
  #deviceId
86
+ #identityKeypair
77
87
  #coreManager
78
88
  #indexWriter
79
89
  #dataStores
@@ -90,6 +100,7 @@ export class MapeoProject extends TypedEmitter {
90
100
  #l
91
101
  /** @type {Boolean} this avoids loading multiple configs in parallel */
92
102
  #loadingConfig
103
+ #isArchiveDevice
93
104
 
94
105
  static EMPTY_PROJECT_SETTINGS = EMPTY_PROJECT_SETTINGS
95
106
 
@@ -106,6 +117,7 @@ export class MapeoProject extends TypedEmitter {
106
117
  * @param {CoreStorage} opts.coreStorage Folder to store all hypercore data
107
118
  * @param {(mediaType: 'blobs' | 'icons') => Promise<string>} opts.getMediaBaseUrl
108
119
  * @param {import('./local-peers.js').LocalPeers} opts.localPeers
120
+ * @param {boolean} opts.isArchiveDevice Whether this device is an archive device
109
121
  * @param {Logger} [opts.logger]
110
122
  *
111
123
  */
@@ -122,20 +134,58 @@ export class MapeoProject extends TypedEmitter {
122
134
  getMediaBaseUrl,
123
135
  localPeers,
124
136
  logger,
137
+ isArchiveDevice,
125
138
  }) {
126
139
  super()
127
140
 
128
141
  this.#l = Logger.create('project', logger)
129
142
  this.#deviceId = getDeviceId(keyManager)
130
- this.#projectId = projectKeyToId(projectKey)
143
+ this.#projectKey = projectKey
131
144
  this.#loadingConfig = false
145
+ this.#isArchiveDevice = isArchiveDevice
146
+
147
+ const getReplicationStream = this[kProjectReplicate].bind(this, true)
132
148
 
133
149
  ///////// 1. Setup database
150
+
134
151
  this.#sqlite = new Database(dbPath)
135
152
  const db = drizzle(this.#sqlite)
136
- migrate(db, { migrationsFolder: projectMigrationsFolder })
153
+ const migrationResult = migrate(db, {
154
+ migrationsFolder: projectMigrationsFolder,
155
+ })
156
+ let reindex
157
+ switch (migrationResult) {
158
+ case 'initialized database':
159
+ case 'no migration':
160
+ reindex = false
161
+ break
162
+ case 'migrated':
163
+ reindex = true
164
+ break
165
+ default:
166
+ throw new ExhaustivenessError(migrationResult)
167
+ }
137
168
 
138
- ///////// 2. Setup random-access-storage functions
169
+ const indexedTables = [
170
+ observationTable,
171
+ trackTable,
172
+ presetTable,
173
+ fieldTable,
174
+ coreOwnershipTable,
175
+ roleTable,
176
+ deviceInfoTable,
177
+ iconTable,
178
+ translationTable,
179
+ remoteDetectionAlertTable,
180
+ ]
181
+
182
+ ///////// 2. Wipe data if we need to re-index
183
+
184
+ if (reindex) {
185
+ for (const table of indexedTables) db.delete(table).run()
186
+ }
187
+
188
+ ///////// 3. Setup random-access-storage functions
139
189
 
140
190
  /** @type {ConstructorParameters<typeof CoreManager>[0]['storage']} */
141
191
  const coreManagerStorage = (name) =>
@@ -145,7 +195,7 @@ export class MapeoProject extends TypedEmitter {
145
195
  const indexerStorage = (name) =>
146
196
  coreStorage(path.join(INDEXER_STORAGE_FOLDER_NAME, name))
147
197
 
148
- ///////// 3. Create instances
198
+ ///////// 4. Create instances
149
199
 
150
200
  this.#coreManager = new CoreManager({
151
201
  projectSecretKey,
@@ -158,17 +208,7 @@ export class MapeoProject extends TypedEmitter {
158
208
  })
159
209
 
160
210
  this.#indexWriter = new IndexWriter({
161
- tables: [
162
- observationTable,
163
- trackTable,
164
- presetTable,
165
- fieldTable,
166
- coreOwnershipTable,
167
- roleTable,
168
- deviceInfoTable,
169
- iconTable,
170
- translationTable,
171
- ],
211
+ tables: indexedTables,
172
212
  sqlite: this.#sqlite,
173
213
  getWinner,
174
214
  mapDoc: (doc, version) => {
@@ -190,6 +230,7 @@ export class MapeoProject extends TypedEmitter {
190
230
  namespace: 'auth',
191
231
  batch: (entries) => this.#indexWriter.batch(entries),
192
232
  storage: indexerStorage,
233
+ reindex,
193
234
  }),
194
235
  config: new DataStore({
195
236
  coreManager: this.#coreManager,
@@ -200,12 +241,14 @@ export class MapeoProject extends TypedEmitter {
200
241
  sharedIndexWriter,
201
242
  }),
202
243
  storage: indexerStorage,
244
+ reindex,
203
245
  }),
204
246
  data: new DataStore({
205
247
  coreManager: this.#coreManager,
206
248
  namespace: 'data',
207
249
  batch: (entries) => this.#indexWriter.batch(entries),
208
250
  storage: indexerStorage,
251
+ reindex,
209
252
  }),
210
253
  }
211
254
 
@@ -224,6 +267,12 @@ export class MapeoProject extends TypedEmitter {
224
267
  db,
225
268
  getTranslations,
226
269
  }),
270
+ remoteDetectionAlert: new DataType({
271
+ dataStore: this.#dataStores.data,
272
+ table: remoteDetectionAlertTable,
273
+ db,
274
+ getTranslations,
275
+ }),
227
276
  preset: new DataType({
228
277
  dataStore: this.#dataStores.config,
229
278
  table: presetTable,
@@ -275,7 +324,7 @@ export class MapeoProject extends TypedEmitter {
275
324
  },
276
325
  }),
277
326
  }
278
- const identityKeypair = keyManager.getIdentityKeypair()
327
+ this.#identityKeypair = keyManager.getIdentityKeypair()
279
328
  const coreKeypairs = getCoreKeypairs({
280
329
  projectKey,
281
330
  projectSecretKey,
@@ -284,14 +333,14 @@ export class MapeoProject extends TypedEmitter {
284
333
  this.#coreOwnership = new CoreOwnership({
285
334
  dataType: this.#dataTypes.coreOwnership,
286
335
  coreKeypairs,
287
- identityKeypair,
336
+ identityKeypair: this.#identityKeypair,
288
337
  })
289
338
  this.#roles = new Roles({
290
339
  dataType: this.#dataTypes.role,
291
340
  coreOwnership: this.#coreOwnership,
292
341
  coreManager: this.#coreManager,
293
342
  projectKey: projectKey,
294
- deviceKey: keyManager.getIdentityKeypair().publicKey,
343
+ deviceKey: this.#identityKeypair.publicKey,
295
344
  })
296
345
 
297
346
  this.#memberApi = new MemberApi({
@@ -299,16 +348,18 @@ export class MapeoProject extends TypedEmitter {
299
348
  roles: this.#roles,
300
349
  coreOwnership: this.#coreOwnership,
301
350
  encryptionKeys,
351
+ getProjectName: this.#getProjectName.bind(this),
302
352
  projectKey,
303
353
  rpc: localPeers,
354
+ getReplicationStream,
355
+ waitForInitialSyncWithPeer: (deviceId, abortSignal) =>
356
+ this.$sync[kWaitForInitialSyncWithPeer](deviceId, abortSignal),
304
357
  dataTypes: {
305
358
  deviceInfo: this.#dataTypes.deviceInfo,
306
359
  project: this.#dataTypes.projectSettings,
307
360
  },
308
361
  })
309
362
 
310
- const projectPublicId = projectKeyToPublicId(projectKey)
311
-
312
363
  this.#blobStore = new BlobStore({
313
364
  coreManager: this.#coreManager,
314
365
  })
@@ -320,7 +371,7 @@ export class MapeoProject extends TypedEmitter {
320
371
  if (!base.endsWith('/')) {
321
372
  base += '/'
322
373
  }
323
- return base + projectPublicId
374
+ return base + this.#projectPublicId
324
375
  },
325
376
  })
326
377
 
@@ -332,7 +383,7 @@ export class MapeoProject extends TypedEmitter {
332
383
  if (!base.endsWith('/')) {
333
384
  base += '/'
334
385
  }
335
- return base + projectPublicId
386
+ return base + this.#projectPublicId
336
387
  },
337
388
  })
338
389
 
@@ -340,14 +391,33 @@ export class MapeoProject extends TypedEmitter {
340
391
  coreManager: this.#coreManager,
341
392
  coreOwnership: this.#coreOwnership,
342
393
  roles: this.#roles,
394
+ blobDownloadFilter: null,
343
395
  logger: this.#l,
396
+ getServerWebsocketUrls: async () => {
397
+ const members = await this.#memberApi.getMany()
398
+ /** @type {string[]} */
399
+ const serverWebsocketUrls = []
400
+ for (const member of members) {
401
+ if (
402
+ member.deviceType === 'selfHostedServer' &&
403
+ member.selfHostedServerDetails
404
+ ) {
405
+ const { baseUrl } = member.selfHostedServerDetails
406
+ const wsUrl = new URL(`/sync/${this.#projectPublicId}`, baseUrl)
407
+ wsUrl.protocol = wsUrl.protocol === 'http:' ? 'ws:' : 'wss:'
408
+ serverWebsocketUrls.push(wsUrl.href)
409
+ }
410
+ }
411
+ return serverWebsocketUrls
412
+ },
413
+ getReplicationStream,
344
414
  })
345
415
 
346
416
  this.#translationApi = new TranslationApi({
347
417
  dataType: this.#dataTypes.translation,
348
418
  })
349
419
 
350
- ///////// 4. Replicate local peers automatically
420
+ ///////// 5. Replicate local peers automatically
351
421
 
352
422
  // Replicate already connected local peers
353
423
  for (const peer of localPeers.peers) {
@@ -415,6 +485,14 @@ export class MapeoProject extends TypedEmitter {
415
485
  return this.#deviceId
416
486
  }
417
487
 
488
+ get #projectId() {
489
+ return projectKeyToId(this.#projectKey)
490
+ }
491
+
492
+ get #projectPublicId() {
493
+ return projectKeyToPublicId(this.#projectKey)
494
+ }
495
+
418
496
  /**
419
497
  * Resolves when hypercores have all loaded
420
498
  *
@@ -498,6 +576,10 @@ export class MapeoProject extends TypedEmitter {
498
576
  return this.#dataTypes.field
499
577
  }
500
578
 
579
+ get remoteDetectionAlert() {
580
+ return this.#dataTypes.remoteDetectionAlert
581
+ }
582
+
501
583
  get $member() {
502
584
  return this.#memberApi
503
585
  }
@@ -556,6 +638,13 @@ export class MapeoProject extends TypedEmitter {
556
638
  }
557
639
  }
558
640
 
641
+ /**
642
+ * @returns {Promise<undefined | string>}
643
+ */
644
+ async #getProjectName() {
645
+ return (await this.$getProjectSettings()).name
646
+ }
647
+
559
648
  async $getOwnRole() {
560
649
  return this.#roles.getRole(this.#deviceId)
561
650
  }
@@ -577,28 +666,38 @@ export class MapeoProject extends TypedEmitter {
577
666
  /**
578
667
  * Replicate a project to a @hyperswarm/secret-stream. Invites will not
579
668
  * function because the RPC channel is not connected for project replication,
580
- * and only this project will replicate (to replicate multiple projects you
581
- * need to replicate the manager instance via manager[kManagerReplicate])
669
+ * and only this project will replicate.
582
670
  *
583
- * @param {Parameters<import('hypercore')['replicate']>[0]} stream A duplex stream, a @hyperswarm/secret-stream, or a Protomux instance
671
+ * @param {(
672
+ * boolean |
673
+ * import('stream').Duplex |
674
+ * import('streamx').Duplex
675
+ * )} isInitiatorOrStream
676
+ * @returns {ReplicationStream}
584
677
  */
585
- [kProjectReplicate](stream) {
586
- // @ts-expect-error - hypercore types need updating
587
- const replicationStream = this.#coreManager.creatorCore.replicate(stream, {
588
- // @ts-ignore - hypercore types do not currently include this option
589
- ondiscoverykey: async (discoveryKey) => {
590
- const protomux =
591
- /** @type {import('protomux')<import('@hyperswarm/secret-stream')>} */ (
592
- replicationStream.noiseStream.userData
593
- )
594
- this.#syncApi[kHandleDiscoveryKey](discoveryKey, protomux)
595
- },
596
- })
678
+ [kProjectReplicate](isInitiatorOrStream) {
679
+ const replicationStream = this.#coreManager.creatorCore.replicate(
680
+ isInitiatorOrStream,
681
+ /**
682
+ * Hypercore types need updating.
683
+ * @type {any}
684
+ */ ({
685
+ keyPair: this.#identityKeypair,
686
+ /** @param {Buffer} discoveryKey */
687
+ ondiscoverykey: async (discoveryKey) => {
688
+ const protomux =
689
+ /** @type {import('protomux')<import('@hyperswarm/secret-stream')>} */ (
690
+ replicationStream.noiseStream.userData
691
+ )
692
+ this.#syncApi[kHandleDiscoveryKey](discoveryKey, protomux)
693
+ },
694
+ })
695
+ )
597
696
  return replicationStream
598
697
  }
599
698
 
600
699
  /**
601
- * @param {Pick<import('@comapeo/schema').DeviceInfoValue, 'name' | 'deviceType'>} value
700
+ * @param {Pick<import('@comapeo/schema').DeviceInfoValue, 'name' | 'deviceType' | 'selfHostedServerDetails'>} value
602
701
  * @returns {Promise<import('@comapeo/schema').DeviceInfo>}
603
702
  */
604
703
  async [kSetOwnDeviceInfo](value) {
@@ -611,6 +710,7 @@ export class MapeoProject extends TypedEmitter {
611
710
  const doc = {
612
711
  name: value.name,
613
712
  deviceType: value.deviceType,
713
+ selfHostedServerDetails: value.selfHostedServerDetails,
614
714
  schemaName: /** @type {const} */ ('deviceInfo'),
615
715
  }
616
716
 
@@ -624,6 +724,17 @@ export class MapeoProject extends TypedEmitter {
624
724
  return deviceInfo.update(existingDoc.versionId, doc)
625
725
  }
626
726
 
727
+ /** @param {boolean} isArchiveDevice */
728
+ async [kSetIsArchiveDevice](isArchiveDevice) {
729
+ this.#isArchiveDevice = isArchiveDevice
730
+ // TODO: call this.#syncApi[kSetBlobDownloadFilter]()
731
+ }
732
+
733
+ /** @returns {boolean} */
734
+ get [kIsArchiveDevice]() {
735
+ return this.#isArchiveDevice
736
+ }
737
+
627
738
  /**
628
739
  * @returns {import('./icon-api.js').IconApi}
629
740
  */
@@ -887,9 +998,7 @@ export class MapeoProject extends TypedEmitter {
887
998
  * @returns {EditableProjectSettings}
888
999
  */
889
1000
  function extractEditableProjectSettings(projectDoc) {
890
- // eslint-disable-next-line no-unused-vars
891
- const { schemaName, ...result } = valueOf(projectDoc)
892
- return result
1001
+ return omit(valueOf(projectDoc), ['schemaName'])
893
1002
  }
894
1003
 
895
1004
  /**