···1717 return Ok(0); // Actor doesn't exist yet
1818 };
19192020- // drop any label defs not currently in the list
2121- let _ = conn
2222- .execute(
2323- "DELETE FROM labeler_defs WHERE labeler_actor_id=$1 AND NOT label_identifier = any($2)",
2424- &[&labeler_actor_id, &rec.policies.label_values],
2525- )
2626- .await?;
2727-2020+ // Build labeler_defs array from label values and definitions
2121+ // Maps label_identifier -> definition
2822 let definitions = rec
2923 .policies
3024 .label_value_definitions
···3226 .map(|def| (def.identifier.clone(), def))
3327 .collect::<HashMap<String, &LabelValueDefinition>>();
34282929+ // Build arrays of values for each composite field
3030+ let mut label_identifiers = Vec::new();
3131+ let mut severities = Vec::new();
3232+ let mut blurs_vals = Vec::new();
3333+ let mut default_settings = Vec::new();
3434+ let mut adult_onlys = Vec::new();
3535+ let mut locales_vals = Vec::new();
3636+3537 for label in &rec.policies.label_values {
3638 let definition = definitions.get(label);
37393838- let severity = definition.map(|v| v.severity.to_string());
3939- let blurs = definition.map(|v| v.blurs.to_string());
4040- let default_setting = definition
4141- .and_then(|v| v.default_setting)
4242- .map(|v| v.to_string());
4343- let adult_only = definition.and_then(|v| v.adult_only).unwrap_or_default();
4444- let locales = definition.and_then(|v| serde_json::to_value(&v.locales).ok());
4040+ label_identifiers.push(label.clone());
4141+ severities.push(definition.map(|v| v.severity.to_string()));
4242+ blurs_vals.push(definition.map(|v| v.blurs.to_string()));
4343+ default_settings.push(definition.and_then(|v| v.default_setting).map(|v| v.to_string()));
4444+ adult_onlys.push(definition.and_then(|v| v.adult_only).unwrap_or_default());
4545+ locales_vals.push(definition.and_then(|v| serde_json::to_value(&v.locales).ok()));
4646+ }
45474646- let _ = conn
4747- .execute(
4848- include_str!("sql/label_defs_upsert.sql"),
4949- &[
5050- &labeler_actor_id,
5151- &label,
5252- &severity,
5353- &blurs,
5454- &default_setting,
5555- &adult_only,
5656- &locales,
5757- ],
5858- )
5959- .await?;
6060- }
4848+ // Update labeler_defs array on actors table using composite type constructor
4949+ // ROW(...) constructs the composite type, ARRAY[...] builds the array
5050+ conn.execute(
5151+ "UPDATE actors
5252+ SET labeler_defs = (
5353+ SELECT ARRAY_AGG(
5454+ ROW(
5555+ label_identifier,
5656+ severity::text::label_severity,
5757+ blurs::text::label_blurs,
5858+ default_setting::text::label_default_setting,
5959+ adult_only,
6060+ locales,
6161+ NOW()
6262+ )::labeler_def_record
6363+ ORDER BY idx
6464+ )
6565+ FROM unnest($2::text[], $3::text[], $4::text[], $5::text[], $6::boolean[], $7::jsonb[])
6666+ WITH ORDINALITY AS t(label_identifier, severity, blurs, default_setting, adult_only, locales, idx)
6767+ )
6868+ WHERE id = $1",
6969+ &[
7070+ &labeler_actor_id,
7171+ &label_identifiers,
7272+ &severities,
7373+ &blurs_vals,
7474+ &default_settings,
7575+ &adult_onlys,
7676+ &locales_vals,
7777+ ],
7878+ )
7979+ .await?;
61806281 Ok(0)
6382}
+29-21
consumer/src/db/operations/labeler.rs
···88///
99/// This function:
1010/// 1. Gets/creates actor_id for the DID
1111-/// 2. Inserts stub labeler if not found (status='stub')
1111+/// 2. Sets labeler_cid and labeler_status='stub' on actors table if not already set
1212/// 3. Returns actor_id
1313///
1414/// Uses advisory locks to prevent concurrent transactions from racing on the same labeler URI.
1515///
1616-/// Labelers have a 1:1 relationship with actors (actor_id is the PK),
1717-/// so the actor_id serves as both the labeler identifier and return value.
1616+/// Labelers are now denormalized into the actors table with labeler_* columns.
1817pub async fn ensure_labeler_stub<C: GenericClient>(
1918 conn: &C,
2019 did: &str,
···3635 // Get/create actor_id (discard allowlist status and was_created, not needed for labelers)
3736 let actor_id = crate::db::actor::ensure_actor_id(conn, did, None, None, chrono::Utc::now()).await?;
38373939- // Use CTE to SELECT first, then conditionally INSERT only if not found
4040- // This prevents unnecessary sequence consumption when labeler stub already exists
4141- // Still uses ON CONFLICT for race condition safety between concurrent transactions
3838+ // Set labeler_cid and labeler_status on actors table if not already set
3939+ // Only update if labeler_cid is NULL (stub not yet created)
4240 conn.execute(
4343- "WITH existing AS (
4444- SELECT actor_id FROM labelers WHERE actor_id = $1
4545- )
4646- INSERT INTO labelers (actor_id, cid, created_at, status)
4747- SELECT $1, $2, NOW(), 'stub'::labeler_status
4848- WHERE NOT EXISTS (SELECT 1 FROM existing)
4949- ON CONFLICT (actor_id) DO NOTHING",
4141+ "UPDATE actors
4242+ SET labeler_cid = $2,
4343+ labeler_created_at = NOW(),
4444+ labeler_status = 'stub'::labeler_status,
4545+ labeler_like_count = 0
4646+ WHERE id = $1 AND labeler_cid IS NULL",
5047 &[&actor_id, &cid_digest],
5148 )
5249 .await?;
···108105109106pub async fn labeler_delete<C: GenericClient>(conn: &C, actor_id: i32) -> Result<u64> {
110107 // Labeler records always use rkey "self", so no rkey parameter needed
108108+ // Now sets labeler_* columns to NULL instead of deleting from separate table
111109112110 conn.execute(
113113- "DELETE FROM labelers
114114- WHERE actor_id = $1",
111111+ "UPDATE actors
112112+ SET labeler_cid = NULL,
113113+ labeler_created_at = NULL,
114114+ labeler_reasons = NULL,
115115+ labeler_subject_types = NULL,
116116+ labeler_subject_collections = NULL,
117117+ labeler_status = NULL,
118118+ labeler_like_count = NULL,
119119+ labeler_defs = NULL
120120+ WHERE id = $1",
115121 &[&actor_id],
116122 )
117123 .await
···122128///
123129/// This is called after bulk inserting labeler_likes to update the aggregate counts.
124130/// Uses a single UPDATE statement with aggregation for efficiency.
131131+/// Now updates actors.labeler_like_count instead of labelers.like_count
125132pub async fn increment_labeler_like_counts<C: GenericClient>(
126133 conn: &C,
127134 labeler_actor_ids: &[i32],
···131138 }
132139133140 conn.execute(
134134- "UPDATE labelers
135135- SET like_count = like_count + counts.count
141141+ "UPDATE actors
142142+ SET labeler_like_count = COALESCE(labeler_like_count, 0) + counts.count
136143 FROM (
137144 SELECT actor_id, COUNT(*) as count
138145 FROM unnest($1::int[]) as actor_id
139146 GROUP BY actor_id
140147 ) AS counts
141141- WHERE labelers.actor_id = counts.actor_id",
148148+ WHERE actors.id = counts.actor_id",
142149 &[&labeler_actor_ids],
143150 )
144151 .await
···148155/// Decrement like_count for a single labeler
149156///
150157/// This is called when deleting a labeler_like record.
158158+/// Now updates actors.labeler_like_count instead of labelers.like_count
151159pub async fn decrement_labeler_like_count<C: GenericClient>(
152160 conn: &C,
153161 labeler_actor_id: i32,
154162) -> Result<u64> {
155163 conn.execute(
156156- "UPDATE labelers
157157- SET like_count = GREATEST(like_count - 1, 0)
158158- WHERE actor_id = $1",
164164+ "UPDATE actors
165165+ SET labeler_like_count = GREATEST(COALESCE(labeler_like_count, 0) - 1, 0)
166166+ WHERE id = $1",
159167 &[&labeler_actor_id],
160168 )
161169 .await
+12-14
consumer/src/db/sql/label_service_upsert.sql
···11--- Insert/update labeler service with self-contained schema (no records table)
11+-- Insert/update labeler service on actors table (denormalized)
22-- Parameters: $1=actor_id, $2=cid(bytea), $3=reasons, $4=subject_types, $5=subject_collections
33-- NOTE: actor_id is provided by dispatcher after ensuring actor exists
44-INSERT INTO labelers (actor_id, cid, reasons, subject_types, subject_collections)
55-SELECT
66- $1, -- actor_id (provided by dispatcher)
77- $2::bytea, -- cid (embedded)
88- $3::text[]::reason_type[],
99- $4::text[]::subject_type[],
1010- $5
1111-ON CONFLICT (actor_id) DO UPDATE SET
1212- cid=EXCLUDED.cid,
1313- reasons=EXCLUDED.reasons,
1414- subject_types=EXCLUDED.subject_types,
1515- subject_collections=EXCLUDED.subject_collections,
1616- status='complete'::labeler_status
44+-- Sets labeler_* columns on actors table instead of separate labelers table
55+UPDATE actors
66+SET
77+ labeler_cid = $2::bytea,
88+ labeler_created_at = COALESCE(labeler_created_at, NOW()),
99+ labeler_reasons = $3::text[]::reason_type[],
1010+ labeler_subject_types = $4::text[]::subject_type[],
1111+ labeler_subject_collections = $5,
1212+ labeler_status = 'complete'::labeler_status,
1313+ labeler_like_count = COALESCE(labeler_like_count, 0)
1414+WHERE id = $1