ImmuTable

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.

Installation

def deps do
[
{:immu_table, "~> 0.1.0"}
]
end

Requirements

Generators

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:text

The context generator creates a complete context module with all ImmuTable operations:

Quick Start

1. Create a Migration

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
end

This creates a table with these additional columns:

2. Define the Schema

defmodule MyApp.Tasks.Task do
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
end

Key differences from standard Ecto schemas:

3. Create a Context Module

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

API Reference

CRUD Operations

FunctionDescription
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

Query Functions

FunctionDescription
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}

Query Helpers

# 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()

How It Works

Version Creation

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 vs Row ID

Soft Deletes

Deleting creates a tombstone row with deleted_at set. The entity and all its history remain in the database. Use undelete/2 to restore.

Phoenix LiveView Integration

Routes should use entity_id for stable URLs:

live "/tasks/:entity_id", TaskLive.Show, :show
live "/tasks/:entity_id/edit", TaskLive.Form, :edit

See the demo/ folder for a complete Phoenix LiveView example with:

Options

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` output

By default, direct Repo.update and Repo.delete calls raise ImmutableViolationError.