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