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>
164 lines
7.2 KiB
Markdown
164 lines
7.2 KiB
Markdown
# Reactive models
|
|
|
|
The QML side has two reactive primitives that wrap a Mercure-fed REST endpoint into a Qt model: `ReactiveListModel` for collections, `ReactiveObject` for single entities. They are what makes the headline `make:bridge:resource` workflow turn into a live UI without any handwritten cross-side glue.
|
|
|
|
For the wire-level details — dual-publish topics, version counter, `correlationKey` — see [Update semantics](update-semantics.md).
|
|
|
|
## `ReactiveListModel`
|
|
|
|
`QAbstractListModel` subclass that:
|
|
|
|
1. Issues a `GET <source>` once `BackendConnection.connectionState === Online`.
|
|
2. Subscribes to `<topic>` over Mercure SSE.
|
|
3. Applies events to the local rows.
|
|
4. Exposes a `pending` role for optimistic mutations.
|
|
|
|
```qml
|
|
import PhpQml.Bridge
|
|
|
|
ReactiveListModel {
|
|
id: todoModel
|
|
baseUrl: BackendConnection.url
|
|
token: BackendConnection.token
|
|
source: "/api/todos" // initial GET + mutation endpoint
|
|
topic: "app://model/todo" // collection Mercure topic
|
|
}
|
|
|
|
ListView {
|
|
model: todoModel
|
|
delegate: ItemDelegate {
|
|
required property string id
|
|
required property string title
|
|
required property bool done
|
|
required property bool pending // ← provided by the model
|
|
opacity: pending ? 0.5 : 1.0
|
|
// …
|
|
}
|
|
}
|
|
```
|
|
|
|
### Properties
|
|
|
|
| Property | Type | Notes |
|
|
| --- | --- | --- |
|
|
| `baseUrl` | string | Usually `BackendConnection.url`. |
|
|
| `token` | string | Bearer token. Reads `BackendConnection.token`. |
|
|
| `source` | string | REST path, e.g. `/api/todos`. Initial GET hits this; mutations append `/<id>`. |
|
|
| `topic` | string | Mercure topic. Convention: `app://model/<resource-name>`. |
|
|
| `idField` | string | Defaults to `id`. Override if your resource keys on `slug`, `uuid`, etc. |
|
|
| `ready` | bool | `true` once the initial GET has populated the model. Useful for empty-state UI. |
|
|
|
|
### Roles
|
|
|
|
Every entity in the response becomes a row. Each entity field becomes a role of the same name. On top of that the model adds:
|
|
|
|
- **`pending`** — `true` while an optimistic mutation against this row is in flight.
|
|
|
|
### Methods
|
|
|
|
```qml
|
|
todoModel.invoke(method, urlSuffix, body, optimistic)
|
|
```
|
|
|
|
| Arg | Description |
|
|
| --- | --- |
|
|
| `method` | `"POST"`, `"PATCH"`, `"DELETE"`, … |
|
|
| `urlSuffix` | Appended to `source`. Pass `""` for collection-level mutations (e.g. POST), `"/" + id` for entity-level. |
|
|
| `body` | JS object — serialised as JSON. Pass `null` for verbs without a body. |
|
|
| `optimistic` | `{ op: "upsert"|"delete", id, data? }` — the local patch applied before the HTTP call returns. |
|
|
|
|
`invoke()` mints a uuidv7 `Idempotency-Key` and returns it. The header round-trips into Mercure as `correlationKey` so the model can reconcile its in-flight optimistic patch against the server echo.
|
|
|
|
### Signals
|
|
|
|
| Signal | Fired on |
|
|
| --- | --- |
|
|
| `commandFailed(key, status, problem)` | HTTP failure. Local optimistic patch already rolled back; `problem` is the parsed Symfony Problem-Details JSON if available. |
|
|
| `commandTimedOut(key)` | HTTP succeeded but Mercure echo never arrived within 5 s. Model has re-fetched. |
|
|
|
|
### Event handling
|
|
|
|
When a Mercure event lands on `<topic>`:
|
|
|
|
```json
|
|
{ "op": "upsert", "id": "01HX…", "data": { "title": "buy milk", "done": false }, "correlationKey": "…", "version": 42 }
|
|
```
|
|
|
|
- `upsert` — find by `idField`, update existing row or append a new one.
|
|
- `delete` — find by `idField`, remove the row.
|
|
- `correlationKey` matches an in-flight `Idempotency-Key` → reconcile + clear `pending`.
|
|
- `version` skips by more than 1 → drop the model and re-fetch.
|
|
|
|
`op` and `data` shape are intentionally identical between the events you receive and the optimistic patches you supply, so adding new mutation types is mostly a backend concern.
|
|
|
|
## `ReactiveObject`
|
|
|
|
Single-entity twin. Wraps a `QQmlPropertyMap` so QML accesses fields as plain properties.
|
|
|
|
```qml
|
|
ReactiveObject {
|
|
id: currentTodo
|
|
baseUrl: BackendConnection.url
|
|
token: BackendConnection.token
|
|
source: "/api/todos/" + selectedId // initial GET
|
|
topic: "app://model/todo/" + selectedId
|
|
}
|
|
|
|
Label { text: currentTodo.fields.title || "(loading)" }
|
|
CheckBox { checked: currentTodo.fields.done || false }
|
|
```
|
|
|
|
### Properties
|
|
|
|
| Property | Type | Notes |
|
|
| --- | --- | --- |
|
|
| `baseUrl` / `token` / `source` / `topic` | as above | But `source` is the entity URL (`/api/todos/<id>`) and `topic` is the entity topic (`app://model/todo/<id>`). |
|
|
| `fields` | `QQmlPropertyMap` | Dynamic — fields appear under their JSON names. |
|
|
| `ready` | bool | Initial GET completed. |
|
|
| `pending` | bool | Optimistic mutation in flight. |
|
|
|
|
### Methods
|
|
|
|
```qml
|
|
currentTodo.patch({ done: true })
|
|
currentTodo.remove()
|
|
```
|
|
|
|
Both behave like `ReactiveListModel.invoke()` — optimistic local mutation, HTTP request, Mercure reconciliation.
|
|
|
|
## Choosing between them
|
|
|
|
- **List view, table, grid** — `ReactiveListModel`.
|
|
- **Detail view of one entity** — `ReactiveObject`. Doesn't pull the entire collection over the wire.
|
|
- **Both at once** (master/detail) — instantiate one of each. They use *different* Mercure topics, so a list update doesn't churn the detail subscription and vice-versa. The dual-publish is exactly to avoid this trade-off.
|
|
|
|
If your detail view shows fields that aren't in the list payload (e.g. a heavy `description` field), `ReactiveObject`'s entity-topic subscription gets the full record on every change without affecting list-view subscribers.
|
|
|
|
## Mercure dual-publish
|
|
|
|
Every `#[BridgeResource]` mutation publishes to two topics. From the PHP side this is invisible — `ModelPublisher::publishUpdate($entity)` dispatches both events with the same payload, the same `correlationKey`, and a shared incremented `version`.
|
|
|
|
```
|
|
postPersist | postUpdate | postRemove
|
|
│
|
|
▼
|
|
ModelPublisher::publishUpdate
|
|
│
|
|
├──▶ Mercure: app://model/<name> (collection event)
|
|
└──▶ Mercure: app://model/<name>/<id> (entity event)
|
|
```
|
|
|
|
Why both? A `ReactiveListModel` shouldn't process events about other resources, and a `ReactiveObject` shouldn't process events about *other instances* of its resource. Filtering at subscribe time is what Mercure topics are for; the cost is one extra publish per mutation.
|
|
|
|
## Anti-patterns
|
|
|
|
- **Driving the model from a custom mutation path that bypasses `invoke()`.** You lose `pending`, `correlationKey` reconciliation, and rollback. If your HTTP call doesn't fit the `invoke()` shape, do the call manually but don't mutate the model directly — let the Mercure echo do it.
|
|
- **Reusing one `ReactiveListModel` across windows.** Each instance owns its Mercure subscription; sharing means a closed window's subscription stays alive until the host exits. Spawn one per window; they auto-cohere via the server.
|
|
- **Sub-classing the model in QML to add fields.** Don't — the model is dynamic. Add the field to the entity, regenerate the migration, and the field appears as a role automatically.
|
|
|
|
## See also
|
|
|
|
- [Update semantics](update-semantics.md) — the wire-level rules these models implement.
|
|
- [Makers](makers.md) — how `make:bridge:resource` produces a starter `<Name>List.qml` already wired up.
|
|
- [QML API reference](qml-api.md#reactivelistmodel) — exhaustive property/method list.
|