50eca0627f
Benchmark-gated round (benches/ROUND12.md; every change ships with a BEFORE/AFTER harness + equivalence gates, one candidate rejected by its own bench): DB / query shapes (bench_round12_queries): - NC sharee search: username-only projection instead of the 21-column row (incl. the <=512 KiB avatar) per match, + gin_trgm_ops indexes on auth.users for the leading-wildcard ILIKE (4.98x; 54.7x with index). - Password login: delete the redundant full-row update_user — create_session already stamps last_login_at in its own txn (4.45x per login). - Email-verified stamp: narrow conditional UPDATE (8.9x); OIDC repeat login now compares profile state in memory and issues ZERO queries when nothing changed (was: full 17-column rewrite per login). - Refresh rotation: revoke+insert+stamp fused into one transaction via new rotate_session port method (1.18x). - WOPI CheckFileInfo / authorize_wopi_access: require(Read) + get_file + check(Update) overlapped with tokio::join!, original result precedence (cold 1.34x). - Upload quota gate: user-envelope + drive-cap checks fused into ONE round-trip (check_upload_quotas) — the NC chunked PUT pays this per chunk (1.81x, 2 -> 1 queries/chunk); shared verdict evaluators keep error shapes byte-identical. CPU / allocs (bench_round12_micro): - sized_json: pre-sized listing serialization replacing axum Json's 128 B seed + doubling-realloc chain on files/folder-resources/photos/search responses (1.40x, 13 -> 2 allocs per 500-row page; byte-identical). - Security headers: 4 SetResponseHeaderLayer folded into the CSP middleware pass (5 layers -> 1; 1.43x per request, -26 allocs; header set gated byte-identical incl. 304s). - Media capture-metadata: single-read extraction — nom-exif now parses the buffer kamadak already read (zero-copy Bytes) and videos open once with a kind() dispatch; per-image opens 2-3 -> 1 (1.44x warm geomean, 1.6-3.2x cold cache; extraction outputs gated identical incl. the MIME-mislabel track fallback). - Chunked-upload session ops: owner gate folded into the operation's own DashMap lookup + stack-encoded uuid compare (5 -> 3 lookups, -2 allocs, 1.28x per chunk). Blob cache (bench_blob_cache_index + round-3 regression guard): - CachedBlobBackend index: tokio::sync::Mutex<LruCache> -> moka::sync::Cache with byte weigher. The mutex serialized every cached chunk read and scaled NEGATIVELY (2.08 -> 1.07 Mops/s from 1 -> 2 readers); moka probes are lock-free (2.17x at K=2). Byte budget now enforced by moka (manual current_size + collect_evictions machinery deleted); eviction listener unlinks size-evicted files only (Replaced entries keep their file — gated). Single-flight miss gate unchanged (16 concurrent misses -> 1 fetch re-verified via the round-3 harness). - put_blob now populates the cache BEFORE the inner backend consumes the source file (the old order failed 100% of the time — local renames, S3/Azure delete the source — so the first read after a whole-file put re-downloaded from the remote); inner-put failure invalidates the entry. Frontend (vitest gates): - List-view thumbnails request the 150px icon rendition instead of 400px preview into a 40px slot (~7.1x fewer pixels, ~4-5x fewer bytes per thumbnail across list views); grid keeps preview. Rejected by its own bench (kept as evidence in bench_round12_micro §2): - Single-pass compression predicate: the monomorphized And-chain already costs ~4.6 ns / 0 allocs total; the fused node measured within noise. New migration: 20260719000000_users_search_trgm.sql (trgm indexes). Deferred with prepared design: grouped file/grid view virtualization (single-VirtualRows flatten, the photos pattern) — next round's headline. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BfidAJD5AHw23jtvBUNamB
176 lines
6.3 KiB
TypeScript
176 lines
6.3 KiB
TypeScript
/** File endpoints — ported from fileOperations.js. */
|
||
import { apiFetch } from '$lib/api/client';
|
||
import { getCsrfHeaders } from '$lib/api/csrf';
|
||
|
||
const JSON_HEADERS = { 'Content-Type': 'application/json' };
|
||
|
||
/**
|
||
* Instant upload: materialise a file from a blob the caller **already owns**,
|
||
* by its whole-file BLAKE3 — zero content bytes cross the wire. Returns the HTTP
|
||
* status so the caller can fall back to a plain upload on 404 (hash not owned).
|
||
* Scoped to the caller's own content server-side (no cross-user probing).
|
||
*/
|
||
export async function createFileByHash(
|
||
folderId: string,
|
||
name: string,
|
||
hash: string
|
||
): Promise<{ ok: boolean; status: number; data?: unknown }> {
|
||
const res = await apiFetch('/api/files/by-hash', {
|
||
method: 'POST',
|
||
credentials: 'same-origin',
|
||
headers: { ...JSON_HEADERS, ...getCsrfHeaders() },
|
||
body: JSON.stringify({ name, folder_id: folderId, hash })
|
||
});
|
||
const data = res.ok ? await res.json().catch(() => undefined) : undefined;
|
||
return { ok: res.ok, status: res.status, data };
|
||
}
|
||
|
||
/**
|
||
* Batch dedup check: given candidate whole-file BLAKE3 hashes, return the set
|
||
* the caller **already owns** — in a single round trip. Drives instant uploads:
|
||
* a file whose hash is in the set can be created with zero content bytes.
|
||
* Resolves an empty set on any failure, so the caller just uploads everything.
|
||
*/
|
||
export async function dedupCheckBatch(hashes: string[]): Promise<Set<string>> {
|
||
if (hashes.length === 0) return new Set();
|
||
const res = await apiFetch('/api/dedup/check-batch', {
|
||
method: 'POST',
|
||
credentials: 'same-origin',
|
||
headers: { ...JSON_HEADERS, ...getCsrfHeaders() },
|
||
body: JSON.stringify({ hashes })
|
||
});
|
||
if (!res.ok) return new Set();
|
||
const data = (await res.json().catch(() => null)) as { owned?: string[] } | null;
|
||
return new Set(data?.owned ?? []);
|
||
}
|
||
|
||
export async function uploadFile(folderId: string | null, file: File): Promise<void> {
|
||
const form = new FormData();
|
||
if (folderId) form.append('folder_id', folderId);
|
||
form.append('file', file);
|
||
const res = await apiFetch('/api/files/upload', {
|
||
method: 'POST',
|
||
credentials: 'same-origin',
|
||
cache: 'no-store',
|
||
headers: getCsrfHeaders(), // multipart boundary set automatically; do not set Content-Type
|
||
body: form
|
||
});
|
||
if (!res.ok) throw new Error(`upload failed: ${res.status}`);
|
||
}
|
||
|
||
/**
|
||
* Upload with progress reporting. `fetch` can't surface upload progress, so this
|
||
* uses XHR; CSRF headers are attached the same way as {@link uploadFile}.
|
||
* `onProgress` receives a fraction in [0, 1] (or NaN when length is unknown).
|
||
*/
|
||
export function uploadFileWithProgress(
|
||
folderId: string | null,
|
||
file: File,
|
||
onProgress: (fraction: number) => void
|
||
): Promise<void> {
|
||
return new Promise((resolve, reject) => {
|
||
const form = new FormData();
|
||
if (folderId) form.append('folder_id', folderId);
|
||
form.append('file', file);
|
||
const xhr = new XMLHttpRequest();
|
||
xhr.open('POST', '/api/files/upload');
|
||
xhr.withCredentials = true;
|
||
for (const [k, v] of Object.entries(getCsrfHeaders())) xhr.setRequestHeader(k, v);
|
||
|
||
// Self-aborting watchdog so a stalled connection can never pin an upload
|
||
// slot forever (and leave a zombie XHR holding one of the browser's few
|
||
// per-host connections). While the body is uploading we reset the deadline
|
||
// on every progress tick — a slow but *moving* transfer is fine; once the
|
||
// body is fully sent we give the server a fixed window to respond. On a
|
||
// stall we `xhr.abort()`, which frees the connection immediately.
|
||
const SEND_STALL_MS = 30_000;
|
||
const RESPONSE_MS = 60_000;
|
||
let watchdog: ReturnType<typeof setTimeout>;
|
||
const arm = (ms: number) => {
|
||
clearTimeout(watchdog);
|
||
watchdog = setTimeout(() => xhr.abort(), ms);
|
||
};
|
||
|
||
xhr.upload.onprogress = (e) => {
|
||
onProgress(e.lengthComputable ? e.loaded / e.total : NaN);
|
||
arm(SEND_STALL_MS);
|
||
};
|
||
xhr.upload.onload = () => arm(RESPONSE_MS); // body sent — wait for the server
|
||
xhr.onload = () => {
|
||
clearTimeout(watchdog);
|
||
if (xhr.status >= 200 && xhr.status < 300) resolve();
|
||
else {
|
||
// Flag quota so a batch can stop early instead of retrying every file.
|
||
const err = new Error(`upload failed: ${xhr.status}`) as Error & { isQuota?: boolean };
|
||
err.isQuota = xhr.status === 507;
|
||
reject(err);
|
||
}
|
||
};
|
||
xhr.onerror = () => {
|
||
clearTimeout(watchdog);
|
||
reject(new Error('upload failed: network error'));
|
||
};
|
||
xhr.onabort = () => {
|
||
clearTimeout(watchdog);
|
||
reject(new Error('upload stalled — aborted'));
|
||
};
|
||
arm(SEND_STALL_MS);
|
||
xhr.send(form);
|
||
});
|
||
}
|
||
|
||
export async function renameFile(fileId: string, name: string): Promise<void> {
|
||
const res = await apiFetch(`/api/files/${fileId}/rename`, {
|
||
method: 'PUT',
|
||
credentials: 'same-origin',
|
||
headers: { ...JSON_HEADERS, ...getCsrfHeaders() },
|
||
body: JSON.stringify({ name })
|
||
});
|
||
if (!res.ok) throw new Error(`rename file failed: ${res.status}`);
|
||
}
|
||
|
||
export async function moveFile(fileId: string, targetFolderId: string | null): Promise<void> {
|
||
const res = await apiFetch(`/api/files/${fileId}/move`, {
|
||
method: 'PUT',
|
||
credentials: 'same-origin',
|
||
headers: { ...JSON_HEADERS, ...getCsrfHeaders() },
|
||
body: JSON.stringify({ folder_id: targetFolderId || null })
|
||
});
|
||
if (!res.ok) throw new Error(`move file failed: ${res.status}`);
|
||
}
|
||
|
||
export async function deleteFile(fileId: string): Promise<void> {
|
||
const res = await apiFetch(`/api/files/${fileId}`, {
|
||
method: 'DELETE',
|
||
credentials: 'same-origin',
|
||
headers: getCsrfHeaders()
|
||
});
|
||
if (!res.ok) throw new Error(`delete file failed: ${res.status}`);
|
||
}
|
||
|
||
export function fileDownloadUrl(fileId: string): string {
|
||
return `/api/files/${fileId}`;
|
||
}
|
||
|
||
export function fileInlineUrl(fileId: string): string {
|
||
return `/api/files/${fileId}?inline=true`;
|
||
}
|
||
|
||
/** Thumbnail URL for a file at the given size (server-rendered, content-typed). */
|
||
export function fileThumbnailUrl(
|
||
fileId: string,
|
||
size: 'icon' | 'preview' | 'large' = 'preview'
|
||
): string {
|
||
return `/api/files/${fileId}/thumbnail/${size}`;
|
||
}
|
||
|
||
/**
|
||
* Thumbnail size matched to the rendering slot. List rows draw thumbnails in
|
||
* a 40×40 box, so the 150px `icon` rendition is already ≥2× retina density —
|
||
* fetching the 400px `preview` there moved ~7× more pixels than the slot can
|
||
* show (benches/ROUND12.md §F1). Grid cards (100×70 slot) keep `preview`.
|
||
*/
|
||
export function thumbSizeForView(view: 'grid' | 'list'): 'icon' | 'preview' {
|
||
return view === 'list' ? 'icon' : 'preview';
|
||
}
|