diff --git a/migrations/V4__fix_fts_stale_on_edit.sql b/migrations/V4__fix_fts_stale_on_edit.sql new file mode 100644 index 0000000..440e6ae --- /dev/null +++ b/migrations/V4__fix_fts_stale_on_edit.sql @@ -0,0 +1,23 @@ +-- V4: ノート編集時に FTS 索引が旧テキストのまま残るバグの修正。 +-- これまで upsert が text 列を更新せず、FTS にも AFTER UPDATE トリガが +-- なかったため、編集されたノートは検索で取りこぼされていた。 + +-- external content FTS5 の delete + insert パターン。 +-- FTS 側にエントリが存在するのは text IS NOT NULL の行のみ(V1 の insert +-- トリガの不変条件)で、存在しないエントリへの 'delete' は database corrupt +-- 扱いになるため、INSERT ... SELECT ... WHERE で条件付き実行にする。 +-- 2 トリガに分割しないのは発火順が未定義なため(delete → insert の順を保証する)。 +CREATE TRIGGER IF NOT EXISTS notes_fts_au + AFTER UPDATE OF text ON notes_cache BEGIN + INSERT INTO notes_fts(notes_fts, rowid, text) + SELECT 'delete', old.rowid, old.text WHERE old.text IS NOT NULL; + INSERT INTO notes_fts(rowid, text) + SELECT new.rowid, new.text WHERE new.text IS NOT NULL; +END; + +-- 既存 DB では text 列が note_json と乖離している可能性があるため修復する。 +-- この UPDATE は上のトリガを発火させ、FTS 索引も追随する。 +UPDATE notes_cache +SET text = json_extract(note_json, '$.text') +WHERE json_valid(note_json) + AND text IS NOT json_extract(note_json, '$.text'); diff --git a/src/db.rs b/src/db.rs index b07923e..dbbecb9 100644 --- a/src/db.rs +++ b/src/db.rs @@ -417,6 +417,7 @@ impl Database { "INSERT INTO notes_cache (note_id, account_id, server_host, created_at, text, note_json, cached_at, timeline_type, uri) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9) ON CONFLICT(note_id, account_id) DO UPDATE SET + text = excluded.text, note_json = excluded.note_json, cached_at = excluded.cached_at, timeline_type = excluded.timeline_type, @@ -1551,6 +1552,39 @@ mod tests { assert_eq!(results[0].id, "n1"); } + #[test] + fn fts_search_reflects_note_edit() { + let (_dir, db) = temp_db(); + db.cache_notes(&[sample_note("n1", "before edit text")], "home") + .unwrap(); + + // 同じノートが編集後のテキストで再キャッシュされる(Misskey のノート編集) + db.cache_notes(&[sample_note("n1", "after edit text")], "home") + .unwrap(); + + let hit_new = db.search_cached_notes("acc-1", "after", 10).unwrap(); + assert_eq!(hit_new.len(), 1, "編集後テキストで検索できること"); + assert_eq!(hit_new[0].id, "n1"); + + let hit_old = db.search_cached_notes("acc-1", "before", 10).unwrap(); + assert!(hit_old.is_empty(), "編集前テキストの索引が残らないこと"); + } + + #[test] + fn fts_search_reflects_edit_from_null_text() { + let (_dir, db) = temp_db(); + // text が null のノート(renote 等)が後からテキスト付きで再キャッシュされる + let mut no_text = sample_note("n1", ""); + no_text.text = None; + db.cache_notes(&[no_text], "home").unwrap(); + + db.cache_notes(&[sample_note("n1", "now has text")], "home") + .unwrap(); + + let results = db.search_cached_notes("acc-1", "now has", 10).unwrap(); + assert_eq!(results.len(), 1); + } + #[test] fn cache_date_range() { let (_dir, db) = temp_db();