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