Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
82a4154
docs: plan the stateful-models program from the issue #18 survey
Yaraslaut Jul 27, 2026
875e390
feat(core): keyed, shareable model instances
Yaraslaut Jul 27, 2026
dbe1ce1
feat(transport): carry keyed instance sharing over the real transports
Yaraslaut Jul 27, 2026
36796ee
feat(bank): reshape the example onto stateful, keyed models
Yaraslaut Jul 27, 2026
779bd8a
feat(core): instance subscriptions, replacing the reactive-draft mech…
Yaraslaut Jul 27, 2026
0cf74dd
docs: fold the shipped §F design into the authoritative specs
Yaraslaut Jul 27, 2026
c3dffb6
test(bank): move the payee streaming test onto instance subscriptions
Yaraslaut Jul 27, 2026
2eee489
fix(bridge): prune subscriptions of destroyed handlers on publish
Yaraslaut Jul 27, 2026
aa634d8
fix(bridge): do not keep a stale instance id when an attach fails
Yaraslaut Jul 27, 2026
f93ae51
fix: clear clang-tidy on every line this branch changes
Yaraslaut Jul 27, 2026
8d73904
docs(bridge): state that subscription fan-out is per Bridge
Yaraslaut Jul 27, 2026
38b6e2d
fix: keep the no-subscriber path free, and de-flake the switchBackend…
Yaraslaut Jul 27, 2026
bd4d271
test: cover result-sourced primary keys, which shipped untested
Yaraslaut Jul 27, 2026
2bd38d1
test(net): cover keyed instance sharing over a real socket
Yaraslaut Jul 27, 2026
8131cb3
docs: record the stateful-models program in the changelog
Yaraslaut Jul 27, 2026
8ab6bca
docs(shared_instances): document the assign verb, correct the change …
Yaraslaut Jul 27, 2026
e304cad
test: cover the directory's collision and no-op paths
Yaraslaut Jul 27, 2026
b7da593
test(remote): cover the server side of the shared instance directory
Yaraslaut Jul 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,54 @@ API surface).
(`scripts/check_deprecated_markers.sh`) enforcing that every
`[[deprecated("...")]]` marker names a replacement and a target removal
version.
- **Keyed, shareable model instances.** A model declares a nested `PrimaryKey`
alias; actions declare which field carries it (`BRIDGE_KEY_FROM`) or that
their result establishes it (`BRIDGE_KEY_FROM_RESULT`).
`BridgeHandler<M, AllowShared>` joins a server-side directory keyed on
`(typeId, primary)`, so handlers in one process — or in two clients over one
`RemoteServer` — reach the same instance. Adds `attach()`, `primary()` and
`instances()` to the handler, the `primary`/`shared` envelope fields and the
`attach`/`assign`/`instances` wire kinds, all additive. See
`docs/spec/core/shared_instances.md`.
- **Instance subscriptions.** `BridgeHandler::subscribe<R>(cb)`, keyed on the
result/state type, fires whenever an `R` is produced on the instance the
handler is attached to — by any handler attached to it. Fan-out is per
`Bridge`; there is no server-initiated push. See
`docs/spec/core/bridge.md#subscription-semantics`.

### Changed

- **`RemoteServer::closeConnection` now releases one reference per attachment
rather than erasing every model in the scope.** Required by cross-client
instance sharing: otherwise one client's disconnect destroys an instance
another client is still attached to. A connection scope records an
attachment *count* per instance for the same reason. Unshared instances have
exactly one attacher, so their lifetime is unchanged.
- `examples/bank` is reshaped onto stateful, keyed models: `AccountModel` holds
one account in memory keyed by account id, and the new `CustomerModel` takes
the per-owner half (`ListAccounts`/`OpenAccount`).

### Removed

- **The reactive-draft mechanism** — `BridgeHandler::set<&A::field>`,
`reset<A>`, the action-keyed `subscribe<A>`, and their in-flight coalescing.
Its job is done better by a stateful model holding the draft itself, and
`subscribe` now means instance subscriptions (above). `morph::flows::FlowSession`
already owned its own draft tuple and now gates on `ActionValidator` and
dispatches directly; its public API, the `w-*`/`app-*` schema and
`WizardView.qml` are unchanged. `ActionValidator` keeps its server-side
validation role and loses only its draft-readiness one. Pre-1.0, per
`docs/spec/VERSIONING.md`.

### Fixed

- `CustomerModel::execute(ListAccounts)` dereferenced `QuerySingle`'s optional
unchecked (inherited verbatim from the old `AccountModel`); it now throws
`NotFound`.
- A flaky assertion in `tests/test_concurrency_invariants.cpp`: `succeeded > 0`
during backend churn is a scheduling race, not an invariant — under a
thread-serialising tool the switcher can cancel every in-flight call. The
same structural property is now asserted against the quiesced bridge.
- Stale pre-JSON "5-part"/"6-part protocol" wording in `docs/ARCHITECTURE.md`
and a test comment — the wire has been a JSON `Envelope` since it superseded
the pipe-delimited protocol.
25 changes: 16 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ and the call site above are unchanged.
`Bridge` and share it.
- **`BridgeHandler<M>`** — your typed, GUI-facing handle to one model type `M`.
It registers `M` on the bridge on construction and deregisters on destruction
(RAII). This is the object you call `.execute(...)` / `.subscribe<A>(...)` on.
(RAII). This is the object you call `.execute(...)` / `.subscribe<R>(...)` on.
Create one per model type, wherever in the UI you need to talk to that model.

## Multiple models across multiple files
Expand Down Expand Up @@ -382,14 +382,19 @@ morph is a young, actively developed library with thorough test coverage of its
core. It is honest about the following boundaries — read the per-subsystem specs
in [`docs/spec/`](docs/spec) before relying on any of these in production:

- **Security is app-supplied.** The wire protocol has no version negotiation,
no message-size or timeout bounds, and no built-in authentication; `Context`
identity is unauthenticated and `RemoteServer` model ids are guessable
sequential integers with control messages unauthorized. `RemoteServer`
assumes a trusted, authenticated transport — it is not a hardened
public-internet server as shipped.
- **Security is app-supplied.** There is no built-in authentication: `Context`
identity is whatever the client claims until an `IAuthorizer` verifies it, and
the default authorizer allows everything. Protocol version negotiation,
message-size and timeout bounds, opaque model ids, and register/per-instance
authorization hooks all ship (see `docs/spec/security.md`), but they are
**opt-in** — a server that configures none of them assumes a trusted,
authenticated transport and is not a hardened public-internet server.
- **`Completion<T>` is a leaf callback primitive**, not a composable future: one
handler per outcome, no `T→U` chaining, no `co_await`, no cancellation.
- **Instance subscriptions are best-effort and in-process.** `subscribe<R>`
fans out to handlers on the same `Bridge`; there is no server-initiated push,
so two separate clients sharing an instance do not see each other's results
until they ask again. No replay, no durability, no coalescing.
- **Exact numbers are fixed-width.** `Rational` is an `int64` pair; `+`/`-`/`*`
can overflow (undefined behaviour) rather than returning an error, and high
decimal precision shrinks the representable magnitude. Wire input is *clamped*,
Expand All @@ -399,8 +404,10 @@ in [`docs/spec/`](docs/spec) before relying on any of these in production:
the store/log divergence gap by opting into `IModelHolder::setOutboxManaged` +
`journal::OutboxRelay` (see `docs/spec/journal/journal.md`); a model that
doesn't opt in keeps the default fire-after-success append.
- **Offline durability is bring-your-own.** Only an in-memory queue ships; the
crash-safety story depends on a durable queue you implement.
- **Offline durability is opt-in.** `FileOfflineQueue` (NDJSON, always built)
and `SqliteOfflineQueue` (`MORPH_BUILD_OFFLINE_SQLITE`) both persist across
restarts; the in-memory queue remains the default, so crash-safety depends on
selecting a durable one.
- **Registration is global and macro-driven** (per-TU, static-init, string type
ids); there is no runtime deregistration and unknown ids fail at runtime, not
compile time.
Expand Down
67 changes: 33 additions & 34 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -310,7 +310,7 @@ Because these are mutually exclusive per topology, recording is automatically se

**`Loggable`** (`morph::model::Loggable::{Yes,No}`) is a strong-typed opt-out on the existing `BRIDGE_REGISTER_ACTION` macro (an optional 4th argument; no separate registration macro). Default is `Yes` — every action is recorded unless explicitly marked `Loggable::No` (typically pure queries like `GetAccount`/`ListAccounts`). Hand-written `ActionTraits` specialisations that predate this member (as used in several tests) are unaffected: `morph::model::detail::actionLoggable<A>()` defaults to `Yes` when the member is absent, via a `HasLoggableFlag` concept exactly like `ActionValidator`'s `HasValidate`.

**`ActionLogPolicy<A>::coalesce`** (default `false`) decides whether repeated executions of the same action against the same entity should collapse to the latest occurrence at a checkpoint, or whether every occurrence is a distinct, permanent fact. This matters because the fielded/reactive `set<...>` mechanism (see "Subscriptions and fielded actions" below) can already fire the same action many times in a row — without coalescing, every keystroke-driven re-fire would become a permanent log entry. `false` is correct for anything resembling a business event (a deposit); `true` is for drafts/settings where only the final value matters.
**`ActionLogPolicy<A>::coalesce`** (default `false`) decides whether repeated executions of the same action against the same entity should collapse to the latest occurrence at a checkpoint, or whether every occurrence is a distinct, permanent fact. This matters because a form driving one action per edit can fire the same action many times in a row — without coalescing, every keystroke-driven re-fire would become a permanent log entry. `false` is correct for anything resembling a business event (a deposit); `true` is for drafts/settings where only the final value matters.

**`SessionLog`** (`journal.hpp`) is where coalescing actually happens. It keeps full, uncoalesced history in memory (the raw material for `undoLast()`), and `checkpoint(durableSink)` reduces everything appended since the last checkpoint by `(modelType, entityKey, actionType)` — keeping only the latest entry where `coalesce == true`, every entry otherwise — before forwarding the reduced set to the real sink. `undoLast()` needs no inverse operations: it drops the most recent entry and calls `journal::replay()` over what remains, reusing the same `ActionDispatcher`/`ModelRegistryFactory` `RemoteServer` already relies on for dispatch. This is not a workaround — a model's entire state genuinely is "initial state plus its ordered actions replayed," so reconstructing it by replay is the direct statement of that fact, not a special case.

Expand Down Expand Up @@ -412,52 +412,51 @@ handler.execute(MyAction{21})
.onError([](std::exception_ptr e) { /* runs on GUI thread */ });
```

## Subscriptions and fielded actions
## Instance subscriptions

The framework offers two complementary surfaces for invoking actions from the GUI:
The framework offers two complementary surfaces for talking to a model:

1. **One-shot**: `handler.execute(action) → Completion<R>`. The full action is built in the GUI and sent in a single call. Suitable for actions that fire on a button click ("delete this order", "submit form").
2. **Fielded / reactive**: `handler.subscribe<A>(cb)` + `handler.set<&A::field>(value)`. Field values stream into a client-side draft, a per-action validator decides when the draft is ready, and the framework dispatches `model.execute(draft)` and pushes the result to the subscriber. Suitable for forms where each widget edits one field and the GUI should respond live as the user types.
1. **One-shot**: `handler.execute(action) -> Completion<R>`. The action is built
at the call site and dispatched in one call.
2. **Observing**: `handler.subscribe<R>(cb)` fires whenever an `R` is produced
on *the instance this handler is attached to* — by this handler, by another
handler sharing that instance, or by another screen entirely. Suitable for a
view that renders some model state and must stay current when anything
changes it.

### API

```cpp
// Per-action validator — template specialisation, no Model coupling.
template <typename A>
struct morph::model::ActionValidator {
static bool ready(const A&) noexcept { return true; } // default: one-shot
};
A subscription names the **result/state type**, not an action. The subscriber
describes what it renders rather than what somebody else must call to produce
it, so adding an action that also yields an `R` never breaks an existing
subscriber.

// User specialises (or uses the macro) for actions with fielded readiness.
BRIDGE_REGISTER_VALIDATOR(FormAction, [](const FormAction& a) {
return a.a != 0.0 && a.b != 0.0 && a.c != 0.0;
})
```
### API

```cpp
morph::bridge::BridgeHandler<FormModel> handler{bridge, &guiExec};
morph::bridge::BridgeHandler<AccountModel, morph::bridge::AllowShared> screen{bridge, guiExec};
morph::bridge::BridgeHandler<AccountModel, morph::bridge::AllowShared> sidebar{bridge, guiExec};

handler.subscribe<FormAction>([](double sum) { renderTotal(sum); });
screen.attach(42);
sidebar.attach(42); // same instance

handler.set<&FormAction::a>(3.0);
handler.set<&FormAction::b>(5.0);
handler.set<&FormAction::c>(7.0); // validator passes → execute → callback fires
screen.subscribe<AccountInfo>([](AccountInfo info) { renderBalance(info); });

handler.unsubscribe<FormAction>();
handler.reset<FormAction>();
sidebar.execute(Deposit{.amountMinor = 5000});
// -> screen's callback runs: it never had to know Deposit exists
```

### Behavior

| Aspect | Default |
|---|---|
| **Validator default** | `ActionValidator<A>::ready` returns `true` for any action without a specialisation. First `set<>` triggers a fire. |
| **Re-fire** | Every `set<>` that lands a `ready()==true` state dispatches the action again — live recomputation. The draft persists between fires. |
| **Draft persistence** | Drafts survive successive fires, `unsubscribe`, and `Bridge::switchBackend`. Destroyed with the handler or via `reset<A>()`. |
| **In-flight coalescing** | If patches land while a previous execute is in flight, exactly one re-fire is queued for when it completes — running with the latest draft snapshot. Further patches during the same flight collapse into that single pending re-fire. Matches typical reactive-UI behaviour. |
| **Subscriber cardinality** | One subscriber per `(handler, Action type)`. `subscribe<A>(cb)` replaces any prior callback. |
| **No-subscriber fire** | If `set<>` triggers an execute but no subscriber is installed, the action still runs and the result is silently dropped. |
| **Subscription thread** | Callbacks always run on the executor passed at handler construction (`guiExec`). |
| **Keying** | On the result type `R`. Any action producing an `R` notifies. |
| **Scope** | The instance the handler is currently attached to. Matched at publish time, so a subscription follows a re-pointed handler. |
| **Subscriber cardinality** | One callback per `(handler, R)`. `subscribe<R>(cb)` replaces any prior callback. |
| **Echo** | The originating handler is notified too — no "was this mine" bookkeeping in subscribers. |
| **Failures** | A failed action notifies nobody. |
| **Ordering** | Per instance, guaranteed by that instance's strand. Nothing is guaranteed between instances. |
| **Durability** | None. Best-effort and unbuffered: no replay, no cursor, no coalescing. |
| **Callback thread** | Always the executor passed at handler construction. |


## Exact values, units, and schema-driven forms

Expand Down Expand Up @@ -546,7 +545,7 @@ required empty-capable field is engaged" — `Quantity`, `Choice`, `Timestamp`,
or any type with a `hasValue()`) intended as the body of the action's
`validate()` — which the existing `ActionValidator` resolution picks up
automatically. One declaration then drives the schema's `required` array, the
client-side submit gate, and the fielded-action readiness check.
client-side submit gate, and the server-side readiness check.

### `morph::time::Timestamp` and `morph::forms::Choice` — dates and combo boxes

Expand Down Expand Up @@ -703,7 +702,7 @@ documented behavior.
| Glaze for JSON | Reflects aggregate types automatically; no hand-written serialisation per action. |
| `CompletionState<T>` internal only | Keeps the public API free of state-handling machinery; implementation can change without breaking callers. |
| JSON `Envelope` wire protocol | Self-describing and forward-compatible (unknown keys ignored); carries a `callId` so async WebSocket replies can be correlated back to pending `Completion` objects. |
| Client-side drafts for fielded actions | Avoids new wire messages, server-side draft state, and a server push channel. Patches never leave the client; only the full action is sent when the validator passes. |
| Subscriptions keyed on the result type, scoped to the instance | A subscriber is a renderer: it names the state it draws, not the actions that produce it, so a new action never breaks it. Scoping to the instance is what lets two screens on one shared model see each other's work without a query-invalidation vocabulary. |
| `ActionValidator<A>` is action-typed, not model-typed | Different actions on the same model have different readiness requirements; pinning the predicate to the action keeps GUI code oblivious to model internals. |
| `set<auto FieldPtr>(value)` over `set<Action>(&Action::f, value)` | Member-pointer NTTP encodes both the action type and the field type; the call site stays terse without losing type safety. |
| `Rational` wire codec canonicalises on read | Wire input is untrusted; every deserialised value passes the reducing constructor, so invariants hold no matter what a client sends. |
Expand Down
Loading
Loading