factos_pog

factos_pog is the PostgreSQL backend for Factos, implemented with pog.

It stores accepted facts in an append-only PostgreSQL event log, reads the facts relevant to a command, runs your pure factos.Decider, and appends new facts only if the relevant context is still stable.

Use this package when PostgreSQL is your event store and your consistency rules are expressed with Factos event types and tags.

It stores append-only event records and durable outbox effects. Applications own their domain codecs and effect execution; factos_pog owns atomic persistence, leasing, retry scheduling, dead-lettering, and administrative replay.

Guides

Install

[dependencies]
factos = ">= 1.0.0 and < 2.0.0"
factos_pog = ">= 4.0.0 and < 5.0.0"
gleam_erlang = ">= 1.0.0 and < 2.0.0"
pog = ">= 4.1.0 and < 5.0.0"

Set up the schema

The backend ships reusable dbmate-compatible migrations in priv/dbmate/. Application databases should vendor those files into their own migration repository, commit them, and run them with their normal migration tool before dispatching commands. The application migration repository owns ordering and execution history; factos_pog owns only the reusable schema artifacts.

In an Erlang-target migration tool, locate the package priv directory and copy the package migrations into your application migration directory:

import gleam/erlang/application

let assert Ok(priv_directory) = application.priv_directory("factos_pog")
let migrations_directory = priv_directory <> "/dbmate"

priv/migrations.sql is retained for compatibility with already-published versions and for fresh bootstrap examples. Do not treat it as the append-only migration history for an application database. factos_pog.migrate still exists for v1 compatibility, but it is deprecated.

The migrations create:

Define a codec

Your domain event type remains yours. PostgreSQL stores opaque bytes plus queryable metadata, so the application provides an event codec.

fn ticket_codec() -> factos_pog.EventCodec(Event) {
  factos_pog.codec(encode:, decode:)
}

The encoder prepares an event for persistence:

fn encode(event: Event) -> factos_pog.Proposed(Event) {
  case event {
    TicketSold(buyer) ->
      factos_pog.new_proposed(
        event:,
        type_: factos.event_type("TicketSold"),
        version: 1,
        data: bit_array.from_string(buyer),
      )
      |> factos_pog.with_tags(tags: [
        factos.tag("event:gleam-gathering-2026"),
      ])
  }
}

new_proposed requires the typed event, durable type, schema version, and payload. It starts with no tags and empty metadata. Add either optional value through with_tags or with_metadata; Proposed(event) is opaque so every encoded event starts from that complete required representation.

The decoder turns stored rows back into domain events:

fn decode(
  stored: factos_pog.StoredEvent,
) -> Result(factos.Decoded(Event), factos_pog.DecodeError) {
  case factos.event_type_name(stored.type_) {
    "TicketSold" -> {
      use buyer <- result.try(
        bit_array.to_string(stored.data)
        |> result.replace_error(factos_pog.InvalidData),
      )
      Ok(factos_pog.decoded_event(stored, event: TicketSold(buyer)))
    }
    _ -> Error(factos_pog.UnknownEvent)
  }
}

Tags are the query contract. If future commands need to find an event by payload value, expose that value as a tag when writing the event.

Dispatch commands

Configure the complete event-delivery topology once per bounded context. Dispatcher construction rejects blank or duplicate subscription names before database IO. A dispatcher owns the event codec and every named durable subscription; individual commands cannot add or remove reactors.

let assert Ok(dispatcher) =
  factos_pog.dispatcher(
    codec: ticket_codec(),
    subscriptions: [ticket_subscription()],
  )

let assert Ok(dispatch) =
  factos_pog.new_dispatch(
    dispatcher:,
    connection:,
    stream: buyer_stream(attempt),
    decider: ticket_decider(),
  )
  |> factos_pog.with_query(query: sale_query())
  |> factos_pog.dispatch(
    BuyTicket(buyer_name(attempt)),
    event_id: uuid.v4_string,
  )

The backend:

  1. opens a PostgreSQL SERIALIZABLE transaction;
  2. reads rows matching the configured query, or the target stream;
  3. decodes and folds them into decision state;
  4. runs the decider;
  5. appends only if the observed context is still current;
  6. assigns event ids and inserts event and tag-index rows;
  7. runs every configured pure subscription reactor and inserts its effects;
  8. retries serialization/deadlock failures up to the builder’s retry attempts;
  9. commits events and effects atomically.

The return type is:

pub type Dispatch(event) {
  Dispatch(append: Append, events: List(factos.Recorded(event)))
}

dispatch.events contains the committed records inserted by this dispatch. Configured subscriptions have already persisted their durable effects.

Stream-only dispatch

Leave the query unset when one stream revision is intentionally the consistency boundary:

let assert Ok(dispatch) =
  factos_pog.new_dispatch(
    dispatcher:,
    connection:,
    stream: "ticket-sale-renata",
    decider: ticket_decider(),
  )
  |> factos_pog.dispatch(BuyTicket("renata"), event_id: uuid.v4_string)

Stream-only dispatch remains useful for stream-shaped rules, but a builder with with_query is the better fit when a command depends on facts selected by event type and tag.

Deliver durable effects

Configure one retry policy for each durable consumer identity. new_retry_policy() starts with 12 attempts, a 30-second initial delay, a 15-minute maximum delay, a 24-hour maximum age, and 20 percent jitter. Override only the values that differ, then validate them with build. Workers use delivered(), retryable_failure(reason:), or permanent_failure(reason:) to classify one attempt; these values do not control a worker actor. Workers cannot choose their own delay or exceed the configured attempt and age budgets.

let assert Ok(policy) =
  factos_pog.new_retry_policy()
  |> factos_pog.build
let assert Ok(consumer) =
  factos_pog.outbox_consumer(
    name: "payments.ledger_process_manager",
    target: "ledgers",
    policy:,
  )
let assert Ok(messages) =
  factos_pog.lease_outbox(
    connection,
    consumer:,
    limit: 10,
    lease_for_milliseconds: 30_000,
  )

messages
|> list.each(fn(message) {
  let outcome = case deliver(message) {
    Ok(Nil) -> factos_pog.delivered()
    Error(Temporary) ->
      factos_pog.retryable_failure(reason: "ledger_unavailable")
    Error(Permanent) ->
      factos_pog.permanent_failure(reason: "invalid_ledger_effect")
  }
  let assert Ok(_) =
    factos_pog.settle_outbox(connection, consumer:, message:, outcome:)
})

Retry delays use capped exponential backoff plus deterministic jitter. Settlement is one guarded PostgreSQL update: an expired or superseded lease returns StaleLease. list_dead_letters and replay_dead_letter provide operational inspection and explicit replay without replacing the original payload.

Tradeoff: serializable contention

PostgreSQL SERIALIZABLE isolation protects arbitrary event-type/tag predicates without a global application lock. Conflicting transactions can abort and retry, so deciders, reactors, event codecs, effect codecs, and event-id generators must remain pure or idempotent. External work starts only after leasing committed outbox rows.

Search Document