This commit is contained in:
BruebachL 2026-11-04 10:30:37 -07:00 • committed by GitHub
commit cd6f73888f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 286 additions and 46 deletions

View file

@ -3,15 +3,18 @@
#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>
#include <libcockatrice/deck_list/deck_list.h>
#include <libcockatrice/network/client/abstract/abstract_client.h>
#include <libcockatrice/protocol/pb/command_deck_del.pb.h>
#include <libcockatrice/protocol/pb/command_deck_download.pb.h>
#include <libcockatrice/protocol/pb/command_deck_list.pb.h>
#include <libcockatrice/protocol/pb/command_deck_new_dir.pb.h>
@ -114,6 +117,7 @@ void DeckSyncManager::syncNow()
skippedCount = 0;
pendingUploads.clear();
pendingDownloads.clear();
pendingDeletes.clear();
ensuredDirs.clear();
ensuredDirs.insert(QString());
localFiles.clear();
@ -254,8 +258,26 @@ void DeckSyncManager::onListFinished(const Response &response, const CommandCont
}
}
}
if (serverId == -1) {
// 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());
if (!QFile::copy(absolutePath, backupPath) || !QFile::remove(absolutePath)) {
++skippedCount;
continue;
}
sidecar.remove(it.key());
++downloadedCount;
continue;
}
const QString serverPath = serverPathFor(it.key());
pendingUploads.append(UploadOp{.relativePath = it.key(),
.serverPath = serverPath,
@ -277,6 +299,7 @@ void DeckSyncManager::onListFinished(const Response &response, const CommandCont
record.lastSyncSecs = nowSecs;
record.deleted = false;
sidecar.set(it.key(), record);
snapshotLocalFile(it.key(), record.guid);
++unchangedCount;
continue;
}
@ -315,6 +338,21 @@ void DeckSyncManager::onListFinished(const Response &response, const CommandCont
.lastModifiedSecs = it.value().lastModifiedSecs});
}
const QMap<QString, DeckSyncRecord> knownRecords = sidecar.records();
for (auto it = knownRecords.begin(); it != knownRecords.end(); ++it) {
if (localFiles.contains(it.key())) {
continue;
}
if (QFileInfo::exists(deckPath + "/" + it.key())) {
continue;
}
if (it.value().remoteId == -1) {
sidecar.remove(it.key());
continue;
}
pendingDeletes.append(it.key());
}
sidecar.save();
processNext();
}
@ -330,7 +368,12 @@ void DeckSyncManager::processNext()
if (!syncing) {
return;
}
if (!pendingUploads.isEmpty()) {
if (!pendingDeletes.isEmpty()) {
const QString relativePath = pendingDeletes.first();
DeckSyncSidecar sidecar(sidecarPath);
sidecar.load();
sendDelete(relativePath, sidecar.get(relativePath).remoteId);
} else if (!pendingUploads.isEmpty()) {
processNextUpload();
} else if (!pendingDownloads.isEmpty()) {
sendDownload(pendingDownloads.first());
@ -425,7 +468,12 @@ void DeckSyncManager::sendUpload(const UploadOp &op)
cmd.set_color_identity(colorIdentityProvider(loaded->deckList).toStdString());
}
PendingCommand *pend = AbstractClient::prepareSessionCommand(cmd);
pend->setExtraData(op.relativePath);
QVariantMap context;
context.insert("path", op.relativePath);
context.insert("serverPath", op.serverPath);
context.insert("deckId", op.deckId);
context.insert("guid", op.guid);
pend->setExtraData(context);
connect(pend, &PendingCommand::finished, this, &DeckSyncManager::onUploadFinished);
client->sendCommand(pend);
}
@ -437,8 +485,22 @@ void DeckSyncManager::onUploadFinished(const Response &response,
if (!syncing) {
return;
}
const QString relativePath = extraData.toString();
const QVariantMap context = extraData.toMap();
const QString relativePath = context.value("path").toString();
const int deckId = context.value("deckId", -1).toInt();
if (response.response_code() != Response::RespOk) {
if (response.response_code() == Response::RespNameNotFound && deckId != -1) {
// The server row is gone (purged tombstone or removed elsewhere):
// upload the deck as new instead of updating a dead id.
UploadOp retry;
retry.relativePath = relativePath;
retry.serverPath = context.value("serverPath").toString();
retry.deckId = -1;
retry.guid = context.value("guid").toString();
pendingUploads.prepend(retry);
processNext();
return;
}
++skippedCount;
processNext();
return;
@ -469,6 +531,7 @@ void DeckSyncManager::onUploadFinished(const Response &response,
record.deleted = false;
sidecar.set(relativePath, record);
sidecar.save();
snapshotLocalFile(relativePath, record.guid);
++uploadedCount;
processNext();
}
@ -569,10 +632,71 @@ void DeckSyncManager::onDownloadFinished(const Response &response,
record.deleted = false;
sidecar.set(relativePath, record);
sidecar.save();
snapshotLocalFile(relativePath, record.guid);
++downloadedCount;
processNext();
}
void DeckSyncManager::sendDelete(const QString &relativePath, int deckId)
{
emit statusMessage(tr("Deleting %1 from server...").arg(relativePath));
Command_DeckDel cmd;
cmd.set_deck_id(deckId);
PendingCommand *pend = AbstractClient::prepareSessionCommand(cmd);
pend->setExtraData(relativePath);
connect(pend, &PendingCommand::finished, this, &DeckSyncManager::onDeleteFinished);
client->sendCommand(pend);
}
void DeckSyncManager::onDeleteFinished(const Response &response,
const CommandContainer & /*commandContainer*/,
const QVariant &extraData)
{
if (!syncing) {
return;
}
const QString relativePath = extraData.toString();
for (int i = 0; i < pendingDeletes.size(); ++i) {
if (pendingDeletes.at(i) == relativePath) {
pendingDeletes.removeAt(i);
break;
}
}
// Both outcomes converge the record: pushed deletes and server-side
// deletes (already tombstoned, answering NameNotFound) are done.
if (response.response_code() == Response::RespOk || response.response_code() == Response::RespNameNotFound) {
DeckSyncSidecar sidecar(sidecarPath);
sidecar.load();
sidecar.remove(relativePath);
sidecar.save();
} else {
++skippedCount;
}
processNext();
}
void DeckSyncManager::snapshotLocalFile(const QString &relativePath, const QString &guid)
{
if (guid.isEmpty()) {
return;
}
QFile source(deckPath + "/" + relativePath);
if (!source.open(QIODevice::ReadOnly)) {
return;
}
QDir().mkpath(deckPath + "/" + SYNC_META_DIR + "/bases");
// 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)
{
syncing = false;

View file

@ -38,6 +38,10 @@ class ServerInfo_DeckStorage_Folder;
*
* Ownership: fire-and-forget per run, driven by PendingCommand round trips.
* No watcher, no auto triggers, no conflict UI — those arrive in later layers.
*
* Deletes are tombstones, never silent: a locally deleted deck is pushed with
* Command_DeckDel, a server-side delete is applied locally only after copying
* the file to a .sync-backup, and resurrection reuses the stored deck id.
*/
class DeckSyncManager : public QObject
{
@ -78,6 +82,8 @@ private slots:
onUploadFinished(const Response &response, const CommandContainer &commandContainer, const QVariant &extraData);
void
onDownloadFinished(const Response &response, const CommandContainer &commandContainer, const QVariant &extraData);
void
onDeleteFinished(const Response &response, const CommandContainer &commandContainer, const QVariant &extraData);
private:
struct ServerFile
@ -125,6 +131,8 @@ private:
void sendMkDirFor(const QString &dir);
void sendUpload(const UploadOp &op);
void sendDownload(const DownloadOp &op);
void sendDelete(const QString &relativePath, int deckId);
void snapshotLocalFile(const QString &relativePath, const QString &guid);
static QString parentDir(const QString &path);
QString uniqueLocalPath(const QString &relativePath) const;
@ -139,6 +147,7 @@ private:
QSet<QString> ensuredDirs;
QList<UploadOp> pendingUploads;
QList<DownloadOp> pendingDownloads;
QList<QString> pendingDeletes;
int uploadedCount = 0;
int downloadedCount = 0;
int unchangedCount = 0;

View file

@ -600,3 +600,16 @@ max_shares_per_day=50
; Minimum enforced: 1.
; Default: 20
max_versions=20
; How many days a deleted deck stays restorable as a tombstone before the
; purge clock removes it (with its versions) for good. Re-uploading the
; same deck within this window revives the tombstone instead of creating a copy.
; A value of 0 purges tombstones on the next purge run.
; Default: 30
tombstone_days=30
; How often, in minutes, the tombstone purge runs. This is independent of
; [deck_share] cleanup_interval, so disabling share cleanup does not disable
; purging. A value of 0 disables purging entirely.
; Default: 60
purge_interval=60

View file

@ -639,6 +639,16 @@ bool Servatrice::initServer()
connect(legacyBackupSweepClock, &QTimer::timeout, this, &Servatrice::sweepLegacyBackups);
legacyBackupSweepClock->start(kLegacyBackupSweepIntervalMs);
// Tombstone purging runs on its own clock: turning share cleanup off must not
// silently disable it as well.
deckTombstoneClock = new QTimer(this);
connect(deckTombstoneClock, SIGNAL(timeout()), this, SLOT(purgeDeckTombstones()));
const int deckSyncPurgeInterval = getDeckSyncPurgeInterval();
if (deckSyncPurgeInterval > 0) {
qDebug() << "Starting deck tombstone purge clock, interval" << deckSyncPurgeInterval << "ms";
deckTombstoneClock->start(deckSyncPurgeInterval);
}
// SOCKET SERVER
if (getNumberOfTCPPools() > 0) {
gameServer =
@ -863,6 +873,11 @@ void Servatrice::cleanupExpiredDeckShares()
servatriceDatabaseInterface->cleanupExpiredDeckShares();
}
void Servatrice::purgeDeckTombstones()
{
servatriceDatabaseInterface->purgeDeckTombstones(getDeckSyncTombstoneDays());
}
void Servatrice::statusUpdate()
{
if (!servatriceDatabaseInterface->checkSql()) {
@ -1402,6 +1417,17 @@ int Servatrice::getDeckSyncMaxVersions() const
return qMax(1, settingsCache->value("deck_sync/max_versions", 20).toInt());
}
int Servatrice::getDeckSyncTombstoneDays() const
{
return qMax(0, settingsCache->value("deck_sync/tombstone_days", 30).toInt());
}
int Servatrice::getDeckSyncPurgeInterval() const
{
// default: every 60 minutes, independent of deck_share/cleanup_interval
return qMax(0, settingsCache->value("deck_sync/purge_interval", 60).toInt()) * 60000;
}
int Servatrice::getNumberOfTCPPools() const
{
return settingsCache->value("server/number_pools", 1).toInt();

View file

@ -166,6 +166,7 @@ private slots:
void sweepLegacyBackups();
void shutdownTimeout();
void cleanupExpiredDeckShares();
void purgeDeckTombstones();
protected:
void doSendIslMessage(const IslMessage &msg, int _serverId) override;
@ -181,6 +182,7 @@ private:
QTimer *pingClock, *statusUpdateClock;
QTimer *deckShareCleanupClock;
QTimer *legacyBackupSweepClock;
QTimer *deckTombstoneClock;
Servatrice_GameServer *gameServer;
Servatrice_WebsocketGameServer *websocketGameServer;
Servatrice_IslServer *islServer;
@ -321,6 +323,8 @@ public:
int getDeckShareMaxDecksPerShare() const;
int getDeckShareMaxSharesPerDay() const;
int getDeckSyncMaxVersions() const;
int getDeckSyncTombstoneDays() const;
int getDeckSyncPurgeInterval() const;
int getMaxUsersPerAddress() const;
/// Raw security/trusted_sources list; match addresses against it with isTrustedSource().
QString getTrustedSources() const;

View file

@ -1384,8 +1384,8 @@ DeckList *Servatrice_DatabaseInterface::getDeckFromDatabase(int deckId, int user
{
checkSql();
QSqlQuery *query =
prepareQuery("select content from {prefix}_decklist_files where id = :id and id_user = :id_user");
QSqlQuery *query = prepareQuery(
"select content from {prefix}_decklist_files where id = :id and id_user = :id_user and deleted = 0");
query->bindValue(":id", deckId);
query->bindValue(":id_user", userId);
execSqlQuery(query);
@ -1536,6 +1536,19 @@ void Servatrice_DatabaseInterface::cleanupExpiredDeckShares()
execSqlQuery(query);
}
void Servatrice_DatabaseInterface::purgeDeckTombstones(int olderThanDays)
{
checkSql();
// DB-native clock: tombstones are stamped with NOW() on this same
// database, so the grace window is immune to app/db clock skew.
// The null arm only catches manually flagged rows.
QSqlQuery *query = prepareQuery("delete from {prefix}_decklist_files where deleted = 1 and (last_modified < "
"DATE_SUB(NOW(), INTERVAL :days DAY) or last_modified is null)");
query->bindValue(":days", qMax(0, olderThanDays));
execSqlQuery(query);
}
bool Servatrice_DatabaseInterface::getDeckSharesForUser(int userId, QList<DeckShareSummaryRecord> &shares)
{
checkSql();

View file

@ -155,6 +155,11 @@ public:
bool getDeckShareItem(const QString &token, int itemId, QString &content);
/** @brief Deletes all expired share bundles (cascades to their items). */
void cleanupExpiredDeckShares();
/**
* @brief Hard-deletes tombstoned decks older than the grace period (cascades to versions).
* @param olderThanDays Tombstones older than this many days are purged; 0 purges all tombstones.
*/
void purgeDeckTombstones(int olderThanDays);
int getNextGameId() override;
int getNextReplayId() override;

View file

@ -710,7 +710,8 @@ bool AbstractServerSocketInterface::deckListHelper(int folderId,
query = sqlInterface->prepareQuery("select id, name, upload_time, is_public, banner_card_name, "
"banner_card_provider, color_identity, tags, guid, hash, last_modified from "
"{prefix}_decklist_files where id_folder = :id_folder and id_user = :id_user");
"{prefix}_decklist_files where id_folder = :id_folder and id_user = :id_user "
"and deleted = 0");
query->bindValue(":id_folder", folderId);
query->bindValue(":id_user", userId);
if (!sqlInterface->execSqlQuery(query)) {
@ -855,8 +856,8 @@ Response::ResponseCode AbstractServerSocketInterface::cmdDeckSetVisibility(const
sqlInterface->checkSql();
if (cmd.has_deck_id()) {
QSqlQuery *query =
sqlInterface->prepareQuery("select 1 from {prefix}_decklist_files where id = :id and id_user = :id_user");
QSqlQuery *query = sqlInterface->prepareQuery(
"select 1 from {prefix}_decklist_files where id = :id and id_user = :id_user and deleted = 0");
query->bindValue(":id", cmd.deck_id());
query->bindValue(":id_user", userInfo->id());
sqlInterface->execSqlQuery(query);
@ -966,7 +967,8 @@ void AbstractServerSocketInterface::deckDelDirHelper(int basePathId)
deckDelDirHelper(query->value(0).toInt());
}
query = sqlInterface->prepareQuery("delete from {prefix}_decklist_files where id_folder = :id_folder");
query = sqlInterface->prepareQuery("update {prefix}_decklist_files set deleted = 1, last_modified = NOW() "
"where id_folder = :id_folder and deleted = 0");
query->bindValue(":id_folder", basePathId);
sqlInterface->execSqlQuery(query);
@ -1017,18 +1019,17 @@ Response::ResponseCode AbstractServerSocketInterface::cmdDeckDel(const Command_D
sqlInterface->checkSql();
QSqlQuery *query =
sqlInterface->prepareQuery("select id from {prefix}_decklist_files where id = :id and id_user = :id_user");
sqlInterface->prepareQuery("update {prefix}_decklist_files set deleted = 1, last_modified = NOW() where id = "
":id and id_user = :id_user and deleted = 0");
query->bindValue(":id", cmd.deck_id());
query->bindValue(":id_user", userInfo->id());
sqlInterface->execSqlQuery(query);
if (!query->next()) {
if (!sqlInterface->execSqlQuery(query)) {
return Response::RespContextError;
}
if (query->numRowsAffected() == 0) {
return Response::RespNameNotFound;
}
query = sqlInterface->prepareQuery("delete from {prefix}_decklist_files where id = :id");
query->bindValue(":id", cmd.deck_id());
sqlInterface->execSqlQuery(query);
return Response::RespOk;
}
@ -1129,31 +1130,71 @@ Response::ResponseCode AbstractServerSocketInterface::cmdDeckUpload(const Comman
return Response::RespNameNotFound;
}
QSqlQuery *query =
sqlInterface->prepareQuery("insert into {prefix}_decklist_files (id_folder, id_user, name, upload_time, "
"content, is_public, banner_card_name, banner_card_provider, color_identity, "
"tags, guid, hash, last_modified) values(:id_folder, :id_user, :name, NOW(), "
":content, :is_public, :banner_card_name, :banner_card_provider, "
":color_identity, :tags, :guid, :hash, NOW())");
query->bindValue(":id_folder", folderId);
query->bindValue(":id_user", userInfo->id());
query->bindValue(":name", deckName);
query->bindValue(":content", deckStr);
query->bindValue(":is_public", cmd.has_is_public() && cmd.is_public() ? 1 : 0);
query->bindValue(":banner_card_name", bannerCardName);
query->bindValue(":banner_card_provider", bannerCardProvider);
query->bindValue(":color_identity", colorIdentity);
query->bindValue(":tags", tagsJson);
query->bindValue(":guid", guid);
query->bindValue(":hash", deckHash);
if (!sqlInterface->execSqlQuery(query)) {
// A deck that was deleted elsewhere leaves a tombstone behind. Revive it
// instead of inserting a second row with the same guid, which would split
// the deck's version history and leave the tombstone to be purged.
int deckId = -1;
bool inserted = false;
QSqlQuery *tombstoneQuery =
sqlInterface->prepareQuery("select id from {prefix}_decklist_files where id_user = :id_user and guid = "
":guid and deleted = 1 order by last_modified desc limit 1");
tombstoneQuery->bindValue(":id_user", userInfo->id());
tombstoneQuery->bindValue(":guid", guid);
if (!sqlInterface->execSqlQuery(tombstoneQuery)) {
return Response::RespContextError;
}
const int deckId = query->lastInsertId().toInt();
if (tombstoneQuery->next()) {
deckId = tombstoneQuery->value(0).toInt();
QSqlQuery *reviveQuery = sqlInterface->prepareQuery(
"update {prefix}_decklist_files set id_folder=:id_folder, name=:name, upload_time=NOW(), "
"content=:content, is_public=:is_public, banner_card_name=:banner_card_name, "
"banner_card_provider=:banner_card_provider, color_identity=:color_identity, tags=:tags, "
"hash=:hash, last_modified=NOW(), deleted=0 where id=:id and id_user=:id_user");
reviveQuery->bindValue(":id_folder", folderId);
reviveQuery->bindValue(":id_user", userInfo->id());
reviveQuery->bindValue(":id", deckId);
reviveQuery->bindValue(":name", deckName);
reviveQuery->bindValue(":content", deckStr);
reviveQuery->bindValue(":is_public", cmd.has_is_public() && cmd.is_public() ? 1 : 0);
reviveQuery->bindValue(":banner_card_name", bannerCardName);
reviveQuery->bindValue(":banner_card_provider", bannerCardProvider);
reviveQuery->bindValue(":color_identity", colorIdentity);
reviveQuery->bindValue(":tags", tagsJson);
reviveQuery->bindValue(":hash", deckHash);
if (!sqlInterface->execSqlQuery(reviveQuery)) {
return Response::RespContextError;
}
} else {
QSqlQuery *query = sqlInterface->prepareQuery(
"insert into {prefix}_decklist_files (id_folder, id_user, name, upload_time, "
"content, is_public, banner_card_name, banner_card_provider, color_identity, "
"tags, guid, hash, last_modified) values(:id_folder, :id_user, :name, NOW(), "
":content, :is_public, :banner_card_name, :banner_card_provider, "
":color_identity, :tags, :guid, :hash, NOW())");
query->bindValue(":id_folder", folderId);
query->bindValue(":id_user", userInfo->id());
query->bindValue(":name", deckName);
query->bindValue(":content", deckStr);
query->bindValue(":is_public", cmd.has_is_public() && cmd.is_public() ? 1 : 0);
query->bindValue(":banner_card_name", bannerCardName);
query->bindValue(":banner_card_provider", bannerCardProvider);
query->bindValue(":color_identity", colorIdentity);
query->bindValue(":tags", tagsJson);
query->bindValue(":guid", guid);
query->bindValue(":hash", deckHash);
if (!sqlInterface->execSqlQuery(query)) {
return Response::RespContextError;
}
deckId = query->lastInsertId().toInt();
inserted = true;
}
if (!storeDeckVersion(deckId, deckStr, deckHash)) {
QSqlQuery *cleanupQuery = sqlInterface->prepareQuery("delete from {prefix}_decklist_files where id = :id");
cleanupQuery->bindValue(":id", deckId);
sqlInterface->execSqlQuery(cleanupQuery);
if (inserted) {
QSqlQuery *cleanupQuery =
sqlInterface->prepareQuery("delete from {prefix}_decklist_files where id = :id");
cleanupQuery->bindValue(":id", deckId);
sqlInterface->execSqlQuery(cleanupQuery);
}
return Response::RespContextError;
}
@ -1163,9 +1204,12 @@ Response::ResponseCode AbstractServerSocketInterface::cmdDeckUpload(const Comman
stampQuery->bindValue(":id", deckId);
stampQuery->bindValue(":id_user", userInfo->id());
if (!sqlInterface->execSqlQuery(stampQuery) || !stampQuery->next()) {
QSqlQuery *cleanupQuery = sqlInterface->prepareQuery("delete from {prefix}_decklist_files where id = :id");
cleanupQuery->bindValue(":id", deckId);
sqlInterface->execSqlQuery(cleanupQuery);
if (inserted) {
QSqlQuery *cleanupQuery =
sqlInterface->prepareQuery("delete from {prefix}_decklist_files where id = :id");
cleanupQuery->bindValue(":id", deckId);
sqlInterface->execSqlQuery(cleanupQuery);
}
return Response::RespContextError;
}
@ -1198,9 +1242,11 @@ Response::ResponseCode AbstractServerSocketInterface::cmdDeckUpload(const Comman
}
const QString oldHash = currentQuery->value(1).toString();
QString updateQuery = "update {prefix}_decklist_files set name=:name, upload_time=NOW(), content=:content, "
"banner_card_name=:banner_card_name, banner_card_provider=:banner_card_provider, "
"color_identity=:color_identity, tags=:tags, guid=:guid, hash=:hash, last_modified=NOW()";
QString updateQuery =
"update {prefix}_decklist_files set name=:name, upload_time=NOW(), content=:content, "
"banner_card_name=:banner_card_name, banner_card_provider=:banner_card_provider, "
"color_identity=:color_identity, tags=:tags, guid=:guid, hash=:hash, last_modified=NOW(), "
"deleted=0";
if (cmd.has_is_public()) {
updateQuery += ", is_public=:is_public";
}
@ -1402,7 +1448,7 @@ Response::ResponseCode AbstractServerSocketInterface::cmdDeckShareCreate(const C
// issues its own query on the same cached statement set.
QSqlQuery *query =
sqlInterface->prepareQuery("select id, color_identity from {prefix}_decklist_files where id_folder = "
":id_folder and id_user = :id_user");
":id_folder and id_user = :id_user and deleted = 0");
query->bindValue(":id_folder", folderId);
query->bindValue(":id_user", userInfo->id());
if (!sqlInterface->execSqlQuery(query)) {