mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-12 13:14:12 -04:00
183 lines
9.0 KiB
C#
183 lines
9.0 KiB
C#
using System.Text.Json;
|
|
|
|
namespace OpenNest.Data;
|
|
|
|
/// <summary>
|
|
/// URL-bound, bounded history browsing. Historical reads never bind an editable document.
|
|
/// A selection change cancels and invalidates previous reads even if a backend ignores cancellation.
|
|
/// Mutations use the displayed current revision; an uncertain failure requires an explicit refresh.
|
|
/// </summary>
|
|
public sealed class NestHistorySession : IDisposable
|
|
{
|
|
private readonly INestRepository repository;
|
|
private CancellationTokenSource? pending;
|
|
private int generation;
|
|
private bool disposed;
|
|
public string ServerUrl { get; }
|
|
public Guid NestId { get; private set; }
|
|
public bool Deleted { get; private set; }
|
|
public NestRecord? Current { get; private set; }
|
|
public NestRevisionPage? Page { get; private set; }
|
|
public bool IsMutating { get; private set; }
|
|
public const int PageSize = 50;
|
|
|
|
public NestHistorySession(INestRepository repository, string serverUrl)
|
|
{
|
|
this.repository = repository ?? throw new ArgumentNullException(nameof(repository));
|
|
ServerUrl = Normalize(serverUrl);
|
|
}
|
|
|
|
public async Task<bool> LoadAsync(Guid id, bool deleted = false, int offset = 0,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
ObjectDisposedException.ThrowIf(disposed, this);
|
|
if (IsMutating) throw new InvalidOperationException("Wait for the current operation to finish.");
|
|
if (id == Guid.Empty || offset < 0) throw new ArgumentOutOfRangeException(nameof(id));
|
|
pending?.Cancel();
|
|
var run = ++generation;
|
|
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
|
pending = cts;
|
|
NestId = id;
|
|
Deleted = deleted;
|
|
Current = null;
|
|
Page = null;
|
|
try
|
|
{
|
|
var record = deleted ? await repository.GetDeletedMetadataAsync(id, cts.Token)
|
|
: await repository.GetMetadataAsync(id, cts.Token);
|
|
if (record == null) throw new FileNotFoundException("This nest is no longer in this view. Refresh the list.");
|
|
ValidateRecord(record, id, deleted);
|
|
if (record.Revision < 1) throw new NotSupportedException("Upgrade the nest server to browse or change version history. Current nests can still be opened read-only.");
|
|
var page = deleted ? await repository.GetDeletedRevisionsAsync(id, offset, PageSize, cts.Token)
|
|
: await repository.GetRevisionsAsync(id, offset, PageSize, cts.Token);
|
|
if (page.Offset != offset || page.Items.Count > PageSize || page.Total < offset + page.Items.Count
|
|
|| page.Items.Any(r => r.Id != id || r.Revision < 1))
|
|
throw new InvalidDataException("The server returned invalid nest history.");
|
|
if (page.Items.Any(r => r.Revision > record.Revision))
|
|
throw new IOException("The current version changed while loading history. Refresh to review the new current version.");
|
|
if (run != generation || cts.IsCancellationRequested || disposed) return false;
|
|
Current = record;
|
|
Page = page;
|
|
return true;
|
|
}
|
|
catch (Exception) when (run != generation || cts.IsCancellationRequested || disposed) { return false; }
|
|
finally { if (ReferenceEquals(pending, cts)) pending = null; }
|
|
}
|
|
|
|
public async Task<byte[]?> DownloadAsync(int revision, string serverUrl, CancellationToken ct = default)
|
|
{
|
|
EnsureReady(serverUrl);
|
|
var id = NestId;
|
|
var run = generation;
|
|
var snapshot = Page!.Items.SingleOrDefault(r => r.Revision == revision)
|
|
?? throw new InvalidOperationException("Select a revision on the displayed page.");
|
|
var file = Deleted ? await repository.GetDeletedRevisionFileAsync(id, revision, ct)
|
|
: await repository.GetRevisionFileAsync(id, revision, ct);
|
|
ct.ThrowIfCancellationRequested();
|
|
if (disposed || run != generation) return null;
|
|
if (file == null || file.LongLength != snapshot.FileSize)
|
|
throw new InvalidDataException("The revision archive is missing or does not match its metadata. Refresh before trying again.");
|
|
return file;
|
|
}
|
|
|
|
public async Task<NestRecord> RestoreAsync(int sourceRevision, string serverUrl, CancellationToken ct = default)
|
|
{
|
|
EnsureReady(serverUrl);
|
|
if (Deleted) throw new InvalidOperationException("Recover the deleted nest before restoring an earlier version.");
|
|
if (!Page!.Items.Any(r => r.Revision == sourceRevision)) throw new InvalidOperationException("Select a displayed revision.");
|
|
return await MutateAsync(() => repository.RestoreAsync(NestId, sourceRevision, Current!.Revision, ct), false, ct);
|
|
}
|
|
|
|
public async Task<NestRecord> RecoverAsync(string serverUrl, CancellationToken ct = default)
|
|
{
|
|
EnsureReady(serverUrl);
|
|
if (!Deleted) throw new InvalidOperationException("This nest is not deleted.");
|
|
return await MutateAsync(() => repository.RecoverAsync(NestId, Current!.Revision, ct), false, ct);
|
|
}
|
|
|
|
public async Task<NestRecord> DeleteAsync(string serverUrl, CancellationToken ct = default)
|
|
{
|
|
EnsureReady(serverUrl);
|
|
if (Deleted) throw new InvalidOperationException("This nest is already deleted.");
|
|
return await MutateAsync(() => repository.DeleteAsync(NestId, Current!.Revision, ct), true, ct);
|
|
}
|
|
|
|
private async Task<NestRecord> MutateAsync(Func<Task<NestRecord>> write, bool deleted, CancellationToken ct)
|
|
{
|
|
IsMutating = true;
|
|
var expected = Current!.Revision;
|
|
try
|
|
{
|
|
var result = await write();
|
|
ValidateRecord(result, NestId, deleted);
|
|
if (result.Revision != expected + 1) throw new InvalidDataException("Invalid mutation revision. Refresh to reconcile.");
|
|
// Verify the exact target after the write; do not declare success from the response alone.
|
|
var verified = deleted ? await repository.GetDeletedMetadataAsync(NestId, ct)
|
|
: await repository.GetMetadataAsync(NestId, ct);
|
|
if (verified == null || verified.Id != NestId || verified.Revision < result.Revision || verified.Deleted != deleted)
|
|
throw new InvalidDataException("Could not verify the new current revision. Refresh to reconcile.");
|
|
ct.ThrowIfCancellationRequested();
|
|
if (disposed) throw new OperationCanceledException("The history browser was closed. Reopen to reconcile.");
|
|
Current = verified;
|
|
Deleted = deleted;
|
|
Page = null;
|
|
return result;
|
|
}
|
|
catch { Current = null; throw; }
|
|
finally { IsMutating = false; }
|
|
}
|
|
|
|
private void EnsureReady(string url)
|
|
{
|
|
ObjectDisposedException.ThrowIf(disposed, this);
|
|
if (Normalize(url) != ServerUrl) throw new InvalidOperationException("The nest server changed. Close this browser and reopen it on the selected server.");
|
|
if (Current == null || IsMutating) throw new InvalidOperationException("Refresh the current version before continuing.");
|
|
}
|
|
|
|
private static void ValidateRecord(NestRecord record, Guid id, bool deleted)
|
|
{
|
|
if (record.Id != id || record.Deleted != deleted) throw new InvalidDataException("The server returned a different nest identity or visibility.");
|
|
}
|
|
|
|
private static string Normalize(string? url) => (url ?? "").Trim().TrimEnd('/');
|
|
|
|
/// <summary>Read an immutable current snapshot and ensure metadata did not change during download.</summary>
|
|
public static async Task<NestCurrentSnapshot> ReadCurrentAsync(INestRepository repository, Guid id,
|
|
string serverUrl, CancellationToken ct = default)
|
|
{
|
|
var before = await repository.GetMetadataAsync(id, ct)
|
|
?? throw new FileNotFoundException("This nest no longer exists on the server.");
|
|
ValidateRecord(before, id, false);
|
|
var beforeMetadata = JsonSerializer.Serialize(before);
|
|
var file = before.Revision > 0 ? await repository.GetRevisionFileAsync(id, before.Revision, ct)
|
|
: await repository.GetFileAsync(id, ct);
|
|
var after = await repository.GetMetadataAsync(id, ct);
|
|
ct.ThrowIfCancellationRequested();
|
|
if (after == null || JsonSerializer.Serialize(after) != beforeMetadata
|
|
|| file == null || file.LongLength != before.FileSize)
|
|
throw new InvalidDataException("The current nest changed or its archive does not match its metadata. Refresh and open it again.");
|
|
return new NestCurrentSnapshot(before, file, Normalize(serverUrl));
|
|
}
|
|
|
|
public void Clear()
|
|
{
|
|
if (IsMutating) throw new InvalidOperationException("Wait for the current operation to finish.");
|
|
++generation;
|
|
pending?.Cancel();
|
|
NestId = Guid.Empty;
|
|
Current = null;
|
|
Page = null;
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
disposed = true;
|
|
++generation;
|
|
pending?.Cancel();
|
|
Current = null;
|
|
Page = null;
|
|
}
|
|
}
|
|
|
|
public sealed record NestCurrentSnapshot(NestRecord Metadata, byte[] Archive, string ServerUrl);
|