morph 0.1.0
Asynchronous bridge between GUI thread and business models
Loading...
Searching...
No Matches
journal.hpp
1// SPDX-License-Identifier: Apache-2.0
2
3#pragma once
4#include <algorithm>
5#include <cstddef>
6#include <cstdint>
7#include <functional>
8#include <memory>
9#include <stdexcept>
10#include <string>
11#include <string_view>
12#include <unordered_map>
13#include <utility>
14#include <vector>
15
16#include "../attributes.hpp"
18#include "../core/executor.hpp"
19#include "../core/model.hpp"
20#include "../core/registry.hpp"
21#include "action_log.hpp"
22
23namespace morph::journal {
24
25/// @brief Thrown by `replay()` when a recorded entry's payload fingerprint
26/// (`LogEntry::schema`) disagrees with the fingerprint this build
27/// computes for the same action, and no migration covers the pair.
28///
29/// Without this signal the same situation reconstructs, successfully, a state
30/// that was never recorded — a renamed field decoding to its default, reported
31/// with as much confidence as a correct one. There is no "degraded"
32/// reconstruction to fall
33/// back to and no way to tell how much of the entry survived the decode, so
34/// this throws rather than warning: an audit trail that cannot be reconstructed
35/// faithfully must say so.
36///
37/// @par Who handles it
38/// The caller of `replay()` / `SessionLog::undoLast()` — application-level
39/// reconstruction code, not the framework. The three answers available to it
40/// are: register a migration for the pair (see `PayloadMigrationRegistry`),
41/// restore a build whose fingerprint matches, or surface the failure. The
42/// framework deliberately offers no fourth.
43struct SchemaMismatchError : std::runtime_error {
44 /// @brief Builds the message naming the action and both fingerprints.
45 /// @param modelType `LogEntry::modelType` of the offending entry.
46 /// @param actionType `LogEntry::actionType` of the offending entry.
47 /// @param recorded Fingerprint stamped on the entry when it was written.
48 /// @param current Fingerprint this build computes for that action.
49 SchemaMismatchError(std::string_view modelType, std::string_view actionType, std::string_view recorded,
50 std::string_view current)
51 : std::runtime_error{"journal::replay: payload schema mismatch for " + std::string{modelType} + "/" +
52 std::string{actionType} + ": entry was written with schema " + std::string{recorded} +
53 ", this build decodes it as " + std::string{current} +
54 " (register a migration for this pair, or replay with a build that matches)"} {}
55};
56
57/// @brief What `replay()` does with an entry carrying no payload fingerprint.
58///
59/// An entry has an empty `LogEntry::schema` when it was written before the
60/// field existed, or when application code appended it directly rather than
61/// through an action execution. Such an entry is not "known good" — it is
62/// *unverifiable*: there is no record of the shape that produced its payload,
63/// so no check can be performed at all.
64enum class UnstampedPayloadPolicy : std::uint8_t {
65 /// @brief Replay it, without verifying a fingerprint it does not carry.
66 ///
67 /// The default, because it is the only choice that keeps journals written
68 /// by earlier builds replayable at all — refusing them by default would
69 /// turn an upgrade into a data-loss event for every retained journal, which
70 /// is precisely the failure this feature exists to prevent, inverted.
71 /// Choosing this is choosing to accept the *pre-existing* silent-default
72 /// risk for *pre-existing* entries; it grants no leniency to anything
73 /// written after the stamp exists.
74 Replay,
75
76 /// @brief Refuse: throw `SchemaMismatchError` on the first unstamped entry.
77 ///
78 /// For a caller whose correctness claim is "this reconstruction is
79 /// faithful" and who would rather have no answer than an unverifiable one —
80 /// a regulated audit trail, a conformance run, a test pinning that every
81 /// entry in a fixture really is stamped.
82 Refuse,
83};
84
85/// @brief Registry of payload migrations, keyed by `(actionType, fromSchema)`.
86///
87/// The escape hatch that makes "refuse on mismatch" a livable contract rather
88/// than a one-way door. A migration is a pure function over the recorded
89/// payload JSON: given the bytes an older build wrote, return the bytes this
90/// build's `ActionTraits<A>::fromJson` should see. Reconstruction applies it in
91/// memory only — the journal on disk is never rewritten, so the recorded
92/// history stays exactly what was recorded and the migration stays reviewable
93/// as code.
94///
95/// @par Thread safety
96/// None, matching `morph::model::detail::ActionDispatcher`: register every
97/// migration during start-up, before any `replay()` can run.
98///
99/// @code
100/// morph::journal::PayloadMigrationRegistry migrations;
101/// migrations.add("setState", oldFingerprint, [](std::string_view payload) {
102/// // rename "state" -> "stateCode"
103/// return rewriteKey(payload, "state", "stateCode");
104/// });
105/// auto holder = morph::journal::replay("Sample", log.entries(), registry, dispatcher, migrations);
106/// @endcode
108public:
109 /// @brief Rewrites one recorded payload JSON into the current shape.
110 using Migration = std::function<std::string(std::string_view)>;
111
112 /// @brief Registers @p migration for entries of @p actionType stamped with
113 /// @p fromSchema. Replaces any migration already registered for that
114 /// exact pair.
115 /// @param actionType `LogEntry::actionType` the migration applies to.
116 /// @param fromSchema `LogEntry::schema` value the migration reads.
117 /// @param migration Payload rewriter.
118 void add(std::string_view actionType, std::string_view fromSchema, Migration migration) {
119 _migrations[Key{std::string{actionType}, std::string{fromSchema}}] = std::move(migration);
120 }
121
122 /// @brief Looks up the migration for `(actionType, fromSchema)`.
123 ///
124 /// Returns a pointer into the registry, valid until the entry is replaced
125 /// or the registry is destroyed (`std::unordered_map` does not invalidate
126 /// references to surviving elements on rehash).
127 /// @param actionType `LogEntry::actionType` to match.
128 /// @param fromSchema `LogEntry::schema` to match.
129 /// @return Pointer to the migration, or `nullptr` if none is registered —
130 /// into this registry, so valid only for as long as it is.
131 [[nodiscard]] const Migration* find(std::string_view actionType,
132 std::string_view fromSchema) const MORPH_LIFETIMEBOUND {
133 auto iter = _migrations.find(::morph::model::detail::PairKeyView{actionType, fromSchema});
134 return iter == _migrations.end() ? nullptr : &iter->second;
135 }
136
137 /// @brief Removes every registered migration.
138 void clear() { _migrations.clear(); }
139
140 /// @brief Returns how many migrations are registered.
141 /// @return The registration count.
142 [[nodiscard]] std::size_t size() const noexcept { return _migrations.size(); }
143
144private:
145 using Key = std::pair<std::string, std::string>;
146 // Both functors, not just the hash: `unordered_map` enables heterogeneous
147 // lookup only when the hash *and* the equality are transparent. Name the
148 // hash alone and every `find` silently builds a `Key` to probe with, while
149 // looking like it does not. `add` builds one regardless, because it
150 // inserts.
151 std::unordered_map<Key, Migration, ::morph::model::detail::PairKeyHash, ::morph::model::detail::PairKeyEqual>
152 _migrations;
153};
154
155/// @brief Returns the process-level migration registry `replay()` uses by
156/// default. Empty until an application adds to it.
157/// @return Reference to the process-wide registry.
158[[nodiscard]] inline PayloadMigrationRegistry& defaultPayloadMigrations() {
159 static PayloadMigrationRegistry registry;
160 return registry;
161}
162
163namespace detail {
164
165/// @brief Thread-local flag telling executing model code whether the current
166/// dispatch is happening inside `replay()`.
167///
168/// Installed by `replay()` around its dispatch loop via `ScopedReplayFlag`,
169/// mirroring how `morph::session::detail::tlsCurrent()`/`ScopedContext` thread
170/// a per-call `Context` through dispatch -- same shape (a thread-local slot
171/// plus an RAII guard that restores the previous value on scope exit), applied
172/// to a `bool` instead of a `const Context*`. Model/rule code never touches
173/// this directly; it reads the public accessor `isReplaying()`.
174inline bool& tlsIsReplaying() {
175 thread_local bool tls = false;
176 return tls;
177}
178
179/// @brief RAII helper that sets the thread-local replay flag for its scope,
180/// restoring the previous value on destruction.
181///
182/// Nests correctly: a `replay()` call that itself triggers another `replay()`
183/// (not a pattern this framework uses today, but not precluded) leaves the
184/// flag `true` for the whole nested extent and restores the outer call's value
185/// on the inner guard's destruction -- the same nesting behavior
186/// `session::detail::ScopedContext` already has for `Context`.
188public:
189 /// @brief Sets the thread-local replay flag to `true`, saving whatever
190 /// value was there before.
191 ScopedReplayFlag() : _previous{tlsIsReplaying()} { tlsIsReplaying() = true; }
192 /// @brief Restores the saved value.
193 ~ScopedReplayFlag() { tlsIsReplaying() = _previous; }
194
195 ScopedReplayFlag(const ScopedReplayFlag&) = delete;
196 ScopedReplayFlag& operator=(const ScopedReplayFlag&) = delete;
198 ScopedReplayFlag& operator=(ScopedReplayFlag&&) = delete;
199
200private:
201 bool _previous;
202};
203
204} // namespace detail
205
206/// @brief Returns `true` if the calling thread is currently inside a
207/// `replay()` dispatch, `false` otherwise.
208///
209/// This is the signal Phase 6's automation-rules engine (and any other model
210/// code that reacts to its own actions) checks before evaluating a rule: a
211/// rule that fires again while `replay()` re-applies its recorded trigger
212/// entry would double-apply a cascade that is also being replayed from its own
213/// recorded entry (see `docs/spec/journal/journal.md`'s cascade-journaling
214/// section). Reading this outside of any `replay()` call (the ordinary,
215/// live-dispatch case) always returns `false`.
216/// @return `true` during `replay()`'s dispatch loop on this thread, `false` otherwise.
217[[nodiscard]] inline bool isReplaying() noexcept { return detail::tlsIsReplaying(); }
218
219/// @brief Reconstructs model state by replaying @p entries, in order, against a
220/// freshly created model instance.
221///
222/// Builds on the same `ModelRegistryFactory` / `ActionDispatcher` machinery
223/// `RemoteServer` already uses to dispatch incoming requests — no separate
224/// replay engine is needed. Because a model's entire state is exactly "initial
225/// state plus the ordered actions replayed against it", this both reconstructs
226/// state from a durable log and powers `SessionLog::undoLast()` below.
227///
228/// Sets `isReplaying()` to `true` for the duration of the dispatch loop below
229/// (via `detail::ScopedReplayFlag`), so any model/rule code executed as part of
230/// a replayed dispatch can tell it is being replayed rather than live-dispatched
231/// -- this is what lets Phase 6's rules engine suppress rule evaluation on
232/// replay while `replay()` re-applies the cascade's own recorded entry
233/// unchanged. The flag is restored to its prior value (`false`, for any
234/// ordinary top-level caller) once this function returns, so it never leaks
235/// into dispatches that happen after `replay()` completes.
236///
237/// @par Payload-schema verification
238/// Before dispatching an entry, `replay()` compares the entry's
239/// `LogEntry::schema` against `ActionDispatcher::schemaFor(modelType,
240/// actionType)` — the fingerprint *this* build computes for that action:
241///
242/// - **Equal** — dispatch, unchanged.
243/// - **Different, with a migration registered** for `(actionType, entry.schema)`
244/// in @p migrations — the migration rewrites the payload JSON in memory, and
245/// the rewritten bytes are dispatched. The stored entry is untouched.
246/// - **Different, with no migration** — throw `SchemaMismatchError`. Decoding
247/// it anyway is what would reconstruct a state nobody ever recorded.
248/// - **Entry unstamped** (`schema` empty) — governed by @p unstamped; see
249/// `UnstampedPayloadPolicy`.
250///
251/// An entry naming an action this build has not registered is *not* rejected
252/// here (`schemaFor` returns empty for it); it falls through to `dispatch()`,
253/// which throws "unknown action" — the more precise diagnostic of the two.
254///
255/// @param modelTypeId String type-id of the model to reconstruct (`ModelTraits<M>::typeId()`).
256/// @param entries Ordered entries to replay, typically from `IActionLog::entries()`. Entries
257/// with `outcome == Outcome::Failed` are skipped (see below).
258/// @param registry Model factory registry; defaults to the process-level singleton.
259/// @param dispatcher Action dispatcher; defaults to the process-level singleton.
260/// @param migrations Payload migrations consulted on a fingerprint mismatch;
261/// defaults to the process-level registry, which is empty
262/// unless the application populated it.
263/// @param unstamped What to do with an entry carrying no fingerprint;
264/// defaults to `UnstampedPayloadPolicy::Replay`.
265/// @return A freshly created holder with @p entries replayed against it.
266/// @throws std::runtime_error if @p modelTypeId or any entry's action type is unregistered.
267/// @throws SchemaMismatchError if an entry's payload fingerprint disagrees with this
268/// build's and no migration covers it, or if an unstamped entry is met
269/// under `UnstampedPayloadPolicy::Refuse`.
270inline std::unique_ptr<::morph::model::detail::IModelHolder> replay(
271 std::string_view modelTypeId, const std::vector<LogEntry>& entries,
272 ::morph::model::detail::ModelRegistryFactory& registry = ::morph::model::detail::defaultRegistry(),
273 ::morph::model::detail::ActionDispatcher& dispatcher = ::morph::model::detail::defaultDispatcher(),
274 const PayloadMigrationRegistry& migrations = defaultPayloadMigrations(),
275 UnstampedPayloadPolicy unstamped = UnstampedPayloadPolicy::Replay) {
276 auto holder = registry.create(modelTypeId);
277 // `registry.create` (via `ModelFactory::create`) auto-attaches the process
278 // default action log. Detach it before replaying: reconstruction re-runs the
279 // recorded actions, and without this each replayed dispatch would re-record
280 // into the live sink, corrupting the very audit trail we are reading from.
281 holder->attachActionLog(nullptr, {});
282 const detail::ScopedReplayFlag replayFlag;
283 for (const auto& entry : entries) {
284 // A Failed entry (see action_log.hpp's `Outcome`) never mutated model
285 // state -- Model::execute threw or the validator rejected it before any
286 // state change -- so there is nothing to reconstruct from it. Worse,
287 // re-dispatching it would very likely throw the same exception again
288 // (the same rejected precondition), aborting reconstruction outright.
289 // Skipping it is exactly "replay only committed facts", which is what
290 // this function promises.
291 if (entry.outcome == Outcome::Failed) {
292 continue;
293 }
294 // Payload-schema gate. `current` is empty for an action this build never
295 // registered; leaving that to dispatch()'s "unknown action" keeps the
296 // two failures distinguishable instead of reporting the missing
297 // registration as a schema change.
298 const std::string current = dispatcher.schemaFor(entry.modelType, entry.actionType);
299 std::string_view payload{entry.payload};
300 std::string migrated;
301 if (entry.schema.empty()) {
302 if (unstamped == UnstampedPayloadPolicy::Refuse) {
303 throw SchemaMismatchError{entry.modelType, entry.actionType, "(unstamped)", current};
304 }
305 } else if (!current.empty() && entry.schema != current) {
306 const auto* migration = migrations.find(entry.actionType, entry.schema);
307 if (migration == nullptr) {
308 throw SchemaMismatchError{entry.modelType, entry.actionType, entry.schema, current};
309 }
310 migrated = (*migration)(entry.payload);
311 payload = migrated;
312 }
313 dispatcher.dispatch(entry.modelType, entry.actionType, *holder, payload);
314 }
315 return holder;
316}
317
318/// @brief Full-fidelity, in-memory log of one model instance's executed actions.
319///
320/// Attach directly via `IModelHolder::attachActionLog` — every successfully
321/// executed loggable action is appended here in order, regardless of any
322/// `ActionLogPolicy::coalesce` setting. This full history is the raw material
323/// for `undoLast()`. `checkpoint()` is the one place coalescing actually
324/// happens: entries accumulated since the last checkpoint are reduced by
325/// `(modelType, entityKey, actionType)` — keeping only the latest occurrence
326/// where the action's policy says `coalesce == true`, keeping every occurrence
327/// otherwise — and only that reduced set reaches the durable sink.
328///
329/// @par One owner
330/// The history and the checkpoint watermark belong to the executor given at
331/// construction, which must run one task at a time. `append()` runs there —
332/// at once when called on it, posted from a model's strand. `entries()`,
333/// `undoLast()` and `checkpoint()` run only there; a caller elsewhere reads
334/// through `entries(replyExec, entityKey)`. Because a checkpoint and every
335/// append are tasks of the one owner, a checkpoint's slice-and-forward is
336/// never interleaved with an append or with another checkpoint. See
337/// `docs/spec/journal/journal.md`, "One owner".
338class SessionLog : public IActionLog {
339public:
341 using IActionLog::flush;
342
343 /// @brief Constructs an empty session log belonging to @p owner.
344 /// @param owner The executor every access runs on; must run one task at a
345 /// time. Borrowed: it must outlive this log and run what it posts.
346 explicit SessionLog(::morph::exec::IExecutor& owner MORPH_LIFETIMEBOUND)
347 : _owned{owner, std::make_shared<State>()} {}
348
349 /// @brief Appends @p entry, assigning a monotonically increasing `seq`, on
350 /// the owner.
351 ///
352 /// Always full fidelity: this is what `undoLast()` walks back through, so
353 /// nothing is coalesced or dropped here.
354 /// @param entry Entry to append; `seq` is overwritten regardless of the input value.
355 void append(LogEntry entry) override {
356 _owned.apply("SessionLog::append", [entry = std::move(entry)](State& state) mutable {
357 entry.seq = ++state.nextSeq;
358 state.all.push_back(std::move(entry));
359 });
360 }
361
362 /// @brief No-op — `checkpoint()` is this class's real commit point. Callable anywhere.
363 void flush() override {}
364
365 /// @brief Returns the full history (or one entity's slice of it) in append
366 /// order. On the owner.
367 /// @param entityKey If non-empty, restricts the result to that entity's entries.
368 /// @return Matching entries, in append order.
369 [[nodiscard]] std::vector<LogEntry> entries(std::string_view entityKey = {}) const override {
370 return detail::entriesFor(_owned.read("SessionLog::entries").all, entityKey);
371 }
372
373 /// @brief The executor given at construction.
374 /// @return The owner.
375 [[nodiscard]] ::morph::exec::IExecutor* owner() const noexcept override { return &_owned.owner(); }
376
377 /// @brief Drops the most recently appended entry and replays everything that
378 /// remains against a fresh model instance, reconstructing the state
379 /// the model was in immediately before that entry executed. On the owner.
380 ///
381 /// No inverse/undo operations are needed on the action types themselves —
382 /// this reuses `replay()` over a shorter prefix of the same log. A no-op
383 /// (returns a freshly created, un-replayed holder) if the log is empty.
384 ///
385 /// Undo only rewinds this in-memory history; it never moves the checkpoint
386 /// watermark, which is keyed by entry `seq` (see `checkpoint()`). Undoing an
387 /// entry a prior `checkpoint()` already forwarded does **not** un-forward it:
388 /// the durable sink is append-only, so that entry stays durable while the
389 /// reconstructed history diverges from it. A later `checkpoint()` never
390 /// re-forwards a coalesced-away, already-committed entry. Applications that
391 /// need to durably reverse a checkpointed action must record a compensating
392 /// action, not rely on `undoLast()`.
393 ///
394 /// Replays through `replay()` with its default payload-schema settings.
395 /// That gate cannot fire here in practice: the entries being replayed were
396 /// appended by this same process, so their fingerprints are this build's by
397 /// construction. Undo is not a cross-version path.
398 ///
399 /// Synchronous: it returns the reconstructed holder, and its caller — the
400 /// application — is the owner.
401 ///
402 /// @param modelTypeId String type-id of the model to reconstruct.
403 /// @param registry Model factory registry; defaults to the process-level singleton.
404 /// @param dispatcher Action dispatcher; defaults to the process-level singleton.
405 /// @return A freshly created holder with the pre-undo state replayed onto it.
406 std::unique_ptr<::morph::model::detail::IModelHolder> undoLast(
407 std::string_view modelTypeId,
408 ::morph::model::detail::ModelRegistryFactory& registry = ::morph::model::detail::defaultRegistry(),
409 ::morph::model::detail::ActionDispatcher& dispatcher = ::morph::model::detail::defaultDispatcher()) {
410 State& state = _owned.read("SessionLog::undoLast");
411 if (!state.all.empty()) {
412 // The checkpoint watermark is a *seq* threshold, not an index into
413 // `all`, so dropping the tail entry cannot lower it: undo never
414 // rewinds the watermark, and a later checkpoint never re-forwards
415 // a coalesced-away, already-committed entry.
416 state.all.pop_back();
417 }
418 return replay(modelTypeId, state.all, registry, dispatcher);
419 }
420
421 /// @brief Coalesces entries appended since the last checkpoint and forwards
422 /// the reduced set, in order, to @p durableSink; then flushes it. On
423 /// the owner.
424 ///
425 /// Advances the checkpoint watermark (the `seq` of the last committed entry)
426 /// *before* forwarding, so the batch is consumed even if @p durableSink's
427 /// `append()` or `flush()` throws: a checkpoint is a forward-only commit
428 /// point (at-most-once), not a transaction to retry. A no-op if nothing has
429 /// been appended since the last checkpoint.
430 ///
431 /// @par Ordering
432 /// The whole body is one task of the owner, where every `append()` runs
433 /// too, so no append lands between taking the slice and forwarding it, and
434 /// two checkpoints forward their batches in the order they ran. That is
435 /// the append-order identity the sink relies on.
436 ///
437 /// @param durableSink Receives only the coalesced entries — never the raw
438 /// stream. Driven from this log's owner, so it must share it: its
439 /// `flush()` answers only on its own owner.
440 /// @param dispatcher Supplies each action's `coalesce` policy; defaults to
441 /// the process-level singleton (the same one actions were
442 /// registered against via `BRIDGE_REGISTER_ACTION`).
443 void checkpoint(IActionLog& durableSink, ::morph::model::detail::ActionDispatcher& dispatcher =
444 ::morph::model::detail::defaultDispatcher()) {
445 State& state = _owned.read("SessionLog::checkpoint");
446 // Forward every entry whose seq is strictly past the last committed
447 // seq. `seq` is assigned once at append and never reused, so this is
448 // stable under coalescing and under `undoLast()` removing tail
449 // entries — unlike a raw index into `all`, which those mutate.
450 std::vector<LogEntry> pending;
451 uint64_t highestSeq = state.committedUpToSeq;
452 for (const auto& entry : state.all) {
453 if (entry.seq > state.committedUpToSeq) {
454 pending.push_back(entry);
455 highestSeq = std::max(highestSeq, entry.seq);
456 }
457 }
458 if (pending.empty()) {
459 return;
460 }
461 state.committedUpToSeq = highestSeq;
462 for (auto& entry : coalesced(pending, dispatcher)) {
463 durableSink.append(std::move(entry));
464 }
465 durableSink.flush();
466 }
467
468protected:
469 /// @brief Reads the history on the owner, delivered on @p replyExec.
470 /// @param replyExec Where the answer is delivered.
471 /// @param entityKey If non-empty, restricts the result to that entity's entries.
472 /// @return The matching entries.
474 std::string entityKey) const override {
475 return _owned.ask<std::vector<LogEntry>>(
476 "SessionLog::entries", replyExec,
477 [entityKey = std::move(entityKey)](State& state) { return detail::entriesFor(state.all, entityKey); });
478 }
479
480private:
481 /// @brief Reduces @p pending by `(modelType, entityKey, actionType)`: the
482 /// latest occurrence overwrites earlier ones (in place, preserving
483 /// first-seen position) wherever @p dispatcher says the action
484 /// coalesces; every other entry is kept as-is.
485 static std::vector<LogEntry> coalesced(const std::vector<LogEntry>& pending,
486 ::morph::model::detail::ActionDispatcher& dispatcher) {
487 std::vector<LogEntry> out;
488 std::unordered_map<std::string, std::size_t> lastIndexForKey;
489 for (const auto& entry : pending) {
490 if (!dispatcher.coalesce(entry.modelType, entry.actionType)) {
491 out.push_back(entry);
492 continue;
493 }
494 std::string key = entry.modelType + '\0' + entry.entityKey + '\0' + entry.actionType;
495 auto iter = lastIndexForKey.find(key);
496 if (iter == lastIndexForKey.end()) {
497 lastIndexForKey.emplace(std::move(key), out.size());
498 out.push_back(entry);
499 } else {
500 // NOLINTNEXTLINE(cppcoreguidelines-pro-bounds-avoid-unchecked-container-access) — index from same loop
501 out[iter->second] = entry;
502 }
503 }
504 return out;
505 }
506
507 /// Everything the log holds; touched only on the owner.
508 struct State {
509 std::vector<LogEntry> all;
510 uint64_t committedUpToSeq{0};
511 uint64_t nextSeq{0};
512 };
513
514 ::morph::exec::detail::OwnedState<State> _owned;
515};
516
517} // namespace morph::journal
Move-only handle representing the eventual result of an asynchronous operation.
Registry of payload migrations, keyed by (actionType, fromSchema).
Definition journal.hpp:107
const Migration * find(std::string_view actionType, std::string_view fromSchema) const
Looks up the migration for (actionType, fromSchema).
Definition journal.hpp:131
std::function< std::string(std::string_view)> Migration
Rewrites one recorded payload JSON into the current shape.
Definition journal.hpp:110
void add(std::string_view actionType, std::string_view fromSchema, Migration migration)
Registers migration for entries of actionType stamped with fromSchema. Replaces any migration already...
Definition journal.hpp:118
std::size_t size() const noexcept
Returns how many migrations are registered.
Definition journal.hpp:142
void clear()
Removes every registered migration.
Definition journal.hpp:138
::morph::async::Completion< std::vector< LogEntry > > askEntries(::morph::exec::IExecutor &replyExec, std::string entityKey) const override
Reads the history on the owner, delivered on replyExec.
Definition journal.hpp:473
std::vector< LogEntry > entries(std::string_view entityKey={}) const override
Returns the full history (or one entity's slice of it) in append order. On the owner.
Definition journal.hpp:369
::morph::exec::IExecutor * owner() const noexcept override
The executor given at construction.
Definition journal.hpp:375
std::unique_ptr<::morph::model::detail::IModelHolder > undoLast(std::string_view modelTypeId, ::morph::model::detail::ModelRegistryFactory &registry=::morph::model::detail::defaultRegistry(), ::morph::model::detail::ActionDispatcher &dispatcher=::morph::model::detail::defaultDispatcher())
Drops the most recently appended entry and replays everything that remains against a fresh model inst...
Definition journal.hpp:406
SessionLog(::morph::exec::IExecutor &owner)
Constructs an empty session log belonging to owner.
Definition journal.hpp:346
void checkpoint(IActionLog &durableSink, ::morph::model::detail::ActionDispatcher &dispatcher=::morph::model::detail::defaultDispatcher())
Coalesces entries appended since the last checkpoint and forwards the reduced set,...
Definition journal.hpp:443
void append(LogEntry entry) override
Appends entry, assigning a monotonically increasing seq, on the owner.
Definition journal.hpp:355
void flush() override
No-op — checkpoint() is this class's real commit point. Callable anywhere.
Definition journal.hpp:363
RAII helper that sets the thread-local replay flag for its scope, restoring the previous value on des...
Definition journal.hpp:187
~ScopedReplayFlag()
Restores the saved value.
Definition journal.hpp:193
ScopedReplayFlag()
Sets the thread-local replay flag to true, saving whatever value was there before.
Definition journal.hpp:191
OwnedState: a component's state, and the one executor it is touched on.
Abstract executor interface.
Definition executor.hpp:70
Interface for durable storage of executed-action entries.
virtual void flush()=0
Pushes any buffered entries to the durable backend. No-op for sinks with nothing to buffer (e....
virtual std::vector< LogEntry > entries(std::string_view entityKey={}) const =0
Returns recorded entries in append order.
virtual void append(LogEntry entry)=0
Appends entry. Implementations assign entry.seq.
One recorded execution of an action against a model instance.
uint64_t seq
Monotonic order assigned by the sink on append(). Callers pass 0.
Thrown by replay() when a recorded entry's payload fingerprint (LogEntry::schema) disagrees with the ...
Definition journal.hpp:43
SchemaMismatchError(std::string_view modelType, std::string_view actionType, std::string_view recorded, std::string_view current)
Builds the message naming the action and both fingerprints.
Definition journal.hpp:49