Skip to content

iOS: Implement feed/article creation with deduplication #25

@leogdion

Description

@leogdion

Overview

Enable iOS app users to add new feeds to the shared public catalog with immediate article display. Requires implementing feed/article creation with proper deduplication and race condition handling.

Background

Schema updated to allow _creator (authenticated users) to CREATE Feed and Article records:

  • Users can add new feeds from iOS app
  • Users cannot modify feeds after creation (only server can WRITE)
  • iOS app must parse RSS and create articles on first add for immediate display
  • Server continues to handle all subsequent updates via scheduled jobs

Implementation Requirements

1. Feed Deduplication

Problem: Prevent duplicate feeds when multiple users add the same feed URL simultaneously.

Solution: Use deterministic recordName based on feedURL

// Generate recordName from feedURL
func recordNameForFeed(_ feedURL: String) -> String {
    let hash = SHA256.hash(data: Data(feedURL.utf8))
    let hashString = hash.compactMap { String(format: "%02x", $0) }.joined()
    return "feed-\(hashString.prefix(16))"
}

Benefits:

  • CloudKit enforces recordName uniqueness automatically
  • Duplicate feed attempts fail gracefully with CKError.serverRecordChanged
  • No race conditions - first write wins

Implementation:

func addFeed(url: String) async throws {
    // 1. Check if feed already exists
    let recordName = recordNameForFeed(url)
    
    do {
        let existing = try await cloudKit.fetchRecord(recordName: recordName, recordType: "Feed")
        // Feed already exists - show user existing feed/articles
        return existing
    } catch {
        // Feed doesn't exist - continue to create
    }
    
    // 2. Parse RSS feed
    let parsedFeed = try await RSSParser.parse(url: url)
    
    // 3. Create Feed record with deterministic recordName
    let feedRecord = CKRecord(recordType: "Feed", recordID: CKRecord.ID(recordName: recordName))
    feedRecord["feedURL"] = url
    feedRecord["title"] = parsedFeed.title
    // ... other fields
    
    try await cloudKit.save(feedRecord)
    
    // 4. Create articles
    try await createArticles(parsedFeed.articles, feedRecordName: recordName)
}

2. Article Deduplication

Problem: Prevent duplicate articles when multiple users add the same feed or server updates overlap with user-created articles.

Current Server Behavior (UpdateCommand.swift:192-236):

  • Server queries existing articles by GUID before creating
  • Only creates articles with new GUIDs
  • Updates articles if contentHash changed

iOS App Requirements:

  • Must use same GUID-based deduplication logic
  • Query existing articles before creating
  • Handle partial success (some articles may already exist)

Implementation:

func createArticles(_ articles: [ParsedArticle], feedRecordName: String) async throws {
    // 1. Extract GUIDs from parsed articles
    let guids = articles.map { $0.guid }
    
    // 2. Query existing articles with those GUIDs
    let existingArticles = try await queryArticlesByGUIDs(guids, feedRecordName: feedRecordName)
    let existingGUIDs = Set(existingArticles.map { $0.guid })
    
    // 3. Filter to only new articles
    let newArticles = articles.filter { !existingGUIDs.contains($0.guid) }
    
    // 4. Create only new articles
    let records = newArticles.map { article -> CKRecord in
        let record = CKRecord(recordType: "Article")
        record["feedRecordName"] = feedRecordName
        record["guid"] = article.guid
        record["title"] = article.title
        record["url"] = article.url
        record["content"] = article.content
        record["contentHash"] = article.contentHash
        record["publishedTimestamp"] = article.publishedDate
        record["fetchedTimestamp"] = Date()
        // ... other fields
        return record
    }
    
    // 5. Batch save (handle partial failures gracefully)
    try await cloudKit.saveRecords(records)
}

3. Error Handling

Feed Already Exists:

catch let error as CKError where error.code == .serverRecordChanged {
    // Feed was created by another user between check and create
    // Fetch the existing feed and show to user
    return try await cloudKit.fetchRecord(recordName: recordName, recordType: "Feed")
}

RSS Parse Failures:

  • Invalid feed URL → Show user-friendly error
  • Network timeout → Retry with exponential backoff
  • Malformed RSS → Show validation error with details

Partial Article Creation Failures:

  • Some articles fail validation → Skip invalid articles, create valid ones
  • Network errors → Retry failed articles
  • Log failures for debugging

4. User Experience

Add Feed Flow:

  1. User enters feed URL
  2. Show loading indicator "Parsing feed..."
  3. Display feed preview (title, description, article count)
  4. User confirms → Create feed + articles
  5. Navigate to feed view with articles immediately visible

Feedback:

  • ✅ "Feed added successfully! 25 articles loaded."
  • ⚠️ "Feed already in catalog. Showing existing articles."
  • ❌ "Failed to parse feed: Invalid RSS format"

Testing Checklist

  • Add new feed → Articles appear immediately
  • Add duplicate feed → Graceful error, show existing feed
  • Race condition: Two users add same feed → First wins, second sees existing
  • Invalid feed URL → User-friendly error message
  • Network timeout during RSS parse → Retry logic works
  • Malformed RSS → Validation error displayed
  • Partial article creation failure → Valid articles created, invalid skipped
  • Server update after user-created articles → No duplicates, existing articles updated if changed

Dependencies

  • CelestraKit package (Feed, Article models)
  • RSS parsing library (SyndiKit or equivalent)
  • CloudKit framework
  • SHA256 hashing for recordName generation

Related Issues

  • Server-side duplicate handling improvements (see separate issue)

References

  • Schema: schema.ckdb (lines 43-50, 85-94)
  • Server deduplication logic: UpdateCommand.swift:192-236
  • CloudKit permissions model: CLAUDE.md

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions