Skip to content

Implement anonymous subscriber count tracking and feed prioritization #24

@leogdion

Description

@leogdion

Overview

Implement anonymous subscriber tracking to prioritize feed updates based on popularity. The CLI should update feeds with more iOS app subscribers more frequently.

Architecture

iOS App (clients)
  ↓ subscribe/unsubscribe
FeedSubscription records (source of truth)
  ↓ aggregated by CLI
Feed.subscriberCount (cached for performance)
  ↓ used for prioritization
CLI update command (--update-min-popularity, sorting)

Implementation Tasks

1. Add FeedSubscription Record Type to Schema

Update schema.ckdb:

// FeedSubscription - Anonymous feed subscription tracking
RECORD TYPE FeedSubscription (
    "___recordID"            REFERENCE QUERYABLE,    // SHA256(userID + feedID)
    "feedRecordName"         STRING QUERYABLE,       // Feed record name
    
    // Permissions: Users can manage their own subscriptions
    GRANT READ TO "_world",
    GRANT CREATE, WRITE, DELETE TO "_icloud"
);

Note: The record ID is a SHA256 hash of userID + feedID to ensure:

  • Same user + same feed = same record (prevents duplicates across devices)
  • Can't correlate subscriptions across different feeds (privacy)
  • One-way hash (can't reverse to get user ID)

Deploy schema: ./Scripts/setup-cloudkit-schema.sh

2. Add FeedSubscription Model to CelestraKit

Create the model in CelestraKit package (shared between CLI and iOS app):

public struct FeedSubscription: Sendable {
    public let recordName: String
    public let feedRecordName: String
    
    // Helper to generate hashed record ID
    public static func subscriptionRecordID(
        for feedRecordName: String,
        userRecordID: String
    ) -> String {
        let input = "\(feedRecordName)-\(userRecordID)"
        let hash = SHA256.hash(data: Data(input.utf8))
        return hash.compactMap { String(format: "%02x", $0) }.joined()
    }
}

3. Implement sync-subscriber-counts CLI Command

Create Sources/CelestraCloud/Commands/SyncSubscriberCountsCommand.swift:

Algorithm:

  1. Query all FeedSubscription records
  2. Group by feedRecordName and count
  3. Batch update Feed.subscriberCount fields (non-atomic, batches of 10)
  4. Log results (feeds updated, total subscribers counted)

Command usage:

celestra-cloud sync-subscriber-counts

4. Add CloudKit Service Methods

In Sources/CelestraCloudKit/Services/CloudKitService+Celestra.swift:

// Query all subscription records
func queryAllSubscriptions() async throws -> [FeedSubscription]

// Batch update subscriber counts on feeds
func updateSubscriberCounts(_ counts: [String: Int]) async throws -> BatchOperationResult

5. Update Workflow Integration

In .github/workflows/update-feeds.yml, run sync before updates:

- name: Sync subscriber counts
  run: |
    source .env
    celestra-cloud sync-subscriber-counts

- name: Update feeds (prioritized by popularity)
  run: |
    source .env
    celestra-cloud update --update-delay ${{ env.UPDATE_DELAY }}

6. Enhance Feed Update Prioritization

Update UpdateCommand.swift to sort by subscriber count:

let feeds = try await service.queryFeeds(
    filters: filters,
    sortBy: [
        .descending("subscriberCount"),  // Popular feeds first
        .ascending("attemptedTimestamp")  // Then by staleness
    ],
    limit: limit
)

7. iOS App Integration (Future Work)

Document how iOS app should create/delete subscriptions:

// Subscribe to feed
func subscribe(to feedRecordName: String) async throws {
    let userRecordID = try await CKContainer.default().userRecordID()
    let recordID = FeedSubscription.subscriptionRecordID(
        for: feedRecordName,
        userRecordID: userRecordID.recordName
    )
    
    let record = CKRecord(
        recordType: "FeedSubscription",
        recordID: CKRecord.ID(recordName: recordID)
    )
    record["feedRecordName"] = feedRecordName
    
    try await CKContainer.default().publicCloudDatabase.save(record)
}

// Unsubscribe from feed
func unsubscribe(from feedRecordName: String) async throws {
    let userRecordID = try await CKContainer.default().userRecordID()
    let recordID = FeedSubscription.subscriptionRecordID(
        for: feedRecordName,
        userRecordID: userRecordID.recordName
    )
    
    try await CKContainer.default().publicCloudDatabase.deleteRecord(
        withID: CKRecord.ID(recordName: recordID)
    )
}

Benefits

  1. iOS app: Fast subscription management (create/delete one record)
  2. CLI: Efficient filtering and sorting without counting per-feed
  3. Users: Popular feeds update more frequently
  4. Privacy: Anonymous tracking via hashed record IDs
  5. Cost: Minimal CloudKit queries

Testing Plan

  1. Add FeedSubscription test data in development environment
  2. Run sync-subscriber-counts and verify Feed.subscriberCount updated
  3. Run update --update-min-popularity 5 and verify filtering works
  4. Verify feeds are processed in descending subscriber count order

References

  • Privacy-preserving design using SHA256 hashing
  • Eventual consistency model (counts synced periodically)
  • Non-atomic batch updates for resilience

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