dignity.js 0.5.4 → 0.6.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.
@@ -2,6 +2,11 @@ const nacl = require('tweetnacl');
2
2
  const naclUtil = require('tweetnacl-util');
3
3
  const EventEmitter = require('../utils/event-emitter');
4
4
  const { MessageSecurityService, stableStringify } = require('../security/message-security-service');
5
+ const {
6
+ DEFAULT_PEER_GROUP_OPTIONS,
7
+ peerGroupScope,
8
+ selectFanoutPeers
9
+ } = require('../gossip/peer-group');
5
10
 
6
11
  function computeContentHash(data) {
7
12
  const canonical = stableStringify(data || {});
@@ -27,6 +32,7 @@ function computeContentHash(data) {
27
32
  * - broadcastMessage(type, payload, { connectToPeers, broadcastScope })
28
33
  * - pushRecordSnapshot(collection, id, options) — full record sync for late joiners
29
34
  * - getRecordPeerIds(collection, id) — owner + collaborators for connectToPeers
35
+ * - joinPeerGroup(groupId) / publishToPeerGroup(groupId, type, payload) — scalable gossip PubSub
30
36
  *
31
37
  * Authorization model:
32
38
  * - object creator is the owner
@@ -68,9 +74,29 @@ class DignityP2P extends EventEmitter {
68
74
  : 45000;
69
75
  this.discoveryRooms = new Map(); // scope -> { metadata, heartbeatIntervalMs, ttlMs, timer }
70
76
  this.presenceByScope = new Map(); // scope -> Map(peerId -> presence)
77
+ this.peerGroups = new Map(); // groupId -> PeerGroup config
78
+ this.seenGossipIds = new Map(); // gossipId -> expiresAt
79
+ this.defaultPeerGroupFanout = security && typeof security.peerGroupFanout === 'number'
80
+ ? security.peerGroupFanout
81
+ : DEFAULT_PEER_GROUP_OPTIONS.fanout;
82
+ this.defaultPeerGroupMaxActivePeers = security && typeof security.peerGroupMaxActivePeers === 'number'
83
+ ? security.peerGroupMaxActivePeers
84
+ : DEFAULT_PEER_GROUP_OPTIONS.maxActivePeers;
85
+ this.defaultGossipMaxHops = security && typeof security.gossipMaxHops === 'number'
86
+ ? security.gossipMaxHops
87
+ : DEFAULT_PEER_GROUP_OPTIONS.maxHops;
88
+ this.globalMaxOpenConnections = security && typeof security.globalMaxOpenConnections === 'number'
89
+ ? security.globalMaxOpenConnections
90
+ : 32;
91
+ this.gossipIdTtlMs = security && typeof security.gossipIdTtlMs === 'number'
92
+ ? security.gossipIdTtlMs
93
+ : 5 * 60 * 1000;
94
+ this.maxAppliedOperations = security && typeof security.maxAppliedOperations === 'number'
95
+ ? security.maxAppliedOperations
96
+ : 50000;
71
97
 
72
98
  this.state = new Map(); // collection -> Map(id -> record)
73
- this.appliedOperations = new Set();
99
+ this.appliedOperations = new Map(); // opId -> appliedAt
74
100
  this.boundMessageHandler = this.handleIncomingMessage.bind(this);
75
101
  }
76
102
 
@@ -80,6 +106,15 @@ class DignityP2P extends EventEmitter {
80
106
  }
81
107
 
82
108
  async stop() {
109
+ const joinedGroups = Array.from(this.peerGroups.keys());
110
+ for (const groupId of joinedGroups) {
111
+ try {
112
+ await this.leavePeerGroup(groupId);
113
+ } catch (error) {
114
+ this.emit('warning', { type: 'peer-group-leave-failed', groupId, error });
115
+ }
116
+ }
117
+
83
118
  const joinedScopes = Array.from(this.discoveryRooms.keys());
84
119
  for (const scope of joinedScopes) {
85
120
  // Best effort leave announce; do not fail node shutdown if network is interrupted.
@@ -510,6 +545,7 @@ class DignityP2P extends EventEmitter {
510
545
  const connectToPeers = securityContext.connectToPeers;
511
546
  if (Array.isArray(connectToPeers) && connectToPeers.length > 0) {
512
547
  await this.ensureConnectedToPeers(connectToPeers);
548
+ await this.enforceConnectionBudget();
513
549
  }
514
550
 
515
551
  const envelope = await this.securityService.secureOutgoingMessage({
@@ -518,6 +554,17 @@ class DignityP2P extends EventEmitter {
518
554
  targetId: null,
519
555
  securityContext
520
556
  });
557
+
558
+ const fanoutPeerIds = securityContext.fanoutPeerIds;
559
+ if (
560
+ Array.isArray(fanoutPeerIds)
561
+ && fanoutPeerIds.length > 0
562
+ && typeof this.networkAdapter.sendToPeers === 'function'
563
+ ) {
564
+ await this.networkAdapter.sendToPeers(envelope, fanoutPeerIds);
565
+ return;
566
+ }
567
+
521
568
  await this.networkAdapter.broadcast(envelope);
522
569
  }
523
570
 
@@ -535,9 +582,342 @@ class DignityP2P extends EventEmitter {
535
582
  payload,
536
583
  targetId
537
584
  });
585
+
586
+ if (targetId && typeof this.networkAdapter.sendToPeers === 'function') {
587
+ await this.networkAdapter.sendToPeers(envelope, [targetId]);
588
+ return;
589
+ }
590
+
538
591
  await this.networkAdapter.broadcast(envelope);
539
592
  }
540
593
 
594
+ peerGroupScopeFor(groupId) {
595
+ return peerGroupScope(groupId);
596
+ }
597
+
598
+ getPeerGroupConfig(groupId) {
599
+ return this.peerGroups.get(groupId) || null;
600
+ }
601
+
602
+ listPeerGroupMembers(groupId, options = {}) {
603
+ return this.listPeers(this.peerGroupScopeFor(groupId), options);
604
+ }
605
+
606
+ getPeerGroupStats() {
607
+ const adapter = this.networkAdapter;
608
+ const openPeerIds = typeof adapter.listOpenPeerIds === 'function'
609
+ ? adapter.listOpenPeerIds()
610
+ : [];
611
+
612
+ return {
613
+ joinedGroups: Array.from(this.peerGroups.keys()),
614
+ seenGossipCount: this.seenGossipIds.size,
615
+ openConnectionCount: openPeerIds.length,
616
+ globalMaxOpenConnections: this.globalMaxOpenConnections
617
+ };
618
+ }
619
+
620
+ pruneSeenGossip() {
621
+ const now = this.now();
622
+ for (const [gossipId, expiresAt] of this.seenGossipIds.entries()) {
623
+ if (expiresAt <= now) {
624
+ this.seenGossipIds.delete(gossipId);
625
+ }
626
+ }
627
+ }
628
+
629
+ hasSeenGossip(gossipId) {
630
+ if (!gossipId) {
631
+ return false;
632
+ }
633
+
634
+ this.pruneSeenGossip();
635
+ return this.seenGossipIds.has(gossipId);
636
+ }
637
+
638
+ markSeenGossip(gossipId) {
639
+ if (!gossipId) {
640
+ return;
641
+ }
642
+
643
+ this.seenGossipIds.set(gossipId, this.now() + this.gossipIdTtlMs);
644
+ }
645
+
646
+ listConnectedPeerIds() {
647
+ if (typeof this.networkAdapter.listOpenPeerIds === 'function') {
648
+ return this.networkAdapter.listOpenPeerIds();
649
+ }
650
+ return [];
651
+ }
652
+
653
+ selectPeerGroupFanout(groupId, count, excludePeerIds = []) {
654
+ const scope = this.peerGroupScopeFor(groupId);
655
+ const peers = this.listPeers(scope, { includeSelf: false });
656
+ return selectFanoutPeers({
657
+ peers,
658
+ count,
659
+ excludePeerIds: [...excludePeerIds, this.nodeId],
660
+ connectedPeerIds: this.listConnectedPeerIds()
661
+ });
662
+ }
663
+
664
+ async enforceConnectionBudget() {
665
+ const adapter = this.networkAdapter;
666
+ if (typeof adapter.listOpenPeerIds !== 'function' || typeof adapter.disconnectPeer !== 'function') {
667
+ return;
668
+ }
669
+
670
+ const openPeerIds = adapter.listOpenPeerIds();
671
+ if (openPeerIds.length <= this.globalMaxOpenConnections) {
672
+ return;
673
+ }
674
+
675
+ const excess = openPeerIds.length - this.globalMaxOpenConnections;
676
+ const toClose = openPeerIds.slice(0, excess);
677
+ for (const peerId of toClose) {
678
+ try {
679
+ await adapter.disconnectPeer(peerId);
680
+ } catch (error) {
681
+ this.emit('warning', { type: 'peer-disconnect-failed', peerId, error });
682
+ }
683
+ }
684
+ }
685
+
686
+ async joinPeerGroup(groupId, options = {}) {
687
+ if (!groupId) {
688
+ throw new Error('joinPeerGroup requires groupId');
689
+ }
690
+
691
+ const scope = this.peerGroupScopeFor(groupId);
692
+ const config = {
693
+ fanout: typeof options.fanout === 'number' ? options.fanout : this.defaultPeerGroupFanout,
694
+ maxActivePeers: typeof options.maxActivePeers === 'number'
695
+ ? options.maxActivePeers
696
+ : this.defaultPeerGroupMaxActivePeers,
697
+ maxHops: typeof options.maxHops === 'number' ? options.maxHops : this.defaultGossipMaxHops,
698
+ relayEnabled: options.relayEnabled !== false
699
+ };
700
+
701
+ await this.joinDiscovery(scope, {
702
+ metadata: {
703
+ peerGroup: groupId,
704
+ ...(options.metadata || {})
705
+ },
706
+ bootstrapPeerIds: options.bootstrapPeerIds,
707
+ heartbeatIntervalMs: options.heartbeatIntervalMs,
708
+ ttlMs: options.ttlMs
709
+ });
710
+
711
+ this.peerGroups.set(groupId, config);
712
+ this.emit('peergroupjoined', { groupId, config });
713
+ return config;
714
+ }
715
+
716
+ async leavePeerGroup(groupId) {
717
+ if (!groupId) {
718
+ return;
719
+ }
720
+
721
+ const scope = this.peerGroupScopeFor(groupId);
722
+ await this.leaveDiscovery(scope);
723
+ this.peerGroups.delete(groupId);
724
+ this.emit('peergroupleft', { groupId });
725
+ }
726
+
727
+ async publishToPeerGroup(groupId, innerMessageType, innerPayload, options = {}) {
728
+ if (!groupId) {
729
+ throw new Error('publishToPeerGroup requires groupId');
730
+ }
731
+
732
+ const group = this.peerGroups.get(groupId);
733
+ if (!group && options.allowUnjoined !== true) {
734
+ throw new Error(`PeerGroup ${groupId} has not been joined`);
735
+ }
736
+
737
+ const fanout = typeof options.fanout === 'number'
738
+ ? options.fanout
739
+ : (group ? group.fanout : this.defaultPeerGroupFanout);
740
+ const maxActivePeers = group ? group.maxActivePeers : this.defaultPeerGroupMaxActivePeers;
741
+ const maxHop = typeof options.maxHops === 'number'
742
+ ? options.maxHops
743
+ : (group ? group.maxHops : this.defaultGossipMaxHops);
744
+
745
+ const fanoutPeerIds = this.selectPeerGroupFanout(groupId, fanout, [this.nodeId]);
746
+ if (fanoutPeerIds.length > 0) {
747
+ await this.ensureConnectedToPeers(fanoutPeerIds.slice(0, maxActivePeers));
748
+ await this.enforceConnectionBudget();
749
+ }
750
+
751
+ const gossipId = options.gossipId || this.idGenerator();
752
+ this.markSeenGossip(gossipId);
753
+
754
+ await this.broadcastMessage('peer-group:gossip', {
755
+ groupId,
756
+ gossipId,
757
+ publisherId: this.nodeId,
758
+ hop: 0,
759
+ maxHop,
760
+ innerMessageType,
761
+ innerPayload
762
+ }, {
763
+ broadcastScope: this.peerGroupScopeFor(groupId),
764
+ fanoutPeerIds
765
+ });
766
+
767
+ return { gossipId, fanoutPeerIds };
768
+ }
769
+
770
+ async publishRecordToPeerGroup(groupId, collectionName, id, options = {}) {
771
+ const collection = this.getCollection(collectionName);
772
+ const raw = collection.get(id);
773
+ if (!raw || raw.deletedAt) {
774
+ throw new Error(`Object ${id} does not exist in ${collectionName}`);
775
+ }
776
+
777
+ const record = this.normalizeRecord(raw);
778
+ return this.publishToPeerGroup(groupId, 'record:snapshot', {
779
+ collectionName,
780
+ record
781
+ }, options);
782
+ }
783
+
784
+ async handlePeerGroupGossip(decrypted) {
785
+ const payload = decrypted.payload || {};
786
+ const {
787
+ groupId,
788
+ gossipId,
789
+ publisherId = decrypted.senderId,
790
+ hop = 0,
791
+ maxHop: payloadMaxHop,
792
+ innerMessageType,
793
+ innerPayload
794
+ } = payload;
795
+
796
+ if (!groupId || !innerMessageType || !gossipId) {
797
+ return;
798
+ }
799
+
800
+ if (!this.peerGroups.has(groupId)) {
801
+ return;
802
+ }
803
+
804
+ if (this.hasSeenGossip(gossipId)) {
805
+ return;
806
+ }
807
+
808
+ this.markSeenGossip(gossipId);
809
+ await this.dispatchPeerGroupInnerMessage(innerMessageType, innerPayload, {
810
+ groupId,
811
+ senderId: decrypted.senderId,
812
+ publisherId
813
+ });
814
+
815
+ const group = this.peerGroups.get(groupId);
816
+ const configuredMaxHop = group ? group.maxHops : this.defaultGossipMaxHops;
817
+ const maxHop = typeof payloadMaxHop === 'number'
818
+ ? Math.min(payloadMaxHop, configuredMaxHop)
819
+ : configuredMaxHop;
820
+
821
+ if (!group || group.relayEnabled === false || hop >= maxHop) {
822
+ return;
823
+ }
824
+
825
+ const relayPeers = this.selectPeerGroupFanout(groupId, group.fanout, [
826
+ decrypted.senderId,
827
+ this.nodeId
828
+ ]);
829
+
830
+ if (relayPeers.length === 0) {
831
+ return;
832
+ }
833
+
834
+ await this.ensureConnectedToPeers(relayPeers.slice(0, group.maxActivePeers));
835
+ await this.enforceConnectionBudget();
836
+
837
+ await this.broadcastMessage('peer-group:gossip', {
838
+ groupId,
839
+ gossipId,
840
+ publisherId,
841
+ hop: hop + 1,
842
+ maxHop,
843
+ innerMessageType,
844
+ innerPayload
845
+ }, {
846
+ broadcastScope: this.peerGroupScopeFor(groupId),
847
+ fanoutPeerIds: relayPeers
848
+ });
849
+ }
850
+
851
+ normalizeGossipOperation(operation, publisherId) {
852
+ if (!operation || !publisherId) {
853
+ return null;
854
+ }
855
+
856
+ if (operation.actorId && operation.actorId !== publisherId) {
857
+ this.emit('warning', {
858
+ type: 'gossip-operation-actor-mismatch',
859
+ publisherId,
860
+ actorId: operation.actorId,
861
+ kind: operation.kind,
862
+ collection: operation.collectionName,
863
+ id: operation.id
864
+ });
865
+ return null;
866
+ }
867
+
868
+ const normalized = {
869
+ ...operation,
870
+ actorId: publisherId
871
+ };
872
+
873
+ if (normalized.kind === 'create') {
874
+ normalized.ownerId = publisherId;
875
+ }
876
+
877
+ return normalized;
878
+ }
879
+
880
+ async dispatchPeerGroupInnerMessage(innerMessageType, innerPayload, context = {}) {
881
+ if (innerMessageType === 'operation') {
882
+ const operation = this.normalizeGossipOperation(
883
+ innerPayload,
884
+ context.publisherId || context.senderId
885
+ );
886
+ if (operation) {
887
+ this.applyOperation(operation);
888
+ }
889
+ return;
890
+ }
891
+
892
+ if (innerMessageType === 'record:snapshot') {
893
+ const { collectionName, record } = innerPayload || {};
894
+ if (collectionName && record) {
895
+ const applied = this.restoreRecord(collectionName, record, {
896
+ rejectOnHashMismatch: true,
897
+ rejectOnOwnershipMismatch: true,
898
+ via: 'peer-group'
899
+ });
900
+ if (applied) {
901
+ this.emit('change', {
902
+ kind: 'snapshot',
903
+ collection: collectionName,
904
+ id: record.id,
905
+ via: 'peer-group',
906
+ groupId: context.groupId
907
+ });
908
+ }
909
+ }
910
+ return;
911
+ }
912
+
913
+ this.emit('peergroupmessage', {
914
+ groupId: context.groupId,
915
+ senderId: context.senderId,
916
+ type: innerMessageType,
917
+ payload: innerPayload
918
+ });
919
+ }
920
+
541
921
  getPresenceMap(scope) {
542
922
  if (!this.presenceByScope.has(scope)) {
543
923
  this.presenceByScope.set(scope, new Map());
@@ -695,8 +1075,16 @@ class DignityP2P extends EventEmitter {
695
1075
  }
696
1076
 
697
1077
  async handleIncomingMessage(message) {
698
- // Backward compatibility for raw operation payloads
1078
+ // Backward compatibility for raw operation payloads (in-memory tests only)
699
1079
  if (message && message.opId && message.kind) {
1080
+ if (this.securityService.options.enabled) {
1081
+ this.emit('messageignored', {
1082
+ reason: 'raw-operation-rejected',
1083
+ hint: 'Unsigned raw operations are disabled when security is enabled'
1084
+ });
1085
+ return;
1086
+ }
1087
+
700
1088
  this.applyOperation(message);
701
1089
  return;
702
1090
  }
@@ -709,10 +1097,6 @@ class DignityP2P extends EventEmitter {
709
1097
  return;
710
1098
  }
711
1099
 
712
- if (message && message.senderId && message.senderPublicKey) {
713
- this.trustPeerPublicKey(message.senderId, message.senderPublicKey);
714
- }
715
-
716
1100
  let decrypted;
717
1101
  try {
718
1102
  decrypted = await this.securityService.decryptIncomingMessage(message);
@@ -733,6 +1117,10 @@ class DignityP2P extends EventEmitter {
733
1117
  return;
734
1118
  }
735
1119
 
1120
+ if (message && message.senderId && message.senderPublicKey) {
1121
+ this.trustPeerPublicKey(message.senderId, message.senderPublicKey);
1122
+ }
1123
+
736
1124
  if (decrypted.messageType === 'operation') {
737
1125
  this.applyOperation(decrypted.payload);
738
1126
  return;
@@ -759,21 +1147,32 @@ class DignityP2P extends EventEmitter {
759
1147
  const payload = decrypted.payload || {};
760
1148
  const scope = payload.scope || 'main';
761
1149
  const peerId = payload.peerId || decrypted.senderId;
762
- if (!peerId) {
1150
+ if (!peerId || peerId !== decrypted.senderId) {
1151
+ return;
1152
+ }
1153
+
1154
+ if (!this.discoveryRooms.has(scope)) {
763
1155
  return;
764
1156
  }
765
1157
 
1158
+ const room = this.discoveryRooms.get(scope);
766
1159
  const presenceMap = this.getPresenceMap(scope);
767
1160
  const isNewPeerInScope = !presenceMap.has(peerId);
1161
+ const requestedTtl = typeof payload.ttlMs === 'number' ? payload.ttlMs : room.ttlMs;
1162
+ const ttlMs = Math.min(requestedTtl, room.ttlMs);
768
1163
 
769
1164
  this.upsertPresence(
770
1165
  scope,
771
1166
  peerId,
772
1167
  payload.metadata || {},
773
- payload.ttlMs || this.defaultPresenceTtlMs,
774
- payload.announcedAt || this.now()
1168
+ ttlMs,
1169
+ this.now()
775
1170
  );
776
1171
 
1172
+ if (payload.metadata && payload.metadata.publicKey) {
1173
+ this.trustPeerPublicKey(peerId, payload.metadata.publicKey);
1174
+ }
1175
+
777
1176
  // Discovery handshake: when a new peer appears in a joined scope,
778
1177
  // send our current presence so late joiners quickly converge.
779
1178
  if (isNewPeerInScope && peerId !== this.nodeId && this.discoveryRooms.has(scope)) {
@@ -794,6 +1193,10 @@ class DignityP2P extends EventEmitter {
794
1193
  const payload = decrypted.payload || {};
795
1194
  const scope = payload.scope || 'main';
796
1195
  const peerId = payload.peerId || decrypted.senderId;
1196
+ if (!peerId || peerId !== decrypted.senderId) {
1197
+ return;
1198
+ }
1199
+
797
1200
  const map = this.presenceByScope.get(scope);
798
1201
  if (map && peerId && map.has(peerId)) {
799
1202
  map.delete(peerId);
@@ -802,6 +1205,11 @@ class DignityP2P extends EventEmitter {
802
1205
  return;
803
1206
  }
804
1207
 
1208
+ if (decrypted.messageType === 'peer-group:gossip') {
1209
+ await this.handlePeerGroupGossip(decrypted);
1210
+ return;
1211
+ }
1212
+
805
1213
  this.emit('message', {
806
1214
  senderId: decrypted.senderId,
807
1215
  targetId: decrypted.targetId,
@@ -857,7 +1265,7 @@ class DignityP2P extends EventEmitter {
857
1265
  this.emit('conflict', details);
858
1266
  }
859
1267
 
860
- restoreRecord(collectionName, record) {
1268
+ restoreRecord(collectionName, record, options = {}) {
861
1269
  if (!record || !record.id) {
862
1270
  return false;
863
1271
  }
@@ -870,14 +1278,49 @@ class DignityP2P extends EventEmitter {
870
1278
 
871
1279
  const restoredData = { ...(record.data || {}) };
872
1280
  const computedHash = computeContentHash(restoredData);
873
- if (record.hash && record.hash !== computedHash) {
1281
+ const rejectOnHashMismatch = options.rejectOnHashMismatch === true;
1282
+ const rejectOnOwnershipMismatch = options.rejectOnOwnershipMismatch === true;
1283
+
1284
+ if (
1285
+ rejectOnOwnershipMismatch
1286
+ && current
1287
+ && record.ownerId
1288
+ && current.ownerId !== record.ownerId
1289
+ ) {
1290
+ this.emit('warning', {
1291
+ type: 'ownership-mismatch',
1292
+ collection: collectionName,
1293
+ id: record.id,
1294
+ currentOwnerId: current.ownerId,
1295
+ advertisedOwnerId: record.ownerId,
1296
+ via: options.via || null
1297
+ });
1298
+ return false;
1299
+ }
1300
+
1301
+ if (!record.hash) {
1302
+ const warning = {
1303
+ type: 'content-hash-missing',
1304
+ collection: collectionName,
1305
+ id: record.id,
1306
+ via: options.via || null
1307
+ };
1308
+ this.emit('warning', warning);
1309
+ if (rejectOnHashMismatch) {
1310
+ return false;
1311
+ }
1312
+ } else if (record.hash !== computedHash) {
874
1313
  this.emit('warning', {
875
1314
  type: 'content-hash-mismatch',
876
1315
  collection: collectionName,
877
1316
  id: record.id,
878
1317
  advertisedHash: record.hash,
879
- computedHash
1318
+ computedHash,
1319
+ via: options.via || null
880
1320
  });
1321
+ if (rejectOnHashMismatch) {
1322
+ return false;
1323
+ }
881
1324
  }
882
1325
 
883
1326
  collection.set(record.id, {
@@ -927,6 +1370,16 @@ class DignityP2P extends EventEmitter {
927
1370
  return record;
928
1371
  }
929
1372
 
1373
+ pruneAppliedOperations() {
1374
+ while (this.appliedOperations.size > this.maxAppliedOperations) {
1375
+ const oldestOpId = this.appliedOperations.keys().next().value;
1376
+ if (!oldestOpId) {
1377
+ break;
1378
+ }
1379
+ this.appliedOperations.delete(oldestOpId);
1380
+ }
1381
+ }
1382
+
930
1383
  applyOperation(operation) {
931
1384
  if (!operation || !operation.opId || this.appliedOperations.has(operation.opId)) {
932
1385
  return false;
@@ -952,7 +1405,8 @@ class DignityP2P extends EventEmitter {
952
1405
  version: 1
953
1406
  });
954
1407
 
955
- this.appliedOperations.add(operation.opId);
1408
+ this.appliedOperations.set(operation.opId, this.now());
1409
+ this.pruneAppliedOperations();
956
1410
  this.emit('change', { kind: 'create', collection: operation.collectionName, id: operation.id });
957
1411
  return true;
958
1412
  }
@@ -1003,7 +1457,8 @@ class DignityP2P extends EventEmitter {
1003
1457
  current.updatedAt = operation.timestamp;
1004
1458
  current.version += 1;
1005
1459
 
1006
- this.appliedOperations.add(operation.opId);
1460
+ this.appliedOperations.set(operation.opId, this.now());
1461
+ this.pruneAppliedOperations();
1007
1462
  this.emit('change', {
1008
1463
  kind: 'transfer-ownership',
1009
1464
  collection: operation.collectionName,
@@ -1036,7 +1491,8 @@ class DignityP2P extends EventEmitter {
1036
1491
  current.updatedAt = operation.timestamp;
1037
1492
  current.version += 1;
1038
1493
 
1039
- this.appliedOperations.add(operation.opId);
1494
+ this.appliedOperations.set(operation.opId, this.now());
1495
+ this.pruneAppliedOperations();
1040
1496
  this.emit('change', { kind: 'delete', collection: operation.collectionName, id: operation.id });
1041
1497
  return true;
1042
1498
  }
@@ -1072,7 +1528,8 @@ class DignityP2P extends EventEmitter {
1072
1528
  current.updatedAt = operation.timestamp;
1073
1529
  current.version += 1;
1074
1530
 
1075
- this.appliedOperations.add(operation.opId);
1531
+ this.appliedOperations.set(operation.opId, this.now());
1532
+ this.pruneAppliedOperations();
1076
1533
  this.emit('change', { kind: 'update', collection: operation.collectionName, id: operation.id });
1077
1534
  return true;
1078
1535
  }
@@ -0,0 +1,64 @@
1
+ const PEER_GROUP_SCOPE_PREFIX = 'gossip:';
2
+
3
+ const DEFAULT_PEER_GROUP_OPTIONS = {
4
+ fanout: 3,
5
+ maxActivePeers: 8,
6
+ maxHops: 6,
7
+ relayEnabled: true
8
+ };
9
+
10
+ function peerGroupScope(groupId) {
11
+ if (!groupId) {
12
+ throw new Error('peerGroupScope requires groupId');
13
+ }
14
+ return `${PEER_GROUP_SCOPE_PREFIX}${groupId}`;
15
+ }
16
+
17
+ function parsePeerGroupScope(scope) {
18
+ if (!scope || !scope.startsWith(PEER_GROUP_SCOPE_PREFIX)) {
19
+ return null;
20
+ }
21
+ return scope.slice(PEER_GROUP_SCOPE_PREFIX.length);
22
+ }
23
+
24
+ function shufflePeerIds(peerIds, randomFn = Math.random) {
25
+ const list = [...peerIds];
26
+ for (let i = list.length - 1; i > 0; i -= 1) {
27
+ const j = Math.floor(randomFn() * (i + 1));
28
+ [list[i], list[j]] = [list[j], list[i]];
29
+ }
30
+ return list;
31
+ }
32
+
33
+ function selectFanoutPeers({
34
+ peers,
35
+ count,
36
+ excludePeerIds = [],
37
+ connectedPeerIds = [],
38
+ randomFn = Math.random
39
+ }) {
40
+ const excluded = new Set(excludePeerIds.filter(Boolean));
41
+ const candidates = peers
42
+ .map((entry) => entry.peerId || entry)
43
+ .filter((peerId) => peerId && !excluded.has(peerId));
44
+
45
+ const connected = new Set(connectedPeerIds.filter(Boolean));
46
+ const preferred = candidates.filter((peerId) => connected.has(peerId));
47
+ const others = candidates.filter((peerId) => !connected.has(peerId));
48
+
49
+ const ordered = [
50
+ ...shufflePeerIds(preferred, randomFn),
51
+ ...shufflePeerIds(others, randomFn)
52
+ ];
53
+
54
+ return ordered.slice(0, Math.max(0, count));
55
+ }
56
+
57
+ module.exports = {
58
+ PEER_GROUP_SCOPE_PREFIX,
59
+ DEFAULT_PEER_GROUP_OPTIONS,
60
+ peerGroupScope,
61
+ parsePeerGroupScope,
62
+ shufflePeerIds,
63
+ selectFanoutPeers
64
+ };