Adopt Persistord 1.0.0-beta.3: upserts, timestamps, guild scope, managed webhooks - #91
Conversation
beta.3 moves the Discord skeleton graph out of DiscordDbContext and into DiscordGraphDbContext. The bot owns its Discord resources rather than mirroring them, so it stays on DiscordDbContext and the five mirror tables (Guilds/Channels/Users/Members/Roles) leave the model — they have never held a row. EventSubscriptions and PairedEntities go with them: production code only ever purged them, nothing ever wrote one. ClearAllTablesAsync replaces the hand-rolled factory reset, which needed a SQLite-only defer_foreign_keys pragma, raw DELETE statements and an identifier guard to satisfy the Sonar gate. SnowflakeKeyConvention makes GuildSettings' explicit ValueGeneratedNever redundant. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every store that wrote a row was hand-rolling the same three things: read-then-insert-or-update, a catch(DbUpdateException) that detaches, re-reads the winner and re-applies the mutation, and a manual clock stamp. Persistord's UpsertAsync does all three, so fifteen call sites collapse and six identical race-recovery blocks disappear with them. ICreatedAt/IUpdatedAt plus a TimestampInterceptor registered on the context factory replace the manual stamps. That needed one naming decision: the interfaces name the columns CreatedAt/UpdatedAt, so the five entities that spelled them CreatedUtc/UpdatedUtc are renamed to match (a column rename, no data touched). PostedUtc and LastSeenUtc stay hand-written — they record when something happened in Discord or in game, not when the row was last written, and VendingStore deliberately only moves PostedUtc when a message is reposted. ClanStore and VendingStore now take TimeProvider rather than IClock, so the persistence layer reads one clock. Two shapes deliberately keep their own logic: ConnectionStore's insert guard (it must swallow a vanished-parent FK violation and report "no change", which is not a natural-key race) and ServerService.ResolveOrCreateByEndpointAsync (its caller needs "was it created?", which UpsertResult.Changed does not answer — Changed is true for any pending write in the context). Tests move onto Persistord.Testing: SqliteTestDatabase replaces two copies of the in-memory fixture and the shared-cache boilerplate inlined across twenty files, and a FixedTimeProvider drives the interceptor where a test asserts on a timestamp. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every mapped entity carries a GuildId, so every one of them now declares IGuildScoped. That turns the guild purge from a hand-maintained list — four ExecuteDelete calls outside any transaction, plus a per-server RemoveAsync loop leaning on the RustServer cascade — into one PurgeGuildAsync: dependents before principals, in a single transaction, covering both the rows that cascaded off RustServer and the ones that never had a foreign key to it. A new guild-scoped table now joins the purge by declaring the interface instead of by someone remembering to add a line. Stopping the connection loops stays where it was, before any delete: a loop whose server row has vanished faults on the connection-state foreign key at its next status write and leaks its socket. The purge runs as SQL and does not touch the change tracker, so the tracker is cleared after it. ApplyGuildRoot is deliberately not used: the bot has no Guilds table and never writes one, and the cascading foreign key it wires would make a guild row a prerequisite for every insert. PurgeGuildAsync needs neither. GuildScopeConvention adds a GuildId index to the five per-server tables whose key leads with ServerId; the rest already lead with GuildId and are untouched. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
DiscordChatWebhookPoster re-discovered its webhook by name on every boot, so renaming one in Discord orphaned it and the bot silently created a duplicate alongside it — the exact failure Persistord.Managed's ManagedWebhook exists to prevent. The webhook id and token are now recorded per (guild, channel, kind) and a restart posts through the webhook it already owns. The name lookup stays as the fallback: a guild provisioned by an older build has no record yet, and it must adopt its existing webhook rather than create a second one. If the recorded webhook turns out to be unusable — deleted, or its token revoked, which the DiscordWebhookClient constructor surfaces — the record is dropped and that same fallback resolves a replacement. Only ManagedWebhookConfiguration is applied, not ApplyManagedModule: the bot owns its categories, channels and anchored messages through its own Provisioned* tables, whose scope is a real foreign key to RustServers rather than ManagedResource's opaque string, so mapping the other three would only add empty tables. The token is protected by the bot's own ICredentialProtector, the one that already covers player and FCM credentials; Persistord's [Protected] attribute on that column stays inert, and wiring Persistord.Protection later would have to drop the manual call in the same change. Because ManagedResource is IGuildScoped and timestamped, the new table joins the guild purge and the timestamp interceptor without any further wiring. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
🔵 Needs a closer look
It includes broad persistence refactors (upserts/timestamps/purges), schema migrations, and new managed webhook storage that warrant careful human validation beyond automated review.
Pull request overview
This PR upgrades the persistence layer to Persistord 1.0.0-beta.3, replacing several hand-rolled EF Core patterns (upserts, timestamp stamping, guild-scoped purges, and test DB plumbing) and introducing managed chat webhooks persisted per (guild, channel, kind) to prevent webhook duplication across restarts.
Changes:
- Adopt Persistord beta.3 helpers:
UpsertAsync, timestamp interception viaTimeProvider, andPurgeGuildAsync/ClearAllTablesAsync. - Remove unused “mirror” tables and migrate timestamp columns (
CreatedUtc/UpdatedUtc→CreatedAt/UpdatedAt). - Add managed chat webhook persistence and update chat posting APIs to include
guildId.
File summaries
| File | Description |
|---|---|
| tests/RustPlusBot.Persistence.Tests/Workspace/WorkspaceStoreWipePingTests.cs | Update workspace tests for new fixtures/time provider |
| tests/RustPlusBot.Persistence.Tests/Workspace/WorkspaceStoreTests.cs | Update workspace tests for new fixtures/time provider |
| tests/RustPlusBot.Persistence.Tests/Workspace/WorkspaceStoreByKeyTests.cs | Update workspace-by-key tests for new fixtures/time provider |
| tests/RustPlusBot.Persistence.Tests/Wipes/WipeBaselineStoreTests.cs | Switch tests to Persistord.Testing DB wrapper |
| tests/RustPlusBot.Persistence.Tests/VendingStoreTests.cs | Update vending tests for TimeProvider + new fixture DB |
| tests/RustPlusBot.Persistence.Tests/Switches/SwitchStoreTests.cs | Update switch tests for interceptor-driven timestamps |
| tests/RustPlusBot.Persistence.Tests/Switches/SmartSwitchSchemaTests.cs | Update schema tests for CreatedAt rename |
| tests/RustPlusBot.Persistence.Tests/StorageMonitors/StorageMonitorStoreTests.cs | Update storage monitor tests for CreatedAt rename |
| tests/RustPlusBot.Persistence.Tests/StorageMonitors/SmartStorageMonitorSchemaTests.cs | Update schema tests for CreatedAt rename |
| tests/RustPlusBot.Persistence.Tests/SqliteContextFixture.cs | Centralize SqliteTestDatabase + TimestampInterceptor setup |
| tests/RustPlusBot.Persistence.Tests/RustPlusBot.Persistence.Tests.csproj | Add Persistord.Testing dependency |
| tests/RustPlusBot.Persistence.Tests/PersistenceRegistrationTests.cs | Assert chat webhook store registration |
| tests/RustPlusBot.Persistence.Tests/FixedTimeProvider.cs | Add mutable TimeProvider for timestamp assertions |
| tests/RustPlusBot.Persistence.Tests/Devices/PairedDeviceStoreTests.cs | Update paired-device tests for new stores/fixture |
| tests/RustPlusBot.Persistence.Tests/Credentials/FcmRegistrationStoreTests.cs | Update FCM tests for interceptor-driven UpdatedAt |
| tests/RustPlusBot.Persistence.Tests/Connections/ConnectionStoreTests.cs | Update connection store tests for new fixture + no clock |
| tests/RustPlusBot.Persistence.Tests/Commands/MuteStoreTests.cs | Update mute tests for new fixture DB wrapper |
| tests/RustPlusBot.Persistence.Tests/ClanStoreTests.cs | Update clan tests for TimeProvider + timestamp rename |
| tests/RustPlusBot.Persistence.Tests/Chat/ChatWebhookStoreTests.cs | New tests for persisted managed webhooks + protection behavior |
| tests/RustPlusBot.Persistence.Tests/BotDbContextTests.cs | Add model-shape tests for Persistord conventions + IGuildScoped |
| tests/RustPlusBot.Persistence.Tests/Alarms/SmartAlarmSchemaTests.cs | Update schema tests for CreatedAt rename |
| tests/RustPlusBot.Persistence.Tests/Alarms/AlarmStoreTests.cs | Update alarm tests for interceptor-driven timestamps |
| tests/RustPlusBot.Features.Workspace.Tests/Teardown/ServerPurgeServiceTests.cs | Update teardown tests to use SqliteTestDatabase |
| tests/RustPlusBot.Features.Workspace.Tests/Teardown/GuildPurgeServiceTests.cs | Update purge tests to cover IGuildScoped tables |
| tests/RustPlusBot.Features.Workspace.Tests/RustPlusBot.Features.Workspace.Tests.csproj | Add Persistord.Testing dependency |
| tests/RustPlusBot.Features.Workspace.Tests/Locating/TeamChatChannelLocatorTests.cs | Replace keep-alive connection with SqliteTestDatabase.Shared |
| tests/RustPlusBot.Features.Workspace.Tests/Locating/SwitchChannelLocatorTests.cs | Replace keep-alive connection with SqliteTestDatabase.Shared |
| tests/RustPlusBot.Features.Workspace.Tests/Locating/StorageMonitorChannelLocatorTests.cs | Replace keep-alive connection with SqliteTestDatabase.Shared |
| tests/RustPlusBot.Features.Workspace.Tests/Locating/SetupChannelLocatorTests.cs | Replace keep-alive connection with SqliteTestDatabase.Shared |
| tests/RustPlusBot.Features.Workspace.Tests/Locating/PlayerEventChannelLocatorTests.cs | Replace keep-alive connection with SqliteTestDatabase.Shared |
| tests/RustPlusBot.Features.Workspace.Tests/Locating/MapChannelLocatorTests.cs | Replace keep-alive connection with SqliteTestDatabase.Shared |
| tests/RustPlusBot.Features.Workspace.Tests/Locating/EventChannelLocatorTests.cs | Replace keep-alive connection with SqliteTestDatabase.Shared |
| tests/RustPlusBot.Features.Workspace.Tests/Locating/CachingChannelLocatorTests.cs | Replace keep-alive connection with SqliteTestDatabase.Shared |
| tests/RustPlusBot.Features.Workspace.Tests/Locating/AlarmChannelLocatorTests.cs | Replace keep-alive connection with SqliteTestDatabase.Shared |
| tests/RustPlusBot.Features.Pairing.Tests/TestDb.cs | Replace private in-memory connection with SqliteTestDatabase.Private |
| tests/RustPlusBot.Features.Pairing.Tests/ServerPairingCoordinatorTests.cs | Update harness to hold SqliteTestDatabase instead of connection |
| tests/RustPlusBot.Features.Pairing.Tests/RustPlusBot.Features.Pairing.Tests.csproj | Add Persistord.Testing dependency |
| tests/RustPlusBot.Features.Pairing.Tests/PairingSupervisorTests.cs | Use SqliteTestDatabase.Options() for scoped contexts |
| tests/RustPlusBot.Features.Connections.Tests/TeamChatSenderTests.cs | Replace keep-alive connection with SqliteTestDatabase.Shared |
| tests/RustPlusBot.Features.Connections.Tests/SwitchQueryTests.cs | Replace keep-alive connection with SqliteTestDatabase.Shared |
| tests/RustPlusBot.Features.Connections.Tests/SwitchPrimingTests.cs | Replace keep-alive connection with SqliteTestDatabase.Shared |
| tests/RustPlusBot.Features.Connections.Tests/StorageSweepTests.cs | Replace keep-alive connection with SqliteTestDatabase.Shared |
| tests/RustPlusBot.Features.Connections.Tests/StorageMonitorPrimingTests.cs | Replace keep-alive connection with SqliteTestDatabase.Shared |
| tests/RustPlusBot.Features.Connections.Tests/ServerQueryTests.cs | Replace keep-alive connection with SqliteTestDatabase.Shared |
| tests/RustPlusBot.Features.Connections.Tests/RustPlusBot.Features.Connections.Tests.csproj | Add Persistord.Testing dependency |
| tests/RustPlusBot.Features.Connections.Tests/MapImageQueryTests.cs | Replace keep-alive connection with SqliteTestDatabase.Shared |
| tests/RustPlusBot.Features.Connections.Tests/ConnectionSupervisorTests.cs | Replace keep-alive connection with SqliteTestDatabase.Shared |
| tests/RustPlusBot.Features.Connections.Tests/ClanSupervisorTests.cs | Replace keep-alive connection with SqliteTestDatabase.Shared |
| tests/RustPlusBot.Features.Connections.Tests/AlarmSweepTests.cs | Replace keep-alive connection with SqliteTestDatabase.Shared |
| tests/RustPlusBot.Features.Connections.Tests/AlarmPrimingTests.cs | Replace keep-alive connection with SqliteTestDatabase.Shared |
| tests/RustPlusBot.Features.Chat.Tests/Hosting/ChatHostedServiceTests.cs | Update webhook poster calls to include guildId |
| tests/RustPlusBot.Features.Chat.Tests/ChatRelayTests.cs | Update webhook poster calls to include guildId |
| tests/RustPlusBot.Features.Chat.Tests/ChatRegistrationTests.cs | Update webhook poster calls to include guildId |
| src/RustPlusBot.Persistence/Workspace/WorkspaceStore.cs | Replace read-then-write with Persistord UpsertAsync + interceptor timestamps |
| src/RustPlusBot.Persistence/Vending/VendingStore.cs | Use UpsertAsync and TimeProvider for PostedUtc semantics |
| src/RustPlusBot.Persistence/Switches/SwitchStore.cs | Remove clock injection; rely on shared paired-device store |
| src/RustPlusBot.Persistence/StorageMonitors/StorageMonitorStore.cs | Remove clock injection; rely on shared paired-device store |
| src/RustPlusBot.Persistence/RustPlusBot.Persistence.csproj | Add Persistord.Managed dependency |
| src/RustPlusBot.Persistence/PersistenceServiceCollectionExtensions.cs | Register TimeProvider + TimestampInterceptor + chat webhook store |
| src/RustPlusBot.Persistence/Migrations/BotDbContextModelSnapshot.cs | Update model snapshot for dropped tables + managed webhooks + timestamps |
| src/RustPlusBot.Persistence/Migrations/20260910150057_ChatWebhooks.cs | Add ManagedWebhooks table + unique index |
| src/RustPlusBot.Persistence/Migrations/20260910145600_GuildScopeIndexes.cs | Add GuildId indexes for guild-scoped tables |
| src/RustPlusBot.Persistence/Migrations/20260910145042_StampedTimestamps.Designer.cs | Add migration designer for timestamp renames |
| src/RustPlusBot.Persistence/Migrations/20260910145042_StampedTimestamps.cs | Rename timestamp columns to CreatedAt/UpdatedAt |
| src/RustPlusBot.Persistence/Migrations/20260910143851_DropMirrorAndDeadTables.cs | Drop unused mirror/dead tables |
| src/RustPlusBot.Persistence/Map/MapSettingsStore.cs | Replace read-then-write with UpsertAsync |
| src/RustPlusBot.Persistence/Maintenance/DatabaseMaintenanceService.cs | Replace manual wipe logic with Persistord ClearAllTablesAsync |
| src/RustPlusBot.Persistence/Devices/PairedDeviceStore.cs | Replace insert-race recovery with UpsertAsync |
| src/RustPlusBot.Persistence/Credentials/FcmRegistrationStore.cs | Replace manual upsert + timestamps with UpsertAsync + interceptor |
| src/RustPlusBot.Persistence/Credentials/CredentialStore.cs | Replace read-then-write with UpsertAsync |
| src/RustPlusBot.Persistence/Connections/ConnectionStore.cs | Remove manual UpdatedAt writes; rely on interceptor |
| src/RustPlusBot.Persistence/Configurations/PairedEntityConfiguration.cs | Remove configuration for dropped PairedEntities table |
| src/RustPlusBot.Persistence/Configurations/GuildSettingsConfiguration.cs | Rely on Persistord snowflake key convention |
| src/RustPlusBot.Persistence/Configurations/EventSubscriptionConfiguration.cs | Remove configuration for dropped EventSubscriptions table |
| src/RustPlusBot.Persistence/Commands/MuteStore.cs | Replace read-then-write with UpsertAsync |
| src/RustPlusBot.Persistence/Clans/ClanStore.cs | Replace read-then-write with UpsertAsync + TimeProvider for LastSeenUtc |
| src/RustPlusBot.Persistence/Chat/IChatWebhookStore.cs | New persistence interface for chat relay webhooks |
| src/RustPlusBot.Persistence/Chat/ChatWebhookStore.cs | Persist managed webhooks with token protection + forget-on-unreadable |
| src/RustPlusBot.Persistence/BotDbContext.cs | Remove unused sets; add ManagedWebhook set + apply ManagedWebhookConfiguration |
| src/RustPlusBot.Persistence/Alarms/AlarmStore.cs | Replace insert-race recovery with UpsertAsync |
| src/RustPlusBot.Features.Workspace/Teardown/GuildPurgeService.cs | Replace explicit deletes with Persistord PurgeGuildAsync |
| src/RustPlusBot.Features.Chat/Webhooks/IChatWebhookPoster.cs | Add guildId parameter to poster API |
| src/RustPlusBot.Features.Chat/Webhooks/DiscordChatWebhookPoster.cs | Record/reuse webhooks via IChatWebhookStore; fallback to name lookup |
| src/RustPlusBot.Features.Chat/Relaying/ChatRelay.cs | Pass guildId through to webhook poster |
| src/RustPlusBot.Domain/Workspace/ProvisionedMessage.cs | Add IGuildScoped + timestamp marker interfaces; doc updates |
| src/RustPlusBot.Domain/Workspace/ProvisionedChannel.cs | Add IGuildScoped + ICreatedAt marker; doc updates |
| src/RustPlusBot.Domain/Workspace/ProvisionedCategory.cs | Add IGuildScoped + ICreatedAt marker; doc updates |
| src/RustPlusBot.Domain/Vending/VendingStockNotification.cs | Add IGuildScoped marker; move GuildId field position |
| src/RustPlusBot.Domain/Vending/VendingNotification.cs | Add IGuildScoped marker; move GuildId field position |
| src/RustPlusBot.Domain/Vending/VendingListingTrack.cs | Rename CreatedUtc→CreatedAt + implement ICreatedAt |
| src/RustPlusBot.Domain/Vending/VendingGridTrack.cs | Rename CreatedUtc→CreatedAt + implement ICreatedAt |
| src/RustPlusBot.Domain/Servers/RustServer.cs | Add IGuildScoped marker; move GuildId field position |
| src/RustPlusBot.Domain/RustPlusBot.Domain.csproj | Reference Persistord.Core for marker interfaces |
| src/RustPlusBot.Domain/Map/ServerMapSettings.cs | Add IGuildScoped marker; move GuildId field position |
| src/RustPlusBot.Domain/Guilds/GuildSettings.cs | Add IGuildScoped marker; move GuildId field position |
| src/RustPlusBot.Domain/Events/EventSubscription.cs | Remove dropped EventSubscription entity |
| src/RustPlusBot.Domain/Entities/PairedEntity.cs | Remove dropped PairedEntity entity |
| src/RustPlusBot.Domain/Devices/PairedDeviceEntity.cs | Add IGuildScoped + ICreatedAt marker; rename CreatedUtc→CreatedAt |
| src/RustPlusBot.Domain/Credentials/PlayerCredential.cs | Add IGuildScoped marker; move GuildId field position |
| src/RustPlusBot.Domain/Credentials/FcmRegistration.cs | Add IGuildScoped + IUpdatedAt marker; doc updates |
| src/RustPlusBot.Domain/Connections/ConnectionState.cs | Add IGuildScoped + IUpdatedAt marker; doc updates |
| src/RustPlusBot.Domain/Commands/ServerCommandSettings.cs | Add IGuildScoped marker; move GuildId field position |
| src/RustPlusBot.Domain/Clans/ClanState.cs | Add IGuildScoped marker; move GuildId field position |
| src/RustPlusBot.Domain/Clans/ClanPlayerName.cs | Rename UpdatedUtc→UpdatedAt + implement IUpdatedAt |
| src/RustPlusBot.Domain/Alarms/SmartAlarm.cs | Rename CreatedUtc→CreatedAt + implement ICreatedAt |
| Directory.Packages.props | Bump Persistord packages and add Persistord.Testing version |
Review details
Files not reviewed (4)
- src/RustPlusBot.Persistence/Migrations/20260910143851_DropMirrorAndDeadTables.Designer.cs: Generated file
- src/RustPlusBot.Persistence/Migrations/20260910145042_StampedTimestamps.Designer.cs: Generated file
- src/RustPlusBot.Persistence/Migrations/20260910145600_GuildScopeIndexes.Designer.cs: Generated file
- src/RustPlusBot.Persistence/Migrations/20260910150057_ChatWebhooks.Designer.cs: Generated file
- Files reviewed: 105/109 changed files
- Comments generated: 2
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| var (context, connection) = SqliteContextFixture.Create(new FixedTimeProvider(Now)); | ||
| await using var _ = context; | ||
| await using var __ = connection; | ||
| var store = new FcmRegistrationStore(context, PassThroughProtector(), FixedClock()); | ||
| var store = new FcmRegistrationStore(context, PassThroughProtector()); |
There was a problem hiding this comment.
Fixed in daaabb8, along with every other site in the suite. Same commit renames the locals that still said connection: the fixture hands back a SqliteTestDatabase now, not a SqliteConnection.
Disposal runs in reverse of declaration, so `using var _ = context;` followed by `using var __ = database;` tore the database down first and left the context outliving it. Harmless in practice — EF does not own a connection it was handed — but it is backwards, and the suite already declared it the other way round in about half its files. The fixture's second element is a SqliteTestDatabase now, not a SqliteConnection, so the locals that still called it `connection` are renamed to match what they hold. Addresses the two review comments on #91. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Persistord
1.0.0-beta.3ships the six things this bot was hand-rolling on top ofPersistord.Core 1.0.0-beta2. This adopts them, in four reviewable commits.What changed
1.
chore:bump to beta.3, drop the tables nothing writes. beta.3 moved the Discord skeleton graph out ofDiscordDbContextintoDiscordGraphDbContext. The bot owns its Discord resources rather than mirroring them, so it stays onDiscordDbContextandGuilds/Channels/Users/Members/Rolesleave the model — none has ever held a row.EventSubscriptionsandPairedEntitiesgo with them: production code only ever purged them, nothing ever wrote one.ClearAllTablesAsyncreplaces the hand-rolled factory reset (a SQLite-onlydefer_foreign_keyspragma, rawDELETEstatements and an identifier guard for the Sonar gate).2.
refactor:upserts and timestamps. Fifteen read-then-insert-or-update blocks collapse ontoUpsertAsync, taking six identicalcatch (DbUpdateException)race-recovery blocks with them. Every natural key used is already backed by a unique index, which is what makes that recovery correct.ICreatedAt/IUpdatedAtplus aTimestampInterceptoron the context factory replace fifteen manual clock stamps; the five entities that spelled the columnsCreatedUtc/UpdatedUtcare renamed to match (rename only, no data touched).ClanStoreandVendingStoretakeTimeProviderinstead ofIClock, so the persistence layer reads one clock. Tests move ontoPersistord.Testing:SqliteTestDatabasereplaces two copies of the in-memory fixture plus the shared-cache boilerplate inlined across twenty files.3.
refactor:IGuildScoped. Every mapped entity declares it, so the guild purge becomes onePurgeGuildAsync— dependents before principals, in a single transaction — instead of four untransactedExecuteDeletecalls plus a per-serverRemoveAsyncloop leaning on theRustServercascade. A new guild-scoped table now joins the purge by declaring the interface rather than by someone remembering to add a line.GuildScopeConventionadds aGuildIdindex to the five per-server tables whose key leads withServerId.4.
feat:managed chat webhooks.DiscordChatWebhookPosterre-discovered its webhook by name on every boot, so a rename orphaned it and the bot silently created a duplicate — the exact failureManagedWebhookexists to prevent. Webhook id and token are now recorded per (guild, channel, kind); the name lookup stays as the fallback so a guild provisioned by an older build adopts its existing webhook instead of getting a second one.Deliberate non-adoptions
ApplyGuildRoot— the bot has noGuildstable and never writes one; the cascading FK it wires would make a guild row a prerequisite for every insert.PurgeGuildAsyncneeds neither.ApplyManagedModule— onlyManagedWebhookConfigurationis applied. The bot'sProvisioned*tables key their scope by a real foreign key toRustServers(load-bearing: it is why teardown must delete Discord resources before the server row), whereManagedResource.Scopeis an opaque string. Mapping the other three would only add empty tables.Persistord.Protection— the webhook token is protected by the bot's ownICredentialProtector, the one already covering player and FCM credentials. Persistord's[Protected]attribute on that column stays inert; wiringApplyProtectionlater would have to drop the manual call in the same change, and migrating the existing columns would mean re-encrypting live ciphertext under a new purpose string.ConnectionStore.UpsertStatusAsynckeeps its own insert guard (it must swallow a vanished-parent FK violation and report "no change", which is not a natural-key race), andServerService.ResolveOrCreateByEndpointAsynckeeps its own recovery because its caller needs "was it created?" —UpsertResult.Changedreports whether the call wrote, which is true for any pending change in the context.Migrations
Four, all additive or empty-table drops:
DropMirrorAndDeadTables,StampedTimestamps(column renames),GuildScopeIndexes,ChatWebhooks.Note
RustPlusBot.Domainnow referencesPersistord.Corefor the marker interfaces, which pulls EF Core in transitively — the interfaces do not ship in a dependency-free abstractions package.Verification
dotnet build: clean.dotnet test: 1670 passed, 0 failed (up from 1661; the new tests cover the chat webhook store, the model shape Persistord's conventions assume, and that every mapped entity is guild-scoped). ReSharperReformatAndReorderapplied to every touched file.🤖 Generated with Claude Code