Skip to content

Migrations

The store has been versioned since v1, and the migration plan was wired up before there was anything to migrate. That decision is why every version step since has been an ordinary change rather than a store reset.

Versions

Version Change
1.0.0 Shift only: id, start, optional end
2.0.0 Adds RouteSample, and a Shift.routeSamples relationship
3.0.0 Adds RouteSample.captureSessionID, an optional marker of capture continuity
4.0.0 Adds Shift.grossEarningsAmount, an optional Decimal holding manually entered earnings
5.0.0 Adds the Delivery entity and a Shift.deliveries relationship
6.0.0 Adds the PickupPlace entity and an optional Delivery.pickupPlace reference
7.0.0 Adds Delivery.grossEarningsAmount, an optional Decimal holding manually entered per-delivery earnings
8.0.0 Adds the Expense entity. No existing entity changes, and no relationship is added
9.0.0 Adds the ShiftPause entity and a Shift.pauses relationship. No existing attribute changes
10.0.0 Removes the Shift.routeSamples relationship. RouteSample.shift is unchanged, and no stored value moves
11.0.0 Adds Delivery.expectedEarningsAmount, an optional Decimal holding what the driver expects an active delivery to pay
12.0.0 Adds the Offer entity, an optional Delivery.offer reference and a Shift.offers relationship. Backfills one offer per existing delivery
13.0.0 Adds the DeliveryTip entity and a cascading Delivery.additionalTips relationship. Backfills nothing
14.0.0 Adds Shift.fuelMilesPerGallonValue and Shift.fuelGasPricePerGallonAmount, two optional Decimal columns holding the assumptions a shift's fuel estimate is worked out under. Backfills nothing
15.0.0 Adds the VehicleProfile and DriverSettings entities and an optional Shift.fuelVehicleName column. Backfills nothing
16.0.0 Adds the RouteSuspension entity and a cascading Shift.routeSuspensions relationship, holding the stretches the driver recorded the vehicle as parked. Backfills nothing

The current version is v16. Field-level detail is on Data model.

DashPilotSchemaV1 through DashPilotSchemaV15 hold frozen copies of their models rather than reusing the file-scope types, which have moved on. The plan then describes where a store is coming from as truthfully as where it is going, and the copies are never used at runtime outside migration.

DashPilotSchemaV15 was frozen in the interval that added v16, and the freeze was forced the same way every one before it was: v16 gives Shift a cascading collection of the stretches it was parked for, so reusing the file-scope types under v15 would describe every pre-v16 store as one that could already say why a stretch of route is missing. No build that wrote one could.

DashPilotSchemaV14 was frozen one interval earlier, and the freeze was forced the same way: v15 gives Shift the name of the vehicle its economy came from, so reusing the file-scope types under v14 would describe every pre-v15 store as one that already recorded a vehicle. No build that wrote one had vehicles at all.

DashPilotSchemaV13 was frozen one interval earlier, and that freeze was forced the way v12's was: v14 gives Shift two columns, so reusing the file-scope types under v13 would describe every pre-v14 store as one that already recorded a fuel economy and a gas price. It did not, and a version that claims otherwise cannot be used to prove a migration preserved anything. v12 was frozen one interval earlier for the same reason, when v13 gave Delivery a cascading collection of tips. Each version gets its copies as the plan moves past it.

Every stage but one is lightweight, deliberately

Every step but v12 is purely additive, and each time the decision not to backfill was the substantive one. v10 is the only one that removes anything, and it removes a relationship rather than any stored value. v12 is the only custom stage, and it is custom because it has something to transform rather than because it has something to tidy.

v1 to v2

A new entity and a new empty relationship. SwiftData can apply that without being told how, and there is nothing to derive: a shift recorded before route capture existed genuinely has no route. A custom stage would be code with nothing to do, and a willMigrate and didMigrate pair that walks every shift for no reason is a way to lose data, not a way to protect it.

v2 to v3

One new optional attribute on an existing entity. Nothing is backfilled, and that is the point: a capture session identifier states that two samples were recorded without an interruption, and a v2 store holds no evidence of that either way. Grouping legacy samples into invented sessions would produce exactly what the attribute exists to prevent, which is a gap presented as a continuous stretch of driving.

Migrated samples keep nil, and the mileage calculation treats their continuity as inferred rather than proven, which is why such a route is always reported as partial.

v3 to v4

One new optional attribute on Shift. A v3 store records no earnings at all, which is not the same statement as "these shifts paid nothing". Writing 0 into every existing shift would turn the absence of a figure into a claim about every shift a driver has ever recorded, and there would be no way afterwards to tell a fabricated zero from one they typed.

Migrated shifts keep nil, and the interface offers to add an amount rather than showing one.

v4 to v5

A new entity and a new empty relationship — the same shape as v1 to v2, and with the same nothing to derive. A shift recorded before delivery recording existed genuinely has no deliveries: DashPilot observes no delivery platform, so there is no source anywhere in the store from which a past delivery could be reconstructed. Inventing one per hour, per route segment or per anything else would write work into a driver's history that they never recorded, and nothing afterwards could tell it from work they did.

Existing shifts migrate with zero deliveries, and their route samples, capture session identifiers and recorded amounts are untouched.

v5 to v6

A new entity and a new optional reference to it, which SwiftData can add without being told how. Every existing delivery migrates with no pickup place, and the catalogue of places starts empty.

That emptiness is the substantive decision, in the same shape as v4 to v5. A v5 store records nothing about which business any delivery came from, and DashPilot has no source from which to recover one: it reads no delivery platform, resolves no address, and holds no merchant data of any kind. Attributing a past delivery to a place by its route, its timing or its resemblance to another would write a business's name into a driver's history on the app's authority rather than theirs, and no later screen could tell that apart from a place the driver named themselves.

The pickup place's own uniqueness is enforced in PickupPlaceService, not by a .unique attribute. That is partly a migration decision: a unique constraint would bind the store's shape to a normalisation policy that is allowed to improve, and improving it would then become a schema change rather than a code change. See Pickup identity.

v6 to v7

One new optional attribute on Delivery, the same shape as v3 to v4 one entity along. Every existing delivery migrates with no amount recorded, and every shift keeps the amount it already had.

The temptation this stage refuses is the one thing it could plausibly have done. A v6 store often holds a completed shift with a recorded total and the deliveries performed during it, so a number and a set of rows to spread it over are both sitting right there. Spreading it — evenly, by duration, by pickup wait, by anything — would put a figure against each delivery that the driver never typed, and no later screen, calculation or export could tell it apart from one they did.

The two amounts are independent facts entered separately, and neither is evidence for the other: deliveries go unrecorded, stacked orders are paid together, and adjustments post at shift level. A migrated delivery keeps nil, which the app reads as "not recorded" and never as 0.00, and the interface offers to add an amount rather than showing one. See Earnings and metrics.

v7 to v8

A new entity with no relationship to anything, which SwiftData can add without being told how. Not one existing entity changes shape, so there is nothing to rewrite, reinterpret or walk: every shift, route sample, capture session identifier, delivery, lifecycle timestamp, pickup place and recorded amount carries over untouched, and the expense table starts empty.

Empty is the only honest state for it. A v7 store records what a driver's work paid and nothing about what it cost, and DashPilot has no source from which a past cost could be recovered: it observes no purchase, reads no card, receipt or platform, and models no fuel consumption or vehicle wear. Deriving fuel from recorded mileage, or a per-mile vehicle charge from anything at all, would write costs the driver never entered into their history, and this fabrication would be worse than an invented earnings figure, because every net figure the app shows would then be built on it.

The absence of a relationship is itself the modelling decision, not a shortcut: an expense carries its own date, and a period contains it by that date. See Recorded expenses.

v8 to v9

A new entity and a new empty relationship, which SwiftData can add without being told how. It is the same shape as v1 to v2 and v4 to v5. No existing attribute moves, and in particular Shift.endedAt is untouched, so endedAt == nil still means the shift has not finished and every fetch, invariant and relaunch-recovery path written against that definition keeps working.

Every migrated shift keeps the duration it has always had. Working duration is elapsed time less recorded pause time, a shift with no pauses has zero pause time, and zero here is a measurement rather than a missing value: a build that could not pause a shift did not leave the question unanswered, it made the answer none. So a pre-v9 shift's working duration is exactly its elapsed duration, and every hourly rate, period total and exported figure derived from it is unchanged by this version.

What this stage refuses is the inference that looks reasonable. A gap in a route, a long stretch with no delivery recorded, an unusually long shift: each resembles a break, and none is evidence of one. DashPilot observes nothing about why a driver was not moving, so reading any of them as a pause would shorten a shift the driver recorded as whole, raise every rate derived from it, and leave no way afterwards to tell an invented pause from one they tapped. See Pausing a shift.

v9 to v10

A relationship declared from one side instead of two, which SwiftData can apply without being told how. Nothing is added, removed, retyped or rewritten: the column saying which shift a position belongs to lives on RouteSample and is untouched, so every stored route keeps every one of its samples, every sample keeps its shift, its timestamp, its coordinate, its accuracy and its capture session identifier, and every recorded mileage figure measures exactly what it measured before.

This is a version rather than a quiet edit, and the distinction is worth stating. A v9 store does open against the v10 models with every sample still resolving to its shift, because the foreign key never moves. That is not the same as the store being unchanged: reopening a v9 store under v10 rewrites the recorded version hashes of both Shift and RouteSample, which is SwiftData saying the model is a different one and that it has migrated the store. The surviving foreign key is exactly what makes it tempting to treat this as no change at all.

What the stage must not become is a cleanup. A route sample whose shift is missing is not something this version creates, and deleting rows here on the theory that some might be orphaned would destroy recorded history to tidy a table.

Why the collection went at all is a performance finding, not a modelling preference. See Persistence.

v10 to v11

One new optional attribute on an existing entity, which SwiftData can add without being told how. It is the same shape as v3 to v4 and v6 to v7, and it has the same nothing to derive: a delivery recorded before the app could ask what an order was expected to pay has no expectation, because none was ever entered. Every migrated delivery keeps nil, which the app reads as "not recorded" and never as 0.00.

The inference this stage refuses is sitting in plain sight. A v10 store often holds a delivered delivery with a recorded gross amount, and copying that figure into the new column would produce, for most deliveries, exactly the number the driver would have typed. It would also be the app asserting on its own authority that they expected what they were paid, in the one column whose whole purpose is to be distinguishable from the amount beside it. Nothing afterwards could tell an invented expectation from an entered one, and the first screen to show expected $8.50 · recorded $8.50 would be stating a coincidence the migration manufactured.

No figure a driver has already recorded changes value, and nothing derived from one moves: shift gross, period gross, every rate, the delivery-earnings subtotal and every exported summary are built from grossEarningsAmount alone, before this version and after it. See Expected pay.

v11 to v12

The first custom stage in the app's history. Adding the Offer entity and the reference to it would migrate lightweight on its own, and that is exactly what must not be left to happen: it would leave every delivery a driver has ever recorded holding no offer, in a build where a delivery holding no offer is a row the app cannot produce. Every screen, every grouping and every exported record would then carry a second reading for history, forever.

So didMigrate walks the deliveries and gives each one its own one-delivery offer, taking that delivery's own acceptance timestamp. That is the truthful reconstruction and the whole of it: a v11 store records one acceptance per delivery, because that is how the driver recorded them.

The inference this stage refuses is the one that looks like free information. Two deliveries accepted a second apart, or sharing a pickup place, or overlapping completely, all look like a stacked offer, and none of them is evidence of one: a driver tapping Start Delivery twice in a row produces exactly that shape, and so does a driver accepting two separate orders outside the same restaurant. Grouping them would invent platform metadata the store has never held, on the app's authority rather than the driver's.

No figure moves. An offer holds no money, no duration and no distance, so shift gross, delivery gross, expected pay, delivery active time, every rate, every period total and every exported summary are derived from exactly what they were derived from before. See Offers.

Two rows are left alone rather than repaired: a delivery already holding an offer, which a v11 store cannot contain but a re-entrant migration could present, and a delivery attached to no shift at all, which has no shift for an offer to belong to.

v12 to v13

One new entity and one new empty cascading collection, applied lightweight.

There is nothing to backfill, and that is a claim rather than a shrug. A delivery carrying no tip is the ordinary shape in this build as well: it is what a delivery with nothing beyond the platform's own figure looks like, its effective earnings are its recorded gross to the cent, and every subtotal, coverage count, rate and exported value derived from a migrated store is therefore the figure it already was. That is the difference from v11 to v12, which had to write because a delivery holding no offer was a row this app cannot produce.

Splitting a historical amount into pay and tip is the inference this stage refuses, and it is the tempting one, because for many deliveries the driver really did add a cash tip into the figure they typed. A v12 store holds no evidence of which ones: a round number, a larger than usual amount and a plain hand-typed figure are indistinguishable. Guessing would put a split into a driver's history on the app's authority, and no later screen or export could tell it from one they recorded.

Migrated deliveries keep no tips, and the interface offers to add one rather than showing one. See Additional tips.

v13 to v14

Two new optional columns on an entity that already exists, applied lightweight.

There is nothing truthful to write. A shift recording no fuel economy and no gas price is the ordinary shape in this build too: it is exactly what a shift created today starts as, and it reports that its fuel estimate is unavailable, naming the half that is missing, rather than reporting an estimate of $0.00. Not one previously derived total, rate, coverage count or exported value moves.

Backfilling is the failure mode here, not the feature. The obvious helpful stage would copy whatever fuel economy the driver enters first into every shift behind it. That would put an assumption they never made into their whole history, and it is precisely the dependence on a current global figure the version exists to prevent: the estimates would move again the next time they changed vehicle. A recorded fuel expense is no better a source, because it records what one fill-up cost, which is neither a price per gallon nor a statement about which shift burned it.

Migrated shifts record no assumptions, and the interface offers to add them. See Estimated fuel and net.

v14 to v15

Two new entities and one new optional column, applied lightweight.

There is nothing truthful to write, and here there are three tempting versions of writing it.

  • Creating a vehicle profile out of the fuel economies a driver's shifts already record would invent a vehicle they never named, and would have to guess how many vehicles those economies describe.
  • Writing a vehicle name onto the shifts that recorded an economy would attribute those shifts to a vehicle the store holds no evidence of. No build that wrote a v14 store had vehicles.
  • Seeding the settings row from the most recent shift's gas price would turn one shift's recorded assumption into a current preference, and the next shift started would then carry a figure the driver never chose.

So a migrated store opens with no vehicle profiles, no settings row and no vehicle name on any shift, while every shift keeps the fuel economy and gas price it recorded and the estimate derived from them. The settings row is created the first time the driver opens Settings, not by the migration.

Nothing joins a shift to a vehicle, in either direction and at any version. A shift records the vehicle's name rather than a reference to it, so deleting a profile cascades nowhere and leaves every shift worked in it whole. DriverSettings.selectedVehicleID is an identifier for the same reason: a deleted profile leaves a selection that resolves to nothing, which reads as no vehicle selected, a state the app is designed to be in. See Settings and vehicles.

v15 to v16

One new entity and one new cascading relationship, applied lightweight.

There is nothing truthful to write, and the tempting version of writing it is specific. The stage could read a v15 shift's route, find the gaps in it and record a stretch parked for each. It must not. A gap is left by a pause, a lost permission, a terminated process, a shift started while the app was behind another one and a tunnel, and the rows those leave behind are identical: the route holds no evidence of which stop is which. Writing "the driver parked here" over any of them would put a statement the driver never made into their history, and nothing afterwards could tell it from one they did.

So a migrated store opens with no suspensions at all. Every shift keeps its route, the mileage that route measures, the gaps it already had and the partial-route wording that follows from them: a shift recorded before the driver could say they had parked genuinely never said it.

A suspension joins the shift and nothing else, in either direction and at any version. There is no relationship to Delivery, because whether the vehicle is moving is a fact about the driver and their vehicle rather than about any one order, and a driver shopping for one delivery while carrying another has one vehicle and it is parked. See Parking for a pickup.

Proving a migration rather than assuming it

ModelContainerFactory.makeContainer(versionedSchema:at:) is a test seam that opens a store under a historical version without the plan. A test can therefore write a store shaped the way an older build would have left it, close it, and then open it normally through the shipping factory.

That is how "a v1 store keeps its shifts" is proven. The suite covers each step:

  • A v1 store's shifts survive, with their start and end timestamps intact and no route.
  • A v2 store's shifts and route samples survive, and the migrated samples carry no capture session.
  • A v3 store's shifts, samples and sessions survive, and their earnings are absent rather than zero.
  • A v4 store's shifts, samples, sessions and recorded amounts survive, and no delivery is fabricated for any of them.
  • A v5 store's shifts, samples, sessions, amounts and every delivery timestamp survive, and no delivery is attributed to a pickup place that was never named.
  • A v6 store's shifts, samples, sessions, shift amounts, deliveries, pickup places and the relationships between them survive — with the pickup waits and the delivery active time they produce identical afterwards — and no delivery is given an amount. One case is built specifically to make dividing a round shift total by four deliveries look reasonable, and asserts that it does not happen.

  • A v7 store's shifts, samples, sessions, shift amounts, deliveries, pickup places, per-delivery amounts and the derived results over them survive, and the new expense table is empty. One case presents a store holding an amount, a route and deliveries, which is everything a plausible cost could have been derived from, and asserts that no expense is fabricated from any of it.

  • A v8 store's shifts, samples, sessions, amounts, deliveries, pickup places and expenses survive with their durations unchanged, and the new pause table is empty. One case drives a long shift with a single early delivery and a route that stops after it, which is the shape that most resembles a break, and asserts that it keeps a working duration equal to its elapsed one.

  • A v11 store's shifts, samples, sessions, amounts, deliveries, pickup places, expected amounts, pauses and expenses survive, and every delivery comes out inside a one-delivery offer of its own. One case holds two deliveries accepted a second apart on the same shift, which is the shape a grouping inference would seize on, and asserts that they end up in two different offers. Another asserts that a shift's unioned delivery active time, its delivery-earnings coverage and its hourly rate are the figures they were before the step.

Each step is also walked from every earlier version, so a device that skipped several releases is covered by the same suite rather than by assumption.

Rules for the next schema change

  • Consider compatibility before adding or renaming anything.
  • Preserve existing user data. A destructive reset is not a substitute for a migration.
  • Add coverage for the new step in the same interval that adds the step.
  • When a version step has to transform data, write a custom stage and test the transformation, not just the fact that the store opens.
  • Do not invent a value to fill a column that older data genuinely does not answer. Optional and absent is a truthful migration; a fabricated default is not.