mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-20 05:56:53 +00:00
8c93dc8d9c
The day row on the phone now shows how many stops of a city walk were visited on that day, "2 of 5 visited", like the desktop row does. Both rows read it from one place, and a tap on the phone names it in full without opening the row. A walk's stops can be checked off, skipped and taken back without a connection. The mark shows at once, waits in the offline queue and goes out when the connection is back, in the order the marks were made and behind any older mark of the same day that still waits. The day plan, the walk's detail and a reload read the visits kept on this device with the waiting marks on top, so a mark made offline stays on screen until the server has it. A mark the server answers with "not found", because the stop, the day or the walk was removed meanwhile, counts as done and holds back no later mark of that day. Any other refusal, the Tours addon turned off among them, keeps the mark as a failed change to try again from the offline settings. The visits are read again when the connection comes and goes, and once more after the waiting marks went out, so a mark the server dropped or refused leaves the day plan. A trip's visits that answer after another trip was opened no longer show there. The offline sync keeps each trip's walk visits and reads the stops of a walk this device has never opened, so it can be checked off offline too. A walk whose stops this device could not read lists no stand-in stops and no count, and says to open it again while online. A walk put on a day while offline says its stops can be checked off once it has synced, instead of offering buttons that cannot work yet. A mark that fails says to open the walk again and try once more, in every language. Marks carry no version, so the last one to reach the server wins, as with the other changes sent after being offline. The offline guide says so.
949 lines
44 KiB
TypeScript
949 lines
44 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, tourVisitListResponseSchema, type TourCreateResponse } from '@trek/shared'
|
|
import { cacheAssignment } from '../db/cacheAssignment'
|
|
import { replaceAssignmentVisits } from '../db/cacheTourVisits'
|
|
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.
|
|
*/
|
|
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)
|
|
}
|
|
// 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
|
|
},
|
|
}
|