/ GUNRPG.Infrastructure / Persistence / LiteDbOperatorEventStore.cs
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  }