Current section
Files
Jump to
Current section
Files
src/factos.erl
-module(factos).
-compile([no_auto_import, nowarn_unused_vars, nowarn_unused_function, nowarn_nomatch, inline]).
-define(FILEPATH, "src/factos.gleam").
-export([event_type/1, event_type_name/1, tag/1, tag_value/1, empty_metadata/0, metadata/1, metadata_entries/1, 'query'/1, query_item/2, decider/3, view/2, reactor/1, compute_events/3, compute_state/3, evolve_recorded/3, project/2, project_from/3, react/2, react_all/2, merge_reactors/2, merge_views/2, decide_context/3, matches_query/2, highest_position/2]).
-export_type([event_type/0, tag/0, metadata/0, 'query'/0, query_item/0, sequence_position/0, append_condition/0, decider/4, view/2, reactor/2, revision/0, decoded/1, recorded/1, context/2, loaded_stream/2]).
-if(?OTP_RELEASE >= 27).
-define(MODULEDOC(Str), -moduledoc(Str)).
-define(DOC(Str), -doc(Str)).
-else.
-define(MODULEDOC(Str), -compile([])).
-define(DOC(Str), -compile([])).
-endif.
?MODULEDOC(
" Store-independent event-sourcing domain primitives.\n"
"\n"
" Factos keeps the domain model in the application. The core module models\n"
" facts, command contexts, pure decision components, and pure views. Concrete\n"
" storage concerns live in backend packages such as `factos_sqlight` and\n"
" `factos_kurrentdb_erlang`.\n"
"\n"
" This package follows a context-first reading of Event Sourcing: accepted\n"
" facts are the authoritative state of the system, and the facts relevant to a\n"
" command are considered before new facts are accepted. Aggregates, stream-per-\n"
" object storage, CQRS, projections, and message brokers are implementation\n"
" choices rather than prerequisites.\n"
"\n"
" The central flow is:\n"
"\n"
" 1. Select a command context with `Query`.\n"
" 2. Fold the matching recorded events into a temporary decision state.\n"
" 3. Run a pure `Decider`.\n"
" 4. Append the produced facts only if the context is still stable.\n"
"\n"
" Backends implement the storage-specific parts of that flow. This module keeps\n"
" the shared types and pure computations small and portable.\n"
).
-opaque event_type() :: {event_type, binary()}.
-opaque tag() :: {tag, binary()}.
-opaque metadata() :: {metadata, list({binary(), binary()})}.
-type 'query'() :: all_events | {'query', list(query_item())}.
-type query_item() :: {query_item, list(event_type()), list(tag())}.
-type sequence_position() :: no_position | {sequence_position, integer()}.
-type append_condition() :: no_append_condition |
{fail_if_events_match, 'query'(), sequence_position()}.
-type decider(DKT, DKU, DKV, DKW) :: {decider,
DKU,
fun((DKU, DKT) -> {ok, list(DKV)} | {error, DKW}),
fun((DKU, DKV) -> DKU)}.
-type view(DKX, DKY) :: {view, DKX, fun((DKX, DKY) -> DKX)}.
-type reactor(DKZ, DLA) :: {reactor, fun((recorded(DKZ)) -> list(DLA))}.
-type revision() :: no_events | {current_revision, integer()}.
-type decoded(DLB) :: {decoded,
DLB,
event_type(),
integer(),
list(tag()),
metadata()}.
-type recorded(DLC) :: {recorded,
binary(),
binary(),
integer(),
sequence_position(),
event_type(),
integer(),
list(tag()),
metadata(),
DLC}.
-type context(DLD, DLE) :: {context,
'query'(),
DLE,
list(recorded(DLD)),
sequence_position(),
append_condition()}.
-type loaded_stream(DLF, DLG) :: {loaded_stream,
binary(),
DLG,
list(recorded(DLF)),
revision()}.
-file("src/factos.gleam", 207).
?DOC(" Wrap an event type name.\n").
-spec event_type(binary()) -> event_type().
event_type(Name) ->
{event_type, Name}.
-file("src/factos.gleam", 212).
?DOC(" Unwrap an event type name.\n").
-spec event_type_name(event_type()) -> binary().
event_type_name(Event_type) ->
{event_type, Name} = Event_type,
Name.
-file("src/factos.gleam", 218).
?DOC(" Wrap a tag value.\n").
-spec tag(binary()) -> tag().
tag(Value) ->
{tag, Value}.
-file("src/factos.gleam", 223).
?DOC(" Unwrap a tag value.\n").
-spec tag_value(tag()) -> binary().
tag_value(Tag) ->
{tag, Value} = Tag,
Value.
-file("src/factos.gleam", 229).
?DOC(" No event metadata.\n").
-spec empty_metadata() -> metadata().
empty_metadata() ->
{metadata, []}.
-file("src/factos.gleam", 234).
?DOC(" Build event metadata from key/value pairs.\n").
-spec metadata(list({binary(), binary()})) -> metadata().
metadata(Entries) ->
{metadata, Entries}.
-file("src/factos.gleam", 239).
?DOC(" Unwrap event metadata entries.\n").
-spec metadata_entries(metadata()) -> list({binary(), binary()}).
metadata_entries(Metadata) ->
{metadata, Entries} = Metadata,
Entries.
-file("src/factos.gleam", 248).
?DOC(
" Build a query from query items.\n"
"\n"
" An empty list becomes `AllEvents`; otherwise the query contains the supplied\n"
" items. Query items are OR-combined by `matches_query`.\n"
).
-spec 'query'(list(query_item())) -> 'query'().
'query'(Items) ->
case Items of
[] ->
all_events;
[_ | _] ->
{'query', Items}
end.
-file("src/factos.gleam", 259).
?DOC(
" Build one command-context query branch.\n"
"\n"
" Event types are OR-combined. Tags are AND-combined. Empty lists act as wildcards\n"
" for that part of the item.\n"
).
-spec query_item(list(event_type()), list(tag())) -> query_item().
query_item(Types, Tags) ->
{query_item, Types, Tags}.
-file("src/factos.gleam", 271).
?DOC(
" Build a pure command-side decider.\n"
"\n"
" The supplied functions remain owned by the application domain. Factos only\n"
" stores them together so backends and tests can run the same read-decide-append\n"
" flow consistently.\n"
).
-spec decider(
DLM,
fun((DLM, DLN) -> {ok, list(DLO)} | {error, DLQ}),
fun((DLM, DLO) -> DLM)
) -> decider(DLN, DLM, DLO, DLQ).
decider(Initial, Decide, Evolve) ->
{decider, Initial, Decide, Evolve}.
-file("src/factos.gleam", 283).
?DOC(
" Build a pure projection view.\n"
"\n"
" A view folds events into read-side state. It does not prescribe where that\n"
" state is stored or how events are delivered.\n"
).
-spec view(DLX, fun((DLX, DLY) -> DLX)) -> view(DLX, DLY).
view(Initial, Evolve) ->
{view, Initial, Evolve}.
-file("src/factos.gleam", 295).
?DOC(
" Build a pure event reactor.\n"
"\n"
" Reactors are the side-effect planning counterpart to views: they consume\n"
" committed recorded events and return application-owned effect values without\n"
" executing IO.\n"
).
-spec reactor(fun((recorded(DMB)) -> list(DMD))) -> reactor(DMB, DMD).
reactor(React) ->
{reactor, React}.
-file("src/factos.gleam", 451).
-spec fold_events(DPN, list(DPO), fun((DPN, DPO) -> DPN)) -> DPN.
fold_events(Initial, Events, Evolve) ->
gleam@list:fold(
Events,
Initial,
fun(State, Event) -> Evolve(State, Event) end
).
-file("src/factos.gleam", 305).
?DOC(
" Fold events with a decider and decide which new events a command produces.\n"
"\n"
" This is useful for unit tests and for in-memory command handling. It does not\n"
" perform any append or consistency check.\n"
).
-spec compute_events(decider(DMH, any(), DMJ, DMK), list(DMJ), DMH) -> {ok,
list(DMJ)} |
{error, DMK}.
compute_events(Decider, Events, Command) ->
{decider, Initial, Decide, Evolve} = Decider,
Decide(fold_events(Initial, Events, Evolve), Command).
-file("src/factos.gleam", 318).
?DOC(
" Decide from an optional current state and return the state after produced events.\n"
"\n"
" If `current` is `None`, the decider's initial state is used. The function first\n"
" runs the decider, then folds the produced events into the decision state.\n"
).
-spec compute_state(
decider(DMT, DMU, any(), DMW),
gleam@option:option(DMU),
DMT
) -> {ok, DMU} | {error, DMW}.
compute_state(Decider, Current, Command) ->
{decider, Initial, Decide, Evolve} = Decider,
State@1 = case Current of
{some, State} ->
State;
none ->
Initial
end,
gleam@result:'try'(
Decide(State@1, Command),
fun(Events) -> {ok, fold_events(State@1, Events, Evolve)} end
).
-file("src/factos.gleam", 337).
?DOC(
" Fold recorded events into state using a domain evolution function.\n"
"\n"
" Backends use this after decoding stored events. Only the domain event payload is\n"
" passed to `evolve`; storage metadata is ignored for state computation.\n"
).
-spec evolve_recorded(DNE, list(recorded(DNF)), fun((DNE, DNF) -> DNE)) -> DNE.
evolve_recorded(Initial, Events, Evolve) ->
gleam@list:fold(
Events,
Initial,
fun(State, Recorded) -> Evolve(State, erlang:element(10, Recorded)) end
).
-file("src/factos.gleam", 347).
?DOC(" Project events from a view's initial state.\n").
-spec project(view(DNI, DNJ), list(DNJ)) -> DNI.
project(View, Events) ->
{view, Initial, Evolve} = View,
fold_events(Initial, Events, Evolve).
-file("src/factos.gleam", 356).
?DOC(" Project events starting from an already materialized view state.\n").
-spec project_from(view(DNN, DNO), DNN, list(DNO)) -> DNN.
project_from(View, State, Events) ->
{view, _, Evolve} = View,
fold_events(State, Events, Evolve).
-file("src/factos.gleam", 366).
?DOC(" Produce effect values for one committed recorded event.\n").
-spec react(reactor(DNS, DNT), recorded(DNS)) -> list(DNT).
react(Reactor, Event) ->
{reactor, React} = Reactor,
React(Event).
-file("src/factos.gleam", 375).
?DOC(" Produce effect values for committed recorded events, preserving event order.\n").
-spec react_all(reactor(DNY, DNZ), list(recorded(DNY))) -> list(DNZ).
react_all(Reactor, Events) ->
gleam@list:flat_map(Events, fun(Event) -> react(Reactor, Event) end).
-file("src/factos.gleam", 387).
?DOC(
" Merge two reactors that consume the same event type.\n"
"\n"
" The resulting reactor runs both reactors for each event and concatenates their\n"
" produced effects in argument order.\n"
).
-spec merge_reactors(reactor(DOF, DOG), reactor(DOF, DOG)) -> reactor(DOF, DOG).
merge_reactors(First, Second) ->
{reactor,
fun(Event) ->
lists:append(react(First, Event), react(Second, Event))
end}.
-file("src/factos.gleam", 399).
?DOC(
" Merge two views that consume the same event type.\n"
"\n"
" The resulting view keeps both states in a tuple and evolves both for every\n"
" event. This is a convenience for composing small pure projections.\n"
).
-spec merge_views(view(DON, DOO), view(DOR, DOO)) -> view({DON, DOR}, DOO).
merge_views(First, Second) ->
{view, First_initial, First_evolve} = First,
{view, Second_initial, Second_evolve} = Second,
{view,
{First_initial, Second_initial},
fun(State, Event) ->
{First_state, Second_state} = State,
{First_evolve(First_state, Event),
Second_evolve(Second_state, Event)}
end}.
-file("src/factos.gleam", 415).
?DOC(
" Run a command against a previously read context.\n"
"\n"
" The returned tuple preserves the original context alongside the newly produced\n"
" events so a backend can append them with `context.append_condition`.\n"
).
-spec decide_context(context(DOW, DOX), DPA, decider(DPA, DOX, DOW, DPB)) -> {ok,
{context(DOW, DOX), list(DOW)}} |
{error, DPB}.
decide_context(Context, Command, Decider) ->
gleam@result:'try'(
(erlang:element(3, Decider))(erlang:element(3, Context), Command),
fun(Events) -> {ok, {Context, Events}} end
).
-file("src/factos.gleam", 475).
-spec matches_tags(list(tag()), list(tag())) -> boolean().
matches_tags(Event_tags, Required_tags) ->
case Required_tags of
[] ->
true;
[_ | _] ->
gleam@list:all(
Required_tags,
fun(Required) ->
gleam@list:any(Event_tags, fun(Tag) -> Tag =:= Required end)
end
)
end.
-file("src/factos.gleam", 465).
-spec matches_types(event_type(), list(event_type())) -> boolean().
matches_types(Event_type, Types) ->
case Types of
[] ->
true;
[_ | _] ->
gleam@list:any(Types, fun(Required) -> Event_type =:= Required end)
end.
-file("src/factos.gleam", 460).
-spec matches_item(recorded(any()), query_item()) -> boolean().
matches_item(Recorded, Item) ->
{query_item, Types, Tags} = Item,
matches_types(erlang:element(6, Recorded), Types) andalso matches_tags(
erlang:element(8, Recorded),
Tags
).
-file("src/factos.gleam", 425).
?DOC(" Test whether a recorded event belongs to a query-defined context.\n").
-spec matches_query(recorded(any()), 'query'()) -> boolean().
matches_query(Recorded, Query) ->
case Query of
all_events ->
true;
{'query', Items} ->
gleam@list:any(
Items,
fun(_capture) -> matches_item(Recorded, _capture) end
)
end.
-file("src/factos.gleam", 436).
?DOC(
" Return the later of two sequence positions.\n"
"\n"
" `NoPosition` acts as absence of an observed position. If both positions are\n"
" concrete, the larger integer is returned.\n"
).
-spec highest_position(sequence_position(), sequence_position()) -> sequence_position().
highest_position(Left, Right) ->
case {Left, Right} of
{no_position, Position} ->
Position;
{Position@1, no_position} ->
Position@1;
{{sequence_position, Left@1}, {sequence_position, Right@1}} ->
case Left@1 >= Right@1 of
true ->
{sequence_position, Left@1};
false ->
{sequence_position, Right@1}
end
end.