T-HUB/apps/api/migrations/022_live_destinations.sql
glass beadian aa5e262f3a Tactical Media Broadcast Hub — initial release
Self-hosted multi-platform broadcast newsroom for activist collectives:
publishing pipeline with group approval and DST-proof scheduling, TweetDeck-style
column wall with hostile-content-safe rendering, live control room with Auto
Director, bridged chat governance with polls and quorum, distributed sandboxed
transcode workers, encrypted content-addressed archive, monthly analytics.

MIT licensed. See README.md and docs/.
2026-10-10 11:56:42 +00:00

136 lines
7.2 KiB
PL/PgSQL

-- 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;