dignity.js 0.5.3 → 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.
@@ -1,5 +1,20 @@
1
+ const nacl = require('tweetnacl');
2
+ const naclUtil = require('tweetnacl-util');
1
3
  const EventEmitter = require('../utils/event-emitter');
2
- const { MessageSecurityService } = require('../security/message-security-service');
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');
10
+
11
+ function computeContentHash(data) {
12
+ const canonical = stableStringify(data || {});
13
+ const bytes = naclUtil.decodeUTF8(canonical);
14
+ const hash = nacl.hash(bytes);
15
+ const hex = Array.from(hash, (b) => b.toString(16).padStart(2, '0')).join('');
16
+ return `sha512:${hex}`;
17
+ }
3
18
 
4
19
  /**
5
20
  * Core node API for replicated object collections.
@@ -17,6 +32,7 @@ const { MessageSecurityService } = require('../security/message-security-service
17
32
  * - broadcastMessage(type, payload, { connectToPeers, broadcastScope })
18
33
  * - pushRecordSnapshot(collection, id, options) — full record sync for late joiners
19
34
  * - getRecordPeerIds(collection, id) — owner + collaborators for connectToPeers
35
+ * - joinPeerGroup(groupId) / publishToPeerGroup(groupId, type, payload) — scalable gossip PubSub
20
36
  *
21
37
  * Authorization model:
22
38
  * - object creator is the owner
@@ -58,9 +74,29 @@ class DignityP2P extends EventEmitter {
58
74
  : 45000;
59
75
  this.discoveryRooms = new Map(); // scope -> { metadata, heartbeatIntervalMs, ttlMs, timer }
60
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;
61
97
 
62
98
  this.state = new Map(); // collection -> Map(id -> record)
63
- this.appliedOperations = new Set();
99
+ this.appliedOperations = new Map(); // opId -> appliedAt
64
100
  this.boundMessageHandler = this.handleIncomingMessage.bind(this);
65
101
  }
66
102
 
@@ -70,6 +106,15 @@ class DignityP2P extends EventEmitter {
70
106
  }
71
107
 
72
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
+
73
118
  const joinedScopes = Array.from(this.discoveryRooms.keys());
74
119
  for (const scope of joinedScopes) {
75
120
  // Best effort leave announce; do not fail node shutdown if network is interrupted.
@@ -101,6 +146,8 @@ class DignityP2P extends EventEmitter {
101
146
  return null;
102
147
  }
103
148
 
149
+ const normalizedData = { ...(record.data || {}) };
150
+
104
151
  return {
105
152
  id: record.id,
106
153
  ownerId: record.ownerId,
@@ -108,7 +155,8 @@ class DignityP2P extends EventEmitter {
108
155
  createdAt: record.createdAt,
109
156
  updatedAt: record.updatedAt,
110
157
  version: record.version,
111
- data: { ...record.data }
158
+ hash: record.hash || computeContentHash(normalizedData),
159
+ data: normalizedData
112
160
  };
113
161
  }
114
162
 
@@ -204,7 +252,7 @@ class DignityP2P extends EventEmitter {
204
252
  ownerId: this.nodeId,
205
253
  collaboratorIds,
206
254
  timestamp,
207
- payload: { ...data }
255
+ payload: { ...(data || {}) }
208
256
  };
209
257
 
210
258
  this.applyOperation(operation);
@@ -497,6 +545,7 @@ class DignityP2P extends EventEmitter {
497
545
  const connectToPeers = securityContext.connectToPeers;
498
546
  if (Array.isArray(connectToPeers) && connectToPeers.length > 0) {
499
547
  await this.ensureConnectedToPeers(connectToPeers);
548
+ await this.enforceConnectionBudget();
500
549
  }
501
550
 
502
551
  const envelope = await this.securityService.secureOutgoingMessage({
@@ -505,6 +554,17 @@ class DignityP2P extends EventEmitter {
505
554
  targetId: null,
506
555
  securityContext
507
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
+
508
568
  await this.networkAdapter.broadcast(envelope);
509
569
  }
510
570
 
@@ -522,9 +582,342 @@ class DignityP2P extends EventEmitter {
522
582
  payload,
523
583
  targetId
524
584
  });
585
+
586
+ if (targetId && typeof this.networkAdapter.sendToPeers === 'function') {
587
+ await this.networkAdapter.sendToPeers(envelope, [targetId]);
588
+ return;
589
+ }
590
+
525
591
  await this.networkAdapter.broadcast(envelope);
526
592
  }
527
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
+
528
921
  getPresenceMap(scope) {
529
922
  if (!this.presenceByScope.has(scope)) {
530
923
  this.presenceByScope.set(scope, new Map());
@@ -682,8 +1075,16 @@ class DignityP2P extends EventEmitter {
682
1075
  }
683
1076
 
684
1077
  async handleIncomingMessage(message) {
685
- // Backward compatibility for raw operation payloads
1078
+ // Backward compatibility for raw operation payloads (in-memory tests only)
686
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
+
687
1088
  this.applyOperation(message);
688
1089
  return;
689
1090
  }
@@ -696,10 +1097,6 @@ class DignityP2P extends EventEmitter {
696
1097
  return;
697
1098
  }
698
1099
 
699
- if (message && message.senderId && message.senderPublicKey) {
700
- this.trustPeerPublicKey(message.senderId, message.senderPublicKey);
701
- }
702
-
703
1100
  let decrypted;
704
1101
  try {
705
1102
  decrypted = await this.securityService.decryptIncomingMessage(message);
@@ -720,6 +1117,10 @@ class DignityP2P extends EventEmitter {
720
1117
  return;
721
1118
  }
722
1119
 
1120
+ if (message && message.senderId && message.senderPublicKey) {
1121
+ this.trustPeerPublicKey(message.senderId, message.senderPublicKey);
1122
+ }
1123
+
723
1124
  if (decrypted.messageType === 'operation') {
724
1125
  this.applyOperation(decrypted.payload);
725
1126
  return;
@@ -746,21 +1147,32 @@ class DignityP2P extends EventEmitter {
746
1147
  const payload = decrypted.payload || {};
747
1148
  const scope = payload.scope || 'main';
748
1149
  const peerId = payload.peerId || decrypted.senderId;
749
- if (!peerId) {
1150
+ if (!peerId || peerId !== decrypted.senderId) {
1151
+ return;
1152
+ }
1153
+
1154
+ if (!this.discoveryRooms.has(scope)) {
750
1155
  return;
751
1156
  }
752
1157
 
1158
+ const room = this.discoveryRooms.get(scope);
753
1159
  const presenceMap = this.getPresenceMap(scope);
754
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);
755
1163
 
756
1164
  this.upsertPresence(
757
1165
  scope,
758
1166
  peerId,
759
1167
  payload.metadata || {},
760
- payload.ttlMs || this.defaultPresenceTtlMs,
761
- payload.announcedAt || this.now()
1168
+ ttlMs,
1169
+ this.now()
762
1170
  );
763
1171
 
1172
+ if (payload.metadata && payload.metadata.publicKey) {
1173
+ this.trustPeerPublicKey(peerId, payload.metadata.publicKey);
1174
+ }
1175
+
764
1176
  // Discovery handshake: when a new peer appears in a joined scope,
765
1177
  // send our current presence so late joiners quickly converge.
766
1178
  if (isNewPeerInScope && peerId !== this.nodeId && this.discoveryRooms.has(scope)) {
@@ -781,6 +1193,10 @@ class DignityP2P extends EventEmitter {
781
1193
  const payload = decrypted.payload || {};
782
1194
  const scope = payload.scope || 'main';
783
1195
  const peerId = payload.peerId || decrypted.senderId;
1196
+ if (!peerId || peerId !== decrypted.senderId) {
1197
+ return;
1198
+ }
1199
+
784
1200
  const map = this.presenceByScope.get(scope);
785
1201
  if (map && peerId && map.has(peerId)) {
786
1202
  map.delete(peerId);
@@ -789,6 +1205,11 @@ class DignityP2P extends EventEmitter {
789
1205
  return;
790
1206
  }
791
1207
 
1208
+ if (decrypted.messageType === 'peer-group:gossip') {
1209
+ await this.handlePeerGroupGossip(decrypted);
1210
+ return;
1211
+ }
1212
+
792
1213
  this.emit('message', {
793
1214
  senderId: decrypted.senderId,
794
1215
  targetId: decrypted.targetId,
@@ -844,7 +1265,7 @@ class DignityP2P extends EventEmitter {
844
1265
  this.emit('conflict', details);
845
1266
  }
846
1267
 
847
- restoreRecord(collectionName, record) {
1268
+ restoreRecord(collectionName, record, options = {}) {
848
1269
  if (!record || !record.id) {
849
1270
  return false;
850
1271
  }
@@ -855,11 +1276,59 @@ class DignityP2P extends EventEmitter {
855
1276
  return false;
856
1277
  }
857
1278
 
1279
+ const restoredData = { ...(record.data || {}) };
1280
+ const computedHash = computeContentHash(restoredData);
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) {
1313
+ this.emit('warning', {
1314
+ type: 'content-hash-mismatch',
1315
+ collection: collectionName,
1316
+ id: record.id,
1317
+ advertisedHash: record.hash,
1318
+ computedHash,
1319
+ via: options.via || null
1320
+ });
1321
+ if (rejectOnHashMismatch) {
1322
+ return false;
1323
+ }
1324
+ }
1325
+
858
1326
  collection.set(record.id, {
859
1327
  id: record.id,
860
1328
  ownerId: record.ownerId,
861
1329
  collaboratorIds: this.normalizeCollaboratorIds(record.collaboratorIds),
862
- data: { ...(record.data || {}) },
1330
+ data: restoredData,
1331
+ hash: computedHash,
863
1332
  createdAt: record.createdAt,
864
1333
  updatedAt: record.updatedAt,
865
1334
  deletedAt: record.deletedAt || null,
@@ -881,7 +1350,8 @@ class DignityP2P extends EventEmitter {
881
1350
  id: raw.id,
882
1351
  ownerId: raw.ownerId,
883
1352
  collaboratorIds: Array.isArray(raw.collaboratorIds) ? [...raw.collaboratorIds] : [],
884
- data: { ...raw.data },
1353
+ data: { ...(raw.data || {}) },
1354
+ hash: raw.hash || computeContentHash(raw.data || {}),
885
1355
  createdAt: raw.createdAt,
886
1356
  updatedAt: raw.updatedAt,
887
1357
  deletedAt: raw.deletedAt || null,
@@ -900,6 +1370,16 @@ class DignityP2P extends EventEmitter {
900
1370
  return record;
901
1371
  }
902
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
+
903
1383
  applyOperation(operation) {
904
1384
  if (!operation || !operation.opId || this.appliedOperations.has(operation.opId)) {
905
1385
  return false;
@@ -917,14 +1397,16 @@ class DignityP2P extends EventEmitter {
917
1397
  id: operation.id,
918
1398
  ownerId: operation.ownerId,
919
1399
  collaboratorIds: this.normalizeCollaboratorIds(operation.collaboratorIds),
920
- data: { ...operation.payload },
1400
+ data: { ...(operation.payload || {}) },
1401
+ hash: computeContentHash(operation.payload || {}),
921
1402
  createdAt: operation.timestamp,
922
1403
  updatedAt: operation.timestamp,
923
1404
  deletedAt: null,
924
1405
  version: 1
925
1406
  });
926
1407
 
927
- this.appliedOperations.add(operation.opId);
1408
+ this.appliedOperations.set(operation.opId, this.now());
1409
+ this.pruneAppliedOperations();
928
1410
  this.emit('change', { kind: 'create', collection: operation.collectionName, id: operation.id });
929
1411
  return true;
930
1412
  }
@@ -975,7 +1457,8 @@ class DignityP2P extends EventEmitter {
975
1457
  current.updatedAt = operation.timestamp;
976
1458
  current.version += 1;
977
1459
 
978
- this.appliedOperations.add(operation.opId);
1460
+ this.appliedOperations.set(operation.opId, this.now());
1461
+ this.pruneAppliedOperations();
979
1462
  this.emit('change', {
980
1463
  kind: 'transfer-ownership',
981
1464
  collection: operation.collectionName,
@@ -1008,7 +1491,8 @@ class DignityP2P extends EventEmitter {
1008
1491
  current.updatedAt = operation.timestamp;
1009
1492
  current.version += 1;
1010
1493
 
1011
- this.appliedOperations.add(operation.opId);
1494
+ this.appliedOperations.set(operation.opId, this.now());
1495
+ this.pruneAppliedOperations();
1012
1496
  this.emit('change', { kind: 'delete', collection: operation.collectionName, id: operation.id });
1013
1497
  return true;
1014
1498
  }
@@ -1035,6 +1519,7 @@ class DignityP2P extends EventEmitter {
1035
1519
  ...current.data,
1036
1520
  ...operation.payload
1037
1521
  };
1522
+ current.hash = computeContentHash(current.data);
1038
1523
 
1039
1524
  if (Array.isArray(operation.collaboratorIds) && operation.actorId === current.ownerId) {
1040
1525
  current.collaboratorIds = this.normalizeCollaboratorIds(operation.collaboratorIds);
@@ -1043,7 +1528,8 @@ class DignityP2P extends EventEmitter {
1043
1528
  current.updatedAt = operation.timestamp;
1044
1529
  current.version += 1;
1045
1530
 
1046
- this.appliedOperations.add(operation.opId);
1531
+ this.appliedOperations.set(operation.opId, this.now());
1532
+ this.pruneAppliedOperations();
1047
1533
  this.emit('change', { kind: 'update', collection: operation.collectionName, id: operation.id });
1048
1534
  return true;
1049
1535
  }