using System; using Svrnty.CQRS.Events.Abstractions.Schema; using Svrnty.CQRS.Events.Abstractions.Models; using Svrnty.CQRS.Events.Abstractions.EventStore; using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; using System.Reflection; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging; using Svrnty.CQRS.Events.Abstractions; namespace Svrnty.CQRS.Events.Schema; /// /// Default implementation of with caching and automatic upcasting. /// /// /// /// This implementation provides: /// - In-memory caching of schema information /// - Automatic discovery of upcaster methods (convention-based) /// - Multi-hop upcasting through version chains /// - Thread-safe registration and lookup /// /// public sealed class SchemaRegistry : ISchemaRegistry { private readonly ISchemaStore _store; private readonly ILogger _logger; private readonly IJsonSchemaGenerator? _jsonSchemaGenerator; private readonly ConcurrentDictionary _schemaCache = new(); private readonly ConcurrentDictionary _latestVersionCache = new(); private readonly SemaphoreSlim _registrationLock = new(1, 1); public SchemaRegistry( ISchemaStore store, ILogger logger, IJsonSchemaGenerator? jsonSchemaGenerator = null) { _store = store ?? throw new ArgumentNullException(nameof(store)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); _jsonSchemaGenerator = jsonSchemaGenerator; } public async Task RegisterSchemaAsync( int version, Type? upcastFromType = null, string? jsonSchema = null, CancellationToken cancellationToken = default) where TEvent : ICorrelatedEvent { var eventType = EventVersionAttribute.GetEventTypeName(typeof(TEvent)); var schemaId = $"{eventType}:v{version}"; // Check if already registered if (_schemaCache.TryGetValue(schemaId, out var cached)) { _logger.LogDebug("Schema {SchemaId} already registered (cached)", schemaId); return cached; } await _registrationLock.WaitAsync(cancellationToken); try { // Double-check after acquiring lock if (_schemaCache.TryGetValue(schemaId, out cached)) return cached; // Check if already in store var existing = await _store.GetSchemaAsync(eventType, version, cancellationToken); if (existing != null) { _schemaCache[schemaId] = existing; UpdateLatestVersionCache(eventType, version); return existing; } // Generate JSON schema if not provided and generator is available var finalJsonSchema = jsonSchema; if (string.IsNullOrWhiteSpace(finalJsonSchema) && _jsonSchemaGenerator != null) { try { finalJsonSchema = await _jsonSchemaGenerator.GenerateSchemaAsync( typeof(TEvent), cancellationToken); _logger.LogDebug( "Auto-generated JSON schema for {EventType} v{Version}", eventType, version); } catch (Exception ex) { _logger.LogWarning( ex, "Failed to auto-generate JSON schema for {EventType} v{Version}. Schema will be registered without JSON schema.", eventType, version); } } // Create new schema info var schema = new SchemaInfo( EventType: eventType, Version: version, ClrType: typeof(TEvent), JsonSchema: finalJsonSchema, UpcastFromType: upcastFromType, UpcastFromVersion: upcastFromType != null ? version - 1 : null, RegisteredAt: DateTimeOffset.UtcNow); // Validate schema.Validate(); // Verify upcast chain if this is not version 1 if (version > 1) { if (upcastFromType == null) throw new InvalidOperationException($"Version {version} must specify UpcastFromType"); // Verify previous version exists var previousVersion = await _store.GetSchemaAsync(eventType, version - 1, cancellationToken); if (previousVersion == null) { throw new InvalidOperationException( $"Cannot register version {version} before version {version - 1} is registered"); } } // Store await _store.StoreSchemaAsync(schema, cancellationToken); // Cache _schemaCache[schemaId] = schema; UpdateLatestVersionCache(eventType, version); _logger.LogInformation( "Registered schema {EventType} v{Version} (CLR type: {ClrType})", eventType, version, typeof(TEvent).Name); return schema; } finally { _registrationLock.Release(); } } public async Task GetSchemaAsync( string eventType, int version, CancellationToken cancellationToken = default) { var schemaId = $"{eventType}:v{version}"; // Check cache first if (_schemaCache.TryGetValue(schemaId, out var cached)) return cached; // Load from store var schema = await _store.GetSchemaAsync(eventType, version, cancellationToken); if (schema != null) { _schemaCache[schemaId] = schema; } return schema; } public async Task GetSchemaByTypeAsync( Type clrType, CancellationToken cancellationToken = default) { // Try to find in cache first var cached = _schemaCache.Values.FirstOrDefault(s => s.ClrType == clrType); if (cached != null) return cached; // Get event type name and version from attribute var versionAttr = clrType.GetCustomAttribute(); if (versionAttr == null) return null; var eventTypeName = EventVersionAttribute.GetEventTypeName(clrType); return await GetSchemaAsync(eventTypeName, versionAttr.Version, cancellationToken); } public async Task GetLatestVersionAsync( string eventType, CancellationToken cancellationToken = default) { // Check cache first if (_latestVersionCache.TryGetValue(eventType, out var cachedVersion)) return cachedVersion; // Load from store var latestVersion = await _store.GetLatestVersionAsync(eventType, cancellationToken); if (latestVersion.HasValue) { _latestVersionCache[eventType] = latestVersion.Value; } return latestVersion; } public async Task> GetSchemaHistoryAsync( string eventType, CancellationToken cancellationToken = default) { return await _store.GetSchemaHistoryAsync(eventType, cancellationToken); } public async Task UpcastAsync( ICorrelatedEvent @event, int? targetVersion = null, CancellationToken cancellationToken = default) { var currentType = @event.GetType(); var currentSchema = await GetSchemaByTypeAsync(currentType, cancellationToken); if (currentSchema == null) { // Event is not versioned, return as-is _logger.LogDebug("Event type {EventType} is not versioned, no upcasting needed", currentType.Name); return @event; } var eventTypeName = currentSchema.EventType; var currentVersion = currentSchema.Version; // Determine target version var actualTargetVersion = targetVersion ?? await GetLatestVersionAsync(eventTypeName, cancellationToken); if (!actualTargetVersion.HasValue) { _logger.LogWarning("No versions found for event type {EventType}", eventTypeName); return @event; } // Already at target version if (currentVersion == actualTargetVersion.Value) { _logger.LogDebug("Event already at target version {Version}", currentVersion); return @event; } // Perform multi-hop upcasting var current = @event; var version = currentVersion; while (version < actualTargetVersion.Value) { var nextVersion = version + 1; var nextSchema = await GetSchemaAsync(eventTypeName, nextVersion, cancellationToken); if (nextSchema == null) { throw new InvalidOperationException( $"Cannot upcast to version {nextVersion}: schema not found"); } _logger.LogDebug( "Upcasting {EventType} from v{FromVersion} to v{ToVersion}", eventTypeName, version, nextVersion); current = await UpcastSingleHopAsync(current, nextSchema, cancellationToken); version = nextVersion; } _logger.LogInformation( "Successfully upcast {EventType} from v{FromVersion} to v{ToVersion}", eventTypeName, currentVersion, version); return current; } public async Task NeedsUpcastingAsync( ICorrelatedEvent @event, int? targetVersion = null, CancellationToken cancellationToken = default) { var currentType = @event.GetType(); var currentSchema = await GetSchemaByTypeAsync(currentType, cancellationToken); if (currentSchema == null) return false; // Not versioned var eventTypeName = currentSchema.EventType; var actualTargetVersion = targetVersion ?? await GetLatestVersionAsync(eventTypeName, cancellationToken); return actualTargetVersion.HasValue && currentSchema.Version < actualTargetVersion.Value; } private async Task UpcastSingleHopAsync( ICorrelatedEvent fromEvent, SchemaInfo toSchema, CancellationToken cancellationToken) { var fromType = fromEvent.GetType(); var toType = toSchema.ClrType; // Strategy 1: Look for static UpcastFrom method on target type var upcastMethod = toType.GetMethod( "UpcastFrom", BindingFlags.Public | BindingFlags.Static, null, new[] { fromType }, null); if (upcastMethod != null && upcastMethod.ReturnType == toType) { _logger.LogDebug( "Using static UpcastFrom method: {ToType}.UpcastFrom({FromType})", toType.Name, fromType.Name); var result = upcastMethod.Invoke(null, new object[] { fromEvent }); if (result is ICorrelatedEvent upcastEvent) return upcastEvent; throw new InvalidOperationException( $"UpcastFrom method returned unexpected type: {result?.GetType().Name}"); } // Strategy 2: Look for registered IEventUpcaster implementation // (This would require DI integration - placeholder for now) throw new InvalidOperationException( $"No upcaster found for {fromType.Name} → {toType.Name}. " + $"Add a static method: public static {toType.Name} UpcastFrom({fromType.Name} from)"); } private void UpdateLatestVersionCache(string eventType, int version) { _latestVersionCache.AddOrUpdate( eventType, version, (key, existing) => Math.Max(existing, version)); } }