using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Storage; using SVSim.Database.Entities.Guild; using System.Data; namespace SVSim.Database.Repositories.Guild; public sealed class GuildChatMessageRepository : IGuildChatMessageRepository { private readonly SVSimDbContext _db; public GuildChatMessageRepository(SVSimDbContext db) { _db = db; } /// /// Allocates the next per-guild monotonic MessageId and inserts the row inside a serializable /// transaction (Postgres). The unique (GuildId, MessageId) index is the backstop — if a /// concurrent insert wins the race we retry once before surfacing the exception. /// InMemory provider does not support transactions; callers are single-threaded in tests. /// public async Task AppendAsync(GuildChatMessage msg, CancellationToken ct = default) { for (int attempt = 0; attempt < 2; attempt++) { IDbContextTransaction? tx = null; try { // BeginTransactionAsync throws NotSupportedException on the InMemory provider. // Catch and fall through — single-threaded tests don't need transaction isolation. try { tx = await _db.Database.BeginTransactionAsync(IsolationLevel.Serializable, ct); } catch (InvalidOperationException) { // InMemory provider does not support transactions. } int nextId = await GetMaxMessageIdAsync(msg.GuildId, ct) + 1; msg.MessageId = nextId; _db.GuildChatMessages.Add(msg); await _db.SaveChangesAsync(ct); if (tx is not null) await tx.CommitAsync(ct); return msg; } catch (DbUpdateException) when (attempt == 0) { // Unique violation on (GuildId, MessageId) — another writer raced us. // Detach the failed entity and retry once. if (tx is not null) { try { await tx.RollbackAsync(ct); } catch { /* ignore rollback failure */ } } _db.Entry(msg).State = EntityState.Detached; msg.MessageId = 0; // will be re-assigned on retry } finally { if (tx is not null) await tx.DisposeAsync(); } } throw new InvalidOperationException( $"Failed to allocate a unique MessageId for GuildId={msg.GuildId} after 2 attempts."); } /// /// Window query for message history. /// direction 1 = OLD (walk backwards from start, return ascending), /// direction 2 = NEW (walk forwards from start), /// direction 3 = BOTH (around start, older half + newer half). /// start = 0 means "latest". /// Result is always ordered oldest-to-newest. /// public async Task> GetWindowAsync( int guildId, int start, int direction, int limit, CancellationToken ct = default) { IQueryable baseQ = _db.GuildChatMessages .Where(m => m.GuildId == guildId); if (start == 0) { // Latest N messages. return await baseQ .OrderByDescending(m => m.MessageId) .Take(limit) .OrderBy(m => m.MessageId) .ToListAsync(ct); } switch (direction) { case 1: // OLD — messages older than (and including) start return await baseQ .Where(m => m.MessageId <= start) .OrderByDescending(m => m.MessageId) .Take(limit) .OrderBy(m => m.MessageId) .ToListAsync(ct); case 2: // NEW — messages newer than (and including) start return await baseQ .Where(m => m.MessageId >= start) .OrderBy(m => m.MessageId) .Take(limit) .ToListAsync(ct); case 3: // BOTH — half older, half newer around start { int half = limit / 2; var older = await baseQ .Where(m => m.MessageId <= start) .OrderByDescending(m => m.MessageId) .Take(half) .ToListAsync(ct); var newer = await baseQ .Where(m => m.MessageId > start) .OrderBy(m => m.MessageId) .Take(limit - half) .ToListAsync(ct); return older.OrderBy(m => m.MessageId).Concat(newer).ToList(); } default: return await baseQ .OrderByDescending(m => m.MessageId) .Take(limit) .OrderBy(m => m.MessageId) .ToListAsync(ct); } } public async Task GetMaxMessageIdAsync(int guildId, CancellationToken ct = default) { var max = await _db.GuildChatMessages .Where(m => m.GuildId == guildId) .MaxAsync(m => (int?)m.MessageId, ct); return max ?? 0; } public async Task DeleteAllForGuildAsync(int guildId, CancellationToken ct = default) { var messages = await _db.GuildChatMessages.Where(m => m.GuildId == guildId).ToListAsync(ct); _db.GuildChatMessages.RemoveRange(messages); if (messages.Count > 0) await _db.SaveChangesAsync(ct); } public async Task ClearDeckAsync(int guildId, int messageId, long callerViewerId, CancellationToken ct = default) { var msg = await _db.GuildChatMessages .FirstOrDefaultAsync(m => m.GuildId == guildId && m.MessageId == messageId, ct); if (msg is null) return false; if (msg.AuthorViewerId != callerViewerId) return false; if (msg.DeckPayload is null) return false; msg.DeckPayload = null; msg.Body = ""; await _db.SaveChangesAsync(ct); return true; } }