feat: add version migration system for schema changes (Closes #104)

Implement a migration framework that automatically upgrades serialized
character data from older schema versions to the current version on load.
Includes MigrationRegistry for chaining migrations, a concrete v0.1->v0.2
migration, centralized SchemaVersion constant, and 17 unit tests covering
migration paths, unknown fields, missing defaults, and error handling.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
claude-bot
2026-04-04 18:52:10 +00:00
parent a9d7f961ca
commit 5f0ccd496d
11 changed files with 528 additions and 7 deletions
@@ -1,15 +1,21 @@
package org.shahondin1624.lib.functions
import kotlinx.serialization.encodeToString
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.jsonObject
import org.shahondin1624.model.charactermodel.ShadowrunCharacter
import org.shahondin1624.model.migration.MigrationRegistry
/**
* Platform-agnostic serialization helpers for ShadowrunCharacter.
*
* File I/O (reading/writing from disk) should be implemented per-platform
* (androidMain, jvmMain, iosMain, webMain). Here we only convert to/from JSON strings.
*
* On deserialization, the version migration system is invoked automatically:
* if the JSON has an older schema version, all necessary migrations are applied
* before the data is decoded into [ShadowrunCharacter].
*/
object DataLoader {
// Shared Json configuration used across the app
@@ -24,7 +30,17 @@ object DataLoader {
json.encodeToString(character)
fun deserialize(jsonString: String): ShadowrunCharacter {
val character: ShadowrunCharacter = json.decodeFromString(jsonString)
// Step 1: Parse to generic JSON tree
val jsonElement = json.parseToJsonElement(jsonString).jsonObject
// Step 2: Apply version migrations if needed
val migrated: JsonObject = MigrationRegistry.migrateToCurrentVersion(jsonElement)
// Step 3: Decode the (possibly migrated) JSON to ShadowrunCharacter
val character: ShadowrunCharacter = json.decodeFromJsonElement(
ShadowrunCharacter.serializer(), migrated
)
// After deserialization, @Transient attributes in DamageMonitor defaults to EMPTY_ATTRIBUTES.
// Reconstitute with the character's actual attributes.
return character.copy(
@@ -3,6 +3,7 @@ package org.shahondin1624.model.attributes
import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
import org.shahondin1624.model.Versionable
import org.shahondin1624.model.migration.SchemaVersion
import org.shahondin1624.model.modifier.AttributeModifier
import org.shahondin1624.model.modifier.ModifierCache
@@ -19,7 +20,7 @@ data class Attributes(
val edge: Int,
@Transient
private val cache: ModifierCache = ModifierCache(),
override val version: String = "v0.1"
override val version: String = SchemaVersion.CURRENT
): Versionable {
private fun applyModifiers(modifiers: List<AttributeModifier>): Attributes {
return cache.applyModifiers(modifiers, this)
@@ -2,6 +2,7 @@ package org.shahondin1624.model.characterdata
import kotlinx.serialization.Serializable
import org.shahondin1624.model.Versionable
import org.shahondin1624.model.migration.SchemaVersion
@Serializable
data class CharacterData(
@@ -17,5 +18,5 @@ data class CharacterData(
val publicAwareness: Int,
val totalKarma: Int,
val currentKarma: Int,
override val version: String = "v0.1"
override val version: String = SchemaVersion.CURRENT
): Versionable
@@ -6,6 +6,7 @@ import org.shahondin1624.model.Versionable
import org.shahondin1624.model.attributes.Attribute
import org.shahondin1624.model.attributes.AttributeType
import org.shahondin1624.model.attributes.Attributes
import org.shahondin1624.model.migration.SchemaVersion
import org.shahondin1624.model.modifier.AttributeModifier
import org.shahondin1624.model.modifier.ModifierCache
@@ -34,7 +35,7 @@ data class DamageMonitor(
private val overflowCurrent: Int = 0,
@Transient
private val cache: ModifierCache = ModifierCache(),
override val version: String = "v0.1"
override val version: String = SchemaVersion.CURRENT
): Versionable {
init {
require(stunCurrent >= 0) { "Stun damage cannot be negative" }
@@ -5,6 +5,7 @@ import org.shahondin1624.model.Versionable
import org.shahondin1624.model.attributes.Attributes
import org.shahondin1624.model.characterdata.CharacterData
import org.shahondin1624.model.characterdata.Lifestyle
import org.shahondin1624.model.migration.SchemaVersion
import org.shahondin1624.model.talents.Talents
@Serializable
@@ -15,5 +16,5 @@ data class ShadowrunCharacter(
val damageMonitor: DamageMonitor,
val notes: String = "",
val lifestyles: List<Lifestyle> = emptyList(),
override val version: String = "v0.1"
override val version: String = SchemaVersion.CURRENT
): Versionable
@@ -0,0 +1,77 @@
package org.shahondin1624.model.migration
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.jsonPrimitive
/**
* Registry of all known version migrations. Applies migrations sequentially
* to bring old schema versions up to [SchemaVersion.CURRENT].
*/
object MigrationRegistry {
private val migrations: List<VersionMigration> = listOf(
MigrationV01ToV02()
)
/**
* Extract the version string from a root-level JSON object.
* Returns [SchemaVersion.CURRENT] if no version field is present
* (treats unversioned data as current to avoid breaking existing data).
*/
fun extractVersion(json: JsonObject): String {
return json["version"]?.jsonPrimitive?.content ?: SchemaVersion.CURRENT
}
/**
* Check if the given version needs migration to reach [SchemaVersion.CURRENT].
*/
fun needsMigration(version: String): Boolean {
return version != SchemaVersion.CURRENT
}
/**
* Find the ordered chain of migrations needed to go from [fromVersion]
* to [SchemaVersion.CURRENT].
*
* @throws IllegalStateException if no migration path exists
*/
fun findMigrationChain(fromVersion: String): List<VersionMigration> {
if (!needsMigration(fromVersion)) return emptyList()
val chain = mutableListOf<VersionMigration>()
var currentVersion = fromVersion
while (currentVersion != SchemaVersion.CURRENT) {
val migration = migrations.find { it.fromVersion == currentVersion }
?: throw IllegalStateException(
"No migration path from version '$currentVersion' to '${SchemaVersion.CURRENT}'. " +
"Known migrations: ${migrations.map { "${it.fromVersion} -> ${it.toVersion}" }}"
)
chain.add(migration)
currentVersion = migration.toVersion
}
return chain
}
/**
* Apply all necessary migrations to bring a JSON object from its current
* version to [SchemaVersion.CURRENT].
*
* @param json The root JSON object (must contain a "version" field)
* @return The migrated JSON object at the current schema version,
* or the original object if no migration is needed
* @throws IllegalStateException if no migration path exists for the detected version
*/
fun migrateToCurrentVersion(json: JsonObject): JsonObject {
val version = extractVersion(json)
if (!needsMigration(version)) return json
val chain = findMigrationChain(version)
var current = json
for (migration in chain) {
current = migration.migrate(current)
}
return current
}
}
@@ -0,0 +1,55 @@
package org.shahondin1624.model.migration
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
/**
* Migration from schema v0.1 to v0.2.
*
* Changes in v0.2:
* - Ensures "notes" field exists on ShadowrunCharacter (default: "")
* - Ensures "lifestyles" field exists on ShadowrunCharacter (default: [])
* - Updates all version fields from "v0.1" to "v0.2"
*
* This migration serves as both a real migration for backward compatibility
* with early saves and a template for future migrations.
*/
class MigrationV01ToV02 : VersionMigration {
override val fromVersion: String = SchemaVersion.V0_1
override val toVersion: String = SchemaVersion.V0_2
override fun migrate(json: JsonObject): JsonObject {
val mutable = json.toMutableMap()
// Ensure top-level fields added after v0.1 exist with defaults
if ("notes" !in mutable) {
mutable["notes"] = JsonPrimitive("")
}
if ("lifestyles" !in mutable) {
mutable["lifestyles"] = JsonArray(emptyList())
}
// Update version fields at all levels
mutable["version"] = JsonPrimitive(toVersion)
mutable.updateNestedVersion("characterData")
mutable.updateNestedVersion("attributes")
mutable.updateNestedVersion("talents")
mutable.updateNestedVersion("damageMonitor")
return JsonObject(mutable)
}
/**
* Update the "version" field inside a nested JSON object.
*/
private fun MutableMap<String, JsonElement>.updateNestedVersion(key: String) {
val nested = this[key]
if (nested is JsonObject) {
val nestedMutable = nested.toMutableMap()
nestedMutable["version"] = JsonPrimitive(toVersion)
this[key] = JsonObject(nestedMutable)
}
}
}
@@ -0,0 +1,16 @@
package org.shahondin1624.model.migration
/**
* Central schema version constant. All model classes should reference this
* as their default version value.
*/
object SchemaVersion {
const val CURRENT: String = "v0.2"
/**
* Initial version used before the migration system was introduced.
*/
const val V0_1: String = "v0.1"
const val V0_2: String = "v0.2"
}
@@ -0,0 +1,21 @@
package org.shahondin1624.model.migration
import kotlinx.serialization.json.JsonObject
/**
* Interface for a single version migration step.
* Each implementation transforms JSON from one schema version to the next.
*/
interface VersionMigration {
/** The version this migration upgrades from. */
val fromVersion: String
/** The version this migration upgrades to. */
val toVersion: String
/**
* Transform the JSON object from [fromVersion] schema to [toVersion] schema.
* The returned JsonObject should have all version fields updated to [toVersion].
*/
fun migrate(json: JsonObject): JsonObject
}
@@ -2,11 +2,12 @@ package org.shahondin1624.model.talents
import kotlinx.serialization.Serializable
import org.shahondin1624.model.Versionable
import org.shahondin1624.model.migration.SchemaVersion
@Serializable
data class Talents(
val talents: List<TalentDefinition> = createAllProvidedTalents(),
override val version: String = "v0.1"
override val version: String = SchemaVersion.CURRENT
) : Versionable {
/**
* Returns a copy with the specified talent's value updated.
@@ -0,0 +1,331 @@
package org.shahondin1624
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import org.shahondin1624.lib.functions.DataLoader
import org.shahondin1624.model.attributes.Attribute
import org.shahondin1624.model.attributes.AttributeType
import org.shahondin1624.model.attributes.Attributes
import org.shahondin1624.model.characterdata.CharacterData
import org.shahondin1624.model.characterdata.Metatype
import org.shahondin1624.model.charactermodel.DamageMonitor
import org.shahondin1624.model.charactermodel.ShadowrunCharacter
import org.shahondin1624.model.migration.MigrationRegistry
import org.shahondin1624.model.migration.MigrationV01ToV02
import org.shahondin1624.model.migration.SchemaVersion
import org.shahondin1624.model.talents.Talent
import org.shahondin1624.model.talents.Talents
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class VersionMigrationTest {
private val json = Json {
prettyPrint = true
ignoreUnknownKeys = true
encodeDefaults = true
}
// -- Helper to build a minimal v0.1 character JSON string --
private fun buildV01CharacterJson(
includeNotes: Boolean = false,
includeLifestyles: Boolean = false,
extraFields: Map<String, String> = emptyMap()
): String {
val notesField = if (includeNotes) "\"notes\": \"Some notes\"," else ""
val lifestylesField = if (includeLifestyles) "\"lifestyles\": []," else ""
val extra = extraFields.entries.joinToString(",") { "\"${it.key}\": ${it.value}" }
val extraComma = if (extra.isNotEmpty()) ",$extra" else ""
return """
{
"characterData": {
"concept": "Test",
"nuyen": 1000,
"essence": 6.0,
"name": "Test Character",
"metatype": "Human",
"age": 25,
"gender": "male",
"streetCred": 0,
"notoriety": 0,
"publicAwareness": 0,
"totalKarma": 0,
"currentKarma": 0,
"version": "v0.1"
},
"attributes": {
"body": { "type": "Body", "value": 3 },
"agility": { "type": "Agility", "value": 3 },
"reaction": { "type": "Reaction", "value": 3 },
"strength": { "type": "Strength", "value": 3 },
"willpower": { "type": "Willpower", "value": 3 },
"logic": { "type": "Logic", "value": 3 },
"intuition": { "type": "Intuition", "value": 3 },
"charisma": { "type": "Charisma", "value": 3 },
"edge": 2,
"version": "v0.1"
},
"talents": {
"talents": [
{
"type": "provided",
"name": "Firearms",
"attribute": "Agility",
"value": 3,
"custom": false
}
],
"version": "v0.1"
},
"damageMonitor": {
"stunCurrent": 0,
"physicalCurrent": 0,
"overflowCurrent": 0,
"version": "v0.1"
},
$notesField
$lifestylesField
"version": "v0.1"
$extraComma
}
""".trimIndent()
}
// ---- MigrationRegistry Tests ----
@Test
fun extractVersionReturnsVersionFromJson() {
val jsonObj = json.parseToJsonElement("""{"version": "v0.1", "name": "test"}""").jsonObject
assertEquals("v0.1", MigrationRegistry.extractVersion(jsonObj))
}
@Test
fun extractVersionReturnsCurrentWhenNoVersionField() {
val jsonObj = json.parseToJsonElement("""{"name": "test"}""").jsonObject
assertEquals(SchemaVersion.CURRENT, MigrationRegistry.extractVersion(jsonObj))
}
@Test
fun needsMigrationReturnsTrueForOldVersion() {
assertTrue(MigrationRegistry.needsMigration("v0.1"))
}
@Test
fun needsMigrationReturnsFalseForCurrentVersion() {
assertFalse(MigrationRegistry.needsMigration(SchemaVersion.CURRENT))
}
@Test
fun findMigrationChainReturnsEmptyForCurrentVersion() {
val chain = MigrationRegistry.findMigrationChain(SchemaVersion.CURRENT)
assertTrue(chain.isEmpty())
}
@Test
fun findMigrationChainReturnsPathFromV01() {
val chain = MigrationRegistry.findMigrationChain("v0.1")
assertEquals(1, chain.size)
assertEquals("v0.1", chain[0].fromVersion)
assertEquals("v0.2", chain[0].toVersion)
}
@Test
fun findMigrationChainThrowsForUnknownVersion() {
assertFailsWith<IllegalStateException> {
MigrationRegistry.findMigrationChain("v99.99")
}
}
// ---- MigrationV01ToV02 Tests ----
@Test
fun migrationV01ToV02UpdatesVersionFields() {
val input = json.parseToJsonElement(buildV01CharacterJson()).jsonObject
val migration = MigrationV01ToV02()
val result = migration.migrate(input)
assertEquals("v0.2", result["version"]?.jsonPrimitive?.content)
assertEquals("v0.2", result["characterData"]?.jsonObject?.get("version")?.jsonPrimitive?.content)
assertEquals("v0.2", result["attributes"]?.jsonObject?.get("version")?.jsonPrimitive?.content)
assertEquals("v0.2", result["talents"]?.jsonObject?.get("version")?.jsonPrimitive?.content)
assertEquals("v0.2", result["damageMonitor"]?.jsonObject?.get("version")?.jsonPrimitive?.content)
}
@Test
fun migrationV01ToV02AddsNotesFieldWhenMissing() {
val input = json.parseToJsonElement(buildV01CharacterJson(includeNotes = false)).jsonObject
val migration = MigrationV01ToV02()
val result = migration.migrate(input)
assertEquals("", result["notes"]?.jsonPrimitive?.content)
}
@Test
fun migrationV01ToV02PreservesExistingNotes() {
val input = json.parseToJsonElement(buildV01CharacterJson(includeNotes = true)).jsonObject
val migration = MigrationV01ToV02()
val result = migration.migrate(input)
assertEquals("Some notes", result["notes"]?.jsonPrimitive?.content)
}
@Test
fun migrationV01ToV02AddsLifestylesFieldWhenMissing() {
val input = json.parseToJsonElement(buildV01CharacterJson(includeLifestyles = false)).jsonObject
val migration = MigrationV01ToV02()
val result = migration.migrate(input)
assertTrue(result.containsKey("lifestyles"))
}
@Test
fun migrationV01ToV02PreservesExistingLifestyles() {
val input = json.parseToJsonElement(buildV01CharacterJson(includeLifestyles = true)).jsonObject
val migration = MigrationV01ToV02()
val result = migration.migrate(input)
assertTrue(result.containsKey("lifestyles"))
}
// ---- DataLoader Integration Tests ----
@Test
fun deserializeV01JsonMigratesToCurrentVersion() {
val v01Json = buildV01CharacterJson()
val character = DataLoader.deserialize(v01Json)
assertEquals(SchemaVersion.CURRENT, character.version)
assertEquals(SchemaVersion.CURRENT, character.characterData.version)
assertEquals(SchemaVersion.CURRENT, character.attributes.version)
assertEquals(SchemaVersion.CURRENT, character.talents.version)
assertEquals("Test Character", character.characterData.name)
assertEquals(Metatype.Human, character.characterData.metatype)
}
@Test
fun deserializeV01JsonWithoutNotesDefaultsToEmpty() {
val v01Json = buildV01CharacterJson(includeNotes = false)
val character = DataLoader.deserialize(v01Json)
assertEquals("", character.notes)
}
@Test
fun deserializeV01JsonWithoutLifestylesDefaultsToEmpty() {
val v01Json = buildV01CharacterJson(includeLifestyles = false)
val character = DataLoader.deserialize(v01Json)
assertEquals(emptyList(), character.lifestyles)
}
@Test
fun deserializeCurrentVersionJsonSkipsMigration() {
// Create a character, serialize it (will be at current version), then deserialize
val attributes = Attributes(
body = Attribute(AttributeType.Body, 3),
agility = Attribute(AttributeType.Agility, 3),
reaction = Attribute(AttributeType.Reaction, 3),
strength = Attribute(AttributeType.Strength, 3),
willpower = Attribute(AttributeType.Willpower, 3),
logic = Attribute(AttributeType.Logic, 3),
intuition = Attribute(AttributeType.Intuition, 3),
charisma = Attribute(AttributeType.Charisma, 3),
edge = 2
)
val original = ShadowrunCharacter(
characterData = CharacterData(
concept = "Test", nuyen = 1000, essence = 6.0f, name = "Test",
metatype = Metatype.Human, age = 25, gender = "male",
streetCred = 0, notoriety = 0, publicAwareness = 0,
totalKarma = 0, currentKarma = 0
),
attributes = attributes,
talents = Talents(
talents = listOf(
Talent(name = "Test", attribute = AttributeType.Body, value = 1, custom = false)
)
),
damageMonitor = DamageMonitor(attributes = attributes),
notes = "Some notes"
)
val serialized = DataLoader.serialize(original)
val restored = DataLoader.deserialize(serialized)
assertEquals(SchemaVersion.CURRENT, restored.version)
assertEquals("Some notes", restored.notes)
assertEquals("Test", restored.characterData.name)
}
@Test
fun unknownFieldsInJsonAreIgnored() {
// Add an unknown field to the JSON - should not crash
val v01Json = buildV01CharacterJson(
extraFields = mapOf("unknownField" to "\"some value\"", "anotherUnknown" to "42")
)
val character = DataLoader.deserialize(v01Json)
// Should deserialize successfully, ignoring unknown fields
assertEquals("Test Character", character.characterData.name)
assertEquals(SchemaVersion.CURRENT, character.version)
}
@Test
fun missingOptionalFieldsUseSensibleDefaults() {
// v0.1 JSON without notes and lifestyles - should use defaults
val v01Json = buildV01CharacterJson(includeNotes = false, includeLifestyles = false)
val character = DataLoader.deserialize(v01Json)
assertEquals("", character.notes, "Missing notes should default to empty string")
assertEquals(emptyList(), character.lifestyles, "Missing lifestyles should default to empty list")
}
@Test
fun versionIsCheckedOnLoadAndMigrationApplied() {
// Verify that v0.1 data gets migrated to current
val v01Json = buildV01CharacterJson()
val character = DataLoader.deserialize(v01Json)
assertEquals(SchemaVersion.CURRENT, character.version)
// Verify that re-serializing produces current version
val reSerialized = DataLoader.serialize(character)
val reLoaded = DataLoader.deserialize(reSerialized)
assertEquals(SchemaVersion.CURRENT, reLoaded.version)
}
@Test
fun migrationChainAppliesSequentially() {
// With current setup there's one migration (v0.1 -> v0.2)
// Verify the full chain works through MigrationRegistry
val input = json.parseToJsonElement(buildV01CharacterJson()).jsonObject
val migrated = MigrationRegistry.migrateToCurrentVersion(input)
assertEquals(SchemaVersion.CURRENT, migrated["version"]?.jsonPrimitive?.content)
}
@Test
fun currentVersionJsonNeedNoMigration() {
val currentJson = JsonObject(mapOf("version" to JsonPrimitive(SchemaVersion.CURRENT)))
val result = MigrationRegistry.migrateToCurrentVersion(currentJson)
// Should return the same object since no migration is needed
assertEquals(SchemaVersion.CURRENT, result["version"]?.jsonPrimitive?.content)
}
@Test
fun invalidVersionThrowsMeaningfulError() {
val badJson = json.parseToJsonElement("""{"version": "v99.99"}""").jsonObject
val exception = assertFailsWith<IllegalStateException> {
MigrationRegistry.migrateToCurrentVersion(badJson)
}
assertTrue(exception.message!!.contains("v99.99"))
assertTrue(exception.message!!.contains("No migration path"))
}
}