storage: add behavior boundary and postgres adapter skeleton

This commit is contained in:
2026-03-13 20:20:58 +01:00
parent 307372fdfe
commit 7ec588805b
13 changed files with 365 additions and 1 deletions

View File

@@ -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

View File

@@ -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
View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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