diff --git a/SharpChat.MariaDB/MariaDBMessageStorage.cs b/SharpChat.MariaDB/MariaDBMessageStorage.cs
index 8b7c25a..47179a7 100644
--- a/SharpChat.MariaDB/MariaDBMessageStorage.cs
+++ b/SharpChat.MariaDB/MariaDBMessageStorage.cs
@@ -10,6 +10,34 @@ using ZLogger;
 namespace SharpChat.MariaDB;
 
 public class MariaDBMessageStorage(MariaDBStorage storage, ILogger logger) : MessageStorage {
+    public async Task LogMessage(Message msg) {
+        try {
+            using MariaDBConnection conn = await storage.CreateConnection();
+            await conn.RunCommand(
+                "INSERT IGNORE INTO sqc_events (event_id, event_type, event_channel, event_data"
+                + ", event_sender, event_sender_name, event_sender_colour, event_sender_rank, event_sender_nick, event_sender_perms"
+                + ", event_created, event_deleted)"
+                + " VALUES (@id, @type, @channel, @data"
+                + ", @sender, @sender_name, @sender_colour, @sender_rank, @sender_nick, @sender_perms"
+                + ", FROM_UNIXTIME(@created), FROM_UNIXTIME(@deleted))",
+                new MySqlParameter("id", msg.Id),
+                new MySqlParameter("type", msg.Type),
+                new MySqlParameter("channel", string.IsNullOrWhiteSpace(msg.ChannelName) ? null : msg.ChannelName),
+                new MySqlParameter("data", JsonSerializer.SerializeToUtf8Bytes(msg.Data)),
+                new MySqlParameter("sender", long.TryParse(msg.SenderId, out long senderId64) && senderId64 > 0 ? senderId64 : null),
+                new MySqlParameter("sender_name", msg.SenderName),
+                new MySqlParameter("sender_colour", msg.SenderColour.ToMisuzu()),
+                new MySqlParameter("sender_rank", msg.SenderRank),
+                new MySqlParameter("sender_nick", string.IsNullOrWhiteSpace(msg.SenderNickName) ? null : msg.SenderNickName),
+                new MySqlParameter("sender_perms", MariaDBUserPermissionsConverter.To(msg.SenderPermissions)),
+                new MySqlParameter("created", msg.Created.ToUnixTimeSeconds()),
+                new MySqlParameter("deleted", msg.Deleted?.ToUnixTimeSeconds())
+            );
+        } catch(MySqlException ex) {
+            logger.ZLogError($"Error in LogMessage(Message): {ex}");
+        }
+    }
+
     public async Task LogMessage(
         long id,
         string type,
@@ -41,7 +69,7 @@ public class MariaDBMessageStorage(MariaDBStorage storage, ILogger logger) : Mes
                 new MySqlParameter("sender_perms", MariaDBUserPermissionsConverter.To(senderPerms))
             );
         } catch(MySqlException ex) {
-            logger.ZLogError($"Error in LogMessage(): {ex}");
+            logger.ZLogError($"Error in LogMessage(long, ...): {ex}");
         }
     }
 
@@ -103,6 +131,34 @@ public class MariaDBMessageStorage(MariaDBStorage storage, ILogger logger) : Mes
         return null;
     }
 
+    public async Task<long> CountMessages(
+        string? channelName = null,
+        bool includeDeleted = false
+    ) {
+        List<MySqlParameter> parameters = [];
+        bool firstParam = true;
+        StringBuilder qb = new();
+        qb.Append("SELECT COUNT(*) FROM sqc_events");
+
+        if(!includeDeleted) {
+            firstParam = false;
+            qb.Append(" WHERE event_deleted IS NULL");
+        }
+
+        if(!string.IsNullOrEmpty(channelName)) {
+            qb.AppendFormat(" {0} (event_channel = @channel OR event_channel IS NULL)", firstParam ? "WHERE" : "AND");
+            parameters.Add(new MySqlParameter("channel", channelName));
+        }
+
+        try {
+            using MariaDBConnection conn = await storage.CreateConnection();
+            return await conn.RunQueryValue<long>(qb.ToString(), [.. parameters]);
+        } catch(MySqlException ex) {
+            logger.ZLogError($"Error in CountMessages({channelName}, {includeDeleted}): {ex}");
+            return 0;
+        }
+    }
+
     public async Task<IEnumerable<Message>> GetMessages(
         string? channelName = null,
         int? take = 20,
diff --git a/SharpChat.SQLite/SQLiteMessageStorage.cs b/SharpChat.SQLite/SQLiteMessageStorage.cs
index 881e929..aacbb89 100644
--- a/SharpChat.SQLite/SQLiteMessageStorage.cs
+++ b/SharpChat.SQLite/SQLiteMessageStorage.cs
@@ -11,6 +11,29 @@ using ZLogger;
 namespace SharpChat.SQLite;
 
 public class SQLiteMessageStorage(ILogger logger, SQLiteConnection conn) : MessageStorage {
+    public async Task LogMessage(Message msg) {
+        try {
+            await conn.RunCommand(
+                "INSERT OR IGNORE INTO messages (msg_id, msg_type, msg_created, msg_channel, msg_sender, msg_sender_name, msg_sender_colour, msg_sender_rank, msg_sender_nick, msg_sender_perms, msg_data)"
+                + " VALUES (@id, @type, @created, @channel, @sender, @sender_name, @sender_colour, @sender_rank, @sender_nick, @sender_perms, @data)",
+                new SQLiteParameter("id", msg.Id),
+                new SQLiteParameter("type", msg.Type),
+                new SQLiteParameter("channel", string.IsNullOrWhiteSpace(msg.ChannelName) ? null : msg.ChannelName),
+                new SQLiteParameter("data", JsonSerializer.SerializeToUtf8Bytes(msg.Data)),
+                new SQLiteParameter("sender", long.TryParse(msg.SenderId, out long senderId64) && senderId64 > 0 ? senderId64 : null),
+                new SQLiteParameter("sender_name", msg.SenderName),
+                new SQLiteParameter("sender_colour", msg.SenderColour.Rgb.HasValue ? msg.SenderColour.Rgb.Value.Raw : null),
+                new SQLiteParameter("sender_rank", msg.SenderRank),
+                new SQLiteParameter("sender_nick", string.IsNullOrWhiteSpace(msg.SenderNickName) ? null : msg.SenderNickName),
+                new SQLiteParameter("sender_perms", SQLiteUserPermissionsConverter.To(msg.SenderPermissions)),
+                new SQLiteParameter("created", $"{msg.Created:s}Z"),
+                new SQLiteParameter("deleted", msg.Deleted is null ? null : $"{msg.Deleted:s}Z")
+            );
+        } catch(SQLiteException ex) {
+            logger.ZLogError($"Error in LogMessage(Message): {ex}");
+        }
+    }
+
     public async Task LogMessage(
         long id,
         string type,
@@ -40,7 +63,7 @@ public class SQLiteMessageStorage(ILogger logger, SQLiteConnection conn) : Messa
                 new SQLiteParameter("data", data == null ? "{}" : JsonSerializer.SerializeToUtf8Bytes(data))
             );
         } catch(SQLiteException ex) {
-            logger.ZLogError($"Error in LogMessage(): {ex}");
+            logger.ZLogError($"Error in LogMessage(long, ...): {ex}");
         }
     }
 
@@ -61,7 +84,7 @@ public class SQLiteMessageStorage(ILogger logger, SQLiteConnection conn) : Messa
             reader.GetString("msg_type"),
             reader.IsDBNull(reader.GetOrdinal("msg_sender")) ? null : reader.GetString("msg_sender"),
             reader.IsDBNull(reader.GetOrdinal("msg_sender_name")) ? string.Empty : reader.GetString("msg_sender_name"),
-            ColourInheritable.FromMisuzu((int)reader.GetInt64("msg_sender_colour")),
+            reader.IsDBNull(reader.GetOrdinal("msg_sender_colour")) ? ColourInheritable.None : ColourInheritable.FromRaw((int)reader.GetInt64("msg_sender_colour")),
             (int)reader.GetInt64("msg_sender_rank"),
             SQLiteUserPermissionsConverter.From((SQLiteUserPermissions)reader.GetInt64("msg_sender_perms")),
             reader.IsDBNull(reader.GetOrdinal("msg_sender_nick")) ? string.Empty : reader.GetString("msg_sender_nick"),
@@ -87,6 +110,33 @@ public class SQLiteMessageStorage(ILogger logger, SQLiteConnection conn) : Messa
         }
     }
 
+    public async Task<long> CountMessages(
+        string? channelName = null,
+        bool includeDeleted = false
+    ) {
+        List<SQLiteParameter> parameters = [];
+        bool firstParam = true;
+        StringBuilder qb = new();
+        qb.Append("SELECT COUNT(*) FROM messages");
+
+        if(!includeDeleted) {
+            firstParam = false;
+            qb.Append(" WHERE msg_deleted IS NULL");
+        }
+
+        if(!string.IsNullOrEmpty(channelName)) {
+            qb.AppendFormat(" {0} (msg_channel = @channel OR msg_channel IS NULL)", firstParam ? "WHERE" : "AND");
+            parameters.Add(new SQLiteParameter("channel", channelName));
+        }
+
+        try {
+            return await conn.RunQueryValue<long>(qb.ToString(), [.. parameters]);
+        } catch(SQLiteException ex) {
+            logger.ZLogError($"Error in CountMessages({channelName}, {includeDeleted}): {ex}");
+            return 0;
+        }
+    }
+
     public async Task<IEnumerable<Message>> GetMessages(
         string? channelName = null,
         int? take = 20,
diff --git a/SharpChat/Program.cs b/SharpChat/Program.cs
index 5eb2a1f..c130d80 100644
--- a/SharpChat/Program.cs
+++ b/SharpChat/Program.cs
@@ -154,57 +154,10 @@ using StreamConfig config = StreamConfig.FromPath(configFile);
 
 if(cts.IsCancellationRequested) return;
 
-if(args.Contains("--convert-db")) {
-    logger.ZLogInformation($"Converting MariaDB storage to SQLite");
-
-    MariaDBStorage mariadbStorage = new(logFactory.CreateLogger("mariadb"), MariaDBStorage.BuildConnectionString(config.ScopeTo("mariadb")));
-    await mariadbStorage.UpgradeStorage();
-
+if(args.Contains("--migrate-storage") || args.Contains("--convert-db")) {
+    MariaDBStorage mariadb = new(logFactory.CreateLogger("mariadb"), MariaDBStorage.BuildConnectionString(config.ScopeTo("mariadb")));
     using SQLiteStorage sqlite = new(logFactory.CreateLogger("sqlite"), SQLiteStorage.BuildConnectionString(config.ScopeTo("sqlite"), false));
-    await sqlite.UpgradeStorage();
-
-    using MariaDBConnection mariadb = await mariadbStorage.CreateConnection();
-    long rows = await mariadb.RunQueryValue<long>("SELECT COUNT(*) FROM sqc_events");
-
-    using MySqlCommand export = mariadb.Connection.CreateCommand();
-    export.CommandText = "SELECT event_id, event_type, UNIX_TIMESTAMP(event_created) AS event_created, UNIX_TIMESTAMP(event_deleted) AS event_deleted, event_channel, event_sender, event_sender_name, event_sender_colour, event_sender_rank, event_sender_nick, event_sender_perms, event_data FROM sqc_events";
-    export.CommandTimeout = int.MaxValue;
-    using MySqlDataReader reader = await export.ExecuteReaderAsync();
-
-    using SQLiteCommand import = sqlite.Connection.Connection.CreateCommand();
-    import.CommandText = "INSERT OR IGNORE INTO messages (msg_id, msg_type, msg_created, msg_deleted, msg_channel, msg_sender, msg_sender_name, msg_sender_colour, msg_sender_rank, msg_sender_nick, msg_sender_perms, msg_data) VALUES (@id, @type, @created, @deleted, @channel, @sender, @sender_name, @sender_colour, @sender_rank, @sender_nick, @sender_perms, @data)";
-
-    long completed = 0;
-    DateTimeOffset lastReport = DateTimeOffset.UtcNow;
-    logger.ZLogInformation($"Beginning conversion of {rows} rows...");
-    while(reader.Read()) {
-        if(cts.IsCancellationRequested) return;
-
-        import.Parameters.Clear();
-        import.Parameters.Add(new SQLiteParameter("id", reader.GetInt64("event_id").ToString()));
-        import.Parameters.Add(new SQLiteParameter("type", reader.GetString("event_type")));
-        import.Parameters.Add(new SQLiteParameter("created", DateTimeOffset.FromUnixTimeSeconds(reader.GetInt32("event_created")).ToString("s") + "Z"));
-        import.Parameters.Add(new SQLiteParameter("deleted", reader.IsDBNull(reader.GetOrdinal("event_deleted")) ? null : DateTimeOffset.FromUnixTimeSeconds(reader.GetInt32("event_deleted")).ToString("s") + "Z"));
-        import.Parameters.Add(new SQLiteParameter("channel", reader.IsDBNull(reader.GetOrdinal("event_channel")) ? null : reader.GetString("event_channel")));
-        import.Parameters.Add(new SQLiteParameter("sender", reader.IsDBNull(reader.GetOrdinal("event_sender")) ? null : reader.GetString("event_sender")));
-        import.Parameters.Add(new SQLiteParameter("sender_name", reader.IsDBNull(reader.GetOrdinal("event_sender_name")) ? string.Empty : reader.GetString("event_sender_name")));
-        ColourInheritable colour = ColourInheritable.FromMisuzu(reader.GetInt32("event_sender_colour"));
-        import.Parameters.Add(new SQLiteParameter("sender_colour", colour.Rgb.HasValue ? colour.Rgb.Value : null));
-        import.Parameters.Add(new SQLiteParameter("sender_rank", reader.IsDBNull(reader.GetOrdinal("event_sender_rank")) ? null : reader.GetInt32("event_sender_rank")));
-        import.Parameters.Add(new SQLiteParameter("sender_nick", reader.IsDBNull(reader.GetOrdinal("event_sender_nick")) ? string.Empty : reader.GetString("event_sender_nick")));
-        import.Parameters.Add(new SQLiteParameter("sender_perms", SQLiteUserPermissionsConverter.To(MariaDBUserPermissionsConverter.From((MariaDBUserPermissions)reader.GetInt32("event_sender_perms")))));
-        import.Parameters.Add(new SQLiteParameter("data", reader.GetString("event_data")));
-        await import.PrepareAsync();
-        await import.ExecuteNonQueryAsync();
-
-        ++completed;
-        if(DateTimeOffset.UtcNow - lastReport > TimeSpan.FromMinutes(1)) {
-            lastReport = DateTimeOffset.UtcNow;
-            double completion = (double)completed / rows;
-            logger.ZLogInformation($"{completed} of {rows} converted ({completion:P2})...");
-        }
-    }
-    logger.ZLogInformation($"Converted all {completed} rows!");
+    await new StorageMigrator(logFactory.CreateLogger("migrate"), mariadb, sqlite).Migrate(cts.Token);
     return;
 }
 
diff --git a/SharpChatCommon/Messages/MessageStorage.cs b/SharpChatCommon/Messages/MessageStorage.cs
index f5573fc..c9bdc71 100644
--- a/SharpChatCommon/Messages/MessageStorage.cs
+++ b/SharpChatCommon/Messages/MessageStorage.cs
@@ -1,6 +1,7 @@
 namespace SharpChat.Messages;
 
 public interface MessageStorage {
+    Task LogMessage(Message msg);
     Task LogMessage(
         long id,
         string type,
@@ -15,6 +16,10 @@ public interface MessageStorage {
     );
     Task DeleteMessage(Message msg);
     Task<Message?> GetMessage(long id);
+    Task<long> CountMessages(
+        string? channelName = null,
+        bool includeDeleted = false
+    );
     Task<IEnumerable<Message>> GetMessages(
         string? channelName = null,
         int? take = 20,
diff --git a/SharpChatCommon/StorageMigrator.cs b/SharpChatCommon/StorageMigrator.cs
new file mode 100644
index 0000000..e5e87cf
--- /dev/null
+++ b/SharpChatCommon/StorageMigrator.cs
@@ -0,0 +1,54 @@
+using Microsoft.Extensions.Logging;
+using SharpChat.Messages;
+using ZLogger;
+
+namespace SharpChat;
+
+public class StorageMigrator(ILogger logger, Storage source, Storage target) {
+    public async Task Migrate(CancellationToken cancellationToken) {
+        try {
+            logger.ZLogInformation($"Converting from {source.GetType().Name} to {target.GetType().Name}!");
+            logger.ZLogInformation($"Ensuring both source and target are fully upgraded...");
+            if(cancellationToken.IsCancellationRequested) return;
+
+            await source.UpgradeStorage();
+            if(cancellationToken.IsCancellationRequested) return;
+
+            await target.UpgradeStorage();
+            if(cancellationToken.IsCancellationRequested) return;
+
+            logger.ZLogInformation($"Creating message storage instances...");
+            MessageStorage sourceMsgs = source.CreateMessageStorage();
+            MessageStorage targetMsgs = target.CreateMessageStorage();
+
+            if(cancellationToken.IsCancellationRequested) return;
+            long msgCount = await sourceMsgs.CountMessages(includeDeleted: true);
+            if(msgCount < 1) {
+                logger.ZLogInformation($"No messages to migrate, skipping...");
+            } else {
+                logger.ZLogInformation($"Migrating {msgCount} message{(msgCount == 1 ? "" : "s")}...");
+
+                long migrated = 0;
+                DateTimeOffset lastReport = DateTimeOffset.UtcNow;
+                IEnumerable<Message> msgs = await sourceMsgs.GetMessages(take: null, includeDeleted: true);
+                foreach(Message msg in msgs) {
+                    if(cancellationToken.IsCancellationRequested) break;
+
+                    await targetMsgs.LogMessage(msg);
+
+                    ++migrated;
+                    if(DateTimeOffset.UtcNow - lastReport > TimeSpan.FromMinutes(1)) {
+                        lastReport = DateTimeOffset.UtcNow;
+                        double completion = (double)migrated / msgCount;
+                        logger.ZLogInformation($"{migrated} of {msgCount} message{(migrated == 1 ? "" : "s")} migrated ({completion:P2})...");
+                    }
+                }
+
+                logger.ZLogInformation($"Migrated {migrated} message{(migrated == 1 ? "" : "s")}!");
+            }
+        } catch(Exception ex) {
+            logger.ZLogError($"Error during migration: {ex}");
+            throw;
+        }
+    }
+}