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));
+ }
+ }
+}