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:
@@ -15,7 +15,7 @@ import { getToken } from './auth';
|
||||
import { api } from './api';
|
||||
import type { DeltaResponse } from './types';
|
||||
|
||||
type StreamTicketResponse = { ticket: string };
|
||||
type StreamTicketResponse = { ticket: string; server_time: string };
|
||||
|
||||
type EventHandler = (data: string) => void;
|
||||
|
||||
@@ -78,9 +78,11 @@ export function connectSse(): void {
|
||||
// pass that on the URL. The JWT itself never appears in URLs / access logs.
|
||||
void (async () => {
|
||||
let ticket: string;
|
||||
let serverTime: string;
|
||||
try {
|
||||
const res = await api.post<StreamTicketResponse>('/stream/ticket', {});
|
||||
ticket = res.ticket;
|
||||
serverTime = res.server_time;
|
||||
} catch {
|
||||
// Failed to mint a ticket (auth lapse, network blip). Back off and retry
|
||||
// via the existing error path.
|
||||
@@ -95,12 +97,17 @@ export function connectSse(): void {
|
||||
eventSource.onopen = () => {
|
||||
// Successful connection — reset the backoff counter.
|
||||
reconnectAttempt = 0;
|
||||
// If we have a previous timestamp this is a reconnect — fetch the gap.
|
||||
// If we have a previous timestamp this is a reconnect — fetch the gap. The
|
||||
// delta advances `lastEventTime` from the SERVER clock it returns.
|
||||
const since = lastEventTime;
|
||||
if (since) {
|
||||
void deltaFetchAndFan(since);
|
||||
} else {
|
||||
// First connect: seed the cursor from the server clock at ticket-mint time,
|
||||
// never `new Date()` — a skewed browser clock would otherwise shift the very
|
||||
// first reconnect window and could drop uploads.
|
||||
lastEventTime = serverTime;
|
||||
}
|
||||
lastEventTime = new Date().toISOString();
|
||||
};
|
||||
|
||||
for (const eventName of KNOWN_EVENTS) {
|
||||
@@ -159,7 +166,12 @@ export function setLastEventTime(time: string): void {
|
||||
}
|
||||
|
||||
function dispatch(eventType: string, data: string): void {
|
||||
lastEventTime = new Date().toISOString();
|
||||
// Advance the reconnect cursor from the SERVER timestamp carried in the payload (when
|
||||
// present — e.g. a new upload's `created_at`), never the browser clock. Events without
|
||||
// a timestamp (likes, lock toggles) leave the cursor where it is; the next delta
|
||||
// re-fetches from the last content timestamp we saw, which merges idempotently.
|
||||
const ts = extractCreatedAt(data);
|
||||
if (ts) lastEventTime = ts;
|
||||
const list = handlers.get(eventType);
|
||||
if (list) {
|
||||
for (const handler of list) {
|
||||
@@ -168,6 +180,17 @@ function dispatch(eventType: string, data: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** Pull an ISO `created_at` out of an event payload if it has one, else undefined. */
|
||||
function extractCreatedAt(data: string): string | undefined {
|
||||
try {
|
||||
const parsed = JSON.parse(data);
|
||||
if (parsed && typeof parsed.created_at === 'string') return parsed.created_at;
|
||||
} catch {
|
||||
// non-JSON payload (e.g. a plain count) — no timestamp to extract
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all feed activity since `since` and fan it out as a synthetic `feed-delta`
|
||||
* event. Subscribers (typically the feed page) merge the result into their
|
||||
@@ -179,6 +202,9 @@ async function deltaFetchAndFan(since: string): Promise<void> {
|
||||
const response = await api.get<DeltaResponse>(
|
||||
`/feed/delta?since=${encodeURIComponent(since)}`
|
||||
);
|
||||
// Advance the cursor to the server clock this delta was computed at, so the next
|
||||
// reconnect resumes exactly where the server left off (no browser-clock skew).
|
||||
lastEventTime = response.server_time;
|
||||
dispatch('feed-delta', JSON.stringify(response));
|
||||
} catch {
|
||||
// non-fatal
|
||||
|
||||
Reference in New Issue
Block a user