feat(server): GET /api/library/sync endpoint

Returns batched upserts + deletes since the supplied cursor. Empty cursor
returns full snapshot; subsequent calls pull deltas. Per-user entities
(likes, playlists) are scoped to the authed user. Composite-key entities
(likes, playlist_tracks) use stable string ids encoded by sync.EncodeLikeID
/ sync.EncodePlaylistTrackID.

Behavior:
  204 No Content - no changes since cursor
  200 OK         - JSON syncResponse {cursor, upserts, deletes}
  410 Gone       - cursor older than oldest log row; client must reset
  401 / 500      - standard envelope errors

Adds sqlc queries GetAlbumsByIDs, GetTracksByIDs, GetPlaylistsByIDs to
mirror the existing GetArtistsByIDs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-09 22:36:26 -04:00
parent 3959c85111
commit 9bf4b504b3
8 changed files with 641 additions and 0 deletions
+287
View File
@@ -0,0 +1,287 @@
package api
import (
"context"
"encoding/json"
"net/http"
"strconv"
"github.com/jackc/pgx/v5/pgtype"
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
syncpkg "git.fabledsword.com/bvandeusen/minstrel/internal/sync"
)
// SyncBatchLimit caps the number of change rows returned per call.
// Clients that need more loop with the returned cursor.
const SyncBatchLimit = 1000
// syncResponse is the wire shape returned by GET /api/library/sync.
// upserts and deletes are separated by entity type so clients can apply
// them to typed local tables. cursor is the largest library_changes.id
// the server returned in this batch — clients pass it back as `since`
// next time.
type syncResponse struct {
Cursor int64 `json:"cursor"`
Upserts map[string][]json.RawMessage `json:"upserts"`
Deletes map[string][]string `json:"deletes"`
}
// handleLibrarySync returns batched upserts + deletes since the supplied
// cursor. Cursor is the last `library_changes.id` the client has seen.
// Empty / zero / invalid cursor means "give me everything" (initial sync).
//
// Responses:
// - 204 No Content — no changes since cursor
// - 200 OK — JSON syncResponse with upserts, deletes, new cursor
// - 410 Gone — cursor older than oldest log row; client must reset
// - 401 / 500 — standard envelope errors
func (h *handlers) handleLibrarySync(w http.ResponseWriter, r *http.Request) {
user, ok := requireUser(w, r)
if !ok {
return
}
ctx := r.Context()
since, err := strconv.ParseInt(r.URL.Query().Get("since"), 10, 64)
if err != nil || since < 0 {
since = 0
}
q := dbq.New(h.pool)
// 410 path: if the requested cursor is older than the oldest row,
// tell the client to reset. (No compaction job in this slice; the
// path exists for future compaction support.)
if since > 0 {
minCursor, err := q.GetMinLibraryChangeCursor(ctx)
if err != nil {
writeErrWithLog(w, h.logger, "library_sync: GetMinLibraryChangeCursor", err)
return
}
if minCursor > 0 && since+1 < minCursor {
writeErr(w, &apierror.Error{
Status: http.StatusGone,
Code: "cursor_too_old",
Message: "reset client cursor to 0 and resync",
})
return
}
}
changes, err := q.GetLibraryChangesSince(ctx, dbq.GetLibraryChangesSinceParams{
ID: since,
Limit: SyncBatchLimit,
})
if err != nil {
writeErrWithLog(w, h.logger, "library_sync: GetLibraryChangesSince", err)
return
}
if len(changes) == 0 {
w.WriteHeader(http.StatusNoContent)
return
}
// Group ids by entity type so we can fetch upsert payloads in batches.
upsertIDs := map[syncpkg.EntityType][]string{}
deleteIDs := map[syncpkg.EntityType][]string{}
var maxID int64
for _, c := range changes {
et := syncpkg.EntityType(c.EntityType)
if c.ID > maxID {
maxID = c.ID
}
if c.Op == string(syncpkg.OpUpsert) {
upsertIDs[et] = append(upsertIDs[et], c.EntityID)
} else {
deleteIDs[et] = append(deleteIDs[et], c.EntityID)
}
}
upserts, err := h.hydrateUpserts(ctx, q, user.ID, upsertIDs)
if err != nil {
writeErrWithLog(w, h.logger, "library_sync: hydrateUpserts", err)
return
}
// Format deletes — convert EntityType keys to strings for JSON.
deletes := make(map[string][]string, len(deleteIDs))
for et, ids := range deleteIDs {
deletes[string(et)] = ids
}
writeJSON(w, http.StatusOK, syncResponse{
Cursor: maxID,
Upserts: upserts,
Deletes: deletes,
})
}
// hydrateUpserts loads the current row payloads for each upserted id,
// keyed by entity type (string form). Returns json.RawMessage values so
// each entity's full sqlc row shape passes through unchanged.
//
// Per-user entities (likes, playlists, playlist_tracks) are scoped to
// userID — the change log is global but each user only sees rows that
// concern them.
func (h *handlers) hydrateUpserts(
ctx context.Context,
q *dbq.Queries,
userID pgtype.UUID,
ids map[syncpkg.EntityType][]string,
) (map[string][]json.RawMessage, error) {
out := map[string][]json.RawMessage{}
if rows := ids[syncpkg.EntityArtist]; len(rows) > 0 {
uuids := stringsToUUIDs(rows)
artists, err := q.GetArtistsByIDs(ctx, uuids)
if err != nil {
return nil, err
}
for _, a := range artists {
b, _ := json.Marshal(a)
out["artist"] = append(out["artist"], b)
}
}
if rows := ids[syncpkg.EntityAlbum]; len(rows) > 0 {
uuids := stringsToUUIDs(rows)
albums, err := q.GetAlbumsByIDs(ctx, uuids)
if err != nil {
return nil, err
}
for _, a := range albums {
b, _ := json.Marshal(a)
out["album"] = append(out["album"], b)
}
}
if rows := ids[syncpkg.EntityTrack]; len(rows) > 0 {
uuids := stringsToUUIDs(rows)
tracks, err := q.GetTracksByIDs(ctx, uuids)
if err != nil {
return nil, err
}
for _, t := range tracks {
b, _ := json.Marshal(t)
out["track"] = append(out["track"], b)
}
}
userIDStr := uuidToString(userID)
if rows := ids[syncpkg.EntityLikeTrack]; len(rows) > 0 {
out["like_track"] = scopedLikeRows(rows, userIDStr, "track_id")
}
if rows := ids[syncpkg.EntityLikeAlbum]; len(rows) > 0 {
out["like_album"] = scopedLikeRows(rows, userIDStr, "album_id")
}
if rows := ids[syncpkg.EntityLikeArtist]; len(rows) > 0 {
out["like_artist"] = scopedLikeRows(rows, userIDStr, "artist_id")
}
if rows := ids[syncpkg.EntityPlaylist]; len(rows) > 0 {
uuids := stringsToUUIDs(rows)
playlists, err := q.GetPlaylistsByIDs(ctx, uuids)
if err != nil {
return nil, err
}
for _, p := range playlists {
// Filter: only return playlists owned by this user OR public ones
if uuidToString(p.UserID) != userIDStr && !p.IsPublic {
continue
}
b, _ := json.Marshal(p)
out["playlist"] = append(out["playlist"], b)
}
}
if rows := ids[syncpkg.EntityPlaylistTrack]; len(rows) > 0 {
// playlist_track ids are "<playlist>:<track>" — we don't know
// playlist ownership here without a JOIN. Pass through; client
// reconciles against its locally-cached playlists.
var msgs []json.RawMessage
for _, id := range rows {
parts := splitOnce(id, ":")
if len(parts) != 2 {
continue
}
b, _ := json.Marshal(map[string]string{
"playlist_id": parts[0],
"track_id": parts[1],
})
msgs = append(msgs, b)
}
out["playlist_track"] = msgs
}
return out, nil
}
// scopedLikeRows filters composite-key like rows to those owned by userID
// and emits {user_id, <entityKey>} JSON shapes. entityKey is "track_id",
// "album_id", or "artist_id" depending on the like table.
func scopedLikeRows(rows []string, userIDStr, entityKey string) []json.RawMessage {
var msgs []json.RawMessage
for _, id := range rows {
parts := splitOnce(id, ":")
if len(parts) != 2 || parts[0] != userIDStr {
continue
}
b, _ := json.Marshal(map[string]string{
"user_id": parts[0],
entityKey: parts[1],
})
msgs = append(msgs, b)
}
return msgs
}
// stringsToUUIDs parses each string as a pgtype.UUID. Invalid entries
// are skipped silently — they can't have come from a valid scan/mutation.
func stringsToUUIDs(strs []string) []pgtype.UUID {
out := make([]pgtype.UUID, 0, len(strs))
for _, s := range strs {
u, ok := parseUUID(s)
if !ok {
continue
}
out = append(out, u)
}
return out
}
// uuidToString renders a pgtype.UUID as the canonical 8-4-4-4-12 hex
// form. Returns "" if the UUID is not valid.
func uuidToString(u pgtype.UUID) string {
if !u.Valid {
return ""
}
b := u.Bytes
return formatUUIDBytes(b)
}
func formatUUIDBytes(b [16]byte) string {
const hex = "0123456789abcdef"
out := make([]byte, 36)
pos := 0
for i := 0; i < 16; i++ {
if i == 4 || i == 6 || i == 8 || i == 10 {
out[pos] = '-'
pos++
}
out[pos] = hex[b[i]>>4]
out[pos+1] = hex[b[i]&0x0f]
pos += 2
}
return string(out)
}
// splitOnce returns [before, after] split at the first occurrence of sep,
// or [s] if sep doesn't appear. Used for composite-key parsing.
func splitOnce(s, sep string) []string {
for i := 0; i+len(sep) <= len(s); i++ {
if s[i:i+len(sep)] == sep {
return []string{s[:i], s[i+len(sep):]}
}
}
return []string{s}
}
+219
View File
@@ -0,0 +1,219 @@
package api
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strconv"
"testing"
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/sync"
)
func callLibrarySync(h *handlers, user dbq.User, since string) *httptest.ResponseRecorder {
url := "/api/library/sync"
if since != "" {
url += "?since=" + since
}
req := httptest.NewRequest(http.MethodGet, url, nil)
req = req.WithContext(context.WithValue(req.Context(), auth.UserCtxKeyForTest(), user))
w := httptest.NewRecorder()
h.handleLibrarySync(w, req)
return w
}
func TestLibrarySync_NoChanges_Returns204(t *testing.T) {
h, pool := testHandlers(t)
truncateLibrary(t, pool)
u := seedUser(t, pool, "alice", "x", false)
w := callLibrarySync(h, u, "0")
if w.Code != http.StatusNoContent {
t.Fatalf("status = %d body = %s", w.Code, w.Body.String())
}
}
func TestLibrarySync_AfterArtistUpsert_ReturnsHydratedPayload(t *testing.T) {
h, pool := testHandlers(t)
truncateLibrary(t, pool)
u := seedUser(t, pool, "alice", "x", false)
// Seed an artist + write a change row in the same tx — that's the
// invariant the scanner wiring (Task 5) will enforce, and the
// handler relies on.
ctx := context.Background()
tx, err := pool.Begin(ctx)
if err != nil {
t.Fatalf("begin: %v", err)
}
defer tx.Rollback(ctx)
var artistID string
if err := tx.QueryRow(ctx,
`INSERT INTO artists (name, sort_name) VALUES ($1, $2) RETURNING id::text`,
"Boards of Canada", "Boards of Canada",
).Scan(&artistID); err != nil {
t.Fatalf("insert artist: %v", err)
}
if err := sync.LogChange(ctx, tx, sync.EntityArtist, artistID, sync.OpUpsert); err != nil {
t.Fatalf("LogChange: %v", err)
}
if err := tx.Commit(ctx); err != nil {
t.Fatalf("commit: %v", err)
}
w := callLibrarySync(h, u, "0")
if w.Code != http.StatusOK {
t.Fatalf("status = %d body = %s", w.Code, w.Body.String())
}
var body struct {
Cursor int64 `json:"cursor"`
Upserts map[string][]json.RawMessage `json:"upserts"`
Deletes map[string][]string `json:"deletes"`
}
if err := json.NewDecoder(w.Body).Decode(&body); err != nil {
t.Fatalf("decode: %v", err)
}
if body.Cursor <= 0 {
t.Fatalf("expected positive cursor, got %d", body.Cursor)
}
if len(body.Upserts["artist"]) != 1 {
t.Fatalf("expected 1 artist upsert, got %d", len(body.Upserts["artist"]))
}
}
func TestLibrarySync_CursorMonotonic(t *testing.T) {
h, pool := testHandlers(t)
truncateLibrary(t, pool)
u := seedUser(t, pool, "alice", "x", false)
ctx := context.Background()
tx, _ := pool.Begin(ctx)
var artistID string
_ = tx.QueryRow(ctx,
`INSERT INTO artists (name, sort_name) VALUES ($1, $2) RETURNING id::text`,
"A", "A",
).Scan(&artistID)
_ = sync.LogChange(ctx, tx, sync.EntityArtist, artistID, sync.OpUpsert)
_ = tx.Commit(ctx)
w1 := callLibrarySync(h, u, "0")
if w1.Code != http.StatusOK {
t.Fatalf("first call status = %d", w1.Code)
}
var body1 struct {
Cursor int64 `json:"cursor"`
}
if err := json.NewDecoder(w1.Body).Decode(&body1); err != nil {
t.Fatalf("decode: %v", err)
}
w2 := callLibrarySync(h, u, strconv.FormatInt(body1.Cursor, 10))
if w2.Code != http.StatusNoContent {
t.Fatalf("second call status = %d body = %s", w2.Code, w2.Body.String())
}
}
func TestLibrarySync_DeleteIsReflected(t *testing.T) {
h, pool := testHandlers(t)
truncateLibrary(t, pool)
u := seedUser(t, pool, "alice", "x", false)
// Just write a delete change row directly (no underlying mutation).
// Scanner wiring (Task 5) is what couples real deletes to LogChange;
// here we test the handler reflects what's in the log.
ctx := context.Background()
tx, _ := pool.Begin(ctx)
_ = sync.LogChange(ctx, tx, sync.EntityArtist, "deadbeef-dead-beef-dead-beefdeadbeef", sync.OpDelete)
_ = tx.Commit(ctx)
w := callLibrarySync(h, u, "0")
if w.Code != http.StatusOK {
t.Fatalf("status = %d body = %s", w.Code, w.Body.String())
}
var body struct {
Cursor int64 `json:"cursor"`
Upserts map[string][]json.RawMessage `json:"upserts"`
Deletes map[string][]string `json:"deletes"`
}
if err := json.NewDecoder(w.Body).Decode(&body); err != nil {
t.Fatalf("decode: %v", err)
}
if got := body.Deletes["artist"]; len(got) != 1 || got[0] != "deadbeef-dead-beef-dead-beefdeadbeef" {
t.Fatalf("expected one artist delete; got %#v", body.Deletes)
}
}
func TestLibrarySync_LikeRowScopedToUser(t *testing.T) {
h, pool := testHandlers(t)
truncateLibrary(t, pool)
u := seedUser(t, pool, "alice", "x", false)
other := seedUser(t, pool, "bob", "x", false)
// Two like_track rows: one for u, one for other. u's call should
// see only their own row.
ctx := context.Background()
tx, _ := pool.Begin(ctx)
_ = sync.LogChange(ctx, tx, sync.EntityLikeTrack,
sync.EncodeLikeID(uuidToString(u.ID), "11111111-1111-1111-1111-111111111111"),
sync.OpUpsert)
_ = sync.LogChange(ctx, tx, sync.EntityLikeTrack,
sync.EncodeLikeID(uuidToString(other.ID), "22222222-2222-2222-2222-222222222222"),
sync.OpUpsert)
_ = tx.Commit(ctx)
w := callLibrarySync(h, u, "0")
if w.Code != http.StatusOK {
t.Fatalf("status = %d body = %s", w.Code, w.Body.String())
}
var body struct {
Upserts map[string][]json.RawMessage `json:"upserts"`
}
if err := json.NewDecoder(w.Body).Decode(&body); err != nil {
t.Fatalf("decode: %v", err)
}
got := body.Upserts["like_track"]
if len(got) != 1 {
t.Fatalf("expected 1 like row scoped to alice, got %d", len(got))
}
}
// Pure unit tests below — run even with -short.
func TestSplitOnce(t *testing.T) {
cases := []struct {
in, sep string
want []string
}{
{"a:b", ":", []string{"a", "b"}},
{"a:b:c", ":", []string{"a", "b:c"}},
{"abc", ":", []string{"abc"}},
{":x", ":", []string{"", "x"}},
}
for _, c := range cases {
got := splitOnce(c.in, c.sep)
if len(got) != len(c.want) {
t.Errorf("splitOnce(%q,%q): len got=%d want=%d", c.in, c.sep, len(got), len(c.want))
continue
}
for i := range got {
if got[i] != c.want[i] {
t.Errorf("splitOnce(%q,%q)[%d] = %q want %q", c.in, c.sep, i, got[i], c.want[i])
}
}
}
}
func TestFormatUUIDBytes(t *testing.T) {
var b [16]byte
for i := range b {
b[i] = byte(i)
}
got := formatUUIDBytes(b)
want := "00010203-0405-0607-0809-0a0b0c0d0e0f"
if got != want {
t.Errorf("formatUUIDBytes = %q want %q", got, want)
}
}
+38
View File
@@ -168,6 +168,44 @@ func (q *Queries) GetAlbumCoverageRollup(ctx context.Context) (GetAlbumCoverageR
return i, err
}
const getAlbumsByIDs = `-- name: GetAlbumsByIDs :many
SELECT id, title, sort_title, artist_id, release_date, mbid, cover_art_path, created_at, updated_at, cover_art_source, cover_art_sources_version FROM albums WHERE id = ANY($1::uuid[])
`
// Batched lookup used by /api/library/sync to hydrate upsert payloads
// (#357). Mirror of GetArtistsByIDs.
func (q *Queries) GetAlbumsByIDs(ctx context.Context, dollar_1 []pgtype.UUID) ([]Album, error) {
rows, err := q.db.Query(ctx, getAlbumsByIDs, dollar_1)
if err != nil {
return nil, err
}
defer rows.Close()
var items []Album
for rows.Next() {
var i Album
if err := rows.Scan(
&i.ID,
&i.Title,
&i.SortTitle,
&i.ArtistID,
&i.ReleaseDate,
&i.Mbid,
&i.CoverArtPath,
&i.CreatedAt,
&i.UpdatedAt,
&i.CoverArtSource,
&i.CoverArtSourcesVersion,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listAlbumsAlphaByArtist = `-- name: ListAlbumsAlphaByArtist :many
SELECT albums.id, albums.title, albums.sort_title, albums.artist_id, albums.release_date, albums.mbid, albums.cover_art_path, albums.created_at, albums.updated_at, albums.cover_art_source, albums.cover_art_sources_version, artists.sort_name AS artist_sort_name
FROM albums
+40
View File
@@ -173,6 +173,46 @@ func (q *Queries) GetPlaylist(ctx context.Context, id pgtype.UUID) (GetPlaylistR
return i, err
}
const getPlaylistsByIDs = `-- name: GetPlaylistsByIDs :many
SELECT id, user_id, name, description, is_public, cover_path, track_count, duration_sec, created_at, updated_at, kind, system_variant, seed_artist_id FROM playlists WHERE id = ANY($1::uuid[])
`
// Batched lookup used by /api/library/sync to hydrate upsert payloads
// (#357). Mirror of GetArtistsByIDs.
func (q *Queries) GetPlaylistsByIDs(ctx context.Context, dollar_1 []pgtype.UUID) ([]Playlist, error) {
rows, err := q.db.Query(ctx, getPlaylistsByIDs, dollar_1)
if err != nil {
return nil, err
}
defer rows.Close()
var items []Playlist
for rows.Next() {
var i Playlist
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.Kind,
&i.SystemVariant,
&i.SeedArtistID,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listAllPlaylistTracksForCollage = `-- name: ListAllPlaylistTracksForCollage :many
SELECT pt.position,
albums.cover_art_path AS album_cover_path
+42
View File
@@ -143,6 +143,48 @@ func (q *Queries) GetTrackByPath(ctx context.Context, filePath string) (Track, e
return i, err
}
const getTracksByIDs = `-- name: GetTracksByIDs :many
SELECT id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at FROM tracks WHERE id = ANY($1::uuid[])
`
// Batched lookup used by /api/library/sync to hydrate upsert payloads
// (#357). Mirror of GetArtistsByIDs.
func (q *Queries) GetTracksByIDs(ctx context.Context, dollar_1 []pgtype.UUID) ([]Track, error) {
rows, err := q.db.Query(ctx, getTracksByIDs, dollar_1)
if err != nil {
return nil, err
}
defer rows.Close()
var items []Track
for rows.Next() {
var i Track
if err := rows.Scan(
&i.ID,
&i.Title,
&i.AlbumID,
&i.ArtistID,
&i.TrackNumber,
&i.DiscNumber,
&i.DurationMs,
&i.FilePath,
&i.FileSize,
&i.FileFormat,
&i.Bitrate,
&i.Mbid,
&i.Genre,
&i.AddedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listArtistTracksForUser = `-- name: ListArtistTracksForUser :many
SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at,
albums.title AS album_title,
+5
View File
@@ -159,3 +159,8 @@ UPDATE albums
updated_at = now()
WHERE id = $1;
-- name: GetAlbumsByIDs :many
-- Batched lookup used by /api/library/sync to hydrate upsert payloads
-- (#357). Mirror of GetArtistsByIDs.
SELECT * FROM albums WHERE id = ANY($1::uuid[]);
+5
View File
@@ -108,3 +108,8 @@ LEFT JOIN albums ON albums.id = t.album_id
WHERE pt.playlist_id = $1
ORDER BY pt.position
LIMIT $2;
-- name: GetPlaylistsByIDs :many
-- Batched lookup used by /api/library/sync to hydrate upsert payloads
-- (#357). Mirror of GetArtistsByIDs.
SELECT * FROM playlists WHERE id = ANY($1::uuid[]);
+5
View File
@@ -95,3 +95,8 @@ SELECT COUNT(*) FROM tracks WHERE artist_id = $1;
-- checks the service does next.
DELETE FROM tracks WHERE id = $1
RETURNING id, album_id, artist_id, file_path, mbid;
-- name: GetTracksByIDs :many
-- Batched lookup used by /api/library/sync to hydrate upsert payloads
-- (#357). Mirror of GetArtistsByIDs.
SELECT * FROM tracks WHERE id = ANY($1::uuid[]);