BEGIN; CREATE TABLE IF NOT EXISTS schema_migrations(version text PRIMARY KEY, applied_at timestamptz NOT NULL DEFAULT now()); CREATE TABLE IF NOT EXISTS assets ( id uuid PRIMARY KEY, kind text NOT NULL CHECK(kind IN ('image','video','audio','text')), filename text, storage_key text UNIQUE, mime_type text, byte_size bigint CHECK(byte_size > 0), sha256 text, body text, width integer, height integer, duration double precision, has_audio boolean, processing_status text NOT NULL DEFAULT 'ready' CHECK(processing_status IN ('ready','pending','processing','failed')), processing_error text, created_at timestamptz NOT NULL DEFAULT now(), CHECK ((kind='text' AND body IS NOT NULL AND storage_key IS NULL) OR (kind<>'text' AND storage_key IS NOT NULL AND byte_size > 0)) ); CREATE TABLE IF NOT EXISTS asset_variants ( asset_id uuid NOT NULL REFERENCES assets(id) ON DELETE CASCADE, role text NOT NULL CHECK(role IN ('silent_video','audio_track','poster')), storage_key text NOT NULL UNIQUE, mime_type text NOT NULL, byte_size bigint NOT NULL CHECK(byte_size>0), PRIMARY KEY(asset_id,role) ); CREATE TABLE IF NOT EXISTS posts ( id uuid PRIMARY KEY, slug text NOT NULL UNIQUE, kind text NOT NULL CHECK(kind IN ('image','video','audio','article','motion')), title text NOT NULL CHECK(length(trim(title))>0), page_key text, cover_asset_id uuid REFERENCES assets(id), created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), CHECK((kind IN ('article','motion') AND page_key IS NOT NULL) OR (kind IN ('image','video','audio') AND page_key IS NULL)) ); CREATE TABLE IF NOT EXISTS post_assets ( post_id uuid NOT NULL REFERENCES posts(id) ON DELETE CASCADE, asset_id uuid NOT NULL REFERENCES assets(id) ON DELETE RESTRICT, position integer NOT NULL CHECK(position>=0), role text NOT NULL DEFAULT 'content' CHECK(role IN ('content','caption')), PRIMARY KEY(post_id,position) ); CREATE INDEX IF NOT EXISTS post_assets_asset ON post_assets(asset_id); CREATE TABLE IF NOT EXISTS tags ( id uuid PRIMARY KEY, parent_id uuid REFERENCES tags(id) ON DELETE RESTRICT, slug text NOT NULL CHECK(slug ~ '^[a-z0-9][a-z0-9-]*$'), name text NOT NULL CHECK(length(trim(name))>0), UNIQUE NULLS NOT DISTINCT(parent_id,slug), CHECK(id IS DISTINCT FROM parent_id) ); CREATE OR REPLACE FUNCTION prevent_tag_cycle() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN -- Serialize taxonomy edits so concurrent moves cannot introduce a cycle. PERFORM pg_advisory_xact_lock(718131); IF NEW.parent_id IS NOT NULL AND EXISTS ( WITH RECURSIVE ancestors AS ( SELECT id,parent_id FROM tags WHERE id=NEW.parent_id UNION ALL SELECT t.id,t.parent_id FROM tags t JOIN ancestors a ON t.id=a.parent_id ) SELECT 1 FROM ancestors WHERE id=NEW.id ) THEN RAISE EXCEPTION 'Tag hierarchy cannot contain a cycle'; END IF; RETURN NEW; END $$; DROP TRIGGER IF EXISTS tags_no_cycles ON tags; CREATE TRIGGER tags_no_cycles BEFORE INSERT OR UPDATE ON tags FOR EACH ROW EXECUTE FUNCTION prevent_tag_cycle(); CREATE TABLE IF NOT EXISTS post_tags(post_id uuid REFERENCES posts(id) ON DELETE CASCADE,tag_id uuid REFERENCES tags(id) ON DELETE CASCADE,PRIMARY KEY(post_id,tag_id)); CREATE TABLE IF NOT EXISTS asset_tags(asset_id uuid REFERENCES assets(id) ON DELETE CASCADE,tag_id uuid REFERENCES tags(id) ON DELETE CASCADE,PRIMARY KEY(asset_id,tag_id)); CREATE TABLE IF NOT EXISTS media_jobs ( id uuid PRIMARY KEY, asset_id uuid NOT NULL UNIQUE REFERENCES assets(id) ON DELETE CASCADE, status text NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','processing','ready','failed')), attempts integer NOT NULL DEFAULT 0, available_at timestamptz NOT NULL DEFAULT now(), locked_at timestamptz, last_error text ); CREATE TABLE IF NOT EXISTS contribution_events ( id uuid PRIMARY KEY, kind text NOT NULL, subject_type text NOT NULL, subject_id uuid, amount integer NOT NULL DEFAULT 1 CHECK(amount>0), occurred_at timestamptz NOT NULL DEFAULT now(), idempotency_key text UNIQUE, metadata jsonb NOT NULL DEFAULT '{}' ); CREATE INDEX IF NOT EXISTS contribution_events_time ON contribution_events(occurred_at); CREATE INDEX IF NOT EXISTS posts_timeline ON posts(created_at DESC,id DESC); INSERT INTO schema_migrations(version) VALUES('001_library') ON CONFLICT DO NOTHING; COMMIT;