feat(account): add deletion lifecycle
This commit is contained in:
@@ -0,0 +1,64 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user