From d216bcd406d48a56808fb48961d70edb8bf92aab Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sun, 11 Oct 2026 16:41:15 -0400 Subject: [PATCH] feat(server): version guarded metadata edits --- OpenNest.Data/INestRepository.cs | 4 + OpenNest.Data/RemoteNestRepository.cs | 17 ++++- OpenNest.Server.Tests/NestApiTests.cs | 2 +- .../NestDatabaseConcurrencyTests.cs | 3 + OpenNest.Server.Tests/NestMutationTests.cs | 74 +++++++++++++++++++ OpenNest.Server.Tests/NestRevisionTests.cs | 2 +- OpenNest.Server/NestDatabase.cs | 8 ++ OpenNest.Server/Program.cs | 18 ++++- 8 files changed, 122 insertions(+), 6 deletions(-) create mode 100644 OpenNest.Server.Tests/NestMutationTests.cs diff --git a/OpenNest.Data/INestRepository.cs b/OpenNest.Data/INestRepository.cs index f0ec687..427ca42 100644 --- a/OpenNest.Data/INestRepository.cs +++ b/OpenNest.Data/INestRepository.cs @@ -44,6 +44,10 @@ public interface INestRepository CancellationToken cancellationToken = default) => throw new NotSupportedException("This repository does not support nest version history."); + Task UpdateMetadataAsync(Guid id, NestRecord record, int expectedRevision, + CancellationToken cancellationToken = default) => + throw new NotSupportedException("This repository does not support guarded metadata edits."); + /// Atomically replaces the current archive only at the expected revision. Task UpdateFileAsync(Guid id, byte[] nestFile, NestRecord record, int expectedRevision, CancellationToken cancellationToken = default) => diff --git a/OpenNest.Data/RemoteNestRepository.cs b/OpenNest.Data/RemoteNestRepository.cs index a0b23a9..5ceffb7 100644 --- a/OpenNest.Data/RemoteNestRepository.cs +++ b/OpenNest.Data/RemoteNestRepository.cs @@ -129,8 +129,7 @@ public sealed class RemoteNestRepository : INestRepository, IDisposable public async Task UpdateMetadataAsync( Guid id, NestRecord record, CancellationToken cancellationToken = default) { - await Task.CompletedTask; - throw new IOException("Metadata updates require guarded revision support and are not available yet. Upgrade the client before editing server metadata."); + return await UpdateMetadataAsync(id, record, record.Revision, cancellationToken); } public async Task DeleteAsync(Guid id, CancellationToken cancellationToken = default) @@ -193,6 +192,20 @@ public sealed class RemoteNestRepository : INestRepository, IDisposable return saved; } + public async Task UpdateMetadataAsync(Guid id, NestRecord record, int expectedRevision, + CancellationToken cancellationToken = default) + { + ValidateId(id); + ValidateRevision(expectedRevision); + await GetRevisionsAsync(id, limit: 1, cancellationToken: cancellationToken); + using var request = new HttpRequestMessage(HttpMethod.Put, Url($"api/nests/{id}/metadata")); + request.Headers.IfMatch.Add(new EntityTagHeaderValue($"\"{expectedRevision.ToString(CultureInfo.InvariantCulture)}\"")); + request.Content = JsonContent.Create(record, options: JsonOptions); + using var response = await _httpClient.SendAsync(request, cancellationToken); + await EnsureSuccess(response, $"edit nest {id}; reload after a conflict", cancellationToken); + return await ReadRecord(response, cancellationToken); + } + private static void ValidateId(Guid id) { if (id == Guid.Empty) throw new ArgumentException("A nest id is required.", nameof(id)); diff --git a/OpenNest.Server.Tests/NestApiTests.cs b/OpenNest.Server.Tests/NestApiTests.cs index 4182ca8..56a62ef 100644 --- a/OpenNest.Server.Tests/NestApiTests.cs +++ b/OpenNest.Server.Tests/NestApiTests.cs @@ -141,7 +141,7 @@ public sealed class NestApiTests var before = await server.SnapshotAsync(); server.Client.DefaultRequestHeaders.IfMatch.Add(new EntityTagHeaderValue("\"1\"")); using var metadata = await server.Client.PutAsync($"/api/nests/{created.Id}/metadata", new StringContent("{}")); - Assert.Equal(HttpStatusCode.NotImplemented, metadata.StatusCode); + Assert.Equal(HttpStatusCode.BadRequest, metadata.StatusCode); using var delete = await server.Client.DeleteAsync($"/api/nests/{created.Id}"); Assert.Equal(HttpStatusCode.NotImplemented, delete.StatusCode); Assert.Equal(before, await server.SnapshotAsync()); diff --git a/OpenNest.Server.Tests/NestDatabaseConcurrencyTests.cs b/OpenNest.Server.Tests/NestDatabaseConcurrencyTests.cs index 1e4c2da..4f0bf0b 100644 --- a/OpenNest.Server.Tests/NestDatabaseConcurrencyTests.cs +++ b/OpenNest.Server.Tests/NestDatabaseConcurrencyTests.cs @@ -17,6 +17,7 @@ public sealed class NestDatabaseConcurrencyTests [InlineData("GetFile")] [InlineData("Insert")] [InlineData("UpdateFile")] + [InlineData("UpdateMetadata")] [InlineData("History")] [InlineData("GetRevisionFile")] [InlineData("Health")] @@ -45,6 +46,7 @@ public sealed class NestDatabaseConcurrencyTests case "GetFile": database.GetFile(id); break; case "Insert": database.Insert(Guid.NewGuid(), record, new byte[] { 4 }); break; case "UpdateFile": database.Update(id, record, new byte[] { 5 }, 1); break; + case "UpdateMetadata": database.UpdateMetadata(id, record, 1); break; case "History": database.History(id, 0, 100); break; case "GetRevisionFile": database.GetRevisionFile(id, 1); break; case "Health": Assert.True(database.IsHealthy()); break; @@ -103,6 +105,7 @@ public sealed class NestDatabaseConcurrencyTests () => database.Get(id), () => database.GetFile(id), () => database.Update(id, record, new byte[] { 2 }, 1), + () => database.UpdateMetadata(id, record, 2), () => database.History(id, 0, 100), () => Assert.True(database.IsHealthy()), () => database.GetRevisionFile(id, 1), diff --git a/OpenNest.Server.Tests/NestMutationTests.cs b/OpenNest.Server.Tests/NestMutationTests.cs new file mode 100644 index 0000000..d571a93 --- /dev/null +++ b/OpenNest.Server.Tests/NestMutationTests.cs @@ -0,0 +1,74 @@ +using System.Net; +using System.Net.Http.Json; +using Microsoft.Data.Sqlite; +using OpenNest.Data; + +namespace OpenNest.Server.Tests; + +public sealed class NestMutationTests +{ + [Fact] + public async Task Metadata_AppendsExactArchiveAndNoOpStillChecksPrecondition() + { + using var factory = new ServerFactory(); + using var client = factory.CreateClient(); + using var repository = new RemoteNestRepository(client, client.BaseAddress!.ToString()); + var original = await repository.UploadAsync([0, 255, 1], new NestRecord { Name = "Fixture" }); + original.Comments = "Edited"; + var saved = await repository.UpdateMetadataAsync(original.Id, original); + Assert.Equal(2, saved.Revision); + var retry = await repository.UpdateMetadataAsync(saved.Id, saved); + Assert.Equal(saved.SavedAt, retry.SavedAt); + Assert.Equal(2, retry.Revision); + Assert.Contains("409", (await Assert.ThrowsAsync(() => repository.UpdateMetadataAsync(original.Id, original))).Message); + var history = await repository.GetRevisionsAsync(saved.Id); + Assert.Equal(new[] { "Edited", "" }, history.Items.Select(r => r.Comments)); + foreach (var revision in history.Items) + Assert.Equal(new byte[] { 0, 255, 1 }, await repository.GetRevisionFileAsync(saved.Id, revision.Revision)); + using var reopened = new NestDatabase(factory.DatabasePath, pooling: false); + Assert.Equal(2, reopened.History(saved.Id, 0, 100)!.Total); + Assert.Equal(new byte[] { 0, 255, 1 }, reopened.GetFile(saved.Id)); + } + + [Theory] + [InlineData(null, 428)] + [InlineData("1", 400)] + [InlineData("\"2\"", 409)] + public async Task Metadata_RejectedWriteLeavesHistory(string? header, int status) + { + using var factory = new ServerFactory(); + using var client = factory.CreateClient(); + using var repository = new RemoteNestRepository(client, client.BaseAddress!.ToString()); + var saved = await repository.UploadAsync([1], new NestRecord { Name = "Fixture" }); + if (header is not null) client.DefaultRequestHeaders.TryAddWithoutValidation("If-Match", header); + using var response = await client.PutAsJsonAsync($"/api/nests/{saved.Id}/metadata", new NestRecord { Name = "Rejected" }); + Assert.Equal(status, (int)response.StatusCode); + using var reopened = new NestDatabase(factory.DatabasePath, pooling: false); + Assert.Equal("Fixture", reopened.Get(saved.Id)!.Name); + Assert.Equal(1, reopened.History(saved.Id, 0, 100)!.Total); + } + + [Fact] + public async Task Metadata_FailedSnapshotRollsBack() + { + using var factory = new ServerFactory(); + using var client = factory.CreateClient(); + using var repository = new RemoteNestRepository(client, client.BaseAddress!.ToString()); + var saved = await repository.UploadAsync([1], new NestRecord { Name = "Fixture" }); + FailSnapshots(factory.DatabasePath); + saved.Comments = "Rejected"; + Assert.Contains("500", (await Assert.ThrowsAsync(() => repository.UpdateMetadataAsync(saved.Id, saved))).Message); + using var reopened = new NestDatabase(factory.DatabasePath, pooling: false); + Assert.Equal("", reopened.Get(saved.Id)!.Comments); + Assert.Equal(1, reopened.History(saved.Id, 0, 100)!.Total); + } + + private static void FailSnapshots(string path) + { + using var connection = new SqliteConnection($"Data Source={path};Pooling=False"); + connection.Open(); + using var command = connection.CreateCommand(); + command.CommandText = "CREATE TRIGGER fail_snapshot BEFORE INSERT ON nest_revisions BEGIN SELECT RAISE(ABORT, 'fixture failure'); END"; + command.ExecuteNonQuery(); + } +} diff --git a/OpenNest.Server.Tests/NestRevisionTests.cs b/OpenNest.Server.Tests/NestRevisionTests.cs index 4ab392e..198f3ad 100644 --- a/OpenNest.Server.Tests/NestRevisionTests.cs +++ b/OpenNest.Server.Tests/NestRevisionTests.cs @@ -186,7 +186,7 @@ public sealed class NestRevisionTests [Theory] [InlineData("metadata", null, 428)] [InlineData("delete", null, 428)] - [InlineData("metadata", "\"1\"", 501)] + [InlineData("metadata", "\"1\"", 400)] [InlineData("delete", "\"1\"", 501)] [InlineData("file", "\"1\"", 400)] public async Task BlockedOrMalformedWrite_PreservesAllSnapshots(string route, string? header, int status) diff --git a/OpenNest.Server/NestDatabase.cs b/OpenNest.Server/NestDatabase.cs index 10c9740..b2c7b6e 100644 --- a/OpenNest.Server/NestDatabase.cs +++ b/OpenNest.Server/NestDatabase.cs @@ -158,6 +158,14 @@ public sealed class NestDatabase : IDisposable } } + public NestRecord? UpdateMetadata(Guid id, NestRecord record, int expectedRevision) + { + lock (_sync) + { + return Update(id, record, GetFile(id) ?? [], expectedRevision); + } + } + public NestRecord? Update(Guid id, NestRecord record, byte[] file, int expectedRevision) { lock (_sync) diff --git a/OpenNest.Server/Program.cs b/OpenNest.Server/Program.cs index 74b2d55..37681d0 100644 --- a/OpenNest.Server/Program.cs +++ b/OpenNest.Server/Program.cs @@ -101,8 +101,22 @@ app.MapPut("/api/nests/{id:guid}/file", async (Guid id, HttpContext http, NestDa } }); -app.MapPut("/api/nests/{id:guid}/metadata", (Guid id, HttpRequest request, NestDatabase db) => - BlockDeferredMutation(id, request, db)); +app.MapPut("/api/nests/{id:guid}/metadata", async (Guid id, HttpRequest request, NestDatabase db) => +{ + var error = ReadPrecondition(request, out var expected); + if (error is not null) return error; + NestRecord? record; + try { record = await request.ReadFromJsonAsync(); } + catch (Exception ex) when (ex is JsonException or InvalidOperationException or BadHttpRequestException) + { return Results.BadRequest("Invalid metadata JSON."); } + if (record is null) return Results.BadRequest("Missing metadata."); + try + { + var updated = db.UpdateMetadata(id, record, expected); + return updated is null ? Results.NotFound() : Results.Ok(updated); + } + catch (RevisionConflictException conflict) { return Results.Json(conflict.Current, statusCode: 409); } +}); app.MapDelete("/api/nests/{id:guid}", (Guid id, HttpRequest request, NestDatabase db) => BlockDeferredMutation(id, request, db));