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.