diff --git a/backend/database/database.go b/backend/database/database.go index 3300ecf..00d48ee 100644 --- a/backend/database/database.go +++ b/backend/database/database.go @@ -322,6 +322,16 @@ func runMigrations( } } + // Migration 8: rebuild FTS5 search_index with + // contentless_delete=1 so individual rows can be deleted. + if version < 8 { + if err := migration8ContentlessDelete( + ctx, db, logger, + ); err != nil { + return err + } + } + return nil } @@ -1078,6 +1088,72 @@ func migration7PhantomFilePath( return nil } +// migration8ContentlessDelete rebuilds the FTS5 search_index with +// contentless_delete=1 so that individual rows can be deleted. +// This is a prerequisite for inline tag edit → DB sync in Phase 16. +// +// SAFETY: Hand-crafted SQL for FTS5 schema migration. +func migration8ContentlessDelete( + ctx context.Context, + db *sql.DB, + logger *slog.Logger, +) error { + logger.Info( + "applying migration 8: rebuilding FTS5 search_index with contentless_delete=1", + ) + + // Drop the old contentless FTS5 table (content='' only). + if _, err := db.ExecContext( + ctx, `DROP TABLE IF EXISTS search_index`, + ); err != nil { + return fmt.Errorf( + "migration 8: could not drop search_index: %w", err, + ) + } + + // Recreate with contentless_delete=1 added. + // SAFETY: Must match backend/database/sql/schemas/search_index.sql exactly. + if _, err := db.ExecContext(ctx, ` + CREATE VIRTUAL TABLE IF NOT EXISTS search_index USING fts5( + file_path, + title, + artist, + album, + content='', + contentless_delete=1, + tokenize='unicode61 remove_diacritics 2' + ) + `); err != nil { + return fmt.Errorf( + "migration 8: could not recreate search_index: %w", err, + ) + } + + // Repopulate from track_metadata VIEW. + // SAFETY: FTS5 INSERT from VIEW; no user input. + if _, err := db.ExecContext(ctx, ` + INSERT INTO search_index(rowid, file_path, title, artist, album) + SELECT id, file_path, title, artist_name, album + FROM track_metadata + `); err != nil { + return fmt.Errorf( + "migration 8: could not repopulate search_index: %w", err, + ) + } + + if _, err := db.ExecContext( + ctx, "PRAGMA user_version = 8", + ); err != nil { + return fmt.Errorf( + "migration 8: could not set user_version: %w", err, + ) + } + + logger.Info("migration 8 complete") + + return nil +} + // readLibraryDirFromTOML reads the TOML config file and returns // the Library.DirectoryPath value, or "" if not configured. func readLibraryDirFromTOML(logger *slog.Logger) string { diff --git a/backend/database/search.go b/backend/database/search.go index 406a2cc..5b637d6 100644 --- a/backend/database/search.go +++ b/backend/database/search.go @@ -117,21 +117,25 @@ func (d *DB) InsertSearchIndex( 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 { +// DeleteSearchIndex removes a single row from the FTS5 search_index +// by rowid. This is supported because the table uses +// contentless_delete=1. Phase 16 uses this for inline tag edits +// (delete old entry, reinsert with updated metadata). +func (d *DB) DeleteSearchIndex(rowid int64) error { + _, err := d.db.ExecContext(d.Ctx, + `DELETE FROM search_index WHERE rowid = ?`, rowid, + ) + if err != nil { + return fmt.Errorf("could not delete search index entry: %w", err) + } + 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. +// We drop and recreate it to ensure a clean slate for full rebuilds. func (d *DB) ClearSearchIndex() error { - // SAFETY: FTS5 contentless table cannot be DELETEd from. - // Drop + recreate is the only way to clear it. No parameters. + // SAFETY: Drop + recreate for full rebuild. No parameters. if _, err := d.db.ExecContext(d.Ctx, `DROP TABLE IF EXISTS search_index`, ); err != nil { @@ -145,6 +149,7 @@ func (d *DB) ClearSearchIndex() error { artist, album, content='', + contentless_delete=1, tokenize='unicode61 remove_diacritics 2' ) `); err != nil { diff --git a/backend/database/sql/schemas/search_index.sql b/backend/database/sql/schemas/search_index.sql index d2f4f2c..efbaf35 100644 --- a/backend/database/sql/schemas/search_index.sql +++ b/backend/database/sql/schemas/search_index.sql @@ -4,5 +4,6 @@ CREATE VIRTUAL TABLE IF NOT EXISTS search_index USING fts5( artist, album, content='', + contentless_delete=1, tokenize='unicode61 remove_diacritics 2' ); diff --git a/backend/library/library.go b/backend/library/library.go index 97baa40..f04494e 100644 --- a/backend/library/library.go +++ b/backend/library/library.go @@ -1077,11 +1077,10 @@ func (l *Library) updateAudioFileMetadata( } // 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. + // With contentless_delete=1 (migration 8), DeleteSearchIndex + // now works for individual row removal. For scan updates we + // still do delete + reinsert; Phase 16 will use the same + // pattern for inline tag edits. tags := result.tags if tags == nil { tags = &metadata.TrackMetadata{}