A community based topic aggregation platform built on atproto

feat(db): migrate content_labels from TEXT[] to JSONB for selfLabels

Migrates content_labels column from TEXT[] to JSONB to preserve full
com.atproto.label.defs#selfLabels structure including the optional 'neg' field.

Changes:
- Migration 015: TEXT[] → JSONB with data conversion function
- Convert existing {nsfw,spoiler} to {"values":[{"val":"nsfw"},{"val":"spoiler"}]}
- Update post_repo to store/retrieve full JSON blob (no flattening)
- Update feed repos to deserialize JSONB directly
- Remove pq.StringArray usage from all repositories

Before: TEXT[] storage lost 'neg' field and future extensions
After: JSONB preserves complete selfLabels structure with no data loss

Migration uses temporary PL/pgSQL function to handle conversion safely.
Rollback migration converts back to TEXT[] (lossy - drops 'neg' field).

+80 -35
+47
internal/db/migrations/015_alter_content_labels_to_jsonb.sql
··· 1 + -- +goose Up 2 + -- Change content_labels from TEXT[] to JSONB to preserve full com.atproto.label.defs#selfLabels structure 3 + -- This allows storing the optional 'neg' field and future extensions 4 + 5 + -- Create temporary function to convert TEXT[] to selfLabels JSONB 6 + -- +goose StatementBegin 7 + CREATE OR REPLACE FUNCTION convert_labels_to_jsonb(labels TEXT[]) 8 + RETURNS JSONB AS $$ 9 + BEGIN 10 + IF labels IS NULL OR array_length(labels, 1) = 0 THEN 11 + RETURN NULL; 12 + END IF; 13 + 14 + RETURN jsonb_build_object( 15 + 'values', 16 + (SELECT jsonb_agg(jsonb_build_object('val', label)) 17 + FROM unnest(labels) AS label) 18 + ); 19 + END; 20 + $$ LANGUAGE plpgsql IMMUTABLE; 21 + -- +goose StatementEnd 22 + 23 + -- Convert column type using the function 24 + ALTER TABLE posts 25 + ALTER COLUMN content_labels TYPE JSONB 26 + USING convert_labels_to_jsonb(content_labels); 27 + 28 + -- Drop the temporary function 29 + DROP FUNCTION convert_labels_to_jsonb(TEXT[]); 30 + 31 + -- Update column comment 32 + COMMENT ON COLUMN posts.content_labels IS 'Self-applied labels per com.atproto.label.defs#selfLabels (JSONB: {"values":[{"val":"nsfw","neg":false}]})'; 33 + 34 + -- +goose Down 35 + -- Revert JSONB back to TEXT[] (lossy - drops 'neg' field) 36 + ALTER TABLE posts 37 + ALTER COLUMN content_labels TYPE TEXT[] 38 + USING CASE 39 + WHEN content_labels IS NULL THEN NULL 40 + ELSE ARRAY( 41 + SELECT value->>'val' 42 + FROM jsonb_array_elements(content_labels->'values') AS value 43 + ) 44 + END; 45 + 46 + -- Restore original comment 47 + COMMENT ON COLUMN posts.content_labels IS 'Self-applied labels (nsfw, spoiler, violence)';
+11 -8
internal/db/postgres/feed_repo.go
··· 11 11 "strconv" 12 12 "strings" 13 13 "time" 14 - 15 - "github.com/lib/pq" 16 14 ) 17 15 18 16 type postgresFeedRepo struct { ··· 329 327 communityRef posts.CommunityRef 330 328 title, content sql.NullString 331 329 facets, embed sql.NullString 332 - labels pq.StringArray 330 + labelsJSON sql.NullString 333 331 editedAt sql.NullTime 334 332 communityAvatar sql.NullString 335 333 hotRank sql.NullFloat64 ··· 339 337 &postView.URI, &postView.CID, &postView.RKey, 340 338 &authorView.DID, &authorView.Handle, 341 339 &communityRef.DID, &communityRef.Name, &communityAvatar, 342 - &title, &content, &facets, &embed, &labels, 340 + &title, &content, &facets, &embed, &labelsJSON, 343 341 &postView.CreatedAt, &editedAt, &postView.IndexedAt, 344 342 &postView.UpvoteCount, &postView.DownvoteCount, &postView.Score, &postView.CommentCount, 345 343 &hotRank, ··· 386 384 // Alpha: No viewer state for basic feed 387 385 // TODO(feed-generator): Implement viewer state (saved, voted, blocked) in feed generator skeleton 388 386 389 - // Build the record (required by lexicon - social.coves.post.record structure) 387 + // Build the record (required by lexicon - social.coves.community.post structure) 390 388 record := map[string]interface{}{ 391 - "$type": "social.coves.post.record", 389 + "$type": "social.coves.community.post", 392 390 "community": communityRef.DID, 393 391 "author": authorView.DID, 394 392 "createdAt": postView.CreatedAt.Format(time.RFC3339), ··· 413 411 record["embed"] = embedData 414 412 } 415 413 } 416 - if len(labels) > 0 { 417 - record["contentLabels"] = labels 414 + if labelsJSON.Valid { 415 + // Labels are stored as JSONB containing full com.atproto.label.defs#selfLabels structure 416 + // Deserialize and include in record 417 + var selfLabels posts.SelfLabels 418 + if err := json.Unmarshal([]byte(labelsJSON.String), &selfLabels); err == nil { 419 + record["labels"] = selfLabels 420 + } 418 421 } 419 422 420 423 postView.Record = record
+10 -7
internal/db/postgres/feed_repo_base.go
··· 12 12 "strconv" 13 13 "strings" 14 14 "time" 15 - 16 - "github.com/lib/pq" 17 15 ) 18 16 19 17 // feedRepoBase contains shared logic for timeline and discover feed repositories ··· 283 281 communityRef posts.CommunityRef 284 282 title, content sql.NullString 285 283 facets, embed sql.NullString 286 - labels pq.StringArray 284 + labelsJSON sql.NullString 287 285 editedAt sql.NullTime 288 286 communityAvatar sql.NullString 289 287 hotRank sql.NullFloat64 ··· 293 291 &postView.URI, &postView.CID, &postView.RKey, 294 292 &authorView.DID, &authorView.Handle, 295 293 &communityRef.DID, &communityRef.Name, &communityAvatar, 296 - &title, &content, &facets, &embed, &labels, 294 + &title, &content, &facets, &embed, &labelsJSON, 297 295 &postView.CreatedAt, &editedAt, &postView.IndexedAt, 298 296 &postView.UpvoteCount, &postView.DownvoteCount, &postView.Score, &postView.CommentCount, 299 297 &hotRank, ··· 339 337 340 338 // Build the record (required by lexicon) 341 339 record := map[string]interface{}{ 342 - "$type": "social.coves.post.record", 340 + "$type": "social.coves.community.post", 343 341 "community": communityRef.DID, 344 342 "author": authorView.DID, 345 343 "createdAt": postView.CreatedAt.Format(time.RFC3339), ··· 364 362 record["embed"] = embedData 365 363 } 366 364 } 367 - if len(labels) > 0 { 368 - record["contentLabels"] = labels 365 + if labelsJSON.Valid { 366 + // Labels are stored as JSONB containing full com.atproto.label.defs#selfLabels structure 367 + // Deserialize and include in record 368 + var selfLabels posts.SelfLabels 369 + if err := json.Unmarshal([]byte(labelsJSON.String), &selfLabels); err == nil { 370 + record["labels"] = selfLabels 371 + } 369 372 } 370 373 371 374 postView.Record = record
+12 -20
internal/db/postgres/post_repo.go
··· 4 4 "Coves/internal/core/posts" 5 5 "context" 6 6 "database/sql" 7 - "encoding/json" 8 7 "fmt" 9 8 "strings" 10 - 11 - "github.com/lib/pq" 12 9 ) 13 10 14 11 type postgresPostRepo struct { ··· 36 33 embedJSON.Valid = true 37 34 } 38 35 39 - // Convert content labels to PostgreSQL array 40 - var labelsArray pq.StringArray 36 + // Store content labels as JSONB 37 + // post.ContentLabels contains com.atproto.label.defs#selfLabels JSON: {"values":[{"val":"nsfw","neg":false}]} 38 + // Store the full JSON blob to preserve the 'neg' field and future extensions 39 + var labelsJSON sql.NullString 41 40 if post.ContentLabels != nil { 42 - // Parse JSON array string to []string 43 - var labels []string 44 - if err := json.Unmarshal([]byte(*post.ContentLabels), &labels); err == nil { 45 - labelsArray = labels 46 - } 41 + labelsJSON.String = *post.ContentLabels 42 + labelsJSON.Valid = true 47 43 } 48 44 49 45 query := ` ··· 62 58 err := r.db.QueryRowContext( 63 59 ctx, query, 64 60 post.URI, post.CID, post.RKey, post.AuthorDID, post.CommunityDID, 65 - post.Title, post.Content, facetsJSON, embedJSON, labelsArray, 61 + post.Title, post.Content, facetsJSON, embedJSON, labelsJSON, 66 62 post.CreatedAt, 67 63 ).Scan(&post.ID, &post.IndexedAt) 68 64 if err != nil { ··· 101 97 ` 102 98 103 99 var post posts.Post 104 - var facetsJSON, embedJSON sql.NullString 105 - var contentLabels pq.StringArray 100 + var facetsJSON, embedJSON, labelsJSON sql.NullString 106 101 107 102 err := r.db.QueryRowContext(ctx, query, uri).Scan( 108 103 &post.ID, &post.URI, &post.CID, &post.RKey, 109 104 &post.AuthorDID, &post.CommunityDID, 110 - &post.Title, &post.Content, &facetsJSON, &embedJSON, &contentLabels, 105 + &post.Title, &post.Content, &facetsJSON, &embedJSON, &labelsJSON, 111 106 &post.CreatedAt, &post.EditedAt, &post.IndexedAt, &post.DeletedAt, 112 107 &post.UpvoteCount, &post.DownvoteCount, &post.Score, &post.CommentCount, 113 108 ) ··· 126 121 if embedJSON.Valid { 127 122 post.Embed = &embedJSON.String 128 123 } 129 - if len(contentLabels) > 0 { 130 - labelsJSON, marshalErr := json.Marshal(contentLabels) 131 - if marshalErr == nil { 132 - labelsStr := string(labelsJSON) 133 - post.ContentLabels = &labelsStr 134 - } 124 + if labelsJSON.Valid { 125 + // Labels are stored as JSONB containing full com.atproto.label.defs#selfLabels structure 126 + post.ContentLabels = &labelsJSON.String 135 127 } 136 128 137 129 return &post, nil