feat(guild): repositories for Guild aggregate
This commit is contained in:
167
SVSim.Database/Repositories/Guild/GuildChatMessageRepository.cs
Normal file
167
SVSim.Database/Repositories/Guild/GuildChatMessageRepository.cs
Normal file
@@ -0,0 +1,167 @@
|
||||
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; }
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public async Task<GuildChatMessage> 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.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<GuildChatMessage>> GetWindowAsync(
|
||||
int guildId, int start, int direction, int limit, CancellationToken ct = default)
|
||||
{
|
||||
IQueryable<GuildChatMessage> 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<int> 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<bool> 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user