api-surface #1
2 changed files with 298 additions and 19 deletions
feat(cache): merge_map partial updates, struct-aware cascade purge, typed message fold-in
commit
ceab408ce9
|
|
@ -63,6 +63,7 @@ defmodule Dexcord.Cache do
|
|||
# Guild child collections lifted out of the guild row into their own tables.
|
||||
# `emojis` (and stickers) stay inline on the guild and are replaced wholesale.
|
||||
@guild_child_fields [:channels, :threads, :roles, :members, :presences, :voice_states]
|
||||
@guild_child_keys ~w(channels threads roles members presences voice_states)
|
||||
|
||||
# --- lifecycle ----------------------------------------------------------
|
||||
|
||||
|
|
@ -136,10 +137,58 @@ defmodule Dexcord.Cache do
|
|||
:ok
|
||||
end
|
||||
|
||||
# Task 1 placeholder: full replace (minus child arrays). Task 2 replaces this
|
||||
# with a `merge_map/2` that preserves gateway-only fields like `joined_at`.
|
||||
def handle_dispatch(:GUILD_UPDATE, %Dexcord.Guild{} = guild, _raw, _config) do
|
||||
:ets.insert(@guilds, {guild.id, without_children(guild)})
|
||||
# GUILD_UPDATE carries no child arrays and omits gateway-only fields like
|
||||
# `joined_at`; merge only the keys Discord actually sent so those survive
|
||||
# (api-surface.AC2.21). A guild we've never seen is stored decoded-minus-children.
|
||||
def handle_dispatch(:GUILD_UPDATE, %Dexcord.Guild{} = guild, raw, _config) do
|
||||
partial = Map.drop(raw, @guild_child_keys)
|
||||
|
||||
case :ets.lookup(@guilds, guild.id) do
|
||||
[{_, %Dexcord.Guild{} = existing}] ->
|
||||
:ets.insert(@guilds, {guild.id, Dexcord.Guild.merge_map(existing, partial)})
|
||||
|
||||
_ ->
|
||||
:ets.insert(@guilds, {guild.id, without_children(guild)})
|
||||
end
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
def handle_dispatch(
|
||||
:GUILD_EMOJIS_UPDATE,
|
||||
%Dexcord.Events.GuildEmojisUpdate{} = e,
|
||||
_raw,
|
||||
_config
|
||||
) do
|
||||
update_guild(e.guild_id, fn g -> %{g | emojis: e.emojis} end)
|
||||
end
|
||||
|
||||
def handle_dispatch(
|
||||
:GUILD_STICKERS_UPDATE,
|
||||
%Dexcord.Events.GuildStickersUpdate{} = e,
|
||||
_raw,
|
||||
_config
|
||||
) do
|
||||
update_guild(e.guild_id, fn g -> %{g | stickers: e.stickers} end)
|
||||
end
|
||||
|
||||
def handle_dispatch(:GUILD_DELETE, %Dexcord.UnavailableGuild{id: id} = stub, _raw, _config)
|
||||
when not is_nil(id) do
|
||||
if stub.unavailable do
|
||||
# Guild went unavailable (outage), not removed: keep a stub placeholder.
|
||||
:ets.insert(@guilds, {id, stub})
|
||||
else
|
||||
# The bot was removed (no `unavailable` key): cascade-purge every table.
|
||||
# Tuple-keyed tables match on the guild-id prefix; channels are struct
|
||||
# rows selected by their `guild_id` field (api-surface.AC2.22).
|
||||
:ets.delete(@guilds, id)
|
||||
:ets.match_delete(@members, {{id, :_}, :_})
|
||||
:ets.match_delete(@roles, {{id, :_}, :_})
|
||||
:ets.match_delete(@presences, {{id, :_}, :_})
|
||||
:ets.match_delete(@voice_states, {{id, :_}, :_})
|
||||
:ets.select_delete(@channels, [{{:_, %{guild_id: id}}, [], [true]}])
|
||||
end
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
|
|
@ -167,6 +216,28 @@ defmodule Dexcord.Cache do
|
|||
:ok
|
||||
end
|
||||
|
||||
# GUILD_MEMBER_UPDATE is a partial: merge only the keys it sent into the cached
|
||||
# row (keeping `user: nil`/`user_id`), and refresh the separate user record.
|
||||
def handle_dispatch(
|
||||
:GUILD_MEMBER_UPDATE,
|
||||
%Dexcord.Events.GuildMemberUpdate{guild_id: gid, user: %Dexcord.User{id: uid} = user},
|
||||
raw,
|
||||
_config
|
||||
)
|
||||
when not is_nil(gid) and not is_nil(uid) do
|
||||
upsert_user(user)
|
||||
partial = Map.delete(raw, "user")
|
||||
|
||||
merged =
|
||||
case :ets.lookup(@members, {gid, uid}) do
|
||||
[{_, %Dexcord.Member{} = existing}] -> Dexcord.Member.merge_map(existing, partial)
|
||||
_ -> Dexcord.Member.from_map(partial)
|
||||
end
|
||||
|
||||
:ets.insert(@members, {{gid, uid}, %{merged | user: nil, user_id: uid}})
|
||||
:ok
|
||||
end
|
||||
|
||||
def handle_dispatch(
|
||||
:GUILD_MEMBER_REMOVE,
|
||||
%Dexcord.Events.GuildMemberRemove{guild_id: gid, user: %Dexcord.User{id: uid}},
|
||||
|
|
@ -228,23 +299,48 @@ defmodule Dexcord.Cache do
|
|||
:ok
|
||||
end
|
||||
|
||||
def handle_dispatch(:USER_UPDATE, %Dexcord.User{} = user, raw, _config) do
|
||||
case :ets.lookup(@me, :me) do
|
||||
[{_, %Dexcord.User{} = existing}] ->
|
||||
:ets.insert(@me, {:me, Dexcord.User.merge_map(existing, raw)})
|
||||
|
||||
_ ->
|
||||
:ets.insert(@me, {:me, user})
|
||||
end
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
# Opportunistic freshness: cache the (real, non-webhook) author and, when the
|
||||
# message carries a guild member fragment, fold it into the members table.
|
||||
# Webhook messages (`webhook_id` set) write nothing.
|
||||
def handle_dispatch(
|
||||
:MESSAGE_CREATE,
|
||||
%Dexcord.Message{author: %Dexcord.User{id: id} = author},
|
||||
_raw,
|
||||
%Dexcord.Message{webhook_id: nil, author: %Dexcord.User{id: aid} = author} = message,
|
||||
raw,
|
||||
_config
|
||||
)
|
||||
when is_integer(id) do
|
||||
# Opportunistic freshness: cache the (real, non-webhook) message author. The
|
||||
# richer member fold-in and webhook guard land in Task 2.
|
||||
upsert_user(author)
|
||||
when is_integer(aid) do
|
||||
merge_user(aid, author, raw["author"])
|
||||
|
||||
if message.member && message.guild_id do
|
||||
gid = message.guild_id
|
||||
partial = Map.delete(raw["member"] || %{}, "user")
|
||||
|
||||
merged =
|
||||
case :ets.lookup(@members, {gid, aid}) do
|
||||
[{_, %Dexcord.Member{} = existing}] -> Dexcord.Member.merge_map(existing, partial)
|
||||
_ -> Dexcord.Member.from_map(partial)
|
||||
end
|
||||
|
||||
:ets.insert(@members, {{gid, aid}, %{merged | user: nil, user_id: aid}})
|
||||
end
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
# Everything else - unknown events, events with no cache mapping, and any raw
|
||||
# fallback map from a failed decode (which matches no struct clause) - is a
|
||||
# no-op. The merge-flavored events (GUILD_UPDATE merge, GUILD_MEMBER_UPDATE,
|
||||
# USER_UPDATE, GUILD_EMOJIS/STICKERS_UPDATE, GUILD_DELETE cascade) land in Task 2.
|
||||
# fallback map from a failed decode (which matches no struct clause) - is a no-op.
|
||||
def handle_dispatch(_name, _decoded, _raw, _config), do: :ok
|
||||
|
||||
# --- write helpers ------------------------------------------------------
|
||||
|
|
@ -253,6 +349,16 @@ defmodule Dexcord.Cache do
|
|||
Enum.reduce(@guild_child_fields, guild, fn field, acc -> Map.put(acc, field, []) end)
|
||||
end
|
||||
|
||||
# Apply `fun` to an existing guild row in place; a no-op if the guild is unknown.
|
||||
defp update_guild(gid, fun) do
|
||||
case :ets.lookup(@guilds, gid) do
|
||||
[{_, %Dexcord.Guild{} = g}] -> :ets.insert(@guilds, {gid, fun.(g)})
|
||||
_ -> :ok
|
||||
end
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
defp put_me(%Dexcord.User{} = user), do: :ets.insert(@me, {:me, user})
|
||||
defp put_me(_), do: :ok
|
||||
|
||||
|
|
@ -320,6 +426,19 @@ defmodule Dexcord.Cache do
|
|||
|
||||
defp upsert_user(_), do: :ok
|
||||
|
||||
# Upsert a user, merging a raw partial fragment over any existing record so the
|
||||
# fields the fragment lacks (e.g. a message author fragment vs. a full user) are
|
||||
# preserved. Falls back to the decoded struct when there's no existing row.
|
||||
defp merge_user(uid, %Dexcord.User{} = fallback, raw_fragment) do
|
||||
merged =
|
||||
case :ets.lookup(@users, uid) do
|
||||
[{_, %Dexcord.User{} = existing}] -> Dexcord.User.merge_map(existing, raw_fragment || %{})
|
||||
_ -> fallback
|
||||
end
|
||||
|
||||
:ets.insert(@users, {uid, merged})
|
||||
end
|
||||
|
||||
# Overlay `new` onto `existing`, keeping `existing`'s value wherever `new`'s is
|
||||
# nil - so a fuller cached row is never clobbered by a sparser struct.
|
||||
defp merge_non_nil(%module{} = existing, %module{} = new) do
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ defmodule Dexcord.CacheTest do
|
|||
|
||||
# Feed a raw wire payload exactly as the dispatcher does: decode once, then hand
|
||||
# the cache both the decoded struct and the raw partial map.
|
||||
defp feed(name, raw, config \\ @on) do
|
||||
defp feed(name, raw, config) do
|
||||
Cache.handle_dispatch(name, Dexcord.Events.decode(name, raw), raw, config)
|
||||
end
|
||||
|
||||
|
|
@ -111,8 +111,8 @@ defmodule Dexcord.CacheTest do
|
|||
end
|
||||
end
|
||||
|
||||
describe "GUILD_UPDATE (Task 1 placeholder)" do
|
||||
test "stores the updated guild row without touching child tables" do
|
||||
describe "GUILD_UPDATE and child tables" do
|
||||
test "updates the guild row without touching the per-guild child tables" do
|
||||
feed(:GUILD_CREATE, guild_create(100), @on)
|
||||
feed(:GUILD_UPDATE, %{"id" => "100", "name" => "renamed"}, @on)
|
||||
|
||||
|
|
@ -248,11 +248,171 @@ defmodule Dexcord.CacheTest do
|
|||
end
|
||||
end
|
||||
|
||||
describe "MESSAGE_CREATE (Task 1: author only)" do
|
||||
test "opportunistically upserts the message author as a struct" do
|
||||
data = %{"id" => "1", "guild_id" => "5", "author" => user(2), "content" => "hi"}
|
||||
describe "GUILD_UPDATE / GUILD_EMOJIS_UPDATE / GUILD_STICKERS_UPDATE" do
|
||||
test "GUILD_UPDATE merge preserves gateway-only fields like joined_at (AC2.21)" do
|
||||
golden = Dexcord.Fixtures.load!("guild_create.json")
|
||||
gid = golden["id"]
|
||||
feed(:GUILD_CREATE, golden, @off)
|
||||
|
||||
original = Cache.guild!(gid)
|
||||
assert %DateTime{} = original.joined_at
|
||||
|
||||
feed(:GUILD_UPDATE, %{"id" => gid, "name" => "renamed"}, @off)
|
||||
|
||||
updated = Cache.guild!(gid)
|
||||
assert updated.name == "renamed"
|
||||
# joined_at is absent from GUILD_UPDATE payloads: the merge must not drop it.
|
||||
assert updated.joined_at == original.joined_at
|
||||
assert updated.verification_level == original.verification_level
|
||||
end
|
||||
|
||||
test "GUILD_UPDATE on an unknown guild stores it decoded (minus children)" do
|
||||
feed(:GUILD_UPDATE, %{"id" => "100", "name" => "fresh"}, @off)
|
||||
assert {:ok, %Dexcord.Guild{id: 100, name: "fresh", members: []}} = Cache.guild(100)
|
||||
end
|
||||
|
||||
test "GUILD_EMOJIS_UPDATE replaces the inline emoji list" do
|
||||
feed(:GUILD_CREATE, guild_create(100), @off)
|
||||
|
||||
feed(
|
||||
:GUILD_EMOJIS_UPDATE,
|
||||
%{"guild_id" => "100", "emojis" => [%{"id" => "77", "name" => "wave"}]},
|
||||
@off
|
||||
)
|
||||
|
||||
{:ok, g} = Cache.guild(100)
|
||||
assert [%Dexcord.Emoji{name: "wave"}] = g.emojis
|
||||
end
|
||||
|
||||
test "GUILD_STICKERS_UPDATE replaces the inline sticker list" do
|
||||
feed(:GUILD_CREATE, guild_create(100), @off)
|
||||
|
||||
feed(
|
||||
:GUILD_STICKERS_UPDATE,
|
||||
%{"guild_id" => "100", "stickers" => [%{"id" => "88", "name" => "party"}]},
|
||||
@off
|
||||
)
|
||||
|
||||
{:ok, g} = Cache.guild(100)
|
||||
assert [%Dexcord.Sticker{name: "party"}] = g.stickers
|
||||
end
|
||||
end
|
||||
|
||||
describe "GUILD_MEMBER_UPDATE merge" do
|
||||
test "merges only present keys and creates the row when absent" do
|
||||
feed(
|
||||
:GUILD_MEMBER_ADD,
|
||||
member(1, %{"guild_id" => "5", "joined_at" => "2020-01-01T00:00:00Z"}),
|
||||
@off
|
||||
)
|
||||
|
||||
{:ok, before} = Cache.member(5, 1)
|
||||
assert before.nick == "nick1"
|
||||
assert %DateTime{} = before.joined_at
|
||||
|
||||
feed(
|
||||
:GUILD_MEMBER_UPDATE,
|
||||
%{"guild_id" => "5", "user" => user(1), "roles" => ["9", "10"]},
|
||||
@off
|
||||
)
|
||||
|
||||
{:ok, updated} = Cache.member(5, 1)
|
||||
assert updated.roles == [9, 10]
|
||||
# nick / joined_at were not in the update payload: they must survive.
|
||||
assert updated.nick == "nick1"
|
||||
assert updated.joined_at == before.joined_at
|
||||
assert updated.user == nil
|
||||
assert updated.user_id == 1
|
||||
|
||||
# No existing row: the update creates one.
|
||||
feed(:GUILD_MEMBER_UPDATE, %{"guild_id" => "5", "user" => user(2), "nick" => "new"}, @off)
|
||||
{:ok, created} = Cache.member(5, 2)
|
||||
assert created.nick == "new"
|
||||
assert created.user_id == 2
|
||||
assert created.user == nil
|
||||
end
|
||||
end
|
||||
|
||||
describe "USER_UPDATE merge" do
|
||||
test "merges the partial into the cached me record, preserving absent fields" do
|
||||
feed(
|
||||
:READY,
|
||||
%{
|
||||
"user" => %{"id" => "10", "username" => "self", "global_name" => "Selfy"},
|
||||
"guilds" => []
|
||||
},
|
||||
@off
|
||||
)
|
||||
|
||||
feed(:USER_UPDATE, %{"id" => "10", "username" => "renamed"}, @off)
|
||||
|
||||
{:ok, me} = Cache.me()
|
||||
assert me.username == "renamed"
|
||||
# global_name was absent from the update: merge must preserve it.
|
||||
assert me.global_name == "Selfy"
|
||||
assert me.id == 10
|
||||
end
|
||||
end
|
||||
|
||||
describe "MESSAGE_CREATE fold-in" do
|
||||
test "folds author + member fragment and stamps user_id" do
|
||||
data = %{
|
||||
"id" => "1",
|
||||
"guild_id" => "5",
|
||||
"author" => user(2),
|
||||
"member" => %{"nick" => "frag", "roles" => ["9"]}
|
||||
}
|
||||
|
||||
feed(:MESSAGE_CREATE, data, @off)
|
||||
|
||||
assert {:ok, %Dexcord.User{id: 2}} = Cache.user(2)
|
||||
{:ok, m} = Cache.member(5, 2)
|
||||
assert m.user_id == 2
|
||||
assert m.user == nil
|
||||
assert m.nick == "frag"
|
||||
assert m.roles == [9]
|
||||
end
|
||||
|
||||
test "ignores webhook authors (writes nothing)" do
|
||||
data = %{
|
||||
"id" => "1",
|
||||
"webhook_id" => "99",
|
||||
"author" => %{"id" => "99", "username" => "hook"}
|
||||
}
|
||||
|
||||
feed(:MESSAGE_CREATE, data, @off)
|
||||
assert Cache.user(99) == :error
|
||||
end
|
||||
end
|
||||
|
||||
describe "GUILD_DELETE cascade (AC2.22)" do
|
||||
test "purge wipes exactly that guild's rows across every per-guild table, and no others" do
|
||||
feed(:GUILD_CREATE, guild_create(100), @on)
|
||||
feed(:GUILD_CREATE, guild_create(200), @on)
|
||||
|
||||
feed(:GUILD_DELETE, %{"id" => "100"}, @on)
|
||||
|
||||
# Guild 100 gone everywhere - including the struct-valued channels table.
|
||||
assert Cache.guild(100) == :error
|
||||
assert Cache.members(100) == []
|
||||
assert Cache.roles(100) == []
|
||||
assert Cache.presences(100) == []
|
||||
assert Cache.voice_states(100) == []
|
||||
assert Cache.channels(100) == []
|
||||
|
||||
# Guild 200 fully intact.
|
||||
assert {:ok, %Dexcord.Guild{}} = Cache.guild(200)
|
||||
assert [_] = Cache.members(200)
|
||||
assert [_] = Cache.roles(200)
|
||||
assert [_] = Cache.presences(200)
|
||||
assert [_] = Cache.voice_states(200)
|
||||
assert [_] = Cache.channels(200)
|
||||
end
|
||||
|
||||
test "unavailable:true keeps a stub instead of purging" do
|
||||
feed(:GUILD_CREATE, guild_create(100), @on)
|
||||
feed(:GUILD_DELETE, %{"id" => "100", "unavailable" => true}, @on)
|
||||
assert {:ok, %Dexcord.UnavailableGuild{unavailable: true}} = Cache.guild(100)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue