fix(user-flow): close offline-queue, export, session, ban & realtime gaps
Comprehensive user-flow review across guest/host/admin roles, then fixes with
e2e regression guards. Highlights:
- Offline upload queue auto-resumes on reconnect: network errors keep items
pending (not error), 4xx are terminal (no infinite retry), quota exhaustion
returns a distinct 413; queue cap + dedup.
- Export lifecycle: release atomically locks uploads; reopen invalidates and
re-release regenerates the keepsake; workers claim their job atomically so a
reopen->re-release can't corrupt the ZIP; startup re-spawns interrupted
exports.
- Sessions slide on activity (no 30-day cliff); JWT expiry deferred to the
revocable session row; sign-out-everywhere + revoke-on-PIN-reset.
- Ban always hides content (v_feed / find_visible_media / export filter
is_banned) but stays a read-only ban per USER_JOURNEYS §10 — sessions are
not revoked, read access + keepsake download preserved.
- Realtime: server-clock SSE delta cursor; event-closed/opened drive the UI
live; feed_delta rate-limited; like returns {liked, like_count} to fix
multi-device drift; lightbox live comments; diashow delta backfill.
- Forgotten-PIN in-app request flow; simultaneous same-name join returns 409;
quota increment is transactional; operator floor.
Adds e2e/specs/10-flow-review/ (offline resume, export integrity, deterministic
anti-race guard) and updates existing specs for the new contracts.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -11,7 +11,10 @@ export interface QueueItem {
|
||||
mimeType: string;
|
||||
caption: string;
|
||||
hashtags: string;
|
||||
status: 'pending' | 'uploading' | 'done' | 'error';
|
||||
// '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;
|
||||
}
|
||||
@@ -26,8 +29,50 @@ 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;
|
||||
|
||||
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();
|
||||
|
||||
/**
|
||||
* 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.
|
||||
@@ -76,6 +121,26 @@ class RateLimitError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {}
|
||||
|
||||
export async function loadQueue(): Promise<void> {
|
||||
const database = await getDb();
|
||||
const myUserId = getUserId();
|
||||
@@ -98,6 +163,14 @@ export async function loadQueue(): Promise<void> {
|
||||
error: entry.error
|
||||
}));
|
||||
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();
|
||||
})();
|
||||
}
|
||||
|
||||
export async function addToQueue(
|
||||
@@ -108,6 +181,33 @@ export async function addToQueue(
|
||||
const database = await getDb();
|
||||
const userId = getUserId();
|
||||
if (!userId) return; // not authenticated — nothing to do
|
||||
|
||||
// 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+user.
|
||||
const mine = get(queueItems).filter((i) => i.userId === userId);
|
||||
const dup = mine.some(
|
||||
(i) =>
|
||||
i.fileName === file.name &&
|
||||
i.fileSize === file.size &&
|
||||
(i.status === 'pending' || i.status === 'uploading')
|
||||
);
|
||||
if (dup) return;
|
||||
|
||||
// Cap the queue so stuck/blocked blobs can't grow IndexedDB without bound. When full,
|
||||
// evict the oldest terminal item (done/error/blocked) to make room; if none exist,
|
||||
// refuse silently rather than pile on.
|
||||
if (mine.length >= MAX_QUEUE_ITEMS) {
|
||||
const evictable = get(queueItems).find(
|
||||
(i) => i.userId === userId && (i.status === 'done' || i.status === 'error' || i.status === 'blocked')
|
||||
);
|
||||
if (evictable) {
|
||||
await database.delete(STORE_NAME, evictable.id);
|
||||
queueItems.update((items) => items.filter((it) => it.id !== evictable.id));
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const id = crypto.randomUUID();
|
||||
const entry = {
|
||||
id,
|
||||
@@ -184,6 +284,10 @@ async function processQueue(): Promise<void> {
|
||||
|
||||
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;
|
||||
@@ -201,7 +305,12 @@ async function processQueue(): Promise<void> {
|
||||
}, e.retryAfterSecs * 1000);
|
||||
break;
|
||||
}
|
||||
// Other errors are already handled inside uploadItem (marked as 'error')
|
||||
if (e instanceof NetworkError) {
|
||||
// Connectivity dropped mid-flight — the item is back to 'pending'. Stop the
|
||||
// loop; the `online` listener resumes it. No error state, no manual tap.
|
||||
break;
|
||||
}
|
||||
// Other errors are already handled inside uploadItem (marked 'error'/'blocked')
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
@@ -258,6 +367,8 @@ async function uploadItem(id: string): Promise<void> {
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
resolve();
|
||||
} else if (xhr.status === 429) {
|
||||
// Rate limit only (quota-full is now a distinct 413, handled below as
|
||||
// terminal) — back off and auto-resume when the window lifts.
|
||||
try {
|
||||
const body = JSON.parse(xhr.responseText);
|
||||
const secs = typeof body.retry_after_secs === 'number' ? body.retry_after_secs : 60;
|
||||
@@ -265,7 +376,20 @@ async function uploadItem(id: string): Promise<void> {
|
||||
} catch {
|
||||
reject(new RateLimitError(60));
|
||||
}
|
||||
} else if (xhr.status >= 400 && xhr.status < 500) {
|
||||
// Any other 4xx is permanent for this blob (locked/banned/released/quota).
|
||||
// Retrying just re-hits the same rejection forever, so mark it terminal.
|
||||
let msg = 'Upload nicht möglich.';
|
||||
try {
|
||||
const body = JSON.parse(xhr.responseText);
|
||||
if (body.message) msg = body.message;
|
||||
else if (xhr.status === 413) msg = 'Speicher-Limit erreicht.';
|
||||
} catch {
|
||||
if (xhr.status === 413) msg = 'Speicher-Limit erreicht.';
|
||||
}
|
||||
reject(new TerminalError(msg));
|
||||
} else {
|
||||
// 5xx / unexpected — transient, keep it retryable.
|
||||
try {
|
||||
const body = JSON.parse(xhr.responseText);
|
||||
reject(new Error(body.message || `HTTP ${xhr.status}`));
|
||||
@@ -275,8 +399,8 @@ async function uploadItem(id: string): Promise<void> {
|
||||
}
|
||||
});
|
||||
|
||||
xhr.addEventListener('error', () => reject(new Error('Netzwerkfehler')));
|
||||
xhr.addEventListener('abort', () => reject(new Error('Abgebrochen')));
|
||||
xhr.addEventListener('error', () => reject(new NetworkError('Netzwerkfehler')));
|
||||
xhr.addEventListener('abort', () => reject(new NetworkError('Abgebrochen')));
|
||||
xhr.send(formData);
|
||||
});
|
||||
|
||||
@@ -296,6 +420,24 @@ async function uploadItem(id: string): Promise<void> {
|
||||
updateItemStatus(id, 'pending');
|
||||
throw e; // Propagate to processQueue for scheduling
|
||||
}
|
||||
if (e instanceof NetworkError) {
|
||||
// No connectivity — keep the item pending and propagate so processQueue stops
|
||||
// the loop (no point hammering while offline). The `online` listener resumes it.
|
||||
entry.status = 'pending';
|
||||
await database.put(STORE_NAME, entry);
|
||||
updateItemStatus(id, 'pending');
|
||||
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);
|
||||
return;
|
||||
}
|
||||
const msg = e instanceof Error ? e.message : 'Upload fehlgeschlagen.';
|
||||
entry.status = 'error';
|
||||
entry.error = msg;
|
||||
|
||||
Reference in New Issue
Block a user