Building a Basic Chat with Spector
View SourceThis guide shows how to use Spector to build a simple chat with edit history tracking using event links.
Overview
Basic chat applications need:
- Message history with user identification
- Ability to edit messages while preserving previous versions
- Simple linear conversation flow (no branching)
Spector's event sourcing with links tracks every edit as a linked event chain.
Setup
1. Define the Chat Schema
defmodule MyApp.BasicChat do
use Spector.Evented, events: MyApp.BasicChatEvents, actions: [:edit]
use Ecto.Schema
alias Ecto.Changeset
import Ecto.Query
defmodule Message do
use Ecto.Schema
@primary_key false
embedded_schema do
field :id, :binary_id
field :user_id, :binary_id
field :content, :string
end
def changeset(message \\ %__MODULE__{}, attrs) do
message
|> Changeset.cast(attrs, [:user_id, :content])
|> Spector.changeset_put_event_id(attrs)
|> Changeset.validate_required([:id, :user_id, :content])
end
end
@primary_key {:id, :binary_id, autogenerate: false}
embedded_schema do
embeds_many :messages, Message
end
@impl true
def prepare_event(event_changeset, previous_events, %{edits: previous_id}) do
previous_events
|> Enum.find(&(&1.id == previous_id))
|> then(&Changeset.put_assoc(event_changeset, :edits, [&1]))
end
def prepare_event(event_changeset, _previous_events, _attrs) do
event_changeset
end
def changeset(chat, %{edits: message_id, content: new_content}) when chat.action == :edit do
chat = Changeset.change(chat)
current_messages = Changeset.get_field(chat, :messages, [])
updated_messages =
Enum.map(current_messages, fn message ->
if message.id == message_id do
%{message | content: new_content}
else
message
end
end)
Changeset.put_change(chat, :messages, updated_messages)
end
def changeset(chat \\ %__MODULE__{}, attrs) do
chat = Changeset.change(chat)
message =
attrs
|> Message.changeset()
|> Changeset.apply_action!(:insert)
current_messages = Changeset.get_field(chat, :messages, [])
Changeset.put_change(chat, :messages, [message | current_messages])
end
def list_messages(chat_id) do
edited_ids =
from(link in "basic_chat_edits", select: link.previous_id)
|> MyApp.Repo.all()
Spector.all_events(__MODULE__, chat_id)
|> Enum.reject(&(&1.id in edited_ids))
end
def get_edit_history(message_id) do
previous_ids =
from(link in "basic_chat_edits",
where: link.event_id == type(^message_id, :binary_id),
select: link.previous_id
)
MyApp.Repo.all(
from(e in MyApp.BasicChatEvents,
where: e.id == ^message_id or e.id in subquery(previous_ids),
order_by: [asc: e.inserted_at]
)
)
end
end2. Define the Events Module
defmodule MyApp.BasicChatEvents do
use Spector.Events,
table: "basic_chat_events",
links: [edits: {"basic_chat_edits", :previous_id}],
schemas: [MyApp.BasicChat],
repo: MyApp.Repo
endThe edits link creates a relationship from the edited message to its previous version.
3. Create the Migration
defmodule MyApp.Repo.Migrations.CreateBasicChatEvents do
use Ecto.Migration
def up do
Spector.Migration.up(
table: "basic_chat_events",
links: [{"basic_chat_edits", :previous_id}]
)
end
def down do
Spector.Migration.down(
table: "basic_chat_events",
links: [{"basic_chat_edits", :previous_id}]
)
end
endUsage
Starting a Conversation
user_id = Ecto.UUID.generate()
assert {:ok, %MyApp.BasicChat{messages: [%{user_id: ^user_id, content: "Hello everyone!"}]} = chat} =
Spector.insert(MyApp.BasicChat, %{
user_id: user_id,
content: "Hello everyone!"
})Adding Messages
Note that messages are prepended, so the most recent message appears first:
other_user_id = Ecto.UUID.generate()
assert {:ok, %{messages: [%{user_id: ^other_user_id, content: "Hi there!"}, %{content: "Hello everyone!"}]} = chat} =
Spector.update(chat, %{
user_id: other_user_id,
content: "Hi there!"
})Editing a Message
Use the :edit action with :edits pointing to the message ID being edited:
[_, %{id: first_message_id}] = chat.messages
assert {:ok, %{messages: [%{content: "Hi there!"}, %{content: "Hello everyone! (edited)"}]} = chat} =
Spector.execute(chat, :edit, %{
edits: first_message_id,
content: "Hello everyone! (edited)"
})How It Works
Insert: Creates the first event with the initial message.
Update: Adds new messages to the conversation.
Edit: Creates a new event linked to the previous version via
basic_chat_edits. The original remains in the event log.History: Follow the
editslinks backward to see all versions of a message.
Tips
- The
user_idin each message tracks the author - Edits preserve the original
user_id- add anedited_byfield if you need to track who made edits - For real-time updates, combine with Phoenix PubSub
- Add
inserted_atfrom the event for message timestamps