Rust AppView - highly experimental!
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: denormalize strategy; mutes, blocks, bookmarks

+268 -156
+60
migrations/2025-12-08-012930_denormalize_user_preferences/down.sql
··· 1 + -- Rollback Phase 4: Restore mutes, blocks, and bookmarks tables 2 + 3 + -- Recreate mutes table 4 + CREATE TABLE mutes ( 5 + actor_id INTEGER NOT NULL, 6 + subject_actor_id INTEGER NOT NULL, 7 + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), 8 + PRIMARY KEY (actor_id, subject_actor_id) 9 + ); 10 + 11 + -- Recreate blocks table 12 + CREATE TABLE blocks ( 13 + actor_id INTEGER NOT NULL, 14 + subject_actor_id INTEGER NOT NULL, 15 + rkey BIGINT NOT NULL, 16 + PRIMARY KEY (actor_id, rkey) 17 + ); 18 + 19 + -- Recreate bookmarks table 20 + CREATE TABLE bookmarks ( 21 + actor_id INTEGER NOT NULL, 22 + rkey BIGINT NOT NULL, 23 + post_actor_id INTEGER NOT NULL, 24 + post_rkey BIGINT NOT NULL, 25 + PRIMARY KEY (actor_id, rkey) 26 + ); 27 + 28 + -- Restore mutes from mutes array 29 + INSERT INTO mutes (actor_id, subject_actor_id, created_at) 30 + SELECT 31 + a.id, 32 + (m).subject_actor_id, 33 + (m).created_at 34 + FROM actors a, unnest(a.mutes) AS m 35 + WHERE a.mutes IS NOT NULL; 36 + 37 + -- Restore blocks from blocks array 38 + INSERT INTO blocks (actor_id, subject_actor_id, rkey) 39 + SELECT 40 + a.id, 41 + (b).subject_actor_id, 42 + (b).rkey 43 + FROM actors a, unnest(a.blocks) AS b 44 + WHERE a.blocks IS NOT NULL; 45 + 46 + -- Restore bookmarks from bookmarks array 47 + INSERT INTO bookmarks (actor_id, rkey, post_actor_id, post_rkey) 48 + SELECT 49 + a.id, 50 + (b).rkey, 51 + (b).post_actor_id, 52 + (b).post_rkey 53 + FROM actors a, unnest(a.bookmarks) AS b 54 + WHERE a.bookmarks IS NOT NULL; 55 + 56 + -- Drop user preference arrays from actors 57 + ALTER TABLE actors 58 + DROP COLUMN bookmarks, 59 + DROP COLUMN blocks, 60 + DROP COLUMN mutes;
+54
migrations/2025-12-08-012930_denormalize_user_preferences/up.sql
··· 1 + -- Phase 4: Denormalize user preferences (mutes, blocks, bookmarks) into actors table 2 + -- 3 + -- This migration moves mutes, blocks, and bookmarks from separate tables into arrays 4 + -- on the actors table, similar to how follows were denormalized in Phase 3. 5 + -- 6 + -- Benefits: 7 + -- - Single row lookup for all user preferences 8 + -- - Eliminates JOINs when checking if user has muted/blocked/bookmarked 9 + -- - Better cache locality (all user data in one row) 10 + -- - Simpler queries for user preference checks 11 + 12 + -- Add user preference arrays to actors table 13 + ALTER TABLE actors 14 + ADD COLUMN mutes mute_record[], 15 + ADD COLUMN blocks block_record[], 16 + ADD COLUMN bookmarks bookmark_record[]; 17 + 18 + -- Backfill mutes array from mutes table 19 + UPDATE actors a 20 + SET mutes = ( 21 + SELECT ARRAY_AGG( 22 + ROW(m.subject_actor_id, m.created_at)::mute_record 23 + ORDER BY m.created_at DESC 24 + ) 25 + FROM mutes m 26 + WHERE m.actor_id = a.id 27 + ); 28 + 29 + -- Backfill blocks array from blocks table 30 + UPDATE actors a 31 + SET blocks = ( 32 + SELECT ARRAY_AGG( 33 + ROW(b.subject_actor_id, b.rkey)::block_record 34 + ORDER BY b.rkey DESC 35 + ) 36 + FROM blocks b 37 + WHERE b.actor_id = a.id 38 + ); 39 + 40 + -- Backfill bookmarks array from bookmarks table 41 + UPDATE actors a 42 + SET bookmarks = ( 43 + SELECT ARRAY_AGG( 44 + ROW(b.post_actor_id, b.post_rkey, b.rkey)::bookmark_record 45 + ORDER BY b.rkey DESC 46 + ) 47 + FROM bookmarks b 48 + WHERE b.actor_id = a.id 49 + ); 50 + 51 + -- Drop old tables 52 + DROP TABLE mutes; 53 + DROP TABLE blocks; 54 + DROP TABLE bookmarks;
+3 -15
parakeet-db/src/composite_types.rs
··· 26 26 use crate::schema::sql_types::{ 27 27 PostExtEmbed, PostVideoEmbed, PostImageEmbed, PostFacetEmbed, PostVideoCaption, 28 28 LabelerDefRecord, FollowRecord, 29 - // Note: Other composite types will be added when used in Phase 4-7: 30 - // MuteRecord, BlockRecord, BookmarkRecord, ThreadMuteRecord, 31 - // ListMuteRecord, ListBlockRecord, PostLabel, ActorLabel, 29 + MuteRecord, BlockRecord, BookmarkRecord, // Phase 4: User preferences 30 + // Note: Other composite types will be added when used in Phase 5-7: 31 + // ThreadMuteRecord, ListMuteRecord, ListBlockRecord, PostLabel, ActorLabel, 32 32 }; 33 33 34 34 // Placeholder SQL types for composite types not yet used in tables ··· 37 37 mod placeholder_sql_types { 38 38 use diesel::query_builder::QueryId; 39 39 use diesel::sql_types::SqlType; 40 - 41 - #[derive(QueryId, SqlType)] 42 - #[diesel(postgres_type(name = "mute_record"))] 43 - pub struct MuteRecord; 44 - 45 - #[derive(QueryId, SqlType)] 46 - #[diesel(postgres_type(name = "block_record"))] 47 - pub struct BlockRecord; 48 - 49 - #[derive(QueryId, SqlType)] 50 - #[diesel(postgres_type(name = "bookmark_record"))] 51 - pub struct BookmarkRecord; 52 40 53 41 #[derive(QueryId, SqlType)] 54 42 #[diesel(postgres_type(name = "thread_mute_record"))]
+7 -37
parakeet-db/src/models.rs
··· 34 34 // 35 35 // ============================================================================= 36 36 37 - use crate::composite_types::{Follow, LabelerDef}; 37 + use crate::composite_types::{Block, Bookmark, Follow, LabelerDef, Mute}; 38 38 use crate::tid_util::{decode_tid, encode_tid, TidError}; 39 39 use crate::types::*; 40 40 use chrono::prelude::*; ··· 164 164 // Social graph arrays (from follows table, denormalized - bidirectional) 165 165 pub following: Option<Vec<Option<Follow>>>, // Who this actor follows 166 166 pub followers: Option<Vec<Option<Follow>>>, // Who follows this actor 167 + // User preference arrays (from mutes, blocks, bookmarks tables, denormalized) 168 + pub mutes: Option<Vec<Option<Mute>>>, // Muted actors 169 + pub blocks: Option<Vec<Option<Block>>>, // Blocked actors 170 + pub bookmarks: Option<Vec<Option<Bookmark>>>, // Bookmarked posts 167 171 } 168 172 169 173 // AllowlistEntry model removed - allowlist table dropped in favor of actors.sync_state ··· 450 454 // Follow model removed - follow relationships now stored as follow_record[] arrays on actors table 451 455 // Follow composite type is defined in composite_types.rs 452 456 453 - #[derive(Clone, Debug, Queryable, Selectable, Identifiable)] 454 - #[diesel(table_name = crate::schema::blocks)] 455 - #[diesel(primary_key(actor_id, rkey))] 456 - #[diesel(check_for_backend(diesel::pg::Pg))] 457 - pub struct Block { 458 - pub actor_id: i32, // PK: FK to actors (blocker) 459 - pub rkey: i64, // PK: TID as INT8 460 - pub subject_actor_id: i32, // FK to actors (blocked) 461 - // Note: created_at derived from TID rkey via created_at() method 462 - // Note: CID is synthetic, generated from actor_id + rkey 463 - } 464 - 465 - // Off-protocol social actions (have created_at because not in repo) 466 - 467 - #[derive(Clone, Debug, Queryable, Selectable)] 468 - #[diesel(table_name = crate::schema::mutes)] 469 - #[diesel(primary_key(actor_id, subject_actor_id))] 470 - #[diesel(check_for_backend(diesel::pg::Pg))] 471 - pub struct Mute { 472 - pub actor_id: i32, // PK: FK to actors 473 - pub subject_actor_id: i32, // PK: FK to actors 474 - pub created_at: DateTime<Utc>, // Off-protocol: has own timestamp 475 - } 476 - 477 - #[derive(Clone, Debug, Queryable, Selectable)] 478 - #[diesel(table_name = crate::schema::bookmarks)] 479 - #[diesel(primary_key(actor_id, rkey))] 480 - #[diesel(check_for_backend(diesel::pg::Pg))] 481 - pub struct Bookmark { 482 - pub actor_id: i32, // PK: FK to actors 483 - pub rkey: i64, // PK: TID as INT8 484 - pub post_actor_id: i32, // FK to posts (actor_id) 485 - pub post_rkey: i64, // FK to posts (rkey) 486 - // Note: Bookmarks can only reference posts 487 - // Note: created_at derived from TID rkey via created_at() method 488 - } 457 + // Block, Mute, Bookmark models removed - user preferences now stored as arrays on actors table 458 + // Composite types are defined in composite_types.rs 489 459 490 460 // Profile, NotifDecl, ChatDecl, and Status structs removed - data now consolidated into Actor struct 491 461 // See actors table columns: profile_*, status_*, chat_*, notif_decl_*, notif_seen_at, notif_unread_count
+26 -36
parakeet-db/src/schema.rs
··· 10 10 pub struct ActorSyncState; 11 11 12 12 #[derive(diesel::query_builder::QueryId, diesel::sql_types::SqlType)] 13 + #[diesel(postgres_type(name = "block_record"))] 14 + pub struct BlockRecord; 15 + 16 + #[derive(diesel::query_builder::QueryId, diesel::sql_types::SqlType)] 17 + #[diesel(postgres_type(name = "bookmark_record"))] 18 + pub struct BookmarkRecord; 19 + 20 + #[derive(diesel::query_builder::QueryId, diesel::sql_types::SqlType)] 13 21 #[diesel(postgres_type(name = "caption_mime_type"))] 14 22 pub struct CaptionMimeType; 15 23 ··· 42 50 pub struct ImageMimeType; 43 51 44 52 #[derive(diesel::query_builder::QueryId, diesel::sql_types::SqlType)] 53 + #[diesel(postgres_type(name = "labeler_def_record"))] 54 + pub struct LabelerDefRecord; 55 + 56 + #[derive(diesel::query_builder::QueryId, diesel::sql_types::SqlType)] 57 + #[diesel(postgres_type(name = "labeler_status"))] 58 + pub struct LabelerStatus; 59 + 60 + #[derive(diesel::query_builder::QueryId, diesel::sql_types::SqlType)] 45 61 #[diesel(postgres_type(name = "label_blurs"))] 46 62 pub struct LabelBlurs; 47 63 ··· 54 70 pub struct LabelSeverity; 55 71 56 72 #[derive(diesel::query_builder::QueryId, diesel::sql_types::SqlType)] 57 - #[diesel(postgres_type(name = "labeler_def_record"))] 58 - pub struct LabelerDefRecord; 59 - 60 - #[derive(diesel::query_builder::QueryId, diesel::sql_types::SqlType)] 61 - #[diesel(postgres_type(name = "labeler_status"))] 62 - pub struct LabelerStatus; 63 - 64 - #[derive(diesel::query_builder::QueryId, diesel::sql_types::SqlType)] 65 73 #[diesel(postgres_type(name = "language_code"))] 66 74 pub struct LanguageCode; 67 75 68 76 #[derive(diesel::query_builder::QueryId, diesel::sql_types::SqlType)] 69 77 #[diesel(postgres_type(name = "list_type"))] 70 78 pub struct ListType; 79 + 80 + #[derive(diesel::query_builder::QueryId, diesel::sql_types::SqlType)] 81 + #[diesel(postgres_type(name = "mute_record"))] 82 + pub struct MuteRecord; 71 83 72 84 #[derive(diesel::query_builder::QueryId, diesel::sql_types::SqlType)] 73 85 #[diesel(postgres_type(name = "notif_allow_subscriptions"))] ··· 176 188 use super::sql_types::LabelerStatus; 177 189 use super::sql_types::LabelerDefRecord; 178 190 use super::sql_types::FollowRecord; 191 + use super::sql_types::MuteRecord; 192 + use super::sql_types::BlockRecord; 193 + use super::sql_types::BookmarkRecord; 179 194 180 195 actors (id) { 181 196 id -> Int4, ··· 228 243 labeler_defs -> Nullable<Array<Nullable<LabelerDefRecord>>>, 229 244 following -> Nullable<Array<Nullable<FollowRecord>>>, 230 245 followers -> Nullable<Array<Nullable<FollowRecord>>>, 246 + mutes -> Nullable<Array<Nullable<MuteRecord>>>, 247 + blocks -> Nullable<Array<Nullable<BlockRecord>>>, 248 + bookmarks -> Nullable<Array<Nullable<BookmarkRecord>>>, 231 249 } 232 250 } 233 251 ··· 241 259 scheduled_at -> Timestamptz, 242 260 started_at -> Nullable<Timestamptz>, 243 261 completed_at -> Nullable<Timestamptz>, 244 - } 245 - } 246 - 247 - diesel::table! { 248 - blocks (actor_id, rkey) { 249 - actor_id -> Int4, 250 - rkey -> Int8, 251 - subject_actor_id -> Int4, 252 - } 253 - } 254 - 255 - diesel::table! { 256 - bookmarks (actor_id, rkey) { 257 - actor_id -> Int4, 258 - rkey -> Int8, 259 - post_actor_id -> Int4, 260 - post_rkey -> Int8, 261 262 } 262 263 } 263 264 ··· 400 401 description_facets -> Nullable<Jsonb>, 401 402 avatar_cid -> Nullable<Bytea>, 402 403 status -> RecordStatus, 403 - } 404 - } 405 - 406 - diesel::table! { 407 - mutes (actor_id, subject_actor_id) { 408 - actor_id -> Int4, 409 - subject_actor_id -> Int4, 410 - created_at -> Timestamptz, 411 404 } 412 405 } 413 406 ··· 607 600 _diesel_schema_inference, 608 601 actors, 609 602 backfill_jobs, 610 - blocks, 611 - bookmarks, 612 603 constellation_enrichment_queue, 613 604 feedgen_likes, 614 605 feedgens, ··· 620 611 list_items, 621 612 list_mutes, 622 613 lists, 623 - mutes, 624 614 notifications, 625 615 post_aggregate_stats, 626 616 posts,
+8 -7
parakeet/src/db/bookmarks.rs
··· 8 8 /// Get bookmarks for a user with cursor pagination 9 9 /// 10 10 /// Returns list of (created_at, subject_uri, cid_str) tuples 11 + /// 12 + /// OPTIMIZED: Reads from denormalized bookmarks array (single row lookup!) 11 13 pub async fn get_user_bookmarks( 12 14 conn: &mut AsyncPgConnection, 13 15 actor_id: i32, ··· 28 30 cid: Vec<u8>, 29 31 } 30 32 31 - // Use .bind() for cursor parameter to prevent SQL injection 32 33 diesel::sql_query( 33 - "SELECT tid_timestamp(b.rkey) as created_at, 34 + "SELECT tid_timestamp((b).rkey) as created_at, 34 35 a.did, 35 36 p.rkey, 36 37 p.cid 37 - FROM bookmarks b 38 - INNER JOIN posts p ON b.post_actor_id = p.actor_id AND b.post_rkey = p.rkey 38 + FROM actors, unnest(bookmarks) AS b 39 + INNER JOIN posts p ON (b).post_actor_id = p.actor_id AND (b).post_rkey = p.rkey 39 40 INNER JOIN actors a ON p.actor_id = a.id 40 - WHERE b.actor_id = $1 41 + WHERE actors.id = $1 41 42 AND p.status = 'complete' 42 - AND ($2::timestamptz IS NULL OR tid_timestamp(b.rkey) < $2) 43 - ORDER BY b.rkey DESC 43 + AND ($2::timestamptz IS NULL OR tid_timestamp((b).rkey) < $2) 44 + ORDER BY (b).rkey DESC 44 45 LIMIT $3" 45 46 ) 46 47 .bind::<Integer, _>(actor_id)
+15 -12
parakeet/src/db/graph.rs
··· 7 7 /// Get muted accounts for a user with cursor pagination 8 8 /// 9 9 /// Returns list of (created_at, subject_did) tuples 10 + /// 11 + /// OPTIMIZED: Reads from denormalized mutes array (single row lookup!) 10 12 pub async fn get_user_mutes( 11 13 conn: &mut AsyncPgConnection, 12 14 actor_id: i32, ··· 21 23 subject_did: String, 22 24 } 23 25 24 - // Use .bind() for cursor parameter to prevent SQL injection 25 26 diesel::sql_query( 26 - "SELECT m.created_at, subject.did as subject_did 27 - FROM mutes m 28 - INNER JOIN actors subject ON m.subject_actor_id = subject.id 29 - WHERE m.actor_id = $1 30 - AND ($2::timestamptz IS NULL OR m.created_at < $2) 31 - ORDER BY m.created_at DESC 27 + "SELECT (m).created_at, subject.did as subject_did 28 + FROM actors, unnest(mutes) AS m 29 + INNER JOIN actors subject ON (m).subject_actor_id = subject.id 30 + WHERE actors.id = $1 31 + AND ($2::timestamptz IS NULL OR (m).created_at < $2) 32 + ORDER BY (m).created_at DESC 32 33 LIMIT $3" 33 34 ) 34 35 .bind::<Integer, _>(actor_id) ··· 72 73 /// Get blocked accounts for a user with cursor pagination 73 74 /// 74 75 /// Returns list of (created_at, subject_actor_id) tuples 76 + /// 77 + /// OPTIMIZED: Reads from denormalized blocks array (single row lookup!) 75 78 pub async fn get_user_blocks( 76 79 conn: &mut AsyncPgConnection, 77 80 actor_id: i32, ··· 87 90 } 88 91 89 92 diesel::sql_query( 90 - "SELECT DISTINCT ON (b.subject_actor_id) tid_timestamp(b.rkey) as created_at, b.subject_actor_id 91 - FROM blocks b 92 - WHERE b.actor_id = $1 93 - AND ($2::timestamptz IS NULL OR tid_timestamp(b.rkey) < $2) 94 - ORDER BY b.subject_actor_id, b.rkey DESC 93 + "SELECT tid_timestamp((b).rkey) as created_at, (b).subject_actor_id 94 + FROM actors, unnest(blocks) AS b 95 + WHERE id = $1 96 + AND ($2::timestamptz IS NULL OR tid_timestamp((b).rkey) < $2) 97 + ORDER BY (b).rkey DESC 95 98 LIMIT $3" 96 99 ) 97 100 .bind::<Integer, _>(actor_id)
+30 -17
parakeet/src/xrpc/app_bsky/bookmark.rs
··· 69 69 // Generate rkey (TID) for the bookmark from current timestamp 70 70 let rkey = chrono::Utc::now().timestamp_micros(); 71 71 72 - let _ = diesel_async::RunQueryDsl::execute( 73 - diesel::insert_into(schema::bookmarks::table) // TODO: Consider communicating back to consumer worker using PostgreSQL queue 74 - .values(( 75 - schema::bookmarks::actor_id.eq(actor_id), 76 - schema::bookmarks::rkey.eq(rkey), 77 - schema::bookmarks::post_actor_id.eq(post_actor_id), 78 - schema::bookmarks::post_rkey.eq(post_rkey), 79 - )) 80 - .on_conflict((schema::bookmarks::actor_id, schema::bookmarks::post_actor_id, schema::bookmarks::post_rkey)) 81 - .do_nothing(), 72 + // Append to bookmarks array (off-protocol, managed directly by AppView) 73 + // Deduplicates based on post_actor_id + post_rkey 74 + diesel_async::RunQueryDsl::execute( 75 + diesel::sql_query( 76 + "UPDATE actors 77 + SET bookmarks = COALESCE(bookmarks, ARRAY[]::bookmark_record[]) || 78 + ARRAY[ROW($2, $3, $4)::bookmark_record] 79 + WHERE id = $1 80 + AND NOT EXISTS ( 81 + SELECT 1 FROM unnest(bookmarks) b 82 + WHERE (b).post_actor_id = $2 AND (b).post_rkey = $3 83 + )" 84 + ) 85 + .bind::<diesel::sql_types::Integer, _>(actor_id) 86 + .bind::<diesel::sql_types::Integer, _>(post_actor_id) 87 + .bind::<diesel::sql_types::BigInt, _>(post_rkey) 88 + .bind::<diesel::sql_types::BigInt, _>(rkey), 82 89 &mut conn, 83 90 ) 84 91 .await?; ··· 137 144 ) 138 145 .await?; 139 146 140 - let _ = diesel_async::RunQueryDsl::execute( 141 - diesel::delete(schema::bookmarks::table).filter( // TODO: Consider communicating back to consumer worker using PostgreSQL queue 142 - schema::bookmarks::actor_id 143 - .eq(actor_id) 144 - .and(schema::bookmarks::post_actor_id.eq(post_actor_id)) 145 - .and(schema::bookmarks::post_rkey.eq(post_rkey)), 146 - ), 147 + // Remove from bookmarks array (off-protocol, managed directly by AppView) 148 + diesel_async::RunQueryDsl::execute( 149 + diesel::sql_query( 150 + "UPDATE actors 151 + SET bookmarks = ARRAY( 152 + SELECT b FROM unnest(bookmarks) AS b 153 + WHERE NOT ((b).post_actor_id = $2 AND (b).post_rkey = $3) 154 + ) 155 + WHERE id = $1" 156 + ) 157 + .bind::<diesel::sql_types::Integer, _>(actor_id) 158 + .bind::<diesel::sql_types::Integer, _>(post_actor_id) 159 + .bind::<diesel::sql_types::BigInt, _>(post_rkey), 147 160 &mut conn, 148 161 ) 149 162 .await?;
+29 -15
parakeet/src/xrpc/app_bsky/graph/mutes.rs
··· 96 96 ) 97 97 .await?; 98 98 99 - let _ = diesel_async::RunQueryDsl::execute( 100 - diesel::insert_into(schema::mutes::table) // TODO: Consider communicating back to consumer worker using PostgreSQL queue 101 - .values(( 102 - schema::mutes::actor_id.eq(actor_id), 103 - schema::mutes::subject_actor_id.eq(subject_actor_id), 104 - schema::mutes::created_at.eq(chrono::Utc::now()), 105 - )) 106 - .on_conflict((schema::mutes::actor_id, schema::mutes::subject_actor_id)) 107 - .do_nothing(), 99 + // Append to mutes array (off-protocol, managed directly by AppView) 100 + // Deduplicates based on subject_actor_id 101 + let created_at = chrono::Utc::now(); 102 + diesel_async::RunQueryDsl::execute( 103 + diesel::sql_query( 104 + "UPDATE actors 105 + SET mutes = COALESCE(mutes, ARRAY[]::mute_record[]) || 106 + ARRAY[ROW($2, $3)::mute_record] 107 + WHERE id = $1 108 + AND NOT EXISTS ( 109 + SELECT 1 FROM unnest(mutes) m 110 + WHERE (m).subject_actor_id = $2 111 + )" 112 + ) 113 + .bind::<diesel::sql_types::Integer, _>(actor_id) 114 + .bind::<diesel::sql_types::Integer, _>(subject_actor_id) 115 + .bind::<diesel::sql_types::Timestamptz, _>(created_at), 108 116 &mut conn, 109 117 ) 110 118 .await?; ··· 190 198 ) 191 199 .await?; 192 200 193 - let _ = diesel_async::RunQueryDsl::execute( 194 - diesel::delete(schema::mutes::table).filter( // TODO: Consider communicating back to consumer worker using PostgreSQL queue 195 - schema::mutes::actor_id 196 - .eq(actor_id) 197 - .and(schema::mutes::subject_actor_id.eq(subject_actor_id)), 198 - ), 201 + // Remove from mutes array (off-protocol, managed directly by AppView) 202 + diesel_async::RunQueryDsl::execute( 203 + diesel::sql_query( 204 + "UPDATE actors 205 + SET mutes = ARRAY( 206 + SELECT m FROM unnest(mutes) AS m 207 + WHERE NOT ((m).subject_actor_id = $2) 208 + ) 209 + WHERE id = $1" 210 + ) 211 + .bind::<diesel::sql_types::Integer, _>(actor_id) 212 + .bind::<diesel::sql_types::Integer, _>(subject_actor_id), 199 213 &mut conn, 200 214 ) 201 215 .await?;
+36 -17
parakeet/src/xrpc/community_lexicon/bookmarks.rs
··· 6 6 use axum::Json; 7 7 use diesel::prelude::*; 8 8 use lexica::community_lexicon::bookmarks::Bookmark; 9 - use parakeet_db::{models, schema}; 9 + use parakeet_db::schema; 10 10 use serde::{Deserialize, Serialize}; 11 11 12 12 #[derive(Debug, Deserialize)] ··· 41 41 ) 42 42 .await?; 43 43 44 - let mut bookmarks_query = schema::bookmarks::table 45 - .select(models::Bookmark::as_select()) 46 - .filter(schema::bookmarks::actor_id.eq(actor_id)) 47 - .into_boxed(); 44 + // Note: tags filtering not supported in current schema 45 + if query.tags.is_some() { 46 + tracing::warn!("tags filtering requested but not supported in current schema"); 47 + } 48 + 49 + // Read from bookmarks array 50 + use diesel::sql_types::{BigInt, Integer, Nullable}; 48 51 49 - if let Some(cursor) = datetime_cursor(query.cursor.as_ref()) { 50 - // Convert timestamp cursor to TID for rkey-based pagination 51 - let cursor_micros = cursor.timestamp_micros(); 52 - let cursor_tid = cursor_micros << 10; // Shift left 10 bits to create approximate TID 53 - bookmarks_query = bookmarks_query.filter(schema::bookmarks::rkey.lt(cursor_tid)); 52 + #[derive(diesel::QueryableByName)] 53 + struct BookmarkRecord { 54 + #[diesel(sql_type = Integer)] 55 + post_actor_id: i32, 56 + #[diesel(sql_type = BigInt)] 57 + post_rkey: i64, 58 + #[diesel(sql_type = BigInt)] 59 + rkey: i64, 54 60 } 55 61 56 - // Note: tags filtering not supported in current schema 57 - if query.tags.is_some() { 58 - tracing::warn!("tags filtering requested but not supported in current schema"); 62 + impl BookmarkRecord { 63 + fn created_at(&self) -> chrono::DateTime<chrono::Utc> { 64 + parakeet_db::tid_util::tid_to_datetime(self.rkey) 65 + } 59 66 } 60 67 68 + let cursor_tid = query.cursor.as_ref() 69 + .and_then(|c| datetime_cursor(Some(c))) 70 + .map(|dt| dt.timestamp_micros() << 10); 71 + 61 72 let results = diesel_async::RunQueryDsl::load( 62 - bookmarks_query 63 - .order(schema::bookmarks::rkey.desc()) 64 - .limit(i64::from(limit)), 73 + diesel::sql_query( 74 + "SELECT (b).post_actor_id, (b).post_rkey, (b).rkey 75 + FROM actors, unnest(bookmarks) AS b 76 + WHERE id = $1 77 + AND ($2::bigint IS NULL OR (b).rkey < $2) 78 + ORDER BY (b).rkey DESC 79 + LIMIT $3" 80 + ) 81 + .bind::<Integer, _>(actor_id) 82 + .bind::<Nullable<BigInt>, _>(cursor_tid) 83 + .bind::<BigInt, _>(i64::from(limit)), 65 84 &mut conn, 66 85 ) 67 86 .await?; 68 87 69 88 let cursor = results 70 89 .last() 71 - .map(|bm| bm.created_at().timestamp_millis().to_string()); 90 + .map(|bm: &BookmarkRecord| bm.created_at().timestamp_millis().to_string()); 72 91 73 92 // Reconstruct post AT URIs from posts table using natural keys 74 93 // Note: Bookmarks can only reference posts (not feedgens, lists, etc.)