Managed Resources
Persistord.Managed remembers the Discord resources your bot itself created and
owns — a category, a channel, a message it edits in place, a webhook — keyed by a
name you chose. It is not a mirror: it does not shadow every channel or message in a
guild the way Persistord.Core's skeleton graph or Persistord.Messages do, and it
never calls Discord. It only makes the record side of "did I already create this?"
trivial, so your bot stops re-discovering resources by name on every boot.
The four entities
Every managed resource derives from the abstract ManagedResource, which is not
mapped as an entity type itself — EF maps only the four concrete resources below,
each to its own pinned table (ManagedCategories, ManagedChannels,
ManagedMessages, ManagedWebhooks). ManagedResource carries the shape they all
share:
Id(long) — surrogate primary key.GuildId(ulong) — the owning guild.Scope(string) — an opaque consumer partition. See Scope below.Key(string) — your stable identifier for the resource.DiscordId(ulong) — the snowflake Discord handed back.CreatedAt/UpdatedAt(DateTimeOffset) — stamped byPersistord.Core'sTimestampInterceptorwhen the context is constructed with aTimeProvider.
(GuildId, Scope, Key) is a unique index on every one of the four tables, so
"find-or-create" is a single indexed lookup.
| Entity | Extra columns | Notes |
|---|---|---|
ManagedCategory |
— | just the shared shape |
ManagedChannel |
ParentDiscordId? |
the category or parent channel it was created under, if any |
ManagedMessage |
ChannelDiscordId, ContentHash? |
also indexed on DiscordId alone — a MessageDeleted gateway event carries only the message id |
ManagedWebhook |
ChannelDiscordId, Token |
Token is stored in plaintext unless you reference Persistord.Protection and call ApplyProtection — see Protection |
Wiring
Declare the DbSets you actually use, call ApplyManagedModule() from
OnModelCreating, and optionally ApplyGuildRoot() if GuildEntity is your tenant
root (see Core Graph):
public sealed class MyBotContext(DbContextOptions<MyBotContext> options)
: DiscordDbContext(options)
{
public DbSet<ManagedCategory> Categories => Set<ManagedCategory>();
public DbSet<ManagedChannel> Channels => Set<ManagedChannel>();
public DbSet<ManagedMessage> Messages => Set<ManagedMessage>();
public DbSet<ManagedWebhook> Webhooks => Set<ManagedWebhook>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.ApplyManagedModule();
modelBuilder.ApplyGuildRoot(cascade: false); // optional: see the note below
}
}
ApplyManagedModule() maps all four resource types regardless of which DbSets you
declare — an unused one just costs an empty table.
ApplyGuildRoot()'s default is cascade: true, which wires a cascading foreign
key from every managed resource's GuildId to GuildEntity — deleting a guild
row then deletes every managed resource scoped to it for free, but it also makes
the guild row a prerequisite: a managed resource written before its guild's
row exists fails with a foreign-key violation, which is exactly what the
reconcile loop below would hit if it ran against a cascade: true root with no
guild row present. Pass cascade: true once your bot always creates the
GuildEntity row on JoinedGuild before reconciling anything scoped to that
guild — see Guild Lifecycle.
Scope
Scope is an opaque consumer string, chosen by you — a game-server id, a playlist
id, whatever your resources hang off. It is never a foreign key, so this module never
couples to your own tables.
Scope is never null. ManagedScope.Global is "", and ManagedScope.Normalize(string?)
turns a caller's null into it. This is deliberate: SQL treats NULLs as distinct in a
unique index, so (GuildId, NULL, Key) would happily accept duplicate guild-wide
rows on SQLite and PostgreSQL alike. Making Scope non-nullable with "" for
"global" is what keeps (GuildId, Scope, Key) a real unique key.
The sharp edge: a consumer writing raw LINQ against a managed DbSet must compare
Scope against "", not null.
// Wrong — Scope is never null, so this predicate matches nothing.
var globalChannels = await context.Channels
.Where(c => c.GuildId == guildId && c.Scope == null)
.ToListAsync();
// Right.
var globalChannels = await context.Channels
.Where(c => c.GuildId == guildId && c.Scope == ManagedScope.Global)
.ToListAsync();
The store helpers (UpsertManagedAsync, FindManagedAsync, DeleteScopeAsync) all
accept scope as string? and normalize it for you, so this only bites code that
queries the DbSets directly.
The reconcile loop
The module never talks to Discord — "does the resource still exist, and does it
still look right" is your reconciler's loop. FindManagedAsync and
UpsertManagedAsync make the record side of that loop trivial:
var record = await db.FindManagedAsync<ManagedMessage>(guildId, scope, "dashboard");
var message = record is null ? null : await channel.GetMessageAsync(record.DiscordId);
if (message is null)
{
message = await channel.SendMessageAsync(embed: embed);
}
else
{
await message.ModifyAsync(m => m.Embed = embed);
}
await db.UpsertManagedAsync<ManagedMessage>(guildId, scope, "dashboard", message.Id, m =>
{
m.ChannelDiscordId = channel.Id;
m.ContentHash = hash;
});
FindManagedAsync, the Discord round trip, and UpsertManagedAsync are three
separate steps on purpose: this module owns none of the middle one.
Render gating with ContentHash
ManagedMessage.ContentHash exists to skip needless edits. The module never
computes it — the payload is yours — but the pattern is: hash the payload you are
about to render, compare it against the stored ContentHash, and only call
Discord's edit endpoint (and write the new hash back through UpsertManagedAsync's
configure callback, as in the snippet above) when the hash actually changed.
var hash = ComputeHash(payload);
if (record?.ContentHash == hash)
{
return; // nothing changed since the last render
}
This survives a restart. A process-local cache of "what did I last render" does not — it starts empty every time the bot restarts, and pays for one redundant edit per message.
Tearing down a scope
DeleteScopeAsync deletes every managed record of one scope, across all four
tables, in one transaction — use it once the thing the scope stood for is gone (a
game server unpaired, a playlist deleted). It deletes records, never Discord
objects: tear those down in Discord first.
// Discord side first: this module never talks to Discord.
await TearDownDiscordResourcesAsync(guildId, scope);
// Then forget the records.
var deleted = await db.DeleteScopeAsync(guildId, scope);
ListScopesAsync lists the distinct scopes that still have records in a guild,
sorted and excluding ManagedScope.Global — useful to answer "which scopes do I
still hold resources for?" before deciding what to tear down:
var activeScopes = await db.ListScopesAsync(guildId); // e.g. ["server-1", "server-2"]
What this module deliberately does not do
- It never talks to Discord. Fetching a resource, deciding it is stale, and recreating it is entirely your reconciler's job.
- It has no notion of channel type, permissions, or name. Those are spec-driven in your bot and re-derived every reconcile pass, not persisted here.
See also
- Core Graph —
DiscordDbContext,GuildEntity, andApplyGuildRoot(). - Protection — encrypting
ManagedWebhook.Tokenat rest. - Testing — in-memory SQLite fixtures for testing a context that applies this module.