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_pog and factos_sqlight.

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, CQRS, projections, and message brokers are implementation choices rather than prerequisites.

The central flow is:

  1. Select a DecisionContext for the command.
  2. Fold the matching recorded events into a temporary decision state.
  3. Apply the model’s pure decision function.
  4. 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 {
  FailIfEventsMatch(
    decision_context: DecisionContext,
    after: SequencePosition,
  )
}

Constructors

  • FailIfEventsMatch(
      decision_context: DecisionContext,
      after: SequencePosition,
    )

    Append only if no event selected by decision_context appeared after after.

    This models Command Context Consistency. The decision was made from the selected facts visible at after, so the append must fail if that relevant context changed before the new facts are recorded. NoContext selects no events and therefore represents an unconditional append.

The facts a command must consider before deciding.

A decision can explicitly ignore history, match every recorded event, or select events using OR-combined Item values. See Item for the matching rules inside each branch.

pub type DecisionContext {
  NoContext
  Matching(items: List(Item))
  AllEvents
}

Constructors

  • NoContext

    Decide from the model’s initial state without reading prior events.

  • Matching(items: List(Item))

    Read and protect every event selected by the matching items.

  • AllEvents

    Match every recorded event.

Result of a successful command dispatch.

position is the final global position assigned to this dispatch, or NoPosition when no events were produced. events preserves append order.

pub type Dispatch(event) {
  Dispatch(
    position: SequencePosition,
    events: List(Recorded(event)),
  )
}

Constructors

A domain, subscription, storage, or codec failure produced by dispatch.

pub type Error(domain_error, subscription_error, store_error, decode_error) {
  DomainError(domain_error)
  SubscriptionError(subscription_error)
  StoreError(store_error)
  AppendConditionFailed(AppendCondition)
  DecodeError(decode_error)
  InvalidSchema(EventType, Int)
}

Constructors

  • DomainError(domain_error)

    The model rejected the command with a domain error.

  • SubscriptionError(subscription_error)

    A strong subscription could not extend the dispatch transaction.

  • StoreError(store_error)

    The backend store returned an error.

  • AppendConditionFailed(AppendCondition)

    The command context changed before its events could be appended.

  • DecodeError(decode_error)

    A stored event could not be decoded by the application codec.

  • InvalidSchema(EventType, Int)

    No decoder exists for the stored event type and version.

pub type Event(event) {
  Event(payload: event, descriptor: @internal EventDescriptor)
}

Constructors

  • Event(payload: event, descriptor: @internal EventDescriptor)

A store-visible event type name.

Event types are part of the decision-context selection contract. A backend may use them for efficient context reads, and applications should keep names stable enough for stored history to remain decodable.

pub type EventType {
  EventType(String)
}

Constructors

  • EventType(String)
pub type Item {
  Item(types: List(EventType), tags: List(Tag))
}

Constructors

  • Item(types: List(EventType), tags: List(Tag))

    One branch of a selective command context.

    Within an item, event types are OR-combined and tags are AND-combined. Empty types means any event type matches. Empty tags means no tag constraint.

    An item with types: [UserRegistered, UsernameReserved] and tags: [username:renata] means: events of either type that also have the username:renata tag.

Application metadata attached to a recorded event.

Metadata does not participate in decision-context matching. It is intended for operational and audit context such as correlation ids, causation ids, actors, and timestamps.

pub opaque type Metadata
pub type Model(command, state, event, domain_error) =
  @internal InternalModel(command, state, event, domain_error)
pub type Recorded(event) {
  Recorded(
    id: String,
    position: SequencePosition,
    event: Event(event),
  )
}

Constructors

  • Recorded(
      id: String,
      position: SequencePosition,
      event: Event(event),
    )

    A stored event with backend metadata.

    position is the global sequence position used for ordering and context consistency. The descriptor carries store-visible type, version, tags, and application metadata.

pub type SequencePosition {
  NoPosition
  SequencePosition(Int)
}

Constructors

  • NoPosition
  • SequencePosition(Int)

    A backend-specific global sequence position used by context append conditions to express “after this observed point in history”.

Extend a backend transaction for one event accepted by a dispatch.

The transaction type is backend-defined. Interactive backends can execute immediately and return the same transaction connection. Planning backends can return an immutable transaction plan containing additional mutations.

pub type Subscription(transaction, wrapped_event, subscription_error) {
  Subscription(
    fn(transaction, wrapped_event) -> Result(
      transaction,
      subscription_error,
    ),
  )
}

Constructors

  • Subscription(
      fn(transaction, wrapped_event) -> Result(
        transaction,
        subscription_error,
      ),
    )

A store-visible tag value.

Tags expose selected payload information to the event store so commands can select 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 type Tag {
  Tag(String)
}

Constructors

  • Tag(String)

Values

pub fn event(
  type_ type_: EventType,
  version version: Int,
  data payload: payload,
) -> Event(payload)

Prepare a domain event with empty tags and metadata for persistence.

pub fn matches(
  event: Event(event),
  decision_context: DecisionContext,
) -> Bool

Test whether a recorded event belongs to a decision context.

pub fn metadata(entries: List(#(String, String))) -> Metadata

Build event metadata from key/value pairs.

pub fn model(
  initial initial: state,
  try try: fn(state, command) -> Result(List(event), domain_error),
  evolve evolve: fn(state, event) -> state,
  encode encode: fn(event) -> Event(json.Json),
  decode decode: fn(EventType, Int) -> Result(
    decode.Decoder(event),
    Nil,
  ),
) -> Model(command, state, event, domain_error)

Create a reusable model from pure decision functions and an event codec.

evolve folds matching history from initial. try receives that state and the command, then returns new events or a domain error.


pub const incremented = factos.EventType("Incremented")

type Command {
  Increment
}

type Event {
  Incremented
}

fn try(
  state: Int,
  command: Command,
) -> Result(List(Event), Nil) {
  case state, command {
    value, Increment -> Ok([Incremented])
  }
}

fn evolve(state: Int, event: Event) -> Int {
  case state, event {
    value, Incremented -> value
  }
}

fn encode(event: Event) -> factos.Event(json.Json) {
  case event {
    Incremented -> factos.event(type_: incremented, version: 1, data: json.null())
  }
}

fn decode(type_: factos.EventType, version: Int) {
  case version {
    1 if type_ == incremented ->
      Ok(decode.success(Incremented))
    _ -> Error(Nil)
  }
}

let model =
  factos.model(
    initial: 0,
    try:,
    evolve:,
    encode:,
    decode:,
  )
}
pub fn subscription(
  apply apply: fn(transaction, delivery) -> Result(
    transaction,
    subscription_error,
  ),
) -> Subscription(transaction, delivery, subscription_error)

Configure work that shares the backend append transaction.

The callback returns the updated transaction value. Returning Error rejects the subscription and rolls back the originating append.

let projection =
  factos.subscription(apply: fn(transaction, recorded) {
    Ok(projection.insert(transaction, recorded.event.payload))
  })
pub fn with_metadata(
  proposed: Event(payload),
  metadata metadata: Metadata,
) -> Event(payload)

Replace the metadata on a proposed event.

pub fn with_tags(
  proposed: Event(payload),
  tags tags: List(Tag),
) -> Event(payload)

Replace the tags on a proposed event.

Search Document