156 lines
5.5 KiB
C
156 lines
5.5 KiB
C
|
|
#pragma once
|
||
|
|
|
||
|
|
#include <QAbstractListModel>
|
||
|
|
#include <QHash>
|
||
|
|
#include <QJsonArray>
|
||
|
|
#include <QJsonObject>
|
||
|
|
#include <QString>
|
||
|
|
#include <QVector>
|
||
|
|
#include <QtQmlIntegration>
|
||
|
|
|
||
|
|
class QNetworkAccessManager;
|
||
|
|
class QNetworkReply;
|
||
|
|
class QTimer;
|
||
|
|
|
||
|
|
namespace PhpQml::Bridge {
|
||
|
|
|
||
|
|
class MercureClient;
|
||
|
|
|
||
|
|
/// QAbstractListModel that mirrors a `#[BridgeResource]` collection from
|
||
|
|
/// the backend. Loads via HTTP GET, then keeps in sync via Mercure SSE.
|
||
|
|
///
|
||
|
|
/// The role names are derived dynamically from the first row's JSON keys,
|
||
|
|
/// plus an internal `pending` role used by optimistic mutations
|
||
|
|
/// (PLAN.md §5 *Optimistic updates*).
|
||
|
|
///
|
||
|
|
/// Phase 2 implements diff application (upsert / delete / replace) and
|
||
|
|
/// version-gap detection. Cursor pagination is wired but the default
|
||
|
|
/// "fetch everything" behaviour is fine for small collections; bigger
|
||
|
|
/// resources should set `pageSize` and call `fetchMore()` from the view.
|
||
|
|
class ReactiveListModel : public QAbstractListModel
|
||
|
|
{
|
||
|
|
Q_OBJECT
|
||
|
|
QML_ELEMENT
|
||
|
|
|
||
|
|
Q_PROPERTY(QString baseUrl READ baseUrl WRITE setBaseUrl NOTIFY baseUrlChanged)
|
||
|
|
Q_PROPERTY(QString token READ token WRITE setToken NOTIFY tokenChanged)
|
||
|
|
Q_PROPERTY(QString source READ source WRITE setSource NOTIFY sourceChanged)
|
||
|
|
Q_PROPERTY(QString topic READ topic WRITE setTopic NOTIFY topicChanged)
|
||
|
|
Q_PROPERTY(bool ready READ ready NOTIFY readyChanged)
|
||
|
|
Q_PROPERTY(QString error READ error NOTIFY errorChanged)
|
||
|
|
|
||
|
|
public:
|
||
|
|
explicit ReactiveListModel(QObject* parent = nullptr);
|
||
|
|
~ReactiveListModel() override;
|
||
|
|
|
||
|
|
// QAbstractListModel
|
||
|
|
int rowCount(const QModelIndex& parent = QModelIndex()) const override;
|
||
|
|
QVariant data(const QModelIndex& index, int role) const override;
|
||
|
|
QHash<int, QByteArray> roleNames() const override;
|
||
|
|
|
||
|
|
QString baseUrl() const { return m_baseUrl; }
|
||
|
|
void setBaseUrl(const QString& v);
|
||
|
|
|
||
|
|
QString token() const { return m_token; }
|
||
|
|
void setToken(const QString& v);
|
||
|
|
|
||
|
|
QString source() const { return m_source; }
|
||
|
|
void setSource(const QString& v);
|
||
|
|
|
||
|
|
QString topic() const { return m_topic; }
|
||
|
|
void setTopic(const QString& v);
|
||
|
|
|
||
|
|
bool ready() const noexcept { return m_ready; }
|
||
|
|
QString error() const { return m_error; }
|
||
|
|
|
||
|
|
/// Triggers a re-fetch from `source`. Useful for manual refresh and
|
||
|
|
/// after a Mercure version gap has been detected.
|
||
|
|
Q_INVOKABLE void refresh();
|
||
|
|
|
||
|
|
/// Optimistically apply a local change and POST/PATCH/DELETE the
|
||
|
|
/// matching command. The model marks the row `pending` until the
|
||
|
|
/// matching Mercure echo arrives (correlation-key matched). On HTTP
|
||
|
|
/// 4xx/5xx or timeout, rolls back and emits `commandFailed` /
|
||
|
|
/// `commandTimedOut`.
|
||
|
|
///
|
||
|
|
/// @param method one of "POST", "PATCH", "DELETE"
|
||
|
|
/// @param path absolute path on the backend, e.g. "/api/todos/42"
|
||
|
|
/// @param body QVariantMap to JSON-encode (or QVariant{} for none)
|
||
|
|
/// @param optimistic local mutation to apply immediately:
|
||
|
|
/// {"op":"upsert"|"delete", "id":..., "data":{...}}
|
||
|
|
Q_INVOKABLE void invoke(const QString& method,
|
||
|
|
const QString& path,
|
||
|
|
const QVariant& body,
|
||
|
|
const QVariantMap& optimistic);
|
||
|
|
|
||
|
|
signals:
|
||
|
|
void baseUrlChanged();
|
||
|
|
void tokenChanged();
|
||
|
|
void sourceChanged();
|
||
|
|
void topicChanged();
|
||
|
|
void readyChanged();
|
||
|
|
void errorChanged();
|
||
|
|
|
||
|
|
void commandSucceeded(const QString& correlationKey, const QVariant& response);
|
||
|
|
void commandFailed(const QString& correlationKey, int status, const QVariant& problem);
|
||
|
|
void commandTimedOut(const QString& correlationKey);
|
||
|
|
|
||
|
|
private slots:
|
||
|
|
void onFetchFinished();
|
||
|
|
void onMercureUpdate(const QString& data, const QString& id);
|
||
|
|
void onCommandReplyFinished();
|
||
|
|
|
||
|
|
private:
|
||
|
|
void rewireMercure();
|
||
|
|
void setReady(bool r);
|
||
|
|
void setError(const QString& e);
|
||
|
|
|
||
|
|
void fetchInitial();
|
||
|
|
QByteArray buildIdempotencyKey() const;
|
||
|
|
void applyEnvelope(const QJsonObject& envelope);
|
||
|
|
void applyOptimisticDiff(const QVariantMap& diff, const QByteArray& key);
|
||
|
|
void rollbackOptimistic(const QByteArray& key);
|
||
|
|
|
||
|
|
int findIndexById(const QString& id) const;
|
||
|
|
QHash<int, QByteArray> deriveRoleNames(const QJsonObject& sample) const;
|
||
|
|
void resetRolesFromSample(const QJsonObject& sample);
|
||
|
|
QVariant roleValue(const QJsonObject& row, int role) const;
|
||
|
|
|
||
|
|
QString m_baseUrl;
|
||
|
|
QString m_token;
|
||
|
|
QString m_source;
|
||
|
|
QString m_topic;
|
||
|
|
bool m_ready = false;
|
||
|
|
QString m_error;
|
||
|
|
|
||
|
|
QNetworkAccessManager* m_nam = nullptr;
|
||
|
|
QNetworkReply* m_pending = nullptr;
|
||
|
|
MercureClient* m_mercure = nullptr;
|
||
|
|
|
||
|
|
QVector<QJsonObject> m_rows;
|
||
|
|
QHash<QString, int> m_idToIndex;
|
||
|
|
QHash<int, QByteArray> m_roles;
|
||
|
|
int m_pendingRole = -1; // assigned when roles built
|
||
|
|
|
||
|
|
int m_lastVersion = 0;
|
||
|
|
|
||
|
|
/// In-flight optimistic mutations keyed by Idempotency-Key.
|
||
|
|
/// Stored: { backupRow (or sentinel), action ("upsert"/"delete"), id }.
|
||
|
|
struct InFlight {
|
||
|
|
QString id;
|
||
|
|
QString op; // "upsert" or "delete"
|
||
|
|
bool hadRow = false;
|
||
|
|
QJsonObject backupRow; // pre-mutation snapshot for rollback
|
||
|
|
};
|
||
|
|
QHash<QByteArray, InFlight> m_inFlight;
|
||
|
|
|
||
|
|
QHash<QNetworkReply*, QByteArray> m_replyToKey;
|
||
|
|
|
||
|
|
/// commandTimedOut fires this many ms after the request goes out
|
||
|
|
/// without a Mercure echo (PLAN.md §5).
|
||
|
|
static constexpr int kEchoTimeoutMs = 10000;
|
||
|
|
QHash<QByteArray, QTimer*> m_echoTimers;
|
||
|
|
};
|
||
|
|
|
||
|
|
} // namespace PhpQml::Bridge
|