diff --git a/CHANGELOG.md b/CHANGELOG.md index 3bdbb9a..0eda6cd 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/*` now declare `unique_constraint/3` on the audit_log pkey (using configured table name) so duplicate PK inserts add a constraint error to the changeset instead of raising `Ecto.ConstraintError`. Best-effort audit logging behavior is unchanged. Closes OPS-4578. + ## [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..7e95380 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" + @table_name Application.compile_env(:ecto_trail, :table_name, "audit_log") + @pkey_constraint_name "#{@table_name}_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..3e8c4fe 100644 --- a/test/unit/ecto_trail_test.exs +++ b/test/unit/ecto_trail_test.exs @@ -198,6 +198,50 @@ defmodule EctoTrailTest do assert [%{name: "name"}] = TestRepo.all(Resource) assert [] == TestRepo.all(Changelog) end + + test "does not raise constraint error and succeeds when audit_log pkey collides (best-effort audit)", %{ + schema: schema + } do + # Force a collision on the next audit_log PK insert by pre-inserting a row at a high id + # and resetting the sequence so the generated insert attempts to reuse it. + table = Application.get_env(:ecto_trail, :table_name, "audit_log") + seq_name = "#{table}_id_seq" + + alias EctoTrail.Changelog + + high_id = 2_000_000 + now = DateTime.utc_now() |> DateTime.truncate(:second) + + sentinel = %Changelog{ + id: high_id, + actor_id: "seed", + resource: "resources", + resource_id: "0", + changeset: %{}, + change_type: :insert, + inserted_at: now + } + + TestRepo.insert!(sentinel) + + # Set sequence so the *next* value produced is `high_id`; the audit insert in update_and_log will collide on pkey. + Ecto.Adapters.SQL.query!(TestRepo, "SELECT setval($1, $2, true)", [seq_name, high_id - 1]) + + result = + schema + |> Changeset.change(%{name: "collision-test"}) + |> TestRepo.update_and_log("constraint-actor") + + # The update must succeed; the audit log attempt will hit unique violation on pkey, + # be turned into a changeset error (thanks to unique_constraint/3), logged, and swallowed per best-effort contract. + assert {:ok, %Resource{name: "collision-test"}} = result + + # Sentinel row is still the only one for that seed; colliding insert did not create a second row. + assert TestRepo.aggregate( + from(c in Changelog, where: c.resource_id == "0" and c.actor_id == "seed"), + :count + ) == 1 + end end describe "upsert_and_log/3" do