Packages
macula
0.30.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,
call_to/4,
call_to/5,
advertise/3,
advertise/4,
unadvertise/2,
get_node_id/1,
get_known_peers/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
]).
%% Cluster API (v0.16.3+) - For bc_gitops integration
-export([
ensure_distributed/0,
get_cookie/0,
set_cookie/1,
monitor_nodes/0,
unmonitor_nodes/0
]).
%% 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 Get known peers from the DHT routing table.
%%
%% Returns peers discovered via Kademlia bootstrap lookup (FIND_NODE).
%% These are addresses the node knows about — not necessarily active connections.
%%
%% == Examples ==
%%
%% ```
%% {ok, Peers} = macula:get_known_peers(Client).
%% %% Peers = [#{node_id => <<...>>, endpoint => <<"10.0.0.1:9443">>}, ...]
%% '''
%% @end
-spec get_known_peers(Client :: client()) -> {ok, [map()]} | {error, Reason :: term()}.
get_known_peers(Client) when is_pid(Client) ->
get_known_peers_from_routing_table(Client).
get_known_peers_from_routing_table(Client) ->
case macula:get_node_id(Client) of
{ok, NodeId} ->
get_known_peers_with_node_id(NodeId);
{error, Reason} ->
{error, Reason}
end.
get_known_peers_with_node_id(NodeId) ->
case whereis(macula_routing_server) of
undefined ->
{ok, []};
RoutingServer ->
Nodes = macula_routing_server:find_closest(RoutingServer, NodeId, 100),
Peers = lists:filtermap(fun(N) -> format_peer_node(N, NodeId) end, Nodes),
{ok, Peers}
end.
format_peer_node(NodeInfo, MyNodeId) when is_map(NodeInfo) ->
PeerNodeId = maps:get(node_id, NodeInfo, undefined),
format_peer_node_id(PeerNodeId, MyNodeId, NodeInfo);
format_peer_node(_, _) ->
false.
format_peer_node_id(undefined, _, _) -> false;
format_peer_node_id(MyId, MyId, _) -> false;
format_peer_node_id(PeerNodeId, _, NodeInfo) ->
Endpoint = maps:get(endpoint, NodeInfo, maps:get(address, NodeInfo, undefined)),
HexId = case PeerNodeId of
B when is_binary(B), byte_size(B) =:= 32 -> binary:encode_hex(B);
B when is_binary(B) -> B;
_ -> null
end,
{true, #{node_id => HexId, endpoint => Endpoint}}.
%% @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 Make an RPC call to a specific target node.
%%
%% Unlike `call/4' which discovers any provider via DHT, this function
%% sends the RPC directly to the specified target node. Use this when you
%% already know which node provides the service (e.g., from a previous
%% DHT discovery, a specific publisher node, or direct node advertisement).
%%
%% The message is still routed via DHT infrastructure (for NAT traversal,
%% relay fallback, etc.), but it targets a specific node rather than
%% discovering one.
%%
%% == Examples ==
%%
%% ```
%% %% Call a specific node (e.g., publisher node for package pull)
%% PublisherNodeId = <<...32 bytes...>>,
%% {ok, Manifest} = macula:call_to(Client, PublisherNodeId,
%% <<"packages.manifest.fetch">>,
%% #{image_ref => <<"my.app:1.0.0">>}).
%%
%% %% With timeout
%% {ok, Result} = macula:call_to(Client, TargetNodeId,
%% <<"my.procedure">>, Args,
%% #{timeout => 30000}).
%% '''
-spec call_to(Client :: client(), TargetNodeId :: binary(), Procedure :: procedure(), Args :: args()) ->
{ok, Result :: term()} | {error, Reason :: term()}.
call_to(Client, TargetNodeId, Procedure, Args) when is_pid(Client), is_binary(TargetNodeId), is_binary(Procedure) ->
call_to(Client, TargetNodeId, Procedure, Args, #{}).
%% @doc Make an RPC call to a specific target node with options.
-spec call_to(Client :: client(), TargetNodeId :: binary(), Procedure :: procedure(), Args :: args(),
Opts :: options()) ->
{ok, Result :: term()} | {error, Reason :: term()}.
call_to(Client, TargetNodeId, Procedure, Args, Opts) when is_pid(Client), is_binary(TargetNodeId), is_binary(Procedure), is_map(Opts) ->
macula_peer:call_to(Client, TargetNodeId, 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).
%%%===================================================================
%%% Cluster API (v0.16.3+)
%%%===================================================================
%% @doc Ensure this node is running in distributed mode.
%%
%% If the node is already distributed, returns `ok' immediately.
%% Otherwise, starts distribution with a generated node name.
%%
%% This function is used by bc_gitops to delegate cluster setup
%% to the Macula platform when available.
%%
%% Examples:
%% ```
%% ok = macula:ensure_distributed().
%% '''
%%
%% @since v0.16.3
-spec ensure_distributed() -> ok | {error, term()}.
ensure_distributed() ->
macula_cluster:ensure_distributed().
%% @doc Get the Erlang cookie for the cluster.
%%
%% Resolves the cookie from various sources in priority order:
%% 1. Application env: `{macula, [{cookie, CookieValue}]}'
%% 2. Environment variable: `MACULA_COOKIE' or `RELEASE_COOKIE'
%% 3. User's ~/.erlang.cookie file
%% 4. Auto-generated (persisted to ~/.erlang.cookie)
%%
%% Examples:
%% ```
%% Cookie = macula:get_cookie().
%% '''
%%
%% @since v0.16.3
-spec get_cookie() -> atom().
get_cookie() ->
macula_cluster:get_cookie().
%% @doc Set the Erlang cookie for this node and persist it.
%%
%% Sets the cookie for the current node and attempts to persist
%% it to ~/.erlang.cookie for future sessions.
%%
%% Examples:
%% ```
%% ok = macula:set_cookie(my_secret_cookie).
%% '''
%%
%% @since v0.16.3
-spec set_cookie(atom() | binary()) -> ok.
set_cookie(Cookie) ->
macula_cluster:set_cookie(Cookie).
%% @doc Subscribe to node up/down events.
%%
%% After calling this function, the calling process will receive
%% `{nodeup, Node}' and `{nodedown, Node}' messages when nodes
%% join or leave the cluster.
%%
%% Examples:
%% ```
%% ok = macula:monitor_nodes().
%% receive
%% {nodeup, Node} -> handle_node_up(Node);
%% {nodedown, Node} -> handle_node_down(Node)
%% end.
%% '''
%%
%% @since v0.16.3
-spec monitor_nodes() -> ok.
monitor_nodes() ->
macula_cluster:monitor_nodes().
%% @doc Unsubscribe from node up/down events.
%%
%% Stops the calling process from receiving nodeup/nodedown messages.
%%
%% Examples:
%% ```
%% ok = macula:unmonitor_nodes().
%% '''
%%
%% @since v0.16.3
-spec unmonitor_nodes() -> ok.
unmonitor_nodes() ->
macula_cluster:unmonitor_nodes().
%%%===================================================================
%%% Internal functions
%%%===================================================================