Packages
macula
0.15.0
7.0.0
6.0.0
5.2.2
5.2.1
5.2.0
5.1.0
5.0.0
4.8.0
4.7.1
4.7.0
4.6.0
4.5.0
4.4.10
4.4.9
4.4.8
4.4.7
4.4.6
4.4.5
4.4.4
4.4.3
4.4.2
4.4.1
4.4.0
4.3.1
4.3.0
4.2.9
4.2.8
4.2.7
4.2.6
4.2.5
4.2.4
4.2.3
4.2.2
4.2.1
4.2.0
4.1.1
4.1.0
4.0.0
3.16.0
3.15.3
3.15.2
3.15.1
3.14.0
3.13.0
3.12.1
3.12.0
3.11.1
3.11.0
3.10.3
3.10.2
3.10.1
3.9.0
3.8.0
3.7.0
3.5.0
3.4.0
3.3.0
3.2.0
3.1.0
3.0.0
2.1.1
2.1.0
2.0.0
1.5.2
1.5.1
1.4.30
1.4.29
1.4.28
1.4.27
1.4.26
1.4.25
1.4.24
1.4.23
1.4.22
1.4.21
1.4.20
1.4.19
1.4.18
1.4.17
1.4.16
1.4.15
1.4.14
1.4.13
1.4.11
1.4.10
1.4.9
1.4.8
1.4.7
1.4.6
1.4.5
1.4.4
1.4.3
1.4.2
1.4.1
1.4.0
1.3.1
1.3.0
1.2.0
1.1.0
1.0.10
1.0.9
1.0.8
1.0.7
1.0.6
1.0.5
1.0.4
1.0.3
1.0.2
1.0.1
1.0.0
0.48.6
0.48.5
0.48.4
0.48.3
0.48.2
0.48.1
0.48.0
0.47.1
0.47.0
0.46.3
0.46.1
0.46.0
0.45.3
0.45.2
0.45.1
0.45.0
0.44.2
0.44.1
0.44.0
0.43.3
0.43.2
0.43.1
0.43.0
0.42.9
0.42.8
0.42.7
0.42.6
0.42.5
0.42.4
0.42.3
0.42.2
0.42.1
0.42.0
0.41.1
0.41.0
0.40.1
0.40.0
0.39.9
0.39.8
0.39.7
0.39.6
0.39.5
0.39.4
0.39.3
0.39.2
0.39.1
0.39.0
0.38.8
0.38.7
0.38.6
0.38.5
0.38.4
0.38.3
0.38.2
0.38.1
0.38.0
0.37.7
0.37.6
0.37.5
0.37.4
0.37.3
0.37.2
0.37.1
0.37.0
0.36.6
0.36.5
0.36.4
0.36.3
0.36.2
0.36.1
0.36.0
0.35.4
0.35.3
0.35.2
0.35.1
0.35.0
0.34.1
0.34.0
0.33.1
0.33.0
0.32.5
0.32.4
0.32.3
0.32.2
0.32.1
0.32.0
0.31.9
0.31.8
0.31.7
0.31.6
0.31.5
0.31.4
0.31.3
0.31.2
0.31.1
0.31.0
0.30.10
0.30.9
0.30.8
0.30.7
0.30.6
0.30.5
0.30.4
0.30.3
0.30.2
0.30.1
0.30.0
0.29.0
0.28.3
0.28.2
0.28.1
0.28.0
0.27.1
0.27.0
0.26.1
0.26.0
0.25.6
0.25.5
0.25.4
0.25.3
0.25.2
0.25.1
0.25.0
0.24.6
0.24.5
0.24.4
0.24.3
0.24.2
0.24.1
0.24.0
0.23.3
0.23.2
0.23.1
0.23.0
0.22.12
0.22.11
0.22.10
0.22.9
0.22.8
0.22.7
0.22.6
0.22.5
0.22.4
0.22.3
0.22.2
0.22.1
0.22.0
0.21.7
0.21.6
0.21.5
0.21.4
0.21.2
0.21.1
0.21.0
0.20.25
0.20.24
0.20.23
0.20.22
0.20.21
0.20.20
0.20.19
0.20.18
0.20.17
0.20.16
0.20.15
0.20.14
0.20.13
0.20.12
0.20.11
0.20.10
0.20.9
0.20.8
0.20.7
0.20.6
0.20.5
0.20.3
0.20.2
0.20.1
0.20.0
0.19.2
0.19.1
0.19.0
0.18.1
0.18.0
0.17.4
0.17.3
0.17.2
0.17.1
0.17.0
0.16.6
0.16.5
0.16.4
0.16.3
0.16.2
0.16.1
0.16.0
0.15.1
0.15.0
0.14.3
0.14.2
0.14.1
0.14.0
0.12.6
0.12.5
0.12.3
0.11.3
0.10.2
0.10.1
0.10.0
0.9.2
0.9.1
0.9.0
0.8.25
0.8.24
0.8.23
0.8.22
0.8.21
0.8.20
0.8.19
0.8.18
0.8.17
0.8.16
0.8.15
0.8.14
0.8.13
0.8.12
0.8.11
0.8.10
0.8.9
0.8.8
0.8.7
0.8.6
0.8.5
0.8.4
0.8.3
0.8.2
0.8.1
0.8.0
0.7.30
0.7.29
0.7.28
0.7.27
0.7.26
0.7.25
0.7.24
0.7.23
0.7.22
0.7.21
0.7.20
0.7.19
0.7.18
0.7.17
0.7.16
0.7.15
0.7.14
0.7.13
0.7.12
0.7.11
0.7.10
0.7.9
0.7.8
0.7.7
0.7.6
0.7.5
0.7.4
0.7.3
0.7.2
0.7.1
0.7.0
0.6.7
0.6.6
0.6.5
0.6.4
0.6.3
0.6.2
0.6.1
0.6.0
0.5.0
0.4.4
0.4.3
0.4.2
0.4.1
0.4.0
0.3.4
0.3.3
0.3.2
0.3.1
Macula HTTP/3 Mesh SDK — connect, subscribe, publish, call, advertise
Current section
Files
Jump to
Current section
Files
src/macula.erl
%%%-------------------------------------------------------------------
%%% @doc
%%% Macula - Main API for distributed workloads on Macula platform.
%%%
%%% This is the ONLY module workload applications should import. It provides
%%% a stable, versioned API for all platform operations:
%%%
%%% - Mesh networking (connect, publish, subscribe, RPC)
%%% - Platform Layer (leader election, CRDTs, workload registration)
%%% - Service discovery (DHT queries, node identity)
%%%
%%% == Quick Start ==
%%%
%%% Connect to local platform and publish events:
%%% ```
%%% {ok, Client} = macula:connect_local(#{realm => <<"my.app">>}),
%%% ok = macula:publish(Client, <<"my.events">>, #{type => <<"test">>}).
%%% '''
%%%
%%% == Architecture ==
%%%
%%% Workload applications run in the same BEAM VM as the Macula platform.
%%% Use `connect_local/1` to connect via process-to-process communication:
%%%
%%% ```
%%% Workload App → macula:connect_local/1 → macula_gateway → Mesh (QUIC/HTTP3)
%%% '''
%%%
%%% == Platform Layer (v0.10.0+) ==
%%%
%%% Register with platform for coordination features:
%%% ```
%%% {ok, #{leader_node := Leader}} = macula:register_workload(Client, #{
%%% workload_name => <<"my_app">>
%%% }).
%%% '''
%%%
%%% == DHT Network Bootstrap ==
%%%
%%% The platform handles DHT bootstrapping via `MACULA_BOOTSTRAP_PEERS`.
%%% Workloads don't need to manage peer discovery.
%%%
%%% @end
%%%-------------------------------------------------------------------
-module(macula).
-behaviour(macula_client_behaviour).
-include_lib("kernel/include/logger.hrl").
%% Mesh Networking API
-export([
connect/2,
connect_local/1,
disconnect/1,
publish/3,
publish/4,
subscribe/3,
unsubscribe/2,
discover_subscribers/2,
call/3,
call/4,
advertise/3,
advertise/4,
unadvertise/2,
get_node_id/1
]).
%% Platform Layer API (v0.10.0+)
-export([
register_workload/2,
get_leader/1,
subscribe_leader_changes/2,
propose_crdt_update/3,
propose_crdt_update/4,
read_crdt/2
]).
%% Type exports
-export_type([
client/0,
topic/0,
event_data/0,
procedure/0,
args/0,
options/0,
subscription_ref/0
]).
%%%===================================================================
%%% Types
%%%===================================================================
-type client() :: pid().
%% Reference to a connected Macula mesh client.
-type topic() :: binary().
%% Topic name for pub/sub operations. Topics should describe event types,
%% not entity IDs. Example: `"my.app.user.registered"' (good),
%% not `"my.app.user.123.registered"' (bad - ID belongs in payload).
-type event_data() :: map() | binary().
%% Event payload data. Typically a map that will be JSON-encoded.
-type procedure() :: binary().
%% RPC procedure name. Example: `"my.app.get_user"'.
-type args() :: map() | list() | binary().
%% Arguments for RPC calls.
-type options() :: map().
%% Connection or operation options.
-type subscription_ref() :: reference().
%% Reference to an active subscription for unsubscribe operations.
%%%===================================================================
%%% API Functions
%%%===================================================================
%% @doc Connect to a Macula mesh network.
%%
%% Creates a new HTTP/3 (QUIC) connection to the specified mesh endpoint.
%%
%% == Options ==
%%
%% <ul>
%% <li>`realm' - Required. Binary realm identifier (e.g., `<<"my.app.realm">>')</li>
%% <li>`auth' - Optional. Authentication map with `api_key' or other auth methods</li>
%% <li>`timeout' - Optional. Connection timeout in milliseconds (default: 5000)</li>
%% <li>`node_id' - Optional. 32-byte node ID (generated if not provided)</li>
%% </ul>
%%
%% == Examples ==
%%
%% ```
%% %% Basic connection
%% {ok, Client} = macula:connect(<<"https://mesh.local:443">>, #{
%% realm => <<"my.realm">>
%% }).
%%
%% %% With API key authentication
%% {ok, Client} = macula:connect(<<"https://mesh.local:443">>, #{
%% realm => <<"my.realm">>,
%% auth => #{api_key => <<"secret-key">>}
%% }).
%% '''
-spec connect(Url :: binary(), Opts :: options()) ->
{ok, client()} | {error, Reason :: term()}.
connect(Url, Opts) when is_binary(Url), is_map(Opts) ->
macula_peer:start_link(Url, Opts);
connect(Url, Opts) when is_list(Url), is_map(Opts) ->
connect(list_to_binary(Url), Opts).
%% @doc Connect to the local Macula gateway (for in-VM workloads).
%%
%% This function is used by applications running in the same BEAM VM as
%% the Macula platform. Instead of creating a QUIC connection to localhost,
%% it connects directly to the local `macula_gateway' process via
%% process-to-process communication.
%%
%% == Architecture ==
%%
%% ```
%% Phoenix/Elixir App → macula_local_client → macula_gateway
%% ↓ (QUIC)
%% Other Peers
%% '''
%%
%% == When to Use ==
%%
%% <ul>
%% <li>✅ Use `connect_local/1' when your application runs in the same VM as Macula</li>
%% <li>✅ Phoenix applications deployed with Macula in the same container</li>
%% <li>❌ Do NOT use `connect/2' with localhost URL - it creates unnecessary QUIC overhead</li>
%% </ul>
%%
%% == Options ==
%%
%% <ul>
%% <li>`realm' - Required. Binary realm identifier (e.g., `<<"my.app.realm">>')</li>
%% <li>`event_handler' - Optional. PID to receive events (default: caller PID)</li>
%% </ul>
%%
%% == Examples ==
%%
%% ```
%% %% Elixir Phoenix application
%% {:ok, client} = :macula.connect_local(%{
%% realm: "macula.arcade.dev"
%% })
%%
%% %% Erlang application
%% {ok, Client} = macula:connect_local(#{
%% realm => <<"my.app.realm">>
%% }).
%% '''
%%
%% @since v0.8.9
-spec connect_local(Opts :: options()) ->
{ok, client()} | {error, Reason :: term()}.
connect_local(Opts) when is_map(Opts) ->
macula_local_client:start_link(Opts).
%% @doc Disconnect from the Macula mesh.
%%
%% Cleanly closes the HTTP/3 connection and cleans up all subscriptions.
-spec disconnect(Client :: client()) -> ok | {error, Reason :: term()}.
disconnect(Client) when is_pid(Client) ->
macula_peer:stop(Client).
%% @doc Publish an event to a topic.
%%
%% Publishes data to the specified topic. All subscribers to this topic
%% will receive the event.
%%
%% == Topic Design ==
%%
%% Topics should describe EVENT TYPES, not entity instances:
%% <ul>
%% <li>Good: `<<"my.app.user.registered">>' (event type)</li>
%% <li>Bad: `<<"my.app.user.123.registered">>' (entity ID in topic)</li>
%% </ul>
%%
%% Entity IDs belong in the event payload, not the topic name.
%%
%% == Examples ==
%%
%% ```
%% %% Publish with default options
%% ok = macula:publish(Client, <<"my.app.events">>, #{
%% type => <<"user.registered">>,
%% user_id => <<"user-123">>,
%% email => <<"user@example.com">>
%% }).
%%
%% %% Publish with options
%% ok = macula:publish(Client, <<"my.app.events">>, #{
%% data => <<"important">>
%% }, #{acknowledge => true}).
%% '''
-spec publish(Client :: client(), Topic :: topic(), Data :: event_data()) ->
ok | {error, Reason :: term()}.
publish(Client, Topic, Data) when is_pid(Client), is_binary(Topic) ->
publish(Client, Topic, Data, #{}).
%% @doc Publish an event with options.
%%
%% This is fire-and-forget - returns ok immediately without blocking.
%% Uses gen_server:cast to avoid blocking the caller (prevents LiveView freezes).
%% Both macula_local_client and macula_peer handle {publish_async, ...} casts.
-spec publish(Client :: client(), Topic :: topic(), Data :: event_data(),
Opts :: options()) ->
ok | {error, Reason :: term()}.
publish(Client, Topic, Data, Opts) when is_pid(Client), is_binary(Topic), is_map(Opts) ->
%% Use cast for async fire-and-forget semantics (prevents UI freezes)
gen_server:cast(Client, {publish_async, Topic, Data, Opts}),
ok.
%% @doc Subscribe to a topic.
%%
%% Subscribes to events on the specified topic. The callback function
%% will be invoked for each event received.
%%
%% == Callback Function ==
%%
%% The callback receives the event data and should return `ok'.
%%
%% == Examples ==
%%
%% ```
%% %% Simple subscription
%% {ok, SubRef} = macula:subscribe(Client, <<"my.app.events">>,
%% fun(EventData) ->
%% io:format("Event: ~p~n", [EventData]),
%% ok
%% end).
%%
%% %% Unsubscribe later
%% ok = macula:unsubscribe(Client, SubRef).
%% '''
-spec subscribe(Client :: client(), Topic :: topic(),
Callback :: fun((event_data()) -> ok)) ->
{ok, subscription_ref()} | {error, Reason :: term()}.
subscribe(Client, Topic, Callback) when is_pid(Client), is_binary(Topic), is_function(Callback, 1) ->
macula_peer:subscribe(Client, Topic, Callback).
%% @doc Unsubscribe from a topic.
%%
%% Removes the subscription identified by the subscription reference.
-spec unsubscribe(Client :: client(), SubRef :: subscription_ref()) ->
ok | {error, Reason :: term()}.
unsubscribe(Client, SubRef) when is_pid(Client), is_reference(SubRef) ->
macula_peer:unsubscribe(Client, SubRef).
%% @doc Discover subscribers to a topic via DHT query.
%%
%% Queries the DHT for all nodes subscribed to the given topic.
%% Returns a list of subscriber nodes with their node IDs and endpoints.
%%
%% This is used for P2P discovery before sending direct messages.
-spec discover_subscribers(Client :: client(), Topic :: topic()) ->
{ok, [#{node_id := binary(), endpoint := binary()}]} | {error, Reason :: term()}.
discover_subscribers(Client, Topic) when is_pid(Client), is_binary(Topic) ->
macula_peer:discover_subscribers(Client, Topic).
%% @doc Get the node ID of this client.
%%
%% Returns the 32-byte node ID assigned to this client.
-spec get_node_id(Client :: client()) -> {ok, binary()} | {error, Reason :: term()}.
get_node_id(Client) when is_pid(Client) ->
macula_peer:get_node_id(Client).
%% @doc Make a synchronous RPC call.
%%
%% Calls a remote procedure and waits for the result.
%%
%% == Examples ==
%%
%% ```
%% %% Simple RPC call
%% {ok, User} = macula:call(Client, <<"my.app.get_user">>, #{
%% user_id => <<"user-123">>
%% }).
%%
%% %% With timeout
%% {ok, Result} = macula:call(Client, <<"my.app.process">>,
%% #{data => <<"large">>},
%% #{timeout => 30000}).
%% '''
-spec call(Client :: client(), Procedure :: procedure(), Args :: args()) ->
{ok, Result :: term()} | {error, Reason :: term()}.
call(Client, Procedure, Args) when is_pid(Client), is_binary(Procedure) ->
macula_peer:call(Client, Procedure, Args).
%% @doc Make an RPC call with options.
-spec call(Client :: client(), Procedure :: procedure(), Args :: args(),
Opts :: options()) ->
{ok, Result :: term()} | {error, Reason :: term()}.
call(Client, Procedure, Args, Opts) when is_pid(Client), is_binary(Procedure), is_map(Opts) ->
macula_peer:call(Client, Procedure, Args, Opts).
%% @doc Advertise a service that this client provides.
%%
%% Registers a handler function for the specified procedure and advertises
%% it to the DHT so other clients can discover and call it.
%%
%% The handler function receives a map of arguments and must return
%% `{ok, Result}' or `{error, Reason}'.
%%
%% == Options ==
%%
%% <ul>
%% <li>`ttl' - Advertisement TTL in seconds (default: 300)</li>
%% <li>`metadata' - Custom metadata map (default: #{})</li>
%% </ul>
%%
%% == Examples ==
%%
%% ```
%% %% Define a handler function
%% Handler = fun(#{user_id := UserId}) ->
%% {ok, #{user_id => UserId, name => <<"Alice">>}}
%% end.
%%
%% %% Advertise the service
%% {ok, Ref} = macula:advertise(
%% Client,
%% <<"my.app.get_user">>,
%% Handler
%% ).
%%
%% %% Other clients can now call:
%% %% {ok, User} = macula:call(OtherClient, <<"my.app.get_user">>,
%% %% #{user_id => <<"user-123">>}).
%% '''
-spec advertise(Client :: client(), Procedure :: procedure(),
Handler :: macula_service_registry:handler_fn()) ->
{ok, reference()} | {error, Reason :: term()}.
advertise(Client, Procedure, Handler) when is_pid(Client), is_binary(Procedure), is_function(Handler) ->
advertise(Client, Procedure, Handler, #{}).
%% @doc Advertise a service with options.
-spec advertise(Client :: client(), Procedure :: procedure(),
Handler :: macula_service_registry:handler_fn(),
Opts :: options()) ->
{ok, reference()} | {error, Reason :: term()}.
advertise(Client, Procedure, Handler, Opts) when is_pid(Client), is_binary(Procedure),
is_function(Handler), is_map(Opts) ->
macula_peer:advertise(Client, Procedure, Handler, Opts).
%% @doc Stop advertising a service.
%%
%% Removes the local handler and stops advertising to the DHT.
%%
%% == Examples ==
%%
%% ```
%% ok = macula:unadvertise(Client, <<"my.app.get_user">>).
%% '''
-spec unadvertise(Client :: client(), Procedure :: procedure()) ->
ok | {error, Reason :: term()}.
unadvertise(Client, Procedure) when is_pid(Client), is_binary(Procedure) ->
macula_peer:unadvertise(Client, Procedure).
%%%===================================================================
%%% Platform Layer API (v0.10.0+)
%%%===================================================================
%% @doc Register this workload with the Platform Layer.
%%
%% Registers the workload application with Macula's Platform Layer and
%% returns information about the current platform cluster state, including
%% the current leader node.
%%
%% == Options ==
%%
%% <ul>
%% <li>`workload_name' - Required. Binary name identifying this workload type
%% (e.g., `<<"macula_arcade">>', `<<"my_app">>')</li>
%% <li>`capabilities' - Optional. List of atoms describing workload capabilities
%% (e.g., `[coordinator, game_server]')</li>
%% </ul>
%%
%% == Returns ==
%%
%% <ul>
%% <li>`leader_node' - Binary node ID of the current Platform Layer leader</li>
%% <li>`cluster_size' - Integer count of nodes in the platform cluster</li>
%% <li>`platform_version' - Binary version string (e.g., `<<"0.10.0">>')</li>
%% </ul>
%%
%% == Examples ==
%%
%% ```
%% {ok, Client} = macula:connect_local(#{realm => <<"my.app">>}),
%% {ok, Info} = macula:register_workload(Client, #{
%% workload_name => <<"my_app_coordinator">>,
%% capabilities => [coordinator, matchmaking]
%% }),
%% #{leader_node := Leader, cluster_size := Size} = Info.
%% '''
%%
%% @since v0.10.0
-spec register_workload(Client :: client(), Opts :: options()) ->
{ok, map()} | {error, Reason :: term()}.
register_workload(Client, Opts) when is_pid(Client), is_map(Opts) ->
macula_local_client:register_workload(Client, Opts).
%% @doc Get the current Platform Layer leader node.
%%
%% Queries the Platform Layer for the current leader node ID. The leader
%% is elected via Raft consensus and handles coordination tasks.
%%
%% Returns `{error, no_leader}' if leader election is in progress.
%%
%% == Examples ==
%%
%% ```
%% case macula:get_leader(Client) of
%% {ok, LeaderNodeId} ->
%% %% Check if we're the leader
%% {ok, OurNodeId} = macula:get_node_id(Client),
%% case LeaderNodeId == OurNodeId of
%% true -> coordinate_globally();
%% false -> defer_to_leader()
%% end;
%% {error, no_leader} ->
%% wait_for_leader_election()
%% end.
%% '''
%%
%% @since v0.10.0
-spec get_leader(Client :: client()) ->
{ok, binary()} | {error, no_leader | term()}.
get_leader(Client) when is_pid(Client) ->
macula_local_client:get_leader(Client).
%% @doc Subscribe to Platform Layer leader change notifications.
%%
%% Registers a callback function to be invoked whenever the Platform Layer
%% leader changes due to election or node failure.
%%
%% The callback receives a map with:
%% <ul>
%% <li>`old_leader' - Previous leader node ID (may be `undefined')</li>
%% <li>`new_leader' - New leader node ID</li>
%% <li>`term' - Raft term number (monotonically increasing)</li>
%% </ul>
%%
%% == Examples ==
%%
%% ```
%% {ok, SubRef} = macula:subscribe_leader_changes(Client,
%% fun(#{old_leader := Old, new_leader := New}) ->
%% io:format("Leader changed: ~p -> ~p~n", [Old, New]),
%% handle_leadership_transition(New),
%% ok
%% end).
%% '''
%%
%% @since v0.10.0
-spec subscribe_leader_changes(Client :: client(), Callback :: fun((map()) -> ok)) ->
{ok, subscription_ref()} | {error, Reason :: term()}.
subscribe_leader_changes(Client, Callback) when is_pid(Client), is_function(Callback, 1) ->
macula_local_client:subscribe_leader_changes(Client, Callback).
%% @doc Propose a CRDT update to Platform Layer shared state.
%%
%% Updates platform-managed shared state using Conflict-Free Replicated
%% Data Types (CRDTs) for automatic conflict resolution across nodes.
%%
%% Default CRDT type is `lww_register' (Last-Write-Wins Register).
%% See `propose_crdt_update/4' for other CRDT types.
%%
%% == Examples ==
%%
%% ```
%% %% Store simple value (LWW-Register)
%% ok = macula:propose_crdt_update(
%% Client,
%% <<"my.app.config.max_users">>,
%% 1000
%% ).
%%
%% %% Later read it back
%% {ok, 1000} = macula:read_crdt(Client, <<"my.app.config.max_users">>).
%% '''
%%
%% @since v0.10.0
-spec propose_crdt_update(Client :: client(), Key :: binary(), Value :: term()) ->
ok | {error, Reason :: term()}.
propose_crdt_update(Client, Key, Value) when is_pid(Client), is_binary(Key) ->
propose_crdt_update(Client, Key, Value, #{crdt_type => lww_register}).
%% @doc Propose a CRDT update with specific CRDT type.
%%
%% Updates platform-managed shared state using the specified CRDT type
%% for automatic conflict resolution.
%%
%% == CRDT Types ==
%%
%% <ul>
%% <li>`lww_register' - Last-Write-Wins Register (default)
%% <ul><li>Use for: Configuration values, latest status</li>
%% <li>Conflict resolution: Latest timestamp wins</li></ul></li>
%% <li>`g_counter' - Grow-Only Counter
%% <ul><li>Use for: Metrics, totals (never decrease)</li>
%% <li>Operations: increment only</li></ul></li>
%% <li>`pn_counter' - Positive-Negative Counter
%% <ul><li>Use for: Bidirectional counters (can increase/decrease)</li>
%% <li>Operations: increment, decrement</li></ul></li>
%% <li>`g_set' - Grow-Only Set
%% <ul><li>Use for: Accumulating collections (never remove)</li>
%% <li>Operations: add elements only</li></ul></li>
%% <li>`or_set' - Observed-Remove Set
%% <ul><li>Use for: Sets with add/remove operations</li>
%% <li>Operations: add, remove elements</li></ul></li>
%% </ul>
%%
%% == Examples ==
%%
%% ```
%% %% Increment a counter
%% ok = macula:propose_crdt_update(
%% Client,
%% <<"my.app.active_games">>,
%% {increment, 1},
%% #{crdt_type => pn_counter}
%% ).
%%
%% %% Add to a set
%% ok = macula:propose_crdt_update(
%% Client,
%% <<"my.app.player_ids">>,
%% {add, <<"player123">>},
%% #{crdt_type => or_set}
%% ).
%% '''
%%
%% @since v0.10.0
-spec propose_crdt_update(Client :: client(), Key :: binary(), Value :: term(), Opts :: options()) ->
ok | {error, Reason :: term()}.
propose_crdt_update(Client, Key, Value, Opts) when is_pid(Client), is_binary(Key), is_map(Opts) ->
macula_local_client:propose_crdt_update(Client, Key, Value, Opts).
%% @doc Read the current value of a CRDT-managed shared state entry.
%%
%% Reads from the local CRDT replica. The value reflects all converged
%% updates from across the platform cluster.
%%
%% Returns `{error, not_found}' if the key has never been written.
%%
%% == Examples ==
%%
%% ```
%% %% Read LWW-Register value
%% {ok, MaxUsers} = macula:read_crdt(Client, <<"my.app.config.max_users">>).
%%
%% %% Read counter value
%% {ok, GameCount} = macula:read_crdt(Client, <<"my.app.active_games">>).
%%
%% %% Read set value
%% {ok, PlayerSet} = macula:read_crdt(Client, <<"my.app.player_ids">>).
%% '''
%%
%% @since v0.10.0
-spec read_crdt(Client :: client(), Key :: binary()) ->
{ok, term()} | {error, not_found | term()}.
read_crdt(Client, Key) when is_pid(Client), is_binary(Key) ->
macula_local_client:read_crdt(Client, Key).
%%%===================================================================
%%% Internal functions
%%%===================================================================