Files
minstrel/internal/api/library_sync.go
T
bvandeusen 27f123f7d9 fix(server,flutter): sync wire format + systemVariant column (#357)
Two fixes in one commit because they're entangled — the systemVariant
work would have been theater otherwise.

## The wire-format bug

/api/library/sync was emitting PascalCase JSON for artist / album /
track / playlist upserts (raw json.Marshal of sqlc-generated structs
with no JSON tags — sqlc.yaml: emit_json_tags=false). Flutter's
sync_controller _*FromJson reads snake_case keys, so all metadata
sync rows landed in drift with empty strings / zero ints.

The like_track / like_album / like_artist / playlist_track entities
work because they're hand-built `map[string]string` payloads with
snake_case keys — they sidestepped the bug. The 4 raw-marshal
entities did not.

Existing sync test caught zero of this — it asserts on len(upserts)
not field shape.

Fix: server-side view structs in library_sync_views.go with proper
JSON tags + pgtype-flattening (UUID → 8-4-4-4-12 hex string,
Date → "2006-01-02"). Mirrors the playlistRowView pattern from
/api/playlists. New library_sync_views_test.go pins the wire keys
so future field-name drift breaks loud.

## systemVariant column (closes #357 plan C v1 limitation)

playlistSyncView now carries `system_variant` server → wire.

Flutter drift schema bumped from 1 → 2 with onUpgrade adding the
`systemVariant TEXT NULL` column to cached_playlists. Cursor reset
to 0 in the migration so existing rows refresh with the new field
on the next sync.

playlistsListProvider now filters locally by systemVariant:
- kind='user'   → systemVariant IS NULL  (the add-to-playlist sheet's intent)
- kind='system' → systemVariant IS NOT NULL
- kind='all'    → no filter

Closes the documented v1 limitation where the add-to-playlist sheet
showed system playlists alongside user-created ones.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 18:47:30 -04:00

262 lines
7.6 KiB
Go

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(toArtistSyncView(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(toAlbumSyncView(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(toTrackSyncView(t))
out["track"] = append(out["track"], b)
}
}
userIDStr := syncpkg.FormatUUID(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 syncpkg.FormatUUID(p.UserID) != userIDStr && !p.IsPublic {
continue
}
b, _ := json.Marshal(toPlaylistSyncView(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
}
// 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}
}