slap_yjs

slap_yjs durably stores Yjs documents, and keeps each document in sync across the nodes of an Elixir cluster. Yjs is a CRDT library: its shared data types merge concurrent edits from many clients without conflicts, and its protocol exchanges those edits between them. Each document is stored as a snapshot log, using slap_snapshot_log. Clients connected to different nodes edit the same document and see each other's cursors and other Yjs awareness state.

Use slap_yjs when your application uses y_ex, the Elixir bindings for Yjs. Your application is responsible for providing the client connection, forwarding sync and awareness messages, and handling authentication; this package does not start a WebSocket or HTTP server.

About Slap {: .info}

This package is part of Slap.

Slap provides Elixir bindings for SlateDB (an embedded key-value engine optimized for storing data in object storage), as well as services built on top of these bindings. The services run inside your application, on one node or several.

Services:

Building blocks:

Complete distribution:

The slap package pulls in all seven libraries. If you only need a subset, add those packages directly.

Installation

Requires Elixir 1.18 or later and Erlang/OTP 26.2 or later (see Requirements).

To depend on slap_yjs directly, add it to the dependencies in your application's mix.exs:

defp deps do
  [
    {:slap_yjs, "~> 0.4.0"}
  ]
end

Run mix deps.get. API documentation is on HexDocs.

Example

In an application that depends on slap_yjs, add these children to its supervision tree, in this order:

children = [
  {Slap.Streams.Cluster, store: {:local, "/tmp/slap-yjs"}, shards: 8},
  Slap.Yjs.Docs
]

For an application-owned Streams cluster, use the pattern in the slap_streams README and pass cluster: MyApp.StreamsCluster when starting Slap.Yjs.Docs and calling Slap.Yjs.Store. Run one Streams cluster per VM.

Define a document server module:

defmodule MyApp.DocServer do
  use Slap.Yjs.DocServer
end

Start your application with iex -S mix, then join the document. IEx acts as the client process:

iex> {:ok, server} = Slap.Yjs.Docs.join(MyApp.DocServer, {"my-service", "doc-1"}); :ok
:ok
iex> is_reference(Process.monitor(server))
true

iex> :ok = Slap.Yjs.DocServer.sync(server, 5_000)
:ok
iex> {:ok, snapshots} = Slap.Yjs.Store.snapshots({"my-service", "doc-1"}); is_list(snapshots)
true

Before forwarding awareness, claim and authorize its client id. Pass each Yjs sync or authorized awareness message from the client to MyApp.DocServer.process_message_v1(server, message, self()). Encode each {:slap_yjs_update, _, _} and {:slap_yjs_awareness, _, _} message the client process receives with Slap.Yjs.DocServer.encode_message/1, and send it to the client. sync/2 waits for local updates to be stored and for the server to catch up with other nodes.

To read or edit a document from server code, get its Yex.Doc with Slap.Yjs.DocServer.doc/2 and use y_ex's functions on it. To pass settings to your document server module, use the :assigns option of Slap.Yjs.Docs.join/3.

Client behavior

Awareness and presence

Yjs refers to ephemeral participant state as awareness, such as a participant's name, cursor position, or selection. Awareness is not stored as document content.

Authorizing client awareness

Each awareness entry has a client id chosen by the browser. That id is separate from the authenticated user id. Checking the name or user id in the state does not establish who may update the entry: another connection could submit its own state under the same client id and overwrite it.

Local publishers must claim their awareness id with Slap.Yjs.DocServer.claim/4 before submitting states or removals through process_message_v1/3. The caller must be subscribed and pass self() as the message origin. A claim belongs to that subscriber until it leaves or exits. Another connection of the same principal may take over; the previous connection's states and removals are then rejected, and its departure cannot remove the replacement's state.

For example, a reconnecting browser may keep client id 42 while its old connection is still alive. The new connection claims 42 with the same authenticated principal. Ownership transfers immediately, even if the browser resends a state with the clock the server already has. Yjs may ignore that equal-clock state, but the old connection's later exit still cannot delete the replacement's presence.

The host supplies the authenticated principal and still authorizes or stamps state fields. One awareness update can contain entries for several clients: y-websocket clients forward states they receive and removals for timed-out states. Filter entries independently and bind each connection to its first accepted id. Claim only when establishing that binding; submitting later states checks ownership without reclaiming the id.

This example expects states to identify their user as %{"user" => %{"id" => user_id}}. It returns {message, client_id}; message is nil when no entries are accepted.

Start a new subscription with client_id set to nil. Save the returned value after each call and pass it to the next call:

def authorize_awareness(server, message, user_id, client_id) do
  with {:ok, 1, framed} <- Slap.Yjs.Frame.read_varuint(message),
       {:ok, [update]} <- Slap.Yjs.Frame.parse(framed),
       {:ok, entries} <- Slap.Yjs.Awareness.decode(update) do
    case Enum.flat_map_reduce(entries, client_id, &accept(&1, &2, user_id, server)) do
      {[], client_id} -> {nil, client_id}
      {kept, client_id} ->
        {<<1>> <> Slap.Yjs.Frame.frame(Slap.Yjs.Awareness.encode(kept)), client_id}
    end
  else
    _ -> {nil, client_id}
  end
end

defp accept({id, _clock, %{"user" => %{"id" => user_id}}} = entry, nil, user_id, server) do
  case Slap.Yjs.DocServer.claim(server, id, user_id) do
    :ok -> {[entry], id}
    {:error, :not_owner} -> {[], :not_owner}
    {:error, _reason} -> {[], nil}
  end
end

defp accept({id, _clock, %{"user" => %{"id" => user_id}}} = entry, id, user_id, _server),
  do: {[entry], id}

defp accept({id, _clock, nil} = entry, id, _user_id, _server), do: {[entry], id}
defp accept(_entry, client_id, _user_id, _server), do: {[], client_id}

Forward a non-nil message with MyApp.DocServer.process_message_v1(server, message, self()). The server checks ownership again before applying the entries. If it returns {:error, :not_owner}, set the connection's client_id to :not_owner too. The next call to the example function will then discard the entries. An accepted update can still be ignored under awareness clock rules.

A takeover prevents the old subscriber from reclaiming that id for the rest of its subscription. Unsubscribing and subscribing again starts a new subscription, even for the same pid. A server restart clears all claims and requires clients to subscribe again. Reset client_id to nil only when starting a new subscription.

Multi-node ownership limitation

Claims protect submissions to one document server. Awareness relayed from another document server is accepted without these ownership checks. Two servers can each accept a claim for the same client id, including claims by different principals. Their updates can conflict with each other or with server-side presence. Hosts must authenticate and filter awareness on every node; local claims do not provide exclusive ownership across nodes.

Server-side presence

Start Slap.Yjs.Presence for a server-side participant such as an agent. It subscribes to the document, publishes and renews its state, rejoins after the document server stops, and removes its state when the presence process stops.

With the supervision tree and MyApp.DocServer from the example above:

{:ok, presence} =
  Slap.Yjs.Presence.start_link(
    module: MyApp.DocServer,
    doc_id: {"my-service", "doc-1"},
    owner: self(),
    state: fn client_id ->
      %{"user" => %{"name" => "Assistant"}, "colorIndex" => rem(client_id, 8), "busy" => false}
    end
  )

The initial state can be a map or a function of the generated client id. The function runs once in each presence process, before its first publication. Rejoining keeps the same id and the latest state, including any updates.

With owner: self(), the caller's exit stops the presence, including normal exit and while the presence is retrying a join. Presence's default restart: :transient child policy prevents a supervisor restart after owner exit. The owner may differ from the process that starts or supervises the presence. To remove presence on an explicit leave while the owner remains alive, retain the presence pid and call GenServer.stop(presence).

update/2 replaces the participant's whole state. Browser clients receive this state through the same Yjs awareness protocol used for other participants. Your client UI decides how to display fields such as "user" and "busy". The state is renewed every 15 seconds by default; see Slap.Yjs.Presence for configuration options.

How it works

Requirements

OTP 26.2 or later (mix.exs checks it): the document servers monitor their :pg group, and before 26.2 a process that both monitors and joins a group crashes the :pg scope when it exits.

Tests

Follow the repository's development setup, then run from slap_yjs/:

mix test

Set SLAP_TEST_S3_ENDPOINT and SLAP_TEST_S3_BUCKET to include the RustFS tests. The multi-node convergence test uses :peer and needs epmd; SLAP_YJS_PROP_SEED replays its edit sequence.

License

Slap is released under the terms of the Apache License 2.0.

Copyright (c) 2026, Michael Russo.