Files
jobtrackingapp/JobTrackerApi/Services/AccountDeletionTombstoneStore.cs
T
cesnimda 842e793f69
CI and Deploy / test (pull_request) Successful in 5m18s
CI and Deploy / deploy (pull_request) Has been skipped
feat(account): add deletion lifecycle
2026-08-15 19:03:54 +02:00

65 lines
2.9 KiB
C#

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<IReadOnlyList<AccountDeletionTombstone>> ReadAsync(CancellationToken cancellationToken)
{
await _gate.WaitAsync(cancellationToken);
try { return await ReadUnsafeAsync(cancellationToken); }
finally { _gate.Release(); }
}
private async Task<IReadOnlyList<AccountDeletionTombstone>> ReadUnsafeAsync(CancellationToken cancellationToken)
{
if (!File.Exists(LedgerPath)) return Array.Empty<AccountDeletionTombstone>();
var result = new List<AccountDeletionTombstone>();
foreach (var line in await File.ReadAllLinesAsync(LedgerPath, cancellationToken))
{
if (string.IsNullOrWhiteSpace(line)) continue;
try
{
var item = JsonSerializer.Deserialize<AccountDeletionTombstone>(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;
}
}