[DeckSync] Address tombstone review: remote deletes, revival and purge clock

- Treat a record whose server row is gone as a remote delete only when the
  local file still matches the sidecar's base hash. An edit made after the
  remote delete is newer than the delete, so it uploads and revives the row
  instead of being removed.
- Revive a tombstone with the same user and guid in cmdDeckUpload instead of
  inserting a second row next to it, so the deck keeps its id and version
  history and the promise in servatrice.ini.example holds.
- Run tombstone purging on its own clock, driven by the new
  [deck_sync] purge_interval option, so disabling share cleanup no longer
  disables purging silently.
- Write the base snapshot through QSaveFile and log a failed commit, so a
  crash or a full disk cannot leave a truncated base for DeckDiff::merge().
This commit is contained in:
Lukas Brübach 2026-11-01 13:21:05 +01:00 • committed by Lukas Brübach
parent f8cd3126f9
commit a94db4171b
5 changed files with 112 additions and 34 deletions

View file

@ -3,10 +3,12 @@
#include "../deck_loader/deck_loader.h"
#include "deck_sync_sidecar.h"
#include <QDebug>
#include <QDir>
#include <QDirIterator>
#include <QFile>
#include <QFileInfo>
#include <QSaveFile>
#include <QStringList>
#include <QUuid>
#include <libcockatrice/deck_list/deck_file_format.h>
@ -257,9 +259,14 @@ void DeckSyncManager::onListFinished(const Response &response, const CommandCont
}
}
if (serverId == -1) {
if (sidecarRecord.remoteId != -1) {
// The server no longer lists this deck: it was deleted elsewhere.
// Apply the delete locally, keeping a backup of the file.
// The sidecar knows a server row that the listing no longer has: it was
// deleted elsewhere. Only mirror that delete when the local file is
// still exactly what was synced - an edit made after the remote delete
// is newer than the delete and gets uploaded again.
const bool knownToServer = sidecarRecord.remoteId != -1 || !sidecarRecord.guid.isEmpty();
const bool unchangedSinceSync =
!sidecarRecord.baseHash.isEmpty() && it.value().hash == sidecarRecord.baseHash;
if (knownToServer && unchangedSinceSync) {
const QString absolutePath = deckPath + "/" + it.key();
const QString backupPath =
absolutePath + BACKUP_SUFFIX + QString::number(QDateTime::currentDateTime().toSecsSinceEpoch());
@ -678,11 +685,16 @@ void DeckSyncManager::snapshotLocalFile(const QString &relativePath, const QStri
return;
}
QDir().mkpath(deckPath + "/" + SYNC_META_DIR + "/bases");
QFile snapshot(deckPath + "/" + SYNC_META_DIR + "/bases/" + guid + ".cod");
if (!snapshot.open(QIODevice::WriteOnly | QIODevice::Truncate)) {
// QSaveFile: a crash or a full disk must not leave a truncated base behind,
// because DeckDiff::merge() against a partial base re-adds or drops cards.
QSaveFile snapshot(deckPath + "/" + SYNC_META_DIR + "/bases/" + guid + ".cod");
if (!snapshot.open(QIODevice::WriteOnly)) {
return;
}
snapshot.write(source.readAll());
if (!snapshot.commit()) {
qWarning() << "DeckSync: could not write the base snapshot for" << relativePath;
}
}
void DeckSyncManager::failSync(const QString &error)