Spector.Evented behaviour (Spector v0.6.0)

View Source

Mark a schema as evented, linking it to an event log table.

Basic Usage

defmodule MyApp.User do
  use Spector.Evented, events: MyApp.Events
  use Ecto.Schema

  schema "users" do
    field :name, :string
    timestamps()
  end

  def changeset(changeset, attrs) do
    changeset
    |> Ecto.Changeset.cast(attrs, [:name, :inserted_at, :updated_at])
    |> Ecto.Changeset.validate_required([:name])
  end
end

Timestamps in Changesets

Always include inserted_at and updated_at in your changeset's cast fields when using timestamps(). Spector stores these values in event payloads to ensure proper timestamp replay. Without casting them, replayed events won't restore original timestamps.

Options

  • :events (required) - The events module (defined with Spector.Events)
  • :repo - Override the repo for this schema's table (default: uses events repo)
  • :version - Schema version for migrations (default: 0). See "Schema Versioning" below
  • :actions - List of custom action atoms (default: []). See "Custom Actions" below

Custom Actions

Beyond the built-in :insert, :update, and :delete actions, you can define custom actions for domain-specific operations:

defmodule MyApp.Item do
  use Spector.Evented,
    events: MyApp.Events,
    actions: [:archive, :restore]

  schema "items" do
    field :name, :string
    field :value, :integer
    field :archived_at, :utc_datetime_usec
    timestamps()
  end

  # Handle the archive action
  def changeset(changeset, attrs) when changeset.action == :archive do
    changeset
    |> Ecto.Changeset.change(archived_at: Spector.get_attr(attrs, :archived_at))
  end

  # Handle other actions
  def changeset(changeset, attrs) do
    changeset
    |> Ecto.Changeset.cast(attrs, [:name, :value, :inserted_at, :updated_at])
  end
end

Execute custom actions with Spector.execute/3:

{:ok, item} = Spector.execute(item, :archive, %{archived_at: DateTime.utc_now()})

Schema Versioning

When you change your schema (add/remove/rename fields), increment the version and handle migrations in your changeset:

defmodule MyApp.User do
  # Version 0: had :title field
  # Version 1: renamed :title to :name
  use Spector.Evented, events: MyApp.Events, version: 1

  schema "users" do
    field :name, :string
    timestamps()
  end

  # Migrate v0 events (with :title) to v1 (with :name)
  def changeset(changeset, attrs) when version_is(attrs, 0) do
    attrs = Map.put(attrs, "name", Spector.get_attr(attrs, :title))
    do_changeset(changeset, attrs)
  end

  def changeset(changeset, attrs), do: do_changeset(changeset, attrs)

  defp do_changeset(changeset, attrs) do
    changeset
    |> Ecto.Changeset.cast(attrs, [:name, :inserted_at, :updated_at])
    |> Ecto.Changeset.validate_required([:name])
  end
end

The version_is/2 and version_in/2 guards help you handle different versions.

During updates, Spector replays all stored events through your changeset/2 function. Old events retain their original version, so your version guards automatically migrate historical data during replay.

Generated Functions

Using this module generates:

  • __spector__/1 - Internal metadata accessor
  • Imports version_in/2 and version_is/2 guards
  • Sets @primary_key to {:id, UUIDv7, autogenerate: false} (Spector manages IDs)

Customizing the Primary Key

The default @primary_key can be overridden by defining it after use Spector.Evented:

defmodule MyApp.Record do
  use Spector.Evented, events: MyApp.Events
  use Ecto.Schema

  @primary_key {:uuid, UUIDv7, autogenerate: false}
  schema "records" do
    field :name, :string
  end
end

The primary key must be a binary type (not integer). This is validated at compile time.

Optional Callbacks

prepare_event/3

Called before inserting an event, allowing last-minute modifications to the event changeset. Useful for populating link associations.

@behaviour Spector.Evented

@impl true
def prepare_event(event_changeset, existing_events, attrs) do
  # Modify event_changeset as needed
  event_changeset
end

The callback receives:

  • event_changeset - The changeset for the event about to be inserted
  • existing_events - All existing events for this record (with links preloaded)
  • attrs - The attributes passed to the action

Note: prepare_event/3 always runs inside a transaction.

Summary

Callbacks

Convert the current record state into an attrs map for a savepoint event.

Functions

Creates a has_many association to the event log for this record.

Guard to check if attrs version is within a range or list of integers.

Guard to check if attrs version equals a specific integer.

Callbacks

prepare_event(event_changeset, existing_events, attrs)

(optional)
@callback prepare_event(
  event_changeset :: Ecto.Changeset.t(),
  existing_events :: [struct()],
  attrs :: map()
) :: Ecto.Changeset.t()

savepoint(record, version)

(optional)
@callback savepoint(record :: struct(), version :: non_neg_integer()) :: map()

Convert the current record state into an attrs map for a savepoint event.

Called by Spector.savepoint/2 to capture the full state of a record. When replaying events, savepoints allow starting from an intermediate state instead of replaying from the beginning.

The version parameter is the schema version at the time the savepoint was registered (from the savepoint event's __version__ field). This allows handling schema migrations when replaying from older savepoints.

@behaviour Spector.Evented

@impl true
def savepoint(record, _version) do
  %{
    name: record.name,
    email: record.email,
    status: record.status
  }
end

The returned attrs map should contain all fields needed to reconstruct the record's state at this point.

Testing savepoints is highly encouraged. Use Spector.Integrity.verify_savepoints/2 to ensure savepoints correctly capture the replayed state from all possible replay paths.

Functions

event_log(name)

(macro)

Creates a has_many association to the event log for this record.

Use this inside your schema definition to add an association that retrieves all events for a given record.

Note: This macro only works with database-backed schemas, not embedded schemas. Embedded schemas cannot use Ecto associations for preloading.

Example

defmodule MyApp.User do
  use Spector.Evented, events: MyApp.Events
  use Ecto.Schema

  schema "users" do
    field :name, :string
    event_log :log
  end
end

Then you can preload and access events:

user = Repo.get(User, id) |> Repo.preload(:log)
user.log  # Returns all events for this user

version_in(attrs, range)

(macro)

Guard to check if attrs version is within a range or list of integers.

Example: def changeset(struct, attrs) when version_in(attrs, 0..2)

version_is(attrs, version)

(macro)

Guard to check if attrs version equals a specific integer.

Example: def changeset(struct, attrs) when version_is(attrs, 0)