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

28 KiB

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:

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

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:

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

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:

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.

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:

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

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.

The fix is surgical — the infrastructure is already there:

Step 1: Remove eagerFetch() from constructor. Change the constructor to only set up event listeners:

constructor() {
    EventsOn(Events.LibraryScanComplete, () => {
        this.invalidate();
    });
    this.loadCoverSize();
    // Remove: this.eagerFetch();
}

Step 2: Make invalidate() only clear caches, not re-fetch:

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.

Replace the single persistTracks() call with operation-specific methods:

For AddTrack/AddTracks: INSERT only the new tracks.

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.

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:

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

Create a VIEW that encapsulates the common JOIN pattern:

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

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

Core test helper — testdb package:

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

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

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

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

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

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

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.tsHIGH confidence (direct code reading)
  • sqlc sqlc.slice() for SQLite: docs.sqlc.dev/en/stable/howto/select.htmlHIGH confidence (official documentation, verified)
  • sqlc batch operations (:batchexec etc.) are PostgreSQL-only: docs.sqlc.dev/en/stable/reference/query-annotations.htmlHIGH 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