diff --git a/CHANGELOG.md b/CHANGELOG.md index 3bdbb9a..876c04a 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 + +- `changelog_changeset/1` now registers `unique_constraint/3` for the pkey (covers "audit_log_pkey", "audit_logs_pkey", and common naming variants). This turns an `Ecto.ConstraintError` on duplicate pkey inserts (observed during `update_and_log` under concurrent or sequence-reset conditions) into a normal `{:error, changeset}` so callers can handle it. Closes OPS-4629. + ## [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..f55973d 100644 --- a/lib/ecto_trail/ecto_trail.ex +++ b/lib/ecto_trail/ecto_trail.ex @@ -573,5 +573,7 @@ defmodule EctoTrail do defp changelog_changeset(attrs) do Changeset.cast(%Changelog{}, attrs, @changelog_fields) + |> Changeset.unique_constraint(:id, name: "audit_log_pkey") + |> Changeset.unique_constraint(:id, name: "audit_logs_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..d6e55e3 100644 --- a/test/unit/ecto_trail_test.exs +++ b/test/unit/ecto_trail_test.exs @@ -198,6 +198,36 @@ defmodule EctoTrailTest do assert [%{name: "name"}] = TestRepo.all(Resource) assert [] == TestRepo.all(Changelog) end + + test "returns {:error, changeset} with unique_constraint on pkey violation instead of raising Ecto.ConstraintError", + %{schema: schema} do + # Seed one log entry, then plant a conflicting pkey row and rewind the sequence + # so the *next* audit log insert (from update_and_log) collides on the pkey. + first_log = TestRepo.one(Changelog) + conflict_id = first_log.id + + TestRepo.delete(first_log) + + Ecto.Adapters.SQL.query!( + TestRepo, + "INSERT INTO \"audit_log\" (id, actor_id, resource, resource_id, changeset, change_type, inserted_at)\n VALUES ($1, 'seeded','resources','#{schema.id}', '{}'::jsonb, 'insert', now())", + [conflict_id] + ) + + Ecto.Adapters.SQL.query!( + TestRepo, + "SELECT setval(pg_get_serial_sequence('audit_log', 'id'), $1, false)", + [conflict_id] + ) + + result = + schema + |> Changeset.change(%{name: "after-conflict"}) + |> TestRepo.update_and_log("cowboy") + + assert {:error, %Changeset{errors: errors}} = result + assert Keyword.has_key?(errors, :id) + end end describe "upsert_and_log/3" do