From 030502ca382dc2338068e5a4a9d784e8065feb28 Mon Sep 17 00:00:00 2001 From: magdev Date: Sat, 2 May 2026 02:40:12 +0200 Subject: [PATCH] Phase 2 sub-commit 3: full Update Semantics + ReactiveListModel + AppShell MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- PLAN.md | 1 + framework/qml/CMakeLists.txt | 3 + framework/qml/qml/AppShell.qml | 89 +++++ framework/qml/src/BackendConnection.cpp | 27 +- framework/qml/src/BackendConnection.h | 14 +- framework/qml/src/ReactiveListModel.cpp | 471 ++++++++++++++++++++++++ framework/qml/src/ReactiveListModel.h | 155 ++++++++ framework/skeleton/qml/Main.qml | 18 +- 8 files changed, 763 insertions(+), 15 deletions(-) create mode 100644 framework/qml/qml/AppShell.qml create mode 100644 framework/qml/src/ReactiveListModel.cpp create mode 100644 framework/qml/src/ReactiveListModel.h diff --git a/PLAN.md b/PLAN.md index ce6f342..2246541 100644 --- a/PLAN.md +++ b/PLAN.md @@ -483,6 +483,7 @@ Every dependency is version-pinned: Qt, the FrankenPHP binary URL with verified #### Workflow files - `.gitea/workflows/ci.yml` — `quality` + `build` on every push and PR. + - `.gitea/workflows/release.yml` — `v*` tag triggered, depends on `build`, signs and uploads. ## 12. Open Questions and Risks diff --git a/framework/qml/CMakeLists.txt b/framework/qml/CMakeLists.txt index c2059b7..419b9cc 100644 --- a/framework/qml/CMakeLists.txt +++ b/framework/qml/CMakeLists.txt @@ -31,8 +31,11 @@ qt_add_qml_module(php_qml_bridge src/SingleInstance.cpp src/MercureClient.h src/MercureClient.cpp + src/ReactiveListModel.h + src/ReactiveListModel.cpp QML_FILES qml/RestClient.qml + qml/AppShell.qml ) target_include_directories(php_qml_bridge PUBLIC src/) diff --git a/framework/qml/qml/AppShell.qml b/framework/qml/qml/AppShell.qml new file mode 100644 index 0000000..ab8419c --- /dev/null +++ b/framework/qml/qml/AppShell.qml @@ -0,0 +1,89 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import PhpQml.Bridge + +// Optional convenience root component that surfaces the Update Semantics +// state machine (PLAN.md §5) as default UI: +// - Reconnecting: top banner +// - Offline: modal overlay with retry +// +// Apps wrap their root content with `AppShell { ... }` and the banner / +// overlay are driven automatically by BackendConnection.connectionState. +// Skip it if you want full control over the chrome. +Item { + id: shell + + default property alias content: contentSlot.children + + Item { + id: contentSlot + anchors.fill: parent + anchors.topMargin: banner.visible ? banner.height : 0 + // Block input on the underlying content while offline so users + // don't queue mutations into a queue we don't have yet. + enabled: BackendConnection.connectionState !== BackendConnection.Offline + } + + // ── Reconnecting banner ──────────────────────────────────────────── + Rectangle { + id: banner + anchors { + top: parent.top + left: parent.left + right: parent.right + } + height: visible ? 32 : 0 + visible: BackendConnection.connectionState === BackendConnection.Reconnecting + color: "#d89614" + + Label { + anchors.centerIn: parent + text: "Reconnecting…" + color: "white" + font.pixelSize: 13 + } + } + + // ── Offline overlay ──────────────────────────────────────────────── + Rectangle { + id: offlineOverlay + anchors.fill: parent + visible: BackendConnection.connectionState === BackendConnection.Offline + color: "#aa000000" + z: 100 + + // Eat events so clicks don't fall through to the disabled content. + MouseArea { + anchors.fill: parent + onClicked: {} // intentional no-op + } + + Frame { + anchors.centerIn: parent + padding: 24 + + ColumnLayout { + spacing: 12 + Label { + text: "Offline" + font.pixelSize: 18 + font.bold: true + Layout.alignment: Qt.AlignHCenter + } + Label { + text: BackendConnection.error || "Backend not reachable." + opacity: 0.7 + wrapMode: Text.Wrap + Layout.preferredWidth: 280 + Layout.alignment: Qt.AlignHCenter + } + Button { + text: "Retry" + Layout.alignment: Qt.AlignHCenter + onClicked: BackendConnection.restart() + } + } + } + } +} diff --git a/framework/qml/src/BackendConnection.cpp b/framework/qml/src/BackendConnection.cpp index 823ce98..79566fe 100644 --- a/framework/qml/src/BackendConnection.cpp +++ b/framework/qml/src/BackendConnection.cpp @@ -73,18 +73,35 @@ void BackendConnection::onProbeFinished() if (!reply) return; reply->deleteLater(); + bool ok = false; if (reply->error() == QNetworkReply::NoError) { const int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); if (status == 200) { - setError(QString()); - setState(ConnectionState::Online); - return; + ok = true; + } else { + setError(QStringLiteral("/healthz returned HTTP %1").arg(status)); } - setError(QStringLiteral("/healthz returned HTTP %1").arg(status)); } else { setError(reply->errorString()); } - setState(ConnectionState::Error); + + if (ok) { + setError(QString()); + m_firstFailureSinceOnline.invalidate(); + setState(ConnectionState::Online); + return; + } + + // Probe failed. Decide between Reconnecting and Offline based on how + // long we've been failing since the last Online. (PLAN.md §5.) + if (!m_firstFailureSinceOnline.isValid()) { + m_firstFailureSinceOnline.start(); + } + if (m_firstFailureSinceOnline.hasExpired(m_offlineThresholdMs)) { + setState(ConnectionState::Offline); + } else { + setState(ConnectionState::Reconnecting); + } } void BackendConnection::setState(ConnectionState s) diff --git a/framework/qml/src/BackendConnection.h b/framework/qml/src/BackendConnection.h index 0e2369b..0fe550c 100644 --- a/framework/qml/src/BackendConnection.h +++ b/framework/qml/src/BackendConnection.h @@ -1,5 +1,6 @@ #pragma once +#include #include #include #include @@ -37,13 +38,16 @@ public: }; Q_ENUM(Mode) - /// Phase 1 surfaces only Connecting / Online / Error. The full enum - /// (Reconnecting, Offline) lands with the Update Semantics layer in - /// Phase 2 (PLAN.md §5). + /// Full Update Semantics enum (PLAN.md §5). + /// - Connecting : initial state until first probe response + /// - Online : last probe succeeded + /// - Reconnecting : ≥1 probe failed since last success; backing off + /// - Offline : reconnect failures exceeded the threshold (default 30 s) enum class ConnectionState { Connecting, Online, - Error, + Reconnecting, + Offline, }; Q_ENUM(ConnectionState) @@ -86,6 +90,8 @@ private: QNetworkAccessManager* m_nam = nullptr; QNetworkReply* m_pendingReply = nullptr; QTimer* m_retryTimer = nullptr; + QElapsedTimer m_firstFailureSinceOnline; // not started while Online + int m_offlineThresholdMs = 30000; }; } // namespace PhpQml::Bridge diff --git a/framework/qml/src/ReactiveListModel.cpp b/framework/qml/src/ReactiveListModel.cpp new file mode 100644 index 0000000..763fa61 --- /dev/null +++ b/framework/qml/src/ReactiveListModel.cpp @@ -0,0 +1,471 @@ +#include "ReactiveListModel.h" + +#include "MercureClient.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace PhpQml::Bridge { + +ReactiveListModel::ReactiveListModel(QObject* parent) + : QAbstractListModel(parent) + , m_nam(new QNetworkAccessManager(this)) + , m_mercure(new MercureClient(this)) +{ + connect(m_mercure, &MercureClient::update, + this, &ReactiveListModel::onMercureUpdate); +} + +ReactiveListModel::~ReactiveListModel() +{ + qDeleteAll(m_echoTimers); +} + +void ReactiveListModel::setBaseUrl(const QString& v) +{ + if (m_baseUrl == v) return; + m_baseUrl = v; + rewireMercure(); + emit baseUrlChanged(); + if (!m_source.isEmpty()) refresh(); +} + +void ReactiveListModel::setToken(const QString& v) +{ + if (m_token == v) return; + m_token = v; + m_mercure->setToken(QString()); // Mercure subscribes anonymously by default + emit tokenChanged(); +} + +void ReactiveListModel::setSource(const QString& v) +{ + if (m_source == v) return; + m_source = v; + emit sourceChanged(); + if (!m_baseUrl.isEmpty()) refresh(); +} + +void ReactiveListModel::setTopic(const QString& v) +{ + if (m_topic == v) return; + m_topic = v; + rewireMercure(); + emit topicChanged(); +} + +void ReactiveListModel::rewireMercure() +{ + if (m_baseUrl.isEmpty() || m_topic.isEmpty()) return; + m_mercure->stop(); + m_mercure->setHubUrl(m_baseUrl + QStringLiteral("/.well-known/mercure")); + m_mercure->setTopic(m_topic); + m_mercure->start(); +} + +void ReactiveListModel::setReady(bool r) +{ + if (m_ready == r) return; + m_ready = r; + emit readyChanged(); +} + +void ReactiveListModel::setError(const QString& e) +{ + if (m_error == e) return; + m_error = e; + emit errorChanged(); +} + +void ReactiveListModel::refresh() +{ + fetchInitial(); +} + +int ReactiveListModel::rowCount(const QModelIndex& parent) const +{ + if (parent.isValid()) return 0; + return m_rows.size(); +} + +QHash ReactiveListModel::roleNames() const +{ + return m_roles.isEmpty() + ? QHash{{Qt::DisplayRole, "display"}} + : m_roles; +} + +QVariant ReactiveListModel::data(const QModelIndex& index, int role) const +{ + if (!index.isValid() || index.row() < 0 || index.row() >= m_rows.size()) { + return {}; + } + return roleValue(m_rows[index.row()], role); +} + +QVariant ReactiveListModel::roleValue(const QJsonObject& row, int role) const +{ + if (role == m_pendingRole) { + return row.value(QStringLiteral("__pending")).toBool(); + } + auto it = m_roles.constFind(role); + if (it == m_roles.constEnd()) return {}; + const QString key = QString::fromUtf8(it.value()); + const QJsonValue v = row.value(key); + switch (v.type()) { + case QJsonValue::String: return v.toString(); + case QJsonValue::Double: return v.toDouble(); + case QJsonValue::Bool: return v.toBool(); + case QJsonValue::Null: return {}; + case QJsonValue::Array: return v.toArray().toVariantList(); + case QJsonValue::Object: return v.toObject().toVariantMap(); + default: return {}; + } +} + +QHash ReactiveListModel::deriveRoleNames(const QJsonObject& sample) const +{ + QHash roles; + int role = Qt::UserRole + 1; + for (auto it = sample.constBegin(); it != sample.constEnd(); ++it) { + if (it.key() == QStringLiteral("__pending")) continue; + roles.insert(role++, it.key().toUtf8()); + } + roles.insert(role, "pending"); + return roles; +} + +void ReactiveListModel::resetRolesFromSample(const QJsonObject& sample) +{ + if (!m_roles.isEmpty()) return; // already derived + m_roles = deriveRoleNames(sample); + // The last inserted role is `pending` (see deriveRoleNames). + for (auto it = m_roles.constBegin(); it != m_roles.constEnd(); ++it) { + if (it.value() == QByteArrayLiteral("pending")) { + m_pendingRole = it.key(); + break; + } + } +} + +int ReactiveListModel::findIndexById(const QString& id) const +{ + auto it = m_idToIndex.constFind(id); + return it == m_idToIndex.constEnd() ? -1 : it.value(); +} + +void ReactiveListModel::fetchInitial() +{ + if (m_baseUrl.isEmpty() || m_source.isEmpty()) return; + if (m_pending) { + m_pending->abort(); + m_pending->deleteLater(); + m_pending = nullptr; + } + + QNetworkRequest req(QUrl(m_baseUrl + m_source)); + req.setRawHeader("Accept", "application/json"); + if (!m_token.isEmpty()) { + req.setRawHeader("Authorization", "Bearer " + m_token.toUtf8()); + } + req.setTransferTimeout(5000); + + setReady(false); + m_pending = m_nam->get(req); + connect(m_pending, &QNetworkReply::finished, + this, &ReactiveListModel::onFetchFinished); +} + +void ReactiveListModel::onFetchFinished() +{ + QNetworkReply* reply = m_pending; + m_pending = nullptr; + if (!reply) return; + reply->deleteLater(); + + if (reply->error() != QNetworkReply::NoError) { + setError(reply->errorString()); + return; + } + const int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + if (status < 200 || status >= 300) { + setError(QStringLiteral("GET %1 returned HTTP %2").arg(m_source).arg(status)); + return; + } + + QJsonParseError parseErr{}; + const QJsonDocument doc = QJsonDocument::fromJson(reply->readAll(), &parseErr); + if (parseErr.error != QJsonParseError::NoError || !doc.isArray()) { + setError(QStringLiteral("Initial fetch did not return a JSON array.")); + return; + } + + const QJsonArray items = doc.array(); + beginResetModel(); + m_rows.clear(); + m_idToIndex.clear(); + if (!items.isEmpty()) { + resetRolesFromSample(items.first().toObject()); + m_rows.reserve(items.size()); + for (const QJsonValue& v : items) { + const QJsonObject row = v.toObject(); + const QString id = row.value(QStringLiteral("id")).toVariant().toString(); + m_idToIndex.insert(id, m_rows.size()); + m_rows.append(row); + } + } + endResetModel(); + + setError(QString()); + setReady(true); +} + +void ReactiveListModel::onMercureUpdate(const QString& data, const QString& /*id*/) +{ + QJsonParseError err{}; + const QJsonDocument doc = QJsonDocument::fromJson(data.toUtf8(), &err); + if (err.error != QJsonParseError::NoError || !doc.isObject()) return; + applyEnvelope(doc.object()); +} + +void ReactiveListModel::applyEnvelope(const QJsonObject& envelope) +{ + const int version = envelope.value(QStringLiteral("version")).toInt(); + if (m_lastVersion > 0 && version > m_lastVersion + 1) { + // gap — re-fetch to recover + m_lastVersion = 0; + refresh(); + return; + } + m_lastVersion = std::max(m_lastVersion, version); + + const QString op = envelope.value(QStringLiteral("op")).toString(); + const QString id = envelope.value(QStringLiteral("id")).toVariant().toString(); + const QString key = envelope.value(QStringLiteral("correlationKey")).toString(); + + if (op == QStringLiteral("delete")) { + const int row = findIndexById(id); + if (row < 0) return; + beginRemoveRows(QModelIndex(), row, row); + m_rows.remove(row); + m_idToIndex.remove(id); + // re-index + for (int i = row; i < m_rows.size(); ++i) { + m_idToIndex.insert(m_rows[i].value(QStringLiteral("id")).toVariant().toString(), i); + } + endRemoveRows(); + } else if (op == QStringLiteral("upsert") || op == QStringLiteral("replace")) { + QJsonObject payload = envelope.value(QStringLiteral("data")).toObject(); + if (m_roles.isEmpty()) resetRolesFromSample(payload); + const int row = findIndexById(id); + if (row < 0) { + beginInsertRows(QModelIndex(), m_rows.size(), m_rows.size()); + m_idToIndex.insert(id, m_rows.size()); + m_rows.append(payload); + endInsertRows(); + } else { + m_rows[row] = payload; + const QModelIndex idx = index(row); + emit dataChanged(idx, idx); + } + } + + // If this echoes one of our optimistic mutations, clear it. + if (!key.isEmpty()) { + const QByteArray k = key.toUtf8(); + if (m_inFlight.contains(k)) { + m_inFlight.remove(k); + if (auto* t = m_echoTimers.take(k)) t->deleteLater(); + emit commandSucceeded(key, {}); + } + } +} + +void ReactiveListModel::invoke(const QString& method, + const QString& path, + const QVariant& body, + const QVariantMap& optimistic) +{ + if (m_baseUrl.isEmpty()) return; + + const QByteArray key = buildIdempotencyKey(); + + // Apply the optimistic diff locally if one was provided. + if (!optimistic.isEmpty()) { + applyOptimisticDiff(optimistic, key); + } + + QNetworkRequest req(QUrl(m_baseUrl + path)); + req.setRawHeader("Accept", "application/json"); + req.setRawHeader("Idempotency-Key", key); + if (!m_token.isEmpty()) { + req.setRawHeader("Authorization", "Bearer " + m_token.toUtf8()); + } + + QByteArray bytes; + if (body.isValid() && !body.isNull()) { + bytes = QJsonDocument(QJsonObject::fromVariantMap(body.toMap())).toJson(QJsonDocument::Compact); + req.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); + } + + QNetworkReply* reply = nullptr; + if (method == QStringLiteral("POST")) { + reply = m_nam->post(req, bytes); + } else if (method == QStringLiteral("PATCH")) { + reply = m_nam->sendCustomRequest(req, "PATCH", bytes); + } else if (method == QStringLiteral("DELETE")) { + reply = m_nam->deleteResource(req); + } else { + rollbackOptimistic(key); + return; + } + m_replyToKey.insert(reply, key); + connect(reply, &QNetworkReply::finished, + this, &ReactiveListModel::onCommandReplyFinished); + + // Arm the echo-timeout fallback. + auto* t = new QTimer(this); + t->setSingleShot(true); + t->setInterval(kEchoTimeoutMs); + connect(t, &QTimer::timeout, this, [this, key]() { + if (!m_inFlight.contains(key)) return; + rollbackOptimistic(key); + emit commandTimedOut(QString::fromUtf8(key)); + }); + t->start(); + m_echoTimers.insert(key, t); +} + +void ReactiveListModel::applyOptimisticDiff(const QVariantMap& diff, const QByteArray& key) +{ + const QString op = diff.value(QStringLiteral("op")).toString(); + const QString id = diff.value(QStringLiteral("id")).toString(); + if (op.isEmpty() || id.isEmpty()) return; + + InFlight inflight; + inflight.id = id; + inflight.op = op; + + if (op == QStringLiteral("delete")) { + const int row = findIndexById(id); + if (row >= 0) { + inflight.hadRow = true; + inflight.backupRow = m_rows[row]; + beginRemoveRows(QModelIndex(), row, row); + m_rows.remove(row); + m_idToIndex.remove(id); + for (int i = row; i < m_rows.size(); ++i) { + m_idToIndex.insert(m_rows[i].value(QStringLiteral("id")).toVariant().toString(), i); + } + endRemoveRows(); + } + } else { // upsert + const QVariantMap dataMap = diff.value(QStringLiteral("data")).toMap(); + QJsonObject row = QJsonObject::fromVariantMap(dataMap); + row.insert(QStringLiteral("id"), id); + row.insert(QStringLiteral("__pending"), true); + if (m_roles.isEmpty()) resetRolesFromSample(row); + + const int existing = findIndexById(id); + if (existing >= 0) { + inflight.hadRow = true; + inflight.backupRow = m_rows[existing]; + m_rows[existing] = row; + const QModelIndex idx = index(existing); + emit dataChanged(idx, idx); + } else { + beginInsertRows(QModelIndex(), m_rows.size(), m_rows.size()); + m_idToIndex.insert(id, m_rows.size()); + m_rows.append(row); + endInsertRows(); + } + } + m_inFlight.insert(key, inflight); +} + +void ReactiveListModel::rollbackOptimistic(const QByteArray& key) +{ + auto it = m_inFlight.find(key); + if (it == m_inFlight.end()) return; + InFlight in = it.value(); + m_inFlight.erase(it); + if (auto* t = m_echoTimers.take(key)) t->deleteLater(); + + const int row = findIndexById(in.id); + if (in.op == QStringLiteral("delete")) { + if (in.hadRow && row < 0) { + // we removed a row; put it back + beginInsertRows(QModelIndex(), m_rows.size(), m_rows.size()); + m_idToIndex.insert(in.id, m_rows.size()); + m_rows.append(in.backupRow); + endInsertRows(); + } + } else { // upsert + if (in.hadRow && row >= 0) { + m_rows[row] = in.backupRow; + const QModelIndex idx = index(row); + emit dataChanged(idx, idx); + } else if (!in.hadRow && row >= 0) { + beginRemoveRows(QModelIndex(), row, row); + m_rows.remove(row); + m_idToIndex.remove(in.id); + for (int i = row; i < m_rows.size(); ++i) { + m_idToIndex.insert(m_rows[i].value(QStringLiteral("id")).toVariant().toString(), i); + } + endRemoveRows(); + } + } +} + +void ReactiveListModel::onCommandReplyFinished() +{ + auto* reply = qobject_cast(sender()); + if (!reply) return; + const QByteArray key = m_replyToKey.take(reply); + reply->deleteLater(); + + const int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + if (reply->error() == QNetworkReply::NoError && status >= 200 && status < 300) { + // Don't clear in-flight here — wait for the Mercure echo so the + // model's state reflects the server's view, not our optimistic guess. + return; + } + + // Failure: rollback and emit commandFailed. + QJsonParseError err{}; + const QJsonDocument doc = QJsonDocument::fromJson(reply->readAll(), &err); + QVariant problem; + if (err.error == QJsonParseError::NoError && doc.isObject()) { + problem = doc.object().toVariantMap(); + } + rollbackOptimistic(key); + emit commandFailed(QString::fromUtf8(key), status, problem); +} + +QByteArray ReactiveListModel::buildIdempotencyKey() const +{ + // RFC 4122 v4 — Math.random-grade entropy is fine for idempotency, + // matches RestClient.qml's _uuid4 helper. + auto* gen = QRandomGenerator::system(); + quint64 a = gen->generate64(); + quint64 b = gen->generate64(); + a = (a & 0xFFFFFFFFFFFF0FFFULL) | 0x0000000000004000ULL; // version 4 + b = (b & 0x3FFFFFFFFFFFFFFFULL) | 0x8000000000000000ULL; // variant 1 + return QString::asprintf( + "%08llx-%04llx-%04llx-%04llx-%012llx", + (unsigned long long) ((a >> 32) & 0xFFFFFFFFULL), + (unsigned long long) ((a >> 16) & 0xFFFFULL), + (unsigned long long) (a & 0xFFFFULL), + (unsigned long long) ((b >> 48) & 0xFFFFULL), + (unsigned long long) (b & 0xFFFFFFFFFFFFULL) + ).toLatin1(); +} + +} // namespace PhpQml::Bridge diff --git a/framework/qml/src/ReactiveListModel.h b/framework/qml/src/ReactiveListModel.h new file mode 100644 index 0000000..7e01e57 --- /dev/null +++ b/framework/qml/src/ReactiveListModel.h @@ -0,0 +1,155 @@ +#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 diff --git a/framework/skeleton/qml/Main.qml b/framework/skeleton/qml/Main.qml index 0676887..d2ecc2d 100644 --- a/framework/skeleton/qml/Main.qml +++ b/framework/skeleton/qml/Main.qml @@ -63,9 +63,14 @@ ApplicationWindow { Rectangle { Layout.preferredWidth: 12; Layout.preferredHeight: 12; radius: 6 - color: BackendConnection.connectionState === BackendConnection.Online - ? "#3ab36c" - : (BackendConnection.connectionState === BackendConnection.Error ? "#d8503c" : "#d89614") + color: { + switch (BackendConnection.connectionState) { + case BackendConnection.Online: return "#3ab36c" + case BackendConnection.Reconnecting: return "#d89614" + case BackendConnection.Offline: return "#d8503c" + default: return "#888" + } + } } Label { text: "Backend: " + window._stateName(BackendConnection.connectionState) } @@ -123,9 +128,10 @@ ApplicationWindow { function _stateName(s) { switch (s) { - case BackendConnection.Connecting: return "connecting" - case BackendConnection.Online: return "online" - case BackendConnection.Error: return "error" + case BackendConnection.Connecting: return "connecting" + case BackendConnection.Online: return "online" + case BackendConnection.Reconnecting: return "reconnecting" + case BackendConnection.Offline: return "offline" } return "?" }