CREATE TABLE IF NOT EXISTS chat_messages ( id BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL, room_id BIGINT NOT NULL, user_id BIGINT, client_message_id TEXT, content TEXT NOT NULL DEFAULT '', message_type SMALLINT NOT NULL DEFAULT 1, status SMALLINT NOT NULL DEFAULT 1, version BIGINT NOT NULL DEFAULT 1, reply_to_message_id BIGINT, reply_to_message_created_at TIMESTAMPTZ, metadata JSONB, edited_at TIMESTAMPTZ, deleted_at TIMESTAMPTZ, deleted_by BIGINT, delete_reason TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, content_search TSVECTOR GENERATED ALWAYS AS (to_tsvector('simple', content)) STORED, CHECK (version >= 1), CHECK (metadata IS NULL OR jsonb_typeof(metadata) = 'object'), CHECK ( (reply_to_message_id IS NULL AND reply_to_message_created_at IS NULL) OR (reply_to_message_id IS NOT NULL AND reply_to_message_created_at IS NOT NULL) ), PRIMARY KEY (id, created_at), UNIQUE (room_id, id, created_at), FOREIGN KEY (room_id) REFERENCES rooms(id) ON DELETE RESTRICT, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL, FOREIGN KEY (deleted_by) REFERENCES users(id) ON DELETE SET NULL, FOREIGN KEY (room_id, reply_to_message_id, reply_to_message_created_at) REFERENCES chat_messages(room_id, id, created_at) ON DELETE SET NULL (reply_to_message_id, reply_to_message_created_at) ) PARTITION BY RANGE (created_at); CREATE INDEX IF NOT EXISTS idx_chat_messages_room_pagination ON chat_messages(room_id, created_at DESC, id DESC); CREATE INDEX IF NOT EXISTS idx_chat_messages_user_created ON chat_messages(user_id, created_at DESC); CREATE INDEX IF NOT EXISTS idx_chat_messages_created_at ON chat_messages(created_at DESC); CREATE INDEX IF NOT EXISTS idx_chat_messages_status ON chat_messages(room_id, status, created_at DESC, id DESC); CREATE INDEX IF NOT EXISTS idx_chat_messages_reply_target ON chat_messages(reply_to_message_id, reply_to_message_created_at); CREATE INDEX IF NOT EXISTS idx_chat_messages_content_search ON chat_messages USING gin(content_search); CREATE INDEX IF NOT EXISTS idx_chat_messages_content_trgm ON chat_messages USING gin(content gin_trgm_ops); CREATE INDEX IF NOT EXISTS idx_chat_messages_playback_media ON chat_messages( room_id, ((metadata #>> '{playback,mediaId}')), ( CASE WHEN jsonb_typeof(metadata #> '{playback,positionSeconds}') = 'number' THEN (metadata #>> '{playback,positionSeconds}')::double precision ELSE NULL END ), created_at, id ) WHERE metadata ? 'playback'; CREATE INDEX IF NOT EXISTS idx_chat_messages_playback_playlist_target ON chat_messages( room_id, ((metadata #>> '{playback,playlistId}')), ((metadata #>> '{playback,targetHash}')), ( CASE WHEN jsonb_typeof(metadata #> '{playback,positionSeconds}') = 'number' THEN (metadata #>> '{playback,positionSeconds}')::double precision ELSE NULL END ), created_at, id ) WHERE metadata ? 'playback'; CREATE TABLE IF NOT EXISTS chat_message_events ( sequence BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, event_id TEXT NOT NULL UNIQUE, room_id BIGINT NOT NULL, actor_user_id BIGINT NOT NULL, message_id BIGINT NOT NULL, message_created_at TIMESTAMPTZ NOT NULL, event_type TEXT NOT NULL, event_version BIGINT NOT NULL DEFAULT 1, message_version BIGINT NOT NULL, payload JSONB NOT NULL, summary JSONB NOT NULL, occurred_at TIMESTAMPTZ NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, CHECK (event_version >= 1), CHECK (message_version >= 1), CHECK (jsonb_typeof(payload) = 'object'), CHECK (jsonb_typeof(summary) = 'object'), FOREIGN KEY (room_id) REFERENCES rooms(id) ON DELETE CASCADE, FOREIGN KEY (room_id, message_id, message_created_at) REFERENCES chat_messages(room_id, id, created_at) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS idx_chat_message_events_room_sequence ON chat_message_events(room_id, sequence); CREATE INDEX IF NOT EXISTS idx_chat_message_events_message_sequence ON chat_message_events(room_id, message_id, message_created_at, sequence); CREATE INDEX IF NOT EXISTS idx_chat_message_events_created_at ON chat_message_events(created_at, sequence); CREATE INDEX IF NOT EXISTS idx_chat_message_events_type_sequence ON chat_message_events(room_id, event_type, sequence); CREATE TABLE IF NOT EXISTS chat_message_idempotency ( room_id BIGINT NOT NULL, user_id BIGINT NOT NULL, client_message_id TEXT NOT NULL, request_hash TEXT NOT NULL, message_id BIGINT, message_created_at TIMESTAMPTZ, event_id TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (room_id, user_id, client_message_id), FOREIGN KEY (room_id) REFERENCES rooms(id) ON DELETE CASCADE, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, FOREIGN KEY (room_id, message_id, message_created_at) REFERENCES chat_messages(room_id, id, created_at) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS idx_chat_message_idempotency_created ON chat_message_idempotency(created_at DESC); CREATE INDEX IF NOT EXISTS idx_chat_message_idempotency_message ON chat_message_idempotency(message_id, message_created_at); CREATE INDEX IF NOT EXISTS idx_chat_message_idempotency_event ON chat_message_idempotency(event_id) WHERE event_id IS NOT NULL; CREATE TABLE IF NOT EXISTS chat_message_attachments ( id TEXT NOT NULL, kind SMALLINT NOT NULL, room_id BIGINT NOT NULL, message_id BIGINT NOT NULL, message_created_at TIMESTAMPTZ NOT NULL, filename TEXT, storage_backend TEXT NOT NULL, object_key TEXT NOT NULL, url TEXT, mime_type TEXT, size_bytes BIGINT, width INTEGER, height INTEGER, metadata JSONB NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, CHECK (size_bytes IS NULL OR size_bytes > 0), CHECK (width IS NULL OR width > 0), CHECK (height IS NULL OR height > 0), CHECK (jsonb_typeof(metadata) = 'object'), PRIMARY KEY (room_id, message_id, message_created_at, id), FOREIGN KEY (room_id, message_id, message_created_at) REFERENCES chat_messages(room_id, id, created_at) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS idx_chat_message_attachments_message ON chat_message_attachments(message_id, message_created_at, created_at, id); CREATE INDEX IF NOT EXISTS idx_chat_message_attachments_room ON chat_message_attachments(room_id, created_at DESC); CREATE TABLE IF NOT EXISTS chat_message_operation_idempotency ( room_id BIGINT NOT NULL, user_id BIGINT NOT NULL, client_operation_id TEXT NOT NULL, operation_kind SMALLINT NOT NULL, request_hash TEXT NOT NULL, message_id BIGINT NOT NULL, message_created_at TIMESTAMPTZ NOT NULL, event_id TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (room_id, user_id, client_operation_id), FOREIGN KEY (room_id) REFERENCES rooms(id) ON DELETE CASCADE, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, FOREIGN KEY (room_id, message_id, message_created_at) REFERENCES chat_messages(room_id, id, created_at) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS idx_chat_message_operation_idempotency_message ON chat_message_operation_idempotency(room_id, message_id, message_created_at); CREATE INDEX IF NOT EXISTS idx_chat_message_operation_idempotency_event ON chat_message_operation_idempotency(event_id) WHERE event_id IS NOT NULL; CREATE TABLE IF NOT EXISTS chat_read_states ( room_id BIGINT NOT NULL, user_id BIGINT NOT NULL, last_read_message_id BIGINT, last_read_message_created_at TIMESTAMPTZ, last_read_event_id TEXT, last_read_event_sequence BIGINT, updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (room_id, user_id), CHECK ( (last_read_message_id IS NULL AND last_read_message_created_at IS NULL) OR (last_read_message_id IS NOT NULL AND last_read_message_created_at IS NOT NULL) ), CHECK (last_read_event_sequence IS NULL OR last_read_event_sequence >= 1), FOREIGN KEY (room_id) REFERENCES rooms(id) ON DELETE CASCADE, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, FOREIGN KEY (room_id, last_read_message_id, last_read_message_created_at) REFERENCES chat_messages(room_id, id, created_at) ON DELETE SET NULL (last_read_message_id, last_read_message_created_at) ); CREATE INDEX IF NOT EXISTS idx_chat_read_states_room_updated ON chat_read_states(room_id, updated_at DESC); CREATE INDEX IF NOT EXISTS idx_chat_read_states_message ON chat_read_states(last_read_message_id, last_read_message_created_at);