Skip to content

Step-chain sync dedup + per-user rate limits - #1360

Merged
Asherlc merged 20 commits into
mainfrom
Asherlc/all-provider-sync-core
Jun 24, 2026
Merged

Asherlc merged 20 commits into
mainfrom
Asherlc/all-provider-sync-core

Conversation

@Asherlc

@Asherlc Asherlc commented Jun 24, 2026

Copy link
Copy Markdown
Owner

Summary

Add step-chain sync job dedup for Garmin/WHOOP and switch rate-limit tracking from provider-wide to per-user scope.

Core changes

  • SyncApiQuery type + syncApiQueryKey() — stable key for comparing provider API requests across queued jobs (sorted filter keys enforces determinism)
  • Resolver plugin system (registerSyncRequestQueryResolver) — providers register a function that maps job data → API query for dedup keying
  • Enqueue-time dedup (enqueueSyncJobWithRequestDedup) — checks for pending jobs by provider:user:queryKey job ID before adding
  • Step-plan-time dedup (planSyncStepIfRequestNotPending) — listPendingSyncRequestQueryKeys() scans active/waiting/delayed jobs and skips steps already queued
  • Per-user rate limits — Garmin/WHOOP moved createProviderRateLimitFetch from constructor (provider scope) to sync() call (user scope with userId)
  • Removed skipRemainingAfterRateLimit from WHOOP checkpoint; Garmin applyRateLimitToCheckpoint is now a no-op — both obsoleted by the dedup approach

Code quality fixes

  • Extract duplicated resolveScopedUserIdsrc/lib/user-context.ts
  • Replace side-effect imports with explicit registerProviderSyncRequestResolver() called alongside provider registration using provider.id
  • Add dofek/jobs/sync-request-query-registration to root package.json exports
  • Fix misleading docstring in garmin/sync-checkpoint.ts
  • Add collision-risk comment on SHA-256 job ID truncation
  • Add TODO comment about queue scanning performance

Summary by cubic

Adds request-level dedup for Garmin and WHOOP step-chain syncs and switches rate limiting to per-user scope to cut duplicate API calls and avoid cross-user throttling. Also extracts a reusable debounce hook with tests to prevent stale updates after unmount.

  • New Features

    • Request dedup with stable SyncApiQuery keys and job IDs; enqueue-time dedup checks getJob(), reuses pending jobs, and removes completed/failed before re-adding.
    • Step planners (Garmin/WHOOP) skip API steps already queued using listPendingSyncRequestQueryKeys().
    • Resolver system maps job data → API query; non-step providers dedup by sync window; resolvers registered at startup via dofek/jobs/sync-request-query-registration.
    • Web: extracted useDebouncedValue to packages/web/src/hooks with tests.
  • Refactors

    • Per-user rate limits for Garmin/WHOOP; rate-limit errors include scope and userId.
    • Garmin: auth login and token refresh use the rate-limit fetch; activities_list dedup keys by offset.
    • WHOOP: removed skipRemainingAfterRateLimit; non-rate-limit errors are captured.
    • Extracted resolveScopedUserId; BullMQ job IDs use -.
    • Added ./jobs/sync-request-query-registration to package exports.

Written for commit 7e97f3d. Summary will update on new commits.

Review in cubic

Summary by CodeRabbit

  • New Features
    • Provider-specific sync request resolution now generates deterministic request job IDs.
    • Sync request planning and enqueueing deduplicate by reusing existing pending/active jobs; completed/failed jobs are re-enqueued.
    • Resolver registration is wired during provider initialization.
    • Added package export for ./jobs/sync-request-query-registration.
  • Bug Fixes
    • Rate-limit errors now include user/provider scope details for Garmin and WHOOP.
    • Prevent scheduling steps when their API request is already pending.
    • Sync checkpoint behavior no longer trims remaining API steps after rate limiting; pending-resume is preserved.
    • Web debounced updates no longer set state after unmount.
  • Tests
    • Added/expanded unit tests for request deduplication, resolver registration, pending-query planning, and error scope handling.

Add sync request dedup for Garmin/WHOOP step-chain sync:
- Create SyncApiQuery type + stable key for comparing API requests
- Add resolver plugin system for provider-specific query mapping
- Dedup at enqueue time: check for pending jobs before adding
- Scan pending jobs when planning steps to skip already-queued API calls
- Switch Garmin/WHOOP rate limit tracking from provider-wide to per-user scope
- Remove skipRemainingAfterRateLimit from WHOOP checkpoint (obsoleted by dedup)
- Make Garmin applyRateLimitToCheckpoint a no-op (obsoleted by dedup)

Code quality fixes:
- Extract duplicated resolveScopedUserId to src/lib/user-context.ts
- Replace side-effect resolver registration with explicit function call
- Register resolvers alongside providers using provider.id
- Add dofek/jobs/sync-request-query-registration to package.json exports
- Fix misleading docstring in garmin/sync-checkpoint.ts
- Add collision-risk comment on SHA-256 job ID truncation
- Add TODO comment about queue scanning performance
@qodo-code-review

Copy link
Copy Markdown

Qodo reviews are paused for this user.

Troubleshooting steps vary by plan Learn more →

On a Teams plan?
Reviews resume once this user has a paid seat and their Git account is linked in Qodo.
Link Git account →

Using GitHub Enterprise Server, GitLab Self-Managed, or Bitbucket Data Center?
These require an Enterprise plan - Contact us
Contact us →

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Sorry @Asherlc, you have reached your weekly rate limit of 500000 diff characters.

Please try again later or upgrade to continue using Sourcery

@coderabbitai

coderabbitai Bot commented Jun 24, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds sync request query resolution, request deduplication, provider resolver wiring, Garmin and Whoop request planning, scoped rate-limit metadata, and a debounced web hook export.

Changes

Sync Request Deduplication and Scoped Rate Limits

Layer / File(s) Summary
Request query primitives
src/lib/sync-api-query.ts, src/lib/sync-request-query.ts, src/lib/sync-request-queue.ts, src/lib/user-context.ts, src/lib/*.test.ts, cspell.json
Defines sync API query keying, sync request query resolution, pending-query detection, scoped user resolution, and matching unit coverage.
Deduplicated sync enqueue
src/jobs/sync-request-job.ts, src/jobs/enqueue-sync-job.ts, src/jobs/*.test.ts, packages/server/src/mcp/route.test.ts
Adds request-aware enqueue logic that reuses active BullMQ jobs by deterministic job ID, removes stale jobs before re-enqueueing, and extends queue mocks for getJob-based behavior.
Provider resolver registration wiring
src/jobs/sync-request-query-registration.ts, src/jobs/provider-registration.ts, packages/server/src/routers/sync-helpers.ts, package.json, src/jobs/sync-request-query-registration.test.ts
Adds dynamic Garmin and Whoop sync-request resolver registration, invokes it during provider startup in both registration paths, and exposes the new subpath export.
Garmin request mapping and planning
src/providers/garmin/sync-api-query.ts, src/providers/garmin/sync-request-query.ts, src/providers/garmin/sync-step-plan.ts, src/providers/garmin/provider.ts, packages/garmin-connect/src/client.ts, src/providers/garmin*.test.ts
Adds Garmin sync-step to API-query mapping, resolves Garmin sync requests from checkpoints, filters pending request keys during step planning, updates provider rate-limit handling, and adds related tests and mocks.
Whoop request mapping, planning, and checkpoint changes
src/providers/whoop/sync-checkpoint.ts, src/providers/whoop/sync-orchestrator.ts, src/providers/whoop/provider.ts, packages/whoop-whoop/src/client.ts, src/providers/whoop*.test.ts
Updates the Whoop checkpoint schema and orchestration, maps Whoop sync steps to API queries, resolves Whoop sync requests from checkpoints, plans steps against pending request keys, and threads scoped rate-limit metadata through provider and client code.

Web debounce cleanup

Layer / File(s) Summary
Unmount-safe debounced value
packages/web/src/pages/ProviderDetailPage.tsx, packages/web/src/pages/ProviderDetailPage.test.tsx
Exports the debounce hook and guards the delayed state update against unmount.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • Asherlc/dofek#1313: Modifies enqueueSyncJob in the same job flow that this PR rewires for request deduplication.
  • Asherlc/dofek#1341: Touches the same WHOOP sync planning path that this PR refactors around pending request keys.
  • Asherlc/dofek#1349: Changes Garmin step-chain sync plumbing that overlaps with this PR’s Garmin query mapping and planning.

Suggested labels

area/server, area/web, area/providers, type/feature, breaking-change

🚥 Pre-merge checks | ✅ 1 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Title check ⚠️ Warning The title matches the change, but it is not in imperative mood and lacks the required area prefix. Rename it to an imperative, area-prefixed title, e.g. "[server] Add step-chain sync dedup and per-user rate limits".
✅ Passed checks (1 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


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.

@github-actions

github-actions Bot commented Jun 24, 2026

Copy link
Copy Markdown
Contributor

Storybook previews for a4be7a59 are ready:

This comment updates automatically on each PR push.

…message mismatch

- Change buildSyncRequestJobId separator from ':' to '-' (BullMQ rejects colons)
- Add createProviderRateLimitFetch wrapping in Garmin authSetup().automatedLogin()
- Update test expectations for shared resolveScopedUserId error message

@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: 8

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/providers/whoop/sync-orchestrator.ts (1)

458-463: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Report unexpected sync failures to Sentry before returning.

This branch still turns non-rate-limit exceptions into SyncResult.errors without captureException(err), so provider failures disappear from monitoring even though the sync continues. As per coding guidelines, "Always report errors to Sentry" and path instructions, "Never silently swallow errors — every unexpected catch must call captureException()."

🤖 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 `@src/providers/whoop/sync-orchestrator.ts` around lines 458 - 463, In the
non-rate-limit error path in sync-orchestrator’s unexpected exception handling,
the error is being added to SyncResult.errors without being reported to Sentry
first. Update the branch in the sync flow around the isWhoopRateLimitError check
to call captureException(err) before pushing to errors and returning, so
provider failures are always surfaced to monitoring. Use the existing err
handling in the catch block and keep the rate-limit checkpoint behavior
unchanged.

Sources: Coding guidelines, Path instructions

🤖 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 `@src/jobs/sync-request-job.test.ts`:
- Around line 1-72: The current tests cover request resolution and job ID
stability, but they miss the dedup fall-through handled by
enqueueSyncJobWithRequestDedup and the finished-job branch in
sync-request-job.ts. Add a test that mocks getJob returning a completed or
failed job and assert the re-enqueue path is taken, so the behavior of
enqueueSyncJobWithRequestDedup is pinned down. Use the existing sync-request-job
test suite and the enqueueSyncJobWithRequestDedup/getJob symbols to place the
new coverage alongside the current resolveWhoopSyncRequestQuery and
buildSyncRequestJobId tests.

In `@src/jobs/sync-request-job.ts`:
- Around line 17-28: The sync request job flow in syncRequestJob should not
reuse a BullMQ jobId that still exists in a completed or failed state, since
that can prevent addJob from enqueueing a new sync. In the existing getJob/
existing.getState duplicate-check path, remove the finished job before returning
to the fallback, or generate a fresh jobId when the state is in
DUPLICATE_REQUEST_JOB_STATES, so queue.add via addJob can create the new job.

In `@src/jobs/sync-request-query-registration.ts`:
- Around line 5-20: Add a colocated unit test for
registerProviderSyncRequestResolver to lock down the provider-id dispatch
behavior. Cover the supported branches in registerProviderSyncRequestResolver by
verifying that provider.id values for "garmin" and "whoop" load the correct
resolver modules and call registerSyncRequestQueryResolver with the expected
resolver, and add an unsupported-provider case that confirms no registration
happens. Mock the dynamic imports and the registerSyncRequestQueryResolver
dependency so the test is isolated and clearly exercises the wiring in
sync-request-query-registration.ts.

In `@src/lib/user-context.ts`:
- Around line 3-8: Add a colocated unit test for resolveScopedUserId() in
src/lib/user-context.test.ts to cover all three behaviors: returning the
explicit userId argument, falling back to getTokenUserId() when no argument is
provided, and throwing when neither source yields a value. Use the
resolveScopedUserId and getTokenUserId symbols to locate the helper, and mock
the token-context dependency so the fallback and missing-user paths are isolated
and deterministic.

In `@src/providers/garmin/provider.ts`:
- Around line 113-116: The token refresh path in Garmin provider is bypassing
the scoped rate-limit wrapper because `GarminConnectClient.fromTokens()` is
still being called with `this.#baseFetchFn` instead of the wrapped fetch used by
`sync()`. Update `#resolveTokens(..., fetchFn)` and the `fromTokens()` call in
`GarminProvider` so refresh requests use the same scoped `fetchFn` that tracks
adaptive 429s and can surface `GarminRateLimitError`. Keep the change consistent
across the token-resolution flow so user-scoped throttling applies to both
initial sync and token refresh.

In `@src/providers/garmin/sync-request-query.ts`:
- Around line 8-13: The sync-request resolution in sync-request-query should not
map a checkpoint with phase "done" to the same connectapi/activities request as
a fresh job, since that can collide with the no-checkpoint key. Update the logic
in the request builder to return null when checkpoint.phase === "done", while
keeping the existing fresh-job behavior for missing checkpoints. Use the
checkpoint handling branch in the query resolver to distinguish completed
step-chain jobs from new syncs.

In `@src/providers/garmin/sync-step-plan.test.ts`:
- Around line 318-320: The test is hardcoding the serialized pending-query key,
which duplicates the private format used by syncApiQueryKey() and makes the
assertion brittle. Update the pending set construction in sync-step-plan.test.ts
to derive the expected key from garminSyncStepToApiQuery() and syncApiQueryKey()
instead of embedding the literal string, so the test stays aligned with the
planner’s query-key serialization.

In `@src/providers/whoop/sync-step-plan.test.ts`:
- Around line 255-258: The pending sync key fixture in the sync-step-plan test
is hardcoded to a serialized query string, which tightly couples the test to the
current JSON ordering. Update the test to build the expected key using
syncApiQueryKey() so it matches the same contract used by the planner and stays
resilient to serialization changes. Use the shared key builder in the
pendingQueryMocks.listPendingSyncRequestQueryKeys setup instead of the literal
string.

---

Outside diff comments:
In `@src/providers/whoop/sync-orchestrator.ts`:
- Around line 458-463: In the non-rate-limit error path in sync-orchestrator’s
unexpected exception handling, the error is being added to SyncResult.errors
without being reported to Sentry first. Update the branch in the sync flow
around the isWhoopRateLimitError check to call captureException(err) before
pushing to errors and returning, so provider failures are always surfaced to
monitoring. Use the existing err handling in the catch block and keep the
rate-limit checkpoint behavior unchanged.
🪄 Autofix (Beta)

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: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: d9f7dc72-6fb4-49d2-ad5e-5e42ab62fd25

📥 Commits

Reviewing files that changed from the base of the PR and between be13979 and 3210986.

📒 Files selected for processing (36)
  • package.json
  • packages/garmin-connect/src/client.ts
  • packages/server/src/routers/sync-helpers.ts
  • packages/whoop-whoop/src/client.ts
  • src/jobs/enqueue-sync-job.test.ts
  • src/jobs/enqueue-sync-job.ts
  • src/jobs/process-scheduled-sync-job.test.ts
  • src/jobs/process-sync-job.test.ts
  • src/jobs/provider-registration.ts
  • src/jobs/sync-request-job.test.ts
  • src/jobs/sync-request-job.ts
  • src/jobs/sync-request-query-registration.ts
  • src/lib/sync-api-query.test.ts
  • src/lib/sync-api-query.ts
  • src/lib/sync-request-query.test.ts
  • src/lib/sync-request-query.ts
  • src/lib/sync-request-queue.ts
  • src/lib/user-context.ts
  • src/providers/garmin.test.ts
  • src/providers/garmin/provider.ts
  • src/providers/garmin/sync-api-query.ts
  • src/providers/garmin/sync-checkpoint.test.ts
  • src/providers/garmin/sync-checkpoint.ts
  • src/providers/garmin/sync-request-query.ts
  • src/providers/garmin/sync-step-plan.test.ts
  • src/providers/garmin/sync-step-plan.ts
  • src/providers/whoop.test.ts
  • src/providers/whoop/provider.ts
  • src/providers/whoop/sync-api-query.test.ts
  • src/providers/whoop/sync-api-query.ts
  • src/providers/whoop/sync-checkpoint.test.ts
  • src/providers/whoop/sync-checkpoint.ts
  • src/providers/whoop/sync-orchestrator.ts
  • src/providers/whoop/sync-request-query.ts
  • src/providers/whoop/sync-step-plan.test.ts
  • src/providers/whoop/sync-step-plan.ts
💤 Files with no reviewable changes (2)
  • src/providers/whoop/sync-checkpoint.test.ts
  • src/providers/whoop/sync-checkpoint.ts

Comment thread src/jobs/sync-request-job.test.ts Outdated
Comment thread src/jobs/sync-request-job.ts
Comment thread src/jobs/sync-request-query-registration.ts Outdated
Comment thread src/lib/user-context.ts
Comment thread src/providers/garmin/provider.ts Outdated
Comment thread src/providers/garmin/sync-request-query.ts Outdated
Comment thread src/providers/garmin/sync-step-plan.test.ts
Comment thread src/providers/whoop/sync-step-plan.test.ts
Asherlc added 4 commits June 24, 2026 10:17
- Remove completed/failed jobs before re-adding with same jobId
- Pass rate-limit fetchFn through to Garmin token refresh
- Add dedup, resolver-dispatch, and resolveScopedUserId tests
- Return null for done checkpoint in Garmin query resolver
- Use computed syncApiQueryKey in Garmin and WHOOP step-plan tests
- Add captureException for non-rate-limit errors in WHOOP orchestrator
- Narrow registerProviderSyncRequestResolver param to { id: string }

@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.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
src/jobs/sync-request-job.ts (1)

17-25: 🎯 Functional Correctness | 🟠 Major

Treat any nonterminal BullMQ state as a dedup hit.
DUPLICATE_REQUEST_JOB_STATES misses valid pending states like waiting-children, so an existing job can still be removed and re-added. AGENTS.md says to fix the root cause and avoid workarounds; keep the existing job unless getState() returns completed or failed.

🤖 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 `@src/jobs/sync-request-job.ts` around lines 17 - 25, The duplicate-request
dedupe logic in sync-request-job should treat every nonterminal BullMQ state as
a cache hit instead of relying on DUPLICATE_REQUEST_JOB_STATES. Update the
existing-job check in sync-request-job.ts so that when getJob(nextOptions.jobId)
finds a job and existing.getState() returns anything other than completed or
failed, the function returns the existing job and does not remove/recreate it.
Keep the change localized around the job-state handling in the sync request job
flow, and preserve removal only for truly terminal jobs.
src/providers/garmin/sync-step-plan.ts (1)

183-193: 🩺 Stability & Availability | 🟠 Major

Exclude the current sync job from pending request keyslistPendingSyncRequestQueryKeys("garmin", userId) includes the job being processed, so a fresh sync can treat connectapi/activities?start=0 as pending and skip the initial activities_list step. Pass the current job id through the lookup and filter it out at the source.

🤖 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 `@src/providers/garmin/sync-step-plan.ts` around lines 183 - 193, The
pending-request lookup is currently including the sync job that is actively
running, which can cause `activities_list` to be skipped for a fresh Garmin
sync. Update `buildGarminSyncStepPlan` to pass the current job identifier into
`listPendingSyncRequestQueryKeys`, and filter that job out inside the
pending-keys query so `planSyncStepIfRequestNotPending` only sees other jobs’
keys.
🤖 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.

Outside diff comments:
In `@src/jobs/sync-request-job.ts`:
- Around line 17-25: The duplicate-request dedupe logic in sync-request-job
should treat every nonterminal BullMQ state as a cache hit instead of relying on
DUPLICATE_REQUEST_JOB_STATES. Update the existing-job check in
sync-request-job.ts so that when getJob(nextOptions.jobId) finds a job and
existing.getState() returns anything other than completed or failed, the
function returns the existing job and does not remove/recreate it. Keep the
change localized around the job-state handling in the sync request job flow, and
preserve removal only for truly terminal jobs.

In `@src/providers/garmin/sync-step-plan.ts`:
- Around line 183-193: The pending-request lookup is currently including the
sync job that is actively running, which can cause `activities_list` to be
skipped for a fresh Garmin sync. Update `buildGarminSyncStepPlan` to pass the
current job identifier into `listPendingSyncRequestQueryKeys`, and filter that
job out inside the pending-keys query so `planSyncStepIfRequestNotPending` only
sees other jobs’ keys.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: a30c2204-2496-40c4-babc-f9f9a4078a50

📥 Commits

Reviewing files that changed from the base of the PR and between 3210986 and 9f9315d.

📒 Files selected for processing (15)
  • cspell.json
  • packages/garmin-connect/src/client.ts
  • src/jobs/sync-request-job.test.ts
  • src/jobs/sync-request-job.ts
  • src/jobs/sync-request-query-registration.test.ts
  • src/jobs/sync-request-query-registration.ts
  • src/lib/user-context.test.ts
  • src/providers/garmin.test.ts
  • src/providers/garmin/provider.ts
  • src/providers/garmin/sync-request-query.ts
  • src/providers/garmin/sync-step-plan.test.ts
  • src/providers/garmin/sync-step-plan.ts
  • src/providers/whoop/provider.ts
  • src/providers/whoop/sync-orchestrator.ts
  • src/providers/whoop/sync-step-plan.test.ts

Asherlc added 7 commits June 24, 2026 10:40
…dis connection errors

The step-chain sync dedup code introduced calls to
listPendingSyncRequestQueryKeys (sync-request-queue.ts) which
creates a BullMQ Queue backed by ioredis.  Three test files
(garmin.test.ts, whoop.test.ts, sync-checkpoint.test.ts) imported
step-plan/orchestrator modules that transitively trigger this, but
lacked the mock — causing all 180 of their unit tests to fail with
'Connection is closed' from ioredis.
…Stryker mutants

Add 4 test cases covering the surviving conditional-expression and
object-literal mutants in whoop/sync-request-query.ts:

- bootstrap phase with cursorMs
- bootstrap phase with null cursor (returns null)
- api phase with out-of-bounds step index (returns null)
- done phase with valid api steps (returns null, not continuing)

These cover every branch in the function, raising the mutation score
from 61.54% to well above the 75% break threshold.
The enqueueSyncJobWithRequestDedup function (introduced by the sync
dedup PR) calls getJob on the queue before addJob. The mock only
provided add, so getJob was undefined and calling it threw, preventing
addJob (and thus queueAdd) from ever being reached.
…error classes

Shard 9: Add resolveGarminSyncRequestQuery test covering all 4 branches
(no checkpoint, phase=done, step past end, valid step) — kills 15
surviving mutants in garmin/sync-request-query.ts.

Shard 15: Add scope/userId assertions to GarminRateLimitError and
WhoopRateLimitError tests — kills 8 surviving mutants (EqualityOperator,
ConditionalExpression, LogicalOperator) in both error constructors.
…uncedValue

The useDebouncedValue hook used window.setTimeout. After JSDOM teardown
in tests, the timer callback fired, called setDebouncedValue, which
triggered React 19 dispatchSetState -> resolveUpdatePriority. React 19
client dev mode accesses window there, causing ReferenceError.

Fix: add useRef mount guard so setDebouncedValue is never called after
unmount, and use setTimeout/clearTimeout without window. prefix.
Both shards had survivors where createProviderRateLimitFetch options
object was mutated to {} by Stryker (ObjectLiteral + ArrowFunction).
No test verified the options were passed through, so empty options
didn't change behavior in any existing test.

Fix: mock createProviderRateLimitFetch with a spy that wraps the
original, then assert options.scope, options.userId, and
options.createRateLimitError were populated. This kills the ObjectLiteral
and ArrowFunction mutants, raising total score to 75%.
Typecheck failed because vi.mock factory returning vi.fn() doesn't
change the module's TS type at import sites, and as assertions
are banned by Biome plugin.

Fix: use vi.mocked() helper + toHaveBeenCalledWith with
expect.objectContaining instead of manually filtering mock.calls.
This avoids any type-narrowing issues and the banned as casts.

@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

🤖 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 `@packages/web/src/pages/ProviderDetailPage.tsx`:
- Around line 390-402: Add a colocated regression test for the debounce hook
behavior in ProviderDetailPage by exercising the unmount path with fake timers
and confirming no delayed state update occurs after cleanup. Use the same
hook/component area around the useEffect debounce logic and mountedRef guard,
and create the test as ProviderDetailPage.test.tsx so it verifies the timer is
cleared and setDebouncedValue is not called after unmount.
🪄 Autofix (Beta)

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: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: dccdab70-3826-468e-9b62-30ed49438a86

📥 Commits

Reviewing files that changed from the base of the PR and between 9f9315d and e4bda3a.

📒 Files selected for processing (9)
  • packages/garmin-connect/src/client.test.ts
  • packages/server/src/mcp/route.test.ts
  • packages/web/src/pages/ProviderDetailPage.tsx
  • packages/whoop-whoop/src/client.test.ts
  • src/jobs/sync-request-job.test.ts
  • src/providers/garmin.test.ts
  • src/providers/garmin/sync-checkpoint.test.ts
  • src/providers/garmin/sync-request-query.test.ts
  • src/providers/whoop.test.ts

Comment thread packages/web/src/pages/ProviderDetailPage.tsx Outdated
Asherlc added 5 commits June 24, 2026 11:42
Shard 6 (garmin/provider.ts ArrowFunction at line 132):
  createRateLimitError callback survived when mutated to () => undefined
  because expect.any(Function) matches any function. Fix: invoke the
  callback from the mock's recorded args and assert it returns a
  GarminRateLimitError. Also kills the OptionalChaining no-cov at line 136.

Shard 5 (sync-request-queue.ts, 3 survived + 7 no cov):
  listProviderSyncJobsForUser had 3 survivors (ArrowFunction,
  ArrayDeclaration, MethodExpression) — no test exercised the function.
  Added colocated unit tests with mocked BullMQ queue that verify:
  - getJobs called with correct pending states
  - filter returns only matching user IDs
  - empty results for no-match or no-job cases.
  listPendingSyncRequestQueryKeys tests cover the for-loop body, if (query)
  branches, and key collection (killing 6 no-cov mutants).
Same pattern as the garmin fix — invoke the createRateLimitError
callback from recorded mock args and assert it returns a
WhoopRateLimitError instead of just checking expect.any(Function).
Export useDebouncedValue hook and add colocated tests for:
- Initial value returned immediately
- Value updates after delay
- Timeout cleared on unmount (no stale state update)
Add colocated unit tests for whoopSyncStepToApiQuery and
garminSyncStepToApiQuery exercising every switch case with
exact return value assertions.

Shard 1 (whoop): 47.62% -> 100%
Shard 8 (garmin): 47.83% -> 100%

@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: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/web/src/pages/ProviderDetailPage.tsx (1)

388-406: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

mountedRef guard is dead code — clearTimeout already prevents the stale update.

The cleanup runs clearTimeout(timer) on both unmount and [value, delayMs] changes, so the timer callback only fires while the effect is active — i.e. mountedRef.current is always true when it runs. The if (mountedRef.current) false branch is unreachable, including in the "no update after unmount" test (which passes purely because of clearTimeout). Drop the ref and keep the cleanup.

♻️ Proposed simplification
 export function useDebouncedValue<T>(value: T, delayMs: number): T {
   const [debouncedValue, setDebouncedValue] = useState(value);
-  const mountedRef = useRef(true);
 
   useEffect(() => {
-    mountedRef.current = true;
-    const timer = setTimeout(() => {
-      if (mountedRef.current) {
-        setDebouncedValue(value);
-      }
-    }, delayMs);
-    return () => {
-      mountedRef.current = false;
-      clearTimeout(timer);
-    };
+    const timer = setTimeout(() => setDebouncedValue(value), delayMs);
+    return () => clearTimeout(timer);
   }, [value, delayMs]);
 
   return debouncedValue;
 }

As per coding guidelines: "Apply minimum fix: only perform the minimum fix required... do not add extra error handling, validation, or infrastructure unless explicitly requested."

🤖 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 `@packages/web/src/pages/ProviderDetailPage.tsx` around lines 388 - 406, The
`useDebouncedValue` hook in `ProviderDetailPage` has dead `mountedRef` logic
because the `clearTimeout` cleanup already prevents stale updates. Remove the
`mountedRef` ref and the `if (mountedRef.current)` guard, and keep the effect
focused on scheduling the timeout and clearing it in the cleanup.

Source: Coding guidelines

🤖 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 `@packages/web/src/pages/ProviderDetailPage.tsx`:
- Line 388: The exported useDebouncedValue hook is only present for the test and
is not part of the real public API, so remove the export from ProviderDetailPage
and validate the behavior through the existing public surface (such as
useDebouncedFilters/SyncHistory interactions) instead. If useDebouncedValue is
intended to be reusable, move it out of ProviderDetailPage into a shared hooks
module with a real production consumer, and keep the export there for that
reason.

In `@src/lib/sync-request-queue.test.ts`:
- Around line 76-82: The mock for sync request query resolution is using an
empty string to stand in for a missing sinceIso value, which hides the
absent-value path. Update the mock in sync-request-queue.test.ts around
mockResolveSyncRequestQuery and mockSyncApiQueryKey to preserve sinceIso as
undefined or omit the field when it is missing, and keep the key builder
handling the undefined case explicitly instead of coercing it to an empty
string.
- Around line 69-89: Add a regression test in listPendingSyncRequestQueryKeys to
cover duplicate query keys: mock getJobs so two jobs resolve through
resolveSyncRequestQuery to the same query, then verify syncApiQueryKey produces
the same string and the returned Set contains only one entry. Use the existing
listPendingSyncRequestQueryKeys, mockResolveSyncRequestQuery, and
mockSyncApiQueryKey helpers so the test locks in the dedup behavior.

In `@src/providers/garmin/sync-api-query.test.ts`:
- Around line 5-15: The Garmin request key for activities_list is currently
collapsing all pages because the query builder hardcodes the start filter to 0.
Update garminSyncStepToApiQuery in sync-api-query.ts to use step.offset for the
start value so syncApiQueryKey() can distinguish follow-up pages queued by
sync-orchestrator.ts. Keep the activities_list path the same, but ensure the
generated filters reflect the step offset.

---

Outside diff comments:
In `@packages/web/src/pages/ProviderDetailPage.tsx`:
- Around line 388-406: The `useDebouncedValue` hook in `ProviderDetailPage` has
dead `mountedRef` logic because the `clearTimeout` cleanup already prevents
stale updates. Remove the `mountedRef` ref and the `if (mountedRef.current)`
guard, and keep the effect focused on scheduling the timeout and clearing it in
the cleanup.
🪄 Autofix (Beta)

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: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: fe3c7c57-ccc5-468f-afb2-8eab6dfe9e1d

📥 Commits

Reviewing files that changed from the base of the PR and between e4bda3a and 3085348.

📒 Files selected for processing (7)
  • packages/web/src/pages/ProviderDetailPage.test.tsx
  • packages/web/src/pages/ProviderDetailPage.tsx
  • src/lib/sync-request-queue.test.ts
  • src/providers/garmin.test.ts
  • src/providers/garmin/sync-api-query.test.ts
  • src/providers/whoop.test.ts
  • src/providers/whoop/sync-api-query.test.ts

Comment thread packages/web/src/pages/ProviderDetailPage.tsx Outdated
Comment thread src/lib/sync-request-queue.test.ts
Comment thread src/lib/sync-request-queue.test.ts
Comment thread src/providers/garmin/sync-api-query.test.ts Outdated
Asherlc added 2 commits June 24, 2026 13:55
- Extract useDebouncedValue to hooks/ dir, remove test-only export
- Remove dead mountedRef from useDebouncedValue (clearTimeout suffices)
- Remove empty-string sentinel in sync-request-queue.test.ts mock
- Add dedup regression test for listPendingSyncRequestQueryKeys
- Propagate step.offset in garminSyncStepToApiQuery (activities_list)
CI typecheck failed because vitest globals aren't configured in
packages/web tsconfig — must import test lifecycle hooks explicitly.
@Asherlc
Asherlc merged commit d2c9b8c into main Jun 24, 2026
78 checks passed
@Asherlc
Asherlc deleted the Asherlc/all-provider-sync-core branch June 24, 2026 22:06
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