···11+-- Phase 4: Denormalize user preferences (mutes, blocks, bookmarks) into actors table
22+--
33+-- This migration moves mutes, blocks, and bookmarks from separate tables into arrays
44+-- on the actors table, similar to how follows were denormalized in Phase 3.
55+--
66+-- Benefits:
77+-- - Single row lookup for all user preferences
88+-- - Eliminates JOINs when checking if user has muted/blocked/bookmarked
99+-- - Better cache locality (all user data in one row)
1010+-- - Simpler queries for user preference checks
1111+1212+-- Add user preference arrays to actors table
1313+ALTER TABLE actors
1414+ ADD COLUMN mutes mute_record[],
1515+ ADD COLUMN blocks block_record[],
1616+ ADD COLUMN bookmarks bookmark_record[];
1717+1818+-- Backfill mutes array from mutes table
1919+UPDATE actors a
2020+SET mutes = (
2121+ SELECT ARRAY_AGG(
2222+ ROW(m.subject_actor_id, m.created_at)::mute_record
2323+ ORDER BY m.created_at DESC
2424+ )
2525+ FROM mutes m
2626+ WHERE m.actor_id = a.id
2727+);
2828+2929+-- Backfill blocks array from blocks table
3030+UPDATE actors a
3131+SET blocks = (
3232+ SELECT ARRAY_AGG(
3333+ ROW(b.subject_actor_id, b.rkey)::block_record
3434+ ORDER BY b.rkey DESC
3535+ )
3636+ FROM blocks b
3737+ WHERE b.actor_id = a.id
3838+);
3939+4040+-- Backfill bookmarks array from bookmarks table
4141+UPDATE actors a
4242+SET bookmarks = (
4343+ SELECT ARRAY_AGG(
4444+ ROW(b.post_actor_id, b.post_rkey, b.rkey)::bookmark_record
4545+ ORDER BY b.rkey DESC
4646+ )
4747+ FROM bookmarks b
4848+ WHERE b.actor_id = a.id
4949+);
5050+5151+-- Drop old tables
5252+DROP TABLE mutes;
5353+DROP TABLE blocks;
5454+DROP TABLE bookmarks;
+3-15
parakeet-db/src/composite_types.rs
···2626use crate::schema::sql_types::{
2727 PostExtEmbed, PostVideoEmbed, PostImageEmbed, PostFacetEmbed, PostVideoCaption,
2828 LabelerDefRecord, FollowRecord,
2929- // Note: Other composite types will be added when used in Phase 4-7:
3030- // MuteRecord, BlockRecord, BookmarkRecord, ThreadMuteRecord,
3131- // ListMuteRecord, ListBlockRecord, PostLabel, ActorLabel,
2929+ MuteRecord, BlockRecord, BookmarkRecord, // Phase 4: User preferences
3030+ // Note: Other composite types will be added when used in Phase 5-7:
3131+ // ThreadMuteRecord, ListMuteRecord, ListBlockRecord, PostLabel, ActorLabel,
3232};
33333434// Placeholder SQL types for composite types not yet used in tables
···3737mod placeholder_sql_types {
3838 use diesel::query_builder::QueryId;
3939 use diesel::sql_types::SqlType;
4040-4141- #[derive(QueryId, SqlType)]
4242- #[diesel(postgres_type(name = "mute_record"))]
4343- pub struct MuteRecord;
4444-4545- #[derive(QueryId, SqlType)]
4646- #[diesel(postgres_type(name = "block_record"))]
4747- pub struct BlockRecord;
4848-4949- #[derive(QueryId, SqlType)]
5050- #[diesel(postgres_type(name = "bookmark_record"))]
5151- pub struct BookmarkRecord;
52405341 #[derive(QueryId, SqlType)]
5442 #[diesel(postgres_type(name = "thread_mute_record"))]
+7-37
parakeet-db/src/models.rs
···3434//
3535// =============================================================================
36363737-use crate::composite_types::{Follow, LabelerDef};
3737+use crate::composite_types::{Block, Bookmark, Follow, LabelerDef, Mute};
3838use crate::tid_util::{decode_tid, encode_tid, TidError};
3939use crate::types::*;
4040use chrono::prelude::*;
···164164 // Social graph arrays (from follows table, denormalized - bidirectional)
165165 pub following: Option<Vec<Option<Follow>>>, // Who this actor follows
166166 pub followers: Option<Vec<Option<Follow>>>, // Who follows this actor
167167+ // User preference arrays (from mutes, blocks, bookmarks tables, denormalized)
168168+ pub mutes: Option<Vec<Option<Mute>>>, // Muted actors
169169+ pub blocks: Option<Vec<Option<Block>>>, // Blocked actors
170170+ pub bookmarks: Option<Vec<Option<Bookmark>>>, // Bookmarked posts
167171}
168172169173// AllowlistEntry model removed - allowlist table dropped in favor of actors.sync_state
···450454// Follow model removed - follow relationships now stored as follow_record[] arrays on actors table
451455// Follow composite type is defined in composite_types.rs
452456453453-#[derive(Clone, Debug, Queryable, Selectable, Identifiable)]
454454-#[diesel(table_name = crate::schema::blocks)]
455455-#[diesel(primary_key(actor_id, rkey))]
456456-#[diesel(check_for_backend(diesel::pg::Pg))]
457457-pub struct Block {
458458- pub actor_id: i32, // PK: FK to actors (blocker)
459459- pub rkey: i64, // PK: TID as INT8
460460- pub subject_actor_id: i32, // FK to actors (blocked)
461461- // Note: created_at derived from TID rkey via created_at() method
462462- // Note: CID is synthetic, generated from actor_id + rkey
463463-}
464464-465465-// Off-protocol social actions (have created_at because not in repo)
466466-467467-#[derive(Clone, Debug, Queryable, Selectable)]
468468-#[diesel(table_name = crate::schema::mutes)]
469469-#[diesel(primary_key(actor_id, subject_actor_id))]
470470-#[diesel(check_for_backend(diesel::pg::Pg))]
471471-pub struct Mute {
472472- pub actor_id: i32, // PK: FK to actors
473473- pub subject_actor_id: i32, // PK: FK to actors
474474- pub created_at: DateTime<Utc>, // Off-protocol: has own timestamp
475475-}
476476-477477-#[derive(Clone, Debug, Queryable, Selectable)]
478478-#[diesel(table_name = crate::schema::bookmarks)]
479479-#[diesel(primary_key(actor_id, rkey))]
480480-#[diesel(check_for_backend(diesel::pg::Pg))]
481481-pub struct Bookmark {
482482- pub actor_id: i32, // PK: FK to actors
483483- pub rkey: i64, // PK: TID as INT8
484484- pub post_actor_id: i32, // FK to posts (actor_id)
485485- pub post_rkey: i64, // FK to posts (rkey)
486486- // Note: Bookmarks can only reference posts
487487- // Note: created_at derived from TID rkey via created_at() method
488488-}
457457+// Block, Mute, Bookmark models removed - user preferences now stored as arrays on actors table
458458+// Composite types are defined in composite_types.rs
489459490460// Profile, NotifDecl, ChatDecl, and Status structs removed - data now consolidated into Actor struct
491461// See actors table columns: profile_*, status_*, chat_*, notif_decl_*, notif_seen_at, notif_unread_count
···88/// Get bookmarks for a user with cursor pagination
99///
1010/// Returns list of (created_at, subject_uri, cid_str) tuples
1111+///
1212+/// OPTIMIZED: Reads from denormalized bookmarks array (single row lookup!)
1113pub async fn get_user_bookmarks(
1214 conn: &mut AsyncPgConnection,
1315 actor_id: i32,
···2830 cid: Vec<u8>,
2931 }
30323131- // Use .bind() for cursor parameter to prevent SQL injection
3233 diesel::sql_query(
3333- "SELECT tid_timestamp(b.rkey) as created_at,
3434+ "SELECT tid_timestamp((b).rkey) as created_at,
3435 a.did,
3536 p.rkey,
3637 p.cid
3737- FROM bookmarks b
3838- INNER JOIN posts p ON b.post_actor_id = p.actor_id AND b.post_rkey = p.rkey
3838+ FROM actors, unnest(bookmarks) AS b
3939+ INNER JOIN posts p ON (b).post_actor_id = p.actor_id AND (b).post_rkey = p.rkey
3940 INNER JOIN actors a ON p.actor_id = a.id
4040- WHERE b.actor_id = $1
4141+ WHERE actors.id = $1
4142 AND p.status = 'complete'
4242- AND ($2::timestamptz IS NULL OR tid_timestamp(b.rkey) < $2)
4343- ORDER BY b.rkey DESC
4343+ AND ($2::timestamptz IS NULL OR tid_timestamp((b).rkey) < $2)
4444+ ORDER BY (b).rkey DESC
4445 LIMIT $3"
4546 )
4647 .bind::<Integer, _>(actor_id)
+15-12
parakeet/src/db/graph.rs
···77/// Get muted accounts for a user with cursor pagination
88///
99/// Returns list of (created_at, subject_did) tuples
1010+///
1111+/// OPTIMIZED: Reads from denormalized mutes array (single row lookup!)
1012pub async fn get_user_mutes(
1113 conn: &mut AsyncPgConnection,
1214 actor_id: i32,
···2123 subject_did: String,
2224 }
23252424- // Use .bind() for cursor parameter to prevent SQL injection
2526 diesel::sql_query(
2626- "SELECT m.created_at, subject.did as subject_did
2727- FROM mutes m
2828- INNER JOIN actors subject ON m.subject_actor_id = subject.id
2929- WHERE m.actor_id = $1
3030- AND ($2::timestamptz IS NULL OR m.created_at < $2)
3131- ORDER BY m.created_at DESC
2727+ "SELECT (m).created_at, subject.did as subject_did
2828+ FROM actors, unnest(mutes) AS m
2929+ INNER JOIN actors subject ON (m).subject_actor_id = subject.id
3030+ WHERE actors.id = $1
3131+ AND ($2::timestamptz IS NULL OR (m).created_at < $2)
3232+ ORDER BY (m).created_at DESC
3233 LIMIT $3"
3334 )
3435 .bind::<Integer, _>(actor_id)
···7273/// Get blocked accounts for a user with cursor pagination
7374///
7475/// Returns list of (created_at, subject_actor_id) tuples
7676+///
7777+/// OPTIMIZED: Reads from denormalized blocks array (single row lookup!)
7578pub async fn get_user_blocks(
7679 conn: &mut AsyncPgConnection,
7780 actor_id: i32,
···8790 }
88918992 diesel::sql_query(
9090- "SELECT DISTINCT ON (b.subject_actor_id) tid_timestamp(b.rkey) as created_at, b.subject_actor_id
9191- FROM blocks b
9292- WHERE b.actor_id = $1
9393- AND ($2::timestamptz IS NULL OR tid_timestamp(b.rkey) < $2)
9494- ORDER BY b.subject_actor_id, b.rkey DESC
9393+ "SELECT tid_timestamp((b).rkey) as created_at, (b).subject_actor_id
9494+ FROM actors, unnest(blocks) AS b
9595+ WHERE id = $1
9696+ AND ($2::timestamptz IS NULL OR tid_timestamp((b).rkey) < $2)
9797+ ORDER BY (b).rkey DESC
9598 LIMIT $3"
9699 )
97100 .bind::<Integer, _>(actor_id)
+30-17
parakeet/src/xrpc/app_bsky/bookmark.rs
···6969 // Generate rkey (TID) for the bookmark from current timestamp
7070 let rkey = chrono::Utc::now().timestamp_micros();
71717272- let _ = diesel_async::RunQueryDsl::execute(
7373- diesel::insert_into(schema::bookmarks::table) // TODO: Consider communicating back to consumer worker using PostgreSQL queue
7474- .values((
7575- schema::bookmarks::actor_id.eq(actor_id),
7676- schema::bookmarks::rkey.eq(rkey),
7777- schema::bookmarks::post_actor_id.eq(post_actor_id),
7878- schema::bookmarks::post_rkey.eq(post_rkey),
7979- ))
8080- .on_conflict((schema::bookmarks::actor_id, schema::bookmarks::post_actor_id, schema::bookmarks::post_rkey))
8181- .do_nothing(),
7272+ // Append to bookmarks array (off-protocol, managed directly by AppView)
7373+ // Deduplicates based on post_actor_id + post_rkey
7474+ diesel_async::RunQueryDsl::execute(
7575+ diesel::sql_query(
7676+ "UPDATE actors
7777+ SET bookmarks = COALESCE(bookmarks, ARRAY[]::bookmark_record[]) ||
7878+ ARRAY[ROW($2, $3, $4)::bookmark_record]
7979+ WHERE id = $1
8080+ AND NOT EXISTS (
8181+ SELECT 1 FROM unnest(bookmarks) b
8282+ WHERE (b).post_actor_id = $2 AND (b).post_rkey = $3
8383+ )"
8484+ )
8585+ .bind::<diesel::sql_types::Integer, _>(actor_id)
8686+ .bind::<diesel::sql_types::Integer, _>(post_actor_id)
8787+ .bind::<diesel::sql_types::BigInt, _>(post_rkey)
8888+ .bind::<diesel::sql_types::BigInt, _>(rkey),
8289 &mut conn,
8390 )
8491 .await?;
···137144 )
138145 .await?;
139146140140- let _ = diesel_async::RunQueryDsl::execute(
141141- diesel::delete(schema::bookmarks::table).filter( // TODO: Consider communicating back to consumer worker using PostgreSQL queue
142142- schema::bookmarks::actor_id
143143- .eq(actor_id)
144144- .and(schema::bookmarks::post_actor_id.eq(post_actor_id))
145145- .and(schema::bookmarks::post_rkey.eq(post_rkey)),
146146- ),
147147+ // Remove from bookmarks array (off-protocol, managed directly by AppView)
148148+ diesel_async::RunQueryDsl::execute(
149149+ diesel::sql_query(
150150+ "UPDATE actors
151151+ SET bookmarks = ARRAY(
152152+ SELECT b FROM unnest(bookmarks) AS b
153153+ WHERE NOT ((b).post_actor_id = $2 AND (b).post_rkey = $3)
154154+ )
155155+ WHERE id = $1"
156156+ )
157157+ .bind::<diesel::sql_types::Integer, _>(actor_id)
158158+ .bind::<diesel::sql_types::Integer, _>(post_actor_id)
159159+ .bind::<diesel::sql_types::BigInt, _>(post_rkey),
147160 &mut conn,
148161 )
149162 .await?;
+29-15
parakeet/src/xrpc/app_bsky/graph/mutes.rs
···9696 )
9797 .await?;
98989999- let _ = diesel_async::RunQueryDsl::execute(
100100- diesel::insert_into(schema::mutes::table) // TODO: Consider communicating back to consumer worker using PostgreSQL queue
101101- .values((
102102- schema::mutes::actor_id.eq(actor_id),
103103- schema::mutes::subject_actor_id.eq(subject_actor_id),
104104- schema::mutes::created_at.eq(chrono::Utc::now()),
105105- ))
106106- .on_conflict((schema::mutes::actor_id, schema::mutes::subject_actor_id))
107107- .do_nothing(),
9999+ // Append to mutes array (off-protocol, managed directly by AppView)
100100+ // Deduplicates based on subject_actor_id
101101+ let created_at = chrono::Utc::now();
102102+ diesel_async::RunQueryDsl::execute(
103103+ diesel::sql_query(
104104+ "UPDATE actors
105105+ SET mutes = COALESCE(mutes, ARRAY[]::mute_record[]) ||
106106+ ARRAY[ROW($2, $3)::mute_record]
107107+ WHERE id = $1
108108+ AND NOT EXISTS (
109109+ SELECT 1 FROM unnest(mutes) m
110110+ WHERE (m).subject_actor_id = $2
111111+ )"
112112+ )
113113+ .bind::<diesel::sql_types::Integer, _>(actor_id)
114114+ .bind::<diesel::sql_types::Integer, _>(subject_actor_id)
115115+ .bind::<diesel::sql_types::Timestamptz, _>(created_at),
108116 &mut conn,
109117 )
110118 .await?;
···190198 )
191199 .await?;
192200193193- let _ = diesel_async::RunQueryDsl::execute(
194194- diesel::delete(schema::mutes::table).filter( // TODO: Consider communicating back to consumer worker using PostgreSQL queue
195195- schema::mutes::actor_id
196196- .eq(actor_id)
197197- .and(schema::mutes::subject_actor_id.eq(subject_actor_id)),
198198- ),
201201+ // Remove from mutes array (off-protocol, managed directly by AppView)
202202+ diesel_async::RunQueryDsl::execute(
203203+ diesel::sql_query(
204204+ "UPDATE actors
205205+ SET mutes = ARRAY(
206206+ SELECT m FROM unnest(mutes) AS m
207207+ WHERE NOT ((m).subject_actor_id = $2)
208208+ )
209209+ WHERE id = $1"
210210+ )
211211+ .bind::<diesel::sql_types::Integer, _>(actor_id)
212212+ .bind::<diesel::sql_types::Integer, _>(subject_actor_id),
199213 &mut conn,
200214 )
201215 .await?;
+36-17
parakeet/src/xrpc/community_lexicon/bookmarks.rs
···66use axum::Json;
77use diesel::prelude::*;
88use lexica::community_lexicon::bookmarks::Bookmark;
99-use parakeet_db::{models, schema};
99+use parakeet_db::schema;
1010use serde::{Deserialize, Serialize};
11111212#[derive(Debug, Deserialize)]
···4141 )
4242 .await?;
43434444- let mut bookmarks_query = schema::bookmarks::table
4545- .select(models::Bookmark::as_select())
4646- .filter(schema::bookmarks::actor_id.eq(actor_id))
4747- .into_boxed();
4444+ // Note: tags filtering not supported in current schema
4545+ if query.tags.is_some() {
4646+ tracing::warn!("tags filtering requested but not supported in current schema");
4747+ }
4848+4949+ // Read from bookmarks array
5050+ use diesel::sql_types::{BigInt, Integer, Nullable};
48514949- if let Some(cursor) = datetime_cursor(query.cursor.as_ref()) {
5050- // Convert timestamp cursor to TID for rkey-based pagination
5151- let cursor_micros = cursor.timestamp_micros();
5252- let cursor_tid = cursor_micros << 10; // Shift left 10 bits to create approximate TID
5353- bookmarks_query = bookmarks_query.filter(schema::bookmarks::rkey.lt(cursor_tid));
5252+ #[derive(diesel::QueryableByName)]
5353+ struct BookmarkRecord {
5454+ #[diesel(sql_type = Integer)]
5555+ post_actor_id: i32,
5656+ #[diesel(sql_type = BigInt)]
5757+ post_rkey: i64,
5858+ #[diesel(sql_type = BigInt)]
5959+ rkey: i64,
5460 }
55615656- // Note: tags filtering not supported in current schema
5757- if query.tags.is_some() {
5858- tracing::warn!("tags filtering requested but not supported in current schema");
6262+ impl BookmarkRecord {
6363+ fn created_at(&self) -> chrono::DateTime<chrono::Utc> {
6464+ parakeet_db::tid_util::tid_to_datetime(self.rkey)
6565+ }
5966 }
60676868+ let cursor_tid = query.cursor.as_ref()
6969+ .and_then(|c| datetime_cursor(Some(c)))
7070+ .map(|dt| dt.timestamp_micros() << 10);
7171+6172 let results = diesel_async::RunQueryDsl::load(
6262- bookmarks_query
6363- .order(schema::bookmarks::rkey.desc())
6464- .limit(i64::from(limit)),
7373+ diesel::sql_query(
7474+ "SELECT (b).post_actor_id, (b).post_rkey, (b).rkey
7575+ FROM actors, unnest(bookmarks) AS b
7676+ WHERE id = $1
7777+ AND ($2::bigint IS NULL OR (b).rkey < $2)
7878+ ORDER BY (b).rkey DESC
7979+ LIMIT $3"
8080+ )
8181+ .bind::<Integer, _>(actor_id)
8282+ .bind::<Nullable<BigInt>, _>(cursor_tid)
8383+ .bind::<BigInt, _>(i64::from(limit)),
6584 &mut conn,
6685 )
6786 .await?;
68876988 let cursor = results
7089 .last()
7171- .map(|bm| bm.created_at().timestamp_millis().to_string());
9090+ .map(|bm: &BookmarkRecord| bm.created_at().timestamp_millis().to_string());
72917392 // Reconstruct post AT URIs from posts table using natural keys
7493 // Note: Bookmarks can only reference posts (not feedgens, lists, etc.)