Benchmark-gated (equivalence + BEFORE/AFTER; results + reproduce commands in
benches/ROUND7.md):
- Photos timeline re-grouped + re-laid-out the whole accumulated library on
every 60-item page (both `groups` and `photoRows` were $derived over the
full list), Σ ≈ O(N²/60) main-thread work during a scroll. Pages arrive
newest-first so grouping is append-only: the new PhotoTimeline
(lib/utils/photoTimeline.ts) re-buckets only the fresh page and re-lays-out
only changed groups, reusing untouched groups' cached rows, falling back to
a full rebuild on any config/deletion/non-append change. The pure
buildPhotoRows is the verbatim reference the gate holds it equal to at every
page. 50×60 drain: 76 500 → 3 000 grouping ops (25.5x), 23.0 → 2.2 ms
(10.6x).
- Range downloads paid authz + access-notify twice: download_file_impl
resolves the file via get_file_with_perms, then the Range branch re-ran
require_file + notify_file_accessed per request. Media/PDF viewers fetch
exclusively via Range (one request per seek), so every seek in a scrub
re-authorized an already-cleared file. Now routed through the non-perms
get_file_range_preloaded (matching the share-landing + WebDAV range paths);
the unused _with_perms range method is removed. The request-level gate still
denies before the branch runs (bench asserts member granted, outsider
denied). Per seek removed: WARM 0.67 µs, COLD 1362.66 µs — a grant-cascade
drive-resolve query per seek for a shared-drive recipient on a cold cache.
- /api/folders/{id}/resources row→DTO mapping cloned row.name into the DTO
though the row is owned; folders move it (fixed icons), files compute the
name-derived icon/category classes first then move it. 500-row page:
10.004 → 9.004 allocs/row (500 clones removed), output identical.
Deferred with rationale in ROUND7.md: thumbnail ACL-before-304 (security
posture — needs a security review, not a perf tweak), batch_operations
Arc<str>→String widening, list-view O(N²) on smaller lists, and the serial→
join! pairs (decide-by-bench with injected latency, per the round-6 rejection).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017aJu9ghvuT8WqC31ZEGTBA
Four SPA hot-path fixes, each shipping with a vitest benchmark gate
(verbatim BEFORE replica + equivalence + perf assertion) so CI
re-verifies the win on every run:
- fetchFolderListing invoked onPage after EVERY 200-row page with the
whole accumulated listing, and the files view re-sorts everything per
emission — O(N²/page) main-thread work on large folders. Page one and
the final page always emit; intermediates coalesce to one per 150 ms.
25×200 load: 30.9 → 4.0 ms (7.8x), 65 000 → 5 200 sorted elements.
- selected/favoriteIds/sharedIds (files) and favoriteIds (recent) were
$state<Set>s copied whole on every toggle. Now one SvelteSet each,
mutated in place (the useSelection pattern): 1 000 toggles @ N=5 000
771.9 → 1.9 ms (399x); one-toggle fan-out across 40 mounted rows
40 → 3 re-runs when refining a select-all.
- batchDelete/moveInto awaited one request per item serially and
probed listing.folders.find per id (O(N·M)). Now an id index built
once + mapLimit(6) fan-out, failure semantics preserved: 100-item
delete @ 5 ms RTT 525 → 89 ms (5.9x), 38 825 → 500 probes.
- t() re-split its dotted key and walked the nested dict on every call,
and interpolate regex-scanned strings without placeholders. Resolved
values now memoize per (dict, key) in a WeakMap + a {{ guard:
20k mixed calls 22.7 → 8.6 ms (2.63x).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017aJu9ghvuT8WqC31ZEGTBA
Round 3 of benchmark-gated optimizations (benches/ROUND3.md; every change
gated by a before/after benchmark — an AFTER that did not beat its BEFORE
was to be rolled back; none needed it. Equivalence gates assert identical
row sequences / byte-identical output on every behavior-preserving rewrite):
DB hot paths (local PG16, EXPLAIN-verified):
- Web-UI listing (list_resources_paged): cursor pushed INSIDE the
folders/files UNION-ALL branches as sargable row-value comparisons with
per-branch ORDER/LIMIT + two partial expression indexes
(folder_id, LOWER(name), id). 20k-entry folder: 26.6 -> 1.3 ms/page
(19.5x); other sort modes at parity or better. New migration
20260918000000. [benches/LISTING-KEYSET.md section in ROUND3]
- Photos timeline (list_media_files): per-drive CROSS JOIN LATERAL top-N
on the timeline index, joins moved above the top-N. 50k-photo library:
97.4 -> 1.6 ms/page (55.7x). The old "LIMIT stops the scan early"
comment was refuted by EXPLAIN.
- PROPFIND sub-folders (both DAV surfaces): keyset list_folders_batch off
idx_folders_unique_name replaces COUNT(*) OVER() + LIMIT/OFFSET
(5k dirs: 79.7 -> 17.9 ms full walk, 4.5x).
Concurrency:
- Basic-auth cache single-flight (moka try_get_with): 8 concurrent DAV
connections at TTL expiry paid 8 Argon2id runs (2.6 s CPU + 8x64 MiB);
now 1 (300 ms). Failed verifications remain uncached.
- CachedBlobBackend per-hash single-flight + unique tmp names: 16
concurrent cold readers = 16 full remote downloads racing truncating
writes on ONE deterministic .tmp (corruptible cache); now 1 download
(16x less egress, 2.8x wall on a shared link) and torn files can never
be renamed into the cache.
I/O and allocations:
- Chunk-assembly reads 64K -> 512K buffers (2.3x, 8x fewer syscalls);
chunk-spool writes via BufWriter 512K (5.6x, 32x fewer syscalls).
- S3/Azure put_blob_from_bytes_unsynced overrides: dedup settle no longer
pays a HEAD probe per new chunk (2 RTT -> 1, 1.8x); Azure stops copying
every chunk (Bytes -> Body, -0.44 ms - 4 MiB alloc per 4 MiB chunk).
- Entity->DTO mapping: Arc<str> interning of closed-set display fields +
common MIMEs, 1-alloc etag/size formatting, FolderDto moves instead of
clones. File row: 11 -> 4 allocs; folder row: 11.8 -> 1 (2.1x faster).
- CardDAV REPORT: deleted dead per-contact vCard pre-generation and the
O(N^2) uid scan whose result was discarded (5k contacts: 55.7 -> 5.7 ms,
9.8x); byte-identical XML asserted.
- Search-results cache: byte weigher + 32 MiB budget
(OXICLOUD_SEARCH_CACHE_MAX_BYTES) replaces the 1000-ENTRY cap that let
~300 MiB of enriched rows sit in RSS; read latency parity.
- Dropped aws-config + aws-smithy-types (zero references; -82 dep-graph
nodes, three SDK stacks gone from every build). tokio "process" is now
an explicit feature (was enabled transitively by aws-config).
Frontend:
- Cached Intl.DateTimeFormat keyed by (locale, options) in formatDate and
4 sibling callsites: 20k dates 2612 -> 51 ms (51.6x); vitest gate
asserts output identity across locales and a 3x floor.
Validation: cargo fmt + clippy --all-features --all-targets -D warnings
clean; 518 unit + 548 integration-cfg tests green; new-shape endpoints
smoke-tested end-to-end over HTTP (all 5 listing sort modes with cursor
walks, WebDAV PROPFIND Depth-1, photos timeline, Basic-auth DAV login);
frontend npm run check clean, new vitest gates green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EBsU2qEzny3A8WQUEuMNCr
Round 2 of benchmark-gated optimizations (benches/ROUND2.md; every change
gated by a before/after in examples/bench_round2.rs — an AFTER that did
not beat its BEFORE was to be rolled back; none needed it):
- Range requests (REST/DAV/shares) answered from the moka content cache
for sub-10MB files: PG resolve + open/seek/read -> Bytes::slice.
256KiB seeks: 1,730/s -> 3.7M/s (p50 552us -> 0.15us).
- Streaming folder/share ZIPs via tokio duplex: TTFB no longer scales
with archive size (326ms -> 0.4ms on 192MiB corpus; total also faster).
Content-Length dropped (size unknown up front).
- NC chunked-upload per-PUT gate: O(k) directory scan+stat -> in-RAM
per-session counter (lazy rebuild on cold start). 1,000-chunk upload
gate cost: 33.1s -> 0.09s cumulative.
- Delta download + commit-verify now use the CDC path's
buffered(read_prefetch) read-ahead: 64-chunk drain at 5ms open
latency 440ms -> 51ms; order preserved.
- CDC ingest settles batches on a spawned task (depth-1 pipeline) so
the source stream keeps flowing during PG pin + backend writes;
rollback ledger shared + lock-serialized so compensation stays exact
on cancellation. 512MiB paced ingest: 60-69 -> 74-75 MB/s.
OXICLOUD_INGEST_OVERLAP=0 restores inline settling (ops/bench hatch).
- Frontend: instant-upload BLAKE3 hashing moved off the main thread to
a bounded Web Worker pool (File handles by reference); vitest gate
asserts the pool beats sequential (first gate draft posting buffers
was 2.6x slower and was rewritten — copies dominated).
Validation: cargo fmt + clippy -D warnings clean; 514 unit + 544
integration tests green; 270 frontend tests green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CBK1RdtzyP6759Muqe1K1w
Every change is benchmark-verified (harness + before/after numbers in
benches/, measured on this branch; reproduction commands in each doc):
DAV / sync-client hot paths
- PROPFIND dead-properties: one = ANY($1) query per 500-child page instead
of one sequential query per child, and indexable `=` predicates instead
of IS NOT DISTINCT FROM (seq scans). 2,000-child folder: 1.07-4.54 s of
DB chatter -> 4-6 ms (258-773x). Applied to native + NC PROPFIND and
both NC REPORT handlers. [benches/DEAD-PROPS.md]
- Folder paging: keyset cursor (name > $last) + new partial index
(folder_id, name) replaces LIMIT/OFFSET full-folder rescan per page.
Full 20k-file walk: 1266 ms -> 77 ms (16.5x). New migration
20260917000000. [benches/PROPFIND-PAGING.md]
- NC chroot / default-drive resolution: moka caches (30 s TTL, explicit
invalidation on drive mutations) for find_default_for_user and the
markerless chroot FolderDto. 2 uncached queries + 2 pool checkouts per
NC/WebDAV/WOPI request -> sub-us moka hit (p50 0.7-3.6 ms -> ~1 us).
[benches/CHROOT-CACHE.md]
- Quota: PROPFINDs whose prop list never names a quota prop skip the
2-query resolution entirely (wants_quota()); the remaining lookups read
2 columns instead of the full auth.users row with its <=512 KiB avatar
(11-16x, p50 3.4 ms -> 0.29 ms). Same narrow read now gates every
upload quota check. [benches/QUOTA-PATH.md]
CPU on the request path
- ZIP exports (folder download, share ZIP, batch download): entries whose
MIME says already-compressed (JPEG/MP4/zip/pdf/...) are Stored instead
of Deflate - deflate ran inline on the tokio writer task at ~41 MB/s
for ~0% size gain. Mixed media corpus: 4.31x wall and CPU, archive size
unchanged. Shared predicate in common::mime_detect. [benches/ZIP-MEDIA.md]
- Compression layers: tower-http's default maps to Brotli QUALITY 11
(verified in brotli-8.0.2 source and empirically: 90 ms per 64 KiB JSON
response, 1.3 s per 700 KiB bundle). Both layers pinned to Precise(4):
99x less CPU for ~15% more bytes. SPA assets are now precompressed at
build time (scripts/precompress.mjs, 77% smaller) and served via
ServeDir::precompressed_br/gzip: 2016x less per-request work, and
clients get the better q11 bytes. [benches/STATIC-PRECOMPRESSED.md]
Batched / cached backend paths [benches/NPLUS1-AND-CACHES.md]
- Content-search ReBAC re-verification: new
AuthorizationEngine::check_files_read_batch (default = old loop;
PgAclEngine override batches drive resolution + reuses role cache).
200 sequential point SELECTs per search -> 1-2 queries.
- Batch-ZIP subtree downloads: drop per-file re-authz + per-file Recent
recording (2 writes/file) for subtree entries already authorized at the
root - mirrors the native folder-download path. ~6,000 statements
removed from a 2,000-file archive.
- CDC chunk manifests: immutable by content address, now moka-cached
(weight-bounded 32 MiB, 60 s TTL, positive-only, invalidated on delete)
- removes one manifest query (p50 0.44-4.4 ms) from every stream,
range and full blob read.
- People tab: grouped COUNT + batched cover lookup instead of dragging
every face row with its 2 KiB embedding (10k faces: 30.4 ms & 21 MB ->
3.8 ms & 1.3 KB, 8.1x); merge() is one set-based UPDATE.
[benches/PEOPLE-LIST.md]
- Photos timeline cursor: raw timestamptz comparison instead of
EXTRACT(EPOCH ...) wrapper + IS NULL OR disjunction - cursor is an
index boundary again, deep scroll stops re-scanning skipped rows.
- Public share landing: one atomic UPDATE ... access_count + 1 (was
SELECT + full-row write-back: racy, lost updates, clobbered concurrent
owner edits) - 3 round-trips -> 2 per visit.
- move_to_trash: dead full-entity SELECT feeding a documented no-op
removed from both branches; dead fields dropped from TrashService.
- NFC normalization: is_nfc_quick fast path skips the decompose/recompose
state machine for the ~100% already-NFC case (every row loaded from PG).
Frontend
- Large folders paint after page one (~200 items) via fetchFolderListing's
new onPage hook instead of waiting for every sequential page.
- Tested-and-reverted (kept for the record): cached Intl.Collator for name
sorts - vitest showed it 2x SLOWER than V8's argument-less localeCompare
fast path (5.6 ms vs 12.1 ms / 5k names). Sort order untouched.
New bench harnesses under examples/ (bench feature): zip_media,
dead_props, chroot_cache, quota_path, people_list, propfind_paging,
static_precompress.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CBK1RdtzyP6759Muqe1K1w
- add resource kind filter (file, folder, drive) in shared section (localStorage stored)
- add user preferences serverside store
- add client side dotfile filter (show/hide dotfiles) (user perf stored, default: dotfiles are shown)
for security trashed dotfile are always displayed
protection added: if a folder has only hidden items, a notification invite user to display it
if a user rename or create a hidden item, a notification tells it to user
this fix the nextcloud login + drive selector (chroot)
fix also invitation / magic link
also correct the UX: once user has logged in nextcloud, show an explicita page
The ko.json locale existed but was missing 377 of 1392 keys (~27%),
covering entire feature areas added since the initial translation:
admin plugin management, storage/OIDC/SMTP settings, photos, music
playlists, device pairing, search filters, share dialogs, and more.
Filled in all missing keys with natural Korean translations matching
the existing tone and terminology in the file (파일/폴더/공유/업로드
등), verified full key parity with en.json (1392/1392) and matching
{{placeholder}} interpolation tokens on every translated string.
- fix issue with selected drive and user logout/login via another user
(was raising a "404 not found")
- normalize all localStorage to "oxi-" prefix
- add a specific frontend/AGENTS.md for frontend part (stop increasing the global AGENTS.md)
add `include_in_photo_index` and `include_in_music_index` policies
both true for default personal drive
photo is implemented
music is not yet implemented
normalize environment to avoid such issues during tests
on non EN local machine:
```
AssertionError: expected 'il y a 2 ans' to match /year/
❯ src/lib/utils/time.test.ts:24:60
22|
23| it('formats past times in the largest matching unit', () => {
24| expect(relativeTimeAgo(Date.now() - 2 * 31_536_000_000)).toMatch(/year/);
```
The e2e CI job ran the legacy `scenarios/*` specs against the vanilla `static/`
frontend that upstream has since removed, so it could never pass. Point CI at
this repo's SvelteKit SPA suite (tests/e2e/spa) and wire up what it needs:
- CI: build the release binary with `--features plugins` (the admin Plugins-tab
specs exercise the WASM runtime) and run `npm run test:coverage`, building the
instrumented SPA with COVERAGE=1 VITE_E2E=1 so the server serves the
data-testid-instrumented build the specs drive.
- Coverage harness: target 127.0.0.1 instead of `localhost` (which resolves to
::1 first on CI runners while the server binds IPv4, so readiness never
connected) and poll `/ready` for webServer readiness; tee start-server-spa.sh
output to a log surfaced by an always-run CI step for diagnostics.
- Files page: restore a persistent breadcrumb home link (buildCrumbs returns
only the path folders, so there was no "go home" affordance), and fix the
`?file=` deep-link race where the viewer→URL effect stripped the param before
the listing loaded — a bookmarked preview link now opens the viewer.
All 101 spa specs pass locally.
Add an end-to-end and unit test suite for the SvelteKit frontend:
- Playwright e2e specs (tests/e2e/spa) with a throwaway container stack,
codegen scenarios, and an Istanbul-based coverage report pipeline.
- Vitest unit tests across API endpoints, components, stores and composables.
- `data-testid` hooks on interactive elements (AppShell, FileViewer,
ShareDialog, search, photos, files breadcrumbs, login/Nextcloud flows,
public share pages) so the e2e suite can target them deterministically.
- Serve the SPA app-shell CSP from a <meta> policy (svelte.config.js) plus a
middleware that skips the CSP header on HTML; move the Nextcloud Login Flow
v2 grant page to the SvelteKit /nextcloud/login route.
- `just front-codegen` recipe and start-server-spa.sh harness.
Make the test environment robust and consistent:
- Install a deterministic in-memory localStorage/sessionStorage in the Vitest
setup so storage behaves identically across Node versions (Node 26 ships a
native Web Storage global that otherwise shadows jsdom's).
- Pin devenv to Node 26 + PostgreSQL 18 and pin every CI job to Node 26.3.0
so the dev shell and CI run the same toolchain versions.
Repair the API/WebDAV (hurl) suite, which had drifted from the backend:
- Migrate the removed `/api/folders/{id}/listing` endpoint to `/resources`
(cursor-paginated `{items:[{resource_type,resource}]}` shape) across the
batch-copy, grants, nested-group, and WebDAV NC tests + the dav_helpers
wipe routine.
- Stop photos_etag from uploading the dedup-tracked fixture so the dedup
blob-lifecycle test can own its content-addressed blob exclusively.
- dedup_create now asserts the idempotent same-content re-upload (201 +
existing file id) instead of the stale 409 expectation.
Generated coverage reports, nyc output and the e2e server runtime data dir
are gitignored rather than committed.
Videos now get a thumbnail generated eagerly server-side on upload, through
the same WebP/blob-hash pipeline as photos — instead of the old browser path
that only ran when the Photos grid first rendered a video tile, re-downloaded
the whole video to seek a frame, and PUT 3 JPEGs back (and produced nothing at
all for HEVC/.mov, which a browser <video> cannot decode).
- New VideoFramePort (application) + FfmpegVideoFrameService / NoopVideoFrameService
(infrastructure): shell out to the system ffmpeg (no compile-time libav dep),
extract one representative frame as PNG, bounded by its own semaphore + a
per-process timeout + kill_on_drop. Noop when ffmpeg is absent/disabled, so
videos degrade gracefully to no thumbnail.
- ThumbnailRefreshHook.on_file_created routes video/* to
generate_video_thumbnails_background: stream the (decrypted, reassembled) blob
to a size- and time-bounded temp file on the data volume, extract a frame, and
reuse the shared render_and_persist_all_webp helper — so video thumbnails are
WebP, blob-hash keyed (dedup'd) and content-negotiated, exactly like photos.
- GET thumbnail serves the video's WebP to every client (byte-sniffed
Content-Type); a genuine miss returns 204.
- Config: OXICLOUD_ENABLE_VIDEO_THUMBNAILS (default true, needs ffmpeg detected
at startup) + OXICLOUD_FFMPEG_PATH / _CONCURRENCY / _TIMEOUT_SECS / _MAX_MB.
- Dockerfile installs ffmpeg in the runtime image.
- Frontend: drop the client-side generateVideoThumb/frameFromVideo re-download
path; the server is now the source of truth.
Benchmark (examples/bench_video_thumbnails.rs, needs ffmpeg): 4/4 codecs incl.
HEVC/.mov produce a thumbnail server-side (was 0% for HEVC); ~50-70 ms/frame in
the background; ~3.9 KB preview WebP; up to ~23x less per-first-view transfer on
the test corpus (far more on real multi-MB clips). Methodology in
benches/VIDEO-THUMB.md.
Hardening from an adversarial review: video render holds the decode_semaphore
like the image path; the ffmpeg scale filter bounds both dimensions; the blob
stream has a timeout; the temp file lives on the data volume; the size cap uses
saturating_mul.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ShareDialog (~15 KB JS) and MoveDialog (~5 KB JS) were statically imported by the
files, favorites, recent and shared routes, so they downloaded on every visit
even if the user never opened a share/move dialog. Convert them to the existing
lazyComponent pattern (as already used for FileViewer/WopiEditor): the chunk is
fetched the first time the dialog is opened.
The Vite manifest confirms both flip from static to isDynamicEntry. This defers
~26 KB raw / ~9.5 KB gzipped (JS + CSS) off the initial load of those four routes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Checkpoint of the in-progress frontend toolchain work (Vite pinned to ^6 after
the 7/8 rolldown build break, eslint-plugin-svelte v3 navigation/reactivity
fixes, CI/Dockerfile/manifest updates) together with three UI performance
optimizations (verified on the Vite 6 build):
- Critical CSS: move auth.css/music.css off the global path into their route
chunks (login/device/nextcloud-login, music) -- -25% gzipped critical CSS
(~5.4 KB) on every non-auth/non-music page load.
- relativeTimeAgo: cache the Intl.RelativeTimeFormat (was rebuilt per call, once
per row per render) -- 22.7x faster date formatting in large lists.
- Virtualize search results and grouped trash (list view) via VirtualList -- DOM
rows mounted stay ~constant (~27) instead of O(N) (94.6% fewer for 500 hits).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
OIDC SSO login intermittently ended on a 403 "Invalid or expired OIDC state
— possible CSRF attack" even though the login had already succeeded
server-side.
Root cause: the (now-removed) legacy vanilla-JS frontend registered a
`/sw.js` service worker that, with navigation preload enabled, double-fetched
the top-level navigation to `/api/auth/oidc/callback`. The OIDC `state` is
single-use, so the first callback consumed it and logged the user in while
the duplicate (~0.4s later) found the state gone and returned the 403 the
browser rendered.
Backend — idempotent callback: after a successful web login, remember
`state -> exchange_code` in a short-lived (120s) cache. A duplicate callback
whose state was already consumed now replays that same redirect instead of
403-ing, returning the cached result directly without re-running the IdP code
exchange (the authorization `code` is single-use too). Keyed by the
unguessable 32-byte state, so it adds no new attack surface and fixes the 403
for everyone — including browsers still running a stale legacy service worker.
Frontend — evict the stale worker: the current SvelteKit app registers no
service worker, so fresh clients can't double-fire. But a browser that
previously loaded the legacy frontend still has `/sw.js` registered and
controlling pages (and `/sw.js` now 404s, so vendor self-cleanup is
inconsistent). killLegacyServiceWorker() runs first in the root layout's
onMount: it surgically unregisters only `/sw.js` workers, drops only the
legacy `oxicloud-cache-*` caches, and reloads once (guarded).
Fixes#510.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two robustness fixes behind the recurring "folder upload stuck at ~93%" reports.
1. Skip non-regular files up front. A copied s6/runit service tree contains
FIFOs (e.g. supervise/control named pipes) that report a size but BLOCK
FOREVER when the browser reads them — the deterministic ~8-files-short that
no retry/watchdog tweak could fix. uploadBatch/uploadTree now probe each
file's first chunk against a 3 s timeout (partitionReadable), upload only the
readable ones, and report the rest: "N uploaded · M skipped (not regular
files)". Progress runs over the uploadable count, so it reaches 100% instead
of parking at 93% while a lane hangs on a pipe.
2. Auto-reload on a new deploy. svelte.config.js polls _app/version.json
(60 s); the root layout reloads itself when the deployed build changes —
unless an upload is in flight — so an open tab can't keep running stale code
after a rebuild (the recurring "my fix isn't applied" trap).
npm run check: 0 errors, 58 tests pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The legacy-frontend removal dropped the deprecated /api/folders/{id}/listing
route, but folders.ts still called it, so every folder view 404'd
("listing failed: 404"). Complete the migration: fetchFolderListing now pages
through the cursor-paginated /api/folders/{id}/resources feed and rebuilds the
combined {folders, files} listing the views expect.
- Pages through next_cursor (limit 200) and splits mixed resource items by
resource_type. 403 still throws; the 304/ETag fast-path is gone (that feed has
no whole-listing ETag) so the in-memory folderCache is the only revalidation.
- Favorite/share badge sets aren't carried by /resources, so they come back
empty for now (no star / share badge until wired from /favorites + /shares).
- folders.test.ts updated to the paginated shape.
npm run check: 0 errors. 58 tests pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>