Optimize map rendering layers#21
Closed
koala73 wants to merge 2 commits into
Closed
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Owner
Author
|
Closing - main has diverged significantly (10+ commits to Map.ts). Will manually implement the base/dynamic layer optimization concept into current code. |
EleCor79
added a commit
to EleCor79/BehavioralHealthPulse
that referenced
this pull request
Mar 2, 2026
Distribute all 32 feeds from feeds-health-italy-eu.csv into the FEEDS config consumed by loadNews() / fetchCategoryFeeds(): - ministero-salute: +MinSalute News Specifiche (CSV koala73#18) - iss-epicentro: +ISS Notizie (CSV koala73#3), +Epicentro Coronavirus (CSV koala73#17) - aifa-tracker: +AIFA Feed (CSV koala73#5), +EMA News (CSV koala73#8), +FDA (CSV koala73#12) - agenas-ospedali: +AGENAS RSS (CSV koala73#4), +PNRR (CSV koala73#6/koala73#19), +Lombardia (CSV koala73#20), +Lazio (CSV koala73#21) - ema-europa: +EMA Clinical Trials (CSV koala73#22) - ecdc-sorveglianza:+ECDC Weekly Threats (CSV koala73#23), +WHO DON (CSV koala73#9), +ProMED (CSV koala73#10) - live-news: +Sanitainformazione (CSV koala73#24), +Humanitas (CSV koala73#26) - europe: +EU Core Health Indicators (CSV koala73#31), +GLOBSEC HRI (CSV koala73#32), +ISTAT (CSV koala73#13), +EIN Health Europe (CSV koala73#29) - rare-diseases: NEW category — EURORDIS (CSV koala73#14), Orphanet IT (CSV koala73#15), Telethon (CSV koala73#16), CDC FluView (CSV koala73#11) Panel disabled by default, available in settings Co-Authored-By: Claude Opus 4.6 <[email protected]>
scorphx
pushed a commit
to scorphx/sauron
that referenced
this pull request
Apr 6, 2026
… fixes, simplifications P1: - koala73#21: Add POST /api/forecast/v1/trigger-simulation — agents can now enqueue simulation runs via HTTP; 5-min global rate limit, atomic NX task key, sorted-set queue write (proto + generated types + handler + handler.ts registration) P2: - koala73#15: Fix buildSimulationStructuralWorld to handle array macroRegion on signals (was silently producing empty touchingSignals for signals with array macroRegion) P3: - koala73#16: Simulation package simplifications — extract slugify/MAX_SEED_SUMMARY constants, build candidateById Map once in orchestrator, read gateDetails from live getImpactValidationFloors() values (adds thirdOrderMappedFloor), widen actor regex to match "are the primary actors" etc, add debug log on regex miss - koala73#28: Add completionStatus + eligibleTheaterCount to simulation outcome object for machine-readable branching (no_eligible_theaters | all_failed | partial | complete) - koala73#29: Add simulation_round_1 / simulation_round_2 cases to getForecastLlmCallOptions; new FORECAST_LLM_SIMULATION_PROVIDER_ORDER + FORECAST_LLM_SIMULATION_MODEL_OPENROUTER env vars for per-stage routing without code changes - koala73#17: Mark complete — getSimulationPackage RPC + Redis existence key were already shipped; confirmed no additional work needed https://claude.ai/code/session_015fz1MvRuBJGYbgHubGEabA
koala73
added a commit
that referenced
this pull request
May 8, 2026
…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 ✅.
This was referenced May 18, 2026
koala73
added a commit
that referenced
this pull request
May 18, 2026
…C2) (#3811) * feat(forecast): trigger-simulation endpoint + runId filter (#3734 C1+C2) Closes #3734. Implements the C1+C2 scope of v3 plan (docs/plans/2026-05-18-003-feat-simulation-trigger-and-runid-filter-plan.md; also tracked as #3798): - POST /api/forecast/v1/trigger-simulation — PRO-gated, mirrors run-scenario shape (isCallerPremium → queue-depth → server-derived runId → idempotency pre-check → enqueue). Returns { queued, runId, pkgFingerprint, reason }. External reason taxonomy collapses to {'', 'no_package', 'already-handled'} — server logs retain the distinction between already-queued vs already-completed-this-cycle to avoid a cron-timing oracle (Sec4). - GET /api/forecast/v1/get-simulation-outcome?runId=X — filter now actually fires. New behavior: by-run hit → return outcome; tombstone hit → fall through to :latest with distinct note; queued-but-not-completed → return processing=true (uses listQueuedSimulationTasks; prevents queued-vs-expired indistinguishability from contaminating the 30-day demand signal); miss → fall through with expiry note. - Worker (writeSimulationOutcome) writes BOTH :latest (canonical, 60d TTL) AND :by-run:{runId} (secondary index, 24h TTL). By-run failure logs + attempts tombstone payload so reads can distinguish transient failure from true expiry (D9). - pkgFingerprint = sha256(pkgKey).slice(0,16) opaque token. Replaces what v2 had as raw R2 path (raw path leaked bucket layout to Pro callers per Sec7). Worker verifies task.pkgFingerprint vs current pointer fingerprint with truthy-guard predicate to avoid spurious package_rotated logs on pre-upgrade in-flight tasks (Feas-F4). - .mjs seeder enqueueSimulationTask backports redis_error reason code so the auto-trigger path can surface transport failures cleanly (Adv-F4 / SG-2). No documented asymmetry between TS and .mjs paths. - Shared shim at server/_shared/_simulation-queue-constants.mjs (with .d.ts) is single source of truth for queue key constants + 24h TTL + pkgFingerprint helper + MAX_QUEUE_DEPTH. Both the seeder and the new TS handler module import natively from it. - PREMIUM_RPC_PATHS entry added so the gateway's Pro-bearer gate fires before per-IP rate-limit (matches run-scenario precedent). - ENDPOINT_RATE_POLICIES entry: { limit: 10, window: '60 s' } per-IP, identical to run-scenario. - Proto: TriggerSimulationRequest carries an optional client_version field (sebuf v0.11.1 emits a typecheck-broken POST client for fully-empty request messages; one field works around it). Server logs but never branches on it. - Plan + tracking metadata: TODO #21 transitioned from pending → complete; TODO #27 work log updated noting the filter is now actually active (the original 2026-03-24 "complete" was docs-only). Tests (26 total, all passing): - tests/simulation-queue-parity.test.mts (7) — TS ↔ .mjs parity for all reason codes (no documented asymmetry); structural lock on the worker's truthy-guard predicate - tests/forecast-trigger-simulation.test.mts (9) — handler test for 403 / 429 / no_package / already-handled (both states collapse to single external code) / happy path with opaque fingerprint / 503 transport on pointer read AND enqueue / regression test that internal codes never leak externally - tests/forecast-get-simulation-outcome.test.mts (6) — all 5 read paths + NOT_FOUND - tests/simulation-outcome-by-run-write.test.mjs (4) — structural locks on :latest canonicality, :by-run TTL + no-NX flag, tombstone-on-failure pattern, return shape Follow-up: #3809 filed for the audit-reframe docs/marketing work (surface "simulations auto-run hourly via cron" in OpenAPI / Pro marketing / future MCP tool descriptions). Closing #3734 in code without that docs change leaves the audit's framing defensible to future reviewers. * docs(simulation): operator notes for process-simulation-tasks.mjs --run-id Inline operator notes on the script that runs the simulation worker: - The `--run-id=X` format must match `/^\d{13,}-[a-z0-9-]{1,64}$/i`; invalid IDs are silently skipped (pre-existing footgun #3734 Adv-F5). - Force-reprocess via `--run-id` overwrites both :latest and :by-run keys with no NX, resets the 24h TTL; CDN cache may serve stale for up to 30min (D6 trade-off documented in v3 plan). No behavior change — comments only. Discoverable by operators reading the script they invoke, rather than requiring knowledge of a separate runbook file. * fix(deps): restore platform-optional peer deps in package-lock.json The local pre-push hook ran 'npm install --prefer-offline' (its safety net when node_modules is missing in a worktree) and that pruned five '[email protected]' optional peer-dep entries that exist for non-macOS platforms. CI's 'npm ci' on Linux then refused the lockfile. Restoring the lockfile to origin/main's version — those entries belong there for cross-platform compatibility. * fix(simulation): switch pkgFingerprint to Web Crypto (Edge-compat) Vercel Edge bundler hoisted `server/_shared/_simulation-queue-constants.mjs` into Edge function bundles (transitively via cache-keys.ts → various Edge handlers). The shim's `import { createHash } from 'node:crypto'` is not available in Edge runtime, breaking deploy with: The Edge Function "api/discord/oauth/callback" is referencing unsupported modules: - __vc__ns__/5/server/_shared/_simulation-queue-constants.mjs: node:crypto Switch to `globalThis.crypto.subtle.digest('SHA-256', ...)` — the only crypto API available in BOTH Edge runtime AND Node 19+. The seeder (scripts/seed-forecasts.mjs) keeps working without a polyfill. Web Crypto's subtle.digest is async-only, so pkgFingerprint() is now async. Both call sites (handler's getSimulationPackagePointer in the TS module + worker's currentFingerprint in the .mjs seeder) already run inside async functions; updated to await. Output is byte-identical to the previous Node-crypto version (16-char sha256 hex prefix), so payloads and parity tests remain stable. * review(pr-3811): address all 4 Greptile findings P1 — Tombstone SET now uses NX (scripts/seed-forecasts.mjs). A by-run primary SET that landed server-side but threw a network error on the response would have been overwritten by the tombstone on the catch path, corrupting the real outcome for the 24h TTL window. NX preserves the real write when it actually landed; if Redis is fully down the tombstone also fails and the read path falls back to :latest with the standard "may have expired" note (acceptable). P2 — Single source of truth for SIMULATION_OUTCOME_LATEST_KEY (server/worldmonitor/forecast/v1/get-simulation-outcome.ts). Was importing the key from both _simulation-queue-constants.mjs (the designated shim) AND cache-keys.ts. Drift on a future schema-version bump could silently break the :latest fallback path. Import only from the shim now; cache-keys still has the const (unchanged, kept for non-simulation consumers). P2 — clientVersion now logged per the proto comment promise (server/worldmonitor/forecast/v1/trigger-simulation.ts). The proto field's doc says "Server LOGS this with the success breadcrumb" but the handler was marking req as unused. Echoes the field in the success log line; sanitized (truncated to 64 chars, restricted charset) since it's a free-text caller-provided string. P2 — User-Agent header on Upstash GET in simulation-queue.ts (server/_shared/simulation-queue.ts). AGENTS.md requires User-Agent on all server-side fetch calls. redisGetThrowing was missing it. Tests: 26/26 simulation tests still pass. Typecheck clean. * review(pr-3811): address 2 human-review blocking findings Fix 1 — processing=true responses can be cached for minutes (P1). get-simulation-outcome.ts returned {processing: true} on the by-run-miss + runId-queued branch without marking no-cache. The gateway's `slow` cache tier would cache the transient state for up to 30 min, so polling clients would never see the outcome land after the worker finished. Added markNoCacheResponse on that branch + regression test asserting X-No-Cache=1 on the response. Fix 2 — enqueueSimulationTaskForServer + .mjs enqueueSimulationTask could return queued:true even when ZADD failed (P1, both sides). Worker discovers tasks EXCLUSIVELY via ZRANGE on the queue ZSET (scripts/seed-forecasts.mjs listQueuedSimulationTasks at line 17052, then processNextSimulationTask iterates queuedRunIds). A task key without a corresponding ZSET member is invisible to the worker until the 4h task TTL — silent stuck-invisible failure mode. Previously labeled "best-effort"; ZADD is actually load-bearing. Now: - Capture the ZADD result (transport throw OR non-numeric reply both count as failure) - On failure: issue compensating DEL on the task key so a future trigger isn't blocked by SET NX and no stale half-state lingers for the 4h TTL - Return {queued: false, reason: 'redis_error'} - EXPIRE failure stays non-fatal (TTL hint only — queue member is durable; logged but doesn't roll back) Added parity regression test covering both .mjs and .ts: simulates SET NX success + ZADD non-numeric reply, asserts both implementations surface redis_error AND issue the compensating DEL. All 9223 tests pass; typecheck + typecheck:api green.
koala73
added a commit
that referenced
this pull request
May 26, 2026
…ns + queries + service + UI (#3621) * feat(country-codes): add toIso2 normalizer for alpha-2/3/name input (U1) 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 * feat(convex): add followedCountries + aggregate counter tables (U12) 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 * feat(convex): add followedCountries mutations + ISO-2 registry validator (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 * feat(convex): followedCountries queries + relay endpoint (U14) 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 * feat(followed-countries): client service — anonymous mode + entitlement 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 * feat(followed-countries): sign-in handoff + Convex bridge (U3) 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 * feat(follow-button): reusable star button helper for 3 surfaces (U4) 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 * feat(panels): mount FollowButton on Deep Dive, Intel Modal, CII rows (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 * fix(followed-countries): convex server-side hardening pass (Phase 1) 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 * fix(followed-countries): service-core hardening pass (Phase 2) 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 * fix(follow-button): UI hardening — exhaustive switch + inFlight guard (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 * chore(env): document VITE_FOLLOW_COUNTRIES_ENABLED in .env.example (Phase 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 * fix(followed-countries): per-user serialization doc — close TOCTOU cap-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 ✅. * fix(followed-countries): pre-seeded sharded lock — close nested TOCTOU 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). * fix(followed-countries): treat UNAUTHENTICATED as transient in handoff 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. * fix(ci): seed followedCountries shards on Convex deploy (P1) 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. * fix(followed-countries): race-tolerant shard seed + dedupe cron + remove 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. * feat(follow-button): minimal CSS for star button + host layouts (PR A 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). * fix(followed-countries): serialize country counters * fix(followed-countries): register picker global
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Motivation
render()by caching static geometry and only updating dynamic overlays.topojson.featureconversion on each render by extracting features once on map data load.Description
loadMapData()by callingtopojson.featureonce and storing results inthis.countryFeatures.this.baseLayerGroupfor static base elements andthis.dynamicLayerGroup(withoverlaysSvgGroup) for per-render dynamic content, and only rebuild the base when size changes or on initial load.renderGrid,renderGraticule, andrenderCountriesto accept a target group, and update layer renderers to append intothis.dynamicLayerGroupinstead of the root SVG.renderSanctionsapproach withupdateCountryFills()to toggle country fills based onthis.state.layers.sanctionswithout rebuilding base geometry.Testing
Codex Task