using System.Text.Json; namespace JobTrackerApi.Services; public sealed record AccountDeletionTombstone(string SchemaVersion, Guid RequestId, string OwnerKey, DateTimeOffset CompletedAtUtc); public sealed class AccountDeletionTombstoneStore(AppPaths paths) { private const string SchemaVersion = "jobtracker.account-deletion-tombstone.v1"; private readonly SemaphoreSlim _gate = new(1, 1); private string LedgerPath => Path.Combine(paths.AccountDeletionTombstonesRoot, "tombstones.jsonl"); public async Task AppendAsync(Guid requestId, string ownerKey, DateTimeOffset completedAtUtc, CancellationToken cancellationToken) { await _gate.WaitAsync(cancellationToken); try { var existing = await ReadUnsafeAsync(cancellationToken); if (existing.Any(item => item.RequestId == requestId)) return; Directory.CreateDirectory(paths.AccountDeletionTombstonesRoot); var line = JsonSerializer.Serialize(new AccountDeletionTombstone(SchemaVersion, requestId, ownerKey, completedAtUtc)); await File.AppendAllTextAsync(LedgerPath, line + Environment.NewLine, cancellationToken); } finally { _gate.Release(); } } public async Task> ReadAsync(CancellationToken cancellationToken) { await _gate.WaitAsync(cancellationToken); try { return await ReadUnsafeAsync(cancellationToken); } finally { _gate.Release(); } } private async Task> ReadUnsafeAsync(CancellationToken cancellationToken) { if (!File.Exists(LedgerPath)) return Array.Empty(); var result = new List(); foreach (var line in await File.ReadAllLinesAsync(LedgerPath, cancellationToken)) { if (string.IsNullOrWhiteSpace(line)) continue; try { var item = JsonSerializer.Deserialize(line); if (item is null || item.SchemaVersion != SchemaVersion || item.RequestId == Guid.Empty || item.OwnerKey.Length != 64 || item.OwnerKey.Any(character => !Uri.IsHexDigit(character))) throw new InvalidOperationException("The account-deletion tombstone ledger contains an invalid record."); result.Add(item); } catch (JsonException) { // A partial/corrupt line is never ignored by replay callers: expose a sentinel so // startup fails closed instead of declaring the tombstone set complete. throw new InvalidOperationException("The account-deletion tombstone ledger contains an invalid record."); } } return result; } }