CultCache: a second type for one schema is refused, typed
F7 (operator, 2026-09-30): "Schema aliasing sounds like a really bad idea, I support retiring it." C# now holds one type per schema, as TypeScript, Python and Rust already do. - Delete IsExactWireAlias and the branch that registered an identical second type by CLR type only. A second type with the same schema name and version is refused with CultSchemaConflictException naming both types and the claimed id, whether it is identical, declares other compatible ids, or has other members. The untyped DuplicateSchemaRegistration refusal and the separate SchemaIdClaimedTwice path collapse into that one refusal. - Owner-beats-lister (batch 10) is unchanged: one type owns an id, others may list it as compatible. - The per-name version index stays; it serves v1/v2 of one schema name, not aliasing. - Tests: the alias-registration tests are replaced by one refusal test over identical, re-declared and re-shaped second types, in both registration orders. - cultcache-store-composition.md drops the AsSchemaAlias stale-protection note. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
1e0455028c
commit
98999997ce
3 changed files with 33 additions and 114 deletions
|
|
@ -71,9 +71,8 @@ which route wrote it.
|
|||
after subscribing with a `Sequence` at or below the read is already in the
|
||||
document and is dropped. CultMesh cache-record mirrors (`ObserveAsync`,
|
||||
`ReactiveAsync`, and `RefreshAsync` on both) do this.
|
||||
- Not stale-protected: schema-alias handles (`AsSchemaAlias`) get no
|
||||
`Sequence`; removals do not advance a mirror's applied `Sequence`, and the
|
||||
mirrors do not surface removals.
|
||||
- Not stale-protected: removals do not advance a mirror's applied `Sequence`,
|
||||
and the mirrors do not surface removals.
|
||||
- `AddJournal` registers an ordered admission journal, for audit, replication or
|
||||
a derived index. The cache calls each journal under its gate, right after an
|
||||
admission is applied and before that admission is published, once per
|
||||
|
|
|
|||
|
|
@ -689,29 +689,16 @@ namespace GameCult.Caching
|
|||
if (indexes.ByType.TryGetValue(descriptor.DocumentType, out var registered))
|
||||
return registered;
|
||||
|
||||
// One type owns a schema, whatever its declaration. Its id hashes the schema's name, version and members, so a second
|
||||
// type of the same name and version is refused here whether or not it hashes alike. Other types may list the id as
|
||||
// compatible beside its owner, as a v2 class does that keeps v1's class registered: a record under the id resolves to
|
||||
// its owner, and to a lister only when nothing owns it. Neither depends on the order types were registered in.
|
||||
indexes.BySchemaName.TryGetValue(descriptor.SchemaName, out var schemaNameVersions);
|
||||
var schemaVersionOwner = schemaNameVersions?.FirstOrDefault(candidate =>
|
||||
string.Equals(candidate.SchemaVersion, descriptor.SchemaVersion, StringComparison.Ordinal));
|
||||
if (schemaVersionOwner != null)
|
||||
{
|
||||
if (IsExactWireAlias(schemaVersionOwner, descriptor))
|
||||
{
|
||||
indexes.ByType[descriptor.DocumentType] = descriptor;
|
||||
return descriptor;
|
||||
}
|
||||
throw SchemaClaimedTwice(schemaVersionOwner, descriptor);
|
||||
|
||||
if (string.Equals(schemaVersionOwner.SchemaId, descriptor.SchemaId, StringComparison.Ordinal))
|
||||
throw SchemaIdClaimedTwice(descriptor.SchemaId, schemaVersionOwner, descriptor);
|
||||
throw DuplicateSchemaRegistration(
|
||||
$"schema name '{descriptor.SchemaName}' version '{descriptor.SchemaVersion}'",
|
||||
schemaVersionOwner.DocumentType,
|
||||
descriptor.DocumentType);
|
||||
}
|
||||
|
||||
// A schema id hashes the schema's name and version, so the one other type that could own this id is the one refused or
|
||||
// aliased above. Other types may list the id as compatible beside its owner, as a v2 class does that keeps v1's class
|
||||
// registered: a record under the id resolves to its owner, and to a lister only when nothing owns it. Neither depends on
|
||||
// the order types were registered in.
|
||||
indexes.ByType[descriptor.DocumentType] = descriptor;
|
||||
indexes.BySchemaId[descriptor.SchemaId] = descriptor;
|
||||
foreach (var compatibleSchemaId in descriptor.CompatibleSchemaIds)
|
||||
|
|
@ -724,39 +711,17 @@ namespace GameCult.Caching
|
|||
return descriptor;
|
||||
}
|
||||
|
||||
// An alias is the same schema in every respect a store sees, including the ids it declares compatible: a type whose
|
||||
// declaration differed would have it dropped by whichever registered second.
|
||||
private static bool IsExactWireAlias(
|
||||
CultDocumentDescriptor canonical,
|
||||
CultDocumentDescriptor candidate) =>
|
||||
string.Equals(canonical.SchemaName, candidate.SchemaName, StringComparison.Ordinal) &&
|
||||
string.Equals(canonical.SchemaVersion, candidate.SchemaVersion, StringComparison.Ordinal) &&
|
||||
string.Equals(canonical.SchemaId, candidate.SchemaId, StringComparison.Ordinal) &&
|
||||
string.Equals(canonical.ContentHash, candidate.ContentHash, StringComparison.Ordinal) &&
|
||||
string.Equals(canonical.CanonicalSchemaJson, candidate.CanonicalSchemaJson, StringComparison.Ordinal) &&
|
||||
canonical.CompatibleSchemaIds.SequenceEqual(candidate.CompatibleSchemaIds, StringComparer.Ordinal);
|
||||
|
||||
private static CultSchemaConflictException SchemaIdClaimedTwice(
|
||||
string schemaId,
|
||||
private static CultSchemaConflictException SchemaClaimedTwice(
|
||||
CultDocumentDescriptor existing,
|
||||
CultDocumentDescriptor claimed) =>
|
||||
new(
|
||||
$"CultCache schema id '{schemaId}' is already carried by CLR type '{existing.DocumentType.FullName}' " +
|
||||
$"(schema '{existing.SchemaName}') and cannot also be claimed by '{claimed.DocumentType.FullName}' " +
|
||||
$"(schema '{claimed.SchemaName}'). A schema id is owned by one registered type; others may only declare it compatible.",
|
||||
schemaId,
|
||||
$"CultCache schema '{claimed.SchemaName}' version '{claimed.SchemaVersion}' is already registered to CLR type " +
|
||||
$"'{existing.DocumentType.FullName}' and cannot also be claimed by '{claimed.DocumentType.FullName}'. " +
|
||||
"A schema is owned by one registered type; others may only declare its id compatible.",
|
||||
claimed.SchemaId,
|
||||
new[] { existing.SchemaName, claimed.SchemaName },
|
||||
string.Empty);
|
||||
|
||||
private static InvalidOperationException DuplicateSchemaRegistration(
|
||||
string identity,
|
||||
Type existingType,
|
||||
Type claimedType) =>
|
||||
new(
|
||||
$"CultCache {identity} is already registered to CLR type " +
|
||||
$"'{existingType.FullName}' and cannot also be claimed by '{claimedType.FullName}'. " +
|
||||
"Schema compatibility must be declared through explicit persisted-schema alias metadata.");
|
||||
|
||||
private sealed class RegistryIndexes
|
||||
{
|
||||
public RegistryIndexes()
|
||||
|
|
|
|||
|
|
@ -33,28 +33,6 @@ public sealed class CultDocumentRegistryTests
|
|||
Assert.That(second, Is.SameAs(first));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ExactWireCompatibleAliasRegistersByTypeWithOneCanonicalSchemaDescriptor()
|
||||
{
|
||||
var registry = new CultDocumentRegistry();
|
||||
var firstType = Emit("alias_owner", "tests.registry.alias", "v1");
|
||||
var aliasType = Emit("alias_claimant", "tests.registry.alias", "v1");
|
||||
|
||||
var canonical = registry.GetRequired(firstType);
|
||||
var alias = registry.GetRequired(aliasType);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(alias.DocumentType, Is.EqualTo(aliasType));
|
||||
Assert.That(alias.SchemaId, Is.EqualTo(canonical.SchemaId));
|
||||
Assert.That(registry.GetRequired(aliasType), Is.SameAs(alias));
|
||||
Assert.That(registry.GetRequiredBySchemaId(canonical.SchemaId), Is.SameAs(canonical));
|
||||
Assert.That(
|
||||
registry.AllDescriptors.Count(descriptor => descriptor.SchemaId == canonical.SchemaId),
|
||||
Is.EqualTo(2));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void DifferentVersionsSharingSchemaNameRegisterWithoutReflectionOrderSelection()
|
||||
{
|
||||
|
|
@ -85,28 +63,6 @@ public sealed class CultDocumentRegistryTests
|
|||
.And.Message.Contains("v2"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void IncompatibleTypesClaimingSameSchemaNameAndVersionFailBeforeMutation()
|
||||
{
|
||||
var registry = new CultDocumentRegistry();
|
||||
var firstType = Emit("layout_owner", "tests.registry.layout_collision", "v1");
|
||||
var secondType = Emit(
|
||||
"layout_claimant",
|
||||
"tests.registry.layout_collision",
|
||||
"v1",
|
||||
new[] { new Field("Value", typeof(string), 0) });
|
||||
var first = registry.GetRequired(firstType);
|
||||
|
||||
Assert.That(
|
||||
() => registry.GetRequired(secondType),
|
||||
Throws.TypeOf<InvalidOperationException>()
|
||||
.With.Message.Contains("schema name 'tests.registry.layout_collision' version 'v1'")
|
||||
.And.Message.Contains(firstType.FullName)
|
||||
.And.Message.Contains(secondType.FullName));
|
||||
Assert.That(registry.GetRequiredBySchemaId(first.SchemaId), Is.SameAs(first));
|
||||
Assert.That(registry.AllDescriptors.Any(descriptor => descriptor.DocumentType == secondType), Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ConcurrentRefreshAndReadsObserveCompleteRegistrySnapshots()
|
||||
{
|
||||
|
|
@ -122,10 +78,10 @@ public sealed class CultDocumentRegistryTests
|
|||
});
|
||||
}
|
||||
|
||||
// A second type owning a schema id is refused, typed, naming both, whichever order the two register in, and the registry
|
||||
// keeps what it had.
|
||||
private static void AssertSecondClaimRefused(Type first, Type second, string schemaId)
|
||||
// A second type for one schema is refused, typed, naming both and the claimed id, and the registry keeps what it had.
|
||||
private static void AssertSecondClaimRefused(Type first, Type second)
|
||||
{
|
||||
var schemaId = CultDocumentRegistry.ForTypes(new[] { second }).GetRequired(second).SchemaId;
|
||||
var refusal = Assert.Throws<CultSchemaConflictException>(() => CultDocumentRegistry.ForTypes(new[] { first, second }))!;
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
|
|
@ -191,26 +147,25 @@ public sealed class CultDocumentRegistryTests
|
|||
Assert.That(CultDocumentRegistry.ForTypes(listers.Prepend(owner)).ResolvePersistedSchema(sharedId, catalog).DocumentType, Is.EqualTo(owner));
|
||||
}
|
||||
|
||||
// The same schema declaring different compatible ids is not an alias: whichever registered second would lose its declaration.
|
||||
[TestCase(false)]
|
||||
[TestCase(true)]
|
||||
public void TheSameSchemaWithADifferentDeclarationIsNotAnAlias(bool reversed)
|
||||
// One type per schema, in every runtime: a second type of the same schema name and version is refused whichever registers
|
||||
// first, whether it is identical to the first, declares different compatible ids, or has different members.
|
||||
[TestCase("identical", false)]
|
||||
[TestCase("identical", true)]
|
||||
[TestCase("declaration", false)]
|
||||
[TestCase("declaration", true)]
|
||||
[TestCase("members", false)]
|
||||
[TestCase("members", true)]
|
||||
public void ASecondTypeForOneSchemaIsRefused(string difference, bool reversed)
|
||||
{
|
||||
var bare = Emit("bare", "tests.registry.declared_alias", "v1");
|
||||
var declaring = Emit("declaring", "tests.registry.declared_alias", "v1", compatibleSchemaIds: new[] { "tests.registry.older" });
|
||||
var schemaId = CultDocumentRegistry.ForTypes(new[] { bare }).GetRequired(bare).SchemaId;
|
||||
var schemaName = $"tests.registry.claimed_twice_{difference}";
|
||||
var first = Emit($"first_{difference}", schemaName, "v1");
|
||||
var second = difference switch
|
||||
{
|
||||
"identical" => Emit($"second_{difference}", schemaName, "v1"),
|
||||
"declaration" => Emit($"second_{difference}", schemaName, "v1", compatibleSchemaIds: new[] { "tests.registry.older" }),
|
||||
_ => Emit($"second_{difference}", schemaName, "v1", new[] { new Field("Value", typeof(string), 0) }),
|
||||
};
|
||||
|
||||
AssertSecondClaimRefused(reversed ? declaring : bare, reversed ? bare : declaring, schemaId);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TheSameSchemaWithTheSameDeclarationIsAnAlias()
|
||||
{
|
||||
var first = Emit("declaring_first", "tests.registry.same_declaration", "v1", compatibleSchemaIds: new[] { "tests.registry.same_older" });
|
||||
var second = Emit("declaring_second", "tests.registry.same_declaration", "v1", compatibleSchemaIds: new[] { "tests.registry.same_older" });
|
||||
|
||||
var registry = CultDocumentRegistry.ForTypes(new[] { first, second });
|
||||
|
||||
Assert.That(registry.GetRequired(second).SchemaId, Is.EqualTo(registry.GetRequired(first).SchemaId));
|
||||
AssertSecondClaimRefused(reversed ? second : first, reversed ? first : second);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue