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
- How it works — transaction and storage overview.
- Durable subscriptions — defining and backfilling configured reactions.
- Durable effects — leasing, settlement, retries, dead letters, and replay.
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:
factos_events: append-only event rows;factos_event_tags: indexed tag rows used for context reads;factos_outbox: durable integration effects produced atomically with events.
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:
- opens a PostgreSQL
SERIALIZABLEtransaction; - reads rows matching the configured query, or the target stream;
- decodes and folds them into decision state;
- runs the decider;
- appends only if the observed context is still current;
- assigns event ids and inserts event and tag-index rows;
- runs every configured pure subscription reactor and inserts its effects;
- retries serialization/deadlock failures up to the builder’s retry attempts;
- 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.