feat(server): version guarded metadata edits

This commit is contained in:
aj committed 2026-10-11 16:49:12 -04:00
1 parent 11471e3c71
commit d216bcd406
8 files changed
+122 -6

No files matched your search

+4
View File
@@ -44,6 +44,10 @@ public interface INestRepository
CancellationToken cancellationToken = default) =>
throw new NotSupportedException("This repository does not support nest version history.");
Task<NestRecord> UpdateMetadataAsync(Guid id, NestRecord record, int expectedRevision,
CancellationToken cancellationToken = default) =>
throw new NotSupportedException("This repository does not support guarded metadata edits.");
/// <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) =>
+15 -2
View File
@@ -129,8 +129,7 @@ public sealed class RemoteNestRepository : INestRepository, IDisposable
public async Task<NestRecord> 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<NestRecord> 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));
+1 -1
View File
@@ -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());
@@ -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),
@@ -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<IOException>(() => 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<IOException>(() => 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();
}
}
+1 -1
View File
@@ -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)
+8
View File
@@ -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)
+16 -2
View File
@@ -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<NestRecord>(); }
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));