Client-side decision-consistent reads: fold a read model, carry forward the exact log
position it was folded at, and let Chronicle.Transactions.UnitOfWork guard a subsequent
append with it — the Dynamic Consistency Boundary (DCB) pattern.
Chronicle.decide(fn _unit_of_work ->
{:ok, %{instance: seat}} = Chronicle.ReadModels.DecisionReads.get(MyApp.ReadModels.Seat, "seat-5")
if seat == nil do
Chronicle.EventSequences.EventLog.append("seat-5", %SeatBooked{by: "alice"})
end
end)If another process books the same seat between the read and the commit, the kernel rejects
the whole batch and Chronicle.decide/2 returns {:error, %Chronicle.ReadModels.DecisionConflict{}}
instead of appending anything.
Call get/2 (or its stream variant, get/4) from inside a unit of work — it enrolls the read
as a decision dependency automatically. Call get_detached/2 (or get_detached/4) to fold the
same way without an ambient unit of work, when you only need the guarded read's current value
and are not about to commit anything against it.
What can be guarded
Not every read model shape can be protected: admit/2 and admit_for_stream/2 run the same
checks get/2/get_detached/2 do, without any I/O, so a caller can check eligibility ahead of
time. See Chronicle.ReadModels.DecisionReadRefused for every refusal reason.
Passive reducers (Elixir-only)
The .NET client refuses every reducer-backed read model outright. A read model
produced by a passive reducer (use Chronicle.Reducers.Reducer, active: false) can be
decision-read here instead: because a passive reducer is never kept warm by an observer, its
current value is always folded fresh from Chronicle.EventSequences.EventLog.get_for_event_source/2,
which makes it trivial to capture the exact boundary the fold stopped at. An active
reducer is still refused with :reducer — only the client that asked for the fold knows it
reached a consistent point, and an active reducer never asks.
Summary
Functions
Checks, without any I/O, whether model_module can back a model-keyed decision read.
Checks, without any I/O, whether model_module can back a stream-scoped decision read.
Folds model_module at key and enrolls the read as a decision dependency of the current
unit of work, so Chronicle.Transactions.UnitOfWork.commit/1 (and Chronicle.decide/2)
guards the eventual append against anything else changing it first.
Stream-scoped variant of get/3. See get_detached/5 for the stream-scoping rules.
Folds model_module at key in a fresh, guarded session, independently of any unit of work.
Folds model_module guarding one event stream directly, independently of the model's own key.
Types
@type refusal_reason() :: Chronicle.ReadModels.DecisionReadRefused.reason()
Functions
@spec admit(module(), keyword()) :: {:ok, [module()]} | {:error, refusal_reason()}
Checks, without any I/O, whether model_module can back a model-keyed decision read.
Returns {:ok, event_types} when admitted, or {:error, reason} — see
Chronicle.ReadModels.DecisionReadRefused for every reason.
@spec admit_for_stream(module(), keyword()) :: {:ok, [module()]} | {:error, refusal_reason()}
Checks, without any I/O, whether model_module can back a stream-scoped decision read.
Elixir admits the same shapes for a stream-scoped read as for a model-keyed one; unlike the .NET client, it does not additionally admit a projection keyed by the event stream id itself.
@spec get(module(), String.t(), keyword()) :: {:ok, Chronicle.ReadModels.DecisionRead.t()} | {:error, :requires_unit_of_work | term()}
Folds model_module at key and enrolls the read as a decision dependency of the current
unit of work, so Chronicle.Transactions.UnitOfWork.commit/1 (and Chronicle.decide/2)
guards the eventual append against anything else changing it first.
Returns {:error, :requires_unit_of_work} when called outside Chronicle.decide/2 or
Chronicle.Transactions.UnitOfWork.with_unit_of_work/2. Raises
Chronicle.ReadModels.DecisionReadRefused when the model's shape cannot be guarded.
@spec get(module(), String.t(), String.t(), String.t(), keyword()) :: {:ok, Chronicle.ReadModels.DecisionRead.t()} | {:error, :requires_unit_of_work | term()}
Stream-scoped variant of get/3. See get_detached/5 for the stream-scoping rules.
@spec get_detached(module(), String.t(), keyword()) :: {:ok, Chronicle.ReadModels.DecisionRead.t()} | {:error, term()}
Folds model_module at key in a fresh, guarded session, independently of any unit of work.
Returns {:ok, %Chronicle.ReadModels.DecisionRead{}}, or {:error, reason} for a transport
failure. Raises Chronicle.ReadModels.DecisionReadRefused when the model's shape cannot be
guarded at all.
Options
@spec get_detached(module(), String.t(), String.t(), String.t(), keyword()) :: {:ok, Chronicle.ReadModels.DecisionRead.t()} | {:error, term()}
Folds model_module guarding one event stream directly, independently of the model's own key.
event_source_id is used both as the model's lookup key and as the target of the guard.
Pass :event_source_type in opts to additionally narrow the event source type.
Refused with :all_streams_not_supported when event_stream_type is empty or "All" — a
stream-scoped guard needs a specific stream.