diff --git a/CMakeLists.txt b/CMakeLists.txt index 11148dd..323fa64 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -69,6 +69,9 @@ target_sources(morph include/morph/strand.hpp include/morph/completion.hpp include/morph/model.hpp + include/morph/action_log.hpp + include/morph/journal.hpp + include/morph/file_action_log.hpp include/morph/registry.hpp include/morph/backend.hpp include/morph/remote.hpp diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 1be8e86..474cf09 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -16,10 +16,11 @@ The public surface is split per topic so callers always know whether a name is p | `morph::log` | Configurable logging | `LogLevel`, `setLogger`, `setLogLevel`, `getLogLevel`, `logDebug`, `logInfo`, `logWarn`, `logError` | | `morph::exec` | Executor primitives | `IExecutor`, `ThreadPoolExecutor`, `MainThreadExecutor` | | `morph::async` | Async result handle | `Completion` | -| `morph::model` | Model & action traits | `ModelTraits<>`, `ActionTraits<>`, `ActionValidator<>` | +| `morph::model` | Model & action traits | `ModelTraits<>`, `ActionTraits<>`, `ActionValidator<>`, `ActionLogPolicy<>`, `Loggable` | | `morph::backend` | Pluggable backends | `LocalBackend`, `RemoteServer`, `SimulatedRemoteBackend` | | `morph::bridge` | Bridge between handler and backend | `Bridge`, `BridgeHandler` | | `morph::offline` | Connectivity + replay | `NetworkMonitor`, `NetworkMonitorConfig`, `IOfflineQueue`, `QueueItem`, `InMemoryOfflineQueue`, `SyncWorker`, `SyncResult` | +| `morph::journal` | Ordered, replayable action log (issue #3) | `LogEntry`, `IActionLog`, `InMemoryActionLog`, `FileActionLog`, `SessionLog`, `replay()`, `toJson`/`fromJson`, `setActionLog`, `defaultActionLog`, `ScopedActionLog` | | `morph::qt` | Qt integration (built only when `MORPH_BUILD_QT=ON`) | `QtExecutor`, `QtWebSocketBackend`, `QtWebSocketServer` | Every nested `detail` namespace under those topics holds implementation symbols. These do appear in some public signatures (e.g. `Bridge`'s constructor takes `unique_ptr`), but callers never type a detail name directly — `std::make_unique(...)` converts implicitly. @@ -62,11 +63,14 @@ Every nested `detail` namespace under those topics holds implementation symbols. | `executor.hpp` | `IExecutor`, `ThreadPoolExecutor`, `MainThreadExecutor` (`morph::exec::`) | | `strand.hpp` | `ModelId`, `ModelIdHash`, `StrandExecutor` — serialises tasks per model (`morph::exec::detail::`) | | `completion.hpp` | `CompletionState` (detail) + `Completion` (public) — result handle | -| `model.hpp` | `IModelHolder`, `ModelHolder`, `ModelFactory`, `IBackendChangedSink`, `BackendChangedNotifiable` — type-erased model storage (`morph::model::detail::`) | -| `registry.hpp` | `ModelTraits<>`, `ActionTraits<>`, `ActionValidator<>` (public) + `ActionDispatcher`, `ModelRegistryFactory`, `defaultDispatcher()`, `defaultRegistry()`, `ParseError`, `registerModelOnce`, `registerActionOnce` (detail). Registration macros `BRIDGE_REGISTER_MODEL`, `BRIDGE_REGISTER_ACTION`, `BRIDGE_REGISTER_VALIDATOR` are defined here at file scope. | -| `backend.hpp` | `LocalBackend` (public) + `ActionCall`, `IBackend` (detail) | -| `remote.hpp` | `RemoteServer`, `SimulatedRemoteBackend` (`morph::backend::`) | -| `bridge.hpp` | `Bridge`, `BridgeHandler` (public) + `HandlerBinding`, `MemberPointerTraits` (detail) | +| `model.hpp` | `IModelHolder`, `ModelHolder`, `ModelFactory`, `IBackendChangedSink`, `BackendChangedNotifiable` — type-erased model storage; `IModelHolder::attachActionLog`/`hasActionLog`/`recordIfAttached` (`morph::model::detail::`) | +| `registry.hpp` | `ModelTraits<>`, `ActionTraits<>`, `ActionValidator<>`, `ActionLogPolicy<>`, `Loggable` (public) + `ActionDispatcher` (now also tracking each action's `coalesce` policy), `ModelRegistryFactory`, `defaultDispatcher()`, `defaultRegistry()`, `ParseError`, `registerModelOnce`, `registerActionOnce`, `actionLoggable()` (detail). Registration macros `BRIDGE_REGISTER_MODEL`, `BRIDGE_REGISTER_ACTION` (optional 4th `Loggable` argument), `BRIDGE_REGISTER_VALIDATOR` are defined here at file scope. | +| `action_log.hpp` | `LogEntry`, `IActionLog`, `InMemoryActionLog`, `toJson`/`fromJson`, `SerializationError`, `setActionLog`, `defaultActionLog`, `ScopedActionLog` (`morph::journal::`) — the durable-sink interface and the process-wide default sink, with zero dependency on `model.hpp`/`registry.hpp` | +| `journal.hpp` | `SessionLog`, `replay()` (`morph::journal::`) — full-fidelity session log with `checkpoint()` coalescing and `undoLast()`, built on `action_log.hpp` + the existing `ActionDispatcher`/`ModelRegistryFactory` | +| `file_action_log.hpp` | `FileActionLog` (`morph::journal::`) — append-only NDJSON `IActionLog`, `flush()` fsyncs | +| `backend.hpp` | `LocalBackend` (public) + `ActionCall`, `IBackend` (detail), including the non-breaking `registerModelWithContext()` default method | +| `remote.hpp` | `RemoteServer` (now with `setLogProvider()`), `SimulatedRemoteBackend` (`morph::backend::`) | +| `bridge.hpp` | `Bridge`, `BridgeHandler` (public) + `HandlerBinding` (now carrying `contextKey`), `MemberPointerTraits` (detail) | | `network_monitor.hpp` | `NetworkMonitorConfig`, `NetworkMonitor` — background probe thread, online/offline state machine | | `offline_queue.hpp` | `IOfflineQueue`, `QueueItem`, `InMemoryOfflineQueue` — durable write queue abstraction | | `sync_worker.hpp` | `SyncWorker`, `SyncResult` — drains offline queue on reconnect via caller-supplied replay | @@ -128,7 +132,7 @@ field is the discriminator: | `kind` | Direction | Required fields | Meaning | |---|---|---|---| -| `"register"` | client → server | `typeId` | Register a model instance; server replies `ok` with `modelId` | +| `"register"` | client → server | `typeId`, `contextKey` (optional) | Register a model instance; server replies `ok` with `modelId` | | `"deregister"` | client → server | `modelId` | Destroy model instance; server replies `ok` | | `"execute"` | client → server | `callId`, `modelId`, `modelType`, `actionType`, `body`, `session` (optional) | Dispatch an action | | `"ok"` | server → client | `callId`, plus `body` (execute result) or `modelId` (register reply) | Success | @@ -142,6 +146,12 @@ incoming `execute` envelope through its configured `IAuthorizer`; a `false` return causes the server to reply with `err|unauthorized` (callId echoed). The default authorizer permits everything. +`contextKey` carries a `register`ing instance's stable identity (e.g. an +account id) from `HandlerBinding::contextKey` across the wire, so a +server-side `RemoteServer::LogProvider` can attach an action log to the +instance it creates — see "Action log" below. Empty (the default) means no +identity; the field is ignored on every other envelope kind. + ## Component detail ### Executors @@ -233,6 +243,34 @@ morph::offline::NetworkMonitor monitor{ `InMemoryOfflineQueue` implements the interface with a `std::deque` protected by a mutex. It does not deduplicate. +### Action log (issue #3) — ordered, coalescing, identity-aware execution history + +`morph::journal` records executed actions as an ordered, replayable log, distinct in purpose from `IOfflineQueue` above: `IOfflineQueue` holds pending writes awaiting retry and deletes them once delivered; the action log is a permanent audit/replay trail — entries are never removed by the framework. + +**`IActionLog`** is the durable-sink interface (`append`, `flush`, `entries`), implemented by `InMemoryActionLog` and `FileActionLog` (append-only NDJSON, `flush()` fsyncs). Each `LogEntry` carries `modelType`, `entityKey`, `actionType`, `payload`/`result` JSON, `principal`, and a sink-assigned `seq`. + +**Set the sink once, in `main()` — every model uses it automatically.** `morph::journal::setActionLog(log)` installs a process-wide default. `ModelFactory::create()` — the factory behind every ordinary model registration, local *or* remote — attaches that default to each new instance automatically (empty `entityKey`). No per-model, per-handler, or per-backend wiring is required; `RemoteServer`-owned instances get it exactly the same way, since they're constructed through the same factory. `defaultActionLog()` reads the current sink back; `ScopedActionLog` (RAII, mirrors `morph::log::ScopedLoggerOverride`) installs one temporarily and restores the previous one on scope exit — the tool tests use it to avoid leaking a sink across test cases. + + +Application code that needs a specific instance identity (e.g. per-account auditing) can still call `IModelHolder::attachActionLog(log, contextKey)` explicitly on that instance — an explicit call always overrides the default, and is the seam `HandlerBinding::contextKey`/`RemoteServer::setLogProvider` (below) build on for the remote case. Recording itself happens at the two call sites that are the *only* two places `Model::execute()` is ever invoked in the whole codebase: + +| Site | Topology | +|---|---| +| `ActionDispatcher::registerAction`'s runner (`registry.hpp`) | Every remote/Qt topology — `RemoteServer` owns the persistent `IModelHolder`s and dispatches through here | +| `Bridge::executeVia`'s `localOp` (`bridge.hpp`) | Local mode only — `LocalBackend` calls this directly; remote backends never invoke `localOp` at all | + +Because these are mutually exclusive per topology, recording is automatically server-side wherever a client/server split exists, with no extra plumbing. + +**`Loggable`** (`morph::model::Loggable::{Yes,No}`) is a strong-typed opt-out on the existing `BRIDGE_REGISTER_ACTION` macro (an optional 4th argument; no separate registration macro). Default is `Yes` — every action is recorded unless explicitly marked `Loggable::No` (typically pure queries like `GetAccount`/`ListAccounts`). Hand-written `ActionTraits` specialisations that predate this member (as used in several tests) are unaffected: `morph::model::detail::actionLoggable()` defaults to `Yes` when the member is absent, via a `HasLoggableFlag` concept exactly like `ActionValidator`'s `HasValidate`. + +**`ActionLogPolicy::coalesce`** (default `false`) decides whether repeated executions of the same action against the same entity should collapse to the latest occurrence at a checkpoint, or whether every occurrence is a distinct, permanent fact. This matters because the fielded/reactive `set<...>` mechanism (see "Subscriptions and fielded actions" below) can already fire the same action many times in a row — without coalescing, every keystroke-driven re-fire would become a permanent log entry. `false` is correct for anything resembling a business event (a deposit); `true` is for drafts/settings where only the final value matters. + +**`SessionLog`** (`journal.hpp`) is where coalescing actually happens. It keeps full, uncoalesced history in memory (the raw material for `undoLast()`), and `checkpoint(durableSink)` reduces everything appended since the last checkpoint by `(modelType, entityKey, actionType)` — keeping only the latest entry where `coalesce == true`, every entry otherwise — before forwarding the reduced set to the real sink. `undoLast()` needs no inverse operations: it drops the most recent entry and calls `journal::replay()` over what remains, reusing the same `ActionDispatcher`/`ModelRegistryFactory` `RemoteServer` already relies on for dispatch. This is not a workaround — a model's entire state genuinely is "initial state plus its ordered actions replayed," so reconstructing it by replay is the direct statement of that fact, not a special case. + +**Remote-mode per-instance identity** (`RemoteServer::setLogProvider`) is the advanced escape hatch for when the global default isn't granular enough: `RemoteServer` owns the actual model instances behind any remote/simulated-remote client, so it is the only place able to attach a *different* log (or a specific `entityKey`) to a *specific* instance. `HandlerBinding::contextKey` (client-side) travels through the `register` wire envelope's `contextKey` field; if a `LogProvider` is installed, `RemoteServer` calls it with `(modelType, contextKey)` and attaches whatever `IActionLog` it returns (or nothing, if it returns `nullptr` or no `contextKey` was sent) before the instance ever executes an action — overriding whatever the global default would have attached. + +**Not yet built** (see the design note linked from issue #3): the outbox pattern an integration against a model that also owns its own durable store would need (to avoid the log and the store's committed state silently diverging) — see `examples/bank`, which demonstrates `setActionLog` end to end against SQLite-backed models but writes to its own DB and the audit log as two independent steps, not one atomic outbox write. A Kafka-backed sink (dropped for now; the `IActionLog` interface is designed so one can be added later without touching call sites) and any read-model built on top of it are noted as future work. + ### SyncWorker `morph::offline::SyncWorker` drains an `IOfflineQueue` on reconnect. The caller supplies a `ReplayFunction` (`bool(const std::string& payload)`) that knows how to process each item — the framework has no knowledge of what replay means (insert to DB, POST to API, etc.). diff --git a/examples/bank/include/bank/models/account_model.hpp b/examples/bank/include/bank/models/account_model.hpp index b0d3df4..15d252c 100644 --- a/examples/bank/include/bank/models/account_model.hpp +++ b/examples/bank/include/bank/models/account_model.hpp @@ -48,6 +48,6 @@ using bank::dto::OpenAccount; BRIDGE_REGISTER_MODEL(AccountModel, "AccountModel") BRIDGE_REGISTER_ACTION(AccountModel, OpenAccount, "OpenAccount") -BRIDGE_REGISTER_ACTION(AccountModel, ListAccounts, "ListAccounts") -BRIDGE_REGISTER_ACTION(AccountModel, GetAccount, "GetAccount") +BRIDGE_REGISTER_ACTION(AccountModel, ListAccounts, "ListAccounts", ::morph::model::Loggable::No) +BRIDGE_REGISTER_ACTION(AccountModel, GetAccount, "GetAccount", ::morph::model::Loggable::No) BRIDGE_REGISTER_ACTION(AccountModel, CloseAccount, "CloseAccount") diff --git a/examples/bank/include/bank/models/auth_model.hpp b/examples/bank/include/bank/models/auth_model.hpp index d18e17d..6161597 100644 --- a/examples/bank/include/bank/models/auth_model.hpp +++ b/examples/bank/include/bank/models/auth_model.hpp @@ -43,4 +43,4 @@ BRIDGE_REGISTER_MODEL(AuthModel, "AuthModel") BRIDGE_REGISTER_ACTION(AuthModel, RegisterUser, "RegisterUser") BRIDGE_REGISTER_ACTION(AuthModel, LoginRequest, "LoginRequest") BRIDGE_REGISTER_ACTION(AuthModel, ChangePassword, "ChangePassword") -BRIDGE_REGISTER_ACTION(AuthModel, WhoAmI, "WhoAmI") +BRIDGE_REGISTER_ACTION(AuthModel, WhoAmI, "WhoAmI", ::morph::model::Loggable::No) diff --git a/examples/bank/include/bank/models/budget_model.hpp b/examples/bank/include/bank/models/budget_model.hpp index 276a8a4..e6b82dd 100644 --- a/examples/bank/include/bank/models/budget_model.hpp +++ b/examples/bank/include/bank/models/budget_model.hpp @@ -33,5 +33,5 @@ using bank::dto::SpendingByKind; BRIDGE_REGISTER_MODEL(BudgetModel, "BudgetModel") BRIDGE_REGISTER_ACTION(BudgetModel, SetBudget, "SetBudget") BRIDGE_REGISTER_ACTION(BudgetModel, DeleteBudget, "DeleteBudget") -BRIDGE_REGISTER_ACTION(BudgetModel, ListBudgets, "ListBudgets") -BRIDGE_REGISTER_ACTION(BudgetModel, SpendingByKind, "SpendingByKind") +BRIDGE_REGISTER_ACTION(BudgetModel, ListBudgets, "ListBudgets", ::morph::model::Loggable::No) +BRIDGE_REGISTER_ACTION(BudgetModel, SpendingByKind, "SpendingByKind", ::morph::model::Loggable::No) diff --git a/examples/bank/include/bank/models/card_model.hpp b/examples/bank/include/bank/models/card_model.hpp index baca67b..c1c3415 100644 --- a/examples/bank/include/bank/models/card_model.hpp +++ b/examples/bank/include/bank/models/card_model.hpp @@ -43,4 +43,4 @@ BRIDGE_REGISTER_ACTION(CardModel, UnfreezeCard, "UnfreezeCard") BRIDGE_REGISTER_ACTION(CardModel, CancelCard, "CancelCard") BRIDGE_REGISTER_ACTION(CardModel, SetCardLimit, "SetCardLimit") BRIDGE_REGISTER_ACTION(CardModel, ChangePin, "ChangePin") -BRIDGE_REGISTER_ACTION(CardModel, ListCards, "ListCards") +BRIDGE_REGISTER_ACTION(CardModel, ListCards, "ListCards", ::morph::model::Loggable::No) diff --git a/examples/bank/include/bank/models/loan_model.hpp b/examples/bank/include/bank/models/loan_model.hpp index 46309e7..a2b40ed 100644 --- a/examples/bank/include/bank/models/loan_model.hpp +++ b/examples/bank/include/bank/models/loan_model.hpp @@ -36,6 +36,6 @@ using bank::dto::RepayLoan; BRIDGE_REGISTER_MODEL(LoanModel, "LoanModel") BRIDGE_REGISTER_ACTION(LoanModel, ApplyLoan, "ApplyLoan") BRIDGE_REGISTER_ACTION(LoanModel, RepayLoan, "RepayLoan") -BRIDGE_REGISTER_ACTION(LoanModel, GetLoan, "GetLoan") -BRIDGE_REGISTER_ACTION(LoanModel, ListLoans, "ListLoans") -BRIDGE_REGISTER_ACTION(LoanModel, LoanScheduleRequest, "LoanScheduleRequest") +BRIDGE_REGISTER_ACTION(LoanModel, GetLoan, "GetLoan", ::morph::model::Loggable::No) +BRIDGE_REGISTER_ACTION(LoanModel, ListLoans, "ListLoans", ::morph::model::Loggable::No) +BRIDGE_REGISTER_ACTION(LoanModel, LoanScheduleRequest, "LoanScheduleRequest", ::morph::model::Loggable::No) diff --git a/examples/bank/include/bank/models/notification_model.hpp b/examples/bank/include/bank/models/notification_model.hpp index be37a44..6903296 100644 --- a/examples/bank/include/bank/models/notification_model.hpp +++ b/examples/bank/include/bank/models/notification_model.hpp @@ -31,6 +31,6 @@ using bank::dto::Notify; BRIDGE_REGISTER_MODEL(NotificationModel, "NotificationModel") BRIDGE_REGISTER_ACTION(NotificationModel, Notify, "Notify") -BRIDGE_REGISTER_ACTION(NotificationModel, ListNotifications, "ListNotifications") +BRIDGE_REGISTER_ACTION(NotificationModel, ListNotifications, "ListNotifications", ::morph::model::Loggable::No) BRIDGE_REGISTER_ACTION(NotificationModel, MarkRead, "MarkRead") BRIDGE_REGISTER_ACTION(NotificationModel, MarkAllRead, "MarkAllRead") diff --git a/examples/bank/include/bank/models/payee_model.hpp b/examples/bank/include/bank/models/payee_model.hpp index 76a8585..79eb02d 100644 --- a/examples/bank/include/bank/models/payee_model.hpp +++ b/examples/bank/include/bank/models/payee_model.hpp @@ -37,4 +37,4 @@ using bank::dto::RemovePayee; BRIDGE_REGISTER_MODEL(PayeeModel, "PayeeModel") BRIDGE_REGISTER_ACTION(PayeeModel, AddPayee, "AddPayee") BRIDGE_REGISTER_ACTION(PayeeModel, RemovePayee, "RemovePayee") -BRIDGE_REGISTER_ACTION(PayeeModel, ListPayees, "ListPayees") +BRIDGE_REGISTER_ACTION(PayeeModel, ListPayees, "ListPayees", ::morph::model::Loggable::No) diff --git a/examples/bank/include/bank/models/payment_model.hpp b/examples/bank/include/bank/models/payment_model.hpp index aa742ff..f978baf 100644 --- a/examples/bank/include/bank/models/payment_model.hpp +++ b/examples/bank/include/bank/models/payment_model.hpp @@ -48,4 +48,4 @@ BRIDGE_REGISTER_ACTION(PaymentModel, PayBill, "PayBill") BRIDGE_REGISTER_ACTION(PaymentModel, SchedulePayment, "SchedulePayment") BRIDGE_REGISTER_ACTION(PaymentModel, CreateStandingOrder, "CreateStandingOrder") BRIDGE_REGISTER_ACTION(PaymentModel, CancelPayment, "CancelPayment") -BRIDGE_REGISTER_ACTION(PaymentModel, ListPayments, "ListPayments") +BRIDGE_REGISTER_ACTION(PaymentModel, ListPayments, "ListPayments", ::morph::model::Loggable::No) diff --git a/examples/bank/include/bank/models/statement_model.hpp b/examples/bank/include/bank/models/statement_model.hpp index 393be9d..db75717 100644 --- a/examples/bank/include/bank/models/statement_model.hpp +++ b/examples/bank/include/bank/models/statement_model.hpp @@ -24,4 +24,4 @@ using bank::StatementModel; using bank::dto::GenerateStatement; BRIDGE_REGISTER_MODEL(StatementModel, "StatementModel") -BRIDGE_REGISTER_ACTION(StatementModel, GenerateStatement, "GenerateStatement") +BRIDGE_REGISTER_ACTION(StatementModel, GenerateStatement, "GenerateStatement", ::morph::model::Loggable::No) diff --git a/examples/bank/include/bank/models/transaction_model.hpp b/examples/bank/include/bank/models/transaction_model.hpp index 8c0b7b6..a3383b3 100644 --- a/examples/bank/include/bank/models/transaction_model.hpp +++ b/examples/bank/include/bank/models/transaction_model.hpp @@ -10,6 +10,11 @@ /// The Transaction model: deposits, withdrawals, atomic transfers, and history. /// Transfers update two accounts and write two ledger rows inside a single /// `SqlTransaction`, so a failure leaves no partial state. +/// +/// Deposit/Withdraw/Transfer default to `morph::model::Loggable::Yes` — when +/// the app attaches an action log via `IModelHolder::attachActionLog` (see +/// `bank_cli`'s `main.cpp`), every money movement is recorded automatically. +/// `History` is a pure read and opts out explicitly. namespace bank { @@ -41,4 +46,4 @@ BRIDGE_REGISTER_MODEL(TransactionModel, "TransactionModel") BRIDGE_REGISTER_ACTION(TransactionModel, Deposit, "Deposit") BRIDGE_REGISTER_ACTION(TransactionModel, Withdraw, "Withdraw") BRIDGE_REGISTER_ACTION(TransactionModel, Transfer, "Transfer") -BRIDGE_REGISTER_ACTION(TransactionModel, History, "History") +BRIDGE_REGISTER_ACTION(TransactionModel, History, "History", ::morph::model::Loggable::No) diff --git a/examples/bank/src/cli/main.cpp b/examples/bank/src/cli/main.cpp index 1e1bf19..6c7666b 100644 --- a/examples/bank/src/cli/main.cpp +++ b/examples/bank/src/cli/main.cpp @@ -8,6 +8,8 @@ #include #include #include +#include +#include #include #include @@ -67,6 +69,11 @@ T await(morph::async::Completion completion, morph::exec::MainThreadExecutor& bank::Money usd(std::int64_t minor) { return bank::Money{.minor = minor, .currency = bank::Currency::USD}; } /// Runs the full scenario against an already-wired bridge + gui executor. +/// +/// No per-handler wiring is needed for auditing: `morph::journal::setActionLog` +/// was called once in `main()`, so every handler constructed below — local or +/// (simulated) remote — automatically records to it. `session::current()`'s +/// principal is captured on each entry automatically too, from the login below. void runScenario(morph::bridge::Bridge& bridge, morph::exec::MainThreadExecutor& gui, const char* label, const std::string& principal) { std::println("\n========== {} ==========", label); @@ -180,6 +187,14 @@ int main() { std::filesystem::remove(dbPath, ec); bank::db::setup("DRIVER=SQLite3;Database=" + dbPath.string()); + // Audit trail: set once, here, and every model created from this point on + // — in either scenario below, local or (simulated) remote — automatically + // records to it. No per-handler wiring needed; see morph::journal::setActionLog. + const auto auditPath = std::filesystem::temp_directory_path() / "morph_bank_cli_audit.ndjson"; + std::filesystem::remove(auditPath, ec); + auto auditLog = std::make_shared(auditPath); + morph::journal::setActionLog(auditLog); + morph::exec::ThreadPoolExecutor workerPool{4}; morph::exec::MainThreadExecutor gui; @@ -189,7 +204,10 @@ int main() { runScenario(bridge, gui, "LocalBackend", "demo-local"); } - // 2) Remote backend (simulated): identical scenario, identical code. + // 2) Remote backend (simulated): identical scenario, identical code. The + // audited instances RemoteServer creates for this backend go through the + // very same ModelFactory::create() as the local ones above, so the + // audit log reaches them too with no extra wiring. { morph::exec::ThreadPoolExecutor serverPool{4}; auto server = std::make_shared(serverPool); @@ -197,6 +215,13 @@ int main() { runScenario(bridge, gui, "SimulatedRemoteBackend", "demo-remote"); } + auditLog->flush(); + std::println("\n========== Audit trail ({}) ==========", auditPath.string()); + for (const auto& entry : auditLog->entries()) { + std::println("[{}] {}::{} payload={} result={}", entry.principal, entry.modelType, entry.actionType, + entry.payload, entry.result); + } + std::println("\nDone."); return 0; } diff --git a/include/morph/action_log.hpp b/include/morph/action_log.hpp new file mode 100644 index 0000000..07142fe --- /dev/null +++ b/include/morph/action_log.hpp @@ -0,0 +1,239 @@ +// SPDX-License-Identifier: Apache-2.0 + +#pragma once +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace morph::journal { + +/// @brief One recorded execution of an action against a model instance. +/// +/// Produced automatically by `morph::model::detail::IModelHolder::recordIfAttached` +/// — application and model code never construct or append these directly. +struct LogEntry { + /// @brief Monotonic order assigned by the sink on `append()`. Callers pass `0`. + uint64_t seq = 0; + + /// @brief String type-id of the model the action ran against (`ModelTraits::typeId()`). + std::string modelType; + + /// @brief Stable identity of the model instance (e.g. an account id), stamped + /// from the value passed to `attachActionLog()`. Empty if none was set. + std::string entityKey; + + /// @brief String type-id of the executed action (`ActionTraits::typeId()`). + std::string actionType; + + /// @brief JSON-encoded request (`ActionTraits::toJson`). + std::string payload; + + /// @brief JSON-encoded result (`ActionTraits::resultToJson`), captured after + /// successful execution. + std::string result; + + /// @brief Auth principal from `morph::session::current()`, if any. Empty if unset. + std::string principal; + + /// @brief Wall-clock time of execution, milliseconds since the Unix epoch. + int64_t timestampMs = 0; +}; + +/// @brief Thrown by `toJson`/`fromJson` when `LogEntry` (de)serialisation fails. +struct SerializationError : std::runtime_error { + using std::runtime_error::runtime_error; +}; + +namespace detail { + +/// @brief Converts a Glaze error into a `SerializationError`, or does nothing +/// if @p errCode reports success. +/// +/// Shared by `toJson` and `fromJson` on purpose, not just for DRY: this is one +/// non-template function, so both call through the exact same compiled branch. +/// `fromJson`'s failure path is easy to exercise for real (malformed JSON is +/// everyday input); `toJson`'s is not — Glaze's buffer-writer has no reachable +/// failure mode for a flat struct of strings/integers like `LogEntry` (its +/// only write-relevant error codes are for recursion-depth limits `LogEntry` +/// can't hit, and `dump_int_error`, which nothing in Glaze's own source ever +/// actually raises). Routing both through here means `toJson`'s error branch +/// is the same branch `fromJson`'s test already exercises, rather than a +/// second, structurally-unreachable copy of the same three lines. +/// @param errCode Result of a `glz::write_json`/`glz::read_json` call. +/// @param context Buffer or input passed to `glz::format_error` for the message. +inline void throwOnGlazeError(const glz::error_ctx& errCode, std::string_view context) { + if (errCode) { + throw SerializationError{glz::format_error(errCode, context)}; + } +} + +} // namespace detail + +/// @brief Encodes @p entry as JSON. +/// +/// `LogEntry` is a plain aggregate, so Glaze reflects it without a `glz::meta` +/// specialisation — the same automatic reflection `BRIDGE_REGISTER_ACTION` +/// relies on for user action structs. Used by sinks that need an opaque +/// string representation (`FileActionLog`). +/// @throws SerializationError on encode failure (see `detail::throwOnGlazeError` +/// for why this is not realistically reachable for `LogEntry`). +inline std::string toJson(const LogEntry& entry) { + std::string out; + detail::throwOnGlazeError(glz::write_json(entry, out), out); + return out; +} + +/// @brief Decodes @p json into a `LogEntry`. +/// @throws SerializationError if @p json is not a valid `LogEntry`. +inline LogEntry fromJson(std::string_view json) { + LogEntry entry{}; + detail::throwOnGlazeError(glz::read_json(entry, json), json); + return entry; +} + +/// @brief Interface for durable storage of executed-action entries. +/// +/// Entries are never removed by the framework — this is a permanent, append-only +/// record, unlike `morph::offline::IOfflineQueue` (whose `markDone()` deletes +/// items once retried successfully). Implementations range from in-memory +/// (`InMemoryActionLog`) to file, SQL, or network-backed stores supplied by the +/// host application. +struct IActionLog { + virtual ~IActionLog() = default; + + /// @brief Appends @p entry. Implementations assign `entry.seq`. + /// @param entry Entry to append. + virtual void append(LogEntry entry) = 0; + + /// @brief Pushes any buffered entries to the durable backend. No-op for sinks + /// with nothing to buffer (e.g. `InMemoryActionLog`). + virtual void flush() = 0; + + /// @brief Returns recorded entries in append order. + /// @param entityKey If non-empty, restricts the result to that entity's entries. + /// @return Matching entries, in append order. + [[nodiscard]] virtual std::vector entries(std::string_view entityKey = {}) const = 0; +}; + +/// @brief Thread-safe in-memory implementation of `IActionLog`. +/// +/// Suitable for testing and for applications that do not need cross-process +/// durability. Mirrors `morph::offline::InMemoryOfflineQueue`'s shape. +class InMemoryActionLog : public IActionLog { +public: + /// @brief Appends @p entry, assigning a monotonically increasing `seq`. Thread-safe. + /// @param entry Entry to append; `seq` is overwritten regardless of the input value. + void append(LogEntry entry) override { + std::scoped_lock lock{_mtx}; + entry.seq = ++_nextSeq; + _entries.push_back(std::move(entry)); + } + + /// @brief No-op — there is no external backend to flush to. + void flush() override {} + + /// @brief Returns a snapshot of matching entries in append order. Thread-safe. + /// @param entityKey If non-empty, restricts the result to that entity's entries. + /// @return Matching entries, in append order. + [[nodiscard]] std::vector entries(std::string_view entityKey = {}) const override { + std::scoped_lock lock{_mtx}; + if (entityKey.empty()) { + return _entries; + } + std::vector out; + for (const auto& entry : _entries) { + if (entry.entityKey == entityKey) { + out.push_back(entry); + } + } + return out; + } + +private: + mutable std::mutex _mtx; + std::vector _entries; + uint64_t _nextSeq{0}; +}; + +namespace detail { + +/// @brief Process-wide slot holding the default action log, plus the mutex +/// guarding it. A function-local static, not a namespace-scope global, +/// so it's safe regardless of translation-unit init order. +inline std::pair>& defaultActionLogState() { + static std::pair> state; + return state; +} + +} // namespace detail + +/// @brief Installs @p log as the process-wide default action log. +/// +/// Every model instance created via `morph::model::detail::ModelFactory::create()` +/// — which is every model registered the ordinary way, whether the active +/// backend ends up being local or remote — automatically gets @p log attached +/// (with an empty `entityKey`) from that point on. Call this once at startup; +/// no per-model or per-handler wiring is needed for the common case. +/// +/// Application code that needs a specific instance identity (e.g. per-account +/// auditing) can still call `IModelHolder::attachActionLog` explicitly on that +/// instance afterward — an explicit call always overrides whatever the +/// default attached. Thread-safe. +/// +/// @param log Sink to attach automatically, or `nullptr` to stop auto-attaching +/// (existing instances keep whatever they already have). +inline void setActionLog(std::shared_ptr log) { + auto& [mtx, slot] = detail::defaultActionLogState(); + std::scoped_lock lock{mtx}; + slot = std::move(log); +} + +/// @brief Returns the currently installed default action log, or `nullptr` +/// if none has been set. Thread-safe. +[[nodiscard]] inline std::shared_ptr defaultActionLog() { + auto& [mtx, slot] = detail::defaultActionLogState(); + std::scoped_lock lock{mtx}; + return slot; +} + +/// @brief RAII helper that installs a default action log for its lifetime and +/// restores the previous one on destruction. +/// +/// Mirrors `morph::log::ScopedLoggerOverride`. Intended for tests (so one test +/// case's sink never leaks into the next) and for applications that need to +/// temporarily redirect auto-attached logging within a scope. +/// +/// @par Example +/// @code +/// { +/// morph::journal::ScopedActionLog guard{std::make_shared()}; +/// // ... models created in this scope auto-attach guard's log ... +/// } // previous default restored here +/// @endcode +class ScopedActionLog { +public: + /// @brief Installs @p log as the default, saving whatever was there before. + /// @param log New default for the lifetime of this object. + explicit ScopedActionLog(std::shared_ptr log) : _previous{defaultActionLog()} { + setActionLog(std::move(log)); + } + + /// @brief Restores the saved default. + ~ScopedActionLog() { setActionLog(std::move(_previous)); } + + ScopedActionLog(const ScopedActionLog&) = delete; + ScopedActionLog& operator=(const ScopedActionLog&) = delete; + ScopedActionLog(ScopedActionLog&&) = delete; + ScopedActionLog& operator=(ScopedActionLog&&) = delete; + +private: + std::shared_ptr _previous; +}; + +} // namespace morph::journal diff --git a/include/morph/backend.hpp b/include/morph/backend.hpp index 6b74e1a..e252b4d 100644 --- a/include/morph/backend.hpp +++ b/include/morph/backend.hpp @@ -8,6 +8,7 @@ #include #include #include +#include #include #include @@ -61,6 +62,28 @@ struct IBackend { const std::string& typeId, std::function()> factory) = 0; + /// @brief Registers a new model instance, additionally passing @p contextKey — + /// the instance's stable identity (e.g. an account id) — through to + /// backends that can make use of it. + /// + /// Default implementation forwards to `registerModel()` and drops @p contextKey, + /// which is exactly correct for `LocalBackend`: the caller's own @p factory + /// closure already captures whatever identity it needs directly (see + /// `IModelHolder::attachActionLog`), so there is nothing for the backend to + /// forward. Backends whose model instances live behind a wire protocol + /// (`SimulatedRemoteBackend`) override this to carry @p contextKey across — + /// see `wire::Envelope::contextKey` and `RemoteServer::setLogProvider`. + /// @param typeId String type-id of the model to instantiate. + /// @param factory Callable that constructs the `IModelHolder` (local path only). + /// @param contextKey Stable identity of the new instance; empty if none. + /// @return Newly assigned `ModelId`. + virtual ::morph::exec::detail::ModelId registerModelWithContext( + const std::string& typeId, std::function()> factory, + std::string_view contextKey) { + (void)contextKey; + return registerModel(typeId, std::move(factory)); + } + /// @brief Removes the model identified by @p mid from the backend. virtual void deregisterModel(::morph::exec::detail::ModelId mid) = 0; diff --git a/include/morph/bridge.hpp b/include/morph/bridge.hpp index 04593ba..1ae3f6a 100644 --- a/include/morph/bridge.hpp +++ b/include/morph/bridge.hpp @@ -54,6 +54,16 @@ struct HandlerBinding { /// @brief Factory used to re-register the model on a backend switch. std::function()> modelFactory; + /// @brief Stable identity of this model instance (e.g. an account id). + /// + /// Empty by default — the common local-mode case needs nothing here, since + /// `modelFactory` can already call `IModelHolder::attachActionLog` directly + /// with whatever identity it captures. Set this when the active backend may + /// be remote (`SimulatedRemoteBackend`, `QtWebSocketBackend`): it travels in + /// the `register` wire envelope so a server-side `RemoteServer::LogProvider` + /// can attach a log to the instance it creates. See `IBackend::registerModelWithContext`. + std::string contextKey; + /// @brief Current `ModelId` value in the active backend (0 = unbound). std::atomic currentId{0}; }; @@ -110,7 +120,8 @@ class Bridge { binding->typeId = std::string{::morph::model::ModelTraits::typeId()}; binding->modelFactory = [] { return ::morph::model::detail::ModelFactory::create(); }; std::scoped_lock lock{_mtx}; - binding->currentId.store(loadBackend()->registerModel(binding->typeId, binding->modelFactory).v); + binding->currentId.store( + loadBackend()->registerModelWithContext(binding->typeId, binding->modelFactory, binding->contextKey).v); _handlers.push_back(binding); return binding; } @@ -118,11 +129,13 @@ class Bridge { /// @brief Registers a pre-built binding with a custom factory. /// /// Use this overload when the model factory needs to capture external - /// dependencies that the type-erasing default factory cannot carry. + /// dependencies that the type-erasing default factory cannot carry, or when + /// `binding->contextKey` needs to be set before registration (see `HandlerBinding::contextKey`). /// @param binding Pre-constructed binding. Its `typeId` and `modelFactory` must be set. void registerHandler(const std::shared_ptr& binding) { std::scoped_lock lock{_mtx}; - binding->currentId.store(loadBackend()->registerModel(binding->typeId, binding->modelFactory).v); + binding->currentId.store( + loadBackend()->registerModelWithContext(binding->typeId, binding->modelFactory, binding->contextKey).v); _handlers.push_back(binding); } @@ -171,7 +184,7 @@ class Bridge { if (!binding) { continue; } - auto newId = newShared->registerModel(binding->typeId, binding->modelFactory); + auto newId = newShared->registerModelWithContext(binding->typeId, binding->modelFactory, binding->contextKey); binding->currentId.store(newId.v); live.push_back(weak); } @@ -250,7 +263,26 @@ class Bridge { }; call.localOp = [sharedAction](::morph::model::detail::IModelHolder& holder) -> std::shared_ptr { auto& model = holder.template into(); - return std::make_shared(model.execute(*sharedAction)); + auto result = std::make_shared(model.execute(*sharedAction)); + // Local mode has no client/server split, so this is the same execution + // site `ActionDispatcher::registerAction`'s runner is for remote modes + // (registry.hpp) — see that overload's doc comment for the full story. + if constexpr (::morph::model::detail::actionLoggable() == ::morph::model::Loggable::Yes) { + if (holder.hasActionLog()) { + // entityKey/principal/timestampMs are filled in by recordIfAttached. + holder.recordIfAttached(::morph::journal::LogEntry{ + .seq = 0, + .modelType = std::string{::morph::model::ModelTraits::typeId()}, + .entityKey = {}, + .actionType = std::string{::morph::model::ActionTraits::typeId()}, + .payload = ::morph::model::ActionTraits::toJson(*sharedAction), + .result = ::morph::model::ActionTraits::resultToJson(*result), + .principal = {}, + .timestampMs = 0, + }); + } + } + return result; }; { std::scoped_lock lock{_sessionMtx}; @@ -298,7 +330,7 @@ class Bridge { if (!binding) { continue; } - auto newId = pinned->registerModel(binding->typeId, binding->modelFactory); + auto newId = pinned->registerModelWithContext(binding->typeId, binding->modelFactory, binding->contextKey); binding->currentId.store(newId.v); } }); diff --git a/include/morph/file_action_log.hpp b/include/morph/file_action_log.hpp new file mode 100644 index 0000000..cfec806 --- /dev/null +++ b/include/morph/file_action_log.hpp @@ -0,0 +1,120 @@ +// SPDX-License-Identifier: Apache-2.0 + +#pragma once +#include "action_log.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +#if defined(_WIN32) +#include +#else +#include +#endif + +namespace morph::journal { + +/// @brief Append-only, newline-delimited-JSON `IActionLog` backed by a local file. +/// +/// Each entry is written as one `journal::toJson`-encoded line. `flush()` flushes +/// the C stdio buffer and then issues a real fsync (POSIX `fsync` / Windows +/// `_commit`), so a crash immediately after `flush()` returns cannot lose data — +/// this is the "local file" sink from the original request, and the natural +/// target for `SessionLog::checkpoint()` at a "Save" action. +/// +/// @par Process-local `seq` +/// Like `InMemoryActionLog`, `seq` is assigned fresh per process instance — it +/// does not resume from the highest `seq` already on disk if the file is +/// reopened. Entries remain correctly ordered on disk regardless (append-only), +/// but `seq` alone is not a cross-restart unique key; use `entries()`' natural +/// file order for that. +/// +/// @par Thread safety +/// All public methods are thread-safe (guarded by an internal mutex). Safe to +/// use from multiple threads within one process; not safe for multiple +/// processes to append to the same path concurrently. +class FileActionLog : public IActionLog { +public: + /// @brief Opens (creating if necessary) @p path for appending. + /// @param path File to append entries to. + /// @throws std::runtime_error if the file cannot be opened. + explicit FileActionLog(std::filesystem::path path) : _path{std::move(path)} { + _file = std::fopen(_path.string().c_str(), "a"); + if (_file == nullptr) { + throw std::runtime_error("FileActionLog: failed to open " + _path.string()); + } + } + + /// @brief Closes the underlying file. + /// + /// `_file` is always non-null here: the constructor either finishes with a + /// valid handle or throws before completing (in which case this destructor + /// never runs), and copy/move are deleted, so there is no path that could + /// null it out afterwards. + ~FileActionLog() override { std::fclose(_file); } + + FileActionLog(const FileActionLog&) = delete; + FileActionLog& operator=(const FileActionLog&) = delete; + FileActionLog(FileActionLog&&) = delete; + FileActionLog& operator=(FileActionLog&&) = delete; + + /// @brief Appends @p entry as one JSON line. Buffered until `flush()`. Thread-safe. + /// @param entry Entry to append; `seq` is overwritten regardless of the input value. + void append(LogEntry entry) override { + std::scoped_lock lock{_mtx}; + entry.seq = ++_nextSeq; + auto line = toJson(entry); + line.push_back('\n'); + std::fwrite(line.data(), 1, line.size(), _file); + } + + /// @brief Flushes stdio's buffer, then fsyncs the file descriptor. Thread-safe. + void flush() override { + std::scoped_lock lock{_mtx}; + std::fflush(_file); +#if defined(_WIN32) + _commit(_fileno(_file)); +#else + ::fsync(fileno(_file)); +#endif + } + + /// @brief Re-reads the file from disk and decodes every line. Thread-safe. + /// + /// Reads whatever is currently on disk, including anything written but not + /// yet `flush()`ed if the platform's stdio buffering has already handed it + /// to the OS — callers that need a guaranteed-durable view should `flush()` + /// first. + /// @param entityKey If non-empty, restricts the result to that entity's entries. + /// @return Matching entries, in on-disk (append) order. + [[nodiscard]] std::vector entries(std::string_view entityKey = {}) const override { + std::scoped_lock lock{_mtx}; + std::ifstream in{_path}; + std::vector out; + std::string line; + while (std::getline(in, line)) { + if (line.empty()) { + continue; + } + auto entry = fromJson(line); + if (entityKey.empty() || entry.entityKey == entityKey) { + out.push_back(std::move(entry)); + } + } + return out; + } + +private: + std::filesystem::path _path; + std::FILE* _file = nullptr; + mutable std::mutex _mtx; + uint64_t _nextSeq{0}; +}; + +} // namespace morph::journal diff --git a/include/morph/journal.hpp b/include/morph/journal.hpp new file mode 100644 index 0000000..6ba135f --- /dev/null +++ b/include/morph/journal.hpp @@ -0,0 +1,180 @@ +// SPDX-License-Identifier: Apache-2.0 + +#pragma once +#include +#include +#include +#include +#include +#include +#include +#include + +#include "action_log.hpp" +#include "model.hpp" +#include "registry.hpp" + +namespace morph::journal { + +/// @brief Reconstructs model state by replaying @p entries, in order, against a +/// freshly created model instance. +/// +/// Builds on the same `ModelRegistryFactory` / `ActionDispatcher` machinery +/// `RemoteServer` already uses to dispatch incoming requests — no separate +/// replay engine is needed. Because a model's entire state is exactly "initial +/// state plus the ordered actions replayed against it", this both reconstructs +/// state from a durable log and powers `SessionLog::undoLast()` below. +/// +/// @param modelTypeId String type-id of the model to reconstruct (`ModelTraits::typeId()`). +/// @param entries Ordered entries to replay, typically from `IActionLog::entries()`. +/// @param registry Model factory registry; defaults to the process-level singleton. +/// @param dispatcher Action dispatcher; defaults to the process-level singleton. +/// @return A freshly created holder with @p entries replayed against it. +/// @throws std::runtime_error if @p modelTypeId or any entry's action type is unregistered. +inline std::unique_ptr<::morph::model::detail::IModelHolder> replay( + std::string_view modelTypeId, const std::vector& entries, + ::morph::model::detail::ModelRegistryFactory& registry = ::morph::model::detail::defaultRegistry(), + ::morph::model::detail::ActionDispatcher& dispatcher = ::morph::model::detail::defaultDispatcher()) { + auto holder = registry.create(modelTypeId); + for (const auto& entry : entries) { + dispatcher.dispatch(entry.modelType, entry.actionType, *holder, entry.payload); + } + return holder; +} + +/// @brief Full-fidelity, in-memory log of one model instance's executed actions. +/// +/// Attach directly via `IModelHolder::attachActionLog` — every successfully +/// executed loggable action is appended here in order, regardless of any +/// `ActionLogPolicy::coalesce` setting. This full history is the raw material +/// for `undoLast()`. `checkpoint()` is the one place coalescing actually +/// happens: entries accumulated since the last checkpoint are reduced by +/// `(modelType, entityKey, actionType)` — keeping only the latest occurrence +/// where the action's policy says `coalesce == true`, keeping every occurrence +/// otherwise — and only that reduced set reaches the durable sink. +/// +/// @par Thread safety +/// All public methods are thread-safe (guarded by an internal mutex). +class SessionLog : public IActionLog { +public: + /// @brief Appends @p entry, assigning a monotonically increasing `seq`. + /// + /// Always full fidelity: this is what `undoLast()` walks back through, so + /// nothing is coalesced or dropped here. + /// @param entry Entry to append; `seq` is overwritten regardless of the input value. + void append(LogEntry entry) override { + std::scoped_lock lock{_mtx}; + entry.seq = ++_nextSeq; + _all.push_back(std::move(entry)); + } + + /// @brief No-op — `checkpoint()` is this class's real commit point. + void flush() override {} + + /// @brief Returns the full history (or one entity's slice of it) in append order. + /// @param entityKey If non-empty, restricts the result to that entity's entries. + /// @return Matching entries, in append order. + [[nodiscard]] std::vector entries(std::string_view entityKey = {}) const override { + std::scoped_lock lock{_mtx}; + if (entityKey.empty()) { + return _all; + } + std::vector out; + for (const auto& entry : _all) { + if (entry.entityKey == entityKey) { + out.push_back(entry); + } + } + return out; + } + + /// @brief Drops the most recently appended entry and replays everything that + /// remains against a fresh model instance, reconstructing the state + /// the model was in immediately before that entry executed. + /// + /// No inverse/undo operations are needed on the action types themselves — + /// this reuses `replay()` over a shorter prefix of the same log. A no-op + /// (returns a freshly created, un-replayed holder) if the log is empty. + /// + /// @param modelTypeId String type-id of the model to reconstruct. + /// @param registry Model factory registry; defaults to the process-level singleton. + /// @param dispatcher Action dispatcher; defaults to the process-level singleton. + /// @return A freshly created holder with the pre-undo state replayed onto it. + 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()) { + std::vector remaining; + { + std::scoped_lock lock{_mtx}; + if (!_all.empty()) { + _all.pop_back(); + _committedUpTo = std::min(_committedUpTo, _all.size()); + } + remaining = _all; + } + return replay(modelTypeId, remaining, registry, dispatcher); + } + + /// @brief Coalesces entries appended since the last checkpoint and forwards + /// the reduced set, in order, to @p durableSink; then flushes it. + /// + /// Advances the checkpoint regardless of whether @p durableSink->flush() + /// throws, matching `IOfflineQueue`-style at-least-once semantics: a + /// checkpoint is a forward-only commit point, not a transaction to retry. + /// A no-op if nothing has been appended since the last checkpoint. + /// + /// @param durableSink Receives only the coalesced entries — never the raw stream. + /// @param dispatcher Supplies each action's `coalesce` policy; defaults to + /// the process-level singleton (the same one actions were + /// registered against via `BRIDGE_REGISTER_ACTION`). + void checkpoint(IActionLog& durableSink, + ::morph::model::detail::ActionDispatcher& dispatcher = ::morph::model::detail::defaultDispatcher()) { + std::vector pending; + { + std::scoped_lock lock{_mtx}; + if (_committedUpTo >= _all.size()) { + return; + } + pending.assign(_all.begin() + static_cast(_committedUpTo), _all.end()); + _committedUpTo = _all.size(); + } + for (auto& entry : coalesced(pending, dispatcher)) { + durableSink.append(std::move(entry)); + } + durableSink.flush(); + } + +private: + /// @brief Reduces @p pending by `(modelType, entityKey, actionType)`: the + /// latest occurrence overwrites earlier ones (in place, preserving + /// first-seen position) wherever @p dispatcher says the action + /// coalesces; every other entry is kept as-is. + static std::vector coalesced(const std::vector& pending, + ::morph::model::detail::ActionDispatcher& dispatcher) { + std::vector out; + std::unordered_map lastIndexForKey; + for (const auto& entry : pending) { + if (!dispatcher.coalesce(entry.modelType, entry.actionType)) { + out.push_back(entry); + continue; + } + std::string key = entry.modelType + '\0' + entry.entityKey + '\0' + entry.actionType; + auto iter = lastIndexForKey.find(key); + if (iter == lastIndexForKey.end()) { + lastIndexForKey.emplace(std::move(key), out.size()); + out.push_back(entry); + } else { + out[iter->second] = entry; + } + } + return out; + } + + mutable std::mutex _mtx; + std::vector _all; + std::size_t _committedUpTo{0}; + uint64_t _nextSeq{0}; +}; + +} // namespace morph::journal diff --git a/include/morph/model.hpp b/include/morph/model.hpp index ac7dc98..aa6a2db 100644 --- a/include/morph/model.hpp +++ b/include/morph/model.hpp @@ -1,11 +1,14 @@ // SPDX-License-Identifier: Apache-2.0 #pragma once +#include #include #include #include #include +#include "action_log.hpp" +#include "session.hpp" #include "strand.hpp" namespace morph::model::detail { @@ -71,6 +74,49 @@ struct IModelHolder { } return static_cast*>(this)->model; } + + /// @brief Attaches a durable action log and this instance's stable identity. + /// + /// Set once, typically from the same custom `HandlerBinding::modelFactory` + /// closure already used to inject other dependencies. @p contextKey is stamped + /// onto every `LogEntry` this instance produces (e.g. an account id) so log + /// entries are identifiable without parsing `payload`/`result` JSON. + /// @param log Sink entries are forwarded to. Pass a `SessionLog` to also + /// get undo/checkpoint support. + /// @param contextKey Stable identity of this model instance. + void attachActionLog(std::shared_ptr<::morph::journal::IActionLog> log, std::string contextKey) { + _actionLog = std::move(log); + _contextKey = std::move(contextKey); + } + + /// @brief Returns `true` if an action log is attached to this instance. + [[nodiscard]] bool hasActionLog() const noexcept { return static_cast(_actionLog); } + + /// @brief Records @p entry if a log is attached; no-op otherwise. + /// + /// Called automatically by the two places `Model::execute()` is actually + /// invoked (`ActionDispatcher`'s runner and `Bridge::executeVia`'s local + /// op) — model code and application code never call this directly. + /// Overwrites `entityKey`, `principal`, and `timestampMs` on @p entry; + /// callers only need to fill `modelType`, `actionType`, `payload`, `result`. + /// @param entry Entry to record; `seq` is assigned by the attached sink. + void recordIfAttached(::morph::journal::LogEntry entry) { + if (!_actionLog) { + return; + } + entry.entityKey = _contextKey; + if (const auto* ctx = ::morph::session::current()) { + entry.principal = ctx->principal; + } + entry.timestampMs = std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()) + .count(); + _actionLog->append(std::move(entry)); + } + +private: + std::shared_ptr<::morph::journal::IActionLog> _actionLog; + std::string _contextKey; }; /// @brief Concrete holder that stores a `Model` by value. @@ -94,11 +140,23 @@ struct ModelHolder : IModelHolder, BackendChangedMixin { class ModelFactory { public: /// @brief Creates a new `ModelHolder` on the heap. + /// + /// If a process-wide default action log is installed (see + /// `morph::journal::setActionLog`), it is attached to the new holder + /// automatically (with an empty `entityKey`) — this is the single + /// construction path behind every ordinary model registration, local or + /// remote, which is what makes "set the log once in `main()`" work + /// uniformly across topologies. Callers that need a specific instance + /// identity call `attachActionLog` again afterward to override it. /// @tparam Model The model type to instantiate. /// @return Owning pointer to the new holder. template static std::unique_ptr create() { - return std::make_unique>(); + auto holder = std::make_unique>(); + if (auto log = ::morph::journal::defaultActionLog()) { + holder->attachActionLog(std::move(log), {}); + } + return holder; } }; diff --git a/include/morph/registry.hpp b/include/morph/registry.hpp index 4941c93..8efa124 100644 --- a/include/morph/registry.hpp +++ b/include/morph/registry.hpp @@ -93,8 +93,57 @@ struct ActionValidator { } }; +/// @brief Whether an action's executions are recorded to an attached action log. +/// +/// A strong type instead of a bare `bool` so registration call sites read as +/// intent (`Loggable::No`) rather than an unexplained `false`. +enum class Loggable { No, Yes }; + +/// @brief Per-action policy deciding how repeated executions are checkpointed +/// into a durable action log. +/// +/// Deliberately minimal for now — only `coalesce` exists, and it has no +/// registration macro yet. Specialise directly for the rare action where only +/// the latest occurrence should survive a checkpoint (e.g. a form-field edit +/// fired repeatedly via `BridgeHandler::set<...>`); every other action defaults +/// to `false`, meaning every execution is treated as a distinct, permanent fact +/// (the right default for anything resembling a business event). +/// @tparam Action Concrete action type. +template +struct ActionLogPolicy { + /// @brief If `true`, a checkpoint keeps only the most recent entry per + /// `(modelType, entityKey, actionType)`. If `false` (default), every + /// entry survives. + static constexpr bool coalesce = false; +}; + namespace detail { +/// @brief Concept satisfied by `ActionTraits` specialisations that expose a +/// `Loggable loggable` static member. +/// +/// Lets `actionLoggable()` default to `Loggable::Yes` for hand-written +/// `ActionTraits` specialisations (as used in tests) that predate this member, +/// instead of requiring every existing specialisation to be updated. +template +concept HasLoggableFlag = requires { + { ActionTraits::loggable } -> std::convertible_to; +}; + +/// @brief Returns `ActionTraits::loggable` if present, otherwise `Loggable::Yes`. +/// +/// Logging every action by default (opt out via the macro's 4th argument) means +/// new actions are captured automatically; only actions that are known to be +/// pure queries need to opt out explicitly. +template +constexpr Loggable actionLoggable() { + if constexpr (HasLoggableFlag) { + return ActionTraits::loggable; + } else { + return Loggable::Yes; + } +} + /// @brief Exception thrown when JSON serialisation or deserialisation fails. struct ParseError : std::runtime_error { using std::runtime_error::runtime_error; @@ -114,6 +163,12 @@ class ActionDispatcher { using Runner = std::function; /// @brief Registers a runner for `(Model, Action)` under the given string ids. + /// + /// This is the single execution site used by `RemoteServer` (every remote and + /// Qt WebSocket topology) — `Model::execute()` runs here, on whichever process + /// actually owns @p holder. If a `journal::IActionLog` is attached to @p holder + /// (via `IModelHolder::attachActionLog`) and `Action` is loggable (the default), + /// the executed action is recorded automatically after it succeeds. template void registerAction(std::string_view modelId, std::string_view actionId) { Key key{std::string{modelId}, std::string{actionId}}; @@ -121,8 +176,25 @@ class ActionDispatcher { auto action = ActionTraits::fromJson(payloadJson); auto& model = holder.template into(); auto result = model.execute(action); - return ActionTraits::resultToJson(result); + auto resultJson = ActionTraits::resultToJson(result); + if constexpr (detail::actionLoggable() == Loggable::Yes) { + if (holder.hasActionLog()) { + // entityKey/principal/timestampMs are filled in by recordIfAttached. + holder.recordIfAttached(::morph::journal::LogEntry{ + .seq = 0, + .modelType = std::string{ModelTraits::typeId()}, + .entityKey = {}, + .actionType = std::string{ActionTraits::typeId()}, + .payload = std::string{payloadJson}, + .result = resultJson, + .principal = {}, + .timestampMs = 0, + }); + } + } + return resultJson; }; + _coalesce[key] = ActionLogPolicy::coalesce; } /// @brief Dispatches an action against @p holder and returns the JSON-encoded result. @@ -136,6 +208,17 @@ class ActionDispatcher { return iter->second(holder, payload); } + /// @brief Returns whether `(modelId, actionId)` was registered with + /// `ActionLogPolicy::coalesce == true`. + /// + /// Used by `journal::SessionLog::checkpoint()` to decide, from the type-erased + /// `LogEntry` stream, which entries collapse to their latest occurrence. + /// Unknown pairs (never registered) default to `false` — every entry kept. + [[nodiscard]] bool coalesce(std::string_view modelId, std::string_view actionId) const { + auto iter = _coalesce.find(Key{std::string{modelId}, std::string{actionId}}); + return iter != _coalesce.end() && iter->second; + } + /// @brief Returns the process-level singleton dispatcher. static ActionDispatcher& instance() { return defaultDispatcher(); } @@ -149,6 +232,7 @@ class ActionDispatcher { } }; std::unordered_map _runners; + std::unordered_map _coalesce; }; /// @brief Registry that creates `IModelHolder` instances by string type-id. @@ -234,14 +318,34 @@ inline bool registerActionOnce(std::string_view modelId, std::string_view action /// registers the action with the process-level `ActionDispatcher` at static-init time. /// `BRIDGE_REGISTER_MODEL(M, ...)` must be called before this macro. /// +/// An optional 4th argument overrides whether executions of @p A are recorded to +/// an attached action log (see `IModelHolder::attachActionLog`); omitted, it +/// defaults to `morph::model::Loggable::Yes`, so every action is captured unless +/// explicitly opted out — typically for pure queries: +/// @code +/// BRIDGE_REGISTER_ACTION(AccountModel, Deposit, "Deposit") // logged +/// BRIDGE_REGISTER_ACTION(AccountModel, GetAccount, "GetAccount", morph::model::Loggable::No) // opt out +/// @endcode +/// /// @param M Concrete model type that handles the action. /// @param A Concrete action type. /// @param NAME String literal used as the action type-id. -#define BRIDGE_REGISTER_ACTION(M, A, NAME) \ +/// @param ... Optional: a `morph::model::Loggable` value (defaults to `Loggable::Yes`). +#define BRIDGE_REGISTER_ACTION(...) \ + BRIDGE_REGISTER_ACTION_PICK(__VA_ARGS__, BRIDGE_REGISTER_ACTION_4, BRIDGE_REGISTER_ACTION_3) \ + (__VA_ARGS__) + +/// @cond detail +#define BRIDGE_REGISTER_ACTION_PICK(_1, _2, _3, _4, NAME, ...) NAME + +#define BRIDGE_REGISTER_ACTION_3(M, A, NAME) BRIDGE_REGISTER_ACTION_4(M, A, NAME, ::morph::model::Loggable::Yes) + +#define BRIDGE_REGISTER_ACTION_4(M, A, NAME, LOGGABLE) \ template <> \ struct morph::model::ActionTraits { \ using Result = decltype(std::declval().execute(std::declval())); \ static constexpr std::string_view typeId() { return NAME; } \ + static constexpr ::morph::model::Loggable loggable = (LOGGABLE); \ static std::string toJson(const A& action) { \ std::string out; \ if (auto errCode = glz::write_json(action, out)) { \ @@ -275,6 +379,7 @@ inline bool registerActionOnce(std::string_view modelId, std::string_view action [[maybe_unused]] const bool bridge_action_reg_##M##_##A = \ morph::model::detail::registerActionOnce(morph::model::ModelTraits::typeId(), NAME); \ } +/// @endcond /// @brief Registers a readiness predicate for action @p A. /// diff --git a/include/morph/remote.hpp b/include/morph/remote.hpp index 01043cd..43de923 100644 --- a/include/morph/remote.hpp +++ b/include/morph/remote.hpp @@ -7,10 +7,12 @@ #include #include #include +#include #include #include #include +#include "action_log.hpp" #include "backend.hpp" #include "session.hpp" #include "wire.hpp" @@ -103,6 +105,28 @@ class RemoteServer : public std::enable_shared_from_this { return reply; } + /// @brief Callable that supplies the action log to attach to a newly + /// registered instance, given its model type and `contextKey`. + /// + /// Return `nullptr` to register the instance with no log attached (e.g. for + /// model types or context keys the host app doesn't want journaled). + using LogProvider = + std::function(std::string_view modelType, std::string_view contextKey)>; + + /// @brief Installs @p provider, consulted on every `register` envelope whose + /// `contextKey` is non-empty. + /// + /// This is what closes the gap `IModelHolder::attachActionLog` leaves open + /// for remote topologies: `RemoteServer` owns the actual model instances for + /// every remote/simulated-remote client, so it is the only place that can + /// attach a log to them. Pass `nullptr` to remove a previously installed + /// provider (new registrations get no log). Thread-safe. + /// @param provider Callable invoked synchronously while handling `register`. + void setLogProvider(LogProvider provider) { + std::scoped_lock lock{_logProviderMtx}; + _logProvider = std::move(provider); + } + private: void dispatchMessage(const std::string& msg, std::function& reply) { ::morph::wire::Envelope env; @@ -118,6 +142,18 @@ class RemoteServer : public std::enable_shared_from_this { throw std::runtime_error("register requires a typeId"); } auto holder = _registry.create(env.typeId); + if (!env.contextKey.empty()) { + LogProvider provider; + { + std::scoped_lock lock{_logProviderMtx}; + provider = _logProvider; + } + if (provider) { + if (auto log = provider(env.typeId, env.contextKey)) { + holder->attachActionLog(std::move(log), env.contextKey); + } + } + } ::morph::exec::detail::ModelId mid{_nextId.fetch_add(1) + 1}; { std::scoped_lock lock{_regMtx}; @@ -181,6 +217,8 @@ class RemoteServer : public std::enable_shared_from_this { ::morph::exec::detail::ModelIdHash> _models; std::atomic _nextId{0}; + std::mutex _logProviderMtx; + LogProvider _logProvider; }; /// @brief `IBackend` adapter that routes all calls through a `RemoteServer` using a @@ -209,8 +247,24 @@ class SimulatedRemoteBackend : public detail::IBackend { ::morph::exec::detail::ModelId registerModel( const std::string& typeId, std::function()>) override { - auto reply = ::morph::wire::decode( - _server.handleInline(::morph::wire::encode(::morph::wire::makeRegister(typeId)))); + return registerModelWithContext(typeId, nullptr, {}); + } + + /// @brief Registers the model type on the server, carrying @p contextKey across + /// the wire so the server's `RemoteServer::LogProvider` (if configured) + /// can attach an action log to the instance it creates. + /// + /// @p factory is still ignored — model construction is delegated to the + /// server's `ModelRegistryFactory`, same as `registerModel()`. + /// @param typeId String type-id sent in the `register` message. + /// @param contextKey Stable identity of the new instance; empty if none. + /// @return `ModelId` assigned by the server. + /// @throws std::runtime_error if the server replies with an error. + ::morph::exec::detail::ModelId registerModelWithContext( + const std::string& typeId, std::function()>, + std::string_view contextKey) override { + auto reply = ::morph::wire::decode(_server.handleInline( + ::morph::wire::encode(::morph::wire::makeRegister(typeId, std::string{contextKey})))); if (reply.kind == "ok") { return ::morph::exec::detail::ModelId{reply.modelId}; } diff --git a/include/morph/wire.hpp b/include/morph/wire.hpp index cda23c3..82195b6 100644 --- a/include/morph/wire.hpp +++ b/include/morph/wire.hpp @@ -18,7 +18,9 @@ namespace morph::wire { /// Empty/zero fields are tolerated — callers populate only what their kind needs. /// /// @par Discriminator values -/// - `"register"` — client requests model creation. Uses `typeId`. +/// - `"register"` — client requests model creation. Uses `typeId`, and +/// optionally `contextKey` (the new instance's stable +/// identity, e.g. an account id — see `RemoteServer::setLogProvider`). /// - `"deregister"` — client destroys an instance. Uses `modelId`. /// - `"execute"` — client dispatches an action. Uses `callId`, `modelId`, /// `modelType`, `actionType`, `body`, and optionally `session`. @@ -35,6 +37,12 @@ struct Envelope { /// @brief Model type id for `register`. std::string typeId; + /// @brief Stable identity of the model instance being registered (e.g. an + /// account id). Empty means "no identity" — the server-side holder + /// gets no action log attached even if a `LogProvider` is configured. + /// Ignored on every kind other than `register`. + std::string contextKey; + /// @brief Existing model instance id for `deregister`, `execute`, `ok(register)`. uint64_t modelId = 0; @@ -57,10 +65,13 @@ struct Envelope { }; /// @brief Builds a `register` envelope. -inline Envelope makeRegister(std::string typeId) { +/// @param typeId Model type id to register. +/// @param contextKey Optional stable identity for the new instance (default: none). +inline Envelope makeRegister(std::string typeId, std::string contextKey = {}) { Envelope env; env.kind = "register"; env.typeId = std::move(typeId); + env.contextKey = std::move(contextKey); return env; } diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index e0fbf37..8f0402f 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -30,6 +30,8 @@ add_executable(morph_tests test_coverage_extra.cpp test_coverage_push95.cpp test_server_limits.cpp + test_action_log.cpp + test_action_log_phase2.cpp ) target_link_libraries(morph_tests diff --git a/tests/test_action_log.cpp b/tests/test_action_log.cpp new file mode 100644 index 0000000..1bffbae --- /dev/null +++ b/tests/test_action_log.cpp @@ -0,0 +1,608 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// Coverage for the ordered action log (issue #3, phase 1): action_log.hpp, +// journal.hpp, and the touched lines in model.hpp/registry.hpp/bridge.hpp — +// Loggable default-on, ActionLogPolicy::coalesce, IModelHolder::attachActionLog/ +// recordIfAttached, the two real `Model::execute()` sites (ActionDispatcher's +// runner and Bridge::executeVia's localOp), SessionLog checkpoint/undo, and +// journal::replay. + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +#include "test_support.hpp" + +using SyncExec = morph::testing::InlineExecutor; +using morph::journal::IActionLog; +using morph::journal::InMemoryActionLog; +using morph::journal::LogEntry; +using morph::journal::SessionLog; + +// Fully-initialised LogEntry construction — entityKey/payload/result default to +// empty so call sites only spell out what a given test actually cares about. +namespace { +LogEntry makeEntry(std::string modelType, std::string entityKey, std::string actionType, std::string payload = {}, + std::string result = {}) { + return LogEntry{ + .seq = 0, + .modelType = std::move(modelType), + .entityKey = std::move(entityKey), + .actionType = std::move(actionType), + .payload = std::move(payload), + .result = std::move(result), + .principal = {}, + .timestampMs = 0, + }; +} +} // namespace + +// ── Test model, registered via the macros (global registry/dispatcher) ───────── +// +// ALDeposit/ALSetNickname default to Loggable::Yes (no 4th macro argument). +// ALGetBalance opts out explicitly — a pure query, the case the design doc +// calls out as the reason the default is "log everything, opt specific actions +// out" rather than the other way around. + +struct ALDeposit { + int amount = 0; +}; +struct ALGetBalance {}; +struct ALSetNickname { + std::string name; +}; + +struct ALModel { + int balance = 0; + std::string nickname; + int execute(const ALDeposit& a) { + balance += a.amount; + return balance; + } + int execute(const ALGetBalance& /*a*/) { return balance; } + std::string execute(const ALSetNickname& a) { + nickname = a.name; + return nickname; + } +}; + +// SetNickname is the "same field edited repeatedly" case — only the latest +// occurrence should survive a checkpoint. Must be visible before the +// BRIDGE_REGISTER_ACTION call below (explicit specialisations must precede the +// point where registerAction is implicitly instantiated). +template <> +struct morph::model::ActionLogPolicy { + static constexpr bool coalesce = true; +}; + +BRIDGE_REGISTER_MODEL(ALModel, "AL_Model") +BRIDGE_REGISTER_ACTION(ALModel, ALDeposit, "AL_Deposit") +BRIDGE_REGISTER_ACTION(ALModel, ALGetBalance, "AL_GetBalance", ::morph::model::Loggable::No) +BRIDGE_REGISTER_ACTION(ALModel, ALSetNickname, "AL_SetNickname") + +// ── Hand-written ActionTraits, no `loggable` member — mirrors test_dispatch_di.cpp. +// Exercises actionLoggable()'s "member absent" branch: defaults to Yes without +// requiring every pre-existing manual ActionTraits specialisation to be touched. + +struct ALLegacyAction { + int x = 0; +}; +struct ALLegacyModel { + int execute(const ALLegacyAction& a) { return a.x; } +}; + +template <> +struct morph::model::ActionTraits { + using Result = int; + static constexpr std::string_view typeId() { return "AL_LegacyAction"; } + static std::string toJson(const ALLegacyAction& a) { return R"({"x":)" + std::to_string(a.x) + "}"; } + static ALLegacyAction fromJson(std::string_view json) { + ALLegacyAction action{}; + auto pos = json.find(':'); + if (pos != std::string_view::npos) { + action.x = std::stoi(std::string{json.substr(pos + 1)}); + } + return action; + } + static std::string resultToJson(const int& r) { return std::to_string(r); } + static int resultFromJson(std::string_view s) { return std::stoi(std::string{s}); } +}; +template <> +struct morph::model::ModelTraits { + static constexpr std::string_view typeId() { return "AL_LegacyModel"; } +}; + +// ── InMemoryActionLog ──────────────────────────────────────────────────────── + +TEST_CASE("morph::journal::InMemoryActionLog: append assigns increasing seq, preserves order", "[action_log]") { + InMemoryActionLog log; + log.append(makeEntry("M", "", "A1")); + log.append(makeEntry("M", "", "A2")); + auto all = log.entries(); + REQUIRE(all.size() == 2); + REQUIRE(all[0].actionType == "A1"); + REQUIRE(all[0].seq == 1); + REQUIRE(all[1].actionType == "A2"); + REQUIRE(all[1].seq == 2); +} + +TEST_CASE("morph::journal::InMemoryActionLog: entries(entityKey) filters, empty key returns all", "[action_log]") { + InMemoryActionLog log; + log.append(makeEntry("M", "acct-1", "A")); + log.append(makeEntry("M", "acct-2", "A")); + log.append(makeEntry("M", "acct-1", "B")); + + REQUIRE(log.entries().size() == 3); + auto acct1 = log.entries("acct-1"); + REQUIRE(acct1.size() == 2); + REQUIRE(acct1[0].actionType == "A"); + REQUIRE(acct1[1].actionType == "B"); + REQUIRE(log.entries("no-such-account").empty()); +} + +TEST_CASE("morph::journal::InMemoryActionLog: flush is a callable no-op", "[action_log]") { + InMemoryActionLog log; + log.append(makeEntry("M", "", "A")); + log.flush(); + REQUIRE(log.entries().size() == 1); +} + +// ── Loggable default / opt-out, actionLoggable() ───────────────────────── + +TEST_CASE("Loggable: macro defaults to Yes, explicit 4th argument opts out", "[action_log][loggable]") { + STATIC_REQUIRE(morph::model::ActionTraits::loggable == morph::model::Loggable::Yes); + STATIC_REQUIRE(morph::model::ActionTraits::loggable == morph::model::Loggable::Yes); + STATIC_REQUIRE(morph::model::ActionTraits::loggable == morph::model::Loggable::No); +} + +TEST_CASE("actionLoggable(): defaults to Yes when ActionTraits has no loggable member", "[action_log][loggable]") { + // Runtime REQUIRE (not STATIC_REQUIRE) so the call is actually emitted and + // instrumented — a compile-time-only check would never show up in coverage. + auto legacy = morph::model::detail::actionLoggable(); + auto deposit = morph::model::detail::actionLoggable(); + auto getBalance = morph::model::detail::actionLoggable(); + REQUIRE(legacy == morph::model::Loggable::Yes); + REQUIRE(deposit == morph::model::Loggable::Yes); + REQUIRE(getBalance == morph::model::Loggable::No); +} + +// ── IModelHolder::attachActionLog / hasActionLog / recordIfAttached ───────── + +TEST_CASE("IModelHolder: recordIfAttached is a no-op with no log attached", "[action_log][holder]") { + auto holder = morph::model::detail::ModelFactory::create(); + REQUIRE_FALSE(holder->hasActionLog()); + holder->recordIfAttached(makeEntry("AL_Model", "", "AL_Deposit")); // must not throw + SUCCEED("no crash with no log attached"); +} + +TEST_CASE("IModelHolder: attachActionLog stamps entityKey and timestamp automatically", "[action_log][holder]") { + auto holder = morph::model::detail::ModelFactory::create(); + auto log = std::make_shared(); + holder->attachActionLog(log, "acct-42"); + REQUIRE(holder->hasActionLog()); + + holder->recordIfAttached(makeEntry("AL_Model", "", "AL_Deposit", "{\"amount\":5}", "5")); + + auto entries = log->entries(); + REQUIRE(entries.size() == 1); + REQUIRE(entries[0].entityKey == "acct-42"); + REQUIRE(entries[0].payload == "{\"amount\":5}"); + REQUIRE(entries[0].result == "5"); + REQUIRE(entries[0].principal.empty()); + REQUIRE(entries[0].timestampMs > 0); +} + +TEST_CASE("IModelHolder: recordIfAttached captures the active session principal", "[action_log][holder]") { + auto holder = morph::model::detail::ModelFactory::create(); + auto log = std::make_shared(); + holder->attachActionLog(log, "acct-7"); + + ::morph::session::Context ctx; + ctx.principal = "alice"; + { + ::morph::session::detail::ScopedContext scoped{ctx}; + holder->recordIfAttached(makeEntry("AL_Model", "", "AL_Deposit")); + } + holder->recordIfAttached(makeEntry("AL_Model", "", "AL_Deposit")); // outside the scope + + auto entries = log->entries(); + REQUIRE(entries.size() == 2); + REQUIRE(entries[0].principal == "alice"); + REQUIRE(entries[1].principal.empty()); +} + +// ── ActionDispatcher: the server-side execution site (registry.hpp) ──────── +// +// This is the exact code path RemoteServer::dispatchExecute uses for every +// remote/Qt topology — exercised directly here (isolated dispatcher/registry, +// matching test_dispatch_di.cpp's style) rather than through a live socket. + +TEST_CASE("ActionDispatcher: records loggable actions, skips opted-out ones, tracks coalesce", "[action_log][dispatch]") { + morph::model::detail::ActionDispatcher dispatcher; + morph::model::detail::ModelRegistryFactory registry; + registry.registerModel("AL_Model"); + dispatcher.registerAction("AL_Model", "AL_Deposit"); + dispatcher.registerAction("AL_Model", "AL_GetBalance"); + dispatcher.registerAction("AL_Model", "AL_SetNickname"); + + REQUIRE_FALSE(dispatcher.coalesce("AL_Model", "AL_Deposit")); + REQUIRE(dispatcher.coalesce("AL_Model", "AL_SetNickname")); + REQUIRE_FALSE(dispatcher.coalesce("AL_Model", "no-such-action")); + + auto holder = registry.create("AL_Model"); + auto log = std::make_shared(); + holder->attachActionLog(log, "acct-1"); + + auto depositJson = morph::model::ActionTraits::toJson(ALDeposit{.amount = 10}); + REQUIRE(dispatcher.dispatch("AL_Model", "AL_Deposit", *holder, depositJson) == "10"); + REQUIRE(dispatcher.dispatch("AL_Model", "AL_GetBalance", *holder, "{}") == "10"); + + auto entries = log->entries(); + REQUIRE(entries.size() == 1); // GetBalance opted out — not recorded + REQUIRE(entries[0].modelType == "AL_Model"); + REQUIRE(entries[0].actionType == "AL_Deposit"); + REQUIRE(entries[0].entityKey == "acct-1"); + REQUIRE(entries[0].payload == depositJson); + REQUIRE(entries[0].result == "10"); +} + +TEST_CASE("ActionDispatcher: dispatch against a holder with no log attached does not crash", "[action_log][dispatch]") { + morph::model::detail::ActionDispatcher dispatcher; + morph::model::detail::ModelRegistryFactory registry; + registry.registerModel("AL_Model"); + dispatcher.registerAction("AL_Model", "AL_Deposit"); + + auto holder = registry.create("AL_Model"); + REQUIRE_FALSE(holder->hasActionLog()); + auto depositJson = morph::model::ActionTraits::toJson(ALDeposit{.amount = 3}); + REQUIRE(dispatcher.dispatch("AL_Model", "AL_Deposit", *holder, depositJson) == "3"); +} + +TEST_CASE("ActionDispatcher: runner records for hand-written ActionTraits with no loggable member", + "[action_log][dispatch]") { + morph::model::detail::ActionDispatcher dispatcher; + morph::model::detail::ModelRegistryFactory registry; + registry.registerModel("AL_LegacyModel"); + dispatcher.registerAction("AL_LegacyModel", "AL_LegacyAction"); + + auto holder = registry.create("AL_LegacyModel"); + auto log = std::make_shared(); + holder->attachActionLog(log, "legacy-1"); + + REQUIRE(dispatcher.dispatch("AL_LegacyModel", "AL_LegacyAction", *holder, R"({"x":9})") == "9"); + REQUIRE(log->entries().size() == 1); +} + +// ── Bridge::executeVia's localOp — the local-mode execution site (bridge.hpp) ── + +TEST_CASE("Bridge/LocalBackend: local-mode execution records loggable actions, skips opted-out ones", + "[action_log][bridge]") { + morph::exec::ThreadPoolExecutor pool{2}; + SyncExec cbExec; + morph::bridge::Bridge bridge{std::make_unique(pool)}; + + auto log = std::make_shared(); + auto binding = std::make_shared(); + binding->typeId = "AL_Model"; + binding->modelFactory = [log] { + auto holder = morph::model::detail::ModelFactory::create(); + holder->attachActionLog(log, "acct-99"); + return holder; + }; + morph::bridge::BridgeHandler handler{bridge, &cbExec, binding}; + + std::atomic depositResult{-1}; + handler.execute(ALDeposit{.amount = 20}) + .then([&](int v) { depositResult.store(v); }) + .onError([](const std::exception_ptr&) {}); + REQUIRE(morph::testing::waitUntil([&] { return depositResult.load() != -1; })); + REQUIRE(depositResult.load() == 20); + + std::atomic balanceResult{-1}; + handler.execute(ALGetBalance{}) + .then([&](int v) { balanceResult.store(v); }) + .onError([](const std::exception_ptr&) {}); + REQUIRE(morph::testing::waitUntil([&] { return balanceResult.load() != -1; })); + + auto entries = log->entries(); + REQUIRE(entries.size() == 1); // GetBalance opted out + REQUIRE(entries[0].actionType == "AL_Deposit"); + REQUIRE(entries[0].entityKey == "acct-99"); +} + +TEST_CASE("Bridge/LocalBackend: local-mode execution without an attached log does not crash", + "[action_log][bridge]") { + morph::exec::ThreadPoolExecutor pool{2}; + SyncExec cbExec; + morph::bridge::Bridge bridge{std::make_unique(pool)}; + morph::bridge::BridgeHandler handler{bridge, &cbExec}; // default factory — no log + + std::atomic result{-1}; + handler.execute(ALDeposit{.amount = 4}).then([&](int v) { result.store(v); }).onError([](const std::exception_ptr&) { + }); + REQUIRE(morph::testing::waitUntil([&] { return result.load() != -1; })); + REQUIRE(result.load() == 4); +} + +// ── SimulatedRemoteBackend: recording never happens on the client side ───── +// +// SimulatedRemoteBackend::registerModel's factory parameter is documented as +// ignored — the server's own ModelRegistryFactory constructs the instance. +// This proves that guarantee holds for logging specifically: a log attached +// via the client's HandlerBinding factory is never populated, because the +// factory itself is never even invoked for a remote backend. + +TEST_CASE("SimulatedRemoteBackend: client-side factory (and its attached log) is never invoked", + "[action_log][remote]") { + morph::exec::ThreadPoolExecutor pool{2}; + SyncExec cbExec; + + morph::model::detail::ModelRegistryFactory serverRegistry; + morph::model::detail::ActionDispatcher serverDispatcher; + serverRegistry.registerModel("AL_Model"); + serverDispatcher.registerAction("AL_Model", "AL_Deposit"); + + auto server = std::make_shared(pool, serverDispatcher, serverRegistry); + morph::bridge::Bridge bridge{std::make_unique(*server)}; + + std::atomic factoryCalled{false}; + auto clientLog = std::make_shared(); + auto binding = std::make_shared(); + binding->typeId = "AL_Model"; + binding->modelFactory = [&factoryCalled, clientLog] { + factoryCalled.store(true); + auto holder = morph::model::detail::ModelFactory::create(); + holder->attachActionLog(clientLog, "acct-client"); + return holder; + }; + morph::bridge::BridgeHandler handler{bridge, &cbExec, binding}; + + std::atomic result{-1}; + handler.execute(ALDeposit{.amount = 7}).then([&](int v) { result.store(v); }).onError([](const std::exception_ptr&) { + }); + REQUIRE(morph::testing::waitUntil([&] { return result.load() != -1; })); + REQUIRE(result.load() == 7); // executed correctly, server-side, with no log attached there + + REQUIRE_FALSE(factoryCalled.load()); + REQUIRE(clientLog->entries().empty()); +} + +// ── journal::replay ────────────────────────────────────────────────────────── + +TEST_CASE("journal::replay: reconstructs state by re-executing entries in order", "[action_log][journal]") { + morph::model::detail::ActionDispatcher dispatcher; + morph::model::detail::ModelRegistryFactory registry; + registry.registerModel("AL_Model"); + dispatcher.registerAction("AL_Model", "AL_Deposit"); + + std::vector entries{ + makeEntry("AL_Model", "", "AL_Deposit", morph::model::ActionTraits::toJson(ALDeposit{.amount = 10})), + makeEntry("AL_Model", "", "AL_Deposit", morph::model::ActionTraits::toJson(ALDeposit{.amount = 5})), + }; + + auto holder = morph::journal::replay("AL_Model", entries, registry, dispatcher); + REQUIRE(holder->into().balance == 15); +} + +TEST_CASE("journal::replay: propagates the registry's unknown-model error", "[action_log][journal]") { + morph::model::detail::ActionDispatcher dispatcher; + morph::model::detail::ModelRegistryFactory registry; + REQUIRE_THROWS_AS(morph::journal::replay("NoSuchModel", {}, registry, dispatcher), std::runtime_error); +} + +// ── journal::SessionLog ────────────────────────────────────────────────────── + +TEST_CASE("SessionLog: append/entries/flush behave like InMemoryActionLog", "[action_log][journal]") { + SessionLog log; + log.append(makeEntry("M", "e1", "A")); + log.append(makeEntry("M", "e2", "A")); + log.flush(); + REQUIRE(log.entries().size() == 2); + REQUIRE(log.entries("e1").size() == 1); + REQUIRE(log.entries("nope").empty()); +} + +TEST_CASE("SessionLog::checkpoint: coalesces by (modelType, entityKey, actionType), keeps distinct actions", + "[action_log][journal]") { + morph::model::detail::ActionDispatcher dispatcher; + morph::model::detail::ModelRegistryFactory registry; + registry.registerModel("AL_Model"); + dispatcher.registerAction("AL_Model", "AL_Deposit"); + dispatcher.registerAction("AL_Model", "AL_SetNickname"); + + SessionLog session; + session.append(makeEntry("AL_Model", "acct-1", "AL_Deposit", "", "10")); + session.append(makeEntry("AL_Model", "acct-1", "AL_SetNickname", "", "bob")); + session.append(makeEntry("AL_Model", "acct-1", "AL_Deposit", "", "15")); + session.append(makeEntry("AL_Model", "acct-1", "AL_SetNickname", "", "bobby")); + + InMemoryActionLog durable; + session.checkpoint(durable, dispatcher); + + auto out = durable.entries(); + REQUIRE(out.size() == 3); // two Deposits kept distinct, two SetNickname coalesced into one + REQUIRE(out[0].actionType == "AL_Deposit"); + REQUIRE(out[0].result == "10"); + REQUIRE(out[1].actionType == "AL_SetNickname"); + REQUIRE(out[1].result == "bobby"); // latest occurrence, original (first-seen) position + REQUIRE(out[2].actionType == "AL_Deposit"); + REQUIRE(out[2].result == "15"); +} + +TEST_CASE("SessionLog::checkpoint: no-op when nothing new since the last checkpoint", "[action_log][journal]") { + morph::model::detail::ActionDispatcher dispatcher; + SessionLog session; + session.append(makeEntry("M", "e", "A")); + + InMemoryActionLog durable; + session.checkpoint(durable, dispatcher); + REQUIRE(durable.entries().size() == 1); + + session.checkpoint(durable, dispatcher); // nothing appended since — must not duplicate + REQUIRE(durable.entries().size() == 1); +} + +TEST_CASE("SessionLog::undoLast: replays the prefix, reconstructing pre-undo state", "[action_log][journal]") { + morph::model::detail::ActionDispatcher dispatcher; + morph::model::detail::ModelRegistryFactory registry; + registry.registerModel("AL_Model"); + dispatcher.registerAction("AL_Model", "AL_Deposit"); + + auto session = std::make_shared(); + auto holder = registry.create("AL_Model"); + holder->attachActionLog(session, "acct-undo"); + + auto deposit10 = morph::model::ActionTraits::toJson(ALDeposit{.amount = 10}); + auto deposit5 = morph::model::ActionTraits::toJson(ALDeposit{.amount = 5}); + dispatcher.dispatch("AL_Model", "AL_Deposit", *holder, deposit10); + dispatcher.dispatch("AL_Model", "AL_Deposit", *holder, deposit5); + REQUIRE(holder->into().balance == 15); + REQUIRE(session->entries().size() == 2); + + auto afterUndo1 = session->undoLast("AL_Model", registry, dispatcher); + REQUIRE(afterUndo1->into().balance == 10); + REQUIRE(session->entries().size() == 1); + + auto afterUndo2 = session->undoLast("AL_Model", registry, dispatcher); + REQUIRE(afterUndo2->into().balance == 0); + REQUIRE(session->entries().empty()); + + // Undo on an already-empty log is a no-op that still returns a fresh holder. + auto afterUndo3 = session->undoLast("AL_Model", registry, dispatcher); + REQUIRE(afterUndo3->into().balance == 0); +} + +TEST_CASE("SessionLog::undoLast: clamps the checkpoint position when undoing past it", + "[action_log][journal]") { + morph::model::detail::ActionDispatcher dispatcher; + morph::model::detail::ModelRegistryFactory registry; + registry.registerModel("AL_Model"); + dispatcher.registerAction("AL_Model", "AL_Deposit"); + + auto session = std::make_shared(); + auto holder = registry.create("AL_Model"); + holder->attachActionLog(session, "acct-clamp"); + + auto deposit1 = morph::model::ActionTraits::toJson(ALDeposit{.amount = 1}); + dispatcher.dispatch("AL_Model", "AL_Deposit", *holder, deposit1); + dispatcher.dispatch("AL_Model", "AL_Deposit", *holder, deposit1); + + InMemoryActionLog durable; + session->checkpoint(durable, dispatcher); // committedUpTo == 2 + REQUIRE(durable.entries().size() == 2); + + session->undoLast("AL_Model", registry, dispatcher); // pops entry #2 — committedUpTo must clamp to 1 + REQUIRE(session->entries().size() == 1); + + // A subsequent checkpoint must see nothing pending (checkpoint already + // covered the one remaining entry before the undo) rather than re-sending it. + session->checkpoint(durable, dispatcher); + REQUIRE(durable.entries().size() == 2); +} + +// ── setActionLog / defaultActionLog / ScopedActionLog ─────────────────────── +// +// morph_tests runs every TEST_CASE in one process, and the default action log +// is process-wide global state — every test here uses ScopedActionLog so its +// override never leaks into a test that runs after it, regardless of order. + +TEST_CASE("ScopedActionLog: installs and restores the default action log", "[action_log][default]") { + REQUIRE(morph::journal::defaultActionLog() == nullptr); + auto log = std::make_shared(); + { + morph::journal::ScopedActionLog guard{log}; + REQUIRE(morph::journal::defaultActionLog() == log); + } + REQUIRE(morph::journal::defaultActionLog() == nullptr); +} + +TEST_CASE("ScopedActionLog: nested scopes restore in the correct order", "[action_log][default]") { + auto outer = std::make_shared(); + auto inner = std::make_shared(); + morph::journal::ScopedActionLog outerGuard{outer}; + REQUIRE(morph::journal::defaultActionLog() == outer); + { + morph::journal::ScopedActionLog innerGuard{inner}; + REQUIRE(morph::journal::defaultActionLog() == inner); + } + REQUIRE(morph::journal::defaultActionLog() == outer); +} + +TEST_CASE("ModelFactory::create: auto-attaches the default action log when one is installed", + "[action_log][default]") { + auto log = std::make_shared(); + morph::journal::ScopedActionLog guard{log}; + + auto holder = morph::model::detail::ModelFactory::create(); + REQUIRE(holder->hasActionLog()); + + morph::model::detail::ActionDispatcher dispatcher; + dispatcher.registerAction("AL_Model", "AL_Deposit"); + auto depositJson = morph::model::ActionTraits::toJson(ALDeposit{.amount = 12}); + REQUIRE(dispatcher.dispatch("AL_Model", "AL_Deposit", *holder, depositJson) == "12"); + + auto entries = log->entries(); + REQUIRE(entries.size() == 1); + REQUIRE(entries[0].entityKey.empty()); // auto-attach uses an empty entityKey + REQUIRE(entries[0].actionType == "AL_Deposit"); +} + +TEST_CASE("ModelFactory::create: does not attach a log when no default is installed", "[action_log][default]") { + REQUIRE(morph::journal::defaultActionLog() == nullptr); + auto holder = morph::model::detail::ModelFactory::create(); + REQUIRE_FALSE(holder->hasActionLog()); +} + +TEST_CASE("IModelHolder::attachActionLog: an explicit call overrides the auto-attached default", + "[action_log][default]") { + auto defaultLog = std::make_shared(); + morph::journal::ScopedActionLog guard{defaultLog}; + + auto holder = morph::model::detail::ModelFactory::create(); // auto-attaches defaultLog + auto specificLog = std::make_shared(); + holder->attachActionLog(specificLog, "acct-override"); // explicit call wins + + morph::model::detail::ActionDispatcher dispatcher; + dispatcher.registerAction("AL_Model", "AL_Deposit"); + auto depositJson = morph::model::ActionTraits::toJson(ALDeposit{.amount = 3}); + dispatcher.dispatch("AL_Model", "AL_Deposit", *holder, depositJson); + + REQUIRE(defaultLog->entries().empty()); + auto entries = specificLog->entries(); + REQUIRE(entries.size() == 1); + REQUIRE(entries[0].entityKey == "acct-override"); +} + +TEST_CASE("ModelFactory::create: auto-attach also reaches server-created holders (ModelRegistryFactory)", + "[action_log][default]") { + auto log = std::make_shared(); + morph::journal::ScopedActionLog guard{log}; + + // ModelRegistryFactory::registerModel is exactly what RemoteServer + // uses to construct instances for every remote/simulated-remote client — + // it routes through the same ModelFactory::create(), so the global + // default reaches server-side instances with no contextKey/LogProvider + // wiring needed. + morph::model::detail::ModelRegistryFactory registry; + morph::model::detail::ActionDispatcher dispatcher; + registry.registerModel("AL_Model"); + dispatcher.registerAction("AL_Model", "AL_Deposit"); + + auto holder = registry.create("AL_Model"); + REQUIRE(holder->hasActionLog()); + auto depositJson = morph::model::ActionTraits::toJson(ALDeposit{.amount = 7}); + dispatcher.dispatch("AL_Model", "AL_Deposit", *holder, depositJson); + + REQUIRE(log->entries().size() == 1); +} diff --git a/tests/test_action_log_phase2.cpp b/tests/test_action_log_phase2.cpp new file mode 100644 index 0000000..dd6dfdd --- /dev/null +++ b/tests/test_action_log_phase2.cpp @@ -0,0 +1,391 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// Coverage for phase 2 of the ordered action log (issue #3): LogEntry JSON +// round-trip, FileActionLog, and the wire::Envelope::contextKey + +// RemoteServer::LogProvider mechanism that closes phase 1's "remote identity" +// gap. (Phase 3, a Kafka-shaped sink, was dropped for now.) + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +#include "test_support.hpp" + +using SyncExec = morph::testing::InlineExecutor; +using morph::journal::FileActionLog; +using morph::journal::IActionLog; +using morph::journal::InMemoryActionLog; +using morph::journal::LogEntry; + +namespace { +LogEntry makeEntry(std::string modelType, std::string entityKey, std::string actionType, std::string payload = {}, + std::string result = {}) { + return LogEntry{ + .seq = 0, + .modelType = std::move(modelType), + .entityKey = std::move(entityKey), + .actionType = std::move(actionType), + .payload = std::move(payload), + .result = std::move(result), + .principal = {}, + .timestampMs = 0, + }; +} + +/// RAII temp-file path: unique per test, removed on scope exit even on failure. +struct TempFile { + std::filesystem::path path; + explicit TempFile(std::string_view name) + : path{std::filesystem::temp_directory_path() / + (std::string{"morph_test_"} + std::string{name} + "_" + + std::to_string(reinterpret_cast(this)) + ".ndjson")} { + std::filesystem::remove(path); + } + ~TempFile() { std::filesystem::remove(path); } + TempFile(const TempFile&) = delete; + TempFile& operator=(const TempFile&) = delete; +}; +} // namespace + +// ── Phase 2 test model (separate from test_action_log.cpp's ALModel to avoid +// global-registry collisions across translation units) ───────────────────── + +struct P2Deposit { + int amount = 0; +}; +struct P2GetBalance {}; +struct P2Save {}; // signals "commit now" — the app decides what that means, not the framework + +struct P2Model { + int balance = 0; + int execute(const P2Deposit& a) { + balance += a.amount; + return balance; + } + int execute(const P2GetBalance& /*a*/) { return balance; } + int execute(const P2Save& /*a*/) { return balance; } +}; + +BRIDGE_REGISTER_MODEL(P2Model, "P2_Model") +BRIDGE_REGISTER_ACTION(P2Model, P2Deposit, "P2_Deposit") +BRIDGE_REGISTER_ACTION(P2Model, P2GetBalance, "P2_GetBalance", ::morph::model::Loggable::No) +BRIDGE_REGISTER_ACTION(P2Model, P2Save, "P2_Save", ::morph::model::Loggable::No) + +// ── LogEntry JSON round-trip ───────────────────────────────────────────────── + +TEST_CASE("journal::toJson/fromJson: round-trips every field", "[action_log][phase2][json]") { + auto entry = makeEntry("P2_Model", "acct-1", "P2_Deposit", "{\"amount\":5}", "5"); + entry.seq = 7; + entry.principal = "alice"; + entry.timestampMs = 123456789; + + auto json = morph::journal::toJson(entry); + auto decoded = morph::journal::fromJson(json); + + REQUIRE(decoded.seq == 7); + REQUIRE(decoded.modelType == "P2_Model"); + REQUIRE(decoded.entityKey == "acct-1"); + REQUIRE(decoded.actionType == "P2_Deposit"); + REQUIRE(decoded.payload == "{\"amount\":5}"); + REQUIRE(decoded.result == "5"); + REQUIRE(decoded.principal == "alice"); + REQUIRE(decoded.timestampMs == 123456789); +} + +TEST_CASE("journal::fromJson: throws SerializationError on malformed input", "[action_log][phase2][json]") { + REQUIRE_THROWS_AS(morph::journal::fromJson("not json"), morph::journal::SerializationError); +} + +// ── FileActionLog ──────────────────────────────────────────────────────────── + +TEST_CASE("FileActionLog: append+flush persists entries, entries() reads them back", "[action_log][phase2][file]") { + TempFile tmp{"file_basic"}; + { + FileActionLog log{tmp.path}; + log.append(makeEntry("P2_Model", "acct-1", "P2_Deposit", "{}", "10")); + log.append(makeEntry("P2_Model", "acct-2", "P2_Deposit", "{}", "20")); + log.flush(); + + auto all = log.entries(); + REQUIRE(all.size() == 2); + REQUIRE(all[0].entityKey == "acct-1"); + REQUIRE(all[0].seq == 1); + REQUIRE(all[1].entityKey == "acct-2"); + REQUIRE(all[1].seq == 2); + + REQUIRE(log.entries("acct-1").size() == 1); + REQUIRE(log.entries("no-such").empty()); + } + + // Survives the FileActionLog object being destroyed and a fresh one opened + // over the same path — this is what makes it "durable" rather than in-memory. + FileActionLog reopened{tmp.path}; + REQUIRE(reopened.entries().size() == 2); +} + +TEST_CASE("FileActionLog: throws if the path cannot be opened", "[action_log][phase2][file]") { + REQUIRE_THROWS_AS(FileActionLog(std::filesystem::path{"/no/such/directory/at/all/log.ndjson"}), + std::runtime_error); +} + +TEST_CASE("FileActionLog: entries() skips blank lines", "[action_log][phase2][file]") { + TempFile tmp{"file_blank_line"}; + { + FileActionLog log{tmp.path}; + log.append(makeEntry("P2_Model", "acct-1", "P2_Deposit", "{}", "1")); + log.flush(); + } + // A blank line can't be produced by FileActionLog itself (every write ends + // in exactly one '\n'), but a hand-edited or externally-appended file could + // have one — entries() must skip it rather than fail decoding it as JSON. + { + std::ofstream raw{tmp.path, std::ios::app}; + raw << "\n"; + } + { + FileActionLog log{tmp.path}; + log.append(makeEntry("P2_Model", "acct-2", "P2_Deposit", "{}", "2")); + log.flush(); + + auto all = log.entries(); + REQUIRE(all.size() == 2); + REQUIRE(all[0].entityKey == "acct-1"); + REQUIRE(all[1].entityKey == "acct-2"); + } +} + +// ── Save action end-to-end: SessionLog + FileActionLog, the pattern the design +// doc asked for ("wire sessionLog.checkpoint(sink) into a real Save action's +// completion handler") ────────────────────────────────────────────────────── + +TEST_CASE("Save action end-to-end: intermediate actions stay in-memory, Save checkpoints to a real file, " + "replay from disk reproduces state", + "[action_log][phase2][integration]") { + TempFile tmp{"save_e2e"}; + morph::exec::ThreadPoolExecutor pool{2}; + SyncExec cbExec; + morph::bridge::Bridge bridge{std::make_unique(pool)}; + + auto sessionLog = std::make_shared(); + auto fileLog = std::make_shared(tmp.path); + + auto binding = std::make_shared(); + binding->typeId = "P2_Model"; + binding->modelFactory = [sessionLog] { + auto holder = morph::model::detail::ModelFactory::create(); + holder->attachActionLog(sessionLog, "acct-save-1"); + return holder; + }; + morph::bridge::BridgeHandler handler{bridge, &cbExec, binding}; + + for (int amount : {10, 20, 30}) { + std::atomic done{false}; + handler.execute(P2Deposit{.amount = amount}) + .then([&](int) { done.store(true); }) + .onError([](const std::exception_ptr&) {}); + REQUIRE(morph::testing::waitUntil([&] { return done.load(); })); + } + REQUIRE(sessionLog->entries().size() == 3); + REQUIRE(fileLog->entries().empty()); // nothing durable yet — no checkpoint has run + + // The app decides what "Save" means; the framework never guesses. Here, + // the Save action's own completion is where checkpointing happens. + std::atomic saved{false}; + handler.execute(P2Save{}) + .then([&](int) { + sessionLog->checkpoint(*fileLog); + saved.store(true); + }) + .onError([](const std::exception_ptr&) {}); + REQUIRE(morph::testing::waitUntil([&] { return saved.load(); })); + + auto onDisk = fileLog->entries(); + REQUIRE(onDisk.size() == 3); // three Deposits, all distinct (coalesce==false default) + for (const auto& entry : onDisk) { + REQUIRE(entry.entityKey == "acct-save-1"); + REQUIRE(entry.actionType == "P2_Deposit"); + } + + // Reconstructing state purely from what's on disk reproduces the live model. + morph::model::detail::ModelRegistryFactory registry; + morph::model::detail::ActionDispatcher dispatcher; + registry.registerModel("P2_Model"); + dispatcher.registerAction("P2_Model", "P2_Deposit"); + auto reconstructed = morph::journal::replay("P2_Model", onDisk, registry, dispatcher); + REQUIRE(reconstructed->into().balance == 60); +} + +// ── wire::Envelope::contextKey ─────────────────────────────────────────────── + +TEST_CASE("wire::makeRegister: contextKey round-trips through encode/decode", "[action_log][phase2][wire]") { + auto env = morph::wire::makeRegister("P2_Model", "acct-42"); + REQUIRE(env.contextKey == "acct-42"); + auto decoded = morph::wire::decode(morph::wire::encode(env)); + REQUIRE(decoded.contextKey == "acct-42"); + REQUIRE(decoded.typeId == "P2_Model"); +} + +TEST_CASE("wire::makeRegister: contextKey defaults to empty", "[action_log][phase2][wire]") { + auto env = morph::wire::makeRegister("P2_Model"); + REQUIRE(env.contextKey.empty()); +} + +// ── RemoteServer::setLogProvider — closes phase 1's remote-identity gap ───── + +TEST_CASE("RemoteServer::setLogProvider: attaches a log to the server-created holder", "[action_log][phase2][remote]") { + morph::exec::ThreadPoolExecutor pool{2}; + morph::model::detail::ModelRegistryFactory registry; + morph::model::detail::ActionDispatcher dispatcher; + registry.registerModel("P2_Model"); + dispatcher.registerAction("P2_Model", "P2_Deposit"); + + auto server = std::make_shared(pool, dispatcher, registry); + + std::vector requestedFor; + auto log = std::make_shared(); + server->setLogProvider([&](std::string_view modelType, std::string_view contextKey) { + requestedFor.emplace_back(std::string{modelType} + ":" + std::string{contextKey}); + return log; + }); + + auto regReply = + morph::wire::decode(server->handleInline(morph::wire::encode(morph::wire::makeRegister("P2_Model", "acct-9")))); + REQUIRE(regReply.kind == "ok"); + REQUIRE(requestedFor == std::vector{"P2_Model:acct-9"}); + + morph::wire::Envelope exec; + exec.kind = "execute"; + exec.modelId = regReply.modelId; + exec.modelType = "P2_Model"; + exec.actionType = "P2_Deposit"; + exec.body = morph::model::ActionTraits::toJson(P2Deposit{.amount = 15}); + morph::testing::WaitReply waiter; + server->handle(morph::wire::encode(exec), std::ref(waiter)); + REQUIRE(waiter.await()); + REQUIRE(waiter.env.kind == "ok"); + + auto entries = log->entries(); + REQUIRE(entries.size() == 1); + REQUIRE(entries[0].entityKey == "acct-9"); + REQUIRE(entries[0].actionType == "P2_Deposit"); +} + +TEST_CASE("RemoteServer::setLogProvider: not consulted when contextKey is empty", "[action_log][phase2][remote]") { + morph::exec::ThreadPoolExecutor pool{2}; + morph::model::detail::ModelRegistryFactory registry; + morph::model::detail::ActionDispatcher dispatcher; + registry.registerModel("P2_Model"); + + auto server = std::make_shared(pool, dispatcher, registry); + bool providerCalled = false; + server->setLogProvider([&](std::string_view, std::string_view) { + providerCalled = true; + return std::make_shared(); + }); + + auto reply = morph::wire::decode(server->handleInline(morph::wire::encode(morph::wire::makeRegister("P2_Model")))); + REQUIRE(reply.kind == "ok"); + REQUIRE_FALSE(providerCalled); +} + +TEST_CASE("RemoteServer::setLogProvider: a provider returning nullptr attaches no log", "[action_log][phase2][remote]") { + morph::exec::ThreadPoolExecutor pool{2}; + morph::model::detail::ModelRegistryFactory registry; + morph::model::detail::ActionDispatcher dispatcher; + registry.registerModel("P2_Model"); + dispatcher.registerAction("P2_Model", "P2_Deposit"); + + auto server = std::make_shared(pool, dispatcher, registry); + server->setLogProvider([](std::string_view, std::string_view) { return nullptr; }); + + auto regReply = + morph::wire::decode(server->handleInline(morph::wire::encode(morph::wire::makeRegister("P2_Model", "acct-x")))); + REQUIRE(regReply.kind == "ok"); + + morph::wire::Envelope exec; + exec.kind = "execute"; + exec.modelId = regReply.modelId; + exec.modelType = "P2_Model"; + exec.actionType = "P2_Deposit"; + exec.body = morph::model::ActionTraits::toJson(P2Deposit{.amount = 1}); + morph::testing::WaitReply waiter; + server->handle(morph::wire::encode(exec), std::ref(waiter)); + REQUIRE(waiter.await()); + REQUIRE(waiter.env.kind == "ok"); // executes fine even though no log got attached +} + +TEST_CASE("RemoteServer::setLogProvider: nullptr provider removes a previously installed one", + "[action_log][phase2][remote]") { + morph::exec::ThreadPoolExecutor pool{2}; + morph::model::detail::ModelRegistryFactory registry; + morph::model::detail::ActionDispatcher dispatcher; + registry.registerModel("P2_Model"); + auto server = std::make_shared(pool, dispatcher, registry); + + bool called = false; + server->setLogProvider([&](std::string_view, std::string_view) { + called = true; + return nullptr; + }); + server->setLogProvider(nullptr); + + auto reply = + morph::wire::decode(server->handleInline(morph::wire::encode(morph::wire::makeRegister("P2_Model", "acct-y")))); + REQUIRE(reply.kind == "ok"); + REQUIRE_FALSE(called); +} + +// ── End-to-end: Bridge + SimulatedRemoteBackend + contextKey + LogProvider ── +// +// This is the scenario phase 1 explicitly could not support: the client sets +// HandlerBinding::contextKey, SimulatedRemoteBackend carries it across the +// wire, and the server's LogProvider attaches a real log to the instance it +// creates — recording happens server-side, with a real per-account identity, +// for a genuinely remote-shaped topology. + +TEST_CASE("End-to-end: HandlerBinding::contextKey reaches the server's LogProvider via SimulatedRemoteBackend", + "[action_log][phase2][remote]") { + morph::exec::ThreadPoolExecutor pool{2}; + SyncExec cbExec; + morph::model::detail::ModelRegistryFactory registry; + morph::model::detail::ActionDispatcher dispatcher; + registry.registerModel("P2_Model"); + dispatcher.registerAction("P2_Model", "P2_Deposit"); + + auto server = std::make_shared(pool, dispatcher, registry); + auto log = std::make_shared(); + server->setLogProvider([&](std::string_view, std::string_view) { return log; }); + + morph::bridge::Bridge bridge{std::make_unique(*server)}; + + auto binding = std::make_shared(); + binding->typeId = "P2_Model"; + binding->contextKey = "acct-remote-1"; + binding->modelFactory = [] { return morph::model::detail::ModelFactory::create(); }; + morph::bridge::BridgeHandler handler{bridge, &cbExec, binding}; + + std::atomic result{-1}; + handler.execute(P2Deposit{.amount = 30}).then([&](int v) { result.store(v); }).onError([](const std::exception_ptr&) { + }); + REQUIRE(morph::testing::waitUntil([&] { return result.load() != -1; })); + REQUIRE(result.load() == 30); + + auto entries = log->entries(); + REQUIRE(entries.size() == 1); + REQUIRE(entries[0].entityKey == "acct-remote-1"); + REQUIRE(entries[0].actionType == "P2_Deposit"); +}