import { openDB, type IDBPDatabase } from 'idb'; import { writable, get } from 'svelte/store'; import { getToken, getUserId, clearAuth, onClearAuth, onSetAuth } from './auth'; import { onSseEvent } from './sse'; import { refreshQuota } from './quota-store'; import { toast } from './toast-store'; export interface QueueItem { id: string; userId: string; fileName: string; fileSize: number; /** Source file's last-modified ms; part of the dedup key so two distinct photos that * happen to share a name and byte length don't collapse into one. */ lastModified?: number; mimeType: string; caption: string; hashtags: string; // 'error' is retryable (network / 5xx); 'blocked' is TERMINAL (a 4xx the server will // keep rejecting — locked event, banned user, released gallery, quota full). Blocked // items have had their blob purged from IndexedDB and offer no retry. status: 'pending' | 'uploading' | 'done' | 'error' | 'blocked'; progress: number; error?: string; } // Store does NOT hold file blobs — those stay in IndexedDB only export const queueItems = writable([]); export const isProcessing = writable(false); /** Set to the timestamp (ms) at which the rate-limit lifts, or null when clear. */ export const rateLimitRetryAt = writable(null); const DB_NAME = 'eventsnap-uploads'; const STORE_NAME = 'queue'; /** Hard cap on queued items per device — bounds IndexedDB growth from stuck blobs. */ const MAX_QUEUE_ITEMS = 100; /** * How many times an item may be re-sent by an AUTOMATIC resume (`online`, SSE reconnect, * backoff timer) before it parks and waits for the guest to tap "Erneut". * * Without a cap, `requeueRetriable` flips every failed item back to `pending` on every * `online` event and every SSE reconnect — on a congested venue AP those fire constantly, * so one failing 200 MB video re-uploads from byte zero all evening and starves the ~100 * other guests sharing the uplink. A manual retry resets the counter: an explicit tap is * evidence the guest wants to spend the bandwidth. */ const MAX_AUTO_ATTEMPTS = 5; /** * Quiet time after which an item's automatic-retry budget refills. * * The cap above is a rate limiter, and a rate limiter needs a window or it is a lifetime quota. * Five attempts on a 5/10/20/40s ladder is ~75 seconds, so ANY outage longer than that — a venue * AP brownout, a captive portal re-arming, an `app` container restart — permanently parked every * in-flight photo behind a per-row button three taps deep that no guest will find. * * 10 minutes is chosen against the thing being protected: the concern is a hot loop re-sending a * 200 MB video over a shared uplink, and one re-send per item per 10 minutes is not that. It is * also comfortably longer than every outage the queue can ride out on its own. */ const RETRY_BUDGET_WINDOW_MS = 10 * 60_000; /** Exponential backoff between automatic attempts: 5s, 10s, 20s, 40s, … capped below. */ const RETRY_BASE_DELAY_MS = 5_000; const MAX_RETRY_DELAY_MS = 5 * 60_000; /** * Abort an upload that has not moved a single byte for this long. * * A phone roaming between APs leaves a half-open TCP connection: the XHR neither errors nor * completes, so the item sits in `uploading` forever and `processQueue`'s `processing` flag * never clears — the ENTIRE queue stops draining, with no way out short of force-quitting * the app. Bytes-moved (not elapsed time) is the right signal: it never punishes a slow but * healthy LTE upload, which is why the wall-clock `xhr.timeout` below is only a backstop. */ const STALL_TIMEOUT_MS = 90_000; const STALL_CHECK_INTERVAL_MS = 5_000; /** * Ceiling for the window AFTER the last byte is sent. * * `upload.progress` is silent there BY DEFINITION — the server is sniffing the magic bytes, * committing the row and writing the response — so the bytes-moved signal above has nothing * to measure and the watchdog used to simply switch off at `loadend`. That left the wall-clock * `xhr.timeout` as the only remaining bound: 5 minutes for a photo, up to 60 for a video. A * half-open socket in that window (phone roams wifi→LTE while the server is still writing a * 500 MB video) pinned `processing` for that entire time, so NOTHING else in the guest's queue * drained either. * * Sized above the backend's worst-case commit path, not above compression — compression is * spawned after the response and does not hold it open. */ const RESPONSE_TIMEOUT_MS = 120_000; /** * Pure predicate behind the watchdog, extracted so the policy is unit-testable without * standing up an XHR harness. */ export function shouldAbortForStall( lastActivityAt: number, now: number, bodySent: boolean ): boolean { const ceiling = bodySent ? RESPONSE_TIMEOUT_MS : STALL_TIMEOUT_MS; return now - lastActivityAt > ceiling; } /** * Normal timer jitter/throttling budget. A tick later than `interval + this` did not run * because the page was suspended, not because it was merely late. */ const SUSPEND_TOLERANCE_MS = 2_000; /** * Total suspension credit a single silent stretch may ever be granted. * * A tick gap cannot distinguish "the page was frozen" from "the page is being throttled to one * tick per minute while the network stack keeps running" — and in the second case the socket can * be genuinely dead. Crediting every late tick meant the observed silence grew by only one * interval per real minute, so a dead upload took ~18 minutes to detect while holding the * queue's `processing` latch and blocking every other photo the guest had queued. * * Capping the total at one stall window bounds detection at roughly 2x the ceiling (~3 min) * whatever the throttling pattern, while still absorbing the pocket-length freezes this exists * for. Crucially the budget RESETS on real progress (see the `progress` handler), so a long * upload that is actually moving survives any number of screen locks — only an upload that is * silent AND suspended spends it, and that is exactly the case where being wrong is cheap: one * re-send, bounded by MAX_AUTO_ATTEMPTS. */ export const MAX_SUSPEND_CREDIT_MS = STALL_TIMEOUT_MS; /** * Wall-clock the watchdog interval FAILED to cover because the page was suspended. * * `Date.now()` keeps advancing while a backgrounded phone is frozen, but `setInterval` does * not run. So the first tick after a screen lock saw the entire sleep as "no bytes moved" and * aborted a connection that was very possibly healthy — restarting a 200 MB video from byte * zero, burning one of five PERMANENT auto-attempts (`chargeAttempt`), and breaking the drain * loop for every other queued photo. A phone in a pocket between shots is the common case at a * party, not an edge case. * * The interval is its own suspension detector: a tick scheduled 5s out that arrives 130s late * means the page was frozen for ~125s, and that is exactly the window the watchdog had no * right to measure. Deliberately chosen over a `visibilitychange` listener, which only covers * the causes that happen to fire that event — a throttled-but-visible tab, a closed laptop lid * and an occluded window all freeze timers without one. It also needs no listener, no * module-level state, no SSR guard and no teardown. * * `performance.now()` was rejected as the clock source: Safari pauses it across system sleep * on some paths while Chrome does not, which is precisely the non-uniformity that makes it * unusable as the sole signal. * * Returns 0 for a normal tick, and 0 if the clock jumps BACKWARDS (an NTP correction) — that * fails open, and the wall-clock `xhr.timeout` still bounds the request. */ export function suspendedSinceLastTick( lastTickAt: number, now: number, intervalMs: number = STALL_CHECK_INTERVAL_MS ): number { const overshoot = now - lastTickAt - intervalMs; return overshoot > SUSPEND_TOLERANCE_MS ? overshoot : 0; } /** * Wall-clock cap for one attempt, scaled by file size assuming a floor of ~8 kB/s — a * deliberately pessimistic rate, because killing a slow-but-progressing upload would lose * exactly the videos that are hardest to re-take. The stall watchdog is what actually * catches a dead connection; this only bounds the pathological case where bytes trickle * fast enough to reset the watchdog but the upload would never finish. */ const MIN_UPLOAD_TIMEOUT_MS = 5 * 60_000; const MAX_UPLOAD_TIMEOUT_MS = 60 * 60_000; const ASSUMED_MIN_BYTES_PER_SEC = 8_000; /** * How long to wait for IndexedDB to open before giving up and running from memory. * `openDB` does not settle at all while another tab pins an older version (the `blocked` * callback fires but the promise stays pending), and every queue operation awaits it — so * without this bound a second open tab silently disables uploading device-wide. */ const DB_OPEN_TIMEOUT_MS = 5_000; /** The persisted shape of a queue row. `blob` is the only field that never reaches the store. */ interface QueueEntry { id: string; userId: string; fileName: string; fileSize: number; lastModified?: number; mimeType: string; caption: string; hashtags: string; status: QueueItem['status']; error?: string; /** Consecutive failed attempts; gates + delays automatic resumes. Reset by a manual retry. */ attempts?: number; /** Earliest ms timestamp at which an automatic resume may re-send this item. */ nextAttemptAt?: number; /** When the most recent attempt failed. Lets the budget refill after a quiet spell. */ lastFailureAt?: number; /** * The guest stopped this transfer themselves (the ✕ on an in-flight row). * * `requeueRetriable` requeues any blob-bearing `error` item that is under budget and past its * backoff — and a cancel deliberately charges NO attempt and sets NO backoff, so without this * flag it matched on both counts and the upload restarted from byte zero within ~120s (an * `online` event, or the SSE backstop's `feed-delta` poll). It then restarted forever, because * a path that never charges an attempt can never exhaust the budget that would stop it. The row * said "Abgebrochen. Tippe auf „Erneut“." the whole time, on a shared venue uplink. * * Cleared by `retryItem` — an explicit tap is the guest changing their mind. */ cancelled?: boolean; /** * Parked waiting for a specific host action, with the blob intact. * * Both values are reversible 403s whose answer cannot change without somebody deciding to * change it, which makes automatic retries pure waste: they re-pushed the whole photo over * cellular on every budget refill for the rest of the night while the guest was told to tap * a camera button that 403s. * * - `'reopen'` — the gallery was released (`gallery_released`). Cleared by `event-opened`. * - `'unban'` — the uploader is banned (`user_banned`). Cleared by `user-shown`. * * An explicit `retryItem` clears either: a deliberate tap is the guest asking us to try * anyway, and if the condition still holds the next response simply re-parks it. */ parkedFor?: 'reopen' | 'unban'; blob?: Blob; } // Resume the queue as soon as connectivity returns. Registered once, guarded for SSR. // This is the other half of the "flushes when you're back online" promise — without it // a reconnect only resumes if the user manually re-stages a file. let onlineBound = false; function bindOnline(): void { if (onlineBound || typeof window === 'undefined') return; window.addEventListener('online', () => { void (async () => { await requeueRetriable(); await processQueue(); })(); }); onlineBound = true; } bindOnline(); // Rehydrate on every IDENTITY change, not just on a cold boot. // // `+layout.svelte` calls `loadQueue()` on mount, but only `if (getToken())` — and the boot // that matters most has no token: a mid-event 401 (host PIN reset, redeployed JWT_SECRET) // clears auth and hard-navigates to /join, so the layout skips it. The guest then recovers, // which finishes with `goto('/feed')` — a client-side navigation, so `onMount` never runs // again and `queueItems` stays the empty array it was initialised to. Their queued photos // sit in IndexedDB with nothing reading them, and the FAB badge reads 0 — exactly the // silent-loss failure the boot-time call was added to prevent, one route over. // // `onSetAuth` is the mechanism the role store already uses for this same reason. onSetAuth(() => void loadQueue()); // Drop the in-memory view when an identity goes away, so the next user of a shared device // does not see the previous guest's file names in the queue. This deliberately does NOT // touch IndexedDB: a 401 is often transient, the blobs are user-scoped on every read, and // destroying them here would lose photos the AuthError path goes out of its way to keep. onClearAuth(() => queueItems.set([])); // Resume the queue when the host reopens the event. A queued upload that hit a locked/ // released event kept its blob and parked as a retryable `error` (LockedError); flipping it // back to pending and re-draining recovers it so a photo staged during a lock isn't lost. // We resume on TWO signals: // - `event-opened`: the live reopen, when the SSE happens to be connected. // - `feed-delta`: fired on every SSE (re)connect (see sse.ts `deltaFetchAndFan`). Because // `event-opened` has no server-side replay and the SSE is disconnected on non-feed pages // / backgrounded tabs, a reopen is often MISSED live — this catches up on the next // reconnect. Retrying while still locked just re-parks the item (bounded, one per event). // One-way import (sse.ts never imports this module). let sseBound = false; function bindSse(): void { if (sseBound || typeof window === 'undefined') return; const resume = (options: { resetAttempts?: boolean; release?: 'reopen' | 'unban' } = {}) => { void (async () => { await requeueRetriable(options); await processQueue(); })(); }; // A reopen is a deliberate host action that changes the server's answer, so it's fair to // give parked items a fresh retry budget. A plain reconnect is not — that's the signal // that fires over and over on a flapping AP. onSseEvent('event-opened', () => resume({ resetAttempts: true, release: 'reopen' })); // An unban is the same kind of evidence, for the guest it names. `user-shown` is broadcast to // everyone (every feed needs to un-hide that user's photos), so check it is actually us // before resuming — otherwise one guest's unban would resume every OTHER banned guest's // queue straight into another 403. onSseEvent('user-shown', (payload) => { let userId: unknown; try { userId = (JSON.parse(String(payload)) as { user_id?: unknown }).user_id; } catch { return; } if (userId && userId === getUserId()) resume({ resetAttempts: true, release: 'unban' }); }); onSseEvent('feed-delta', () => resume()); sseBound = true; } bindSse(); /** * Flip transient `error` items (5xx / a network drop that got marked before we could * reclassify it) back to `pending` so a resume actually retries them. Terminal `blocked` * items (403/413) are left alone — retrying those never succeeds. * * This is the ONLY automatic retry path, and it is driven by events that fire constantly on * a bad network (`online`, and `feed-delta` on every SSE reconnect), so it must be the thing * that enforces the budget: an item is only requeued while it is under `MAX_AUTO_ATTEMPTS` * and past its backoff deadline. Beyond that it stays parked until the guest taps "Erneut". * * `resetAttempts` is for signals that are positive evidence the blocking condition changed * (the host reopening the event), where starting the budget over is warranted. * * `release` names the host action that just happened, and un-parks only the items that were * waiting for exactly that (`parkedFor`). A reopen must not resume a banned guest's queue, and * an unban must not resume uploads into a released gallery — both would just 403 again. */ async function requeueRetriable( options: { resetAttempts?: boolean; release?: 'reopen' | 'unban' } = {} ): Promise { const myUserId = getUserId(); const all = await storeGetAll(); const now = Date.now(); const requeued = new Set(); let soonest: number | null = null; for (const entry of all) { if (entry.userId !== myUserId || entry.status !== 'error' || !entry.blob) continue; // A cancel is the guest's decision, not a transient failure — never undo it automatically, // not even on `resetAttempts` (the host reopening the event says nothing about whether // this guest still wants this photo sent). Only `retryItem` clears it. if (entry.cancelled) continue; // Parked waiting on a host action. Only the matching signal releases it, so a plain // reconnect leaves it alone instead of re-pushing the photo at a server whose answer // cannot have changed. A manual "Erneut" bypasses this via `retryItem`. if (entry.parkedFor) { if (entry.parkedFor !== options.release) continue; entry.parkedFor = undefined; } if (options.resetAttempts) { entry.attempts = 0; entry.nextAttemptAt = undefined; } // The budget is a RATE, not a lifetime allowance. // // Five attempts with a 5/10/20/40s ladder is ~75 seconds of failure end to end. A venue // AP brownout, a re-armed captive portal or a backend restart lasting two minutes — with // `navigator.onLine` still true the whole time, so none of it takes the offline path — // therefore exhausted every automatic attempt and parked the item until the guest went // FAB → sheet → "Warteschlange" → per-row "Erneut". Nobody does that; the photo simply // never arrives. Refilling after a quiet spell keeps the bound that matters (no hot // retry loop against a server that is genuinely down) while letting the evening recover // from a blip on its own. const lastFailureAt = entry.lastFailureAt ?? 0; if (now - lastFailureAt > RETRY_BUDGET_WINDOW_MS) { entry.attempts = 0; entry.nextAttemptAt = undefined; } if ((entry.attempts ?? 0) >= MAX_AUTO_ATTEMPTS) continue; if (entry.nextAttemptAt && entry.nextAttemptAt > now) { // Still cooling down — remember the earliest deadline so the sweep below can // come back for it without waiting for another `online`/reconnect to happen by. soonest = soonest === null ? entry.nextAttemptAt : Math.min(soonest, entry.nextAttemptAt); continue; } entry.status = 'pending'; entry.error = undefined; entry.nextAttemptAt = undefined; await storePut(entry); requeued.add(entry.id); } if (soonest !== null) scheduleRetrySweep(soonest - now); queueItems.update((items) => items.map((item) => requeued.has(item.id) ? { ...item, status: 'pending' as const, progress: 0, error: undefined } : item ) ); } // A single pending wake-up for the earliest backoff deadline. Without it an item that fails // while the network is nominally fine would sit until the next `online` event or SSE // reconnect — which on a stable-but-broken connection (captive portal, dead upstream) may // never come, leaving the guest with a red badge and no visible progress. let retrySweepTimer: ReturnType | null = null; let retrySweepAt = 0; function scheduleRetrySweep(delayMs: number): void { if (typeof window === 'undefined') return; const at = Date.now() + Math.max(0, delayMs); if (retrySweepTimer && retrySweepAt <= at) return; if (retrySweepTimer) clearTimeout(retrySweepTimer); retrySweepAt = at; retrySweepTimer = setTimeout( () => { retrySweepTimer = null; void (async () => { await requeueRetriable(); await processQueue(); })(); }, Math.max(0, delayMs) ); } /** * Record a failed attempt on an entry and compute when the next automatic one may run. * Returns true once the budget is spent, so the caller can say so in the item's error text — * the queue row is the only place a guest can learn that nothing is retrying any more. */ function chargeAttempt(entry: QueueEntry): boolean { const attempts = (entry.attempts ?? 0) + 1; entry.attempts = attempts; entry.lastFailureAt = Date.now(); if (attempts >= MAX_AUTO_ATTEMPTS) { entry.nextAttemptAt = undefined; return true; } const delay = Math.min(RETRY_BASE_DELAY_MS * 2 ** (attempts - 1), MAX_RETRY_DELAY_MS); entry.nextAttemptAt = Date.now() + delay; scheduleRetrySweep(delay); return false; } /** Append the "auto-retry has stopped" hint once the budget is spent. */ function withRetryHint(message: string, exhausted: boolean): string { return exhausted ? `${message} Tippe auf „Erneut“.` : message; } // --------------------------------------------------------------------------------------- // Persistence layer. // // IndexedDB is NOT guaranteed to be there: iOS private mode denies it outright, a storage // quota can be refused mid-event, and a second open tab holding an older DB version leaves // `openDB` pending forever. Every one of those used to reject or hang the very first `await` // of `addToQueue`, which stranded the composer with both buttons stuck on "Wird hochgeladen…". // // So the store degrades instead of failing: entries that cannot be persisted live in an // in-memory overlay, and the guest is told ONCE, in German, that their photos won't survive // closing the app. An upload that works but isn't crash-proof beats no upload at all. // --------------------------------------------------------------------------------------- let dbPromise: Promise | null = null; const memoryEntries = new Map(); let warnedNoPersistence = false; function warnNoPersistence(): void { if (warnedNoPersistence) return; warnedNoPersistence = true; toast( 'Fotos können auf diesem Gerät nicht zwischengespeichert werden. Die Uploads laufen weiter — lass die App bitte offen, bis sie fertig sind.', 'warning', 9000 ); } async function openQueueDb(): Promise { if (dbPromise) return dbPromise; dbPromise = (async () => { try { // v1 → v2: add `userId` index so each guest's queue is isolated on shared devices. // Pre-existing entries (no userId) are dropped on upgrade; nothing useful was ever // persisted across logouts before this version. // Version 3 self-heals installs corrupted by a shipped v1→v2 bug: that upgrade // opened a *new* transaction inside the callback, which throws InvalidStateError // ("A version change transaction is running") and aborts the whole upgrade — // leaving some browsers at version 2 with NO 'queue' object store (so every queue // write failed and no upload ever fired). Bumping to 3 re-runs this upgrade for // those installs; the contains() guard recreates the missing store instead of // assuming createObjectStore only ever runs on a brand-new DB. const opening = openDB(DB_NAME, 3, { upgrade(database, oldVersion, _newVersion, transaction) { if (!database.objectStoreNames.contains(STORE_NAME)) { database.createObjectStore(STORE_NAME, { keyPath: 'id' }); } else if (oldVersion < 2) { // Existing v1 store: its entries predate the `userId` field, so drop them // rather than misattribute them to whoever is signed in now. Reuse the // active version-change transaction (never open a new one here — see above). // Skipped when we just created the store, which is already empty. transaction.objectStore(STORE_NAME).clear(); } }, blocked() { // Another tab still holds an older version, so the upgrade cannot start. There // is nothing we can do from here except tell the guest which action unblocks it // — the timeout below keeps the queue usable meanwhile. toast( 'EventSnap ist noch in einem anderen Tab geöffnet. Bitte schließe die anderen Tabs.', 'warning', 9000 ); } }); const database = await Promise.race([ opening, new Promise((resolve) => setTimeout(() => resolve(null), DB_OPEN_TIMEOUT_MS)) ]); if (!database) { warnNoPersistence(); return null; } return database; } catch { // Private mode / quota denial / corrupted profile — fall back to memory. warnNoPersistence(); return null; } })(); return dbPromise; } async function storeGet(id: string): Promise { const overlay = memoryEntries.get(id); if (overlay) return overlay; const database = await openQueueDb(); if (!database) return undefined; try { return await database.get(STORE_NAME, id); } catch { return undefined; } } async function storeGetAll(): Promise { const database = await openQueueDb(); let persisted: QueueEntry[] = []; if (database) { try { persisted = await database.getAll(STORE_NAME); } catch { persisted = []; } } // The overlay shadows the persisted copy: once a row fails to write, memory holds the // newer state and the stale IndexedDB row must not resurrect it. return [...memoryEntries.values(), ...persisted.filter((entry) => !memoryEntries.has(entry.id))]; } async function storePut(entry: QueueEntry): Promise { if (memoryEntries.has(entry.id)) { memoryEntries.set(entry.id, entry); return; } const database = await openQueueDb(); if (database) { try { await database.put(STORE_NAME, entry); return; } catch { // Most often QuotaExceededError on a phone with a full photo library. Keep going // from memory rather than throwing out of addToQueue/uploadItem. } } memoryEntries.set(entry.id, entry); warnNoPersistence(); } async function storeDelete(id: string): Promise { memoryEntries.delete(id); const database = await openQueueDb(); if (!database) return; try { await database.delete(STORE_NAME, id); } catch { /* nothing to recover — the in-memory view is already authoritative */ } } async function storeClear(): Promise { memoryEntries.clear(); const database = await openQueueDb(); if (!database) return; try { await database.clear(STORE_NAME); } catch { /* see storeDelete */ } } /** * Wipe every queue entry — both IndexedDB rows and the in-memory store. Called on * explicit logout so a second guest using the same device doesn't inherit (or be * blamed for) the previous guest's pending uploads. */ export async function clearQueue(): Promise { await storeClear(); queueItems.set([]); rateLimitRetryAt.set(null); } class RateLimitError extends Error { retryAfterSecs: number; constructor(secs: number) { super('rate_limited'); this.retryAfterSecs = secs; } } /** * A permanent, non-retryable failure — the server returned a 4xx (other than 429) that * will never succeed on retry: uploads locked, user banned, gallery already released, or * per-user quota full. The item is moved to the terminal `blocked` state and its blob is * dropped from IndexedDB (no point keeping bytes we'll never send). */ class TerminalError extends Error { constructor(message: string) { super(message); } } /** * A connectivity failure — the request never reached the server (offline, DNS, dropped * connection). The item stays `pending` (not `error`) so the `online` listener and the * next `processQueue` pick it up automatically. This is what makes "the queue flushes * when you're back online" actually true for a file staged with no signal. */ class NetworkError extends Error {} /** * The guest aborted this upload themselves (the ✕ on an in-flight row). A NetworkError * subclass because the transport outcome is identical — but it must NOT stop the batch or * count against the retry budget, since it says nothing about the connection. */ class CancelledError extends NetworkError {} /** * The session is gone/expired (HTTP 401). This is emphatically NOT terminal: the blob is * KEPT (deleting it — the old behavior for any 4xx — destroyed a guest's staged photos the * instant their sliding session lapsed or a host reset their PIN, exactly when auto-resume * fires). We mark the item retryable and route to re-auth; once the user signs back in with * the same identity (via /recover), `loadQueue` re-associates these entries by userId and * they resume. Distinct from `TerminalError` so the 4xx branch can't purge the blob. */ class AuthError extends Error {} /** * A REVERSIBLE 403 — the event is closed or the gallery was released, but a host can reopen * it. Backend tags these with code `uploads_locked` (distinct from a permanent `forbidden` * like a banned user). The blob is KEPT and the item parks as retryable; the `event-opened` * SSE (or a manual retry) resumes it. Without this, a photo staged during a lock was purged * as terminal and lost the moment the host reopened. */ class LockedError extends Error {} /** * The gallery has been RELEASED — `gallery_released`. A subclass of `LockedError` so every * blob-preserving code path below keeps treating it as a reversible lock (the host *can* still * reopen, and losing a photo is the worst outcome). * * What differs is the retry policy. A closed event is a pause the host means to undo, so * auto-resuming on reconnect is right. A released gallery is the end of the event, and in the * normal flow nobody reopens it — so auto-retrying re-pushes a multi-megabyte photo over * cellular on every budget refill, forever, for an answer that will not change, while the guest * is told to tap a camera button that 403s. Items parked this way sit still (see * `awaitingReopen` in `requeueRetriable`) until a real `event-opened` arrives or the guest * retries by hand. */ class ReleasedError extends LockedError {} /** * The uploader is banned — `user_banned`. Also a `LockedError` subclass, so the blob survives: * `unban_user` exists and the host's confirm copy promises the photos come back, which the old * generic-`forbidden` classification made impossible for anything mid-flight (blob purged, row * moved to `blocked`, and `blocked` has no retry button). * * Parks still like `ReleasedError` and waits for `user-shown`. */ class BannedError extends LockedError {} /** Retry policy for an upload response status. */ export type UploadOutcome = 'success' | 'rate_limit' | 'auth' | 'transient' | 'terminal'; /** * Classify an upload HTTP status into a retry policy. Pure + exported so the * data-loss-critical rules are unit-testable without an XHR/IndexedDB harness: * - 401 → `auth` (session gone: KEEP the blob, re-auth — NEVER purge it) * - 408 → `transient` (request timeout: retryable) * - 429 → `rate_limit`(back off, auto-resume) * - other 4xx → `terminal` (locked/banned/released/quota — the only case that purges) * - 2xx → `success`; 5xx/other → `transient` * The one rule that must never regress: a 401 is `auth`, not `terminal`. */ export function classifyUploadStatus(status: number): UploadOutcome { if (status >= 200 && status < 300) return 'success'; if (status === 429) return 'rate_limit'; if (status === 401) return 'auth'; if (status === 408) return 'transient'; if (status >= 400 && status < 500) return 'terminal'; return 'transient'; } /** * Within the `terminal` bucket, decide whether a 4xx is a REVERSIBLE lock (keep the blob, * park retryable for a host reopen) rather than a permanent rejection (purge the blob). * Pure + exported so this data-loss-critical rule is unit-testable without an XHR harness. * * Reversible when: * - the backend tagged it `uploads_locked` (event closed / gallery released — a host can reopen), OR * - it's `quota_exceeded` (413). The per-user ceiling is `free_disk * tolerance / uploaders`, * which MOVES: the numerator falls and the denominator rises all evening, so a guest who was * comfortably under it at 20:00 is over it at 22:00 through nobody's action, and a host * deleting content or the hourly reclaim can put them back under it just as passively. It is * the textbook reversible lock, and treating it as permanent meant a 400 MB video was pushed * across cellular in full and THEN deleted from IndexedDB — gone on both sides, unrecoverable * without re-picking from the camera roll (impossible for an in-app camera capture). OR * - it's ANY 403 we can't positively identify as a permanent ban (`forbidden`). An unparseable * 403 body (proxy/WAF/captive portal) must NOT purge the blob — losing a photo is the worst * outcome, and 403 is the reversible-lock status here. * A `forbidden` 403 (banned) and every other 4xx (too large, wrong type) are permanent → purge. */ export function isReversibleLock(status: number, errorCode: unknown): boolean { return ( errorCode === 'uploads_locked' || errorCode === 'gallery_released' || // A ban is lifted by `unban_user`, and the host UI promises the photos come back. Purging // the blob here made that promise impossible to keep for anything mid-flight. errorCode === 'user_banned' || errorCode === 'quota_exceeded' || (status === 403 && errorCode !== 'forbidden') ); } /** * Within the reversible-lock bucket, is this the END of the event rather than a pause? * * `gallery_released` means the keepsake has been snapshotted. The blob is still kept (a host * reopen is possible), but the item must stop auto-retrying — see `ReleasedError`. Pure + * exported for the same reason as `isReversibleLock`: it decides whether a guest's photo gets * re-pushed over cellular all night. */ export function isGalleryReleased(errorCode: unknown): boolean { return errorCode === 'gallery_released'; } /** * Is this a ban (`user_banned`)? Reversible, blob kept — but like a release it will not lift on * its own, so the item parks still and waits for the `user-shown` SSE rather than re-pushing the * photo on every reconnect at a guest who is currently not allowed to upload. */ export function isUserBanned(errorCode: unknown): boolean { return errorCode === 'user_banned'; } /** * Rehydrate a persisted IndexedDB entry into an in-memory `QueueItem`. Pure + exported so the * field-mapping is unit-testable. The rule that must not regress: `lastModified` MUST be carried * across — addToQueue's dedup keys on it, so an item restored from IndexedDB (page reload / PWA * relaunch) with an undefined lastModified would fail to match a re-selection of the same file * and silently queue it twice. `uploading` is downgraded to `pending` (an interrupted in-flight * upload must resume, not stay stuck spinning). */ export function entryToQueueItem(entry: { id: string; userId: string; fileName: string; fileSize: number; lastModified?: number; mimeType: string; caption?: string; hashtags?: string; status: QueueItem['status'] | 'uploading'; error?: string; }): QueueItem { return { id: entry.id, userId: entry.userId, fileName: entry.fileName, fileSize: entry.fileSize, lastModified: entry.lastModified, mimeType: entry.mimeType, caption: entry.caption ?? '', hashtags: entry.hashtags ?? '', status: entry.status === 'uploading' ? 'pending' : entry.status, progress: entry.status === 'done' ? 100 : 0, error: entry.error }; } export async function loadQueue(): Promise { const myUserId = getUserId(); const all = await storeGetAll(); // Only surface entries that belong to the current user. Entries from a previous // guest on this device are filtered out (and would also be wiped on their next // explicit logout via `clearQueue`). const items: QueueItem[] = all .filter((entry) => entry.userId && entry.userId === myUserId) .map(entryToQueueItem); queueItems.set(items); // Staged-but-unsent items from a prior session (queued offline, tab closed before // reconnect) must resume now — otherwise the "queue flushes when you're back online" // promise only holds if the user manually re-stages a file. Reclaim transient errors // (a network drop from a prior session) so they retry instead of stalling. void (async () => { await requeueRetriable(); await processQueue(); })(); } /** * Release parked items whose blocking condition is already over, using the authoritative state * the app fetches at boot. * * `parkedFor` is persisted to IndexedDB, but the only things that cleared it were the LIVE * `event-opened` / `user-shown` SSE events. Those only reach a tab that is open at the moment the * host acts — and the realistic sequence is the opposite one: the guest's photo is parked, they * close the app at the end of the night, and the host lifts the ban or reopens uploads the next * morning. Nothing then ever un-parked the item, so it sat in the queue forever while the toast * had promised "wird gesendet, sobald die Sperre aufgehoben ist". * * Called once per boot with what `/me/context` and the event state actually say, so a park can * never outlive the condition it was waiting on. Cheap: a no-op unless something is parked. */ export async function releaseResolvedParks(state: { banned: boolean; uploadsOpen: boolean; }): Promise { // Each release is scoped to its own signal, exactly as the SSE path is: being unbanned says // nothing about whether the gallery reopened, and vice versa. if (!state.banned) { await requeueRetriable({ resetAttempts: true, release: 'unban' }); } if (state.uploadsOpen) { await requeueRetriable({ resetAttempts: true, release: 'reopen' }); } await processQueue(); } /** Outcome of an `addToQueue` call, so the caller can tell the user when a file was NOT * actually queued (deduped, or the queue is full of un-evictable in-flight items). */ export type EnqueueResult = 'queued' | 'duplicate' | 'full'; export async function addToQueue( file: File, caption: string, hashtags: string ): Promise { const userId = getUserId(); // Not authenticated — nothing to queue. Return the silent 'duplicate' outcome rather than // 'full' so the caller doesn't show a misleading "queue full" toast. Practically // unreachable: the upload view sits behind a token guard. if (!userId) return 'duplicate'; // Dedup: don't queue the same file twice while an identical one is still unsent // (double-tap, re-added after a flaky reconnect). Matches on name+size+lastModified+user // — including lastModified so two genuinely different photos sharing a name and byte // length aren't silently collapsed into one. const mine = get(queueItems).filter((i) => i.userId === userId); const dup = mine.some( (i) => i.fileName === file.name && i.fileSize === file.size && (i.lastModified ?? 0) === file.lastModified && (i.status === 'pending' || i.status === 'uploading') ); if (dup) return 'duplicate'; // Cap the queue so stuck/blocked blobs can't grow IndexedDB without bound. When full, // evict the oldest item whose blob is already gone or unrecoverable — `done` (blob deleted // on success), `blocked` (blob purged, terminal), or an `error` whose blob is missing. // An `error` item that STILL HOLDS a blob is never evicted: it is retryable (a network // blip, or a locked/released upload that resumes on reopen) and dropping it would silently // lose a photo the retry paths deliberately kept. If nothing is evictable, refuse and tell // the caller so it can surface a "queue full" message rather than dropping a photo the // user believed was queued. if (mine.length >= MAX_QUEUE_ITEMS) { const evictable = await findEvictable(userId); if (evictable) { await storeDelete(evictable); queueItems.update((items) => items.filter((it) => it.id !== evictable)); } else { return 'full'; } } // This id is also the server-side idempotency key (`client_upload_id`), so it is minted // exactly ONCE per file here and reused by every retry — see uploadItem. const id = crypto.randomUUID(); const entry: QueueEntry = { id, userId, fileName: file.name, fileSize: file.size, lastModified: file.lastModified, mimeType: file.type, caption, hashtags, status: 'pending', blob: file }; await storePut(entry); queueItems.update((items) => [ ...items, { id, userId, fileName: file.name, fileSize: file.size, lastModified: file.lastModified, mimeType: file.type, caption, hashtags, status: 'pending', progress: 0 } ]); processQueue(); return 'queued'; } export async function retryItem(id: string): Promise { const entry = await storeGet(id); if (!entry) return; entry.status = 'pending'; entry.error = undefined; // A deliberate tap outranks the automatic budget: reset the counter and clear any backoff // so a guest who watched their photo fail five times can still get it sent right now. entry.attempts = 0; entry.nextAttemptAt = undefined; // And it is the one thing that un-cancels: tapping "Erneut" on a row the guest stopped // themselves is them changing their mind. entry.cancelled = false; // Same for a parked item: an explicit tap is the guest asking us to try anyway (the host may // have reopened or unbanned without this device seeing the SSE). If the condition still // holds the next response re-parks it, so this cannot become a loop. entry.parkedFor = undefined; await storePut(entry); queueItems.update((items) => items.map((item) => item.id === id ? { ...item, status: 'pending' as const, progress: 0, error: undefined } : item ) ); processQueue(); } /** * Abort an in-flight upload on the guest's command. The only escape hatch from an upload that * is technically alive but going nowhere — without it the queue's single slot stays occupied * until the stall watchdog fires (or the app is force-quit). The item parks as retryable with * its blob intact, so "Erneut" still works afterwards. */ export function cancelItem(id: string): void { const xhr = activeUploads.get(id); if (!xhr) return; cancelledUploads.add(id); xhr.abort(); } export async function removeItem(id: string): Promise { // An item can be removed while its request is still on the wire — stop the transfer, or it // keeps pushing bytes for a row that no longer exists. `removedUploads` tells the abort // handler that the row is gone, so it doesn't race the delete below and resurrect it. if (activeUploads.has(id)) { removedUploads.add(id); cancelItem(id); } await storeDelete(id); queueItems.update((items) => items.filter((item) => item.id !== id)); } export async function clearCompleted(): Promise { const items = get(queueItems); for (const item of items) { if (item.status === 'done') { await storeDelete(item.id); } } queueItems.update((items) => items.filter((item) => item.status !== 'done')); } /** * Find the id of an item whose slot can be reclaimed: its blob is gone (`done`, `blocked`) or * was never there to begin with. The blob-less `error` case matters because several early * returns in `uploadItem` produce exactly that — an item with no bytes left to send, which can * never succeed on retry, yet used to hold a queue slot and a red FAB badge forever. */ async function findEvictable(userId: string): Promise { for (const item of get(queueItems)) { if (item.userId !== userId) continue; if (item.status === 'done' || item.status === 'blocked') return item.id; if (item.status !== 'error') continue; const entry = await storeGet(item.id); if (!entry?.blob) return item.id; } return null; } let processing = false; /** In-flight requests by item id, so a stuck upload can be aborted from the UI. */ const activeUploads = new Map(); /** Ids whose abort was requested by the guest, to tell a ✕ apart from a dropped connection. */ const cancelledUploads = new Set(); /** Ids aborted because the row itself is being deleted — their failure must persist nothing. */ const removedUploads = new Set(); /** * Send a guest whose session died back to /join. * * Duplicated from api.ts (where it is module-private) rather than imported: api.ts owns the * foreground half of the same rule and deliberately uses `window.location` over `goto`, since * a full document load resets every module-level store — the correct behaviour after a session * loss, and safe here because the queued blobs live in IndexedDB and survive it. */ function redirectToJoin(): void { if (typeof window === 'undefined') return; const path = window.location.pathname; // Already on an auth screen — re-navigating would throw away half-typed recovery input. if (['/join', '/recover'].some((r) => path === r || path.startsWith(`${r}/`))) return; window.location.assign('/join'); } async function processQueue(): Promise { if (processing) return; processing = true; isProcessing.set(true); try { while (true) { // Offline: leave items 'pending' rather than burning through them into 'error'. // The `online` listener re-enters here the moment connectivity returns. if (typeof navigator !== 'undefined' && navigator.onLine === false) break; const items = get(queueItems); const next = items.find((item) => item.status === 'pending'); if (!next) break; try { await uploadItem(next.id); } catch (e) { if (e instanceof RateLimitError) { // Keep all pending items as-is; schedule queue resume when limit lifts const retryAt = Date.now() + e.retryAfterSecs * 1000; rateLimitRetryAt.set(retryAt); setTimeout(() => { rateLimitRetryAt.set(null); processQueue(); }, e.retryAfterSecs * 1000); break; } if (e instanceof AuthError) { // Dead session — stop the batch (every further item would 401 too). Items // stay retryable with blobs intact; the re-auth flow (clearAuth) takes over. break; } if (e instanceof LockedError) { // Event locked — stop the batch (every item would hit the same lock). Items // stay retryable with blobs intact; the `event-opened` SSE resumes them. break; } if (e instanceof CancelledError) { // The guest cancelled THIS item, which says nothing about the connection — // keep draining so the rest of their photos still go out. (Checked before // NetworkError, which it extends.) continue; } if (e instanceof NetworkError) { // Connectivity dropped mid-flight. If offline the item is back to 'pending' // and the `online` listener resumes it; if the failure hit while nominally // online it's now a retryable 'error'. Either way, stop hammering here. break; } // Other errors are already handled inside uploadItem (marked 'error'/'blocked') } } } finally { processing = false; isProcessing.set(false); } } async function uploadItem(id: string): Promise { const entry = await storeGet(id); if (!entry || !entry.blob) { // Persist the status, don't just paint it. `updateItemStatus` alone writes the store // only, so the row on disk still said `pending` — leaving a blob-less `error` item that // re-entered the drain loop on every resume, and which nothing could ever evict. await failEarly(id, entry, 'Datei nicht gefunden.'); return; } const token = getToken(); const currentUserId = getUserId(); if (!token || !currentUserId) { await failEarly(id, entry, 'Nicht angemeldet.'); return; } // Defense-in-depth: if the device's signed-in user changed since this entry was // queued, refuse to upload it under the new identity. `loadQueue` already filters // by user; this guards the in-memory store path too. if (entry.userId && entry.userId !== currentUserId) { await failEarly(id, entry, 'Anderer Nutzer angemeldet.'); return; } updateItemStatus(id, 'uploading'); try { const formData = new FormData(); // Idempotency key, sent FIRST so the server can read it before it starts streaming the // body (multipart fields arrive in order). The queue item id is stable across every // retry, so a reply lost on the way back — the classic congested-wifi failure — makes // the server return the ORIGINAL upload instead of committing the photo a second time // and charging the guest's quota twice. Both 200 (deduped) and 201 (created) are // success; `classifyUploadStatus` already treats the whole 2xx range that way. formData.append('client_upload_id', entry.id); formData.append('file', entry.blob, entry.fileName); if (entry.caption) formData.append('caption', entry.caption); if (entry.hashtags) formData.append('hashtags', entry.hashtags); await new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); xhr.open('POST', '/api/v1/upload'); xhr.setRequestHeader('Authorization', `Bearer ${token}`); // Wall-clock backstop only — generous enough that a slow-but-alive LTE upload is // never killed by it. See MIN/MAX_UPLOAD_TIMEOUT_MS. xhr.timeout = Math.min( MAX_UPLOAD_TIMEOUT_MS, Math.max(MIN_UPLOAD_TIMEOUT_MS, (entry.fileSize / ASSUMED_MIN_BYTES_PER_SEC) * 1000) ); // Stall watchdog: a phone that roams between APs mid-upload leaves a half-open // connection that never errors and never completes. Only "no bytes moved" catches // that without also punishing a healthy slow link. let lastProgressAt = Date.now(); let lastTickAt = Date.now(); let creditSpent = 0; let bodySent = false; let stalled = false; const stallTimer = setInterval(() => { // Never fire twice. `xhr.abort()` on a request already in `readyState === DONE` // emits NO `abort` event, so `settle()` would never run: the interval would keep // running forever, re-aborting every 5s, and `activeUploads` would keep a stale // entry so the guest's ✕ button silently did nothing. if (stalled) return; const now = Date.now(); // Credit back the window the page was frozen. The watchdog measures SILENCE, and // a period in which it could not observe anything is not evidence of silence. // Clamped to `now` so a progress event delivered right at resume cannot push the // timestamp into the future. // Bounded: see MAX_SUSPEND_CREDIT_MS. Without the cap, sustained background // throttling credited back all but one interval on every tick, so a dead socket // took ~18 minutes to notice while the queue's latch stayed held. const credit = Math.min( suspendedSinceLastTick(lastTickAt, now), Math.max(0, MAX_SUSPEND_CREDIT_MS - creditSpent) ); creditSpent += credit; lastProgressAt = Math.min(now, lastProgressAt + credit); lastTickAt = now; if (!shouldAbortForStall(lastProgressAt, now, bodySent)) return; stalled = true; clearInterval(stallTimer); xhr.abort(); }, STALL_CHECK_INTERVAL_MS); const settle = (fn: () => void) => { clearInterval(stallTimer); activeUploads.delete(id); fn(); }; activeUploads.set(id, xhr); xhr.upload.addEventListener('progress', (e) => { lastProgressAt = Date.now(); // Bytes moved: this stretch of silence is over, so the suspension budget refills. creditSpent = 0; if (e.lengthComputable) { const pct = Math.round((e.loaded / e.total) * 100); queueItems.update((items) => items.map((item) => (item.id === id ? { ...item, progress: pct } : item)) ); } }); // Once the last byte is out the watchdog has nothing left to measure: the server may // legitimately sit on the request while it validates and stores the file, and no // progress event fires in that window. Aborting on the 90s no-progress ceiling there // would re-send a whole video the server had already accepted. // // But switching the watchdog OFF here (which is what this used to do) handed the // window to `xhr.timeout` alone — 5 to 60 minutes, during which a half-open socket // holds `processing` and the guest's whole queue stops draining. So instead of // disarming, widen: `shouldAbortForStall` switches to RESPONSE_TIMEOUT_MS once // `bodySent` flips, which bounds the wedge at 2 minutes without ever firing on a // server that is legitimately still working. xhr.upload.addEventListener('loadend', () => { bodySent = true; lastProgressAt = Date.now(); }); xhr.addEventListener('load', () => { const body = (() => { try { return JSON.parse(xhr.responseText); } catch { return null; } })(); switch (classifyUploadStatus(xhr.status)) { case 'success': // 201 = created, 200 = the server recognised `client_upload_id` and replayed // the original upload. Identical outcome for us: the photo is on the server. settle(resolve); break; case 'rate_limit': { // Back off and auto-resume when the window lifts (quota-full is a distinct // 413, classified 'terminal' below). const secs = typeof body?.retry_after_secs === 'number' ? body.retry_after_secs : 60; settle(() => reject(new RateLimitError(secs))); break; } case 'auth': // Session expired/revoked. NOT terminal — keep the blob and re-auth. Lumping // this into the terminal bucket (which purges the blob) irrecoverably // destroyed queued photos whenever a sliding session lapsed or a host reset // the PIN — precisely when the `online` auto-resume kicks in. settle(() => reject(new AuthError('Sitzung abgelaufen. Bitte melde dich erneut an.'))); break; case 'transient': // 408 (request timeout) behaves like a network blip so it stays retryable; // 5xx is a generic retryable error. Neither purges the blob. settle(() => xhr.status === 408 ? reject(new NetworkError('Zeitüberschreitung')) : reject(new Error(body?.message || `HTTP ${xhr.status}`)) ); break; case 'terminal': { // A REVERSIBLE lock (event closed / gallery released) is tagged // `uploads_locked` by the backend — keep the blob and park it retryable so // a host reopen resumes it, instead of purging it like a permanent 4xx. // Also treat ANY 403 we can't positively identify as permanent (`forbidden` // = banned) as reversible: an unparseable 403 body (proxy/WAF/captive // portal) must NOT purge the blob — losing a photo is the worst outcome, and // 403 is the reversible-lock status here. if (isReversibleLock(xhr.status, body?.error)) { const msg = body?.message || 'Event ist geschlossen.'; settle(() => reject( isGalleryReleased(body?.error) ? new ReleasedError(msg) : isUserBanned(body?.error) ? new BannedError(msg) : new LockedError(msg) ) ); break; } // Any other 4xx the server will keep rejecting (banned, too large, wrong // type). Quota is NOT here any more — it moves with free disk and the // uploader count, so it is a reversible lock handled above. let msg = body?.message || 'Upload nicht möglich.'; if (!body?.message && xhr.status === 413) msg = 'Speicher-Limit erreicht.'; settle(() => reject(new TerminalError(msg))); break; } } }); xhr.addEventListener('error', () => settle(() => reject(new NetworkError('Netzwerkfehler')))); xhr.addEventListener('timeout', () => settle(() => reject(new NetworkError('Zeitüberschreitung'))) ); xhr.addEventListener('abort', () => settle(() => { // Three ways to land here, and they need different answers: the guest tapped ✕ // (don't stop the batch, don't spend retry budget), the watchdog killed a dead // connection, or the browser aborted on its own. if (cancelledUploads.delete(id)) reject(new CancelledError('Abgebrochen')); else if (stalled) reject(new NetworkError('Verbindung eingeschlafen')); else reject(new NetworkError('Abgebrochen')); }) ); // `send` can throw SYNCHRONOUSLY — most plausibly on a phone whose OS purged the // backing store for the blob, leaving a neutered File. The executor would turn that // into a rejection and `uploadItem` would recover, but `settle()` never runs: the // stall interval leaks and `activeUploads` keeps a stale entry, so the ✕ button on // that item stops working for the rest of the session. try { xhr.send(formData); } catch { settle(() => reject(new NetworkError('Netzwerkfehler'))); } }); // Success — remove blob from IndexedDB, mark done entry.status = 'done'; entry.error = undefined; entry.attempts = 0; entry.nextAttemptAt = undefined; delete entry.blob; await storePut(entry); updateItemStatus(id, 'done'); // Refresh the per-user quota snapshot so the My Account widget reflects this // upload's bytes without a manual reload. void refreshQuota(); } catch (e) { if (e instanceof RateLimitError) { // Reset to pending so it will be retried when the queue resumes entry.status = 'pending'; await storePut(entry); updateItemStatus(id, 'pending'); throw e; // Propagate to processQueue for scheduling } if (e instanceof LockedError) { // A condition that can lift without the guest doing anything: the event is closed or // the gallery released (a host can reopen), or the storage quota is currently // exceeded (it moves with free disk and the uploader count, and a host takedown or // the hourly media reclaim puts them back under it). KEEP the blob and park the item // as retryable so it survives until then. `event-opened` and the `feed-delta` // reconnect both auto-resume it; a manual "Erneut" also works. Never purge here. // Neither a release nor a ban lifts on its own, so charging an attempt — and with it // the backoff ladder and budget refill that drive automatic re-pushes — buys nothing // but bandwidth. Park those still and wait for the host action. const parkedFor: 'reopen' | 'unban' | undefined = e instanceof ReleasedError ? 'reopen' : e instanceof BannedError ? 'unban' : undefined; const exhausted = parkedFor ? false : chargeAttempt(entry); entry.status = 'error'; entry.parkedFor = parkedFor; entry.error = parkedFor ? e.message : withRetryHint(e.message, exhausted); await storePut(entry); updateItemStatus(id, 'error', entry.error); // Say it out loud. The queue list is only mounted on /upload and the composer sends // the guest straight to /feed, so this message otherwise lands in a store that // nothing on screen renders — the photo just never appears and the guest, with no // operator to ask, assumes it worked. // // For a release, be explicit that the photo is NOT lost and NOT coming back on its // own — that is the whole difference the guest needs to act on. const parkedHint = parkedFor === 'reopen' ? ' Dein Foto bleibt auf diesem Gerät gespeichert — frag die Gastgeber, ob sie die Galerie noch einmal öffnen.' : parkedFor === 'unban' ? ' Dein Foto bleibt auf diesem Gerät gespeichert und wird gesendet, sobald die Sperre aufgehoben ist.' : ' Du findest den Upload über den Kamera-Button.'; toast(`${entry.fileName}: ${e.message}${parkedHint}`, 'warning', parkedFor ? 9000 : 6000); throw e; } if (e instanceof AuthError) { // Dead session — KEEP the blob (never purge on auth failure) and mark retryable so // the file survives re-auth. Route through the app's single de-auth path (matches // api.ts's 401 handling). The blob persists in IndexedDB keyed by userId; once the // user signs back in with the SAME identity (via /recover), the next time the upload // view loads `loadQueue` re-associates this entry and it can be retried/resumed. entry.status = 'error'; entry.error = e.message; await storePut(entry); updateItemStatus(id, 'error', e.message); clearAuth(); // And actually TAKE them to /join. `clearAuth` alone hides the bottom nav and the // FAB (both gated on `isAuthenticated`), route guards only run in onMount, and a // standalone PWA has no URL bar — so a 401 that arrived from a background upload // left the guest on a dead screen with no control that leads anywhere. api.ts does // this for every foreground request; a background one is no different. redirectToJoin(); throw e; } if (e instanceof CancelledError) { // Aborted because the row is being deleted — writing the entry back here would // undo `removeItem`'s delete and the item would reappear on the next reload. if (removedUploads.delete(id)) throw e; // The guest's own ✕. Park it retryable with the blob intact and spend no retry // budget — they asked for the transfer to stop, not for the photo to be dropped. // `cancelled` is what keeps the automatic path from immediately undoing that; see the // field's docstring. Without it the ✕ was purely cosmetic. entry.status = 'error'; entry.cancelled = true; entry.error = 'Abgebrochen. Tippe auf „Erneut“.'; await storePut(entry); updateItemStatus(id, 'error', entry.error); throw e; } if (e instanceof NetworkError) { const offline = typeof navigator !== 'undefined' && navigator.onLine === false; if (offline) { // Genuinely offline — keep the item pending; the `online` listener resumes it // automatically with no user action. entry.status = 'pending'; await storePut(entry); updateItemStatus(id, 'pending'); } else { // Network-level failure while the OS still reports online (server down, // connection refused, TLS error, captive portal, or our stall watchdog firing on // a half-open connection). The `online` event will NEVER fire in this case, so // leaving it 'pending' would strand the item with no retry path and no spinner. // Mark it retryable 'error' so the user gets a working "Erneut" button, and let // the backoff sweep pick it up. const exhausted = chargeAttempt(entry); const msg = withRetryHint(`${e.message}. Erneut versuchen.`, exhausted); entry.status = 'error'; entry.error = msg; await storePut(entry); updateItemStatus(id, 'error', msg); // Only shout once the automatic attempts are used up: a single blip self-heals // seconds later and a toast for each one would just train guests to ignore them. if (exhausted) { toast( `${entry.fileName} konnte nicht hochgeladen werden. Tippe auf den Kamera-Button, um es erneut zu versuchen.`, 'error', 7000 ); } } throw e; } if (e instanceof TerminalError) { // Permanent rejection — drop the blob (we'll never resend it) and mark blocked // so the UI shows a clear reason and offers no retry. delete entry.blob; entry.status = 'blocked'; entry.error = e.message; await storePut(entry); updateItemStatus(id, 'blocked', e.message); // Tell the user NOW. The queue list only lives on /upload, and the flow sends // them straight to /feed after staging a photo — so without this a rejected // upload was silently swallowed: the blob is gone, the FAB badge drops exactly // as if it had succeeded, and the photo simply never appears. toast(`${entry.fileName}: ${e.message}`, 'error'); return; } // Everything else is a retryable server-side failure (5xx, an unparseable response). const exhausted = chargeAttempt(entry); const msg = withRetryHint(e instanceof Error ? e.message : 'Upload fehlgeschlagen.', exhausted); entry.status = 'error'; entry.error = msg; await storePut(entry); updateItemStatus(id, 'error', msg); // Same reasoning as the network branch: nothing renders this message where the guest // is standing, so a 5xx would otherwise be completely invisible to them. if (exhausted) { toast( `${entry.fileName} konnte nicht hochgeladen werden. Tippe auf den Kamera-Button, um es erneut zu versuchen.`, 'error', 7000 ); } } } /** * Mark an item failed on BOTH sides of the queue before a single byte is sent. The in-memory * `updateItemStatus` on its own left the persisted row saying `pending`, so the next resume * picked the item up again — forever, since these paths produce items with no blob to send. */ async function failEarly( id: string, entry: QueueEntry | undefined, message: string ): Promise { if (entry) { entry.status = 'error'; entry.error = message; await storePut(entry); } updateItemStatus(id, 'error', message); } function updateItemStatus(id: string, status: QueueItem['status'], error?: string): void { queueItems.update((items) => items.map((item) => item.id === id ? { ...item, status, progress: status === 'done' ? 100 : status === 'pending' ? 0 : item.progress, error } : item ) ); }