fix(offline): a missing driving-settings row no longer discards the trip bundle

syncTrip read the roadtrip preferences before writing anything and rethrew
everything but a 404. That read has an offline fallback which throws a bare
Error when there is no cached row, which is the state a first sync is in and
the state a logout leaves behind, so the whole downloaded bundle for that trip
was dropped while the run still counted it. Settings reported "N trips stored"
over an empty database, and the trips were gone the moment the connection was.
The read moved to the end and is never fatal now: it belongs to an optional
addon and the addon answers 404 when it is off.

Two things that fed it, from the same trace. A sync run started on
navigator.onLine alone, so work-offline downloaded over the real connection
while every repo read answered from cache. And work-offline itself survived a
logout, in localStorage, while step six of the same logout deleted the database
it reads from, so the next account came up believing it was offline with
nothing to be offline with.

Also in this pass: deleting a hotel booking released its day stop after
deleting the booking row, which now clears the stop's pointer by trigger. The
release goes first, or the stop stands with nothing left to reach it.
This commit is contained in:
Maurice
2026-09-13 22:37:39 +02:00
parent c42cf5477b
commit 89bae414ff
4 changed files with 59 additions and 15 deletions
@@ -884,18 +884,22 @@ export class ReservationsService {
let accommodationDeleted = false;
let stayMirror = noStayMirror();
if (reservation.accommodation_id) {
// trip_id in the WHERE, not just the reservation's own scope: a row
// written before referencesOutsideTrip existed can still carry a
// foreign accommodation_id, and the cascade must not follow it.
const removed = this.db.run(
'DELETE FROM day_accommodations WHERE id = ? AND trip_id = ?', reservation.accommodation_id, tripId,
// trip_id in the check, not just the reservation's own scope: a row written
// before referencesOutsideTrip existed can still carry a foreign
// accommodation_id, and the cascade must not follow it. The stops go by
// accommodation id alone, which is exactly the reach that guard denies.
const ownStay = this.db.get<{ id: number }>(
'SELECT id FROM day_accommodations WHERE id = ? AND trip_id = ?', reservation.accommodation_id, tripId,
);
accommodationDeleted = removed.changes > 0;
// Only once that DELETE actually took a row on THIS trip. The stops go by
// accommodation id alone, which is exactly the reach the trip_id guard above
// is there to deny a foreign id. A stop left carrying a dead booking's id is
// one the day list hides and nobody can reach to remove.
if (accommodationDeleted) stayMirror = this.accommodations.dropStayStops(reservation.accommodation_id);
if (ownStay) {
// Released before the row goes, not after: the release looks the stops up
// by accommodation id, and that pointer is cleared the moment the stay is
// deleted. Reversed, the stop stands with nothing left to remove it, and
// the day list hides it for carrying a booking id.
stayMirror = this.accommodations.dropStayStops(reservation.accommodation_id);
this.db.run('DELETE FROM day_accommodations WHERE id = ? AND trip_id = ?', reservation.accommodation_id, tripId);
accommodationDeleted = true;
}
}
const linkedBudget = this.db.get<{ id: number }>('SELECT id FROM budget_items WHERE trip_id = ? AND reservation_id = ?', tripId, id);