mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
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:
@@ -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> {
|
||||
|
||||
@@ -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 () => ({
|
||||
|
||||
@@ -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 () => ({
|
||||
|
||||
@@ -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 () => ({
|
||||
|
||||
@@ -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 () => ({
|
||||
|
||||
@@ -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 () => ({
|
||||
|
||||
@@ -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 () => ({
|
||||
|
||||
@@ -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 () => ({
|
||||
|
||||
@@ -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 () => ({
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user