LiteDbOperatorEventStore.cs
1 using GUNRPG.Application.Operators; 2 using GUNRPG.Core.Operators; 3 using LiteDB; 4 5 namespace GUNRPG.Infrastructure.Persistence; 6 7 /// <summary> 8 /// LiteDB-backed implementation of IOperatorEventStore. 9 /// Stores operator events with hash chain integrity verification. 10 /// Thread-safe for concurrent requests. 11 /// </summary> 12 public sealed class LiteDbOperatorEventStore : IOperatorEventStore 13 { 14 private readonly ILiteCollection<OperatorEventDocument> _events; 15 private readonly LiteDatabase _database; 16 17 public LiteDbOperatorEventStore(LiteDatabase database) 18 { 19 _database = database ?? throw new ArgumentNullException(nameof(database)); 20 _events = _database.GetCollection<OperatorEventDocument>("operator_events"); 21 22 // Create indexes for efficient queries 23 _events.EnsureIndex(x => x.OperatorId); 24 _events.EnsureIndex(x => x.SequenceNumber); 25 _events.EnsureIndex(x => x.AccountId); 26 // Create unique compound index with explicit name to enforce ordering 27 _events.EnsureIndex("idx_op_seq", x => new { x.OperatorId, x.SequenceNumber }, true); 28 } 29 30 public Task AppendEventAsync(OperatorEvent evt) 31 { 32 if (evt == null) 33 throw new ArgumentNullException(nameof(evt)); 34 35 // Verify hash integrity before storing 36 if (!evt.VerifyHash()) 37 throw new InvalidOperationException("Cannot append event with invalid hash"); 38 39 // Validate sequence-0 (genesis event) invariants 40 if (evt.SequenceNumber == 0) 41 { 42 if (evt.PreviousHash != string.Empty) 43 throw new InvalidOperationException( 44 "Genesis event (sequence 0) must have empty previous hash"); 45 } 46 47 // Check if this sequence already exists 48 var existing = _events.FindOne(doc => 49 doc.OperatorId == evt.OperatorId.Value && 50 doc.SequenceNumber == evt.SequenceNumber); 51 52 if (existing != null) 53 throw new InvalidOperationException( 54 $"Event with sequence {evt.SequenceNumber} already exists for operator {evt.OperatorId}"); 55 56 // If not the first event, verify chain integrity 57 if (evt.SequenceNumber > 0) 58 { 59 var previousEvent = _events.FindOne(doc => 60 doc.OperatorId == evt.OperatorId.Value && 61 doc.SequenceNumber == evt.SequenceNumber - 1); 62 63 if (previousEvent == null) 64 throw new InvalidOperationException( 65 $"Cannot append event at sequence {evt.SequenceNumber}. Previous event not found."); 66 67 if (previousEvent.Hash != evt.PreviousHash) 68 throw new InvalidOperationException( 69 $"Hash chain broken at sequence {evt.SequenceNumber}. Previous hash mismatch."); 70 } 71 72 // Map to document and insert 73 var document = new OperatorEventDocument 74 { 75 OperatorId = evt.OperatorId.Value, 76 SequenceNumber = evt.SequenceNumber, 77 EventType = evt.EventType, 78 Payload = evt.Payload, 79 PreviousHash = evt.PreviousHash, 80 Hash = evt.Hash, 81 Timestamp = evt.Timestamp 82 }; 83 84 _events.Insert(document); 85 return Task.CompletedTask; 86 } 87 88 public Task AppendEventsAsync(IReadOnlyList<OperatorEvent> events) 89 { 90 if (events == null) 91 throw new ArgumentNullException(nameof(events)); 92 93 if (events.Count == 0) 94 return Task.CompletedTask; 95 96 // Validate all events belong to same operator 97 var operatorId = events[0].OperatorId; 98 if (events.Any(e => e.OperatorId != operatorId)) 99 throw new InvalidOperationException("All events in batch must belong to the same operator"); 100 101 // Validate events are in sequence order 102 for (int i = 1; i < events.Count; i++) 103 { 104 if (events[i].SequenceNumber != events[i - 1].SequenceNumber + 1) 105 throw new InvalidOperationException( 106 $"Events must be in sequential order. Expected sequence {events[i - 1].SequenceNumber + 1}, got {events[i].SequenceNumber}"); 107 } 108 109 // Use transaction to ensure atomicity 110 _database.BeginTrans(); 111 try 112 { 113 foreach (var evt in events) 114 { 115 // Verify hash integrity before storing 116 if (!evt.VerifyHash()) 117 throw new InvalidOperationException($"Cannot append event with invalid hash at sequence {evt.SequenceNumber}"); 118 119 // Validate sequence-0 (genesis event) invariants 120 if (evt.SequenceNumber == 0) 121 { 122 if (evt.PreviousHash != string.Empty) 123 throw new InvalidOperationException( 124 "Genesis event (sequence 0) must have empty previous hash"); 125 } 126 127 // Check if this sequence already exists 128 var existing = _events.FindOne(doc => 129 doc.OperatorId == evt.OperatorId.Value && 130 doc.SequenceNumber == evt.SequenceNumber); 131 132 if (existing != null) 133 throw new InvalidOperationException( 134 $"Event with sequence {evt.SequenceNumber} already exists for operator {evt.OperatorId}"); 135 136 // If not the first event, verify chain integrity 137 if (evt.SequenceNumber > 0) 138 { 139 var previousEvent = _events.FindOne(doc => 140 doc.OperatorId == evt.OperatorId.Value && 141 doc.SequenceNumber == evt.SequenceNumber - 1); 142 143 if (previousEvent == null) 144 throw new InvalidOperationException( 145 $"Cannot append event at sequence {evt.SequenceNumber}. Previous event not found."); 146 147 if (previousEvent.Hash != evt.PreviousHash) 148 throw new InvalidOperationException( 149 $"Hash chain broken at sequence {evt.SequenceNumber}. Previous hash mismatch."); 150 } 151 152 // Map to document and insert 153 var document = new OperatorEventDocument 154 { 155 OperatorId = evt.OperatorId.Value, 156 SequenceNumber = evt.SequenceNumber, 157 EventType = evt.EventType, 158 Payload = evt.Payload, 159 PreviousHash = evt.PreviousHash, 160 Hash = evt.Hash, 161 Timestamp = evt.Timestamp 162 }; 163 164 _events.Insert(document); 165 } 166 167 _database.Commit(); 168 } 169 catch 170 { 171 _database.Rollback(); 172 throw; 173 } 174 175 return Task.CompletedTask; 176 } 177 178 public Task<IReadOnlyList<OperatorEvent>> LoadEventsAsync(OperatorId operatorId) 179 { 180 // Load all events for this operator, ordered by sequence 181 var documents = _events 182 .Find(doc => doc.OperatorId == operatorId.Value) 183 .OrderBy(doc => doc.SequenceNumber) 184 .ToList(); 185 186 // Map to domain events and verify chain, with automatic rollback on corruption 187 var events = new List<OperatorEvent>(); 188 OperatorEvent? previousEvent = null; 189 190 foreach (var doc in documents) 191 { 192 var evt = MapToDomainEvent(doc); 193 194 // Verify the rehydrated event's hash matches what was stored 195 if (evt.Hash != doc.Hash) 196 { 197 // Corruption detected - rollback to last valid event 198 // TODO: Add logging/metrics to track rollback incidents for diagnostics 199 RollbackInvalidEvents(operatorId, doc.SequenceNumber); 200 break; 201 } 202 203 // Verify hash computation is correct 204 if (!evt.VerifyHash()) 205 { 206 // Corruption detected - rollback to last valid event 207 // TODO: Add logging/metrics to track rollback incidents for diagnostics 208 RollbackInvalidEvents(operatorId, doc.SequenceNumber); 209 break; 210 } 211 212 // Verify chain 213 if (!evt.VerifyChain(previousEvent)) 214 { 215 // Chain broken - rollback to last valid event 216 // TODO: Add logging/metrics to track rollback incidents for diagnostics 217 RollbackInvalidEvents(operatorId, doc.SequenceNumber); 218 break; 219 } 220 221 events.Add(evt); 222 previousEvent = evt; 223 } 224 225 IReadOnlyList<OperatorEvent> result = events; 226 return Task.FromResult(result); 227 } 228 229 /// <summary> 230 /// Rolls back (deletes) all events at or after the specified sequence number. 231 /// This is called when hash chain verification fails to discard corrupted events. 232 /// </summary> 233 private void RollbackInvalidEvents(OperatorId operatorId, long fromSequence) 234 { 235 // Delete all events from this sequence onwards 236 _events.DeleteMany(doc => 237 doc.OperatorId == operatorId.Value && 238 doc.SequenceNumber >= fromSequence); 239 } 240 241 public Task<bool> OperatorExistsAsync(OperatorId operatorId) 242 { 243 var exists = _events.Exists(doc => doc.OperatorId == operatorId.Value); 244 return Task.FromResult(exists); 245 } 246 247 public Task<long> GetCurrentSequenceAsync(OperatorId operatorId) 248 { 249 var maxSequence = _events 250 .Find(doc => doc.OperatorId == operatorId.Value) 251 .Select(doc => doc.SequenceNumber) 252 .DefaultIfEmpty(-1L) 253 .Max(); 254 255 return Task.FromResult(maxSequence); 256 } 257 258 public Task<IReadOnlyList<OperatorId>> ListOperatorIdsAsync() 259 { 260 var operatorIds = _events 261 .FindAll() 262 .Select(doc => doc.OperatorId) 263 .Distinct() 264 .Select(guid => OperatorId.FromGuid(guid)) 265 .ToList(); 266 267 IReadOnlyList<OperatorId> result = operatorIds; 268 return Task.FromResult(result); 269 } 270 271 public Task<IReadOnlyList<OperatorId>> ListOperatorIdsByAccountAsync(Guid accountId) 272 { 273 var operatorIds = _events 274 .Find(doc => doc.SequenceNumber == 0 && doc.AccountId == accountId) 275 .Select(doc => OperatorId.FromGuid(doc.OperatorId)) 276 .ToList(); 277 278 IReadOnlyList<OperatorId> result = operatorIds; 279 return Task.FromResult(result); 280 } 281 282 public Task<Guid?> GetOperatorAccountIdAsync(OperatorId operatorId) 283 { 284 var genesis = _events.FindOne(doc => 285 doc.OperatorId == operatorId.Value && doc.SequenceNumber == 0); 286 287 Guid? accountId = genesis?.AccountId; 288 return Task.FromResult(accountId); 289 } 290 291 public Task AssociateOperatorWithAccountAsync(OperatorId operatorId, Guid accountId) 292 { 293 var genesis = _events.FindOne(doc => 294 doc.OperatorId == operatorId.Value && doc.SequenceNumber == 0); 295 296 if (genesis == null) 297 throw new InvalidOperationException( 298 $"Cannot associate account: genesis event not found for operator {operatorId}"); 299 300 genesis.AccountId = accountId; 301 _events.Update(genesis); 302 return Task.CompletedTask; 303 } 304 305 /// <summary> 306 /// Maps a persistence document to a domain event. 307 /// Uses factory methods on concrete event types to reconstruct events from storage. 308 /// </summary> 309 private static OperatorEvent MapToDomainEvent(OperatorEventDocument doc) 310 { 311 var operatorId = OperatorId.FromGuid(doc.OperatorId); 312 313 // Recreate the appropriate event type using their rehydration factory methods 314 return doc.EventType switch 315 { 316 "OperatorCreated" => OperatorCreatedEvent.Rehydrate(operatorId, doc.Payload, doc.Timestamp), 317 "XpGained" => XpGainedEvent.Rehydrate(operatorId, doc.SequenceNumber, doc.Payload, doc.PreviousHash, doc.Timestamp), 318 "WoundsTreated" => WoundsTreatedEvent.Rehydrate(operatorId, doc.SequenceNumber, doc.Payload, doc.PreviousHash, doc.Timestamp), 319 "LoadoutChanged" => LoadoutChangedEvent.Rehydrate(operatorId, doc.SequenceNumber, doc.Payload, doc.PreviousHash, doc.Timestamp), 320 "PerkUnlocked" => PerkUnlockedEvent.Rehydrate(operatorId, doc.SequenceNumber, doc.Payload, doc.PreviousHash, doc.Timestamp), 321 "CombatVictory" => CombatVictoryEvent.Rehydrate(operatorId, doc.SequenceNumber, doc.Payload, doc.PreviousHash, doc.Timestamp), 322 "ExfilFailed" => ExfilFailedEvent.Rehydrate(operatorId, doc.SequenceNumber, doc.Payload, doc.PreviousHash, doc.Timestamp), 323 "OperatorDied" => OperatorDiedEvent.Rehydrate(operatorId, doc.SequenceNumber, doc.Payload, doc.PreviousHash, doc.Timestamp), 324 "InfilStarted" => InfilStartedEvent.Rehydrate(operatorId, doc.SequenceNumber, doc.Payload, doc.PreviousHash, doc.Timestamp), 325 "InfilEnded" => InfilEndedEvent.Rehydrate(operatorId, doc.SequenceNumber, doc.Payload, doc.PreviousHash, doc.Timestamp), 326 "CombatSessionStarted" => CombatSessionStartedEvent.Rehydrate(operatorId, doc.SequenceNumber, doc.Payload, doc.PreviousHash, doc.Timestamp), 327 "PetActionApplied" => PetActionAppliedEvent.Rehydrate(operatorId, doc.SequenceNumber, doc.Payload, doc.PreviousHash, doc.Timestamp), 328 _ => throw new InvalidOperationException($"Unknown event type: {doc.EventType}") 329 }; 330 } 331 }