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));
}
}