diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3bdbb9a..476d7b7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+## [1.0.4] - 2026-06-13
+
+### Fixed
+
+- `upsert_and_log` (and other `*_and_log`) no longer raise `Ecto.ConstraintError` on `audit_log_pkey` under concurrent upserts. `changelog_changeset` now declares `unique_constraint(:id, name: "
_pkey")` so Ecto turns the violation into a changeset error that the logging paths swallow. Closes OPS-4571.
+
## [1.0.3] - 2026-05-28
### Fixed
diff --git a/lib/ecto_trail/ecto_trail.ex b/lib/ecto_trail/ecto_trail.ex
index 74d7075..ecdd2cf 100644
--- a/lib/ecto_trail/ecto_trail.ex
+++ b/lib/ecto_trail/ecto_trail.ex
@@ -55,6 +55,8 @@ defmodule EctoTrail do
@default_max_params 65_000
@changelog_fields [:actor_id, :resource, :resource_id, :changeset, :change_type]
@not_loaded_pattern "Ecto.Association.NotLoaded"
+ @audit_log_table Application.compile_env(:ecto_trail, :table_name, "audit_log")
+ @audit_log_pkey "#{@audit_log_table}_pkey"
defmacro __using__(_) do
quote do
@@ -572,6 +574,8 @@ defmodule EctoTrail do
defp map_custom_ecto_type(value), do: value
defp changelog_changeset(attrs) do
- Changeset.cast(%Changelog{}, attrs, @changelog_fields)
+ %Changelog{}
+ |> Changeset.cast(attrs, @changelog_fields)
+ |> Changeset.unique_constraint(:id, name: @audit_log_pkey)
end
end
diff --git a/mix.exs b/mix.exs
index c895a54..e08c811 100644
--- a/mix.exs
+++ b/mix.exs
@@ -1,7 +1,7 @@
defmodule EctoTrail.Mixfile do
use Mix.Project
- @version "1.0.3"
+ @version "1.0.4"
def project do
[
diff --git a/test/unit/ecto_trail_test.exs b/test/unit/ecto_trail_test.exs
index 1a44768..5c92dd8 100644
--- a/test/unit/ecto_trail_test.exs
+++ b/test/unit/ecto_trail_test.exs
@@ -225,6 +225,35 @@ defmodule EctoTrailTest do
change_type: :upsert
} = TestRepo.one(Changelog)
end
+
+ test "upsert_and_log does not raise Ecto.ConstraintError on audit_log pkey collision (concurrent upsert case)" do
+ {:ok, schema} = TestRepo.insert(%Resource{name: "uq-base"})
+
+ # Seed one audit row so the pkey sequence exists and has a value, then rewind it
+ # so the *next* autogenerated PK will collide (serial id in test DB).
+ {:ok, _seed} =
+ TestRepo.insert(%Changelog{
+ actor_id: "seed",
+ resource: "resources",
+ resource_id: "999",
+ changeset: %{},
+ change_type: :insert
+ })
+
+ max_id = TestRepo.one(from(c in Changelog, select: max(c.id)))
+ seq_name = "audit_log_id_seq"
+ Ecto.Adapters.SQL.query!(TestRepo, "SELECT setval($1, $2, true)", [seq_name, max_id - 1])
+
+ # Before the fix this raises Ecto.ConstraintError because log_changes did a bare insert
+ # without unique_constraint(:id, name: "audit_log_pkey"). After fix the violation becomes
+ # a normal {:error, changeset} which the *_and_log paths swallow (audit is best-effort).
+ result =
+ schema
+ |> Changeset.change(%{name: "uq-after"})
+ |> TestRepo.upsert_and_log("concurrency-actor")
+
+ assert {:ok, %Resource{name: "uq-after"}} = result
+ end
end
describe "use inside Ecto.Multi (prevents nested multi tx RuntimeError)" do