mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-13 14:41:59 -04:00
feat(server): version guarded metadata edits
This commit is contained in:
1 parent
11471e3c71
commit
d216bcd406
8 files changed
+122
-6
No files matched your search
@@ -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) =>
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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));
|
||||
|
||||
|
||||
Reference in new issue
Block a user