Packages

An Ash-native Replicant.Sink adapter: effect-once CDC mirroring into AshPostgres resources.

Current section

Files

Jump to
ash_replicant lib ash_replicant.ex
Raw

lib/ash_replicant.ex

defmodule AshReplicant do
@moduledoc """
An Ash-native `Replicant.Sink` adapter. Mirrors a source Postgres database's
committed CDC changes into AshPostgres resources with effect-once semantics
(dup = 0, loss = 0), resolving resource, tenant, and classification in the Ash
layer while keeping `replicant` tenant-blind.
This is the `ash_postgres`-of-`replicant`: `replicant` is the tenant-blind CDC
transport; multitenancy and classification live here, one layer up.
"""
@version Mix.Project.config()[:version]
@doc "The library version string."
@spec version() :: String.t()
def version, do: @version
@doc """
Start a CDC pipeline that mirrors into Ash resources. `opts`:
* `:sink` — a module built with `use AshReplicant.Sink` (carries repo/domains/checkpoint/slot).
* `:connection` — Postgrex opts (point at a standby).
* `:publication` — replication identifier.
* `:go_forward_only`, `:snapshot` — passed through to `Replicant.start_link/1`.
The `slot_name` is NOT a `start_link` option — it is baked into the sink via
`use AshReplicant.Sink, slot_name: ...` and is the single source of truth for
both the `:persistent_term` index key and the replication slot.
Builds the `{schema,table}=>resource` index from the sink's domains, **fails
closed** on a duplicate or missing source table, caches the index in
`:persistent_term`, then starts the `replicant` pipeline.
"""
@spec start_link(keyword()) :: {:ok, pid()} | {:error, term()}
def start_link(opts) do
sink = Keyword.fetch!(opts, :sink)
connection = Keyword.fetch!(opts, :connection)
publication = Keyword.fetch!(opts, :publication)
%{domains: domains, slot_name: slot_name} = sink.__ash_replicant_config__()
with {:ok, index} <- AshReplicant.Resolver.build_index(domains) do
:persistent_term.put({AshReplicant, slot_name}, index)
Replicant.start_link(
connection: connection,
slot_name: slot_name,
publication: publication,
sink: sink,
go_forward_only: Keyword.get(opts, :go_forward_only, false),
snapshot: Keyword.get(opts, :snapshot, false)
)
end
end
@doc """
Stop a running pipeline by slot name (idempotent). Clears the cached resolver
index from `:persistent_term` so a subsequent `start_link/1` rebuilds it fresh.
"""
@spec stop_supervised(String.t()) :: :ok
def stop_supervised(slot_name) do
:persistent_term.erase({AshReplicant, slot_name})
Replicant.stop(slot_name)
end
end