crossmate

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

commit f79951c49f4dd5b835fc80baea32f573c6784aa2
parent a343ce72c0e73ecc997777a384157d6f7cd903db
Author: Michael Camilleri <[email protected]>
Date:   Sun,  2 Aug 2026 23:29:31 +0900

Fetch completedAt when reading a Chronicle for the ledger

The ledger backfill recorded nothing, so the External calendar still
left compacted puzzles unmarked after a launch in which it had fetched
every archive in the zone. It asked CloudKit for the payload asset
alone, but payload(from:) cross-checks the record's completedAt against
the completion time inside the blob — a record fetched without that key
fails the identity guard exactly as a tampered one would — so every
decode returned nil and the pass wrote no rows.

This commit names both keys as payloadDesiredKeys and reads them through
that constant, in the backfill and in materializeChronicles, which
carried the correct pair inline.

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

Diffstat:
MCrossmate/Persistence/ChronicleLedger.swift | 7++++---
MCrossmate/Sync/Archive.swift | 6++++++
MCrossmate/Sync/GameArchiver.swift | 43+++++++++++++++++++++++++------------------
MTests/Unit/ArchiveTests.swift | 19+++++++++++++++++++
4 files changed, 54 insertions(+), 21 deletions(-)

diff --git a/Crossmate/Persistence/ChronicleLedger.swift b/Crossmate/Persistence/ChronicleLedger.swift @@ -25,9 +25,10 @@ extension ChronicleLedgerEntity { participants: String?, in ctx: NSManagedObjectContext ) { - // Nothing to answer the browser's question with, and a row with no date - // would only have to be re-fetched later to find that out again. - guard puzzleDate != nil else { return } + // Written even when the puzzle carries no publication date. Such a row + // marks no day — readers require a date — but it records that the + // payload has been decoded, so the backfill doesn't re-fetch the same + // archive on every launch to reach the same conclusion. let entity = existing(originalGameID: originalGameID, in: ctx) ?? ChronicleLedgerEntity(context: ctx) entity.originalGameID = originalGameID diff --git a/Crossmate/Sync/Archive.swift b/Crossmate/Sync/Archive.swift @@ -35,6 +35,12 @@ enum Archive { static let zoneName = "completed-archives" static let payloadKey = "payload" + /// The keys any fetch must ask for to decode a Chronicle. `completedAt` is + /// not optional extra metadata: `payload(from:)` cross-checks it against the + /// blob's own completion time, and a record fetched without it fails that + /// identity guard exactly as a tampered one would. + static let payloadDesiredKeys = ["completedAt", payloadKey] + // MARK: - Inbound asset bounds /// Byte cap on the `cells` asset, checked on disk before it is read. The diff --git a/Crossmate/Sync/GameArchiver.swift b/Crossmate/Sync/GameArchiver.swift @@ -612,13 +612,16 @@ final class GameArchiver { let outstanding = identified.filter { !known.contains($0.originalGameID) } guard !outstanding.isEmpty else { return } let batch = Array(outstanding.prefix(Self.chronicleLedgerBackfillLimit)) - await backfillChronicleLedger(batch.map(\.recordID)) - if outstanding.count > batch.count { - eventLog?.note( - "GameArchiver: chronicle ledger backfilled \(batch.count); " + - "\(outstanding.count - batch.count) remaining for a later launch" - ) - } + let written = await backfillChronicleLedger(batch.map(\.recordID)) + // Reported whether or not anything was written: a run that reads + // payloads and records none is the signature of a decode that is + // failing silently, and is otherwise invisible from the device. + eventLog?.note( + "GameArchiver: chronicle ledger backfilled \(written) of " + + "\(batch.count) fetched; \(outstanding.count - batch.count) " + + "remaining for a later launch", + level: written < batch.count ? "error" : "info" + ) } /// Every Chronicle in the archive zone, metadata only. @@ -641,29 +644,32 @@ final class GameArchiver { /// `GameEntity` row is created: a Chronicle that isn't in the Game List's /// window has no business appearing in the library, and materialising it /// would only be undone by the next trim. - private func backfillChronicleLedger(_ recordIDs: [CKRecord.ID]) async { - guard !recordIDs.isEmpty else { return } + @discardableResult + private func backfillChronicleLedger(_ recordIDs: [CKRecord.ID]) async -> Int { + guard !recordIDs.isEmpty else { return 0 } let database = container.privateCloudDatabase let result = try? await database.records( for: recordIDs, - desiredKeys: [Archive.payloadKey] + desiredKeys: Archive.payloadDesiredKeys ) - guard let result else { return } + guard let result else { return 0 } let payloads = result.values.compactMap { item -> Archive.Payload? in guard let record = try? item.get() else { return nil } return Archive.payload(from: record) } - guard !payloads.isEmpty else { return } + guard !payloads.isEmpty else { return 0 } let ctx = persistence.container.newBackgroundContext() - await ctx.perform { + return await ctx.perform { for payload in payloads { - guard let xd = try? XD.parse(payload.puzzleSource) else { continue } - let puzzle = Puzzle(xd: xd) + // An unparseable archive still earns its row: the decode has + // been paid for once, and without a row the backfill would + // fetch it again on every launch. + let puzzle = (try? XD.parse(payload.puzzleSource)).map(Puzzle.init(xd:)) ChronicleLedgerEntity.upsert( originalGameID: payload.originalGameID, - publisher: puzzle.publisher, - puzzleDate: puzzle.date, + publisher: puzzle?.publisher, + puzzleDate: puzzle?.date, participants: payload.wasShared ? payload.participants.map(\.authorID).sorted().joined(separator: ",") : nil, @@ -671,6 +677,7 @@ final class GameArchiver { ) } if ctx.hasChanges { try? ctx.save() } + return payloads.count } } @@ -859,7 +866,7 @@ final class GameArchiver { let database = container.privateCloudDatabase let result = try? await database.records( for: recordIDs, - desiredKeys: ["completedAt", Archive.payloadKey] + desiredKeys: Archive.payloadDesiredKeys ) guard let result else { return } let records = result.compactMap { _, item in try? item.get() } diff --git a/Tests/Unit/ArchiveTests.swift b/Tests/Unit/ArchiveTests.swift @@ -142,6 +142,25 @@ struct ArchiveTests { } } + /// A fetch that asks for fewer keys than `payloadDesiredKeys` returns a + /// record whose missing `completedAt` fails the identity guard, so the + /// payload decodes to nil and the caller sees an empty result rather than + /// an error. Pins the constant as the contract every Chronicle fetch reads. + @Test("A Chronicle fetched with payloadDesiredKeys still decodes") + func payloadDesiredKeysSufficeToDecode() throws { + let snapshot = sampleSnapshot(originalGameID: UUID()) + try withArchiveRecord(from: snapshot) { record in + for key in record.allKeys() + where !Archive.payloadDesiredKeys.contains(key) { + record[key] = nil as (any CKRecordValueProtocol)? + } + #expect(Archive.payload(from: record)?.originalGameID == snapshot.originalGameID) + + record["completedAt"] = nil as (any CKRecordValueProtocol)? + #expect(Archive.payload(from: record) == nil) + } + } + @Test("Chronicle rejects completion metadata that disagrees with its payload") func mismatchedCompletionMetadataRejected() throws { let snapshot = sampleSnapshot(originalGameID: UUID())