Otzel Integration Guide
View SourceThis guide covers integrating Otzel, an Operational Transformation (OT) library for Elixir, with Spector event-sourced schemas. Otzel provides Quill.js-compatible rich text operations that pair naturally with event sourcing.
Overview
Why Otzel + Event Sourcing?
Traditional approaches store full document contents on each save. With Otzel and Spector:
- Store diffs, not documents: Events contain delta operations (insertions, deletions, formatting changes) rather than full content
- Automatic composition: When replaying events, deltas compose into the current document state
- Full edit history: Every keystroke or edit is preserved in the event log
- Conflict resolution: OT transforms handle concurrent edits from multiple clients
The Key Insight
Otzel deltas are composable: delta1 |> Otzel.compose(delta2) |> Otzel.compose(delta3) produces the final document state. This maps perfectly to event replay—each event stores a delta, and composing them during replay reconstructs the document.
Basic Usage with Otzel.Ecto.Delta
For simple cases without custom embeds, use Otzel's built-in Ecto type:
defmodule MyApp.Document do
use Spector.Evented, events: MyApp.Events
use Ecto.Schema
alias Ecto.Changeset
schema "documents" do
field :title, :string
field :content, Otzel.Ecto.Delta, default: Otzel.quill_init()
timestamps()
end
def changeset(changeset, attrs) do
changeset
|> Changeset.cast(attrs, [:title, :inserted_at, :updated_at])
|> apply_delta_change(attrs)
|> Changeset.validate_required([:title, :content])
end
defp apply_delta_change(changeset, attrs) do
case Spector.fetch_attr(attrs, :content_diff) do
{:ok, diff} when not is_nil(diff) ->
changeset
|> Changeset.get_field(:content)
|> Otzel.compose(ensure_otzel_structs(diff))
|> then(&Changeset.put_change(changeset, :content, &1))
_ ->
changeset
end
end
# Event payloads come back as JSON maps during replay
# Convert them back to Otzel structs
defp ensure_otzel_structs([diff | _] = delta) when not is_struct(diff) do
{:ok, ops} = Otzel.from_json(delta)
ops
end
defp ensure_otzel_structs(delta), do: delta
endThe Quill Default
Quill.js requires documents to end with a newline character. Use Otzel.quill_init() which returns [Otzel.insert("\n")] as the default value for Quill-backed fields.
Custom Embeds with embed_encoder Option
When you need custom embeds (checkboxes, images, etc.), use the :embed_encoder option with Otzel.from_json/2:
# Signature: Otzel.from_json(json, embed_encoder: {Module, :function})
# Or: Otzel.from_json(json, embed_encoder: &function/1)
# The encoder function receives a raw JSON map and returns:
# - A struct for recognized embeds
# - The original value unchanged for unrecognized data
defmodule MyApp.Embeds do
def encode(%{"checkbox" => checked}) do
%MyApp.Checkbox{checked: checked}
end
def encode(%{"image" => %{"url" => url, "alt" => alt}}) do
%MyApp.Image{url: url, alt: alt}
end
def encode(other), do: other # Pass through unchanged
end
# Usage:
{:ok, delta} = Otzel.from_json(json, embed_encoder: {MyApp.Embeds, :encode})
# Or with function capture:
{:ok, delta} = Otzel.from_json(json, embed_encoder: &MyApp.Embeds.encode/1)Embed Structs
Define struct modules for your custom embeds:
defmodule MyApp.Checkbox do
@derive Jason.Encoder
defstruct [:checked]
end
defmodule MyApp.Image do
@derive Jason.Encoder
defstruct [:url, :alt]
endEmbeds appear in deltas as Otzel.insert(%MyApp.Checkbox{checked: true}).
Schema with Custom Embeds
When using custom embeds, you need to handle struct conversion during event replay:
defmodule MyApp.Document do
use Spector.Evented, events: MyApp.Events
use Ecto.Schema
alias Ecto.Changeset
schema "documents" do
field :title, :string
field :content, Otzel.Ecto.Delta, default: Otzel.quill_init()
# Virtual fields for conflict resolution
field :last_event_id, :binary_id, virtual: true
field :replay_events, {:array, :map}, virtual: true
timestamps()
end
def changeset(changeset, attrs) do
changeset
|> Changeset.cast(attrs, [:title, :inserted_at, :updated_at])
|> apply_delta_change(attrs)
|> Changeset.validate_required([:title, :content])
end
defp apply_delta_change(changeset, attrs) do
case Spector.fetch_attr(attrs, :content_diff) do
{:ok, diff} when not is_nil(diff) ->
changeset
|> Changeset.get_field(:content)
|> Otzel.compose(ensure_otzel_structs(diff))
|> then(&Changeset.put_change(changeset, :content, &1))
_ ->
changeset
end
end
# Event payloads come back as JSON maps during replay
# Convert them back to Otzel structs, handling custom embeds
defp ensure_otzel_structs([diff | _] = delta) when not is_struct(diff) do
Otzel.from_json(delta, embed_encoder: &embed_encoder/1)
end
defp ensure_otzel_structs(delta), do: delta
defp embed_encoder(%{"checkbox" => checked}) do
%MyApp.Checkbox{checked: checked}
end
defp embed_encoder(%{"image" => %{"url" => url, "alt" => alt}}) do
%MyApp.Image{url: url, alt: alt}
end
defp embed_encoder(other), do: other
endDelta Composition in Changesets
The core pattern composes incoming diffs with the current document state:
defp apply_delta_change(changeset, attrs) do
case Spector.fetch_attr(attrs, :content_diff) do
{:ok, diff} when not is_nil(diff) ->
changeset
|> Changeset.get_field(:content)
|> Otzel.compose(ensure_otzel_structs(diff))
|> then(&Changeset.put_change(changeset, :content, &1))
_ ->
changeset
end
endWhy Diffs Compose Correctly During Replay
When events replay:
- First event:
[] |> compose(diff1)= state after edit 1 - Second event:
state1 |> compose(diff2)= state after edit 2 - And so on...
Each event stores only the delta (diff), but composition rebuilds the full document state. This is storage-efficient and provides complete edit history.
Events That Don't Modify the Delta
When an event only modifies non-delta fields (e.g., updating title without changing content), you have two options:
- Omit
content_diff: Theapply_delta_changefunction returns the changeset unchanged - Pass
content_diff: []: An empty delta composes to a no-op (Otzel.compose(content, []) == content)
Both approaches work correctly during replay. Omitting the field is cleaner, but explicit [] can be useful for audit trails showing the field was considered.
Warning: Never pass content_diff: nil. The guard when not is_nil(diff) protects against this, but nil values stored in event payloads can cause subtle bugs during replay. Always omit the key entirely or use [].
Conflict Resolution
When multiple clients edit concurrently, the server may receive a diff based on stale state. OT transforms resolve this.
The Pattern
- Client sends diff +
previous_id(the last event ID it knew about) - Server checks if
previous_idmatches the actual last event - If stale, transform the incoming diff against intervening server changes
- Store the transformed diff
Full Implementation
defmodule MyApp.Document do
use Spector.Evented, events: MyApp.Events
use Ecto.Schema
alias Ecto.Changeset
schema "documents" do
field :title, :string
field :content, Otzel.Ecto.Delta, default: Otzel.quill_init()
# Virtual fields for conflict resolution
field :last_event_id, :binary_id, virtual: true
field :replay_events, {:array, :map}, virtual: true
timestamps()
end
def changeset(changeset, attrs) do
changeset
|> Changeset.cast(attrs, [:title, :inserted_at, :updated_at])
|> apply_delta_change(attrs)
|> Changeset.validate_required([:title, :content])
end
defp apply_delta_change(changeset, attrs) do
case Spector.fetch_attr(attrs, :content_diff) do
{:ok, diff} when not is_nil(diff) ->
diff = ensure_otzel_structs(diff)
# Check for conflict: did other events happen since the client's base state?
transformed_diff = maybe_transform_diff(diff, attrs)
changeset
|> Changeset.get_field(:content)
|> Otzel.compose(transformed_diff)
|> then(&Changeset.put_change(changeset, :content, &1))
_ ->
changeset
end
end
defp maybe_transform_diff(diff, attrs) do
previous_id = Spector.get_attr(attrs, :previous_id)
replay_events = Spector.get_attr(attrs, :replay_events, [])
case find_client_base_index(replay_events, previous_id) do
nil ->
# No previous_id or not found - no transform needed
diff
base_index ->
# Transform against all events after the client's base
server_changes =
replay_events
|> Enum.drop(base_index + 1)
|> Enum.map(&extract_content_diff/1)
|> Enum.reject(&is_nil/1)
Enum.reduce(server_changes, diff, fn server_diff, client_diff ->
# Transform: given concurrent edits, how should the client diff be adjusted?
Otzel.transform(server_diff, client_diff, :right)
end)
end
end
defp find_client_base_index(events, previous_id) when is_binary(previous_id) do
Enum.find_index(events, fn event ->
to_string(event.id) == previous_id or event.id == previous_id
end)
end
defp find_client_base_index(_, _), do: nil
defp extract_content_diff(%{payload: %{"content_diff" => diff}}) do
ensure_otzel_structs(diff)
end
defp extract_content_diff(%{payload: %{content_diff: diff}}) do
ensure_otzel_structs(diff)
end
defp extract_content_diff(_), do: nil
defp ensure_otzel_structs([diff | _] = delta) when not is_struct(diff) do
Otzel.from_json(delta, embed_encoder: &embed_encoder/1)
end
defp ensure_otzel_structs(delta), do: delta
defp embed_encoder(%{"checkbox" => checked}) do
%MyApp.Checkbox{checked: checked}
end
defp embed_encoder(other), do: other
endUsage with Conflict Detection
def update_document(document, content_diff, previous_id) do
# Fetch events for replay and conflict detection
events = Spector.all_events(MyApp.Document, document.id)
Spector.update(document, %{
content_diff: content_diff,
previous_id: previous_id,
replay_events: events
})
endFrontend Integration
On the Elixir side, receive JSON deltas from the frontend and pass to your context:
defmodule MyAppWeb.DocumentLive do
# Handle delta from Quill.js
def handle_event("text_change", %{"delta" => delta_json, "previous_id" => prev_id}, socket) do
case Otzel.from_json(delta_json, embed_encoder: &embed_encoder/1) do
{:ok, delta} ->
MyApp.Documents.update_content(
socket.assigns.document,
delta,
prev_id
)
{:error, _} ->
{:noreply, put_flash(socket, :error, "Invalid delta")}
end
end
defp embed_encoder(%{"checkbox" => checked}) do
%MyApp.Checkbox{checked: checked}
end
defp embed_encoder(other), do: other
endThis pattern works with any frontend that sends Quill-compatible delta JSON.
Custom Ecto Type (When Needed)
Create a custom Ecto type only when you need:
- Custom embeds baked into the type
- A different default (non-Quill)
defmodule MyApp.Delta.Richtext do
use Ecto.Type
@impl true
def type, do: :map
@impl true
def cast(data) when is_list(data), do: {:ok, data}
def cast(data) when is_map(data) do
Otzel.from_json(data, embed_encoder: &embed_encoder/1)
end
def cast(_), do: :error
@impl true
def load(nil), do: {:ok, Otzel.quill_init()}
def load(data) when is_map(data) do
Otzel.from_json(data, embed_encoder: &embed_encoder/1)
end
@impl true
def dump(delta) when is_list(delta), do: {:ok, Otzel.to_json(delta)}
def dump(_), do: :error
defp embed_encoder(%{"checkbox" => checked}) do
%MyApp.Checkbox{checked: checked}
end
defp embed_encoder(other), do: other
endAdvanced: Non-Quill OT Fields
Sometimes you need OT for structured data that isn't Quill rich text—annotated sequences, custom formats, or domain-specific structures.
Key Differences from Quill Fields
- Different default: Use
[](empty list), NOTOtzel.quill_init() - Must be non-null: Use
default: []in the schema - Different embed encoder: Handle domain-specific embeds
Example: Annotated Sequence
defmodule MyApp.Delta.Sequence do
use Ecto.Type
@impl true
def type, do: :map
@impl true
def cast(data) when is_list(data), do: {:ok, data}
def cast(data) when is_map(data) do
Otzel.from_json(data, embed_encoder: &sequence_embed_encoder/1)
end
def cast(_), do: :error
@impl true
def load(nil), do: {:ok, []} # Empty list, not Quill newline
def load(data) when is_map(data) do
Otzel.from_json(data, embed_encoder: &sequence_embed_encoder/1)
end
@impl true
def dump(delta) when is_list(delta), do: {:ok, Otzel.to_json(delta)}
def dump(_), do: :error
# Domain-specific embeds
defp sequence_embed_encoder(%{"marker" => %{"type" => type, "position" => pos}}) do
%MyApp.Marker{type: type, position: pos}
end
defp sequence_embed_encoder(other), do: other
enddefmodule MyApp.Annotation do
use Spector.Evented, events: MyApp.Events
use Ecto.Schema
schema "annotations" do
field :name, :string
field :sequence, MyApp.Delta.Sequence, default: [] # Empty list default
timestamps()
end
endMultiple OT Types in One Application
You may need different OT field types in the same application.
Pattern 1: Quill + Non-Quill Fields
When you have both a rich text editor and a structured OT field:
defmodule MyApp.AnnotatedDocument do
use Spector.Evented, events: MyApp.Events
use Ecto.Schema
schema "annotated_documents" do
# Quill rich text - requires trailing newline
field :content, Otzel.Ecto.Delta, default: Otzel.quill_init()
# Structured annotations - empty list default
field :annotations, MyApp.Delta.Sequence, default: []
timestamps()
end
endEach field uses its own Ecto type with the appropriate:
- Default value (
Otzel.quill_init()vs[]) - Embed encoder (Quill embeds vs domain embeds)
Pattern 2: Multiple Quill Fields with Different Plugins
When you have multiple Quill editors with different embed configurations, create custom types:
defmodule MyApp.Delta.BasicRichtext do
use Ecto.Type
# Standard Quill with no custom embeds
# ... standard implementation using Otzel.from_json/1
end
defmodule MyApp.Delta.ChecklistRichtext do
use Ecto.Type
# Quill with checkbox support
defp embed_encoder(%{"checkbox" => checked}) do
%MyApp.Checkbox{checked: checked}
end
defp embed_encoder(other), do: other
endBest Practices
Use
Otzel.Ecto.Deltafor simple cases: Only create custom Ecto types when you need custom embeds baked into the typeUse the correct
embed_encodersignature:# Correct - as keyword option Otzel.from_json(data, embed_encoder: &embed_encoder/1) Otzel.from_json(data, embed_encoder: {Module, :function}) # Wrong - as positional argument Otzel.from_json(data, &embed_encoder/1)Embed encoders return values directly (not wrapped in
{:ok, ...}):# Correct defp embed_encoder(%{"checkbox" => checked}) do %MyApp.Checkbox{checked: checked} end defp embed_encoder(other), do: other # Wrong defp embed_encoder(%{"checkbox" => checked}) do {:ok, %MyApp.Checkbox{checked: checked}} endAlways store diffs, not full documents: Pass
content_diffto your changeset, compose with current stateUse the correct default:
- Quill fields:
Otzel.quill_init()(returns[Otzel.insert("\n")]) - Non-Quill OT fields:
[]
- Quill fields:
Handle JSON round-trip in changesets: Event payloads come back as JSON maps during replay. Use
ensure_otzel_structspattern withOtzel.from_json/2Handle both atom and string keys: Use
Spector.get_attr/2orSpector.fetch_attr/2Test conflict resolution: Write tests that simulate concurrent edits with stale
previous_idvalues
See Also
- Otzel Documentation
- Quill.js Delta Format
- Building a Basic Chat - For simpler text fields without OT