From b52361a05a97ee60258d36b2d6b8feecbd95c032 Mon Sep 17 00:00:00 2001 From: Marcus Pasell <3690498+rickyrombo@users.noreply.github.com> Date: Mon, 10 Aug 2026 09:13:12 -0700 Subject: [PATCH 1/2] fix(api): read playlists_previously_containing_track as written 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 --- api/dbv1/access.go | 145 ++++++++++-------- api/dbv1/access_removals_test.go | 92 +++++++++++ ...v1_track_access_removed_from_album_test.go | 136 ++++++++++++++++ 3 files changed, 313 insertions(+), 60 deletions(-) create mode 100644 api/dbv1/access_removals_test.go create mode 100644 api/v1_track_access_removed_from_album_test.go diff --git a/api/dbv1/access.go b/api/dbv1/access.go index 193b6a43..91369044 100644 --- a/api/dbv1/access.go +++ b/api/dbv1/access.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "math" + "strconv" "golang.org/x/sync/errgroup" ) @@ -13,6 +14,50 @@ type Access struct { Download bool `json:"download"` } +// trackRemoval is one track's departure from one album. Buying the album +// before this moment keeps access to the track afterwards, so the pair and the +// timestamp have to travel together: two tracks can leave the same album on +// different days, and a purchase between those days covers only one of them. +type trackRemoval struct { + TrackID int32 `json:"track_id"` + PlaylistID int32 `json:"playlist_id"` + RemovalTime int64 `json:"removal_time"` +} + +// parseTrackRemovals reads tracks.playlists_previously_containing_track. +// +// The indexer writes it as a jsonb object keyed by playlist id, with the +// removal recorded as a unix timestamp under "time": +// +// {"1284768821": {"time": 1725873897}} +// +// Anything unparseable yields no removals, which denies access rather than +// granting it. +func parseTrackRemovals(trackID int32, raw json.RawMessage) []trackRemoval { + if len(raw) == 0 { + return nil + } + var record map[string]struct { + Time int64 `json:"time"` + } + if err := json.Unmarshal(raw, &record); err != nil { + return nil + } + removals := make([]trackRemoval, 0, len(record)) + for playlistID, entry := range record { + id, err := strconv.ParseInt(playlistID, 10, 32) + if err != nil { + continue + } + removals = append(removals, trackRemoval{ + TrackID: trackID, + PlaylistID: int32(id), + RemovalTime: entry.Time, + }) + } + return removals +} + func (q *Queries) GetPlaylistAccess( ctx context.Context, myId int32, @@ -101,11 +146,8 @@ func (q *Queries) GetBulkTrackAccess( trackIDs := make(map[int32]struct{}) playlistIDs := make(map[int32]struct{}) tokenGateTokenMints := make(map[string]struct{}) - prevPlaylistData := make(map[int32][]byte) // trackID -> JSON of previous playlists - prevPlaylistsMap := make(map[int32][]struct { - PlaylistID int32 `json:"playlist_id"` - RemovalTime string `json:"removal_time"` - }) + // trackID -> the albums this track has left, and when + prevRemovals := make(map[int32][]trackRemoval) // Collect records that need to be fetched for _, track := range tracks { @@ -129,15 +171,8 @@ func (q *Queries) GetBulkTrackAccess( for _, playlistID := range track.PlaylistsContainingTrack { playlistIDs[playlistID] = struct{}{} } - if len(track.PlaylistsPreviouslyContainingTrack) > 0 { - prevPlaylistData[track.TrackID] = track.PlaylistsPreviouslyContainingTrack - var prevPlaylists []struct { - PlaylistID int32 `json:"playlist_id"` - RemovalTime string `json:"removal_time"` - } - if err := json.Unmarshal(track.PlaylistsPreviouslyContainingTrack, &prevPlaylists); err == nil { - prevPlaylistsMap[track.TrackID] = prevPlaylists - } + if removals := parseTrackRemovals(track.TrackID, track.PlaylistsPreviouslyContainingTrack); len(removals) > 0 { + prevRemovals[track.TrackID] = removals } } } @@ -157,15 +192,8 @@ func (q *Queries) GetBulkTrackAccess( for _, playlistID := range track.PlaylistsContainingTrack { playlistIDs[playlistID] = struct{}{} } - if len(track.PlaylistsPreviouslyContainingTrack) > 0 { - prevPlaylistData[track.TrackID] = track.PlaylistsPreviouslyContainingTrack - var prevPlaylists []struct { - PlaylistID int32 `json:"playlist_id"` - RemovalTime string `json:"removal_time"` - } - if err := json.Unmarshal(track.PlaylistsPreviouslyContainingTrack, &prevPlaylists); err == nil { - prevPlaylistsMap[track.TrackID] = prevPlaylists - } + if removals := parseTrackRemovals(track.TrackID, track.PlaylistsPreviouslyContainingTrack); len(removals) > 0 { + prevRemovals[track.TrackID] = removals } } } @@ -202,7 +230,8 @@ func (q *Queries) GetBulkTrackAccess( tippedUsers := make(map[int32]bool) purchasedTracks := make(map[int32]bool) purchasedPlaylists := make(map[int32]bool) - prevPurchasedPlaylists := make(map[int32]bool) + // tracks whose access survives via an album bought before the track left it + prevPurchasedTracks := make(map[int32]bool) userTokenBalances := make(map[string]int64) walletTokenBalances := make(map[string]int64) coinDecimals := make(map[string]int32) @@ -377,35 +406,39 @@ func (q *Queries) GetBulkTrackAccess( } // Query for previously purchased playlists - if len(prevPlaylistData) > 0 { - // Collect all previous playlist IDs - prevPlaylistIDs := make([]int32, 0) - for _, prevPlaylists := range prevPlaylistsMap { - for _, prevPlaylist := range prevPlaylists { - prevPlaylistIDs = append(prevPlaylistIDs, prevPlaylist.PlaylistID) - } + if len(prevRemovals) > 0 { + // Flatten to (track, album, removal time) triples. Matching has to be + // done on the pair: a purchase covers a track only if it predates that + // track's removal from that album, not the album's earliest removal. + flat := make([]trackRemoval, 0, len(prevRemovals)) + for _, removals := range prevRemovals { + flat = append(flat, removals...) } - if len(prevPlaylistIDs) > 0 { + if len(flat) > 0 { + payload, err := json.Marshal(flat) + if err != nil { + return nil, err + } g.Go(func() error { rows, err := q.db.Query(ctx, ` - SELECT up.content_id - FROM v_usdc_purchases up - JOIN jsonb_each_text($2) AS prev_playlists(playlist_id, removal_time) - ON up.content_id = prev_playlists.playlist_id::integer - WHERE up.buyer_user_id = $1 - AND up.content_type = 'album' - AND up.content_id = ANY($3) - AND up.created_at <= to_timestamp(prev_playlists.removal_time::numeric) - `, myId, prevPlaylistData, prevPlaylistIDs) + SELECT DISTINCT r.track_id + FROM jsonb_to_recordset($2::jsonb) + AS r(track_id int, playlist_id int, removal_time bigint) + JOIN v_usdc_purchases up + ON up.content_id = r.playlist_id + AND up.content_type = 'album' + AND up.buyer_user_id = $1 + WHERE up.created_at <= to_timestamp(r.removal_time) + `, myId, payload) if err != nil { return err } defer rows.Close() for rows.Next() { - var playlistID int32 - if err := rows.Scan(&playlistID); err == nil { - prevPurchasedPlaylists[playlistID] = true + var trackID int32 + if err := rows.Scan(&trackID); err == nil { + prevPurchasedTracks[trackID] = true } } return rows.Err() @@ -474,14 +507,10 @@ func (q *Queries) GetBulkTrackAccess( } } - // Check previous playlist purchases - if !hasAccess && len(track.PlaylistsPreviouslyContainingTrack) > 0 { - for _, prevPlaylist := range prevPlaylistsMap[track.TrackID] { - if prevPurchasedPlaylists[prevPlaylist.PlaylistID] { - hasAccess = true - break - } - } + // Bought an album that used to contain this track, before it + // left: access survives the removal. + if !hasAccess { + hasAccess = prevPurchasedTracks[track.TrackID] } } result[track.TrackID] = Access{ @@ -521,14 +550,10 @@ func (q *Queries) GetBulkTrackAccess( } } - // Check previous playlist purchases - if !hasAccess && len(track.PlaylistsPreviouslyContainingTrack) > 0 { - for _, prevPlaylist := range prevPlaylistsMap[track.TrackID] { - if prevPurchasedPlaylists[prevPlaylist.PlaylistID] { - hasAccess = true - break - } - } + // Bought an album that used to contain this track, before it + // left: access survives the removal. + if !hasAccess { + hasAccess = prevPurchasedTracks[track.TrackID] } } // If there are download conditions, there is always stream access diff --git a/api/dbv1/access_removals_test.go b/api/dbv1/access_removals_test.go new file mode 100644 index 00000000..d9c031b7 --- /dev/null +++ b/api/dbv1/access_removals_test.go @@ -0,0 +1,92 @@ +package dbv1 + +import ( + "encoding/json" + "sort" + "testing" +) + +// The shape below is what the indexer has always written and what production +// rows contain. Reading it wrongly does not error anywhere a user can see — +// it silently denies access to tracks people paid for — so it is pinned here. +func TestParseTrackRemovals(t *testing.T) { + tests := []struct { + name string + raw string + want []trackRemoval + }{ + { + name: "production shape", + raw: `{"1284768821": {"time": 1725873897}}`, + want: []trackRemoval{{TrackID: 7, PlaylistID: 1284768821, RemovalTime: 1725873897}}, + }, + { + name: "several albums, each with its own removal time", + raw: `{"100": {"time": 111}, "200": {"time": 222}}`, + want: []trackRemoval{ + {TrackID: 7, PlaylistID: 100, RemovalTime: 111}, + {TrackID: 7, PlaylistID: 200, RemovalTime: 222}, + }, + }, + { + name: "empty object — the column default", + raw: `{}`, + want: []trackRemoval{}, + }, + { + name: "absent", + raw: ``, + want: nil, + }, + // Anything we cannot read must deny rather than grant. + { + name: "array shape is not what the indexer writes", + raw: `[{"playlist_id": 100, "removal_time": "111"}]`, + want: nil, + }, + { + name: "non-numeric playlist key is skipped", + raw: `{"not-an-id": {"time": 111}}`, + want: []trackRemoval{}, + }, + { + name: "garbage", + raw: `{`, + want: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := parseTrackRemovals(7, json.RawMessage(tt.raw)) + sort.Slice(got, func(i, j int) bool { return got[i].PlaylistID < got[j].PlaylistID }) + + if tt.want == nil && got != nil { + t.Fatalf("got %+v, want nil", got) + } + if len(got) != len(tt.want) { + t.Fatalf("got %d removals %+v, want %d", len(got), got, len(tt.want)) + } + for i := range tt.want { + if got[i] != tt.want[i] { + t.Errorf("[%d] = %+v, want %+v", i, got[i], tt.want[i]) + } + } + }) + } +} + +// The parsed removals are handed to Postgres as a jsonb array and read back +// with jsonb_to_recordset(... AS r(track_id int, playlist_id int, +// removal_time bigint)). The struct tags are that column contract, so a rename +// here has to be a rename there. +func TestTrackRemovalMarshalsToRecordsetColumns(t *testing.T) { + b, err := json.Marshal([]trackRemoval{{TrackID: 7, PlaylistID: 100, RemovalTime: 111}}) + if err != nil { + t.Fatalf("marshal: %v", err) + } + const want = `[{"track_id":7,"playlist_id":100,"removal_time":111}]` + if string(b) != want { + t.Errorf("got %s, want %s", b, want) + } +} diff --git a/api/v1_track_access_removed_from_album_test.go b/api/v1_track_access_removed_from_album_test.go new file mode 100644 index 00000000..0dbfc0f7 --- /dev/null +++ b/api/v1_track_access_removed_from_album_test.go @@ -0,0 +1,136 @@ +package api + +import ( + "encoding/json" + "strconv" + "testing" + "time" + + "api.audius.co/database" + "api.audius.co/trashid" + "github.com/stretchr/testify/assert" +) + +// Buying an album grants access to its tracks, and that access survives a +// track later being removed from the album — but only for buyers whose +// purchase predates the removal. +// +// The entitlement is decided from tracks.playlists_previously_containing_track, +// a jsonb object keyed by playlist id: {"": {"time": }}. +// +// Two tracks leaving the same album at different times is the case that makes +// this per-(track, album) rather than per-album: a purchase can sit between +// the two removals and cover one track but not the other. +func TestTrackAccessAfterRemovalFromPurchasedAlbum(t *testing.T) { + app := emptyTestApp(t) + + const ( + artistID = 900 + albumID = 910 + early = 920 // left the album first + late = 921 // left the album later + + boughtBeforeBoth = 901 // purchase precedes both removals + boughtBetween = 902 // purchase sits between the two removals + boughtAfterBoth = 903 // purchase follows both removals + ) + const ( + walletBefore = "0x4954d18926ba0ed9378938444731be4e622537b2" + walletBetween = "0x7d273271690538cf855e5b3002a0dd8c154bb060" + walletAfter = "0x855d28d495ec1b06364bb7a521212753e2190b95" + ) + var ( + removedEarly = time.Date(2025, 3, 1, 0, 0, 0, 0, time.UTC) + removedLate = time.Date(2025, 9, 1, 0, 0, 0, 0, time.UTC) + ) + + usdcGate := map[string]any{ + "usdc_purchase": map[string]any{ + "price": 100.0, + "splits": []map[string]any{{"user_id": artistID, "percentage": 100.0}}, + }, + } + + fixtures := database.FixtureMap{ + "users": []map[string]any{ + {"user_id": artistID, "handle": "artist900", "handle_lc": "artist900", "wallet": "0xc3d1d41e6872ffbd15c473d14fc3a9250be5b5e0"}, + {"user_id": boughtBeforeBoth, "handle": "buyer901", "handle_lc": "buyer901", "wallet": walletBefore}, + {"user_id": boughtBetween, "handle": "buyer902", "handle_lc": "buyer902", "wallet": walletBetween}, + {"user_id": boughtAfterBoth, "handle": "buyer903", "handle_lc": "buyer903", "wallet": walletAfter}, + }, + "playlists": []map[string]any{ + {"playlist_id": albumID, "playlist_name": "The Album", "playlist_owner_id": artistID, "is_album": true}, + }, + "tracks": []map[string]any{ + { + "track_id": early, "owner_id": artistID, "title": "Left Early", + "is_stream_gated": true, "stream_conditions": usdcGate, + "playlists_previously_containing_track": map[string]any{ + strconv.Itoa(albumID): map[string]any{"time": removedEarly.Unix()}, + }, + }, + { + "track_id": late, "owner_id": artistID, "title": "Left Late", + "is_stream_gated": true, "stream_conditions": usdcGate, + "playlists_previously_containing_track": map[string]any{ + strconv.Itoa(albumID): map[string]any{"time": removedLate.Unix()}, + }, + }, + }, + "sol_purchases": []map[string]any{ + {"signature": "sigbefore", "instruction_index": 0, "buyer_user_id": boughtBeforeBoth, "amount": 2000000, + "content_type": "album", "content_id": albumID, "created_at": removedEarly.Add(-24 * time.Hour), "is_valid": true}, + {"signature": "sigbetween", "instruction_index": 0, "buyer_user_id": boughtBetween, "amount": 2000000, + "content_type": "album", "content_id": albumID, "created_at": removedEarly.Add(24 * time.Hour), "is_valid": true}, + {"signature": "sigafter", "instruction_index": 0, "buyer_user_id": boughtAfterBoth, "amount": 2000000, + "content_type": "album", "content_id": albumID, "created_at": removedLate.Add(24 * time.Hour), "is_valid": true}, + }, + } + database.Seed(app.pool.Replicas[0], fixtures) + + // The viewer has to be both named (user_id, which becomes myId) and proven + // (a signed wallet) — the query parameter alone is a 403. + streamAccess := func(t *testing.T, trackID, viewerID int, wallet string) bool { + t.Helper() + status, body := testGetWithWallet(t, app, + "/v1/tracks/"+trashid.MustEncodeHashID(trackID)+"/access-info?user_id="+trashid.MustEncodeHashID(viewerID), + wallet) + assert.Equal(t, 200, status) + var resp struct { + Data struct { + Access struct { + Stream bool `json:"stream"` + } `json:"access"` + } `json:"data"` + } + if err := json.Unmarshal(body, &resp); err != nil { + t.Fatalf("decode access-info: %v", err) + } + return resp.Data.Access.Stream + } + + for _, tc := range []struct { + name string + viewer int + wallet string + wantEarly, wantLate bool + }{ + // Bought before either track left: both are covered. + {"purchase precedes both removals", boughtBeforeBoth, walletBefore, true, true}, + // The discriminating case: the purchase sits between the removals, so + // it covers only the track that was still in the album at the time. + {"purchase between the two removals", boughtBetween, walletBetween, false, true}, + // Bought after both had left: the album never contained them for this + // buyer. + {"purchase follows both removals", boughtAfterBoth, walletAfter, false, false}, + } { + t.Run(tc.name, func(t *testing.T) { + if got := streamAccess(t, early, tc.viewer, tc.wallet); got != tc.wantEarly { + t.Errorf("track that left early: stream access = %v, want %v", got, tc.wantEarly) + } + if got := streamAccess(t, late, tc.viewer, tc.wallet); got != tc.wantLate { + t.Errorf("track that left late: stream access = %v, want %v", got, tc.wantLate) + } + }) + } +} From 1b1d86308436fc87e4a38c7036d6a8876daae72b Mon Sep 17 00:00:00 2001 From: Raymond Jacobson Date: Mon, 10 Aug 2026 09:53:21 -0700 Subject: [PATCH 2/2] fix(api): backfill track playlist reverse indexes --- ..._backfill_track_playlist_reverse_index.sql | 121 ++++++++++++++++++ go.mod | 2 +- go.sum | 4 +- 3 files changed, 124 insertions(+), 3 deletions(-) create mode 100644 ddl/migrations/0238_backfill_track_playlist_reverse_index.sql diff --git a/ddl/migrations/0238_backfill_track_playlist_reverse_index.sql b/ddl/migrations/0238_backfill_track_playlist_reverse_index.sql new file mode 100644 index 00000000..f9dae22d --- /dev/null +++ b/ddl/migrations/0238_backfill_track_playlist_reverse_index.sql @@ -0,0 +1,121 @@ +-- Reconcile the playlist reverse index stored on tracks with playlist_tracks. +-- +-- tracks.playlists_containing_track is part of album-purchase authorization: +-- when a track is gated, the API checks whether the listener bought an album +-- that currently contains it. The legacy Python indexer maintained this array +-- alongside playlist_tracks, but the Go ETL initially maintained only the +-- junction table. Rows indexed during that gap therefore have an authoritative +-- playlist_tracks relation without the corresponding reverse index. +-- +-- This migration treats playlist_tracks as the source of truth and repairs all +-- state that can be established without guessing: +-- +-- * active relations are present in playlists_containing_track; +-- * removed relations are absent from playlists_containing_track; and +-- * an active relation has no stale entry in +-- playlists_previously_containing_track. +-- +-- Missing historical-removal entries are deliberately not synthesized. +-- playlist_tracks.updated_at is written with now(), not block time, while the +-- API compares the removal timestamp with a purchase timestamp. Using that +-- wall-clock value could grant access to someone who bought the album after +-- the on-chain removal but before a delayed indexer processed it. Existing +-- removal entries are preserved; the ETL version shipped with this migration +-- writes exact block timestamps for future removals. +-- +-- Rollout note: ddl migrations run in the pre-roll Job, before the new ETL +-- indexer replaces the old one. The old indexer can therefore create a small +-- number of additional mismatches between this transaction committing and the +-- rollout completing. This migration is intentionally safe to run again after +-- the new indexer owns the writer; the PR rollout notes include that rerun and +-- a zero-mismatch verification query. +-- +-- The set comparison ignores array order, so already-correct tracks do not get +-- rewritten merely because their playlist ids were accumulated in a different +-- order. Changed arrays are normalized to ascending playlist id order. +-- +-- Updating tracks fires two broad triggers in production. trg_tracks is kept +-- enabled so search/index consumers observe repaired rows. on_track is disabled +-- because it recounts the owner's entire catalog once per updated track; none +-- of the fields changed here affect that aggregate or create notifications. +-- The temporary table records whether this migration disabled the trigger so a +-- pre-disabled trigger is not accidentally enabled at the end. On a database +-- bootstrapped from ddl/ alone, migrations run before functions and on_track +-- does not exist yet, so both trigger operations become no-ops. +-- +-- A SHARE lock keeps playlist_tracks stable while the expected sets are built. +-- It blocks playlist membership writes, but not reads, for this transaction. +-- The tracks trigger lock and row updates likewise block competing writes only +-- for the duration of the backfill. Re-running is a no-op once the sets agree. + +BEGIN; +SET LOCAL lock_timeout = '5s'; +SET LOCAL statement_timeout = 0; + +LOCK TABLE playlist_tracks IN SHARE MODE; + +CREATE TEMP TABLE track_playlist_backfill_trigger_state +ON COMMIT DROP AS +SELECT 1 AS disabled_by_this_migration +FROM pg_trigger +WHERE tgrelid = 'tracks'::regclass + AND tgname = 'on_track' + AND NOT tgisinternal + AND tgenabled <> 'D'; + +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM track_playlist_backfill_trigger_state) THEN + ALTER TABLE tracks DISABLE TRIGGER on_track; + END IF; +END $$; + +WITH expected AS MATERIALIZED ( + 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 +) +UPDATE tracks t +SET + playlists_containing_track = e.active_playlist_ids, + playlists_previously_containing_track = + CASE + WHEN cardinality(e.active_playlist_ids) = 0 + THEN t.playlists_previously_containing_track + ELSE t.playlists_previously_containing_track - ARRAY( + SELECT playlist_id::text + FROM unnest(e.active_playlist_ids) AS playlist_id + ) + END +FROM expected e +WHERE t.track_id = e.track_id + AND 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 + ) + ) + ); + +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM track_playlist_backfill_trigger_state) THEN + ALTER TABLE tracks ENABLE TRIGGER on_track; + END IF; +END $$; + +COMMIT; diff --git a/go.mod b/go.mod index 189d16bd..35f6c278 100644 --- a/go.mod +++ b/go.mod @@ -7,7 +7,7 @@ require ( github.com/AlecAivazis/survey/v2 v2.3.7 github.com/Doist/unfurlist v0.0.0-20250409100812-515f2735f8e5 github.com/OpenAudio/go-openaudio v1.8.2-0.20260727214803-1d9f69772e87 - github.com/OpenAudio/go-openaudio/pkg/etl v1.6.4 + github.com/OpenAudio/go-openaudio/pkg/etl v1.6.5-0.20260810163330-95f8e2ff0c66 github.com/aquasecurity/esquery v0.2.0 github.com/axiomhq/axiom-go v0.23.0 github.com/axiomhq/hyperloglog v0.2.5 diff --git a/go.sum b/go.sum index bf99cd52..84d75bfd 100644 --- a/go.sum +++ b/go.sum @@ -22,8 +22,8 @@ github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 h1:TngWCqHvy9oXAN6lEV github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5/go.mod h1:lmUJ/7eu/Q8D7ML55dXQrVaamCz2vxCfdQBasLZfHKk= github.com/OpenAudio/go-openaudio v1.8.2-0.20260727214803-1d9f69772e87 h1:HiLw4qrUkVWRADJjGsdHLlFMdJjhU5WCVNAfeKfww4s= github.com/OpenAudio/go-openaudio v1.8.2-0.20260727214803-1d9f69772e87/go.mod h1:lLRvUF5oWkxOyZx8rp/ecqxuMo3yzPvuvJbLSfvxguQ= -github.com/OpenAudio/go-openaudio/pkg/etl v1.6.4 h1:SkKNhfvPWOlEGBw6i1LvAF1iNA0Gdf5rf/9izr2Kbro= -github.com/OpenAudio/go-openaudio/pkg/etl v1.6.4/go.mod h1:z7X/5RziXEpASGTz7tD+P3pg4W5iCA1cKW7ogw8GShY= +github.com/OpenAudio/go-openaudio/pkg/etl v1.6.5-0.20260810163330-95f8e2ff0c66 h1:L5Db2Ht2XpUOBrU9RJZRxh3COkqJpPv9PVkRbJfND/o= +github.com/OpenAudio/go-openaudio/pkg/etl v1.6.5-0.20260810163330-95f8e2ff0c66/go.mod h1:z7X/5RziXEpASGTz7tD+P3pg4W5iCA1cKW7ogw8GShY= github.com/StackExchange/wmi v1.2.1 h1:VIkavFPXSjcnS+O8yTq7NI32k0R5Aj+v39y29VYDOSA= github.com/StackExchange/wmi v1.2.1/go.mod h1:rcmrprowKIVzvc+NUiLncP2uuArMWLCbu9SBzvHz7e8= github.com/VictoriaMetrics/fastcache v1.12.2 h1:N0y9ASrJ0F6h0QaC3o6uJb3NIZ9VKLjCM7NQbSmF7WI=