teecup/frontend/lib/offline-queue.ts
Erol Haagenrud 5c2455c1fe
All checks were successful
Backend-tester / test (push) Successful in 21s
Optimistisk versjonssjekk for org-turneringers match-scoring
Fortsetter robusthetslinjen fra ADR-057: hole_score/match_hole_result
(scoring.py) hadde ingen samtidighetsvern, ren siste-skriving-vinner --
den siste dokumenterte, kjente svakheten fra den opprinnelige
"harde kjerner"-vurderingen. Samme version-kolonne-mønster, tilpasset
UPSERT (INSERT ... ON CONFLICT DO UPDATE ... WHERE) i stedet for ren
UPDATE. Offline-kø-kjedingen generalisert til å nøkle på ressurs
(resourceKey), ikke bare URL -- nødvendig siden disse to endepunktene
deler samme URL for alle hull i en match. Se ADR-060/CHANGELOG punkt 69.
23/23 backend-tester grønne, tsc rent. Migrasjon 063 kjørt mot ekte
teecup_db, teecup_api/teecup_frontend rebygget, ingen konsollfeil.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-10 23:19:29 +02:00

147 lines
6 KiB
TypeScript

// Offline skrive-kø for scoreregistrering (ADR-028). Ren IndexedDB, ingen
// avhengighet til service workeren -- gir umiddelbar, presis
// UI-tilbakemelding ("lagret lokalt, venter på synk") direkte fra
// scorekort-komponenten, som er enklere og mer testbart enn å prøve å gjøre
// det samme inni en SW fetch-handler. Bevisst IKKE Background Sync API:
// iOS Safari støtter den ikke i det hele tatt, og en stor andel av
// klubb-/vennegjeng-brukerne er trolig på iPhone -- et enkelt
// window.online-lytter-mønster (se session-scorecard.tsx) fungerer overalt.
const DB_NAME = "teecup-offline"
const DB_VERSION = 1
const STORE = "pending-writes"
export type QueueEntry = {
id: number
url: string
method: string
body: unknown
matchId: string
createdAt: number
// Identifiserer HVILKEN rad på serveren denne skrivingen gjelder --
// default url (uendret oppførsel for kallere med én rad per URL, f.eks.
// round_hole). NØDVENDIG å sette eksplisitt når flere ulike rader deler
// samme URL (f.eks. scoring.py sin /hole-scores, som tar imot ALLE hull
// i en match på nøyaktig samme endepunkt) -- ellers ville versjons-
// kjedingen i flushQueue under blandet sammen versjonsnummer mellom
// urelaterte rader (se ADR-060).
resourceKey?: string
}
function openDb(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const req = indexedDB.open(DB_NAME, DB_VERSION)
req.onupgradeneeded = () => {
const db = req.result
if (!db.objectStoreNames.contains(STORE)) {
const store = db.createObjectStore(STORE, { keyPath: "id", autoIncrement: true })
store.createIndex("matchId", "matchId", { unique: false })
}
}
req.onsuccess = () => resolve(req.result)
req.onerror = () => reject(req.error)
})
}
export async function enqueueWrite(
entry: Omit<QueueEntry, "id" | "createdAt">,
): Promise<number> {
const db = await openDb()
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE, "readwrite")
const req = tx.objectStore(STORE).add({ ...entry, createdAt: Date.now() })
req.onsuccess = () => resolve(req.result as number)
req.onerror = () => reject(req.error)
})
}
export async function listQueue(matchId: string): Promise<QueueEntry[]> {
const db = await openDb()
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE, "readonly")
const req = tx.objectStore(STORE).index("matchId").getAll(matchId)
req.onsuccess = () =>
resolve((req.result as QueueEntry[]).sort((a, b) => a.createdAt - b.createdAt))
req.onerror = () => reject(req.error)
})
}
async function removeFromQueue(id: number): Promise<void> {
const db = await openDb()
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE, "readwrite")
tx.objectStore(STORE).delete(id)
tx.oncomplete = () => resolve()
tx.onerror = () => reject(tx.error)
})
}
export async function queueCount(matchId: string): Promise<number> {
return (await listQueue(matchId)).length
}
export type FlushOutcome = { entry: QueueEntry; ok: boolean; message?: string }
// Sender køede skrivinger i rekkefølge, ETT om gangen (ikke parallelt) --
// server-siden er en upsert per hull, så rekkefølgen har betydning hvis
// samme hull ble endret flere ganger offline. Stopper ved første ekte
// nettverksfeil (resten blir liggende til neste forsøk); en DEFINITIV
// HTTP-feil (f.eks. 409 "matchen er avgjort") fjernes fra køen og
// rapporteres tilbake i stedet for å bli hengende for alltid.
export async function flushQueue(matchId: string): Promise<FlushOutcome[]> {
const entries = await listQueue(matchId)
const outcomes: FlushOutcome[] = []
// Kjeder expected_version fremover for flere køede skrivinger til SAMME
// RESSURS i én flush-runde (2026-08-10, ADR-057 versjonssjekk; nøkkel
// generalisert til resourceKey 2026-08-10, ADR-060). Uten dette ville en
// spillers EGNE påfølgende offline-redigeringer av samme rad avvist
// hverandre som falske konflikter: kun den FØRSTE køede oppføringen har
// en expected_version som fortsatt stemmer med serveren -- lokal state
// (og dermed enqueue-tidspunktets versjon) oppdateres aldri mellom to
// sekvensielle avspillinger i samme batch, så oppføring 2 ville ellers
// sendt den samme (nå utdaterte) versjonen som 1.
//
// NØKKELEN er `resourceKey ?? url`, IKKE bare url -- for round_hole er
// url alene entydig (én URL per hull), men scoring.py sine /hole-scores/
// /hole-results tar imot ALLE hull i en match på nøyaktig samme URL. Uten
// en mer presis nøkkel ville versjonen fra hull 3 sin skriving blitt
// brukt som "forventet versjon" for en påfølgende, helt urelatert
// skriving til hull 7 -- verre enn ingen kjeding i det hele tatt.
const latestVersionByKey = new Map<string, number>()
for (const entry of entries) {
const key = entry.resourceKey ?? entry.url
let body: unknown = entry.body
if (
body !== null &&
typeof body === "object" &&
"expected_version" in body &&
latestVersionByKey.has(key)
) {
body = { ...body, expected_version: latestVersionByKey.get(key) }
}
let res: Response
try {
res = await fetch(entry.url, {
method: entry.method,
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify(body),
})
} catch {
break // fortsatt offline -- stopp, resten prøves igjen senere
}
if (res.ok) {
const updated: { version?: number } | null = await res.json().catch(() => null)
if (updated && typeof updated.version === "number") {
latestVersionByKey.set(key, updated.version)
}
await removeFromQueue(entry.id)
outcomes.push({ entry, ok: true })
} else {
const respBody: { detail?: { message?: string } } | null = await res.json().catch(() => null)
await removeFromQueue(entry.id)
outcomes.push({ entry, ok: false, message: respBody?.detail?.message ?? `Feil ${res.status}` })
}
}
return outcomes
}