morph 0.1.0
Asynchronous bridge between GUI thread and business models
Loading...
Searching...
No Matches
model.hpp
1// SPDX-License-Identifier: Apache-2.0
2
3#pragma once
4#include <chrono>
5#include <concepts>
6#include <core/platform/Clock.hpp>
7#include <exception>
8#include <functional>
9#include <memory>
10#include <stdexcept>
11#include <string_view>
12#include <type_traits>
13#include <typeindex>
14#include <typeinfo>
15#include <utility>
16
17// strand.hpp defines no symbol this header uses. It is kept deliberately:
18// consumers (tests/test_model.cpp among them) reach
19// morph::exec::detail::ModelId through it, and for a header-only public library
20// dropping a transitive include is a source-breaking change for its users --
21// not worth the tidiness. The <concepts> include above is the real gap it was
22// masking; this file uses std::same_as and `concept` without including it.
23#include "../journal/action_log.hpp"
24#include "../session/session.hpp"
26#include "logger.hpp"
27#include "strand.hpp"
28
29namespace morph::model {
30
31// ── Post-commit tail ──────────────────────────────────────────────────────────
32
33/// @brief Runs @p tail, an action handler's work *after* its commit, and
34/// contains any exception it throws.
35///
36/// Once a handler has committed, its mutation is durable. What it does next --
37/// journalling, a rule cascade, rebuilding cached state -- can still fail, and
38/// if that failure leaves `execute()` the caller is told the action failed
39/// while the write it made stands. This is the seam that keeps the two apart:
40/// the tail's exception is logged through `morph::log::logError` and goes no
41/// further. Only work that follows the commit belongs in @p tail; an exception
42/// from before it still means the mutation did not happen and must still reach
43/// the caller.
44///
45/// Anything the caller's return value depends on is best computed before the
46/// commit, where a failure rolls the write back. A handler that cannot do that
47/// uses the value-returning overload instead.
48///
49/// @tparam Tail Nullary callable returning `void`.
50/// @param tail The post-commit work to run.
51/// @param what Names the handler in the log line, e.g. `"[bookmarks::BookmarkModel] BulkEdit"`.
52template <typename Tail>
53 requires std::invocable<Tail> && std::is_void_v<std::invoke_result_t<Tail>>
54void runPostCommitTail(Tail&& tail, std::string_view what) noexcept {
55 try {
56 std::invoke(std::forward<Tail>(tail));
57 } catch (const std::exception& error) {
58 ::morph::log::logError("{} committed, but its post-commit tail failed: {}", what, error.what());
59 } catch (...) {
60 ::morph::log::logError("{} committed, but its post-commit tail threw a non-std::exception", what);
61 }
62}
63
64/// @brief The same containment for a tail that produces the handler's result,
65/// falling back to the state the handler committed if the tail throws.
66///
67/// For a handler whose return value is refreshed by its post-commit work --
68/// a state rebuilt after a rule cascade, say -- and which already holds a
69/// truthful answer from before the tail began. If the tail throws, that answer
70/// is returned instead: it describes what the commit made durable, which is
71/// the one thing the caller must not be misinformed about.
72///
73/// @tparam Result The handler's result type; deduced from @p committed.
74/// @tparam Tail Nullary callable whose result converts to @p Result.
75/// @param tail The post-commit work to run.
76/// @param committed The result to return if @p tail throws.
77/// @param what Names the handler in the log line.
78/// @return @p tail's result, or @p committed if it threw.
79template <typename Result, typename Tail>
80 requires std::invocable<Tail> && std::convertible_to<std::invoke_result_t<Tail>, Result>
81[[nodiscard]] Result runPostCommitTail(Tail&& tail, Result committed,
82 std::string_view what) noexcept(std::is_nothrow_move_constructible_v<Result>) {
83 try {
84 return std::invoke(std::forward<Tail>(tail));
85 } catch (const std::exception& error) {
86 ::morph::log::logError("{} committed, but its post-commit tail failed: {}", what, error.what());
87 } catch (...) {
88 ::morph::log::logError("{} committed, but its post-commit tail threw a non-std::exception", what);
89 }
90 return committed;
91}
92
93} // namespace morph::model
94
95namespace morph::model::detail {
96
97template <typename Model>
98struct ModelHolder;
99
100// ── Backend-change notification interface ─────────────────────────────────────
101
102/// @brief Optional interface for models that need to react to backend switches.
103///
104/// Implemented automatically by `ModelHolder<M>` when `M` declares
105/// `void onBackendChanged()`. `Bridge::switchBackend()` discovers this
106/// capability via `dynamic_cast` without coupling to the concrete type.
107// NOLINTBEGIN(cppcoreguidelines-special-member-functions)
108struct IBackendChangedSink {
109 virtual ~IBackendChangedSink() = default;
110
111 /// @brief Called after the bridge has switched to a new backend and
112 /// re-registered all handlers on it.
113 virtual void onBackendChanged() = 0;
114};
115// NOLINTEND(cppcoreguidelines-special-member-functions)
116
117// ── Concept ───────────────────────────────────────────────────────────────────
118
119/// @brief Concept satisfied by model types that expose `void onBackendChanged()`.
120template <typename M>
121concept BackendChangedNotifiable = requires(M& model) {
122 { model.onBackendChanged() } -> std::same_as<void>;
123};
124
125/// @brief Concept satisfied by model types that expose a model-level
126/// `attachActionLog(std::shared_ptr<IActionLog>, std::string)`.
127///
128/// A small number of keyed/shared models cannot rely on `IModelHolder::recordIfAttached`'s auto-append alone (that
129/// mechanism serves plain fire-after-success journaling; a model that needs
130/// to read its own log back, e.g. for an activity-stream view, needs the
131/// *same* `IActionLog` instance available on `Model` itself, not only on the
132/// type-erased holder wrapping it) and so declares its own `attachActionLog`
133/// method with this exact shape, purely by structural convention — there is
134/// no shared base class or interface a model opts into. This concept is what
135/// lets `ModelHolder<Model>::onActionLogAttached` forward to it generically,
136/// the same "detect the hook structurally, forward only if present" shape
137/// `BackendChangedNotifiable`/`BackendChangedMixin` already establish for
138/// `onBackendChanged()`.
139template <typename M>
140concept ModelLevelActionLogAttachable =
141 requires(M& model, std::shared_ptr<::morph::journal::IActionLog> log, std::string key) {
142 { model.attachActionLog(log, key) } -> std::same_as<void>;
143 };
144
145/// @brief Concept satisfied by model types that expose a model-level
146/// `attachIdentity(std::string primaryKey)`.
147///
148/// A registry-constructed instance (`ModelRegistryFactory::create`, used by
149/// `RemoteServer`'s `register`/`attach` paths) knows its own primary key —
150/// `env.primary` — at construction time, but nothing on that path previously
151/// told the model itself: `ModelFactory::create<Model>()` default-constructs
152/// it and the key only ever reached `Model::execute` inside a keyed action's
153/// own payload. A model that wants to know its key once, at construction,
154/// rather than re-deriving it from every action, opts in by declaring
155/// `attachIdentity` with this exact shape — purely by structural convention,
156/// the same "detect the hook structurally, forward only if present" shape
157/// `ModelLevelActionLogAttachable`/`BackendChangedNotifiable` already
158/// establish. This concept is what lets `ModelHolder<Model>::onIdentityAttached`
159/// forward to it generically.
160template <typename M>
161concept ModelIdentityAttachable = requires(M& model, std::string key) {
162 { model.attachIdentity(key) } -> std::same_as<void>;
163};
164
165// ── Conditional mixin ─────────────────────────────────────────────────────────
166
167/// @brief Empty base when `M` does not declare `onBackendChanged()`.
168template <typename M, bool = BackendChangedNotifiable<M>>
169struct BackendChangedMixin {};
170
171/// @brief Specialisation that adds `IBackendChangedSink` when `M` declares `onBackendChanged()`; its one virtual is
172/// implemented by `ModelHolder<M>::onBackendChanged`.
173template <typename M>
174struct BackendChangedMixin<M, true> : IBackendChangedSink {};
175
176// ── Core type-erasure ─────────────────────────────────────────────────────────
177
178/// @brief Type-erased wrapper that owns a single model instance.
179///
180/// Used internally by backends to store heterogeneous models in a single map.
181// NOLINTBEGIN(cppcoreguidelines-special-member-functions)
182struct IModelHolder {
183 virtual ~IModelHolder() = default;
184
185 /// @brief Returns the `std::type_index` of the concrete model type.
186 [[nodiscard]] virtual std::type_index type() const noexcept = 0;
187
188 /// @brief Returns `true` if the concrete model type declares `void onBackendChanged()`.
189 ///
190 /// Answered by `ModelHolder<Model>` from the `BackendChangedNotifiable<Model>`
191 /// concept — a compile-time constant per concrete `Model` — so a backend can
192 /// learn this once, at registration time, instead of `dynamic_cast`ing every
193 /// holder on every backend switch. See `LocalBackend::registerModel` /
194 /// `LocalBackend::notifyBackendChanged` (`backend.hpp`).
195 [[nodiscard]] virtual bool isBackendChangeAware() const noexcept = 0;
196
197 /// @brief Forwards to the concrete model's `onBackendChanged()`, if it declared one.
198 ///
199 /// Default implementation is a no-op, for holders whose model is not
200 /// backend-change-aware. `ModelHolder<Model>` overrides this to call
201 /// `Model::onBackendChanged()` when `BackendChangedNotifiable<Model>` holds.
202 /// Called by `LocalBackend::notifyBackendChanged` through this base-class
203 /// virtual — no `dynamic_cast`, no RTTI dependency on this path.
204 virtual void onBackendChanged() {}
205
206 /// @brief The gate every action on this instance enters before it runs.
207 ///
208 /// Held from an action's start to its end -- for a Task handler, until its
209 /// Task completes -- so the next action does not start while a handler is
210 /// suspended. Touched only on this instance's strand. See `ActionGate` and
211 /// `docs/spec/core/coroutines.md`, "Not re-entrant: the action gate".
212 /// @return This instance's gate.
213 [[nodiscard]] ActionGate& actionGate() noexcept { return _actionGate; }
214
215 /// @brief Down-casts to a concrete `Model` reference.
216 ///
217 /// @tparam Model The expected concrete type.
218 /// @throws std::bad_cast if the stored type is not `Model`.
219 template <typename Model>
220 Model& into() {
221 if (type() != std::type_index(typeid(Model))) {
222 throw std::bad_cast{};
223 }
224 return static_cast<ModelHolder<Model>*>(this)->model;
225 }
226
227 /// @brief Attaches a durable action log and this instance's stable identity.
228 ///
229 /// Set once, typically from the same custom `HandlerBinding::modelFactory`
230 /// closure already used to inject other dependencies, or from
231 /// `RemoteServer::LogProvider` for a registry-constructed remote instance
232 /// (see `docs/spec/journal/journal.md`, "Attaching a log to remote
233 /// instances"). @p contextKey is stamped onto every `LogEntry` this
234 /// instance produces (e.g. an account id) so log entries are identifiable
235 /// without parsing `payload`/`result` JSON.
236 ///
237 /// Also forwards @p log/@p contextKey to the wrapped model's own
238 /// `attachActionLog(log, contextKey)`, if it declares one matching
239 /// `ModelLevelActionLogAttachable` — see `onActionLogAttached`'s doc
240 /// comment for why this second call exists alongside the holder's own
241 /// `_actionLog`/`_contextKey` state below.
242 ///
243 /// @p wallClock is what `recordIfAttached` stamps `LogEntry::timestampMs`
244 /// from. It is borrowed, not owned, so it must outlive this holder; a test
245 /// passes a `core::platform::ManualWallClock` to pin the stamp.
246 /// @param log Sink entries are forwarded to. Pass a `SessionLog` to also
247 /// get undo/checkpoint support.
248 /// @param contextKey Stable identity of this model instance.
249 /// @param wallClock Clock entries are timestamped from; the system clock by default.
250 void attachActionLog(std::shared_ptr<::morph::journal::IActionLog> log, std::string contextKey,
251 ::core::platform::WallClockRef wallClock = ::core::platform::defaultSystemWallClock()) {
252 onActionLogAttached(log, contextKey);
253 _actionLog = std::move(log);
254 _contextKey = std::move(contextKey);
255 _wallClock = wallClock;
256 }
257
258 /// @brief Tells this instance its own stable primary key, once, at
259 /// construction time.
260 ///
261 /// Called by `ModelRegistryFactory::create(modelId, primary)` immediately
262 /// after building the holder, when @p primaryKey is non-empty — the
263 /// registry-constructed counterpart of a model reading its key back out
264 /// of every keyed action's payload. Forwards to the wrapped model's own
265 /// `attachIdentity(primaryKey)`, if it declares one matching
266 /// `ModelIdentityAttachable`; a no-op otherwise, for the majority of
267 /// models that have no need to know their own key ahead of the first
268 /// action that carries it.
269 ///
270 /// Independent of `attachActionLog`/`_contextKey`: `contextKey` is the
271 /// action log's entity-key field, `primaryKey` is the shared-instance
272 /// directory key (`backend.hpp`'s `BindRequest` carries them as two named
273 /// fields, since they mean different things) — a caller
274 /// wanting both calls both.
275 /// @param primaryKey Stable identity of this model instance; a no-op if empty.
276 void attachIdentity(std::string primaryKey) {
277 if (primaryKey.empty()) {
278 return;
279 }
280 onIdentityAttached(primaryKey);
281 }
282
283 /// @brief Returns `true` if an action log is attached to this instance.
284 [[nodiscard]] bool hasActionLog() const noexcept { return static_cast<bool>(_actionLog); }
285
286 /// @brief Marks this instance as outbox-managed: the model records its own
287 /// `LogEntry` (inside its own store's transaction) and relays it via
288 /// `journal::OutboxRelay`, so `recordIfAttached` must stop
289 /// auto-appending for it — otherwise the framework's normal
290 /// fire-after-success append would double-log the same action.
291 ///
292 /// Independent of `attachActionLog`/`hasActionLog()`: those keep reporting
293 /// whatever log is attached and whether one is attached, respectively; this
294 /// flag only changes whether `recordIfAttached` actually forwards to it.
295 /// Defaults to `false` — every instance auto-appends exactly as before
296 /// unless a model explicitly opts in.
297 /// @param outboxManaged `true` to suppress `recordIfAttached`; `false` to
298 /// restore the ordinary fire-after-success behavior.
299 void setOutboxManaged(bool outboxManaged) noexcept { _outboxManaged = outboxManaged; }
300
301 /// @brief Returns `true` if `setOutboxManaged(true)` was called on this instance.
302 [[nodiscard]] bool isOutboxManaged() const noexcept { return _outboxManaged; }
303
304 /// @brief Records @p entry if a log is attached and this instance is not
305 /// outbox-managed; no-op otherwise.
306 ///
307 /// Called automatically by the two places `Model::execute()` is actually
308 /// invoked (`ActionDispatcher`'s runner and `Bridge::executeVia`'s local
309 /// op) — model code and application code never call this directly.
310 /// Overwrites `entityKey`, `principal`, and `timestampMs` (from the clock
311 /// given to `attachActionLog`) on @p entry;
312 /// callers only need to fill `modelType`, `actionType`, `payload`, `result`.
313 /// A no-op if no log is attached, **or** if `setOutboxManaged(true)` was
314 /// called on this instance (the model records its own entry elsewhere).
315 /// @param entry Entry to record; `seq` is assigned by the attached sink.
316 void recordIfAttached(::morph::journal::LogEntry entry) {
317 if (!_actionLog || _outboxManaged) {
318 return;
319 }
320 entry.entityKey = _contextKey;
321 if (const auto* ctx = ::morph::session::current()) {
322 entry.principal = ctx->principal;
323 }
324 entry.timestampMs =
325 std::chrono::duration_cast<std::chrono::milliseconds>(_wallClock.now().time_since_epoch()).count();
326 _actionLog->append(std::move(entry));
327 }
328
329protected:
330 /// @brief Forwards @p log/@p contextKey to the wrapped model's own
331 /// `attachActionLog`, if it declares one.
332 ///
333 /// `ModelHolder<Model>` implements it, detecting the member structurally
334 /// (`ModelLevelActionLogAttachable<Model>`) and doing nothing for a model
335 /// without one; this default does nothing, for a holder that is not a
336 /// `ModelHolder`. It is what lets a registry-constructed instance hold a
337 /// model-level log reference (e.g. for an activity-stream view) beside
338 /// the holder's own, which `recordIfAttached`'s auto-append uses.
339 /// @param log Sink entries are forwarded to.
340 /// @param contextKey Stable identity of this model instance.
341 virtual void onActionLogAttached(const std::shared_ptr<::morph::journal::IActionLog>& log,
342 const std::string& contextKey) {
343 (void)log;
344 (void)contextKey;
345 }
346
347 /// @brief Forwards @p primaryKey to the wrapped model's own
348 /// `attachIdentity`, if it declares one.
349 ///
350 /// `ModelHolder<Model>` implements it, detecting the member structurally
351 /// (`ModelIdentityAttachable<Model>`) and doing nothing for a model
352 /// without one; this default does nothing, for a holder that is not a
353 /// `ModelHolder`.
354 /// @param primaryKey Stable identity of this model instance; never empty
355 /// (the public `attachIdentity` filters that case out).
356 virtual void onIdentityAttached(const std::string& primaryKey) { (void)primaryKey; }
357
358private:
359 ActionGate _actionGate;
360 std::shared_ptr<::morph::journal::IActionLog> _actionLog;
361 std::string _contextKey;
362 ::core::platform::WallClockRef _wallClock{::core::platform::defaultSystemWallClock()};
363 bool _outboxManaged{false};
364};
365// NOLINTEND(cppcoreguidelines-special-member-functions)
366
367/// @brief Concrete holder that stores a `Model` by value.
368///
369/// Inherits `BackendChangedMixin<Model>` so that backend-change notifications
370/// are forwarded automatically when `Model` opts in.
371template <typename Model>
372struct ModelHolder : IModelHolder, BackendChangedMixin<Model> {
373 /// @brief The owned model instance.
374 Model model;
375
376 /// @brief Constructs the model by forwarding all arguments.
377 template <typename... Args>
378 explicit ModelHolder(Args&&... args) : model{std::forward<Args>(args)...} {}
379
380 /// @brief Returns `typeid(Model)` wrapped in a `std::type_index`.
381 [[nodiscard]] std::type_index type() const noexcept override { return typeid(Model); }
382
383 /// @brief Returns `BackendChangedNotifiable<Model>` — a compile-time constant.
384 [[nodiscard]] bool isBackendChangeAware() const noexcept override { return BackendChangedNotifiable<Model>; }
385
386 /// @brief Forwards to `Model::onBackendChanged()` iff `Model` declared one; no-op otherwise.
387 ///
388 /// This single definition is the final overrider for two unrelated base
389 /// virtuals: `IModelHolder::onBackendChanged` (always), and — when `Model`
390 /// is notifiable — `IBackendChangedSink::onBackendChanged`, reached via
391 /// `BackendChangedMixin<Model, true>`; both call paths (`IModelHolder*` and
392 /// `dynamic_cast<IBackendChangedSink*>`) land here.
393 void onBackendChanged() override {
394 if constexpr (BackendChangedNotifiable<Model>) {
395 model.onBackendChanged();
396 }
397 }
398
399protected:
400 /// @brief Forwards to `Model::attachActionLog(log, contextKey)` iff `Model`
401 /// declared one matching `ModelLevelActionLogAttachable`; no-op otherwise.
402 void onActionLogAttached(const std::shared_ptr<::morph::journal::IActionLog>& log,
403 const std::string& contextKey) override {
404 if constexpr (ModelLevelActionLogAttachable<Model>) {
405 model.attachActionLog(log, contextKey);
406 }
407 }
408
409 /// @brief Forwards to `Model::attachIdentity(primaryKey)` iff `Model`
410 /// declared one matching `ModelIdentityAttachable`; no-op otherwise.
411 void onIdentityAttached(const std::string& primaryKey) override {
412 if constexpr (ModelIdentityAttachable<Model>) {
413 model.attachIdentity(primaryKey);
414 }
415 }
416};
417
418/// @brief Factory that creates default-constructed `ModelHolder<Model>` instances.
419class ModelFactory {
420public:
421 /// @brief Creates a new `ModelHolder<Model>` on the heap.
422 ///
423 /// If a process-wide default action log is installed (see
424 /// `morph::journal::setActionLog`), it is attached to the new holder
425 /// automatically (with an empty `entityKey`) — this is the single
426 /// construction path behind every ordinary model registration, local or
427 /// remote, which is what makes "set the log once in `main()`" work
428 /// uniformly across topologies. Callers that need a specific instance
429 /// identity call `attachActionLog` again afterward to override it.
430 /// @tparam Model The model type to instantiate.
431 /// @return Owning pointer to the new holder.
432 template <typename Model>
433 static std::unique_ptr<IModelHolder> create() {
434 auto holder = std::make_unique<ModelHolder<Model>>();
435 if (auto log = ::morph::journal::defaultActionLog()) {
436 holder->attachActionLog(std::move(log), {});
437 }
438 return holder;
439 }
440};
441
442} // namespace morph::model::detail
morph's strands: one per model instance, from core-cpp's core::async::KeyedStrands,...
std::string principal
Auth principal from morph::session::current(), if any. Empty if unset.
std::string entityKey
Stable identity of the model instance (e.g. an account id), stamped from the value passed to attachAc...
int64_t timestampMs
Wall-clock time of execution, milliseconds since the Unix epoch.
What drives a model's Task handler: the driver coroutine that starts and finishes it,...