storage: add behavior boundary and postgres adapter skeleton
This commit is contained in:
@@ -20,7 +20,7 @@ Implementation checklist for Parrhesia relay.
|
||||
|
||||
## Phase 2 — storage boundary + postgres adapter
|
||||
|
||||
- [ ] Define `Parrhesia.Storage.*` behaviors (events/moderation/groups/admin)
|
||||
- [x] Define `Parrhesia.Storage.*` behaviors (events/moderation/groups/admin)
|
||||
- [ ] Implement Postgres adapter modules behind behaviors
|
||||
- [ ] Create migrations for events, tags, moderation, membership
|
||||
- [ ] Implement replaceable/addressable semantics at storage layer
|
||||
|
||||
@@ -19,6 +19,12 @@ config :parrhesia,
|
||||
nip_50_search: false,
|
||||
nip_77_negentropy: false,
|
||||
nip_ee_mls: false
|
||||
],
|
||||
storage: [
|
||||
events: Parrhesia.Storage.Adapters.Postgres.Events,
|
||||
moderation: Parrhesia.Storage.Adapters.Postgres.Moderation,
|
||||
groups: Parrhesia.Storage.Adapters.Postgres.Groups,
|
||||
admin: Parrhesia.Storage.Adapters.Postgres.Admin
|
||||
]
|
||||
|
||||
config :parrhesia, Parrhesia.Web.Endpoint, port: 4000
|
||||
|
||||
63
lib/parrhesia/storage.ex
Normal file
63
lib/parrhesia/storage.ex
Normal file
@@ -0,0 +1,63 @@
|
||||
defmodule Parrhesia.Storage do
|
||||
@moduledoc """
|
||||
Storage boundary entrypoint.
|
||||
|
||||
Domain/runtime code should resolve behavior modules through this module instead of
|
||||
depending on concrete adapter implementations directly.
|
||||
"""
|
||||
|
||||
@default_modules [
|
||||
events: Parrhesia.Storage.Adapters.Postgres.Events,
|
||||
moderation: Parrhesia.Storage.Adapters.Postgres.Moderation,
|
||||
groups: Parrhesia.Storage.Adapters.Postgres.Groups,
|
||||
admin: Parrhesia.Storage.Adapters.Postgres.Admin
|
||||
]
|
||||
|
||||
@spec events() :: module()
|
||||
def events, do: fetch_module!(:events, Parrhesia.Storage.Events)
|
||||
|
||||
@spec moderation() :: module()
|
||||
def moderation, do: fetch_module!(:moderation, Parrhesia.Storage.Moderation)
|
||||
|
||||
@spec groups() :: module()
|
||||
def groups, do: fetch_module!(:groups, Parrhesia.Storage.Groups)
|
||||
|
||||
@spec admin() :: module()
|
||||
def admin, do: fetch_module!(:admin, Parrhesia.Storage.Admin)
|
||||
|
||||
defp fetch_module!(key, behavior) do
|
||||
module =
|
||||
:parrhesia
|
||||
|> Application.get_env(:storage, [])
|
||||
|> Keyword.get(key, Keyword.fetch!(@default_modules, key))
|
||||
|
||||
ensure_behavior!(module, behavior, key)
|
||||
end
|
||||
|
||||
defp ensure_behavior!(module, behavior, key) do
|
||||
case Code.ensure_loaded(module) do
|
||||
{:module, _loaded_module} ->
|
||||
if implements_behavior?(module, behavior) do
|
||||
module
|
||||
else
|
||||
raise ArgumentError,
|
||||
"configured storage module #{inspect(module)} for #{inspect(key)} " <>
|
||||
"does not implement #{inspect(behavior)}"
|
||||
end
|
||||
|
||||
{:error, reason} ->
|
||||
raise ArgumentError,
|
||||
"configured storage module #{inspect(module)} for #{inspect(key)} could not be loaded: " <>
|
||||
"#{inspect(reason)}"
|
||||
end
|
||||
end
|
||||
|
||||
defp implements_behavior?(module, behavior) do
|
||||
module
|
||||
|> module_info_attributes()
|
||||
|> Keyword.get(:behaviour, [])
|
||||
|> Enum.member?(behavior)
|
||||
end
|
||||
|
||||
defp module_info_attributes(module), do: module.module_info(:attributes)
|
||||
end
|
||||
19
lib/parrhesia/storage/adapters/postgres/admin.ex
Normal file
19
lib/parrhesia/storage/adapters/postgres/admin.ex
Normal file
@@ -0,0 +1,19 @@
|
||||
defmodule Parrhesia.Storage.Adapters.Postgres.Admin do
|
||||
@moduledoc """
|
||||
PostgreSQL-backed implementation for `Parrhesia.Storage.Admin`.
|
||||
|
||||
Implementation is intentionally staged; callbacks currently return
|
||||
`{:error, :not_implemented}` until NIP-86 management storage lands.
|
||||
"""
|
||||
|
||||
@behaviour Parrhesia.Storage.Admin
|
||||
|
||||
@impl true
|
||||
def execute(_context, _method, _params), do: {:error, :not_implemented}
|
||||
|
||||
@impl true
|
||||
def append_audit_log(_context, _entry), do: {:error, :not_implemented}
|
||||
|
||||
@impl true
|
||||
def list_audit_logs(_context, _opts), do: {:error, :not_implemented}
|
||||
end
|
||||
31
lib/parrhesia/storage/adapters/postgres/events.ex
Normal file
31
lib/parrhesia/storage/adapters/postgres/events.ex
Normal file
@@ -0,0 +1,31 @@
|
||||
defmodule Parrhesia.Storage.Adapters.Postgres.Events do
|
||||
@moduledoc """
|
||||
PostgreSQL-backed implementation for `Parrhesia.Storage.Events`.
|
||||
|
||||
Implementation is intentionally staged; callbacks currently return
|
||||
`{:error, :not_implemented}` until migrations and query paths land.
|
||||
"""
|
||||
|
||||
@behaviour Parrhesia.Storage.Events
|
||||
|
||||
@impl true
|
||||
def put_event(_context, _event), do: {:error, :not_implemented}
|
||||
|
||||
@impl true
|
||||
def get_event(_context, _event_id), do: {:error, :not_implemented}
|
||||
|
||||
@impl true
|
||||
def query(_context, _filters, _opts), do: {:error, :not_implemented}
|
||||
|
||||
@impl true
|
||||
def count(_context, _filters, _opts), do: {:error, :not_implemented}
|
||||
|
||||
@impl true
|
||||
def delete_by_request(_context, _event), do: {:error, :not_implemented}
|
||||
|
||||
@impl true
|
||||
def vanish(_context, _event), do: {:error, :not_implemented}
|
||||
|
||||
@impl true
|
||||
def purge_expired(_opts), do: {:error, :not_implemented}
|
||||
end
|
||||
31
lib/parrhesia/storage/adapters/postgres/groups.ex
Normal file
31
lib/parrhesia/storage/adapters/postgres/groups.ex
Normal file
@@ -0,0 +1,31 @@
|
||||
defmodule Parrhesia.Storage.Adapters.Postgres.Groups do
|
||||
@moduledoc """
|
||||
PostgreSQL-backed implementation for `Parrhesia.Storage.Groups`.
|
||||
|
||||
Implementation is intentionally staged; callbacks currently return
|
||||
`{:error, :not_implemented}` until group/membership schema lands.
|
||||
"""
|
||||
|
||||
@behaviour Parrhesia.Storage.Groups
|
||||
|
||||
@impl true
|
||||
def put_membership(_context, _membership), do: {:error, :not_implemented}
|
||||
|
||||
@impl true
|
||||
def get_membership(_context, _group_id, _pubkey), do: {:error, :not_implemented}
|
||||
|
||||
@impl true
|
||||
def delete_membership(_context, _group_id, _pubkey), do: {:error, :not_implemented}
|
||||
|
||||
@impl true
|
||||
def list_memberships(_context, _group_id), do: {:error, :not_implemented}
|
||||
|
||||
@impl true
|
||||
def put_role(_context, _role), do: {:error, :not_implemented}
|
||||
|
||||
@impl true
|
||||
def delete_role(_context, _group_id, _pubkey, _role), do: {:error, :not_implemented}
|
||||
|
||||
@impl true
|
||||
def list_roles(_context, _group_id, _pubkey), do: {:error, :not_implemented}
|
||||
end
|
||||
46
lib/parrhesia/storage/adapters/postgres/moderation.ex
Normal file
46
lib/parrhesia/storage/adapters/postgres/moderation.ex
Normal file
@@ -0,0 +1,46 @@
|
||||
defmodule Parrhesia.Storage.Adapters.Postgres.Moderation do
|
||||
@moduledoc """
|
||||
PostgreSQL-backed implementation for `Parrhesia.Storage.Moderation`.
|
||||
|
||||
Implementation is intentionally staged; callbacks currently return
|
||||
`{:error, :not_implemented}` until table design and policy paths land.
|
||||
"""
|
||||
|
||||
@behaviour Parrhesia.Storage.Moderation
|
||||
|
||||
@impl true
|
||||
def ban_pubkey(_context, _pubkey), do: {:error, :not_implemented}
|
||||
|
||||
@impl true
|
||||
def unban_pubkey(_context, _pubkey), do: {:error, :not_implemented}
|
||||
|
||||
@impl true
|
||||
def pubkey_banned?(_context, _pubkey), do: {:error, :not_implemented}
|
||||
|
||||
@impl true
|
||||
def allow_pubkey(_context, _pubkey), do: {:error, :not_implemented}
|
||||
|
||||
@impl true
|
||||
def disallow_pubkey(_context, _pubkey), do: {:error, :not_implemented}
|
||||
|
||||
@impl true
|
||||
def pubkey_allowed?(_context, _pubkey), do: {:error, :not_implemented}
|
||||
|
||||
@impl true
|
||||
def ban_event(_context, _event_id), do: {:error, :not_implemented}
|
||||
|
||||
@impl true
|
||||
def unban_event(_context, _event_id), do: {:error, :not_implemented}
|
||||
|
||||
@impl true
|
||||
def event_banned?(_context, _event_id), do: {:error, :not_implemented}
|
||||
|
||||
@impl true
|
||||
def block_ip(_context, _ip_address), do: {:error, :not_implemented}
|
||||
|
||||
@impl true
|
||||
def unblock_ip(_context, _ip_address), do: {:error, :not_implemented}
|
||||
|
||||
@impl true
|
||||
def ip_blocked?(_context, _ip_address), do: {:error, :not_implemented}
|
||||
end
|
||||
16
lib/parrhesia/storage/admin.ex
Normal file
16
lib/parrhesia/storage/admin.ex
Normal file
@@ -0,0 +1,16 @@
|
||||
defmodule Parrhesia.Storage.Admin do
|
||||
@moduledoc """
|
||||
Storage callbacks used by relay management endpoints (NIP-86 backing).
|
||||
"""
|
||||
|
||||
@type context :: map()
|
||||
@type method :: atom() | binary()
|
||||
@type params :: map()
|
||||
@type result :: map() | list() | term()
|
||||
@type audit_entry :: map()
|
||||
@type reason :: term()
|
||||
|
||||
@callback execute(context(), method(), params()) :: {:ok, result()} | {:error, reason()}
|
||||
@callback append_audit_log(context(), audit_entry()) :: :ok | {:error, reason()}
|
||||
@callback list_audit_logs(context(), keyword()) :: {:ok, [audit_entry()]} | {:error, reason()}
|
||||
end
|
||||
22
lib/parrhesia/storage/events.ex
Normal file
22
lib/parrhesia/storage/events.ex
Normal file
@@ -0,0 +1,22 @@
|
||||
defmodule Parrhesia.Storage.Events do
|
||||
@moduledoc """
|
||||
Storage callbacks for event persistence and query operations.
|
||||
"""
|
||||
|
||||
@type context :: map()
|
||||
@type event_id :: binary()
|
||||
@type event :: map()
|
||||
@type filter :: map()
|
||||
@type query_opts :: keyword()
|
||||
@type count_result :: non_neg_integer() | %{optional(atom()) => term()}
|
||||
@type reason :: term()
|
||||
|
||||
@callback put_event(context(), event()) :: {:ok, event()} | {:error, reason()}
|
||||
@callback get_event(context(), event_id()) :: {:ok, event() | nil} | {:error, reason()}
|
||||
@callback query(context(), [filter()], query_opts()) :: {:ok, [event()]} | {:error, reason()}
|
||||
@callback count(context(), [filter()], query_opts()) ::
|
||||
{:ok, count_result()} | {:error, reason()}
|
||||
@callback delete_by_request(context(), event()) :: {:ok, non_neg_integer()} | {:error, reason()}
|
||||
@callback vanish(context(), event()) :: {:ok, non_neg_integer()} | {:error, reason()}
|
||||
@callback purge_expired(query_opts()) :: {:ok, non_neg_integer()} | {:error, reason()}
|
||||
end
|
||||
22
lib/parrhesia/storage/groups.ex
Normal file
22
lib/parrhesia/storage/groups.ex
Normal file
@@ -0,0 +1,22 @@
|
||||
defmodule Parrhesia.Storage.Groups do
|
||||
@moduledoc """
|
||||
Storage callbacks for NIP-29/NIP-43 group membership and role state.
|
||||
"""
|
||||
|
||||
@type context :: map()
|
||||
@type group_id :: binary()
|
||||
@type pubkey :: binary()
|
||||
@type membership :: map()
|
||||
@type role :: map()
|
||||
@type reason :: term()
|
||||
|
||||
@callback put_membership(context(), membership()) :: {:ok, membership()} | {:error, reason()}
|
||||
@callback get_membership(context(), group_id(), pubkey()) ::
|
||||
{:ok, membership() | nil} | {:error, reason()}
|
||||
@callback delete_membership(context(), group_id(), pubkey()) :: :ok | {:error, reason()}
|
||||
@callback list_memberships(context(), group_id()) :: {:ok, [membership()]} | {:error, reason()}
|
||||
|
||||
@callback put_role(context(), role()) :: {:ok, role()} | {:error, reason()}
|
||||
@callback delete_role(context(), group_id(), pubkey(), binary()) :: :ok | {:error, reason()}
|
||||
@callback list_roles(context(), group_id(), pubkey()) :: {:ok, [role()]} | {:error, reason()}
|
||||
end
|
||||
27
lib/parrhesia/storage/moderation.ex
Normal file
27
lib/parrhesia/storage/moderation.ex
Normal file
@@ -0,0 +1,27 @@
|
||||
defmodule Parrhesia.Storage.Moderation do
|
||||
@moduledoc """
|
||||
Storage callbacks for moderation and access-control state.
|
||||
"""
|
||||
|
||||
@type context :: map()
|
||||
@type pubkey :: binary()
|
||||
@type event_id :: binary()
|
||||
@type ip_address :: binary()
|
||||
@type reason :: term()
|
||||
|
||||
@callback ban_pubkey(context(), pubkey()) :: :ok | {:error, reason()}
|
||||
@callback unban_pubkey(context(), pubkey()) :: :ok | {:error, reason()}
|
||||
@callback pubkey_banned?(context(), pubkey()) :: {:ok, boolean()} | {:error, reason()}
|
||||
|
||||
@callback allow_pubkey(context(), pubkey()) :: :ok | {:error, reason()}
|
||||
@callback disallow_pubkey(context(), pubkey()) :: :ok | {:error, reason()}
|
||||
@callback pubkey_allowed?(context(), pubkey()) :: {:ok, boolean()} | {:error, reason()}
|
||||
|
||||
@callback ban_event(context(), event_id()) :: :ok | {:error, reason()}
|
||||
@callback unban_event(context(), event_id()) :: :ok | {:error, reason()}
|
||||
@callback event_banned?(context(), event_id()) :: {:ok, boolean()} | {:error, reason()}
|
||||
|
||||
@callback block_ip(context(), ip_address()) :: :ok | {:error, reason()}
|
||||
@callback unblock_ip(context(), ip_address()) :: :ok | {:error, reason()}
|
||||
@callback ip_blocked?(context(), ip_address()) :: {:ok, boolean()} | {:error, reason()}
|
||||
end
|
||||
53
test/parrhesia/storage/behaviour_contracts_test.exs
Normal file
53
test/parrhesia/storage/behaviour_contracts_test.exs
Normal file
@@ -0,0 +1,53 @@
|
||||
defmodule Parrhesia.Storage.BehaviourContractsTest do
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
test "events behavior exposes expected callbacks" do
|
||||
assert callback_names(Parrhesia.Storage.Events) ==
|
||||
[:count, :delete_by_request, :get_event, :purge_expired, :put_event, :query, :vanish]
|
||||
end
|
||||
|
||||
test "moderation behavior exposes expected callbacks" do
|
||||
assert callback_names(Parrhesia.Storage.Moderation) ==
|
||||
[
|
||||
:allow_pubkey,
|
||||
:ban_event,
|
||||
:ban_pubkey,
|
||||
:block_ip,
|
||||
:disallow_pubkey,
|
||||
:event_banned?,
|
||||
:ip_blocked?,
|
||||
:pubkey_allowed?,
|
||||
:pubkey_banned?,
|
||||
:unban_event,
|
||||
:unban_pubkey,
|
||||
:unblock_ip
|
||||
]
|
||||
end
|
||||
|
||||
test "groups behavior exposes expected callbacks" do
|
||||
assert callback_names(Parrhesia.Storage.Groups) ==
|
||||
[
|
||||
:delete_membership,
|
||||
:delete_role,
|
||||
:get_membership,
|
||||
:list_memberships,
|
||||
:list_roles,
|
||||
:put_membership,
|
||||
:put_role
|
||||
]
|
||||
end
|
||||
|
||||
test "admin behavior exposes expected callbacks" do
|
||||
assert callback_names(Parrhesia.Storage.Admin) ==
|
||||
[:append_audit_log, :execute, :list_audit_logs]
|
||||
end
|
||||
|
||||
defp callback_names(behavior_module) do
|
||||
behavior_module
|
||||
|> behaviour_callbacks()
|
||||
|> Enum.map(fn {name, _arity} -> name end)
|
||||
|> Enum.sort()
|
||||
end
|
||||
|
||||
defp behaviour_callbacks(behavior_module), do: behavior_module.behaviour_info(:callbacks)
|
||||
end
|
||||
28
test/parrhesia/storage_test.exs
Normal file
28
test/parrhesia/storage_test.exs
Normal file
@@ -0,0 +1,28 @@
|
||||
defmodule Parrhesia.StorageTest do
|
||||
use ExUnit.Case, async: false
|
||||
|
||||
alias Parrhesia.Storage
|
||||
|
||||
test "resolves default storage modules" do
|
||||
assert Storage.events() == Parrhesia.Storage.Adapters.Postgres.Events
|
||||
assert Storage.moderation() == Parrhesia.Storage.Adapters.Postgres.Moderation
|
||||
assert Storage.groups() == Parrhesia.Storage.Adapters.Postgres.Groups
|
||||
assert Storage.admin() == Parrhesia.Storage.Adapters.Postgres.Admin
|
||||
end
|
||||
|
||||
test "raises when configured module does not implement required behavior" do
|
||||
previous = Application.get_env(:parrhesia, :storage, [])
|
||||
|
||||
Application.put_env(:parrhesia, :storage, events: Parrhesia.Config)
|
||||
|
||||
on_exit(fn ->
|
||||
Application.put_env(:parrhesia, :storage, previous)
|
||||
end)
|
||||
|
||||
assert_raise ArgumentError,
|
||||
~r/does not implement Parrhesia\.Storage\.Events/,
|
||||
fn ->
|
||||
Storage.events()
|
||||
end
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user