Files
php-qml/docs/update-semantics.md
magdev da048434b8 docs: rewrite README + add comprehensive docs/
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>
2026-05-02 22:18:37 +02:00

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 OK from /healthz. The default AppShell shows 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. AppShell shows 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). AppShell overlays a modal with a Retry button that calls BackendConnection.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:

  1. Apply the optimistic mutation to the local model immediately. The mutated row(s) get their pending role flipped to true so QML can drop opacity / disable controls.
  2. Send the HTTP request with a fresh Idempotency-Key (uuidv7) in the header.
  3. Listen on the Mercure topic for the matching correlationKey. When it arrives, the local copy is reconciled with the server state and pending flips back to false.
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.tokenRotated fires; ReactiveListModel and MercureClient pick 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, MercureClient resubscribes with Last-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.cppinvoke(), applyEvent(), pending role, 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