From fa266e5829c9c4fdc8741fbc52d889a761c2139b Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 19:49:58 +0200 Subject: [PATCH] CultNet C#: a second type for one schema id is refused, typed Soul F5. F7 (operator, 2026-09-30): "Schema aliasing sounds like a really bad idea, I support retiring it." CultNetDocumentRegistry.Register wrote both maps unconditionally, so the last binding for a schema id won. - Register refuses a binding whose type already holds another schema id, or whose schema id another type holds, with the reference runtime's CultSchemaConflictException: the new binding's schema id, both document types, an empty record key. Nothing is bound on refusal. Binding a type to the schema id it holds again replaces the binding. This matches Rust (fc3f3ee5) and Kotlin. - Tests: both claimant shapes in both orders with two bindings ahead of the holder (F6/M11), the first binding intact afterwards, and a same-type rebind replacing. Co-Authored-By: Claude Opus 5.5 --- .../CultNetDocumentRegistry.cs | 23 ++++- .../CultNetDocumentRegistryConflictTests.cs | 91 +++++++++++++++++++ 2 files changed, 113 insertions(+), 1 deletion(-) create mode 100644 tests/GameCult.Networking.Tests/CultNetDocumentRegistryConflictTests.cs diff --git a/src/GameCult.Networking/CultNetDocumentRegistry.cs b/src/GameCult.Networking/CultNetDocumentRegistry.cs index eb23d503..805312a3 100644 --- a/src/GameCult.Networking/CultNetDocumentRegistry.cs +++ b/src/GameCult.Networking/CultNetDocumentRegistry.cs @@ -165,10 +165,31 @@ namespace GameCult.Networking } /// - /// Registers a document binding. + /// Registers a document binding. One binding owns a document type and a schema id: a binding claiming a type or a schema + /// id another binding already holds is refused with , naming the new binding's + /// schema id and both document types, and nothing is bound. Binding a type to the schema id it already holds replaces + /// that binding. /// public CultNetDocumentRegistry Register(CultNetDocumentBinding binding) { + var holder = + _bindingsByType.TryGetValue(binding.DocumentType, out var sameType) && + !string.Equals(sameType.SchemaId, binding.SchemaId, StringComparison.Ordinal) + ? sameType + : _bindingsBySchemaId.TryGetValue(binding.SchemaId, out var sameSchema) && + sameSchema.DocumentType != binding.DocumentType + ? sameSchema + : null; + if (holder != null) + { + throw new CultSchemaConflictException( + $"CultNet schema id \"{holder.SchemaId}\" is already bound to type \"{holder.DocumentType.FullName}\"; " + + $"type \"{binding.DocumentType.FullName}\" (schema id \"{binding.SchemaId}\") cannot also be bound.", + binding.SchemaId, + new[] { holder.DocumentType.FullName ?? holder.DocumentType.Name, binding.DocumentType.FullName ?? binding.DocumentType.Name }, + string.Empty); + } + _bindingsBySchemaId[binding.SchemaId] = binding; _bindingsByType[binding.DocumentType] = binding; return this; diff --git a/tests/GameCult.Networking.Tests/CultNetDocumentRegistryConflictTests.cs b/tests/GameCult.Networking.Tests/CultNetDocumentRegistryConflictTests.cs new file mode 100644 index 00000000..6f48ee7e --- /dev/null +++ b/tests/GameCult.Networking.Tests/CultNetDocumentRegistryConflictTests.cs @@ -0,0 +1,91 @@ +#nullable enable +using System; +using GameCult.Caching; +using MessagePack; +using NUnit.Framework; + +namespace GameCult.Networking.Tests +{ + // F7 (operator, 2026-09-30): "Schema aliasing sounds like a really bad idea, I support retiring it." One binding owns a + // document type and a schema id, in the reference runtime as in Rust, Kotlin and TypeScript. + public sealed class CultNetDocumentRegistryConflictTests + { + private static CultNetDocumentBinding Bind(string? schemaId = null) where T : class => + CultNetDocumentBinding.ForDocument(CultDocumentRegistry.Shared, schemaId); + + // Two bindings stand ahead of the holder, so a conflict check that looks only at the first holder misses it. + private static CultNetDocumentRegistry RegistryHolding(CultNetDocumentBinding holder) => + new CultNetDocumentRegistry(CultDocumentRegistry.Shared) + .Register(Bind()) + .Register(Bind()) + .Register(holder); + + [Test] + public void A_second_type_for_one_schema_id_is_refused_typed_in_either_order() + { + var first = Bind("f5.shared"); + var second = Bind("f5.shared"); + foreach (var (held, claimant) in new[] { (first, second), (second, first) }) + { + var registry = RegistryHolding(held); + var error = Assert.Throws(() => registry.Register(claimant))!; + Assert.That(error.SchemaId, Is.EqualTo("f5.shared")); + Assert.That(error.SchemaNames, Is.EqualTo(new[] { held.DocumentType.FullName, claimant.DocumentType.FullName })); + Assert.That(error.RecordKey, Is.EqualTo(string.Empty)); + Assert.That(error.Message, Does.Contain($"type \"{claimant.DocumentType.FullName}\"")); + // Nothing was bound: the schema id still resolves to the first type, and the claimant's type to nothing. + Assert.That(registry.GetBySchemaId("f5.shared"), Is.SameAs(held)); + Assert.That(registry.GetByDocumentType(claimant.DocumentType), Is.Null); + } + } + + [Test] + public void A_type_bound_to_one_schema_id_cannot_be_bound_to_another() + { + var own = Bind(); + var registry = RegistryHolding(own); + var error = Assert.Throws(() => registry.Register(Bind("f5.elsewhere")))!; + Assert.That(error.SchemaId, Is.EqualTo("f5.elsewhere")); + Assert.That(registry.GetBySchemaId("f5.elsewhere"), Is.Null); + Assert.That(registry.GetByDocumentType(typeof(ConflictNote)), Is.SameAs(own)); + } + + [Test] + public void Binding_a_type_to_the_schema_id_it_holds_again_replaces_the_binding() + { + var registry = RegistryHolding(Bind()); + var again = Bind(); + registry.Register(again); + Assert.That(registry.GetByDocumentType(typeof(ConflictNote)), Is.SameAs(again)); + Assert.That(registry.GetBySchemaId(again.SchemaId), Is.SameAs(again)); + } + + [CultDocument("f5.conflict-note", "f5.conflict-note.v1")] + [MessagePackObject(AllowPrivate = true)] + public sealed class ConflictNote + { + [Key(0)] public string Body = string.Empty; + } + + [CultDocument("f5.conflict-other-note", "f5.conflict-other-note.v1")] + [MessagePackObject(AllowPrivate = true)] + public sealed class ConflictOtherNote + { + [Key(0)] public string Body = string.Empty; + } + + [CultDocument("f5.conflict-filler-a", "f5.conflict-filler-a.v1")] + [MessagePackObject(AllowPrivate = true)] + public sealed class ConflictFillerA + { + [Key(0)] public string Body = string.Empty; + } + + [CultDocument("f5.conflict-filler-b", "f5.conflict-filler-b.v1")] + [MessagePackObject(AllowPrivate = true)] + public sealed class ConflictFillerB + { + [Key(0)] public string Body = string.Empty; + } + } +}