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:
+11
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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)");
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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)!
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,47 +179,58 @@
|
||||
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);
|
||||
}
|
||||
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 {
|
||||
for (const grant of item.grants) {
|
||||
let key: string;
|
||||
let header: Lane['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' };
|
||||
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 });
|
||||
}
|
||||
}
|
||||
ensure(key, header).rows.push({ grant, item });
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
});
|
||||
: {
|
||||
groupKey: 'sharedWith',
|
||||
emit: (item, sink) => {
|
||||
for (const grant of item.grants) {
|
||||
let key: string;
|
||||
let header: Lane['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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// 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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) ──────────────────────────────────────────
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user