fix(offline): let the cache drop what was deleted elsewhere

The trip sync and every repo list only ever upserted, so a place a
collaborator deleted while this device was offline came back on the
next offline read, and a trip the user had been removed from stayed on
the device. A sync and a full list now replace the trip's rows, keeping
only the ones created offline that have not synced yet, and a cached
trip the server no longer lists is cleared.
This commit is contained in:
Maurice
2026-10-07 16:32:02 +02:00
parent f092e7a95f
commit 38656eeffd
11 changed files with 100 additions and 33 deletions
+28 -1
View File
@@ -345,9 +345,36 @@ export async function upsertAccommodations(items: Accommodation[]): Promise<void
await offlineDb.accommodations.bulkPut(items);
}
type TripScopedTable = 'days' | 'places' | 'packingItems' | 'todoItems' | 'budgetItems' | 'reservations' | 'tripFiles' | 'accommodations';
/**
* Make one trip's rows in `table` match the server's list: put every row it
* sent and drop the ones it no longer has, so a place a collaborator deleted
* while this device was offline does not come back on the next offline read.
* Rows with a negative (temporary) id were created offline and have not
* synced yet; they stay.
*/
export async function replaceTripRows<T extends { id: number }>(table: TripScopedTable, tripId: number, rows: T[]): Promise<void> {
const target = offlineDb[table] as unknown as Table<T, number>;
const keep = new Set(rows.map(r => r.id));
await offlineDb.transaction('rw', target, async () => {
await target
.where('trip_id')
.equals(tripId)
.filter(row => row.id > 0 && !keep.has(row.id))
.delete();
await target.bulkPut(rows);
});
}
export async function upsertTripMembers(tripId: number, members: TripMember[]): Promise<void> {
const rows: CachedTripMember[] = members.map(m => ({ ...m, tripId }));
await offlineDb.tripMembers.bulkPut(rows);
const keep = new Set(rows.map(r => r.id));
await offlineDb.transaction('rw', offlineDb.tripMembers, async () => {
// A member who left the trip leaves the cached list too.
await offlineDb.tripMembers.where('tripId').equals(tripId).filter(m => !keep.has(m.id)).delete();
await offlineDb.tripMembers.bulkPut(rows);
});
}
export async function upsertTags(tags: Tag[]): Promise<void> {
+2 -2
View File
@@ -1,5 +1,5 @@
import { accommodationsApi } from '../api/client'
import { offlineDb, upsertAccommodations } from '../db/offlineDb'
import { offlineDb, replaceTripRows } from '../db/offlineDb'
import { onlineThenCache } from './withOfflineFallback'
import type { Accommodation } from '../types'
@@ -8,7 +8,7 @@ export const accommodationRepo = {
return onlineThenCache(
async () => {
const result = await accommodationsApi.list(tripId)
upsertAccommodations(result.accommodations || []).catch(() => {})
replaceTripRows('accommodations', Number(tripId), result.accommodations || []).catch(() => {})
return result
},
async () => ({
+2 -2
View File
@@ -1,5 +1,5 @@
import { budgetApi } from '../api/client'
import { offlineDb, upsertBudgetItems } from '../db/offlineDb'
import { offlineDb, replaceTripRows } from '../db/offlineDb'
import { onlineThenCache } from './withOfflineFallback'
import type { BudgetItem } from '../types'
@@ -8,7 +8,7 @@ export const budgetRepo = {
return onlineThenCache(
async () => {
const result = await budgetApi.list(tripId)
void upsertBudgetItems(result.items)
void replaceTripRows('budgetItems', Number(tripId), result.items)
return result
},
async () => ({
+2 -2
View File
@@ -1,5 +1,5 @@
import { daysApi } from '../api/client'
import { offlineDb, upsertDays, upsertTrip } from '../db/offlineDb'
import { offlineDb, replaceTripRows, upsertTrip } from '../db/offlineDb'
import { isEffectivelyOffline } from '../sync/networkMode'
import { onlineThenCache } from './withOfflineFallback'
import type { Day, Trip } from '../types'
@@ -14,7 +14,7 @@ export const dayRepo = {
return onlineThenCache(
async () => {
const result = await daysApi.list(tripId)
void upsertDays(result.days)
void replaceTripRows('days', Number(tripId), result.days)
return result
},
async () => ({
+2 -2
View File
@@ -1,5 +1,5 @@
import { filesApi } from '../api/client'
import { offlineDb, upsertTripFiles } from '../db/offlineDb'
import { offlineDb, replaceTripRows } from '../db/offlineDb'
import { onlineThenCache } from './withOfflineFallback'
import type { TripFile } from '../types'
@@ -8,7 +8,7 @@ export const fileRepo = {
return onlineThenCache(
async () => {
const result = await filesApi.list(tripId)
void upsertTripFiles(result.files)
void replaceTripRows('tripFiles', Number(tripId), result.files)
return result
},
async () => ({
+2 -2
View File
@@ -1,5 +1,5 @@
import { packingApi } from '../api/client'
import { offlineDb, upsertPackingItems } from '../db/offlineDb'
import { offlineDb, replaceTripRows } from '../db/offlineDb'
import { mutationQueue, generateUUID, nextTempId } from '../sync/mutationQueue'
import { isEffectivelyOffline } from '../sync/networkMode'
import { onlineThenCache } from './withOfflineFallback'
@@ -10,7 +10,7 @@ export const packingRepo = {
return onlineThenCache(
async () => {
const result = await packingApi.list(tripId)
void upsertPackingItems(result.items)
void replaceTripRows('packingItems', Number(tripId), result.items)
return result
},
async () => ({
+3 -2
View File
@@ -1,5 +1,5 @@
import { placesApi } from '../api/client'
import { offlineDb, upsertPlaces } from '../db/offlineDb'
import { offlineDb, replaceTripRows, upsertPlaces } from '../db/offlineDb'
import { mutationQueue, generateUUID, nextTempId } from '../sync/mutationQueue'
import { isEffectivelyOffline } from '../sync/networkMode'
import { onlineThenCache } from './withOfflineFallback'
@@ -10,7 +10,8 @@ export const placeRepo = {
return onlineThenCache(
async () => {
const result = await placesApi.list(tripId, params)
void upsertPlaces(result.places)
// Filtered lists are a subset; only the whole list may drop what it lacks.
void (params ? upsertPlaces(result.places) : replaceTripRows('places', Number(tripId), result.places))
return result
},
async () => ({
+2 -2
View File
@@ -1,5 +1,5 @@
import { reservationsApi } from '../api/client'
import { offlineDb, upsertReservations } from '../db/offlineDb'
import { offlineDb, replaceTripRows } from '../db/offlineDb'
import { onlineThenCache } from './withOfflineFallback'
import type { Reservation } from '../types'
@@ -8,7 +8,7 @@ export const reservationRepo = {
return onlineThenCache(
async () => {
const result = await reservationsApi.list(tripId)
void upsertReservations(result.reservations)
void replaceTripRows('reservations', Number(tripId), result.reservations)
return result
},
async () => ({
+2 -2
View File
@@ -1,5 +1,5 @@
import { todoApi } from '../api/client'
import { offlineDb, upsertTodoItems } from '../db/offlineDb'
import { offlineDb, replaceTripRows } from '../db/offlineDb'
import { onlineThenCache } from './withOfflineFallback'
import type { TodoItem } from '../types'
@@ -8,7 +8,7 @@ export const todoRepo = {
return onlineThenCache(
async () => {
const result = await todoApi.list(tripId)
void upsertTodoItems(result.items)
void replaceTripRows('todoItems', Number(tripId), result.items)
return result
},
async () => ({
+20 -16
View File
@@ -15,14 +15,7 @@ import { tripsApi, tagsApi, categoriesApi } from '../api/client'
import {
offlineDb,
upsertTrip,
upsertDays,
upsertPlaces,
upsertPackingItems,
upsertTodoItems,
upsertBudgetItems,
upsertReservations,
upsertTripFiles,
upsertAccommodations,
replaceTripRows,
upsertTripMembers,
upsertTags,
upsertCategories,
@@ -116,15 +109,17 @@ function isVideo(file: TripFile): boolean {
async function syncTrip(tripId: number): Promise<void> {
const bundle = await tripsApi.bundle(tripId) as TripBundle
// Replace, not merge: deletions made elsewhere while this device was offline
// reach the cache only through here, since the live events were missed.
await upsertTrip(bundle.trip)
await upsertDays(bundle.days)
await upsertPlaces(bundle.places)
await upsertPackingItems(bundle.packingItems)
await upsertTodoItems(bundle.todoItems)
await upsertBudgetItems(bundle.budgetItems)
await upsertReservations(bundle.reservations)
await upsertTripFiles(bundle.files)
await upsertAccommodations(bundle.accommodations || [])
await replaceTripRows('days', tripId, bundle.days)
await replaceTripRows('places', tripId, bundle.places)
await replaceTripRows('packingItems', tripId, bundle.packingItems)
await replaceTripRows('todoItems', tripId, bundle.todoItems)
await replaceTripRows('budgetItems', tripId, bundle.budgetItems)
await replaceTripRows('reservations', tripId, bundle.reservations)
await replaceTripRows('tripFiles', tripId, bundle.files)
await replaceTripRows('accommodations', tripId, bundle.accommodations || [])
await upsertTripMembers(tripId, bundle.members || [])
// Merged onto the existing row, not written over it: `put` replaces the whole
// record, and the row also carries `areaPlacesKey` — the fingerprint that says
@@ -209,6 +204,15 @@ let _syncing = false
* sync; clears Dexie for stale or user-disabled trips as a side effect.
*/
async function reconcileTrips(trips: Trip[]): Promise<Trip[]> {
// A cached trip the server no longer lists was deleted, or this user was
// removed from it. Its data has no business staying on the device.
const listed = new Set(trips.map(t => t.id))
const gone = (await offlineDb.trips.toArray()).filter(t => t.id > 0 && !listed.has(t.id))
await Promise.all(gone.map(async t => {
await clearTripData(t.id).catch(console.error)
await offlineDb.trips.delete(t.id)
}))
const stale = trips.filter(isStale)
// Trips the user turned off explicitly are evicted regardless of date.
const disabled = trips.filter(t => !isTripOfflineEnabled(t.id))
@@ -437,3 +437,38 @@ describe('tripSyncManager.syncAll — logout while syncing', () => {
});
vi.mock('../../../src/repo/roadtripPreferencesRepo', () => ({ roadtripPreferencesRepo: { read: vi.fn(async () => ({})) } }))
describe('tripSyncManager.syncAll — deletions made elsewhere', () => {
it('drops cached rows the server no longer has, and keeps the ones created offline', async () => {
const tripId = 400;
const bundle = makeBundle(tripId);
await upsertTrip(bundle.trip);
// Deleted by a collaborator while this device was offline.
await offlineDb.places.put(buildPlace({ trip_id: tripId, id: 7001 }));
// Created offline, not synced yet: a negative temp id.
await offlineDb.places.put(buildPlace({ trip_id: tripId, id: -55 }));
// Another trip's place is none of this sync's business.
await offlineDb.places.put(buildPlace({ trip_id: 999, id: 7002 }));
server.use(
http.get('/api/trips', () => HttpResponse.json({ trips: [buildTrip({ id: tripId, end_date: dateOffset(5) })] })),
http.get(`/api/trips/${tripId}/bundle`, () => HttpResponse.json(bundle)),
);
await tripSyncManager.syncAll();
const ids = (await offlineDb.places.toArray()).map(p => p.id).sort((a, b) => a - b);
expect(ids).toEqual([-55, bundle.places[0].id, 7002].sort((a, b) => a - b));
});
it('clears a cached trip the server no longer lists (deleted, or this user was removed)', async () => {
const gone = makeBundle(500);
await upsertTrip(gone.trip);
await offlineDb.places.put(buildPlace({ trip_id: 500, id: 8001 }));
server.use(http.get('/api/trips', () => HttpResponse.json({ trips: [] })));
await tripSyncManager.syncAll();
expect(await offlineDb.trips.get(500)).toBeUndefined();
expect(await offlineDb.places.where('trip_id').equals(500).count()).toBe(0);
});
});