Phase 2 sub-commit 3: full Update Semantics + ReactiveListModel + AppShell
Some checks failed
CI / Quality (push) Failing after 1m45s
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:
1
PLAN.md
1
PLAN.md
@@ -483,6 +483,7 @@ Every dependency is version-pinned: Qt, the FrankenPHP binary URL with verified
|
|||||||
#### Workflow files
|
#### Workflow files
|
||||||
|
|
||||||
- `.gitea/workflows/ci.yml` — `quality` + `build` on every push and PR.
|
- `.gitea/workflows/ci.yml` — `quality` + `build` on every push and PR.
|
||||||
|
|
||||||
- `.gitea/workflows/release.yml` — `v*` tag triggered, depends on `build`, signs and uploads.
|
- `.gitea/workflows/release.yml` — `v*` tag triggered, depends on `build`, signs and uploads.
|
||||||
|
|
||||||
## 12. Open Questions and Risks
|
## 12. Open Questions and Risks
|
||||||
|
|||||||
@@ -31,8 +31,11 @@ qt_add_qml_module(php_qml_bridge
|
|||||||
src/SingleInstance.cpp
|
src/SingleInstance.cpp
|
||||||
src/MercureClient.h
|
src/MercureClient.h
|
||||||
src/MercureClient.cpp
|
src/MercureClient.cpp
|
||||||
|
src/ReactiveListModel.h
|
||||||
|
src/ReactiveListModel.cpp
|
||||||
QML_FILES
|
QML_FILES
|
||||||
qml/RestClient.qml
|
qml/RestClient.qml
|
||||||
|
qml/AppShell.qml
|
||||||
)
|
)
|
||||||
|
|
||||||
target_include_directories(php_qml_bridge PUBLIC src/)
|
target_include_directories(php_qml_bridge PUBLIC src/)
|
||||||
|
|||||||
89
framework/qml/qml/AppShell.qml
Normal file
89
framework/qml/qml/AppShell.qml
Normal file
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -73,18 +73,35 @@ void BackendConnection::onProbeFinished()
|
|||||||
if (!reply) return;
|
if (!reply) return;
|
||||||
reply->deleteLater();
|
reply->deleteLater();
|
||||||
|
|
||||||
|
bool ok = false;
|
||||||
if (reply->error() == QNetworkReply::NoError) {
|
if (reply->error() == QNetworkReply::NoError) {
|
||||||
const int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
|
const int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
|
||||||
if (status == 200) {
|
if (status == 200) {
|
||||||
setError(QString());
|
ok = true;
|
||||||
setState(ConnectionState::Online);
|
} else {
|
||||||
return;
|
|
||||||
}
|
|
||||||
setError(QStringLiteral("/healthz returned HTTP %1").arg(status));
|
setError(QStringLiteral("/healthz returned HTTP %1").arg(status));
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
setError(reply->errorString());
|
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)
|
void BackendConnection::setState(ConnectionState s)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
|
#include <QElapsedTimer>
|
||||||
#include <QObject>
|
#include <QObject>
|
||||||
#include <QString>
|
#include <QString>
|
||||||
#include <QtQmlIntegration>
|
#include <QtQmlIntegration>
|
||||||
@@ -37,13 +38,16 @@ public:
|
|||||||
};
|
};
|
||||||
Q_ENUM(Mode)
|
Q_ENUM(Mode)
|
||||||
|
|
||||||
/// Phase 1 surfaces only Connecting / Online / Error. The full enum
|
/// Full Update Semantics enum (PLAN.md §5).
|
||||||
/// (Reconnecting, Offline) lands with the Update Semantics layer in
|
/// - Connecting : initial state until first probe response
|
||||||
/// Phase 2 (PLAN.md §5).
|
/// - 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 {
|
enum class ConnectionState {
|
||||||
Connecting,
|
Connecting,
|
||||||
Online,
|
Online,
|
||||||
Error,
|
Reconnecting,
|
||||||
|
Offline,
|
||||||
};
|
};
|
||||||
Q_ENUM(ConnectionState)
|
Q_ENUM(ConnectionState)
|
||||||
|
|
||||||
@@ -86,6 +90,8 @@ private:
|
|||||||
QNetworkAccessManager* m_nam = nullptr;
|
QNetworkAccessManager* m_nam = nullptr;
|
||||||
QNetworkReply* m_pendingReply = nullptr;
|
QNetworkReply* m_pendingReply = nullptr;
|
||||||
QTimer* m_retryTimer = nullptr;
|
QTimer* m_retryTimer = nullptr;
|
||||||
|
QElapsedTimer m_firstFailureSinceOnline; // not started while Online
|
||||||
|
int m_offlineThresholdMs = 30000;
|
||||||
};
|
};
|
||||||
|
|
||||||
} // namespace PhpQml::Bridge
|
} // namespace PhpQml::Bridge
|
||||||
|
|||||||
471
framework/qml/src/ReactiveListModel.cpp
Normal file
471
framework/qml/src/ReactiveListModel.cpp
Normal file
@@ -0,0 +1,471 @@
|
|||||||
|
#include "ReactiveListModel.h"
|
||||||
|
|
||||||
|
#include "MercureClient.h"
|
||||||
|
|
||||||
|
#include <QJsonArray>
|
||||||
|
#include <QJsonDocument>
|
||||||
|
#include <QJsonObject>
|
||||||
|
#include <QNetworkAccessManager>
|
||||||
|
#include <QNetworkReply>
|
||||||
|
#include <QNetworkRequest>
|
||||||
|
#include <QRandomGenerator>
|
||||||
|
#include <QTimer>
|
||||||
|
#include <QUrl>
|
||||||
|
|
||||||
|
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<int, QByteArray> ReactiveListModel::roleNames() const
|
||||||
|
{
|
||||||
|
return m_roles.isEmpty()
|
||||||
|
? QHash<int, QByteArray>{{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<int, QByteArray> ReactiveListModel::deriveRoleNames(const QJsonObject& sample) const
|
||||||
|
{
|
||||||
|
QHash<int, QByteArray> 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<QNetworkReply*>(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
|
||||||
155
framework/qml/src/ReactiveListModel.h
Normal file
155
framework/qml/src/ReactiveListModel.h
Normal 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
|
||||||
@@ -63,9 +63,14 @@ ApplicationWindow {
|
|||||||
|
|
||||||
Rectangle {
|
Rectangle {
|
||||||
Layout.preferredWidth: 12; Layout.preferredHeight: 12; radius: 6
|
Layout.preferredWidth: 12; Layout.preferredHeight: 12; radius: 6
|
||||||
color: BackendConnection.connectionState === BackendConnection.Online
|
color: {
|
||||||
? "#3ab36c"
|
switch (BackendConnection.connectionState) {
|
||||||
: (BackendConnection.connectionState === BackendConnection.Error ? "#d8503c" : "#d89614")
|
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) }
|
Label { text: "Backend: " + window._stateName(BackendConnection.connectionState) }
|
||||||
|
|
||||||
@@ -125,7 +130,8 @@ ApplicationWindow {
|
|||||||
switch (s) {
|
switch (s) {
|
||||||
case BackendConnection.Connecting: return "connecting"
|
case BackendConnection.Connecting: return "connecting"
|
||||||
case BackendConnection.Online: return "online"
|
case BackendConnection.Online: return "online"
|
||||||
case BackendConnection.Error: return "error"
|
case BackendConnection.Reconnecting: return "reconnecting"
|
||||||
|
case BackendConnection.Offline: return "offline"
|
||||||
}
|
}
|
||||||
return "?"
|
return "?"
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user