A STALLED UPLOAD BLOCKED EVERYTHING, FOREVER. The XHR set no timeout and had no stall detection, so a half-open connection from an AP roam left the item `uploading` indefinitely — which kept `processQueue`'s `processing` flag set, so the whole rest of the queue stopped draining. The UI offered no control at all for an `uploading` item. The guest saw "Wird hochgeladen 43%" all evening with four photos stuck behind it and no button to press; the only escape was force-quitting the PWA, which nobody guesses. Now: a watchdog aborts when no bytes move for 90s, disarmed on `loadend` so the server may take its time storing a file it already has; a size-scaled timeout as a generous backstop that will not kill slow-but-progressing LTE; and a cancel button. FAILURES WERE INVISIBLE. `handleSubmit` navigates to /feed immediately, and the queue component is mounted only on /upload — so a 5xx, a captive-portal error or an uploads-locked 403 wrote a German message into an item that nothing ever rendered. The guest believed the photo was uploading; it never appeared. Same for the documented rate-limit countdown banner, which lives in that same unreachable component and is now also rendered from the layout. RETRIES WERE UNCAPPED. `requeueRetriable` flipped every errored item back to pending on the `online` event AND on every `feed-delta` — i.e. every SSE reconnect — with no attempt counter and no backoff. On a flapping network a large failing video was re-uploaded from byte zero all evening, saturating the AP for everyone. Now a persisted attempt count, exponential backoff and a cap of five. INDEXEDDB COULD STRAND THE COMPOSER. `openDB` had no `blocked` handler, so a second tab holding an older version made it never settle, and it rejects outright on iOS private mode; `handleSubmit` had no try/catch and never reset `submitting`, so both buttons stayed disabled reading "Wird hochgeladen…" permanently, with no error and nothing queued. There is now a `blocked` handler plus a settle timeout, an in-memory fallback so uploading still works when persistence is unavailable, and a `finally`. A 401 during a background upload cleared the session without redirecting — and api.ts documents exactly why that strands a guest: the nav and FAB are gated on `isAuthenticated` so they vanish, route guards only run on mount, and a standalone PWA has no URL bar. Three early-return paths wrote status only to memory and never to IndexedDB, leaving blob-less error rows that could never be evicted and held the red FAB badge lit all night. A banned guest was offered the entire upload flow — FAB, camera, staging — and only the POST 403'd, while the new read-only banner told them uploading was disabled. The sheet now consults the ban, the layout subscribes to `user-hidden` so a live ban reaches the UI instead of arriving as a stream of 403 toasts, and the banner clears `env(safe-area-inset-bottom)` so the bottom nav stops covering it on notched iPhones. The /upload submit bar gets the same inset — it sat in the home-indicator zone, where the system swallows the first tap. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1140 lines
45 KiB
TypeScript
1140 lines
45 KiB
TypeScript
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<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;
|
|
|
|
/**
|
|
* 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;
|
|
|
|
/** 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;
|
|
|
|
/**
|
|
* 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;
|
|
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 } = {}) => {
|
|
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 }));
|
|
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.
|
|
*/
|
|
async function requeueRetriable(options: { resetAttempts?: boolean } = {}): Promise<void> {
|
|
const myUserId = getUserId();
|
|
const all = await storeGetAll();
|
|
const now = Date.now();
|
|
const requeued = new Set<string>();
|
|
let soonest: number | null = null;
|
|
for (const entry of all) {
|
|
if (entry.userId !== myUserId || entry.status !== 'error' || !entry.blob) continue;
|
|
if (options.resetAttempts) {
|
|
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<typeof setTimeout> | 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;
|
|
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<IDBPDatabase | null> | null = null;
|
|
const memoryEntries = new Map<string, QueueEntry>();
|
|
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<IDBPDatabase | null> {
|
|
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<null>((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<QueueEntry | undefined> {
|
|
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<QueueEntry[]> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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 {}
|
|
|
|
/** 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 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();
|
|
})();
|
|
}
|
|
|
|
/** 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<EnqueueResult> {
|
|
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<void> {
|
|
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;
|
|
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<void> {
|
|
// 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<void> {
|
|
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<string | null> {
|
|
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<string, XMLHttpRequest>();
|
|
/** Ids whose abort was requested by the guest, to tell a ✕ apart from a dropped connection. */
|
|
const cancelledUploads = new Set<string>();
|
|
/** Ids aborted because the row itself is being deleted — their failure must persist nothing. */
|
|
const removedUploads = new Set<string>();
|
|
|
|
/**
|
|
* 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<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 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<void> {
|
|
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<void>((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 stalled = false;
|
|
const stallTimer = setInterval(() => {
|
|
if (Date.now() - lastProgressAt < STALL_TIMEOUT_MS) return;
|
|
stalled = true;
|
|
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();
|
|
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 there would re-send a whole video
|
|
// the server had already accepted, so hand over to `xhr.timeout` instead.
|
|
xhr.upload.addEventListener('loadend', () => clearInterval(stallTimer));
|
|
|
|
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)) {
|
|
settle(() => 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.';
|
|
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'));
|
|
})
|
|
);
|
|
xhr.send(formData);
|
|
});
|
|
|
|
// 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) {
|
|
// 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.
|
|
const exhausted = chargeAttempt(entry);
|
|
entry.status = 'error';
|
|
entry.error = 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.
|
|
toast(
|
|
`${entry.fileName}: ${e.message} Du findest den Upload über den Kamera-Button.`,
|
|
'warning',
|
|
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.
|
|
entry.status = 'error';
|
|
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<void> {
|
|
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
|
|
)
|
|
);
|
|
}
|