9.9 KiB
Plan 005: Deduplicate public-resume view-count writes
Executor instructions: Follow step by step. Run every verification and confirm before moving on. Honor "STOP conditions". When done, update the status row in
plans/README.md.Drift check (run first):
git diff --stat 73daf22b2..HEAD -- packages/api/src/features/resume/service.ts packages/api/src/features/resume/access-policy.tsIf either changed since this plan, compare "Current state" excerpts against live code; on mismatch, STOP.
Status
- Priority: P2
- Effort: M
- Risk: MED
- Depends on: 001 recommended (its Step 5 test characterizes
statistics.increment, giving you a safety net that this plan changes the caller, not the write body). - Category: perf
- Planned at: commit
73daf22b2, 2026-07-08
Why this matters
Every public-resume view triggers a database write. getBySlug
(packages/api/src/features/resume/service.ts:475) calls
statistics.increment(...) whenever shouldCountForStatistics is true, and
increment (lines 199–237) runs a transaction with two INSERT ... ON CONFLICT DO UPDATE statements (resumeStatistics + resumeStatisticsDaily).
This fires on every non-owner load — bots, crawlers, and refreshes included.
TanStack Query's 60s staleTime only suppresses within-session refetches; it
does nothing for distinct sessions or server-side crawlers. A popular public
resume therefore drives continuous write traffic to two tables for what is
functionally the same view counted many times.
This plan adds a short-window per-viewer dedup so a burst of views from the same client within a window counts once, cutting write volume without changing what the counters mean to users (roughly "unique-ish views per window").
Current state
-
packages/api/src/features/resume/service.ts:475—getBySlugreceivesinput.requestHeaders: Headers, resolves the resume, and at lines 505–507:if (shouldCountForStatistics(resume, viewer)) { await resumeService.statistics.increment({ id: resume.id, views: true }); } -
packages/api/src/features/resume/access-policy.ts:76—shouldCountForStatistics(resume, viewer)decides whether a view counts (e.g. skip the owner). This plan adds an orthogonal "have we already counted this viewer recently?" gate; it does not changeshouldCountForStatistics. -
increment(service.ts:199-237) — the two-table transactional write. Do not change its body; this plan changes only whether it is called. -
Reusable client-identity helper:
packages/utils/src/rate-limit.tsexportsTRUSTED_IP_HEADERS(the ordered list of proxy IP headers the app trusts). The rate-limit middleware already derives a client key from these headers the same way. ReuseTRUSTED_IP_HEADERSto read the viewer IP frominput.requestHeaders; fall back to auser-agent-based key when no trusted IP header is present (mirror the middleware's fallback so behavior is consistent). -
Environment:
REDIS_URLexists inpackages/env/src/server.tsandturbo.jsonglobalEnv, but the app runs as a single Node process by default and the existing rate limiter uses an in-memory store (@orpc/experimental-ratelimit/memory). Match that: an in-memory TTL cache is the right default here.
Commands you will need
| Purpose | Command | Expected |
|---|---|---|
| Typecheck | pnpm --filter @reactive-resume/api typecheck |
exit 0 |
| Test | pnpm --filter @reactive-resume/api test -- resume |
all pass |
| All api | pnpm --filter @reactive-resume/api test |
all pass |
(Do NOT run pnpm check.)
Scope
In scope:
- A new small helper module, e.g.
packages/api/src/features/resume/view-dedup.ts(create) — an in-memory TTL set keyed by${resumeId}:${clientKey}with ashouldCountView(...)function that returnstrueat most once per key per window. packages/api/src/features/resume/view-dedup.test.ts(create)packages/api/src/features/resume/service.ts— gate theincrementcall (lines 505–507) on the new helper.plans/README.md(status row)
Out of scope:
increment's write body and the DB schema — unchanged.shouldCountForStatisticsinaccess-policy.ts— unchanged.- Any Redis / distributed-cache implementation — an in-memory window is the agreed default; a distributed store is a documented future upgrade, not this plan.
- Download counting (
downloads: true) — this plan is about view writes only; do not alter download increments.
Git workflow
- Branch:
advisor/005-stats-view-dedup - Commit:
perf(api): dedup public-resume view increments within a short window - Do NOT push or open a PR unless instructed.
Steps
Step 1: Write the dedup helper
Create view-dedup.ts exporting a pure-ish, testable function. Suggested
shape (keep it small — this is not a cache library):
// ponytail: in-memory per-process dedup window. Single-instance is the default
// deploy; for multi-instance, swap the Map for a Redis SETNX+EXPIRE keyed the
// same way (REDIS_URL already exists in env). Upgrade only if you scale out.
const WINDOW_MS = 60 * 60 * 1000; // 1 hour
const seen = new Map<string, number>(); // key -> expiry timestamp
export function shouldCountView(key: string, now: number): boolean {
const expiry = seen.get(key);
if (expiry !== undefined && expiry > now) return false;
seen.set(key, now + WINDOW_MS);
return true;
}
export function clientKeyFromHeaders(headers: Headers): string { /* uses TRUSTED_IP_HEADERS, UA fallback */ }
Take now as a parameter (don't call Date.now() inside the predicate) so the
test can drive the clock deterministically. Add a lightweight size guard: if
seen.size exceeds a cap (e.g. 50_000), prune entries whose expiry has passed
before inserting, so the Map can't grow unbounded. Reuse TRUSTED_IP_HEADERS
from @reactive-resume/utils/rate-limit inside clientKeyFromHeaders.
Verify: pnpm --filter @reactive-resume/api typecheck → exit 0.
Step 2: Test the helper
Create view-dedup.test.ts:
shouldCountView(key, t)returnstruethe first time,falsefor the same key within the window, andtrueagain oncenowis past the window.- Two different keys are independent.
clientKeyFromHeadersderives distinct keys for distinct trusted-IP headers and a stable key for the UA fallback when no IP header is present.
Verify: pnpm --filter @reactive-resume/api test -- view-dedup → all pass.
Step 3: Gate the increment call
In service.ts getBySlug, wrap the existing count so it fires only when both
the policy allows it AND the viewer hasn't been counted this window:
if (shouldCountForStatistics(resume, viewer)) {
const key = `${resume.id}:${clientKeyFromHeaders(input.requestHeaders)}`;
if (shouldCountView(key, Date.now())) {
await resumeService.statistics.increment({ id: resume.id, views: true });
}
}
Do not change anything else in getBySlug.
Verify: pnpm --filter @reactive-resume/api typecheck → exit 0.
Step 4: Confirm no regression in existing resume tests
Verify: pnpm --filter @reactive-resume/api test -- resume → all pass. In
particular, if Plan 001's statistics.increment characterization test exists,
it should still pass (this plan didn't touch increment's body). If a
getBySlug test asserts increment-on-view, it may now need the test to pass a
fresh header/clock so the first view still counts — update it to reflect the
dedup (first view counts, immediate repeat does not).
Test plan
view-dedup.test.ts: window behavior + key derivation (Step 2 cases).- If an existing
getBySlugtest asserts view counting, extend it: first call with a given client key increments; an immediate second call with the same key does not; a call with a different key does. - Verification:
pnpm --filter @reactive-resume/api test→ all pass.
Done criteria
Machine-checkable. ALL must hold:
packages/api/src/features/resume/view-dedup.tsand its.test.tsexistpnpm --filter @reactive-resume/api test -- view-deduppasses with ≥ 4 casesgrep -n "shouldCountView" packages/api/src/features/resume/service.tsshows the gate around the increment callpnpm --filter @reactive-resume/api typecheckexits 0pnpm --filter @reactive-resume/api testexits 0git status --porcelainlists only in-scope files +plans/README.mdplans/README.mdstatus row for 005 updated
STOP conditions
Stop and report if:
getBySlugno longer callsstatistics.incrementat lines ~505–507, or its signature no longer exposesrequestHeaders— the code drifted.- You cannot derive a client key from
input.requestHeadersbecause headers aren't actually populated at this layer in practice (check a real request path / existing rate-limit middleware usage) — report it; a dedup keyed on an empty header is worthless. - You find that view counting must remain exact (product decision that every raw hit counts) — the dedup changes counter semantics slightly; if unsure, stop and confirm before shipping.
Maintenance notes
- Semantics change: counters become "unique-ish views per 1h window per client" rather than "raw hits". Document this near the helper. If the product wants raw hit counts back, this gate is the single place to remove.
- Multi-instance ceiling: the in-memory Map is per-process. If the app is ever horizontally scaled, each instance dedups independently (still a large reduction, but not global). The ponytail comment names Redis as the upgrade path; do it only if scale-out happens.
- Reviewer should confirm
Date.now()is only called at the call site (Step 3), not inside the predicate, so the helper stays testable.