SboxSuperuser/Core/Storage/StoreCoordinator.Recovery.cs

Methods for reading backups and performing a recovery commit in the store coordinator. GetBackupsAsync enumerates stored "c-" backup files, decodes up to 100 backups and returns the current head; RecoverAsync validates a selected backup by generation id, merges an audit intent into its JSON payload, validates the transaction format and commits a new revision.

File Access
using System.Text.Json;

namespace SboxSuperuser.Core.Storage;

internal sealed partial class StoreCoordinator
{
	internal async Task<StorageBackups> GetBackupsAsync( string directory, CancellationToken cancellation )
	{
		var backups = new List<StoredRevision>();
		long head = 0;
		var hasMore = false;
		var outcome = await SerializedAsync( () =>
		{
			if ( !_files.DirectoryExists( directory ) )
			{
				return Task.FromResult( new StorageOutcome( StorageStatus.Missing ) );
			}

			var names = Names( directory );
			head = names.Select( StoreFormat.RevisionFromName ).DefaultIfEmpty( 0 ).Max();
			foreach ( var name in names.Where( name => name.StartsWith( "c-", StringComparison.Ordinal ) )
				.OrderByDescending( StoreFormat.RevisionFromName ) )
			{
				cancellation.ThrowIfCancellationRequested();
				try
				{
					var document = Decode( directory, name );
					if ( backups.Count == 100 )
					{
						hasMore = true;
						break;
					}

					backups.Add( document );
				}
				catch ( UnsupportedStoreSchemaException )
				{
					// Incompatible records remain on disk but cannot be offered for restore.
				}
				catch ( Exception error ) when ( IsDataError( error ) )
				{
					// Retain damaged evidence. ReadCurrent independently reports the active head's health.
				}
			}

			return Task.FromResult( ReadCurrent( directory, 1 ) );
		}, cancellation );
		return new StorageBackups( outcome.Status, head, backups.AsReadOnly(), hasMore );
	}

	internal async Task<StorageOutcome> RecoverAsync( string directory, Guid generationId,
		long expectedHeadRevision, string recoveryIntent, CancellationToken cancellation )
	{
		return await SerializedAsync( async () =>
		{
			if ( !_files.DirectoryExists( directory ) )
			{
				return new StorageOutcome( StorageStatus.Missing );
			}

			var names = Names( directory );
			var head = names.Select( StoreFormat.RevisionFromName ).DefaultIfEmpty( 0 ).Max();
			if ( head != expectedHeadRevision || head == long.MaxValue )
			{
				return new StorageOutcome( StorageStatus.Conflict );
			}

			var selected = names.SingleOrDefault( name => name.StartsWith( "c-", StringComparison.Ordinal )
				&& name.EndsWith( $"-{generationId:N}.json", StringComparison.Ordinal ) );
			if ( selected is null )
			{
				return new StorageOutcome( StorageStatus.InvalidData );
			}

			var backup = Decode( directory, selected );
			string payload;
			try
			{
				using var source = JsonDocument.Parse( backup.PayloadJson );
				var intents = source.RootElement.GetProperty( "auditIntents" ).EnumerateArray()
					.Select( item => item.GetRawText() ).Append( recoveryIntent );
				payload = "{\"state\":" + source.RootElement.GetProperty( "state" ).GetRawText()
					+ ",\"auditIntents\":[" + string.Join( ",", intents ) + "]}";
				TransactionFormat.Validate( payload );
			}
			catch ( Exception error ) when ( IsDataError( error ) )
			{
				return new StorageOutcome( StorageStatus.InvalidData );
			}

			return await CommitAsync( directory, new StoredRevision( Guid.NewGuid(), 1, head + 1, payload ), cancellation );
		}, cancellation );
	}
}