crossmate

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

commit 386810a1adddbbd83b4831dc91c82d215b232ac4
parent fdf1e2aec6ca54b274312346c5861d21cb8941b2
Author: Michael Camilleri <[email protected]>
Date:   Wed, 27 May 2026 15:37:50 +0900

Use private struct rather than long tuple for return value

Diffstat:
MCrossmate/Sync/RecordApplier.swift | 66++++++++++++++++++++++++++++++++++++------------------------------
MCrossmate/Sync/SyncEngine.swift | 67++++++++++++++++++++++++++++++-------------------------------------
2 files changed, 66 insertions(+), 67 deletions(-)

diff --git a/Crossmate/Sync/RecordApplier.swift b/Crossmate/Sync/RecordApplier.swift @@ -16,6 +16,17 @@ struct AuthorDelta: Sendable { let latestUpdate: Date } +struct BatchEffects { + var movesUpdated = Set<UUID>() + var affected = Set<UUID>() + var pings: [Ping] = [] + var playersUpdated = Set<UUID>() + var playerPresenceChanged = Set<UUID>() + var removed = Set<UUID>() + var readCursors: [(UUID, Date)] = [] + var authorDeltas: [AuthorDelta] = [] +} + extension SyncEngine { func applyDirectRecordZoneChanges( records: [CKRecord], @@ -26,13 +37,8 @@ extension SyncEngine { let ctx = persistence.container.newBackgroundContext() ctx.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump let localAuthorID = await currentLocalAuthorID() - let (movesUpdatedGameIDs, affectedGameIDs, playersUpdatedGameIDs, playerPresenceChangedGameIDs, readCursors, authorDeltas): - (Set<UUID>, Set<UUID>, Set<UUID>, Set<UUID>, [(UUID, Date)], [AuthorDelta]) = ctx.performAndWait { - var movesUpdated = Set<UUID>() - var affected = Set<UUID>() - var playersUpdated = Set<UUID>() - var playerPresenceChanged = Set<UUID>() - var read: [(UUID, Date)] = [] + let effects: BatchEffects = ctx.performAndWait { + var effects = BatchEffects() // Pre-pass: snapshot the merged grid for every game that has a // Moves record in this batch, so the post-apply diff can attribute // each cell transition to the LWW-winning writer. @@ -47,7 +53,7 @@ extension SyncEngine { switch record.recordType { case "Game": let entity = RecordSerializer.applyGameRecord(record, to: ctx, databaseScope: scopeValue) - if let id = entity.id { affected.insert(id) } + if let id = entity.id { effects.affected.insert(id) } case "Moves": if let value = RecordSerializer.parseMovesRecord(record) { let cellsChanged = RecordSerializer.applyMovesRecord( @@ -56,8 +62,8 @@ extension SyncEngine { to: ctx, localAuthorID: localAuthorID ) - if cellsChanged { movesUpdated.insert(value.gameID) } - affected.insert(value.gameID) + if cellsChanged { effects.movesUpdated.insert(value.gameID) } + effects.affected.insert(value.gameID) } case "Player": if let (gameID, _) = RecordSerializer.parsePlayerRecordName(record.recordID.recordName) { @@ -65,11 +71,11 @@ extension SyncEngine { record, in: ctx, localAuthorID: localAuthorID, - onFirstTime: { playersUpdated.insert($0) }, - onPresenceChange: { playerPresenceChanged.insert($0) }, - onReadCursor: { read.append(($0, $1)) } + onFirstTime: { effects.playersUpdated.insert($0) }, + onPresenceChange: { effects.playerPresenceChanged.insert($0) }, + onReadCursor: { effects.readCursors.append(($0, $1)) } ) - affected.insert(gameID) + effects.affected.insert(gameID) } default: break @@ -82,14 +88,14 @@ extension SyncEngine { in: ctx ) if let id = self.gameID(fromRecordName: deletion.0.recordName) { - affected.insert(id) + effects.affected.insert(id) } } - for gameID in movesUpdated { + for gameID in effects.movesUpdated { self.replayCellCache(for: gameID, in: ctx) } let afterGrids = Self.gridSnapshots(for: movesBearingGameIDs, in: ctx) - let deltas = Self.authorDeltas( + effects.authorDeltas = Self.authorDeltas( before: beforeGrids, after: afterGrids, cutoff: Date().addingTimeInterval(-SessionMonitor.quiescenceWindow) @@ -106,23 +112,23 @@ extension SyncEngine { ) } } - return (movesUpdated, affected, playersUpdated, playerPresenceChanged, read, deltas) + return effects } - if let onRemoteMovesUpdated, !movesUpdatedGameIDs.isEmpty { - await onRemoteMovesUpdated(movesUpdatedGameIDs) + if let onRemoteMovesUpdated, !effects.movesUpdated.isEmpty { + await onRemoteMovesUpdated(effects.movesUpdated) } - if let onRemoteAuthorDelta, !authorDeltas.isEmpty { - await onRemoteAuthorDelta(authorDeltas) + if let onRemoteAuthorDelta, !effects.authorDeltas.isEmpty { + await onRemoteAuthorDelta(effects.authorDeltas) } - if let onRemotePlayersUpdated, !playersUpdatedGameIDs.isEmpty { - await onRemotePlayersUpdated(playersUpdatedGameIDs) + if let onRemotePlayersUpdated, !effects.playersUpdated.isEmpty { + await onRemotePlayersUpdated(effects.playersUpdated) } - if let onRemotePlayerPresenceChanged, !playerPresenceChangedGameIDs.isEmpty { - await onRemotePlayerPresenceChanged(playerPresenceChangedGameIDs) + if let onRemotePlayerPresenceChanged, !effects.playerPresenceChanged.isEmpty { + await onRemotePlayerPresenceChanged(effects.playerPresenceChanged) } - if let onIncomingReadCursor, !readCursors.isEmpty { - await onIncomingReadCursor(readCursors) + if let onIncomingReadCursor, !effects.readCursors.isEmpty { + await onIncomingReadCursor(effects.readCursors) } let pingDeletedGameIDs = Set(deletions.compactMap { deletion -> UUID? in deletion.0.recordName.hasPrefix("ping-") @@ -131,11 +137,11 @@ extension SyncEngine { if let onPingDeleted, !pingDeletedGameIDs.isEmpty { await onPingDeleted(pingDeletedGameIDs) } - if !affectedGameIDs.isEmpty { + if !effects.affected.isEmpty { NotificationCenter.default.post( name: .playerRosterShouldRefresh, object: nil, - userInfo: ["gameIDs": affectedGameIDs] + userInfo: ["gameIDs": effects.affected] ) } } diff --git a/Crossmate/Sync/SyncEngine.swift b/Crossmate/Sync/SyncEngine.swift @@ -1168,15 +1168,8 @@ actor SyncEngine { let ctx = persistence.container.newBackgroundContext() ctx.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump let localAuthorID = await currentLocalAuthorID() - let (movesUpdatedGameIDs, affectedGameIDs, pings, playersUpdatedGameIDs, playerPresenceChangedGameIDs, removedGameIDs, readCursors, authorDeltas): - (Set<UUID>, Set<UUID>, [Ping], Set<UUID>, Set<UUID>, Set<UUID>, [(UUID, Date)], [AuthorDelta]) = ctx.performAndWait { - var movesUpdated = Set<UUID>() - var affected = Set<UUID>() - var pings: [Ping] = [] - var playersUpdated = Set<UUID>() - var playerPresenceChanged = Set<UUID>() - var removed = Set<UUID>() - var read: [(UUID, Date)] = [] + let effects: BatchEffects = ctx.performAndWait { + var effects = BatchEffects() // Pre-pass: snapshot the merged grid for every game that has a // Moves record in this batch, so the post-apply diff can attribute // each cell transition to the LWW-winning writer. @@ -1192,7 +1185,7 @@ actor SyncEngine { switch record.recordType { case "Game": let entity = RecordSerializer.applyGameRecord(record, to: ctx, databaseScope: scope) - if let id = entity.id { affected.insert(id) } + if let id = entity.id { effects.affected.insert(id) } case "Moves": if let value = RecordSerializer.parseMovesRecord(record) { let cellsChanged = RecordSerializer.applyMovesRecord( @@ -1201,8 +1194,8 @@ actor SyncEngine { to: ctx, localAuthorID: localAuthorID ) - if cellsChanged { movesUpdated.insert(value.gameID) } - affected.insert(value.gameID) + if cellsChanged { effects.movesUpdated.insert(value.gameID) } + effects.affected.insert(value.gameID) } case "Player": if let (gameID, _) = RecordSerializer.parsePlayerRecordName(record.recordID.recordName) { @@ -1210,15 +1203,15 @@ actor SyncEngine { record, in: ctx, localAuthorID: localAuthorID, - onFirstTime: { playersUpdated.insert($0) }, - onPresenceChange: { playerPresenceChanged.insert($0) }, - onReadCursor: { read.append(($0, $1)) } + onFirstTime: { effects.playersUpdated.insert($0) }, + onPresenceChange: { effects.playerPresenceChanged.insert($0) }, + onReadCursor: { effects.readCursors.append(($0, $1)) } ) - affected.insert(gameID) + effects.affected.insert(gameID) } case "Ping": if let ping = Ping.parseRecord(record) { - pings.append(ping) + effects.pings.append(ping) } case "Decision": let wrote = RecordSerializer.applyDecisionRecord( @@ -1235,7 +1228,7 @@ actor SyncEngine { ), dKind == "left", let gid = UUID(uuidString: dKey) { - removed.insert(gid) + effects.removed.insert(gid) } default: break @@ -1248,14 +1241,14 @@ actor SyncEngine { in: ctx ) if let id = self.gameID(fromRecordName: deletion.recordID.recordName) { - affected.insert(id) + effects.affected.insert(id) } } - for gameID in movesUpdated { + for gameID in effects.movesUpdated { self.replayCellCache(for: gameID, in: ctx) } let afterGrids = Self.gridSnapshots(for: movesBearingGameIDs, in: ctx) - let deltas = Self.authorDeltas( + effects.authorDeltas = Self.authorDeltas( before: beforeGrids, after: afterGrids, cutoff: Date().addingTimeInterval(-SessionMonitor.quiescenceWindow) @@ -1277,28 +1270,28 @@ actor SyncEngine { ) } } - return (movesUpdated, affected, pings, playersUpdated, playerPresenceChanged, removed, read, deltas) + return effects } - if let onRemoteMovesUpdated, !movesUpdatedGameIDs.isEmpty { - await onRemoteMovesUpdated(movesUpdatedGameIDs) + if let onRemoteMovesUpdated, !effects.movesUpdated.isEmpty { + await onRemoteMovesUpdated(effects.movesUpdated) } - if let onRemoteAuthorDelta, !authorDeltas.isEmpty { - await onRemoteAuthorDelta(authorDeltas) + if let onRemoteAuthorDelta, !effects.authorDeltas.isEmpty { + await onRemoteAuthorDelta(effects.authorDeltas) } - if let onRemotePlayersUpdated, !playersUpdatedGameIDs.isEmpty { - await onRemotePlayersUpdated(playersUpdatedGameIDs) + if let onRemotePlayersUpdated, !effects.playersUpdated.isEmpty { + await onRemotePlayersUpdated(effects.playersUpdated) } - if let onRemotePlayerPresenceChanged, !playerPresenceChangedGameIDs.isEmpty { - await onRemotePlayerPresenceChanged(playerPresenceChangedGameIDs) + if let onRemotePlayerPresenceChanged, !effects.playerPresenceChanged.isEmpty { + await onRemotePlayerPresenceChanged(effects.playerPresenceChanged) } - if let onIncomingReadCursor, !readCursors.isEmpty { - await onIncomingReadCursor(readCursors) + if let onIncomingReadCursor, !effects.readCursors.isEmpty { + await onIncomingReadCursor(effects.readCursors) } - if let onPings, !pings.isEmpty { - await onPings(pings) + if let onPings, !effects.pings.isEmpty { + await onPings(effects.pings) } - for id in removedGameIDs { + for id in effects.removed { if let cb = onGameRemoved { await cb(id) } } let pingDeletedGameIDs = Set(event.deletions.compactMap { deletion -> UUID? in @@ -1308,11 +1301,11 @@ actor SyncEngine { if let onPingDeleted, !pingDeletedGameIDs.isEmpty { await onPingDeleted(pingDeletedGameIDs) } - if !affectedGameIDs.isEmpty { + if !effects.affected.isEmpty { NotificationCenter.default.post( name: .playerRosterShouldRefresh, object: nil, - userInfo: ["gameIDs": affectedGameIDs] + userInfo: ["gameIDs": effects.affected] ) } }