Phase 2 sub-commit 3: full Update Semantics + ReactiveListModel + AppShell
Some checks failed
CI / Quality (push) Failing after 1m45s

BackendConnection's ConnectionState enum is now Connecting / Online /
Reconnecting / Offline (PLAN.md §5). The probe loop tracks the first
failure since the last Online and transitions to Reconnecting on any
failed probe, then to Offline once the configurable threshold (30 s
default) is exceeded. The Error state is gone; Reconnecting + the
exposed `error` string subsume its UI role.

ReactiveListModel is the headline QML type:

- QAbstractListModel that GETs `baseUrl + source` for an initial JSON
  array and then keeps in sync via an internal MercureClient subscribed
  to `topic`.
- Role names are derived dynamically from the first row's keys plus an
  internal `pending` boolean role used by optimistic mutations.
- Diff application: upsert (insert-or-update), delete, replace; gap
  detection via the envelope `version` field with auto re-fetch.
- `invoke(method, path, body, optimistic)` is the optimistic command
  primitive. Generates an Idempotency-Key, applies the local diff,
  POST/PATCH/DELETEs with that key, and resolves on the matching
  Mercure echo (correlation-key matched in ModelPublisher's envelope).
  Rolls back and emits commandFailed on 4xx/5xx, commandTimedOut after
  10 s without an echo. Phase 4 packaging will surface configuration
  for the timeout.

AppShell.qml is the optional convenience root:

- Reads BackendConnection.connectionState.
- Reconnecting → top banner.
- Offline → modal overlay with the error string and a Retry button
  (calls BackendConnection.restart()).
- Wraps user content via `default property alias content`.

Apps that want full chrome control can skip AppShell entirely; the
skeleton's Main.qml keeps its own status display for demonstration
and is unaffected.

ReactiveObject (single-entity twin of ReactiveListModel) is intentionally
deferred — same envelope handling, smaller surface; will land in Phase 2
follow-up or Phase 3 alongside the todo example. Cursor pagination is
similarly deferred (the Phase 2 done criterion uses small lists).

Smoke tested: /healthz + /api/ping round-trip cleanly, zero Mercure 401s,
clean shutdown. composer quality stays green (16 tests, 45 assertions).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-02 02:40:12 +02:00
parent 1c5a5761f6
commit 030502ca38
8 changed files with 763 additions and 15 deletions

View File

@@ -0,0 +1,155 @@
#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