diff --git a/Directory.Build.props b/Directory.Build.props index 1c570458..3ab70ed8 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -4,7 +4,7 @@ net9.0;net10.0 enable enable - 4.5.2 + 4.6.0-alpha.1 latest Jeremy D. Miller logo.png diff --git a/Directory.Packages.props b/Directory.Packages.props index c312dd56..6cb30dea 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -22,8 +22,8 @@ IEventDatabase.QueryDeadLetterEventsAsync (dead-letter row drill-in), DeadLetterEvent.TenantId, and the tenant-aware daemon surface. Polecat implements the dead-letter row query + per-tenant FetchDeadLetterCountsAsync below. --> - - + + - + @@ -57,7 +57,7 @@ - + diff --git a/src/Polecat/DocumentStore.DocumentDiagnostics.cs b/src/Polecat/DocumentStore.DocumentDiagnostics.cs new file mode 100644 index 00000000..af72d9b2 --- /dev/null +++ b/src/Polecat/DocumentStore.DocumentDiagnostics.cs @@ -0,0 +1,154 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using JasperFx.Core.Reflection; +using JasperFx.Documents; +using Microsoft.Data.SqlClient; + +namespace Polecat; + +public partial class DocumentStore : IDocumentStoreDiagnostics +{ + /// + /// Store-agnostic, read-only document-query surface for monitoring consoles + /// (CritterWatch #545). Mirrors the role + /// plays for event streams: list the mapped document types, page their stored + /// rows as raw JSON, and fetch one by id — without the console referencing + /// Polecat directly. Reads the canonical data column straight off the + /// document table so the JSON matches exactly what Polecat persisted. + /// + Task> IDocumentStoreDiagnostics.DocumentTypesAsync( + CancellationToken token) + { + var refs = MaterializeMappings() + .OrderBy(m => m.DocumentType.Name) + .Select(m => new DocumentTypeRef( + m.DocumentType.FullNameInCode(), + m.DocumentType.Name.ToLowerInvariant(), + m.DatabaseSchemaName)) + .ToList(); + + return Task.FromResult>(refs); + } + + async Task IDocumentStoreDiagnostics.QueryDocumentsAsync( + string documentTypeName, DocumentQueryOptions options, CancellationToken token) + { + var mapping = ResolveMappingForDiagnostics(documentTypeName); + if (mapping == null) + { + return new DocumentQueryResult(Array.Empty(), 0, options.PageNumber, options.PageSize); + } + + var table = mapping.QualifiedTableName; + var where = options.IdEquals != null ? " where cast(id as nvarchar(max)) = @id" : ""; + + var pageNumber = Math.Max(1, options.PageNumber); + var pageSize = Math.Max(1, options.PageSize); + var offset = (pageNumber - 1) * pageSize; + + await using var conn = new SqlConnection(Database.ConnectionString); + await conn.OpenAsync(token).ConfigureAwait(false); + + long total; + await using (var countCmd = conn.CreateCommand()) + { + countCmd.CommandText = $"select count_big(*) from {table}{where}"; + if (options.IdEquals != null) + { + countCmd.Parameters.AddWithValue("@id", options.IdEquals); + } + + total = Convert.ToInt64(await countCmd.ExecuteScalarAsync(token).ConfigureAwait(false)); + } + + var rows = new List(); + await using (var cmd = conn.CreateCommand()) + { + // cast(data as nvarchar(max)) guarantees the column comes back as JSON text + // whether it is stored as nvarchar or the native json type. + cmd.CommandText = + $"select cast(data as nvarchar(max)) from {table}{where} " + + $"order by id offset {offset} rows fetch next {pageSize} rows only"; + if (options.IdEquals != null) + { + cmd.Parameters.AddWithValue("@id", options.IdEquals); + } + + await using var reader = await cmd.ExecuteReaderAsync(token).ConfigureAwait(false); + while (await reader.ReadAsync(token).ConfigureAwait(false)) + { + rows.Add(reader.GetString(0)); + } + } + + return new DocumentQueryResult(rows, total, pageNumber, pageSize); + } + + async Task IDocumentStoreDiagnostics.LoadDocumentJsonAsync( + string documentTypeName, string id, CancellationToken token) + { + var mapping = ResolveMappingForDiagnostics(documentTypeName); + if (mapping == null) + { + return null; + } + + var table = mapping.QualifiedTableName; + + await using var conn = new SqlConnection(Database.ConnectionString); + await conn.OpenAsync(token).ConfigureAwait(false); + + await using var cmd = conn.CreateCommand(); + cmd.CommandText = + $"select top 1 cast(data as nvarchar(max)) from {table} where cast(id as nvarchar(max)) = @id"; + cmd.Parameters.AddWithValue("@id", id); + + var result = await cmd.ExecuteScalarAsync(token).ConfigureAwait(false); + return result == null || result is DBNull ? null : (string)result; + } + + private Storage.DocumentMapping? ResolveMappingForDiagnostics(string documentTypeName) + { + return MaterializeMappings().FirstOrDefault(m => + string.Equals(m.DocumentType.FullNameInCode(), documentTypeName, StringComparison.OrdinalIgnoreCase) + || string.Equals(m.DocumentType.FullName, documentTypeName, StringComparison.OrdinalIgnoreCase) + || string.Equals(m.DocumentType.Name, documentTypeName, StringComparison.OrdinalIgnoreCase) + || string.Equals(m.DocumentType.Name.ToLowerInvariant(), documentTypeName, StringComparison.OrdinalIgnoreCase)); + } + + /// + /// Force lazy provider materialization (Schema.For<T> registrations + aggregate + /// document types declared by projections) and return the resulting mappings — the + /// same two-source dance TryCreateUsage performs, so the Explorer sees the same + /// document set the descriptor snapshot does. + /// + private IEnumerable MaterializeMappings() + { + var seenDocumentTypes = new HashSet(); + + foreach (var expr in Options.Schema.Expressions) + { + var exprType = expr.GetType(); + if (!exprType.IsGenericType) continue; + + var documentType = exprType.GetGenericArguments()[0]; + if (seenDocumentTypes.Add(documentType)) + { + Options.Providers.GetProvider(documentType); + } + } + + foreach (var aggregate in Options.Projections.All.OfType()) + { + if (seenDocumentTypes.Add(aggregate.AggregateType)) + { + Options.Providers.GetProvider(aggregate.AggregateType); + } + } + + return Options.Providers.AllProviders.Select(p => p.Mapping); + } +} diff --git a/src/Polecat/DocumentStore.DocumentStoreUsage.cs b/src/Polecat/DocumentStore.DocumentStoreUsage.cs index e27440f1..48882d2f 100644 --- a/src/Polecat/DocumentStore.DocumentStoreUsage.cs +++ b/src/Polecat/DocumentStore.DocumentStoreUsage.cs @@ -144,8 +144,10 @@ private DocumentMappingDescriptor BuildMappingDescriptor( UseOptimisticConcurrency = mapping.UseOptimisticConcurrency, UseNumericRevisions = mapping.UseNumericRevisions, SubClassCount = mapping.SubClasses.Count, - // Polecat doesn't have a partition strategy today — leave null. + SubClasses = mapping.SubClasses.Select(x => TypeDescriptor.For(x.DocumentType)).ToArray(), + // Polecat doesn't have a partition strategy today — leave both null. PartitioningStrategy = null, + Partitioning = null, Ddl = ddl, }; } diff --git a/src/Polecat/PolecatServiceCollectionExtensions.cs b/src/Polecat/PolecatServiceCollectionExtensions.cs index 91b5cd9b..fbadbedd 100644 --- a/src/Polecat/PolecatServiceCollectionExtensions.cs +++ b/src/Polecat/PolecatServiceCollectionExtensions.cs @@ -98,6 +98,8 @@ public static PolecatConfigurationExpression AddPolecat( // bridge for IEventStore. services.AddSingleton(sp => sp.GetRequiredService()); + services.AddSingleton(sp => + (JasperFx.Documents.IDocumentStoreDiagnostics)sp.GetRequiredService()); // Default session factory: lightweight sessions services.TryAddSingleton(sp => diff --git a/src/Polecat/PolecatStoreServiceCollectionExtensions.cs b/src/Polecat/PolecatStoreServiceCollectionExtensions.cs index 417e4739..aa2631b2 100644 --- a/src/Polecat/PolecatStoreServiceCollectionExtensions.cs +++ b/src/Polecat/PolecatStoreServiceCollectionExtensions.cs @@ -89,6 +89,8 @@ public static PolecatStoreExpression AddPolecatStore( // PolecatServiceCollectionExtensions.AddPolecat for the primary store. services.AddSingleton(sp => sp.GetRequiredService()); + services.AddSingleton(sp => + (JasperFx.Documents.IDocumentStoreDiagnostics)sp.GetRequiredService()); return new PolecatStoreExpression(services); }