T-HUB/apps/api/migrations/013_production.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

327 lines
19 KiB
PL/PgSQL

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