Spector.Evented behaviour (Spector v0.6.0)
View SourceMark 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
endTimestamps 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 withSpector.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
endExecute 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
endThe 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/2andversion_is/2guards - Sets
@primary_keyto{: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
endThe 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
endThe callback receives:
event_changeset- The changeset for the event about to be insertedexisting_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
@callback prepare_event( event_changeset :: Ecto.Changeset.t(), existing_events :: [struct()], attrs :: map() ) :: Ecto.Changeset.t()
@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
}
endThe 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
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
endThen you can preload and access events:
user = Repo.get(User, id) |> Repo.preload(:log)
user.log # Returns all events for this user
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)
Guard to check if attrs version equals a specific integer.
Example: def changeset(struct, attrs) when version_is(attrs, 0)