fdf445d2b0
Benchmark-gated round (benches/ROUND9.md): every change carries a BEFORE/AFTER bench with equivalence/safety gates; verdicts below are from the committed harnesses on 4 cores / local PG 16. Backend: - Blob decorators (Retry/Cached) now forward put_blob_from_bytes_unsynced + sync_blobs — the trait default had silently reinstated HEAD-before-PUT per chunk on decorated remote stacks, undoing ROUND3 §8. Full production stack: 500 probes -> 0, 1.9x wall at 10 ms RTT (bench_s3_put §3). - NC PROPFIND per-page enrichment triple (favorites / oc:fileid / dead props) overlapped with tokio::join!: 2.07x local, 2.86x at 5 ms RTT (bench_nc_enrich_join, injected-latency decide-by-bench). - Search enrichment consumes its DTOs and carries the interned Arc<str> display fields end-to-end (SearchFileResultDto type change, OpenAPI shape preserved): enrich_file 2.0x, 11.6 -> 2.2 allocs/row; the NC REPORT conversion stops re-running all three classifiers per row (bench_search_enrich). - NC session Arc end-to-end: SharedNcSession extractor (8 -> 0 allocs), Arc<FolderDto> chroot cache (4 -> 0/hit), single shared Arc<CurrentUser> + lazy span render (11 -> 6/build) (bench_nc_session). - Storage micro-pack: atomic create_new chunk writes (2.1x fresh), stream_chunks over the manifest Arc (4097 -> 0 allocs/read incl. the Range path), manifest single-flight (herd 64 -> 1 loads), hex_lower for chunk Content-MD5 (18 -> 1 allocs) (bench_storage_micro). - OCS capabilities memoized into OnceLock<[Bytes;2]>: 237x, 102 -> 0 allocs/poll, byte-identical (bench_capabilities_static). - Drive::is_empty COUNT(*) sum -> EXISTS: 34.4x on a 100k-file drive (bench_drive_is_empty). - favorites/recents row-map ROUND7 port: path/name/blob_hash moved, -2.75 allocs/row (bench_resource_row_map §2). - Folder rows decode binary UUIDs (ROUND6 §10 port): 1.03-1.07x page fetch, honest verdict incl. one noise-band wash documented (bench_folder_uuid_decode). - Authz: file cascade decision decomposed into memoized folder-level decision + direct-grant lookup (ROUND8 deferred item). Cold shared-album first view 592 -> 418 µs/thumb; warm path unchanged; safety gates incl. new direct-grant sibling isolation, revoke-flush re-verified, full integration authz suite green (bench_thumbnail_cascade_cache). Frontend (vitest gates committed beside the code): - resolveLabel/resolveRecipient O(directory) scan -> id-keyed Map: 13.9x (recipients.bench.test.ts). - ResourceList selection-prune effect skips when nothing is selected (100 -> 0 Set builds per drain) and the photos timeline reads a listener-fed mobile flag instead of matchMedia per recompute (listDerives.bench.test.ts). Verification: cargo fmt + clippy --all-features --all-targets -D warnings clean; 524 unit + 554 integration (--cfg integration_tests) tests pass; frontend npm run check clean with 293 vitest tests green. Deferred with rationale in ROUND9.md: CalDAV authz-before-fetch reorder (maintainer sign-off), per-page batched parent resolution, JWT-claims Arc<str>, batch_operations signature widening. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XDc9VtXvskJ6dnMRraSndn
210 lines
7.3 KiB
TypeScript
210 lines
7.3 KiB
TypeScript
/**
|
||
* Recipient search for the share People tab — system users (via the system
|
||
* address book) + groups (via /api/groups/search) + a synthesized "invite by
|
||
* email" suggestion when the query parses as an email. Ported from the original
|
||
* shareModal recipient autocomplete (addressBook.searchContacts + _searchGroups
|
||
* + _looksLikeEmail).
|
||
*/
|
||
import { apiFetch } from '$lib/api/client';
|
||
import { session } from '$lib/stores/session.svelte';
|
||
import type { SubjectType } from './grants';
|
||
|
||
export interface Recipient {
|
||
type: Extract<SubjectType, 'user' | 'group' | 'email'>;
|
||
/** For email recipients this is the normalised email; for users/groups, the UUID. */
|
||
id: string;
|
||
label: string;
|
||
sublabel?: string;
|
||
}
|
||
|
||
interface Contact {
|
||
id: string;
|
||
first_name?: string;
|
||
last_name?: string;
|
||
full_name?: string;
|
||
email?: Array<{ email: string; is_primary?: boolean }>;
|
||
}
|
||
|
||
interface GroupResult {
|
||
id: string;
|
||
name: string;
|
||
}
|
||
|
||
/**
|
||
* Permissive client-side email check — matches a non-whitespace local part, an
|
||
* `@`, and a domain with a dot. The server's `normalize_email` is authoritative;
|
||
* this just decides whether to surface the synthetic invite-by-email row.
|
||
*/
|
||
function looksLikeEmail(q: string): boolean {
|
||
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(q);
|
||
}
|
||
|
||
// The system book lists all users; we filter client-side (matches the original).
|
||
// Two caches because the backend response differs (default excludes the caller,
|
||
// `?include_self=1` returns them). Keying by flag avoids one variant overwriting
|
||
// the other.
|
||
let contactCache: Contact[] | null = null;
|
||
let contactCacheWithSelf: Contact[] | null = null;
|
||
/** `false` once we confirm the system address book is unavailable. */
|
||
let directoryAvailable: boolean | null = null;
|
||
|
||
async function systemContacts(includeSelf = false): Promise<Contact[]> {
|
||
const cached = includeSelf ? contactCacheWithSelf : contactCache;
|
||
if (cached) return cached;
|
||
try {
|
||
// `?include_self=true` (not `=1`) — Axum's `Query` extractor uses
|
||
// `serde_urlencoded`, which only deserialises `"true"`/`"false"`
|
||
// for `bool`. Sending `=1` would 400 before the handler runs.
|
||
const url = includeSelf
|
||
? '/api/address-books/system/contacts?include_self=true'
|
||
: '/api/address-books/system/contacts';
|
||
const res = await apiFetch(url, { credentials: 'same-origin' });
|
||
if (!res.ok) {
|
||
directoryAvailable = false;
|
||
if (includeSelf) {
|
||
contactCacheWithSelf = [];
|
||
return contactCacheWithSelf;
|
||
}
|
||
contactCache = [];
|
||
return contactCache;
|
||
}
|
||
directoryAvailable = true;
|
||
const list = (await res.json()) as Contact[];
|
||
if (includeSelf) contactCacheWithSelf = list;
|
||
else contactCache = list;
|
||
return list;
|
||
} catch {
|
||
directoryAvailable = false;
|
||
if (includeSelf) {
|
||
contactCacheWithSelf = [];
|
||
return contactCacheWithSelf;
|
||
}
|
||
contactCache = [];
|
||
return contactCache;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Whether the system user directory is reachable. Returns `true` until proven
|
||
* otherwise so callers degrade gracefully; call `ensureResolvers()` first to
|
||
* get an accurate answer.
|
||
*/
|
||
export function isDirectoryAvailable(): boolean {
|
||
return directoryAvailable !== false;
|
||
}
|
||
|
||
function contactLabel(c: Contact): { label: string; email: string } {
|
||
const name = [c.first_name, c.last_name].filter(Boolean).join(' ') || c.full_name || '';
|
||
const email = c.email?.find((e) => e.is_primary)?.email ?? c.email?.[0]?.email ?? '';
|
||
return { label: name || email || c.id, email };
|
||
}
|
||
|
||
async function searchGroups(q: string): Promise<Recipient[]> {
|
||
try {
|
||
const res = await apiFetch(`/api/groups/search?q=${encodeURIComponent(q)}&limit=8`, {
|
||
credentials: 'same-origin'
|
||
});
|
||
if (!res.ok) return [];
|
||
const groups = (await res.json()) as GroupResult[];
|
||
return groups.map((g) => ({ type: 'group' as const, id: g.id, label: g.name }));
|
||
} catch {
|
||
return [];
|
||
}
|
||
}
|
||
|
||
// ── Label resolution for existing grants (subject id → display name) ────────
|
||
let groupCache: Map<string, string> | null = null;
|
||
|
||
async function loadGroups(): Promise<Map<string, string>> {
|
||
if (groupCache) return groupCache;
|
||
groupCache = new Map();
|
||
try {
|
||
const res = await apiFetch('/api/groups/search?q=&limit=200', { credentials: 'same-origin' });
|
||
if (res.ok) {
|
||
for (const g of (await res.json()) as GroupResult[]) groupCache.set(g.id, g.name);
|
||
}
|
||
} catch {
|
||
/* leave empty */
|
||
}
|
||
return groupCache;
|
||
}
|
||
|
||
/** Preload the user + group caches so grant rows can show names. */
|
||
export async function ensureResolvers(): Promise<void> {
|
||
await Promise.all([systemContacts(), loadGroups()]);
|
||
}
|
||
|
||
// O(1) id→contact index over `contactCache`, built once per cache identity.
|
||
// `resolveLabel`/`resolveRecipient` run per rendered grant row on /shared —
|
||
// the previous `contactCache.find(...)` linear scan made each render frame
|
||
// O(rows × directory size).
|
||
let contactById: Map<string, Contact> | null = null;
|
||
let contactByIdSource: Contact[] | null = null;
|
||
|
||
function contactIndex(): Map<string, Contact> | null {
|
||
if (!contactCache) return null;
|
||
if (!contactById || contactByIdSource !== contactCache) {
|
||
contactById = new Map(contactCache.map((c) => [c.id, c]));
|
||
contactByIdSource = contactCache;
|
||
}
|
||
return contactById;
|
||
}
|
||
|
||
/** Resolve a subject id to a display label using the preloaded caches. */
|
||
export function resolveLabel(type: 'user' | 'group', id: string): string {
|
||
if (type === 'group') return groupCache?.get(id) ?? id;
|
||
const c = contactIndex()?.get(id);
|
||
return c ? contactLabel(c).label : id;
|
||
}
|
||
|
||
/** Resolve a subject id to a label + sublabel (email) for member vignettes. */
|
||
export function resolveRecipient(type: 'user' | 'group', id: string): Recipient {
|
||
if (type === 'group') {
|
||
return { type: 'group', id, label: groupCache?.get(id) ?? id };
|
||
}
|
||
const c = contactIndex()?.get(id);
|
||
if (!c) return { type: 'user', id, label: id };
|
||
const { label, email } = contactLabel(c);
|
||
return { type: 'user', id, label, sublabel: email };
|
||
}
|
||
|
||
/**
|
||
* Combined user + group results matching the query (case-insensitive), plus a
|
||
* synthetic invite-by-email suggestion when the query is an email that no
|
||
* contact already owns. Capped at 8 combined (groups, then users, then email).
|
||
*
|
||
* `includeSelf` defaults to `false` — the share modal excludes the current
|
||
* caller from the picker because "you can't share with yourself". The admin
|
||
* drive-owners surface flips it on: an admin legitimately needs to add
|
||
* themselves (or anyone) as Owner without that personal-share restriction.
|
||
*/
|
||
export async function searchRecipients(
|
||
query: string,
|
||
{ includeSelf = false }: { includeSelf?: boolean } = {}
|
||
): Promise<Recipient[]> {
|
||
const q = query.toLowerCase().trim();
|
||
if (!q) return [];
|
||
const currentUserId = session.user?.id ?? null;
|
||
const [contacts, groups] = await Promise.all([systemContacts(includeSelf), searchGroups(q)]);
|
||
const matched = contacts
|
||
.filter((c) => includeSelf || c.id !== currentUserId)
|
||
.map((c) => ({ c, ...contactLabel(c) }))
|
||
.filter(
|
||
({ label, email }) => label.toLowerCase().includes(q) || email.toLowerCase().includes(q)
|
||
);
|
||
const users: Recipient[] = matched.map(({ c, label, email }) => ({
|
||
type: 'user' as const,
|
||
id: c.id,
|
||
label,
|
||
sublabel: email
|
||
}));
|
||
|
||
const emailItems: Recipient[] = [];
|
||
if (looksLikeEmail(q)) {
|
||
const exists = matched.some(({ email }) => email.toLowerCase() === q);
|
||
if (!exists) emailItems.push({ type: 'email', id: q, label: q });
|
||
}
|
||
|
||
return [...groups, ...users, ...emailItems].slice(0, 8);
|
||
}
|