···2626 SELECT id FROM actors WHERE did = $2
2727 )
2828 SELECT
2929- EXISTS(SELECT 1 FROM follows WHERE actor_id = (SELECT id FROM root_actor) AND subject_actor_id = (SELECT id FROM post_actor)) as following,
3030- EXISTS(SELECT 1 FROM follows WHERE actor_id = (SELECT id FROM post_actor) AND subject_actor_id = (SELECT id FROM root_actor)) as followed",
2929+ EXISTS(
3030+ SELECT 1 FROM actors a, unnest(COALESCE(a.following, ARRAY[]::follow_record[])) AS f
3131+ WHERE a.id = (SELECT id FROM root_actor)
3232+ AND (f).subject_actor_id = (SELECT id FROM post_actor)
3333+ ) as following,
3434+ EXISTS(
3535+ SELECT 1 FROM actors a, unnest(COALESCE(a.following, ARRAY[]::follow_record[])) AS f
3636+ WHERE a.id = (SELECT id FROM post_actor)
3737+ AND (f).subject_actor_id = (SELECT id FROM root_actor)
3838+ ) as followed",
3139 &[&root_author, &post_author],
3240 )
3341 .await?;
···8694 SPLIT_PART(SUBSTRING(uri FROM 6), '/', 3) as rkey
8795 FROM list_uris
8896 ),
8989- list_ids AS (
9090- SELECT l.id
9797+ list_keys AS (
9898+ SELECT a.id as list_owner_actor_id, lp.rkey as list_rkey
9199 FROM list_parts lp
92100 INNER JOIN actors a ON a.did = lp.did
93101 INNER JOIN lists l ON l.actor_id = a.id AND l.rkey = lp.rkey
···95103 SELECT count(*)
96104 FROM list_items li
97105 INNER JOIN actors a ON a.did = $2
9898- WHERE li.list_id IN (SELECT id FROM list_ids)
9999- AND li.subject_actor_id = a.id",
106106+ INNER JOIN list_keys lk ON li.list_owner_actor_id = lk.list_owner_actor_id
107107+ AND li.list_rkey = lk.list_rkey
108108+ WHERE li.subject_actor_id = a.id",
100109 &[&allow_lists, &post_author],
101110 )
102111 .await?;
+4-4
consumer/src/db/mod.rs
···134134) -> Result<bool> {
135135 let row = conn
136136 .query_opt(
137137- "SELECT 1 FROM thread_mutes
138138- WHERE actor_id = $1
139139- AND root_post_actor_id = $2
140140- AND root_post_rkey = $3",
137137+ "SELECT 1 FROM actors, unnest(COALESCE(thread_mutes, ARRAY[]::thread_mute_record[])) AS tm
138138+ WHERE id = $1
139139+ AND (tm).root_post_actor_id = $2
140140+ AND (tm).root_post_rkey = $3",
141141 &[&recipient_actor_id, &root_post_actor_id, &root_post_rkey],
142142 )
143143 .await?;
+36-28
consumer/src/db/operations/graph.rs
···2222 let (table_id, key_id) = crate::database_writer::locking::actor_record_lock("follows", actor_id, rkey);
2323 crate::database_writer::locking::acquire_lock(conn, table_id, key_id).await?;
24242525+ // Check if exact same follow already exists (same subject_actor_id and rkey)
2626+ let already_exists = conn
2727+ .query_opt(
2828+ "SELECT 1 FROM actors, unnest(COALESCE(following, ARRAY[]::follow_record[])) AS f
2929+ WHERE id = $1 AND (f).subject_actor_id = $2 AND (f).rkey = $3",
3030+ &[&actor_id, &subject_actor_id, &rkey],
3131+ )
3232+ .await?
3333+ .is_some();
3434+3535+ if already_exists {
3636+ return Ok(0); // Duplicate, no change needed
3737+ }
3838+2539 // BIDIRECTIONAL UPDATE: Update both following[] and followers[] arrays
2626- // 1. Add to actor's following array
2727- // 2. Add to subject_actor's followers array
4040+ // 1. Add to actor's following array - stores (subject_actor_id, rkey)
4141+ // 2. Add to subject_actor's followers array - stores (follower_actor_id, rkey) using same follow_record type
2842 // Returns number of rows updated (should be 2 if both actors exist)
2943 let rows = conn
3044 .execute(
3131- "WITH follow_record AS (
3232- SELECT ROW($2, $3)::follow_record as rec
4545+ "WITH follow_rec AS (
4646+ SELECT ROW($3, $2)::follow_record as rec -- (subject_actor_id, rkey)
3347 ),
3448 update_following AS (
3549 UPDATE actors
3650 SET following = COALESCE(following, ARRAY[]::follow_record[]) ||
3737- (SELECT ARRAY[rec] FROM follow_record)
5151+ (SELECT ARRAY[rec] FROM follow_rec)
3852 WHERE id = $1
3953 AND NOT EXISTS (
4054 SELECT 1 FROM unnest(following) f WHERE (f).rkey = $2
···4458 update_followers AS (
4559 UPDATE actors
4660 SET followers = COALESCE(followers, ARRAY[]::follow_record[]) ||
4747- ARRAY[ROW($1, $2)::follow_record]
6161+ ARRAY[ROW($1, $2)::follow_record] -- (follower_actor_id, rkey)
4862 WHERE id = $3
4963 AND NOT EXISTS (
5050- SELECT 1 FROM unnest(followers) f WHERE (f).actor_id = $1 AND (f).rkey = $2
6464+ SELECT 1 FROM unnest(followers) f WHERE (f).subject_actor_id = $1 AND (f).rkey = $2
5165 )
5266 RETURNING 1
5367 )
···5872 .await?;
59736074 // Return 1 if at least one array was updated (follow was created)
6161- // Return 0 if neither was updated (duplicate follow)
7575+ // Return 0 if neither was updated (shouldn't happen since we checked already_exists)
6276 Ok(if rows > 0 { 1 } else { 0 })
6377}
6478···94108 UPDATE actors
95109 SET followers = ARRAY(
96110 SELECT f FROM unnest(followers) AS f
9797- WHERE NOT ((f).actor_id = $2 AND (f).rkey = $1)
111111+ WHERE NOT ((f).subject_actor_id = $2 AND (f).rkey = $1)
98112 )
99113 WHERE id = (SELECT subject_actor_id FROM follow_to_delete)
100114 AND EXISTS (
101115 SELECT 1 FROM unnest(followers) f
102102- WHERE (f).actor_id = $2 AND (f).rkey = $1
116116+ WHERE (f).subject_actor_id = $2 AND (f).rkey = $1
103117 )
104118 RETURNING 1
105119 )
···243257244258 let (list_did, _collection, list_rkey) = (parts[0], parts[1], parts[2]);
245259246246- // Resolve list owner DID to actor_id
247247- let list_actor_id: i32 = conn.query_one(
248248- "INSERT INTO actors (did, handle, created_at)
249249- VALUES ($1, NULL, NOW())
250250- ON CONFLICT (did) DO UPDATE SET did = EXCLUDED.did
251251- RETURNING id",
252252- &[&list_did],
253253- )
254254- .await?
255255- .get(0);
260260+ // Resolve list owner DID to actor_id (creates stub actor if needed)
261261+ let (list_actor_id, _, _) = super::feed::get_actor_id(conn, list_did).await?;
256262257263 // Acquire advisory lock on record to prevent deadlocks
258264 let (table_id, key_id) = crate::database_writer::locking::actor_record_lock("list_blocks", actor_id, rkey);
259265 crate::database_writer::locking::acquire_lock(conn, table_id, key_id).await?;
260266261267 // Append to list_blocks array with natural key deduplication
268268+ // list_block_record has fields: (list_actor_id, list_rkey, rkey, cid)
262269 // Returns number of rows updated (1 if inserted, 0 if duplicate)
263270 let rows = conn.execute(
264271 "UPDATE actors
···267274 WHERE id = $1
268275 AND NOT EXISTS (
269276 SELECT 1 FROM unnest(list_blocks) lb
270270- WHERE (lb).rkey = $2
277277+ WHERE (lb).rkey = $4
271278 )",
272279 &[
273280 &actor_id,
274274- &rkey,
275275- &cid_digest,
276276- &list_actor_id,
277277- &list_rkey,
281281+ &list_actor_id, // $2: list_actor_id
282282+ &list_rkey, // $3: list_rkey
283283+ &rkey, // $4: rkey
284284+ &cid_digest, // $5: cid
278285 ],
279286 )
280287 .await
···320327 let (table_id, key_id) = crate::database_writer::locking::actor_record_lock("list_items", actor_id, rkey);
321328 crate::database_writer::locking::acquire_lock(conn, table_id, key_id).await?;
322329323323- // Resolve list_id by creating stub list if needed
324324- let list_id = super::feed::ensure_list_id(conn, &rec.list).await?;
330330+ // Resolve list natural keys by creating stub list if needed
331331+ let (list_owner_actor_id, list_rkey) = super::feed::ensure_list_natural_key(conn, &rec.list).await?;
325332326333 conn.execute(
327334 include_str!("../sql/list_item_upsert.sql"),
···331338 &cid_digest,
332339 // Note: created_at (param $4) removed - derived from TID rkey
333340 &rkey,
334334- &list_id, // Pass resolved list_id directly
341341+ &list_owner_actor_id, // Pass resolved list_owner_actor_id (may be NULL)
342342+ &list_rkey, // Pass resolved list_rkey (may be NULL)
335343 ],
336344 )
337345 .await
+13-15
consumer/src/db/record_exists/queries.rs
···5353 Ok(row.is_some())
5454}
55555656-/// Check if a follow exists
5656+/// Check if a follow exists in the actor's following[] array
5757pub async fn follow_exists<C: GenericClient>(conn: &C, did: &str, rkey: i64) -> QueryResult<bool> {
5858 let row = conn
5959 .query_opt(
6060- "SELECT 1 FROM follows f
6161- INNER JOIN actors a ON f.actor_id = a.id
6262- WHERE a.did = $1 AND f.rkey = $2",
6060+ "SELECT 1 FROM actors a, unnest(COALESCE(a.following, ARRAY[]::follow_record[])) AS f
6161+ WHERE a.did = $1 AND (f).rkey = $2",
6362 &[&did, &rkey],
6463 )
6564 .await?;
6665 Ok(row.is_some())
6766}
68676969-/// Check if a block exists
6868+/// Check if a block exists in the actor's blocks[] array
7069pub async fn block_exists<C: GenericClient>(conn: &C, did: &str, rkey: i64) -> QueryResult<bool> {
7170 let row = conn
7271 .query_opt(
7373- "SELECT 1 FROM blocks b
7474- INNER JOIN actors a ON b.actor_id = a.id
7575- WHERE a.did = $1 AND b.rkey = $2",
7272+ "SELECT 1 FROM actors a, unnest(COALESCE(a.blocks, ARRAY[]::block_record[])) AS b
7373+ WHERE a.did = $1 AND (b).rkey = $2",
7674 &[&did, &rkey],
7775 )
7876 .await?;
···250248251249/// Check if a labeler exists and should skip fetching
252250///
251251+/// DENORMALIZED: Labelers are now stored directly on actors table (labeler_status, labeler_cid, etc)
252252+///
253253/// Returns true if the labeler exists with any status except 'stub'.
254254/// - stub: Return false (needs fetching)
255255/// - missing: Return true (permanently unfetchable, skip)
···258258pub async fn labeler_exists<C: GenericClient>(conn: &C, did: &str) -> QueryResult<bool> {
259259 let row = conn
260260 .query_opt(
261261- "SELECT 1 FROM labelers l
262262- INNER JOIN actors a ON l.actor_id = a.id
263263- WHERE a.did = $1 AND l.status != 'stub'::labeler_status",
261261+ "SELECT 1 FROM actors
262262+ WHERE did = $1 AND labeler_status IS NOT NULL AND labeler_status != 'stub'::labeler_status",
264263 &[&did],
265264 )
266265 .await?;
···279278 Ok(row.is_some())
280279}
281280282282-/// Check if a bookmark exists
281281+/// Check if a bookmark exists in the actor's bookmarks[] array
283282pub async fn bookmark_exists<C: GenericClient>(
284283 conn: &C,
285284 did: &str,
···287286) -> QueryResult<bool> {
288287 let row = conn
289288 .query_opt(
290290- "SELECT 1 FROM bookmarks b
291291- INNER JOIN actors a ON b.actor_id = a.id
292292- WHERE a.did = $1 AND b.rkey = $2",
289289+ "SELECT 1 FROM actors a, unnest(COALESCE(a.bookmarks, ARRAY[]::bookmark_record[])) AS b
290290+ WHERE a.did = $1 AND (b).rkey = $2",
293291 &[&did, &rkey],
294292 )
295293 .await?;
+8-5
consumer/src/db/sql/list_item_upsert.sql
···11--- Insert list_item with self-contained schema (no records table)
11+-- Insert list_item with natural keys (no synthetic list_id)
22-- This prevents foreign key violations when indexing list items
33--- Parameters: $1=actor_id(INT4), $2=subject_actor_id(i32), $3=cid(bytea), $4=rkey, $5=list_id(INT8)
33+-- Parameters: $1=actor_id(INT4), $2=subject_actor_id(i32), $3=cid(bytea), $4=rkey,
44+-- $5=list_owner_actor_id(INT4), $6=list_rkey(TEXT)
45-- Note: created_at is derived from TID rkey
56-- Note: actor_id is provided by caller after calling get_actor_id (ensures zero sequence waste)
66-INSERT INTO list_items (actor_id, rkey, cid, list_id, subject_actor_id)
77+INSERT INTO list_items (actor_id, rkey, cid, list_owner_actor_id, list_rkey, subject_actor_id)
78SELECT
89 $1, -- actor_id (provided by caller)
910 $4, -- rkey (INT8)
1011 $3, -- cid (embedded, already bytea)
1111- $5, -- list_id (already resolved, may be stub)
1212+ $5, -- list_owner_actor_id (already resolved, may be NULL)
1313+ $6, -- list_rkey (already resolved, may be NULL)
1214 $2::int4 -- subject_actor_id (already resolved, cast to int4)
1315WHERE $1::int4 IS NOT NULL -- Only insert if owner exists
1416 AND $2::int4 IS NOT NULL -- Only insert if subject exists
1517ON CONFLICT (actor_id, rkey) DO UPDATE SET
1618 cid=EXCLUDED.cid,
1717- list_id=EXCLUDED.list_id,
1919+ list_owner_actor_id=EXCLUDED.list_owner_actor_id,
2020+ list_rkey=EXCLUDED.list_rkey,
1821 subject_actor_id=EXCLUDED.subject_actor_id
+6-5
consumer/src/workers/stub_resolution/queries.rs
···75757676/// Find stub labelers and return their AT URIs for fetching
7777///
7878+/// DENORMALIZED: Labelers are now stored directly on actors table (labeler_status, labeler_cid, etc)
7979+///
7880/// This query finds labelers with status='stub', constructs their AT URIs,
7981/// and returns them for enqueuing to the fetch queue.
8082/// Limits to 100 records per batch to avoid overwhelming the fetch queue.
8183pub async fn find_stub_labelers<C: GenericClient>(conn: &C) -> QueryResult<Vec<String>> {
8284 let rows = conn
8385 .query(
8484- "SELECT 'at://' || a.did || '/app.bsky.labeler.service/self' as uri
8585- FROM labelers l
8686- INNER JOIN actors a ON l.actor_id = a.id
8787- WHERE l.status = 'stub'::labeler_status
8888- ORDER BY l.created_at ASC
8686+ "SELECT 'at://' || did || '/app.bsky.labeler.service/self' as uri
8787+ FROM actors
8888+ WHERE labeler_status = 'stub'::labeler_status
8989+ ORDER BY labeler_created_at ASC
8990 LIMIT 100",
9091 &[],
9192 )
+28-34
consumer/tests/graph_operations_test.rs
···8585 .get(0);
8686 assert_eq!(target_count, 1, "Target actor should be created");
87878888- // Verify follow relationship exists
8888+ // Verify follow relationship exists in following[] array
8989 let follow_count: i64 = tx
9090 .query_one(
9191- "SELECT COUNT(*) FROM follows f
9292- INNER JOIN actors a ON f.actor_id = a.id
9393- WHERE a.did = $1 AND f.rkey = tid_to_i64($2)",
9191+ "SELECT COUNT(*) FROM actors a, unnest(a.following) AS f
9292+ WHERE a.did = $1 AND (f).rkey = tid_to_i64($2)",
9493 &[&"did:plc:user123", &"3l7mkz4lmk245"],
9594 )
9695 .await
···214213 "Should return deleted target DID"
215214 );
216215217217- // Verify follow was deleted
216216+ // Verify follow was deleted from following[] array
218217 let follow_count: i64 = tx
219218 .query_one(
220220- "SELECT COUNT(*) FROM follows f
221221- INNER JOIN actors a ON f.actor_id = a.id
222222- WHERE a.did = $1 AND f.rkey = tid_to_i64($2)",
219219+ "SELECT COUNT(*) FROM actors a, unnest(COALESCE(a.following, ARRAY[]::follow_record[])) AS f
220220+ WHERE a.did = $1 AND (f).rkey = tid_to_i64($2)",
223221 &[&"did:plc:user789", &"3l7mkz4lmk247"],
224222 )
225223 .await
···294292 .get(0);
295293 assert!(blocked_exists, "Blocked actor should exist");
296294297297- // Verify block relationship
295295+ // Verify block relationship in blocks[] array
298296 let block_count: i64 = tx
299297 .query_one(
300300- "SELECT COUNT(*) FROM blocks b
301301- INNER JOIN actors a ON b.actor_id = a.id
298298+ "SELECT COUNT(*) FROM actors a, unnest(a.blocks) AS b
302299 WHERE a.did = $1",
303300 &[&"did:plc:blocker123"],
304301 )
···710707 .get(0);
711708 assert!(item_exists, "List item should exist");
712709713713- // Verify list_id is set (not pending)
714714- let list_id_is_set: bool = tx
710710+ // Verify list natural keys are set (not NULL)
711711+ let list_keys_are_set: bool = tx
715712 .query_one(
716716- "SELECT li.list_id IS NOT NULL
713713+ "SELECT li.list_owner_actor_id IS NOT NULL AND li.list_rkey IS NOT NULL
717714 FROM list_items li
718715 INNER JOIN actors a ON li.actor_id = a.id
719716 WHERE a.did = $1 AND li.rkey = tid_to_i64($2)",
···722719 .await
723720 .wrap_err("Failed to query list_items")?
724721 .get(0);
725725- assert!(list_id_is_set, "List ID should be set (not pending)");
722722+ assert!(list_keys_are_set, "List natural keys should be set (not NULL)");
726723 Ok(())
727724}
728725···766763 );
767764 assert_eq!(result.wrap_err("Operation failed")?, 1, "Should insert 1 row");
768765769769- // Verify list_id is set (not NULL) - stub list was created
770770- let list_id_is_set: bool = tx
766766+ // Verify list natural keys are set (not NULL) - stub list was created
767767+ let list_keys_are_set: bool = tx
771768 .query_one(
772772- "SELECT li.list_id IS NOT NULL
769769+ "SELECT li.list_owner_actor_id IS NOT NULL AND li.list_rkey IS NOT NULL
773770 FROM list_items li
774771 INNER JOIN actors a ON li.actor_id = a.id
775772 WHERE a.did = $1 AND li.rkey = tid_to_i64($2)",
···778775 .await
779776 .wrap_err("Failed to query list_items")?
780777 .get(0);
781781- assert!(list_id_is_set, "List ID should be set (stub list created)");
778778+ assert!(list_keys_are_set, "List natural keys should be set (stub list created)");
782779783780 // Verify the stub list was created with correct status
784781 let stub_list_row = tx
···938935 );
939936 assert_eq!(result.wrap_err("Operation failed")?, 1, "Should insert 1 row");
940937941941- // Verify list block exists
938938+ // Verify list block exists in list_blocks[] array
942939 let block_exists: bool = tx
943940 .query_one(
944941 "SELECT EXISTS(
945945- SELECT 1 FROM list_blocks lb
946946- INNER JOIN actors a ON lb.actor_id = a.id
947947- WHERE a.did = $1 AND lb.rkey = tid_to_i64($2)
942942+ SELECT 1 FROM actors a, unnest(a.list_blocks) AS lb
943943+ WHERE a.did = $1 AND (lb).rkey = tid_to_i64($2)
948944 )",
949945 &[&"did:plc:blocker7", &"3l7mkz4lmk24m"],
950946 )
···953949 .get(0);
954950 assert!(block_exists, "List block should exist");
955951956956- // Verify list_id is set
957957- let list_id_is_set: bool = tx
952952+ // Verify list natural keys are set
953953+ let list_keys_are_set: bool = tx
958954 .query_one(
959959- "SELECT lb.list_id IS NOT NULL
960960- FROM list_blocks lb
961961- INNER JOIN actors a ON lb.actor_id = a.id
962962- WHERE a.did = $1 AND lb.rkey = tid_to_i64($2)",
955955+ "SELECT (lb).list_actor_id IS NOT NULL AND (lb).list_rkey IS NOT NULL
956956+ FROM actors a, unnest(a.list_blocks) AS lb
957957+ WHERE a.did = $1 AND (lb).rkey = tid_to_i64($2)",
963958 &[&"did:plc:blocker7", &"3l7mkz4lmk24m"],
964959 )
965960 .await
966961 .wrap_err("Failed to query list_blocks")?
967962 .get(0);
968968- assert!(list_id_is_set, "List ID should be set");
963963+ assert!(list_keys_are_set, "List natural keys should be set");
969964 Ok(())
970965}
971966···10281023 );
10291024 assert_eq!(result.unwrap(), 1, "Should delete 1 row");
1030102510311031- // Verify list block was deleted
10261026+ // Verify list block was deleted from list_blocks[] array
10321027 let block_exists: bool = tx
10331028 .query_one(
10341029 "SELECT EXISTS(
10351035- SELECT 1 FROM list_blocks lb
10361036- INNER JOIN actors a ON lb.actor_id = a.id
10371037- WHERE a.did = $1 AND lb.rkey = tid_to_i64($2)
10301030+ SELECT 1 FROM actors a, unnest(COALESCE(a.list_blocks, ARRAY[]::list_block_record[])) AS lb
10311031+ WHERE a.did = $1 AND (lb).rkey = tid_to_i64($2)
10381032 )",
10391033 &[&"did:plc:blocker8", &"3l7mkz4lmk24o"],
10401034 )
+6-3
consumer/tests/notification_test.rs
···101101 .wrap_err("Failed to get actor ID")?
102102 .get(0);
103103104104- // Insert a thread mute using natural keys (root_post_actor_id, root_post_rkey)
105105- // This is the only place we manually INSERT as there's no function for it
104104+ // Insert a thread mute into the actors.thread_mutes[] array
105105+ // thread_mute_record has fields: (root_post_actor_id, root_post_rkey, created_at)
106106 tx.execute(
107107- "INSERT INTO thread_mutes (actor_id, root_post_actor_id, root_post_rkey, created_at) VALUES ($1, $2, $3, NOW())",
107107+ "UPDATE actors
108108+ SET thread_mutes = COALESCE(thread_mutes, ARRAY[]::thread_mute_record[]) ||
109109+ ARRAY[ROW($2, $3, NOW())::thread_mute_record]
110110+ WHERE id = $1",
108111 &[&actor_id, &rootpostauthor_id, &rkey_i64],
109112 )
110113 .await