{"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/ffec79ac339946fd2d24e85845da5755dcaa534b/convex/alertRules.ts#L154-L190","documentation":"Thrown by normalizeTickers when the cleaned/deduped ticker list exceeds TICKERS_MAX (50). Input is trimmed, uppercased, filtered to ^[A-Z][A-Z0-9&-]{0,11}(\\.[A-Z]{1,3})?$ (plain symbols, share-class, dot-suffix exchange listings), and deduped before the cap check. The cap mirrors the client-side market watchlist limit in src/services/market-watchlist.ts.","triggerScenarios":"Calling setAlertRules, setAlertRulesForUser, or setNotificationConfigForUser with a tickers array yielding more than 50 unique valid symbols after normalization.","commonSituations":"Bulk import from a portfolio CSV; a watchlist sync that copies an entire index membership; a patched client bypassing the UI cap.","solutions":["Trim the tickers array to at most 50 unique valid symbols before submitting.","Filter client-side to the regex ^[A-Z][A-Z0-9&-]{0,11}(\\.[A-Z]{1,3})?$ — invalid shapes (^GSPC, GC=F, cashtags) are silently dropped server-side anyway.","Reconcile against src/services/market-watchlist.ts which enforces the same 50-entry cap on the client."],"exampleFix":"// before\nconst tickers = portfolio.holdings.map(h => h.symbol); // 80 symbols\nawait setAlertRules(ctx, { ..., tickers });\n// after\nconst tickers = portfolio.holdings\n  .map(h => h.symbol.toUpperCase().trim())\n  .filter((s, i, arr) => /^[A-Z][A-Z0-9&-]{0,11}(\\.[A-Z]{1,3})?$/.test(s) && arr.indexOf(s) === i)\n  .slice(0, 50);\nawait setAlertRules(ctx, { ..., tickers });","handlingStrategy":"validation","validationCode":"const TICKERS_MAX = 50;\nconst TICKER_RE = /^[A-Z][A-Z0-9&-]{0,11}(\\.[A-Z]{1,3})?$/;\nfunction normalizeAndCapTickers(input) {\n  const seen = new Set();\n  const out = [];\n  for (const raw of input) {\n    const upper = String(raw).trim().toUpperCase();\n    if (!TICKER_RE.test(upper) || seen.has(upper)) continue;\n    seen.add(upper);\n    out.push(upper);\n    if (out.length === TICKERS_MAX) break;\n  }\n  return out;\n}","typeGuard":"const TICKER_RE = /^[A-Z][A-Z0-9&-]{0,11}(\\.[A-Z]{1,3})?$/;\nfunction isTickerList(input): input is string[] {\n  return Array.isArray(input) && input.every(t => typeof t === 'string' && TICKER_RE.test(t.trim().toUpperCase()));\n}","tryCatchPattern":"try {\n  await setAlertRules(args);\n} catch (e) {\n  if (e instanceof ConvexError && /tickers list capped/.test(String(e.message))) {\n    // truncate to top 50 and retry\n  } else throw e;\n}","preventionTips":["Client-side, mirror normalizeTickers (same regex + dedupe + cap 50) before submit.","Align the UI watchlist with src/services/market-watchlist.ts which caps at 50.","Reject index/futures symbols (^GSPC, GC=F) in the picker so they aren't counted before being dropped."],"tags":["convex","validation","limits","notifications","watchlist"],"backgroundTag":null,"analyzedSha":"ffec79ac339946fd2d24e85845da5755dcaa534b","analyzedAt":"2026-08-12T11:24:56.012Z","schemaVersion":2},"datasetVersion":"2026-08-13T09:17:06.757Z"}