A community based topic aggregation platform built on atproto

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>

Changed files
+243 -82
cmd
server
internal
atproto
jetstream
lexicon
com
atproto
social
coves
feed
interaction
db
validation
tests
lexicon-test-data
feed
interaction
+2 -2
cmd/server/main.go
···
voteJetstreamURL := os.Getenv("VOTE_JETSTREAM_URL")
if voteJetstreamURL == "" {
// Listen to vote record CREATE/DELETE events from user repositories
-
voteJetstreamURL = "ws://localhost:6008/subscribe?wantedCollections=social.coves.interaction.vote"
+
voteJetstreamURL = "ws://localhost:6008/subscribe?wantedCollections=social.coves.feed.vote"
}
voteEventConsumer := jetstream.NewVoteEventConsumer(voteRepo, userService, db)
···
}()
log.Printf("Started Jetstream vote consumer: %s", voteJetstreamURL)
-
log.Println(" - Indexing: social.coves.interaction.vote CREATE/DELETE operations")
+
log.Println(" - Indexing: social.coves.feed.vote CREATE/DELETE operations")
log.Println(" - Updating: Post vote counts atomically")
// Register XRPC routes
+5 -5
internal/atproto/jetstream/vote_consumer.go
···
)
// VoteEventConsumer consumes vote-related events from Jetstream
-
// Handles CREATE and DELETE operations for social.coves.interaction.vote
+
// Handles CREATE and DELETE operations for social.coves.feed.vote
type VoteEventConsumer struct {
voteRepo votes.Repository
userService users.UserService
···
commit := event.Commit
// Handle vote record operations
-
if commit.Collection == "social.coves.interaction.vote" {
+
if commit.Collection == "social.coves.feed.vote" {
switch commit.Operation {
case "create":
return c.createVote(ctx, event.Did, commit)
···
}
// Build AT-URI for this vote
-
// Format: at://voter_did/social.coves.interaction.vote/rkey
-
uri := fmt.Sprintf("at://%s/social.coves.interaction.vote/%s", repoDID, commit.RKey)
+
// Format: at://voter_did/social.coves.feed.vote/rkey
+
uri := fmt.Sprintf("at://%s/social.coves.feed.vote/%s", repoDID, commit.RKey)
// Parse timestamp from record
createdAt, err := time.Parse(time.RFC3339, voteRecord.CreatedAt)
···
// deleteVote soft-deletes a vote and updates post counts
func (c *VoteEventConsumer) deleteVote(ctx context.Context, repoDID string, commit *CommitEvent) error {
// Build AT-URI for the vote being deleted
-
uri := fmt.Sprintf("at://%s/social.coves.interaction.vote/%s", repoDID, commit.RKey)
+
uri := fmt.Sprintf("at://%s/social.coves.feed.vote/%s", repoDID, commit.RKey)
// Get existing vote to know its direction (for decrementing the right counter)
existingVote, err := c.voteRepo.GetByURI(ctx, uri)
+156
internal/atproto/lexicon/com/atproto/label/defs.json
···
+
{
+
"lexicon": 1,
+
"id": "com.atproto.label.defs",
+
"defs": {
+
"label": {
+
"type": "object",
+
"description": "Metadata tag on an atproto resource (eg, repo or record).",
+
"required": ["src", "uri", "val", "cts"],
+
"properties": {
+
"ver": {
+
"type": "integer",
+
"description": "The AT Protocol version of the label object."
+
},
+
"src": {
+
"type": "string",
+
"format": "did",
+
"description": "DID of the actor who created this label."
+
},
+
"uri": {
+
"type": "string",
+
"format": "uri",
+
"description": "AT URI of the record, repository (account), or other resource that this label applies to."
+
},
+
"cid": {
+
"type": "string",
+
"format": "cid",
+
"description": "Optionally, CID specifying the specific version of 'uri' resource this label applies to."
+
},
+
"val": {
+
"type": "string",
+
"maxLength": 128,
+
"description": "The short string name of the value or type of this label."
+
},
+
"neg": {
+
"type": "boolean",
+
"description": "If true, this is a negation label, overwriting a previous label."
+
},
+
"cts": {
+
"type": "string",
+
"format": "datetime",
+
"description": "Timestamp when this label was created."
+
},
+
"exp": {
+
"type": "string",
+
"format": "datetime",
+
"description": "Timestamp at which this label expires (no longer applies)."
+
},
+
"sig": {
+
"type": "bytes",
+
"description": "Signature of dag-cbor encoded label."
+
}
+
}
+
},
+
"selfLabels": {
+
"type": "object",
+
"description": "Metadata tags on an atproto record, published by the author within the record.",
+
"required": ["values"],
+
"properties": {
+
"values": {
+
"type": "array",
+
"items": { "type": "ref", "ref": "#selfLabel" },
+
"maxLength": 10
+
}
+
}
+
},
+
"selfLabel": {
+
"type": "object",
+
"description": "Metadata tag on an atproto record, published by the author within the record. Note that schemas should use #selfLabels, not #selfLabel.",
+
"required": ["val"],
+
"properties": {
+
"val": {
+
"type": "string",
+
"maxLength": 128,
+
"description": "The short string name of the value or type of this label."
+
}
+
}
+
},
+
"labelValueDefinition": {
+
"type": "object",
+
"description": "Declares a label value and its expected interpretations and behaviors.",
+
"required": ["identifier", "severity", "blurs", "locales"],
+
"properties": {
+
"identifier": {
+
"type": "string",
+
"description": "The value of the label being defined. Must only include lowercase ascii and the '-' character ([a-z-]+).",
+
"maxLength": 100,
+
"maxGraphemes": 100
+
},
+
"severity": {
+
"type": "string",
+
"description": "How should a client visually convey this label? 'inform' means neutral and informational; 'alert' means negative and warning; 'none' means show nothing.",
+
"knownValues": ["inform", "alert", "none"]
+
},
+
"blurs": {
+
"type": "string",
+
"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.",
+
"knownValues": ["content", "media", "none"]
+
},
+
"defaultSetting": {
+
"type": "string",
+
"description": "The default setting for this label.",
+
"knownValues": ["ignore", "warn", "hide"],
+
"default": "warn"
+
},
+
"adultOnly": {
+
"type": "boolean",
+
"description": "Does the user need to have adult content enabled in order to configure this label?"
+
},
+
"locales": {
+
"type": "array",
+
"items": { "type": "ref", "ref": "#labelValueDefinitionStrings" }
+
}
+
}
+
},
+
"labelValueDefinitionStrings": {
+
"type": "object",
+
"description": "Strings which describe the label in the UI, localized into a specific language.",
+
"required": ["lang", "name", "description"],
+
"properties": {
+
"lang": {
+
"type": "string",
+
"description": "The code of the language these strings are written in.",
+
"format": "language"
+
},
+
"name": {
+
"type": "string",
+
"description": "A short human-readable name for the label.",
+
"maxGraphemes": 64,
+
"maxLength": 640
+
},
+
"description": {
+
"type": "string",
+
"description": "A longer description of what the label means and why it might be applied.",
+
"maxGraphemes": 10000,
+
"maxLength": 100000
+
}
+
}
+
},
+
"labelValue": {
+
"type": "string",
+
"knownValues": [
+
"!hide",
+
"!no-promote",
+
"!warn",
+
"!no-unauthenticated",
+
"dmca-violation",
+
"doxxing",
+
"porn",
+
"sexual",
+
"nudity",
+
"nsfl",
+
"gore"
+
]
+
}
+
}
+
}
+15
internal/atproto/lexicon/com/atproto/repo/strongRef.json
···
+
{
+
"lexicon": 1,
+
"id": "com.atproto.repo.strongRef",
+
"description": "A URI with a content-hash fingerprint.",
+
"defs": {
+
"main": {
+
"type": "object",
+
"required": ["uri", "cid"],
+
"properties": {
+
"uri": { "type": "string", "format": "at-uri" },
+
"cid": { "type": "string", "format": "cid" }
+
}
+
}
+
}
+
}
+32
internal/atproto/lexicon/social/coves/feed/vote.json
···
+
{
+
"lexicon": 1,
+
"id": "social.coves.feed.vote",
+
"defs": {
+
"main": {
+
"type": "record",
+
"description": "Record declaring a vote (upvote or downvote) on a post or comment. Requires authentication.",
+
"key": "tid",
+
"record": {
+
"type": "object",
+
"required": ["subject", "direction", "createdAt"],
+
"properties": {
+
"subject": {
+
"type": "ref",
+
"ref": "com.atproto.repo.strongRef",
+
"description": "Strong reference to the post or comment being voted on"
+
},
+
"direction": {
+
"type": "string",
+
"knownValues": ["up", "down"],
+
"description": "Vote direction: up for upvote, down for downvote"
+
},
+
"createdAt": {
+
"type": "string",
+
"format": "datetime",
+
"description": "Timestamp when the vote was created"
+
}
+
}
+
}
+
}
+
}
+
}
-49
internal/atproto/lexicon/social/coves/interaction/vote.json
···
-
{
-
"lexicon": 1,
-
"id": "social.coves.interaction.vote",
-
"defs": {
-
"main": {
-
"type": "record",
-
"description": "A vote (upvote or downvote) on a post or comment",
-
"key": "tid",
-
"record": {
-
"type": "object",
-
"required": ["subject", "direction", "createdAt"],
-
"properties": {
-
"subject": {
-
"type": "ref",
-
"ref": "#strongRef",
-
"description": "Strong reference to the post or comment being voted on"
-
},
-
"direction": {
-
"type": "string",
-
"enum": ["up", "down"],
-
"description": "Vote direction: up for upvote, down for downvote"
-
},
-
"createdAt": {
-
"type": "string",
-
"format": "datetime",
-
"description": "Timestamp when the vote was created"
-
}
-
}
-
}
-
},
-
"strongRef": {
-
"type": "object",
-
"description": "Strong reference to a record (AT-URI + CID)",
-
"required": ["uri", "cid"],
-
"properties": {
-
"uri": {
-
"type": "string",
-
"format": "at-uri",
-
"description": "AT-URI of the record"
-
},
-
"cid": {
-
"type": "string",
-
"format": "cid",
-
"description": "CID of the record content"
-
}
-
}
-
}
-
}
-
}
+8 -5
internal/db/migrations/013_create_votes_table.sql
···
-- Votes are indexed from the firehose after being written to user repositories
CREATE TABLE votes (
id BIGSERIAL PRIMARY KEY,
-
uri TEXT UNIQUE NOT NULL, -- AT-URI (at://voter_did/social.coves.interaction.vote/rkey)
+
uri TEXT UNIQUE NOT NULL, -- AT-URI (at://voter_did/social.coves.feed.vote/rkey)
cid TEXT NOT NULL, -- Content ID
rkey TEXT NOT NULL, -- Record key (TID)
voter_did TEXT NOT NULL, -- User who voted (from AT-URI repo field)
···
-- Timestamps
created_at TIMESTAMPTZ NOT NULL, -- Voter's timestamp from record
indexed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- When indexed by AppView
-
deleted_at TIMESTAMPTZ, -- Soft delete (for firehose delete events)
+
deleted_at TIMESTAMPTZ -- Soft delete (for firehose delete events)
-
-- Foreign keys
-
CONSTRAINT fk_voter FOREIGN KEY (voter_did) REFERENCES users(did) ON DELETE CASCADE
+
-- NO foreign key constraint on voter_did to allow out-of-order indexing from Jetstream
+
-- Vote events may arrive before user events, which is acceptable since:
+
-- 1. Votes are authenticated by the user's PDS (security maintained)
+
-- 2. Orphaned votes from never-indexed users are harmless
+
-- 3. This prevents race conditions in the firehose consumer
);
-- Indexes for common query patterns
···
-- Comment on table
COMMENT ON TABLE votes IS 'Votes indexed from user repositories via Jetstream firehose consumer';
-
COMMENT ON COLUMN votes.uri IS 'AT-URI in format: at://voter_did/social.coves.interaction.vote/rkey';
+
COMMENT ON COLUMN votes.uri IS 'AT-URI in format: at://voter_did/social.coves.feed.vote/rkey';
COMMENT ON COLUMN votes.subject_uri IS 'Strong reference to post/comment being voted on';
COMMENT ON INDEX unique_voter_subject_active IS 'Ensures one active vote per user per subject (soft delete aware)';
+12 -12
internal/db/postgres/vote_repo_test.go
···
createTestUser(t, db, "testvoter123.test", voterDID)
vote := &votes.Vote{
-
URI: "at://did:plc:testvoter123/social.coves.interaction.vote/3k1234567890",
+
URI: "at://did:plc:testvoter123/social.coves.feed.vote/3k1234567890",
CID: "bafyreigtest123",
RKey: "3k1234567890",
VoterDID: voterDID,
···
createTestUser(t, db, "testvoter456.test", voterDID)
vote := &votes.Vote{
-
URI: "at://did:plc:testvoter456/social.coves.interaction.vote/3k9876543210",
+
URI: "at://did:plc:testvoter456/social.coves.feed.vote/3k9876543210",
CID: "bafyreigtest456",
RKey: "3k9876543210",
VoterDID: voterDID,
···
// Don't create test user - vote should still be created (FK removed)
// This allows votes to be indexed before users in Jetstream
vote := &votes.Vote{
-
URI: "at://did:plc:nonexistentvoter/social.coves.interaction.vote/3k1111111111",
+
URI: "at://did:plc:nonexistentvoter/social.coves.feed.vote/3k1111111111",
CID: "bafyreignovoter",
RKey: "3k1111111111",
VoterDID: "did:plc:nonexistentvoter",
···
// Create vote
vote := &votes.Vote{
-
URI: "at://did:plc:testvoter789/social.coves.interaction.vote/3k5555555555",
+
URI: "at://did:plc:testvoter789/social.coves.feed.vote/3k5555555555",
CID: "bafyreigtest789",
RKey: "3k5555555555",
VoterDID: voterDID,
···
repo := NewVoteRepository(db)
ctx := context.Background()
-
_, err := repo.GetByURI(ctx, "at://did:plc:nonexistent/social.coves.interaction.vote/nope")
+
_, err := repo.GetByURI(ctx, "at://did:plc:nonexistent/social.coves.feed.vote/nope")
assert.ErrorIs(t, err, votes.ErrVoteNotFound)
}
···
// Create vote
vote := &votes.Vote{
-
URI: "at://did:plc:testvoter999/social.coves.interaction.vote/3k6666666666",
+
URI: "at://did:plc:testvoter999/social.coves.feed.vote/3k6666666666",
CID: "bafyreigtest999",
RKey: "3k6666666666",
VoterDID: voterDID,
···
// Create vote
vote := &votes.Vote{
-
URI: "at://did:plc:testvoterdelete/social.coves.interaction.vote/3k7777777777",
+
URI: "at://did:plc:testvoterdelete/social.coves.feed.vote/3k7777777777",
CID: "bafyreigdelete",
RKey: "3k7777777777",
VoterDID: voterDID,
···
createTestUser(t, db, "testvoterdelete2.test", voterDID)
vote := &votes.Vote{
-
URI: "at://did:plc:testvoterdelete2/social.coves.interaction.vote/3k8888888888",
+
URI: "at://did:plc:testvoterdelete2/social.coves.feed.vote/3k8888888888",
CID: "bafyreigdelete2",
RKey: "3k8888888888",
VoterDID: voterDID,
···
// Create multiple votes on same subject
vote1 := &votes.Vote{
-
URI: "at://did:plc:testvoterlist1/social.coves.interaction.vote/3k9999999991",
+
URI: "at://did:plc:testvoterlist1/social.coves.feed.vote/3k9999999991",
CID: "bafyreiglist1",
RKey: "3k9999999991",
VoterDID: voterDID1,
···
CreatedAt: time.Now(),
}
vote2 := &votes.Vote{
-
URI: "at://did:plc:testvoterlist2/social.coves.interaction.vote/3k9999999992",
+
URI: "at://did:plc:testvoterlist2/social.coves.feed.vote/3k9999999992",
CID: "bafyreiglist2",
RKey: "3k9999999992",
VoterDID: voterDID2,
···
// Create multiple votes by same voter
vote1 := &votes.Vote{
-
URI: "at://did:plc:testvoterlistvoter/social.coves.interaction.vote/3k0000000001",
+
URI: "at://did:plc:testvoterlistvoter/social.coves.feed.vote/3k0000000001",
CID: "bafyreigvoter1",
RKey: "3k0000000001",
VoterDID: voterDID,
···
CreatedAt: time.Now(),
}
vote2 := &votes.Vote{
-
URI: "at://did:plc:testvoterlistvoter/social.coves.interaction.vote/3k0000000002",
+
URI: "at://did:plc:testvoterlistvoter/social.coves.feed.vote/3k0000000002",
CID: "bafyreigvoter2",
RKey: "3k0000000002",
VoterDID: voterDID,
+1 -1
internal/validation/lexicon.go
···
// ValidateVote validates a vote record
func (v *LexiconValidator) ValidateVote(vote map[string]interface{}) error {
-
return v.ValidateRecord(vote, "social.coves.interaction.vote")
+
return v.ValidateRecord(vote, "social.coves.feed.vote")
}
// ValidateModerationAction validates a moderation action (ban, tribunalVote, etc.)
+3 -3
internal/validation/lexicon_test.go
···
// Test with JSON string
jsonString := `{
-
"$type": "social.coves.interaction.vote",
+
"$type": "social.coves.feed.vote",
"subject": {
"uri": "at://did:plc:test/social.coves.community.post/abc123",
"cid": "bafyreigj3fwnwjuzr35k2kuzmb5dixxczrzjhqkr5srlqplsh6gq3bj3si"
···
"createdAt": "2024-01-01T00:00:00Z"
}`
-
if err := validator.ValidateRecord(jsonString, "social.coves.interaction.vote"); err != nil {
+
if err := validator.ValidateRecord(jsonString, "social.coves.feed.vote"); err != nil {
t.Errorf("Failed to validate JSON string: %v", err)
}
// Test with JSON bytes
jsonBytes := []byte(jsonString)
-
if err := validator.ValidateRecord(jsonBytes, "social.coves.interaction.vote"); err != nil {
+
if err := validator.ValidateRecord(jsonBytes, "social.coves.feed.vote"); err != nil {
t.Errorf("Failed to validate JSON bytes: %v", err)
}
}
+9
tests/lexicon-test-data/feed/vote-valid.json
···
+
{
+
"$type": "social.coves.feed.vote",
+
"subject": {
+
"uri": "at://did:plc:alice123/social.coves.community.post/3kbx2n5p",
+
"cid": "bafyreigj3fwnwjuzr35k2kuzmb5dixxczrzjhqkr5srlqplsh6gq3bj3si"
+
},
+
"direction": "up",
+
"createdAt": "2025-01-09T15:00:00Z"
+
}
-5
tests/lexicon-test-data/interaction/vote-valid.json
···
-
{
-
"$type": "social.coves.interaction.vote",
-
"subject": "at://did:plc:alice123/social.coves.post.text/3kbx2n5p",
-
"createdAt": "2025-01-09T15:00:00Z"
-
}