-- 023: WP7 bulk source import + live-settings guard. -- -- Three additive pieces, one migration: -- -- (1) `virtual_channel_sources` (013) is widened to admit *imported* sources. -- 013's CHECK only modelled hand-entered HLS URLs and local playlists; a -- PeerTube channel/live imported in bulk carries `remote_ref` + -- `connection_id` and has an HLS URL only once a live is resolvable. The -- dedupe key UNIQUE (workspace_id, virtual_channel_id, remote_ref) from -- 013 is unchanged and stays the only dedupe authority: imports write -- INSERT ... ON CONFLICT DO NOTHING RETURNING id. -- -- (2) `virtual_channel_imports`: one row per bulk import request. The HTTP -- request only parses + validates + creates the job row; resolution and -- inserts run in the background, and the row is the progress/report -- document (processed/total + imported/duplicate/invalid/failed counts -- and a bounded per-entry outcome list). A `running` row whose heartbeat -- (`updated_at`) went stale is reaped to `abandoned` on read, so a -- process crash never leaves a job spinning forever. -- -- (3) `virtual_channel_live_settings`: the channel's live output settings -- (profile/resolution, latency, replay policy) behind an optimistic -- concurrency `version`. Writers take the channel row FOR UPDATE (one -- writer per channel), then CAS on `version`; a stale version or a -- started live is a 409 with nothing written. The CHECKs mirror the -- app-side validator (WP7 acceptance: permanentLive + saveReplay and an -- out-of-range latencyMode are rejected by the DB on a direct insert -- too), and a trigger keeps `version` monotone. -- -- House style throughout: tenant-composite keys (id, workspace_id), composite -- FKs that pin every child to one workspace's channel, reject_workspace_change -- / reject_owner_change immutability, FORCE RLS with the NULLIF tenant policy, -- grants to sp_app (003 adds the default-privilege side for future tables). SET LOCAL lock_timeout = '5s'; -- (1) Imported sources: kind 'hls' now requires an HLS URL *or* a remote_ref -- (a channel imported now, playback attached when its live starts). The -- playlist arm is untouched: local, connectionless, remote_ref-less. ALTER TABLE virtual_channel_sources DROP CONSTRAINT virtual_channel_sources_check; ALTER TABLE virtual_channel_sources ADD CONSTRAINT virtual_channel_sources_check CHECK ( ( kind = 'hls' AND playlist_loop IS NULL AND (hls_url IS NOT NULL OR remote_ref IS NOT NULL) ) OR ( kind = 'playlist' AND hls_url IS NULL AND remote_ref IS NULL AND connection_id IS NULL AND playlist_loop IS NOT NULL ) ); -- (2) Import jobs. CREATE TABLE virtual_channel_imports ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), workspace_id uuid NOT NULL, virtual_channel_id uuid NOT NULL, -- Connection the import resolves through (default host for bare handles). connection_id uuid, status text NOT NULL DEFAULT 'running' CHECK (status IN ('running', 'completed', 'failed', 'abandoned')), -- total counts every input row (valid or not); processed advances per row. total int NOT NULL DEFAULT 0 CHECK (total >= 0 AND total <= 5000), processed int NOT NULL DEFAULT 0 CHECK (processed >= 0 AND processed <= total), imported int NOT NULL DEFAULT 0 CHECK (imported >= 0), duplicate int NOT NULL DEFAULT 0 CHECK (duplicate >= 0), invalid int NOT NULL DEFAULT 0 CHECK (invalid >= 0), failed int NOT NULL DEFAULT 0 CHECK (failed >= 0), -- Bounded per-entry report (first IMPORT_REPORT_ENTRY_LIMIT rows): what the -- user pasted, what happened, why. Counts are always authoritative. entries jsonb NOT NULL DEFAULT '[]'::jsonb CHECK (jsonb_typeof(entries) = 'array'), error text, created_by uuid, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), completed_at timestamptz, UNIQUE (id, workspace_id), FOREIGN KEY (workspace_id, virtual_channel_id) REFERENCES virtual_channels (workspace_id, id) ON DELETE CASCADE ON UPDATE RESTRICT, FOREIGN KEY (connection_id, workspace_id) REFERENCES connections (id, workspace_id) ON DELETE NO ACTION ON UPDATE RESTRICT DEFERRABLE INITIALLY DEFERRED ); CREATE TRIGGER trg_vci_ws_immutable BEFORE UPDATE ON virtual_channel_imports FOR EACH ROW EXECUTE FUNCTION reject_workspace_change(); CREATE TRIGGER trg_vci_owner_immutable BEFORE UPDATE ON virtual_channel_imports FOR EACH ROW EXECUTE FUNCTION reject_owner_change('virtual_channel_id'); ALTER TABLE virtual_channel_imports ENABLE ROW LEVEL SECURITY; ALTER TABLE virtual_channel_imports FORCE ROW LEVEL SECURITY; CREATE POLICY tenant_isolation ON virtual_channel_imports USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid) WITH CHECK (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid); CREATE INDEX virtual_channel_imports_channel ON virtual_channel_imports (workspace_id, virtual_channel_id, created_at DESC); -- (3) Channel live settings, version-fenced. CREATE TABLE virtual_channel_live_settings ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), workspace_id uuid NOT NULL, virtual_channel_id uuid NOT NULL, -- Output shape: a named preset, or an explicit triple (custom). output_profile text NOT NULL DEFAULT '720p30' CHECK (output_profile IN ('720p30', '1080p30', '1080p60', 'custom')), width int CHECK (width IS NULL OR (width >= 16 AND width <= 7680 AND width % 2 = 0)), height int CHECK (height IS NULL OR (height >= 16 AND height <= 4320 AND height % 2 = 0)), fps int CHECK (fps IS NULL OR (fps >= 1 AND fps <= 120)), -- LiveVideoLatencyMode: 1=Default, 2=High latency, 3=Small latency. latency_mode int NOT NULL DEFAULT 1 CHECK (latency_mode IN (1, 2, 3)), permanent_live boolean NOT NULL DEFAULT false, save_replay boolean NOT NULL DEFAULT false, -- Replay privacy etc.; only meaningful while save_replay is on. replay_settings jsonb CHECK (replay_settings IS NULL OR jsonb_typeof(replay_settings) = 'object'), -- Latch: once the channel's live has started, settings are frozen (409). live_started_at timestamptz, -- Optimistic concurrency: every accepted write bumps version exactly once. version bigint NOT NULL DEFAULT 0 CHECK (version >= 0), updated_by uuid, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), UNIQUE (workspace_id, virtual_channel_id), UNIQUE (id, workspace_id), FOREIGN KEY (workspace_id, virtual_channel_id) REFERENCES virtual_channels (workspace_id, id) ON DELETE CASCADE ON UPDATE RESTRICT, CHECK ( (output_profile <> 'custom' AND width IS NULL AND height IS NULL AND fps IS NULL) OR (output_profile = 'custom' AND width IS NOT NULL AND height IS NOT NULL AND fps IS NOT NULL) ), -- PeerTube rejects saveReplay + permanentLive (400); we refuse it earlier -- and the DB refuses it for direct inserts. CHECK (NOT (permanent_live AND save_replay)), CHECK (save_replay OR replay_settings IS NULL) ); CREATE TRIGGER trg_vcls_ws_immutable BEFORE UPDATE ON virtual_channel_live_settings FOR EACH ROW EXECUTE FUNCTION reject_workspace_change(); CREATE TRIGGER trg_vcls_owner_immutable BEFORE UPDATE ON virtual_channel_live_settings FOR EACH ROW EXECUTE FUNCTION reject_owner_change('virtual_channel_id'); -- version never regresses; a stale CAS writer cannot rewind the fence. CREATE FUNCTION reject_live_settings_version_regression() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF NEW.version < OLD.version THEN RAISE EXCEPTION 'live settings version cannot decrease' USING ERRCODE = '23514'; END IF; RETURN NEW; END $$; CREATE TRIGGER trg_vcls_version_monotone BEFORE UPDATE ON virtual_channel_live_settings FOR EACH ROW EXECUTE FUNCTION reject_live_settings_version_regression(); ALTER TABLE virtual_channel_live_settings ENABLE ROW LEVEL SECURITY; ALTER TABLE virtual_channel_live_settings FORCE ROW LEVEL SECURITY; CREATE POLICY tenant_isolation ON virtual_channel_live_settings USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid) WITH CHECK (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid); GRANT SELECT, INSERT, UPDATE, DELETE ON virtual_channel_imports, virtual_channel_live_settings TO sp_app;