README.md

# 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

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

### Requirements

- Elixir ~> 1.14
- Ecto SQL ~> 3.10
- PostgreSQL (required for advisory locks and UUIDv7)

## Generators

ImmuTable includes Mix generators to scaffold schemas, contexts, and migrations:

```bash
# 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:
- `list_posts/0` - List current records
- `get_post!/1` and `get_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

## Quick Start

### 1. Create a Migration

Use `create_immutable_table` instead of `create table`:

```elixir
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:
- `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)

### 2. Define the Schema

```elixir
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
end
```

Key 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_schema` instead of `schema` - injects metadata fields automatically
- No `timestamps()` - ImmuTable uses `valid_from` instead

### 3. Create a Context Module

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

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

### Query Functions

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

### Query Helpers

```elixir
# 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

- `entity_id` - Stable identifier. Use this in URLs and foreign keys.
- `id` - Unique per version. Changes with every update.

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

```elixir
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:
- CRUD operations
- Version history timeline
- Soft delete with restore
- Tombstone view

## Options

Configure behavior per-schema:

```elixir
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`.