feat(data): fetch bounded nest history and guard saves

This commit is contained in:
aj committed 2026-10-11 16:38:35 -04:00
1 parent a47b414392
commit ed7600dc90
7 files changed
+343 -32

No files matched your search

+15
View File
@@ -34,4 +34,19 @@ public interface INestRepository
Guid id, NestRecord record, CancellationToken cancellationToken = default);
Task DeleteAsync(Guid id, CancellationToken cancellationToken = default);
/// <summary>Reads bounded immutable history; unsupported backends fail closed.</summary>
Task<NestRevisionPage> GetRevisionsAsync(Guid id, int offset = 0, int limit = 100,
CancellationToken cancellationToken = default) =>
throw new NotSupportedException("This repository does not support nest version history.");
Task<byte[]?> GetRevisionFileAsync(Guid id, int revisionNumber,
CancellationToken cancellationToken = default) =>
throw new NotSupportedException("This repository does not support nest version history.");
/// <summary>Atomically replaces the current archive only at the expected revision.</summary>
Task<NestRecord> 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.");
}
+12
View File
@@ -0,0 +1,12 @@
namespace OpenNest.Data;
/// <summary>A bounded page of immutable metadata snapshots, newest first.</summary>
public sealed class NestRevisionPage
{
public const int DefaultLimit = 100;
public const int MaxLimit = 200;
public IReadOnlyList<NestRecord> Items { get; init; } = Array.Empty<NestRecord>();
public int Total { get; init; }
public int Offset { get; init; }
public int Limit { get; init; }
}
+33 -6
View File
@@ -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<NestRecord> 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;
}
+69 -11
View File
@@ -123,26 +123,84 @@ public sealed class RemoteNestRepository : INestRepository, IDisposable
public async Task<NestRecord> 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<NestRecord> 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<NestRevisionPage> 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<NestRevisionPage>(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<byte[]?> 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<NestRecord> 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()
@@ -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<IOException>(() => 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<IOException>(() => 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<NestRecord> 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<NestRecord> 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<NestRecord> UpdateFileAsync(Guid id, byte[] file, NestRecord record, CancellationToken ct = default)
{
Updates++;
+9 -15
View File
@@ -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<IOException>(() => 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<IOException>(() => 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<IOException>(() => 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]
@@ -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<IOException>(() => 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<string>();
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<IOException>(() => 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<ArgumentOutOfRangeException>(() => 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<IOException>(() => 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<IOException>(() => 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<OperationCanceledException>(() => repository.GetRevisionsAsync(Guid.NewGuid(), cancellationToken: source.Token));
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => 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<IOException>(() => repository.UpdateMetadataAsync(Guid.NewGuid(), new NestRecord()));
await Assert.ThrowsAsync<IOException>(() => 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>(T value) => new(HttpStatusCode.OK) { Content = JsonContent.Create(value) };
private sealed class Handler(Func<HttpRequestMessage, HttpResponseMessage> reply) : HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
return Task.FromResult(reply(request));
}
}
}