factos
Store-independent event-sourcing domain primitives.
Factos keeps the domain model in the application. The core module models
facts, command contexts, pure decision components, and pure views. Concrete
storage concerns live in backend packages such as factos_sqlight and
factos_kurrentdb_erlang.
This package follows a context-first reading of Event Sourcing: accepted facts are the authoritative state of the system, and the facts relevant to a command are considered before new facts are accepted. Aggregates, stream-per- object storage, CQRS, projections, and message brokers are implementation choices rather than prerequisites.
The central flow is:
- Select a command context with
Query. - Fold the matching recorded events into a temporary decision state.
- Run a pure
Decider. - Append the produced facts only if the context is still stable.
Backends implement the storage-specific parts of that flow. This module keeps the shared types and pure computations small and portable.
Types
pub type AppendCondition {
NoAppendCondition
FailIfEventsMatch(query: Query, after: SequencePosition)
}
Constructors
-
NoAppendConditionAppend without an additional context condition.
-
FailIfEventsMatch(query: Query, after: SequencePosition)Append only if no event matching
queryappeared afterafter.This models Command Context Consistency. The decision was made from the matching facts visible at
after, so the append must fail if that relevant context changed before the new facts are recorded.
pub type Context(event, state) {
Context(
query: Query,
state: state,
events: List(Recorded(event)),
position: SequencePosition,
append_condition: AppendCondition,
)
}
Constructors
-
Context( query: Query, state: state, events: List(Recorded(event)), position: SequencePosition, append_condition: AppendCondition, )A command context read from history.
The context contains the query that selected the relevant facts, the folded decision state, the matching recorded events, the highest observed position, and the append condition needed to protect the decision.
pub type Decider(command, state, event, domain_error) {
Decider(
initial: state,
decide: fn(state, command) -> Result(
List(event),
domain_error,
),
evolve: fn(state, event) -> state,
)
}
Constructors
-
Decider( initial: state, decide: fn(state, command) -> Result(List(event), domain_error), evolve: fn(state, event) -> state, )A pure command-side domain component.
initialis the empty decision state.evolvefolds accepted events into state.decideapplies a command to the folded state and either returns new events or a domain error.A decider has no dependency on storage, transactions, codecs, projections, subscriptions, or transports.
pub type Decoded(event) {
Decoded(
event: event,
type_: EventType,
version: Int,
tags: List(Tag),
metadata: Metadata,
)
}
Constructors
-
A domain event decoded from backend storage.
Backends use codecs supplied by the application. The decoded value includes the domain event plus the event type and tags that should participate in query matching, along with non-query event metadata.
EventType
opaqueA store-visible event type name.
Event types are part of the query contract. A backend may use them for efficient context reads, and applications should keep names stable enough for stored history to remain decodable.
pub opaque type EventType
pub type LoadedStream(event, state) {
LoadedStream(
stream: String,
state: state,
events: List(Recorded(event)),
revision: Revision,
)
}
Constructors
-
A stream read from history.
This is the stream-consistency counterpart to
Context. It contains the folded state for one stream and its current revision.
Metadata
opaqueApplication metadata attached to a recorded event.
Metadata is not part of query matching. It is intended for operational and audit context such as correlation ids, causation ids, actors, and timestamps.
pub opaque type Metadata
pub type QueryItem {
QueryItem(types: List(EventType), tags: List(Tag))
}
Constructors
-
One branch of a command-context query.
Within an item, event types are OR-combined and tags are AND-combined. Empty
typesmeans any event type matches. Emptytagsmeans no tag constraint.A query item with
types: [UserRegistered, UsernameReserved]andtags: [username:renata]means: events of either type that also have theusername:renatatag.
pub type Recorded(event) {
Recorded(
id: String,
stream: String,
revision: Int,
position: SequencePosition,
type_: EventType,
version: Int,
tags: List(Tag),
metadata: Metadata,
event: event,
)
}
Constructors
-
Recorded( id: String, stream: String, revision: Int, position: SequencePosition, type_: EventType, version: Int, tags: List(Tag), metadata: Metadata, event: event, )A stored event with backend metadata.
revisionis the per-stream revision.positionis the global sequence position used for context consistency.type_andtagsare store-visible query metadata.
pub type Revision {
NoEvents
CurrentRevision(Int)
}
Constructors
-
NoEventsA stream has no events.
-
CurrentRevision(Int)The last known revision of a stream.
pub type SequencePosition {
NoPosition
SequencePosition(Int)
}
Constructors
-
NoPositionNo global position was observed.
-
SequencePosition(Int)A backend-specific global sequence position.
Positions are used by context append conditions to express “after this observed point in history”. They are not stream revisions.
Tag
opaqueA store-visible tag value.
Tags expose selected payload information to the event store so commands can
query the facts relevant to a decision. For example, an event payload may
contain username: "renata", while the stored event also carries the tag
username:renata.
pub opaque type Tag
pub type View(state, event) {
View(initial: state, evolve: fn(state, event) -> state)
}
Constructors
-
View(initial: state, evolve: fn(state, event) -> state)A pure projection fold.
Views derive read-side state from events. They are intentionally only the computation; persistence, delivery, rebuilds, and subscription management are outside the core library.
Values
pub fn compute_events(
decider decider: Decider(command, state, event, domain_error),
events events: List(event),
command command: command,
) -> Result(List(event), domain_error)
Fold events with a decider and decide which new events a command produces.
This is useful for unit tests and for in-memory command handling. It does not perform any append or consistency check.
pub fn compute_state(
decider decider: Decider(command, state, event, domain_error),
current current: option.Option(state),
command command: command,
) -> Result(state, domain_error)
Decide from an optional current state and return the state after produced events.
If current is None, the decider’s initial state is used. The function first
runs the decider, then folds the produced events into the decision state.
pub fn decide_context(
context: Context(event, state),
command: command,
decider: Decider(command, state, event, domain_error),
) -> Result(#(Context(event, state), List(event)), domain_error)
Run a command against a previously read context.
The returned tuple preserves the original context alongside the newly produced
events so a backend can append them with context.append_condition.
pub fn decider(
initial initial: state,
decide decide: fn(state, command) -> Result(
List(event),
domain_error,
),
evolve evolve: fn(state, event) -> state,
) -> Decider(command, state, event, domain_error)
Build a pure command-side decider.
The supplied functions remain owned by the application domain. Factos only stores them together so backends and tests can run the same read-decide-append flow consistently.
pub fn evolve_recorded(
initial initial: state,
events events: List(Recorded(event)),
evolve evolve: fn(state, event) -> state,
) -> state
Fold recorded events into state using a domain evolution function.
Backends use this after decoding stored events. Only the domain event payload is
passed to evolve; storage metadata is ignored for state computation.
pub fn highest_position(
left: SequencePosition,
right: SequencePosition,
) -> SequencePosition
Return the later of two sequence positions.
NoPosition acts as absence of an observed position. If both positions are
concrete, the larger integer is returned.
pub fn matches_query(
recorded: Recorded(event),
query: Query,
) -> Bool
Test whether a recorded event belongs to a query-defined context.
pub fn merge_views(
first first: View(first_state, event),
second second: View(second_state, event),
) -> View(#(first_state, second_state), event)
Merge two views that consume the same event type.
The resulting view keeps both states in a tuple and evolves both for every event. This is a convenience for composing small pure projections.
pub fn metadata(entries: List(#(String, String))) -> Metadata
Build event metadata from key/value pairs.
pub fn metadata_entries(
metadata: Metadata,
) -> List(#(String, String))
Unwrap event metadata entries.
pub fn project(
view view: View(state, event),
events events: List(event),
) -> state
Project events from a view’s initial state.
pub fn project_from(
view view: View(state, event),
state state: state,
events events: List(event),
) -> state
Project events starting from an already materialized view state.
pub fn query(items: List(QueryItem)) -> Query
Build a query from query items.
An empty list becomes AllEvents; otherwise the query contains the supplied
items. Query items are OR-combined by matches_query.