diff --git a/OpenNest.Data/NestRecord.cs b/OpenNest.Data/NestRecord.cs
index faaadb5..f207cca 100644
--- a/OpenNest.Data/NestRecord.cs
+++ b/OpenNest.Data/NestRecord.cs
@@ -11,6 +11,9 @@ public sealed class NestRecord
{
public Guid Id { get; set; }
+ /// Server revision; zero means the server does not support history.
+ public int Revision { get; set; }
+
public string Name { get; set; } = "";
public string Customer { get; set; } = "";
diff --git a/OpenNest.Server.Tests/NestApiTests.cs b/OpenNest.Server.Tests/NestApiTests.cs
index 8ab4364..4182ca8 100644
--- a/OpenNest.Server.Tests/NestApiTests.cs
+++ b/OpenNest.Server.Tests/NestApiTests.cs
@@ -99,6 +99,7 @@ public sealed class NestApiTests
nest.Status = NestStatus.ToBeCut;
nest.Plates[0].Parts[0].Location = new Vector(4, 5);
var archive = Archive(nest);
+ server.Client.DefaultRequestHeaders.IfMatch.Add(new EntityTagHeaderValue("\"1\""));
var updated = await session.SaveAsync(server.Repository, server.Url, nest, archive);
Assert.Equal(created.Id, updated.Id);
@@ -133,41 +134,17 @@ public sealed class NestApiTests
}
[Fact]
- public async Task MetadataUpdate_ChangesFieldsWithoutTouchingArchiveOrFileSize()
+ public async Task MetadataAndDelete_AreBlockedWithoutMutation()
{
using var server = new TestServerSession();
- var nest = SyntheticNest();
- var archive = Archive(nest);
- var created = await new NestSaveSession().SaveAsync(server.Repository, server.Url, nest, archive);
-
- var change = Clone(created);
- change.Name = "Renamed by metadata update";
- change.Status = NestStatus.HasBeenCut;
- change.Comments = "Metadata-only change";
- change.FileSize = 1; // Server-computed; must be ignored.
- var updated = await server.Repository.UpdateMetadataAsync(created.Id, change);
-
- Assert.Equal(created.Id, updated.Id);
- Assert.Equal("Renamed by metadata update", updated.Name);
- Assert.Equal(NestStatus.HasBeenCut, updated.Status);
- Assert.Equal("Metadata-only change", updated.Comments);
- Assert.Equal(archive.LongLength, updated.FileSize);
- AssertSameMetadata(updated, (await server.Repository.GetMetadataAsync(created.Id))!);
- Assert.Equal(archive, await server.Repository.GetFileAsync(created.Id));
- }
-
- [Fact]
- public async Task Delete_RemovesRecordAndArchive()
- {
- using var server = new TestServerSession();
- var nest = SyntheticNest();
- var created = await new NestSaveSession().SaveAsync(server.Repository, server.Url, nest, Archive(nest));
-
- await server.Repository.DeleteAsync(created.Id);
-
- Assert.Empty(await server.Repository.ListAsync());
- Assert.Null(await server.Repository.GetMetadataAsync(created.Id));
- Assert.Null(await server.Repository.GetFileAsync(created.Id));
+ var created = await server.SeedAsync();
+ 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);
+ using var delete = await server.Client.DeleteAsync($"/api/nests/{created.Id}");
+ Assert.Equal(HttpStatusCode.NotImplemented, delete.StatusCode);
+ Assert.Equal(before, await server.SnapshotAsync());
}
public static TheoryData InvalidUploads()
@@ -199,6 +176,7 @@ public sealed class NestApiTests
var method = route == "create" ? HttpMethod.Post : HttpMethod.Put;
var path = route == "create" ? "/api/nests" : $"/api/nests/{existing.Id}/file";
using var request = new HttpRequestMessage(method, path) { Content = InvalidUploadContent(body) };
+ request.Headers.IfMatch.Add(new EntityTagHeaderValue("\"1\""));
using var response = await server.Client.SendAsync(request);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
@@ -208,7 +186,7 @@ public sealed class NestApiTests
[Theory]
[InlineData("{not-json")]
[InlineData("null")]
- public async Task MetadataUpdate_WithInvalidJson_Returns400AndLeavesStoreUnchanged(string json)
+ public async Task MetadataUpdate_WithoutPrecondition_Returns428AndLeavesStoreUnchanged(string json)
{
using var server = new TestServerSession();
var existing = await server.SeedAsync();
@@ -217,7 +195,7 @@ public sealed class NestApiTests
using var content = new StringContent(json, Encoding.UTF8, "application/json");
using var response = await server.Client.PutAsync($"/api/nests/{existing.Id}/metadata", content);
- Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
+ Assert.Equal((HttpStatusCode)428, response.StatusCode);
Assert.Equal(before, await server.SnapshotAsync());
}
@@ -250,6 +228,7 @@ public sealed class NestApiTests
foreach (var create in requests)
{
using var request = create();
+ request.Headers.IfMatch.Add(new EntityTagHeaderValue("\"1\""));
using var response = await server.Client.SendAsync(request);
Assert.True(response.StatusCode == HttpStatusCode.NotFound,
$"{request.Method} {request.RequestUri} returned {(int)response.StatusCode}, expected 404.");
@@ -339,6 +318,7 @@ public sealed class NestApiTests
{
var expected = NestRecordFactory.FromNest(nest, saved.Id, archive.LongLength);
expected.SavedAt = saved.SavedAt;
+ expected.Revision = saved.Revision;
return expected;
}
diff --git a/OpenNest.Server.Tests/NestDatabaseConcurrencyTests.cs b/OpenNest.Server.Tests/NestDatabaseConcurrencyTests.cs
index 32d5e9c..1e4c2da 100644
--- a/OpenNest.Server.Tests/NestDatabaseConcurrencyTests.cs
+++ b/OpenNest.Server.Tests/NestDatabaseConcurrencyTests.cs
@@ -17,8 +17,8 @@ public sealed class NestDatabaseConcurrencyTests
[InlineData("GetFile")]
[InlineData("Insert")]
[InlineData("UpdateFile")]
- [InlineData("UpdateMetadata")]
- [InlineData("Delete")]
+ [InlineData("History")]
+ [InlineData("GetRevisionFile")]
[InlineData("Health")]
[InlineData("Dispose")]
public async Task Concurrency_EveryOperationWaitsForTheSameMonitor(string operation)
@@ -44,9 +44,9 @@ public sealed class NestDatabaseConcurrencyTests
case "Get": database.Get(id); break;
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 }); break;
- case "UpdateMetadata": database.Update(id, record, null); break;
- case "Delete": database.Delete(id); break;
+ case "UpdateFile": database.Update(id, record, new byte[] { 5 }, 1); break;
+ case "History": database.History(id, 0, 100); break;
+ case "GetRevisionFile": database.GetRevisionFile(id, 1); break;
case "Health": Assert.True(database.IsHealthy()); break;
case "Dispose": database.Dispose(); break;
default: throw new InvalidOperationException($"Unknown test operation: {operation}");
@@ -102,10 +102,10 @@ public sealed class NestDatabaseConcurrencyTests
() => database.Query(new NestQuery { Search = "Synthetic", Limit = 1 }),
() => database.Get(id),
() => database.GetFile(id),
- () => database.Update(id, record, new byte[] { 2 }),
- () => database.Update(id, record, null),
+ () => database.Update(id, record, new byte[] { 2 }, 1),
+ () => database.History(id, 0, 100),
() => Assert.True(database.IsHealthy()),
- () => database.Delete(id),
+ () => database.GetRevisionFile(id, 1),
];
foreach (var operation in operations)
{
@@ -143,17 +143,13 @@ public sealed class NestDatabaseConcurrencyTests
Assert.Equal(file, database.GetFile(id));
record.Comments = "Archive updated";
file = file.Concat(new byte[] { 42 }).ToArray();
- saved = database.Update(id, record, file)!;
+ saved = database.Update(id, record, file, 1)!;
AssertStoredRecord(record, id, file, saved);
record.Comments = "Metadata only";
- saved = database.Update(id, record, null)!;
+ saved = database.Update(id, record, file, saved.Revision)!;
AssertStoredRecord(record, id, file, saved);
Assert.Equal(file.LongLength, saved.FileSize);
Assert.Equal(file, database.GetFile(id));
- var transient = Guid.NewGuid();
- database.Insert(transient, record, new byte[] { 9 });
- Assert.True(database.Delete(transient));
- Assert.Null(database.Get(transient));
records.Add((saved, file));
}
return records;
@@ -193,11 +189,12 @@ public sealed class NestDatabaseConcurrencyTests
Assert.Contains(await repository.ListAsync(watchdog.Token), r => r.Id == saved.Id);
nest.Notes = $"Archive change {lane}-{index}";
file = NestApiTests.Archive(nest);
+ client.DefaultRequestHeaders.IfMatch.Add(new System.Net.Http.Headers.EntityTagHeaderValue("\"1\""));
var updated = await session.SaveAsync(repository, client.BaseAddress.ToString(), nest, file,
cancellationToken: watchdog.Token);
+ client.DefaultRequestHeaders.IfMatch.Clear();
AssertStoredRecord(NestRecordFactory.FromNest(nest, saved.Id, file.LongLength), saved.Id, file, updated);
- updated.Comments = $"Metadata only {lane}-{index}";
- saved = await repository.UpdateMetadataAsync(updated.Id, updated, watchdog.Token);
+ saved = updated;
AssertStoredRecord(updated, updated.Id, file, saved);
Assert.Equal(file.LongLength, saved.FileSize);
Assert.Equal(file, await repository.GetFileAsync(saved.Id, watchdog.Token));
@@ -243,6 +240,7 @@ public sealed class NestDatabaseConcurrencyTests
expected.Id = id;
expected.FileSize = file.LongLength;
expected.SavedAt = actual.SavedAt;
+ expected.Revision = actual.Revision;
Assert.NotEqual(default, actual.SavedAt);
AssertRecord(expected, actual);
}
diff --git a/OpenNest.Server.Tests/NestRevisionTests.cs b/OpenNest.Server.Tests/NestRevisionTests.cs
new file mode 100644
index 0000000..4ab392e
--- /dev/null
+++ b/OpenNest.Server.Tests/NestRevisionTests.cs
@@ -0,0 +1,247 @@
+using System.Net;
+using System.Net.Http.Json;
+using System.Text.Json;
+using Microsoft.Data.Sqlite;
+using Microsoft.Extensions.DependencyInjection;
+using OpenNest.Data;
+
+namespace OpenNest.Server.Tests;
+
+public sealed class NestRevisionTests
+{
+ private const string LegacySchema = """
+ CREATE TABLE nests (
+ id TEXT PRIMARY KEY, name TEXT NOT NULL, customer TEXT NOT NULL DEFAULT '',
+ dateCreated TEXT NOT NULL, dateModified TEXT NOT NULL, material TEXT NOT NULL DEFAULT '',
+ thickness REAL NOT NULL DEFAULT 0, status TEXT NOT NULL DEFAULT 'Quote',
+ plateCount INTEGER NOT NULL DEFAULT 0, partCount INTEGER NOT NULL DEFAULT 0,
+ comments TEXT NOT NULL DEFAULT '', madeBy TEXT NOT NULL DEFAULT '',
+ fileSize INTEGER NOT NULL DEFAULT 0, savedAt TEXT NOT NULL, file BLOB NOT NULL);
+ """;
+
+ private static readonly JsonSerializerOptions Options = new(JsonSerializerDefaults.Web)
+ { Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() } };
+
+ internal static MultipartFormDataContent Body(NestRecord record, byte[] bytes)
+ {
+ var body = new MultipartFormDataContent();
+ body.Add(new StringContent(JsonSerializer.Serialize(record)), "metadata");
+ body.Add(new ByteArrayContent(bytes), "file", "fixture.nest");
+ return body;
+ }
+
+ [Fact]
+ public async Task ChangedSave_PreservesExactHistoryAndNoOp()
+ {
+ using var factory = new ServerFactory();
+ using var client = factory.CreateClient();
+ var record = new NestRecord { Name = "Initial" };
+ using var created = await client.PostAsync("/api/nests", Body(record, [1, 2]));
+ var current = (await created.Content.ReadFromJsonAsync(Options))!;
+ Assert.Equal(1, current.Revision);
+ record.Name = "Changed";
+ client.DefaultRequestHeaders.TryAddWithoutValidation("If-Match", "\"1\"");
+ using var updated = await client.PutAsync($"/api/nests/{current.Id}/file", Body(record, [3, 4]));
+ Assert.Equal(HttpStatusCode.OK, updated.StatusCode);
+ var latest = (await updated.Content.ReadFromJsonAsync(Options))!;
+ Assert.Equal(2, latest.Revision);
+ Assert.Equal(new byte[] { 1, 2 }, await client.GetByteArrayAsync($"/api/nests/{current.Id}/revisions/1/file"));
+ Assert.Equal(new byte[] { 3, 4 }, await client.GetByteArrayAsync($"/api/nests/{current.Id}/file"));
+ client.DefaultRequestHeaders.Remove("If-Match");
+ client.DefaultRequestHeaders.TryAddWithoutValidation("If-Match", "\"2\"");
+ using var retry = await client.PutAsync($"/api/nests/{current.Id}/file", Body(record, [3, 4]));
+ Assert.Equal(latest.SavedAt, (await retry.Content.ReadFromJsonAsync(Options))!.SavedAt);
+ var page = await client.GetFromJsonAsync($"/api/nests/{current.Id}/revisions?limit=1", Options);
+ Assert.Equal(2, page!.Total);
+ Assert.Equal(2, Assert.Single(page.Items).Revision);
+ }
+
+ [Theory]
+ [InlineData(null, 428)]
+ [InlineData("1", 400)]
+ [InlineData("\"0\"", 400)]
+ [InlineData("\"2\"", 409)]
+ public async Task RejectedWrite_LeavesCurrentAndHistoryUnchanged(string? header, int status)
+ {
+ using var factory = new ServerFactory();
+ Guid id;
+ using (var client = factory.CreateClient())
+ {
+ using var created = await client.PostAsync("/api/nests", Body(new NestRecord { Name = "Initial" }, [1]));
+ id = (await created.Content.ReadFromJsonAsync(Options))!.Id;
+ if (header is not null) client.DefaultRequestHeaders.TryAddWithoutValidation("If-Match", header);
+ using var response = await client.PutAsync($"/api/nests/{id}/file", Body(new NestRecord { Name = "Rejected" }, [2]));
+ Assert.Equal(status, (int)response.StatusCode);
+ Assert.Equal(new byte[] { 1 }, await client.GetByteArrayAsync($"/api/nests/{id}/file"));
+ }
+ using var reopened = new NestDatabase(factory.DatabasePath, pooling: false);
+ Assert.Equal("Initial", reopened.Get(id)!.Name);
+ Assert.Equal(1, reopened.Get(id)!.Revision);
+ }
+
+ [Fact]
+ public async Task FailedRevisionInsert_RollsBackCurrentProjection()
+ {
+ using var factory = new ServerFactory();
+ using var client = factory.CreateClient();
+ using var created = await client.PostAsync("/api/nests", Body(new NestRecord { Name = "Initial" }, [1]));
+ var current = (await created.Content.ReadFromJsonAsync(Options))!;
+ using (var connection = new SqliteConnection($"Data Source={factory.DatabasePath};Pooling=False"))
+ {
+ connection.Open();
+ using var command = connection.CreateCommand();
+ command.CommandText = "CREATE TRIGGER fail_revision BEFORE INSERT ON nest_revisions BEGIN SELECT RAISE(ABORT, 'fixture failure'); END";
+ command.ExecuteNonQuery();
+ }
+ client.DefaultRequestHeaders.TryAddWithoutValidation("If-Match", "\"1\"");
+ using var failed = await client.PutAsync($"/api/nests/{current.Id}/file", Body(new NestRecord { Name = "Rejected" }, [2]));
+ Assert.Equal(HttpStatusCode.InternalServerError, failed.StatusCode);
+ Assert.Equal(new byte[] { 1 }, await client.GetByteArrayAsync($"/api/nests/{current.Id}/file"));
+ using var reopened = new NestDatabase(factory.DatabasePath, pooling: false);
+ Assert.Equal("Initial", reopened.Get(current.Id)!.Name);
+ Assert.Equal(1, reopened.History(current.Id, 0, 100)!.Total);
+ }
+ [Fact]
+ public void OldSchema_BackfillsExactRowsAndIsIdempotent()
+ {
+ using var factory = new ServerFactory();
+ var ids = new[] { Guid.NewGuid(), Guid.NewGuid() };
+ var bytes = new[] { new byte[] { 0, 1, 255 }, new byte[] { 7, 8, 9, 0 } };
+ var timestamp = new DateTime(2026, 1, 2, 3, 4, 5);
+ using (var connection = new SqliteConnection($"Data Source={factory.DatabasePath};Pooling=False"))
+ {
+ connection.Open();
+ using var command = connection.CreateCommand();
+ command.CommandText = LegacySchema;
+ command.ExecuteNonQuery();
+ for (var i = 0; i < ids.Length; i++)
+ {
+ command.Parameters.Clear();
+ command.CommandText = "INSERT INTO nests VALUES ($id, $name, 'Fixture', $date, $date, 'Alloy', 0.25, 'ToBeCut', 2, 3, 'Notes', 'Author', $size, $date, $file)";
+ command.Parameters.AddWithValue("$id", ids[i].ToString());
+ command.Parameters.AddWithValue("$name", $"Legacy {i}");
+ command.Parameters.AddWithValue("$date", timestamp.ToString("o"));
+ command.Parameters.AddWithValue("$size", bytes[i].Length);
+ command.Parameters.AddWithValue("$file", bytes[i]);
+ command.ExecuteNonQuery();
+ }
+ }
+ for (var open = 0; open < 2; open++)
+ {
+ using var database = new NestDatabase(factory.DatabasePath, pooling: false);
+ Assert.Equal(2, database.List().Count);
+ for (var i = 0; i < ids.Length; i++)
+ {
+ var current = database.Get(ids[i])!;
+ Assert.Equal(1, current.Revision);
+ Assert.Equal(timestamp, current.SavedAt);
+ Assert.Equal($"Legacy {i}", current.Name);
+ Assert.Equal("Fixture", current.Customer);
+ Assert.Equal("Notes", current.Comments);
+ Assert.Equal(bytes[i], database.GetFile(ids[i]));
+ Assert.Equal(bytes[i], database.GetRevisionFile(ids[i], 1));
+ var historical = Assert.Single(database.History(ids[i], 0, 100)!.Items);
+ Assert.Equal(JsonSerializer.Serialize(current), JsonSerializer.Serialize(historical));
+ }
+ }
+ }
+
+ [Theory]
+ [InlineData("limit=0")]
+ [InlineData("limit=201")]
+ [InlineData("offset=-1")]
+ [InlineData("limit=1&limit=2")]
+ [InlineData("limit=no")]
+ [InlineData("unknown=1")]
+ public async Task History_InvalidPagination_IsRejected(string query)
+ {
+ using var factory = new ServerFactory();
+ using var client = factory.CreateClient();
+ using var response = await client.GetAsync($"/api/nests/{Guid.NewGuid()}/revisions?{query}");
+ Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
+ }
+
+ [Fact]
+ public async Task ConcurrentGuardedSaves_OneWinsAndOneConflicts()
+ {
+ using var factory = new ServerFactory();
+ using var client = factory.CreateClient();
+ using var created = await client.PostAsync("/api/nests", Body(new NestRecord { Name = "Initial" }, [1]));
+ var current = (await created.Content.ReadFromJsonAsync(Options))!;
+ client.DefaultRequestHeaders.TryAddWithoutValidation("If-Match", "\"1\"");
+ var responses = await Task.WhenAll(Enumerable.Range(2, 2).Select(i =>
+ client.PutAsync($"/api/nests/{current.Id}/file", Body(new NestRecord { Name = $"Writer {i}" }, [(byte)i]))));
+ try
+ {
+ Assert.Single(responses.Where(r => r.StatusCode == HttpStatusCode.OK));
+ var conflict = Assert.Single(responses.Where(r => r.StatusCode == HttpStatusCode.Conflict));
+ Assert.Equal(2, (await conflict.Content.ReadFromJsonAsync(Options))!.Revision);
+ var history = await client.GetFromJsonAsync($"/api/nests/{current.Id}/revisions", Options);
+ Assert.Equal(new[] { 2, 1 }, history!.Items.Select(r => r.Revision));
+ Assert.Equal(new byte[] { 1 }, await client.GetByteArrayAsync($"/api/nests/{current.Id}/revisions/1/file"));
+ }
+ finally { foreach (var response in responses) response.Dispose(); }
+ }
+
+ [Theory]
+ [InlineData("metadata", null, 428)]
+ [InlineData("delete", null, 428)]
+ [InlineData("metadata", "\"1\"", 501)]
+ [InlineData("delete", "\"1\"", 501)]
+ [InlineData("file", "\"1\"", 400)]
+ public async Task BlockedOrMalformedWrite_PreservesAllSnapshots(string route, string? header, int status)
+ {
+ using var factory = new ServerFactory();
+ using var client = factory.CreateClient();
+ using var created = await client.PostAsync("/api/nests", Body(new NestRecord { Name = "Initial" }, [1]));
+ var current = (await created.Content.ReadFromJsonAsync(Options))!;
+ if (header is not null) client.DefaultRequestHeaders.TryAddWithoutValidation("If-Match", header);
+ using var response = route == "delete"
+ ? await client.DeleteAsync($"/api/nests/{current.Id}")
+ : await client.PutAsync($"/api/nests/{current.Id}/{route}", new StringContent("Malformed"));
+ Assert.Equal(status, (int)response.StatusCode);
+ Assert.Equal(new byte[] { 1 }, await client.GetByteArrayAsync($"/api/nests/{current.Id}/file"));
+ var history = await client.GetFromJsonAsync($"/api/nests/{current.Id}/revisions", Options);
+ Assert.Equal(1, history!.Total);
+ using var reopened = new NestDatabase(factory.DatabasePath, pooling: false);
+ Assert.Equal("Initial", reopened.Get(current.Id)!.Name);
+ Assert.Equal(new byte[] { 1 }, reopened.GetRevisionFile(current.Id, 1));
+ }
+
+ [Fact]
+ public async Task UnknownHistoryOrVersion_Is404_AndOffsetIsBounded()
+ {
+ using var factory = new ServerFactory();
+ using var client = factory.CreateClient();
+ using var unknown = await client.GetAsync($"/api/nests/{Guid.NewGuid()}/revisions");
+ Assert.Equal(HttpStatusCode.NotFound, unknown.StatusCode);
+ using var created = await client.PostAsync("/api/nests", Body(new NestRecord { Name = "Initial" }, [1]));
+ var current = (await created.Content.ReadFromJsonAsync(Options))!;
+ using var missing = await client.GetAsync($"/api/nests/{current.Id}/revisions/2/file");
+ Assert.Equal(HttpStatusCode.NotFound, missing.StatusCode);
+ var page = await client.GetFromJsonAsync($"/api/nests/{current.Id}/revisions?offset=2147483647", Options);
+ Assert.Empty(page!.Items);
+ Assert.Equal(1, page.Total);
+ }
+
+ [Fact]
+ public void FailedMigration_RollsBackSchemaAndCanRestart()
+ {
+ using var factory = new ServerFactory();
+ using var connection = new SqliteConnection($"Data Source={factory.DatabasePath};Pooling=False");
+ connection.Open();
+ using var command = connection.CreateCommand();
+ command.CommandText = LegacySchema + "CREATE TABLE nest_revisions (fixture TEXT);";
+ command.ExecuteNonQuery();
+ Assert.Throws(() => new NestDatabase(factory.DatabasePath, pooling: false));
+ command.CommandText = "PRAGMA user_version";
+ Assert.Equal(0L, command.ExecuteScalar());
+ command.CommandText = "SELECT COUNT(*) FROM pragma_table_info('nests') WHERE name='revision'";
+ Assert.Equal(0L, command.ExecuteScalar());
+ command.CommandText = "DROP TABLE nest_revisions";
+ command.ExecuteNonQuery();
+ using var database = new NestDatabase(factory.DatabasePath, pooling: false);
+ Assert.Empty(database.List());
+ }
+
+}
diff --git a/OpenNest.Server/NestDatabase.cs b/OpenNest.Server/NestDatabase.cs
index 1aaad3f..10c9740 100644
--- a/OpenNest.Server/NestDatabase.cs
+++ b/OpenNest.Server/NestDatabase.cs
@@ -29,7 +29,9 @@ public sealed class NestDatabase : IDisposable
Pooling = pooling,
}.ToString());
_connection.Open();
- Execute("""
+ try
+ {
+ Execute("""
PRAGMA journal_mode=WAL;
CREATE TABLE IF NOT EXISTS nests (
id TEXT PRIMARY KEY,
@@ -50,6 +52,9 @@ public sealed class NestDatabase : IDisposable
);
CREATE INDEX IF NOT EXISTS ix_nests_savedAt ON nests(savedAt DESC);
""");
+ Migrate();
+ }
+ catch { _connection.Dispose(); throw; }
}
public IReadOnlyList List()
@@ -134,7 +139,9 @@ public sealed class NestDatabase : IDisposable
{
lock (_sync)
{
+ using var transaction = _connection.BeginTransaction();
using var command = _connection.CreateCommand();
+ command.Transaction = transaction;
command.CommandText = """
INSERT INTO nests (id, name, customer, dateCreated, dateModified, material,
thickness, status, plateCount, partCount, comments, madeBy, fileSize,
@@ -145,42 +152,114 @@ public sealed class NestDatabase : IDisposable
""";
AddRecordParameters(command, id, record, file, updateFile: true);
command.ExecuteNonQuery();
+ Snapshot(id, transaction);
+ transaction.Commit();
return Get(id) ?? throw new InvalidOperationException("Insert did not persist.");
}
}
- public NestRecord? Update(Guid id, NestRecord record, byte[]? file)
+ public NestRecord? Update(Guid id, NestRecord record, byte[] file, int expectedRevision)
{
lock (_sync)
{
- if (Get(id) is null)
- return null;
+ using var transaction = _connection.BeginTransaction();
+ var current = Get(id);
+ if (current is null) return null;
+ if (current.Revision != expectedRevision) throw new RevisionConflictException(current);
+ if (SameMetadata(current, record) && GetFile(id)!.AsSpan().SequenceEqual(file)) return current;
using var command = _connection.CreateCommand();
- var fileClause = file is null ? "" : ", file = $file, fileSize = $fileSize";
+ command.Transaction = transaction;
+ const string fileClause = ", file = $file, fileSize = $fileSize";
command.CommandText = $"""
UPDATE nests SET
name = $name, customer = $customer, dateCreated = $dateCreated,
dateModified = $dateModified, material = $material,
thickness = $thickness, status = $status, plateCount = $plateCount,
partCount = $partCount, comments = $comments, madeBy = $madeBy,
- savedAt = $savedAt{fileClause}
+ savedAt = $savedAt, revision = $revision{fileClause}
WHERE id = $id
""";
AddRecordParameters(command, id, record, file, updateFile: file is not null);
+ command.Parameters.AddWithValue("$revision", checked(current.Revision + 1));
command.ExecuteNonQuery();
+ Snapshot(id, transaction);
+ transaction.Commit();
return Get(id);
}
}
- public bool Delete(Guid id)
+ public NestPage? History(Guid id, int offset, int limit)
+ {
+ if (offset < 0 || limit is < 1 or > 200) throw new ArgumentOutOfRangeException(nameof(limit));
+ lock (_sync)
+ {
+ if (Get(id) is null) return null;
+ using var count = _connection.CreateCommand();
+ count.CommandText = "SELECT COUNT(*) FROM nest_revisions WHERE id=$id";
+ count.Parameters.AddWithValue("$id", id.ToString());
+ var total = checked((int)(long)count.ExecuteScalar()!);
+ using var command = _connection.CreateCommand();
+ command.CommandText = $"SELECT {RecordColumns} FROM nest_revisions WHERE id=$id ORDER BY revision DESC LIMIT $limit OFFSET $offset";
+ command.Parameters.AddWithValue("$id", id.ToString());
+ command.Parameters.AddWithValue("$limit", limit);
+ command.Parameters.AddWithValue("$offset", offset);
+ var records = new List();
+ using var reader = command.ExecuteReader();
+ while (reader.Read()) records.Add(ReadRecord(reader));
+ return new NestPage { Items = records, Total = total, Offset = offset, Limit = limit };
+ }
+ }
+
+ public byte[]? GetRevisionFile(Guid id, int revision)
{
lock (_sync)
{
using var command = _connection.CreateCommand();
- command.CommandText = "DELETE FROM nests WHERE id = $id";
+ command.CommandText = "SELECT file FROM nest_revisions WHERE id=$id AND revision=$revision";
command.Parameters.AddWithValue("$id", id.ToString());
- return command.ExecuteNonQuery() > 0;
+ command.Parameters.AddWithValue("$revision", revision);
+ return command.ExecuteScalar() as byte[];
+ }
+ }
+
+ private static bool SameMetadata(NestRecord a, NestRecord b) =>
+ a.Name == b.Name && a.Customer == b.Customer && a.DateCreated == b.DateCreated &&
+ a.DateModified == b.DateModified && a.Material == b.Material && a.Thickness == b.Thickness &&
+ a.Status == b.Status && a.PlateCount == b.PlateCount && a.PartCount == b.PartCount &&
+ a.Comments == b.Comments && a.MadeBy == b.MadeBy;
+
+ private void Snapshot(Guid id, SqliteTransaction transaction)
+ {
+ using var command = _connection.CreateCommand();
+ command.Transaction = transaction;
+ command.CommandText = $"INSERT INTO nest_revisions ({RecordColumns}, file) SELECT {RecordColumns}, file FROM nests WHERE id=$id";
+ command.Parameters.AddWithValue("$id", id.ToString());
+ command.ExecuteNonQuery();
+ }
+
+ private void Migrate()
+ {
+ lock (_sync)
+ {
+ using var transaction = _connection.BeginTransaction();
+ using var command = _connection.CreateCommand();
+ command.Transaction = transaction;
+ command.CommandText = "PRAGMA user_version";
+ var version = (long)command.ExecuteScalar()!;
+ if (version > 1) throw new InvalidOperationException("Database schema is newer than this server.");
+ if (version == 0)
+ {
+ command.CommandText = """
+ ALTER TABLE nests ADD COLUMN revision INTEGER NOT NULL DEFAULT 1;
+ CREATE TABLE nest_revisions AS SELECT * FROM nests WHERE 0;
+ CREATE UNIQUE INDEX ix_nest_revisions_key ON nest_revisions(id, revision);
+ INSERT INTO nest_revisions SELECT * FROM nests;
+ PRAGMA user_version=1;
+ """;
+ command.ExecuteNonQuery();
+ }
+ transaction.Commit();
}
}
@@ -217,7 +296,7 @@ public sealed class NestDatabase : IDisposable
private const string RecordColumns = """
id, name, customer, dateCreated, dateModified, material, thickness,
- status, plateCount, partCount, comments, madeBy, fileSize, savedAt
+ status, plateCount, partCount, comments, madeBy, fileSize, savedAt, revision
""";
// Case-insensitive (ASCII) substring over the text columns and the status's stored
@@ -303,6 +382,12 @@ public sealed class NestDatabase : IDisposable
MadeBy = reader.GetString(11),
FileSize = reader.GetInt64(12),
SavedAt = DateTime.Parse(reader.GetString(13)),
+ Revision = reader.GetInt32(14),
};
}
}
+
+public sealed class RevisionConflictException(NestRecord current) : Exception("The nest has changed. Reload or Save As.")
+{
+ public NestRecord Current { get; } = current;
+}
diff --git a/OpenNest.Server/Program.cs b/OpenNest.Server/Program.cs
index 22e5f45..74b2d55 100644
--- a/OpenNest.Server/Program.cs
+++ b/OpenNest.Server/Program.cs
@@ -62,41 +62,75 @@ app.MapPost("/api/nests", async (HttpContext http, NestDatabase db) =>
var id = record!.Id == Guid.Empty ? Guid.NewGuid() : record.Id;
return Results.Ok(db.Insert(id, record, file!));
});
+app.MapGet("/api/nests/{id:guid}/revisions", (Guid id, HttpRequest request, NestDatabase db) =>
+{
+ var offset = 0;
+ var limit = 100;
+ foreach (var parameter in request.Query)
+ {
+ if (parameter.Value.Count != 1 || !int.TryParse(parameter.Value[0], out var value))
+ return Results.BadRequest("Invalid history pagination.");
+ switch (parameter.Key)
+ {
+ case "offset": offset = value; break;
+ case "limit": limit = value; break;
+ default: return Results.BadRequest("Unknown history parameter.");
+ }
+ }
+ if (offset < 0 || limit is < 1 or > 200) return Results.BadRequest("Invalid history pagination.");
+ return db.History(id, offset, limit) is { } page ? Results.Ok(page) : Results.NotFound();
+});
+app.MapGet("/api/nests/{id:guid}/revisions/{revision:int}/file", (Guid id, int revision, NestDatabase db) =>
+ revision < 1 ? Results.BadRequest("Invalid revision.") :
+ db.GetRevisionFile(id, revision) is { } file ? Results.File(file, "application/zip", $"{id}-r{revision}.nest") : Results.NotFound());
+
app.MapPut("/api/nests/{id:guid}/file", async (Guid id, HttpContext http, NestDatabase db) =>
{
+ var precondition = ReadPrecondition(http.Request, out var expected);
+ if (precondition is not null) return precondition;
var (record, file, error) = await ReadMultipart(http);
- if (error is not null)
- return error;
-
- var updated = db.Update(id, record!, file);
- return updated is null ? Results.NotFound() : Results.Ok(updated);
-});
-
-app.MapPut("/api/nests/{id:guid}/metadata", async (Guid id, HttpContext http, NestDatabase db) =>
-{
- NestRecord? record;
+ if (error is not null) return error;
try
{
- record = await http.Request.ReadFromJsonAsync();
+ var updated = db.Update(id, record!, file!, expected);
+ return updated is null ? Results.NotFound() : Results.Ok(updated);
}
- catch (JsonException)
+ catch (RevisionConflictException conflict)
{
- return Results.BadRequest("Invalid metadata JSON.");
+ return Results.Json(conflict.Current, statusCode: StatusCodes.Status409Conflict);
}
-
- if (record is null)
- return Results.BadRequest("Missing metadata.");
-
- // Keep the stored archive's size; metadata updates never touch the file.
- var updated = db.Update(id, record, file: null);
- return updated is null ? Results.NotFound() : Results.Ok(updated);
});
-app.MapDelete("/api/nests/{id:guid}", (Guid id, NestDatabase db) =>
- db.Delete(id) ? Results.NoContent() : Results.NotFound());
+app.MapPut("/api/nests/{id:guid}/metadata", (Guid id, HttpRequest request, NestDatabase db) =>
+ BlockDeferredMutation(id, request, db));
+app.MapDelete("/api/nests/{id:guid}", (Guid id, HttpRequest request, NestDatabase db) =>
+ BlockDeferredMutation(id, request, db));
app.Run();
+static IResult? ReadPrecondition(HttpRequest request, out int revision)
+{
+ revision = 0;
+ var values = request.Headers.IfMatch;
+ if (values.Count == 0) return Results.Json("Upgrade/reload required: send If-Match with the current revision.", statusCode: 428);
+ var text = values.Count == 1 ? values[0] : null;
+ if (text is null || text.Length < 3 || text[0] != '"' || text[^1] != '"' ||
+ !text[1..^1].All(c => c is >= '0' and <= '9') ||
+ !int.TryParse(text[1..^1], out revision) || revision < 1)
+ return Results.BadRequest("If-Match must contain one quoted positive integer revision.");
+ return null;
+}
+
+static IResult BlockDeferredMutation(Guid id, HttpRequest request, NestDatabase db)
+{
+ var error = ReadPrecondition(request, out var expected);
+ if (error is not null) return error;
+ var current = db.Get(id);
+ if (current is null) return Results.NotFound();
+ if (current.Revision != expected) return Results.Json(current, statusCode: 409);
+ return Results.Json("Versioned metadata and visibility changes are not available yet; no changes were made.", statusCode: 501);
+}
+
// Shared multipart reader for upload endpoints. Returns a (record, file, error) triple.
static async Task<(NestRecord? Record, byte[]? File, IResult? Error)> ReadMultipart(HttpContext http)
{