Packages
macula
0.26.0
7.1.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_registry_system/macula_registry_store.erl
%%%-------------------------------------------------------------------
%%% @doc Macula Registry Store
%%%
%%% Handles local package storage with ETS index and disk persistence:
%%% - Package metadata stored in ETS for fast lookups
%%% - BEAM archives stored on disk
%%% - TTL-based cleanup for stale entries
%%% - DHT integration for distributed discovery
%%%
%%% @end
%%%-------------------------------------------------------------------
-module(macula_registry_store).
-behaviour(gen_server).
%% API
-export([start_link/1]).
-export([store_package/2, get_package/2, get_package/3]).
-export([list_packages/1, search_packages/2]).
-export([delete_package/3, package_exists/3]).
-export([get_versions/2, get_latest_version/2]).
-export([prune_expired/1]).
%% gen_server callbacks
-export([init/1, handle_call/3, handle_cast/2, handle_info/2,
terminate/2, code_change/3]).
-include_lib("kernel/include/logger.hrl").
-define(SERVER, ?MODULE).
-define(PACKAGE_TABLE, macula_registry_packages).
-define(INDEX_TABLE, macula_registry_index).
-define(DEFAULT_TTL, 86400000). % 24 hours in ms
-define(CLEANUP_INTERVAL, 300000). % 5 minutes
-record(state, {
storage_path :: file:filename(),
package_table :: ets:tid(),
index_table :: ets:tid(),
default_ttl :: pos_integer(),
cleanup_timer :: reference() | undefined
}).
-record(package_entry, {
key :: {binary(), binary()}, % {PackageName, Version}
manifest :: map(),
checksum :: binary(),
signature :: binary(),
public_key :: binary(),
published_at :: integer(),
expires_at :: integer(),
archive_path :: file:filename()
}).
-record(index_entry, {
package_name :: binary(),
versions :: [binary()],
latest :: binary()
}).
%%%===================================================================
%%% API functions
%%%===================================================================
%% @doc Start the registry store
-spec start_link(Config :: map()) -> {ok, pid()} | {error, term()}.
start_link(Config) ->
gen_server:start_link({local, ?SERVER}, ?MODULE, Config, []).
%% @doc Store a package in the registry
-spec store_package(pid(), map()) -> {ok, binary()} | {error, term()}.
store_package(Pid, PackageData) ->
gen_server:call(Pid, {store_package, PackageData}, 30000).
%% @doc Get a package by name (latest version)
-spec get_package(pid(), binary()) -> {ok, map()} | {error, not_found}.
get_package(Pid, PackageName) ->
gen_server:call(Pid, {get_package, PackageName, latest}).
%% @doc Get a package by name and version
-spec get_package(pid(), binary(), binary()) -> {ok, map()} | {error, not_found}.
get_package(Pid, PackageName, Version) ->
gen_server:call(Pid, {get_package, PackageName, Version}).
%% @doc List all packages
-spec list_packages(pid()) -> [map()].
list_packages(Pid) ->
gen_server:call(Pid, list_packages).
%% @doc Search packages by pattern
-spec search_packages(pid(), binary()) -> [map()].
search_packages(Pid, Pattern) ->
gen_server:call(Pid, {search_packages, Pattern}).
%% @doc Delete a package version
-spec delete_package(pid(), binary(), binary()) -> ok | {error, not_found}.
delete_package(Pid, PackageName, Version) ->
gen_server:call(Pid, {delete_package, PackageName, Version}).
%% @doc Check if a package version exists
-spec package_exists(pid(), binary(), binary()) -> boolean().
package_exists(Pid, PackageName, Version) ->
gen_server:call(Pid, {package_exists, PackageName, Version}).
%% @doc Get all versions of a package
-spec get_versions(pid(), binary()) -> [binary()].
get_versions(Pid, PackageName) ->
gen_server:call(Pid, {get_versions, PackageName}).
%% @doc Get the latest version of a package
-spec get_latest_version(pid(), binary()) -> {ok, binary()} | {error, not_found}.
get_latest_version(Pid, PackageName) ->
gen_server:call(Pid, {get_latest_version, PackageName}).
%% @doc Remove expired packages
-spec prune_expired(pid()) -> {ok, non_neg_integer()}.
prune_expired(Pid) ->
gen_server:call(Pid, prune_expired).
%%%===================================================================
%%% gen_server callbacks
%%%===================================================================
%% @private
init(Config) ->
process_flag(trap_exit, true),
StoragePath = get_storage_path(Config),
ok = ensure_storage_dir(StoragePath),
PackageTable = ets:new(?PACKAGE_TABLE, [
set, protected, named_table,
{keypos, #package_entry.key}
]),
IndexTable = ets:new(?INDEX_TABLE, [
set, protected, named_table,
{keypos, #index_entry.package_name}
]),
TTL = maps:get(default_ttl, Config, ?DEFAULT_TTL),
%% Load existing packages from disk
ok = load_packages_from_disk(StoragePath, PackageTable, IndexTable, TTL),
%% Start cleanup timer
Timer = erlang:send_after(?CLEANUP_INTERVAL, self(), cleanup),
{ok, #state{
storage_path = StoragePath,
package_table = PackageTable,
index_table = IndexTable,
default_ttl = TTL,
cleanup_timer = Timer
}}.
%% @private
handle_call({store_package, PackageData}, _From, State) ->
Result = do_store_package(PackageData, State),
{reply, Result, State};
handle_call({get_package, PackageName, latest}, _From, State) ->
Result = do_get_latest_package(PackageName, State),
{reply, Result, State};
handle_call({get_package, PackageName, Version}, _From, State) ->
Result = do_get_package(PackageName, Version, State),
{reply, Result, State};
handle_call(list_packages, _From, State) ->
Result = do_list_packages(State),
{reply, Result, State};
handle_call({search_packages, Pattern}, _From, State) ->
Result = do_search_packages(Pattern, State),
{reply, Result, State};
handle_call({delete_package, PackageName, Version}, _From, State) ->
Result = do_delete_package(PackageName, Version, State),
{reply, Result, State};
handle_call({package_exists, PackageName, Version}, _From, State) ->
Result = ets:member(State#state.package_table, {PackageName, Version}),
{reply, Result, State};
handle_call({get_versions, PackageName}, _From, State) ->
Result = do_get_versions(PackageName, State),
{reply, Result, State};
handle_call({get_latest_version, PackageName}, _From, State) ->
case ets:lookup(State#state.index_table, PackageName) of
[#index_entry{latest = Latest}] -> {reply, {ok, Latest}, State};
[] -> {reply, {error, not_found}, State}
end;
handle_call(prune_expired, _From, State) ->
Count = do_prune_expired(State),
{reply, {ok, Count}, State};
handle_call(_Request, _From, State) ->
{reply, {error, unknown_request}, State}.
%% @private
handle_cast(_Msg, State) ->
{noreply, State}.
%% @private
handle_info(cleanup, State) ->
_Count = do_prune_expired(State),
Timer = erlang:send_after(?CLEANUP_INTERVAL, self(), cleanup),
{noreply, State#state{cleanup_timer = Timer}};
handle_info(_Info, State) ->
{noreply, State}.
%% @private
terminate(_Reason, State) ->
case State#state.cleanup_timer of
undefined -> ok;
Timer -> erlang:cancel_timer(Timer)
end,
ok.
%% @private
code_change(_OldVsn, State, _Extra) ->
{ok, State}.
%%%===================================================================
%%% Internal functions
%%%===================================================================
%% @private Get storage path from config
%% Priority: Config map > application env > env var > $HOME/.macula/registry
get_storage_path(Config) ->
case maps:get(storage_path, Config, undefined) of
undefined ->
case application:get_env(macula, registry_storage_path) of
{ok, AppPath} -> AppPath;
undefined ->
case os:getenv("MACULA_REGISTRY_STORAGE_PATH") of
false ->
Home = os:getenv("HOME", "/tmp"),
filename:join([Home, ".macula", "registry"]);
EnvPath -> EnvPath
end
end;
Path -> Path
end.
%% @private Ensure storage directory exists
ensure_storage_dir(Path) ->
case filelib:ensure_dir(filename:join(Path, "dummy")) of
ok -> ok;
{error, Reason} ->
?LOG_WARNING("[RegistryStore] Cannot create storage dir ~s: ~p", [Path, Reason]),
ok % Continue anyway - may be read-only
end.
%% @private Store a package
do_store_package(PackageData, State) ->
#{
package_name := PackageName,
version := Version,
manifest := Manifest,
beam_archive := BeamArchive,
signature := Signature,
public_key := PublicKey
} = PackageData,
Checksum = crypto:hash(sha256, BeamArchive),
Now = erlang:system_time(millisecond),
ExpiresAt = Now + State#state.default_ttl,
%% Save archive to disk
ArchivePath = archive_path(State#state.storage_path, PackageName, Version),
case save_archive(ArchivePath, BeamArchive) of
ok ->
%% Store metadata in ETS
Entry = #package_entry{
key = {PackageName, Version},
manifest = Manifest,
checksum = Checksum,
signature = Signature,
public_key = PublicKey,
published_at = Now,
expires_at = ExpiresAt,
archive_path = ArchivePath
},
ets:insert(State#state.package_table, Entry),
%% Update index
update_index(State#state.index_table, PackageName, Version),
?LOG_INFO("[RegistryStore] Stored package ~s v~s", [PackageName, Version]),
{ok, Checksum};
{error, Reason} ->
{error, {storage_failed, Reason}}
end.
%% @private Get latest package
do_get_latest_package(PackageName, State) ->
case ets:lookup(State#state.index_table, PackageName) of
[#index_entry{latest = Latest}] ->
do_get_package(PackageName, Latest, State);
[] ->
{error, not_found}
end.
%% @private Get package by version
do_get_package(PackageName, Version, State) ->
case ets:lookup(State#state.package_table, {PackageName, Version}) of
[Entry] ->
case load_archive(Entry#package_entry.archive_path) of
{ok, BeamArchive} ->
{ok, #{
package_name => PackageName,
version => Version,
manifest => Entry#package_entry.manifest,
beam_archive => BeamArchive,
checksum => Entry#package_entry.checksum,
signature => Entry#package_entry.signature,
public_key => Entry#package_entry.public_key,
published_at => Entry#package_entry.published_at
}};
{error, _Reason} ->
%% Archive missing from disk
{error, not_found}
end;
[] ->
{error, not_found}
end.
%% @private List all packages
do_list_packages(State) ->
ets:foldl(fun(#index_entry{package_name = Name, versions = Versions, latest = Latest}, Acc) ->
[#{name => Name, versions => Versions, latest => Latest} | Acc]
end, [], State#state.index_table).
%% @private Search packages by pattern
do_search_packages(Pattern, State) ->
%% Simple prefix/substring matching
PatternStr = binary_to_list(Pattern),
ets:foldl(fun(#index_entry{package_name = Name} = Entry, Acc) ->
NameStr = binary_to_list(Name),
case string:find(NameStr, PatternStr) of
nomatch -> Acc;
_ -> [#{name => Name,
versions => Entry#index_entry.versions,
latest => Entry#index_entry.latest} | Acc]
end
end, [], State#state.index_table).
%% @private Delete package version
do_delete_package(PackageName, Version, State) ->
case ets:lookup(State#state.package_table, {PackageName, Version}) of
[Entry] ->
%% Delete archive from disk
file:delete(Entry#package_entry.archive_path),
%% Remove from ETS
ets:delete(State#state.package_table, {PackageName, Version}),
%% Update index
remove_from_index(State#state.index_table, PackageName, Version),
?LOG_INFO("[RegistryStore] Deleted package ~s v~s", [PackageName, Version]),
ok;
[] ->
{error, not_found}
end.
%% @private Get all versions
do_get_versions(PackageName, State) ->
case ets:lookup(State#state.index_table, PackageName) of
[#index_entry{versions = Versions}] -> Versions;
[] -> []
end.
%% @private Prune expired packages
do_prune_expired(State) ->
Now = erlang:system_time(millisecond),
Expired = ets:foldl(fun(#package_entry{key = Key, expires_at = ExpiresAt}, Acc) ->
case ExpiresAt < Now of
true -> [Key | Acc];
false -> Acc
end
end, [], State#state.package_table),
lists:foreach(fun({PackageName, Version}) ->
do_delete_package(PackageName, Version, State)
end, Expired),
length(Expired).
%% @private Update package index
update_index(IndexTable, PackageName, Version) ->
case ets:lookup(IndexTable, PackageName) of
[#index_entry{versions = Versions} = Entry] ->
NewVersions = lists:usort([Version | Versions]),
NewLatest = find_latest_version(NewVersions),
ets:insert(IndexTable, Entry#index_entry{
versions = NewVersions,
latest = NewLatest
});
[] ->
ets:insert(IndexTable, #index_entry{
package_name = PackageName,
versions = [Version],
latest = Version
})
end.
%% @private Remove version from index
remove_from_index(IndexTable, PackageName, Version) ->
case ets:lookup(IndexTable, PackageName) of
[#index_entry{versions = Versions}] ->
NewVersions = lists:delete(Version, Versions),
case NewVersions of
[] ->
ets:delete(IndexTable, PackageName);
_ ->
NewLatest = find_latest_version(NewVersions),
ets:insert(IndexTable, #index_entry{
package_name = PackageName,
versions = NewVersions,
latest = NewLatest
})
end;
[] ->
ok
end.
%% @private Find latest version using SemVer comparison
find_latest_version([]) -> undefined;
find_latest_version([V]) -> V;
find_latest_version(Versions) ->
lists:foldl(fun(V, Max) ->
case macula_registry_manifest:compare_versions(V, Max) of
gt -> V;
_ -> Max
end
end, hd(Versions), tl(Versions)).
%% @private Build archive path
archive_path(StoragePath, PackageName, Version) ->
FileName = iolist_to_binary([PackageName, "-", Version, ".tar.gz"]),
filename:join([StoragePath, "packages", FileName]).
%% @private Save archive to disk
save_archive(Path, Data) ->
ok = filelib:ensure_dir(Path),
file:write_file(Path, Data).
%% @private Load archive from disk
load_archive(Path) ->
file:read_file(Path).
%% @private Load existing packages from disk
%% Note: PackageTable, IndexTable, and TTL are reserved for future implementation
%% when we add full disk-to-ETS loading capability
load_packages_from_disk(StoragePath, _PackageTable, _IndexTable, _TTL) ->
PackagesDir = filename:join(StoragePath, "packages"),
case filelib:is_dir(PackagesDir) of
true ->
?LOG_INFO("[RegistryStore] Loading packages from ~s", [PackagesDir]),
%% In a real implementation, we'd parse .tar.gz files to extract metadata
%% For now, we start with an empty registry
ok;
false ->
ok
end.