fix(messagestore): match TEXT-stored UUID form in FTS bulk insert and LoadByGuids

messages.Id is declared BLOB but stored as TEXT because Microsoft.Data.Sqlite
binds Guid parameters as UUID strings (UpsertMessage uses AddWithValue with
a Guid). RebuildFtsIndex cast reader.GetValue(0) to byte[] and threw
InvalidCastException at the first row. LoadByGuids bound byte[] params
against the TEXT-stored Id and would have returned no rows once the index
had built.

- RebuildFtsIndex reads via GetGuid and stores ToString() in
  messages_fts.message_guid.
- LoadByGuids parses incoming UUID strings and binds them as Guid so
  Microsoft.Data.Sqlite re-serialises to TEXT, matching the messages.Id
  storage form.
- DbViewer caller variable renamed hexIds -> guidHits for clarity.
This commit is contained in:
2026-05-14 09:58:58 +02:00
parent 299fd59cbb
commit 1003a88cad
2 changed files with 24 additions and 16 deletions
+22 -14
View File
@@ -690,7 +690,12 @@ internal class MessageStore : IDisposable
{
ct.ThrowIfCancellationRequested();
var guidBytes = (byte[])reader.GetValue(0);
// messages.Id is BLOB-typed in the schema but stored as TEXT
// because Microsoft.Data.Sqlite binds Guid parameters as UUID
// strings by default (UpsertMessage uses AddWithValue("$Id",
// message.Id)). reader.GetValue(0) therefore returns string,
// not byte[]; GetGuid parses the TEXT form regardless.
var idGuid = reader.GetGuid(0);
var senderChunks = MessagePackSerializer.Deserialize<List<Chunk>>(
reader.GetFieldValue<byte[]>(1),
MsgPackOptions
@@ -700,7 +705,7 @@ internal class MessageStore : IDisposable
MsgPackOptions
);
pG.Value = Convert.ToHexString(guidBytes);
pG.Value = idGuid.ToString();
pS.Value = ChunkUtil.ToRawString(senderChunks);
pC.Value = ChunkUtil.ToRawString(contentChunks);
insert.ExecuteNonQuery();
@@ -760,24 +765,27 @@ internal class MessageStore : IDisposable
}
}
// Joins hex-encoded GUIDs from FullTextSearch back to Message rows. The
// primary key is BLOB, so we decode the hex back to bytes for the IN(...)
// lookup. SQLite has a hard parameter limit of 999 in default builds, so
// we chunk the input -- a 1000-hit FTS query never explodes the SELECT.
// Result ordering is not guaranteed; callers re-sort (e.g. DbViewer sorts
// by Date descending in Sub-Task 4.4).
public IReadOnlyList<Message> LoadByGuids(IReadOnlyList<string> hexIds)
// Joins UUID strings from FullTextSearch back to Message rows. messages.Id
// is BLOB-declared in the schema but actually stored as TEXT (UUID form)
// because Microsoft.Data.Sqlite serialises Guid parameters as strings by
// default. Binding the lookup parameters as Guid keeps the same TEXT
// storage form on both sides so the IN(...) compare matches. SQLite has a
// hard parameter limit of 999 in default builds, so we chunk the input --
// a 1000-hit FTS query never explodes the SELECT. Result ordering is not
// guaranteed; callers re-sort (e.g. DbViewer sorts by Date descending in
// Sub-Task 4.4).
public IReadOnlyList<Message> LoadByGuids(IReadOnlyList<string> guidStrings)
{
if (hexIds.Count == 0)
if (guidStrings.Count == 0)
return Array.Empty<Message>();
lock (_readLock)
{
var result = new List<Message>(hexIds.Count);
var result = new List<Message>(guidStrings.Count);
const int chunkSize = 500;
for (var offset = 0; offset < hexIds.Count; offset += chunkSize)
for (var offset = 0; offset < guidStrings.Count; offset += chunkSize)
{
var batch = hexIds.Skip(offset).Take(chunkSize).ToList();
var batch = guidStrings.Skip(offset).Take(chunkSize).ToList();
using var cmd = Connection.CreateCommand();
var placeholders = string.Join(",", batch.Select((_, i) => $"$id{i}"));
cmd.CommandText = $"""
@@ -787,7 +795,7 @@ internal class MessageStore : IDisposable
WHERE Id IN ({placeholders}) AND Deleted = false;
""";
for (var i = 0; i < batch.Count; i++)
cmd.Parameters.AddWithValue($"$id{i}", Convert.FromHexString(batch[i]));
cmd.Parameters.AddWithValue($"$id{i}", Guid.Parse(batch[i]));
using var reader = cmd.ExecuteReader();
while (reader.Read())
+2 -2
View File
@@ -485,8 +485,8 @@ public class DbViewer : Window
// still serves the page.
if (UseFullTextSearch && Plugin.MessageManager.Store.IsFtsIndexBuilt)
{
var hexIds = Plugin.MessageManager.Store.FullTextSearch(SimpleSearchTerm);
var resolved = Plugin.MessageManager.Store.LoadByGuids(hexIds);
var guidHits = Plugin.MessageManager.Store.FullTextSearch(SimpleSearchTerm);
var resolved = Plugin.MessageManager.Store.LoadByGuids(guidHits);
return new ConcurrentStack<Message>(resolved.OrderByDescending(m => m.Date));
}