CarboniteLint
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:
- Discovers tracked tables by querying
information_schema.triggers— the database is the single source of truth. No configuration to maintain. - Maps table names to Ecto schema modules via
__schema__(:source). - Parses each source file's AST to find functions with bare
Repo.update/insert/deletecalls whose arguments reference a tracked schema. - Reports functions that lack a
Carbonite.Multi.insert_transactioncall (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}
]
endUsage
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.UserCustom 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)
endExcluding 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
Repo.delete(variable)— when a tracked struct is passed as a bare variable (not a changeset or struct literal), the scanner can't infer its type. In practice, deletions on tracked tables should useEcto.Multi+Carbonite.Multi.insert_transaction.Indirect mutations — if a changeset is created in one function and passed to another that calls
Repo.update, the scanner won't connect the two. The convention of creating changesets and calling Repo in the same function covers most real-world code.
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
-
Update
@versioninmix.exs -
Update
CHANGELOG.md - Update version in README installation section
-
Commit:
git commit -am "Release vX.Y.Z" -
Tag:
git tag vX.Y.Z -
Push:
git push origin main --tags - GitHub Actions creates the release from the changelog
mix hex.publish && mix hex.publish docs
License
MIT — see LICENSE.