diff --git a/Tharga.Platform.sln b/Tharga.Platform.sln
index fd4f33d..1851820 100644
--- a/Tharga.Platform.sln
+++ b/Tharga.Platform.sln
@@ -30,6 +30,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tharga.Platform.Mcp", "Thar
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tharga.Platform.Mcp.Tests", "Tharga.Platform.Mcp.Tests\Tharga.Platform.Mcp.Tests.csproj", "{32F3F58A-486D-44FB-AAD1-DF6CB19100B0}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tharga.Team.MongoDB.Tests", "Tharga.Team.MongoDB.Tests\Tharga.Team.MongoDB.Tests.csproj", "{F53219A8-CB6D-423C-9314-1397F08E1202}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -148,6 +150,18 @@ Global
{32F3F58A-486D-44FB-AAD1-DF6CB19100B0}.Release|x64.Build.0 = Release|Any CPU
{32F3F58A-486D-44FB-AAD1-DF6CB19100B0}.Release|x86.ActiveCfg = Release|Any CPU
{32F3F58A-486D-44FB-AAD1-DF6CB19100B0}.Release|x86.Build.0 = Release|Any CPU
+ {F53219A8-CB6D-423C-9314-1397F08E1202}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {F53219A8-CB6D-423C-9314-1397F08E1202}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {F53219A8-CB6D-423C-9314-1397F08E1202}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {F53219A8-CB6D-423C-9314-1397F08E1202}.Debug|x64.Build.0 = Debug|Any CPU
+ {F53219A8-CB6D-423C-9314-1397F08E1202}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {F53219A8-CB6D-423C-9314-1397F08E1202}.Debug|x86.Build.0 = Debug|Any CPU
+ {F53219A8-CB6D-423C-9314-1397F08E1202}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {F53219A8-CB6D-423C-9314-1397F08E1202}.Release|Any CPU.Build.0 = Release|Any CPU
+ {F53219A8-CB6D-423C-9314-1397F08E1202}.Release|x64.ActiveCfg = Release|Any CPU
+ {F53219A8-CB6D-423C-9314-1397F08E1202}.Release|x64.Build.0 = Release|Any CPU
+ {F53219A8-CB6D-423C-9314-1397F08E1202}.Release|x86.ActiveCfg = Release|Any CPU
+ {F53219A8-CB6D-423C-9314-1397F08E1202}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
diff --git a/Tharga.Team.MongoDB.Tests/RegisterUserRepositoryTests.cs b/Tharga.Team.MongoDB.Tests/RegisterUserRepositoryTests.cs
new file mode 100644
index 0000000..f397c18
--- /dev/null
+++ b/Tharga.Team.MongoDB.Tests/RegisterUserRepositoryTests.cs
@@ -0,0 +1,52 @@
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using Tharga.MongoDB;
+
+namespace Tharga.Team.MongoDB.Tests;
+
+///
+/// Verifies the consumer-facing index extension point added under Tharga/Platform#65:
+/// registers a
+/// subclass of as the implementation of
+/// , so consumers can declare per-deployment indices.
+///
+/// We inspect the on the registered
+/// rather than building the provider, because the underlying DiskRepositoryCollectionBase
+/// ctor casts the injected factory to a concrete type that a test substitute can't satisfy.
+///
+public class RegisterUserRepositoryTests
+{
+ [Fact]
+ public void RegisterUserRepository_Default_Overload_Registers_Builtin_Collection()
+ {
+ var services = new ServiceCollection();
+ services.AddThargaTeamRepository(o => o.RegisterUserRepository());
+
+ var descriptor = services.Single(s =>
+ s.ServiceType == typeof(IUserRepositoryCollection));
+
+ Assert.Equal(
+ typeof(UserRepositoryCollection),
+ descriptor.ImplementationType);
+ }
+
+ [Fact]
+ public void RegisterUserRepository_Subclass_Overload_Registers_Consumer_Subclass()
+ {
+ var services = new ServiceCollection();
+ services.AddThargaTeamRepository(o =>
+ o.RegisterUserRepository());
+
+ var descriptor = services.Single(s =>
+ s.ServiceType == typeof(IUserRepositoryCollection));
+
+ Assert.Equal(typeof(CustomCollection), descriptor.ImplementationType);
+ }
+
+ public class CustomCollection : UserRepositoryCollection
+ {
+ public CustomCollection(IMongoDbServiceFactory mongoDbServiceFactory, ILogger> logger, IOptions options = null)
+ : base(mongoDbServiceFactory, logger, options) { }
+ }
+}
diff --git a/Tharga.Team.MongoDB.Tests/Tharga.Team.MongoDB.Tests.csproj b/Tharga.Team.MongoDB.Tests/Tharga.Team.MongoDB.Tests.csproj
new file mode 100644
index 0000000..f7f0794
--- /dev/null
+++ b/Tharga.Team.MongoDB.Tests/Tharga.Team.MongoDB.Tests.csproj
@@ -0,0 +1,27 @@
+
+
+ net10.0
+ enable
+ false
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Tharga.Team.MongoDB.Tests/UserRepositoryCollectionIndicesTests.cs b/Tharga.Team.MongoDB.Tests/UserRepositoryCollectionIndicesTests.cs
new file mode 100644
index 0000000..4da4bbf
--- /dev/null
+++ b/Tharga.Team.MongoDB.Tests/UserRepositoryCollectionIndicesTests.cs
@@ -0,0 +1,37 @@
+using System.Runtime.CompilerServices;
+using MongoDB.Bson;
+using MongoDB.Bson.Serialization;
+using MongoDB.Driver;
+
+namespace Tharga.Team.MongoDB.Tests;
+
+///
+/// Verifies that declares the
+/// unique Identity index added under Tharga/Platform#65.
+///
+/// The base DiskRepositoryCollectionBase ctor casts the injected factory to a concrete
+/// MongoDbService, which an NSubstitute proxy can't satisfy. Since the override is purely
+/// declarative and doesn't depend on instance state, we bypass the ctor via
+/// .
+///
+public class UserRepositoryCollectionIndicesTests
+{
+ [Fact]
+ public void Indices_Includes_Unique_Identity_Index()
+ {
+ var collection = (UserRepositoryCollection)
+ RuntimeHelpers.GetUninitializedObject(typeof(UserRepositoryCollection));
+
+ var indices = collection.Indices.ToArray();
+
+ var identityIndex = Assert.Single(indices);
+ Assert.Equal("Identity", identityIndex.Options.Name);
+ Assert.True(identityIndex.Options.Unique);
+
+ var keyDoc = identityIndex.Keys.Render(new RenderArgs(
+ BsonSerializer.SerializerRegistry.GetSerializer(),
+ BsonSerializer.SerializerRegistry));
+ Assert.True(keyDoc.Contains("Identity"));
+ Assert.Equal(1, keyDoc["Identity"].AsInt32);
+ }
+}
diff --git a/Tharga.Team.MongoDB.Tests/UserServiceRepositoryBaseRaceTests.cs b/Tharga.Team.MongoDB.Tests/UserServiceRepositoryBaseRaceTests.cs
new file mode 100644
index 0000000..2c974a5
--- /dev/null
+++ b/Tharga.Team.MongoDB.Tests/UserServiceRepositoryBaseRaceTests.cs
@@ -0,0 +1,139 @@
+using System.Reflection;
+using System.Runtime.CompilerServices;
+using System.Security.Claims;
+using Microsoft.AspNetCore.Components.Authorization;
+using MongoDB.Bson;
+using MongoDB.Driver;
+using Tharga.MongoDB;
+
+namespace Tharga.Team.MongoDB.Tests;
+
+///
+/// Verifies the catch-DuplicateKey recovery path in .
+/// When two first-time logins for the same Identity race, the unique Identity index on
+/// guarantees one wins. The losing thread catches
+/// the with and re-reads
+/// the winning row instead of throwing (issue Tharga/Platform#65).
+///
+public class UserServiceRepositoryBaseRaceTests
+{
+ [Fact]
+ public async Task GetUserAsync_NoExistingUser_AddsAndReturnsCandidate()
+ {
+ var repo = Substitute.For>();
+ repo.GetAsync("alice@example.com").Returns((TestUserEntity)null);
+ repo.AddAsync(Arg.Any()).Returns(Task.CompletedTask);
+
+ var sut = new TestUserService(repo, new TestUserEntity { Identity = "alice@example.com", Key = "u-alice" });
+ var result = await sut.InvokeGetUserAsync(BuildClaims("alice@example.com"));
+
+ Assert.NotNull(result);
+ Assert.Equal("u-alice", result.Key);
+ await repo.Received(1).AddAsync(Arg.Any());
+ }
+
+ [Fact]
+ public async Task GetUserAsync_ExistingUser_DoesNotAdd()
+ {
+ var existing = new TestUserEntity { Identity = "bob@example.com", Key = "u-bob" };
+ var repo = Substitute.For>();
+ repo.GetAsync("bob@example.com").Returns(existing);
+
+ var sut = new TestUserService(repo, new TestUserEntity { Identity = "bob@example.com", Key = "should-not-be-used" });
+ var result = await sut.InvokeGetUserAsync(BuildClaims("bob@example.com"));
+
+ Assert.Same(existing, result);
+ await repo.DidNotReceive().AddAsync(Arg.Any());
+ }
+
+ [Fact]
+ public async Task GetUserAsync_AddThrowsDuplicateKey_ReReadsAndReturnsWinner()
+ {
+ // Race: first GetAsync returns null, candidate is created, AddAsync hits the unique-Identity
+ // index conflict and throws. Recovery re-reads by Identity and returns the winner.
+ var winner = new TestUserEntity { Identity = "carol@example.com", Key = "u-winner" };
+ var repo = Substitute.For>();
+ repo.GetAsync("carol@example.com").Returns((TestUserEntity)null, winner);
+ repo.AddAsync(Arg.Any()).Returns(_ => throw BuildDuplicateKeyException());
+
+ var sut = new TestUserService(repo, new TestUserEntity { Identity = "carol@example.com", Key = "u-loser" });
+ var result = await sut.InvokeGetUserAsync(BuildClaims("carol@example.com"));
+
+ Assert.Same(winner, result);
+ await repo.Received(2).GetAsync("carol@example.com");
+ await repo.Received(1).AddAsync(Arg.Any());
+ }
+
+ [Fact]
+ public async Task GetUserAsync_AddThrowsNonDuplicateKey_Propagates()
+ {
+ var repo = Substitute.For>();
+ repo.GetAsync("dave@example.com").Returns((TestUserEntity)null);
+ repo.AddAsync(Arg.Any()).Returns(_ => throw new InvalidOperationException("unrelated"));
+
+ var sut = new TestUserService(repo, new TestUserEntity { Identity = "dave@example.com", Key = "u-dave" });
+
+ await Assert.ThrowsAsync(() => sut.InvokeGetUserAsync(BuildClaims("dave@example.com")));
+ }
+
+ private static ClaimsPrincipal BuildClaims(string identity)
+ {
+ // Tharga.Toolkit's GetIdentity().Identity reads ClaimTypes.NameIdentifier off the principal.
+ var claims = new[] { new Claim(ClaimTypes.NameIdentifier, identity) };
+ return new ClaimsPrincipal(new ClaimsIdentity(claims, "test"));
+ }
+
+ ///
+ /// Construct a carrying a with
+ /// via reflection. Both types have only
+ /// internal constructors in MongoDB.Driver 3.x; we bypass them via
+ /// and set backing fields directly.
+ /// Fragile across driver upgrades but sufficient for a single test fixture.
+ ///
+ private static MongoWriteException BuildDuplicateKeyException()
+ {
+ var writeError = (WriteError)RuntimeHelpers.GetUninitializedObject(typeof(WriteError));
+ SetField(writeError, "_category", ServerErrorCategory.DuplicateKey);
+ SetField(writeError, "_code", 11000);
+ SetField(writeError, "_message", "E11000 duplicate key error");
+ SetField(writeError, "_details", new BsonDocument());
+
+ var exception = (MongoWriteException)RuntimeHelpers.GetUninitializedObject(typeof(MongoWriteException));
+ SetField(exception, "_writeError", writeError);
+ return exception;
+ }
+
+ private static void SetField(object instance, string fieldName, object value)
+ {
+ var field = instance.GetType().GetField(fieldName, BindingFlags.NonPublic | BindingFlags.Instance);
+ if (field == null) throw new InvalidOperationException($"Field '{fieldName}' not found on {instance.GetType().FullName}. The MongoDB.Driver internal layout may have changed.");
+ field.SetValue(instance, value);
+ }
+
+ public record TestUserEntity : EntityBase, IUser
+ {
+ public string Identity { get; init; }
+ public string Key { get; init; }
+ public string EMail { get; init; }
+ public string Name { get; init; }
+ }
+
+ private sealed class TestUserService : UserServiceRepositoryBase
+ {
+ private readonly TestUserEntity _candidate;
+
+ public TestUserService(IUserRepository repo, TestUserEntity candidate)
+ : base(Substitute.For(), repo)
+ {
+ _candidate = candidate;
+ }
+
+ protected override Task CreateUserEntityAsync(ClaimsPrincipal claimsPrincipal, string identity)
+ {
+ return Task.FromResult(_candidate);
+ }
+
+ // Expose the protected method for direct invocation in tests.
+ public Task InvokeGetUserAsync(ClaimsPrincipal principal) => GetUserAsync(principal);
+ }
+}
diff --git a/Tharga.Team.MongoDB/README.md b/Tharga.Team.MongoDB/README.md
index f7716c5..61d591a 100644
--- a/Tharga.Team.MongoDB/README.md
+++ b/Tharga.Team.MongoDB/README.md
@@ -26,6 +26,32 @@ builder.Services.AddThargaTeamRepository(o =>
Requires [Tharga.MongoDB](https://www.nuget.org/packages/Tharga.MongoDB) to be configured with a connection string.
+## Adding per-deployment User indices
+
+The built-in `UserRepositoryCollection` declares a unique index on `Identity`. To add additional indices (e.g. on a custom email column), subclass the collection and register the subclass via the `RegisterUserRepository` overload:
+
+```csharp
+public class MyUserRepositoryCollection : UserRepositoryCollection
+{
+ public MyUserRepositoryCollection(IMongoDbServiceFactory factory, ILogger> logger, IOptions options = null)
+ : base(factory, logger, options) { }
+
+ public override IEnumerable> Indices =>
+ [
+ // Keep the base Identity index
+ ..base.Indices,
+ // Plus your own
+ new(Builders.IndexKeys.Ascending(x => x.EMail),
+ new CreateIndexOptions { Unique = true, Name = "EMail" })
+ ];
+}
+
+builder.Services.AddThargaTeamRepository(o =>
+{
+ o.RegisterUserRepository();
+});
+```
+
## Dependencies
- [Tharga.Team](https://www.nuget.org/packages/Tharga.Team) - Domain models and service abstractions.
diff --git a/Tharga.Team.MongoDB/ThargaTeamOptions.cs b/Tharga.Team.MongoDB/ThargaTeamOptions.cs
index 2a430be..ba6cb21 100644
--- a/Tharga.Team.MongoDB/ThargaTeamOptions.cs
+++ b/Tharga.Team.MongoDB/ThargaTeamOptions.cs
@@ -5,6 +5,7 @@ namespace Tharga.Team.MongoDB;
public record ThargaTeamOptions
{
internal Type _userEntity;
+ internal Type _userCollectionType;
internal Type _teamEntity;
internal Type _teamMemberModel;
@@ -18,10 +19,29 @@ public record ThargaTeamOptions
///
public string UserCollectionName { get; set; } = "User";
+ ///
+ /// Registers the User repository using the built-in .
+ /// Use the RegisterUserRepository<TUserEntity, TCollection> overload to register a consumer
+ /// subclass that declares additional per-deployment indices.
+ ///
public void RegisterUserRepository()
where TUserEntity : EntityBase, IUser
{
_userEntity = typeof(TUserEntity);
+ _userCollectionType = null;
+ }
+
+ ///
+ /// Registers the User repository with a consumer-provided collection subclass.
+ /// Use this when you need to add per-deployment indices on top of the built-in
+ /// unique Identity index (e.g. a unique index on a custom email field).
+ ///
+ public void RegisterUserRepository()
+ where TUserEntity : EntityBase, IUser
+ where TCollection : UserRepositoryCollection
+ {
+ _userEntity = typeof(TUserEntity);
+ _userCollectionType = typeof(TCollection);
}
public void RegisterTeamRepository()
diff --git a/Tharga.Team.MongoDB/ThargaTeamRegistration.cs b/Tharga.Team.MongoDB/ThargaTeamRegistration.cs
index 019ace1..9d74888 100644
--- a/Tharga.Team.MongoDB/ThargaTeamRegistration.cs
+++ b/Tharga.Team.MongoDB/ThargaTeamRegistration.cs
@@ -21,7 +21,8 @@ public static void AddThargaTeamRepository(this IServiceCollection services, Act
var userRepositoryImplementationType = typeof(UserRepository<>).MakeGenericType(userEntityType);
var userRepositoryCollectionInterfaceType = typeof(IUserRepositoryCollection<>).MakeGenericType(userEntityType);
- var userRepositoryCollectionImplementationType = typeof(UserRepositoryCollection<>).MakeGenericType(userEntityType);
+ var userRepositoryCollectionImplementationType = o._userCollectionType
+ ?? typeof(UserRepositoryCollection<>).MakeGenericType(userEntityType);
services.AddTransient(userRepositoryInterfaceType, userRepositoryImplementationType);
services.AddTransient(userRepositoryCollectionInterfaceType, userRepositoryCollectionImplementationType);
diff --git a/Tharga.Team.MongoDB/UserRepositoryCollection.cs b/Tharga.Team.MongoDB/UserRepositoryCollection.cs
index e8bed6d..68a7c8f 100644
--- a/Tharga.Team.MongoDB/UserRepositoryCollection.cs
+++ b/Tharga.Team.MongoDB/UserRepositoryCollection.cs
@@ -1,11 +1,18 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
+using MongoDB.Bson;
+using MongoDB.Driver;
using Tharga.MongoDB;
using Tharga.MongoDB.Disk;
namespace Tharga.Team.MongoDB;
-internal class UserRepositoryCollection : DiskRepositoryCollectionBase, IUserRepositoryCollection
+///
+/// MongoDB collection definition for User documents. Public so consumers can subclass it
+/// to declare per-deployment indices on their own shape.
+/// Register the subclass via .
+///
+public class UserRepositoryCollection : DiskRepositoryCollectionBase, IUserRepositoryCollection
where TUserEntity : EntityBase, IUser
{
private readonly string _collectionName;
@@ -17,4 +24,10 @@ public UserRepositoryCollection(IMongoDbServiceFactory mongoDbServiceFactory, IL
}
public override string CollectionName => _collectionName;
+
+ public override IEnumerable> Indices =>
+ [
+ new(Builders.IndexKeys.Ascending(x => x.Identity),
+ new CreateIndexOptions { Unique = true, Name = "Identity" })
+ ];
}
diff --git a/Tharga.Team.MongoDB/UserServiceRepositoryBase.cs b/Tharga.Team.MongoDB/UserServiceRepositoryBase.cs
index e11aea9..5cd3021 100644
--- a/Tharga.Team.MongoDB/UserServiceRepositoryBase.cs
+++ b/Tharga.Team.MongoDB/UserServiceRepositoryBase.cs
@@ -1,4 +1,5 @@
using Microsoft.AspNetCore.Components.Authorization;
+using MongoDB.Driver;
using System.Security.Claims;
using Tharga.MongoDB;
using Tharga.Toolkit;
@@ -23,13 +24,21 @@ protected override async Task GetUserAsync(ClaimsPrincipal claimsPrincipa
var identity = claimsPrincipal.GetIdentity().Identity;
var user = await _userRepository.GetAsync(identity);
- if (user == null)
+ if (user != null) return user;
+
+ var candidate = await CreateUserEntityAsync(claimsPrincipal, identity);
+ try
{
- user = await CreateUserEntityAsync(claimsPrincipal, identity);
- await _userRepository.AddAsync(user);
+ await _userRepository.AddAsync(candidate);
+ return candidate;
+ }
+ catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey)
+ {
+ // Lost the race against a concurrent first-time login for the same identity.
+ // The unique Identity index on UserRepositoryCollection guarantees only one wins;
+ // re-read and return the winner. Issue Tharga/Platform#65.
+ return await _userRepository.GetAsync(identity);
}
-
- return user;
}
protected override IAsyncEnumerable GetAllAsync()