feat(convex/customers): add normalizedEmail field + index for broadcast dedup#3424
Conversation
…st dedup Required pre-work for the PRO-launch broadcast. Without this, the audience-build dedup query (registrations − emailSuppressions − paying customers) is a full `customers` table scan, AND the join would silently fail across case/whitespace differences between `registrations.normalizedEmail` (lowercased+trimmed) and `customers.email` (raw) — leaking paid users into the "buy PRO!" send. Mirrors the existing convention from `registrations` and `emailSuppressions`, both of which already store + index `normalizedEmail`. - schema.ts: customers.normalizedEmail (v.optional, backward-compatible) + by_normalized_email index - subscriptionHelpers.ts: populate normalizedEmail at both write sites in handleSubscriptionActive (insert + patch); single derivation reused - backfillCustomerNormalizedEmail.ts (new): idempotent paginated internalMutation + countPending diagnostic query for existing rows Operational sequence after deploy: npx convex run payments/backfillCustomerNormalizedEmail:countPending npx convex run payments/backfillCustomerNormalizedEmail:backfill # rerun backfill until done:true; verify with countPending → pending:0
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Greptile SummaryThis PR adds a
Confidence Score: 3/5The schema and live write-path changes are safe, but the backfill script has two P1 defects that would make it unreliable or fail outright on production data — fix before running. Two P1 findings in the backfill mutation (full-scan ignoring Convex's document-read limit, and empty-email rows bypassing batchSize) pull the score below the P1 ceiling of 4. The production write path (subscriptionHelpers) and schema are clean, which keeps it above 2. convex/payments/backfillCustomerNormalizedEmail.ts — both the pagination strategy and the empty-email branch need fixes before the backfill is run. Important Files Changed
Flowchart%%{init: {'theme': 'neutral'}}%%
flowchart TD
A[backfill called] --> B["ctx.db.query('customers').collect()\n⚠️ loads ALL rows"]
B --> C{row.normalizedEmail\nnon-empty?}
C -- yes --> D[alreadySet++ / continue]
C -- no --> E["computed = email.trim().toLowerCase()"]
E --> F{computed.length == 0?}
F -- yes --> G["patch normalizedEmail = ''\nemptyEmail++\n⚠️ patched NOT incremented"]
G --> C
F -- no --> H["patch normalizedEmail = computed\npatched++"]
H --> I{patched >= limit?}
I -- yes --> J[break]
I -- no --> C
J --> K["remaining = all.length - scanned\ndone = remaining == 0"]
K --> L[return stats]
style B fill:#ffcccc
style G fill:#ffcccc
Reviews (1): Last reviewed commit: "feat(convex/customers): add normalizedEm..." | Re-trigger Greptile |
| }, | ||
| handler: async (ctx, { batchSize }) => { | ||
| const limit = batchSize ?? 500; | ||
| const all = await ctx.db.query("customers").collect(); |
There was a problem hiding this comment.
Full table scan defeats batchSize pagination
.collect() fetches every customer row into memory before applying the batch limit. Convex enforces a 16,384-document read limit per mutation — once the customers table exceeds that, every call to backfill will throw, making the backfill unrunnable. The batchSize argument currently only caps writes, not reads.
Use a cursor-aware approach — e.g., filter to rows missing the field and call .take(limit) — so each invocation only reads what it needs to process:
const rows = await ctx.db
.query("customers")
.filter((q) => q.eq(q.field("normalizedEmail"), undefined))
.take(limit);This also eliminates the need for the alreadySet counter and keeps reads proportional to batchSize.
| if (computed.length === 0) { | ||
| emptyEmail++; | ||
| await ctx.db.patch(row._id, { normalizedEmail: "" }); | ||
| continue; | ||
| } |
There was a problem hiding this comment.
Empty-email rows bypass the batch limit
When computed.length === 0 the code patches the row and increments emptyEmail but does not increment patched, so the patched >= limit guard never fires for these rows. If there are many customers with blank emails, a single invocation will write far beyond the intended batchSize, potentially hitting Convex's per-mutation write budget or causing unexpectedly long mutation times.
await ctx.db.patch(row._id, { normalizedEmail: "" });
emptyEmail++;
patched++; // count against the limit like any other row
if (patched >= limit) break;
continue;| * Diagnostic: how many customer rows still need backfilling? | ||
| * Returns an exact count (full scan — only run from CLI, not hot paths). | ||
| */ | ||
| export const countPending = query({ |
There was a problem hiding this comment.
countPending should be internalQuery
The comment explicitly says "only run from CLI, not hot paths", but exporting it as a public query (not internalQuery) means any authenticated client can call it and trigger a full table scan. Using internalQuery matches the intent and prevents accidental exposure.
import { internalMutation, internalQuery } from "../generated/server";
// ...
export const countPending = internalQuery({… P2)
Three findings on the backfill mutation, all valid:
1. (P1) `.collect()` defeats batchSize pagination — read scope was the entire
table, only writes were capped. Once `customers` exceeds Convex's
16,384-doc read limit the mutation throws and the backfill becomes
unrunnable. Switched to `.filter(q => q.eq(q.field("normalizedEmail"),
undefined)).take(limit)` so reads scale with `batchSize` and patched rows
drop out of subsequent calls automatically.
2. (P1) Empty-email rows bypassed the batch limit — they hit the patch path
without incrementing `patched`, so a customer set with many blank emails
could drive a single invocation past the intended write budget. Now
counted against `patched` like any other patched row (and tracked
separately via `emptyEmail` for diagnostics).
3. (P2) `countPending` was exported as public `query` despite the JSDoc
stating CLI-only. Switched to `internalQuery` so authenticated clients
can't trigger a full table scan. Intent now matches export.
Cleanup that fell out of (1):
- Removed `alreadySet` counter (filter excludes already-patched rows by
construction, so the counter is no longer meaningful).
- Simplified return shape: `{ read, patched, emptyEmail, done }`.
- `done = rows.length < limit` (we got fewer than we asked for ⇒ no more).
|
Addressed all three Greptile findings in 4dfa2ae: P1 #1 — P1 #2 — Empty-email rows bypassed the limit: Empty-email patches now count against P2 #3 — Cleanup that fell out of #1: removed the now-meaningless |
…1 PR #3429 round 4) Greptile flagged that removing the confidence>=0.9 skip unconditionally opens genuine current keyword=critical events to silent demotion: the LLM cache hit feeds capLlmUpgrade which is Math.min, so a stale/wrong cache entry saying 'info' demotes critical -> info with no safeguard. The retrospective case PR #3424 wanted to handle is already handled UPSTREAM in classifyByKeyword via classSource='keyword-historical- downgrade' (confidence 0.85, level=info), which still flows through this function and is caught by the L3 marker check. Items reaching enrichWithAiCache at confidence 0.9 are by construction keyword=critical matches where the keyword classifier saw NO marker - trust the keyword verdict for those. The L3 marker check still runs BEFORE this skip, so the keyword=info (no-match, confidence 0.3) + marker case - the brief 2026-04-26-1302 'Science history: melts down...' shape - still gets forced to info.
…3) (#3429) * fix(classifier): two-layer historical-retrospective downgrade (L1 + L3) Brief 2026-04-26-1302 surfaced a 40-year Chernobyl anniversary article from Live Science: "Science history: Chernobyl nuclear power plant melts down, bringing the world to the brink of disaster — April 26, 1986 - Live Science" Investigation showed two distinct failure modes that cooperate to ship retrospective/anniversary content as if it were a current crisis: L1 — enrichWithAiCache skipped the LLM cache result for keyword=critical items (`if (0.9 <= item.confidence) continue;`). Original intent was "trust keyword when confident", but for retrospective titles that trip a CRITICAL keyword (e.g. "meltdown"), the LLM is the only thing that could disambiguate context — and the gate locked it out. Removed. The U4 +2-tier cap already protects against UPWARD over-promotion; symmetric DOWNWARD demotion is unconditionally safer than keeping a suspect keyword classification (capLlmUpgrade is a Math.min so downgrades pass freely). Cache application also now covers the new 'keyword-historical-downgrade' classSource so LLM can confirm/override the heuristic. L3 — Two-layer historical-marker downgrade: L3a (classifier-side): classifyByKeyword now downgrades CRITICAL/HIGH keyword matches to info when the title contains a retrospective marker. Markers (cheap regex): - Prefix: ^(Science history|On this day|Today in|This day in| Throwback|Flashback) - Phrase: \b(N years/decades/months ago|after|later|anniversary| in memoriam|remembering|commemorat|retrospective)\b - Full date: "April 26, 1986" or ISO 1986-04-26 Standalone 4-digit year is intentionally NOT a marker (current-event headlines like "Russia warns of 2026 strike" would falsely trigger). Downgrade applies only to CRITICAL/HIGH; LOW/MEDIUM are left alone (don't clear brief thresholds anyway, over-aggression cost outweighs signal). Distinct source tag 'keyword-historical-downgrade' for telemetry. L3b (LLM-cache-side defense-in-depth): The Chernobyl headline ABOVE doesn't match any CRITICAL keyword in the keyword classifier ("meltdown" substring isn't present in "melts down" — there's a space). So L3a wouldn't catch it. But the LLM cache had promoted this title (or one with the same hash) to CRITICAL at some prior point, and enrichWithAiCache applied that cache hit. The new L3b guard re-runs hasHistoricalMarker AFTER capLlmUpgrade — if the LLM-promoted level is CRITICAL/HIGH AND the title has a marker, force info. Logs on every fire so operators can audit. Tests: - tests/news-classifier-historical-downgrade.test.mts — 23 cases for hasHistoricalMarker predicate matrix + classifyByKeyword integration. Includes regression guards: current-year mentions don't trigger, "history" alone doesn't trigger (only the prefix forms), LOW/MEDIUM not downgraded, current-event critical headlines unchanged. - tests/news-classifier-llm-historical-guard.test.mts — 9 cases covering the LLM-cache guard predicate behavior + behavioral semantics doc (CRITICAL+marker → info; HIGH+marker → info; MEDIUM+marker → unchanged; CRITICAL-no-marker → unchanged). Includes the EXACT brief 2026-04-26-1302 title as a test fixture. Type tightening: - ClassificationResult.source: 'keyword' | 'keyword-historical-downgrade' - ParsedItem.classSource: 'keyword' | 'keyword-historical-downgrade' | 'llm' 106/106 tests pass across the touched + parity-coupled surface; importance-score-parity preserved. * fix(classifier): narrow historical markers (P2 PR #3429 round 2) Reviewer found two false-positive vectors that suppressed real critical alerts: - "Today in Ukraine: Russian missile strikes Kyiv" → was downgraded to info because of bare "Today in" prefix. This is a current-event headline pattern, not a historical one. - "Missile launch reported on April 26, 2026" → was downgraded because any full-date pattern matched, regardless of year. Both vectors would have suppressed real missile/attack/invasion alerts before they reached the brief. Fixes: 1. Prefix regex narrowed: REMOVED: bare "Today in" / "This day in" (too broad — legitimate current-event uses). KEPT: "Science history:", "Throwback", "Flashback" (always retrospective). ADDED: "On this day in YYYY" (year required — prevents bare "On this day, Iran fires missile" from triggering). ADDED: "This day in history" (specific phrasing — distinct from bare "This day in"). 2. Full-date matching now extracts the year and only treats dates as retrospective when year < currentYear - 1 (i.e. 2+ years old). In 2026: - 2024 and earlier dates → retrospective marker fires. - 2025 (last year) → NOT a marker (could be current context, e.g. "court ruling on April 15, 2025 takes effect"). - 2026 (current) → NOT a marker. - 2027+ (future) → NOT a marker (clock skew or scheduled events). Same logic applies to ISO date format. 3. hasHistoricalMarker(title, nowMs?) now takes optional nowMs for unit testability. Defaults to Date.now(); production callers omit. Tests: - 4 explicit reviewer-fix safety cases: * "Today in Ukraine: Russian missile strikes Kyiv" → high (preserved) * "Missile launch reported on April 26, 2026" → high (preserved) * "Today in tech: Apple unveils iPhone" → no marker * "On this day, Iran invasion begins" (no year) → no marker - 4 full-date boundary cases (1986, 2024, 2025, 2026, 2027) verify the year-based gating. - All test calls pass NOW = 2026-04-15 UTC to pin "current year" deterministically. 120/120 tests pass; importance-score-parity preserved. * fix(classifier): run historical-marker guard before capLlmUpgrade (P1 PR #3429 round 3) Previously the L3 defense-in-depth marker check ran on cappedLevel and only fired for critical/high. For keyword=info + hit=critical, capLlm- Upgrade demotes to medium (info+2=medium) — the post-cap check missed it and the brief 2026-04-26-1302 Chernobyl case shipped at MEDIUM, which still ships in 'all'-sensitivity briefs. Move the check to run on the RAW hit before the cap and force info unconditionally (not just critical/high). Retrospective markers now suppress the LLM verdict at every non-info level. * fix(classifier): restore confidence>=0.9 skip for keyword=critical (P1 PR #3429 round 4) Greptile flagged that removing the confidence>=0.9 skip unconditionally opens genuine current keyword=critical events to silent demotion: the LLM cache hit feeds capLlmUpgrade which is Math.min, so a stale/wrong cache entry saying 'info' demotes critical -> info with no safeguard. The retrospective case PR #3424 wanted to handle is already handled UPSTREAM in classifyByKeyword via classSource='keyword-historical- downgrade' (confidence 0.85, level=info), which still flows through this function and is caught by the L3 marker check. Items reaching enrichWithAiCache at confidence 0.9 are by construction keyword=critical matches where the keyword classifier saw NO marker - trust the keyword verdict for those. The L3 marker check still runs BEFORE this skip, so the keyword=info (no-match, confidence 0.3) + marker case - the brief 2026-04-26-1302 'Science history: melts down...' shape - still gets forced to info.
Why
Pre-work for the PRO-launch broadcast to ~tens-of-thousands of waitlist users.
The audience-build dedup query is
registrations−emailSuppressions− paying customers (from thecustomersjoin, sincesubscriptionsis keyed onuserId, not email). Onmaintoday:registrations.normalizedEmailis lowercased + trimmed, indexedemailSuppressions.normalizedEmailis lowercased + trimmed, indexedcustomers.emailis raw (whatever Dodo sent us — case-preserved), not indexedResult: the dedup join either does a full
customersscan on every audience build, or — if we naively join onemail == normalizedEmail— silently drops paid users whose Dodo email had any case/whitespace divergence from their waitlist signup. Failure mode: paid users receive a "buy PRO!" launch email. That's the worst-case outcome of the whole broadcast effort.What
Mirrors the convention
registrationsandemailSuppressionsalready use.convex/schema.ts— addscustomers.normalizedEmail: v.optional(v.string())+by_normalized_emailindex. Optional so existing rows pass schema validation; comment explains the broadcast-dedup motivation and points at the backfill command.convex/payments/subscriptionHelpers.ts—handleSubscriptionActivenow derivesnormalizedEmailonce and writes it at both upsert paths (existing-customer patch + new-customer insert).convex/payments/backfillCustomerNormalizedEmail.ts(new) — idempotent paginatedinternalMutation(defaultbatchSize: 500) +countPendingdiagnosticquery. Skips rows that already have a non-empty value; handles empty-email rows by writing empty string. Pattern mirrorsseedProductPlans.ts.Verification
npx tsc --noEmit -p convex/tsconfig.json→ cleanv.optional→ no validation break for existing rows on deploynormalizedEmailfrom the sameemail.trim().toLowerCase()derivation as the existing convention sites (registerInterest.ts:60,emailSuppressions.ts:11)Operational sequence after merge
Convex deploys schema automatically via the Vercel hook. Then:
Out of scope
normalizedEmailfromv.optionalto required. Separate cleanup PR after backfill confirms 100% coverage.