Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
<TargetFrameworks>net9.0;net10.0</TargetFrameworks>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<Version>4.5.2</Version>
<Version>4.6.0-alpha.1</Version>
<LangVersion>latest</LangVersion>
<Authors>Jeremy D. Miller</Authors>
<PackageIcon>logo.png</PackageIcon>
Expand Down
8 changes: 4 additions & 4 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -22,16 +22,16 @@
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. -->
<PackageVersion Include="JasperFx" Version="2.13.2" />
<PackageVersion Include="JasperFx.Events" Version="2.13.2" />
<PackageVersion Include="JasperFx" Version="2.14.2" />
<PackageVersion Include="JasperFx.Events" Version="2.14.2" />
<!-- Pin the sibling JasperFx packages for parity with Marten 9's matrix
even though Polecat doesn't currently reference them directly —
keeps the lockstep matrix coherent when transitive resolution
surfaces them through JasperFx / JasperFx.Events updates. The
RuntimeCompiler 5.x line is the active continuation of the 4.x
lineage; do not pin against the parallel stale 2.0.x series. -->
<PackageVersion Include="JasperFx.RuntimeCompiler" Version="5.0.0" />
<PackageVersion Include="JasperFx.SourceGenerator" Version="2.13.2" />
<PackageVersion Include="JasperFx.SourceGenerator" Version="2.14.2" />
<PackageVersion Include="Microsoft.Data.SqlClient" Version="7.0.0" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.0" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
Expand All @@ -57,7 +57,7 @@
<PackageVersion Include="StronglyTypedId" Version="1.0.0-beta08" />

<!-- Source generators (matched to JasperFx.Events above) -->
<PackageVersion Include="JasperFx.Events.SourceGenerator" Version="2.13.2" />
<PackageVersion Include="JasperFx.Events.SourceGenerator" Version="2.14.2" />

<!-- Build automation -->
<PackageVersion Include="Nuke.Common" Version="9.0.4" />
Expand Down
154 changes: 154 additions & 0 deletions src/Polecat/DocumentStore.DocumentDiagnostics.cs
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// Store-agnostic, read-only document-query surface for monitoring consoles
/// (CritterWatch #545). Mirrors the role <see cref="JasperFx.Events.IEventStore"/>
/// 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 <c>data</c> column straight off the
/// document table so the JSON matches exactly what Polecat persisted.
/// </summary>
Task<IReadOnlyList<DocumentTypeRef>> 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<IReadOnlyList<DocumentTypeRef>>(refs);
}

async Task<DocumentQueryResult> IDocumentStoreDiagnostics.QueryDocumentsAsync(
string documentTypeName, DocumentQueryOptions options, CancellationToken token)
{
var mapping = ResolveMappingForDiagnostics(documentTypeName);
if (mapping == null)
{
return new DocumentQueryResult(Array.Empty<string>(), 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<string>();
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<string?> 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));
}

/// <summary>
/// Force lazy provider materialization (Schema.For&lt;T&gt; registrations + aggregate
/// document types declared by projections) and return the resulting mappings — the
/// same two-source dance <c>TryCreateUsage</c> performs, so the Explorer sees the same
/// document set the descriptor snapshot does.
/// </summary>
private IEnumerable<Storage.DocumentMapping> MaterializeMappings()
{
var seenDocumentTypes = new HashSet<Type>();

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<JasperFx.Events.Aggregation.IAggregateProjection>())
{
if (seenDocumentTypes.Add(aggregate.AggregateType))
{
Options.Providers.GetProvider(aggregate.AggregateType);
}
}

return Options.Providers.AllProviders.Select(p => p.Mapping);
}
}
4 changes: 3 additions & 1 deletion src/Polecat/DocumentStore.DocumentStoreUsage.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
}
Expand Down
2 changes: 2 additions & 0 deletions src/Polecat/PolecatServiceCollectionExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,8 @@ public static PolecatConfigurationExpression AddPolecat(
// bridge for IEventStore.
services.AddSingleton<JasperFx.Events.IDocumentStoreUsageSource>(sp =>
sp.GetRequiredService<IDocumentStore>());
services.AddSingleton<JasperFx.Documents.IDocumentStoreDiagnostics>(sp =>
(JasperFx.Documents.IDocumentStoreDiagnostics)sp.GetRequiredService<IDocumentStore>());

// Default session factory: lightweight sessions
services.TryAddSingleton<ISessionFactory>(sp =>
Expand Down
2 changes: 2 additions & 0 deletions src/Polecat/PolecatStoreServiceCollectionExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,8 @@ public static PolecatStoreExpression<T> AddPolecatStore<T>(
// PolecatServiceCollectionExtensions.AddPolecat for the primary store.
services.AddSingleton<JasperFx.Events.IDocumentStoreUsageSource>(sp =>
sp.GetRequiredService<T>());
services.AddSingleton<JasperFx.Documents.IDocumentStoreDiagnostics>(sp =>
(JasperFx.Documents.IDocumentStoreDiagnostics)sp.GetRequiredService<T>());

return new PolecatStoreExpression<T>(services);
}
Expand Down