feat(savedata): recover from rotating backups on hash mismatch

When primary savedata fails its SHA-256 integrity check, query
savedata_backups in recency order and return the first slot that
decompresses cleanly. Recovery is read-only — the next successful
Save() overwrites the primary with fresh data and a new hash,
self-healing the corruption transparently.

Closes #178
This commit is contained in:
Houmgaor
2026-03-19 19:28:30 +01:00
parent 6139e90968
commit 08e7de2c5e
6 changed files with 236 additions and 3 deletions

View File

@@ -237,6 +237,38 @@ func (r *CharacterRepository) UpdateGCPAndPact(charID uint32, gcp uint32, pactID
return err
}
// SavedataBackup holds one row from the savedata_backups table.
type SavedataBackup struct {
Slot int
Data []byte
SavedAt time.Time
}
// LoadBackupsByRecency returns all backup slots for a character, ordered
// most-recent first. Returns an empty (non-nil) slice if no backups exist.
func (r *CharacterRepository) LoadBackupsByRecency(charID uint32) ([]SavedataBackup, error) {
rows, err := r.db.Query(
`SELECT slot, savedata, saved_at FROM savedata_backups
WHERE char_id = $1
ORDER BY saved_at DESC`,
charID,
)
if err != nil {
return nil, err
}
defer rows.Close() //nolint:errcheck // rows.Close error is non-actionable here
backups := make([]SavedataBackup, 0)
for rows.Next() {
var b SavedataBackup
if err := rows.Scan(&b.Slot, &b.Data, &b.SavedAt); err != nil {
return nil, err
}
backups = append(backups, b)
}
return backups, rows.Err()
}
// SaveBackup upserts a savedata snapshot into the rotating backup table.
func (r *CharacterRepository) SaveBackup(charID uint32, slot int, data []byte) error {
_, err := r.db.Exec(`