@comapeo/core 5.5.0 → 6.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/dist/blob-api.d.ts.map +1 -1
  2. package/dist/blob-store/downloader.d.ts.map +1 -1
  3. package/dist/blob-store/hyperdrive-index.d.ts.map +1 -1
  4. package/dist/blob-store/index.d.ts.map +1 -1
  5. package/dist/core-manager/bitfield-rle.d.ts.map +1 -1
  6. package/dist/core-manager/core-index.d.ts.map +1 -1
  7. package/dist/core-manager/index.d.ts.map +1 -1
  8. package/dist/core-ownership.d.ts.map +1 -1
  9. package/dist/datastore/index.d.ts.map +1 -1
  10. package/dist/datatype/index.d.ts +7 -0
  11. package/dist/datatype/index.d.ts.map +1 -1
  12. package/dist/discovery/local-discovery.d.ts.map +1 -1
  13. package/dist/errors.d.ts +437 -35
  14. package/dist/errors.d.ts.map +1 -1
  15. package/dist/fastify-plugins/blobs.d.ts.map +1 -1
  16. package/dist/fastify-plugins/icons.d.ts.map +1 -1
  17. package/dist/fastify-plugins/maps.d.ts.map +1 -1
  18. package/dist/generated/rpc.d.ts +1 -0
  19. package/dist/generated/rpc.d.ts.map +1 -1
  20. package/dist/icon-api.d.ts +0 -1
  21. package/dist/icon-api.d.ts.map +1 -1
  22. package/dist/import-categories.d.ts.map +1 -1
  23. package/dist/index-writer/index.d.ts.map +1 -1
  24. package/dist/index.d.ts +1 -0
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/intl/parse-bcp-47.d.ts.map +1 -1
  27. package/dist/invite/invite-api.d.ts.map +1 -1
  28. package/dist/lib/drizzle-helpers.d.ts.map +1 -1
  29. package/dist/lib/hypercore-helpers.d.ts.map +1 -1
  30. package/dist/lib/key-by.d.ts.map +1 -1
  31. package/dist/local-peers.d.ts +0 -14
  32. package/dist/local-peers.d.ts.map +1 -1
  33. package/dist/logger.d.ts.map +1 -1
  34. package/dist/mapeo-manager.d.ts +2 -1
  35. package/dist/mapeo-manager.d.ts.map +1 -1
  36. package/dist/mapeo-project.d.ts +1 -3
  37. package/dist/mapeo-project.d.ts.map +1 -1
  38. package/dist/member-api.d.ts +42 -7
  39. package/dist/member-api.d.ts.map +1 -1
  40. package/dist/roles.d.ts.map +1 -1
  41. package/dist/schema/json-schema-to-drizzle.d.ts.map +1 -1
  42. package/dist/schema.d.ts +2 -0
  43. package/dist/schema.d.ts.map +1 -0
  44. package/dist/sync/core-sync-state.d.ts.map +1 -1
  45. package/dist/sync/peer-sync-controller.d.ts.map +1 -1
  46. package/dist/sync/sync-api.d.ts.map +1 -1
  47. package/dist/utils.d.ts +8 -10
  48. package/dist/utils.d.ts.map +1 -1
  49. package/package.json +18 -2
  50. package/src/blob-api.js +24 -4
  51. package/src/blob-store/downloader.js +7 -6
  52. package/src/blob-store/entries-stream.js +1 -1
  53. package/src/blob-store/hyperdrive-index.js +3 -5
  54. package/src/blob-store/index.js +15 -20
  55. package/src/core-manager/bitfield-rle.js +2 -1
  56. package/src/core-manager/core-index.js +2 -1
  57. package/src/core-manager/index.js +9 -10
  58. package/src/core-ownership.js +7 -3
  59. package/src/datastore/index.js +13 -9
  60. package/src/datatype/index.js +28 -5
  61. package/src/discovery/local-discovery.js +8 -7
  62. package/src/errors.js +530 -62
  63. package/src/fastify-controller.js +3 -3
  64. package/src/fastify-plugins/blobs.js +21 -14
  65. package/src/fastify-plugins/icons.js +18 -9
  66. package/src/fastify-plugins/maps.js +6 -5
  67. package/src/generated/rpc.d.ts +1 -0
  68. package/src/generated/rpc.js +12 -1
  69. package/src/generated/rpc.ts +13 -0
  70. package/src/icon-api.js +15 -7
  71. package/src/import-categories.js +6 -7
  72. package/src/index-writer/index.js +3 -2
  73. package/src/index.js +1 -0
  74. package/src/intl/parse-bcp-47.js +2 -1
  75. package/src/invite/invite-api.js +26 -20
  76. package/src/lib/drizzle-helpers.js +54 -39
  77. package/src/lib/hypercore-helpers.js +4 -2
  78. package/src/lib/key-by.js +3 -1
  79. package/src/local-peers.js +39 -46
  80. package/src/logger.js +2 -1
  81. package/src/mapeo-manager.js +36 -23
  82. package/src/mapeo-project.js +68 -61
  83. package/src/member-api.js +177 -96
  84. package/src/roles.js +11 -10
  85. package/src/schema/json-schema-to-drizzle.js +13 -4
  86. package/src/schema.js +1 -0
  87. package/src/sync/core-sync-state.js +2 -1
  88. package/src/sync/peer-sync-controller.js +4 -3
  89. package/src/sync/sync-api.js +9 -9
  90. package/src/translation-api.js +2 -2
  91. package/src/utils.js +56 -41
  92. package/dist/lib/error.d.ts +0 -51
  93. package/dist/lib/error.d.ts.map +0 -1
  94. package/src/lib/error.js +0 -71
package/src/member-api.js CHANGED
@@ -5,9 +5,7 @@ import { TypedEmitter } from 'tiny-typed-emitter'
5
5
  import { pEvent } from 'p-event'
6
6
  import { InviteResponse_Decision } from './generated/rpc.js'
7
7
  import {
8
- assert,
9
8
  noop,
10
- ExhaustivenessError,
11
9
  projectKeyToId,
12
10
  projectKeyToProjectInviteId,
13
11
  projectKeyToPublicId,
@@ -17,26 +15,51 @@ import { keyBy } from './lib/key-by.js'
17
15
  import { abortSignalAny } from './lib/ponyfills.js'
18
16
  import timingSafeEqual from 'string-timing-safe-equal'
19
17
  import { isHostnameIpAddress } from './lib/is-hostname-ip-address.js'
20
- import { ErrorWithCode, getErrorMessage } from './lib/error.js'
21
- import { InviteAbortedError, ProjectDetailsSendFailError } from './errors.js'
18
+ import {
19
+ AlreadyBlockedError,
20
+ DeviceIdNotForServerError,
21
+ ensureKnownError,
22
+ InvalidServerResponseError,
23
+ InvalidUrlError,
24
+ InviteAbortedError,
25
+ IncompleteProjectDataError,
26
+ MissingOwnDeviceInfoError,
27
+ NetworkError,
28
+ ProjectDetailsSendFailError,
29
+ ProjectNotInAllowlistError,
30
+ ServerTooManyProjectsError,
31
+ ExhaustivenessError,
32
+ InvalidRoleIDForNewInviteError,
33
+ InvalidProjectNameError,
34
+ UnexpectedError,
35
+ AlreadyInvitingError,
36
+ InvalidResponseBodyError,
37
+ RPCDisconnectBeforeAckError,
38
+ } from './errors.js'
22
39
  import { wsCoreReplicator } from './lib/ws-core-replicator.js'
23
40
  import {
24
41
  BLOCKED_ROLE_ID,
42
+ COORDINATOR_ROLE_ID,
43
+ CREATOR_ROLE_ID,
25
44
  LEFT_ROLE_ID,
26
45
  MEMBER_ROLE_ID,
27
46
  ROLES,
28
47
  isRoleIdForNewInvite,
29
48
  } from './roles.js'
49
+ import { kCreateOrUpdateWithDocId } from './datatype/index.js'
50
+
51
+ const ACTIVE_ROLE_IDS = [CREATOR_ROLE_ID, MEMBER_ROLE_ID, COORDINATOR_ROLE_ID]
52
+
30
53
  /**
31
54
  * @import {
32
55
  * DeviceInfo,
33
56
  * DeviceInfoValue,
34
57
  * ProjectSettings,
35
- * ProjectSettingsValue
58
+ * ProjectSettingsValue,
36
59
  * } from '@comapeo/schema'
37
60
  */
38
61
  /** @import { Promisable } from 'type-fest' */
39
- /** @import { Invite, InviteResponse } from './generated/rpc.js' */
62
+ /** @import { DeviceInfo_DeviceType, Invite, InviteResponse } from './generated/rpc.js' */
40
63
  /** @import { DataType } from './datatype/index.js' */
41
64
  /** @import { DataStore } from './datastore/index.js' */
42
65
  /** @import { deviceInfoTable } from './schema/project.js' */
@@ -56,6 +79,16 @@ import {
56
79
  * @prop {string} selfHostedServerDetails.baseUrl
57
80
  */
58
81
 
82
+ /**
83
+ * @typedef {object} InvitePeerInfo
84
+ * @prop {DeviceInfo['name']} name
85
+ * @prop {DeviceInfo['deviceType']} deviceType
86
+ */
87
+
88
+ /**
89
+ * @typedef {Omit<MemberInfo, 'role'> & {role: import('./roles.js').Role<typeof MEMBER_ROLE_ID | typeof COORDINATOR_ROLE_ID | typeof CREATOR_ROLE_ID>}} ActiveMemberInfo
90
+ */
91
+
59
92
  export class MemberApi extends TypedEmitter {
60
93
  #ownDeviceId
61
94
  #roles
@@ -86,7 +119,7 @@ export class MemberApi extends TypedEmitter {
86
119
  * @param {() => ReplicationStream} opts.getReplicationStream
87
120
  * @param {(deviceId: string, abortSignal: AbortSignal) => Promise<void>} opts.waitForInitialSyncWithPeer
88
121
  * @param {Object} opts.dataTypes
89
- * @param {Pick<DeviceInfoDataType, 'getByDocId' | 'getMany'>} opts.dataTypes.deviceInfo
122
+ * @param {Pick<DeviceInfoDataType, 'getByDocId' | 'getMany' | kCreateOrUpdateWithDocId>} opts.dataTypes.deviceInfo
90
123
  * @param {Pick<ProjectDataType, 'getByDocId'>} opts.dataTypes.project
91
124
  * @param {Logger} [opts.logger]
92
125
  */
@@ -130,6 +163,7 @@ export class MemberApi extends TypedEmitter {
130
163
  * @param {string} [opts.roleDescription]
131
164
  * @param {Buffer} [opts.__testOnlyInviteId] Hard-code the invite ID. Only for tests.
132
165
  * @param {number} [opts.initialSyncTimeoutMs=5000]
166
+ * @param {InvitePeerInfo} [opts.peerInfo]
133
167
  * @returns {Promise<(
134
168
  * typeof InviteResponse_Decision.ACCEPT |
135
169
  * typeof InviteResponse_Decision.REJECT |
@@ -144,13 +178,15 @@ export class MemberApi extends TypedEmitter {
144
178
  roleDescription,
145
179
  __testOnlyInviteId,
146
180
  initialSyncTimeoutMs = 5000,
181
+ peerInfo,
147
182
  }
148
183
  ) {
149
- assert(isRoleIdForNewInvite(roleId), 'Invalid role ID for new invite')
150
- assert(
151
- !this.#outboundInvitesByDevice.has(deviceId),
152
- 'Already inviting this device ID'
153
- )
184
+ if (!isRoleIdForNewInvite(roleId)) {
185
+ throw new InvalidRoleIDForNewInviteError({ roleId })
186
+ }
187
+ if (this.#outboundInvitesByDevice.has(deviceId)) {
188
+ throw new AlreadyInvitingError()
189
+ }
154
190
 
155
191
  const abortController = new AbortController()
156
192
  const abortSignal = abortController.signal
@@ -160,10 +196,11 @@ export class MemberApi extends TypedEmitter {
160
196
  const { name: invitorName } = await this.getById(this.#ownDeviceId)
161
197
  // since we are always getting #ownDeviceId,
162
198
  // this should never throw (see comment on getById), but it pleases ts
163
- assert(
164
- invitorName,
165
- 'Internal error trying to read own device name for this invite'
166
- )
199
+ if (!invitorName) {
200
+ throw new UnexpectedError(
201
+ 'Internal error trying to read own device name for this invite'
202
+ )
203
+ }
167
204
 
168
205
  abortSignal.throwIfAborted()
169
206
 
@@ -172,11 +209,14 @@ export class MemberApi extends TypedEmitter {
172
209
  const projectInviteId = projectKeyToProjectInviteId(this.#projectKey)
173
210
  const project = await this.#dataTypes.project.getByDocId(projectId)
174
211
  const projectName = project.name
175
- assert(projectName, 'Project must have a name to invite people')
212
+ if (!projectName) {
213
+ throw new InvalidProjectNameError()
214
+ }
176
215
 
177
216
  const projectColor = project.projectColor
178
217
  const projectDescription = project.projectDescription
179
218
  const sendStats = project.sendStats
219
+ const invitorWroteDeviceInfo = !!peerInfo
180
220
 
181
221
  abortSignal.throwIfAborted()
182
222
 
@@ -190,6 +230,7 @@ export class MemberApi extends TypedEmitter {
190
230
  roleDescription,
191
231
  invitorName,
192
232
  sendStats,
233
+ invitorWroteDeviceInfo,
193
234
  }
194
235
 
195
236
  const inviteResponse = await this.#sendInviteAndGetResponse(
@@ -221,6 +262,20 @@ export class MemberApi extends TypedEmitter {
221
262
  }
222
263
  await this.#roles.assignRole(deviceId, roleId)
223
264
 
265
+ if (invitorWroteDeviceInfo) {
266
+ const { name, deviceType } = peerInfo
267
+ const doc = {
268
+ name: name,
269
+ deviceType: deviceType,
270
+ selfHostedServerDetails: undefined,
271
+ schemaName: /** @type {const} */ ('deviceInfo'),
272
+ }
273
+ await this.#dataTypes.deviceInfo[kCreateOrUpdateWithDocId](
274
+ deviceId,
275
+ doc
276
+ )
277
+ }
278
+
224
279
  try {
225
280
  let abortSync = new AbortController().signal
226
281
  if (initialSyncTimeoutMs) {
@@ -234,14 +289,14 @@ export class MemberApi extends TypedEmitter {
234
289
 
235
290
  return inviteResponse.decision
236
291
  default:
237
- throw new ExhaustivenessError(inviteResponse.decision)
292
+ throw new ExhaustivenessError({ value: inviteResponse.decision })
238
293
  }
239
- } catch (err) {
240
- if (err instanceof Error && err.name === 'RPCDisconnectBeforeAckError') {
241
- this.#l.log('ERROR: Disconnect before ack', err)
294
+ } catch (e) {
295
+ if (e instanceof RPCDisconnectBeforeAckError) {
296
+ this.#l.log('ERROR: Disconnect before ack', e)
242
297
  throw new InviteAbortedError()
243
298
  }
244
- throw err
299
+ throw ensureKnownError(e)
245
300
  } finally {
246
301
  this.#outboundInvitesByDevice.delete(deviceId)
247
302
  }
@@ -285,13 +340,13 @@ export class MemberApi extends TypedEmitter {
285
340
  try {
286
341
  await this.#rpc.sendInvite(deviceId, invite)
287
342
  return await responsePromise
288
- } catch (err) {
289
- if (err instanceof Error && err.name === 'AbortError') {
290
- this.#l.log('ERROR: Timed out sending invite', err)
343
+ } catch (e) {
344
+ if (e instanceof Error && e.name === 'AbortError') {
345
+ this.#l.log('ERROR: Timed out sending invite', e)
291
346
  throw new InviteAbortedError()
292
347
  } else {
293
- this.#l.log('ERROR: Unexpected error during invite send', err)
294
- throw err
348
+ this.#l.log('ERROR: Unexpected error during invite send', e)
349
+ throw ensureKnownError(e)
295
350
  }
296
351
  } finally {
297
352
  abortController.abort()
@@ -342,7 +397,7 @@ export class MemberApi extends TypedEmitter {
342
397
  if (
343
398
  !isValidServerBaseUrl(baseUrl, { dangerouslyAllowInsecureConnections })
344
399
  ) {
345
- throw new ErrorWithCode('INVALID_URL', 'Server base URL is invalid')
400
+ throw new InvalidUrlError()
346
401
  }
347
402
 
348
403
  const { serverDeviceId } = await this.#addServerToProject(baseUrl)
@@ -367,7 +422,7 @@ export class MemberApi extends TypedEmitter {
367
422
  const { roleId } = member.role
368
423
 
369
424
  if (roleId === BLOCKED_ROLE_ID || roleId === LEFT_ROLE_ID) {
370
- throw new ErrorWithCode('ALREADY_BLOCKED', 'Member already blocked')
425
+ throw new AlreadyBlockedError()
371
426
  }
372
427
 
373
428
  // Add blocked role to project
@@ -392,14 +447,12 @@ export class MemberApi extends TypedEmitter {
392
447
  const member = await this.getById(serverDeviceId)
393
448
 
394
449
  if (!member.selfHostedServerDetails) {
395
- throw new ErrorWithCode(
396
- 'DEVICE_ID_NOT_FOR_SERVER',
397
- 'DeviceId is not for a server peer'
398
- )
450
+ throw new DeviceIdNotForServerError({
451
+ deviceId: serverDeviceId.slice(0, 7),
452
+ })
399
453
  }
400
-
401
454
  if (member.role.roleId === BLOCKED_ROLE_ID) {
402
- throw new ErrorWithCode('ALREADY_BLOCKED', 'Server peer already blocked')
455
+ throw new AlreadyBlockedError()
403
456
  }
404
457
 
405
458
  const { baseUrl } = member.selfHostedServerDetails
@@ -422,10 +475,7 @@ export class MemberApi extends TypedEmitter {
422
475
  async #addServerToProject(baseUrl) {
423
476
  const projectName = await this.#getProjectName()
424
477
  if (!projectName) {
425
- throw new ErrorWithCode(
426
- 'MISSING_DATA',
427
- 'Project must have name to add server peer'
428
- )
478
+ throw new IncompleteProjectDataError()
429
479
  }
430
480
 
431
481
  const requestUrl = new URL('projects', baseUrl)
@@ -448,13 +498,10 @@ export class MemberApi extends TypedEmitter {
448
498
  body: JSON.stringify(requestBody),
449
499
  headers: { 'Content-Type': 'application/json' },
450
500
  })
451
- } catch (err) {
452
- throw new ErrorWithCode(
453
- 'NETWORK_ERROR',
454
- `Failed to add server peer due to network error: ${getErrorMessage(
455
- err
456
- )}`
457
- )
501
+ } catch (e) {
502
+ throw new NetworkError('Failed to add server peer due to network error', {
503
+ cause: e,
504
+ })
458
505
  }
459
506
 
460
507
  return await parseAddServerResponse(response)
@@ -483,18 +530,12 @@ export class MemberApi extends TypedEmitter {
483
530
 
484
531
  try {
485
532
  await pEvent(websocket, 'open', { rejectionEvents: ['error'] })
486
- } catch (rejectionEvent) {
487
- throw new ErrorWithCode(
488
- // It's difficult for us to reliably disambiguate between "network error"
489
- // and "invalid response from server" here, so we just say it was an
490
- // invalid server response.
491
- 'INVALID_SERVER_RESPONSE',
492
- 'Failed to open the socket',
493
- rejectionEvent &&
494
- typeof rejectionEvent === 'object' &&
495
- 'error' in rejectionEvent
496
- ? { cause: rejectionEvent.error }
497
- : { cause: rejectionEvent }
533
+ } catch (e) {
534
+ throw new InvalidServerResponseError(
535
+ 'Failed to open websocket for initial sync',
536
+ e && typeof e === 'object' && 'error' in e
537
+ ? { cause: e.error }
538
+ : { cause: e }
498
539
  )
499
540
  }
500
541
 
@@ -538,32 +579,54 @@ export class MemberApi extends TypedEmitter {
538
579
  const result = { deviceId, role }
539
580
 
540
581
  try {
541
- const configCoreId = await this.#coreOwnership.getCoreId(
542
- deviceId,
543
- 'config'
544
- )
545
-
546
- const deviceInfo = await this.#dataTypes.deviceInfo.getByDocId(
547
- configCoreId
548
- )
582
+ const deviceInfo = await this.#getDeviceInfo(deviceId)
549
583
 
550
584
  result.name = deviceInfo.name
551
585
  result.deviceType = deviceInfo.deviceType
552
586
  result.joinedAt = deviceInfo.createdAt
553
587
  result.selfHostedServerDetails = deviceInfo.selfHostedServerDetails
554
- } catch (err) {
588
+ } catch (e) {
555
589
  // Attempting to get someone else may throw because sync hasn't occurred or completed
556
590
  // Only throw if attempting to get themself since the relevant information should be available
557
- if (deviceId === this.#ownDeviceId) throw err
591
+ if (deviceId === this.#ownDeviceId) {
592
+ throw new MissingOwnDeviceInfoError({ cause: e })
593
+ }
558
594
  }
559
595
 
560
596
  return result
561
597
  }
562
598
 
563
599
  /**
564
- * @returns {Promise<Array<MemberInfo>>}
600
+ * @param {string} deviceId
601
+ * @returns {Promise<DeviceInfo>}
602
+ */
603
+ async #getDeviceInfo(deviceId) {
604
+ try {
605
+ return await this.#dataTypes.deviceInfo.getByDocId(deviceId)
606
+ } catch (e) {
607
+ const configCoreId = await this.#coreOwnership.getCoreId(
608
+ deviceId,
609
+ 'config'
610
+ )
611
+ return this.#dataTypes.deviceInfo.getByDocId(configCoreId)
612
+ }
613
+ }
614
+
615
+ /**
616
+ * @overload
617
+ * @returns {Promise<Array<ActiveMemberInfo>>}
565
618
  */
566
- async getMany() {
619
+ /**
620
+ * @template {boolean} [T=false]
621
+ *
622
+ * @overload
623
+ * @param {Object} opts
624
+ * @param {T} [opts.includeLeft=false]
625
+ *
626
+ * @returns {Promise<T extends true ? Array<MemberInfo> : Array<ActiveMemberInfo>>}
627
+ *
628
+ */
629
+ async getMany({ includeLeft = false } = {}) {
567
630
  const [allRoles, allDeviceInfo] = await Promise.all([
568
631
  this.#roles.getAll(),
569
632
  this.#dataTypes.deviceInfo.getMany(),
@@ -571,8 +634,17 @@ export class MemberApi extends TypedEmitter {
571
634
 
572
635
  const deviceInfoByConfigCoreId = keyBy(allDeviceInfo, ({ docId }) => docId)
573
636
 
574
- return Promise.all(
575
- [...allRoles.entries()].map(async ([deviceId, role]) => {
637
+ /**
638
+ * @type {Array<Promise<MemberInfo>>}
639
+ */
640
+ const activeMemberInfoPromises = []
641
+
642
+ for (const [deviceId, role] of allRoles.entries()) {
643
+ if (!includeLeft && !isActiveMemberRole(role)) {
644
+ continue
645
+ }
646
+
647
+ const getMemberInfo = async () => {
576
648
  /** @type {MemberInfo} */
577
649
  const memberInfo = { deviceId, role }
578
650
 
@@ -589,15 +661,20 @@ export class MemberApi extends TypedEmitter {
589
661
  memberInfo.joinedAt = deviceInfo?.createdAt
590
662
  memberInfo.selfHostedServerDetails =
591
663
  deviceInfo?.selfHostedServerDetails
592
- } catch (err) {
664
+ } catch (e) {
593
665
  // Attempting to get someone else may throw because sync hasn't occurred or completed
594
666
  // Only throw if attempting to get themself since the relevant information should be available
595
- if (deviceId === this.#ownDeviceId) throw err
667
+ if (deviceId === this.#ownDeviceId) {
668
+ throw new MissingOwnDeviceInfoError({ cause: e })
669
+ }
596
670
  }
597
671
 
598
672
  return memberInfo
599
- })
600
- )
673
+ }
674
+ activeMemberInfoPromises.push(getMemberInfo())
675
+ }
676
+
677
+ return Promise.all(activeMemberInfoPromises)
601
678
  }
602
679
 
603
680
  /**
@@ -610,6 +687,15 @@ export class MemberApi extends TypedEmitter {
610
687
  }
611
688
  }
612
689
 
690
+ /**
691
+ * @param {import('./roles.js').Role} role
692
+ *
693
+ * @returns {role is ActiveMemberInfo['role']}
694
+ */
695
+ function isActiveMemberRole(role) {
696
+ return ACTIVE_ROLE_IDS.includes(role.roleId)
697
+ }
698
+
613
699
  /**
614
700
  * @param {string} baseUrl
615
701
  * @param {object} options
@@ -625,7 +711,7 @@ function isValidServerBaseUrl(
625
711
  /** @type {URL} */ let url
626
712
  try {
627
713
  url = new URL(baseUrl)
628
- } catch (_err) {
714
+ } catch {
629
715
  return false
630
716
  }
631
717
 
@@ -670,20 +756,22 @@ async function parseAddServerResponse(response) {
670
756
  if (response.status === 200) {
671
757
  try {
672
758
  const responseBody = await response.json()
673
- assert(
674
- responseBody &&
759
+ if (
760
+ !(
761
+ responseBody &&
675
762
  typeof responseBody === 'object' &&
676
763
  'data' in responseBody &&
677
764
  responseBody.data &&
678
765
  typeof responseBody.data === 'object' &&
679
766
  'deviceId' in responseBody.data &&
680
- typeof responseBody.data.deviceId === 'string',
681
- 'Response body is valid'
682
- )
767
+ typeof responseBody.data.deviceId === 'string'
768
+ )
769
+ ) {
770
+ throw new InvalidResponseBodyError()
771
+ }
683
772
  return { serverDeviceId: responseBody.data.deviceId }
684
- } catch (err) {
685
- throw new ErrorWithCode(
686
- 'INVALID_SERVER_RESPONSE',
773
+ } catch {
774
+ throw new InvalidServerResponseError(
687
775
  "Failed to add server peer because we couldn't parse the response"
688
776
  )
689
777
  }
@@ -692,7 +780,7 @@ async function parseAddServerResponse(response) {
692
780
  let responseBody
693
781
  try {
694
782
  responseBody = await response.json()
695
- } catch (_) {
783
+ } catch {
696
784
  responseBody = null
697
785
  }
698
786
  if (
@@ -705,22 +793,15 @@ async function parseAddServerResponse(response) {
705
793
  ) {
706
794
  switch (responseBody.error.code) {
707
795
  case 'PROJECT_NOT_IN_ALLOWLIST':
708
- throw new ErrorWithCode(
709
- 'PROJECT_NOT_IN_SERVER_ALLOWLIST',
710
- "The server only allows specific projects to be added, and this isn't one of them"
711
- )
796
+ throw new ProjectNotInAllowlistError()
712
797
  case 'TOO_MANY_PROJECTS':
713
- throw new ErrorWithCode(
714
- 'SERVER_HAS_TOO_MANY_PROJECTS',
715
- "The server limits the number of projects it can have and it's at the limit"
716
- )
798
+ throw new ServerTooManyProjectsError()
717
799
  default:
718
800
  break
719
801
  }
720
802
  }
721
803
 
722
- throw new ErrorWithCode(
723
- 'INVALID_SERVER_RESPONSE',
804
+ throw new InvalidServerResponseError(
724
805
  `Failed to add server peer due to HTTP status code ${response.status}`
725
806
  )
726
807
  }
package/src/roles.js CHANGED
@@ -1,8 +1,8 @@
1
1
  import { currentSchemaVersions } from '@comapeo/schema'
2
2
  import mapObject from 'map-obj'
3
3
  import { kCreateWithDocId, kDataStore } from './datatype/index.js'
4
- import { assert, setHas } from './utils.js'
5
- import { nullIfNotFound } from './errors.js'
4
+ import { setHas } from './utils.js'
5
+ import { nullIfNotFound, RoleAssignError } from './errors.js'
6
6
  import { TypedEmitter } from 'tiny-typed-emitter'
7
7
  /** @import { Namespace } from './types.js' */
8
8
 
@@ -332,7 +332,7 @@ export class Roles extends TypedEmitter {
332
332
  // Default to creator role, but can be overwritten if a different role is
333
333
  // set below
334
334
  result.set(projectCreatorDeviceId, CREATOR_ROLE)
335
- } catch (e) {
335
+ } catch {
336
336
  // Not found, we don't know who the project creator is so we can't include
337
337
  // them in the returned map
338
338
  }
@@ -369,10 +369,11 @@ export class Roles extends TypedEmitter {
369
369
  * @param {string} opts.reason
370
370
  */
371
371
  async assignRole(deviceId, roleId, opts) {
372
- assert(
373
- isRoleIdAssignableToAnyone(roleId),
374
- `Role ID should be assignable to anyone but got ${roleId}`
375
- )
372
+ if (!isRoleIdAssignableToAnyone(roleId)) {
373
+ throw new RoleAssignError(
374
+ `Role ID should be assignable to anyone but got ${roleId}`
375
+ )
376
+ }
376
377
 
377
378
  let fromIndex = 0
378
379
  let authCoreId
@@ -391,12 +392,12 @@ export class Roles extends TypedEmitter {
391
392
  }
392
393
  if (roleId === LEFT_ROLE_ID) {
393
394
  if (deviceId !== this.#ownDeviceId) {
394
- throw new Error('Cannot assign LEFT role to another device')
395
+ throw new RoleAssignError('Cannot assign LEFT role to another device')
395
396
  }
396
397
  } else {
397
398
  const ownRole = await this.getRole(this.#ownDeviceId)
398
399
  if (!ownRole.roleAssignment.includes(roleId)) {
399
- throw new Error('Lacks permission to assign role ' + roleId)
400
+ throw new RoleAssignError('Lacks permission to assign role ' + roleId)
400
401
  }
401
402
  }
402
403
 
@@ -423,7 +424,7 @@ export class Roles extends TypedEmitter {
423
424
  isAssigningProjectCreatorRole &&
424
425
  roleId !== BLOCKED_ROLE_ID
425
426
  ) {
426
- throw new Error(
427
+ throw new RoleAssignError(
427
428
  'Project creators can only be assigned the blocked role'
428
429
  )
429
430
  }
@@ -1,5 +1,8 @@
1
1
  import { text, integer, real, sqliteTable } from 'drizzle-orm/sqlite-core'
2
- import { ExhaustivenessError } from '../utils.js'
2
+ import {
3
+ InvalidComapeoSchemaFormatError,
4
+ ExhaustivenessError,
5
+ } from '../errors.js'
3
6
 
4
7
  /**
5
8
  * @template {{ [ K in keyof TSchema['properties'] ]?: any }} TObjectType
@@ -20,14 +23,20 @@ export function jsonSchemaToDrizzleSqliteTable(
20
23
  { additionalColumns, primaryKey } = {}
21
24
  ) {
22
25
  if (schema.type !== 'object' || !schema.properties) {
23
- throw new Error('Cannot process JSONSchema as SQL table')
26
+ throw new InvalidComapeoSchemaFormatError({
27
+ tableName,
28
+ reason: 'Missing schema properties',
29
+ })
24
30
  }
25
31
  /** @type {Record<string, any>} */
26
32
  const columns = {}
27
33
  for (const [key, value] of Object.entries(schema.properties)) {
28
34
  if (typeof value !== 'object') continue
29
35
  if (isArray(value.type) || typeof value.type === 'undefined') {
30
- throw new Error('Cannot process JSONSchema as SQL table')
36
+ throw new InvalidComapeoSchemaFormatError({
37
+ tableName,
38
+ reason: 'Columns must have single known type',
39
+ })
31
40
  }
32
41
  switch (value.type) {
33
42
  case 'boolean':
@@ -56,7 +65,7 @@ export function jsonSchemaToDrizzleSqliteTable(
56
65
  // Skip handling this right now
57
66
  continue
58
67
  default:
59
- throw new ExhaustivenessError(value.type)
68
+ throw new ExhaustivenessError({ value: value.type })
60
69
  }
61
70
  if (isRequired(schema, key)) {
62
71
  columns[key] = columns[key].notNull()
package/src/schema.js ADDED
@@ -0,0 +1 @@
1
+ export * from '@comapeo/schema'
@@ -3,6 +3,7 @@ import RemoteBitfield, {
3
3
  BITS_PER_PAGE,
4
4
  } from '../core-manager/remote-bitfield.js'
5
5
  import { Logger } from '../logger.js'
6
+ import { InvalidBitfieldIndexError } from '../errors.js'
6
7
  /** @import { HypercorePeer, HypercoreRemoteBitfield, Namespace } from '../types.js' */
7
8
 
8
9
  /**
@@ -501,7 +502,7 @@ export function bitCount32(n) {
501
502
  * @param {number} index
502
503
  */
503
504
  function getBitfieldWord(bitfield, index) {
504
- if (index % 32 !== 0) throw new Error('Index must be multiple of 32')
505
+ if (index % 32 !== 0) throw new InvalidBitfieldIndexError()
505
506
  const j = index & (BITS_PER_PAGE - 1)
506
507
  const i = (index - j) / BITS_PER_PAGE
507
508
 
@@ -1,8 +1,9 @@
1
1
  import mapObject from 'map-obj'
2
2
  import { NAMESPACES, PRESYNC_NAMESPACES } from '../constants.js'
3
3
  import { Logger } from '../logger.js'
4
- import { ExhaustivenessError, createMap } from '../utils.js'
4
+ import { createMap } from '../utils.js'
5
5
  import { unreplicate } from '../lib/hypercore-helpers.js'
6
+ import { ExhaustivenessError } from '../errors.js'
6
7
  /** @import { CoreRecord } from '../core-manager/index.js' */
7
8
  /** @import { Role } from '../roles.js' */
8
9
  /** @import { SyncEnabledState } from './sync-api.js' */
@@ -195,7 +196,7 @@ export class PeerSyncController {
195
196
  isAnySyncEnabled = isDataSyncEnabled = true
196
197
  break
197
198
  default:
198
- throw new ExhaustivenessError(this.#syncEnabledState)
199
+ throw new ExhaustivenessError({ value: this.#syncEnabledState })
199
200
  }
200
201
 
201
202
  for (const ns of NAMESPACES) {
@@ -228,7 +229,7 @@ export class PeerSyncController {
228
229
  this.#disableNamespace(ns)
229
230
  }
230
231
  } else {
231
- throw new ExhaustivenessError(cap)
232
+ throw new ExhaustivenessError({ value: cap })
232
233
  }
233
234
  }
234
235
  }