commit f15ade50015425e709e6370319f450331fbaf0ea
parent d05b059102722b9670e84f9d263419f0de4da3e4
Author: Michael Camilleri <[email protected]>
Date: Thu, 23 Jul 2026 11:05:48 +0900
Compact completed games into Chronicles
Completed games retained their multi-record CloudKit zones indefinitely,
while each participant archive added another custom zone and three
assets. This made finished games an increasingly expensive part of the
user's iCloud storage.
This commit writes each completed game as one authenticated,
LZFSE-compressed Chronicle in a common private zone. Participants emit a
chronicled Ping only after preserving a complete replay, and the owner
retires the live zone once the accepted roster has acknowledged it. A
hard 14-day deadline still retires the zone when a participant does not
respond, retaining a final-state Chronicle without replay; an
account-wide ubiquitous key-value grace date gives existing games the
full window after v1.1.0 first launches.
Legacy Archive records remain readable and migrate into Chronicles.
Restored games retain their final grid and replay state, while
individual deletion removes only the relevant Chronicle rather than the
common zone. Payload identity, integrity, decompression and decoded
sizes are bounded before materialisation.
Co-Authored-By: Codex GPT 5.6 Sol <[email protected]>
Diffstat:
15 files changed, 1048 insertions(+), 387 deletions(-)
diff --git a/Crossmate/Models/CrossmateModel.xcdatamodeld/CrossmateModel.xcdatamodel/contents b/Crossmate/Models/CrossmateModel.xcdatamodeld/CrossmateModel.xcdatamodel/contents
@@ -10,7 +10,9 @@
<attribute name="ckZoneName" optional="YES" attributeType="String"/>
<attribute name="ckZoneOwnerName" optional="YES" attributeType="String"/>
<attribute name="archivedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
+ <attribute name="archiveAcknowledgedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="archiveGameID" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
+ <attribute name="archiveParticipants" optional="YES" attributeType="String"/>
<attribute name="completedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="completedBy" optional="YES" attributeType="String"/>
<attribute name="createdAt" attributeType="Date" usesScalarValueType="NO"/>
@@ -34,6 +36,7 @@
<attribute name="puzzleParserVersion" optional="YES" attributeType="Integer 64" defaultValueString="0" renamingIdentifier="puzzleCmVersion" usesScalarValueType="YES"/>
<attribute name="puzzleSource" attributeType="String"/>
<attribute name="replayCacheComplete" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
+ <attribute name="replayUnavailable" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="shareParticipants" optional="YES" attributeType="String"/>
<attribute name="syncVersion" optional="YES" attributeType="Integer 64" defaultValueString="1" usesScalarValueType="YES"/>
<attribute name="title" attributeType="String"/>
diff --git a/Crossmate/Persistence/GameStore.swift b/Crossmate/Persistence/GameStore.swift
@@ -263,12 +263,13 @@ struct GameCloudDeletion: Sendable, Equatable {
let databaseScope: DatabaseScope
let ckZoneName: String
let ckZoneOwnerName: String
- /// The private-DB archive backup zone (archive-<id>) to tear down alongside
- /// the game, set only for a finished participant game whose backup lives in
- /// a zone separate from its live (shared) one. nil otherwise — an unarchived
- /// game, or a materialized archive whose own zone already is the archive and
- /// is covered by ckZoneName.
- let archiveZoneName: String?
+ /// False for a materialized archive: its ckZoneName is the account-wide
+ /// archive zone, which must never be deleted as part of removing one game.
+ let deletesLiveZone: Bool
+ /// The one compact Archive record to delete from the common private zone.
+ let archiveRecordName: String?
+ /// Best-effort cleanup for archives written before v1.1.0.
+ let legacyArchiveZoneName: String?
}
/// Per-entity memoisation of `GameSummary`. The library list re-runs on
@@ -1332,21 +1333,23 @@ final class GameStore {
guard let entity = try context.fetch(request).first else { return }
- // A finished participant game (scope 1, archived, still a participant)
- // carries a separate private-DB archive backup under archive-<id>;
- // deleting the game outright should drop that backup too. A materialized
- // or revoked archive is excluded — its own zone already is the archive,
- // covered by ckZoneName above, so it needs no second teardown.
- let hasSeparateArchive = entity.databaseScope == 1
- && entity.archivedAt != nil
- && !entity.isAccessRevoked
+ let materializedOriginalID = entity.ckRecordName.flatMap(
+ Archive.originalGameID(fromName:)
+ )
+ let originalGameID = materializedOriginalID ?? id
+ let isMaterializedArchive = materializedOriginalID != nil
+ let hasArchive = isMaterializedArchive || entity.archivedAt != nil
let deletion = GameCloudDeletion(
gameID: id,
databaseScope: DatabaseScope(entityValue: entity.databaseScope),
ckZoneName: entity.ckZoneName ?? "game-\(id.uuidString)",
ckZoneOwnerName: entity.ckZoneOwnerName ?? CKCurrentUserDefaultName,
- archiveZoneName: hasSeparateArchive
- ? Archive.zoneID(forOriginalGameID: id).zoneName
+ deletesLiveZone: !isMaterializedArchive,
+ archiveRecordName: hasArchive
+ ? Archive.recordName(forOriginalGameID: originalGameID)
+ : nil,
+ legacyArchiveZoneName: hasArchive
+ ? Archive.legacyZoneID(forOriginalGameID: originalGameID).zoneName
: nil
)
@@ -1559,6 +1562,20 @@ final class GameStore {
movesJournal.recordedEntries(gameID: gameID)
}
+ /// Deadline-retired owner archives intentionally omit journals. This is a
+ /// durable product state, not a transient CloudKit failure, so replay must
+ /// fail immediately instead of querying the archive zone and interpreting
+ /// its lack of live Journal records as an empty replay.
+ func isReplayUnavailable(forGameID gameID: UUID) async -> Bool {
+ let ctx = persistence.container.newBackgroundContext()
+ return await ctx.perform {
+ let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
+ req.predicate = NSPredicate(format: "id == %@", gameID as CVarArg)
+ req.fetchLimit = 1
+ return (try? ctx.fetch(req).first)?.replayUnavailable == true
+ }
+ }
+
/// Other devices' journals cached locally for replay, grouped by source
/// device — or `nil` if this game's cache isn't known-complete yet
/// (`replayCacheComplete`), in which case the caller must fetch from
diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift
@@ -716,7 +716,11 @@ final class AppServices {
persistence: persistence,
syncEngine: syncEngine,
syncMonitor: self.syncMonitor,
- eventLog: eventLog
+ eventLog: eventLog,
+ localIdentity: { [identity, preferences] in
+ guard let authorID = identity.currentID, !authorID.isEmpty else { return nil }
+ return (authorID, preferences.name)
+ }
)
self.cloudService = CloudService(
container: self.ckContainer,
@@ -1185,8 +1189,13 @@ final class AppServices {
await self?.accountPush.reconcilePushRegistration()
}
- await syncEngine.setOnGameRemoved { [weak self, store, gameViewedStore, announcements] gameID in
+ await syncEngine.setOnGameRemoved { [weak self, store, gameViewedStore, announcements, gameArchiver] gameID in
let wasOpen = store.handleRemoteRemoval(gameID: gameID)
+ // Another owner device may have retired this completed live zone.
+ // Its compact private Archive is account-wide, but the Archive
+ // record was intentionally inert while the live row existed; apply
+ // it now that the zone deletion removed that row.
+ await gameArchiver.restoreRetired(gameID: gameID)
gameViewedStore.advance(Date(), forGame: gameID)
// The local row is gone, so drop its badge ledger entry: a seen
// horizon can't clear it once there's no game left to open.
@@ -1202,12 +1211,13 @@ final class AppServices {
await self?.accountPush.reconcilePushRegistration()
}
- await syncEngine.setOnGameCompleted { [weak self] gameID in
+ await syncEngine.setOnGameCompleted { [weak self, gameArchiver] gameID in
await self?.shareController.closeTicketForCompletedGame(gameID: gameID)
// Completion learned purely via sync (this device wasn't present at
// the finish, so persistCompletion never ran): drop the now-useless
// peer-change ledger, the writer's terminal-game path doing the work.
self?.store.enqueuePeerChangeLedgerUpdate(for: [gameID])
+ await gameArchiver.archiveIfNeeded(gameID: gameID)
}
await syncEngine.setOnCompletionRecordsSaved { [weak self] records in
diff --git a/Crossmate/Services/InviteCoordinator.swift b/Crossmate/Services/InviteCoordinator.swift
@@ -643,12 +643,14 @@ final class InviteCoordinator {
// before the notification-authorization guard — so the badge updates
// even when the banner is suppressed or unauthorized.
await refreshAppBadge("present pings")
- // `.friend` is the friendship-bootstrap handshake. `.join` and `.hail`
+ // `.friend` is the friendship-bootstrap handshake. `.chronicled` is the
+ // completed-game retirement handshake. `.join` and `.hail`
// are legacy live-notification/bootstrap kinds; APNs and Game-record
// engagement creds own those jobs now. System pings do not require
// notification authorization.
let (systemPings, playerFacingPings) = pings.partitioned {
$0.kind == .friend || $0.kind == .join || $0.kind == .hail
+ || $0.kind == .chronicled
}
for ping in systemPings where ping.kind == .friend {
await friendController.applyFriendPing(
@@ -893,7 +895,7 @@ final class InviteCoordinator {
let player = nickname
?? (ping.playerName.isEmpty ? "A player" : ping.playerName)
return "\(player) declined your invitation to \(puzzleSuffix)"
- case .friend, .join, .hail:
+ case .friend, .join, .hail, .chronicled:
// System-only kinds handled by the friendship-bootstrap /
// engagement paths; never presented as a notification. If this
// text surfaces in a log or alert, `presentPings` dispatch has
diff --git a/Crossmate/Services/ReplayLoader.swift b/Crossmate/Services/ReplayLoader.swift
@@ -51,6 +51,10 @@ final class ReplayLoader {
case .unavailable: return "unavailable"
}
}
+ if await store.isReplayUnavailable(forGameID: gameID) {
+ syncMonitor.note("replay[\(short)]: unavailable by archive retention policy")
+ return .unavailable
+ }
// This device's live journal is always overlaid (fresher than any
// uploaded copy of itself), whether the contributors' journals come
// from the local cache or a fresh CloudKit fetch.
diff --git a/Crossmate/Sync/Archive.swift b/Crossmate/Sync/Archive.swift
@@ -1,17 +1,18 @@
import CloudKit
+import Compression
import CoreData
import CryptoKit
import Foundation
-/// Serialization + materialization for the private-zone archive of a finished
-/// shared game.
+/// Serialization + materialization for a finished game's compact private
+/// archive.
///
/// When a participant (not the owner) finishes a shared game, that game's data
/// lives only in the owner's shared zone; if the owner later deletes it, the
/// participant keeps a local copy but has no CloudKit backing, so a new device
-/// or reinstall loses it. To close that gap each participant writes a
+/// or reinstall loses it. To close that gap every involved account writes a
/// self-contained snapshot — final grid + the full multi-author move journal —
-/// into a zone in *their own* private database. A finished game is immutable
+/// into one common zone in *its own* private database. A finished game is immutable
/// (`isCompleted` latches at completion), so the snapshot needs no
/// reconciliation: it is written once and only ever read back to rebuild a
/// standalone completed game on another device or after the original is revoked.
@@ -21,10 +22,18 @@ import Foundation
/// tied to the shared zone (`RecordBuilder`), and the journal-upload path only
/// uploads *this device's own* rows — so it cannot reproduce the full
/// multi-author journal replay needs. Instead everything is folded into a single
-/// `Archive` record carrying `puzzleSource`, the final cells, and one
-/// merged journal asset.
+/// `Chronicle` record carrying all metadata and game data in one compressed
+/// payload asset. Once every accepted participant acknowledges that snapshot
+/// (or the retention deadline expires), the owner can delete the much larger
+/// live per-game zone without deleting the compact archive.
enum Archive {
- static let recordType = "Archive"
+ /// The compact v1.1.0 record. It deliberately has a new CloudKit type so
+ /// its schema contains only the single payload Asset.
+ static let recordType = "Chronicle"
+ /// Three-asset records written before v1.1.0.
+ static let legacyRecordType = "Archive"
+ static let zoneName = "completed-archives"
+ static let payloadKey = "payload"
// MARK: - Inbound asset bounds
@@ -40,6 +49,13 @@ enum Archive {
/// genuine.
static let maxJournalsAssetBytes = 8_388_608
+ /// Bounds both the compressed asset read and the allocation used to
+ /// decompress its versioned payload. The payload contains the three legacy
+ /// assets plus a small amount of metadata; this leaves ample headroom while
+ /// preventing a hostile envelope from requesting an arbitrary allocation.
+ static let maxPayloadAssetBytes = 12_582_912
+ static let maxDecodedPayloadBytes = 16_777_216
+
/// Upper bound on decoded final-grid cells; `XD.maxGridDimension`² is the
/// largest cell count any admissible puzzle can produce.
static let maxCellCount = XD.maxGridDimension * XD.maxGridDimension
@@ -79,7 +95,16 @@ enum Archive {
return UUID(uuid: uuid)
}
- static func zoneID(forOriginalGameID gameID: UUID) -> CKRecordZone.ID {
+ static var zoneID: CKRecordZone.ID {
+ CKRecordZone.ID(
+ zoneName: zoneName,
+ ownerName: CKCurrentUserDefaultName
+ )
+ }
+
+ /// The per-game zone used by archives written before v1.1.0. Kept solely
+ /// for backward-compatible reads and one-way migration into `zoneID`.
+ static func legacyZoneID(forOriginalGameID gameID: UUID) -> CKRecordZone.ID {
CKRecordZone.ID(
zoneName: "archive-\(gameID.uuidString)",
ownerName: CKCurrentUserDefaultName
@@ -87,18 +112,24 @@ enum Archive {
}
static func recordName(forOriginalGameID gameID: UUID) -> String {
+ "chronicle-\(gameID.uuidString)"
+ }
+
+ static func legacyRecordName(forOriginalGameID gameID: UUID) -> String {
"archive-\(gameID.uuidString)"
}
- /// The original game id encoded in an `archive-<UUID>` record/zone name, or
+ /// The original game id encoded in either generation's record name, or
/// `nil` if the name doesn't match.
static func originalGameID(fromName name: String) -> UUID? {
- guard name.hasPrefix("archive-") else { return nil }
- return UUID(uuidString: String(name.dropFirst("archive-".count)))
+ for prefix in ["chronicle-", "archive-"] where name.hasPrefix(prefix) {
+ return UUID(uuidString: String(name.dropFirst(prefix.count)))
+ }
+ return nil
}
static func isArchiveZone(_ zoneName: String) -> Bool {
- zoneName.hasPrefix("archive-")
+ zoneName == self.zoneName || zoneName.hasPrefix("archive-")
}
// MARK: - Final-grid wire format
@@ -155,8 +186,123 @@ enum Archive {
let entries: Data
}
+ /// The complete archive body. It is encoded as JSON only as an internal
+ /// representation, then wrapped in an authenticated, bounded LZFSE envelope
+ /// and stored as one CKAsset. No field needs to remain queryable in CloudKit:
+ /// record identity carries the original game ID and Crossmate materializes
+ /// the complete payload before displaying it.
+ private struct Blob: Codable {
+ let formatVersion: Int
+ let originalGameID: UUID
+ let archiveGameID: UUID
+ let title: String
+ let puzzleSource: String
+ let completedAt: Date
+ let completedBy: String?
+ let solveSeconds: Int
+ let replayAvailable: Bool
+ let cells: [Cell]
+ let journals: [DeviceJournalWire]
+ }
+
+ private static let envelopeMagic = Data("CMARCH01".utf8)
+ private static let envelopeHeaderBytes = 8 + MemoryLayout<UInt64>.size + 32
+
+ enum PayloadError: Error, CustomStringConvertible {
+ case oversizedCompressedPayload(bytes: Int)
+ case oversizedDecodedPayload(bytes: Int)
+ case malformedEnvelope
+ case unsupportedFormat(Int)
+ case decompressionFailed
+ case digestMismatch
+ case identityMismatch
+ case oversizedPuzzleSource(bytes: Int)
+
+ var description: String {
+ switch self {
+ case .oversizedCompressedPayload(let bytes):
+ return "archive payload exceeds \(maxPayloadAssetBytes) compressed bytes (\(bytes))"
+ case .oversizedDecodedPayload(let bytes):
+ return "archive payload exceeds \(maxDecodedPayloadBytes) decoded bytes (\(bytes))"
+ case .malformedEnvelope:
+ return "archive payload envelope is malformed"
+ case .unsupportedFormat(let version):
+ return "archive payload format \(version) is unsupported"
+ case .decompressionFailed:
+ return "archive payload decompression failed"
+ case .digestMismatch:
+ return "archive payload digest does not match"
+ case .identityMismatch:
+ return "archive payload identity does not match its record"
+ case .oversizedPuzzleSource(let bytes):
+ return "archive puzzle source exceeds \(XD.maxSourceBytes) bytes (\(bytes))"
+ }
+ }
+ }
+
+ private static func encodeEnvelope(_ decoded: Data) throws -> Data {
+ guard decoded.count <= maxDecodedPayloadBytes else {
+ throw PayloadError.oversizedDecodedPayload(bytes: decoded.count)
+ }
+ let compressed = try (decoded as NSData).compressed(using: .lzfse) as Data
+ var result = Data()
+ result.reserveCapacity(envelopeHeaderBytes + compressed.count)
+ result.append(envelopeMagic)
+ var length = UInt64(decoded.count).bigEndian
+ withUnsafeBytes(of: &length) { result.append(contentsOf: $0) }
+ result.append(contentsOf: SHA256.hash(data: decoded))
+ result.append(compressed)
+ guard result.count <= maxPayloadAssetBytes else {
+ throw PayloadError.oversizedCompressedPayload(bytes: result.count)
+ }
+ return result
+ }
+
+ private static func decodeEnvelope(_ envelope: Data) throws -> Data {
+ guard envelope.count >= envelopeHeaderBytes,
+ envelope.prefix(envelopeMagic.count) == envelopeMagic
+ else { throw PayloadError.malformedEnvelope }
+
+ let lengthRange = envelopeMagic.count..<(envelopeMagic.count + MemoryLayout<UInt64>.size)
+ let decodedLength = envelope[lengthRange].reduce(UInt64(0)) { ($0 << 8) | UInt64($1) }
+ guard decodedLength <= UInt64(maxDecodedPayloadBytes),
+ let decodedCount = Int(exactly: decodedLength),
+ decodedCount > 0
+ else {
+ throw PayloadError.oversizedDecodedPayload(bytes: Int(clamping: decodedLength))
+ }
+
+ let digestStart = lengthRange.upperBound
+ let digestEnd = digestStart + 32
+ let expectedDigest = envelope[digestStart..<digestEnd]
+ let compressed = envelope[digestEnd...]
+ guard !compressed.isEmpty else { throw PayloadError.malformedEnvelope }
+ var decoded = Data(count: decodedCount)
+ let written = decoded.withUnsafeMutableBytes { destination in
+ compressed.withUnsafeBytes { source in
+ compression_decode_buffer(
+ destination.bindMemory(to: UInt8.self).baseAddress!,
+ decodedCount,
+ source.bindMemory(to: UInt8.self).baseAddress!,
+ compressed.count,
+ nil,
+ COMPRESSION_LZFSE
+ )
+ }
+ }
+ guard written == decodedCount else { throw PayloadError.decompressionFailed }
+ guard Data(SHA256.hash(data: decoded)) == expectedDigest else {
+ throw PayloadError.digestMismatch
+ }
+ return decoded
+ }
+
private static func encodeJournals(_ journals: [DeviceJournal]) throws -> Data {
- let wire = try journals
+ try JSONEncoder().encode(journalWire(journals))
+ }
+
+ private static func journalWire(_ journals: [DeviceJournal]) throws -> [DeviceJournalWire] {
+ try journals
.sorted { ($0.key.authorID, $0.key.deviceID) < ($1.key.authorID, $1.key.deviceID) }
.map {
DeviceJournalWire(
@@ -165,11 +311,14 @@ enum Archive {
entries: try JournalCodec.encode($0.entries)
)
}
- return try JSONEncoder().encode(wire)
}
private static func decodeJournals(_ data: Data) throws -> [DeviceJournal] {
let wire = try JSONDecoder().decode([DeviceJournalWire].self, from: data)
+ return try decodeJournals(wire)
+ }
+
+ private static func decodeJournals(_ wire: [DeviceJournalWire]) throws -> [DeviceJournal] {
guard wire.count <= maxJournalDeviceCount else {
throw LimitError.tooManyDeviceJournals(count: wire.count)
}
@@ -217,6 +366,7 @@ enum Archive {
/// is still reachable.
static func snapshot(
forGameID gameID: UUID,
+ originalGameID: UUID? = nil,
in ctx: NSManagedObjectContext
) -> Snapshot? {
let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
@@ -239,7 +389,7 @@ enum Archive {
}
return Snapshot(
- originalGameID: gameID,
+ originalGameID: originalGameID ?? gameID,
title: entity.title ?? "",
puzzleSource: source,
completedAt: completedAt,
@@ -327,31 +477,36 @@ enum Archive {
let temporaryAssetFileURLs: [URL]
}
- static func recordPackage(from snapshot: Snapshot) throws -> RecordPackage {
- let zone = zoneID(forOriginalGameID: snapshot.originalGameID)
+ static func recordPackage(
+ from snapshot: Snapshot,
+ replayAvailable: Bool = true
+ ) throws -> RecordPackage {
+ let zone = zoneID
let recordID = CKRecord.ID(
recordName: recordName(forOriginalGameID: snapshot.originalGameID),
zoneID: zone
)
let record = CKRecord(recordType: recordType, recordID: recordID)
- record["originalGameID"] = snapshot.originalGameID.uuidString as CKRecordValue
- record["archiveGameID"] = archiveGameID(for: snapshot.originalGameID).uuidString as CKRecordValue
- record["title"] = snapshot.title as CKRecordValue
- record["completedAt"] = snapshot.completedAt as CKRecordValue
- if let completedBy = snapshot.completedBy {
- record["completedBy"] = completedBy as CKRecordValue
- }
- record["solveSeconds"] = Int64(snapshot.solveSeconds) as CKRecordValue
-
- let puzzleSource = try asset(for: Data(snapshot.puzzleSource.utf8), ext: "xd")
- let cells = try asset(for: try encodeCells(snapshot.cells), ext: "json")
- let journals = try asset(for: try encodeJournals(snapshot.journal), ext: "json")
- record["puzzleSource"] = puzzleSource.asset
- record["cells"] = cells.asset
- record["journals"] = journals.asset
+
+ let blob = Blob(
+ formatVersion: 1,
+ originalGameID: snapshot.originalGameID,
+ archiveGameID: archiveGameID(for: snapshot.originalGameID),
+ title: snapshot.title,
+ puzzleSource: snapshot.puzzleSource,
+ completedAt: snapshot.completedAt,
+ completedBy: snapshot.completedBy,
+ solveSeconds: snapshot.solveSeconds,
+ replayAvailable: replayAvailable,
+ cells: snapshot.cells.sorted { ($0.row, $0.col) < ($1.row, $1.col) },
+ journals: replayAvailable ? try journalWire(snapshot.journal) : []
+ )
+ let encoded = try JSONEncoder().encode(blob)
+ let payload = try asset(for: encodeEnvelope(encoded), ext: "cmarchive")
+ record[payloadKey] = payload.asset
return RecordPackage(
record: record,
- temporaryAssetFileURLs: [puzzleSource.url, cells.url, journals.url]
+ temporaryAssetFileURLs: [payload.url]
)
}
@@ -376,6 +531,7 @@ enum Archive {
/// The frozen solve time in whole seconds, or `nil` for archives written
/// before the field existed (their materialised game simply shows no time).
let solveSeconds: Int?
+ let replayAvailable: Bool
let cells: [Cell]
let journal: [DeviceJournal]
}
@@ -384,7 +540,10 @@ enum Archive {
/// without round-tripping through CloudKit. Used to promote the archive on
/// revocation while still offline — the local game data is fully present, so
/// the cloud copy need not have landed back.
- static func payload(from snapshot: Snapshot) -> Payload {
+ static func payload(
+ from snapshot: Snapshot,
+ replayAvailable: Bool = true
+ ) -> Payload {
Payload(
originalGameID: snapshot.originalGameID,
archiveGameID: archiveGameID(for: snapshot.originalGameID),
@@ -393,8 +552,9 @@ enum Archive {
completedAt: snapshot.completedAt,
completedBy: snapshot.completedBy,
solveSeconds: snapshot.solveSeconds,
+ replayAvailable: replayAvailable,
cells: snapshot.cells,
- journal: snapshot.journal
+ journal: replayAvailable ? snapshot.journal : []
)
}
@@ -402,12 +562,81 @@ enum Archive {
from record: CKRecord,
onDiagnostic: ((String) -> Void)? = nil
) -> Payload? {
+ switch record.recordType {
+ case recordType:
+ return blobPayload(from: record, onDiagnostic: onDiagnostic)
+ case legacyRecordType:
+ return legacyPayload(from: record, onDiagnostic: onDiagnostic)
+ default:
+ return nil
+ }
+ }
+
+ private static func blobPayload(
+ from record: CKRecord,
+ onDiagnostic: ((String) -> Void)?
+ ) -> Payload? {
guard record.recordType == recordType,
+ let recordOriginalID = originalGameID(fromName: record.recordID.recordName),
+ record.recordID.recordName == recordName(
+ forOriginalGameID: recordOriginalID
+ ),
+ let asset = record[payloadKey] as? CKAsset,
+ let url = asset.fileURL
+ else { return nil }
+ do {
+ let envelope = try RecordSerializer.boundedAssetData(
+ at: url,
+ limit: maxPayloadAssetBytes
+ )
+ let decoded = try decodeEnvelope(envelope)
+ let blob = try JSONDecoder().decode(Blob.self, from: decoded)
+ guard blob.formatVersion == 1 else {
+ throw PayloadError.unsupportedFormat(blob.formatVersion)
+ }
+ guard blob.originalGameID == recordOriginalID,
+ blob.archiveGameID == archiveGameID(for: recordOriginalID)
+ else { throw PayloadError.identityMismatch }
+ let sourceBytes = blob.puzzleSource.utf8.count
+ guard sourceBytes <= XD.maxSourceBytes else {
+ throw PayloadError.oversizedPuzzleSource(bytes: sourceBytes)
+ }
+ guard blob.cells.count <= maxCellCount else {
+ throw LimitError.tooManyCells(count: blob.cells.count)
+ }
+ let journals = blob.replayAvailable ? try decodeJournals(blob.journals) : []
+ return Payload(
+ originalGameID: blob.originalGameID,
+ archiveGameID: blob.archiveGameID,
+ title: blob.title,
+ puzzleSource: blob.puzzleSource,
+ completedAt: blob.completedAt,
+ completedBy: blob.completedBy,
+ solveSeconds: blob.solveSeconds,
+ replayAvailable: blob.replayAvailable,
+ cells: blob.cells,
+ journal: journals
+ )
+ } catch {
+ onDiagnostic?("archive payload rejected for \(record.recordID.recordName): \(error)")
+ return nil
+ }
+ }
+
+ private static func legacyPayload(
+ from record: CKRecord,
+ onDiagnostic: ((String) -> Void)?
+ ) -> Payload? {
+ guard record.recordType == legacyRecordType,
let originalString = record["originalGameID"] as? String,
let originalGameID = UUID(uuidString: originalString),
let archiveString = record["archiveGameID"] as? String,
let archiveGameID = UUID(uuidString: archiveString),
- let completedAt = record["completedAt"] as? Date
+ let completedAt = record["completedAt"] as? Date,
+ record.recordID.recordName == legacyRecordName(
+ forOriginalGameID: originalGameID
+ ),
+ archiveGameID == self.archiveGameID(for: originalGameID)
else { return nil }
// Each asset is size-gated on disk before it is read, then count-gated
@@ -448,14 +677,16 @@ enum Archive {
completedAt: completedAt,
completedBy: record["completedBy"] as? String,
solveSeconds: (record["solveSeconds"] as? Int64).map(Int.init),
+ replayAvailable: true,
cells: cells,
journal: journal
)
}
/// Rebuilds a standalone completed, owned game from an archive payload, under
- /// the derived `archiveGameID`. Idempotent: a second application of the same
- /// (frozen) archive is a no-op once the row exists. The created row is never
+ /// the derived `archiveGameID`. Reapplying refreshes the frozen grid and
+ /// replay cache, allowing a complete cloud snapshot to replace an earlier
+ /// local no-replay fallback without creating a duplicate. The row is never
/// enqueued for sync, so it pushes no Game/Moves/Player record — the
/// `Archive` record in the private zone remains its only cloud identity.
///
@@ -471,18 +702,28 @@ enum Archive {
guard !payload.puzzleSource.isEmpty else { return nil }
let archiveID = payload.archiveGameID
- let existing = NSFetchRequest<GameEntity>(entityName: "GameEntity")
- existing.predicate = NSPredicate(format: "id == %@", archiveID as CVarArg)
- existing.fetchLimit = 1
- if let row = try? ctx.fetch(existing).first { return row }
+ let request = NSFetchRequest<GameEntity>(entityName: "GameEntity")
+ request.predicate = NSPredicate(format: "id == %@", archiveID as CVarArg)
+ request.fetchLimit = 1
+ let entity: GameEntity
+ if let existing = try? ctx.fetch(request).first {
+ entity = existing
+ for cell in (existing.cells as? Set<CellEntity>) ?? [] {
+ ctx.delete(cell)
+ }
+ for journal in (existing.journal as? Set<JournalEntity>) ?? [] {
+ ctx.delete(journal)
+ }
+ } else {
+ entity = GameEntity(context: ctx)
+ entity.id = archiveID
+ }
- let entity = GameEntity(context: ctx)
- entity.id = archiveID
// A sentinel record name: distinct from the `game-` form so no sync
// path mistakes the archive for a pushable Game record, while staying
// non-nil for code that fetches games by `ckRecordName`.
entity.ckRecordName = recordName(forOriginalGameID: payload.originalGameID)
- entity.ckZoneName = zoneID(forOriginalGameID: payload.originalGameID).zoneName
+ entity.ckZoneName = zoneID.zoneName
entity.ckZoneOwnerName = nil
entity.databaseScope = 0
entity.syncVersion = GameSyncVersion.legacy
@@ -499,10 +740,11 @@ enum Archive {
entity.updatedAt = payload.completedAt
entity.archivedAt = payload.completedAt
entity.archiveGameID = archiveID
- // Every contributor's log is captured below, so the replay cache is
- // complete by construction — replay reads it locally, never the
- // (now-gone) shared zone.
- entity.replayCacheComplete = true
+ // A deadline fallback deliberately carries no journal. Mark it
+ // unavailable rather than presenting an authoritative-looking empty or
+ // partial replay.
+ entity.replayUnavailable = !payload.replayAvailable
+ entity.replayCacheComplete = payload.replayAvailable
for cell in payload.cells {
// The payload is peer-controlled; its Int16 fields can't overflow
@@ -521,7 +763,7 @@ enum Archive {
// replay reader treats every author — including the archiving user's own
// historical moves — as a cached contributor (the archived game has no
// *live* local journal to overlay).
- for deviceJournal in payload.journal {
+ for deviceJournal in payload.replayAvailable ? payload.journal : [] {
for value in deviceJournal.entries {
let row = JournalEntity(context: ctx)
row.game = entity
diff --git a/Crossmate/Sync/CloudDiagnostics.swift b/Crossmate/Sync/CloudDiagnostics.swift
@@ -419,7 +419,9 @@ extension SyncEngine {
totals.recordCount += 1
totals.recordCounts[record.recordType, default: 0] += 1
if totals.title == nil,
- record.recordType == "Game" || record.recordType == Archive.recordType {
+ record.recordType == "Game"
+ || record.recordType == Archive.recordType
+ || record.recordType == Archive.legacyRecordType {
totals.title = record["title"] as? String
}
diff --git a/Crossmate/Sync/CloudQuery.swift b/Crossmate/Sync/CloudQuery.swift
@@ -1144,4 +1144,30 @@ extension SyncEngine {
)
return JournalReplayFetch(journals: journals, expectedDevices: expected)
}
+
+ /// Returns the authenticated accounts that have durably acknowledged a
+ /// complete private Chronicle. The Ping records remain in the live zone, so
+ /// this query is the restart-safe source of truth for owner retirement.
+ func fetchChronicleAcknowledgements(forGameID gameID: UUID) async throws -> Set<String>? {
+ let ctx = persistence.container.newBackgroundContext()
+ guard let info = zoneInfo(forGameID: gameID, in: ctx),
+ info.scope == .private,
+ !info.isAccessRevoked
+ else { return nil }
+ let records = try await queryRecords(
+ type: "Ping",
+ database: container.privateCloudDatabase,
+ zoneID: info.zoneID,
+ predicate: NSPredicate(format: "kind == %@", PingKind.chronicled.rawValue),
+ desiredKeys: ["kind"]
+ )
+ return Set(records.compactMap { record in
+ guard RecordSerializer.isTrustedGameScopedRecord(record),
+ let (_, authorID, _) = RecordSerializer.parsePingRecordName(
+ record.recordID.recordName
+ )
+ else { return nil }
+ return authorID
+ })
+ }
}
diff --git a/Crossmate/Sync/GameArchiver.swift b/Crossmate/Sync/GameArchiver.swift
@@ -2,186 +2,274 @@ import CloudKit
import CoreData
import Foundation
-/// Writes the private-zone archive of a finished shared game, and promotes it to
-/// a normal completed game when the original is revoked.
-///
-/// See `Archive` for the why. This type owns the side effects: it
-/// creates the `archive-<gameID>` zone in the participant's *private* database
-/// and saves the snapshot record there with a raw `CKModifyRecordsOperation`
-/// (mirroring `FriendController`'s raw private-DB writes), rather than routing
-/// through `CKSyncEngine`'s entity-driven push path — the archive is not backed
-/// by a normal sync entity. Every device (including the author) then receives
-/// the record back through the private engine's `fetchedRecordZoneChanges` and
-/// applies it (`SyncEngine` `Archive` case): inert where the live original
-/// still exists, hydrated into a completed owned game where it doesn't.
+/// Compacts completed games into the account's private archive zone and retires
+/// their multi-record live zones once every participant has archived, or once
+/// the hard retention deadline expires.
@MainActor
final class GameArchiver {
nonisolated static let archiveRetryWindow: TimeInterval = 14 * 24 * 60 * 60
+ private struct LocalGame {
+ let snapshot: Archive.Snapshot
+ let databaseScope: DatabaseScope
+ let liveZoneID: CKRecordZone.ID
+ let isShared: Bool
+ /// nil means the owner has not received an authoritative CKShare roster
+ /// yet. An empty set is a known solo/no-participant game.
+ let acceptedParticipants: Set<String>?
+ let archiveAcknowledgedAt: Date?
+ }
+
+ private struct StoredArchive {
+ let payload: Archive.Payload
+ let isLegacy: Bool
+ }
+
private let container: CKContainer
private let persistence: PersistenceController
private let syncEngine: SyncEngine
private let syncMonitor: SyncMonitor?
private let eventLog: EventLog?
- private var ensuredArchiveZones = Set<CKRecordZone.ID>()
+ private let localIdentity: () -> (authorID: String, playerName: String)?
+ private let localDefaults: UserDefaults
+ private let ubiquitousStore: NSUbiquitousKeyValueStore?
+ private var ensuredArchiveZone = false
init(
container: CKContainer,
persistence: PersistenceController,
syncEngine: SyncEngine,
syncMonitor: SyncMonitor? = nil,
- eventLog: EventLog? = nil
+ eventLog: EventLog? = nil,
+ localIdentity: @escaping () -> (authorID: String, playerName: String)? = { nil },
+ localDefaults: UserDefaults = .standard,
+ ubiquitousStore: NSUbiquitousKeyValueStore? = .default
) {
self.container = container
self.persistence = persistence
self.syncEngine = syncEngine
self.syncMonitor = syncMonitor
self.eventLog = eventLog
+ self.localIdentity = localIdentity
+ self.localDefaults = localDefaults
+ self.ubiquitousStore = ubiquitousStore
}
- // MARK: - Write
-
- /// Archives a just-finished participant game, refreshing the snapshot until
- /// every contributing device's journal is captured. A no-op for owned games
- /// (already durable in the owner's own private DB) and for games already
- /// marked complete (`archivedAt != nil`).
- ///
- /// Convergence: at completion the peers' journals almost never exist yet —
- /// they upload at *their* own completion — so the first pass usually captures
- /// only this device's log. Each later call (driven by the reconcile sweep)
- /// re-fetches the shared zone, folds in whatever has since uploaded, and
- /// force-overwrites the archive. `archivedAt` is set once the log is
- /// complete (one journal per device that wrote grid state), or once the
- /// 14-day retry window has elapsed and we deliberately settle for the best
- /// available snapshot.
- func archiveIfNeeded(gameID: UUID) async {
- let ctx = persistence.container.newBackgroundContext()
- let local: Archive.Snapshot? = await ctx.perform {
- guard Self.shouldArchive(gameID: gameID, in: ctx) else { return nil }
- return Archive.snapshot(forGameID: gameID, in: ctx)
- }
- guard let local else { return }
-
- // Gather every available copy of each device's log, newest-wins:
- // this device's own local log (authoritative) over a freshly-fetched
- // peer log over whatever a sibling device already folded into the cloud
- // archive.
- let fetch = try? await syncEngine.fetchReplay(forGameID: local.originalGameID)
- let existing = await fetchArchivePayload(originalGameID: local.originalGameID)
- var snapshot = local
- if let existing { snapshot = Archive.merging(snapshot, peerJournals: existing.journal) }
- if let fetch { snapshot = Archive.merging(snapshot, peerJournals: fetch.journals) }
+ // MARK: - Reconciliation
- // Complete = a journal for every device that wrote grid state. Unknown
- // when the shared zone is unreachable (no fetch), so treat as incomplete
- // and let a later sweep settle it.
- let present = Set(snapshot.journal.map(\.key))
- let isComplete = fetch.map { $0.expectedDevices.subtracting(present).isEmpty } ?? false
- let retryExpired = Self.hasArchiveRetryExpired(completedAt: local.completedAt)
- let shouldFinalize = isComplete || retryExpired
- if retryExpired && !isComplete {
- syncMonitor?.note(
- "archive \(local.originalGameID.uuidString.prefix(8)): " +
- "retry window expired; finalizing incomplete archive"
- )
- }
-
- // Skip a redundant overwrite (and the push churn it causes) when the
- // cloud archive already holds every device we'd write — only the
- // completeness marker might still need flipping.
- if let existing, present.isSubset(of: Set(existing.journal.map(\.key))) {
- if shouldFinalize { await markArchived(originalGameID: local.originalGameID) }
- return
- }
- await write(snapshot, markComplete: shouldFinalize)
+ /// Immediate completion path. It writes/refreshes the archive and emits a
+ /// participant acknowledgement, but leaves zone retirement to the cold-
+ /// launch reconciliation path so an open Success Panel is never replaced
+ /// underneath the user.
+ func archiveIfNeeded(gameID: UUID) async {
+ guard let graceStart = await accountGraceStart() else { return }
+ _ = await reconcileArchive(gameID: gameID, graceStart: graceStart)
}
- /// Re-attempts (and converges) the archive for any completed participant game
- /// not yet marked complete — covering a completion that happened while
- /// offline, one whose peers hadn't uploaded their journals yet, or a game
- /// completed before this feature shipped. Driven by the cold-launch freshen
- /// sweep; after 14 days from completion, `archiveIfNeeded` finalizes the
- /// best available archive so legacy/incomplete games stop retrying forever.
+ /// Cold-launch backstop for new completions, migrations, acknowledgements,
+ /// and owner-side zone retirement.
func reconcileUnarchived() async {
+ guard let graceStart = await accountGraceStart() else { return }
+ await migrateMaterializedLegacyArchives()
+
let ctx = persistence.container.newBackgroundContext()
let ids: [UUID] = await ctx.perform {
let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
req.predicate = NSPredicate(
- format: "databaseScope == 1 AND completedAt != nil AND archivedAt == nil AND isAccessRevoked == NO"
+ format: "completedAt != nil AND isAccessRevoked == NO AND ckRecordName BEGINSWITH %@",
+ "game-"
)
return ((try? ctx.fetch(req)) ?? []).compactMap(\.id)
}
for id in ids {
- await archiveIfNeeded(gameID: id)
+ guard let result = await reconcileArchive(gameID: id, graceStart: graceStart),
+ result.local.databaseScope == .private
+ else { continue }
+ await retireOwnedGameIfEligible(
+ result.local,
+ snapshot: result.snapshot,
+ archiveComplete: result.complete,
+ graceStart: graceStart
+ )
}
}
nonisolated static func hasArchiveRetryExpired(
completedAt: Date,
+ graceStart: Date = .distantPast,
now: Date = Date()
) -> Bool {
- now.timeIntervalSince(completedAt) >= archiveRetryWindow
+ now >= max(completedAt, graceStart).addingTimeInterval(archiveRetryWindow)
}
- private nonisolated static func shouldArchive(gameID: UUID, in ctx: NSManagedObjectContext) -> Bool {
- let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
- req.predicate = NSPredicate(format: "id == %@", gameID as CVarArg)
- req.fetchLimit = 1
- guard let entity = try? ctx.fetch(req).first else { return false }
- return entity.databaseScope == 1
- && entity.completedAt != nil
- && entity.archivedAt == nil
- && !entity.isAccessRevoked
+ private func localGame(gameID: UUID) async -> LocalGame? {
+ let ctx = persistence.container.newBackgroundContext()
+ return await ctx.perform {
+ let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
+ req.predicate = NSPredicate(format: "id == %@", gameID as CVarArg)
+ req.fetchLimit = 1
+ guard let entity = try? ctx.fetch(req).first,
+ entity.completedAt != nil,
+ !entity.isAccessRevoked,
+ entity.ckRecordName?.hasPrefix("game-") == true,
+ let snapshot = Archive.snapshot(forGameID: gameID, in: ctx)
+ else { return nil }
+
+ let scope = DatabaseScope(entityValue: entity.databaseScope)
+ let isShared = entity.ckShareRecordName != nil || scope == .shared
+ let participants: Set<String>?
+ if !isShared {
+ participants = []
+ if entity.archiveParticipants == nil { entity.archiveParticipants = "" }
+ } else if let encoded = entity.archiveParticipants {
+ participants = Set(encoded.split(separator: ",").map(String.init))
+ } else if let encoded = entity.shareParticipants {
+ entity.archiveParticipants = encoded
+ participants = Set(encoded.split(separator: ",").map(String.init))
+ } else {
+ participants = nil
+ }
+ if ctx.hasChanges { try? ctx.save() }
+ return LocalGame(
+ snapshot: snapshot,
+ databaseScope: scope,
+ liveZoneID: CKRecordZone.ID(
+ zoneName: entity.ckZoneName ?? "game-\(gameID.uuidString)",
+ ownerName: entity.ckZoneOwnerName ?? CKCurrentUserDefaultName
+ ),
+ isShared: isShared,
+ acceptedParticipants: participants,
+ archiveAcknowledgedAt: entity.archiveAcknowledgedAt
+ )
+ }
}
- /// Force-overwrites the archive record with `snapshot`. `markComplete` flips
- /// the `archivedAt` marker that stops the reconcile sweep — set only when the
- /// caller knows every contributing device is captured.
- private func write(_ snapshot: Archive.Snapshot, markComplete: Bool) async {
- let zoneID = Archive.zoneID(forOriginalGameID: snapshot.originalGameID)
- do {
- try await ensureArchiveZone(zoneID)
- let package = try Archive.recordPackage(from: snapshot)
- try await save(package.record)
- removeTemporaryArchiveFiles(package.temporaryAssetFileURLs)
- if markComplete { await markArchived(originalGameID: snapshot.originalGameID) }
- } catch {
- syncMonitor?.recordError("archive game", error)
- eventLog?.note(
- "GameArchiver: write deferred for \(snapshot.originalGameID.uuidString); " +
- "will retry on cold launch — \(error)",
- level: "error"
+ private func reconcileArchive(
+ gameID: UUID,
+ graceStart: Date
+ ) async -> (local: LocalGame, snapshot: Archive.Snapshot, complete: Bool)? {
+ guard let local = await localGame(gameID: gameID) else { return nil }
+
+ let fetch = try? await syncEngine.fetchReplay(forGameID: gameID)
+ let stored = await fetchArchive(originalGameID: gameID)
+ var snapshot = local.snapshot
+ if let stored { snapshot = Archive.merging(snapshot, peerJournals: stored.payload.journal) }
+ if let fetch { snapshot = Archive.merging(snapshot, peerJournals: fetch.journals) }
+
+ let present = Set(snapshot.journal.map(\.key))
+ let fetchedComplete = fetch.map { $0.expectedDevices.subtracting(present).isEmpty } ?? false
+ // Only a compact payload's explicit flag is authoritative. Legacy
+ // archives used archivedAt for both completeness and a timed best-effort
+ // fallback, so they must be checked against the still-live zone again.
+ let compactComplete = stored.map { !$0.isLegacy && $0.payload.replayAvailable } ?? false
+ let complete = compactComplete || fetchedComplete
+
+ let storedKeys = Set(stored?.payload.journal.map(\.key) ?? [])
+ let needsWrite = stored == nil
+ || stored?.isLegacy == true
+ || stored?.payload.replayAvailable != complete
+ || !present.isSubset(of: storedKeys)
+ if needsWrite {
+ guard await write(snapshot, replayAvailable: complete) else { return nil }
+ }
+
+ if stored?.isLegacy == true {
+ await syncEngine.enqueueDeleteLegacyArchiveZone(
+ Archive.legacyZoneID(forOriginalGameID: gameID)
)
}
+
+ if complete {
+ await markArchived(originalGameID: gameID)
+ if local.databaseScope == .shared, local.archiveAcknowledgedAt == nil {
+ await acknowledgeChronicle(gameID: gameID)
+ }
+ }
+ return (local, snapshot, complete)
}
- private func removeTemporaryArchiveFiles(_ urls: [URL]) {
- for url in urls {
+ // MARK: - Retirement
+
+ private func retireOwnedGameIfEligible(
+ _ local: LocalGame,
+ snapshot: Archive.Snapshot,
+ archiveComplete: Bool,
+ graceStart: Date
+ ) async {
+ let expired = Self.hasArchiveRetryExpired(
+ completedAt: snapshot.completedAt,
+ graceStart: graceStart
+ )
+
+ var quorum = false
+ if archiveComplete, let expected = local.acceptedParticipants {
+ if expected.isEmpty {
+ quorum = true
+ } else if let acknowledged = try? await syncEngine.fetchChronicleAcknowledgements(
+ forGameID: snapshot.originalGameID
+ ) {
+ quorum = expected.isSubset(of: acknowledged)
+ }
+ }
+ guard quorum || expired else { return }
+
+ let keepReplay = quorum && archiveComplete
+ if !keepReplay {
+ syncMonitor?.note(
+ "archive \(snapshot.originalGameID.uuidString.prefix(8)): " +
+ "retention deadline reached; retiring without replay"
+ )
+ guard await write(snapshot, replayAvailable: false) else { return }
+ }
+ guard await promoteOwnedBeforeRetirement(snapshot, replayAvailable: keepReplay) else {
+ return
+ }
+ await syncEngine.enqueueRetireOwnedGameZone(local.liveZoneID)
+ }
+
+ private func promoteOwnedBeforeRetirement(
+ _ snapshot: Archive.Snapshot,
+ replayAvailable: Bool
+ ) async -> Bool {
+ let ctx = persistence.container.newBackgroundContext()
+ return await ctx.perform {
+ let payload = Archive.payload(from: snapshot, replayAvailable: replayAvailable)
+ guard Archive.materialize(payload, in: ctx) != nil else { return false }
+ let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
+ req.predicate = NSPredicate(
+ format: "id == %@", snapshot.originalGameID as CVarArg
+ )
+ req.fetchLimit = 1
+ if let original = try? ctx.fetch(req).first { ctx.delete(original) }
do {
- try FileManager.default.removeItem(at: url)
+ if ctx.hasChanges { try ctx.save() }
+ return true
} catch {
- eventLog?.note(
- "GameArchiver: failed to remove temporary archive asset \(url.lastPathComponent) — \(error)",
- level: "error"
- )
+ return false
}
}
}
- /// Reads back the archive record from this user's private database — the
- /// accumulated, possibly cross-device-converged copy. `nil` when it doesn't
- /// exist yet or the database is unreachable.
- private func fetchArchivePayload(
- originalGameID: UUID
- ) async -> Archive.Payload? {
- let recordID = CKRecord.ID(
- recordName: Archive.recordName(forOriginalGameID: originalGameID),
- zoneID: Archive.zoneID(forOriginalGameID: originalGameID)
+ // MARK: - Acknowledgement
+
+ private func acknowledgeChronicle(gameID: UUID) async {
+ guard let identity = localIdentity() else { return }
+ let enqueued = await syncEngine.enqueuePing(
+ kind: .chronicled,
+ gameID: gameID,
+ authorID: identity.authorID,
+ playerName: identity.playerName
)
- guard let record = try? await container.privateCloudDatabase.record(for: recordID) else {
- return nil
+ guard enqueued else { return }
+ let ctx = persistence.container.newBackgroundContext()
+ await ctx.perform {
+ let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
+ req.predicate = NSPredicate(format: "id == %@", gameID as CVarArg)
+ req.fetchLimit = 1
+ guard let entity = try? ctx.fetch(req).first else { return }
+ entity.archiveAcknowledgedAt = Date()
+ try? ctx.save()
}
- return Archive.payload(from: record)
}
private func markArchived(originalGameID: UUID) async {
@@ -191,114 +279,220 @@ final class GameArchiver {
req.predicate = NSPredicate(format: "id == %@", originalGameID as CVarArg)
req.fetchLimit = 1
guard let entity = try? ctx.fetch(req).first else { return }
- entity.archivedAt = Date()
+ if entity.archivedAt == nil { entity.archivedAt = Date() }
entity.archiveGameID = Archive.archiveGameID(for: originalGameID)
try? ctx.save()
}
}
- // MARK: - Promote on revocation
-
- /// Turns a revoked shared game into a durable, owned completed game, then
- /// deletes the revoked original so the library shows a single completed game
- /// rather than a dead tombstone.
- ///
- /// Prefers the cloud archive as the source: by the time an owner deletes,
- /// the reconcile sweep has usually converged it to the *full* multi-author
- /// log, whereas this device's local `JournalEntity` rows only hold its own
- /// moves plus any peers it happened to cache for replay. Falls back to the
- /// local data (and seeds a cloud copy from it) when the archive can't be
- /// reached — e.g. a game that completed and was revoked entirely offline.
+ // MARK: - Restore / legacy migration
+
+ /// Rebuilds a completed game after another owner device retired its live
+ /// zone and the sync applier removed the local live row first.
+ func restoreRetired(gameID: UUID) async {
+ guard let stored = await fetchArchive(originalGameID: gameID) else { return }
+ let ctx = persistence.container.newBackgroundContext()
+ await ctx.perform {
+ _ = Archive.materialize(stored.payload, in: ctx)
+ if ctx.hasChanges { try? ctx.save() }
+ }
+ }
+
+ /// Promotes a participant's private archive when the owner retires the live
+ /// shared zone. Falls back to the local snapshot only when CloudKit has not
+ /// delivered the compact record yet.
func promoteRevoked(gameID: UUID) async {
let ctx = persistence.container.newBackgroundContext()
let local: Archive.Snapshot? = await ctx.perform {
let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
req.predicate = NSPredicate(format: "id == %@", gameID as CVarArg)
req.fetchLimit = 1
- // Only finished games are archivable; an owner deleting an
- // in-progress shared game leaves the revoked tombstone as-is.
guard (try? ctx.fetch(req).first)?.completedAt != nil else { return nil }
return Archive.snapshot(forGameID: gameID, in: ctx)
}
guard let local else { return }
-
- let payload: Archive.Payload
- if let cloud = await fetchArchivePayload(originalGameID: gameID) {
- payload = cloud
- } else {
- // No reachable cloud copy — back up the local data now (the private
- // DB is still writable) and promote from it.
- await write(local, markComplete: true)
- payload = Archive.payload(from: local)
- }
-
+ let payload = await fetchArchive(originalGameID: gameID)?.payload
+ ?? Archive.payload(from: local, replayAvailable: false)
let promoteCtx = persistence.container.newBackgroundContext()
await promoteCtx.perform {
- // Only retire the revoked original once its replacement exists.
- // materialize returns nil when the payload's puzzleSource is empty —
- // e.g. a cloud Archive whose puzzleSource CKAsset failed to download
- // (typically transient). Deleting anyway would drop the finished game
- // with nothing to replace it; leave the revoked row so a later pass
- // can promote it from the intact cloud archive.
guard Archive.materialize(payload, in: promoteCtx) != nil else { return }
let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
req.predicate = NSPredicate(format: "id == %@", gameID as CVarArg)
req.fetchLimit = 1
- if let original = try? promoteCtx.fetch(req).first {
- promoteCtx.delete(original)
- }
+ if let original = try? promoteCtx.fetch(req).first { promoteCtx.delete(original) }
if promoteCtx.hasChanges { try? promoteCtx.save() }
}
}
- // MARK: - CloudKit helpers
+ private func migrateMaterializedLegacyArchives() async {
+ let ctx = persistence.container.newBackgroundContext()
+ let candidates: [(localID: UUID, originalID: UUID)] = await ctx.perform {
+ let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
+ req.predicate = NSPredicate(format: "ckRecordName BEGINSWITH %@", "archive-")
+ return ((try? ctx.fetch(req)) ?? []).compactMap { entity in
+ guard let localID = entity.id,
+ entity.ckZoneName?.hasPrefix("archive-") == true,
+ let name = entity.ckRecordName,
+ let originalID = Archive.originalGameID(fromName: name)
+ else { return nil }
+ return (localID, originalID)
+ }
+ }
+ for candidate in candidates {
+ let snapshot: Archive.Snapshot? = await ctx.perform {
+ Archive.snapshot(
+ forGameID: candidate.localID,
+ originalGameID: candidate.originalID,
+ in: ctx
+ )
+ }
+ guard let snapshot,
+ await write(snapshot, replayAvailable: true)
+ else { continue }
+ await ctx.perform {
+ let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
+ req.predicate = NSPredicate(format: "id == %@", candidate.localID as CVarArg)
+ req.fetchLimit = 1
+ if let entity = try? ctx.fetch(req).first {
+ entity.ckRecordName = Archive.recordName(
+ forOriginalGameID: candidate.originalID
+ )
+ entity.ckZoneName = Archive.zoneName
+ try? ctx.save()
+ }
+ }
+ await syncEngine.enqueueDeleteLegacyArchiveZone(
+ Archive.legacyZoneID(forOriginalGameID: candidate.originalID)
+ )
+ }
+ }
+
+ // MARK: - CloudKit archive I/O
+
+ private func fetchArchive(originalGameID: UUID) async -> StoredArchive? {
+ let name = Archive.recordName(forOriginalGameID: originalGameID)
+ let commonID = CKRecord.ID(recordName: name, zoneID: Archive.zoneID)
+ if let record = try? await container.privateCloudDatabase.record(for: commonID),
+ let payload = Archive.payload(from: record) {
+ return StoredArchive(payload: payload, isLegacy: false)
+ }
+ let legacyID = CKRecord.ID(
+ recordName: Archive.legacyRecordName(forOriginalGameID: originalGameID),
+ zoneID: Archive.legacyZoneID(forOriginalGameID: originalGameID)
+ )
+ if let record = try? await container.privateCloudDatabase.record(for: legacyID),
+ let payload = Archive.payload(from: record) {
+ return StoredArchive(payload: payload, isLegacy: true)
+ }
+ return nil
+ }
+
+ @discardableResult
+ private func write(
+ _ snapshot: Archive.Snapshot,
+ replayAvailable: Bool
+ ) async -> Bool {
+ do {
+ try await ensureArchiveZone()
+ let package = try Archive.recordPackage(
+ from: snapshot,
+ replayAvailable: replayAvailable
+ )
+ defer { removeTemporaryArchiveFiles(package.temporaryAssetFileURLs) }
+ try await save(package.record)
+ return true
+ } catch {
+ syncMonitor?.recordError("archive game", error)
+ eventLog?.note(
+ "GameArchiver: write deferred for \(snapshot.originalGameID.uuidString); " +
+ "will retry on cold launch — \(error)",
+ level: "error"
+ )
+ return false
+ }
+ }
- private func ensureArchiveZone(_ zoneID: CKRecordZone.ID) async throws {
- guard !ensuredArchiveZones.contains(zoneID) else { return }
+ private func ensureArchiveZone() async throws {
+ guard !ensuredArchiveZone else { return }
do {
- try await createArchiveZone(zoneID)
+ try await createArchiveZone(Archive.zoneID)
} catch {
guard Self.isZoneAlreadyExists(error) else { throw error }
}
- ensuredArchiveZones.insert(zoneID)
+ ensuredArchiveZone = true
+ }
+
+ private func createArchiveZone(_ zoneID: CKRecordZone.ID) async throws {
+ try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
+ let operation = CKModifyRecordZonesOperation(
+ recordZonesToSave: [CKRecordZone(zoneID: zoneID)],
+ recordZoneIDsToDelete: nil
+ )
+ operation.qualityOfService = .utility
+ operation.modifyRecordZonesResultBlock = { continuation.resume(with: $0) }
+ container.privateCloudDatabase.add(operation)
+ }
+ }
+
+ private func save(_ record: CKRecord) async throws {
+ try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
+ let operation = CKModifyRecordsOperation(recordsToSave: [record], recordIDsToDelete: nil)
+ operation.savePolicy = .allKeys
+ operation.qualityOfService = .utility
+ operation.modifyRecordsResultBlock = { continuation.resume(with: $0) }
+ container.privateCloudDatabase.add(operation)
+ }
}
private nonisolated static func isZoneAlreadyExists(_ error: Error) -> Bool {
guard let ckError = error as? CKError else { return false }
if ckError.code == .serverRejectedRequest {
- let description = ckError.localizedDescription.lowercased()
- return description.contains("already exist")
+ return ckError.localizedDescription.lowercased().contains("already exist")
}
if ckError.code == .partialFailure {
- return ckError.partialErrorsByItemID?.values.contains { itemError in
- isZoneAlreadyExists(itemError)
+ return ckError.partialErrorsByItemID?.values.contains {
+ isZoneAlreadyExists($0)
} ?? false
}
return false
}
- private func createArchiveZone(_ zoneID: CKRecordZone.ID) async throws {
- try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
- let op = CKModifyRecordZonesOperation(
- recordZonesToSave: [CKRecordZone(zoneID: zoneID)],
- recordZoneIDsToDelete: nil
- )
- op.qualityOfService = .utility
- op.modifyRecordZonesResultBlock = { result in cont.resume(with: result) }
- container.privateCloudDatabase.add(op)
+ private func removeTemporaryArchiveFiles(_ urls: [URL]) {
+ for url in urls {
+ do {
+ try FileManager.default.removeItem(at: url)
+ } catch {
+ eventLog?.note(
+ "GameArchiver: failed to remove temporary archive asset " +
+ "\(url.lastPathComponent) — \(error)",
+ level: "error"
+ )
+ }
}
}
- private func save(_ record: CKRecord) async throws {
- try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
- let op = CKModifyRecordsOperation(recordsToSave: [record], recordIDsToDelete: nil)
- // Force-overwrite: each convergence pass writes a fresh record (no
- // change tag), and the archive is a frozen game, so last-writer-wins
- // across this user's own devices is exactly right.
- op.savePolicy = .allKeys
- op.qualityOfService = .utility
- op.modifyRecordsResultBlock = { result in cont.resume(with: result) }
- container.privateCloudDatabase.add(op)
+ // MARK: - Account-wide migration grace
+
+ private static let graceKey = "archiveGraceStart.v1.1.0"
+
+ /// Uses ubiquitous key-value storage as an account-wide shared default,
+ /// with an author-keyed local fallback for immediate reads and account
+ /// switches. Devices continually publish the earliest value they have seen,
+ /// so a delayed KVS update can postpone retirement but can never make the
+ /// grace period shorter than the first v1.1.0 launch observed by a device.
+ private func accountGraceStart() async -> Date? {
+ guard let authorID = localIdentity()?.authorID, !authorID.isEmpty else { return nil }
+ let localKey = "\(Self.graceKey).\(authorID)"
+ ubiquitousStore?.synchronize()
+ let localValue = localDefaults.object(forKey: localKey) as? Double
+ let cloudValue = ubiquitousStore?.object(forKey: Self.graceKey) as? Double
+ let earliest = [localValue, cloudValue].compactMap { $0 }.min()
+ ?? Date().timeIntervalSince1970
+ localDefaults.set(earliest, forKey: localKey)
+ if cloudValue == nil || earliest < cloudValue! {
+ ubiquitousStore?.set(earliest, forKey: Self.graceKey)
+ ubiquitousStore?.synchronize()
}
+ return Date(timeIntervalSince1970: earliest)
}
}
diff --git a/Crossmate/Sync/Presence.swift b/Crossmate/Sync/Presence.swift
@@ -57,6 +57,10 @@ enum PingKind: String, Codable, Sendable {
/// Legacy engagement room bootstrap. Live rooms now rendezvous through
/// Game-record engagement credentials; this remains parseable for cleanup.
case hail
+ /// Durable completion handshake. A participant writes this into the live
+ /// game zone only after their complete private Chronicle has saved. The owner
+ /// retains these records until it retires the entire zone.
+ case chronicled
}
/// The CloudKit database a fetched record or zone belongs to, naming the raw
diff --git a/Crossmate/Sync/RecordApplier.swift b/Crossmate/Sync/RecordApplier.swift
@@ -145,7 +145,7 @@ extension SyncEngine {
)
effects.rosterRelevant.insert(gameID)
}
- case Archive.recordType:
+ case Archive.recordType, Archive.legacyRecordType:
if let id = self.applyArchiveRecord(
record,
in: ctx,
diff --git a/Crossmate/Sync/RecordSerializer.swift b/Crossmate/Sync/RecordSerializer.swift
@@ -315,7 +315,8 @@ enum RecordSerializer {
/// zone/scope gates.
static func isGameScopedRecordType(_ type: CKRecord.RecordType) -> Bool {
switch type {
- case "Game", "Moves", "Player", "Ping", "Journal", Archive.recordType:
+ case "Game", "Moves", "Player", "Ping", "Journal",
+ Archive.recordType, Archive.legacyRecordType:
return true
default:
return false
@@ -402,8 +403,19 @@ enum RecordSerializer {
case Archive.recordType:
guard let gameID = Archive.originalGameID(fromName: recordID.recordName),
- recordID.zoneID.zoneName == Archive.recordName(forOriginalGameID: gameID),
- (record["originalGameID"] as? String) == gameID.uuidString
+ recordID.recordName == Archive.recordName(forOriginalGameID: gameID),
+ recordID.zoneID.zoneName == Archive.zoneName
+ else { return false }
+ return true
+
+ case Archive.legacyRecordType:
+ guard let gameID = Archive.originalGameID(fromName: recordID.recordName),
+ recordID.recordName == Archive.legacyRecordName(
+ forOriginalGameID: gameID
+ ),
+ recordID.zoneID.zoneName == Archive.legacyZoneID(
+ forOriginalGameID: gameID
+ ).zoneName
else { return false }
return true
@@ -443,7 +455,17 @@ enum RecordSerializer {
guard let originalID = Archive.originalGameID(fromName: recordID.recordName) else {
return false
}
- return recordID.zoneID.zoneName == Archive.recordName(forOriginalGameID: originalID)
+ return recordID.recordName == Archive.recordName(forOriginalGameID: originalID)
+ && recordID.zoneID.zoneName == Archive.zoneName
+ case Archive.legacyRecordType:
+ guard let originalID = Archive.originalGameID(fromName: recordID.recordName) else {
+ return false
+ }
+ return recordID.recordName == Archive.legacyRecordName(
+ forOriginalGameID: originalID
+ ) && recordID.zoneID.zoneName == Archive.legacyZoneID(
+ forOriginalGameID: originalID
+ ).zoneName
default:
return false
}
diff --git a/Crossmate/Sync/SyncEngine.swift b/Crossmate/Sync/SyncEngine.swift
@@ -656,28 +656,50 @@ actor SyncEngine {
/// own zone, so this removes all remote records for the puzzle, including
/// moves, player records, pings, and share metadata.
func enqueueDeleteGame(_ deletion: GameCloudDeletion) {
- let zoneID = CKRecordZone.ID(
- zoneName: deletion.ckZoneName,
- ownerName: deletion.ckZoneOwnerName
- )
- let engine = deletion.databaseScope == .shared ? sharedEngine : privateEngine
- guard let engine else { return }
- engine.state.add(pendingDatabaseChanges: [.deleteZone(zoneID)])
- sendChangesDetached(on: engine)
+ if deletion.deletesLiveZone {
+ let zoneID = CKRecordZone.ID(
+ zoneName: deletion.ckZoneName,
+ ownerName: deletion.ckZoneOwnerName
+ )
+ let engine = deletion.databaseScope == .shared ? sharedEngine : privateEngine
+ if let engine {
+ engine.state.add(pendingDatabaseChanges: [.deleteZone(zoneID)])
+ sendChangesDetached(on: engine)
+ }
+ }
- // A finished participant game keeps a self-contained backup in a
- // separate archive-<id> zone (see GameArchiver). The live game lives in
- // the shared database, so the deletion above never reaches that backup —
- // tear it down here. The archive is always in this account's private
- // database, so it routes through the private engine regardless of the
- // game's own scope.
- guard let archiveZoneName = deletion.archiveZoneName,
- let privateEngine else { return }
- let archiveZoneID = CKRecordZone.ID(
- zoneName: archiveZoneName,
- ownerName: CKCurrentUserDefaultName
- )
- privateEngine.state.add(pendingDatabaseChanges: [.deleteZone(archiveZoneID)])
+ guard let privateEngine else { return }
+ if let archiveRecordName = deletion.archiveRecordName {
+ let recordID = CKRecord.ID(
+ recordName: archiveRecordName,
+ zoneID: Archive.zoneID
+ )
+ privateEngine.state.add(pendingRecordZoneChanges: [.deleteRecord(recordID)])
+ }
+ if let legacyZoneName = deletion.legacyArchiveZoneName {
+ let legacyZoneID = CKRecordZone.ID(
+ zoneName: legacyZoneName,
+ ownerName: CKCurrentUserDefaultName
+ )
+ privateEngine.state.add(pendingDatabaseChanges: [.deleteZone(legacyZoneID)])
+ }
+ sendChangesDetached(on: privateEngine)
+ }
+
+ /// Retires an owned completed game's live zone after its archive policy has
+ /// been satisfied. Unlike user deletion, this deliberately leaves the
+ /// account's Archive record intact.
+ func enqueueRetireOwnedGameZone(_ zoneID: CKRecordZone.ID) {
+ guard let privateEngine else { return }
+ privateEngine.state.add(pendingDatabaseChanges: [.deleteZone(zoneID)])
+ sendChangesDetached(on: privateEngine)
+ }
+
+ /// Removes a migrated legacy per-game archive zone after the compact record
+ /// has saved in the common archive zone.
+ func enqueueDeleteLegacyArchiveZone(_ zoneID: CKRecordZone.ID) {
+ guard let privateEngine else { return }
+ privateEngine.state.add(pendingDatabaseChanges: [.deleteZone(zoneID)])
sendChangesDetached(on: privateEngine)
}
@@ -778,6 +800,7 @@ actor SyncEngine {
/// user-facing play events go through the push worker. Sender-only
/// state: the payload is committed to a durable outbox before the
/// CKSyncEngine save is queued, then retained until CloudKit confirms it.
+ @discardableResult
func enqueuePing(
kind: PingKind,
gameID: UUID,
@@ -785,7 +808,7 @@ actor SyncEngine {
playerName: String,
payload: String? = nil,
addressee: String? = nil
- ) async {
+ ) async -> Bool {
let ctx = persistence.container.newBackgroundContext()
let zoneAndTitle: (info: ZoneInfo, title: String)? = await ctx.perform {
guard let info = self.zoneInfo(forGameID: gameID, in: ctx) else { return nil }
@@ -802,7 +825,7 @@ actor SyncEngine {
"game=\(gameID.uuidString) " +
"— no zone info (game not yet synced/shared on this device)"
)
- return
+ return false
}
let engine = zoneAndTitle.info.scope == .shared ? sharedEngine : privateEngine
guard let engine else {
@@ -812,7 +835,7 @@ actor SyncEngine {
"— no CKSyncEngine for " +
"\(zoneAndTitle.info.scope == .shared ? "shared" : "private") scope"
)
- return
+ return false
}
let deviceID = RecordSerializer.localDeviceID
let eventTimestampMs = Int64(Date().timeIntervalSince1970 * 1000)
@@ -840,7 +863,7 @@ actor SyncEngine {
try await storePendingPing(ping, recordName: recordName)
} catch {
await trace("ping send: failed to persist outbox record \(recordName) — \(error)")
- return
+ return false
}
let recordID = CKRecord.ID(recordName: recordName, zoneID: zoneAndTitle.info.zoneID)
engine.state.add(pendingRecordZoneChanges: [.saveRecord(recordID)])
@@ -851,6 +874,7 @@ actor SyncEngine {
"zone=\(zoneAndTitle.info.zoneID.zoneName) record=\(recordName)"
)
sendChangesDetached(on: engine)
+ return true
}
/// Registers an `.opened` Ping for cross-device notification dismissal.
@@ -1793,11 +1817,11 @@ actor SyncEngine {
) {
effects.journalsSynced.insert(gid)
}
- case Archive.recordType:
- // A finished shared game this user archived to their own
+ case Archive.recordType, Archive.legacyRecordType:
+ // A compact Chronicle or legacy Archive in this user's
// private DB. Inert where the live original still exists;
- // hydrated into a standalone completed game on a device that
- // lacks it (fresh install / after the original was revoked).
+ // hydrated into a standalone completed game on a device
+ // that lacks it (fresh install / after revocation).
if let id = self.applyArchiveRecord(
record,
in: ctx,
diff --git a/Tests/Unit/ArchiveTests.swift b/Tests/Unit/ArchiveTests.swift
@@ -124,9 +124,13 @@ struct ArchiveTests {
let original = UUID()
let name = Archive.recordName(forOriginalGameID: original)
#expect(Archive.originalGameID(fromName: name) == original)
- #expect(Archive.isArchiveZone(
- Archive.zoneID(forOriginalGameID: original).zoneName
- ))
+ #expect(Archive.originalGameID(
+ fromName: Archive.legacyRecordName(forOriginalGameID: original)
+ ) == original)
+ #expect(Archive.isArchiveZone(Archive.zoneID.zoneName))
+ #expect(Archive.isArchiveZone(Archive.legacyZoneID(
+ forOriginalGameID: original
+ ).zoneName))
#expect(Archive.originalGameID(fromName: "game-\(original.uuidString)") == nil)
}
@@ -138,7 +142,7 @@ struct ArchiveTests {
let snapshot = sampleSnapshot(originalGameID: original)
let payload = try withArchiveRecord(from: snapshot) { record in
#expect(record.recordType == Archive.recordType)
- #expect(record.recordID.zoneID.zoneName == "archive-\(original.uuidString)")
+ #expect(record.recordID.zoneID.zoneName == Archive.zoneName)
return try #require(Archive.payload(from: record))
}
#expect(payload.originalGameID == original)
@@ -163,37 +167,20 @@ struct ArchiveTests {
}
}
- #expect(package.temporaryAssetFileURLs.count == 3)
- #expect(Set(package.temporaryAssetFileURLs).count == 3)
+ #expect(package.temporaryAssetFileURLs.count == 1)
for url in package.temporaryAssetFileURLs {
#expect(FileManager.default.fileExists(atPath: url.path))
}
- let assetURLs = [
- (package.record["puzzleSource"] as? CKAsset)?.fileURL,
- (package.record["cells"] as? CKAsset)?.fileURL,
- (package.record["journals"] as? CKAsset)?.fileURL,
- ]
+ let assetURLs = [(package.record[Archive.payloadKey] as? CKAsset)?.fileURL]
#expect(Set(assetURLs.compactMap { $0 }) == Set(package.temporaryAssetFileURLs))
}
- // MARK: - Inbound asset bounds
-
- /// Mirrors the private `DeviceJournalWire` shape so tests can hand-build
- /// hostile `journals` asset payloads.
- private struct WireJournal: Codable {
- let authorID: String
- let deviceID: String
- let entries: Data
- }
+ // MARK: - Compressed payload bounds
- /// Builds a valid archive record, then replaces the asset at `key` with a
- /// file containing `data`, and runs `body` over the resulting payload and
- /// any diagnostics `Archive.payload` reported.
private func withTamperedPayload<T>(
- key: String,
data: Data,
- _ body: (Archive.Payload, [String]) throws -> T
+ _ body: (Archive.Payload?, [String]) throws -> T
) throws -> T {
let package = try Archive.recordPackage(from: sampleSnapshot(originalGameID: UUID()))
let tampered = FileManager.default.temporaryDirectory
@@ -204,58 +191,37 @@ struct ArchiveTests {
try? FileManager.default.removeItem(at: url)
}
}
- package.record[key] = CKAsset(fileURL: tampered)
+ package.record[Archive.payloadKey] = CKAsset(fileURL: tampered)
var diagnostics: [String] = []
- let payload = try #require(
- Archive.payload(from: package.record, onDiagnostic: { diagnostics.append($0) })
+ let payload = Archive.payload(
+ from: package.record,
+ onDiagnostic: { diagnostics.append($0) }
)
return try body(payload, diagnostics)
}
- @Test("an oversized puzzleSource asset is rejected before reading, failing materialization closed")
- func oversizedPuzzleSourceAssetRejected() throws {
+ @Test("an oversized compressed payload is rejected before reading")
+ func oversizedPayloadRejected() throws {
try withTamperedPayload(
- key: "puzzleSource",
- data: Data(count: XD.maxSourceBytes + 1)
+ data: Data(count: Archive.maxPayloadAssetBytes + 1)
) { payload, diagnostics in
- #expect(payload.puzzleSource.isEmpty)
+ #expect(payload == nil)
#expect(diagnostics.count == 1)
- #expect(diagnostics.first?.contains("puzzleSource") == true)
+ #expect(diagnostics.first?.contains("payload") == true)
#expect(diagnostics.first?.contains("exceeds") == true)
- // An empty puzzleSource is exactly what materialize refuses.
- let persistence = makeTestPersistence()
- #expect(Archive.materialize(payload, in: persistence.viewContext) == nil)
}
}
- @Test("an oversized cells asset is rejected before reading")
- func oversizedCellsAssetRejected() throws {
- try withTamperedPayload(
- key: "cells",
- data: Data(count: Archive.maxCellsAssetBytes + 1)
- ) { payload, diagnostics in
- #expect(payload.cells.isEmpty)
- #expect(diagnostics.first?.contains("cells") == true)
- #expect(diagnostics.first?.contains("exceeds") == true)
- // The rest of the payload still lands.
- #expect(!payload.puzzleSource.isEmpty)
- #expect(!payload.journal.isEmpty)
- }
- }
-
- @Test("an oversized journals asset is rejected before reading")
- func oversizedJournalsAssetRejected() throws {
- try withTamperedPayload(
- key: "journals",
- data: Data(count: Archive.maxJournalsAssetBytes + 1)
- ) { payload, diagnostics in
- #expect(payload.journal.isEmpty)
- #expect(diagnostics.first?.contains("journals") == true)
- #expect(diagnostics.first?.contains("exceeds") == true)
+ @Test("a corrupt compressed payload fails closed")
+ func corruptPayloadRejected() throws {
+ try withTamperedPayload(data: Data("not an archive".utf8)) { payload, diagnostics in
+ #expect(payload == nil)
+ #expect(diagnostics.count == 1)
+ #expect(diagnostics.first?.contains("malformed") == true)
}
}
- @Test("a cells asset over the cell-count cap is rejected whole")
+ @Test("a compressed payload over the cell-count cap is rejected whole")
func cellCountExhaustionRejected() throws {
let cells = (0...Archive.maxCellCount).map {
Archive.Cell(
@@ -263,52 +229,138 @@ struct ArchiveTests {
letter: "A", markCode: 0, letterAuthorID: nil
)
}
- let data = try JSONEncoder().encode(cells)
- // Under the byte cap, so the count gate is what fires.
- #expect(data.count <= Archive.maxCellsAssetBytes)
- try withTamperedPayload(key: "cells", data: data) { payload, diagnostics in
- #expect(payload.cells.isEmpty)
+ let base = sampleSnapshot(originalGameID: UUID())
+ let snapshot = Archive.Snapshot(
+ originalGameID: base.originalGameID,
+ title: base.title,
+ puzzleSource: base.puzzleSource,
+ completedAt: base.completedAt,
+ completedBy: base.completedBy,
+ solveSeconds: base.solveSeconds,
+ cells: cells,
+ journal: base.journal
+ )
+ try withArchiveRecord(from: snapshot) { record in
+ var diagnostics: [String] = []
+ #expect(Archive.payload(from: record, onDiagnostic: { diagnostics.append($0) }) == nil)
#expect(diagnostics.first?.contains("cells") == true)
}
}
- @Test("a journals asset over the device-count cap is rejected whole")
+ @Test("a compressed payload over the device-count cap is rejected whole")
func journalDeviceCountExhaustionRejected() throws {
- let empty = try JournalCodec.encode([])
- let wire = (0...Archive.maxJournalDeviceCount).map {
- WireJournal(authorID: "author\($0)", deviceID: "device", entries: empty)
+ let base = sampleSnapshot(originalGameID: UUID())
+ let journals = (0...Archive.maxJournalDeviceCount).map {
+ DeviceJournal(
+ key: JournalDeviceKey(authorID: "author\($0)", deviceID: "device"),
+ entries: []
+ )
}
- let data = try JSONEncoder().encode(wire)
- #expect(data.count <= Archive.maxJournalsAssetBytes)
- try withTamperedPayload(key: "journals", data: data) { payload, diagnostics in
- #expect(payload.journal.isEmpty)
- #expect(diagnostics.first?.contains("journals") == true)
+ let snapshot = Archive.Snapshot(
+ originalGameID: base.originalGameID,
+ title: base.title,
+ puzzleSource: base.puzzleSource,
+ completedAt: base.completedAt,
+ completedBy: base.completedBy,
+ solveSeconds: base.solveSeconds,
+ cells: base.cells,
+ journal: journals
+ )
+ try withArchiveRecord(from: snapshot) { record in
+ var diagnostics: [String] = []
+ #expect(Archive.payload(from: record, onDiagnostic: { diagnostics.append($0) }) == nil)
+ #expect(diagnostics.first?.contains("device journals") == true)
}
}
- @Test("one device's over-limit journal blob degrades to an empty log without sinking the archive")
- func perDeviceJournalOverLimitDegradesEmpty() throws {
- let hostile = (0...JournalCodec.maxEntryCount).map {
- journalValue(seq: Int64($0), row: 0, col: 0, letter: "A", actingAuthorID: "alice")
+ @Test("a replay-disabled archive omits journals and preserves the final state")
+ func replayDisabledPayload() throws {
+ let snapshot = sampleSnapshot(originalGameID: UUID())
+ let package = try Archive.recordPackage(from: snapshot, replayAvailable: false)
+ defer { package.temporaryAssetFileURLs.forEach { try? FileManager.default.removeItem(at: $0) } }
+ let payload = try #require(Archive.payload(from: package.record))
+ #expect(!payload.replayAvailable)
+ #expect(payload.journal.isEmpty)
+ #expect(payload.cells == snapshot.cells)
+ #expect(payload.puzzleSource == snapshot.puzzleSource)
+ let persistence = makeTestPersistence()
+ let game = try #require(Archive.materialize(payload, in: persistence.viewContext))
+ #expect(game.replayUnavailable)
+ #expect(!game.replayCacheComplete)
+ }
+
+ @Test("the archive payload is compressed")
+ func payloadIsCompressed() throws {
+ let base = sampleSnapshot(originalGameID: UUID())
+ let snapshot = Archive.Snapshot(
+ originalGameID: base.originalGameID,
+ title: base.title,
+ puzzleSource: String(repeating: base.puzzleSource, count: 20),
+ completedAt: base.completedAt,
+ completedBy: base.completedBy,
+ solveSeconds: base.solveSeconds,
+ cells: base.cells,
+ journal: base.journal
+ )
+ let package = try Archive.recordPackage(from: snapshot)
+ defer { package.temporaryAssetFileURLs.forEach { try? FileManager.default.removeItem(at: $0) } }
+ let url = try #require((package.record[Archive.payloadKey] as? CKAsset)?.fileURL)
+ let compressedBytes = try Data(contentsOf: url).count
+ let uncompressedComponents = Data(snapshot.puzzleSource.utf8).count
+ + (try JSONEncoder().encode(snapshot.cells)).count
+ + snapshot.journal.reduce(0) { total, journal in
+ total + ((try? JournalCodec.encode(journal.entries).count) ?? 0)
+ }
+ #expect(compressedBytes < uncompressedComponents)
+ }
+
+ @Test("legacy three-asset records remain readable")
+ func legacyRecordReadable() throws {
+ struct WireJournal: Codable {
+ let authorID: String
+ let deviceID: String
+ let entries: Data
}
- let wire = [
- WireJournal(
- authorID: "alice", deviceID: "deviceA",
- entries: try JournalCodec.encode(hostile)
- ),
+ let snapshot = sampleSnapshot(originalGameID: UUID())
+ let zone = Archive.legacyZoneID(forOriginalGameID: snapshot.originalGameID)
+ let record = CKRecord(
+ recordType: Archive.legacyRecordType,
+ recordID: CKRecord.ID(
+ recordName: Archive.legacyRecordName(
+ forOriginalGameID: snapshot.originalGameID
+ ),
+ zoneID: zone
+ )
+ )
+ record["originalGameID"] = snapshot.originalGameID.uuidString as CKRecordValue
+ record["archiveGameID"] = Archive.archiveGameID(for: snapshot.originalGameID).uuidString as CKRecordValue
+ record["title"] = snapshot.title as CKRecordValue
+ record["completedAt"] = snapshot.completedAt as CKRecordValue
+ record["completedBy"] = snapshot.completedBy as CKRecordValue?
+ record["solveSeconds"] = Int64(snapshot.solveSeconds) as CKRecordValue
+
+ let files = (0..<3).map { _ in
+ FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
+ }
+ defer { files.forEach { try? FileManager.default.removeItem(at: $0) } }
+ try Data(snapshot.puzzleSource.utf8).write(to: files[0])
+ try JSONEncoder().encode(snapshot.cells).write(to: files[1])
+ let journals = try snapshot.journal.map {
WireJournal(
- authorID: "bob", deviceID: "deviceB",
- entries: try JournalCodec.encode([
- journalValue(seq: 0, row: 0, col: 1, letter: "B", actingAuthorID: "bob"),
- ])
- ),
- ]
- let data = try JSONEncoder().encode(wire)
- try withTamperedPayload(key: "journals", data: data) { payload, _ in
- let journals = normalized(payload.journal)
- #expect(journals[aliceKey]?.isEmpty == true)
- #expect(journals[bobKey]?.count == 1)
+ authorID: $0.key.authorID,
+ deviceID: $0.key.deviceID,
+ entries: try JournalCodec.encode($0.entries)
+ )
}
+ try JSONEncoder().encode(journals).write(to: files[2])
+ record["puzzleSource"] = CKAsset(fileURL: files[0])
+ record["cells"] = CKAsset(fileURL: files[1])
+ record["journals"] = CKAsset(fileURL: files[2])
+
+ let payload = try #require(Archive.payload(from: record))
+ #expect(payload.replayAvailable)
+ #expect(payload.originalGameID == snapshot.originalGameID)
+ #expect(normalized(payload.journal) == normalized(snapshot.journal))
}
// MARK: - Convergence merge
@@ -470,6 +522,22 @@ struct ArchiveTests {
))
}
+ @Test("migration grace gives old completions 14 days from v1.1.0 adoption")
+ func archiveRetryWindowUsesLaterMigrationStart() {
+ let completedAt = Date(timeIntervalSince1970: 1_600_000_000)
+ let graceStart = Date(timeIntervalSince1970: 1_700_000_000)
+ #expect(!GameArchiver.hasArchiveRetryExpired(
+ completedAt: completedAt,
+ graceStart: graceStart,
+ now: graceStart.addingTimeInterval(GameArchiver.archiveRetryWindow - 1)
+ ))
+ #expect(GameArchiver.hasArchiveRetryExpired(
+ completedAt: completedAt,
+ graceStart: graceStart,
+ now: graceStart.addingTimeInterval(GameArchiver.archiveRetryWindow)
+ ))
+ }
+
@Test("materialize is idempotent — a second application creates no duplicate")
func materializeIdempotent() throws {
let persistence = makeTestPersistence()
@@ -489,6 +557,36 @@ struct ArchiveTests {
#expect(try ctx.count(for: req) == 1)
}
+ @Test("a complete archive replaces an earlier no-replay fallback")
+ func materializeUpgradesReplayFallback() throws {
+ let persistence = makeTestPersistence()
+ let ctx = persistence.viewContext
+ let original = UUID()
+ let snapshot = sampleSnapshot(originalGameID: original)
+
+ let fallback = Archive.payload(from: snapshot, replayAvailable: false)
+ let initial = try #require(Archive.materialize(fallback, in: ctx))
+ try ctx.save()
+ #expect(initial.replayUnavailable)
+ #expect(((initial.journal as? Set<JournalEntity>) ?? []).isEmpty)
+
+ let complete = Archive.payload(from: snapshot, replayAvailable: true)
+ let upgraded = try #require(Archive.materialize(complete, in: ctx))
+ try ctx.save()
+
+ #expect(upgraded === initial)
+ #expect(!upgraded.replayUnavailable)
+ #expect(upgraded.replayCacheComplete)
+ #expect(((upgraded.journal as? Set<JournalEntity>) ?? []).count == 3)
+
+ let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
+ req.predicate = NSPredicate(
+ format: "id == %@",
+ Archive.archiveGameID(for: original) as CVarArg
+ )
+ #expect(try ctx.count(for: req) == 1)
+ }
+
// MARK: - Dedup in the inbound applier
@Test("applier skips materialization while a live original exists")
diff --git a/cloudkit.ckdb b/cloudkit.ckdb
@@ -21,6 +21,19 @@ DEFINE SCHEMA
GRANT READ TO "_world"
);
+ RECORD TYPE Chronicle (
+ "___createTime" TIMESTAMP,
+ "___createdBy" REFERENCE,
+ "___etag" STRING,
+ "___modTime" TIMESTAMP,
+ "___modifiedBy" REFERENCE,
+ "___recordID" REFERENCE,
+ payload ASSET,
+ GRANT WRITE TO "_creator",
+ GRANT CREATE TO "_icloud",
+ GRANT READ TO "_world"
+ );
+
RECORD TYPE Decision (
"___createTime" TIMESTAMP,
"___createdBy" REFERENCE,