Skip to content

feat(jef-117): extend backend caching to User, Skill, Education, and WorkExperience - #289

Merged
mankatcheung merged 2 commits into
mainfrom
feat/jef-117-extend-backend-caching
Aug 8, 2026
Merged

mankatcheung merged 2 commits into
mainfrom
feat/jef-117-extend-backend-caching

Conversation

@mankatcheung

@mankatcheung mankatcheung commented Aug 8, 2026

Copy link
Copy Markdown
Owner

Summary

  • Wraps four previously-uncached repositories in the same cache-aside pattern already used for notes/interview rounds/applications/etc: User, Skill, Education, WorkExperience.
  • CachedUserRepository caches findById and findByEmail. findById backs UserResolver.getMe(), which the web app calls on effectively every authenticated page load — the single most-read row in the system after the ApiToken hash lookup from JEF-116.
    • A user has two natural cache keys (id and email), and email can change via update() (email-change flow), so invalidation tracks an id → email map — update() clears both the old and new cached email entries, not just whichever is convenient.
    • create() also defensively invalidates the target email's cache entry, since RegisterUseCase/OAuth account linking call findByEmail() first to check for a duplicate and may have cached a stale "not found" result that would otherwise hide the freshly-created user.
    • findAll() stays uncached — it's a once-a-week batch read from the digest cron, not a hot path.
    • updateLastDigestSentAt() invalidates userById after delegating, since it changes a cached field (unlike ApiToken.updateLastUsed from JEF-116, this only fires once a week per user, so invalidating costs nothing).
  • CachedSkillRepository, CachedEducationRepository, CachedWorkExperienceRepository cache findAllByUserId/findById — direct ports of CachedNoteRepository, same per-owner child-entity shape as Notes/Contacts, just keyed by userId.
  • Raw Drizzle repos are re-registered in the DI container under drizzleUserRepository/drizzleSkillRepository/drizzleEducationRepository/drizzleWorkExperienceRepository. The outward-facing Cradle keys (userRepository/skillRepository/educationRepository/workExperienceRepository) are unchanged, so no consumer/resolver/use-case files needed any edits.
  • New CACHE_KEYS entries: userById, userByEmail, skillById, skillList, educationById, educationList, workExperienceById, workExperienceList.

Implements the plan from JEF-117. Independent of JEF-116/#287 (this branch is based on main, not stacked on that PR).

Test plan

  • pnpm --filter @job-finder/api typecheck — clean
  • pnpm --filter @job-finder/api test — 1063/1063 pass
  • pnpm --filter @job-finder/api lint — clean (one pre-existing unrelated warning in schema.ts)
  • New dedicated test coverage for all four cached repositories, including a dedicated test proving CachedUserRepository.update() invalidates the old cached email, not just the new one

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added caching for user profiles, skills, education, and work experience lookups.
    • Repeated requests can now return faster results, including when records are missing.
    • Cache entries are refreshed automatically after records are created, updated, or deleted.
    • Updated application configuration to use the new caching across relevant data access.
  • Tests

    • Added comprehensive automated coverage for cache reuse, invalidation, separate user data, and edge cases.

…WorkExperience

Wraps four previously-uncached repositories in the same cache-aside
pattern already used for notes/interview rounds/applications:

- CachedUserRepository caches findById and findByEmail — findById
  backs UserResolver.getMe(), which the web app calls on effectively
  every authenticated page load. Since a user has two natural cache
  keys (id and email) and email can change via update(), invalidation
  tracks an id→email map so update() can clear both the old and new
  cached email entries, not just whichever is convenient. create()
  also defensively invalidates the target email's cache, since
  RegisterUseCase/OAuth linking call findByEmail() first to check for
  duplicates and may have cached a stale "not found" result.
  findAll() stays uncached — it's only a once-a-week batch read in the
  digest cron, not a hot path.
- CachedSkillRepository, CachedEducationRepository,
  CachedWorkExperienceRepository cache findAllByUserId/findById,
  direct ports of CachedNoteRepository — same per-owner child-entity
  shape as Notes/Contacts, just keyed by userId.

Raw Drizzle repos for these four are re-registered under
drizzleUserRepository/drizzleSkillRepository/drizzleEducationRepository/drizzleWorkExperienceRepository
in the DI container so the cached decorators can wrap them, while the
outward-facing Cradle keys (userRepository/skillRepository/educationRepository/workExperienceRepository)
are unchanged — no consumer/resolver files needed edits.
@coderabbitai

coderabbitai Bot commented Aug 8, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@mankatcheung, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 47 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: a8ea0713-106e-4082-8a80-cd237d35784c

📥 Commits

Reviewing files that changed from the base of the PR and between 39baa72 and 0105543.

📒 Files selected for processing (4)
  • apps/api/src/__tests__/infrastructure/cache/CachedUserRepository.test.ts
  • apps/api/src/constants.ts
  • apps/api/src/http/container.ts
  • apps/api/src/infrastructure/db/repositories/CachedUserRepository.ts

Walkthrough

Added cache-aside decorators for four repositories. Added cache-key builders, mutation invalidation, ownership tracking, container wiring, and comprehensive Vitest coverage.

Changes

Repository caching

Layer / File(s) Summary
Cache keys and user caching
apps/api/src/constants.ts, apps/api/src/infrastructure/db/repositories/CachedUserRepository.ts, apps/api/src/__tests__/infrastructure/cache/CachedUserRepository.test.ts
Added user cache keys and cached ID and email lookups. Mutations invalidate ID, email, and stale email entries. findAll remains uncached.
Skill, education, and work-experience caching
apps/api/src/infrastructure/db/repositories/CachedSkillRepository.ts, apps/api/src/infrastructure/db/repositories/CachedEducationRepository.ts, apps/api/src/infrastructure/db/repositories/CachedWorkExperienceRepository.ts, apps/api/src/__tests__/infrastructure/cache/CachedSkillRepository.test.ts, apps/api/src/__tests__/infrastructure/cache/CachedEducationRepository.test.ts, apps/api/src/__tests__/infrastructure/cache/CachedWorkExperienceRepository.test.ts
Added cached list and record lookups. Create, update, and delete operations delegate to the inner repositories and invalidate affected caches.
Raw and cached repository registration
apps/api/src/http/container.ts
Separated raw Drizzle repositories from cached repository properties in Cradle. Registered cached decorators for all four repository types.

Estimated code review effort: 4 (Complex) | ~60 minutes

Possibly related issues

Possibly related PRs

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant CachedUserRepository
  participant Cache
  participant DrizzleUserRepository
  Client->>CachedUserRepository: findById or findByEmail
  CachedUserRepository->>Cache: read lookup key
  CachedUserRepository->>DrizzleUserRepository: fetch on cache miss
  DrizzleUserRepository-->>CachedUserRepository: return user or null
  CachedUserRepository->>Cache: store lookup result
  CachedUserRepository-->>Client: return user or null
Loading

Poem

A rabbit hops through cached rows,
With fresh keys neatly placed.
Old entries flee when records change,
While nulls are stored in haste.
The inner store still does the work—
And tests keep watch in case!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: extending backend caching to four repository types.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 2
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
⚔️ Resolve merge conflicts 💡
  • Resolve merge conflict in branch feat/jef-117-extend-backend-caching
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/jef-117-extend-backend-caching

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (2)
apps/api/src/__tests__/infrastructure/cache/CachedUserRepository.test.ts (1)

146-160: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Rename this test to match its assertion.

The name states "does not redundantly invalidate the email cache", but the assertion confirms the opposite: the email entry is invalidated and the next read reaches the inner repository. The inline comment already notes this. Rename the test to describe the checked behaviour, for example "invalidates the email cache even when the email is unchanged".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/src/__tests__/infrastructure/cache/CachedUserRepository.test.ts`
around lines 146 - 160, Rename the test case around repo.update and the
subsequent findByEmail call to describe that the email cache is invalidated even
when the email is unchanged, matching the existing assertion and inline comment.
apps/api/src/infrastructure/db/repositories/CachedUserRepository.ts (1)

12-14: 🚀 Performance & Scalability | 🔵 Trivial | ⚖️ Poor tradeoff

Bound the ownership-tracking maps in the four cached decorators.

Each new decorator keeps an auxiliary Map that records ownership so delete() can invalidate the correct list or email key. The MemoryCache entries expire after the TTL, but these maps have no TTL. Entries are added on every cache miss and removed only by delete(). The container registers all four decorators as Lifetime.SINGLETON at apps/api/src/http/container.ts lines 503-508, so the maps live for the whole process and grow with the number of distinct records the API has served.

The same pattern already exists in CachedApplicationRepository and CachedNoteRepository, so this is not a regression introduced here. Consider a shared bounded structure, for example an LRU map with a size cap, or delete the map entry when the corresponding cache key expires. A size cap is safe because a missing map entry only skips a list invalidation that the TTL will resolve.

  • apps/api/src/infrastructure/db/repositories/CachedUserRepository.ts#L12-L14: bound emailByUserId.
  • apps/api/src/infrastructure/db/repositories/CachedSkillRepository.ts#L16-L17: bound userIdBySkillId.
  • apps/api/src/infrastructure/db/repositories/CachedEducationRepository.ts#L16-L17: bound userIdByEducationId.
  • apps/api/src/infrastructure/db/repositories/CachedWorkExperienceRepository.ts#L16-L17: bound userIdByWorkExperienceId.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/src/infrastructure/db/repositories/CachedUserRepository.ts` around
lines 12 - 14, Bound the ownership-tracking maps used by the four cached
decorators so they cannot grow without limit, using a shared or equivalent
capped/LRU structure while preserving existing invalidation behavior. Update
emailByUserId in
apps/api/src/infrastructure/db/repositories/CachedUserRepository.ts (lines
12-14), userIdBySkillId in
apps/api/src/infrastructure/db/repositories/CachedSkillRepository.ts (lines
16-17), userIdByEducationId in
apps/api/src/infrastructure/db/repositories/CachedEducationRepository.ts (lines
16-17), and userIdByWorkExperienceId in
apps/api/src/infrastructure/db/repositories/CachedWorkExperienceRepository.ts
(lines 16-17); missing ownership entries must safely skip the corresponding list
invalidation.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/api/src/infrastructure/db/repositories/CachedUserRepository.ts`:
- Around line 106-109: Update CachedUserRepository.updateLastDigestSentAt to
also invalidate the cached userByEmail entry using the email available through
emailByUserId, matching the cache invalidation performed by delete() and
update().

---

Nitpick comments:
In `@apps/api/src/__tests__/infrastructure/cache/CachedUserRepository.test.ts`:
- Around line 146-160: Rename the test case around repo.update and the
subsequent findByEmail call to describe that the email cache is invalidated even
when the email is unchanged, matching the existing assertion and inline comment.

In `@apps/api/src/infrastructure/db/repositories/CachedUserRepository.ts`:
- Around line 12-14: Bound the ownership-tracking maps used by the four cached
decorators so they cannot grow without limit, using a shared or equivalent
capped/LRU structure while preserving existing invalidation behavior. Update
emailByUserId in
apps/api/src/infrastructure/db/repositories/CachedUserRepository.ts (lines
12-14), userIdBySkillId in
apps/api/src/infrastructure/db/repositories/CachedSkillRepository.ts (lines
16-17), userIdByEducationId in
apps/api/src/infrastructure/db/repositories/CachedEducationRepository.ts (lines
16-17), and userIdByWorkExperienceId in
apps/api/src/infrastructure/db/repositories/CachedWorkExperienceRepository.ts
(lines 16-17); missing ownership entries must safely skip the corresponding list
invalidation.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 1dce6770-0a8c-453d-9ec6-4d1bb1e8c78c

📥 Commits

Reviewing files that changed from the base of the PR and between 68e4a8c and 39baa72.

📒 Files selected for processing (10)
  • apps/api/src/__tests__/infrastructure/cache/CachedEducationRepository.test.ts
  • apps/api/src/__tests__/infrastructure/cache/CachedSkillRepository.test.ts
  • apps/api/src/__tests__/infrastructure/cache/CachedUserRepository.test.ts
  • apps/api/src/__tests__/infrastructure/cache/CachedWorkExperienceRepository.test.ts
  • apps/api/src/constants.ts
  • apps/api/src/http/container.ts
  • apps/api/src/infrastructure/db/repositories/CachedEducationRepository.ts
  • apps/api/src/infrastructure/db/repositories/CachedSkillRepository.ts
  • apps/api/src/infrastructure/db/repositories/CachedUserRepository.ts
  • apps/api/src/infrastructure/db/repositories/CachedWorkExperienceRepository.ts

Comment on lines +106 to +109
async updateLastDigestSentAt(id: string, sentAt: Date): Promise<void> {
await this.inner.updateLastDigestSentAt(id, sentAt);
this.cache.delete(CACHE_KEYS.userById(id));
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Invalidate the byEmail entry in updateLastDigestSentAt.

update() clears both userById and userByEmail. updateLastDigestSentAt() clears only userById. A cached userByEmail entry therefore keeps a stale lastDigestSentAt until the TTL expires. The stored email is available in emailByUserId, so the fix is symmetric with delete().

🐛 Proposed fix
   async updateLastDigestSentAt(id: string, sentAt: Date): Promise<void> {
     await this.inner.updateLastDigestSentAt(id, sentAt);
     this.cache.delete(CACHE_KEYS.userById(id));
+    const email = this.emailByUserId.get(id);
+    if (email) this.cache.delete(CACHE_KEYS.userByEmail(email));
   }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
async updateLastDigestSentAt(id: string, sentAt: Date): Promise<void> {
await this.inner.updateLastDigestSentAt(id, sentAt);
this.cache.delete(CACHE_KEYS.userById(id));
}
async updateLastDigestSentAt(id: string, sentAt: Date): Promise<void> {
await this.inner.updateLastDigestSentAt(id, sentAt);
this.cache.delete(CACHE_KEYS.userById(id));
const email = this.emailByUserId.get(id);
if (email) this.cache.delete(CACHE_KEYS.userByEmail(email));
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/src/infrastructure/db/repositories/CachedUserRepository.ts` around
lines 106 - 109, Update CachedUserRepository.updateLastDigestSentAt to also
invalidate the cached userByEmail entry using the email available through
emailByUserId, matching the cache invalidation performed by delete() and
update().

…ackend-caching

# Conflicts:
#	apps/api/src/constants.ts
#	apps/api/src/http/container.ts
@mankatcheung

Copy link
Copy Markdown
Owner Author

Merged `main` in to resolve the conflict caused by JEF-116/#287 landing after this branch was cut — both touched `constants.ts` (additive, kept both sets of `CACHE_KEYS`) and `container.ts` (both renamed/added Cradle registrations; merged so all 7 cached repos from #287 plus the 4 from this PR coexist under their final `drizzleXxxRepository`/`CachedXxxRepository` names).

Also picked up `IUserRepository.findByBackupEmail` from JEF-36/#283 (also merged to main in the interim) — added it to `CachedUserRepository` as an uncached passthrough, since its only two call sites (backup-email recovery request, add-backup-email verification) are already rate-limited and low-frequency, not worth a third cache key.

Re-verified clean typecheck/lint, and the full test suite (1122/1124 — same 2 pre-existing flaky tests as before, both pass individually in isolation).

@mankatcheung
mankatcheung merged commit fc10538 into main Aug 8, 2026
11 checks passed
@mankatcheung
mankatcheung deleted the feat/jef-117-extend-backend-caching branch August 18, 2026 13:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant