Skip to content

feat(mobile): API hardening — GraphQL cache isolation, idempotency keys, cancellation, OpenAPI contract check - #1016

Merged
llinsss merged 2 commits into
DogStark:mainfrom
driftsorbit:feat/mobile-api-hardening-975-978
Aug 28, 2026
Merged

feat(mobile): API hardening — GraphQL cache isolation, idempotency keys, cancellation, OpenAPI contract check#1016
llinsss merged 2 commits into
DogStark:mainfrom
driftsorbit:feat/mobile-api-hardening-975-978

Conversation

@driftsorbit

Copy link
Copy Markdown
Contributor

Summary

Four narrowly-scoped mobile API-boundary hardening changes, each with focused tests. Based on the latest main; synthetic data only.

Issue Change
#978 Reset the normalized GraphQL cache atomically on logout / account switch
#976 Idempotency keys on every mobile mutation, preserved through offline replay
#975 Standardized API cancellation on screen unmount + rapid navigation
#977 CI contract check: handwritten mobile services vs. backend OpenAPI

#978 — Prevent GraphQL cache leakage between accounts

InMemoryCache is keyed by type + id, not by account, so a cache-and-network read could hand the next account a prior account's pets or medical records before the network response landed.

  • resetApolloStore() (src/services/apolloClient.ts) drops all normalized state via clearStore() — no active query is refetched with a missing/stale token during teardown.
  • Concurrent callers (e.g. logout racing an inactivity timeout) are coalesced into a single teardown.
  • Wired into authService.logout() after tokens are cleared, via dynamic import so the Apollo/graphql-ws module graph stays out of auth unit tests and non-Expo environments.
  • Tests (src/services/__tests__/apolloCacheIsolation.test.ts): reproduce the leak on a shared cache, verify the wipe on a warm cache, verify a safe no-op on an already-empty cache (offline logout), verify concurrent-reset coalescing.

#976 — API idempotency keys for all mobile mutations

Network retries, 5xx retries, the 401-refresh replay, and offline-queue replay could each duplicate records, payments, appointments, and support requests.

  • apiClient request interceptor stamps a stable Idempotency-Key (RFC-4122 v4, crypto.randomUUID when available) on every POST/PUT/PATCH/DELETE before it — or any retry of the same config — leaves the device. Reads are untouched; an explicit caller-supplied key is always preserved.
  • offlineQueue persists the key on QueuedMutation and replays it verbatim, so a mutation queued offline and retried days later still collapses server-side.
  • Helpers exported: generateIdempotencyKey, readIdempotencyKey, withIdempotencyKey, IDEMPOTENCY_HEADER.
  • Tests (src/services/__tests__/apiClientIdempotency.test.ts): gap characterization, uniqueness/format, per-method behavior (mutations vs. reads), explicit-key preservation, retry-safety (re-stamping is a no-op), case-insensitive header match.

#975 — Standardize API cancellation on screen unmount

Long requests were resolving into unmounted or stale screens and keeping the radio awake.

  • useAbortController() (src/hooks/useAbortController.ts): one AbortController per mount, aborted exactly once on unmount; renew() aborts the previous scope and opens a new one for rapid navigation / filter changes so only the newest request can settle the screen. StrictMode-safe.
  • isAbortError() / ignoreAbort() recognize the DOMException, axios CanceledError (ERR_CANCELED), and Node ABORT_ERR, so call sites can swallow the expected post-cancel rejection.
  • Tests (src/hooks/__tests__/useAbortController.test.ts): stale-screen hazard characterization, abort-on-unmount, renew() semantics, a 5-change rapid-filter burst leaving only the last scope alive, isAbortError/ignoreAbort classification.

#977 — Validate backend OpenAPI against the generated mobile client

Handwritten services can drift from backend routes/DTOs.

  • scripts/validate-openapi-client.ts scans literal apiClient/api/authClient.<method>('/path') call sites in src/services, normalizes dynamic segments to the spec's {param} form, and diffs against backend/docs/openapi.json.
  • The repo already carries historical drift, so CI fails on new drift only: scripts/openapi-client-baseline.json snapshots the 51 currently-known-undocumented endpoints; documenting one (shrinking the baseline) is always allowed. --update-baseline re-snapshots; --strict (CI) exits non-zero on anything new.
  • Wired into the quality job in .github/workflows/ci.yml as npm run openapi:validate-client.
  • Tests (scripts/__tests__/validate-openapi-client.test.ts): path normalizer, drift detector (undocumented path, undocumented method on a known path, happy path), and a live assertion that the critical mobile REST surface (auth, pets, medical records, appointments, medications) is documented.

Testing

  • npx tsx scripts/validate-openapi-client.ts --strict → passes (0 new drift).
  • Jest suites above are added following existing repo conventions; they run in CI (node_modules is not vendored in this working copy, so the JS suites were not executed locally).
  • No secrets, health records, contact details, location, wallet material, or raw tokens added to logs, fixtures, or tests — synthetic data only.
  • No UI changes (accessibility / RTL / dynamic-type criteria N/A). No native module changes; behavior is JS-layer and identical on iOS and Android.

closes #975
closes #976
closes #977
closes #978

…ion, OpenAPI contract

- DogStark#978 resetApolloStore() wipes the normalized GraphQL cache atomically on
  logout/account switch so cache-and-network can't leak a prior account's
  pets or records; wired into authService.logout().
- DogStark#976 Every mutating request (POST/PUT/PATCH/DELETE) carries a stable
  Idempotency-Key via the apiClient request interceptor; the offline queue
  persists the key and replays it verbatim so retries don't duplicate
  records, payments, appointments, or support requests.
- DogStark#975 useAbortController hook: one AbortSignal per screen, aborted on
  unmount; renew() cancels the previous scope on rapid navigation/filter
  changes. isAbortError()/ignoreAbort() helpers for swallowing the expected
  rejection.
- DogStark#977 scripts/validate-openapi-client.ts contract check wired into CI —
  scans src/services call sites against backend/docs/openapi.json and fails
  on NEW drift beyond a committed baseline.

Focused tests added for each change.
@drips-wave

drips-wave Bot commented Aug 26, 2026

Copy link
Copy Markdown

@driftsorbit Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits.

You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀

Learn more about application limits

@llinsss
llinsss merged commit ec6b441 into DogStark:main Aug 28, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants