diff --git a/backend/database/search.go b/backend/database/search.go index 2b2193f..a0fba59 100644 --- a/backend/database/search.go +++ b/backend/database/search.go @@ -117,24 +117,41 @@ func (d *DB) InsertSearchIndex( return err } -// DeleteSearchIndex removes a row from the FTS5 search_index. -func (d *DB) DeleteSearchIndex(rowid int64) error { - // SAFETY: FTS5 virtual table DELETE unsupported by sqlc. Rowid is parameterized. - _, err := d.db.ExecContext(d.Ctx, ` - DELETE FROM search_index WHERE rowid = ? - `, rowid) - - return err +// DeleteSearchIndex is a no-op for contentless FTS5 tables. +// Contentless FTS5 (content=”) does not support DELETE. +// Stale entries are harmless: they point to rowids that no longer +// match in track_metadata, so JOINs in search queries filter them +// out. The index is fully rebuilt during FullRescan. +func (d *DB) DeleteSearchIndex(_ int64) error { + return nil } // ClearSearchIndex removes all rows from the FTS5 search_index. +// The search_index is a contentless FTS5 table (content=”), which +// does not support DELETE. We drop and recreate it instead. func (d *DB) ClearSearchIndex() error { - // SAFETY: FTS5 virtual table DELETE unsupported by sqlc. No parameters; unconditional delete. - _, err := d.db.ExecContext(d.Ctx, ` - DELETE FROM search_index - `) + // SAFETY: FTS5 contentless table cannot be DELETEd from. + // Drop + recreate is the only way to clear it. No parameters. + if _, err := d.db.ExecContext(d.Ctx, + `DROP TABLE IF EXISTS search_index`, + ); err != nil { + return fmt.Errorf("could not drop search_index: %w", err) + } - return err + if _, err := d.db.ExecContext(d.Ctx, ` + CREATE VIRTUAL TABLE IF NOT EXISTS search_index USING fts5( + file_path, + title, + artist, + album, + content='', + tokenize='unicode61 remove_diacritics 2' + ) + `); err != nil { + return fmt.Errorf("could not recreate search_index: %w", err) + } + + return nil } // RebuildSearchIndex repopulates the FTS5 search_index from diff --git a/backend/database/search_test.go b/backend/database/search_test.go index 6808045..3c64209 100644 --- a/backend/database/search_test.go +++ b/backend/database/search_test.go @@ -742,14 +742,21 @@ func TestClearSearchIndex(t *testing.T) { t.Fatal("SearchFTS before clear: got 0 results") } - // ClearSearchIndex uses DELETE on a contentless FTS5 table - // (content=''), which SQLite does not support. This documents - // the limitation — the error is expected. RebuildSearchIndex - // only succeeds when the index is empty (e.g., after drop+recreate - // or on a fresh database before any inserts). + // ClearSearchIndex drops and recreates the contentless FTS5 + // table, which is the only way to clear a content='' table. err = db.ClearSearchIndex() - if err == nil { - t.Log("ClearSearchIndex succeeded (unexpected for contentless FTS5 with data)") + if err != nil { + t.Fatalf("ClearSearchIndex: %v", err) + } + + // Verify the index is empty after clear. + results, err = db.SearchFTS("queen", 10) + if err != nil { + t.Fatalf("SearchFTS after clear: %v", err) + } + + if len(results) != 0 { + t.Fatalf("SearchFTS after clear: got %d results, want 0", len(results)) } } diff --git a/backend/library/library.go b/backend/library/library.go index 63bb7bf..6d528d6 100644 --- a/backend/library/library.go +++ b/backend/library/library.go @@ -860,7 +860,12 @@ func (l *Library) updateAudioFileMetadata( ) } - // Index in FTS5 search_index (delete old entry, insert new). + // Re-index in FTS5 search_index. + // Contentless FTS5 (content='') does not support DELETE, so we + // cannot remove the old entry. Inserting a new row with the + // same rowid is accepted by FTS5 — the old entry becomes stale + // but harmless (search JOINs against track_metadata filter it). + // The index is fully rebuilt during FullRescan. tags := result.tags if tags == nil { tags = &metadata.TrackMetadata{} @@ -875,21 +880,6 @@ func (l *Library) updateAudioFileMetadata( album := tags.Album - // SAFETY: FTS5 virtual table, see search.go:DeleteSearchIndex. Rowid parameterized. - if _, err := tx.ExecContext( - l.ctx, - `DELETE FROM search_index WHERE rowid = ?`, - result.existingFileID, - ); err != nil { - l.logger.Warn( - "could not remove old FTS entry", - "id", result.existingFileID, - "err", err, - ) - - metrics.addWarning(result.absolutePath, "commit", err) - } - // SAFETY: FTS5 virtual table, see search.go:InsertSearchIndex. All values parameterized. if _, err := tx.ExecContext( l.ctx, diff --git a/backend/library/rescan.go b/backend/library/rescan.go index 04594d5..2e40948 100644 --- a/backend/library/rescan.go +++ b/backend/library/rescan.go @@ -160,22 +160,21 @@ func (l *Library) clearLibraryTables() error { ) } - // Clear FTS5 search index. - // SAFETY: FTS5 virtual table, see search.go:ClearSearchIndex. No parameters; unconditional delete. - if _, err := tx.ExecContext( - l.ctx, `DELETE FROM search_index`, - ); err != nil { - return fmt.Errorf( - "could not clear search index: %w", err, - ) - } - if err := tx.Commit(); err != nil { return fmt.Errorf( "could not commit library clear transaction: %w", err, ) } + // Clear FTS5 search index AFTER the transaction. + // ClearSearchIndex drops and recreates the contentless FTS5 + // virtual table, which cannot run inside a transaction. + if err := l.db.ClearSearchIndex(); err != nil { + return fmt.Errorf( + "could not clear search index: %w", err, + ) + } + l.logger.Info("all library tables cleared") return nil