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:
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)