feat(15-01): migrate FTS5 search_index to contentless_delete=1

- Add contentless_delete=1 to search_index.sql schema file
- Update ClearSearchIndex CREATE statement to match schema
- Replace DeleteSearchIndex no-op with real DELETE WHERE rowid
- Add migration 8: drop/recreate/repopulate FTS5 table
- Update library.go comment about FTS entry lifecycle
This commit is contained in:
2026-03-16 18:10:57 -04:00
parent 73d9be8e26
commit cb5155b890
4 changed files with 96 additions and 15 deletions
+76
View File
@@ -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 {
+15 -10
View File
@@ -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 {
@@ -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'
);