diff --git a/CHANGELOG.md b/CHANGELOG.md index 3bdbb9a..16a4878 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 + +- Declare `unique_constraint` on the audit_log pkey inside `changelog_changeset/1` so `update_and_log` (and other `*_and_log` / `log` paths) do not raise `Ecto.ConstraintError` on `audit_logs_pkey` when the log table's sequence produces a colliding id. Closes OPS-4594. + ## [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..0ebec78 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_table Application.compile_env(:ecto_trail, :table_name, "audit_log") + @pkey_constraint_name "#{@audit_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: @pkey_constraint_name) 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..d59f42c 100644 --- a/test/unit/ecto_trail_test.exs +++ b/test/unit/ecto_trail_test.exs @@ -329,4 +329,34 @@ defmodule EctoTrailTest do ) end end + + describe "update_and_log/3 pkey constraint resilience" do + setup do + {:ok, schema} = TestRepo.insert(%Resource{name: "name"}) + {:ok, %{schema: schema}} + end + + test "does not raise Ecto.ConstraintError on audit_log pkey collision; main update succeeds and log failure is swallowed", + %{schema: schema} do + # Force a deterministic pkey collision for the *next* library-generated insert. + # Insert a colliding row with an explicit id, then setval so the sequence will emit the same id. + TestRepo.query!(""" + INSERT INTO audit_log (id, actor_id, resource, resource_id, changeset, change_type, inserted_at) + VALUES (987654, 'seed', 'resources', '1', '{}', 'update', now()) + """) + + TestRepo.query!("SELECT setval('audit_log_id_seq', 987653, true)") + + # The upcoming log insert inside update_and_log will use nextval()=987654 and hit pkey dup. + # Before the fix: raises Ecto.ConstraintError (see OPS-4594 stack). + # After the fix: unique_constraint turns it into a changeset error; log_changes swallows it (logs + {:ok, reason}). + result = + schema + |> Changeset.change(%{name: "name-after-collision"}) + |> TestRepo.update_and_log("constraint-actor") + + assert {:ok, %Resource{name: "name-after-collision"}} = result + assert [%{name: "name-after-collision"}] = TestRepo.all(Resource) + end + end end