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