Pingline M1 — models and the connection actor
Unit 26 · Pingline Milestone 1. You build the data model and the actor ConnectionState
that every later milestone leans on. No UI yet — this is the spine.
Before a single message crosses the wire you need two things: value types that describe a
message and a conversation, and one object that owns the truth about whether you are connected.
That object has to be safe to hit from many tasks at once — the send path, the receive path,
the reconnect timer — so it is an actor, not a class you hope nobody races.
The models
Messages come from a database and travel over a socket, so they must be Codable (to
decode the JSON payload) and Sendable (to cross task boundaries safely). Structs of
Sendable parts are Sendable for free.
import Foundation struct Message: Identifiable, Codable, Sendable, Equatable { let id: UUID let conversationID: UUID let senderID: UUID let body: String var imageURL: URL? // an optional image attachment (a Storage path) var reactions: [Reaction] // tapbacks keyed to this message var createdAt: Date // `var`: Milestone 3's echo-dedupe rewrites it to the server's timestamp // Local-only: how this message is doing on its way to the server. var deliveryState: DeliveryState = .sent } struct Reaction: Identifiable, Codable, Sendable, Equatable { let id: UUID let symbol: String // an SF Symbol tapback let byMe: Bool } enum DeliveryState: String, Codable, Sendable { case sending // optimistic — shown locally, not yet acknowledged case sent // confirmed by the server case delivered // reached the recipient's device case read // the recipient opened the thread case failed // the send did not go through } struct Conversation: Identifiable, Codable, Sendable, Equatable { let id: UUID let title: String var isGroup: Bool // a group thread attributes each message to its sender var lastMessage: String var unreadCount: Int }
Message is Identifiable so SwiftUI lists can diff it by id — you will care about that a
lot in Project 7. The deliveryState field never comes from the server; it is how the client
tracks an optimistic message through its lifecycle. createdAt gives you a stable sort key so
out-of-order arrivals still render in send order.
Give every message a client-generated UUID before you send it. That id is what lets you
match the server's echo back to the optimistic row you already showed — the seam that makes
reconciliation possible in Milestone 3.
Why the connection is an actor
The connection has mutable state — are we connected, what is buffered — that is touched from
several tasks concurrently. That is the exact situation actor exists for: it serializes
access so no two tasks corrupt the buffer, without a lock you write by hand.
enum ConnectionStatus: Sendable { case disconnected case connecting case connected } actor ConnectionState { private(set) var status: ConnectionStatus = .disconnected private var outbox: [Message] = [] // messages queued while offline func markConnecting() { status = .connecting } func markConnected() { status = .connected } func markDisconnected() { status = .disconnected } // Queue a message that could not be sent right now. func enqueue(_ message: Message) { outbox.append(message) } // Hand back everything buffered and clear the outbox, atomically. func drainOutbox() -> [Message] { let pending = outbox outbox.removeAll() return pending } }
Every method here is synchronous inside the actor, so each one is atomic against the others.
drainOutbox() in particular must be one method: reading the array and clearing it in a single
isolated step means a message can never be sent twice or dropped. Split it into an awaited
"read" and an awaited "clear" and you have introduced the exact race the checkpoint hunts.
In Kotlin you might reach for a Mutex around a MutableList, or a Channel for the outbox.
An actor folds the mutual exclusion into the type: there is no lock object to forget to take,
because the only way to touch outbox is through an isolated method. The reentrancy caveat
still applies — keep read-modify-write inside one suspension-free method.
Wiring the client (shape only)
The Supabase client is created once and shared. Connect flips the status and, on success, drains anything that queued while you were down. You will implement the actual subscribe in Milestone 2; here you establish the state machine.
func connect() async { await markConnecting() // ... open the Supabase realtime channel here (Milestone 2) ... await markConnected() let pending = await drainOutbox() // flush the offline buffer for message in pending { await send(message) // send() defined in M3 } }
Notice connect() calls drainOutbox() once and iterates the snapshot. Because the drain is
atomic, any message enqueued after the drain stays safely in the outbox for the next flush —
no lost sends, no double sends.
Checkpoint: your app should now…
- Compile with
Message,Conversation,DeliveryState, andConnectionStatusasSendablevalue types. - Have an
actor ConnectionStatethat tracks status and buffers an outbox. - Pass a quick unit test: enqueue three messages,
drainOutbox()returns all three, and a second drain returns an empty array.
Stretch goals
- Add a
reconnect(after:)that backs off exponentially and caps the delay. - Track
statuschanges as anAsyncStream<ConnectionStatus>so the UI can show a banner.
Knowledge check
Q: Why is ConnectionState an actor rather than an @Observable class?
Because its mutable state is written from many tasks concurrently (send, receive, reconnect),
and an actor serializes that access to prevent data races. An @Observable class is for state
a view observes on the main actor; connection state is infrastructure hit off the main actor.
Q: Why must drainOutbox() be a single synchronous method?
So reading the buffer and clearing it are atomic. If you split them across an await, another
task could enqueue or drain in between, causing a message to be sent twice or lost.