{"record":{"id":"ff1f42b9493b25d4","repo":"koala73/worldmonitor","slug":"empty-input","errorCode":"EMPTY_INPUT","errorMessage":"EMPTY_INPUT","messagePattern":"EMPTY_INPUT","errorType":"exception","errorClass":"ConvexError","httpStatus":null,"severity":"warning","filePath":"convex/followedCountries.ts","lineNumber":497,"sourceCode":" *\n * Resolves Codex-deepening round-1 P0 (server-side cap on merge) and\n * round-2 P1 (canonicalize duplicates before counting). Free users with\n * existingCount >= LIMIT accept zero new rows — never silently grow above\n * the cap during merge. (Grandfathering above-cap rows on PRO→free\n * downgrade is a separate concern handled by NOT auto-deleting on\n * downgrade; merge is the FIRST sign-in and has no PRO history to\n * grandfather.)\n */\nexport const mergeAnonymousLocal = mutation({\n  args: { countries: v.array(v.string()) },\n  handler: async (ctx, args): Promise<MergeAnonymousLocalResult> => {\n    const identity = await ctx.auth.getUserIdentity();\n    if (!identity) throw new ConvexError({ kind: \"UNAUTHENTICATED\" });\n    const userId = identity.subject;\n\n    // Step 2: empty-input guard.\n    if (args.countries.length === 0) {\n      throw new ConvexError({ kind: \"EMPTY_INPUT\" });\n    }\n\n    // Step 3: defensive upper-bound on input length.\n    if (args.countries.length > MAX_MERGE_INPUT) {\n      throw new ConvexError({\n        kind: \"INPUT_TOO_LARGE\",\n        max: MAX_MERGE_INPUT,\n        received: args.countries.length,\n      });\n    }\n\n    // Step 4: ISO-2 registry filter; collect droppedInvalid in input order.\n    const droppedInvalid: string[] = [];\n    const validInputs: string[] = [];\n    for (const code of args.countries) {\n      if (isValidIso2(code)) {\n        validInputs.push(code);\n      } else {","sourceCodeStart":479,"sourceCodeEnd":515,"githubUrl":"https://github.com/koala73/worldmonitor/blob/ffec79ac339946fd2d24e85845da5755dcaa534b/convex/followedCountries.ts#L479-L515","documentation":"Thrown by `mergeAnonymousLocal` (Step 2) when `args.countries.length === 0`. This is a guard before the upper-bound and ISO2-filter steps. Merging zero countries is a no-op and indicates the client dispatched the merge with no local state. Carries `{ kind: \"EMPTY_INPUT\" }`. It is distinct from `INPUT_TOO_LARGE` (too many) and from a successful merge that drops all-invalid codes (which returns `droppedInvalid` instead of throwing).","triggerScenarios":"Calling `mergeAnonymousLocal` with `countries: []`; the local-storage key for anonymous follows is missing/empty and the client unconditionally dispatches the merge; a first sign-in for a user who never followed anything anonymously.","commonSituations":"The anonymous-follows localStorage is cleared (different browser, incognito) and the merge fires anyway; the merge is wired to a sign-in event without checking whether local follows exist; a migration path that always calls merge \"just in case\".","solutions":["Client-side: only call `mergeAnonymousLocal` when `localCountries.length > 0`.","On `err.data.kind === \"EMPTY_INPUT\"`, treat as a no-op success (clear any pending-merge flag) — there is nothing to merge.","Initialize local follows from localStorage with a default of `[]` and skip the mutation when empty."],"exampleFix":"// before\nawait convex.mutation(api.followedCountries.mergeAnonymousLocal, { countries: localFollows ?? [] });\n\n// after\nconst local = JSON.parse(localStorage.getItem(\"anonFollows\") ?? \"[]\");\nif (local.length === 0) { localStorage.removeItem(\"pendingMerge\"); return; }\nawait convex.mutation(api.followedCountries.mergeAnonymousLocal, { countries: local });","handlingStrategy":"validation","validationCode":"const local: string[] = JSON.parse(localStorage.getItem(\"anonFollows\") ?? \"[]\");\nif (!Array.isArray(local) || local.length === 0) {\n  localStorage.removeItem(\"pendingMerge\");\n  return; // nothing to merge\n}","typeGuard":"function hasMergeInput(countries: unknown): countries is string[] {\n  return Array.isArray(countries) && countries.length > 0;\n}","tryCatchPattern":"try {\n  await convex.mutation(api.followedCountries.mergeAnonymousLocal, { countries: local });\n} catch (err) {\n  if (err.data?.kind === \"EMPTY_INPUT\") { /* no-op success */ localStorage.removeItem(\"pendingMerge\"); }\n  else throw err;\n}","preventionTips":["Skip the mutation when local follows is empty.","Treat EMPTY_INPUT as a successful no-op.","Clear the pending-merge flag on EMPTY_INPUT.","Default localStorage parse to []."],"tags":["validation","convex","merge","input-guard"],"backgroundTag":null,"analyzedSha":"ffec79ac339946fd2d24e85845da5755dcaa534b","analyzedAt":"2026-08-12T11:24:56.012Z","schemaVersion":2},"datasetVersion":"2026-08-13T09:17:06.757Z"}