Skip to content
C.W.K.
Stream
Lesson 04 of 07 · published

The Wrist Outbox: The Only Copy Until the Phone Says So

~17 min · on-the-wrist, outbox, idempotency, watchconnectivity, data-safety

Level 0Bundle Opener
0 XP0/81 lessons0/17 achievements
0/100 XP to next level100 XP to go0% complete
"The payload never leaves the device on a hope."

Why the Watch Keeps Its Own Queue

transferUserInfo is already queued, ordered and guaranteed by the system, so keeping a second copy on the watch looks redundant. It is not, for two reasons. The local write is the capture, and the transfer is a separate act that can fail or take hours, so the words must be safe on the wrist before anything is sent. And a visible queue is honest: "3 waiting" is something the wearer can see, instead of being told everything is already filed. The watch's queue follows the phone's outbox rules one hop out. An entry is removed only in the transfer's didFinish, never at send time. Everything still waiting is offered again whenever the session activates, because the receiver deduplicates on the id. And each capture carries the moment it was spoken, with its time zone, stamped on the wrist; the phone passes that through untouched, because restamping on arrival would record a lie after a phone that was off for hours.

An Unreadable Queue Is Kept, Never Replaced

The first version of the queue loaded with try? decode … else pending = []. That starts empty whenever the file cannot be read, and the next capture's save writes that empty list over whatever was waiting. On the watch those entries are the only copy. One added field in a new build is enough to make an old file unreadable. The kit's queue now moves an unreadable file aside with its bytes intact and names it in a restore failure the app can show.

Idempotent Where It Counts: The Store

Deduplication at a door only covers what is still standing in it. The training app's phone store appended every offer, the watch offered its captures again on each activation by design, and a payload offered twice became two entries with one id. One acknowledgement settled one; the other sat at the head of the phone's queue for eight TestFlight builds. The store itself must be idempotent on the capture id.

The phone's door has an ordering rule of its own. It commits the capture first and records the id in its seen ledger after. Written the other way, a commit that throws leaves the id marked as seen, the watch's next re-offer is dropped at the door, and the capture, whose only other copy the watch was holding for exactly this case, is gone once the watch lets go. Written this way, a crash between commit and record costs one redundant commit, which the idempotent store absorbs. The ledger is a bounded cache that stops honest echoes early; it is never the only defence.

Counts That Mean What They Say

A waiting count read from the watch's queue alone is the transfer daemon's state, not the phone's: "3 waiting" was true of the transfers and misleading about captures the phone already held. When the phone answers the doorbell with a receipt, those ids count as handed over until their transfers finish, and the status line folds them in.

Code

A watch queue that keeps an unreadable file, and a phone door that commits before it remembers·swift
import Foundation

struct WristCapture: Codable, Equatable, Sendable {
    let id: String
    let text: String
    let spokenAt: Date          // stamped on the wrist when spoken; the phone carries it untouched
    let timeZone: String
}

/// The watch's own queue. On the wrist this file IS the captures until the phone has them.
final class WristQueue {
    private let file: URL
    private(set) var pending: [WristCapture] = []
    private(set) var restoreFailure: String?

    init(file: URL) {
        self.file = file
        guard let data = try? Data(contentsOf: file) else { return }   // no file yet: a fresh install
        do {
            pending = try JSONDecoder().decode([WristCapture].self, from: data)
        } catch {
            // Never start empty over an unreadable queue: the next write would erase what was spoken.
            let aside = file.deletingLastPathComponent().appending(path: "queue-unreadable-\(Int(Date().timeIntervalSince1970)).json")
            try? FileManager.default.moveItem(at: file, to: aside)
            restoreFailure = "The waiting captures could not be read and were kept at \(aside.lastPathComponent)."
        }
    }

    /// Idempotent on id: the same capture offered twice stays one entry.
    func enqueue(_ capture: WristCapture) throws {
        guard !pending.contains(where: { $0.id == capture.id }) else { return }
        pending.append(capture)
        try save()
    }

    /// Called from the transfer's didFinish, and nowhere else.
    func acknowledge(_ id: String) throws {
        pending.removeAll { $0.id == id }
        try save()
    }

    private func save() throws {
        try JSONEncoder().encode(pending).write(to: file, options: .atomic)
    }
}

/// The phone's door. Commit first, remember second: a crash between them costs one redundant
/// commit, which an idempotent store absorbs. The other order could lose the only copy.
struct PhoneDoor {
    var seen: Set<String>
    let commit: (WristCapture) throws -> Void

    mutating func admit(_ capture: WristCapture) throws -> Bool {
        guard !seen.contains(capture.id) else { return false }   // the echo of a ring or a re-offer
        try commit(capture)
        seen.insert(capture.id)
        return true
    }
}
The rules, exercised·text
A check run on macOS:

  pending after double offer: 1
  restored pending: 0 | failure: The waiting captures could not be read and were kept at
                                 queue-unreadable-1789393851.json      <- moved aside, bytes intact
  admit: true  echo: false  store: ["w1"]
  after a failed commit, seen contains id: false                        <- the next re-offer still gets in

External links

Exercise

Put WristQueue in Spark's watch target and PhoneDoor in the phone target, then write three tests. First, write a queue file in an older shape and prove that loading keeps it aside and reports it. Second, offer one capture twice and prove there is one entry. Third, give the door a commit closure that throws once and succeeds the second time, and prove the capture is committed on the re-offer. Finally, reverse the door's order so the id is recorded before the commit, and show which of the three tests fails.
Hint
Inject the file URL and the commit closure so no WatchConnectivity is involved. A counter captured by the closure is enough to make it throw only on the first call.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.