Spector.Events behaviour (Spector v0.6.0)

View Source

Define an event log table for storing events from evented schemas.

Basic Usage

defmodule MyApp.Events do
  use Spector.Events,
    table: "events",
    schemas: [MyApp.User, MyApp.Post],
    repo: MyApp.Repo
end

Options

  • :table (required) - The database table name for storing events
  • :schemas (required) - List of schemas that will log events to this table
  • :repo (required) - The Ecto repo module to use for database operations
  • :hashed - Enable hash chain integrity (default: false). See "Hash Chain Integrity" below
  • :aliases - Action aliases for refactoring. See "Action Aliases" below
  • :shard - Sharding function name (atom). See "Table Sharding" below
  • :links - List of link associations for many-to-many relationships. See "Event Links" below

Table Sharding

For high-volume event logs, you can shard events across multiple tables using the :shard option. Specify a function name (atom) that takes a parent_id and returns the table name:

defmodule MyApp.Events do
  use Spector.Events,
    table: "events",  # base table name (used for schema definition)
    schemas: [MyApp.User],
    repo: MyApp.Repo,
    shard: :shard_table

  def shard_table(parent_id) do
    # Shard based on first byte of UUID
    <<first_byte, _rest::binary>> = parent_id
    "events_#{rem(first_byte, 4)}"
  end
end

You'll need to create migrations for each shard table. The sharding function must be deterministic - the same parent_id must always map to the same table.

When combining sharding with hash chain integrity (hashed: true), each shard table maintains its own independent hash chain.

Enable event linking with the :links option to create many-to-many relationships between events:

use Spector.Events,
  table: "events",
  schemas: [MyApp.Chat],
  repo: MyApp.Repo,
  links: [ancestors: {"event_ancestors", :ancestor_id}]

Each link creates a join table and a many_to_many association on the events module. You can define multiple links:

links: [
  ancestors: {"event_ancestors", :ancestor_id},
  categories: {"event_categories", :category_id}
]

For links that use an Ecto schema module, pass the module instead of a table name:

links: [links: {MyApp.EventLink, :linked_id}]

The two approaches differ in what the association returns:

  • Table name string: Creates a many_to_many association. Preloading returns the linked events directly. You cannot use put_assoc to set fields on the join table.

  • Schema module: Creates a has_many association. Preloading returns the link records themselves (e.g., %EventLink{event_id: ..., linked_id: ..., type: ...}). You can use put_assoc in prepare_event/3 to create links with custom fields.

Use a schema module when you need to store metadata on links (types, timestamps, etc.) or need to create links via put_assoc. Use a table name string for simple joins where you only care about which events are connected.

See the Event Links Guide for details.

When combining sharding with links, a separate link table is created for each shard. Use link_table_for/2 to get the correct link table name for a given parent_id:

def prepare_event(event_changeset, _previous_events, attrs) do
  parent_id = Ecto.Changeset.get_field(event_changeset, :parent_id)
  link_table = MyEvents.link_table_for(parent_id, "ancestors")

  link = %MyLink{ancestor_id: attrs[:ancestor_id]}
  link = Ecto.put_meta(link, source: link_table)

  Ecto.Changeset.put_assoc(event_changeset, :ancestors, [link])
end

Schema Indexing

Schemas are stored as integers in the database. By default, schemas are auto-indexed starting from 0. To ensure stability when adding/removing schemas, you can specify explicit indexes:

schemas: [MyApp.User, MyApp.Post, {MyApp.Comment, 10}]

In this example, User gets index 0, Post gets index 1, and Comment gets index 10. This allows you to remove Post later without breaking existing data.

Hash Chain Integrity

Enable hashed: true to create a cryptographic hash chain linking all events:

use Spector.Events,
  table: "events",
  schemas: [MyApp.User],
  repo: MyApp.Repo,
  hashed: true

Each event's hash includes the previous event's hash, creating a tamper-evident chain. Any modification to historical events will break the chain.

Note: Hash chain integrity requires PostgreSQL due to the use of LOCK TABLE ... IN EXCLUSIVE MODE for serialization.

Action Aliases

When refactoring action names, use aliases to maintain backwards compatibility with existing events in the database:

use Spector.Events,
  table: "events",
  schemas: [MyApp.Item],
  repo: MyApp.Repo,
  aliases: [soft_delete: :archive]  # soft_delete uses archive's hash

This allows renaming :archive to :soft_delete in your code while still reading old events that used :archive.

Compilation Dependencies

Using this module creates a compilation dependency on all schema modules listed in the :schemas option. This means changes to those schema modules will trigger recompilation of the events module.

Generated Functions

Using this module generates the following functions:

  • changeset/1, changeset/2 - Build an event changeset
  • table_for/1 - Get the table name for a given parent_id
  • shard/2 - Apply sharding to a changeset
  • __spector__/1 - Internal metadata accessor

Summary

Callbacks

Build an event changeset from attributes.

Build an event changeset from an existing struct and attributes.

Get the link table name for a given parent_id and base link table name.

Apply sharding to a changeset based on the parent_id.

Get the table name for a given parent_id.

Callbacks

changeset(attrs)

@callback changeset(attrs :: map()) :: Ecto.Changeset.t()

Build an event changeset from attributes.

changeset(struct, attrs)

@callback changeset(struct :: struct(), attrs :: map()) :: Ecto.Changeset.t()

Build an event changeset from an existing struct and attributes.

shard(changeset, parent_id)

@callback shard(changeset :: Ecto.Changeset.t(), parent_id :: binary()) ::
  Ecto.Changeset.t()

Apply sharding to a changeset based on the parent_id.

Updates the changeset's data source to the appropriate shard table.

table_for(parent_id)

@callback table_for(parent_id :: binary()) :: String.t()

Get the table name for a given parent_id.

For non-sharded tables, returns the configured table name. For sharded tables, calls the shard function to determine the table.