morph 0.1.0
Asynchronous bridge between GUI thread and business models
Loading...
Searching...
No Matches
registry.hpp
1// SPDX-License-Identifier: Apache-2.0
2
3#pragma once
4#include <algorithm>
5#include <atomic>
6#include <cassert>
7#include <concepts>
8#include <cstdint>
9#include <cstdio>
10#include <cstdlib>
11#include <functional>
12#include <glaze/glaze.hpp>
13#include <map>
14#include <memory>
16#include <stdexcept>
17#include <string>
18#include <string_view>
19#include <type_traits>
20#include <typeindex>
21#include <unordered_map>
22#include <utility>
23#include <vector>
24
25#include "../forms/field_errors.hpp"
26#include "../forms/forms.hpp"
28#include "model.hpp"
29#include "payload_schema.hpp"
30#include "profiler.hpp"
31
32namespace morph::model {
33
34/// @brief Traits specialisation that maps a model type to its string type-id.
35///
36/// Users must specialise this (or use `BRIDGE_REGISTER_MODEL`) before the model
37/// can be registered with a backend.
38/// @tparam Model Concrete model type.
39template <typename Model>
41
42/// @brief Traits specialisation that maps an action type to its id, JSON codec,
43/// and result type.
44///
45/// Users must specialise this (or use `BRIDGE_REGISTER_ACTION`).
46/// @tparam Action Concrete action type.
47template <typename Action>
49
50// ── The registration-phase latch ────────────────────────────────────────────
51//
52// The three process-level registries (`ActionDispatcher`,
53// `ModelRegistryFactory`, `ActionExecuteRegistry`) are unsynchronised
54// `unordered_map`s, and `docs/spec/core/registry.md` ("Thread safety") states
55// the precondition that makes that safe as a hard constraint: writes happen
56// during static initialisation, and after `main()` begins the maps are
57// read-only. Nothing detected a violation. A `dlopen`ed module registering on
58// a worker thread while another thread dispatches is undefined behaviour whose
59// only symptom is intermittent map corruption.
60//
61// This latch makes that precondition checkable. It starts open, closes when
62// the program says the registration phase is over, and the three `register*Once`
63// helpers assert against it. Closing is one-way: nothing re-opens it outside
64// `detail::reopenRegistrationPhaseForTesting`.
65
66namespace detail {
67
68/// @brief The process-wide registration-phase flag.
69///
70/// A function-local `static` in an `inline` function, for the reason the
71/// registries themselves are: one instance per process with no header-level
72/// static-ordering problem. `std::atomic` because the whole point is that it
73/// is read from the threads the constraint is about.
74///
75/// @return Reference to the process-wide flag; `true` once the registration
76/// phase has closed.
77[[nodiscard]] inline std::atomic<bool>& registrationPhaseFlag() noexcept {
78 static std::atomic<bool> closed{false};
79 return closed;
80}
81
82} // namespace detail
83
84/// @brief Declares the registration phase over: no further registration with
85/// the process-level registries is permitted.
86///
87/// Call it once, early in `main()`, before any thread is spawned. From that
88/// point a debug build asserts on every `registerModelOnce` /
89/// `registerActionOnce` / `registerActionExecutorOnce` — which is what a
90/// `dlopen`ed module's static initialisers run — turning the spec's hard
91/// constraint from advice into a diagnosable failure.
92///
93/// Calling it is optional: a **debug** build also closes the latch by itself on
94/// the first read of a process-level registry (`ActionDispatcher::dispatch`,
95/// `ModelRegistryFactory::create`, `ActionExecuteRegistry::execute` on the
96/// singleton instance), so an application that never calls this still gets the
97/// check from its first dispatch onwards. Calling it explicitly closes the
98/// window between `main()` and that first dispatch.
99///
100/// Idempotent, thread-safe, and a no-op after the first call.
101inline void closeRegistrationPhase() noexcept {
102 detail::registrationPhaseFlag().store(true, std::memory_order_release);
103}
104
105/// @brief Reports whether the registration phase has closed.
106///
107/// @return `true` once `closeRegistrationPhase` has been called, or — in a
108/// debug build only — once a process-level registry has been read.
109/// Release builds do not close the latch on their own, so this answers
110/// `false` there until the application closes it explicitly.
111[[nodiscard]] inline bool registrationPhaseClosed() noexcept {
112 return detail::registrationPhaseFlag().load(std::memory_order_acquire);
113}
114
115namespace detail {
116
117/// @brief Re-opens the registration-phase latch. **Test-only.**
118///
119/// The latch is one-way by design: an application that re-opens it has
120/// re-created exactly the hazard the latch exists to catch. It is exposed for
121/// morph's own suite, which must run a registrar on both sides of the latch in
122/// one process to prove the assertion fires for the right reason.
123inline void reopenRegistrationPhaseForTesting() noexcept {
124 registrationPhaseFlag().store(false, std::memory_order_release);
125}
126
127/// @brief Closes the latch on the first read of a process-level registry.
128///
129/// Debug builds only — in a release build this is an empty function and the
130/// latch closes only when the application calls `closeRegistrationPhase`. That
131/// asymmetry is deliberate: the auto-close exists to feed an `assert`, which a
132/// release build has already compiled out, and `ActionDispatcher::dispatch` is
133/// a per-request path that should not pay for a check nothing reads.
134///
135/// @param isProcessRegistry `true` when the registry being read is the
136/// process-level singleton. A locally constructed `ActionDispatcher` (as
137/// several tests own) is not covered by the constraint and must not
138/// close the latch for the whole program.
139inline void noteRegistryRead([[maybe_unused]] bool isProcessRegistry) noexcept {
140#ifndef NDEBUG
141 if (isProcessRegistry) {
142 ::morph::model::closeRegistrationPhase();
143 }
144#endif
145}
146
147/// @brief A `(modelId, actionId)` pair as a pair of views, for lookups.
148///
149/// The type a caller looks a registry entry up *with*. The stored key is a
150/// pair of `std::string`s, because the registry owns its ids; a caller has
151/// `string_view`s, because every id it can name arrives as one — a wire
152/// envelope's decoded field, or a `constexpr` `ModelTraits<M>::typeId()`.
153/// Materialising the stored type just to hash it would charge every lookup up
154/// to two `std::string` constructions, so the hash and equality below are
155/// transparent and this is what `find()` is given.
156using PairKeyView = std::pair<std::string_view, std::string_view>;
157
158/// @brief Transparent hash functor for `(modelId, actionId)` registry keys.
159///
160/// Shared between `ActionDispatcher` (server-side dispatch) and
161/// `ActionExecuteRegistry` (client-side generic-execute) since their key types
162/// are structurally identical.
163///
164/// Both overloads reduce to the same `string_view` body rather than calling
165/// `std::hash<std::string>` on one side and `std::hash<std::string_view>` on
166/// the other. Those two happen to agree on every standard library morph
167/// builds against, but "happens to agree" is not a property a heterogeneous
168/// lookup may rest on: if they ever disagreed, a transparent `find()` would
169/// hash into the wrong bucket and report a registered action as unknown, with
170/// no diagnostic anywhere. Routing both through one body makes the agreement
171/// structural instead of coincidental.
172struct PairKeyHash {
173 /// @brief Marks the functor transparent, enabling heterogeneous lookup.
174 using is_transparent = void;
175
176 /// @brief Combines the hashes of `key.first` and `key.second`.
177 /// @param key The pair of views to hash.
178 /// @return The combined hash value.
179 [[nodiscard]] std::size_t operator()(PairKeyView key) const noexcept {
180 std::size_t seed = std::hash<std::string_view>{}(key.first);
181 // NOLINTNEXTLINE(readability-magic-numbers, cppcoreguidelines-avoid-magic-numbers)
182 seed ^= std::hash<std::string_view>{}(key.second) + 0x9e3779b9 + (seed << 6) + (seed >> 2);
183 return seed;
184 }
185
186 /// @brief Hashes a stored key by viewing it.
187 /// @param key The stored pair to hash.
188 /// @return The combined hash value.
189 [[nodiscard]] std::size_t operator()(const std::pair<std::string, std::string>& key) const noexcept {
190 return (*this)(PairKeyView{key.first, key.second});
191 }
192};
193
194/// @brief Transparent equality functor for `(modelId, actionId)` registry keys.
195///
196/// The second half of what `unordered_map`'s heterogeneous lookup needs: a
197/// transparent hash alone is not enough, the equality must accept the lookup
198/// type too.
199struct PairKeyEqual {
200 /// @brief Marks the functor transparent, enabling heterogeneous lookup.
201 using is_transparent = void;
202
203 /// @brief Compares two keys of any mix of owned and viewed halves.
204 /// @tparam LhsT Left-hand pair type; both halves must view as `string_view`.
205 /// @tparam RhsT Right-hand pair type; both halves must view as `string_view`.
206 /// @param lhs Left-hand key.
207 /// @param rhs Right-hand key.
208 /// @return `true` if both halves compare equal as text.
209 template <typename LhsT, typename RhsT>
210 [[nodiscard]] bool operator()(const LhsT& lhs, const RhsT& rhs) const noexcept {
211 return std::string_view{lhs.first} == std::string_view{rhs.first} &&
212 std::string_view{lhs.second} == std::string_view{rhs.second};
213 }
214};
215
216/// @brief Concept satisfied by actions that expose a `bool validate() const` member.
217///
218/// Drives the default `ActionValidator::ready(...)` path: if the action supplies
219/// a `validate()` method, it is called automatically; otherwise readiness defaults
220/// to `true` (matching the existing one-shot semantics).
221template <typename A>
222concept HasValidate = requires(const A& act) {
223 { act.validate() } -> std::convertible_to<bool>;
224};
225
226} // namespace detail
227
228/// @brief Per-action readiness predicate deciding whether an action may execute.
229///
230/// Enforced on every dispatch path that reaches `Model::execute` (see
231/// `ValidationError` below for the sites), and consulted by
232/// `morph::flows::FlowSession::set<>` (forms/flows.hpp) to decide whether an
233/// in-progress draft is complete enough to fire.
234///
235/// Resolution order (highest-priority first):
236/// 1. **Explicit specialisation** of `ActionValidator<Action>` — typically via
237/// `BRIDGE_REGISTER_VALIDATOR(Action, fn)`. Wins over everything.
238/// 2. **`bool validate() const`** member on `Action` — picked up automatically.
239/// Co-locates the predicate with the data it inspects; preferred when you
240/// own the action type.
241/// 3. **Default** — returns `true`, matching one-shot semantics (the first
242/// `set<>` lands and the action fires).
243///
244/// Validation is intentionally a property of the **action**, not the model:
245/// different actions on the same model have different readiness requirements,
246/// and keeping the predicate next to the action keeps the GUI side oblivious
247/// to model internals.
248///
249/// @code
250/// struct FormAction {
251/// double a;
252/// double b;
253/// double c;
254/// bool validate() const { return a != 0.0 && b != 0.0 && c != 0.0; }
255/// };
256/// // No macro needed; ActionValidator<FormAction>::ready calls .validate() automatically.
257/// @endcode
258///
259/// @tparam Action Concrete action type.
260template <typename Action>
262 /// @brief Returns `true` if the action is in a state that should be executed.
263 ///
264 /// Auto-detects a `bool validate() const` member on @p action via the
265 /// `morph::model::detail::HasValidate` concept. Falls back to `true`.
266 ///
267 /// @param action Draft action whose readiness is being checked.
268 /// @return `true` if the action should fire, `false` to keep collecting fields.
269 static constexpr bool ready(const Action& action) {
270 if constexpr (detail::HasValidate<Action>) {
271 return action.validate();
272 } else {
273 (void)action;
274 return true;
275 }
276 }
277};
278
279/// @brief Thrown when a model id, or a model's action id, is registered for a type other than the one that already
280/// holds it.
281///
282/// Two registrations of one id would otherwise leave whichever static initialiser ran last, an order the link
283/// decides: two applications linked into one process could each reach the other's model. Registering the same type
284/// again, as every translation unit including a `BRIDGE_REGISTER_*` header does, is not a conflict. The static-init
285/// helpers report it and abort, because there is no caller to catch it before `main`.
286struct DuplicateRegistrationError : std::logic_error {
287 /// @brief Constructs the error naming the id and both types.
288 /// @param what `model` or `action`.
289 /// @param taken The id, `model` or `model/action`.
290 /// @param held The type that holds it.
291 /// @param incoming The type registered again under it.
292 DuplicateRegistrationError(std::string_view what, std::string_view taken, std::type_index held,
293 std::type_index incoming)
294 : std::logic_error("morph: the " + std::string{what} + " id `" + std::string{taken} +
295 "` is registered for two types (" + held.name() + " and " + incoming.name() +
296 "); give each type its own id") {}
297};
298
299namespace detail {
300
301/// @brief Records that @p name belongs to @p type, refusing it for any other type.
302/// @param types The ids registered so far, with their types.
303/// @param what `model` or `action`, for the message.
304/// @param name The id.
305/// @param type The type registering it.
306/// @throws DuplicateRegistrationError when another type holds @p name.
307template <typename Map, typename Key>
308// NOLINTNEXTLINE(bugprone-easily-swappable-parameters): a kind, then the id; swapped, only the message reads oddly.
309void claimId(Map& types, std::string_view what, Key const& key, std::string_view name, std::type_index type) {
310 auto const [iter, inserted] = types.try_emplace(key, type);
311 if (!inserted && iter->second != type) {
312 throw DuplicateRegistrationError{what, name, iter->second, type};
313 }
314}
315
316/// @brief Ends a static-init registration that conflicts: there is no caller to catch it before `main`.
317/// @param error The conflict.
318[[noreturn]] inline void abortRegistration(DuplicateRegistrationError const& error) noexcept {
319 static_cast<void>(std::fputs(error.what(), stderr));
320 static_cast<void>(std::fputc('\n', stderr));
321 std::abort();
322}
323
324} // namespace detail
325
326/// @brief Thrown when a decoded action fails `ActionValidator<Action>::ready(...)`
327/// on a server-side or local execution path, before `Model::execute` runs.
328///
329/// Raised by `morph::model::detail::ActionDispatcher::registerAction`'s runner
330/// (the server dispatch path used by `RemoteServer`, every remote and Qt
331/// WebSocket topology) and by `Bridge::executeVia`'s `localOp` (the in-process
332/// path used by `LocalBackend`) — the two sites an action can reach ungated: the
333/// runner by a hand-built envelope from an untrusted remote client, `localOp` by
334/// an action built by hand and handed to `BridgeHandler::execute<Action>()`. An
335/// action that *did* pass `morph::flows::FlowSession::set<>`'s gate
336/// (forms/flows.hpp) or the type-erased `executeJson` gate
337/// (`ActionExecuteRegistry::registerAction`) still reaches whichever of the two
338/// its topology dispatches through — `FlowSession::fireStep` goes through the
339/// handler like any other caller — and is simply re-checked there.
340///
341/// Deriving from `std::runtime_error` means existing `catch (const std::exception&)`
342/// handling — e.g. `RemoteServer::dispatchExecute`'s strand catch, which turns any
343/// thrown exception into an `err` reply — keeps working unchanged; callers that care
344/// can `catch`/`dynamic_cast` the specific type instead.
345struct ValidationError : std::runtime_error {
346 /// @brief Constructs the error with a message of the form
347 /// `"action failed validation: <modelType>/<actionType>"`.
348 /// @param modelType `ModelTraits<Model>::typeId()` of the target model.
349 /// @param actionType `ActionTraits<Action>::typeId()` of the rejected action.
350 ValidationError(std::string_view modelType, std::string_view actionType)
351 : std::runtime_error("action failed validation: " + std::string{modelType} + "/" + std::string{actionType}) {}
352};
353
354/// @brief Thrown when an action executed and its mutation committed, but the
355/// framework could not finish reporting that success.
356///
357/// Raised by the two sites that actually run `Model::execute` — the server
358/// dispatch path (`morph::model::detail::ActionDispatcher::registerAction`'s
359/// runner) and the in-process path (`Bridge::executeVia`'s `localOp`,
360/// `bridge.hpp`) — for anything that fails *after* `Model::execute` returned:
361/// serialising the result (`ActionTraits<Action>::resultToJson`, which throws
362/// `detail::ParseError`) and appending the journal entry (a
363/// `journal::IActionLog` whose `append` could not reach its backend, which
364/// `action_log.hpp` requires it to signal by throwing).
365///
366/// It exists because those failures are not execution failures and must not be
367/// reported as one. An action whose journal append throws has already changed
368/// the model; telling the caller the action was rejected invites a retry of a
369/// write that landed, and recording `journal::Outcome::Failed` for it puts a
370/// permanent lie in the audit trail, attributed to the action rather than to
371/// the infrastructure that failed. So a post-execution failure is a distinct
372/// type carrying a distinct message: the action ran, the reporting did not.
373///
374/// No journal entry is written when the throw came from `resultToJson`. A
375/// `Succeeded` entry carries the result by definition (see
376/// `docs/spec/journal/journal.md`, `LogEntry::result`), and there is none to
377/// carry; the caller is told instead, which is the only channel left.
378///
379/// Deriving from `std::runtime_error` keeps existing `catch (const
380/// std::exception&)` handling working — `RemoteServer::dispatchExecute`'s
381/// strand catch still turns this into an `err` reply, and `LocalBackend`'s
382/// still rejects the `Completion` — while a caller that wants to tell a
383/// committed-but-unrecorded action from a rejected one can catch this type and
384/// read `result()`.
385class ActionRecordingError : public std::runtime_error {
386public:
387 /// @brief Constructs the error with a message of the form
388 /// `"action executed but was not recorded: <cause>"`.
389 /// @param result JSON-encoded result of the committed action, or `""` when
390 /// serialising it is what failed.
391 /// @param cause `std::exception::what()` from the failure that stopped the
392 /// recording.
393 ActionRecordingError(std::string result, std::string cause)
394 : std::runtime_error("action executed but was not recorded: " + cause),
395 _result(std::move(result)),
396 _cause(std::move(cause)) {}
397
398 /// @brief Returns the committed action's JSON-encoded result.
399 /// @return The result JSON, or `""` when `resultToJson` is what threw.
400 [[nodiscard]] const std::string& result() const noexcept { return _result; }
401
402 /// @brief Returns the underlying failure's message, without this type's prefix.
403 /// @return `std::exception::what()` of the failure that stopped the recording.
404 [[nodiscard]] const std::string& cause() const noexcept { return _cause; }
405
406private:
407 std::string _result;
408 std::string _cause;
409};
410
411/// @brief Whether an action's executions are recorded to an attached action log.
412///
413/// A strong type instead of a bare `bool` so registration call sites read as
414/// intent (`Loggable::No`) rather than an unexplained `false`.
415enum class Loggable : std::uint8_t { No, Yes };
416
417/// @brief Per-action policy deciding how repeated executions are checkpointed
418/// into a durable action log.
419///
420/// Deliberately minimal for now — only `coalesce` exists, and it has no
421/// registration macro yet. Specialise directly for the rare action where only
422/// the latest occurrence should survive a checkpoint (e.g. a form-field edit
423/// fired repeatedly via `morph::flows::FlowSession::set<>`); every other action
424/// defaults to `false`, meaning every execution is treated as a distinct, permanent fact
425/// (the right default for anything resembling a business event).
426/// @tparam Action Concrete action type.
427template <typename Action>
429 /// @brief If `true`, a checkpoint keeps only the most recent entry per
430 /// `(modelType, entityKey, actionType)`. If `false` (default), every
431 /// entry survives.
432 static constexpr bool coalesce = false;
433};
434
435namespace detail {
436
437/// @brief Concept satisfied by `ActionTraits<A>` specialisations that expose a
438/// `Loggable loggable` static member.
439///
440/// Lets `actionLoggable()` default to `Loggable::Yes` for hand-written
441/// `ActionTraits` specialisations (as used in tests) that predate this member,
442/// instead of requiring every existing specialisation to be updated.
443template <typename A>
444concept HasLoggableFlag = requires {
445 { ActionTraits<A>::loggable } -> std::convertible_to<Loggable>;
446};
447
448/// @brief Returns `ActionTraits<A>::loggable` if present, otherwise `Loggable::Yes`.
449///
450/// Logging every action by default (opt out via the macro's 4th argument) means
451/// new actions are captured automatically; only actions that are known to be
452/// pure queries need to opt out explicitly.
453template <typename A>
454constexpr Loggable actionLoggable() {
455 if constexpr (HasLoggableFlag<A>) {
456 return ActionTraits<A>::loggable;
457 } else {
458 return Loggable::Yes;
459 }
460}
461
462/// @brief Concept satisfied by `ActionTraits<A>` specialisations that expose a
463/// `payloadSchema()` static member.
464///
465/// Both `BRIDGE_REGISTER_ACTION` and `BRIDGE_REGISTER_ACTION_FOR_CLIENT`
466/// generate one; a hand-written `ActionTraits` does not have to.
467template <typename A>
468concept HasPayloadSchema = requires {
469 { ActionTraits<A>::payloadSchema() } -> std::convertible_to<std::string_view>;
470};
471
472/// @brief Returns `ActionTraits<A>::payloadSchema()` if the specialisation
473/// provides one, otherwise the empty string.
474///
475/// **The fingerprint is a property of the codec, not of the action type.** The
476/// macro-generated codecs read and write through Glaze's reflection, so their
477/// JSON shape *is* the struct's reflected shape and
478/// `morph::model::payloadFingerprint` describes it exactly. A hand-written
479/// `ActionTraits` may map its struct to entirely different JSON -- it may not
480/// even be a reflectable type (`test_client_execute_deadline.cpp` registers an
481/// anonymous-namespace struct, which Glaze's traditional reflection cannot name
482/// at all) -- so deriving a "shape" from the struct would describe something
483/// that never reaches the journal.
484///
485/// Empty means *unstamped*: entries for such an action are recorded without a
486/// fingerprint and replay treats them exactly as every build before the
487/// fingerprint existed did (see `journal::UnstampedPayloadPolicy`). A
488/// hand-written specialisation that wants the guarantee opts in by defining its
489/// own `payloadSchema()`.
490///
491/// @tparam A Action type.
492/// @return The fingerprint, or `""` when the specialisation provides none.
493template <typename A>
494inline std::string actionPayloadSchema() {
495 if constexpr (HasPayloadSchema<A>) {
496 return std::string{ActionTraits<A>::payloadSchema()};
497 } else {
498 return {};
499 }
500}
501
502/// @brief The request id of the session installed on the calling thread, or
503/// empty when there is none: the text that links one dispatch's
504/// profiler zones across the threads it crosses.
505/// @return A view into the installed session, valid while it stays installed.
506[[nodiscard]] inline std::string_view currentRequestId() noexcept {
507 auto const* const ctx = ::morph::session::current();
508 return ctx != nullptr ? std::string_view{ctx->requestId} : std::string_view{};
509}
510
511/// @brief Records one executed action's *successful* outcome to @p holder's
512/// attached action log, if any (`IModelHolder::recordIfAttached` is
513/// itself a no-op with none attached).
514///
515/// Shared by the two sites that actually execute an action and must record
516/// the result either way: `ActionDispatcher::registerAction`'s runner
517/// (server-side dispatch, this file) and `Bridge::executeVia`'s local op
518/// (`bridge.hpp`, the in-process `LocalBackend` path). Paired with
519/// `recordActionFailure` below rather than a single outcome-plus-two-strings
520/// function, so a call site cannot transpose `result`/`error` into the wrong
521/// slot for the outcome it is actually recording. `seq`, `entityKey`,
522/// `principal`, and `timestampMs` are left at their `LogEntry` defaults —
523/// `recordIfAttached` overwrites `entityKey`/`principal`/`timestampMs`
524/// itself, and the sink assigns `seq` on append.
525/// @param holder Model holder to record against.
526/// @param modelType `ModelTraits<Model>::typeId()`.
527/// @param actionType `ActionTraits<Action>::typeId()`.
528/// @param payload JSON-encoded request (`ActionTraits<Action>::toJson`).
529/// @param schema `actionPayloadSchema<Action>()`, computed once by the caller.
530/// @param result JSON-encoded result (`ActionTraits<Action>::resultToJson`).
531inline void recordActionSuccess(IModelHolder& holder, std::string modelType, std::string actionType,
532 std::string payload, std::string schema, std::string result) {
533 MORPH_ZONE("recordActionSuccess");
534 holder.recordIfAttached(::morph::journal::LogEntry{
535 .seq = 0,
536 .modelType = std::move(modelType),
537 .entityKey = {},
538 .actionType = std::move(actionType),
539 .payload = std::move(payload),
540 .schema = std::move(schema),
541 .result = std::move(result),
542 .outcome = ::morph::journal::Outcome::Succeeded,
543 .error = {},
544 .principal = {},
545 .timestampMs = 0,
546 });
547}
548
549/// @brief Records one executed action's *failed* outcome to @p holder's
550/// attached action log, if any. See `recordActionSuccess` above (its
551/// paired counterpart) for the shared rationale.
552/// @param holder Model holder to record against.
553/// @param modelType `ModelTraits<Model>::typeId()`.
554/// @param actionType `ActionTraits<Action>::typeId()`.
555/// @param payload JSON-encoded request (`ActionTraits<Action>::toJson`).
556/// @param schema `actionPayloadSchema<Action>()`, computed once by the caller.
557/// @param error `std::exception::what()`.
558inline void recordActionFailure(IModelHolder& holder, std::string modelType, std::string actionType,
559 std::string payload, std::string schema, std::string error) {
560 MORPH_ZONE("recordActionFailure");
561 holder.recordIfAttached(::morph::journal::LogEntry{
562 .seq = 0,
563 .modelType = std::move(modelType),
564 .entityKey = {},
565 .actionType = std::move(actionType),
566 .payload = std::move(payload),
567 .schema = std::move(schema),
568 .result = {},
569 .outcome = ::morph::journal::Outcome::Failed,
570 .error = std::move(error),
571 .principal = {},
572 .timestampMs = 0,
573 });
574}
575
576/// @brief Exception thrown when JSON serialisation or deserialisation fails.
577struct ParseError : std::runtime_error {
578 using std::runtime_error::runtime_error;
579};
580
581/// @brief Write options for an action's/result's own JSON body: identical to
582/// `morph::wire::detail::EscapingWriteOpts`, applied one layer down.
583///
584/// The envelope codec already escapes ASCII control bytes (see
585/// `docs/spec/core/wire.md`, "Control bytes in string fields"); the action and
586/// result bodies it carries are serialized here, separately, and need exactly
587/// the same treatment for exactly the same two reasons. With the option off,
588/// a raw `0x00`–`0x1F` in any caller-supplied string field of an action makes
589/// the body invalid JSON that the peer's own reader rejects — and, when the
590/// same string also contains an escaped character, glaze's chunked fast path
591/// silently rewrites such a byte as two `0x00`s, destroying the payload in a
592/// way that still decodes. Action bodies are pure caller data (a paste's
593/// content, a message, a filename), so this is if anything more exposed than
594/// the envelope was.
595///
596/// Deliberately duplicated rather than reused from `morph::wire`: the action
597/// codec belongs to the model layer and must not acquire a dependency on the
598/// transport layer's header just to share a four-line option struct.
599///
600/// Applies to writing only — glaze's reader already accepts `\\uXXXX`.
601struct EscapingWriteOpts : glz::opts {
602 /// @brief Emit control bytes as `\\uXXXX` rather than raw.
603 // NOLINTNEXTLINE(readability-identifier-naming) — the name is glaze's, not ours; the option is matched by name.
604 bool escape_control_characters = true;
605};
606
607/// @brief Everything a peer that is **not** linked against an action's C++ needs
608/// in order to build, and to sanity-check, a payload for it.
609///
610/// Assembled once per action type (see `actionDescription`) and served verbatim
611/// by `RemoteServer`'s `"schemas"` envelope kind. Two consumers, one document:
612/// a generic client renders `schema` as a form, and `RemoteServer`'s optional
613/// `PayloadCompleteness::RequireDeclaredFields` gate reads `required` — so the
614/// rule the server enforces is by construction the rule it published.
615struct ActionDescription {
616 /// @brief `morph::forms::schemaJson<A>()` with two extra top-level keys
617 /// merged in: `x-payloadFingerprint` and `x-payloadShape`.
618 ///
619 /// Empty only when schema generation produced nothing usable at all.
620 std::string schema;
621
622 /// @brief Wire names of every member the schema's `required` array lists.
623 ///
624 /// Derived from the served document rather than recomputed, so a field the
625 /// gate insists on is exactly a field the schema told the client about.
626 std::vector<std::string> required;
627};
628
629/// @brief Builds `A`'s `ActionDescription`.
630///
631/// The merge is done over a DOM rather than by splicing text: the fingerprint
632/// and shape are ordinary JSON string values, and glaze's writer is the only
633/// thing here that knows how to escape them. On a DOM read failure the raw
634/// `morph::forms::schemaJson<A>()` text is served unannotated and `required` is
635/// left empty — a description facility degrades, it does not throw.
636///
637/// @tparam A Action type.
638/// @return The assembled description.
639template <typename A>
640[[nodiscard]] inline ActionDescription buildActionDescription() {
641 ActionDescription desc;
642 desc.schema = ::morph::forms::schemaJson<A>();
643 // u64 number mode for the same reason mergeSchemaExtras uses it: the
644 // schema carries int64/uint64 bounds a double-only DOM would round on the
645 // way back out.
646 glz::generic_u64 dom{};
647 if (glz::read_json(dom, desc.schema) || !dom.is_object()) {
648 return desc;
649 }
650 if (dom.contains("required") && dom["required"].is_array()) {
651 for (const auto& name : dom["required"].get_array()) {
652 if (name.is_string()) {
653 desc.required.push_back(name.template get<std::string>());
654 }
655 }
656 }
657 dom["x-payloadFingerprint"] = payloadFingerprint<A>();
658 dom["x-payloadShape"] = payloadShapeString<A>();
659 std::string merged;
660 // COVERAGE GAP, documented not closed: glz::write_json never fails on
661 // this always-well-formed `dom` (every field just came from a
662 // successful glz::read_json a few lines above, plus two plain string
663 // insertions) -- closing this branch would need forging a failure
664 // inside glaze's own serializer over data this function produces
665 // itself, not something reachable through schemaJson<A>()'s return
666 // text the way the two branches above this one are. No trigger found.
667 //
668 // WARNING for whoever next measures this file's coverage: this line
669 // can read as "covered" in a merged/aggregated coverage report even
670 // though the branch above is genuinely never taken by any real test.
671 // tests/test_wire_schemas.cpp's WireSchemasUnsatisfiable
672 // fixture (a different, deliberately-closed finding) throws partway
673 // through this function, after llvm-cov's per-function body-region
674 // counter has already incremented for that instantiation -- llvm-cov
675 // then synthesizes phantom nonzero branch counts for every top-level
676 // `if` in that one instantiation, including this one, and
677 // aggregate_lcov_branches.py sums arm counts across instantiations, so
678 // the phantom contribution folds into this line's aggregate. Verify via
679 // `llvm-cov show -show-instantiations=true` scoped to a real,
680 // non-throwing instantiation before trusting a "this line is covered"
681 // reading here.
682 if (!glz::write_json(dom, merged)) {
683 desc.schema = std::move(merged);
684 }
685 return desc;
686}
687
688/// @brief `A`'s `ActionDescription`, computed once per type per process.
689///
690/// A function-local static, exactly like `morph::forms::schemaJson<A>()`'s own
691/// cache: `RemoteServer` may answer `"schemas"` on any pool thread, and the
692/// maps `ActionDispatcher` fills at static-init time are read-only afterwards,
693/// so the only mutable state involved has to be the one C++ already guarantees
694/// is initialised exactly once. A throw from `buildActionDescription` (an
695/// action whose `formRules` are self-contradicting — see
696/// `forms::UnsatisfiableFormError`) leaves the static uninitialised and a later
697/// call retries, rather than serving a half-built document.
698///
699/// @tparam A Action type.
700/// @return Reference to the process-lifetime description for `A`.
701template <typename A>
702[[nodiscard]] inline const ActionDescription& actionDescription() {
703 static const ActionDescription kDesc = buildActionDescription<A>();
704 return kDesc;
705}
706
707// Forward declarations so instance() methods inside the classes can reference them.
708inline class ActionDispatcher& defaultDispatcher();
709inline class ModelRegistryFactory& defaultRegistry();
710
711/// @brief Registry that maps (modelId, actionId) pairs to type-erased runner functions.
712///
713/// Used by `RemoteServer` to dispatch incoming JSON requests without knowing the
714/// concrete model or action types at the call site.
715class ActionDispatcher {
716public:
717 /// @brief Type-erased action runner: deserialises, executes, and serialises the result.
718 using Runner = std::function<std::string(IModelHolder&, std::string_view)>;
719
720 /// @brief What `evaluate` answers for one action body.
721 struct Evaluation {
722 /// @brief The action, reconciled and recomputed, as `ActionTraits<Action>::toJson` writes it.
723 std::string body;
724 /// @brief `forms::fieldErrors` of the recomputed action: field name to message.
725 std::map<std::string, std::string, std::less<>> errors;
726 /// @brief Whether `dispatch` would hand this body to `Model::execute`: its `Quantity` fields are within
727 /// their units' bounds and `ActionValidator<Action>::ready` holds.
728 ///
729 /// Can hold while `errors` is not empty: a required field is reported even for an action whose validator
730 /// does not require it, and `dispatch` checks only the validator.
731 bool ready = false;
732 };
733
734 /// @brief Type-erased evaluator: deserialises, reconciles, recomputes and checks, without executing.
735 using Evaluator = Evaluation (*)(std::string_view);
736
737 /// @brief Receives the outcome of an asynchronous dispatch: the JSON result,
738 /// or the exception (with an empty result).
739 using DispatchDone = std::function<void(std::string, std::exception_ptr)>;
740
741 /// @brief Type-erased runner for a Task handler: deserialises, starts the
742 /// handler on the model's strand, and hands the serialised result or
743 /// the exception to its last argument when the Task completes.
744 using AsyncRunner = void (*)(IModelHolder&, std::string_view,
745 const std::shared_ptr<::morph::exec::detail::TaskResumer>&, ::core::async::StopToken,
746 DispatchDone);
747
748 /// @brief Registers a runner for `(Model, Action)` under the given string ids.
749 ///
750 /// This is the single execution site used by `RemoteServer` (every remote and
751 /// Qt WebSocket topology) — `Model::execute()` runs here, on whichever process
752 /// actually owns @p holder. Before `Model::execute` runs, the runner reconciles
753 /// any `Quantity` fields to their declared precision
754 /// (`morph::forms::reconcileDeclaredPrecision`), overwrites any declared
755 /// computed fields from their inputs (`morph::forms::recomputeAll`,
756 /// `forms.hpp`), and enforces `ActionValidator<Action>::ready(action)`,
757 /// throwing `ValidationError` when it returns `false` — the same checks the
758 /// client bridge dispatch path (`ActionExecuteRegistry::registerAction`,
759 /// `bridge.hpp`) already performs. If a `journal::IActionLog` is attached to
760 /// @p holder (via `IModelHolder::attachActionLog`) and `Action` is loggable
761 /// (the default), the executed action is recorded automatically -- on a
762 /// successful `Model::execute` with `outcome = Outcome::Succeeded` and the
763 /// JSON result, and equally on a thrown `std::exception` (a validation
764 /// failure, a rejected write) with `outcome = Outcome::Failed` and
765 /// `error = what()`, `result` empty. An exception from `Model::execute`
766 /// propagates unchanged after the `Failed` entry is recorded.
767 ///
768 /// `Outcome::Failed` means the model rejected the action, and nothing else:
769 /// only `Model::execute` can produce it. Serialising the result and
770 /// appending the entry both run after the model's mutation has committed,
771 /// and both can throw -- `resultToJson` raises `detail::ParseError`, and a
772 /// sink that could not reach its backend is required to say so by throwing
773 /// (`journal/action_log.hpp`). Those surface as `ActionRecordingError`, so
774 /// a committed write is never reported to the caller, or filed in the
775 /// audit trail, as a rejected one.
776 ///
777 /// Every recorded entry is stamped with `payloadFingerprint<Action>()` in
778 /// `LogEntry::schema`, and the same fingerprint is filed under
779 /// `(modelId, actionId)` for `schemaFor()` to hand back to the type-erased
780 /// replay path. That pair is what lets `journal::replay()` notice that the
781 /// shape which wrote an entry is not the shape it is about to decode it
782 /// with -- see `docs/spec/journal/journal.md`, "Payload schema fingerprint".
783 /// @throws ValidationError if the decoded action fails `ActionValidator<Action>::ready`.
784 /// @throws ActionRecordingError if the action executed but its result could
785 /// not be serialised or its journal entry could not be appended.
786 template <typename Model, typename Action>
787 void registerAction(std::string_view modelId, std::string_view actionId) {
788 Key key{std::string{modelId}, std::string{actionId}};
789 detail::claimId(_actionTypes, "action", key, key.first + "/" + key.second, std::type_index{typeid(Action)});
790 ActionEntry& entry = _actions[std::move(key)];
791 if constexpr (isTaskHandler<decltype(std::declval<Model&>().execute(std::declval<Action&>()))>) {
792 entry.asyncRunner = &runTaskHandler<Model, Action>;
793 entry.runner = [](IModelHolder&, std::string_view) -> std::string {
794 throw std::logic_error{
795 "ActionDispatcher::dispatch: this action's handler returns core::async::Task and cannot complete "
796 "synchronously; dispatch it with dispatchAsync"};
797 };
798 } else {
799 entry.runner = &runHandler<Model, Action>;
800 }
801 entry.evaluator = &evaluateAction<Model, Action>;
802 entry.coalesce = ActionLogPolicy<Action>::coalesce;
803 entry.schema = detail::actionPayloadSchema<Action>();
804 // Deliberately a thunk, not the description itself: registration runs
805 // at static-init time, where `buildActionDescription`'s throw path (an
806 // action with self-contradicting `formRules`) would abort the process
807 // before `main` rather than surface as an `err` reply. The thunk defers
808 // the whole computation to the first caller who asks.
809 entry.describe = []() -> const ActionDescription& { return actionDescription<Action>(); };
810 }
811
812private:
813 /// @brief Decodes @p payloadJson into an `Action` and applies every gate
814 /// that must run before any handler sees it.
815 /// @tparam Model Concrete model type.
816 /// @tparam Action Concrete action type.
817 /// @param payloadJson The action's JSON body.
818 /// @return The decoded, reconciled, recomputed and validated action.
819 /// @throws ValidationError if the action fails `ActionValidator<Action>::ready`.
820 template <typename Model, typename Action>
821 static Action prepareAction(std::string_view payloadJson) {
822 MORPH_ZONE("ActionDispatcher::prepareAction");
823 auto action = ActionTraits<Action>::fromJson(payloadJson);
824 // Retag any Quantity fields to their declared precision so a
825 // hand-built wire payload matches the schema's advertised
826 // x-decimalPlaces, exactly as the client bridge dispatch path
827 // (ActionExecuteRegistry::registerAction, bridge.hpp) already does.
828 // No-op for actions with no Quantity members. See
829 // docs/spec/forms/forms.md.
830 ::morph::forms::reconcileDeclaredPrecision(action);
831 // Pre-decode wire validation seam: reject a Quantity field whose
832 // engaged value falls outside its unit's declared bounds
833 // (UnitTraits<E>::bounds), before the action's own validate()
834 // (a business-rule check) ever runs. No-op for actions with no
835 // Quantity members, or whose units declare no bounds(). See
836 // docs/spec/forms/forms.md, "Pre-decode wire validation".
837 ::morph::forms::enforceQuantityBounds(action);
838 // Overwrite any computed fields from their declared inputs. This is
839 // the true server-side execution site for every remote and Qt
840 // WebSocket topology (RemoteServer -> ActionDispatcher::dispatch) --
841 // the one path a hand-built wire envelope reaches directly,
842 // bypassing every client-side gate. A tampered computed value on
843 // the wire is discarded here, before the validator check and
844 // Model::execute run. No-op for actions with no computedFields. See
845 // docs/spec/forms/forms.md.
846 ::morph::forms::recomputeAll(action);
847 // Enforce the action's validator on the server dispatch path — the
848 // one path an untrusted remote client can drive directly with a
849 // hand-built envelope, bypassing the client-side gates
850 // (morph::flows::FlowSession::set<> and
851 // ActionExecuteRegistry::registerAction). ActionValidator<Action>::ready
852 // auto-detects a `bool validate() const` member and defaults to
853 // `true` for actions with no validator, so this is a no-op for
854 // unvalidated actions (zero behavior change) and a hard gate for
855 // validated ones. The exception propagates out of this lambda to
856 // ActionDispatcher::dispatch's caller (RemoteServer::dispatchExecute's
857 // strand catch turns it into an `err` reply).
858 if (!ActionValidator<Action>::ready(action)) {
859 throw ValidationError{ModelTraits<Model>::typeId(), ActionTraits<Action>::typeId()};
860 }
861 return action;
862 }
863
864 /// @brief The evaluator of `(Model, Action)`: the gates `prepareAction` applies, made as checks rather than
865 /// refusals, with no `Model::execute`, no journal entry and no model instance.
866 /// @tparam Model Concrete model type.
867 /// @tparam Action Concrete action type.
868 /// @param payloadJson The action's JSON body.
869 /// @return The recomputed body, its field errors and whether `dispatch` would execute it.
870 template <typename Model, typename Action>
871 static Evaluation evaluateAction(std::string_view payloadJson) {
872 MORPH_ZONE("ActionDispatcher::evaluateAction");
873 auto action = ActionTraits<Action>::fromJson(payloadJson);
874 ::morph::forms::reconcileDeclaredPrecision(action);
875 ::morph::forms::recomputeAll(action);
876 Evaluation evaluation;
877 evaluation.errors = ::morph::forms::fieldErrors(action);
878 evaluation.ready =
879 !::morph::forms::checkQuantityBounds(action).has_value() && ActionValidator<Action>::ready(action);
880 evaluation.body = ActionTraits<Action>::toJson(action);
881 return evaluation;
882 }
883
884 /// @brief The runner of an ordinary handler: prepares the action, runs
885 /// `Model::execute`, journals the outcome and returns the JSON result.
886 /// @tparam Model Concrete model type.
887 /// @tparam Action Concrete action type.
888 /// @param holder The model instance.
889 /// @param payloadJson The action's JSON body.
890 /// @return The JSON-encoded result.
891 template <typename Model, typename Action>
892 static std::string runHandler(IModelHolder& holder, std::string_view payloadJson) {
893 auto action = prepareAction<Model, Action>(payloadJson);
894 auto& model = holder.template into<Model>();
895 // Model::execute is the only call inside this try, because it is
896 // the only one whose failure means the action was rejected. A
897 // rejected or throwing execution -- a validation failure, a lost
898 // connection, a rejected write -- records Outcome::Failed (when a
899 // log is attached and Action is loggable) so it leaves an audit
900 // trail rather than silence, and the exception is rethrown
901 // unchanged. See docs/spec/journal/journal.md, "Outcome".
902// MSVC's C4702 fires on the `return` below for any action whose handler never
903// returns -- a test double whose body is a bare `throw`, for instance. The
904// warning is correct for that instantiation and wrong as a verdict on this
905// statement, which every other instantiation reaches. It is suppressed here
906// rather than in each translation unit that instantiates such a handler,
907// because the set of those is open-ended: eleven test files already qualify.
908#ifdef _MSC_VER
909#pragma warning(push)
910#pragma warning(disable : 4702)
911#endif
912 auto result = [&] {
913 try {
914 return model.execute(action);
915 } catch (const std::exception& exc [[maybe_unused]]) {
916 if constexpr (detail::actionLoggable<Action>() == Loggable::Yes) {
917 if (holder.hasActionLog()) {
918 detail::recordActionFailure(holder, std::string{ModelTraits<Model>::typeId()},
919 std::string{ActionTraits<Action>::typeId()},
920 std::string{payloadJson}, detail::actionPayloadSchema<Action>(),
921 exc.what());
922 }
923 }
924 throw;
925 }
926 }();
927#ifdef _MSC_VER
928#pragma warning(pop)
929#endif
930 // Past this point the model's mutation has committed, so neither
931 // step below may be reported as an execution failure. Both can
932 // still throw -- resultToJson raises ParseError, and a sink that
933 // could not reach its backend is required to throw (see
934 // journal/action_log.hpp) -- and inside the try above that throw
935 // would tell the caller a durable write was rejected and file an
936 // Outcome::Failed entry naming the infrastructure fault as the
937 // action's own error. ActionRecordingError instead says what is
938 // true: the action ran, the reporting of it did not. A throw from
939 // resultToJson leaves no entry at all, because a Succeeded entry
940 // carries the result and there is none to carry.
941 std::string resultJson;
942 try {
943 resultJson = ActionTraits<Action>::resultToJson(result);
944 if constexpr (detail::actionLoggable<Action>() == Loggable::Yes) {
945 if (holder.hasActionLog()) {
946 // entityKey/principal/timestampMs are filled in by recordIfAttached.
947 detail::recordActionSuccess(holder, std::string{ModelTraits<Model>::typeId()},
948 std::string{ActionTraits<Action>::typeId()}, std::string{payloadJson},
949 detail::actionPayloadSchema<Action>(), resultJson);
950 }
951 }
952 } catch (const std::exception& exc) {
953 throw ActionRecordingError{std::move(resultJson), exc.what()};
954 }
955 return resultJson;
956 }
957
958 /// @brief The runner of a Task handler: prepares the action, starts the
959 /// handler on the model's strand, and -- when its Task completes --
960 /// journals the outcome and hands the JSON result or the exception to
961 /// @p done. See `docs/spec/core/coroutines.md`.
962 /// @tparam Model Concrete model type.
963 /// @tparam Action Concrete action type.
964 /// @param holder The model instance; kept alive by @p done's owner until
965 /// @p done has run.
966 /// @param payloadJson The action's JSON body.
967 /// @param executor The handler's resumer, on the model's strand.
968 /// @param token The stop token the handler observes.
969 /// @param done Called exactly once, on the strand.
970 template <typename Model, typename Action>
971 // runHandler's decode, validation and journalling, around a Task instead of
972 // a call; kept in one piece for the same reason runHandler is.
973 // NOLINTNEXTLINE(readability-function-cognitive-complexity)
974 static void runTaskHandler(IModelHolder& holder, std::string_view payloadJson,
975 const std::shared_ptr<::morph::exec::detail::TaskResumer>& executor,
976 ::core::async::StopToken token, DispatchDone done) {
977 using R = HandlerResultT<decltype(std::declval<Model&>().execute(std::declval<Action&>()))>;
978 // Owned here and kept by the completion callback: the handler may take
979 // the action by reference, and its frame outlives this call.
980 std::shared_ptr<Action> action;
981 ::core::async::Task<R> task;
982 try {
983 action = std::make_shared<Action>(prepareAction<Model, Action>(payloadJson));
984 task = holder.template into<Model>().execute(*action);
985 } catch (...) {
986 done(std::string{}, std::current_exception());
987 return;
988 }
989 detail::startTaskHandler<R>(
990 executor, std::move(task), std::move(token),
991 [&holder, action, payload = std::string{payloadJson}, done = std::move(done)](
992 std::optional<R> result, const std::exception_ptr& error) mutable {
993 // `holder` is read only when the action is loggable; this keeps the capture used otherwise.
994 static_cast<void>(holder);
995 if (error) {
996 // The handler failed, so the action was rejected: recorded
997 // Outcome::Failed, as runHandler records a throw from
998 // Model::execute. A journal write that throws here fails the
999 // call with the handler's own exception still.
1000 try {
1001 if constexpr (detail::actionLoggable<Action>() == Loggable::Yes) {
1002 if (holder.hasActionLog()) {
1003 try {
1004 std::rethrow_exception(error);
1005 } catch (const std::exception& exc) {
1006 detail::recordActionFailure(holder, std::string{ModelTraits<Model>::typeId()},
1007 std::string{ActionTraits<Action>::typeId()}, payload,
1008 detail::actionPayloadSchema<Action>(), exc.what());
1009 } catch (...) { // NOLINT(bugprone-empty-catch): as runHandler
1010 // Not a std::exception: recorded nowhere, as runHandler's
1011 // rethrow leaves such a throw unrecorded.
1012 }
1013 }
1014 }
1015 } catch (...) { // NOLINT(bugprone-empty-catch): the handler's exception is reported
1016 }
1017 done(std::string{}, error);
1018 return;
1019 }
1020 // The Task completed, so the model's mutation has committed: as in
1021 // runHandler, failing to serialise the result or to record it is an
1022 // ActionRecordingError, never a rejection.
1023 std::string resultJson;
1024 try {
1025 resultJson = ActionTraits<Action>::resultToJson(*result);
1026 if constexpr (detail::actionLoggable<Action>() == Loggable::Yes) {
1027 if (holder.hasActionLog()) {
1028 detail::recordActionSuccess(holder, std::string{ModelTraits<Model>::typeId()},
1029 std::string{ActionTraits<Action>::typeId()}, payload,
1030 detail::actionPayloadSchema<Action>(), resultJson);
1031 }
1032 }
1033 } catch (const std::exception& exc) {
1034 done(std::string{},
1035 std::make_exception_ptr(ActionRecordingError{std::move(resultJson), exc.what()}));
1036 return;
1037 } catch (...) {
1038 done(std::string{}, std::current_exception());
1039 return;
1040 }
1041 done(std::move(resultJson), nullptr);
1042 });
1043 }
1044
1045public:
1046 /// @brief Dispatches an action against @p holder and returns the JSON-encoded result.
1047 std::string dispatch(std::string_view modelId, std::string_view actionId, IModelHolder& holder,
1048 std::string_view payload) {
1049 MORPH_ZONE("ActionDispatcher::dispatch");
1050 MORPH_ZONE_TEXT(actionId);
1051 MORPH_ZONE_TEXT(detail::currentRequestId());
1052 // A dispatch means the maps are being read, which in the registration
1053 // model means the registration phase is over. Debug builds only; see
1054 // `detail::noteRegistryRead`.
1055 detail::noteRegistryRead(this == &defaultDispatcher());
1056 // Looked up by view. Building the stored `Key` to hash it would cost
1057 // two `std::string` constructions per dispatch -- and two heap
1058 // allocations whenever an id passes the SSO threshold, which
1059 // `"CreateSwimlane"` (14 characters) misses by one.
1060 auto iter = _actions.find(detail::PairKeyView{modelId, actionId});
1061 if (iter == _actions.end()) {
1062 // The concatenation here is the one string cost left, and it is on
1063 // the error path only: a dispatch that is about to throw can pay
1064 // for its own diagnostic.
1065 throw std::runtime_error("unknown action: " + std::string{modelId} + "/" + std::string{actionId});
1066 }
1067 return iter->second.runner(holder, payload);
1068 }
1069
1070 /// @brief Evaluates a body of `(modelId, actionId)` without running it.
1071 ///
1072 /// Decodes @p payload, reconciles its `Quantity` precision, recomputes its computed fields, and reports the
1073 /// result with its field errors and whether `dispatch` would execute it. No model instance is involved, nothing
1074 /// is journalled, and nothing changes, so an evaluation can be repeated freely. The answer for a body the
1075 /// gates refuse is a value, not a throw; only a body that does not decode throws.
1076 /// @param modelId The model's registered type id.
1077 /// @param actionId The action's registered type id.
1078 /// @param payload The action's JSON body.
1079 /// @return The evaluation.
1080 /// @throws std::runtime_error for an unregistered pair, or a body `ActionTraits<Action>::fromJson` refuses.
1081 [[nodiscard]] Evaluation evaluate(std::string_view modelId, std::string_view actionId,
1082 std::string_view payload) const {
1083 MORPH_ZONE("ActionDispatcher::evaluate");
1084 detail::noteRegistryRead(this == &defaultDispatcher());
1085 auto iter = _actions.find(detail::PairKeyView{modelId, actionId});
1086 if (iter == _actions.end()) {
1087 throw std::runtime_error("unknown action: " + std::string{modelId} + "/" + std::string{actionId});
1088 }
1089 return iter->second.evaluator(payload);
1090 }
1091
1092 /// @brief Dispatches an action against @p holder and hands its JSON result,
1093 /// or its exception, to @p done -- at once for an ordinary handler,
1094 /// when its Task completes for a Task handler.
1095 ///
1096 /// Called on the model's strand. A Task handler is started there and
1097 /// resumed through @p executor; see `docs/spec/core/coroutines.md`.
1098 /// @param modelId Model type-id.
1099 /// @param actionId Action type-id.
1100 /// @param holder The model instance; must stay alive until @p done has run.
1101 /// @param payload The action's JSON body.
1102 /// @param executor The handler's resumer, on the model's strand.
1103 /// @param token The stop token a Task handler observes.
1104 /// @param done Called exactly once, with the result or the exception.
1105 void dispatchAsync(std::string_view modelId, std::string_view actionId, IModelHolder& holder,
1106 std::string_view payload, const std::shared_ptr<::morph::exec::detail::TaskResumer>& executor,
1107 ::core::async::StopToken token, DispatchDone done) {
1108 MORPH_ZONE("ActionDispatcher::dispatchAsync");
1109 MORPH_ZONE_TEXT(detail::currentRequestId());
1110 const ActionEntry* entry = nullptr;
1111 try {
1112 detail::noteRegistryRead(this == &defaultDispatcher());
1113 auto iter = _actions.find(detail::PairKeyView{modelId, actionId});
1114 if (iter == _actions.end()) {
1115 throw std::runtime_error("unknown action: " + std::string{modelId} + "/" + std::string{actionId});
1116 }
1117 entry = &iter->second;
1118 } catch (...) {
1119 done(std::string{}, std::current_exception());
1120 return;
1121 }
1122 if (entry->asyncRunner != nullptr) {
1123 entry->asyncRunner(holder, payload, executor, std::move(token), std::move(done));
1124 return;
1125 }
1126 std::string result;
1127 try {
1128 result = entry->runner(holder, payload);
1129 } catch (...) {
1130 done(std::string{}, std::current_exception());
1131 return;
1132 }
1133 done(std::move(result), nullptr);
1134 }
1135
1136 /// @brief Returns whether `(modelId, actionId)` has a Task handler, which
1137 /// only `dispatchAsync` can run, rather than an ordinary one, which
1138 /// `dispatch` runs too.
1139 ///
1140 /// Lets a caller that already holds everything an ordinary dispatch needs
1141 /// call `dispatch` directly, and pay for what a Task handler needs -- a
1142 /// resumer, shared state for its callback -- only when there is one.
1143 /// @return False for an unknown pair, which `dispatch` then reports.
1144 [[nodiscard]] bool dispatchesAsync(std::string_view modelId, std::string_view actionId) const {
1145 auto iter = _actions.find(detail::PairKeyView{modelId, actionId});
1146 return iter != _actions.end() && iter->second.asyncRunner != nullptr;
1147 }
1148
1149 /// @brief Returns whether `(modelId, actionId)` was registered with
1150 /// `ActionLogPolicy<Action>::coalesce == true`.
1151 ///
1152 /// Used by `journal::SessionLog::checkpoint()` to decide, from the type-erased
1153 /// `LogEntry` stream, which entries collapse to their latest occurrence.
1154 /// Unknown pairs (never registered) default to `false` — every entry kept.
1155 [[nodiscard]] bool coalesce(std::string_view modelId, std::string_view actionId) const {
1156 auto iter = _actions.find(detail::PairKeyView{modelId, actionId});
1157 return iter != _actions.end() && iter->second.coalesce;
1158 }
1159
1160 /// @brief Returns the payload fingerprint `(modelId, actionId)` was
1161 /// registered with, or an empty string if the pair is unregistered.
1162 ///
1163 /// This is the type-erased half of the journal's payload-evolution check:
1164 /// `registerAction` knows the concrete `Action` and can compute
1165 /// `payloadFingerprint<Action>()`; `journal::replay()` sees only strings on
1166 /// a `LogEntry` and needs to ask for the current build's fingerprint by id.
1167 /// The empty return is not an error -- an entry naming an unregistered
1168 /// action fails at `dispatch()` with "unknown action" a moment later, which
1169 /// is the better diagnostic for that case.
1170 ///
1171 /// @param modelId Model type-id.
1172 /// @param actionId Action type-id.
1173 /// @return The registered fingerprint, or `""` if the pair is unregistered.
1174 [[nodiscard]] std::string schemaFor(std::string_view modelId, std::string_view actionId) const {
1175 auto iter = _actions.find(detail::PairKeyView{modelId, actionId});
1176 return iter == _actions.end() ? std::string{} : iter->second.schema;
1177 }
1178
1179 /// @brief Returns the `{actionType: schema}` document for every action
1180 /// registered under @p modelId.
1181 ///
1182 /// The wire's answer to `"schemas"` (`wire::makeSchemas`). Action ids are
1183 /// emitted in sorted order so two calls — and two servers built from the
1184 /// same sources — produce byte-identical documents; `_describe` is an
1185 /// unordered map, whose iteration order is not a contract.
1186 ///
1187 /// A model type with no registered actions yields `{}` rather than an
1188 /// error: "this type exposes no actions" and "this type does not exist"
1189 /// are not distinguishable here (`ActionDispatcher` never sees a model
1190 /// type that registered no action at all), and inventing a distinction
1191 /// would tell an unauthenticated prober which type ids are real.
1192 ///
1193 /// @param modelId Model type-id to describe.
1194 /// @return The `{actionType: schema}` JSON object.
1195 /// @throws morph::forms::UnsatisfiableFormError if one of the model's
1196 /// actions declares `formRules` no submission could satisfy —
1197 /// propagated from `morph::forms::schemaJson`, which decides that
1198 /// at generation time.
1199 [[nodiscard]] std::string schemasJson(std::string_view modelId) const {
1200 std::vector<std::string_view> actionIds;
1201 for (const auto& [key, entry] : _actions) {
1202 if (key.first == modelId) {
1203 actionIds.emplace_back(key.second);
1204 }
1205 }
1206 std::ranges::sort(actionIds);
1207 std::string out{"{"};
1208 bool first = true;
1209 for (std::string_view const actionId : actionIds) {
1210 if (!first) {
1211 out += ',';
1212 }
1213 first = false;
1214 // The id is a C++ string literal from BRIDGE_REGISTER_ACTION, but
1215 // it reaches a client as JSON, so it is written as JSON rather
1216 // than pasted between hand-written quotes.
1217 std::string quoted;
1218 (void)glz::write_json(std::string{actionId}, quoted);
1219 out += quoted;
1220 out += ':';
1221 const auto& desc = _actions.find(detail::PairKeyView{modelId, actionId})->second.describe();
1222 out += desc.schema.empty() ? "{}" : desc.schema;
1223 }
1224 out += '}';
1225 return out;
1226 }
1227
1228 /// @brief Returns the wire names `(modelId, actionId)`'s served schema
1229 /// marks `required`, or `nullptr` when there is no such list.
1230 ///
1231 /// The read side of `RemoteServer`'s optional
1232 /// `PayloadCompleteness::RequireDeclaredFields` gate. `nullptr` means
1233 /// *nothing to check* — the pair is unregistered, or its schema could not
1234 /// be generated at all — never *nothing is required*: an action whose
1235 /// description cannot be produced never published a requirement, so there
1236 /// is no published rule for the gate to enforce.
1237 ///
1238 /// @param modelId Model type-id.
1239 /// @param actionId Action type-id.
1240 /// @return Pointer to the required-field list, or `nullptr`.
1241 [[nodiscard]] const std::vector<std::string>* requiredFieldsFor(std::string_view modelId,
1242 std::string_view actionId) const {
1243 auto iter = _actions.find(detail::PairKeyView{modelId, actionId});
1244 if (iter == _actions.end()) {
1245 return nullptr;
1246 }
1247 try {
1248 return &iter->second.describe().required;
1249 } catch (const std::exception&) {
1250 return nullptr;
1251 }
1252 }
1253
1254 /// @brief The model type-ids @p actionId is registered under.
1255 ///
1256 /// An action id names one action, but the pair registered is
1257 /// `(model, action)`, so a server that knows only the action id, as a
1258 /// record watch's admission does, asks here which model types to
1259 /// authorize it against.
1260 ///
1261 /// @param actionId Action type-id.
1262 /// @return The model type-ids, sorted; empty for an unregistered action.
1263 [[nodiscard]] std::vector<std::string> modelsOf(std::string_view actionId) const {
1264 std::vector<std::string> models;
1265 for (const auto& [key, entry] : _actions) {
1266 if (key.second == actionId) {
1267 models.push_back(key.first);
1268 }
1269 }
1270 std::ranges::sort(models);
1271 return models;
1272 }
1273
1274 /// @brief The action type-ids registered under @p modelId.
1275 ///
1276 /// What a server authorizes an instance watch against: a principal may
1277 /// watch an instance of a model only when it may run one of its actions.
1278 ///
1279 /// @param modelId Model type-id.
1280 /// @return The action type-ids, sorted; empty for a model with none.
1281 [[nodiscard]] std::vector<std::string> actionsOf(std::string_view modelId) const {
1282 std::vector<std::string> actions;
1283 for (const auto& [key, entry] : _actions) {
1284 if (key.first == modelId) {
1285 actions.push_back(key.second);
1286 }
1287 }
1288 std::ranges::sort(actions);
1289 return actions;
1290 }
1291
1292 /// @brief Returns the process-level singleton dispatcher.
1293 static ActionDispatcher& instance() { return defaultDispatcher(); }
1294
1295private:
1296 using Key = std::pair<std::string, std::string>;
1297 /// @brief Returns the process-lifetime `ActionDescription` for one action.
1298 ///
1299 /// Type-erased so `schemasJson`/`requiredFieldsFor` can reach a concrete
1300 /// action's forms schema without knowing its type, and a *thunk* rather
1301 /// than the value so the cost (and the throw path) lands on the first
1302 /// caller instead of on static init.
1303 using Describer = std::function<const ActionDescription&()>;
1304
1305 /// @brief Everything registered under one `(modelId, actionId)` pair.
1306 ///
1307 /// One record, not four parallel maps keyed identically. Four maps filled
1308 /// together by `registerAction` could not go out of step in practice, but
1309 /// nothing would say so -- and `RemoteServer::handle` reaches this key
1310 /// three times on the way through one request (`schemaFor`,
1311 /// `requiredFieldsFor`, `dispatch`). One record makes the lockstep
1312 /// structural and makes those three one hash each rather than one hash
1313 /// into a different table each.
1314 struct ActionEntry {
1315 /// @brief Type-erased decode/execute/encode runner.
1316 Runner runner;
1317 /// @brief The Task handler runner, or null for an ordinary handler.
1318 AsyncRunner asyncRunner = nullptr;
1319 /// @brief The evaluator `evaluate` runs.
1320 Evaluator evaluator = nullptr;
1321 /// @brief `ActionLogPolicy<Action>::coalesce` as registered.
1322 bool coalesce = false;
1323 /// @brief `payloadFingerprint<Action>()` as registered.
1324 std::string schema;
1325 /// @brief Thunk yielding the process-lifetime `ActionDescription`.
1326 Describer describe;
1327 };
1328
1329 std::unordered_map<Key, ActionEntry, PairKeyHash, PairKeyEqual> _actions;
1330 /// @brief The action type each `(model, action)` id pair was registered for.
1331 std::unordered_map<Key, std::type_index, PairKeyHash, PairKeyEqual> _actionTypes;
1332};
1333
1334/// @brief Registry that creates `IModelHolder` instances by string type-id.
1335///
1336/// Used by `RemoteServer` to instantiate models on demand from incoming
1337/// `"register"` messages.
1338class ModelRegistryFactory {
1339public:
1340 /// @brief Registers a default-construction factory for `Model` under @p modelId.
1341 ///
1342 /// Equivalent to `registerModel<Model>(modelId, [] { return
1343 /// ModelFactory::create<Model>(); })` — the plain default-construction path
1344 /// every `BRIDGE_REGISTER_MODEL` invocation uses.
1345 /// @param modelId String id to register the factory under.
1346 template <typename Model>
1347 void registerModel(std::string_view modelId) {
1348 detail::claimId(_types, "model", std::string{modelId}, modelId, std::type_index{typeid(Model)});
1349 _factories.insert_or_assign(std::string{modelId}, [] { return ModelFactory::create<Model>(); });
1350 }
1351
1352 /// @brief Registers a custom construction hook for `Model` under @p modelId.
1353 ///
1354 /// The per-instance dependency-injection seam for registry-constructed
1355 /// (`Socket`-mode / `RemoteServer`) models — the equivalent of
1356 /// `Bridge::HandlerBinding::modelFactory` for the client-side `Local`-mode
1357 /// path. @p factory runs once per incoming `"register"` message (and once
1358 /// per fresh shared-instance creation) and may capture arbitrary
1359 /// per-instance dependencies (an injectable clock, a secondary log handle,
1360 /// a feature flag) that `Model`'s default constructor cannot reach — this
1361 /// is also the seam a model whose only constructor takes arguments (i.e.
1362 /// is not default-constructible at all) needs in order to be registered
1363 /// for remote/socket instantiation in the first place.
1364 ///
1365 /// @p factory returns an owning pointer to a freshly built holder (e.g.
1366 /// `std::make_unique<ModelHolder<Model>>(...)`), not a bare `Model` — the
1367 /// caller controls construction end-to-end, including passing
1368 /// constructor arguments `ModelHolder`'s forwarding constructor accepts.
1369 /// This overload does **not** auto-attach the process-wide default action
1370 /// log the way the default-construction overload does: a caller supplying
1371 /// its own factory is assumed to attach whatever log/identity it needs
1372 /// (via `IModelHolder::attachActionLog`) inside the closure, or to rely on
1373 /// `RemoteServer`'s `LogProvider` doing so afterward, exactly as the
1374 /// default path allows.
1375 ///
1376 /// @tparam Model Concrete model type. Used only to assert (debug builds)
1377 /// that @p factory's returned holder actually reports
1378 /// `typeid(Model)` — @p factory is otherwise free to build
1379 /// the holder however it likes, so nothing *prevents* a
1380 /// mismatched `Model`/@p factory pairing at compile time.
1381 /// @tparam Factory Callable returning an owning pointer convertible to
1382 /// `std::unique_ptr<IModelHolder>`; deduced from @p factory.
1383 /// @param modelId String id to register the factory under.
1384 /// @param factory Callable that constructs and returns a fresh holder.
1385 template <typename Model, typename Factory>
1386 requires std::invocable<Factory> &&
1387 std::convertible_to<std::invoke_result_t<Factory>, std::unique_ptr<IModelHolder>>
1388 void registerModel(std::string_view modelId, Factory factory) {
1389 // The same type may replace its factory: that is how a custom construction hook overrides the default.
1390 detail::claimId(_types, "model", std::string{modelId}, modelId, std::type_index{typeid(Model)});
1391 _factories.insert_or_assign(std::string{modelId}, [factory = std::move(factory)]() mutable {
1392 std::unique_ptr<IModelHolder> holder{factory()};
1393 // misc-static-assert fires here -- `holder` is a runtime
1394 // `std::unique_ptr` the factory just returned, so nothing in this condition
1395 // is a constant expression, and the check's own fix does not compile:
1396 // "static assertion expression is not an integral constant expression /
1397 // function parameter 'holder' with unknown value cannot be used in a constant
1398 // expression". Same finding and same suppression as the three
1399 // `register*Once` helpers below.
1400 //
1401 // Note the suppression is needed even though `clang-tidy-diff` analyses
1402 // changed lines only: without it, the next person to edit this line
1403 // inherits a red build that is not theirs.
1404 //
1405 // Last re-checked at clang-tidy 22.1.8, the version CI pins: the diagnostic
1406 // still fires and the suggested fix still does not compile. The stamp is here
1407 // because nothing re-checks it for you -- no script flags a directive that has
1408 // stopped suppressing anything, and nothing triggers a re-read on a
1409 // CLANG_VERSION bump. Re-read this when the pin moves; if the finding is gone,
1410 // delete all four directives together.
1411 // NOLINTNEXTLINE(misc-static-assert,cert-dcl03-c)
1412 assert(!holder ||
1413 (holder->type() == std::type_index(typeid(Model)) &&
1414 "registerModel<Model>(modelId, factory): factory returned a holder for a different type"));
1415 return holder;
1416 });
1417 }
1418
1419 /// @brief Creates a new model holder for the type registered under @p modelId.
1420 ///
1421 /// @param modelId Type-id the holder's factory was registered under.
1422 /// @param primary The instance's stable primary key, or empty for an
1423 /// anonymous (unshareable) instance. Forwarded to the new
1424 /// holder via `IModelHolder::attachIdentity` immediately
1425 /// after construction, which in turn reaches `Model`'s own
1426 /// `attachIdentity`, if it declares one matching
1427 /// `ModelIdentityAttachable` — a no-op otherwise, and a
1428 /// no-op entirely when @p primary is empty. This is what
1429 /// lets `RemoteServer` (whose two construction call sites
1430 /// already have `env.primary` in scope) tell a keyed
1431 /// model its own key once, at construction, rather than
1432 /// the model re-deriving it from every action's payload.
1433 std::unique_ptr<IModelHolder> create(std::string_view modelId, std::string_view primary = {}) {
1434 // See `ActionDispatcher::dispatch` -- same latch, same reason.
1435 detail::noteRegistryRead(this == &defaultRegistry());
1436 auto iter = _factories.find(std::string{modelId});
1437 if (iter == _factories.end()) {
1438 throw std::runtime_error("unknown model type: " + std::string{modelId});
1439 }
1440 auto holder = iter->second();
1441 holder->attachIdentity(std::string{primary});
1442 return holder;
1443 }
1444
1445 /// @brief Whether a model type is registered under @p modelId.
1446 /// @param modelId Model type-id.
1447 /// @return True when `create(modelId)` would find a factory.
1448 [[nodiscard]] bool contains(std::string_view modelId) const {
1449 detail::noteRegistryRead(this == &defaultRegistry());
1450 return _factories.contains(std::string{modelId});
1451 }
1452
1453 /// @brief Returns the process-level singleton registry.
1454 static ModelRegistryFactory& instance() { return defaultRegistry(); }
1455
1456private:
1457 std::unordered_map<std::string, std::function<std::unique_ptr<IModelHolder>()>> _factories;
1458 /// @brief The model type each id was registered for.
1459 std::unordered_map<std::string, std::type_index> _types;
1460};
1461
1462/// @brief Returns the process-level `ActionDispatcher` singleton.
1463inline ActionDispatcher& defaultDispatcher() {
1464 static ActionDispatcher inst;
1465 return inst;
1466}
1467
1468/// @brief Returns the process-level `ModelRegistryFactory` singleton.
1469inline ModelRegistryFactory& defaultRegistry() {
1470 static ModelRegistryFactory inst;
1471 return inst;
1472}
1473
1474// The three `register*Once` helpers are `noexcept` while allocating, and that
1475// is a decision rather than an oversight. Their only caller is the
1476// initialiser of a namespace-scope variable generated by `BRIDGE_REGISTER_*`,
1477// and [basic.start.dynamic]/[except.terminate] already call `std::terminate`
1478// when an exception escapes the dynamic initialisation of a non-local
1479// variable. Dropping `noexcept` would therefore not change what an OOM during
1480// static init does -- it would only move the `terminate` one frame outwards
1481// and cost the callers an unwind path that can never be taken. The `noexcept`
1482// says what the call site already guarantees. The assertions below are what
1483// enforce that the call site really is the only one.
1484
1485/// @brief Static-init helper for `BRIDGE_REGISTER_MODEL`.
1486template <typename Model>
1487inline bool registerModelOnce(std::string_view modelId) noexcept {
1488 // clang-tidy's misc-static-assert fires on any `assert(!f())` whose `f` takes no
1489 // argument, whether or not `f` is constexpr -- and this one loads an atomic.
1490 // Applying the check's own fix does not compile: `static_assert` on this
1491 // condition is "static assertion expression is not an integral constant
1492 // expression / non-constexpr function 'registrationPhaseClosed' cannot be used
1493 // in a constant expression". The finding is upstream of this repository.
1494 //
1495 // Last re-checked at clang-tidy 22.1.8, the version CI pins: the diagnostic
1496 // still fires and the suggested fix still does not compile. The stamp is here
1497 // because nothing re-checks it for you -- no script flags a directive that has
1498 // stopped suppressing anything, and nothing triggers a re-read on a
1499 // CLANG_VERSION bump. Re-read this when the pin moves; if the finding is gone,
1500 // delete all four directives together.
1501 // NOLINTNEXTLINE(misc-static-assert,cert-dcl03-c)
1502 assert(!::morph::model::registrationPhaseClosed() &&
1503 "registerModelOnce: registration after the registration phase closed. The process-level "
1504 "registries are unsynchronised and are read-only once dispatch begins -- registering now "
1505 "races their internals against concurrent lookups (see docs/spec/core/registry.md, "
1506 "\"Thread safety\"). Load and register plugin modules before the first dispatch.");
1507 try {
1508 ModelRegistryFactory::instance().registerModel<Model>(modelId);
1509 } catch (DuplicateRegistrationError const& error) {
1510 detail::abortRegistration(error);
1511 }
1512 return true;
1513}
1514
1515/// @brief Static-init helper for `BRIDGE_REGISTER_ACTION`.
1516template <typename Model, typename Action>
1517inline bool registerActionOnce(std::string_view modelId, std::string_view actionId) noexcept {
1518 // clang-tidy's misc-static-assert fires on any `assert(!f())` whose `f` takes no
1519 // argument, whether or not `f` is constexpr -- and this one loads an atomic.
1520 // Applying the check's own fix does not compile: `static_assert` on this
1521 // condition is "static assertion expression is not an integral constant
1522 // expression / non-constexpr function 'registrationPhaseClosed' cannot be used
1523 // in a constant expression". The finding is upstream of this repository.
1524 //
1525 // Last re-checked at clang-tidy 22.1.8, the version CI pins: the diagnostic
1526 // still fires and the suggested fix still does not compile. The stamp is here
1527 // because nothing re-checks it for you -- no script flags a directive that has
1528 // stopped suppressing anything, and nothing triggers a re-read on a
1529 // CLANG_VERSION bump. Re-read this when the pin moves; if the finding is gone,
1530 // delete all four directives together.
1531 // NOLINTNEXTLINE(misc-static-assert,cert-dcl03-c)
1532 assert(!::morph::model::registrationPhaseClosed() &&
1533 "registerActionOnce: registration after the registration phase closed. The process-level "
1534 "registries are unsynchronised and are read-only once dispatch begins -- registering now "
1535 "races their internals against concurrent lookups (see docs/spec/core/registry.md, "
1536 "\"Thread safety\"). Load and register plugin modules before the first dispatch.");
1537 try {
1538 ActionDispatcher::instance().registerAction<Model, Action>(modelId, actionId);
1539 } catch (DuplicateRegistrationError const& error) {
1540 detail::abortRegistration(error);
1541 }
1542 return true;
1543}
1544
1545/// @brief Static-init helper for every action registration: records where the
1546/// action's result holds which reflected aggregates.
1547///
1548/// Walks `ActionTraits<Action>::Result`, the unwrapped type, so a `Task<R>`
1549/// handler contributes `R`. Idempotent per action type: a registration repeated
1550/// in another translation unit does not walk again. A different action type
1551/// under an id already taken keeps the first type's slots and lists the id in
1552/// `actionResultConflicts()`.
1553/// @tparam Action The action type; `ActionTraits<Action>` must be visible.
1554/// @param actionId The action's registered type-id.
1555/// @return Always true, so the result can initialise a namespace-scope variable.
1556template <typename Action>
1557inline bool registerActionResultOnce(std::string_view actionId) noexcept {
1558 // NOLINTNEXTLINE(misc-static-assert,cert-dcl03-c)
1559 assert(!::morph::model::registrationPhaseClosed() &&
1560 "registerActionResultOnce: registration after the registration phase closed. The process-level "
1561 "registries are unsynchronised and are read-only once dispatch begins -- registering now "
1562 "races their internals against concurrent lookups. Load and register plugin modules before "
1563 "the first dispatch.");
1564 std::type_index const action{typeid(Action)};
1565 auto& entries = mutableActionResultSlots();
1566 if (auto const existing = entries.find(actionId); existing != entries.end()) {
1567 if (existing->second.action != action) {
1568 auto& conflicts = mutableActionResultConflicts();
1569 if (std::ranges::find(conflicts, actionId) == conflicts.end()) {
1570 conflicts.emplace_back(actionId);
1571 }
1572 }
1573 return true;
1574 }
1575 entries.emplace(
1576 std::string{actionId},
1577 ActionResult{.action = action, .resultSlots = walkResult<typename ActionTraits<Action>::Result>()});
1578 return true;
1579}
1580
1581/// @brief Static-init helper for `BRIDGE_REGISTER_ACTION`'s generic-execute registration.
1582///
1583/// Forward declaration only; the definition is in `bridge.hpp` (after `ActionExecuteRegistry`
1584/// is visible) to avoid a `registry.hpp` -> `bridge.hpp` include cycle. `bridge.hpp` already
1585/// includes `registry.hpp`, not the other way round.
1586///
1587/// HARD REQUIREMENT: `BRIDGE_REGISTER_ACTION_4` calls this function unconditionally, but only
1588/// declares it here — it does NOT define it. Any translation unit that invokes
1589/// `BRIDGE_REGISTER_ACTION` MUST include `<morph/core/bridge.hpp>` (directly or transitively)
1590/// somewhere in the same translation unit, or the build will fail to link with an unresolved
1591/// external symbol for this function.
1592template <typename Model, typename Action>
1593bool registerActionExecutorOnce(std::string_view modelId, std::string_view actionId) noexcept;
1594
1595/// @brief Link-time canary for `BRIDGE_DECLARE_MODEL` / `BRIDGE_REGISTER_MODEL_SOURCE`.
1596///
1597/// `BRIDGE_DECLARE_MODEL(M, NAME)` calls this specialisation behind an
1598/// `extern template` declaration, which suppresses implicit instantiation of
1599/// this always-visible, trivial definition and instead demands an *explicit*
1600/// instantiation elsewhere in the link. `BRIDGE_REGISTER_MODEL_SOURCE(M)` is
1601/// the only macro that provides one. A model declared via
1602/// `BRIDGE_DECLARE_MODEL` but never registered via
1603/// `BRIDGE_REGISTER_MODEL_SOURCE` anywhere in the same binary therefore fails
1604/// to *link* with an unresolved symbol naming this function, instead of
1605/// compiling clean and surfacing only as `ModelRegistryFactory::create`'s
1606/// runtime `"unknown model type"`, far from the cause. The body does nothing;
1607/// only whether an explicit instantiation for `Model` exists anywhere the
1608/// linker looks is being tested.
1609/// @tparam Model Concrete model type a `BRIDGE_DECLARE_MODEL` call named.
1610template <typename Model>
1611void modelSourceRegistrationRequired() {}
1612
1613/// @brief Link-time canary for `BRIDGE_DECLARE_ACTION` / `BRIDGE_REGISTER_ACTION_SOURCE`.
1614///
1615/// Same mechanism as `modelSourceRegistrationRequired` above, keyed on the
1616/// `(Model, Action)` pair `BRIDGE_DECLARE_ACTION` and
1617/// `BRIDGE_REGISTER_ACTION_SOURCE` were each invoked with.
1618/// @tparam Model Model type the action belongs to.
1619/// @tparam Action Concrete action type a `BRIDGE_DECLARE_ACTION` call named.
1620template <typename Model, typename Action>
1621void actionSourceRegistrationRequired() {}
1622
1623} // namespace detail
1624
1625} // namespace morph::model
1626
1627// NOLINTBEGIN(bugprone-macro-parentheses)
1628// NOLINTBEGIN(cppcoreguidelines-macro-usage) — registration macros are the intended public API
1629// The registrars below are namespace-scope `const bool`s whose initialisers run
1630// before main, inside an unnamed namespace so each translation unit gets its own
1631// copy — and `BRIDGE_DECLARE_*` places that in a header by design. clang-tidy
1632// attributes these to every expansion site, so they are suppressed here, once:
1633// * bugprone-throwing-static-initialization / cert-err58-cpp -- registration
1634// cannot be wrapped in a try block; a throw aborts at start-up, which is the
1635// right outcome for a type that cannot be registered.
1636// * misc-anonymous-namespace-in-header / cert-dcl59-cpp -- the per-TU copy is
1637// the point: it is what ODR-uses the link canary in each including TU.
1638// NOLINTBEGIN(bugprone-throwing-static-initialization,cert-err58-cpp,misc-anonymous-namespace-in-header,cert-dcl59-cpp)
1639
1640// Keying the generated registrar names on `__COUNTER__` (rather than on the type spelling)
1641// keeps them valid identifiers regardless of how `M`/`A` are written. A namespace-qualified
1642// or template type (e.g. `app::models::Report`) would otherwise be pasted directly into the
1643// identifier, producing invalid tokens like `bridge_model_reg_app::models::Report`.
1644// `__LINE__` was tried first, but it is only unique *within a single physical file*: two
1645// headers that each invoke this macro on the same line number (e.g. `card_model.hpp:38` and
1646// `payment_model.hpp:38`) produce the same identifier once both are `#include`d into one
1647// translation unit, and since C++ unnamed namespaces are per-TU (not per-file), that is a
1648// hard redefinition error. `__COUNTER__` increments monotonically for the entire translation
1649// unit regardless of which file expands it, so it cannot collide this way. The extra
1650// indirection (`_CAT` calling `_CAT_`) is required so `__COUNTER__` is expanded to its numeric
1651// value before the paste, rather than being pasted as the literal text "__COUNTER__".
1652#define BRIDGE_DETAIL_CAT_(a, b) a##b
1653#define BRIDGE_DETAIL_CAT(a, b) BRIDGE_DETAIL_CAT_(a, b)
1654
1655/// @brief Suppresses the two registrars whose bodies reference a model's
1656/// constructor/`execute()` definitions (`registerModelOnce`,
1657/// `registerActionOnce`), for a pure client that dispatches every
1658/// action to a remote peer and never constructs a model locally.
1659///
1660/// A model's constructor and `Model::execute` are ordinary functions the
1661/// compiler emits a call to inside these registrars' lambda bodies, whether
1662/// or not that lambda is ever invoked at runtime — so a plain client-only
1663/// build still forces the linker to resolve them, pulling in implementations
1664/// (a database driver, a native UI framework, an OS-specific API) the client
1665/// target may have no link path for at all, and will never call regardless.
1666/// The executor registrar (`registerActionExecutorOnce`, `bridge.hpp`) routes
1667/// through `Bridge::executeVia` -> `IBackend::execute` (fully abstract) and
1668/// needs only the action's JSON codecs plus `Model::execute`'s *declaration*
1669/// (for `ActionTraits::Result`, via `decltype`) — never its definition — so
1670/// it is unaffected and always emitted.
1671///
1672/// The result-slot registrar (`registerActionResultOnce`) references no model
1673/// code, but it is suppressed too: it records facts only a process that hosts
1674/// models serves, and a pure client takes them from the server's catalog.
1675///
1676/// Defined on the `morph` target's INTERFACE via the `MORPH_CLIENT_ONLY`
1677/// CMake option, never per translation unit: two TUs disagreeing on whether a
1678/// model registers itself would violate ODR. See docs/spec/core/registry.md,
1679/// "MORPH_CLIENT_ONLY".
1680///
1681/// @warning NEVER define this for a process that hosts models (a server, or
1682/// any `Bridge` running `LocalBackend`) — it silently registers nothing, and
1683/// the model fails at runtime with "unknown model type" rather than at
1684/// compile/link time.
1685/// @brief Emits the `extern template` link requirement `BRIDGE_DECLARE_MODEL`/
1686/// `BRIDGE_DECLARE_ACTION` place in the header (`MORPH_DETAIL_REQUIRE_SOURCE_LOCAL`),
1687/// and the matching explicit-instantiation definition
1688/// `BRIDGE_REGISTER_MODEL_SOURCE`/`BRIDGE_REGISTER_ACTION_SOURCE` place
1689/// in the `.cpp` (`MORPH_DETAIL_DEFINE_SOURCE_LOCAL`).
1690///
1691/// One pair of macros for both the model and action canaries: @p ...
1692/// carries the whole call expression (`modelSourceRegistrationRequired<M>()`
1693/// or `actionSourceRegistrationRequired<M, A>()`), passed through the
1694/// variadic parameter rather than a fixed one so the template argument
1695/// list's own comma isn't mistaken for a macro-argument separator. @p PREFIX
1696/// (`MORPH_DETAIL_REQUIRE_SOURCE_LOCAL` only) names the generated variable,
1697/// same role as the fixed prefixes `MORPH_DETAIL_REGISTER_MODEL_LOCAL`/
1698/// `MORPH_DETAIL_REGISTER_ACTION_LOCAL` above use directly.
1699#ifdef MORPH_CLIENT_ONLY
1700#define MORPH_DETAIL_REGISTER_MODEL_LOCAL(M, NAME)
1701#define MORPH_DETAIL_REGISTER_ACTION_LOCAL(M, A, NAME)
1702#define MORPH_DETAIL_REGISTER_RESULT_LOCAL(A)
1703#define MORPH_DETAIL_REQUIRE_SOURCE_LOCAL(PREFIX, ...)
1704#define MORPH_DETAIL_DEFINE_SOURCE_LOCAL(...)
1705#else
1706// clang-format off
1707// Hand-aligned: clang-format pulls the short registerModelOnce() call up onto the
1708// BRIDGE_DETAIL_CAT line and then breaks *inside* the token-paste invocation, which
1709// also splits the pair apart visually -- MORPH_DETAIL_REGISTER_ACTION_LOCAL has a
1710// longer right-hand side and so survives untouched. Keep the two parallel by hand.
1711#define MORPH_DETAIL_REGISTER_MODEL_LOCAL(M, NAME) \
1712 namespace { \
1713 [[maybe_unused]] const bool BRIDGE_DETAIL_CAT(bridge_model_reg_, __COUNTER__) = \
1714 morph::model::detail::registerModelOnce<M>(NAME); \
1715 }
1716#define MORPH_DETAIL_REGISTER_ACTION_LOCAL(M, A, NAME) \
1717 namespace { \
1718 [[maybe_unused]] const bool BRIDGE_DETAIL_CAT(bridge_action_reg_, __COUNTER__) = \
1719 morph::model::detail::registerActionOnce<M, A>(morph::model::ModelTraits<M>::typeId(), NAME); \
1720 }
1721#define MORPH_DETAIL_REGISTER_RESULT_LOCAL(A) \
1722 namespace { \
1723 [[maybe_unused]] const bool BRIDGE_DETAIL_CAT(bridge_action_result_reg_, __COUNTER__) = \
1724 morph::model::detail::registerActionResultOnce<A>(morph::model::ActionTraits<A>::typeId()); \
1725 }
1726#define MORPH_DETAIL_REQUIRE_SOURCE_LOCAL(PREFIX, ...) \
1727 extern template void __VA_ARGS__; \
1728 namespace { \
1729 [[maybe_unused]] const bool BRIDGE_DETAIL_CAT(PREFIX, __COUNTER__) = (__VA_ARGS__, true); \
1730 }
1731#define MORPH_DETAIL_DEFINE_SOURCE_LOCAL(...) template void __VA_ARGS__;
1732// clang-format on
1733#endif
1734
1735/// @brief `ModelTraits<M>`'s specialisation alone -- the type-only half
1736/// `BRIDGE_REGISTER_MODEL` and `BRIDGE_DECLARE_MODEL` both build on, so
1737/// the two cannot drift apart the way two independently hand-written
1738/// copies could.
1739/// @param M Concrete model type.
1740/// @param NAME String literal used as the type-id.
1741// clang-format off -- public macro surface; see CONTRIBUTING.md, "Formatting/linting".
1742#define BRIDGE_DETAIL_MODEL_TRAITS_ONLY_BODY(M, NAME) \
1743 template <> \
1744 struct morph::model::ModelTraits<M> { \
1745 static constexpr std::string_view typeId() noexcept { return NAME; } \
1746 };
1747// clang-format on
1748
1749/// @brief Registers model type @p M with the string id @p NAME.
1750///
1751/// Specialises `morph::model::ModelTraits<M>` and registers a factory with the
1752/// process-level `ModelRegistryFactory` at static-init time — unless
1753/// `MORPH_CLIENT_ONLY` is defined, in which case that registrar is suppressed
1754/// (see `MORPH_DETAIL_REGISTER_MODEL_LOCAL`'s doc comment above).
1755///
1756/// @param M Concrete model type.
1757/// @param NAME String literal used as the type-id.
1758// clang-format off -- public macro surface; see CONTRIBUTING.md, "Formatting/linting".
1759#define BRIDGE_REGISTER_MODEL(M, NAME) \
1760 BRIDGE_DETAIL_MODEL_TRAITS_ONLY_BODY(M, NAME) \
1761 MORPH_DETAIL_REGISTER_MODEL_LOCAL(M, NAME)
1762// clang-format on
1763
1764/// @brief Declares model type @p M's `ModelTraits<M>` specialisation without
1765/// registering it -- the header-safe half `BRIDGE_REGISTER_MODEL`
1766/// decomposes into.
1767///
1768/// `BRIDGE_REGISTER_MODEL`'s registrar initialiser instantiates
1769/// `registerModelOnce<M>` in every translation unit that includes the header
1770/// it sits in; `BRIDGE_DECLARE_MODEL` emits only the trait specialisation
1771/// (essentially free to repeat per TU) and defers the actual registration to
1772/// `BRIDGE_REGISTER_MODEL_SOURCE(M)`, called once in the one `.cpp` that
1773/// should own it. See docs/spec/core/registry.md, "Moving a registrar out of
1774/// the header", for when this is worth doing and for the link-time canary
1775/// this macro emits in @p M's place: a `BRIDGE_DECLARE_MODEL` with no
1776/// matching `BRIDGE_REGISTER_MODEL_SOURCE` anywhere in the link fails to
1777/// *link* with an unresolved symbol, rather than compiling clean and only
1778/// surfacing as `ModelRegistryFactory::create`'s runtime
1779/// `"unknown model type"`.
1780///
1781/// @param M Concrete model type.
1782/// @param NAME String literal used as the type-id.
1783// clang-format off -- public macro surface; see CONTRIBUTING.md, "Formatting/linting".
1784#define BRIDGE_DECLARE_MODEL(M, NAME) \
1785 BRIDGE_DETAIL_MODEL_TRAITS_ONLY_BODY(M, NAME) \
1786 MORPH_DETAIL_REQUIRE_SOURCE_LOCAL(bridge_model_src_req_, \
1787 morph::model::detail::modelSourceRegistrationRequired<M>())
1788// clang-format on
1789
1790/// @brief Registers model type @p M -- previously declared via
1791/// `BRIDGE_DECLARE_MODEL(M, NAME)` -- with the process-level
1792/// `ModelRegistryFactory` at static-init time.
1793///
1794/// Call exactly once, in the one `.cpp` that owns @p M's registration --
1795/// typically the translation unit that defines @p M's out-of-line members.
1796/// Requires `morph::model::ModelTraits<M>` already visible (from
1797/// `BRIDGE_DECLARE_MODEL` or `BRIDGE_REGISTER_MODEL` in an included header)
1798/// and reads its `typeId()` rather than taking a `NAME` argument again, so the
1799/// two cannot register under a different string than the one @p M's traits
1800/// actually report. Suppressed under `MORPH_CLIENT_ONLY`, exactly like the
1801/// registrar `BRIDGE_REGISTER_MODEL` emits directly.
1802///
1803/// @param M Concrete model type, already declared via `BRIDGE_DECLARE_MODEL`.
1804// clang-format off -- public macro surface; see CONTRIBUTING.md, "Formatting/linting".
1805#define BRIDGE_REGISTER_MODEL_SOURCE(M) \
1806 MORPH_DETAIL_DEFINE_SOURCE_LOCAL(morph::model::detail::modelSourceRegistrationRequired<M>()) \
1807 MORPH_DETAIL_REGISTER_MODEL_LOCAL(M, morph::model::ModelTraits<M>::typeId())
1808// clang-format on
1809
1810/// @brief Registers action type @p A (for model @p M) with the string id @p NAME.
1811///
1812/// Specialises `morph::model::ActionTraits<A>` with JSON codec functions and
1813/// registers the action with the process-level `ActionDispatcher` at static-init time.
1814/// `BRIDGE_REGISTER_MODEL(M, ...)` must be called before this macro.
1815///
1816/// An optional 4th argument overrides whether executions of @p A are recorded to
1817/// an attached action log (see `IModelHolder::attachActionLog`); omitted, it
1818/// defaults to `morph::model::Loggable::Yes`, so every action is captured unless
1819/// explicitly opted out — typically for pure queries:
1820/// @code
1821/// BRIDGE_REGISTER_ACTION(AccountModel, Deposit, "Deposit") // logged
1822/// BRIDGE_REGISTER_ACTION(AccountModel, GetAccount, "GetAccount", morph::model::Loggable::No) // opt out
1823/// @endcode
1824///
1825/// HARD REQUIREMENT: this macro's expansion unconditionally calls
1826/// `morph::model::detail::registerActionExecutorOnce<M, A>`, which is only *declared* in
1827/// `registry.hpp` and *defined* in `<morph/core/bridge.hpp>`. Every translation unit that invokes
1828/// `BRIDGE_REGISTER_ACTION` MUST include `<morph/core/bridge.hpp>` (directly or transitively) in
1829/// that same translation unit, or the build will fail to link with an unresolved external
1830/// symbol for `registerActionExecutorOnce<M, A>`.
1831///
1832/// @param M Concrete model type that handles the action.
1833/// @param A Concrete action type.
1834/// @param NAME String literal used as the action type-id.
1835/// @param ... Optional: a `morph::model::Loggable` value (defaults to `Loggable::Yes`).
1836// clang-format off -- public macro surface; see CONTRIBUTING.md, "Formatting/linting".
1837#define BRIDGE_REGISTER_ACTION(...) \
1838 BRIDGE_REGISTER_ACTION_PICK(__VA_ARGS__, BRIDGE_REGISTER_ACTION_4, BRIDGE_REGISTER_ACTION_3) \
1839 (__VA_ARGS__)
1840// clang-format on
1841
1842/// @brief Registers action type @p A (for model @p M) the same way
1843/// `BRIDGE_REGISTER_ACTION` does, but names the action's result type
1844/// @p RESULT explicitly instead of deducing it from
1845/// `decltype(std::declval<M&>().execute(std::declval<A>()))`.
1846///
1847/// `BRIDGE_REGISTER_ACTION`'s `Result` deduction requires `M` to be a
1848/// **complete type with `execute(A)` declared** at the point of registration
1849/// — ordinarily the model's own header, which a client must then `#include`
1850/// (and everything that header transitively pulls in — a persistence mixin's
1851/// database-driver headers, for a model backed by one) just to compute
1852/// `ActionTraits<A>::Result`, even under `MORPH_CLIENT_ONLY`, where the
1853/// client never constructs `M` or calls `M::execute` at all (see
1854/// `docs/spec/core/registry.md`, "MORPH_CLIENT_ONLY").
1855///
1856/// A pure client names @p RESULT directly instead, so @p M can be a
1857/// lightweight, declaration-only facade type — forward-declared or declaring
1858/// no members at all — carrying none of the real model's persistence
1859/// surface. @p M still needs `BRIDGE_REGISTER_MODEL(M, ...)` (for
1860/// `ModelTraits<M>::typeId()`, itself needing no completeness either) and is
1861/// used only as `BridgeHandler<M>`'s template tag and dispatch-registry key;
1862/// nothing on the path a `MORPH_CLIENT_ONLY` build actually calls
1863/// (`BridgeHandler<M>::execute<A>()`/`executeJson`, routed through
1864/// `Bridge::executeVia`) reads a member of `M`, so `M` need not be complete
1865/// at the call site at all.
1866///
1867/// A build that does **not** define `MORPH_CLIENT_ONLY` may also use this
1868/// macro — the emitted `ActionTraits<A>` specialisation is identical either
1869/// way — but gets none of the header-avoidance benefit unless `M` really is
1870/// left incomplete, since something in that same link must still define the
1871/// real model to satisfy `MORPH_DETAIL_REGISTER_MODEL_LOCAL`/
1872/// `MORPH_DETAIL_REGISTER_ACTION_LOCAL`'s registrars (suppressed only under
1873/// `MORPH_CLIENT_ONLY`).
1874///
1875/// @warning @p RESULT must name the exact same type `BRIDGE_REGISTER_ACTION`
1876/// would have deduced from the real model's `M::execute(A)`. Nothing checks
1877/// this — a mismatch is a silent JSON (de)serialisation bug (wrong shape
1878/// on the wire), not a compile error, since `resultToJson`/`resultFromJson`
1879/// below are instantiated against whatever @p RESULT names, and the server
1880/// side (registered separately, from the real model's own header, via the
1881/// plain `BRIDGE_REGISTER_ACTION`) still serialises the type `M::execute`
1882/// actually returns.
1883///
1884/// @param M Model type -- may be a declaration-only facade under
1885/// `MORPH_CLIENT_ONLY`; must be the real, complete model type
1886/// otherwise (see above).
1887/// @param A Concrete action type.
1888/// @param RESULT The action's result type, named explicitly.
1889/// @param NAME String literal used as the action type-id.
1890/// @param ... Optional: a `morph::model::Loggable` value (defaults to `Loggable::Yes`).
1891// clang-format off -- public macro surface; see CONTRIBUTING.md, "Formatting/linting".
1892#define BRIDGE_REGISTER_ACTION_FOR_CLIENT(...) \
1893 BRIDGE_REGISTER_ACTION_FOR_CLIENT_PICK(__VA_ARGS__, BRIDGE_REGISTER_ACTION_FOR_CLIENT_5, \
1894 BRIDGE_REGISTER_ACTION_FOR_CLIENT_4) \
1895 (__VA_ARGS__)
1896// clang-format on
1897
1898/// @cond detail
1899#define BRIDGE_REGISTER_ACTION_FOR_CLIENT_PICK(_1, _2, _3, _4, _5, NAME, ...) NAME
1900
1901// clang-format off -- public macro surface; see CONTRIBUTING.md, "Formatting/linting".
1902#define BRIDGE_REGISTER_ACTION_FOR_CLIENT_4(M, A, RESULT, NAME) \
1903 BRIDGE_REGISTER_ACTION_FOR_CLIENT_5(M, A, RESULT, NAME, ::morph::model::Loggable::Yes)
1904// clang-format on
1905
1906/// @brief `ActionTraits<A>`'s specialisation alone -- the type-only half
1907/// `BRIDGE_DETAIL_ACTION_TRAITS_BODY` decomposes into, reused directly
1908/// by `BRIDGE_DECLARE_ACTION` for the case that wants the trait
1909/// specialisation in a header without registering anything there.
1910/// Parameterised only on how `Result` is spelled, exactly like
1911/// `BRIDGE_DETAIL_ACTION_TRAITS_BODY` below.
1912/// @param M Model type (see each public macro's own doc comment for
1913/// the completeness requirement `RESULT_ALIAS` differs on).
1914/// @param A Concrete action type.
1915/// @param RESULT_ALIAS Expression naming the action's result type -- either
1916/// `RESULT` (named explicitly) or the `decltype(...)`
1917/// deduction from `M::execute(A)`.
1918/// @param NAME String literal used as the action type-id.
1919/// @param LOGGABLE A `morph::model::Loggable` value.
1920// clang-format off -- public macro surface; see CONTRIBUTING.md, "Formatting/linting".
1921#define BRIDGE_DETAIL_ACTION_TRAITS_ONLY_BODY(M, A, RESULT_ALIAS, NAME, LOGGABLE) \
1922 template <> \
1923 struct morph::model::ActionTraits<A> { \
1924 using Result = RESULT_ALIAS; \
1925 static constexpr std::string_view typeId() { return NAME; } \
1926 static constexpr ::morph::model::Loggable loggable = (LOGGABLE); \
1927 /* Structural fingerprint of the JSON shape the codecs below read and write. */ \
1928 /* Stamped on every journal entry and checked on replay -- see */ \
1929 /* morph::model::payloadFingerprint and docs/spec/journal/journal.md. */ \
1930 static const std::string& payloadSchema() { return ::morph::model::payloadFingerprint<A>(); } \
1931 static std::string toJson(const A& action) { \
1932 std::string out; \
1933 /* EscapingWriteOpts, not write_json: a raw control byte in any */ \
1934 /* caller-supplied string field would otherwise produce a body the */ \
1935 /* peer's reader rejects, or be silently mangled by glaze's chunked */ \
1936 /* fast path — see its doc comment in registry.hpp. */ \
1937 if (auto const errCode = glz::write<::morph::model::detail::EscapingWriteOpts{}>(action, out)) { \
1938 throw morph::model::detail::ParseError{glz::format_error(errCode, out)}; \
1939 } \
1940 return out; \
1941 } \
1942 static A fromJson(std::string_view jsonStr) { \
1943 A action{}; \
1944 /* null_terminated=false: jsonStr is a caller-supplied view with no */ \
1945 /* guaranteed trailing '\0' (e.g. an execute envelope's `body`) — see */ \
1946 /* the identical fix + rationale on morph::wire::decode (wire.hpp). */ \
1947 static constexpr glz::opts kLenientRead{.null_terminated = false, .error_on_unknown_keys = false}; \
1948 /* The codec boundary for an action payload: morph::wire carries `body` as \
1949 an opaque string and never parses it, so this is the first and only \
1950 place a Rational inside it is decoded. A Rational decode cannot fail -- \
1951 it clamps what it cannot represent -- so {"num":5,"den":0,"dp":2} would \
1952 otherwise arrive as a plausible 5/1 that no model-level validate() could \
1953 recognise as altered. Deciding that a silently-altered payload is a \
1954 protocol violation belongs here, where we know the bytes came off a wire. */ \
1955 ::morph::math::WireClampScope const clampedRationals; \
1956 if (auto const errCode = glz::read<kLenientRead>(action, jsonStr)) { \
1957 throw morph::model::detail::ParseError{glz::format_error(errCode, jsonStr)}; \
1958 } \
1959 if (clampedRationals.clamped() != 0) { \
1960 throw morph::model::detail::ParseError{ \
1961 "action body contains a Rational that cannot be represented exactly"}; \
1962 } \
1963 return action; \
1964 } \
1965 static std::string resultToJson(const Result& result) { \
1966 std::string out; \
1967 /* EscapingWriteOpts: see toJson() above — a result body carries */ \
1968 /* caller data back (a paste's content, a fetched record) and needs */ \
1969 /* the identical treatment. */ \
1970 if (auto const errCode = glz::write<::morph::model::detail::EscapingWriteOpts{}>(result, out)) { \
1971 throw morph::model::detail::ParseError{glz::format_error(errCode, out)}; \
1972 } \
1973 return out; \
1974 } \
1975 static Result resultFromJson(std::string_view jsonStr) { \
1976 Result result{}; \
1977 /* null_terminated=false: see the identical fix on fromJson() above. */ \
1978 static constexpr glz::opts kLenientRead{.null_terminated = false, .error_on_unknown_keys = false}; \
1979 if (auto const errCode = glz::read<kLenientRead>(result, jsonStr)) { \
1980 throw morph::model::detail::ParseError{glz::format_error(errCode, jsonStr)}; \
1981 } \
1982 return result; \
1983 } \
1984 };
1985// clang-format on
1986
1987/// @brief The registrar blocks `BRIDGE_DETAIL_ACTION_TRAITS_BODY` appends
1988/// after `BRIDGE_DETAIL_ACTION_TRAITS_ONLY_BODY`'s trait
1989/// specialisation, factored out so `BRIDGE_REGISTER_ACTION_SOURCE` can
1990/// emit exactly the same registrars from a `.cpp`, without
1991/// repeating the trait specialisation `BRIDGE_DECLARE_ACTION` already
1992/// placed in the header.
1993///
1994/// There are three: the dispatcher's runner (`registerActionOnce`), the
1995/// result's slots (`registerActionResultOnce`), both suppressed under
1996/// `MORPH_CLIENT_ONLY`, and the generic executor
1997/// (`registerActionExecutorOnce`), always emitted.
1998///
1999/// Reads `morph::model::ActionTraits<A>::typeId()` rather than taking a
2000/// `NAME` parameter, so a `BRIDGE_DECLARE_ACTION`/`BRIDGE_REGISTER_ACTION_SOURCE`
2001/// pair cannot register under a different string than the one @p A's traits
2002/// specialisation actually reports -- there is only one place `NAME` is
2003/// spelled, in whichever macro declared the traits.
2004/// @param M Model type that handles the action.
2005/// @param A Concrete action type; `morph::model::ActionTraits<A>` must already
2006/// be visible.
2007// clang-format off -- public macro surface; see CONTRIBUTING.md, "Formatting/linting".
2008#define BRIDGE_DETAIL_ACTION_REGISTRARS(M, A) \
2009 MORPH_DETAIL_REGISTER_ACTION_LOCAL(M, A, morph::model::ActionTraits<A>::typeId()) \
2010 MORPH_DETAIL_REGISTER_RESULT_LOCAL(A) \
2011 namespace { \
2012 [[maybe_unused]] const bool BRIDGE_DETAIL_CAT(bridge_action_exec_reg_, __COUNTER__) = \
2013 morph::model::detail::registerActionExecutorOnce<M, A>(morph::model::ModelTraits<M>::typeId(), \
2014 morph::model::ActionTraits<A>::typeId()); \
2015 }
2016// clang-format on
2017
2018/// @brief Shared body for `ActionTraits<A>`'s specialisation plus its
2019/// registrars -- everything `BRIDGE_REGISTER_ACTION_4` and
2020/// `BRIDGE_REGISTER_ACTION_FOR_CLIENT_5` expand to alike, parameterised
2021/// only on how `Result` is spelled. Composes
2022/// `BRIDGE_DETAIL_ACTION_TRAITS_ONLY_BODY` (the type) with
2023/// `BRIDGE_DETAIL_ACTION_REGISTRARS` (the registrars) -- the same
2024/// two pieces `BRIDGE_DECLARE_ACTION`/`BRIDGE_REGISTER_ACTION_SOURCE`
2025/// emit separately, in a header and a `.cpp` respectively.
2026///
2027/// `BRIDGE_REGISTER_ACTION_4` deduces `Result` from `M::execute(A)`, which
2028/// requires `M` complete at the call site; `BRIDGE_REGISTER_ACTION_FOR_CLIENT_5`
2029/// takes `RESULT_ALIAS` as an explicit type instead, so `M` may stay
2030/// declaration-only (see `BRIDGE_REGISTER_ACTION_FOR_CLIENT`'s doc comment).
2031/// That one line is the only difference between the two public macros; every
2032/// other member here is byte-identical between them.
2033/// @param M Model type (see each public macro's own doc comment for
2034/// the completeness requirement `RESULT_ALIAS` differs on).
2035/// @param A Concrete action type.
2036/// @param RESULT_ALIAS Expression naming the action's result type -- either
2037/// `RESULT` (named explicitly) or the `decltype(...)`
2038/// deduction from `M::execute(A)`.
2039/// @param NAME String literal used as the action type-id.
2040/// @param LOGGABLE A `morph::model::Loggable` value.
2041#define BRIDGE_DETAIL_ACTION_TRAITS_BODY(M, A, RESULT_ALIAS, NAME, LOGGABLE) \
2042 BRIDGE_DETAIL_ACTION_TRAITS_ONLY_BODY(M, A, RESULT_ALIAS, NAME, LOGGABLE) \
2043 BRIDGE_DETAIL_ACTION_REGISTRARS(M, A)
2044
2045// clang-format off -- public macro surface; see CONTRIBUTING.md, "Formatting/linting".
2046#define BRIDGE_REGISTER_ACTION_FOR_CLIENT_5(M, A, RESULT, NAME, LOGGABLE) \
2047 BRIDGE_DETAIL_ACTION_TRAITS_BODY(M, A, RESULT, NAME, LOGGABLE)
2048// clang-format on
2049/// @endcond
2050
2051/// @cond detail
2052#define BRIDGE_REGISTER_ACTION_PICK(_1, _2, _3, _4, NAME, ...) NAME
2053
2054#define BRIDGE_REGISTER_ACTION_3(M, A, NAME) BRIDGE_REGISTER_ACTION_4(M, A, NAME, ::morph::model::Loggable::Yes)
2055
2056/// `Result` deduction shared by `BRIDGE_REGISTER_ACTION_4` and
2057/// `BRIDGE_DECLARE_ACTION_4` -- both need `M` complete with `execute(A)`
2058/// declared for the same reason, spelled once so the two cannot drift.
2059/// Wrapped in `HandlerResultT` so a `core::async::Task<R>`-returning handler
2060/// resolves to `R` on both paths alike; without that, a declare-only header
2061/// and its out-of-line registration would disagree on the action's result
2062/// type for every coroutine handler.
2063#define BRIDGE_DETAIL_DEDUCED_ACTION_RESULT(M, A) \
2064 ::morph::model::HandlerResultT<decltype(std::declval<M&>().execute(std::declval<A>()))>
2065
2066// clang-format off -- public macro surface; see CONTRIBUTING.md, "Formatting/linting".
2067#define BRIDGE_REGISTER_ACTION_4(M, A, NAME, LOGGABLE) \
2068 BRIDGE_DETAIL_ACTION_TRAITS_BODY(M, A, BRIDGE_DETAIL_DEDUCED_ACTION_RESULT(M, A), NAME, LOGGABLE)
2069// clang-format on
2070/// @endcond
2071
2072/// @brief Declares action type @p A's `ActionTraits<A>` specialisation
2073/// without registering it -- the header-safe half `BRIDGE_REGISTER_ACTION`
2074/// decomposes into.
2075///
2076/// Same 3-or-4-argument shape as `BRIDGE_REGISTER_ACTION` (an optional
2077/// trailing `Loggable`) and the same `Result`-deduction requirement -- `M`
2078/// must be complete with `execute(A)` *declared* at this point, though (unlike
2079/// `BRIDGE_REGISTER_ACTION_SOURCE` below) not necessarily *defined*, since
2080/// nothing this macro emits calls it. Pair with
2081/// `BRIDGE_REGISTER_ACTION_SOURCE(M, A)` in the one `.cpp` that should
2082/// actually register @p A -- see `BRIDGE_DECLARE_MODEL`'s doc comment for why,
2083/// and docs/spec/core/registry.md, "Moving a registrar out of the header", for
2084/// the measured cost this exists to move out of the header.
2085///
2086/// @param M Concrete model type that handles the action.
2087/// @param A Concrete action type.
2088/// @param NAME String literal used as the action type-id.
2089/// @param ... Optional: a `morph::model::Loggable` value (defaults to `Loggable::Yes`).
2090// clang-format off -- public macro surface; see CONTRIBUTING.md, "Formatting/linting".
2091#define BRIDGE_DECLARE_ACTION(...) \
2092 BRIDGE_DECLARE_ACTION_PICK(__VA_ARGS__, BRIDGE_DECLARE_ACTION_4, BRIDGE_DECLARE_ACTION_3) \
2093 (__VA_ARGS__)
2094// clang-format on
2095
2096/// @cond detail
2097#define BRIDGE_DECLARE_ACTION_PICK(_1, _2, _3, _4, NAME, ...) NAME
2098
2099#define BRIDGE_DECLARE_ACTION_3(M, A, NAME) BRIDGE_DECLARE_ACTION_4(M, A, NAME, ::morph::model::Loggable::Yes)
2100
2101// clang-format off -- public macro surface; see CONTRIBUTING.md, "Formatting/linting".
2102#define BRIDGE_DECLARE_ACTION_4(M, A, NAME, LOGGABLE) \
2103 BRIDGE_DETAIL_ACTION_TRAITS_ONLY_BODY(M, A, BRIDGE_DETAIL_DEDUCED_ACTION_RESULT(M, A), NAME, LOGGABLE) \
2104 MORPH_DETAIL_REQUIRE_SOURCE_LOCAL(bridge_action_src_req_, \
2105 morph::model::detail::actionSourceRegistrationRequired<M, A>())
2106// clang-format on
2107/// @endcond
2108
2109/// @brief Registers action type @p A (for model @p M) -- previously declared
2110/// via `BRIDGE_DECLARE_ACTION(M, A, ...)` -- with the process-level
2111/// `ActionDispatcher` and `ActionExecuteRegistry` at static-init time.
2112///
2113/// Call exactly once, in the one `.cpp` that owns @p M's registration.
2114/// Requires `morph::model::ActionTraits<A>` already visible (from
2115/// `BRIDGE_DECLARE_ACTION` or `BRIDGE_REGISTER_ACTION` in an included header)
2116/// and, unlike `BRIDGE_DECLARE_ACTION`, requires @p M complete with
2117/// `execute(A)` *defined* -- the registered runner calls it. Reads
2118/// `ActionTraits<A>::typeId()` rather than taking a `NAME` argument again, so
2119/// the two cannot register under a different string than the one @p A's
2120/// traits actually report. Suppressed under `MORPH_CLIENT_ONLY`, exactly like
2121/// the two registrars `BRIDGE_REGISTER_ACTION` emits directly.
2122///
2123/// HARD REQUIREMENT: this macro's expansion unconditionally calls
2124/// `morph::model::detail::registerActionExecutorOnce<M, A>`, exactly as
2125/// `BRIDGE_REGISTER_ACTION` does -- the translation unit calling it MUST
2126/// include `<morph/core/bridge.hpp>` (directly or transitively), or the build
2127/// fails to link with an unresolved external symbol for
2128/// `registerActionExecutorOnce<M, A>`.
2129///
2130/// @param M Concrete model type that handles the action, already declared via
2131/// `BRIDGE_DECLARE_ACTION`.
2132/// @param A Concrete action type, already declared via `BRIDGE_DECLARE_ACTION`.
2133// clang-format off -- public macro surface; see CONTRIBUTING.md, "Formatting/linting".
2134#define BRIDGE_REGISTER_ACTION_SOURCE(M, A) \
2135 MORPH_DETAIL_DEFINE_SOURCE_LOCAL(morph::model::detail::actionSourceRegistrationRequired<M, A>()) \
2136 BRIDGE_DETAIL_ACTION_REGISTRARS(M, A)
2137// clang-format on
2138
2139/// @brief Registers a readiness predicate for action @p A.
2140///
2141/// Specialises `morph::model::ActionValidator<A>` so that @p FN gates @p A on every
2142/// dispatch path before `Model::execute` runs, and gates
2143/// `morph::flows::FlowSession::set<>`'s auto-fire (forms/flows.hpp).
2144///
2145/// @param A Concrete action type.
2146/// @param FN Callable `bool(const A&)`.
2147// clang-format off -- public macro surface; see CONTRIBUTING.md, "Formatting/linting".
2148#define BRIDGE_REGISTER_VALIDATOR(A, FN) \
2149 template <> \
2150 struct morph::model::ActionValidator<A> { \
2151 static bool ready(const A& action) { return (FN)(action); } \
2152 };
2153// clang-format on
2154// NOLINTEND(bugprone-throwing-static-initialization,cert-err58-cpp,misc-anonymous-namespace-in-header,cert-dcl59-cpp)
2155// NOLINTEND(cppcoreguidelines-macro-usage)
2156// NOLINTEND(bugprone-macro-parentheses)
const std::string & result() const noexcept
Returns the committed action's JSON-encoded result.
Definition registry.hpp:400
const std::string & cause() const noexcept
Returns the underlying failure's message, without this type's prefix.
Definition registry.hpp:404
ActionRecordingError(std::string result, std::string cause)
Constructs the error with a message of the form "action executed but was not recorded: <cause>".
Definition registry.hpp:393
JSON-Forms-style schema generation for morph actions.
std::function< async::Completion< std::string >(std::string const &typeId, std::string const &actionId, std::string const &bodyJson)> Evaluator
Evaluates an action body without running it: the model's type id, the action id and the JSON body in,...
Structural fingerprint of an action payload's JSON shape.
Compile-time profiler instrumentation: Tracy zones, plots, messages and thread names,...
#define MORPH_ZONE_TEXT(text)
Attaches text to the enclosing scope's MORPH_ZONE; an empty text attaches nothing.
Definition profiler.hpp:126
#define MORPH_ZONE(name)
Opens a profiler zone named name that ends with the enclosing scope. At most one per scope.
Definition profiler.hpp:118
Exact rational arithmetic for morph model/action values.
Where an action's result holds which reflected types.
Per-action policy deciding how repeated executions are checkpointed into a durable action log.
Definition registry.hpp:428
static constexpr bool coalesce
If true, a checkpoint keeps only the most recent entry per (modelType, entityKey, actionType)....
Definition registry.hpp:432
Traits specialisation that maps an action type to its id, JSON codec, and result type.
Definition registry.hpp:48
Per-action readiness predicate deciding whether an action may execute.
Definition registry.hpp:261
static constexpr bool ready(const Action &action)
Returns true if the action is in a state that should be executed.
Definition registry.hpp:269
DuplicateRegistrationError(std::string_view what, std::string_view taken, std::type_index held, std::type_index incoming)
Constructs the error naming the id and both types.
Definition registry.hpp:292
Traits specialisation that maps a model type to its string type-id.
Definition registry.hpp:40
ValidationError(std::string_view modelType, std::string_view actionType)
Constructs the error with a message of the form "action failed validation: <modelType>/<actionType>".
Definition registry.hpp:350
std::function< void()> Action
A widget event handler taking no arguments.
Definition view.hpp:33