From 4507eb35e18e5b05489da833aee07908070ee651 Mon Sep 17 00:00:00 2001 From: hitalin Date: Thu, 16 Jul 2026 23:04:55 +0900 Subject: [PATCH] =?UTF-8?q?fix(db):=20=E3=83=8E=E3=83=BC=E3=83=88=E7=B7=A8?= =?UTF-8?q?=E9=9B=86=E6=99=82=E3=81=AB=20FTS=20=E7=B4=A2=E5=BC=95=E3=81=8C?= =?UTF-8?q?=E6=97=A7=E3=83=86=E3=82=AD=E3=82=B9=E3=83=88=E3=81=AE=E3=81=BE?= =?UTF-8?q?=E3=81=BE=E6=AE=8B=E3=82=8B=E3=83=90=E3=82=B0=E3=82=92=E4=BF=AE?= =?UTF-8?q?=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit notes_cache の upsert が text 列を更新せず、FTS に AFTER UPDATE トリガも なかったため、編集されたノートはローカル検索で取りこぼされていた (hitalin/notedeck#783 の調査で発覚)。 - upsert の DO UPDATE SET に text = excluded.text を追加 - V4 migration: AFTER UPDATE OF text トリガを追加。FTS 側にエントリが ない行への 'delete' は database corrupt になるため、INSERT ... SELECT ... WHERE で NULL text をガードした単一トリガにする(2 トリガ分割は 発火順が未定義) - 既存 DB の text / note_json 乖離を migration 内で修復(トリガ経由で FTS も追随) Co-Authored-By: Claude Fable 5 --- migrations/V4__fix_fts_stale_on_edit.sql | 23 ++++++++++++++++ src/db.rs | 34 ++++++++++++++++++++++++ 2 files changed, 57 insertions(+) create mode 100644 migrations/V4__fix_fts_stale_on_edit.sql 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();