commit a343ce72c0e73ecc997777a384157d6f7cd903db
parent 11e8e8bcb46ef0f3ef6916736e568315541e18e3
Author: Michael Camilleri <[email protected]>
Date: Sun, 2 Aug 2026 23:17:24 +0900
Keep marking External calendar days after compaction
The External calendar marked only the puzzles the device still held. A
completed game is compacted into a Chronicle and its local row evicted
once it falls outside the Game List's seven-day window, so anything
finished earlier went unmarked — on a library of any age, nearly the
whole history — and the 'Puzzle Already in Library' alert missed those
puzzles too. The question cannot be put to CloudKit cheaply either: a
Chronicle carries completedAt and an opaque payload asset, and the
publication date lives inside the blob.
This commit records that date, once, in a local ChronicleLedgerEntity.
The write sits in Archive.materialize, which every path that decodes a
payload funnels through, so the date is captured before the trim that
discards the row can reach it. nytPuzzleDatesInLibrary then unions the
ledger with the rows it already reads, skipping entries whose
collaborators are currently blocked — consulted at read time, so the
block table keeps its say without being mirrored onto the ledger.
The archive zone remains authoritative over what the user still holds.
deleteGame drops an entry alongside the record it names, and
reconcileChronicleLedger walks the zone's metadata once per cold launch
to prune entries for games deleted on another device and to backfill
archives this device has never decoded. The backfill is capped per run,
so a long history converges over several launches rather than one long
one, and it writes no GameEntity rows: a Chronicle outside the Game
List's window has no business in the library. The Chronicle record shape
is unchanged.
Co-Authored-By: Claude Opus 5 <[email protected]>
Diffstat:
8 files changed, 397 insertions(+), 2 deletions(-)
diff --git a/Crossmate.xcodeproj/project.pbxproj b/Crossmate.xcodeproj/project.pbxproj
@@ -204,6 +204,7 @@
C1D97A4CD02BC9C22C4208BB /* NYTAuthServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED8154F949E1D94252F70765 /* NYTAuthServiceTests.swift */; };
C30C0C4E54E4209A22843872 /* CrossmateModel.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = F93AC31640C40FCC039570A3 /* CrossmateModel.xcdatamodeld */; };
C472EF02D8C7B0AC1D2284B8 /* CustomButtons.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9B757D86362CD6F0500E9CB /* CustomButtons.swift */; };
+ C4D4433FAC1BBE14388FBCC9 /* ChronicleLedger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 48328BAB53475A2063429A98 /* ChronicleLedger.swift */; };
C511387D9FFBCC2E2F5EF699 /* NotificationService.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 51318FC5DAE02D35CB005729 /* NotificationService.appex */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
C58F15CBEADA72032B54009D /* ReplayControlsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 847415468DBB1C566D18BC17 /* ReplayControlsTests.swift */; };
C6CC673F2D4BB8A1C0439FBB /* PushClientTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63AE7146492AD286EF13C3E7 /* PushClientTests.swift */; };
@@ -369,6 +370,7 @@
465F2BB469EFE84CF3733398 /* Game.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Game.swift; sourceTree = "<group>"; };
46801B570FC0B2C791ECDED3 /* PlayerSessionNavigationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerSessionNavigationTests.swift; sourceTree = "<group>"; };
47532AED239AEF476D8E9206 /* NotificationStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationStateTests.swift; sourceTree = "<group>"; };
+ 48328BAB53475A2063429A98 /* ChronicleLedger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChronicleLedger.swift; sourceTree = "<group>"; };
48EC33BC7DDAC3B7F29C3F02 /* AppDelegatePushBufferTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegatePushBufferTests.swift; sourceTree = "<group>"; };
4A467BC00116EEC8500BE6A1 /* PersistenceRecoveryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistenceRecoveryTests.swift; sourceTree = "<group>"; };
4B166E87D13C41C4D3575169 /* AccountPushCoordinatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountPushCoordinatorTests.swift; sourceTree = "<group>"; };
@@ -766,6 +768,7 @@
565DBAFC8DB2589B3F0AF90E /* Persistence */ = {
isa = PBXGroup;
children = (
+ 48328BAB53475A2063429A98 /* ChronicleLedger.swift */,
43DC132D49361C56DE79C13E /* GameMutator.swift */,
93EE5BA78566EDED68D846AB /* GameStore.swift */,
CF3D29B227D2B0E699423C48 /* Journal.swift */,
@@ -1227,6 +1230,7 @@
6BE7E91158F4DF1F71247C6D /* CellMark.swift in Sources */,
E9A65F3548DD062FD36A19E1 /* CellPatterns.swift in Sources */,
5FB26F40F5DB52111E3D1BDC /* CheckResult.swift in Sources */,
+ C4D4433FAC1BBE14388FBCC9 /* ChronicleLedger.swift in Sources */,
659F7E48357E164144A19789 /* CloudContainer.swift in Sources */,
E15A40AA623B60279E8DDF43 /* CloudDiagnostics.swift in Sources */,
1016604FBD4D63A0B9AAE503 /* CloudQuery.swift in Sources */,
diff --git a/Crossmate/Models/CrossmateModel.xcdatamodeld/CrossmateModel.xcdatamodel/contents b/Crossmate/Models/CrossmateModel.xcdatamodeld/CrossmateModel.xcdatamodel/contents
@@ -183,6 +183,15 @@
<fetchIndexElement property="seq" type="Binary" order="ascending"/>
</fetchIndex>
</entity>
+ <entity name="ChronicleLedgerEntity" representedClassName="ChronicleLedgerEntity" syncable="YES" codeGenerationType="class">
+ <attribute name="originalGameID" attributeType="UUID" usesScalarValueType="NO"/>
+ <attribute name="participants" optional="YES" attributeType="String"/>
+ <attribute name="publisher" optional="YES" attributeType="String"/>
+ <attribute name="puzzleDate" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
+ <fetchIndex name="byOriginalGameID">
+ <fetchIndexElement property="originalGameID" type="Binary" order="ascending"/>
+ </fetchIndex>
+ </entity>
<entity name="PeerChangeEntity" representedClassName="PeerChangeEntity" syncable="YES" codeGenerationType="class">
<attribute name="authorID" optional="YES" attributeType="String"/>
<attribute name="changedAt" attributeType="Date" usesScalarValueType="NO"/>
diff --git a/Crossmate/Persistence/ChronicleLedger.swift b/Crossmate/Persistence/ChronicleLedger.swift
@@ -0,0 +1,113 @@
+import CoreData
+import Foundation
+
+/// A local-only note of which puzzle each Chronicle holds.
+///
+/// Completed games are compacted into Chronicle records and their local rows
+/// are evicted once they fall outside the Game List's window, so the store on
+/// its own can't answer "have I played this puzzle before?" for anything older
+/// than a few days. The Chronicle carries only `completedAt` and an opaque
+/// payload — publication date lives inside the blob — so the question can't be
+/// asked of CloudKit cheaply either.
+///
+/// This ledger records the answer the one time a payload is decoded, and
+/// survives the eviction that discards the row. It is derived state, not a
+/// second source of truth: the archive zone's record list stays authoritative,
+/// and `retainOnly(_:in:)` drops anything it no longer contains.
+extension ChronicleLedgerEntity {
+ /// Records what a decoded payload said about its puzzle. Existing rows are
+ /// updated in place: `originalGameID` is the identity, and a Chronicle
+ /// rewritten with a longer journal describes the same puzzle as before.
+ static func upsert(
+ originalGameID: UUID,
+ publisher: String?,
+ puzzleDate: Date?,
+ 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 }
+ let entity = existing(originalGameID: originalGameID, in: ctx)
+ ?? ChronicleLedgerEntity(context: ctx)
+ entity.originalGameID = originalGameID
+ entity.publisher = publisher
+ entity.puzzleDate = puzzleDate
+ entity.participants = participants
+ }
+
+ static func remove(originalGameID: UUID, in ctx: NSManagedObjectContext) {
+ guard let entity = existing(originalGameID: originalGameID, in: ctx) else { return }
+ ctx.delete(entity)
+ }
+
+ /// The archives this device has already decoded, so a backfill pass can ask
+ /// CloudKit only for the ones it has never seen.
+ static func knownOriginalGameIDs(in ctx: NSManagedObjectContext) -> Set<UUID> {
+ let request = NSFetchRequest<NSDictionary>(entityName: "ChronicleLedgerEntity")
+ request.resultType = .dictionaryResultType
+ request.propertiesToFetch = ["originalGameID"]
+ let rows = (try? ctx.fetch(request)) ?? []
+ return Set(rows.compactMap { $0["originalGameID"] as? UUID })
+ }
+
+ /// Drops rows whose Chronicle is no longer in the archive zone — the games
+ /// deleted on another device. Deletions made here prune inline instead.
+ /// Returns the number removed.
+ @discardableResult
+ static func retainOnly(
+ _ originalGameIDs: Set<UUID>,
+ in ctx: NSManagedObjectContext
+ ) -> Int {
+ let request = NSFetchRequest<ChronicleLedgerEntity>(entityName: "ChronicleLedgerEntity")
+ var removed = 0
+ for entity in (try? ctx.fetch(request)) ?? [] {
+ guard let id = entity.originalGameID, !originalGameIDs.contains(id) else { continue }
+ ctx.delete(entity)
+ removed += 1
+ }
+ return removed
+ }
+
+ /// Publication dates recorded for `publisher`, excluding any game featuring
+ /// a currently blocked collaborator. The block table is consulted at read
+ /// time rather than mirrored onto these rows, so blocking and unblocking
+ /// need no reconciliation pass of their own.
+ static func puzzleDates(
+ publisher: String,
+ blockedAuthorIDs: Set<String>,
+ in ctx: NSManagedObjectContext
+ ) -> [Date] {
+ let request = NSFetchRequest<NSDictionary>(entityName: "ChronicleLedgerEntity")
+ request.resultType = .dictionaryResultType
+ request.propertiesToFetch = ["puzzleDate", "participants"]
+ request.predicate = NSPredicate(
+ format: "publisher == %@ AND puzzleDate != nil",
+ publisher
+ )
+ let rows = (try? ctx.fetch(request)) ?? []
+ return rows.compactMap { row in
+ guard let date = row["puzzleDate"] as? Date else { return nil }
+ let participants = (row["participants"] as? String)
+ .map(Self.participantAuthorIDs) ?? []
+ guard blockedAuthorIDs.isDisjoint(with: participants) else { return nil }
+ return date
+ }
+ }
+
+ /// Matches `GameEntity.archiveParticipants`, which stores the sorted author
+ /// IDs of a shared game as one comma-separated string.
+ static func participantAuthorIDs(_ stored: String) -> Set<String> {
+ Set(stored.split(separator: ",").map(String.init).filter { !$0.isEmpty })
+ }
+
+ private static func existing(
+ originalGameID: UUID,
+ in ctx: NSManagedObjectContext
+ ) -> ChronicleLedgerEntity? {
+ let request = NSFetchRequest<ChronicleLedgerEntity>(entityName: "ChronicleLedgerEntity")
+ request.predicate = NSPredicate(format: "originalGameID == %@", originalGameID as CVarArg)
+ request.fetchLimit = 1
+ return try? ctx.fetch(request).first
+ }
+}
diff --git a/Crossmate/Persistence/GameStore.swift b/Crossmate/Persistence/GameStore.swift
@@ -556,7 +556,7 @@ extension GameEntity {
return (try? ctx.count(for: request)) == 1
}
- private static func blockedAuthorIDs(in ctx: NSManagedObjectContext) -> Set<String> {
+ static func blockedAuthorIDs(in ctx: NSManagedObjectContext) -> Set<String> {
let blockedReq = NSFetchRequest<FriendEntity>(entityName: "FriendEntity")
blockedReq.predicate = NSPredicate(format: "isBlocked == YES")
blockedReq.propertiesToFetch = ["authorID"]
@@ -1315,6 +1315,12 @@ final class GameStore {
/// rows in a live/Chronicle family carry the same publication date, and
/// the returned `Set` collapses the duplicate.
///
+ /// Rows only cover what is currently local, which is live games plus the
+ /// recently completed: everything older has been compacted into a Chronicle
+ /// and evicted. `ChronicleLedgerEntity` carries those dates, so the two are
+ /// unioned — each half derived from its own authority, and the `Set`
+ /// collapses a date that both report.
+ ///
/// Fetched as dictionaries rather than managed objects: the only column
/// wanted is a `Date`, and materialising `GameEntity` rows would drag the
/// whole library's `puzzleSource` XD text through the row cache with it.
@@ -1331,7 +1337,14 @@ final class GameStore {
"New York Times"
)
let rows = (try? context.fetch(request)) ?? []
- let dates = rows.compactMap { $0["cachedPuzzleDate"] as? Date }
+ var dates = rows.compactMap { $0["cachedPuzzleDate"] as? Date }
+ dates.append(
+ contentsOf: ChronicleLedgerEntity.puzzleDates(
+ publisher: "New York Times",
+ blockedAuthorIDs: GameEntity.blockedAuthorIDs(in: context),
+ in: context
+ )
+ )
var calendar = Calendar(identifier: .gregorian)
calendar.timeZone = TimeZone(identifier: "America/New_York") ?? .gmt
@@ -1478,6 +1491,12 @@ final class GameStore {
currentEntity = nil
}
+ // The archive record goes with the game, so its index entry goes too.
+ // Deletions made on another device are caught by the reconcile instead.
+ if hasArchive {
+ ChronicleLedgerEntity.remove(originalGameID: originalGameID, in: context)
+ }
+
context.delete(entity)
try context.save()
onGameDeleted(deletion)
diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift
@@ -1516,6 +1516,9 @@ final class AppServices {
hasStartedRemoteCompletedMigration = true
Task { @MainActor [weak self] in
await self?.gameArchiver.migrateMissingCompletedGames()
+ // After the migration, so an archive it compacts this launch is
+ // already in the ledger and needs no payload read of its own.
+ await self?.gameArchiver.reconcileChronicleLedger()
}
}
diff --git a/Crossmate/Sync/Archive.swift b/Crossmate/Sync/Archive.swift
@@ -929,6 +929,16 @@ enum Archive {
? payload.participants.map(\.authorID).sorted().joined(separator: ",")
: nil
entity.isSupersededByChronicle = false
+ // Every path that turns a Chronicle payload into a local row arrives
+ // here, so this is where the index learns which puzzle the archive
+ // holds — knowledge the eviction that follows would otherwise discard.
+ ChronicleLedgerEntity.upsert(
+ originalGameID: payload.originalGameID,
+ publisher: entity.cachedPublisher,
+ puzzleDate: entity.cachedPuzzleDate,
+ participants: entity.archiveParticipants,
+ in: ctx
+ )
// When the live row is still present, it owns the account's mutable
// unread watermark. Mirror that state before the Chronicle becomes the
// visible Completed tile (and before retirement may delete the live
diff --git a/Crossmate/Sync/GameArchiver.swift b/Crossmate/Sync/GameArchiver.swift
@@ -48,6 +48,12 @@ final class GameArchiver {
nonisolated static let archiveRetryWindow: TimeInterval = 14 * 24 * 60 * 60
nonisolated static let completedPageSize = 7
+ /// Payload reads allowed per ledger backfill run. A typical Chronicle is
+ /// tens of kilobytes, so this is a modest download for a library that has
+ /// never been indexed, and a long history simply converges over a few
+ /// launches instead of one long run.
+ nonisolated static let chronicleLedgerBackfillLimit = 50
+
struct CompletedPage: Sendable {
let oldestCompletedAt: Date?
let hasMore: Bool
@@ -566,6 +572,108 @@ final class GameArchiver {
}
}
+ /// Brings `ChronicleLedgerEntity` into line with the archive zone, which is
+ /// authoritative: rows whose Chronicle is gone are dropped, and archives
+ /// this device has never decoded are read once so the browser's calendar
+ /// can mark them.
+ ///
+ /// The walk is metadata only — record IDs and `completedAt`, 50 to a page —
+ /// so reconciliation costs one query for a typical library. Only the
+ /// backfill reads payloads, and it is capped per run: what it doesn't reach
+ /// stays unknown and is picked up on the next launch, since each run
+ /// re-derives the outstanding set rather than tracking a cursor.
+ ///
+ /// Deliberately walks its own cursor rather than the Game List's paging
+ /// state: `chronicleCursor` and `bufferedCompleted` belong to Load More,
+ /// and resetting them here would make the list re-read pages it has shown.
+ func reconcileChronicleLedger() async {
+ let identified: [(originalGameID: UUID, recordID: CKRecord.ID)]
+ do {
+ identified = try await allChronicleRecordIdentities()
+ } catch {
+ syncMonitor?.recordError("scan chronicle ledger", error)
+ return
+ }
+
+ let present = Set(identified.map(\.originalGameID))
+ let ctx = persistence.container.newBackgroundContext()
+ let known: Set<UUID> = await ctx.perform {
+ let removed = ChronicleLedgerEntity.retainOnly(present, in: ctx)
+ if ctx.hasChanges { try? ctx.save() }
+ if removed > 0 {
+ self.eventLog?.note(
+ "GameArchiver: chronicle ledger dropped \(removed) stale entr" +
+ (removed == 1 ? "y" : "ies")
+ )
+ }
+ return ChronicleLedgerEntity.knownOriginalGameIDs(in: ctx)
+ }
+
+ 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"
+ )
+ }
+ }
+
+ /// Every Chronicle in the archive zone, metadata only.
+ private func allChronicleRecordIdentities() async throws
+ -> [(originalGameID: UUID, recordID: CKRecord.ID)] {
+ var identities: [(originalGameID: UUID, recordID: CKRecord.ID)] = []
+ var cursor: CKQueryOperation.Cursor?
+ repeat {
+ let page = try await fetchChronicleMetadataPage(continuing: cursor)
+ cursor = page.cursor
+ for item in page.records {
+ guard case .chronicle(let recordID) = item.source else { continue }
+ identities.append((item.originalGameID, recordID))
+ }
+ } while cursor != nil
+ return identities
+ }
+
+ /// Reads payloads purely to learn which puzzle each archive holds. No
+ /// `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 }
+ let database = container.privateCloudDatabase
+ let result = try? await database.records(
+ for: recordIDs,
+ desiredKeys: [Archive.payloadKey]
+ )
+ guard let result else { return }
+ 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 }
+
+ let ctx = persistence.container.newBackgroundContext()
+ await ctx.perform {
+ for payload in payloads {
+ guard let xd = try? XD.parse(payload.puzzleSource) else { continue }
+ let puzzle = Puzzle(xd: xd)
+ ChronicleLedgerEntity.upsert(
+ originalGameID: payload.originalGameID,
+ publisher: puzzle.publisher,
+ puzzleDate: puzzle.date,
+ participants: payload.wasShared
+ ? payload.participants.map(\.authorID).sorted().joined(separator: ",")
+ : nil,
+ in: ctx
+ )
+ }
+ if ctx.hasChanges { try? ctx.save() }
+ }
+ }
+
/// One-launch migration backstop for completed live zones that this device
/// has never materialised. It begins only after the bounded Game List load
/// has returned, so old payloads cannot delay or expand the initial list.
diff --git a/Tests/Unit/GameStoreLibraryDatesTests.swift b/Tests/Unit/GameStoreLibraryDatesTests.swift
@@ -110,4 +110,133 @@ struct GameStoreLibraryDatesTests {
#expect(store.nytPuzzleDatesInLibrary() == [Self.nytDay(2020, 1, 1)])
}
+
+ // MARK: - Chronicle ledger
+
+ @discardableResult
+ private func addLedgerEntry(
+ publisher: String = "New York Times",
+ date: Date,
+ participants: String? = nil,
+ in ctx: NSManagedObjectContext
+ ) throws -> UUID {
+ let originalGameID = UUID()
+ ChronicleLedgerEntity.upsert(
+ originalGameID: originalGameID,
+ publisher: publisher,
+ puzzleDate: date,
+ participants: participants,
+ in: ctx
+ )
+ try ctx.save()
+ return originalGameID
+ }
+
+ private func addBlockedFriend(authorID: String, in ctx: NSManagedObjectContext) throws {
+ let friend = FriendEntity(context: ctx)
+ friend.authorID = authorID
+ friend.pairKey = "pair-\(authorID)"
+ friend.createdAt = Date()
+ friend.isBlocked = true
+ try ctx.save()
+ }
+
+ @Test("A compacted puzzle with no local row is still reported as held")
+ func reportsLedgerOnlyDates() throws {
+ let persistence = makeTestPersistence()
+ let store = makeTestStore(persistence: persistence)
+ try addLedgerEntry(date: Self.nytDay(2026, 7, 14), in: persistence.viewContext)
+
+ #expect(store.nytPuzzleDatesInLibrary() == [Self.nytDay(2026, 7, 14)])
+ }
+
+ @Test("A ledger entry for another publisher is excluded")
+ func excludesLedgerEntriesFromOtherPublishers() throws {
+ let persistence = makeTestPersistence()
+ let store = makeTestStore(persistence: persistence)
+ try addLedgerEntry(
+ publisher: "Crossmate",
+ date: Self.nytDay(2026, 7, 14),
+ in: persistence.viewContext
+ )
+
+ #expect(store.nytPuzzleDatesInLibrary().isEmpty)
+ }
+
+ @Test("A ledger entry featuring a blocked collaborator is not reported as held")
+ func excludesLedgerEntriesWithBlockedParticipants() throws {
+ let persistence = makeTestPersistence()
+ let store = makeTestStore(persistence: persistence)
+ try addBlockedFriend(authorID: "_bob", in: persistence.viewContext)
+ try addLedgerEntry(
+ date: Self.nytDay(2026, 7, 14),
+ participants: "_alice,_bob",
+ in: persistence.viewContext
+ )
+
+ #expect(store.nytPuzzleDatesInLibrary().isEmpty)
+ }
+
+ @Test("A ledger entry whose collaborators are unblocked is reported as held")
+ func includesLedgerEntriesWithUnblockedParticipants() throws {
+ let persistence = makeTestPersistence()
+ let store = makeTestStore(persistence: persistence)
+ try addBlockedFriend(authorID: "_carol", in: persistence.viewContext)
+ try addLedgerEntry(
+ date: Self.nytDay(2026, 7, 14),
+ participants: "_alice,_bob",
+ in: persistence.viewContext
+ )
+
+ #expect(store.nytPuzzleDatesInLibrary() == [Self.nytDay(2026, 7, 14)])
+ }
+
+ @Test("A date held as both a live row and a ledger entry collapses to one")
+ func deduplicatesAcrossRowAndLedger() throws {
+ let persistence = makeTestPersistence()
+ let store = makeTestStore(persistence: persistence)
+ try addGame(date: "2026-07-14", in: persistence.viewContext)
+ try addLedgerEntry(date: Self.nytDay(2026, 7, 14), in: persistence.viewContext)
+
+ #expect(store.nytPuzzleDatesInLibrary() == [Self.nytDay(2026, 7, 14)])
+ }
+
+ @Test("Deleting an archived game drops its ledger entry")
+ func deleteGamePrunesLedgerEntry() throws {
+ let persistence = makeTestPersistence()
+ let store = makeTestStore(persistence: persistence)
+ let ctx = persistence.viewContext
+ let game = try addGame(date: "2026-07-14", in: ctx)
+ let gameID = try #require(game.id)
+ // What compaction leaves behind: an archived live row whose publication
+ // date is also recorded in the ledger.
+ game.archivedAt = Date()
+ ChronicleLedgerEntity.upsert(
+ originalGameID: gameID,
+ publisher: "New York Times",
+ puzzleDate: Self.nytDay(2026, 7, 14),
+ participants: nil,
+ in: ctx
+ )
+ try ctx.save()
+
+ try store.deleteGame(id: gameID)
+
+ #expect(ChronicleLedgerEntity.knownOriginalGameIDs(in: ctx).isEmpty)
+ #expect(store.nytPuzzleDatesInLibrary().isEmpty)
+ }
+
+ @Test("Reconciling against the archive zone drops entries whose Chronicle is gone")
+ func retainOnlyDropsMissingChronicles() throws {
+ let persistence = makeTestPersistence()
+ let ctx = persistence.viewContext
+ let kept = try addLedgerEntry(date: Self.nytDay(2026, 7, 14), in: ctx)
+ try addLedgerEntry(date: Self.nytDay(2026, 7, 15), in: ctx)
+
+ let removed = ChronicleLedgerEntity.retainOnly([kept], in: ctx)
+ try ctx.save()
+
+ #expect(removed == 1)
+ #expect(ChronicleLedgerEntity.knownOriginalGameIDs(in: ctx) == [kept])
+ }
}