From 4002a996b81309dbe588bdeb0271d330a558a9c8 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 17:47:48 +0200 Subject: [PATCH] CultMesh Kotlin: a cache refuses a second type for one schema id, typed F7 (operator, 2026-09-30): "Schema aliasing sounds like a really bad idea, I support retiring it." CultCache.register wrote its type and schema maps unconditionally, and put, get, getAll and delete register implicitly, so one Kotlin cache could hold two types for one schema and codecForSchema answered with the last. - register refuses a codec whose type already holds another schema id, or whose schema id another type holds, with the new CultSchemaConflictException (schema id, both document types, empty record key), the Kotlin counterpart of the other runtimes' typed conflict. The implicit registration in put, get, getAll and delete goes through the same check. Registering the same type for the same schema again stays allowed. - Self-test: other-type same-schema and same-type other-schema claimants, through register, put, get, getAll and delete, each in a fresh cache; the first codec still answers for its schema afterwards. Co-Authored-By: Claude Opus 5.5 --- .../kotlin/org/gamecult/cultmesh/CultMesh.kt | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/packages/cultmesh-kotlin/src/main/kotlin/org/gamecult/cultmesh/CultMesh.kt b/packages/cultmesh-kotlin/src/main/kotlin/org/gamecult/cultmesh/CultMesh.kt index 2117c2d8..4c87a051 100644 --- a/packages/cultmesh-kotlin/src/main/kotlin/org/gamecult/cultmesh/CultMesh.kt +++ b/packages/cultmesh-kotlin/src/main/kotlin/org/gamecult/cultmesh/CultMesh.kt @@ -55,6 +55,17 @@ data class CultCacheEnvelope( val payload: ByteArray, ) +/** + * A schema this cache cannot give to one more type: another type already holds the schema id, or the type already holds + * another schema id. Nothing is registered. It names the refused codec's schema id and both document types. + */ +class CultSchemaConflictException( + val schemaId: String, + val schemaNames: List, + val recordKey: String, + message: String, +) : IOException(message) + class CultCache { companion object { const val GLOBAL_KEY = "__global__" @@ -64,7 +75,19 @@ class CultCache { private val codecsBySchema = linkedMapOf>() private val values = linkedMapOf>() + // One type owns a schema id. Every path that names a codec (register, put, get, getAll, delete) comes through here. fun register(codec: CultDocumentCodec) { + val holder = codecs[codec.documentType]?.takeIf { it.schemaVersion != codec.schemaVersion } + ?: codecsBySchema[codec.schemaVersion]?.takeIf { it.documentType != codec.documentType } + if (holder != null) { + throw CultSchemaConflictException( + codec.schemaVersion, + listOf(holder.documentType, codec.documentType), + "", + "Kotlin CultCache type ${holder.documentType} holds schema ${holder.schemaVersion}; " + + "type ${codec.documentType} cannot also be registered for schema id ${codec.schemaVersion}", + ) + } codecs[codec.documentType] = codec codecsBySchema[codec.schemaVersion] = codec } @@ -3539,6 +3562,42 @@ private fun cultCacheRawSnapshotsRoundTripThroughCultNetMessages() { check(syncedAlias == KotlinUiNote("kotlin.alias_note.v1", "canonical-to-ui")) check(target.require(uiNote, "note:alias") == syncedAlias) + // One type per schema in one cache: a second type for a held schema id, or a held type for another schema id, is + // refused typed, by register and by the implicit registration in put, get, getAll and delete alike. + fun refusal(action: () -> Unit): CultSchemaConflictException? = + try { action(); null } catch (conflict: CultSchemaConflictException) { conflict } + val canonicalOtherSchema = cultDocument(KotlinAliasNoteCodec( + documentType = "kotlin.alias_note", + schemaVersion = "kotlin.alias_note.v2", + create = ::KotlinCanonicalNote, + readSchemaVersion = KotlinCanonicalNote::schemaVersion, + readBody = KotlinCanonicalNote::body, + )) + for ((first, second) in listOf(canonicalNote to uiNote, uiNote to canonicalNote, canonicalNote to canonicalOtherSchema)) { + @Suppress("UNCHECKED_CAST") + val claimant = second as CultDocumentDefinition + val claimantValue = claimant.codec.decode(canonicalNote.codec.encode(KotlinCanonicalNote("kotlin.alias_note.v1", "x"))) + val attempts: List<(CultCache) -> Unit> = listOf( + { cache -> cache.register(claimant) }, + { cache -> cache.put(claimant, "note:second", claimantValue) }, + { cache -> cache.get(claimant, "note:second") }, + { cache -> cache.getAll(claimant) }, + { cache -> cache.delete(claimant.codec, "note:second") }, + ) + for (attempt in attempts) { + val cache = CultCache() + @Suppress("UNCHECKED_CAST") + cache.register(first as CultDocumentDefinition) + val conflict = refusal { attempt(cache) } + check(conflict != null) { "${second.documentType}/${second.schemaVersion} beside ${first.documentType}/${first.schemaVersion} was not refused" } + check(conflict.schemaId == second.schemaVersion && conflict.recordKey == "") + check(conflict.schemaNames == listOf(first.documentType, second.documentType)) + check(conflict.message!!.contains(second.schemaVersion)) + check(cache.codecForSchema(first.schemaVersion).documentType == first.documentType) + } + } + CultCache().apply { register(canonicalNote); register(canonicalNote); put(canonicalNote, "note:again", KotlinCanonicalNote("kotlin.alias_note.v1", "again")) } + val reactiveUiNote = cultDocument(KotlinReactiveNoteCodec( documentType = "kotlin.reactive_note.ui", schemaVersion = "kotlin.reactive_note.v1",