Skip to content

fix(api): restore album-purchase track access - #1014

Merged
raymondjacobson merged 2 commits into
mainfrom
fix/previously-containing-track-access
Aug 10, 2026
Merged

fix(api): restore album-purchase track access#1014
raymondjacobson merged 2 commits into
mainfrom
fix/previously-containing-track-access

Conversation

@rickyrombo

@rickyrombo rickyrombo commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Buying an album grants access to its tracks, and that access is meant to survive a track later leaving the album for anyone whose purchase predates the removal. This PR now fixes the full path: the API reader, the ETL writer dependency, and the recoverable historical data.

Reader fixes

tracks.playlists_previously_containing_track is a jsonb object keyed by playlist id:

{"1284768821": {"time": 1725873897}}

Three bugs prevented the API from using it:

  1. The reader unmarshalled that object into a Go slice of {playlist_id, removal_time}. The error was swallowed, so access was denied silently.
  2. The SQL used jsonb_each_text and cast the object value ({"time": ...}) directly to numeric, which would fail once the reader bug was fixed.
  3. The result was modeled per playlist, but removal time belongs to a (track, album) pair. If two tracks leave the same album at different times, a purchase between the removals covers only the track that was still in the album.

The API now parses the production object shape, flattens it to (track_id, playlist_id, removal_time) records, and joins those records to purchases with jsonb_to_recordset. Malformed input denies rather than grants.

ETL writer

go.mod now consumes the merged OpenAudio writer from OpenAudio/go-openaudio#481:

github.com/OpenAudio/go-openaudio/pkg/etl v1.6.5-0.20260810163330-95f8e2ff0c66

That pseudo-version resolves to merge commit 95f8e2ff0c66573920488e272dc53ca245c57343. The writer updates playlist_tracks and both reverse-index columns in the same transaction, using the block timestamp for future removal records.

Backfill

Migration 0238_backfill_track_playlist_reverse_index.sql treats playlist_tracks as the authoritative membership table and:

  • fills tracks.playlists_containing_track with every active relation;
  • removes inactive relations from that array;
  • clears stale removal records for tracks that are active again; and
  • preserves existing historical removal records.

It compares arrays as sets so ordering alone does not rewrite a track, keeps the search trigger enabled, temporarily suppresses the expensive catalog recount trigger, preserves a pre-disabled trigger state, and is idempotent.

The migration deliberately does not invent missing historical removal records. playlist_tracks.updated_at was written with now(), not block time; using it for entitlement could grant access to a buyer who purchased after the on-chain removal but before a delayed indexer processed it. The merged ETL writer records exact block timestamps going forward.

Rollout

API DDL runs in the pre-roll migration Job, while the old indexer can still be active. After the new indexer version is fully rolled out, manually execute ddl/migrations/0238_backfill_track_playlist_reverse_index.sql once more against the writer database. Restarting pg_migrate alone will not rerun an already tracked file.

Then verify that the current reverse index has no mismatches:

WITH expected AS (
    SELECT
        track_id,
        COALESCE(
            array_agg(playlist_id ORDER BY playlist_id)
                FILTER (WHERE is_removed = false),
            '{}'::integer[]
        ) AS active_playlist_ids
    FROM playlist_tracks
    GROUP BY track_id
)
SELECT count(*) AS mismatched_tracks
FROM tracks t
JOIN expected e USING (track_id)
WHERE t.is_current = true
  AND (
      NOT (
          t.playlists_containing_track @> e.active_playlist_ids
          AND e.active_playlist_ids @> t.playlists_containing_track
      )
      OR EXISTS (
          SELECT 1
          FROM unnest(e.active_playlist_ids) AS playlist_id
          WHERE jsonb_exists(
              t.playlists_previously_containing_track,
              playlist_id::text
          )
      )
  );

Expected result: 0.

Verification

  • go test -count=1 ./api/dbv1 -run 'TestParseTrackRemovals|TestTrackRemovalMarshalsToRecordsetColumns'
  • go test -count=1 ./api -run TestTrackAccessAfterRemovalFromPurchasedAlbum
  • repository migration manager idempotency run and all SQL tests
  • migration fixture covering active, removed, stale-removal, and array-order cases
  • second migration run: UPDATE 0
  • pre-disabled on_track remains disabled
  • post-backfill verification query: mismatched_tracks = 0

TestSearch still requires Elasticsearch when run locally; it was already unrelated to this change.

Buying an album grants access to its tracks, and that access is meant to
survive a track later leaving the album for anyone whose purchase predates
the removal. That check has never worked.

tracks.playlists_previously_containing_track is a jsonb object keyed by
playlist id:

  {"1284768821": {"time": 1725873897}}

Three things were wrong, stacked so that only the first was reachable:

  1. The reader unmarshalled that object into a Go slice of
     {playlist_id, removal_time}. That always errors, the error was
     swallowed by an `err == nil` guard, and the resulting empty map meant
     the entitlement loop iterated nothing -- denying access silently, with
     no log and no failed request.

  2. The query read the same column with jsonb_each_text and cast the value
     to numeric. The value is an object, not a scalar, so it raises
     `invalid input syntax for type numeric`. Unreachable in practice
     because (1) short-circuits first: fixing only the Go side would have
     traded a silent denial for a query error.

  3. The model was playlist-shaped. Removal times belong to a (track,
     album) pair -- two tracks can leave the same album months apart, and a
     purchase between those dates covers one but not the other. Collecting
     results into a playlist-keyed set cannot express that and would grant
     or deny both together.

The column is now parsed as the object it is, flattened to (track_id,
playlist_id, removal_time) triples, and joined with jsonb_to_recordset so
each pair is matched against the purchase date on its own. Anything
unparseable yields no removals, which denies rather than grants.

Verified against Postgres with two tracks leaving one album at different
times: a buyer before both keeps both, a buyer between them keeps only the
track still in the album at purchase, a buyer after both keeps neither.
TestTrackAccessAfterRemovalFromPurchasedAlbum covers all three through the
access-info endpoint, and fails if the old array-shaped reader is restored.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@raymondjacobson raymondjacobson changed the title fix(api): read playlists_previously_containing_track as written fix(api): restore album-purchase track access Aug 10, 2026
@raymondjacobson
raymondjacobson merged commit e3d1ecc into main Aug 10, 2026
2 checks passed
@raymondjacobson
raymondjacobson deleted the fix/previously-containing-track-access branch August 10, 2026 17:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants