{"record":{"id":"bb6825c40d4e9b93","repo":"koala73/worldmonitor","slug":"countries-limit-exceeded","errorCode":"COUNTRIES_LIMIT_EXCEEDED","errorMessage":"countries list capped at ${COUNTRIES_MAX} entries","messagePattern":"countries list capped at (.+?) entries","errorType":"error_code","errorClass":"ConvexError","httpStatus":null,"severity":"error","filePath":"convex/alertRules.ts","lineNumber":129,"sourceCode":" * NOT a strict ISO-3166 registry check — invalid alpha-2 codes (e.g. \"XX\")\n * pass shape validation but the relay's includes() check just won't match\n * any real event country. We deliberately don't soft-couple this file to a\n * canonical registry list (that lives elsewhere as part of the\n * followed-countries primitive) — keep alertRules independently shippable.\n */\nfunction normalizeCountries(input: string[]): string[] {\n  const cleaned: string[] = [];\n  const seen = new Set<string>();\n  for (const raw of input) {\n    if (typeof raw !== \"string\") continue;\n    const upper = raw.trim().toUpperCase();\n    if (!/^[A-Z]{2}$/.test(upper)) continue;\n    if (seen.has(upper)) continue;\n    seen.add(upper);\n    cleaned.push(upper);\n  }\n  if (cleaned.length > COUNTRIES_MAX) {\n    throw new ConvexError({\n      code: \"COUNTRIES_LIMIT_EXCEEDED\",\n      message: `countries list capped at ${COUNTRIES_MAX} entries`,\n    });\n  }\n  return cleaned;\n}\n\n// Same defensive ceiling as COUNTRIES_MAX — mirrors the client-side market\n// watchlist cap (src/services/market-watchlist.ts stops at 50 entries).\nconst TICKERS_MAX = 50;\n\n/**\n * Shape-validate + normalize an inbound `tickers` array (#4922 U3).\n * Modeled EXACTLY on normalizeCountries above:\n *  - trim each entry\n *  - uppercase\n *  - keep only `^[A-Z][A-Z0-9&-]{0,11}(\\.[A-Z]{1,3})?$` shapes — plain\n *    symbols (AAPL), share-class/conglomerate forms (BRK-B, M&M.NS) and","sourceCodeStart":111,"sourceCodeEnd":147,"githubUrl":"https://github.com/koala73/worldmonitor/blob/eeab0a219fce0f02a00603b532dbae9041b934ac/convex/alertRules.ts#L111-L147","documentation":"normalizeCountries (convex/alertRules.ts:117) shape-validates, trims, uppercases, and dedupes the countries array, then enforces the defensive ceiling COUNTRIES_MAX = 50. The cap fires only after cleaning — invalid shapes are silently dropped and duplicates collapse — so it triggers on more than 50 distinct codes matching ^[A-Z]{2}$, throwing ConvexError { code: 'COUNTRIES_LIMIT_EXCEEDED', message: 'countries list capped at 50 entries' }. There are ~250 ISO-3166 alpha-2 codes, so a full 'select all' list will always exceed it.","triggerScenarios":"setAlertRules (or setAlertRulesForUser / setDigestSettingsForUser / setNotificationConfigForUser) with a countries array containing more than 50 distinct valid alpha-2 codes — e.g. a bulk 'select all countries' action submitting ~249 codes.","commonSituations":"A country multi-select with a 'select all' control mirroring the full ISO-3166 list; importing alert preferences from another product with no cap; migration scripts bulk-writing country scopes; client code diverging from the 50-entry watchlist cap that mirrors this server limit (src/services/market-watchlist.ts also stops at 50).","solutions":["Normalize client-side exactly like the server (trim, uppercase, keep ^[A-Z]{2}$, dedupe) and cap at 50 before calling.","Restrict the picker to countries the user actually follows — more than 50 country-scoped alerts exceeds any real usage pattern.","For imports, validate first and page/drop the remainder: there is no server-side partial write, the whole mutation rejects.","If a higher cap is truly required, change COUNTRIES_MAX in convex/alertRules.ts and the client watchlist cap together — 50 is a deliberate defensive ceiling, not a platform limit."],"exampleFix":"// before\nawait mutateAPI.alertRules.setAlertRules({ variant: 'default', enabled: true, eventTypes: [], channels: ['email'], countries: ALL_ISO_COUNTRIES }); // ~249 entries\n// ConvexError: COUNTRIES_LIMIT_EXCEEDED\n\n// after — same normalization as the server, capped at 50\nconst normalizeCountries = (input: string[]) =>\n  [...new Set(input.map((c) => c.trim().toUpperCase()).filter((c) => /^[A-Z]{2}$/.test(c)))].slice(0, 50);\nawait mutateAPI.alertRules.setAlertRules({ variant: 'default', enabled: true, eventTypes: [], channels: ['email'], countries: normalizeCountries(selected) });","handlingStrategy":"validation","validationCode":"// Replicate the server normalization, then cap and surface truncation.\nfunction normalizeCountries(input: string[]): string[] {\n  const cleaned = [...new Set(input.map((c) => c.trim().toUpperCase()).filter((c) => /^[A-Z]{2}$/.test(c)))];\n  return cleaned.slice(0, 50);\n}\nconst countries = normalizeCountries(selectedCountries);\nif (new Set(selectedCountries.map((c) => c.trim().toUpperCase()).filter((c) => /^[A-Z]{2}$/.test(c))).size > 50) {\n  showToast('Only the first 50 countries are saved.');\n}\nawait mutateAPI.alertRules.setAlertRules({ ...args, countries });","typeGuard":"const isWithinCountriesCap = (v: unknown): v is string[] =>\n  Array.isArray(v) &&\n  new Set((v as string[]).map((c) => String(c).trim().toUpperCase()).filter((c) => /^[A-Z]{2}$/.test(c))).size <= 50;","tryCatchPattern":"try {\n  await mutateAPI.alertRules.setAlertRules(args);\n} catch (err) {\n  if (err instanceof ConvexError && (err.data as { code?: string }).code === 'COUNTRIES_LIMIT_EXCEEDED') {\n    setPickerError('Pick at most 50 countries'); // keep first 50 by user priority\n    return;\n  }\n  throw err;\n}","preventionTips":["Normalize before counting — junk shapes and duplicates don't count toward the server's 50.","Cap the multi-select UI at 50 with a visible counter.","Keep the client watchlist cap and COUNTRIES_MAX in sync (both 50 today)."],"tags":["convex","validation","limit","countries","notifications"],"backgroundTag":"array-length-limit-exceeded","analyzedSha":"eeab0a219fce0f02a00603b532dbae9041b934ac","analyzedAt":"2026-08-21T16:51:25.751Z","contentChangedAt":"2026-08-21T16:51:25.751Z","schemaVersion":2},"datasetVersion":"2026-09-23T08:17:48.524Z"}