class_name SaveMigrationRegistry extends RefCounted ## A deterministic, in-memory registry for adjacent save-format migrations. ## ## Every registered step migrates exactly N -> N + 1. The callback receives a ## deep copy of the current value plus the two version numbers and must return ## either the migrated Dictionary, or { ok = true, data = Dictionary }. A ## callback can reject a migration with { ok = false, code = ..., message = ... }. ## The caller-owned source Dictionary is never passed to migration code. var _steps: Dictionary = {} var _migration_ids: Dictionary = {} func register_step(from_version: int, migration_id: StringName, migrator: Callable) -> Dictionary: if from_version < 0: return _failure(&"INVALID_FROM_VERSION", "from_version must be non-negative") if migration_id.is_empty(): return _failure(&"INVALID_MIGRATION_ID", "migration_id must not be empty") if not migrator.is_valid(): return _failure(&"INVALID_MIGRATOR", str(migration_id)) if _steps.has(from_version): return _failure(&"DUPLICATE_MIGRATION_STEP", _required_step_id(from_version)) if _migration_ids.has(migration_id): return _failure(&"DUPLICATE_MIGRATION_ID", str(migration_id)) _steps[from_version] = { &"id": migration_id, &"migrator": migrator, } _migration_ids[migration_id] = from_version return { &"ok": true, &"code": &"OK", &"migration_id": migration_id, &"from_version": from_version, &"to_version": from_version + 1, } func has_step(from_version: int) -> bool: return _steps.has(from_version) func get_step_id(from_version: int) -> StringName: if not _steps.has(from_version): return StringName() return StringName(_steps[from_version][&"id"]) func get_registered_steps() -> Array[Dictionary]: var versions: Array = _steps.keys() versions.sort() var result: Array[Dictionary] = [] for version_value: Variant in versions: var from_version := int(version_value) result.append({ &"id": get_step_id(from_version), &"from_version": from_version, &"to_version": from_version + 1, }) return result func migrate(source: Dictionary, from_version: int, target_version: int) -> Dictionary: if from_version < 0 or target_version < 0 or target_version < from_version: return _failure(&"INVALID_VERSION_RANGE", "%d -> %d" % [from_version, target_version]) var source_guard := source.duplicate(true) var working := source.duplicate(true) var applied_ids: Array[StringName] = [] for version in range(from_version, target_version): if not _steps.has(version): return { &"ok": false, &"code": &"MIGRATION_STEP_MISSING", &"message": "missing adjacent migration %d -> %d" % [version, version + 1], &"failed_from_version": version, &"failed_to_version": version + 1, &"required_migration_id": _required_step_id(version), &"applied_migration_ids": applied_ids, } var step: Dictionary = _steps[version] var migration_id := StringName(step[&"id"]) var migrator: Callable = step[&"migrator"] # Each callback receives its own deep copy so even a failing mutator cannot # contaminate the last accepted intermediate value. var callback_value: Variant = migrator.call(working.duplicate(true), version, version + 1) var unpacked := _unpack_callback_result(callback_value) if not unpacked[&"ok"]: return { &"ok": false, &"code": StringName(unpacked.get(&"code", &"MIGRATION_STEP_FAILED")), &"message": str(unpacked.get(&"message", migration_id)), &"failed_migration_id": migration_id, &"failed_from_version": version, &"failed_to_version": version + 1, &"applied_migration_ids": applied_ids, } working = unpacked[&"data"].duplicate(true) applied_ids.append(migration_id) if source != source_guard: # This should be unreachable because callbacks only see deep copies. Keep # the guard explicit so purity regressions fail closed. return _failure(&"SOURCE_MUTATED", "migration source changed unexpectedly") return { &"ok": true, &"code": &"OK", &"data": working.duplicate(true), &"from_version": from_version, &"target_version": target_version, &"applied_migration_ids": applied_ids, } func _unpack_callback_result(value: Variant) -> Dictionary: if not value is Dictionary: return _failure(&"MIGRATION_INVALID_RESULT", "migration callback must return a Dictionary") var result: Dictionary = value if result.has(&"ok") or result.has("ok"): var result_ok := bool(result.get(&"ok", result.get("ok", false))) if not result_ok: return _failure( StringName(result.get(&"code", result.get("code", &"MIGRATION_STEP_FAILED"))), str(result.get(&"message", result.get("message", "migration rejected"))) ) var data_value: Variant = result.get(&"data", result.get("data", null)) if not data_value is Dictionary: return _failure(&"MIGRATION_INVALID_RESULT", "successful migration has no Dictionary data") return {&"ok": true, &"code": &"OK", &"data": data_value} return {&"ok": true, &"code": &"OK", &"data": result} func _required_step_id(from_version: int) -> StringName: return StringName("format_%d_to_%d" % [from_version, from_version + 1]) func _failure(code: StringName, message: String) -> Dictionary: return { &"ok": false, &"code": code, &"message": message, }