From 38149059c3a5cf1dccf604922d518e89a8fedd60 Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Wed, 13 May 2026 19:51:54 +0200 Subject: [PATCH] feat(messagestore): add Migrate4 with standalone FTS5 virtual table Lays down a messages_fts virtual table with message_guid (UNINDEXED, hex TEXT of the BLOB primary key), sender_text and content_text columns using the unicode61 tokenizer with diacritic folding. Standalone FTS5 without content='messages' linking, because messages.Id is BLOB and FTS5's content_rowid contract requires an INTEGER rowid alias. LoadByGuids (Task 4.3) will resolve the hex GUIDs back to messages rows via WHERE Id IN (...) joins. Schema step only -- the bulk-insert worker that fills the index lives in Task 4.2. Internal Connection property exposure plus a HasMessagesFtsTable helper let the Build-Suite verify Migrate4 without raw PRAGMA glue in each test. v1.4.8 H2 Sub-Task 4.1. --- HellionChat/MessageStore.cs | 47 ++++++++++++++++++++++++++++++++++++- 1 file changed, 46 insertions(+), 1 deletion(-) diff --git a/HellionChat/MessageStore.cs b/HellionChat/MessageStore.cs index a1e1851..9aa3bfe 100644 --- a/HellionChat/MessageStore.cs +++ b/HellionChat/MessageStore.cs @@ -126,7 +126,12 @@ internal class MessageStore : IDisposable private const int MessageQueryLimit = 10_000; private string DbPath { get; } - private SqliteConnection Connection { get; set; } + + // Internal so the Build-Suite tests can verify Migrate4's CREATE VIRTUAL + // TABLE result via a one-off PRAGMA without exposing a dedicated helper + // for each schema invariant. Setter stays private; the ctor is the only + // place that assigns. + internal SqliteConnection Connection { get; private set; } internal static readonly MessagePackSerializerOptions MsgPackOptions = MessagePackSerializerOptions.Standard.WithResolver( @@ -227,13 +232,19 @@ internal class MessageStore : IDisposable migrationsToDo.Add(Migrate1); migrationsToDo.Add(Migrate2); migrationsToDo.Add(Migrate3); + migrationsToDo.Add(Migrate4); break; case 1: migrationsToDo.Add(Migrate2); migrationsToDo.Add(Migrate3); + migrationsToDo.Add(Migrate4); break; case 2: migrationsToDo.Add(Migrate3); + migrationsToDo.Add(Migrate4); + break; + case 3: + migrationsToDo.Add(Migrate4); break; } @@ -344,6 +355,30 @@ internal class MessageStore : IDisposable SetMigrationVersion(3); } + private void Migrate4() + { + _logger.Information("Running migration 4: Add FTS5 virtual table for full-text search"); + + // Standalone FTS5 table (no content='messages' linking, no content_rowid). + // messages.Id is BLOB-PK (Guid), which is incompatible with FTS5's + // content_rowid requirement of an INTEGER rowid alias. We store the + // GUID as a hex TEXT column (UNINDEXED so the tokenizer skips it) and + // FTS5 manages its own internal INTEGER rowid. LoadByGuids joins back + // via WHERE Id IN (... unhex(message_guid)) when the search returns. + using var cmd = Connection.CreateCommand(); + cmd.CommandText = """ + CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5( + message_guid UNINDEXED, + sender_text, + content_text, + tokenize='unicode61 remove_diacritics 2' + ); + """; + cmd.ExecuteNonQuery(); + + SetMigrationVersion(4); + } + private void SetMigrationVersion(int version) { _logger.Information($"Setting version {version}"); @@ -493,6 +528,16 @@ internal class MessageStore : IDisposable return Convert.ToInt32(cmd.ExecuteScalar()); } + // Schema probe for the v1.4.8 FTS5 virtual table. Used by the Build-Suite + // tests to verify Migrate4's CREATE VIRTUAL TABLE actually landed without + // duplicating PRAGMA glue in each test body. + internal bool HasMessagesFtsTable() + { + using var cmd = Connection.CreateCommand(); + cmd.CommandText = "SELECT count(*) FROM sqlite_master WHERE name='messages_fts';"; + return (long)(cmd.ExecuteScalar() ?? 0L) > 0; + } + internal void UpsertMessage(Message message) { // Privacy filter -- drop disallowed ChatTypes before they reach storage.