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
1 change: 1 addition & 0 deletions docs/spec/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ behavioural differences between the two, collected in one table, are in

**When things go wrong**
[`error_handling.md`](error_handling.md) ·
[`core/post_commit_tail.md`](core/post_commit_tail.md) ·
[`core/logger.md`](core/logger.md) ·
[`core/observability.md`](core/observability.md)

Expand Down
154 changes: 154 additions & 0 deletions docs/spec/core/post_commit_tail.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
# `runPostCommitTail` — design

Design spec for `morph::model::runPostCommitTail` (`include/morph/core/model.hpp`):
the seam between an action handler's commit and the work that follows it.

Read this before writing a mutating `execute()` that does anything after its
commit — journalling to a model-owned log, a rule cascade, rebuilding cached
state.

## Contents

- [The problem](#the-problem)
- [API surface](#api-surface)
- [Where the tail starts](#where-the-tail-starts)
- [Why a utility, not a dispatch-layer hook](#why-a-utility-not-a-dispatch-layer-hook)
- [Relation to the framework's own recording](#relation-to-the-frameworks-own-recording)
- [Out of scope](#out-of-scope)
- [Cross-references](#cross-references)

## The problem

A mutating handler validates, opens a transaction, mutates, **commits**, and
then often keeps working: it appends to an action log it owns, fires rules the
write triggered, or re-reads state for its return value. Everything after the
commit can still throw — a contended `SQLITE_BUSY` past the busy timeout, a
journal sink whose `append` refuses the entry (which
[`IActionLog::append`](../journal/journal.md)'s contract *requires* it to signal
by throwing).

If that exception leaves `execute()`, the caller is told the action failed while
the write it made stands. A client that believes it will retry, and the retry
applies the mutation twice. That is the defect this helper closes: **a call must
not report failure for a mutation that already happened.**

## API surface

```cpp
namespace morph::model {

template <typename Tail>
requires std::invocable<Tail> && std::is_void_v<std::invoke_result_t<Tail>>
void runPostCommitTail(Tail&& tail, std::string_view what) noexcept;

template <typename Result, typename Tail>
requires std::invocable<Tail> && std::convertible_to<std::invoke_result_t<Tail>, Result>
[[nodiscard]] Result runPostCommitTail(Tail&& tail, Result committed, std::string_view what)
noexcept(std::is_nothrow_move_constructible_v<Result>);

}
```

Both overloads run `tail` once. If it throws, the exception is caught — a
`std::exception` and anything else alike — and reported through
`morph::log::logError`:

```
<what> committed, but its post-commit tail failed: <exception what()>
<what> committed, but its post-commit tail threw a non-std::exception
```

Nothing is rethrown. `what` names the handler; by convention it carries the
model's tag too (`"[kanban::BoardModel] CreateColumn"`), because the log line is
the only place the failure surfaces.

- **The `void` overload** is for a tail whose result the caller does not need —
the common case, where the tail is journalling alone and the handler's return
value was computed before the commit. It is `noexcept`: `logError`'s
formatting overload is itself `noexcept`, so nothing on the path can escape.
- **The value-returning overload** is for a handler whose return value is
*refreshed* by the tail — a board state rebuilt after a rule cascade the move
fired. `committed` is the truthful answer the handler already holds from
before the tail began; if the tail throws, that is what is returned. `Result`
is deduced from `committed` alone, so the caller receives the handler's own
result type and the tail may yield anything convertible to it. It is
`noexcept` whenever returning `committed` cannot throw.

The `void` overload rejects a value-returning tail at compile time rather than
discarding its value, because a tail that computes something is almost always a
tail whose value the caller was meant to get.

## Where the tail starts

Only work *after* the commit goes through the helper. An exception from before
the commit still means the mutation did not happen, and it must still reach the
caller: wrapping it would report success for a write that was rolled back.

Anything the caller's **return value** depends on is best computed *before* the
commit, inside the transaction. A re-read that fails there rolls the write back,
so the caller's "this failed" is true. A re-read that fails after the commit
leaves nothing truthful to return unless the handler already holds an answer —
which is exactly the value-returning overload's precondition. The cost of
reading inside the transaction is that the write lock is held for the length of
the read; for SQLite under contention that is measurable, and the handler that
already needs the state inside the transaction (for an idempotency ledger row,
say) pays nothing extra.

## Why a utility, not a dispatch-layer hook

The seam is a function the handler calls on itself, at the point in its own
body where it knows the commit succeeded. The alternatives — a result type
carrying a deferred continuation, an `ActionTraits` after-commit hook the
dispatcher calls, a journal decorator that swallows `append` failures — all add
an extension point the framework calls into after `execute()` returns. None is
needed for this, and each costs more:

- A hook called after `execute()` returns runs on a different frame, so it
cannot see what the handler computed inside the transaction unless that
travels in the result. A rule cascade built from rows inserted in the same
transaction is an ordinary local variable to a tail closure.
- A continuation-carrying result type would change every `execute()` signature
and have to be hidden from `resultToJson`, the wire and `journal::replay`.
- A swallowing journal decorator covers only journalling, and makes "the entry
is recorded" no longer a promise the outbox relay can rely on.

It also keeps a model independent of the dispatch layer: there is no new
model-to-dispatcher contract, only a utility in the same category as
`morph::log::logError`, and a directly constructed model gets the same
guarantee as a registered one.

The one shape this does not express is post-commit work that must run in a
**different execution context** from the one `execute()` returns on — genuinely
asynchronous follow-on work. The tail runs synchronously, before `execute()`
returns. A handler that needs the other shape needs one of the dispatch-level
designs above, alongside this helper rather than instead of it.

## Relation to the framework's own recording

For a registered model, the dispatch layer appends the action's own
`Succeeded` entry after `execute()` returns, and a sink failure there surfaces
as `morph::model::ActionRecordingError` — the caller is told the write happened
and its record did not (journal.md, "A refused recording is not an execution
failure"). That path has a channel back to the caller, because the dispatcher
owns the reply.

A handler's own tail has no such channel without changing its result type, so
the helper's channel is the error log. The two are complementary: the framework
covers the entry it writes, the helper covers the work a handler does itself.

## Out of scope

- **Failures before the commit.** A non-domain exception thrown before the
commit is the action's failure, and a registered model's dispatch site records
it `Outcome::Failed` already. The helper deliberately does not touch that
path.
- **Retrying the tail.** A failed tail is logged, not retried. Whether a missed
journal entry or cascade should be recovered is the application's decision.

## Cross-references

| Spec | Why |
|---|---|
| [registry.md](registry.md) | `ActionDispatcher`, `IModelHolder` and the dispatch sites whose recording this complements. |
| [../journal/journal.md](../journal/journal.md) | `IActionLog::append`'s throwing contract; `ActionRecordingError`. |
| [logger.md](logger.md) | `morph::log::logError`, the helper's reporting channel. |
20 changes: 19 additions & 1 deletion docs/spec/forms/forms.md
Original file line number Diff line number Diff line change
Expand Up @@ -1259,12 +1259,30 @@ such a member as a `oneOf` of `const` alternatives, each carrying its own
This is standard JSON-Schema vocabulary, not an `x-*` extension: no morph key
declares it and none is needed. A renderer recognises the shape by the property
holding a `oneOf`/`anyOf` in which **every** branch bar `{"type": "null"}`
carries a `const`. One branch without a `const` and it is not a closed set —
carries a `const` (or is itself such a set, for an optional member — below).
One branch that does neither and it is not a closed set —
that is the nullability shape above, and a partial list would be worse than no
list at all. The bare JSON-Schema `enum` keyword (`{"enum": ["a", "b"]}`), which
glaze does not emit but a hand-written schema may, states the same thing and is
read the same way.

An **optional** enum member (`std::optional<E>`) reaches its set one level
down. glaze emits it as a nullable `anyOf` whose non-null branch is a `$ref` to
the enum's definition, and that definition is the `oneOf` of `const`s above:

```json
"grade": {"anyOf": [{"$ref": "#/$defs/Grade"}, {"type": "null"}]},
"$defs": {"Grade": {"type": "string",
"oneOf": [{"title": "Low", "const": "Low"},
{"title": "High", "const": "High"}]}}
```

A non-null branch that, after following its `$ref`, is itself a
`oneOf`/`anyOf` of `const` alternatives therefore counts as those alternatives:
the member is the same closed set, and it is optional. Exactly one level is
read this way. A set nested deeper is not a shape any generator produces, and it
stays "not a closed set" rather than being guessed at.

Two obligations follow, and `DynamicForm.qml` meets both:

- **Draw a selection control**, not a text field. The alternatives' `title`s are
Expand Down
2 changes: 1 addition & 1 deletion examples/kanban/include/kanban/models/board_model.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -393,7 +393,7 @@ class BoardModel {
/// *replace* the failure being reported with the failure to
/// report it: a less diagnosable exception, and on a destructor
/// path a `std::terminate`. So it is contained here, the same way
/// `runPostCommitTail` contains the mirror case, and
/// `morph::model::runPostCommitTail` contains the mirror case, and
/// the exception the caller sees is always the original one.
///
/// **Precondition:** an exception is being handled. This is a
Expand Down
Loading
Loading