feat(followed-countries): watchlist primitive PR A — schema + mutations + queries + service + UI#3621
Conversation
Foundation for the followed-countries watchlist primitive. Normalizes any country input form (ISO-3166 alpha-2, alpha-3, lowercase, common country names) to canonical alpha-2 uppercase. Returns null on unrecognized input. Plan: docs/plans/2026-05-02-001-feat-followed-countries-watchlist-primitive-plan.md U1
Two new Convex tables for the followed-countries watchlist primitive:
- followedCountries: { userId, country, addedAt } indexed by_user,
by_country, by_user_country. Per-country reverse lookup via
by_country enables future fan-out (digest, breaking-news relay).
- followedCountriesCounts: { country, count, updatedAt } indexed
by_country. Aggregate counter maintained atomically by U13
mutations so public countFollowers query is O(1) per call rather
than O(n) on by_country.collect().
Constants in convex/constants.ts: FREE_TIER_FOLLOW_LIMIT=3 (server-
authoritative cap), MAX_MERGE_INPUT=100 (anti-abuse ceiling),
COUNTRY_COUNT_PRIVACY_FLOOR=5 (returned-as-zero threshold for
public counts).
No migration; both tables empty on creation.
Plan: docs/plans/2026-05-02-001-feat-followed-countries-watchlist-primitive-plan.md U12
…tor (U13)
Three server-authoritative mutations on the new followedCountries
table, with atomic counter maintenance for the aggregate
followedCountriesCounts table:
- followCountry({country}): auth + ISO-2 registry validate +
idempotent on (userId, country) + free-tier cap (server-enforced
via ConvexError({kind:'FREE_CAP'})) + atomic counter +1.
- unfollowCountry({country}): auth + validate + idempotent +
atomic counter -1 (max(0, ...) defensive).
- mergeAnonymousLocal({countries}): auth + MAX_MERGE_INPUT ceiling
(anti-abuse) + ISO-2 registry filter + first-seen dedupe + bounded
accept for free users (cap-fitting; over-cap → droppedDueToCap[])
+ atomic counter +N.
ISO-2 registry validator at convex/lib/iso2.ts mirrors the canonical
alpha-2 set from src/utils/country-codes.ts. Both registries must
stay in lockstep (documented inline).
All errors typed ConvexError({kind, ...}) with object data per
the convex-error-string-data-strips-errordata-on-wire memory.
32 new tests covering: auth/validation, free-tier cap (under, at,
exceeded), idempotency for both follow + unfollow, counter
correctness across all paths (never goes negative), mergeAnonymous
with grandfather rejection / partial accept / oversized input /
duplicate inputs / mixed valid+invalid.
Plan: docs/plans/2026-05-02-001-feat-followed-countries-watchlist-primitive-plan.md U13
Three queries + one relay HTTP action over the followedCountries +
followedCountriesCounts tables:
- listFollowed = query({}): auth'd reactive query for current user;
returns string[] sorted by addedAt asc; [] when no auth identity.
Drives the client-side reactive subscription (U2/U3).
- countFollowers = query({country}): public no-auth query backed by
the aggregate counter table — O(1) per call, not O(n) on
by_country.collect(). Privacy floor (COUNTRY_COUNT_PRIVACY_FLOOR=5)
returns 0 below threshold to limit follower-set inference. Drives
future 'X people watching' social-proof UI.
- listFollowersPage = internalQuery({country, cursor?, limit}):
internal-only paginated cursor on by_country index, limit clamped
[1, 500]. NEVER exposed publicly (declared as internalQuery, NOT
query — the typecheck-level privacy boundary). Drives future
per-country fan-out (digest, breaking-news relay).
- internalListFollowedForUser = internalQuery({userId}): internal
helper used by the relay endpoint (which has no Clerk identity).
POST /relay/followed-countries HTTP action mirrors the existing
/relay/user-preferences pattern: shared-secret auth via
timingSafeEqualStrings, body {userId}, returns {countries: string[]}.
Used by PR C's brief composer to read followed-countries server-side.
29 new tests covering: per-user reads, sort order, no-auth empty,
counter-table-backed counts, privacy floor edges (4 vs 5), cursor
pagination across multi-page result, limit clamp [1,500],
@ts-expect-error privacy assertion that listFollowersPage is NOT
on api.* (only internal.*), relay 200/400/401 paths.
Plan: docs/plans/2026-05-02-001-feat-followed-countries-watchlist-primitive-plan.md U14
…nt gating (U2)
Single client-side owner of watchlist semantics for the followed-
countries primitive. U2 ships the anonymous (localStorage) path
fully working; signed-in mode plumbing is stubbed with explicit
TODO(U3) markers for the next unit to fill in.
Public API:
- getFollowed(): string[]
- isFollowed(code: string): boolean
- addCountry(input): Promise<FollowMutationResult>
- removeCountry(input): Promise<FollowMutationResult>
- subscribe(handler): unsubscribe
- serviceEntitlementState(): 'pro' | 'free' | 'loading'
- WM_FOLLOWED_COUNTRIES_CHANGED custom event
Discriminated-union return (memory: discriminated-union-over-sentinel-
boolean): { ok: true } | { ok: false, reason: 'DISABLED' |
'INVALID_INPUT' | 'FREE_CAP' | 'ENTITLEMENT_LOADING' |
'HANDOFF_PENDING' | 'STORAGE_FULL' }. Never throws.
Anonymous-vs-loading distinction (Codex deepening round-1 P1):
serviceEntitlementState() returns 'free' when getCurrentClerkUser()
is null, regardless of entitlement state — anonymous users never
block on entitlement loading. Only signed-in users with null
entitlement state enter 'loading'.
Storage: localStorage 'wm-followed-countries-v1' = JSON.stringify(
{ countries: string[] }). NOT enrolled in CLOUD_SYNC_KEYS — the
dedicated Convex table replaces that path for this feature.
Feature flag VITE_FOLLOW_COUNTRIES_ENABLED gates all mutations at
the service layer (refusal at top of addCountry/removeCountry).
Default ON; only '0' disables.
25 tests covering: happy paths, normalization (alpha-3 → alpha-2),
idempotency, FREE_CAP cap enforcement, PRO unlimited, ENTITLEMENT_
LOADING (signed-in only), anonymous-never-loads, feature-flag
DISABLED, corrupt/wrong-shape localStorage, STORAGE_FULL on quota
throw.
Plan: docs/plans/2026-05-02-001-feat-followed-countries-watchlist-primitive-plan.md U2
Wires the signed-in mode of the followed-countries service: - Auth-state listener installed once at app boot. On every Clerk user transition, increments _handoffGeneration and captures userIdAtStart for any in-flight handoff. Post-await callbacks verify both BEFORE clearing localStorage / subscribing — resolves the in-flight auth race (Codex deepening round-1 P1). - Sign-in handoff orchestrator reads localStorage, calls api.followedCountries.mergeAnonymousLocal, clears localStorage on success, subscribes to listFollowed reactive query. - handoffPending UX: addCountry/removeCountry return HANDOFF_PENDING so FollowButton can show a syncing tooltip; getFollowed unions localStorage with the user-scoped subscription snapshot for stable display, BUT only if snapshot.userId matches current Clerk userId (Codex deepening round-2 P1 — no cross-user leak). - Sign-out / user-A → user-B: increments _handoffGeneration, unsubscribes, CLEARS _lastKnownSubscriptionSnapshot = null, resets _handoffState. localStorage retained for next anon session. - Network failure: _handoffState = 'failed', visibilitychange retry. Idempotency means safe to re-run mergeAnonymousLocal. - Convex error → reason mapping via err.data.kind (memory: convex-error-string-data-strips-errordata-on-wire). FREE_CAP preserves currentCount + limit. - Cap-drop event: when mergeAnonymousLocal returns droppedDueToCap[], dispatch WM_FOLLOWED_COUNTRIES_CAP_DROP for the upgrade-CTA toast (consumed by U4 FollowButton). 24 new sign-in handoff tests covering: empty/corrupt localStorage skip + clean, free-tier bounded accept with cap-drop event, network failure → visibilitychange retry, in-flight auth-race sign-out (gen guard drops result), in-flight user-swap (userIdAtStart guard drops result), HANDOFF_PENDING blocks writes, getFollowed user-scoped union, sign-out clears snapshot, sign-in→sign-out→different-user flow, reactive snapshot updates. Plan: docs/plans/2026-05-02-001-feat-followed-countries-watchlist-primitive-plan.md U3
Single mountable factory used by CountryDeepDivePanel, CountryIntelModal, and CIIPanel rows in U5. Owns: - Visual states: outlined-star (unfollowed), filled-star (followed), spinner (entitlement loading), hidden (feature flag off). At-cap state shows 'Upgrade to follow more' tooltip pre-click. - Click handler: addCountry/removeCountry. FREE_CAP triggers the existing upgrade flow (mirroring notifications-settings.ts lazy- import pattern: openSignIn for anon, startCheckout for signed-in, fallback to /pro#pricing). HANDOFF_PENDING / DISABLED / loading → defensive no-op. - Reactivity: subscribes to WM_FOLLOWED_COUNTRIES_CHANGED and onEntitlementChange; teardown unsubscribes both. Idempotent teardown. - Anonymous-vs-loading distinction (Codex round-2 P1): driven by serviceEntitlementState() helper, NOT raw getEntitlementState(). Anonymous users render interactive (state a/b), only signed-in- awaiting-snapshot enters spinner state. Adds isFollowFeatureEnabled() exported helper to the service so the button gates on the same source of truth as the service. 18 tests covering visual states, anonymous click flow, entitlement- loading window with PRO/FREE resolution, subscription + teardown. Cap-drop toast (WM_FOLLOWED_COUNTRIES_CAP_DROP from U3) is NOT wired here — left as TODO for App-level toast service. CSS is semantic class names only; styling lands in PR B. Plan: docs/plans/2026-05-02-001-feat-followed-countries-watchlist-primitive-plan.md U4
…(U5) Surfaces the followed-countries primitive on the three PR-A entry points. No other behavior changes (pin-to-top, filter chips, brief weighting are PR B / PR C). CountryDeepDivePanel: - FollowButton (size: md) inserted in header next to title - Teardown wired into resetPanelContent() + hide(); idempotent CountryIntelModal: - FollowButton (size: md) in header between country name and level - Mount on show(); teardown on showLoading()/hide(); idempotent CIIPanel: - FollowButton (size: sm) as first child of every .cii-country row - Map<countryCode, teardown> tracks per-row mounts; cleared on every wholesale rebuild + on destroy() to prevent listener leaks - Click stopPropagation so star toggle does NOT also trigger row-level onCountryClick (mirrors the existing cii-share-btn pattern) Semantic class names only (.wm-follow-btn + per-host wrappers); CSS lands in PR B's UX polish. Verification: npm run typecheck + typecheck:api clean; full test:data suite still green (7898/7898). Plan: docs/plans/2026-05-02-001-feat-followed-countries-watchlist-primitive-plan.md U5
P3 #21: followCountry now reads entitlement tier BEFORE collecting all user rows; PRO callers skip the O(N) `.collect()` of followedCountries since they have no cap to check. Free users are unchanged. P2 #12: countFollowers privacy-floor doc/code alignment — comment now matches the `<` comparator (1-4 followers → 0; 5+ → exact count). P2 #19: /relay/followed-countries userId validation tightened to mirror /relay/user-preferences rigor — non-empty string with bounded length (<=256 chars) instead of just truthy. Mitigates oversized / non-string abuse vectors. P2 #13: ISO-2 dual-registry parity test upgraded from size-only (`.size === 239`) to set-equality. Catches drift where one side has, e.g., 'XK' and the other has 'EU' with the same total count. `ISO2_TO_ISO3` is now exported from `src/utils/country-codes.ts`. Tests: 278 → 283 (added 1 set-equality, 1 PRO-skip-collect, 3 relay userId validation tests). Plan/Review reference: ce-code-review run 20260502-195816-dae403d7
P1 #3: _runHandoff catch now uses _extractConvexErrorKind. Permanent ConvexError kinds (INPUT_TOO_LARGE, EMPTY_INPUT, UNAUTHENTICATED) skip the visibilitychange retry path: clear localStorage, transition to new 'failed-permanent' state, install the reactive subscription so signed-in reads still work. Transient errors (network / undefined kind) still retry. P1 #4: max-retry counter (5) + exponential backoff (1, 2, 4, 8, 16 seconds) gate the visibilitychange retry path. After exhaustion the state flips to 'failed-permanent' and no further retries are scheduled. _clearFailedHandoffForTests() exposed as the test recovery hook. Production has no equivalent today; sign-out / sign-in starts a fresh generation. Test seam _setHandoffBackoffForTests collapses the backoff schedule so tests don't have to wait seconds. P1 #5: signed-in addCountry/removeCountry now return HANDOFF_PENDING when the convex client is null instead of falling back to localStorage. Stale partial-writes that never reconcile with the authoritative table are no longer possible in signed-in mode. _setDepsForTests gains a 'force-null' literal so test injection can return null without falling through to the production importer. P1 #6: dropped the `if (existing.includes(code)) return {ok:true}` short-circuit on the SIGNED-IN branch of addCountry/removeCountry. The Convex mutation is itself idempotent and authoritative; the client-side snapshot is eventually consistent and could lie (e.g., another tab just unfollowed). Anonymous-mode short-circuit retained because localStorage IS the source of truth there. P1 #8: replaced unknown-typed ConvexClientLike/ConvexApiLike with FunctionReference<...> generics from convex/server. Mutation arg/result shapes (e.g., {country: code} vs {countries: code}) are now checked at the call site, eliminating the entire typo class. P1 #9: imports MergeAnonymousLocalResult from convex/followedCountries instead of hand-rolling an inline subset. P1 #10: cross-tab `storage` event listener installed alongside the auth-state listener. Filters on key === FOLLOWED_COUNTRIES_STORAGE_KEY and re-dispatches as WM_FOLLOWED_COUNTRIES_CHANGED so FollowButtons in other tabs re-render after a Tab-A mutation. P1 #11: signed-in addCountry/removeCountry capture {userIdAtStart, genAtStart} BEFORE the await, then call _authStillMatches() after the await. A sign-out / user-swap mid-mutation surfaces as HANDOFF_PENDING instead of letting user-A's success "land" while we're already user-B. P2 #20: empty-handoff path defers dispatchChanged until the first reactive snapshot lands. Tracks _initialSnapshotReceived; getFollowed() falls back to localStorage during the gap between 'complete' being set and the first onUpdate callback firing. Avoids a brief flash of empty-list rendering during the subscription warm-up window. Tests: data 7898 → 7911 (added 13 — INPUT_TOO_LARGE/EMPTY_INPUT/ UNAUTHENTICATED permanent-kind handling, plain-error transient guard, max-retry exhaustion + recovery hook, client-null HANDOFF_PENDING for both add and remove, P1 #6 stale-snapshot non-short-circuit, cross-tab storage event re-dispatch x2, post-await auth re-check, empty-handoff deferred dispatch). Convex 283/283 unchanged. Plan/Review reference: ce-code-review run 20260502-195816-dae403d7
… (Phase 3) P2 #16: added `assertNever(result)` default branch to the FollowButton onClick switch on `FollowMutationResult.reason`. When every variant is handled by a `case`, `result` narrows to `never` at the default; adding a new reason to `FollowMutationResult` will widen the residual type and produce a TS2345 ('not assignable to never') at typecheck time. Catches the future-variant bug class at compile time, with a runtime fallback for malformed test fakes. P2 #17: introduced an `inFlight` boolean closed over by the click handler. When a mutation is already pending, additional clicks are dropped silently (no duplicate addCountry/removeCountry fires). Cleared in finally{} so a thrown service-layer error doesn't latch the button. Without this, a rapid double-click on an unfollowed button produced TWO follow mutations — the service is idempotent on (user, country) but the second add was wasted network + counter-increment work and a toggle pattern (click on, click off) could land in the unintended state. Tests: data 7911 → 7913 (added inFlight rapid-double-click suppression test + assertNever runtime-guard sanity test). Plan/Review reference: ce-code-review run 20260502-195816-dae403d7
…hase 4) P2 #18: adds the missing .env.example entry for the followed-countries feature flag. Mirrors the format of sibling flags (VITE_CLOUD_PREFS_ENABLED) and clarifies the default-on-unless-'0' semantics enforced by `isFeatureFlagEnabled()` in src/services/followed-countries.ts. Plan/Review reference: ce-code-review run 20260502-195816-dae403d7
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Greptile SummaryThis PR lays the foundation for the per-user country watchlist primitive: two new Convex tables (
Confidence Score: 3/5The backend mutations, schema, and relay endpoint are safe to merge; the main risk is in the client-side handoff state machine where a transient Convex token-delivery error at sign-in permanently destroys the user's anonymous follow list. A user who signs in while Convex's token hasn't propagated yet will silently lose all their anonymous follows, with no recovery path short of signing out and back in — even though the next visibilitychange retry would have succeeded. The inconsistency with how addCountry handles the same error confirms this is unintentional. src/services/followed-countries.ts — specifically the _runHandoff catch block that classifies UNAUTHENTICATED as a permanent error. Important Files Changed
Sequence DiagramsequenceDiagram
participant Browser as Browser (anon)
participant Service as FollowedCountriesService
participant Clerk as Clerk Auth
participant Convex as Convex (followedCountries)
Browser->>Service: addCountry("US") [anon]
Service->>Browser: writes localStorage, dispatches WM_FOLLOWED_COUNTRIES_CHANGED
Note over Browser,Convex: User signs in
Clerk-->>Service: onAuthStateChange
Service->>Service: "_handoffState = pending, increment gen"
Service->>Convex: mergeAnonymousLocal
alt Success
Convex-->>Service: accepted countries
Service->>Service: "removeLocalStorage, _handoffState = complete"
Service->>Convex: onUpdate listFollowed
Convex-->>Service: reactive snapshot
Service->>Browser: dispatchChanged
else UNAUTHENTICATED token race current behavior
Convex-->>Service: ConvexError UNAUTHENTICATED
Service->>Service: removeLocalStorage loses anonymous follows
Service->>Service: "_handoffState = failed-permanent"
else UNAUTHENTICATED token race suggested fix
Convex-->>Service: ConvexError UNAUTHENTICATED
Service->>Service: markFailedAndScheduleRetry retry on visibilitychange
end
Reviews (1): Last reviewed commit: "chore(env): document VITE_FOLLOW_COUNTRI..." | Re-trigger Greptile |
| if ( | ||
| kind === 'INPUT_TOO_LARGE' || | ||
| kind === 'EMPTY_INPUT' || | ||
| kind === 'UNAUTHENTICATED' | ||
| ) { | ||
| // Permanent: the input shape OR the auth identity is the problem. | ||
| // Clear localStorage so the next sign-in starts clean. Install the | ||
| // reactive subscription so signed-in reads still work. | ||
| console.warn( | ||
| `[followed-countries] handoff permanent failure (kind=${kind}); clearing localStorage`, | ||
| ); | ||
| removeLocalStorage(); | ||
| _handoffState = 'failed-permanent'; | ||
| _initialSnapshotReceived = false; | ||
| void _startReactiveSubscription(userIdAtStart, gen); | ||
| return; | ||
| } |
There was a problem hiding this comment.
UNAUTHENTICATED treated as permanent clears localStorage during transient token race
UNAUTHENTICATED is classified as a permanent error and triggers removeLocalStorage(), irreversibly destroying the user's anonymous follows. But addCountry (same process, same error) explicitly comments: "Race: Clerk says we're signed in but Convex hasn't seen the identity yet (or the token expired)" and returns HANDOFF_PENDING (transient). During the sign-in handoff, _authStillMatches returning true at this point confirms the user is still signed in per Clerk — so the Convex UNAUTHENTICATED is always a transient JWT-delivery race, not a permanent auth problem. Treating it as permanent loses anonymous follows when a simple retry would have succeeded.
| if ( | |
| kind === 'INPUT_TOO_LARGE' || | |
| kind === 'EMPTY_INPUT' || | |
| kind === 'UNAUTHENTICATED' | |
| ) { | |
| // Permanent: the input shape OR the auth identity is the problem. | |
| // Clear localStorage so the next sign-in starts clean. Install the | |
| // reactive subscription so signed-in reads still work. | |
| console.warn( | |
| `[followed-countries] handoff permanent failure (kind=${kind}); clearing localStorage`, | |
| ); | |
| removeLocalStorage(); | |
| _handoffState = 'failed-permanent'; | |
| _initialSnapshotReceived = false; | |
| void _startReactiveSubscription(userIdAtStart, gen); | |
| return; | |
| } | |
| if (kind === 'INPUT_TOO_LARGE' || kind === 'EMPTY_INPUT') { | |
| // Permanent: the input shape is the problem. | |
| // Clear localStorage so the next sign-in starts clean. Install the | |
| // reactive subscription so signed-in reads still work. | |
| console.warn( | |
| `[followed-countries] handoff permanent failure (kind=${kind}); clearing localStorage`, | |
| ); | |
| removeLocalStorage(); | |
| _handoffState = 'failed-permanent'; | |
| _initialSnapshotReceived = false; | |
| void _startReactiveSubscription(userIdAtStart, gen); | |
| return; | |
| } | |
| if (kind === 'UNAUTHENTICATED') { | |
| // Transient: Clerk says we're signed in but Convex hasn't received | |
| // the JWT yet (token delivery race). Same classification as | |
| // addCountry. _authStillMatches returning true at this point | |
| // confirms the user is still signed in per Clerk, so this is NOT | |
| // a permanent auth problem. Retry via visibilitychange. | |
| _markFailedAndScheduleRetry(userIdAtStart, gen); | |
| return; | |
| } |
| // --------------------------------------------------------------------------- | ||
|
|
||
| /** Mirror of the server-side `convex/constants.ts::FREE_TIER_FOLLOW_LIMIT`. */ | ||
| export const FREE_TIER_FOLLOW_LIMIT = 3; |
There was a problem hiding this comment.
Client-side
FREE_TIER_FOLLOW_LIMIT can silently drift from server constant
The constant is hardcoded here as 3 — a copy of convex/constants.ts::FREE_TIER_FOLLOW_LIMIT. If the server value is bumped (e.g. to 5 for a promotion) without updating this file, anonymous users are capped at 3 in the UI while Convex would actually accept 5, and atCap in FollowButton would incorrectly show "Upgrade to follow more" at count 3. There is no test that asserts parity (unlike the ISO-2 registry parity test). Importing directly from the server constant file would enforce parity at compile time; alternatively, adding a parity assertion in the test suite would catch drift.
…p-bypass (P0) Codex round-3 review run 20260502-195816-dae403d7 (adv-001 / adv-002) flagged a P0 cap-bypass on `followCountry` and `mergeAnonymousLocal`. Convex per-document OCC tracks reads at the DOCUMENT level, not at the index-range level — so two parallel `followCountry` mutations from the same user can both read empty/ under-cap from `followedCountries` (an index range), both pass the cap check, and both insert. Cap bypass + potential duplicate (userId, country) rows. The same shape applies to `mergeAnonymousLocal` from N tabs at sign-in. Mitigation: a per-user serialization document (new table `followedCountriesUserMeta`) that every mutation reads AND writes. Convex's real OCC then forces concurrent same-user mutations to serialize on this row: the loser of the race retries, re-reads the post-winner state (which now contains the winner's `(userId, country)` row + bumped count), and either passes correctly (still under cap), throws `FREE_CAP`, or returns idempotent. The denormalized `count` field also makes the cap check O(1) — happy side effect that closes P3 #21 (`.collect()` for cap purposes is gone). Schema: new table `followedCountriesUserMeta` keyed by userId, with a denormalized `count` and `updatedAt`. Convex auto-deploys schema before handlers in the same push, so single-PR shipping is safe. Mutations: - `followCountry`: read meta first, use `count` for cap check (free tier only), patch/insert meta at the END after row insert + counter +1. Idempotent (already-followed) path skips meta write — no observable change, no race to lose. - `unfollowCountry`: read meta first, decrement to `Math.max(0, count - 1)` at the end. Idempotent (no-row) path skips meta write. - `mergeAnonymousLocal`: read meta for the cap denominator, `existingRows` still required for the dedup set. Patch meta with `existingCount + accepted.length` at the end. Skip meta write when `accepted.length === 0`. Tests (+9, total 283 → 292): - Concurrent same-user same-country `Promise.all` → exactly 1 row + 1 idempotent response. - Concurrent same-user cap-boundary (2 seeded + 2 attempts on cap=3) → exactly 1 fulfilled, 1 rejected with FREE_CAP, final ≤ cap. - Concurrent mixed follow/unfollow → consistent end state, parity invariant. - Concurrent `mergeAnonymousLocal` from 5 tabs (free user) → final ≤ cap, no duplicate (userId, country) rows. - Concurrent `mergeAnonymousLocal` from 5 tabs (PRO user) → exactly the deduped union, all per-country counters at 1. - Meta-count parity invariant after mixed mutation sequence. - Idempotent paths (followCountry on existing, unfollowCountry on absent) don't bump/decrement meta count. - FREE_CAP throw rolls back all writes (transaction atomicity). Concurrency-test caveat documented in the test file: convex-test 0.0.43's TransactionManager (node_modules/convex-test/dist/index.js:1268) takes a single `_waitOnCurrentFunction` lock at top-level mutation begin, so `Promise.all` of mutations runs strictly sequentially in the mock. There is NO real OCC retry simulator. Tests therefore prove the FINAL-STATE INVARIANT — even when the second mutation runs back-to-back against the post-winner state, cap/idempotency/meta-parity hold. In production the Convex platform's OCC layer turns the same final-state invariant into the cap-bypass guarantee. See memory `convex-occ-retry-vs-app-cas-conflict- different-layers` for the layer separation. Test helper `seedFollowedCountries(t, userId, codes)` added to seed both the rows AND the user-meta row in parity for tests that bypass the mutation API. Files: - convex/schema.ts: +`followedCountriesUserMeta` table + index. - convex/followedCountries.ts: +`readUserMeta` / `writeUserMeta` helpers, meta read/write inserted into all 3 mutation paths, expanded inline docs on the OCC mechanism. - convex/__tests__/followed-countries-mutations.test.ts: +9 concurrent / parity tests + `seedFollowedCountries` helper + `readUserMetaCount` helper + caveat block on convex-test's serialized mock. Verification: `npm run typecheck` ✅, `npm run typecheck:api` ✅, `npm run test:convex` 292 / 292 ✅.
…U on user-meta create (P0 v2) Codex round-4 review of PR #3621 caught that the round-3 P0 fix (followedCountriesUserMeta per-user lock) did not actually close the cap-bypass: the meta document is created LAZILY on first mutation, and Convex per-document OCC tracks reads at the document level, not at the empty-index-range level. Two parallel first-ever mutations from the same brand-new user could both read meta=undefined and both INSERT, producing duplicate meta rows that break the next .unique() read AND re-open cap-bypass / counter double-increment. Fix: Approach B (pre-seeded sharded lock). - New table followedCountriesShards with one row per shard id in [0, SHARD_COUNT) (SHARD_COUNT=64 in convex/constants.ts), pre-seeded by _seedShards. - convex/lib/shards.ts::userIdToShard(userId) — deterministic djb2 hash. Frozen contract; changing it would silently remap users. - Every followCountry / unfollowCountry / mergeAnonymousLocal mutation reads its shard at the top (throws SHARDS_NOT_SEEDED loud if missing — operator error, never silent), then patches lastTouchedAt at the end of any non-idempotent path. The read+write pair on an ALREADY-EXISTING document is what triggers Convex OCC to serialize concurrent same-user mutations. - Tier 2 (existing user-meta row) kept additionally for the O(1) cap-check denominator and parity invariant — but its lazy create is now race-free under the shard lock. - Daily cron followed-countries-shards-seed at 03:00 UTC re-runs _seedShards (idempotent) so a missed deploy-seed step self-heals. - Public seedShards mutation for operator CLI: npx convex run --prod followedCountries:seedShards after a fresh deploy without waiting for the cron. Tests added (mutations test file, +6 tests, 292 -> 298): - first-ever follow on a brand-new user creates exactly 1 meta row - two back-to-back mergeAnonymousLocal calls on a brand-new user -> one meta row, no duplicates - operator running _seedShards after partial seed completes idempotently (0 steady-state, plugs holes only) - SHARDS_NOT_SEEDED throws when shards table is empty (operator error path, all three mutations) - public seedShards mutation reachable via operator CLI surface - userIdToShard determinism + range invariant Test fixtures (makeT() helper) call _seedShards before any mutation runs, mirroring the production deploy + cron post-condition. Files changed: - convex/constants.ts: SHARD_COUNT=64 - convex/schema.ts: followedCountriesShards table + index - convex/lib/shards.ts (new): userIdToShard djb2 - convex/followedCountries.ts: shard read/write at every mutation, _seedShards (internal) + seedShards (public operator) mutations - convex/crons.ts: daily _seedShards cron at 03:00 UTC - convex/__tests__/followed-countries-mutations.test.ts: makeT() helper, 6 new tests - convex/__tests__/followed-countries-queries.test.ts: makeT() helper (queries-test invokes followCountry, also needs shards) References: Codex review run /private/tmp/worldmonitor-pr3621-review/ findings_round4.md (P0 v2). Memory: convex-occ-retry-vs-app-cas-conflict-different-layers (layer separation: this is the app-side serialization layer that lets Convex OCC do its job).
…f retry (P1)
Codex round-4 P1: subscribeAuthState emits the current signed-in
state IMMEDIATELY on subscribe, but Convex auth is not yet ready (the
JWT has not been attached to the Convex client at that tick).
mergeAnonymousLocal fires before Convex sees the auth -> throws
ConvexError({kind:'UNAUTHENTICATED'}).
The previous classification (Phase-2 P1 #3) put UNAUTHENTICATED in the
PERMANENT-error list alongside INPUT_TOO_LARGE / EMPTY_INPUT, so every
transient auth lag cleared localStorage and lost the anonymous follows.
Two-part fix:
(a) Treat UNAUTHENTICATED as TRANSIENT, not permanent.
_runHandoff catch path no longer routes UNAUTHENTICATED to
failed-permanent + removeLocalStorage. It falls through to
_markFailedAndScheduleRetry, which arms the visibilitychange retry
and counts toward MAX_HANDOFF_RETRIES (5). A genuinely-stuck auth
mismatch eventually flips to failed-permanent after the budget is
exhausted, same as the network-failure path.
(b) Defer the merge until Convex auth is ready.
waitForConvexAuth() exists at src/services/convex-client.ts:79 —
it resolves when Convex's setAuth callback confirms the client is
authenticated, with a 10s timeout. We import it and await it BEFORE
the mergeAnonymousLocal call so the typical race never fires at
all. On timeout we still attempt the call; the catch from (a)
treats any resulting UNAUTHENTICATED as transient and the
visibilitychange retry wins once Convex catches up.
Test seam: _waitForConvexAuthFn module-level binding + new
_setDepsForTests({waitForConvexAuth}) override so tests can drive the
deferred-by-auth flow without going through the real Convex client.
Tests added (tests/followed-countries-sign-in-handoff.test.mjs,
+3 new tests, 1 modified — 37 -> 40 in this file):
- first call throws UNAUTHENTICATED, visibility retry succeeds ->
final state has merged data, localStorage cleared, no follows lost
(the canonical scenario this fix targets)
- UNAUTHENTICATED IS counted toward MAX_HANDOFF_RETRIES — 5
consecutive UNAUTHENTICATED throws -> failed-permanent (proves
runaway-retry guard intact for genuinely-stuck auth)
- waitForConvexAuth is awaited BEFORE the merge call (proves
deferred-by-auth path is wired correctly)
Modified: the original "UNAUTHENTICATED -> 'failed-permanent';
localStorage cleared" test is rewritten to assert the new behavior
(state='failed', retry armed, localStorage retained) with an inline
comment explaining the previous behavior was wrong.
Files changed:
- src/services/followed-countries.ts: import waitForConvexAuth from
convex-client, _waitForConvexAuthFn seam, await before merge,
drop UNAUTHENTICATED from permanent-error branch
- tests/followed-countries-sign-in-handoff.test.mjs: 1 modified +
3 new tests
References: Codex review run /private/tmp/worldmonitor-pr3621-review/
findings_round4.md (P1). waitForConvexAuth helper found at
src/services/convex-client.ts:79 — exists today and is used by the
existing entitlement subscription path; this fix wires it into the
followed-countries handoff for the same reason.
The followCountry / unfollowCountry / mergeAnonymousLocal mutations throw SHARDS_NOT_SEEDED if followedCountriesShards is empty. Today the seed runs only via the 03:00 UTC daily cron, so a deploy landing at 04:00 UTC would leave the feature broken for ~23h until the next cron tick. Run npx convex run --prod followedCountries:_seedShards inline after npx convex deploy --yes so the table is populated before any traffic hits the new mutations. Idempotent: existing shard rows are skipped, only missing ids in [0, SHARD_COUNT) are inserted.
…ove public seed (P1) Two stacked P1 issues from Codex round-3 review of PR #3621: 1. Public seedShards mutation was unauthenticated — any browser ConvexHttpClient could call it. Removed; the post-deploy CI step now targets the internal _seedShards directly via `npx convex run --prod followedCountries:_seedShards` (npx convex run resolves internal functions by file:export path). 2. _seedShards has a TOCTOU race: two simultaneous calls against an empty table both read empty, both insert the full range, producing 2 rows per shardId. Previously readShardOrThrow used .unique() which throws on duplicates → bricks the affected shard for all users hashing to it. Approach D (real-world correct): make readShardOrThrow tolerant of duplicates via .first() (returns oldest by _creationTime tiebreaker, so OCC contention is preserved across all in-flight mutations on that shard during the duplicate window) AND add a daily _dedupeShards cron that deletes extras keeping the oldest row per shardId. Tests: - duplicate shard rows: mutation succeeds, counter parity holds - _dedupeShards: zero dups → no-op; N dups → reduces to 1 row per shardId, oldest survives - _seedShards idempotent under back-to-back concurrent re-run - public seedShards no longer exported (source-text negative assertion) Net: 298 → 302 tests, all green.
… foundation) Third-pass review P2: PR A's src/utils/follow-button.ts:220 emits .wm-follow-btn* markup mounted on three live surfaces (CIIPanel, CountryDeepDivePanel, CountryIntelModal) but no CSS shipped — buttons rendered as native browser controls and the CIIPanel host (a span inside a block-layout .cii-country row) put the star on its own line. Visual analogue: .cii-share-btn (transparent + 1px var(--border) + var(--text-muted) text + var(--semantic-info) hover). Same border-radius family, same micro-padding scale. CSS variables used: --border, --border-strong, --text-muted, --semantic-info. Reuses the existing @Keyframes spin. Critical layout fix (CIIPanel): added position:relative to .cii-country and absolute-positioned .cii-follow-btn-host at top:6px left:6px. The :not(:empty) gate keeps the rule a no-op when the feature flag is off (handle.html === ''), and the sibling-combinator rule .cii-follow-btn-host:not(:empty) ~ .cii-header { padding-left:26px } shifts header content right ONLY when the star is mounted — flag-off rows render identically to today. CDP + CountryIntelModal hosts are simple display:inline-flex since both parent containers (.cdp-header-left, .country-intel-title) are already flex with gap. Polish (color tuning, hover transitions, animation curves, empty/cap nudge styling) intentionally deferred to PR B per the third-pass review. +156 lines (single appended block in src/styles/main.css).
Per-user brief composition now reads followed countries via the
/relay/followed-countries endpoint shipped in PR A and applies a
stable, in-lane bias toward followed-country threads — without
displacing critical events from the top.
- scripts/lib/followed-countries-fetch.cjs: CJS helper mirroring
user-context.cjs::fetchUserPreferences. 10s timeout, returns []
on every soft-failure path (missing env, 4xx/5xx, transport error,
malformed JSON, wrong shape, non-string entries). Never throws.
- scripts/lib/brief-compose.mjs: reorderForFollowedBias() applies a
stable composite sort (severityLane, isFollowed, originalIndex).
Critical-severity threads stay in their lane regardless of bias —
R10 invariant. FOLLOWED_BIAS_MULTIPLIER (default 1.25, env-tunable)
is exported as the public knob U11 telemetry will correlate against.
Bias runs BEFORE filterTopStories; LLM editorial truth via
synthesis.rankedStoryHashes still wins when present.
- scripts/seed-digest-notifications.mjs: per-user fetchFollowedCountries
+ free-tier clamp (tier < 1 → slice(0, 3)) — third layer of the
three-layer entitlement gate (UI cap @ FollowButton, mutation cap @
Convex, composer clamp here; memory: paywalled-feature-needs-three-
layer-entitlement-gate). getUserTier() shares relay-fetch with
isUserPro and fails OPEN on transport error (honors full list,
same polarity as the sibling helper).
- Operator visibility via structured log line, NOT envelope shape
change. server/_shared/brief-render.js asserts a strict allow-list
for envelope keys; adding a debug field would force a
BRIEF_ENVELOPE_VERSION bump and tug at 7 reader sites including
api/latest-brief.ts (explicit no-touch constraint). Pinned by
'envelope shape is unchanged' test.
- 36 new tests:
- 16 fetch tests: env-missing, happy path, 404/4xx/5xx, transport
error, malformed JSON, wrong shape, non-string-entries filter,
invalid userId, AbortSignal.timeout integration.
- 20 bias tests: empty watchlist no-op, lift within lane, R10
critical override, free-tier clamp pass-through, rankedStoryHashes
precedence, case-insensitive country match, envelope-shape
invariance contract.
Sibling-consumer audit (grep fetchUserPreferences|/relay/user-
preferences across scripts/api/server/convex): scripts/lib/user-
context.cjs (producer), scripts/seed-digest-notifications.mjs
(consumer of extractUserContext, untouched), scripts/notification-
relay.cjs (consumer, untouched), convex/http.ts (relay definition,
unchanged). New field doesn't transit userPreferences — separate
followedCountries Convex table + dedicated /relay/followed-countries
endpoint from PR A. Zero impact on existing readers.
Plan: U10
PR: #3621-stack (PR C builds on PR A + PR B)
Measures whether U10's followed-country bias actually lifts engagement
on followed-country threads. Fires brief_thread_open events from both
the dashboard panel and the hosted magazine.
Event shape: { country, followed, severity, source } where
- country: ISO-2 code or null
- followed: boolean (computed at click time, not render time)
- severity: critical|high|medium|low|info|null
- source: 'dashboard' | 'magazine'
Dashboard (src/components/LatestBriefPanel.ts:421-433):
- Single per-brief-open signal on cover-card onclick (the panel only
renders one click target → magazine). Measures dashboard→magazine
pull-through. country/severity null, source='dashboard'.
- Try/catch wrapper — analytics outage does NOT block navigation.
Magazine (server/_shared/brief-render.js + api/brief/[userId]/[issueDate].ts):
- Auth'd route fetches followedCountries via /relay/followed-countries
(1500ms timeout, soft-fail to []) and passes through renderBriefMagazine.
- Renderer stamps data-thread-open / data-country / data-severity /
data-followed on each story's source-link anchor. Per-story
granularity matches what users actually click.
- Inline BRIEF_THREAD_OPEN_SCRIPT installs document-level capture-phase
click listener that forwards to window.umami?.track(...).
Try/catch wrapped, never calls preventDefault — navigation continues.
- Public-mirror route does NOT pass followedCountries; public-mode
forces followedSet empty so every story stamps data-followed='0'
(no auth identity → can't measure followed engagement).
Composite-country handling ('IL / LB'): tokenize on whitespace+slash,
keep two-letter ASCII tokens, primary = first token, followed=true if
ANY token is in watchlist. Bounded analytics cardinality.
NO envelope-shape change — preserves U10's contract. Magazine renderer
accepts followedCountries as an additive option; the envelope itself
is unchanged so api/latest-brief.ts and other read sites are unaffected.
22 new tests covering: dashboard event shape on click, no-country
fallback, followed-true / followed-false, double-click fires twice
(no dedup), analytics throw doesn't block click, magazine
data-followed='1' / data-followed absent, composite-country tokenizer.
Plan: U11
PR: #3621-stack (PR C builds on PR A + PR B)
Per-user brief composition now reads followed countries via the
/relay/followed-countries endpoint shipped in PR A and applies a
stable, in-lane bias toward followed-country threads — without
displacing critical events from the top.
- scripts/lib/followed-countries-fetch.cjs: CJS helper mirroring
user-context.cjs::fetchUserPreferences. 10s timeout, returns []
on every soft-failure path (missing env, 4xx/5xx, transport error,
malformed JSON, wrong shape, non-string entries). Never throws.
- scripts/lib/brief-compose.mjs: reorderForFollowedBias() applies a
stable composite sort (severityLane, isFollowed, originalIndex).
Critical-severity threads stay in their lane regardless of bias —
R10 invariant. FOLLOWED_BIAS_MULTIPLIER (default 1.25, env-tunable)
is exported as the public knob U11 telemetry will correlate against.
Bias runs BEFORE filterTopStories; LLM editorial truth via
synthesis.rankedStoryHashes still wins when present.
- scripts/seed-digest-notifications.mjs: per-user fetchFollowedCountries
+ free-tier clamp (tier < 1 → slice(0, 3)) — third layer of the
three-layer entitlement gate (UI cap @ FollowButton, mutation cap @
Convex, composer clamp here; memory: paywalled-feature-needs-three-
layer-entitlement-gate). getUserTier() shares relay-fetch with
isUserPro and fails OPEN on transport error (honors full list,
same polarity as the sibling helper).
- Operator visibility via structured log line, NOT envelope shape
change. server/_shared/brief-render.js asserts a strict allow-list
for envelope keys; adding a debug field would force a
BRIEF_ENVELOPE_VERSION bump and tug at 7 reader sites including
api/latest-brief.ts (explicit no-touch constraint). Pinned by
'envelope shape is unchanged' test.
- 36 new tests:
- 16 fetch tests: env-missing, happy path, 404/4xx/5xx, transport
error, malformed JSON, wrong shape, non-string-entries filter,
invalid userId, AbortSignal.timeout integration.
- 20 bias tests: empty watchlist no-op, lift within lane, R10
critical override, free-tier clamp pass-through, rankedStoryHashes
precedence, case-insensitive country match, envelope-shape
invariance contract.
Sibling-consumer audit (grep fetchUserPreferences|/relay/user-
preferences across scripts/api/server/convex): scripts/lib/user-
context.cjs (producer), scripts/seed-digest-notifications.mjs
(consumer of extractUserContext, untouched), scripts/notification-
relay.cjs (consumer, untouched), convex/http.ts (relay definition,
unchanged). New field doesn't transit userPreferences — separate
followedCountries Convex table + dedicated /relay/followed-countries
endpoint from PR A. Zero impact on existing readers.
Plan: U10
PR: #3621-stack (PR C builds on PR A + PR B)
Measures whether U10's followed-country bias actually lifts engagement
on followed-country threads. Fires brief_thread_open events from both
the dashboard panel and the hosted magazine.
Event shape: { country, followed, severity, source } where
- country: ISO-2 code or null
- followed: boolean (computed at click time, not render time)
- severity: critical|high|medium|low|info|null
- source: 'dashboard' | 'magazine'
Dashboard (src/components/LatestBriefPanel.ts:421-433):
- Single per-brief-open signal on cover-card onclick (the panel only
renders one click target → magazine). Measures dashboard→magazine
pull-through. country/severity null, source='dashboard'.
- Try/catch wrapper — analytics outage does NOT block navigation.
Magazine (server/_shared/brief-render.js + api/brief/[userId]/[issueDate].ts):
- Auth'd route fetches followedCountries via /relay/followed-countries
(1500ms timeout, soft-fail to []) and passes through renderBriefMagazine.
- Renderer stamps data-thread-open / data-country / data-severity /
data-followed on each story's source-link anchor. Per-story
granularity matches what users actually click.
- Inline BRIEF_THREAD_OPEN_SCRIPT installs document-level capture-phase
click listener that forwards to window.umami?.track(...).
Try/catch wrapped, never calls preventDefault — navigation continues.
- Public-mirror route does NOT pass followedCountries; public-mode
forces followedSet empty so every story stamps data-followed='0'
(no auth identity → can't measure followed engagement).
Composite-country handling ('IL / LB'): tokenize on whitespace+slash,
keep two-letter ASCII tokens, primary = first token, followed=true if
ANY token is in watchlist. Bounded analytics cardinality.
NO envelope-shape change — preserves U10's contract. Magazine renderer
accepts followedCountries as an additive option; the envelope itself
is unchanged so api/latest-brief.ts and other read sites are unaffected.
22 new tests covering: dashboard event shape on click, no-country
fallback, followed-true / followed-false, double-click fires twice
(no dedup), analytics throw doesn't block click, magazine
data-followed='1' / data-followed absent, composite-country tokenizer.
Plan: U11
PR: #3621-stack (PR C builds on PR A + PR B)
Followed countries appear at the top of the CIIPanel ranking,
non-followed maintain their original relative order. Panel re-
renders reactively on WM_FOLLOWED_COUNTRIES_CHANGED.
- Partition logic extracted to _cii-panel-partition.ts (pure helper,
testable without Panel.ts's import.meta.glob deps).
- Stable sort via Array.prototype.filter twice (preserves order by
construction, avoids the sort-before-positional-index trap).
- Two section labels ('FOLLOWING' / 'ALL') render only when both
groups are non-empty; zero-followed and all-followed cases match
today's UX byte-identically.
- subscribe() from the service (NOT manual window.addEventListener);
unsubscribe in destroy() to prevent listener leaks across re-mount.
- rerenderRows() reuses cached this.scores — no recomputation on
watchlist change.
- 16 new tests covering: zero-followed, all-followed, partial,
followed-but-absent-in-scores, watchlist-mutation reactivity,
and section-label rendering predicate.
Plan: U6
PR: #3621-stack (PR B builds on PR A foundation)
Toggleable chip on country-scoped multi-row panels that hides
non-followed entries client-side. Default off. Persisted per-panel-
instance in localStorage so each panel's choice survives reload but
doesn't bleed across unrelated panels.
- Reusable factory in src/utils/followed-only-chip.ts mirroring
follow-button's { html, attach, isActive } shape. Storage key:
wm-followed-only-filter-${panelId}.
- Disabled when watchlist empty (tooltip prompts user to follow
countries first); re-renders disabled state on
WM_FOLLOWED_COUNTRIES_CHANGED.
- Hidden entirely when VITE_FOLLOW_COUNTRIES_ENABLED is off.
- Empty-state message when filter yields zero rows: 'No items in
your followed countries. Add countries by tapping the star, or
turn off this filter.'
Target panels (multi-country lists with per-row ISO-2 codes):
- DiseaseOutbreaksPanel
- DisplacementPanel (filter applies inside both origins + hosts tabs)
Skipped CountryDeepDivePanel (single-country, tautological).
Skipped news / climate / breaking-news panels (no per-row country
code in row data).
24 new tests covering: chip helper unit behavior (default off,
persistence, per-panel scoping, disabled tooltip, re-render on
watchlist change, feature-flag-off branch, onChange callback) +
panel integration (5/5 rows; 5→2 with 2 followed; empty watchlist
disables chip but persisted-active state preserved; rows without
code are filtered when chip on; re-filter on watchlist change).
Plan: U7
PR: #3621-stack (PR B builds on PR A foundation)
Per-user brief composition now reads followed countries via the
/relay/followed-countries endpoint shipped in PR A and applies a
stable, in-lane bias toward followed-country threads — without
displacing critical events from the top.
- scripts/lib/followed-countries-fetch.cjs: CJS helper mirroring
user-context.cjs::fetchUserPreferences. 10s timeout, returns []
on every soft-failure path (missing env, 4xx/5xx, transport error,
malformed JSON, wrong shape, non-string entries). Never throws.
- scripts/lib/brief-compose.mjs: reorderForFollowedBias() applies a
stable composite sort (severityLane, isFollowed, originalIndex).
Critical-severity threads stay in their lane regardless of bias —
R10 invariant. FOLLOWED_BIAS_MULTIPLIER (default 1.25, env-tunable)
is exported as the public knob U11 telemetry will correlate against.
Bias runs BEFORE filterTopStories; LLM editorial truth via
synthesis.rankedStoryHashes still wins when present.
- scripts/seed-digest-notifications.mjs: per-user fetchFollowedCountries
+ free-tier clamp (tier < 1 → slice(0, 3)) — third layer of the
three-layer entitlement gate (UI cap @ FollowButton, mutation cap @
Convex, composer clamp here; memory: paywalled-feature-needs-three-
layer-entitlement-gate). getUserTier() shares relay-fetch with
isUserPro and fails OPEN on transport error (honors full list,
same polarity as the sibling helper).
- Operator visibility via structured log line, NOT envelope shape
change. server/_shared/brief-render.js asserts a strict allow-list
for envelope keys; adding a debug field would force a
BRIEF_ENVELOPE_VERSION bump and tug at 7 reader sites including
api/latest-brief.ts (explicit no-touch constraint). Pinned by
'envelope shape is unchanged' test.
- 36 new tests:
- 16 fetch tests: env-missing, happy path, 404/4xx/5xx, transport
error, malformed JSON, wrong shape, non-string-entries filter,
invalid userId, AbortSignal.timeout integration.
- 20 bias tests: empty watchlist no-op, lift within lane, R10
critical override, free-tier clamp pass-through, rankedStoryHashes
precedence, case-insensitive country match, envelope-shape
invariance contract.
Sibling-consumer audit (grep fetchUserPreferences|/relay/user-
preferences across scripts/api/server/convex): scripts/lib/user-
context.cjs (producer), scripts/seed-digest-notifications.mjs
(consumer of extractUserContext, untouched), scripts/notification-
relay.cjs (consumer, untouched), convex/http.ts (relay definition,
unchanged). New field doesn't transit userPreferences — separate
followedCountries Convex table + dedicated /relay/followed-countries
endpoint from PR A. Zero impact on existing readers.
Plan: U10
PR: #3621-stack (PR C builds on PR A + PR B)
Measures whether U10's followed-country bias actually lifts engagement
on followed-country threads. Fires brief_thread_open events from both
the dashboard panel and the hosted magazine.
Event shape: { country, followed, severity, source } where
- country: ISO-2 code or null
- followed: boolean (computed at click time, not render time)
- severity: critical|high|medium|low|info|null
- source: 'dashboard' | 'magazine'
Dashboard (src/components/LatestBriefPanel.ts:421-433):
- Single per-brief-open signal on cover-card onclick (the panel only
renders one click target → magazine). Measures dashboard→magazine
pull-through. country/severity null, source='dashboard'.
- Try/catch wrapper — analytics outage does NOT block navigation.
Magazine (server/_shared/brief-render.js + api/brief/[userId]/[issueDate].ts):
- Auth'd route fetches followedCountries via /relay/followed-countries
(1500ms timeout, soft-fail to []) and passes through renderBriefMagazine.
- Renderer stamps data-thread-open / data-country / data-severity /
data-followed on each story's source-link anchor. Per-story
granularity matches what users actually click.
- Inline BRIEF_THREAD_OPEN_SCRIPT installs document-level capture-phase
click listener that forwards to window.umami?.track(...).
Try/catch wrapped, never calls preventDefault — navigation continues.
- Public-mirror route does NOT pass followedCountries; public-mode
forces followedSet empty so every story stamps data-followed='0'
(no auth identity → can't measure followed engagement).
Composite-country handling ('IL / LB'): tokenize on whitespace+slash,
keep two-letter ASCII tokens, primary = first token, followed=true if
ANY token is in watchlist. Bounded analytics cardinality.
NO envelope-shape change — preserves U10's contract. Magazine renderer
accepts followedCountries as an additive option; the envelope itself
is unchanged so api/latest-brief.ts and other read sites are unaffected.
22 new tests covering: dashboard event shape on click, no-country
fallback, followed-true / followed-false, double-click fires twice
(no dedup), analytics throw doesn't block click, magazine
data-followed='1' / data-followed absent, composite-country tokenizer.
Plan: U11
PR: #3621-stack (PR C builds on PR A + PR B)
…p + deep-dive notify (#3629) * feat(cii): pin followed countries to top with stable sort (U6) Followed countries appear at the top of the CIIPanel ranking, non-followed maintain their original relative order. Panel re- renders reactively on WM_FOLLOWED_COUNTRIES_CHANGED. - Partition logic extracted to _cii-panel-partition.ts (pure helper, testable without Panel.ts's import.meta.glob deps). - Stable sort via Array.prototype.filter twice (preserves order by construction, avoids the sort-before-positional-index trap). - Two section labels ('FOLLOWING' / 'ALL') render only when both groups are non-empty; zero-followed and all-followed cases match today's UX byte-identically. - subscribe() from the service (NOT manual window.addEventListener); unsubscribe in destroy() to prevent listener leaks across re-mount. - rerenderRows() reuses cached this.scores — no recomputation on watchlist change. - 16 new tests covering: zero-followed, all-followed, partial, followed-but-absent-in-scores, watchlist-mutation reactivity, and section-label rendering predicate. Plan: U6 PR: #3621-stack (PR B builds on PR A foundation) * feat(panels): 'Followed only' filter chip on Disease + Displacement (U7) Toggleable chip on country-scoped multi-row panels that hides non-followed entries client-side. Default off. Persisted per-panel- instance in localStorage so each panel's choice survives reload but doesn't bleed across unrelated panels. - Reusable factory in src/utils/followed-only-chip.ts mirroring follow-button's { html, attach, isActive } shape. Storage key: wm-followed-only-filter-${panelId}. - Disabled when watchlist empty (tooltip prompts user to follow countries first); re-renders disabled state on WM_FOLLOWED_COUNTRIES_CHANGED. - Hidden entirely when VITE_FOLLOW_COUNTRIES_ENABLED is off. - Empty-state message when filter yields zero rows: 'No items in your followed countries. Add countries by tapping the star, or turn off this filter.' Target panels (multi-country lists with per-row ISO-2 codes): - DiseaseOutbreaksPanel - DisplacementPanel (filter applies inside both origins + hosts tabs) Skipped CountryDeepDivePanel (single-country, tautological). Skipped news / climate / breaking-news panels (no per-row country code in row data). 24 new tests covering: chip helper unit behavior (default off, persistence, per-panel scoping, disabled tooltip, re-render on watchlist change, feature-flag-off branch, onChange callback) + panel integration (5/5 rows; 5→2 with 2 followed; empty watchlist disables chip but persisted-active state preserved; rows without code are filtered when chip on; re-filter on watchlist change). Plan: U7 PR: #3621-stack (PR B builds on PR A foundation) * feat(deep-dive): 'Notify me about this country' sub-action under follow star (U8 degraded) Mounts a small inline link next to the FollowButton on the Country Deep Dive header. Visible only while the user is following the country; clicking opens the notifications settings tab. Degraded path: the alertRules.countries schema PR has NOT merged, so this PR ships only the link wiring -- no form pre-fill. The future PR swaps the body of openNotificationsForCountry to pre-fill the create form via routing param. Injection point is intentionally a single helper so the change is local. See plan U8 R9 + TODO comment inside src/utils/notify-country-link.ts. - New src/utils/notify-country-link.ts factory: { html, attach } -> teardown shape, mirrors FollowButton + FollowedOnlyChip; subscribes to WM_FOLLOWED_COUNTRIES_CHANGED so visibility tracks per-country state. - CountryDeepDivePanel mounts a sibling host beside cdp-follow-btn-host and tears it down alongside the FollowButton. - Event-handlers wires WM_OPEN_NOTIFICATIONS_FOR_COUNTRY to unifiedSettings.open('notifications') (degraded -- detail.country ignored until schema PR enables pre-fill). - main.css adds .cdp-notify-link understated dotted-underline style. - 12 new tests in tests/country-deep-dive-notify-sub-action.test.mjs cover visibility / reactivity / click->helper / click->real event / feature-flag-off no-op / teardown idempotency. * fix(followed-countries): three review fixes — flag gate, listener teardown, chip placement PR #3629 (PR B of followed-countries) review fixes: Finding 1 (P1) — CIIPanel.ts:174 partitioned by getFollowed() regardless of the VITE_FOLLOW_COUNTRIES_ENABLED flag. `getFollowed()` reads localStorage in anonymous mode whether the flag is on or off (only mutations are short-circuited), so flag-off users still saw partitioning + FOLLOWING / ALL section labels even though FollowButton was hidden. Gate the partition input with isFollowFeatureEnabled() — when off, treat followed list as [] so partitionByFollowed becomes a no-op. Single source-of-truth gate in buildList() covers both render paths (refresh / rerenderRows / renderFromCached all flow through it). Finding 2 (P2) — event-handlers.ts:1123 added the WM_OPEN_NOTIFICATIONS_FOR_COUNTRY listener with an inline anonymous function in setupUnifiedSettings. Same-document reinit (HMR / test harnesses / multiple App instances) accumulated listeners that retained stale AppContext closures — every dispatched event fired all of them against stale state. Stored the handler on boundNotifyForCountryHandler and removed it in destroy(), matching the existing bound-handler pattern (boundFocalPointsReadyHandler, boundThemeChangedHandler, etc). Finding 3 (P2) — DiseaseOutbreaksPanel.ts:80 and DisplacementPanel.ts:59 appended the "Followed only" chip host to this.header AFTER the Panel base class had already appended .panel-close-btn (Panel.ts:748). Result: close button no longer rightmost, breaking user expectation that X is the last header control. Use insertBefore against the existing .panel-close-btn so the chip lands directly before close; falls back to appendChild defensively if the close button isn't present. Tests: +9 (cii-panel-pin-to-top: flag-off identity passthrough; country-deep-dive-notify-sub-action: listener install/destroy lifecycle + HMR shape; country-panels-followed-only-filter: chip placement before close on both panel shapes + bug-shape regression guard). typecheck + typecheck:api clean. test:data 7977 / 0 fail (was 7968). * fix(followed-countries): handle followed-only edge cases * fix(followed-countries): harden cap-drop toast * ci: re-trigger workflows after stack rebase * fix(followed-countries): clear cap-drop toast timer
Per-user brief composition now reads followed countries via the
/relay/followed-countries endpoint shipped in PR A and applies a
stable, in-lane bias toward followed-country threads — without
displacing critical events from the top.
- scripts/lib/followed-countries-fetch.cjs: CJS helper mirroring
user-context.cjs::fetchUserPreferences. 10s timeout, returns []
on every soft-failure path (missing env, 4xx/5xx, transport error,
malformed JSON, wrong shape, non-string entries). Never throws.
- scripts/lib/brief-compose.mjs: reorderForFollowedBias() applies a
stable composite sort (severityLane, isFollowed, originalIndex).
Critical-severity threads stay in their lane regardless of bias —
R10 invariant. FOLLOWED_BIAS_MULTIPLIER (default 1.25, env-tunable)
is exported as the public knob U11 telemetry will correlate against.
Bias runs BEFORE filterTopStories; LLM editorial truth via
synthesis.rankedStoryHashes still wins when present.
- scripts/seed-digest-notifications.mjs: per-user fetchFollowedCountries
+ free-tier clamp (tier < 1 → slice(0, 3)) — third layer of the
three-layer entitlement gate (UI cap @ FollowButton, mutation cap @
Convex, composer clamp here; memory: paywalled-feature-needs-three-
layer-entitlement-gate). getUserTier() shares relay-fetch with
isUserPro and fails OPEN on transport error (honors full list,
same polarity as the sibling helper).
- Operator visibility via structured log line, NOT envelope shape
change. server/_shared/brief-render.js asserts a strict allow-list
for envelope keys; adding a debug field would force a
BRIEF_ENVELOPE_VERSION bump and tug at 7 reader sites including
api/latest-brief.ts (explicit no-touch constraint). Pinned by
'envelope shape is unchanged' test.
- 36 new tests:
- 16 fetch tests: env-missing, happy path, 404/4xx/5xx, transport
error, malformed JSON, wrong shape, non-string-entries filter,
invalid userId, AbortSignal.timeout integration.
- 20 bias tests: empty watchlist no-op, lift within lane, R10
critical override, free-tier clamp pass-through, rankedStoryHashes
precedence, case-insensitive country match, envelope-shape
invariance contract.
Sibling-consumer audit (grep fetchUserPreferences|/relay/user-
preferences across scripts/api/server/convex): scripts/lib/user-
context.cjs (producer), scripts/seed-digest-notifications.mjs
(consumer of extractUserContext, untouched), scripts/notification-
relay.cjs (consumer, untouched), convex/http.ts (relay definition,
unchanged). New field doesn't transit userPreferences — separate
followedCountries Convex table + dedicated /relay/followed-countries
endpoint from PR A. Zero impact on existing readers.
Plan: U10
PR: #3621-stack (PR C builds on PR A + PR B)
Measures whether U10's followed-country bias actually lifts engagement
on followed-country threads. Fires brief_thread_open events from both
the dashboard panel and the hosted magazine.
Event shape: { country, followed, severity, source } where
- country: ISO-2 code or null
- followed: boolean (computed at click time, not render time)
- severity: critical|high|medium|low|info|null
- source: 'dashboard' | 'magazine'
Dashboard (src/components/LatestBriefPanel.ts:421-433):
- Single per-brief-open signal on cover-card onclick (the panel only
renders one click target → magazine). Measures dashboard→magazine
pull-through. country/severity null, source='dashboard'.
- Try/catch wrapper — analytics outage does NOT block navigation.
Magazine (server/_shared/brief-render.js + api/brief/[userId]/[issueDate].ts):
- Auth'd route fetches followedCountries via /relay/followed-countries
(1500ms timeout, soft-fail to []) and passes through renderBriefMagazine.
- Renderer stamps data-thread-open / data-country / data-severity /
data-followed on each story's source-link anchor. Per-story
granularity matches what users actually click.
- Inline BRIEF_THREAD_OPEN_SCRIPT installs document-level capture-phase
click listener that forwards to window.umami?.track(...).
Try/catch wrapped, never calls preventDefault — navigation continues.
- Public-mirror route does NOT pass followedCountries; public-mode
forces followedSet empty so every story stamps data-followed='0'
(no auth identity → can't measure followed engagement).
Composite-country handling ('IL / LB'): tokenize on whitespace+slash,
keep two-letter ASCII tokens, primary = first token, followed=true if
ANY token is in watchlist. Bounded analytics cardinality.
NO envelope-shape change — preserves U10's contract. Magazine renderer
accepts followedCountries as an additive option; the envelope itself
is unchanged so api/latest-brief.ts and other read sites are unaffected.
22 new tests covering: dashboard event shape on click, no-country
fallback, followed-true / followed-false, double-click fires twice
(no dedup), analytics throw doesn't block click, magazine
data-followed='1' / data-followed absent, composite-country tokenizer.
Plan: U11
PR: #3621-stack (PR C builds on PR A + PR B)
Per-user brief composition now reads followed countries via the
/relay/followed-countries endpoint shipped in PR A and applies a
stable, in-lane bias toward followed-country threads — without
displacing critical events from the top.
- scripts/lib/followed-countries-fetch.cjs: CJS helper mirroring
user-context.cjs::fetchUserPreferences. 10s timeout, returns []
on every soft-failure path (missing env, 4xx/5xx, transport error,
malformed JSON, wrong shape, non-string entries). Never throws.
- scripts/lib/brief-compose.mjs: reorderForFollowedBias() applies a
stable composite sort (severityLane, isFollowed, originalIndex).
Critical-severity threads stay in their lane regardless of bias —
R10 invariant. FOLLOWED_BIAS_MULTIPLIER (default 1.25, env-tunable)
is exported as the public knob U11 telemetry will correlate against.
Bias runs BEFORE filterTopStories; LLM editorial truth via
synthesis.rankedStoryHashes still wins when present.
- scripts/seed-digest-notifications.mjs: per-user fetchFollowedCountries
+ free-tier clamp (tier < 1 → slice(0, 3)) — third layer of the
three-layer entitlement gate (UI cap @ FollowButton, mutation cap @
Convex, composer clamp here; memory: paywalled-feature-needs-three-
layer-entitlement-gate). getUserTier() shares relay-fetch with
isUserPro and fails OPEN on transport error (honors full list,
same polarity as the sibling helper).
- Operator visibility via structured log line, NOT envelope shape
change. server/_shared/brief-render.js asserts a strict allow-list
for envelope keys; adding a debug field would force a
BRIEF_ENVELOPE_VERSION bump and tug at 7 reader sites including
api/latest-brief.ts (explicit no-touch constraint). Pinned by
'envelope shape is unchanged' test.
- 36 new tests:
- 16 fetch tests: env-missing, happy path, 404/4xx/5xx, transport
error, malformed JSON, wrong shape, non-string-entries filter,
invalid userId, AbortSignal.timeout integration.
- 20 bias tests: empty watchlist no-op, lift within lane, R10
critical override, free-tier clamp pass-through, rankedStoryHashes
precedence, case-insensitive country match, envelope-shape
invariance contract.
Sibling-consumer audit (grep fetchUserPreferences|/relay/user-
preferences across scripts/api/server/convex): scripts/lib/user-
context.cjs (producer), scripts/seed-digest-notifications.mjs
(consumer of extractUserContext, untouched), scripts/notification-
relay.cjs (consumer, untouched), convex/http.ts (relay definition,
unchanged). New field doesn't transit userPreferences — separate
followedCountries Convex table + dedicated /relay/followed-countries
endpoint from PR A. Zero impact on existing readers.
Plan: U10
PR: #3621-stack (PR C builds on PR A + PR B)
Measures whether U10's followed-country bias actually lifts engagement
on followed-country threads. Fires brief_thread_open events from both
the dashboard panel and the hosted magazine.
Event shape: { country, followed, severity, source } where
- country: ISO-2 code or null
- followed: boolean (computed at click time, not render time)
- severity: critical|high|medium|low|info|null
- source: 'dashboard' | 'magazine'
Dashboard (src/components/LatestBriefPanel.ts:421-433):
- Single per-brief-open signal on cover-card onclick (the panel only
renders one click target → magazine). Measures dashboard→magazine
pull-through. country/severity null, source='dashboard'.
- Try/catch wrapper — analytics outage does NOT block navigation.
Magazine (server/_shared/brief-render.js + api/brief/[userId]/[issueDate].ts):
- Auth'd route fetches followedCountries via /relay/followed-countries
(1500ms timeout, soft-fail to []) and passes through renderBriefMagazine.
- Renderer stamps data-thread-open / data-country / data-severity /
data-followed on each story's source-link anchor. Per-story
granularity matches what users actually click.
- Inline BRIEF_THREAD_OPEN_SCRIPT installs document-level capture-phase
click listener that forwards to window.umami?.track(...).
Try/catch wrapped, never calls preventDefault — navigation continues.
- Public-mirror route does NOT pass followedCountries; public-mode
forces followedSet empty so every story stamps data-followed='0'
(no auth identity → can't measure followed engagement).
Composite-country handling ('IL / LB'): tokenize on whitespace+slash,
keep two-letter ASCII tokens, primary = first token, followed=true if
ANY token is in watchlist. Bounded analytics cardinality.
NO envelope-shape change — preserves U10's contract. Magazine renderer
accepts followedCountries as an additive option; the envelope itself
is unchanged so api/latest-brief.ts and other read sites are unaffected.
22 new tests covering: dashboard event shape on click, no-country
fallback, followed-true / followed-false, double-click fires twice
(no dedup), analytics throw doesn't block click, magazine
data-followed='1' / data-followed absent, composite-country tokenizer.
Plan: U11
PR: #3621-stack (PR C builds on PR A + PR B)
* feat(digest): brief composer follows watchlist with stable bias (U10)
Per-user brief composition now reads followed countries via the
/relay/followed-countries endpoint shipped in PR A and applies a
stable, in-lane bias toward followed-country threads — without
displacing critical events from the top.
- scripts/lib/followed-countries-fetch.cjs: CJS helper mirroring
user-context.cjs::fetchUserPreferences. 10s timeout, returns []
on every soft-failure path (missing env, 4xx/5xx, transport error,
malformed JSON, wrong shape, non-string entries). Never throws.
- scripts/lib/brief-compose.mjs: reorderForFollowedBias() applies a
stable composite sort (severityLane, isFollowed, originalIndex).
Critical-severity threads stay in their lane regardless of bias —
R10 invariant. FOLLOWED_BIAS_MULTIPLIER (default 1.25, env-tunable)
is exported as the public knob U11 telemetry will correlate against.
Bias runs BEFORE filterTopStories; LLM editorial truth via
synthesis.rankedStoryHashes still wins when present.
- scripts/seed-digest-notifications.mjs: per-user fetchFollowedCountries
+ free-tier clamp (tier < 1 → slice(0, 3)) — third layer of the
three-layer entitlement gate (UI cap @ FollowButton, mutation cap @
Convex, composer clamp here; memory: paywalled-feature-needs-three-
layer-entitlement-gate). getUserTier() shares relay-fetch with
isUserPro and fails OPEN on transport error (honors full list,
same polarity as the sibling helper).
- Operator visibility via structured log line, NOT envelope shape
change. server/_shared/brief-render.js asserts a strict allow-list
for envelope keys; adding a debug field would force a
BRIEF_ENVELOPE_VERSION bump and tug at 7 reader sites including
api/latest-brief.ts (explicit no-touch constraint). Pinned by
'envelope shape is unchanged' test.
- 36 new tests:
- 16 fetch tests: env-missing, happy path, 404/4xx/5xx, transport
error, malformed JSON, wrong shape, non-string-entries filter,
invalid userId, AbortSignal.timeout integration.
- 20 bias tests: empty watchlist no-op, lift within lane, R10
critical override, free-tier clamp pass-through, rankedStoryHashes
precedence, case-insensitive country match, envelope-shape
invariance contract.
Sibling-consumer audit (grep fetchUserPreferences|/relay/user-
preferences across scripts/api/server/convex): scripts/lib/user-
context.cjs (producer), scripts/seed-digest-notifications.mjs
(consumer of extractUserContext, untouched), scripts/notification-
relay.cjs (consumer, untouched), convex/http.ts (relay definition,
unchanged). New field doesn't transit userPreferences — separate
followedCountries Convex table + dedicated /relay/followed-countries
endpoint from PR A. Zero impact on existing readers.
Plan: U10
PR: #3621-stack (PR C builds on PR A + PR B)
* feat(telemetry): brief_thread_open event for measuring bias lift (U11)
Measures whether U10's followed-country bias actually lifts engagement
on followed-country threads. Fires brief_thread_open events from both
the dashboard panel and the hosted magazine.
Event shape: { country, followed, severity, source } where
- country: ISO-2 code or null
- followed: boolean (computed at click time, not render time)
- severity: critical|high|medium|low|info|null
- source: 'dashboard' | 'magazine'
Dashboard (src/components/LatestBriefPanel.ts:421-433):
- Single per-brief-open signal on cover-card onclick (the panel only
renders one click target → magazine). Measures dashboard→magazine
pull-through. country/severity null, source='dashboard'.
- Try/catch wrapper — analytics outage does NOT block navigation.
Magazine (server/_shared/brief-render.js + api/brief/[userId]/[issueDate].ts):
- Auth'd route fetches followedCountries via /relay/followed-countries
(1500ms timeout, soft-fail to []) and passes through renderBriefMagazine.
- Renderer stamps data-thread-open / data-country / data-severity /
data-followed on each story's source-link anchor. Per-story
granularity matches what users actually click.
- Inline BRIEF_THREAD_OPEN_SCRIPT installs document-level capture-phase
click listener that forwards to window.umami?.track(...).
Try/catch wrapped, never calls preventDefault — navigation continues.
- Public-mirror route does NOT pass followedCountries; public-mode
forces followedSet empty so every story stamps data-followed='0'
(no auth identity → can't measure followed engagement).
Composite-country handling ('IL / LB'): tokenize on whitespace+slash,
keep two-letter ASCII tokens, primary = first token, followed=true if
ANY token is in watchlist. Bounded analytics cardinality.
NO envelope-shape change — preserves U10's contract. Magazine renderer
accepts followedCountries as an additive option; the envelope itself
is unchanged so api/latest-brief.ts and other read sites are unaffected.
22 new tests covering: dashboard event shape on click, no-country
fallback, followed-true / followed-false, double-click fires twice
(no dedup), analytics throw doesn't block click, magazine
data-followed='1' / data-followed absent, composite-country tokenizer.
Plan: U11
PR: #3621-stack (PR C builds on PR A + PR B)
* fix(brief): bound followed-country telemetry latency
* fix(brief): clarify followed telemetry behavior
* fix(brief): drain followed telemetry on misses
* fix(tests): add clusterId to U11 story() factory for v4 envelope validator
PR B merged a v4 envelope contract bump (server/_shared/brief-render.js:367)
that requires every story.clusterId to be a non-empty string. PR C's U11
telemetry tests pre-dated that requirement; their story() factory does not
set clusterId, so all 18 subtests in brief-thread-open-telemetry.test.mjs
fail envelope validation before any assertion runs.
Fix: add `clusterId: 'cluster-test-default'` to the factory defaults. The
renderer does not surface clusterId in any rendered output (no data-*
attribute derives from it) and the validator does not enforce uniqueness
within a single envelope, so a constant default is sufficient.
Summary
PR A of the followed-countries watchlist primitive. Lays the foundation for personalization across the dashboard: per-user country watchlist with a server-authoritative free-tier cap, per-country reverse lookups for future fan-out (digest, breaking-news), and a reusable FollowButton mounted on three panel surfaces.
Plan:
docs/plans/2026-05-02-001-feat-followed-countries-watchlist-primitive-plan.md— Codex-approved after 6 deepening rounds.What ships in PR A
followedCountriesrows,followedCountriesCountsaggregate counter,followedCountriesUserMetaper-user serialization doc — with indexesby_user,by_country,by_user_country. Constants inconvex/constants.ts:FREE_TIER_FOLLOW_LIMIT=3,MAX_MERGE_INPUT=100,COUNTRY_COUNT_PRIVACY_FLOOR=5.ConvexError({kind})typed errors):followCountry,unfollowCountry,mergeAnonymousLocal.listFollowed(auth'd reactive),countFollowers(public, counter-table-backed O(1) with privacy floor),listFollowersPage(internalQuery, paginated cursor)./relay/followed-countriesHTTP action — RELAY_SHARED_SECRET-gated; consumed by PR C's brief composer.src/services/followed-countries.ts) — three modes (anonymous / handoffPending / signed-in), auth-state listener with_handoffGenerationrace guards, user-scoped subscription snapshot, cross-tabstorageevent, ConvexError → reason mapping, max-retry + exponential backoff withfailed-permanentterminal state.FollowButtonhelper mounted onCountryDeepDivePanel,CountryIntelModal, and CIIPanel rows with proper teardown bookkeeping; exhaustive switch (assertNever) andinFlightbusy-state.Implementation units (8)
Test plan
npm run typecheck— cleannpm run typecheck:api— cleannpm run test:convex— 292 pass (70 new tests)npm run test:data— 7913 pass (98 new tests)INPUT_TOO_LARGEpermanent-error classification, max-retry exhaustion, cross-tab storage sync, post-await auth re-check, FollowButton rapid double-click suppression, relay endpoint validation, privacy floor edges (4 vs 5), concurrent same-user mutations preserve cap (TOCTOU)Code review
Full
/ce-code-reviewran (13 reviewers). 19 of 19 actionable findings resolved across 5 follow-up commits including the P0 TOCTOU. Run artifact:.context/compound-engineering/ce-code-review/20260502-195816-dae403d7/.TOCTOU resolution (originally P0; fixed in
9b7dd885f)Convex per-document OCC tracks reads at the document level, not the index-range level. Two parallel
followCountry({country: 'US'})from the same user could both pass the empty-row check and both insert. Fix: a per-user serialization document (followedCountriesUserMeta) that every mutation reads + writes. Concurrent same-user mutations now serialize via Convex's per-document OCC; the loser retries against the winner's commit and either passes correctly (still under cap), fails withFREE_CAP(cap now reached), or returns idempotent (row now exists for same(userId, country)). The denormalized count on the meta row also makes the cap check O(1) for both free and PRO paths.convex-testdoes NOT simulate true concurrency (verified: it serializes mutations under a per-function lock). The new tests instead assert the final-state invariant (cap holds, no duplicate(userId, country), count parity) under back-to-back execution, which is the exact behavior production OCC retry produces. Documented inline.Known issues — deferred to follow-up
P2 —
countFollowersunrate-limited. Public unauthenticated query; needs Vercel edge route wrapper for rate limiting. Not pure Convex code; deferred to its own PR.Next: PR B (consumer wiring)
CII pin-to-top (followed countries first), "Followed only" filter chip on country-scoped panels, deep-dive star sub-action wired to alert-rule create form pre-fill.
Plan + design
docs/plans/2026-05-02-001-feat-followed-countries-watchlist-primitive-plan.md(1057 lines, deepened + Codex-approved)