CarboniteLint

Hex.pmCILicense: MIT

Catch missing Carbonite audit transactions before they hit production.

The problem

Carbonite uses PostgreSQL triggers to enforce that every mutation on a tracked table is preceded by an INSERT INTO carbonite_default.transactions. If you forget, the trigger raises a foreign_key_violation at runtime.

But test suites disable these triggers (via override_mode = 'ignore') so that factory inserts don't need audit transactions. This creates a blind spot: a bare Repo.update() on a tracked table passes all tests, then blows up in production.

How it works

CarboniteLint catches these gaps with static analysis:

  1. Discovers tracked tables by querying information_schema.triggers — the database is the single source of truth. No configuration to maintain.
  2. Maps table names to Ecto schema modules via __schema__(:source).
  3. Parses each source file's AST to find functions with bare Repo.update/insert/delete calls whose arguments reference a tracked schema.
  4. Reports functions that lack a Carbonite.Multi.insert_transaction call (or your configured audit wrapper).

When you add a new Carbonite trigger via migration, CarboniteLint automatically checks all write paths to that table. Nothing to update.

Installation

Add carbonite_lint to your list of dependencies in mix.exs:

def deps do
  [
    {:carbonite_lint, "~> 0.1.0", only: :test, runtime: false}
  ]
end

Usage

Add a test that runs the lint check:

defmodule MyApp.CarboniteLintTest do
  use ExUnit.Case

  test "all mutations on tracked tables have audit wrappers" do
    assert [] =
      CarboniteLint.run(
        repo: MyApp.Repo,
        otp_app: :my_app
      )
  end
end

That's it. If someone adds a bare Repo.update on a tracked table without wrapping it in Carbonite.Multi.insert_transaction, the test fails with a clear message:

Found 1 unaudited Carbonite mutation.

Every Repo.update/insert/delete on a Carbonite-tracked table must be preceded
by a Carbonite.Multi.insert_transaction/2 call (or your configured audit wrapper).

  lib/my_app/users.ex:42 update_email/? mutates MyApp.Users.User

Custom audit wrappers

If you wrap Carbonite calls in your own module (e.g., MyApp.Audit.multi/2), add the patterns:

CarboniteLint.run(
  repo: MyApp.Repo,
  otp_app: :my_app,
  audit_patterns: [
    ~r/Carbonite\.Multi\.insert_transaction\(/,
    ~r/Carbonite\.insert_transaction\(/,
    ~r/Audit\.multi\(/,
    ~r/Audit\.without_triggers\(/,
    ~r/Audit\.disable_triggers\(/
  ]
)

Runtime enforcement

Use with_enforcement/2 in individual tests to verify a specific function creates an audit transaction with triggers enabled:

test "update_user creates audit trail" do
  user = UserFactory.insert!()  # triggers disabled — factory works

  CarboniteLint.with_enforcement(MyApp.Repo, fn ->
    assert {:ok, _} = MyApp.Users.update_user(user, %{name: "New"})
  end)
end

Excluding paths

Skip generated code or vendored directories:

CarboniteLint.run(
  repo: MyApp.Repo,
  otp_app: :my_app,
  exclude_paths: ["lib/my_app_web/", "lib/my_app/generated/"]
)

Options

Option Required Default Description
:repo yes Your Ecto Repo module
:otp_app yes OTP application for module discovery
:audit_patterns no Carbonite defaults List of regexes matching audit wrapper calls
:paths no ["lib/"] Source paths to scan
:exclude_paths no [] Path substrings to skip

Limitations

Contributing

git clone https://github.com/tommeier/carbonite_lint
cd carbonite_lint
mix deps.get
mix test

Tests require a local PostgreSQL instance (user: postgres, password: postgres).

Releasing

  1. Update @version in mix.exs
  2. Update CHANGELOG.md
  3. Update version in README installation section
  4. Commit: git commit -am "Release vX.Y.Z"
  5. Tag: git tag vX.Y.Z
  6. Push: git push origin main --tags
  7. GitHub Actions creates the release from the changelog
  8. mix hex.publish && mix hex.publish docs

License

MIT — see LICENSE.