-- Database schema for Webstory -- This schema supports the resources defined in proto/webstory/v1/api.proto -- Enable UUID generation CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; -- Stories table CREATE TABLE IF NOT EXISTS stories ( -- Primary key id SERIAL PRIMARY KEY, -- Story ID (used in resource name: stories/{story_id}) story_id VARCHAR(63) NOT NULL, -- Title of the story title VARCHAR(500) NOT NULL, -- Content of the story content TEXT, -- Description or summary of the story description TEXT, -- Labels for organizing and categorizing the story labels TEXT[], -- Timestamps created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- Etag for concurrency control etag VARCHAR(128) NOT NULL, -- Constraints CONSTRAINT stories_story_id_unique UNIQUE (story_id), CONSTRAINT stories_story_id_pattern CHECK (story_id ~ '^[a-z][0-9-]{2,61}[0-9]$'), CONSTRAINT stories_etag_pattern CHECK (char_length(etag) > 0) ); -- Create index for efficient filtering and sorting CREATE INDEX IF NOT EXISTS idx_stories_title ON stories (title); CREATE INDEX IF NOT EXISTS idx_stories_labels ON stories USING GIN (labels); CREATE INDEX IF NOT EXISTS idx_stories_created_at ON stories (created_at DESC); -- Scenes table (child of stories) CREATE TABLE IF NOT EXISTS scenes ( -- Primary key id SERIAL PRIMARY KEY, -- Foreign key to stories story_id INTEGER NOT NULL REFERENCES stories(id) ON DELETE CASCADE, -- Scene ID (used in resource name: stories/{story}/scenes/{scene_id}) scene_id VARCHAR(63) NOT NULL, -- Scene number within the story scene_number INTEGER NOT NULL, -- Title of the scene title VARCHAR(500) NOT NULL, -- Content of the scene content TEXT, -- Description of the scene description TEXT, -- Timestamps created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- Etag for concurrency control etag VARCHAR(128) NOT NULL, -- Constraints CONSTRAINT scenes_story_scene_id_unique UNIQUE (story_id, scene_id), CONSTRAINT scenes_story_id_exists CHECK (story_id > 0), CONSTRAINT scenes_scene_id_pattern CHECK (scene_id ~ '^[a-z][0-9-]{2,61}[0-9]$'), CONSTRAINT scenes_scene_number_positive CHECK (scene_number > 0), CONSTRAINT scenes_etag_pattern CHECK (char_length(etag) > 0) ); -- Create index for efficient queries CREATE INDEX IF NOT EXISTS idx_scenes_story_id ON scenes (story_id); CREATE INDEX IF NOT EXISTS idx_scenes_scene_number ON scenes (story_id, scene_number); CREATE INDEX IF NOT EXISTS idx_scenes_title ON scenes (title); -- Actors table (child of stories) CREATE TABLE IF NOT EXISTS actors ( -- Primary key id SERIAL PRIMARY KEY, -- Foreign key to stories story_id INTEGER NOT NULL REFERENCES stories(id) ON DELETE CASCADE, -- Actor ID (used in resource name: stories/{story}/actors/{actor_id}) actor_id VARCHAR(63) NOT NULL, -- Name of the actor name_value VARCHAR(255) NOT NULL, -- Role or character name this actor plays in the story role VARCHAR(255), -- Optional notes about the actor notes TEXT, -- Timestamps created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- Etag for concurrency control etag VARCHAR(128) NOT NULL, -- Constraints CONSTRAINT actors_story_actor_id_unique UNIQUE (story_id, actor_id), CONSTRAINT actors_story_id_exists CHECK (story_id > 0), CONSTRAINT actors_actor_id_pattern CHECK (actor_id ~ '^[a-z][0-9-]{2,61}[0-9]$'), CONSTRAINT actors_etag_pattern CHECK (char_length(etag) > 0) ); -- Create index for efficient queries CREATE INDEX IF NOT EXISTS idx_actors_story_id ON actors (story_id); CREATE INDEX IF NOT EXISTS idx_actors_name_value ON actors (story_id, name_value); CREATE INDEX IF NOT EXISTS idx_actors_role ON actors (story_id, role); -- Function to update updated_at timestamp CREATE OR REPLACE FUNCTION update_updated_at_column() RETURNS TRIGGER AS $$ BEGIN NEW.updated_at = NOW(); -- Update etag when row is modified (simple version using timestamp) NEW.etag := md5(NEW.created_at::text || NEW.updated_at::text); RETURN NEW; END; $$ LANGUAGE plpgsql; -- Trigger for stories table CREATE TRIGGER update_stories_updated_at BEFORE UPDATE ON stories FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); -- Trigger for scenes table CREATE TRIGGER update_scenes_updated_at BEFORE UPDATE ON scenes FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); -- Trigger for actors table CREATE TRIGGER update_actors_updated_at BEFORE UPDATE ON actors FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); -- Function to generate new etag (called during inserts) CREATE OR REPLACE FUNCTION generate_etag() RETURNS TRIGGER AS $$ BEGIN NEW.etag := md5(NEW.id::text || NEW.created_at::text); RETURN NEW; END; $$ LANGUAGE plpgsql; -- Trigger for etag generation on inserts CREATE TRIGGER generate_stories_etag BEFORE INSERT ON stories FOR EACH ROW EXECUTE FUNCTION generate_etag(); CREATE TRIGGER generate_scenes_etag BEFORE INSERT ON scenes FOR EACH ROW EXECUTE FUNCTION generate_etag(); CREATE TRIGGER generate_actors_etag BEFORE INSERT ON actors FOR EACH ROW EXECUTE FUNCTION generate_etag(); -- Helper function to get stories with label filtering CREATE OR REPLACE FUNCTION get_stories( p_page_size INTEGER DEFAULT 50, p_page_token TEXT DEFAULT NULL, p_filter TEXT DEFAULT NULL ) RETURNS TABLE ( id INTEGER, story_id VARCHAR(63), title VARCHAR(500), content TEXT, description TEXT, labels TEXT[], created_at TIMESTAMPTZ, updated_at TIMESTAMPTZ, etag VARCHAR(128) ) AS $$ DECLARE v_page_size INTEGER; v_last_id INTEGER; BEGIN -- Validate and limit page size v_page_size := LEAST(GREATEST(p_page_size, 1), 1000); -- Parse page token to get last ID for pagination IF p_page_token IS NOT NULL AND p_page_token != '' THEN v_last_id := p_page_token::INTEGER; END IF; -- Build dynamic query based on filter -- Note: This is a simplified version. Production code should handle -- filter parsing more robustly RETURN QUERY SELECT s.id, s.story_id, s.title, s.content, s.description, s.labels, s.created_at, s.updated_at, s.etag FROM stories s WHERE (v_last_id IS NULL OR s.id > v_last_id) ORDER BY s.id LIMIT v_page_size; END; $$ LANGUAGE plpgsql;