Skip to content

Persistence

SwiftData is the only store. There is no cache, no file export, no defaults-backed copy of shift data and no remote database.

Container construction

ModelContainerFactory builds every container from the current schema and passes DashPilotMigrationPlan. Wiring the plan up front from v1 meant adding v2 was an ordinary change rather than a store reset.

Container creation throws instead of trapping. AppModelContainer opens the process's container once, lazily, and is the one place the app's own store is chosen; DashPilotApp renders PersistenceUnavailableView on failure, which is a visible state a driver can act on rather than a crash. The scene and the App Intents share that single container, so a write made without a screen is visible to the screen when there is one.

Tests and previews use makeInMemoryContainer(), which shares the same schema and the same migration plan as the shipping store, so a schema mistake fails in tests rather than only on a device.

Entry point Used by Behaviour
makeContainer() The app Current schema, migration plan, on-disk store
makeContainer(at:) Tests The same, at an explicit URL, so a store can be closed and reopened
makeContainer(versionedSchema:at:) Tests A store opened under a historical version without the plan, so a test can write a store shaped the way an older build would have left it
makeInMemoryContainer() Tests, previews Current schema and plan, nothing on disk

What is stored

Eleven entities. Their fields are listed under Data model.

Shift holds a start timestamp, an optional end timestamp, an optional gross earnings amount and the two optional figures its fuel estimate is worked out under. Everything else about a shift, including its lifecycle state, its durations, its distance, its rates, its estimated fuel and its estimated net, is derived when it is asked for.

ShiftPause stores when the driver recorded pausing and, once they resume, when they recorded resuming. It is a row rather than a flag on Shift because a boolean could say a shift is paused now but not for how long or how many times, and an accumulated "paused seconds" would be a running sum the app had to keep correct across every crash and failed save. Whether a shift is paused is a pause with no end; how long it was paused is the union of its rows.

RouteSuspension stores when the driver recorded the vehicle as parked and, once they drive again, when they recorded that. A row rather than a flag for the reason ShiftPause is one, and a separate entity from it rather than a kind column on one: a pause is subtracted from working time and a stretch parked is subtracted from nothing, and one entity carrying both would be a single if away from a shopping trip coming off somebody's hours. It joins the shift and never a delivery, because a driver shopping for one order while carrying another has one vehicle and it is parked.

Delivery stores five timestamps, its shift and the offer it arrived in. Its state is derived from which of those timestamps exist rather than stored beside them, so nothing in the store can disagree with the events it summarises. Nothing identifying a restaurant, a customer or an address is stored, and no amount is attributed to a delivery.

Offer stores when the driver recorded accepting one piece of work, and holds the deliveries it contained. It exists because one acceptance can contain more than one dropoff, and before it the store could not tell two deliveries taken in a single tap from two taken ten minutes apart. It holds no money, no duration and no distance: an offer is a grouping the driver recorded, so every figure stays on the delivery it belongs to. Delivery.shift is kept beside Delivery.offer rather than replaced by it, because that column is what every fetch, aggregate, export figure and delete rule is built on.

DeliveryTip stores one tip a delivery received outside what the platform recorded paying for it: its amount, the method it arrived by, when it was recorded, and its delivery. Rows rather than a second column on Delivery, because tips arrive as separate events with separate methods and a single mutable column would collapse them into a figure the driver has to maintain by hand.

Expense stores when a cost was incurred, its amount, its category and an optional short note. It has no relationship to anything. See below.

RouteSample stores a timestamp, a latitude, a longitude, a horizontal accuracy and the capture session it was recorded in, and nothing else. CLLocation also reports speed, course, altitude and their accuracies, but nothing implemented reads them, and a coordinate history is sensitive enough that each field needs a reason rather than an availability.

A shift does not hold its route

RouteSample.shift is a to-one relationship, and it is the only place the relationship between a shift and its route is declared. Shift holds no matching collection. A shift's route is fetched, through Shift.routeSamples(), which owns the predicate and the ordering; Shift.routeSampleCount counts without loading one.

That is a performance finding rather than a modelling preference, and it was measured rather than guessed. Up to v9 the shift held routeSamples, and maintaining that collection cost time proportional to the number of positions already attached to the shift. Driving the shipping capture path at one accepted position a second, on the iPhone 17 simulator against an on-disk store, writing one position cost 6.7 ms at the start of a shift and 178 ms seven hours in, about 7.3 µs per stored row, all of it on the main actor once a second. Recording a shift was quadratic in its length.

The cause was isolated rather than assumed: rebuilding the context made no difference, fetching the shift rather than creating it made no difference, changing the delete rule made no difference, and the size of the table made no difference. Starting a new shift every nine hundred positions made the cost flat, and so did removing the collection while keeping the to-one relationship. A Set-typed collection is not an alternative, because SwiftData requires Codable for one and will not compile it.

After the change an eight-hour shift records at a flat 24 to 29 µs per position from the first to the 28,800th, and the whole shift costs 4.5 s of main-actor time instead of tens of minutes. Memory does not move. The figures are reproduced by the gated RouteCaptureWritePerformanceTests.

Nothing is cached to buy this. Recorded mileage is still measured from the route on demand, for the reason it always was: the cost was in recording a position, not in measuring one.

The delete rules

Shift.deliveries uses deleteRule: .cascade, since v5, and Shift.pauses since v9. A shift's deliveries and its pauses describe that shift and nothing else, so deleting the shift takes both with it. The orphans would otherwise be exactly the sensitive rows the app promises to keep accountable to a shift.

A shift's route is deleted explicitly, because a relationship declared from one side carries no delete rule and there is no cascade left to carry the positions away. ShiftService.deleteCompletedShift(_:) fetches the shift's route rows, deletes them, deletes the shift, and saves once. One transaction is the point: a store that refuses the write leaves the shift and its whole route, and there is no ordering in which a shift disappears while its coordinates survive. The rows are fetched and deleted rather than removed with a batch delete, which would run beneath the context, could not be rolled back with it, and so could not be part of the same transaction.

Removing the cascade made deletion faster rather than slower, by removing the same quadratic on the way out: the cascade walked the collection and took 35.6 s for a 7,200-position route, where the explicit delete takes 484 ms, and an eight-hour route now deletes in about 2 s.

A delivery's own optional amount, added in v7, is an attribute rather than a relationship, so it goes with the delivery under the same cascade — which is why the delete confirmation names every amount recorded on a shift and on its deliveries.

Delivery.pickupPlace, added in v6, deliberately does not cascade, in either direction. A pickup place is shared between deliveries and between shifts, so deleting a delivery — or the shift that cascades to it — must leave the place standing for everything else that still names it, and deleting a place must never take deliveries with it. The inverse PickupPlace.deliveries nullifies. A place left referenced by nothing is kept rather than collected: it stops being recent, and typing the name again finds it. Deleting a driver's own vocabulary as a side effect of deleting a shift would widen the one operation this project keeps deliberately narrow.

Tests assert that the deleted shift's positions and deliveries are gone, that another shift's rows and recorded amount are untouched, that no delivery is left without a shift, that a pickup place another delivery still names survives, that a rolled back deletion leaves the shift and its whole route, and that a refused delete changes nothing at all.

Earnings are stored as a decimal

Shift.grossEarningsAmount is a Decimal, not a Money and not a Double. SwiftData persists a Decimal as a decimal attribute, so the exact amount survives a round trip with no binary floating point in the store and no second monetary type in the app. The property is private; grossEarnings and setGrossEarnings(_:) are the conversion, in one place, and the rest of the app only ever holds a Money.

nil and zero are different facts everywhere: in the model, in migration, in the interface and in the metrics. nil means the driver has not recorded what the shift paid; 0 means they recorded that it paid nothing. Removing an amount is therefore its own operation (clearGrossEarnings()) rather than an empty text field that ambiguously means both "invalid" and "delete".

Two invariants live on the model rather than in a view, so no screen, test or later caller can set an amount the app would refuse to display: earnings can be recorded only on a completed shift, and never negative. ShiftService adds the store write and the same rollback rule the lifecycle transitions use, so an amount can never be showing in the interface while the store holds something else.

A fuel assumption is stored on the shift it was used for

Shift.fuelMilesPerGallonValue and Shift.fuelGasPricePerGallonAmount are two optional Decimal columns, stored for the reason the earnings amount is and held to the same nil-is-not-zero rule. They are the vehicle fuel economy and the price of a gallon the driver assumed, and the estimated fuel cost derived from them is not stored: it is recomputed from the route and these two every time it is read, like every other derived figure here.

They are a snapshot, not a reference, and that is the whole modelling decision. An assumption that is not recorded where it was used is an assumption that rewrites history: if one global figure backed every shift, a driver changing vehicle or filling up at a different price would silently re-cost every shift they had ever worked. So each shift keeps its own pair and its estimate is always derived from that. What a driver enters today seeds a text field for the next shift, and that is all it does.

Two columns rather than a new entity, and rather than a vehicle table. There is exactly one pair per shift, it has no lifecycle of its own, nothing points at it, and it is never listed, ordered, counted or corrected independently of the shift that holds it. Two nullable columns say "estimated under these assumptions, or under none" completely, and they cannot go missing, be orphaned or be duplicated.

The invariants live on the model, as the earnings ones do: only a completed shift may record them, a fuel economy must be greater than zero because it is the divisor, and a gas price may not be negative while zero is allowed and means the fuel was recorded as costing nothing. Both halves are validated before either is written, so a refused edit leaves the pair that was already there.

Nothing here is an expense. Recording assumptions inserts no Expense, reads none and changes none. See Estimated fuel and net.

An expense is stored unattached

Expense has no shift and no delivery, and nothing anywhere in the store relates one to the other. That is the modelling decision behind the feature rather than a simplification: a tank of fuel is burned across several shifts, a set of tyres across thousands of miles, and attaching a cost to whichever shift happened to be running when it was typed would record an attribution the driver never made. It is the same fabrication the app refuses when it declines to divide a shift's amount among its deliveries.

Membership is by date. A period contains an expense if its occurredAt falls in the period, by the same half-open rule that puts a shift in one. Two consequences follow directly from the shape: no recorded cost is attached to a shift or a delivery, so no recorded net exists at shift level, and deleting a shift cascades to nothing, because an expense has no relationship to be cascaded along, and the cost happened whether or not the shift's record is still there. A shift's estimated net after fuel is not a counter-example: its subtrahend comes from that shift's own recorded mileage and its own recorded assumptions, and no expense enters it.

The amount is a Decimal for the reason a shift's is, and it is required: an expense with no amount is not a record of anything. A recorded 0.00 is still a recorded amount. The category is stored as a plain string rather than as the enum, so a stored word a build cannot name reads as other rather than failing a fetch, which is the same reason PickupPlace stores plain strings.

Delivery state is not a column

Delivery has no persisted state and no isPickedUp-style booleans. state is computed from arrivedAtPickupAt, pickedUpAt, deliveredAt and cancelledAt, so there is one authoritative answer to what a delivery is doing and it is the same data that forms the historical record.

"Active" is likewise a query, not a flag: deliveredAt == nil && cancelledAt == nil. That is what makes relaunch recovery ordinary rather than a code path — a delivery left running when the app was terminated is simply still running when a new DeliveryService reads the store, with its original timestamps, all of them, each with its own state. Several unfinished deliveries are ordinary rather than an anomaly; what the same fetch reports as a structural fault is an active delivery attached to a shift that has already ended. The rule holds across a relaunch as well as within a session.

Writes during capture

Accepted route samples are inserted immediately and saved in batches of ten. Saving on every callback would mean a store write roughly once a second for the length of a shift; building a batching subsystem without measurement would be solving a problem nobody has demonstrated. Ten is the smallest step that removes the per-callback write, and every deliberate stop (backgrounding, ending a shift, losing permission) flushes first, so the exposure to an abrupt kill is a few seconds of route.

A failed save rolls back. The last accepted sample is cleared with it, because the row it referred to no longer exists, and the next candidate is judged as the first of the route, which is what it is. Capture stops and the state says the store is unavailable, rather than continuing to collect samples that cannot be kept.

When capture is pointed at a shift again, the newest already-stored sample is read back with a one-row fetch, so capture resumed after a relaunch, a backgrounding or a permission interruption still judges candidates against the route as it stands. Fetching the whole route to find it would load an entire shift's positions to look at one row.

Schema evolution

Schema versions and the reasoning behind each migration step are on Migrations.