Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
52 changes: 45 additions & 7 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>` |
| `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<M>` |
| `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<backend::detail::IBackend>`), but callers never type a detail name directly — `std::make_unique<morph::backend::LocalBackend>(...)` converts implicitly.
Expand Down Expand Up @@ -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<T>` (detail) + `Completion<T>` (public) — result handle |
| `model.hpp` | `IModelHolder`, `ModelHolder<T>`, `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<M>` (public) + `HandlerBinding`, `MemberPointerTraits` (detail) |
| `model.hpp` | `IModelHolder`, `ModelHolder<T>`, `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<A>()` (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<M>` (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 |
Expand Down Expand Up @@ -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 |
Expand All @@ -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
Expand Down Expand Up @@ -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<Model>()` — 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<A>()` defaults to `Yes` when the member is absent, via a `HasLoggableFlag` concept exactly like `ActionValidator`'s `HasValidate`.

**`ActionLogPolicy<A>::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.).
Expand Down
4 changes: 2 additions & 2 deletions examples/bank/include/bank/models/account_model.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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")
2 changes: 1 addition & 1 deletion examples/bank/include/bank/models/auth_model.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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)
4 changes: 2 additions & 2 deletions examples/bank/include/bank/models/budget_model.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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)
2 changes: 1 addition & 1 deletion examples/bank/include/bank/models/card_model.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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)
6 changes: 3 additions & 3 deletions examples/bank/include/bank/models/loan_model.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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)
2 changes: 1 addition & 1 deletion examples/bank/include/bank/models/notification_model.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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")
2 changes: 1 addition & 1 deletion examples/bank/include/bank/models/payee_model.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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)
2 changes: 1 addition & 1 deletion examples/bank/include/bank/models/payment_model.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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)
2 changes: 1 addition & 1 deletion examples/bank/include/bank/models/statement_model.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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)
7 changes: 6 additions & 1 deletion examples/bank/include/bank/models/transaction_model.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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 {

Expand Down Expand Up @@ -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)
Loading
Loading