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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1078,6 +1088,72 @@ func migration7PhantomFilePath(
|
|||||||
return nil
|
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
|
// readLibraryDirFromTOML reads the TOML config file and returns
|
||||||
// the Library.DirectoryPath value, or "" if not configured.
|
// the Library.DirectoryPath value, or "" if not configured.
|
||||||
func readLibraryDirFromTOML(logger *slog.Logger) string {
|
func readLibraryDirFromTOML(logger *slog.Logger) string {
|
||||||
|
|||||||
+15
-10
@@ -117,21 +117,25 @@ func (d *DB) InsertSearchIndex(
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeleteSearchIndex is a no-op for contentless FTS5 tables.
|
// DeleteSearchIndex removes a single row from the FTS5 search_index
|
||||||
// Contentless FTS5 (content=”) does not support DELETE.
|
// by rowid. This is supported because the table uses
|
||||||
// Stale entries are harmless: they point to rowids that no longer
|
// contentless_delete=1. Phase 16 uses this for inline tag edits
|
||||||
// match in track_metadata, so JOINs in search queries filter them
|
// (delete old entry, reinsert with updated metadata).
|
||||||
// out. The index is fully rebuilt during FullRescan.
|
func (d *DB) DeleteSearchIndex(rowid int64) error {
|
||||||
func (d *DB) DeleteSearchIndex(_ 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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ClearSearchIndex removes all rows from the FTS5 search_index.
|
// ClearSearchIndex removes all rows from the FTS5 search_index.
|
||||||
// The search_index is a contentless FTS5 table (content=”), which
|
// We drop and recreate it to ensure a clean slate for full rebuilds.
|
||||||
// does not support DELETE. We drop and recreate it instead.
|
|
||||||
func (d *DB) ClearSearchIndex() error {
|
func (d *DB) ClearSearchIndex() error {
|
||||||
// SAFETY: FTS5 contentless table cannot be DELETEd from.
|
// SAFETY: Drop + recreate for full rebuild. No parameters.
|
||||||
// Drop + recreate is the only way to clear it. No parameters.
|
|
||||||
if _, err := d.db.ExecContext(d.Ctx,
|
if _, err := d.db.ExecContext(d.Ctx,
|
||||||
`DROP TABLE IF EXISTS search_index`,
|
`DROP TABLE IF EXISTS search_index`,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
@@ -145,6 +149,7 @@ func (d *DB) ClearSearchIndex() error {
|
|||||||
artist,
|
artist,
|
||||||
album,
|
album,
|
||||||
content='',
|
content='',
|
||||||
|
contentless_delete=1,
|
||||||
tokenize='unicode61 remove_diacritics 2'
|
tokenize='unicode61 remove_diacritics 2'
|
||||||
)
|
)
|
||||||
`); err != nil {
|
`); err != nil {
|
||||||
|
|||||||
@@ -4,5 +4,6 @@ CREATE VIRTUAL TABLE IF NOT EXISTS search_index USING fts5(
|
|||||||
artist,
|
artist,
|
||||||
album,
|
album,
|
||||||
content='',
|
content='',
|
||||||
|
contentless_delete=1,
|
||||||
tokenize='unicode61 remove_diacritics 2'
|
tokenize='unicode61 remove_diacritics 2'
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1077,11 +1077,10 @@ func (l *Library) updateAudioFileMetadata(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Re-index in FTS5 search_index.
|
// Re-index in FTS5 search_index.
|
||||||
// Contentless FTS5 (content='') does not support DELETE, so we
|
// With contentless_delete=1 (migration 8), DeleteSearchIndex
|
||||||
// cannot remove the old entry. Inserting a new row with the
|
// now works for individual row removal. For scan updates we
|
||||||
// same rowid is accepted by FTS5 — the old entry becomes stale
|
// still do delete + reinsert; Phase 16 will use the same
|
||||||
// but harmless (search JOINs against track_metadata filter it).
|
// pattern for inline tag edits.
|
||||||
// The index is fully rebuilt during FullRescan.
|
|
||||||
tags := result.tags
|
tags := result.tags
|
||||||
if tags == nil {
|
if tags == nil {
|
||||||
tags = &metadata.TrackMetadata{}
|
tags = &metadata.TrackMetadata{}
|
||||||
|
|||||||
Reference in New Issue
Block a user