From ed7600dc905a2344b3fba03081424f80cab65bda Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sun, 11 Oct 2026 16:38:35 -0400 Subject: [PATCH] feat(data): fetch bounded nest history and guard saves --- OpenNest.Data/INestRepository.cs | 15 ++ OpenNest.Data/NestRevision.cs | 12 ++ OpenNest.Data/NestSaveSession.cs | 39 ++++- OpenNest.Data/RemoteNestRepository.cs | 80 +++++++-- OpenNest.Tests/Data/NestSaveSessionTests.cs | 40 +++++ OpenNest.Tests/Data/NestStorageTests.cs | 24 +-- OpenNest.Tests/Data/RemoteNestHistoryTests.cs | 165 ++++++++++++++++++ 7 files changed, 343 insertions(+), 32 deletions(-) create mode 100644 OpenNest.Data/NestRevision.cs create mode 100644 OpenNest.Tests/Data/RemoteNestHistoryTests.cs diff --git a/OpenNest.Data/INestRepository.cs b/OpenNest.Data/INestRepository.cs index 1e4aaf4..f0ec687 100644 --- a/OpenNest.Data/INestRepository.cs +++ b/OpenNest.Data/INestRepository.cs @@ -34,4 +34,19 @@ public interface INestRepository Guid id, NestRecord record, CancellationToken cancellationToken = default); Task DeleteAsync(Guid id, CancellationToken cancellationToken = default); + + /// Reads bounded immutable history; unsupported backends fail closed. + Task GetRevisionsAsync(Guid id, int offset = 0, int limit = 100, + CancellationToken cancellationToken = default) => + throw new NotSupportedException("This repository does not support nest version history."); + + Task GetRevisionFileAsync(Guid id, int revisionNumber, + CancellationToken cancellationToken = default) => + throw new NotSupportedException("This repository does not support nest version history."); + + /// Atomically replaces the current archive only at the expected revision. + Task UpdateFileAsync(Guid id, byte[] nestFile, NestRecord record, int expectedRevision, + CancellationToken cancellationToken = default) => + throw new NotSupportedException("This repository does not support guarded saves. Upgrade the nest server and reload."); + } diff --git a/OpenNest.Data/NestRevision.cs b/OpenNest.Data/NestRevision.cs new file mode 100644 index 0000000..706cc55 --- /dev/null +++ b/OpenNest.Data/NestRevision.cs @@ -0,0 +1,12 @@ +namespace OpenNest.Data; + +/// A bounded page of immutable metadata snapshots, newest first. +public sealed class NestRevisionPage +{ + public const int DefaultLimit = 100; + public const int MaxLimit = 200; + public IReadOnlyList Items { get; init; } = Array.Empty(); + public int Total { get; init; } + public int Offset { get; init; } + public int Limit { get; init; } +} diff --git a/OpenNest.Data/NestSaveSession.cs b/OpenNest.Data/NestSaveSession.cs index c337e3d..f48d64c 100644 --- a/OpenNest.Data/NestSaveSession.cs +++ b/OpenNest.Data/NestSaveSession.cs @@ -9,6 +9,15 @@ public sealed class NestSaveSession { public Guid RemoteId { get; private set; } public string ServerUrl { get; private set; } = ""; + public int Revision { get; private set; } + public bool RequiresReload { get; private set; } + + public void Bind(Guid id, string serverUrl, int revision) + { + if (revision < 0) throw new ArgumentOutOfRangeException(nameof(revision)); + Bind(id, serverUrl); + Revision = revision; + } public void Bind(Guid id, string serverUrl) { @@ -16,6 +25,8 @@ public sealed class NestSaveSession throw new ArgumentException("A saved nest needs a non-empty id.", nameof(id)); RemoteId = id; ServerUrl = Normalize(serverUrl); + Revision = 0; // Legacy reads remain viewable, but must not become guarded saves. + RequiresReload = false; } public async Task SaveAsync( @@ -32,13 +43,29 @@ public sealed class NestSaveSession var id = !saveCopy && ServerUrl == normalizedUrl ? RemoteId : Guid.Empty; var record = NestRecordFactory.FromNest(nest, id, archive.LongLength); - var saved = id == Guid.Empty - ? await repository.UploadAsync(archive, record, cancellationToken) - : await repository.UpdateFileAsync(id, archive, record, cancellationToken); - if (saved.Id == Guid.Empty || (id != Guid.Empty && saved.Id != id)) - throw new InvalidDataException("Server returned an invalid nest id."); + if (id != Guid.Empty && (Revision < 1 || RequiresReload)) + throw new IOException("Reload the current nest revision before saving, or Save As a new nest. Upgrade the nest server if version history is unavailable."); + NestRecord saved; + try + { + saved = id == Guid.Empty + ? await repository.UploadAsync(archive, record, cancellationToken) + : await repository.UpdateFileAsync(id, archive, record, Revision, cancellationToken); + } + catch (Exception ex) when (id != Guid.Empty && ex is IOException or System.Net.Http.HttpRequestException or OperationCanceledException or System.Text.Json.JsonException) + { + // The request may have committed before its reply was lost. Do not replay + // pending bytes until the operator explicitly reloads/reconciles the record. + RequiresReload = true; + throw; + } + if (saved.Id == Guid.Empty || (id != Guid.Empty && (saved.Id != id || saved.Revision < Revision))) + { + if (id != Guid.Empty) RequiresReload = true; + throw new InvalidDataException("Server returned an invalid nest id or revision. Reload to reconcile before saving again."); + } - Bind(saved.Id, normalizedUrl); + Bind(saved.Id, normalizedUrl, saved.Revision); return saved; } diff --git a/OpenNest.Data/RemoteNestRepository.cs b/OpenNest.Data/RemoteNestRepository.cs index aa8b7e4..a0b23a9 100644 --- a/OpenNest.Data/RemoteNestRepository.cs +++ b/OpenNest.Data/RemoteNestRepository.cs @@ -123,26 +123,84 @@ public sealed class RemoteNestRepository : INestRepository, IDisposable public async Task UpdateFileAsync( Guid id, byte[] nestFile, NestRecord record, CancellationToken cancellationToken = default) { - using var content = BuildMultipart(nestFile, record); - using var response = await _httpClient.PutAsync(Url($"api/nests/{id}/file"), content, cancellationToken); - await EnsureSuccess(response, $"update nest {id}", cancellationToken); - return await ReadRecord(response, cancellationToken); + return await UpdateFileAsync(id, nestFile, record, record.Revision, cancellationToken); } public async Task UpdateMetadataAsync( Guid id, NestRecord record, CancellationToken cancellationToken = default) { - using var content = new StringContent( - JsonSerializer.Serialize(record, JsonOptions), Encoding.UTF8, "application/json"); - using var response = await _httpClient.PutAsync(Url($"api/nests/{id}/metadata"), content, cancellationToken); - await EnsureSuccess(response, $"update nest metadata {id}", cancellationToken); - return await ReadRecord(response, cancellationToken); + await Task.CompletedTask; + throw new IOException("Metadata updates require guarded revision support and are not available yet. Upgrade the client before editing server metadata."); } public async Task DeleteAsync(Guid id, CancellationToken cancellationToken = default) { - using var response = await _httpClient.DeleteAsync(Url($"api/nests/{id}"), cancellationToken); - await EnsureSuccess(response, $"delete nest {id}", cancellationToken); + await Task.CompletedTask; + throw new IOException("Deletion requires guarded soft-delete support and is not available yet. Upgrade the client before deleting a server nest."); + } + + public async Task GetRevisionsAsync(Guid id, int offset = 0, int limit = 100, + CancellationToken cancellationToken = default) + { + ValidateId(id); + if (offset < 0) throw new ArgumentOutOfRangeException(nameof(offset)); + if (limit < 1 || limit > NestRevisionPage.MaxLimit) throw new ArgumentOutOfRangeException(nameof(limit)); + using var response = await _httpClient.GetAsync( + Url($"api/nests/{id}/revisions?offset={offset.ToString(CultureInfo.InvariantCulture)}&limit={limit.ToString(CultureInfo.InvariantCulture)}"), cancellationToken); + if (response.StatusCode == HttpStatusCode.NotFound) + throw new IOException("Nest history is unavailable or this nest no longer exists. Upgrade the nest server if it does not support version history, then reload."); + await EnsureSuccess(response, $"read nest history {id}", cancellationToken); + var page = await response.Content.ReadFromJsonAsync(JsonOptions, cancellationToken) + ?? throw new IOException("Nest server returned an empty history page."); + if (page.Items is null || page.Items.Count > limit || page.Offset != offset || page.Limit != limit + || page.Total < 0 || page.Items.Count > System.Math.Max(0L, (long)page.Total - offset) + || page.Items.Any(item => item is null || item.Revision < 1 || item.Id != id) + || page.Items.Zip(page.Items.Skip(1)).Any(pair => pair.First.Revision <= pair.Second.Revision)) + throw new IOException("Nest server returned an invalid or unbounded history page."); + return page; + } + + public async Task GetRevisionFileAsync(Guid id, int revisionNumber, + CancellationToken cancellationToken = default) + { + ValidateId(id); + ValidateRevision(revisionNumber); + using var response = await _httpClient.GetAsync(Url($"api/nests/{id}/revisions/{revisionNumber.ToString(CultureInfo.InvariantCulture)}/file"), cancellationToken); + if (response.StatusCode == HttpStatusCode.NotFound) return null; + await EnsureSuccess(response, $"download nest {id} revision {revisionNumber}", cancellationToken); + return await response.Content.ReadAsByteArrayAsync(cancellationToken); + } + + public async Task UpdateFileAsync(Guid id, byte[] nestFile, NestRecord record, int expectedRevision, + CancellationToken cancellationToken = default) + { + ValidateId(id); + if (expectedRevision < 1) + throw new IOException("This nest has no known revision. Upgrade the nest server and reload before saving, or Save As a new nest."); + // A legacy server can ignore If-Match and overwrite data. Prove history support + // before sending any mutation; never infer revision 1 from a legacy read. + var history = await GetRevisionsAsync(id, limit: 1, cancellationToken: cancellationToken); + if (history.Items.Count == 0) + throw new IOException("Nest server returned no revision history. Upgrade the nest server and reload before saving."); + using var request = new HttpRequestMessage(HttpMethod.Put, Url($"api/nests/{id}/file")); + request.Headers.IfMatch.Add(new EntityTagHeaderValue($"\"{expectedRevision.ToString(CultureInfo.InvariantCulture)}\"")); + request.Content = BuildMultipart(nestFile, record); + using var response = await _httpClient.SendAsync(request, cancellationToken); + await EnsureSuccess(response, $"update nest {id}; reload the current version or Save As after a conflict", cancellationToken); + var saved = await ReadRecord(response, cancellationToken); + if (saved.Id != id || saved.Revision < expectedRevision) + throw new IOException("Nest server returned an invalid saved revision. Reload to reconcile before saving again."); + return saved; + } + + private static void ValidateId(Guid id) + { + if (id == Guid.Empty) throw new ArgumentException("A nest id is required.", nameof(id)); + } + + private static void ValidateRevision(int revisionNumber) + { + if (revisionNumber < 1) throw new ArgumentOutOfRangeException(nameof(revisionNumber)); } public void Dispose() diff --git a/OpenNest.Tests/Data/NestSaveSessionTests.cs b/OpenNest.Tests/Data/NestSaveSessionTests.cs index 4013bff..eb1bb68 100644 --- a/OpenNest.Tests/Data/NestSaveSessionTests.cs +++ b/OpenNest.Tests/Data/NestSaveSessionTests.cs @@ -22,6 +22,8 @@ public class NestSaveSessionTests await session.SaveAsync(repository, "http://shop:8090", nest, bytes); Assert.Equal(firstId, repository.UpdatedId); Assert.Equal(1, repository.Updates); + Assert.Equal(1, repository.ExpectedRevision); + Assert.Equal(2, session.Revision); await session.SaveAsync(repository, "http://shop:8090", nest, bytes, saveCopy: true); Assert.NotEqual(firstId, session.RemoteId); @@ -50,6 +52,35 @@ public class NestSaveSessionTests Assert.Equal("http://new:8090", session.ServerUrl); } + [Fact] + public async Task FailedUpdate_RetainsIdServerAndExpectedRevision_NoAutomaticRetry() + { + var repository = new FakeRepository { Fail = true }; + var session = new NestSaveSession(); + var id = Guid.NewGuid(); + session.Bind(id, "http://shop/", 4); + await Assert.ThrowsAsync(() => session.SaveAsync(repository, "http://shop", new Nest("Job"), [1])); + Assert.Equal(id, session.RemoteId); + Assert.Equal("http://shop", session.ServerUrl); + Assert.Equal(4, session.Revision); + Assert.Equal(4, repository.ExpectedRevision); + Assert.Equal(1, repository.Updates); + Assert.True(session.RequiresReload); + await Assert.ThrowsAsync(() => session.SaveAsync(repository, "http://shop", new Nest("Job"), [1])); + Assert.Equal(1, repository.Updates); + session.Bind(id, "http://shop", 5); + Assert.False(session.RequiresReload); + } + + [Fact] + public void LegacyBind_DoesNotFabricateARevision() + { + var session = new NestSaveSession(); + session.Bind(Guid.NewGuid(), "http://shop", 5); + session.Bind(Guid.NewGuid(), "http://shop"); + Assert.Equal(0, session.Revision); + } + private sealed class FakeRepository : INestRepository { public int Uploads { get; private set; } @@ -57,6 +88,7 @@ public class NestSaveSessionTests public Guid UpdatedId { get; private set; } public NestRecord? LastRecord { get; private set; } public bool Fail { get; set; } + public int ExpectedRevision { get; private set; } public Task UploadAsync(byte[] file, NestRecord record, CancellationToken ct = default) { @@ -64,9 +96,17 @@ public class NestSaveSessionTests LastRecord = record; if (Fail) throw new IOException("Offline"); record.Id = Guid.NewGuid(); + record.Revision = 1; return Task.FromResult(record); } + public Task UpdateFileAsync(Guid id, byte[] file, NestRecord record, int expectedRevision, CancellationToken ct = default) + { + ExpectedRevision = expectedRevision; + record.Revision = expectedRevision + 1; + return UpdateFileAsync(id, file, record, ct); + } + public Task UpdateFileAsync(Guid id, byte[] file, NestRecord record, CancellationToken ct = default) { Updates++; diff --git a/OpenNest.Tests/Data/NestStorageTests.cs b/OpenNest.Tests/Data/NestStorageTests.cs index b7a33f9..bf9e87b 100644 --- a/OpenNest.Tests/Data/NestStorageTests.cs +++ b/OpenNest.Tests/Data/NestStorageTests.cs @@ -179,18 +179,15 @@ public class RemoteNestRepositoryTests } [Fact] - public async Task UpdateFileAsync_PutsMultipartToIdFileRoute() + public async Task UpdateFileAsync_RefusesLegacyRecordBeforeHttp() { var id = Guid.NewGuid(); var record = SampleRecord(id); var handler = new StubHandler(_ => StubHandler.Json(record)); using var repo = new RemoteNestRepository(new HttpClient(handler), "http://server:8090"); - await repo.UpdateFileAsync(id, new byte[] { 9 }, record); - - var request = handler.Requests[0]; - Assert.Equal(HttpMethod.Put, request.Method); - Assert.EndsWith($"/api/nests/{id}/file", request.RequestUri!.ToString()); + await Assert.ThrowsAsync(() => repo.UpdateFileAsync(id, new byte[] { 9 }, record)); + Assert.Empty(handler.Requests); // A legacy record has no revision precondition. } [Fact] @@ -218,22 +215,19 @@ public class RemoteNestRepositoryTests } [Fact] - public async Task UpdateMetadataAsync_PutsJsonToMetadataRoute() + public async Task UpdateMetadataAsync_RefusesUntilGuardedHistoryIsAvailable() { var id = Guid.NewGuid(); var record = SampleRecord(id); var handler = new StubHandler(_ => StubHandler.Json(record)); using var repo = new RemoteNestRepository(new HttpClient(handler), "http://s"); - await repo.UpdateMetadataAsync(id, record); - - Assert.Equal(HttpMethod.Put, handler.Requests[0].Method); - Assert.EndsWith($"/api/nests/{id}/metadata", handler.Requests[0].RequestUri!.ToString()); - Assert.Equal("application/json", handler.Requests[0].Content!.Headers.ContentType!.MediaType); + await Assert.ThrowsAsync(() => repo.UpdateMetadataAsync(id, record)); + Assert.Empty(handler.Requests); // Metadata history is delivered in S3. } [Fact] - public async Task DeleteAsync_ThrowsWithStatusDetailOnError() + public async Task DeleteAsync_RefusesUntilGuardedSoftDeleteIsAvailable() { var handler = new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.InternalServerError) { @@ -242,8 +236,8 @@ public class RemoteNestRepositoryTests using var repo = new RemoteNestRepository(new HttpClient(handler), "http://s"); var ex = await Assert.ThrowsAsync(() => repo.DeleteAsync(Guid.NewGuid())); - Assert.Contains("500", ex.Message); - Assert.Contains("disk on fire", ex.Message); + Assert.Contains("soft-delete", ex.Message); + Assert.Empty(handler.Requests); } [Fact] diff --git a/OpenNest.Tests/Data/RemoteNestHistoryTests.cs b/OpenNest.Tests/Data/RemoteNestHistoryTests.cs new file mode 100644 index 0000000..3fb0632 --- /dev/null +++ b/OpenNest.Tests/Data/RemoteNestHistoryTests.cs @@ -0,0 +1,165 @@ +using System.Net; +using System.Net.Http; +using System.Net.Http.Json; +using OpenNest.Data; + +namespace OpenNest.Tests.Data; + +public class RemoteNestHistoryTests +{ + [Fact] + public async Task LegacyServerIgnoringHeaders_IsNeverSentAnUpdate() + { + var mutations = 0; + using var http = new HttpClient(new Handler(request => + { + if (request.Method == HttpMethod.Put) + { + mutations++; + return new HttpResponseMessage(HttpStatusCode.OK) { Content = JsonContent.Create(new NestRecord()) }; + } + return new HttpResponseMessage(HttpStatusCode.NotFound); + })); + using var repository = new RemoteNestRepository(http, "http://legacy/"); + await Assert.ThrowsAsync(() => repository.UpdateFileAsync(Guid.NewGuid(), [1], new NestRecord())); + Assert.Equal(0, mutations); + } + + [Fact] + public async Task GuardedUpdate_PreflightsHistoryAndSendsQuotedRevision() + { + var id = Guid.NewGuid(); + var paths = new List(); + using var http = new HttpClient(new Handler(request => + { + paths.Add(request.RequestUri!.PathAndQuery); + if (request.Method == HttpMethod.Get) + return History(id); + Assert.Equal("\"4\"", Assert.Single(request.Headers.IfMatch).Tag); + return Json(new NestRecord { Id = id, Revision = 5 }); + })); + using var repository = new RemoteNestRepository(http, "http://shop/"); + var saved = await repository.UpdateFileAsync(id, [0, 255], new NestRecord(), 4); + Assert.Equal(5, saved.Revision); + Assert.Equal(new[] { $"/api/nests/{id}/revisions?offset=0&limit=1", $"/api/nests/{id}/file" }, paths); + } + + [Fact] + public async Task KnownRevisionAgainstLegacyServer_PreflightRefusesBeforeMutation() + { + var mutations = 0; + using var http = new HttpClient(new Handler(request => + { + if (request.Method != HttpMethod.Get) mutations++; + return new HttpResponseMessage(HttpStatusCode.NotFound); + })); + using var repository = new RemoteNestRepository(http, "http://legacy/"); + var error = await Assert.ThrowsAsync(() => repository.UpdateFileAsync(Guid.NewGuid(), [1], new NestRecord(), 4)); + Assert.Contains("Upgrade the nest server", error.Message); + Assert.Equal(0, mutations); + } + + [Theory] + [InlineData(-1, 1)] + [InlineData(0, 0)] + [InlineData(0, 201)] + public async Task InvalidBounds_AreRefusedBeforeHttp(int offset, int limit) + { + using var http = new HttpClient(new Handler(_ => throw new InvalidOperationException("HTTP must not run"))); + using var repository = new RemoteNestRepository(http, "http://shop/"); + await Assert.ThrowsAsync(() => repository.GetRevisionsAsync(Guid.NewGuid(), offset, limit)); + } + + [Fact] + public async Task HistoryAndDownload_AreBoundedAndByteExact_UnknownVersionIsNull() + { + var id = Guid.NewGuid(); + byte[] bytes = [0, 255, 13, 10, 128]; + using var http = new HttpClient(new Handler(request => + { + if (request.RequestUri!.Query.Length > 0) + return Json(new NestRevisionPage { Items = [new NestRecord { Id = id, Revision = 3, Comments = "earlier" }], Total = 5, Offset = 2, Limit = 1 }); + if (request.RequestUri.AbsolutePath.EndsWith("/999/file")) return new HttpResponseMessage(HttpStatusCode.NotFound); + Assert.EndsWith("/3/file", request.RequestUri.AbsolutePath); + return new HttpResponseMessage(HttpStatusCode.OK) { Content = new ByteArrayContent(bytes) }; + })); + using var repository = new RemoteNestRepository(http, "http://shop/"); + var page = await repository.GetRevisionsAsync(id, 2, 1); + Assert.Equal("earlier", Assert.Single(page.Items).Comments); + Assert.Equal(bytes, await repository.GetRevisionFileAsync(id, 3)); + Assert.Null(await repository.GetRevisionFileAsync(id, 999)); + } + + [Fact] + public async Task UnboundedOrInvalidHistoryResponse_IsNotCapabilityProof() + { + var id = Guid.NewGuid(); + var writes = 0; + using var http = new HttpClient(new Handler(request => + { + if (request.Method != HttpMethod.Get) writes++; + return Json(new NestRevisionPage { Items = [new NestRecord { Id = id, Revision = 1 }, new NestRecord { Id = id, Revision = 1 }], Total = 2, Limit = 1 }); + })); + using var repository = new RemoteNestRepository(http, "http://shop/"); + await Assert.ThrowsAsync(() => repository.UpdateFileAsync(id, [1], new NestRecord(), 1)); + Assert.Equal(0, writes); + } + + [Theory] + [InlineData(409)] + [InlineData(428)] + public async Task FailedGuardedUpdate_IsNotRetried(int status) + { + var id = Guid.NewGuid(); + var writes = 0; + using var http = new HttpClient(new Handler(request => + { + if (request.Method == HttpMethod.Get) return History(id); + writes++; + return new HttpResponseMessage((HttpStatusCode)status) { Content = new StringContent("current revision 5") }; + })); + using var repository = new RemoteNestRepository(http, "http://shop/"); + var error = await Assert.ThrowsAsync(() => repository.UpdateFileAsync(id, [1], new NestRecord(), 4)); + Assert.Contains(status.ToString(), error.Message); + Assert.Contains("current revision 5", error.Message); + Assert.Equal(1, writes); + } + + [Fact] + public async Task CancelledHistoryAndDownload_PropagateCancellation() + { + using var http = new HttpClient(new Handler(_ => throw new InvalidOperationException("Cancelled HTTP must not run"))); + using var repository = new RemoteNestRepository(http, "http://shop/"); + using var source = new CancellationTokenSource(); + source.Cancel(); + await Assert.ThrowsAnyAsync(() => repository.GetRevisionsAsync(Guid.NewGuid(), cancellationToken: source.Token)); + await Assert.ThrowsAnyAsync(() => repository.GetRevisionFileAsync(Guid.NewGuid(), 1, source.Token)); + } + + [Fact] + public async Task LegacyMetadataAndDelete_AreNeverSentToAnOldServer() + { + using var http = new HttpClient(new Handler(_ => throw new InvalidOperationException("Unsafe mutation"))); + using var repository = new RemoteNestRepository(http, "http://legacy/"); + await Assert.ThrowsAsync(() => repository.UpdateMetadataAsync(Guid.NewGuid(), new NestRecord())); + await Assert.ThrowsAsync(() => repository.DeleteAsync(Guid.NewGuid())); + } + + private static HttpResponseMessage History(Guid id) => Json(new NestRevisionPage + { + Items = [new NestRecord { Id = id, Revision = 4 }], + Total = 4, + Limit = 1, + }); + + private static HttpResponseMessage Json(T value) => new(HttpStatusCode.OK) { Content = JsonContent.Create(value) }; + + private sealed class Handler(Func reply) : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + return Task.FromResult(reply(request)); + } + } +}