A community based topic aggregation platform built on atproto

feat(pds): add PutRecord with optimistic locking for comment updates

Implement PutRecord in PDS client with swapRecord CID validation:
- Add ErrConflict error type for HTTP 409 responses
- Add PutRecord method to Client interface with optimistic locking
- Map 409 status to ErrConflict in wrapAPIError

Migrate UpdateComment to use PutRecord:
- Use existingRecord.CID as swapRecord for concurrent modification detection
- Add ErrConcurrentModification error type in comments package
- Return proper error when PDS detects CID mismatch

Testing:
- Add PutRecord unit tests (success, conflict, typed errors)
- Add PutRecord to mockPDSClient for unit test compatibility
- Add integration test for concurrent modification detection

๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code)

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

Changed files
+463 -131
docs
internal
tests
integration
-124
docs/PRD_BACKLOG.md
···
## ๐Ÿ”ต P3: Technical Debt
-
### Implement PutRecord in PDS Client
-
**Added:** 2025-12-04 | **Effort:** 2-3 hours | **Priority:** Technical Debt
-
**Status:** ๐Ÿ“‹ TODO
-
-
**Problem:**
-
The PDS client (`internal/atproto/pds/client.go`) only has `CreateRecord` but lacks `PutRecord`. This means updates use `CreateRecord` with an existing rkey, which:
-
1. Loses optimistic locking (no CID swap check)
-
2. Is semantically incorrect (creates vs updates)
-
3. Could cause race conditions on concurrent updates
-
-
**atProto Best Practice:**
-
- `com.atproto.repo.putRecord` should be used for updates
-
- Accepts `swapRecord` (expected CID) for optimistic locking
-
- Returns conflict error if CID doesn't match (concurrent modification detected)
-
-
**Solution:**
-
Add `PutRecord` method to the PDS client interface:
-
-
```go
-
// Client interface addition
-
type Client interface {
-
// ... existing methods ...
-
-
// PutRecord creates or updates a record with optional optimistic locking.
-
// If swapRecord is provided, the operation fails if the current CID doesn't match.
-
PutRecord(ctx context.Context, collection string, rkey string, record any, swapRecord string) (uri string, cid string, err error)
-
}
-
-
// Implementation
-
func (c *client) PutRecord(ctx context.Context, collection string, rkey string, record any, swapRecord string) (string, string, error) {
-
payload := map[string]any{
-
"repo": c.did,
-
"collection": collection,
-
"rkey": rkey,
-
"record": record,
-
}
-
-
// Optional: optimistic locking via CID swap check
-
if swapRecord != "" {
-
payload["swapRecord"] = swapRecord
-
}
-
-
var result struct {
-
URI string `json:"uri"`
-
CID string `json:"cid"`
-
}
-
-
err := c.apiClient.Post(ctx, syntax.NSID("com.atproto.repo.putRecord"), payload, &result)
-
if err != nil {
-
return "", "", wrapAPIError(err, "putRecord")
-
}
-
-
return result.URI, result.CID, nil
-
}
-
```
-
-
**Error Handling:**
-
Add new error type for conflict detection:
-
```go
-
var ErrConflict = errors.New("record was modified by another operation")
-
```
-
-
Map HTTP 409 in `wrapAPIError`:
-
```go
-
case 409:
-
return fmt.Errorf("%s: %w: %s", operation, ErrConflict, apiErr.Message)
-
```
-
-
**Files to Modify:**
-
- `internal/atproto/pds/client.go` - Add `PutRecord` method and interface
-
- `internal/atproto/pds/errors.go` - Add `ErrConflict` error type
-
-
**Testing:**
-
- Unit test: Verify payload includes `swapRecord` when provided
-
- Integration test: Concurrent updates detect conflict
-
- Integration test: Update without `swapRecord` still works (backwards compatible)
-
-
**Blocked By:** Nothing
-
**Blocks:** "Migrate UpdateComment to use PutRecord"
-
-
---
-
-
### Migrate UpdateComment to Use PutRecord
-
**Added:** 2025-12-04 | **Effort:** 1 hour | **Priority:** Technical Debt
-
**Status:** ๐Ÿ“‹ TODO (Blocked)
-
**Blocked By:** "Implement PutRecord in PDS Client"
-
-
**Problem:**
-
`UpdateComment` in `internal/core/comments/comment_service.go` uses `CreateRecord` for updates instead of `PutRecord`. This lacks optimistic locking and is semantically incorrect.
-
-
**Current Code (lines 687-690):**
-
```go
-
// TODO: Use PutRecord instead of CreateRecord for proper update semantics with optimistic locking.
-
// PutRecord should accept the existing CID (existingRecord.CID) to ensure concurrent updates are detected.
-
// However, PutRecord is not yet implemented in internal/atproto/pds/client.go.
-
uri, cid, err := pdsClient.CreateRecord(ctx, commentCollection, rkey, updatedRecord)
-
```
-
-
**Solution:**
-
Once `PutRecord` is implemented in the PDS client, update to:
-
```go
-
// Use PutRecord with optimistic locking via existing CID
-
uri, cid, err := pdsClient.PutRecord(ctx, commentCollection, rkey, updatedRecord, existingRecord.CID)
-
if err != nil {
-
if errors.Is(err, pds.ErrConflict) {
-
// Record was modified by another operation - return appropriate error
-
return nil, fmt.Errorf("comment was modified, please refresh and try again: %w", err)
-
}
-
// ... existing error handling
-
}
-
```
-
-
**Files to Modify:**
-
- `internal/core/comments/comment_service.go` - UpdateComment method
-
- `internal/core/comments/errors.go` - Add `ErrConcurrentModification` if needed
-
-
**Testing:**
-
- Unit test: Verify `PutRecord` is called with correct CID
-
- Integration test: Simulate concurrent update, verify conflict handling
-
-
**Impact:** Proper optimistic locking prevents lost updates from race conditions
-
-
---
-
### Consolidate Environment Variable Validation
**Added:** 2025-10-11 | **Effort:** 2-3 hours
+33
internal/atproto/pds/client.go
···
// GetRecord retrieves a single record by collection and rkey.
GetRecord(ctx context.Context, collection string, rkey string) (*RecordResponse, error)
+
// PutRecord creates or updates a record with optional optimistic locking.
+
// If swapRecord CID is provided, the operation fails if the current CID doesn't match.
+
PutRecord(ctx context.Context, collection string, rkey string, record any, swapRecord string) (uri string, cid string, err error)
+
// DID returns the authenticated user's DID.
DID() string
···
return fmt.Errorf("%s: %w: %s", operation, ErrForbidden, apiErr.Message)
case 404:
return fmt.Errorf("%s: %w: %s", operation, ErrNotFound, apiErr.Message)
+
case 409:
+
return fmt.Errorf("%s: %w: %s", operation, ErrConflict, apiErr.Message)
}
}
···
Value: result.Value,
}, nil
}
+
+
// PutRecord creates or updates a record with optional optimistic locking.
+
func (c *client) PutRecord(ctx context.Context, collection string, rkey string, record any, swapRecord string) (string, string, error) {
+
payload := map[string]any{
+
"repo": c.did,
+
"collection": collection,
+
"rkey": rkey,
+
"record": record,
+
}
+
+
// Optional: optimistic locking via CID swap check
+
if swapRecord != "" {
+
payload["swapRecord"] = swapRecord
+
}
+
+
var result struct {
+
URI string `json:"uri"`
+
CID string `json:"cid"`
+
}
+
+
err := c.apiClient.Post(ctx, syntax.NSID("com.atproto.repo.putRecord"), payload, &result)
+
if err != nil {
+
return "", "", wrapAPIError(err, "putRecord")
+
}
+
+
return result.URI, result.CID, nil
+
}
+231
internal/atproto/pds/client_test.go
···
wantTyped: ErrBadRequest,
},
{
+
name: "409 maps to ErrConflict",
+
err: &atclient.APIError{StatusCode: 409, Name: "InvalidSwap", Message: "Record CID mismatch"},
+
operation: "putRecord",
+
wantTyped: ErrConflict,
+
},
+
{
name: "500 wraps without typed error",
err: &atclient.APIError{StatusCode: 500, Name: "InternalError", Message: "Server error"},
operation: "listRecords",
···
})
+
+
// TestClient_PutRecord tests the PutRecord method with a mock server.
+
func TestClient_PutRecord(t *testing.T) {
+
tests := []struct {
+
name string
+
collection string
+
rkey string
+
record map[string]any
+
swapRecord string
+
serverResponse map[string]any
+
serverStatus int
+
wantURI string
+
wantCID string
+
wantErr bool
+
}{
+
{
+
name: "successful put with swapRecord",
+
collection: "social.coves.comment",
+
rkey: "3kjzl5kcb2s2v",
+
record: map[string]any{
+
"$type": "social.coves.comment",
+
"content": "Updated comment content",
+
},
+
swapRecord: "bafyreigbtj4x7ip5legnfznufuopl4sg4knzc2cof6duas4b3q2fy6swua",
+
serverResponse: map[string]any{
+
"uri": "at://did:plc:test/social.coves.comment/3kjzl5kcb2s2v",
+
"cid": "bafyreihd4q3yqcfvnv5zlp6n4fqzh6z4p4m3mwc7vvr6k2j6y6v2a3b4c5",
+
},
+
serverStatus: http.StatusOK,
+
wantURI: "at://did:plc:test/social.coves.comment/3kjzl5kcb2s2v",
+
wantCID: "bafyreihd4q3yqcfvnv5zlp6n4fqzh6z4p4m3mwc7vvr6k2j6y6v2a3b4c5",
+
wantErr: false,
+
},
+
{
+
name: "successful put without swapRecord",
+
collection: "social.coves.comment",
+
rkey: "3kjzl5kcb2s2v",
+
record: map[string]any{
+
"$type": "social.coves.comment",
+
"content": "Updated comment",
+
},
+
swapRecord: "",
+
serverResponse: map[string]any{
+
"uri": "at://did:plc:test/social.coves.comment/3kjzl5kcb2s2v",
+
"cid": "bafyreihd4q3yqcfvnv5zlp6n4fqzh6z4p4m3mwc7vvr6k2j6y6v2a3b4c5",
+
},
+
serverStatus: http.StatusOK,
+
wantURI: "at://did:plc:test/social.coves.comment/3kjzl5kcb2s2v",
+
wantCID: "bafyreihd4q3yqcfvnv5zlp6n4fqzh6z4p4m3mwc7vvr6k2j6y6v2a3b4c5",
+
wantErr: false,
+
},
+
{
+
name: "conflict error (409)",
+
collection: "social.coves.comment",
+
rkey: "test",
+
record: map[string]any{"$type": "social.coves.comment"},
+
swapRecord: "bafyreigbtj4x7ip5legnfznufuopl4sg4knzc2cof6duas4b3q2fy6swua",
+
serverResponse: map[string]any{
+
"error": "InvalidSwap",
+
"message": "Record CID does not match",
+
},
+
serverStatus: http.StatusConflict,
+
wantErr: true,
+
},
+
{
+
name: "server error",
+
collection: "social.coves.comment",
+
rkey: "test",
+
record: map[string]any{"$type": "social.coves.comment"},
+
swapRecord: "",
+
serverResponse: map[string]any{
+
"error": "InvalidRequest",
+
"message": "Invalid record",
+
},
+
serverStatus: http.StatusBadRequest,
+
wantErr: true,
+
},
+
}
+
+
for _, tt := range tests {
+
t.Run(tt.name, func(t *testing.T) {
+
// Create mock server
+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+
// Verify method
+
if r.Method != http.MethodPost {
+
t.Errorf("expected POST request, got %s", r.Method)
+
}
+
+
// Verify path
+
expectedPath := "/xrpc/com.atproto.repo.putRecord"
+
if r.URL.Path != expectedPath {
+
t.Errorf("path = %q, want %q", r.URL.Path, expectedPath)
+
}
+
+
// Verify request body
+
var payload map[string]any
+
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
+
t.Fatalf("failed to decode request body: %v", err)
+
}
+
+
// Check required fields
+
if payload["collection"] != tt.collection {
+
t.Errorf("collection = %v, want %v", payload["collection"], tt.collection)
+
}
+
if payload["rkey"] != tt.rkey {
+
t.Errorf("rkey = %v, want %v", payload["rkey"], tt.rkey)
+
}
+
+
// Check swapRecord inclusion
+
if tt.swapRecord != "" {
+
if payload["swapRecord"] != tt.swapRecord {
+
t.Errorf("swapRecord = %v, want %v", payload["swapRecord"], tt.swapRecord)
+
}
+
} else {
+
if _, exists := payload["swapRecord"]; exists {
+
t.Error("swapRecord should not be included when empty")
+
}
+
}
+
+
// Send response
+
w.Header().Set("Content-Type", "application/json")
+
w.WriteHeader(tt.serverStatus)
+
json.NewEncoder(w).Encode(tt.serverResponse)
+
}))
+
defer server.Close()
+
+
// Create client
+
apiClient := atclient.NewAPIClient(server.URL)
+
apiClient.Auth = &bearerAuth{token: "test-token"}
+
+
c := &client{
+
apiClient: apiClient,
+
did: "did:plc:test",
+
host: server.URL,
+
}
+
+
// Execute PutRecord
+
ctx := context.Background()
+
uri, cid, err := c.PutRecord(ctx, tt.collection, tt.rkey, tt.record, tt.swapRecord)
+
+
if tt.wantErr {
+
if err == nil {
+
t.Fatal("expected error, got nil")
+
}
+
return
+
}
+
+
if err != nil {
+
t.Fatalf("unexpected error: %v", err)
+
}
+
+
if uri != tt.wantURI {
+
t.Errorf("uri = %q, want %q", uri, tt.wantURI)
+
}
+
+
if cid != tt.wantCID {
+
t.Errorf("cid = %q, want %q", cid, tt.wantCID)
+
}
+
})
+
}
+
}
+
+
// TestClient_TypedErrors_PutRecord tests that PutRecord returns typed errors.
+
func TestClient_TypedErrors_PutRecord(t *testing.T) {
+
tests := []struct {
+
name string
+
serverStatus int
+
wantErr error
+
}{
+
{
+
name: "401 returns ErrUnauthorized",
+
serverStatus: http.StatusUnauthorized,
+
wantErr: ErrUnauthorized,
+
},
+
{
+
name: "403 returns ErrForbidden",
+
serverStatus: http.StatusForbidden,
+
wantErr: ErrForbidden,
+
},
+
{
+
name: "409 returns ErrConflict",
+
serverStatus: http.StatusConflict,
+
wantErr: ErrConflict,
+
},
+
{
+
name: "400 returns ErrBadRequest",
+
serverStatus: http.StatusBadRequest,
+
wantErr: ErrBadRequest,
+
},
+
}
+
+
for _, tt := range tests {
+
t.Run(tt.name, func(t *testing.T) {
+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+
w.Header().Set("Content-Type", "application/json")
+
w.WriteHeader(tt.serverStatus)
+
json.NewEncoder(w).Encode(map[string]any{
+
"error": "TestError",
+
"message": "Test error message",
+
})
+
}))
+
defer server.Close()
+
+
apiClient := atclient.NewAPIClient(server.URL)
+
apiClient.Auth = &bearerAuth{token: "test-token"}
+
+
c := &client{
+
apiClient: apiClient,
+
did: "did:plc:test",
+
host: server.URL,
+
}
+
+
ctx := context.Background()
+
_, _, err := c.PutRecord(ctx, "test.collection", "rkey", map[string]any{}, "")
+
+
if err == nil {
+
t.Fatal("expected error, got nil")
+
}
+
+
if !errors.Is(err, tt.wantErr) {
+
t.Errorf("expected errors.Is(%v, %v) to be true", err, tt.wantErr)
+
}
+
})
+
}
+
}
+3
internal/atproto/pds/errors.go
···
// ErrBadRequest indicates the request was malformed or invalid (HTTP 400).
ErrBadRequest = errors.New("bad request")
+
+
// ErrConflict indicates the record was modified by another operation (HTTP 409).
+
ErrConflict = errors.New("record was modified by another operation")
)
// IsAuthError returns true if the error is an authentication/authorization error.
+5 -6
internal/core/comments/comment_service.go
···
CreatedAt: createdAt, // Preserve original timestamp
}
-
// Update the record on PDS (putRecord)
-
// Note: This creates a new CID even though the URI stays the same
-
// TODO: Use PutRecord instead of CreateRecord for proper update semantics with optimistic locking.
-
// PutRecord should accept the existing CID (existingRecord.CID) to ensure concurrent updates are detected.
-
// However, PutRecord is not yet implemented in internal/atproto/pds/client.go.
-
uri, cid, err := pdsClient.CreateRecord(ctx, commentCollection, rkey, updatedRecord)
+
// Update the record on PDS with optimistic locking via swapRecord CID
+
uri, cid, err := pdsClient.PutRecord(ctx, commentCollection, rkey, updatedRecord, existingRecord.CID)
if err != nil {
s.logger.Error("failed to update comment on PDS",
"error", err,
···
"rkey", rkey)
if pds.IsAuthError(err) {
return nil, ErrNotAuthorized
+
}
+
if errors.Is(err, pds.ErrConflict) {
+
return nil, ErrConcurrentModification
}
return nil, fmt.Errorf("failed to update comment: %w", err)
}
+17
internal/core/comments/comment_write_service_test.go
···
createError error // Error to return on CreateRecord
getError error // Error to return on GetRecord
deleteError error // Error to return on DeleteRecord
+
putError error // Error to return on PutRecord
did string // DID of the authenticated user
hostURL string // PDS host URL
}
···
func (m *mockPDSClient) ListRecords(ctx context.Context, collection string, limit int, cursor string) (*pds.ListRecordsResponse, error) {
return &pds.ListRecordsResponse{}, nil
+
}
+
+
func (m *mockPDSClient) PutRecord(ctx context.Context, collection, rkey string, record any, swapRecord string) (string, string, error) {
+
if m.putError != nil {
+
return "", "", m.putError
+
}
+
+
// Store record (same logic as CreateRecord)
+
if m.records[collection] == nil {
+
m.records[collection] = make(map[string]interface{})
+
}
+
m.records[collection][rkey] = record
+
+
uri := fmt.Sprintf("at://%s/%s/%s", m.did, collection, rkey)
+
cid := fmt.Sprintf("bafytest%d", time.Now().UnixNano())
+
return uri, cid, nil
}
// mockPDSClientFactory creates mock PDS clients for testing
+5 -1
internal/core/comments/errors.go
···
// ErrCommentAlreadyExists indicates a comment with this URI already exists
ErrCommentAlreadyExists = errors.New("comment already exists")
+
+
// ErrConcurrentModification indicates the comment was modified since it was loaded
+
ErrConcurrentModification = errors.New("comment was modified by another operation")
)
// IsNotFound checks if an error is a "not found" error
···
// IsConflict checks if an error is a conflict/already exists error
func IsConflict(err error) bool {
-
return errors.Is(err, ErrCommentAlreadyExists)
+
return errors.Is(err, ErrCommentAlreadyExists) ||
+
errors.Is(err, ErrConcurrentModification)
}
// IsValidationError checks if an error is a validation error
+169
tests/integration/comment_write_test.go
···
func parseTestDID(did string) (syntax.DID, error) {
return syntax.ParseDID(did)
}
+
+
// TestCommentWrite_ConcurrentModificationDetection tests that PutRecord's swapRecord
+
// CID validation correctly detects concurrent modifications.
+
// This verifies the optimistic locking mechanism that prevents lost updates.
+
func TestCommentWrite_ConcurrentModificationDetection(t *testing.T) {
+
if testing.Short() {
+
t.Skip("Skipping E2E test in short mode")
+
}
+
+
db := setupTestDB(t)
+
defer func() { _ = db.Close() }()
+
+
ctx := context.Background()
+
pdsURL := getTestPDSURL()
+
+
// Setup repositories and service
+
commentRepo := postgres.NewCommentRepository(db)
+
+
commentPDSFactory := func(ctx context.Context, session *oauthlib.ClientSessionData) (pds.Client, error) {
+
if session.AccessToken == "" {
+
return nil, fmt.Errorf("session has no access token")
+
}
+
if session.HostURL == "" {
+
return nil, fmt.Errorf("session has no host URL")
+
}
+
return pds.NewFromAccessToken(session.HostURL, session.AccountDID.String(), session.AccessToken)
+
}
+
+
commentService := comments.NewCommentServiceWithPDSFactory(
+
commentRepo,
+
nil,
+
nil,
+
nil,
+
nil,
+
commentPDSFactory,
+
)
+
+
// Create test user
+
testUserHandle := fmt.Sprintf("concurrency-%d.local.coves.dev", time.Now().Unix())
+
testUserEmail := fmt.Sprintf("concurrency-%d@test.local", time.Now().Unix())
+
testUserPassword := "test-password-123"
+
+
pdsAccessToken, userDID, err := createPDSAccount(pdsURL, testUserHandle, testUserEmail, testUserPassword)
+
if err != nil {
+
t.Skipf("PDS not available: %v", err)
+
}
+
+
// Setup OAuth
+
mockStore := NewMockOAuthStore()
+
mockStore.AddSessionWithPDS(userDID, "session-"+userDID, pdsAccessToken, pdsURL)
+
+
parsedDID, _ := parseTestDID(userDID)
+
session, _ := mockStore.GetSession(ctx, parsedDID, "session-"+userDID)
+
+
// Step 1: Create a comment
+
t.Logf("\n๐Ÿ“ Step 1: Creating initial comment...")
+
createReq := comments.CreateCommentRequest{
+
Reply: comments.ReplyRef{
+
Root: comments.StrongRef{
+
URI: "at://did:plc:test/social.coves.community.post/test123",
+
CID: "bafypost",
+
},
+
Parent: comments.StrongRef{
+
URI: "at://did:plc:test/social.coves.community.post/test123",
+
CID: "bafypost",
+
},
+
},
+
Content: "Original content for concurrency test",
+
Langs: []string{"en"},
+
}
+
+
createResp, err := commentService.CreateComment(ctx, session, createReq)
+
if err != nil {
+
t.Fatalf("Failed to create comment: %v", err)
+
}
+
t.Logf("โœ… Comment created: URI=%s, CID=%s", createResp.URI, createResp.CID)
+
originalCID := createResp.CID
+
+
// Step 2: Update the comment (this changes the CID)
+
t.Logf("\n๐Ÿ“ Step 2: Updating comment (this changes CID)...")
+
updateReq := comments.UpdateCommentRequest{
+
URI: createResp.URI,
+
Content: "Updated content - CID has changed",
+
}
+
+
updateResp, err := commentService.UpdateComment(ctx, session, updateReq)
+
if err != nil {
+
t.Fatalf("Failed to update comment: %v", err)
+
}
+
t.Logf("โœ… Comment updated: New CID=%s", updateResp.CID)
+
newCID := updateResp.CID
+
+
// Verify CIDs are different
+
if originalCID == newCID {
+
t.Fatalf("CIDs should be different after update: original=%s, new=%s", originalCID, newCID)
+
}
+
+
// Step 3: Simulate concurrent modification detection using direct PDS client
+
// Create a PDS client and attempt to update with the stale (original) CID
+
t.Logf("\n๐Ÿ” Step 3: Testing concurrent modification detection with stale CID...")
+
+
pdsClient, err := pds.NewFromAccessToken(pdsURL, userDID, pdsAccessToken)
+
if err != nil {
+
t.Fatalf("Failed to create PDS client: %v", err)
+
}
+
+
rkey := utils.ExtractRKeyFromURI(createResp.URI)
+
+
// Try to update with the ORIGINAL (now stale) CID - this should fail with 409
+
staleRecord := map[string]interface{}{
+
"$type": "social.coves.community.comment",
+
"reply": map[string]interface{}{
+
"root": map[string]interface{}{
+
"uri": "at://did:plc:test/social.coves.community.post/test123",
+
"cid": "bafypost",
+
},
+
"parent": map[string]interface{}{
+
"uri": "at://did:plc:test/social.coves.community.post/test123",
+
"cid": "bafypost",
+
},
+
},
+
"content": "This update should fail - using stale CID",
+
"createdAt": time.Now().UTC().Format(time.RFC3339),
+
}
+
+
_, _, err = pdsClient.PutRecord(ctx, "social.coves.community.comment", rkey, staleRecord, originalCID)
+
+
// Verify we get ErrConflict
+
if err == nil {
+
t.Fatal("Expected ErrConflict when updating with stale CID, got nil")
+
}
+
+
if !errors.Is(err, pds.ErrConflict) {
+
t.Errorf("Expected pds.ErrConflict, got: %v", err)
+
}
+
+
t.Logf("โœ… Correctly detected concurrent modification!")
+
t.Logf(" Error: %v", err)
+
+
// Step 4: Verify that updating with the correct CID succeeds
+
t.Logf("\n๐Ÿ“ Step 4: Verifying update with correct CID succeeds...")
+
correctRecord := map[string]interface{}{
+
"$type": "social.coves.community.comment",
+
"reply": map[string]interface{}{
+
"root": map[string]interface{}{
+
"uri": "at://did:plc:test/social.coves.community.post/test123",
+
"cid": "bafypost",
+
},
+
"parent": map[string]interface{}{
+
"uri": "at://did:plc:test/social.coves.community.post/test123",
+
"cid": "bafypost",
+
},
+
},
+
"content": "This update should succeed - using correct CID",
+
"createdAt": time.Now().UTC().Format(time.RFC3339),
+
}
+
+
_, finalCID, err := pdsClient.PutRecord(ctx, "social.coves.community.comment", rkey, correctRecord, newCID)
+
if err != nil {
+
t.Fatalf("Update with correct CID should succeed, got: %v", err)
+
}
+
+
t.Logf("โœ… Update with correct CID succeeded: New CID=%s", finalCID)
+
+
t.Logf("\nโœ… CONCURRENT MODIFICATION DETECTION TEST COMPLETE:")
+
t.Logf(" โœ“ PutRecord with stale CID correctly returns ErrConflict")
+
t.Logf(" โœ“ PutRecord with correct CID succeeds")
+
t.Logf(" โœ“ Optimistic locking prevents lost updates")
+
}