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).

Changed files
+80 -35
internal
+47
internal/db/migrations/015_alter_content_labels_to_jsonb.sql
···
+
-- +goose Up
+
-- Change content_labels from TEXT[] to JSONB to preserve full com.atproto.label.defs#selfLabels structure
+
-- This allows storing the optional 'neg' field and future extensions
+
+
-- Create temporary function to convert TEXT[] to selfLabels JSONB
+
-- +goose StatementBegin
+
CREATE OR REPLACE FUNCTION convert_labels_to_jsonb(labels TEXT[])
+
RETURNS JSONB AS $$
+
BEGIN
+
IF labels IS NULL OR array_length(labels, 1) = 0 THEN
+
RETURN NULL;
+
END IF;
+
+
RETURN jsonb_build_object(
+
'values',
+
(SELECT jsonb_agg(jsonb_build_object('val', label))
+
FROM unnest(labels) AS label)
+
);
+
END;
+
$$ LANGUAGE plpgsql IMMUTABLE;
+
-- +goose StatementEnd
+
+
-- Convert column type using the function
+
ALTER TABLE posts
+
ALTER COLUMN content_labels TYPE JSONB
+
USING convert_labels_to_jsonb(content_labels);
+
+
-- Drop the temporary function
+
DROP FUNCTION convert_labels_to_jsonb(TEXT[]);
+
+
-- Update column comment
+
COMMENT ON COLUMN posts.content_labels IS 'Self-applied labels per com.atproto.label.defs#selfLabels (JSONB: {"values":[{"val":"nsfw","neg":false}]})';
+
+
-- +goose Down
+
-- Revert JSONB back to TEXT[] (lossy - drops 'neg' field)
+
ALTER TABLE posts
+
ALTER COLUMN content_labels TYPE TEXT[]
+
USING CASE
+
WHEN content_labels IS NULL THEN NULL
+
ELSE ARRAY(
+
SELECT value->>'val'
+
FROM jsonb_array_elements(content_labels->'values') AS value
+
)
+
END;
+
+
-- Restore original comment
+
COMMENT ON COLUMN posts.content_labels IS 'Self-applied labels (nsfw, spoiler, violence)';
+11 -8
internal/db/postgres/feed_repo.go
···
"strconv"
"strings"
"time"
-
-
"github.com/lib/pq"
)
type postgresFeedRepo struct {
···
communityRef posts.CommunityRef
title, content sql.NullString
facets, embed sql.NullString
-
labels pq.StringArray
+
labelsJSON sql.NullString
editedAt sql.NullTime
communityAvatar sql.NullString
hotRank sql.NullFloat64
···
&postView.URI, &postView.CID, &postView.RKey,
&authorView.DID, &authorView.Handle,
&communityRef.DID, &communityRef.Name, &communityAvatar,
-
&title, &content, &facets, &embed, &labels,
+
&title, &content, &facets, &embed, &labelsJSON,
&postView.CreatedAt, &editedAt, &postView.IndexedAt,
&postView.UpvoteCount, &postView.DownvoteCount, &postView.Score, &postView.CommentCount,
&hotRank,
···
// Alpha: No viewer state for basic feed
// TODO(feed-generator): Implement viewer state (saved, voted, blocked) in feed generator skeleton
-
// Build the record (required by lexicon - social.coves.post.record structure)
+
// Build the record (required by lexicon - social.coves.community.post structure)
record := map[string]interface{}{
-
"$type": "social.coves.post.record",
+
"$type": "social.coves.community.post",
"community": communityRef.DID,
"author": authorView.DID,
"createdAt": postView.CreatedAt.Format(time.RFC3339),
···
record["embed"] = embedData
}
}
-
if len(labels) > 0 {
-
record["contentLabels"] = labels
+
if labelsJSON.Valid {
+
// Labels are stored as JSONB containing full com.atproto.label.defs#selfLabels structure
+
// Deserialize and include in record
+
var selfLabels posts.SelfLabels
+
if err := json.Unmarshal([]byte(labelsJSON.String), &selfLabels); err == nil {
+
record["labels"] = selfLabels
+
}
}
postView.Record = record
+10 -7
internal/db/postgres/feed_repo_base.go
···
"strconv"
"strings"
"time"
-
-
"github.com/lib/pq"
)
// feedRepoBase contains shared logic for timeline and discover feed repositories
···
communityRef posts.CommunityRef
title, content sql.NullString
facets, embed sql.NullString
-
labels pq.StringArray
+
labelsJSON sql.NullString
editedAt sql.NullTime
communityAvatar sql.NullString
hotRank sql.NullFloat64
···
&postView.URI, &postView.CID, &postView.RKey,
&authorView.DID, &authorView.Handle,
&communityRef.DID, &communityRef.Name, &communityAvatar,
-
&title, &content, &facets, &embed, &labels,
+
&title, &content, &facets, &embed, &labelsJSON,
&postView.CreatedAt, &editedAt, &postView.IndexedAt,
&postView.UpvoteCount, &postView.DownvoteCount, &postView.Score, &postView.CommentCount,
&hotRank,
···
// Build the record (required by lexicon)
record := map[string]interface{}{
-
"$type": "social.coves.post.record",
+
"$type": "social.coves.community.post",
"community": communityRef.DID,
"author": authorView.DID,
"createdAt": postView.CreatedAt.Format(time.RFC3339),
···
record["embed"] = embedData
}
}
-
if len(labels) > 0 {
-
record["contentLabels"] = labels
+
if labelsJSON.Valid {
+
// Labels are stored as JSONB containing full com.atproto.label.defs#selfLabels structure
+
// Deserialize and include in record
+
var selfLabels posts.SelfLabels
+
if err := json.Unmarshal([]byte(labelsJSON.String), &selfLabels); err == nil {
+
record["labels"] = selfLabels
+
}
}
postView.Record = record
+12 -20
internal/db/postgres/post_repo.go
···
"Coves/internal/core/posts"
"context"
"database/sql"
-
"encoding/json"
"fmt"
"strings"
-
-
"github.com/lib/pq"
)
type postgresPostRepo struct {
···
embedJSON.Valid = true
}
-
// Convert content labels to PostgreSQL array
-
var labelsArray pq.StringArray
+
// Store content labels as JSONB
+
// post.ContentLabels contains com.atproto.label.defs#selfLabels JSON: {"values":[{"val":"nsfw","neg":false}]}
+
// Store the full JSON blob to preserve the 'neg' field and future extensions
+
var labelsJSON sql.NullString
if post.ContentLabels != nil {
-
// Parse JSON array string to []string
-
var labels []string
-
if err := json.Unmarshal([]byte(*post.ContentLabels), &labels); err == nil {
-
labelsArray = labels
-
}
+
labelsJSON.String = *post.ContentLabels
+
labelsJSON.Valid = true
}
query := `
···
err := r.db.QueryRowContext(
ctx, query,
post.URI, post.CID, post.RKey, post.AuthorDID, post.CommunityDID,
-
post.Title, post.Content, facetsJSON, embedJSON, labelsArray,
+
post.Title, post.Content, facetsJSON, embedJSON, labelsJSON,
post.CreatedAt,
).Scan(&post.ID, &post.IndexedAt)
if err != nil {
···
`
var post posts.Post
-
var facetsJSON, embedJSON sql.NullString
-
var contentLabels pq.StringArray
+
var facetsJSON, embedJSON, labelsJSON sql.NullString
err := r.db.QueryRowContext(ctx, query, uri).Scan(
&post.ID, &post.URI, &post.CID, &post.RKey,
&post.AuthorDID, &post.CommunityDID,
-
&post.Title, &post.Content, &facetsJSON, &embedJSON, &contentLabels,
+
&post.Title, &post.Content, &facetsJSON, &embedJSON, &labelsJSON,
&post.CreatedAt, &post.EditedAt, &post.IndexedAt, &post.DeletedAt,
&post.UpvoteCount, &post.DownvoteCount, &post.Score, &post.CommentCount,
)
···
if embedJSON.Valid {
post.Embed = &embedJSON.String
}
-
if len(contentLabels) > 0 {
-
labelsJSON, marshalErr := json.Marshal(contentLabels)
-
if marshalErr == nil {
-
labelsStr := string(labelsJSON)
-
post.ContentLabels = &labelsStr
-
}
+
if labelsJSON.Valid {
+
// Labels are stored as JSONB containing full com.atproto.label.defs#selfLabels structure
+
post.ContentLabels = &labelsJSON.String
}
return &post, nil