using StellaOps.PacksRegistry.Core.Contracts; using StellaOps.PacksRegistry.Core.Models; using System.Text.Json; namespace StellaOps.PacksRegistry.Infrastructure.FileSystem; public sealed class FilePackRepository : IPackRepository { private readonly string _root; private readonly string _indexPath; private readonly JsonSerializerOptions _jsonOptions; private readonly SemaphoreSlim _mutex = new(1, 1); public FilePackRepository(string root) { _root = string.IsNullOrWhiteSpace(root) ? Path.GetFullPath("data/packs") : Path.GetFullPath(root); _indexPath = Path.Combine(_root, "index.ndjson"); _jsonOptions = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, WriteIndented = false }; Directory.CreateDirectory(_root); Directory.CreateDirectory(Path.Combine(_root, "blobs")); } public async Task UpsertAsync(PackRecord record, byte[] content, byte[]? provenance, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(record); ArgumentNullException.ThrowIfNull(content); await _mutex.WaitAsync(cancellationToken).ConfigureAwait(false); try { var blobPath = Path.Combine(_root, "blobs", record.Digest.Replace(':', '_')); await File.WriteAllBytesAsync(blobPath, content, cancellationToken).ConfigureAwait(false); if (provenance is { Length: > 0 } && record.ProvenanceDigest is not null) { var provPath = Path.Combine(_root, "provenance", record.ProvenanceDigest.Replace(':', '_')); Directory.CreateDirectory(Path.GetDirectoryName(provPath)!); await File.WriteAllBytesAsync(provPath, provenance, cancellationToken).ConfigureAwait(false); } await using var stream = new FileStream(_indexPath, FileMode.Append, FileAccess.Write, FileShare.Read); await using var writer = new StreamWriter(stream); var json = JsonSerializer.Serialize(record, _jsonOptions); await writer.WriteLineAsync(json.AsMemory(), cancellationToken).ConfigureAwait(false); } finally { _mutex.Release(); } } public async Task GetAsync(string packId, CancellationToken cancellationToken = default) { ArgumentException.ThrowIfNullOrWhiteSpace(packId); var records = await ReadAllAsync(cancellationToken).ConfigureAwait(false); return records.LastOrDefault(r => string.Equals(r.PackId, packId, StringComparison.OrdinalIgnoreCase)); } public async Task> ListAsync(string? tenantId = null, CancellationToken cancellationToken = default) { var records = await ReadAllAsync(cancellationToken).ConfigureAwait(false); if (!string.IsNullOrWhiteSpace(tenantId)) { records = records.Where(r => string.Equals(r.TenantId, tenantId, StringComparison.OrdinalIgnoreCase)); } return records .OrderBy(r => r.TenantId, StringComparer.OrdinalIgnoreCase) .ThenBy(r => r.Name, StringComparer.OrdinalIgnoreCase) .ThenBy(r => r.Version, StringComparer.OrdinalIgnoreCase) .ToArray(); } private async Task> ReadAllAsync(CancellationToken cancellationToken) { if (!File.Exists(_indexPath)) { return Array.Empty(); } await _mutex.WaitAsync(cancellationToken).ConfigureAwait(false); try { var lines = await File.ReadAllLinesAsync(_indexPath, cancellationToken).ConfigureAwait(false); return lines .Where(line => !string.IsNullOrWhiteSpace(line)) .Select(line => JsonSerializer.Deserialize(line, _jsonOptions)) .Where(r => r is not null)!; } finally { _mutex.Release(); } } public async Task GetContentAsync(string packId, CancellationToken cancellationToken = default) { var record = await GetAsync(packId, cancellationToken).ConfigureAwait(false); if (record is null) { return null; } var blobPath = Path.Combine(_root, "blobs", record.Digest.Replace(':', '_')); if (!File.Exists(blobPath)) { return null; } return await File.ReadAllBytesAsync(blobPath, cancellationToken).ConfigureAwait(false); } public async Task GetProvenanceAsync(string packId, CancellationToken cancellationToken = default) { var record = await GetAsync(packId, cancellationToken).ConfigureAwait(false); if (record?.ProvenanceDigest is null) { return null; } var provPath = Path.Combine(_root, "provenance", record.ProvenanceDigest.Replace(':', '_')); if (!File.Exists(provPath)) { return null; } return await File.ReadAllBytesAsync(provPath, cancellationToken).ConfigureAwait(false); } }