Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 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
e78e9a3
feat(core): declare a model's key without touching the model class
Yaraslaut Jul 28, 2026
e094575
fix(remote): do not let maxLiveModels refuse a pure attach
Yaraslaut Jul 28, 2026
fd02245
fix(docs): reference the shared-instances spec the way ARCHITECTURE.m…
Yaraslaut Jul 28, 2026
9f00d7e
test: close the remaining reachable gaps in the shared-instance paths
Yaraslaut Jul 28, 2026
77ef200
fix(remote): gate assign behind the same authorizer check as attach/r…
Yaraslaut Aug 3, 2026
b1485bd
fix(bridge): check liveness before touching the bridge in executeVia'…
Yaraslaut Aug 3, 2026
8c96ec0
docs(bridge): document executeVia's liveness-gated result handling
Yaraslaut Aug 3, 2026
da25faa
test(bridge): strengthen FIX 5 regression coverage per review
Yaraslaut Aug 3, 2026
1bb4ceb
test(bridge): make the hasSubscribers() probe a deterministic guard-p…
Yaraslaut Aug 3, 2026
2c7a7c9
fix(remote): deregister releases the requesting connection's own scop…
Yaraslaut Aug 3, 2026
3f293df
fix(wire): carry contextKey on attach, not only register
Yaraslaut Aug 3, 2026
e5d3c12
fix(bridge): route BridgeHandler::execute's attach failures through o…
Yaraslaut Aug 3, 2026
19a2c70
fix(core): make attachModel/the server's attach re-point atomic (acqu…
Yaraslaut Aug 3, 2026
2cdd1b7
fix(bridge): give shared-handler attach/register/assign a dedicated m…
Yaraslaut Aug 3, 2026
2adebd4
fix(core): forbid assignPrimary from re-keying an already-keyed share…
Yaraslaut Aug 3, 2026
393b067
feat(core): release a freshly created shared instance whose first act…
Yaraslaut Aug 3, 2026
2814b99
test(shared-instances): assert the second attacher lands on a differe…
Yaraslaut Aug 3, 2026
841bde1
docs(shared_instances): document the empty-string primary key limitation
Yaraslaut Aug 3, 2026
9857490
fix(remote): make acquireSharedInstance's directory-miss re-point atomic
Yaraslaut Aug 3, 2026
91de103
fix(remote): rename dispatchExecute's lambda-local mid to avoid shado…
Yaraslaut Aug 3, 2026
43012dc
docs(bridge): narrow assignHandlerPrimary's doc comment to its actual…
Yaraslaut Aug 3, 2026
09034f0
docs(shared-instances): correct the poisoning failure-mode description
Yaraslaut Aug 3, 2026
cc58ab6
docs(wire): stop saying contextKey is register-only in the reference …
Yaraslaut Aug 3, 2026
d056abc
fix(tests): suppress a clang-only false-positive warning in the guard…
Yaraslaut Aug 3, 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
47 changes: 47 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,56 @@ API surface).
(`scripts/check_deprecated_markers.sh`) enforcing that every
`[[deprecated("...")]]` marker names a replacement and a target removal
version.
- **Keyed, shareable model instances.** One `BRIDGE_MODEL_KEY(Model, Action,
&Action::field)` beside the existing registrations designates the action that
defines a model's key and deduces the key type from that field, so the model's
own class body says nothing about keys; further actions carrying the same key
use `BRIDGE_KEY_FROM`, and creating actions use the `…_FROM_RESULT` variants.
`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.
81 changes: 71 additions & 10 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 @@ -196,6 +196,60 @@ out of scope it cleanly deregisters itself.
> locally via a capturing factory will fail at `register` time in remote mode if
> it isn't macro-registered.

## Shared model instances

By default each `BridgeHandler` owns its own model instance — two handlers for
`AccountModel` are two independent objects. When several screens should be
looking at *the same* account, give the model a **primary key** and opt the
handlers into sharing. The model class itself does not change:

```cpp
struct AccountModel { // still a plain C++ class
AccountInfo execute(const LoadAccount&); // ← names the account
Balance execute(const Deposit&); // ← keyless
Balance execute(const GetBalance&); // ← keyless
};

BRIDGE_REGISTER_MODEL (AccountModel, "AccountModel")
BRIDGE_REGISTER_ACTION(AccountModel, LoadAccount, "LoadAccount")
BRIDGE_REGISTER_ACTION(AccountModel, Deposit, "Deposit")
BRIDGE_REGISTER_ACTION(AccountModel, GetBalance, "GetBalance")

// One line: deduces the key type from the field, and marks LoadAccount as the
// action that carries it.
BRIDGE_MODEL_KEY(AccountModel, LoadAccount, &LoadAccount::id);
```

Attachment is then automatic — a key is named exactly once, and everything
afterwards follows the handler:

```cpp
using morph::bridge::AllowShared;
morph::bridge::BridgeHandler<AccountModel, AllowShared> screen {bridge, &guiExecutor};
morph::bridge::BridgeHandler<AccountModel, AllowShared> sidebar{bridge, &guiExecutor};

screen .execute(LoadAccount{.id = 32}); // no instance for 32 yet → constructs one
sidebar.execute(LoadAccount{.id = 32}); // 32 is live → attaches, constructs nothing

screen .execute(Deposit{.amountMinor = 100}); // keyless → instance 32
sidebar.execute(GetBalance{}); // keyless → instance 32, sees the 100
```

- **The directory is server-side**, so with a remote backend two *clients* also
meet on one instance, not just two handlers in one process.
- **Lifetime is refcounted**: the instance lives until the last handler attached
to it goes away — including across a dropped connection.
- **`BridgeHandler<Model>` is unchanged.** Sharing is opt-in per handler; a
plain handler still gets its own private instance and is invisible to the
directory.
- `handler.instances()` returns the live keys, and `handler.attach(key)` binds
without executing anything.

Sharing only earns its keep when a model actually *holds* state — see
[`examples/bank`](examples/bank), whose `AccountModel` keeps one account in
memory. Full design in
[`docs/spec/core/shared_instances.md`](docs/spec/core/shared_instances.md).

## Subsystems

morph is layered: the async/bridge core is always present; everything else is an
Expand All @@ -205,7 +259,7 @@ opt-in header you include only if you need it.
|---|---|---|
| `morph::exec` | `executor.hpp`, `strand.hpp` | `IExecutor`, `ThreadPoolExecutor`, `MainThreadExecutor`, per-model `StrandExecutor` |
| `morph::async` | `completion.hpp` | `Completion<T>` — move-only result handle with `.then` / `.onError` |
| `morph::model` | `registry.hpp`, `model.hpp` | Registration traits, validators, `ActionDispatcher`, type-erased holders |
| `morph::model` | `registry.hpp`, `model.hpp`, `model_key.hpp` | Registration traits, validators, `ActionDispatcher`, type-erased holders, model primary keys |
| `morph::backend` | `backend.hpp`, `remote.hpp` | `LocalBackend`, `RemoteServer`, `SimulatedRemoteBackend` |
| `morph::bridge` | `bridge.hpp` | `Bridge`, `BridgeHandler<M>` — the user-facing API |
| `morph::wire` | `wire.hpp` | JSON `Envelope` protocol between client and server |
Expand Down Expand Up @@ -382,14 +436,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 +458,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
Loading
Loading