Skip to content
C.W.K.
Lesson 01 of 05 · published

Keep First, Send Later

~15 min · ios, outbox, idempotency, cwkkit

Level 0First Word
0 XP0/47 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete

One target on the family kit

SparkMobile is one universal XcodeGen target for iPhone and iPad. Its project.yml is the source, and the .xcodeproj is generated and never kept. The floor is iOS 18, because the app uses the Swift version of Vision's text request, and the language is Swift 6. The app links two products of CwkKit, the family's Swift package, taken as a path dependency: CwkKitCore for plumbing every family app shares (the capture outbox, the family endpoints, the share inbox) and CwkKitMedia for shared views (the upload line, the phrase card, Talk with Pippa). VisionKit rides along for the live scanner. Two extensions are embedded in the bundle: SparkShare for the share sheet and SparkWidgets for the Control Center button. One build number covers the app and both extensions, because App Store Connect refuses an extension whose number differs from its host's.

The kit matters on the phone more than anywhere else in Spark. Spark's outbox is the kit's CaptureOutbox with Spark's payload inside it, and the upload line in the next lesson is the kit's too. Sibling Kit Quest teaches how a family shares code like this without letting copies drift. This lesson is about what Spark puts into it.

Kept before the network is asked

The rule is short: a capture is safe before the network hears about it. When Dad presses the shutter, AppModel.capture(photo:) does three things before any request (the second code block). It settles the capture truth: the photo's own time if it has one, otherwise the moment it was kept. It mints every id the send will need: the moment's ULID, one media id for the copy Pippa reads and one for the original, the practical turn's request id, and the outbox entry's id. Then it writes both files into the app container with file protection and commits the entry to the outbox. Only after that does it start a drain in the background.

The doc comment says it plainly: the moment is safe once this returns without throwing, and its id is already final. If the outbox commit fails, the staged files are removed again, so the phone never holds bytes that no entry points to. A capture made in airplane mode has the same identity it will have on the engine, and it completes once.

The payload is the whole contract between the shutter and the send (the first code block). Look at the done set at the bottom. The send marks each step there as it lands, and that is how a retry knows where to resume.

Principle: Mint identity where the event happens. An id born on the server cannot exist in airplane mode, and a retry without one cannot tell a resend from a second capture.

The order that shows Pippa soonest

A photo capture is two files: a 2048 px copy for me to read, and the full-resolution original Dad keeps. On a slow link the original can take minutes. So the send is ordered for the answer, and the archive waits:

StepWhat goesWhy here
1The copy's upload lineShown before any request, so the screen moves from the first second
2The moment recordThe phone's ids and capture truth; the entry's id is the client request id
3The copy's bytesSent to the copy's own media id
4The practical turnThe copy's finishing request: Pippa starts reading while the original waits
5The originalThe heavy file, last
6A document's textOnly for a shared PDF or Word file: the engine reads it into the moment
7The device copyThe engine's record and the files saved for offline use before the entry may retire

Every step is idempotent. The moment and the files carry ids minted on the phone, the turn carries its request id, and a step already in the done set is skipped. The third code block is the test that pins this down: a first pass sends the moment and the copy, the turn answers 503, and the next pass sends only the turn and the original.

Two kinds of failure

The send sorts what goes wrong into two kinds, and the kit's OutboxFailure.classify draws the line. Most of 4xx is permanent: a malformed field or a file over the size limit is this capture's own fault, and sending it again will not help. That entry is parked as needing attention, its line offers no Retry, and Dad sees the engine's reason. Five 4xx codes are carved out as transient. 408, 425 and 429 are the server asking to be retried, in those words. 401 and 403 mean the session is wrong, not the capture, and parking entries for them would quarantine every capture in the queue for a fault none of them caused. Spark's own doc comment above outboxFailure still says a 4xx is permanent, but the kit's switch is what runs. Those five, a dropped link, and an engine that answered 503 are transient: the entry goes back to waiting and the next pass tries again. A refused capture is never retried forever, and a reachable one is never given up on.

One more detail keeps the queue moving. The kit's drain takes its list of entries when a pass starts, so a photo kept during a pass used to sit unsent until the next sync pass. Spark's drain() is coalesced: a call that arrives while a pass runs asks for one more pass, so the new capture goes right after the one in flight.

Code

cwkSpark, clients/apple/SparkMobile/Sources/Outbox.swift: the capture payload and its done set (verbatim, lines 10-41)·swift
struct CapturePayload: Codable, Equatable, Sendable {
    var momentId: String
    var kind: String
    var capture: CaptureTruth
    var situation: String?
    var text: String?
    var place: PlaceSample?
    var pippaFile: String?
    var pippaMediaId: String?
    var originalFile: String?
    var originalMediaId: String?
    var originalFilename: String?
    var originalContentType: String?
    var lines: [VisionLine]
    var practicalRequestId: String
    /// A text kept for high-quality work: no practical reading is asked.
    /// Optional so entries queued before it existed still decode.
    var skipsReading: Bool?
    /// A document (a shared PDF, a Word file): after the original is up,
    /// the engine reads it into the moment's text (step 6).
    var readsOriginalAsText: Bool?
    /// A crumb sent here from Waystone or Vesta: the moment arrives linked
    /// to it. Optional so entries queued before it existed still decode.
    var links: [ItemRef]?
    /// How far the send got: each step is idempotent, and a step already
    /// done is not repeated (the original is the heavy one).
    var done: Set<Step> = []

    enum Step: String, Codable, Sendable {
        case moment, copy, turn, original, text
    }
}
cwkSpark, clients/apple/SparkMobile/Sources/AppModel.swift: a photo is kept, every id minted, before any request (verbatim, lines 276-306)·swift
    /// Keep a photo on this device, then try the engine. The moment is safe
    /// the moment this returns without throwing; its id is final already.
    @discardableResult
    func capture(photo: PreparedPhoto, situation: Situation, place: PlaceSample?,
                 momentId: String = ULID.mint(), otherwise truth: CaptureTruth? = nil,
                 entryId: String = ClientRequestId.mint(prefix: "moment-"), links: [ItemRef]? = nil) async throws -> String {
        let capture = Self.truth(takenAt: photo.takenAt, otherwise: truth)
        // A camera still carries no metadata of its own; the moment's truth
        // fills it, on the original and on the copy Pippa reads (todo #590).
        var photo = photo
        photo.original = PhotoMetadata.filled(photo.original, capturedAt: capture.capturedAt, place: place)
        photo.forPippa = PhotoMetadata.filled(photo.forPippa, capturedAt: capture.capturedAt, place: place)
        let payload = CapturePayload(
            momentId: momentId,
            kind: "photo",
            capture: capture,
            situation: situation.rawValue,
            place: place,
            pippaMediaId: ULID.mint(),
            originalMediaId: ULID.mint(),
            originalFilename: photo.originalFilename,
            originalContentType: photo.originalContentType,
            lines: photo.lines,
            practicalRequestId: ClientRequestId.mint(prefix: "practical-"),
            links: links)
        _ = try await outbox.enqueue(payload, photo: photo, entryId: entryId)
        settings.situation = situation
        queued = await outbox.unsent
        Task { await drain() }
        return momentId
    }
cwkSpark, clients/apple/SparkMobile/Tests/SparkMobileTests.swift: the send order, and a retry that skips what is done (verbatim, lines 250-293)·swift
    func testTheSendGoesMomentCopyTurnOriginalAndSkipsWhatIsDone() async throws {
        let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
        defer { try? FileManager.default.removeItem(at: directory) }
        let outbox = MomentOutbox(directory: directory)
        let photo = PreparedPhoto(original: Data("original".utf8), originalFilename: "IMG_1.HEIC",
                                  originalContentType: "image/heic", forPippa: Data("copy".utf8),
                                  lines: [], takenAt: nil)
        let payload = CapturePayload(momentId: Self.momentId, kind: "photo", capture: CaptureTruth.now(),
                                     situation: "restaurant", pippaMediaId: "COPY", originalMediaId: "ORIGINAL",
                                     originalFilename: "IMG_1.HEIC", originalContentType: "image/heic",
                                     lines: [], practicalRequestId: "practical-abc12345")
        let entry = try await outbox.enqueue(payload, photo: photo)

        let media: [String: Any] = ["media_id": "M", "moment_id": Self.momentId, "filename": "f", "url": "/u", "role": "r"]
        let scripted = ScriptedHTTPTransport(replies: [
            ScriptedHTTPTransport.json(["moment": Self.momentJSON()]),
            ScriptedHTTPTransport.json(["media": media]),
            ScriptedHTTPTransport.json(["detail": "Spark's brain is not reachable"], status: 503),
        ])
        do {
            _ = try await client(scripted).send(entry, outbox: outbox, label: "Menu")
            XCTFail("a 503 must leave the entry for the next pass")
        } catch let failure as OutboxFailure {
            XCTAssertFalse(failure.isPermanent)
        }
        XCTAssertEqual(scripted.calls.map(\.path), [
            "/api/moments", "/api/moments/\(Self.momentId)/media/COPY", "/api/moments/\(Self.momentId)/practical",
        ])

        // The next pass skips the moment and the copy it already sent.
        let again = try await XCTUnwrapAsync(await outbox.entry(momentId: Self.momentId))
        XCTAssertEqual(again.payload.done, [.moment, .copy])
        let retry = ScriptedHTTPTransport(replies: [
            ScriptedHTTPTransport.json(["turn": ["id": "t1", "status": "running"]]),
            ScriptedHTTPTransport.json(["media": media]),
        ])
        let sent = try await client(retry).send(again, outbox: outbox, label: "Menu")
        XCTAssertEqual(sent, Self.momentId)
        XCTAssertEqual(retry.calls.map(\.path), [
            "/api/moments/\(Self.momentId)/practical", "/api/moments/\(Self.momentId)/media/ORIGINAL",
        ])
        XCTAssertEqual(retry.calls.last?.query, "role=original&filename=IMG_1.HEIC")
        XCTAssertEqual(retry.calls.last?.headers["Content-Type"], "image/heic")
    }

External links

Exercise

Build a capture outbox for an app of your own that sends something heavy, such as a photo with a large original. Mint every id on the device, write the files before the first request, record each completed step in the entry, and order the steps so the user sees an answer before the heavy file goes. Then script a transport that fails the third request with a 503 and assert that the next pass sends only what is left.
Hint
Make the steps a small set stored with the entry, and check it before each request. Spark's test records every path the scripted transport saw, so the assertion is a plain list comparison: first pass moment, copy, turn; second pass turn, original.

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.