Files
yellowjacket/.planning/research/ARCHITECTURE.md
T

755 lines
28 KiB
Markdown

# Architecture Research: Refactoring Patterns for YellowJacket Consolidation
**Domain:** Go/Wails/Lit desktop music player — codebase consolidation
**Researched:** 2026-02-27
**Confidence:** HIGH (patterns derived from codebase analysis + Go stdlib + official sqlc docs)
## Issue 1: Two-Phase Initialization Race Conditions
### Current Problem
Six components use a `SetContext(ctx context.Context)` pattern where the Wails runtime context is stored on a struct field without synchronization:
```go
// queue/queue.go:134 — no lock
func (q *Queue) SetContext(ctx context.Context) {
q.ctx = ctx
}
// library/library.go:120 — no lock, also calls registerEventHandlers()
func (l *Library) SetContext(ctx context.Context) {
l.ctx = ctx
l.registerEventHandlers()
}
// player/player.go:163 — double lock/unlock
func (p *Player) SetContext(ctx context.Context) {
p.mu.Lock()
p.ctx = ctx
p.mu.Unlock()
p.mu.Lock()
p.restoreStateLocked()
p.mu.Unlock()
}
```
The race is technically real: `q.ctx` is written without `q.mu` but read inside methods that hold `q.mu`. Go's race detector would flag this. In practice it's safe because `SetContext` is called once during sequential startup in `OnStartup()`, before any concurrent access is possible.
### Recommended Approach: Mutex-Guarded SetContext
**Do NOT use `sync.Once` or `atomic.Value`.** These are the wrong tools because:
- `sync.Once` is for "do this exactly once" initialization. `SetContext` doesn't need that — it needs "set this value safely." `sync.Once` would prevent re-setting if the context ever changed (unlikely but architecturally constraining).
- `atomic.Value` requires boxing `context.Context` into an `any`, adds `.Load().(context.Context)` type assertions everywhere the context is read, and makes code harder to follow for no real benefit.
**Instead, hold the existing mutex through the entire SetContext operation:**
```go
// queue/queue.go — recommended fix
func (q *Queue) SetContext(ctx context.Context) {
q.mu.Lock()
defer q.mu.Unlock()
q.ctx = ctx
}
// player/player.go — combine the two lock acquisitions
func (p *Player) SetContext(ctx context.Context) {
p.mu.Lock()
defer p.mu.Unlock()
p.ctx = ctx
p.restoreStateLocked()
}
```
For **Library** and **Playlist**, which don't have a mutex because they currently have no concurrent access pattern, add one:
```go
type Library struct {
mu sync.Mutex // protects ctx and conf
ctx context.Context
// ... rest unchanged
}
func (l *Library) SetContext(ctx context.Context) {
l.mu.Lock()
defer l.mu.Unlock()
l.ctx = ctx
l.registerEventHandlers()
}
```
**For `SetPlayer()` and `SetRescanHooks()`:** These are also startup-only setters. The simplest correct fix is to guard them with the same mutex. Alternatively, document a "must be called before first use" contract with a comment. The mutex approach is preferred because it eliminates the race detector complaint without requiring callers to understand ordering constraints.
### `startupErr` Package-Level Variable
Move to a field on `YellowJacketApp`:
```go
type YellowJacketApp struct {
// ... existing fields ...
startupErr error // set in OnStartup, checked in OnDomReady
}
```
This is safe because Wails guarantees `OnStartup` completes before `OnDomReady` runs — they are sequentially called lifecycle hooks, not concurrent.
### Risk Assessment
| Change | Risk | Rationale |
|--------|------|-----------|
| Add mutex to Queue/Config SetContext | **Low** | Mechanical — add lock/unlock, no logic change |
| Combine Player double-lock | **Low** | Reducing lock operations, equivalent behavior |
| Add mutex to Library/Playlist | **Low** | New mutex, but only guards startup path |
| Move startupErr to struct | **Very Low** | Field move, identical semantics |
### Dependencies
None — this can be done at any time and is a prerequisite for safe testing of these packages.
---
## Issue 2: Event Name Synchronization
### Current Problem
`backend/events/events.go` defines 19 event name constants. `frontend/src/events.ts` mirrors them as an `as const` object. A typo in either file silently breaks communication with no compile-time or runtime detection.
The TypeScript file is missing `LibraryConfigChanged` from the Go side (it's in the Config events group in Go but absent from the TS events). This is exactly the class of bug this pattern creates.
### Recommended Approach: Build-Time Code Generation
**Generate the TypeScript file from the Go source as part of the build.**
Create a `cmd/genevents/main.go` that parses `backend/events/events.go` using `go/ast` and generates `frontend/src/events.ts`:
```go
// cmd/genevents/main.go
package main
import (
"go/ast"
"go/parser"
"go/token"
"os"
"text/template"
)
const tmpl = `// Code generated by cmd/genevents. DO NOT EDIT.
export const Events = {
{{- range .}}
{{.Name}}: "{{.Value}}",
{{- end}}
} as const;
export type EventName = (typeof Events)[keyof typeof Events];
`
func main() {
fset := token.NewFileSet()
f, _ := parser.ParseFile(fset, "backend/events/events.go", nil, 0)
var events []struct{ Name, Value string }
ast.Inspect(f, func(n ast.Node) bool {
vs, ok := n.(*ast.ValueSpec)
if !ok || len(vs.Names) == 0 || len(vs.Values) == 0 {
return true
}
bl, ok := vs.Values[0].(*ast.BasicLit)
if !ok {
return true
}
name := vs.Names[0].Name
value := bl.Value[1 : len(bl.Value)-1] // strip quotes
events = append(events, struct{ Name, Value string }{name, value})
return true
})
t := template.Must(template.New("").Parse(tmpl))
out, _ := os.Create("frontend/src/events.ts")
defer out.Close()
t.Execute(out, events)
}
```
Wire into the existing `go generate ./...` pipeline via a directive in `events.go`:
```go
//go:generate go run ../../cmd/genevents/main.go
package events
```
**Why not a shared JSON/YAML schema?** It adds a third file and a parsing step for both sides. Go's AST parsing is trivial and keeps the Go file as the single source of truth.
**Why not runtime validation?** It would only catch mismatches when the specific event fires, and by then the damage is done. Build-time generation prevents mismatches entirely.
**Build verification step:** Add a `make` target or pre-commit hook check:
```makefile
check-events:
go generate ./backend/events/...
git diff --exit-code frontend/src/events.ts || (echo "events.ts is out of date" && exit 1)
```
### Risk Assessment
| Change | Risk | Rationale |
|--------|------|-----------|
| Code generator | **Low** | Additive — doesn't change existing code behavior |
| Build integration | **Very Low** | Existing `go generate` pipeline |
| Pre-commit check | **Very Low** | Fails fast if someone edits Go constants without regenerating |
### Dependencies
None — independent of all other changes.
---
## Issue 3: Store Architecture for Large Datasets
### Current Problem
`LibraryStore` eagerly calls `GetAllTracks()`, `GetAllAlbums()`, `GetAllArtists()`, `GetAllGenres()` on construction (line 300-304). For a 50k+ track library, this loads all data into the webview's JS heap at startup.
The store already has correct lazy-load infrastructure (check `tracks !== null`, loading flags, `waitFor*` methods). The problem is that `eagerFetch()` bypasses all of it by calling all four getters immediately.
### Recommended Approach: Lazy Loading by Active View
The fix is surgical — the infrastructure is already there:
**Step 1: Remove `eagerFetch()` from constructor.** Change the constructor to only set up event listeners:
```typescript
constructor() {
EventsOn(Events.LibraryScanComplete, () => {
this.invalidate();
});
this.loadCoverSize();
// Remove: this.eagerFetch();
}
```
**Step 2: Make `invalidate()` only clear caches, not re-fetch:**
```typescript
private invalidate(): void {
this.tracks = null;
this.albums = null;
this.artists = null;
this.genres = null;
this.scrollPositions = { tracks: 0, albums: 0, artists: 0, genres: 0 };
this.notify();
// Remove: this.eagerFetch();
}
```
Data will be fetched on-demand when a view's controller calls `getTracks()`, `getAlbums()`, etc. The existing null-check + loading-flag + waitFor pattern handles concurrent access correctly.
**Step 3: Prefetch the initial view data only.** If the app opens to the tracks view by default, the tracks controller will trigger `getTracks()` on its first render. This is already what happens — the eager fetch just front-loads all four queries unnecessarily.
**Step 4 (optional, for 100k+ libraries): Implement paginated data providers.** This is a larger change and should only be pursued if lazy loading alone doesn't solve perceived startup lag. The approach:
- Backend: Add `GetTracksPage(offset, limit int)` and `GetTrackCount()` queries to sqlc
- Frontend: Replace `library.Track[]` with a `DataProvider` interface that the virtual scroller queries by range
- The existing virtual scrolling components (`track-list`, `cover-grid`) already render only visible rows — they just hold the full dataset backing array
**Recommendation:** Start with Steps 1-3 (remove eager fetch). Measure. Only build Step 4 if data shows the full `GetAllTracks()` call is still a problem for the initial view. For 50k tracks, a single indexed query returning rows is fast (~100ms on SSD); the bigger cost is JSON serialization across the Wails bridge, which lazy loading solves by deferring non-active-view data.
### Risk Assessment
| Change | Risk | Rationale |
|--------|------|-----------|
| Remove eagerFetch | **Low** | Lazy infrastructure already exists and is tested by the `getTracks()` pattern |
| Invalidate without re-fetch | **Low** | Controllers already call getters on update |
| Paginated data providers | **Medium** | Requires backend + frontend + virtual scroller changes |
### Dependencies
- Independent of backend changes.
- If paginated data providers are needed, requires new sqlc queries (connects to Issue 5).
---
## Issue 4: Queue Persistence — Incremental Updates
### Current Problem
`commitMutation()``persistTracks()` does `DELETE FROM queue_tracks` + batch INSERT for the entire queue on every single mutation (add, remove, move, clear). For a 5000-track queue, every track add triggers a full table rewrite: ~5000 DELETEs + ~5000 INSERTs.
The sqlc queries already define `InsertQueueTrack`, `RemoveQueueTrack`, `RemoveQueueTrackByPosition`, `ShiftQueuePositionsDown`, and `ShiftQueuePositionsUp` — but none of them are used. The persistence layer bypasses sqlc entirely with hand-crafted batch SQL.
### Recommended Approach: Operation-Specific Persistence
Replace the single `persistTracks()` call with operation-specific methods:
**For AddTrack/AddTracks:** INSERT only the new tracks.
```go
func (q *Queue) persistAddTracks(tracks []Track) {
for _, t := range tracks {
_, err := q.db.Queries.InsertQueueTrack(q.db.Ctx, sqlcgen.InsertQueueTrackParams{
AudioFileID: t.AudioFileID,
Position: t.Position,
})
if err != nil {
q.logger.Error("Failed to persist added track", "err", err)
}
}
}
```
**For RemoveTrack/RemoveTracks:** DELETE specific rows + shift positions.
```go
func (q *Queue) persistRemoveTracks(positions []int) {
tx, err := q.db.BeginTx()
if err != nil { return }
txQ := q.db.Queries.WithTx(tx)
// Remove in descending order to avoid position shifts during removal
slices.SortFunc(positions, func(a, b int) int { return b - a })
for _, pos := range positions {
txQ.RemoveQueueTrackByPosition(q.db.Ctx, int64(pos))
txQ.ShiftQueuePositionsDown(q.db.Ctx, int64(pos))
}
tx.Commit()
}
```
**For MoveQueueTracks/InsertNextTracks:** These reorder arbitrary ranges. Use DELETE + INSERT for the affected range only, or fall back to full rewrite when >50% of tracks are affected.
**For SetQueue and Clear:** Keep the existing DELETE ALL + batch INSERT — these are full replacement operations by definition.
**Refactored `commitMutation`:**
```go
type mutationKind int
const (
mutationFull mutationKind = iota // SetQueue, Clear
mutationAdd // AddTrack, AddTracks
mutationRemove // RemoveTrack, RemoveTracks
mutationReorder // MoveQueueTracks, InsertNext*
)
func (q *Queue) commitMutation(kind mutationKind, affectedTracks []Track, affectedPositions []int) {
if q.shuffleMode {
q.generateShuffleOrder()
}
switch kind {
case mutationAdd:
q.persistAddTracks(affectedTracks)
case mutationRemove:
q.persistRemoveTracks(affectedPositions)
case mutationReorder, mutationFull:
q.persistTracks() // full rewrite for complex operations
}
q.persistState()
}
```
**Performance impact:** For the common case (user adds a track to a 5000-track queue), this goes from ~10,000 SQL operations to 1 INSERT + 1 UPDATE. The full rewrite is reserved for SetQueue (infrequent) and complex reorders.
### Risk Assessment
| Change | Risk | Rationale |
|--------|------|-----------|
| Incremental add persistence | **Low** | Uses existing sqlc queries already defined |
| Incremental remove persistence | **Low** | Uses existing sqlc queries + transaction |
| Full rewrite for reorder | **Very Low** | Keeps current behavior for complex cases |
| commitMutation refactor | **Medium** | Changes call signatures throughout queue.go |
### Dependencies
- **Should come after Issue 1** (SetContext fixes) so tests can verify persistence correctness.
- **Should come after Issue 6** (test architecture) because persistence changes need test coverage to verify correctness.
---
## Issue 5: SQL Query Consolidation — FTS5 JOIN Pattern
### Current Problem
The same JOIN pattern (audio_files → recordings → artist_credit → release_group_recordings → release_groups) appears in:
1. `SearchFTS()` — search.go:34-57
2. `SearchFTSByFilename()` — search.go:92-116
3. `SearchFTSTracks()` — search.go:232-274
4. `RebuildSearchIndex()` — search.go:168-188
5. `migration2BasenameAndFTS()` — database.go:287-311
Plus a simpler variant in `lookupChunk()` (persistence.go:64-73).
### Recommended Approach: SQLite VIEW + sqlc Queries
**Create a VIEW that encapsulates the common JOIN pattern:**
```sql
-- sql/schemas/31_views.sql
CREATE VIEW IF NOT EXISTS track_metadata AS
SELECT
af.id AS audio_file_id,
af.file_path,
af.length_milliseconds,
af.basename,
af.sample_rate,
af.bit_depth,
af.channels,
af.bitrate,
af.file_size,
af.file_type_id,
af.recording_id,
COALESCE(r.name, '') AS title,
COALESCE(ac.text, '') AS artist_name,
r.track_number,
r.disc_number,
COALESCE(r.year, 0) AS year,
COALESCE(r.composer, '') AS composer,
COALESCE(rg.name, '') AS album,
r.artist_credit_id,
r.id AS recording_row_id
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;
```
**Then use the VIEW in sqlc queries:**
```sql
-- sql/queries/search.sql
-- name: SearchFTS :many
SELECT tm.file_path, tm.length_milliseconds, tm.title, tm.artist_name, tm.album
FROM search_index si
JOIN track_metadata tm ON tm.audio_file_id = si.rowid
WHERE search_index MATCH ?
ORDER BY rank
LIMIT ?;
-- name: SearchFTSByFilename :many
SELECT tm.file_path, tm.length_milliseconds, tm.title, tm.artist_name, tm.album
FROM search_index si
JOIN track_metadata tm ON tm.audio_file_id = si.rowid
WHERE search_index MATCH ?
ORDER BY rank
LIMIT ?;
-- name: RebuildSearchIndex :exec
INSERT INTO search_index(rowid, file_path, title, artist, album)
SELECT audio_file_id, file_path, title, artist_name, album
FROM track_metadata;
```
**Why a VIEW and not a Go constant/query builder?**
- sqlc can parse VIEWs and generate type-safe Go code from queries against them.
- The JOIN is executed by SQLite's query planner, which optimizes VIEW queries the same as inline JOINs.
- It eliminates all 5 copies of the JOIN at the SQL level, not just the Go level.
- A Go string constant containing the JOIN clause would still require hand-crafted SQL around it, defeating sqlc's type safety.
**For `lookupChunk` in queue persistence:** This uses `sqlc.slice()` — migrate to:
```sql
-- name: LookupTrackMetaBatch :many
SELECT af.id, af.file_path,
COALESCE(r.name, '') AS title,
COALESCE(ac.text, '') AS artist
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
WHERE af.file_path IN (sqlc.slice('filePaths'));
```
This replaces the hand-crafted `fmt.Sprintf` batch query with sqlc-generated code that handles the dynamic IN clause expansion. Confirmed: sqlc `sqlc.slice()` is supported for MySQL and SQLite (verified in official docs at `docs.sqlc.dev/en/stable/howto/select.html`).
**For `SearchFTSTracks` (the 16-column variant):** This query has additional columns (genre via subquery, file_type). Extend the VIEW or create a second wider VIEW `track_metadata_full` that includes genre and file_type JOINs.
**Migration note:** The `migration2BasenameAndFTS` function uses the JOIN inline in a migration. Migrations should NOT reference VIEWs because the VIEW might not exist yet when the migration runs. Keep the inline JOIN in migrations — they run once and don't need deduplication.
### Risk Assessment
| Change | Risk | Rationale |
|--------|------|-----------|
| CREATE VIEW | **Low** | SQLite VIEWs are well-supported, IF NOT EXISTS is safe |
| Migrate search to sqlc | **Medium** | Changing hand-crafted SQL to generated code requires careful testing |
| sqlc.slice for batch lookups | **Medium** | Different code generation pattern, needs verification |
| Keep inline JOIN in migrations | **Very Low** | No change to migration code |
### Dependencies
- **Should come after Issue 6** (test architecture) so search behavior can be regression-tested.
- Independent of Issues 1-4.
---
## Issue 6: Test Architecture for DB-Dependent Packages
### Current Problem
No tests exist for queue, library, database, or config packages. Existing tests (`playlist/match_test.go`, `metadata/*_test.go`) test pure functions that don't require DB or OS dependencies. The player test requires hardware and is skipped in CI.
### Recommended Approach: In-Memory SQLite + Test Helpers
**Core test helper — `testdb` package:**
```go
// internal/testdb/testdb.go
package testdb
import (
"testing"
"yellowjacket/backend/database"
)
// New creates a fresh in-memory database with all schemas applied.
// The database is automatically closed when the test completes.
func New(t *testing.T) *database.DB {
t.Helper()
db, err := database.NewTestDB()
if err != nil {
t.Fatalf("failed to create test database: %v", err)
}
t.Cleanup(func() {
db.Close()
})
return db
}
```
**Modify `database.NewDB` to support in-memory mode:**
```go
// database/database.go
// NewTestDB creates an in-memory database for testing.
// It applies all schemas and migrations identically to NewDB.
func NewTestDB() (*DB, error) {
return newDB(":memory:")
}
// Extract common init logic into newDB(dsn string)
func newDB(dsn string) (*DB, error) {
dbCtx := context.Background()
db, err := sql.Open("sqlite", dsn+"?_busy_timeout=5000&_journal_mode=WAL")
// ... rest of current NewDB logic
}
```
The `modernc.org/sqlite` driver fully supports `:memory:` databases. Each test gets an isolated database — no cleanup needed, no file I/O, no disk contention.
**Test pattern for Queue:**
```go
// queue/queue_test.go
package queue_test
import (
"context"
"testing"
"log/slog"
"yellowjacket/backend/queue"
"yellowjacket/internal/testdb"
)
// mockPlayer implements queue.TrackLoader for tests
type mockPlayer struct {
loaded string
playing bool
position int
}
func (m *mockPlayer) LoadFile(path string) error { m.loaded = path; return nil }
func (m *mockPlayer) Play() error { m.playing = true; return nil }
func (m *mockPlayer) IsPlaying() bool { return m.playing }
func (m *mockPlayer) CurrentPositionSeconds() (int, error) { return m.position, nil }
func (m *mockPlayer) UnloadTrack() { m.loaded = ""; m.playing = false }
func TestSetQueueAndNavigate(t *testing.T) {
db := testdb.New(t)
// Seed test tracks
seedTracks(t, db, 10)
q := queue.NewQueue(slog.Default(), db)
q.SetContext(context.Background()) // no Wails runtime needed for tests
q.SetPlayer(&mockPlayer{})
paths := getTestTrackPaths(t, db)
q.SetQueue(paths, 0, false)
state := q.GetState()
if state.CurrentIndex != 0 { t.Errorf("expected index 0, got %d", state.CurrentIndex) }
if len(state.Tracks) != 10 { t.Errorf("expected 10 tracks, got %d", len(state.Tracks)) }
}
```
**Key insight: `context.Background()` works for SetContext in tests.** The Wails context is only needed for `runtime.EventsEmit()` and `runtime.EventsOn()`. In tests, these calls will simply no-op (emit to nobody, subscribe to nobody). Queue logic doesn't depend on event delivery — it just fires and forgets. If a test needs to verify events were emitted, introduce an `EventEmitter` interface later.
**Test pattern for Config:**
```go
// config/config_test.go
func TestLoadSaveRoundtrip(t *testing.T) {
dir := t.TempDir()
// Write a known TOML file
// Load it
// Verify fields
// Save it
// Load again
// Verify identical
}
```
Config tests don't need a database — they need a temp directory for the TOML file. Use `t.TempDir()`.
**Test pattern for Database/Search:**
```go
func TestSearchFTS(t *testing.T) {
db := testdb.New(t)
seedTracksWithMetadata(t, db)
results, err := db.SearchFTS("beethoven", 10)
if err != nil { t.Fatal(err) }
if len(results) != 1 { t.Errorf("expected 1 result, got %d", len(results)) }
}
```
**Test pattern for Player (pure logic extraction):**
```go
// player/volume_test.go — no hardware needed
func TestUserVolumeToInternal(t *testing.T) {
tests := []struct{ user UserVolume; expected float64 }{
{0, -5.0},
{50, -2.5},
{100, 0.0},
}
for _, tt := range tests {
got := tt.user.toInternal()
if math.Abs(got - tt.expected) > 0.01 {
t.Errorf("UserVolume(%d).toInternal() = %f, want %f", tt.user, got, tt.expected)
}
}
}
```
### Mocking Strategy
**Use real in-memory SQLite, not mocked interfaces.** Reasons:
1. The `modernc.org/sqlite` driver is pure Go — no CGo, no external deps, fast in-memory mode
2. Mocking the DB interface would require mocking `*sqlcgen.Queries` (dozens of methods) — fragile and doesn't test real query behavior
3. SQLite in-memory is effectively instant — no performance reason to mock
4. Tests that exercise real SQL catch bugs that mock tests miss (FTS5 tokenization, JOIN correctness, migration logic)
**Mock only at narrow interfaces:**
- `TrackLoader` for queue tests (already an interface)
- File system for library scan tests (use `testing/fstest.MapFS` or a temp directory with test audio files)
- Wails runtime can be a no-op `context.Background()` — events fire into the void
### Risk Assessment
| Change | Risk | Rationale |
|--------|------|-----------|
| `NewTestDB()` function | **Very Low** | Extracts existing logic, adds `:memory:` path |
| `internal/testdb` helper | **Very Low** | New test-only package |
| Queue tests with mock player | **Low** | Tests new code, doesn't change production code |
| Config tests with TempDir | **Very Low** | Isolated, no production code changes |
| Player pure logic extraction | **Low** | Moving existing code to new functions |
### Dependencies
- `NewTestDB()` in database package must be created first — all other test packages depend on it.
- **This is the foundation for safe refactoring** — should be one of the first things built.
---
## Recommended Build Order
Based on dependency analysis and risk:
```
Phase 1: Foundation (no dependencies, enables everything else)
├── 1a. Test architecture (Issue 6) — NewTestDB, testdb helper
├── 1b. Event code generation (Issue 2) — independent, low risk
└── 1c. SetContext mutex fixes (Issue 1) — independent, low risk
Phase 2: Safety Net (requires Phase 1a)
├── 2a. Queue unit tests — using testdb + mock player
├── 2b. Database/search tests — using testdb
└── 2c. Config tests — using TempDir
Phase 3: Refactoring (requires Phase 2 tests as safety net)
├── 3a. SQL VIEW + sqlc migration (Issue 5) — search tests verify no regression
├── 3b. Queue incremental persistence (Issue 4) — queue tests verify no regression
└── 3c. Library store lazy loading (Issue 3) — frontend change, lower risk
Phase 4: Extended Tests
├── 4a. Library scan tests — complex, last because scan code may change during Phase 3
└── 4b. Player pure logic tests — independent extraction
```
### Phase Ordering Rationale
1. **Tests before refactoring** because the consolidation milestone's entire purpose is safe improvement. Refactoring without tests in a codebase with known concurrency issues is high-risk.
2. **SetContext fixes (1c) before queue tests (2a)** because the race conditions in SetContext would cause flaky test failures under `-race`.
3. **SQL VIEW (3a) before queue persistence (3b)** because the VIEW changes the database schema that queue queries depend on. Do schema changes first, then change query patterns.
4. **Frontend lazy loading (3c) last in Phase 3** because it's the lowest-risk change (removing code, not adding it) and is independent of backend refactoring.
---
## Anti-Patterns to Avoid
### Anti-Pattern 1: Interface-Heavy Mocking
**What people do:** Create interfaces for everything (`DatabaseInterface`, `ConfigInterface`) to enable mock-based testing.
**Why it's wrong for this codebase:** SQLite in-memory is as fast as a mock and tests real behavior. Interface proliferation adds complexity without catching real SQL bugs.
**Do this instead:** Use real in-memory SQLite for DB tests. Only create interfaces at natural boundaries (like `TrackLoader`, which already exists).
### Anti-Pattern 2: Premature Abstraction of Persistence
**What people do:** Build a generic "repository pattern" or ORM-like layer to abstract all SQL.
**Why it's wrong for this codebase:** sqlc already provides type-safe generated code. Adding another abstraction layer on top of sqlc defeats its purpose.
**Do this instead:** Use sqlc queries directly. Use VIEWs for complex JOINs. Hand-craft SQL only for dynamic batch operations where sqlc can't help.
### Anti-Pattern 3: Global Event Bus Replacement
**What people do:** Replace Wails events with a custom pub/sub system to enable testing.
**Why it's wrong for this codebase:** The Wails event system is deeply integrated and works well. The real problem (event name parity) is solved by code generation, not by replacing the event system.
**Do this instead:** Use `context.Background()` in tests (events no-op). Add code generation for event names. If event verification is needed later, wrap `runtime.EventsEmit` in a thin injectable function.
---
## Sources
- Codebase analysis: `backend/queue/queue.go`, `backend/queue/persistence.go`, `backend/player/player.go`, `backend/library/library.go`, `backend/config/config.go`, `backend/database/search.go`, `backend/database/database.go`, `backend/events/events.go`, `frontend/src/events.ts`, `frontend/src/store/library-store.ts`**HIGH confidence** (direct code reading)
- sqlc `sqlc.slice()` for SQLite: `docs.sqlc.dev/en/stable/howto/select.html`**HIGH confidence** (official documentation, verified)
- sqlc batch operations (`:batchexec` etc.) are PostgreSQL-only: `docs.sqlc.dev/en/stable/reference/query-annotations.html`**HIGH confidence** (official documentation, verified)
- sqlc VIEW support: sqlc parses `CREATE VIEW` in schema files — **MEDIUM confidence** (documented for PostgreSQL; SQLite support inferred from general DDL handling, needs validation)
- `modernc.org/sqlite` `:memory:` support: standard `database/sql` behavior — **HIGH confidence** (Go stdlib)
- Go `sync.Mutex` patterns: Go stdlib documentation — **HIGH confidence**
- Go `go/ast` for code generation: Go stdlib — **HIGH confidence**
---
*Architecture research for: YellowJacket consolidation milestone*
*Researched: 2026-02-27*