refactor: move the on-disk stores to kotlinx, closing the last reflective binding

The scheduled-post, PoW-queue and resource-usage files were the last three places
Jackson derived JSON keys from Kotlin constructor parameter names. They are
@Serializable now, so kotlinx bakes every key in as a string literal and the five
keep rules that protected them are gone.

    reflection contract   24 checks -> 19
    ScheduledPost -> z3h, PersistedPoWJob -> i2e, PowJobsFile -> ake

These files are user data — queued posts that are pre-signed and waiting, posts
already sent that are still mining — so a format shift would not crash, it would
silently empty the queue on the first launch after an update. Both new test
classes pin the format against the exact bytes the Jackson build wrote, captured
from it before the switch: they assert kotlinx decodes that string AND re-encodes
it byte for byte, which is what a downgrade, or an older `amy` sharing the same
file, depends on. `encodeDefaults = true` is load-bearing — without it kotlinx
drops `"version":1` from every file it rewrites, and there is a test for that too.

Also closed the door behind this. JacksonMapper.fromJsonTo is generic, so nothing
stopped a new OptimizedSerializable being passed to it and bound reflectively —
fine in debug, obfuscated one-letter JSON keys on the wire in release. That is
exactly how NIP-47 and CLINK got there. It now fails loudly on the first call for
a type with no registered deserializer, naming the two ways out.

One entry deliberately kept: ScheduledPostStatus' constants. The enum is still
the on-disk format, and a plain @Serializable enum is not obviously immune —
kotlinx builds its descriptor from the entries and I have not proven those names
are literals rather than Enum.name read at runtime. The blanket enum rule already
covers it, so keeping the check costs nothing, and "every queued post's status
unreadable" is not a good thing to guess at. The contract says so in place.

Verified: full ./gradlew test green, R8 release build green, 19/19 contract
checks, the three DTOs confirmed renamed and the enum constants confirmed still
present in the shipped DEX.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DEoxktEZyTrAS33vVZBiwm
This commit is contained in:
Claude
2026-09-19 12:58:18 +00:00
parent 475da3ff3c
commit 2d1e398910
10 changed files with 372 additions and 41 deletions
+6 -7
View File
@@ -166,13 +166,12 @@
# those types at the hand-written kotlinx serializers, which name every field as a # those types at the hand-written kotlinx serializers, which name every field as a
# string literal. Nothing to keep, nothing to verify. # string literal. Nothing to keep, nothing to verify.
# On-disk JSON written and re-read by the app itself. The field names are the # The on-disk stores (scheduled posts, pending PoW jobs, resource usage) used to
# file format, so renaming them makes every existing file unreadable. # need a keep each, because Jackson derived their JSON keys from the Kotlin
-keep class com.vitorpamplona.amethyst.commons.scheduledposts.ScheduledPost { *; } # constructor parameter names. They are @Serializable now: kotlinx bakes every key
-keep class com.vitorpamplona.amethyst.commons.scheduledposts.ScheduledPostFile { *; } # in as a string literal, so the field names can be renamed freely. The formats are
-keep class com.vitorpamplona.amethyst.service.pow.PowJobsFile { *; } # pinned by ScheduledPostFileFormatTest and PowAndUsageFileFormatTest instead,
-keep class com.vitorpamplona.amethyst.commons.service.pow.PersistedPoWJob { *; } # which assert the bytes against what the Jackson build wrote.
-keep class com.vitorpamplona.amethyst.service.resourceusage.ResourceUsageStore$UsageFile { *; }
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Names referenced from outside the DEX # Names referenced from outside the DEX
@@ -20,9 +20,6 @@
*/ */
package com.vitorpamplona.amethyst.service.pow package com.vitorpamplona.amethyst.service.pow
import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue
import com.vitorpamplona.amethyst.commons.service.pow.PersistedPoWJob import com.vitorpamplona.amethyst.commons.service.pow.PersistedPoWJob
import com.vitorpamplona.amethyst.commons.service.pow.PoWJobPersistence import com.vitorpamplona.amethyst.commons.service.pow.PoWJobPersistence
import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.Log
@@ -32,6 +29,8 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import java.io.File import java.io.File
/** /**
@@ -46,9 +45,18 @@ class PowJobStore(
private val storageFile: File, private val storageFile: File,
scope: CoroutineScope, scope: CoroutineScope,
) : PoWJobPersistence { ) : PoWJobPersistence {
private val mapper = /**
jacksonObjectMapper() * `encodeDefaults = true` so this writes the same bytes Jackson did — kotlinx
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) * omits a value equal to its default, which would silently drop `"version":1`
* from every file this build rewrites. `ignoreUnknownKeys` matches the
* FAIL_ON_UNKNOWN_PROPERTIES=false it replaces, so a file written by a newer
* build still loads here.
*/
private val json =
Json {
ignoreUnknownKeys = true
encodeDefaults = true
}
// one lane: launch order == execution order, so a save followed by its // one lane: launch order == execution order, so a save followed by its
// remove can never be applied backwards. // remove can never be applied backwards.
@@ -103,7 +111,7 @@ class PowJobStore(
jobs = jobs =
try { try {
if (storageFile.exists() && storageFile.length() > 0) { if (storageFile.exists() && storageFile.length() > 0) {
mapper.readValue<PowJobsFile>(storageFile).jobs.toMutableList() json.decodeFromString<PowJobsFile>(storageFile.readText()).jobs.toMutableList()
} else { } else {
mutableListOf() mutableListOf()
} }
@@ -120,7 +128,7 @@ class PowJobStore(
storageFile.parentFile?.mkdirs() storageFile.parentFile?.mkdirs()
val tmp = File(storageFile.parentFile, storageFile.name + ".tmp") val tmp = File(storageFile.parentFile, storageFile.name + ".tmp")
try { try {
mapper.writeValue(tmp, PowJobsFile(version = 1, jobs = jobs.toList())) tmp.writeText(json.encodeToString(PowJobsFile(version = 1, jobs = jobs.toList())))
if (!tmp.renameTo(storageFile)) { if (!tmp.renameTo(storageFile)) {
if (!storageFile.delete() || !tmp.renameTo(storageFile)) { if (!storageFile.delete() || !tmp.renameTo(storageFile)) {
Log.e(TAG) { "Failed to rename $tmp to $storageFile" } Log.e(TAG) { "Failed to rename $tmp to $storageFile" }
@@ -147,6 +155,7 @@ class PowJobStore(
} }
} }
@Serializable
data class PowJobsFile( data class PowJobsFile(
val version: Int = 1, val version: Int = 1,
val jobs: List<PersistedPoWJob> = emptyList(), val jobs: List<PersistedPoWJob> = emptyList(),
@@ -20,12 +20,11 @@
*/ */
package com.vitorpamplona.amethyst.service.resourceusage package com.vitorpamplona.amethyst.service.resourceusage
import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue
import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import java.io.File import java.io.File
/** /**
@@ -60,6 +59,7 @@ class ResourceUsageStore(
*/ */
private val keepDays: Long = 7, private val keepDays: Long = 7,
) { ) {
@Serializable
data class UsageFile( data class UsageFile(
val version: Int = 1, val version: Int = 1,
val days: Map<String, Map<String, Long>> = emptyMap(), val days: Map<String, Map<String, Long>> = emptyMap(),
@@ -67,9 +67,18 @@ class ResourceUsageStore(
val alertsOptOut: Boolean = false, val alertsOptOut: Boolean = false,
) )
private val mapper = /**
jacksonObjectMapper() * `encodeDefaults = true` so this writes the same bytes Jackson did — kotlinx
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) * omits a value equal to its default, which would silently drop `"version":1`
* from every file this build rewrites. `ignoreUnknownKeys` matches the
* FAIL_ON_UNKNOWN_PROPERTIES=false it replaces, so a file written by a newer
* build still loads here.
*/
private val json =
Json {
ignoreUnknownKeys = true
encodeDefaults = true
}
private val mutex = Mutex() private val mutex = Mutex()
private var loaded = false private var loaded = false
@@ -133,7 +142,7 @@ class ResourceUsageStore(
data = data =
try { try {
if (storageFile.exists() && storageFile.length() > 0) { if (storageFile.exists() && storageFile.length() > 0) {
mapper.readValue<UsageFile>(storageFile) json.decodeFromString<UsageFile>(storageFile.readText())
} else { } else {
UsageFile() UsageFile()
} }
@@ -148,7 +157,7 @@ class ResourceUsageStore(
storageFile.parentFile?.mkdirs() storageFile.parentFile?.mkdirs()
val tmp = File(storageFile.parentFile, storageFile.name + ".tmp") val tmp = File(storageFile.parentFile, storageFile.name + ".tmp")
try { try {
mapper.writeValue(tmp, data) tmp.writeText(json.encodeToString(data))
if (!tmp.renameTo(storageFile)) { if (!tmp.renameTo(storageFile)) {
if (!storageFile.delete() || !tmp.renameTo(storageFile)) { if (!storageFile.delete() || !tmp.renameTo(storageFile)) {
Log.e(TAG) { "Failed to rename $tmp to $storageFile" } Log.e(TAG) { "Failed to rename $tmp to $storageFile" }
@@ -0,0 +1,135 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.pow
import com.vitorpamplona.amethyst.commons.service.pow.PersistedPoWJob
import com.vitorpamplona.amethyst.service.resourceusage.ResourceUsageStore
import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* The PoW-queue and resource-usage files survived the move off Jackson.
*
* Both hold state an update must not lose: `pending_pow_jobs.json` is posts the user
* already hit send on that are still mining, and the usage file backs the
* high-consumption alert. Each literal below is the exact string the Jackson build
* wrote for the object beside it, captured before the switch.
*/
class PowAndUsageFileFormatTest {
private val json =
Json {
ignoreUnknownKeys = true
encodeDefaults = true
}
private val powSample =
PowJobsFile(
version = 1,
jobs =
listOf(
PersistedPoWJob(
id = "j1",
accountPubkey = "pk1",
kind = 1,
difficulty = 21,
templateJson = """{"t":1}""",
replayType = "broadcast",
relayUrls = listOf("wss://a"),
extraEventsJson = emptyList(),
publishAtSec = null,
recipientPubkeys = listOf("r1"),
wrapExpirationDelta = null,
createdAtSec = 999L,
),
),
)
private val usageSample =
ResourceUsageStore.UsageFile(
version = 1,
days = mapOf("20260919" to mapOf("relay.a" to 12L, "relay.b" to 34L)),
lastAlertAtSec = 555L,
alertsOptOut = true,
)
@Test
fun powJobsWriteTheBytesJacksonWrote() {
assertEquals(POW_JACKSON_OUTPUT, json.encodeToString(powSample))
}
@Test
fun powJobsFromTheJacksonBuildStillLoad() {
val loaded = json.decodeFromString<PowJobsFile>(POW_JACKSON_OUTPUT)
assertEquals(1, loaded.jobs.size)
val job = loaded.jobs.first()
assertEquals("j1", job.id)
assertEquals(21, job.difficulty)
assertEquals("broadcast", job.replayType)
assertEquals(listOf("r1"), job.recipientPubkeys)
assertEquals(999L, job.createdAtSec)
assertEquals(null, job.publishAtSec)
}
@Test
fun usageWritesTheBytesJacksonWrote() {
assertEquals(USAGE_JACKSON_OUTPUT, json.encodeToString(usageSample))
}
@Test
fun usageFromTheJacksonBuildStillLoads() {
val loaded = json.decodeFromString<ResourceUsageStore.UsageFile>(USAGE_JACKSON_OUTPUT)
assertEquals(mapOf("relay.a" to 12L, "relay.b" to 34L), loaded.days["20260919"])
assertEquals(555L, loaded.lastAlertAtSec)
assertTrue(loaded.alertsOptOut)
}
@Test
fun bothReadersTolerateKeysFromANewerBuild() {
val pow = POW_JACKSON_OUTPUT.replace("""{"version":1""", """{"version":1,"futureKey":[1]""")
val usage = USAGE_JACKSON_OUTPUT.replace("""{"version":1""", """{"version":1,"futureKey":{"a":1}""")
assertEquals(
"j1",
json
.decodeFromString<PowJobsFile>(pow)
.jobs
.first()
.id,
)
assertEquals(555L, json.decodeFromString<ResourceUsageStore.UsageFile>(usage).lastAlertAtSec)
}
companion object {
private const val POW_JACKSON_OUTPUT =
"""{"version":1,"jobs":[{"id":"j1","accountPubkey":"pk1","kind":1,"difficulty":21,""" +
""""templateJson":"{\"t\":1}","replayType":"broadcast","relayUrls":["wss://a"],""" +
""""extraEventsJson":[],"publishAtSec":null,"recipientPubkeys":["r1"],""" +
""""wrapExpirationDelta":null,"createdAtSec":999}]}"""
private const val USAGE_JACKSON_OUTPUT =
"""{"version":1,"days":{"20260919":{"relay.a":12,"relay.b":34}},""" +
""""lastAlertAtSec":555,"alertsOptOut":true}"""
}
}
@@ -20,6 +20,9 @@
*/ */
package com.vitorpamplona.amethyst.commons.scheduledposts package com.vitorpamplona.amethyst.commons.scheduledposts
import kotlinx.serialization.Serializable
@Serializable
enum class ScheduledPostStatus { enum class ScheduledPostStatus {
PENDING, PENDING,
PUBLISHING, PUBLISHING,
@@ -28,6 +31,7 @@ enum class ScheduledPostStatus {
CANCELLED, CANCELLED,
} }
@Serializable
data class ScheduledPost( data class ScheduledPost(
val id: String, val id: String,
val accountPubkey: String, val accountPubkey: String,
@@ -44,6 +48,7 @@ data class ScheduledPost(
val terminatedAtSec: Long? = null, val terminatedAtSec: Long? = null,
) )
@Serializable
data class ScheduledPostFile( data class ScheduledPostFile(
val version: Int = 1, val version: Int = 1,
val posts: List<ScheduledPost> = emptyList(), val posts: List<ScheduledPost> = emptyList(),
@@ -20,6 +20,8 @@
*/ */
package com.vitorpamplona.amethyst.commons.service.pow package com.vitorpamplona.amethyst.commons.service.pow
import kotlinx.serialization.Serializable
/** /**
* Durable record of a template mining job so a post survives process death: * Durable record of a template mining job so a post survives process death:
* everything needed to re-mine and re-send with no lambda captured — the * everything needed to re-mine and re-send with no lambda captured — the
@@ -31,6 +33,7 @@ package com.vitorpamplona.amethyst.commons.service.pow
* outbox relays, [REPLAY_RELAYS] publishes to [relayUrls], [REPLAY_SCHEDULE] * outbox relays, [REPLAY_RELAYS] publishes to [relayUrls], [REPLAY_SCHEDULE]
* signs and parks the event in the scheduled-post store for [publishAtSec]. * signs and parks the event in the scheduled-post store for [publishAtSec].
*/ */
@Serializable
data class PersistedPoWJob( data class PersistedPoWJob(
val id: String, val id: String,
val accountPubkey: String, val accountPubkey: String,
@@ -20,15 +20,13 @@
*/ */
package com.vitorpamplona.amethyst.commons.scheduledposts package com.vitorpamplona.amethyst.commons.scheduledposts
import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue
import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import kotlinx.serialization.json.Json
import java.io.File import java.io.File
import java.nio.file.Files import java.nio.file.Files
import java.nio.file.attribute.PosixFilePermission import java.nio.file.attribute.PosixFilePermission
@@ -37,9 +35,18 @@ class ScheduledPostStore(
private val storageFile: File, private val storageFile: File,
private val nowSec: () -> Long = { System.currentTimeMillis() / 1000 }, private val nowSec: () -> Long = { System.currentTimeMillis() / 1000 },
) { ) {
private val mapper = /**
jacksonObjectMapper() * `encodeDefaults = true` so this writes the same bytes Jackson did — kotlinx
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) * omits a value equal to its default, which would silently drop `"version":1`
* from every file this build rewrites. `ignoreUnknownKeys` matches the
* FAIL_ON_UNKNOWN_PROPERTIES=false it replaces, so a file written by a newer
* build still loads here.
*/
private val json =
Json {
ignoreUnknownKeys = true
encodeDefaults = true
}
private val mutex = Mutex() private val mutex = Mutex()
private var loaded = false private var loaded = false
@@ -330,7 +337,7 @@ class ScheduledPostStore(
val fromDisk = val fromDisk =
try { try {
if (storageFile.exists() && storageFile.length() > 0) { if (storageFile.exists() && storageFile.length() > 0) {
mapper.readValue<ScheduledPostFile>(storageFile).posts.toMutableList() json.decodeFromString<ScheduledPostFile>(storageFile.readText()).posts.toMutableList()
} else { } else {
// File vanished — treat as no external state; keep current in-memory. // File vanished — treat as no external state; keep current in-memory.
return return
@@ -348,7 +355,7 @@ class ScheduledPostStore(
posts = posts =
try { try {
if (storageFile.exists() && storageFile.length() > 0) { if (storageFile.exists() && storageFile.length() > 0) {
mapper.readValue<ScheduledPostFile>(storageFile).posts.toMutableList() json.decodeFromString<ScheduledPostFile>(storageFile.readText()).posts.toMutableList()
} else { } else {
mutableListOf() mutableListOf()
} }
@@ -408,7 +415,7 @@ class ScheduledPostStore(
storageFile.parentFile?.mkdirs() storageFile.parentFile?.mkdirs()
val tmp = File(storageFile.parentFile, storageFile.name + ".tmp") val tmp = File(storageFile.parentFile, storageFile.name + ".tmp")
try { try {
mapper.writeValue(tmp, ScheduledPostFile(version = 1, posts = snapshot)) tmp.writeText(json.encodeToString(ScheduledPostFile(version = 1, posts = snapshot)))
// Restrict to owner-only BEFORE the rename so the store is never briefly // Restrict to owner-only BEFORE the rename so the store is never briefly
// world-readable. It holds pre-signed events + the account's pubkey, which // world-readable. It holds pre-signed events + the account's pubkey, which
// must not leak to other local users on a shared machine. // must not leak to other local users on a shared machine.
@@ -0,0 +1,117 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.scheduledposts
import kotlinx.serialization.json.Json
import kotlin.test.Test
import kotlin.test.assertEquals
/**
* The scheduled-post file survived the move off Jackson.
*
* This file is a user's queued posts: pre-signed events waiting for their publish
* time. If the format shifted when the serializer changed, an existing install
* would silently drop everything it had queued on the first launch after the
* update — no crash, no error, just an empty queue.
*
* [JACKSON_OUTPUT] is the exact string the Jackson build wrote for [sample],
* captured from it before the switch. The assertions are that the kotlinx reader
* loads it and the kotlinx writer reproduces it byte for byte — the second half
* matters because it is what a downgrade, or an older `amy` build sharing the same
* file, has to keep reading.
*/
class ScheduledPostFileFormatTest {
private val json =
Json {
ignoreUnknownKeys = true
encodeDefaults = true
}
private val sample =
ScheduledPostFile(
version = 1,
posts =
listOf(
ScheduledPost(
id = "id1",
accountPubkey = "pk1",
signedEventJson = """{"k":1}""",
relayUrls = listOf("wss://a", "wss://b"),
extraEventsJson = listOf("e1"),
publishAtSec = 111L,
createdAtSec = 222L,
status = ScheduledPostStatus.PENDING,
lastAttemptAtSec = null,
attemptCount = 0,
lastError = null,
terminatedAtSec = 333L,
),
),
)
@Test
fun writesTheBytesJacksonWrote() {
assertEquals(JACKSON_OUTPUT, json.encodeToString(sample))
}
@Test
fun readsAFileWrittenByTheJacksonBuild() {
val loaded = json.decodeFromString<ScheduledPostFile>(JACKSON_OUTPUT)
assertEquals(1, loaded.version)
assertEquals(1, loaded.posts.size)
val post = loaded.posts.first()
assertEquals("id1", post.id)
assertEquals("""{"k":1}""", post.signedEventJson)
assertEquals(listOf("wss://a", "wss://b"), post.relayUrls)
assertEquals(ScheduledPostStatus.PENDING, post.status)
assertEquals(333L, post.terminatedAtSec)
}
@Test
fun aFileFromANewerBuildStillLoads() {
// Forward compatibility: the reader must not choke on a key it does not know,
// which is what FAIL_ON_UNKNOWN_PROPERTIES=false used to buy.
val withExtras =
JACKSON_OUTPUT
.replace("""{"version":1""", """{"version":1,"somethingNew":{"a":1}""")
.replace(""""id":"id1"""", """"id":"id1","perPostFutureField":true""")
val loaded = json.decodeFromString<ScheduledPostFile>(withExtras)
assertEquals("id1", loaded.posts.first().id)
}
@Test
fun versionIsNotDroppedJustBecauseItEqualsItsDefault() {
// kotlinx omits a value equal to its default unless encodeDefaults is set.
// Losing "version" would make the file unreadable to anything that checks it.
assertEquals(true, json.encodeToString(sample).startsWith("""{"version":1,"""))
}
companion object {
private const val JACKSON_OUTPUT =
"""{"version":1,"posts":[{"id":"id1","accountPubkey":"pk1","signedEventJson":"{\"k\":1}",""" +
""""relayUrls":["wss://a","wss://b"],"extraEventsJson":["e1"],"publishAtSec":111,""" +
""""createdAtSec":222,"status":"PENDING","lastAttemptAtSec":null,"attemptCount":0,""" +
""""lastError":null,"terminatedAtSec":333}]}"""
}
}
@@ -57,6 +57,7 @@ import com.vitorpamplona.quartz.nip59Giftwrap.rumors.Rumor
import com.vitorpamplona.quartz.nip59Giftwrap.rumors.jackson.RumorDeserializer import com.vitorpamplona.quartz.nip59Giftwrap.rumors.jackson.RumorDeserializer
import com.vitorpamplona.quartz.nip59Giftwrap.rumors.jackson.RumorSerializer import com.vitorpamplona.quartz.nip59Giftwrap.rumors.jackson.RumorSerializer
import java.io.InputStream import java.io.InputStream
import kotlin.reflect.KClass
class JacksonMapper { class JacksonMapper {
companion object { companion object {
@@ -125,9 +126,56 @@ class JacksonMapper {
fun fromJsonToEventList(json: String): List<Event> = mapper.readValue(json, eventListTypeInstance) fun fromJsonToEventList(json: String): List<Event> = mapper.readValue(json, eventListTypeInstance)
inline fun <reified T : OptimizedSerializable> fromJsonTo(json: String): T = mapper.readValue<T>(json) /**
* The types this mapper has a registered deserializer for.
*
* [fromJsonTo] is generic, so nothing in the type system stops a new
* [OptimizedSerializable] being passed to it. Jackson would answer by binding
* that type REFLECTIVELY off its Kotlin constructor parameter names — which
* works in debug, and in a release build writes obfuscated one-letter JSON keys
* onto the wire. That is how NIP-47 and CLINK ended up needing a package keep
* rule each, and the symptom only ever shows up in production.
*
* So the fallback is closed: an unregistered type fails here, loudly, on the
* first call. Register a StdSerializer/StdDeserializer pair below, or route the
* type at kotlinx in OptimizedJsonMapper the way NIP-47 and CLINK are.
*/
@PublishedApi
internal val registered: Set<KClass<*>> =
setOf(
Event::class,
Filter::class,
Message::class,
Command::class,
TagArray::class,
EventTemplate::class,
Rumor::class,
BunkerMessage::class,
BunkerRequest::class,
BunkerResponse::class,
)
inline fun <reified T : OptimizedSerializable> fromJsonTo(json: InputStream): T = mapper.readValue<T>(json) @PublishedApi
internal fun checkRegistered(type: KClass<*>) {
if (type !in registered) {
throw IllegalArgumentException(
"No Jackson deserializer is registered for $type, so Jackson would bind it " +
"reflectively and emit obfuscated field names in a release build. Register " +
"one in JacksonMapper, or route the type at KotlinSerializationMapper in " +
"OptimizedJsonMapper.",
)
}
}
inline fun <reified T : OptimizedSerializable> fromJsonTo(json: String): T {
checkRegistered(T::class)
return mapper.readValue<T>(json)
}
inline fun <reified T : OptimizedSerializable> fromJsonTo(json: InputStream): T {
checkRegistered(T::class)
return mapper.readValue<T>(json)
}
fun toJson(event: Event): String = EventManualSerializer.toJson(event.id, event.pubKey, event.createdAt, event.kind, event.tags, event.content, event.sig) fun toJson(event: Event): String = EventManualSerializer.toJson(event.id, event.pubKey, event.createdAt, event.kind, event.tags, event.content, event.sig)
+6 -7
View File
@@ -78,13 +78,6 @@ class com.vitorpamplona.amethyst.service.calendar.CalendarReminderWorker
# any more — OptimizedJsonMapper routes them at kotlinx serializers that write # any more — OptimizedJsonMapper routes them at kotlinx serializers that write
# every field name as a string literal — so there is no rule to verify. # every field name as a string literal — so there is no rule to verify.
# --- JSON the app writes to disk and reads back after an update --------------
# Field names are the file format; renaming them orphans every existing file.
fields com.vitorpamplona.amethyst.commons.scheduledposts.ScheduledPost
fields com.vitorpamplona.amethyst.commons.scheduledposts.ScheduledPostFile
fields com.vitorpamplona.amethyst.commons.service.pow.PersistedPoWJob
fields com.vitorpamplona.amethyst.service.pow.PowJobsFile
fields com.vitorpamplona.amethyst.service.resourceusage.ResourceUsageStore$UsageFile
# --- Enum constants persisted as strings ------------------------------------- # --- Enum constants persisted as strings -------------------------------------
# The preference stores write `enum.name` into DataStore and read it back with # The preference stores write `enum.name` into DataStore and read it back with
@@ -103,4 +96,10 @@ enum com.vitorpamplona.amethyst.model.ProfileGalleryType CLASSIC
enum com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinBackend enum com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinBackend
enum com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallType enum com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallType
enum com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.ChannelExpand enum com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.ChannelExpand
# Still listed after ScheduledPost moved to kotlinx: this one is also the on-disk
# format of the scheduled-post file, and a plain @Serializable enum is not obviously
# immune — kotlinx builds its descriptor from the entries, and it has not been proven
# here that those names are literals rather than Enum.name read at runtime. Keeping
# the constants costs nothing (the blanket enum rule already provides them) and the
# failure mode — every queued post's status unreadable — is not worth guessing at.
enum com.vitorpamplona.amethyst.commons.scheduledposts.ScheduledPostStatus PENDING PUBLISHING SENT FAILED CANCELLED enum com.vitorpamplona.amethyst.commons.scheduledposts.ScheduledPostStatus PENDING PUBLISHING SENT FAILED CANCELLED