feat: play-count read path — stitched historic-stats, playcounts, deltas
What
The read path of recoupable/chat#1791 — second api slice, fulfilling the contract merged in recoupable/docs#238. Three store-served endpoints (PR 1 of the agreed 2-PR split; the write path — snapshots Workflow + backfill worker — follows):
GET /api/research/track/historic-stats— Spotify history now comes from the measurement store only: Apify snapshot captures + Songstats-backfilled points, labeled per point (data_source), same-date collisions resolved in favor of the apify capture,start_date/end_datehonored. Unbackfilled tracks return their snapshot-only series and are enqueued tosongstats_backfill_queue(rank = latest play count) — the request never blocks, and Songstats never serves the Spotify request path. Other sources delegate to the untouched passthrough with per-point provenance labels.GET /api/research/playcounts?spotify_album_id— latest capture for every mapped track on an album (identifiers → songs → measurements; album name fromsongs.album); 404 pointing atPOST /snapshotswhen nothing is captured yet.GET /api/research/track/playcount-deltas?isrc&since[&until]— nearest-capture deltas per(platform, metric)withdays+run_rate_annualized; emptydeltas(not an error) on insufficient history; 404 for an unknown ISRC.
#657 lessons, applied from the start
- OCP: both passthroughs untouched; all routing in new
lib/research/playcounts/orchestrators; handlers wire one line. - Parse-once zod boundary:
historicStatsPayloadSchema(looseObject) validated once in the orchestrator;labelSongstatsHistoricProvenanceis a pure typed transform; shape drift warns + passes through unlabeled. - One select per table:
selectSongMeasurementsgainssongs[]+ optionalplatform/metric(no sibling files);selectSongIdentifiersgainssongs[]; newselectSongs/upsertSongstatsBackfillQueuefollow conventions. - No trivial extractions: single-expression merges stay inline;
buildSpotifyHistoricStat/computePlaycountDeltasare extracted because they carry real logic (dedupe, windowing, grouping) — each with direct tests.
Tests
36 new tests, every unit RED before GREEN. Full suite 604 files pass; tsc delta 0 (198 pre-existing); lint + format clean (formatted before push this time).
Verification plan (preview)
Live data exists from #657's verification (The Spins: 2 apify captures in the store). Will verify: historic-stats snapshot-only series + queue row appears; deltas between the two real captures; playcounts for K.I.D.S. (Deluxe); 400/404/401 paths; a seeded songstats-data_source row to prove the mixed-provenance stitch. Results table to follow as a comment.
Merge order
docs#238 ✅ → database#31/#32 ✅ → api#657 ✅ → this → write path (snapshots Workflow + backfill worker, needs a small database claim-RPC PR first).
🤖 Generated with Claude Code
Summary by cubic
Adds the play-count read path backed by our measurement store. Spotify historic stats are stitched from snapshots and backfill points; plus new endpoints for album playcounts and per-track deltas.
-
New Features
- GET /api/research/track/historic-stats: Spotify history served from the store (Apify snapshots + Songstats backfill), per-point data_source, date bounds honored. Unbackfilled tracks return snapshots only and are enqueued; other sources still proxy Songstats.
- GET /api/research/playcounts?spotify_album_id=: Latest platform-displayed playcount per mapped track on an album; 404 points to POST /api/research/snapshots when no captures exist.
- GET /api/research/track/playcount-deltas?isrc=&since[&until]: Nearest-capture deltas per (platform, metric) with days and annualized run rate. Empty deltas on insufficient history; 404 for unknown ISRC.
-
Refactors
- Store access:
selectSong_measurementsnow supportssongorsongs[]with optional platform/metric filters;selectSong_identifiersaddssongs[]; newselectSongs. - Backfill queue: idempotent upsert to
songstats_backfill_queuewith rank by latest playcount; errors are logged and never block reads.
- Store access: