T-HUB/apps/api/migrations/023_virtual_channel_import.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

160 lines
8.2 KiB
PL/PgSQL

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