api-surface #1

Merged
luna merged 51 commits from api-surface into mistress 2026-07-05 14:43:13 +00:00
2 changed files with 141 additions and 0 deletions
Showing only changes of commit c3c483017d - Show all commits

feat(cache): fill/1 hydration for envelope structs

Luna 2026-07-05 07:40:23 -03:00

View file

@ -566,6 +566,44 @@ defmodule Dexcord.Cache do
@spec voice_states(id()) :: [entity()]
def voice_states(guild_id), do: prefix_values(@voice_states, guild_id)
# --- hydration ----------------------------------------------------------
@doc """
Best-effort hydration of an envelope struct's declared `hydrate` slots from
the cache. Each slot is filled from ETS by the id in its `from` field; a cache
miss (or a nil source id) leaves that slot `nil`. A struct with no `hydrate`
declarations is returned unchanged.
This function reads ETS **only** - it never issues HTTP and never blocks on the
network, so it is safe to call from any process on the hot path
(api-surface.AC3.8). It is meant to be called by **user handler code** when a
handler wants the related objects inline; the `Dexcord.Dispatcher` never calls
it (hydration is opt-in, not a cost paid on every event).
Already-populated slots are left alone, so `fill/1` is idempotent.
"""
@spec fill(struct()) :: struct()
def fill(%module{} = event) do
if function_exported?(module, :__hydrations__, 0) do
Enum.reduce(module.__hydrations__(), event, fn h, acc ->
with nil <- Map.fetch!(acc, h.name),
id when not is_nil(id) <- Map.fetch!(acc, h.from),
{:ok, value} <- fill_lookup(h.type, id) do
Map.put(acc, h.name, value)
else
_ -> acc
end
end)
else
event
end
end
defp fill_lookup(Dexcord.User, id), do: user(id)
defp fill_lookup(:channel, id), do: channel(id)
defp fill_lookup(Dexcord.Guild, id), do: guild(id)
defp fill_lookup(_type, _id), do: :error
# --- read helpers -------------------------------------------------------
defp cache_presences?(config), do: Map.get(config, :cache_presences, false)

View file

@ -0,0 +1,103 @@
defmodule Dexcord.CacheFillTest do
@moduledoc """
Unit tests for `Dexcord.Cache.fill/1` (api-surface.AC3.8): best-effort
hydration of an envelope's declared `hydrate` slots from ETS only.
These tests start ONLY `Dexcord.Cache` - no FakeRest, no Finch pool. `fill/1`
reads ETS and nothing else, so any accidental HTTP round-trip would crash on
the missing connection pool; the suite passing is the proof that `fill/1`
never touches the network.
"""
use ExUnit.Case, async: false
alias Dexcord.Cache
alias Dexcord.Events
setup do
Dexcord.EnvSandbox.sandbox_env()
start_supervised!(Dexcord.Cache)
:ok
end
# Seed the cache exactly as the dispatcher would: decode once, hand the cache
# both the decoded struct and the raw partial map.
defp feed(name, raw) do
Cache.handle_dispatch(name, Events.decode(name, raw), raw, %{cache_presences: false})
end
# The golden guild's own ids (see test/fixtures/guild_create.json).
@guild_id 900_000_000_000_000_000
# channel 100000000000000000 is a type-0 text channel.
@channel_id 100_000_000_000_000_000
# member/user 800000000000000000 is Nelly.
@user_id 800_000_000_000_000_000
defp reaction(overrides) do
raw =
Map.merge(
%{
"user_id" => to_string(@user_id),
"channel_id" => to_string(@channel_id),
"message_id" => "100000000000000099",
"guild_id" => to_string(@guild_id),
"emoji" => %{"id" => nil, "name" => "👍"}
},
overrides
)
Events.decode(:MESSAGE_REACTION_ADD, raw)
end
describe "fill/1 hydration (AC3.8)" do
test "populates every declared slot from the cache on a full hit" do
feed(:GUILD_CREATE, Dexcord.Fixtures.load!("guild_create.json"))
event = reaction(%{})
# After decode the hydrate slots are always nil.
assert %Dexcord.Events.ReactionAdd{user: nil, channel: nil, guild: nil} = event
filled = Cache.fill(event)
assert %Dexcord.Events.ReactionAdd{
user: %Dexcord.User{id: @user_id},
channel: %Dexcord.TextChannel{id: @channel_id},
guild: %Dexcord.Guild{id: @guild_id}
} = filled
end
test "a cache miss leaves that slot nil while the others still fill" do
feed(:GUILD_CREATE, Dexcord.Fixtures.load!("guild_create.json"))
# Unknown user id: not in the cache.
event = reaction(%{"user_id" => "111111111111111111"})
filled = Cache.fill(event)
assert filled.user == nil
assert %Dexcord.TextChannel{id: @channel_id} = filled.channel
assert %Dexcord.Guild{id: @guild_id} = filled.guild
end
test "an unseeded cache leaves every slot nil (never blocks on the network)" do
# No GUILD_CREATE seeded and, deliberately, no FakeRest/Finch started: if
# fill/1 ever reached for HTTP it would crash here instead of returning nils.
filled = Cache.fill(reaction(%{}))
assert %Dexcord.Events.ReactionAdd{user: nil, channel: nil, guild: nil} = filled
end
test "fill/1 is idempotent" do
feed(:GUILD_CREATE, Dexcord.Fixtures.load!("guild_create.json"))
event = reaction(%{})
once = Cache.fill(event)
assert Cache.fill(once) == once
end
test "a struct with no hydration slots passes through unchanged" do
user = %Dexcord.User{id: @user_id, username: "nelly"}
assert Cache.fill(user) == user
end
end
end