README is now tight and link-heavy: 60-second tour, then deep links into docs/. The wall of detail moved out. docs/ covers the framework end-to-end: - getting-started.md — prerequisites by distro (Tumbleweed, Fedora, Debian/Ubuntu, Arch), full first-run walkthrough, troubleshooting. - architecture.md — process pair, transport, dev/bundled mode. - update-semantics.md — state machine + optimistic mutations + key round-tripping. - reactive-models.md — ReactiveListModel, ReactiveObject, Mercure dual-publish. - makers.md — make:bridge:resource/command/window. - dev-workflow.md — hot reload (PHP + QML), dev console, editor configs, bridge:doctor, snapshot/integration test loops, perfsmoke. - bundled-mode.md — supervisor, per-session secret rotation, first-launch migrations, auto-update wiring. - packaging-linux.md — make appimage, build-appimage.sh CLI, AppImageUpdate sidecar, perfsmoke budgets, release CI, bundle-size breakdown. - qml-api.md / php-api.md — exhaustive symbol reference with all Q_PROPERTY/Q_INVOKABLE/signals + every public PHP service / attribute / command. - configuration.md — every env var (host, Symfony, dev script, packaging script, perfsmoke), every CLI flag (php-qml-init, build-appimage.sh), make targets, default ports/paths. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
8.3 KiB
Update semantics
How the QML side stays in sync with the backend through happy path, in-flight mutations, transient failures, and outright disconnects. This is the most subtle part of the framework — every other doc references this one.
The model is laid out in PLAN.md §5; this page describes the shipping behaviour.
Connection state machine
BackendConnection.connectionState exposes one of four values:
┌─ probe OK ──┐
Connecting │ ▼
│ └──────── Online ───── probe fails ────► Reconnecting
│ ▲ │
│ │ │
│ └── probe OK ────────────────────┤
│ │
│ 30s + boot grace ▼
└───────────────► Offline ◀───────────────────── (still failing)
- Connecting — initial probe in flight, before the first
200 OKfrom/healthz. The defaultAppShellshows the user's content with no chrome. - Online — last probe was
200. Mutations are enabled. - Reconnecting — at least one probe failed since the last success.
AppShellshows an orange banner. UI is still interactive; mutations stay enabled. - Offline — failures persisted longer than
m_offlineThresholdMs(30 s by default; +10 s "boot grace" in bundled mode while the supervisor restarts).AppShelloverlays a modal with a Retry button that callsBackendConnection.restart().
The probe fires every 5 s (kProbeIntervalMs) and times out at 2 s (kProbeTimeoutMs). On Online → Reconnecting the timer doesn't change; on the first failure we just start counting toward the 30 s threshold.
Why no auto-Offline → Connecting
Once we're Offline the only way out is restart() — either via the AppShell button or programmatically. We don't keep retrying forever: a long-Offline app eats CPU on probes and confuses users with phantom "back online" flips when the network blips.
Optimistic mutations
ReactiveListModel.invoke(method, url, body, optimistic) runs three things in parallel:
- Apply the optimistic mutation to the local model immediately. The mutated row(s) get their
pendingrole flipped totrueso QML can drop opacity / disable controls. - Send the HTTP request with a fresh
Idempotency-Key(uuidv7) in the header. - Listen on the Mercure topic for the matching
correlationKey. When it arrives, the local copy is reconciled with the server state andpendingflips back tofalse.
ReactiveListModel {
id: todoModel
source: "/api/todos"
topic: "app://model/todo"
}
CheckBox {
onToggled: {
todoModel.invoke(
"PATCH", "/api/todos/" + id,
{ done: checked },
{ op: "upsert", id: id, data: { id: id, title: title, done: checked } }
)
}
}
The 4th arg is the optimistic patch. It uses the same {op, id, data} shape that Mercure events carry, so the model can apply it through the same code path that handles real events.
What can go wrong
| Failure | What the model does |
|---|---|
| HTTP returns 4xx / 5xx | Roll back the optimistic patch, emit commandFailed(key, status, problem). |
| HTTP succeeds but Mercure echo never arrives | After 5 s the model emits commandTimedOut(key) and re-fetches the resource so the local state catches up to whatever happened server-side. |
| Mercure echo arrives without a matching in-flight key | Treat as a regular state push — this is the path used when another window mutates the same resource. |
| Network drops mid-mutation | The probe machinery flips state to Reconnecting. The mutation's HTTP request will eventually time out (60 s default Qt transferTimeout) and roll back. The user can retry once Online resumes. |
The pending row role is the QML side's signal to indicate something is happening, don't let me click again. Most apps use it to dim the row and disable nested controls — see examples/todo/qml/Main.qml for the canonical pattern.
Idempotency keys & correlation keys
Same value, different name on each side:
| Side | Name | Where |
|---|---|---|
| HTTP request | Idempotency-Key header |
Set by ReactiveListModel.invoke() |
| Symfony controller | $request->headers->get('Idempotency-Key') |
Available to your action if you need it |
CorrelationKeyListener |
Stores it on the CorrelationContext |
Reads the request header into a request-scoped service |
ModelPublisher |
Reads from CorrelationContext, sets on every Update |
Auto-applied to dual-publish events |
| Mercure event | correlationKey field |
What ReactiveListModel matches against |
| QML side | key arg to commandFailed, commandTimedOut |
Lets the app correlate UI feedback to its in-flight call |
If your controller does multiple persists (e.g. a MarkAllDone command that flips many rows), every triggered Mercure event carries the same correlationKey. The model treats that as one logical mutation completing — it clears pending on every row that matches.
Versioning & dropped events
Each #[BridgeResource] carries a monotonically increasing version counter persisted in bridge_resource_version. ModelPublisher writes the next version on every event:
{ "topic": "app://model/todo", "version": 42, "data": { … } }
ReactiveListModel tracks the highest version seen. If the next event jumps by more than 1, the client knows it dropped events somewhere — most likely a transient SSE disconnect — and triggers a full re-fetch. After the re-fetch the version high-water-mark is reset to whatever the server's current value is.
This is the only mechanism that prevents a stale model after a partial network blip. It's deliberately simple: we don't replay missed events, we just resync.
Multi-window coherence
Two windows of the same app each instantiate their own ReactiveListModel pointing at the same topic. They each:
- Run their own initial GET.
- Run their own Mercure subscription.
- Apply each event independently.
They stay coherent because the server state is one source of truth and Mercure broadcasts every change to every subscriber. There is no inter-window IPC and no shared model in the host.
Optimistic mutations mutate only the window that issued them. The other window sees the change a few ms later when the Mercure echo arrives — pending never lit up there.
Edge cases
- Bundled-mode FrankenPHP crash mid-mutation. The supervisor restarts FrankenPHP with a fresh per-session token.
BackendConnection.tokenRotatedfires;ReactiveListModelandMercureClientpick up the new token on their next request. The interrupted mutation rolls back when its HTTP call eventually fails, then the Mercure stream reconnects and the model is in the right state. - Two clients send conflicting optimistic mutations. Both succeed locally; both
Idempotency-Keys round-trip; both rows reconcile to whatever the server actually persisted. Last-writer-wins at the database; both client UIs converge. - Mercure subscription drops while Offline. When the connection state flips back to Online,
MercureClientresubscribes withLast-Event-ID(Mercure's standard SSE replay) so it gets the events that fired during the gap. If the version counter still skips, the model re-fetches.
Where to look in the code
framework/qml/src/ReactiveListModel.cpp—invoke(),applyEvent(),pendingrole, version-skip detection.framework/qml/src/BackendConnection.cpp— probe loop, state transitions, threshold logic.framework/php/src/ModelPublisher.php— dual-publish,correlationKey, version increment.framework/php/src/EventSubscriber/CorrelationKeyListener.php— request → context plumbing.
See also
- Reactive models — the QML models in detail.
- Bundled mode — token rotation across supervisor restarts.
- PLAN.md §5 — design rationale, including alternatives that didn't make it.