import { openDB, type IDBPDatabase } from 'idb'; import { writable, get } from 'svelte/store'; import { getToken, getUserId, clearAuth } 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; // ── Upload watchdog ─────────────────────────────────────────────────────────── // The upload XHR had no timeout of any kind while `processQueue` held the `processing` / // `isProcessing` latch across it. A half-open socket — the classic phone-leaves-wifi case, // where no `error` and no `abort` event ever fires — pinned that latch forever and wedged // the whole queue with no recovery short of a reload. // // Deliberately NOT a flat total timeout: a legitimate 500 MB video over a venue uplink can // run 30+ minutes while making perfectly steady progress, and a total cap would kill exactly // the uploads that matter most. What we bound is SILENCE. /** No-progress ceiling while the request body is still being sent. */ const UPLOAD_STALL_MS = 90_000; /** Ceiling for the window AFTER the last byte is sent. `upload.progress` is silent there by * definition (the server is sniffing, committing and answering), so the stall detector has * no signal and this coarser bound takes over. Sized above the backend's own worst-case * commit path, not above compression — compression is async and does not hold the response. */ const UPLOAD_RESPONSE_TIMEOUT_MS = 120_000; /** How often the watchdog re-checks. Coarse on purpose; it only needs to bound the wedge. */ const UPLOAD_WATCHDOG_INTERVAL_MS = 5_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 ? UPLOAD_RESPONSE_TIMEOUT_MS : UPLOAD_STALL_MS; return now - lastActivityAt > ceiling; } let db: IDBPDatabase | null = null; // 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(); // 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 = () => { void (async () => { await requeueRetriable(); await processQueue(); })(); }; onSseEvent('event-opened', resume); 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. */ async function requeueRetriable(): Promise { const database = await getDb(); const myUserId = getUserId(); const all = await database.getAll(STORE_NAME); for (const entry of all) { if (entry.userId === myUserId && entry.status === 'error' && entry.blob) { entry.status = 'pending'; entry.error = undefined; await database.put(STORE_NAME, entry); } } queueItems.update((items) => items.map((item) => item.status === 'error' ? { ...item, status: 'pending' as const, progress: 0, error: undefined } : item ) ); } async function getDb(): Promise { if (db) return db; // 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. db = await 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(); } } }); return db; } /** * 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 { // Always clear the in-memory view, even if the store is unreachable: this runs on logout, // and leaving the previous guest's items on screen for the next one is the worse failure. const database = await getDbSafe(); if (database) await database.clear(STORE_NAME); 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 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 {} /** 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 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 non-403 4xx (e.g. 413 quota) are permanent → purge. */ export function isReversibleLock(status: number, errorCode: unknown): boolean { return errorCode === 'uploads_locked' || (status === 403 && errorCode !== 'forbidden'); } /** * 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 database = await getDb(); const myUserId = getUserId(); const all = await database.getAll(STORE_NAME); // 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(); })(); } /** Outcome of an `addToQueue` call, so the caller can tell the user when a file was NOT * actually queued (deduped, the queue is full of un-evictable in-flight items, or the * local store itself is unusable). */ export type EnqueueResult = 'queued' | 'duplicate' | 'full' | 'failed'; export async function addToQueue( file: File, caption: string, hashtags: string ): Promise { // IndexedDB is not guaranteed available: Safari private mode refuses to open a DB, an // upgrade can be blocked by another tab, and `put` of a 500 MB blob can hit a // QuotaExceededError. Every one of those used to reject out of here into a `handleSubmit` // with no catch — leaving a permanent "Wird hochgeladen…" spinner, no toast, and (for an // in-app camera capture) the only copy of the photo gone. Report it instead. let database: IDBPDatabase; try { database = await getDb(); } catch (e) { console.warn('upload queue unavailable (IndexedDB)', e); return 'failed'; } 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 — ONLY `done` (blob // deleted on success) or `blocked` (blob purged, terminal). NEVER evict `error`: those are // retryable and still hold a live blob (a network blip, or a locked/released upload that // resumes on reopen) — evicting one would silently lose a photo the fix 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 = get(queueItems).find( (i) => i.userId === userId && (i.status === 'done' || i.status === 'blocked') ); if (evictable) { await database.delete(STORE_NAME, evictable.id); queueItems.update((items) => items.filter((it) => it.id !== evictable.id)); } else { return 'full'; } } const id = crypto.randomUUID(); const entry = { id, userId, fileName: file.name, fileSize: file.size, lastModified: file.lastModified, mimeType: file.type, caption, hashtags, status: 'pending', blob: file }; // Persist BEFORE touching the store: a failure here (quota exceeded on a large blob) must // not leave a phantom item the UI shows as queued but nothing can ever upload. try { await database.put(STORE_NAME, entry); } catch (e) { console.warn('upload queue write failed (IndexedDB)', e); return 'failed'; } 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'; } /** `getDb` that reports unavailability instead of rejecting. For the UI-driven queue * operations, where an unusable IndexedDB must degrade to "nothing happened" rather than * leave a caller awaiting a rejected promise it never catches. */ async function getDbSafe(): Promise { try { return await getDb(); } catch (e) { console.warn('upload queue unavailable (IndexedDB)', e); return null; } } export async function retryItem(id: string): Promise { const database = await getDbSafe(); if (!database) return; const entry = await database.get(STORE_NAME, id); if (!entry) return; entry.status = 'pending'; entry.error = undefined; await database.put(STORE_NAME, entry); queueItems.update((items) => items.map((item) => item.id === id ? { ...item, status: 'pending' as const, progress: 0, error: undefined } : item ) ); processQueue(); } export async function removeItem(id: string): Promise { const database = await getDbSafe(); if (!database) return; await database.delete(STORE_NAME, id); queueItems.update((items) => items.filter((item) => item.id !== id)); } export async function clearCompleted(): Promise { const database = await getDbSafe(); if (!database) return; const items = get(queueItems); for (const item of items) { if (item.status === 'done') { await database.delete(STORE_NAME, item.id); } } queueItems.update((items) => items.filter((item) => item.status !== 'done')); } let processing = false; 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 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 database = await getDb(); const entry = await database.get(STORE_NAME, id); if (!entry || !entry.blob) { updateItemStatus(id, 'error', 'Datei nicht gefunden.'); return; } const token = getToken(); const currentUserId = getUserId(); if (!token || !currentUserId) { updateItemStatus(id, 'error', '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) { updateItemStatus(id, 'error', 'Anderer Nutzer angemeldet.'); return; } updateItemStatus(id, 'uploading'); try { const formData = new FormData(); 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}`); // Watchdog state — see UPLOAD_STALL_MS. `timedOut` distinguishes our own abort from // a user-initiated one so the surfaced message stays honest. let lastActivityAt = Date.now(); let bodySent = false; let timedOut = false; let watchdog: ReturnType | undefined; const stopWatchdog = () => { if (watchdog !== undefined) { clearInterval(watchdog); watchdog = undefined; } }; // `loadend` on the XHR itself fires on success, error, abort and timeout alike — the // one hook that guarantees the interval is released on every exit path. xhr.addEventListener('loadend', stopWatchdog); xhr.upload.addEventListener('progress', (e) => { lastActivityAt = Date.now(); 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)) ); } }); xhr.addEventListener('load', () => { const body = (() => { try { return JSON.parse(xhr.responseText); } catch { return null; } })(); switch (classifyUploadStatus(xhr.status)) { case 'success': 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; 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. 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. if (xhr.status === 408) reject(new NetworkError('Zeitüberschreitung')); else 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)) { reject(new LockedError(body?.message || 'Event ist geschlossen.')); break; } // Any other 4xx the server will keep rejecting (banned / quota). let msg = body?.message || 'Upload nicht möglich.'; if (!body?.message && xhr.status === 413) msg = 'Speicher-Limit erreicht.'; reject(new TerminalError(msg)); break; } } }); // Body fully handed to the socket: `upload.progress` goes quiet from here, so switch // the watchdog to the response ceiling rather than let it fire on normal waiting. xhr.upload.addEventListener('loadend', () => { bodySent = true; lastActivityAt = Date.now(); }); xhr.addEventListener('error', () => reject(new NetworkError('Netzwerkfehler'))); xhr.addEventListener('abort', () => reject(new NetworkError(timedOut ? 'Zeitüberschreitung' : 'Abgebrochen')) ); xhr.send(formData); // Armed only after send(), so the clock starts with the request. NetworkError is // already the retryable branch (blob kept, "Erneut" offered), so a stalled upload // now recovers exactly like a network blip instead of wedging the queue. watchdog = setInterval(() => { if (!shouldAbortForStall(lastActivityAt, Date.now(), bodySent)) return; stopWatchdog(); timedOut = true; xhr.abort(); }, UPLOAD_WATCHDOG_INTERVAL_MS); }); // Success — remove blob from IndexedDB, mark done entry.status = 'done'; delete entry.blob; await database.put(STORE_NAME, 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 database.put(STORE_NAME, entry); updateItemStatus(id, 'pending'); throw e; // Propagate to processQueue for scheduling } if (e instanceof LockedError) { // Event closed / gallery released, but a host can reopen — KEEP the blob and park // the item as retryable so it survives until reopen. The `event-opened` SSE // (bindSse) auto-resumes it; a manual "Erneut" also works. Never purge here. entry.status = 'error'; entry.error = e.message; await database.put(STORE_NAME, entry); updateItemStatus(id, 'error', e.message); 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 database.put(STORE_NAME, entry); updateItemStatus(id, 'error', e.message); clearAuth(); 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 database.put(STORE_NAME, entry); updateItemStatus(id, 'pending'); } else { // Network-level failure while the OS still reports online (server down, // connection refused, TLS error, captive portal). 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 a later real reconnect requeues it. entry.status = 'error'; entry.error = 'Netzwerkfehler. Erneut versuchen.'; await database.put(STORE_NAME, entry); updateItemStatus(id, 'error', 'Netzwerkfehler. Erneut versuchen.'); } 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 database.put(STORE_NAME, 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; } const msg = e instanceof Error ? e.message : 'Upload fehlgeschlagen.'; entry.status = 'error'; entry.error = msg; await database.put(STORE_NAME, entry); updateItemStatus(id, 'error', msg); } } 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 ) ); }