Skip to content
C.W.K.
Lesson 04 of 10 · 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/84 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. Two edges matter as much as the main case. A file that exists but will not read is the same danger as one that will not decode, so the queue does not treat a read error as a fresh install. And if the move itself fails, the queue leaves the file where it is and refuses every write, because writing anything there would replace it.

The same rule runs the other way for a new capture. The local write is the capture, so a capture whose save failed is not in the queue: the entry is taken back out of memory and the error goes to the caller. Otherwise a retry finds the id already pending, returns success without writing, and the words are gone at the next launch.

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
}

enum WristQueueError: Error, Equatable {
    case unreadableOriginal     // the old file is still in place and must not be written over
}

/// 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?
    private var sealed = false                  // true while an unreadable original sits at `file`

    init(file: URL) {
        self.file = file
        guard FileManager.default.fileExists(atPath: file.path) else { return }   // a fresh install
        do {
            // A file that will not READ is the same danger as one that will not decode.
            pending = try JSONDecoder().decode([WristCapture].self, from: Data(contentsOf: file))
        } catch {
            let aside = file.deletingLastPathComponent().appending(path: "queue-unreadable-\(Int(Date().timeIntervalSince1970)).json")
            do {
                try FileManager.default.moveItem(at: file, to: aside)
                restoreFailure = "The waiting captures could not be read and were kept at \(aside.lastPathComponent)."
            } catch {
                // Could not move it aside: leave it where it is and refuse every write instead.
                sealed = true
                restoreFailure = "The waiting captures could not be read and were left in place."
            }
        }
    }

    /// Idempotent on id. A capture whose save failed is not queued, so a retry really saves it.
    func enqueue(_ capture: WristCapture) throws {
        guard !pending.contains(where: { $0.id == capture.id }) else { return }
        pending.append(capture)
        do { try save() } catch { pending.removeLast(); throw error }
    }

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

    private func save() throws {
        if sealed { throw WristQueueError.unreadableOriginal }
        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-1791839558.json.     <- moved aside, bytes intact
  first save failed, pending: 0                                         <- not saved, not queued
  retry saved: true, after relaunch: 1
  sealed write refused: unreadableOriginal                              <- could not move it: no write at all
  original bytes intact: true
  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 the tests below. 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. Add two more: make the first save fail (a missing parent directory is enough) and prove a retry writes the capture to disk, and make an unreadable file impossible to move aside and prove the queue refuses to write over it. Finally, reverse the door's order so the id is recorded before the commit, and show which test 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 in — Please sign in to comment.

No comments yet — be the first.