Code/V2/Application/CharacterReferenceMutationService.cs

Service handling upsert and delete mutations for searchable character references. It stages changes in a unit of work, fences referenced character lifecycle guards to increment a reference revision, and commits the unit of work returning mapped persistence results.

File Access
#nullable enable

using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Hexagon.V2.Domain;
using Hexagon.V2.Kernel;
using Hexagon.V2.Persistence;

namespace Hexagon.V2.Application;

/// <summary>
/// Sole mutation boundary for searchable character references. Every old and new character target
/// is fenced by its lifecycle guard in the same transaction, making delete/reference races conflict.
/// </summary>
public sealed class CharacterReferenceMutationService
{
	private readonly DomainRepositories _repositories;

	public CharacterReferenceMutationService( DomainRepositories repositories ) =>
		_repositories = repositories ?? throw new ArgumentNullException( nameof(repositories) );

	public async ValueTask<OperationResult<CommitReceipt>> UpsertAsync(
		string key,
		CharacterReferenceRecord reference,
		CancellationToken cancellationToken = default )
	{
		ArgumentNullException.ThrowIfNull( reference );
		var unit = _repositories.Provider.BeginUnitOfWork();
		var staged = StageUpsert( unit, key, reference );
		if ( staged.Failed )
		{
			await unit.DisposeAsync();
			return OperationResult<CommitReceipt>.Failure( staged.Error!.Code, staged.Error.Message );
		}

		var committed = await unit.CommitAsync( cancellationToken );
		await unit.DisposeAsync();
		return committed.Succeeded
			? OperationResult<CommitReceipt>.Success( committed.Value! )
			: OperationResult<CommitReceipt>.Failure(
				PersistenceResultMapping.MapCode( committed.Error!.Code ), committed.Error.Message );
	}

	public async ValueTask<OperationResult<CommitReceipt>> DeleteAsync(
		string key,
		CancellationToken cancellationToken = default )
	{
		var unit = _repositories.Provider.BeginUnitOfWork();
		var staged = StageDelete( unit, key );
		if ( staged.Failed )
		{
			await unit.DisposeAsync();
			return OperationResult<CommitReceipt>.Failure( staged.Error!.Code, staged.Error.Message );
		}
		var committed = await unit.CommitAsync( cancellationToken );
		await unit.DisposeAsync();
		return committed.Succeeded
			? OperationResult<CommitReceipt>.Success( committed.Value! )
			: OperationResult<CommitReceipt>.Failure(
				PersistenceResultMapping.MapCode( committed.Error!.Code ), committed.Error.Message );
	}

	public OperationResult StageUpsert(
		IUnitOfWork unit,
		string key,
		CharacterReferenceRecord reference )
	{
		ArgumentNullException.ThrowIfNull( unit );
		ArgumentNullException.ThrowIfNull( reference );
		var existing = _repositories.CharacterReferences.Find( key );
		var fenced = FenceTargets( unit, Targets( existing?.Value, reference ) );
		if ( fenced.Failed ) return fenced;
		if ( existing is null ) unit.Create( _repositories.CharacterReferences, key, reference );
		else
		{
			var editor = unit.Edit( _repositories.CharacterReferences, existing );
			if ( editor is null )
				return OperationResult.Failure( ErrorCode.Conflict, "Character reference changed before mutation." );
			editor.Replace( reference );
			unit.Save( editor );
		}
		return OperationResult.Success();
	}

	public OperationResult StageDelete( IUnitOfWork unit, string key )
	{
		ArgumentNullException.ThrowIfNull( unit );
		var existing = _repositories.CharacterReferences.Find( key );
		if ( existing is null ) return OperationResult.Failure( ErrorCode.NotFound, "Character reference was not found." );
		var fenced = FenceTargets( unit, Targets( existing.Value, null ) );
		if ( fenced.Failed ) return fenced;
		unit.Delete( _repositories.CharacterReferences, existing );
		return OperationResult.Success();
	}

	private OperationResult FenceTargets( IUnitOfWork unit, IReadOnlySet<CharacterId> targets )
	{
		foreach ( var target in targets )
		{
			var document = _repositories.CharacterLifecycleGuards.Find(
				DomainKeys.CharacterLifecycleGuard( target ) );
			if ( document is null )
				return OperationResult.Failure( ErrorCode.NotFound, $"Character lifecycle guard '{target}' is missing." );
			var editor = unit.Edit( _repositories.CharacterLifecycleGuards, document );
			if ( editor is null )
				return OperationResult.Failure( ErrorCode.Conflict, $"Character lifecycle guard '{target}' changed." );
			editor.Replace( editor.Value with { ReferenceRevision = checked(editor.Value.ReferenceRevision + 1) } );
			unit.Save( editor );
		}
		return OperationResult.Success();
	}

	private static IReadOnlySet<CharacterId> Targets(
		CharacterReferenceRecord? previous,
		CharacterReferenceRecord? next )
	{
		var result = new HashSet<CharacterId>();
		Add( previous );
		Add( next );
		return result;

		void Add( CharacterReferenceRecord? value )
		{
			if ( value is null ) return;
			result.Add( value.CharacterId );
			if ( value.RelatedCharacterId is { } related ) result.Add( related );
		}
	}
}