143 lines
5.1 KiB
GDScript
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,
|
|
}
|