Skip to content

perf(bookmarks): answer the wishlist from one aggregation - #112

Merged
udaycodespace merged 1 commit into
udaycodespace:mainfrom
MOHITKOURAV01:perf/107-saved-courses-query
Aug 27, 2026
Merged

perf(bookmarks): answer the wishlist from one aggregation#112
udaycodespace merged 1 commit into
udaycodespace:mainfrom
MOHITKOURAV01:perf/107-saved-courses-query

Conversation

@MOHITKOURAV01

Copy link
Copy Markdown
Contributor

Summary

GET /api/bookmarks read the user's entire bookmark collection, populated a course for every row, and then filtered, sorted, counted and sliced in Node — to return a page of twelve. This replaces it with one aggregation.

Related Issue

Closes #107

What changed?

  • backend/utils/bookmarkListing.js (new) — query validation, the expressions for availability / access type / numeric price / search text, the pipeline builder, and the row shaper. 17 exports, all unit tested.
  • backend/controllers/courseBookmarkController.jsgetSavedCourses becomes parse → build → run → respond. serializeCourse, parsePrice, isPaidCourse and two local helpers are gone; the pipeline expresses what they did. The other four handlers are untouched.
  • backend/tests/bookmark-listing.test.js (new) — 40 tests, pure and end-to-end.
  • docs/issue-107-saved-courses-query.md (new) — write-up.

No frontend change. The response body is byte-compatible, rejection messages included, and there is a test asserting the key set.

The pipeline

$match the user on the indexed { userId: 1, createdAt: -1 }$lookup the course projecting eight fields rather than a document → $unwind preserving rows whose course is gone → $addFields availability, access type, numeric price and search text → $facet { rows, total, categories }.

The load mattered more than the row count suggests: BookmarksProvider calls this on mount and after every clear, and SavedCourses.jsx re-runs it on each learnhub:bookmark-change event — so saving five courses in a row meant five full reads.

Two details worth a reviewer's attention

The category list is deliberately not filtered

categories populates the category dropdown, and the old code built it from all the user's bookmarks, before any filter ran. Building it from the filtered set would remove every other option from the control the user just used, with no way back.

So the $facet sits before the filter stages, and the filters run inside the rows and total branches only. There is a test asserting the list is identical filtered and unfiltered.

This does mean the $lookup is bounded by the user's bookmark count rather than by the page — the category list cannot be computed without the join. That is inherent to the feature, not to this implementation. Everything else (filtering, sorting, counting, slicing, shaping) now happens in the database, and the join returns eight projected fields rather than whole course documents.

aggregate() does not cast ids

find() casts a string id against the schema; aggregate() does not, and getUserId returns req.user._id.toString(). $match: { userId: "<hex>" } against an ObjectId field matches nothing — silently, as an empty wishlist rather than as an error. My first run of the new tests failed on exactly this; the cast is now explicit, with a test that a real token's string id still finds the user's bookmarks.

Type

  • Bug fix
  • New feature
  • Refactor
  • Docs only
  • Tests
  • Config / workflow
  • Security
  • Breaking change

Areas touched

  • Frontend
  • Backend
  • Database
  • Docs
  • Workflow / GitHub Actions
  • Config / environment

Testing

  • Tested locally
  • Build passes
  • Lint passes
  • Tests added or updated
  • Docs only, no runtime testing needed

cd backend && npm test354 pass, 0 fail (314 before, 40 added).

There is no lint script in backend/; the box is left unticked rather than ticked falsely.

Test steps

  1. Sign in as a student and bulk-insert a large wishlist:
    const bulk = [];
    for (const courseId of allCourseIds) bulk.push({ userId, courseId, createdAt: new Date(), updatedAt: new Date() });
    db.coursebookmarks.insertMany(bulk);
    db.setProfilingLevel(2);
  2. Open /saved-courses → it responds instead of hanging.
  3. Read db.system.profile: the query for a twelve-row page now shows a $limit stage. Previously it reported the user's full bookmark count with no limit at all.
  4. Filter by access or category and page through → pagination.totalItems counts every match, not the twelve on screen.
  5. Search for a course that is not on the current page → it is found. Previously the filter only saw rows already in memory.
  6. Pick a category from the dropdown → every other category is still listed.
  7. Delete a saved course from the admin dashboard, reload the wishlist → the card is still there, marked unavailable, and the Unavailable filter finds it.

Screenshots

  • Not needed
  • Added below

The wishlist renders identically; the change is what it costs to render.

Edge cases checked

  • Empty or missing data
  • Loading / slow response
  • API failure / server error
  • Rate limit / throttling
  • Invalid or unexpected input
  • Permission / access denied
  • Partial or inconsistent data
  • Mobile / small screen behavior
  • Other

Other edge case details

  • Deleted courses$unwind with preserveNullAndEmptyArrays, or the row would vanish instead of showing as unavailable, which is the entire point of the deleted availability filter. Tested both ways round.
  • Cross-user isolation — a test asserts one student's token returns 0 rows against another student's populated wishlist.
  • Regex injectionescapeRegex stays on both the search and the category path; there is a test that ?search=( returns no rows rather than a 500.
  • Unstable page boundaries — every sort now ends in _id. Two courses saved in the same millisecond (ordinary — the wishlist saves in bursts) could otherwise swap places between requests and make a card appear on both page one and page two, or on neither.
  • Title sorting — runs with { locale: "en", strength: 2 } so it behaves like the localeCompare it replaces rather than sorting every uppercase title first. Only the two title sorts pay for the collation.
  • Grouped prices"1,299" reads as 1299, not 1. Tested through the price sort.
  • Page past the end — detected from the count and re-run once, returning the last page.
  • Empty wishlist — an empty page with an empty category list, not NaN and not an error.

Checklist

  • Read CONTRIBUTING.md
  • Linked the issue
  • Assigned before starting or approved by maintainer
  • Changes are focused on one issue
  • No debug logs or unused code
  • Documentation updated if needed
  • No new warnings or console errors
  • Changes are meaningful, not trivial

Notes

isPaidCourse was /\d/.test(...) — any digit anywhere. That loose rule is kept rather than tightened: changing it would silently move courses between the free and paid filters on data users have already saved. ACCESS_EXPRESSION reproduces it exactly.

No new index was needed: courseBookmarkModel already carries { userId: 1, createdAt: -1 }, which covers the leading $match and the default sort.

$lookup needs a collection name, not a model, so it is read off Course.collection.name rather than hardcoded — a change to Mongoose's pluralisation cannot silently make the join return nothing.

getSavedCourses read the user's entire bookmark collection on every request,
populated a course for each row, and then filtered, sorted, counted and sliced in
Node — to return a page of twelve. serializeCourse and its per-row regex ran over
every saved course to produce those twelve, the category list was rebuilt by
walking all of them into a Set, and title-asc called localeCompare across the
full list.

The load was multiplied by how the client uses it: BookmarksProvider calls the
endpoint on mount and after every clear, and SavedCourses re-runs it on each
bookmark-change event, so saving five courses in a row meant five full reads.

It is the same defect udaycodespace#96 fixed for the admin user and course lists.

One pipeline now: match the user on the indexed userId, look up the course
projecting eight fields rather than a document, keep the rows whose course is
gone, add the computed availability, access type, numeric price and search text,
then facet the rows, the count and the category list.

The category list stays deliberately unfiltered — it populates the dropdown, and
building it from the filtered set would remove every other option from the
control the user just used. The facet therefore sits before the filter stages,
which run inside the rows and total branches only.

Also casts the user id explicitly. find() casts a string id against the schema
and aggregate() does not, so matching the token's string id against an ObjectId
field would have read as an empty wishlist rather than as an error.

The response body is unchanged, rejection messages included, so the wishlist page
needed no edit.
@udaycodespace
udaycodespace self-requested a review August 27, 2026 06:50
@udaycodespace udaycodespace added ECSoC26 Required label for a PR to be eligible for Sentinel scoring good-pr PA-awarded bonus for an exceptionally executed PR — +15 XP and removed documentation backend fullstack tests labels Aug 27, 2026
@udaycodespace

Copy link
Copy Markdown
Owner

@MOHITKOURAV01 LGTM! Looks good to me. Backend tests are passing (354/354).

Approved.

@udaycodespace udaycodespace added the good-backend PA-awarded bonus for outstanding backend work — +50 XP label Aug 27, 2026
@udaycodespace
udaycodespace merged commit b9d135c into udaycodespace:main Aug 27, 2026
2 of 12 checks passed
@ecsoc-sentinel ecsoc-sentinel Bot added the ECSoC26-L3 Difficult, auto-assigned by Sentinel — 15 points label Aug 27, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ECSoC26-L3 Difficult, auto-assigned by Sentinel — 15 points ECSoC26 Required label for a PR to be eligible for Sentinel scoring good-backend PA-awarded bonus for outstanding backend work — +50 XP good-pr PA-awarded bonus for an exceptionally executed PR — +15 XP

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Performance]: GET /api/bookmarks reads every saved row, populates each one, and pages in Node

2 participants