130 lines
8.3 KiB
Markdown
130 lines
8.3 KiB
Markdown
|
|
# 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](../PLAN.md#5-update-semantics-the-hard-problem); 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`.
|
||
|
|
|
||
|
|
```qml
|
||
|
|
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:
|
||
|
|
|
||
|
|
```json
|
||
|
|
{ "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-Key`s 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.cpp` — `invoke()`, `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
|
||
|
|
|
||
|
|
- [Reactive models](reactive-models.md) — the QML models in detail.
|
||
|
|
- [Bundled mode](bundled-mode.md) — token rotation across supervisor restarts.
|
||
|
|
- [PLAN.md §5](../PLAN.md#5-update-semantics-the-hard-problem) — design rationale, including alternatives that didn't make it.
|