perf: round 16 — shares-lane & contextMap incremental builders, folder/href/disposition/preview alloc cuts

Finishes the route-level half of the O(N²/page) grouped-listing class ROUND15
fixed inside ResourceList, plus a backend CPU/alloc micro-pack. Every change is
benchmark-gated with a hard rollback rule; no PostgreSQL needed for any arm
(benches/ROUND16.md).

Frontend (vitest):
- F1 "My shares" lanes: the `lanes` $derived.by re-bucketed the whole
  accumulated grant list on every page and every grant edit. SharedLanesBuilder
  re-emits only the fresh page (fan-out + first-appearance header), reusing
  untouched lanes' array refs. 25.5x fewer emit calls, 8.8x wall, O(N²/page)→O(N).
- F2 contextMap (trash/recent/favorites/shared-with-me): each rebuilt a fresh
  N-key Map, re-hashing every accumulated id, per page. primeContextPage holds a
  persistent SvelteMap primed per page (the shipped favoriteIds shape).
  25.5x fewer entry calls, 7.2x wall.
- Extracted the shared O(1) append test (isAppendExtension); F1's gate re-covers it.

Backend (counting-allocator):
- M1 folder display constants Arc::from -> intern_display (3 sites): 3 -> 0 allocs/row.
- M2 build_content_disposition (every download + Range seek): 3 -> 1 alloc, 6x, 2.67x wall.
- M3 nc_href (every NC PROPFIND/REPORT href): Vec+join+format -> one pre-sized
  buffer, keeping urlencoding::encode (byte-identical). 38 -> 27 allocs/op.
- M4 NC preview fileId: collect-then-parse -> borrow-slice parse. 4 -> 0 allocs.

Gates: sharedLanes/listContext.bench.test.ts, examples/bench_round16_micro.rs
(GATE PASS all sections). Frontend: vitest 331 pass, svelte-check clean.
Backend: clippy -D warnings clean, 524 lib tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0193NjactJVqfU32gxeJDj8m
This commit is contained in:
Claude
2026-07-19 17:30:22 +00:00
parent 8537b5949a
commit 955f4a7b9f
20 changed files with 1388 additions and 123 deletions
+11
View File
@@ -354,6 +354,17 @@ name = "bench_micro_allocs"
path = "examples/bench_micro_allocs.rs"
required-features = ["bench"]
# Round-16 battery ────────────────────────────────────────────────────────────
# Round-16 CPU/alloc micro-pack — folder display constants `Arc::from` → interned
# clone (3 sites), `build_content_disposition` 3→1 alloc (every download + Range
# seek), `nc_href` Vec+join → single pre-sized buffer (every NC PROPFIND/REPORT
# href), NC preview `fileId` collect-then-parse → borrowed-slice parse. No Postgres.
[[example]]
name = "bench_round16_micro"
path = "examples/bench_round16_micro.rs"
required-features = ["bench"]
# Round-15 battery ────────────────────────────────────────────────────────────
# Round-15 CPU/alloc micro-pack — exif Make/Model in-place trim (drop the
+178
View File
@@ -0,0 +1,178 @@
# Round 16 — shares-lane & contextMap incremental builders, folder/href/disposition/preview alloc cuts
Benchmark-gated, same rule as ROUND2–15: every change ships with a
BEFORE/AFTER benchmark and an equivalence/safety gate; an AFTER that doesn't
beat its BEFORE is rolled back (never applied). The roll-back rule is encoded
directly into each harness as a `GATE FAIL … rollback` non-zero exit (Rust) or
a threshold `expect()` (frontend), so a regression fails CI rather than
shipping.
This round finishes the **route-level half** of the O(N²/page) grouped-listing
class ROUND15 §F1 fixed *inside* `ResourceList` — the two remaining producers
that feed it (the "My shares" `lanes` tree and the trash/recent/favorites/
shared-with-me `contextMap`) — and lands a backend CPU/alloc micro-pack of four
per-request allocation cuts surfaced by a fresh hot-path audit.
Measured on 4 cores / 15 GiB, **no PostgreSQL needed for any Round-16 arm**
(frontend: Node 22 / vitest; backend: release counting-allocator examples).
Reproduce any row with the command in its section.
## Summary
| # | change | key metric | before → after |
|--:|---|---|---|
| F1 | "My shares" (`shared/+page.svelte`) `lanes` `$derived.by` re-bucketed the WHOLE accumulated grant list on every infinite-scroll page (and every grant edit); `SharedLanesBuilder` re-emits only the fresh page and reuses each untouched lane's array reference | 50×50 (2 500-item) drain | **63 750 → 2 500 `emit` calls (25.5×)** · **8.8× wall** · O(N²/page) → O(N) |
| F2 | trash / recent / favorites / shared-with-me rebuilt a fresh `Map` (hashing every accumulated id) as `contextMap = $derived(new Map(raw.map(…)))` every page; `primeContextPage` holds one persistent `SvelteMap` and sets only the fresh page's entries (mirrors `favoriteIds`, ROUND14 §F2) | 50×50 drain, 4 routes | **63 750 → 2 500 `entry` calls (25.5×)** · **7.2× wall** · O(N²/page) → O(N) |
| M1 | folder display constants — the trash-listing / NC-search-REPORT / path-resolver folder branch built `Arc::<str>::from("fas fa-folder")` (+ 2 more): 3 heap allocs/row where the sibling file branch already used the interned `Arc` clone | 3 fields/folder row | **3.00 → 0.00 allocs/op**, 1.14× wall |
| M2 | `build_content_disposition` — every download + Range seek built an `encoded` String, an `ascii_safe` String, and the `format!` result (3 allocs); fast-path all-attr-char names + single in-place buffer do it in 1 | 5 names/op | **30.00 → 5.00 allocs/op (6×)**, 2.67× wall |
| M3 | `nc_href` — every NC PROPFIND/REPORT href allocated a per-segment `Vec<Cow>`, a joined String and the `format!`; one pre-sized buffer keeps `urlencoding::encode` (identical bytes) | 5 hrefs/op | **38.00 → 27.00 allocs/op**, 1.44× wall |
| M4 | NC preview `fileId` — the handler `collect()`ed the digit prefix into a String only to reparse it to `i64`; parse the borrowed prefix slice instead | 5 ids/op | **4.00 → 0.00 allocs/op**, 2.51× wall |
## [F1] "My shares" — incremental lanes builder
```
cd frontend && npx vitest run src/lib/utils/sharedLanes.bench.test.ts
```
The shares page pages its outgoing grants in via infinite scroll
(`raw = [...raw, ...page.items]`), and the `lanes` `$derived.by` re-bucketed the
whole accumulated (kind-filtered) list on every page — allocating a fresh lane
object and a fresh `rows` array for *every* lane each time — Σ ≈ O(N²/page)
`emit` calls across a drain. It also re-fired on every grant edit (role/expiry/
password), each of which reassigns `raw`, re-bucketing the entire list for a
one-row change.
`SharedLanesBuilder` (extracted to `$lib/utils/sharedLanes`, off the Svelte
reactive graph so it's unit/benchmark-testable) is the F1-flagship pattern
generalized for the lanes shape, which differs from `ResourceList`'s sections
in two ways: **fan-out** (one grant item contributes rows to *many* lanes in the
"shared with" group-by) and a **header captured at first appearance** (vs a
label recomputed each sync). On an append it re-emits only the fresh page and
hands back the same `rows` array reference for every untouched lane, emitting a
fresh array only for lanes the page actually grew. Any non-append (group-by
switch, grant edit, kind-filter toggle) falls back to a full rebuild, so the
output is always deep-equal to the pure `buildLanes` reference — including the
non-contiguous "shared with" group-by, where a page sprays rows across
already-emitted subject lanes (the same non-monotonic case F1 handled for trash
grouped by drive). The O(1) append test is shared with `resourceSections` via
`isAppendExtension` (extracted this round, re-validated by F1's own gate).
50×50 (2 500-item) drain: **63 750 → 2 500 `emit` calls (25.5× fewer), 8.8×
wall**. Gates: (1) equivalence — deep-equal to `buildLanes` at *every* page for
both the by-files (contiguous) and by-subject (non-contiguous fan-out)
group-bys; (2) reference stability — untouched lanes keep their exact array
reference across an append while a grown lane gets a fresh one; (3) correct
fallback on group-by switch, grant edit and kind-filter toggle; (4) perf — the
deterministic O(N) `emit`-call count, plus a best-of-3 wall ≥3×.
## [F2] Grouped routes — incremental `contextMap`
```
cd frontend && npx vitest run src/lib/utils/listContext.bench.test.ts
```
`/trash`, `/recent`, `/favorites` and `/shared-with-me` each fed `ResourceList`
a per-item `contextMap` (`id → ItemContext`, carrying the envelope's date /
owner / drive fields the group-by and row render read) built as
`$derived(new Map(raw.map((it) => [id, ctx])))` — a brand-new Map re-hashing
every accumulated id on **every** infinite-scroll page. O(N) per page ⇒ Σ
O(N²/page) across a drain, and a fresh instance each page invalidated every
reader. ROUND15 §F1 fixed the `sections` half *inside* `ResourceList`; this is
the route-level projection that feeds it, flagged on ROUND14's deferred list and
never landed.
`primeContextPage` (`$lib/utils/listContext`) applies the shipped `favoriteIds`
shape (ROUND14 §F2, a persistent `SvelteSet` primed per page): each route holds
one persistent `SvelteMap` for the component's lifetime and, in `load()`, clears
it on a reset and sets only the freshly-fetched page's entries — O(page) per
page, O(N) across the drain, one stable instance. The map only ever needs to be
a superset of the displayed ids (rows removed by a delete aren't rendered, so
their stale entries are never read), and every id entering `raw` comes through a
`load()` page, so the map always covers what's on screen. `shared-with-me`
passes a drive-skipping entry (drives never reach the row UI), so its map
matches the displayed `fileFolderGrants` exactly.
50×50 drain: **63 750 → 2 500 `entry` calls (25.5× fewer), 7.2× wall**. Gates:
(1) equivalence — the primed map is deep-equal to a full `new Map(cumulative.map(…))`
rebuild at every page, including skipped drives and the reset path; (2) perf —
the deterministic O(N) entry-call count, plus a best-of-3 wall ≥3×.
## [M1]–[M4] Backend CPU/alloc micro-pack
```
cargo run --release --features bench --example bench_round16_micro
```
Counting-allocator micro-bench; each section is BEFORE (verbatim replica of the
shipped-before shape) vs AFTER (the shipped function itself where reachable —
`intern_display`, `nc_href` — else a verbatim replica of the shipped-after
shape), with a byte/-value equivalence gate and a `GATE FAIL … rollback` exit.
- **[M1] Folder display constants → interned clone.** The trash-listing
(`trash_service.rs`), NC-search-REPORT (`report_handler.rs`) and path-resolver
(`path_resolver_service.rs`) folder branches each built
`Arc::<str>::from("fas fa-folder")` + `"folder-icon"` + `"Folder"` — three
heap allocations + memcpys per folder row — although all three literals are in
the `DISPLAY_INTERN` closed set and the **file branch of the very same
function** already used `intern_display` (a lookup + refcount bump, 0 allocs).
ROUND11 interned the file classifiers on these paths but missed the folder
constants. Per trashed / searched / resolved folder row: **3.00 → 0.00
allocs/op, 1.14× wall**.
- **[M2] `build_content_disposition` 3 → 1 alloc.** Called on every download and
every Range seek (media/PDF scrubbing pays it per seek), it built a
percent-`encoded` String, an `ascii_safe` filtered String, and the `format!`
result — 3 allocations. The shipped code fast-paths an all-attr-char name
(`filename` and `filename*` are the name verbatim → one `format!`) and, for
names needing encoding, writes the ASCII fallback and percent-encoded form
into a single pre-sized buffer. Byte-identical across ASCII / spaced / unicode
/ quote+backslash names: **30.00 → 5.00 allocs/op (6×), 2.67× wall** (5
names/op, a fast/slow mix).
- **[M3] `nc_href` Vec+join → single buffer.** Every NC PROPFIND/REPORT href
allocated a per-segment `Vec<Cow>`, a joined String and the `format!` result;
the native WebDAV side already fixed this exact shape (`encode_uri_path`). The
shipped code writes the prefix, user and each encoded segment straight into one
pre-sized buffer, keeping `urlencoding::encode` so the emitted bytes are
unchanged (incl. root trailing slash and internal `//`): **38.00 → 27.00
allocs/op, 1.44× wall** (5 hrefs/op — the Vec + join + format drop; the
per-segment encode Cows, unavoidable, remain).
- **[M4] NC preview `fileId` borrow-slice parse.** The preview handler
`collect()`ed the leading digit run into a String only to reparse it to `i64`;
the shipped code finds the digit-prefix length and parses the borrowed slice —
0 allocations. Per NC thumbnail request (a gallery fires one per tile): **4.00
→ 0.00 allocs/op, 2.51× wall** (5 ids/op).
## Not shipped — deferred to a later round
Surfaced by the Round-16 audit but not landed (each wants its own decision,
Postgres fixture, or a larger change):
- **Backend query-shape (needs Postgres):** carried forward from ROUND15 —
`music_storage_adapter::list_public_playlists` 1 + N `COUNT(*)` fold; contact
REST listings over-fetching the multi-KB `vcard` TEXT (wants a *lite* row
mapper).
- **Backend CPU/alloc (no Postgres, next micro-pack):** the two WebDAV PROPFIND
surfaces still quote `d:getetag` into a fresh String per row and `format!` the
per-row href per child (the CalDAV §A6 reused-buffer treatment never reached
them); `delta_upload_service` → `hash_chunk_sequence` clones every chunk hash a
second time (`.iter().cloned()` on an already-owned Vec — change the signature
to take it by value); `contact_to_vcard` seeds from a 27-byte String and
`.to_uppercase()`-allocates each TYPE token.
- **Frontend (vitest-benchmarkable):** `VirtualRows.offsets` prefix-sum is
rebuilt in full on every photos-timeline page (residual O(N²) on the hottest
scroll surface — an incremental extend needs care to keep the downstream
`$derived` reference-invalidation correct); the dotfile filter and
`ResourceList.itemIndexById` re-scan the whole accumulated list per page (both
conditional — hide-dotfiles on / an active selection — hence lower priority).
## Environment / methodology
- `cd frontend && npx vitest run src/lib/utils/sharedLanes.bench.test.ts`
and `… listContext.bench.test.ts` — Node 22 / vitest, no Postgres. Wall gates
take the best-of-3 (min) per arm to shrug off scheduler/GC noise under a
saturated runner (round14 §F1 pattern); the deterministic O(N) call-count is
the primary rollback gate.
- `cargo run --release --features bench --example bench_round16_micro`
— counting allocator, no Postgres (`BENCH_ITERS`).
- Roll-back rule encoded per harness: the Rust example `std::process::exit(1)`
with `GATE FAIL … rollback` if an AFTER arm fails to reduce allocations; the
vitest gates `expect()` the O(N) call count and the ≥3× wall.
+389
View File
@@ -0,0 +1,389 @@
//! Round-16 CPU/alloc micro-pack (no Postgres).
//!
//! Each section is BEFORE (verbatim replica of the shipped-before shape) vs
//! AFTER (the shipped function itself where it is reachable, else a verbatim
//! replica of the shipped-after shape), with a byte/-value equivalence gate and
//! a `GATE FAIL … rollback` check that exits non-zero if the AFTER arm fails to
//! reduce allocations — the round's roll-back rule encoded into the benchmark.
//!
//! [M1] Folder display constants — the trash-listing / NC-search-REPORT /
//! path-resolver folder branch built `Arc::<str>::from("fas fa-folder")`
//! (+ "folder-icon" + "Folder"): 3 heap allocs/row. All three are in the
//! `DISPLAY_INTERN` closed set, so `intern_display` returns an `Arc`
//! clone (refcount bump, 0 allocs) — the sibling file branch already did.
//! [M2] `build_content_disposition` — every download and every Range seek
//! built an `encoded` String, an `ascii_safe` String, and the `format!`
//! result: 3 allocs. The shipped fast path (all-attr-char name) and the
//! single in-place buffer (slow path) do it in 1.
//! [M3] `nc_href` — every NC PROPFIND/REPORT href allocated a per-segment
//! `Vec<Cow>`, a joined String and the `format!` result. The shipped
//! single pre-sized buffer keeps `urlencoding::encode` (identical bytes)
//! and drops the Vec + join + format.
//! [M4] NC preview `fileId` — the handler `collect()`ed the digit prefix into
//! a String only to reparse it to `i64`. The shipped code parses the
//! borrowed digit-prefix slice — 0 allocs.
//!
//! Run:
//! cargo run --release --features bench --example bench_round16_micro
//! Tunables (env): BENCH_ITERS (200000)
use std::alloc::{GlobalAlloc, Layout, System};
use std::env;
use std::hint::black_box;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Instant;
use oxicloud::application::dtos::display_helpers::intern_display;
use oxicloud::interfaces::nextcloud::webdav_handler::nc_href;
use percent_encoding::{AsciiSet, NON_ALPHANUMERIC, utf8_percent_encode};
static ALLOC_CALLS: AtomicU64 = AtomicU64::new(0);
struct CountingAlloc;
unsafe impl GlobalAlloc for CountingAlloc {
unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
ALLOC_CALLS.fetch_add(1, Ordering::Relaxed);
unsafe { System.alloc(layout) }
}
unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
unsafe { System.dealloc(ptr, layout) }
}
unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, new_size: usize) -> *mut u8 {
ALLOC_CALLS.fetch_add(1, Ordering::Relaxed);
unsafe { System.realloc(ptr, layout, new_size) }
}
unsafe fn alloc_zeroed(&self, layout: Layout) -> *mut u8 {
ALLOC_CALLS.fetch_add(1, Ordering::Relaxed);
unsafe { System.alloc_zeroed(layout) }
}
}
#[global_allocator]
static GLOBAL: CountingAlloc = CountingAlloc;
fn env_or<T: std::str::FromStr>(key: &str, default: T) -> T {
env::var(key)
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(default)
}
struct Measured {
wall_ns_per_op: f64,
allocs_per_op: f64,
}
fn measure<F: FnMut()>(iters: usize, mut f: F) -> Measured {
let a0 = ALLOC_CALLS.load(Ordering::Relaxed);
let t = Instant::now();
for _ in 0..iters {
f();
}
let wall = t.elapsed().as_nanos() as f64 / iters as f64;
let allocs = (ALLOC_CALLS.load(Ordering::Relaxed) - a0) as f64 / iters as f64;
Measured {
wall_ns_per_op: wall,
allocs_per_op: allocs,
}
}
fn print_row(label: &str, m: &Measured) {
println!(
"| {:<44} | {:>12.1} | {:>10.2} |",
label, m.wall_ns_per_op, m.allocs_per_op
);
}
fn header_footer(name: &str, before: &Measured, after: &Measured) {
println!("| arm | ns/op | allocs/op |");
print_row(&format!("BEFORE {name}"), before);
print_row(&format!("AFTER {name}"), after);
println!(
"# {:.2}x wall, {:.2} fewer allocs/op",
before.wall_ns_per_op / after.wall_ns_per_op,
before.allocs_per_op - after.allocs_per_op
);
}
fn gate_allocs(tag: &str, before: &Measured, after: &Measured) {
if after.allocs_per_op >= before.allocs_per_op {
eprintln!("GATE FAIL [{tag}]: AFTER did not reduce allocations — rollback");
std::process::exit(1);
}
}
// ────────────────────────────────────────────────────────────────────────────
// [M1] Folder display constants — 3 `Arc::from` allocs vs 0 (interned clone)
// ────────────────────────────────────────────────────────────────────────────
fn section_intern() {
let iters: usize = env_or("BENCH_ITERS", 200_000);
const CONSTS: [&str; 3] = ["fas fa-folder", "folder-icon", "Folder"];
// Gate: the interned Arc carries identical bytes to `Arc::from`.
for s in CONSTS {
assert_eq!(
&*intern_display(s),
&*Arc::<str>::from(s),
"intern differs for {s:?}"
);
}
// BEFORE: the folder branch's three `Arc::<str>::from(literal)` — 3 allocs.
let before = measure(iters, || {
for s in CONSTS {
black_box(Arc::<str>::from(black_box(s)));
}
});
// AFTER: the shipped `intern_display` — closed-set lookup + refcount bump.
let after = measure(iters, || {
for s in CONSTS {
black_box(intern_display(black_box(s)));
}
});
println!("\n## [M1] folder display constants (3 fields/row)");
header_footer("folder Arc::from → intern", &before, &after);
gate_allocs("M1", &before, &after);
}
// ────────────────────────────────────────────────────────────────────────────
// [M2] build_content_disposition — 3 allocs vs 1
// ────────────────────────────────────────────────────────────────────────────
const RFC5987_SET: &AsciiSet = &NON_ALPHANUMERIC
.remove(b'!')
.remove(b'#')
.remove(b'$')
.remove(b'&')
.remove(b'+')
.remove(b'-')
.remove(b'.')
.remove(b'^')
.remove(b'_')
.remove(b'`')
.remove(b'|')
.remove(b'~');
fn disposition_of(mime: &str, force_inline: bool) -> &'static str {
if force_inline
|| mime.starts_with("image/")
|| mime == "application/pdf"
|| mime.starts_with("video/")
|| mime.starts_with("audio/")
{
"inline"
} else {
"attachment"
}
}
/// BEFORE: verbatim replica of the shipped-before body — three allocations.
fn cd_before(name: &str, mime: &str, force_inline: bool) -> String {
let disposition = disposition_of(mime, force_inline);
let encoded = utf8_percent_encode(name, RFC5987_SET).to_string();
let ascii_safe: String = name
.chars()
.filter(|c| c.is_ascii_graphic() || *c == ' ')
.map(|c| match c {
'"' | '\\' => '_',
_ => c,
})
.collect();
format!("{disposition}; filename=\"{ascii_safe}\"; filename*=UTF-8''{encoded}")
}
/// AFTER: verbatim replica of the shipped `build_content_disposition`.
fn cd_after(name: &str, mime: &str, force_inline: bool) -> String {
let disposition = disposition_of(mime, force_inline);
let all_attr_char = name.bytes().all(|b| {
b.is_ascii_alphanumeric()
|| matches!(
b,
b'!' | b'#' | b'$' | b'&' | b'+' | b'-' | b'.' | b'^' | b'_' | b'`' | b'|' | b'~'
)
});
if all_attr_char {
return format!("{disposition}; filename=\"{name}\"; filename*=UTF-8''{name}");
}
let mut out = String::with_capacity(disposition.len() + name.len() * 4 + 32);
out.push_str(disposition);
out.push_str("; filename=\"");
for c in name.chars().filter(|c| c.is_ascii_graphic() || *c == ' ') {
out.push(match c {
'"' | '\\' => '_',
_ => c,
});
}
out.push_str("\"; filename*=UTF-8''");
for chunk in utf8_percent_encode(name, RFC5987_SET) {
out.push_str(chunk);
}
out
}
fn section_content_disposition() {
let iters: usize = env_or("BENCH_ITERS", 200_000);
// Fast-path (all-attr-char) and slow-path (space / unicode / quote+backslash)
// names, inline and attachment.
let samples: &[(&str, &str, bool)] = &[
("report.pdf", "application/pdf", false),
("photo.jpg", "image/jpeg", false),
("My Holiday Photo.png", "image/png", false),
("résumé final.docx", "application/octet-stream", false),
("weird\"na\\me.txt", "text/plain", false),
];
// Gate: byte-identical output to the old chain across every shape.
for &(n, m, f) in samples {
assert_eq!(
cd_before(n, m, f),
cd_after(n, m, f),
"content-disposition differs for {n:?}"
);
}
let before = measure(iters, || {
for &(n, m, f) in samples {
black_box(cd_before(black_box(n), m, f));
}
});
let after = measure(iters, || {
for &(n, m, f) in samples {
black_box(cd_after(black_box(n), m, f));
}
});
println!(
"\n## [M2] build_content_disposition ({} names/op)",
samples.len()
);
header_footer("content-disposition", &before, &after);
gate_allocs("M2", &before, &after);
}
// ────────────────────────────────────────────────────────────────────────────
// [M3] nc_href — Vec<Cow> + join + format! vs one pre-sized buffer
// ────────────────────────────────────────────────────────────────────────────
/// BEFORE: verbatim replica of the shipped-before `nc_href`.
fn nc_href_before(username: &str, subpath: &str) -> String {
let subpath = subpath.trim_matches('/');
let encoded_user = urlencoding::encode(username);
if subpath.is_empty() {
format!("/remote.php/dav/files/{}/", encoded_user)
} else {
let encoded_segments: Vec<_> = subpath
.split('/')
.map(|seg| urlencoding::encode(seg))
.collect();
format!(
"/remote.php/dav/files/{}/{}",
encoded_user,
encoded_segments.join("/")
)
}
}
fn section_nc_href() {
let iters: usize = env_or("BENCH_ITERS", 200_000);
let samples: &[(&str, &str)] = &[
("alice", ""),
("alice", "Documents/report.pdf"),
("alice", "Photos/2026/My Holiday.jpg"),
("bob smith", "Résumés/final draft.docx"),
("carol", "a/deeply/nested/folder/tree/file.txt"),
];
// Gate: the shipped `nc_href` is byte-identical to the old shape.
for &(u, sp) in samples {
assert_eq!(
nc_href_before(u, sp),
nc_href(u, sp),
"nc_href differs for {u:?}/{sp:?}"
);
}
let before = measure(iters, || {
for &(u, sp) in samples {
black_box(nc_href_before(black_box(u), black_box(sp)));
}
});
let after = measure(iters, || {
for &(u, sp) in samples {
black_box(nc_href(black_box(u), black_box(sp)));
}
});
println!("\n## [M3] nc_href ({} hrefs/op)", samples.len());
header_footer("nc_href", &before, &after);
gate_allocs("M3", &before, &after);
}
// ────────────────────────────────────────────────────────────────────────────
// [M4] NC preview fileId — collect-into-String-then-parse vs borrow-slice parse
// ────────────────────────────────────────────────────────────────────────────
/// BEFORE: verbatim replica — allocate the digit prefix, then reparse it.
fn parse_before(file_id: &str) -> Result<i64, ()> {
let numeric_part: String = file_id.chars().take_while(|c| c.is_ascii_digit()).collect();
numeric_part.parse().map_err(|_| ())
}
/// AFTER: verbatim replica of the shipped code — parse the borrowed prefix.
fn parse_after(file_id: &str) -> Result<i64, ()> {
let end = file_id
.as_bytes()
.iter()
.position(|b| !b.is_ascii_digit())
.unwrap_or(file_id.len());
file_id[..end].parse().map_err(|_| ())
}
fn section_preview_parse() {
let iters: usize = env_or("BENCH_ITERS", 200_000);
// The NC app appends an instance suffix; plus all-digit, non-digit and empty.
let samples = ["00000326ocnca", "123456789", "42abc", "notanid", ""];
// Gate: identical parse outcome across every shape.
for s in samples {
assert_eq!(
parse_before(s),
parse_after(s),
"preview parse differs for {s:?}"
);
}
let before = measure(iters, || {
for s in samples {
let _ = black_box(parse_before(black_box(s)));
}
});
let after = measure(iters, || {
for s in samples {
let _ = black_box(parse_after(black_box(s)));
}
});
println!(
"\n## [M4] NC preview fileId parse ({} ids/op)",
samples.len()
);
header_footer("preview fileId parse", &before, &after);
gate_allocs("M4", &before, &after);
}
fn main() {
println!("#################################################################");
println!("# Round-16 CPU/alloc micro-pack");
println!("#################################################################");
section_intern();
section_content_disposition();
section_nc_href();
section_preview_parse();
println!("\nGATE PASS (all sections)");
}
+21
View File
@@ -0,0 +1,21 @@
/**
* O(1) append detection shared by the incremental grouped-list builders
* (`resourceSections`'s `ResourceSectionsBuilder`, `sharedLanes`'s
* `SharedLanesBuilder`): true iff `next` is a strict prefix extension of
* `prev` — strictly longer, and sharing prev's boundary element by identity.
*
* Both builders use it to choose between their O(N) incremental `extend` and a
* full rebuild. The accumulated lists they guard are only ever mutated by
* appending a page (infinite scroll: `raw = [...raw, ...page]`) or replaced by
* a filtered copy that preserves element identity — so a matching boundary
* object is a sound witness that only fresh items were appended. Any other
* change (deletion, filter toggle, reorder) fails the boundary check and falls
* back to a rebuild, keeping the output byte-for-byte equal to a full pass.
*/
export function isAppendExtension<T>(prev: readonly T[], next: readonly T[]): boolean {
if (next.length <= prev.length) return false;
// Prefix identity via the boundary object — O(1). If the element that used
// to be last is still at that index, the prefix was untouched and next just
// grew at the tail.
return prev.length === 0 || next[prev.length - 1] === prev[prev.length - 1];
}
@@ -0,0 +1,141 @@
import { describe, expect, it } from 'vitest';
import { primeContextPage } from './listContext';
/**
* Benchmark gate for the incremental route-level `contextMap` maintenance
* (primeContextPage) that replaced `contextMap = $derived(new Map(raw.map(...)))`
* on the trash / recent / favorites / shared-with-me routes.
*
* Audit finding (ROUND16 §F2, the route-level half of the class ROUND15 §F1
* fixed inside ResourceList): each route paged its rows in via
* `raw = [...raw, ...page.items]` and rebuilt a brand-new Map — hashing every
* accumulated id — on EVERY page. O(N) per page ⇒ Σ O(N²/page) across a drain,
* plus a fresh Map instance each page. The fix holds one persistent map and
* sets only the fresh page's entries (mirrors the shipped `favoriteIds`
* SvelteSet, ROUND14 §F2).
*
* Gates (rollback rule: an AFTER that fails to beat its BEFORE fails CI):
* 1. Equivalence — at EVERY page, the incrementally-primed map is deep-equal
* to a full `new Map(cumulative.map(entry))` rebuild, including skipped
* entries (drives → null) and the reset path.
* 2. Perf — `entry` work collapses from Σ O(N²/page) to O(N) across the drain
* (deterministic call count) and wall drops ≥3x.
*/
interface Ctx {
date: string | null;
ownerId: string | null;
}
interface Raw {
resource: { id: string; updated_by: string | null };
resource_type: 'file' | 'folder' | 'drive';
accessed_at: string;
}
const pad = (i: number) => i.toString().padStart(6, '0');
/** Item `i`; every 10th is a `drive` (skipped by the shared-with-me-style entry). */
function raw(i: number): Raw {
return {
resource: { id: `res-${pad(i)}`, updated_by: `user-${i % 8}` },
resource_type: i % 10 === 0 ? 'drive' : i % 3 === 0 ? 'folder' : 'file',
accessed_at: `2026-07-${pad((i % 27) + 1).slice(-2)}`
};
}
/** Maps a raw item to its `[id, ctx]`, skipping drives (returns null) — counts calls. */
function makeEntry(counter?: { n: number }): (it: Raw) => readonly [string, Ctx] | null {
return (it) => {
if (counter) counter.n++;
if (it.resource_type === 'drive') return null;
return [it.resource.id, { date: it.accessed_at, ownerId: it.resource.updated_by }];
};
}
/** Verbatim BEFORE: the old derive — a fresh Map hashing the whole cumulative list. */
function rebuild(
cumulative: Raw[],
entry: (it: Raw) => readonly [string, Ctx] | null
): Map<string, Ctx> {
const m = new Map<string, Ctx>();
for (const it of cumulative) {
const e = entry(it);
if (e !== null) m.set(e[0], e[1]);
}
return m;
}
const PAGE = 50;
const PAGES = 50; // 2 500-item drain
describe('incremental contextMap (benchmark gate)', () => {
it('stays deep-equal to the full rebuild at every page (incl. skipped drives)', () => {
const all = Array.from({ length: PAGE * PAGES }, (_, i) => raw(i));
const entry = makeEntry();
const map = new Map<string, Ctx>();
for (let p = 1; p <= PAGES; p++) {
const page = all.slice((p - 1) * PAGE, p * PAGE);
primeContextPage(map, p === 1, page, entry);
const reference = rebuild(all.slice(0, p * PAGE), entry);
expect(new Map(map), `page ${p}`).toEqual(reference);
}
});
it('clears on reset and re-primes to the reset page only', () => {
const all = Array.from({ length: 200 }, (_, i) => raw(i));
const entry = makeEntry();
const map = new Map<string, Ctx>();
primeContextPage(map, true, all.slice(0, 100), entry);
primeContextPage(map, false, all.slice(100, 150), entry);
// Reset with a disjoint page: prior ids must be gone.
const resetPage = all.slice(150, 200);
primeContextPage(map, true, resetPage, entry);
expect(new Map(map)).toEqual(rebuild(resetPage, entry));
});
it('collapses entry work from Σ O(N²/page) to O(N) and runs ≥3x faster', () => {
const N = PAGE * PAGES;
const all = Array.from({ length: N }, (_, i) => raw(i));
// Deterministic call-count gate (the hard rollback gate): incremental
// computes each item's entry exactly once; the rebuild is quadratic. This
// holds regardless of machine load.
const afterCounter = { n: 0 };
const afterEntry = makeEntry(afterCounter);
const countMap = new Map<string, Ctx>();
for (let p = 1; p <= PAGES; p++) {
primeContextPage(countMap, p === 1, all.slice((p - 1) * PAGE, p * PAGE), afterEntry);
}
const beforeCounter = { n: 0 };
const beforeEntry = makeEntry(beforeCounter);
for (let p = 1; p <= PAGES; p++) rebuild(all.slice(0, p * PAGE), beforeEntry);
expect(afterCounter.n).toBe(N);
expect(beforeCounter.n).toBe((PAGES * (PAGES + 1) * PAGE) / 2);
expect(afterCounter.n).toBeLessThan(beforeCounter.n / 5);
// Wall gate — best-of-3 (min) per arm to shrug off scheduler / GC noise
// under a saturated test runner (mirrors round14 §F1's `Math.min` pattern).
const entry = makeEntry();
const runAfter = () => {
const m = new Map<string, Ctx>();
const t = performance.now();
for (let p = 1; p <= PAGES; p++) {
primeContextPage(m, p === 1, all.slice((p - 1) * PAGE, p * PAGE), entry);
}
return performance.now() - t;
};
const runBefore = () => {
const t = performance.now();
for (let p = 1; p <= PAGES; p++) rebuild(all.slice(0, p * PAGE), entry);
return performance.now() - t;
};
const afterMs = Math.min(runAfter(), runAfter(), runAfter());
const beforeMs = Math.min(runBefore(), runBefore(), runBefore());
console.info(
`contextMap ${PAGES}×${PAGE}: before ${beforeCounter.n} entry calls / ${beforeMs.toFixed(1)} ms — after ${afterCounter.n} calls / ${afterMs.toFixed(1)} ms (${(beforeCounter.n / afterCounter.n).toFixed(1)}x fewer calls, ${(beforeMs / afterMs).toFixed(1)}x wall)`
);
expect(afterMs).toBeLessThan(beforeMs / 3);
});
});
+40
View File
@@ -0,0 +1,40 @@
/**
* Incremental maintenance of a grouped-listing route's per-item `contextMap`
* (the `id → ItemContext` envelope `ResourceList` reads via `ctxOf`).
*
* The trash / recent / favorites / shared-with-me routes page their rows in via
* infinite scroll (`raw = [...raw, ...page.items]`) and each derived its
* contextMap as `new Map(raw.map((it) => [id, ctx]))` — rebuilding a brand-new
* Map, hashing every accumulated id, on EVERY page. O(N) per page ⇒ O(N²)
* across a drain, and a fresh Map instance each page invalidated every reader
* (ROUND15 landed the `sections` half of this class inside `ResourceList` but
* left the route-level projection that feeds it untouched).
*
* {@link primeContextPage} mirrors the shipped `favoriteIds` fix (ROUND14 §F2,
* `SvelteSet` primed per page): the route holds ONE persistent reactive map
* (`SvelteMap`) for the component's lifetime and, in `load()`, clears it on a
* reset and sets only the freshly-fetched page's entries — O(page) per page,
* O(N) across the drain, one stable instance. The map only ever needs to be a
* superset of the currently-displayed ids: rows removed by a delete are no
* longer rendered, so their now-stale entries are never read (identical
* reasoning to `favoriteIds`). Every id entering `raw` comes through a
* `load()` page, so the map always covers what is on screen.
*
* The param is typed `Map` (not `SvelteMap`) so the benchmark can drive the
* exact same update logic against a plain Map, decoupled from Svelte
* reactivity — the same way `round14.bench.test.ts` benches the `favoriteIds`
* set. Callers pass their `SvelteMap` at runtime.
*/
export function primeContextPage<Raw, C>(
map: Map<string, C>,
reset: boolean,
page: Iterable<Raw>,
/** Map one fetched item to its `[id, ctx]` entry, or `null` to skip it (e.g. drives). */
entry: (item: Raw) => readonly [string, C] | null
): void {
if (reset) map.clear();
for (const item of page) {
const e = entry(item);
if (e !== null) map.set(e[0], e[1]);
}
}
+3 -10
View File
@@ -27,6 +27,8 @@
* gate asserts the incremental builder stays deep-equal to it at every page.
*/
import { isAppendExtension } from './appendExtension';
/** One swimlane: a bucket key, its (possibly async-resolved) header label, and its rows. */
export interface ResourceSection<T> {
key: string;
@@ -107,15 +109,6 @@ export class ResourceSectionsBuilder<T, C> {
/** False until a grouped sync has populated the accumulation state. */
#grouped = false;
/** Whether `next` extends `prev` (same prefix objects + strictly longer). */
#isAppend(prev: T[], next: T[]): boolean {
if (next.length <= prev.length) return false;
// Prefix identity via the boundary object — O(1); the list is only ever
// mutated by appending a page or by replacing it with a filtered copy
// (which preserves element identity).
return prev.length === 0 || next[prev.length - 1] === prev[prev.length - 1];
}
#rebuild(items: T[], grouping: SectionGrouping<T, C>): void {
const bucketOf = grouping.bucketOf!;
this.#order = [];
@@ -172,7 +165,7 @@ export class ResourceSectionsBuilder<T, C> {
if (
this.#grouped &&
this.#bucketOf === grouping.bucketOf &&
this.#isAppend(this.#items, items)
isAppendExtension(this.#items, items)
) {
this.#extend(items, grouping);
} else {
@@ -0,0 +1,257 @@
import { describe, expect, it } from 'vitest';
import { SharedLanesBuilder, buildLanes, type Lane, type LaneGrouping } from './sharedLanes';
/**
* Benchmark gate for the incremental lanes builder (SharedLanesBuilder) that
* replaced the `lanes` `$derived.by` on the "My shares" page
* (shared/+page.svelte).
*
* Audit finding (ROUND15 deferred): the shares page pages its outgoing grants
* in via `raw = [...raw, ...page.items]`, and `lanes` re-bucketed the WHOLE
* accumulated (filtered) list on every page — and on every grant edit —
* allocating a fresh lane object + a fresh `rows` array for every lane each
* time. Σ ≈ O(N²/page) `emit` calls during an infinite-scroll drain. Same
* class as the F1 flagship (ResourceList.sections), but the lanes shape fans
* one item out to many rows across many lanes and caches a header at first
* appearance — see sharedLanes.ts.
*
* Gates (rollback rule: an AFTER that fails to beat its BEFORE fails CI):
* 1. Equivalence — at EVERY page of the drain, the incremental output is
* deep-equal to the verbatim full-rebuild reference (buildLanes), for the
* by-files group-by (1 lane per resource, contiguous) AND the by-subject
* group-by (a resource's grants scatter across subject lanes, so a fresh
* page sprays rows into already-emitted lanes — non-contiguous).
* 2. Reference stability — untouched lanes keep their exact `rows` array
* reference across a page append; a grown lane gets a fresh one.
* 3. Fallback — group-by switch, grant edit / deletion and kind-filter toggle
* fall back to a correct full rebuild.
* 4. Perf — `emit` work collapses from Σ O(N²/page) to O(N) across the drain
* (deterministic call count) and wall drops ≥3x.
*/
interface Grant {
grant_id: string;
subject_type: 'user' | 'group' | 'link';
subject_id: string;
has_password: boolean;
}
interface Item {
resource: { id: string; name: string };
grants: Grant[];
}
type Header =
| { kind: 'resource'; item: Item }
| { kind: 'user'; id: string }
| { kind: 'group'; id: string }
| { kind: 'linkPublic' }
| { kind: 'linkPassword' };
type Row = { grant: Grant; item: Item };
const pad = (i: number) => i.toString().padStart(6, '0');
/**
* Item `i` with 3 grants: two user grants whose subject round-robins across a
* small pool (so by-subject buckets repeat across items → non-contiguous), and
* one link grant (public / password alternating). Mirrors the shape the shares
* endpoint returns.
*/
function item(i: number): Item {
return {
resource: { id: `res-${pad(i)}`, name: `file-${pad(i)}` },
grants: [
{
grant_id: `g-${pad(i)}-0`,
subject_type: 'user',
subject_id: `user-${i % 8}`,
has_password: false
},
{
grant_id: `g-${pad(i)}-1`,
subject_type: 'group',
subject_id: `group-${i % 5}`,
has_password: false
},
{
grant_id: `g-${pad(i)}-2`,
subject_type: 'link',
subject_id: '',
has_password: i % 2 === 0
}
]
};
}
/** By-files group-by: one lane per resource; the old derive's unconditional `ensure`. */
function itemsGrouping(counter?: { n: number }): LaneGrouping<Item, Header, Row> {
return {
groupKey: 'items',
emit: (it, sink) => {
if (counter) counter.n++;
const key = `resource:${it.resource.id}`;
const header: Header = { kind: 'resource', item: it };
sink.open(key, header);
for (const grant of it.grants) sink.push(key, header, { grant, item: it });
}
};
}
/** By-subject group-by: a resource's grants scatter across one lane per subject / link kind. */
function sharedWithGrouping(counter?: { n: number }): LaneGrouping<Item, Header, Row> {
return {
groupKey: 'sharedWith',
emit: (it, sink) => {
if (counter) counter.n++;
for (const grant of it.grants) {
let key: string;
let header: Header;
if (grant.subject_type === 'user') {
key = `user:${grant.subject_id}`;
header = { kind: 'user', id: grant.subject_id };
} else if (grant.subject_type === 'group') {
key = `group:${grant.subject_id}`;
header = { kind: 'group', id: grant.subject_id };
} else if (grant.has_password) {
key = 'links:password';
header = { kind: 'linkPassword' };
} else {
key = 'links:public';
header = { kind: 'linkPublic' };
}
sink.push(key, header, { grant, item: it });
}
}
};
}
const PAGE = 50;
const PAGES = 50; // 2 500-item drain
describe('incremental shared lanes (benchmark gate)', () => {
for (const [name, mk] of [
['by-files (contiguous, 1 lane/resource)', itemsGrouping],
['by-subject (non-contiguous fan-out)', sharedWithGrouping]
] as const) {
it(`stays deep-equal to the full rebuild at every page — ${name}`, () => {
const all = Array.from({ length: PAGE * PAGES }, (_, i) => item(i));
const builder = new SharedLanesBuilder<Item, Header, Row>();
const g = mk();
for (let p = 1; p <= PAGES; p++) {
const cumulative = all.slice(0, p * PAGE);
const incremental = builder.sync(cumulative, g);
const reference = buildLanes(cumulative, g);
expect(incremental, `page ${p}`).toEqual(reference);
}
});
}
it('keeps untouched lane arrays reference-stable and refreshes grown ones', () => {
// A grouping that yields both stable and grown lanes on append: each item
// contributes to a per-block lane (block = ⌊i/40⌋, so older blocks are
// untouched by a later page) AND a single global lane (grows every page).
const grouping: LaneGrouping<Item, Header, Row> = {
groupKey: 'blocks',
emit: (it, sink) => {
const i = Number(it.resource.id.slice(4));
const blockKey = `block:${Math.floor(i / 40)}`;
sink.push(blockKey, { kind: 'user', id: blockKey }, { grant: it.grants[0], item: it });
sink.push('all', { kind: 'user', id: 'all' }, { grant: it.grants[1], item: it });
}
};
const all = Array.from({ length: 200 }, (_, i) => item(i));
const builder = new SharedLanesBuilder<Item, Header, Row>();
const first = builder.sync(all.slice(0, 120), grouping);
const refBefore = new Map(first.map((l) => [l.key, l.rows]));
const second = builder.sync(all.slice(0, 160), grouping);
const refAfter = new Map(second.map((l) => [l.key, l.rows]));
// Old blocks (0,1,2 = items 0..119) are untouched → same array reference.
expect(refAfter.get('block:0')).toBe(refBefore.get('block:0'));
expect(refAfter.get('block:2')).toBe(refBefore.get('block:2'));
// The global lane grew → a fresh reference (a keyed {#each} re-renders it).
expect(refAfter.get('all')).not.toBe(refBefore.get('all'));
// And a brand-new block appeared for items 120..159.
expect(refBefore.has('block:3')).toBe(false);
expect(refAfter.has('block:3')).toBe(true);
});
it('falls back to a correct full rebuild on group-by switch, edit and filter toggle', () => {
const all = Array.from({ length: 300 }, (_, i) => item(i));
const builder = new SharedLanesBuilder<Item, Header, Row>();
const byItems = itemsGrouping();
const bySubject = sharedWithGrouping();
// Drain a few pages by-files, then switch to by-subject (groupKey change → rebuild).
builder.sync(all.slice(0, 150), byItems);
builder.sync(all.slice(0, 300), byItems);
expect(builder.sync(all.slice(0, 300), bySubject)).toEqual(
buildLanes(all.slice(0, 300), bySubject)
);
// Grant edit under the SAME group-by: an item's grants change but the item
// list length is unchanged → not a strict append → rebuild. Mutate a copy.
const edited = all
.slice(0, 300)
.map((it, i) => (i === 10 ? { ...it, grants: it.grants.slice(0, 1) } : it));
expect(builder.sync(edited, bySubject)).toEqual(buildLanes(edited, bySubject));
// Deletion (list shrinks) → rebuild.
const shrunk = edited.filter((_, i) => i % 9 !== 0);
expect(builder.sync(shrunk, bySubject)).toEqual(buildLanes(shrunk, bySubject));
// Kind-filter toggle: the filtered list becomes a different (reordered)
// subset → boundary mismatch → rebuild, still equal to a full pass.
const filtered = all.slice(0, 300).filter((_, i) => i % 3 === 0);
expect(builder.sync(filtered, byItems)).toEqual(buildLanes(filtered, byItems));
});
it('collapses emit work from Σ O(N²/page) to O(N) and runs ≥3x faster', () => {
const N = PAGE * PAGES;
const all = Array.from({ length: N }, (_, i) => item(i));
// Deterministic call-count gate (the hard rollback gate): the incremental
// builder emits each item exactly once across the drain; the full rebuild
// is quadratic (Σ_{p=1..P} p·PAGE). This is noise-free — it holds regardless
// of machine load.
const afterCounter = { n: 0 };
const gAfterCount = sharedWithGrouping(afterCounter);
const countBuilder = new SharedLanesBuilder<Item, Header, Row>();
for (let p = 1; p <= PAGES; p++) countBuilder.sync(all.slice(0, p * PAGE), gAfterCount);
const beforeCounter = { n: 0 };
const gBeforeCount = sharedWithGrouping(beforeCounter);
for (let p = 1; p <= PAGES; p++) buildLanes(all.slice(0, p * PAGE), gBeforeCount);
expect(afterCounter.n).toBe(N);
expect(beforeCounter.n).toBe((PAGES * (PAGES + 1) * PAGE) / 2);
expect(afterCounter.n).toBeLessThan(beforeCounter.n / 5);
// Wall gate — best-of-3 (min) per arm to shrug off scheduler / GC noise
// under a saturated test runner (mirrors round14 §F1's `Math.min` pattern);
// the tiny incremental arm is otherwise vulnerable to a single GC pause.
// The O(N²)→O(N) collapse leaves ample headroom over the 3x floor.
const runAfter = () => {
const b = new SharedLanesBuilder<Item, Header, Row>();
const g = sharedWithGrouping();
const t = performance.now();
for (let p = 1; p <= PAGES; p++) b.sync(all.slice(0, p * PAGE), g);
return performance.now() - t;
};
const runBefore = () => {
const g = sharedWithGrouping();
const t = performance.now();
for (let p = 1; p <= PAGES; p++) buildLanes(all.slice(0, p * PAGE), g);
return performance.now() - t;
};
const afterMs = Math.min(runAfter(), runAfter(), runAfter());
const beforeMs = Math.min(runBefore(), runBefore(), runBefore());
console.info(
`shared lanes ${PAGES}×${PAGE}: before ${beforeCounter.n} emit calls / ${beforeMs.toFixed(1)} ms — after ${afterCounter.n} calls / ${afterMs.toFixed(1)} ms (${(beforeCounter.n / afterCounter.n).toFixed(1)}x fewer calls, ${(beforeMs / afterMs).toFixed(1)}x wall)`
);
expect(afterMs).toBeLessThan(beforeMs / 3);
});
});
// Keep the exported types referenced so a stray unused-import lint can't creep in.
export type { Lane };
+188
View File
@@ -0,0 +1,188 @@
/**
* Incremental swimlane builder for the "My shares" page (`shared/+page.svelte`).
*
* The ROUND15-deferred follow-up to the F1 flagship (`resourceSections.ts`):
* that fix replaced `ResourceList`'s `sections` derive; this one replaces the
* `lanes` `$derived.by` on the shares page, which had the same O(N²/page)
* shape. The page pages its outgoing grants in via infinite scroll
* (`raw = [...raw, ...page.items]`), and `lanes` re-bucketed the WHOLE
* accumulated (filtered) list on every page — and on every grant edit —
* allocating a brand-new lane object and a brand-new `rows` array for every
* lane each time. Σ ≈ O(N²/page) `emit` calls across an infinite-scroll drain.
*
* The lanes shape differs from `resourceSections` in two ways, so it gets its
* own builder rather than reusing `ResourceSectionsBuilder` (only the O(1)
* append test is genuinely shared — see {@link isAppendExtension}):
*
* - **Fan-out.** One input item contributes 0..N rows across 0..M lanes (in
* the "shared with" group-by a resource's grants scatter across one lane per
* distinct subject), whereas a resource section maps one item to exactly one
* bucket with the item itself as the row.
* - **Header captured at first appearance.** A lane's header (a tagged union
* identifying the resource / subject / link kind) is fixed by the lane's
* first-seen member and never recomputed — unlike a section's `label`, which
* is recomputed every sync because it resolves async. (The shares page mirrors
* that: it renders the header's *label* live via `resolveLabel(...)` at render
* time from the stable header, so only the header identity is cached here.)
*
* Correctness does not depend on lane contiguity in server order. The
* "shared with" group-by is non-monotonic — a fresh page sprays rows across
* already-emitted subject lanes — exactly like F1's "trash by drive" case, and
* stays byte-for-byte equal to a full rebuild (it just refreshes more lanes per
* page). The pure {@link buildLanes} is the verbatim reference (what the old
* `lanes` derive produced); the benchmark gate holds the incremental builder
* deep-equal to it at every page.
*/
import { isAppendExtension } from './appendExtension';
/** One swimlane: a stable key, its first-appearance header, and its rows. */
export interface Lane<H, R> {
key: string;
header: H;
rows: R[];
}
/**
* Sink an item's {@link LaneGrouping.emit} writes its contributions to.
* `open` ensures a lane exists (0 rows is valid — mirrors the old derive's
* unconditional `ensure(...)` in the by-files group-by); `push` ensures the
* lane and appends a row. The `header` is consulted only when the key is first
* seen.
*/
export interface LaneSink<H, R> {
open(key: string, header: H): void;
push(key: string, header: H, row: R): void;
}
/**
* The grouping the builder needs: a `groupKey` identity (a change forces a full
* rebuild) and an `emit` that maps one item to its lane contributions via the
* {@link LaneSink}. Generic over item `T`, header `H` and row `R` so the module
* stays independent of the shares page's concrete types.
*/
export interface LaneGrouping<T, H, R> {
/** Identity of the active grouping; a change between syncs forces a rebuild. */
groupKey: string;
/** Emit an item's lane contributions, in order, into `sink`. */
emit: (item: T, sink: LaneSink<H, R>) => void;
}
/**
* Verbatim reference: the `Lane[]` the old `lanes` `$derived.by` produced for
* `items` under `grouping`. Lane order is first-appearance; within a lane, row
* order is (item, then emit) order. The benchmark gate holds the incremental
* builder equal to this at every page.
*/
export function buildLanes<T, H, R>(items: T[], grouping: LaneGrouping<T, H, R>): Lane<H, R>[] {
const out: Lane<H, R>[] = [];
const byKey = new Map<string, Lane<H, R>>();
const ensure = (key: string, header: H): Lane<H, R> => {
let lane = byKey.get(key);
if (lane === undefined) {
lane = { key, header, rows: [] };
byKey.set(key, lane);
out.push(lane);
}
return lane;
};
const sink: LaneSink<H, R> = {
open: (key, header) => void ensure(key, header),
push: (key, header, row) => ensure(key, header).rows.push(row)
};
for (const item of items) grouping.emit(item, sink);
return out;
}
/**
* Incremental lanes builder. Call {@link sync} with the current (already
* kind-filtered) item list and grouping on every change; it detects the common
* case — the list grew by appending a page under an unchanged group-by — and
* re-emits only the fresh items, appending to the touched lanes (each of which
* gets a fresh `rows` array so a keyed `{#each}` re-renders it) while every
* untouched lane keeps its exact array reference. Any other change (group-by
* switch, grant edit / deletion, kind-filter toggle, non-append) falls back to
* a full rebuild, so the result is always deep-equal to {@link buildLanes}.
*/
export class SharedLanesBuilder<T, H, R> {
/** Last synced list — the append cursor and the append-detection baseline. */
#items: T[] = [];
/** Lane keys in first-appearance order. */
#order: string[] = [];
/** key → the lane's first-appearance header. */
#headers = new Map<string, H>();
/** key → the lane's rows array (a fresh reference whenever it grows). */
#rows = new Map<string, R[]>();
/** The `groupKey` of the last sync; a change forces a rebuild. */
#groupKey: string | null = null;
#rebuild(items: T[], grouping: LaneGrouping<T, H, R>): void {
this.#order = [];
this.#headers = new Map();
this.#rows = new Map();
const ensure = (key: string, header: H): R[] => {
let arr = this.#rows.get(key);
if (arr === undefined) {
arr = [];
this.#rows.set(key, arr);
this.#headers.set(key, header);
this.#order.push(key);
}
return arr;
};
const sink: LaneSink<H, R> = {
open: (key, header) => void ensure(key, header),
push: (key, header, row) => ensure(key, header).push(row)
};
for (const item of items) grouping.emit(item, sink);
this.#items = items;
}
#extend(items: T[], grouping: LaneGrouping<T, H, R>): void {
const fresh = items.slice(this.#items.length);
// Collect the fresh page's rows per touched lane, plus the keys the page
// newly introduces (in first-appearance order). Untouched lanes are never
// entered here, so they keep their exact existing `rows` reference.
const freshByKey = new Map<string, R[]>();
const newKeys: string[] = [];
const touch = (key: string, header: H): R[] => {
let add = freshByKey.get(key);
if (add === undefined) {
add = [];
freshByKey.set(key, add);
if (!this.#rows.has(key)) {
newKeys.push(key);
this.#headers.set(key, header);
}
}
return add;
};
const sink: LaneSink<H, R> = {
open: (key, header) => void touch(key, header),
push: (key, header, row) => touch(key, header).push(row)
};
for (const item of fresh) grouping.emit(item, sink);
for (const [k, add] of freshByKey) {
const existing = this.#rows.get(k);
// New lane → adopt the fresh array; grown lane → fresh concat (new
// reference, so a keyed `{#each}` refreshes exactly the grown lanes).
this.#rows.set(k, existing === undefined ? add : existing.concat(add));
}
for (const k of newKeys) this.#order.push(k);
this.#items = items;
}
sync(items: T[], grouping: LaneGrouping<T, H, R>): Lane<H, R>[] {
if (this.#groupKey === grouping.groupKey && isAppendExtension(this.#items, items)) {
this.#extend(items, grouping);
} else {
this.#rebuild(items, grouping);
}
this.#groupKey = grouping.groupKey;
return this.#order.map((k) => ({
key: k,
header: this.#headers.get(k)!,
rows: this.#rows.get(k)!
}));
}
}
+10 -6
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import { SvelteSet } from 'svelte/reactivity';
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
import { primeContextPage } from '$lib/utils/listContext';
import Button from '$lib/components/Button.svelte';
import { useOwnerCache } from '$lib/composables/useOwnerCache.svelte';
import { errorToast } from '$lib/utils/errors';
@@ -52,11 +53,10 @@
// items on this page are favorites — pass every id in `favoriteIds`
// so the star widget lights up universally.
const items = $derived(raw.map((it) => it.resource as FileItem | FolderItem));
const contextMap = $derived(
new Map<string, ItemContext>(
raw.map((it) => [it.resource.id, { date: it.favorited_at } satisfies ItemContext])
)
);
// Persistent reactive map, primed per page in `load()` (benches/ROUND16.md §F2)
// instead of rebuilding a fresh Map that re-hashes the whole accumulated list
// on every infinite-scroll page. Mirrors the sibling `favoriteIds` SvelteSet.
const contextMap = new SvelteMap<string, ItemContext>();
// Persistent reactive set, updated in place per page (add the fresh page's
// ids; clear on reset) instead of rebuilding a brand-new SvelteSet over the
// whole accumulated list on every infinite-scroll page — that was O(N²)
@@ -117,6 +117,10 @@
// reset, then add only this page's ids (benches/ROUND14.md §F2).
if (reset) favoriteIds.clear();
for (const it of page.items) favoriteIds.add(it.resource.id);
primeContextPage(contextMap, reset, page.items, (it) => [
it.resource.id,
{ date: it.favorited_at }
]);
cursor = page.next_cursor;
void owners.resolve(page.items.map((i) => i.resource.created_by));
} catch (e) {
+10 -9
View File
@@ -5,7 +5,8 @@
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { onMount } from 'svelte';
import { SvelteSet } from 'svelte/reactivity';
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
import { primeContextPage } from '$lib/utils/listContext';
import { clearRecent, fetchRecentPage, type RecentResourceItem } from '$lib/api/endpoints/recent';
import {
addFavorite,
@@ -59,14 +60,10 @@
// shared `isDotfile` predicate purely for the empty-state message
// below (distinguishes "genuinely empty" from "everything filtered").
const items = $derived(raw.map((it) => it.resource as FileItem | FolderItem));
const contextMap = $derived(
new Map<string, ItemContext>(
raw.map((it) => [
it.resource.id,
{ date: it.accessed_at, ownerId: it.resource.updated_by ?? null } satisfies ItemContext
])
)
);
// Persistent reactive map, primed per page in `load()` (benches/ROUND16.md §F2)
// instead of rebuilding a fresh Map that re-hashes the whole accumulated list
// on every infinite-scroll page. Mirrors the sibling `favoriteIds` SvelteSet.
const contextMap = new SvelteMap<string, ItemContext>();
const hiddenCount = $derived(
preferences.hideDotfiles ? items.filter((i) => isDotfile(i.name)).length : 0
);
@@ -131,6 +128,10 @@
resourceTypes: ['file', 'folder']
});
raw = reset ? page.items : [...raw, ...page.items];
primeContextPage(contextMap, reset, page.items, (it) => [
it.resource.id,
{ date: it.accessed_at, ownerId: it.resource.updated_by ?? null }
]);
cursor = page.next_cursor;
void owners.resolve(page.items.map((i) => i.resource.updated_by));
} catch (e) {
@@ -1,5 +1,7 @@
<script lang="ts">
import { errorMessage } from '$lib/utils/errors';
import { SvelteMap } from 'svelte/reactivity';
import { primeContextPage } from '$lib/utils/listContext';
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { onMount } from 'svelte';
@@ -43,14 +45,11 @@
// so the sharer shows up in the vignette (rather than the resource's
// intrinsic `created_by`, which is a stranger for grantees).
const items = $derived(fileFolderGrants.map((it) => it.resource as FileItem | FolderItem));
const contextMap = $derived(
new Map<string, ItemContext>(
fileFolderGrants.map((it) => [
it.resource.id,
{ date: it.granted_at, ownerId: it.granted_by ?? null } satisfies ItemContext
])
)
);
// Persistent reactive map, primed per page in `load()` (benches/ROUND16.md §F2)
// instead of rebuilding a fresh Map that re-hashes the whole accumulated list
// on every infinite-scroll page. Drives are skipped (they never reach the
// row UI), so the map covers exactly the displayed `fileFolderGrants`.
const contextMap = new SvelteMap<string, ItemContext>();
// Server-supported sort_by values (see grant_handler.rs:615):
// granted_at, granted_by, name, type
@@ -96,6 +95,11 @@
reverse: rev
});
raw = reset ? page.items : [...raw, ...page.items];
primeContextPage(contextMap, reset, page.items, (it) =>
it.resource_type === 'drive'
? null
: [it.resource.id, { date: it.granted_at, ownerId: it.granted_by ?? null }]
);
cursor = page.next_cursor;
// Warm the sharer-name cache so the "Shared by" group headers
// show real names instead of UUIDs.
+33 -21
View File
@@ -28,6 +28,7 @@
import { t } from '$lib/i18n/index.svelte';
import { ui } from '$lib/stores/ui.svelte';
import { formatDate, iconNameFromClass } from '$lib/utils/display';
import { SharedLanesBuilder, type LaneGrouping } from '$lib/utils/sharedLanes';
type GroupBy = 'items' | 'sharedWith';
@@ -178,25 +179,29 @@
rows: { grant: OutgoingResourceGrant; item: OutgoingGrantItem }[];
}
const lanes = $derived.by((): Lane[] => {
const out: Lane[] = [];
// Transient scratch map built inside $derived.by and discarded — not reactive state.
// eslint-disable-next-line svelte/prefer-svelte-reactivity
const byKey = new Map<string, Lane>();
const ensure = (key: string, header: Lane['header']): Lane => {
let lane = byKey.get(key);
if (!lane) {
lane = { key, header, rows: [] };
byKey.set(key, lane);
out.push(lane);
type LaneRow = Lane['rows'][number];
// The active grouping as a stable-identity descriptor: `groupKey` changes
// only when the user switches group-by, so an infinite-scroll page (or a
// grant edit that reassigns `raw`) takes the builder's O(N) incremental path
// instead of re-bucketing the whole accumulated list. `emit` reproduces the
// old derive exactly — `open` is the old unconditional `ensure` (a by-files
// lane exists even with zero grants); `push` is `ensure(...).rows.push`.
const laneGrouping = $derived.by(
(): LaneGrouping<OutgoingGrantItem, Lane['header'], LaneRow> =>
groupBy === 'items'
? {
groupKey: 'items',
emit: (item, sink) => {
const key = `resource:${item.resource.id}`;
const header: Lane['header'] = { kind: 'resource', item };
sink.open(key, header);
for (const grant of item.grants) sink.push(key, header, { grant, item });
}
return lane;
};
for (const item of filteredRaw) {
if (groupBy === 'items') {
const lane = ensure(`resource:${item.resource.id}`, { kind: 'resource', item });
for (const grant of item.grants) lane.rows.push({ grant, item });
} else {
}
: {
groupKey: 'sharedWith',
emit: (item, sink) => {
for (const grant of item.grants) {
let key: string;
let header: Lane['header'];
@@ -213,12 +218,19 @@
key = 'links:public';
header = { kind: 'linkPublic' };
}
ensure(key, header).rows.push({ grant, item });
sink.push(key, header, { grant, item });
}
}
}
return out;
});
);
// Persistent across reactive ticks: re-buckets only the freshly-appended page
// and hands back the same rows-array reference for untouched lanes, falling
// back to a full rebuild (deep-equal to the pure `buildLanes` reference) on a
// group-by switch, grant edit or kind-filter toggle. Mirrors ResourceList's
// `sectionsBuilder` (benches/ROUND16.md §F1).
const lanesBuilder = new SharedLanesBuilder<OutgoingGrantItem, Lane['header'], LaneRow>();
const lanes = $derived.by(() => lanesBuilder.sync(filteredRaw, laneGrouping));
function laneTitle(header: Lane['header']): string {
switch (header.kind) {
+10 -14
View File
@@ -1,5 +1,7 @@
<script lang="ts">
import { errorToast } from '$lib/utils/errors';
import { SvelteMap } from 'svelte/reactivity';
import { primeContextPage } from '$lib/utils/listContext';
import { onMount } from 'svelte';
import {
deleteTrashItem,
@@ -44,20 +46,10 @@
// travel through `contextMap`, which page-provided group-by / render
// callbacks read via the `ctx` parameter.
const items = $derived(raw.map((it) => it.resource as FileItem | FolderItem));
const contextMap = $derived(
new Map<string, ItemContext>(
raw.map((it) => [
it.resource.id,
{
date: it.deletion_date,
extras: {
driveId: it.drive_id,
trashedAt: it.trashed_at
}
}
])
)
);
// Persistent reactive map, primed per page in `load()` (benches/ROUND16.md §F2)
// instead of rebuilding a fresh Map that re-hashes the whole accumulated list
// on every infinite-scroll page. Mirrors the shipped `favoriteIds` SvelteSet.
const contextMap = new SvelteMap<string, ItemContext>();
// "Drive" group rank: default-personal first, then secondary personal, then
// shared — matches `DrivePicker.svelte::sortedDrives` so the sidebar and
@@ -140,6 +132,10 @@
resourceTypes: ['file', 'folder']
});
raw = reset ? page.items : [...raw, ...page.items];
primeContextPage(contextMap, reset, page.items, (it) => [
it.resource.id,
{ date: it.deletion_date, extras: { driveId: it.drive_id, trashedAt: it.trashed_at } }
]);
cursor = page.next_cursor;
} catch (e) {
console.error('trash: load error', e);
+3 -3
View File
@@ -882,9 +882,9 @@ fn row_to_item_dto(row: TrashResourceRow) -> TrashResourceItemDto {
created_at: row.resource_created_at.timestamp() as u64,
modified_at: row.modified_at.timestamp() as u64,
is_root: false,
icon_class: std::sync::Arc::from("fas fa-folder"),
icon_special_class: std::sync::Arc::from("folder-icon"),
category: std::sync::Arc::from("Folder"),
icon_class: intern_display("fas fa-folder"),
icon_special_class: intern_display("folder-icon"),
category: intern_display("Folder"),
// §14 provenance not selected by the trash listing query.
created_by: None,
updated_by: None,
@@ -180,9 +180,9 @@ impl PathResolverService {
created_at: created_at as u64,
modified_at: modified_at as u64,
is_root: false,
icon_class: Arc::from("fas fa-folder"),
icon_special_class: Arc::from("folder-icon"),
category: Arc::from("Folder"),
icon_class: intern_display("fas fa-folder"),
icon_special_class: intern_display("folder-icon"),
category: intern_display("Folder"),
// §14 provenance not selected by this resolver path —
// it's used for existence/type discrimination, not
// detailed DTO emission. Callers that need provenance
+30 -9
View File
@@ -1153,18 +1153,39 @@ pub(super) fn build_content_disposition(name: &str, mime: &str, force_inline: bo
.remove(b'`')
.remove(b'|')
.remove(b'~');
let encoded = utf8_percent_encode(name, RFC5987_SET).to_string();
// Fast path: a name whose every byte is an RFC 5987 attr-char needs neither
// percent-encoding nor ASCII-fallback filtering ('"' and '\\' are not
// attr-chars, so none is substituted), so `filename` and `filename*` are the
// name verbatim — one allocation (the header) instead of three.
let all_attr_char = name.bytes().all(|b| {
b.is_ascii_alphanumeric()
|| matches!(
b,
b'!' | b'#' | b'$' | b'&' | b'+' | b'-' | b'.' | b'^' | b'_' | b'`' | b'|' | b'~'
)
});
if all_attr_char {
return format!("{disposition}; filename=\"{name}\"; filename*=UTF-8''{name}");
}
let ascii_safe: String = name
.chars()
.filter(|c| c.is_ascii_graphic() || *c == ' ')
.map(|c| match c {
// Slow path: assemble the header in one pre-sized buffer, writing the ASCII
// fallback and the percent-encoded form in place — no throwaway `ascii_safe`
// / `encoded` Strings. Sized for the worst case (every byte → %XX) so it
// never grows.
let mut out = String::with_capacity(disposition.len() + name.len() * 4 + 32);
out.push_str(disposition);
out.push_str("; filename=\"");
for c in name.chars().filter(|c| c.is_ascii_graphic() || *c == ' ') {
out.push(match c {
'"' | '\\' => '_',
_ => c,
})
.collect();
format!("{disposition}; filename=\"{ascii_safe}\"; filename*=UTF-8''{encoded}")
});
}
out.push_str("\"; filename*=UTF-8''");
for chunk in utf8_percent_encode(name, RFC5987_SET) {
out.push_str(chunk);
}
out
}
// ── Route handlers (free functions) ──────────────────────────────────────────
+6 -5
View File
@@ -43,12 +43,13 @@ pub async fn handle_preview(
) -> impl IntoResponse {
// Parse the Nextcloud file ID — the NC app may append an instance suffix
// (e.g. "00000326ocnca"), so strip non-digit characters first.
let numeric_part: String = params
let digit_end = params
.file_id
.chars()
.take_while(|c| c.is_ascii_digit())
.collect();
let nc_file_id: i64 = match numeric_part.parse() {
.as_bytes()
.iter()
.position(|b| !b.is_ascii_digit())
.unwrap_or(params.file_id.len());
let nc_file_id: i64 = match params.file_id[..digit_end].parse() {
Ok(id) => id,
Err(_) => {
return Response::builder()
+4 -4
View File
@@ -10,7 +10,7 @@ use quick_xml::{
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use crate::application::dtos::display_helpers::format_file_size;
use crate::application::dtos::display_helpers::{format_file_size, intern_display};
use crate::application::dtos::file_dto::FileDto;
use crate::application::dtos::folder_dto::FolderDto;
use crate::application::dtos::search_dto::SearchCriteriaDto;
@@ -443,9 +443,9 @@ fn folder_dto_from_search(
created_at: sr.created_at,
modified_at: sr.modified_at,
is_root: sr.is_root,
icon_class: Arc::from("fas fa-folder"),
icon_special_class: Arc::from("folder-icon"),
category: Arc::from("Folder"),
icon_class: intern_display("fas fa-folder"),
icon_special_class: intern_display("folder-icon"),
category: intern_display("Folder"),
// §14 provenance not selected by search results.
created_by: None,
updated_by: None,
+20 -12
View File
@@ -186,19 +186,27 @@ pub fn nc_collection_href(username: &str, subpath: &str) -> String {
pub fn nc_href(username: &str, subpath: &str) -> String {
let subpath = subpath.trim_matches('/');
let encoded_user = urlencoding::encode(username);
if subpath.is_empty() {
format!("/remote.php/dav/files/{}/", encoded_user)
} else {
let encoded_segments: Vec<_> = subpath
.split('/')
.map(|seg| urlencoding::encode(seg))
.collect();
format!(
"/remote.php/dav/files/{}/{}",
encoded_user,
encoded_segments.join("/")
)
// Write the prefix, user and each encoded segment straight into one
// pre-sized buffer — avoids the per-segment `Vec<Cow>`, the joined String and
// the `format!` result the previous `.map(...).collect().join("/")` allocated
// on every NC PROPFIND/REPORT href (mirrors the native `encode_uri_path`).
// Keeps `urlencoding::encode` so the emitted bytes are unchanged.
const PREFIX: &str = "/remote.php/dav/files/";
let mut out = String::with_capacity(PREFIX.len() + encoded_user.len() + subpath.len() + 8);
out.push_str(PREFIX);
out.push_str(&encoded_user);
out.push('/');
// No empty-segment filter: `split('/')` on an empty (root) subpath yields a
// single "" whose encode is "" — leaving the trailing slash above intact —
// and any internal "//" is preserved byte-for-byte, exactly as the old
// `split → map → join("/")` produced.
for (i, seg) in subpath.split('/').enumerate() {
if i > 0 {
out.push('/');
}
out.push_str(&urlencoding::encode(seg));
}
out
}
/// Dispatch Nextcloud WebDAV request to the appropriate handler.