diff --git a/client/src/db/offlineDb.ts b/client/src/db/offlineDb.ts index 5c2f16063..a12f72291 100644 --- a/client/src/db/offlineDb.ts +++ b/client/src/db/offlineDb.ts @@ -345,9 +345,36 @@ export async function upsertAccommodations(items: Accommodation[]): Promise(table: TripScopedTable, tripId: number, rows: T[]): Promise { + const target = offlineDb[table] as unknown as Table; + 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 { 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 { diff --git a/client/src/repo/accommodationRepo.ts b/client/src/repo/accommodationRepo.ts index 93cb05500..8713ad4aa 100644 --- a/client/src/repo/accommodationRepo.ts +++ b/client/src/repo/accommodationRepo.ts @@ -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 () => ({ diff --git a/client/src/repo/budgetRepo.ts b/client/src/repo/budgetRepo.ts index b0af41694..a8cbf06ef 100644 --- a/client/src/repo/budgetRepo.ts +++ b/client/src/repo/budgetRepo.ts @@ -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 () => ({ diff --git a/client/src/repo/dayRepo.ts b/client/src/repo/dayRepo.ts index 0424fe59d..b00bf6208 100644 --- a/client/src/repo/dayRepo.ts +++ b/client/src/repo/dayRepo.ts @@ -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 () => ({ diff --git a/client/src/repo/fileRepo.ts b/client/src/repo/fileRepo.ts index 7f85160df..3e6f43fc7 100644 --- a/client/src/repo/fileRepo.ts +++ b/client/src/repo/fileRepo.ts @@ -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 () => ({ diff --git a/client/src/repo/packingRepo.ts b/client/src/repo/packingRepo.ts index ce06a9550..220aad517 100644 --- a/client/src/repo/packingRepo.ts +++ b/client/src/repo/packingRepo.ts @@ -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 () => ({ diff --git a/client/src/repo/placeRepo.ts b/client/src/repo/placeRepo.ts index 54c571d39..800666785 100644 --- a/client/src/repo/placeRepo.ts +++ b/client/src/repo/placeRepo.ts @@ -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 () => ({ diff --git a/client/src/repo/reservationRepo.ts b/client/src/repo/reservationRepo.ts index ac5b766ec..b34544dc3 100644 --- a/client/src/repo/reservationRepo.ts +++ b/client/src/repo/reservationRepo.ts @@ -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 () => ({ diff --git a/client/src/repo/todoRepo.ts b/client/src/repo/todoRepo.ts index bdfb40091..27f1b584c 100644 --- a/client/src/repo/todoRepo.ts +++ b/client/src/repo/todoRepo.ts @@ -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 () => ({ diff --git a/client/src/sync/tripSyncManager.ts b/client/src/sync/tripSyncManager.ts index 9d325b6ec..c4e942693 100644 --- a/client/src/sync/tripSyncManager.ts +++ b/client/src/sync/tripSyncManager.ts @@ -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 { 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 { + // 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)) diff --git a/client/tests/unit/sync/tripSyncManager.test.ts b/client/tests/unit/sync/tripSyncManager.test.ts index e7c16c91e..c09ebc770 100644 --- a/client/tests/unit/sync/tripSyncManager.test.ts +++ b/client/tests/unit/sync/tripSyncManager.test.ts @@ -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); + }); +});