From 18801dd7fa3e74ae9447f5fa577ace455e85a7f4 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Fri, 21 Aug 2026 13:24:56 -0500 Subject: [PATCH 1/2] feat: music_generations table and a 100 MiB public-uploads limit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Schema for the /music end-to-end slice (recoupable/chat#1992, contract: recoupable/docs#308). Lands before the api PRs that read and write it. music_generations doubles as the run record for the workflow that produces each song, the way playcount_snapshots does: the API reads the row rather than the Workflow API, so one resource answers status, result, and the logs timeline. Ownership is account_id plus a nullable organization_id, both cascading — a generated song is user content, not a log, so it dies with its owner. The bucket limit is a real blocker rather than a nicety: MiniMax returns 44.1 kHz stereo WAV at about 10.6 MB per minute, so the existing 25 MiB cap would fail the upload for anything past roughly 148 seconds while the API accepts up to 300 - after fal had already rendered and charged. RLS is enabled with zero policies. The three most recent tables here skip that statement; these rows hold user prompts, lyrics, and a storage key, so this one does not copy that pattern. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017fSvwazBitPfsTQvVqpi8q --- ...0260821170000_create_music_generations.sql | 110 ++++++++++++++++++ ...170100_raise_public_uploads_size_limit.sql | 20 ++++ 2 files changed, 130 insertions(+) create mode 100644 supabase/migrations/20260821170000_create_music_generations.sql create mode 100644 supabase/migrations/20260821170100_raise_public_uploads_size_limit.sql diff --git a/supabase/migrations/20260821170000_create_music_generations.sql b/supabase/migrations/20260821170000_create_music_generations.sql new file mode 100644 index 0000000..49b413f --- /dev/null +++ b/supabase/migrations/20260821170000_create_music_generations.sql @@ -0,0 +1,110 @@ +-- Create music_generations: songs generated with MiniMax Music 3 on fal.ai, +-- and the run record for the workflow that produces them +-- (recoupable/chat#1992, contract: recoupable/docs#308). +-- +-- Nothing in the schema tracks generated media today. There is no fal, image, +-- video or audio generation table anywhere in these migrations, so every +-- column here is new rather than an extension of an existing shape. +-- +-- Why the row is also the run record: a generation takes roughly one to two +-- minutes, far past a request budget, so POST /api/music inserts a pending row +-- and hands the id to a Vercel Workflow. That is the same pattern +-- playcount_snapshots uses (20260610010000) — the API reads the row, never the +-- Workflow API — and it means one resource answers status, result, and +-- timeline with no second call and no dependency on Workflow run retention. +-- +-- Why real cascading foreign keys rather than the loose ids on +-- apify_scraper_runs and email_send_log: those are logs, where outliving the +-- account is the point. A generated song is user content, so it follows +-- catalog_valuations (20260729230000) and dies with its owner. +-- +-- Scope is personal-or-organization only. There is deliberately no +-- artist_account_id: the artist axis was dropped from v1 on a KISS call +-- (chat#1992, 2026-08-21), and adding the column now would ship a nullable +-- field nothing writes. + +CREATE TABLE IF NOT EXISTS public.music_generations ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + -- The account the generation belongs to, after the standard account_id + -- override has been resolved — not necessarily the caller. + account_id UUID NOT NULL REFERENCES public.accounts(id) ON DELETE CASCADE, + -- Organization context captured at creation. NULL means a personal + -- generation. Stored rather than derived through account_organization_ids + -- so the gallery read stays a single indexed filter, and so moving an + -- account between organizations cannot retroactively reassign old songs. + organization_id UUID REFERENCES public.accounts(id) ON DELETE CASCADE, + -- TEXT + CHECK, not a Postgres enum: only two enum types exist across all + -- of these migrations and both are legacy. + status TEXT NOT NULL DEFAULT 'pending' + CHECK (status IN ('pending', 'processing', 'completed', 'failed')), + -- The generating model. Defaulted rather than hardcoded in the API so a + -- future model swap is a value change, not a contract change. + model TEXT NOT NULL DEFAULT 'minimax/music-3', + prompt TEXT NOT NULL, + lyrics TEXT NOT NULL, + -- Display title. NULL until the generation completes. + title TEXT, + -- What the caller asked for, versus what the model actually produced. The + -- model may stop early, so these genuinely differ and both are worth + -- keeping: the first explains the price charged, the second the audio. + requested_duration_seconds NUMERIC, + duration_seconds NUMERIC, + -- Generation parameters as resolved for the fal call, so a completed row + -- carries everything needed to reproduce it. seed is NULL until fal + -- reports the seed it actually used for a randomized request. + seed BIGINT, + num_inference_steps INTEGER, + guidance_scale NUMERIC, + -- fal's queue request id, for correlating with their dashboard when a + -- generation stalls. + fal_request_id TEXT, + workflow_run_id TEXT, + -- fal's CDN URL, kept as provenance. Third-party and may expire, so it is + -- never the thing we serve once the mirror below succeeds. + source_url TEXT, + -- Key inside the public-uploads bucket, once the audio is mirrored. NULL + -- until completed. UNIQUE because two rows pointing at one object would + -- make deletion unsafe. + storage_key TEXT UNIQUE, + mime_type TEXT, + file_size_bytes BIGINT, + credits_charged INTEGER, + -- Workflow timeline as [{at, message}], appended a step at a time. Lives + -- on the row rather than in the Workflow API so a stuck generation is + -- diagnosable from the resource alone, and so the timeline outlives + -- Workflow run retention. + logs JSONB NOT NULL DEFAULT '[]'::jsonb, + error_message TEXT, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now() +); + +-- The gallery read: one account's generations, newest first. +CREATE INDEX IF NOT EXISTS music_generations_account_created_idx + ON public.music_generations (account_id, created_at DESC); + +-- The organization-scoped variant of the same read. +CREATE INDEX IF NOT EXISTS music_generations_organization_created_idx + ON public.music_generations (organization_id, created_at DESC) + WHERE organization_id IS NOT NULL; + +-- Sweeping for in-flight work: rows stuck in pending or processing. +CREATE INDEX IF NOT EXISTS music_generations_status_created_idx + ON public.music_generations (status, created_at DESC); + +-- Correlating a fal webhook or a support question back to the row. +CREATE INDEX IF NOT EXISTS music_generations_fal_request_idx + ON public.music_generations (fal_request_id) + WHERE fal_request_id IS NOT NULL; + +CREATE TRIGGER set_updated_at + BEFORE UPDATE ON public.music_generations + FOR EACH ROW EXECUTE FUNCTION trigger_set_updated_at(); + +-- RLS on with zero policies: denies anon and authenticated outright while +-- service_role, which is how the API writes and reads, bypasses it. These rows +-- hold user-authored prompts and lyrics plus a storage key, so leaving the +-- table reachable through PostgREST with the anon key would expose one +-- account's songs to any other. The three most recent tables here skip the +-- statement; that is the pattern this one deliberately does not copy. +ALTER TABLE public.music_generations ENABLE ROW LEVEL SECURITY; diff --git a/supabase/migrations/20260821170100_raise_public_uploads_size_limit.sql b/supabase/migrations/20260821170100_raise_public_uploads_size_limit.sql new file mode 100644 index 0000000..f6a01a4 --- /dev/null +++ b/supabase/migrations/20260821170100_raise_public_uploads_size_limit.sql @@ -0,0 +1,20 @@ +-- Raise the public-uploads size limit from 25 MiB to 100 MiB so generated +-- audio fits (recoupable/chat#1992). +-- +-- MiniMax Music 3 returns 44.1 kHz 16-bit stereo WAV, which is about +-- 10.6 MB per minute. Against the 25 MiB limit set in 20260508151035 that caps +-- a mirrored song at roughly 148 seconds, while the API accepts a requested +-- duration of up to 300. Without this, a long generation renders successfully +-- on fal, is charged for, and then fails at the upload step — the worst +-- possible place to discover the limit. +-- +-- 100 MiB covers a 300-second WAV (about 53 MB) with room for the other audio +-- types the bucket already allows. The allowed_mime_types list is untouched: +-- audio/wav and audio/mpeg were permitted from the start. +-- +-- Idempotent: safe to re-apply. + +update storage.buckets + set file_size_limit = 104857600 -- 100 MiB + where id = 'public-uploads' + and (file_size_limit is null or file_size_limit < 104857600); From 25a5d5ca976fcbea42111736a5a422c842c1c381 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Fri, 21 Aug 2026 16:50:34 -0500 Subject: [PATCH 2/2] refactor: cut music_generations from 24 columns to 13 Review feedback on KISS and DRY. Everything another system already knows comes out of the table. Dropped: organization_id (organizations are accounts, so account_id alone carries scope), requested_duration_seconds, num_inference_steps, guidance_scale and seed (parameters ride along as workflow arguments; the resolved seed is in fal's result), credits_charged (usage_events is the ledger), mime_type and file_size_bytes (constant, and the storage object knows its own size), source_url (dead the moment the mirror lands), title (nothing ever wrote it), and logs (the workflow run is the timeline; workflow_run_id is the handle). Kept error_message deliberately: the gallery lists failures and cannot make a call per row, and a failed row with no reason is a dead end. Also from review: DROP TRIGGER IF EXISTS before CREATE TRIGGER, which has no IF NOT EXISTS and would fail on a re-run; a positive-duration CHECK; and 64 MiB rather than 100 on the bucket, sized to the longest song we accept, since the limit is per bucket rather than per MIME type. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017fSvwazBitPfsTQvVqpi8q --- ...0260821170000_create_music_generations.sql | 122 +++++++----------- ...170100_raise_public_uploads_size_limit.sql | 32 +++-- 2 files changed, 63 insertions(+), 91 deletions(-) diff --git a/supabase/migrations/20260821170000_create_music_generations.sql b/supabase/migrations/20260821170000_create_music_generations.sql index 49b413f..adb2d5b 100644 --- a/supabase/migrations/20260821170000_create_music_generations.sql +++ b/supabase/migrations/20260821170000_create_music_generations.sql @@ -1,110 +1,76 @@ --- Create music_generations: songs generated with MiniMax Music 3 on fal.ai, --- and the run record for the workflow that produces them +-- Create music_generations: songs generated with MiniMax Music 3 on fal.ai -- (recoupable/chat#1992, contract: recoupable/docs#308). -- --- Nothing in the schema tracks generated media today. There is no fal, image, --- video or audio generation table anywhere in these migrations, so every --- column here is new rather than an extension of an existing shape. +-- Nothing in this schema tracks generated media today, so every column is new +-- rather than an extension of an existing shape. -- -- Why the row is also the run record: a generation takes roughly one to two -- minutes, far past a request budget, so POST /api/music inserts a pending row --- and hands the id to a Vercel Workflow. That is the same pattern --- playcount_snapshots uses (20260610010000) — the API reads the row, never the --- Workflow API — and it means one resource answers status, result, and --- timeline with no second call and no dependency on Workflow run retention. +-- and hands the id to a Vercel Workflow. Same pattern as playcount_snapshots +-- (20260610010000) - the API reads the row, never the Workflow API. -- --- Why real cascading foreign keys rather than the loose ids on --- apify_scraper_runs and email_send_log: those are logs, where outliving the --- account is the point. A generated song is user content, so it follows --- catalog_valuations (20260729230000) and dies with its owner. +-- Deliberately narrow. Anything another system already knows is not stored +-- here: generation parameters ride along as workflow arguments, the seed and +-- the step timeline are readable from fal_request_id and workflow_run_id, and +-- credits are accounted in usage_events. What stays is what the gallery has to +-- render without making a call per row. -- --- Scope is personal-or-organization only. There is deliberately no --- artist_account_id: the artist axis was dropped from v1 on a KISS call --- (chat#1992, 2026-08-21), and adding the column now would ship a nullable --- field nothing writes. +-- Scope needs no second column. Organizations are accounts in this schema, so +-- an organization's song is one whose account_id is that organization; the +-- membership join tables already say which accounts are organizations. CREATE TABLE IF NOT EXISTS public.music_generations ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - -- The account the generation belongs to, after the standard account_id - -- override has been resolved — not necessarily the caller. - account_id UUID NOT NULL REFERENCES public.accounts(id) ON DELETE CASCADE, - -- Organization context captured at creation. NULL means a personal - -- generation. Stored rather than derived through account_organization_ids - -- so the gallery read stays a single indexed filter, and so moving an - -- account between organizations cannot retroactively reassign old songs. - organization_id UUID REFERENCES public.accounts(id) ON DELETE CASCADE, + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + -- The owning account, after the standard account_id override resolves. + -- A person or an organization; nothing here needs to know which. + account_id UUID NOT NULL REFERENCES public.accounts(id) ON DELETE CASCADE, -- TEXT + CHECK, not a Postgres enum: only two enum types exist across all -- of these migrations and both are legacy. - status TEXT NOT NULL DEFAULT 'pending' + status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'processing', 'completed', 'failed')), - -- The generating model. Defaulted rather than hardcoded in the API so a - -- future model swap is a value change, not a contract change. - model TEXT NOT NULL DEFAULT 'minimax/music-3', - prompt TEXT NOT NULL, - lyrics TEXT NOT NULL, - -- Display title. NULL until the generation completes. - title TEXT, - -- What the caller asked for, versus what the model actually produced. The - -- model may stop early, so these genuinely differ and both are worth - -- keeping: the first explains the price charged, the second the audio. - requested_duration_seconds NUMERIC, - duration_seconds NUMERIC, - -- Generation parameters as resolved for the fal call, so a completed row - -- carries everything needed to reproduce it. seed is NULL until fal - -- reports the seed it actually used for a randomized request. - seed BIGINT, - num_inference_steps INTEGER, - guidance_scale NUMERIC, - -- fal's queue request id, for correlating with their dashboard when a - -- generation stalls. - fal_request_id TEXT, - workflow_run_id TEXT, - -- fal's CDN URL, kept as provenance. Third-party and may expire, so it is - -- never the thing we serve once the mirror below succeeds. - source_url TEXT, + -- Provenance for immutable content. A song made by one model has to stay + -- attributable once a second model exists, and it cannot be backfilled. + model TEXT NOT NULL DEFAULT 'minimax/music-3', + prompt TEXT NOT NULL, + lyrics TEXT NOT NULL, + -- Actual length, reported by fal. Rendered on every gallery card, so it is + -- stored rather than fetched per row. + duration_seconds NUMERIC CHECK (duration_seconds IS NULL OR duration_seconds > 0), -- Key inside the public-uploads bucket, once the audio is mirrored. NULL -- until completed. UNIQUE because two rows pointing at one object would -- make deletion unsafe. - storage_key TEXT UNIQUE, - mime_type TEXT, - file_size_bytes BIGINT, - credits_charged INTEGER, - -- Workflow timeline as [{at, message}], appended a step at a time. Lives - -- on the row rather than in the Workflow API so a stuck generation is - -- diagnosable from the resource alone, and so the timeline outlives - -- Workflow run retention. - logs JSONB NOT NULL DEFAULT '[]'::jsonb, - error_message TEXT, - created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), - updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now() + storage_key TEXT UNIQUE, + -- Handles to the two external systems this generation touches: fal for the + -- request itself, Vercel Workflow for the run that drove it. Different + -- systems answer different questions, so both are kept. + fal_request_id TEXT, + workflow_run_id TEXT, + -- Why a generation failed, in terms a user can act on. The one thing the + -- workflow cannot answer cheaply: the gallery lists failures and cannot + -- make a call per row, and a failed row with no reason is a dead end. + error_message TEXT, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now() ); -- The gallery read: one account's generations, newest first. CREATE INDEX IF NOT EXISTS music_generations_account_created_idx ON public.music_generations (account_id, created_at DESC); --- The organization-scoped variant of the same read. -CREATE INDEX IF NOT EXISTS music_generations_organization_created_idx - ON public.music_generations (organization_id, created_at DESC) - WHERE organization_id IS NOT NULL; - --- Sweeping for in-flight work: rows stuck in pending or processing. +-- Sweeping for work still in flight. CREATE INDEX IF NOT EXISTS music_generations_status_created_idx ON public.music_generations (status, created_at DESC); --- Correlating a fal webhook or a support question back to the row. -CREATE INDEX IF NOT EXISTS music_generations_fal_request_idx - ON public.music_generations (fal_request_id) - WHERE fal_request_id IS NOT NULL; - +-- CREATE TRIGGER has no IF NOT EXISTS, so re-applying this file would fail +-- here even though every statement above is idempotent. +DROP TRIGGER IF EXISTS set_updated_at ON public.music_generations; CREATE TRIGGER set_updated_at BEFORE UPDATE ON public.music_generations FOR EACH ROW EXECUTE FUNCTION trigger_set_updated_at(); -- RLS on with zero policies: denies anon and authenticated outright while --- service_role, which is how the API writes and reads, bypasses it. These rows +-- service_role, which is how the API reads and writes, bypasses it. These rows -- hold user-authored prompts and lyrics plus a storage key, so leaving the -- table reachable through PostgREST with the anon key would expose one --- account's songs to any other. The three most recent tables here skip the --- statement; that is the pattern this one deliberately does not copy. +-- account's songs to any other. ALTER TABLE public.music_generations ENABLE ROW LEVEL SECURITY; diff --git a/supabase/migrations/20260821170100_raise_public_uploads_size_limit.sql b/supabase/migrations/20260821170100_raise_public_uploads_size_limit.sql index f6a01a4..849d8be 100644 --- a/supabase/migrations/20260821170100_raise_public_uploads_size_limit.sql +++ b/supabase/migrations/20260821170100_raise_public_uploads_size_limit.sql @@ -1,20 +1,26 @@ --- Raise the public-uploads size limit from 25 MiB to 100 MiB so generated --- audio fits (recoupable/chat#1992). +-- Raise the public-uploads size limit from 25 MiB to 64 MiB so generated audio +-- fits (recoupable/chat#1992). -- --- MiniMax Music 3 returns 44.1 kHz 16-bit stereo WAV, which is about --- 10.6 MB per minute. Against the 25 MiB limit set in 20260508151035 that caps --- a mirrored song at roughly 148 seconds, while the API accepts a requested --- duration of up to 300. Without this, a long generation renders successfully --- on fal, is charged for, and then fails at the upload step — the worst --- possible place to discover the limit. +-- MiniMax Music 3 returns 44.1 kHz 16-bit stereo WAV, about 10.6 MB per +-- minute. Against the 25 MiB limit set in 20260508151035 that caps a mirrored +-- song at roughly 148 seconds, while the API accepts a requested duration of +-- up to 300. Without this, a long generation renders on fal, is charged for, +-- and then fails at the upload step. -- --- 100 MiB covers a 300-second WAV (about 53 MB) with room for the other audio --- types the bucket already allows. The allowed_mime_types list is untouched: --- audio/wav and audio/mpeg were permitted from the start. +-- 64 MiB is sized to the longest song we accept (300 seconds is about 50.5 +-- MiB) and no further, rather than a round 100. The limit is per bucket, not +-- per MIME type, so every raise also raises the ceiling for the images, PDFs +-- and CSVs that share this bucket - keeping the number tight keeps that blast +-- radius small. A separate audio-only bucket would scope it exactly, at the +-- cost of a second bucket, its own keys and a second upload path; not worth it +-- for a 39 MiB difference on an API-gated bucket. +-- +-- allowed_mime_types is untouched: audio/wav and audio/mpeg were permitted +-- from the start. -- -- Idempotent: safe to re-apply. update storage.buckets - set file_size_limit = 104857600 -- 100 MiB + set file_size_limit = 67108864 -- 64 MiB where id = 'public-uploads' - and (file_size_limit is null or file_size_limit < 104857600); + and (file_size_limit is null or file_size_limit < 67108864);