## Summary
- add idempotent block, unblock, and paginated block-list APIs across
HTTP, gRPC, service, and repository layers
- hide blocked creators from authenticated personal discovery while
leaving anonymous discovery unchanged
- preserve joined, favorited, and directly accessed rooms with a
creator_blocked marker
- filter blocked users from message history, search, context, pinned
messages, replay, unread counts, live events, and reconnect replay
- add the user_blocks migration, SQLx offline metadata, and
repository/API coverage
## Behavior
Blocking is private to the current account. It does not alter the
blocked account or public anonymous discovery. Detailed personal room
lists remain complete and expose creator_blocked so clients can label
the creator. Observer-specific realtime filtering advances event cursors
and fails closed when block-state lookup is unavailable.
## Verification
- make check
- make fmt-check
- cargo test --workspace: 5222 passed
- synctv-api-common library tests: 638 passed, 165 ignored
- PostgreSQL favorite-room creator_blocked integration test
- SQLx prepare across the workspace
- real two-account HTTP, realtime, and room-flow integration test
Client: https://github.com/synctv-org/synctv-app/pull/47
"query":"\n SELECT id AS \"id!\",\n room_id AS \"room_id!: RoomId\",\n user_id AS \"user_id?: UserId\",\n client_message_id,\n content AS \"content!\",\n message_type AS \"message_type!: ChatMessageType\",\n status AS \"status!: ChatMessageStatus\",\n version AS \"version!\",\n reply_to_message_id,\n reply_to_message_created_at,\n metadata AS \"metadata?: ChatMetadata\",\n edited_at,\n deleted_at,\n deleted_by AS \"deleted_by?: UserId\",\n delete_reason,\n created_at AS \"created_at!\"\n FROM chat_messages\n WHERE room_id = $1\n AND deletion_source IS DISTINCT FROM $7\n AND ($2 OR status <> $3)\n AND (created_at, id) > ($4, $5)\n ORDER BY created_at ASC, id ASC\n LIMIT $6\n ",
"query":"\n SELECT id AS \"id!\",\n room_id AS \"room_id!: RoomId\",\n user_id AS \"user_id?: UserId\",\n client_message_id,\n content AS \"content!\",\n message_type AS \"message_type!: ChatMessageType\",\n status AS \"status!: ChatMessageStatus\",\n version AS \"version!\",\n reply_to_message_id,\n reply_to_message_created_at,\n metadata AS \"metadata?: ChatMetadata\",\n edited_at,\n deleted_at,\n deleted_by AS \"deleted_by?: UserId\",\n delete_reason,\n created_at AS \"created_at!\"\n FROM chat_messages\n WHERE room_id = $1\n AND deletion_source IS DISTINCT FROM $7\n AND ($2 OR status <> $3)\n AND (created_at, id) > ($4, $5)\n AND ($8::bigint IS NULL OR NOT EXISTS (\n SELECT 1 FROM user_blocks ub\n WHERE ub.blocker_user_id = $8 AND ub.blocked_user_id = user_id\n ))\n ORDER BY created_at ASC, id ASC\n LIMIT $6\n ",
"query":"\n SELECT COUNT(*) AS \"count!\"\n FROM chat_messages\n WHERE room_id = $1\n AND deletion_source IS DISTINCT FROM $4\n AND status <> $2\n AND (user_id IS NULL OR user_id <> $3)\n AND created_at >= NOW() - INTERVAL '90 days'\n ",
"query":"\n SELECT COUNT(*) AS \"count!\"\n FROM chat_messages\n WHERE room_id = $1\n AND deletion_source IS DISTINCT FROM $4\n AND status <> $2\n AND (user_id IS NULL OR user_id <> $3)\n AND NOT EXISTS (\n SELECT 1 FROM user_blocks ub\n WHERE ub.blocker_user_id = $3 AND ub.blocked_user_id = user_id\n )\n AND created_at >= NOW() - INTERVAL '90 days'\n ",
"query":"\n SELECT p.room_id AS \"room_id!: RoomId\",\n p.message_id AS \"message_id!\",\n p.message_created_at AS \"message_created_at!\",\n p.pinned_by AS \"pinned_by?: UserId\",\n u.username AS pinned_by_username,\n p.note,\n p.pinned_at AS \"pinned_at!\"\n FROM chat_message_pins p\n LEFT JOIN users u ON u.id = p.pinned_by\n JOIN chat_messages m\n ON m.room_id = p.room_id\n AND m.id = p.message_id\n AND m.created_at = p.message_created_at\n WHERE p.room_id = $1\n AND m.status <> $2\n AND m.deletion_source IS DISTINCT FROM $4\n ORDER BY p.pinned_at DESC, p.message_id DESC\n LIMIT $3\n ",
"query":"\n SELECT p.room_id AS \"room_id!: RoomId\",\n p.message_id AS \"message_id!\",\n p.message_created_at AS \"message_created_at!\",\n p.pinned_by AS \"pinned_by?: UserId\",\n u.username AS pinned_by_username,\n p.note,\n p.pinned_at AS \"pinned_at!\"\n FROM chat_message_pins p\n LEFT JOIN users u ON u.id = p.pinned_by\n JOIN chat_messages m\n ON m.room_id = p.room_id\n AND m.id = p.message_id\n AND m.created_at = p.message_created_at\n WHERE p.room_id = $1\n AND m.status <> $2\n AND m.deletion_source IS DISTINCT FROM $4\n AND ($5::bigint IS NULL OR NOT EXISTS (\n SELECT 1 FROM user_blocks ub\n WHERE ub.blocker_user_id = $5 AND ub.blocked_user_id = m.user_id\n ))\n ORDER BY p.pinned_at DESC, p.message_id DESC\n LIMIT $3\n ",
"query":"\n SELECT blocker_user_id AS \"blocker_user_id!: UserId\"\n FROM user_blocks\n WHERE blocked_user_id = $1 AND blocker_user_id = ANY($2::bigint[])\n ",
"query":"\n WITH search_terms AS (\n SELECT websearch_to_tsquery('simple', $2) AS tsquery\n )\n SELECT m.id AS \"id!\",\n m.room_id AS \"room_id!: RoomId\",\n m.user_id AS \"user_id?: UserId\",\n m.client_message_id,\n m.content AS \"content!\",\n m.message_type AS \"message_type!: ChatMessageType\",\n m.status AS \"status!: ChatMessageStatus\",\n m.version AS \"version!\",\n m.reply_to_message_id,\n m.reply_to_message_created_at,\n m.metadata AS \"metadata?: ChatMetadata\",\n m.edited_at,\n m.deleted_at,\n m.deleted_by AS \"deleted_by?: UserId\",\n m.delete_reason,\n m.created_at AS \"created_at!\"\n FROM chat_messages m\n CROSS JOIN search_terms st\n WHERE m.room_id = $1\n AND m.deletion_source IS DISTINCT FROM $8\n AND (m.content_search @@ st.tsquery OR m.content ILIKE $3 ESCAPE '\\')\n AND ($4 OR m.status <> $5)\n AND ($6::bigint IS NULL OR m.user_id = $6)\n ORDER BY m.created_at DESC, m.id DESC\n LIMIT $7\n ",
"query":"\n WITH search_terms AS (\n SELECT websearch_to_tsquery('simple', $2) AS tsquery\n )\n SELECT m.id AS \"id!\",\n m.room_id AS \"room_id!: RoomId\",\n m.user_id AS \"user_id?: UserId\",\n m.client_message_id,\n m.content AS \"content!\",\n m.message_type AS \"message_type!: ChatMessageType\",\n m.status AS \"status!: ChatMessageStatus\",\n m.version AS \"version!\",\n m.reply_to_message_id,\n m.reply_to_message_created_at,\n m.metadata AS \"metadata?: ChatMetadata\",\n m.edited_at,\n m.deleted_at,\n m.deleted_by AS \"deleted_by?: UserId\",\n m.delete_reason,\n m.created_at AS \"created_at!\"\n FROM chat_messages m\n CROSS JOIN search_terms st\n WHERE m.room_id = $1\n AND m.deletion_source IS DISTINCT FROM $8\n AND (m.content_search @@ st.tsquery OR m.content ILIKE $3 ESCAPE '\\')\n AND ($4 OR m.status <> $5)\n AND ($6::bigint IS NULL OR m.user_id = $6)\n AND ($9::bigint IS NULL OR NOT EXISTS (\n SELECT 1 FROM user_blocks ub\n WHERE ub.blocker_user_id = $9 AND ub.blocked_user_id = m.user_id\n ))\n ORDER BY m.created_at DESC, m.id DESC\n LIMIT $7\n ",
"query":"\n SELECT COUNT(*) AS \"count!\"\n FROM chat_message_events e\n JOIN chat_messages m\n ON m.room_id = e.room_id\n AND m.id = e.message_id\n AND m.created_at = e.message_created_at\n WHERE e.room_id = $1\n AND e.sequence > $2\n AND e.event_type = $3\n AND m.status <> $4\n AND m.deletion_source IS DISTINCT FROM $6\n AND (m.user_id IS NULL OR m.user_id <> $5)\n ",
"query":"\n SELECT COUNT(*) AS \"count!\"\n FROM chat_message_events e\n JOIN chat_messages m\n ON m.room_id = e.room_id\n AND m.id = e.message_id\n AND m.created_at = e.message_created_at\n WHERE e.room_id = $1\n AND e.sequence > $2\n AND e.event_type = $3\n AND m.status <> $4\n AND m.deletion_source IS DISTINCT FROM $6\n AND (m.user_id IS NULL OR m.user_id <> $5)\n AND NOT EXISTS (\n SELECT 1 FROM user_blocks ub\n WHERE ub.blocker_user_id = $5 AND ub.blocked_user_id = m.user_id\n )\n ",
"query":"\n WITH search_terms AS (\n SELECT websearch_to_tsquery('simple', $2) AS tsquery\n )\n SELECT m.id AS \"id!\",\n m.room_id AS \"room_id!: RoomId\",\n m.user_id AS \"user_id?: UserId\",\n m.client_message_id,\n m.content AS \"content!\",\n m.message_type AS \"message_type!: ChatMessageType\",\n m.status AS \"status!: ChatMessageStatus\",\n m.version AS \"version!\",\n m.reply_to_message_id,\n m.reply_to_message_created_at,\n m.metadata AS \"metadata?: ChatMetadata\",\n m.edited_at,\n m.deleted_at,\n m.deleted_by AS \"deleted_by?: UserId\",\n m.delete_reason,\n m.created_at AS \"created_at!\"\n FROM chat_messages m\n CROSS JOIN search_terms st\n WHERE m.room_id = $1\n AND m.deletion_source IS DISTINCT FROM $10\n AND (m.content_search @@ st.tsquery OR m.content ILIKE $3 ESCAPE '\\')\n AND ($4 OR m.status <> $5)\n AND ($6::bigint IS NULL OR m.user_id = $6)\n AND (m.created_at, m.id) < ($7, $8)\n ORDER BY m.created_at DESC, m.id DESC\n LIMIT $9\n ",
"query":"\n WITH search_terms AS (\n SELECT websearch_to_tsquery('simple', $2) AS tsquery\n )\n SELECT m.id AS \"id!\",\n m.room_id AS \"room_id!: RoomId\",\n m.user_id AS \"user_id?: UserId\",\n m.client_message_id,\n m.content AS \"content!\",\n m.message_type AS \"message_type!: ChatMessageType\",\n m.status AS \"status!: ChatMessageStatus\",\n m.version AS \"version!\",\n m.reply_to_message_id,\n m.reply_to_message_created_at,\n m.metadata AS \"metadata?: ChatMetadata\",\n m.edited_at,\n m.deleted_at,\n m.deleted_by AS \"deleted_by?: UserId\",\n m.delete_reason,\n m.created_at AS \"created_at!\"\n FROM chat_messages m\n CROSS JOIN search_terms st\n WHERE m.room_id = $1\n AND m.deletion_source IS DISTINCT FROM $10\n AND (m.content_search @@ st.tsquery OR m.content ILIKE $3 ESCAPE '\\')\n AND ($4 OR m.status <> $5)\n AND ($6::bigint IS NULL OR m.user_id = $6)\n AND (m.created_at, m.id) < ($7, $8)\n AND ($11::bigint IS NULL OR NOT EXISTS (\n SELECT 1 FROM user_blocks ub\n WHERE ub.blocker_user_id = $11 AND ub.blocked_user_id = m.user_id\n ))\n ORDER BY m.created_at DESC, m.id DESC\n LIMIT $9\n ",
"query":"\n SELECT id AS \"id!\",\n room_id AS \"room_id!: RoomId\",\n user_id AS \"user_id?: UserId\",\n client_message_id,\n content AS \"content!\",\n message_type AS \"message_type!: ChatMessageType\",\n status AS \"status!: ChatMessageStatus\",\n version AS \"version!\",\n reply_to_message_id,\n reply_to_message_created_at,\n metadata AS \"metadata?: ChatMetadata\",\n edited_at,\n deleted_at,\n deleted_by AS \"deleted_by?: UserId\",\n delete_reason,\n created_at AS \"created_at!\"\n FROM chat_messages\n WHERE room_id = $1\n AND deletion_source IS DISTINCT FROM $6\n AND ($2 OR status <> $3)\n AND created_at >= NOW() - INTERVAL '90 days'\n AND message_type = ANY($5::smallint[])\n ORDER BY created_at DESC, id DESC\n LIMIT $4\n ",
"query":"\n SELECT id AS \"id!\",\n room_id AS \"room_id!: RoomId\",\n user_id AS \"user_id?: UserId\",\n client_message_id,\n content AS \"content!\",\n message_type AS \"message_type!: ChatMessageType\",\n status AS \"status!: ChatMessageStatus\",\n version AS \"version!\",\n reply_to_message_id,\n reply_to_message_created_at,\n metadata AS \"metadata?: ChatMetadata\",\n edited_at,\n deleted_at,\n deleted_by AS \"deleted_by?: UserId\",\n delete_reason,\n created_at AS \"created_at!\"\n FROM chat_messages\n WHERE room_id = $1\n AND deletion_source IS DISTINCT FROM $6\n AND ($2 OR status <> $3)\n AND created_at >= NOW() - INTERVAL '90 days'\n AND message_type = ANY($5::smallint[])\n AND ($7::bigint IS NULL OR NOT EXISTS (\n SELECT 1 FROM user_blocks ub\n WHERE ub.blocker_user_id = $7 AND ub.blocked_user_id = user_id\n ))\n ORDER BY created_at DESC, id DESC\n LIMIT $4\n ",
"query":"\n SELECT id AS \"id!\",\n room_id AS \"room_id!: RoomId\",\n user_id AS \"user_id?: UserId\",\n client_message_id,\n content AS \"content!\",\n message_type AS \"message_type!: ChatMessageType\",\n status AS \"status!: ChatMessageStatus\",\n version AS \"version!\",\n reply_to_message_id,\n reply_to_message_created_at,\n metadata AS \"metadata?: ChatMetadata\",\n edited_at,\n deleted_at,\n deleted_by AS \"deleted_by?: UserId\",\n delete_reason,\n created_at AS \"created_at!\"\n FROM chat_messages\n WHERE room_id = $1\n AND deletion_source IS DISTINCT FROM $7\n AND ($2 OR status <> $3)\n AND (created_at, id) < ($4, $5)\n ORDER BY created_at DESC, id DESC\n LIMIT $6\n ",
"query":"\n SELECT id AS \"id!\",\n room_id AS \"room_id!: RoomId\",\n user_id AS \"user_id?: UserId\",\n client_message_id,\n content AS \"content!\",\n message_type AS \"message_type!: ChatMessageType\",\n status AS \"status!: ChatMessageStatus\",\n version AS \"version!\",\n reply_to_message_id,\n reply_to_message_created_at,\n metadata AS \"metadata?: ChatMetadata\",\n edited_at,\n deleted_at,\n deleted_by AS \"deleted_by?: UserId\",\n delete_reason,\n created_at AS \"created_at!\"\n FROM chat_messages\n WHERE room_id = $1\n AND deletion_source IS DISTINCT FROM $7\n AND ($2 OR status <> $3)\n AND (created_at, id) < ($4, $5)\n AND ($8::bigint IS NULL OR NOT EXISTS (\n SELECT 1 FROM user_blocks ub\n WHERE ub.blocker_user_id = $8 AND ub.blocked_user_id = user_id\n ))\n ORDER BY created_at DESC, id DESC\n LIMIT $6\n ",
"query":"\n SELECT COUNT(*) AS \"count!\"\n FROM chat_messages\n WHERE room_id = $1\n AND deletion_source IS DISTINCT FROM $6\n AND status <> $2\n AND (user_id IS NULL OR user_id <> $3)\n AND (created_at, id) > ($4, $5)\n ",
"query":"\n SELECT COUNT(*) AS \"count!\"\n FROM chat_messages\n WHERE room_id = $1\n AND deletion_source IS DISTINCT FROM $6\n AND status <> $2\n AND (user_id IS NULL OR user_id <> $3)\n AND (created_at, id) > ($4, $5)\n AND NOT EXISTS (\n SELECT 1 FROM user_blocks ub\n WHERE ub.blocker_user_id = $3 AND ub.blocked_user_id = user_id\n )\n ",
"query":"\n SELECT id AS \"id!\",\n room_id AS \"room_id!: RoomId\",\n user_id AS \"user_id?: UserId\",\n client_message_id,\n content AS \"content!\",\n message_type AS \"message_type!: ChatMessageType\",\n status AS \"status!: ChatMessageStatus\",\n version AS \"version!\",\n reply_to_message_id,\n reply_to_message_created_at,\n metadata AS \"metadata?: ChatMetadata\",\n edited_at,\n deleted_at,\n deleted_by AS \"deleted_by?: UserId\",\n delete_reason,\n created_at AS \"created_at!\"\n FROM chat_messages\n WHERE room_id = $1\n AND deletion_source IS DISTINCT FROM $8\n AND ($2 OR status <> $3)\n AND (created_at, id) < ($4, $5)\n AND message_type = ANY($7::smallint[])\n ORDER BY created_at DESC, id DESC\n LIMIT $6\n ",
"query":"\n SELECT id AS \"id!\",\n room_id AS \"room_id!: RoomId\",\n user_id AS \"user_id?: UserId\",\n client_message_id,\n content AS \"content!\",\n message_type AS \"message_type!: ChatMessageType\",\n status AS \"status!: ChatMessageStatus\",\n version AS \"version!\",\n reply_to_message_id,\n reply_to_message_created_at,\n metadata AS \"metadata?: ChatMetadata\",\n edited_at,\n deleted_at,\n deleted_by AS \"deleted_by?: UserId\",\n delete_reason,\n created_at AS \"created_at!\"\n FROM chat_messages\n WHERE room_id = $1\n AND deletion_source IS DISTINCT FROM $8\n AND ($2 OR status <> $3)\n AND (created_at, id) < ($4, $5)\n AND message_type = ANY($7::smallint[])\n AND ($9::bigint IS NULL OR NOT EXISTS (\n SELECT 1 FROM user_blocks ub\n WHERE ub.blocker_user_id = $9 AND ub.blocked_user_id = user_id\n ))\n ORDER BY created_at DESC, id DESC\n LIMIT $6\n ",
"query":"\n INSERT INTO user_blocks (blocker_user_id, blocked_user_id)\n VALUES ($1, $2)\n ON CONFLICT (blocker_user_id, blocked_user_id)\n DO UPDATE SET blocker_user_id = EXCLUDED.blocker_user_id\n RETURNING created_at AS \"created_at!\"\n ",
"query":"\n WITH candidates AS (\n SELECT id,\n room_id,\n user_id,\n client_message_id,\n content,\n message_type,\n status,\n version,\n reply_to_message_id,\n reply_to_message_created_at,\n metadata,\n edited_at,\n deleted_at,\n deleted_by,\n delete_reason,\n created_at,\n CASE\n WHEN jsonb_typeof(metadata #> '{playback,positionSeconds}') = 'number'\n THEN (metadata #>> '{playback,positionSeconds}')::double precision\n ELSE NULL\n END AS playback_position\n FROM chat_messages\n WHERE room_id = $1\n AND deletion_source IS DISTINCT FROM $11\n AND ($2 OR status <> $3)\n AND ($4::text IS NULL OR metadata #>> '{playback,mediaId}' = $4)\n AND ($5::text IS NULL OR metadata #>> '{playback,playlistId}' = $5)\n AND ($6::text IS NULL OR metadata #>> '{playback,targetHash}' = $6)\n AND message_type = ANY($7::smallint[])\n )\n SELECT id AS \"id!\",\n room_id AS \"room_id!: RoomId\",\n user_id AS \"user_id?: UserId\",\n client_message_id,\n content AS \"content!\",\n message_type AS \"message_type!: ChatMessageType\",\n status AS \"status!: ChatMessageStatus\",\n version AS \"version!\",\n reply_to_message_id,\n reply_to_message_created_at,\n metadata AS \"metadata?: ChatMetadata\",\n edited_at,\n deleted_at,\n deleted_by AS \"deleted_by?: UserId\",\n delete_reason,\n created_at AS \"created_at!\"\n FROM candidates\n WHERE playback_position BETWEEN $8 AND $9\n ORDER BY playback_position ASC, created_at ASC, id ASC\n LIMIT $10\n ",
"query":"\n WITH candidates AS (\n SELECT id,\n room_id,\n user_id,\n client_message_id,\n content,\n message_type,\n status,\n version,\n reply_to_message_id,\n reply_to_message_created_at,\n metadata,\n edited_at,\n deleted_at,\n deleted_by,\n delete_reason,\n created_at,\n CASE\n WHEN jsonb_typeof(metadata #> '{playback,positionSeconds}') = 'number'\n THEN (metadata #>> '{playback,positionSeconds}')::double precision\n ELSE NULL\n END AS playback_position\n FROM chat_messages\n WHERE room_id = $1\n AND deletion_source IS DISTINCT FROM $11\n AND ($2 OR status <> $3)\n AND ($4::text IS NULL OR metadata #>> '{playback,mediaId}' = $4)\n AND ($5::text IS NULL OR metadata #>> '{playback,playlistId}' = $5)\n AND ($6::text IS NULL OR metadata #>> '{playback,targetHash}' = $6)\n AND message_type = ANY($7::smallint[])\n AND ($12::bigint IS NULL OR NOT EXISTS (\n SELECT 1 FROM user_blocks ub\n WHERE ub.blocker_user_id = $12 AND ub.blocked_user_id = user_id\n ))\n )\n SELECT id AS \"id!\",\n room_id AS \"room_id!: RoomId\",\n user_id AS \"user_id?: UserId\",\n client_message_id,\n content AS \"content!\",\n message_type AS \"message_type!: ChatMessageType\",\n status AS \"status!: ChatMessageStatus\",\n version AS \"version!\",\n reply_to_message_id,\n reply_to_message_created_at,\n metadata AS \"metadata?: ChatMetadata\",\n edited_at,\n deleted_at,\n deleted_by AS \"deleted_by?: UserId\",\n delete_reason,\n created_at AS \"created_at!\"\n FROM candidates\n WHERE playback_position BETWEEN $8 AND $9\n ORDER BY playback_position ASC, created_at ASC, id ASC\n LIMIT $10\n ",