crossmate

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

commit 26703a665ada5331272668651d42560a4e05c3f7
parent c99a6b2c94ee9761432da1009d1102569083775b
Author: Michael Camilleri <[email protected]>
Date:   Mon,  6 Jul 2026 21:50:28 +0900

Keep a public share link's seat ticket present

A public share link is only joinable while its seat-ticket Ping exists:
a link joiner's seat check consumes a ticket seat, and a missing ticket
is read as no seat available, so every joiner is bounced with 'This
puzzle already has the maximum of three people' even when the puzzle is
empty. createShareLink saved the share as a public link before minting
the ticket, so when the mint threw — as it did while the ticket's
authorID field was still rejected by the production schema — the link
went live but ticketless and permanently unjoinable.

This commit makes createShareLink roll the link back to private when
setTicketSeats fails, so a failed mint surfaces the error and leaves no
live link rather than a poisoned one the caller cannot retry. It also
adds ensureTicketExists, called as the owner reopens an existing link,
which re-mints a ticket only when one is absent — reconstructing the
seat count from the share's current invitees and leaving a healthy
ticket untouched. Together these heal a link whose original mint failed
and keep the ticket, not the participant list, as the authoritative cap:
public-link joiners do not reliably appear in the participant list, so
the join-time check still declines to admit when no ticket is found.

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

Diffstat:
MCrossmate/Sync/ShareController.swift | 68++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
1 file changed, 62 insertions(+), 6 deletions(-)

diff --git a/Crossmate/Sync/ShareController.swift b/Crossmate/Sync/ShareController.swift @@ -137,12 +137,23 @@ final class ShareController { } catch let error as CKError where error.code == .serverRecordChanged { savedShare = try await recoverShareLinkAfterSaveConflict(error, for: gameID) } - try await setTicketSeats( - max(0, Self.maximumInviteesPerPuzzle - Self.inviteeCount(in: savedShare)), - claimedAuthorIDs: Self.inviteeAuthorIDs(in: savedShare), - for: gameID, - in: savedShare.recordID.zoneID - ) + // Mint the seat ticket only after the share is saved, but treat a + // mint failure as fatal to the whole operation. A live public link + // with no ticket rejects every joiner — their seat check finds no + // ticket to claim and bounces them. Roll the link back to private so + // we never hand out an unjoinable link, then surface the error so the + // caller can retry (e.g. once a missing schema field is deployed). + do { + try await setTicketSeats( + max(0, Self.maximumInviteesPerPuzzle - Self.inviteeCount(in: savedShare)), + claimedAuthorIDs: Self.inviteeAuthorIDs(in: savedShare), + for: gameID, + in: savedShare.recordID.zoneID + ) + } catch { + try? await disablePublicLinkIfNeeded(savedShare, for: gameID) + throw error + } let url = try shareURL(from: savedShare) syncMonitor?.note("share link created for \(gameID.uuidString): \(url.absoluteString)") syncMonitor?.recordSuccess("create share link") @@ -429,6 +440,11 @@ final class ShareController { return nil } guard share.publicPermission != .none else { return nil } + // Reusing an existing link is the moment to heal a ticket that never + // landed: a link whose original mint failed stays live but unjoinable + // until a ticket exists. Best-effort, and only mints when the ticket is + // absent so a healthy ticket's seat count is never reopened. + await ensureTicketExists(for: gameID, share: share) return share.url } @@ -724,6 +740,11 @@ final class ShareController { // Under the cap. A simultaneous link joiner may not be visible in the // participant list yet; consuming a ticket seat settles it atomically. + // A missing ticket counts as no seat available: the participant list is + // not an authoritative cap for public-link joiners (they don't reliably + // appear in it), so the ticket is the only real counter. The owner-side + // rollback and self-heal keep a live link's ticket present, so a genuine + // joiner meets a real ticket rather than falling through here. let ticketID = CKRecord.ID( recordName: Self.ticketRecordName(for: gameID), zoneID: zoneID @@ -845,6 +866,41 @@ final class ShareController { } } + /// Best-effort mint of a *missing* seat ticket for an existing public link. + /// A link created before the ticket mechanism — or one whose original mint + /// failed (e.g. a not-yet-deployed schema field) — has a live URL but no + /// ticket, so every link joiner's seat check finds nothing and bounces them. + /// Reconstructing a ticket from the share's current invitees restores + /// joinability. Only mints when the ticket is absent: a present ticket is + /// left untouched so its already-consumed seats aren't reopened. + private func ensureTicketExists(for gameID: UUID, share: CKShare) async { + let zoneID = share.recordID.zoneID + let ticketID = CKRecord.ID( + recordName: Self.ticketRecordName(for: gameID), + zoneID: zoneID + ) + do { + _ = try await container.privateCloudDatabase.record(for: ticketID) + // Present already — leave its seat count untouched. + } catch let error as CKError where error.code == .unknownItem { + do { + try await setTicketSeats( + max(0, Self.maximumInviteesPerPuzzle - Self.inviteeCount(in: share)), + claimedAuthorIDs: Self.inviteeAuthorIDs(in: share), + for: gameID, + in: zoneID + ) + syncMonitor?.note("healed missing seat ticket for \(gameID.uuidString)") + } catch { + syncMonitor?.note( + "seat ticket heal skipped for \(gameID.uuidString): \(error.localizedDescription)" + ) + } + } catch { + // Transient fetch failure — the join-time seat check is the backstop. + } + } + /// Returns true when this joiner successfully consumed a public-link seat. /// Legacy tickets have no seat count and are consumed by deleting the record. private func consumeTicketSeat(ticketID: CKRecord.ID, in database: CKDatabase) async throws -> Bool {