feat(db): playlists schema for M7 #352 slice 1

Migration 0014 adds playlists + playlist_tracks. track_id is nullable
with ON DELETE SET NULL — tracks can be removed from the library
without silently dropping playlist entries; the denormalized snapshot
(title/artist/album/duration) keeps the row legible afterwards. UI
renders such rows greyed-out.

Indexes: playlists by (user_id, updated_at DESC) and a partial public
index for cross-user discovery; playlist_tracks partial index on
track_id to support the FK SET NULL lookup.

Queries provide CRUD + rollup recompute (track_count, duration_sec)
+ append/remove primitives. Reorder is service-layer orchestrated via
raw tx.Exec; no SQL primitive needed.
This commit is contained in:
2026-05-03 10:03:36 -04:00
parent e056c8b8be
commit 1226cb7583
5 changed files with 599 additions and 0 deletions
+24
View File
@@ -335,6 +335,30 @@ type PlaySession struct {
ClientID *string
}
type Playlist struct {
ID pgtype.UUID
UserID pgtype.UUID
Name string
Description string
IsPublic bool
CoverPath *string
TrackCount int32
DurationSec int32
CreatedAt pgtype.Timestamptz
UpdatedAt pgtype.Timestamptz
}
type PlaylistTrack struct {
PlaylistID pgtype.UUID
Position int32
TrackID pgtype.UUID
Title string
ArtistName string
AlbumTitle string
DurationSec int32
AddedAt pgtype.Timestamptz
}
type ScrobbleQueue struct {
ID pgtype.UUID
UserID pgtype.UUID
+424
View File
@@ -0,0 +1,424 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: playlists.sql
package dbq
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const appendPlaylistTrack = `-- name: AppendPlaylistTrack :one
INSERT INTO playlist_tracks (playlist_id, position, track_id, title, artist_name, album_title, duration_sec)
SELECT
$1::uuid,
COALESCE((SELECT MAX(position) + 1 FROM playlist_tracks WHERE playlist_id = $1::uuid), 0),
t.id,
t.title,
artists.name,
albums.title,
(t.duration_ms / 1000)::integer
FROM tracks t
JOIN albums ON albums.id = t.album_id
JOIN artists ON artists.id = t.artist_id
WHERE t.id = $2::uuid
RETURNING playlist_id, position, track_id, title, artist_name, album_title, duration_sec, added_at
`
type AppendPlaylistTrackParams struct {
PlaylistID pgtype.UUID
TrackID pgtype.UUID
}
// Inserts at the next available position. Snapshot fields are copied
// from the tracks/albums/artists join at insert time. tracks.duration_ms
// is converted to seconds for the snapshot.
func (q *Queries) AppendPlaylistTrack(ctx context.Context, arg AppendPlaylistTrackParams) (PlaylistTrack, error) {
row := q.db.QueryRow(ctx, appendPlaylistTrack, arg.PlaylistID, arg.TrackID)
var i PlaylistTrack
err := row.Scan(
&i.PlaylistID,
&i.Position,
&i.TrackID,
&i.Title,
&i.ArtistName,
&i.AlbumTitle,
&i.DurationSec,
&i.AddedAt,
)
return i, err
}
const createPlaylist = `-- name: CreatePlaylist :one
INSERT INTO playlists (user_id, name, description, is_public)
VALUES ($1, $2, $3, $4)
RETURNING id, user_id, name, description, is_public, cover_path, track_count, duration_sec, created_at, updated_at
`
type CreatePlaylistParams struct {
UserID pgtype.UUID
Name string
Description string
IsPublic bool
}
func (q *Queries) CreatePlaylist(ctx context.Context, arg CreatePlaylistParams) (Playlist, error) {
row := q.db.QueryRow(ctx, createPlaylist,
arg.UserID,
arg.Name,
arg.Description,
arg.IsPublic,
)
var i Playlist
err := row.Scan(
&i.ID,
&i.UserID,
&i.Name,
&i.Description,
&i.IsPublic,
&i.CoverPath,
&i.TrackCount,
&i.DurationSec,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const deletePlaylist = `-- name: DeletePlaylist :one
DELETE FROM playlists WHERE id = $1
RETURNING id, cover_path
`
type DeletePlaylistRow struct {
ID pgtype.UUID
CoverPath *string
}
// Returns cover_path so the caller can clean up the cached collage on disk.
func (q *Queries) DeletePlaylist(ctx context.Context, id pgtype.UUID) (DeletePlaylistRow, error) {
row := q.db.QueryRow(ctx, deletePlaylist, id)
var i DeletePlaylistRow
err := row.Scan(&i.ID, &i.CoverPath)
return i, err
}
const deletePlaylistTrack = `-- name: DeletePlaylistTrack :exec
DELETE FROM playlist_tracks
WHERE playlist_id = $1 AND position = $2
`
type DeletePlaylistTrackParams struct {
PlaylistID pgtype.UUID
Position int32
}
// Two-step: delete the row at `position`, then renumber subsequent rows
// to close the gap. The renumber is a single UPDATE; the service layer
// runs both in one transaction.
func (q *Queries) DeletePlaylistTrack(ctx context.Context, arg DeletePlaylistTrackParams) error {
_, err := q.db.Exec(ctx, deletePlaylistTrack, arg.PlaylistID, arg.Position)
return err
}
const getPlaylist = `-- name: GetPlaylist :one
SELECT p.id, p.user_id, p.name, p.description, p.is_public, p.cover_path, p.track_count, p.duration_sec, p.created_at, p.updated_at, u.username AS owner_username
FROM playlists p
JOIN users u ON u.id = p.user_id
WHERE p.id = $1
`
type GetPlaylistRow struct {
ID pgtype.UUID
UserID pgtype.UUID
Name string
Description string
IsPublic bool
CoverPath *string
TrackCount int32
DurationSec int32
CreatedAt pgtype.Timestamptz
UpdatedAt pgtype.Timestamptz
OwnerUsername string
}
func (q *Queries) GetPlaylist(ctx context.Context, id pgtype.UUID) (GetPlaylistRow, error) {
row := q.db.QueryRow(ctx, getPlaylist, id)
var i GetPlaylistRow
err := row.Scan(
&i.ID,
&i.UserID,
&i.Name,
&i.Description,
&i.IsPublic,
&i.CoverPath,
&i.TrackCount,
&i.DurationSec,
&i.CreatedAt,
&i.UpdatedAt,
&i.OwnerUsername,
)
return i, err
}
const listAllPlaylistTracksForCollage = `-- name: ListAllPlaylistTracksForCollage :many
SELECT pt.position,
albums.cover_art_path AS album_cover_path
FROM playlist_tracks pt
LEFT JOIN tracks t ON t.id = pt.track_id
LEFT JOIN albums ON albums.id = t.album_id
WHERE pt.playlist_id = $1
ORDER BY pt.position
LIMIT $2
`
type ListAllPlaylistTracksForCollageParams struct {
PlaylistID pgtype.UUID
Limit int32
}
type ListAllPlaylistTracksForCollageRow struct {
Position int32
AlbumCoverPath *string
}
// First N tracks for the collage. Uses LEFT JOIN on albums for the
// cover_path; rows with NULL cover_path get the glyph fallback.
func (q *Queries) ListAllPlaylistTracksForCollage(ctx context.Context, arg ListAllPlaylistTracksForCollageParams) ([]ListAllPlaylistTracksForCollageRow, error) {
rows, err := q.db.Query(ctx, listAllPlaylistTracksForCollage, arg.PlaylistID, arg.Limit)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListAllPlaylistTracksForCollageRow
for rows.Next() {
var i ListAllPlaylistTracksForCollageRow
if err := rows.Scan(&i.Position, &i.AlbumCoverPath); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listPlaylistTracks = `-- name: ListPlaylistTracks :many
SELECT pt.playlist_id, pt.position, pt.track_id, pt.title, pt.artist_name, pt.album_title, pt.duration_sec, pt.added_at,
t.id AS live_track_id,
albums.id AS album_id,
artists.id AS artist_id
FROM playlist_tracks pt
LEFT JOIN tracks t ON t.id = pt.track_id
LEFT JOIN albums ON albums.id = t.album_id
LEFT JOIN artists ON artists.id = t.artist_id
WHERE pt.playlist_id = $1
ORDER BY pt.position
`
type ListPlaylistTracksRow struct {
PlaylistID pgtype.UUID
Position int32
TrackID pgtype.UUID
Title string
ArtistName string
AlbumTitle string
DurationSec int32
AddedAt pgtype.Timestamptz
LiveTrackID pgtype.UUID
AlbumID pgtype.UUID
ArtistID pgtype.UUID
}
// Joined to tracks for the live track id (the service layer derives the
// stream URL from it); LEFT JOIN preserves the row when track_id is NULL
// (track was removed from the library). The denormalized snapshot fields
// on playlist_tracks remain authoritative for title/artist/album text.
func (q *Queries) ListPlaylistTracks(ctx context.Context, playlistID pgtype.UUID) ([]ListPlaylistTracksRow, error) {
rows, err := q.db.Query(ctx, listPlaylistTracks, playlistID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListPlaylistTracksRow
for rows.Next() {
var i ListPlaylistTracksRow
if err := rows.Scan(
&i.PlaylistID,
&i.Position,
&i.TrackID,
&i.Title,
&i.ArtistName,
&i.AlbumTitle,
&i.DurationSec,
&i.AddedAt,
&i.LiveTrackID,
&i.AlbumID,
&i.ArtistID,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listPlaylistsForUser = `-- name: ListPlaylistsForUser :many
SELECT p.id, p.user_id, p.name, p.description, p.is_public, p.cover_path, p.track_count, p.duration_sec, p.created_at, p.updated_at, u.username AS owner_username
FROM playlists p
JOIN users u ON u.id = p.user_id
WHERE p.user_id = $1 OR p.is_public = true
ORDER BY p.updated_at DESC
`
type ListPlaylistsForUserRow struct {
ID pgtype.UUID
UserID pgtype.UUID
Name string
Description string
IsPublic bool
CoverPath *string
TrackCount int32
DurationSec int32
CreatedAt pgtype.Timestamptz
UpdatedAt pgtype.Timestamptz
OwnerUsername string
}
// Owner's playlists (any visibility) + other users' public playlists.
// Ordered by updated_at desc so newly-edited ones float to the top.
func (q *Queries) ListPlaylistsForUser(ctx context.Context, userID pgtype.UUID) ([]ListPlaylistsForUserRow, error) {
rows, err := q.db.Query(ctx, listPlaylistsForUser, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListPlaylistsForUserRow
for rows.Next() {
var i ListPlaylistsForUserRow
if err := rows.Scan(
&i.ID,
&i.UserID,
&i.Name,
&i.Description,
&i.IsPublic,
&i.CoverPath,
&i.TrackCount,
&i.DurationSec,
&i.CreatedAt,
&i.UpdatedAt,
&i.OwnerUsername,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const renumberPlaylistTracksAfter = `-- name: RenumberPlaylistTracksAfter :exec
UPDATE playlist_tracks
SET position = position - 1
WHERE playlist_id = $1 AND position > $2
`
type RenumberPlaylistTracksAfterParams struct {
PlaylistID pgtype.UUID
Position int32
}
// Used after DeletePlaylistTrack to close the gap.
func (q *Queries) RenumberPlaylistTracksAfter(ctx context.Context, arg RenumberPlaylistTracksAfterParams) error {
_, err := q.db.Exec(ctx, renumberPlaylistTracksAfter, arg.PlaylistID, arg.Position)
return err
}
const setPlaylistCover = `-- name: SetPlaylistCover :exec
UPDATE playlists SET cover_path = $2, updated_at = now() WHERE id = $1
`
type SetPlaylistCoverParams struct {
ID pgtype.UUID
CoverPath *string
}
func (q *Queries) SetPlaylistCover(ctx context.Context, arg SetPlaylistCoverParams) error {
_, err := q.db.Exec(ctx, setPlaylistCover, arg.ID, arg.CoverPath)
return err
}
const updatePlaylist = `-- name: UpdatePlaylist :one
UPDATE playlists
SET
name = CASE WHEN $1::boolean THEN $2::text ELSE name END,
description = CASE WHEN $3::boolean THEN $4::text ELSE description END,
is_public = CASE WHEN $5::boolean THEN $6::boolean ELSE is_public END,
updated_at = now()
WHERE id = $7
RETURNING id, user_id, name, description, is_public, cover_path, track_count, duration_sec, created_at, updated_at
`
type UpdatePlaylistParams struct {
UpdateName bool
Name string
UpdateDescription bool
Description string
UpdateIsPublic bool
IsPublic bool
ID pgtype.UUID
}
// Updates only the fields whose corresponding `updateX` flag is true.
// The flags let the service layer keep PATCH semantics (only-touch-what-the-caller-sent)
// without writing N variants.
func (q *Queries) UpdatePlaylist(ctx context.Context, arg UpdatePlaylistParams) (Playlist, error) {
row := q.db.QueryRow(ctx, updatePlaylist,
arg.UpdateName,
arg.Name,
arg.UpdateDescription,
arg.Description,
arg.UpdateIsPublic,
arg.IsPublic,
arg.ID,
)
var i Playlist
err := row.Scan(
&i.ID,
&i.UserID,
&i.Name,
&i.Description,
&i.IsPublic,
&i.CoverPath,
&i.TrackCount,
&i.DurationSec,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const updatePlaylistRollups = `-- name: UpdatePlaylistRollups :exec
UPDATE playlists
SET
track_count = (SELECT COUNT(*) FROM playlist_tracks pt WHERE pt.playlist_id = $1),
duration_sec = (SELECT COALESCE(SUM(pt.duration_sec), 0) FROM playlist_tracks pt WHERE pt.playlist_id = $1),
updated_at = now()
WHERE id = $1
`
// Set track_count + duration_sec from a fresh aggregate. Called after
// every mutation that touches playlist_tracks. Cheap; the table is small.
func (q *Queries) UpdatePlaylistRollups(ctx context.Context, playlistID pgtype.UUID) error {
_, err := q.db.Exec(ctx, updatePlaylistRollups, playlistID)
return err
}
@@ -0,0 +1,2 @@
DROP TABLE IF EXISTS playlist_tracks;
DROP TABLE IF EXISTS playlists;
@@ -0,0 +1,39 @@
-- M7 #352 slice 1: playlists CRUD foundation.
-- See docs/superpowers/specs/2026-05-03-m7-playlists-crud-design.md.
CREATE TABLE playlists (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name text NOT NULL,
description text NOT NULL DEFAULT '',
is_public boolean NOT NULL DEFAULT false,
cover_path text,
track_count integer NOT NULL DEFAULT 0,
duration_sec integer NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX playlists_user_idx ON playlists (user_id, updated_at DESC);
CREATE INDEX playlists_public_idx ON playlists (is_public, updated_at DESC) WHERE is_public;
-- track_id is nullable + ON DELETE SET NULL so deleting a track from
-- the library doesn't silently drop entries from operators' playlists.
-- Denormalized title/artist/album/duration carry a snapshot so the row
-- is still legible after the upstream track row is gone.
CREATE TABLE playlist_tracks (
playlist_id uuid NOT NULL REFERENCES playlists(id) ON DELETE CASCADE,
position integer NOT NULL,
track_id uuid REFERENCES tracks(id) ON DELETE SET NULL,
title text NOT NULL,
artist_name text NOT NULL,
album_title text NOT NULL,
duration_sec integer NOT NULL,
added_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (playlist_id, position)
);
-- Partial index supports the FK lookup that fires when a track is
-- deleted (ON DELETE SET NULL) — without it, deleting a track triggers
-- a full scan of playlist_tracks.
CREATE INDEX playlist_tracks_track_idx ON playlist_tracks (track_id) WHERE track_id IS NOT NULL;
+110
View File
@@ -0,0 +1,110 @@
-- name: CreatePlaylist :one
INSERT INTO playlists (user_id, name, description, is_public)
VALUES ($1, $2, $3, $4)
RETURNING *;
-- name: GetPlaylist :one
SELECT p.*, u.username AS owner_username
FROM playlists p
JOIN users u ON u.id = p.user_id
WHERE p.id = $1;
-- name: ListPlaylistsForUser :many
-- Owner's playlists (any visibility) + other users' public playlists.
-- Ordered by updated_at desc so newly-edited ones float to the top.
SELECT p.*, u.username AS owner_username
FROM playlists p
JOIN users u ON u.id = p.user_id
WHERE p.user_id = $1 OR p.is_public = true
ORDER BY p.updated_at DESC;
-- name: UpdatePlaylist :one
-- Updates only the fields whose corresponding `updateX` flag is true.
-- The flags let the service layer keep PATCH semantics (only-touch-what-the-caller-sent)
-- without writing N variants.
UPDATE playlists
SET
name = CASE WHEN sqlc.arg(update_name)::boolean THEN sqlc.arg(name)::text ELSE name END,
description = CASE WHEN sqlc.arg(update_description)::boolean THEN sqlc.arg(description)::text ELSE description END,
is_public = CASE WHEN sqlc.arg(update_is_public)::boolean THEN sqlc.arg(is_public)::boolean ELSE is_public END,
updated_at = now()
WHERE id = sqlc.arg(id)
RETURNING *;
-- name: UpdatePlaylistRollups :exec
-- Set track_count + duration_sec from a fresh aggregate. Called after
-- every mutation that touches playlist_tracks. Cheap; the table is small.
UPDATE playlists
SET
track_count = (SELECT COUNT(*) FROM playlist_tracks pt WHERE pt.playlist_id = $1),
duration_sec = (SELECT COALESCE(SUM(pt.duration_sec), 0) FROM playlist_tracks pt WHERE pt.playlist_id = $1),
updated_at = now()
WHERE id = $1;
-- name: SetPlaylistCover :exec
UPDATE playlists SET cover_path = $2, updated_at = now() WHERE id = $1;
-- name: DeletePlaylist :one
-- Returns cover_path so the caller can clean up the cached collage on disk.
DELETE FROM playlists WHERE id = $1
RETURNING id, cover_path;
-- name: ListPlaylistTracks :many
-- Joined to tracks for the live track id (the service layer derives the
-- stream URL from it); LEFT JOIN preserves the row when track_id is NULL
-- (track was removed from the library). The denormalized snapshot fields
-- on playlist_tracks remain authoritative for title/artist/album text.
SELECT pt.*,
t.id AS live_track_id,
albums.id AS album_id,
artists.id AS artist_id
FROM playlist_tracks pt
LEFT JOIN tracks t ON t.id = pt.track_id
LEFT JOIN albums ON albums.id = t.album_id
LEFT JOIN artists ON artists.id = t.artist_id
WHERE pt.playlist_id = $1
ORDER BY pt.position;
-- name: AppendPlaylistTrack :one
-- Inserts at the next available position. Snapshot fields are copied
-- from the tracks/albums/artists join at insert time. tracks.duration_ms
-- is converted to seconds for the snapshot.
INSERT INTO playlist_tracks (playlist_id, position, track_id, title, artist_name, album_title, duration_sec)
SELECT
sqlc.arg(playlist_id)::uuid,
COALESCE((SELECT MAX(position) + 1 FROM playlist_tracks WHERE playlist_id = sqlc.arg(playlist_id)::uuid), 0),
t.id,
t.title,
artists.name,
albums.title,
(t.duration_ms / 1000)::integer
FROM tracks t
JOIN albums ON albums.id = t.album_id
JOIN artists ON artists.id = t.artist_id
WHERE t.id = sqlc.arg(track_id)::uuid
RETURNING *;
-- name: DeletePlaylistTrack :exec
-- Two-step: delete the row at `position`, then renumber subsequent rows
-- to close the gap. The renumber is a single UPDATE; the service layer
-- runs both in one transaction.
DELETE FROM playlist_tracks
WHERE playlist_id = $1 AND position = $2;
-- name: RenumberPlaylistTracksAfter :exec
-- Used after DeletePlaylistTrack to close the gap.
UPDATE playlist_tracks
SET position = position - 1
WHERE playlist_id = $1 AND position > $2;
-- name: ListAllPlaylistTracksForCollage :many
-- First N tracks for the collage. Uses LEFT JOIN on albums for the
-- cover_path; rows with NULL cover_path get the glyph fallback.
SELECT pt.position,
albums.cover_art_path AS album_cover_path
FROM playlist_tracks pt
LEFT JOIN tracks t ON t.id = pt.track_id
LEFT JOIN albums ON albums.id = t.album_id
WHERE pt.playlist_id = $1
ORDER BY pt.position
LIMIT $2;