diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3bdbb9a..d20bc2f 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
+
+- `*_and_log/*` and `log/*` no longer raise `Ecto.ConstraintError` on `
_pkey` (unique_constraint) during internal audit log insert from `log_changes`; constraint is now declared on the changeset. Closes OPS-4593.
+
## [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..81ef3c4 100644
--- a/lib/ecto_trail/ecto_trail.ex
+++ b/lib/ecto_trail/ecto_trail.ex
@@ -54,6 +54,7 @@ defmodule EctoTrail do
@redacted_fields_config Application.compile_env(:ecto_trail, :redacted_fields, nil)
@default_max_params 65_000
@changelog_fields [:actor_id, :resource, :resource_id, :changeset, :change_type]
+ @table_name Application.compile_env(:ecto_trail, :table_name, "audit_log")
@not_loaded_pattern "Ecto.Association.NotLoaded"
defmacro __using__(_) do
@@ -572,6 +573,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, [:id | @changelog_fields])
+ |> Changeset.unique_constraint(:id, name: "#{@table_name}_pkey")
end
end
diff --git a/mix.exs b/mix.exs
index c895a54..2950d3e 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
[
@@ -39,7 +39,7 @@ defmodule EctoTrail.Mixfile do
{:excoveralls, ">= 0.5.0", only: [:dev, :test]},
{:credo, ">= 0.5.1", only: [:dev, :test]},
{:ecto_enum, "~> 1.0"},
- {:benchee, "~> 1.0", only: [:dev, :test]}
+ {:benchee, "~> 1.5", only: [:dev, :test]}
]
end
diff --git a/test/unit/ecto_trail_test.exs b/test/unit/ecto_trail_test.exs
index 1a44768..594f2e0 100644
--- a/test/unit/ecto_trail_test.exs
+++ b/test/unit/ecto_trail_test.exs
@@ -329,4 +329,59 @@ defmodule EctoTrailTest do
)
end
end
+
+ describe "audit log pkey unique constraint" do
+ test "update_and_log does not raise Ecto.ConstraintError when audit log pkey collides (inside transaction)" do
+ {:ok, schema} = TestRepo.insert(%Resource{name: "pkey-collision"})
+
+ table = Application.get_env(:ecto_trail, :table_name, "audit_log")
+ seq_name = "#{table}_id_seq"
+
+ # Seed two high ids so we can force the internal changelog insert to hit a pkey violation.
+ # We pre-insert id 987654322, then setval so the next generated id during log_changes will be exactly that.
+ TestRepo.query!(
+ "INSERT INTO \"#{table}\" (id, actor_id, resource, resource_id, changeset, change_type, inserted_at) VALUES (987654321, 'seed', 'resources', '0', $1, 'insert', now())",
+ [~s({})]
+ )
+
+ TestRepo.query!(
+ "INSERT INTO \"#{table}\" (id, actor_id, resource, resource_id, changeset, change_type, inserted_at) VALUES (987654322, 'seed2', 'resources', '0', $1, 'insert', now())",
+ [~s({})]
+ )
+
+ TestRepo.query!("SELECT setval($1, 987654321, true)", [seq_name])
+
+ # This mirrors the reported call path: update_and_log inside a transaction.
+ result =
+ TestRepo.transaction(fn ->
+ schema
+ |> Changeset.change(%{name: "after-pkey-collision"})
+ |> TestRepo.update_and_log("pkey-collision-actor")
+ end)
+
+ # Must not raise; the audit log insert failure is swallowed (existing behavior) but no crash to caller.
+ assert {:ok, %Resource{name: "after-pkey-collision"}} = result
+ end
+
+ test "insert_and_log does not raise Ecto.ConstraintError when audit log pkey collides" do
+ table = Application.get_env(:ecto_trail, :table_name, "audit_log")
+ seq_name = "#{table}_id_seq"
+
+ TestRepo.query!(
+ "INSERT INTO \"#{table}\" (id, actor_id, resource, resource_id, changeset, change_type, inserted_at) VALUES (987654401, 'seed', 'resources', '0', $1, 'insert', now())",
+ [~s({})]
+ )
+
+ TestRepo.query!(
+ "INSERT INTO \"#{table}\" (id, actor_id, resource, resource_id, changeset, change_type, inserted_at) VALUES (987654402, 'seed2', 'resources', '0', $1, 'insert', now())",
+ [~s({})]
+ )
+
+ TestRepo.query!("SELECT setval($1, 987654401, true)", [seq_name])
+
+ result = TestRepo.insert_and_log(%Resource{name: "insert-pkey-collision"}, "pkey-collision-actor")
+
+ assert {:ok, %Resource{name: "insert-pkey-collision"}} = result
+ end
+ end
end