Make CloudKit apply path scope-aware and re-upload local-newer merges

CloudKit record names carry a source scope suffix so the same channel
or video ID can exist under different sources, but the apply path
matched local entities by bare ID: incoming records could merge into an
entity from a different scope, and a remote deletion of one scope
deleted every local entity with that ID and cascaded scoped deletions
for all other scopes back to CloudKit.

- Match local entities on the full mapper-derived record name when
  merging incoming records, materializing records for upload, and
  applying remote deletions.
- Remote deletions now remove only the matching entity and no longer
  queue CloudKit deletions (no echo, no cross-scope cascade).
- Insert dedupe for CloudKit-applied records is scope-aware, so a
  same-ID record from a different source inserts instead of being
  silently dropped. Remotely added bookmarks also update the
  fast-lookup cache immediately.
- When conflict resolution during a fetch keeps newer local data, the
  merge result is queued for upload; previously the server (and other
  devices) kept the stale version unless the entity was edited again.
  Strict timestamp comparisons prevent re-upload ping-pong between
  devices.
This commit is contained in:
Arkadiusz Fal
2026-07-15 20:15:03 +02:00
parent 262dab8f9e
commit c7ad4fad2e
6 changed files with 395 additions and 75 deletions

View File

@@ -200,6 +200,16 @@ extension DataManager {
}
}
/// Gets all subscriptions matching a channel ID. The same ID can exist
/// under multiple source scopes; callers that care about a specific
/// source must pick the matching entity themselves.
func subscriptions(forChannelID channelID: String) -> [Subscription] {
let descriptor = FetchDescriptor<Subscription>(
predicate: #Predicate { $0.channelID == channelID }
)
return (try? modelContext.fetch(descriptor)) ?? []
}
/// Gets all subscriptions.
func subscriptions() -> [Subscription] {
let descriptor = FetchDescriptor<Subscription>(
@@ -219,15 +229,17 @@ extension DataManager {
/// Inserts a subscription into the database.
/// Used by SubscriptionService for caching server subscriptions locally.
func insertSubscription(_ subscription: Subscription) {
// Check for duplicates
// Check for duplicates within the same source scope - the same
// channel ID can legitimately exist under different sources
let channelID = subscription.channelID
let descriptor = FetchDescriptor<Subscription>(
predicate: #Predicate { $0.channelID == channelID }
)
let scopeSuffix = subscription.sourceScopeSuffix
do {
let existing = try modelContext.fetch(descriptor)
if existing.isEmpty {
if !existing.contains(where: { $0.sourceScopeSuffix == scopeSuffix }) {
modelContext.insert(subscription)
save()
}
@@ -394,3 +406,17 @@ extension DataManager {
}
}
}
// MARK: - Source Scope
private extension Subscription {
/// Record-name scope suffix used to distinguish same-ID entities across sources.
var sourceScopeSuffix: String {
SourceScope.from(
sourceRawValue: sourceRawValue,
globalProvider: providerName,
instanceURLString: instanceURLString,
externalExtractor: nil
).recordNameSuffix
}
}