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.
This commit is contained in:
2026-05-13 19:51:54 +02:00
parent 67175419a9
commit 38149059c3
+46 -1
View File
@@ -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.