dignity.js 0.7.0 → 0.8.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.
@@ -15,8 +15,24 @@ const { deriveKeyPairFromCredentials } = require('../security/derive-key-pair');
15
15
  const {
16
16
  DEFAULT_PEER_GROUP_OPTIONS,
17
17
  peerGroupScope,
18
+ parsePeerGroupScope,
18
19
  selectFanoutPeers
19
20
  } = require('../gossip/peer-group');
21
+ const {
22
+ operationToDomainEvent,
23
+ signDomainEvent,
24
+ verifyDomainEvent,
25
+ applyDomainEventToView,
26
+ createEmptyView,
27
+ buildCheckpoint
28
+ } = require('../cqrs/domain-events');
29
+ const {
30
+ DEFAULT_LIVE_CAP,
31
+ DEFAULT_BULK_INTERVAL_MS,
32
+ assignPeerGroupTier,
33
+ filterPeersByTier
34
+ } = require('../cqrs/peer-group-tiers');
35
+ const { electBulkRelays } = require('../cqrs/bulk-relay');
20
36
 
21
37
  function computeContentHash(data) {
22
38
  const canonical = stableStringify(data || {});
@@ -111,6 +127,10 @@ class DignityP2P extends EventEmitter {
111
127
  this.maxAppliedOperations = security && typeof security.maxAppliedOperations === 'number'
112
128
  ? security.maxAppliedOperations
113
129
  : 50000;
130
+ this.domainEventLogs = new Map(); // groupId -> event[]
131
+ this.lastEventHashByGroup = new Map(); // groupId -> hash
132
+ this.bulkRelayByGroup = new Map(); // groupId -> peerId[]
133
+ this.replicaViews = new Map(); // groupId -> view Map
114
134
 
115
135
  this.state = new Map(); // collection -> Map(id -> record)
116
136
  this.appliedOperations = new Map(); // opId -> appliedAt
@@ -281,6 +301,7 @@ class DignityP2P extends EventEmitter {
281
301
  };
282
302
 
283
303
  this.applyOperation(operation);
304
+ await this.maybePublishDomainEvent(operation, options);
284
305
  await this.broadcastMessage('operation', operation, {
285
306
  broadcastScope: options.broadcastScope || this.resolveBroadcastScope({
286
307
  messageType: 'operation',
@@ -374,6 +395,7 @@ class DignityP2P extends EventEmitter {
374
395
  }
375
396
 
376
397
  this.applyOperation(operation);
398
+ await this.maybePublishDomainEvent(operation, options);
377
399
  await this.broadcastMessage('operation', operation, {
378
400
  broadcastScope: options.broadcastScope || this.resolveBroadcastScope({
379
401
  messageType: 'operation',
@@ -439,6 +461,7 @@ class DignityP2P extends EventEmitter {
439
461
  };
440
462
 
441
463
  this.applyOperation(operation);
464
+ await this.maybePublishDomainEvent(operation, options);
442
465
  await this.broadcastMessage('operation', operation, {
443
466
  broadcastScope: options.broadcastScope || this.resolveBroadcastScope({
444
467
  messageType: 'operation',
@@ -476,6 +499,7 @@ class DignityP2P extends EventEmitter {
476
499
  };
477
500
 
478
501
  this.applyOperation(operation);
502
+ await this.maybePublishDomainEvent(operation, options);
479
503
  await this.broadcastMessage('operation', operation, {
480
504
  broadcastScope: options.broadcastScope || this.resolveBroadcastScope({
481
505
  messageType: 'operation',
@@ -830,9 +854,19 @@ class DignityP2P extends EventEmitter {
830
854
  return [];
831
855
  }
832
856
 
833
- selectPeerGroupFanout(groupId, count, excludePeerIds = []) {
857
+ selectPeerGroupFanout(groupId, count, excludePeerIds = [], fanoutOptions = {}) {
834
858
  const scope = this.peerGroupScopeFor(groupId);
835
- const peers = this.listPeers(scope, { includeSelf: false });
859
+ const group = this.peerGroups.get(groupId);
860
+ let peers = this.listPeers(scope, { includeSelf: false });
861
+
862
+ if (group && group.tiered && fanoutOptions.tier) {
863
+ peers = filterPeersByTier(peers, fanoutOptions.tier);
864
+ }
865
+
866
+ if (fanoutOptions.bulkRelayOnly) {
867
+ peers = peers.filter((peer) => peer.metadata?.bulkRelay === true);
868
+ }
869
+
836
870
  return selectFanoutPeers({
837
871
  peers,
838
872
  count,
@@ -869,18 +903,56 @@ class DignityP2P extends EventEmitter {
869
903
  }
870
904
 
871
905
  const scope = this.peerGroupScopeFor(groupId);
906
+ const role = options.role || options.metadata?.role || 'subscriber';
907
+ const tierMode = options.tierMode || 'auto';
908
+ const tiered = options.tiered === true
909
+ || options.tierMode !== undefined
910
+ || options.role !== undefined
911
+ || typeof options.liveCap === 'number';
912
+ const liveCap = typeof options.liveCap === 'number' ? options.liveCap : DEFAULT_LIVE_CAP;
913
+ const existingMembers = this.listPeerGroupMembers(groupId, { includeSelf: false });
914
+ const existingSubscriberCount = existingMembers.filter((member) => {
915
+ const memberRole = member.metadata?.peerGroupRole || member.metadata?.role;
916
+ return memberRole !== 'publisher';
917
+ }).length;
918
+ let assignedTier = tiered
919
+ ? assignPeerGroupTier({
920
+ joinIndex: existingSubscriberCount,
921
+ liveCap,
922
+ requestedTier: tierMode === 'auto' ? null : tierMode,
923
+ role
924
+ })
925
+ : null;
926
+ const publisherId = options.publisherId || (role === 'publisher' ? this.nodeId : null);
927
+
872
928
  const config = {
873
929
  fanout: typeof options.fanout === 'number' ? options.fanout : this.defaultPeerGroupFanout,
874
930
  maxActivePeers: typeof options.maxActivePeers === 'number'
875
931
  ? options.maxActivePeers
876
932
  : this.defaultPeerGroupMaxActivePeers,
877
933
  maxHops: typeof options.maxHops === 'number' ? options.maxHops : this.defaultGossipMaxHops,
878
- relayEnabled: options.relayEnabled !== false
934
+ relayEnabled: options.relayEnabled !== false,
935
+ tiered,
936
+ tierMode,
937
+ liveCap,
938
+ bulkIntervalMs: typeof options.bulkIntervalMs === 'number'
939
+ ? options.bulkIntervalMs
940
+ : DEFAULT_BULK_INTERVAL_MS,
941
+ domainEvents: options.domainEvents !== false,
942
+ autoPublishDomainEvents: options.autoPublishDomainEvents !== false,
943
+ role,
944
+ publisherId,
945
+ commandCapable: options.commandCapable !== false,
946
+ peerGroupTier: assignedTier
879
947
  };
880
948
 
881
949
  await this.joinDiscovery(scope, {
882
950
  metadata: {
883
951
  peerGroup: groupId,
952
+ ...(assignedTier ? { peerGroupTier: assignedTier } : {}),
953
+ peerGroupRole: role,
954
+ ...(publisherId ? { publisherId } : {}),
955
+ bulkRelay: false,
884
956
  ...(options.metadata || {})
885
957
  },
886
958
  bootstrapPeerIds: options.bootstrapPeerIds,
@@ -889,10 +961,74 @@ class DignityP2P extends EventEmitter {
889
961
  });
890
962
 
891
963
  this.peerGroups.set(groupId, config);
892
- this.emit('peergroupjoined', { groupId, config });
964
+ if (!this.domainEventLogs.has(groupId)) {
965
+ this.domainEventLogs.set(groupId, []);
966
+ }
967
+ if (!this.replicaViews.has(groupId)) {
968
+ this.replicaViews.set(groupId, createEmptyView());
969
+ }
970
+
971
+ this.refreshBulkRelays(groupId);
972
+ if (tiered && tierMode === 'auto' && role === 'subscriber') {
973
+ assignedTier = this.recalculateOwnPeerGroupTier(groupId) || assignedTier;
974
+ config.peerGroupTier = assignedTier;
975
+ }
976
+ this.emit('peergroupjoined', { groupId, config, tier: assignedTier });
893
977
  return config;
894
978
  }
895
979
 
980
+ recalculateOwnPeerGroupTier(groupId) {
981
+ const group = this.peerGroups.get(groupId);
982
+ if (!group || !group.tiered || group.role !== 'subscriber') {
983
+ return group ? group.peerGroupTier : null;
984
+ }
985
+
986
+ if (group.tierMode !== 'auto') {
987
+ return group.peerGroupTier;
988
+ }
989
+
990
+ const scope = this.peerGroupScopeFor(groupId);
991
+ const members = this.listPeerGroupMembers(groupId, { includeSelf: true });
992
+ const subscribers = members
993
+ .filter((member) => {
994
+ const memberRole = member.metadata?.peerGroupRole || member.metadata?.role;
995
+ return memberRole !== 'publisher';
996
+ })
997
+ .map((member) => member.peerId)
998
+ .sort();
999
+
1000
+ const joinIndex = subscribers.indexOf(this.nodeId);
1001
+ if (joinIndex < 0) {
1002
+ return group.peerGroupTier;
1003
+ }
1004
+
1005
+ const newTier = assignPeerGroupTier({
1006
+ joinIndex,
1007
+ liveCap: group.liveCap,
1008
+ requestedTier: null,
1009
+ role: 'subscriber'
1010
+ });
1011
+
1012
+ if (newTier === group.peerGroupTier) {
1013
+ return newTier;
1014
+ }
1015
+
1016
+ group.peerGroupTier = newTier;
1017
+ const room = this.discoveryRooms.get(scope);
1018
+ if (room) {
1019
+ room.metadata = {
1020
+ ...(room.metadata || {}),
1021
+ peerGroupTier: newTier
1022
+ };
1023
+ this.upsertPresence(scope, this.nodeId, room.metadata, room.ttlMs, this.now());
1024
+ this.announcePresence(scope).catch((error) => {
1025
+ this.emit('warning', { type: 'tier-announce-failed', groupId, error });
1026
+ });
1027
+ }
1028
+
1029
+ return newTier;
1030
+ }
1031
+
896
1032
  async leavePeerGroup(groupId) {
897
1033
  if (!groupId) {
898
1034
  return;
@@ -901,6 +1037,7 @@ class DignityP2P extends EventEmitter {
901
1037
  const scope = this.peerGroupScopeFor(groupId);
902
1038
  await this.leaveDiscovery(scope);
903
1039
  this.peerGroups.delete(groupId);
1040
+ this.bulkRelayByGroup.delete(groupId);
904
1041
  this.emit('peergroupleft', { groupId });
905
1042
  }
906
1043
 
@@ -932,7 +1069,12 @@ class DignityP2P extends EventEmitter {
932
1069
  ? options.maxHops
933
1070
  : (group ? group.maxHops : this.defaultGossipMaxHops);
934
1071
 
935
- const fanoutPeerIds = this.selectPeerGroupFanout(groupId, fanout, [this.nodeId]);
1072
+ const fanoutOptions = {};
1073
+ if (group && group.tiered && options.tier !== 'bulk') {
1074
+ fanoutOptions.tier = options.tier || 'live';
1075
+ }
1076
+
1077
+ const fanoutPeerIds = this.selectPeerGroupFanout(groupId, fanout, [this.nodeId], fanoutOptions);
936
1078
  if (fanoutPeerIds.length > 0) {
937
1079
  await this.ensureConnectedToPeers(fanoutPeerIds.slice(0, maxActivePeers));
938
1080
  await this.enforceConnectionBudget();
@@ -958,6 +1100,248 @@ class DignityP2P extends EventEmitter {
958
1100
  return { gossipId, fanoutPeerIds };
959
1101
  }
960
1102
 
1103
+ async publishPeerGroupBulk(groupId, innerMessageType, innerPayload, options = {}) {
1104
+ const group = this.peerGroups.get(groupId);
1105
+ if (!group && options.allowUnjoined !== true) {
1106
+ throw new Error(`PeerGroup ${groupId} has not been joined`);
1107
+ }
1108
+
1109
+ if (group && group.role !== 'publisher') {
1110
+ throw new Error(`Only publisher can bulk-publish to PeerGroup ${groupId}`);
1111
+ }
1112
+
1113
+ const fanout = typeof options.fanout === 'number'
1114
+ ? options.fanout
1115
+ : (group ? group.fanout : this.defaultPeerGroupFanout);
1116
+ const maxActivePeers = group ? group.maxActivePeers : this.defaultPeerGroupMaxActivePeers;
1117
+ const maxHop = typeof options.maxHops === 'number'
1118
+ ? options.maxHops
1119
+ : (group ? group.maxHops : this.defaultGossipMaxHops);
1120
+
1121
+ const fanoutPeerIds = this.selectPeerGroupFanout(
1122
+ groupId,
1123
+ fanout,
1124
+ [this.nodeId],
1125
+ { tier: 'bulk', bulkRelayOnly: group?.tiered === true }
1126
+ );
1127
+
1128
+ if (fanoutPeerIds.length > 0) {
1129
+ await this.ensureConnectedToPeers(fanoutPeerIds.slice(0, maxActivePeers));
1130
+ await this.enforceConnectionBudget();
1131
+ }
1132
+
1133
+ const gossipId = options.gossipId || this.idGenerator();
1134
+ this.markSeenGossip(gossipId);
1135
+
1136
+ await this.broadcastMessage('peer-group:gossip', {
1137
+ groupId,
1138
+ gossipId,
1139
+ publisherId: this.nodeId,
1140
+ hop: 0,
1141
+ maxHop,
1142
+ deliveryTier: 'bulk',
1143
+ innerMessageType,
1144
+ innerPayload
1145
+ }, {
1146
+ broadcastScope: this.peerGroupScopeFor(groupId),
1147
+ fanoutPeerIds
1148
+ });
1149
+
1150
+ return { gossipId, fanoutPeerIds };
1151
+ }
1152
+
1153
+ async publishPeerGroupCheckpoint(groupId, options = {}) {
1154
+ const group = this.peerGroups.get(groupId);
1155
+ if (!group) {
1156
+ throw new Error(`PeerGroup ${groupId} has not been joined`);
1157
+ }
1158
+
1159
+ const events = this.domainEventLogs.get(groupId) || [];
1160
+ const checkpoint = buildCheckpoint(groupId, events, {
1161
+ publisherId: options.publisherId || group.publisherId || this.nodeId
1162
+ });
1163
+
1164
+ await this.publishPeerGroupBulk(groupId, 'domain:checkpoint', checkpoint, options);
1165
+ this.emit('checkpointpublished', { groupId, checkpoint });
1166
+ return checkpoint;
1167
+ }
1168
+
1169
+ resolvePublisherGroupIds(options = {}) {
1170
+ if (options.peerGroupId) {
1171
+ return [options.peerGroupId];
1172
+ }
1173
+
1174
+ const groups = [];
1175
+ for (const [groupId, config] of this.peerGroups.entries()) {
1176
+ if (config.domainEvents && config.autoPublishDomainEvents && config.role === 'publisher') {
1177
+ groups.push(groupId);
1178
+ }
1179
+ }
1180
+ return groups;
1181
+ }
1182
+
1183
+ async maybePublishDomainEvent(operation, options = {}) {
1184
+ const groupIds = this.resolvePublisherGroupIds(options);
1185
+ if (groupIds.length === 0) {
1186
+ return;
1187
+ }
1188
+
1189
+ for (const groupId of groupIds) {
1190
+ await this.publishDomainEventForOperation(groupId, operation);
1191
+ }
1192
+ }
1193
+
1194
+ async publishDomainEventForOperation(groupId, operation) {
1195
+ const group = this.peerGroups.get(groupId);
1196
+ if (!group || !group.domainEvents) {
1197
+ return null;
1198
+ }
1199
+
1200
+ if (group.role !== 'publisher') {
1201
+ throw new Error(`Only publisher can emit domain events for PeerGroup ${groupId}`);
1202
+ }
1203
+
1204
+ const prevHash = this.lastEventHashByGroup.get(groupId) || null;
1205
+ let event = operationToDomainEvent(operation, {
1206
+ publisherId: this.nodeId,
1207
+ groupId,
1208
+ prevHash,
1209
+ eventIdGenerator: () => this.idGenerator()
1210
+ });
1211
+
1212
+ if (this.securityService.options.signingEnabled && this.securityService.signingSecretKey) {
1213
+ event = signDomainEvent(event, this.securityService.signingSecretKey);
1214
+ }
1215
+
1216
+ const log = this.domainEventLogs.get(groupId) || [];
1217
+ log.push(event);
1218
+ this.domainEventLogs.set(groupId, log);
1219
+ this.lastEventHashByGroup.set(groupId, event.eventHash);
1220
+
1221
+ this.emit('domainevent', event);
1222
+
1223
+ if (group.autoPublishDomainEvents) {
1224
+ await this.publishToPeerGroup(groupId, 'domain:event', event, { tier: 'live' });
1225
+ if (group.tiered) {
1226
+ await this.publishPeerGroupBulk(groupId, 'domain:event', event);
1227
+ }
1228
+ }
1229
+
1230
+ return event;
1231
+ }
1232
+
1233
+ refreshBulkRelays(groupId) {
1234
+ const group = this.peerGroups.get(groupId);
1235
+ if (!group || !group.tiered) {
1236
+ return [];
1237
+ }
1238
+
1239
+ const peers = this.listPeerGroupMembers(groupId, { includeSelf: false });
1240
+ const relays = electBulkRelays(peers);
1241
+ const previous = this.bulkRelayByGroup.get(groupId) || [];
1242
+ this.bulkRelayByGroup.set(groupId, relays);
1243
+
1244
+ const changed = previous.length !== relays.length
1245
+ || previous.some((id, index) => id !== relays[index]);
1246
+
1247
+ if (changed) {
1248
+ this.emit('bulkrelaychanged', { groupId, relays, previous });
1249
+ }
1250
+
1251
+ return relays;
1252
+ }
1253
+
1254
+ ingestRemoteDomainEvent(event, context = {}) {
1255
+ const groupId = event.groupId || context.groupId;
1256
+ if (!groupId) {
1257
+ return false;
1258
+ }
1259
+
1260
+ const group = this.peerGroups.get(groupId);
1261
+ if (!group) {
1262
+ return false;
1263
+ }
1264
+
1265
+ const publisherId = event.publisherId || context.publisherId;
1266
+ if (group.publisherId && publisherId !== group.publisherId) {
1267
+ this.emit('warning', {
1268
+ type: 'domain-event-rejected',
1269
+ groupId,
1270
+ reason: 'publisher-mismatch',
1271
+ eventId: event.eventId,
1272
+ expectedPublisher: group.publisherId,
1273
+ actualPublisher: publisherId
1274
+ });
1275
+ return false;
1276
+ }
1277
+
1278
+ let signingPublicKey = null;
1279
+ if (this.securityService.options.signingEnabled && publisherId) {
1280
+ const peerKey = this.securityService.resolvePeerPublicKey(publisherId, null);
1281
+ signingPublicKey = peerKey ? peerKey.signingPublicKey : null;
1282
+ if (!signingPublicKey) {
1283
+ this.emit('warning', {
1284
+ type: 'domain-event-rejected',
1285
+ groupId,
1286
+ reason: 'missing-publisher-key',
1287
+ eventId: event.eventId,
1288
+ publisherId
1289
+ });
1290
+ return false;
1291
+ }
1292
+ }
1293
+
1294
+ const verified = verifyDomainEvent(event, { signingPublicKey });
1295
+ if (!verified.ok) {
1296
+ this.emit('warning', {
1297
+ type: 'domain-event-rejected',
1298
+ groupId,
1299
+ reason: verified.reason,
1300
+ eventId: event.eventId
1301
+ });
1302
+ return false;
1303
+ }
1304
+
1305
+ if (this.securityService.options.signingEnabled && verified.unsigned) {
1306
+ this.emit('warning', {
1307
+ type: 'domain-event-rejected',
1308
+ groupId,
1309
+ reason: 'unsigned-event',
1310
+ eventId: event.eventId
1311
+ });
1312
+ return false;
1313
+ }
1314
+
1315
+ const log = this.domainEventLogs.get(groupId) || [];
1316
+ if (log.some((entry) => entry.eventId === event.eventId)) {
1317
+ return false;
1318
+ }
1319
+
1320
+ const expectedPrev = log.length > 0 ? log[log.length - 1].eventHash : null;
1321
+ if (event.prevHash !== expectedPrev) {
1322
+ this.emit('chainbroken', {
1323
+ groupId,
1324
+ expectedPrev,
1325
+ actualPrev: event.prevHash,
1326
+ eventId: event.eventId
1327
+ });
1328
+ return false;
1329
+ }
1330
+
1331
+ log.push(event);
1332
+ this.domainEventLogs.set(groupId, log);
1333
+ this.lastEventHashByGroup.set(groupId, event.eventHash);
1334
+
1335
+ if (!group.commandCapable) {
1336
+ const view = this.replicaViews.get(groupId) || createEmptyView();
1337
+ applyDomainEventToView(view, event);
1338
+ this.replicaViews.set(groupId, view);
1339
+ }
1340
+
1341
+ this.emit('domainevent', event);
1342
+ return true;
1343
+ }
1344
+
961
1345
  async publishRecordToPeerGroup(groupId, collectionName, id, options = {}) {
962
1346
  const collection = this.getCollection(collectionName);
963
1347
  const raw = collection.get(id);
@@ -1004,6 +1388,12 @@ class DignityP2P extends EventEmitter {
1004
1388
  });
1005
1389
 
1006
1390
  const group = this.peerGroups.get(groupId);
1391
+ const deliveryTier = payload.deliveryTier || 'live';
1392
+
1393
+ if (group && group.tiered && group.peerGroupTier === 'bulk' && deliveryTier !== 'bulk') {
1394
+ return;
1395
+ }
1396
+
1007
1397
  const configuredMaxHop = group ? group.maxHops : this.defaultGossipMaxHops;
1008
1398
  const maxHop = typeof payloadMaxHop === 'number'
1009
1399
  ? Math.min(payloadMaxHop, configuredMaxHop)
@@ -1013,10 +1403,18 @@ class DignityP2P extends EventEmitter {
1013
1403
  return;
1014
1404
  }
1015
1405
 
1406
+ const relayOptions = {};
1407
+ if (group.tiered) {
1408
+ relayOptions.tier = deliveryTier === 'bulk' ? 'bulk' : 'live';
1409
+ if (deliveryTier === 'bulk') {
1410
+ relayOptions.bulkRelayOnly = true;
1411
+ }
1412
+ }
1413
+
1016
1414
  const relayPeers = this.selectPeerGroupFanout(groupId, group.fanout, [
1017
1415
  decrypted.senderId,
1018
1416
  this.nodeId
1019
- ]);
1417
+ ], relayOptions);
1020
1418
 
1021
1419
  if (relayPeers.length === 0) {
1022
1420
  return;
@@ -1031,6 +1429,7 @@ class DignityP2P extends EventEmitter {
1031
1429
  publisherId,
1032
1430
  hop: hop + 1,
1033
1431
  maxHop,
1432
+ deliveryTier,
1034
1433
  innerMessageType,
1035
1434
  innerPayload
1036
1435
  }, {
@@ -1080,6 +1479,21 @@ class DignityP2P extends EventEmitter {
1080
1479
  return;
1081
1480
  }
1082
1481
 
1482
+ if (innerMessageType === 'domain:event') {
1483
+ this.ingestRemoteDomainEvent(innerPayload, context);
1484
+ return;
1485
+ }
1486
+
1487
+ if (innerMessageType === 'domain:checkpoint') {
1488
+ this.emit('peergroupmessage', {
1489
+ groupId: context.groupId,
1490
+ senderId: context.senderId,
1491
+ type: 'domain:checkpoint',
1492
+ payload: innerPayload
1493
+ });
1494
+ return;
1495
+ }
1496
+
1083
1497
  if (innerMessageType === 'record:snapshot') {
1084
1498
  const { collectionName, record } = innerPayload || {};
1085
1499
  if (collectionName && record) {
@@ -1131,6 +1545,14 @@ class DignityP2P extends EventEmitter {
1131
1545
 
1132
1546
  this.trustPeerFromMetadata(peerId, next.metadata);
1133
1547
 
1548
+ const groupId = parsePeerGroupScope(scope);
1549
+ if (groupId && this.peerGroups.has(groupId)) {
1550
+ this.refreshBulkRelays(groupId);
1551
+ if (peerId !== this.nodeId) {
1552
+ this.recalculateOwnPeerGroupTier(groupId);
1553
+ }
1554
+ }
1555
+
1134
1556
  if (!existing) {
1135
1557
  this.emit('peerdiscovered', { scope, peerId, metadata: next.metadata });
1136
1558
  }
@@ -0,0 +1,35 @@
1
+ const { getPeerTier } = require('./peer-group-tiers');
2
+
3
+ const DEFAULT_BULK_RELAY_COUNT = 3;
4
+
5
+ function electBulkRelays(peers, { count = DEFAULT_BULK_RELAY_COUNT } = {}) {
6
+ const bulkPeers = peers
7
+ .filter((peer) => getPeerTier(peer) === 'bulk')
8
+ .map((peer) => peer.peerId || peer)
9
+ .filter(Boolean)
10
+ .sort();
11
+
12
+ return bulkPeers.slice(0, Math.max(0, count));
13
+ }
14
+
15
+ function isBulkRelay(metadata) {
16
+ return metadata?.bulkRelay === true;
17
+ }
18
+
19
+ function applyBulkRelayFlags(peers, relayPeerIds) {
20
+ const relaySet = new Set(relayPeerIds);
21
+ return peers.map((peer) => ({
22
+ ...peer,
23
+ metadata: {
24
+ ...(peer.metadata || {}),
25
+ bulkRelay: relaySet.has(peer.peerId)
26
+ }
27
+ }));
28
+ }
29
+
30
+ module.exports = {
31
+ DEFAULT_BULK_RELAY_COUNT,
32
+ electBulkRelays,
33
+ isBulkRelay,
34
+ applyBulkRelayFlags
35
+ };