diff --git a/Directory.Packages.props b/Directory.Packages.props
index 0b5b1b4902..392de79f0b 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -13,9 +13,9 @@
-
-
-
+
+
+
@@ -79,8 +79,8 @@
-
-
+
+
diff --git a/src/EventSourcingTests/Bugs/Bug_4199_natural_key_table_not_found.cs b/src/EventSourcingTests/Bugs/Bug_4199_natural_key_table_not_found.cs
new file mode 100644
index 0000000000..c4a441c0db
--- /dev/null
+++ b/src/EventSourcingTests/Bugs/Bug_4199_natural_key_table_not_found.cs
@@ -0,0 +1,106 @@
+using System;
+using System.Threading.Tasks;
+using JasperFx.Events.Aggregation;
+using Marten;
+using Marten.Events.Projections;
+using Marten.Testing.Harness;
+using Shouldly;
+using Xunit;
+
+namespace EventSourcingTests.Bugs;
+
+public class Bug_4199_natural_key_table_not_found : OneOffConfigurationsContext
+{
+ public sealed record OrderNumber(string Value);
+
+ public sealed record OrderPlaced(Guid OrderId, string OrderNumber);
+
+ public sealed record OrderShipped(Guid OrderId, string TrackingNumber);
+
+ public sealed class OrderAggregate
+ {
+ public Guid Id { get; set; }
+
+ [NaturalKey]
+ public OrderNumber Number { get; set; }
+
+ public string? TrackingNumber { get; set; }
+
+ [NaturalKeySource]
+ public void Apply(OrderPlaced e)
+ {
+ Id = e.OrderId;
+ Number = new OrderNumber(e.OrderNumber);
+ }
+
+ public void Apply(OrderShipped e)
+ {
+ TrackingNumber = e.TrackingNumber;
+ }
+ }
+
+ [Fact]
+ public async Task should_auto_create_natural_key_table_on_fetch_for_writing()
+ {
+ // This is the exact scenario from issue #4199:
+ // No explicit projection registration, no ApplyAllConfiguredChangesToDatabaseAsync(),
+ // just FetchForWriting with a natural key type on a self-aggregating aggregate.
+ // The natural key table should be auto-created.
+ StoreOptions(opts =>
+ {
+ // Deliberately no projection registration - relying on auto-discovery
+ });
+
+ var orderId = Guid.NewGuid();
+ var orderNumber = new OrderNumber("ORD-12345");
+
+ // Trigger auto-discovery of the natural key projection by calling FetchForWriting.
+ // This is the pattern the user would follow: first attempt triggers registration,
+ // then subsequent writes include the inline projection.
+ await using var session0 = theStore.LightweightSession();
+ var preCheck = await session0.Events.FetchForWriting(
+ new OrderNumber("nonexistent"));
+ preCheck.Aggregate.ShouldBeNull(); // No stream exists yet, that's fine
+
+ // Now start a stream — the inline projection is registered, so the natural key
+ // mapping will be written alongside the events
+ await using var session1 = theStore.LightweightSession();
+ session1.Events.StartStream(orderId,
+ new OrderPlaced(orderId, orderNumber.Value));
+ await session1.SaveChangesAsync();
+
+ // Fetch by natural key — should find the aggregate
+ await using var session2 = theStore.LightweightSession();
+ var stream = await session2.Events.FetchForWriting(orderNumber);
+
+ stream.ShouldNotBeNull();
+ stream.Aggregate.ShouldNotBeNull();
+ stream.Aggregate.Number.ShouldBe(orderNumber);
+ stream.Aggregate.Id.ShouldBe(orderId);
+ }
+
+ [Fact]
+ public async Task should_work_with_explicit_inline_projection()
+ {
+ // Verify the explicit registration path still works
+ StoreOptions(opts =>
+ {
+ opts.Projections.Snapshot(SnapshotLifecycle.Inline);
+ });
+
+ var orderId = Guid.NewGuid();
+ var orderNumber = new OrderNumber("ORD-67890");
+
+ await using var session1 = theStore.LightweightSession();
+ session1.Events.StartStream(orderId,
+ new OrderPlaced(orderId, orderNumber.Value));
+ await session1.SaveChangesAsync();
+
+ await using var session2 = theStore.LightweightSession();
+ var stream = await session2.Events.FetchForWriting(orderNumber);
+
+ stream.ShouldNotBeNull();
+ stream.Aggregate.ShouldNotBeNull();
+ stream.Aggregate.Number.ShouldBe(orderNumber);
+ }
+}
diff --git a/src/Marten/DocumentStore.cs b/src/Marten/DocumentStore.cs
index a7a3f400fc..3345946ec7 100644
--- a/src/Marten/DocumentStore.cs
+++ b/src/Marten/DocumentStore.cs
@@ -3,6 +3,7 @@
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
+using System.Reflection;
using System.Threading;
using System.Threading.Tasks;
using System.Transactions;
@@ -11,12 +12,14 @@
using JasperFx.Core.Reflection;
using JasperFx.Descriptors;
using JasperFx.Events;
+using JasperFx.Events.Aggregation;
using JasperFx.Events.Daemon;
using JasperFx.Events.Descriptors;
using JasperFx.Events.Projections;
using JasperFx.MultiTenancy;
using Marten.Events;
using Marten.Events.Daemon;
+using Marten.Events.Projections;
using Marten.Events.Daemon.HighWater;
using Marten.Exceptions;
using Marten.Internal.Sessions;
@@ -62,6 +65,7 @@ public DocumentStore(StoreOptions options)
StorageFeatures.PostProcessConfiguration();
Events.Initialize(this);
Options.Projections.DiscoverGeneratedEvolvers(AppDomain.CurrentDomain.GetAssemblies());
+ DiscoverNaturalKeyAggregates(AppDomain.CurrentDomain.GetAssemblies());
Options.Projections.AssertValidity(Options);
if (Options.LogFactory != null)
@@ -471,6 +475,43 @@ public IProjectionDaemon BuildProjectionDaemon(
return new ProjectionDaemon(this, (MartenDatabase)database, logger, detector);
}
+ ///
+ /// Scan loaded assemblies for types marked with [NaturalKeyAggregate] by the source generator
+ /// and auto-register Inline snapshot projections for any that don't already have a projection.
+ ///
+ private void DiscoverNaturalKeyAggregates(Assembly[] assemblies)
+ {
+ foreach (var assembly in assemblies)
+ {
+ IEnumerable attrs;
+ try
+ {
+ attrs = assembly.GetCustomAttributes();
+ }
+ catch
+ {
+ continue;
+ }
+
+ foreach (var attr in attrs)
+ {
+ if (!Options.Projections.TryFindAggregate(attr.AggregateType, out _))
+ {
+ // Register Inline snapshot via reflection — this activates the full natural key pipeline:
+ // NaturalKeyDefinition discovery, NaturalKeyTable creation, NaturalKeyProjection
+ var snapshotMethod = typeof(ProjectionOptions)
+ .GetMethods()
+ .First(m => m.Name == "Snapshot" && m.IsGenericMethod &&
+ m.GetParameters().Length == 2 &&
+ m.GetParameters()[0].ParameterType == typeof(SnapshotLifecycle));
+
+ snapshotMethod.MakeGenericMethod(attr.AggregateType)
+ .Invoke(Options.Projections, new object?[] { SnapshotLifecycle.Inline, null });
+ }
+ }
+ }
+ }
+
///
/// Quick way to stand up a DocumentStore to the given database connection
/// in the "development" mode for auto-creating schema objects as needed
diff --git a/src/Marten/Events/EventStore.FetchForWriting.cs b/src/Marten/Events/EventStore.FetchForWriting.cs
index c6122439c2..2a3db152b7 100644
--- a/src/Marten/Events/EventStore.FetchForWriting.cs
+++ b/src/Marten/Events/EventStore.FetchForWriting.cs
@@ -18,6 +18,7 @@
using Marten.Internal;
using Marten.Internal.Sessions;
using Marten.Internal.Storage;
+using Marten.Storage;
using Marten.Linq.QueryHandlers;
using Weasel.Postgresql;
using Weasel.Postgresql.SqlGeneration;
@@ -252,7 +253,17 @@ private IAggregateFetchPlan determineFetchPlan(StoreOption
{
// Auto-discover natural key from [NaturalKey] attribute on the aggregate type
// BEFORE iterating planners, so the projection is registered and available
- tryAutoRegisterNaturalKeyProjection(options);
+ if (tryAutoRegisterNaturalKeyProjection(options))
+ {
+ // The projection was just auto-registered, which adds a NaturalKeyTable
+ // to the IEvent feature schema. Reset the schema existence check so
+ // EnsureStorageExistsAsync(typeof(IEvent)) will re-evaluate and create
+ // the natural key table.
+ if (_session.Database is MartenDatabase martenDb)
+ {
+ martenDb.ResetSchemaExistenceChecks();
+ }
+ }
foreach (var planner in options.Projections.allPlanners())
{
@@ -284,13 +295,14 @@ private IAggregateFetchPlan determineFetchPlan(StoreOption
/// This enables FetchForWriting with natural keys on self-aggregating types
/// without requiring explicit projection registration.
///
- private static void tryAutoRegisterNaturalKeyProjection(StoreOptions options)
+ /// True if a projection was newly registered
+ private static bool tryAutoRegisterNaturalKeyProjection(StoreOptions options)
where TDoc : class where TId : notnull
{
// Skip if a projection is already registered for this aggregate type
if (options.Projections.TryFindAggregate(typeof(TDoc), out _))
{
- return;
+ return false;
}
var naturalKeyProp = typeof(TDoc).GetProperties(BindingFlags.Public | BindingFlags.Instance)
@@ -298,12 +310,13 @@ private static void tryAutoRegisterNaturalKeyProjection(StoreOptions
if (naturalKeyProp == null || naturalKeyProp.PropertyType != typeof(TId))
{
- return;
+ return false;
}
// Register an Inline snapshot projection so the natural key infrastructure
// (natural key table, inline projection, NaturalKeyFetchPlanner) all activate
options.Projections.Snapshot(SnapshotLifecycle.Inline);
+ return true;
}
}