Skip to content

Database Design

This document is based on the following source files:

  • internal/database/database.go -- DB interface definition
  • internal/database/sqlite.go -- SQLiteDB implementation, connection pool, goose migrations
  • internal/database/unit_of_work.go -- UnitOfWork transaction pattern
  • internal/database/errors.go -- unified error sentinels
  • internal/database/filters.go -- dynamic filtering and sort allowlists
  • internal/database/song_repository.go -- song repository (the largest Repository)
  • internal/database/playlist_repository.go -- playlist repository
  • internal/database/playlist_song_repository.go -- playlist-song junction repository
  • internal/database/config_repository.go -- config item repository
  • internal/database/token_repository.go -- auth token repository
  • internal/database/jsplugin_repository.go -- JS plugin repository
  • internal/database/testutil/memdb.go -- in-memory database test utility
  • internal/database/migrations/0001_init.sql -- complete initial schema (6 tables + indexes + triggers + seed data)
  • internal/database/migrations/0002_*.sql ~ 0025_*.sql -- incremental migrations (including 0016 which adds the plugin_storage table)
  • internal/database/queries/*.sql -- sqlc query definitions (7 files)
  • sqlc.yaml -- sqlc code generation configuration

Table of Contents

  1. Introduction
  2. Database Architecture Overview
  3. ER Diagram
  4. Table Structure Details
  5. Index and Constraint Design
  6. Migration System
  7. sqlc Code Generation
  8. Repository Pattern
  9. UnitOfWork Transaction Pattern
  10. Dynamic SQL and Filtering System
  11. Testing Strategy
  12. Design Decisions and Conventions

1. Introduction

Songloft's data layer is built on SQLite, using WAL (Write-Ahead Logging) mode to achieve read/write concurrency. The entire data access stack consists of four layers: goose embedded migrations manage schema evolution, sqlc generates type-safe Go code from annotated SQL to handle fixed queries, squirrel builds dynamic WHERE/ORDER clauses, and Repository + UnitOfWork encapsulate business-level data operations and cross-table transactions.

The database driver is modernc.org/sqlite (a pure Go implementation), which requires no CGO, supports static compilation with CGO_ENABLED=0, and fits minimal images such as Docker scratch/alpine.

Section sources: internal/database/sqlite.go (Open function, DSN parameters), AGENTS.md (database conventions)


2. Database Architecture Overview

The system contains 7 business tables, forming a data model centered on songs and playlists.

Connection configuration (sqlite.go Open function):

ParameterValueDescription
journal_modeWALRead/write concurrency; reads are not blocked by writes
busy_timeout10000msWaits up to 10 seconds on a lock, avoiding SQLITE_BUSY
synchronousNORMALSufficiently safe under WAL mode
cache_size10000 pages (~40MB)Page cache
foreign_keys1Enables foreign key constraints (CASCADE depends on this)

Go connection pool: MaxOpenConns=10, MaxIdleConns=5, ConnMaxLifetime=30min.

Section sources: internal/database/sqlite.go:28-55


3. ER Diagram

Diagram sources: internal/database/migrations/0001_init.sql, 0007_*.sql ~ 0025_*.sql (plugin_storage see 0016_plugin_storage.sql)


4. Table Structure Details

4.1 songs -- Songs Table

Stores metadata for three song types -- local, remote, and radio -- and is the largest table in the system (37 columns).

FieldTypeDescription
idINTEGER PKAuto-increment primary key
typeTEXT, CHECKlocal / remote / radio
title / artist / albumTEXTBasic metadata
durationREALDuration (seconds)
file_pathTEXTLocal file path (local only)
urlTEXTRemote/radio URL
cover_path / cover_urlTEXTLocal cover art path / remote URL
lyricTEXTLyrics JSON (LyricPayload)
lyric_sourceTEXT, CHECKfile / embedded / scraped / url / cached / manual
lyric_remote_urlTEXTRemote lyrics URL (migration 0003)
file_size / format / bit_rate / sample_ratemixedAudio technical parameters
is_liveINTEGERWhether it is a live stream (0/1)
plugin_entry_pathTEXTSource plugin identifier
source_dataTEXTPlugin-specific source data
dedup_keyTEXTDedup key (unique within a plugin)
year / genreINTEGER / TEXTYear / genre (migration 0007)
fingerprint / fingerprint_durationTEXT / REALAudio fingerprint (migration 0008)
isrcTEXTInternational Standard Recording Code (migration 0009)
cache_pathTEXTLocal cache path for remote songs (migration 0014)
cue_source_path / cue_track_index / cue_audio_pathTEXT / INTEGER / TEXTCUE track split source (migration 0018)
file_modified_atDATETIMEFile modification time, used for incremental scan detection (migration 0019)
trackTEXTTrack number (migration 0020)
language / styleTEXTLanguage / style (migration 0022)
is_videoINTEGERWhether it is a video (0/1, migration 0024)
added_at / updated_atDATETIMEInsertion / update time (maintained by triggers)

Key design: the (plugin_entry_path, dedup_key) composite unique index (a partial index, WHERE dedup_key != '') is used to deduplicate remote songs by plugin. UpsertRemote leverages this index to implement "update if exists, insert if not".

Section sources: internal/database/migrations/0001_init.sql, 0005_lyric_source_manual.sql (table rebuild), 0007~0024 incremental ALTER, internal/database/song_repository.go

4.2 playlists -- Playlists Table

FieldTypeDescription
idINTEGER PKAuto-increment primary key
typeTEXT, CHECKnormal / radio
nameTEXT, UNIQUEPlaylist name (globally unique)
descriptionTEXTDescription
cover_path / cover_urlTEXTCover art
labelsTEXTLabels JSON array (built_in / auto_created)
positionINTEGERSort position
created_at / updated_atDATETIMETimestamps

Seed data: id=1 "Favorites" (labels=["built_in"]), id=2 "Radio Favorites". labels is queried via json_each(), for example to skip built-in playlists during batch deletion.

Section sources: internal/database/migrations/0001_init.sql:30-42,163-166

4.3 playlist_songs -- Playlist-Song Junction Table

FieldTypeDescription
idINTEGER PKAuto-increment primary key
playlist_idINTEGER FK→ playlists(id) ON DELETE CASCADE
song_idINTEGER FK→ songs(id) ON DELETE CASCADE
positionINTEGERSort position of the song within the playlist
added_atDATETIMETime added

UNIQUE(playlist_id, song_id) prevents duplicates. Bidirectional CASCADE ensures automatic cleanup when a song/playlist is deleted.

4.4 configs -- Config Table

key TEXT UNIQUE + value TEXT (usually JSON) + updated_at (maintained by trigger). Writes use UPSERT: INSERT ... ON CONFLICT(key) DO UPDATE SET value = excluded.value.

9 seed configs: migration 0001 writes 8 (music_path, cover_storage_path, scan_config, ffprobe_path, jwt_secret (randomly generated), source_validation/source_fallback/source_metrics); migration 0006 adds plugin_registries.

4.5 auth_tokens -- Auth Token Table

Stores JWT access/refresh token records. token_id is UNIQUE, revoked_at is NULLABLE (NULL means not revoked). Revocation check: revoked_at IS NOT NULL OR expires_at < NOW. CleanExpired periodically purges expired records.

4.6 js_plugins -- JS Plugin Table

Stores metadata for installed plugins (23 columns). entry_path is UNIQUE and serves as the routing identifier. The three fields permissions, public_paths, and external_paths are JSON arrays. The status CHECK constraint allows active/inactive/error.

Section sources: internal/database/migrations/0001_init.sql, 0010_*.sql, 0011_*.sql, 0013_*.sql

4.7 plugin_storage -- Plugin Persistent KV Table

A key-value store that JS plugins read and write through the storage Bridge API (added in migration 0016). The combination of plugin_entry_path + key forms a UNIQUE constraint, implementing a per-plugin isolated namespace. value is TEXT (usually JSON), with created_at / updated_at timestamps. Queries are defined in internal/database/queries/plugin_storage.sql.

FieldTypeDescription
idINTEGER PKAuto-increment primary key
plugin_entry_pathTEXTPlugin identifier (namespace)
keyTEXTKey
valueTEXTValue (defaults to '', usually JSON)
created_at / updated_atDATETIMETimestamps

Section sources: internal/database/migrations/0016_plugin_storage.sql, internal/database/queries/plugin_storage.sql


5. Index and Constraint Design

Index nameTableColumnsTypeDescription
idx_songs_typesongstypeRegularFilter by type
idx_songs_titlesongstitleRegularTitle search
idx_songs_artistsongsartistRegularArtist search
idx_songs_added_atsongsadded_at DESCRegularDefault sort optimization
idx_songs_file_pathsongsfile_pathRegularFolder-prefix LIKE (migration 0004)
idx_songs_plugin_entry_pathsongsplugin_entry_pathPartialWHERE != ''
idx_songs_dedup_key_uniquesongs(plugin_entry_path, dedup_key)Unique + partialRemote song deduplication
idx_songs_fingerprintsongsfingerprintPartialDuplicate detection (migration 0008)
idx_playlists_name_uniqueplaylistsnameUniqueGlobally unique playlist name
idx_playlist_songs_positionplaylist_songs(playlist_id, position)RegularIn-playlist sorting
idx_auth_tokens_*auth_tokenstoken_id/type/expires/revokedRegularQueries across dimensions
idx_js_plugins_*js_pluginsstatus/entry_pathRegularStatus and path queries

The four core tables (songs/playlists/configs/js_plugins) each have an AFTER UPDATE trigger that automatically maintains updated_at = CURRENT_TIMESTAMP.

Section sources: internal/database/migrations/0001_init.sql:106-160


6. Migration System

Migration files are embedded into the Go binary via //go:embed migrations/*.sql; at startup goose.Up automatically runs all unapplied migrations, requiring no external tooling.

go
//go:embed migrations/*.sql
var migrationsFS embed.FS

func runMigrations(db *sql.DB) error {
    goose.SetBaseFS(migrationsFS)
    goose.SetDialect("sqlite3")
    return goose.Up(db, "migrations")
}

Migration File List (25 files)

NumberDescription
0001Initial schema: 6 tables + indexes + triggers + built-in playlists + default config
0002Add scan_auto_create_include_subdirs config item
0003Add lyric_remote_url column, migrate bare LRC to JSON
0004Add file_path index (folder-prefix queries)
0005Relax the lyric_source CHECK constraint to add manual (table rebuild)
0006Seed the official plugin registry URL
0007Add year, genre fields
0008Add fingerprint, fingerprint_duration + partial index
0009Add isrc field
0010js_plugins adds public_paths JSON array
0011js_plugins adds icon field
0012Add scan_auto_create_playlists config item
0013js_plugins adds external_paths JSON array
0014songs adds cache_path (local cache path for remote songs)
0015Add scan playlist mode config
0016Add plugin_storage table (plugin KV storage)
0017Fix cover storage path
0018songs adds CUE support (cue_source_path/cue_track_index/cue_audio_path)
0019songs adds file_modified_at
0020songs adds track (track number)
0021Scan support for .mov format
0022songs adds language, style
0023Scan support for .mp4 format
0024Video support: songs adds is_video
0025Add facet index

Notes

  • SQLite does not support ALTER TABLE ... MODIFY COLUMN or modifying a CHECK constraint, so migration 0005 uses a "table rebuild" strategy: CREATE TABLE songs_new → INSERT INTO ... SELECT → DROP TABLE songs → ALTER TABLE songs_new RENAME TO songs → rebuild all indexes and triggers.
  • Each SQL statement must be independently wrapped in -- +goose StatementBegin / -- +goose StatementEnd; otherwise the SQLite driver executes only the first prepared statement in a single Exec, and subsequent SQL is silently lost.
  • Directly running ALTER data/songloft.db is forbidden; all schema changes must go through migration files.
  • Migration file names follow the 000N_description.sql format, and goose executes them in numeric order.

Section sources: internal/database/sqlite.go:16,58-67, all files under internal/database/migrations/


7. sqlc Code Generation

sqlc.yaml is configured with engine=sqlite, reads SQL from queries/, infers the schema from migrations/, and generates code into internal/database/sqlc/. Key options: emit_interface: true generates a DBTX interface so that *sql.DB and *sql.Tx are interchangeable; emit_empty_slices: true returns []T instead of nil for empty results.

The 7 query files correspond to the 7 tables:

FileQuery countRepresentative queries
songs.sql30GetSongByID, CreateSong, UpdateRemoteSongMutable, FindSongByDedupKey, ListDuplicateFingerprints
playlists.sql13GetPlaylistByID, CreatePlaylist, FindPlaylistByName, InsertAutoCreatedPlaylist
playlist_songs.sql11AddSongToPlaylist, GetPlaylistSongs(Paginated), AddSongToPlaylistIgnore
configs.sql3GetConfig, SetConfig (UPSERT), DeleteConfig
tokens.sql5CreateToken, RevokeToken, IsTokenRevoked, CleanExpiredTokens
js_plugins.sql8ListJSPlugins, CreateJSPlugin, UpdateJSPluginStatus, UpdateJSPluginHashes
plugin_storage.sql7Plugin KV read/write (GetPluginStorage/SetPluginStorage, etc.)

sqlc annotation conventions: :one returns a single row, :many returns a slice, :exec executes only, :execrows returns the number of affected rows, :execlastid returns the last insert ID. After modifying queries you must run make sqlc and commit the generated artifacts.

Section sources: sqlc.yaml, internal/database/queries/*.sql


8. Repository Pattern

8.1 Architecture

Each Repository holds a sqlc.DBTX interface, delegates fixed queries to *sqlc.Queries, and uses squirrel for dynamic queries. It is obtained via SQLiteDB factory methods.

go
type SongRepository struct {
    db      sqlc.DBTX      // *sql.DB or *sql.Tx
    queries *sqlc.Queries  // sqlc-generated queries
}

8.2 SongRepository -- Song Repository

The largest repository, covering the full song lifecycle. Key methods:

MethodSQL approachDescription
GetByID / Create / Update / DeletesqlcBasic CRUD
UpdateLyrics / UpdateDuration / UpdateFingerprintsqlcPartial field updates
UpsertRemotesqlcDeduped write of remote songs by dedup_key
List / ListIDs / CountsquirrelDynamic filtering + sorting + pagination
BatchCreate / BatchDeletemixedBatch operations with automatic transaction management
ListLocalPathssqlcUsed for scan deduplication
ListDuplicateGroupssqlcFingerprint duplicate detection

8.3 PlaylistRepository -- Playlist Repository

MethodSQL approachDescription
Create / Updatesqlc (transaction)Includes duplicate-name conflict detection
List / CountsquirrelLEFT JOIN to count songs
AutoCreatemixed (transaction)Batch-generate playlists based on directory structure
BatchDeletemanual SQLSkips built_in playlists
BatchUpdatePositionssqlc (transaction)Batch update of sort order

8.4 PlaylistSongRepository -- Junction Repository

Manages the many-to-many association between playlists and songs.

MethodSQL approachDescription
AddSong / AddSongIgnoresqlcAdd a song; the latter silently skips if it already exists
AddSongsBatchsqlc (transaction)Batch append, automatically skipping existing entries, returning (added, skipped)
RemoveSongsqlcRemove a song
ReplaceSongsqlc (transaction)Replace an old song with a new one, preserving its position
GetSongs / GetSongsPaginatedsqlcFetch songs ordered by position
CountSongs / MaxPositionsqlcCount and position queries
BatchUpdatePositionssqlc (transaction)Rewrite all positions in the given order
ListPlaylistsContainingSongsqlcFind all playlists containing a given song (used by the conversion service)

8.5 ConfigRepository -- Config Repository

MethodSQL approachDescription
GetsqlcRead by key; returns ErrNotFound if not found
SetsqlcUPSERT write (INSERT ON CONFLICT DO UPDATE)
DeletesqlcDelete by key
List / CountsquirrelKeyword filtering + allowlisted sorting + pagination

8.6 TokenRepository -- Token Repository

MethodSQL approachDescription
CreatesqlcWrite a new token and back-fill the ID
GetByIDsqlcLook up by token_id
RevokesqlcMark as revoked (set revoked_at)
IsRevokedsqlcDetermine whether revoked or expired
ListActivesquirrelList active tokens (dynamic filtering + sorting)
CleanExpiredsqlcBatch-purge expired records

8.7 JSPluginRepository -- Plugin Repository

A pure sqlc repository with no dynamic SQL needs.

MethodSQL approachDescription
GetAll / GetByID / GetByEntryPathsqlcQueries (JSON fields auto-deserialized)
Create / UpdatesqlcFull-field operations (JSON fields serialized)
DeletesqlcDelete
UpdateStatussqlcUpdate the status field only
UpdateHashessqlcUpdate zip_hash/entry_hash/file_mod_time

8.8 Error Semantics

go
var ErrNotFound = errors.New("database: record not found")  // sql.ErrNoRows or RowsAffected()==0
var ErrConflict = errors.New("database: conflict")          // UNIQUE constraint violation

The Service layer uses errors.Is to distinguish: ErrNotFound → HTTP 404, ErrConflict → business semantics (such as ErrPlaylistNameConflict).

Section sources: internal/database/errors.go, the various *_repository.go


9. UnitOfWork Transaction Pattern

Under SQLite's single-writer architecture, cross-table writes must be completed within the same *sql.Tx. UnitOfWork binds the three write-heavy Repositories to the same transaction:

go
type UnitOfWork struct {
    Songs         *SongRepository
    Playlists     *PlaylistRepository
    PlaylistSongs *PlaylistSongRepository
}

SQLiteDB.RunInTx automatically manages the transaction lifecycle, rolling back when fn returns an error, and also rolling back and re-panicking on panic:

go
db.RunInTx(ctx, func(ctx context.Context, uow *database.UnitOfWork) error {
    if err := uow.Songs.Create(ctx, newSong); err != nil {
        return err
    }
    return uow.PlaylistSongs.ReplaceSong(ctx, playlistID, oldSongID, newSong.ID)
})

Some Repository methods also need a transaction internally (such as Create's duplicate-name check + INSERT). Smart nesting is implemented through the runInTx helper function: it starts a transaction itself when the underlying object is *sql.DB, and directly reuses it when the underlying object is already a *sql.Tx.

Section sources: internal/database/unit_of_work.go, internal/database/sqlite.go:80-102


10. Dynamic SQL and Filtering System

Filter Structs

A corresponding Filter is defined for each entity: SongFilter (Type/Keyword/PathPrefix/pagination/sorting), PlaylistFilter (Type/Labels/Keyword), ConfigFilter (Keyword), TokenFilter (TokenType/IsActive).

Sort Allowlist

To prevent SQL injection, sort fields must be within the allowlist, otherwise the query falls back to the default sort:

go
var songOrderWhitelist = map[string]struct{}{
    "id": {}, "title": {}, "artist": {}, "album": {},
    "duration": {}, "added_at": {}, "updated_at": {},
}

applyOrder handles sorting logic uniformly, supporting table-prefixed JOIN queries (such as "p." for the playlist LEFT JOIN). applyPagination handles LIMIT/OFFSET uniformly (limit<=0 means no pagination).

squirrel Build Flow

go
sb := songSelectBuilder()           // base SELECT
sb = applySongFilter(sb, filter)    // dynamic WHERE
sb = applyOrder(sb, ...)            // allowlisted sorting
sb = applyPagination(sb, ...)       // pagination
query, args, _ := sb.ToSql()        // generate SQL

PathPrefix filtering uses escapeLikeLiteral to escape wildcards plus an ESCAPE '\' clause. The playlist list uses a LEFT JOIN subquery to count songs.

Section sources: internal/database/filters.go, internal/database/song_repository.go:280-311,448-464


11. Testing Strategy

testutil.OpenMemoryDB provides a zero-configuration test database:

go
func OpenMemoryDB(t *testing.T) *database.SQLiteDB {
    db, err := database.Open(":memory:")  // automatically runs all goose migrations
    if err != nil { t.Fatalf(...) }
    t.Cleanup(func() { _ = db.Close() })
    return db
}
  • A real SQLite :memory:, not a mock; the schema is fully identical to production
  • Each test gets an independent instance, with no state pollution
  • Hand-written mock DBs are forbidden; all tests go through real Repositories
  • Row-count assertions must subtract the built-in data (2 playlists + 9 configs)

Section sources: internal/database/testutil/memdb.go, AGENTS.md


12. Design Decisions and Conventions

No ORM: fixed queries → sqlc compile-time type checking; dynamic queries → explicit squirrel building (string concatenation forbidden); cross-table writes → RunInTx + UnitOfWork.

JSON field storage: SQLite TEXT fields store JSON, queried via json_each()/json_extract(). The Go layer does json.Marshal on Create/Update and json.Unmarshal in RowToModel. Fields involved: playlists.labels, js_plugins.permissions/public_paths/external_paths, configs.value.

CASCADE deletion: the bidirectional CASCADE on playlist_songs is a core design -- deleting a song automatically cleans up that song's records in all playlists, and deleting a playlist automatically cleans up all associations under it. Repository method comments clearly note this dependency (for example, SongRepository.Delete is commented: "playlist_songs is automatically cleaned up by FK ON DELETE CASCADE"). Relies on the foreign_keys=1 PRAGMA being enabled.

The Service layer must not manipulate transactions directly: it must obtain a UnitOfWork through DB.RunInTx, and calling BeginTx on its own is forbidden, avoiding SQLITE_BUSY deadlocks under SQLite's single-writer model.

Cover art reference counting: cover art is stored shared by content hash; SongRepository.CountCoverPathReferences counts the total number of rows across both the songs and playlists tables that reference the same cover_path, and the caller must confirm the count is 0 before physically deleting the cover file.

Section sources: AGENTS.md (database conventions), the various Repository source files