fix(events): AnyMapSerializer drops nulls instead of failing to read them

toJsonElement wrote a null map value as JSON null, but toAny threw on reading it back — asymmetric, so a single null-valued tool-arg made the whole session's events undecodable: 500 on /events AND a runtime WorkflowFailed when the projection rebuilt mid-run. A Map<String, Any> can't hold a null anyway, so drop null-valued keys on both serialize and deserialize, keeping the log round-trippable.
This commit is contained in:
2026-07-02 13:26:34 +04:00
parent 9eb05fc0dd
commit 883e93cecb
2 changed files with 63 additions and 11 deletions
@@ -28,43 +28,56 @@ private object AnySerializer : KSerializer<Any> {
override fun deserialize(decoder: Decoder): Any {
val jsonDecoder = decoder as? JsonDecoder
?: throw SerializationException("AnySerializer requires a JSON decoder")
return jsonDecoder.decodeJsonElement().toAny()
return jsonDecoder.decodeJsonElement().toAnyOrNull()
?: throw SerializationException("null values are not supported in Map<String, Any>")
}
}
// Null values are dropped, not written: a Map<String, Any> can't hold a Kotlin null, so a null
// (e.g. a tool arg the model emitted as JSON null) carries no information. Serialize and deserialize
// must stay symmetric — the event log has to round-trip anything it writes, or a single null-valued
// map key makes the whole session's events undecodable (500 on /events, unreplayable).
private fun Any?.toJsonElement(): JsonElement = when (this) {
null -> JsonNull
is String -> JsonPrimitive(this)
is Number -> JsonPrimitive(this)
is Boolean -> JsonPrimitive(this)
is Map<*, *> -> JsonObject(entries.associate { (k, v) -> k.toString() to v.toJsonElement() })
is List<*> -> JsonArray(map { it.toJsonElement() })
is Map<*, *> -> JsonObject(entries.mapNotNull { (k, v) -> v?.let { k.toString() to it.toJsonElement() } }.toMap())
is List<*> -> JsonArray(mapNotNull { it?.toJsonElement() })
else -> throw SerializationException("Unsupported type: ${this::class.simpleName}")
}
private fun JsonElement.toAny(): Any = when (this) {
is JsonNull -> throw SerializationException("null values are not supported in Map<String, Any>")
// Returns null for JSON null (and the literal "null" scalar); callers drop those entries so a
// null value never reaches the non-null Map<String, Any>.
private fun JsonElement.toAnyOrNull(): Any? = when (this) {
is JsonNull -> null
is JsonPrimitive -> {
val content = this.content
when {
content == "true" || content == "false" -> content == "true"
content == "null" -> throw SerializationException("null values are not supported in Map<String, Any>")
content == "null" -> null
content.toDoubleOrNull() != null && content.contains('.') -> content.toDouble()
content.toLongOrNull() != null -> content.toLong()
else -> content // fallback to string
}
}
is JsonObject -> entries.associate { (k, v) -> k to v.toAny() }
is JsonArray -> map { it.toAny() }
is JsonObject -> entries.mapNotNull { (k, v) -> v.toAnyOrNull()?.let { k to it } }.toMap()
is JsonArray -> mapNotNull { it.toAnyOrNull() }
}
object AnyMapSerializer : KSerializer<Map<String, Any>> {
private val delegate = MapSerializer(String.serializer(), AnySerializer)
override val descriptor: SerialDescriptor = delegate.descriptor
@Suppress("UNCHECKED_CAST")
override fun deserialize(decoder: Decoder): Map<String, Any> =
delegate.deserialize(decoder)
override fun deserialize(decoder: Decoder): Map<String, Any> {
// Decode the object directly and drop null-valued keys, rather than delegating per-value
// (which throws on a null before the map can skip it). Keeps a null-containing map readable.
val jsonDecoder = decoder as? JsonDecoder
?: return delegate.deserialize(decoder)
val obj = jsonDecoder.decodeJsonElement() as? JsonObject
?: throw SerializationException("expected a JSON object for Map<String, Any>")
return obj.entries.mapNotNull { (k, v) -> v.toAnyOrNull()?.let { k to it } }.toMap()
}
override fun serialize(encoder: Encoder, value: Map<String, Any>) =
delegate.serialize(encoder, value)
@@ -0,0 +1,39 @@
package com.correx.core.events.serialization
import kotlinx.serialization.json.Json
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
class AnyMapSerializerTest {
@Test
fun `deserializes a map whose value is JSON null by dropping that key`() {
// The exact shape that used to 500 the whole session's /events: a persisted tool-arg map
// with a null value. Reading it back must succeed, not throw.
val decoded = Json.decodeFromString(AnyMapSerializer, """{"path":"a.txt","mode":null}""")
assertEquals("a.txt", decoded["path"])
assertFalse(decoded.containsKey("mode"))
}
@Test
fun `round-trips scalars, nested objects, and arrays`() {
val decoded = Json.decodeFromString(
AnyMapSerializer,
"""{"s":"x","n":3,"b":true,"arr":[1,2],"obj":{"k":"v"}}""",
)
assertEquals("x", decoded["s"])
assertEquals(3L, decoded["n"])
assertEquals(true, decoded["b"])
assertEquals(listOf(1L, 2L), decoded["arr"])
assertEquals(mapOf("k" to "v"), decoded["obj"])
}
@Test
fun `does not write null values`() {
val json = Json.encodeToString(AnyMapSerializer, mapOf("a" to "keep"))
assertFalse(json.contains("null"))
assertTrue(json.contains("\"a\":\"keep\""))
}
}