Files
EventSnap/frontend/src/lib/upload-queue.ts
MechaCat02 6fd75adb27 fix(ops): bound logs, drain ffmpeg's stderr, and stop three silent hangs
Six independent operational defects, none of which needed a new feature to fix.

Log rotation. Docker's json-file driver is unbounded by default, and those files
land on the HOST filesystem — outside every deploy.resources.limits in the compose
file, and on the same disk as postgres_data and media_data. A full disk stops
Postgres writing WAL, which takes the event down. Capped at 10m x 3 per service.

Log level. RUST_LOG was set in neither .env.example nor docker-compose.yml, so the
code fallback WAS the production level — and it was `debug`, with tower_http=debug
emitting a line per request and per response into that unrotated file. Now info,
with tower_http=warn to state that those spans are diagnostics, not an access log.

ffmpeg pipe deadlock. run_ffmpeg piped stdout and stderr and then called wait(),
which drains neither. Once the ~64 KiB pipe buffer filled, ffmpeg blocked writing
and wait() never returned — burning the full 120s timeout, twice per seek position,
three times per compression attempt. And the timeout is an Err, so the end state was
a soft-deleted upload: a guest's playable video destroyed by a poster-frame failure.
Now stdout is null (nothing ever read it) and stderr is drained by wait_with_output,
whose tail is logged on a non-zero exit. Note wait_with_output consumes the child, so
the old kill-on-timeout is gone; kill_on_drop(true) already covers it.

Readiness probe. /health never touched the pool, so the disk-full endgame above
stayed green all the way down. Adds /health/ready (SELECT 1 under 2s) as a SECOND
route — the compose healthcheck deliberately keeps pointing at /health, because
caddy gates its startup on it and a DB-dependent probe would turn a Postgres blip
into the reverse proxy refusing to start.

api.ts request timeout. The abort timer was cleared in a finally around fetch(),
which resolves on the response HEAD — leaving res.text() uncovered and no longer
abortable. An upstream that sends headers then stalls the body hung the call
forever. The timer now lives until the body is read, including the 204 path (which
otherwise leaked a live 20s timer per no-content request).

Upload XHR watchdog. The XHR had no timeout while processQueue held the
isProcessing latch across it; on a half-open socket neither error nor abort ever
fires, so the latch pinned and the queue wedged. Bounds SILENCE rather than total
duration — a 500 MB video over a venue uplink legitimately runs 30+ minutes while
making steady progress. Rejects as NetworkError, which is already the retryable
branch, so a stalled upload now recovers like any network blip.

IndexedDB failures. addToQueue called getDb() unguarded and handleSubmit had no
catch, so a private-mode refusal or a QuotaExceededError on a large blob left a
permanent "Wird hochgeladen…" spinner, no toast, and — for an in-app camera
capture — the only copy of the photo gone. Now reported as 'failed', which keeps
the staged files on screen and stays on the page.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 22:25:23 +02:00

764 lines
30 KiB
TypeScript

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<QueueItem[]>([]);
export const isProcessing = writable(false);
/** Set to the timestamp (ms) at which the rate-limit lifts, or null when clear. */
export const rateLimitRetryAt = writable<number | null>(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<void> {
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<IDBPDatabase> {
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<void> {
// 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<void> {
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<EnqueueResult> {
// 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<IDBPDatabase | null> {
try {
return await getDb();
} catch (e) {
console.warn('upload queue unavailable (IndexedDB)', e);
return null;
}
}
export async function retryItem(id: string): Promise<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void>((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<typeof setInterval> | 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
)
);
}