Files
TheTree_mock/scripts/core/save_migration_registry.gd

143 lines
5.1 KiB
GDScript

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,
}