Files
EventSnap/frontend/src/lib
fabi 281eb3bec7 fix(export): refuse an export that cannot fit, and stop peaking at two generations
Nothing in export.rs ever asked whether the keepsake would fit. Both archives write
their media `Compression::Stored`, so each is essentially a byte-for-byte second copy
of the originals -- Gallery.zip always, and Memories.zip for every video and every
image at or under 5 MB. On the documented CX33 (80 GB, all three volumes on one
filesystem) the upload quota's fixed point leaves ~40 GB free, and a release spawns
BOTH halves concurrently against it.

The failure is not "the export failed", it is "the deliverable is stuck":

  1. ENOSPC lands partway through a multi-GB write.
  2. The epoch has already moved, so the job row is `failed` at the CURRENT
     generation and readiness (epoch = event.export_epoch AND status = 'done') is
     false -- GET /export/zip 404s.
  3. The last good archive sits on disk, unreferenced and unreachable.
  4. POST /host/export/rebuild, the only escape, re-arms the same doomed write.

Three changes.

Reclaim before building. `prune_stale_export_files` ran only after the new archive
was written, renamed and finalised. That reads as durability but buys nothing: the
moment `invalidate_and_arm` bumps the epoch the old archive is ALREADY unreachable,
so keeping it reserves gigabytes for a download nobody can perform -- and for a
takedown it is content someone explicitly asked to have removed. Peak usage is now
one generation. Narrower than the post-finalize prune on purpose: final archives
only, never a `.tmp` or a `viewer_tmp_` dir, since a superseded worker can still be
streaming into those and at build START is far more likely to be alive.

Preflight the space. SUM(original_size_bytes) over exactly `query_uploads`'
visibility filter, +10% for ZIP overhead, multiplied by the number of armed jobs --
without that multiplier each of the two concurrent halves independently sees "it
fits" and together they don't. Runs AFTER claim_job, not before as reported: bailing
before the claim leaves the row `pending` with no worker and no error, the
spinner-forever state `mark_failed`'s status guard exists to prevent. Fails open when
the mount can't be read, exactly as the upload quota does.

Show the host the reason. /export/status returned {status, progress_pct} and nothing
else, so the host dashboard could only render "fehlgeschlagen" next to the retry
button. The message was written to the row and surfaced solely in the ADMIN job list
-- a different screen, possibly a different person. It now travels with the status,
and only on a failure, so a message left on a since-succeeded row can't appear beside
a green "ist bereit".

Tests: 10 unit (the u128 clamp caught a real bug in the first draft -- saturating_mul
then /100 turns an overflow into a number ~100x too small, the one direction that
authorises the write being guarded against; the carried-forward archive must survive
its own older epoch in the filename), 4 DB-backed (the estimate is asserted against
the row set the archive actually contains, not against a restatement of the WHERE
clause, so the two queries cannot drift), 3 e2e over the four-hop plumbing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 19:38:26 +02:00
..

lib/ conventions

Short rules. The patterns we already follow as of v0.16 — write new code that fits.

One store per cross-cutting concern. A single *-store.ts file owns each one:

  • auth.ts — JWT / PIN in localStorage, isAuthenticated writable
  • ui-store.ts — bottom-nav visibility, upload-sheet open state, FAB badge count
  • data-mode-store.ts — Saver vs Original media-loading preference
  • privacy-note-store.ts — admin-configured Datenschutzhinweis text
  • quota-store.ts — live per-user storage snapshot
  • upload-queue.ts — IndexedDB-persisted upload queue + processing state

Don't import these into other stores unless strictly necessary; let pages compose them.

DTOs mirror Rust types. All TS interfaces live in types.ts. Each one carries a // mirrors backend/src/path::TypeName comment so the two stay searchable. If you add a Rust DTO, add the TS twin in the same PR.

Gestures via Svelte actions in actions/. Long-press, double-tap, future swipe — each is a use: action that fires a CustomEvent. Components stay free of gesture plumbing.

Reusable bottom sheets via ContextSheet.svelte. Pass an actions: ContextAction[] array. Any page that needs a long-press / kebab context menu uses the same primitive.

SSE relays are listed in sse.ts::KNOWN_EVENTS. New server event → add one entry to that array, that's it.

Diashow transitions live in diashow/transitions/. Each is a Svelte component plus one entry in transitions/index.ts. Adding a new animation is two-line work; no diashow code needs to change.

No new global stores beyond the list above unless the new concept is genuinely app-wide. Page state belongs in the page.