diff --git a/Directory.Packages.props b/Directory.Packages.props
index a93116e0..25bf9341 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -12,7 +12,8 @@
-
+
+
@@ -38,6 +39,7 @@
+
diff --git a/src/RustPlusBot.Domain/Alarms/SmartAlarm.cs b/src/RustPlusBot.Domain/Alarms/SmartAlarm.cs
index 47290f85..112e57b6 100644
--- a/src/RustPlusBot.Domain/Alarms/SmartAlarm.cs
+++ b/src/RustPlusBot.Domain/Alarms/SmartAlarm.cs
@@ -1,16 +1,14 @@
+using Persistord.Core.Abstractions;
using RustPlusBot.Abstractions.Connections;
namespace RustPlusBot.Domain.Alarms;
/// A paired Smart Alarm the bot manages, surviving restarts. Guild- and server-scoped. Driven by the live socket (primed on connect, reacts to SmartDeviceTriggered) — the entity id is the switch-vs-alarm discriminant.
-public sealed class SmartAlarm
+public sealed class SmartAlarm : IGuildScoped, ICreatedAt
{
/// Surrogate primary key.
public Guid Id { get; set; } = Guid.NewGuid();
- /// The owning Discord guild snowflake.
- public ulong GuildId { get; set; }
-
/// The server this alarm belongs to (FK to RustServer, cascade delete).
public Guid ServerId { get; set; }
@@ -26,9 +24,6 @@ public sealed class SmartAlarm
/// The Discord user who accepted (validated) the pairing.
public ulong PairedByUserId { get; set; }
- /// When the alarm was accepted (UTC).
- public DateTimeOffset CreatedUtc { get; set; }
-
/// When true, a trigger going active pings @everyone in #alarms.
public bool PingEveryone { get; set; }
@@ -43,4 +38,10 @@ public sealed class SmartAlarm
/// Per-device reachability; defaults to Reachable. Orthogonal to whole-server connection status.
public DeviceReachability Reachability { get; set; } = DeviceReachability.Reachable;
+
+ /// When the alarm was accepted (UTC). Stamped by Persistord's TimestampInterceptor.
+ public DateTimeOffset CreatedAt { get; set; }
+
+ /// The owning Discord guild snowflake.
+ public ulong GuildId { get; set; }
}
diff --git a/src/RustPlusBot.Domain/Clans/ClanPlayerName.cs b/src/RustPlusBot.Domain/Clans/ClanPlayerName.cs
index 40b9db9c..c5fed779 100644
--- a/src/RustPlusBot.Domain/Clans/ClanPlayerName.cs
+++ b/src/RustPlusBot.Domain/Clans/ClanPlayerName.cs
@@ -1,14 +1,13 @@
+using Persistord.Core.Abstractions;
+
namespace RustPlusBot.Domain.Clans;
///
/// A cached Steam64 id to display-name mapping. The clan API reports members by id only, so names
/// are harvested from clan chat and team snapshots, which do carry them.
///
-public sealed class ClanPlayerName
+public sealed class ClanPlayerName : IGuildScoped, IUpdatedAt
{
- /// The owning guild snowflake.
- public ulong GuildId { get; set; }
-
/// The server id (FK to RustServer).
public Guid ServerId { get; set; }
@@ -18,6 +17,9 @@ public sealed class ClanPlayerName
/// The most recently observed display name.
public string Name { get; set; } = string.Empty;
- /// When the name was last observed (UTC).
- public DateTimeOffset UpdatedUtc { get; set; }
+ /// The owning guild snowflake.
+ public ulong GuildId { get; set; }
+
+ /// When the name was last observed (UTC). Stamped by Persistord's TimestampInterceptor.
+ public DateTimeOffset UpdatedAt { get; set; }
}
diff --git a/src/RustPlusBot.Domain/Clans/ClanState.cs b/src/RustPlusBot.Domain/Clans/ClanState.cs
index e121b5c3..2982df17 100644
--- a/src/RustPlusBot.Domain/Clans/ClanState.cs
+++ b/src/RustPlusBot.Domain/Clans/ClanState.cs
@@ -1,14 +1,13 @@
+using Persistord.Core.Abstractions;
+
namespace RustPlusBot.Domain.Clans;
///
/// The latest known clan snapshot for one (guild, server). Row presence is the single source of
/// truth for whether the paired player is in a clan.
///
-public sealed class ClanState
+public sealed class ClanState : IGuildScoped
{
- /// The owning guild snowflake.
- public ulong GuildId { get; set; }
-
/// The server id (FK to RustServer; primary key, one row per server).
public Guid ServerId { get; set; }
@@ -56,4 +55,7 @@ public sealed class ClanState
/// When this snapshot was last confirmed (UTC).
public DateTimeOffset LastSeenUtc { get; set; }
+
+ /// The owning guild snowflake.
+ public ulong GuildId { get; set; }
}
diff --git a/src/RustPlusBot.Domain/Commands/ServerCommandSettings.cs b/src/RustPlusBot.Domain/Commands/ServerCommandSettings.cs
index f58560af..c73027f8 100644
--- a/src/RustPlusBot.Domain/Commands/ServerCommandSettings.cs
+++ b/src/RustPlusBot.Domain/Commands/ServerCommandSettings.cs
@@ -1,11 +1,10 @@
+using Persistord.Core.Abstractions;
+
namespace RustPlusBot.Domain.Commands;
/// Per-(guild, server) command configuration: trigger prefix and mute state.
-public sealed class ServerCommandSettings
+public sealed class ServerCommandSettings : IGuildScoped
{
- /// The owning guild snowflake.
- public ulong GuildId { get; set; }
-
/// The server id (FK to RustServer; primary key, one row per server).
public Guid ServerId { get; set; }
@@ -14,4 +13,7 @@ public sealed class ServerCommandSettings
/// Whether all bot-to-game output is currently muted.
public bool Muted { get; set; }
+
+ /// The owning guild snowflake.
+ public ulong GuildId { get; set; }
}
diff --git a/src/RustPlusBot.Domain/Connections/ConnectionState.cs b/src/RustPlusBot.Domain/Connections/ConnectionState.cs
index 80205042..f11c644c 100644
--- a/src/RustPlusBot.Domain/Connections/ConnectionState.cs
+++ b/src/RustPlusBot.Domain/Connections/ConnectionState.cs
@@ -1,14 +1,13 @@
+using Persistord.Core.Abstractions;
+
namespace RustPlusBot.Domain.Connections;
/// Persisted last-known connection state per server, so the active identity and status survive restarts.
-public sealed class ConnectionState
+public sealed class ConnectionState : IGuildScoped, IUpdatedAt
{
/// The server this state belongs to (primary key, one row per server).
public Guid RustServerId { get; set; }
- /// The owning Discord guild snowflake.
- public ulong GuildId { get; set; }
-
/// The credential currently selected as active, if any.
public Guid? ActiveCredentialId { get; set; }
@@ -18,6 +17,9 @@ public sealed class ConnectionState
/// Last heartbeat player count, or null if unknown.
public int? PlayerCount { get; set; }
- /// When the state was last updated (UTC).
+ /// The owning Discord guild snowflake.
+ public ulong GuildId { get; set; }
+
+ /// When the state was last updated (UTC). Stamped by Persistord's TimestampInterceptor.
public DateTimeOffset UpdatedAt { get; set; }
}
diff --git a/src/RustPlusBot.Domain/Credentials/FcmRegistration.cs b/src/RustPlusBot.Domain/Credentials/FcmRegistration.cs
index 7e94458c..9460a089 100644
--- a/src/RustPlusBot.Domain/Credentials/FcmRegistration.cs
+++ b/src/RustPlusBot.Domain/Credentials/FcmRegistration.cs
@@ -1,17 +1,16 @@
+using Persistord.Core.Abstractions;
+
namespace RustPlusBot.Domain.Credentials;
///
/// One Discord user's Rust+ FCM listener registration within a guild. One per (GuildId, OwnerUserId).
/// The credentials blob is stored protected at rest (see ICredentialProtector) and feeds the pairing listener.
///
-public sealed class FcmRegistration
+public sealed class FcmRegistration : IGuildScoped, IUpdatedAt
{
/// Surrogate primary key.
public Guid Id { get; set; } = Guid.NewGuid();
- /// The owning Discord guild snowflake.
- public ulong GuildId { get; set; }
-
/// The Discord user who connected these credentials.
public ulong OwnerUserId { get; set; }
@@ -21,6 +20,9 @@ public sealed class FcmRegistration
/// Listener lifecycle state.
public FcmRegistrationStatus Status { get; set; } = FcmRegistrationStatus.Active;
- /// When the registration was last upserted or had its status changed (UTC).
+ /// The owning Discord guild snowflake.
+ public ulong GuildId { get; set; }
+
+ /// When the registration was last written (UTC). Stamped by Persistord's TimestampInterceptor.
public DateTimeOffset UpdatedAt { get; set; }
}
diff --git a/src/RustPlusBot.Domain/Credentials/PlayerCredential.cs b/src/RustPlusBot.Domain/Credentials/PlayerCredential.cs
index f2b34a6b..b25da250 100644
--- a/src/RustPlusBot.Domain/Credentials/PlayerCredential.cs
+++ b/src/RustPlusBot.Domain/Credentials/PlayerCredential.cs
@@ -1,17 +1,16 @@
+using Persistord.Core.Abstractions;
+
namespace RustPlusBot.Domain.Credentials;
///
/// One player's Rust+ credentials within a server's pool. Many per (GuildId, RustServerId).
/// The token fields are stored protected at rest (see ICredentialProtector).
///
-public sealed class PlayerCredential
+public sealed class PlayerCredential : IGuildScoped
{
/// Surrogate primary key.
public Guid Id { get; set; } = Guid.NewGuid();
- /// The owning Discord guild snowflake.
- public ulong GuildId { get; set; }
-
/// The server this credential can connect to.
public Guid RustServerId { get; set; }
@@ -26,4 +25,7 @@ public sealed class PlayerCredential
/// Pool lifecycle state.
public CredentialStatus Status { get; set; } = CredentialStatus.Standby;
+
+ /// The owning Discord guild snowflake.
+ public ulong GuildId { get; set; }
}
diff --git a/src/RustPlusBot.Domain/Devices/PairedDeviceEntity.cs b/src/RustPlusBot.Domain/Devices/PairedDeviceEntity.cs
index 3baa2b73..6826d9ca 100644
--- a/src/RustPlusBot.Domain/Devices/PairedDeviceEntity.cs
+++ b/src/RustPlusBot.Domain/Devices/PairedDeviceEntity.cs
@@ -1,3 +1,4 @@
+using Persistord.Core.Abstractions;
using RustPlusBot.Abstractions.Connections;
namespace RustPlusBot.Domain.Devices;
@@ -14,14 +15,11 @@ namespace RustPlusBot.Domain.Devices;
/// type would pull the base into the model and silently collapse both device tables into one
/// table-per-hierarchy table. Each derived device keeps its own table; this base only shares the columns.
///
-public abstract class PairedDeviceEntity
+public abstract class PairedDeviceEntity : IGuildScoped, ICreatedAt
{
/// Surrogate primary key.
public Guid Id { get; set; } = Guid.NewGuid();
- /// The owning Discord guild snowflake.
- public ulong GuildId { get; set; }
-
/// The server this device belongs to (FK to RustServer, cascade delete).
public Guid ServerId { get; set; }
@@ -37,9 +35,12 @@ public abstract class PairedDeviceEntity
/// The Discord user who accepted (validated) the pairing.
public ulong PairedByUserId { get; set; }
- /// When the pairing was accepted (UTC).
- public DateTimeOffset CreatedUtc { get; set; }
-
/// Per-device reachability; defaults to Reachable. Orthogonal to whole-server connection status.
public DeviceReachability Reachability { get; set; } = DeviceReachability.Reachable;
+
+ /// When the pairing was accepted (UTC). Stamped by Persistord's TimestampInterceptor.
+ public DateTimeOffset CreatedAt { get; set; }
+
+ /// The owning Discord guild snowflake.
+ public ulong GuildId { get; set; }
}
diff --git a/src/RustPlusBot.Domain/Entities/PairedEntity.cs b/src/RustPlusBot.Domain/Entities/PairedEntity.cs
deleted file mode 100644
index 184b8e4b..00000000
--- a/src/RustPlusBot.Domain/Entities/PairedEntity.cs
+++ /dev/null
@@ -1,23 +0,0 @@
-namespace RustPlusBot.Domain.Entities;
-
-/// A paired in-game smart device, discovered via FCM pairing (populated in subsystem 1).
-public sealed class PairedEntity
-{
- /// Surrogate primary key.
- public Guid Id { get; set; } = Guid.NewGuid();
-
- /// The owning Discord guild snowflake.
- public ulong GuildId { get; set; }
-
- /// The server this entity belongs to.
- public Guid RustServerId { get; set; }
-
- /// The in-game entity id.
- public ulong EntityId { get; set; }
-
- /// The device kind.
- public PairedEntityKind Kind { get; set; }
-
- /// User-facing label.
- public string Name { get; set; } = string.Empty;
-}
diff --git a/src/RustPlusBot.Domain/Events/EventSubscription.cs b/src/RustPlusBot.Domain/Events/EventSubscription.cs
deleted file mode 100644
index 75d1d41e..00000000
--- a/src/RustPlusBot.Domain/Events/EventSubscription.cs
+++ /dev/null
@@ -1,17 +0,0 @@
-namespace RustPlusBot.Domain.Events;
-
-/// A guild's opt-in to a named map/live event (e.g. "CargoShip"). Consumed in subsystem 2.
-public sealed class EventSubscription
-{
- /// Surrogate primary key.
- public Guid Id { get; set; } = Guid.NewGuid();
-
- /// The owning Discord guild snowflake.
- public ulong GuildId { get; set; }
-
- /// The server this subscription applies to.
- public Guid RustServerId { get; set; }
-
- /// The event key the guild subscribed to.
- public string EventKey { get; set; } = string.Empty;
-}
diff --git a/src/RustPlusBot.Domain/Guilds/GuildSettings.cs b/src/RustPlusBot.Domain/Guilds/GuildSettings.cs
index d69200cb..d8ef44a3 100644
--- a/src/RustPlusBot.Domain/Guilds/GuildSettings.cs
+++ b/src/RustPlusBot.Domain/Guilds/GuildSettings.cs
@@ -1,14 +1,16 @@
+using Persistord.Core.Abstractions;
+
namespace RustPlusBot.Domain.Guilds;
/// Per-guild configuration. Primary key is the guild snowflake.
-public sealed class GuildSettings
+public sealed class GuildSettings : IGuildScoped
{
- /// The Discord guild snowflake (primary key).
- public ulong GuildId { get; set; }
-
/// BCP-47 culture for localized output (e.g. "en", "fr").
public string Culture { get; set; } = "en";
/// When true, the server-wiped announcement pings @everyone in #events. Off by default.
public bool PingEveryoneOnWipe { get; set; }
+
+ /// The Discord guild snowflake (primary key).
+ public ulong GuildId { get; set; }
}
diff --git a/src/RustPlusBot.Domain/Map/ServerMapSettings.cs b/src/RustPlusBot.Domain/Map/ServerMapSettings.cs
index ce16aa7d..0804fee2 100644
--- a/src/RustPlusBot.Domain/Map/ServerMapSettings.cs
+++ b/src/RustPlusBot.Domain/Map/ServerMapSettings.cs
@@ -1,13 +1,11 @@
+using Persistord.Core.Abstractions;
using RustPlusBot.Abstractions.Connections;
namespace RustPlusBot.Domain.Map;
/// Per-(guild, server) rendered-map layer settings; one row per server. Layers default on.
-public sealed class ServerMapSettings
+public sealed class ServerMapSettings : IGuildScoped
{
- /// The owning guild snowflake.
- public ulong GuildId { get; set; }
-
/// The server id (FK to RustServer; primary key, one row per server).
public Guid ServerId { get; set; }
@@ -34,4 +32,7 @@ public sealed class ServerMapSettings
/// Which grid convention the rendered map and event grid references use.
public MapGridStyle GridStyle { get; set; } = MapGridStyle.InGame;
+
+ /// The owning guild snowflake.
+ public ulong GuildId { get; set; }
}
diff --git a/src/RustPlusBot.Domain/RustPlusBot.Domain.csproj b/src/RustPlusBot.Domain/RustPlusBot.Domain.csproj
index b160cd12..e2e9a392 100644
--- a/src/RustPlusBot.Domain/RustPlusBot.Domain.csproj
+++ b/src/RustPlusBot.Domain/RustPlusBot.Domain.csproj
@@ -4,4 +4,11 @@
+
+
+
+
+
diff --git a/src/RustPlusBot.Domain/Servers/RustServer.cs b/src/RustPlusBot.Domain/Servers/RustServer.cs
index 197dcc8c..68a5e84a 100644
--- a/src/RustPlusBot.Domain/Servers/RustServer.cs
+++ b/src/RustPlusBot.Domain/Servers/RustServer.cs
@@ -1,14 +1,13 @@
+using Persistord.Core.Abstractions;
+
namespace RustPlusBot.Domain.Servers;
/// A Rust+ server target bound to a Discord guild. Guild-scoped.
-public sealed class RustServer
+public sealed class RustServer : IGuildScoped
{
/// Surrogate primary key.
public Guid Id { get; set; } = Guid.NewGuid();
- /// The owning Discord guild snowflake.
- public ulong GuildId { get; set; }
-
/// Display name shown in Discord.
public string Name { get; set; } = string.Empty;
@@ -32,4 +31,7 @@ public sealed class RustServer
/// Baseline: the last observed world size (game units), or null before first observation.
public uint? LastMapSize { get; set; }
+
+ /// The owning Discord guild snowflake.
+ public ulong GuildId { get; set; }
}
diff --git a/src/RustPlusBot.Domain/Vending/VendingGridTrack.cs b/src/RustPlusBot.Domain/Vending/VendingGridTrack.cs
index 503a3768..5ff11f1e 100644
--- a/src/RustPlusBot.Domain/Vending/VendingGridTrack.cs
+++ b/src/RustPlusBot.Domain/Vending/VendingGridTrack.cs
@@ -1,14 +1,13 @@
+using Persistord.Core.Abstractions;
+
namespace RustPlusBot.Domain.Vending;
/// A registered grid cell; every vending machine inside it counts as the team's own.
-public sealed class VendingGridTrack
+public sealed class VendingGridTrack : IGuildScoped, ICreatedAt
{
/// Surrogate primary key.
public Guid Id { get; set; } = Guid.NewGuid();
- /// The owning Discord guild snowflake.
- public ulong GuildId { get; set; }
-
/// The server this registration belongs to (FK to RustServer, cascade delete).
public Guid ServerId { get; set; }
@@ -18,6 +17,9 @@ public sealed class VendingGridTrack
/// The Steam id of the player who registered the cell, for display.
public ulong RegisteredBySteamId { get; set; }
- /// When the cell was registered (UTC).
- public DateTimeOffset CreatedUtc { get; set; }
+ /// When the cell was registered (UTC). Stamped by Persistord's TimestampInterceptor.
+ public DateTimeOffset CreatedAt { get; set; }
+
+ /// The owning Discord guild snowflake.
+ public ulong GuildId { get; set; }
}
diff --git a/src/RustPlusBot.Domain/Vending/VendingListingTrack.cs b/src/RustPlusBot.Domain/Vending/VendingListingTrack.cs
index 8f9c7a4a..a57de502 100644
--- a/src/RustPlusBot.Domain/Vending/VendingListingTrack.cs
+++ b/src/RustPlusBot.Domain/Vending/VendingListingTrack.cs
@@ -1,14 +1,13 @@
+using Persistord.Core.Abstractions;
+
namespace RustPlusBot.Domain.Vending;
/// A listing the team sells, registered by hand rather than read off a machine.
-public sealed class VendingListingTrack
+public sealed class VendingListingTrack : IGuildScoped, ICreatedAt
{
/// Surrogate primary key.
public Guid Id { get; set; } = Guid.NewGuid();
- /// The owning Discord guild snowflake.
- public ulong GuildId { get; set; }
-
/// The server this listing belongs to (FK to RustServer, cascade delete).
public Guid ServerId { get; set; }
@@ -33,6 +32,9 @@ public sealed class VendingListingTrack
/// The Discord user who registered the listing, for display.
public ulong RegisteredByUserId { get; set; }
- /// When the listing was registered (UTC).
- public DateTimeOffset CreatedUtc { get; set; }
+ /// When the listing was registered (UTC). Stamped by Persistord's TimestampInterceptor.
+ public DateTimeOffset CreatedAt { get; set; }
+
+ /// The owning Discord guild snowflake.
+ public ulong GuildId { get; set; }
}
diff --git a/src/RustPlusBot.Domain/Vending/VendingNotification.cs b/src/RustPlusBot.Domain/Vending/VendingNotification.cs
index 65dd8c57..31eb22e3 100644
--- a/src/RustPlusBot.Domain/Vending/VendingNotification.cs
+++ b/src/RustPlusBot.Domain/Vending/VendingNotification.cs
@@ -1,3 +1,5 @@
+using Persistord.Core.Abstractions;
+
namespace RustPlusBot.Domain.Vending;
///
@@ -5,14 +7,11 @@ namespace RustPlusBot.Domain.Vending;
/// "did the owner reprice" is answerable without a second poll — an owner reprice deletes the message,
/// whereas a rival's move only edits it.
///
-public sealed class VendingNotification
+public sealed class VendingNotification : IGuildScoped
{
/// Surrogate primary key.
public Guid Id { get; set; } = Guid.NewGuid();
- /// The owning Discord guild snowflake.
- public ulong GuildId { get; set; }
-
/// The server this notification belongs to (FK to RustServer, cascade delete).
public Guid ServerId { get; set; }
@@ -39,4 +38,7 @@ public sealed class VendingNotification
/// When the message was posted (UTC).
public DateTimeOffset PostedUtc { get; set; }
+
+ /// The owning Discord guild snowflake.
+ public ulong GuildId { get; set; }
}
diff --git a/src/RustPlusBot.Domain/Vending/VendingStockNotification.cs b/src/RustPlusBot.Domain/Vending/VendingStockNotification.cs
index 14dc3252..1522e069 100644
--- a/src/RustPlusBot.Domain/Vending/VendingStockNotification.cs
+++ b/src/RustPlusBot.Domain/Vending/VendingStockNotification.cs
@@ -1,17 +1,16 @@
+using Persistord.Core.Abstractions;
+
namespace RustPlusBot.Domain.Vending;
///
/// A live sell-out message in #vending, one per registered machine. Stores the sold-out set it was
/// rendered against so an owner restock deletes the message rather than silently editing it.
///
-public sealed class VendingStockNotification
+public sealed class VendingStockNotification : IGuildScoped
{
/// Surrogate primary key.
public Guid Id { get; set; } = Guid.NewGuid();
- /// The owning Discord guild snowflake.
- public ulong GuildId { get; set; }
-
/// The server this notification belongs to (FK to RustServer, cascade delete).
public Guid ServerId { get; set; }
@@ -29,4 +28,7 @@ public sealed class VendingStockNotification
/// When the message was posted (UTC).
public DateTimeOffset PostedUtc { get; set; }
+
+ /// The owning Discord guild snowflake.
+ public ulong GuildId { get; set; }
}
diff --git a/src/RustPlusBot.Domain/Workspace/ProvisionedCategory.cs b/src/RustPlusBot.Domain/Workspace/ProvisionedCategory.cs
index 48937caf..0eb28aea 100644
--- a/src/RustPlusBot.Domain/Workspace/ProvisionedCategory.cs
+++ b/src/RustPlusBot.Domain/Workspace/ProvisionedCategory.cs
@@ -1,20 +1,22 @@
+using Persistord.Core.Abstractions;
+
namespace RustPlusBot.Domain.Workspace;
/// A Discord category the bot has provisioned. One per scope (global or per-server).
-public sealed class ProvisionedCategory
+public sealed class ProvisionedCategory : IGuildScoped, ICreatedAt
{
/// Surrogate primary key.
public Guid Id { get; set; } = Guid.NewGuid();
- /// The owning Discord guild snowflake.
- public ulong GuildId { get; set; }
-
/// The server this category belongs to, or null for the global category.
public Guid? RustServerId { get; set; }
/// The provisioned Discord category snowflake.
public ulong DiscordCategoryId { get; set; }
- /// When the record was first created (UTC).
+ /// When the record was first created (UTC). Stamped by Persistord's TimestampInterceptor.
public DateTimeOffset CreatedAt { get; set; }
+
+ /// The owning Discord guild snowflake.
+ public ulong GuildId { get; set; }
}
diff --git a/src/RustPlusBot.Domain/Workspace/ProvisionedChannel.cs b/src/RustPlusBot.Domain/Workspace/ProvisionedChannel.cs
index 853a1890..65f318b5 100644
--- a/src/RustPlusBot.Domain/Workspace/ProvisionedChannel.cs
+++ b/src/RustPlusBot.Domain/Workspace/ProvisionedChannel.cs
@@ -1,14 +1,13 @@
+using Persistord.Core.Abstractions;
+
namespace RustPlusBot.Domain.Workspace;
/// A Discord text channel the bot has provisioned, identified by its stable spec key.
-public sealed class ProvisionedChannel
+public sealed class ProvisionedChannel : IGuildScoped, ICreatedAt
{
/// Surrogate primary key.
public Guid Id { get; set; } = Guid.NewGuid();
- /// The owning Discord guild snowflake.
- public ulong GuildId { get; set; }
-
/// The server this channel belongs to, or null for a global channel.
public Guid? RustServerId { get; set; }
@@ -18,6 +17,9 @@ public sealed class ProvisionedChannel
/// The provisioned Discord channel snowflake.
public ulong DiscordChannelId { get; set; }
- /// When the record was first created (UTC).
+ /// When the record was first created (UTC). Stamped by Persistord's TimestampInterceptor.
public DateTimeOffset CreatedAt { get; set; }
+
+ /// The owning Discord guild snowflake.
+ public ulong GuildId { get; set; }
}
diff --git a/src/RustPlusBot.Domain/Workspace/ProvisionedMessage.cs b/src/RustPlusBot.Domain/Workspace/ProvisionedMessage.cs
index 5d1a2dd9..9d609b45 100644
--- a/src/RustPlusBot.Domain/Workspace/ProvisionedMessage.cs
+++ b/src/RustPlusBot.Domain/Workspace/ProvisionedMessage.cs
@@ -1,14 +1,13 @@
+using Persistord.Core.Abstractions;
+
namespace RustPlusBot.Domain.Workspace;
/// An anchored bot message, edited in place rather than re-posted.
-public sealed class ProvisionedMessage
+public sealed class ProvisionedMessage : IGuildScoped, ICreatedAt, IUpdatedAt
{
/// Surrogate primary key.
public Guid Id { get; set; } = Guid.NewGuid();
- /// The owning Discord guild snowflake.
- public ulong GuildId { get; set; }
-
/// The server this message belongs to, or null for a global message.
public Guid? RustServerId { get; set; }
@@ -21,9 +20,12 @@ public sealed class ProvisionedMessage
/// The anchored message snowflake.
public ulong DiscordMessageId { get; set; }
- /// When the record was first created (UTC).
+ /// When the record was first created (UTC). Stamped by Persistord's TimestampInterceptor.
public DateTimeOffset CreatedAt { get; set; }
- /// When the message was last edited in place (UTC).
+ /// The owning Discord guild snowflake.
+ public ulong GuildId { get; set; }
+
+ /// When the record was last written (UTC). Stamped by Persistord's TimestampInterceptor.
public DateTimeOffset UpdatedAt { get; set; }
}
diff --git a/src/RustPlusBot.Features.Chat/Relaying/ChatRelay.cs b/src/RustPlusBot.Features.Chat/Relaying/ChatRelay.cs
index c35a4b16..af6d4dd6 100644
--- a/src/RustPlusBot.Features.Chat/Relaying/ChatRelay.cs
+++ b/src/RustPlusBot.Features.Chat/Relaying/ChatRelay.cs
@@ -69,7 +69,8 @@ public async Task RelayAsync(RelayedChatLine line, CancellationToken cancellatio
return;
}
- await poster.PostAsync(line.Kind, channelId.Value, line.SenderName, line.Message, cancellationToken)
+ await poster.PostAsync(line.Kind, line.GuildId, channelId.Value, line.SenderName, line.Message,
+ cancellationToken)
.ConfigureAwait(false);
}
diff --git a/src/RustPlusBot.Features.Chat/Webhooks/DiscordChatWebhookPoster.cs b/src/RustPlusBot.Features.Chat/Webhooks/DiscordChatWebhookPoster.cs
index d892dc02..88f09667 100644
--- a/src/RustPlusBot.Features.Chat/Webhooks/DiscordChatWebhookPoster.cs
+++ b/src/RustPlusBot.Features.Chat/Webhooks/DiscordChatWebhookPoster.cs
@@ -2,20 +2,24 @@
using Discord;
using Discord.Webhook;
using Discord.WebSocket;
+using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using RustPlusBot.Abstractions.Chat;
+using RustPlusBot.Persistence.Chat;
namespace RustPlusBot.Features.Chat.Webhooks;
///
-/// Real . Ensures one webhook per (channel kind, channel), named after the
-/// kind (created if missing, re-discovered by name on restart) and caches the webhook client per
-/// (kind, channel). Untested integration shim.
+/// Real . Ensures one webhook per (channel kind, channel) and caches the
+/// webhook client per (kind, channel). The webhook it creates is recorded in the database, so a restart
+/// reuses the webhook the bot already owns instead of hunting for one by name. Untested integration shim.
///
/// The Discord socket client.
+/// Opens a scope to reach the scoped .
/// The logger.
internal sealed partial class DiscordChatWebhookPoster(
DiscordSocketClient client,
+ IServiceScopeFactory scopeFactory,
ILogger logger)
: IChatWebhookPoster, IAsyncDisposable
{
@@ -36,6 +40,7 @@ public ValueTask DisposeAsync()
///
public async Task PostAsync(
ChatChannelKind kind,
+ ulong guildId,
ulong channelId,
string username,
string message,
@@ -43,7 +48,8 @@ public async Task PostAsync(
{
try
{
- var webhook = await GetOrCreateClientAsync(kind, channelId).ConfigureAwait(false);
+ var webhook = await GetOrCreateClientAsync(kind, guildId, channelId, cancellationToken)
+ .ConfigureAwait(false);
if (webhook is null)
{
return;
@@ -61,9 +67,10 @@ await webhook.SendMessageAsync(message, username: username, allowedMentions: All
}
///
- /// Webhook name per channel kind. These strings are load-bearing: the poster re-discovers its
- /// webhook by name on restart, so changing one orphans every webhook already created in live
- /// guilds and silently creates a duplicate alongside it.
+ /// Webhook name per channel kind, and the key its record is stored under. The name is only a
+ /// fallback now that the webhook is recorded — a guild that predates the record still has its
+ /// webhook found by name once, and re-recorded — but changing one of these strings still orphans
+ /// every unrecorded webhook already created in a live guild.
///
/// The channel kind.
/// The webhook name to find or create.
@@ -73,23 +80,100 @@ await webhook.SendMessageAsync(message, username: username, allowedMentions: All
_ => "RustPlusBot TeamChat",
};
- private async Task GetOrCreateClientAsync(ChatChannelKind kind, ulong channelId)
+ private async Task GetOrCreateClientAsync(
+ ChatChannelKind kind,
+ ulong guildId,
+ ulong channelId,
+ CancellationToken cancellationToken)
{
if (_clients.TryGetValue((kind, channelId), out var cached))
{
return cached;
}
+ var name = WebhookNameFor(kind);
+
+ var recorded = await FromRecordAsync(kind, guildId, channelId, name, cancellationToken)
+ .ConfigureAwait(false);
+ if (recorded is not null)
+ {
+ return recorded;
+ }
+
if (await client.GetChannelAsync(channelId).ConfigureAwait(false) is not ITextChannel channel)
{
return null;
}
- var name = WebhookNameFor(kind);
+ // No record yet (or the recorded one was unusable): fall back to the name lookup, which is what
+ // keeps a guild provisioned by an older build from getting a second webhook, then record what we
+ // end up with so this is the last time this channel is searched.
var hooks = await channel.GetWebhooksAsync().ConfigureAwait(false);
var hook = hooks.FirstOrDefault(h => string.Equals(h.Name, name, StringComparison.Ordinal))
?? await channel.CreateWebhookAsync(name).ConfigureAwait(false);
- var webhookClient = new DiscordWebhookClient(hook);
+
+ await SaveRecordAsync(guildId, channelId, name, hook, cancellationToken).ConfigureAwait(false);
+ return Cache(kind, channelId, new DiscordWebhookClient(hook));
+ }
+
+ private async Task FromRecordAsync(
+ ChatChannelKind kind,
+ ulong guildId,
+ ulong channelId,
+ string name,
+ CancellationToken cancellationToken)
+ {
+ var scope = scopeFactory.CreateAsyncScope();
+ await using (scope.ConfigureAwait(false))
+ {
+ var store = scope.ServiceProvider.GetRequiredService();
+ var record = await store.GetAsync(guildId, channelId, name, cancellationToken).ConfigureAwait(false);
+ if (record is null)
+ {
+ return null;
+ }
+
+ try
+ {
+ return Cache(kind, channelId, new DiscordWebhookClient(record.Value.Id, record.Value.Token));
+ }
+#pragma warning disable CA1031 // Broad catch: any failure here means the record is unusable, whatever it was.
+ catch (Exception ex)
+#pragma warning restore CA1031
+ {
+ // The recorded webhook is gone or its token was revoked — the constructor validates it
+ // against Discord. Forget it so the lookup below can adopt or create a replacement,
+ // rather than failing every line from now on.
+ LogRecordUnusable(logger, ex, kind, channelId);
+ await store.ForgetAsync(guildId, channelId, name, cancellationToken).ConfigureAwait(false);
+ return null;
+ }
+ }
+ }
+
+ private async Task SaveRecordAsync(
+ ulong guildId,
+ ulong channelId,
+ string name,
+ IWebhook hook,
+ CancellationToken cancellationToken)
+ {
+ if (string.IsNullOrEmpty(hook.Token))
+ {
+ return; // Nothing worth recording: without the token the record could not be posted through.
+ }
+
+ var scope = scopeFactory.CreateAsyncScope();
+ await using (scope.ConfigureAwait(false))
+ {
+ var store = scope.ServiceProvider.GetRequiredService();
+ await store.SaveAsync(guildId, channelId, name, hook.Id, hook.Token, cancellationToken)
+ .ConfigureAwait(false);
+ }
+ }
+
+ private DiscordWebhookClient Cache(ChatChannelKind kind, ulong channelId, DiscordWebhookClient webhookClient)
+ {
var stored = _clients.GetOrAdd((kind, channelId), webhookClient);
if (!ReferenceEquals(stored, webhookClient))
{
@@ -106,4 +190,12 @@ private static partial void LogPostFailed(
Exception exception,
ChatChannelKind kind,
ulong channelId);
+
+ [LoggerMessage(Level = LogLevel.Warning,
+ Message = "The recorded {Kind} webhook for channel {ChannelId} is unusable; re-resolving it.")]
+ private static partial void LogRecordUnusable(
+ ILogger logger,
+ Exception exception,
+ ChatChannelKind kind,
+ ulong channelId);
}
diff --git a/src/RustPlusBot.Features.Chat/Webhooks/IChatWebhookPoster.cs b/src/RustPlusBot.Features.Chat/Webhooks/IChatWebhookPoster.cs
index fa64255b..61b7feb8 100644
--- a/src/RustPlusBot.Features.Chat/Webhooks/IChatWebhookPoster.cs
+++ b/src/RustPlusBot.Features.Chat/Webhooks/IChatWebhookPoster.cs
@@ -7,6 +7,7 @@ public interface IChatWebhookPoster
{
/// Posts to channel impersonating .
/// The in-game chat channel the line came from.
+ /// The owning guild, which the webhook record is scoped to.
/// The target Discord channel snowflake.
/// The webhook display name (the in-game player's Steam name).
/// The message text.
@@ -14,6 +15,7 @@ public interface IChatWebhookPoster
/// A task that completes when the message has been posted.
Task PostAsync(
ChatChannelKind kind,
+ ulong guildId,
ulong channelId,
string username,
string message,
diff --git a/src/RustPlusBot.Features.Workspace/Teardown/GuildPurgeService.cs b/src/RustPlusBot.Features.Workspace/Teardown/GuildPurgeService.cs
index d4dd2761..95602cb7 100644
--- a/src/RustPlusBot.Features.Workspace/Teardown/GuildPurgeService.cs
+++ b/src/RustPlusBot.Features.Workspace/Teardown/GuildPurgeService.cs
@@ -1,4 +1,4 @@
-using Microsoft.EntityFrameworkCore;
+using Persistord.Core;
using RustPlusBot.Abstractions.Connections;
using RustPlusBot.Features.Workspace.Reconciler;
using RustPlusBot.Persistence;
@@ -8,7 +8,7 @@ namespace RustPlusBot.Features.Workspace.Teardown;
/// Purges a guild: tears down provisioned channels, then deletes its domain rows.
/// The bot database context.
-/// Server management (RemoveAsync cascades all per-server rows).
+/// Server lookup, to know which connection loops to stop.
/// Removes provisioned Discord channels/categories/messages.
/// Held across the whole purge to block concurrent reconciliation.
/// Stops each server's connection loop before its row is deleted.
@@ -31,28 +31,26 @@ public async Task PurgeGuildAsync(ulong guildId, CancellationToken cancellationT
// lock-free core since we already hold the lock (ResetGuildAsync would deadlock re-acquiring).
await teardown.ResetGuildCoreAsync(guildId, cancellationToken).ConfigureAwait(false);
- // 2) Remove each server; the RustServer FK cascade clears its per-server rows
- // (connection state, command/map settings, switches, alarms, storage monitors, credentials).
- // Stop the socket BEFORE each row delete, exactly as ServerRemovalService does for a single
- // server: a connection loop still running when its RustServer row goes away faults on the
- // connection-state foreign key at its next status write, and leaks its socket for the life of
- // the process. StopAsync joins the loop, so it is finished before the delete lands.
+ // 2) Stop every connection loop BEFORE any row is deleted. A loop still running when its
+ // RustServer row goes away faults on the connection-state foreign key at its next status
+ // write, and leaks its socket for the life of the process. StopAsync joins the loop, so it
+ // is finished before the deletes land.
var known = await servers.ListAsync(guildId, cancellationToken).ConfigureAwait(false);
foreach (var serverId in known.Select(server => server.Id))
{
await connections.StopAsync(guildId, serverId).ConfigureAwait(false);
- await servers.RemoveAsync(guildId, serverId, cancellationToken).ConfigureAwait(false);
}
- // 3) Delete guild-keyed rows that have no cascade FK to RustServer (event subscriptions,
- // paired entities, guild settings, FCM registrations).
- await context.EventSubscriptions.Where(e => e.GuildId == guildId)
- .ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false);
- await context.PairedEntities.Where(p => p.GuildId == guildId)
- .ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false);
- await context.GuildSettings.Where(g => g.GuildId == guildId)
- .ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false);
- await context.FcmRegistrations.Where(f => f.GuildId == guildId)
- .ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false);
+ // 3) Delete every guild-scoped row in one transaction. Persistord walks the model for
+ // IGuildScoped entity types and deletes dependents before principals, so this covers both
+ // what used to cascade off RustServer and what never had a foreign key to it (guild
+ // settings, FCM registrations) — and a new guild-scoped table joins it by declaring the
+ // interface, rather than by someone remembering to add a line here.
+ await context.PurgeGuildAsync(guildId, cancellationToken).ConfigureAwait(false);
+
+ // The deletes run as SQL and leave the change tracker holding rows that no longer exist —
+ // the server list above tracked some of them. The next SaveChanges on this scoped context
+ // would try to flush them.
+ context.ChangeTracker.Clear();
}
}
diff --git a/src/RustPlusBot.Persistence/Alarms/AlarmStore.cs b/src/RustPlusBot.Persistence/Alarms/AlarmStore.cs
index bbada0c1..4639b68b 100644
--- a/src/RustPlusBot.Persistence/Alarms/AlarmStore.cs
+++ b/src/RustPlusBot.Persistence/Alarms/AlarmStore.cs
@@ -1,55 +1,39 @@
using Microsoft.EntityFrameworkCore;
+using Persistord.Core;
using RustPlusBot.Abstractions.Connections;
-using RustPlusBot.Abstractions.Time;
using RustPlusBot.Domain.Alarms;
namespace RustPlusBot.Persistence.Alarms;
/// EF-backed .
/// The bot database context.
-/// Supplies the creation timestamp.
-public sealed class AlarmStore(BotDbContext context, IClock clock) : IAlarmStore
+public sealed class AlarmStore(BotDbContext context) : IAlarmStore
{
///
- public async Task AddAsync(
+ public Task AddAsync(
ulong guildId,
Guid serverId,
ulong entityId,
string name,
ulong pairedByUserId,
- CancellationToken ct = default)
- {
- var entity = new SmartAlarm
- {
- GuildId = guildId,
- ServerId = serverId,
- EntityId = entityId,
- Name = name,
- PairedByUserId = pairedByUserId,
- CreatedUtc = clock.UtcNow,
- };
- context.SmartAlarms.Add(entity);
- try
- {
- await context.SaveChangesAsync(ct).ConfigureAwait(false);
- return entity;
- }
- catch (DbUpdateException)
- {
- // Two users accepted the same pending pairing concurrently (both saw ExistsAsync == false); the
- // unique (GuildId, ServerId, EntityId) index rejects the second insert. Recover idempotently by
- // detaching the failed insert and returning the row the winner persisted. If no such row exists,
- // the failure was not the uniqueness race — let it propagate.
- context.Entry(entity).State = EntityState.Detached;
- var existing = await GetAsync(guildId, serverId, entityId, ct).ConfigureAwait(false);
- if (existing is null)
+ CancellationToken ct = default) =>
+ // Adding is idempotent: two users can accept the same pending pairing concurrently, and the
+ // unique (GuildId, ServerId, EntityId) index rejects whichever insert lands second. The empty
+ // mutation is deliberate — the row the winner wrote is returned untouched, name and all —
+ // and Persistord recovers the race by re-reading that winner. CreatedAt is stamped by the
+ // TimestampInterceptor.
+ context.SmartAlarms.UpsertAsync(
+ a => a.GuildId == guildId && a.ServerId == serverId && a.EntityId == entityId,
+ () => new SmartAlarm
{
- throw;
- }
-
- return existing;
- }
- }
+ GuildId = guildId,
+ ServerId = serverId,
+ EntityId = entityId,
+ Name = name,
+ PairedByUserId = pairedByUserId,
+ },
+ _ => { },
+ ct);
///
public Task GetAsync(
@@ -72,7 +56,7 @@ public async Task> ListByServerAsync(
.ToListAsync(ct)
.ConfigureAwait(false);
- return [.. alarms.OrderBy(a => a.CreatedUtc)];
+ return [.. alarms.OrderBy(a => a.CreatedAt)];
}
///
diff --git a/src/RustPlusBot.Persistence/BotDbContext.cs b/src/RustPlusBot.Persistence/BotDbContext.cs
index 51333237..f00acab4 100644
--- a/src/RustPlusBot.Persistence/BotDbContext.cs
+++ b/src/RustPlusBot.Persistence/BotDbContext.cs
@@ -1,13 +1,13 @@
using Microsoft.EntityFrameworkCore;
using Persistord.Core;
+using Persistord.Managed.Configurations;
+using Persistord.Managed.Entities;
using RustPlusBot.Domain.Alarms;
using RustPlusBot.Domain.Clans;
using RustPlusBot.Domain.Commands;
using RustPlusBot.Domain.Connections;
using RustPlusBot.Domain.Credentials;
using RustPlusBot.Domain.Devices;
-using RustPlusBot.Domain.Entities;
-using RustPlusBot.Domain.Events;
using RustPlusBot.Domain.Guilds;
using RustPlusBot.Domain.Map;
using RustPlusBot.Domain.Servers;
@@ -20,8 +20,10 @@
namespace RustPlusBot.Persistence;
///
-/// The bot's EF Core context. Inherits Persistord's DiscordDbContext for the Discord skeleton and
-/// the global ulong<->long snowflake conversion, and adds the Rust-domain sets.
+/// The bot's EF Core context. Inherits Persistord's DiscordDbContext for the global
+/// ulong<->long snowflake conversion and the snowflake-key convention, and adds the Rust-domain
+/// sets. Deliberately not DiscordGraphDbContext: the bot owns its Discord resources rather than
+/// mirroring Discord's graph, so it maps none of the guild/channel/user/member/role skeleton.
///
/// The EF Core options, typically configured with a specific provider (e.g. SQLite, PostgreSQL).
public sealed class BotDbContext(DbContextOptions options) : DiscordDbContext(options)
@@ -47,9 +49,6 @@ public sealed class BotDbContext(DbContextOptions options) : Disco
/// Per-guild settings.
public DbSet GuildSettings => Set();
- /// Paired smart devices.
- public DbSet PairedEntities => Set();
-
/// Paired and managed Smart Switches.
public DbSet SmartSwitches => Set();
@@ -59,9 +58,6 @@ public sealed class BotDbContext(DbContextOptions options) : Disco
/// Managed Smart Storage Monitors.
public DbSet SmartStorageMonitors => Set();
- /// Per-guild event subscriptions.
- public DbSet EventSubscriptions => Set();
-
/// Provisioned Discord categories (global + per-server).
public DbSet ProvisionedCategories => Set();
@@ -89,11 +85,14 @@ public sealed class BotDbContext(DbContextOptions options) : Disco
/// Live sell-out notifications.
public DbSet VendingStockNotifications => Set();
+ /// Chat-relay webhooks the bot created, remembered so they are never re-discovered by name.
+ public DbSet ChatWebhooks => Set();
+
///
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
ArgumentNullException.ThrowIfNull(modelBuilder);
- base.OnModelCreating(modelBuilder); // core skeleton + snowflake convention
+ base.OnModelCreating(modelBuilder);
// PairedDeviceEntity is a code-sharing base, not an entity type: SmartSwitch and
// SmartStorageMonitor each own their table. Ignoring it makes that intent EF-enforced — without
@@ -108,11 +107,9 @@ protected override void OnModelCreating(ModelBuilder modelBuilder)
.ApplyConfiguration(new ServerCommandSettingsConfiguration())
.ApplyConfiguration(new ServerMapSettingsConfiguration())
.ApplyConfiguration(new GuildSettingsConfiguration())
- .ApplyConfiguration(new PairedEntityConfiguration())
.ApplyConfiguration(new SmartSwitchConfiguration())
.ApplyConfiguration(new SmartAlarmConfiguration())
.ApplyConfiguration(new SmartStorageMonitorConfiguration())
- .ApplyConfiguration(new EventSubscriptionConfiguration())
.ApplyConfiguration(new ProvisionedCategoryConfiguration())
.ApplyConfiguration(new ProvisionedChannelConfiguration())
.ApplyConfiguration(new ProvisionedMessageConfiguration())
@@ -121,6 +118,11 @@ protected override void OnModelCreating(ModelBuilder modelBuilder)
.ApplyConfiguration(new VendingGridTrackConfiguration())
.ApplyConfiguration(new VendingListingTrackConfiguration())
.ApplyConfiguration(new VendingNotificationConfiguration())
- .ApplyConfiguration(new VendingStockNotificationConfiguration());
+ .ApplyConfiguration(new VendingStockNotificationConfiguration())
+ // Only the webhook resource, not ApplyManagedModule: that maps all four managed types, and
+ // the bot owns its categories, channels and anchored messages through its own Provisioned*
+ // tables, which key their scope by a real foreign key to RustServers rather than by
+ // ManagedResource's opaque string. Mapping the other three would add three empty tables.
+ .ApplyConfiguration(new ManagedWebhookConfiguration());
}
}
diff --git a/src/RustPlusBot.Persistence/Chat/ChatWebhookStore.cs b/src/RustPlusBot.Persistence/Chat/ChatWebhookStore.cs
new file mode 100644
index 00000000..ebdb1c6a
--- /dev/null
+++ b/src/RustPlusBot.Persistence/Chat/ChatWebhookStore.cs
@@ -0,0 +1,104 @@
+using System.Globalization;
+using System.Security.Cryptography;
+using Microsoft.EntityFrameworkCore;
+using Persistord.Managed;
+using Persistord.Managed.Entities;
+using RustPlusBot.Abstractions.Credentials;
+
+namespace RustPlusBot.Persistence.Chat;
+
+///
+/// EF-backed over Persistord's records,
+/// scoped by channel so one channel can hold a webhook per chat kind.
+///
+/// The bot database context.
+/// Protects the webhook token before it is written.
+public sealed class ChatWebhookStore(BotDbContext context, ICredentialProtector protector) : IChatWebhookStore
+{
+ ///
+ public async Task GetAsync(
+ ulong guildId,
+ ulong channelId,
+ string key,
+ CancellationToken cancellationToken = default)
+ {
+ var record = await context.FindManagedAsync(guildId, Scope(channelId), key,
+ cancellationToken)
+ .ConfigureAwait(false);
+ if (record is null)
+ {
+ return null;
+ }
+
+ try
+ {
+ return new ChatWebhook(record.DiscordId, protector.Unprotect(record.Token));
+ }
+ catch (CryptographicException)
+ {
+ // The Data Protection key ring that wrote this token can no longer read it (a rebuilt
+ // container with no persisted keys, say). The record is dead weight: drop it and report
+ // "none", so the caller re-discovers the webhook and records a readable token.
+ await ForgetAsync(guildId, channelId, key, cancellationToken).ConfigureAwait(false);
+ return null;
+ }
+ }
+
+ ///
+ public async Task SaveAsync(
+ ulong guildId,
+ ulong channelId,
+ string key,
+ ulong webhookId,
+ string token,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(token);
+
+ // The token is written already protected, by the bot's own ICredentialProtector — the same one
+ // that covers player and FCM credentials. Persistord's [Protected] attribute on this column is
+ // inert unless Persistord.Protection is referenced and wired; if it ever is, this call has to
+ // go at the same time or every token gets encrypted twice.
+ await context.UpsertManagedAsync(
+ guildId,
+ Scope(channelId),
+ key,
+ webhookId,
+ record =>
+ {
+ record.ChannelDiscordId = channelId;
+ record.Token = protector.Protect(token);
+ },
+ cancellationToken)
+ .ConfigureAwait(false);
+ }
+
+ ///
+ public async Task ForgetAsync(
+ ulong guildId,
+ ulong channelId,
+ string key,
+ CancellationToken cancellationToken = default)
+ {
+ var scope = Scope(channelId);
+ await context.ChatWebhooks
+ .Where(w => w.GuildId == guildId && w.Scope == scope && w.Key == key)
+ .ExecuteDeleteAsync(cancellationToken)
+ .ConfigureAwait(false);
+
+ // ExecuteDelete bypasses the change tracker; a record read earlier in this scope would linger.
+ foreach (var entry in context.ChangeTracker.Entries()
+ .Where(e => e.Entity.GuildId == guildId && e.Entity.Key == key
+ && string.Equals(e.Entity.Scope, scope,
+ StringComparison.Ordinal))
+ .ToList())
+ {
+ entry.State = EntityState.Detached;
+ }
+ }
+
+ /// The managed scope a channel's webhooks live in: the channel id, as an invariant string.
+ /// The channel snowflake.
+ /// The scope value.
+ private static string Scope(ulong channelId) => channelId.ToString(CultureInfo.InvariantCulture);
+}
diff --git a/src/RustPlusBot.Persistence/Chat/IChatWebhookStore.cs b/src/RustPlusBot.Persistence/Chat/IChatWebhookStore.cs
new file mode 100644
index 00000000..ba5527eb
--- /dev/null
+++ b/src/RustPlusBot.Persistence/Chat/IChatWebhookStore.cs
@@ -0,0 +1,57 @@
+namespace RustPlusBot.Persistence.Chat;
+
+/// A chat-relay webhook the bot created: enough to post through it without asking Discord.
+/// The webhook snowflake.
+/// The webhook token, in plaintext.
+public readonly record struct ChatWebhook(ulong Id, string Token);
+
+///
+/// Remembers the webhooks the chat relay creates, so a restart posts through the webhook it already
+/// owns instead of re-discovering one by name — a rename would otherwise orphan the old webhook and
+/// silently create a duplicate alongside it.
+///
+public interface IChatWebhookStore
+{
+ /// Reads the webhook recorded for a channel, or null when there is none.
+ /// The owning guild.
+ /// The channel the webhook posts to.
+ /// The relay's stable key for the webhook (the chat kind).
+ /// A cancellation token.
+ /// The webhook, or null.
+ Task GetAsync(
+ ulong guildId,
+ ulong channelId,
+ string key,
+ CancellationToken cancellationToken = default);
+
+ /// Records a webhook, replacing whatever was recorded for the same channel and key.
+ /// The owning guild.
+ /// The channel the webhook posts to.
+ /// The relay's stable key for the webhook (the chat kind).
+ /// The webhook snowflake Discord returned.
+ /// The webhook token, in plaintext; it is protected before it is written.
+ /// A cancellation token.
+ /// A task that completes when the record has been written.
+ Task SaveAsync(
+ ulong guildId,
+ ulong channelId,
+ string key,
+ ulong webhookId,
+ string token,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// Drops the record for a channel, so the next post re-discovers or re-creates the webhook. Call it
+ /// when the recorded webhook turns out to be unusable — deleted in Discord, or its token revoked.
+ ///
+ /// The owning guild.
+ /// The channel the webhook posts to.
+ /// The relay's stable key for the webhook (the chat kind).
+ /// A cancellation token.
+ /// A task that completes when the record is gone.
+ Task ForgetAsync(
+ ulong guildId,
+ ulong channelId,
+ string key,
+ CancellationToken cancellationToken = default);
+}
diff --git a/src/RustPlusBot.Persistence/Clans/ClanStore.cs b/src/RustPlusBot.Persistence/Clans/ClanStore.cs
index b32c9500..24ba8b0d 100644
--- a/src/RustPlusBot.Persistence/Clans/ClanStore.cs
+++ b/src/RustPlusBot.Persistence/Clans/ClanStore.cs
@@ -1,14 +1,14 @@
using Microsoft.EntityFrameworkCore;
+using Persistord.Core;
using RustPlusBot.Abstractions.Connections;
-using RustPlusBot.Abstractions.Time;
using RustPlusBot.Domain.Clans;
namespace RustPlusBot.Persistence.Clans;
/// EF-backed .
/// The bot database context.
-/// Supplies write timestamps.
-internal sealed class ClanStore(BotDbContext db, IClock clock) : IClanStore
+/// Supplies the last-seen timestamp; the same clock the TimestampInterceptor uses.
+internal sealed class ClanStore(BotDbContext db, TimeProvider timeProvider) : IClanStore
{
///
public async Task GetAsync(
@@ -52,37 +52,15 @@ public async Task SaveAsync(
{
ArgumentNullException.ThrowIfNull(snapshot);
- var row = await db.ClanStates
- .FirstOrDefaultAsync(s => s.ServerId == serverId, cancellationToken)
+ await db.ClanStates.UpsertAsync(
+ s => s.ServerId == serverId,
+ () => new ClanState
+ {
+ ServerId = serverId
+ },
+ row => Apply(row, guildId, snapshot, timeProvider.GetUtcNow()),
+ cancellationToken)
.ConfigureAwait(false);
-
- if (row is null)
- {
- row = new ClanState
- {
- ServerId = serverId
- };
- db.ClanStates.Add(row);
- }
-
- row.GuildId = guildId;
- row.ClanId = snapshot.ClanId;
- row.Name = snapshot.Name;
- row.Created = snapshot.Created;
- row.Creator = snapshot.Creator;
- row.Motd = snapshot.Motd;
- row.MotdTimestamp = snapshot.MotdTimestamp;
- row.MotdAuthor = snapshot.MotdAuthor;
- row.LogoHash = snapshot.LogoHash;
- row.Color = snapshot.Color;
- row.MaxMemberCount = snapshot.MaxMemberCount;
- row.Score = snapshot.Score;
- row.RolesJson = ClanSnapshotSerializer.Serialize(snapshot.Roles);
- row.MembersJson = ClanSnapshotSerializer.Serialize(snapshot.Members);
- row.InvitesJson = ClanSnapshotSerializer.Serialize(snapshot.Invites);
- row.LastSeenUtc = clock.UtcNow;
-
- await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
}
///
@@ -143,30 +121,40 @@ public async Task RecordNameAsync(
{
ArgumentNullException.ThrowIfNull(name);
- var row = await db.ClanPlayerNames
- .FirstOrDefaultAsync(
+ // Writing the same name again is the common case (every chat line repeats it). The upsert
+ // saves only when something actually changed, so an unchanged row keeps its UpdatedAt.
+ await db.ClanPlayerNames.UpsertAsync(
n => n.ServerId == serverId && n.SteamId == steamId,
+ () => new ClanPlayerName
+ {
+ ServerId = serverId, SteamId = steamId
+ },
+ row =>
+ {
+ row.GuildId = guildId;
+ row.Name = name;
+ },
cancellationToken)
.ConfigureAwait(false);
+ }
- if (row is not null && row.GuildId == guildId && string.Equals(row.Name, name, StringComparison.Ordinal))
- {
- return;
- }
-
- if (row is null)
- {
- row = new ClanPlayerName
- {
- ServerId = serverId, SteamId = steamId
- };
- db.ClanPlayerNames.Add(row);
- }
-
+ private static void Apply(ClanState row, ulong guildId, ClanSnapshot snapshot, DateTimeOffset seenAt)
+ {
row.GuildId = guildId;
- row.Name = name;
- row.UpdatedUtc = clock.UtcNow;
-
- await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
+ row.ClanId = snapshot.ClanId;
+ row.Name = snapshot.Name;
+ row.Created = snapshot.Created;
+ row.Creator = snapshot.Creator;
+ row.Motd = snapshot.Motd;
+ row.MotdTimestamp = snapshot.MotdTimestamp;
+ row.MotdAuthor = snapshot.MotdAuthor;
+ row.LogoHash = snapshot.LogoHash;
+ row.Color = snapshot.Color;
+ row.MaxMemberCount = snapshot.MaxMemberCount;
+ row.Score = snapshot.Score;
+ row.RolesJson = ClanSnapshotSerializer.Serialize(snapshot.Roles);
+ row.MembersJson = ClanSnapshotSerializer.Serialize(snapshot.Members);
+ row.InvitesJson = ClanSnapshotSerializer.Serialize(snapshot.Invites);
+ row.LastSeenUtc = seenAt;
}
}
diff --git a/src/RustPlusBot.Persistence/Commands/MuteStore.cs b/src/RustPlusBot.Persistence/Commands/MuteStore.cs
index 052766e7..fc921fe8 100644
--- a/src/RustPlusBot.Persistence/Commands/MuteStore.cs
+++ b/src/RustPlusBot.Persistence/Commands/MuteStore.cs
@@ -1,4 +1,5 @@
using Microsoft.EntityFrameworkCore;
+using Persistord.Core;
using RustPlusBot.Domain.Commands;
namespace RustPlusBot.Persistence.Commands;
@@ -20,27 +21,17 @@ public async Task GetMutedAsync(ulong guildId, Guid serverId, Cancellation
public async Task SetMutedAsync(ulong guildId,
Guid serverId,
bool muted,
- CancellationToken cancellationToken = default)
- {
- var existing = await context.ServerCommandSettings
- .SingleOrDefaultAsync(s => s.GuildId == guildId && s.ServerId == serverId, cancellationToken)
+ CancellationToken cancellationToken = default) =>
+ await context.ServerCommandSettings.UpsertAsync(
+ s => s.GuildId == guildId && s.ServerId == serverId,
+ () => new ServerCommandSettings
+ {
+ GuildId = guildId, ServerId = serverId
+ },
+ row => row.Muted = muted,
+ cancellationToken)
.ConfigureAwait(false);
- if (existing is null)
- {
- context.ServerCommandSettings.Add(new ServerCommandSettings
- {
- GuildId = guildId, ServerId = serverId, Muted = muted,
- });
- }
- else
- {
- existing.Muted = muted;
- }
-
- await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
- }
-
///
public async Task GetPrefixAsync(ulong guildId,
Guid serverId,
diff --git a/src/RustPlusBot.Persistence/Configurations/EventSubscriptionConfiguration.cs b/src/RustPlusBot.Persistence/Configurations/EventSubscriptionConfiguration.cs
deleted file mode 100644
index d7441aa7..00000000
--- a/src/RustPlusBot.Persistence/Configurations/EventSubscriptionConfiguration.cs
+++ /dev/null
@@ -1,19 +0,0 @@
-using Microsoft.EntityFrameworkCore;
-using Microsoft.EntityFrameworkCore.Metadata.Builders;
-using RustPlusBot.Domain.Events;
-
-namespace RustPlusBot.Persistence.Configurations;
-
-internal sealed class EventSubscriptionConfiguration : IEntityTypeConfiguration
-{
- public void Configure(EntityTypeBuilder builder)
- {
- ArgumentNullException.ThrowIfNull(builder);
- builder.HasKey(e => e.Id);
- builder.HasIndex(e => new
- {
- e.GuildId, e.RustServerId
- });
- builder.Property(e => e.EventKey).IsRequired().HasMaxLength(64);
- }
-}
diff --git a/src/RustPlusBot.Persistence/Configurations/GuildSettingsConfiguration.cs b/src/RustPlusBot.Persistence/Configurations/GuildSettingsConfiguration.cs
index cf9ed8aa..0cead0bd 100644
--- a/src/RustPlusBot.Persistence/Configurations/GuildSettingsConfiguration.cs
+++ b/src/RustPlusBot.Persistence/Configurations/GuildSettingsConfiguration.cs
@@ -9,8 +9,8 @@ internal sealed class GuildSettingsConfiguration : IEntityTypeConfiguration builder)
{
ArgumentNullException.ThrowIfNull(builder);
+ // Persistord's SnowflakeKeyConvention already marks every ulong key caller-supplied.
builder.HasKey(s => s.GuildId);
- builder.Property(s => s.GuildId).ValueGeneratedNever();
builder.Property(s => s.Culture).IsRequired().HasMaxLength(16);
}
}
diff --git a/src/RustPlusBot.Persistence/Configurations/PairedEntityConfiguration.cs b/src/RustPlusBot.Persistence/Configurations/PairedEntityConfiguration.cs
deleted file mode 100644
index c672e55f..00000000
--- a/src/RustPlusBot.Persistence/Configurations/PairedEntityConfiguration.cs
+++ /dev/null
@@ -1,19 +0,0 @@
-using Microsoft.EntityFrameworkCore;
-using Microsoft.EntityFrameworkCore.Metadata.Builders;
-using RustPlusBot.Domain.Entities;
-
-namespace RustPlusBot.Persistence.Configurations;
-
-internal sealed class PairedEntityConfiguration : IEntityTypeConfiguration
-{
- public void Configure(EntityTypeBuilder builder)
- {
- ArgumentNullException.ThrowIfNull(builder);
- builder.HasKey(e => e.Id);
- builder.HasIndex(e => new
- {
- e.GuildId, e.RustServerId
- });
- builder.Property(e => e.Name).IsRequired().HasMaxLength(128);
- }
-}
diff --git a/src/RustPlusBot.Persistence/Connections/ConnectionStore.cs b/src/RustPlusBot.Persistence/Connections/ConnectionStore.cs
index eda066be..3946b370 100644
--- a/src/RustPlusBot.Persistence/Connections/ConnectionStore.cs
+++ b/src/RustPlusBot.Persistence/Connections/ConnectionStore.cs
@@ -1,5 +1,4 @@
using Microsoft.EntityFrameworkCore;
-using RustPlusBot.Abstractions.Time;
using RustPlusBot.Domain.Connections;
using RustPlusBot.Domain.Credentials;
@@ -7,8 +6,7 @@ namespace RustPlusBot.Persistence.Connections;
/// EF-backed .
/// The bot database context.
-/// Supplies the update timestamp.
-public sealed class ConnectionStore(BotDbContext context, IClock clock) : IConnectionStore
+public sealed class ConnectionStore(BotDbContext context) : IConnectionStore
{
///
public Task GetStateAsync(
@@ -58,7 +56,6 @@ public async Task UpsertStatusAsync(
Status = status,
PlayerCount = playerCount,
ActiveCredentialId = activeCredentialId,
- UpdatedAt = clock.UtcNow,
});
try
@@ -93,7 +90,6 @@ public async Task UpsertStatusAsync(
existing.Status = status;
existing.PlayerCount = playerCount;
existing.ActiveCredentialId = activeCredentialId;
- existing.UpdatedAt = clock.UtcNow;
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
return true;
diff --git a/src/RustPlusBot.Persistence/Credentials/CredentialStore.cs b/src/RustPlusBot.Persistence/Credentials/CredentialStore.cs
index f1cb19ba..25e36ac2 100644
--- a/src/RustPlusBot.Persistence/Credentials/CredentialStore.cs
+++ b/src/RustPlusBot.Persistence/Credentials/CredentialStore.cs
@@ -1,4 +1,5 @@
using Microsoft.EntityFrameworkCore;
+using Persistord.Core;
using RustPlusBot.Abstractions.Credentials;
using RustPlusBot.Domain.Credentials;
@@ -17,39 +18,31 @@ public async Task UpsertFromPairingAsync(
{
ArgumentNullException.ThrowIfNull(request);
- var existing = await context.PlayerCredentials
- .SingleOrDefaultAsync(
+ // A resubmitted token revives an Invalid credential as Standby rather than promoting it: only
+ // a brand-new credential honours markActive, which is why that lives in the create branch.
+ var credential = await context.PlayerCredentials.UpsertAsync(
c => c.GuildId == request.GuildId
&& c.RustServerId == request.RustServerId
&& c.OwnerUserId == request.OwnerUserId,
+ () => new PlayerCredential
+ {
+ GuildId = request.GuildId,
+ RustServerId = request.RustServerId,
+ OwnerUserId = request.OwnerUserId,
+ Status = markActive ? CredentialStatus.Active : CredentialStatus.Standby,
+ },
+ row =>
+ {
+ row.SteamId = request.SteamId;
+ row.ProtectedPlayerToken = protector.Protect(request.PlayerToken);
+ if (row.Status == CredentialStatus.Invalid)
+ {
+ row.Status = CredentialStatus.Standby;
+ }
+ },
cancellationToken)
.ConfigureAwait(false);
- if (existing is not null)
- {
- existing.SteamId = request.SteamId;
- existing.ProtectedPlayerToken = protector.Protect(request.PlayerToken);
- if (existing.Status == CredentialStatus.Invalid)
- {
- existing.Status = CredentialStatus.Standby;
- }
-
- await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
- return existing.Id;
- }
-
- var credential = new PlayerCredential
- {
- GuildId = request.GuildId,
- RustServerId = request.RustServerId,
- OwnerUserId = request.OwnerUserId,
- SteamId = request.SteamId,
- ProtectedPlayerToken = protector.Protect(request.PlayerToken),
- Status = markActive ? CredentialStatus.Active : CredentialStatus.Standby,
- };
-
- context.PlayerCredentials.Add(credential);
- await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
return credential.Id;
}
diff --git a/src/RustPlusBot.Persistence/Credentials/FcmRegistrationStore.cs b/src/RustPlusBot.Persistence/Credentials/FcmRegistrationStore.cs
index 5b4fec39..eb55a840 100644
--- a/src/RustPlusBot.Persistence/Credentials/FcmRegistrationStore.cs
+++ b/src/RustPlusBot.Persistence/Credentials/FcmRegistrationStore.cs
@@ -1,6 +1,6 @@
using Microsoft.EntityFrameworkCore;
+using Persistord.Core;
using RustPlusBot.Abstractions.Credentials;
-using RustPlusBot.Abstractions.Time;
using RustPlusBot.Domain.Credentials;
namespace RustPlusBot.Persistence.Credentials;
@@ -8,8 +8,7 @@ namespace RustPlusBot.Persistence.Credentials;
/// EF-backed that protects credentials before persisting.
/// The bot database context.
/// Protects credential material before it is written.
-/// Supplies the update timestamp.
-public sealed class FcmRegistrationStore(BotDbContext context, ICredentialProtector protector, IClock clock)
+public sealed class FcmRegistrationStore(BotDbContext context, ICredentialProtector protector)
: IFcmRegistrationStore
{
///
@@ -21,47 +20,23 @@ public async Task UpsertAsync(
{
ArgumentNullException.ThrowIfNull(fcmCredentialsJson);
- var existing = await context.FcmRegistrations
- .SingleOrDefaultAsync(r => r.GuildId == guildId && r.OwnerUserId == ownerUserId, cancellationToken)
+ // Persistord's upsert owns the (guild, owner) unique-index race: it re-reads the winner once
+ // and applies the same mutation to it. UpdatedAt is stamped by the TimestampInterceptor.
+ var registration = await context.FcmRegistrations.UpsertAsync(
+ r => r.GuildId == guildId && r.OwnerUserId == ownerUserId,
+ () => new FcmRegistration
+ {
+ GuildId = guildId, OwnerUserId = ownerUserId
+ },
+ row =>
+ {
+ row.ProtectedFcmCredentials = protector.Protect(fcmCredentialsJson);
+ row.Status = FcmRegistrationStatus.Active;
+ },
+ cancellationToken)
.ConfigureAwait(false);
- if (existing is not null)
- {
- existing.ProtectedFcmCredentials = protector.Protect(fcmCredentialsJson);
- existing.Status = FcmRegistrationStatus.Active;
- existing.UpdatedAt = clock.UtcNow;
- await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
- return existing.Id;
- }
-
- var registration = new FcmRegistration
- {
- GuildId = guildId,
- OwnerUserId = ownerUserId,
- ProtectedFcmCredentials = protector.Protect(fcmCredentialsJson),
- Status = FcmRegistrationStatus.Active,
- UpdatedAt = clock.UtcNow,
- };
-
- context.FcmRegistrations.Add(registration);
- try
- {
- await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
- return registration.Id;
- }
- catch (DbUpdateException)
- {
- // A concurrent submission for the same (guild, owner) won the unique-index race; update the winner.
- context.Entry(registration).State = EntityState.Detached;
- var winner = await context.FcmRegistrations
- .SingleAsync(r => r.GuildId == guildId && r.OwnerUserId == ownerUserId, cancellationToken)
- .ConfigureAwait(false);
- winner.ProtectedFcmCredentials = protector.Protect(fcmCredentialsJson);
- winner.Status = FcmRegistrationStatus.Active;
- winner.UpdatedAt = clock.UtcNow;
- await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
- return winner.Id;
- }
+ return registration.Id;
}
///
@@ -86,7 +61,6 @@ public async Task SetStatusAsync(
}
registration.Status = status;
- registration.UpdatedAt = clock.UtcNow;
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
}
diff --git a/src/RustPlusBot.Persistence/Devices/PairedDeviceStore.cs b/src/RustPlusBot.Persistence/Devices/PairedDeviceStore.cs
index 1ea04936..59311758 100644
--- a/src/RustPlusBot.Persistence/Devices/PairedDeviceStore.cs
+++ b/src/RustPlusBot.Persistence/Devices/PairedDeviceStore.cs
@@ -1,7 +1,7 @@
using Microsoft.EntityFrameworkCore;
+using Persistord.Core;
using RustPlusBot.Abstractions.Connections;
using RustPlusBot.Abstractions.Devices;
-using RustPlusBot.Abstractions.Time;
using RustPlusBot.Domain.Devices;
namespace RustPlusBot.Persistence.Devices;
@@ -13,53 +13,37 @@ namespace RustPlusBot.Persistence.Devices;
///
/// The persisted device row.
/// The bot database context.
-/// Supplies the creation timestamp.
-public abstract class PairedDeviceStore(BotDbContext context, IClock clock) : IPairedDeviceStore
+public abstract class PairedDeviceStore(BotDbContext context) : IPairedDeviceStore
where TEntity : PairedDeviceEntity, new()
{
/// The device's table.
private DbSet Set => context.Set();
///
- public async Task AddAsync(
+ public Task AddAsync(
ulong guildId,
Guid serverId,
ulong entityId,
string name,
ulong pairedByUserId,
- CancellationToken cancellationToken = default)
- {
- var entity = new TEntity
- {
- GuildId = guildId,
- ServerId = serverId,
- EntityId = entityId,
- Name = name,
- PairedByUserId = pairedByUserId,
- CreatedUtc = clock.UtcNow,
- };
- Set.Add(entity);
- try
- {
- await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
- return entity;
- }
- catch (DbUpdateException)
- {
- // Two users accepted the same pending pairing concurrently (both saw ExistsAsync == false); the
- // unique (GuildId, ServerId, EntityId) index rejects the second insert. Recover idempotently by
- // detaching the failed insert and returning the row the winner persisted. If no such row exists,
- // the failure was not the uniqueness race — let it propagate.
- context.Entry(entity).State = EntityState.Detached;
- var existing = await GetAsync(guildId, serverId, entityId, cancellationToken).ConfigureAwait(false);
- if (existing is null)
+ CancellationToken cancellationToken = default) =>
+ // Adding is idempotent: two users can accept the same pending pairing concurrently, and the
+ // unique (GuildId, ServerId, EntityId) index rejects whichever insert lands second. The empty
+ // mutation is deliberate — the row the winner wrote is returned untouched, name and all —
+ // and Persistord recovers the race by re-reading that winner. CreatedAt is stamped by the
+ // TimestampInterceptor.
+ Set.UpsertAsync(
+ s => s.GuildId == guildId && s.ServerId == serverId && s.EntityId == entityId,
+ () => new TEntity
{
- throw;
- }
-
- return existing;
- }
- }
+ GuildId = guildId,
+ ServerId = serverId,
+ EntityId = entityId,
+ Name = name,
+ PairedByUserId = pairedByUserId,
+ },
+ _ => { },
+ cancellationToken);
/// Gets a device by identity, or null.
/// Owning Discord guild snowflake.
@@ -91,7 +75,7 @@ public async Task> ListByServerAsync(
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
- return [.. devices.OrderBy(s => s.CreatedUtc)];
+ return [.. devices.OrderBy(s => s.CreatedAt)];
}
///
diff --git a/src/RustPlusBot.Persistence/Maintenance/DatabaseMaintenanceService.cs b/src/RustPlusBot.Persistence/Maintenance/DatabaseMaintenanceService.cs
index 76e52496..662c1a3c 100644
--- a/src/RustPlusBot.Persistence/Maintenance/DatabaseMaintenanceService.cs
+++ b/src/RustPlusBot.Persistence/Maintenance/DatabaseMaintenanceService.cs
@@ -1,5 +1,4 @@
-using System.Globalization;
-using Microsoft.EntityFrameworkCore;
+using Persistord.Core;
namespace RustPlusBot.Persistence.Maintenance;
@@ -8,45 +7,10 @@ namespace RustPlusBot.Persistence.Maintenance;
public sealed class DatabaseMaintenanceService(BotDbContext context) : IDatabaseMaintenanceService
{
///
- public async Task ClearAllAsync(CancellationToken cancellationToken = default)
- {
- var tables = context.Model.GetEntityTypes()
- .Select(t => t.GetTableName())
- .Where(name => !string.IsNullOrEmpty(name))
- .Distinct(StringComparer.Ordinal)
- .ToList();
-
- // Wipe every table in one transaction so an interruption rolls back rather than leaving the
- // database partially cleared. defer_foreign_keys defers FK enforcement to commit time (and
- // resets itself when the transaction ends), so tables can be cleared in any order — once every
- // table is empty the commit-time check has nothing to violate. This also avoids leaving a
- // connection-level foreign_keys pragma toggled off on a pooled connection.
- var transaction = await context.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
- await using (transaction.ConfigureAwait(false))
- {
- await context.Database.ExecuteSqlRawAsync("PRAGMA defer_foreign_keys = ON", cancellationToken)
- .ConfigureAwait(false);
-
- foreach (var table in tables)
- {
- // Table names come from the EF model (never user input). Fail loud on an unexpected
- // identifier rather than silently skipping it and reporting a misleading success; the
- // guard also keeps the raw statement demonstrably injection-safe for the Sonar gate.
- if (!IsSafeIdentifier(table!))
- {
- throw new InvalidOperationException(
- string.Create(CultureInfo.InvariantCulture,
- $"Refusing to clear table with an unexpected identifier: '{table}'."));
- }
-
- var sql = string.Create(CultureInfo.InvariantCulture, $"DELETE FROM \"{table}\"");
- await context.Database.ExecuteSqlRawAsync(sql, cancellationToken).ConfigureAwait(false);
- }
-
- await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
- }
- }
-
- private static bool IsSafeIdentifier(string identifier) =>
- identifier.All(c => char.IsLetterOrDigit(c) || c == '_');
+ public Task ClearAllAsync(CancellationToken cancellationToken = default) =>
+ // Persistord deletes every mapped table dependents-first in one transaction, so an
+ // interruption rolls back rather than leaving the database partially cleared. It issues
+ // plain DELETEs through EF, which is why this no longer needs the SQLite-only
+ // defer_foreign_keys pragma nor a raw-SQL identifier guard.
+ context.ClearAllTablesAsync(cancellationToken);
}
diff --git a/src/RustPlusBot.Persistence/Map/MapSettingsStore.cs b/src/RustPlusBot.Persistence/Map/MapSettingsStore.cs
index d35a6089..26158eb4 100644
--- a/src/RustPlusBot.Persistence/Map/MapSettingsStore.cs
+++ b/src/RustPlusBot.Persistence/Map/MapSettingsStore.cs
@@ -1,4 +1,5 @@
using Microsoft.EntityFrameworkCore;
+using Persistord.Core;
using RustPlusBot.Abstractions.Connections;
using RustPlusBot.Domain.Map;
@@ -27,16 +28,34 @@ public async Task SetLayerAsync(ulong guildId,
Guid serverId,
MapLayer layer,
bool enabled,
- CancellationToken cancellationToken = default)
- {
- var existing = await context.ServerMapSettings
- .SingleOrDefaultAsync(s => s.GuildId == guildId && s.ServerId == serverId, cancellationToken)
+ CancellationToken cancellationToken = default) =>
+ await context.ServerMapSettings.UpsertAsync(
+ s => s.GuildId == guildId && s.ServerId == serverId,
+ () => new ServerMapSettings
+ {
+ GuildId = guildId, ServerId = serverId
+ },
+ row => ApplyLayer(row, layer, enabled),
+ cancellationToken)
.ConfigureAwait(false);
- var row = existing ?? new ServerMapSettings
- {
- GuildId = guildId, ServerId = serverId
- };
+ ///
+ public async Task SetGridStyleAsync(ulong guildId,
+ Guid serverId,
+ MapGridStyle style,
+ CancellationToken cancellationToken = default) =>
+ await context.ServerMapSettings.UpsertAsync(
+ s => s.GuildId == guildId && s.ServerId == serverId,
+ () => new ServerMapSettings
+ {
+ GuildId = guildId, ServerId = serverId
+ },
+ row => row.GridStyle = style,
+ cancellationToken)
+ .ConfigureAwait(false);
+
+ private static void ApplyLayer(ServerMapSettings row, MapLayer layer, bool enabled)
+ {
switch (layer)
{
case MapLayer.Grid:
@@ -63,35 +82,5 @@ public async Task SetLayerAsync(ulong guildId,
default:
throw new ArgumentOutOfRangeException(nameof(layer), layer, "Unknown map layer.");
}
-
- if (existing is null)
- {
- context.ServerMapSettings.Add(row);
- }
-
- await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
- }
-
- ///
- public async Task SetGridStyleAsync(ulong guildId,
- Guid serverId,
- MapGridStyle style,
- CancellationToken cancellationToken = default)
- {
- var existing = await context.ServerMapSettings
- .SingleOrDefaultAsync(s => s.GuildId == guildId && s.ServerId == serverId, cancellationToken)
- .ConfigureAwait(false);
-
- var row = existing ?? new ServerMapSettings
- {
- GuildId = guildId, ServerId = serverId
- };
- row.GridStyle = style;
- if (existing is null)
- {
- context.ServerMapSettings.Add(row);
- }
-
- await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
}
}
diff --git a/src/RustPlusBot.Persistence/Migrations/20260910143851_DropMirrorAndDeadTables.Designer.cs b/src/RustPlusBot.Persistence/Migrations/20260910143851_DropMirrorAndDeadTables.Designer.cs
new file mode 100644
index 00000000..0c778c32
--- /dev/null
+++ b/src/RustPlusBot.Persistence/Migrations/20260910143851_DropMirrorAndDeadTables.Designer.cs
@@ -0,0 +1,878 @@
+//
+using System;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using RustPlusBot.Persistence;
+
+#nullable disable
+
+namespace RustPlusBot.Persistence.Migrations
+{
+ [DbContext(typeof(BotDbContext))]
+ [Migration("20260910143851_DropMirrorAndDeadTables")]
+ partial class DropMirrorAndDeadTables
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder.HasAnnotation("ProductVersion", "10.0.11");
+
+ modelBuilder.Entity("RustPlusBot.Domain.Alarms.SmartAlarm", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedUtc")
+ .HasColumnType("TEXT");
+
+ b.Property("EntityId")
+ .HasColumnType("INTEGER");
+
+ b.Property("GuildId")
+ .HasColumnType("INTEGER");
+
+ b.Property("LastIsActive")
+ .HasColumnType("INTEGER");
+
+ b.Property("LastTriggeredUtc")
+ .HasColumnType("TEXT");
+
+ b.Property("MessageId")
+ .HasColumnType("INTEGER");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("TEXT");
+
+ b.Property("PairedByUserId")
+ .HasColumnType("INTEGER");
+
+ b.Property("PingEveryone")
+ .HasColumnType("INTEGER");
+
+ b.Property("Reachability")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER")
+ .HasDefaultValue(0);
+
+ b.Property("RelayToTeamChat")
+ .HasColumnType("INTEGER");
+
+ b.Property("ServerId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ServerId");
+
+ b.HasIndex("GuildId", "ServerId", "EntityId")
+ .IsUnique();
+
+ b.ToTable("SmartAlarms");
+ });
+
+ modelBuilder.Entity("RustPlusBot.Domain.Clans.ClanPlayerName", b =>
+ {
+ b.Property("ServerId")
+ .HasColumnType("TEXT");
+
+ b.Property("SteamId")
+ .HasColumnType("INTEGER");
+
+ b.Property("GuildId")
+ .HasColumnType("INTEGER");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("TEXT");
+
+ b.Property("UpdatedUtc")
+ .HasColumnType("TEXT");
+
+ b.HasKey("ServerId", "SteamId");
+
+ b.ToTable("ClanPlayerNames");
+ });
+
+ modelBuilder.Entity("RustPlusBot.Domain.Clans.ClanState", b =>
+ {
+ b.Property("ServerId")
+ .HasColumnType("TEXT");
+
+ b.Property("ClanId")
+ .HasColumnType("INTEGER");
+
+ b.Property("Color")
+ .HasColumnType("INTEGER");
+
+ b.Property("Created")
+ .HasColumnType("TEXT");
+
+ b.Property("Creator")
+ .HasColumnType("INTEGER");
+
+ b.Property("GuildId")
+ .HasColumnType("INTEGER");
+
+ b.Property("InvitesJson")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("LastSeenUtc")
+ .HasColumnType("TEXT");
+
+ b.Property("LogoHash")
+ .HasMaxLength(64)
+ .HasColumnType("TEXT");
+
+ b.Property("MaxMemberCount")
+ .HasColumnType("INTEGER");
+
+ b.Property("MembersJson")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("Motd")
+ .HasMaxLength(1024)
+ .HasColumnType("TEXT");
+
+ b.Property("MotdAuthor")
+ .HasColumnType("INTEGER");
+
+ b.Property("MotdTimestamp")
+ .HasColumnType("TEXT");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("TEXT");
+
+ b.Property("RolesJson")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("Score")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("ServerId");
+
+ b.ToTable("ClanStates");
+ });
+
+ modelBuilder.Entity("RustPlusBot.Domain.Commands.ServerCommandSettings", b =>
+ {
+ b.Property("ServerId")
+ .HasColumnType("TEXT");
+
+ b.Property("GuildId")
+ .HasColumnType("INTEGER");
+
+ b.Property("Muted")
+ .HasColumnType("INTEGER");
+
+ b.Property("Prefix")
+ .IsRequired()
+ .HasMaxLength(8)
+ .HasColumnType("TEXT");
+
+ b.HasKey("ServerId");
+
+ b.ToTable("ServerCommandSettings");
+ });
+
+ modelBuilder.Entity("RustPlusBot.Domain.Connections.ConnectionState", b =>
+ {
+ b.Property("RustServerId")
+ .HasColumnType("TEXT");
+
+ b.Property("ActiveCredentialId")
+ .HasColumnType("TEXT");
+
+ b.Property("GuildId")
+ .HasColumnType("INTEGER");
+
+ b.Property("PlayerCount")
+ .HasColumnType("INTEGER");
+
+ b.Property("Status")
+ .HasColumnType("INTEGER");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("TEXT");
+
+ b.HasKey("RustServerId");
+
+ b.ToTable("ConnectionStates");
+ });
+
+ modelBuilder.Entity("RustPlusBot.Domain.Credentials.FcmRegistration", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("GuildId")
+ .HasColumnType("INTEGER");
+
+ b.Property("OwnerUserId")
+ .HasColumnType("INTEGER");
+
+ b.Property("ProtectedFcmCredentials")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("Status")
+ .HasColumnType("INTEGER");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("GuildId", "OwnerUserId")
+ .IsUnique();
+
+ b.ToTable("FcmRegistrations");
+ });
+
+ modelBuilder.Entity("RustPlusBot.Domain.Credentials.PlayerCredential", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("GuildId")
+ .HasColumnType("INTEGER");
+
+ b.Property("OwnerUserId")
+ .HasColumnType("INTEGER");
+
+ b.Property("ProtectedPlayerToken")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("RustServerId")
+ .HasColumnType("TEXT");
+
+ b.Property("Status")
+ .HasColumnType("INTEGER");
+
+ b.Property("SteamId")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("RustServerId");
+
+ b.HasIndex("GuildId", "RustServerId", "OwnerUserId")
+ .IsUnique();
+
+ b.ToTable("PlayerCredentials");
+ });
+
+ modelBuilder.Entity("RustPlusBot.Domain.Guilds.GuildSettings", b =>
+ {
+ b.Property("GuildId")
+ .HasColumnType("INTEGER");
+
+ b.Property("Culture")
+ .IsRequired()
+ .HasMaxLength(16)
+ .HasColumnType("TEXT");
+
+ b.Property("PingEveryoneOnWipe")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("GuildId");
+
+ b.ToTable("GuildSettings");
+ });
+
+ modelBuilder.Entity("RustPlusBot.Domain.Map.ServerMapSettings", b =>
+ {
+ b.Property("ServerId")
+ .HasColumnType("TEXT");
+
+ b.Property("GridStyle")
+ .HasColumnType("INTEGER");
+
+ b.Property("GuildId")
+ .HasColumnType("INTEGER");
+
+ b.Property("ShowGrid")
+ .HasColumnType("INTEGER");
+
+ b.Property("ShowMarkers")
+ .HasColumnType("INTEGER");
+
+ b.Property("ShowMonuments")
+ .HasColumnType("INTEGER");
+
+ b.Property("ShowPlayers")
+ .HasColumnType("INTEGER");
+
+ b.Property("ShowRigs")
+ .HasColumnType("INTEGER");
+
+ b.Property("ShowTunnels")
+ .HasColumnType("INTEGER");
+
+ b.Property("ShowVendor")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("ServerId");
+
+ b.ToTable("ServerMapSettings");
+ });
+
+ modelBuilder.Entity("RustPlusBot.Domain.Servers.RustServer", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("AddedByUserId")
+ .HasColumnType("INTEGER");
+
+ b.Property("FacepunchServerId")
+ .HasColumnType("TEXT");
+
+ b.Property("GuildId")
+ .HasColumnType("INTEGER");
+
+ b.Property("Ip")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("TEXT");
+
+ b.Property("LastMapSeed")
+ .HasColumnType("INTEGER");
+
+ b.Property("LastMapSize")
+ .HasColumnType("INTEGER");
+
+ b.Property("LastWipeTimeUtc")
+ .HasColumnType("TEXT");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("TEXT");
+
+ b.Property("Port")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("FacepunchServerId");
+
+ b.HasIndex("GuildId");
+
+ b.HasIndex("GuildId", "Ip", "Port")
+ .IsUnique();
+
+ b.ToTable("RustServers");
+ });
+
+ modelBuilder.Entity("RustPlusBot.Domain.StorageMonitors.SmartStorageMonitor", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedUtc")
+ .HasColumnType("TEXT");
+
+ b.Property("EntityId")
+ .HasColumnType("INTEGER");
+
+ b.Property("GuildId")
+ .HasColumnType("INTEGER");
+
+ b.Property("MessageId")
+ .HasColumnType("INTEGER");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("TEXT");
+
+ b.Property("PairedByUserId")
+ .HasColumnType("INTEGER");
+
+ b.Property("Reachability")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER")
+ .HasDefaultValue(0);
+
+ b.Property("ServerId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ServerId");
+
+ b.HasIndex("GuildId", "ServerId", "EntityId")
+ .IsUnique();
+
+ b.ToTable("SmartStorageMonitors");
+ });
+
+ modelBuilder.Entity("RustPlusBot.Domain.Switches.SmartSwitch", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedUtc")
+ .HasColumnType("TEXT");
+
+ b.Property("EntityId")
+ .HasColumnType("INTEGER");
+
+ b.Property("GuildId")
+ .HasColumnType("INTEGER");
+
+ b.Property("LastIsActive")
+ .HasColumnType("INTEGER");
+
+ b.Property("MessageId")
+ .HasColumnType("INTEGER");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("TEXT");
+
+ b.Property("PairedByUserId")
+ .HasColumnType("INTEGER");
+
+ b.Property("Reachability")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER")
+ .HasDefaultValue(0);
+
+ b.Property("ServerId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ServerId");
+
+ b.HasIndex("GuildId", "ServerId", "EntityId")
+ .IsUnique();
+
+ b.ToTable("SmartSwitches");
+ });
+
+ modelBuilder.Entity("RustPlusBot.Domain.Vending.VendingGridTrack", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedUtc")
+ .HasColumnType("TEXT");
+
+ b.Property("Grid")
+ .IsRequired()
+ .HasMaxLength(8)
+ .HasColumnType("TEXT");
+
+ b.Property("GuildId")
+ .HasColumnType("INTEGER");
+
+ b.Property("RegisteredBySteamId")
+ .HasColumnType("INTEGER");
+
+ b.Property("ServerId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ServerId");
+
+ b.HasIndex("GuildId", "ServerId", "Grid")
+ .IsUnique();
+
+ b.ToTable("VendingGridTracks");
+ });
+
+ modelBuilder.Entity("RustPlusBot.Domain.Vending.VendingListingTrack", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("CostPerOrder")
+ .HasColumnType("INTEGER");
+
+ b.Property("CreatedUtc")
+ .HasColumnType("TEXT");
+
+ b.Property("CurrencyId")
+ .HasColumnType("INTEGER");
+
+ b.Property("CurrencyIsBlueprint")
+ .HasColumnType("INTEGER");
+
+ b.Property("GuildId")
+ .HasColumnType("INTEGER");
+
+ b.Property("ItemId")
+ .HasColumnType("INTEGER");
+
+ b.Property("ItemIsBlueprint")
+ .HasColumnType("INTEGER");
+
+ b.Property("Quantity")
+ .HasColumnType("INTEGER");
+
+ b.Property("RegisteredByUserId")
+ .HasColumnType("INTEGER");
+
+ b.Property("ServerId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ServerId");
+
+ b.HasIndex("GuildId", "ServerId", "ItemId", "ItemIsBlueprint", "CurrencyId", "CurrencyIsBlueprint")
+ .IsUnique();
+
+ b.ToTable("VendingListingTracks");
+ });
+
+ modelBuilder.Entity("RustPlusBot.Domain.Vending.VendingNotification", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("CurrencyId")
+ .HasColumnType("INTEGER");
+
+ b.Property("CurrencyIsBlueprint")
+ .HasColumnType("INTEGER");
+
+ b.Property("GuildId")
+ .HasColumnType("INTEGER");
+
+ b.Property("ItemId")
+ .HasColumnType("INTEGER");
+
+ b.Property("ItemIsBlueprint")
+ .HasColumnType("INTEGER");
+
+ b.Property("MessageId")
+ .HasColumnType("INTEGER");
+
+ b.Property("PostedUtc")
+ .HasColumnType("TEXT");
+
+ b.Property("ReferenceCostPerOrder")
+ .HasColumnType("INTEGER");
+
+ b.Property("ReferenceQuantity")
+ .HasColumnType("INTEGER");
+
+ b.Property("ServerId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ServerId");
+
+ b.HasIndex("GuildId", "ServerId", "ItemId", "ItemIsBlueprint", "CurrencyId", "CurrencyIsBlueprint")
+ .IsUnique();
+
+ b.ToTable("VendingNotifications");
+ });
+
+ modelBuilder.Entity("RustPlusBot.Domain.Vending.VendingStockNotification", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("GuildId")
+ .HasColumnType("INTEGER");
+
+ b.Property("MachineId")
+ .HasColumnType("INTEGER");
+
+ b.Property("MessageId")
+ .HasColumnType("INTEGER");
+
+ b.Property("PostedUtc")
+ .HasColumnType("TEXT");
+
+ b.Property("ServerId")
+ .HasColumnType("TEXT");
+
+ b.Property("SoldOutSignature")
+ .IsRequired()
+ .HasMaxLength(512)
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ServerId");
+
+ b.HasIndex("GuildId", "ServerId", "MachineId")
+ .IsUnique();
+
+ b.ToTable("VendingStockNotifications");
+ });
+
+ modelBuilder.Entity("RustPlusBot.Domain.Workspace.ProvisionedCategory", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedAt")
+ .HasColumnType("TEXT");
+
+ b.Property("DiscordCategoryId")
+ .HasColumnType("INTEGER");
+
+ b.Property("GuildId")
+ .HasColumnType("INTEGER");
+
+ b.Property("RustServerId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("RustServerId");
+
+ b.HasIndex("GuildId", "RustServerId")
+ .IsUnique();
+
+ b.ToTable("ProvisionedCategories");
+ });
+
+ modelBuilder.Entity("RustPlusBot.Domain.Workspace.ProvisionedChannel", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("ChannelKey")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedAt")
+ .HasColumnType("TEXT");
+
+ b.Property("DiscordChannelId")
+ .HasColumnType("INTEGER");
+
+ b.Property("GuildId")
+ .HasColumnType("INTEGER");
+
+ b.Property("RustServerId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("RustServerId");
+
+ b.HasIndex("GuildId", "RustServerId", "ChannelKey")
+ .IsUnique();
+
+ b.ToTable("ProvisionedChannels");
+ });
+
+ modelBuilder.Entity("RustPlusBot.Domain.Workspace.ProvisionedMessage", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedAt")
+ .HasColumnType("TEXT");
+
+ b.Property("DiscordChannelId")
+ .HasColumnType("INTEGER");
+
+ b.Property("DiscordMessageId")
+ .HasColumnType("INTEGER");
+
+ b.Property("GuildId")
+ .HasColumnType("INTEGER");
+
+ b.Property("MessageKey")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("TEXT");
+
+ b.Property("RustServerId")
+ .HasColumnType("TEXT");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("RustServerId");
+
+ b.HasIndex("GuildId", "RustServerId", "MessageKey")
+ .IsUnique();
+
+ b.ToTable("ProvisionedMessages");
+ });
+
+ modelBuilder.Entity("RustPlusBot.Domain.Alarms.SmartAlarm", b =>
+ {
+ b.HasOne("RustPlusBot.Domain.Servers.RustServer", null)
+ .WithMany()
+ .HasForeignKey("ServerId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("RustPlusBot.Domain.Clans.ClanPlayerName", b =>
+ {
+ b.HasOne("RustPlusBot.Domain.Servers.RustServer", null)
+ .WithMany()
+ .HasForeignKey("ServerId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("RustPlusBot.Domain.Clans.ClanState", b =>
+ {
+ b.HasOne("RustPlusBot.Domain.Servers.RustServer", null)
+ .WithOne()
+ .HasForeignKey("RustPlusBot.Domain.Clans.ClanState", "ServerId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("RustPlusBot.Domain.Commands.ServerCommandSettings", b =>
+ {
+ b.HasOne("RustPlusBot.Domain.Servers.RustServer", null)
+ .WithOne()
+ .HasForeignKey("RustPlusBot.Domain.Commands.ServerCommandSettings", "ServerId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("RustPlusBot.Domain.Connections.ConnectionState", b =>
+ {
+ b.HasOne("RustPlusBot.Domain.Servers.RustServer", null)
+ .WithOne()
+ .HasForeignKey("RustPlusBot.Domain.Connections.ConnectionState", "RustServerId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("RustPlusBot.Domain.Credentials.PlayerCredential", b =>
+ {
+ b.HasOne("RustPlusBot.Domain.Servers.RustServer", null)
+ .WithMany()
+ .HasForeignKey("RustServerId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("RustPlusBot.Domain.Map.ServerMapSettings", b =>
+ {
+ b.HasOne("RustPlusBot.Domain.Servers.RustServer", null)
+ .WithOne()
+ .HasForeignKey("RustPlusBot.Domain.Map.ServerMapSettings", "ServerId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("RustPlusBot.Domain.StorageMonitors.SmartStorageMonitor", b =>
+ {
+ b.HasOne("RustPlusBot.Domain.Servers.RustServer", null)
+ .WithMany()
+ .HasForeignKey("ServerId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("RustPlusBot.Domain.Switches.SmartSwitch", b =>
+ {
+ b.HasOne("RustPlusBot.Domain.Servers.RustServer", null)
+ .WithMany()
+ .HasForeignKey("ServerId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("RustPlusBot.Domain.Vending.VendingGridTrack", b =>
+ {
+ b.HasOne("RustPlusBot.Domain.Servers.RustServer", null)
+ .WithMany()
+ .HasForeignKey("ServerId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("RustPlusBot.Domain.Vending.VendingListingTrack", b =>
+ {
+ b.HasOne("RustPlusBot.Domain.Servers.RustServer", null)
+ .WithMany()
+ .HasForeignKey("ServerId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("RustPlusBot.Domain.Vending.VendingNotification", b =>
+ {
+ b.HasOne("RustPlusBot.Domain.Servers.RustServer", null)
+ .WithMany()
+ .HasForeignKey("ServerId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("RustPlusBot.Domain.Vending.VendingStockNotification", b =>
+ {
+ b.HasOne("RustPlusBot.Domain.Servers.RustServer", null)
+ .WithMany()
+ .HasForeignKey("ServerId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("RustPlusBot.Domain.Workspace.ProvisionedCategory", b =>
+ {
+ b.HasOne("RustPlusBot.Domain.Servers.RustServer", null)
+ .WithMany()
+ .HasForeignKey("RustServerId")
+ .OnDelete(DeleteBehavior.Cascade);
+ });
+
+ modelBuilder.Entity("RustPlusBot.Domain.Workspace.ProvisionedChannel", b =>
+ {
+ b.HasOne("RustPlusBot.Domain.Servers.RustServer", null)
+ .WithMany()
+ .HasForeignKey("RustServerId")
+ .OnDelete(DeleteBehavior.Cascade);
+ });
+
+ modelBuilder.Entity("RustPlusBot.Domain.Workspace.ProvisionedMessage", b =>
+ {
+ b.HasOne("RustPlusBot.Domain.Servers.RustServer", null)
+ .WithMany()
+ .HasForeignKey("RustServerId")
+ .OnDelete(DeleteBehavior.Cascade);
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/src/RustPlusBot.Persistence/Migrations/20260910143851_DropMirrorAndDeadTables.cs b/src/RustPlusBot.Persistence/Migrations/20260910143851_DropMirrorAndDeadTables.cs
new file mode 100644
index 00000000..7f6f64df
--- /dev/null
+++ b/src/RustPlusBot.Persistence/Migrations/20260910143851_DropMirrorAndDeadTables.cs
@@ -0,0 +1,171 @@
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace RustPlusBot.Persistence.Migrations
+{
+ ///
+ public partial class DropMirrorAndDeadTables : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropTable(
+ name: "Channels");
+
+ migrationBuilder.DropTable(
+ name: "EventSubscriptions");
+
+ migrationBuilder.DropTable(
+ name: "Guilds");
+
+ migrationBuilder.DropTable(
+ name: "Members");
+
+ migrationBuilder.DropTable(
+ name: "PairedEntities");
+
+ migrationBuilder.DropTable(
+ name: "Roles");
+
+ migrationBuilder.DropTable(
+ name: "Users");
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.CreateTable(
+ name: "Channels",
+ columns: table => new
+ {
+ Id = table.Column(type: "INTEGER", nullable: false),
+ GuildId = table.Column(type: "INTEGER", nullable: false),
+ Name = table.Column(type: "TEXT", nullable: false),
+ ParentId = table.Column(type: "INTEGER", nullable: true),
+ Type = table.Column(type: "INTEGER", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_Channels", x => x.Id);
+ table.ForeignKey(
+ name: "FK_Channels_Channels_ParentId",
+ column: x => x.ParentId,
+ principalTable: "Channels",
+ principalColumn: "Id",
+ onDelete: ReferentialAction.Restrict);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "EventSubscriptions",
+ columns: table => new
+ {
+ Id = table.Column(type: "TEXT", nullable: false),
+ EventKey = table.Column(type: "TEXT", maxLength: 64, nullable: false),
+ GuildId = table.Column(type: "INTEGER", nullable: false),
+ RustServerId = table.Column(type: "TEXT", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_EventSubscriptions", x => x.Id);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "Guilds",
+ columns: table => new
+ {
+ Id = table.Column(type: "INTEGER", nullable: false),
+ Name = table.Column(type: "TEXT", nullable: false),
+ OwnerId = table.Column(type: "INTEGER", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_Guilds", x => x.Id);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "Members",
+ columns: table => new
+ {
+ GuildId = table.Column(type: "INTEGER", nullable: false),
+ UserId = table.Column(type: "INTEGER", nullable: false),
+ JoinedAt = table.Column(type: "TEXT", nullable: true),
+ Nickname = table.Column(type: "TEXT", nullable: true)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_Members", x => new { x.GuildId, x.UserId });
+ });
+
+ migrationBuilder.CreateTable(
+ name: "PairedEntities",
+ columns: table => new
+ {
+ Id = table.Column(type: "TEXT", nullable: false),
+ EntityId = table.Column(type: "INTEGER", nullable: false),
+ GuildId = table.Column(type: "INTEGER", nullable: false),
+ Kind = table.Column(type: "INTEGER", nullable: false),
+ Name = table.Column(type: "TEXT", maxLength: 128, nullable: false),
+ RustServerId = table.Column(type: "TEXT", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_PairedEntities", x => x.Id);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "Roles",
+ columns: table => new
+ {
+ Id = table.Column(type: "INTEGER", nullable: false),
+ Color = table.Column(type: "INTEGER", nullable: false),
+ GuildId = table.Column(type: "INTEGER", nullable: false),
+ Name = table.Column(type: "TEXT", nullable: false),
+ Permissions = table.Column(type: "INTEGER", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_Roles", x => x.Id);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "Users",
+ columns: table => new
+ {
+ Id = table.Column(type: "INTEGER", nullable: false),
+ GlobalName = table.Column(type: "TEXT", nullable: true),
+ Username = table.Column(type: "TEXT", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_Users", x => x.Id);
+ });
+
+ migrationBuilder.CreateIndex(
+ name: "IX_Channels_GuildId",
+ table: "Channels",
+ column: "GuildId");
+
+ migrationBuilder.CreateIndex(
+ name: "IX_Channels_ParentId",
+ table: "Channels",
+ column: "ParentId");
+
+ migrationBuilder.CreateIndex(
+ name: "IX_EventSubscriptions_GuildId_RustServerId",
+ table: "EventSubscriptions",
+ columns: new[] { "GuildId", "RustServerId" });
+
+ migrationBuilder.CreateIndex(
+ name: "IX_PairedEntities_GuildId_RustServerId",
+ table: "PairedEntities",
+ columns: new[] { "GuildId", "RustServerId" });
+
+ migrationBuilder.CreateIndex(
+ name: "IX_Roles_GuildId",
+ table: "Roles",
+ column: "GuildId");
+ }
+ }
+}
diff --git a/src/RustPlusBot.Persistence/Migrations/20260910145042_StampedTimestamps.Designer.cs b/src/RustPlusBot.Persistence/Migrations/20260910145042_StampedTimestamps.Designer.cs
new file mode 100644
index 00000000..0b1a1b14
--- /dev/null
+++ b/src/RustPlusBot.Persistence/Migrations/20260910145042_StampedTimestamps.Designer.cs
@@ -0,0 +1,878 @@
+//
+using System;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using RustPlusBot.Persistence;
+
+#nullable disable
+
+namespace RustPlusBot.Persistence.Migrations
+{
+ [DbContext(typeof(BotDbContext))]
+ [Migration("20260910145042_StampedTimestamps")]
+ partial class StampedTimestamps
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder.HasAnnotation("ProductVersion", "10.0.11");
+
+ modelBuilder.Entity("RustPlusBot.Domain.Alarms.SmartAlarm", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedAt")
+ .HasColumnType("TEXT");
+
+ b.Property("EntityId")
+ .HasColumnType("INTEGER");
+
+ b.Property("GuildId")
+ .HasColumnType("INTEGER");
+
+ b.Property("LastIsActive")
+ .HasColumnType("INTEGER");
+
+ b.Property("LastTriggeredUtc")
+ .HasColumnType("TEXT");
+
+ b.Property("MessageId")
+ .HasColumnType("INTEGER");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("TEXT");
+
+ b.Property("PairedByUserId")
+ .HasColumnType("INTEGER");
+
+ b.Property("PingEveryone")
+ .HasColumnType("INTEGER");
+
+ b.Property("Reachability")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER")
+ .HasDefaultValue(0);
+
+ b.Property("RelayToTeamChat")
+ .HasColumnType("INTEGER");
+
+ b.Property("ServerId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ServerId");
+
+ b.HasIndex("GuildId", "ServerId", "EntityId")
+ .IsUnique();
+
+ b.ToTable("SmartAlarms");
+ });
+
+ modelBuilder.Entity("RustPlusBot.Domain.Clans.ClanPlayerName", b =>
+ {
+ b.Property