Append-only (immutable) tables with version tracking for Ecto.
UPDATE and DELETE operations destroy history. Immutable tables preserve it by inserting new versions instead of modifying rows. This enables audit trails, point-in-time queries, and eliminates lost update problems.
def deps do
[
{:immu_table, "~> 0.1.0"}
]
end- Elixir ~> 1.14
- Ecto SQL ~> 3.10
- PostgreSQL (required for advisory locks and UUIDv7)
ImmuTable includes Mix generators to scaffold schemas, contexts, and migrations:
# Generate a schema with immutable_schema
$ mix immutable.gen.schema Blog.Post posts title:string body:text
# Generate a migration with create_immutable_table
$ mix immutable.gen.migration Blog.Post posts title:string body:text
# Generate context + schema (like phx.gen.context)
$ mix immutable.gen.context Blog Post posts title:string body:textThe context generator creates a complete context module with all ImmuTable operations:
-
list_posts/0- List current records -
get_post!/1andget_post/1- Get by entity_id -
create_post/1- Create version 1 -
update_post/2- Create new version -
delete_post/1- Create tombstone -
undelete_post/1- Restore from tombstone -
get_post_history/1- Get all versions
Use create_immutable_table instead of create table:
defmodule MyApp.Repo.Migrations.CreateTasks do
use Ecto.Migration
import ImmuTable.Migration
def change do
create_immutable_table :tasks do
add :title, :string, null: false
add :description, :text
add :status, :string, default: "pending"
end
end
endThis creates a table with these additional columns:
-
id- unique identifier for this specific version (UUIDv7) -
entity_id- stable identifier across all versions (UUIDv7) -
version- incrementing version number (1, 2, 3...) -
valid_from- timestamp when this version was created -
deleted_at- timestamp when soft-deleted (nil if active)
defmodule MyApp.Tasks.Task do
use Ecto.Schema
use ImmuTable
import Ecto.Changeset, except: [cast: 3]
immutable_schema "tasks" do
field :title, :string
field :description, :string
field :status, :string
end
def changeset(task, attrs \\ %{}) do
task
|> cast(attrs, [:title, :description, :status])
|> validate_required([:title])
end
endKey differences from standard Ecto schemas:
-
use ImmuTable- enables immutable table macros -
import Ecto.Changeset, except: [cast: 3]- use ImmuTable's cast which filters protected fields -
immutable_schemainstead ofschema- injects metadata fields automatically - No
timestamps()- ImmuTable usesvalid_frominstead
defmodule MyApp.Tasks do
alias MyApp.Repo
alias MyApp.Tasks.Task
def list_tasks do
Task
|> ImmuTable.Query.get_current()
|> Repo.all()
end
def get_task!(entity_id) do
ImmuTable.get!(Task, Repo, entity_id)
end
def get_task(entity_id) do
ImmuTable.get(Task, Repo, entity_id)
end
def create_task(attrs) do
changeset = Task.changeset(%Task{}, attrs)
ImmuTable.insert(Repo, changeset)
end
def update_task(%Task{} = task, attrs) do
task
|> Task.changeset(attrs)
|> ImmuTable.update(Repo)
end
def delete_task(%Task{} = task) do
ImmuTable.delete(Repo, task)
end
def get_task_history(entity_id) do
Task
|> ImmuTable.Query.history(entity_id)
|> Repo.all()
end
def undelete_task(%Task{} = task) do
ImmuTable.undelete(Repo, task)
end
end| Function | Description |
|---|---|
ImmuTable.insert(Repo, struct_or_changeset) |
Create version 1 of a new entity |
ImmuTable.update(Repo, struct, changes) |
Create new version with changes |
ImmuTable.update(Repo, changeset) |
Create new version from changeset (pipe-friendly) |
ImmuTable.delete(Repo, struct) |
Create tombstone version (soft delete) |
ImmuTable.undelete(Repo, struct) |
Restore from tombstone |
| Function | Description |
|---|---|
ImmuTable.get(Schema, Repo, entity_id) |
Get current version or nil |
ImmuTable.get!(Schema, Repo, entity_id) |
Get current version or raise |
ImmuTable.fetch_current(Schema, Repo, entity_id) |
Get with status: {:ok, record}, {:error, :deleted}, or {:error, :not_found}
|
# Current (non-deleted) versions only
Task |> ImmuTable.Query.get_current() |> Repo.all()
# Include deleted (tombstoned) records
Task |> ImmuTable.Query.include_deleted() |> Repo.all()
# All versions of a specific entity
Task |> ImmuTable.Query.history(entity_id) |> Repo.all()
# Point-in-time query
Task |> ImmuTable.Query.at_time(~U[2024-01-15 10:00:00Z]) |> Repo.all()
# All versions (no filtering)
Task |> ImmuTable.Query.all_versions() |> Repo.all()Every change creates a new row:
| id | entity_id | version | title | deleted_at |
|------|-----------|---------|------------|------------|
| uuid1| abc123 | 1 | "Draft" | nil | <- insert
| uuid2| abc123 | 2 | "Final" | nil | <- update
| uuid3| abc123 | 3 | "Final" | 2024-01-20 | <- delete (tombstone)
| uuid4| abc123 | 4 | "Restored" | nil | <- undelete
-
entity_id- Stable identifier. Use this in URLs and foreign keys. -
id- Unique per version. Changes with every update.
Deleting creates a tombstone row with deleted_at set. The entity and all its history remain in the database. Use undelete/2 to restore.
Routes should use entity_id for stable URLs:
live "/tasks/:entity_id", TaskLive.Show, :show
live "/tasks/:entity_id/edit", TaskLive.Form, :editSee the demo/ folder for a complete Phoenix LiveView example with:
- CRUD operations
- Version history timeline
- Soft delete with restore
- Tombstone view
Configure behavior per-schema:
use ImmuTable, allow_updates: true # Permit Repo.update (bypasses immutability)
use ImmuTable, allow_deletes: true # Permit Repo.delete (bypasses immutability)
use ImmuTable, allow_version_write: true # Permit :version in changesets (default: false, preserves monotonic versioning)
use ImmuTable, show_row_id: true # Show `id` field in `Inspect` outputBy default, direct Repo.update and Repo.delete calls raise ImmutableViolationError.