@comapeo/core 2.1.0 → 2.2.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 (73) hide show
  1. package/dist/blob-store/downloader.d.ts +43 -0
  2. package/dist/blob-store/downloader.d.ts.map +1 -0
  3. package/dist/blob-store/entries-stream.d.ts +13 -0
  4. package/dist/blob-store/entries-stream.d.ts.map +1 -0
  5. package/dist/blob-store/hyperdrive-index.d.ts +20 -0
  6. package/dist/blob-store/hyperdrive-index.d.ts.map +1 -0
  7. package/dist/blob-store/index.d.ts +29 -21
  8. package/dist/blob-store/index.d.ts.map +1 -1
  9. package/dist/blob-store/utils.d.ts +27 -0
  10. package/dist/blob-store/utils.d.ts.map +1 -0
  11. package/dist/core-manager/index.d.ts +1 -1
  12. package/dist/core-manager/index.d.ts.map +1 -1
  13. package/dist/core-ownership.d.ts.map +1 -1
  14. package/dist/datastore/index.d.ts +1 -1
  15. package/dist/datastore/index.d.ts.map +1 -1
  16. package/dist/datatype/index.d.ts +5 -1
  17. package/dist/discovery/local-discovery.d.ts.map +1 -1
  18. package/dist/errors.d.ts +6 -1
  19. package/dist/errors.d.ts.map +1 -1
  20. package/dist/fastify-plugins/blobs.d.ts.map +1 -1
  21. package/dist/fastify-plugins/maps.d.ts.map +1 -1
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/lib/error.d.ts +14 -0
  24. package/dist/lib/error.d.ts.map +1 -1
  25. package/dist/mapeo-manager.d.ts.map +1 -1
  26. package/dist/mapeo-project.d.ts +17 -17
  27. package/dist/mapeo-project.d.ts.map +1 -1
  28. package/dist/member-api.d.ts +4 -0
  29. package/dist/member-api.d.ts.map +1 -1
  30. package/dist/roles.d.ts.map +1 -1
  31. package/dist/schema/project.d.ts +2 -2
  32. package/dist/sync/core-sync-state.d.ts +20 -15
  33. package/dist/sync/core-sync-state.d.ts.map +1 -1
  34. package/dist/sync/namespace-sync-state.d.ts +13 -1
  35. package/dist/sync/namespace-sync-state.d.ts.map +1 -1
  36. package/dist/sync/peer-sync-controller.d.ts +1 -1
  37. package/dist/sync/sync-api.d.ts +22 -3
  38. package/dist/sync/sync-api.d.ts.map +1 -1
  39. package/dist/sync/sync-state.d.ts +12 -0
  40. package/dist/sync/sync-state.d.ts.map +1 -1
  41. package/dist/translation-api.d.ts +2 -2
  42. package/dist/translation-api.d.ts.map +1 -1
  43. package/dist/types.d.ts +7 -0
  44. package/dist/types.d.ts.map +1 -1
  45. package/package.json +6 -1
  46. package/src/blob-store/downloader.js +130 -0
  47. package/src/blob-store/entries-stream.js +81 -0
  48. package/src/blob-store/hyperdrive-index.js +122 -0
  49. package/src/blob-store/index.js +56 -115
  50. package/src/blob-store/utils.js +54 -0
  51. package/src/core-manager/index.js +2 -1
  52. package/src/core-ownership.js +2 -4
  53. package/src/datastore/index.js +4 -3
  54. package/src/datatype/index.d.ts +5 -1
  55. package/src/datatype/index.js +22 -9
  56. package/src/discovery/local-discovery.js +2 -1
  57. package/src/errors.js +11 -2
  58. package/src/fastify-plugins/blobs.js +16 -1
  59. package/src/fastify-plugins/maps.js +2 -1
  60. package/src/lib/error.js +24 -0
  61. package/src/mapeo-manager.js +3 -2
  62. package/src/mapeo-project.js +89 -19
  63. package/src/member-api.js +68 -26
  64. package/src/roles.js +38 -32
  65. package/src/sync/core-sync-state.js +39 -23
  66. package/src/sync/namespace-sync-state.js +22 -0
  67. package/src/sync/sync-api.js +30 -4
  68. package/src/sync/sync-state.js +18 -0
  69. package/src/translation-api.js +5 -9
  70. package/src/types.ts +8 -0
  71. package/dist/blob-store/live-download.d.ts +0 -107
  72. package/dist/blob-store/live-download.d.ts.map +0 -1
  73. package/src/blob-store/live-download.js +0 -373
package/src/lib/error.js CHANGED
@@ -21,6 +21,30 @@ export class ErrorWithCode extends Error {
21
21
  }
22
22
  }
23
23
 
24
+ /**
25
+ * If the argument is an `Error` instance, return its `code` property if it is a string.
26
+ * Otherwise, returns `undefined`.
27
+ *
28
+ * @param {unknown} maybeError
29
+ * @returns {undefined | string}
30
+ * @example
31
+ * try {
32
+ * // do something
33
+ * } catch (err) {
34
+ * console.error(getErrorCode(err))
35
+ * }
36
+ */
37
+ export function getErrorCode(maybeError) {
38
+ if (
39
+ maybeError instanceof Error &&
40
+ 'code' in maybeError &&
41
+ typeof maybeError.code === 'string'
42
+ ) {
43
+ return maybeError.code
44
+ }
45
+ return undefined
46
+ }
47
+
24
48
  /**
25
49
  * Get the error message from an object if possible.
26
50
  * Otherwise, stringify the argument.
@@ -51,6 +51,7 @@ import {
51
51
  kRequestFullStop,
52
52
  kRescindFullStopRequest,
53
53
  } from './sync/sync-api.js'
54
+ import { NotFoundError } from './errors.js'
54
55
  /** @import { ProjectSettingsValue as ProjectValue } from '@comapeo/schema' */
55
56
  /** @import NoiseSecretStream from '@hyperswarm/secret-stream' */
56
57
  /** @import { SetNonNullable } from 'type-fest' */
@@ -456,7 +457,7 @@ export class MapeoManager extends TypedEmitter {
456
457
  .get()
457
458
 
458
459
  if (!projectKeysTableResult) {
459
- throw new Error(`NotFound: project ID ${projectPublicId} not found`)
460
+ throw new NotFoundError(`Project ID ${projectPublicId} not found`)
460
461
  }
461
462
 
462
463
  const { projectId } = projectKeysTableResult
@@ -896,7 +897,7 @@ export class MapeoManager extends TypedEmitter {
896
897
  .get()
897
898
 
898
899
  if (!row) {
899
- throw new Error(`NotFound: project ID ${projectPublicId} not found`)
900
+ throw new NotFoundError(`Project ID ${projectPublicId} not found`)
900
901
  }
901
902
 
902
903
  const { keysCipher, projectId, projectInfo } = row
@@ -49,15 +49,20 @@ import { omit } from './lib/omit.js'
49
49
  import { MemberApi } from './member-api.js'
50
50
  import {
51
51
  SyncApi,
52
+ kAddBlobWantRange,
53
+ kClearBlobWantRanges,
52
54
  kHandleDiscoveryKey,
55
+ kSetBlobDownloadFilter,
53
56
  kWaitForInitialSyncWithPeer,
54
57
  } from './sync/sync-api.js'
55
58
  import { Logger } from './logger.js'
56
59
  import { IconApi } from './icon-api.js'
57
60
  import { readConfig } from './config-import.js'
58
61
  import TranslationApi from './translation-api.js'
62
+ import { NotFoundError, nullIfNotFound } from './errors.js'
63
+ import { getErrorCode, getErrorMessage } from './lib/error.js'
59
64
  /** @import { ProjectSettingsValue } from '@comapeo/schema' */
60
- /** @import { CoreStorage, KeyPair, Namespace, ReplicationStream } from './types.js' */
65
+ /** @import { CoreStorage, BlobFilter, BlobStoreEntriesStream, KeyPair, Namespace, ReplicationStream } from './types.js' */
61
66
 
62
67
  /** @typedef {Omit<ProjectSettingsValue, 'schemaName'>} EditableProjectSettings */
63
68
  /** @typedef {ProjectSettingsValue['configMetadata']} ConfigMetadata */
@@ -77,6 +82,13 @@ export const kIsArchiveDevice = Symbol('isArchiveDevice (temp - test only)')
77
82
 
78
83
  const EMPTY_PROJECT_SETTINGS = Object.freeze({})
79
84
 
85
+ /** @type {import('./types.js').BlobFilter} */
86
+ const NON_ARCHIVE_DEVICE_DOWNLOAD_FILTER = {
87
+ photo: ['preview', 'thumbnail'],
88
+ // Don't download any audio of video files, since previews and
89
+ // thumbnails aren't supported yet.
90
+ }
91
+
80
92
  /**
81
93
  * @extends {TypedEmitter<{ close: () => void }>}
82
94
  */
@@ -146,6 +158,8 @@ export class MapeoProject extends TypedEmitter {
146
158
 
147
159
  const getReplicationStream = this[kProjectReplicate].bind(this, true)
148
160
 
161
+ const blobDownloadFilter = getBlobDownloadFilter(isArchiveDevice)
162
+
149
163
  ///////// 1. Setup database
150
164
 
151
165
  this.#sqlite = new Database(dbPath)
@@ -362,6 +376,13 @@ export class MapeoProject extends TypedEmitter {
362
376
 
363
377
  this.#blobStore = new BlobStore({
364
378
  coreManager: this.#coreManager,
379
+ downloadFilter: blobDownloadFilter,
380
+ })
381
+
382
+ this.#blobStore.on('error', (err) => {
383
+ // TODO: Handle this error in some way - this error will come from an
384
+ // unexpected error with background blob downloads
385
+ console.error('BlobStore error', err)
365
386
  })
366
387
 
367
388
  this.$blobs = new BlobApi({
@@ -391,7 +412,7 @@ export class MapeoProject extends TypedEmitter {
391
412
  coreManager: this.#coreManager,
392
413
  coreOwnership: this.#coreOwnership,
393
414
  roles: this.#roles,
394
- blobDownloadFilter: null,
415
+ blobDownloadFilter,
395
416
  logger: this.#l,
396
417
  getServerWebsocketUrls: async () => {
397
418
  const members = await this.#memberApi.getMany()
@@ -413,6 +434,48 @@ export class MapeoProject extends TypedEmitter {
413
434
  getReplicationStream,
414
435
  })
415
436
 
437
+ /** @type {Map<string, BlobStoreEntriesStream>} */
438
+ const entriesReadStreams = new Map()
439
+
440
+ this.#coreManager.on('peer-download-intent', async (filter, peerId) => {
441
+ entriesReadStreams.get(peerId)?.destroy()
442
+
443
+ const entriesReadStream = this.#blobStore.createEntriesReadStream({
444
+ live: true,
445
+ filter,
446
+ })
447
+ entriesReadStreams.set(peerId, entriesReadStream)
448
+
449
+ entriesReadStream.once('close', () => {
450
+ if (entriesReadStreams.get(peerId) === entriesReadStream) {
451
+ entriesReadStreams.delete(peerId)
452
+ }
453
+ })
454
+
455
+ this.#syncApi[kClearBlobWantRanges](peerId)
456
+
457
+ try {
458
+ for await (const entry of entriesReadStream) {
459
+ const { blockOffset, blockLength } = entry.value.blob
460
+ this.#syncApi[kAddBlobWantRange](peerId, blockOffset, blockLength)
461
+ }
462
+ } catch (err) {
463
+ if (getErrorCode(err) === 'ERR_STREAM_PREMATURE_CLOSE') return
464
+ this.#l.log(
465
+ 'Error getting blob entries stream for peer %h: %s',
466
+ peerId,
467
+ getErrorMessage(err)
468
+ )
469
+ }
470
+ })
471
+
472
+ this.#coreManager.creatorCore.on('peer-remove', (peer) => {
473
+ const peerKey = peer.protomux.stream.remotePublicKey
474
+ const peerId = peerKey.toString('hex')
475
+ entriesReadStreams.get(peerId)?.destroy()
476
+ entriesReadStreams.delete(peerId)
477
+ })
478
+
416
479
  this.#translationApi = new TranslationApi({
417
480
  dataType: this.#dataTypes.translation,
418
481
  })
@@ -506,6 +569,7 @@ export class MapeoProject extends TypedEmitter {
506
569
  */
507
570
  async close() {
508
571
  this.#l.log('closing project %h', this.#projectId)
572
+ this.#blobStore.close()
509
573
  const dataStorePromises = []
510
574
  for (const dataStore of Object.values(this.#dataStores)) {
511
575
  dataStorePromises.push(dataStore.close())
@@ -599,14 +663,9 @@ export class MapeoProject extends TypedEmitter {
599
663
  async $setProjectSettings(settings) {
600
664
  const { projectSettings } = this.#dataTypes
601
665
 
602
- // We only want to catch the error to the getByDocId call
603
- // Using try/catch for this is a little verbose when dealing with TS types
604
666
  const existing = await projectSettings
605
667
  .getByDocId(this.#projectId)
606
- .catch(() => {
607
- // project does not exist so return null
608
- return null
609
- })
668
+ .catch(nullIfNotFound)
610
669
 
611
670
  if (existing) {
612
671
  return extractEditableProjectSettings(
@@ -659,7 +718,7 @@ export class MapeoProject extends TypedEmitter {
659
718
  const coreId = this.#coreManager
660
719
  .getCoreByDiscoveryKey(coreDiscoveryKey)
661
720
  ?.key.toString('hex')
662
- if (!coreId) throw new Error('NotFound')
721
+ if (!coreId) throw new NotFoundError()
663
722
  return this.#coreOwnership.getOwner(coreId)
664
723
  }
665
724
 
@@ -714,20 +773,23 @@ export class MapeoProject extends TypedEmitter {
714
773
  schemaName: /** @type {const} */ ('deviceInfo'),
715
774
  }
716
775
 
717
- let existingDoc
718
- try {
719
- existingDoc = await deviceInfo.getByDocId(configCoreId)
720
- } catch (err) {
776
+ const existingDoc = await deviceInfo
777
+ .getByDocId(configCoreId)
778
+ .catch(nullIfNotFound)
779
+ if (existingDoc) {
780
+ return await deviceInfo.update(existingDoc.versionId, doc)
781
+ } else {
721
782
  return await deviceInfo[kCreateWithDocId](configCoreId, doc)
722
783
  }
723
-
724
- return deviceInfo.update(existingDoc.versionId, doc)
725
784
  }
726
785
 
727
786
  /** @param {boolean} isArchiveDevice */
728
787
  async [kSetIsArchiveDevice](isArchiveDevice) {
788
+ if (this.#isArchiveDevice === isArchiveDevice) return
789
+ const blobDownloadFilter = getBlobDownloadFilter(isArchiveDevice)
790
+ this.#blobStore.setDownloadFilter(blobDownloadFilter)
791
+ this.#syncApi[kSetBlobDownloadFilter](blobDownloadFilter)
729
792
  this.#isArchiveDevice = isArchiveDevice
730
- // TODO: call this.#syncApi[kSetBlobDownloadFilter]()
731
793
  }
732
794
 
733
795
  /** @returns {boolean} */
@@ -879,7 +941,7 @@ export class MapeoProject extends TypedEmitter {
879
941
  const fieldRefs = fieldNames.map((fieldName) => {
880
942
  const fieldRef = fieldNameToRef.get(fieldName)
881
943
  if (!fieldRef) {
882
- throw new Error(
944
+ throw new NotFoundError(
883
945
  `field ${fieldName} not found (referenced by preset ${value.name})})`
884
946
  )
885
947
  }
@@ -891,7 +953,7 @@ export class MapeoProject extends TypedEmitter {
891
953
  }
892
954
  const iconRef = iconNameToRef.get(iconName)
893
955
  if (!iconRef) {
894
- throw new Error(
956
+ throw new NotFoundError(
895
957
  `icon ${iconName} not found (referenced by preset ${value.name})`
896
958
  )
897
959
  }
@@ -938,7 +1000,7 @@ export class MapeoProject extends TypedEmitter {
938
1000
  })
939
1001
  )
940
1002
  } else {
941
- throw new Error(
1003
+ throw new NotFoundError(
942
1004
  `docRef for ${value.docRefType} with name ${name} not found`
943
1005
  )
944
1006
  }
@@ -993,6 +1055,14 @@ export class MapeoProject extends TypedEmitter {
993
1055
  }
994
1056
  }
995
1057
 
1058
+ /**
1059
+ * @param {boolean} isArchiveDevice
1060
+ * @returns {null | BlobFilter}
1061
+ */
1062
+ function getBlobDownloadFilter(isArchiveDevice) {
1063
+ return isArchiveDevice ? null : NON_ARCHIVE_DEVICE_DOWNLOAD_FILTER
1064
+ }
1065
+
996
1066
  /**
997
1067
  * @param {import("@comapeo/schema").ProjectSettings & { forks: string[] }} projectDoc
998
1068
  * @returns {EditableProjectSettings}
package/src/member-api.js CHANGED
@@ -277,6 +277,10 @@ export class MemberApi extends TypedEmitter {
277
277
  * peer. For example, the project must have a name.
278
278
  * - `NETWORK_ERROR`: there was an issue connecting to the server. Is the
279
279
  * device online? Is the server online?
280
+ * - `SERVER_HAS_TOO_MANY_PROJECTS`: the server limits the number of projects
281
+ * it can have, and it's at the limit.
282
+ * - `PROJECT_NOT_IN_SERVER_ALLOWLIST`: the server only allows specific
283
+ * projects to be added and ours wasn't one of them.
280
284
  * - `INVALID_SERVER_RESPONSE`: we connected to the server but it returned
281
285
  * an unexpected response. Is the server running a compatible version of
282
286
  * CoMapeo Cloud?
@@ -351,32 +355,7 @@ export class MemberApi extends TypedEmitter {
351
355
  )
352
356
  }
353
357
 
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
- }
358
+ return await parseAddServerResponse(response)
380
359
  }
381
360
 
382
361
  /**
@@ -575,3 +554,66 @@ function isValidServerBaseUrl(
575
554
  function encodeBufferForServer(buffer) {
576
555
  return buffer ? b4a.toString(buffer, 'hex') : undefined
577
556
  }
557
+
558
+ /**
559
+ * @param {Response} response
560
+ * @returns {Promise<{ serverDeviceId: string }>}
561
+ */
562
+ async function parseAddServerResponse(response) {
563
+ if (response.status === 200) {
564
+ try {
565
+ const responseBody = await response.json()
566
+ assert(
567
+ responseBody &&
568
+ typeof responseBody === 'object' &&
569
+ 'data' in responseBody &&
570
+ responseBody.data &&
571
+ typeof responseBody.data === 'object' &&
572
+ 'deviceId' in responseBody.data &&
573
+ typeof responseBody.data.deviceId === 'string',
574
+ 'Response body is valid'
575
+ )
576
+ return { serverDeviceId: responseBody.data.deviceId }
577
+ } catch (err) {
578
+ throw new ErrorWithCode(
579
+ 'INVALID_SERVER_RESPONSE',
580
+ "Failed to add server peer because we couldn't parse the response"
581
+ )
582
+ }
583
+ }
584
+
585
+ let responseBody
586
+ try {
587
+ responseBody = await response.json()
588
+ } catch (_) {
589
+ responseBody = null
590
+ }
591
+ if (
592
+ responseBody &&
593
+ typeof responseBody === 'object' &&
594
+ 'error' in responseBody &&
595
+ responseBody.error &&
596
+ typeof responseBody.error === 'object' &&
597
+ 'code' in responseBody.error
598
+ ) {
599
+ switch (responseBody.error.code) {
600
+ case 'PROJECT_NOT_IN_ALLOWLIST':
601
+ throw new ErrorWithCode(
602
+ 'PROJECT_NOT_IN_SERVER_ALLOWLIST',
603
+ "The server only allows specific projects to be added, and this isn't one of them"
604
+ )
605
+ case 'TOO_MANY_PROJECTS':
606
+ throw new ErrorWithCode(
607
+ 'SERVER_HAS_TOO_MANY_PROJECTS',
608
+ "The server limits the number of projects it can have and it's at the limit"
609
+ )
610
+ default:
611
+ break
612
+ }
613
+ }
614
+
615
+ throw new ErrorWithCode(
616
+ 'INVALID_SERVER_RESPONSE',
617
+ `Failed to add server peer due to HTTP status code ${response.status}`
618
+ )
619
+ }
package/src/roles.js CHANGED
@@ -2,6 +2,7 @@ import { currentSchemaVersions } from '@comapeo/schema'
2
2
  import mapObject from 'map-obj'
3
3
  import { kCreateWithDocId, kDataStore } from './datatype/index.js'
4
4
  import { assert, setHas } from './utils.js'
5
+ import { nullIfNotFound } from './errors.js'
5
6
  import { TypedEmitter } from 'tiny-typed-emitter'
6
7
  /** @import { Namespace } from './types.js' */
7
8
 
@@ -98,6 +99,33 @@ export const CREATOR_ROLE = {
98
99
  },
99
100
  }
100
101
 
102
+ /**
103
+ * @type {Role<typeof BLOCKED_ROLE_ID>}
104
+ */
105
+ const BLOCKED_ROLE = {
106
+ roleId: BLOCKED_ROLE_ID,
107
+ name: 'Blocked',
108
+ docs: mapObject(currentSchemaVersions, (key) => {
109
+ return [
110
+ key,
111
+ {
112
+ readOwn: false,
113
+ writeOwn: false,
114
+ readOthers: false,
115
+ writeOthers: false,
116
+ },
117
+ ]
118
+ }),
119
+ roleAssignment: [],
120
+ sync: {
121
+ auth: 'blocked',
122
+ config: 'blocked',
123
+ data: 'blocked',
124
+ blobIndex: 'blocked',
125
+ blob: 'blocked',
126
+ },
127
+ }
128
+
101
129
  /**
102
130
  * This is the role assumed for a device when no role record can be found. This
103
131
  * can happen when an invited device did not manage to sync with the device that
@@ -166,29 +194,7 @@ export const ROLES = {
166
194
  blob: 'allowed',
167
195
  },
168
196
  },
169
- [BLOCKED_ROLE_ID]: {
170
- roleId: BLOCKED_ROLE_ID,
171
- name: 'Blocked',
172
- docs: mapObject(currentSchemaVersions, (key) => {
173
- return [
174
- key,
175
- {
176
- readOwn: false,
177
- writeOwn: false,
178
- readOthers: false,
179
- writeOthers: false,
180
- },
181
- ]
182
- }),
183
- roleAssignment: [],
184
- sync: {
185
- auth: 'blocked',
186
- config: 'blocked',
187
- data: 'blocked',
188
- blobIndex: 'blocked',
189
- blob: 'blocked',
190
- },
191
- },
197
+ [BLOCKED_ROLE_ID]: BLOCKED_ROLE,
192
198
  [LEFT_ROLE_ID]: {
193
199
  roleId: LEFT_ROLE_ID,
194
200
  name: 'Left',
@@ -264,12 +270,10 @@ export class Roles extends TypedEmitter {
264
270
  * @returns {Promise<Role>}
265
271
  */
266
272
  async getRole(deviceId) {
267
- /** @type {string} */
268
- let roleId
269
- try {
270
- const roleAssignment = await this.#dataType.getByDocId(deviceId)
271
- roleId = roleAssignment.roleId
272
- } catch (e) {
273
+ const roleRecord = await this.#dataType
274
+ .getByDocId(deviceId)
275
+ .catch(nullIfNotFound)
276
+ if (!roleRecord) {
273
277
  // The project creator will have the creator role
274
278
  const authCoreId = await this.#coreOwnership.getCoreId(deviceId, 'auth')
275
279
  if (authCoreId === this.#projectCreatorAuthCoreId) {
@@ -280,8 +284,10 @@ export class Roles extends TypedEmitter {
280
284
  return NO_ROLE
281
285
  }
282
286
  }
287
+
288
+ const { roleId } = roleRecord
283
289
  if (!isRoleId(roleId)) {
284
- return ROLES[BLOCKED_ROLE_ID]
290
+ return BLOCKED_ROLE
285
291
  }
286
292
  return ROLES[roleId]
287
293
  }
@@ -383,7 +389,7 @@ export class Roles extends TypedEmitter {
383
389
 
384
390
  const existingRoleDoc = await this.#dataType
385
391
  .getByDocId(deviceId)
386
- .catch(() => null)
392
+ .catch(nullIfNotFound)
387
393
 
388
394
  if (existingRoleDoc) {
389
395
  await this.#dataType.update(
@@ -403,7 +409,7 @@ export class Roles extends TypedEmitter {
403
409
  }
404
410
  }
405
411
 
406
- async #isProjectCreator() {
412
+ #isProjectCreator() {
407
413
  const ownAuthCoreId = this.#coreManager
408
414
  .getWriterCore('auth')
409
415
  .key.toString('hex')
@@ -182,13 +182,23 @@ export class CoreSyncState {
182
182
  * blocks/ranges that are added here
183
183
  *
184
184
  * @param {PeerId} peerId
185
- * @param {Array<{ start: number, length: number }>} ranges
185
+ * @param {number} start
186
+ * @param {number} length
187
+ * @returns {void}
186
188
  */
187
- setPeerWants(peerId, ranges) {
189
+ addWantRange(peerId, start, length) {
188
190
  const peerState = this.#getOrCreatePeerState(peerId)
189
- for (const { start, length } of ranges) {
190
- peerState.setWantRange({ start, length })
191
- }
191
+ peerState.addWantRange(start, length)
192
+ this.#update()
193
+ }
194
+
195
+ /**
196
+ * @param {PeerId} peerId
197
+ * @returns {void}
198
+ */
199
+ clearWantRanges(peerId) {
200
+ const peerState = this.#getOrCreatePeerState(peerId)
201
+ peerState.clearWantRanges()
192
202
  this.#update()
193
203
  }
194
204
 
@@ -291,14 +301,13 @@ export class PeerState {
291
301
  #preHaves = new RemoteBitfield()
292
302
  /** @type {HypercoreRemoteBitfield | undefined} */
293
303
  #haves
294
- /** @type {Bitfield} */
295
- #wants = new RemoteBitfield()
304
+ /**
305
+ * What blocks do we want? If `null`, we want everything.
306
+ * @type {null | Bitfield}
307
+ */
308
+ #wants = null
296
309
  /** @type {PeerNamespaceState['status']} */
297
310
  status = 'stopped'
298
- #wantAll
299
- constructor({ wantAll = true } = {}) {
300
- this.#wantAll = wantAll
301
- }
302
311
  get preHavesBitfield() {
303
312
  return this.#preHaves
304
313
  }
@@ -316,18 +325,27 @@ export class PeerState {
316
325
  this.#haves = bitfield
317
326
  }
318
327
  /**
319
- * Set a range of blocks that a peer wants. This is not part of the Hypercore
328
+ * Add a range of blocks that a peer wants. This is not part of the Hypercore
320
329
  * protocol, so we need our own extension messages that a peer can use to
321
330
  * inform us which blocks they are interested in. For most cores peers always
322
- * want all blocks, but for blob cores often peers only want preview or
331
+ * want all blocks, but for blob cores peers may only want preview or
323
332
  * thumbnail versions of media
324
333
  *
325
- * @param {{ start: number, length: number }} range
334
+ * @param {number} start
335
+ * @param {number} length
336
+ * @returns {void}
326
337
  */
327
- setWantRange({ start, length }) {
328
- this.#wantAll = false
338
+ addWantRange(start, length) {
339
+ this.#wants ??= new RemoteBitfield()
329
340
  this.#wants.setRange(start, length, true)
330
341
  }
342
+ /**
343
+ * Set the range of blocks that this peer wants to the empty set. In other
344
+ * words, this peer wants nothing from this core.
345
+ */
346
+ clearWantRanges() {
347
+ this.#wants = new RemoteBitfield()
348
+ }
331
349
  /**
332
350
  * Returns whether the peer has the block at `index`. If a pre-have bitfield
333
351
  * has been passed, this is used if no connected peer bitfield is available.
@@ -355,8 +373,7 @@ export class PeerState {
355
373
  * @param {number} index
356
374
  */
357
375
  want(index) {
358
- if (this.#wantAll) return true
359
- return this.#wants.get(index)
376
+ return this.#wants ? this.#wants.get(index) : true
360
377
  }
361
378
  /**
362
379
  * Return the "wants" for the 32 blocks from `index`, as a 32-bit integer
@@ -366,11 +383,10 @@ export class PeerState {
366
383
  * the 32 blocks from `index`
367
384
  */
368
385
  wantWord(index) {
369
- if (this.#wantAll) {
370
- // This is a 32-bit number with all bits set
371
- return 2 ** 32 - 1
372
- }
373
- return getBitfieldWord(this.#wants, index)
386
+ return this.#wants
387
+ ? getBitfieldWord(this.#wants, index)
388
+ : // This is a 32-bit number with all bits set
389
+ 2 ** 32 - 1
374
390
  }
375
391
  }
376
392
 
@@ -136,6 +136,28 @@ export class NamespaceSyncState {
136
136
  this.#getCoreState(coreDiscoveryId).insertPreHaves(peerId, start, bitfield)
137
137
  }
138
138
 
139
+ /**
140
+ * @param {string} peerId
141
+ * @param {number} start
142
+ * @param {number} length
143
+ * @returns {void}
144
+ */
145
+ addWantRange(peerId, start, length) {
146
+ for (const coreState of this.#coreStates.values()) {
147
+ coreState.addWantRange(peerId, start, length)
148
+ }
149
+ }
150
+
151
+ /**
152
+ * @param {string} peerId
153
+ * @returns {void}
154
+ */
155
+ clearWantRanges(peerId) {
156
+ for (const coreState of this.#coreStates.values()) {
157
+ coreState.clearWantRanges(peerId)
158
+ }
159
+ }
160
+
139
161
  /**
140
162
  * @param {string} discoveryId
141
163
  */