Files
LehrerApp/LehrerApp.Sync/EventQueue.cs
T
adminandClaude Sonnet 5 f0f8fa25e5 Baustein 6: Datei-Anhaenge synchronisieren (Kapitel 10)
Anhaenge laufen bewusst NICHT ueber den JSON-Ereigniskanal (wuerde ihn
fuer Fotos/Scans stark aufblaehen), sondern ueber einen eigenen
verschluesselten Binaerkanal - analog zum bereits bestehenden Muster
in SnapshotService.

- Neue Endpunkte POST/GET /api/sync/attachments/{storageId} in
  LehrerApp.Api (AttachmentStore, dateibasiert je Nutzer)
- EventQueue: neue, vom JSON-Ereignis getrennte Warteliste fuer
  ausstehende Uploads (SyncEventPublisher traegt Anhaenge einer
  gespeicherten Documentation dort ein)
- AttachmentSyncer laedt ausstehende Anhaenge hoch (in
  SyncEngine.SyncNowAsync nach dem Event-Push)
- EventApplier laedt fehlende Anhaenge nach dem Anwenden eines
  Documentation-Ereignisses nach - ueber die rohe Collection statt
  IAttachmentStorage.Upload, da dieses immer eine neue Id vergaebe und
  hier die Original-StorageId erhalten bleiben muss

Round-Trip-Tests belegen byteidentische Uebertragung.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 11:30:04 +02:00

99 lines
3.9 KiB
C#

using LiteDB;
using LehrerApp.Sync.Models;
namespace LehrerApp.Sync;
/// <summary>
/// Lokale Event-Queue in LiteDB. Puffert Events bis sie
/// erfolgreich zum Server gepusht wurden.
/// </summary>
public class EventQueue : IDisposable
{
private readonly LiteDatabase _db;
private readonly ILiteCollection<SyncEvent> _queue;
private readonly ILiteCollection<SyncMeta> _meta;
private readonly ILiteCollection<ConflictEntry> _conflicts;
private readonly ILiteCollection<PendingAttachmentUpload> _attachmentUploads;
private long _currentSeq;
public EventQueue(string path)
{
_db = new LiteDatabase(path);
_queue = _db.GetCollection<SyncEvent>("queue");
_meta = _db.GetCollection<SyncMeta>("meta");
_conflicts = _db.GetCollection<ConflictEntry>("conflicts");
_attachmentUploads = _db.GetCollection<PendingAttachmentUpload>("attachment_uploads");
_attachmentUploads.EnsureIndex(x => x.StorageId, unique: true);
_queue.EnsureIndex(x => x.SequenceNr);
_currentSeq = _meta.FindById("seq")?.Value ?? 0;
}
public SyncEvent Enqueue(string deviceId, DeviceType deviceType,
string entityType, string entityId, string operation, string payload)
{
var evt = new SyncEvent
{
DeviceId = deviceId,
DeviceType = deviceType,
Timestamp = DateTime.UtcNow,
SequenceNr = ++_currentSeq,
EntityType = entityType,
EntityId = entityId,
Operation = operation,
Payload = payload,
};
_queue.Insert(evt);
_meta.Upsert(new SyncMeta { Id = "seq", Value = _currentSeq });
return evt;
}
public List<SyncEvent> GetPending(int max = 200) =>
_queue.Find(Query.All(nameof(SyncEvent.SequenceNr))).Take(max).ToList();
public int PendingCount() => _queue.Count();
public void Acknowledge(IEnumerable<Guid> ids) { foreach (var id in ids) _queue.Delete(id); }
public long GetLastServerSeq() => _meta.FindById("serverSeq")?.Value ?? 0;
public void SetLastServerSeq(long nr) => _meta.Upsert(new SyncMeta { Id = "serverSeq", Value = nr });
public DateTime? GetLastSyncAt() => _meta.FindById("lastSync")?.Timestamp;
public void SetLastSyncAt(DateTime dt) =>
_meta.Upsert(new SyncMeta { Id = "lastSync", Value = 0, Timestamp = dt });
public void AddConflict(ConflictEntry c) => _conflicts.Insert(c);
public List<ConflictEntry> GetUnreviewed() => _conflicts.Find(c => !c.Reviewed).ToList();
public int ConflictCount() => _conflicts.Count(c => !c.Reviewed);
// ── Anhang-Warteliste (getrennt von der JSON-Ereignis-Outbox, siehe AttachmentSyncer) ────
public void QueueAttachmentUpload(string storageId)
{
if (!_attachmentUploads.Exists(a => a.StorageId == storageId))
_attachmentUploads.Insert(new PendingAttachmentUpload { StorageId = storageId });
}
public List<string> GetPendingAttachmentUploads() =>
_attachmentUploads.FindAll().Select(a => a.StorageId).ToList();
public void MarkAttachmentUploaded(string storageId) =>
_attachmentUploads.DeleteMany(a => a.StorageId == storageId);
public void Dispose() => _db.Dispose();
}
public class ConflictEntry
{
public Guid Id { get; init; } = Guid.NewGuid();
public DateTime DetectedAt { get; init; } = DateTime.UtcNow;
public SyncEvent LocalEvent { get; init; } = null!;
public SyncEvent RemoteEvent { get; init; } = null!;
public string Resolution { get; init; } = "";
public bool Reviewed { get; set; }
}
internal class SyncMeta
{
public string Id { get; set; } = "";
public long Value { get; set; }
public DateTime? Timestamp { get; set; }
}
internal class PendingAttachmentUpload
{
public ObjectId Id { get; set; } = ObjectId.NewObjectId();
public string StorageId { get; set; } = "";
}