mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-13 07:40:59 +00:00
51ed038374
Threads carry a journey scope next to the trip scope, with journey rights (outsider 404, viewers without write tools, addon off), its own routes and a book context, and the client keys chats by trip or journey.
956 lines
45 KiB
TypeScript
956 lines
45 KiB
TypeScript
/**
|
|
* Mutation queue — offline write queue backed by IndexedDB (Dexie).
|
|
*
|
|
* Flow:
|
|
* offline create/update/delete → enqueue() → optimistic Dexie write (in repo)
|
|
* online trigger → flush() → replay REST with X-Idempotency-Key header → update Dexie
|
|
*/
|
|
import { offlineDb, MUTATION_SCHEMA_VERSION } from '../db/offlineDb'
|
|
import { cacheTourAnswer } from '../db/cacheTour'
|
|
import { apiClient } from '../api/client'
|
|
import { isAuthed } from './authGate'
|
|
import { isEffectivelyOffline } from './networkMode'
|
|
import { getOfflinePrefs } from './offlinePrefs'
|
|
import { randomId } from '../utils/randomId'
|
|
import type { QueuedMutation } from '../db/offlineDb'
|
|
import type { Table } from 'dexie'
|
|
import { roadtripPreferencesResponseSchema, assignmentSchema, tourCreateResponseSchema, tourDraftResponseSchema, tourVisitListResponseSchema, type TourCreateResponse } from '@trek/shared'
|
|
import { cacheAssignment } from '../db/cacheAssignment'
|
|
import { replaceAssignmentVisits } from '../db/cacheTourVisits'
|
|
import { cacheTourDraft } from '../db/cacheTourDrafts'
|
|
import type { Assignment, AssignmentParticipant, BudgetItem, BudgetItemMember, Day, DayNote, Reservation, Trip, TripFile } from '../types'
|
|
|
|
// Map Dexie table names used in `resource` field → actual Dexie tables.
|
|
function getTable(resource: string): Table | undefined {
|
|
const map: Record<string, Table> = {
|
|
places: offlineDb.places,
|
|
packingItems: offlineDb.packingItems,
|
|
todoItems: offlineDb.todoItems,
|
|
budgetItems: offlineDb.budgetItems,
|
|
reservations: offlineDb.reservations,
|
|
accommodations: offlineDb.accommodations,
|
|
tripFiles: offlineDb.tripFiles,
|
|
}
|
|
return map[resource]
|
|
}
|
|
|
|
/**
|
|
* Generate a v4-style UUID using the platform crypto API.
|
|
*
|
|
* The implementation moved to utils/randomId so the axios interceptor, which
|
|
* mints the same header for online writes, cannot drift away from it — this
|
|
* value becomes the X-Idempotency-Key and a duplicate silently drops a write.
|
|
*/
|
|
export function generateUUID(): string {
|
|
return randomId()
|
|
}
|
|
|
|
// The flush in progress, from the moment flush() is called until its last pass is
|
|
// done, including the time it waits for another tab's lock. A second call gets
|
|
// this same promise, so no caller resolves while a pass is still to come.
|
|
let _flushRun: Promise<void> | null = null
|
|
// Set when flush() is called while a pass is already running. That pass read its
|
|
// pending rows when it began, so a write queued since then waits for one more
|
|
// pass, which the running flush starts once that pass is done.
|
|
let _flushAgain = false
|
|
|
|
/**
|
|
* Take the cross-tab flush lock, waiting while another tab holds it. Every open
|
|
* tab of the account shares the queue and runs its own triggers, and the
|
|
* in-progress flush only covers its own tab: two tabs read the same pending rows
|
|
* and replay each of them twice, leaning on the server's replay cache to answer
|
|
* the second. Waiting rather than skipping matters to the callers: the online
|
|
* trigger re-seeds Dexie from the server once flush() resolves, and the socket
|
|
* refetches the trip, so a flush that returned while another tab was still
|
|
* replaying would let them lay the server's older rows over edits that are
|
|
* about to land. The waiting tab then runs its own pass, which finds what the
|
|
* holder left. Resolves to the release function. Where the Web Locks API is
|
|
* missing or refuses the request, the flush runs as before, guarded by the
|
|
* flag alone. The lock goes with the tab, so a tab killed mid-flush never
|
|
* strands it.
|
|
*/
|
|
function acquireFlushLock(): Promise<() => void> {
|
|
const noop = () => {}
|
|
const locks = typeof navigator === 'undefined' ? undefined : navigator.locks
|
|
if (!locks || typeof locks.request !== 'function') return Promise.resolve(noop)
|
|
return new Promise(resolve => {
|
|
locks
|
|
.request(`trek-mutation-flush:${offlineDb.name}`, () => new Promise<void>(release => resolve(release)))
|
|
.catch(() => resolve(noop))
|
|
})
|
|
}
|
|
// A row sits on 'syncing' only while its request is in flight, and the shared
|
|
// axios instance times out at 8s. Anything still 'syncing' a minute later
|
|
// belongs to a flush that never reached its catch — the tab was killed, the PWA
|
|
// was evicted, the device slept mid-request. flush() only ever selects
|
|
// 'pending', so without recovery that row is stranded: counted in the badge,
|
|
// never replayed, and blocking every dependent behind it.
|
|
const STUCK_SYNCING_MS = 60_000
|
|
// Monotonically increasing timestamp so same-millisecond enqueues
|
|
// still get a deterministic FIFO order when sorted by createdAt.
|
|
let _lastTs = 0
|
|
// Monotonic counter for offline temp ids. Date.now() alone collides when two
|
|
// creates land in the same millisecond (bulk import, rapid tapping), which would
|
|
// overwrite one optimistic Dexie row. This guarantees distinct negative ids.
|
|
let _lastTempId = 0
|
|
|
|
/**
|
|
* Mint a collision-free temporary (negative) id for an offline-created entity.
|
|
* Monotonic across the session so same-millisecond creates never collide.
|
|
* Never use it for trips: the assistant keys a journey's chat by the negated
|
|
* journey id (store/assistantChatKey.ts) and relies on trip ids being positive.
|
|
*/
|
|
export function nextTempId(): number {
|
|
const now = Date.now()
|
|
_lastTempId = now > _lastTempId ? now : _lastTempId + 1
|
|
return -_lastTempId
|
|
}
|
|
|
|
/**
|
|
* A write the server answers with a 5xx this many times is parked as failed.
|
|
* The gaps double from 30 s, so the last try comes about two hours after the
|
|
* first: long enough for a restart or an outage, short of blocking forever.
|
|
*/
|
|
const MAX_SERVER_ERROR_ATTEMPTS = 8
|
|
const SERVER_ERROR_BACKOFF_MS = 30_000
|
|
|
|
/** HTTP statuses that should be retried later rather than treated as terminal. */
|
|
function isRetryableStatus(status: number | undefined): boolean {
|
|
// 401: token expired mid-flush (offline window) — retry after re-auth.
|
|
// 408/425/429: timeout / too-early / rate-limited — transient.
|
|
return status === 401 || status === 408 || status === 425 || status === 429
|
|
}
|
|
|
|
/**
|
|
* The entity a queued write targets, as `resource:id`. A create is keyed by its
|
|
* temporary id, which is also what the writes queued against it carry until it
|
|
* syncs. Undefined when the row names no entity (it is then never held back).
|
|
*/
|
|
function entityKey(m: QueuedMutation): string | undefined {
|
|
if (!m.resource) return undefined
|
|
const id = m.entityId ?? m.tempEntityId ?? (m.method === 'POST' ? m.tempId : undefined)
|
|
return id === undefined ? undefined : `${m.resource}:${id}`
|
|
}
|
|
|
|
/**
|
|
* Entities with a parked write ('failed', or a 'conflict' waiting for the user),
|
|
* each with the time of the earliest such write. A later write to the same
|
|
* entity must not overtake it: replayed afterwards by Try again or Keep mine,
|
|
* the older write would silently overwrite the newer one, and only places and
|
|
* packing items carry a token the server could refuse it with.
|
|
*/
|
|
async function parkedEntities(): Promise<Map<string, number>> {
|
|
const parked = await offlineDb.mutationQueue.where('status').anyOf(['failed', 'conflict']).toArray()
|
|
const held = new Map<string, number>()
|
|
for (const m of parked) holdEntity(held, m)
|
|
return held
|
|
}
|
|
|
|
function holdEntity(held: Map<string, number>, m: QueuedMutation): void {
|
|
const key = entityKey(m)
|
|
if (key === undefined) return
|
|
const since = held.get(key)
|
|
if (since === undefined || m.createdAt < since) held.set(key, m.createdAt)
|
|
}
|
|
|
|
// The UI build stamped onto each queued write, for diagnosis. Empty where the
|
|
// bundler defined none.
|
|
const BUILD_VERSION: string = typeof __TREK_UI_VERSION__ === 'string' ? __TREK_UI_VERSION__ : ''
|
|
|
|
/**
|
|
* Was this write queued by a build whose row format this one does not know? A
|
|
* tab still on the previous bundle, or an install rolled back, would otherwise
|
|
* replay a shape it cannot read. Such a row stays in the queue as it is, for
|
|
* the build that wrote it; a row without a stamp predates it and is format 1.
|
|
*/
|
|
function isFromNewerFormat(m: QueuedMutation): boolean {
|
|
return (m.schemaVersion ?? 1) > MUTATION_SCHEMA_VERSION
|
|
}
|
|
|
|
/** Pull the server's current entity out of a 409 response body ({ server: {...} }). */
|
|
function extractConflictServer(err: unknown): unknown {
|
|
const data = (err as { response?: { data?: unknown } })?.response?.data
|
|
if (data && typeof data === 'object' && 'server' in data) {
|
|
return (data as { server: unknown }).server
|
|
}
|
|
return null
|
|
}
|
|
|
|
/** Write a server entity into its Dexie table (used when "theirs" wins a conflict). */
|
|
async function applyServerEntity(mutation: QueuedMutation, server: unknown): Promise<void> {
|
|
if (!mutation.resource || !server || typeof server !== 'object' || !('id' in server)) return
|
|
const table = getTable(mutation.resource)
|
|
if (table) await table.put(server)
|
|
}
|
|
|
|
/**
|
|
* Roll back what an offline CREATE wrote under its temporary id, once the
|
|
* server refused it for good. A tour is a place plus its facet, so both go.
|
|
*/
|
|
async function dropTempRows(mutation: QueuedMutation): Promise<void> {
|
|
if (mutation.method === 'DELETE' || mutation.tempId === undefined || !mutation.resource) return
|
|
if (mutation.resource === 'tours') {
|
|
await offlineDb.places.delete(mutation.tempId)
|
|
await offlineDb.tours.delete(mutation.tempId)
|
|
return
|
|
}
|
|
// Visits and day notes live on the cached day, not in a table of their own.
|
|
if (mutation.resource === 'assignments' || mutation.resource === 'dayNotes') {
|
|
const tempId = mutation.tempId
|
|
await offlineDb.days.where('trip_id').equals(mutation.tripId).modify(day => {
|
|
if (mutation.resource === 'assignments') day.assignments = (day.assignments ?? []).filter(a => a.id !== tempId)
|
|
else day.notes_items = (day.notes_items ?? []).filter(n => n.id !== tempId)
|
|
})
|
|
return
|
|
}
|
|
const table = getTable(mutation.resource)
|
|
if (table) await table.delete(mutation.tempId)
|
|
}
|
|
|
|
/**
|
|
* A CREATE replayed and the server answered with the real id: later queued
|
|
* writes still aimed at the temporary one are pointed at it, in memory for
|
|
* this flush and durably for the next.
|
|
*/
|
|
async function remapTempId(mutation: QueuedMutation, realId: number, idMap: Map<number, number>): Promise<void> {
|
|
const tempId = mutation.tempId
|
|
if (tempId === undefined || tempId === realId) return
|
|
idMap.set(tempId, realId)
|
|
await offlineDb.mutationQueue
|
|
.where('tripId')
|
|
.equals(mutation.tripId)
|
|
.filter(m => m.tempEntityId === tempId)
|
|
.modify(m => {
|
|
m.url = m.url.replace('{id}', String(realId))
|
|
m.entityId = realId
|
|
m.tempEntityId = undefined
|
|
})
|
|
const learned = new Map([[tempId, realId]])
|
|
await offlineDb.mutationQueue
|
|
.where('tripId')
|
|
.equals(mutation.tripId)
|
|
.filter(m => remapBodyRefs(m.body, learned) !== m.body)
|
|
.modify(m => { m.body = remapBodyRefs(m.body, learned) })
|
|
}
|
|
|
|
// Body fields that can name an entity created offline earlier in the queue: an
|
|
// expense saved from a place or a booking that has not synced yet, a booking
|
|
// linked to such a place or stay.
|
|
const TEMP_REF_FIELDS = ['place_id', 'reservation_id', 'accommodation_id'] as const
|
|
// Objects inside a body that carry such fields of their own: the stay a hotel
|
|
// booking creates alongside names its place.
|
|
const TEMP_REF_OBJECTS = ['create_accommodation'] as const
|
|
// Resources whose writes carry no version token (see the 409 branch of the flush).
|
|
const UNVERSIONED_RESOURCES = new Set(['assignments', 'days', 'dayNotes', 'tourStationVisits'])
|
|
// A new order of a day's visits can name visits created offline.
|
|
const TEMP_REF_LISTS = ['orderedIds'] as const
|
|
// The 404s of a walk's mark that name a part of it that is gone: the trip, the
|
|
// day, the walk or the stop (TripAccessGuard, TourVisitsService). Any other 404,
|
|
// the Tours addon turned off among them, fails like any other refusal.
|
|
const GONE_WALK_PARTS = new Set(['Trip not found', 'Assignment not found', 'City walk not found', 'Station not found'])
|
|
|
|
/**
|
|
* The body with every link to an offline-created entity pointed at the real id
|
|
* the queue learned for it. The same body when nothing changed.
|
|
*/
|
|
function remapBodyRefs(body: unknown, idMap: Map<number, number>): unknown {
|
|
if (!body || typeof body !== 'object' || Array.isArray(body)) return body
|
|
const fields = body as Record<string, unknown>
|
|
let remapped: Record<string, unknown> | null = null
|
|
for (const field of TEMP_REF_FIELDS) {
|
|
const ref = fields[field]
|
|
const realId = typeof ref === 'number' && ref < 0 ? idMap.get(ref) : undefined
|
|
if (realId === undefined) continue
|
|
remapped ??= { ...fields }
|
|
remapped[field] = realId
|
|
}
|
|
for (const key of TEMP_REF_OBJECTS) {
|
|
const nested = fields[key]
|
|
const next = remapBodyRefs(nested, idMap)
|
|
if (next === nested) continue
|
|
remapped ??= { ...fields }
|
|
remapped[key] = next
|
|
}
|
|
for (const key of TEMP_REF_LISTS) {
|
|
const list = fields[key]
|
|
if (!Array.isArray(list) || !list.some(id => typeof id === 'number' && idMap.has(id))) continue
|
|
remapped ??= { ...fields }
|
|
remapped[key] = list.map(id => (typeof id === 'number' ? idMap.get(id) ?? id : id))
|
|
}
|
|
return remapped ?? body
|
|
}
|
|
|
|
/**
|
|
* The body as it is sent: every link the queue has learned the real id of, and
|
|
* a list without the temporary ids it has not. Those name a create that did not
|
|
* sync, which the server would not know.
|
|
*/
|
|
function bodyToSend(body: unknown, idMap: Map<number, number>): unknown {
|
|
const remapped = remapBodyRefs(body, idMap)
|
|
if (!remapped || typeof remapped !== 'object' || Array.isArray(remapped)) return remapped
|
|
const fields = remapped as Record<string, unknown>
|
|
let trimmed: Record<string, unknown> | null = null
|
|
for (const key of TEMP_REF_LISTS) {
|
|
const list = fields[key]
|
|
if (!Array.isArray(list) || !list.some(id => typeof id === 'number' && id < 0)) continue
|
|
trimmed ??= { ...fields }
|
|
trimmed[key] = list.filter(id => typeof id !== 'number' || id > 0)
|
|
}
|
|
return trimmed ?? remapped
|
|
}
|
|
|
|
/**
|
|
* A replayed expense write. Only `item` names the expense: the members route
|
|
* answers `{ members, item }` with the item's bare row and the paid tick answers
|
|
* `{ member }` with no item at all, so neither fits the first-value rule of the
|
|
* generic branch. The bare row keeps the payers and receipts the local copy
|
|
* holds and takes the members the answer lists.
|
|
*/
|
|
async function reconcileBudgetItem(mutation: QueuedMutation, data: unknown, idMap: Map<number, number>): Promise<void> {
|
|
const answer = (data && typeof data === 'object' ? data : {}) as { item?: BudgetItem; members?: BudgetItemMember[] }
|
|
const item = answer.item
|
|
if (!item || typeof item.id !== 'number') return
|
|
if (mutation.tempId !== undefined && mutation.tempId !== item.id) {
|
|
await offlineDb.budgetItems.delete(mutation.tempId)
|
|
await remapTempId(mutation, item.id, idMap)
|
|
}
|
|
const local = await offlineDb.budgetItems.get(item.id)
|
|
await offlineDb.budgetItems.put({ ...local, ...item, ...(answer.members ? { members: answer.members } : {}) })
|
|
}
|
|
|
|
/**
|
|
* A replayed booking write. Only `reservation` names the booking: the travelers
|
|
* route answers `{ travelers, reservation }`, which the first-value rule of the
|
|
* generic branch would read as the traveler list. The travelers the answer
|
|
* lists replace the local ones; a row that does not list them keeps them.
|
|
*/
|
|
async function reconcileReservation(mutation: QueuedMutation, data: unknown, idMap: Map<number, number>): Promise<void> {
|
|
const answer = (data && typeof data === 'object' ? data : {}) as { reservation?: Reservation; travelers?: Reservation['travelers'] }
|
|
const reservation = answer.reservation
|
|
if (!reservation || typeof reservation.id !== 'number') return
|
|
if (mutation.tempId !== undefined && mutation.tempId !== reservation.id) {
|
|
await offlineDb.reservations.delete(mutation.tempId)
|
|
await remapTempId(mutation, reservation.id, idMap)
|
|
}
|
|
const local = await offlineDb.reservations.get(reservation.id)
|
|
await offlineDb.reservations.put({ ...local, ...reservation, ...(answer.travelers ? { travelers: answer.travelers } : {}) })
|
|
}
|
|
|
|
/**
|
|
* A replayed file write. Only `file` names the file: the link route answers
|
|
* `{ success, links }`, and the file routes answer the stored row without the
|
|
* ids of its further links, so the ones the local copy holds stay.
|
|
*/
|
|
async function reconcileTripFile(data: unknown): Promise<void> {
|
|
const file = (data && typeof data === 'object' ? data : {}) as { file?: TripFile }
|
|
if (!file.file || typeof file.file.id !== 'number') return
|
|
const local = await offlineDb.tripFiles.get(file.file.id)
|
|
await offlineDb.tripFiles.put({ ...local, ...file.file })
|
|
}
|
|
|
|
/**
|
|
* A replayed visit write. A create moves the visit on its cached day from the
|
|
* temporary id to the real one; the visit routes answer `{ assignment }`, the
|
|
* participants route `{ participants }`, and a reorder or delete names no visit.
|
|
*/
|
|
async function reconcileAssignment(mutation: QueuedMutation, data: unknown, entityId: number | undefined, idMap: Map<number, number>): Promise<void> {
|
|
const answer = (data && typeof data === 'object' ? data : {}) as { assignment?: unknown; participants?: AssignmentParticipant[] }
|
|
if (answer.assignment) {
|
|
const saved = assignmentSchema.parse(answer.assignment) as Assignment
|
|
const tempId = mutation.tempId
|
|
if (tempId !== undefined && tempId !== saved.id) {
|
|
await offlineDb.days.where('id').equals(saved.day_id).modify(day => {
|
|
day.assignments = (day.assignments ?? []).map(a => (a.id === tempId ? { ...a, ...saved } : a))
|
|
})
|
|
await remapTempId(mutation, saved.id, idMap)
|
|
}
|
|
await cacheAssignment(saved)
|
|
return
|
|
}
|
|
if (answer.participants && entityId !== undefined) {
|
|
const participants = answer.participants
|
|
await offlineDb.days.where('trip_id').equals(mutation.tripId).modify(day => {
|
|
if (!day.assignments?.some(a => a.id === entityId)) return
|
|
day.assignments = day.assignments.map(a => (a.id === entityId ? { ...a, participants } : a))
|
|
})
|
|
}
|
|
}
|
|
|
|
/**
|
|
* A replayed day write. The day answers `{ day }` without its notes, so the
|
|
* cached notes stay, and so do the cached visits: they may hold writes still
|
|
* waiting behind this one.
|
|
*/
|
|
async function reconcileDay(data: unknown): Promise<void> {
|
|
const day = data && typeof data === 'object' ? (data as { day?: Day }).day : undefined
|
|
if (!day || typeof day.id !== 'number') return
|
|
const { assignments: _assignments, notes_items: _notes, ...fields } = day
|
|
await offlineDb.days.where('id').equals(day.id).modify(row => { Object.assign(row, fields) })
|
|
}
|
|
|
|
/**
|
|
* A replayed trip write. The answer lays its fields over the cached trip, which
|
|
* keeps the counts the list shape carries and the answer does not.
|
|
*/
|
|
async function reconcileTrip(data: unknown): Promise<void> {
|
|
const trip = data && typeof data === 'object' ? (data as { trip?: Trip }).trip : undefined
|
|
if (!trip || typeof trip.id !== 'number') return
|
|
const local = await offlineDb.trips.get(trip.id)
|
|
await offlineDb.trips.put({ ...local, ...trip })
|
|
}
|
|
|
|
/** A replayed day note write: the note lands on its cached day, a create under its real id. */
|
|
async function reconcileDayNote(mutation: QueuedMutation, data: unknown, idMap: Map<number, number>): Promise<void> {
|
|
const note = data && typeof data === 'object' ? (data as { note?: DayNote }).note : undefined
|
|
if (!note || typeof note.id !== 'number') return
|
|
const replaceId = mutation.tempId ?? note.id
|
|
await offlineDb.days.where('id').equals(note.day_id).modify(day => {
|
|
const notes = day.notes_items ?? []
|
|
day.notes_items = notes.some(n => n.id === replaceId)
|
|
? notes.map(n => (n.id === replaceId ? { ...n, ...note } : n))
|
|
: [...notes.filter(n => n.id !== note.id), note]
|
|
})
|
|
if (mutation.tempId !== undefined && mutation.tempId !== note.id) await remapTempId(mutation, note.id, idMap)
|
|
}
|
|
|
|
/**
|
|
* A replayed mark of a city walk's stop (CW-03): the day's visits as the server
|
|
* answers them. An answer that does not read as one is left alone rather than
|
|
* holding the queue up; the next read of the trip fetches them again.
|
|
*/
|
|
async function reconcileStationVisits(mutation: QueuedMutation, data: unknown, assignmentId: number | undefined): Promise<void> {
|
|
const answer = tourVisitListResponseSchema.safeParse(data)
|
|
if (!answer.success || assignmentId === undefined) return
|
|
await replaceAssignmentVisits(mutation.tripId, assignmentId, answer.data.visits)
|
|
}
|
|
|
|
/**
|
|
* A 404 that means the write did what it set out to do: a DELETE of something
|
|
* the server no longer has, or a mark of a walk's stop whose stop, day, walk or
|
|
* trip is gone, since its visits went with it.
|
|
*/
|
|
function doneByNotFound(mutation: QueuedMutation, err: unknown): boolean {
|
|
if (mutation.method === 'DELETE') return true
|
|
if (mutation.resource !== 'tourStationVisits') return false
|
|
// Called on a 404 only, so the server answered.
|
|
const reason = (err as { response: { data?: { error?: unknown } } }).response.data?.error
|
|
return typeof reason === 'string' && GONE_WALK_PARTS.has(reason)
|
|
}
|
|
|
|
/** Visits, days, day notes, the trip itself and a walk's marks, each from the answer its route gives. */
|
|
async function reconcilePlanWrite(mutation: QueuedMutation, data: unknown, entityId: number | undefined, idMap: Map<number, number>): Promise<void> {
|
|
if (mutation.method === 'DELETE') return
|
|
if (mutation.resource === 'assignments') await reconcileAssignment(mutation, data, entityId, idMap)
|
|
else if (mutation.resource === 'days') await reconcileDay(data)
|
|
else if (mutation.resource === 'dayNotes') await reconcileDayNote(mutation, data, idMap)
|
|
else if (mutation.resource === 'trips') await reconcileTrip(data)
|
|
else if (mutation.resource === 'tourStationVisits') await reconcileStationVisits(mutation, data, entityId)
|
|
}
|
|
|
|
/**
|
|
* A replayed tour write. The answer names the tour by its place, so the
|
|
* offline place and facet move from the temporary id to the real one. The
|
|
* place keeps its offline copy until the next list replaces it: the server
|
|
* created it from the same route.
|
|
*/
|
|
async function reconcileTour(mutation: QueuedMutation, saved: TourCreateResponse, idMap: Map<number, number>): Promise<void> {
|
|
const realId = saved.tour.place_id
|
|
await offlineDb.transaction('rw', offlineDb.places, offlineDb.tours, async () => {
|
|
if (mutation.tempId !== undefined && mutation.tempId !== realId) {
|
|
const temp = await offlineDb.places.get(mutation.tempId)
|
|
await offlineDb.places.delete(mutation.tempId)
|
|
await offlineDb.tours.delete(mutation.tempId)
|
|
if (temp && !(await offlineDb.places.get(realId))) await offlineDb.places.put({ ...temp, id: realId })
|
|
}
|
|
await cacheTourAnswer(mutation.tripId, saved)
|
|
})
|
|
await remapTempId(mutation, realId, idMap)
|
|
}
|
|
|
|
export const mutationQueue = {
|
|
/**
|
|
* Add a mutation to the queue.
|
|
* Returns the UUID (= idempotency key).
|
|
*/
|
|
async enqueue(
|
|
mutation: Omit<QueuedMutation, 'status' | 'attempts' | 'createdAt' | 'lastError' | 'schemaVersion' | 'buildVersion'>,
|
|
): Promise<string> {
|
|
const now = Date.now()
|
|
_lastTs = now > _lastTs ? now : _lastTs + 1
|
|
const item: QueuedMutation = {
|
|
...mutation,
|
|
status: 'pending',
|
|
attempts: 0,
|
|
createdAt: _lastTs,
|
|
lastError: null,
|
|
schemaVersion: MUTATION_SCHEMA_VERSION,
|
|
buildVersion: BUILD_VERSION,
|
|
}
|
|
await offlineDb.mutationQueue.put(item)
|
|
return item.id
|
|
},
|
|
|
|
/**
|
|
* Does a write to this entity have to join the queue? Offline, always. Online
|
|
* too while an older write to the same entity is still queued, backing off,
|
|
* parked as failed or waiting as a conflict: sent straight to the server the
|
|
* new write would land first, and Try again or the next flush would replay
|
|
* the older one over it. Queued, it waits behind the older write and keeps
|
|
* the order the user made them in. The repos of visits, a day's visits, tours,
|
|
* a walk's marks and the driving settings ask this, because those writes
|
|
* carry no version token; a place or packing item sends one, so the server
|
|
* refuses the older write (409) and it becomes a conflict instead.
|
|
*/
|
|
async mustQueue(resource: string, entityId: number): Promise<boolean> {
|
|
if (isEffectivelyOffline()) return true
|
|
const key = `${resource}:${entityId}`
|
|
const older = await offlineDb.mutationQueue
|
|
.where('status')
|
|
.anyOf(['pending', 'syncing', 'failed', 'conflict'])
|
|
.filter(m => entityKey(m) === key)
|
|
.first()
|
|
return older !== undefined
|
|
},
|
|
|
|
/**
|
|
* Start a flush for a write that joined the queue while online (see
|
|
* mustQueue). It goes out as soon as the write it waits for is through, not
|
|
* at the next trigger: when a flush is already running, that flush runs one
|
|
* more pass once it is done (see flush). Offline this does nothing.
|
|
*/
|
|
sendSoon(): void {
|
|
if (isEffectivelyOffline()) return
|
|
this.flush().catch(console.error)
|
|
},
|
|
|
|
/**
|
|
* Drain the queue: replay each pending mutation against the server in FIFO order.
|
|
* Stops on the first network error (retried on the next trigger). 4xx answers
|
|
* are marked failed and skipped; a DELETE answered 404 counts as done, and so
|
|
* does a mark of a walk's stop whose stop, day or walk is gone. A 5xx
|
|
* holds back only its own trip, retried with growing gaps, and is parked as
|
|
* failed once it has failed MAX_SERVER_ERROR_ATTEMPTS times. A write parked as
|
|
* failed or as a conflict holds back the later writes to the same entity, so
|
|
* they keep their order whatever the user decides about it. A write queued
|
|
* by a newer build (a higher schemaVersion) is skipped and left pending, and
|
|
* holds back the later writes to its entity the same way.
|
|
*
|
|
* A call that arrives while a flush is running, or still waiting for another
|
|
* tab's lock, asks it for one more round (the running pass read the pending
|
|
* rows when it began and would not see a write queued since) and resolves with
|
|
* it. So whoever refetches once flush() resolves, like the socket's reconnect
|
|
* hook, never does so while this tab or another one is still replaying.
|
|
*/
|
|
flush(): Promise<void> {
|
|
if (isEffectivelyOffline() || !isAuthed()) return Promise.resolve()
|
|
if (_flushRun !== null) {
|
|
_flushAgain = true
|
|
return _flushRun
|
|
}
|
|
const run = (async () => {
|
|
try {
|
|
// A "mine wins" auto-resolution dropped its base token; one more pass now
|
|
// overwrites the server unconditionally. Bounded: the retried write carries
|
|
// no token, so it cannot 409 for the same reason. A flush asked for while a
|
|
// pass ran gets its pass here too. Bounded as well: the flag is only set by
|
|
// a call made during a pass, and every pass clears it when it starts.
|
|
let again = true
|
|
while (again) {
|
|
_flushAgain = false
|
|
const needsRetry = await this._flushPass()
|
|
again = (needsRetry || _flushAgain) && !isEffectivelyOffline() && isAuthed()
|
|
}
|
|
} finally {
|
|
_flushRun = null
|
|
}
|
|
})()
|
|
_flushRun = run
|
|
return run
|
|
},
|
|
|
|
/**
|
|
* One pass over the pending rows, under the cross-tab lock. Only flush() calls
|
|
* it. Resolves to true when a write was re-queued for another pass.
|
|
*/
|
|
async _flushPass(): Promise<boolean> {
|
|
const release = await acquireFlushLock()
|
|
// tempId → realId learned during this flush, so a dependent edit/delete
|
|
// queued against an offline-created entity (still holding the negative id)
|
|
// can be rewritten to the server id before it is replayed.
|
|
const idMap = new Map<number, number>()
|
|
// resource:entityId → freshest updated_at applied during this flush. A second
|
|
// queued edit of the same entity must send THIS token, not the stale one its
|
|
// snapshot was loaded with, or it would 409 against our own first edit (#1135).
|
|
const tokenMap = new Map<string, string>()
|
|
// Set when a conflict auto-resolved as "mine wins": the mutation is re-queued
|
|
// without its base token, so one more pass overwrites the server cleanly.
|
|
let needsRetry = false
|
|
try {
|
|
// Reclaim what an interrupted flush left behind before picking up work.
|
|
// Replaying is safe: the mutation id doubles as the X-Idempotency-Key, so
|
|
// a write the server already applied is answered from its replay cache.
|
|
const stuckBefore = Date.now() - STUCK_SYNCING_MS
|
|
await offlineDb.mutationQueue
|
|
.where('status')
|
|
.equals('syncing')
|
|
.filter(m => (m.syncingSince ?? 0) < stuckBefore)
|
|
.modify(m => { m.status = 'pending'; m.syncingSince = undefined })
|
|
|
|
const pending = await offlineDb.mutationQueue
|
|
.where('status')
|
|
.equals('pending')
|
|
.sortBy('createdAt')
|
|
|
|
// Trips whose queue waits behind a write the server failed on. Their later
|
|
// writes must keep their order, but every other trip goes on syncing.
|
|
const blockedTrips = new Set<number>()
|
|
// Entities whose later writes wait behind a parked one, until the user
|
|
// tries it again or discards it. Grows as writes park during this pass.
|
|
const heldEntities = await parkedEntities()
|
|
|
|
for (const mutation of pending) {
|
|
// Re-checked every pass, not just on entry: this loop writes server
|
|
// responses straight into Dexie, and after a logout the proxy points at
|
|
// the shared anonymous database. A flush that started before logout
|
|
// would otherwise seed it with the previous account's rows.
|
|
if (!isAuthed()) break
|
|
if (blockedTrips.has(mutation.tripId)) continue
|
|
const key = entityKey(mutation)
|
|
const heldSince = key === undefined ? undefined : heldEntities.get(key)
|
|
if (heldSince !== undefined && heldSince < mutation.createdAt) continue
|
|
// Queued by a newer build: not sent, not marked, not dropped. It keeps
|
|
// its place for that build, and the later writes to the same entity
|
|
// wait behind it as they do behind a parked one.
|
|
if (isFromNewerFormat(mutation)) {
|
|
holdEntity(heldEntities, mutation)
|
|
continue
|
|
}
|
|
if (mutation.retryAfter !== undefined && mutation.retryAfter > Date.now()) {
|
|
blockedTrips.add(mutation.tripId)
|
|
continue
|
|
}
|
|
|
|
// Mark as syncing so UI can show progress. The stamp is what lets the
|
|
// next flush tell an in-flight row from one a killed tab abandoned.
|
|
await offlineDb.mutationQueue.update(mutation.id, { status: 'syncing', syncingSince: Date.now() })
|
|
|
|
// Resolve a temp-id reference now that earlier CREATEs in this flush
|
|
// may have completed (FIFO order guarantees the CREATE ran first).
|
|
let reqUrl = mutation.url
|
|
let reqEntityId = mutation.entityId
|
|
if (mutation.tempEntityId !== undefined) {
|
|
const realId = idMap.get(mutation.tempEntityId)
|
|
if (realId !== undefined) {
|
|
reqUrl = reqUrl.replace('{id}', String(realId))
|
|
reqEntityId = realId
|
|
}
|
|
}
|
|
// Placeholder still unresolved → the create it depended on is gone
|
|
// (failed or missing). Surface it as failed rather than firing a 404.
|
|
if (reqUrl.includes('{id}')) {
|
|
await offlineDb.mutationQueue.update(mutation.id, {
|
|
status: 'failed',
|
|
attempts: mutation.attempts + 1,
|
|
lastError: 'unresolved temp id (dependent create did not sync)',
|
|
})
|
|
holdEntity(heldEntities, mutation)
|
|
continue
|
|
}
|
|
|
|
try {
|
|
// Send the optimistic-concurrency token when we have one so the server
|
|
// can reject a stale overwrite (409). Absent header => unconditional
|
|
// write (back-compat with servers / resources that don't check it).
|
|
// A newer token learned earlier in THIS flush (an earlier edit of the
|
|
// same entity) overrides the snapshot's stale base.
|
|
const headers: Record<string, string> = { 'X-Idempotency-Key': mutation.id }
|
|
const tokenKey = mutation.resource !== undefined && reqEntityId !== undefined ? `${mutation.resource}:${reqEntityId}` : undefined
|
|
const baseToken = (tokenKey && tokenMap.get(tokenKey)) || mutation.baseUpdatedAt
|
|
if (baseToken) headers['X-Base-Updated-At'] = baseToken
|
|
const response = await apiClient.request({
|
|
method: mutation.method,
|
|
url: reqUrl,
|
|
data: bodyToSend(mutation.body, idMap),
|
|
headers,
|
|
})
|
|
|
|
if (mutation.resource === 'roadtripPreferences') {
|
|
const saved = roadtripPreferencesResponseSchema.parse(response.data)
|
|
await offlineDb.roadtripPreferences.put(saved)
|
|
}
|
|
// The planner's draft as the account now holds it, or none after a discard.
|
|
if (mutation.resource === 'tourDrafts') {
|
|
await cacheTourDraft(mutation.tripId, mutation.method === 'DELETE' ? null : tourDraftResponseSchema.parse(response.data).draft)
|
|
}
|
|
// Apply canonical server response to Dexie
|
|
// The plan's own rows have no table the generic branches below would
|
|
// write, so those pass over them.
|
|
await reconcilePlanWrite(mutation, response.data, reqEntityId, idMap)
|
|
if (mutation.resource === 'tours') {
|
|
await reconcileTour(mutation, tourCreateResponseSchema.parse(response.data), idMap)
|
|
} else if (mutation.resource === 'budgetItems' && mutation.method !== 'DELETE') {
|
|
await reconcileBudgetItem(mutation, response.data, idMap)
|
|
} else if (mutation.resource === 'reservations' && mutation.method !== 'DELETE') {
|
|
await reconcileReservation(mutation, response.data, idMap)
|
|
} else if (mutation.resource === 'tripFiles' && mutation.method !== 'DELETE') {
|
|
await reconcileTripFile(response.data)
|
|
} else if (mutation.method !== 'DELETE' && mutation.resource) {
|
|
const table = getTable(mutation.resource)
|
|
if (table && response.data && typeof response.data === 'object') {
|
|
// Server returns { place: {...} } or { item: {...} } — grab first value
|
|
const values = Object.values(response.data as Record<string, unknown>)
|
|
const entity = values[0]
|
|
if (entity && typeof entity === 'object' && 'id' in entity) {
|
|
const realId = (entity as { id: number }).id
|
|
// Remove temp optimistic entry if id changed (CREATE case) and
|
|
// remap any queued mutations that still target the negative id.
|
|
if (mutation.tempId !== undefined && mutation.tempId !== realId) {
|
|
await table.delete(mutation.tempId)
|
|
await remapTempId(mutation, realId, idMap)
|
|
}
|
|
await table.put(entity)
|
|
// Advance the base-version token of any other queued edits to the
|
|
// same entity to the value we just wrote. Without this, a second
|
|
// offline edit of the same place/item still carries the pre-flush
|
|
// token and would 409 against our OWN just-applied first edit —
|
|
// self-conflicting and risking loss of the later edit (#1135).
|
|
const newToken = (entity as { updated_at?: unknown }).updated_at
|
|
if (typeof newToken === 'string') {
|
|
// In-memory: consulted when the sibling is replayed later in this
|
|
// same flush (its snapshot still holds the stale base).
|
|
if (mutation.resource) tokenMap.set(`${mutation.resource}:${realId}`, newToken)
|
|
// Durable: survives a flush boundary / reload if the sibling is
|
|
// not reached this pass.
|
|
await offlineDb.mutationQueue
|
|
.where('tripId')
|
|
.equals(mutation.tripId)
|
|
.filter(m =>
|
|
m.id !== mutation.id &&
|
|
m.resource === mutation.resource &&
|
|
m.entityId === realId &&
|
|
(m.status === 'pending' || m.status === 'syncing'),
|
|
)
|
|
.modify(m => { m.baseUpdatedAt = newToken })
|
|
}
|
|
}
|
|
}
|
|
} else if (mutation.method === 'DELETE' && mutation.resource && reqEntityId !== undefined) {
|
|
// DELETE was already applied optimistically; ensure it's gone
|
|
const table = getTable(mutation.resource)
|
|
if (table) await table.delete(reqEntityId)
|
|
}
|
|
|
|
await offlineDb.mutationQueue.delete(mutation.id)
|
|
} catch (err: unknown) {
|
|
const httpStatus = (err as { response?: { status: number } })?.response?.status
|
|
|
|
// A DELETE of something the server no longer has did what it set out
|
|
// to do. Reporting it as a failed sync would flag a change that worked.
|
|
// So did a mark of a walk's stop whose stop, day or walk is gone:
|
|
// parked as failed, it would hold back every later mark of that day
|
|
// for nothing.
|
|
if (httpStatus === 404 && doneByNotFound(mutation, err)) {
|
|
if (mutation.resource && reqEntityId !== undefined) {
|
|
const table = getTable(mutation.resource)
|
|
if (table) await table.delete(reqEntityId)
|
|
}
|
|
await offlineDb.mutationQueue.delete(mutation.id)
|
|
continue
|
|
}
|
|
|
|
// 409 = the entity changed on the server since this offline edit was
|
|
// made. This is NOT a dropped change like other 4xx — resolve it per
|
|
// the user's strategy instead of failing it. Deliberately scoped to
|
|
// edits: an offline DELETE is "delete wins" by design (no CAS on the
|
|
// delete path), so it never reaches here. See the wiki Offline doc.
|
|
// Visits, days, day notes and a walk's marks carry no version, so a
|
|
// 409 there is a refusal (a Tour already on that day) that no retry
|
|
// can change, and it fails like any other 4xx.
|
|
if (httpStatus === 409 && mutation.method !== 'DELETE' && !UNVERSIONED_RESOURCES.has(mutation.resource ?? '')) {
|
|
const server = extractConflictServer(err)
|
|
const strategy = getOfflinePrefs().conflictStrategy
|
|
if (strategy === 'server') {
|
|
// Theirs wins: adopt the server's version locally, drop our write.
|
|
await applyServerEntity(mutation, server)
|
|
await offlineDb.mutationQueue.delete(mutation.id)
|
|
} else if (strategy === 'mine') {
|
|
// Mine wins: re-queue without the base token so the next pass
|
|
// overwrites unconditionally.
|
|
await offlineDb.mutationQueue.update(mutation.id, {
|
|
status: 'pending', baseUpdatedAt: null, conflictServer: undefined,
|
|
attempts: mutation.attempts + 1, lastError: null,
|
|
})
|
|
needsRetry = true
|
|
} else {
|
|
// Ask: park it as a conflict for the user to resolve.
|
|
await offlineDb.mutationQueue.update(mutation.id, {
|
|
status: 'conflict', conflictServer: server ?? null, conflictAt: Date.now(),
|
|
attempts: mutation.attempts + 1, lastError: 'conflict',
|
|
})
|
|
holdEntity(heldEntities, mutation)
|
|
}
|
|
continue
|
|
}
|
|
|
|
const isTerminal =
|
|
httpStatus !== undefined && httpStatus >= 400 && httpStatus < 500 && !isRetryableStatus(httpStatus)
|
|
if (isTerminal) {
|
|
// Permanent client error — roll back the phantom optimistic CREATE so
|
|
// it can't masquerade as synced, then mark failed and continue.
|
|
await dropTempRows(mutation)
|
|
await offlineDb.mutationQueue.update(mutation.id, {
|
|
status: 'failed',
|
|
attempts: mutation.attempts + 1,
|
|
lastError: String(err),
|
|
})
|
|
holdEntity(heldEntities, mutation)
|
|
} else if (httpStatus !== undefined && httpStatus >= 500) {
|
|
// The server answered and failed. Retried with growing gaps, and
|
|
// only this trip waits for it. One that never goes through is
|
|
// parked as failed instead of holding every later write forever.
|
|
const attempts = mutation.attempts + 1
|
|
if (attempts >= MAX_SERVER_ERROR_ATTEMPTS) {
|
|
await dropTempRows(mutation)
|
|
await offlineDb.mutationQueue.update(mutation.id, { status: 'failed', attempts, lastError: String(err), retryAfter: undefined })
|
|
holdEntity(heldEntities, mutation)
|
|
} else {
|
|
await offlineDb.mutationQueue.update(mutation.id, {
|
|
status: 'pending',
|
|
attempts,
|
|
lastError: String(err),
|
|
retryAfter: Date.now() + SERVER_ERROR_BACKOFF_MS * 2 ** (attempts - 1),
|
|
})
|
|
blockedTrips.add(mutation.tripId)
|
|
}
|
|
} else {
|
|
// No answer at all (network) or a retryable status: reset to
|
|
// pending and stop the flush; the next trigger retries.
|
|
await offlineDb.mutationQueue.update(mutation.id, {
|
|
status: 'pending',
|
|
attempts: mutation.attempts + 1,
|
|
lastError: String(err),
|
|
})
|
|
break
|
|
}
|
|
}
|
|
}
|
|
} finally {
|
|
release()
|
|
}
|
|
return needsRetry
|
|
},
|
|
|
|
/**
|
|
* Return all pending/syncing mutations, optionally filtered by tripId.
|
|
* Used by the UI to show per-item pending indicators.
|
|
*/
|
|
async pending(tripId?: number): Promise<QueuedMutation[]> {
|
|
if (tripId !== undefined) {
|
|
return offlineDb.mutationQueue
|
|
.where('tripId')
|
|
.equals(tripId)
|
|
.filter(m => m.status === 'pending' || m.status === 'syncing')
|
|
.toArray()
|
|
}
|
|
return offlineDb.mutationQueue
|
|
.where('status')
|
|
.anyOf(['pending', 'syncing'])
|
|
.toArray()
|
|
},
|
|
|
|
/** Count pending mutations (for banner badge). */
|
|
async pendingCount(): Promise<number> {
|
|
return offlineDb.mutationQueue
|
|
.where('status')
|
|
.anyOf(['pending', 'syncing'])
|
|
.count()
|
|
},
|
|
|
|
/** Count permanently-failed mutations (surfaced separately so the user knows
|
|
* changes were dropped — they are NOT folded into pendingCount). */
|
|
async failedCount(): Promise<number> {
|
|
return offlineDb.mutationQueue
|
|
.where('status')
|
|
.equals('failed')
|
|
.count()
|
|
},
|
|
|
|
/**
|
|
* Put every parked change back in line, from a fresh start, and flush.
|
|
* The later writes to the same entity made on this device waited behind it:
|
|
* offline ones in the queue and, for visits, tours and the driving settings,
|
|
* online ones too (mustQueue), so they go out after it in the order they were
|
|
* made. A place or packing item still sends the token it was edited against,
|
|
* so any newer version on the server, a collaborator's or one saved here
|
|
* online, refuses it (409) and it becomes a conflict. A write without a token
|
|
* overwrites what someone else changed on the server meanwhile, as every
|
|
* replayed offline write of those resources does.
|
|
*/
|
|
async retryFailed(): Promise<void> {
|
|
await offlineDb.mutationQueue
|
|
.where('status')
|
|
.equals('failed')
|
|
.modify(m => { m.status = 'pending'; m.attempts = 0; m.retryAfter = undefined; m.lastError = null })
|
|
await this.flush()
|
|
},
|
|
|
|
/**
|
|
* Drop every parked change. The local copy of an edit that never reached the
|
|
* server goes with the next trip sync, which reads the server's version. The
|
|
* later writes that waited behind a parked one are sent right away.
|
|
*/
|
|
async discardFailed(): Promise<void> {
|
|
await offlineDb.mutationQueue.where('status').equals('failed').delete()
|
|
await this.flush()
|
|
},
|
|
|
|
/** Count unresolved sync conflicts (offline edits the server rejected as stale). */
|
|
async conflictCount(): Promise<number> {
|
|
return offlineDb.mutationQueue
|
|
.where('status')
|
|
.equals('conflict')
|
|
.count()
|
|
},
|
|
|
|
/** All unresolved conflicts, newest first, optionally scoped to one trip. */
|
|
async conflicts(tripId?: number): Promise<QueuedMutation[]> {
|
|
const all = await offlineDb.mutationQueue.where('status').equals('conflict').toArray()
|
|
const scoped = tripId === undefined ? all : all.filter(m => m.tripId === tripId)
|
|
return scoped.sort((a, b) => (b.conflictAt ?? 0) - (a.conflictAt ?? 0))
|
|
},
|
|
|
|
/**
|
|
* Resolve a conflict by keeping the local (offline) edit: re-queue it without
|
|
* the base token so the next flush overwrites the server unconditionally.
|
|
*/
|
|
async resolveKeepMine(id: string): Promise<void> {
|
|
const m = await offlineDb.mutationQueue.get(id)
|
|
if (!m || m.status !== 'conflict') return
|
|
await offlineDb.mutationQueue.update(id, {
|
|
status: 'pending', baseUpdatedAt: null, conflictServer: undefined, conflictAt: undefined, lastError: null,
|
|
})
|
|
await this.flush()
|
|
},
|
|
|
|
/**
|
|
* Resolve a conflict by keeping the server's version: adopt it into the local
|
|
* cache and drop the queued write.
|
|
*/
|
|
async resolveKeepServer(id: string): Promise<void> {
|
|
const m = await offlineDb.mutationQueue.get(id)
|
|
if (!m || m.status !== 'conflict') return
|
|
await applyServerEntity(m, m.conflictServer)
|
|
await offlineDb.mutationQueue.delete(id)
|
|
// Later writes to the same entity waited behind the conflict.
|
|
await this.flush()
|
|
},
|
|
|
|
/** Reset internal flushing flag and timestamp counters — useful in tests. */
|
|
_resetFlushing(): void {
|
|
_flushRun = null
|
|
_flushAgain = false
|
|
_lastTs = 0
|
|
_lastTempId = 0
|
|
},
|
|
}
|