diff --git a/.planning/phases/06-sql-consolidation-code-quality/06-RESEARCH.md b/.planning/phases/06-sql-consolidation-code-quality/06-RESEARCH.md new file mode 100644 index 0000000..b0de034 --- /dev/null +++ b/.planning/phases/06-sql-consolidation-code-quality/06-RESEARCH.md @@ -0,0 +1,528 @@ +# Phase 6: SQL Consolidation & Code Quality - Research + +**Researched:** 2026-03-04 +**Domain:** SQLite VIEW consolidation, Go codegen, sqlc advanced features +**Confidence:** HIGH + +## Summary + +Phase 6 eliminates duplicated SQL JOIN patterns, automates Go→TypeScript event synchronization, migrates eligible hand-crafted SQL to sqlc, and documents all intentional sqlc exceptions. The codebase has a well-defined 5-table JOIN pattern (audio_files → recordings → artist_credit → release_group_recordings subquery → release_groups) duplicated across **10+ locations** in both hand-crafted Go SQL and sqlc query files. This pattern can be consolidated into a single SQLite VIEW named `track_metadata`. + +Verification confirms that sqlc v1.30.0 (the project's current version) fully supports querying from VIEWs and using `sqlc.slice()` for IN clauses with the SQLite engine — both features were tested directly against the project's toolchain. The event codegen task is straightforward: `go/ast` can parse the 4 const blocks in `events.go` (21 constants) and produce the matching TypeScript `events.ts` output. The existing `codegen-check` lefthook hook currently runs `go generate ./...` which was observed to hang in earlier phases (templ generation timeout), but testing now shows it completes in under 1 second — the fix may simply be wiring the new generator into the existing hook and verifying it works end-to-end. + +**Primary recommendation:** Create the `track_metadata` VIEW as migration 4, update all search/rebuild queries to use it, write the event codegen tool using `go/ast`, migrate `lookupChunk` to sqlc with `sqlc.slice()`, and annotate all remaining hand-crafted SQL with `// SAFETY:` comments. + + +## User Constraints (from CONTEXT.md) + +### Locked Decisions +- Single VIEW named `track_metadata` with all 16 columns (file_path, length, title, artist, album, track_number, disc_number, genre, year, composer, file_type, sample_rate, bit_depth, channels, bitrate, file_size) +- VIEW rooted on `audio_files` (not `search_index`) so it's usable for both search queries (JOIN search_index to VIEW) and index rebuilds (SELECT directly from VIEW) +- Created as migration 4 (next sequential PRAGMA user_version bump) +- Lightweight search queries (SearchFTS, SearchFTSByFilename) SELECT only the 5 columns they need from the VIEW — SQLite optimizes unused columns away +- RebuildSearchIndex and migration2 INSERT INTO search_index use the VIEW instead of duplicating the JOIN +- Go constants in `backend/events/events.go` are the source of truth +- Generator written in Go, using `go/ast` to parse the const block from events.go +- Wired into `go generate` via `//go:generate` directive on events.go +- Output format matches current `frontend/src/events.ts` structure exactly: `export const Events = { ... } as const;` — zero changes needed in frontend import sites +- Fix the existing `codegen-check` lefthook pre-commit hook (currently hangs) to run the generator and diff the output — fail if TypeScript file is stale +- Note: `LibraryConfigChanged` exists in Go but is missing from TypeScript — the generator will fix this automatically +- Migrate `lookupChunk()` query fully to sqlc: the SELECT + JOINs + `sqlc.slice()` for the IN clause — use the new `track_metadata` VIEW instead of hand-crafted JOINs +- `insertTrackBatch()` (multi-row VALUES with variable row count) stays hand-crafted — sqlc cannot generate variable-length batch INSERTs. Document as exception. +- All FTS5 operations (~11 statements across search.go, library.go, rescan.go) stay hand-crafted — sqlc does not support FTS5 virtual tables (MATCH, rank, content='' tables). Document all as exceptions. +- Format: two parts — WHY sqlc can't handle it AND what makes it safe +- Example: `// SAFETY: FTS5 MATCH syntax unsupported by sqlc. Query is parameterized; no string interpolation.` +- Scope: runtime query SQL only — migration DDL (ALTER TABLE, CREATE INDEX, PRAGMA) does NOT need SAFETY comments +- Per-statement annotation only — no central registry file. The comments ARE the documentation. +- Cross-reference related operations: FTS5 INSERT/DELETE in library.go and rescan.go should reference search.go functions they relate to + +### Claude's Discretion +- Exact VIEW column ordering and COALESCE/NULL handling +- Generator CLI interface (flags, output path defaults) +- How to structure the sqlc query file for lookupChunk (naming, placement) +- Exact wording of SAFETY comments (as long as they follow the two-part format) +- How to handle the lefthook codegen-check fix (may need to investigate why it hangs) + +### Deferred Ideas (OUT OF SCOPE) +None — discussion stayed within phase scope + + + +## Phase Requirements + +| ID | Description | Research Support | +|----|-------------|-----------------| +| QUAL-01 | Duplicated FTS5 JOIN pattern (5+ copies) consolidated into single SQLite VIEW | VIEW `track_metadata` verified working with sqlc v1.30.0; 10+ duplicate JOIN sites identified across search.go, database.go, audio_files.sql, playlists.sql, genres.sql, persistence.go | +| QUAL-02 | Event constants generated from Go to TypeScript via codegen, wired into go generate and pre-commit hook | 21 Go constants in 4 const blocks parseable by `go/ast`; TypeScript has 20 (missing `LibraryConfigChanged`); `go generate ./...` completes in <1s; lefthook codegen-check hook exists but needs generator wiring | +| QUAL-03 | Queue batch lookups use sqlc.slice() instead of fmt.Sprintf placeholder construction | `sqlc.slice()` confirmed working with SQLite engine in sqlc v1.30.0 (tested directly); `lookupChunk` in persistence.go is the target; chunking logic must be preserved at caller level | +| QUAL-04 | Hand-crafted SQL exceptions documented with // SAFETY: comments | ~11 FTS5 statements + 1 insertTrackBatch identified; two-part comment format decided | + + +## Standard Stack + +### Core +| Tool | Version | Purpose | Why Standard | +|------|---------|---------|--------------| +| sqlc | v1.30.0 | SQL-to-Go codegen | Already in use (`go tool sqlc`); supports VIEWs and `sqlc.slice()` for SQLite | +| go/ast | stdlib (Go 1.25) | Parse Go const blocks for event codegen | Standard library, no dependencies; reliable AST parsing | +| go/parser | stdlib (Go 1.25) | Parse Go source files | Used with go/ast for the event generator | +| go/token | stdlib (Go 1.25) | Token positions for AST parsing | Required by go/parser | + +### Supporting +| Tool | Version | Purpose | When to Use | +|------|---------|---------|-------------| +| lefthook | v1.13.6+ | Pre-commit hook runner | Wire event codegen check into existing `codegen-check` hook | +| modernc.org/sqlite | v1.45.0 | SQLite driver (pure Go) | Already in use; VIEW support is standard SQLite | + +### Alternatives Considered +| Instead of | Could Use | Tradeoff | +|------------|-----------|----------| +| go/ast | Regex parsing of events.go | Fragile, breaks on comments/formatting changes; go/ast is robust | +| SQLite VIEW | Rewrite all queries in sqlc | FTS5 queries can't use sqlc; VIEW gives partial consolidation | +| sqlc.slice() | Keep hand-crafted lookupChunk | sqlc.slice() is cleaner and eliminates manual placeholder construction | + +## Architecture Patterns + +### VIEW Schema Location +``` +backend/database/sql/schemas/ +├── ...existing schema files... +└── track_metadata_view.sql # CREATE VIEW IF NOT EXISTS track_metadata +``` + +The VIEW SQL file goes in the schemas directory so sqlc can see it during code generation. File naming should sort after the tables it depends on (alphabetical ordering puts `track_metadata_view.sql` after all table schemas). + +**Important:** `CREATE VIEW IF NOT EXISTS` is the correct DDL for the schema file. The VIEW will also be created by migration 4 for existing databases, but the schema file ensures sqlc knows about it and new databases get it automatically. + +### Pattern 1: VIEW Definition +**What:** The `track_metadata` VIEW consolidates the 5-table JOIN into a reusable SQL object +**When to use:** Any query needing audio file metadata with title/artist/album +**Example:** +```sql +-- In backend/database/sql/schemas/track_metadata_view.sql +CREATE VIEW IF NOT EXISTS track_metadata AS +SELECT + af.id, + af.file_path, + af.length_milliseconds, + COALESCE(r.name, '') AS title, + COALESCE(ac.text, '') AS artist_name, + r.track_number, + r.disc_number, + COALESCE(rg.name, '') AS album, + CAST(COALESCE( + (SELECT GROUP_CONCAT(g.name, '||') + FROM recording_genres rg_sub + JOIN genres g ON rg_sub.genre_id = g.id + WHERE rg_sub.recording_id = r.id), + '' + ) AS TEXT) AS genre, + COALESCE(r.year, 0) AS year, + COALESCE(r.composer, '') AS composer, + COALESCE(ft.extension, '') AS file_type, + af.sample_rate, + af.bit_depth, + af.channels, + af.bitrate, + af.file_size +FROM audio_files af +LEFT JOIN recordings r ON af.recording_id = r.id +LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id +LEFT JOIN ( + SELECT recording_id, + MIN(release_group_id) AS release_group_id + FROM release_group_recordings + GROUP BY recording_id +) rgr ON r.id = rgr.recording_id +LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id +LEFT JOIN file_types ft ON af.file_type_id = ft.id; +``` + +**Note:** The VIEW includes `af.id` (needed for FTS5 rowid matching and queue lookups). The `id` column is included in the VIEW but queries don't have to select it. It also uses LEFT JOIN throughout (not INNER JOIN) to match the existing pattern — some audio files may have recording_id=0 (no metadata yet). + +### Pattern 2: Search Queries Using VIEW +**What:** FTS5 search queries JOIN search_index to the VIEW +**When to use:** SearchFTS, SearchFTSByFilename, SearchFTSTracks +**Example:** +```sql +-- Hand-crafted (stays in search.go — FTS5 MATCH unsupported by sqlc) +SELECT + tm.file_path, + tm.length_milliseconds, + tm.title, + tm.artist_name, + tm.album +FROM search_index si +JOIN track_metadata tm ON tm.id = si.rowid +WHERE search_index MATCH ? +ORDER BY rank +LIMIT ? +``` + +### Pattern 3: Rebuild Using VIEW +**What:** RebuildSearchIndex selects directly from VIEW +**When to use:** Full FTS5 index rebuild, migration 2 FTS population +**Example:** +```sql +-- Hand-crafted (stays in search.go — FTS5 INSERT unsupported by sqlc) +INSERT INTO search_index(rowid, file_path, title, artist, album) +SELECT id, file_path, title, artist_name, album +FROM track_metadata +``` + +### Pattern 4: sqlc.slice() for Batch Lookups +**What:** Queue lookupChunk migrated to sqlc query using VIEW + sqlc.slice() +**When to use:** Batch file path lookups in queue persistence +**Example:** +```sql +-- In backend/database/sql/queries/queue.sql (or audio_files.sql) +-- name: LookupTrackMetaBatch :many +SELECT id, file_path, title, artist_name +FROM track_metadata +WHERE file_path IN (sqlc.slice('paths')); +``` + +**Critical note:** The generated sqlc code does NOT handle chunking — it generates a single query with all placeholders. The caller (`lookupTrackMetaBatch`) must still chunk the paths array at `maxSQLiteVars = 900` before calling the generated method. The chunking loop stays; only the inner SQL construction moves to sqlc. + +### Pattern 5: Event Codegen with go/ast +**What:** Go program reads events.go const blocks, generates events.ts +**When to use:** Automated via `//go:generate` directive +**Example structure:** +```go +// backend/events/gen_events_ts.go (or cmd/gen-events/main.go) +package main + +import ( + "go/ast" + "go/parser" + "go/token" + // ... +) + +func main() { + fset := token.NewFileSet() + f, err := parser.ParseFile(fset, "events.go", nil, parser.ParseComments) + // Walk AST, extract const declarations + // Group by comment blocks (Playback, Queue, Config, Playlist, Library) + // Generate TypeScript output matching current format +} +``` + +### Anti-Patterns to Avoid +- **Don't put the VIEW in a migration-only file without the schema file:** sqlc needs the VIEW definition in the schema directory to generate code against it. The migration creates it for existing DBs; the schema file teaches sqlc about it. +- **Don't remove chunking from lookupTrackMetaBatch:** `sqlc.slice()` doesn't auto-chunk. SQLite has a bind variable limit (~32766 in newer versions, but the project uses a conservative 900). The chunking loop must remain. +- **Don't try to make FTS5 queries use sqlc:** FTS5 MATCH syntax, `content=''` virtual tables, and rank ordering are unsupported by sqlc's parser. These must stay hand-crafted. +- **Don't change the migration2 code to use the VIEW for DB version < 4:** Migration 2 runs before migration 4 in sequence. For databases upgrading from version 1→4, migration 2 must still work without the VIEW. Only databases already at version ≥ 4 (including fresh DBs) should use the VIEW in the rebuild path. + +## Don't Hand-Roll + +| Problem | Don't Build | Use Instead | Why | +|---------|-------------|-------------|-----| +| Go AST parsing | Regex/string matching on events.go | `go/ast` + `go/parser` + `go/token` | Handles comments, multiline, formatting robustly | +| SQL IN clause placeholder construction | `fmt.Sprintf` with manual `?` joining | `sqlc.slice()` | Generates correct placeholder expansion; type-safe | +| Duplicate JOIN patterns | Copy-paste SQL across files | SQLite VIEW | Single source of truth; SQLite optimizes unused columns | + +**Key insight:** The manual placeholder construction in `lookupChunk` is exactly the pattern `sqlc.slice()` was designed to replace — sqlc generates the same `strings.Replace` / `strings.Repeat` code but with type safety and no manual `args` slice building. + +## Common Pitfalls + +### Pitfall 1: Migration Ordering with VIEW +**What goes wrong:** migration2 tries to SELECT from `track_metadata` VIEW before migration 4 creates it +**Why it happens:** Migrations run sequentially by version number. A database at version 1 runs migration 2 (which populates FTS) before migration 4 (which creates the VIEW). +**How to avoid:** Keep the existing inline JOIN in `migration2BasenameAndFTS`. Only the `RebuildSearchIndex` function (called at runtime, not during migration) should use the VIEW. The VIEW schema file handles fresh databases; migration 4 handles existing databases. +**Warning signs:** `no such table: track_metadata` error during migration + +### Pitfall 2: sqlc Schema File Ordering +**What goes wrong:** sqlc fails to parse the VIEW definition because it references tables not yet defined +**Why it happens:** sqlc processes schema files in filesystem order. If `track_metadata_view.sql` sorts before the tables it references, sqlc can't resolve them. +**How to avoid:** Name the file so it sorts after all dependencies. `track_metadata_view.sql` sorts after `recordings.sql`, `release_groups.sql`, etc. (all start with lowercase letters before 't'). Alternatively, prefix with `zz_` if needed, but alphabetical ordering of `track_metadata_view.sql` already works. +**Warning signs:** sqlc generate errors about unknown tables/columns + +### Pitfall 3: VIEW Column Mismatch with Existing Queries +**What goes wrong:** Queries that used INNER JOINs (e.g., `GetAllTracksWithFullMetadata` uses `JOIN recordings r` not `LEFT JOIN`) return different results when switched to the VIEW (which uses LEFT JOINs) +**Why it happens:** The VIEW uses LEFT JOINs to handle audio files without metadata. Existing sqlc queries that use INNER JOINs implicitly filter out unmatched rows. +**How to avoid:** Only replace queries that already use LEFT JOINs (search queries, playlist metadata queries, SearchAudioFilesByBasename). Leave queries with intentional INNER JOINs (like `GetAllTracksWithFullMetadata`) as-is, or add `WHERE r.id IS NOT NULL` to preserve INNER JOIN semantics. Carefully review each query's JOIN type before converting. +**Warning signs:** Extra rows with empty metadata appearing in results + +### Pitfall 4: codegen-check Hook Scope +**What goes wrong:** The event generator is added to `go generate` but the codegen-check hook still runs the full `go generate ./...` which includes templ and sqlc, making it slow +**Why it happens:** The hook runs all generators, not just the event one +**How to avoid:** The hook currently runs `go generate ./...` and then diffs. This approach is actually fine — testing shows `go generate ./...` completes in <1 second when nothing has changed. The hanging issue from earlier phases appears to be resolved. Verify the hook works end-to-end after wiring in the new generator. +**Warning signs:** Hook taking >5 seconds (should be <2s) + +### Pitfall 5: sqlc.slice() Empty Slice Behavior +**What goes wrong:** Passing an empty slice to a `sqlc.slice()` query +**Why it happens:** The generated code replaces the placeholder with `NULL` for empty slices, which means `WHERE file_path IN (NULL)` — this matches nothing (correct behavior), but the caller should still handle it +**How to avoid:** The chunking logic in `lookupTrackMetaBatch` already handles empty input (returns empty map). The sqlc-generated code also handles empty slices gracefully (returns empty results). No action needed, but be aware of the behavior. +**Warning signs:** N/A — behavior is correct + +### Pitfall 6: Generated TypeScript File Must Be Deterministic +**What goes wrong:** The event generator produces different output on different runs (e.g., map iteration order), causing the codegen-check hook to always fail +**Why it happens:** Go maps don't have deterministic iteration order +**How to avoid:** Use `ast.Inspect` or iterate `f.Decls` in source order (AST preserves declaration order). Don't collect into a map and iterate — iterate the AST directly and emit in declaration order. +**Warning signs:** `codegen-check` hook always shows diff even when events.go hasn't changed + +## Code Examples + +### Example 1: Migration 4 — Create track_metadata VIEW +```sql +-- In migration 4 (backend/database/database.go) +CREATE VIEW IF NOT EXISTS track_metadata AS +SELECT + af.id, + af.file_path, + af.length_milliseconds, + COALESCE(r.name, '') AS title, + COALESCE(ac.text, '') AS artist_name, + r.track_number, + r.disc_number, + COALESCE(rg.name, '') AS album, + CAST(COALESCE( + (SELECT GROUP_CONCAT(g.name, '||') + FROM recording_genres rg_sub + JOIN genres g ON rg_sub.genre_id = g.id + WHERE rg_sub.recording_id = r.id), + '' + ) AS TEXT) AS genre, + COALESCE(r.year, 0) AS year, + COALESCE(r.composer, '') AS composer, + COALESCE(ft.extension, '') AS file_type, + af.sample_rate, + af.bit_depth, + af.channels, + af.bitrate, + af.file_size +FROM audio_files af +LEFT JOIN recordings r ON af.recording_id = r.id +LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id +LEFT JOIN ( + SELECT recording_id, + MIN(release_group_id) AS release_group_id + FROM release_group_recordings + GROUP BY recording_id +) rgr ON r.id = rgr.recording_id +LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id +LEFT JOIN file_types ft ON af.file_type_id = ft.id; +``` + +### Example 2: Consolidated SearchFTS Using VIEW +```go +// In search.go — replaces the inline 5-table JOIN +rows, err := d.db.QueryContext(d.Ctx, ` + SELECT + tm.file_path, + tm.length_milliseconds, + tm.title, + tm.artist_name, + tm.album + FROM search_index si + JOIN track_metadata tm ON tm.id = si.rowid + WHERE search_index MATCH ? + ORDER BY rank + LIMIT ? +`, ftsQuery, limit) +``` + +### Example 3: Consolidated RebuildSearchIndex Using VIEW +```go +// In search.go — replaces inline JOIN for rebuild +_, err := d.db.ExecContext(d.Ctx, ` + INSERT INTO search_index(rowid, file_path, title, artist, album) + SELECT id, file_path, title, artist_name, album + FROM track_metadata +`) +``` + +### Example 4: sqlc Query for lookupChunk Replacement +```sql +-- In backend/database/sql/queries/queue.sql (or a new track_metadata.sql) +-- name: LookupTrackMetaByPaths :many +SELECT id, file_path, title, artist_name +FROM track_metadata +WHERE file_path IN (sqlc.slice('paths')); +``` + +### Example 5: Event Generator Core Logic +```go +// Using go/ast to extract constants from events.go +fset := token.NewFileSet() +f, err := parser.ParseFile(fset, eventsGoPath, nil, parser.ParseComments) +if err != nil { + log.Fatal(err) +} + +type eventConst struct { + Name string + Value string +} + +var events []eventConst + +ast.Inspect(f, func(n ast.Node) bool { + genDecl, ok := n.(*ast.GenDecl) + if !ok || genDecl.Tok != token.CONST { + return true + } + for _, spec := range genDecl.Specs { + vs, ok := spec.(*ast.ValueSpec) + if !ok || len(vs.Names) == 0 || len(vs.Values) == 0 { + continue + } + lit, ok := vs.Values[0].(*ast.BasicLit) + if !ok || lit.Kind != token.STRING { + continue + } + name := vs.Names[0].Name + value := strings.Trim(lit.Value, `"`) + events = append(events, eventConst{Name: name, Value: value}) + } + return true +}) +``` + +### Example 6: SAFETY Comment Examples +```go +// SAFETY: FTS5 MATCH syntax unsupported by sqlc. Query is parameterized; no string interpolation. +rows, err := d.db.QueryContext(d.Ctx, `SELECT ... FROM search_index si ... WHERE search_index MATCH ?`, ...) + +// SAFETY: FTS5 virtual table INSERT unsupported by sqlc. All values come from track_metadata VIEW; no user input. +_, err := d.db.ExecContext(d.Ctx, `INSERT INTO search_index(rowid, ...) SELECT ... FROM track_metadata`) + +// SAFETY: FTS5 virtual table, see search.go:InsertSearchIndex. Parameterized. +_, err := tx.ExecContext(l.ctx, `INSERT INTO search_index(rowid, ...) VALUES (?, ?, ?, ?, ?)`, ...) + +// SAFETY: Multi-row INSERT with variable row count unsupported by sqlc. Placeholder count matches args length; no string interpolation. +_, err := tx.ExecContext(q.db.Ctx, query, args...) +``` + +## State of the Art + +| Old Approach | Current Approach | When Changed | Impact | +|--------------|------------------|--------------|--------| +| Manual IN clause placeholder | `sqlc.slice()` | sqlc v1.18+ | Type-safe slice parameters for MySQL/SQLite | +| Duplicate JOINs everywhere | SQLite VIEWs | Always available | Single source of truth, optimizer handles unused columns | +| Manual event sync | Codegen from Go→TS | This phase | Eliminates drift (LibraryConfigChanged already missing) | + +**Deprecated/outdated:** +- None relevant — all tools are current versions + +## Existing Duplicate JOIN Inventory + +All locations with the 5-table audio metadata JOIN pattern: + +### Hand-Crafted SQL in Go (stay hand-crafted, get SAFETY comments) +| File | Function/Line | Pattern | VIEW Applicable? | +|------|--------------|---------|-----------------| +| `backend/database/search.go:34` | SearchFTS | FTS5 MATCH + 5-table JOIN | Yes — replace JOIN with `JOIN track_metadata` | +| `backend/database/search.go:92` | SearchFTSByFilename | FTS5 MATCH + 5-table JOIN | Yes — replace JOIN with `JOIN track_metadata` | +| `backend/database/search.go:232` | SearchFTSTracks | FTS5 MATCH + 6-table JOIN (+ file_types) | Yes — replace JOIN with `JOIN track_metadata` | +| `backend/database/search.go:168` | RebuildSearchIndex | INSERT INTO FTS from 5-table JOIN | Yes — `SELECT FROM track_metadata` | +| `backend/database/database.go:344` | migration2BasenameAndFTS | INSERT INTO FTS from 5-table JOIN | **No** — must keep inline (runs before migration 4) | +| `backend/library/library.go:798` | commitNewAudioFile | FTS5 INSERT VALUES | No — single-row parameterized insert, no JOIN | +| `backend/library/library.go:879` | updateAudioFileMetadata | FTS5 DELETE + INSERT | No — single-row operations, no JOIN | +| `backend/library/rescan.go:165` | clearAllLibraryData | FTS5 DELETE all | No — simple DELETE, no JOIN | +| `backend/queue/persistence.go:64` | lookupChunk | 3-table JOIN + fmt.Sprintf IN | Yes — migrate to sqlc with VIEW | +| `backend/queue/persistence.go:195` | insertTrackBatch | Multi-row INSERT with variable VALUES | No — stays hand-crafted (no JOINs) | + +### sqlc Query Files (already managed by sqlc, may benefit from VIEW) +| File | Query Name | Pattern | VIEW Applicable? | +|------|-----------|---------|-----------------| +| `audio_files.sql:106` | SearchAudioFilesByBasename | 5-table JOIN (same subquery pattern) | Yes — could use VIEW | +| `audio_files.sql:75` | GetAllTracksWithFullMetadata | 6-table JOIN (INNER JOINs) | Partial — uses INNER JOINs (different semantics) | +| `playlists.sql:37` | GetPlaylistTracksWithMetadata | 6-table JOIN + cover_art | Partial — includes cover_art JOIN not in VIEW | +| `playlists.sql:63` | GetAllPlaylistTracksWithMetadata | 6-table JOIN + cover_art | Partial — includes cover_art JOIN not in VIEW | +| `genres.sql:26` | GetTracksByGenre | 7-table JOIN (genre-rooted) | Partial — rooted on genres, not audio_files | +| `queue.sql:15` | GetQueueTracks | 3-table JOIN | Partial — simpler pattern (no rgr subquery) | + +### Scope Decision for sqlc Queries +The VIEW consolidation primarily targets the **hand-crafted Go SQL** where the duplication is most problematic (search.go has 3 copies of the identical pattern). For sqlc queries, converting to use the VIEW is optional and should be done case-by-case: +- `SearchAudioFilesByBasename` — good candidate (exact same pattern) +- Playlist/genre queries — involve additional JOINs (cover_art, genre tables) beyond what the VIEW provides, so the benefit is lower +- `GetAllTracksWithFullMetadata` — uses INNER JOINs intentionally, semantics differ from VIEW's LEFT JOINs + +## FTS5 Statements Requiring SAFETY Comments + +Complete inventory of hand-crafted FTS5 SQL statements: + +| # | File | Line | Operation | Comment Needed | +|---|------|------|-----------|---------------| +| 1 | `search.go` | 34 | SearchFTS — `WHERE search_index MATCH ?` | Yes | +| 2 | `search.go` | 92 | SearchFTSByFilename — `WHERE search_index MATCH ?` | Yes | +| 3 | `search.go` | 133 | InsertSearchIndex — `INSERT INTO search_index` | Yes | +| 4 | `search.go` | 143 | DeleteSearchIndex — `DELETE FROM search_index WHERE rowid = ?` | Yes | +| 5 | `search.go` | 152 | ClearSearchIndex — `DELETE FROM search_index` | Yes | +| 6 | `search.go` | 168 | RebuildSearchIndex — `INSERT INTO search_index ... SELECT FROM` | Yes | +| 7 | `search.go` | 232 | SearchFTSTracks — `WHERE search_index MATCH ?` | Yes | +| 8 | `library.go` | 798 | commitNewAudioFile — `INSERT INTO search_index ... VALUES` | Yes (cross-ref search.go) | +| 9 | `library.go` | 879 | updateAudioFileMetadata — `DELETE FROM search_index` | Yes (cross-ref search.go) | +| 10 | `library.go` | 891 | updateAudioFileMetadata — `INSERT INTO search_index ... VALUES` | Yes (cross-ref search.go) | +| 11 | `rescan.go` | 165 | clearAllLibraryData — `DELETE FROM search_index` | Yes (cross-ref search.go) | +| 12 | `persistence.go` | 195 | insertTrackBatch — multi-row `INSERT INTO queue_tracks` | Yes (variable VALUES count) | + +## Event Constant Inventory + +### Go (backend/events/events.go) — 21 constants in 4 blocks +``` +Playback: PlaybackStateChanged, PlaybackFinished, TrackChanged, SeekFailed, VolumeChanged +Queue: QueueChanged, QueueIndexChanged, QueueModeChanged, QueueTracksModified +Config: LibraryConfigChanged, ThemeConfigChanged, TrackListConfigChanged, FavoritesConfigChanged +Playlist: PlaylistCreated, PlaylistDeleted, PlaylistRenamed, PlaylistTracksChanged, PlaylistsRestored, DefaultPlaylistChanged +Library: LibraryScanStarted, LibraryScanComplete +``` + +### TypeScript (frontend/src/events.ts) — 20 constants +Missing: `LibraryConfigChanged` (exists in Go, absent from TypeScript) + +### Generator Output Format Target +```typescript +export const Events = { + // Playback events (backend → frontend push) + PlaybackStateChanged: "PlaybackStateChanged", + // ... preserving comment groups and ordering +} as const; + +export type EventName = (typeof Events)[keyof typeof Events]; +``` + +## Open Questions + +1. **Should sqlc queries (SearchAudioFilesByBasename, etc.) also be updated to use the VIEW?** + - What we know: The VIEW consolidation is primarily targeting hand-crafted Go SQL in search.go. Sqlc queries are already managed and less prone to drift. + - What's unclear: Whether updating sqlc queries provides enough benefit to justify the churn and testing. + - Recommendation: Update `SearchAudioFilesByBasename` (exact same pattern). Leave playlist/genre queries as-is (they have additional JOINs the VIEW doesn't cover). This is Claude's discretion per CONTEXT.md. + +2. **Where should the event generator Go file live?** + - What we know: It needs to be a `main` package (standalone executable for `go:generate`). Options: `backend/events/cmd/gen-events-ts/main.go` or `cmd/gen-events-ts/main.go` or inline in `backend/events/`. + - What's unclear: Project convention for codegen tools (none exist yet). + - Recommendation: `backend/events/cmd/genevents/main.go` — keeps it close to the source of truth. The `//go:generate` directive on events.go runs it. + +3. **codegen-check hook — is it actually fixed?** + - What we know: `go generate ./...` now completes in <1 second in testing. Previous hanging was during Phase 2 (Feb 2026). + - What's unclear: Whether the fix was a templ version update, environment change, or something else. + - Recommendation: After wiring the event generator, test the full hook manually (`lefthook run pre-commit`) before declaring it fixed. If it still hangs, narrow the hook scope to only run event codegen check (not full `go generate ./...`). + +## Sources + +### Primary (HIGH confidence) +- sqlc v1.30.0 official docs — [select.html#mysql-and-sqlite](https://docs.sqlc.dev/en/stable/howto/select.html#mysql-and-sqlite) — `sqlc.slice()` syntax and generated code +- sqlc v1.30.0 official docs — [ddl.html](https://docs.sqlc.dev/en/stable/howto/ddl.html) — Schema handling including VIEWs +- Direct verification: `go tool sqlc generate` tested with VIEW + `sqlc.slice()` against project's sqlc v1.30.0 — both work correctly +- Go stdlib `go/ast`, `go/parser`, `go/token` documentation — standard library, stable API + +### Secondary (MEDIUM confidence) +- Codebase analysis: 10+ duplicate JOIN instances identified by grep across .go and .sql files +- lefthook.yml examination: `codegen-check` hook structure and `go generate ./...` command +- `go generate ./...` timing test: completes in <1s (2 templ + 1 sqlc generators, all no-op) + +### Tertiary (LOW confidence) +- None — all findings verified against primary sources or direct testing + +## Metadata + +**Confidence breakdown:** +- Standard stack: HIGH — sqlc v1.30.0 verified directly; go/ast is stable stdlib +- Architecture: HIGH — VIEW + sqlc.slice() both tested against project toolchain +- Pitfalls: HIGH — migration ordering verified by reading database.go; JOIN semantics verified by reading query files + +**Research date:** 2026-03-04 +**Valid until:** 2026-04-04 (stable tools, no fast-moving dependencies)