{"record":{"id":"5e11cfbb8bf40e60","repo":"koala73/worldmonitor","slug":"tickers-limit-exceeded","errorCode":"TICKERS_LIMIT_EXCEEDED","errorMessage":"tickers list capped at ${TICKERS_MAX} entries","messagePattern":"tickers list capped at (.+?) entries","errorType":"error_code","errorClass":"ConvexError","httpStatus":null,"severity":"error","filePath":"convex/alertRules.ts","lineNumber":172,"sourceCode":" *  - cap at TICKERS_MAX\n *\n * NOT a registry check against shared/stocks.json — a shape-valid symbol\n * the extractor never emits simply never intersects at the relay. Keeps\n * alertRules independently shippable, same rationale as countries.\n */\nfunction normalizeTickers(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][A-Z0-9&-]{0,11}(\\.[A-Z]{1,3})?$/.test(upper)) continue;\n    if (seen.has(upper)) continue;\n    seen.add(upper);\n    cleaned.push(upper);\n  }\n  if (cleaned.length > TICKERS_MAX) {\n    throw new ConvexError({\n      code: \"TICKERS_LIMIT_EXCEEDED\",\n      message: `tickers list capped at ${TICKERS_MAX} entries`,\n    });\n  }\n  return cleaned;\n}\n\nexport const getAlertRules = query({\n  args: {},\n  handler: async (ctx) => {\n    const identity = await ctx.auth.getUserIdentity();\n    if (!identity) return [];\n    return await ctx.db\n      .query(\"alertRules\")\n      .withIndex(\"by_user\", (q) => q.eq(\"userId\", identity.subject))\n      .collect();\n  },\n});","sourceCodeStart":154,"sourceCodeEnd":190,"githubUrl":"https://github.com/koala73/worldmonitor/blob/eeab0a219fce0f02a00603b532dbae9041b934ac/convex/alertRules.ts#L154-L190","documentation":"normalizeTickers (convex/alertRules.ts:160) applies the same pattern as countries to watchlist ticker-scope (#4922 U3): trim, uppercase, keep only shapes matching ^[A-Z][A-Z0-9&-]{0,11}(\\.[A-Z]{1,3})?$ (AAPL, BRK-B, RELIANCE.NS), silently drop others (^GSPC, GC=F, cashtags, prose), dedupe, then enforce TICKERS_MAX = 50. More than 50 distinct valid ticker shapes throws ConvexError { code: 'TICKERS_LIMIT_EXCEEDED', message: 'tickers list capped at 50 entries' }.","triggerScenarios":"setAlertRules (or setAlertRulesForUser / setNotificationConfigForUser) with a tickers array holding more than 50 distinct shape-valid symbols after normalization — e.g. importing a full portfolio or a watchlist that grew past the client cap.","commonSituations":"Client watchlist code (src/services/market-watchlist.ts caps at 50) bypassed by bulk import or direct API use; portfolio CSV imports with hundreds of symbols; scripts replaying watchlists into alert rules; users assuming indices/futures count — dropped shapes do not count toward the cap, only valid ones do.","solutions":["Normalize client-side with the same regex and dedupe, then cap at 50 before calling.","Limit ticker-scoped alerts to the user's top-50 watchlist entries — the cap mirrors the market-watchlist client cap by design.","For imports, validate and trim beforehand: the mutation rejects atomically, no partial write happens.","If a higher cap is genuinely needed, raise TICKERS_MAX in convex/alertRules.ts together with the client watchlist cap so both stay in sync."],"exampleFix":"// before\nawait mutateAPI.alertRules.setAlertRules({ variant: 'default', enabled: true, eventTypes: ['markets'], channels: ['email'], tickers: fullPortfolio }); // 120 valid symbols\n// ConvexError: TICKERS_LIMIT_EXCEEDED\n\n// after — same shape filter + dedupe + cap as the server\nconst TICKER_RE = /^[A-Z][A-Z0-9&-]{0,11}(\\.[A-Z]{1,3})?$/;\nconst normalizeTickers = (input: string[]) =>\n  [...new Set(input.map((t) => t.trim().toUpperCase()).filter((t) => TICKER_RE.test(t)))].slice(0, 50);\nawait mutateAPI.alertRules.setAlertRules({ variant: 'default', enabled: true, eventTypes: ['markets'], channels: ['email'], tickers: normalizeTickers(fullPortfolio) });","handlingStrategy":"validation","validationCode":"// Replicate normalizeTickers, then cap at 50.\nconst TICKER_RE = /^[A-Z][A-Z0-9&-]{0,11}(\\.[A-Z]{1,3})?$/;\nfunction normalizeTickers(input: string[]): string[] {\n  return [...new Set(input.map((t) => t.trim().toUpperCase()).filter((t) => TICKER_RE.test(t)))].slice(0, 50);\n}\nawait mutateAPI.alertRules.setAlertRules({ ...args, tickers: normalizeTickers(selectedTickers) });","typeGuard":"const isWithinTickersCap = (v: unknown): v is string[] =>\n  Array.isArray(v) &&\n  new Set((v as string[]).map((t) => String(t).trim().toUpperCase()).filter((t) => /^[A-Z][A-Z0-9&-]{0,11}(\\.[A-Z]{1,3})?$/.test(t))).size <= 50;","tryCatchPattern":"try {\n  await mutateAPI.alertRules.setAlertRules(args);\n} catch (err) {\n  if (err instanceof ConvexError && (err.data as { code?: string }).code === 'TICKERS_LIMIT_EXCEEDED') {\n    setPickerError('Pick at most 50 tickers');\n    return;\n  }\n  throw err;\n}","preventionTips":["Dedupe and shape-filter before counting — indices, futures, and cashtags are dropped server-side and don't count.","Cap ticker multi-selects at 50, mirroring the market-watchlist client cap.","Validate imported portfolios before submission; the mutation rejects atomically with no partial write."],"tags":["convex","validation","limit","tickers","watchlist"],"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"}