crossmate

A collaborative crossword app for iOS
Log | Files | Refs | LICENSE

commit df7ebce237a6bbb50d070236ee581b10c045f032
parent 01543f2188998257d2ee77953d667e73f4608c59
Author: Michael Camilleri <[email protected]>
Date:   Thu,  2 Jul 2026 23:15:28 +0900

Migrate the Int16 scope plumbing onto DatabaseScope

The 0/1 database-scope convention flowed between components as bare
Int16 values — engine selection, security gates, and zone lookups all
compared magic numbers, and the DatabaseScope enum introduced with the
decline-Ping hardening named the convention only on the Ping fetch path.
This commit adopts the enum everywhere scope travels between components:
SyncEngine's enqueue/delete/burst/zone helpers and its checkpoint
dictionaries, CloudZones.ZoneInfo, CloudQuery's per-scope switches,
RecordSerializer's Decision parse and apply gates,
RecordApplier.applyDirectRecordZoneChanges, GameCloudDeletion,
AccountPushCoordinator's key-publish targets, PlayerNamePublisher's
rename fan-out, and FriendController's key-publisher closure.

Core Data remains the Int16 boundary: entity attributes, predicates,
direct entity.databaseScope comparisons, and private entity snapshots
(PlayerRoster.FetchedRoster, GameSummaryCache.Key) keep the raw value,
converting once at that edge via the new DatabaseScope(entityValue:) and
DatabaseScope(isPrivate:) initialisers. Nothing persisted encodes the
enum — ZoneInfo, checkpoints, and burst state are in-memory — and the
liveQueryCheckpoints string keys keep the rawValue form, so key shapes
are unchanged. Purely type-level; no behaviour change.

Co-Authored-By: Claude Fable 5 <[email protected]>

Diffstat:
MCrossmate/Persistence/GameStore.swift | 4++--
MCrossmate/Services/AccountPushCoordinator.swift | 10+++++-----
MCrossmate/Services/PlayerNamePublisher.swift | 12++++++------
MCrossmate/Sync/CloudQuery.swift | 64++++++++++++++++++++++++++++++++--------------------------------
MCrossmate/Sync/CloudZones.swift | 28++++++++++++++--------------
MCrossmate/Sync/FriendController.swift | 16++++++++--------
MCrossmate/Sync/Presence.swift | 17++++++++++++++---
MCrossmate/Sync/RecordApplier.swift | 2+-
MCrossmate/Sync/RecordSerializer.swift | 24++++++++++++------------
MCrossmate/Sync/SyncEngine.swift | 83+++++++++++++++++++++++++++++++++++++++----------------------------------------
MTests/Unit/AccountPushCoordinatorTests.swift | 2+-
MTests/Unit/PlayerNamePublisherTests.swift | 4++--
MTests/Unit/RecordSerializerTests.swift | 22+++++++++++-----------
13 files changed, 149 insertions(+), 139 deletions(-)

diff --git a/Crossmate/Persistence/GameStore.swift b/Crossmate/Persistence/GameStore.swift @@ -248,7 +248,7 @@ struct GameSummary: Identifiable, Equatable { /// The sync layer cannot look this up after the Core Data cascade completes. struct GameCloudDeletion: Sendable, Equatable { let gameID: UUID - let databaseScope: Int16 + let databaseScope: DatabaseScope let ckZoneName: String let ckZoneOwnerName: String /// The private-DB archive backup zone (archive-<id>) to tear down alongside @@ -1287,7 +1287,7 @@ final class GameStore { && !entity.isAccessRevoked let deletion = GameCloudDeletion( gameID: id, - databaseScope: entity.databaseScope, + databaseScope: DatabaseScope(entityValue: entity.databaseScope), ckZoneName: entity.ckZoneName ?? "game-\(id.uuidString)", ckZoneOwnerName: entity.ckZoneOwnerName ?? CKCurrentUserDefaultName, archiveZoneName: hasSeparateArchive diff --git a/Crossmate/Services/AccountPushCoordinator.swift b/Crossmate/Services/AccountPushCoordinator.swift @@ -216,7 +216,7 @@ final class AccountPushCoordinator { func ensureFriendInvitationKeyPublished( pairKey: String, friendZoneID: CKRecordZone.ID, - friendZoneScope: Int16 + friendZoneScope: DatabaseScope ) async { guard preferences.isICloudSyncEnabled else { return } guard let authorID = identity.currentID, !authorID.isEmpty else { return } @@ -242,7 +242,7 @@ final class AccountPushCoordinator { private func friendEncryptionKeyTargets( localAuthorID: String - ) async -> [(pairKey: String, zoneID: CKRecordZone.ID, scope: Int16)] { + ) async -> [(pairKey: String, zoneID: CKRecordZone.ID, scope: DatabaseScope)] { let ctx = persistence.container.newBackgroundContext() return await ctx.perform { let req = NSFetchRequest<FriendEntity>(entityName: "FriendEntity") @@ -254,11 +254,11 @@ final class AccountPushCoordinator { let pairKey = friend.pairKey ?? FriendZone.pairKey(localAuthorID, friendAuthorID) guard FriendZone.outboxAccepted(pairKey: pairKey) else { return nil } // The friend decrypts our invite pushes by reading our key from - // their inbox — our outbox (shared DB, scope 1). + // their inbox — our outbox (shared DB). return ( pairKey, FriendZone.outboxZoneID(pairKey: pairKey, friendAuthorID: friendAuthorID), - Int16(1) + .shared ) } } @@ -267,7 +267,7 @@ final class AccountPushCoordinator { private func friendEncryptionKeyTarget( pairKey: String, localAuthorID: String - ) async -> (zoneID: CKRecordZone.ID, scope: Int16)? { + ) async -> (zoneID: CKRecordZone.ID, scope: DatabaseScope)? { await friendEncryptionKeyTargets(localAuthorID: localAuthorID) .first { $0.pairKey == pairKey } .map { ($0.zoneID, $0.scope) } diff --git a/Crossmate/Services/PlayerNamePublisher.swift b/Crossmate/Services/PlayerNamePublisher.swift @@ -62,7 +62,7 @@ final class PlayerNamePublisher { private let authorIdentity: AuthorIdentity private let enqueuePlayer: (UUID, String, String) async -> Void /// (authorID, name, version, zoneID, scope) → `SyncEngine.enqueueNameDecision`. - private let enqueueNameDecision: (String, String, Int64, CKRecordZone.ID, Int16) async -> Void + private let enqueueNameDecision: (String, String, Int64, CKRecordZone.ID, DatabaseScope) async -> Void private var debounceTask: Task<Void, Never>? private var observationTask: Task<Void, Never>? @@ -76,7 +76,7 @@ final class PlayerNamePublisher { persistence: PersistenceController, authorIdentity: AuthorIdentity, enqueuePlayer: @escaping (UUID, String, String) async -> Void, - enqueueNameDecision: @escaping (String, String, Int64, CKRecordZone.ID, Int16) async -> Void + enqueueNameDecision: @escaping (String, String, Int64, CKRecordZone.ID, DatabaseScope) async -> Void ) { self.preferences = preferences self.persistence = persistence @@ -148,7 +148,7 @@ final class PlayerNamePublisher { let version = NameVersionStore.next(authorID: authorID) await enqueueNameDecision( - authorID, name, version, RecordSerializer.accountZoneID, 0 + authorID, name, version, RecordSerializer.accountZoneID, .private ) let ctx = persistence.container.newBackgroundContext() for target in await Self.friendZoneTargets(in: ctx) { @@ -164,21 +164,21 @@ final class PlayerNamePublisher { /// it never blocks the main actor even though the caller is `@MainActor`. private nonisolated static func friendZoneTargets( in ctx: NSManagedObjectContext - ) async -> [(zoneID: CKRecordZone.ID, scope: Int16)] { + ) async -> [(zoneID: CKRecordZone.ID, scope: DatabaseScope)] { await ctx.perform { let req = NSFetchRequest<FriendEntity>(entityName: "FriendEntity") req.predicate = NSPredicate(format: "isBlocked == NO") let friends = (try? ctx.fetch(req)) ?? [] return friends.compactMap { friend in // Our name is told to a friend by writing into their inbox — - // our outbox (shared DB, scope 1). + // our outbox (shared DB). guard let pairKey = friend.pairKey, let authorID = friend.authorID, FriendZone.outboxAccepted(pairKey: pairKey) else { return nil } return ( FriendZone.outboxZoneID(pairKey: pairKey, friendAuthorID: authorID), - Int16(1) + .shared ) } } diff --git a/Crossmate/Sync/CloudQuery.swift b/Crossmate/Sync/CloudQuery.swift @@ -9,16 +9,16 @@ extension SyncEngine { @discardableResult func fetchPushPingsDirect(scope: CKDatabase.Scope) async throws -> Int { let database: CKDatabase - let scopeValue: Int16 + let scopeValue: DatabaseScope let label: String switch scope { case .private: database = container.privateCloudDatabase - scopeValue = 0 + scopeValue = .private label = "private" case .shared: database = container.sharedCloudDatabase - scopeValue = 1 + scopeValue = .shared label = "shared" case .public: return 0 @@ -105,7 +105,7 @@ extension SyncEngine { var pings: [Ping] = [] var fetchedCount = 0 for record in collected { - guard let ping = Ping.parseRecord(record, fetchedFrom: DatabaseScope(rawValue: scopeValue)) else { continue } + guard let ping = Ping.parseRecord(record, fetchedFrom: scopeValue) else { continue } fetchedCount += 1 let modDate = record.modificationDate ?? Date() if seen.updateValue(modDate, forKey: record.recordID.recordName) == nil { @@ -151,16 +151,16 @@ extension SyncEngine { @discardableResult func fetchFriendInvitesDirect(scope: CKDatabase.Scope) async throws -> Int { let database: CKDatabase - let scopeValue: Int16 + let scopeValue: DatabaseScope let label: String switch scope { case .private: database = container.privateCloudDatabase - scopeValue = 0 + scopeValue = .private label = "private" case .shared: database = container.sharedCloudDatabase - scopeValue = 1 + scopeValue = .shared label = "shared" case .public: return 0 @@ -196,7 +196,7 @@ extension SyncEngine { ) return PerZoneInvites( pings: records.compactMap { - Ping.parseRecord($0, fetchedFrom: DatabaseScope(rawValue: scopeValue)) + Ping.parseRecord($0, fetchedFrom: scopeValue) }, recordNames: Set(records.map(\.recordID.recordName)), scannedAuthorID: target.authorID, @@ -245,14 +245,14 @@ extension SyncEngine { } private func friendInviteScanTargets( - forScope scope: Int16 + forScope scope: DatabaseScope ) -> [(zoneID: CKRecordZone.ID, authorID: String)] { // Invites we receive are written by the friend into our inbox — the - // zone we own (private DB, scope 0). Our outboxes (scope 1) hold only + // zone we own (private DB). Our outboxes (shared DB) hold only // what we send, so there is nothing to scan there. Blocked friends are // skipped: they can no longer write to us, and we don't surface their // earlier invites. - guard scope == 0 else { return [] } + guard scope == .private else { return [] } let ctx = persistence.container.newBackgroundContext() return ctx.performAndWait { let req = NSFetchRequest<FriendEntity>(entityName: "FriendEntity") @@ -329,8 +329,8 @@ extension SyncEngine { /// private or shared friend zone depending on how the pair befriended. func deleteInvitePingsAfterLeave(forGameID gameID: UUID) async { for (scopeValue, database) in [ - (Int16(0), container.privateCloudDatabase), - (Int16(1), container.sharedCloudDatabase) + (DatabaseScope.private, container.privateCloudDatabase), + (DatabaseScope.shared, container.sharedCloudDatabase) ] { for zoneID in friendZoneIDs(forScope: scopeValue) { let records: [CKRecord] @@ -350,7 +350,7 @@ extension SyncEngine { continue } for record in records { - guard let ping = Ping.parseRecord(record, fetchedFrom: DatabaseScope(rawValue: scopeValue)), + guard let ping = Ping.parseRecord(record, fetchedFrom: scopeValue), ping.gameID == gameID else { continue } deletePing(recordName: ping.recordName, zoneID: zoneID, databaseScope: scopeValue) await trace( @@ -368,16 +368,16 @@ extension SyncEngine { @discardableResult func fetchBackgroundSessionsDirect(scope: CKDatabase.Scope) async throws -> [Session] { let database: CKDatabase - let scopeValue: Int16 + let scopeValue: DatabaseScope let label: String switch scope { case .private: database = container.privateCloudDatabase - scopeValue = 0 + scopeValue = .private label = "private" case .shared: database = container.sharedCloudDatabase - scopeValue = 1 + scopeValue = .shared label = "shared" case .public: return [] @@ -490,16 +490,16 @@ extension SyncEngine { @discardableResult func discoverNewZonesDirect(scope: CKDatabase.Scope) async throws -> Int { let database: CKDatabase - let scopeValue: Int16 + let scopeValue: DatabaseScope let label: String switch scope { case .private: database = container.privateCloudDatabase - scopeValue = 0 + scopeValue = .private label = "private" case .shared: database = container.sharedCloudDatabase - scopeValue = 1 + scopeValue = .shared label = "shared" case .public: return 0 @@ -629,16 +629,16 @@ extension SyncEngine { @discardableResult func fetchGameDirect(scope: CKDatabase.Scope, gameID: UUID) async throws -> Bool { let database: CKDatabase - let scopeValue: Int16 + let scopeValue: DatabaseScope let label: String switch scope { case .private: database = container.privateCloudDatabase - scopeValue = 0 + scopeValue = .private label = "private" case .shared: database = container.sharedCloudDatabase - scopeValue = 1 + scopeValue = .shared label = "shared" case .public: return false @@ -664,7 +664,7 @@ extension SyncEngine { return true } - let checkpointKey = "\(scopeValue):\(gameID.uuidString)" + let checkpointKey = "\(scopeValue.rawValue):\(gameID.uuidString)" let since = liveQueryCheckpoints[checkpointKey]? .addingTimeInterval(-liveQueryCheckpointOverlap) let gameRecordID = CKRecord.ID( @@ -798,13 +798,13 @@ extension SyncEngine { // a later `fetchGameDirect(since:)` from skipping unfetched moves. if !onlyGame, let latestModification = records.compactMap(\.modificationDate).max() { - setLiveQueryCheckpoint(latestModification, scopeValue: 1, gameID: gameID) + setLiveQueryCheckpoint(latestModification, scopeValue: .shared, gameID: gameID) } await applyDirectRecordZoneChanges( records: records, deletions: [], - scopeValue: 1 + scopeValue: .shared ) await trace( "shared accepted-game fetch: \(gameID.uuidString.prefix(8)), " + @@ -834,16 +834,16 @@ extension SyncEngine { @discardableResult func fetchKnownGameMovesDirect(scope: CKDatabase.Scope) async throws -> Int { let database: CKDatabase - let scopeValue: Int16 + let scopeValue: DatabaseScope let label: String switch scope { case .private: database = container.privateCloudDatabase - scopeValue = 0 + scopeValue = .private label = "private" case .shared: database = container.sharedCloudDatabase - scopeValue = 1 + scopeValue = .shared label = "shared" case .public: return 0 @@ -1021,10 +1021,10 @@ extension SyncEngine { private func setLiveQueryCheckpoint( _ date: Date, - scopeValue: Int16, + scopeValue: DatabaseScope, gameID: UUID ) { - liveQueryCheckpoints["\(scopeValue):\(gameID.uuidString)"] = date + liveQueryCheckpoints["\(scopeValue.rawValue):\(gameID.uuidString)"] = date } func deleteRecords( @@ -1065,7 +1065,7 @@ extension SyncEngine { guard let info = zoneInfo(forGameID: gameID, in: ctx), !info.isAccessRevoked else { return nil } - let database = info.scope == 1 + let database = info.scope == .shared ? container.sharedCloudDatabase : container.privateCloudDatabase diff --git a/Crossmate/Sync/CloudZones.swift b/Crossmate/Sync/CloudZones.swift @@ -4,7 +4,7 @@ import Foundation extension SyncEngine { struct ZoneInfo { - let scope: Int16 + let scope: DatabaseScope let zoneID: CKRecordZone.ID let isAccessRevoked: Bool let isCloudConfirmed: Bool @@ -31,7 +31,7 @@ extension SyncEngine { let zoneName = entity.ckZoneName ?? "game-\(gameID.uuidString)" let ownerName = entity.ckZoneOwnerName ?? CKCurrentUserDefaultName return ZoneInfo( - scope: entity.databaseScope, + scope: DatabaseScope(entityValue: entity.databaseScope), zoneID: CKRecordZone.ID(zoneName: zoneName, ownerName: ownerName), isAccessRevoked: entity.isAccessRevoked, isCloudConfirmed: entity.ckSystemFields != nil @@ -46,7 +46,7 @@ extension SyncEngine { /// of interest (for shared games, they pre-date our join; for owned /// games, they pre-date the game's existence). nonisolated func knownZones( - forScope scope: Int16, + forScope scope: DatabaseScope, onlyIncomplete: Bool = false, in ctx: NSManagedObjectContext ) -> [(zoneID: CKRecordZone.ID, createdAt: Date)] { @@ -74,7 +74,7 @@ extension SyncEngine { format: onlyIncomplete ? "databaseScope == %d AND completedAt == nil AND isAccessRevoked == NO" : "databaseScope == %d AND isAccessRevoked == NO", - scope + scope.rawValue ) guard let entities = try? ctx.fetch(req) else { return [] } var seen = Set<String>() @@ -98,14 +98,14 @@ extension SyncEngine { // blocked. Floor is `.distantPast`: any unseen invite should be // processed. let friendReq = NSFetchRequest<FriendEntity>(entityName: "FriendEntity") - friendReq.predicate = scope == 0 + friendReq.predicate = scope == .private ? NSPredicate(value: true) : NSPredicate(format: "isBlocked == NO") for friend in (try? ctx.fetch(friendReq)) ?? [] { guard let pairKey = friend.pairKey, let authorID = friend.authorID else { continue } - let zoneID = scope == 0 + let zoneID = scope == .private ? FriendZone.inboxZoneID(pairKey: pairKey) : FriendZone.outboxZoneID(pairKey: pairKey, friendAuthorID: authorID) let key = "\(zoneID.ownerName)|\(zoneID.zoneName)" @@ -117,14 +117,14 @@ extension SyncEngine { } nonisolated func incompleteKnownZones( - forScope scope: Int16, + forScope scope: DatabaseScope, in ctx: NSManagedObjectContext ) -> [ActivityZoneInfo] { ctx.performAndWait { let req = NSFetchRequest<GameEntity>(entityName: "GameEntity") req.predicate = NSPredicate( format: "databaseScope == %d AND completedAt == nil AND isAccessRevoked == NO", - scope + scope.rawValue ) guard let entities = try? ctx.fetch(req) else { return [] } var seen = Set<String>() @@ -145,14 +145,14 @@ extension SyncEngine { } } - nonisolated func friendZoneIDs(forScope scope: Int16) -> [CKRecordZone.ID] { + nonisolated func friendZoneIDs(forScope scope: DatabaseScope) -> [CKRecordZone.ID] { let ctx = persistence.container.newBackgroundContext() return ctx.performAndWait { - // scope 0 → our inboxes (private DB, we own them, kept even when - // blocked); scope 1 → our outboxes (the friends' inboxes in the - // shared DB, skipped while blocked). Both derived from the pair. + // .private → our inboxes (we own them, kept even when blocked); + // .shared → our outboxes (the friends' inboxes in the shared DB, + // skipped while blocked). Both derived from the pair. let req = NSFetchRequest<FriendEntity>(entityName: "FriendEntity") - req.predicate = scope == 0 + req.predicate = scope == .private ? NSPredicate(value: true) : NSPredicate(format: "isBlocked == NO") var seen = Set<String>() @@ -161,7 +161,7 @@ extension SyncEngine { guard let pairKey = friend.pairKey, let authorID = friend.authorID else { continue } - let zoneID = scope == 0 + let zoneID = scope == .private ? FriendZone.inboxZoneID(pairKey: pairKey) : FriendZone.outboxZoneID(pairKey: pairKey, friendAuthorID: authorID) let key = "\(zoneID.ownerName)|\(zoneID.zoneName)" diff --git a/Crossmate/Sync/FriendController.swift b/Crossmate/Sync/FriendController.swift @@ -23,7 +23,7 @@ final class FriendController { /// key into the friend zone so the pair can seal invite pushes. Supplied by /// `AppServices`; nil where push isn't wired (e.g. tests). typealias FriendInvitationKeyPublisher = - (_ pairKey: String, _ friendZoneID: CKRecordZone.ID, _ friendZoneScope: Int16) async -> Void + (_ pairKey: String, _ friendZoneID: CKRecordZone.ID, _ friendZoneScope: DatabaseScope) async -> Void let container: CKContainer private let persistence: PersistenceController @@ -161,7 +161,7 @@ final class FriendController { zoneID: zoneID, // The seed is only ever written into the friend's inbox (our // outbox), a zone the friend owns. - scope: DatabaseScope.shared.rawValue + scope: .shared ) } @@ -239,7 +239,7 @@ final class FriendController { // friend can decrypt invite pushes we seal; self-heals on the next // push-registration refresh if this misses. Task { [weak self] in - await self?.publishFriendInvitationKey?(payload.pairKey, outboxZoneID, 1) + await self?.publishFriendInvitationKey?(payload.pairKey, outboxZoneID, .shared) } syncMonitor?.recordSuccess("accept friendship") // `applyFriendPing` runs inside the `onPings` CKSyncEngine @@ -474,7 +474,7 @@ final class FriendController { playerName: localDisplayName ?? "", addressee: friendAuthorID, friendZoneID: FriendZone.outboxZoneID(pairKey: pairKey, friendAuthorID: friendAuthorID), - friendZoneScope: 1, + friendZoneScope: .shared, payload: encoded ) syncMonitor?.recordSuccess("migrate friendship") @@ -551,7 +551,7 @@ final class FriendController { playerName: inviterName, addressee: friendAuthorID, friendZoneID: FriendZone.outboxZoneID(pairKey: pairKey, friendAuthorID: friendAuthorID), - friendZoneScope: 1, + friendZoneScope: .shared, payload: encoded ) } @@ -588,7 +588,7 @@ final class FriendController { playerName: declinerName, addressee: inviterAuthorID, friendZoneID: FriendZone.outboxZoneID(pairKey: pairKey, friendAuthorID: inviterAuthorID), - friendZoneScope: 1 + friendZoneScope: .shared ) } @@ -613,7 +613,7 @@ final class FriendController { await syncEngine.deletePing( recordName: recordName, zoneID: FriendZone.inboxZoneID(pairKey: pairKey), - databaseScope: 0 + databaseScope: .private ) } @@ -953,7 +953,7 @@ final class FriendController { record, to: ctx, localAuthorID: nil, - databaseScope: 0 + databaseScope: .private ) if wrote { try ctx.save() diff --git a/Crossmate/Sync/Presence.swift b/Crossmate/Sync/Presence.swift @@ -63,12 +63,23 @@ enum PingKind: String, Sendable { /// `Int16` convention used throughout the app (Core Data's /// `GameEntity.databaseScope`, sync bookkeeping, push payloads): `0` is the /// private database — zones this user owns — and `1` is the shared database — -/// zones this user joined. New code should traffic in this enum and convert -/// with `rawValue` at the Core Data / CloudKit boundaries; the existing -/// `Int16` plumbing migrates incrementally. +/// zones this user joined. Plumbing between components traffics in this enum; +/// `rawValue` converts at the Core Data storage boundary (entity attributes +/// and predicates stay `Int16`). enum DatabaseScope: Int16, Sendable { case `private` = 0 case shared = 1 + + init(isPrivate: Bool) { + self = isPrivate ? .private : .shared + } + + /// Reads an entity's stored `databaseScope`. Only `0`/`1` are ever + /// written; anything else falls back to `.private`, matching the historic + /// `scope == 1 ? shared : private` reading of the raw column. + init(entityValue: Int16) { + self = DatabaseScope(rawValue: entityValue) ?? .private + } } struct Ping: Sendable { diff --git a/Crossmate/Sync/RecordApplier.swift b/Crossmate/Sync/RecordApplier.swift @@ -72,7 +72,7 @@ extension SyncEngine { func applyDirectRecordZoneChanges( records: [CKRecord], deletions: [(CKRecord.ID, CKRecord.RecordType)], - scopeValue: Int16 + scopeValue: DatabaseScope ) async { guard !records.isEmpty || !deletions.isEmpty else { return } let ctx = persistence.container.newBackgroundContext() diff --git a/Crossmate/Sync/RecordSerializer.swift b/Crossmate/Sync/RecordSerializer.swift @@ -227,15 +227,15 @@ enum RecordSerializer { /// `CKCurrentUserDefaultName` owner placeholder — so match the name. static func isAccountZonePrivateDecision( _ record: CKRecord, - databaseScope: Int16 + databaseScope: DatabaseScope ) -> Bool { record.recordID.zoneID.zoneName == accountZoneID.zoneName - && databaseScope == 0 + && databaseScope == .private } static func parseAccountPushAddressDecision( _ record: CKRecord, - databaseScope: Int16 + databaseScope: DatabaseScope ) -> String? { guard record.recordType == "Decision", record.recordID.recordName == accountPushAddressDecisionName, @@ -265,7 +265,7 @@ enum RecordSerializer { static func parseAccountPushSecretDecision( _ record: CKRecord, - databaseScope: Int16 + databaseScope: DatabaseScope ) -> (secret: String, version: Int64)? { guard record.recordType == "Decision", record.recordID.recordName == accountPushSecretDecisionName, @@ -779,7 +779,7 @@ enum RecordSerializer { static func applyGameRecord( _ record: CKRecord, to context: NSManagedObjectContext, - databaseScope: Int16 = 0, + databaseScope: DatabaseScope = .private, onEngagementChange: ((UUID) -> Void)? = nil, onCompletedTransition: ((UUID) -> Void)? = nil, onContentKeyChange: ((UUID) -> Void)? = nil @@ -814,7 +814,7 @@ enum RecordSerializer { entity.ckZoneName = record.recordID.zoneID.zoneName let ownerName = record.recordID.zoneID.ownerName entity.ckZoneOwnerName = ownerName == CKCurrentUserDefaultName ? nil : ownerName - entity.databaseScope = databaseScope + entity.databaseScope = databaseScope.rawValue // Local mutable fields take precedence while a push is in flight. // The flag is set atomically with the local write (in `markCompleted`, @@ -1076,7 +1076,7 @@ enum RecordSerializer { _ record: CKRecord, to ctx: NSManagedObjectContext, localAuthorID: String?, - databaseScope: Int16 = 0 + databaseScope: DatabaseScope = .private ) -> Bool { guard record.recordType == "Decision" else { return false } // Identity comes from the record name (always present, immutable); @@ -1161,10 +1161,10 @@ enum RecordSerializer { } // The friend writes their name into *our inbox* (their outbox), so // a friend's name only ever arrives via the zone we own — the - // private engine, scope 0. Reject one seen at scope 1 (that would be + // private engine. Reject one seen at shared scope (that would be // the friend's own inbox, not the channel they tell us their name // through). - guard databaseScope == 0 else { return false } + guard databaseScope == .private else { return false } let version = decisionVersion(record) let req = NSFetchRequest<FriendEntity>(entityName: "FriendEntity") req.predicate = NSPredicate(format: "pairKey == %@", pairKey) @@ -1236,11 +1236,11 @@ enum RecordSerializer { } // Like the `name` case: the friend writes their key into *our inbox* // (their outbox), so it only ever arrives via the zone we own — the - // private engine, scope 0. Reject one seen at scope 1 (that would be + // private engine. Reject one seen at shared scope (that would be // the friend's own inbox, not the channel they publish to us). The // zone-name gate alone is insufficient: a peer owns a zone that - // *names* itself for this pair and shares it to us at scope 1. - guard databaseScope == 0 else { return false } + // *names* itself for this pair and shares it to us at shared scope. + guard databaseScope == .private else { return false } FriendEncryptionKeyDirectory.upsert(payload, for: key) return false default: diff --git a/Crossmate/Sync/SyncEngine.swift b/Crossmate/Sync/SyncEngine.swift @@ -187,9 +187,8 @@ actor SyncEngine { /// Per-scope checkpoint for the background ping fast path. Independent of /// CKSyncEngine's change tokens and of `liveQueryCheckpoints` (which are - /// per-zone and Moves/Player oriented). Keyed by databaseScope value - /// (0 = private, 1 = shared). - var pingPushCheckpoints: [Int16: Date] = [:] + /// per-zone and Moves/Player oriented). + var pingPushCheckpoints: [DatabaseScope: Date] = [:] let pingPushCheckpointOverlap: TimeInterval = 30 /// Per-scope record-name → modificationDate of Ping records already /// surfaced by the fast path. The time-window query deliberately re-fetches @@ -197,7 +196,7 @@ actor SyncEngine { /// so without this a record — unboundedly, the newest one — would re-emit /// on every push. Pruned to the overlap window each scan, so it stays /// small. Mirrors the presentation-layer dedupe in `NotificationState`. - var seenPingRecords: [Int16: [String: Date]] = [:] + var seenPingRecords: [DatabaseScope: [String: Date]] = [:] let backgroundSessionLookback: TimeInterval = 10 * 60 func setTracer(_ t: @MainActor @Sendable @escaping (String) -> Void) { @@ -401,8 +400,8 @@ actor SyncEngine { /// explicitly fans out read cursor, name, and the initial selection /// inside a burst, so the batch is bounded by the work that produces /// it rather than by a wall-clock window. - private var playerSendBurstDepth: [Int16: Int] = [:] - private var playerSendBurstPending: Set<Int16> = [] + private var playerSendBurstDepth: [DatabaseScope: Int] = [:] + private var playerSendBurstPending: Set<DatabaseScope> = [] /// Opens a Player-record send burst for `gameID`'s scope. Subsequent /// `enqueuePlayer` calls on that scope skip their immediate @@ -410,7 +409,7 @@ actor SyncEngine { /// which fires one `sendChanges` if any enqueue landed in between. /// Returns the scope so the caller can close the matching burst even /// if the game's zone routing changes after the call. - func beginPlayerSendBurst(gameID: UUID) -> Int16? { + func beginPlayerSendBurst(gameID: UUID) -> DatabaseScope? { let ctx = persistence.container.newBackgroundContext() guard let info = zoneInfo(forGameID: gameID, in: ctx) else { return nil } playerSendBurstDepth[info.scope, default: 0] += 1 @@ -420,7 +419,7 @@ actor SyncEngine { /// Closes a burst opened by `beginPlayerSendBurst`. The outermost /// frame drains the affected engine if any enqueue landed during the /// burst; nested frames just decrement the counter. - func endPlayerSendBurst(scope: Int16) { + func endPlayerSendBurst(scope: DatabaseScope) { guard let depth = playerSendBurstDepth[scope], depth > 0 else { return } if depth > 1 { playerSendBurstDepth[scope] = depth - 1 @@ -428,7 +427,7 @@ actor SyncEngine { } playerSendBurstDepth.removeValue(forKey: scope) guard playerSendBurstPending.remove(scope) != nil else { return } - let engine = scope == 1 ? sharedEngine : privateEngine + let engine = scope == .shared ? sharedEngine : privateEngine guard let engine else { return } sendChangesDetached(on: engine) } @@ -469,7 +468,7 @@ actor SyncEngine { var sharedIDs: [CKRecord.ID] = [] for gameID in gameIDs { guard let info = zoneInfo(forGameID: gameID, in: ctx) else { continue } - let isShared = info.scope == 1 + let isShared = info.scope == .shared let req = NSFetchRequest<MovesEntity>(entityName: "MovesEntity") if let localAuthorID, !localAuthorID.isEmpty { req.predicate = NSPredicate( @@ -553,7 +552,7 @@ actor SyncEngine { zoneName: deletion.ckZoneName, ownerName: deletion.ckZoneOwnerName ) - let engine = deletion.databaseScope == 1 ? sharedEngine : privateEngine + let engine = deletion.databaseScope == .shared ? sharedEngine : privateEngine guard let engine else { return } engine.state.add(pendingDatabaseChanges: [.deleteZone(zoneID)]) sendChangesDetached(on: engine) @@ -607,14 +606,14 @@ actor SyncEngine { } return } - let engine = zoneAndTitle.info.scope == 1 ? sharedEngine : privateEngine + let engine = zoneAndTitle.info.scope == .shared ? sharedEngine : privateEngine guard let engine else { Task { await trace( "ping send: SKIPPED kind=\(kind.rawValue) " + "game=\(gameID.uuidString) " + "— no CKSyncEngine for " + - "\(zoneAndTitle.info.scope == 1 ? "shared" : "private") scope" + "\(zoneAndTitle.info.scope == .shared ? "shared" : "private") scope" ) } return @@ -644,7 +643,7 @@ actor SyncEngine { await trace( "ping send: enqueued kind=\(kind.rawValue) " + "game=\(gameID.uuidString) " + - "target=\(zoneAndTitle.info.scope == 1 ? "shared" : "private") " + + "target=\(zoneAndTitle.info.scope == .shared ? "shared" : "private") " + "zone=\(zoneAndTitle.info.zoneID.zoneName) record=\(recordName)" ) } @@ -696,8 +695,8 @@ actor SyncEngine { /// Game List rows after a device restores or replays zone history. func purgeLegacyInvitePings_v1() async { guard NotificationState.legacyInvitePurgeNeeded() else { return } - let privateZones = friendZoneIDs(forScope: 0) - let sharedZones = friendZoneIDs(forScope: 1) + let privateZones = friendZoneIDs(forScope: .private) + let sharedZones = friendZoneIDs(forScope: .shared) do { let privateDeleted = try await purgeLegacyInvitePings( in: privateZones, @@ -748,11 +747,11 @@ actor SyncEngine { guard NotificationState.staleHailPurgeNeeded() else { return } do { let privateDeleted = try await purgeStaleHailPings( - in: gameZoneIDs(forScope: 0), + in: gameZoneIDs(forScope: .private), database: container.privateCloudDatabase ) let sharedDeleted = try await purgeStaleHailPings( - in: gameZoneIDs(forScope: 1), + in: gameZoneIDs(forScope: .shared), database: container.sharedCloudDatabase ) NotificationState.markStaleHailPurged() @@ -794,11 +793,11 @@ actor SyncEngine { guard NotificationState.legacyPlayPingPurgeNeeded() else { return } do { let privateDeleted = try await purgeLegacyPlayPings( - in: gameZoneIDs(forScope: 0), + in: gameZoneIDs(forScope: .private), database: container.privateCloudDatabase ) let sharedDeleted = try await purgeLegacyPlayPings( - in: gameZoneIDs(forScope: 1), + in: gameZoneIDs(forScope: .shared), database: container.sharedCloudDatabase ) NotificationState.markLegacyPlayPingPurged() @@ -834,13 +833,13 @@ actor SyncEngine { return deleted } - private func gameZoneIDs(forScope scope: Int16) -> [CKRecordZone.ID] { + private func gameZoneIDs(forScope scope: DatabaseScope) -> [CKRecordZone.ID] { let ctx = persistence.container.newBackgroundContext() return ctx.performAndWait { let req = NSFetchRequest<GameEntity>(entityName: "GameEntity") req.predicate = NSPredicate( format: "databaseScope == %d AND isAccessRevoked == NO", - scope + scope.rawValue ) var seen = Set<String>() var result: [CKRecordZone.ID] = [] @@ -859,8 +858,8 @@ actor SyncEngine { /// Registers a directed Ping (`.invite` or `.decline`) into an existing /// *friend* zone. Unlike `enqueuePing`, the target zone is the friend zone /// (not the game zone), so the zone and engine are passed in explicitly: - /// `scope == 1` means we joined the friend zone (it lives in our shared DB - /// → shared engine); `scope == 0` means we own it (private DB → private + /// `.shared` means we joined the friend zone (it lives in our shared DB + /// → shared engine); `.private` means we own it (private DB → private /// engine). The zone already exists by the time an invite or decline is /// possible, so no `saveZone`. `gameID` is the game in question; it rides /// the record name so the recipient resolves it without reading the game @@ -874,10 +873,10 @@ actor SyncEngine { playerName: String, addressee: String, friendZoneID: CKRecordZone.ID, - friendZoneScope: Int16, + friendZoneScope: DatabaseScope, payload: String? = nil ) { - let engine = friendZoneScope == 1 ? sharedEngine : privateEngine + let engine = friendZoneScope == .shared ? sharedEngine : privateEngine guard let engine else { return } let deviceID = RecordSerializer.localDeviceID let eventTimestampMs = Int64(Date().timeIntervalSince1970 * 1000) @@ -928,8 +927,8 @@ actor SyncEngine { /// Registers a `Decision` into an existing *friend* zone — the channel a /// display name rides to reach the other participant. Engine selection - /// mirrors `enqueueFriendInvitePing`: `scope == 1` means we joined the - /// zone (shared engine), `scope == 0` means we own it (private engine). + /// mirrors `enqueueFriendInvitePing`: `.shared` means we joined the + /// zone (shared engine), `.private` means we own it (private engine). /// The zone already exists by the time a friendship is recorded, so no /// `saveZone`. /// Returns whether the decision was actually enqueued. It is dropped when @@ -942,9 +941,9 @@ actor SyncEngine { payload: String? = nil, version: Int64? = nil, friendZoneID: CKRecordZone.ID, - friendZoneScope: Int16 + friendZoneScope: DatabaseScope ) -> Bool { - guard let engine = friendZoneScope == 1 ? sharedEngine : privateEngine else { return false } + guard let engine = friendZoneScope == .shared ? sharedEngine : privateEngine else { return false } registerDecisionSave( kind: kind, key: key, payload: payload, version: version, zoneID: friendZoneID, engine: engine @@ -960,7 +959,7 @@ actor SyncEngine { name: String, version: Int64, zoneID: CKRecordZone.ID, - scope: Int16 + scope: DatabaseScope ) { if zoneID == RecordSerializer.accountZoneID { enqueueDecision( @@ -1075,7 +1074,7 @@ actor SyncEngine { func deletePing(recordName: String, gameID: UUID) { let ctx = persistence.container.newBackgroundContext() guard let info = zoneInfo(forGameID: gameID, in: ctx) else { return } - let engine = info.scope == 1 ? sharedEngine : privateEngine + let engine = info.scope == .shared ? sharedEngine : privateEngine guard let engine else { return } pendingPings.removeValue(forKey: recordName) let recordID = CKRecord.ID(recordName: recordName, zoneID: info.zoneID) @@ -1087,8 +1086,8 @@ actor SyncEngine { /// friend invites. Unlike `deletePing(recordName:gameID:)`, the GameEntity /// may not exist before acceptance, so the caller supplies the friend-zone /// route directly. - func deletePing(recordName: String, zoneID: CKRecordZone.ID, databaseScope: Int16) { - let engine = databaseScope == 1 ? sharedEngine : privateEngine + func deletePing(recordName: String, zoneID: CKRecordZone.ID, databaseScope: DatabaseScope) { + let engine = databaseScope == .shared ? sharedEngine : privateEngine guard let engine else { return } pendingPings.removeValue(forKey: recordName) let recordID = CKRecord.ID(recordName: recordName, zoneID: zoneID) @@ -1127,7 +1126,7 @@ actor SyncEngine { ) return } - let engine = info.scope == 1 ? sharedEngine : privateEngine + let engine = info.scope == .shared ? sharedEngine : privateEngine guard let engine else { return } let recordName = RecordSerializer.recordName(forPlayerInGame: gameID, authorID: authorID) let recordID = CKRecord.ID(recordName: recordName, zoneID: info.zoneID) @@ -1150,7 +1149,7 @@ actor SyncEngine { guard let gameID = gameID(fromRecordName: ckRecordName) else { return } let ctx = persistence.container.newBackgroundContext() guard let info = zoneInfo(forGameID: gameID, in: ctx) else { return } - let engine = info.scope == 1 ? sharedEngine : privateEngine + let engine = info.scope == .shared ? sharedEngine : privateEngine guard let engine else { return } // Save the zone before the game record so it exists when records arrive. engine.state.add(pendingDatabaseChanges: [.saveZone(CKRecordZone(zoneID: info.zoneID))]) @@ -1174,7 +1173,7 @@ actor SyncEngine { // build-time reap drops the save before it reaches CloudKit — an empty // journal never uploads, so it can't add a phantom device key to // replay's present set. No need to guard the enqueue itself. - let engine = info.scope == 1 ? sharedEngine : privateEngine + let engine = info.scope == .shared ? sharedEngine : privateEngine guard let engine else { return } let recordName = RecordSerializer.recordName( forJournalInGame: gameID, @@ -1207,14 +1206,14 @@ actor SyncEngine { source: String = "manual" ) async throws -> Bool { let engine: CKSyncEngine? - let scopeValue: Int16 + let scopeValue: DatabaseScope switch scope { case .private: engine = privateEngine - scopeValue = 0 + scopeValue = .private case .shared: engine = sharedEngine - scopeValue = 1 + scopeValue = .shared case .public: return false @unknown default: @@ -1429,7 +1428,7 @@ actor SyncEngine { _ event: CKSyncEngine.Event.FetchedRecordZoneChanges, isPrivate: Bool ) async { - let scope: Int16 = isPrivate ? 0 : 1 + let scope = DatabaseScope(isPrivate: isPrivate) let src = currentFetchSource ?? "framework" await trace( "\(isPrivate ? "private" : "shared") fetch [src=\(src)]: " + @@ -1868,7 +1867,7 @@ actor SyncEngine { // device would keep deriving divergent per-game addresses. settledDecisions.insert(failure.record.recordID) if let serverRecord { - let scope: Int16 = isPrivate ? 0 : 1 + let scope = DatabaseScope(isPrivate: isPrivate) if let address = RecordSerializer.parseAccountPushAddressDecision(serverRecord, databaseScope: scope) { accountAddresses.append(address) } diff --git a/Tests/Unit/AccountPushCoordinatorTests.swift b/Tests/Unit/AccountPushCoordinatorTests.swift @@ -65,7 +65,7 @@ struct AccountPushCoordinatorTests { await coordinator.ensureFriendInvitationKeyPublished( pairKey: pairKey, friendZoneID: CKRecordZone.ID(zoneName: "friend-\(pairKey)", ownerName: "owner"), - friendZoneScope: 0 + friendZoneScope: .private ) // The key is minted locally so this device can listen on its address... diff --git a/Tests/Unit/PlayerNamePublisherTests.swift b/Tests/Unit/PlayerNamePublisherTests.swift @@ -17,7 +17,7 @@ struct PlayerNamePublisherTests { let name: String let version: Int64 let zoneID: CKRecordZone.ID - let scope: Int16 + let scope: DatabaseScope } @MainActor @@ -119,7 +119,7 @@ struct PlayerNamePublisherTests { RecordSerializer.accountZoneID.zoneName, bobZone.zoneName, carolZone.zoneName ]) let carol = spy.decisions.first { $0.zoneID.zoneName == carolZone.zoneName } - #expect(carol?.scope == 1) + #expect(carol?.scope == .shared) #expect(carol?.zoneID.ownerName == "_carol") // Fan-out no longer touches per-game Player rows. #expect(fetchPlayerNames(authorID: authorID, in: persistence).isEmpty) diff --git a/Tests/Unit/RecordSerializerTests.swift b/Tests/Unit/RecordSerializerTests.swift @@ -810,7 +810,7 @@ struct RecordSerializerTests { zone: RecordSerializer.accountZoneID, version: 5 ) - let parsed = RecordSerializer.parseAccountPushSecretDecision(record, databaseScope: 0) + let parsed = RecordSerializer.parseAccountPushSecretDecision(record, databaseScope: .private) #expect(parsed?.secret == "the-secret") #expect(parsed?.version == 5) } @@ -831,7 +831,7 @@ struct RecordSerializerTests { zone: friendZone, version: 99 ) - #expect(RecordSerializer.parseAccountPushSecretDecision(record, databaseScope: 0) == nil) + #expect(RecordSerializer.parseAccountPushSecretDecision(record, databaseScope: .private) == nil) } @Test("parseAccountPushSecretDecision rejects an account-named zone in the shared scope") @@ -846,7 +846,7 @@ struct RecordSerializerTests { zone: RecordSerializer.accountZoneID, version: 99 ) - #expect(RecordSerializer.parseAccountPushSecretDecision(record, databaseScope: 1) == nil) + #expect(RecordSerializer.parseAccountPushSecretDecision(record, databaseScope: .shared) == nil) } @Test("parseAccountPushSecretDecision treats a missing version as the base generation") @@ -857,7 +857,7 @@ struct RecordSerializerTests { payload: "legacy-secret", zone: RecordSerializer.accountZoneID ) - let parsed = RecordSerializer.parseAccountPushSecretDecision(record, databaseScope: 0) + let parsed = RecordSerializer.parseAccountPushSecretDecision(record, databaseScope: .private) #expect(parsed?.version == RecordSerializer.decisionBaseVersion) } @@ -973,7 +973,7 @@ struct RecordSerializerTests { zone: friendZoneID(local: "_alice", remote: "_mallory") ) let wrote = RecordSerializer.applyDecisionRecord( - record, to: ctx, localAuthorID: "_alice", databaseScope: 0 + record, to: ctx, localAuthorID: "_alice", databaseScope: .private ) #expect(!wrote) let req = NSFetchRequest<FriendEntity>(entityName: "FriendEntity") @@ -1005,7 +1005,7 @@ struct RecordSerializerTests { zone: friendZoneID(local: "_alice", remote: "_mallory") ) let wrote = RecordSerializer.applyDecisionRecord( - record, to: ctx, localAuthorID: "_alice", databaseScope: 0 + record, to: ctx, localAuthorID: "_alice", databaseScope: .private ) #expect(!wrote) let req = NSFetchRequest<GameEntity>(entityName: "GameEntity") @@ -1026,7 +1026,7 @@ struct RecordSerializerTests { zone: friendZoneID(local: "_alice", remote: "_bob") ) let wrote = RecordSerializer.applyDecisionRecord( - record, to: ctx, localAuthorID: "_alice", databaseScope: 1 + record, to: ctx, localAuthorID: "_alice", databaseScope: .shared ) #expect(!wrote) } @@ -1104,7 +1104,7 @@ struct RecordSerializerTests { record, to: ctx, localAuthorID: "_alice", - databaseScope: 1 + databaseScope: .shared ) #expect(!wrote) #expect(FriendEncryptionKeyDirectory.payload(for: "_bob") == nil) @@ -1200,7 +1200,7 @@ struct RecordSerializerTests { // scope 0). let record = nameDecisionRecord(subject: "_bob", name: "Brandon", version: 2, zone: zone) let wrote = RecordSerializer.applyDecisionRecord( - record, to: ctx, localAuthorID: "_alice", databaseScope: 0 + record, to: ctx, localAuthorID: "_alice", databaseScope: .private ) #expect(wrote) @@ -1411,7 +1411,7 @@ struct RecordSerializerTests { subject: "_bob", nickname: "Bobby", version: 1, zone: fetchedZone ) let wrote = RecordSerializer.applyDecisionRecord( - record, to: ctx, localAuthorID: "_alice", databaseScope: 0 + record, to: ctx, localAuthorID: "_alice", databaseScope: .private ) #expect(wrote) #expect(friend.nickname == "Bobby") @@ -1435,7 +1435,7 @@ struct RecordSerializerTests { subject: "_bob", nickname: "Gotcha", version: 9, zone: spoofZone ) let wrote = RecordSerializer.applyDecisionRecord( - record, to: ctx, localAuthorID: "_alice", databaseScope: 1 + record, to: ctx, localAuthorID: "_alice", databaseScope: .shared ) #expect(!wrote) #expect(friend.nickname?.isEmpty != false)