Persistord.Managed 1.0.0-beta.3

This is a prerelease version of Persistord.Managed.
dotnet add package Persistord.Managed --version 1.0.0-beta.3
                    
NuGet\Install-Package Persistord.Managed -Version 1.0.0-beta.3
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="Persistord.Managed" Version="1.0.0-beta.3" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Persistord.Managed" Version="1.0.0-beta.3" />
                    
Directory.Packages.props
<PackageReference Include="Persistord.Managed" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add Persistord.Managed --version 1.0.0-beta.3
                    
#r "nuget: Persistord.Managed, 1.0.0-beta.3"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package Persistord.Managed@1.0.0-beta.3
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=Persistord.Managed&version=1.0.0-beta.3&prerelease
                    
Install as a Cake Addin
#tool nuget:?package=Persistord.Managed&version=1.0.0-beta.3&prerelease
                    
Install as a Cake Tool

Persistord.Managed

<div align="center">

NuGet Downloads

← Persistord docs · Documentation site

</div>

Entities, model wiring, and store helpers for Persistord that remember the Discord resources your bot itself created and owns — a category, a channel, a message it edits in place, a webhook — so it stops re-discovering them by name on every boot and hand-rolling bespoke tables to track what it made. This module is deliberately not a mirror: it does not shadow every channel or message in the guild the way Persistord.Core's skeleton or Persistord.Messages do. It records only the handful of resources your bot itself created, keyed by a name you chose, not by Discord's own graph.

The shared shape

Every managed resource is a row keyed by (GuildId, Scope, Key) plus the Discord snowflake the bot got back:

  • GuildId — the owning guild.
  • Scope — an opaque partition inside the guild, chosen by you: a game-server id, a playlist id, whatever your resources hang off. It is not a foreign key, so this module never couples to your own tables. ManagedScope.Global ("") means guild-wide. Scope is a non-nullable string in the database and on the CLR type — not string? behind a value converter — because a converter does not travel through null comparisons: Where(m => m.Scope == null) would translate to IS NULL and silently match nothing. Pass null to ManagedScope.Normalize (or to any helper built on this module) and get ManagedScope.Global back.
  • Key — your stable identifier for the resource, unique within a guild and scope.
  • DiscordId — the snowflake Discord handed back when the resource was created.

(GuildId, Scope, Key) is a unique index on every managed resource, so "find-or-create" is a single indexed lookup.

The four resources

All four are keyed by the same natural key and derive from the abstract ManagedResource, which also carries the surrogate Id, CreatedAt, and UpdatedAt (stamped by Persistord.Core's TimestampInterceptor when your context is constructed with a TimeProvider).

Entity Natural key Extra columns
ManagedCategory (GuildId, Scope, Key)
ManagedChannel (GuildId, Scope, Key) ParentDiscordId? — the parent it was created under, if any.
ManagedMessage (GuildId, Scope, Key) ChannelDiscordId, ContentHash? — see ContentHash and render gating. Also indexed on DiscordId alone, because a MessageDeleted gateway event carries only the message id.
ManagedWebhook (GuildId, Scope, Key) ChannelDiscordId, Token — see the warning below.

ManagedWebhook.Token is annotated [Protected] (Persistord.Core.Abstractions.ProtectedAttribute), which is inert on its own — reference Persistord.Protection and call ApplyProtection to encrypt it at rest. Without that, the token is stored in plaintext.

Store helpers

ManagedStoreExtensions adds four DbContext extension methods. They are thin wrappers over Persistord.Core's UpsertAsync, not a repository layer: you keep your own DbContext and your own reconciler, and nothing here ever talks to Discord.

  • Task<TResource> UpsertManagedAsync<TResource>(this DbContext context, ulong guildId, string? scope, string key, ulong discordId, Action<TResource>? configure = null, CancellationToken cancellationToken = default) where TResource : ManagedResource, new() — creates or updates the record for (guildId, scope, key). configure sets the type-specific columns (a channel's parent, a message's channel and content hash, a webhook's token) and runs on created and existing rows alike.
  • Task<TResource?> FindManagedAsync<TResource>(this DbContext context, ulong guildId, string? scope, string key, CancellationToken cancellationToken = default) where TResource : ManagedResource — reads one record by its natural key, or null when there is none.
  • Task<int> DeleteScopeAsync(this DbContext context, ulong guildId, string? scope, CancellationToken cancellationToken = default) — deletes every managed record of one scope, across all four tables, in one transaction. Use it when the thing the scope stood for is gone — a game server was unpaired, a playlist was deleted. This deletes records, never Discord objects: tear those down in Discord first.
  • Task<IReadOnlyList<string>> ListScopesAsync(this DbContext context, ulong guildId, CancellationToken cancellationToken = default) — lists the distinct scopes that still have records in the guild, sorted, excluding ManagedScope.Global. For a bot that scopes by game server, this answers "which servers do I still hold resources for?".

Reconciling with Discord

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 are what make the record side of that loop trivial:

var record = await context.FindManagedAsync<ManagedMessage>(guildId, scope: "server-7", key: "dashboard");

IUserMessage? message = record is null
    ? null
    : await TryGetMessageAsync(channel, record.DiscordId); // null on a 404

message ??= await channel.SendMessageAsync(embed: BuildDashboardEmbed());

await context.UpsertManagedAsync<ManagedMessage>(
    guildId,
    scope: "server-7",
    key: "dashboard",
    discordId: message.Id,
    configure: m => m.ChannelDiscordId = channel.Id);

FindManagedAsync, the Discord round trip, and UpsertManagedAsync are three separate steps on purpose: this module owns none of the middle one.

ContentHash and render gating

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 via UpsertManagedAsync's configure callback) when the hash changed. A bot that redraws a dashboard on every gateway event, but only actually edits the message when its content changed, is what this column is for.

Wiring it up

Declare the DbSets you actually use and call ApplyManagedModule() from OnModelCreating:

public sealed class MyBotContext(DbContextOptions<MyBotContext> options)
    : Persistord.Core.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 and makes the guild row a prerequisite: a managed resource written before its guild's row exists fails with a foreign-key violation. Pass cascade: true once your bot always creates the GuildEntity row on JoinedGuild before reconciling anything scoped to that guild — see the Guild Lifecycle article on the documentation site.

Table names are pinned

Each resource is mapped to a fixed table name (ManagedCategories, ManagedChannels, ManagedMessages, ManagedWebhooks) via ToTable(...) in its configuration, rather than letting EF Core name the table after the DbSet property you declare. These tables belong to the module: renaming your DbSet property must not move the table.

License

MIT

Product Compatible and additional computed target framework versions.
.NET net10.0 is compatible.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
1.0.0-beta.3 88 9/10/2026