-- 013: Streaming slice S1 — production schema (docs/design/streaming-architecture.md -- §2, §4, §6 S1; proposal §4.4 same-channel invariant). -- -- No virtual channel or channel-source table existed before this migration, so -- 013 creates the minimal `virtual_channels` / `virtual_channel_sources` the -- production tables hang off. `virtual_channel_sources.kind` is born with -- `playlist` alongside `hls` (§5.1: a playlist is an ordinary ingest kind). -- -- Same-channel invariant (structural, not a handler check): every production -- row carries `virtual_channel_id`, and every reference to a scene, source or -- overlay goes through a composite FK that includes (workspace_id, -- virtual_channel_id). A slot can only reference a source of its own channel, -- a bus can only hold a scene of its own channel, an overlay revision can only -- belong to an overlay slot of its own channel — a cross-channel write fails -- at the constraint, whichever code path issued it. -- -- Guard FKs (a reference that must block a direct delete but not a purge) -- are NO ACTION *and* DEFERRABLE INITIALLY DEFERRED. Plain NO ACTION is -- checked when each cascade step's RI trigger fires, so a workspace purge that -- reaches video_assets before it reaches playlist_items (deeper in the -- workspace -> channel -> source -> item chain) aborts mid-cascade. Deferred, -- the check runs at commit: a purge succeeds, a direct delete still fails. -- -- Geometry is integer basis points of the output frame (0..10000), matching -- the desired-state v2 type layer (apps/api/src/media-control/production.ts). -- Video assets get the composite key that playlist items reference, so a -- playlist can never name another tenant's asset by raw id. ALTER TABLE video_assets ADD CONSTRAINT video_assets_id_workspace_key UNIQUE (id, workspace_id); -- Ownership columns (the row's parent aggregate) are immutable: moving a row -- to another parent is delete + create. A column listed with a leading '?' is -- an `ON DELETE SET NULL` reference: only its non-null -> NULL transition (the -- FK action itself) is permitted. CREATE FUNCTION reject_owner_change() RETURNS trigger LANGUAGE plpgsql AS $$ DECLARE col text; nullable boolean; old_value text; new_value text; BEGIN FOREACH col IN ARRAY TG_ARGV LOOP nullable := left(col, 1) = '?'; IF nullable THEN col := substr(col, 2); END IF; old_value := to_jsonb(OLD) ->> col; new_value := to_jsonb(NEW) ->> col; IF new_value IS DISTINCT FROM old_value AND NOT (nullable AND new_value IS NULL) THEN RAISE EXCEPTION '%.% is immutable', TG_TABLE_NAME, col USING ERRCODE = '23514'; END IF; END LOOP; RETURN NEW; END $$; CREATE TABLE virtual_channels ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE ON UPDATE RESTRICT, slug text NOT NULL CHECK (slug ~ '^[a-z0-9][a-z0-9-]{0,62}$'), name text NOT NULL CHECK (btrim(name) <> '' AND length(name) <= 120), desired_status text NOT NULL DEFAULT 'STOPPED' CHECK (desired_status IN ('STOPPED', 'RUNNING')), observed_status text NOT NULL DEFAULT 'STOPPED' CHECK (observed_status IN ('PENDING_CAPACITY', 'STOPPED', 'STARTING', 'RUNNING', 'DEGRADED', 'RESTARTING', 'FAILED', 'UNKNOWN')), -- The single per-channel desiredVersion fence: every desired-state tx -- (TAKE, scene edit, overlay swap, gain step) locks this row FOR UPDATE and -- bumps it once. desired_version bigint NOT NULL DEFAULT 0 CHECK (desired_version >= 0), observed_version bigint NOT NULL DEFAULT 0 CHECK (observed_version >= 0), created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), UNIQUE (workspace_id, slug), UNIQUE (workspace_id, id) ); CREATE TABLE virtual_channel_sources ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), workspace_id uuid NOT NULL, virtual_channel_id uuid NOT NULL, kind text NOT NULL CHECK (kind IN ('hls', 'playlist')), label text NOT NULL CHECK (label ~ '^[[:alnum:]][[:alnum:] _.-]{0,63}$'), priority int NOT NULL DEFAULT 0 CHECK (priority >= 0), -- Connection the source was imported through (a candidate poller); NULL for -- playlists and hand-entered URLs. connection_id uuid, -- Remote identity (import dedupe); a playlist is local and has none. remote_ref text CHECK (remote_ref IS NULL OR btrim(remote_ref) <> ''), hls_url text CHECK (hls_url IS NULL OR (hls_url ~ '^https://' AND length(hls_url) <= 2048)), playlist_loop boolean, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), CHECK ( (kind = 'hls' AND hls_url IS NOT NULL AND playlist_loop IS 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) ), UNIQUE (workspace_id, virtual_channel_id, remote_ref), 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 (connection_id, workspace_id) REFERENCES connections (id, workspace_id) ON DELETE NO ACTION ON UPDATE RESTRICT DEFERRABLE INITIALLY DEFERRED ); CREATE INDEX virtual_channel_sources_priority ON virtual_channel_sources (workspace_id, virtual_channel_id, priority); CREATE INDEX virtual_channel_sources_connection ON virtual_channel_sources (connection_id, workspace_id) WHERE connection_id IS NOT NULL; -- Ordered, pre-normalized assets of a playlist source (the worker's asset feeder walks these). CREATE TABLE playlist_items ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), workspace_id uuid NOT NULL, virtual_channel_id uuid NOT NULL, source_id uuid NOT NULL, position int NOT NULL CHECK (position >= 0 AND position < 500), video_asset_id uuid NOT NULL, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), UNIQUE (workspace_id, source_id, position), UNIQUE (workspace_id, id), FOREIGN KEY (workspace_id, virtual_channel_id, source_id) REFERENCES virtual_channel_sources (workspace_id, virtual_channel_id, id) ON DELETE CASCADE ON UPDATE RESTRICT, FOREIGN KEY (video_asset_id, workspace_id) REFERENCES video_assets (id, workspace_id) ON DELETE NO ACTION ON UPDATE RESTRICT DEFERRABLE INITIALLY DEFERRED ); CREATE INDEX playlist_items_asset ON playlist_items (video_asset_id, workspace_id); CREATE TABLE production_scenes ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), workspace_id uuid NOT NULL, virtual_channel_id uuid NOT NULL, label text NOT NULL CHECK (label ~ '^[[:alnum:]][[:alnum:] _.-]{0,63}$'), layout text NOT NULL CHECK (layout IN ('single', 'pip', '2x1', '2x2', '3x3', '4x4', 'custom')), created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), 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 ); CREATE INDEX production_scenes_channel ON production_scenes (workspace_id, virtual_channel_id); CREATE TABLE scene_slots ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), workspace_id uuid NOT NULL, virtual_channel_id uuid NOT NULL, scene_id uuid NOT NULL, slot_index int NOT NULL CHECK (slot_index >= 0 AND slot_index < 16), -- NULL = empty slot (renders the fallback); also the ON DELETE SET NULL target. source_id uuid, -- Explicit rectangle for custom layouts and PIP insets; all NULL = the layout's own cell. x int, y int, w int, h int, fit text NOT NULL DEFAULT 'contain' CHECK (fit IN ('contain', 'cover', 'crop')), audio_mode text NOT NULL DEFAULT 'off' CHECK (audio_mode IN ('off', 'on', 'primary')), gain_db smallint NOT NULL DEFAULT 0 CHECK (gain_db BETWEEN -24 AND 0 AND gain_db % 3 = 0), created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), CHECK ((x IS NULL AND y IS NULL AND w IS NULL AND h IS NULL) OR (x IS NOT NULL AND y IS NOT NULL AND w IS NOT NULL AND h IS NOT NULL)), CHECK (x >= 0 AND y >= 0 AND w > 0 AND h > 0), CHECK (x + w <= 10000 AND y + h <= 10000), UNIQUE (workspace_id, scene_id, slot_index), UNIQUE (workspace_id, id), FOREIGN KEY (workspace_id, virtual_channel_id, scene_id) REFERENCES production_scenes (workspace_id, virtual_channel_id, id) ON DELETE CASCADE ON UPDATE RESTRICT, -- Column-list SET NULL (PostgreSQL >= 15): empties the slot, keeps tenant + channel set. FOREIGN KEY (workspace_id, virtual_channel_id, source_id) REFERENCES virtual_channel_sources (workspace_id, virtual_channel_id, id) ON DELETE SET NULL (source_id) ON UPDATE RESTRICT ); -- At most one primary audio slot per scene (program mix default: primary-only). CREATE UNIQUE INDEX scene_slots_one_primary ON scene_slots (workspace_id, scene_id) WHERE audio_mode = 'primary'; CREATE INDEX scene_slots_source ON scene_slots (workspace_id, virtual_channel_id, source_id) WHERE source_id IS NOT NULL; CREATE INDEX scene_slots_scene ON scene_slots (workspace_id, virtual_channel_id, scene_id); CREATE TABLE production_buses ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), workspace_id uuid NOT NULL, virtual_channel_id uuid NOT NULL, bus text NOT NULL CHECK (bus IN ('preview', 'program')), -- Declared mutable reference: TAKE/CUT re-point it in place. NULL = empty bus. -- NO ACTION: a scene on a bus cannot be deleted directly, but a channel -- cascade that removes both succeeds (checked at end of statement). scene_id uuid, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), UNIQUE (workspace_id, virtual_channel_id, bus), UNIQUE (workspace_id, id), FOREIGN KEY (workspace_id, virtual_channel_id) REFERENCES virtual_channels (workspace_id, id) ON DELETE CASCADE ON UPDATE RESTRICT, FOREIGN KEY (workspace_id, virtual_channel_id, scene_id) REFERENCES production_scenes (workspace_id, virtual_channel_id, id) ON DELETE NO ACTION ON UPDATE RESTRICT DEFERRABLE INITIALLY DEFERRED ); CREATE INDEX production_buses_scene ON production_buses (workspace_id, virtual_channel_id, scene_id) WHERE scene_id IS NOT NULL; -- An overlay slot sits on one scene; toggling `enabled` arms it on PST and it -- goes live with TAKE. Its pixels are always a pre-rendered asset (§4.2) -- except the server-generated clock, whose format is a closed enum. CREATE TABLE overlay_slots ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), workspace_id uuid NOT NULL, virtual_channel_id uuid NOT NULL, scene_id uuid NOT NULL, kind text NOT NULL CHECK (kind IN ('lower_third', 'chyron', 'logo', 'clock')), enabled boolean NOT NULL DEFAULT false, layer smallint NOT NULL DEFAULT 0 CHECK (layer BETWEEN 0 AND 7), pos_x int NOT NULL DEFAULT 0 CHECK (pos_x BETWEEN 0 AND 10000), pos_y int NOT NULL DEFAULT 0 CHECK (pos_y BETWEEN 0 AND 10000), chyron_speed_px_s smallint CHECK (chyron_speed_px_s BETWEEN 40 AND 400), chyron_strip_width_px int CHECK (chyron_strip_width_px BETWEEN 1 AND 16384), clock_format text CHECK (clock_format IN ('HH:MM', 'HH:MM:SS')), -- Revision currently composited; NULL until the first render lands. current_revision_id uuid, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), CHECK ((kind = 'chyron') = (chyron_speed_px_s IS NOT NULL AND chyron_strip_width_px IS NOT NULL)), CHECK (kind = 'chyron' OR (chyron_speed_px_s IS NULL AND chyron_strip_width_px IS NULL)), CHECK ((kind = 'clock') = (clock_format IS NOT NULL)), CHECK (kind <> 'clock' OR current_revision_id IS NULL), UNIQUE (workspace_id, id), UNIQUE (workspace_id, virtual_channel_id, id), FOREIGN KEY (workspace_id, virtual_channel_id, scene_id) REFERENCES production_scenes (workspace_id, virtual_channel_id, id) ON DELETE CASCADE ON UPDATE RESTRICT ); CREATE INDEX overlay_slots_scene ON overlay_slots (workspace_id, virtual_channel_id, scene_id); -- Append-only history of an overlay slot's validated content and its rendered -- asset. FFmpeg only ever sees `asset_id` (sha256 content id from StorageService). CREATE TABLE overlay_revisions ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), workspace_id uuid NOT NULL, virtual_channel_id uuid NOT NULL, overlay_slot_id uuid NOT NULL, template_id text NOT NULL CHECK (template_id ~ '^[a-z0-9][a-z0-9_-]{0,63}$'), -- Typed field values (title <= 80, subtitle <= 120, style enum), validated in the app layer. fields jsonb NOT NULL DEFAULT '{}'::jsonb CHECK (jsonb_typeof(fields) = 'object'), render_state text NOT NULL DEFAULT 'pending' CHECK (render_state IN ('pending', 'rendered', 'failed')), asset_id text CHECK (asset_id ~ '^[0-9a-f]{64}$'), created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), CHECK ((render_state = 'rendered') = (asset_id IS NOT NULL)), UNIQUE (workspace_id, id), UNIQUE (workspace_id, virtual_channel_id, overlay_slot_id, id), FOREIGN KEY (workspace_id, virtual_channel_id, overlay_slot_id) REFERENCES overlay_slots (workspace_id, virtual_channel_id, id) ON DELETE CASCADE ON UPDATE RESTRICT ); CREATE INDEX overlay_revisions_slot ON overlay_revisions (workspace_id, virtual_channel_id, overlay_slot_id, created_at DESC); -- The current revision must be one of THIS slot's revisions (same channel, same slot). ALTER TABLE overlay_slots ADD CONSTRAINT overlay_slots_current_revision_fkey FOREIGN KEY (workspace_id, virtual_channel_id, id, current_revision_id) REFERENCES overlay_revisions (workspace_id, virtual_channel_id, overlay_slot_id, id) ON DELETE SET NULL (current_revision_id) ON UPDATE RESTRICT; CREATE INDEX overlay_slots_current_revision ON overlay_slots (workspace_id, virtual_channel_id, id, current_revision_id) WHERE current_revision_id IS NOT NULL; -- Immutability: tenant column everywhere, ownership columns per table. CREATE TRIGGER trg_virtual_channel_ws_immutable BEFORE UPDATE ON virtual_channels FOR EACH ROW EXECUTE FUNCTION reject_workspace_change(); CREATE TRIGGER trg_vc_source_ws_immutable BEFORE UPDATE ON virtual_channel_sources FOR EACH ROW EXECUTE FUNCTION reject_workspace_change(); CREATE TRIGGER trg_vc_source_owner_immutable BEFORE UPDATE ON virtual_channel_sources FOR EACH ROW EXECUTE FUNCTION reject_owner_change('virtual_channel_id', 'kind'); CREATE TRIGGER trg_playlist_item_ws_immutable BEFORE UPDATE ON playlist_items FOR EACH ROW EXECUTE FUNCTION reject_workspace_change(); CREATE TRIGGER trg_playlist_item_owner_immutable BEFORE UPDATE ON playlist_items FOR EACH ROW EXECUTE FUNCTION reject_owner_change('virtual_channel_id', 'source_id'); CREATE TRIGGER trg_scene_ws_immutable BEFORE UPDATE ON production_scenes FOR EACH ROW EXECUTE FUNCTION reject_workspace_change(); CREATE TRIGGER trg_scene_owner_immutable BEFORE UPDATE ON production_scenes FOR EACH ROW EXECUTE FUNCTION reject_owner_change('virtual_channel_id'); CREATE TRIGGER trg_scene_slot_ws_immutable BEFORE UPDATE ON scene_slots FOR EACH ROW EXECUTE FUNCTION reject_workspace_change(); CREATE TRIGGER trg_scene_slot_owner_immutable BEFORE UPDATE ON scene_slots FOR EACH ROW EXECUTE FUNCTION reject_owner_change('virtual_channel_id', 'scene_id', '?source_id'); CREATE TRIGGER trg_bus_ws_immutable BEFORE UPDATE ON production_buses FOR EACH ROW EXECUTE FUNCTION reject_workspace_change(); CREATE TRIGGER trg_bus_owner_immutable BEFORE UPDATE ON production_buses FOR EACH ROW EXECUTE FUNCTION reject_owner_change('virtual_channel_id', 'bus'); CREATE TRIGGER trg_overlay_slot_ws_immutable BEFORE UPDATE ON overlay_slots FOR EACH ROW EXECUTE FUNCTION reject_workspace_change(); CREATE TRIGGER trg_overlay_slot_owner_immutable BEFORE UPDATE ON overlay_slots FOR EACH ROW EXECUTE FUNCTION reject_owner_change('virtual_channel_id', 'scene_id', 'kind'); CREATE TRIGGER trg_overlay_revision_ws_immutable BEFORE UPDATE ON overlay_revisions FOR EACH ROW EXECUTE FUNCTION reject_workspace_change(); CREATE TRIGGER trg_overlay_revision_owner_immutable BEFORE UPDATE ON overlay_revisions FOR EACH ROW EXECUTE FUNCTION reject_owner_change('virtual_channel_id', 'overlay_slot_id', 'template_id', 'fields'); ALTER TABLE virtual_channels ENABLE ROW LEVEL SECURITY; ALTER TABLE virtual_channel_sources ENABLE ROW LEVEL SECURITY; ALTER TABLE playlist_items ENABLE ROW LEVEL SECURITY; ALTER TABLE production_scenes ENABLE ROW LEVEL SECURITY; ALTER TABLE scene_slots ENABLE ROW LEVEL SECURITY; ALTER TABLE production_buses ENABLE ROW LEVEL SECURITY; ALTER TABLE overlay_slots ENABLE ROW LEVEL SECURITY; ALTER TABLE overlay_revisions ENABLE ROW LEVEL SECURITY; ALTER TABLE virtual_channels FORCE ROW LEVEL SECURITY; ALTER TABLE virtual_channel_sources FORCE ROW LEVEL SECURITY; ALTER TABLE playlist_items FORCE ROW LEVEL SECURITY; ALTER TABLE production_scenes FORCE ROW LEVEL SECURITY; ALTER TABLE scene_slots FORCE ROW LEVEL SECURITY; ALTER TABLE production_buses FORCE ROW LEVEL SECURITY; ALTER TABLE overlay_slots FORCE ROW LEVEL SECURITY; ALTER TABLE overlay_revisions FORCE ROW LEVEL SECURITY; CREATE POLICY tenant_isolation ON virtual_channels USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid) WITH CHECK (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid); CREATE POLICY tenant_isolation ON virtual_channel_sources USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid) WITH CHECK (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid); CREATE POLICY tenant_isolation ON playlist_items USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid) WITH CHECK (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid); CREATE POLICY tenant_isolation ON production_scenes USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid) WITH CHECK (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid); CREATE POLICY tenant_isolation ON scene_slots USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid) WITH CHECK (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid); CREATE POLICY tenant_isolation ON production_buses USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid) WITH CHECK (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid); CREATE POLICY tenant_isolation ON overlay_slots USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid) WITH CHECK (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid); CREATE POLICY tenant_isolation ON overlay_revisions 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_channels, virtual_channel_sources, playlist_items, production_scenes, scene_slots, production_buses, overlay_slots, overlay_revisions TO sp_app;