commit d427dee4a032f9341164a5fa714086dda1a4d12b
parent 22d706ba645f55fe74cea0c9ed86863e48bd6267
Author: Michael Camilleri <[email protected]>
Date: Tue, 21 Jul 2026 10:00:46 +0900
Make invite Ping delivery durable
Inviting a friend could show a checkmark after the share update even
though the Ping was only queued. An atomic Decision conflict could
reject the sibling Ping, and its memory-only payload could not be
reconstructed after relaunch, leaving the invitation stranded without
another user action.
This commit stores pending Ping payloads in a durable Core Data outbox
and reconciles them with CKSyncEngine at startup. Confirmed saves and
already-present records retire the outbox row, while explicit
cancellation or zone orphaning discards it. After a substantive
atomic-batch failure is resolved, only collateral .batchRequestFailed
records that remain reconstructable are re-enqueued.
The invite UI now progresses through Sending, Queued and Sent. A pulsing
clock identifies durable work waiting for CloudKit, and the checkmark is
reserved for CloudKit's acknowledgement that the Ping exists — it does
not claim the recipient has fetched it or received a push. Per-friend
delivery state also prevents late queued or failed updates from
regressing a confirmed send.
Co-Authored-By: Codex GPT 5.6 Sol <[email protected]>
Diffstat:
13 files changed, 666 insertions(+), 63 deletions(-)
diff --git a/Crossmate.xcodeproj/project.pbxproj b/Crossmate.xcodeproj/project.pbxproj
@@ -246,6 +246,7 @@
F8DDA34AC1A6B6499C5D222E /* PlayerPreferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46148CF0F4D719692F81A6EC /* PlayerPreferences.swift */; };
FC480FE2930EAE406F5BBBDA /* GameRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBD2570A5A3573D66B3C4A52 /* GameRowView.swift */; };
FC4853B4261B06945D0D1470 /* PushPayloadCipher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33DE10D2A5AFBAC73469BD33 /* PushPayloadCipher.swift */; };
+ FE447FF4339EA0BF874B1114 /* InviteDeliveryStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28AB8C4D560CA5D4E4C61750 /* InviteDeliveryStore.swift */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@@ -316,6 +317,7 @@
275DB12E9D81DD48B9025718 /* GameSyncVersion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameSyncVersion.swift; sourceTree = "<group>"; };
27ECEA51DE42D07495744EF8 /* JournalReplay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JournalReplay.swift; sourceTree = "<group>"; };
283C5C97180C805B6C5BF622 /* PerGameZoneTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PerGameZoneTests.swift; sourceTree = "<group>"; };
+ 28AB8C4D560CA5D4E4C61750 /* InviteDeliveryStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InviteDeliveryStore.swift; sourceTree = "<group>"; };
29686FFBC5BC8BCCF8CCC49A /* DriveMonitorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DriveMonitorTests.swift; sourceTree = "<group>"; };
298A9C54A1CC753E860E174E /* FriendsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FriendsView.swift; sourceTree = "<group>"; };
2A832061C19BA0F073617CA2 /* ImportedBrowseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImportedBrowseView.swift; sourceTree = "<group>"; };
@@ -901,6 +903,7 @@
462CE0FD356F6137C9BFD30F /* ImportService.swift */,
6BDD06460A76D4AF31077732 /* InputMonitor.swift */,
10064D171DB7C48D3DE1E769 /* InviteCoordinator.swift */,
+ 28AB8C4D560CA5D4E4C61750 /* InviteDeliveryStore.swift */,
33878A29B09A6154C7A63C82 /* KeychainHelper.swift */,
A253416F4FEA271A80B22A73 /* NYTAuthService.swift */,
B0938B0ACB40772EE522D77C /* NYTPuzzleFetcher.swift */,
@@ -1216,6 +1219,7 @@
F8D37DBE75D7B3F039A8FAC8 /* ImportedBrowseView.swift in Sources */,
1A19D13D9B820E276C60819E /* InputMonitor.swift in Sources */,
59230713D85AE6895852B06A /* InviteCoordinator.swift in Sources */,
+ FE447FF4339EA0BF874B1114 /* InviteDeliveryStore.swift in Sources */,
7D9337A19747C79070AB3D59 /* InviteEntity+DisplayName.swift in Sources */,
13C0F34520828020AD825D07 /* JoiningPuzzleView.swift in Sources */,
9502840161DB88BB6BB409D5 /* Journal.swift in Sources */,
diff --git a/Crossmate/Models/CrossmateModel.xcdatamodeld/CrossmateModel.xcdatamodel/contents b/Crossmate/Models/CrossmateModel.xcdatamodeld/CrossmateModel.xcdatamodel/contents
@@ -106,6 +106,16 @@
<attribute name="id" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="lastSyncedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
</entity>
+ <entity name="PendingPingEntity" representedClassName="PendingPingEntity" syncable="YES" codeGenerationType="class">
+ <attribute name="databaseScope" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/>
+ <attribute name="payloadData" attributeType="Binary"/>
+ <attribute name="recordName" attributeType="String"/>
+ <attribute name="zoneName" attributeType="String"/>
+ <attribute name="zoneOwnerName" attributeType="String"/>
+ <fetchIndex name="byRecordName">
+ <fetchIndexElement property="recordName" type="Binary" order="ascending"/>
+ </fetchIndex>
+ </entity>
<entity name="FriendEntity" representedClassName="FriendEntity" syncable="YES" codeGenerationType="class">
<attribute name="authorID" attributeType="String"/>
<attribute name="createdAt" attributeType="Date" usesScalarValueType="NO"/>
diff --git a/Crossmate/Services/AppActions.swift b/Crossmate/Services/AppActions.swift
@@ -28,6 +28,21 @@ final class AppActions {
try await services.invites.inviteFriend(gameID: gameID, friendAuthorID: friendAuthorID)
}
+ func inviteDelivery(gameID: UUID, friendAuthorID: String) -> InviteDelivery {
+ services.inviteDeliveries.delivery(
+ gameID: gameID,
+ friendAuthorID: friendAuthorID
+ )
+ }
+
+ func markInviteFailed(gameID: UUID, friendAuthorID: String) {
+ services.inviteDeliveries.markFailed(
+ recordName: nil,
+ gameID: gameID,
+ friendAuthorID: friendAuthorID
+ )
+ }
+
func acceptInvite(shareURL: String, pingRecordName: String) async throws {
_ = try await services.invites.acceptInvite(
shareURL: shareURL,
diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift
@@ -325,6 +325,7 @@ final class AppServices {
let importService: ImportService
let engagementHost: EngagementHost
let engagementStatus = EngagementStatus()
+ let inviteDeliveries = InviteDeliveryStore()
private(set) lazy var appActions = AppActions(services: self)
/// Live-channel lifecycle (room reconcile/mint, teardown/reconnect/
/// lease-expiry timers, inbound channel events); see `EngagementLifecycle`.
@@ -1117,6 +1118,30 @@ final class AppServices {
await self.invites.presentPings(pings)
}
+ await syncEngine.setOnPingDeliveryUpdate { [weak self] update in
+ guard let self else { return }
+ switch update.state {
+ case .queued:
+ self.inviteDeliveries.markQueued(
+ recordName: update.recordName,
+ gameID: update.gameID,
+ friendAuthorID: update.addressee
+ )
+ case .sent:
+ self.inviteDeliveries.markSent(
+ recordName: update.recordName,
+ gameID: update.gameID,
+ friendAuthorID: update.addressee
+ )
+ case .failed:
+ self.inviteDeliveries.markFailed(
+ recordName: update.recordName,
+ gameID: update.gameID,
+ friendAuthorID: update.addressee
+ )
+ }
+ }
+
await syncEngine.setOnAccountChange { [weak self] in
guard let self else { return }
let previousID = self.identity.currentID
diff --git a/Crossmate/Services/InviteDeliveryStore.swift b/Crossmate/Services/InviteDeliveryStore.swift
@@ -0,0 +1,74 @@
+import Foundation
+import Observation
+
+enum InviteDeliveryPhase: Equatable {
+ case idle
+ case queued
+ case sent
+ case failed
+}
+
+/// Per-recipient delivery state observed by the invite UI. Each row gets a
+/// stable, narrowly-scoped observable object, so one friend's acknowledgement
+/// does not invalidate every other friend row.
+@MainActor
+@Observable
+final class InviteDelivery {
+ let gameID: UUID
+ let friendAuthorID: String
+ private(set) var phase: InviteDeliveryPhase = .idle
+
+ init(gameID: UUID, friendAuthorID: String) {
+ self.gameID = gameID
+ self.friendAuthorID = friendAuthorID
+ }
+
+ fileprivate func setPhase(_ phase: InviteDeliveryPhase) {
+ self.phase = phase
+ }
+}
+
+/// Bridges SyncEngine's record-level acknowledgements to stable invite-row
+/// models. The record-name index closes the race where CloudKit confirms a
+/// fast send before the inviting task itself has returned to the view.
+@MainActor
+final class InviteDeliveryStore {
+ private struct Key: Hashable {
+ let gameID: UUID
+ let friendAuthorID: String
+ }
+
+ private var deliveries: [Key: InviteDelivery] = [:]
+ private var keyByRecordName: [String: Key] = [:]
+
+ func delivery(gameID: UUID, friendAuthorID: String) -> InviteDelivery {
+ let key = Key(gameID: gameID, friendAuthorID: friendAuthorID)
+ if let existing = deliveries[key] { return existing }
+ let delivery = InviteDelivery(gameID: gameID, friendAuthorID: friendAuthorID)
+ deliveries[key] = delivery
+ return delivery
+ }
+
+ func markQueued(recordName: String, gameID: UUID, friendAuthorID: String) {
+ let key = Key(gameID: gameID, friendAuthorID: friendAuthorID)
+ keyByRecordName[recordName] = key
+ let delivery = delivery(gameID: gameID, friendAuthorID: friendAuthorID)
+ guard delivery.phase != .sent else { return }
+ delivery.setPhase(.queued)
+ }
+
+ func markSent(recordName: String, gameID: UUID, friendAuthorID: String) {
+ let key = keyByRecordName[recordName]
+ ?? Key(gameID: gameID, friendAuthorID: friendAuthorID)
+ keyByRecordName.removeValue(forKey: recordName)
+ delivery(gameID: key.gameID, friendAuthorID: key.friendAuthorID)
+ .setPhase(.sent)
+ }
+
+ func markFailed(recordName: String?, gameID: UUID, friendAuthorID: String) {
+ if let recordName { keyByRecordName.removeValue(forKey: recordName) }
+ let delivery = delivery(gameID: gameID, friendAuthorID: friendAuthorID)
+ guard delivery.phase != .sent else { return }
+ delivery.setPhase(.failed)
+ }
+}
diff --git a/Crossmate/Sync/CloudQuery.swift b/Crossmate/Sync/CloudQuery.swift
@@ -358,7 +358,7 @@ extension SyncEngine {
guard RecordSerializer.isTrustedGameScopedRecord(record),
let ping = Ping.parseRecord(record, fetchedFrom: scopeValue),
ping.gameID == gameID else { continue }
- deletePing(recordName: ping.recordName, zoneID: zoneID, databaseScope: scopeValue)
+ await deletePing(recordName: ping.recordName, zoneID: zoneID, databaseScope: scopeValue)
await trace(
"leave invite cleanup: deleting invite ping \(ping.recordName) " +
"for \(gameID.uuidString)"
diff --git a/Crossmate/Sync/FriendController.swift b/Crossmate/Sync/FriendController.swift
@@ -466,7 +466,7 @@ final class FriendController {
}
FriendZone.markInboxEstablished(pairKey: pairKey)
FriendZone.markOutboxAccepted(pairKey: pairKey)
- await syncEngine.enqueueFriendZonePing(
+ try await syncEngine.enqueueFriendZonePing(
kind: .friend,
gameID: Self.migrationSentinelGameID(pairKey: pairKey),
gameTitle: "",
@@ -543,7 +543,7 @@ final class FriendController {
throw FriendError.payloadEncodingFailed
}
- await syncEngine.enqueueFriendZonePing(
+ try await syncEngine.enqueueFriendZonePing(
kind: .invite,
gameID: gameID,
gameTitle: gameTitle,
@@ -580,7 +580,7 @@ final class FriendController {
guard let pairKey = friend.pairKey else { throw FriendError.friendNotFound }
try await ensureOutboxAccepted(pairKey: pairKey, friendAuthorID: inviterAuthorID)
- await syncEngine.enqueueFriendZonePing(
+ try await syncEngine.enqueueFriendZonePing(
kind: .decline,
gameID: gameID,
gameTitle: gameTitle,
diff --git a/Crossmate/Sync/Presence.swift b/Crossmate/Sync/Presence.swift
@@ -40,7 +40,7 @@ enum PeerPresence {
/// `kind` field. Pings now cover durable bootstrap/side-channel events that
/// do not need live APN timing. User-facing play events ride on the push
/// worker, and simultaneous co-solving rides on engagement state.
-enum PingKind: String, Sendable {
+enum PingKind: String, Codable, Sendable {
/// Legacy collaborator-joined notification. New clients no longer write
/// or alert on this kind; it remains parseable for old records.
case join
@@ -66,7 +66,7 @@ enum PingKind: String, Sendable {
/// 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 {
+enum DatabaseScope: Int16, Codable, Sendable {
case `private` = 0
case shared = 1
diff --git a/Crossmate/Sync/SyncEngine.swift b/Crossmate/Sync/SyncEngine.swift
@@ -9,6 +9,19 @@ enum CompletionDurableRecordKind: String, Codable, Hashable, Sendable {
case journal
}
+enum PingDeliveryState: Sendable {
+ case queued
+ case sent
+ case failed
+}
+
+struct PingDeliveryUpdate: Sendable {
+ let recordName: String
+ let gameID: UUID
+ let addressee: String
+ let state: PingDeliveryState
+}
+
extension EnvironmentValues {
@Entry var syncEngine: SyncEngine? = nil
}
@@ -44,6 +57,10 @@ extension Notification.Name {
/// replay them onto the `CellEntity` cache.
/// - Notify the main actor so the in-memory `Game` stays current.
actor SyncEngine {
+ enum PingOutboxError: Error {
+ case syncEngineUnavailable
+ }
+
let container: CKContainer
let persistence: PersistenceController
@@ -71,10 +88,18 @@ actor SyncEngine {
/// second call a no-op.
private var isStarting = false
- /// In-memory map for Ping records pending send. Pings have no Core Data
- /// backing — they're write-once-and-forget — so we stash the minimal data
- /// here keyed by record name and look it up in `buildRecord`.
+ /// In-memory mirror of the durable PendingPingEntity outbox, keyed by
+ /// record name. CKSyncEngine persists the pending record ID but not the
+ /// body supplied by its record provider, so the outbox is what makes a
+ /// queued invite reconstructable after process termination.
private var pendingPings: [String: PingPayload] = [:]
+
+ private lazy var pendingPingContext: NSManagedObjectContext = {
+ let ctx = persistence.container.newBackgroundContext()
+ ctx.automaticallyMergesChangesFromParent = true
+ ctx.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump
+ return ctx
+ }()
/// Payloads for `Decision` records pending send, keyed by
/// `decisionStateKey` (zone + record name — the same decision record can
/// be pending in several zones at once, e.g. a name Decision fanned out
@@ -119,7 +144,7 @@ actor SyncEngine {
"\(recordID.zoneID.zoneName)/\(recordID.recordName)"
}
- struct PingPayload {
+ struct PingPayload: Codable {
let gameID: UUID
let authorID: String
let deviceID: String
@@ -129,6 +154,13 @@ actor SyncEngine {
let kind: PingKind
let payload: String?
let addressee: String?
+ let zoneName: String
+ let zoneOwnerName: String
+ let databaseScope: DatabaseScope
+
+ var recordZoneID: CKRecordZone.ID {
+ CKRecordZone.ID(zoneName: zoneName, ownerName: zoneOwnerName)
+ }
}
/// Label for the in-flight fetch — surfaced in traces so the diagnostics
@@ -167,6 +199,7 @@ actor SyncEngine {
/// token refresh.
var onRemoteCredentialsChanged: (@MainActor @Sendable (Set<UUID>) async -> Void)?
var onPings: (@MainActor @Sendable ([Ping]) async -> Void)?
+ private var onPingDeliveryUpdate: (@MainActor @Sendable (PingDeliveryUpdate) -> Void)?
private var onAccountChange: (@MainActor @Sendable () async -> Void)?
private var onGameAccessRevoked: (@MainActor @Sendable (UUID) async -> Void)?
private var onGameRemoved: (@MainActor @Sendable (UUID) async -> Void)?
@@ -258,6 +291,12 @@ actor SyncEngine {
onPings = cb
}
+ func setOnPingDeliveryUpdate(
+ _ cb: @MainActor @Sendable @escaping (PingDeliveryUpdate) -> Void
+ ) {
+ onPingDeliveryUpdate = cb
+ }
+
func setOnAccountChange(_ cb: @MainActor @Sendable @escaping () async -> Void) {
onAccountChange = cb
}
@@ -337,8 +376,9 @@ actor SyncEngine {
defer { isStarting = false }
// CKSyncEngine restores its pending `.saveRecord` changes from the
- // serialized state below; restore the matching Decision payloads so a
- // pending decision rebuilds with its body instead of as a poison record.
+ // serialized state below; restore the matching Ping/Decision payloads
+ // so pending records rebuild with their bodies.
+ await restorePendingPings()
restorePendingDecisionPayloads()
restorePendingDecisionVersions()
@@ -362,6 +402,12 @@ actor SyncEngine {
delegate: self
))
+ // The outbox is authoritative. A process can terminate after its row
+ // commits but before CKSyncEngine serializes the matching pending ID,
+ // so re-add every durable Ping on startup. Record IDs are stable and
+ // CKSyncEngine deduplicates repeated pending saves.
+ reconcilePendingPingOutbox()
+
// CKSyncEngine's automatic subscription creation is unreliable in
// practice — diagnostics on real devices showed both scopes with zero
// subscriptions even after a healthy initial fetch and push, which
@@ -635,11 +681,103 @@ actor SyncEngine {
sendChangesDetached(on: privateEngine)
}
+ private func storePendingPing(
+ _ ping: PingPayload,
+ recordName: String
+ ) async throws {
+ let data = try JSONEncoder().encode(ping)
+ let ctx = pendingPingContext
+ try await ctx.perform {
+ let req = NSFetchRequest<PendingPingEntity>(entityName: "PendingPingEntity")
+ req.predicate = NSPredicate(format: "recordName == %@", recordName)
+ req.fetchLimit = 1
+ let entity = try ctx.fetch(req).first ?? PendingPingEntity(context: ctx)
+ entity.recordName = recordName
+ entity.zoneName = ping.zoneName
+ entity.zoneOwnerName = ping.zoneOwnerName
+ entity.databaseScope = ping.databaseScope.rawValue
+ entity.payloadData = data
+ try ctx.save()
+ }
+ pendingPings[recordName] = ping
+ await publishPingDeliveryUpdate(.queued, recordName: recordName, ping: ping)
+ }
+
+ private func removePendingPing(recordName: String) async {
+ pendingPings.removeValue(forKey: recordName)
+ let ctx = pendingPingContext
+ await ctx.perform {
+ let req = NSFetchRequest<PendingPingEntity>(entityName: "PendingPingEntity")
+ req.predicate = NSPredicate(format: "recordName == %@", recordName)
+ for entity in (try? ctx.fetch(req)) ?? [] {
+ ctx.delete(entity)
+ }
+ if ctx.hasChanges { try? ctx.save() }
+ }
+ }
+
+ private func confirmPendingPing(recordName: String) async {
+ if let ping = pendingPings[recordName] {
+ await publishPingDeliveryUpdate(.sent, recordName: recordName, ping: ping)
+ }
+ await removePendingPing(recordName: recordName)
+ }
+
+ private func restorePendingPings() async {
+ let ctx = pendingPingContext
+ pendingPings = await ctx.perform {
+ let req = NSFetchRequest<PendingPingEntity>(entityName: "PendingPingEntity")
+ let rows = (try? ctx.fetch(req)) ?? []
+ var restored: [String: PingPayload] = [:]
+ for row in rows {
+ guard let name = row.recordName,
+ let data = row.payloadData,
+ let ping = try? JSONDecoder().decode(PingPayload.self, from: data)
+ else {
+ ctx.delete(row)
+ continue
+ }
+ restored[name] = ping
+ }
+ if ctx.hasChanges { try? ctx.save() }
+ return restored
+ }
+ for (recordName, ping) in pendingPings {
+ await publishPingDeliveryUpdate(.queued, recordName: recordName, ping: ping)
+ }
+ }
+
+ private func publishPingDeliveryUpdate(
+ _ state: PingDeliveryState,
+ recordName: String,
+ ping: PingPayload
+ ) async {
+ guard ping.kind == .invite,
+ let addressee = ping.addressee,
+ !addressee.isEmpty,
+ let onPingDeliveryUpdate
+ else { return }
+ await onPingDeliveryUpdate(PingDeliveryUpdate(
+ recordName: recordName,
+ gameID: ping.gameID,
+ addressee: addressee,
+ state: state
+ ))
+ }
+
+ private func reconcilePendingPingOutbox() {
+ for (recordName, ping) in pendingPings {
+ let engine = ping.databaseScope == .shared ? sharedEngine : privateEngine
+ let recordID = CKRecord.ID(recordName: recordName, zoneID: ping.recordZoneID)
+ engine?.state.add(pendingRecordZoneChanges: [.saveRecord(recordID)])
+ }
+ }
+
/// Registers a Ping record as a pending send. Pings now only cover
/// bootstrap kinds (`.join` / `.friend` / `.invite` / `.hail`) — the
/// user-facing play events go through the push worker. Sender-only
- /// state: the payload is stashed in `pendingPings` and only used to
- /// build the outgoing `CKRecord`; nothing is persisted.
+ /// state: the payload is committed to a durable outbox before the
+ /// CKSyncEngine save is queued, then retained until CloudKit confirms it.
func enqueuePing(
kind: PingKind,
gameID: UUID,
@@ -684,7 +822,7 @@ actor SyncEngine {
deviceID: deviceID,
eventTimestampMs: eventTimestampMs
)
- pendingPings[recordName] = PingPayload(
+ let ping = PingPayload(
gameID: gameID,
authorID: authorID,
deviceID: deviceID,
@@ -693,8 +831,17 @@ actor SyncEngine {
eventTimestampMs: eventTimestampMs,
kind: kind,
payload: payload,
- addressee: addressee
+ addressee: addressee,
+ zoneName: zoneAndTitle.info.zoneID.zoneName,
+ zoneOwnerName: zoneAndTitle.info.zoneID.ownerName,
+ databaseScope: zoneAndTitle.info.scope
)
+ do {
+ try await storePendingPing(ping, recordName: recordName)
+ } catch {
+ await trace("ping send: failed to persist outbox record \(recordName) — \(error)")
+ return
+ }
let recordID = CKRecord.ID(recordName: recordName, zoneID: zoneAndTitle.info.zoneID)
engine.state.add(pendingRecordZoneChanges: [.saveRecord(recordID)])
await trace(
@@ -931,9 +1078,11 @@ actor SyncEngine {
friendZoneID: CKRecordZone.ID,
friendZoneScope: DatabaseScope,
payload: String? = nil
- ) {
+ ) async throws {
let engine = friendZoneScope == .shared ? sharedEngine : privateEngine
- guard let engine else { return }
+ guard let engine else {
+ throw PingOutboxError.syncEngineUnavailable
+ }
let deviceID = RecordSerializer.localDeviceID
let eventTimestampMs = Int64(Date().timeIntervalSince1970 * 1000)
let recordName = RecordSerializer.recordName(
@@ -942,7 +1091,7 @@ actor SyncEngine {
deviceID: deviceID,
eventTimestampMs: eventTimestampMs
)
- pendingPings[recordName] = PingPayload(
+ let ping = PingPayload(
gameID: gameID,
authorID: authorID,
deviceID: deviceID,
@@ -951,8 +1100,12 @@ actor SyncEngine {
eventTimestampMs: eventTimestampMs,
kind: kind,
payload: payload,
- addressee: addressee
+ addressee: addressee,
+ zoneName: friendZoneID.zoneName,
+ zoneOwnerName: friendZoneID.ownerName,
+ databaseScope: friendZoneScope
)
+ try await storePendingPing(ping, recordName: recordName)
let recordID = CKRecord.ID(recordName: recordName, zoneID: friendZoneID)
engine.state.add(pendingRecordZoneChanges: [.saveRecord(recordID)])
sendChangesDetached(on: engine)
@@ -1127,12 +1280,15 @@ actor SyncEngine {
/// CKSyncEngine before the callback unwinds (same class as the
/// friend-invite `fetchChanges` trap). Detaching keeps it off the
/// callback's actor; the drain only needs to land eventually.
- func deletePing(recordName: String, gameID: UUID) {
+ func deletePing(recordName: String, gameID: UUID) async {
let ctx = persistence.container.newBackgroundContext()
guard let info = zoneInfo(forGameID: gameID, in: ctx) else { return }
let engine = info.scope == .shared ? sharedEngine : privateEngine
guard let engine else { return }
- pendingPings.removeValue(forKey: recordName)
+ if let ping = pendingPings[recordName] {
+ await publishPingDeliveryUpdate(.failed, recordName: recordName, ping: ping)
+ }
+ await removePendingPing(recordName: recordName)
let recordID = CKRecord.ID(recordName: recordName, zoneID: info.zoneID)
engine.state.add(pendingRecordZoneChanges: [.deleteRecord(recordID)])
sendChangesDetached(on: engine)
@@ -1142,10 +1298,13 @@ 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: DatabaseScope) {
+ func deletePing(recordName: String, zoneID: CKRecordZone.ID, databaseScope: DatabaseScope) async {
let engine = databaseScope == .shared ? sharedEngine : privateEngine
guard let engine else { return }
- pendingPings.removeValue(forKey: recordName)
+ if let ping = pendingPings[recordName] {
+ await publishPingDeliveryUpdate(.failed, recordName: recordName, ping: ping)
+ }
+ await removePendingPing(recordName: recordName)
let recordID = CKRecord.ID(recordName: recordName, zoneID: zoneID)
engine.state.add(pendingRecordZoneChanges: [.deleteRecord(recordID)])
sendChangesDetached(on: engine)
@@ -1327,7 +1486,7 @@ actor SyncEngine {
stateSerialization: nil,
delegate: self
))
- pendingPings = [:]
+ reconcilePendingPingOutbox()
pendingDecisionPayloads = [:]
persistPendingDecisionPayloads()
pendingDecisionVersions = [:]
@@ -1900,7 +2059,7 @@ actor SyncEngine {
for record in event.savedRecords {
let name = record.recordID.recordName
if name.hasPrefix("ping-") {
- pendingPings.removeValue(forKey: name)
+ await confirmPendingPing(recordName: name)
} else if name.hasPrefix("decision-") {
let stateKey = Self.decisionStateKey(record.recordID)
pendingDecisionPayloads.removeValue(forKey: stateKey)
@@ -1915,15 +2074,17 @@ actor SyncEngine {
let pendingVersionsSnapshot = pendingDecisionVersions
let ctx = persistence.container.newBackgroundContext()
ctx.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump
- let (failureMessages, orphanedZones, resolvedDecisions, settledJournals,
- resolvedAccountAddresses, resolvedAccountSecrets, decisionWins, recoveredSaves):
- ([String], Set<CKRecordZone.ID>, Set<CKRecord.ID>, Set<CKRecord.ID>,
+ let (failureMessages, orphanedZones, resolvedDecisions, settledPings, settledJournals,
+ resolvedAccountAddresses, resolvedAccountSecrets, decisionWins, recoveredSaves,
+ batchFailedSaves, unresolvedFailureZones):
+ ([String], Set<CKRecordZone.ID>, Set<CKRecord.ID>, Set<CKRecord.ID>, Set<CKRecord.ID>,
[String], [(secret: String, version: Int64)],
[(recordID: CKRecord.ID, stateKey: String, systemFields: Data)],
- [CKRecord.ID]) = await ctx.perform {
+ [CKRecord.ID], [CKRecord.ID], Set<CKRecordZone.ID>) = await ctx.perform {
var messages: [String] = []
var orphaned = Set<CKRecordZone.ID>()
var settledDecisions = Set<CKRecord.ID>()
+ var settledPings = Set<CKRecord.ID>()
var settledJournals = Set<CKRecord.ID>()
var accountAddresses: [String] = []
var accountSecrets: [(secret: String, version: Int64)] = []
@@ -1940,6 +2101,13 @@ actor SyncEngine {
// metadata, completion) can strand on an idle game until its next
// change.
var recoveredSaves: [CKRecord.ID] = []
+ // Atomic operations report the causal record's substantive error
+ // and `.batchRequestFailed` for otherwise-valid siblings. The
+ // latter have already fallen out of CKSyncEngine's pending state;
+ // retain them for an explicit retry after every causal record in
+ // their zone has been settled or recovered.
+ var batchFailedSaves: [CKRecord.ID] = []
+ var unresolvedFailureZones = Set<CKRecordZone.ID>()
for record in event.savedRecords {
self.writeBackSystemFields(record: record, in: ctx)
let savedName = record.recordID.recordName
@@ -1962,11 +2130,25 @@ actor SyncEngine {
// error and is pure noise next to the "settled" message.
var settled = false
if err.domain == CKErrorDomain,
+ err.code == CKError.batchRequestFailed.rawValue {
+ batchFailedSaves.append(failure.record.recordID)
+ settled = true
+ } else if err.domain == CKErrorDomain,
err.code == CKError.zoneNotFound.rawValue {
orphaned.insert(failure.record.recordID.zoneID)
} else if !isPrivate,
self.isInvalidSharedZoneOwnerError(err) {
orphaned.insert(failure.record.recordID.zoneID)
+ } else if name.hasPrefix("ping-"),
+ err.domain == CKErrorDomain,
+ err.code == CKError.serverRecordChanged.rawValue {
+ // Pings are immutable and their retry keeps the original
+ // record ID. A conflict therefore means the outbox item is
+ // already durable (for example, the app terminated after
+ // CloudKit saved it but before processing the sent event).
+ settledPings.insert(failure.record.recordID)
+ settled = true
+ messages.append("send: ping \(name) already present — settled")
} else if name.hasPrefix("decision-"),
err.domain == CKErrorDomain,
err.code == CKError.serverRecordChanged.rawValue {
@@ -2032,11 +2214,13 @@ actor SyncEngine {
messages.append("send: journal \(name) already present — settled")
} else if self.recoverServerChangedSave(failure.error, failedRecordName: name, in: ctx) {
recoveredSaves.append(failure.record.recordID)
+ settled = true
messages.append(
"send: recovered stale system fields for \(name) from CloudKit server record"
)
}
guard !settled else { continue }
+ unresolvedFailureZones.insert(failure.record.recordID.zoneID)
let userInfo = err.userInfo
.map { "\($0.key)=\($0.value)" }
.joined(separator: " | ")
@@ -2054,8 +2238,9 @@ actor SyncEngine {
)
}
}
- return (messages, orphaned, settledDecisions, settledJournals,
- accountAddresses, accountSecrets, decisionWins, recoveredSaves)
+ return (messages, orphaned, settledDecisions, settledPings, settledJournals,
+ accountAddresses, accountSecrets, decisionWins, recoveredSaves,
+ batchFailedSaves, unresolvedFailureZones)
}
if !orphanedZones.isEmpty {
await applyZoneOrphaning(orphanedZones, isPrivate: isPrivate, source: "send")
@@ -2065,17 +2250,7 @@ actor SyncEngine {
// joined friend zone rides the shared one.
let decisionEngine = isPrivate ? privateEngine : sharedEngine
if !resolvedDecisions.isEmpty, let decisionEngine {
- decisionEngine.state.remove(
- pendingRecordZoneChanges: resolvedDecisions.map { .saveRecord($0) }
- )
- for recordID in resolvedDecisions {
- let stateKey = Self.decisionStateKey(recordID)
- pendingDecisionPayloads.removeValue(forKey: stateKey)
- pendingDecisionVersions.removeValue(forKey: stateKey)
- decisionSystemFields.removeValue(forKey: stateKey)
- }
- persistPendingDecisionPayloads()
- persistPendingDecisionVersions()
+ settleDecisionRecords(resolvedDecisions, on: decisionEngine)
}
// Adopt the server's tag for each versioned decision we're overwriting,
// re-enqueue the save (the failed change is no longer pending), then
@@ -2119,6 +2294,22 @@ actor SyncEngine {
pendingRecordZoneChanges: settledJournals.map { .saveRecord($0) }
)
}
+ if !settledPings.isEmpty {
+ let engine = isPrivate ? privateEngine : sharedEngine
+ engine?.state.remove(
+ pendingRecordZoneChanges: settledPings.map { .saveRecord($0) }
+ )
+ for recordID in settledPings {
+ await confirmPendingPing(recordName: recordID.recordName)
+ }
+ }
+ let retryableBatchSiblings = batchFailedSaves.filter {
+ !orphanedZones.contains($0.zoneID)
+ && !unresolvedFailureZones.contains($0.zoneID)
+ }
+ if !retryableBatchSiblings.isEmpty {
+ await recoverBatchFailedSaves(retryableBatchSiblings, isPrivate: isPrivate)
+ }
for message in failureMessages {
await trace(message)
}
@@ -2127,6 +2318,86 @@ actor SyncEngine {
}
}
+ private func settleDecisionRecords(
+ _ recordIDs: Set<CKRecord.ID>,
+ on engine: CKSyncEngine
+ ) {
+ engine.state.remove(
+ pendingRecordZoneChanges: recordIDs.map { .saveRecord($0) }
+ )
+ for recordID in recordIDs {
+ let stateKey = Self.decisionStateKey(recordID)
+ pendingDecisionPayloads.removeValue(forKey: stateKey)
+ pendingDecisionVersions.removeValue(forKey: stateKey)
+ decisionSystemFields.removeValue(forKey: stateKey)
+ }
+ persistPendingDecisionPayloads()
+ persistPendingDecisionVersions()
+ }
+
+ /// Re-adds only atomic-failure siblings whose records can still be built.
+ /// The caller invokes this after resolving all substantive failures in the
+ /// zone, preventing a blind retry of the original atomic batch.
+ private func recoverBatchFailedSaves(
+ _ recordIDs: [CKRecord.ID],
+ isPrivate: Bool
+ ) async {
+ guard let engine = isPrivate ? privateEngine : sharedEngine else { return }
+ let scope = DatabaseScope(isPrivate: isPrivate)
+ let pingSnapshot = pendingPings
+ let decisionSnapshot = pendingDecisionPayloads
+ let decisionVersionSnapshot = pendingDecisionVersions
+ let decisionSystemFieldsSnapshot = decisionSystemFields
+ let ctx = persistence.container.newBackgroundContext()
+ var recovered: [CKRecord.ID] = []
+ for recordID in recordIDs {
+ guard buildRecord(
+ for: recordID,
+ in: ctx,
+ databaseScope: scope,
+ pings: pingSnapshot,
+ decisions: decisionSnapshot,
+ decisionVersions: decisionVersionSnapshot,
+ decisionSystemFields: decisionSystemFieldsSnapshot
+ ) != nil else {
+ await trace(
+ "send: atomic sibling \(recordID.recordName) is no longer reconstructable — dropped"
+ )
+ continue
+ }
+ recovered.append(recordID)
+ }
+ guard !recovered.isEmpty else { return }
+ engine.state.add(
+ pendingRecordZoneChanges: recovered.map { .saveRecord($0) }
+ )
+ await trace(
+ "send: re-enqueued \(recovered.count) reconstructable atomic-failure sibling" +
+ (recovered.count == 1 ? "" : "s")
+ )
+ sendChangesDetached(on: engine)
+ }
+
+ /// Test seam for the atomic-failure recovery sequence. CKSyncEngine's sent
+ /// event has no public initializer, so tests supply the causal Decision and
+ /// collateral record IDs directly after arranging their pending payloads.
+ func recoverAtomicBatchForTesting(
+ settledDecisionID: CKRecord.ID,
+ batchFailedRecordIDs: [CKRecord.ID],
+ scope: CKDatabase.Scope
+ ) async {
+ guard let engine = scope == .shared ? sharedEngine : privateEngine else { return }
+ let allIDs = [settledDecisionID] + batchFailedRecordIDs
+ engine.state.remove(
+ pendingRecordZoneChanges: allIDs.map { .saveRecord($0) }
+ )
+ settleDecisionRecords([settledDecisionID], on: engine)
+ await recoverBatchFailedSaves(
+ batchFailedRecordIDs,
+ isPrivate: scope != .shared
+ )
+ }
+
nonisolated func isInvalidSharedZoneOwnerError(_ error: NSError) -> Bool {
let values = [error.localizedDescription] + error.userInfo.map { "\($0.value)" }
return values.contains {
@@ -2167,11 +2438,14 @@ actor SyncEngine {
}
}
- for (name, _) in pendingPings {
- guard let gameID = gameID(fromRecordName: name) else { continue }
- if zones.contains(where: { $0.zoneName == "game-\(gameID.uuidString)" }) {
- pendingPings.removeValue(forKey: name)
+ let orphanedPingNames = pendingPings.compactMap { name, ping in
+ zones.contains(ping.recordZoneID) ? name : nil
+ }
+ for name in orphanedPingNames {
+ if let ping = pendingPings[name] {
+ await publishPingDeliveryUpdate(.failed, recordName: name, ping: ping)
}
+ await removePendingPing(recordName: name)
}
let ctx = persistence.container.newBackgroundContext()
@@ -2379,10 +2653,9 @@ extension SyncEngine: CKSyncEngineDelegate {
/// Builds the next outbound batch for `engine`, reaping any pending
/// `.saveRecord` whose record can no longer be reconstructed. For a ping
- /// that means its ephemeral `pendingPings` payload was lost across a
- /// relaunch (CKSyncEngine persists pending changes; the ping body is
- /// in-memory only); for game/moves/player it means the Core Data entity
- /// was deleted. Either way the save can never succeed, so the change is
+ /// that means its durable outbox row was missing or unreadable; for
+ /// game/moves/player it means the Core Data entity was deleted. Either way
+ /// the save can never succeed, so the change is
/// dropped instead of returning a nil batch that leaves it queued forever
/// — `Pending Changes` would never drain and no error is ever surfaced.
/// Mirrors Apple's CKSyncEngine reference implementation, which reaps
@@ -2483,4 +2756,8 @@ extension SyncEngine: CKSyncEngineDelegate {
func pendingPingRecordNamesForTesting() -> [String] {
Array(pendingPings.keys).sorted()
}
+
+ func confirmPendingPingForTesting(recordName: String) async {
+ await confirmPendingPing(recordName: recordName)
+ }
}
diff --git a/Crossmate/Views/Components/FriendAvatarView.swift b/Crossmate/Views/Components/FriendAvatarView.swift
@@ -8,7 +8,11 @@ struct FriendAvatarView: View {
/// Invite is in flight: the glyph spins over to a paper plane, then
/// rings broadcast outward from the avatar.
case sending
- /// Invite delivered: the glyph spins once and becomes a checkmark.
+ /// Invite is durable locally and waiting for CloudKit acknowledgement.
+ /// The clock keeps the broadcast rings active while delivery is queued.
+ case queued
+ /// CloudKit confirmed the Ping exists: the glyph spins once and becomes
+ /// a checkmark. This does not claim the recipient has fetched it.
case sent
}
@@ -35,6 +39,8 @@ struct FriendAvatarView: View {
switch invitePhase {
case .sent:
return "checkmark"
+ case .queued:
+ return "clock.fill"
case .sending:
return "paperplane.fill"
case nil:
@@ -86,7 +92,9 @@ struct FriendAvatarView: View {
}
.frame(width: size, height: size)
.onAppear {
- if invitePhase == .sending { ringsActive = true }
+ if invitePhase == .sending || invitePhase == .queued {
+ ringsActive = true
+ }
}
.onChange(of: invitePhase) { _, phase in
ringsTask?.cancel()
@@ -100,6 +108,9 @@ struct FriendAvatarView: View {
guard !Task.isCancelled else { return }
ringsActive = true
}
+ case .queued:
+ ringsActive = true
+ withAnimation(glyphSpin) { revolutions += 1 }
case .sent:
ringsActive = false
withAnimation(glyphSpin) { revolutions += 1 }
diff --git a/Crossmate/Views/Friends/FriendPickerView.swift b/Crossmate/Views/Friends/FriendPickerView.swift
@@ -97,10 +97,21 @@ struct FriendPickerView: View {
.disabled(authorID.isEmpty || invitingAuthorID != nil || invited || (isInviteLimitReached && !invited))
}
- /// Maps the row's invite state to the avatar's animation phase. Sending
- /// is checked first so the glyph keeps wiggling right up until the work
- /// finishes, then resolves straight into the spin-to-checkmark.
+ /// Maps durable delivery ahead of the task-local sending state so the UI
+ /// can show a queued clock as soon as the outbox commits, then reserve the
+ /// checkmark for CloudKit's acknowledgement.
private func invitePhase(authorID: String, invited: Bool) -> FriendAvatarView.InvitePhase? {
+ if let delivery = appActions?.inviteDelivery(
+ gameID: gameID,
+ friendAuthorID: authorID
+ ) {
+ switch delivery.phase {
+ case .queued: return .queued
+ case .sent: return .sent
+ case .failed: return nil
+ case .idle: break
+ }
+ }
if invitingAuthorID == authorID { return .sending }
if invited { return .sent }
return nil
@@ -118,6 +129,7 @@ struct FriendPickerView: View {
isInviteLimitReached = invitedAuthorIDs.count >= ShareController.maximumPeoplePerPuzzle - 1
}
} catch {
+ appActions.markInviteFailed(gameID: gameID, friendAuthorID: authorID)
if case ShareController.ShareError.collaborationLimitReached = error {
withAnimation(.snappy) { isInviteLimitReached = true }
}
diff --git a/Crossmate/Views/GameList/GameShareItem.swift b/Crossmate/Views/GameList/GameShareItem.swift
@@ -318,10 +318,21 @@ struct GameShareSheet: View {
.disabled(authorID.isEmpty || invitingAuthorID != nil || wasInvited || isLoadingExistingLink || (isInviteLimitReached && !wasInvited))
}
- /// Maps the row's invite state to the avatar's animation phase. Sending
- /// is checked first so the glyph keeps wiggling right up until the work
- /// finishes, then resolves straight into the spin-to-checkmark.
+ /// Maps durable delivery ahead of the task-local sending state so the UI
+ /// can show a queued clock as soon as the outbox commits, then reserve the
+ /// checkmark for CloudKit's acknowledgement.
private func invitePhase(authorID: String, invited: Bool) -> FriendAvatarView.InvitePhase? {
+ if let delivery = appActions?.inviteDelivery(
+ gameID: gameID,
+ friendAuthorID: authorID
+ ) {
+ switch delivery.phase {
+ case .queued: return .queued
+ case .sent: return .sent
+ case .failed: return nil
+ case .idle: break
+ }
+ }
if invitingAuthorID == authorID { return .sending }
if invited { return .sent }
return nil
@@ -340,6 +351,7 @@ struct GameShareSheet: View {
isInviteLimitReached = invitedAuthorIDs.count >= ShareController.maximumPeoplePerPuzzle - 1
}
} catch {
+ appActions.markInviteFailed(gameID: gameID, friendAuthorID: authorID)
if case ShareController.ShareError.collaborationLimitReached = error {
withAnimation(.snappy) { isInviteLimitReached = true }
}
diff --git a/Tests/Unit/Sync/PendingChangeReapTests.swift b/Tests/Unit/Sync/PendingChangeReapTests.swift
@@ -6,9 +6,9 @@ import Testing
@testable import Crossmate
/// Pins down the reap path in `makeRecordZoneChangeBatch`. A pending
-/// `.saveRecord` whose record can't be reconstructed (a ping whose in-memory
-/// `pendingPings` payload was lost across a relaunch, or a deleted Core Data
-/// entity) must be dropped from the engine's persisted pending changes rather
+/// `.saveRecord` whose record can't be reconstructed (a ping whose durable
+/// outbox row is missing/unreadable, or a deleted Core Data entity) must be
+/// dropped from the engine's persisted pending changes rather
/// than left queued forever. Without this the device showed a permanent
/// `Pending Changes: 1` that never drained and surfaced no error — every push
/// "succeeded" while silently sending nothing (see the stuck-ping log).
@@ -56,7 +56,7 @@ struct PendingChangeReapTests {
// Delete the backing entity so `buildRecord` can no longer
// reconstruct the `game-` record — the same dead-end the engine hits
- // for a ping whose payload didn't survive a relaunch.
+ // for a ping whose durable outbox row is missing or unreadable.
let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
req.predicate = NSPredicate(format: "id == %@", gameID as CVarArg)
let entity = try #require(try ctx.fetch(req).first)
@@ -92,4 +92,167 @@ struct PendingChangeReapTests {
let after = await engine.pendingSaveRecordNames(scope: .private)
#expect(after.contains(pingName))
}
+
+ @Test("Atomic Decision conflict re-enqueues its reconstructable Ping sibling")
+ func atomicDecisionConflictRecoversPing() async throws {
+ let persistence = makeTestPersistence()
+ let engine = await makeEngine(persistence: persistence)
+ let zoneID = CKRecordZone.ID(
+ zoneName: "friend-atomic-recovery",
+ ownerName: "_friend"
+ )
+
+ let decisionName = RecordSerializer.decisionRecordName(
+ kind: RecordSerializer.nameDecisionKind,
+ key: "_localAuthor"
+ )
+ let decisionID = CKRecord.ID(recordName: decisionName, zoneID: zoneID)
+ #expect(await engine.enqueueFriendDecision(
+ kind: RecordSerializer.nameDecisionKind,
+ key: "_localAuthor",
+ payload: "Local",
+ version: 1,
+ friendZoneID: zoneID,
+ friendZoneScope: .shared
+ ))
+ try await engine.enqueueFriendZonePing(
+ kind: .invite,
+ gameID: UUID(),
+ gameTitle: "Atomic",
+ authorID: "_localAuthor",
+ playerName: "Local",
+ addressee: "_friend",
+ friendZoneID: zoneID,
+ friendZoneScope: .shared,
+ payload: #"{"gameShareURL":"https://example.com/share"}"#
+ )
+
+ let pingName = try #require(
+ await engine.pendingPingRecordNamesForTesting().first
+ )
+ let pingID = CKRecord.ID(recordName: pingName, zoneID: zoneID)
+
+ // A sent atomic batch falls out of CKSyncEngine's pending state. The
+ // Decision's serverRecordChanged error is causal and settles because
+ // the write-once record already exists; the Ping receives only
+ // batchRequestFailed and must be added back explicitly afterward.
+ await engine.recoverAtomicBatchForTesting(
+ settledDecisionID: decisionID,
+ batchFailedRecordIDs: [pingID],
+ scope: .shared
+ )
+
+ let pending = await engine.pendingSaveRecordNames(scope: .shared)
+ #expect(!pending.contains(decisionName))
+ #expect(pending.contains(pingName))
+ #expect(await engine.pendingPingRecordNamesForTesting() == [pingName])
+
+ // The retry remains materializable rather than being reaped by the
+ // next record-provider pass.
+ _ = await engine.makeRecordZoneChangeBatch(forTestingScope: .shared)
+ #expect(await engine.pendingSaveRecordNames(scope: .shared).contains(pingName))
+ }
+
+ @Test("Pending Ping outbox survives SyncEngine restart")
+ func pendingPingOutboxSurvivesRestart() async throws {
+ let persistence = makeTestPersistence()
+ let firstEngine = await makeEngine(persistence: persistence)
+ let zoneID = CKRecordZone.ID(
+ zoneName: "friend-durable-outbox",
+ ownerName: "_friend"
+ )
+ try await firstEngine.enqueueFriendZonePing(
+ kind: .invite,
+ gameID: UUID(),
+ gameTitle: "Durable",
+ authorID: "_localAuthor",
+ playerName: "Local",
+ addressee: "_friend",
+ friendZoneID: zoneID,
+ friendZoneScope: .shared,
+ payload: #"{"gameShareURL":"https://example.com/share"}"#
+ )
+ let recordName = try #require(
+ await firstEngine.pendingPingRecordNamesForTesting().first
+ )
+
+ // A new engine mirrors an app relaunch. Its CKSyncEngine state may or
+ // may not have serialized the enqueue before termination, so startup
+ // restores the outbox row and independently re-adds the stable ID.
+ let relaunchedEngine = await makeEngine(persistence: persistence)
+ #expect(await relaunchedEngine.pendingPingRecordNamesForTesting() == [recordName])
+ #expect(
+ await relaunchedEngine.pendingSaveRecordNames(scope: .shared)
+ .contains(recordName)
+ )
+ }
+
+ @Test("Invite delivery advances from queued to CloudKit-confirmed sent")
+ func inviteDeliveryPhases() async throws {
+ let persistence = makeTestPersistence()
+ let engine = await makeEngine(persistence: persistence)
+ let deliveries = InviteDeliveryStore()
+ let gameID = UUID()
+ let friendAuthorID = "_friend"
+ let delivery = deliveries.delivery(
+ gameID: gameID,
+ friendAuthorID: friendAuthorID
+ )
+ #expect(delivery.phase == .idle)
+
+ await engine.setOnPingDeliveryUpdate { update in
+ switch update.state {
+ case .queued:
+ deliveries.markQueued(
+ recordName: update.recordName,
+ gameID: update.gameID,
+ friendAuthorID: update.addressee
+ )
+ case .sent:
+ deliveries.markSent(
+ recordName: update.recordName,
+ gameID: update.gameID,
+ friendAuthorID: update.addressee
+ )
+ case .failed:
+ deliveries.markFailed(
+ recordName: update.recordName,
+ gameID: update.gameID,
+ friendAuthorID: update.addressee
+ )
+ }
+ }
+
+ let zoneID = CKRecordZone.ID(
+ zoneName: "friend-delivery-phases",
+ ownerName: friendAuthorID
+ )
+ try await engine.enqueueFriendZonePing(
+ kind: .invite,
+ gameID: gameID,
+ gameTitle: "Phases",
+ authorID: "_localAuthor",
+ playerName: "Local",
+ addressee: friendAuthorID,
+ friendZoneID: zoneID,
+ friendZoneScope: .shared,
+ payload: #"{"gameShareURL":"https://example.com/share"}"#
+ )
+ let recordName = try #require(
+ await engine.pendingPingRecordNamesForTesting().first
+ )
+ #expect(delivery.phase == .queued)
+
+ await engine.confirmPendingPingForTesting(recordName: recordName)
+ #expect(delivery.phase == .sent)
+ #expect(await engine.pendingPingRecordNamesForTesting().isEmpty)
+
+ // A late/replayed queued update cannot regress a confirmed send.
+ deliveries.markQueued(
+ recordName: recordName,
+ gameID: gameID,
+ friendAuthorID: friendAuthorID
+ )
+ #expect(delivery.phase == .sent)
+ }
}