mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
38656eeffd
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.
540 lines
21 KiB
TypeScript
540 lines
21 KiB
TypeScript
import type { RoadtripPreferences } from '@trek/shared';
|
|
import Dexie, { type Table } from 'dexie';
|
|
import type { Trip, Day, Place, PackingItem, TodoItem, BudgetItem, Reservation, TripFile, Accommodation, TripMember, Tag, Category } from '../types';
|
|
|
|
/** TripMember enriched with tripId so we can index by trip. */
|
|
export interface CachedTripMember extends TripMember {
|
|
tripId: number;
|
|
}
|
|
|
|
// ── Queue + sync types ────────────────────────────────────────────────────────
|
|
|
|
// 'conflict' is terminal-until-resolved: the server rejected the replay because
|
|
// the entity changed underneath the offline edit (#1135 ask 3). It is surfaced
|
|
// to the user for a keep-mine / keep-theirs decision rather than dropped.
|
|
export type MutationStatus = 'pending' | 'syncing' | 'failed' | 'conflict';
|
|
|
|
export interface QueuedMutation {
|
|
/** UUID — also used as X-Idempotency-Key sent to the server */
|
|
id: string;
|
|
tripId: number;
|
|
method: 'POST' | 'PUT' | 'PATCH' | 'DELETE';
|
|
url: string;
|
|
body: unknown;
|
|
createdAt: number;
|
|
status: MutationStatus;
|
|
attempts: number;
|
|
lastError: string | null;
|
|
/** Dexie table name to write the server response into after flush (e.g. 'places') */
|
|
resource?: string;
|
|
/** For CREATE mutations enqueued offline: the temporary negative id written to Dexie */
|
|
tempId?: number;
|
|
/** For DELETE mutations: the entity id to remove from Dexie on flush */
|
|
entityId?: number;
|
|
/**
|
|
* For PUT/DELETE enqueued offline against a still-unsynced (negative-id) entity:
|
|
* the temp id of the target. The url carries an `{id}` placeholder that the
|
|
* mutation queue rewrites to the real server id once the dependent CREATE flushes.
|
|
*/
|
|
tempEntityId?: number;
|
|
/**
|
|
* Optimistic-concurrency token: the entity's `updated_at` at the moment the
|
|
* offline edit was made. Sent as `X-Base-Updated-At` on replay so the server
|
|
* can reject the write (409) if someone else changed the entity in the
|
|
* meantime. Absent for creates and for resources without a token.
|
|
*/
|
|
baseUpdatedAt?: string | null;
|
|
/**
|
|
* Set when the replay came back 409: the server's current version of the
|
|
* entity, kept so the conflict resolver can show "theirs" beside "mine"
|
|
* (which is reconstructed from `body`). Only present while status==='conflict'.
|
|
*/
|
|
conflictServer?: unknown;
|
|
/** When the conflict was detected (for ordering / display). */
|
|
conflictAt?: number;
|
|
/**
|
|
* When the row was marked 'syncing'. Only meaningful while it is — the flush
|
|
* that set it clears the row on success or moves it off 'syncing' on failure.
|
|
* A stamp that outlives its flush is how a killed tab is recognised on the
|
|
* next one (see mutationQueue's STUCK_SYNCING_MS).
|
|
*/
|
|
syncingSince?: number;
|
|
/**
|
|
* Not before this time (ms): set after the server answered with a 5xx, so a
|
|
* write the server keeps failing on is retried with growing gaps rather than
|
|
* on every trigger.
|
|
*/
|
|
retryAfter?: number;
|
|
}
|
|
|
|
export interface SyncMeta {
|
|
tripId: number;
|
|
lastSyncedAt: number | null;
|
|
status: 'idle' | 'syncing' | 'error';
|
|
/** Bounding box [minLng, minLat, maxLng, maxLat] of pre-downloaded map tiles */
|
|
tilesBbox: [number, number, number, number] | null;
|
|
/** Non-photo files available offline for this trip after the last sync. */
|
|
filesCachedCount: number;
|
|
/**
|
|
* The rounded bbox the cached area places were fetched for. Compared rather
|
|
* than a timestamp: adding a place inside the area the trip already covers
|
|
* should not re-download it, and moving the trip should.
|
|
*
|
|
* Optional so a row written before this landed still reads.
|
|
*/
|
|
areaPlacesKey?: string;
|
|
}
|
|
|
|
export interface BlobCacheEntry {
|
|
/** Relative URL, e.g. "/api/files/42/download" */
|
|
url: string;
|
|
/**
|
|
* Trip this blob belongs to, so it is evicted together with the trip in
|
|
* clearTripData. Legacy rows cached before v3 carry the sentinel -1.
|
|
*/
|
|
tripId: number;
|
|
blob: Blob;
|
|
/** Byte size captured at insert time — Blob.size is not reliably preserved
|
|
* across IndexedDB round-trips, so the LRU budget reads this instead. */
|
|
bytes: number;
|
|
mime: string;
|
|
cachedAt: number;
|
|
}
|
|
|
|
/**
|
|
* A place from the TREK Places index, cached for the area of one trip.
|
|
*
|
|
* NOT a trip place — those are in `places` and belong to the user. These are
|
|
* candidates: the shops, restaurants and stations that happen to be near where
|
|
* the trip goes, taken once so search still answers on a plane. They are
|
|
* disposable and are re-fetched whenever the trip's area changes.
|
|
*
|
|
* The key is the GERS id, which is stable across index rebuilds; `tripId` is
|
|
* indexed so the whole set can be dropped with the trip.
|
|
*/
|
|
export interface CachedAreaPlace {
|
|
/** GERS id, without the `gers:` prefix the server puts on `osm_id`. */
|
|
gers: string;
|
|
tripId: number;
|
|
name: string;
|
|
/** Lowercased, diacritics folded — what an offline search actually matches on. */
|
|
searchName: string;
|
|
address: string;
|
|
lat: number | null;
|
|
lng: number | null;
|
|
category: string | null;
|
|
website: string | null;
|
|
phone: string | null;
|
|
cachedAt: number;
|
|
}
|
|
|
|
/** An uploaded booking-import source file, kept so the review flow can attach it to the
|
|
* created bookings even after a page reload during the (background) parse. Keyed by job. */
|
|
export interface ImportSourceFile {
|
|
jobId: string;
|
|
fileName: string;
|
|
blob: Blob;
|
|
createdAt: number;
|
|
}
|
|
|
|
// ── Dexie class ────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* The offline DB is scoped per user so that one account can never read another
|
|
* account's cached data on a shared device. Anonymous (logged-out) state uses
|
|
* the base name; a logged-in user uses `trek-offline-u<userId>`.
|
|
*/
|
|
const ANON_DB_NAME = 'trek-offline';
|
|
|
|
function userDbName(userId: number | string): string {
|
|
return `trek-offline-u${userId}`;
|
|
}
|
|
|
|
/**
|
|
* Best-effort read of the persisted auth snapshot so the very first DB opened on
|
|
* app load (before loadUser resolves) is already the correct per-user one — the
|
|
* PWA can render cached data offline without leaking across users.
|
|
*/
|
|
function initialDbName(): string {
|
|
try {
|
|
const raw = typeof localStorage !== 'undefined' ? localStorage.getItem('trek_auth_snapshot') : null;
|
|
if (!raw) return ANON_DB_NAME;
|
|
const id = JSON.parse(raw)?.state?.user?.id;
|
|
return id != null ? userDbName(id) : ANON_DB_NAME;
|
|
} catch {
|
|
return ANON_DB_NAME;
|
|
}
|
|
}
|
|
|
|
class TrekOfflineDb extends Dexie {
|
|
roadtripPreferences!: Table<{ tripId: number; preferences: RoadtripPreferences }, number>;
|
|
trips!: Table<Trip, number>;
|
|
days!: Table<Day, number>;
|
|
places!: Table<Place, number>;
|
|
packingItems!: Table<PackingItem, number>;
|
|
todoItems!: Table<TodoItem, number>;
|
|
budgetItems!: Table<BudgetItem, number>;
|
|
reservations!: Table<Reservation, number>;
|
|
tripFiles!: Table<TripFile, number>;
|
|
accommodations!: Table<Accommodation, number>;
|
|
tripMembers!: Table<CachedTripMember, [number, number]>;
|
|
tags!: Table<Tag, number>;
|
|
categories!: Table<Category, number>;
|
|
mutationQueue!: Table<QueuedMutation, string>;
|
|
syncMeta!: Table<SyncMeta, number>;
|
|
blobCache!: Table<BlobCacheEntry, string>;
|
|
importFiles!: Table<ImportSourceFile, [string, string]>;
|
|
areaPlaces!: Table<CachedAreaPlace, [string, number]>;
|
|
|
|
constructor(name: string = ANON_DB_NAME) {
|
|
super(name);
|
|
|
|
this.version(1).stores({
|
|
trips: 'id',
|
|
days: 'id, trip_id',
|
|
places: 'id, trip_id',
|
|
packingItems: 'id, trip_id',
|
|
todoItems: 'id, trip_id',
|
|
budgetItems: 'id, trip_id',
|
|
reservations: 'id, trip_id',
|
|
tripFiles: 'id, trip_id',
|
|
mutationQueue:'id, tripId, status, createdAt',
|
|
syncMeta: 'tripId',
|
|
blobCache: 'url, cachedAt',
|
|
});
|
|
|
|
this.version(2).stores({
|
|
accommodations: 'id, trip_id',
|
|
tripMembers: '[tripId+id], tripId',
|
|
tags: 'id',
|
|
categories: 'id',
|
|
});
|
|
|
|
// v3: scope the blob cache by trip so it can be evicted with the trip and
|
|
// bounded by an LRU budget (see enforceBlobBudget).
|
|
this.version(3).stores({
|
|
blobCache: 'url, cachedAt, tripId',
|
|
}).upgrade(async (tx) => {
|
|
await tx.table('blobCache').toCollection().modify((row: Partial<BlobCacheEntry>) => {
|
|
if (row.tripId == null) row.tripId = -1;
|
|
if (row.bytes == null) row.bytes = row.blob?.size ?? 0;
|
|
});
|
|
});
|
|
|
|
// v4: durable store for booking-import source files (survives a reload mid-parse).
|
|
this.version(4).stores({
|
|
importFiles: '[jobId+fileName], jobId, createdAt',
|
|
});
|
|
|
|
// v5: places from the TREK Places index for the trip's area, so search
|
|
// answers offline. `searchName` is indexed because that is what an offline
|
|
// query filters on; `tripId` so the set drops with its trip.
|
|
this.version(6).stores({ roadtripPreferences: 'tripId' });
|
|
this.version(5).stores({
|
|
areaPlaces: 'gers, tripId, searchName',
|
|
});
|
|
|
|
// v7/v8: the same place near two trips is two rows now.
|
|
//
|
|
// Keyed on the GERS id alone, one row could only ever name one trip, and a
|
|
// bulkPut for the second trip rewrote the first trip's rows to point at it.
|
|
// Switching the second trip off then deleted the shared set by tripId, and
|
|
// the first trip's areaPlacesKey still matched its bbox, so nothing ever
|
|
// downloaded them again: a trip whose switch read "on" with no offline
|
|
// search behind it. Dexie cannot change a primary key in place, so the
|
|
// table is dropped and rebuilt, and every stored fingerprint is cleared so
|
|
// the next sync refills what the drop took.
|
|
this.version(7).stores({ areaPlaces: null });
|
|
this.version(8).stores({
|
|
areaPlaces: '[gers+tripId], gers, tripId, searchName',
|
|
}).upgrade(async (tx) => {
|
|
await tx.table('syncMeta').toCollection().modify((row: { areaPlacesKey?: string }) => {
|
|
delete row.areaPlacesKey;
|
|
});
|
|
});
|
|
}
|
|
}
|
|
|
|
// The live instance is swapped on login/logout via reopenForUser/reopenAnonymous.
|
|
// A Proxy keeps the exported `offlineDb` binding stable for the ~19 modules that
|
|
// import it directly, while every access forwards to the current connection.
|
|
let _db = new TrekOfflineDb(initialDbName());
|
|
|
|
export const offlineDb = new Proxy({} as TrekOfflineDb, {
|
|
get(_target, prop) {
|
|
const value = (_db as unknown as Record<string | symbol, unknown>)[prop];
|
|
return typeof value === 'function' ? (value as (...args: unknown[]) => unknown).bind(_db) : value;
|
|
},
|
|
set(_target, prop, value) {
|
|
(_db as unknown as Record<string | symbol, unknown>)[prop] = value;
|
|
return true;
|
|
},
|
|
}) as TrekOfflineDb;
|
|
|
|
async function switchTo(name: string): Promise<void> {
|
|
if (_db.name === name) {
|
|
if (!_db.isOpen()) await _db.open();
|
|
return;
|
|
}
|
|
if (_db.isOpen()) _db.close();
|
|
_db = new TrekOfflineDb(name);
|
|
await _db.open();
|
|
}
|
|
|
|
/** Point the offline DB at a specific user's scoped database (call on login). */
|
|
export async function reopenForUser(userId: number | string): Promise<void> {
|
|
await switchTo(userDbName(userId));
|
|
}
|
|
|
|
/** Point the offline DB at the anonymous database (call on logout). */
|
|
export async function reopenAnonymous(): Promise<void> {
|
|
await switchTo(ANON_DB_NAME);
|
|
}
|
|
|
|
/**
|
|
* Delete the current user's scoped database entirely and return to the anonymous
|
|
* DB. Used on logout so no trace of the account's data remains on the device.
|
|
*/
|
|
export async function deleteCurrentUserDb(): Promise<void> {
|
|
// Already anonymous: there is nothing to delete, and opening a second
|
|
// connection to the same name would leak the current handle for the session.
|
|
if (_db.name === ANON_DB_NAME) {
|
|
await switchTo(ANON_DB_NAME);
|
|
return;
|
|
}
|
|
try { await _db.delete(); } catch { /* ignore — fall through to anon */ }
|
|
_db = new TrekOfflineDb(ANON_DB_NAME);
|
|
await _db.open();
|
|
}
|
|
|
|
// ── Bulk upsert helpers ────────────────────────────────────────────────────────
|
|
|
|
export async function upsertTrip(trip: Trip): Promise<void> {
|
|
await offlineDb.trips.put(trip);
|
|
}
|
|
|
|
export async function upsertDays(days: Day[]): Promise<void> {
|
|
await offlineDb.days.bulkPut(days);
|
|
}
|
|
|
|
export async function upsertPlaces(places: Place[]): Promise<void> {
|
|
await offlineDb.places.bulkPut(places);
|
|
}
|
|
|
|
export async function upsertPackingItems(items: PackingItem[]): Promise<void> {
|
|
await offlineDb.packingItems.bulkPut(items);
|
|
}
|
|
|
|
export async function upsertTodoItems(items: TodoItem[]): Promise<void> {
|
|
await offlineDb.todoItems.bulkPut(items);
|
|
}
|
|
|
|
export async function upsertBudgetItems(items: BudgetItem[]): Promise<void> {
|
|
await offlineDb.budgetItems.bulkPut(items);
|
|
}
|
|
|
|
export async function upsertReservations(items: Reservation[]): Promise<void> {
|
|
await offlineDb.reservations.bulkPut(items);
|
|
}
|
|
|
|
export async function upsertTripFiles(files: TripFile[]): Promise<void> {
|
|
await offlineDb.tripFiles.bulkPut(files);
|
|
}
|
|
|
|
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 }));
|
|
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> {
|
|
await offlineDb.tags.bulkPut(tags);
|
|
}
|
|
|
|
export async function upsertCategories(categories: Category[]): Promise<void> {
|
|
await offlineDb.categories.bulkPut(categories);
|
|
}
|
|
|
|
export async function upsertSyncMeta(meta: SyncMeta): Promise<void> {
|
|
await offlineDb.syncMeta.put(meta);
|
|
}
|
|
|
|
/**
|
|
* Read a pre-downloaded file blob for offline use. Returns null when the file
|
|
* was never cached (or on any read error). The stored MIME is reapplied so the
|
|
* caller's inline-vs-download decision stays correct even if the persisted Blob
|
|
* lost its type.
|
|
*/
|
|
export async function getCachedBlob(url: string): Promise<Blob | null> {
|
|
try {
|
|
const entry = await offlineDb.blobCache.get(url);
|
|
if (!entry) return null;
|
|
return entry.blob.type
|
|
? entry.blob
|
|
: new Blob([entry.blob], { type: entry.mime || 'application/octet-stream' });
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// ── Booking-import source files ─────────────────────────────────────────────
|
|
|
|
/** Abandoned import files (never reviewed) are pruned after this long. */
|
|
const IMPORT_FILE_TTL_MS = 60 * 60_000;
|
|
|
|
/**
|
|
* Persist the uploaded source files for a background import job so the per-item review can
|
|
* attach each document to its booking even if the page reloads during the parse. Best-effort.
|
|
*/
|
|
export async function saveImportFiles(jobId: string, files: File[]): Promise<void> {
|
|
try {
|
|
const now = Date.now();
|
|
await offlineDb.importFiles.bulkPut(files.map(f => ({ jobId, fileName: f.name, blob: f, createdAt: now })));
|
|
// Prune leftovers from imports that were never reviewed.
|
|
await offlineDb.importFiles.where('createdAt').below(now - IMPORT_FILE_TTL_MS).delete();
|
|
} catch { /* the in-memory copy still serves the no-reload path */ }
|
|
}
|
|
|
|
/** A job's stored source files, rebuilt as File objects (name + type preserved for upload). */
|
|
export async function getImportFiles(jobId: string): Promise<File[]> {
|
|
try {
|
|
const rows = await offlineDb.importFiles.where('jobId').equals(jobId).toArray();
|
|
return rows.map(r => new File([r.blob], r.fileName, { type: r.blob.type || 'application/octet-stream' }));
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/** Drop a job's stored source files once they've been handed to the review flow. */
|
|
export async function deleteImportFiles(jobId: string): Promise<void> {
|
|
try { await offlineDb.importFiles.where('jobId').equals(jobId).delete(); } catch { /* ignore */ }
|
|
}
|
|
|
|
// ── Blob-cache budget ───────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Upper bounds for the offline file-blob cache. Kept conservative so trip
|
|
* documents never starve the map-tile cache (sized at MAX_TILES in
|
|
* tilePrefetcher.ts) for the origin's storage quota.
|
|
*/
|
|
export const BLOB_CACHE_MAX_ENTRIES = 200;
|
|
export const BLOB_CACHE_MAX_BYTES = 100 * 1024 * 1024; // 100 MB
|
|
|
|
/**
|
|
* Evict oldest-by-cachedAt blobs until the cache is under both the entry-count
|
|
* and byte budget. Call after inserting new blobs. LRU on insertion time, which
|
|
* is a reasonable proxy for access for write-once document blobs.
|
|
*/
|
|
export async function enforceBlobBudget(
|
|
maxCount = BLOB_CACHE_MAX_ENTRIES,
|
|
maxBytes = BLOB_CACHE_MAX_BYTES,
|
|
): Promise<void> {
|
|
const entries = await offlineDb.blobCache.orderBy('cachedAt').toArray();
|
|
let count = entries.length;
|
|
let totalBytes = entries.reduce((sum, e) => sum + (e.bytes ?? 0), 0);
|
|
if (count <= maxCount && totalBytes <= maxBytes) return;
|
|
|
|
const toDelete: string[] = [];
|
|
for (const e of entries) {
|
|
if (count <= maxCount && totalBytes <= maxBytes) break;
|
|
toDelete.push(e.url);
|
|
totalBytes -= e.bytes ?? 0;
|
|
count -= 1;
|
|
}
|
|
if (toDelete.length) await offlineDb.blobCache.bulkDelete(toDelete);
|
|
}
|
|
|
|
// ── Eviction / cleanup ────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Delete one trip's cached READ data (eviction, per-trip opt-out). The offline
|
|
* write queue is deliberately preserved except for already-dropped 'failed' rows:
|
|
* a trip can be evicted for being stale, or turned off in the storage settings,
|
|
* while it still holds unsynced offline edits (pending/syncing) or unresolved
|
|
* conflicts — those must survive so the user's work is not silently lost (#1135).
|
|
* The replay only needs the queued REST request, not the cached entities, and a
|
|
* successful flush re-adds the canonical row. The full "Clear cache" wipe goes
|
|
* through clearAll(), which intentionally drops everything.
|
|
*/
|
|
export async function clearTripData(tripId: number): Promise<void> {
|
|
await offlineDb.transaction(
|
|
'rw',
|
|
[
|
|
offlineDb.days,
|
|
offlineDb.places,
|
|
offlineDb.packingItems,
|
|
offlineDb.todoItems,
|
|
offlineDb.budgetItems,
|
|
offlineDb.reservations,
|
|
offlineDb.tripFiles,
|
|
offlineDb.accommodations,
|
|
offlineDb.tripMembers,
|
|
offlineDb.mutationQueue,
|
|
offlineDb.syncMeta,
|
|
offlineDb.blobCache,
|
|
offlineDb.areaPlaces,
|
|
offlineDb.roadtripPreferences,
|
|
],
|
|
async () => {
|
|
await offlineDb.roadtripPreferences.delete(tripId);
|
|
await offlineDb.days.where('trip_id').equals(tripId).delete();
|
|
await offlineDb.places.where('trip_id').equals(tripId).delete();
|
|
await offlineDb.packingItems.where('trip_id').equals(tripId).delete();
|
|
await offlineDb.todoItems.where('trip_id').equals(tripId).delete();
|
|
await offlineDb.budgetItems.where('trip_id').equals(tripId).delete();
|
|
await offlineDb.reservations.where('trip_id').equals(tripId).delete();
|
|
await offlineDb.tripFiles.where('trip_id').equals(tripId).delete();
|
|
await offlineDb.accommodations.where('trip_id').equals(tripId).delete();
|
|
await offlineDb.tripMembers.where('tripId').equals(tripId).delete();
|
|
// Keep pending/syncing/conflict mutations — only purge dead 'failed' rows.
|
|
await offlineDb.mutationQueue.where('tripId').equals(tripId).and(m => m.status === 'failed').delete();
|
|
await offlineDb.syncMeta.where('tripId').equals(tripId).delete();
|
|
await offlineDb.blobCache.where('tripId').equals(tripId).delete();
|
|
// The cached places around this trip's area go with it. They are searched
|
|
// across every trip, so leaving them behind kept a switched-off trip
|
|
// answering offline searches — and nothing else ever deleted them, so they
|
|
// accumulated for the life of the install.
|
|
await offlineDb.areaPlaces.where('tripId').equals(tripId).delete();
|
|
},
|
|
);
|
|
// Remove the trip row itself outside the transaction since it's a separate table
|
|
await offlineDb.trips.delete(tripId);
|
|
}
|
|
|
|
/** Wipe the entire offline database (called on logout). */
|
|
export async function clearAll(): Promise<void> {
|
|
await offlineDb.delete();
|
|
// Re-open so subsequent operations don't fail
|
|
await offlineDb.open();
|
|
}
|