---
phase: 15-schema-migration-write-safety
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- backend/database/sql/schemas/search_index.sql
- backend/database/database.go
- backend/database/search.go
- backend/database/search_test.go
- backend/library/library.go
autonomous: true
requirements: [SCHEMA-01]
must_haves:
truths:
- "FTS5 search_index uses contentless_delete=1 after migration 8"
- "DeleteSearchIndex performs a real DELETE for individual rows"
- "Existing search queries return identical results after migration"
- "Migration is idempotent — safe to re-run if interrupted"
- "ClearSearchIndex still works for full rebuilds"
artifacts:
- path: "backend/database/sql/schemas/search_index.sql"
provides: "Updated FTS5 schema with contentless_delete=1"
contains: "contentless_delete=1"
- path: "backend/database/database.go"
provides: "Migration 8 function"
contains: "migration8"
- path: "backend/database/search.go"
provides: "Real DeleteSearchIndex implementation"
exports: ["DeleteSearchIndex"]
- path: "backend/database/search_test.go"
provides: "Tests for delete, insert-update cycle, and search correctness"
min_lines: 50
key_links:
- from: "backend/database/database.go"
to: "backend/database/search.go"
via: "migration 8 calls RebuildSearchIndex"
pattern: "RebuildSearchIndex"
- from: "backend/database/search.go"
to: "backend/database/sql/schemas/search_index.sql"
via: "ClearSearchIndex CREATE statement matches schema file"
pattern: "contentless_delete=1"
- from: "backend/library/library.go"
to: "backend/database/search.go"
via: "library calls InsertSearchIndex and DeleteSearchIndex"
pattern: "DeleteSearchIndex"
---
Migrate FTS5 search_index to contentless_delete=1 and implement real row-level DELETE support.
Purpose: Currently, DeleteSearchIndex is a no-op because contentless FTS5 tables cannot delete rows. After adding `contentless_delete=1`, individual rows can be deleted/updated — a prerequisite for inline tag edit → DB sync in Phase 16.
Output: Migration 8 function, updated schema, real DeleteSearchIndex, passing tests.
@/home/caleb/.config/opencode/get-shit-done/workflows/execute-plan.md
@/home/caleb/.config/opencode/get-shit-done/templates/summary.md
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/15-schema-migration-write-safety/15-CONTEXT.md
@backend/database/database.go
@backend/database/search.go
@backend/database/search_test.go
@backend/database/sql/schemas/search_index.sql
@backend/library/library.go
From backend/database/database.go:
```go
type DB struct {
db *sql.DB
Ctx context.Context
Queries *sqlcgen.Queries
logger *slog.Logger
}
func (d *DB) runMigrations() error // sequential if version < N blocks
// Current: PRAGMA user_version ends at 7
// Migration 2 (migration2BasenameAndFTS) rebuilds FTS5 on startup
```
From backend/database/search.go:
```go
func (d *DB) InsertSearchIndex(rowid int64, filePath, title, artist, album string) error
func (d *DB) DeleteSearchIndex(_ int64) error // CURRENT: no-op, discards rowid
func (d *DB) ClearSearchIndex() error // DROP + recreate FTS5 table
func (d *DB) RebuildSearchIndex() error // ClearSearchIndex + bulk insert from track_metadata
func (d *DB) SearchFTS(query string) ([]SearchResult, error)
func (d *DB) SearchFTSByFilename(query string) ([]SearchResult, error)
func (d *DB) SearchFTSTracks(query string) ([]Track, error)
func (d *DB) SearchFTSTracksByLibrary(query string, libraryID int64) ([]Track, error)
```
From backend/database/search_test.go:
```go
func seedSearchData(t *testing.T, db *DB) // Seeds 7 tracks with full FK chains
// Tests use NewTestDB(t), t.Parallel(), t.Errorf/t.Fatalf patterns
```
From backend/library/library.go (raw FTS5 SQL):
```go
// Line ~1013-1017: INSERT INTO search_index(rowid, file_path, title, artist, album) VALUES (?, ?, ?, ?, ?)
// Line ~1100-1103: Same INSERT pattern for metadata updates
// Line ~1080-1084: Comment explaining stale FTS entries are harmless
```
Task 1: Migrate FTS5 schema and add migration 8
backend/database/sql/schemas/search_index.sql
backend/database/database.go
backend/database/search.go
backend/library/library.go
**1. Update the FTS5 schema file** (`backend/database/sql/schemas/search_index.sql`):
Change `content=''` to `content='', contentless_delete=1`. The full CREATE statement becomes:
```sql
CREATE VIRTUAL TABLE IF NOT EXISTS search_index USING fts5(
file_path,
title,
artist,
album,
content='',
contentless_delete=1,
tokenize='unicode61 remove_diacritics 2'
);
```
Note: `content=''` is still required — `contentless_delete=1` is an addition, not a replacement. Both options must be present together per SQLite docs.
**2. Update ClearSearchIndex** in `backend/database/search.go`:
Update the inline CREATE VIRTUAL TABLE statement in ClearSearchIndex to match the schema file exactly (add `contentless_delete=1`). This is the second place the FTS5 schema is defined.
**3. Implement real DeleteSearchIndex** in `backend/database/search.go`:
Replace the no-op with a real implementation. With `contentless_delete=1`, the correct DELETE syntax is:
```go
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
}
```
Update the doc comment to remove the "no-op" explanation and document the new behavior.
**4. Add migration 8** to `runMigrations()` in `backend/database/database.go`:
Add a new `if version < 8` block after the existing migration 7 block. The migration must:
- Call `d.ClearSearchIndex()` to DROP the old `content=''` table
- The schema file (already embedded and applied at startup before migrations) creates the new `content='', contentless_delete=1` table — BUT since schemas run first, the old table already exists and `IF NOT EXISTS` skips the creation. So the migration needs to explicitly DROP and recreate.
- After dropping, recreate using the new schema. Don't call ClearSearchIndex here (which has the updated schema) — instead, drop the table and let `RebuildSearchIndex()` handle both recreate + repopulate:
```go
if version < 8 {
d.logger.Info("migration 8: rebuilding FTS5 search_index with contentless_delete=1")
if err := d.RebuildSearchIndex(); err != nil {
return fmt.Errorf("migration 8: could not rebuild search index: %w", err)
}
if _, err := d.db.ExecContext(d.Ctx,
`PRAGMA user_version = 8`,
); err != nil {
return fmt.Errorf("migration 8: could not set user_version: %w", err)
}
}
```
This is naturally idempotent per the CONTEXT.md decision — if interrupted, re-running drops and rebuilds again.
**5. Update library.go raw SQL comments** in `backend/library/library.go`:
Around lines 1080-1084, update the comment that says "stale entries are harmless" to note that `DeleteSearchIndex` now works and Phase 16 will use it for inline updates. The INSERT statements themselves don't change — they already use the correct column names and rowid binding.
**What to avoid:** Do NOT change any column names in the FTS5 table (file_path, title, artist, album). Do NOT modify the tokenizer. Do NOT change InsertSearchIndex or any search query SQL — the only changes are to the table options and DeleteSearchIndex.
cd /mnt/vault/dev/golang/yellowjacket && go test -tags webkit2_41 -run TestSearch -count=1 -timeout 30s ./backend/database/ && go test -tags webkit2_41 -run TestMigration -count=1 -timeout 30s ./backend/database/
- search_index.sql contains `contentless_delete=1`
- ClearSearchIndex CREATE statement matches schema file
- DeleteSearchIndex performs a real DELETE (not a no-op)
- Migration 8 exists and sets PRAGMA user_version = 8
- All existing search tests pass unchanged (SearchFTS, SearchFTSByFilename, etc.)
- `go vet -tags webkit2_41 ./backend/database/` and `go vet -tags webkit2_41 ./backend/library/` pass
Task 2: Add tests for FTS5 row deletion and update cycle
backend/database/search_test.go
Add new test functions to `backend/database/search_test.go` that verify the new DeleteSearchIndex behavior and the insert-delete-reinsert cycle needed for tag editing.
**Tests to add:**
1. **TestDeleteSearchIndex** — Table-driven test:
- Seed data with `seedSearchData(t, db)` (7 tracks)
- Delete one row by rowid
- Verify searching for that track's title returns no results
- Verify searching for other tracks still works
- Cases: delete existing rowid (success), delete non-existent rowid (no error — DELETE WHERE with no match is fine in SQLite)
2. **TestSearchIndexUpdateCycle** — Simulates tag edit flow:
- Insert a track into search_index with rowid=100, title="Old Title", artist="Old Artist"
- Verify search for "Old Title" returns rowid 100
- Delete rowid 100 from search_index
- Verify search for "Old Title" returns no results
- Re-insert rowid 100 with title="New Title", artist="New Artist"
- Verify search for "New Title" returns rowid 100
- Verify search for "Old Title" returns no results (no ghost/stale entries)
3. **TestClearSearchIndexPreservesSchema** — Verify ClearSearchIndex still works:
- Seed data
- Call ClearSearchIndex()
- Verify search returns no results
- Insert new data
- Verify search works again (table was recreated with correct schema including contentless_delete=1)
All tests must follow existing patterns:
- Use `t.Parallel()` at top level
- Use `NewTestDB(t)` for DB setup
- Use `t.Errorf` / `t.Fatalf` (no assertion libraries)
- Use `seedSearchData(t, db)` where appropriate
Note: The seedSearchData helper creates full FK chains (audio_files → recordings → artists → etc.) that satisfy the track_metadata VIEW's JOINs. For TestSearchIndexUpdateCycle, you'll need to insert a minimal audio_file + recording chain to have valid data in track_metadata for the search JOIN. Look at seedSearchData for the exact pattern.
cd /mnt/vault/dev/golang/yellowjacket && go test -tags webkit2_41 -v -run "TestDeleteSearchIndex|TestSearchIndexUpdateCycle|TestClearSearchIndexPreservesSchema" -count=1 -timeout 30s ./backend/database/
- TestDeleteSearchIndex passes — deleting a row removes it from search results
- TestSearchIndexUpdateCycle passes — delete + reinsert produces no ghost entries
- TestClearSearchIndexPreservesSchema passes — drop/recreate preserves new schema
- All existing search_test.go tests continue to pass
- `make test` passes (full test suite)
1. `make test` — full test suite passes (includes race detector)
2. `make lint` — no new lint violations
3. `go vet -tags webkit2_41 ./backend/database/ ./backend/library/` — no issues
4. Grep verification: `grep -n 'contentless_delete=1' backend/database/sql/schemas/search_index.sql backend/database/search.go` shows both locations updated
5. Grep verification: `grep -n 'no-op\|no.op\|NOOP' backend/database/search.go` returns no matches (no-op comment removed)
- FTS5 search_index table uses `content='', contentless_delete=1` in both schema file and ClearSearchIndex
- DeleteSearchIndex performs `DELETE FROM search_index WHERE rowid = ?` (no longer a no-op)
- Migration 8 drops and rebuilds the FTS5 table with the new schema
- All existing search tests pass unchanged
- New tests verify row deletion, update cycle (delete + reinsert), and ClearSearchIndex
- Full `make test` and `make lint` pass