V2/Persistence/PersistencePrimitives.cs

Persistence primitives and types for a storage layer. Defines identifiers (PersistedTypeKey, DocumentAddress), revisions, envelope DTOs for documents and tombstones, error/result types, invariant/context/index classes, repository/unit-of-work interfaces, editor type, name validation, health/shutdown types and small utility records used by a persistence provider.

File Access
#nullable enable

using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;

namespace Hexagon.V2.Persistence;

/// <summary>
/// Stable persistence identifier for a concrete registered CLR type.
/// This deliberately remains independent from domain-layer identifiers.
/// </summary>
public readonly record struct PersistedTypeKey
{
	public string Value { get; }

	public PersistedTypeKey( string value )
	{
		Value = PersistenceName.Validate( value, nameof(value) );
	}

	public override string ToString() => Value;
}

/// <summary>
/// Address of one document in the logical persistence view.
/// </summary>
public readonly record struct DocumentAddress
{
	public string Collection { get; }
	public string Key { get; }

	public DocumentAddress( string collection, string key )
	{
		Collection = PersistenceName.Validate( collection, nameof(collection) );
		Key = PersistenceName.ValidateDocumentKey( key, nameof(key) );
	}

	public override string ToString() => $"{Collection}/{Key}";
}

/// <summary>
/// Monotonic per-document revision. Zero represents a document that has never existed.
/// Tombstones retain their positive revision.
/// </summary>
public readonly record struct DocumentRevision( long Value ) : IComparable<DocumentRevision>
{
	public static DocumentRevision None { get; } = new( 0 );

	public DocumentRevision Next()
	{
		if ( Value < 0 || Value == long.MaxValue )
		{
			throw new InvalidOperationException( $"Document revision {Value} cannot be incremented." );
		}

		return new DocumentRevision( Value + 1 );
	}

	public int CompareTo( DocumentRevision other ) => Value.CompareTo( other.Value );
	public override string ToString() => Value.ToString( System.Globalization.CultureInfo.InvariantCulture );
}

/// <summary>
/// Public, versioned representation of a committed document.
/// </summary>
public sealed record PersistedDocumentEnvelope
{
	public const int CurrentEnvelopeVersion = 1;

	public int EnvelopeVersion { get; init; } = CurrentEnvelopeVersion;
	public required string Collection { get; init; }
	public required string Key { get; init; }
	public required long Revision { get; init; }
	public required string PersistedType { get; init; }
	public required int TypeVersion { get; init; }
	public required JsonElement Payload { get; init; }
}

/// <summary>
/// Public, versioned representation of a committed deletion. Tombstones participate in
/// optimistic concurrency and prevent a deleted document from being mistaken for a never-seen key.
/// </summary>
public sealed record PersistedTombstoneEnvelope
{
	public const int CurrentEnvelopeVersion = 1;

	public int EnvelopeVersion { get; init; } = CurrentEnvelopeVersion;
	public required string Collection { get; init; }
	public required string Key { get; init; }
	public required long Revision { get; init; }
	public required string PersistedType { get; init; }
	public required int TypeVersion { get; init; }
}

public sealed record DocumentSnapshot<T>( string Key, DocumentRevision Revision, T Value ) where T : class;

public enum PersistenceErrorCode
{
	None = 0,
	NotInitialized,
	Disposed,
	NotFound,
	AlreadyExists,
	RevisionConflict,
	TypeNotRegistered,
	CollectionTypeMismatch,
	SerializationFailed,
	DurabilityFailed,
	CorruptStore,
	InvalidOperation,
	LeaseUnavailable,
	InvariantViolation,
	StorageLimitExceeded,
	StaleTransaction
}

public sealed record PersistenceError(
	PersistenceErrorCode Code,
	string Message,
	DocumentAddress? Address = null,
	Exception? Exception = null );

public readonly record struct PersistenceResult<T>( T? Value, PersistenceError? Error )
{
	public bool Succeeded => Error is null;

	public static PersistenceResult<T> Success( T value ) => new( value, null );
	public static PersistenceResult<T> Failure( PersistenceError error ) => new( default, error );
}

public sealed record CommittedDocumentVersion( DocumentAddress Address, DocumentRevision Revision, bool IsDeleted );

public sealed record CommitReceipt( long Sequence, IReadOnlyList<CommittedDocumentVersion> Documents )
{
	public long CompactionGeneration { get; init; }
}

public enum PersistenceProviderState
{
	Created = 0,
	Initializing,
	Ready,
	Draining,
	Disposed,
	Faulted
}

public sealed record PersistenceInvariantIssue( string Code, string Path, string Message );

public sealed record PersistenceCandidateDocument(
	DocumentAddress Address,
	DocumentRevision Revision,
	PersistedTypeKey PersistedType,
	int TypeVersion,
	object Value );

/// <summary>Read-only candidate view used identically before commit publication and during recovery.</summary>
public sealed class PersistenceInvariantContext
{
	private static readonly IReadOnlyDictionary<DocumentAddress, PersistenceCandidateDocument?> EmptyDelta =
		new Dictionary<DocumentAddress, PersistenceCandidateDocument?>();
	private readonly PersistenceInvariantDocumentIndex _baseIndex;
	private readonly IReadOnlyDictionary<DocumentAddress, PersistenceCandidateDocument?> _delta;
	private readonly Dictionary<string, IReadOnlyList<PersistenceCandidateDocument>> _collections =
		new( StringComparer.Ordinal );
	private IReadOnlyCollection<PersistenceCandidateDocument>? _documentValues;
	private long _visitedDocumentCount;

	internal PersistenceInvariantContext(
		long sequence,
		long compactionGeneration,
		IReadOnlyDictionary<DocumentAddress, PersistenceCandidateDocument> documents,
		IReadOnlyDictionary<DocumentAddress, PersistenceCandidateDocument>? previousDocuments = null,
		IReadOnlySet<DocumentAddress>? changedAddresses = null,
		bool isRecovery = false )
	{
		Sequence = sequence;
		CompactionGeneration = compactionGeneration;
		_baseIndex = new PersistenceInvariantDocumentIndex( documents.Values );
		_delta = EmptyDelta;
		PreviousDocuments = previousDocuments ?? new Dictionary<DocumentAddress, PersistenceCandidateDocument>();
		ChangedAddresses = changedAddresses ?? new HashSet<DocumentAddress>();
		IsRecovery = isRecovery;
	}

	internal PersistenceInvariantContext(
		long sequence,
		long compactionGeneration,
		PersistenceInvariantDocumentIndex baseIndex,
		IReadOnlyDictionary<DocumentAddress, PersistenceCandidateDocument?> delta,
		IReadOnlySet<DocumentAddress> changedAddresses,
		bool isRecovery = false )
	{
		Sequence = sequence;
		CompactionGeneration = compactionGeneration;
		_baseIndex = baseIndex ?? throw new ArgumentNullException( nameof(baseIndex) );
		_delta = delta ?? throw new ArgumentNullException( nameof(delta) );
		PreviousDocuments = baseIndex.Documents;
		ChangedAddresses = changedAddresses ?? throw new ArgumentNullException( nameof(changedAddresses) );
		IsRecovery = isRecovery;
	}

	public long Sequence { get; }
	public long CompactionGeneration { get; }
	public bool IsRecovery { get; }
	public IReadOnlyDictionary<DocumentAddress, PersistenceCandidateDocument> PreviousDocuments { get; }
	public IReadOnlySet<DocumentAddress> ChangedAddresses { get; }
	public long VisitedDocumentCount => Interlocked.Read( ref _visitedDocumentCount );
	public IReadOnlyCollection<PersistenceCandidateDocument> Documents =>
		_documentValues ??= MaterializeDocuments();

	public IReadOnlyList<PersistenceCandidateDocument> Collection( string collection )
	{
		ArgumentException.ThrowIfNullOrWhiteSpace( collection );
		if ( _collections.TryGetValue( collection, out var documents ) ) return documents;
		var values = _baseIndex.Collection( collection )
			.ToDictionary( document => document.Address, document => document );
		foreach ( var change in _delta )
		{
			if ( change.Key.Collection != collection ) continue;
			if ( change.Value is null ) values.Remove( change.Key );
			else values[change.Key] = change.Value;
		}
		documents = values.Values
			.OrderBy( document => document.Address.Key, StringComparer.Ordinal )
			.ToArray();
		RecordVisits( documents.Count );
		_collections.Add( collection, documents );
		return documents;
	}

	public bool TryGet( DocumentAddress address, out PersistenceCandidateDocument? document )
	{
		RecordVisits( 1 );
		if ( _delta.TryGetValue( address, out document ) ) return document is not null;
		return _baseIndex.Documents.TryGetValue( address, out document );
	}

	public bool TryGetPrevious( DocumentAddress address, out PersistenceCandidateDocument? document )
	{
		RecordVisits( 1 );
		return PreviousDocuments.TryGetValue( address, out document );
	}

	internal void RecordVisitedDocuments( int count )
	{
		if ( count < 0 ) throw new ArgumentOutOfRangeException( nameof(count) );
		RecordVisits( count );
	}

	private IReadOnlyCollection<PersistenceCandidateDocument> MaterializeDocuments()
	{
		var values = new Dictionary<DocumentAddress, PersistenceCandidateDocument>( _baseIndex.Documents );
		foreach ( var change in _delta )
		{
			if ( change.Value is null ) values.Remove( change.Key );
			else values[change.Key] = change.Value;
		}
		var documents = values.Values.ToArray();
		RecordVisits( documents.Length );
		return documents;
	}

	private void RecordVisits( int count ) => Interlocked.Add( ref _visitedDocumentCount, count );
}

internal sealed class PersistenceInvariantDocumentIndex
{
	private readonly Dictionary<DocumentAddress, PersistenceCandidateDocument> _documents = new();
	private readonly IReadOnlyDictionary<DocumentAddress, PersistenceCandidateDocument> _readOnlyDocuments;
	private readonly Dictionary<string, SortedDictionary<string, PersistenceCandidateDocument>> _collections =
		new( StringComparer.Ordinal );

	public PersistenceInvariantDocumentIndex() =>
		_readOnlyDocuments = new ReadOnlyDictionary<DocumentAddress, PersistenceCandidateDocument>( _documents );

	public PersistenceInvariantDocumentIndex( IEnumerable<PersistenceCandidateDocument> documents ) : this()
	{
		foreach ( var document in documents ) Publish( document );
	}

	public IReadOnlyDictionary<DocumentAddress, PersistenceCandidateDocument> Documents => _readOnlyDocuments;

	public IReadOnlyList<PersistenceCandidateDocument> Collection( string collection ) =>
		_collections.TryGetValue( collection, out var documents )
			? documents.Values.ToArray()
			: Array.Empty<PersistenceCandidateDocument>();

	public void Publish( PersistenceCandidateDocument document )
	{
		_documents[document.Address] = document;
		if ( !_collections.TryGetValue( document.Address.Collection, out var collection ) )
		{
			collection = new SortedDictionary<string, PersistenceCandidateDocument>( StringComparer.Ordinal );
			_collections.Add( document.Address.Collection, collection );
		}
		collection[document.Address.Key] = document;
	}

	public void Remove( DocumentAddress address )
	{
		_documents.Remove( address );
		if ( !_collections.TryGetValue( address.Collection, out var collection ) ) return;
		collection.Remove( address.Key );
		if ( collection.Count == 0 ) _collections.Remove( address.Collection );
	}
}

public interface IPersistenceInvariantSet
{
	IReadOnlyList<PersistenceInvariantIssue> Validate( PersistenceInvariantContext context );
}

public sealed record PersistenceInvariantPreparation(
	IReadOnlyList<PersistenceInvariantIssue> Issues,
	object? PublicationState = null );

/// <summary>
/// Optional commit-time incremental invariant path. Prepare must not mutate the
/// committed invariant index. Publish runs only after the commit is durable;
/// Rebuild reconstructs the index from a fully validated recovery view.
/// </summary>
public interface IIncrementalPersistenceInvariantSet : IPersistenceInvariantSet
{
	PersistenceInvariantPreparation Prepare( PersistenceInvariantContext context );
	void Publish( PersistenceInvariantPreparation preparation );
	void Rebuild( PersistenceInvariantContext context );
}

public sealed class EmptyPersistenceInvariantSet : IPersistenceInvariantSet
{
	public static EmptyPersistenceInvariantSet Instance { get; } = new();
	private EmptyPersistenceInvariantSet() { }
	public IReadOnlyList<PersistenceInvariantIssue> Validate( PersistenceInvariantContext context ) =>
		Array.Empty<PersistenceInvariantIssue>();
}

public sealed record CommitPreconditionContext(
	long Sequence,
	long CompactionGeneration,
	PersistenceInvariantContext Candidate );

public interface ICommitPrecondition
{
	PersistenceInvariantIssue? Validate( CommitPreconditionContext context );
}

public sealed record PersistenceShutdownResult(
	long DurableSequence,
	long CheckpointSequence,
	bool IsClean,
	bool IsRecoverable,
	PersistenceResult<long> Checkpoint,
	bool LeaseReleased,
	string? Detail );

internal readonly record struct CheckpointPersistenceOutcome(
	bool CleanupSucceeded,
	string? Detail )
{
	public static CheckpointPersistenceOutcome Clean { get; } = new( true, null );

	public static CheckpointPersistenceOutcome Degraded( Exception exception ) => new(
		false,
		$"Verified checkpoint was published, but immutable-history cleanup remains pending: " +
		$"{exception.GetType().Name}: {exception.Message}" );
}

/// <summary>
/// Result of publishing one immutable WAL acknowledgement. The acknowledgement is the
/// transaction commit point; metadata written after it is recoverable bookkeeping only.
/// </summary>
internal readonly record struct CommitPersistenceOutcome(
	bool MetadataRepairPending,
	string? Detail )
{
	public static CommitPersistenceOutcome Clean { get; } = new( false, null );

	public static CommitPersistenceOutcome Degraded( Exception exception ) => new(
		true,
		$"The transaction acknowledgement is durable, but redundant commit-head repair is pending: " +
		$"{exception.GetType().Name}: {exception.Message}" );
}

public enum PersistenceHealthStatus
{
	Uninitialized = 0,
	Healthy,
	Degraded,
	Fatal,
	Disposed
}

/// <summary>
/// Observable persistence status. A checkpoint or redundant commit-head failure is degraded and
/// retryable. A failure before a verified acknowledgement is fatal because the caller cannot safely
/// infer a durable transaction from a frame alone. <see cref="DiscardedUnacknowledgedFrames"/> counts
/// frames swept during recovery because no acknowledgement referenced them (interrupted commits and
/// interrupted checkpoint prunes); those transactions were never acknowledged, so their discard is
/// normal crash recovery, not data loss, and does not degrade the store.
/// </summary>
public sealed record PersistenceHealth(
	PersistenceHealthStatus Status,
	long Sequence,
	long CheckpointSequence,
	bool CheckpointRetryPending,
	int DiscardedUnacknowledgedFrames,
	bool RecoveredFromCheckpointFallback,
	string? Detail,
	DateTimeOffset ObservedAtUtc )
{
	/// <summary>
	/// True when an acknowledged transaction is durable and visible but its redundant
	/// commit-head file must be repaired before another write or clean shutdown.
	/// </summary>
	public bool CommitMetadataRepairPending { get; init; }
}

public sealed class PersistenceCorruptionException : Exception
{
	public PersistenceCorruptionException( string message ) : base( message ) { }
	public PersistenceCorruptionException( string message, Exception innerException ) : base( message, innerException ) { }
}

/// <summary>
/// Typed committed view for an immutable aggregate type. Repeated reads of one revision return the
/// same canonical instance; all proposed mutation must therefore use a unit-of-work editor.
/// </summary>
public interface IPersistenceRepository<T> where T : class
{
	string Collection { get; }
	DocumentSnapshot<T>? Find( string key );
	IReadOnlyList<DocumentSnapshot<T>> All();
}

public interface IUnitOfWork : IAsyncDisposable
{
	DocumentEditor<T>? Edit<T>( IPersistenceRepository<T> repository, DocumentSnapshot<T> observed ) where T : class;
	void RequireUnchanged<T>( IPersistenceRepository<T> repository, DocumentSnapshot<T> observed ) where T : class;
	void Create<T>( IPersistenceRepository<T> repository, string key, T value ) where T : class;
	void Put<T>( IPersistenceRepository<T> repository, string key, T value ) where T : class;
	void Save<T>( DocumentEditor<T> editor ) where T : class;
	void Delete<T>( IPersistenceRepository<T> repository, DocumentSnapshot<T> observed ) where T : class;
	void Require( ICommitPrecondition precondition );
	ValueTask<PersistenceResult<CommitReceipt>> CommitAsync( CancellationToken cancellationToken = default );
}

public interface IPersistenceProvider : IAsyncDisposable
{
	PersistedTypeRegistry Types { get; }
	PersistenceHealth Health { get; }
	bool IsInitialized { get; }
	PersistenceProviderState State { get; }
	Guid StoreId { get; }
	Guid WriterEpoch { get; }
	long CompactionGeneration { get; }

	ValueTask InitializeAsync( CancellationToken cancellationToken = default );
	IPersistenceRepository<T> Repository<T>( string collection ) where T : class;
	IUnitOfWork BeginUnitOfWork();
	ValueTask<PersistenceResult<long>> CheckpointAsync( CancellationToken cancellationToken = default );
	ValueTask<PersistenceShutdownResult> ShutdownAsync( CancellationToken cancellationToken = default );
}

/// <summary>
/// Isolated editable copy of a committed document. Calling <see cref="Replace"/> or mutating
/// <see cref="Value"/> never changes the logical view until the owning unit of work commits.
/// </summary>
public sealed class DocumentEditor<T> where T : class
{
	private readonly object _owner;

	internal DocumentEditor(
		object owner,
		IPersistenceRepository<T> repository,
		string key,
		DocumentRevision originalRevision,
		T value )
	{
		_owner = owner;
		Repository = repository;
		Key = key;
		OriginalRevision = originalRevision;
		Value = value;
	}

	public IPersistenceRepository<T> Repository { get; }
	public string Key { get; }
	public DocumentRevision OriginalRevision { get; }
	public T Value { get; private set; }

	public void Replace( T value ) => Value = value ?? throw new ArgumentNullException( nameof(value) );

	internal bool BelongsTo( object owner ) => ReferenceEquals( _owner, owner );
}

internal static class PersistenceName
{
	private const int MaximumNameLength = 128;
	private const int MaximumDocumentKeyLength = 512;

	public static string Validate( string value, string parameterName )
	{
		ArgumentException.ThrowIfNullOrWhiteSpace( value, parameterName );

		if ( value.Length > MaximumNameLength )
		{
			throw new ArgumentOutOfRangeException( parameterName, $"Persistence name exceeds {MaximumNameLength} characters." );
		}

		for ( var index = 0; index < value.Length; index++ )
		{
			var character = value[index];
			if ( !IsNameCharacter( character ) )
			{
				throw new ArgumentException(
					$"Persistence name '{value}' contains invalid character '{character}'. Use lowercase letters, digits, '.', '_' or '-'.",
					parameterName );
			}
		}

		return value;
	}

	public static string ValidateDocumentKey( string value, string parameterName )
	{
		ArgumentException.ThrowIfNullOrWhiteSpace( value, parameterName );

		if ( value.Length > MaximumDocumentKeyLength )
		{
			throw new ArgumentOutOfRangeException( parameterName, $"Document key exceeds {MaximumDocumentKeyLength} characters." );
		}

		for ( var index = 0; index < value.Length; index++ )
		{
			var character = value[index];
			if ( char.IsControl( character ) || character is '/' or '\\' )
			{
				throw new ArgumentException( "Document keys cannot contain control characters or path separators.", parameterName );
			}
		}

		return value;
	}

	private static bool IsNameCharacter( char character ) =>
		character is >= 'a' and <= 'z' or >= '0' and <= '9' or '.' or '_' or '-';
}