feat(api): offline-first sync layer
Implements the stubbed /sync endpoints: - GET /sync/changes — delta pull (likes, plays, changed playlists with their track ids, changed catalogue tracks) over the half-open window (since, cursor]; the cursor is the DB clock, so it's immune to app/DB skew. - POST /sync/push — idempotent append of client like/play events (ON CONFLICT DO NOTHING by client-supplied id); events for tracks the server doesn't have are skipped (graceful degradation). Adds a server-ingestion column `synced_at` to the likes + play_history event logs (migration) as the delta ordering key, so an event pushed with an older event time still surfaces for other devices. Repos gain list_since/add_event (likes, history), list_changed_since (playlists, tracks) and playlist.track_ids; wired via SyncService in deps. Also fixes test isolation exposed by the registry-backed /admin/sources endpoint: test_sources_api clears the process-cached source registry, and test_admin_api no longer hardcodes the environment name. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+92
-4
@@ -1,15 +1,103 @@
|
||||
"""Client sync endpoints (offline-first event log)."""
|
||||
"""Client sync endpoints (offline-first event log).
|
||||
|
||||
from typing import Any
|
||||
``GET /sync/changes`` pulls everything the caller changed since their cursor;
|
||||
``POST /sync/push`` uploads the like/play events a client accumulated offline
|
||||
(idempotent — replays are no-ops). See :mod:`app.application.sync_service`.
|
||||
"""
|
||||
|
||||
import datetime as dt
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.deps import AlbumRepoDep, ArtistRepoDep, CurrentUser, SyncServiceDep
|
||||
from app.api.schemas.sync import (
|
||||
LikeEventOut,
|
||||
PlayEventOut,
|
||||
PlaylistSyncOut,
|
||||
SyncChangesOut,
|
||||
SyncPushIn,
|
||||
SyncPushOut,
|
||||
)
|
||||
from app.api.v1.tracks import _build_track_out
|
||||
from app.application.sync_service import LikeEvent, PlayEvent
|
||||
|
||||
router = APIRouter(prefix="/sync", tags=["sync"])
|
||||
|
||||
|
||||
@router.get("/changes")
|
||||
async def get_changes() -> Any: ...
|
||||
async def get_changes(
|
||||
service: SyncServiceDep,
|
||||
artist_repo: ArtistRepoDep,
|
||||
album_repo: AlbumRepoDep,
|
||||
user: CurrentUser,
|
||||
since: dt.datetime | None = None,
|
||||
) -> SyncChangesOut:
|
||||
"""Delta since ``since`` (omit for a full snapshot). Persist ``cursor`` from
|
||||
the response and pass it back as ``?since=`` next time."""
|
||||
changes = await service.get_changes(user.id, since=since)
|
||||
|
||||
artist_ids = list({t.artist_id for t in changes.tracks})
|
||||
album_ids = list({t.album_id for t in changes.tracks if t.album_id is not None})
|
||||
artists = {a.id: a for a in await artist_repo.get_many(artist_ids)}
|
||||
albums = {a.id: a for a in await album_repo.get_many(album_ids)}
|
||||
tracks_out = await _build_track_out(changes.tracks, artists, albums)
|
||||
|
||||
return SyncChangesOut(
|
||||
cursor=changes.cursor,
|
||||
likes=[
|
||||
LikeEventOut(
|
||||
id=lk.id, track_id=lk.track_id, value=lk.value, created_at=lk.created_at
|
||||
)
|
||||
for lk in changes.likes
|
||||
],
|
||||
plays=[
|
||||
PlayEventOut(
|
||||
id=p.id,
|
||||
track_id=p.track_id,
|
||||
played_at=p.played_at,
|
||||
play_duration_seconds=p.play_duration_seconds,
|
||||
completed=p.completed,
|
||||
)
|
||||
for p in changes.plays
|
||||
],
|
||||
playlists=[
|
||||
PlaylistSyncOut(
|
||||
id=d.playlist.id,
|
||||
name=d.playlist.name,
|
||||
description=d.playlist.description,
|
||||
version=d.playlist.version,
|
||||
updated_at=d.playlist.updated_at,
|
||||
track_ids=d.track_ids,
|
||||
)
|
||||
for d in changes.playlists
|
||||
],
|
||||
tracks=tracks_out,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/push")
|
||||
async def push_changes() -> Any: ...
|
||||
async def push_changes(
|
||||
body: SyncPushIn, service: SyncServiceDep, user: CurrentUser
|
||||
) -> SyncPushOut:
|
||||
result = await service.push(
|
||||
user.id,
|
||||
likes=[
|
||||
LikeEvent(id=e.id, track_id=e.track_id, value=e.value, created_at=e.created_at)
|
||||
for e in body.likes
|
||||
],
|
||||
plays=[
|
||||
PlayEvent(
|
||||
id=e.id,
|
||||
track_id=e.track_id,
|
||||
played_at=e.played_at,
|
||||
play_duration_seconds=e.play_duration_seconds,
|
||||
completed=e.completed,
|
||||
)
|
||||
for e in body.plays
|
||||
],
|
||||
)
|
||||
return SyncPushOut(
|
||||
cursor=result.cursor,
|
||||
accepted_likes=result.accepted_likes,
|
||||
accepted_plays=result.accepted_plays,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user