Files
yellowjacket/.planning/phases/06-sql-consolidation-code-quality/06-RESEARCH.md
T

30 KiB

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>

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 </user_constraints>

<phase_requirements>

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
</phase_requirements>

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:

-- 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:

-- 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:

-- 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:

-- 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:

// 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

-- 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

// 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

// 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

-- 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

// 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

// 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

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-sqlitesqlc.slice() syntax and generated code
  • sqlc v1.30.0 official docs — 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)