#pragma once #include #include #include #include #include #include #include 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 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 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 m_rows; QHash m_idToIndex; QHash 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 m_inFlight; QHash 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 m_echoTimers; }; } // namespace PhpQml::Bridge