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:
@@ -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
@@ -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'
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user