Current section
Files
Jump to
Current section
Files
lib/pdf_ex/session.ex
defmodule PdfEx.Session do
@moduledoc """
Public facade for collaborative editing sessions.
`open/1` seeds the snapshot and starts a supervised per-document session.
Writes (`apply_op/2`) are serialized through the session GenServer; reads
(`fetch/1`) bypass it entirely and read the `CacheOwner`-held ETS snapshot.
"""
alias PdfEx.{CacheOwner, Document, DocumentSessionServer, Op}
@type doc_id :: term()
@doc "Seeds the snapshot and starts a supervised session. Returns its `doc_id` (pass `:id` to choose one)."
@spec open(Document.t(), [{:id, doc_id()}]) :: {:ok, doc_id()} | {:error, term()}
def open(%Document{} = doc, opts \\ []) do
doc_id = Keyword.get_lazy(opts, :id, fn -> System.unique_integer([:positive]) end)
# Start (Registry-backed, atomic) BEFORE seeding the cache: the loser of a
# concurrent same-:id race gets :already_started and returns an error
# without ever clobbering the winner's live snapshot.
case DynamicSupervisor.start_child(
PdfEx.SessionSupervisor,
{DocumentSessionServer, document_id: doc_id}
) do
{:ok, _pid} ->
CacheOwner.put(doc_id, doc)
{:ok, doc_id}
{:error, {:already_started, _pid}} ->
{:error, :already_open}
{:error, reason} ->
{:error, reason}
end
end
@doc "Applies an op to the session (server-serialized write). For OT-mediated concurrent edits use `submit_op/2`."
@spec apply_op(doc_id(), Op.t()) :: {:ok, Op.t()} | {:error, PdfEx.Error.t()}
def apply_op(doc_id, op), do: DocumentSessionServer.apply_operation(doc_id, op)
@doc "Submits an OT `Pending` op; it is transformed against concurrent edits, applied, and the new version returned (`:noop` if dropped)."
@spec submit_op(doc_id(), PdfEx.OT.Pending.t()) ::
{:ok, Op.t() | :noop, non_neg_integer()} | {:error, PdfEx.Error.t()}
def submit_op(doc_id, pending), do: DocumentSessionServer.submit(doc_id, pending)
@doc "Returns the session's current version counter."
@spec version(doc_id()) :: non_neg_integer()
def version(doc_id), do: DocumentSessionServer.version(doc_id)
@doc "Reads the current document snapshot directly from the cache (bypasses the server)."
@spec fetch(doc_id()) :: {:ok, Document.t()} | {:error, :no_session}
def fetch(doc_id) do
case CacheOwner.fetch(doc_id) do
{:ok, doc} -> {:ok, doc}
:error -> {:error, :no_session}
end
end
@doc "Returns the session GenServer pid for `doc_id`, or `nil` if none is running."
@spec whereis(doc_id()) :: pid() | nil
def whereis(doc_id) do
case Registry.lookup(PdfEx.SessionRegistry, doc_id) do
[{pid, _}] -> pid
[] -> nil
end
end
@doc "Stops the session and drops its cached snapshot."
@spec close(doc_id()) :: :ok
def close(doc_id) do
case whereis(doc_id) do
pid when is_pid(pid) -> DynamicSupervisor.terminate_child(PdfEx.SessionSupervisor, pid)
nil -> :ok
end
CacheOwner.delete(doc_id)
:ok
end
end