mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-08-24 23:32:19 +10:00
chore: update dependencies
This commit is contained in:
@@ -0,0 +1,224 @@
|
||||
# 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.ts`
|
||||
> If 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` — `getBySlug` receives
|
||||
`input.requestHeaders: Headers`, resolves the resume, and at lines 505–507:
|
||||
|
||||
```ts
|
||||
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** change `shouldCountForStatistics`.
|
||||
|
||||
- `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.ts`
|
||||
exports `TRUSTED_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. Reuse `TRUSTED_IP_HEADERS` to read the viewer IP from
|
||||
`input.requestHeaders`; fall back to a `user-agent`-based key when no trusted
|
||||
IP header is present (mirror the middleware's fallback so behavior is
|
||||
consistent).
|
||||
|
||||
- **Environment**: `REDIS_URL` exists in `packages/env/src/server.ts` and
|
||||
`turbo.json` globalEnv, 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 a `shouldCountView(...)`
|
||||
function that returns `true` at most once per key per window.
|
||||
- `packages/api/src/features/resume/view-dedup.test.ts` (create)
|
||||
- `packages/api/src/features/resume/service.ts` — gate the `increment` call
|
||||
(lines 505–507) on the new helper.
|
||||
- `plans/README.md` (status row)
|
||||
|
||||
**Out of scope**:
|
||||
- `increment`'s write body and the DB schema — unchanged.
|
||||
- `shouldCountForStatistics` in `access-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):
|
||||
|
||||
```ts
|
||||
// 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)` returns `true` the first time, `false` for the same
|
||||
key within the window, and `true` again once `now` is past the window.
|
||||
- Two different keys are independent.
|
||||
- `clientKeyFromHeaders` derives 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:
|
||||
|
||||
```ts
|
||||
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 `getBySlug` test 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.ts` and its `.test.ts` exist
|
||||
- [ ] `pnpm --filter @reactive-resume/api test -- view-dedup` passes with ≥ 4 cases
|
||||
- [ ] `grep -n "shouldCountView" packages/api/src/features/resume/service.ts` shows the gate around the increment call
|
||||
- [ ] `pnpm --filter @reactive-resume/api typecheck` exits 0
|
||||
- [ ] `pnpm --filter @reactive-resume/api test` exits 0
|
||||
- [ ] `git status --porcelain` lists only in-scope files + `plans/README.md`
|
||||
- [ ] `plans/README.md` status row for 005 updated
|
||||
|
||||
## STOP conditions
|
||||
|
||||
Stop and report if:
|
||||
|
||||
- `getBySlug` no longer calls `statistics.increment` at lines ~505–507, or its
|
||||
signature no longer exposes `requestHeaders` — the code drifted.
|
||||
- You cannot derive a client key from `input.requestHeaders` because 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.
|
||||
Reference in New Issue
Block a user