diff --git a/.gitignore b/.gitignore
index 16b1c56..215570f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,6 +8,9 @@ http-motd.txt
 _webdb.txt
 msz_url.txt
 sharpchat.cfg
+sharpchat.db
+sharpchat.db-wal
+sharpchat.db-shm
 SharpChat/version.txt
 
 # User-specific files
diff --git a/SharpChat.MariaDB/MariaDBConnection.cs b/SharpChat.MariaDB/MariaDBConnection.cs
new file mode 100644
index 0000000..9af5840
--- /dev/null
+++ b/SharpChat.MariaDB/MariaDBConnection.cs
@@ -0,0 +1,52 @@
+using MySqlConnector;
+using System.Data;
+
+namespace SharpChat.MariaDB;
+
+public class MariaDBConnection(MySqlConnection conn) : IDisposable {
+    public async Task<int> RunCommand(string command, params MySqlParameter[] parameters) {
+        using MySqlCommand cmd = conn.CreateCommand();
+        if(parameters?.Length > 0)
+            cmd.Parameters.AddRange(parameters);
+        cmd.CommandText = command;
+        return await cmd.ExecuteNonQueryAsync();
+    }
+
+    public async Task<MySqlDataReader?> RunQuery(string command, params MySqlParameter[] parameters) {
+        using MySqlCommand cmd = conn.CreateCommand();
+        if(parameters?.Length > 0)
+            cmd.Parameters.AddRange(parameters);
+        cmd.CommandText = command;
+        return await cmd.ExecuteReaderAsync(CommandBehavior.CloseConnection);
+    }
+
+    public async Task<T> RunQueryValue<T>(string command, params MySqlParameter[] parameters)
+        where T : struct {
+        using MySqlCommand cmd = conn.CreateCommand();
+        if(parameters?.Length > 0)
+            cmd.Parameters.AddRange(parameters);
+        cmd.CommandText = command;
+        await cmd.PrepareAsync();
+
+        object? raw = await cmd.ExecuteScalarAsync();
+        return raw is T value ? value : default;
+    }
+
+    private bool disposed = false;
+
+    ~MariaDBConnection() {
+        DoDispose();
+    }
+
+    public void Dispose() {
+        DoDispose();
+        GC.SuppressFinalize(this);
+    }
+
+    private void DoDispose() {
+        if(disposed)
+            return;
+        disposed = true;
+        conn.Dispose();
+    }
+}
diff --git a/SharpChat.MariaDB/MariaDBMessageStorage.cs b/SharpChat.MariaDB/MariaDBMessageStorage.cs
index 8c8cbb4..b9b1ad6 100644
--- a/SharpChat.MariaDB/MariaDBMessageStorage.cs
+++ b/SharpChat.MariaDB/MariaDBMessageStorage.cs
@@ -4,9 +4,7 @@ using System.Text.Json;
 
 namespace SharpChat.MariaDB;
 
-public partial class MariaDBMessageStorage(string connString) : MessageStorage {
-    private string ConnectionString { get; } = connString ?? throw new ArgumentNullException(nameof(connString));
-
+public class MariaDBMessageStorage(MariaDBStorage storage) : MessageStorage {
     public async Task LogMessage(
         long id,
         string type,
@@ -19,41 +17,77 @@ public partial class MariaDBMessageStorage(string connString) : MessageStorage {
         UserPermissions senderPerms,
         object? data = null
     ) {
-        await RunCommand(
-            "INSERT INTO sqc_events (event_id, event_created, event_type, event_channel, event_data"
-            + ", event_sender, event_sender_name, event_sender_colour, event_sender_rank, event_sender_nick, event_sender_perms)"
-            + " VALUES (@id, NOW(), @type, @channel, @data"
-            + ", @sender, @sender_name, @sender_colour, @sender_rank, @sender_nick, @sender_perms)",
-            new MySqlParameter("id", id),
-            new MySqlParameter("type", type),
-            new MySqlParameter("channel", string.IsNullOrWhiteSpace(channelName) ? null : channelName),
-            new MySqlParameter("data", data == null ? "{}" : JsonSerializer.SerializeToUtf8Bytes(data)),
-            new MySqlParameter("sender", long.TryParse(senderId, out long senderId64) && senderId64 > 0 ? senderId64 : null),
-            new MySqlParameter("sender_name", senderName),
-            new MySqlParameter("sender_colour", senderColour.ToMisuzu()),
-            new MySqlParameter("sender_rank", senderRank),
-            new MySqlParameter("sender_nick", string.IsNullOrWhiteSpace(senderNick) ? null : senderNick),
-            new MySqlParameter("sender_perms", ToStoredPermissions(senderPerms))
+        try {
+            using MariaDBConnection conn = await storage.CreateConnection();
+            await conn.RunCommand(
+                "INSERT INTO sqc_events (event_id, event_created, event_type, event_channel, event_data"
+                + ", event_sender, event_sender_name, event_sender_colour, event_sender_rank, event_sender_nick, event_sender_perms)"
+                + " VALUES (@id, NOW(), @type, @channel, @data"
+                + ", @sender, @sender_name, @sender_colour, @sender_rank, @sender_nick, @sender_perms)",
+                new MySqlParameter("id", id),
+                new MySqlParameter("type", type),
+                new MySqlParameter("channel", string.IsNullOrWhiteSpace(channelName) ? null : channelName),
+                new MySqlParameter("data", data == null ? "{}" : JsonSerializer.SerializeToUtf8Bytes(data)),
+                new MySqlParameter("sender", long.TryParse(senderId, out long senderId64) && senderId64 > 0 ? senderId64 : null),
+                new MySqlParameter("sender_name", senderName),
+                new MySqlParameter("sender_colour", senderColour.ToMisuzu()),
+                new MySqlParameter("sender_rank", senderRank),
+                new MySqlParameter("sender_nick", string.IsNullOrWhiteSpace(senderNick) ? null : senderNick),
+                new MySqlParameter("sender_perms", MariaDBUserPermissionsConverter.To(senderPerms))
+            );
+        } catch(MySqlException ex) {
+            Logger.Write(ex);
+        }
+    }
+
+    public async Task DeleteMessage(Message msg) {
+        try {
+            using MariaDBConnection conn = await storage.CreateConnection();
+            await conn.RunCommand(
+                "UPDATE IGNORE sqc_events SET event_deleted = NOW() WHERE event_id = @id AND event_deleted IS NULL",
+                new MySqlParameter("id", msg.Id)
+            );
+        } catch(MySqlException ex) {
+            Logger.Write(ex);
+        }
+    }
+
+    private static Message ReadMessage(MySqlDataReader reader) {
+        using Stream data = reader.GetStream("event_data");
+        return new Message(
+            reader.GetInt64("event_id"),
+            reader.GetString("event_type"),
+            reader.IsDBNull(reader.GetOrdinal("event_sender")) ? null : reader.GetString("event_sender"),
+            reader.IsDBNull(reader.GetOrdinal("event_sender_name")) ? string.Empty : reader.GetString("event_sender_name"),
+            ColourInheritable.FromMisuzu(reader.GetInt32("event_sender_colour")),
+            reader.GetInt32("event_sender_rank"),
+            MariaDBUserPermissionsConverter.From((MariaDBUserPermissions)reader.GetInt32("event_sender_perms")),
+            reader.IsDBNull(reader.GetOrdinal("event_sender_nick")) ? string.Empty : reader.GetString("event_sender_nick"),
+            DateTimeOffset.FromUnixTimeSeconds(reader.GetInt32("event_created")),
+            reader.IsDBNull(reader.GetOrdinal("event_deleted")) ? null : DateTimeOffset.FromUnixTimeSeconds(reader.GetInt32("event_deleted")),
+            reader.IsDBNull(reader.GetOrdinal("event_channel")) ? null : reader.GetString("event_channel"),
+            JsonDocument.Parse(data)
         );
     }
 
-    public async Task<Message?> GetMessage(long seqId) {
+    public async Task<Message?> GetMessage(long id) {
         try {
-            using MySqlDataReader? reader = await RunQuery(
+            using MariaDBConnection conn = await storage.CreateConnection();
+            using MySqlDataReader? reader = await conn.RunQuery(
                 "SELECT event_id, event_type, event_data, event_channel"
                 + ", event_sender, event_sender_name, event_sender_colour, event_sender_rank, event_sender_nick, event_sender_perms"
                 + ", UNIX_TIMESTAMP(event_created) AS event_created"
                 + ", UNIX_TIMESTAMP(event_deleted) AS event_deleted"
                 + " FROM sqc_events"
                 + " WHERE event_id = @id",
-                new MySqlParameter("id", seqId)
+                new MySqlParameter("id", id)
             );
 
             if(reader is null)
                 return null;
 
             while(reader.Read()) {
-                Message evt = ReadEvent(reader);
+                Message evt = ReadMessage(reader);
                 if(evt != null)
                     return evt;
             }
@@ -64,29 +98,12 @@ public partial class MariaDBMessageStorage(string connString) : MessageStorage {
         return null;
     }
 
-    private static Message ReadEvent(MySqlDataReader reader) {
-        using Stream data = reader.GetStream("event_data");
-        return new Message(
-            reader.GetInt64("event_id"),
-            reader.GetString("event_type"),
-            reader.IsDBNull(reader.GetOrdinal("event_sender")) ? null : reader.GetString("event_sender"),
-            reader.IsDBNull(reader.GetOrdinal("event_sender_name")) ? string.Empty : reader.GetString("event_sender_name"),
-            ColourInheritable.FromMisuzu(reader.GetInt32("event_sender_colour")),
-            reader.GetInt32("event_sender_rank"),
-            FromMessagePermissions((MariaDBUserPermissions)reader.GetInt32("event_sender_perms")),
-            reader.IsDBNull(reader.GetOrdinal("event_sender_nick")) ? string.Empty : reader.GetString("event_sender_nick"),
-            DateTimeOffset.FromUnixTimeSeconds(reader.GetInt32("event_created")),
-            reader.IsDBNull(reader.GetOrdinal("event_deleted")) ? null : DateTimeOffset.FromUnixTimeSeconds(reader.GetInt32("event_deleted")),
-            reader.IsDBNull(reader.GetOrdinal("event_channel")) ? null : reader.GetString("event_channel"),
-            JsonDocument.Parse(data)
-        );
-    }
-
     public async Task<IEnumerable<Message>> GetMessages(string channelName, int amount = 20, int offset = 0) {
-        List<Message> events = [];
+        List<Message> msgs = [];
 
         try {
-            using MySqlDataReader? reader = await RunQuery(
+            using MariaDBConnection conn = await storage.CreateConnection();
+            using MySqlDataReader? reader = await conn.RunQuery(
                 "SELECT event_id, event_type, event_data, event_channel"
                 + ", event_sender, event_sender_name, event_sender_colour, event_sender_rank, event_sender_nick, event_sender_perms"
                 + ", UNIX_TIMESTAMP(event_created) AS event_created"
@@ -101,26 +118,19 @@ public partial class MariaDBMessageStorage(string connString) : MessageStorage {
                 new MySqlParameter("offset", offset)
             );
             if(reader is null)
-                return events;
+                return msgs;
 
             while(reader.Read()) {
-                Message evt = ReadEvent(reader);
+                Message evt = ReadMessage(reader);
                 if(evt != null)
-                    events.Add(evt);
+                    msgs.Add(evt);
             }
         } catch(MySqlException ex) {
             Logger.Write(ex);
         }
 
-        events.Reverse();
+        msgs.Reverse();
 
-        return events;
-    }
-
-    public async Task DeleteMessage(Message evt) {
-        await RunCommand(
-            "UPDATE IGNORE sqc_events SET event_deleted = NOW() WHERE event_id = @id AND event_deleted IS NULL",
-            new MySqlParameter("id", evt.Id)
-        );
+        return msgs;
     }
 }
diff --git a/SharpChat.MariaDB/MariaDBMessageStorage_Database.cs b/SharpChat.MariaDB/MariaDBMessageStorage_Database.cs
deleted file mode 100644
index 6f83f80..0000000
--- a/SharpChat.MariaDB/MariaDBMessageStorage_Database.cs
+++ /dev/null
@@ -1,87 +0,0 @@
-using MySqlConnector;
-using SharpChat.Configuration;
-
-namespace SharpChat.MariaDB;
-
-public partial class MariaDBMessageStorage {
-    public static string BuildConnString(Config config) {
-        return BuildConnString(
-            config.ReadValue("host", "localhost"),
-            config.ReadValue("user", string.Empty),
-            config.ReadValue("pass", string.Empty),
-            config.ReadValue("db", "sharpchat")
-        );
-    }
-
-    public static string BuildConnString(string? host, string? username, string? password, string? database) {
-        return new MySqlConnectionStringBuilder {
-            Server = host,
-            UserID = username,
-            Password = password,
-            Database = database,
-            OldGuids = false,
-            TreatTinyAsBoolean = false,
-            CharacterSet = "utf8mb4",
-            SslMode = MySqlSslMode.None,
-            ForceSynchronous = true,
-            ConnectionTimeout = 5,
-            DefaultCommandTimeout = 900, // fuck it, 15 minutes
-        }.ToString();
-    }
-
-    private async Task<MySqlConnection> GetConnection() {
-        MySqlConnection conn = new(ConnectionString);
-        await conn.OpenAsync();
-        return conn;
-    }
-
-    private async Task<int> RunCommand(string command, params MySqlParameter[] parameters) {
-        try {
-            using MySqlConnection conn = await GetConnection();
-            using MySqlCommand cmd = conn.CreateCommand();
-            if(parameters?.Length > 0)
-                cmd.Parameters.AddRange(parameters);
-            cmd.CommandText = command;
-            return await cmd.ExecuteNonQueryAsync();
-        } catch(MySqlException ex) {
-            Logger.Write(ex);
-        }
-
-        return 0;
-    }
-
-    private async Task<MySqlDataReader?> RunQuery(string command, params MySqlParameter[] parameters) {
-        try {
-            MySqlConnection conn = await GetConnection();
-            MySqlCommand cmd = conn.CreateCommand();
-            if(parameters?.Length > 0)
-                cmd.Parameters.AddRange(parameters);
-            cmd.CommandText = command;
-            return await cmd.ExecuteReaderAsync(System.Data.CommandBehavior.CloseConnection);
-        } catch(MySqlException ex) {
-            Logger.Write(ex);
-        }
-
-        return null;
-    }
-
-    private async Task<T> RunQueryValue<T>(string command, params MySqlParameter[] parameters)
-        where T : struct {
-        try {
-            using MySqlConnection conn = await GetConnection();
-            using MySqlCommand cmd = conn.CreateCommand();
-            if(parameters?.Length > 0)
-                cmd.Parameters.AddRange(parameters);
-            cmd.CommandText = command;
-            await cmd.PrepareAsync();
-
-            object? raw = await cmd.ExecuteScalarAsync();
-            if(raw is T value)
-                return value;
-        } catch(MySqlException ex) {
-            Logger.Write(ex);
-        }
-
-        return default;
-    }
-}
diff --git a/SharpChat.MariaDB/MariaDBMessageStorage_Migrations.cs b/SharpChat.MariaDB/MariaDBMigrations.cs
similarity index 76%
rename from SharpChat.MariaDB/MariaDBMessageStorage_Migrations.cs
rename to SharpChat.MariaDB/MariaDBMigrations.cs
index 312fbf7..ddae353 100644
--- a/SharpChat.MariaDB/MariaDBMessageStorage_Migrations.cs
+++ b/SharpChat.MariaDB/MariaDBMigrations.cs
@@ -2,16 +2,16 @@ using MySqlConnector;
 
 namespace SharpChat.MariaDB;
 
-public partial class MariaDBMessageStorage {
+public class MariaDBMigrations(MariaDBConnection conn) {
     private async Task DoMigration(string name, Func<Task> action) {
-        bool done = await RunQueryValue<long>(
+        bool done = await conn.RunQueryValue<long>(
             "SELECT COUNT(*) FROM sqc_migrations WHERE migration_name = @name",
             new MySqlParameter("name", name)
         ) > 0;
         if(!done) {
             Logger.Write($"Running migration '{name}'...");
             await action();
-            await RunCommand(
+            await conn.RunCommand(
                 "INSERT INTO sqc_migrations (migration_name) VALUES (@name)",
                 new MySqlParameter("name", name)
             );
@@ -19,7 +19,7 @@ public partial class MariaDBMessageStorage {
     }
 
     public async Task RunMigrations() {
-        await RunCommand(
+        await conn.RunCommand(
             "CREATE TABLE IF NOT EXISTS sqc_migrations ("
             + "migration_name VARCHAR(255) NOT NULL,"
             + "migration_completed TIMESTAMP NOT NULL DEFAULT current_timestamp(),"
@@ -38,9 +38,9 @@ public partial class MariaDBMessageStorage {
     }
 
     private async Task UpdateCollationsAndUseJsonType() {
-        await RunCommand("UPDATE sqc_events SET event_target = LOWER(CONVERT(event_target USING ascii))");
-        await RunCommand("UPDATE sqc_events SET event_sender_nick = NULL WHERE event_sender_nick = ''");
-        await RunCommand(
+        await conn.RunCommand("UPDATE sqc_events SET event_target = LOWER(CONVERT(event_target USING ascii))");
+        await conn.RunCommand("UPDATE sqc_events SET event_sender_nick = NULL WHERE event_sender_nick = ''");
+        await conn.RunCommand(
             "ALTER TABLE sqc_events COLLATE='utf8mb4_unicode_520_ci',"
             + " CHANGE COLUMN event_type event_type VARCHAR(255) NOT NULL COLLATE 'ascii_general_ci' AFTER event_id,"
             + " CHANGE COLUMN event_created event_created TIMESTAMP NOT NULL DEFAULT current_timestamp() AFTER event_type,"
@@ -55,27 +55,27 @@ public partial class MariaDBMessageStorage {
     }
 
     private async Task UpdateEventTypeNames() {
-        await RunCommand(@"UPDATE sqc_events SET event_type = ""msg:add"" WHERE event_type = ""SharpChat.Events.ChatMessage""");
-        await RunCommand(@"UPDATE sqc_events SET event_type = ""user:connect"" WHERE event_type = ""SharpChat.Events.UserConnectEvent""");
-        await RunCommand(@"UPDATE sqc_events SET event_type = ""user:disconnect"" WHERE event_type = ""SharpChat.Events.UserDisconnectEvent""");
-        await RunCommand(@"UPDATE sqc_events SET event_type = ""chan:join"" WHERE event_type = ""SharpChat.Events.UserChannelJoinEvent""");
-        await RunCommand(@"UPDATE sqc_events SET event_type = ""chan:leave"" WHERE event_type = ""SharpChat.Events.UserChannelLeaveEvent""");
+        await conn.RunCommand(@"UPDATE sqc_events SET event_type = ""msg:add"" WHERE event_type = ""SharpChat.Events.ChatMessage""");
+        await conn.RunCommand(@"UPDATE sqc_events SET event_type = ""user:connect"" WHERE event_type = ""SharpChat.Events.UserConnectEvent""");
+        await conn.RunCommand(@"UPDATE sqc_events SET event_type = ""user:disconnect"" WHERE event_type = ""SharpChat.Events.UserDisconnectEvent""");
+        await conn.RunCommand(@"UPDATE sqc_events SET event_type = ""chan:join"" WHERE event_type = ""SharpChat.Events.UserChannelJoinEvent""");
+        await conn.RunCommand(@"UPDATE sqc_events SET event_type = ""chan:leave"" WHERE event_type = ""SharpChat.Events.UserChannelLeaveEvent""");
     }
 
     private async Task NoMoreFlagsField() {
         // MessageFlags.Action is just a field in the data object
-        await RunCommand("UPDATE sqc_events SET event_data = JSON_MERGE_PATCH(event_data, JSON_OBJECT('act', true)) WHERE event_flags & 1");
+        await conn.RunCommand("UPDATE sqc_events SET event_data = JSON_MERGE_PATCH(event_data, JSON_OBJECT('act', true)) WHERE event_flags & 1");
 
         // MessageFlags.Broadcast can be implied by just having a NULL as the channel name
-        await RunCommand("UPDATE sqc_events SET event_target = NULL WHERE event_flags & 2");
+        await conn.RunCommand("UPDATE sqc_events SET event_target = NULL WHERE event_flags & 2");
 
         // MessageFlags.Log was never meaningfully used by anything and basically just meant "not-msg:add"
         // MessageFlags.Private was also never meaningfully used, can be determined by checking if the channel name starts with @
-        await RunCommand("ALTER TABLE sqc_events DROP COLUMN event_flags");
+        await conn.RunCommand("ALTER TABLE sqc_events DROP COLUMN event_flags");
     }
 
     private async Task EventUserAndNickNameTo1000() {
-        await RunCommand(
+        await conn.RunCommand(
             "ALTER TABLE sqc_events"
             + " CHANGE COLUMN event_sender_name event_sender_name VARCHAR(1000) NULL DEFAULT NULL COLLATE 'utf8mb4_unicode_520_ci' AFTER event_sender,"
             + " CHANGE COLUMN event_sender_nick event_sender_nick VARCHAR(1000) NULL DEFAULT NULL COLLATE 'utf8mb4_unicode_520_ci' AFTER event_sender_rank;"
@@ -83,21 +83,21 @@ public partial class MariaDBMessageStorage {
     }
 
     private async Task EventDataAsMediumBlob() {
-        await RunCommand(
+        await conn.RunCommand(
             "ALTER TABLE sqc_events"
             + " CHANGE COLUMN event_data event_data MEDIUMBLOB NULL DEFAULT NULL AFTER event_flags;"
         );
     }
 
     private async Task AllowNullTarget() {
-        await RunCommand(
+        await conn.RunCommand(
             "ALTER TABLE sqc_events"
             + " CHANGE COLUMN event_target event_target VARBINARY(255) NULL AFTER event_type;"
         );
     }
 
     private async Task CreateEventsTable() {
-        await RunCommand(
+        await conn.RunCommand(
             "CREATE TABLE sqc_events ("
             + "event_id BIGINT(20) NOT NULL,"
             + "event_sender BIGINT(20) UNSIGNED NULL DEFAULT NULL,"
diff --git a/SharpChat.MariaDB/MariaDBStorage.cs b/SharpChat.MariaDB/MariaDBStorage.cs
new file mode 100644
index 0000000..71ccea5
--- /dev/null
+++ b/SharpChat.MariaDB/MariaDBStorage.cs
@@ -0,0 +1,47 @@
+using MySqlConnector;
+using SharpChat.Configuration;
+using SharpChat.Messages;
+
+namespace SharpChat.MariaDB;
+
+public class MariaDBStorage(string connString) : Storage {
+    public async Task<MariaDBConnection> CreateConnection() {
+        MySqlConnection conn = new(connString);
+        await conn.OpenAsync();
+        return new MariaDBConnection(conn);
+    }
+
+    public MessageStorage CreateMessageStorage() {
+        return new MariaDBMessageStorage(this);
+    }
+
+    public async Task UpgradeStorage() {
+        using MariaDBConnection conn = await CreateConnection();
+        await new MariaDBMigrations(conn).RunMigrations();
+    }
+
+    public static string BuildConnectionString(Config config) {
+        return BuildConnectionString(
+            config.ReadValue("host", "localhost"),
+            config.ReadValue("user", string.Empty),
+            config.ReadValue("pass", string.Empty),
+            config.ReadValue("db", "sharpchat")
+        );
+    }
+
+    public static string BuildConnectionString(string? host, string? username, string? password, string? database) {
+        return new MySqlConnectionStringBuilder {
+            Server = host,
+            UserID = username,
+            Password = password,
+            Database = database,
+            OldGuids = false,
+            TreatTinyAsBoolean = false,
+            CharacterSet = "utf8mb4",
+            SslMode = MySqlSslMode.None,
+            ForceSynchronous = false,
+            ConnectionTimeout = 5,
+            DefaultCommandTimeout = 900, // fuck it, 15 minutes
+        }.ToString();
+    }
+}
diff --git a/SharpChat.MariaDB/MariaDBMessageStorage_Permissions.cs b/SharpChat.MariaDB/MariaDBUserPermissionsConverter.cs
similarity index 95%
rename from SharpChat.MariaDB/MariaDBMessageStorage_Permissions.cs
rename to SharpChat.MariaDB/MariaDBUserPermissionsConverter.cs
index 9854583..428fa1c 100644
--- a/SharpChat.MariaDB/MariaDBMessageStorage_Permissions.cs
+++ b/SharpChat.MariaDB/MariaDBUserPermissionsConverter.cs
@@ -1,7 +1,7 @@
 namespace SharpChat.MariaDB;
 
-public partial class MariaDBMessageStorage {
-    public static UserPermissions FromMessagePermissions(MariaDBUserPermissions mup) {
+public static class MariaDBUserPermissionsConverter {
+    public static UserPermissions From(MariaDBUserPermissions mup) {
         UserPermissions perms = 0;
 
         if(mup.HasFlag(MariaDBUserPermissions.KickUser))
@@ -50,7 +50,7 @@ public partial class MariaDBMessageStorage {
         return perms;
     }
 
-    public static MariaDBUserPermissions ToStoredPermissions(UserPermissions up) {
+    public static MariaDBUserPermissions To(UserPermissions up) {
         MariaDBUserPermissions perms = 0;
 
         if(up.HasFlag(UserPermissions.KickUser))
diff --git a/SharpChat.SQLite/SQLiteConnection.cs b/SharpChat.SQLite/SQLiteConnection.cs
new file mode 100644
index 0000000..e611c04
--- /dev/null
+++ b/SharpChat.SQLite/SQLiteConnection.cs
@@ -0,0 +1,56 @@
+using System.Data;
+using System.Data.Common;
+using System.Data.SQLite;
+using NativeSQLiteConnection = System.Data.SQLite.SQLiteConnection;
+
+namespace SharpChat.SQLite;
+
+public class SQLiteConnection(NativeSQLiteConnection conn) : IDisposable {
+    public async Task<int> RunCommand(string command, params SQLiteParameter[] parameters) {
+        using SQLiteCommand cmd = conn.CreateCommand();
+        if(parameters?.Length > 0)
+            cmd.Parameters.AddRange(parameters);
+        cmd.CommandText = command;
+        return await cmd.ExecuteNonQueryAsync();
+    }
+
+    public async Task<DbDataReader?> RunQuery(string command, params SQLiteParameter[] parameters) {
+        using SQLiteCommand cmd = conn.CreateCommand();
+        if(parameters?.Length > 0)
+            cmd.Parameters.AddRange(parameters);
+        cmd.CommandText = command;
+        return await cmd.ExecuteReaderAsync();
+    }
+
+    public async Task<T> RunQueryValue<T>(string command, params SQLiteParameter[] parameters)
+        where T : struct {
+        using SQLiteCommand cmd = conn.CreateCommand();
+        if(parameters?.Length > 0)
+            cmd.Parameters.AddRange(parameters);
+        cmd.CommandText = command;
+        await cmd.PrepareAsync();
+
+        object? raw = await cmd.ExecuteScalarAsync();
+        return raw is T value ? value : default;
+    }
+
+    private bool disposed = false;
+
+    ~SQLiteConnection() {
+        DoDispose();
+    }
+
+    public void Dispose() {
+        DoDispose();
+        GC.SuppressFinalize(this);
+    }
+
+    private void DoDispose() {
+        if(disposed)
+            return;
+        disposed = true;
+
+        RunCommand("VACUUM").Wait();
+        conn.Dispose();
+    }
+}
diff --git a/SharpChat.SQLite/SQLiteMessageStorage.cs b/SharpChat.SQLite/SQLiteMessageStorage.cs
new file mode 100644
index 0000000..7e10924
--- /dev/null
+++ b/SharpChat.SQLite/SQLiteMessageStorage.cs
@@ -0,0 +1,126 @@
+using SharpChat.Messages;
+using System.Data;
+using System.Data.Common;
+using System.Data.SQLite;
+using System.Text;
+using System.Text.Json;
+
+namespace SharpChat.SQLite;
+
+public class SQLiteMessageStorage(SQLiteConnection conn) : MessageStorage {
+    public async Task LogMessage(
+        long id,
+        string type,
+        string channelName,
+        string senderId,
+        string senderName,
+        ColourInheritable senderColour,
+        int senderRank,
+        string senderNick,
+        UserPermissions senderPerms,
+        object? data = null
+    ) {
+        try {
+            await conn.RunCommand(
+                "INSERT 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", id),
+                new SQLiteParameter("type", type),
+                new SQLiteParameter("created", $"{DateTimeOffset.UtcNow:s}Z"),
+                new SQLiteParameter("channel", string.IsNullOrWhiteSpace(channelName) ? null : channelName),
+                new SQLiteParameter("sender", long.TryParse(senderId, out long senderId64) && senderId64 > 0 ? senderId64 : null),
+                new SQLiteParameter("sender_name", senderName),
+                new SQLiteParameter("sender_colour", senderColour.Rgb.HasValue ? senderColour.Rgb.Value.Raw : null),
+                new SQLiteParameter("sender_rank", senderRank),
+                new SQLiteParameter("sender_nick", string.IsNullOrWhiteSpace(senderNick) ? null : senderNick),
+                new SQLiteParameter("sender_perms", SQLiteUserPermissionsConverter.To(senderPerms)),
+                new SQLiteParameter("data", data == null ? "{}" : JsonSerializer.SerializeToUtf8Bytes(data))
+            );
+        } catch(SQLiteException ex) {
+            Logger.Write(ex);
+        }
+    }
+
+    public async Task DeleteMessage(Message msg) {
+        try {
+            await conn.RunCommand(
+                "UPDATE IGNORE messages SET msg_deleted = NOW() WHERE msg_id = @id AND msg_deleted IS NULL",
+                new SQLiteParameter("id", msg.Id)
+            );
+        } catch(SQLiteException ex) {
+            Logger.Write(ex);
+        }
+    }
+
+    private static Message ReadMessage(DbDataReader reader) {
+        return new Message(
+            reader.GetInt64("msg_id"),
+            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(reader.GetInt32("msg_sender_colour")),
+            reader.GetInt32("msg_sender_rank"),
+            SQLiteUserPermissionsConverter.From((SQLiteUserPermissions)reader.GetInt32("msg_sender_perms")),
+            reader.IsDBNull(reader.GetOrdinal("msg_sender_nick")) ? string.Empty : reader.GetString("msg_sender_nick"),
+            DateTimeOffset.Parse(reader.GetString("msg_created")),
+            reader.IsDBNull(reader.GetOrdinal("msg_deleted")) ? null : DateTimeOffset.Parse(reader.GetString("msg_deleted")),
+            reader.IsDBNull(reader.GetOrdinal("msg_channel")) ? null : reader.GetString("msg_channel"),
+            JsonDocument.Parse(reader.GetString("msg_data"))
+        );
+    }
+
+    public async Task<Message?> GetMessage(long id) {
+        try {
+            using DbDataReader? reader = await conn.RunQuery(
+                "SELECT 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"
+                + " FROM messages WHERE msg_id = @id",
+                new SQLiteParameter("id", id)
+            );
+
+            if(reader is null)
+                return null;
+
+            while(reader.Read()) {
+                Message evt = ReadMessage(reader);
+                if(evt != null)
+                    return evt;
+            }
+        } catch(SQLiteException ex) {
+            Logger.Write(ex);
+        }
+
+        return null;
+    }
+
+    public async Task<IEnumerable<Message>> GetMessages(string channelName, int amount = 20, int offset = 0) {
+        List<Message> msgs = [];
+
+        try {
+            using DbDataReader? reader = await conn.RunQuery(
+                "SELECT 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"
+                + " FROM messages"
+                + " WHERE msg_deleted IS NULL AND (msg_channel = @channel OR msg_channel IS NULL)"
+                + " AND msg_id > @offset"
+                + " ORDER BY msg_id DESC"
+                + " LIMIT @amount",
+                new SQLiteParameter("channel", channelName),
+                new SQLiteParameter("amount", amount),
+                new SQLiteParameter("offset", offset)
+            );
+            if(reader is null)
+                return msgs;
+
+            while(reader.Read()) {
+                Message evt = ReadMessage(reader);
+                if(evt != null)
+                    msgs.Add(evt);
+            }
+        } catch(SQLiteException ex) {
+            Logger.Write(ex);
+        }
+
+        msgs.Reverse();
+
+        return msgs;
+    }
+}
diff --git a/SharpChat.SQLite/SQLiteMigrations.cs b/SharpChat.SQLite/SQLiteMigrations.cs
new file mode 100644
index 0000000..1dcc464
--- /dev/null
+++ b/SharpChat.SQLite/SQLiteMigrations.cs
@@ -0,0 +1,45 @@
+namespace SharpChat.SQLite;
+
+public class SQLiteMigrations(SQLiteConnection conn) {
+    public async Task RunMigrations() {
+        long currentVersion = await conn.RunQueryValue<long>("PRAGMA user_version");
+        long version = currentVersion;
+
+        async Task doMigration(int expect, Func<Task> action) {
+            if(version < expect) {
+                await action();
+                ++version;
+            }
+        };
+
+        await doMigration(1, CreateMessagesTable);
+
+        if(currentVersion != version)
+            await conn.RunCommand($"PRAGMA user_version = {version}");
+    }
+
+    private async Task CreateMessagesTable() {
+        await conn.RunCommand(
+            @"CREATE TABLE ""messages"" ("
+            + @"""msg_id"" INTEGER NOT NULL,"
+            + @"""msg_type"" TEXT NOT NULL COLLATE NOCASE,"
+            + @"""msg_created"" TEXT NOT NULL COLLATE NOCASE,"
+            + @"""msg_deleted"" TEXT DEFAULT NULL COLLATE NOCASE,"
+            + @"""msg_channel"" TEXT DEFAULT NULL COLLATE NOCASE,"
+            + @"""msg_sender"" TEXT DEFAULT NULL COLLATE BINARY,"
+            + @"""msg_sender_name"" TEXT DEFAULT NULL COLLATE NOCASE,"
+            + @"""msg_sender_colour"" INTEGER DEFAULT NULL,"
+            + @"""msg_sender_rank"" INTEGER DEFAULT NULL,"
+            + @"""msg_sender_nick"" TEXT DEFAULT NULL COLLATE NOCASE,"
+            + @"""msg_sender_perms"" INTEGER DEFAULT NULL,"
+            + @"""msg_data"" BLOB DEFAULT NULL CHECK(JSON_VALID(""msg_data"") AND JSON_TYPE(""msg_data"") = ""object"") COLLATE BINARY,"
+            + @"PRIMARY KEY(""msg_id"")"
+            + @");"
+        );
+        await conn.RunCommand(@"CREATE INDEX ""messages_channel_index"" ON ""messages"" (""msg_channel"");");
+        await conn.RunCommand(@"CREATE INDEX ""messages_created_index"" ON ""messages"" (""msg_created"");");
+        await conn.RunCommand(@"CREATE INDEX ""messages_deleted_index"" ON ""messages"" (""msg_deleted"");");
+        await conn.RunCommand(@"CREATE INDEX ""messages_event_type"" ON ""messages"" (""msg_type"");");
+        await conn.RunCommand(@"CREATE INDEX ""messages_sender_index"" ON ""messages"" (""msg_sender"");");
+    }
+}
diff --git a/SharpChat.SQLite/SQLiteStorage.cs b/SharpChat.SQLite/SQLiteStorage.cs
new file mode 100644
index 0000000..b0eec3f
--- /dev/null
+++ b/SharpChat.SQLite/SQLiteStorage.cs
@@ -0,0 +1,61 @@
+using SharpChat.Configuration;
+using SharpChat.Messages;
+using System.Data.SQLite;
+using NativeSQLiteConnection = System.Data.SQLite.SQLiteConnection;
+
+namespace SharpChat.SQLite;
+
+public class SQLiteStorage(string connString) : Storage, IDisposable {
+    public const string MEMORY = "file::memory:?cache=shared";
+    public const string DEFAULT = "sharpchat.db";
+
+    public SQLiteConnection Connection { get; } = new SQLiteConnection(new NativeSQLiteConnection(connString).OpenAndReturn());
+
+    public MessageStorage CreateMessageStorage() {
+        return new SQLiteMessageStorage(Connection);
+    }
+
+    public async Task UpgradeStorage() {
+        await new SQLiteMigrations(Connection).RunMigrations();
+    }
+
+    public static string BuildConnectionString(Config config) {
+        return BuildConnectionString(
+            config.ReadValue("path", DEFAULT)!,
+            config.ReadValue("pass")
+        );
+    }
+
+    public static string BuildConnectionString(string path, string? password) {
+        return new SQLiteConnectionStringBuilder {
+            DataSource = string.IsNullOrWhiteSpace(path) ? MEMORY : path,
+            DateTimeFormat = SQLiteDateFormats.ISO8601,
+            DateTimeKind = DateTimeKind.Utc,
+            FailIfMissing = false,
+            ForeignKeys = true,
+            JournalMode = SQLiteJournalModeEnum.Wal,
+            LegacyFormat = false,
+            Password = string.IsNullOrWhiteSpace(password) ? null : password,
+            ReadOnly = false,
+            UseUTF16Encoding = false,
+        }.ToString();
+    }
+
+    private bool disposed = false;
+
+    ~SQLiteStorage() {
+        DoDispose();
+    }
+
+    public void Dispose() {
+        DoDispose();
+        GC.SuppressFinalize(this);
+    }
+
+    private void DoDispose() {
+        if(disposed)
+            return;
+        disposed = true;
+        Connection.Dispose();
+    }
+}
diff --git a/SharpChat.SQLite/SQLiteUserPermissions.cs b/SharpChat.SQLite/SQLiteUserPermissions.cs
new file mode 100644
index 0000000..c09c01b
--- /dev/null
+++ b/SharpChat.SQLite/SQLiteUserPermissions.cs
@@ -0,0 +1,26 @@
+namespace SharpChat.SQLite;
+
+[Flags]
+public enum SQLiteUserPermissions : long {
+    SendMessage = 0x1L,
+    DeleteOwnMessage = 0x2L,
+    DeleteAnyMessage = 0x4L,
+    EditOwnMessage = 0x8L,
+    EditAnyMessage = 0x10L,
+    SendBroadcast = 0x20L,
+    ViewLogs = 0x40L,
+    KickUser = 0x80L,
+    BanUser = 0x100L,
+    PardonUser = 0x200L,
+    PardonIPAddress = 0x400L,
+    ViewIPAddress = 0x800L,
+    ViewBanList = 0x1000L,
+    CreateChannel = 0x2000L,
+    SetChannelPermanent = 0x4000L,
+    SetChannelPassword = 0x8000L,
+    SetChannelMinimumRank = 0x10000L,
+    DeleteChannel = 0x20000L,
+    JoinAnyChannel = 0x40000L,
+    SetOwnNickname = 0x80000L,
+    SetOthersNickname = 0x100000L,
+}
diff --git a/SharpChat.SQLite/SQLiteUserPermissionsConverter.cs b/SharpChat.SQLite/SQLiteUserPermissionsConverter.cs
new file mode 100644
index 0000000..8ddfcfb
--- /dev/null
+++ b/SharpChat.SQLite/SQLiteUserPermissionsConverter.cs
@@ -0,0 +1,101 @@
+namespace SharpChat.SQLite;
+
+public static class SQLiteUserPermissionsConverter {
+    public static UserPermissions From(SQLiteUserPermissions sup) {
+        UserPermissions up = 0;
+
+        if(sup.HasFlag(SQLiteUserPermissions.SendMessage))
+            up |= UserPermissions.SendMessage;
+        if(sup.HasFlag(SQLiteUserPermissions.DeleteOwnMessage))
+            up |= UserPermissions.DeleteOwnMessage;
+        if(sup.HasFlag(SQLiteUserPermissions.DeleteAnyMessage))
+            up |= UserPermissions.DeleteAnyMessage;
+        if(sup.HasFlag(SQLiteUserPermissions.EditOwnMessage))
+            up |= UserPermissions.EditOwnMessage;
+        if(sup.HasFlag(SQLiteUserPermissions.EditAnyMessage))
+            up |= UserPermissions.EditAnyMessage;
+        if(sup.HasFlag(SQLiteUserPermissions.SendBroadcast))
+            up |= UserPermissions.SendBroadcast;
+        if(sup.HasFlag(SQLiteUserPermissions.ViewLogs))
+            up |= UserPermissions.ViewLogs;
+        if(sup.HasFlag(SQLiteUserPermissions.KickUser))
+            up |= UserPermissions.KickUser;
+        if(sup.HasFlag(SQLiteUserPermissions.BanUser))
+            up |= UserPermissions.BanUser;
+        if(sup.HasFlag(SQLiteUserPermissions.PardonUser))
+            up |= UserPermissions.PardonUser;
+        if(sup.HasFlag(SQLiteUserPermissions.PardonIPAddress))
+            up |= UserPermissions.PardonIPAddress;
+        if(sup.HasFlag(SQLiteUserPermissions.ViewIPAddress))
+            up |= UserPermissions.ViewIPAddress;
+        if(sup.HasFlag(SQLiteUserPermissions.ViewBanList))
+            up |= UserPermissions.ViewBanList;
+        if(sup.HasFlag(SQLiteUserPermissions.CreateChannel))
+            up |= UserPermissions.CreateChannel;
+        if(sup.HasFlag(SQLiteUserPermissions.SetChannelPermanent))
+            up |= UserPermissions.SetChannelPermanent;
+        if(sup.HasFlag(SQLiteUserPermissions.SetChannelPassword))
+            up |= UserPermissions.SetChannelPassword;
+        if(sup.HasFlag(SQLiteUserPermissions.SetChannelMinimumRank))
+            up |= UserPermissions.SetChannelMinimumRank;
+        if(sup.HasFlag(SQLiteUserPermissions.DeleteChannel))
+            up |= UserPermissions.DeleteChannel;
+        if(sup.HasFlag(SQLiteUserPermissions.JoinAnyChannel))
+            up |= UserPermissions.JoinAnyChannel;
+        if(sup.HasFlag(SQLiteUserPermissions.SetOwnNickname))
+            up |= UserPermissions.SetOwnNickname;
+        if(sup.HasFlag(SQLiteUserPermissions.SetOthersNickname))
+            up |= UserPermissions.SetOthersNickname;
+
+        return up;
+    }
+
+    public static SQLiteUserPermissions To(UserPermissions up) {
+        SQLiteUserPermissions sup = 0;
+
+        if(up.HasFlag(UserPermissions.SendMessage))
+            sup |= SQLiteUserPermissions.SendMessage;
+        if(up.HasFlag(UserPermissions.DeleteOwnMessage))
+            sup |= SQLiteUserPermissions.DeleteOwnMessage;
+        if(up.HasFlag(UserPermissions.DeleteAnyMessage))
+            sup |= SQLiteUserPermissions.DeleteAnyMessage;
+        if(up.HasFlag(UserPermissions.EditOwnMessage))
+            sup |= SQLiteUserPermissions.EditOwnMessage;
+        if(up.HasFlag(UserPermissions.EditAnyMessage))
+            sup |= SQLiteUserPermissions.EditAnyMessage;
+        if(up.HasFlag(UserPermissions.SendBroadcast))
+            sup |= SQLiteUserPermissions.SendBroadcast;
+        if(up.HasFlag(UserPermissions.ViewLogs))
+            sup |= SQLiteUserPermissions.ViewLogs;
+        if(up.HasFlag(UserPermissions.KickUser))
+            sup |= SQLiteUserPermissions.KickUser;
+        if(up.HasFlag(UserPermissions.BanUser))
+            sup |= SQLiteUserPermissions.BanUser;
+        if(up.HasFlag(UserPermissions.PardonUser))
+            sup |= SQLiteUserPermissions.PardonUser;
+        if(up.HasFlag(UserPermissions.PardonIPAddress))
+            sup |= SQLiteUserPermissions.PardonIPAddress;
+        if(up.HasFlag(UserPermissions.ViewIPAddress))
+            sup |= SQLiteUserPermissions.ViewIPAddress;
+        if(up.HasFlag(UserPermissions.ViewBanList))
+            sup |= SQLiteUserPermissions.ViewBanList;
+        if(up.HasFlag(UserPermissions.CreateChannel))
+            sup |= SQLiteUserPermissions.CreateChannel;
+        if(up.HasFlag(UserPermissions.SetChannelPermanent))
+            sup |= SQLiteUserPermissions.SetChannelPermanent;
+        if(up.HasFlag(UserPermissions.SetChannelPassword))
+            sup |= SQLiteUserPermissions.SetChannelPassword;
+        if(up.HasFlag(UserPermissions.SetChannelMinimumRank))
+            sup |= SQLiteUserPermissions.SetChannelMinimumRank;
+        if(up.HasFlag(UserPermissions.DeleteChannel))
+            sup |= SQLiteUserPermissions.DeleteChannel;
+        if(up.HasFlag(UserPermissions.JoinAnyChannel))
+            sup |= SQLiteUserPermissions.JoinAnyChannel;
+        if(up.HasFlag(UserPermissions.SetOwnNickname))
+            sup |= SQLiteUserPermissions.SetOwnNickname;
+        if(up.HasFlag(UserPermissions.SetOthersNickname))
+            sup |= SQLiteUserPermissions.SetOthersNickname;
+
+        return sup;
+    }
+}
diff --git a/SharpChat.SQLite/SharpChat.SQLite.csproj b/SharpChat.SQLite/SharpChat.SQLite.csproj
new file mode 100644
index 0000000..b47e6ba
--- /dev/null
+++ b/SharpChat.SQLite/SharpChat.SQLite.csproj
@@ -0,0 +1,17 @@
+<Project Sdk="Microsoft.NET.Sdk">
+
+  <PropertyGroup>
+    <TargetFramework>net9.0</TargetFramework>
+    <ImplicitUsings>enable</ImplicitUsings>
+    <Nullable>enable</Nullable>
+  </PropertyGroup>
+
+  <ItemGroup>
+    <PackageReference Include="System.Data.SQLite.Core" Version="1.0.119" />
+  </ItemGroup>
+
+  <ItemGroup>
+    <ProjectReference Include="..\SharpChatCommon\SharpChatCommon.csproj" />
+  </ItemGroup>
+
+</Project>
diff --git a/SharpChat.sln b/SharpChat.sln
index f92ff48..c4c8520 100644
--- a/SharpChat.sln
+++ b/SharpChat.sln
@@ -29,6 +29,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Storage", "Storage", "{D5EB
 EndProject
 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SharpChat.MariaDB", "SharpChat.MariaDB\SharpChat.MariaDB.csproj", "{5B760B2D-F0AD-46E5-B701-8C53D25E2355}"
 EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SharpChat.SQLite", "SharpChat.SQLite\SharpChat.SQLite.csproj", "{6D74CAE7-200D-44C8-B950-9F45B843E133}"
+EndProject
 Global
 	GlobalSection(SolutionConfigurationPlatforms) = preSolution
 		Debug|Any CPU = Debug|Any CPU
@@ -55,6 +57,10 @@ Global
 		{5B760B2D-F0AD-46E5-B701-8C53D25E2355}.Debug|Any CPU.Build.0 = Debug|Any CPU
 		{5B760B2D-F0AD-46E5-B701-8C53D25E2355}.Release|Any CPU.ActiveCfg = Release|Any CPU
 		{5B760B2D-F0AD-46E5-B701-8C53D25E2355}.Release|Any CPU.Build.0 = Release|Any CPU
+		{6D74CAE7-200D-44C8-B950-9F45B843E133}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+		{6D74CAE7-200D-44C8-B950-9F45B843E133}.Debug|Any CPU.Build.0 = Debug|Any CPU
+		{6D74CAE7-200D-44C8-B950-9F45B843E133}.Release|Any CPU.ActiveCfg = Release|Any CPU
+		{6D74CAE7-200D-44C8-B950-9F45B843E133}.Release|Any CPU.Build.0 = Release|Any CPU
 	EndGlobalSection
 	GlobalSection(SolutionProperties) = preSolution
 		HideSolutionNode = FALSE
@@ -63,6 +69,7 @@ Global
 		{A9B0B652-C20F-4C62-A96A-EF7ACD2079E9} = {5BB7CDAA-06BB-4746-BA07-7EF9090774D8}
 		{FEDDC565-B784-4D6F-BEF5-121C383D7AB2} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8}
 		{5B760B2D-F0AD-46E5-B701-8C53D25E2355} = {D5EB4BD7-7C69-41F5-9D94-AA5E25B26BDA}
+		{6D74CAE7-200D-44C8-B950-9F45B843E133} = {D5EB4BD7-7C69-41F5-9D94-AA5E25B26BDA}
 	EndGlobalSection
 	GlobalSection(ExtensibilityGlobals) = postSolution
 		SolutionGuid = {42279FE1-5980-440A-87F8-25338DFE54CF}
diff --git a/SharpChat/Program.cs b/SharpChat/Program.cs
index 8cc8f32..6612bc2 100644
--- a/SharpChat/Program.cs
+++ b/SharpChat/Program.cs
@@ -1,9 +1,10 @@
 using SharpChat;
 using SharpChat.Configuration;
-using SharpChat.Messages;
 using SharpChat.Flashii;
-using System.Text;
 using SharpChat.MariaDB;
+using SharpChat.Messages;
+using SharpChat.SQLite;
+using System.Text;
 
 const string CONFIG = "sharpchat.cfg";
 
@@ -124,18 +125,23 @@ FlashiiClient flashii = new(httpClient, config.ScopeTo("msz"));
 
 if(hasCancelled) return;
 
-MessageStorage msgStore;
-if(string.IsNullOrWhiteSpace(config.SafeReadValue("mariadb:host", string.Empty))) {
-    msgStore = new VirtualMessageStorage();
-} else {
-    MariaDBMessageStorage mdbes = new(MariaDBMessageStorage.BuildConnString(config.ScopeTo("mariadb")));
-    msgStore = mdbes;
-    await mdbes.RunMigrations();
+Storage storage = string.IsNullOrWhiteSpace(config.SafeReadValue("mariadb:host", string.Empty))
+    ? new SQLiteStorage(SQLiteStorage.BuildConnectionString(config.ScopeTo("sqlite")))
+    : new MariaDBStorage(MariaDBStorage.BuildConnectionString(config.ScopeTo("mariadb")));
+
+try {
+    if(hasCancelled) return;
+
+    await storage.UpgradeStorage();
+
+    if(hasCancelled) return;
+
+    using SockChatServer scs = new(flashii, flashii, storage.CreateMessageStorage(), config.ScopeTo("chat"));
+    scs.Listen(mre);
+
+    mre.WaitOne();
+} finally {
+    if(storage is IDisposable disp)
+        disp.Dispose();
 }
 
-if(hasCancelled) return;
-
-using SockChatServer scs = new(flashii, flashii, msgStore, config.ScopeTo("chat"));
-scs.Listen(mre);
-
-mre.WaitOne();
diff --git a/SharpChat/SharpChat.csproj b/SharpChat/SharpChat.csproj
index 16cd97b..e530e41 100644
--- a/SharpChat/SharpChat.csproj
+++ b/SharpChat/SharpChat.csproj
@@ -35,6 +35,7 @@
     <ProjectReference Include="..\SharpChat.Flashii\SharpChat.Flashii.csproj" />
     <ProjectReference Include="..\SharpChat.MariaDB\SharpChat.MariaDB.csproj" />
     <ProjectReference Include="..\SharpChat.SockChat\SharpChat.SockChat.csproj" />
+    <ProjectReference Include="..\SharpChat.SQLite\SharpChat.SQLite.csproj" />
     <ProjectReference Include="..\SharpChatCommon\SharpChatCommon.csproj" />
   </ItemGroup>
 
diff --git a/SharpChatCommon/ColourInheritable.cs b/SharpChatCommon/ColourInheritable.cs
index fa4558b..5820c98 100644
--- a/SharpChatCommon/ColourInheritable.cs
+++ b/SharpChatCommon/ColourInheritable.cs
@@ -1,14 +1,16 @@
 namespace SharpChat;
 
-public readonly record struct ColourInheritable(ColourRgb? rgb) {
+public readonly record struct ColourInheritable(ColourRgb? Rgb) {
     public static readonly ColourInheritable None = new(null);
-    public override string ToString() => rgb.HasValue ? rgb.Value.ToString() : "inherit";
+    public override string ToString() => Rgb.HasValue ? Rgb.Value.ToString() : "inherit";
+
+    public bool Inherits => !Rgb.HasValue;
 
     public static ColourInheritable FromRaw(int? raw) => raw is null ? None : new(new ColourRgb(raw.Value));
     public static ColourInheritable FromRgb(byte red, byte green, byte blue) => new(ColourRgb.FromRgb(red, green, blue));
 
     // these should go Away
     private const int MSZ_INHERIT = 0x40000000;
-    public int ToMisuzu() => rgb.HasValue ? rgb.Value.Raw : MSZ_INHERIT;
+    public int ToMisuzu() => Rgb.HasValue ? Rgb.Value.Raw : MSZ_INHERIT;
     public static ColourInheritable FromMisuzu(int msz) => (msz & MSZ_INHERIT) > 0 ? None : new(new ColourRgb(msz & 0xFFFFFF));
 }
diff --git a/SharpChatCommon/Messages/MessageStorage.cs b/SharpChatCommon/Messages/MessageStorage.cs
index e8dce95..5024b02 100644
--- a/SharpChatCommon/Messages/MessageStorage.cs
+++ b/SharpChatCommon/Messages/MessageStorage.cs
@@ -2,7 +2,7 @@ namespace SharpChat.Messages;
 
 public interface MessageStorage {
     Task LogMessage(
-        long msgId,
+        long id,
         string type,
         string channelName,
         string senderId,
@@ -13,7 +13,7 @@ public interface MessageStorage {
         UserPermissions senderPerms,
         object? data = null
     );
-    Task DeleteMessage(Message evt);
-    Task<Message?> GetMessage(long msgId);
+    Task DeleteMessage(Message msg);
+    Task<Message?> GetMessage(long id);
     Task<IEnumerable<Message>> GetMessages(string channelName, int amount = 20, int offset = 0);
 }
diff --git a/SharpChatCommon/Messages/VirtualMessageStorage.cs b/SharpChatCommon/Messages/VirtualMessageStorage.cs
deleted file mode 100644
index a985c3e..0000000
--- a/SharpChatCommon/Messages/VirtualMessageStorage.cs
+++ /dev/null
@@ -1,61 +0,0 @@
-using System.Text.Json;
-
-namespace SharpChat.Messages;
-
-public class VirtualMessageStorage : MessageStorage {
-    private readonly Dictionary<long, Message> Messages = [];
-
-    public Task LogMessage(
-        long id,
-        string type,
-        string channelName,
-        string senderId,
-        string senderName,
-        ColourInheritable senderColour,
-        int senderRank,
-        string senderNick,
-        UserPermissions senderPerms,
-        object? data = null
-    ) {
-        Messages.Add(
-            id,
-            new(
-                id,
-                type,
-                long.TryParse(senderId, out long senderId64) && senderId64 > 0 ? senderId : null,
-                senderName,
-                senderColour,
-                senderRank,
-                senderPerms,
-                senderNick,
-                DateTimeOffset.Now,
-                null,
-                channelName,
-                JsonDocument.Parse(data == null ? "{}" : JsonSerializer.Serialize(data))
-            )
-        ); 
-
-        return Task.CompletedTask;
-    }
-
-    public Task<Message?> GetMessage(long seqId) {
-        return Task.FromResult(Messages.TryGetValue(seqId, out Message? evt) ? evt : null);
-    }
-
-    public Task DeleteMessage(Message evt) {
-        Messages.Remove(evt.Id);
-        return Task.CompletedTask;
-    }
-
-    public Task<IEnumerable<Message>> GetMessages(string channelName, int amount = 20, int offset = 0) {
-        IEnumerable<Message> subset = Messages.Values.Where(ev => ev.ChannelName == channelName);
-
-        int start = subset.Count() - offset - amount;
-        if(start < 0) {
-            amount += start;
-            start = 0;
-        }
-
-        return Task.FromResult(subset.Skip(start).Take(amount).ToArray() as IEnumerable<Message>);
-    }
-}
diff --git a/SharpChatCommon/Storage.cs b/SharpChatCommon/Storage.cs
new file mode 100644
index 0000000..df3b6a1
--- /dev/null
+++ b/SharpChatCommon/Storage.cs
@@ -0,0 +1,8 @@
+using SharpChat.Messages;
+
+namespace SharpChat;
+
+public interface Storage {
+    MessageStorage CreateMessageStorage();
+    Task UpgradeStorage();
+}