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>
This commit is contained in:
163
docs/reactive-models.md
Normal file
163
docs/reactive-models.md
Normal file
@@ -0,0 +1,163 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user