A community based topic aggregation platform built on atproto
11
fork

Configure Feed

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

feat(lexicon): migrate vote to feed namespace and apply atProto best practices

This commit migrates the vote lexicon to align with atProto conventions and
fixes several pre-existing bugs discovered during the migration.

## Main Changes

1. **Namespace Migration**: social.coves.interaction.vote → social.coves.feed.vote
- Follows Bluesky's pattern (app.bsky.feed.like)
- All feed interactions now in consistent namespace
- Updated all code references, tests, and Jetstream consumers

2. **atProto Best Practices** (per https://github.com/bluesky-social/atproto/discussions/4245):
- Changed `enum` to `knownValues` for future extensibility
- Use standard `com.atproto.repo.strongRef` instead of custom definition
- Enhanced description to mention authentication requirement

3. **Added Core atProto Schemas**:
- com.atproto.repo.strongRef.json
- com.atproto.label.defs.json
- Required for lexicon validation, standard practice for Go projects

## Bug Fixes

1. **Foreign Key Constraint Mismatch** (013_create_votes_table.sql):
- REMOVED FK constraint on voter_did → users(did)
- Code comments stated FK was removed, but migration still had it
- Tests expected no FK for out-of-order Jetstream indexing
- Now consistent: votes can be indexed before users

2. **Invalid Test Data** (tests/lexicon-test-data/feed/vote-valid.json):
- Missing required `direction` field
- `subject` was string instead of strongRef object
- Now valid: includes direction, proper strongRef with uri+cid

## Files Changed

**Lexicon & Test Data:**
- Moved: internal/atproto/lexicon/social/coves/{interaction → feed}/vote.json
- Moved: tests/lexicon-test-data/{interaction → feed}/vote-valid.json
- Added: internal/atproto/lexicon/com/atproto/repo/strongRef.json
- Added: internal/atproto/lexicon/com/atproto/label/defs.json

**Code (10 files updated):**
- internal/validation/lexicon.go
- internal/validation/lexicon_test.go
- internal/atproto/jetstream/vote_consumer.go
- cmd/server/main.go (Jetstream URL)
- internal/db/postgres/vote_repo_test.go (12 test URIs)
- internal/db/migrations/013_create_votes_table.sql

## Tests

✅ All vote repository tests passing (11 tests)
✅ Validation tests passing with new lexicon path
✅ TestVoteRepo_Create_VoterNotFound passing (validates FK removal)
✅ Lexicon schema validation passing
✅ No regressions introduced

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>

+243 -82
+2 -2
cmd/server/main.go
··· 353 353 voteJetstreamURL := os.Getenv("VOTE_JETSTREAM_URL") 354 354 if voteJetstreamURL == "" { 355 355 // Listen to vote record CREATE/DELETE events from user repositories 356 - voteJetstreamURL = "ws://localhost:6008/subscribe?wantedCollections=social.coves.interaction.vote" 356 + voteJetstreamURL = "ws://localhost:6008/subscribe?wantedCollections=social.coves.feed.vote" 357 357 } 358 358 359 359 voteEventConsumer := jetstream.NewVoteEventConsumer(voteRepo, userService, db) ··· 366 366 }() 367 367 368 368 log.Printf("Started Jetstream vote consumer: %s", voteJetstreamURL) 369 - log.Println(" - Indexing: social.coves.interaction.vote CREATE/DELETE operations") 369 + log.Println(" - Indexing: social.coves.feed.vote CREATE/DELETE operations") 370 370 log.Println(" - Updating: Post vote counts atomically") 371 371 372 372 // Register XRPC routes
+5 -5
internal/atproto/jetstream/vote_consumer.go
··· 12 12 ) 13 13 14 14 // VoteEventConsumer consumes vote-related events from Jetstream 15 - // Handles CREATE and DELETE operations for social.coves.interaction.vote 15 + // Handles CREATE and DELETE operations for social.coves.feed.vote 16 16 type VoteEventConsumer struct { 17 17 voteRepo votes.Repository 18 18 userService users.UserService ··· 42 42 commit := event.Commit 43 43 44 44 // Handle vote record operations 45 - if commit.Collection == "social.coves.interaction.vote" { 45 + if commit.Collection == "social.coves.feed.vote" { 46 46 switch commit.Operation { 47 47 case "create": 48 48 return c.createVote(ctx, event.Did, commit) ··· 74 74 } 75 75 76 76 // Build AT-URI for this vote 77 - // Format: at://voter_did/social.coves.interaction.vote/rkey 78 - uri := fmt.Sprintf("at://%s/social.coves.interaction.vote/%s", repoDID, commit.RKey) 77 + // Format: at://voter_did/social.coves.feed.vote/rkey 78 + uri := fmt.Sprintf("at://%s/social.coves.feed.vote/%s", repoDID, commit.RKey) 79 79 80 80 // Parse timestamp from record 81 81 createdAt, err := time.Parse(time.RFC3339, voteRecord.CreatedAt) ··· 109 109 // deleteVote soft-deletes a vote and updates post counts 110 110 func (c *VoteEventConsumer) deleteVote(ctx context.Context, repoDID string, commit *CommitEvent) error { 111 111 // Build AT-URI for the vote being deleted 112 - uri := fmt.Sprintf("at://%s/social.coves.interaction.vote/%s", repoDID, commit.RKey) 112 + uri := fmt.Sprintf("at://%s/social.coves.feed.vote/%s", repoDID, commit.RKey) 113 113 114 114 // Get existing vote to know its direction (for decrementing the right counter) 115 115 existingVote, err := c.voteRepo.GetByURI(ctx, uri)
+156
internal/atproto/lexicon/com/atproto/label/defs.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "com.atproto.label.defs", 4 + "defs": { 5 + "label": { 6 + "type": "object", 7 + "description": "Metadata tag on an atproto resource (eg, repo or record).", 8 + "required": ["src", "uri", "val", "cts"], 9 + "properties": { 10 + "ver": { 11 + "type": "integer", 12 + "description": "The AT Protocol version of the label object." 13 + }, 14 + "src": { 15 + "type": "string", 16 + "format": "did", 17 + "description": "DID of the actor who created this label." 18 + }, 19 + "uri": { 20 + "type": "string", 21 + "format": "uri", 22 + "description": "AT URI of the record, repository (account), or other resource that this label applies to." 23 + }, 24 + "cid": { 25 + "type": "string", 26 + "format": "cid", 27 + "description": "Optionally, CID specifying the specific version of 'uri' resource this label applies to." 28 + }, 29 + "val": { 30 + "type": "string", 31 + "maxLength": 128, 32 + "description": "The short string name of the value or type of this label." 33 + }, 34 + "neg": { 35 + "type": "boolean", 36 + "description": "If true, this is a negation label, overwriting a previous label." 37 + }, 38 + "cts": { 39 + "type": "string", 40 + "format": "datetime", 41 + "description": "Timestamp when this label was created." 42 + }, 43 + "exp": { 44 + "type": "string", 45 + "format": "datetime", 46 + "description": "Timestamp at which this label expires (no longer applies)." 47 + }, 48 + "sig": { 49 + "type": "bytes", 50 + "description": "Signature of dag-cbor encoded label." 51 + } 52 + } 53 + }, 54 + "selfLabels": { 55 + "type": "object", 56 + "description": "Metadata tags on an atproto record, published by the author within the record.", 57 + "required": ["values"], 58 + "properties": { 59 + "values": { 60 + "type": "array", 61 + "items": { "type": "ref", "ref": "#selfLabel" }, 62 + "maxLength": 10 63 + } 64 + } 65 + }, 66 + "selfLabel": { 67 + "type": "object", 68 + "description": "Metadata tag on an atproto record, published by the author within the record. Note that schemas should use #selfLabels, not #selfLabel.", 69 + "required": ["val"], 70 + "properties": { 71 + "val": { 72 + "type": "string", 73 + "maxLength": 128, 74 + "description": "The short string name of the value or type of this label." 75 + } 76 + } 77 + }, 78 + "labelValueDefinition": { 79 + "type": "object", 80 + "description": "Declares a label value and its expected interpretations and behaviors.", 81 + "required": ["identifier", "severity", "blurs", "locales"], 82 + "properties": { 83 + "identifier": { 84 + "type": "string", 85 + "description": "The value of the label being defined. Must only include lowercase ascii and the '-' character ([a-z-]+).", 86 + "maxLength": 100, 87 + "maxGraphemes": 100 88 + }, 89 + "severity": { 90 + "type": "string", 91 + "description": "How should a client visually convey this label? 'inform' means neutral and informational; 'alert' means negative and warning; 'none' means show nothing.", 92 + "knownValues": ["inform", "alert", "none"] 93 + }, 94 + "blurs": { 95 + "type": "string", 96 + "description": "What should this label hide in the UI, if applied? 'content' hides all of the target; 'media' hides the images/video/audio; 'none' hides nothing.", 97 + "knownValues": ["content", "media", "none"] 98 + }, 99 + "defaultSetting": { 100 + "type": "string", 101 + "description": "The default setting for this label.", 102 + "knownValues": ["ignore", "warn", "hide"], 103 + "default": "warn" 104 + }, 105 + "adultOnly": { 106 + "type": "boolean", 107 + "description": "Does the user need to have adult content enabled in order to configure this label?" 108 + }, 109 + "locales": { 110 + "type": "array", 111 + "items": { "type": "ref", "ref": "#labelValueDefinitionStrings" } 112 + } 113 + } 114 + }, 115 + "labelValueDefinitionStrings": { 116 + "type": "object", 117 + "description": "Strings which describe the label in the UI, localized into a specific language.", 118 + "required": ["lang", "name", "description"], 119 + "properties": { 120 + "lang": { 121 + "type": "string", 122 + "description": "The code of the language these strings are written in.", 123 + "format": "language" 124 + }, 125 + "name": { 126 + "type": "string", 127 + "description": "A short human-readable name for the label.", 128 + "maxGraphemes": 64, 129 + "maxLength": 640 130 + }, 131 + "description": { 132 + "type": "string", 133 + "description": "A longer description of what the label means and why it might be applied.", 134 + "maxGraphemes": 10000, 135 + "maxLength": 100000 136 + } 137 + } 138 + }, 139 + "labelValue": { 140 + "type": "string", 141 + "knownValues": [ 142 + "!hide", 143 + "!no-promote", 144 + "!warn", 145 + "!no-unauthenticated", 146 + "dmca-violation", 147 + "doxxing", 148 + "porn", 149 + "sexual", 150 + "nudity", 151 + "nsfl", 152 + "gore" 153 + ] 154 + } 155 + } 156 + }
+15
internal/atproto/lexicon/com/atproto/repo/strongRef.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "com.atproto.repo.strongRef", 4 + "description": "A URI with a content-hash fingerprint.", 5 + "defs": { 6 + "main": { 7 + "type": "object", 8 + "required": ["uri", "cid"], 9 + "properties": { 10 + "uri": { "type": "string", "format": "at-uri" }, 11 + "cid": { "type": "string", "format": "cid" } 12 + } 13 + } 14 + } 15 + }
+32
internal/atproto/lexicon/social/coves/feed/vote.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "social.coves.feed.vote", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "description": "Record declaring a vote (upvote or downvote) on a post or comment. Requires authentication.", 8 + "key": "tid", 9 + "record": { 10 + "type": "object", 11 + "required": ["subject", "direction", "createdAt"], 12 + "properties": { 13 + "subject": { 14 + "type": "ref", 15 + "ref": "com.atproto.repo.strongRef", 16 + "description": "Strong reference to the post or comment being voted on" 17 + }, 18 + "direction": { 19 + "type": "string", 20 + "knownValues": ["up", "down"], 21 + "description": "Vote direction: up for upvote, down for downvote" 22 + }, 23 + "createdAt": { 24 + "type": "string", 25 + "format": "datetime", 26 + "description": "Timestamp when the vote was created" 27 + } 28 + } 29 + } 30 + } 31 + } 32 + }
-49
internal/atproto/lexicon/social/coves/interaction/vote.json
··· 1 - { 2 - "lexicon": 1, 3 - "id": "social.coves.interaction.vote", 4 - "defs": { 5 - "main": { 6 - "type": "record", 7 - "description": "A vote (upvote or downvote) on a post or comment", 8 - "key": "tid", 9 - "record": { 10 - "type": "object", 11 - "required": ["subject", "direction", "createdAt"], 12 - "properties": { 13 - "subject": { 14 - "type": "ref", 15 - "ref": "#strongRef", 16 - "description": "Strong reference to the post or comment being voted on" 17 - }, 18 - "direction": { 19 - "type": "string", 20 - "enum": ["up", "down"], 21 - "description": "Vote direction: up for upvote, down for downvote" 22 - }, 23 - "createdAt": { 24 - "type": "string", 25 - "format": "datetime", 26 - "description": "Timestamp when the vote was created" 27 - } 28 - } 29 - } 30 - }, 31 - "strongRef": { 32 - "type": "object", 33 - "description": "Strong reference to a record (AT-URI + CID)", 34 - "required": ["uri", "cid"], 35 - "properties": { 36 - "uri": { 37 - "type": "string", 38 - "format": "at-uri", 39 - "description": "AT-URI of the record" 40 - }, 41 - "cid": { 42 - "type": "string", 43 - "format": "cid", 44 - "description": "CID of the record content" 45 - } 46 - } 47 - } 48 - } 49 - }
+8 -5
internal/db/migrations/013_create_votes_table.sql
··· 3 3 -- Votes are indexed from the firehose after being written to user repositories 4 4 CREATE TABLE votes ( 5 5 id BIGSERIAL PRIMARY KEY, 6 - uri TEXT UNIQUE NOT NULL, -- AT-URI (at://voter_did/social.coves.interaction.vote/rkey) 6 + uri TEXT UNIQUE NOT NULL, -- AT-URI (at://voter_did/social.coves.feed.vote/rkey) 7 7 cid TEXT NOT NULL, -- Content ID 8 8 rkey TEXT NOT NULL, -- Record key (TID) 9 9 voter_did TEXT NOT NULL, -- User who voted (from AT-URI repo field) ··· 18 18 -- Timestamps 19 19 created_at TIMESTAMPTZ NOT NULL, -- Voter's timestamp from record 20 20 indexed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- When indexed by AppView 21 - deleted_at TIMESTAMPTZ, -- Soft delete (for firehose delete events) 21 + deleted_at TIMESTAMPTZ -- Soft delete (for firehose delete events) 22 22 23 - -- Foreign keys 24 - CONSTRAINT fk_voter FOREIGN KEY (voter_did) REFERENCES users(did) ON DELETE CASCADE 23 + -- NO foreign key constraint on voter_did to allow out-of-order indexing from Jetstream 24 + -- Vote events may arrive before user events, which is acceptable since: 25 + -- 1. Votes are authenticated by the user's PDS (security maintained) 26 + -- 2. Orphaned votes from never-indexed users are harmless 27 + -- 3. This prevents race conditions in the firehose consumer 25 28 ); 26 29 27 30 -- Indexes for common query patterns ··· 35 38 36 39 -- Comment on table 37 40 COMMENT ON TABLE votes IS 'Votes indexed from user repositories via Jetstream firehose consumer'; 38 - COMMENT ON COLUMN votes.uri IS 'AT-URI in format: at://voter_did/social.coves.interaction.vote/rkey'; 41 + COMMENT ON COLUMN votes.uri IS 'AT-URI in format: at://voter_did/social.coves.feed.vote/rkey'; 39 42 COMMENT ON COLUMN votes.subject_uri IS 'Strong reference to post/comment being voted on'; 40 43 COMMENT ON INDEX unique_voter_subject_active IS 'Ensures one active vote per user per subject (soft delete aware)'; 41 44
+12 -12
internal/db/postgres/vote_repo_test.go
··· 63 63 createTestUser(t, db, "testvoter123.test", voterDID) 64 64 65 65 vote := &votes.Vote{ 66 - URI: "at://did:plc:testvoter123/social.coves.interaction.vote/3k1234567890", 66 + URI: "at://did:plc:testvoter123/social.coves.feed.vote/3k1234567890", 67 67 CID: "bafyreigtest123", 68 68 RKey: "3k1234567890", 69 69 VoterDID: voterDID, ··· 91 91 createTestUser(t, db, "testvoter456.test", voterDID) 92 92 93 93 vote := &votes.Vote{ 94 - URI: "at://did:plc:testvoter456/social.coves.interaction.vote/3k9876543210", 94 + URI: "at://did:plc:testvoter456/social.coves.feed.vote/3k9876543210", 95 95 CID: "bafyreigtest456", 96 96 RKey: "3k9876543210", 97 97 VoterDID: voterDID, ··· 132 132 // Don't create test user - vote should still be created (FK removed) 133 133 // This allows votes to be indexed before users in Jetstream 134 134 vote := &votes.Vote{ 135 - URI: "at://did:plc:nonexistentvoter/social.coves.interaction.vote/3k1111111111", 135 + URI: "at://did:plc:nonexistentvoter/social.coves.feed.vote/3k1111111111", 136 136 CID: "bafyreignovoter", 137 137 RKey: "3k1111111111", 138 138 VoterDID: "did:plc:nonexistentvoter", ··· 164 164 165 165 // Create vote 166 166 vote := &votes.Vote{ 167 - URI: "at://did:plc:testvoter789/social.coves.interaction.vote/3k5555555555", 167 + URI: "at://did:plc:testvoter789/social.coves.feed.vote/3k5555555555", 168 168 CID: "bafyreigtest789", 169 169 RKey: "3k5555555555", 170 170 VoterDID: voterDID, ··· 192 192 repo := NewVoteRepository(db) 193 193 ctx := context.Background() 194 194 195 - _, err := repo.GetByURI(ctx, "at://did:plc:nonexistent/social.coves.interaction.vote/nope") 195 + _, err := repo.GetByURI(ctx, "at://did:plc:nonexistent/social.coves.feed.vote/nope") 196 196 assert.ErrorIs(t, err, votes.ErrVoteNotFound) 197 197 } 198 198 ··· 211 211 212 212 // Create vote 213 213 vote := &votes.Vote{ 214 - URI: "at://did:plc:testvoter999/social.coves.interaction.vote/3k6666666666", 214 + URI: "at://did:plc:testvoter999/social.coves.feed.vote/3k6666666666", 215 215 CID: "bafyreigtest999", 216 216 RKey: "3k6666666666", 217 217 VoterDID: voterDID, ··· 255 255 256 256 // Create vote 257 257 vote := &votes.Vote{ 258 - URI: "at://did:plc:testvoterdelete/social.coves.interaction.vote/3k7777777777", 258 + URI: "at://did:plc:testvoterdelete/social.coves.feed.vote/3k7777777777", 259 259 CID: "bafyreigdelete", 260 260 RKey: "3k7777777777", 261 261 VoterDID: voterDID, ··· 293 293 createTestUser(t, db, "testvoterdelete2.test", voterDID) 294 294 295 295 vote := &votes.Vote{ 296 - URI: "at://did:plc:testvoterdelete2/social.coves.interaction.vote/3k8888888888", 296 + URI: "at://did:plc:testvoterdelete2/social.coves.feed.vote/3k8888888888", 297 297 CID: "bafyreigdelete2", 298 298 RKey: "3k8888888888", 299 299 VoterDID: voterDID, ··· 331 331 332 332 // Create multiple votes on same subject 333 333 vote1 := &votes.Vote{ 334 - URI: "at://did:plc:testvoterlist1/social.coves.interaction.vote/3k9999999991", 334 + URI: "at://did:plc:testvoterlist1/social.coves.feed.vote/3k9999999991", 335 335 CID: "bafyreiglist1", 336 336 RKey: "3k9999999991", 337 337 VoterDID: voterDID1, ··· 341 341 CreatedAt: time.Now(), 342 342 } 343 343 vote2 := &votes.Vote{ 344 - URI: "at://did:plc:testvoterlist2/social.coves.interaction.vote/3k9999999992", 344 + URI: "at://did:plc:testvoterlist2/social.coves.feed.vote/3k9999999992", 345 345 CID: "bafyreiglist2", 346 346 RKey: "3k9999999992", 347 347 VoterDID: voterDID2, ··· 373 373 374 374 // Create multiple votes by same voter 375 375 vote1 := &votes.Vote{ 376 - URI: "at://did:plc:testvoterlistvoter/social.coves.interaction.vote/3k0000000001", 376 + URI: "at://did:plc:testvoterlistvoter/social.coves.feed.vote/3k0000000001", 377 377 CID: "bafyreigvoter1", 378 378 RKey: "3k0000000001", 379 379 VoterDID: voterDID, ··· 383 383 CreatedAt: time.Now(), 384 384 } 385 385 vote2 := &votes.Vote{ 386 - URI: "at://did:plc:testvoterlistvoter/social.coves.interaction.vote/3k0000000002", 386 + URI: "at://did:plc:testvoterlistvoter/social.coves.feed.vote/3k0000000002", 387 387 CID: "bafyreigvoter2", 388 388 RKey: "3k0000000002", 389 389 VoterDID: voterDID,
+1 -1
internal/validation/lexicon.go
··· 91 91 92 92 // ValidateVote validates a vote record 93 93 func (v *LexiconValidator) ValidateVote(vote map[string]interface{}) error { 94 - return v.ValidateRecord(vote, "social.coves.interaction.vote") 94 + return v.ValidateRecord(vote, "social.coves.feed.vote") 95 95 } 96 96 97 97 // ValidateModerationAction validates a moderation action (ban, tribunalVote, etc.)
+3 -3
internal/validation/lexicon_test.go
··· 93 93 94 94 // Test with JSON string 95 95 jsonString := `{ 96 - "$type": "social.coves.interaction.vote", 96 + "$type": "social.coves.feed.vote", 97 97 "subject": { 98 98 "uri": "at://did:plc:test/social.coves.community.post/abc123", 99 99 "cid": "bafyreigj3fwnwjuzr35k2kuzmb5dixxczrzjhqkr5srlqplsh6gq3bj3si" ··· 102 102 "createdAt": "2024-01-01T00:00:00Z" 103 103 }` 104 104 105 - if err := validator.ValidateRecord(jsonString, "social.coves.interaction.vote"); err != nil { 105 + if err := validator.ValidateRecord(jsonString, "social.coves.feed.vote"); err != nil { 106 106 t.Errorf("Failed to validate JSON string: %v", err) 107 107 } 108 108 109 109 // Test with JSON bytes 110 110 jsonBytes := []byte(jsonString) 111 - if err := validator.ValidateRecord(jsonBytes, "social.coves.interaction.vote"); err != nil { 111 + if err := validator.ValidateRecord(jsonBytes, "social.coves.feed.vote"); err != nil { 112 112 t.Errorf("Failed to validate JSON bytes: %v", err) 113 113 } 114 114 }
+9
tests/lexicon-test-data/feed/vote-valid.json
··· 1 + { 2 + "$type": "social.coves.feed.vote", 3 + "subject": { 4 + "uri": "at://did:plc:alice123/social.coves.community.post/3kbx2n5p", 5 + "cid": "bafyreigj3fwnwjuzr35k2kuzmb5dixxczrzjhqkr5srlqplsh6gq3bj3si" 6 + }, 7 + "direction": "up", 8 + "createdAt": "2025-01-09T15:00:00Z" 9 + }
-5
tests/lexicon-test-data/interaction/vote-valid.json
··· 1 - { 2 - "$type": "social.coves.interaction.vote", 3 - "subject": "at://did:plc:alice123/social.coves.post.text/3kbx2n5p", 4 - "createdAt": "2025-01-09T15:00:00Z" 5 - }