Packages
Grow-only, two-phase, and observed-remove sets for Gleam — conflict-free replicated set types
Current section
Files
Jump to
Current section
Files
src/lattice_sets@two_p_set.erl
-module(lattice_sets@two_p_set).
-compile([no_auto_import, nowarn_unused_vars, nowarn_unused_function, nowarn_nomatch, inline]).
-define(FILEPATH, "src/lattice_sets/two_p_set.gleam").
-export([new/0, add_with_delta/2, add/2, remove_with_delta/2, remove/2, contains/2, value/1, merge/2, to_json/1, from_json/1]).
-export_type([two_p_set/1]).
-if(?OTP_RELEASE >= 27).
-define(MODULEDOC(Str), -moduledoc(Str)).
-define(DOC(Str), -doc(Str)).
-else.
-define(MODULEDOC(Str), -compile([])).
-define(DOC(Str), -compile([])).
-endif.
?MODULEDOC(
" A two-phase set (2P-Set) CRDT.\n"
"\n"
" Supports both add and remove, but an element can only be removed once. Once\n"
" removed (tombstoned), an element can never be re-added. Internally tracks\n"
" two sets: `added` and `removed`. An element is active if it is in `added`\n"
" but not in `removed`. Use `ORSet` if you need re-add after remove.\n"
"\n"
" ## Example\n"
"\n"
" ```gleam\n"
" import lattice_sets/two_p_set\n"
"\n"
" let set = two_p_set.new()\n"
" |> two_p_set.add(\"alice\")\n"
" |> two_p_set.add(\"bob\")\n"
" |> two_p_set.remove(\"bob\")\n"
" two_p_set.contains(set, \"alice\") // -> True\n"
" two_p_set.contains(set, \"bob\") // -> False (tombstoned)\n"
" ```\n"
).
-opaque two_p_set(EJN) :: {two_p_set, gleam@set:set(EJN), gleam@set:set(EJN)}.
-file("src/lattice_sets/two_p_set.gleam", 37).
?DOC(" Create a new empty 2P-Set.\n").
-spec new() -> two_p_set(any()).
new() ->
{two_p_set, gleam@set:new(), gleam@set:new()}.
-file("src/lattice_sets/two_p_set.gleam", 60).
?DOC(
" Add an element and return both the new state and a delta.\n"
"\n"
" The returned delta is a `TwoPSet` whose `added` set contains only the\n"
" inserted element and whose `removed` set is empty. Merging the delta\n"
" into a remote via `merge` (union of both halves) produces the same\n"
" result as merging the full new state.\n"
).
-spec add_with_delta(two_p_set(EJT), EJT) -> {two_p_set(EJT), two_p_set(EJT)}.
add_with_delta(Tpset, Element) ->
Updated = {two_p_set,
gleam@set:insert(erlang:element(2, Tpset), Element),
erlang:element(3, Tpset)},
Delta = {two_p_set, gleam@set:from_list([Element]), gleam@set:new()},
{Updated, Delta}.
-file("src/lattice_sets/two_p_set.gleam", 49).
?DOC(
" Add an element to the set.\n"
"\n"
" If the element has already been tombstoned (removed), this call records the\n"
" element in `added` but the element will not be considered active because\n"
" the tombstone takes precedence.\n"
"\n"
" See `add_with_delta` for the delta-state variant that also returns a\n"
" small payload suitable for incremental sync (e.g. over websockets).\n"
).
-spec add(two_p_set(EJQ), EJQ) -> two_p_set(EJQ).
add(Tpset, Element) ->
{Updated, _} = add_with_delta(Tpset, Element),
Updated.
-file("src/lattice_sets/two_p_set.gleam", 87).
?DOC(
" Remove an element and return both the new state and a delta.\n"
"\n"
" The returned delta is a `TwoPSet` whose `removed` set contains only the\n"
" tombstoned element and whose `added` set is empty. Merging the delta into\n"
" a remote via `merge` propagates the tombstone, deactivating the element\n"
" in the remote replica regardless of its prior state.\n"
).
-spec remove_with_delta(two_p_set(EKA), EKA) -> {two_p_set(EKA), two_p_set(EKA)}.
remove_with_delta(Tpset, Element) ->
Updated = {two_p_set,
erlang:element(2, Tpset),
gleam@set:insert(erlang:element(3, Tpset), Element)},
Delta = {two_p_set, gleam@set:new(), gleam@set:from_list([Element])},
{Updated, Delta}.
-file("src/lattice_sets/two_p_set.gleam", 76).
?DOC(
" Remove an element from the set by adding it to the tombstone set.\n"
"\n"
" Once tombstoned, the element is permanently inactive. Removing an element\n"
" that was never added is also valid and creates a preemptive tombstone.\n"
"\n"
" See `remove_with_delta` for the delta-state variant.\n"
).
-spec remove(two_p_set(EJX), EJX) -> two_p_set(EJX).
remove(Tpset, Element) ->
{Updated, _} = remove_with_delta(Tpset, Element),
Updated.
-file("src/lattice_sets/two_p_set.gleam", 100).
?DOC(
" Check if the set currently contains the given element.\n"
"\n"
" Returns `True` only if `element` is in `added` and NOT in `removed`.\n"
).
-spec contains(two_p_set(EKE), EKE) -> boolean().
contains(Tpset, Element) ->
gleam@set:contains(erlang:element(2, Tpset), Element) andalso not gleam@set:contains(
erlang:element(3, Tpset),
Element
).
-file("src/lattice_sets/two_p_set.gleam", 108).
?DOC(
" Return the set of all currently active elements.\n"
"\n"
" Active elements are those in `added` that have not been tombstoned.\n"
" Equivalent to `added ∖ removed`.\n"
).
-spec value(two_p_set(EKG)) -> gleam@set:set(EKG).
value(Tpset) ->
gleam@set:filter(
erlang:element(2, Tpset),
fun(Element) ->
not gleam@set:contains(erlang:element(3, Tpset), Element)
end
).
-file("src/lattice_sets/two_p_set.gleam", 116).
?DOC(
" Merge two 2P-Sets by taking the union of both added sets and both removed sets.\n"
"\n"
" A tombstone on any replica propagates to all replicas after merge.\n"
" Merge is commutative, associative, and idempotent (a valid CRDT join).\n"
).
-spec merge(two_p_set(EKJ), two_p_set(EKJ)) -> two_p_set(EKJ).
merge(A, B) ->
{two_p_set,
gleam@set:union(erlang:element(2, A), erlang:element(2, B)),
gleam@set:union(erlang:element(3, A), erlang:element(3, B))}.
-file("src/lattice_sets/two_p_set.gleam", 128).
?DOC(
" Encode a `TwoPSet(String)` as a self-describing JSON value.\n"
"\n"
" Format: `{\"type\": \"two_p_set\", \"v\": 1, \"state\": {\"added\": [...], \"removed\": [...]}}`\n"
"\n"
" The encoded value can be restored with `from_json`.\n"
).
-spec to_json(two_p_set(binary())) -> gleam@json:json().
to_json(Tpset) ->
gleam@json:object(
[{<<"type"/utf8>>, gleam@json:string(<<"two_p_set"/utf8>>)},
{<<"v"/utf8>>, gleam@json:int(1)},
{<<"state"/utf8>>,
gleam@json:object(
[{<<"added"/utf8>>,
gleam@json:array(
gleam@set:to_list(erlang:element(2, Tpset)),
fun gleam@json:string/1
)},
{<<"removed"/utf8>>,
gleam@json:array(
gleam@set:to_list(erlang:element(3, Tpset)),
fun gleam@json:string/1
)}]
)}]
).
-file("src/lattice_sets/two_p_set.gleam", 146).
?DOC(
" Decode a `TwoPSet(String)` from a JSON string produced by `to_json`.\n"
"\n"
" Returns `Error` if the string is not valid JSON or does not match the\n"
" expected format.\n"
).
-spec from_json(binary()) -> {ok, two_p_set(binary())} |
{error, gleam@json:decode_error()}.
from_json(Json_string) ->
State_decoder = begin
gleam@dynamic@decode:field(
<<"state"/utf8>>,
begin
gleam@dynamic@decode:field(
<<"added"/utf8>>,
gleam@dynamic@decode:list(
{decoder, fun gleam@dynamic@decode:decode_string/1}
),
fun(Added) ->
gleam@dynamic@decode:field(
<<"removed"/utf8>>,
gleam@dynamic@decode:list(
{decoder,
fun gleam@dynamic@decode:decode_string/1}
),
fun(Removed) ->
gleam@dynamic@decode:success(
{two_p_set,
gleam@set:from_list(Added),
gleam@set:from_list(Removed)}
)
end
)
end
)
end,
fun(State) -> gleam@dynamic@decode:success(State) end
)
end,
Envelope_decoder = begin
gleam@dynamic@decode:field(
<<"type"/utf8>>,
{decoder, fun gleam@dynamic@decode:decode_string/1},
fun(Type_tag) ->
gleam@dynamic@decode:field(
<<"v"/utf8>>,
{decoder, fun gleam@dynamic@decode:decode_int/1},
fun(Version) ->
gleam@dynamic@decode:success({Type_tag, Version})
end
)
end
)
end,
case gleam@json:parse(Json_string, Envelope_decoder) of
{error, E} ->
{error, E};
{ok, {Type_tag@1, Version@1}} ->
case (Type_tag@1 =:= <<"two_p_set"/utf8>>) andalso (Version@1 =:= 1) of
true ->
gleam@json:parse(Json_string, State_decoder);
false ->
{error,
{unable_to_decode,
[{decode_error,
<<"type=two_p_set and v=1"/utf8>>,
<<<<Type_tag@1/binary, " v="/utf8>>/binary,
(erlang:integer_to_binary(Version@1))/binary>>,
[]}]}}
end
end.