#pragma once #include #include #include #include #include #include class QNetworkAccessManager; class QNetworkReply; class QTimer; namespace PhpQml::Bridge { class MercureClient; /// Single-entity twin of ReactiveListModel. /// /// Loads via HTTP GET on `source`, then keeps the entity in sync via /// Mercure SSE on `topic`. The entity's JSON keys are exposed as /// QML-bindable properties on `data` (a QQmlPropertyMap), so QML /// reads them as `obj.data.title` etc. and re-evaluates on change. /// /// `invoke()` provides optimistic mutations identical in shape to /// ReactiveListModel.invoke(): apply locally + Idempotency-Key + roll /// back on `4xx`/`5xx`/timeout, clear `pending` on the matching /// Mercure echo (PLAN.md ยง5). class ReactiveObject : public QObject { 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(QQmlPropertyMap* data READ data CONSTANT) Q_PROPERTY(bool ready READ ready NOTIFY readyChanged) Q_PROPERTY(bool pending READ pending NOTIFY pendingChanged) Q_PROPERTY(bool exists READ exists NOTIFY existsChanged) Q_PROPERTY(QString error READ error NOTIFY errorChanged) public: explicit ReactiveObject(QObject* parent = nullptr); ~ReactiveObject() 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); QQmlPropertyMap* data() const { return m_data; } bool ready() const noexcept { return m_ready; } bool pending() const noexcept { return m_pending; } bool exists() const noexcept { return m_exists; } QString error() const { return m_error; } Q_INVOKABLE void refresh(); /// Same shape as ReactiveListModel::invoke(). The optimistic diff /// is applied to `data`'s keys; rollback restores the prior values /// on failure. 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 pendingChanged(); void existsChanged(); 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 fetchInitial(); void applyPayload(const QJsonObject& payload); void clearData(); void setReady(bool r); void setPending(bool p); void setExists(bool e); void setError(const QString& e); QByteArray buildIdempotencyKey() const; void rollbackOptimistic(const QByteArray& key); QString m_baseUrl; QString m_token; QString m_source; QString m_topic; bool m_ready = false; bool m_pending = false; bool m_exists = false; QString m_error; QQmlPropertyMap* m_data = nullptr; QNetworkAccessManager* m_nam = nullptr; QNetworkReply* m_pendingFetch = nullptr; MercureClient* m_mercure = nullptr; int m_lastVersion = 0; struct InFlight { QHash backup; // pre-mutation values for rollback QStringList addedKeys; // keys we added (need removal on rollback) }; QHash m_inFlight; QHash m_replyToKey; QHash m_echoTimers; static constexpr int kEchoTimeoutMs = 10000; }; } // namespace PhpQml::Bridge