diff --git a/CHANGELOG.md b/CHANGELOG.md index 3bdbb9a..64a53af 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` now includes `:id` and declares `unique_constraint(:id, name: "#{table}_pkey")` so Ecto.ConstraintError on audit PK during `*_and_log` (inside transaction) becomes a changeset error instead of raising. Closes OPS-4617. + ## [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..50aada6 100644 --- a/lib/ecto_trail/ecto_trail.ex +++ b/lib/ecto_trail/ecto_trail.ex @@ -53,7 +53,7 @@ defmodule EctoTrail do # Cache frequently accessed config to avoid repeated lookups @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] + @changelog_fields [:id, :actor_id, :resource, :resource_id, :changeset, :change_type] @not_loaded_pattern "Ecto.Association.NotLoaded" defmacro __using__(_) do @@ -573,5 +573,11 @@ defmodule EctoTrail do defp changelog_changeset(attrs) do Changeset.cast(%Changelog{}, attrs, @changelog_fields) + |> Changeset.unique_constraint(:id, name: pkey_constraint_name()) + end + + defp pkey_constraint_name do + table = Application.get_env(:ecto_trail, :table_name, "audit_log") + "#{table}_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..ed13239 100644 --- a/test/unit/ecto_trail_test.exs +++ b/test/unit/ecto_trail_test.exs @@ -329,4 +329,44 @@ defmodule EctoTrailTest do ) end end + + describe "handles pkey constraint violations on audit_log gracefully (no ConstraintError)" do + test "insert_and_log succeeds even if audit PK would collide (e.g. sequence skew)" do + # Prime the audit_log with an explicit high id via insert_all (bypasses our changeset) + colliding_id = 1_000_007 + + # Insert a "dummy" changelog row claiming that id + {1, _} = + TestRepo.insert_all( + Changelog, + [ + %{ + id: colliding_id, + actor_id: "seq-collision", + resource: "resources", + resource_id: "0", + changeset: %{}, + change_type: :insert, + inserted_at: DateTime.utc_now() |> DateTime.truncate(:second) + } + ] + ) + + # Force the sequence so the next generated id (via default) will collide with the one we inserted. + # setval(seq, n, true) sets last_value to n; next nextval() returns n+1. + # So set to colliding_id-1 to make the next default-generated id == colliding_id. + Ecto.Adapters.SQL.query!(TestRepo, "SELECT setval('audit_log_id_seq', $1, true)", [colliding_id - 1]) + + # Now perform an insert_and_log; internally it will let PG generate the id for the audit row, + # which will collide. Without unique_constraint handling on the changeset this raises Ecto.ConstraintError. + assert {:ok, %Resource{name: "collide-test"}} = + TestRepo.insert_and_log(%Resource{name: "collide-test"}, "pkey-actor") + + # The main resource was inserted + assert TestRepo.exists?(from(r in Resource, where: r.name == "collide-test")) + + # The log step did not succeed in inserting an audit row (constraint violation was turned into error) + refute TestRepo.exists?(from(c in Changelog, where: c.actor_id == "pkey-actor")) + end + end end