-- 022: WP6 live-stream destinations + stream outputs (go-live slice). -- -- Aggregate boundary (proposal §4.4, WP6/WP9): a `live_stream_destinations` -- row owns the platform-side live target — provider, ingest endpoint URL and -- the vaulted ingest credential — and never references media outputs. A -- `stream_outputs` row is a media-output fan-out from a virtual channel and -- references EITHER a destination (credential resolved through the -- destination at spawn) OR its own generic credential handle — never both -- (CHECK below, mirrored in packages/core/src/vc.ts and API validation). -- Enablement lives only on StreamOutput; the destination carries no -- enabled flag of its own, so "disable one output" never mutates the shared -- platform target. -- -- FK actions per the WP6 lifecycle rule: both cross-aggregate FKs are -- ON DELETE NO ACTION ON UPDATE RESTRICT; deletion is app-driven — 409 while -- a referencing output is enabled, otherwise (disabled output) the app -- disables dependents, revokes the ingest credential, then deletes the row. -- `credentials` gains the tenant-composite key this migration needs -- (tenant-composite FKs everywhere, per proposal §4.4/§4.5). -- -- Query catalog (owning WP: WP6 — packages/db does not exist in this repo, so -- the catalog lives here and in apps/api/src/live/live-destination.repository.ts): -- live.destinations.list keyset page ORDER BY label — served by -- live_destinations_ws_label; read path, p95 <= 20 ms. -- live.destinations.usage point lookup of referencing outputs by -- destination (tenant) — served by stream_outputs_destination partial -- index; read path, p95 <= 20 ms. -- live.outputs.channel channel fan-out list — served by -- stream_outputs_channel; read path, p95 <= 20 ms. BEGIN; -- 001 created credentials with only a surrogate PK; 018 added the -- tenant-composite key the cross-aggregate FK below requires. DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'credentials_id_workspace_key') THEN ALTER TABLE credentials ADD CONSTRAINT credentials_id_workspace_key UNIQUE (id, workspace_id); END IF; END $$; CREATE TABLE live_stream_destinations ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE ON UPDATE RESTRICT, label text NOT NULL CHECK (btrim(label) <> '' AND length(label) <= 120), -- Provider of the platform-side target (manifest id of a connector); the -- app resolves behaviour through the connector registry, never by branching -- on this value. provider text NOT NULL CHECK (btrim(provider) <> '' AND length(provider) <= 64), -- Ingest endpoint (rtmp/rtmps/srt). Validated app-side by the shared -- media-control endpoint shape validator before insert; the CHECKs here are -- coarse backstops only. endpoint_url text NOT NULL CHECK (endpoint_url ~ '^(rtmp|rtmps|srt)://' AND length(endpoint_url) <= 2048), -- Ingest credential (stream key etc.) vaulted in `credentials`; NULL only -- for providers with no key. Tenant-composite FK; deleting the credential -- row before the destination is a FK error by design (lifecycle revokes in -- order: destination first, then its credential). credential_id uuid, -- Derived, explicitly-stale platform state (runtime truth is Redis obs:*). last_platform_state text NOT NULL DEFAULT 'unknown' CHECK (last_platform_state IN ('unknown', 'created', 'waiting', 'live', 'ended')), created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), UNIQUE (workspace_id, id), FOREIGN KEY (credential_id, workspace_id) REFERENCES credentials (id, workspace_id) ON DELETE NO ACTION ON UPDATE RESTRICT ); CREATE TRIGGER trg_live_dest_ws_immutable BEFORE UPDATE ON live_stream_destinations FOR EACH ROW EXECUTE FUNCTION reject_workspace_change(); -- Same fail-closed tenant isolation as every tenant table (001/021 style): -- no app.workspace_id => NULL => no rows. FORCE binds sp_app too. ALTER TABLE live_stream_destinations ENABLE ROW LEVEL SECURITY; ALTER TABLE live_stream_destinations FORCE ROW LEVEL SECURITY; CREATE POLICY tenant_isolation ON live_stream_destinations USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid) WITH CHECK (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid); -- Per-workspace listing ordered by label (live.destinations.list). CREATE INDEX live_destinations_ws_label ON live_stream_destinations (workspace_id, label); -- Media-output fan-out of a virtual channel onto one live target. CREATE TABLE stream_outputs ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE ON UPDATE RESTRICT, virtual_channel_id uuid NOT NULL, label text NOT NULL CHECK (btrim(label) <> '' AND length(label) <= 120), -- Output profile reference; resolution is app-side. profile text NOT NULL DEFAULT '720p30' CHECK (profile IN ('720p30', '1080p30', '1080p60')), mode text NOT NULL DEFAULT 'passthrough' CHECK (mode IN ('passthrough', 'normalize')), -- Enablement lives ONLY here (WP9): a disabled output stops pushing but the -- destination row and its credential survive untouched. enabled boolean NOT NULL DEFAULT true, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), -- Exactly one of (destination_id, credential_handle_id): destination- -- referenced outputs resolve the ingest credential through the destination -- at spawn; generic outputs carry their own opaque vault handle. The -- NOT-VALID backstop below is validated app-side (021-style split). destination_id uuid, credential_handle_id text, CONSTRAINT stream_outputs_target_exclusive CHECK ( (destination_id IS NULL) <> (credential_handle_id IS NULL) ), CONSTRAINT stream_outputs_handle_shape CHECK ( credential_handle_id IS NULL OR credential_handle_id ~ '^vh_[A-Za-z0-9_-]{16,128}$' ), UNIQUE (workspace_id, id), UNIQUE (workspace_id, virtual_channel_id, id), FOREIGN KEY (workspace_id, virtual_channel_id) REFERENCES virtual_channels (workspace_id, id) ON DELETE CASCADE ON UPDATE RESTRICT, FOREIGN KEY (destination_id, workspace_id) REFERENCES live_stream_destinations (id, workspace_id) ON DELETE NO ACTION ON UPDATE RESTRICT ); CREATE TRIGGER trg_stream_outputs_ws_immutable BEFORE UPDATE ON stream_outputs FOR EACH ROW EXECUTE FUNCTION reject_workspace_change(); ALTER TABLE stream_outputs ENABLE ROW LEVEL SECURITY; ALTER TABLE stream_outputs FORCE ROW LEVEL SECURITY; CREATE POLICY tenant_isolation ON stream_outputs USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid) WITH CHECK (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid); -- References by destination (live.destinations.usage, 409 on delete). CREATE INDEX stream_outputs_destination ON stream_outputs (workspace_id, destination_id) WHERE destination_id IS NOT NULL; -- Channel fan-out listing (live.outputs.channel). CREATE INDEX stream_outputs_channel ON stream_outputs (workspace_id, virtual_channel_id); GRANT SELECT, INSERT, UPDATE, DELETE ON live_stream_destinations TO sp_app; GRANT SELECT, INSERT, UPDATE, DELETE ON stream_outputs TO sp_app; COMMIT;