From ffcad620077c363ef7d33536ca559f177f36144e Mon Sep 17 00:00:00 2001 From: Dylan Jeffers Date: Mon, 27 Jul 2026 13:44:48 -0700 Subject: [PATCH] fix: stop /v1/tracks/{id}/stems 500ing when stem_of is NULL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A stems join row can outlive its track's stem_of jsonb (an explicit "stem_of": null in a client update wipes the column without touching the stems table — same class of bug as the CID wipe in OpenAudio/go-openaudio#410). category and parent_track_id then come back NULL, the row scan fails, and the endpoint 500s — hiding every stem on the parent, not just the wiped one. This is the remaining NULL-scan hazard after #973 covered orig_filename. COALESCE category/track_cid and read parent_track_id from the stems join key, which is never NULL. Co-Authored-By: Claude Fable 5 --- api/v1_track_stems.go | 14 +++++++--- api/v1_track_stems_test.go | 54 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 3 deletions(-) diff --git a/api/v1_track_stems.go b/api/v1_track_stems.go index a36dccd1..2484e389 100644 --- a/api/v1_track_stems.go +++ b/api/v1_track_stems.go @@ -17,12 +17,20 @@ type TrackStem struct { } func (app *ApiServer) v1TrackStems(c *fiber.Ctx) error { + // Every text/int column here must tolerate NULL: rows written by the Go + // ETL (post the July 2026 Python cutover) can lack orig_filename, and a + // stem_of wiped by an explicit-null client update (see + // OpenAudio/go-openaudio#410 for the same class of bug on CIDs) leaves + // category/parent_track_id NULL while the stems join row survives. A + // single NULL scanned into a non-pointer field fails the whole request, + // which renders every stem on the parent invisible. parent_track_id + // therefore comes from the stems join key, which is never NULL. sql := ` SELECT t.track_id, - t.stem_of->>'category' AS category, - (t.stem_of->>'parent_track_id')::int AS parent_track_id, - t.track_cid, + COALESCE(t.stem_of->>'category', '') AS category, + s.parent_track_id, + COALESCE(t.track_cid, '') AS track_cid, t.owner_id, t.blocknumber, COALESCE(t.orig_filename, t.title, '') AS orig_filename diff --git a/api/v1_track_stems_test.go b/api/v1_track_stems_test.go index 0a7eb013..dc531893 100644 --- a/api/v1_track_stems_test.go +++ b/api/v1_track_stems_test.go @@ -100,3 +100,57 @@ func TestGetTrackStems(t *testing.T) { "data.2.user_id": trashid.MustEncodeHashID(1), }) } + +// A stems join row can outlive its track's stem_of jsonb: an explicit +// "stem_of": null in a client update wipes the column (same class of bug as +// the CID wipe fixed in OpenAudio/go-openaudio#410) without touching the +// stems table. category and parent_track_id then come back NULL from the +// jsonb, and a NULL scanned into a non-pointer field used to fail the whole +// request — hiding every stem on the parent, not just the wiped one. +func TestGetTrackStemsWipedStemOf(t *testing.T) { + app := emptyTestApp(t) + + fixtures := database.FixtureMap{ + "users": { + { + "user_id": 10, + }, + }, + "tracks": { + { + "track_id": 11, + "owner_id": 10, + }, + { + "track_id": 12, + "owner_id": 10, + "title": " Backing Vox 2.wav", + "track_cid": "etlcid1", + "blocknumber": 101, + // no stem_of and no orig_filename: the link survives only in + // the stems join row + }, + }, + "stems": { + { + "child_track_id": 12, + "parent_track_id": 11, + }, + }, + } + + database.Seed(app.pool.Replicas[0], fixtures) + + status, body := testGet(t, app, fmt.Sprintf("/v1/full/tracks/%s/stems", trashid.MustEncodeHashID(11))) + assert.Equal(t, 200, status) + + jsonAssert(t, body, map[string]any{ + "data.#": 1, + "data.0.id": trashid.MustEncodeHashID(12), + "data.0.parent_id": trashid.MustEncodeHashID(11), + "data.0.category": "", + "data.0.orig_filename": " Backing Vox 2.wav", + "data.0.cid": "etlcid1", + "data.0.user_id": trashid.MustEncodeHashID(10), + }) +}