├── .gitignore ├── .travis.yml ├── LICENSE.md ├── README.md ├── config └── config.exs ├── lib └── ot │ ├── server.ex │ └── server │ ├── adapter.ex │ ├── ets_adapter.ex │ └── impl.ex ├── mix.exs ├── mix.lock └── test ├── ot ├── server │ └── ets_adapter_test.exs └── server_test.exs └── test_helper.exs /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where 3rd-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: elixir 2 | elixir: 3 | - 1.5.1 4 | - 1.4.5 5 | otp_release: 6 | - 20.0 7 | - 19.3 8 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # ISC License 2 | 3 | Copyright 2017 Jonathan Clem 4 | 5 | Permission to use, copy, modify, and/or distribute this software for any 6 | purpose with or without fee is hereby granted, provided that the above 7 | copyright notice and this permission notice appear in all copies. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH 10 | REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND 11 | FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, 12 | INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM 13 | LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR 14 | OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR 15 | PERFORMANCE OF THIS SOFTWARE. 16 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # [OT.Server](https://hexdocs.pm/ot_server) [![Build Status](https://travis-ci.org/jclem/ot_server.svg?branch=master)](https://travis-ci.org/jclem/ot_server) 2 | 3 | `OT.Server` is an application that manages the correct handling of submitted 4 | operations in an operational transformation system. It ships with an adapter 5 | for persisting data to ETS, but implementing new adapters is simple. 6 | 7 | For more detailed information about operational transformation, see the 8 | documentation for [ot_ex](https://github.com/jclem/ot_ex) and the various links 9 | therein. 10 | 11 | ## Installation 12 | 13 | The package can be installed by adding `ot_server` to your list of dependencies 14 | in `mix.exs`: 15 | 16 | ```elixir 17 | def deps do 18 | [ 19 | {:ot_server, "~> 0.1.0"} 20 | ] 21 | end 22 | ``` 23 | 24 | ## Usage 25 | 26 | Implement an adapter as per `OT.Server.Adapter` and configure it as the adapter 27 | for the `:ot_server` application: 28 | 29 | ```elixir 30 | config :ot_server, 31 | adapter: MyOTAdapter, 32 | max_retries: 25, 33 | ot_types: %{"text" => OT.Text} 34 | ``` 35 | 36 | For an example of how an adapter can be created, see `OT.Server.ETSAdapter`. 37 | 38 | ### Configuration Options 39 | 40 | - `adapter`: The `OT.Server.Adapter` that `OT.Server` will use to interact with 41 | your data. 42 | - `max_retries`: The number of times a submission will be attempted before it 43 | fails permanently. 44 | - `ot_types`: A map with string keys pointing to modules that implement 45 | `OT.Type`. 46 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | # This file is responsible for configuring your application 2 | # and its dependencies with the aid of the Mix.Config module. 3 | use Mix.Config 4 | 5 | # This configuration is loaded before any dependency and is restricted 6 | # to this project. If another project depends on this project, this 7 | # file won't be loaded nor affect the parent project. For this reason, 8 | # if you want to provide default values for your application for 9 | # 3rd-party users, it should be done in your "mix.exs" file. 10 | 11 | # You can configure your application as: 12 | # 13 | # config :ot_server, key: :value 14 | # 15 | # and access this configuration in your application as: 16 | # 17 | # Application.get_env(:ot_server, :key) 18 | # 19 | # You can also configure a 3rd-party app: 20 | # 21 | # config :logger, level: :info 22 | # 23 | 24 | # It is also possible to import configuration files, relative to this 25 | # directory. For example, you can emulate configuration per environment 26 | # by uncommenting the line below and defining dev.exs, test.exs and such. 27 | # Configuration from the imported file will override the ones defined 28 | # here (which is why it is important to import them last). 29 | # 30 | # import_config "#{Mix.env}.exs" 31 | -------------------------------------------------------------------------------- /lib/ot/server.ex: -------------------------------------------------------------------------------- 1 | defmodule OT.Server do 2 | @moduledoc """ 3 | A safe API for interacting with operations and the data they operate against. 4 | """ 5 | 6 | use GenServer 7 | 8 | @typedoc """ 9 | A map containing OT-related information. 10 | 11 | This map must contain at least three keys: 12 | 13 | - `type`: A string representing the OT type, which will be used to find the 14 | appropriate OT module. 15 | - `version`: A non-negative integer representing the current `t:version/0` of 16 | the datum. 17 | - `content`: The contents of the datum that `t:operation/0`s will be applied 18 | to. 19 | """ 20 | @type datum :: %{required(:type) => String.t, 21 | required(:version) => non_neg_integer, 22 | required(:content) => any, 23 | any => any} 24 | 25 | @typedoc """ 26 | A piece of information that can uniquely identify a `t:datum/0`. 27 | """ 28 | @type datum_id :: any 29 | 30 | @typedoc """ 31 | A list of units of work performed against a single piece of data (a 32 | `t:datum/0`). 33 | """ 34 | @type operation :: [any] 35 | 36 | @typedoc """ 37 | A non-negative integer representing an operation or `t:datum/0` version. 38 | """ 39 | @type version :: non_neg_integer 40 | 41 | @typedoc """ 42 | A tuple representing an `t:operation/0` and its `t:version/0`. 43 | """ 44 | @type operation_info :: {operation, version} 45 | 46 | @doc false 47 | def start_link(_) do 48 | GenServer.start_link(__MODULE__, []) 49 | end 50 | 51 | @doc """ 52 | Submit an operation. 53 | 54 | ## Example 55 | 56 | iex> {:ok, pid} = OT.Server.start_link([]) 57 | iex> :ets.insert(:ot_data, 58 | ...> {"id", %{id: "id", content: "Hllo, ", type: "text", version: 0}}) 59 | iex> OT.Server.submit_operation(pid, "id", {[1, %{i: "e"}], 1}) 60 | iex> OT.Server.submit_operation(pid, "id", {[6, %{i: "world."}], 1}) 61 | {:ok, {[7, %{i: "world."}], 2}} 62 | iex> OT.Server.get_datum(pid, "id") 63 | {:ok, %{id: "id", content: "Hello, world.", type: "text", version: 2}} 64 | 65 | If the operation succeeds, a tuple will be returned with the operation and 66 | its version. Otherwise, an error will be returned. 67 | """ 68 | @spec submit_operation(pid, any, {OT.Operation.t, pos_integer}, any) :: 69 | {:ok, {OT.Operation.t, pos_integer}} | {:error, any} 70 | def submit_operation(pid, datum_id, {op, vsn}, meta \\ nil) do 71 | GenServer.call(pid, {:submit_operation, datum_id, {op, vsn}, meta}) 72 | end 73 | 74 | @doc """ 75 | Get a datum. 76 | 77 | This will call the configured adapter's `c:OT.Server.Adapter.get_datum/1` 78 | function and return that value. 79 | 80 | ## Example 81 | 82 | iex> {:ok, pid} = OT.Server.start_link([]) 83 | iex> :ets.insert(:ot_data, {"id", %{id: "id"}}) 84 | iex> OT.Server.get_datum(pid, "id") 85 | {:ok, %{id: "id"}} 86 | 87 | If the datum is found, it will be returned. Otherwise, an error is returned. 88 | Also, note that this function does get called in a worker, so shares worker 89 | bandwidth with `submit_operation/3`. 90 | """ 91 | @spec get_datum(pid, any) :: {:ok, any} | {:error, any} 92 | def get_datum(pid, id) do 93 | GenServer.call(pid, {:get_datum, id}) 94 | end 95 | 96 | @impl true 97 | def handle_call(call_args, _from, state) do 98 | [command | args] = Tuple.to_list(call_args) 99 | result = apply(OT.Server.Impl, command, args) 100 | {:reply, result, state} 101 | end 102 | end 103 | -------------------------------------------------------------------------------- /lib/ot/server/adapter.ex: -------------------------------------------------------------------------------- 1 | defmodule OT.Server.Adapter do 2 | @moduledoc """ 3 | An adapter behaviour for interacting with peristed data in an operational 4 | transformation system. 5 | """ 6 | 7 | alias OT.Server 8 | 9 | @doc """ 10 | Call a function inside of a transaction. 11 | 12 | This is useful for adapters that use databases that support transactions. All 13 | of the other adapter functions (other than `c:handle_submit_error/3`) will 14 | be call called in the function passed to this function. 15 | 16 | This is a good place to implement locking to ensure that only a single 17 | operation is processed at a time per document, a requirement of this OT 18 | system. 19 | """ 20 | @callback transact(id :: Server.datum_id, (() -> any)) :: {:ok, any} | {:error, any} 21 | 22 | @doc """ 23 | Roll a transaction back. 24 | 25 | This will be called when the attempt to submit an operation fails—for adapters 26 | without real transaction support, they must choose how to repair their data 27 | at this stage, since `c:update_datum/2` may have been called, but 28 | `c:insert_operation/3` may have failed. 29 | """ 30 | @callback rollback(any) :: no_return 31 | 32 | @doc """ 33 | Get the datum identified by the ID. 34 | """ 35 | @callback get_datum(id :: Server.datum_id) :: {:ok, Server.datum} | {:error, any} 36 | 37 | @doc """ 38 | Get any conflicting operations for the given datum at the given version. 39 | 40 | In a proper OT system, this means any operation for the given datum 41 | whose version is greater than or equal to the given version. 42 | 43 | The function must return a list of `t:OT.Server.operation_info/0`s. 44 | """ 45 | @callback get_conflicting_operations(datum :: Server.datum, Server.version) 46 | :: [Server.operation_info] 47 | 48 | @doc """ 49 | Update the `t:OT.Server.datum/0` with the given content and increment its 50 | `t:OT.Server.version/0`. 51 | """ 52 | @callback update_datum(datum :: Server.datum, any) :: {:ok, Server.datum} | {:error, any} 53 | 54 | @doc """ 55 | Insert the given `t:OT.Server.operation/0` into persistence. 56 | 57 | Any metadata that was originally passed to `OT.Server.submit_operation/3` will 58 | also be passed to the adapter. 59 | 60 | On a successful submission, this value is what will be returned from 61 | `OT.Server.submit_operation/3`. 62 | """ 63 | @callback insert_operation(datum :: Server.datum, Server.operation_info, any) :: {:ok, any} | {:error, any} 64 | 65 | @doc """ 66 | Handle a submission error. 67 | 68 | If the error passed to this function constitutes a scenario in which the 69 | submission should be tried again, return `:retry`. Otherwise, return a tagged 70 | error tuple and the call to `OT.Server.submit_operation/3` will fail. 71 | """ 72 | @callback handle_submit_error(any, any, Server.operation_info) :: :retry | {:error, any} 73 | end 74 | -------------------------------------------------------------------------------- /lib/ot/server/ets_adapter.ex: -------------------------------------------------------------------------------- 1 | defmodule OT.Server.ETSAdapter do 2 | @moduledoc """ 3 | This is an adapter for OT.Server that stores data and operations in ETS 4 | tables. 5 | 6 | It is not meant for production use, as all of its data is publicly available 7 | for testing purposes. 8 | """ 9 | 10 | @behaviour OT.Server.Adapter 11 | 12 | use GenServer 13 | 14 | @ops_table :ot_ops 15 | @data_table :ot_data 16 | 17 | defmodule RollbackError do 18 | defexception [:message] 19 | 20 | def exception(error) do 21 | %__MODULE__{message: inspect(error)} 22 | end 23 | end 24 | 25 | @doc false 26 | def start_link(_) do 27 | GenServer.start_link(__MODULE__, [], name: __MODULE__) 28 | end 29 | 30 | @impl GenServer 31 | def init(_) do 32 | :ets.new(@data_table, [:named_table, :set, :public]) 33 | :ets.new(@ops_table, [:named_table, :ordered_set, :public]) 34 | {:ok, []} 35 | end 36 | 37 | @impl OT.Server.Adapter 38 | def transact(_, func) do 39 | GenServer.call(__MODULE__, {:transact, func}) 40 | end 41 | 42 | @impl OT.Server.Adapter 43 | def rollback(err) do 44 | raise RollbackError, err 45 | end 46 | 47 | @impl OT.Server.Adapter 48 | def get_conflicting_operations(%{id: id}, op_vsn) do 49 | # Compiled from: 50 | # :ets.fun2ms(fn {{^id, vsn}, op} when vsn >= op_vsn -> 51 | # {op, vsn} 52 | # end) 53 | match_spec = [{ 54 | {{:"$1", :"$2"}, :"$3"}, 55 | [{:>=, :"$2", {:const, op_vsn}}, {:"=:=", {:const, id}, :"$1"}], 56 | [{{:"$3", :"$2"}}] 57 | }] 58 | 59 | :ets.select(@ops_table, match_spec) 60 | end 61 | 62 | @impl OT.Server.Adapter 63 | def get_datum(id) do 64 | @data_table 65 | |> :ets.lookup(id) 66 | |> case do 67 | [{^id, datum}] -> {:ok, datum} 68 | _ -> {:error, :not_found} 69 | end 70 | end 71 | 72 | @impl OT.Server.Adapter 73 | def handle_submit_error({:error, :version_mismatch}, _, _) do 74 | :retry 75 | end 76 | 77 | def handle_submit_error(err, _, _) do 78 | err 79 | end 80 | 81 | @impl OT.Server.Adapter 82 | def insert_operation(%{id: id}, {op, vsn}, _meta) do 83 | if :ets.insert_new(@ops_table, {{id, vsn}, op}) do 84 | {:ok, {op, vsn}} 85 | else 86 | {:error, :version_mismatch} 87 | end 88 | end 89 | 90 | @impl OT.Server.Adapter 91 | def update_datum(datum, content) do 92 | datum = 93 | datum 94 | |> Map.put(:content, content) 95 | |> Map.put(:version, datum[:version] + 1) 96 | 97 | if :ets.insert(@data_table, {datum[:id], datum}) do 98 | {:ok, datum} 99 | else 100 | {:error, :update_failed} 101 | end 102 | end 103 | 104 | @impl GenServer 105 | def handle_call({:transact, func}, _, state) do 106 | {:reply, {:ok, func.()}, state} 107 | end 108 | end 109 | -------------------------------------------------------------------------------- /lib/ot/server/impl.ex: -------------------------------------------------------------------------------- 1 | defmodule OT.Server.Impl do 2 | @moduledoc """ 3 | Implements the business logic of interacting with data in an OT system. 4 | """ 5 | 6 | @adapter Application.get_env(:ot_server, :adapter, OT.Server.ETSAdapter) 7 | @max_retries Application.get_env(:ot_server, :max_retries) 8 | 9 | @doc """ 10 | Get a datum using the configured `OT.Server.Adapter`. 11 | """ 12 | @spec get_datum(OT.Server.datum_id) :: 13 | {:ok, OT.Server.datum} | {:error, any} 14 | def get_datum(id) do 15 | @adapter.get_datum(id) 16 | end 17 | 18 | @doc """ 19 | Submit an operation using the configured `OT.Server.Adapter`, transforming it 20 | against concurrent operations, if necessary. 21 | """ 22 | @spec submit_operation(OT.Server.datum_id, 23 | OT.Server.operation_info, any, non_neg_integer) :: 24 | {:ok, OT.Server.operation} | {:error, any} 25 | def submit_operation(datum_id, op_vsn, op_meta, retries \\ 0) 26 | 27 | def submit_operation(_, _, _, retries) when retries > @max_retries do 28 | {:error, :max_retries_exceeded} 29 | end 30 | 31 | def submit_operation(datum_id, {op, vsn}, op_meta, retries) do 32 | txn_result = 33 | @adapter.transact(datum_id, fn -> 34 | case attempt_submit_operation(datum_id, {op, vsn}, op_meta) do 35 | {:ok, new_op} -> new_op 36 | {:error, err} -> @adapter.rollback(err) 37 | end 38 | end) 39 | 40 | case txn_result do 41 | {:ok, new_op} -> 42 | {:ok, new_op} 43 | {:error, err} -> 44 | case @adapter.handle_submit_error(err, datum_id, {op, vsn}) do 45 | :retry -> submit_operation(datum_id, {op, vsn}, retries + 1) 46 | err -> err 47 | end 48 | end 49 | end 50 | 51 | defp attempt_submit_operation(datum_id, {op, vsn}, op_meta) do 52 | with {:ok, datum} <- @adapter.get_datum(datum_id), 53 | {:ok, type} <- lookup_type(Map.get(datum, :type)), 54 | {:ok, vsn} <- check_datum_version(Map.get(datum, :version), vsn), 55 | {op, vsn} = get_new_operation(datum, {op, vsn}, type), 56 | {:ok, datum} <- update_datum(datum, op, type) do 57 | @adapter.insert_operation(datum, {op, vsn}, op_meta) 58 | end 59 | end 60 | 61 | defp lookup_type(type_key) do 62 | case Application.get_env(:ot_server, :ot_types, %{})[type_key] do 63 | type when not is_nil(type) -> {:ok, type} 64 | _ -> {:error, :type_not_found} 65 | end 66 | end 67 | 68 | defp check_datum_version(datum_vsn, op_vsn) do 69 | if op_vsn > datum_vsn + 1 do 70 | {:error, {:version_mismatch, op_vsn, datum_vsn}} 71 | else 72 | {:ok, op_vsn} 73 | end 74 | end 75 | 76 | defp get_new_operation(datum, {op, vsn}, type) do 77 | case @adapter.get_conflicting_operations(datum, vsn) do 78 | [] -> 79 | {op, vsn} 80 | conflicting_ops -> 81 | new_vsn = 82 | conflicting_ops 83 | |> Enum.max_by(&(elem(&1, 1))) 84 | |> elem(1) 85 | |> Kernel.+(1) 86 | 87 | new_op = 88 | conflicting_ops 89 | |> Enum.reduce(op, &type.transform(&2, elem(&1, 0), :left)) 90 | 91 | {new_op, new_vsn} 92 | end 93 | end 94 | 95 | defp update_datum(datum, op, type) do 96 | case type.apply(Map.get(datum, :content), op) do 97 | {:ok, content} -> 98 | @adapter.update_datum(datum, content) 99 | err -> 100 | err 101 | end 102 | end 103 | end 104 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule OT.Server.Mixfile do 2 | use Mix.Project 3 | 4 | @version "0.3.1" 5 | @github_url "https://github.com/jclem/ot_server" 6 | 7 | def project do 8 | [ 9 | app: :ot_server, 10 | version: @version, 11 | description: description(), 12 | package: package(), 13 | elixir: "~> 1.4", 14 | build_embedded: Mix.env == :prod, 15 | start_permanent: Mix.env == :prod, 16 | deps: deps(), 17 | 18 | # Docs 19 | name: "OT.Server", 20 | homepage_url: @github_url, 21 | source_url: @github_url, 22 | docs: docs() 23 | ] 24 | end 25 | 26 | # Run "mix help deps" to learn about dependencies. 27 | defp deps do 28 | [ 29 | {:ot_ex, "~> 0.1"}, 30 | {:ex_doc, "~> 0.16", only: [:dev]} 31 | # {:dep_from_hexpm, "~> 0.3.0"}, 32 | # {:dep_from_git, git: "https://github.com/elixir-lang/my_dep.git", tag: "0.1.0"}, 33 | ] 34 | end 35 | 36 | defp description do 37 | """ 38 | OT.Server provides a generic server for submitting operations in an 39 | operational transformation system. 40 | """ 41 | end 42 | 43 | defp package do 44 | [maintainers: ["Jonathan Clem "], 45 | licenses: ["ISC"], 46 | links: %{"GitHub" => @github_url}] 47 | end 48 | 49 | defp docs do 50 | [source_ref: "v#{@version}", 51 | main: "README.md", 52 | extras: ["README.md": [filename: "README.md", title: "Readme"], 53 | "LICENSE.md": [filename: "LICENSE.md", title: "License"]]] 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{"earmark": {:hex, :earmark, "1.2.3", "206eb2e2ac1a794aa5256f3982de7a76bf4579ff91cb28d0e17ea2c9491e46a4", [], [], "hexpm"}, 2 | "ex_doc": {:hex, :ex_doc, "0.16.2", "3b3e210ebcd85a7c76b4e73f85c5640c011d2a0b2f06dcdf5acdb2ae904e5084", [], [{:earmark, "~> 1.1", [hex: :earmark, repo: "hexpm", optional: false]}], "hexpm"}, 3 | "ot_ex": {:hex, :ot_ex, "0.1.0", "cc8dca58217ad2100deda50d4ca0e3e831d055c61051f45c34658472e9daa2af", [], [], "hexpm"}} 4 | -------------------------------------------------------------------------------- /test/ot/server/ets_adapter_test.exs: -------------------------------------------------------------------------------- 1 | defmodule OT.Server.ETSAdapterTest do 2 | use ExUnit.Case, async: true 3 | doctest OT.Server.ETSAdapter 4 | 5 | alias OT.Server.ETSAdapter 6 | 7 | setup do 8 | {:ok, _} = ETSAdapter.start_link([]) 9 | %{} 10 | end 11 | 12 | test ".transact calls the enclosing function" do 13 | result = ETSAdapter.transact("id", fn -> :done end) 14 | assert result == {:ok, :done} 15 | end 16 | 17 | test ".rollback raises the error" do 18 | assert_raise(ETSAdapter.RollbackError, "{:error, \"Error\"}", fn -> 19 | ETSAdapter.rollback({:error, "Error"}) 20 | end) 21 | end 22 | 23 | test ".get_conflicting_operations gets conflicting operations" do 24 | ETSAdapter.insert_operation(%{id: "id"}, {[:op_0], 0}, nil) 25 | ETSAdapter.insert_operation(%{id: "id"}, {[:op_1], 1}, nil) 26 | ETSAdapter.insert_operation(%{id: "id"}, {[:op_2], 2}, nil) 27 | ETSAdapter.insert_operation(%{id: "id2"}, {[:op_2_2], 2}, nil) 28 | 29 | assert ETSAdapter.get_conflicting_operations(%{id: "id"}, 1) == 30 | [{[:op_1], 1}, {[:op_2], 2}] 31 | end 32 | 33 | test ".get_datum fetches the datum" do 34 | datum = %{id: "id"} 35 | :ets.insert(:ot_data, {datum[:id], datum}) 36 | assert ETSAdapter.get_datum(datum[:id]) == {:ok, datum} 37 | end 38 | 39 | test ".get_datum returns an error when not found" do 40 | assert ETSAdapter.get_datum("id") == {:error, :not_found} 41 | end 42 | 43 | test ".handle_submit_error returns a retry for a version mismatch" do 44 | assert ETSAdapter.handle_submit_error({:error, :version_mismatch}, nil, nil) == 45 | :retry 46 | end 47 | 48 | test ".handle_submit_error returns the error for a non-version mismatch" do 49 | assert ETSAdapter.handle_submit_error({:error, :not_found}, nil, nil) == 50 | {:error, :not_found} 51 | end 52 | 53 | test ".insert_operation inserts the operation of it is valid" do 54 | assert ETSAdapter.insert_operation(%{id: "id"}, {[1], 1}, nil) == {:ok, {[1], 1}} 55 | assert ETSAdapter.get_conflicting_operations(%{id: "id"}, 1) == [{[1], 1}] 56 | end 57 | 58 | test ".insert_operation returns an error for a version mismatch" do 59 | ETSAdapter.insert_operation(%{id: "id"}, {[1], 1}, nil) 60 | assert ETSAdapter.insert_operation(%{id: "id"}, {[1], 1}, nil) == 61 | {:error, :version_mismatch} 62 | end 63 | 64 | test ".update_datum updates the datum" do 65 | datum = %{id: "id", content: "A", version: 0} 66 | :ets.insert(:ot_data, {datum[:id], datum}) 67 | {:ok, %{id: "id", content: "B", version: 1}} = 68 | ETSAdapter.update_datum(datum, "B") 69 | assert ETSAdapter.get_datum("id") == 70 | {:ok, %{id: "id", content: "B", version: 1}} 71 | end 72 | end 73 | -------------------------------------------------------------------------------- /test/ot/server_test.exs: -------------------------------------------------------------------------------- 1 | defmodule OT.ServerTest do 2 | use ExUnit.Case 3 | doctest OT.Server 4 | 5 | alias OT.Server 6 | 7 | setup do 8 | Application.put_env(:ot_server, :ot_types, %{"text" => OT.Text}) 9 | OT.Server.ETSAdapter.start_link([]) 10 | {:ok, server} = OT.Server.start_link([]) 11 | {:ok, %{server: server}} 12 | end 13 | 14 | test ".submit_operation submits the operation", %{server: server} do 15 | datum = %{id: "id", content: "", type: "text", version: 0} 16 | :ets.insert(:ot_data, {datum[:id], datum}) 17 | Server.submit_operation(server, "id", {[%{i: "A"}], 1}) 18 | {:ok, datum} = Server.get_datum(server, datum[:id]) 19 | assert datum[:content] == "A" 20 | end 21 | 22 | test ".get_datum gets the datum by ID", %{server: server} do 23 | datum = %{id: "id", content: "", type: "text", version: 0} 24 | :ets.insert(:ot_data, {datum[:id], datum}) 25 | assert Server.get_datum(server, datum[:id]) == {:ok, datum} 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | --------------------------------------------------------------------------------