chore(broadcast): backfill proLaunchWave stamps for canary-250 contacts#3453
Conversation
The 244 registrations who received yesterday's PRO-launch canary broadcast
need a wave stamp in Convex so future wave-export actions can exclude
them. Without this, the next wave-export would re-pick them and re-email.
Two pieces:
1. Schema: add `proLaunchWave?: v.string()` and
`proLaunchWaveAssignedAt?: v.number()` to `registrations`, plus a
`by_proLaunchWave` index for efficient unstamped-only scans at next
wave's pick time. Both fields optional so existing rows pass schema
validation.
2. One-shot internal action `backfillCanaryWaveStamps:backfillCanary250`:
- Pages Resend `GET /contacts?segment_id=<canary>` (cursor-based via
`after=<contact-id>`, max 100/page)
- Normalizes each email (`trim().toLowerCase()` — same convention
`registrations.normalizedEmail` uses)
- Calls internal mutation `_stampWaveByNormalizedEmail` to look up
and patch the matching registration
- Reports {fetched, stamped, alreadyStamped, notFound, failed}
- Idempotent — re-runs are no-ops on already-stamped rows
- Masks emails in logs (Convex dashboard is observable to project
viewers; raw waitlist addresses must never land in plaintext logs)
The full wave-export action that handles "pick N unstamped, stamp them,
push to fresh Resend segment" comes in the next PR — this PR just lays
the schema + the canary backfill so we don't accidentally re-email the
244 when the next wave runs.
Run after deploy:
npx convex run broadcast/backfillCanaryWaveStamps:backfillCanary250
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Greptile SummaryThis PR adds two optional schema fields ( Confidence Score: 4/5Safe to merge — no blocking issues; two P2 findings are worth addressing before this mutation pattern is reused in the follow-up wave-export PR. Only P2 findings present. The backfill is idempotent and scoped to internal usage. The silent-overwrite concern is important context for the follow-up PR but doesn't affect the current one-shot operation since no registrations are pre-stamped. convex/broadcast/backfillCanaryWaveStamps.ts — specifically the _stampWaveByNormalizedEmail mutation's overwrite semantics before it's promoted to a general-purpose wave primitive. Important Files Changed
Sequence DiagramsequenceDiagram
participant Operator
participant backfillCanary250 as backfillCanary250 (Action)
participant Resend as Resend API
participant stampMutation as _stampWaveByNormalizedEmail (Mutation)
participant DB as Convex DB (registrations)
Operator->>backfillCanary250: npx convex run ...
backfillCanary250->>Resend: GET /contacts?segment_id=canary-250&limit=100
Resend-->>backfillCanary250: { data: [...], has_more: true }
loop For each contact in page
backfillCanary250->>stampMutation: runMutation(normalizedEmail, waveLabel, assignedAt)
stampMutation->>DB: query by_normalized_email
DB-->>stampMutation: row (or null)
alt row not found
stampMutation-->>backfillCanary250: { result: notFound }
else already stamped with same label
stampMutation-->>backfillCanary250: { result: alreadyStamped }
else
stampMutation->>DB: patch proLaunchWave + proLaunchWaveAssignedAt
stampMutation-->>backfillCanary250: { result: stamped }
end
end
backfillCanary250->>Resend: GET /contacts?segment_id=canary-250&after=last_id
Resend-->>backfillCanary250: { data: [...], has_more: false }
Note over backfillCanary250: has_more=false, break
backfillCanary250-->>Operator: BackfillStats { fetched, stamped, alreadyStamped, notFound, failed }
Reviews (1): Last reviewed commit: "chore(broadcast): backfill proLaunchWave..." | Re-trigger Greptile |
| if (row.proLaunchWave === waveLabel) { | ||
| return { result: "alreadyStamped" as const }; | ||
| } | ||
| await ctx.db.patch(row._id, { | ||
| proLaunchWave: waveLabel, | ||
| proLaunchWaveAssignedAt: assignedAt, | ||
| }); | ||
| return { result: "stamped" as const }; | ||
| }, |
There was a problem hiding this comment.
Silent overwrite of a different existing wave label
_stampWaveByNormalizedEmail only short-circuits when row.proLaunchWave === waveLabel; if the row already carries a different wave label it silently overwrites it and returns "stamped", indistinguishable from a fresh stamp. For the current one-off canary backfill this is harmless (no rows are pre-stamped), but the future wave-export action described in the PR will reuse this mutation. A row that was accidentally stamped with "wave-2" would silently get clobbered to "canary-250" on a re-run, with no indication in stats. Adding an "alreadyStampedDifferentWave" result (or a guard that throws) would make the contract explicit before this mutation is promoted to the generic wave primitive.
| const normalizedEmail = (contact.email ?? "").trim().toLowerCase(); | ||
| if (!normalizedEmail) { | ||
| stats.failed++; | ||
| continue; | ||
| } |
There was a problem hiding this comment.
Empty-email contacts silently counted as
failed
Contacts with a missing or blank email field (e.g. a manually-added Resend entry with no address) hit stats.failed++ and continue. failed is semantically "stamp operation threw an error", so using it here inflates the error count with a structurally-invalid contact. A dedicated skipped counter (or a log line before continue) would make the post-run stats unambiguous and easier to reconcile against the expected 244.
P1: wrong Resend endpoint
The action built `GET /contacts?segment_id=...`. That URL exists but the
canonical per-segment listing endpoint is
`GET /segments/{segment_id}/contacts` (verified against Resend docs:
https://resend.com/docs/api-reference/segments/list-segment-contacts).
The wrong URL would have failed before stamping any rows, leaving the
244 canary contacts eligible for re-emailing in the next wave —
defeating the entire point of the backfill.
P1: lint guard flagged the per-contact catch
The `try/catch + console.error + stats.failed++` block in
`backfillCanary250` is intentional — per-contact stamp failures are
counted into `stats.failed` and surfaced in the action's return value
(the operator's visible surface for partial failures). Re-throwing
would abort the whole loop on the first failure and leave most
contacts unstamped. Convex auto-Sentry still captures the underlying
mutation throw inside the mutation itself, before it bubbles here as a
rejection.
Added `// sentry-coverage-ok:` marker INSIDE the catch body (the lint
guard checks the body, not surrounding lines) with a multi-line
rationale so the next reader doesn't undo the choice. Lint guard now
clean: 153 files --all, 2 files --diff.
P1: backfill stamp not used by current export path
The schema doc claimed "future wave exports filter on
proLaunchWave === undefined", but the EXISTING audienceExport.ts (the
only exporter that exists today) skipped only on
empty/suppressed/paid — meaning a re-run against pro-launch-main
would re-pick the canary 244 (and any future stamped wave) and the
next broadcast would dupe-email them.
Extended the existing exporter:
- Added `alreadyInPriorWaveSkipped: number` to ExportStats.
- Added a per-row check: `if (row.proLaunchWave) { stats.alreadyInPriorWaveSkipped++; continue; }`.
Sits AFTER suppressed/paid so the priority order is consistent
(auth/permanent suppressions first, then prior-wave history).
- Both dry-run and live-mode honor the skip — operators see the
count in the dry-run output before committing.
This makes the backfill load-bearing as advertised.
P1: stale convex codegen
Adding convex/broadcast/backfillCanaryWaveStamps.ts requires
regenerating convex/_generated/api.d.ts so the new module's
internal mutations/actions are reachable via internal.broadcast.*.
The pre-push gate runs the root + api typechecks but NOT
`tsc -p convex/tsconfig.json`, so the missing codegen slipped through.
Ran `npx convex codegen --typecheck=disable`; verified fix with
`npx tsc --noEmit -p convex/tsconfig.json` (silent / clean).
…#3456) * chore(pre-push): typecheck convex/ to catch stale _generated/api.d.ts PR #3453 review caught a missing-codegen slip: a new module under convex/broadcast/ was committed without re-running `npx convex codegen`, so convex/_generated/api.d.ts was stale. The pre-push gate ran `typecheck` (root) and `typecheck:api` but not `tsc -p convex/tsconfig.json`, so the stale-codegen import error ("Property 'backfillCanaryWaveStamps' does not exist on type 'internal.broadcast'") only surfaced in PR review. Adds `npx tsc --noEmit -p convex/tsconfig.json || exit 1` between the existing API typecheck and the CJS syntax check. Catches: - stale _generated/api.d.ts (forgotten codegen after adding a module) - drift between convex/schema.ts and code that reads it - any TS error inside convex/ that the root tsconfig's project references would otherwise miss * fixup: also add convex typecheck to CI typecheck.yml workflow PR #3456 review caught the gap: pre-push runs locally and can be bypassed (`git push --no-verify`, direct pushes to main, CI-only paths), so the convex typecheck addition was incomplete as a correctness gate. CI's typecheck.yml ran only `typecheck` (root) and `typecheck:api`, leaving stale `convex/_generated/api.d.ts` slippable through CI without a failure. Mirrors the pre-push step into the workflow: - run: npx tsc --noEmit -p convex/tsconfig.json Same step, same exit semantics. Now both layers (local pre-push + remote CI) catch stale codegen and any drift between `convex/schema.ts` and code that reads it.
…ts (koala73#3453) * chore(broadcast): backfill proLaunchWave stamps for canary-250 contacts The 244 registrations who received yesterday's PRO-launch canary broadcast need a wave stamp in Convex so future wave-export actions can exclude them. Without this, the next wave-export would re-pick them and re-email. Two pieces: 1. Schema: add `proLaunchWave?: v.string()` and `proLaunchWaveAssignedAt?: v.number()` to `registrations`, plus a `by_proLaunchWave` index for efficient unstamped-only scans at next wave's pick time. Both fields optional so existing rows pass schema validation. 2. One-shot internal action `backfillCanaryWaveStamps:backfillCanary250`: - Pages Resend `GET /contacts?segment_id=<canary>` (cursor-based via `after=<contact-id>`, max 100/page) - Normalizes each email (`trim().toLowerCase()` — same convention `registrations.normalizedEmail` uses) - Calls internal mutation `_stampWaveByNormalizedEmail` to look up and patch the matching registration - Reports {fetched, stamped, alreadyStamped, notFound, failed} - Idempotent — re-runs are no-ops on already-stamped rows - Masks emails in logs (Convex dashboard is observable to project viewers; raw waitlist addresses must never land in plaintext logs) The full wave-export action that handles "pick N unstamped, stamp them, push to fresh Resend segment" comes in the next PR — this PR just lays the schema + the canary backfill so we don't accidentally re-email the 244 when the next wave runs. Run after deploy: npx convex run broadcast/backfillCanaryWaveStamps:backfillCanary250 * fixup: address review on PR koala73#3453 — fix Resend URL + lint guard P1: wrong Resend endpoint The action built `GET /contacts?segment_id=...`. That URL exists but the canonical per-segment listing endpoint is `GET /segments/{segment_id}/contacts` (verified against Resend docs: https://resend.com/docs/api-reference/segments/list-segment-contacts). The wrong URL would have failed before stamping any rows, leaving the 244 canary contacts eligible for re-emailing in the next wave — defeating the entire point of the backfill. P1: lint guard flagged the per-contact catch The `try/catch + console.error + stats.failed++` block in `backfillCanary250` is intentional — per-contact stamp failures are counted into `stats.failed` and surfaced in the action's return value (the operator's visible surface for partial failures). Re-throwing would abort the whole loop on the first failure and leave most contacts unstamped. Convex auto-Sentry still captures the underlying mutation throw inside the mutation itself, before it bubbles here as a rejection. Added `// sentry-coverage-ok:` marker INSIDE the catch body (the lint guard checks the body, not surrounding lines) with a multi-line rationale so the next reader doesn't undo the choice. Lint guard now clean: 153 files --all, 2 files --diff. * fixup: address review on PR koala73#3453 — close the wave-skip + regen api.d.ts P1: backfill stamp not used by current export path The schema doc claimed "future wave exports filter on proLaunchWave === undefined", but the EXISTING audienceExport.ts (the only exporter that exists today) skipped only on empty/suppressed/paid — meaning a re-run against pro-launch-main would re-pick the canary 244 (and any future stamped wave) and the next broadcast would dupe-email them. Extended the existing exporter: - Added `alreadyInPriorWaveSkipped: number` to ExportStats. - Added a per-row check: `if (row.proLaunchWave) { stats.alreadyInPriorWaveSkipped++; continue; }`. Sits AFTER suppressed/paid so the priority order is consistent (auth/permanent suppressions first, then prior-wave history). - Both dry-run and live-mode honor the skip — operators see the count in the dry-run output before committing. This makes the backfill load-bearing as advertised. P1: stale convex codegen Adding convex/broadcast/backfillCanaryWaveStamps.ts requires regenerating convex/_generated/api.d.ts so the new module's internal mutations/actions are reachable via internal.broadcast.*. The pre-push gate runs the root + api typechecks but NOT `tsc -p convex/tsconfig.json`, so the missing codegen slipped through. Ran `npx convex codegen --typecheck=disable`; verified fix with `npx tsc --noEmit -p convex/tsconfig.json` (silent / clean).
…koala73#3456) * chore(pre-push): typecheck convex/ to catch stale _generated/api.d.ts PR koala73#3453 review caught a missing-codegen slip: a new module under convex/broadcast/ was committed without re-running `npx convex codegen`, so convex/_generated/api.d.ts was stale. The pre-push gate ran `typecheck` (root) and `typecheck:api` but not `tsc -p convex/tsconfig.json`, so the stale-codegen import error ("Property 'backfillCanaryWaveStamps' does not exist on type 'internal.broadcast'") only surfaced in PR review. Adds `npx tsc --noEmit -p convex/tsconfig.json || exit 1` between the existing API typecheck and the CJS syntax check. Catches: - stale _generated/api.d.ts (forgotten codegen after adding a module) - drift between convex/schema.ts and code that reads it - any TS error inside convex/ that the root tsconfig's project references would otherwise miss * fixup: also add convex typecheck to CI typecheck.yml workflow PR koala73#3456 review caught the gap: pre-push runs locally and can be bypassed (`git push --no-verify`, direct pushes to main, CI-only paths), so the convex typecheck addition was incomplete as a correctness gate. CI's typecheck.yml ran only `typecheck` (root) and `typecheck:api`, leaving stale `convex/_generated/api.d.ts` slippable through CI without a failure. Mirrors the pre-push step into the workflow: - run: npx tsc --noEmit -p convex/tsconfig.json Same step, same exit semantics. Now both layers (local pre-push + remote CI) catch stale codegen and any drift between `convex/schema.ts` and code that reads it.
Summary
The 244 registrations who received yesterday's PRO-launch canary broadcast need a wave stamp in Convex so future wave-export actions can exclude them. Without this stamp, the next wave-export would re-pick them and re-email — exactly the duplication the wave system exists to prevent.
This PR lays the schema + the one-shot backfill. The full wave-export action (pick N unstamped, stamp them, push to a fresh Resend segment per wave) comes in a follow-up PR.
Why this design
Resend's API doesn't support broadcast subset/sample/exclude (verified via docs):
POST /broadcastsacceptssegment_idonly — no inclusion/exclusion filters.POST /segmentsacceptsnameonly — segments are membership lists, not query-defined.properties(custom k/v) but no documented query API.So progressive waves require tracking membership somewhere. Convex is the right source of truth for us — it's already where dedup math runs, and it lets us scan unstamped registrations efficiently.
Changes
convex/schema.ts—registrationstableTwo new optional fields + one index:
proLaunchWave?: v.optional(v.string())— wave label (e.g."canary-250","wave-2")proLaunchWaveAssignedAt?: v.optional(v.number())— ms timestamp, mostly for audit.index("by_proLaunchWave", ["proLaunchWave"])— so the next-wave pick can scan only-unstamped efficiently against tens of thousands of registrationsBoth fields optional so existing rows pass schema validation.
convex/broadcast/backfillCanaryWaveStamps.ts(new)Two exports:
_stampWaveByNormalizedEmail— internal mutation that looks upregistrationsbynormalizedEmail, patches the wave fields if not already stamped. Idempotent on the wave label.backfillCanary250— internal action that pages ResendGET /contacts?segment_id=<canary>(cursor pagination viaafter=<contact-id>, 100/page max) and stamps each contact's matching registration.Hard-coded to the canary-250 segment ID and the
sent_attimestamp from the broadcast — this is a one-off historical backfill, not a generic wave-stamp tool.PII safety: emails masked in logs (
jo******@example.comshape) — Convex dashboard is observable to project viewers, raw waitlist addresses must not land in plaintext.Test plan
npx convex run broadcast/backfillCanaryWaveStamps:backfillCanary250{ fetched: 244, stamped: ~244, alreadyStamped: 0, notFound: <small>, failed: 0 }notFoundmay be non-zero if test addresses or manual additions ended up in the canary segment without a matchingregistrationsrow — log inspection (masked) to confirm those are expected.alreadyStampedcount = previousstampedcount,stamped: 0. Idempotency verified.registrationsbyproLaunchWave = "canary-250"— count should matchstamped + alreadyStamped.What comes next (separate PR)
convex/broadcast/audienceWaveExport.ts:assignAndExportWave({ waveLabel, count })that:countregistrations whereproLaunchWave IS NULLAND not in suppressions/customers, random sample.waveLabel.segmentIdfor the existingcreateProLaunchBroadcastflow.That action is the sustainable per-wave primitive — one CLI command per send, no manual dashboard work, no re-emailing previous waves.