Writers derive the catalog from the records they write; the merging is deleted

Every writer (C# single-file and directory, TypeScript, Python; Rust already did) chooses, for each schema id a record carries, one
entry that publishes it: the entry that owns the id (a registered descriptor, else an arrived entry), else one that lists it as
compatible. A registered descriptor wins over an arrived entry with the same own id. Entries are written as chosen, with no union of
compatible ids and no dependence on arrival order; an entry no record needs is not written. Two entries of one tier that share an own id
and disagree on the schema name, records of different types under one id, or a record no chosen entry publishes refuse the write with a
typed error (CultSchemaConflictException, SchemaConflictError) naming the id, the names and a record key, and the store is left as it was.
CultSchemaCatalogEntry.MergeById and the Python and TypeScript merge paths are gone. A cache-held record unchanged since the file was read
is rewritten under its registered id (C# single-file and directory), so an entry that lists an older id is not needed to publish it.
Changelogs for cultnet-py (removed export) and cultcache-py.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-30 10:03:34 +02:00
commit e77246b657
14 changed files with 678 additions and 269 deletions

View file

@ -251,18 +251,16 @@ public sealed class DirectoryMessagePackBackingStore : CacheBackingStore
currentIndex[key] = ToIndexRecord(stored);
}
var catalogCandidates = CultSchemaCatalogEntry.MergeById(
currentManifest.SchemaCatalog
.Concat(_durableCatalog)
.Concat(Entries.Values.Select(entry => entry.Descriptor.ToCatalogEntry())),
preferLast: true)
.ToArray();
var usedSchemaIds = currentIndex.Values.Select(record => record.SchemaId).ToHashSet(StringComparer.Ordinal);
var targetCatalog = catalogCandidates
.Where(entry => usedSchemaIds.Contains(entry.SchemaId) || entry.CompatibleSchemaIds.Any(usedSchemaIds.Contains))
.OrderBy(entry => entry.SchemaName, StringComparer.Ordinal)
// A record this cache holds, unchanged since the manifest was read, is rewritten under its registered schema's id, as a dirty
// one is: the manifest entry that lists an older id for it is then not needed to publish it.
var restamped = currentIndex.Values
.Where(record => !_dirtyKeys.ContainsKey(record.Key) &&
Entries.TryGetValue(record.Key, out var known) && known.StoredAt == record.StoredAt &&
!string.Equals(known.Descriptor.SchemaId, record.SchemaId, StringComparison.Ordinal))
.Select(record => record.Key)
.ToArray();
var keysToWrite = _dirtyKeys.Keys
.Concat(restamped)
.OrderBy(value => value, StringComparer.Ordinal)
.ToArray();
foreach (var key in keysToWrite)
@ -285,6 +283,14 @@ public sealed class DirectoryMessagePackBackingStore : CacheBackingStore
pagePayload);
}
var targetCatalog = CultSchemaCatalogEntry.Derive(
currentIndex.Values,
Entries.Values.Select(entry => entry.Descriptor.ToCatalogEntry()).ToArray(),
currentManifest.SchemaCatalog.Concat(_durableCatalog).ToArray())
.OrderBy(entry => entry.SchemaName, StringComparer.Ordinal)
.ThenBy(entry => entry.SchemaId, StringComparer.Ordinal)
.ToArray();
// The directory store writes only its dirty pages, so it keeps a manifest already marked.
var header = HeaderFor(
keysToWrite.Any(key => Entries.TryGetValue(key, out var written) && written.HoldsIds),